Skip to main content

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 (屏幕坐标)

关键区别:变换计算发生在哪里?

  1. 合并几何体 (CPU 串行计算)

    • 过程:在 JS 中,你必须用循环遍历每一个圆点,手动计算每个顶点的最终世界坐标 vertex.applyMatrix4(matrix)
    • 瓶颈:JS 是单线程的(串行)。虽然 CPU 很快,但它必须一个一个地算。如果有 1万个点,就要排队算 1万次。
    • 结果:CPU 算好所有顶点的最终位置,打包成一个巨大的 Buffer 传给 GPU。GPU 此时只是个“画图员”,没有参与计算位置。
  2. 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 Calls1000 次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)。传统的 TextGeometryCanvasTexture 方案在大规模场景下存在严重性能问题。

6.1 传统方案的痛点

  1. TextGeometry (几何体文字)

    • 原理:将文字轮廓三角化生成 3D 模型。
    • 问题:面数极高!一个字母可能包含数百个三角形,渲染大量文字会瞬间撑爆显存和顶点着色器。
  2. CanvasTexture (贴图文字)

    • 原理:在 Canvas 上画字,作为纹理贴到平面上。
    • 问题
      • 模糊:放大时像素化严重。
      • 内存:每个不同的文字都需要一张纹理,无法复用。

6.2 SDF (Signed Distance Field) 方案

SDF 技术是解决大规模、高清晰度文字渲染的终极方案(如 troika-three-text)。

SDF (Signed Distance Field)

  • 原理:纹理中存储的不是颜色,而是像素到字形轮廓的最近距离
  • 优势:放大时,Shader 通过对距离值插值来重建边缘,保持绝对清晰,不会像传统位图字体那样模糊或有锯齿。

优势

  1. 无限放大不失真:边缘由数学计算重建,无锯齿。
  2. 极高性能:仅需极少的面数(通常是 Quad)和一张共享的 SDF 图集。
  3. 支持特效:轻松实现描边、光晕、阴影,只需在 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 会:

  1. 采样 SDF 纹理:获取当前像素对应的距离值(比如 0.15)

  2. 判断是否在文字内

    // 伪代码
    float distance = texture2D(sdfTexture, uv).r; // 读取距离值
    if (distance > 0.0) {
    // 在文字内部,渲染文字颜色
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); // 红色文字
    } else {
    // 在文字外部,渲染背景(或透明)
    discard; // 丢弃这个像素
    }
  3. 关键:插值重建边缘

    • 当放大时,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 会自动做这个?

  1. 硬件加速:GPU 的纹理采样单元(Texture Sampling Unit)硬件层面就实现了双线性插值,速度极快
  2. 默认行为:当你使用 texture2D() 时,如果纹理的过滤模式设置为 LINEAR(默认),GPU 就会自动进行双线性插值
  3. 无需手动计算:你不需要在 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 米"等精确距离,无论你站在哪里,都能准确判断是否在安全区域内

实际应用中的优势:

  1. 无限放大不失真:因为边缘是通过数学计算重建的,而不是拉伸像素
  2. 性能优秀:只需要一张低分辨率(如 64×64)的 SDF 纹理,就能渲染出任意大小的清晰文字
  3. 支持平滑边缘:可以通过调整阈值实现抗锯齿效果
    // 平滑边缘版本
    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 表面

  1. 原理:在 Fragment Shader 中定义一个 scene(vec3 p) 函数,返回空间中任意点到最近物体的距离
  2. 渲染过程:从相机发射光线,沿着光线方向逐步前进(Ray Marching),每次调用 scene() 函数,直到找到交点
  3. 优势:可以渲染传统几何体难以表达的复杂形状(如分形、流体、云等)

那个网页想解决什么问题?

根据 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 不是"为了炫技而炫技",它在实时光照程序化内容碰撞检测等领域有实际价值。特别是在现代游戏引擎中,它已经成为实现高质量实时渲染的重要工具之一。


思考与练习

  1. 解释 InstancedMeshBatchedMesh 的核心区别。如果你要渲染一个包含 5000 辆不同型号、不同颜色汽车的停车场,你应该选择哪种方案?为什么?
  2. 为什么在 Three.js 中删除一个 Mesh 对象(scene.remove(mesh))不足以释放内存?正确的做法是什么?
  3. 在使用 InstancedMesh 时,如果我们在 animate 循环中修改了某个实例的颜色或位置,为什么画面没有发生变化?还需要调用什么 API?
  4. 传统位图字体在放大时为什么会模糊?SDF(Signed Distance Field)是如何通过数学插值解决这个问题的?