
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.csShader写好了我们需要一个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的顺序至关重要。问题3移动平台Android/iOS上效果异常或性能很差。排查与优化Shader兼容性检查Shader中使用的函数和语法是否被目标平台的GLSL ES支持。避免使用tex2Dlod除非有特定需求使用tex2D代替。使用SHADER_TARGET宏进行条件编译。精度问题在片元着色器开头添加precision mediump float;对于GLES来指定中等精度平衡精度和性能。将worldPos等计算移到顶点阶段插值。带宽压力全屏后处理本身就会带来带宽压力。务必实施“降低采样分辨率”的优化并考虑在低端设备上完全关闭此效果。问题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中执行绘制。问题5效果与场景中的透明物体如粒子、UI叠加不正确。原因后处理OnRenderImage是在所有不透明和透明物体渲染完成后才执行的。我们的Shader基于深度纹理而透明物体通常不写入深度纹理。因此热成像效果无法“透过”透明物体看到后面的热源影响透明物体本身也无法正确参与热强度计算。解决思路复杂这是一个高级话题。一种方案是使用两个Pass第一个Pass渲染不透明物体的热贡献到一张RT第二个Pass用另一个Shader渲染透明物体并以其屏幕坐标采样第一张RT进行混合计算。这需要更精细的渲染管线控制超出了基础示例的范围。在大多数仿真应用中可以要求美术将需要热成像效果的透明物体如火焰、烟雾做成不透明或使用特殊的着色器来写入自定义的热强度数据。这个示例工程提供了一个坚实且可扩展的起点。它剥离了商业插件的黑盒让你能从Shader代码层面理解每一行计算的意义。你可以根据自己的项目需求随意修改衰减算法、添加噪声模拟传感器误差、集成更复杂的温度系统或者将其改造成一个完全不同的视觉特效。希望这份详细的拆解和完整的工程能帮你省下大量摸索的时间。