这一期接着“从0开始的技美实战”系列的节奏聊一个我最近在模拟项目里反复打磨的内容风格化水体。之所以在第十七篇选这个题目是因为它几乎是技美入行绕不开的案例既能练渲染基础又能体现审美判断还不用搭很大场景就能出效果。这篇会按我自己的实操顺序来先拆解水体该有哪些视觉元素再讲Shader层面怎么实现然后给一套可以直接抄的URP风格化水体方案最后聊性能、排坑和几个我觉得很有用的扩展方向。不管你是刚开始学TA的美术同学还是被半透明渲染坑过几次的开发者这篇的内容应该都能让你拿到一个能跑、能调、能继续玩下去的水体方案。1. 风格化水体从观察拆解到技术选型1.1 为什么技美教程总爱拿水体练手我见过很多初级TA的第一个完整作品就是水体或类似的半透明材质原因很简单一个像样的水面Shader几乎能把渲染里的基础模块全部串一遍。你需要处理UV动画需要采样贴图需要跟深度缓冲打交道需要写半透明混合需要算菲涅尔需要调高光还要考虑跟后处理、灯光、甚至天气系统的配合。一个水体做下来等于把PBR之外的那套“按需求定制光照”的思路也过了一遍。更重要的是水体是一个非常吃“审美判断”的东西。同样一套算法参数不同、纹理不同、融合方式不同出来的效果可以从写实跨到卡通、从湖面跨到毒液池。技美这个岗位本质就是在程序和美术之间找平衡点而水体恰好是这种平衡最直观的载体光照算法是程序的活颜色温度、浪形节奏、泡沫位置是美术的活。你自己亲手调一遍才会明白两边在争什么。1.2 先把“看着像水”拆成可实现的图层我在拿到一个水体需求时不会直接上手写Shader而是先把“水”拆成几个可以在节点或代码里单独控制的视觉层。这样做的好处是后续任何一步出了问题都能立刻定位到具体参数而不是在一大团节点里瞎猜。对一个典型的风格化水体我会分成这么几层基础色和深度渐变色浅水区和深水区的颜色差异通常通过采样场景深度获得离岸越近越浅、越透离岸越远颜色越深。波浪起伏用两张方向、速度不同的法线贴图滚动模拟水面的微动态。风格化不一定需要高度方向上的真实位移法线上的扰动已经能骗过眼睛。流动高光在法线扰动的基础上用半方向光或主方向光算高光重点是把高光范围压窄形状要跟着波浪一起“碎”。岸边泡沫浅水区、岸边、物体周围出现的白色泡沫层是水体立体感的关键。泡沫不是均匀铺满的要有噪声扰动要有软边。边缘亮线和菲涅尔视线与水面的夹角越大反射越强、透射越弱。风格化里通常表现为岸边和水面交汇处的一条亮边。拆完这些之后技术方案基本就清楚了深度采样加透明度渐变两张法线贴图UV滚动Blinn-Phong或者直接用半角向量做高光再用噪声图去调制泡沫和边缘。这个框架不挑平台也不吃高端硬件特性。1.3 技术选型节点图还是手写代码到了具体实现我一般看项目情况二选一用Shader Graph搭节点或者直接手写HLSL。两者没有绝对好坏但取舍很明确。Shader Graph的好处是可视化、调参快、对不熟悉代码的人非常友好尤其是把节点连出来之后美术同学自己也能上手改。但短板是节点多了以后连线会变得很乱节点版本一升级可能还会出现行为变化而且跨工程移植时节点图往往不如一份代码文件干净。手写HLSL的好处是代码本身就是文档每一步做了什么、在哪一行清清楚楚性能上也能精确控制指令数减少不必要的采样。缺点自然是学习曲线陡入门阶段容易卡在语法和平台差异上。这次我选择先用HLSL手写一份核心Shader然后再把同样的逻辑在Shader Graph里等价搭一遍。因为手写能讲清楚“为什么”节点图则更适合“我调这个滑条会发生什么”。这两个视角都拿到才算真正掌握。2. 核心细节解析与实操要点2.1 深度纹理水体颜色的底层逻辑风格化水体最出效果的第一步往往不是波浪而是“水深变色”。这个效果的原理并不复杂在渲染水体这个半透明物体时从当前像素出发向后采样场景深度得到“水底”距离距离越远说明水越深颜色越接近深水色距离越近说明是岸边浅水颜色应该更透、更亮甚至能看到水底的颜色。在URP里采样深度图我先要在管线设置里开启Depth Texture。如果这个开关没开采样出来的深度纹理要么是黑的要么值的含义不对。很多人做水体第一天就踩这个坑水面一片漆黑原因不是Shader写错而是深度纹理本身没开。取得场景深度和当前像素深度的差值后我会套一层反比例映射深度值越大混合比例越向深水色偏移。这里要注意一个细节深度差是线性的但水色给人的视觉感知不是线性的。所以我会在中间加一个Power节点或者用Smoothstep做非线性过渡这样浅水区和深水区的分层会更明显不会糊成一片。这个深度值的具体处理我习惯先除以一个可控的_DepthFactor再做一个饱和Saturate操作。_DepthFactor的语义很直白数值越大颜色从浅水切换到深水的速度越快视觉效果是“离岸不远就开始变深”。2.2 菲涅尔让水面边缘“亮”起来菲涅尔就是不同视角下反射和透射比例的变化。看水面时低头垂直看能看到水底水面上的高光比较弱平视远望时水面像镜子一样把天空和周围物体反射进来而且水体和岸边的交界处会有一条亮亮的边。在Shader里计算菲涅尔标准做法是用法线方向和视线方向做点积然后用1减去这个点积得到的就是“视线偏离法线的程度”。对于平整水面法线基本朝上所以在画面边缘的像素视线几乎跟水面平行菲涅尔值会很高。这个值正好可以拿来给边缘混合一层亮色或者增加边缘泡沫强度。风格化水体中我不直接用原始菲涅尔值而是用Pow缩小取值范围把“边缘亮”收窄成一条干净的线而不是一整块渐变。这个技法在做日式卡通水、二次元场景水体时特别常用一条干净的白边能让水面的“手绘感”立刻出来。2.3 波浪法线两张贴图滚动不只是为了动画真正让水面“活”起来的是法线贴图上的扰动。我会准备两张方向、频率不同的法线贴图一张往斜上方滚动另一张往斜下方滚动然后对两张图采样的结果做归一化混合。这样做的原因是单一法线贴图滚动起来很容易产生“所有波浪朝一个方向走”的呆板感尤其是慢速观看时规律性会暴露无遗。两张不同方向、不同速率的法线叠加波峰和波谷会互相交错看起来就像有无数股水流在交织。在URP里做这一步时我会把UV分别乘以Tile值再各自加上Time乘以速度向量。速度向量的XY分量控制滚动方向和速度。混合法线时有个小坑两张法线图直接相加再归一化效果通常是对的但如果其中一张图是DXT5压缩的法线采样出来的值范围可能不完全一致需要在节点里把法线从贴图值重新映射到-1到1区间。这里的参数调节经验是滚动速度不要两边都设成正数否则整体运动感会很单一。一张正方向、一张反方向才是水面那种“来回涌动”感觉的来源。速度数值也不用大0.05到0.2之间通常就很自然数值一大水面看起来像开了快进。2.4 泡沫调制噪声是灵魂泡沫如果只是均匀铺在岸边效果会非常假。真实水面的泡沫一定有碎、有疏、有密还会随着波浪起伏形成各种不规则的条带。所以我做泡沫时不会直接用深度差去画一条硬边而是先把深度差乘上一个泡沫强度系数然后跟噪声图结合。常用的做法是用一张噪声贴图或者带噪声的Detail法线图叠加一个高倍Tiling让噪声在高频区域变化起来然后用深度差作为蒙版把“噪点化”的泡沫显示在浅水区。关键点是Softness参数也就是泡沫边缘的柔化程度。我一般把泡沫阈值控制在0.6到0.9之间阈值之上是泡沫阈值之下是水中间过渡宽度由Softness决定这样水面边缘不会有一条生硬的白色轮廓。还有一个容易被忽略的细节泡沫的UV滚动方向要和水面法线的整体流向一致。如果泡沫朝左滚法线波浪朝右滚视觉上会互相打架水面会显得分裂。3. 实操过程与核心环节实现3.1 准备工程、贴图资源和基础设置这一节开始是纯动手环节。我先把环境定下来一个URP工程场景里放一个地面用来做浅水区和岸边对比主光源用一个平行光场景里再加两三个不同颜色的点光源方便看水面对不同颜色光源的响应。贴图方面我准备了两张法线贴图一张是规则一点的细波纹另一张是偏条状的浪形法线。如果手头没有合适的图直接用引擎自带的基础法线贴图也能临时顶上。泡沫用的噪声图我习惯用一张高频的Perlin噪声Tiling设到5到10让它的颗粒感明显一些。关键的一步是开启管线的Depth Texture选项。在URP的通用渲染管线资源里找到Depth Texture的开关打开。这一步不做后面所有采样场景深度的逻辑都是空中楼阁。3.2 一套可用的URP风格化水体Shader下面这份HLSL是我在项目里用的一套简化版保留了核心逻辑删掉了工程相关的额外特性方便直接照着写。Shader Custom/StylizedWater { Properties { _ShallowColor (浅水颜色, Color) (0.2, 0.8, 0.8, 1.0) _DeepColor (深水颜色, Color) (0.02, 0.15, 0.3, 1.0) _DepthFactor (深度映射强度, Range(0, 2)) 1.0 _WaterAlpha (水体透明度, Range(0, 1)) 0.8 _WaveNormalA (法线贴图A, 2D) bump {} _WaveNormalB (法线贴图B, 2D) bump {} _WaveTilingA (波浪平铺A, Vector) (4, 4, 0, 0) _WaveTilingB (波浪平铺B, Vector) (2, 2, 0, 0) _WaveSpeed (波浪速度, Vector) (0.1, 0.1, -0.08, 0.12) _FoamColor (泡沫颜色, Color) (1, 1, 1, 1) _FoamThreshold (泡沫阈值, Range(0, 2)) 1.0 _FoamSoftness (泡沫柔边, Range(0.01, 1.0)) 0.2 _FoamNoise (泡沫噪声, 2D) white {} _SpecColor (高光颜色, Color) (1, 1, 1, 1) _Gloss (高光强度, Range(0, 1)) 0.6 } SubShader { Tags { RenderPipeline UniversalPipeline Queue Transparent RenderType Transparent } Pass { ZWrite Off Blend SrcAlpha OneMinusSrcAlpha HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; float4 screenPos : TEXCOORD1; float3 normalWS : TEXCOORD2; float3 viewDirWS : TEXCOORD3; }; sampler2D _CameraDepthTexture; TEXTURE2D(_WaveNormalA); SAMPLER(sampler_WaveNormalA); TEXTURE2D(_WaveNormalB); SAMPLER(sampler_WaveNormalB); TEXTURE2D(_FoamNoise); SAMPLER(sampler_FoamNoise); CBUFFER_START(UnityPerMaterial) float4 _ShallowColor; float4 _DeepColor; float _DepthFactor; float _WaterAlpha; float4 _WaveNormalA_ST; float4 _WaveNormalB_ST; float4 _WaveSpeed; float4 _FoamColor; float _FoamThreshold; float _FoamSoftness; float4 _SpecColor; float _Gloss; CBUFFER_END Varyings vert(Attributes input) { Varyings output; output.positionCS TransformObjectToHClip(input.positionOS.xyz); output.uv input.uv; output.screenPos ComputeScreenPos(output.positionCS); output.normalWS TransformObjectToWorldNormal(float3(0, 1, 0)); float3 posWS TransformObjectToWorld(input.positionOS.xyz); output.viewDirWS GetWorldSpaceViewDir(posWS); return output; } float4 frag(Varyings input) : SV_Target { float2 screenUV input.screenPos.xy / input.screenPos.w; float rawDepth SampleSceneDepth(screenUV); float sceneDepth LinearEyeDepth(rawDepth, _ZBufferParams); float surfaceDepth -TransformWorldToView(float3(0, 0, 0)).z; float waterDepth saturate((sceneDepth - surfaceDepth) / max(_DepthFactor, 0.001)); float depthLerp pow(waterDepth, 0.6); float3 baseColor lerp(_ShallowColor.rgb, _DeepColor.rgb, depthLerp); float alpha lerp(_WaterAlpha, 1.0, depthLerp); float2 uvA input.uv * _WaveTilingA.xy _Time.y * _WaveSpeed.xy; float2 uvB input.uv * _WaveTilingB.xy _Time.y * _WaveSpeed.zw; float3 normalA UnpackNormal(SAMPLE_TEXTURE2D(_WaveNormalA, sampler_WaveNormalA, uvA)); float3 normalB UnpackNormal(SAMPLE_TEXTURE2D(_WaveNormalB, sampler_WaveNormalB, uvB)); float3 normalTS normalize(float3(normalA.xy normalB.xy, normalA.z * normalB.z)); float3 normalWS normalize(float3(normalTS.xy, normalTS.z)); float3 viewDir normalize(input.viewDirWS); float fresnel pow(1.0 - saturate(dot(normalWS, viewDir)), 3.0); float3 fresnelColor lerp(_ShallowColor.rgb, _SpecColor.rgb, fresnel) * fresnel * 0.8; float NdotV dot(normalWS, viewDir); float3 halfDir normalize(_MainLightPosition.xyz viewDir); float spec pow(saturate(dot(normalWS, halfDir)), lerp(64, 2, _Gloss)); float3 specColor _SpecColor.rgb * spec * _MainLightColor.rgb; float foamNoise SAMPLE_TEXTURE2D(_FoamNoise, sampler_FoamNoise, input.uv * 8.0 _Time.y * 0.05).r; float foamLine 1.0 - waterDepth; float foamMask smoothstep(_FoamThreshold * 0.5, _FoamThreshold * 0.5 _FoamSoftness, foamLine foamNoise * 0.3); float3 foamColor _FoamColor.rgb * foamMask; float3 finalColor baseColor fresnelColor specColor foamColor; return float4(finalColor, alpha * _WaterAlpha); } ENDHLSL } } }这份代码有几个地方我要专门说明因为它们都是实操中容易出问题的地方。第一屏幕UV的获取。input.screenPos.xy / input.screenPos.w这一步必须放在片元函数里做不能在顶点阶段直接除否则在部分平台会得到错误的透视结果。第二场景深度和像素深度的差值。sceneDepth - surfaceDepth得到的就是从水面往下到水底的距离。我这里用max(_DepthFactor, 0.001)做除法是防止调参时把因子设成0导致整个水面变黑。第三法线混合。我用的是normalA.xy normalB.xy再把z分量重建出来这样写比直接相加两个float3更稳能避免两倍扰动把法线过度拉偏。第四泡沫的smoothstep。这里用了一个简单的经验调法foamLine是离水底越近越大的值加上噪声后再通过阈值和softness控制泡沫范围。注意噪声的Tiling是写死的8倍实际使用我会在材质面板再单独暴露一个参数方便不同尺寸的水面单独调。3.3 材质参数调节的现场记录我把这段代码给一个测试地面铺上之后开始逐个参数调。这个环节没有标准答案但我可以说一下我自己的调参起点和判断依据。先把_DepthFactor放在1.0这时候浅水和深水的分界通常很明显如果觉得分界线太生硬就把0.6的幂次改成0.4让深水色的过渡更平滑。_WaterAlpha在0.7到0.9之间比较安全太低会让水面下面的地面颜色透上来太多喧宾夺主太高则会失去半透明水体该有的通透感。法线贴图的平铺和速度我一开始倾向于给一个保守值_WaveTilingA设置为(4,4)_WaveTilingB设置为(2,2)速度向量分别设为(0.1,0.1)和(-0.08,0.12)。这样水面有来回拉扯感但又不会乱。如果想要更明显的浪感可以把平铺数降低到(2,2)让波纹更大块如果做的是小池塘就把平铺拉高到(6,6)甚至(8,8)让波纹更细小。泡沫这一步最容易翻车的地方是_FoamThreshold。有些人设成0.5结果整个水面都是泡沫有些人设成2.0泡沫又完全消失。我实测下来1.0左右比较稳妥然后靠_FoamSoftness控制边缘过渡。噪声图采样后的值范围大概在0到1之间所以这里的一句经验阈值控制泡沫出现的“距离”柔边控制泡沫边缘的“性格”。想浪大一点柔边小一点泡沫碎而锐利想柔和一点柔边给到0.4泡沫像棉絮一样慢慢化开。3.4 灯光、后处理和地面的配合一个水体能不能融入场景一半以上不在水Shader本身而在它跟周围环境的配合上。首先是地面。如果浅水区下面没有东西深度差就只是天空盒的深度水面会失去“下面有底”的真实感。我建议在岸边铺一块低饱和的地面颜色别太跳否则水一透明地面颜色直接决定水面的主色调。其次是灯光方向。我的高光用的是主方向光如果场景里有阴天或者夜晚环境主光强度很低这时候水面会显得很平。解决办法有两个一是给水体单独放一个只影响水面的平行光或点光源方向斜着打过来让高光破开二是把_Gloss调大让高光范围缩小、亮度集中环境光不强时也能看到水面的“泼光感”。最后是后处理。URP里加一个色调映射和泛光之后水面高光会好看很多尤其是泡沫和边缘亮线的位置泛光会把这些亮色晕开。但要注意泛光阈值别让整片浅水区都因为高亮而糊成一片。4. 性能优化与真机上的那些事4.1 半透明渲染的顺序与ZWrite水体是半透明物体渲染上默认要放在不透明物体之后所以SubShader里的Queue写了Transparent。这里有个很容易被忽略的坑ZWrite一定不能开。如果开了ZWrite水体自己会写深度后面的其他半透明物体就会被错误遮挡而且深度采样也会变得很奇怪。但关了ZWrite又有一个副作用多个半透明物体彼此之间可能出现穿插尤其是两个水体叠在一起时。我的处理方法是把水面按距离排序明确区分主水域和辅水域不要做多层半透叠堆。如果确实需要多层比如瀑布加水面那就把瀑布的深度写入打开让它先渲染。4.2 指令数、采样次数和带宽移动端上水体Shader最大的开销通常不是光照计算而是贴图采样。这份Shader里已经有三张采样法线A、法线B、泡沫噪声。如果再加一层深度采样实际上片元阶段有四次纹理或缓冲相关操作在高端渲染管线下还好但在移动端就属于必须盯着的开销了。我自己的优化策略是法线贴图用两张但尺寸压到512或者256因为水面的法线细节在高频上不太敏感。泡沫噪声如果只是做蒙版可以用灰度图走R通道采样不转RGB。在片元里能省的计算尽量省比如菲涅尔的Pow指数固定写成3不暴露参数省一个动态分支和指令开销。尽量用Half精度但深度计算和屏幕坐标保持Float避免精度不足导致水面在高光处出条纹。4.3 移动端和VR的特殊检查如果项目要跑移动端还有一个坑等着你_CameraDepthTexture在部分移动端GPU上精度不够会导致深水区和浅水区的分界带出现一条条色带。解决思路是用Dither或者噪声去抖动画分界比如在深度插值结果上叠加很小的高频噪声让色带变成颗粒感视觉上不再刺眼。如果目标是VR设备强烈建议再检查一下半透明排序和景深效果。VR里两眼视差对边缘错误极其敏感水面边缘如果出现半透明排序错误会让人明显感到不适。这种情况下我通常会把水面的半透明特调低一点或者用边缘泡沫把最烦人的穿插区域遮住。5. 常见问题与排查技巧实录我整理了一份自己在做水体时最常遇到的几个问题每一个都踩过每个对应的解决思路也都是反复试出来的现象常见原因解决思路水面全黑或没有深浅变色深度纹理未开启screenPos.w未正确还原检查URP资源里的Depth Texture开关片元中先除w再采样泡沫边缘特别僵硬FoamSoftness太小或噪声Tiling太低提高FoamSoftness噪声Tiling调到8到12之间波浪看起来像朝一个方向平移两张法线速度向量方向一致让速度向量一组XY为正一组XY为负增加方向差异水面高光一闪一闪或者花屏主光方向太直上法线混合后精度丢失把主光方向调成斜射法线混合后重新归一化必要时提高Shader精度水体跟岸边衔接生硬深度采样点像素坐标不对岸边没有浅水颜色缓冲确认水面离岸边有足够浅水区给地面铺与浅水色接近的颜色同一个水面在不同镜头角度下颜色突变菲涅尔指数过高或颜色混合方式不对把FresnelPow降低用Saturate限制边缘增强范围5.1 水体边缘一闪一闪的排查过程这里多说一个我印象很深的案例。某个晚上我调一个湖面发现镜头稍微移动湖岸边的水就会快速闪烁不是渐变的而是突然从深蓝色跳到白色。第一反应是法线贴图的问题换了贴图没改善又怀疑是抗锯齿关掉也没用。最后我用Frame Debugger一帧一帧地查才发现是水面和岸边地面的深度差在极端边缘处几乎为0而泡沫的smoothstep恰好把关口设在了0附近导致泡沫在极大极小之间反复横跳。解决方法是把泡沫阈值往上抬给泡沫一个最低的出现距离不要让它贴着0值边缘工作。这个经历让我养成了一个习惯凡是涉及深度差、距离差、阈值差的地方我都要先看一眼极端值的表现再做参数兜底。5.2 用Frame Debugger验证渲染顺序遇到半透明物体闪烁或者被遮挡错误时我第一步永远是打开Frame Debugger一帧一帧看物体的渲染顺序。很多看似是Shader写错了的问题最后都是Queue没设对、或者水体在工程里被自动分到了不透明队列。技美这个岗位一半时间其实是在做这类排查熟练掌握Debug工具比多会写几个节点更值钱。6. 扩展玩法与后续方向这一版水体做出来之后至少还能往下面几个方向继续延伸难度递增但每个都很有意思。6.1 顶点波浪让水面真的有起伏法线扰动只能骗过光照水面本身还是平的。如果要做出浪涌、船体浮沉、浮标晃动这类交互效果就需要在顶点阶段把高度图采样结果作用到顶点位置上。做法是准备一张高度图按法线图同样的速度和方向滚动然后叠加多个正弦波或者Gerstner波把顶点Y轴抬起来。这里最需要注意的是顶点波浪改了位置之后法线也要重算否则高光和波动方向对不上水面看起来像“贴了动态贴图的塑料布”。6.2 交互涟漪玩家踩水、物体落水交互涟漪通常靠一个“交互位置纹理”实现落水点把位置写入一张RenderTextureShader采样这张纹理去扰动附近的法线和泡沫。在移动端做这个功能关键是交互纹理分辨率不用太高256乘256就够用了重点是落水事件的写入频率和衰减速度要配合项目的运行帧率否则会有“断点涟漪”的感觉。6.3 冰面和水下焦散同一个Shader往冰面方向改只需要把透明度降低泡沫变成冰纹深度变色改成透光模糊再把高光调成锐利的镜面反射。焦散效果则是把焦散贴图投影到水底或者用屏幕空间扰动模拟。焦散在风格化水面上不用做太强一点点黄绿色光斑在岸边浅水区晃动就能让水的“体积感”大大提升。这几个方向如果展开写每一块都能单独成一篇。我后续打算在系列里挑一两个继续做深入比如Gerstner波的参数化实现或者URP下的交互水面方案。写这篇的时候我把之前项目里用的一堆参数又重新调了一遍还是踩了一个老坑在没开启深度纹理的工程里直接套Shader白白花了十分钟怀疑人生。先检查管线设置再查Shader逻辑这个顺序你记牢了会少走很多弯路。
阅读完成 · 觉得有帮助?