SpeedTree Vegetation Rendering & a Blender MCP Alternative
LOD(Level of Detail,多级细节)是 SpeedTree 性能的关键。同一棵树预生成多个不同精度的网格版本,引擎根据它在画面里的**屏幕占比(screen coverage)**自动切换,远处用低精度版本省算力。
一棵树在屏幕上占多大,取决于它的世界尺寸 、到相机距离 、相机竖直视场角 和图像高度 (像素)。屏幕占比近似为:
( 是树的高度占图像高度的比例,0 到 1 之间。)距离越远 越大, 越小。引擎预先设定每个 LOD 级别的屏幕占比阈值 ,运行时按当前 选级别。
SpeedTree 典型预生成 3 到 6 个 LOD 版本,面数逐级大幅下降:
| LOD | 三角面数 | 用途 |
|---|---|---|
| LOD0 | ~80000 | 近景高精度,可见细节、纹理清晰 |
| LOD1 | ~20000 | 中景,去掉细枝、合并针叶 |
| LOD2 | ~3000 | 远景,只保留树冠轮廓 |
面数下降是 4× 到 6× 的阶梯。远处一棵 LOD2 树的渲染开销只有 LOD0 的几十分之一,这就是开放世界能渲染百万棵树的根基。
按屏幕占比阈值切换,例如:
Unity 的 LODGroup 组件正是这种机制(见 B13),每个级别绑定一个网格和对应的屏幕占比阈值(百分比,0-1)。阈值要和 FBX 导入设置、引擎渲染管线对齐。
硬切换会”突兀”——树移动时某帧突然换模型。两种缓解:
LOD 版本可以运行时按距离动态简化网格(如基于边折叠的渐进网格 progressive mesh),但实时简化有 CPU/GPU 开销。SpeedTree 选择离线预生成所有 LOD 级别(建模时算好存进资产),运行时只是按阈值切换网格,开销极低。这是”空间换时间”——存储多份网格,换取运行时的零简化计算。
风动画是 SpeedTree 最巧妙的设计。它不用物理模拟(不积分刚体动力学),而是在顶点着色器里用数学函数逼近正弦波,让树木自然摆动。关键是用 frac 和 abs 近似三角波,再平滑成类正弦曲线——比原生 sin 快数倍。
标准正弦 sin(x) 在 GPU 上要调用超越函数单元,相对慢。三角波(triangle wave)只用基本算术就能构造。利用 frac(取小数部分)和 abs(取绝对值):
直觉拆解:
GLSL/HLSL 写法:
float TriangleWave(float x) {
return 1.0 - abs(2.0 * fract(x) - 1.0); // fract = GLSL 的 frac
}
输出周期 1、值域 的三角波,频率由输入 ( 是频率, 是时间)控制。
三角波有尖角(导数不连续),看着生硬。用立方平滑(cubic smooth)把尖角磨圆,逼近正弦曲线的平滑感。常用的是 smoothstep 风格的三次多项式:
把 TriangleWave 的输出(已在 )过一次 CubicSmooth,得到 S 形过渡:
float CubicSmooth(float x) {
return x * x * (3.0 - 2.0 * x); // Hermit 平滑
}
float WindWave(float t, float freq) {
return CubicSmooth(TriangleWave(t * freq));
}
这是 SpeedTree 的 Global Motion 技巧——CubicSmooth(TriangleWave(...)) 在视觉上和 sin 几乎无差别,但只用乘加和绝对值,比 sin 快数倍。大规模植被渲染时这点速度差被实例数放大成巨大收益。
风的位移不能整棵树均匀平移(那样像滑动,不像受风)。SpeedTree 让每个顶点的摆动幅度正比于它离地的高度:
其中 是顶点的归一化高度(根=0,树顶=1), 是最大幅度。效果:树根不动,树顶摆动最大,符合真实树木受风的力学(根部固定,越往梢部力臂越长、形变越大)。
具体到着色器, 从顶点的局部坐标或顶点色(vertex color)的某个通道读取(SpeedTree 烘焙时把风权重存进顶点色/UV):
// 顶点着色器
float wind = WindWave(time, windFreq); // 平滑的风波形 [0,1]
float bend = wind * windStrength * vColor.r; // vColor.r = 风权重(高度)
pos.x += bend; // 沿风向偏移
更精细的做法是分频段叠加:低频大摆 + 高频小抖,模拟阵风叠加细颤。
物理模拟(质点弹簧、有限元)要积分运动方程,每帧解算,对几万棵树根本算不动。顶点着色器方案把风简化为确定的解析函数,每个顶点独立、并行计算,完全在 GPU 上跑,CPU 零负担。代价是风不”真实”(不会因碰撞、湍流改变),但视觉上够用——植被渲染要的是”看起来在动”,不是”运动方程精确”。
frac/fract 的命名跨 API 不同。GLSL 是 fract,HLSL 是 frac,跨平台着色器移植时要改。GPU Instancing 解决大规模植被的性能问题:把同一棵树(同一网格、同一材质)的成百上千个实例,用一次绘制调用渲染出来。这是开放世界游戏能渲染百万棵树的关键。
不开 Instancing 时,每棵树是独立的绘制调用(draw call)。每次 draw call 涉及:
1000 棵树 = 1000 次 draw call。瓶颈不在 GPU 算力,而在 CPU 到 GPU 的通信开销和状态切换——CPU 单线程串行提交 draw call,每秒能提交的次数有限(几万次量级),1000 棵树还没问题,但开放世界几十万棵树就让 CPU 撑爆,GPU 反而闲置。这就是”draw call bound”。
Instancing 的思路:把不变的网格和材质提交一次,再用一个实例数据数组告诉 GPU 每个实例各自的变换(位置、旋转、缩放)。GPU 在顶点着色器里,按实例 ID 从数组里取对应变换,算出每个顶点的最终位置。
CPU 端:网格 + 材质 + [M0, M1, M2, ..., M999] ← 一次提交
GPU 端:每个顶点 = MeshVertex × M[instanceID] ← 并行算所有实例
1000 棵树变成 1 次 draw call。CPU 只提交一次,GPU 用 instanceID 索引变换数组,所有实例并行渲染。开销从”1000 次 CPU-GPU 通信”降到”1 次 + 1000 个矩阵的带宽”。
每个实例有一个 4×4 仿射变换矩阵 ( 是实例 ID),把树的局部坐标 变到世界坐标:
编码该实例的位置、旋转、缩放。完整顶点变换:
是视图矩阵、 是投影矩阵。着色器里 instanceID 内置变量让每个实例自动取自己的 :
// GLSL 实例化
in vec3 position; // 网格顶点(所有实例共享)
in mat4 instanceMatrix; // 每实例变换(顶点属性 divisor=1)
void main() {
gl_Position = VP * instanceMatrix * vec4(position, 1.0);
}
instanceMatrix 用 glVertexAttribDivisor(1) 让它每实例更新一次(而不是每顶点)。
实例化的根本前提是所有实例用同一个网格 + 同一个材质(shader + 纹理)。如果每个实例网格不同、或材质不同(不同纹理、不同参数),状态切换回来了,无法合并成一次 draw call。所以植被资产要尽量统一:
SpeedTree 的资产组织天然符合这个前提——同种树导出统一网格,变体通过实例的随机变换和参数差异实现。
Instancing 和 LOD(B04)配合:每个 LOD 级别是一个网格,对每个 LOD 级别单独发一次实例化 draw call。运行时按实例当前应有的 LOD 级别分组,每组一次 draw call。3 个 LOD + 实例化,能画几十万棵树且只有几次 draw call。
树木的分支结构有强烈的自相似性——一根枝上长出更细的枝,更细的枝上又长出更细的枝,模式递归重复。L-system(Lindenmayer system,林氏系统)正是为描述这种并行重写的递归结构而发明的形式语法。
L-system 是一种字符串重写系统:从初始字符串(公理 axiom)出发,按一组产生式规则(production rules)并行替换字符,迭代若干代,得到一个长字符串;再用海龟几何(turtle graphics)把字符串解释成绘图命令,画出分支结构。
经典海龟命令:
F:前进画一段(长一步、变细一级的枝)。+/-:左转/右转一个角度。&/^:下俯/上仰。\//:左滚/右滚。[:保存当前状态(位置、朝向、粗细)入栈。]:恢复栈顶状态(回到分叉点),用于画完一个子枝后回到分叉点画下一个。方括号 [ ... ] 是分支结构的关键:进入分叉压栈,画完子枝弹栈,实现”一根主干上长出多个枝、每个枝又能再分叉”的递归树形。
规则:F → F[+F]F[-F]F(一个 F 长成一个带左右两个分叉的主干)。从 F 开始迭代两代:
第 0 代: F
第 1 代: F[+F]F[-F]F
第 2 代: F[+F]F[-F]F [ +F[+F]F[-F]F ] F[+F]F[-F]F [ -F[+F]F[-F]F ] F[+F]F[-F]F
代数越多,分支越细越密。代入海龟几何(每次 F 步长缩短、粗细变细、加随机扰动),就画出越来越像真树的分支结构。
视频这棵松树的关键特征是轮生枝(whorled branches)——每隔一段主干长度(如 0.8 m),主干上的一圈水平长出多根一级枝,模拟松树的自然生长。L-system 里实现:
规则参数控制:主干节数、每节轮生枝数、一级/二级分叉数、各级步长缩短比、粗细衰减、角度抖动。调参就能在”写实松”和”风格化”之间切换。
纯确定性 L-system 画出完全对称、机械感的树。加随机扰动:
这是程序化生成(procedural generation)的精髓——确定性骨架 + 受控随机,无限生成不重复但风格统一的树。
L-system 的并行重写天然匹配植物生长的”分生组织”模型:每个生长点同时按规则分裂。它的递归性用很小的规则集生成极复杂的结构,参数化清晰(改几个数就能调树形),且数学上紧凑。替代方案(手工建模、基于体的建模)要么工作量大,要么不直观。SpeedTree、Houdini 的 L-system SOP、Blender 的脚本生成(本项目用 bmesh 程序化建模)都基于这套思想。
把建模导出的 FBX 接入 Unity,核心是 LODGroup 组件 + 风着色器 + GPU Instancing 三件套,对应 SpeedTree 的三大渲染原理(B04/B05/B06)。
Unity 的 LODGroup 组件实现 LOD 系统。结构:
PineTree (根,挂 LODGroup)
├── LOD0 (子节点,挂 LOD0 mesh,高精度)
├── LOD1 (子节点,挂 LOD1 mesh,中精度)
└── LOD2 (子节点,挂 LOD2 mesh,低精度)
LODGroup 上为每个级别设定屏幕占比阈值(百分比)。运行时 Unity 算每个 LOD 对象相对屏幕的大小,按阈值自动激活对应级别的子节点(其他禁用)。阈值要和 FBX 导入时各级别的设置一致,避免切换时尺寸跳变。
阈值示例(与 B04 对应):
| LOD 级别 | 屏幕占比阈值 |
|---|---|
| LOD0 | 25% 及以上 |
| LOD1 | 12.5% – 25% |
| LOD2 | 1% – 12.5% |
| Culled | 1% 以下 |
材质用自定义的 PineWind 着色器,在顶点着色器里实现 B05 的风动画。两种材质区分双面/单面:
| 材质 | 渲染面 | 用途 |
|---|---|---|
| 树皮 | 单面(Cull Back) | 树干,背面看不见,剔除省性能 |
| 针叶 | 双面(Cull Off) | 针叶束是薄片,两面都要可见 |
针叶开双面是因为针叶束没有”实体厚度”,单面渲染从背面看会消失。代价是overdraw增加,但针叶是植被视觉的主体,必须双面。
风着色器核心(B05 详述):顶点着色器读顶点色的风权重通道,乘以 CubicSmooth(TriangleWave(time)),沿风向偏移顶点。time 由引擎每帧传入,是 uniform 变量。
材质上勾选 “Enable GPU Instancing”,让相同网格 + 相同材质的实例合并成一次 draw call(B06)。九棵松树实例只需极少 draw call,几十万棵同理。Instancing 要求所有实例共享网格和材质,所以同种树用同一套资产。
LODGroup 各级别。一帧里,对每个 LOD 级别:
树顶摆动最大(风权重=1)、根部稳定(风权重=0),远处用低精度 LOD,大量实例用极少 draw call——这就是 SpeedTree 三原理在 Unity 里的落地。