three.js 中的树木与森林:LOD、实例化、KTX2 与风——实战指南
如何在 three.js 网页游戏中放入一片真正的森林,同时不拖垮帧率和下载体积:可直接用于游戏的 GLB、压缩、THREE.LOD、InstancedMesh 与 BatchedMesh、镂空树叶、阴影,以及廉价的风着色器。

越来越多的游戏是为浏览器制作的——其中越来越多的代码是和 AI 助手一起写的,而 AI 助手默认就会选用 three.js。比如 Vibe Jam 2026 就要求参赛作品是无需下载即可启动的浏览器游戏,并推荐使用 three.js。这些游戏迟早都会需要一片森林。而森林往往是第一个把 60 FPS 变成 15 FPS、把 5 MB 的页面变成 150 MB 的东西。
网页是所有平台中限制最严格的:性能孱弱的笔记本和手机、与浏览器共用的 GPU,而且每一兆字节都要在玩家看到任何画面之前下载完。本指南一步步展示如何做出能扛住这些限制的植被,并附上适用于当前版本 three.js 的代码。
网页预算
写代码之前先定好上限。以下是针对中端笔记本或手机的起点,而不是铁律——请测量你自己的场景:
| 项目 | 舒适目标 |
|---|---|
| 每帧 draw call | 最多几百个 |
| 每帧三角面 | 50 万–200 万 |
| 单棵树,近景级别 | 5,000–15,000 个三角面 |
| 单棵树,远景级别 | 少于 1,500 个三角面;Impostor 为 2 个 |
| 整片森林的下载量 | 几 MB,而不是几十 MB |
核心思路与任何引擎都一样:**森林的大部分都在远处,所以森林的大部分必须很便宜。**每个细节层级大约应比上一级轻 3–4 倍。
一棵树,四个细节层级:轮廓不变,开销下降。
第 1 步:从可直接用于游戏的 GLB 开始
游戏用的树不只是一个好看的网格。在写下第一行渲染代码之前,先检查文件是否具备:
- 一条 LOD 链,各级轮廓一致(最好最远一级还有 Impostor);
- 以叶簇面片表现的树叶,而不是成千上万片独立的叶子,也不是实心的「西兰花」团块;
- 轴心点位于树干底部,并采用以米为单位的真实比例;
- 简单碰撞体——给树干用胶囊体,而不是给树冠;
- 逐顶点存储的风权重。
这些都可以用免费的浏览器工具检查:多边形计数器、LOD 查看器和 glTF 验证器。
第 2 步:让文件变小——meshopt 与 KTX2
两种压缩能让一棵典型的树缩小好几倍:
- 几何体——meshopt(或 Draco)压缩。
- 纹理——KTX2(Basis Universal)。与 PNG 或 JPEG 不同,KTX2 在显存中依然保持压缩状态,因此既节省下载量,也节省显存。
你可以在浏览器里用我们的 glTF 压缩工具和 KTX2 转换器完成,也可以在命令行中使用 glTF Transform:
npx @gltf-transform/cli optimize tree.glb tree.min.glb --compress meshopt --texture-compress ktx2
(在 CLI 中进行 KTX2 压缩需要安装 KTX-Software;--texture-compress webp 则无需安装。)
在 three.js 中加载压缩后的文件:
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
const ktx2 = new KTX2Loader().setTranscoderPath('/basis/').detectSupport(renderer);
const loader = new GLTFLoader().setKTX2Loader(ktx2).setMeshoptDecoder(MeshoptDecoder);
const gltf = await loader.loadAsync('/models/oak.min.glb');
第 3 步:少量树木——THREE.LOD
对于玩家附近的少数几棵树——村庄广场、一棵主角树——内置的 THREE.LOD 就够用了。它根据到相机的距离显示其中一个子对象;第三个参数是迟滞值,用于防止在边界处闪烁:
const lod = new THREE.LOD();
lod.addLevel(lod0, 0);
lod.addLevel(lod1, 30, 0.1);
lod.addLevel(lod2, 70, 0.1);
lod.addLevel(impostor, 150, 0.1);
lod.position.set(x, 0, z);
scene.add(lod);
渲染器绘制场景时,THREE.LOD 会自动更新。它的局限在于每棵树仍然是独立对象——树皮和树叶各有独立的 draw call。超过几十棵树,就需要实例化了。
第 4 步:大量树木——分块实例化
InstancedMesh 能在一次 draw call 中绘制一个网格的成千上万个副本。但 three.js 把它当作一个对象:整体剔除,没有 LOD——所有实例都以相同细节绘制,哪怕是 500 m 外或相机背后的实例。
解决方法和 Godot、Unity 中一样:把世界划分为块(chunk),为每个块、每个 LOD 级别、每种材质各创建一个 InstancedMesh。然后每个块根据距离选择自己的级别:
const CHUNK = 32; // 米——接近 LOD0 的距离
const STARTS = [0, 35, 80, 160]; // 每级 LOD 的起始距离
const CULL = 400;
function buildChunk(trees, lodMeshes) {
// lodMeshes[i] = [barkMesh, leavesMesh],取自 GLB
const levels = lodMeshes.map((parts) => parts.map((src) => {
const im = new THREE.InstancedMesh(src.geometry, src.material, trees.length);
trees.forEach((m, k) => im.setMatrixAt(k, m));
im.computeBoundingSphere();
return im;
}));
const group = new THREE.Group();
levels.flat().forEach((im) => group.add(im));
group.userData.levels = levels;
group.userData.center = centerOf(trees); // 一个 THREE.Vector3
return group;
}
function updateChunk(group, camera) {
const d = camera.position.distanceTo(group.userData.center);
group.userData.levels.forEach((parts, i) => {
const visible = d >= STARTS[i] && d < (STARTS[i + 1] ?? CULL);
parts.forEach((im) => { im.visible = visible; });
});
}
每秒对每个块调用几次 updateChunk 即可,不必每帧调用——距离变化很慢。块的大小很关键:距离是测量到块中心的,所以如果块远大于 LOD0 范围,紧挨着玩家的树也会显示成粗糙的级别。
BatchedMesh 是更现代的替代方案。它用一种材质绘制许多不同的几何体,对每个对象单独剔除(perObjectFrustumCulled 默认开启),还能用 setGeometryIdAt 更换任意实例的几何体——这正是 LOD 切换要做的事:
const bark = new THREE.BatchedMesh(maxTrees, maxVerts, maxIndices, barkMaterial);
const lodIds = barkLods.map((g) => bark.addGeometry(g));
for (const t of trees) {
t.id = bark.addInstance(lodIds[0]);
bark.setMatrixAt(t.id, t.matrix);
}
// 每秒执行几次:
for (const t of trees) {
const d = camera.position.distanceTo(t.position);
const lod = STARTS.findLastIndex((s) => d >= s);
bark.setGeometryIdAt(t.id, lodIds[lod]);
}
每种材质(树皮、树叶)使用一个 BatchedMesh。如果你更想要现成方案,社区库 @three.ez/instanced-mesh(为实例化提供逐实例剔除和 LOD)和 @three.ez/batched-mesh-extensions(为 BatchedMesh 提供 LOD)可以替你完成这些工作。
还有一条捷径:如果你的文件使用了 glTF 扩展 EXT_mesh_gpu_instancing,GLTFLoader 会自动为你创建 InstancedMesh 对象——一个文件就是一整片森林。
由实例化树木组成的森林:树越来越多,draw call 数量却几乎不增长。
第 5 步:树叶——用镂空,而不是透明
网页上大多数卡顿的森林,都卡在树叶上。
- 用
alphaTest,而不是transparent: true。 透明材质需要排序和混合,不写入深度,还会成倍增加 overdraw。镂空(cutout)便宜得多:
leaves.material.transparent = false;
leaves.material.alphaTest = 0.5;
leaves.material.side = THREE.DoubleSide;
leaves.material.alphaToCoverage = true; // 渲染器开启 antialias: true 时边缘更柔和
- 注意面片形状。 圆形叶簇外面套一张方形面片,会着色大量不可见像素。按叶簇轮廓裁切的面片能节省大量填充率(fill rate)——而在手机上,填充率往往才是真正的瓶颈。
- 在低端设备上降低分辨率。 在高 DPI 手机上,
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5))常常是对植被渲染收益最大的单项优化。
第 6 步:阴影与风
阴影会把森林再画一遍。让阴影相机紧贴玩家周围,使用适中的 shadow.mapSize,并且只让近处的级别投射阴影:
lod2Leaves.castShadow = false;
impostor.castShadow = false;
风应该放在顶点着色器里。如果模型按顶点存储了风权重——比如树干弯曲存在第二套 UV 通道的 x 中,树枝摇摆存在 y 中——就可以用 onBeforeCompile 给标准材质加上运动:
const time = { value: 0 };
geometry.setAttribute('wind', geometry.getAttribute('uv1')); // 来自 GLB 的 TEXCOORD_1
material.onBeforeCompile = (shader) => {
shader.uniforms.uTime = time;
shader.vertexShader = shader.vertexShader
.replace('#include <common>', '#include <common>\nattribute vec2 wind;\nuniform float uTime;')
.replace('#include <begin_vertex>', `#include <begin_vertex>
float sway = sin(uTime * 1.3 + position.y * 0.2);
transformed.x += (sway * wind.x * 0.25 + sin(uTime * 2.7 + position.x) * wind.y * 0.1);
transformed.z += sway * wind.x * 0.1;`);
};
// 在渲染循环中:
time.value = clock.getElapsedTime();
把属性复制为独立的名字,是为了避免与 three.js 冲突:当材质使用第二套 UV 时,three.js 会自行声明 uv1。
第 7 步:测量
renderer.info.render.calls和renderer.info.render.triangles——每帧的 draw call 和三角面数;- 浏览器的 Performance 面板——瓶颈在 CPU 还是 GPU;
- 一部真实的中端手机——而不只是你的开发机。
每次修改前后都要测量。往往一个修复(镂空树叶、像素比、阴影距离)带来的收益比其他所有修改加起来还多。
检查清单
- GLB 包含 LOD 链、叶簇面片、位于根部的轴心点、碰撞体和风权重。
- 几何体用 meshopt 压缩,纹理用 KTX2 压缩。
- 少量近处树木用
THREE.LOD;森林用分块InstancedMesh或BatchedMesh。 - 树叶使用
alphaTest和alphaToCoverage,而不是transparent。 - 只有近处级别投射阴影;阴影相机范围收紧。
- 手机上限制了像素比。
- 在真实设备上测量了 draw call、三角面和帧时间。
FoliMesh 能帮上什么
FoliMesh 是一款在浏览器中运行、围绕游戏预算打造的树木与自然生成器。你为每一级设定三角面预算,它就会导出包含 LOD0–LOD3 以及用于最远距离的十字公告板的 GLB、按叶簇形状裁切的树叶面片、位于根部的轴心点、存储在 TEXCOORD_1 中的风权重(three.js 将其读取为 uv1),以及存放在节点 extras 中的 LOD 距离和碰撞胶囊体(在 three.js 中即 object.userData)。整片森林可以导出为一个使用 EXT_mesh_gpu_instancing 的 GLB,GLTFLoader 会把它转换成实例化网格。
想了解 FoliMesh 与其他工具的对比,请阅读我们的 3D 树木生成器对比。用的是 Godot?请看如何在 Godot 4 中打造高性能森林。
常见问题
three.js 有 LOD 吗?
有,THREE.LOD 会按距离在子对象之间切换。但它无法对 InstancedMesh 内部的单个实例生效——要做到这一点,请使用分块、配合 setGeometryIdAt 的 BatchedMesh,或者 @three.ez/instanced-mesh 之类的库。
InstancedMesh 还是 BatchedMesh?
对于同一网格的大量副本,InstancedMesh 最简单也最快。如果你需要逐对象剔除和逐对象 LOD,或者要用一种材质绘制许多不同的网格,BatchedMesh 更合适。
为什么我的树叶是黑的,或者排序错乱?
通常是因为材质设置了 transparent: true。改用 alphaTest 配合 DoubleSide,排序问题就会消失,性能也会提升。
树木值得用 KTX2 吗? 值得。树皮和树叶纹理往往是下载量和显存中占比最大的部分,而 KTX2 能同时减少这两者。

