Appearance
向量在图形学中的应用
前面我们学习了向量的基础运算:加减、数乘、长度、点积、叉积。现在让我们看看这些运算在实际 3D 图形学中的应用。
这一章将展示三个核心应用场景:
- 光照计算 - 如何用向量计算物体表面的明暗
- 运动模拟 - 如何用向量表示物体的位置和运动
- 几何判断 - 如何用向量判断点与平面的关系
应用一:光照计算
Lambert 漫反射模型
想象一个场景:一个平面在阳光下,哪些部分会更亮?
答案是:垂直于光线方向的部分最亮,平行于光线方向的部分最暗。
这就是 Lambert 漫反射模型,它是 3D 图形学中最基础的光照模型。
数学原理
Lambert 模型的核心公式:
$$ I = I_0 \cdot \max(0, \mathbf{N} \cdot \mathbf{L}) $$
其中:
- $I$ 是最终光照强度
- $I_0$ 是光源强度
- $\mathbf{N}$ 是表面法向量(垂直于表面的单位向量)
- $\mathbf{L}$ 是光线方向(从表面指向光源的单位向量)
- $\mathbf{N} \cdot \mathbf{L}$ 是点积
为什么用点积?回顾第4章:
$$ \mathbf{N} \cdot \mathbf{L} = |\mathbf{N}| \cdot |\mathbf{L}| \cdot \cos\theta = \cos\theta $$
(因为 $\mathbf{N}$ 和 $\mathbf{L}$ 都是单位向量,长度为 1)
余弦值的含义:
- $\cos(0°) = 1$:光线垂直照射,最亮
- $\cos(60°) = 0.5$:光线斜照,亮度减半
- $\cos(90°) = 0$:光线平行于表面,无光照
- $\cos(120°) = -0.5$:光线从背面照射,无光照(用
max(0, ...)截断)
代码实现
javascript
function calculateDiffuse(normal, lightDir, lightIntensity) {
// 确保法向量和光照方向都是单位向量
const N = normal.normalize();
const L = lightDir.normalize();
// 计算点积
const nDotL = N.dot(L);
// 截断负值(背面光照)
const diffuse = Math.max(0, nDotL);
return diffuse * lightIntensity;
}
// 使用示例
const surfaceNormal = new Vector3(0, 1, 0); // 平面朝上
const lightDirection = new Vector3(0, 1, 0); // 光从上方照下来
const intensity = 1.0;
const brightness = calculateDiffuse(surfaceNormal, lightDirection, intensity);
console.log(brightness); // 1.0 - 最亮
// 斜照
const slantLight = new Vector3(1, 1, 0);
const brightness2 = calculateDiffuse(surfaceNormal, slantLight, intensity);
console.log(brightness2.toFixed(2)); // 0.71 - 亮度减弱带颜色的光照
实际应用中,光源有颜色,物体表面也有颜色:
javascript
class Color {
constructor(r, g, b) {
this.r = r;
this.g = g;
this.b = b;
}
multiplyScalar(k) {
return new Color(this.r * k, this.g * k, this.b * k);
}
multiply(color) {
return new Color(this.r * color.r, this.g * color.g, this.b * color.b);
}
}
function calculateLighting(normal, lightDir, lightColor, surfaceColor) {
const N = normal.normalize();
const L = lightDir.normalize();
const nDotL = Math.max(0, N.dot(L));
// 光源颜色 * 表面颜色 * 光照强度
return lightColor.multiply(surfaceColor).multiplyScalar(nDotL);
}
// 红色表面,白光照射
const surface = new Color(1, 0, 0); // 红色
const light = new Color(1, 1, 1); // 白色光
const normal = new Vector3(0, 1, 0);
const lightDir = new Vector3(0, 1, 0);
const finalColor = calculateLighting(normal, lightDir, light, surface);
console.log(finalColor); // Color(1, 0, 0) - 红色多光源
真实场景中通常有多个光源,只需累加每个光源的贡献:
javascript
function calculateMultipleLights(normal, surfaceColor, lights) {
let totalColor = new Color(0, 0, 0);
for (const light of lights) {
const contribution = calculateLighting(
normal,
light.direction,
light.color,
surfaceColor
);
totalColor.r += contribution.r;
totalColor.g += contribution.g;
totalColor.b += contribution.b;
}
return totalColor;
}
// 两个光源
const lights = [
{ direction: new Vector3(0, 1, 0), color: new Color(1, 1, 1) }, // 顶光
{ direction: new Vector3(1, 0, 0), color: new Color(0.3, 0.3, 0.5) } // 侧光(蓝色)
];
const finalColor = calculateMultipleLights(normal, surface, lights);应用二:运动模拟
位置、速度、加速度
在物理模拟中,向量的三个核心概念:
- 位置(Position):物体在空间中的坐标 $\mathbf{p}$
- 速度(Velocity):位置的变化率 $\mathbf{v}$(单位:米/秒)
- 加速度(Acceleration):速度的变化率 $\mathbf{a}$(单位:米/秒²)
它们之间的关系:
$$ \mathbf{v}_{new} = \mathbf{v} + \mathbf{a} \cdot \Delta t $$
$$ \mathbf{p}_{new} = \mathbf{p} + \mathbf{v} \cdot \Delta t $$
其中 $\Delta t$ 是时间步长(通常是 1/60 秒,即 60 FPS)。
代码实现:简单的物理对象
javascript
class PhysicsObject {
constructor(position, velocity, acceleration) {
this.position = position; // 位置向量
this.velocity = velocity; // 速度向量
this.acceleration = acceleration; // 加速度向量
}
update(deltaTime) {
// 更新速度:v = v + a * dt
this.velocity = this.velocity.add(
this.acceleration.multiplyScalar(deltaTime)
);
// 更新位置:p = p + v * dt
this.position = this.position.add(
this.velocity.multiplyScalar(deltaTime)
);
}
}示例:自由落体
物体在重力作用下的运动:
javascript
// 创建物体:初始位置 (0, 10, 0),初始速度为 0
const ball = new PhysicsObject(
new Vector3(0, 10, 0), // position
new Vector3(0, 0, 0), // velocity
new Vector3(0, -9.8, 0) // acceleration (重力)
);
// 模拟 1 秒(60 帧)
for (let i = 0; i < 60; i++) {
ball.update(1 / 60); // deltaTime = 0.0167 秒
console.log(`第 ${i+1} 帧: y = ${ball.position.y.toFixed(2)}`);
}
// 输出:y 逐渐减小,物体下落示例:抛物线运动
斜抛物体的轨迹:
javascript
const projectile = new PhysicsObject(
new Vector3(0, 0, 0), // 从原点发射
new Vector3(10, 15, 0), // 初速度(向右上方)
new Vector3(0, -9.8, 0) // 重力
);
const trajectory = [];
for (let i = 0; i < 120; i++) {
projectile.update(1 / 60);
trajectory.push({ x: projectile.position.x, y: projectile.position.y });
if (projectile.position.y < 0) {
console.log('物体落地');
break;
}
}
console.log(`飞行距离:${projectile.position.x.toFixed(2)} 米`);示例:恒速运动
让物体沿指定方向匀速运动:
javascript
function moveTowards(current, target, speed, deltaTime) {
// 计算方向向量
const direction = target.sub(current).normalize();
// 计算位移
const displacement = direction.multiplyScalar(speed * deltaTime);
// 更新位置
return current.add(displacement);
}
// 使用示例:物体从 (0,0,0) 移动到 (10,0,0),速度 5 米/秒
let position = new Vector3(0, 0, 0);
const target = new Vector3(10, 0, 0);
const speed = 5;
for (let frame = 0; frame < 120; frame++) {
position = moveTowards(position, target, speed, 1/60);
// 检查是否到达
if (position.sub(target).length() < 0.1) {
console.log('到达目标');
break;
}
}应用三:平面与距离
平面的表示
一个平面可以用以下信息定义:
- 法向量 $\mathbf{n}$:垂直于平面的单位向量
- 平面上的一个点 $\mathbf{p_0}$
数学表达式:
$$ \mathbf{n} \cdot (\mathbf{p} - \mathbf{p_0}) = 0 $$
意思是:平面上的任意点 $\mathbf{p}$ 到 $\mathbf{p_0}$ 的向量都垂直于法向量。
点到平面的距离
点 $\mathbf{p}$ 到平面的有向距离(Signed Distance):
$$ d = \mathbf{n} \cdot (\mathbf{p} - \mathbf{p_0}) $$
- $d > 0$:点在平面的正面(法向量指向的一侧)
- $d = 0$:点在平面上
- $d < 0$:点在平面的背面
绝对距离:$|d|$
代码实现
javascript
class Plane {
constructor(normal, point) {
this.normal = normal.normalize(); // 法向量(单位向量)
this.point = point; // 平面上的一个点
}
distanceToPoint(p) {
// 有向距离:n · (p - p0)
return this.normal.dot(p.sub(this.point));
}
isPointInFront(p) {
return this.distanceToPoint(p) > 0;
}
}
// 使用示例:水平面 y = 0
const groundPlane = new Plane(
new Vector3(0, 1, 0), // 法向量朝上
new Vector3(0, 0, 0) // 平面上的一个点
);
const pointA = new Vector3(0, 5, 0);
console.log(groundPlane.distanceToPoint(pointA)); // 5 - 在上方
const pointB = new Vector3(0, -3, 0);
console.log(groundPlane.distanceToPoint(pointB)); // -3 - 在下方应用:点投影到平面
将点 $\mathbf{p}$ 投影到平面上:
$$ \mathbf{p}_{proj} = \mathbf{p} - d \cdot \mathbf{n} $$
其中 $d$ 是点到平面的有向距离。
javascript
projectPoint(p) {
const d = this.distanceToPoint(p);
return p.sub(this.normal.multiplyScalar(d));
}
// 使用示例
const point = new Vector3(3, 5, 2);
const projected = groundPlane.projectPoint(point);
console.log(projected.toString()); // Vector3(3, 0, 2) - y 变为 0应用:射线与平面相交
射线 $\mathbf{r}(t) = \mathbf{o} + t \cdot \mathbf{d}$ 与平面的交点:
$$ t = \frac{(\mathbf{p_0} - \mathbf{o}) \cdot \mathbf{n}}{\mathbf{d} \cdot \mathbf{n}} $$
javascript
function rayPlaneIntersection(rayOrigin, rayDirection, plane) {
const dDotN = rayDirection.dot(plane.normal);
// 射线平行于平面
if (Math.abs(dDotN) < 0.0001) {
return null;
}
const t = plane.point.sub(rayOrigin).dot(plane.normal) / dDotN;
// 交点在射线起点之前
if (t < 0) {
return null;
}
// 计算交点
return rayOrigin.add(rayDirection.multiplyScalar(t));
}
// 使用示例
const rayOrigin = new Vector3(0, 10, 0);
const rayDirection = new Vector3(0, -1, 0); // 向下射线
const intersection = rayPlaneIntersection(rayOrigin, rayDirection, groundPlane);
console.log(intersection.toString()); // Vector3(0, 0, 0) - 击中地面综合示例:带光照和物理的场景
最后,让我们把这三个应用结合起来,创建一个简单但完整的场景:
javascript
// 创建一个会反弹的球
class Ball extends PhysicsObject {
constructor(position, velocity, radius) {
super(position, velocity, new Vector3(0, -9.8, 0)); // 重力
this.radius = radius;
}
checkGroundCollision(groundPlane) {
const distance = groundPlane.distanceToPoint(this.position);
// 球触碰地面
if (distance < this.radius) {
// 反弹:速度在法向量方向上反向
const normalVel = groundPlane.normal.multiplyScalar(
this.velocity.dot(groundPlane.normal)
);
// 反弹系数 0.8(损失 20% 能量)
this.velocity = this.velocity.sub(normalVel.multiplyScalar(1.8));
// 修正位置(避免陷入地面)
this.position = this.position.add(
groundPlane.normal.multiplyScalar(this.radius - distance)
);
}
}
calculateLighting(lightDir, lightColor) {
// 球面顶部的法向量(简化)
const normal = new Vector3(0, 1, 0);
const nDotL = Math.max(0, normal.dot(lightDir.normalize()));
return lightColor.multiplyScalar(nDotL);
}
}
// 模拟场景
const ball = new Ball(new Vector3(0, 10, 0), new Vector3(5, 0, 0), 1);
const ground = new Plane(new Vector3(0, 1, 0), new Vector3(0, 0, 0));
const light = new Vector3(-1, 1, 0); // 斜向光
for (let frame = 0; frame < 300; frame++) {
ball.update(1 / 60);
ball.checkGroundCollision(ground);
const brightness = ball.calculateLighting(light, new Color(1, 1, 1));
if (frame % 30 === 0) {
console.log(`Frame ${frame}: y=${ball.position.y.toFixed(2)}, light=${brightness.r.toFixed(2)}`);
}
}小结
在本章中,我们看到了向量运算在实际场景中的应用:
光照计算:用点积计算 Lambert 漫反射
- 法向量与光线方向的点积决定亮度
- 多光源只需累加贡献
运动模拟:用向量表示物理运动
- 位置、速度、加速度的向量关系
- 重力、抛物线、恒速运动
平面与距离:用向量判断几何关系
- 点到平面的有向距离
- 投影和射线相交
向量是 3D 图形学的基础,但对于更复杂的变换(旋转、缩放、变换组合),我们需要更强大的工具——矩阵。
下一章将学习矩阵的概念和基础运算。
练习:
- 计算法向量
(0, 1, 0)和光照方向(1, 1, 1)的 Lambert 光照强度 - 创建一个物体,初始位置
(0, 0, 0),速度(1, 0, 0),在 10 秒后的位置 - 计算点
(3, 5, 2)到平面(法向量(0, 1, 0),过点(0, 2, 0))的距离 - 修改反弹球示例,让球在 xz 平面上也能反弹(而不是只在 y 方向)
尝试在浏览器控制台中完成这些练习。