原理
在Unity中实现基于噪声纹理(Noise Texture)的动态火焰外壳,核心思路是利用噪声图的颜色值去干扰或裁剪模型的材质表面,并通过时间变量让这种干扰发生位移。它本质上是一种“假”三维的视觉特效。
以下是实现这一特效的几个核心原理解析:
1. 噪声纹理的“扰动”与“采样”原理
噪声纹理(通常是柏林噪声 Perlin Noise 或 纯随机噪声)的每一个像素点都存储了一个介于 0 到 1 之间的灰度值。
- 顶点置换(Vertex Displacement): 将噪声图的灰度值作为权重,沿着模型顶点的法线方向将顶点向外挤出。因为噪声是连续且不规则的,原本平滑的模型外壳就会变成凹凸不平的“火苗”初版。
-
UV 动画(UV Scrolling): 让采样噪声图的 UV 坐标随着时间(
Time)往一个方向(比如轴向上)不断累加。这样一来,噪声的灰度起伏就会向上滚动,模型表面的凹凸和形变也就随之向上“流动”,形成了火焰升腾的动态感。
2. 火焰边缘的“溶解”与“裁剪”原理
真实的火焰外壳应该有层次感,且顶部会消散,而不是一个实心的乱动网格。
-
消散渐变(Dissolve Gradient): 引入一个从下到上(或者从内到外)逐渐变大的渐变值(可以通过模型顶点的
坐标或者模型自带的第二套 UV 获得)。
-
阈值裁剪(Alpha Clipping): 用动态滚动得到的噪声灰度值减去这个消散渐变值。当结果小于某个设定阈值(比如 0)时,就直接丢弃该像素(
clip或discard)。因为越往上消散值越大,算出来的结果就越容易小于 0,这就导致模型外壳在向上流动的过程中,顶部会自然地破碎、撕裂并最终消失。
3. 火焰颜色的“重映射”与“发光”原理
噪声图只有黑白两色,要让它变成火焰,需要将灰度重映射到色彩空间。
- 渐变映射(Gradient Mapping / Ramp Tex): 使用一张包含“黑→深红→橙黄→亮白”的渐变色带图(Ramp Texture)。用前面计算出来的、带有动态噪声影响的最终灰度值,去采样这张色带图。灰度低的地方映射为暗色或透明,灰度高的地方映射为炽热的亮色。
- HDR 与 能量相乘: 为了让火焰具有“灼热感”和“辉光(Bloom)”效果,需要将采样出的颜色乘以一个大于 1 的强度系数(Emission Intensity),使其进入 HDR(高动态范围)区间,再配合相加混合模式(Additive Blending),火焰外壳就会呈现出半透明、叠加发光的炫酷效果。
4. 菲涅尔效应(Fresnel Effect)的边缘强化
为了让火焰看起来像是一个“包裹在外层的壳”,而不是一个实心的发光体,通常会引入菲涅尔原理:
- 根据视线方向(摄像机到物体的向量)与表面法线方向的夹角来计算发光强度。
- 夹角接近 90 度(即模型的边缘轮廓处)发光最强,正面面对摄像机的地方发光最弱。
- 将菲涅尔强度与噪声动画结合,就能确保火焰特效完美地凝聚在物体的边缘轮廓上,形成一层动态跳动的火焰外衣。
总结一下核心流程:
UV 滚动带动噪声图噪声图去挤压顶点(变毛糙)并控制透明度(变破碎)
通过渐变色带上色
结合菲涅尔效应让它只在模型边缘发光。
实现与解释
// 火焰剑外壳 — Additive Unlit,噪声贴图 UV 扭曲(移动端优化)
// 用法:复制武器网格子物体,挂本材质叠在本体之上
// MPB: SetColor("_FireColor", color)
Shader "Custom/URP/Fire Sword Shell"
{
Properties
{
[HDR] _FireColor ("Fire Color (HDR, MPB)", Color) = (3, 1, 0.2, 1)
_Intensity ("Intensity", Range(0, 10)) = 2
_NoiseTex ("Noise Texture", 2D) = "white" {}
_DisplacementStrength ("Displacement Strength", Range(0, 1)) = 0.3
_Tiling ("UV Tiling", Float) = 0.5
_FireScale ("Fire Scale", Float) = 1
_ScrollSpeed1 ("Scroll Speed 1 (xy)", Vector) = (0, -0.3, 0, 0)
_ScrollSpeed2 ("Scroll Speed 2 (xy)", Vector) = (-0.5, -0.4, 0, 0)
_RiseSpeed ("Rise Speed", Float) = 0.4
_RiseAccent ("Rise Accent", Range(0, 1)) = 0.5
_Cutoff ("Soft Cutoff", Range(0, 0.2)) = 0.02
}
SubShader
{
Tags
{
"RenderType"="Transparent"
"Queue"="Transparent+10"
"RenderPipeline"="UniversalPipeline"
"IgnoreProjector"="True"
}
Pass
{
Name "FireShell"
Tags { "LightMode"="UniversalForward" }
Blend One One
ZWrite Off
ZTest LEqual
Cull Back
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma target 2.0
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
struct Attributes
{
float4 positionOS : POSITION;
float2 uv : TEXCOORD0;
};
struct Varyings
{
float2 uv : TEXCOORD0;
float4 positionHCS : SV_POSITION;
};
TEXTURE2D(_NoiseTex);
SAMPLER(sampler_NoiseTex);
CBUFFER_START(UnityPerMaterial)
float4 _NoiseTex_ST;
float4 _FireColor;
float _Intensity;
float _DisplacementStrength;
float _Tiling;
float _FireScale;
float4 _ScrollSpeed1;
float4 _ScrollSpeed2;
float _RiseSpeed;
float _RiseAccent;
float _Cutoff;
CBUFFER_END
Varyings vert(Attributes input)
{
Varyings output;
output.positionHCS = TransformObjectToHClip(input.positionOS.xyz);
output.uv = TRANSFORM_TEX(input.uv, _NoiseTex);
return output;
}
half4 frag(Varyings input) : SV_Target
{
half2 uv = input.uv;
half t = _Time.y;
half2 uv1 = uv * _Tiling + _ScrollSpeed1.xy * t;
half2 uv2 = uv * _Tiling + _ScrollSpeed2.xy * t;
half nA = SAMPLE_TEXTURE2D(_NoiseTex, sampler_NoiseTex, uv1).r;
half nB = SAMPLE_TEXTURE2D(_NoiseTex, sampler_NoiseTex, uv2).r;
half2 distort = half2(nA, nB) - 0.5;
half2 warpedUV = lerp(uv, uv + distort * _DisplacementStrength, _DisplacementStrength);
half fire = SAMPLE_TEXTURE2D(
_NoiseTex,
sampler_NoiseTex,
warpedUV * _FireScale + half2(0.0, -t * _RiseSpeed)).r;
fire *= lerp(1.0h, nA * nB, _RiseAccent);
fire *= smoothstep(_Cutoff, _Cutoff + 0.05h, fire);
half3 col = _FireColor.rgb * fire * _Intensity;
return half4(col, fire);
}
ENDHLSL
}
}
FallBack Off
}
这份移动端优化的 URP HLSL 代码写得非常实用。它的核心设计是用单张噪声贴图的不同通道(或不同采样方向)来互相扭曲,并且为了移动端性能,完全放弃了顶点置换(Vertex Shader 内没有任何膨胀计算),全在片元着色器(Fragment Shader)中通过 UV 扭曲来模拟火焰流动。
我们从头到尾,把每一部分拆开,用最直观的数学与图形学逻辑彻底分析它。
1. 属性声明区(Properties)
这一部分负责将材质的可调参数暴露给 Unity 编辑器面板。
Properties
{
// [HDR] 属性标记:允许在面板里调整颜色强度大于 1,用于配合 Bloom 产生辉光。
// _FireColor 是基础火色。MPB 代表 MaterialPropertyBlock(说明这个变量支持用代码动态修改,适合多把武器不同颜色,不打断 Draw Call 合批)。
[HDR] _FireColor ("Fire Color (HDR, MPB)", Color) = (3, 1, 0.2, 1)
// 火焰自发光强度系数。
_Intensity ("Intensity", Range(0, 10)) = 2
// 关键的单张噪声图:由于是移动端优化,它一张图要当几张用。
_NoiseTex ("Noise Texture", 2D) = "white" {}
// UV 扭曲(置换)的强度。值越大,火苗抖动、变形得越剧烈。
_DisplacementStrength ("Displacement Strength", Range(0, 1)) = 0.3
// 噪声采样的平铺(缩放)倍数,控制火焰纹理的疏密。
_Tiling ("UV Tiling", Float) = 0.5
// 决定火焰主体在 UV 空间的大小。
_FireScale ("Fire Scale", Float) = 1
// 以下两个 Vector 分别对应两层噪声的滚动速度(只用到了 xy 轴,即 U 轴和 V 轴的位移)。
// 这两个速度方向互不相同,用来形成“互相干扰、交错”的动态湍流。
_ScrollSpeed1 ("Scroll Speed 1 (xy)", Vector) = (0, -0.3, 0, 0)
_ScrollSpeed2 ("Scroll Speed 2 (xy)", Vector) = (-0.5, -0.4, 0, 0)
// 火焰主体向上流动的速度。
_RiseSpeed ("Rise Speed", Float) = 0.4
// 混合权重系数:决定流动的噪声对火焰主体的侵蚀、融合程度。
_RiseAccent ("Rise Accent", Range(0, 1)) = 0.5
// 火焰“溶解/裁剪”的软阈值边界。低于这个值的地方将变黑(不发光),模拟火苗破碎边缘。
_Cutoff ("Soft Cutoff", Range(0, 0.2)) = 0.02
}
2. 标签与渲染状态区(SubShader Tags & Pass Settings)
这部分负责告诉 Unity 引擎,该用什么方式、在什么时候去渲染这个火焰外壳。
Tags
{
// 告诉渲染管线,这是一个“半透明”材质
"RenderType"="Transparent"
// 渲染队列设为 Transparent+10,确保它在本体武器(普通半透明物体)渲染完之后,再罩在外面画上去
"Queue"="Transparent+10"
"RenderPipeline"="UniversalPipeline" // 指定专用于 Unity URP(通用渲染管线)
"IgnoreProjector"="True" // 忽略投影机投影,避免无谓的性能开销
}
Pass
{
Name "FireShell"
Tags { "LightMode"="UniversalForward" } // 指定在 URP 的 Forward(前向渲染)阶段执行
// 核心物理模拟:加法混合模式(Additive Blending)
// 最终颜色 = 1 * 源颜色(火焰) + 1 * 目标颜色(背景/剑体颜色)。实现纯发光、不挡光、亮处越叠越亮的效果。
Blend One One
// 既然是自发光半透明外壳,就不应该写入深度缓冲区(ZWrite Off),避免遮挡后面的东西
ZWrite Off
// 深度测试设置为 LEqual(小于等于):只有当火焰外壳和剑本体在空间位置上重合或比它更靠近相机时才渲染
ZTest LEqual
// 剔除背面(Cull Back):只渲染模型的正面。在移动端上,这可以节省一半的像素计算开销
Cull Back
3. HLSL 渲染程序(片元与顶点着色器)
这是 Shader 的计算核心。采用 half 精度(半精度浮点型,16位,在移动端 GPU 上计算速度是 float 32位 的两倍,非常注重优化)。
3.1 结构体定义与常量缓冲区
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma target 2.0 // 兼容极老的移动端硬件(OpenGL ES 2.0 级别)
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
struct Attributes // CPU 输入给顶点着色器的数据
{
float4 positionOS : POSITION; // 模型空间下的顶点坐标
float2 uv : TEXCOORD0; // 模型的第一套 UV 坐标
};
struct Varyings // 顶点着色器输出给片元着色器的数据(经过了插值)
{
float2 uv : TEXCOORD0;
float4 positionHCS : SV_POSITION; // 裁剪空间下的顶点坐标(用于屏幕定位)
};
// 纹理及其采样器的定义
TEXTURE2D(_NoiseTex);
SAMPLER(sampler_NoiseTex);
// URP 下必须将所有的着色器变量包裹在名为 UnityPerMaterial 的 CBUFFER(常量缓冲区)中
// 这能够利用 GPU 的 CBuffer 缓存,大幅提升移动端反复切换材质参数时的性能(SRP Batcher 兼容)
CBUFFER_START(UnityPerMaterial)
float4 _NoiseTex_ST; // 包含纹理的 Tiling 和 Offset 信息
float4 _FireColor;
float _Intensity;
float _DisplacementStrength;
float _Tiling;
float _FireScale;
float4 _ScrollSpeed1;
float4 _ScrollSpeed2;
float _RiseSpeed;
float _RiseAccent;
float _Cutoff;
CBUFFER_END
3.2 顶点着色器(Vertex Shader)
顶点着色器非常干净,没有任何顶点坐标的偏离计算(没有 Vertex Displacement)。它只做了两件最基础的事:空间投影转换、传递 UV。
这完全是为了移动端优化:如果去动顶点,每次模型动作都需要在 GPU 重新计算顶点法线和位置,这在手机上开销较大。
Varyings vert(Attributes input)
{
Varyings output;
// 1. 将模型空间(Object Space)的坐标,投影转换到屏幕裁剪空间(Homogeneous Clip Space)
output.positionHCS = TransformObjectToHClip(input.positionOS.xyz);
// 2. 将 UV 坐标通过 Tiling 和 Offset 计算后,传给下一步(Varyings)
output.uv = TRANSFORM_TEX(input.uv, _NoiseTex);
return output;
}
3.3 片元着色器(Fragment Shader)—— 火焰的灵魂逻辑
这是实现“动态火焰外壳”的核心数学运算。每一个屏幕像素都要执行一次这段逻辑。
half4 frag(Varyings input) : SV_Target
{
half2 uv = input.uv;
half t = _Time.y; // 获取自场景加载后的总时间(秒)
// 1. 计算两层不同滚动方向和速度的 UV 坐标
half2 uv1 = uv * _Tiling + _ScrollSpeed1.xy * t;
half2 uv2 = uv * _Tiling + _ScrollSpeed2.xy * t;
// 2. 采样两次同一个噪声图,但是用不同的 UV 方向
// 移动端优化:只采样红色通道(.r),因为灰度图的 r、g、b 值是相等的,取单通道能极大减少寄存器开销
half nA = SAMPLE_TEXTURE2D(_NoiseTex, sampler_NoiseTex, uv1).r;
half nB = SAMPLE_TEXTURE2D(_NoiseTex, sampler_NoiseTex, uv2).r;
// 3. 构造扭曲向量(数学重映射)
// 因为 nA 和 nB 取值在 [0, 1],如果直接加到 UV 上,UV 会整体往右上角偏移
// 减去 0.5 之后,数值范围变为了 [-0.5, 0.5],这样扭曲就会有向左、向右、向上、向下的双向随机抖动
half2 distort = half2(nA, nB) - 0.5;
// 4. 计算最终扭曲后的 UV 坐标
// 使用 lerp(线性插值)将“原始 UV”向“扭曲后的 UV”靠拢,比例由置换强度控制
half2 warpedUV = lerp(uv, uv + distort * _DisplacementStrength, _DisplacementStrength);
// 5. 使用被严重扭曲后的 UV(warpedUV)去采样第三次噪声,模拟向上升腾的主火焰体
// 注意:这里传入了 `-t * _RiseSpeed`,意味着火焰的纹理图案在被扭曲的同时,还在疯狂向上(V轴正向)拉扯
half fire = SAMPLE_TEXTURE2D(
_NoiseTex,
sampler_NoiseTex,
warpedUV * _FireScale + half2(0.0, -t * _RiseSpeed)).r;
// 6. 湍流叠加与侵蚀
// `nA * nB` 相当于把两层错开滚动的噪声叠加在一起(两层噪声相乘,只有它们都很亮时才亮)
// 用 _RiseAccent 参数控制这种湍流对火焰主体的“腐蚀”程度,让火舌边缘产生不规则的细碎破碎感
fire *= lerp(1.0h, nA * nB, _RiseAccent);
// 7. 软裁剪(Soft Cutoff)—— 模拟火焰撕裂边缘
// smoothstep(min, max, x) 是一个平滑的阶跃函数:
// 当 fire 小于 _Cutoff 时,返回 0(彻底黑掉,裁剪);
// 在 _Cutoff 到 _Cutoff + 0.05 之间,平滑渐变过渡;
// 大于最大值时,返回 1(完全保留)。
// 这避免了 `clip()` 函数在移动端可能导致的硬锯齿,使得火苗边缘是柔和过渡的
fire *= smoothstep(_Cutoff, _Cutoff + 0.05h, fire);
// 8. 自发光与能量叠加
// 将计算出的火苗灰度分布值(fire)乘以高动态范围的火色(_FireColor.rgb),再乘以强度(_Intensity)
half3 col = _FireColor.rgb * fire * _Intensity;
// 9. 最终输出颜色
// RGB 为火色,Alpha 传入 fire
// 因为 Blend 模式是 One One(相加),Alpha 在这里主要影响它与其他半透明物体混合时的权重表现
return half4(col, fire);
}
理论与实践的差距
把这份真实的 URP HLSL 代码,与我们之前讨论的“纯理论模型”放在一起对比,你会发现一个非常经典的工程学妥协与艺术欺骗。
它与我们前面讨论的理想化原理,既有完美的呼应(验证了你的直觉),也有为了移动端性能而做出的巨大改变。
以下是它们的核心异同点对比:
一、 最大的不同:完全放弃了“几何置换”,改用“空间置换”(UV 扭曲)
-
之前提到的原理: 顶点置换(Vertex Displacement)。通过在顶点着色器(VS)中修改顶点的
,让模型表面真的鼓起来。
-
代码中的实际实现:
你给出的这份代码中,顶点着色器(vert函数)极其干净,连一个加法都没做。真正的形变,完全是在片元着色器(frag函数)中通过warpedUV(扭曲的 UV 坐标) 采样实现的。 - 为什么要这么改?(移动端优化的核心):
- 计算开销巨大: 移动端的 GPU 非常害怕在 Vertex Shader 里去采样纹理(称为 VTF - Vertex Texture Fetch)。如果模型有几万个顶点,为了改变形状,GPU 必须在顶点阶段对噪声图进行几万次采样。
- 顶点精度的妥协: 正如你之前直觉指出的,如果模型顶点不够密,顶点置换出来的火焰会有难看的“折线棱角”。
-
Shader 的欺骗艺术: 这份代码选择不改变几何体形状(网格还是平的)。它直接去“揉捏”贴图的 UV 坐标(
distort)。因为贴图纹理非常精细(比如像素),在像素级别进行抖动和扭曲,能以极低的计算成本,呈现出极其平滑、细腻的“动态流动起伏感”,彻底规避了顶点不够导致的锯齿问题。
二、 溶解与裁剪的实现:从“硬砍一刀”变成了“软阶跃”
-
之前提到的原理: 使用
clip(x)或discard指令,当数值小于 0 时直接不渲染该像素。 -
代码中的实际实现:
代码中没有出现clip。它使用的是:
fire *= smoothstep(_Cutoff, _Cutoff + 0.05h, fire); - 为什么要这么改?:
-
硬件杀手
discard: 在现代移动端 GPU(尤其是采用 TBDR 架构的手机芯片)中,使用clip或discard指令会破坏显卡的深度预判机制(Early-Z),导致严重的性能掉帧。 -
锯齿问题:
clip是非黑即白的(要么有,要么没有),边缘会有严重的马赛克锯齿。 -
软阶跃的妙处: 代码使用
smoothstep在的极小区间内做了一次平滑插值。这相当于在火焰撕裂的边缘做了一层极其微弱的羽化抗锯齿。既实现了“低于阈值就变黑、不发光(因为是 Additive 模式,黑色乘以任何数都是黑,加法混合时黑色就等于透明)”的裁剪效果,又保证了边缘的圆润柔和。
三、 完美的印证:用“自相似的分形数学”代替复杂的物理
我们在理论部分提到,自然界的火焰具有分形自相似性。这份代码用了一个极为绝妙的技巧来复现它:
half nA = SAMPLE_TEXTURE2D(_NoiseTex, sampler_NoiseTex, uv1).r;
half nB = SAMPLE_TEXTURE2D(_NoiseTex, sampler_NoiseTex, uv2).r;
...
half fire = SAMPLE_TEXTURE2D(_NoiseTex, ... warpedUV).r;
-
它只使用了一张噪声图(
_NoiseTex)。 - 但是,它在不同的 UV 坐标(
uv1和uv2)下采样了两次,用来产生扭曲向量(distort)。 - 接着,用这个扭曲向量去扭曲 UV,第三次采样这张噪声图,作为火焰的主体。
- 最终: 它用一张黑白图,通过“自己扭曲自己”的手段(用噪声去扰动噪声的采样),在没有任何物理模拟代码的前提下,极为逼真地在大脑中还原出了流体湍流的涡流感、拉伸感和破碎感。
四、 缺失的部分:它没有写“菲涅尔通道”
- 之前提到的原理: 结合菲涅尔效应(Fresnel),让火焰只凝聚在剑的边缘轮廓,形成一个“外壳”罩在武器本体上。
-
代码中的实际实现:
这份代码完全没有计算视线向量(View Direction)与法线向量(Normal)的夹角,因此它没有菲涅尔发光边缘。 -
它的替代方案:
作者在开头的注释里写了:// 用法:复制武器网格子物体,挂本材质叠在本体之上。
它采用了一种更纯粹的美术手法:直接把武器的 Mesh 复制一份,整体稍微放大一点点(或者直接重合),利用Blend One One(相加混合) 让整个外壳叠在本体上发光。虽然少了“中间透、边缘亮”的菲涅尔质感,但对于移动端(特别是性能敏感的休闲手游或同屏人数极多的 MMORPG)来说,省去法线与视线向量的三角函数计算,能换来极其宝贵的帧率。