第 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,保持方向不变。
- 用于只关心方向的场景(如光照计算)。
计算原理:
- 计算原向量的长度(模长):$|\vec{v}| = \sqrt{v_x^2 + v_y^2 + v_z^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 坐标系与空间
坐标系类型:
模型空间(Model/Local Space)
- 物体自己的坐标系。
- 原点是物体的中心(或轴心)。
- 例子:人的鼻子在头的正前方。
世界空间(World Space)
- 整个场景的全局坐标系。
- 所有物体都在这个空间里摆放。
- 例子:人在房间的角落里。
视图空间(View/Camera Space)
- 以相机为原点的坐标系。
- Z 轴通常指向相机后方。
- 例子:从你的眼睛看,物体在你的左边还是右边。
裁剪空间(Clip Space)
- 经过投影变换后的空间。
- 坐标范围通常是 [-1, 1]。
屏幕空间(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 中向量和坐标的使用。
寻找向量创建
// LineRender.ts
// 看看哪里用到了 new THREE.Vector2() 或 new THREE.Vector3()
// 思考它们代表什么(点?方向?速度?)分析距离检测逻辑
// 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?(因为只关心平面距离,忽略高度差)分析数据转换
// 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 对象。 - 优化建议:
- 数据源优化:如果可能,从后端/数据源直接返回扁平数组格式,避免前端转换。
- 使用 TypedArray:转换时直接生成
Float32Array而不是普通数组,减少内存分配。 - 增量更新:只更新变化的部分,而不是每次都全量重建。
- 对象池:复用数组对象,减少 GC 压力。
- Web Workers:如果数据量极大,考虑在 Worker 线程中进行转换。
📝 本周总结
- 我能用 Three.js 创建向量并进行加减运算。
- 我理解点积可以用来判断方向。
- 我明白模型空间和世界空间的区别。
- 我能在项目中找到向量和坐标变换的实际应用代码。
下一步: 第 2 周:矩阵和坐标变换