
前阵子项目里要做一大片森林场景美术把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风偏移角度基准影响该区域树枝的默认弯曲程度。LodPhaseLOD切换时的相位偏移不同区域的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叶片相关属性存叶片的朝向轴和前后偏移。LODDataLOD相位和淡入淡出权重。这些属性由导入器从.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与BillboardShader如何配合引擎做分支切换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_BILLBOARDBillboard LOD时关闭高级风物理。遇到移植到低端机的场景我会直接关闭SPEEDTREE_ENABLE_WIND的龙卷风级高频细节保留一档低频全局风性能立刻有显著改善视觉损失也没那么大。6. 不用源码也能还原材质编辑器里的SpeedTree重建方案6.1 从源码到材质节点的映射不是所有人都有条件改引擎源码但材质图里完全可以还原大部分功能。UE4材质编辑器自带的SpeedTreeWind节点其实就是把引擎里的风算法暴露到节点层。还原方案的核心是四件事材质域设为Masked材质模型设为Subsurface开启TwoSided。世界位置偏移接入SpeedTreeWind节点输出风向量和风力大小通过参数暴露。Opacity Mask使用叶片的Alpha通道配合Noise噪声做抖动裁剪。在材质图里加入多层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参数高效得多。做植被场景时我习惯先调好这个全局比例找到手感后再把精确值落到各材质参数里。