资讯动态

Unity卡通水Shader教程:深度纹理与顶点波浪实现

发布时间:2026/9/8 22:14:52 来源:尧图企业网站定制
做卡通水这个Shader说实话一开始我是拒绝的。以前项目里写实水体做得多SSR、反射探针、法线贴图叠三层PC上看着还行一上移动端整体变成幻灯片。后来接手一个风格化岛屿项目美术那边明确说不要写实水要那种干净、清爽、能看清海底颜色的卡通水面。我想着这不就是把PBR参数调调、饱和度拉高吗结果一上手才发现卡通水看着简单真正要把“卡通感”做出来反而比写实水更吃对渲染管线的理解。这篇东西就围绕Unity里实现卡通风格水着色器展开覆盖从视觉拆解、核心算法、完整Shader代码到场景搭建和问题排查的完整流程。不管是刚开始学Shader的新人还是已经被写实水折磨到头秃、想换风格化路线的开发者都可以直接照着做思路和代码我都尽量写得能跑能看效果。1. 卡通风格水到底在画什么很多人做风格化水的时候第一反应是“把写实水的参数调卡通一点”。这个思路从根上就错了。写实水的目标是接近真实物理效果光照、反射、折射都要尽量正确而卡通水的目标只有一个——让观众一眼看出“这是水”同时不破坏整个画面的风格化统一性。所以你要做的不是简化物理而是重构一套符合卡通审美的视觉规则。1.1 卡通水不是简化版的写实水我见过不少新手把PBR水体里那些参数一股脑调低金属度归零、光滑度拉高然后截图给美术看美术看一眼就说“脏”。因为写实水里的颜色和光影过渡天然是连续的、渐变的但卡通风格追求的是色块感和平涂感连续渐变在卡通画面里反而显得浑浊。真正的卡通水通常有这几个核心特征颜色分层非常明确深水和浅水之间有明显色带高光是一块块清晰利落的亮斑不做模糊过渡水边的泡沫和浪花像“描边”一样挂在岸线上波浪起伏轮廓清晰整体动态偏“弹跳”不太讲究物理波形。这几点拼起来观众才会觉得这是卡通水。另外还有一个容易被忽略的点卡通水的透明度通常不是均匀的越靠近岸边越透明越到深海越不透明。这个变化往往还带有噪声扰动让透明边界不整齐模拟浅滩的感觉。要做到这个效果单靠颜色贴图和噪声是不够的一定要用深度纹理来感知场景的深浅。1.2 把“卡通感”拆成可实现的渲染目标“卡通感”这个词太抽象了没法直接写代码。所以我在动手之前先把它拆成了几个能在Shader里量化的目标。第一是颜色分区我决定用色带的方式把连续的水面颜色切成3到4档让深浅水域边界清晰。第二是波浪形态顶点着色器里用多层正弦波叠加幅度、频率、方向都做成参数方便调出不同水感。第三是边缘光用菲涅尔效果让水面边缘亮起来模拟卡通里常见的外发光勾边感。第四是高光用半兰伯特或者点积的方式做一个高光阈值判断超出阈值的部分显示为清晰亮斑。这些目标拆完之后整个Shader的架构就清晰了顶点阶段处理波浪位移和法线重建片元阶段做深度混合、颜色分区、光照高光和边缘光。每一步都是独立模块改起来也方便。这也算是我这几年做Shader的一点心得先定视觉目标再反推技术方案比拿到一个需求直接开写要高效得多。2. 核心渲染算法拆解这一节是整篇文章的重头戏。我会把卡通水需要用到的几个核心渲染算法逐个拆开讲包括深度纹理的采样方式、顶点波浪的数学原理、法线扰动怎么跟噪声结合、色带和边缘光怎么实现。这些算法单独拿出来都不复杂但组合在一起的时候要注意的细节就多了。2.1 深度纹理让水面懂得深浅变化深度纹理这个名词听着高大上实际上就是相机先把场景渲染一遍但只输出“谁离相机多远”的信息存到一张纹理里。你在后处理或者半透明物体里采样这张纹理就能知道当前像素背后的物体到底离相机多远。水面的透明度判断靠的就是这个原理采样当前水面的深度再采样背景物体的深度两者相减差值越大说明水越深颜色越接近深水色差值越小说明水越浅就越透明越接近浅水色。这里面有几个容易踩坑的地方。第一个是在URP里默认相机不会生成深度纹理需要手动在C#脚本里设置Camera.depthTextureMode | DepthTextureMode.Depth。第二个是在片元着色器里水面的深度要自己计算用ComputeScreenPos得到屏幕坐标后再把w分量传进去最后用线性化方法把深度值从非线性变成线性否则算出来的深度差全被近裁剪面的精度吃掉了。还有一个很多教程不会提的点深度的比较要在同一个坐标系里做。水面深度用LinearEyeDepth背景深度采样后也做同样的线性化两者相减才是有实际意义的距离差。我见过有的项目直接把两个深度值拿来做smoothstep出来的效果就是岸边透明度突然断层怎么调都调不平。2.2 顶点波浪与法线扰动卡通水的波浪不需要物理级真实但需要视觉上“有感觉”。我的做法是在顶点着色器里叠加几层正弦波每层都有自己的方向、波长、速度和振幅。方向决定波浪往哪推波长决定波浪的宽窄速度决定波动快慢振幅决定波浪高度。这里有一个数学细节值得说一下如果只做高度位移不改变法线水面看起来就像一幅会动的贴图毫无体积感。因为光照计算依赖法线而法线是跟顶点位置绑定在一起的。正确的做法是在对顶点做波浪位移的同时对波浪函数求偏导数算出变化后的法线方向。通行的简化做法是先按两个轴的偏导近似切线再叉积求法线。不过在卡通水里我反而建议做得轻一点顶点波浪负责推动画感真正的表面质感交给片元阶段的噪声法线扰动。噪声法线的原理也不算复杂用一张噪声贴图采样两次一次以较快的UV滚动一次以较慢的速度滚动把两个采样结果组合起来作为扰动值叠加到顶点法线上。两个噪声叠加的好处是能模拟水流方向性的复杂感单层噪声看起来就是死板的贴图在平移。我习惯把第一层叫“主浪”UV滚动速度设得适中第二层叫“细波纹”滚动速度稍快但幅度小这样水面既有大浪的方向感又有小波纹的细节感。法线扰动会影响后续的高光计算所以一定要放在光照之前完成而且要记得对扰动量做归一化或者用强度参数控制不然高光会闪得乱七八糟。2.3 色带、高光与边缘光的风格化处理色带是卡通水的灵魂。连续渐变属于写实卡通需要的是“阶梯”。最简单的方法是直接把深度差丢进floor或者round里量化成整数但这样出来的边界非常生硬锯齿明显。我习惯用两段smoothstep做一个中间带先在浅水区算一个值在深水区算一个值然后根据深度差在两个值之间插值。这样既能保留色带的分层感又能在层与层之间留一点点过渡不至于像素级跳变。高光的处理思路跟PBR不同。PBR的高光是由微表面法线分布决定的卡通不需要这么精细只需要“有”和“没有”两个状态。做法也很直接用dot(法线, 光照方向)得到一个值把这个值减去某个阈值再用smoothstep收窄到一个很窄的范围最后乘上光的颜色。这个思路本质上是把Blinn-Phong的指数项换成可控的阈值项出来的高光是一块边缘分明的圆形亮斑相当卡通。边缘光用菲涅尔来实现。pow(1 - dot(法线, 视线方向), 强度)这个公式在很多Shader里都用过核心逻辑是当视线跟法线接近垂直时即你几乎以掠射角看水面边缘会更亮。卡通风格下这个菲涅尔效果可以调得夸张一点让水面周围出现一圈明显的光晕配合深水色和浅水色的分层视觉层次一下就出来了。3. 完整Shader实现与场景搭建讲了这么多原理最终还是要落到代码上。下面这个Shader我基于URP管线写的在Unity 2021及以上版本都能直接跑。它包含了顶点波浪、深度混合、噪声法线、色带、高光和边缘光这些模块可复制度较高。写完代码之后我再讲材质参数怎么配置、场景里要注意什么。3.1 URP下的ToonWater完整代码Shader Custom/ToonWater { Properties { _WaterColorDeep (Deep Water Color, Color) (0.0, 0.2, 0.35, 1) _WaterColorShallow (Shallow Water Color, Color) (0.2, 0.6, 0.7, 1) _FoamColor (Foam Color, Color) (0.9, 0.95, 1, 1) _EdgeFade (Edge Fade, Range(0.1, 5)) 0.8 _ColorDepth (Color Depth, Range(0.1, 5)) 1.5 _NoiseMap (Noise Map, 2D) bump {} _NoiseSpeed (Noise Speed, Vector) (0.05, 0.08, 0, 0) _NoiseScale (Noise Scale, Range(0.1, 5)) 1.2 _NoiseStrength (Noise Strength, Range(0.0, 1.0)) 0.3 _WaveA (Wave A (Dir, Wavelength, Amp, Speed), Vector) (1, 0.3, 0.15, 1.0) _WaveB (Wave B (Dir, Wavelength, Amp, Speed), Vector) (0.6, 0.8, 0.08, 1.4) _SpecColor (Specular Color, Color) (1, 1, 1, 1) _SpecThreshold (Specular Threshold, Range(0.0, 1.0)) 0.85 _SpecSoftness (Specular Softness, Range(0.001, 0.3)) 0.05 _RimColor (Rim Color, Color) (0.8, 0.9, 1, 1) _RimPower (Rim Power, Range(0.5, 8)) 3.0 } SubShader { Tags { RenderTypeTransparent QueueTransparent } Pass { Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Off 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 positionHCS : SV_POSITION; float2 uv : TEXCOORD0; float3 positionWS : TEXCOORD1; float3 normalWS : TEXCOORD2; float4 screenPos : TEXCOORD3; float3 viewDirWS : TEXCOORD4; }; CBUFFER_START(UnityPerMaterial) float4 _WaterColorDeep; float4 _WaterColorShallow; float4 _FoamColor; float _EdgeFade; float _ColorDepth; float4 _NoiseMap_TexelSize; float4 _NoiseSpeed; float _NoiseScale; float _NoiseStrength; float4 _WaveA; float4 _WaveB; float4 _SpecColor; float _SpecThreshold; float _SpecSoftness; float4 _RimColor; float _RimPower; CBUFFER_END TEXTURE2D(_NoiseMap); SAMPLER(sampler_NoiseMap); TEXTURE2D(_CameraDepthTexture); SAMPLER(sampler_CameraDepthTexture); float3 GetWaveNormal(float2 worldXZ, float time) { float2 uvA worldXZ * _WaveA.y _WaveA.xy * time * _WaveA.w; float2 uvB worldXZ * _WaveB.y _WaveB.xy * time * _WaveB.w; float waveA sin(uvA.x uvA.y); float waveB sin(uvB.x uvB.y); float3 normal float3(0, 1, 0); normal.x waveA * _WaveA.z * 0.6; normal.z waveB * _WaveB.z * 0.6; return normalize(normal); } Varyings vert (Attributes IN) { Varyings OUT; float time _Time.y; float waveA sin((IN.positionOS.xz * _WaveA.y _WaveA.xy * time * _WaveA.w).x (IN.positionOS.xz * _WaveA.y _WaveA.xy * time * _WaveA.w).y); float waveB sin((IN.positionOS.xz * _WaveB.y _WaveB.xy * time * _WaveB.w).x (IN.positionOS.xz * _WaveB.y _WaveB.xy * time * _WaveB.w).y); float3 posOS IN.positionOS.xyz; posOS.y waveA * _WaveA.z waveB * _WaveB.z; VertexPositionInputs posInputs GetVertexPositionInputs(posOS); VertexNormalInputs normalInputs GetVertexNormalInputs(float3(0, 1, 0)); OUT.positionHCS posInputs.positionCS; OUT.uv IN.uv; OUT.positionWS posInputs.positionWS; OUT.normalWS normalize(normalInputs.normalWS); OUT.screenPos ComputeScreenPos(posInputs.positionCS); OUT.viewDirWS GetWorldSpaceNormalizeViewDir(posInputs.positionWS); return OUT; } half4 frag (Varyings IN) : SV_Target { float time _Time.y; float2 screenUV IN.screenPos.xy / IN.screenPos.w; float rawDepth SAMPLE_TEXTURE2D(_CameraDepthTexture, sampler_CameraDepthTexture, screenUV).r; float sceneDepth LinearEyeDepth(rawDepth, _ZBufferParams); float waterDepth LinearEyeDepth(IN.screenPos.w, _ZBufferParams); float depthDiff saturate((sceneDepth - waterDepth) * _ColorDepth); float noise1 SAMPLE_TEXTURE2D(_NoiseMap, sampler_NoiseMap, IN.worldPosXZ * _NoiseScale _NoiseSpeed.xy * time).r; float noise2 SAMPLE_TEXTURE2D(_NoiseMap, sampler_NoiseMap, IN.worldPosXZ * _NoiseScale * 0.5 - _NoiseSpeed.zw * time).g; float noise (noise1 noise2) * 0.5; float3 normalWS normalize(IN.normalWS); normalWS.xz noise * _NoiseStrength; normalWS normalize(normalWS); Light mainLight GetMainLight(); float NdotL saturate(dot(normalWS, mainLight.direction)); float3 viewDirWS normalize(IN.viewDirWS); float3 halfDir normalize(mainLight.direction viewDirWS); float NdotH saturate(dot(normalWS, halfDir)); float spec smoothstep(_SpecThreshold - _SpecSoftness, _SpecThreshold _SpecSoftness, NdotH); float fresnel pow(1.0 - saturate(dot(normalWS, viewDirWS)), _RimPower); float shallowMix smoothstep(0.0, _EdgeFade, depthDiff); float deepFactor saturate(depthDiff * _ColorDepth); float band smoothstep(0.0, 0.4, shallowMix) * 0.5 smoothstep(0.4, 0.8, deepFactor) * 0.5; float4 baseColor lerp(_WaterColorDeep, _WaterColorShallow, band); baseColor.rgb mainLight.color * NdotL * 0.4; float foamLine smoothstep(_EdgeFade * 0.8, _EdgeFade, depthDiff); foamLine * 1.0 - smoothstep(0.0, 0.2, noise); baseColor.rgb lerp(baseColor.rgb, _FoamColor.rgb, foamLine * 0.8); baseColor.rgb _SpecColor.rgb * spec * 0.6; baseColor.rgb _RimColor.rgb * fresnel * 0.35; float alpha saturate(0.4 depthDiff * 0.6 - noise * 0.1); alpha smoothstep(0.05, 0.5, alpha); return half4(baseColor.rgb, alpha); } ENDHLSL } } FallBack Universal Render Pipeline/Unlit }这个Shader里有一个地方需要注意上面代码里我用了IN.worldPosXZ但Varyings结构体里没定义这个变量。严格来说你需要把float2 worldPosXZ加到Varyings里然后在vert里赋值OUT.worldPosXZ posInputs.positionWS.xz;。我写的时候偷了个懒这种省略容易让新人直接复制报错这里特意指出来补上就行。代码的整体流程是这样顶点阶段根据两个不同参数的正弦波叠加得到高度位移同时计算偏移后的法线片元阶段先采样深度纹理得到水面的深浅差值然后结合噪声扰动法线再用色带方式混合深水和浅水颜色叠加主光漫反射、泡沫线、风格化高光和菲涅尔边缘光最后根据深浅和噪声控制透明度。3.2 材质参数推荐配置代码写完之后参数调得好不好直接决定最终观感。我把自己项目里用的一套推荐配置列出来你可以照着敲一遍再根据自己的场景微调。参数推荐值说明Deep Water Color(0.02, 0.25, 0.4)深水色偏蓝绿不要加太多绿色否则会显脏Shallow Water Color(0.3, 0.75, 0.8)浅水色要比深水色亮两个等级以上Foam Color(0.92, 0.96, 1)泡沫不要用纯白偏蓝一点更自然Edge Fade0.6~1.0值越大岸边透明过渡范围越大Noise Scale1.0~1.8值越大噪声纹理越密集波纹越碎Noise Strength0.2~0.35太大会导致水面像揉皱的纸Wave A(1, 0.3, 0.12, 1.0)主浪波长0.3表示波距适中Wave B(0.6, 0.8, 0.06, 1.4)副浪波长更长幅度减半Specular Threshold0.82~0.9越大高光范围越小、越锐利Rim Power2.5~4值越小边缘光范围越宽这套参数在岛屿海面和盒子池塘环境下都测试过整体偏明亮、轻快。如果你要做黄昏或者夜晚的卡通水把Deep Water Color调暗边缘光换成暖橙色Rim Power稍微降低出来的味道完全不一样。3.3 场景搭建与C#辅助脚本Shader部分弄好之后场景搭建还有几个容易忽略的环节。第一个就是深度纹理URP下你得先让相机输出深度信息否则水面永远是一团灰色。在场景里放一个空物体挂上下面这个脚本using UnityEngine; [ExecuteAlways] public class WaterDepthSetup : MonoBehaviour { void OnEnable() { if (Camera.main ! null) { Camera.main.depthTextureMode | DepthTextureMode.Depth; } } }这个脚本的作用就是给主相机打一个标记让它在渲染时额外输出深度纹理。注意如果场景里有多台相机或者是用URP的相机Stack做多层渲染每一台实际渲染水面的相机都要设置这个标记。第二个是水面的Mesh。我建议直接用Unity内置的Plane但要把缩放拉大一点比如(20, 1, 20)这种级别。如果缩放太小的Plane配合大面积水面顶点波浪的采样密度不够波浪看起来会非常粗。有条件的话也可以自己在建模软件里做一个细分更多的网格顶点越多波浪细腻度越好。但注意顶点越多性能越差移动端要权衡。第三个是透明排序问题。水面材质要在QueueTransparent里这点代码里已经写好了但场景里如果还有其他透明物体比如玻璃、粒子、草叶它们的渲染顺序可能会让水面穿插看起来混乱。一般的处理方法是把水面的Queue值设为一个比较靠后的层级在URP的Renderer里也可以单独给水面设置一个Transparent Sorting Priority让它排在大多数半透明物体后面。4. 常见问题与排查实录每次发Shader相关的文章评论区问得最多的不是原理而是“我照着做了怎么效果不对”。这一节我把做卡通水过程中最常遇到的几个问题整理出来每一个都是我自己真正踩过的坑。4.1 深度纹理不生效怎么办表现是水面完全透明或者颜色很淡、没有深浅变化。排查方式分三步先确认C#脚本有没有挂上去Camera.main.depthTextureMode的赋值有没有真的执行再在Frame Debugger里看相机Pass有没有多出Depth相关的Pass最后在材质里临时输出深度值看看比如把像素颜色改成深度差确认是不是采样数据本身就有问题。还有一个隐蔽的问题如果你用URP的Renderer Feature或者后处理有些操作会把深度纹理重新拷贝或者覆盖掉。遇到这种情况最简单的方案不是在代码里跟它较劲而是换用URP的DepthNormalsPass把深度当成一张独立的RenderTexture存出来水面的Shader手动绑定这张纹理采样。这样绕过URP内部的深度管理可控性高很多。另外半透明物体自身不写入深度这是一个大坑。由于水面是半透明的它不会往深度缓冲里写自己的深度所以水面背后的场景物体深度是正常的。但是如果你在水面上再放一些树、角色之类的半透明物体它们可能不在深度纹理里水面就会错误地把它们当成“很深”的区域来渲染颜色会突然变深。解决办法是让这些物体用不透明队列渲染或者单独处理它们的深度。4.2 波浪不动、方向不对、闪烁的问题波浪不动大概率是_Time.y没有被正确传入。在URP里_Time默认是支持的但如果你在自定义的Shader里用了记得确保Shader没有被SRP Batcher做特殊处理导致时间值异常。我在实际项目里遇到过好多次_Time.y在编辑器里正常、打包后不动的诡异情况最后是用C#侧往材质里传时间值解决的。波浪方向不对通常是_WaveA参数里xyz分量乘出来的方向向量跟你预期不一致。这个建议先做实验把波浪函数简化成sin(worldPos.x * freq time * speed)看看是不是沿X轴传播再逐渐加参数。闪烁问题要分两种。一种是顶点波浪导致水面三角形之间出现裂缝这是因为正弦波位移了顶点但三角形之间的公共顶点没有被正确共享。解决办法是在建模软件里把平面网格的公共顶点焊接起来。另一种是片元阶段的高光闪烁通常是噪声纹理由Trilinear过滤或者Mipmap等级切换引起的。把噪声贴图的Generate Mip Maps关掉Filter Mode设成Bilinear基本能解决七成闪烁问题。4.3 性能优化与移动端注意点这个Shader在PC上运行毫无压力但拿到移动端就要仔细抠性能了。第一刀砍在噪声采样上我原本在片元阶段采样了3次噪声后来优化成2次视觉差异几乎没有。第二刀是顶点波浪的计算如果场景里水面面积很大但顶点密度不高可以把Wave B直接去掉只留主浪性能能提升不少。还有一个移动端很常见的问题半透明物体的Overdraw问题。水面面积大、又透明会跟背景色反复混合。如果水下有很多珊瑚、鱼类、植物Overdraw会非常夸张。方案有两个要么在水面Shader里限制采样距离太深的地方直接完全透明或完全不透明要么在URP的Renderer里对透明物体单独做逐物体烘焙这个实现成本高一些但效果更好。IOS设备上还要注意Half精度浮点的问题。我的测试机在部分Adreno GPU上水面的色带出现明显的banding就是一层一层横条纹。这是因为深度差在高精度浮点上算得准但半精度下精度不够。解决办法是把深度差相关的计算尽量保留在float最后一步插值再转half能缓解不少。有条件的话也可以给URP的Color Grading开Dithering从画面上直接混入微量噪声让色带变得不那么扎眼。5. 进阶玩法让卡通水真正融入场景基础版能跑了之后你会发现一个问题单独一个水面看着还行放进完整场景里就跟环境产生割裂感。这是因为水面和环境之间的交互太少。这一节我分享几个我自己用下来觉得效果极佳的进阶玩法不算复杂但能让整个画面的融合度上一个台阶。5.1 岸边泡沫与浪花线真正的岸边泡沫不是简单地在贴图上画一条白色条带而是应该跟地形深度、波浪高度联动。我在项目里做了这样一个进阶方案深度差小于某个阈值的位置视为岸边浅滩在这个区域用噪声对深度差做扰动让泡沫呈现出不规则的尖角和断断续续的曲线。做法上把代码里的foamLine改成跟两个噪声源相乘。主噪声控制泡沫的整体密度和形状第二个噪声用来切出不规则的缝隙这样泡沫就能模拟水拍打在岸上那种破碎感。这部分想做得更细还需要加入浪花的“贴岸”效果当顶点波浪的波峰抬升到一定程度时泡沫强度随着波峰高度增加这样浪花一波一波打上来泡沫也跟着一波一波涌起画面就活起来了。5.2 折射与颜色混合卡通水也需要折射但不一定要用GrabPass抓取整个屏幕图像。我在优化版本里用了一个讨巧的方案直接采样背景颜色但不对背景做真正的物理折射偏移而是用噪声对背景采样坐标做轻微扰动配合透明度混合。这样既能让水下的物体看起来有一点扭曲感又能严格控制性能开销。关键是这个方案需要一张背景颜色纹理也就是URP里的_CameraOpaqueTexture。在URP中用UniversalRenderPipelineAsset开启Opaque Texture选项然后在Shader里用两个不同的UV偏移采样背景纹理再把结果根据噪声强度混合模拟出两种不同深度的折射效果。水底比较浅时折射强度小水越深折射越强这个关系跟透明度一起联动效果相当自然。5.3 让水动起来的高级技巧如果你的项目允许更高的性能开销可以研究一下Gerstner波。Gerstner波比纯正弦波更接近真实水面的卷曲形态因为它在水平方向和垂直方向同时产生位移水面的波峰更尖锐、波谷更平坦。它跟正弦波最直观的区别是水面的顶点不是垂直上下跳动而是沿着一个圆形轨迹运动看起来就像真正的海浪在翻滚。实现Gerstner波其实不复杂核心公式是对每个波方向把顶点位置按某一方向压缩再沿法线方向做余弦或正弦偏移。叠加两三层Gerstner波之后水面就已经有很强烈的“活水”感觉了。但注意Gerstner波会让三角形产生拉伸和变形如果网格太粗、顶点太少效果会撕裂。所以用Gerstner波时网格密度要跟上移动端需要谨慎评估。还有一个更省性能的技巧是“视差波浪”。它实际上不对顶点做任何位移而是在片元里用当前法线方向对UV做一个偏移模拟水面高度方向的变化。这个方案在远处看效果不错但近距离非常容易被识破。我一般把它用在远景水面上近处保留顶点波浪远景用视差波浪远近结合视觉连续性和帧率都能兼顾。6. 最后的几点体会做了这么多年渲染我最大的感受是卡通风格不是写实风格的降级而是另一套审美体系的工程实现。它需要你更敏锐地抓住“形”而不是“理”要敢于把物理公式砍到只剩视觉上最有效的那部分。就拿卡通水来说放弃物理级反射和折射并不丢人丢人的是放弃了之后画面反而变得廉价、单调。调Shader参数的时候我建议每次只动一个参数。比如先把色带调成想要的分层再去动高光阈值等颜色和高光都满意了再补边缘光。动两个参数一起调出问题的时候根本不知道是谁带的节奏。这个习惯帮我省了无数个深夜。最后分享一个小技巧卡通水做完之后试试在场景里把相机拉远再看一眼整片水域。大部分情况下你会发现远处的水面和近处的水面观感差别很大这是正常的。因为在透视投影下远处的水面压缩了深度信息色带会自然变粗。要缓解这个问题可以在Shader里加一个基于视角距离的色带参数混合让远处色带层次少一点、近处层次多一点。这个细节很容易被忽略但恰恰是拉开成品和Demo差距的关键。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价