04. 性能优化与工程化
当场景中的物体数量从 100 增加到 10,000 时,代码的写法必须发生质的改变。本章介绍在大规模场景下的生存法则。
1. 核心指标:Draw Calls
Draw Call 是 CPU 告诉 GPU "绘制这个物体" 的一次命令。
- 瓶颈:CPU 提交命令的速度远慢于 GPU 绘制的速度。如果每一帧有 1000 个物体,就有 1000 次 Draw Calls,CPU 会不堪重负,FPS 骤降。
- 目标:尽可能减少 Draw Calls。
2. 几何体合并 (Merge Geometries)
如果你有 1000 个完全静止的立方体(比如建筑物),最好的办法是把它们合成一个巨大的 Mesh。
import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js';
const geometries = [];
for (let i = 0; i < 1000; i++) {
const geometry = new THREE.BoxGeometry(1, 1, 1);
// 必须手动变换几何体顶点,因为合并后只有一个 matrixWorld
geometry.translate(Math.random() * 100, 0, Math.random() * 100);
geometries.push(geometry);
}
// 合并!
const mergedGeometry = BufferGeometryUtils.mergeGeometries(geometries);
const mesh = new THREE.Mesh(mergedGeometry, material);
scene.add(mesh);
结果:1000 次 Draw Call -> 1 次 Draw Call。 缺点:无法单独控制每个物体的移动或显隐。
3. 实例化渲染 (InstancedMesh)
如果你有 1000 个形状相同、材质相同,但位置/旋转/缩放/颜色不同的物体(如树木、子弹、人群),InstancedMesh 是终极解决方案。
它利用 GPU 的硬件实例化特性,一次 Draw Call 绘制无数个副本。
3.1 核心原理:共享几何体
"共享几何体"是什么意思?GPU 是复制几何体还是怎么处理的?
答案是:不是复制,而是重复使用!
- 传统方法:创建 1000 个 Mesh = 1000 份几何体数据 + 1000 次 Draw Call。
- InstancedMesh:创建 1 个 InstancedMesh = 1 份几何体数据 + 1 次 Draw Call + 1000 个变换矩阵。
3.2 代码对比与实现
传统方法(错误示范):
// ❌ 传统方法:创建 1000 个独立的 Mesh
for (let i = 0; i < 1000; i++) {
const geometry = new THREE.PlaneGeometry(2, 2); // 每个都创建新几何体
const mesh = new THREE.Mesh(geometry, material); // 每个都是独立对象
mesh.position.set(x[i], y[i], z[i]);
scene.add(mesh);
}
// 结果:内存占用高,CPU 提交 Draw Call 跑断腿
InstancedMesh 方法(正确示范):
// ✅ InstancedMesh:所有实例共享一个几何体
const sharedGeometry = new THREE.PlaneGeometry(2, 2); // 只创建一次!
const material = new THREE.MeshBasicMaterial({ color: 0xffffff });
// 创建容器,指定最大数量
const instancedMesh = new THREE.InstancedMesh(
sharedGeometry, // 共享的几何体(只存储一份)
material,
1000 // 实例数量
);
const dummy = new THREE.Object3D(); // 辅助对象,用于计算矩阵
// 为每个实例设置不同的变换矩阵
for (let i = 0; i < 1000; i++) {
dummy.position.set(Math.random() * 100, 0, Math.random() * 100);
dummy.rotation.y = Math.random() * Math.PI;
dummy.scale.setScalar(Math.random() + 0.5);
dummy.updateMatrix(); // 计算矩阵
instancedMesh.setMatrixAt(i, dummy.matrix); // 只存储变换矩阵,不复制几何体!
// 可选:设置颜色
instancedMesh.setColorAt(i, new THREE.Color(Math.random() * 0xffffff));
}
scene.add(instancedMesh);
3.3 GPU 层面的工作原理
1. 数据存储对比(GPU 内存)
传统方法(1000 个 Mesh):
┌─────────────────────────────────────┐
│ GPU 内存 │
├─────────────────────────────────────┤
│ Mesh 1 的顶点数据 (4 个顶点) │
│ Mesh 2 的顶点数据 (4 个顶点) │
│ ... │
│ Mesh 1000 的顶点数据 (4 个顶点) │
│ │
│ 总计:4000 个顶点数据 │
└─────────────────────────────────────┘
InstancedMesh(1 个共享几何体):
┌─────────────────────────────────────┐
│ GPU 内存 │
├─────────────────────────────────────┤
│ 共享几何体的顶点数据 (4 个顶点) │ ← 只存储一次!
│ │
│ 实例变换矩阵数组: │
│ [Matrix1, Matrix2, ..., Matrix1000]│ ← 只存储变换矩阵
│ │
│ 总计:4 个顶点 + 1000 个矩阵 │
└─────────────────────────────────────┘
2. 渲染流程对比
传统方法:
CPU 循环 1000 次:
for (i = 0; i < 1000; i++) {
1. 绑定 Mesh i 的几何体
2. 设置 Mesh i 的变换
3. 发送 Draw Call 到 GPU
4. GPU 绘制 Mesh i
}
结果:1000 次 CPU → GPU 通信,1000 次 Draw Call
InstancedMesh 方法:
CPU 执行一次:
1. 绑定共享几何体(只绑定一次)
2. 绑定实例矩阵数组
3. 发送 1 次 Draw Call 到 GPU(告诉 GPU:"画 1000 次")
GPU 内部循环(硬件自动执行):
for (instanceId = 0; instanceId < 1000; instanceId++) {
1. 读取共享几何体的顶点数据
2. 读取 instanceMatrix[instanceId]
3. 应用变换:vertex' = instanceMatrix[instanceId] * vertex
4. 继续渲染管线
}
结果:1 次 CPU → GPU 通信,1 次 Draw Call,GPU 内部自动重复
3.4 关键细节:Vertex Shader
在 Shader 中,GPU 自动处理了实例矩阵的应用:
gl_Position = projectionMatrix * modelViewMatrix * instanceMatrix * vec4(position, 1.0);
// ↑
// 这是关键!每个实例有不同的 instanceMatrix
instanceMatrix:GPU 在渲染每个实例时,自动从矩阵数组中读取对应的矩阵。
矩阵变换链:
position (模型空间)
↓ instanceMatrix (实例变换:每个圆点的位置/旋转/缩放)
↓ modelViewMatrix (模型视图变换:物体到相机空间)
↓ projectionMatrix (投影变换:相机空间到屏幕空间)
↓ gl_Position (屏幕坐标)
关键区别:变换计算发生在哪里?
合并几何体 (CPU 串行计算):
- 过程:在 JS 中,你必须用循环遍历每一个圆点,手动计算每个顶点的最终世界坐标
vertex.applyMatrix4(matrix)。 - 瓶颈:JS 是单线程的(串行)。虽然 CPU 很快,但它必须一个一个地算。如果有 1万个点,就要排队算 1万次。
- 结果:CPU 算好所有顶点的最终位置,打包成一个巨大的 Buffer 传给 GPU。GPU 此时只是个“画图员”,没有参与计算位置。
- 过程:在 JS 中,你必须用循环遍历每一个圆点,手动计算每个顶点的最终世界坐标
InstancedMesh (GPU 并行计算):
- 过程:CPU 只需要把“原始的圆(在原点)”和“200个变换矩阵”扔给 GPU。
- 优势:GPU 拥有成千上万个小核心。在 Vertex Shader 中,GPU 会分配成百上千个线程,同时计算每个实例的顶点位置。
- 比喻:
- 合并几何体:像是一个超快的数学教授(CPU),一个人把 200 张卷子全部做完,然后交给打印机(GPU)打印。
- InstancedMesh:像是教授(CPU)把题目分发给 200 个小学生(GPU 核心),大家同时做题,瞬间完成。
3.5 内存与性能对比(以 1000 个平面为例)
| 指标 | 传统方法 (1000 Mesh) | InstancedMesh | 提升 |
|---|---|---|---|
| 显存占用 | ~78 KB (重复存储几何体) | ~62 KB (只存矩阵) | 几何体越复杂,节省越多 |
| Draw Calls | 1000 次 | 1 次 | 1000 倍 |
| CPU 开销 | 极高 (循环发送命令) | 极低 (一次发送) | 显著提升 FPS |
3.6 动态更新注意事项
如果每一帧都要移动实例(比如粒子效果),修改后必须标记更新:
// 修改第 0 个实例的矩阵
instancedMesh.setMatrixAt(0, newMatrix);
// ⚠️ 重要:告诉 GPU 更新矩阵数据
instancedMesh.instanceMatrix.needsUpdate = true;
// 如果修改了颜色
instancedMesh.instanceColor.needsUpdate = true;
4. 前沿技术:BatchedMesh (多几何体/材质批处理)
InstancedMesh 虽然强大,但有一个致命限制:所有实例必须共享完全相同的 Geometry 和 Material。如果你有 1000 个不同的建筑模型(不同的形状、不同的贴图),InstancedMesh 就无能为力了。
在 Three.js r159+ 版本中,引入了革命性的 BatchedMesh。
4.1 BatchedMesh 的核心优势
BatchedMesh 允许你将不同的几何体和不同的材质打包成一个单一的 Mesh,并在底层通过极少的 Draw Call 进行渲染。
- 突破限制:支持不同的 Geometry 和 Material。
- 动态增删:相比于
BufferGeometryUtils.mergeGeometries这种静态合并,BatchedMesh允许你在运行时动态地增加、删除或移动其中的某个子网格,而无需重新合并整个巨大的几何体。 - 视锥体剔除 (Frustum Culling):
BatchedMesh内部支持对单个 Batch 实例进行视锥体剔除,进一步降低 GPU 负担。
4.2 使用示例
// 1. 创建 BatchedMesh 容器
// 参数:最大实例数, 最大顶点数, 最大索引数, 材质(或材质数组)
const batchedMesh = new THREE.BatchedMesh( 1000, 50000, 100000, material );
// 2. 注册不同的几何体
const geometryId1 = batchedMesh.addGeometry( boxGeometry );
const geometryId2 = batchedMesh.addGeometry( sphereGeometry );
// 3. 添加实例并分配几何体
const instanceId1 = batchedMesh.addInstance( geometryId1 );
const instanceId2 = batchedMesh.addInstance( geometryId2 );
// 4. 设置各自的变换矩阵
batchedMesh.setMatrixAt( instanceId1, matrix1 );
batchedMesh.setMatrixAt( instanceId2, matrix2 );
scene.add( batchedMesh );
4.3 方案选择指南
- 完全相同的物体(如一片森林):使用
InstancedMesh(性能最高)。 - 不同形状/材质的静态或动态物体(如城市建筑群):使用
BatchedMesh。 - 永远不会移动的静态背景:使用
Merge Geometries(最简单,但缺乏灵活性)。
5. 资源销毁 (Dispose)
Three.js 不会自动释放 GPU 内存。如果你删除了一个 Mesh,它的 Geometry 和 Material 数据依然在显存中。
必须手动清理:
function clearScene(obj) {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
if (Array.isArray(obj.material)) {
obj.material.forEach(m => m.dispose());
} else {
obj.material.dispose();
}
// 如果有贴图
if (obj.material.map) obj.material.map.dispose();
}
}
5. 纹理优化
- 尺寸:必须是 2 的幂次方(POT),如 512x512, 1024x1024。非 POT 纹理会导致 GPU 无法生成 Mipmaps,渲染质量差且慢。
- 格式:尽量使用压缩纹理(Basis, KTX2)。JPG/PNG 在显存中会被解压成巨大的位图,而压缩纹理可以直接在显存中读取。
6. 文本渲染优化 (SDF)
在自动驾驶可视化等场景中,经常需要显示大量的文字标签(如速度、概率、ID)。传统的 TextGeometry 或 CanvasTexture 方案在大规模场景下存在严重性能问题。
6.1 传统方案的痛点
TextGeometry (几何体文字):
- 原理:将文字轮廓三角化生成 3D 模型。
- 问题:面数极高!一个字母可能包含数百个三角形,渲染大量文字会瞬间撑爆显存和顶点着色器。
CanvasTexture (贴图文字):
- 原理:在 Canvas 上画字,作为纹理贴到平面上。
- 问题:
- 模糊:放大时像素化严重。
- 内存:每个不同的文字都需要一张纹理,无法复用。
6.2 SDF (Signed Distance Field) 方案
SDF 技术是解决大规模、高清晰度文字渲染的终极方案(如 troika-three-text)。
SDF (Signed Distance Field):
- 原理:纹理中存储的不是颜色,而是像素到字形轮廓的最近距离。
- 优势:放大时,Shader 通过对距离值插值来重建边缘,保持绝对清晰,不会像传统位图字体那样模糊或有锯齿。
优势:
- 无限放大不失真:边缘由数学计算重建,无锯齿。
- 极高性能:仅需极少的面数(通常是 Quad)和一张共享的 SDF 图集。
- 支持特效:轻松实现描边、光晕、阴影,只需在 Shader 中调整阈值。
深度解析:SDF 的工作原理(通俗易懂版)
传统位图字体的困境:
想象一下,你有一张 64×64 像素的字母 "A" 的图片:
- 每个像素要么是黑色(文字部分),要么是白色(背景部分)
- 当你放大 5 倍时,每个像素被拉伸成 5×5 的大方块
- GPU 只能对颜色进行线性插值:黑色和白色之间会变成灰色,形成锯齿和模糊
传统位图放大:
原图 (64×64) 放大 5 倍后
[黑][黑][白] → [黑黑黑黑黑][黑黑黑黑黑][灰灰灰灰灰]
[黑][白][黑] [黑黑黑黑黑][灰灰灰灰灰][黑黑黑黑黑]
[白][白][白] [灰灰灰灰灰][灰灰灰灰灰][灰灰灰灰灰]
↑ 边缘模糊,有锯齿
SDF 的巧妙思路:
SDF 改变了存储的内容。它不再存储"这个像素是什么颜色",而是存储"这个像素到文字边缘有多远"。
第一步:生成 SDF 纹理(预处理阶段)
对于每个像素,计算它到最近文字边缘的距离:
- 如果像素在文字内部,距离是正数(比如 +0.3)
- 如果像素在文字外部,距离是负数(比如 -0.2)
- 如果像素正好在边缘,距离是 0
SDF 纹理示例(简化,实际是连续的距离值):
像素位置 到边缘距离 含义
[+0.5][+0.3][-0.1] 内部 → 内部 → 边缘附近
[+0.3][ 0.0][+0.2] 内部 → 边缘 → 内部
[-0.1][-0.2][-0.3] 边缘附近 → 外部 → 外部
第二步:Shader 渲染时重建边缘(运行时)
当需要渲染文字时,Fragment Shader 会:
采样 SDF 纹理:获取当前像素对应的距离值(比如 0.15)
判断是否在文字内:
// 伪代码
float distance = texture2D(sdfTexture, uv).r; // 读取距离值
if (distance > 0.0) {
// 在文字内部,渲染文字颜色
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); // 红色文字
} else {
// 在文字外部,渲染背景(或透明)
discard; // 丢弃这个像素
}关键:插值重建边缘:
- 当放大时,GPU 会对距离值进行插值,而不是对颜色插值
- 例如:两个相邻像素的距离值是
+0.3和-0.1 - 放大后,中间像素的距离值会被插值为
+0.1(线性插值) - Shader 判断
+0.1 > 0,所以这个像素属于文字内部 - 结果:边缘依然清晰,因为判断依据是距离值,而不是颜色!
深度解析:双线性插值的数学推导
为什么 GPU 会自动做插值?
当你调用 texture2D(sdfTexture, uv) 时,uv 坐标(比如 (0.37, 0.63))通常不会正好对应纹理的整数像素位置。GPU 需要从周围的 4 个像素中计算出中间的值,这个过程就是双线性插值(Bilinear Interpolation)。
第一步:理解一维线性插值
假设我们有两个点,想要计算中间某个位置的值:
点 A (x=0, 值=+0.3) ←→ 点 B (x=1, 值=-0.1)
如果我们想知道 x = 0.5 位置的值,使用线性插值公式:
$$f(x) = f_A \cdot (1 - t) + f_B \cdot t$$
其中:
- $t = x - x_A = 0.5 - 0 = 0.5$(归一化的位置)
- $f_A = +0.3$(点 A 的值)
- $f_B = -0.1$(点 B 的值)
计算: $$f(0.5) = 0.3 \times (1 - 0.5) + (-0.1) \times 0.5 = 0.3 \times 0.5 + (-0.1) \times 0.5 = 0.1$$
结果:中间位置的值是 +0.1,正好在 +0.3 和 -0.1 之间!
第二步:扩展到二维双线性插值
纹理是二维的,所以需要两次线性插值:
假设我们有一个 2×2 的 SDF 纹理:
纹理坐标 (u, v):
(0,0) → +0.5 (1,0) → +0.2
(0,1) → 0.0 (1,1) → -0.3
现在我们想采样 uv = (0.6, 0.4) 位置的值。
步骤 1:水平方向两次插值(先算上下两条边)
上边(v=0):在 u=0.6 处插值
- $t_u = 0.6$
- $f_{top} = 0.5 \times (1 - 0.6) + 0.2 \times 0.6 = 0.5 \times 0.4 + 0.2 \times 0.6 = 0.2 + 0.12 = 0.32$
下边(v=1):在 u=0.6 处插值
- $f_{bottom} = 0.0 \times (1 - 0.6) + (-0.3) \times 0.6 = 0.0 - 0.18 = -0.18$
步骤 2:垂直方向插值(再算中间)
现在我们在垂直方向插值,使用上一步的结果:
- $t_v = 0.4$
- $f(0.6, 0.4) = f{top} \times (1 - t_v) + f{bottom} \times t_v$
- $f(0.6, 0.4) = 0.32 \times (1 - 0.4) + (-0.18) \times 0.4$
- $f(0.6, 0.4) = 0.32 \times 0.6 + (-0.18) \times 0.4 = 0.192 - 0.072 = 0.12$
结果:uv = (0.6, 0.4) 位置的插值结果是 +0.12!
通用公式(双线性插值)
给定四个角的值:
- $f_{00}$:左下角
(0,0)的值 - $f_{10}$:右下角
(1,0)的值 - $f_{01}$:左上角
(0,1)的值 - $f_{11}$:右上角
(1,1)的值
要计算 (u, v) 位置的值:
$$f(u, v) = f{00}(1-u)(1-v) + f{10}u(1-v) + f{01}(1-u)v + f{11}uv$$
验证上面的例子:
- $f{00} = 0.5$,$f{10} = 0.2$,$f{01} = 0.0$,$f{11} = -0.3$
- $u = 0.6$,$v = 0.4$
$$f(0.6, 0.4) = 0.5 \times 0.4 \times 0.6 + 0.2 \times 0.6 \times 0.6 + 0.0 \times 0.4 \times 0.4 + (-0.3) \times 0.6 \times 0.4$$
$$= 0.12 + 0.072 + 0 - 0.072 = 0.12$$ ✅
为什么 GPU 会自动做这个?
- 硬件加速:GPU 的纹理采样单元(Texture Sampling Unit)硬件层面就实现了双线性插值,速度极快
- 默认行为:当你使用
texture2D()时,如果纹理的过滤模式设置为LINEAR(默认),GPU 就会自动进行双线性插值 - 无需手动计算:你不需要在 Shader 中写插值代码,GPU 自动完成
在 SDF 中的意义
传统位图放大时:
- GPU 对颜色值进行插值:
黑色(0) + 白色(1) = 灰色(0.5) - 灰色无法判断是文字还是背景 → 模糊
SDF 放大时:
- GPU 对距离值进行插值:
+0.3 + (-0.1) = +0.1 - Shader 判断
+0.1 > 0→ 属于文字内部 → 清晰
关键洞察:插值本身是相同的数学操作,但插值的对象不同(颜色 vs 距离值),导致结果完全不同!
具体例子:
假设我们有一个 2×2 的 SDF 纹理,存储的是距离值:
原始 SDF 纹理(2×2):
[+0.5][+0.2]
[ 0.0][-0.3]
放大到 4×4 时,GPU 对距离值进行双线性插值:
[+0.5][+0.4][+0.3][+0.2]
[+0.3][+0.2][+0.1][ 0.0]
[+0.1][ 0.0][-0.1][-0.2]
[ 0.0][-0.1][-0.2][-0.3]
Shader 判断(distance > 0 为文字):
[文字][文字][文字][文字]
[文字][文字][文字][边缘]
[文字][边缘][背景][背景]
[边缘][背景][背景][背景]
为什么这样就能保持清晰?
关键在于:距离值是一个连续的数学量,而颜色是离散的。
- 传统位图:黑色(0) 和白色(1) 之间只能插值出灰色(0.5),无法判断"这个灰色到底属于文字还是背景"
- SDF:距离值 +0.3 和 -0.1 之间插值出 +0.1,Shader 可以明确判断
+0.1 > 0,所以属于文字内部
类比理解:
想象你在一个房间里,想知道自己是否在"安全区域"内:
- 传统位图:墙上只标记了"安全"和"危险"两个标签,中间区域模糊不清
- SDF:墙上标记的是"距离安全区域边界还有 3 米"、"还有 1 米"、"已超出 2 米"等精确距离,无论你站在哪里,都能准确判断是否在安全区域内
实际应用中的优势:
- 无限放大不失真:因为边缘是通过数学计算重建的,而不是拉伸像素
- 性能优秀:只需要一张低分辨率(如 64×64)的 SDF 纹理,就能渲染出任意大小的清晰文字
- 支持平滑边缘:可以通过调整阈值实现抗锯齿效果
// 平滑边缘版本
float alpha = smoothstep(-0.5, 0.5, distance); // 在边缘附近平滑过渡
gl_FragColor = vec4(color, alpha);
7. 对象池 (Object Pool)
对于需要频繁创建和销毁的物体(如子弹、粒子、每帧更新的轨迹点),new 和 GC(垃圾回收)是性能杀手。
核心思想:复用,而不是销毁重建。
7.1 实现模式
class BulletPool {
constructor() {
this.pool = [];
this.activeBullets = [];
}
// 获取对象
get() {
let bullet;
if (this.pool.length > 0) {
bullet = this.pool.pop(); // 从池中取出一个
bullet.visible = true;
} else {
bullet = createNewBullet(); // 池空了才新建
}
this.activeBullets.push(bullet);
return bullet;
}
// 回收对象
release(bullet) {
bullet.visible = false;
// 从活跃列表中移除(伪代码)
removeFromArray(this.activeBullets, bullet);
// 放回池中
this.pool.push(bullet);
}
}
7.2 优势
- 零 GC:稳定运行期间几乎不产生垃圾回收,避免画面卡顿。
- 初始化开销低:只需创建一次几何体和材质。
8. 其他技巧
- 视锥体剔除 (Frustum Culling): 默认开启。但对于顶点被 Shader 极大幅度偏移的物体,可能会被错误剔除。可设置
mesh.frustumCulled = false。 - renderOrder:如前所述,合理安排渲染顺序可以利用 Early-Z 剔除遮挡部分。
总结
性能优化的核心思想是:CPU 少发命令,GPU 少画像素,显存少存数据,对象少创建销毁。
补充说明:SDF 的两种应用场景
重要区分:SDF 有两种完全不同的应用!
你可能会在网上看到两种不同的 SDF 技术,它们虽然都叫 SDF,但解决的问题完全不同:
| 维度 | SDF 纹理字体(本文档) | SDF Raymarching(3D 场景渲染) |
|---|---|---|
| 应用场景 | 2D 文字渲染 | 3D 数学定义的表面(分形、云、流体等) |
| 数据来源 | 预生成的纹理(离线计算) | 实时计算的函数(Shader 中定义) |
| 渲染方式 | 采样纹理 → 判断距离值 | 光线步进 → 迭代查找交点 |
| 典型用途 | 清晰文字、UI 图标 | 复杂几何体、程序化生成场景 |
SDF Raymarching 是什么?
这是另一种完全不同的技术,用于渲染数学定义的 3D 表面:
- 原理:在 Fragment Shader 中定义一个
scene(vec3 p)函数,返回空间中任意点到最近物体的距离 - 渲染过程:从相机发射光线,沿着光线方向逐步前进(Ray Marching),每次调用
scene()函数,直到找到交点 - 优势:可以渲染传统几何体难以表达的复杂形状(如分形、流体、云等)
那个网页想解决什么问题?
根据 three.js 论坛的讨论,作者想解决的是:
问题:如何将 SDF Raymarching 场景和传统的 three.js Mesh(如 BoxGeometry)混合渲染在同一个场景中?
挑战:
- SDF Raymarching 是在 Fragment Shader 中完全用代码定义的,没有真实的几何体
- 传统的 three.js Mesh 有真实的顶点和面
- 两者需要正确的深度排序和遮挡关系
解决方案:使用 gl_FragDepth 手动控制深度值
- 在 SDF Raymarching 的 Fragment Shader 中,计算交点的真实 3D 位置
- 将这个位置转换到深度缓冲区的坐标系
- 使用
gl_FragDepth写入正确的深度值 - 这样 GPU 就能正确判断 SDF 物体和传统 Mesh 之间的前后关系
示例场景:
场景组成:
- 黑色平面:SDF Raymarching 渲染的重复球体(用代码定义)
- 立方体:传统的 three.js BoxGeometry
目标:让立方体可以正确遮挡 SDF 球体,反之亦然
总结:
- 本文档的 SDF:用于文字渲染,是预生成的纹理
- 网页讨论的 SDF:用于 3D 场景渲染,是实时计算的函数
- 两者虽然都叫 SDF,但技术栈和应用场景完全不同
SDF Raymarching 的实际应用价值
虽然 SDF Raymarching 看起来像是一个"炫技"的技术,但它确实有非常实际的应用价值:
1. 游戏引擎中的实时光照和阴影(最重要!)
Unreal Engine 5 的 Lumen 系统就大量使用了 SDF 技术:
- Mesh Distance Fields (MDF):将场景中的 Mesh 转换为 SDF 表示
- 用途:加速实时全局光照(Global Illumination)计算
- 优势:相比传统的光线追踪,查询 SDF 距离函数比遍历三角形网格快得多
- 效果:实现高质量动态阴影和间接光照,性能开销可控
实际价值:让游戏开发者能在保持高帧率的同时,获得接近离线渲染的光照质量。
2. 程序化生成内容(Procedural Content)
优势:不需要建模,用数学函数就能定义复杂形状
- 分形几何:Mandelbrot 集合、Julia 集合等数学分形
- 流体效果:水、烟雾、云等自然现象
- 抽象艺术:ShaderToy 上的各种创意效果
实际价值:适合需要无限变化或程序化生成的场景,比如:
- 随机生成的地形细节
- 动态变化的粒子效果
- 艺术化的背景元素
3. 碰撞检测和物理模拟
原理:SDF 函数天然提供了"点到最近表面的距离"
- 如果距离 < 0,点在物体内部(碰撞)
- 如果距离 > 0,点在物体外部(安全)
- 距离值本身可以用来计算碰撞响应和法向量
实际价值:
- 比传统包围盒更精确
- 比精确的三角形碰撞检测更快
- 适合需要大量快速碰撞检测的场景(如粒子系统)
4. 环境光遮蔽(Ambient Occlusion)
原理:在 SDF 场景中,可以快速采样周围的距离值,判断"这个位置被遮挡了多少"
- 不需要复杂的几何计算
- 只需要调用几次
scene()函数
实际价值:快速生成高质量的 AO 效果,增强场景的立体感。
5. Web 端的特殊价值
对于 Web/Three.js 场景,SDF Raymarching 还有额外价值:
- 文件体积小:不需要下载 3D 模型文件,所有形状都在 Shader 代码中
- 加载速度快:没有模型解析和上传到 GPU 的开销
- 无限细节:理论上可以无限放大,细节由数学函数保证
- 创意表达:适合艺术化、抽象化的视觉效果
6. 实际应用案例
| 应用领域 | 具体案例 | 为什么用 SDF Raymarching |
|---|---|---|
| 游戏引擎 | Unreal Engine 5 Lumen | 加速全局光照计算 |
| 创意作品 | ShaderToy 上的各种 Demo | 用代码创造复杂视觉效果 |
| 数据可视化 | 抽象化的数据展示 | 程序化生成,易于参数化调整 |
| Web 应用 | 背景动画、特效 | 文件小、加载快、效果好 |
什么时候应该考虑使用?
✅ 适合的场景:
- 需要程序化生成、无限变化的形状
- 需要高质量实时阴影和光照
- 文件大小和加载速度是关键因素
- 需要数学精确的几何形状(分形、曲线等)
❌ 不适合的场景:
- 需要精确的几何细节(如建筑模型)
- 需要与现有 Mesh 系统深度集成
- 性能要求极高(SDF Raymarching 比传统渲染慢)
- 需要复杂的材质和纹理
总结:
SDF Raymarching 不是"为了炫技而炫技",它在实时光照、程序化内容、碰撞检测等领域有实际价值。特别是在现代游戏引擎中,它已经成为实现高质量实时渲染的重要工具之一。
思考与练习
- 解释
InstancedMesh和BatchedMesh的核心区别。如果你要渲染一个包含 5000 辆不同型号、不同颜色汽车的停车场,你应该选择哪种方案?为什么? - 为什么在 Three.js 中删除一个 Mesh 对象(
scene.remove(mesh))不足以释放内存?正确的做法是什么? - 在使用
InstancedMesh时,如果我们在animate循环中修改了某个实例的颜色或位置,为什么画面没有发生变化?还需要调用什么 API? - 传统位图字体在放大时为什么会模糊?SDF(Signed Distance Field)是如何通过数学插值解决这个问题的?