ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity交互式雪地效果实现:RenderTexture高度场与Shader顶点位移

Unity交互式雪地效果实现:RenderTexture高度场与Shader顶点位移 起初是角色在雪地里走了一圈雪面干净得像被人连夜扫过特别出戏。老板丢来一句“雪地上总得留下点脚印吧。”我第一反应是去商店找现成资源结果要么是几万面片的模拟雪地要么是踩上去只变个颜色的贴花。后来自己摸索出一套成功率很高的做法用一张RenderTexture当“雪地画布”把脚印实时画上去再由Shader做顶点位移把凹陷呈现出来。这就是你搜索时看到的“交互式雪地效果”。这篇文章把整套实现思路、Shader细节、交互接入和实测踩坑都记录下来适合已经有点Shader基础、但第一次尝试交互着色效果的人参考。1. 交互式雪地的路线选型为什么高度场RT是通用解很多人在知乎或B站搜“Unity雪地”时会看到一堆方案每个都看着很酷但真正放进自己的项目里就容易翻车。我先把主流路线摆出来讲清楚各自定位再说为什么最后选了RTShader置换这条路。1.1 常见实现思路横向对比市面上常见的交互式雪地做法大致有这几种方案几何凹陷性能表现足迹持久与恢复实现难度适合平台粒子物理模拟真实差物体一多就扛不住很难做持续痕迹高PC/主机CPU端直接改Mesh/Terrain真实大范围场景性能差中等中小范围实验贴花Decal投影假凹陷最好好低移动端/低配机RT高度场Shader置换真凹陷良好好易于恢复中PC/中高端移动粒子物理模拟适合做“踩下去的瞬间雪花飞溅”但很难保留一条持续的足迹路径。CPU端直接改Mesh或Terrain Heightmap在小面积里很直观可一旦雪地范围来到50米x50米要表现清晰脚印就必须提高顶点密度再叠加碰撞、法线、阴影同步CPU每帧要做的事情太多跑起来很容易卡。贴花方案在移动端非常友好用Decal Projector把鞋印、划痕投到雪地表面只影响颜色和法线。玩家从斜上方看过去配合变暗和边缘亮色也能骗过眼睛。但它本质没有几何下陷角色站在坑里时侧面会穿帮而且多个贴花互相叠加时管理起来麻烦。RT高度场方案的核心思想是用一张RenderTexture记录整个雪地表面的“变形量”角色走过时在RT上画几笔雪地Shader每帧采样这张图把对应位置的顶点往下压。因为绘制动作发生在GPU上CPU只需要算几个坐标性能模型非常干净。这也是我最终采用它的原因。1.2 RTShader置换的代价与适用边界这个方案当然不是免费的它有三个硬性前置条件第一雪地Mesh必须足够密。顶点是“像素”的载体如果网格太稀疏脚印看起来就是几个孤立的下陷点。要么在PC上开曲面细分Tessellation动态增加顶点要么直接把雪地做成高密度网格。第二需要额外维护RenderTexture。分辨率、格式、双缓冲、衰减恢复这些是一套标准流程代码量不大但每一步都有讲究。第三阴影要专门适配。顶点被Shader修改之后ShadowCaster Pass如果不同步修改阴影和网格就对不上出现“人影踩在平面雪上”的诡异效果。如果你做的是移动端超级低配的休闲游戏我不建议直接上几何置换贴花方案更合适。如果你是在PC、独立游戏、数字孪生或者虚拟展厅项目里RT高度场这套方案是目前性价比最高的预算可控效果上限很高。2. 雪地画布RenderTexture的创建、衰减和双缓冲确定方案后第一件事是把“画布”搭起来。这个画布就是一张RenderTexture它记录了雪地上每个位置被踩下去多少。后续所有交互源包括角色脚印、滚动物体、鼠标画笔都是在往这张画布上“画画”。2.1 创建一块支持浮点精度的RT我在项目里用的RT规格是R16G16B16A16_Float即使目前只用到R通道。为什么不直接R8或者R16因为雪地下陷深度需要连续的浮点数值特别是做长周期衰减恢复时R8只有256级精度脚印淡出过程会出现肉眼可见的断层。private RenderTexture CreateFieldRT() { RenderTexture rt new RenderTexture(_resolution, _resolution, 0, RenderTextureFormat.R16G16B16A16_Float); rt.name SnowHeight; rt.wrapMode TextureWrapMode.Clamp; rt.filterMode FilterMode.Bilinear; rt.Create(); return rt; }两个细节必须注意wrapMode必须用Clamp。Shader采样RT时如果UV超出[0,1]Repeat模式会把雪地另一头的痕迹镜像或平铺过来排查时特别难发现看起来就像雪地上凭空多出一块污渍。filterMode可以用Bilinear。脚印边缘会柔和很多但代价是足迹看起来稍微发虚。如果你希望脚印边缘锐利可以改成Point配合更高的RT分辨率。RT分辨率和世界尺寸要配套。我建议按“一个像素对应2到5厘米地面”来设定。假设雪地是200米x200米RT 2048x2048每像素约9.8厘米角色脚踩出来的坑在RT里大概是4到6像素够用了。如果脚印需要非常精细再把分辨率提高到4096同时注意移动端带宽。2.2 双缓冲衰减恢复的正确姿势任何痕迹都不能永久保留雪要慢慢弹起来或者说新雪要覆盖旧脚印。我用的衰减机制是每帧对RT做一次“乘以衰减系数”的操作。这一步看似简单但如果直接在同一个RT上边采样边渲染GPU在同一Pass里对同一纹理进行读写在很多平台上是未定义行为轻则闪烁重则画不上去。正确做法是双缓冲private void Update() { // 指数衰减参数目标是在decaySeconds秒内深度值从1衰减到接近0 float factor Mathf.Pow(0.01f, Time.deltaTime / _decaySeconds); _restoreMat.SetFloat(_Decay, factor); Graphics.Blit(_aliveRT, _standbyRT, _restoreMat); // 交换 (_aliveRT, _standbyRT) (_standbyRT, _aliveRT); // 把当前有效RT暴露给全局Shader参数 Shader.SetGlobalTexture(_SnowHeightTex, _aliveRT); Shader.SetGlobalVector(_SnowFieldSize, new Vector4(_worldSize.x, _worldSize.y, 0, 0)); }Restore Shader的Pass极其简单float4 frag(v2f i) : SV_Target { float oldHeight tex2D(_MainTex, i.uv).r; float newHeight oldHeight * _Decay; return float4(newHeight, newHeight, newHeight, 1.0); }为什么用指数衰减而不是线性衰减你可以试想线性衰减下脚印从深变浅的速率是恒定的看起来像“整体均匀褪色”机器感很强。指数衰减是“刚开始恢复得快越到后面越慢”脚印从明显慢慢变成浅印子最后完全消失这个节奏更接近真实雪地的视觉记忆。我还在衰减Pass里加了一层微弱噪声用来模拟“新雪薄薄覆盖”float noise tex2D(_NoiseTex, i.uv * _NoiseScale).r; float newHeight max(oldHeight * _Decay, noise * 0.002);这样不会把所有脚印都抹成纯黑总有一点点极浅的残留远处看反而更有细节。2.3 绘制叠加如何保证“越踩越深”而不是互相覆盖往画布上画脚印时有个语义问题同一个位置被踩两次下陷量应该取更深的那次而不是简单的颜色混合叠加。如果用普通Alpha Blend第二次较浅的脚印会把原本很深的脚印冲淡视觉上出现“踩两脚反而变平”的离谱现象。Paint Pass里我使用了BlendOp MaxPass { BlendOp Max Blend One One // 片元输出当前brush对下陷深度的贡献值 }BlendOp Max的含义是目标像素保留“当前值”和“新画上去的值”中更大的那个。这正好符合“越踩越深”的直觉。有一点要提前提醒BlendOp Max在PC的D3D11和大部分OpenGL ES 3.x设备上都支持但部分老款移动GPU可能需要降级。遇到不支持的平台我会切到另一套方案先把这一帧要画的brush全部绘制到一张临时小RT再用一个逐像素max的合并Pass合进主RT。看起来绕但兼容性最好。3. 着色器核心顶点下沉、高度场法线和雪堆的配合画布有了接下来就是雪地表面的Shader。这是整个效果最出彩也最容易出bug的部分。我的实现拆成三块顶点偏移让几何真正凹陷、高度场生成法线让光照配合、PBR参数联动让材质看起来像雪。3.1 顶点下沉采样世界坐标不采样模型UVSnow Ground Shader用的是Surface Shader并在编译指令里加了vertex:vert addshadow#pragma surface surf Standard fullforwardshadows vertex:vert addshadowvert函数里要做的事是把当前顶点从模型空间转世界空间计算它落在雪地RT的哪个UV采样下陷高度然后沿法线往下压。void vert(inout appdata_full v, out Input o) { UNITY_INITIALIZE_OUTPUT(Input, o); // 取修改之前的原始世界坐标后面surf里采色用 float3 originalWorldPos mul(unity_ObjectToWorld, v.vertex).xyz; o.originalWorldPos originalWorldPos; // 世界XZ映射到RT的UV float2 fieldUV (originalWorldPos.xz - _SnowFieldPos.xz) / _SnowFieldSize.xy; // 采样下陷高度tex2Dlod避免在顶点阶段自动求导报错 float height tex2Dlod(_SnowHeightTex, float4(fieldUV, 0, 0)).r; // 沿法线方向下压 v.vertex.xyz - normalize(v.normal) * height * _Displacement; }我特别强调一点这里采样RT的UV必须用修改前的世界坐标计算而不是修改后的。如果拿修改后的顶点位置再采样一次下陷位置会继续采样到更大的高度导致“越陷越深”的正反馈网格会塌下去。另外做雪地地面Mesh时我建议把Transform的旋转清零缩放只保留X和Z轴。这样fieldUV (worldPos.xz - fieldPos.xz) / fieldSize这个公式就足够精确。如果雪地有旋转公式就要改成世界坐标到本地坐标再映射复杂度会上去实际收益不高。addshadow这个指令很容易被忽略但它决定了ShadowCaster Pass是否也执行同样的顶点偏移。不加它角色在雪地上走动时投影仍然来自原来的平面顶点阴影边缘是平的露馅概率极高。加了之后生成阴影时也会采样高度图做下压阴影本身也跟着凹陷起伏。3.2 高度场法线让凹陷边缘的光照“活”起来顶点下沉后PBR光照默认用的还是原始的法线所以凹陷区域不会有应有的明暗变化看起来像是“颜色变暗了”而不是“真的凹下去了”。处理这个问题我选的是用一张法线RT。在每帧衰减完成后再跑一个Pass把高度RT转换成法线RT雪地Shader里直接采样这张NormalRT作为法线贴图。高度转法线的Pass核心代码是这个float4 frag(v2f i) : SV_Target { float2 uv i.uv; float2 texelSize _MainTex_TexelSize.xy; float hL tex2D(_MainTex, uv - float2(texelSize.x, 0)).r; float hR tex2D(_MainTex, uv float2(texelSize.x, 0)).r; float hD tex2D(_MainTex, uv - float2(0, texelSize.y)).r; float hU tex2D(_MainTex, uv float2(0, texelSize.y)).r; float slopeX (hR - hL) * _HeightScale; float slopeZ (hU - hD) * _HeightScale; float3 normal normalize(float3(-slopeX, 1, -slopeZ)); return float4(normal * 0.5 0.5, 1.0); }为什么不能直接在surf里把算出来的世界法线塞给o.Normal因为Surface Shader中o.Normal默认解释为切线空间法线直接把世界空间的法线向量放进去结果会完全拧掉。如果不想走NormalRT也可以手动把世界法线变换到切线空间但需要拿到WorldToTangent矩阵代码比这复杂。生成一张法线RT是最不折腾且跨管线最稳的方案。Snow Shader里再这样采样o.Normal UnpackNormal(tex2D(_SnowNormalTex, fieldUV));实际调优时_HeightScale我一般设置在1.0到3.0之间数值越大凹陷边缘的明暗对比越强烈。建议先用1.0跑起来再逐步往上加直到侧面能看到明显的鼓包感。3.3 雪堆、泥土混色和PBR参数联动几何下陷和法线修正解决的是“形状”问题但要让雪地看起来像真实踩过的雪还需要调整材质属性。我的经验是凹陷底部露出泥土或深色冻土边缘略微隆起并堆积更亮的雪踩实区域的Smoothness比周围原雪更高。在surf函数里根据下陷高度h做一段混合void surf(Input IN, inout SurfaceOutputStandard o) { float2 fieldUV (IN.originalWorldPos.xz - _SnowFieldPos.xz) / _SnowFieldSize.xy; float height tex2D(_SnowHeightTex, fieldUV).r; float dirtFactor smoothstep(0.15, 0.45, height); float3 snowColor tex2D(_SnowTex, IN.uv_SnowTex).rgb; float3 dirtColor tex2D(_DirtTex, IN.uv_SnowTex).rgb; o.Albedo lerp(snowColor, dirtColor, saturate(dirtFactor * _DirtVisibility)); o.Smoothness lerp(_SnowSmoothness, _DirtSmoothness, dirtFactor); o.Normal UnpackNormal(tex2D(_SnowNormalTex, fieldUV)); o.Metallic 0.0; }这里的核心参数dirtFactor控制露土比例。太高会让整片凹陷变成泥坑太低又看不出层次。我通常设0.7到0.9。脚印边缘的“雪堆”我没有真的把顶点抬高而是在法线RT生成时增加了一点点边缘高光再用Smoothness联动模拟。想真正做几何隆起需要在顶点阶段额外采样一个偏移位置的高度做二次抬升但网格密度不够时会抖动性价比偏低。踩实的雪Smoothness我设在0.85周围松散雪的Smoothness设在0.6左右。这个对比很小但能明显提升真实感。4. 交互源接入角色脚步、滚动物体与自定义画笔画布和Shader都就绪后剩下的问题变成了什么时候、在哪里、画什么。这一章讲交互源的接入也是项目里最需要调试的部分。4.1 世界坐标到画布UV的换算所有交互源最终都要把世界坐标换算成RT上的UV。我的SnowFieldManager里有一个统一的换算函数public Vector2 WorldToFieldUV(Vector3 worldPos) { Vector3 localPos _snowFieldTransform.InverseTransformPoint(worldPos); float scaleX _snowFieldTransform.localScale.x; float scaleZ _snowFieldTransform.localScale.z; return new Vector2(localPos.x / scaleX 0.5f, localPos.z / scaleZ 0.5f); }这个写法要求雪地Mesh本体是1个单位尺寸的标准平面然后靠Transform缩放拉伸到实际大小。如果你的雪地是自定义Mesh建议直接用Renderer.bounds计算相对位置public Vector2 WorldToFieldUV(Vector3 worldPos) { Bounds b _snowFieldRenderer.bounds; return new Vector2( (worldPos.x - b.min.x) / b.size.x, (worldPos.z - b.min.z) / b.size.z ); }两种方式都可以注意整个项目里统一用一种避免中途混用导致脚印错位。4.2 角色脚步不该每帧都画这是我踩过最大的坑之一。最开始我在Update里每帧获取脚底Transform位置往RT上画脚印结果角色站在原地时同一位置被连续画了几十次变成一个又大又深的黑色圆坑。正确做法是用脚步间距控制绘制频率。private void UpdateFootprint(Transform foot, Texture2D brushTex) { Vector3 footPos foot.position; // 脚距离地面太高时绝不画 if (footPos.y _groundOffset 0.1f) return; _footGap Vector3.Distance(_lastFootPos, footPos); _lastFootPos footPos; if (_footGap _footprintInterval) return; _footGap 0f; // 左右脚用不同鞋底贴图加随机旋转 float rot Random.value * 360f; PaintStamp(footPos, brushTex, _footprintRadius, _footprintStrength, rot); }获取角色脚底位置直接用AnimatorTransform leftFoot animator.GetBoneTransform(HumanBodyBones.LeftFoot); Transform rightFoot animator.GetBoneTransform(HumanBodyBones.RightFoot);鞋底brush贴图我准备了三种左脚、右脚、脚尖更深的。这样印出来不会像盖章一样整齐真实很多。PaintStamp函数内部是把一个圆或鞋底形状的brush画到RT上public void PaintStamp(Vector3 worldPos, Texture brushTex, float radius, float strength, float angle) { Vector2 uv WorldToFieldUV(worldPos); _paintMat.SetTexture(_BrushTex, brushTex); _paintMat.SetVector(_PaintCenter, uv); _paintMat.SetFloat(_PaintRadius, radius / _fieldSize.x); _paintMat.SetFloat(_Strength, strength); _paintMat.SetFloat(_PaintAngle, angle * Mathf.Deg2Rad); Graphics.Blit(Texture2D.whiteTexture, _aliveRT, _paintMat); }PaintShader里画一个brush的思路是当前像素UV到中心距离小于半径就用brush贴图取样出形状否则输出0。因为前面设置了BlendOp Max输出0不会破坏原RT。4.3 连续拖拽与滚动物体线段brush雪地上除了脚印还可能有被拖动的箱子、滚动的球、滑雪板。它们的特点是轨迹连续不能只画一个点。我的做法是在上一帧位置和当前帧位置之间画一条“线段brush”。public void PaintLine(Vector3 startWorld, Vector3 endWorld, Texture brushTex, float radius, float strength) { _paintMat.SetTexture(_BrushTex, brushTex); _paintMat.SetVector(_LineStart, WorldToFieldUV(startWorld)); _paintMat.SetVector(_LineEnd, WorldToFieldUV(endWorld)); _paintMat.SetFloat(_LineRadius, radius / _fieldSize.x); _paintMat.SetFloat(_Strength, strength); Graphics.Blit(Texture2D.whiteTexture, _aliveRT, _paintMat); }PaintShader里用点到线段的最短距离来判断是否绘制float distanceToSegment(float2 p, float2 a, float2 b) { float2 ab b - a; float2 ap p - a; float t saturate(dot(ap, ab) / max(dot(ab, ab), 1e-6)); return distance(ap, t * ab); }每个移动物体都要在Update里记录上一帧位置并且只在地面附近才画。判断“地面附近”我用的方式是从物体中心向下发射一条Raycast如果距离小于物体自身半径加一个阈值就认为在雪地上方。注意一个优化点如果物体停着不动就停止绘制否则每帧都在原地画同一根线段时间一长轨迹处下陷量被BlendOp Max堆得很深看起来像被挖了一刀。4.4 鼠标画笔编辑器调试利器交互源接入早期建议先别急着绑Animator先用鼠标点击地面手动画脚印。我在Manager里留了这样的调试入口void Update() { if (Input.GetMouseButton(0) Camera.main ! null) { Ray ray Camera.main.ScreenPointToRay(Input.mousePosition); if (Physics.Raycast(ray, out RaycastHit hit, 100f, _groundLayerMask)) { PaintStamp(hit.point hit.normal * 0.05f, _testBrushTex, 0.3f, 1f, 0f); } } }有了鼠标画笔你可以瞬间验证RT坐标换算、衰减速度、着色器效果不用每次调效果都重新开一局角色跑图。等鼠标画出来的脚印满意了再接入Animator能省下大量时间。5. 踩坑清单阴影、接缝、移动端兼容和调试技巧这部分是项目上线前最容易出问题的地方我按自己踩过的坑一个个列出来方便你对号入座。5.1 阴影和自阴影顶点动了ShadowCaster也要动前面提到Surface Shader里要加addshadow。我再补充一个更隐蔽的坑如果你使用URP或者HDRPSurface Shader的支持有限很多情况下你需要手写Lit Shader或Shader Graph。在Shader Graph里做顶点位移时一定要检查ShadowCaster Pass是否也走了相同的顶点位移逻辑。Graph里直接连顶点位置时阴影Pass会继承但如果用了自定义Function可能出现阴影不匹配。实测中我遇到的现象是角色脚下的阴影边缘和凹陷边缘错开看起来像角色飘在雪上方。只要阴影Pass同步位移这个问题就消失了。5.2 网格密度和RT分辨率必须配套我最初在100米x100米的雪地上用了每米1个顶点的网格RT拉到2048结果脚印画上去变成几个孤零零的下陷点根本不像脚印。原因就是RT像素精度再高最终展示还是要靠Mesh顶点承载顶点间距太大时一个脚印只能影响一两个顶点。经验公式脚印半径至少覆盖2到3个顶点否则视觉上就是点状噪点。比如脚印半径0.3米网格顶点间距最好不超过0.15米。如果不想全场景铺高密度网格可以有两个替代方向PC端用Tessellation曲面细分根据距离动态细分角色附近的雪地Mesh脚印经过的地方顶点密度自动提高。效果最好但移动端不支持。移动端退而求其次用贴花方案只做颜色和法线假象不碰顶点。这也是为什么移动端Snow效果大多不带真凹陷。5.3 低配Android上的浮点纹理与BlendOp兼容R16G16B16A16_Float在绝大多数支持OpenGL ES 3.0的机器上都能用但老设备上顶点阶段采样浮点纹理的开销比PC高很多。如果目标机型较多建议RT分辨率降到1024或甚至512脚印衰减时间缩短避免大量痕迹同时存在Paint Pass里BlendOp Max做特性检测不支持的设备走“先画临时RT再合并”的降级路线关闭雪地阴影或使用低分辨率ShadowMap减少带宽压力。5.4 常见现象快速诊断表现象可能原因解决办法脚印发虚、边缘糊RT filterMode是Bilinear改成Point或提升RT分辨率脚印是点状、没有形状Mesh顶点密度太低提高网格细分或使用Tessellation凹陷区域光照不变法线RT没有更新或没采样检查NormalRT生成Pass和shader采样UV阴影错位ShadowCaster Pass没同步顶点位移Surface Shader加addshadowShader Graph检查Shadow Pass痕迹叠加变浅Paint Pass用的是普通Blend改成BlendOp Max雪地上出现对称重复痕迹RT wrapMode不是Clamp设置成Clamp调试时我最常用的工具是Frame Debugger把每一帧的Graphics.Blit、RT绘制和Snow Shader的Pass逐个点开看。再配合一个Debug着色器直接把HeightTex的R通道输出到Albedo就能实时看到整个雪地下陷分布是否合理。如果你发现某块区域在不该变化的地方有数值多半是RT坐标换算错了优先检查WorldToFieldUV的返回值和雪地Transform的缩放。这次做完之后我的体会是交互式雪地效果的核心不在“脚印怎么画”而在“把交互信息用RT承载起来让Shader自己动手”。这套思路可以迁移到沙滩脚印、泥地车辙、灰尘累积甚至画布涂鸦核心架构完全一样。如果你也是第一次做这类交互着色效果建议不要一上来就接完整角色先用鼠标画笔把RT和Shader链路调通信任这个流程以后再上脚印、拖拽和其他交互源后面的路会顺很多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进