进阶知识
以下是 threejs 的一些进阶知识点:
1. Z-Fighting(深度冲突)
Z-Fighting 是 3D 渲染中非常典型的视觉 Bug:两个(或多个)表面在深度缓冲区的精度范围内几乎完全重合,GPU 无法稳定决定谁在前谁在后,于是出现闪烁、条纹、斑马纹等鬼影。
- 原理:深度缓冲区精度有限
- GPU 用一个固定位数(通常是 24-bit)的 Z-Buffer (深度缓冲区) 记录每个像素的深度。
- 在透视投影下,精度并不是均匀的:近处精度极高,远处精度极低(
1/z分布)。 - 当两个面几乎共面(比如地砖铺在地面模型上、UI 标注贴在物体表面),它们落到 Z-Buffer 里的整数值可能完全相同或交错出现,导致绘制顺序在像素级别随机切换。
- 典型场景
- 共面几何体:两张 Plane 在完全相同的 Z 平面叠加。
- 远距离大平面:地形、地面、天空盒的远端。
- 贴附在曲面上的细节:网格线、文字标注、贴花 (Decal)、阴影投射物。
- 3D 文字 / UI:边缘、描边与本体。
Z-Fighting 的解决方案
| 方案 | 适用场景 | 核心思路 |
|---|---|---|
| 1. 拉开深度偏移 (Polygon Offset) | 通用,最稳 | material.polygonOffset = true; polygonOffsetFactor = -1; polygonOffsetUnits = -1;(在 GPU 深度计算时人为加一个微小偏移) |
| 2. 物理挪开距离 | 不严格要求共面 | 把附加物沿法线方向推出一点点(0.001 ~ 0.01) |
3. 改用 logDepthBuf / 自定义深度 | 远大场景 | 突破硬件 Z-Buffer 精度限制(Three.js 可通过开启 logarithmic depth buffer 解决远裁面 z-fighting。) |
| 4. 避免完全共面 | 建模阶段 | 合并几何 / 用厚度 (extrude) 替代纯平面 / 整体法线偏移烘焙进顶点 |
| 5. 合理设置 near / far | 整体优化 | 让 camera.near 不要设得过小(会极度拉低精度),far 也不要过大 |
硬件 Z-Buffer 精度按"近多远稀"分布( 1/z ),用 log(z) 重映射后变成"远近均匀"(logarithmic depth buffer),远距离精度从"几个像素挤一起"提升到"每个深度都有独立刻度",共面物体就不再"撞车"了
near 推远 + far 拉近 → 减少比值 → Z-Buffer 远端刻度变密 → 远处两个共面物体不再"撞同一刻度" → Z-Fighting 消失。
透视投影下的深度值 = (far + near) / (far - near) - 2far near / ((far - near) * z) , z 越深,每单位距离对应的"深度值变化"越小 。
比值 = far / near,这个比值越大,远处的"刻度"被压缩得越狠
代码示例:用 PolygonOffset 解决共面闪烁
// 例:给地面加一层网格线(Plane 完全贴在地面上 → 会 Z-Fighting)
const grid = new THREE.GridHelper(10, 10, 0xff0000, 0xff0000);
grid.material.polygonOffset = true;
grid.material.polygonOffsetFactor = -1; // 越负越靠前
grid.material.polygonOffsetUnits = -1; // GPU 加 1 个最小深度单位
scene.add(grid);
💡 关键参数经验:
polygonOffsetFactor控制梯度偏移(按斜率调整),-1是常用值。polygonOffsetUnits控制常量偏移(所有像素加一个固定值),-1是常用值。- 实际项目里可以两个都设
-1,仍然闪烁再调到-2或-4。- 同样可以用作 阴影 acne 的解法(与
light.shadow.bias配合使用更稳)。
避坑口诀
看到斑马纹 → 检查是否共面 → PolygonOffset 是首选;远景闪烁 → 检查 near/far 比例 → 开 log depth buffer。
2. 光线投射 (Raycaster) 与鼠标拾取
在 3D 世界中,用户的鼠标点击是在 2D 的屏幕屏幕坐标系上发生的,如何判断鼠标点击到了哪个 3D 物体? 这就需要用到光线投射 (Raycaster):
- 原理与底层源码机制:将鼠标的屏幕 2D 坐标(clientX, clientY)转换为归一化设备坐标 (NDC)。然后根据相机的位置和方向,从相机发出一条射线,穿过这个 NDC 坐标。在 Three.js 底层源码(如
Mesh.prototype.raycast)中,相交检测分为“粗筛”和“精算”两步:- 粗筛(Bounding Volume 剔除):首先将射线与物体的包围球 (BoundingSphere) 进行相交测试。如果不相交,直接剔除(Early Return);如果相交,再测试包围盒 (BoundingBox)。这一步以极低的计算成本过滤了大部分不可能相交的物体。
- 精算(Ray-Triangle Intersection):对于通过粗筛的物体,遍历其几何体(BufferGeometry)的顶点数据,提取出每一个三角形面。底层调用
Ray.intersectTriangle,使用经典的 Möller–Trumbore 算法 计算射线与三维三角形的交点(通过计算重心坐标系来判断交点是否在三角形内部)。 - 数据封装:计算出交点后,根据重心坐标插值计算出碰撞点的 UV 坐标、法线(Normal),并按距离相机的远近排序返回给开发者。
- 性能问题:Raycaster 是在 CPU 中通过遍历几何体的顶点来计算交叉的。如果场景极其庞大(几百万个顶点),调用一次 Raycaster 可能会严重卡死主线程。
- 优化方案:利用 BVH (Bounding Volume Hierarchy 层次包围盒) 预先建立空间索引,将 O(N) 的遍历复杂度降低为 O(logN);或者利用 GPU 颜色拾取(为每个物体赋予唯一的颜色并渲染到离屏缓冲区,通过读取点击位置的像素颜色来反推物体,效率极高,但不支持遮挡和拾取透明物体,并且内存占用会翻倍)。
代码示例:基础射线拾取
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
window.addEventListener("click", (event) => {
// 1. 将鼠标坐标转化为 NDC 标准设备坐标 [-1, 1]
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; // 注意 Y 轴是反的
// 2. 通过相机和鼠标位置更新射线
raycaster.setFromCamera(mouse, camera);
// 3. 计算相交物体(参数 true 表示递归检测子节点)
const intersects = raycaster.intersectObjects(scene.children, true);
if (intersects.length > 0) {
// 拿到的第一个就是离相机最近的物体
const firstObject = intersects[0].object;
firstObject.material.color.set(0xff0000); // 变红
}
});
3. 后期处理 (Post-Processing)
如果你觉得原生渲染出来的场景干瘪、不真实,这时候就需要加上后期处理(类似用 PS 给照片加滤镜)。
- 原理:不把场景直接渲染到屏幕上,而是渲染到一个离屏的 帧缓冲区 (Framebuffer),将其作为一张 2D 纹理。然后通过编写自定义的 Shader,对这张纹理进行二次处理,最后再绘制到屏幕上。
- 常见特效:
- Bloom (辉光/泛光):让发光的物体产生周围的光晕,赛博朋克风格必备。
- SSAO (屏幕空间环境光遮蔽):通过深度图计算暗角的阴影,极大增强模型的立体感。
- FXAA/SMAA (抗锯齿):通过图像边缘检测算法平滑锯齿。
- DOF (景深):模拟单反相机的近实远虚效果。
代码示例:添加 Bloom 辉光特效
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
import { UnrealBloomPass } from "three/examples/jsm/postprocessing/UnrealBloomPass.js";
// 1. 初始化 Composer
const composer = new EffectComposer(renderer);
// 2. 添加基础渲染通道(把场景原本的样子画下来)
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);
// 3. 添加 Bloom 辉光通道
const bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
1.5, // 强度
0.4, // 半径
0.85, // 阈值(亮度超过这个值的物体才会发光)
);
composer.addPass(bloomPass);
// 4. 替换原有的 renderer.render
function animate() {
requestAnimationFrame(animate);
// renderer.render(scene, camera); // 注释掉原生渲染
composer.render(); // 使用 Composer 渲染
}
💡 实战扩展:结合 Raycaster 与 OutlinePass 实现“点击高亮描边”
在数字孪生或 3D 编辑器中,"点击模型 → 边缘发光高亮"是最高频的交互之一。这正是 光线投射 + 后期处理 完美结合的场景。
底层实现原理(技术亮点):
OutlinePass并不是直接给模型画一个框,而是利用 多 RenderTarget 离屏渲染:- 首先,它会把
selectedObjects数组中的模型,用纯色(通常是白色)单独渲染到一张离屏纹理上,其他没选中的模型全黑(Mask 遮罩层)。- 对这张纯色纹理进行 高斯模糊(Gaussian Blur),使得白色区域向外扩张(形成光晕)。
- 将模糊后的纹理与第一步的纯色纹理进行 相减 (模糊 - 原图),只留下外圈的轮廓光晕(Edge)。
- 最后,在 Fragment Shader 中将这个轮廓与主场景图像叠加混合。
代码实现骨架:
import { OutlinePass } from "three/examples/jsm/postprocessing/OutlinePass.js";
// 1. 初始化描边通道
const outlinePass = new OutlinePass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
scene,
camera,
);
outlinePass.edgeStrength = 3.0; // 发光强度
outlinePass.edgeGlow = 1.0; // 边缘发光扩散度
outlinePass.visibleEdgeColor.set(0x00ff00); // 描边颜色(绿色)
composer.addPass(outlinePass);
// 2. 在之前的 Raycaster click 事件中更新选中状态
window.addEventListener("click", (event) => {
// ... 前面的 raycaster 计算逻辑 ...
const intersects = raycaster.intersectObjects(scene.children, true);
if (intersects.length > 0) {
// 关键:将被点击的物体装入 selectedObjects 数组
// OutlinePass 会自动检测这个数组并在下一帧为其绘制高斯模糊描边
outlinePass.selectedObjects = [intersects[0].object];
} else {
outlinePass.selectedObjects = []; // 点击空白处取消高亮
}
});
4. 粒子系统 (Particle System)
用于模拟雨雪、烟雾、火焰、星空、爆炸等极其细碎且数量庞大的效果。
- Points 材质:Three.js 提供了
THREE.Points和THREE.PointsMaterial。在 WebGL 底层,它利用了gl.POINTS原语,每一个顶点都会被渲染成一个始终朝向相机的正方形精灵图 (Sprite)。 - 性能瓶颈:如果用 CPU 在
requestAnimationFrame每一帧里去更新 100 万个粒子的position,绝对会卡死。 - 优化:将粒子的初始状态(位置、速度、颜色、生命周期)写入 BufferAttribute,然后在 Vertex Shader (顶点着色器) 中利用内置的
time变量来计算它们当前的运动轨迹,彻底解放 CPU。
代码示例:生成 10 万个星空粒子
// 1. 创建空的缓冲几何体
const geometry = new THREE.BufferGeometry();
const particleCount = 100000;
// 2. 准备一维数组存储坐标 (x, y, z)
const positions = new Float32Array(particleCount * 3);
for (let i = 0; i < particleCount * 3; i++) {
// 在 -500 到 500 的空间内随机散布
positions[i] = (Math.random() - 0.5) * 1000;
}
// 3. 将数据作为 position 属性写入几何体
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
// 4. 创建点材质
const material = new THREE.PointsMaterial({
color: 0xffffff,
size: 2, // 粒子大小
sizeAttenuation: true, // 近大远小
});
// 5. 组装为 Points 并加入场景
const stars = new THREE.Points(geometry, material);
scene.add(stars);
💡 Points 粒子 vs InstancedMesh (实例化渲染) 有什么区别?
很多人会问:既然都是渲染大量物体,为什么不直接用
InstancedMesh画 100 万个面片(Plane),而非要用THREE.Points?1. 底层原语不同(决定了性能上限)
- InstancedMesh:底层是
gl.TRIANGLES。你画 1 个四边形需要 4 个顶点 + 2 个三角形面。100 万个粒子 = 400 万个顶点 + 200 万个面,这直接打满了 GPU 的光栅化压力。- Points:底层是
gl.POINTS。这是 WebGL 原生提供的一种特殊的“点原语”,1 个粒子就只有 1 个顶点。100 万个粒子就只有 100 万个顶点,没有面。GPU 在硬件层面会直接把它变成一个正方形,并且永远自动朝向相机(Billboard 效果),连旋转矩阵都不用算。2. 矩阵开销
- InstancedMesh:每个实例都需要一个
4x4 矩阵(16 个 float = 64 字节)来控制位置、旋转和缩放。100 万个实例仅矩阵就要吃掉 64 MB 显存,且每次变动都需要传这么大的数据。- Points:一个粒子只需要一个
vec3(3 个 float = 12 字节)的位置坐标。显存占用极小,带宽压力只有实例化的 1/5。3. 适用边界
- 用 Points:数量极大(10 万 ~ 百万级),且不需要体积感(如雨雪、星空、烟雾特效),永远朝向相机。
- 用 InstancedMesh:数量中等(万级以内),且每个物体有真正的 3D 几何体和法线光照(如森林里的树、成群的飞鸟、弹壳),需要有各自的朝向。