草叶摆动时根部为何漂移:顶点风、根部遮罩与剔除边界
草叶已经能随风弯曲,镜头一转却出现两种比“摆得不自然”更严重的错误:根部像被拔出地面,摆幅最大时整片草又会突然消失。前者来自位移权重没有在根部归零,后者则是 CPU 仍用未变形网格的包围盒做剔除。正弦函数只能生成运动,不能替宿主决定哪些顶点可动,也不能更新 GPU 之外的可见性边界。
上一章把位置从对象空间变换到世界空间,并要求法线与观察方向在同一空间相遇。本章沿用这个顺序,在模型矩阵之前完成对象空间位移:网格高度天然适合作为根部遮罩,位移后的对象位置再进入既有世界变换。这样非均匀缩放仍由对象变换负责,风参数不会混入世界单位。

根部权重必须参与位移,而不是只参与着色
实验网格把根部到叶尖的权重存为 aRootWeight,值域为 [0,1]。顶点阶段用秒制时间、角频率与顶点高度形成相位,再把正弦位移乘以权重。根部权重为零时,无论时间和相位如何变化,横向偏移都严格为零:
#version 300 es
layout(location = 0) in vec2 aPosition;
layout(location = 1) in float aRootWeight;
uniform float uTimeSeconds;
uniform float uAmplitude;
uniform float uFrequency;
uniform float uPhaseScale;
uniform bool uBrokenRootMask;
out float vWeight;
void main() {
float weight = clamp(aRootWeight, 0.0, 1.0);
if (uBrokenRootMask) {
weight = max(weight, 0.25);
}
float phase = uTimeSeconds * uFrequency
+ aPosition.y * uPhaseScale;
float offsetX = sin(phase) * uAmplitude * weight;
vWeight = weight;
gl_Position = vec4(
aPosition.x + offsetX,
aPosition.y,
0.0,
1.0
);
}
uBrokenRootMask 只用于回归。它把最小权重抬到 0.25,使根部也获得四分之一振幅。同一网格、时间 0.5s、振幅 0.26 下,正确路径的根部像素质心为 148.71,错误路径移到 141.46。这不是颜色造成的视觉错觉,因为统计来自实际覆盖像素的位置;根部已经参与几何位移。
高度权重也不必等于模型坐标的原始 y。旗帜可以使用顶点色固定旗杆一侧,树枝可以从资源导入的弯曲权重读取,程序网格则可以按局部包围范围归一化。真正需要保持的是合同:权重零表示锚点,权重一表示允许完整振幅,超出范围的输入在进入位移前钳制。
位移选择对象空间还有一个实际原因:同一份草叶网格可以被放到不同世界位置并沿不同朝向实例化,根部权重和叶片高度仍保持资源自身的含义。若先把顶点变到世界空间,再直接沿世界 X 轴摆动,旋转后的草叶会无视自身朝向;若把世界风向变换回对象空间,则需要明确它使用方向变换而不是位置变换,不能携带平移分量。本次切片固定对象空间 X 为风向,只验证权重、相位和上界,不声称已经实现任意世界风场。
顶点位移还改变了表面切线方向。当前片元颜色只按权重渐变,不参与光照,所以没有伪造“法线仍然正确”的结论。接入受光材质时,可以从位移函数的导数近似弯曲后的切线并重建法线,也可以用相邻采样或资源提供的弯曲模型;直接沿用静止网格法线会让几何在摆动而高光不动。第 4 篇建立的法线矩阵合同仍然有效,但它只能把正确的对象空间法线送到世界空间,不能自动修复位移前已经过期的法线。

振幅上限同时约束画面和 CPU 剔除
JavaScript 持有效果时间与艺术参数,并在 Draw 前把振幅限制在对象空间 0.38 以内。非有限振幅退化为零,负频率同样退化为零;Shader 不接收能够产生无限值或不可预期包围范围的输入:
export function normalizeWind(amplitude, frequency) {
return {
amplitude: Number.isFinite(amplitude)
? Math.min(Math.max(amplitude, 0), 0.38)
: 0,
frequency: Number.isFinite(frequency) && frequency >= 0
? Math.min(frequency, 8)
: 0,
};
}
固定样本中,振幅 0.26 的覆盖宽度为 75 像素;输入 0.80 被钳制到 0.38 后,覆盖宽度增至 88 像素。这组数据说明一个容易遗漏的边界:顶点 Shader 改变的是 GPU 最终位置,但许多引擎在提交 Draw 前已经用 CPU 侧 Bounds 做完视锥剔除。若 Bounds 仍只覆盖静止网格,摆动部分越出旧边界时,整个对象可能被提前剔除。
安全扩张量可以从位移公式直接得到。这里横向最大偏移不超过 maxWeight * amplitude,而 maxWeight <= 1,所以对象空间包围盒在 X 两侧各扩张 0.38 即可覆盖当前合同。若风向扩展到二维或三维,应按各轴最大分量扩张;若位移再叠加交互弯曲、噪声或顶点动画,则必须累加可证明的上界,或改由更保守的动态 Bounds 管理。只增大 Shader 振幅而不更新这一上界,会让参数合法但渲染提交仍然错误。
这里的上界是对象空间长度,进入世界后还会受到模型缩放影响。引擎若保存对象空间 Bounds,可以先扩张再让现有变换生成世界包围盒;若系统直接维护世界 AABB,则必须用模型线性部分把各轴扩张量保守投影过去。只读取某一个缩放分量会在旋转和非均匀缩放组合下低估范围。相位和频率不会改变位移的绝对上界,因此不用逐帧重算 Bounds;只有振幅上限、风向维度或新的位移项发生合同变化时,才需要重建这一元数据。
时间所有权继续沿用第二篇的可暂停秒制时钟。暂停时 uTimeSeconds 保持不变,草叶停在当前相位;恢复后从原相位继续,而不是用墙钟跳到未来。不同实例可以增加稳定的对象相位偏移,避免整片植被同步摆动,但该偏移应来自可复现的实例属性,而不是每帧随机数。否则相同时间和状态无法产生相同顶点位置,截图、回放与故障复现都会失去可比输入。

像素质心把“会动”和“根不动”分开验证
实验入口位于 .tmp/game-shader-webgl-lab/chapters/05-vertex-wind/app.js。真实 Chrome 编译并链接 GLSL ES 3.00,绘制 1296 个顶点后以 gl.readPixels 统计同一网格在四个时间点的覆盖区域:
$ python3 $WORK_DIR/.tmp/daily-labs/2026-08-10-1930/run_lab.py
exit_code=0
pass=10 fail=0 skip=0 duration=12.317s
motion_centroid_x=[149.50,158.13,155.72,147.61]
root_centroid_x=[149.50,148.71,149.65,150.43]
broken_root_centroid_x=141.46
width_pixels=75 clamped_amplitude_width_pixels=88
整体质心随时间变化,证明位移确实进入绘制;根部取样区域的质心跨度只有 1.72 像素,来自首排三角形中非零权重部分的覆盖变化,而权重为零的底边顶点保持固定。错误权重产生超过 7 像素的偏移,回归信号与正确路径明确分离。
这些像素只证明固定网格、固定分辨率下的几何行为,不是性能测试。当前顶点路径增加一次正弦计算,没有纹理采样,也没有测量 GPU 时间。真实草场还会涉及实例相位、LOD、阴影、法线随弯曲更新和移动平台精度;它们不能从本次单片网格结果外推。
本章实际解决的是顶点风的两个提交边界:资源或程序网格拥有根部权重,JavaScript 拥有有限的时间与振幅,Shader 只计算当前 Draw 的对象空间位移;CPU 侧则必须按同一振幅上界扩张剔除范围。下一章做双 Pass 描边时,会复用“位移后位置才是当前几何”的结论:外扩 Pass 若仍读取静止顶点,摆动表面与轮廓就会分离。