GPU Gems 2 · Chapter 1. Toward Photorealism in Virtual Botany
把草地、地面杂物、灌木和树木拆成可管理的层,用确定性布植、billboard 批处理、LOD 与近似阴影在实时预算内制造自然场景的丰富感。
学习目标
- 能把自然场景拆成草地、地面杂物、灌木和树木等层,并为每层分配距离、密度与更新预算
- 能说明相机跟随的种植网格、确定性布植和 billboard 批处理如何减少内存、CPU 工作与 draw call
- 能修改实验中的层、密度、风摆和近似阴影,解释为什么画面变了但刷新后分布仍稳定
- 能回答:为什么“逐根草精确建模”通常不是逼真自然场景的正确起点?
自然场景最难的地方不是画出一棵树,而是让镜头穿过一片很大的树林时,观众仍感觉世界有层次。逐根草、逐片叶子都是真实几何,听起来准确,却会把顶点、内存和提交次数一起推到不可控。更实用的路径是先把复杂度拆开,再决定每一层离镜头多远、以什么表示法出现。
如果不做这层管理,近处草、远处树和永远看不清的地面碎屑会争抢同一份预算。画面未必更真实,反而会在镜头移动时暴露 pop-in、重复纹理和帧时间尖峰。
1. 把自然看成四个互补层
↡把自然场景按尺度和表示法拆成草、杂物、灌木、树木等可独立更新的对象集合的目的不是给美术表格增加列,而是让每层都能拥有自己的可见距离、材质、密度和更新策略。近处的草需要大量细节但单个尺寸很小;远处的树需要保持大轮廓,却不必继续展示叶片的每个洞。
一个可落地的分配方式是:草地用相机朝向的二维片组,地面杂物用少量小网格或片组,灌木用叶簇,树木近处保留主干和主要枝条,远处逐步换成更少、更大的叶簇。所有层最后仍然合成到同一个自然场景里,但它们不再共享同一个细节等级。
2. 相机周围的种植网格
↡围绕相机维护的有限世界空间单元集合,每个单元保存该区域某一层的植物缓冲和材质数据把“无限世界”变成固定大小的工作集。每一层可以有自己的网格半径:草地的网格更近、更密,树木的网格更远、更稀。相机移动时,只有越过边界的单元需要回收或重新生成。
布植结果不能因为玩家转身就改变,否则远处地面会闪烁,联机玩家也会看到不同的植物位置。为此,每个单元的坐标可以参与一个确定性种子:同一个单元、同一层和同一版本的布植规则总能生成同一组候选点。这里的随机性只用来打散外观,不用来改变世界状态。
布植还有一个 CPU 预算问题。新单元可能需要收集相交多边形、过滤不适合的材质和坡度,再把合格区域转成候选点。把所有工作塞进一帧会造成镜头转向时的尖峰;更稳的做法是把单元放入队列,每帧只消耗固定数量的预算,并让网格边缘略大于可见范围,为生成留出时间。
3. 先筛选地面,再生成候选点
最直接的办法是在单元体积内反复向下发射射线,直到碰到足够多的地面点。它在简单地形上容易实现,但遇到只有一小块草地标记的单元时,失败尝试会大量浪费 CPU;有上下叠放的桥面或地形时,射线还可能选错层。
更稳定的流程是先收集与单元相交的地面多边形,再按材质和坡度过滤,最后把合格三角形扫描成候选布植点。候选点仍可以加一个受种子控制的平面偏移,打破严格重复的行列感,但偏移必须保持在三角形范围内。
对于实时系统,空间索引可以快速回答“这个单元可能碰到哪些多边形”,而不是每次从整个世界重查。这个步骤的核心边界很清楚:先决定哪里允许种植,再决定允许区域里具体种在哪里。
4. 草地:用 billboard 批量制造体积感
↡始终朝向相机的纹理四边形或片组,用很少的几何模拟草簇、叶簇等细碎形体不是一张贴在地上的图片。它保留一个世界位置,顶点 shader 再根据相机右向量和上向量,把四个角点展开成面向镜头的四边形。
一个网格单元中的草簇可以共用顶点和索引缓冲,一次提交绘制整批对象。每个顶点除了种植位置,还可以携带 corner = (-1, 0, +1) 一类的角点编码、尺寸、颜色和纹理坐标。若草簇实际适合三角形,甚至可以取消索引,直接使用 triangle soup,避免为不会复用的顶点维护索引缓存。
最小的展开逻辑可以写成下面这样;它的关键不是 API,而是“位置只存一次,角点在 GPU 展开”:
float2 corner = inCorner; // 每个角点的 -1、0 或 +1 编码
float3 world = inPlantPosition
+ corner.x * cameraRight * inSize.x
+ corner.y * cameraUp * inSize.y;
outPosition = mul(viewProjection, float4(world, 1.0));
outColor = inTint;
outUv = inAtlasUv;为了不让整片草看起来像复制粘贴,可以在布植时翻转 U 坐标,从纹理图集中选择不同草簇,并给顶点传递轻微色调。这个变化应该来自确定性种子,而不是每帧重新随机。
5. 无排序的远处淡出
↡用纹理 alpha 与噪声阈值逐像素丢弃片元,让对象看起来逐渐消失而不依赖透明排序把透明问题转换成裁剪问题。草纹理的 alpha 先乘上噪声,再根据对象到相机的距离调整阈值;阈值升高时,更多片元被丢弃,草簇就像被风吹散一样淡出。
这会牺牲一部分连续透明的平滑度,却避免了透明混合的排序要求。自然纹理本身边缘不规则,噪声带来的碎片化通常比硬切一整片四边形更不显眼。注意一次批处理往往只能共享一个阈值,所以阈值最好按网格单元的代表距离计算,或者把距离相近的单元分成更小批次。
6. 灌木和树木:把预算花在轮廓上
树木近处最值得保留的是树干、主枝和整体体积;二级枝条与叶片可以用叶簇片组补足。随着距离增加,↡根据观察距离替换不同复杂度模型的策略逐级减少几何,同时使用更大的叶簇纹理保持树冠轮廓。
树木的纹理最好合并成图集,让同一批次能覆盖多个叶簇和不同树种。图集中不同子图之间要留出边界,否则 mipmap 采样会把邻近叶片的颜色带进当前子图。对于不对称树冠,可以准备多个观察角度的远景图,再按相机与树的相对方向插值,避免单张 billboard 在侧面完全露馅。
7. 近似阴影和场景收束
↡从种植点向主光源发出的遮挡测试射线,只关心是否有阻挡而不求精确最近交点只回答一个视觉上足够有用的问题:这个草簇是否大致处在树根或树冠的阴影里?命中时把对象颜色向环境色移动,未命中时保留更多太阳光。它不追求每片叶子的精确阴影,而是用低成本的明暗线索让密集植物不再像同一张平面贴图。
用三条或五条起点略有偏移的射线,可以用命中比例近似软阴影。这个测试可以在布植时计算,也可以在太阳缓慢移动时周期性重算;如果系统已经有离线 lightmap,直接读取其中的遮挡信息可能更稳定。
最后,天空穹顶的高光区域和全屏 glow 可以把草叶、树枝与天空的关系粘合起来。后处理不是用来拯救错误的几何,而是给已经分好层、完成批处理和基础光照的场景加一层统一的光学气质;过强的 glow 只会让整个世界发白。
8. 动手实验:改一层,观察预算如何变化
先预测:把密度滑杆从 70% 拉到 100%,你预计变化最大的是“草的数量”还是“提交次数”?再切换到树木层,为什么同样的网格不会显示同样多的对象?
虚拟植物层实验
当前显示:草地 billboard。同一组确定性种植点可以换层、换密度,却不会随刷新漂移。
当前批次含 8 个可见样本。密度变化只改变确定性样本的截取范围,不会调用随机数。
实验使用固定的候选点表模拟布植器。实际引擎会从地面几何、材质和种子计算这些点;这里故意把生成结果固定下来,让你只观察层级、密度、风摆和遮挡近似四个因素。切换到树木层时,数量会少,但每个对象的轮廓预算更大;切换阴影时,中间区域会被调暗,而不会改变植物的生成位置。
小结
- 用分层管理不同尺度的自然细节和距离预算。
- 用相机跟随网格控制内存和每帧布植工作量。
- 用确定性种子保证镜头往返时的世界一致性。
- 用 billboard 批处理和 alpha-test dissolve 降低提交与排序成本。
- 用 LOD、shadow-feeler 和 glow 把剩余预算花在整体观感上。
练习
问题 1|设计种植网格 草地和树木都围绕相机生成。为什么不直接共用同一个网格半径?请分别写出两个层更合适的距离与更新策略。
问题 2|改写 Demo 把实验改成“只显示草地且关闭 shadow-feeler”,然后把密度调到 100%。如果帧时间变差,你会先拆小批次、减少对象,还是把每片草换成更复杂的网格?说明你的顺序。
问题 3|稳定性检查 同一个种植单元在玩家转身后出现了不同的草簇。列出至少两个可能原因,并给出一个可验证的修法。
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 场景分层
- 把草、杂物、灌木和树木拆成各自管理的对象集合,每层可以使用不同的距离和表示法。
- 种植网格
- 围绕相机维护的有限工作区域;单元进入范围时生成植物,离开范围时回收资源。
- billboard
- 始终面向相机的纹理片组,用少量几何制造草簇或叶簇的视觉体积。
- alpha-test dissolve
- 用 alpha 阈值丢弃纹理片元,使对象逐渐消失且不需要透明排序。
- level of detail(LOD)
- 根据距离替换不同复杂度的模型;远处保留轮廓,省掉看不清的细节。
- shadow-feeler
- 从种植点朝光源发出的简化遮挡射线,只判断是否被挡住。