Unity点击特效Shader实战:水波涟漪与燃烧扩散

发布时间:2026/10/14 8:40:21
Unity点击特效Shader实战:水波涟漪与燃烧扩散 Unity 点击特效Shader水、燃烧做Unity客户端这些年被问得最多的一个需求就是“点击屏幕的地方出个特效”常见玩法是手游里点水面冒泡泡、点纸片开始烧、点地面扩散一个火圈。用粒子系统做能出效果但一多就卡而且跟场景光照、UI交互融合得很生硬。换成Shader做点击特效等于把特效的计算塞进GPU成本低、表现干净还能直接复用现有渲染管线。这篇文章就说清楚我常用来做“水”和“燃烧”这两个点击特效的Shader方案从原理、坐标处理到调参踩坑全讲一遍。先说结论点击特效用Shader实现核心不是写多复杂的着色器而是解决“点击位置怎么进Shader”“多个点击怎么叠加”“时间和强度怎么控制”这三件事。只要这三件事理顺了水波和燃烧无非是同一套输入数据换不同算法而已。1. 点击特效的前置思路为什么用Shader而不是粒子1.1 粒子系统和Shader的取舍粒子系统做点击特效最直观点击位置实例化一个ParticleSystem让它喷射粒子就行。我以前也在项目里这么干过但遇到三个问题第一个是批量压力移动端同屏粒子数一旦超过五百Draw Call和填充率就明显往上走第二个是表现风格粒子自带的光照、混合模式跟Shader材质很难完全一致水波这种半透明扭曲效果特别容易穿帮第三个是可控性粒子一旦发射出去位置和形态就很难用C#精准控制想做成“顺着点击点向外卷”还得上Vortex模块调节起来很绕。Shader方案则是把这套逻辑完全放到渲染帧里点击的那一刻用C#把坐标和强度写入某种共享数据所有需要响应点击的材质在顶点阶段或片元阶段读取这个数据自己决定怎么扭曲、变色、透明。这样的好处是零额外Draw Call特效成本跟屏幕像素挂钩而不是跟粒子数量挂钩尤其适合全屏或大面积的地面/水面效果。代价是写Shader的思维得转变过来不能再用“发射-更新-消亡”的事件思维要改为“每帧采样数据-按距离和衰减计算输出”的函数思维。1.2 渲染管线和点击坐标怎么配合这里有一个新手容易绕晕的点屏幕点击的坐标系和Shader采样用的UV坐标系完全不是一回事。屏幕坐标以左下角或左上角为原点单位是像素而Shader里UV一般是0到1的浮点数且原点通常也在左下角。即使你在C#里拿到了Input.mousePosition直接塞给Shader往往方向是反的。所以第一步就是统一坐标。我惯用的做法是C#侧拿到Camera.main.ScreenToViewportPoint(输入点)这样得到的坐标自动就是0到1的Viewport坐标单位与UV一致然后直接作为_ClickUV传入Shader。如果是Sprites或UI则把点击点转换到目标的本地UV空间用RectTransformUtility把屏幕坐标换算成局部坐标再除以该物体的宽高。这一步如果错了特效会出现“点左边烧右边”的镜像问题这是我在项目里排查最久的一个坑。还有一个需要提前决定的点是让点击信息存到RenderTexture里还是存成代码数组。两种我都在实际中用区别在于水波这种需要“多波叠加”的强烈建议先用RenderTexture保存点击分布燃烧这种只需记录最近几个点击点的用固定长度数组遍历更直接。后面第二节和第三节会分别展开说明。2. 水波点击特效的Shader实现2.1 水波纹的核心算法思路说到水面点击最常见又出效果的做法是“UV扰动”而不是真的去模拟流体力学。原理是用点击点作为圆心以时间为半径向外扩张一个圆环圆环经过的位置采样一张普通水面贴图时做偏移偏移量由圆环的衰减决定。这个思路做出来的效果近似石头扔进水面激起的涟漪虽然不完全是物理正确的波但在手机屏幕上观看已经被市场验证过。要实现“圆环”常规做法是计算片元到点击点的距离dist然后用frac(dist - _Time.y * speed)拿到一个0到1循环推进的“涟漪相位”再乘以强度就得到周期性扰动量。这一步很关键的点是避免使用sin直接对距离取周期因为正弦会形成中间亮两边暗的条纹反而像声波改成frac 光滑边缘会更接近真实涟漪的细窄环状。如果你想做得更秃噜一点可以在主纹理采样之后把涟漪Offset累加到UV上并在后面再加一次带噪声的normal * ripple让波纹产生轻微的无序。这个对性能影响很小观感上确实比纯圆环自然很多。2.2 用RenderTexture承接多点点击我刚学这种特效时用的是一个长度固定的float数组存所有点击点然后Shader里遍历数组找当前片元是否落在涟漪范围内。问题很明显点击多了要遍历十几二十次移动端片元数一高性能就不行了而且涟漪之间的叠加很生硬。后来换成RenderTexture方案干净很多。具体流程是这样的创建一个RenderTexture分辨率不必太高通常512×512甚至256×256就够因为这只是存“点击位置分布”的轻量信息。每次点击时用Graphics.Blit或GL.LoadOrtho配合一个专门画圆点的Material把圆形渐变纹理绘制到这个RT上圆的中心就是点击的Viewport坐标半径和强度由点击力度控制。这样每一个新点击都会在RT上叠加一笔基础水波Shader直接采样这张RT用RT的像素值作为该片元处“有水波”的强度。这个方案最舒服的地方在于RT天然支持多点叠加多个点击点会共用一张纹理Shader不需要任何循环结构就像普通贴图采样一样简单。需要清波时直接Clear掉RT就可以。代价是多了一张纹理的采样和一笔Blit的开销但这比Draw Call还便宜完全可接受。2.3 水波Shader核心代码讲解下面是一份我简化后的水波点击Shader适合放在URP或内置管线的Unlit Shader里测试时直接挂到一个Plane上就行。Shader Custom/ClickWaterRipple { Properties { _MainTex (Base Water, 2D) white {} _RippleRT (Ripple Map, 2D) black {} _RippleStrength (Ripple Strength, Range(0, 0.5)) 0.08 _RippleSpeed (Ripple Speed, Range(0, 5)) 1.2 } SubShader { Tags { RenderTypeTransparent QueueTransparent } Blend SrcAlpha OneMinusSrcAlpha Cull Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_ST; sampler2D _RippleRT; float _RippleStrength; float _RippleSpeed; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 pos : SV_POSITION; }; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed4 frag (v2f i) : SV_Target { // 从临时RT读取点击热力值 float ripple tex2D(_RippleRT, i.uv).r; // 用热力值驱动时间相位形成向外扩散效果 float phase frac(ripple _Time.y * _RippleSpeed); // 让扩散环变细变锐利 float ring pow(phase, 8); // 采水面纹理并做UV偏移 float2 offset (float2(0.5, 0.5) - i.uv) * ring * _RippleStrength; fixed3 col tex2D(_MainTex, i.uv offset).rgb; // 水波经过处适当提亮 col ring * 0.15; float alpha 1.0; return fixed4(col, alpha); } ENDCG } } }这份代码的妙处是把“水波热力值”直接存成了相位数据当点击点刚写入RT时中心区域像素值是亮的随着时间推移我们通过_Time.y让它周期推进所以亮的地方会产生向外的波环。一个小体会是我把Ring的指数调得比较高8次方目的是让波环更接近“一条线”而不是“一大片光晕”。如果觉得波太散就把指数继续往上加觉得太细往下降到3左右通常3到10之间都能接受。不过要提醒一句上面的Shader只做单层扰动真正作为项目效果的话需要把底部水面贴图换成带法线的水面材质同时把ring * 0.15改成法线强度再跟场景光照结合否则看起来像“贴了一层塑料油”。2.4 点击事件在C#侧怎么写入RTC#负责的部分是监听输入、换算坐标、绘制RT。我简化并梳理成了这四步创建RT和绘制材质。每帧检测鼠标/手指抬起或按下这里我用按下触发一次绘制。把点击位置换算成Viewport坐标并让Y轴方向与UV一致多数情况不用翻转要分平台验证。用Material.SetTexture和Blit将圆点画到RT上随后把RT赋值给水波Shader的_RippleRT。绘制圆点可以用一个很简单的Unlit Shader它的片元输出基于距离中心的距离形成渐变圆。比如fixed4 frag (v2f i) : SV_Target { float d distance(i.uv, float2(0.5, 0.5)); float a smoothstep(0.5, 0.0, d); return fixed4(a, a, a, 1.0); }Blit时把_ClickCenter和_ClickRadius设置好。需要注意RT的分辨率越低边缘越有“像素颗粒感”但也不能为了提高清晰度设到2048那样Blit开销会变大。我实测512在手机屏幕级别的点击反馈上完全够用边缘的轻微模糊反而能模拟水波衰减。提示Graphics.Blit有个坑它会把当前RenderTexture恢复为null所以绘制完之后别急着在相机上套效果先把RT从小临时变量里存下来最后统一还原。3. 燃烧点击特效的实现思路3.1 水和燃烧看似不同底层数据结构一样燃烧特效和水波最大的相似点是都以点击点为中心随时间向外扩散。区别在于水波是“波峰经过时有扭曲然后恢复”燃烧是“被烧过的地方要持续变色、变透明或改变材质属性”。因此水波可以只读一张RT热力值燃烧则需要“记住哪些区域已经烧过了”并且在这个基础上叠加时间演化。我用过的方案里最脏但最稳的是把点击的热力值直接渲染到一张与物体纹理UV对应的遮罩贴图上。烧过的地方遮罩值从0变成1然后后续Shader每次采样这张遮罩把遮罩值当作“已经燃烧的程度”。再叠加一个噪声值让边缘出现不规则的火舌感和焦黑过渡。这样其实把燃烧拆成了两个阶段写入阶段C#侧把点击热力值画进RT和读取演化阶段Shader采样RT并按时间扩散。写入阶段跟水波的RT完全一致所以整个点击输入系统可以复用。这叫“一次采集两种Shader消费”。3.2 燃烧扩散与烧焦边缘的数学表达燃烧如果不加干预只是让遮罩从点击中心均匀向外扩那看起来会非常像“油漆桶填充”一点火焰的感觉都没有。真实燃烧的传播方向是不规则的受材质分布、风力、温度影响。为了用简单办法模拟这种不规则我在Shader里引导遮罩的时候加了一个FBM噪声或者至少叠加两层不同频率的噪声。伪逻辑可以写为每一个采样点p查找该像素原始遮罩值M(p)然后计算它接下来一个时间步里是否会继续燃烧。如果M(p) 某个阈值就让它向外的一个小邻域传播一点燃烧值。由于片元之间不能直接读写同一张RT通常做法是让时间和世界坐标一起驱动噪声让遮罩的采样位置发生扭曲。简化到代码层面核心就是一句float raw tex2D(_BurnMap, uv).r; float noise tex2D(_NoiseMap, uv * _NoiseScale _Time.y * _NoiseSpeed).r; float edge smoothstep(_EdgeThreshold, _EdgeThreshold _BurnWidth, raw noise _BurnSpread);_EdgeThreshold控制燃烧进度阈值_BurnWidth控制烧焦边缘宽度_BurnSpread控制噪声对扩散的扰动幅度。配合两张纹理采样效果就是遮罩值大的区域往前走遮罩值小的区域被噪声拉住形成参差不齐的“火线”。3.3 手写燃烧Shader的骨架代码下面这个Shader我用来做“纸张燃烧”类的平面点击效果点击处开始焦黑向外扩散边缘有橙红色光。它依赖一张_BurnMap与物体UV对应的遮罩一张噪声贴图以及颜色参数。Shader Custom/ClickBurnPaper { Properties { _MainTex (Paper Texture, 2D) white {} _BurnMap (Burn Map, 2D) black {} _NoiseMap (Noise, 2D) gray {} _BurnThreshold (Burn Threshold, Range(0, 1)) 0.45 _BurnWidth (Edge Width, Range(0, 0.2)) 0.05 _EdgeColor (Edge Color, Color) (1, 0.3, 0, 1) _BurntColor (Burnt Color, Color) (0.05, 0.02, 0.01, 1) _Spread (Noise Spread, Range(0, 0.1)) 0.03 } SubShader { Tags { RenderTypeTransparent QueueTransparent } Blend SrcAlpha OneMinusSrcAlpha Cull Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_ST; sampler2D _BurnMap; sampler2D _NoiseMap; float _BurnThreshold; float _BurnWidth; float4 _EdgeColor; float4 _BurntColor; float _Spread; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 pos : SV_POSITION; }; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed4 frag (v2f i) : SV_Target { float burn tex2D(_BurnMap, i.uv).r; float noise tex2D(_NoiseMap, i.uv * 12.0 _Time.y * 0.5).r; float fireEdge smoothstep(_BurnThreshold, _BurnThreshold _BurnWidth, burn noise * _Spread); fixed4 paper tex2D(_MainTex, i.uv); fixed4 burnt lerp(paper, _BurntColor, fireEdge); float edgeMask smoothstep(_BurnThreshold - _BurnWidth * 0.5, _BurnThreshold _BurnWidth * 0.5, burn noise * _Spread); fixed3 finalCol lerp(burnt.rgb, _EdgeColor.rgb, edgeMask * (1 - fireEdge)); float alpha lerp(1.0, 0.9, fireEdge * 0.5); return fixed4(finalCol, alpha); } ENDCG } } }这里fireEdge的含义是“当前像素已经完全燃烧”的程度0代表没烧着1代表烧透了。当burn noise * _Spread超过阈值时fireEdge变大颜色向焦黑过渡。边缘Mask额外取了一层专门把烧焦边缘附近提亮成橙红色这样视觉上就有很明显的“火圈”在推进。有需要的读者可以把_BurnMap改成_RippleRT同款写法也就是点击时Blit一个圆形到RT。唯一要注意的是水波的RT会随时间清掉燃烧的RT不需要频繁清理因为被烧掉的部分是长存的。我在项目中一般用两张RT一张叫_TempRippleRT一张叫_BurnMapRT互不干扰。3.4 ShaderGraph方案什么时候值得用如果你的项目团队里美术不会写代码那ShaderGraph其实也能完成水波和燃烧。水波用“UV扰动 噪声”就能搭燃烧则可以用“Lerp 噪声 时间阈值”节点组合。但ShaderGraph的坏处是性能节点不好控制特别是采样节点多的时候编译出来的指令数可能会悄然超标。我倾向于把ShaderGraph用在原型验证阶段等效果跑通后再用手写HLS直译一份保证移动端能压住帧率。用ShaderGraph搭燃烧比手写要费很多节点因为边缘颜色的过渡需要好几个Lerp和Smoothstep串起来。有人统计过同样的效果手写大概三十行ShaderGraph则要十几个节点连线而且连线越多后面调整越痛苦。所以如果你是独立开发我建议直接啃代码收益更高。4. 点击特效的实操过程与调优细节4.1 把点击位置正确送到Shader的三条路径我前前后后一共试过三种路径把C#点击位置送给Shader这里直接列成表帮助你按项目情况选方式数据存储穿透性多点支持性能表现适用场景全局属性单个Vector4所有材质共享只支持一个点最佳单点击、快速验证Uniform数组长度固定数组所有材质共享自定义上限较好但循环会增开销少量点击点RenderTexture纹理材质采样天然任意点需要Blit和采样水和燃烧的正式项目全局属性用起来最简单比如Shader.SetGlobalVector(_ClickUV, uv)但只够演示。数组方式能做好几个点击点不过需要开GLSL循环移动端部分老GPU对动态循环支持得很差。RenderTexture是我们这种带连续燃烧效果的必备项因为燃烧状态必须持续存在不是一次性事件。还有一条坑如果项目用了多个相机点击坐标到底要转换到哪个相机的Viewport空间我建议以“最终渲染特效的主相机”为准。如果特效在UI层那么要手动判断哪一层UI接收点击并把UI的本地坐标换成材质用的UV否则会出现屏幕裂开两半、上半部分特效镜像的怪问题。4.2 性能优化经验分辨率、采样次数与混合关于RT分辨率我的建议是水波用256到512燃烧用512即可。RT分辨率和最终展示的物体尺寸、屏幕DPI相关。物体很大且透视很近的情况下256会出现“像素块烧焦”的感觉但用到1024后Blit耗时翻倍而视觉上提升并不明显。最好的办法是做一次二分测试从256开始每档提升观察边缘当肉眼几乎分辨不出差异时就采用当前档位。采样次数方面水波Shader整体只有一张主贴图加一张RT加上法线贴图通常不超过三张纹理采样燃烧额外加了一张噪声图。如果手写Shader在Fragment阶段纹理采样超过四个我建议看看能不能合并噪声图到通道里例如把噪声的RGB分别存三种尺度减少采样次数。混合模式也要固定好。水波是半透明要注意透明物体的渲染顺序把水波物体放在透明队列里且QueueTransparent。燃烧的Alpha不一定需要完全透明很多纸面燃烧效果最终会露出底部背景但如果你做的是地面小范围灼烧最好只混合颜色不做深度写入避免和场景里的物品穿插时出现不该有的遮挡关系。4.3 常见问题排查实录这里把我遇到过的点击特效问题整理成一张速查表基本都是Shader新手高频踩坑点现象可能原因解决方法点击点特效出现在屏幕镜像位置C#传入坐标Y轴未翻转或与UV原点不一致统一使用Viewport坐标并在C#侧试InvertY水波扩散方向飘移圆心会跟着视角移动Shader里随便用了屏幕坐标而不是物体UV确保采样的UV来自顶点原始UV不要基于ScreenPositionRT上画的圆点没有出现Blit后RT未恢复或材质没有赋值RT检查RenderTexture.active的保存与还原燃烧效果总是全画面一起烧_BurnMap初始值全1或C#没有清RT创建RT后用Clear或填0且只画点击区域烧焦边缘闪烁严重噪声频率过高或_Spread值过大噪声采样UV的Scale降到8到15Spread设0.01到0.05手机上效果与编辑器差异大半分辨率RT采样失真将RT分辨率从256提到512并给采样加双线性过滤还有一个容易忽略的问题RT必须设置成Format.RGBAHalf还是RGBAFloat如果你的Shader需要从RT里读取精度较高的热力值用RenderTextureFormat.ARGBHalf比默认ARGB32好很多。普通的ARGB32只有8位精度燃烧遮罩值一旦在0.45阈值附近量化误差就会导致边缘闪烁。把RT切换到Half精度后这个问题几乎没再出现过。4.4 调参细节从“能看”到“好看”的三个关键参数跑通代码不等于效果好。我的经验里调参时最先看三个东西强度、速度和阈值。水波Shader中_RippleStrength从0.05开始试值太大水面会有强烈眩晕感值太小则像“油渍轻微波动”。对手机屏幕0.08到0.12是一个比较稳的范围。速度_RippleSpeed实际上决定了波环从中心扩散到边缘的快慢手感调成0.8到1.5之间让用户感觉“点了之后马上有反馈又不至于太快消失”。燃烧Shader中_BurnThreshold是最核心的旋钮它决定一个区域的“燃点”高低。设为0.3低一点烧得快设为0.6需要更多“热力积累”才会燃烧。同时要跟_Spread配合调整_Spread太小边缘形状很规整太大噪声撕裂感太强。建议固定_Threshold在0.45左右先调_Spread看边缘形态然后再回过来动阈值。最后是边缘颜色。水波提亮值0.15在白天场景合适夜晚或者水下要压低到0.05。燃烧的_EdgeColor建议把RGB分离出来调红色给1左右绿色给0.3到0.5蓝色压低到0.1左右这样边缘呈现的是暖橙色而不是发紫。如果要做更灼热的火焰可以把绿色提到0.6让焰心发白。5. 经验总结与扩展思路我个人做这套点击特效最深的体会是不要一上来就追求复杂算法先把“点击数据流”的闭环打通。你只要能把点击坐标稳定地写进RT、再稳定地让Shader读到水波和燃烧无非是同一个输入源的两种输出。等基础闭环通了再去加噪声、多期叠加、外加粒子点缀特效会变得非常自然。最后分享一个小技巧如果你想让多个交互物体同时使用同一份点击数据但又不想每个物体都挂一个C#脚本可以直接把RT和点击参数放到一个静态管理类里由全局管理器统一绘制RTShader侧只声明同名全局属性就好。这样未来加新特效美术那边只需要新建一个Shader完全不用碰C#代码。这个点击特效方案还有一个很自然的扩展方向把水波的RT和燃烧的RT合并成一张多通道纹理R通道存储水波热力G通道存储燃烧状态。这样一个采样就能支持两种特效叠加画雨滴落在地面、火焰顺着雨水边缘蔓延之类的复合效果都能做。后续我会把这种复合效果再单独梳理一份到时候可以继续聊。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询