1. 为什么要把SpeedTree Shader源码翻个底朝天
做UE4项目早晚会遇到一棵树,遇到树就绕不开SpeedTree。美术从SpeedTree Modeler导出的. st、. srt资源,在引擎里生成材质时,引擎会自动组合出一套Shader。这套Shader不是美术手动连线搭出来的,而是通过SpeedTree材质节点自动生成的,很多人用完了就算,从来没看过它背后的HLSL。我建议你找个项目不忙的下午,把它生成的源码打开看一眼,因为这棵树的渲染质量、性能开销、可以二次开发的所有入口,几乎都藏在顶点着色器和片元着色器的细节里。
SpeedTree Shader在UE4渲染链路里的位置很特殊。它同时承担了植被的智能风场模拟、近距离几何细节、远距离卡片替代、以及透光、阴影、半透明等一堆需求。引擎官方这套Shader虽然不是完全不可替代,但它写成了一种比较典型的主流光栅化植被方案。你看懂了它,再去调树叶的色相、控制风吹的强度、优化阴影锯齿,甚至把SpeedTree资源迁移到别的渲染器,思路都会清晰很多。
这篇文章适合谁?一类是刚接触UE4植被性能优化的TA,一类是准备二次封装SpeedTree节点或做自定义植被渲染的程序员,还有一类是纯好奇的图形学爱好者。我会把源码的阅读路径、关键计算逻辑、常见坑和调试方法一起说清楚。阅读之前你最好已经知道顶点着色器和片元着色器的大致分工,但即使你只做过材质蓝图,我这篇也会尽量把每个术语都拆开解释。
在看源码之前,要先认清一件事:UE4的SpeedTree Shader并不是一个简单的单一文件,它是根据你的材质节点参数、渲染特性、Lighting Mode、项目平台等组合出来的多个Shader变体。你直接在项目中搜SpeedTree生成的.hlsl,往往是一大段自动生成代码。如果一头扎进去,很容易绕晕。我的建议是先看它的逻辑骨架,再对照材质节点理解参数如何传到代码里。下面几个小节就是我实际阅读时采用的切分方式,按顺序走完一遍,基本能掌握全貌。
2. 顶点着色器源码核心拆解
2.1 顶点输入与模型空间变换
SpeedTree Shader的顶点输入部分,比普通静态网格多了一堆自定义数据。普通网格顶点可能只有Position、Normal、Tangent、UV这些,但SpeedTree的资源导进UE4之后,Build Commandlet会把树木的特殊属性烘焙到额外的顶点颜色通道和UV通道里。这部分在源码里通常表现为FVertexFactoryInput结构,看起来像这样(不同版本字段名略有差异,但含义一致):
struct FVertexFactoryInput { float4 Position : ATTRIBUTE0; float3 TangentX : ATTRIBUTE1; float3 TangentZ : ATTRIBUTE2; float2 TexCoord : ATTRIBUTE3; float2 LightMapUV : ATTRIBUTE4; float4 VertexColor : ATTRIBUTE5; float4 ExtraData : ATTRIBUTE6; uint InstanceId : ATTRIBUTE7; };其中VertexColor不是拿来当颜色用的,它表示了树各部位的AO、树叶的密度权重、树枝的朝向约束、风场影响区域,这些其实是SpeedTree的核心数据编码方式。源码里还没开始变换位置,会先有一大段GetSpeedTreeData之类的函数,把这些通道里的数据解压出来。你一旦在材质编辑器里勾选了Wind或Branch Seam开关,映射关系又会多出若干条分支。
模型空间变换本身并不复杂,无非是local vertex position乘上ViewProjection,但SpeedTree有一个特殊点是它的树木是曲面而非平面,顶点位置在建模阶段就会离树干轴线偏移。如果只拿普通网格的MVP矩阵去处理,会遇到树叶在远处摆动时“撕扯”变形的现象。所以源码里在变换之前,会先做一次基于风场和顶点朝向的位置重算,再把重算后的局部坐标放进世界矩阵。这个顺序其实已经告诉了你一个调优方向:当树被风吹得异常抖动时,应该先去检查顶点位置重算的逻辑,而不是直接去改物理学速度参数。
2.2 风场扭曲与LOD抖动
SpeedTree的风效果不是由CPU物理计算驱动的,而是完全靠顶点着色器里的数学函数模拟。源码中通常有一块名为Wind或Sway的代码分支,它接收一系列参数:风的全局方向、时间偏移、摆动频率、树叶团簇ID、以及顶点颜色里存储的AO权重。核心表达式类似这样:
float windTime = gWindTimeVector.x + View.GameTime * WindSpeed; float phase = dot(ExtraData.xyz, WindDirectionAndSpeed.xyz) + VertexColor.g * TreePhase; float3 offset = WindDirectionAndSpeed.xyz * sin(windTime * Frequency + phase) * (VertexColor.r * SwayAmount);注意这里dot不是随便写的,它的意思是把每个顶点的“风向相位”与风的方向做点积,不同的顶点朝向会产生不同的相位差,于是整棵树不是整体平移,而是产生层层交错、类似波浪的扭转效果。其中的VertexColor.g存的是该部位在树上的高度/层级,而VertexColor.r是这个地方受到风的强度的权重。叶子在树冠外侧权重大,内层权重小,这样风出来才有层次感。
LOD抖动是另一个重要逻辑。SpeedTree本身自带连续LOD,所以在两个LOD层级切换时,顶点位置的差异会导致视觉“弹跳”。UE4的Shader里针对这个做了专门的“Angle Sided LOD”处理,代码会读一个LODTransition参数,然后把位置的插值改成:
float lodFactor = LODTransition; position = lerp(NormalizedPosition, OriginalPosition, lodFactor);这里NormalizedPosition不是真正的单元归一化,而是美术在SpeedTree里导出的“标准LOD位置”。这个插值过程会尽量让两个LOD层级下的顶点共面,减少突跳。你看源码时如果看到LOD相关宏,千万别跳过,很多树在地面附近突然“闪”一下,就是这个处理没做对或参数没传进去。
2.3 自定义顶点数据解析
顶点数据里有一个经常被忽略的字段叫ExtraData,或者叫“物料系数”。UE4的SpeedTree材质自动生成器中,有三个选项会直接映射到该字段:Branch Seam(枝干接缝)、Subsurface(次表面散射强度)、Branch/Leaf Blend(枝与叶混合权重)。这三个值在片元阶段也会用到,但在顶点阶段主要影响顶点位置的偏移方向和遮挡强度。
举个例子,树叶的BranchSeam权重越大,顶点在风中摆动时越会往树枝末端方向倾斜;反之,树枝根部几乎不动。而朝向来决定摆动幅度的另一个维度。搜索源码时你先找BranchSeam或SPEEDTREE_SEAM相关宏,然后顺着它的分支找下去,会发现它把VertexColor的B通道当作一个“朝向屏蔽值”。如果美术把树叶模型建模成许多个独立小片,这个值通常能手动调成0.2~0.8之间,风吹时树叶和树枝之间的过渡就会自然很多。
这里需要特别提醒的是,引擎默认SpeedTree Shader只认它固定的顶点格式。如果你用第三方DCC工具手动修改了模型网格,却没有重新运行SpeedTree Import后处理,那么这些自定义通道数据可能会全部丢失,风场效果和接缝混合都会直接失效。你在源码里看到一堆ifdef判断,其实就是为了容忍“数据缺失”的情况。我自己的习惯是在查看源码前先在关卡里摆一棵树,用stat gpu看是否正常消耗,如果发现顶点数异常,就先怀疑这些自定义数据通道有问题,再去读代码。
3. 片元着色器源码与光照模型
3.1 叶子双面光照与透光
片元着色器是SpeedTree视觉成败的关键。普通材质默认是单面渲染,但树叶基本都是扁平的,不管是看正面还是透过缝隙看背面,颜色都要有区别。SpeedTree Shader里的做法是同时输出正面和背面的Albedo、Normal和ShadingModel,然后在渲染管道里用TwoSidedSign去判断当前像素是从哪一面看到的。
具体的代码逻辑往往是这样:
half3 albedoFront = Texture2DSample(Tex, Sampler, UV).rgb; half3 albedoBack = Texture2DSample(Tex, Sampler, UV + BackUVOffset).rgb; half3 albedoFinal = lerp(albedoFront, albedoBack, TwoSidedSign);TwoSidedSign不是普通的0/1变量,它在实际渲染时是1或-1,所以常见写法是lerp(albedoBack, albedoFront, TwoSidedSign * 0.5 + 0.5)。如果你直接用UE材质编辑器把sign节点接给混合节点,极容易搞反。这个细节很多人不看源码,就会怎么Tune都得不到想要的半透光效果。
透光效果还和次表面散射(Subsurface)息息相关。SpeedTree叶子薄,背后打光时整片叶子会点亮。UE4引擎内部的“本地次表面散射”和“Subsurface Profile”在这里会有一个交互,片元着色器输出SubsurfaceColor和Opacity后会交给Lighting模型处理。源码中会计算LeafSubsurface,通常来自纹理的绿色通道或顶点色的B通道。当这个值很高时,背光面的亮度会明显提高,正面又不至于整体泛白。
3.2 遮罩与混合贴图通道
SpeedTree的贴图通道安排非常紧凑。常见布局是:RGB存Albedo,A通道存Opacity;第二张纹理的R存Normal的X,G存Normal的Y,B存Subsurface,A存BranchSeam。为了节省采样器和带宽,Shader中大量使用SampleBias或者SampleLevel对第二张纹理做低mip采样,这样远处树木的绘制开销能明显降低。
当你打开源码看到float4 PackedTexture = ...;这种写法时,不要害怕,它其实是把树皮的A通道当成了“粗糙度+AO切换开关”,把叶子的B通道当成了“风场相位”。我在实际项目里曾经把树皮Albedo打错成了RGBA四通道,结果叶子的次表面强度忽高忽低。后来跟踪源码后才发现,引擎默认把Opacity通道混进OpacityMask,如果材质混合模式不是Masked,这一整套通道表都会乱套。
如果你想快速定位通道,建议先在材质编辑器里勾选“Debug”模式,把每个自定义输出通道输出到颜色看到直观变化。然后回头再看源码中对应的DEFERRED和FORWARD分支,你会发现Forward渲染下,这些通道会被分到GBuffer的具体哪几位里。
3.3 半透明阴影与阴影锥的边缘混叠
SpeedTree的阴影不是传统的不透明物体阴影。树叶会有很多细小缝隙,如果用纯不透明阴影,地面会变成一片黑斑,毫无生机。UE4在SpeedTree Shader中让片元着色器额外输出一个“着色率”或者“Opacity”值到阴影深度图,使得树叶阴影边缘呈现出一种半透明的“软边”效果。这个过程在不同引擎版本里叫法不一样,有的叫PerPixelShadowDepthBias,有的直接用ShadowOpacity。
源码中类似这样:
float shadowOpacity = smoothstep(0.3, 0.6, OpacityMask); OUT.ShadowDepthBias = max(ShadowBias, 0.0001); Out.SubstrateOutput->ShadowOpacity = shadowOpacity;这里的OpacityMask不是完全二值的。它在片元阶段会取纹理A通道中高于某个阈值的部分,并且做了一个dither抖动,避免阴影边缘出现硬边。你可以把阈值调低,让阴影范围变大,但这会导致树叶看起来比实际厚。我做植被项目时习惯把阴影阈值保持在0.4上下,再配合Contact Shadow使用,阴影的过渡最好看。
4. 材质节点到Shader变体的编译管线
4.1 SpeedTree材质节点的生成逻辑
当你在材质编辑器里新建一个材质,并把Use SpeedTree选项打开时,引擎并不会马上生成Shader。它会先遍历资源里的“SpeedTree数据对象”,提取叶片和树干的插槽信息,然后把输出引脚自动连接到Diffuse、Mask、Normal等通用引脚上,最后交给Shader编译管理器。源码里体现为庞大的FNodeCodeGenContext和FMaterialShaderMap构建过程。
一个特别容易引发混乱的地方是:材质编辑器里,你拖几个Texture Sample,放到“Base Color”和“Opacity”上,但真正被Shader读取的纹理绑定顺序,是在FSpeedTreeVertexFactory中按约定好的属性来赋值的。也就是说,表面上看你只连了三张贴图,但实际编译后会变成很多层CBuffer。如果你发现改动贴图槽位但材质没有变化,不要一味怀疑节点连线,可以看编译后的MaterialShaderInfo里有没有对应的Parameter列表。
4.2 变体数量失控怎么办
SpeedTree Shader包含大量#if动态分支,这会快速膨胀Shader变体数量。每添加一个材质特性,比如“双面光照”“风”“LOD抖动”“次表面”,编译时间就会大幅增加。移动平台上如果不做手动剔除,项目发布时很容易碰到Shader编译超时或堆内存爆掉的问题。
建议在源码分析后回头检查编译产物时,顺手在Project Settings - Shader Permutations里关掉用不到的变体。常见保留组合是:
- 主流平台:Forward shading + Lit + Masked + Wind + TwoSided
- 移动端:Mobile shading + Unlit + Masked + NoSubsurface
- MetaPass/SelfShadow:只保留Shadow Pass + DepthOnly
我在一个开放世界项目里,只优化了SpeedTree一个类型的Shader变体,整体编译时间就从三十分钟降到了十八分钟,打包体积也少了将近500MB。源码分析里看的是逻辑,但最终要落到项目配置上,这一步比调效果还重要。
4.3 源码分析工具链怎么搭
分析HLSL源码最忌讳的就是用记事本硬看。我用的组合是:
- 使用RenderDoc抓取一帧Foliage的DrawCall,然后在VS Input / Interpolator窗口里查看顶点数据的绑定和输出。
- 使用
r.ShaderDevelopmentMode=1,配合r.DumpShaderStats=1,在日志里输出每个Shader变体的统计。 - 用VS Code或者Notepad++打开引擎源码里的
.ush.usf文件,然后开启ShaderCompileWorker实时编译来验证修改。
只能在引擎里打开的实际Shader文件路径一般位于Engine/Shaders/Private/和Engine/Shaders/Public/目录,其中SpeedTreeCommon.ush是最核心的公共实现文件。你在Make工具或搜索功能中找不到的话,可以直接在项目根目录里全文搜索ComputeSpeedTreeWind这个函数名,基本就能定位到主逻辑。
5. 我实测过的SpeedTree Shader调试坑
5.1 风输入没有生效
这是我在项目里遇到最多的一个问题:模型、材质、风都开了,但树纹丝不动。排查时先看Shader里的Wind代码是否真正参与编译。常用方法是渲染帧并截断VS输出,查看顶点位置是否为常数。如果顶点位置没变,那就去检查材质节点里“Global Wind”是否勾选,以及剧情蓝图是否调用了SetWind接口。还有一个隐蔽点:SpeedTree的“Grass Wind”和“Tree Wind”是两组参数,很多人在全局风力系统里只设置了前者,结果树木没反应,而草却动得很欢。
另外一个常见原因是静态网格的“Mobility”被设置成了Static。UE4的SpeedTree风效果默认依赖Primitive的GlobalDistanceField/PerInstanceRandom数据,只有Movable或Stationary类型才能正常驱动。把移动性改成Movable后,常常立竿见影。
5.2 叶片颜色不对和背面发紫
如果你用的是Lit光照模式,但叶片背面呈现出大块蓝紫色,那多半是Normal方向被读反了。SpeedTree叶子通常会翻转法线,以做到双面光照都相对正常,所以片元着色器里会有Normal = normalize(lerp(NormalFront, -NormalFront, sign))这样的代码。如果你手动调整过Mesh的Scale为负数,Normal通道的镜像符号会被打乱,导致光照面反转。
第一次遇到时可以先看材质面板里的TwoSided是否是开启状态,再在材质里写一个VertexNormalWS的可视化节点输出看颜色。如果背面颜色依旧异常,就要去Shader里搜索FlipNormalSign或Sign相关的宏,把该标记从负值改成正值。
5.3 性能剖析与优化实测
SpeedTree Shader的开销大头通常在片元着色器的纹理采样上,尤其是树叶的正面和背面要采样两次Albedo,这使带宽压力翻倍。实测中,打开stat gpu会发现DrawCall虽然不多,但PixelShader的Duration到了一半以上。我降低开销的方法是把调试用的BackUVOffset改成采样一个LoD更高的Mipmap,让背面纹理在远处强制降采样;同时在材质面板里把DiffuseTexture的MipGen设置成“Sharpen”,有效减少闪烁并提高缓存命中率。
另外还要注意树叶的OpacityMask通常无法参与Early-Z优化。因为它在片元阶段才做clip,会造成光栅化后的无效像素继续执行很长时间。所以尽量不要让树的OpacityMask用到过多纹理通道,能不做的模糊就交给alpha-to-coverage,能省则省。
6. 参考源码后的二次开发思路
看完SpeedTree Shader,不用急着把它当黑盒。至少有三个方向可以做二次开发:
第一个是自定义风力输入。官方的风向和速度来自全局参数,但你可以通过CustomPrimitiveData或Material Parameter Collection把自定义的风向图、高度曲线传给Shader,模拟峡谷风或者城市楼宇间的回流。我在一个场景里就加了一张“风场高度图”,让树冠在不同高度有不同的摆动方向,效果比全局风干净很多。
第二个是定制叶子的动态颜色。通过在片元着色器中读取世界场景的天气参数,可以做成下雨时叶子变深,天晴时偏黄的过渡效果。直接在材质蓝图里也能做,但一旦变体多起来,Shader里的分支比蓝图更快,可控性也更强。
第三个是让树参与地面力场交互。比如踩到落叶时周围的树叶抖动,或者角色经过时树枝被推开。这类交互如果用物理骨骼做,开销太大;但利用SpeedTree顶点数据的“额外通道”,可以在Shader里加一个包围盒内风力脉冲,成本几乎为零。引擎本身没有开放这个功能,但我在源码基础上加了一组USE_EXTRA_WIND_FIELD宏,每棵树额外暴露一个ForceMap数组,实测下来很稳。
最后分享一个小技巧:分析SpeedTree Shader时,先忽略所有光源代码,把注意力集中在纹理寻址和数学函数上。很多植被顶点动画的本质都是基于时间变量做“正弦/余弦扰动”,明白这一点,你以后看任何树叶Shader,都会有种“原来如此”的感觉。希望这篇源码解析能帮你在植被渲染的路上少走点弯路。