Skip to main content

第 1 周:向量和坐标基础

📅 学习计划

本周目标: 理解向量、向量运算和 3D 坐标系,并能在 Three.js 中应用。

时间安排: 每天 1-2 小时,共 5 天


第 1 天:理解向量

📖 理论:什么是向量?

向量(Vector) 是有方向和大小的量。

向量 = 方向 + 大小(长度)

2D 向量示例:

(3, 4) 表示:
- 坐标点:x=3, y=4
- 方向:从原点(0,0)指向(3,4)
- 大小:√(3² + 4²) = 5

3D 向量示例:

(1, 2, 3) 表示:
- X 方向:1
- Y 方向:2
- Z 方向:3

💻 实践:Three.js 中的向量

Three.js 提供了 Vector2, Vector3, Vector4 类。

import * as THREE from "three";

// 创建 3D 向量
const v1 = new THREE.Vector3(1, 2, 3);

// 获取分量
console.log(v1.x, v1.y, v1.z); // 1 2 3

// 设置分量
v1.set(4, 5, 6);
console.log(v1); // Vector3 { x: 4, y: 5, z: 6 }

💻 实践:项目中的向量使用

查看项目代码: LineRender.ts:278-283

// 创建 2D 向量,表示线上的点
const lineVector = new THREE.Vector2(points[0].x, points[0].y);
// 创建 2D 向量,表示车的位置
const carVector = new THREE.Vector2(carPosition.x, carPosition.y);

// 计算两点之间的距离(向量长度)
const distance = carVector.distanceTo(lineVector);

理解:

  • 项目中常用 Vector2 处理地图平面上的计算(忽略高度)。
  • distanceTo() 实际上是计算 (v1 - v2).length()

🎯 练习 1.1:创建和操作向量

在控制台或简单 HTML 中运行:

import * as THREE from "three";

// 1. 创建两个点
const p1 = new THREE.Vector3(1, 1, 1);
const p2 = new THREE.Vector3(4, 5, 1);

// 2. 计算距离
const d = p1.distanceTo(p2);
console.log("距离:", d); // 应该是 5

// 3. 向量克隆(重要!Three.js 中很多操作会修改原向量)
const p3 = p1.clone();
p3.x = 10;
console.log(p1.x); // 1 (原向量不变)

📝 今日任务

  • 理解向量的概念(方向+大小)
  • 熟悉 Three.js Vector3 的基本 API
  • 明白 clone() 的重要性

第 2 天:向量运算

📖 理论:向量基本运算

1. 向量加减法

  • 加法v1 + v2,几何上是首尾相连。用于位置叠加。
  • 减法v1 - v2,几何上是指向 v1 的向量(从 v2 出发)。用于计算方向。

示例:位移 / 速度更新(从当前位置“走一步”)**

import * as THREE from "three";

const pos = new THREE.Vector3(0, 0, 0); // 当前位置
const velocity = new THREE.Vector3(2, 0, -1); // 速度:每秒移动 (2, 0, -1)
const dt = 1 / 60; // 60fps 的一帧

// pos(t+dt) = pos(t) + velocity * dt
pos.addScaledVector(velocity, dt);
console.log("next pos:", pos);

// 减法常见用途:用目标位置 - 当前位置,得到“指向目标”的位移/方向
const targetPos = new THREE.Vector3(5, 0, -3);
const toTarget = new THREE.Vector3().subVectors(targetPos, pos); // 位移向量(还没归一化)
const dirToTarget = toTarget.clone().normalize(); // 方向向量(长度=1)

console.log("toTarget(delta):", toTarget, "dir:", dirToTarget);

2. 向量点积(Dot Product)

  • 公式:a · b = |a||b|cosθ
  • 结果是一个标量(数字)。
  • 用途:判断方向(前/后)、计算夹角、投影。
  • 如果点积 > 0,夹角 < 90°(同向)。
  • 如果点积 = 0,夹角 = 90°(垂直)。
  • 如果点积 < 0,夹角 > 90°(反向)。
  • 交换律a.dot(b) = b.dot(a)(可以反过来写,结果相同)。

示例:视野/FOV 判断(目标是否在前方锥体内)**

import * as THREE from "three";

function isInFrontCone(observerPos, observerForward, targetPos, fovDeg = 60) {
const forward = observerForward.clone().normalize();
const toTarget = new THREE.Vector3()
.subVectors(targetPos, observerPos)
.normalize();

const cosHalfFov = Math.cos(THREE.MathUtils.degToRad(fovDeg * 0.5));
return forward.dot(toTarget) >= cosHalfFov; // dot = cos(theta)
}

const observerPos = new THREE.Vector3(0, 0, 0);
const observerForward = new THREE.Vector3(0, 0, -1);
const targetPos = new THREE.Vector3(2, 0, -5);

console.log(
"in FOV:",
isInFrontCone(observerPos, observerForward, targetPos, 90),
);

3. 向量叉积(Cross Product)

  • 结果是一个向量,垂直于前两个向量构成的平面。
  • 用途:计算法线、判断左/右。

示例:求三角形面法线(光照/正反面/碰撞都用得到)**

import * as THREE from "three";

const p0 = new THREE.Vector3(0, 0, 0);
const p1 = new THREE.Vector3(1, 0, 0);
const p2 = new THREE.Vector3(0, 1, 0);

const e1 = new THREE.Vector3().subVectors(p1, p0);
const e2 = new THREE.Vector3().subVectors(p2, p0);

// 注意顺序:e1 × e2 和 e2 × e1 方向相反(右手定则)
const normal = new THREE.Vector3().crossVectors(e1, e2).normalize();
console.log("face normal:", normal); // 期望接近 (0, 0, 1)

示例:判断目标在“左边还是右边”(俯视 XZ 平面)**

import * as THREE from "three";

// 以地面为 XZ 平面(Y 向上),常见于角色移动/导航
const playerPos = new THREE.Vector3(0, 0, 0);
const playerForward = new THREE.Vector3(0, 0, -1); // 面向 -Z

const targetPos = new THREE.Vector3(2, 0, -5);
const toTarget = new THREE.Vector3().subVectors(targetPos, playerPos);

// cross = forward × toTarget,观察 cross.y 的符号即可判断左右(因为垂直于 XZ 平面的就是 Y 轴)
const cross = new THREE.Vector3().crossVectors(playerForward, toTarget);
if (cross.y > 0) console.log("目标在左侧(需要向左转)");
else if (cross.y < 0) console.log("目标在右侧(需要向右转)");
else console.log("正前/正后(共线)");

示例:求“把 a 转到 b”所需的旋转轴(轴-角 / 四元数常用)**

import * as THREE from "three";

const a = new THREE.Vector3(0, 0, -1).normalize(); // 当前朝向
const b = new THREE.Vector3(1, 0, -1).normalize(); // 目标朝向

// 旋转轴方向:axis = a × b(若 a 与 b 平行/反平行,叉积会接近 0)
const axis = new THREE.Vector3().crossVectors(a, b);
const axisLen = axis.length();

if (axisLen < 1e-8) {
console.log("a 与 b 平行或反平行:旋转轴不稳定,需要特殊处理");
} else {
axis.normalize();
const angle = Math.acos(THREE.MathUtils.clamp(a.dot(b), -1, 1)); // 角度用点积算

const q = new THREE.Quaternion().setFromAxisAngle(axis, angle);
const aRotated = a.clone().applyQuaternion(q);
console.log("axis:", axis, "angle(rad):", angle, "rotated:", aRotated);
}

4. 向量归一化(Normalize)

  • 将向量长度变为 1,保持方向不变。
  • 用于只关心方向的场景(如光照计算)。

计算原理

  1. 计算原向量的长度(模长):$|\vec{v}| = \sqrt{v_x^2 + v_y^2 + v_z^2}$
  2. 将每个分量除以这个长度:$\vec{v_{normalized}} = \frac{\vec{v}}{|\vec{v}|} = \left(\frac{v_x}{|\vec{v}|}, \frac{v_y}{|\vec{v}|}, \frac{v_z}{|\vec{v}|}\right)$

示例

  • (3, 0, 0) → 长度 = 3 → 归一化 = (3/3, 0/3, 0/3) = (1, 0, 0)
  • (3, 4, 0) → 长度 = 5 → 归一化 = (3/5, 4/5, 0) = (0.6, 0.8, 0)

示例:只关心“方向”,不让长度影响结果(例如光照/夹角判断)**

import * as THREE from "three";

const dir = new THREE.Vector3(10, 0, -2);
console.log("before:", dir.length(), dir.clone());

dir.normalize(); // 长度变成 1,方向不变
console.log("after:", dir.length(), dir.clone());

示例:做夹角/FOV 判断时,必须先归一化(否则 dot/acos 会失真)**

import * as THREE from "three";

const forward = new THREE.Vector3(0, 0, -1); // 已经是单位向量
const toTargetRaw = new THREE.Vector3(20, 0, -1); // 很长的“位移”,方向几乎朝 -Z

// ❌ 未归一化:dot 会被长度放大,甚至不在 [-1,1](用 acos 会出 NaN)
const dotRaw = forward.dot(toTargetRaw);
console.log("dotRaw:", dotRaw, "acosRaw:", Math.acos(dotRaw)); // acosRaw 往往是 NaN

// ✅ 归一化后:dot = cos(theta),稳定在 [-1,1],可用于阈值判断/求角度
const toTarget = toTargetRaw.clone().normalize();
const dot = forward.dot(toTarget);
const angleDeg = THREE.MathUtils.radToDeg(Math.acos(THREE.MathUtils.clamp(dot, -1, 1)));
console.log("dot:", dot, "angleDeg:", angleDeg);

// 例如 60° 视野锥:dot >= cos(30°) 才算在视野内
const inFov = dot >= Math.cos(THREE.MathUtils.degToRad(30));
console.log("inFov:", inFov);

💻 实践:Three.js 中的向量运算

const v1 = new THREE.Vector3(1, 0, 0);
const v2 = new THREE.Vector3(0, 1, 0);

// 加法
const sum = v1.clone().add(v2); // (1, 1, 0)

// 点积
const dot = v1.dot(v2); // 0 (垂直)

// 叉积
const cross = v1.clone().cross(v2); // (0, 0, 1) Z轴方向

// 归一化
const v3 = new THREE.Vector3(3, 0, 0);
v3.normalize(); // (1, 0, 0)

🎯 练习 1.2:向量运算实战

场景:判断敌人是在玩家前方还是后方。

import * as THREE from "three";

const playerPos = new THREE.Vector3(0, 0, 0);
const playerDir = new THREE.Vector3(0, 0, -1); // 玩家面向 -Z

const enemyPos = new THREE.Vector3(2, 0, -5); // 敌人在 (-5) 处

// 1. 计算玩家到敌人的方向向量
const toEnemy = new THREE.Vector3().subVectors(enemyPos, playerPos).normalize();

// 2. 计算点积
const dot = playerDir.dot(toEnemy);

// 3. 判断
if (dot > 0) {
console.log("敌人在前方");
} else {
console.log("敌人在后方");
}

📝 今日任务

  • 理解向量加减法的几何意义
  • 掌握点积判断方向的方法
  • 掌握归一化的作用

第 3 天:3D 坐标系

📖 理论:3D 坐标系与空间

坐标系类型:

  1. 模型空间(Model/Local Space)

    • 物体自己的坐标系。
    • 原点是物体的中心(或轴心)。
    • 例子:人的鼻子在头的正前方。
  2. 世界空间(World Space)

    • 整个场景的全局坐标系。
    • 所有物体都在这个空间里摆放。
    • 例子:人在房间的角落里。
  3. 视图空间(View/Camera Space)

    • 以相机为原点的坐标系。
    • Z 轴通常指向相机后方。
    • 例子:从你的眼睛看,物体在你的左边还是右边。
  4. 裁剪空间(Clip Space)

    • 经过投影变换后的空间。
    • 坐标范围通常是 [-1, 1]
  5. 屏幕空间(Screen Space)

    • 最终显示的 2D 像素坐标。
    • 范围是 (0, 0) 到 (width, height)。

💻 实践:Three.js 中的坐标转换

Three.js 自动处理大部分转换,但有时需要手动转换。

const object = new THREE.Mesh(geometry, material);
scene.add(object);

// 本地坐标(模型空间)
object.position.set(1, 0, 0);

// 获取世界坐标
const worldPos = new THREE.Vector3();
object.getWorldPosition(worldPos);

// 世界坐标转屏幕坐标
const screenPos = worldPos.clone().project(camera);
// ⚠️ 注意:project() 返回的是裁剪空间坐标(NDC),不是最终屏幕坐标
// screenPos.x, .y, .z 都在 [-1, 1] 之间
// -1 表示屏幕左/下边缘,1 表示屏幕右/上边缘

// 转换为实际屏幕像素坐标
const x = (screenPos.x * 0.5 + 0.5) * canvas.width; // [-1,1] → [0, width]
const y = (screenPos.y * -0.5 + 0.5) * canvas.height; // [-1,1] → [0, height],注意 Y 轴翻转

// project() 做了什么:
// 1. 世界空间 → 视图空间(以相机为原点)
// 2. 视图空间 → 裁剪空间(应用投影矩阵,得到 NDC 坐标)

💻 实践:项目中的坐标变换

查看项目代码: LineRender.ts:289-296

transform(origionPosition: { x: number; y: number }) {
// correctDZPosition 是一个自定义函数,处理坐标转换
// 这里将原始坐标转换为 Three.js 场景中的坐标
const { position, rotation } = correctDZPosition(
{ x: origionPosition.x, y: origionPosition.y },
this.scene_.userData.carMatrix, // 变换矩阵
);
return { position, rotation };
}

🎯 练习 1.3:坐标系理解

想象一个层级结构: SolarSystem -> Earth -> Moon

  • Moon 的本地坐标是相对于 Earth 的。
  • Moon 的世界坐标是相对于 SolarSystem 原点的。

如果 Earth 移动了,Moon 的世界坐标会变,但本地坐标不变。

📝 今日任务

  • 理解 5 种坐标空间的区别
  • 理解父子层级对坐标的影响
  • 查看项目中的 transform 函数

第 4-5 天:综合练习

🎯 项目实战:LineRender 源码分析

任务: 深入分析 LineRender.ts 中向量和坐标的使用。

  1. 寻找向量创建

    // LineRender.ts
    // 看看哪里用到了 new THREE.Vector2() 或 new THREE.Vector3()
    // 思考它们代表什么(点?方向?速度?)
  2. 分析距离检测逻辑

    // LineRender.ts:277
    checkContour(points: PointType[], carPosition: Point2) {
    const lineVector = new THREE.Vector2(points[0].x, points[0].y);
    const carVector = new THREE.Vector2(carPosition.x, carPosition.y);
    const distance = carVector.distanceTo(lineVector);
    return distance > 20;
    }

    思考:这里为什么用 Vector2 而不是 Vector3?(因为只关心平面距离,忽略高度差)

  3. 分析数据转换

    // LineRender.ts:503
    // 将对象数据转换为扁平数组,传给 Shader
    pA.map((p) => [
    p.x,
    p.y,
    p.z || 0, // 位置
    p.color.r,
    p.color.g,
    p.color.b,
    p.color.a, // 颜色
    ]);

    ⚠️ 性能优化思考

    • 问题:这种 JS 层面的数据转换(对象 → 扁平数组)会有性能开销,特别是数据量大时。
    • 为什么需要转换:WebGL Shader 只能接收 TypedArray(如 Float32Array),不能直接使用 JS 对象。
    • 优化建议
      1. 数据源优化:如果可能,从后端/数据源直接返回扁平数组格式,避免前端转换。
      2. 使用 TypedArray:转换时直接生成 Float32Array 而不是普通数组,减少内存分配。
      3. 增量更新:只更新变化的部分,而不是每次都全量重建。
      4. 对象池:复用数组对象,减少 GC 压力。
      5. Web Workers:如果数据量极大,考虑在 Worker 线程中进行转换。

📝 本周总结

  • 我能用 Three.js 创建向量并进行加减运算。
  • 我理解点积可以用来判断方向。
  • 我明白模型空间和世界空间的区别。
  • 我能在项目中找到向量和坐标变换的实际应用代码。

下一步: 第 2 周:矩阵和坐标变换