拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Unity交互式雪地脚印效果实战:RenderTexture+Shader凹陷留痕

开头部分做Unity也快十年了期间被问过最多的面试题之一就是“雪地脚印怎么做”而新手项目里最容易翻车的视觉需求也往往是“交互着色效果”里的交互式雪地效果。很多朋友第一反应是给雪地做碰撞体检测在雪地里挖洞或者用粒子模拟踩飞起来的雪花结果做出来的效果要么角色一踩就穿帮要么性能直接崩到没法看要么脚印位置永远对不上角色脚底。其实这个效果的本质很简单一句话就能说清雪地本身不需要真的改变你只需要把“被踩过”这件事记录到一张贴图上然后让雪地着色器根据这张贴图做出颜色压暗和顶点凹陷。它解决的是“如何让静态的世界表面拥有动态互动反馈”这个渲染问题原理上和黑板画画、沙地写字、泥地留痕完全一样。这篇博文面向两类人一类是刚入门Unity、手里握着几个基础Shader但不知道如何做起交互效果的开发新手另一类是已经在做地形或模拟类项目、需要在场景表面做出实时“可破坏/可绘制”效果的中级开发者。我会把整套方案的原理、踩坑记录、完整示例代码全部摊开保证你看完就能照着搭出一片能踩出脚印的雪地。1. 整体设计与思路拆解1.1 先想明白一个“雪地脚印效果”的核心逻辑是什么如果你把雪地想象成一张白纸角色踩过去就是拿一支笔在纸上压出痕迹。白纸本身不会变变的只是纸上“有没有被压过”这个信息。雪地场景里每个像素点在被踩之前都是一片漂亮的白色被踩之后要变成“压实的雪”或者“露出下面的泥土”还要比周围低一点看起来像真的凹陷下去。所以整个方案只需要做两件事。第一记录“哪些位置被踩过以及踩得有多深”这一步和渲染无关属于数据写入第二让雪地表面根据这份记录改变颜色和顶点位置这一步才是真正的交互着色。这个思路的好处是可扩展。今天你踩的是雪明天你把颜色从白色换成沙金色再调低凹陷深度雪地就变成了沙滩把“痕迹贴图”的绘制方式从角色脚底改成鼠标位置就变成了一个可涂抹的关卡编辑器。所以别把目光只锁在雪地上做成“表面交互系统”之后能复用的地方非常多。1.2 主流实现方案横向对比与选型理由在Unity里能做“交互式雪地”的方案并不止一种各方案的效果和成本差异极大我简单梳理一下主流路线方案实现思路优点缺点适用场景RenderTexture 俯视相机相机把脚印渲染到RT雪地Shader采样RT做混合与位移性能可控、效果稳定、兼容性好需要理解UV映射设置步骤偏多本篇采用推荐大部分项目GPU细分曲面 顶点位移通过Tessellation Shader细分网格踩踏区域把顶点压下去真实立体感强移动端兼容性差性能开销大PC端玩家可破坏地形类粒子系统模拟踩踏时在地面播放雪尘粒子视觉冲击力强无法做持续留痕作为辅助效果配合主方案计算着色器RWTexture2D用ComputeShader直接修改纹理灵活高效适合超大数据URP/HDRP配置麻烦写错容易崩溃需要复杂笔刷逻辑时我最终选择的是RenderTexture 俯视相机路线原因有三其一它是纯图像空间方案不依赖高端GPU特性手机也能跑其二RT写入和Shader读取是一层标准的数据接口即使后续要换成地形或网格代码逻辑几乎不用改其三这套思路和UI贴图、屏幕后处理、粒子遮罩绘制是同一个套路学会了等于掌握了Unity实时渲染里“动态纹理”这一类问题的通用解法。2. 核心细节解析与实操要点2.1 关键原理把世界坐标“翻译”成雪地上的痕迹坐标要让雪地知道“脚踩到这了”必须把3D世界里的位置对应到雪地表面上这件事比想象中容易踩坑。我的做法是在雪地上方摆一个正交相机让它垂直往下拍。这个相机负责把“处于踩踏状态的位置”渲染进一张RenderTexture里渲染出来的结果就是一个从上往下看的俯视黑白图有痕迹的地方是白色或带深度值的灰度没痕迹的地方是黑色。再用Shader采样这张RT时需要把雪地表面的世界坐标转换成RT的UV坐标。这个转换的数学逻辑是这样的如果是正交俯视相机它的视锥体其实是一个平行六面体。RT左上角对应世界坐标中某个范围的左上角RT右下角对应另一个范围中间是线性变化。于是世界坐标到UV的映射只需要做一个线性缩放float2 GetTrailUV(float3 worldPos, float2 centerOffset, float areaSize) { float2 uv (worldPos.xz - centerOffset) / areaSize 0.5; return uv; }这里centerOffset是雪地平面的中心在世界xz坐标上的值areaSize是相机覆盖范围的大小。如果你直接把相机范围抠出来其实就是相机中心减去半宽再映射到0到1之间。为了省事也可以在脚本里用相机的WorldToViewportPoint方法把任意世界坐标转换为相机的视口坐标0到1这就不用自己去推映射公式了。但要注意如果后续想把手动计算和相机分离或者做跨平台一致性优化手动映射反而更可控。2.2 雪地Shader要做的两件事颜色混合与顶点凹陷有了RT之后剩下的工作全在雪地着色器里。核心逻辑就两条采样RT拿到“被踩深度”然后用这个值同时影响片元颜色和顶点位置。片元颜色的做法是做一个lerp没被踩的地方显示积雪的浅色被踩的地方过渡到泥土、冰面或深色压实的雪。顶点位置的处理是在顶点着色器里沿着雪地的法线方向把顶点往内部推一段距离踩得越深凹陷得越明显。我用Built-in管线的CGShader展示核心代码URP下只需把CGPROGRAM换成HLSLPROGRAM即可原理完全一致Shader Custom/SnowGround { Properties { _SnowColor(Snow Color, Color) (0.92, 0.95, 0.98, 1) _GroundColor(Ground Color, Color) (0.35, 0.27, 0.18, 1) _TrailTex(Trail Texture, 2D) black {} _TrailDepth(Trail Max Depth, Range(0.01, 0.5)) 0.08 } SubShader { Tags { RenderTypeOpaque } LOD 200 CGPROGRAM #pragma surface surf Lambert vertex:vert #pragma target 3.0 fixed4 _SnowColor; fixed4 _GroundColor; sampler2D _TrailTex; float4 _TrailTex_ST; float _TrailDepth; struct Input { float2 uv_TrailTex; float3 worldPos; }; void vert (inout appdata_full v, out Input o) { UNITY_INITIALIZE_OUTPUT(Input, o); // 世界坐标计算 float3 worldPos mul(unity_ObjectToWorld, v.vertex).xyz; // 计算在RT里的UV float2 trailUV (worldPos.xz - _TrailTex_ST.xy) / _TrailTex_ST.zw 0.5; // 采样深度 float trail tex2Dlod(_TrailTex, float4(trailUV, 0, 0)).r; // 沿法线凹陷 v.vertex.xyz v.normal * trail * _TrailDepth; } void surf (Input IN, inout SurfaceOutput o) { // 片元阶段采样 float2 trailUV (IN.worldPos.xz - _TrailTex_ST.xy) / _TrailTex_ST.zw 0.5; float trail tex2D(_TrailTex, trailUV).r; // 颜色从雪过渡到泥土 fixed3 col lerp(_SnowColor, _GroundColor, saturate(trail * 1.5)); o.Albedo col; o.Smoothness 1.0 - trail * 0.5; } ENDCG } FallBack Diffuse }这段代码里最关键的是UV的计算。_TrailTex_ST的xy我用来存雪地平面的中心坐标zw存整个可交互区域的缩放尺寸。这样无论雪地怎么旋转移动只要你保持正交相机和雪地平行贴齐映射结果就是准的。补充一个细节顶点凹陷必须在顶点着色器里做不能在片元着色器里做因为片元阶段已经没法改变几何位置了。而法线方向很关键踩坑可以沿法线而不是直接沿y轴下压这样即使雪地是一个坡面脚印也会顺着坡面凹下去看起来更自然。2.3 为什么要用“记录贴图”而不是直接修改地形网格有人会问既然要凹陷直接修改Mesh顶点不是更直接吗道理没错但你要考虑两个现实问题。一是修改Mesh顶点会把网格从静态变成动态Unity要重建整个Mesh的包围盒、碰撞体、法线信息一个地形动辄几万顶点每帧改一次CPU和内存都扛不住。二是修改顶点是一次性操作无法回退而用RT记录的话你可以随时把RT清黑雪地立刻恢复原状这在做游戏原型测试时简直不要太方便。记录贴图方案本质上用的是“数据与表现解耦”的思路RT存的是过程数据Shader负责把数据翻译成视觉结果。数据可以随时改、随时清、随时做衰减表现层则只做一件事——根据输入决定颜色和高度。这也是引擎渲染里最常见的分层思想。3. 实操过程与核心环节实现3.1 场景准备搭一片最简雪地新建Unity项目版本选2021.3 LTS或更高即可内置渲染管线就能跑通不用刻意装URP。场景里创建一个Plane作为雪地给Plane新建一个Material把上面那段Shader拖进去。再创建一个Capsule作为玩家角色方便后面测试脚印。为了让雪地看起来更像样可以给Plane贴一张无缝雪地纹理并把MeshCollider或BoxCollider加上避免角色掉下去。我这里为了演示简单就不加第三人称控制器了直接用代码控制Capsule在平面上移动同时脚下生成脚印标记。3.2 创建RenderTexture与足迹相机这一步是整个效果的“数据层”。在Project窗口右键 - Create - Render Texture命名为TrailRT。在Inspector里做如下设置Size512 x 512如果你雪地面积很大可以提到1024但移动端慎用Depth BufferNo depth buffer我们只需要颜色信息不需要深度Color FormatR8单通道灰度即可节省带宽或RGBA32Wrap ModeClamp很重要防止UV采样边缘出现花屏Filter ModeBilinear让脚印边缘柔和一点接着在层级面板创建一个Camera命名为TrailCamera做如下配置ProjectionOrthographic位置雪地上方正中心比如(0, 8, 0)Rotation全0Size根据雪地交互范围设置比如5这意味着相机能看到10米x10米的区域Culling Mask只勾选TrailLayer这个LayerClear FlagsSolid Color颜色设为(0, 0, 0, 0)Target Texture拖入刚才创建的TrailRTTrailCamera的作用不是给玩家看它只管“把脚印标记渲染到RT里”。为了让自身不出现在最终画面里你可以把它的AudioListener组件移除把相机的Tag改成Untagged并在脚本里关掉它。3.3 脚步印标记到底把什么东西画进RT接下来的问题是什么东西会被TrailCamera拍进去答案是放在TrailLayer层上的一个球体或圆柱体。角色每走一步脚本在脚底位置放置一个半透明的圆柱体这个圆柱体只被TrailCamera可见渲染出来的就是一张黑白脚印。我更推荐用球体而不是圆柱体因为球体被正交相机从上往下拍形成的圆形区域边缘更柔和。把圆柱体换成SphereScale设置为(0.6, 1, 0.6)材质用一个纯白不透明材质。但有个细节需要注意如果角色站在原地不动脚印只生成一次就被渲染进RT之后就没了。你需要让这个脚印标记持续存在一段时间或者每一帧都在脚下生成新的标记。更实用的是在FixedUpdate里每隔一小段距离生成一个脚印这样走出来的路是连续的。脚步生成脚本我放在下面using UnityEngine; public class FootstepTrail : MonoBehaviour { public RenderTexture trailRT; public LayerMask trailLayer; public GameObject footprintPrefab; public float stepDistance 0.3f; public float markerLifeTime 1.5f; private Vector3 lastStepPos; void Start() { lastStepPos transform.position; } void FixedUpdate() { Vector3 currentPos transform.position; float dist Vector3.Distance(currentPos, lastStepPos); if (dist stepDistance) { SpawnFootprint(currentPos); lastStepPos currentPos; } } void SpawnFootprint(Vector3 pos) { GameObject marker Instantiate(footprintPrefab, pos, Quaternion.Euler(90, 0, 0)); marker.layer LayerMask.NameToLayer(TrailLayer); Destroy(marker, markerLifeTime); } }脚印标记为什么要设置生命周期因为如果它一直存在TrailCamera每帧渲染都会把旧的脚印重新写进RT哪怕是同一个位置也会反复叠加导致颜色过亮。Destroy标记后RT里已经写入的数据不会消失除非你主动清空或做衰减。所以这个1.5秒的LifeTime只是为了让重叠的脚印有机会淡出防止过度曝光。3.4 让RT在雪地上生效打通Shader与数据的连接现在RT里已经有脚印数据了但雪地的材质还不知道RT的存在。最简单的方式是在代码里用Shader.SetGlobalTexture设置全局纹理变量这样场景里所有雪地Shader都能自动读取不需要逐一改材质。void Start() { Shader.SetGlobalTexture(_TrailTex, trailRT); }别忘了在Shader里设置_TrailTex的属性与全局变量同名。如果你只想让某几块特定雪地受影响就改成在材质上直接赋RT效果一样。完成这一步后运行游戏用Capsule在雪地上走一圈你会发现它走出来的路径上已经出现颜色变深凹陷的痕迹了。如果出现痕迹没跟着脚走、偏移半米、或者雪花屏闪烁的情况你需要的不是重新写Shader而是本文章节4的排查清单。3.5 避坑提醒足迹相机的旋转与缩放千万别动我见过太多人为了让相机“更精确拍脚印”会旋转它或调整Scale结果RT里的脚印坐标系和世界坐标对不上。足迹相机的最佳状态就是Original位置在雪地正上方Rotation保持(0,0,0)Projection是OrthographicSize覆盖整片交互区域。任何非均匀缩放都会破坏映射关系。另一个易错点是雪地材质上的Tiling如果设置了大于1的值世界坐标到UV的映射会变乱。所以我上面的代码里用的是世界坐标线性映射和材质Tiling无关这反而是更稳的方案。4. 常见问题与排查技巧实录4.1 脚印对不上、偏移、撕裂是怎么回事脚印偏移几乎都和UV映射错误有关。先检查足迹相机是否覆盖了你期望的xz范围相机Size为5时RT的UV0对应世界坐标(camPos.x - 5, camPos.z - 5)UV1对应(camPos.x 5, camPos.z 5)。如果你雪地平面比这个大那雪地边缘部分超出相机范围自然踩不出脚印。反之如果相机范围比雪地大脚印会很稀疏。记住一个原则先用“print调试”打出你的脚印世界坐标手动对比它在RT采样后的UV值如果这个值超出0到1说明你的映射范围设置错了。4.2 RT残留“鬼影”与雪面发灰问题很多人的RT在运行一段时间后整片雪地会变灰这是因为TrailCamera一直在渲染白色标记RT里旧数据没有被清除。相机Clear Flags里的Solid Color只会在首次生成RT时填充黑色但脚印标记消失后已经写在RT里的白色块不会自己消失。解决办法有三种第一缩短脚印标记的LifeTime让它尽快从相机渲染范围中消失第二写一个定时器每N秒把RT整体做一个暗化Blit模拟雪被风抚平第三用脚本定期重建RT全部清零。我个人的习惯是使用第二种方案创建一个材质写一个只做颜色乘法的Shader把RT Blit到自身用时间系数让它缓慢衰减到黑色。这样玩家走过的地方雪会慢慢“回弹”过段时间就恢复成平坦的样子。public class TrailDecay : MonoBehaviour { public RenderTexture trailRT; public float decaySpeed 0.3f; void Update() { RenderTexture temp RenderTexture.GetTemporary(trailRT.width, trailRT.height); Graphics.Blit(trailRT, temp); // 用自定义材质把temp乘以一个衰减系数后写回trailRT Graphics.Blit(temp, trailRT, decayMaterial); RenderTexture.ReleaseTemporary(temp); } }这个思路本质上是“给RT做周期性的后处理衰减”日常写交互地面时特别实用。4.3 移动端/低端设备上的性能与兼容性问题RenderTexture方案在移动端最怕的是分辨率过高和高频读取。我建议在手机项目里RT分辨率控制在512以内且避免每帧GetTemporary而是在初始化时创建长期复用的RT。Shader里采样RT的语句要控制在每个像素不超过1次顶点着色器里用的是tex2Dlod没问题片元里用tex2D即可。另外要注意RenderTexture的格式。老机型可能不支持R8建议用RGBA32兜底。如果你用URP管线不要忘记把Shader里的Surface Shader改写成Lit或Unlit的HLSL版本URP不支持内置管线的Surface Shader编译这个坑会卡住不少新人。更省力的替代方案是用Shader Graph的“Texture2D Sample”节点读取RT节点连线比手写Shader直观得多性能也相当。4.4 Shader在URP/HDRP下的适配差异如果你的项目用的是URPSurface Shader写法全部作废需要用HLSLPROGRAM的Pass结构。推荐把上面功能改成一个URP的Unlit Shader顶点阶段做位移片元阶段做颜色混合。下面给出URP版本核心代码Varyings vert(Attributes IN) { Varyings OUT; float3 worldPos TransformObjectToWorld(IN.positionOS); float2 trailUV (worldPos.xz - _TrailCenter.xy) / _TrailAreaSize 0.5; float trail tex2Dlod(_TrailTex, float4(trailUV, 0, 0)).r; IN.positionOS.xyz IN.normalOS * trail * _TrailDepth; OUT.positionHCS TransformObjectToHClip(IN.positionOS); OUT.worldPos worldPos; OUT.trailUV trailUV; return OUT; }这里你需要额外传一个参数_TrailAreaSize来表示相机覆盖范围可以设成10根据相机Size乘以2。大多数现代Unity手游项目都在用URP所以这个版本更贴近线上项目的实际问题。5. 从雪地到通用“交互表面”扩展玩法与实用技巧5.1 泥地、沙地、黑板同一套逻辑的三种换皮这套方案的真正价值在于抽象化。你可以把“雪地着色器”里的_SnowColor换成沙金色_GroundColor换成深棕色把_TrailDepth调小到0.02雪地就变成了沙滩把颜色改成粉笔灰白色和黑板绿色再把绘制源从“角色脚底”替换成“鼠标光标”就变成了一块黑板画板把脚印标记换成笔触物体配合压感数值就是最简单的画图软件。我甚至在一个数字孪生项目里用它做过“人流热力图”把RT从黑白改成红蓝渐变色TrailCamera渲染的不是脚印而是一个半透明圆形跟着人移动采样后叠加到地面上就能实时看到哪些区域人流最集中。由此可以断定理解交互着色核心逻辑比记住雪地这一个效果本身重要得多。5.2 给脚印加上“飞雪”粒子视觉层级再升一档顶点凹陷和颜色变化已经有很强的说服力但如果观众视角离得近脚下完全没有尘埃感还是会觉得“假”。建议在角色的脚底每隔一小段距离触发一个ParticleSystem发射3到5个小型雪雾粒子粒子颜色接近雪色透明度快速衰减持续时间不超过0.5秒。它和RT留痕互不干扰只负责锦上添花。效果好坏的关键在于踩踏节奏每步触发一次粒子数量少而轻别做成“造雪机”否则会盖住RT那层精确的凹陷痕迹得不偿失。5.3 动态恢复雪地的几种进阶方案想让雪地“过了几十秒自己长回来”最优雅的做法是在RT里记录的不只是深度还记录时间戳RT的颜色值表示深度Alpha通道表示最后被踩的时间。雪地Shader采样时用当前时间减去时间戳超过一定阈值就动态回到无痕状态。这个方案对Shader的要求更高需要修改RT的Unlit材质让它写入深度和时间戳两个通道。如果项目时间紧就用我上面提到的“整体衰减法”。虽然它会让所有脚印同时变浅但在绝大多数场景里观感是够的而且制作成本极低。有人会问脚印能不能做成真实的“鞋印”而不是圆形当然可以。把TrailCamera投射的物体换成鞋底模型RT里就会出现明确的鞋印轮廓。但要注意这样RT的采样分辨率就得提高否则鞋印边缘会糊成一片。我的建议是项目演出展示时用鞋印模型正常游戏里用圆形性价比最高。5.4 最后分享一个实际项目里的小技巧在真正落地时别把足迹相机绑定到玩家角色上而是把它的位置固定在雪地中心附近。因为如果玩家走到角落脚印标记和RT的映射关系还是一样的但相机的正交范围可能无法覆盖超出的区域。我的做法是把足迹相机的Size动态调大让它可以覆盖角色周围5米的范围留痕范围跟随角色移动但相机永远保持正交和垂直。这样角色的移动范围就不再受限于单张RT的尺寸世界可以无限大而RT始终只记录周围一小圈的雪地状态。配合上全文的UV映射这套方案甚至能在开放大世界里做出连续不断的脚印效果。我一直觉得交互着色效果是Unity里性价比最高的“惊艳型效果”之一——它不需要高精度模型、不需要物理引擎、不需要复杂的粒子系统一张RT、一个正交相机、几十行Shader代码就能换来“世界仿佛在与玩家互动”的沉浸感。希望这篇文能帮你跨过最开始那个映射的坎做出自己想要的雪地脚印。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门