☰
UE4 SpeedTree Shader源码剖析:从风动到LOD的植被渲染实践
2026/10/5 6:13:33 网站建设 项目流程

前阵子项目里要做一大片森林场景,美术把SpeedTree生成的树模型导入UE4后,发现风动效果完全不对:叶子是整体平移的,树冠不会分层摇摆,远处的LOD切换还疯狂闪烁。没办法,只能去啃UE4的SpeedTree Shader源码。啃完之后我最大的感受是:这棵"树"在引擎里根本不是普通模型,而是一整套专门服务于植被渲染的着色器体系,从顶点风的叠加到片元次表面散射,每一层都有讲究。这篇文章把我拆解的源码逻辑、核心参数和实战中踩过的坑都整理出来,给打算深入调植被效果的同学做个参考。

1. 为什么树在引擎里不能当普通模型渲染

1.1 SpeedTree Shader存在的根本原因

如果你把一棵SpeedTree树导出成FBX再作为StaticMesh放进UE4,效果一定不对。原因不在于模型精度,而在于SpeedTree导出数据里藏了大量普通静态网格没有的"信息通道"。

SpeedTree生成的树,本质上是一个高度优化的多面片系统:树干是有厚度的柱体,分支是逐级细化的管状几何,而树叶是成百上千个带Alpha贴图的小面片。这些面片在模型空间中的排列非常依赖"锚点"概念——每一根树枝、每一片叶子都有一个基准点,风一吹,几何体是绕着锚点旋转的,而不是整体平移。普通PBR材质的顶点着色器根本没有这些数据,所以模型动起来就像被一只大手直接推着走。

UE4之所以为SpeedTree单开了一条Shader管线,就是因为这套数据需要专门的VertexFactory来解析。顶点色通道里存的不是颜色,而是锚点权重、风的方向权重、LOD相位等控制量;UV不是简单贴图坐标,有的通道还负责叶片朝向和Billboard的压缩信息。这些数据如果不走专用的着色器入口,GPU拿到后根本不知道该怎么解释。

1.2 UE4对SpeedTree的双路径处理

UE4处理SpeedTree模型有两种主要方式:一种是通过FSpeedTreeVertexFactory和FSpeedTreeMaterialInterface组成的专用可编程管线,材质Domain走MD_Surface,但VertexFactory被强制指定为SpeedTree专用;另一种是把模型作为普通StaticMesh导入,靠材质节点里的SpeedTreeWind节点去模拟风效果。

前者是官方导入器生成的资产,.st文件导入后自动配套专用Shader,能拿到最完整的风数据、LOD相位和Billboard支持。后者是给美术用的兜底方案,顶点数据已经烘焙到了静态网格的通道里,材质节点再还原一部分风逻辑。

我刚接触时犯过一个错误:用FBX格式导入SpeedTree导出的树,然后在材质里连了SpeedTreeWind节点,结果风参数完全不生效。后来查到原因——FBX导入后静态网格的顶点Factory是FLocalVertexFactory,它根本没有把SpeedTree风数据绑定到着色器入口,材质节点连了也读不到那些通道。所以想正经调风动、LOD闪烁和透光,第一步必须是保证模型通过官方导入器进UE4,这是后面所有源码分析的前提。

2. 风系统源码剖析:FSpeedTreeWind与五层风力叠加

2.1 几组核心参数结构

UE4关于SpeedTree风的数据结构,核心在Engine\Source\Runtime\Engine\Public\SpeedTreeWind.h。这个文件定义的结构体非常多,但主干其实是一个树状的参数集合:

struct FSpeedTreeWind { struct FSpeedTreeWindGroup { FSpeedTreeWindBranch Branch; FSpeedTreeWindBranch2 Branch2; }; FSpeedTreeWindGlobal Global; FSpeedTreeWindRipple Ripple; FSpeedTreeWindGroup Group[10]; FSpeedTreeWindBranch Branch; FSpeedTreeWindBranch2 Branch2; float Gust; float Falloff; float Strength; float4 Direction; };

这里看起来有点混乱:既有全局的Global和Ripple,又有一组Group数组,每个Group里还嵌套了Branch和Branch2,最后外面又有一层Branch和Branch2。实际含义是SpeedTree允许把树的各个部位划分成多个组(Group),每组拥有独立的风响应曲线,用来区分树冠上层、下层、迎风侧、背风侧的摆动差异。

FSpeedTreeWindBranch是其中最关键的一组参数:

struct FSpeedTreeWindBranch { float WindOffset; float LodPhase; float Dampen; float Gust; float Ripple; float RippleTime; };
  • WindOffset:风偏移角度基准,影响该区域树枝的默认弯曲程度。
  • LodPhase:LOD切换时的相位偏移,不同区域的LOD抖动相位不同,切LOD时不会所有叶子同时闪。
  • Dampen:阻尼系数,值越大该区域越不容易被风吹动。
  • Gust:该区域对阵风脉冲的响应强度。
  • Ripple和RippleTime:控制风的波纹频率和传播时间。

还有FSpeedTreeWindRipple结构体专门描述涟漪风的传播特征,FSpeedTreeWindGlobal定义全局风的基准高度、距离衰减等参数。理解这些字段,后面看Shader代码时就豁然开朗了。

2.2 风参数如何绑定到Shader

这些结构体本身存在CPU端,引擎会在每帧更新参数,然后通过一个SpeedTreeDataBuffer上传到GPU。顶点着色器读取的不再是单独的Uniform变量,而是从缓冲区按偏移量取数。

在SpeedTreeCommon.ush里,解析风数据的函数大致长这样(以下代码按4.27版本思路整理,去掉了部分编译宏,保留了主干逻辑):

float3 CalcWind(float3 vModelPos, float3 vModelDir, float fTime, uint WindDataOffset, float3 vWindDir, float fWindStrength) { // 从顶点属性中解析风速控制量 float fBranchAt = g_SpeedTreeData.Load(WindDataOffset + 0); float fBranch2At = g_SpeedTreeData.Load(WindDataOffset + 1); float fRippleAt = g_SpeedTreeData.Load(WindDataOffset + 2); float fGlobalAt = g_SpeedTreeData.Load(WindDataOffset + 3); // 全局风 float fGlobalWind = CalcGlobalWind(fTime, vModelPos); // 分支风 float fBranchWind = CalcBranchWind(fTime, vModelPos, vModelDir); // 涟漪风 float fRippleWind = CalcRippleWind(fTime, vModelPos); // 阵风脉冲 float fGust = CalcGust(fTime); return fGlobalWind * fGlobalAt + fBranchWind * fBranchAt + fRippleWind * fRippleAt + fGust * ...; }

这里的g_SpeedTreeData是ByteAddressBuffer,里面存了整个风参数块。每次模型用不同材质变体编译时,编译器根据宏开关决定读取哪些分量,避免所有情况下都做全量计算。

2.3 树枝、树叶、树冠的差异化响应

SpeedTree风系统最巧妙的一点在于不同部位采用不同的运动学模型:

  • 树干的弯曲:本质是一个低频旋转,绕着树干底部的锚点做整体偏摆。参数主要由Global风控制,频率很低,动静不能太大,否则整棵树看起来像橡皮管。
  • 大树枝的摆动:绕各自分叉点做旋转,带有一定的相位差。因为树冠上下层风速和环境遮挡不同,所以LodPhase和WindOffset在这里起作用。
  • 细枝和叶片的抖动:这是高频分量,由Ripple涟漪风控制,波形沿着枝干方向传播,类似麦浪那种波浪推进感。叶片朝向会影响抖动方向,所以顶点着色器必须读取叶片的朝向向量。
  • 整棵树的阵风响应:Gust是一个独立的脉冲函数,不是简单的正弦,而是带有Attack和Release的非对称曲线,模拟真实阵风"突然吹来然后慢慢平息"的感受。

这个设计思路非常值得做程序化动画的人借鉴:不要试图用一个正弦函数搞定所有植物动态,而应该把运动拆成低频大位移和高频小抖动两个通道,分别拟合不同的物理现象。

3. 顶点Shader拆解:一棵树在GPU里如何"呼吸"

3.1 顶点数据流与WindData缓冲

SpeedTree专用顶点工厂和普通顶点工厂最大的区别,在于输入布局里多了一组自定义属性。在FSpeedTreeVertexFactory的声明中,顶点输入除了常见的Position、TangentX、TangentZ、TexCoord之外,还包含:

  • WindData:一组float4,里面压缩了风锚点、风向权重、风速权重等参数。
  • LeafData:叶片相关属性,存叶片的朝向轴和前后偏移。
  • LODData:LOD相位和淡入淡出权重。

这些属性由导入器从.st文件中解析出来并写入顶点缓冲。材质蓝图里若能看到SpeedTreeWind节点,说明该网格的VertexFactory已经具备读取这些属性的能力。

3.2 风计算主函数的一次完整走读

在SpeedTreeVertexFactory.ush中,GetSpeedTreeVertexPosition函数负责最终输出顶点裁剪空间位置。简化后的核心流程如下:

float3 Wind(float3 vPos, float3 vDir, float fTime) { // 读取该顶点的风控制量 float fBranchAt = WindData.x; float fBranch2At = WindData.y; float fRippleAt = WindData.z; float fGlobalAt = WindData.w; float3 vWindDir = normalize(WindDirection.xyz); // 1. 计算垂直方向衰减,树越高,树冠处风速越大 float fHeightFactor = lerp(1.0, 0.0, saturate(vPos.z * GlobalHeightDampen)); // 2. 全局风:低频摇摆,绕模型原点旋转 float fGlobalAngle = sin(fTime * GlobalFrequency) * GlobalStrength; float3 vGlobalOffset = vPos - vPivot; vGlobalOffset = RotateAroundAxis(vGlobalOffset, vWindDir, fGlobalAngle * fHeightFactor); // 3. 分支风:绕树枝锚点旋转,带相位差 float fBranchAngle = sin(fTime * BranchFrequency + LodPhase.x) * BranchStrength; float3 vBranchOffset = vPos - vBranchPivot; vBranchOffset = RotateAroundAxis(vBranchOffset, vBranchDir, fBranchAngle * fBranchAt); // 4. 涟漪风:沿枝干方向传播的波 float fWave = sin(fTime * RipplePeriod - dot(vPos, vBranchDir) * RippleLength); float3 vRippleOffset = vWindDir * fWave * RippleHeight * fRippleAt; return vGlobalOffset + vBranchOffset + vRippleOffset; }

这段逻辑的重点在于:风位移分为三部分,每部分绕不同的轴旋转,最后叠加。树冠的叶片位置被各种旋转叠加后会形成非常自然的"摇摆轨迹",而且不同高度、不同叶片的相位差会产生视觉上的混乱感,反而接近真实树木。

我看源码时一直在琢磨为什么全局风旋转轴是风方向而不是局部Y轴,后来想明白了:全局风不只是让树左右摇,还要让树在风中整体前倾。旋转轴取风向和垂直轴的叉积再叉积,实际是让树绕垂直于风向的水平轴旋转,这样树的摆动方向和受力方向才是匹配的。

3.3 法线、切线与细节层次的联动

顶点位置变了,法线切线也不能偷懒。如果只改位置不改法线,光照在风动下会出现明显的"塑料感"——明明几何体动了,高光却纹丝不动。UE4在SpeedTree顶点工厂里同时输出旋转后的法线。

float3 vTangentX = TransformTangentToWorld(TangentX.xyz, LocalToWorld); float3 vTangentZ = TransformTangentToWorld(TangentZ.xyz, LocalToWorld); float3 vNormal = cross(vTangentZ, vTangentX) * TangentZ.w; vTangentZ = normalize(lerp(vTangentZ, CalcWindRotatedNormal(vTangentZ, fTime), fLeafNormalWeight));

注意这里的fLeafNormalWeight——有些SpeedTree模型允许叶子在风动时保持"面向太阳"的姿态,但法线不跟着动太多,否则实时高光会在叶片上疯狂跳跃。这个参数是美术在SpeedTree里导出的,决定"叶子到底跟着几何体晃还是只晃几何体不晃法线"。

在UE4默认材质节点图里,SpeedTreeWind节点输出的是一个float3,通常连到World Position Offset。如果你想让法线也跟着风动走,需要额外用自定义节点或写一个PostProcess的WorldNormalVector处理,默认情况下引擎只处理位置偏移,法线保持不变。这也是很多新手植被效果"动作很猛但光照死板"的根源。

4. 片元Shader的植物质感:次表面散射、视差与透明度

4.1 次表面散射:树叶透光的实现逻辑

植物叶片是半透明薄片,光线穿透后会散射成柔和的绿色。UE4的PBR管线默认假设物体表面是"不透明"的,叶片的高光强度和背光表现都不对。SpeedTree专用材质把ShadingModel设为MSM_Subsurface或MSM_TwoSidedFoliage,然后在片元着色器里对透射光做特殊处理。

在ShadingModels.ush的SubsurfaceShading中,叶片透射量主要由两个参数控制:

  • SubsurfaceColor:透射颜色,叶子一般填一个偏亮的黄绿色。
  • Opacity:作为透射面积权重,Alpha越大,透光越强。

实际计算时,片元Shader会额外采样一次阴影贴图,并通过BackLitDirection判断光源方向。光源方向和法线方向相反的那一面,会累加一层散射光。之所以叫"薄壁近似",是因为真实次表面散射需要求解辐射传输方程,实时渲染做不到,所以用"背面亮度 = 正片透过光量"来模拟。

这个模型在树冠上的视觉贡献非常大:太阳在树顶时,从下方看树冠内层是亮的,而不是黑的。如果把ShadingModel改成普通DefaultLit,内层树叶会变成一片死黑,层次感瞬间消失。

4.2 视差贴图与高度偏移

SpeedTree的树皮通常不只有一张Diffuse,还会配合法线贴图和高度贴图雕刻沟壑。但树皮是圆柱面,普通UV映射在侧面拉伸严重,视差效果容易出错。UE4的SpeedTree Shader里保留了一个可选的视差功能:在片元着色器里采样高度图,根据视线方向在切线空间做偏移,重新采样Diffuse和Normal。

float2 ParallaxUV = UV + ViewDirTS.xy * HeightMapValue * ParallaxScale;

这个偏移量很小,通常控制在0.02到0.05之间,太大就会有明显的边缘撕裂。源码里的关键点在于:视差偏移必须在采样两层纹理之前完成,否则把偏移后的UV和未偏移的UV混合会产生"两张皮"的撕裂感。

有些SpeedTree材质还会在树皮边缘叠加一层"树皮边缘微光"效果,本质是根据世界法线和视线夹角做一个Fresnel,这不算源码里的核心功能,但用了之后树皮在逆光下会出现一圈浅色描边,对塑造树干体积感很有帮助。

4.3 双面渲染与Alpha裁剪

树叶面片都是法线朝外的单面几何,但叶片又薄又小,观察角度稍微倾斜就会出现"单面消失"的问题。SpeedTree材质必然开启TwoSided渲染,并设置TwoSidedSign来翻转背面法线,确保叶片两面光照正确。

Alpha处理是植被渲染里最纠结的部分。真实的树叶边缘是不规则的,但Masked裁剪会产生明显的锯齿边缘;Translucent混合又会导致深度排序问题和性能飙升。SpeedTree的默认做法是:

  • 叶片Diffuse的Alpha通道存储叶脉形状。
  • 片元着色器里使用OpacityMask,阈值由材质实例控制。
  • 为了消除锯齿,UE4建议开启Dithered Opacity Mask,用Bayer矩阵做抖动裁剪。

抖动裁剪在远距离LOD切换时尤其有用。你会发现引擎在做SpeedTree LOD时正好也用抖动过渡,两者叠加后,只要LOD相位设置正确,几乎看不出换模的瞬间。

5. LOD与Billboard:Shader如何配合引擎做分支切换

5.1 几何LOD与Shader的变体切换

SpeedTree导出的LOD不是简单的顶点数递减,而是分层级的"枝条替换":树干保持不变,大树枝从3D模型逐渐塌缩成面片,最终全部塌缩成Billboard。所以SpeedTree LOD切换不能靠引擎默认的ScreenSize自动切,而是引擎读到模型的LODInfo后,利用顶点着色器里的LODPhase做Dither过渡。

Shader源码里对应的逻辑是:在顶点Shader输出前,根据该顶点所属的LOD级别取一个相位值,片元Shader再通过该相位做渐变裁剪。不同顶点拥有不同相位,所以切换时是随机散点淡出,而不是整棵树同步变淡。

5.2 Billboard朝向修正的Shader实现

当树距离摄像机足够远,会切换成全Billboard模式,用一块十字面片替代整棵树。Billboard的顶点Shader输入里存了压缩的"朝向轴"数据,每次计算时根据视角方向把两个面片旋转到分别面向相机。

float3 vRight = normalize(cross(ViewDir, WorldUp)); float3 vUp = cross(vRight, ViewDir); // 根据UV x分量选择左右朝向,y分量控制高度 float3 vBillboardPos = vPos + vRight * UV.x * BillboardWidth + vUp * UV.y * BillboardHeight;

这个逻辑看起来简单,处理好有两个前提:一是Billboard的UV必须提前烘焙成"以中心为原点、宽度高度为1"的规格化范围;二是模型的锚点必须准确地落在树干根部,否则切换Billboard时树会突然跳起来或扎进地面。SpeedTree导出器里有"Billboard锚点"选项,很多美术会忽略。

5.3 每个LOD的材质复杂度和成本预算

SpeedTree在UE4的材质编辑器里有一套分支控制逻辑,比如同一份材质在不同LOD下可以跳过某些指令,靠的是引擎的FeatureLevelSwitch和QualitySwitch节点。但源码层面更底层的削减机制是VertexFactory的Compile宏。

在SpeedTreeVertexFactory.ush里,编译开关有:

  • SPEEDTREE_ENABLE_WIND:关闭后风计算整体跳过,常用于极小面片或者性能敏感的移动端。
  • SPEEDTREE_ENABLE_LEAF:叶片相关数据不足时跳过叶片分支。
  • SPEEDTREE_ENABLE_BILLBOARD:Billboard LOD时关闭高级风物理。

遇到移植到低端机的场景,我会直接关闭SPEEDTREE_ENABLE_WIND的龙卷风级高频细节,保留一档低频全局风,性能立刻有显著改善,视觉损失也没那么大。

6. 不用源码也能还原:材质编辑器里的SpeedTree重建方案

6.1 从源码到材质节点的映射

不是所有人都有条件改引擎源码,但材质图里完全可以还原大部分功能。UE4材质编辑器自带的SpeedTreeWind节点,其实就是把引擎里的风算法暴露到节点层。

还原方案的核心是四件事:

  1. 材质域设为Masked,材质模型设为Subsurface,开启TwoSided。
  2. 世界位置偏移接入SpeedTreeWind节点输出,风向量和风力大小通过参数暴露。
  3. Opacity Mask使用叶片的Alpha通道,配合Noise噪声做抖动裁剪。
  4. 在材质图里加入多层UV混合采样,模拟树皮和叶片的不同表面细节。

6.2 关键参数接口打通

SpeedTreeWind节点的输入端很多人填不对,最容易被坑的是WindDirection。它需要的是世界空间的风向向量,而且必须是归一化的。有些同学直接把Location连过去了,风就会疯掉。

另一个容易被忽略的点:Stiffness(刚度)参数。它控制的是风在整棵树上的衰减权重,实际等价于FSpeedTreeWind里的Dampen。如果发现树冠和树干以相同幅度摆动,多半是这个值给得太小。

6.3 可视化调试技巧

每次调风参数调到怀疑人生时,最快的定位方法是把中间量可视化。我一般临时创建一个Debug材质,把下面的值输出到Emissive通道:

  • 顶点色R通道:锚点权重,查看风锚点分布是否正确。
  • 风速权重:确认叶片高频区域是否正确分配。
  • 固定风向下的位移强度:直接输出风带来的PositionOffset长度。

这样一帧画面就能看出风数据是否真正生效、哪里权重过高、哪里的锚点没有正确标记。做完调试再切回正式材质。

7. 实战踩坑与性能优化记录

7.1 风没生效的排查链路

这里根据我排查过的几个真实案例,整理一条完整的排查链路。你可以按顺序检查:

排查项说明
网格是否通过官方导入器导入FBX导入的模型不走SpeedTree VertexFactory,风数据读不到
材质是否使用了SpeedTreeWind节点如果没连节点,即使数据有也不会动
顶点色通道是否保留某些DCC工具导出时会清空顶点色,风权重直接丢失
是否单独覆盖了风向如果蓝图或材质里覆盖了风向且值为0,风力再大也没用
是否开启了World Position Offset材质里WPO开关如果被平台特性关掉,风动整体失效

其中最容易踩的是第四项。项目里从主场景复制出一棵树做测试,忘了主Level里有个全局的材质参数覆盖,风向设成0,结果怎么调都不动,白白排查了半小时。

7.2 材质指令数超预算与Shader编译变体

SpeedTree材质默认是全功能版本,指令数普遍偏高,尤其在开启视差、SSS、杂乱细节贴图后。我优化时做过一个轻量版,大概省了40%指令:

  • 关闭视差功能:树皮视差本来在远景也看不出。
  • 关闭叶子背光散射:只在专门的"透光测试"PIE场景里使用。
  • 用Dithered Opacity替代更贵的Alpha到覆盖率(AlphaToCoverage),移动端友好很多。

每次改动后记得用Shader Complexity视图查看指令数分布,别凭感觉判断。

7.3 移动端与PC端的差异化策略

移动端的浮点精度比PC低很多。SpeedTree的风计算里大量使用了正弦、归一化和矩阵旋转,在低精度的half浮点下会产生明显抖动。我的做法是:

  • 在材质Quality Switch节点里分别为PC和Mobile设置不同参数。
  • Mobile版关闭高频Ripple风,只保留全局风和分支风。
  • 叶片抖动幅度降低30%到50%,肉眼基本察觉不到,但GPU压力明显下降。

还有一个移动端专属坑:某些安卓设备不支持ByteAddressBuffer,如果直接打开SpeedTree官方材质会花屏。遇到这种设备,用引擎提供的MobileFallback路径或者压缩到Texture缓冲方案。

最后再分享一个小技巧:UE4在编辑器里有一个隐藏命令r.SpeedTree.WindSpeedScale,可以全局放大缩小风速度,用来快速对比不同风速下的动态效果,比逐个改材质Instance参数高效得多。做植被场景时我习惯先调好这个全局比例,找到手感后再把精确值落到各材质参数里。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询