ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity交互式雪地效果实战:RenderTexture与顶点位移实现踩雪凹陷

Unity交互式雪地效果实战:RenderTexture与顶点位移实现踩雪凹陷 搞这个项目之前我其实走了不少弯路。需求本身很简单玩家在一片雪地上走过身后要留下清晰的脚印踩过的地方雪面要凹陷看起来真的像一脚一脚踩出来的。我一开始想用贴花方案铺几张脚印贴图应付结果被项目组同事吐槽像贴纸一脚下去雪地纹丝不动完全出戏。后来一咬牙把交互式雪地效果从头撸了一遍核心走的正是Unity里交互着色效果这条路子用RenderTexture记录踩踏信息再让Shader读这张图实时改变雪面几何和颜色。这篇文章就把这套方案的完整思路、关键代码和我在实际开发中踩过的坑全部摊开讲适合已经会用Unity基础操作、想进阶Shader和渲染效果的开发者参考。1. 先拆明白需求雪地交互效果到底在“交互”什么1.1 效果的本质是两个层级的联动很多人一听交互式雪地第一反应就是做一张雪地贴图搞几张贴花贴上去不就行了。这种想法忽略了一个关键点交互着色效果的本质是着色器的输入里有动态变化的外部数据。贴花只是把静态图案贴在表面它没有凹陷这个几何维度更谈不上让环境对玩家行为做出连续反应。一个真正有说服力的雪地交互效果至少要包含两层联动几何层玩家踩过的位置雪面网格要真实塌陷下去形成凹陷的脚印形状。颜色层凹陷处的雪由于压实、厚度变化颜色应该比周围更暗甚至露出底下的泥土、岩石或者冰面。这两层必须由同一份交互数据驱动才能做到几何和颜色严丝合缝地对上。如果几何塌陷用一套数据、颜色变化用另一套经常会出现脚印陷下去了但颜色没变或者反向的诡异效果。所以我的结论是不要在贴花上浪费时间直接走RenderTexture记录交互轨迹 顶点着色器位移 片元着色器采样着色这条路一次把几何和颜色都解决掉。1.2 两条技术路线的取舍在实际做之前我梳理了两条主流路线这里列个对比表方便你根据自己项目情况选方案实时性脚印立体感性能消耗平台兼容性适用场景静态贴花 法线贴图无弱仅视觉假象极低全平台无压力过场CG、偶尔出镜的小面积雪地RenderTexture 顶点位移实时强真网格变形中移动端可行开放世界、生存类、可玩性强的雪地场景RenderTexture 曲面细分 顶点位移实时最强平滑细腻的凹陷边缘高桌面端为主移动端要看API追求画面品质的主视角3A项目我这次做的是可玩场景中的雪地不是过场动画所以直接选择了第三种方案但在移动端版本上做了降级处理。核心思想是无论哪种深度交互纹理Interaction Texture这一步是不变的变的只是采样之后怎么用。也就是说先保证踩踏数据被可靠记录渲染端再根据平台能力决定是直接位移顶点还是假装位移用像素扰动。2. RenderTexture 记录层用一张看不见的贴图保存所有踩踏痕迹2.1 RenderTexture 为什么是看不见的贴图RenderTexture本质上就是一张可以实时写入、实时读取的纹理。它不直接显示在屏幕上但可以被GPU在上面绘制也可以被Shader采样。雪地交互全部的地面状态就存在这张图里。我的做法是建一张RenderTextureR通道记录踩踏深度G通道可以额外记录踩踏类型比如脚印、车辙、铲雪。为什么只用R通道因为深度本身就是一个0到1的浮点数不需要彩色一个通道就够还省显存和带宽。几个关键参数我直接给出实测过比较稳的组合分辨率2048x2048对应大约10m x 10m的雪地区域。算一下精度10m / 2048 ≈ 4.9mm每像素一个成年人脚印大约25cm长在纹理上占到50像素左右画出轮廓完全够。分辨率再低脚印边缘就糊了再高性能和显存会吃紧。格式RenderTextureFormat.RHalf或者RFloat。前者精度半浮点够用后者在深度变化剧烈时更稳。普通R8只有256个灰度级深度过渡会看到明显断层。Wrap Mode一定要设成Clamp。默认的Repeat会让画刷在边界处镜像或重复边缘出现诡异的重影。Filter ModeBilinear在下采样时有平滑效果。2.2 绘制刷子的实现逻辑让GPU画而不是CPU填像素记录踩踏痕迹最朴素的想法是拿到足迹位置然后SetPixels改像素。真这么做就完蛋了——每次踩踏要回读纹理到CPU改完再传回GPU一帧卡掉几十毫秒而且GetPixels带有GPU同步等待是性能毒药。正确做法是让GPU自己画准备一个白色的圆球形笔刷网格把它的位置放到脚底接触点的世界坐标缩放成脚印大小然后用一个只输出到RenderTexture的相机或者直接用CommandBuffer Graphics.Blit把笔刷渲染到RT上混合模式设成Blend SrcAlpha OneMinusSrcAlpha或者Blend One One。原理上这一步相当于在看不见的图层上盖了一个白色圆点。每次脚落下就在该位置盖一个笔刷脚挪动时就连续盖出拖痕。核心代码如下public class SnowStepRecorder : MonoBehaviour { public RenderTexture interactionTex; public Material paintMaterial; // 笔刷材质输出白色圆点 // 在角色脚底每帧调用 public void PaintFootprint(Vector3 worldPos, float brushSize, float intensity) { // 把笔刷mesh放到脚底 brushTransform.position worldPos; brushTransform.localScale Vector3.one * brushSize; // 用绘制相机把笔刷画进RT paintCamera.targetTexture interactionTex; paintCamera.RenderWithShader(paintMaterial.shader, RenderType); } }这里有几个容易被忽略的细节踩过的坑都列出来笔刷Mesh最好用一张径向渐变圆贴图去控制笔刷的硬度。完全实心的圆会导致踩踏边界生硬像用印章盖的带径向渐变的笔刷会在边缘形成柔和过渡后续做顶点位移时才平滑。绘制后同一帧就把RT传给材质不要隔帧避免脚踩下去雪地延迟半秒才反应这个同步问题我在HDRP管线里特别明显。如果多个单位同时踩雪比如一群NPC绘制调用会叠加没关系RT天然支持多次叠加绘制这也是它比贴花方案强的地方。2.3 为什么用叠加Drawing而不是一次写入很多人第一次接触这个方案会问如果想擦除脚印怎么办或者脚印叠加会不会越来越深在这个效果里我们正是要越来越深。脚反复踩同一个地方雪原本蓬松被反复压实后凹陷会更深、颜色会更暗这在物理上也是合理的。用叠加绘制第二次踩同一个位置会在原有深度基础上继续加深恰好模拟了压实过程。如果你需要脚印随时间消失的雪地效果比如下雪天大雪覆盖脚印思路是在C#里定时做一次模糊降频或者用第二张模拟雪量恢复的纹理反向叠加。这个后续可以扩展但基础框架不需要一开始就背上这个需求。3. 从记录到变形把高度图变成雪面的凹陷3.1 片元着色器偏移是假凹陷顶点着色器偏移是真凹陷我最初图省事尝试在片元着色器里采样交互纹理然后对worldPos做偏移再计算光照。结果显示的效果是颜色看起来有一块凹陷但轮廓和阴影完全没有变化因为网格几何根本没动。这在视觉上属于障眼法稍微转个角度或者看阴影就穿帮。真正要做出立体凹陷必须在顶点着色器阶段采样RT把顶点沿法线方向压下去。这一步改变的是网格本身凹陷会导致邻近顶点高度差从而影响法线、阴影、遮挡关系这些硬表面细节才能让观众相信雪真的陷下去了。基础代码我贴出来// 顶点着色器 float _SnowDepth; // 最大下陷深度单位米 float _InteractionTexSize; // RT分辨率用于坐标换算 sampler2D _InteractionTex; float4 _SnowMeshWorldPos; // 雪地面的世界坐标范围用于UV映射 void vert(inout appdata_full v, out Input o) { UNITY_INITIALIZE_OUTPUT(Input, o); // 世界坐标转交互纹理UV这一步的关键是把世界坐标映射到0~1 float3 worldPos mul(unity_ObjectToWorld, v.vertex).xyz; float2 uv (worldPos.xz - _SnowMeshWorldPos.xz) / _SnowMeshWorldPos.ww; // 采样交互深度 float interaction tex2Dlod(_InteractionTex, float4(uv, 0, 0)).r; // 沿法线方向下压 v.vertex.xyz - v.normal * interaction * _SnowDepth; // 把下压后的法线也重新计算出来后面详细讲 v.normal normalize(v.normal float3(0, -interaction * 0.5, 0)); o.uv TRANSFORM_TEX(v.texcoord, _MainTex); o.interaction interaction; }这一步要注意的是tex2Dlod而不是tex2D因为顶点着色器没有屏幕坐标的梯度直接用tex2D在某些平台会报错或出现奇怪的Mip选择。下压方向用v.normal而不是固定的float3(0,1,0)。虽然雪地大多平坦但在坡道上脚印如果固定沿Y轴下压会造成脚印悬空或嵌入坡面。_SnowDepth是公开调参的口子标量控制在0.05~0.15米之间比较真实。太浅像水渍太深像挖坑。3.2 网格不够密怎么办曲面细分Tessellation登场直接拿Unity默认的平面10x10段去做顶点位移结果是一场灾难——顶点太少每个脚印只能影响到最近的几个顶点踩下去是一个多边形砸坑毫无轮廓感。解决思路有两个在建模软件里预细分网格把平面做成高密度网格。缺点顶点数剧增Assets体积变大内存和顶点处理开销都上去了而且不同性能平台需要做多套模型。用Shader的曲面细分阶段在GPU上动态细分。这才是正宗的做法也是标题里交互着色效果真正体现高级感的地方。曲面细分由三部分组成Hull Shader决定细分因子、Tessellation 固定功能单元生成新顶点、Domain Shader对生成的新顶点做位移。Unity Surface Shader 里可以直接用tessellate函数// Hull着色器根据相机距离动态计算细分因子 float _TessFactorMin; float _TessFactorMax; float _TessMaxDist; float4 tessellate(appdata_full v0, appdata_full v1, appdata_full v2) { float3 center (v0.vertex v1.vertex v2.vertex) / 3.0; float dist distance(center, _WorldSpaceCameraPos); float tess clamp((_TessMaxDist - dist) / _TessMaxDist, 0.0, 1.0); tess lerp(_TessFactorMin, _TessFactorMax, tess); return tess; }这里我踩过一个大坑细分因子的上限决定GPU有多少顶点要处理。要知道Tessellation是呈指数增长的一个三角形细分因子为n会生成约n²个新三角形。比如基础平面有1000个三角形细分因子6就会变成36000个三角形帧率直接掉一半。我的建议是动态调整离相机近的网格细分因子给6~8脚印边缘细腻。离相机远的部分细分因子给1~3远处本来就看不清脚印轮廓没必要浪费顶点。极端情况比如玩家踩出超长一段拖痕对拖痕区域单独做局部细分但点攒好了一般不会走这一步。3.3 Fallback 方案不靠细分也能出效果如果你做移动端或者WebGLTessellation的支持非常尴尬。OpenGL ES 3.0不支持Vulkan和Metal部分支持但驱动表现不一致。这时候我会用预细分网格替代动态细分在Mesh导入设置里直接把平面细分到200x200段约4万顶点然后在顶点着色器里原本地做位移。4万顶点在现代移动GPU上处理一次并不贵逐顶点还需要处理法线、UV、切线比动态细分开销小很多。只不过资产空间和加载时间会大一些属于用空间换兼容性。4. 法线重建与材质层次凹陷之后如何保持真实4.1 顶点位移后法线为什么坏了顶点被压下去之后网格的朝向发生了变化但Shader在片元阶段拿到的法线还是原始法线因为Unity的v.normal不变。结果就是凹陷处的光照和未凹陷处完全一样看起来像一个画上去的坑而不是真实受光的凹陷。想靠法线贴图补救也只是治标不治本法线方向要和实际几何变化一致必须在Shader里重新算。4.2 用屏幕空间导数重建法线最通用、最省事的办法我用的方案是屏幕空间导数ddx / ddy重建法线。原理很巧妙屏幕空间里相邻像素的世界坐标变化量偏导数就是网格表面在这一小片区域的切平面方向法线就是切平面的垂直向量。// 片元着色器中重建世界空间法线 float3 worldNormal WorldNormalVector(IN, float3(0, 0, 1)); float3 worldPos IN.worldPos; float3 ddxPos ddx(worldPos); float3 ddyPos ddy(worldPos); // 注意cross顺序Unity是左手坐标系这样得到的是朝上的法线 float3 reconstructedNormal normalize(cross(ddyPos, ddxPos)); // 和原有法线平滑混合防止极端变形时出现棱角 float3 finalNormal normalize(lerp(worldNormal, reconstructedNormal, _NormalBlend));如果你在片元着色器阶段有worldPos直接在相邻像素对上做差值就得到导数。这个方案的好处是不需要额外的法线贴图或者法线重建PassGPU自动生成屏幕梯度开销极小。无论顶点怎么位移哪怕是做了Tessellation后的动态细分导数法线都能自动反映最新的几何形状。需要注意的点ddx/ddy在片元着色器里基于屏幕2x2像素块计算如果凹陷边缘非常锐利会在边缘处产生不自然的像素梯度这时候靠_NormalBlend把重建法线和原始法线混合压低突变峰值。4.3 颜色层次踩得越深地面露得越多几何凹陷有了接下来就是颜色的精细活了。一片死白的雪踩下去只有凹陷没有颜色变化还是不够真实。我加了一套三层颜色混合蓬松表层亮白色覆盖在未扰动区域。压实雪层稍微偏蓝灰的白色脚印凹陷底部使用。地面底层深棕色或者岩石灰下陷深度超过某个阈值后暴露。用交互纹理的深度值interaction作为混合权重配合噪声贴图打破边界感// 三层雪色混合 float3 looseColor tex2D(_SnowTex, IN.uv).rgb; float3 packedColor tex2D(_PackedSnowTex, IN.uv).rgb; float3 groundColor tex2D(_GroundTex, IN.uv).rgb; // 深度越大越往下层过渡 float3 color lerp(looseColor, packedColor, smoothstep(0.2, 0.5, interaction)); color lerp(color, groundColor, smoothstep(0.6, 0.95, interaction)); // 加一点噪声避免整片脚印颜色完全一致 float noise tex2D(_NoiseTex, IN.uv * _NoiseScale).r; color * 0.95 0.1 * noise;这里值得操作的是深度阈值的调参。smoothstep的两个参数不要居中对称否则脚印底部会过早露出地面颜色导致每走一步地面都翻出来。我实测下来的推荐区间是(0.25, 0.55)用于压实雪层(0.6, 0.95)用于露出地面。5. 踩坑复盘四个最折磨人的问题与排查链路5.1 坑一RT分辨率看着够脚印却模糊成一片我第一次用的是1024x1024的RT对应8m x 8m地面算下来每像素约7.8mm理论上足够画出清晰脚印。但实际效果是脚印边缘糊成一团像被橡皮擦蹭过。排查链路检查RT格式R8怀疑是精度不够。换了RHalf有改善但不明显。检查笔刷材质笔刷用的是径向渐变但渐变半径太大边缘过渡范围占了脚印总宽度的30%导致边界模糊。把渐变曲线调成中心大部分不透明、只有最外围20%做过渡好转很多。最后发现最大问题是没有开Mipmap且采样时LOD选错RT默认GenerateMips是关的也没有做降噪处理。雪地Mesh上的UV跨越空间很大采样同一张RT时不同距离会用不同LOD导致远近脚印清晰度不一致。最终解法RT开启GenerateMips采样时让tex2Dlod的lod参数指向0级另外在绘制完笔刷后增加一次分离模糊Gaussian BlurPass。模糊核大小5x5左右权重为1/256, 4/256, 6/256, 4/256, 1/256 4/256, 16/256, 24/256, 16/256, 4/256 6/256, 24/256, 36/256, 24/256, 6/256 4/256, 16/256, 24/256, 16/256, 4/256 1/256, 4/256, 6/256, 4/256, 1/256这个模糊不会让脚印糊掉它只是消除踩踏边缘的锯齿感让深度过渡更柔和。5.2 坑二笔刷绘制UV和材质采样UV不在同一个坐标系这个问题非常隐蔽症状是脚明明踩在雪地中间脚印却出现在雪地的边界处或者转了个90度方向。排查链路一开始怀疑世界坐标推算错了打印输出鼠标点击的世界坐标和脚印位置看起来一致。检查材质采样UVworldPos.xz / size 0.5在笔刷绘制时也是同样的公式看起来也没错。打开画刷Shader代码发现画刷的顶点着色器用的UV是直接拿v.uv0~1而笔刷Mesh是Unity自带Quad它的UV方向是左下角为(0,0)右上角为(1,1)。但雪地材质采样时用的是世界坐标两套UV在竖直方向上差了一个翻转。最终解法绘制笔刷的材质里UV必须强制按世界坐标算不要使用笔刷Mesh自身的UVfloat2 uv (worldPos.xz - _SnowMeshWorldPos.xz) / _SnowMeshWorldPos.ww;这个问题在多平台Windows、Android尤其容易炸因为部分平台的纹理V轴方向和DirectX/Vulkan约定不同一旦有平台差异偏移会更明显。5.3 坑三曲面细分在手机上失灵雪地恢复成一块平板我在PC上跑得好好的打包安卓后发现雪地永远是一片平地脚印只有颜色变化没有几何凹陷。第一反应是Shader代码在移动端出错了但日志又没有报错。排查链路检查目标平台的API级别Android用的OpenGL ES 3.0查了Unity手册#pragma hull和#pragma domain在OpenGL ES上是不支持的编译时被静默裁掉于是整个Tessellation阶段形同虚设。处理方式给Shader加了一个_ENABLE_TESSELLATION关键字桌面端开启移动端关闭同时准备一份预细分200x200的Mesh移动端用这个Mesh替代动态细分。要注意不同API下Unity的UnityObjectToClipPos演算结果一致所以只是损失细分精度不会影响位置对齐。最终结论动态细分这个特性请务必在产品初期就确定目标平台。如果必须兼容移动端建议别依赖Tessellation老老实实用预细分网格或者用高度图扰动像素级视觉假凹陷顶着性能好还省心。5.4 坑四阴影和网格错位出现影子悬空的穿帮凹陷做出来后地面上有了坑但平行光的阴影贴图没有跟着变导致阴影还是按照未凹陷的平面来计算坑里的物体和阴影对不上特别明显。排查链路这个问题的本质是阴影Pass没有做和主Pass一样的顶点位移。Unity表面着色器里有addshadow指令它会生成一个专门输出深度的Pass但这个Pass默认不执行我们自定义的顶点位移逻辑。解法在Shader里让Shadow Pass也采样交互纹理并位移顶点。如果是Surface Shader在#pragma surface ... addshadow下Unity会自动让阴影Pass继承顶点修改但也需要在vert函数里做同样的采样计算。还要给阴影设置偏移参数Offset -1, -1避免凹陷边缘在阴影贴图上发生Z-fighting导致的裂缝闪烁。6. 性能预算与平台取舍帧率怎么保住6.1 一帧里到底多了哪些开销做这个效果之前先想清楚每帧多出来的代价RT绘制一次额外的绘制调用笔刷网格draw call外加一次全屏模糊Pass。开销不大但频率注意——角色每走一步只需要在脚刚落地的瞬间触发绘制不要每帧都画。顶点着色器采样每个顶点多一次tex2Dlod。基础平面1000顶点没影响预细分网格4万顶点移动端也扛得住Tessellation生成的几十万顶点桌面级显卡也没问题。片元着色器采样多了交互纹理一次、噪声纹理一次、可能再加一次雪地纹理细节。移动端要注意纹理带宽。6.2 不同平台的配置参考项目桌面端PC/Mac移动端中高端安卓/iOSWebGLRT分辨率2048x20481024x1024512x512细分模式Tessellation因子4~8无Tessellation预细分网格100x100预细分网格64x64实时阴影开启烘焙为主动态Shadow只对角色关闭或低分辨率绘制触发频率每踩一步每踩1~2步每踩1~2步高斯模糊每2帧一次每4帧一次不做或只做一次轻模糊说实话单次RT绘制 一次顶点采样对性能的影响在主流设备上都很小真正吃性能的是Tessellation产生的大量顶点和片元插值。所以在性能敏感平台我会直接砍掉Tessellation并降低RT分辨率给人感觉依然是踩雪有痕只是边缘细节不如桌面端那么锐利。6.3 这套方案的扩展空间做完这套雪地交互效果后我最大的感受是核心其实不是雪而是一套外部事件写入纹理 → 着色器响应变化的通用管线。同样的架构你只需要换笔刷形状换成轮胎形状画刷雪地就有车辙响应逻辑从位移顶点变成改变颜色混合就能做泥地脚印叠加模式从加深变成擦除就能做沙地写字的橡皮擦效果。我之前在另一个项目里拿同一套RenderTexture框架做玩家走过草地草被压平的效果只改了着色器的位移逻辑草叶顶点沿-X/绕底部弯曲管线一点没动半天就调完了。最后再分享一个实战中的小技巧如果你要在室外的雪地上做多人交互务必为每台客户端单独分配一个RT实例不要把RT当成共享全局资源。RT的读写并发问题在多人同屏时会引发脚印互相打架的脏数据。单人场景就无所谓了一个全局RT完全够用。
返回列表