做航天题材游戏或者模拟类的项目时,我最常被问到的效果之一就是“再入大气层时船体表面那种被高温灼烧的样子”。很多朋友看完《坎巴拉太空计划》(以下简称KSP)里那种从橘红渐变到焦黑、边缘带颗粒剥落的再入特效后,第一反应是“这不就是一套火焰粒子加贴图变化吗”,但真要动手做,才发现粒子系统挂在外面是“假烧”,真正像KSP那种“船体自己在烧”的感觉,还得靠Shader去驱动材质本身的变化。
这篇文章就把我在模拟项目X里复刻KSP式大气层摩擦灼烧特效的完整思路和代码拆开讲一遍。核心不是一个现成插件,而是从温度来源、贴图设计、Shader叠加、脚本传参到坑点排查的一整套做法。适合已经会用Unity基础Shader、想更深入做风格化表面效果的朋友,也适合刚准备给航天类项目加再入演出、但不知道从哪里下手的开发者。
1. 思路拆解:为什么看上去“烫”比“烧”更重要
1.1 KSP灼烧效果的视觉本质
先看KSP原版效果给人的直观感受:再入舱外表面并不是均匀变红,而是像被几股超高温气流扫过,局部先亮起来,随后暗斑和裂纹往外渗透,最后整片焦黑,但焦黑里又透出隐隐的红色芯子。这种效果本质上不是火焰,而是高温材料表面发出的热辐射和碳化痕迹。
我把这种观感拆成三个要素:
第一是“热区随强度扩散”。飞船越往低层大气飞、速度越快,热区从温吞的暗红逐渐被“点燃”,由局部小斑点蔓延成大面积亮色。第二是“表面碳化分层”。烧过的地方不能只有红色,得有黑色炭化、剥落、边缘发亮三个层次,否则看起来像涂了一层油漆。第三是“高频抖动”。再入过程中气动不稳定和湍流会让热度分布不停变化,Shader里需要高频噪波叠加在温度场上,否则烧焦区域会死气沉沉。
这三层叠加后,观众根本不需要物理上精确的等离子层模拟,就已经相信“这艘船正在被烤”。
1.2 技术选型:代码Shader还是Shader Graph
手写Shader和Shader Graph二选一,我这次选的是手写CG/HLSL。原因有三点。
一是温度参数种类多。灼烧效果需要强度、速度、边缘朝向、噪声采样、温度插值等多个变量同时作用,在Shader Graph里连出十几个节点虽然也能做,但节点图一旦复杂起来,排查单个变量的影响路径非常费劲。
二是方便跨管线复用。URP、HDRP、甚至老旧的内置渲染管线各有差异,但这套Shader的核心逻辑只依赖UV和向量运算,不依赖管线特化的光照模型,手写代码改动量比节点图小得多。
三是性能可控。手写能精准控制每个pass的复杂度,比如只在需要时计算噪波、只在签证阶段对朝向做判断,不会像图形化编辑器那样经常莫名其妙多出几个重计算节点。
当然Shader Graph也能复现,尤其你只做URP,而且目标是让非程序美术也能自己调参数,那它更友好。但如果你想搞一套真正能压到低端移动端还能满帧的灼烧材质,手写是更稳的方向。
1.3 不做什么:为什么不做真实物理烧蚀
很多项目一上来就希望做“真实热防护烧蚀”,比如表面温度扩散方程、碳层脱落质量损失、气动加热量计算。作为研究是可以,但游戏里的镜头体验几乎感受不到这种物理精度。玩家看到的是几秒内船体从完好变成焦黑,这个过程在真实飞行中可能要持续几分钟,而且真实高温再入时舱外温度达上千度,表面很快整体碳化,反而缺少戏剧性层次。
所以我建议的做法是:物理量只做“输入信号”,视觉结果走风格化。温度、速度、高度这些物理参数用来驱动Shader里的热暴露度,但热暴露度到最终颜色之间,完全由美术可控的曲线、贴图、噪声来翻译。这样既保留“物理出发”的真实感,又能保证不同飞船大小、不同视角下都好看。
2. 核心参数与贴图准备
2.1 温度来源:外界向Shader输送什么
Shader本身不关心飞船的真实壁面温度是否达到800度,它只需要几个0到1之间的驱动值。我会在C#端计算三个参数,然后塞进材质:
- heatExposure:再入总强度,由动压和速度共同决定,值越大表示气动加热越强。
- heatFactor:归一化的表面热流分布,表示“同一个时间点,船头和后身哪个地方被烤得更狠”。
- turbulence:湍流扰动幅度,高度越低、速度越快,抖动越剧烈。
这三个值最终都会通过MaterialPropertyBlock传给正式Shader,为什么用MaterialPropertyBlock呢?因为同一种飞船可能有好几艘,直接改材质实例会导致合批中断和资源复制,MaterialPropertyBlock可以批量设置且不影响共享材质。
2.2 灼烧贴图设计:一张图表达三种状态
我使用的灼烧主图是纹理三通道分别存储信息的方式:
- R通道存“碳化剥落遮罩”:黑色区域表示还没烧透、白色区域表示已经碳化甚至剥落,Shader用它来控制暗色区域的范围。
- G通道存“红热发光遮罩”:白色区域表示高温发光区,主要分布在隔热瓦边缘、连接缝、结构转折处,避免整船均匀发热。
- B通道存“烧焦扩散顺序”:一张从船头到船尾的黑白渐变,表示热流沿船体传播的过程,也可以作为额外的扰动源。
这种通道设计的好处是,美术只需要在工具里画三张灰度图就能精确控制哪里先亮、哪里后黑,不需要改Shader代码就能换飞船只视觉风格。
贴图分辨率建议控制在1024以内。灼烧效果里大量依赖模糊过渡和噪波,太高分辨率反而会把细碎细节暴露出来,导致画面噪点感过重。另外贴图格式建议使用ASTC或ETC2压缩,尽量避免RGB格式直接采样,因为移动端高精度颜色对带宽影响很大。
2.3 顶点色复用技巧
如果不想为每艘飞船单独拆UV画灼烧遮罩,顶点色是效率很高的后备方案:
- 顶点色R:表示“船体朝向再入方向”的热暴露倾向,船头顶点给到1,尾部给到0。
- 顶点色G:表示“结构缝/边缘”的强调程度,想让某个位置优先变红,就把相关顶点的G刷到0.5以上。
- 顶点色B:表示“被气动遮挡”的区域,如两翼内侧、设备舱背面,这些位置热暴露较弱,可压制灼烧强度。
Shader里把顶点色和纹理通道做乘法,就能实现“强局部控制+全局贴图细节”的组合。我的经验是,小飞船(尤其是胶囊体、圆柱体改的)直接用顶点色最省事,而大飞船或复杂外形的目标建议还是拆UV画通道图,精度更高。
3. 手写Shader核心代码与实现
3.1 外部热源与温度场计算
下面这段代码是我在Shader里暴露的参数和控制逻辑。注意这里不直接输出颜色,而是先算一个温度场值。
以下这段是效果核心之一。代码我做了精简,但至少包含外部参数声明、热度输入计算和温度场返回值。
// 温度场输入参数 float _HeatExposure; // 0~1,总热暴露强度 float _HeatFactor; // 0~1,表面热流分布倍率 float _Turbulence; // 0~1,湍流扰动幅度 float _BurnSpeed; // 热度扩散速度 float _NoiseScale; // 噪声缩放 float _NoiseStrength; // 噪声强度 sampler2D _BurnMask; // R: 碳化遮罩 G: 红热遮罩 B: 扩散顺序 float4 _BurnMask_ST; inline float GetNoise(float2 uv, float t) { // 使用双噪声叠加:主低频为明显热斑,副高频为小抖动 float n1 = tex2Dlod(_NoiseTex, float4(uv * _NoiseScale, 0, 0)).r; float n2 = tex2Dlod(_NoiseTexHigh, float4(uv * _NoiseScale * 3.7, 0, 0)).r; return lerp(n1, n2, 0.45) * _NoiseStrength; } inline float GetHeatField(float2 uv, float vertexHeatHint, float rawIntensity) { // 用UV的V方向作为飞船纵向热流传播方向 float burnMask = tex2Dlod(_BurnMask, float4(uv, 0, 0)); float regionHeat = burnMask.b; // 扩散顺序 float edgeHeat = burnMask.g; // 红热遮罩 // 加入顶点色暗示 float localHeat = saturate(regionHeat * (0.3 + 0.7 * vertexHeatHint)); // 扩散:只让热度值上升,不快速下降 float temp = saturate(rawIntensity * _HeatFactor * localHeat); temp = pow(abs(temp), 0.85); // 增加暗部对比 // 湍流扰动 float noise = GetNoise(uv, _Time.y); temp = temp + noise * _Turbulence * (1.0 - temp); temp = saturate(temp); return temp; }这里用pow(abs(temp), 0.85)是为了压低暗部、让温度从低到高的变化更陡峭,模拟“温度不到阈值就不明显变红”的物理直觉。若直接线性映射,会让船体看起来从常温就开始泛红。要注意_NoiseScale不要设成整数倍叠加,否则噪声图案会周期性重复。
3.2 灼烧渐变与边缘发光叠加
温度场出来后,下一步就是把它翻译成三种颜色:红热、烧焦、暗底。
我这里没有使用复杂的多段渐变曲线,而是用两个基础渐变纹理采样后插值。
关键点在于“红热发光”的亮部必须落在遮罩边缘处,所以要反向利用碳化遮罩的过渡带:
float4 frag (v2f i) : SV_Target { float heat = GetHeatField(i.uv.xy, i.vertexHint, _HeatExposure); half4 mask = tex2D(_BurnMask, i.uv.xy); // 碳化程度:优先由R通道决定,同时受热度扩散控制 float charred = mask.r * smoothstep(0.15, 0.85, heat); // 边缘红热:取临近采样差值,模拟“烧穿边缘”发光 float2 dirX = float2(_MainTex_TexelSize.x, 0); float2 dirY = float2(0, _MainTex_TexelSize.y); float charredR = tex2D(_BurnMask, i.uv.xy + dirX).r; float charredL = tex2D(_BurnMask, i.uv.xy - dirX).r; float charredU = tex2D(_BurnMask, i.uv.xy + dirY).r; float charredD = tex2D(_BurnMask, i.uv.xy - dirY).r; float edge = length(float2(charredR - charredL, charredU - charredD)); float hotEdge = edge * smoothstep(0.35, 0.95, heat) * mask.g; float redGlow = pow(max(0, heat - 0.35), 2.0) + hotEdge * 0.7; // 基础船体贴图 half4 albedo = tex2D(_MainTex, i.uv.xy) * _Color; float3 burnColor = lerp(_BurnColor, _CoreColor, hotEdge); float3 finalColor = albedo.rgb * (1.0 - charred) + burnColor * charred; // 让灼烧区域叠加自发光 finalColor += redGlow * _GlowColor.rgb * _GlowColor.a; // 保持高光信息不因烧焦全部消失 float specMask = lerp(1.0, 0.15, charred); // 如果项目有PBR输出,可继续把smoothness乘specMask ... }边缘红热这一步,实质上是用相邻像素的碳化遮罩差异来模拟“高温从隔热瓦缝里透出来”的效果。别看原理简单,画面质感提升非常大。若觉得采样开销高,可以只对heat > 0.2的像素做边缘计算,否则直接返还原始颜色,能省不少GPU周期。
3.3 粒子剥离与亮斑抖动
灼烧效果到了高热度阶段,不能只有颜色变化,还应有视觉上的亮斑瞬间爆发。我把这步放在Shader的第二个pass或者一个单独的叠加pass上,通过参数控制是否启用。
通常做法是采样一张小尺寸噪点图,并让它以很高的频率流动:
float spark = tex2D(_SparkNoise, i.uv.xy * _SparkScale + _Time.x * _SparkFlow).r; spark = step(1.0 - _SparkDensity, spark); float3 sparkColor = spark * _SparkColor.rgb * heat * _SparkIntensity; finalColor += sparkColor;这步要小心:亮斑不能太均匀,否则看起来像雪花点。我给密度参数做了“非均匀分布”,实际项目里优选在脚本端每帧动态改变_SparkScale的方向和_SparkFlow速度,让亮斑方向与气动流向一致,观感更真实。
3.4 材质变体与参数暴露
Shader写完难免要调试,我给材质面板暴露了以下几组参数,并分成三个权限等级:
| 分类 | 参数示例 | 调优建议 |
|---|---|---|
| 全局演出 | HeatExposure / HeatFactor | 一般由脚本实时更新,不在Inspector手调 |
| 美术微调 | BurnColor / CoreColor / GlowColor | 在预览中反复改,注意冷暖平衡 |
| 性能/工艺 | NoiseScale / NoiseStrength / SparkDensity | 调整时观察帧率,过高说明过度依赖噪声采样 |
建议把“脚本驱动参数”和“纯美术参数”分放在不同的材质Header下,否则团队协作时有人不小心改了脚本驱动值,再入效果直接崩掉,排查起来十分痛苦。
4. 实操接入:从C#脚本到材质动画
4.1 脚本端计算热暴露强度
C#端我维护了一个再入监测器,每帧从物理引擎拿速度和当前大气密度,模拟动压头换算成一个rawIntensity,代码块如下:
float RawReentryIntensity() { float speed = rigidbody.velocity.magnitude; // m/s float dynamicPressure = 0.5f * atmosphereDensity * speed * speed; // 阈值处理:低于一定动压不触发灼烧 float intensity = Mathf.InverseLerp(heatThreshold, heatPeak, dynamicPressure); return Mathf.Clamp01(intensity); }注意这里的大气密度不是Unity自带的,而是要根据飞行高度用指数曲线拟合。KSP那样的简化模型通常直接用Mathf.Exp(-height / scaleHeight)来近似,scaleHeight在低海拔设为7000米左右比较接近可视觉接受的范围。
4.2 材质实例参数驱动与插值平滑
真实再入过程热度变化不是瞬间到位,尤其是“降温”阶段,船体冷却会有一个延迟。我在脚本里对heatExposure做了非对称插值:
float currentHeat = matBlock.GetFloat("_HeatExposure"); float targetHeat = rawIntensity; if (targetHeat > currentHeat) currentHeat = Mathf.Lerp(currentHeat, targetHeat, 1f - Mathf.Exp(-Time.deltaTime * heatUpSpeed)); else currentHeat = Mathf.Lerp(currentHeat, targetHeat, 1f - Mathf.Exp(-Time.deltaTime * heatDownSpeed));heatUpSpeed通常设为2到3,模拟快速升温;heatDownSpeed设为0.2到0.3,模拟机体冷却慢热。这种非对称处理在视觉上非常关键,否则玩家看到船刚飞出大气层温度立刻归零,感觉到的是“假参数”而不是“热的惯性”。
测出正确手感后,建议把插值逻辑放到FixedUpdate里(或物理更新帧),避免渲染帧率波动导致加热速度不一致。
4.3 在URP/HDRP/Legacy管线下切换
这套Shader本身不依赖复杂光照,因此切换到不同渲染管线主要是调整Include和宏定义。
在URP里,需要替换UnityCG.cginc相关的内置函数为Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl,并使用TransformObjectToHClip函数替代UnityObjectToClipPos。内置管线直接用UnityCG.cginc即可,代码几乎不改。HDRP则需要注意增加#include "Packages/com.unity.render-pipelines.high-definition/Runtime/ShaderLibrary/ShaderVariables.hlsl",并对光照模型做适配。
如果项目使用了表面着色器处理主体材质,灼烧Shader建议作为额外叠加层或顶点修改器使用,而不是取代原有表面。可以用以下结构兼容:
SubShader { Tags { "RenderType"="Opaque" "Queue"="Geometry" } Pass { CGPROGRAM #pragma multi_compile _ URP_MODE ... ENDCG } }5. 常见问题排查实录
5.1 现象:温度还没到,飞船边缘先烧了
出现这种情况十有八九是碳化遮罩R通道的边缘过渡太硬。R通道图如果是在Photoshop里用硬边笔刷刷的,采样时相邻像素R值差异大,会被边缘红热逻辑当成“缝隙”,从而提前点亮。
解法:把R通道图做一次高斯模糊,模糊强度依据飞船尺寸调节。KSP效果里边缘灼烧存在,但比例是“几个像素宽的过渡带”,不是一整块黑白的反锯齿线。另外检查顶点色B通道(遮挡区)是否加了太强的压制值,导致边缘R通道在未加热时先亮。
5.2 现象:闪烁不对劲,烧焦区域像白噪声
多半是噪声采样频率过高或采样UV过密。我的设置里_NoiseScale在1024贴图上通常设到2到4,而不是8到16;_NoiseStrength初始只给0.15。如果还嫌晃,就把_Turbulence的输入改成低频正弦函数,而不是直接用_Time.y。
还有个隐蔽问题:双噪声叠加时,如果第二张高频噪声图没有设置wrap模式为Repeat,UV超过1后会出现接缝撕裂,看起来像闪白的坏像素。务必把两张噪声贴图的Wrap Mode都设为Repeat,并且关闭mipmap太高阶的bias。
5.3 现象:性能开销过高,低端机发热
灼烧Shader主要性能坑都在过度采样。我在工程里跑了一轮帧率分析,发现tex2Dlod在片元阶段连续采样三张图后,移动端Adreno的GPU负载明显上升。优化手段有两招:一是把灼烧温度场计算放到顶点阶段算一次,用顶点色插值过渡到片元(适合低多边形飞船,因为精度差可接受);二是开启#pragma skip_variants把不需要的变体去掉,减少分支。
比如低端机上只保留“热度=整体”模式,把“湍流噪声”和“边缘采样”放到宏开关下,默认关闭。这样基础效果仍然完整,只是失去了高频抖动细节。
5.4 现象:UV接缝处黑线或染色断层
复合体飞船模型最容易出现接缝染色问题,常见原因是导入模型时UV岛之间没有足够的内缩边距,纹理过滤到边界会采样到其他重叠区域的颜色,导致接缝处出现一条深色或亮色的线。
建议美术侧统一在UV展开时设置2到3像素边距。如果已经在做的模型无法再改,Shader侧可以在采样灼烧贴图前对UV做微小缩放,把采样范围缩进岛心一点:
float2 uvRemap = (i.uv.xy - 0.5) * 0.98 + 0.5; half4 mask = tex2D(_BurnMask, uvRemap);这个缩放比例不能太大,否则会损失边缘细节。0.98是我在多数飞船上试出来的折中值,飞船特别大的模型可以适当改成0.985。
5.5 调参误区:红色太土、焦黑太假
最后提一个美术感受上的坑:灼烧红不能直接用纯红,纯红会显得像油漆,应使用偏橙的红(R=1.0,G=0.35,B=0.03左右),而碳化黑也不能是纯黑,加一点暗紫或暗棕,反射颜色才会显得自然。KSP里那种“被烧过的痕迹”更多来自颜色之间的过渡混合,而不是某一端颜色本身。
我在项目里通常把核心灼烧色设为(255, 95, 10),碳化色设为(55, 50, 62),这两种颜色在SRGB空间下混合后,观感比纯红+纯黑自然很多。
6. 后续扩展思路
如果做完整乎满意的基础效果,我建议下一步可以加两个扩展点。
一是“再入激波辉光”:在飞船周围包一层大气辉光,是让“大气层摩擦”感更强烈的点睛之笔,原理是在一个边缘发光的透明Shader里采样深度深度做环形光晕,和灼烧面上发光呼应,视觉冲击力会高很多。二是“动态热防护破损”:借纹理通道让碳化区域的可见蒙版可被实时修改,比如气动翼脱落或隔热瓦被击碎时,局部暴露锐利的金属色底材,再配一个粒子拖尾,整体演出就非常完整。
再入灼烧效果看起来复杂,本质上是一条“物理量→纹理驱动→噪波扰动→颜色映射”的管线,顺着这个思路,你完全可以在不改动美术资产的前提下,把这个Shader迁移到不同飞船模型上。我自己在模拟项目X里就是从一套基础胶囊体测起,最后平滑扩展到了整支舰队。
每次做这种偏演出的效果,我都会提醒自己:玩家不会去测你的温度场准不准,他们只会觉得“这艘船快烧穿了的紧张感”是否传达到位。Shader的每一个参数,都该为这个目标服务。