☰
Unity点击特效实战:用Shader实现水波纹与燃烧效果
2026/10/14 8:40:12 网站建设 项目流程

做交互类项目时,最让玩家直观感受到“反馈”的,往往不是加血掉血那排UI数字,而是你手指一点屏幕、地面就给出一个响应。水波扩散、地面燃烧这类点击特效,看起来简单,真动手做会发现一堆问题:粒子丢上去太假,一会儿就变成几片半透明纸;用Animation做,又死板得像PPT动画。我做这类效果的第一选择是Shader:在角色脚下或地面材质里,用着色器实时计算点击点的影响范围,通过时间驱动波纹扩散和燃烧侵蚀。这篇文章就把“水”“燃烧”两个点击特效从原理到落地完整拆一遍,包括坐标转换、Shader传参、多次点击叠加、移动端性能优化以及我踩过的坑,适合刚开始接触特效Shader、或者做交互Demo想要更真实反馈的Unity开发者。

1. 为什么点击特效优先用Shader做

1.1 粒子、动画、Shader方案横评

很多同学问我,点击水波为什么不用 Particle System,往点击点扔一堆水滴粒子不是更省事?确实,粒子能做出好看的水花飞溅,但如果你想要的是“地面/湖面本身出现一圈圈扩散的波澜”,粒子的短板就暴露了。

方案视觉贴合度性能成本交互精度与场景融合度实现难度
粒子系统中等,效果比较“浮”每点击生成几十上百粒子,Draw Call高位置容易控制一般,粒子总像飘在表面低
动画贴图低,重复感强低需要预先烘焙多个点位差,贴图边缘生硬低
Shader特效高,光影随表面法线变化极低,几个浮点运算差坐标由射线精确传入好,特效真正“长”在物体表面中

我做水波特效时一开始也试过粒子,踩了个很典型的坑:粒子打在湖面上,从侧面看是立着的,完全没有“涟漪沿着水面铺开”的感觉。想让它贴合地面需要手动拖模型朝向,每个点击点都要处理旋转,遇到斜坡就彻底翻车。后来我把涟漪逻辑搬进了Shader,效果立刻不一样了——波浪沿着材质表面自然扩散,法线一亮一暗,显然是地面本身在“动”,而不是特效贴在表面。

1.2 纯代码Shader和Shader Graph怎么选

Unity里做Shader有两条路:图形化连线的Shader Graph,和直接写HLSL代码的Shader文件。

Shader Graph的优势是可视化,每个节点就是一块积木,调颜色、调范围都很直觉,适合不太熟悉数学的后端策划。但它的劣势也在“图形化”:当你想维护一组点击点数组、在循环里逐个计算距离时,节点图会变得非常臃肿,甚至要为了“循环”额外引一个For Loop节点。这时候纯代码Shader的优越性就出来了——数组、for循环、函数封装,都是顺手的事。

所以我的建议是:简单一次性水波,用Shader Graph能应付;凡是涉及“多点击点”“动态坐标传入”“随时间变化的状态机”,直接写HLSL。下面的代码我全部基于Unity 2021及以上版本的URP(Universal Render Pipeline)管线,如果你还在用内置渲染管线,语法细节需要微调,但核心思路完全一致。

2. 水波纹点击特效:从数学原理到Shader代码

2.1 波纹扩散的数学模型

水波的特效核心,不是“画一个逐渐变大的圆”,而是模拟一个向四周扩散的波前。初中物理就学过,水波是圆环形的疏密波:波峰和波谷交替向外推进。

用数学表达就是:以点击点为中心,波纹半径随时间线性增大,某个位置的位移量等于以“该点到点击点的距离”为变量的正弦函数。我把这个模型写成代码能用的形式:

距离 r = distance(当前片元位置, 点击点位置) 相位 wavePhase = r - 扩散速度 * 时间 波纹强度 = sin(wavePhase * 波数)

用sin而不是直接画一个圆,就是因为正弦天然自带波峰波谷交替。同一个半径上,相位不同,有的地方凸起,有的地方凹陷,这才像水。波数越大,波纹越密;扩散速度控制涟漪往外跑的快慢。

来看我实际使用的核心Shader代码:

Shader "Custom/WaterWaveClick" { Properties { _BaseColor ("水面色", Color) = (0.1, 0.25, 0.4, 1) _WaveColor ("波纹亮色", Color) = (0.8, 0.95, 1.0, 1) _ClickData ("点击数据(位置xyz+时间w)", Vector) = (0, 0, 0, 0) _WaveSpeed ("扩散速度", Float) = 3.0 _MaxRadius ("最大半径", Float) = 4.0 _WaveCount ("波纹圈数", Float) = 6.0 } SubShader { Tags { "RenderPipeline"="UniversalPipeline" "RenderType"="Opaque" } Pass { HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" CBUFFER_START(UnityPerMaterial) float4 _BaseColor; float4 _WaveColor; float4 _ClickData; float _WaveSpeed; float _MaxRadius; float _WaveCount; CBUFFER_END struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionHCS : SV_POSITION; float3 localPos : TEXCOORD0; }; Varyings vert(Attributes IN) { Varyings OUT; OUT.positionHCS = TransformObjectToHClip(IN.positionOS.xyz); OUT.localPos = IN.positionOS.xyz; return OUT; } float4 frag(Varyings IN) : SV_Target { float elapsed = _Time.y - _ClickData.w; // 只在点击效果存活时间内计算 if (elapsed < 0.0 || elapsed > _MaxRadius / _WaveSpeed) return _BaseColor; float dist = distance(IN.localPos, _ClickData.xyz); float wavePhase = dist - elapsed * _WaveSpeed; float wave = sin(wavePhase * _WaveCount); // 波纹强度随半径衰减,避免边缘硬切 float fade = 1.0 - smoothstep(0.0, _MaxRadius, dist); float strength = wave * fade; float3 color = lerp(_BaseColor.rgb, _WaveColor.rgb, strength * 0.35); return float4(color, 1.0); } ENDHLSL } } }

注意我传了一个_ClickData,它的xyz存点击点的模型空间坐标,w存点击发生的时间。Shader里用_Time.y - _ClickData.w算出这个点击已经发生了多久,再通过比较elapsed和_MaxRadius / _WaveSpeed判断波纹是否已经扩散到最大半径。超出寿命就直接返回原色,省去无意义的sin计算。

2.2 法线扰动:让水面真正“动”起来

上面那版代码虽然能看到一圈圈波纹,但颜色明暗变化有限,看起来更像是“变色圆环”而不是水面。真正让水面活过来的,是法线扰动。

水波纹的本质是表面高度变化,高度变化必然影响法线方向。在实时渲染里,我们不需要真的去抬升顶点高度,只要在片元阶段计算法线偏移,再用法线参与光照计算,就能骗过眼睛,让人以为水面凹凸不平。

在片元Shader里获取法线变化,最快的方法是使用ddx和ddy指令。这两个函数能求出某个变量在屏幕x方向和y方向上的变化率。我们已经有dist了,ddx(dist)就是波纹强度在屏幕横向的变化率,ddy(dist)是纵向的变化率。用这两个值构造一个近似的法线:

float dDistX = ddx(dist); float dDistY = ddy(dist); // 波纹引起的法线偏移 float3 rippleNormal = float3(-dDistX * _WaveStrength, 1.0, -dDistY * _WaveStrength); rippleNormal = normalize(rippleNormal);

把这个rippleNormal和原始法线叠加,再参与光照,就能看到高光随着波峰波谷来回扫动。实际项目中我还会给_WaveStrength加上一个随时间衰减的系数——波纹刚产生时法线扰动剧烈,越靠近外圈强度越低,这样更接近真实水波。

如果不想用ddx/ddy(部分老移动GPU对这些指令支持不佳),备选方案是采样一张水法线贴图,用法线贴图的值乘以波纹强度即可,成本更低。但ddx/ddy的好处是无需额外纹理采样带宽,而且波峰波谷的位置精确匹配可见波纹,不会出现“纹理法线动但波纹不动”的割裂感。

2.3 边缘淡化与多层波纹叠加

写波纹时最容易犯的错误是让涟漪“戛然而止”。距离大于最大半径后直接变为0,会产生一个明显的圆形硬边,看着特别假。解决方法是加一个smoothstep衰减:

float fade = 1.0 - smoothstep(_MaxRadius * 0.6, _MaxRadius, dist);

这样涟漪在靠近最大半径时强度平滑降为0,视觉上就没了“切蛋糕”的边界。如果想让波纹更好看,还可以叠加多层正弦:不同波数、不同速度的两三层波纹叠在一起,由于频率错开,会自然形成复杂的干涉图样。这个方法零额外贴图成本,实现也很简单:

float wave1 = sin(dist * 8.0 - elapsed * 4.0); float wave2 = sin(dist * 13.0 - elapsed * 6.5 + 1.2); float wave = (wave1 + wave2) * 0.5;

两层波叠加后,某些区域波峰重合更亮,某些区域相互抵消更暗,水面的丰富度立刻提升一个档次。

3. 燃烧点击特效:噪声侵蚀与火焰边缘

3.1 燃烧扩散的Mask模型

燃烧特效和水波最大的区别在于:水波是“波峰波谷反复震荡”,燃烧是一个不可逆的扩散过程。火焰从点击点开始,往周围蔓延,烧过的区域焦黑,火焰集中在边缘。

我用一个非常朴素的模型来实现:燃烧半径随点击后的时间逐渐增大,某个片元到点击点的距离如果小于当前燃烧半径,就认为是已烧区域。但这会得到圆形边界,不像火的任意蔓延。要打破圆形就得引入噪声。

解决问题的关键是把“到点击点的距离”和“燃烧半径”的差值作为基础边界,再用噪声纹理在这个差值上做扰动。我习惯用一张噪声图(钢印、流汗、云纹都可以),采样世界空间或模型空间的坐标,把噪声值叠加到边界值上:

float edge = dist - burnRadius + (noise - 0.5) * edgeNoiseStrength;

noise越大的地方,边界越往内缩,看起来像火焰还没烧到;noise越小的地方,边界越向外凸,形成不规则的燃烧形状。加上噪声之后,圆形火圈就变成了张牙舞爪的真实火焰蔓延。

核心代码片段:

float elapsed = _Time.y - _ClickData.w; float burnRadius = _MaxRadius * saturate(elapsed / _BurnDuration); float dist = distance(IN.localPos, _ClickData.xyz); float noise = tex2D(_NoiseTex, IN.localPos.xz * _NoiseScale - _Time.y * _FlowSpeed).r; float edge = dist - burnRadius + (noise - 0.5) * _EdgeNoiseStrength; // 已烧Mask:edge 越小越接近已烧区 float burntMask = 1.0 - smoothstep(-_EdgeWidth, 0.0, edge); // 火焰Mask:集中在 edge 为 0 附近的狭窄区间 float flameMask = smoothstep(-_EdgeWidth, 0.0, edge) * (1.0 - smoothstep(0.0, _EdgeWidth * 1.5, edge)); float3 baseCol = _BaseColor.rgb; float3 burntCol = baseCol * _DarkFactor; // 焦黑区 float3 flameCol = lerp(_FlameInnerColor.rgb, _FlameOuterColor.rgb, noise); float3 finalCol = baseCol; finalCol = lerp(finalCol, burntCol, burntMask); finalCol = lerp(finalCol, flameCol, flameMask);

为什么flameMask是两个smoothstep相乘?第一个把 edge 从-_EdgeWidth到0映射为从0到1,第二个把 edge 从0到_EdgeWidth*1.5映射为从1到0。两者相乘,刚好在edge=0附近形成一个尖峰,越靠近火焰中心越亮,越往外越暗,符合火焰边缘高亮的视觉印象。

3.2 火焰边缘与余烬的着色

火的颜色不是单一橙色,从内到外大致是:亮黄白(高温)→ 橙红 → 暗红。我常用两层渐变颜色,按噪声值做插值,让火焰边缘有些随机色块:

float3 flameCol = lerp(float3(1.0, 0.85, 0.3), float3(1.0, 0.3, 0.05), noise);

这样噪声值高的区域偏黄白,低的地方偏橙红,边缘看起来是“沸腾”的。

再处理烧过的区域。如果直接把烧焦区涂成纯黑色,会像被墨水泼过一样假。我习惯保留一部分地面纹理特征,用乘法加深:

float3 burntCol = baseCol * 0.25 + _EmberColor.rgb * emberMask;

_EmberColor是余烬色(暗红色),emberMask只在刚烧过的边缘附近不为0,这样火焰刚过去的位置仍有余烬亮红,过一会才完全冷却暗下去。余烬读数可以通过让emberMask乘一个随时间缓慢减小的系数,模拟“冷得快”的感觉。

3.3 热浪扭曲与粒子配合

Shader能画出一个不错的燃烧区域,但要让整片区域“烧起来”,还需要热浪扭曲和粒子辅助。

热浪扭曲的实现思路是:火焰附近区域,屏幕采样UV被一个随时间变化的噪声扰动。在URP中这通常需要 GrabPass 或使用CameraOpaqueTexture。我在移动端项目里一般不加后处理扭曲,因为这会引入全屏的额外纹理采样。折中方案是在燃烧Shader里只对火焰边缘区域叠加一层半透明热气流,用尾焰粒子模拟上升气流,视觉上也能接受。

粒子部分我用得最多的配置:

  • 黑色烟雾粒子,从点击点上方向四周缓慢扩散,生命周期2到3秒。
  • 小火星粒子,沿火焰边缘随机爆发,做物理运动时受扰动,模拟星火溅射。
  • 偶尔加一个低频音效和手柄震动,反馈感立刻拉满。

调整好粒子后,燃烧就不只是“地面上变色”,而是有一种火势在蔓延的氛围。

4. 点击交互链路:坐标转换、参数传递、多次点击

4.1 射线检测与坐标空间统一

特效Shader写好后,最关键的交互问题来了:玩家点击屏幕,怎么把屏幕位置变成Shader能用的坐标?

标准链路是这样的:

  1. 从摄像机发射一条射线,经过鼠标位置。
  2. 使用Physics.Raycast检测是否与地面碰撞。
  3. 拿到碰撞点hit.point,这是世界坐标。
  4. 把世界坐标转换成物体模型空间坐标,传给Shader。

很多人会忽略第4步,直接把世界坐标传给Shader,Shader里也用世界坐标计算距离。在平面、缩放为1的普通网格上,这没问题。可一旦地面有旋转或者非均匀缩放,世界坐标算出来的距离就失真了。正确的做法是统一到模型空间,脚本里这样写:

Vector3 localClickPoint = hit.collider.transform.InverseTransformPoint(hit.point);

InverseTransformPoint会把世界坐标转换回这个碰撞体的模型空间坐标。Shader这边,顶点着色器把网格的模型空间坐标传给片元,片元再去和这个local坐标计算距离。因为模型空间的坐标不随物体旋转缩放变化,波纹在斜坡、屋顶、旋转平台上都能正确贴合表面。

4.2 Shader参数逐帧更新与数组管理

简单只有一个点击点时,直接material.SetVector("_ClickData", clickDataVector)就行。但实际交互里,玩家不可能只点一下,通常要求多次点击、多个波纹同时扩散、多个燃烧点共存。

在Shader里写死一个点击点不够用,我用数组来管理:

#define MAX_CLICK_COUNT 8 CBUFFER_START(UnityPerMaterial) float4 _ClickData[MAX_CLICK_COUNT]; // xyz = 模型空间坐标, w = 点击时间 float _ClickCount; CBUFFER_END

C# 侧维护一个循环队列:

private Vector4[] _clickDataArray = new Vector4[8]; private int _clickIndex = 0; private int _clickCount = 0; void Update() { if (Input.GetMouseButtonDown(0)) { Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); if (Physics.Raycast(ray, out RaycastHit hit)) { Vector3 localPos = hit.collider.transform.InverseTransformPoint(hit.point); _clickDataArray[_clickIndex] = new Vector4(localPos.x, localPos.y, localPos.z, Time.time); _clickIndex = (_clickIndex + 1) % _clickDataArray.Length; _clickCount = Mathf.Min(_clickCount + 1, _clickDataArray.Length); material.SetVectorArray("_ClickData", _clickDataArray); material.SetFloat("_ClickCount", _clickCount); } } }

在Shader里,遍历数组累加每个点击点的波纹贡献:

float3 finalColor = _BaseColor.rgb; for (int i = 0; i < MAX_CLICK_COUNT; i++) { if (i >= (int)_ClickCount) break; float elapsed = _Time.y - _ClickData[i].w; float dist = distance(IN.localPos, _ClickData[i].xyz); // 计算单个点击的波纹强度,累加到 finalColor }

这里有三个小坑提醒一下:

  • SetVectorArray会一次性把整个数组传给Shader,如果只更新了其中一个元素,最好也重新调用一次整数组设置,不要试图只更新某一项。
  • 数组长度是固定的,但_ClickCount是有效点击数。循环时先判断_ClickCount,避免读到未初始化的脏数据产生假波纹。
  • 如果场景里点击超过8个点,最旧的点就被覆盖,这是一个自然的数量上限。想提高上限,改数组长度并保持C#数组长度一致即可。

4.3 多点击点共用Shader的常见问题

多点击点特效最容易出现的问题是“互相干扰”。比如两个水波叠加后,波峰加波峰可能变成一片亮白,波谷加波谷黑成一片。处理方式有两种:

  • 水波建议直接叠加颜色,因为干涉本来就是水波的特征。
  • 燃烧建议取最大值而不是叠加。多重燃烧时,如果每个点都算出火焰Mask再加起来,两个火焰边缘重叠处会异常刺眼。我一般用max(burnMask, previousMask)而不是+,这样燃烧区域合并自然,不会重复增亮。

另外,一旦使用SetVectorArray和_ClickCount,材质就会变为动态实例化的状态,要留意与GPU Instancing的兼容性。如果同一地面上大量网格都要共享一个特效材质,需要在Shader里给CBUFFER加上#pragma multi_compile_instancing相关的实例化支持,否则批量绘制会被打断。

5. 性能优化与移动端适配

5.1 局部特效面片替代全屏材质

我看到不少Demo直接把点击特效写进整个地面的材质里,地面有多大,像素就有多少。比如一张100x100米的地面,玩家点击一次,GPU要从头到尾跑一遍波纹计算,即使离点击点十万八千里的远处也要执行一遍颜色插值,纯属浪费。

我的优化思路是:特效不在大地材质里算,而是在点击位置生成一个专用的特效面片。这个面片大小刚好覆盖最大波纹半径,比如4米乘4米,使用单独的特效Shader。玩家点击后,脚本实例化一个Quad放到点击点,把它的材质设置为水波/燃烧Shader,并传入点击数据。

这样做的好处非常明显:

  • 特效Shader只面片面覆盖的几平方米像素执行,而不是全屏或全地形。
  • 不会影响地面主材质,避免主材质被动态参数弄脏后其他区域也被干扰。
  • 特效面片结束生命周期后直接销毁,不会永久占用内存和渲染。

代价是每次点击多一个Draw Call,但Quad只有两个三角形,比全屏像素计算便宜得多。我在一个移动端Demo里同时允许4个燃烧点共存,帧率依旧稳定。

5.2 移动端Shader指令预算与优化项

移动端GPU和PC GPU差距很大,Shader里一个看似无关紧要的函数都可能导致性能掉一半。我总结了几条针对点击特效的优化经验:

  • 能用纹理查找替代复杂数学函数的地方,尽量用纹理。比如水波强度可以用一张预计算的波纹渐变纹理,按距离和时间采样,比在片元里算sin便宜,尤其在老GPU上。
  • 减少动态分支。Shader里if (elapsed < 0.0)这种判断在部分移动GPU上开销不小,可以用step函数替代。step(0.0, elapsed)返回0或1,乘到最终强度上,避免分支发散。
  • 数组遍历次数保持固定。我前面的代码直接写死for (int i = 0; i < MAX_CLICK_COUNT; i++),再在内部用_ClickCount判断有效点击。虽然多做几次空循环,但避免了动态循环的不确定性,更多GPU能稳定执行。
  • 数据精度用half而不是float。移动GPU的half运算更快,水波这种对精度要求不高的效果完全可以满足。但要小心距离乘时间累积误差,如果发现波纹位置抖动,换回float。

5.3 材质实例化与合批注意事项

点击特效几乎不可避免要动态修改材质参数。一旦使用SetVector或SetVectorArray,Unity会为这个材质创建一个实例,导致和地面的合批断开。如果特效是独立面片,不影响地面主材质合批,但多个特效面片之间也会因为材质实例不同而无法合批。

优化策略很简单:

  • 特效面片尽量共用一份材质资产,脚本里通过修改MaterialPropertyBlock而不是直接修改共享材质,避免污染其他实例。
  • 如果确定所有点击特效共用同一套Shader参数结构,可以自己模仿GPU实例化传递数组,一个Draw Call画多个Quad,性能会更好。但这样做复杂度较高,新手先做到面上不卡顿即可。

6. 踩坑实录与速查表

6.1 最常见的5个坑

现象原因解决方法
特效黑屏或粉屏Shader代码里用了URP不兼容的函数/宏确保Shader使用HLSLPROGRAM并包含URP的Core.hlsl
波纹出现在错误位置点击点还在世界坐标,Shader用的模型坐标统一用InverseTransformPoint转模型空间
点击后特效不扩散,停在原地时间参数没传进去,或_ClickData.w始终为0检查C#是否在点击时赋值Time.time
多次点击后特效错乱数组长度与C#不一致,读到脏数据使用固定长度数组,并用有效数量限定循环
移动端烧边缘撕裂half精度不足,距离计算误差被放大距离相关变量改回float

6.2 调试工具与调试技巧

Shader问题不像C#代码那样容易打断点,我摸索出一套实用的调试顺序:

  • 先在片段着色器里返回一个纯色,比如返回float4(1,0,0,1),确认Shader本身管线通。
  • 把点击点位置可视化:把Shader输出改成distance(IN.localPos, _ClickData.xyz) < 0.1显示出点击点位置,看它是否真的在模型上。
  • 把时间可视化:输出elapsed / 最大寿命,利用颜色渐变判断时间是否流动、寿命计算是否正确。
  • 一步一确认,最后再加噪声和法线,出问题能立刻定位。

6.3 一点额外心得:Shader变体与打包

Shader特效在编辑器里一切正常,打包后变粉或者变白,这是很多人都遇到过的“灵异事件”。多数原因是Shader变体在打包时被裁剪了,尤其使用multi_compile宏时。

解决方法是把用到的材质加入“Always Included Shaders”列表,或者在Resources文件夹下放一个引用该Shader的材质,强制Unity打包它。我用U8团队提供的一个脚本,在打包前自动扫描场景中所有材质对应的Shader并加入预加载列表,从那以后这个坑基本告别了。

另外,URP管线的Shader变体数量本来就不小,建议在Player Settings里设置适合的管线兼容级别,移动端只保留移动端Shader变体,能显著减少包体和加载时间。

我个人在实际操作里的体会是:点击特效这种交互反馈,重点不在“能点出多炫的光”,而在“点击后的世界有没有真实地回应你”。水波的核心是正弦波和法线的配合,燃烧的核心是噪声侵蚀和不规则边界,而让一切串起来的反而是坐标转换、数组管理等不那么“视觉效果”的部分。建议你把一半时间花在Shader数学上,另一半时间花在坐标与传参链路上,踩过几次坑之后你会发现,这比单纯调粒子参数更能积累出扎实的渲染功底。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询