ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Unity卡通风格水Shader实现全解析:从顶点波浪到泡沫渲染

Unity卡通风格水Shader实现全解析:从顶点波浪到泡沫渲染 作为一个常年跟Shader打交道的Unity开发者我越来越觉得卡通风格水是个特别有意思的话题。刚入行的时候我也喜欢直接拖一个Asset Store里的水体资源看起来效果确实不错——反射、折射、焦散、体积波全都有。但放到游戏场景里总觉得别扭真实感水体的参数太多调起来心累风格化游戏里又显得过度渲染。后来我意识到卡通风格水不是写实水的简化版它有一套完全独立的视觉逻辑——把连续的信息离散化用大色块、硬边缘、明确的分层替代物理模拟的细腻渐变。这篇文章我就完整拆解一遍我自己在Unity中实现卡通风格水着色器的全过程包括Shader框架、波浪顶点动画、颜色分层、菲涅尔边缘、高光造型、泡沫处理以及几个真实项目里踩过的坑。1. 卡通水的卡到底卡在哪先搞清楚风格化需求再动手1.1 写实与卡通的水在视觉逻辑上有什么本质区别写实水追求的是物理正确光线在水面的反射、折射、散射、吸收全部接近真实世界。它的特征是连续渐变、信息量大、动态丰富。而卡通水的核心是概括把水与岸的边界处理成清晰的线把波浪的形态简化成圆润的起伏把光照分层压成几个色阶让观众一眼就认出这是水但不纠结于它像不像真的。这种区别有点像插画和摄影的关系。摄影追求瞬间光影的真实还原插画则是在造型准确的基础上做减法。做卡通水Shader的时候最忌讳的就是思维还停留在我要模拟得更真实结果做出来的东西既不真实也不卡通。1.2 卡通水最常见的四种风格分支不同风格的卡通水技术侧重点完全不一样风格类型代表作品核心技术点赛璐璐风色块分明塞尔达传说旷野之息硬边菲涅尔、色阶渐变、高饱和色带手绘纹理风茶杯头水纹理贴图帧动画、手绘波形UV扰动扁平几何风纪念碑谷纯色块透明渐变、极少的水面动画轻度风格化原神等二次元手游半透明、柔和菲涅尔、简单顶点波浪我这次实现的目标是赛璐璐硬边风格因为它的技术挑战最全面——需要处理颜色离散化、边缘划分、高光造型、波浪顶点动画做完这个其他风格基本就是调参问题。1.3 工具选择Shader Graph还是手写Shader现在的Unity版本URP的Shader Graph已经很好用了有些团队甚至全程不用写代码。但我的建议是卡通水用Shader Graph可以不过你要清楚自己在做什么因为节点多了以后调试效率反而比手写代码低。我自己的选择是手写ShaderLab HLSL原因有三个造轮子的过程能帮你建立完整的着色器知识体系尤其是光照模型、法线扰动、UV动画这些底层逻辑。手写Shader的迭代速度快改一个参数、看一个效果不用在节点之间找连线的来源。代码形态方便版本管理、团队复用几个项目沉淀下来就能形成一套自己的Shader库。当然如果你团队里其他成员习惯用Shader Graph也没有问题。但这一篇的内容我会用代码去讲原理。原理懂了你在节点里怎么连都是水到渠成的事。2. 基础框架搭建渲染队列、Blend模式与属性定义2.1 Tags和RenderType设计的细节Shader开头是Tags和SubShader的声明这一段看起来无关痛痒但实际上决定了很多后续行为。我常用的配置是这样SubShader { Tags { Queue Transparent RenderType Transparent IgnoreProjector True DisableBatching True } Pass { Cull Off ZWrite Off Blend SrcAlpha OneMinusSrcAlpha CGPROGRAM ... ENDCG } }几个关键的细节Queue用Transparent。因为卡通水在很多场景里都不一定是完全不透明的尤其是有岸边浅水区域的时候半透叠色效果会更好。这个选择有代价但后面我会讲如何弥补半透明带来的深度问题。DisableBatching设为True。Unity的静态批处理和SRP Batcher会把多个物体合并到一个绘制调用里但这对顶点动画是灾难——批处理后单个物体的顶点坐标会被合并到同一个模型空间导致世界坐标位置的判断全部失真。如果你的波浪基于世界坐标做相位偏移不关掉批处理不同位置的平面会出现完全一致的波浪形态。Cull Off双面渲染。水面被相机从水下看、或者地形尖角插入水面的时候可能看到背面双面可以避免这些情况下出现不该有的空面。2.2 Blend和ZWrite这对组合怎么选半透明Shader的经典选择是Blend SrcAlpha OneMinusSrcAlpha加上ZWrite Off。这里有个常见的认知误区很多人以为半透明必须ZWrite Off其实半透明物体如果不写深度多个半透明物体之间的排序会漂忽不定但如果写成ZWrite On本身又被混合模式打断会出现穿透和遮挡错乱。我的经验是水面这种大面片场景ZWrite Off没有问题因为水面的可见性基本靠深度比较和颜色混合来体现。真正要小心的是——水面下面的物体如果也是半透明的它们之间的绘制顺序不严格可能出现水面后面的鱼先画了结果水没有覆盖住它的情况。这个问题没有一劳永逸的解法只能靠设计上控制半透明物体的种类和数量。2.3 属性暴露哪些参数必须开放出来做美术向Shader属性面板的设计直接决定合作体验。一个只给固定效果、不可调的Shader美术同学用起来会想骂人但参数太多又会出现调了半天反而更难看的情况。我最终保留的属性如下Properties { _ShallowColor (浅水颜色, Color) (0.2, 0.6, 0.7, 1.0) _DeepColor (深水颜色, Color) (0.02, 0.15, 0.2, 1.0) _FoamColor (泡沫颜色, Color) (1.0, 1.0, 1.0, 0.85) _SpecColor (高光颜色, Color) (1.0, 1.0, 1.0, 1.0) _WaveSpeed (波浪速度, Range(0.0, 5.0)) 1.0 _WaveHeight (波浪高度, Range(0.0, 1.0)) 0.1 _WaveFreq (波浪频率, Range(0.5, 10.0)) 3.0 _FresnelPower(菲涅尔强度, Range(1.0, 8.0)) 3.5 _ColorFeather(色带羽化, Range(0.001, 0.5)) 0.02 _FoamWidth (泡沫宽度, Range(0.01, 1.0)) 0.15 _DeepStart (深水起始深度, Range(0.0, 2.0)) 0.6 }这些参数分四类颜色类浅水/深水/泡沫/高光、波形类速度/高度/频率、风格类菲涅尔/色带羽化、深度类泡沫宽度/深水起始深度。参数不是堆得越多越好每一个都对应一个核心视觉变量。比如_ColorFeather是专门用来控制色块边缘软硬度的这个参数在卡通水观察里至关重要直接决定了你是干净的赛璐璐还是糊成一片的水彩。3. 顶点波浪让水面从平面变成动态水体3.1 顶点着色器里的波浪数学基础顶点波浪最常见的方案是正弦波叠加。原理特别简单把平面网格每个顶点的世界位置作为输入对x和z坐标求正弦/余弦得到y方向的偏移。float4 vertexWorldPos mul(unity_ObjectToWorld, v.vertex); float wave 0.0; wave sin(vertexWorldPos.x * _WaveFreq _Time.y * _WaveSpeed) * _WaveHeight; wave sin(vertexWorldPos.z * _WaveFreq * 1.3 _Time.y * _WaveSpeed * 0.8) * _WaveHeight * 0.6; wave cos((vertexWorldPos.x vertexWorldPos.z) * _WaveFreq * 0.7 _Time.y * _WaveSpeed * 1.2) * _WaveHeight * 0.3; vertexWorldPos.y wave; o.pos mul(UNITY_MATRIX_VP, vertexWorldPos);这里有几个关键点值得展开为什么用世界坐标而不是模型坐标因为水面可能是移动的、旋转的、缩放的。如果基于模型坐标水面物移动位置后波浪形状完全不同基于世界坐标波浪在空间中是稳定的水面移动过去就有真实的水流推移感。为什么叠加多个不同频率的波单个正弦波看起来太机械——整个水面像一个跳动的果冻。叠加两个不同方向、不同频率、不同相位的波能够让波浪交叉处产生近似菱形网格纹理的干扰图案这是水体自然感的重要来源。_Time.y是Unity内置时间变量单位是秒。用它来驱动动画Shader在不同帧率和不同设备上表现一致。3.2 法线计算波浪动起来之后光照不能穿帮很多初学者只做了顶点位移没做法线更新。结果波浪动起来了但高光还是跟着原始平面法线走视觉效果立刻穿帮——水面明明是起伏的光照却像照在镜子上。计算法线最直接的方法是求导。正弦波y A * sin(k * x phi)的切线方向是dy/dx A * k * cos(k * x phi)把三个波的导数线性叠加可以得到每个顶点的偏导数然后重建法线float dx _WaveHeight * _WaveFreq * cos(vertexWorldPos.x * _WaveFreq _Time.y * _WaveSpeed); float dz _WaveHeight * _WaveFreq * cos(vertexWorldPos.z * _WaveFreq _Time.y * _WaveSpeed); // 偏导数构造切线空间 float3 tangent normalize(float3(1.0, dx, 0.0)); float3 bitangent normalize(float3(0.0, dz, 1.0)); float3 normalWS normalize(cross(bitangent, tangent));把这个normalWS传给片元着色器用于光照计算高光、菲涅尔都会跟着波浪起伏走。需要提醒的是多波叠加时求导公式要跟顶点位移公式严格对应。如果你在顶点位移里加了第三个波但求导只算前两个波浪看起来就会有凹凸不一的怪异感。3.3 网格分辨率顶点数给多少才合适顶点波浪的效果上限很大程度上取决于网格本身的细分度。一个默认的Unity Plane网格是10x10放在大场景里每个格子可能好几米宽正弦波根本形不成平滑的波浪形态。我的习惯是在建模软件里生成一个100x100的平面大约2万顶点或者直接用脚本在编辑器里生成这样水面网格在移动端大概能保持可接受的性能。// 用脚本生成一个细分平面的示例C# public static Mesh GenerateGridMesh(int resolution, float size) { Mesh mesh new Mesh(); Vector3[] vertices new Vector3[(resolution 1) * (resolution 1)]; Vector2[] uv new Vector2[vertices.Length]; int[] triangles new int[resolution * resolution * 6]; float halfExtent size * 0.5f; int triIndex 0; for (int y 0; y resolution; y) { for (int x 0; x resolution; x) { int index y * (resolution 1) x; vertices[index] new Vector3(-halfExtent size * x / resolution, 0.0f, -halfExtent size * y / resolution); uv[index] new Vector2((float)x / resolution, (float)y / resolution); if (x resolution y resolution) { triangles[triIndex] index; triangles[triIndex] index resolution 1; triangles[triIndex] index 1; triangles[triIndex] index 1; triangles[triIndex] index resolution 1; triangles[triIndex] index resolution 2; } } } mesh.vertices vertices; mesh.uv uv; mesh.triangles triangles; mesh.RecalculateNormals(); mesh.bounds new Bounds(Vector3.zero, new Vector3(size * 2, 100, size * 2)); return mesh; }注意最后设置的bounds这个细节很关键。网格细分比较大时如果Bounds不覆盖所有顶点相机裁剪时会把水面的一部分错误剔除掉出现水面消失一半的诡异问题。4. 核心视觉色阶渐变、菲涅尔边缘与造型高光4.1 深度信息驱动的颜色分层卡通水的颜色分层一般不是简单地面高度分层而是用深度纹理采样得到这个位置的水有多深再根据深度值把颜色分成几个明确的层级。获取深度信息的方式在Built-in管线和URP中略有不同。Built-in管线里用_CameraDepthTexture需要先在相机上开启深度纹理URP里则是通过_CameraDepthTexture配合SampleSceneDepth函数。// Built-in管线示例 float depth tex2Dproj(_CameraDepthTexture, UNITY_PROJ_COORD(i.screenPos)).r; float waterDepth LinearEyeDepth(depth) - i.screenPos.w; float depthFactor 1.0 - saturate(waterDepth / _DeepStart); float4 waterColor lerp(_DeepColor, _ShallowColor, depthFactor);代码含义是采样屏幕深度减去当前片段的屏幕空间深度i.screenPos.w得到水面到场景物体之间的真实距离。这个距离越大水越深颜色越偏向_DeepColor距离小颜色偏向_ShallowColor。4.2 用smoothstep把连续深度信息离散成色带到这一步waterColor已经是一个连续渐变了。但卡通风格不需要渐变——需要的是几个明确的色带。用smoothstep配合_ColorFeather做边缘羽化就能实现观感上是色带边缘却不硬得突兀的卡通效果。float depthFactor 1.0 - saturate(waterDepth / _DeepStart); float band1 smoothstep(0.0, _ColorFeather, depthFactor); float band2 smoothstep(0.5, 0.5 _ColorFeather, depthFactor); float band3 smoothstep(0.8, 0.8 _ColorFeather, depthFactor); float3 bandColor _DeepColor.rgb; bandColor lerp(bandColor, _MidColor.rgb, band1 * band2); bandColor lerp(bandColor, _ShallowColor.rgb, band3);这个方法的好处是你可以自由控制在某个深度区间显示什么颜色相当于用数值画水彩画的分块上色。如果你想要更极致的二次元硬边可以把_ColorFeather调到0.001几乎就是严格的阶梯跳变。4.3 菲涅尔边缘卡通水的高光轮廓线菲涅尔效应是指视角越接近水面掠射角反射越强。写实水用它做真实的反射强度分层卡通水用同一个公式但输出不再是连续的反射强度而是要么有边缘光、要么没有的布尔感。float fresnel pow(1.0 - saturate(dot(normalWS, viewDirWS)), _FresnelPower); // 卡通硬边处理 float fresnelEdge smoothstep(0.2, 0.2 _ColorFeather, fresnel); col.rgb _FoamColor.rgb * fresnelEdge * 0.6;在赛璐璐风格里这条边缘光相当于水面和岸边的描线非常提神。但要注意强度不能太高否则整个水面轮廓会亮成一片反而丢掉描线的重心感。4.4 造型高光让水面的太阳光变成卡通白云写实水高光是一个光滑的、亮斑随视角移动的椭圆形卡通水高光更像一片不规则的云它由几个不同频率的波形扰动构成再通过step截断成硬边缘。// 用世界坐标噪声扰动高光位置 float2 uvProj vertexWorldPos.xz / 8.0 _Time.y * 0.02; float specMask sin(uvProj.x * 3.0 _Time.y * 0.6) cos(uvProj.y * 4.0 - _Time.y * 0.4) sin((uvProj.x uvProj.y) * 2.0 _Time.y * 0.3); specMask specMask / 3.0; // 归一化到 [-1, 1] 区间 float specIntensity pow(saturate(dot(normalWS, halfDir)), _SpecPower); float specStep step(0.55, specIntensity); float maskStep step(0.35, specMask); // 卡通云状硬边 float finalSpec specStep * maskStep; col.rgb _SpecColor.rgb * finalSpec;核心逻辑是高光的出现同时依赖两个条件——光照方向满足Blinn-Phong条件specIntensity高以及世界坐标上的扰动贴图在那个位置恰好是亮区maskStep为1。叠加同样的扰动到UV上可以让高光像浮动的云朵一样不断变换形状而不是一个僵硬的圆斑。5. 岸上泡沫与水边交互完成度从70%到95%的关键5.1 泡沫产生的物理直觉与实现卡通水里泡沫的作用不只是白花花的浪它还在视觉上承担水和岸的分界线水面冲击物体的提示这两个功能。没有泡沫水面的边界会非常模糊整个场景像一潭死水。泡沫的生成条件一般有两个变量场景深度差水面离底部越近越容易产生泡沫。动态扰动波浪高度大的位置泡沫密度高。实现上用场景深度结合噪声纹理float foamDepthFactor saturate(1.0 - waterDepth / _FoamWidth); float foamNoise tex2D(_FoamTex, worldUV _Time.y * _FoamSpeed).r; // 用噪声阈值控制泡沫分布 float foamMask step(0.5, foamDepthFactor * foamNoise); float softFoam foamDepthFactor * smoothstep(0.3, 0.8, foamNoise); col.rgb lerp(col.rgb, _FoamColor.rgb, saturate(softFoam * 1.5 foamMask * 0.5));我一般准备两种泡沫一种沿岸边密集分布的高频小泡沫用噪声纹理的精细channel驱动另一种是大块成片的浪花用深度差阈值直接切出来。这样岸边既有碎片化的细节又有明确的白色色块。不过仅靠屏幕深度驱动泡沫在移动端有个问题——水面离水面物体极近时深度差会出现比较剧烈的抖动泡沫边缘会闪烁。我的经验是对深度差做时间上的EMA平滑在C#侧处理或者干脆把泡沫区域限制在一个相对宽的范围内避免深度差在阈值临界点反复横跳。5.2 UV滚动泡沫纹理的设计思路泡沫贴图不能太显眼否则就像把马赛克糊在水面上。我自己用Photoshop生成了一张512x512的灰阶噪声图没有用Perlin噪声生成器手工画了一些不同尺度的圆斑、条带和空隙导出为灰度图后作为泡沫纹理。手工绘制的好处是斑点的位置和形状可控不会出现均匀噪声一眼假的情况。UV滚动的方向需要跟场景水流方向匹配。如果你的场景里水流是从上游流向下游泡沫滚动方向也应一致否则眼睛会很快注意到水面在往哪个方向动的问题。一般的做法是统一用一个方向向量控制UV偏移float2 foamUV horizontalWorldPos.xy * _FoamTexTile; foamUV _Time.y * _FoamFlowDirection;如果你希望多个方向都有波浪交错可以为高光扰动和泡沫扰动分别设置不同的流动方向视觉层次会更丰富。5.3 深度纹理采样在URP下的适配用了URP之后深度采样和屏幕坐标的获取方式跟Built-in略有差异。URP里获取屏幕像素坐标、采样深度纹理的典型做法是float4 screenPos ComputeScreenPos(o.pos); float rawDepth SampleSceneDepth(screenPos.xy / screenPos.w); float sceneDepth LinearEyeDepth(rawDepth, _ZBufferParams); float waterDepth sceneDepth - screenPos.w;这里最需要注意的是SampleSceneDepth的输入坐标它在某些平台上是硬件屏幕坐标在另一些平台上是归一化的UV坐标。如果你在编辑器里看一切正常打包到真机后泡沫和深度分层错乱多半是这个坐标没有处理好。稳妥的方案是用ComputeScreenPos后除以w再做一次clamp到 [0,1] 区间。踩过一次这个坑之后我所有URP Shader都会加这个防护。6. 调试记录与性能优化真实项目中遇到的那些坑6.1 半透明物体的阴影问题半透明水面的阴影在Unity里默认是不参与投射的但实际项目里你往往希望水面确实能在岸上投射出阴影——尤其当水面上有大浪的时候浪的阴影能给场景增加不少立体感。问题在于Transparent队列的物体默认不写深度也不参与阴影投射。我的做法是单独做一个ShadowCaster PassPass { Name ShadowCaster Tags { LightMode ShadowCaster } CGPROGRAM #pragma vertex vertShadow #pragma fragment fragShadow #pragma multi_compile_shadowcaster // 顶点着色器里复用同样的波形计算 // 阴影投射只关心顶点位置不需要输出颜色 ENDCG }ShadowCaster Pass的原理是让引擎在生成阴影贴图时用同一套顶点动画把水面波形变换到阴影空间。不加这个Pass水面永远不会出现在阴影贴图里加了之后阴影就有了一层微妙的波纹轮廓。另外提醒一句半透明的Blend在ShadowCaster Pass里是无效的阴影贴图根本不关心颜色混合。所以ShadowCaster Pass的片元着色器只需要返回白色即可不需要处理任何贴图采样。6.2 顶点波浪在远距离和抗锯齿上的表现大平面水面有一个隐藏问题离相机远的片段相邻像素对应的世界坐标间隔非常大正弦波的高频变化会导致严重闪烁Aliasing。比如一个10米外的像素屏幕上一个像素可能对应世界空间0.05米两个像素之间的正弦波相位差非常小看起来还好但如果在高处俯瞰一个像素可能对应0.5米甚至1米这时候正弦波的相位差可以轻松超过半个周期像素之间就是完全随机的亮暗——这就是典型的摩尔纹闪烁。我的处理方案是在片元着色器里根据距离降频float distanceToCamera distance(_WorldSpaceCameraPos, vertexWorldPos.xyz); float lodFactor saturate(distanceToCamera / 100.0); // 根据距离调整波浪有效频率 float effectiveFreq lerp(_WaveFreq, _WaveFreq * 0.2, lodFactor);这样远处的波浪频率降下来细节损失被卡通风格本身的大色块掩盖了反而更概括近处保留细节远处稳定不闪。6.3 移动端性能开销我最终在Android真机上测试了这套Shader的性能机型是骁龙778G使用URP管线1080P分辨率。顶点阶段两次正弦、一次余弦加上法线求导大概消耗0.5ms的GPU时间对整个水面。片元阶段两次深度纹理采样、一次泡沫纹理采样、若干smoothstep和step在低端机上约1.2ms。整体峰值在同时看到水面、植被、角色特效的场景里水面Shader占了整体渲染预算的10%到15%。优化经验是移动端不要用_CameraDepthTexture配合原生屏幕坐标。优先使用URP的_CameraOpaqueTexture不透明纹理和深度纹理它们的生命周期管理更友好。纹理采样是最大开销。泡沫纹理可以用一张128x128的小图平铺多次视觉差异非常小。如果水面占屏幕面积很大可以降分辨率处理或者叠加一层远距离的静态颜色减少像素着色压力。6.4 Shader和C#参数同步的坑最后一个坑是我在接入项目时遇到的C#侧调整波浪速度、高度Shader里却纹丝不动或者只有重启材质才生效。排查发现是Shader属性面板里的参数没有写进材质的MaterialPropertyBlock。在多个水面片共享同一材质时如果你在C#里直接material.SetFloat所有水面都会一起变但如果用了MPB只有对应的那个水面会变。问题的坑在于MPB的赋值优先级高于材质属性面板但如果你在Inspector里拖拽属性面板没有刷新两者会产生面板显示值和实际渲染值不一致的错觉。我的建议是所有波浪参数通过C#侧的配置数据进行集中管理用MPB进行SetInspector面板的参数仅作为非运行时的编辑参考matPropertyBlock.SetFloat(_WaveSpeed, config.waveSpeed); matPropertyBlock.SetFloat(_WaveHeight, config.waveHeight); Renderer.SetPropertyBlock(matPropertyBlock);7. 最后再分享一点参数调试的心得如果你完全照着上面的代码搭了一个版本第一次跑起来的效果大概率不是最终想要的样子。这非常正常着色器的调参是一条看着不舒服→调参数→更难受→再调的迭代路径。但有几个调试心得可以帮你快速收敛第一颜色饱和度宁可往高了调。卡通风格最怕灰和高光不够明确饱和度低了再好的色阶都不好看。第二先调大色块的边缘再调细节。先把色带、菲涅尔、泡沫三个大块的_ColorFeather调到你能明确看到硬边然后一点点放宽羽化值找到硬但不太硬的临界点。第三波浪速度要跟游戏内时间感匹配。建议把_WaveSpeed控制在1.0到2.0之间调太大水面会像沸腾的汤调太小又失去了动态感。第四不要把菲涅尔边缘做得比泡沫还亮。我见过很多人为了描线效果把菲涅尔强度拉得特别高结果水面亮得像LED灯牌。理想情况下菲涅尔边缘亮度应该低于泡沫亮度否则整个水面的层次感全乱。做卡通风格的东西最难的不是技术而是克制。技术手段就那么多但怎么把参数调到恰好能传达这片水是活的、是融进这个世界的才是真正的修行动夫。希望这篇内容能帮你少走一些弯路如果你在实现过程中遇到了其他奇奇怪怪的问题欢迎一起讨论毕竟Shader这条路上大家一起踩坑才有意思。
返回列表