Unity红外热成像渲染:基于后处理Shader的灰度图实现与优化
1. 项目概述与核心价值
最近在做一个模拟训练的项目,需要实现一个红外热成像的观察视角。网上搜了一圈,发现现成的、能直接拿来用的Unity工程或者插件要么收费不菲,要么效果差强人意。很多方案就是简单贴个颜色渐变图,动态变化全靠脚本硬算,性能开销大不说,效果也假得很。于是,我花了点时间,从原理到实现完整走了一遍,自己做了一个效果还不错的红外热成像灰度图渲染方案。现在把这个完整的示例工程整理出来,免费分享给大家。
这个工程的核心,是模拟物体表面温度差异在红外热像仪中呈现出的灰度图像效果。它不仅仅是给场景加个滤镜那么简单,而是基于物体在场景中的空间位置、与“热源”的相对关系以及表面材质属性,动态计算出每个像素应有的“热辐射强度”,并映射为视觉上的灰度层级。这对于军事模拟、安防监控、工业检测、医疗教学等领域的虚拟仿真应用来说,是一个很实用的基础功能点。无论你是想快速集成这个效果到现有项目里,还是想学习其背后的Shader和渲染管线原理,这个工程都能提供一个清晰的起点。
2. 红外热成像效果的核心设计思路
2.1 效果本质:从物理信号到视觉灰度
在动手写代码之前,得先想明白我们要模拟的是什么。真实的热成像仪捕捉的是物体发射的红外辐射强度,这个强度与物体的绝对温度、表面发射率直接相关。但在虚拟环境中,我们无法获得真实的物理温度数据(除非你的模型数据里自带温度场)。因此,一个实用且高效的思路是进行“视觉模拟”而非“物理仿真”。
我们的设计目标是:根据场景中物体的空间属性和自定义的“热源”,生成一张视觉上符合热成像认知的灰度图。这里有几个关键模拟点:
- 距离衰减:离虚拟“热源”越近的物体或部位,显示为越亮的白色(高温);越远则越暗(低温)。
- 遮挡关系:被其他物体遮挡的部分,即使空间上离热源近,也应该显示为低温(暗色)。这需要深度信息。
- 材质差异:不同材质的物体,即使处于相同热环境下,热成像表现也应不同。例如,金属表面可能比布料反射更多的环境“热辐射”,导致其灰度表现有差异。我们可以用物体的Albedo贴图或自定义的材质参数来模拟这种差异。
- 动态变化:热源可能会移动,或者物体自身的“温度”会随时间变化(如发动机启动后变热)。
基于以上,我们决定采用渲染到纹理(Render to Texture)结合自定义后处理Shader的方案。核心流程是:先通过一个Pass渲染出包含“热辐射强度”信息的灰度图,然后再通过后处理将这个灰度图与场景颜色进行混合或替换,最终得到热成像效果的画面。
2.2 技术方案选型:为什么是后处理Shader?
实现类似效果通常有几种路径:
- 顶点/片元着色器直接修改材质:给每个需要热成像效果的材质单独写Shader。优点是灵活,可以为不同材质定制效果;缺点是工作量大,难以统一管理场景级别的热源和遮挡关系,且无法处理非Mesh Renderer的物体(如粒子)。
- 屏幕后处理(Image Effect):在全部场景渲染完成后,对最终屏幕图像进行处理。这是最合适的选择。原因如下:
- 全局性:可以一次性处理整个屏幕的所有物体,天然支持遮挡、UI等所有已渲染元素。
- 信息完备:可以方便地获取到屏幕空间深度纹理(_CameraDepthTexture),这是计算遮挡和距离衰减的关键。
- 性能可控:只需一个全屏绘制调用,性能开销相对稳定,且易于进行LOD优化(如降低采样分辨率)。
- 易于集成:作为一个独立的后处理组件,可以随时在相机上启用或禁用,不影响场景原有材质。
因此,我们的示例工程将围绕一个核心的后处理Shader来构建。同时,我们会配套提供C#脚本,用于动态控制热源位置、强度、灰度映射曲线等参数,让效果可交互、可调节。
注意:对于需要极高性能的移动端项目,全屏后处理需要谨慎评估。可以考虑将热成像渲染到一张低分辨率的Render Texture上,或者仅在特定观察相机上启用此效果。
3. 核心Shader实现细节解析
3.1 获取关键数据:深度、世界坐标与热源
后处理Shader的起点,是从Unity提供的内置纹理中获取我们需要的信息。在v2f结构体和vert函数中,我们需要计算出每个屏幕像素对应的UV坐标。
struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; // 用于从深度重建世界坐标 float3 ray : TEXCOORD1; }; v2f vert (appdata_img v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = v.texcoord; // 计算当前像素指向远裁剪面的射线向量(视角空间) float4 clipPos = float4(v.texcoord * 2.0 - 1.0, 1.0, 1.0); float4 camRay = mul(unity_CameraInvProjection, clipPos); o.ray = camRay.xyz / camRay.w; return o; }在片元着色器frag中,我们开始核心计算:
fixed4 frag (v2f i) : SV_Target { // 1. 采样屏幕颜色和深度 fixed4 originalColor = tex2D(_MainTex, i.uv); float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv); float linearDepth = Linear01Depth(depth); // 2. 重建世界坐标(关键步骤) // 方法:用从相机到远裁剪面的射线,根据线性深度进行插值 float3 worldPos = _WorldSpaceCameraPos + linearDepth * i.ray * _ProjectionParams.z; // 3. 计算到热源的距离衰减 float3 toHeatSource = worldPos - _HeatSourcePos.xyz; float distance = length(toHeatSource); // 使用平滑衰减函数,避免边缘突变 float distanceAttenuation = 1.0 / (1.0 + _DistanceFalloff * distance * distance); // 4. 考虑遮挡(简化版) // 这里是一个简化处理:如果当前点与热源的连线方向上,深度变化剧烈,则认为被遮挡。 // 更精确的做法需要射线步进(Ray Marching),但开销较大。示例中我们先实现基础版。 float occlusion = 1.0; // ... (遮挡计算逻辑,见下文扩展) // 5. 基础热辐射强度 float heatIntensity = distanceAttenuation * occlusion * _HeatSourceIntensity; // 6. 材质影响(示例:用原始颜色的亮度模拟材质发射率差异) float materialFactor = Luminance(originalColor.rgb); heatIntensity *= lerp(1.0, materialFactor, _MaterialInfluence); // 7. 映射到灰度,并输出 float gray = saturate(heatIntensity); // 钳制到[0,1] // 应用一个对比度曲线,让明暗区分更明显 gray = pow(gray, _Contrast); fixed4 thermalColor = fixed4(gray, gray, gray, originalColor.a); // 8. 与原始画面混合(可选) return lerp(originalColor, thermalColor, _EffectBlend); }这个框架包含了从深度重建世界坐标、距离衰减计算到最终灰度映射的核心链条。其中,_HeatSourcePos,_DistanceFalloff,_HeatSourceIntensity,_MaterialInfluence,_Contrast,_EffectBlend等都是暴露给C#脚本控制的Shader属性(Properties)。
3.2 深度重建世界坐标的“坑”与技巧
上面代码中重建世界坐标这一步非常关键,也是新手最容易出错的地方。为什么不用Unity自带的ComputeWorldSpacePosition函数?因为在一些旧的或自定义的管线中可能不直接提供。我们采用射线插值法,其原理是:
i.ray是在顶点着色器中计算出的、从相机到远裁剪面上对应点的向量(在视角空间)。linearDepth是当前片元到相机的归一化线性距离(0近,1远)。- 因此,
_WorldSpaceCameraPos + linearDepth * i.ray * _ProjectionParams.z就得到了当前片元的世界坐标。其中_ProjectionParams.z是相机远裁剪面距离的绝对值,用于将归一化的射线向量缩放回世界空间单位。
实操心得:务必在Shader开头验证重建坐标的准确性。一个简单的调试方法是,将
worldPos的y分量作为颜色输出,观察场景中高度变化是否与颜色渐变平滑对应。如果出现条纹或错位,很可能是深度纹理采样或射线计算有误。
3.3 模拟遮挡与材质影响的实用方法
遮挡的简化模拟: 完全精确的遮挡计算(即判断热源与当前点之间是否有物体阻挡)需要射线步进,在片元着色器中逐段采样深度进行比较,性能代价高。在示例工程中,我实现了一个性价比很高的近似方法:
// 在frag函数中,计算遮挡因子 float occlusion = 1.0; #if defined(USE_OCCLUSION_APPROXIMATE) // 取当前点到热源方向上的一个中间点进行采样 float3 midPoint = worldPos + toHeatSource * 0.3; // 采样路径上的30%处 // 将中间点投影回屏幕空间,采样其深度 float4 midPointClipPos = mul(UNITY_MATRIX_VP, float4(midPoint, 1.0)); float2 midPointUV = (midPointClipPos.xy / midPointClipPos.w) * 0.5 + 0.5; if (all(midPointUV > 0) && all(midPointUV < 1)) { float midDepth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, midPointUV); float midLinearDepth = Linear01Depth(midDepth); float3 midWorldPos = _WorldSpaceCameraPos + midLinearDepth * i.ray * _ProjectionParams.z; // 比较实际中间点世界坐标与理论中间点世界坐标的距离 // 如果距离很大,说明理论点被挡住了,实际看到的是更近的物体表面 if (distance(midPoint, midWorldPos) > _OcclusionThreshold) { occlusion = 0.3; // 被遮挡,强度大幅减弱 } } #endif这种方法只额外采样一次深度纹理,通过比较路径上一点的理论位置和实际渲染表面的位置,来近似判断遮挡。虽然不完美,但在多数情况下视觉上可以接受,且性能影响很小。
材质影响的模拟: 我们利用物体原本的Albedo颜色亮度(Luminance)来模拟不同材质对热辐射的响应差异。例如,深色粗糙表面(低亮度)在热成像中可能显得更暗(发射率低?或吸收好?这里是一种视觉模拟)。通过_MaterialInfluence参数,你可以控制原始颜色对最终热成像效果的影响程度。为0时,所有材质表现一致;为1时,物体原本的颜色亮度将直接影响其热成像灰度。
4. 完整的C#控制脚本与工程组织
4.1 热成像后处理控制器(ThermalImagingController.cs)
Shader写好了,我们需要一个C#脚本来动态传递参数、控制效果开关。这个脚本继承自MonoBehaviour,并挂载到带有Camera组件的物体上,或者通过CommandBuffer动态添加后处理效果。
using UnityEngine; using UnityEngine.Rendering.PostProcessing; // 如果使用PostProcessing V2包 // 如果使用URP,需要引用UnityEngine.Rendering.Universal [ExecuteInEditMode] public class ThermalImagingController : MonoBehaviour { [Header("热源设置")] public Transform heatSource; // 热源Transform,可动态赋值 public Vector3 heatSourcePosition = Vector3.zero; // 热源世界坐标 public float heatIntensity = 1.0f; public float distanceFalloff = 0.01f; [Header("效果参数")] [Range(0,1)] public float materialInfluence = 0.5f; [Range(0.5f, 3.0f)] public float contrast = 1.5f; [Range(0,1)] public float effectBlend = 1.0f; // 0为原图,1为纯热成像 [Header("遮挡近似")] public bool useOcclusionApprox = true; public float occlusionThreshold = 0.5f; private Material _thermalMaterial; public Shader thermalShader; // 拖拽赋值 void OnEnable() { if (thermalShader != null && thermalShader.isSupported) { _thermalMaterial = new Material(thermalShader); _thermalMaterial.hideFlags = HideFlags.HideAndDontSave; } else { Debug.LogError("Thermal shader is not assigned or not supported!"); enabled = false; } } void OnDisable() { if (_thermalMaterial != null) { DestroyImmediate(_thermalMaterial); } } // 在OnRenderImage中应用后处理 void OnRenderImage(RenderTexture source, RenderTexture destination) { if (_thermalMaterial == null || effectBlend <= 0) { Graphics.Blit(source, destination); return; } // 更新Shader参数 Vector3 finalHeatPos = heatSource != null ? heatSource.position : heatSourcePosition; _thermalMaterial.SetVector("_HeatSourcePos", new Vector4(finalHeatPos.x, finalHeatPos.y, finalHeatPos.z, 1.0f)); _thermalMaterial.SetFloat("_HeatSourceIntensity", heatIntensity); _thermalMaterial.SetFloat("_DistanceFalloff", distanceFalloff); _thermalMaterial.SetFloat("_MaterialInfluence", materialInfluence); _thermalMaterial.SetFloat("_Contrast", contrast); _thermalMaterial.SetFloat("_EffectBlend", effectBlend); // 设置关键字以启用/禁用特定功能块 if (useOcclusionApprox) { _thermalMaterial.EnableKeyword("USE_OCCLUSION_APPROXIMATE"); _thermalMaterial.SetFloat("_OcclusionThreshold", occlusionThreshold); } else { _thermalMaterial.DisableKeyword("USE_OCCLUSION_APPROXIMATE"); } // 执行Blit操作,应用材质 Graphics.Blit(source, destination, _thermalMaterial); } // 在编辑模式下,也可以提供一个预览用的Update(非必须) #if UNITY_EDITOR void Update() { if (!Application.isPlaying) { // 强制场景视图刷新 UnityEditorInternal.InternalEditorUtility.RepaintAllViews(); } } #endif }这个脚本提供了完整的Inspector面板控制,你可以实时拖动滑块或修改热源位置,立即在Game视图或Scene视图(因为使用了ExecuteInEditMode)中看到效果变化。
4.2 示例工程场景与资源组织
为了让这个示例工程即拿即用、易于理解,我按以下结构组织了项目:
Assets/ ├── ThermalImagingExample/ │ ├── Scenes/ │ │ └── Demo.unity # 主演示场景 │ ├── Scripts/ │ │ ├── ThermalImagingController.cs │ │ └── SimpleHeatSourceMover.cs # 一个让热源简单移动的脚本 │ ├── Shaders/ │ │ ├── ThermalImaging.shader │ │ └── ThermalImaging.shadergraph # 可选:URP的Shader Graph版本 │ ├── Materials/ │ │ └── ThermalImaging.mat # 创建好的材质球 │ ├── Prefabs/ │ │ └── ThermalCamera.prefab # 预制体,包含相机和控制器 │ └── Textures/ │ └── TestAlbedo.png # 用于测试材质影响的贴图在演示场景中,我放置了几个不同颜色和形状的3D模型(可以使用从SolidWorks等软件导入的FBX模型),一个作为热源的Sphere,以及一个应用了ThermalImagingController的相机。运行后,你可以通过UI滑块或直接移动热源球体,观察整个场景如何实时转变为热成像视图。
5. 进阶优化与效果增强方案
基础效果实现后,我们可以从性能和表现力两个方向进行增强。
5.1 性能优化要点
降低采样分辨率:对于热成像这种对绝对清晰度要求不高的效果,完全可以将后处理渲染到一张分辨率减半(甚至更低)的Render Texture上,然后再上采样到屏幕。这能显著降低像素着色器的调用次数。
// 在ThermalImagingController中 private RenderTexture _halfResRT; void OnRenderImage(...) { if (_halfResRT == null || _halfResRT.width != source.width/2) { // 创建半分辨率RT if (_halfResRT != null) _halfResRT.Release(); _halfResRT = new RenderTexture(source.width/2, source.height/2, 0, source.format); _halfResRT.hideFlags = HideFlags.HideAndDontSave; } // 先Blit到半分辨率RT Graphics.Blit(source, _halfResRT, _thermalMaterial); // 再将半分辨率RT上采样Blit到目标(使用一个简单的双线性采样材质) Graphics.Blit(_halfResRT, destination, _upscaleMaterial); }有选择地启用:为需要热成像的观察视角单独创建一个相机,并设置其Culling Mask,只渲染必要的层。将这个后处理效果仅加在这个相机上。主相机正常渲染UI和其他不需要此效果的内容。
简化Shader计算:在移动端,可以考虑将世界坐标重建的计算从片元着色器移到顶点着色器,然后通过插值传递给片元着色器(
TEXCOORD),虽然精度略有损失,但能提升性能。
5.2 效果增强技巧
多热源支持:目前的Shader只支持单个热源。你可以修改Shader,使其支持一个热源数组。在片元着色器中,遍历所有有效热源,计算每个热源带来的强度,然后叠加(通常取最大值或相加)。
#define MAX_HEAT_SOURCES 4 uniform float4 _HeatSourcePosArray[MAX_HEAT_SOURCES]; uniform float _HeatSourceIntensityArray[MAX_HEAT_SOURCES]; ... float totalHeat = 0; for (int i = 0; i < MAX_HEAT_SOURCES; i++) { if (_HeatSourceIntensityArray[i] > 0) { float dist = distance(worldPos, _HeatSourcePosArray[i].xyz); float atten = 1.0 / (1.0 + _DistanceFalloff * dist * dist); totalHeat = max(totalHeat, atten * _HeatSourceIntensityArray[i]); // 取最热的影响 } }伪彩色(False Color):真实的热成像仪常用“铁红”、“彩虹”等伪彩色模式来增强不同温度区间的辨识度。我们可以很容易地将灰度值
gray作为索引,去采样一个1D的渐变纹理(Gradient Texture),输出彩色结果。uniform sampler2D _ColorRampTex; // 一个水平渐变的纹理 ... float gray = saturate(heatIntensity); fixed3 falseColor = tex2D(_ColorRampTex, float2(gray, 0.5)).rgb;动态温度变化:为场景中的物体附加一个
Temperature脚本,记录其“当前温度”。温度可以随时间向“环境温度”衰减,受热源影响时升高。在后处理Shader中,不再仅仅依赖与热源的距离,而是采样一张存储了各物体温度信息的Render Texture(通过另一个相机或替换Shader渲染得到),实现更复杂、更动态的热扩散模拟。这是向“物理仿真”迈进的一大步,但复杂度也显著增加。
6. 常见问题与排查实录
在实际使用和复现这个工程的过程中,你可能会遇到以下问题。这里是我踩过坑后的经验总结:
问题1:屏幕一片纯白或纯黑,没有渐变效果。
- 排查步骤:
- 检查深度纹理:确保相机的
Depth Texture Mode设置为Depth或DepthNormals。这是_CameraDepthTexture可用的前提。在相机组件或ThermalImagingController的OnEnable中设置camera.depthTextureMode |= DepthTextureMode.Depth;。 - 检查热源位置:在Scene视图中确认
_HeatSourcePos的世界坐标值是否正确传递到了Shader。可以在Shader中临时将worldPos或distance作为颜色输出,看看是否有变化。 - 检查衰减参数:
_DistanceFalloff值可能太大或太小。尝试将其设置为一个极小的值(如0.0001)和一个较大的值(如1),观察效果变化。
- 检查深度纹理:确保相机的
问题2:热成像效果边缘有锯齿或闪烁。
- 原因与解决:
- 深度重建精度问题:在边缘尤其是物体轮廓处,深度值变化剧烈,重建的世界坐标可能有精度误差。可以尝试在Shader中使用
LinearEyeDepth代替Linear01Depth,并确保射线计算正确。 - 深度纹理分辨率:默认深度纹理可能与屏幕分辨率不同。如果问题严重,可以尝试使用高精度的深度纹理模式。
- 后处理抗锯齿:确保后处理在抗锯齿之后执行。如果项目使用了MSAA或SMAA,注意后处理可能会在抗锯齿之前生效,导致采样边缘问题。在URP/HDRP中,正确配置Render Feature的顺序至关重要。
- 深度重建精度问题:在边缘尤其是物体轮廓处,深度值变化剧烈,重建的世界坐标可能有精度误差。可以尝试在Shader中使用
问题3:移动平台(Android/iOS)上效果异常或性能很差。
- 排查与优化:
- Shader兼容性:检查Shader中使用的函数和语法是否被目标平台的GLSL ES支持。避免使用
tex2Dlod(除非有特定需求),使用tex2D代替。使用SHADER_TARGET宏进行条件编译。 - 精度问题:在片元着色器开头添加
precision mediump float;(对于GLES)来指定中等精度,平衡精度和性能。将worldPos等计算移到顶点阶段插值。 - 带宽压力:全屏后处理本身就会带来带宽压力。务必实施“降低采样分辨率”的优化,并考虑在低端设备上完全关闭此效果。
- Shader兼容性:检查Shader中使用的函数和语法是否被目标平台的GLSL ES支持。避免使用
问题4:如何与URP/HDRP兼容?
- 方案:
- URP:推荐使用Shader Graph和Renderer Feature。创建一个Fullscreen Pass Renderer Feature,将我们编写的HLSL代码封装成Custom Function Node,或者在Shader Graph中直接搭建节点。URP不直接支持
OnRenderImage,必须通过Render Feature插入到渲染管线中。 - HDRP:同样使用Custom Pass。HDRP的Custom Pass提供了更强大的控制能力,可以在指定阶段(Before Post Process, After Post Process等)插入全屏绘制。将我们的Shader编写为HDRP兼容的HLSL(注意包含正确的头文件,如
#include "Packages/com.unity.render-pipelines.high-definition/Runtime/ShaderLibrary/ShaderVariables.hlsl"),并在Custom Pass中执行绘制。
- URP:推荐使用Shader Graph和Renderer Feature。创建一个Fullscreen Pass Renderer Feature,将我们编写的HLSL代码封装成Custom Function Node,或者在Shader Graph中直接搭建节点。URP不直接支持
问题5:效果与场景中的透明物体(如粒子、UI)叠加不正确。
- 原因:后处理
OnRenderImage是在所有不透明和透明物体渲染完成后才执行的。我们的Shader基于深度纹理,而透明物体通常不写入深度纹理。因此,热成像效果无法“透过”透明物体看到后面的热源影响,透明物体本身也无法正确参与热强度计算。 - 解决思路(复杂):这是一个高级话题。一种方案是使用两个Pass:第一个Pass渲染不透明物体的热贡献到一张RT;第二个Pass用另一个Shader渲染透明物体,并以其屏幕坐标采样第一张RT,进行混合计算。这需要更精细的渲染管线控制,超出了基础示例的范围。在大多数仿真应用中,可以要求美术将需要热成像效果的透明物体(如火焰、烟雾)做成不透明或使用特殊的着色器来写入自定义的热强度数据。
这个示例工程提供了一个坚实且可扩展的起点。它剥离了商业插件的黑盒,让你能从Shader代码层面理解每一行计算的意义。你可以根据自己的项目需求,随意修改衰减算法、添加噪声模拟传感器误差、集成更复杂的温度系统,或者将其改造成一个完全不同的视觉特效。希望这份详细的拆解和完整的工程,能帮你省下大量摸索的时间。
