news 2026/9/7 3:10:28

用Unity Shader Graph实现卡通风格水着色器完整指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用Unity Shader Graph实现卡通风格水着色器完整指南

1. 项目背景与整体思路

想做卡通风格水着色器这件事,很多Unity开发者会下意识觉得"不就是把水的颜色改成蓝的嘛",真上手之后才发现完全不是这么回事。写实水渲染追求的是物理正确的反射折射、次表面散射、光线吸收衰减,而卡通水恰恰相反,它要做的是"提炼"——把水的形态特征抽象成几个高度风格化的视觉元素:干净分层的颜色、节奏感明确的波浪、边界清晰的泡沫、硬边高光。这套逻辑本质上是平面设计思维在3D渲染里的延伸,跟写实渲染是两套完全不同的话语体系。

这篇文章里我会用Unity的Shader Graph为主线,配合必要的HLSL代码补充,完整拆解一个卡通风格水着色器的实现过程。内容覆盖颜色分层、波浪顶点动画、卡通化高光、岸边泡沫、深度交互这五个核心模块,同时把我在实际项目中踩过的坑和排查思路一并写出来。适合已经能用Unity做基础场景、想往渲染方向深入的开发者阅读,Shader Graph的节点操作会让你快速上手,手写Shader的朋友也能从代码片段里找到参考。

先说清楚一个观念问题:卡通水的核心不是"节点连得多复杂",而是"每一个视觉元素都有明确的形状边界"。写实水可以靠噪声纹理把颜色糊在一起,因为物理上水就是连续介质;但卡通水一旦糊了,风格感就没了。所以整个实现里,我会反复用step函数和smoothstep的sharpness参数来压边界,这是贯穿所有模块的核心手法。

2. 核心渲染模块拆解

2.1 深水与浅水的颜色过渡逻辑

卡通水的第一层视觉是颜色。跟写实水用深度纹理做连续衰减不同,卡通水通常会把水面区域划分为浅水色和深水色两个色块,之间用一条清晰的过渡带分隔。这里的关键在于"过渡带的宽度和位置由什么决定"。

我习惯用两个要素来控制:一是水面网格高度与场景水体底部的高度差,这对应水深;二是相机视角与水面法线的夹角,这对应菲涅尔效应。两者混合之后,颜色过渡会同时具备"地形深浅"和"视角变化"两个维度的动态响应,而不是一个死板的平面渐变。

Shader Graph里实现的话,用Scene Depth Difference节点算出水深,经过smoothstep做区间映射,输出作为两个颜色之间的Lerp系数。smoothstep的两个边界值就是风格化的控制点:浅水区的深度上限和深水区的最小深度。我把边界值的差压得越小,颜色过渡越锐利,风格感越强;反之过渡越柔和。这个参数建议直接暴露到材质属性面板上,方便美术在项目里反复调。

注意:Scene Depth采样依赖相机的深度纹理。如果在URP里没开DepthTexture,这个节点拿到的值是0,水的颜色会直接变成浅水色且毫无过渡。这个问题在后面排查章节会详细讲。

2.2 波浪顶点动画的两种思路

顶点波浪的经典方案是Gerstner波,它能产生尖锐的波峰和宽缓的波谷,适合海洋场景。但Gerstner波有个特点:波峰处顶点会横向挤压,产生水面的"卷曲"感,这在写实海里很漂亮,在卡通风格里却容易显得乱。

卡通水面我反而推荐用多层正弦波叠加,或者退一步用Simple Noise配合时间偏移来做顶点位移。原因是卡通水的波浪要的是"韵律感"——波浪应该像动画帧一样清晰可读,而不是一串喧宾夺主的随机凹凸。实际项目里我常用的参数组合是两层正弦波:一层频率低、振幅大,负责水面整体起伏;另一层频率高、振幅小,负责表面细碎波动。两层的方向可以取不同值,产生近似十字交叉的涟漪效果。

顶点位移在Shader Graph里的实现路径是:Position节点 →AddVertex Position输入。注意要在Vertex分支里去改坐标,而不是在Fragment分支。很多初学者会把波形计算放在片元阶段,结果波浪只影响了颜色和法线,网格本身完全没有形变,水面看起来像"画"上去的而不是"动"起来的。

如果要写HLSL,核心代码就几行:

float waveHeight = sin(positionWS.x * _WaveA freq + _Time.y * _WaveSpeed) * _WaveAmpA + cos(positionWS.z * _WaveB freq + _Time.y * _WaveSpeed) * _WaveAmpB; positionWS.y += waveHeight;

这里有个经验值:波浪振幅不要超过水面网格尺寸的1/10,否则网格拉伸严重,法线会崩,在高光区域会出现肉眼可见的"撕裂感"。

2.3 卡通高光的硬边处理

卡通风格的高光跟写实最大的区别是"形状归整"。写实高光用Blinn-Phong或GGX,高光点有一个平滑的能量衰减过渡;卡通高光要的是把这块衰减用step压成硬边,有时还会故意做成几何形——比如一条带状高光或者一个圆角菱形。

最省事的做法是在半程向量和法线点乘的结果上做step:点乘大于阈值的区域显示高光,小于阈值的区域完全不显示。阈值这一步就把原本连续的高光范围切成了边界分明的两段。但直接step会带来严重的锯齿,尤其是低分辨率下高光边缘会"闪"。

我一般会在step之前先做一次smoothstep,保留一个很小的过渡带,过渡带宽度给0.02到0.05。这个值肉眼几乎看不出渐变感,但能消除大部分锯齿。如果项目对锯齿极度敏感,可以考虑在高光边缘用fwidth做抗锯齿修正:

float ndh = dot(normalWS, halfDir); float specMask = smoothstep(_SpecThreshold - fwidth(ndh) * 0.5, _SpecThreshold + fwidth(ndh) * 0.5, ndh);

这一招在移动端很实用,比开MSAA便宜得多。

另外,卡通水高光还有个常见需求是"高光跟随视角移动但形状不变"。这要靠把点乘结果做归一化处理,或者直接用法线扰动后的固定方向代替半程向量。具体做法是在高光计算里只采样固定的反射方向,而不是每一帧都实时计算视角向量。这个小技巧能让高光在水面上移动更"稳",风格上更接近三渲二的效果。

2.4 菲涅尔边缘与岸边泡沫

卡通水的边缘通常会用一条比较亮或者比较白的"描边"来强化轮廓,这跟日系赛璐璐角色描边的思路一脉相承。物理上光线在大角度入射时反射增强,这个现象在卡通渲染里被抽象成了一根明确的边缘光带。

实现上还是菲涅尔公式,只是把连续的衰减曲线改成硬边界。标准的Fresnel节点输出0到1的连续值,我先用Power节点把曲线集中到边缘区域,再用smoothstep压出清晰的光带。光带的宽度和位置各自暴露参数,边缘色一般用浅白色或浅蓝色,跟深水色形成强烈对比。

岸边泡沫的实现思路有两种,实际项目中我会叠加使用:第一,基于水面高度和场景深度差做泡沫带。深度差小的区域(岸边浅水区)浮现泡沫,深度差大的区域没有泡沫。用smoothstep在深度上做一个窗口区间,得到岸边泡沫的分布掩码。第二,在这个掩码上叠加噪声扰动,让泡沫边缘变得不规则,因为完全光滑的泡沫线看起来很假,像AI生成的示意图。

泡沫本身的颜色一般是纯白带一点不透明度,配合材质透明度做半透叠加。这里有一个要点:泡沫区域的颜色混合模式需要在Blend One OneBlend SrcAlpha OneMinusSrcAlpha之间选择。前者是加法混合,适合发光泡沫;后者是传统透明度混合,适合实心泡沫。卡通风格推荐后者,因为泡沫需要遮挡底部的水色才能有"浮在水面上"的层次感。

3. 实操:搭建卡通水Shader Graph材质

3.1 基础设置与节点图结构

在动手连节点之前,先把Shader Graph配置好。我建议用URP的Lit Shader Graph作为起点,但要做两个关键修改:Surface Type改成Transparent,Render Face改成Both。前者让水面支持透明度混合,后者保证你在水面下方看的时候也能看到颜色,而不是一片黑。如果项目不需要水下视角,Render Face保持Front就行,性能会稍好。

URP里的Lit Shader默认会套用一堆基于物理的光照计算,这在卡通水里面反而是干扰。我自己更常用Unlit模板然后手动接光照参数,这看起来工作量大了,实际上可控性高得多。Unlit模板加一个Sample Cubemap节点或者手动画一个Blinn-Phong高光,也就三四个节点的事,但最终的风格化空间是物理渲染给不了的。

节点图画出来大概是这个结构:左边入口是Vertex阶段的PositionNormal,中间走波浪和法线扰动,右边出口是Base Color、Alpha、Metallic(设为0)、Smoothness(设为0.6到0.8)。法线部分我会用一个Tiling And Offset节点配合Noise Sampler生成扰动法线,贴到Normal通道上。卡通风格的法线扰动不要用太细密的高频噪声,否则高光会碎,建议噪声Tiling控制在2到4之间,频率低一点。

3.2 颜色、波浪、高光与泡沫的节点组合

颜色分层部分,我建议这样连:Scene Depth节点 →Difference(减去物体自身深度)→Divide(除以一个可调参数,把深度值映射到0到1区间)→SmoothStep(得到浅深水混合系数)→Lerp连接浅水色和深水色。这串节点基本是淡水体的标配。

波浪要放在Vertex阶段,与颜色通道互不干扰。用一个Add节点把两个方向的正弦波叠加,结果接到Vertex Position的Y轴分量上。注意这里要修改的是Object Space还是World Space坐标,我推荐直接在Object Space下操作,这样水面网格可以随便挪位置,波浪相位保持一致。

法线扰动放在Fragment阶段,在Normal Vector输出前用一个Normal From Height节点配合噪声贴图生成。做这一步是为了让高光看起来像"碎在水面上",而不是一整块平面反射。

高光结构简单,View DirectionNormal VectorDot Product里算反射强度,再用SmoothStep压边界。如果你想要高光跟随波浪移动,就把法线扰动后的向量接进去;想要稳定的"图层感"高光,就用不扰动的法线。

泡沫节点组是最后拼图:Scene Depth Difference再次被取出来,用SmoothStep生成窗口掩码,乘以Noise Sampler得到的噪波值,结果作为泡沫颜色和透明度的权重,最后跟前面算好的水体颜色做Lerp。这里泡沫的透明度我习惯不完全压到1,保留大概0.85到0.95,这样泡沫边缘跟水色之间会有一点透光,视觉上更自然。

3.3 法线扰动与URP渲染管线的适配细节

URP里如果你选了Lit作为Shader Graph的Master Stack,它默认已经帮你处理了雾、阴影、光照贴图这些。但卡通水一般不进阴影,所以在Surface Options里把Cast Shadows关掉,同时把Receive Shadows也关掉。否则水面上会出现树的阴影投射,观感上会立刻从"卡通"滑向"半写实",很破坏整体风格。

用的是Unlit模板的话,这些默认都不存在,你需要自己处理跟URP的协调。推荐在URP里把水面的Renderer Priority设置得比较靠后,确保水面画在所有不透明物体之后。不然水面和地形互相穿插时,半透明物体的深度写入会造成很奇怪的"穿模"。特别是在有几层瀑布、溪流的场景里,水面绘制顺序错了,泡沫会莫名其妙地浮到石头上面。

另外要注意URP版本差异。URP 12(Unity 2021)以后的Shader Graph里,Scene Depth节点的输出值分辨率跟相机深度纹理一致,如果你的相机开了Opaque Texture但没有开Depth Texture,采样的深度是不准确的。我在项目里会同时把两个选项都打开,反正URP里这两个开关基本不占额外性能,还能避免后面调试时被这种低级配置问题坑半天。

3.4 用HLSL补充Shader Graph做不到的部分

Shader Graph覆盖90%需求没问题,但有几个硬伤:一是Custom Function节点频繁使用容易让节点图变得混乱;二是一些需要循环或分支的算法(比如多层Gerstner波求和)用节点连会非常痛苦。遇到这种场景,我建议直接在Custom Function里写一段HLSL代码。

下面是多层Gerstner波的HLSL片段,可以直接当Custom Function用:

void GerstnerWaves_float(float3 worldPos, float time, float waveCount, float amplitude, float steepness, float wavelength, out float3 result) { float3 pos = float3(0, 0, 0); for (int i = 0; i < waveCount; i++) { float2 dir = normalize(float2(sin(i * 1.3), cos(i * 0.7))); float speed = sqrt(9.8 / wavelength) * 2.0; float freq = 2.0 * 3.14159 / wavelength; float phase = dot(dir, worldPos.xz) * freq + time * speed; float displacement = amplitude * sin(phase); pos.x += dir.x * displacement * steepness; pos.z += dir.y * displacement * steepness; pos.y += amplitude * cos(phase); } result = pos; }

这里要注意Gerstner波参数:steepness这个值不要超过1.0,否则波的尖峰会过度俯冲下来,产生自交。amplitudewavelength的比值建议控制在1/20到1/40之间,跟之前说的"振幅不超过网格尺寸1/10"类似,这是经验上保持网格稳定性的安全区。

另外一个需要HLSL介入的场景是泡沫的"交互响应"——比如角色踩入水面时,脚边的泡沫要溅开。这个如果用Shader Graph连,要传递位置、比较距离、衰减,散成一堆节点之后完全没法读。用HLSL写一个基于世界空间的球形判定就简单了,每帧传入角色位置和一个半径参数,让泡沫权重在一个半径范围内逐渐衰减到0。

4. 常见问题与排查技巧

4.1 水面透明排序与渲染队列异常

最常见的问题大概是水面跟地形穿插时出现奇怪的遮挡。排查思路第一步是看Surface TypeRender Queue。Unity默认的Transparent队列是3000,比Geometry(2000)靠后,正常应该后画。但如果的水面材质里混用了别的Shader(比如某些粒子Shader改的),渲染队列就可能变成2000甚至2500,导致水面先画、地形后画,结果地形把水面盖住了。

解决方法是手动指定渲染队列:在URP的Shader Graph里可以直接在Surface Options里设置Render Queue为Transparent,或者通过代码在运行时给材质设置renderQueue = (int)UnityEngine.Rendering.RenderQueue.Transparent。如果需要几层瀑布叠在一起,还要进一步细分——比如把最上层的瀑布设成3000,中间的水潭设成3001,泡沫设成3002。同队列内部Unity会按距离再排序,但半透明物体之间没法保证顺序,所以Renderer Priority要手动调。

还有一个跟透明排序强相关的问题是"自遮挡":水面上叠加的泡沫层跟水底完全处在同一平面时,因为深度精度不足,会出现闪烁。解决办法很简单——泡沫片元的深度值拉偏一点点,或者干脆让泡沫的渲染队列跟水错开一位。预算允许的情况下,我推荐泡沫单独一个SubShader,配合Offset指令做深度偏移:

SubShader { Tags { "Queue"="Transparent+1" } Offset -1, -1 // ... }

这样泡沫永远画在水面之上,不会有闪面问题。

4.2 深度纹理未开启导致泡沫失效

前面提过Scene Depth依赖深度纹理。实际排查泡沫不显示的问题时,我几乎每次都能在项目设置里找到原因——URP Asset的Depth Texture选项是关的。

开启路径:Project Settings → Graphics → URP Asset → Depth Texture勾选。这里有个容易忽略的点:如果你在场景里用了多个URP Asset(比如不同画质档位各一个),每一个都要勾选,运行时会按当前激活的那个Asset走。

还有一种情况是深度纹理开着,但泡沫还是不对。这时候要检查用的是Scene Depth还是Scene Color。一些教程里会把两个节点搞混,Scene Color是相机看到的颜色缓冲,不是深度缓冲,采样出来是一片乱码。判断方法简单粗暴:把泡沫权重直接设成1看颜色对不对,再用Scene Depth节点做可视化,看水面上呈现的是不是黑白深度灰阶图。如果灰阶图看起来正常,那基本说明问题在后续的smoothstep参数上。

4.3 不同渲染管线下的兼容性问题

如果你的项目跑的是Built-in管线,Shader Graph的节点导出成Shader后代码差异非常大。主要问题一个是API路径不一样(SystemInfo的宏定义不同),另一个是URP里有的关键字(如_RECEIVE_SHADOWS_OFF)在Built-in里不存在,编译会报一系列未定义错误。

我的处理方式是给项目统一用URP,或者在写Shader时做一次宏判断:

#if defined(_RECEIVE_SHADOWS_OFF) // URP shadow handling #else // Built-in shadow handling #endif

不过这属于下策,因为不同管线的光照模型本身差异就很大。如果目标是同时兼容多管线,建议Shader Graph里全部用Unlit模板,把光照计算完全交给手动编码,这样至少在跨管线上不会有奇怪的编译错误。

移动端还有一个单独的坑:OpenGL ES 3.0以下的设备不支持某些高级纹理格式(比如BC7),如果美术给的噪声贴图是BC7压缩,在部分Android真机上会显示成黑块或者花屏。卡通水的噪声贴图通常很小,我干脆不压缩,用RGBA32格式,换来的是绝对稳妥的兼容性。512x512的噪声贴图在内存里占1MB,对移动游戏来说完全可以接受。

4.4 移动端性能优化经验谈

卡通水在移动端的性能瓶颈主要出在三个地方:节点图规模、过度绘制和动态分支。先说节点图,一个完整的卡通水Shader Graph大概40到60个节点,这在桌面平台上连眼皮都不眨,但在部分手机GPU上,Shader编译时间可能直接翻倍,运行时也会因为寄存器压力大而掉帧。

优化思路是合并计算:把两个smoothstep共用一组参数,把泡沫的噪声采样和法线扰动采样合并到同一张贴图,用RG通道存法线,B通道存泡沫。这种技巧在贴图资源管理里叫"通道打包",在Shader里就能省掉一次采样器的开销。移动端采样器数量和金贵,能省就省。

过度绘制方面,水面本身是一张覆盖大范围区域的网格,半透明混合意味着每个像素至少要执行一次额外混合。如果你的水面网格是100x100的平面,像素覆盖率又高,建议把水面拆成几个小块,只在可见范围内保留高精度密度。或者用LOD组,远距离切换到一个没有泡沫、只有一个颜色和一层高光的简化版本。我在实际项目里就做过"远距离无泡沫版本",肉眼基本看不出区别,能节省大概20%的GPU开销。

动态分支(比如在Shader里用if判断平台)尽量少用,GPU的uniform分支不便宜,而且不同GPU的处理方式差异很大。宁可多写两个Pass,或者用同一套代码配合关键字,也不要让一件简单的事靠分支跳来跳去。

5. 扩展方向:从静态水到交互水体

实现到这里,已经有一个能看能用的卡通水了。但如果想让水面真正跟场景互动起来,有两条延展路径值得尝试。

第一条路径是角色与水面的交互。角色涉水时,水面应该在接触点周围泛起涟漪和泡沫。这个不是靠Shader单方面能解决的,需要C#脚本把碰撞点位置传给材质。做法是:脚本在Update里检测角色的脚部位置,如果低于水面高度,就把位置写入一个全局Vector数组,同时把时间戳也传进去;Shader这边维护一个固定数量的波纹数组,每帧加上基于距离的时间衰减函数,在顶点位移里叠加一圈高度偏移,在泡沫权重里叠加一圈泡沫溅射。这套方案在移动端也能跑,关键在于控制波纹数量,我一般控制在16个以内。

第二条路径是改变波浪风格,从"平静水潭"切换到"汹涌海洋"。核心变化是三处:一是把正弦波换成多层Gerstner,让波形有锋利的浪尖;二是把颜色分层从两段改成三段,增加一个"浪花白"中间层;三是把高光的形状从圆点改成沿波峰方向的狭长形。这三处改动在Shader层面都是参数级别的调整,不改架构,但视觉输出是两滴水。

如果想把参数暴露给非程序的同学做场景调节,记得把所有关键数值(水深阈值、泡沫宽度、波浪振幅、高光锐度等)做成材质属性,并起好规范命名的属性名,比如_ShallowColor_DeepColor_FoamThreshold这些。这在团队协作里能省掉大量来回沟通的成本。

动手试的时候,建议从一个非常小的测试场景开始——一张平面网格,一个方向光,一台相机。把Shader Graph每加一个模块就运行一次,反复调参数,感受每个节点对最终画面的影响。这个流程看起来基础,但恰恰是理解卡通水渲染最快的路径。我在最初学习这套方案的时候,就是靠这种方式慢慢把节点图"调"成自己的肌肉记忆的,之后再放进正式项目,遇到问题心里基本就有数了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 3:10:20

网络工程师必学Linux:核心技能与实操指南

简介&#xff1a;网络工程师Linux操作系统理论课程资料&#xff0c;面向备战软考网络工程师科目及系统学习Linux基础知识的读者。内容从实验环境搭建入手&#xff0c;依次讲解Linux硬件与硬盘划分、图形模式与文字模式切换、基本指令输入与在线求助、正确的关机方式&#xff0c…

作者头像 李华
网站建设 2026/9/7 3:09:38

主站与从站为何必须一起学:从EtherCAT到Modbus TCP的调试思路

主站和从站之间做选择&#xff0c;是很多刚接触工业通信的人反复纠结的问题。有人觉得主站是总线的大脑&#xff0c;周期调度、报文组织、错误处理全在主站&#xff0c;不会主站等于没入门&#xff1b;有人觉得现场挂的全是从站&#xff0c;不懂从站怎么解决单站故障。实际上&a…

作者头像 李华
网站建设 2026/9/7 3:08:15

工业自动化3T技术:智能扭矩、张力、温度协同控制实践指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 3:07:46

FPGA 100G UDP协议栈开源移植实战:从CMAC适配到线速打流

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 3:07:17

OpenGL与C++图形学入门:渲染管线、着色器与光照模型实战解析

简介&#xff1a;计算机图形学编程&#xff08;使用OpenGL与C&#xff09;课程配套辅助材料&#xff0c;面向正在学习图形学基础与OpenGL开发的学生&#xff0c;可配合教材边读边翻阅。压缩包共1035个文件&#xff0c;约470.45MB&#xff1b;内容以pptx课件、219个cpp与136个h源…

作者头像 李华