1. 项目概述为什么要在Unity里做红外热成像最近在做一个模拟训练的项目客户提了个挺有意思的需求需要在虚拟的厂房环境里模拟设备巡检时用热像仪看到的效果。说白了就是得在Unity里实现一个逼真的红外热成像特效。这可不是简单贴个红色滤镜就能糊弄过去的它涉及到对场景温度信息的模拟、基于物理的热辐射颜色映射以及如何高效地将这套逻辑整合到实时渲染管线里。红外热成像效果的核心是将不可见的温度信息转化为可见的颜色光谱。在现实中热像仪捕捉的是物体表面发射的红外辐射强度然后通过伪彩色通常是铁红、彩虹、灰度等呈现出来。在Unity中实现它意味着我们需要自己定义一套“温度”数据并编写Shader着色器来实时计算并渲染出对应的颜色。这不仅能用于严肃的工业模拟、安全培训在游戏里也能营造出独特的科幻或战术视角比如《使命召唤》系列里的热成像瞄准镜。这个项目的挑战在于全流程的打通从最底层的Shader编码到如何将Shader效果应用到整个场景或特定物体再到通过RenderTexture渲染纹理进行后期处理或画中画显示。我会结合我实际踩过的坑把从思路到实现的每一步都拆开揉碎了讲目标是让你看完就能动手复现一个基础版本并理解其扩展可能性。2. 核心思路与方案设计模拟温度与渲染管线2.1 效果拆解我们到底要模拟什么一个合格的红外热成像模拟需要包含以下几个视觉特征温度-颜色映射这是根本。需要一个从低温到高温的连续颜色渐变条。通常低温显示为深蓝或黑色中温是红色或黄色高温是亮白或亮蓝取决于配色方案。辐射衰减与混合热辐射会随着距离衰减并且高温物体会“照亮”周围的低温物体。在简化模型中我们可以考虑根据深度或距离对温度颜色进行混合。边缘强化与噪声真实的热像仪图像并非绝对平滑通常带有一定的噪声并且高温区域的边缘比较锐利。加入适量的噪声和边缘检测能极大提升真实感。场景温度数据源我们需要为场景中的物体赋予“温度”属性。最简单的方法是给物体贴一个自定义的“温度图”一张灰度图白色代表高温黑色代表低温。更动态的方法是通过脚本实时计算或修改温度值。基于以上我们的技术方案就清晰了使用Shader进行核心渲染在片元着色器中采样“温度”数据根据映射关系输出颜色。利用RenderTexture进行后期合成将整个场景渲染到一张RenderTexture上然后在这张纹理上应用我们的红外Shader这样可以方便地做全屏效果或者将效果输出到UI中的小窗口画中画。结合脚本驱动动态数据用C#脚本控制物体温度的变化并将数据传递给Shader。2.2 方案选型Surface Shader vs. Image Effect vs. 自定义渲染管线Unity里有几种实现方式各有优劣方案A为每个材质编写Surface Shader做法给需要热成像效果的物体单独赋一个带有我们自定义红外逻辑的材质。优点控制粒度细每个物体可以有不同的温度图或温度值。性能开销与物体数量直接相关。缺点无法处理“热辐射照亮周围”这种全局效果。如果场景物体很多每个都改材质不现实。适用于重点突出少数关键物体的场景比如只高亮显示故障设备。方案B使用OnRenderImage制作全屏后处理Image Effect做法编写一个脚本挂载在相机上使用OnRenderImage函数和Graphics.Blit方法将相机渲染完成的图像进行二次处理。优点实现简单一套Shader处理整个屏幕。容易加入噪声、模糊等全屏特效。是实现标准热成像观察视角最快捷的方式。缺点它处理的是已经渲染好的颜色图像失去了物体的原始信息如我们自定义的温度数据。我们需要在前期渲染时就将温度信息存储到某张额外的纹理中例如使用额外的相机或Shader Pass供后处理Shader读取步骤稍复杂。方案C使用Command Buffer或URP/HDRP自定义渲染管线做法在Built-in管线中使用Command Buffer插入自定义绘制指令或在URP/HDRP中编写自定义的Renderer Feature和Render Pass。优点功能强大灵活性最高。可以精确控制渲染流程轻松实现多Pass渲染例如先渲染温度到一张纹理再合并。是追求高性能、高画质大型项目的选择。缺点复杂度高需要对Unity渲染管线有较深理解。我的选择与理由 对于大多数实战项目尤其是快速原型和中小型应用方案B全屏后处理是平衡难度与效果的优选。它能让玩家通过一个特定“热像仪”相机看到完整的热成像世界体验连贯。本实战也将以这个方案为主线。同时我会在关键环节提及其他方案的思路方便你按需拓展。注意从Unity 2018左右开始OnRenderImage在URP中逐渐被弃用但原理相通。在URP中我们通过创建ScriptableRendererFeature和ScriptableRenderPass来实现类似功能。本文核心Shader逻辑完全通用只是注入渲染管线的方式不同。为保持广泛适用性我们先基于Built-in管线讲解。3. 核心环节一构建温度数据源Shader需要知道每个像素点对应的温度。我们得先把这个数据准备好。3.1 静态温度图最简单直接的方案最直观的方法是使用一张纹理来存储温度信息。我们可以利用模型的第二套UV或顶点颜色但更通用的方法是使用一张单独的Temperature Map。制作温度图在Photoshop等软件中画一张与模型主纹理尺寸相同的灰度图。纯白RGB: 1,1,1代表最高温例如100°C纯黑0,0,0代表最低温例如0°C。你可以根据设备的发热部位如发动机、散热口来绘制白色区域。在Shader中采样在物体的基础Shader中我们新增一个纹理属性_TemperatureTex来采样这张图。但注意我们的后处理Shader无法直接拿到每个物体的这张图。因此我们需要一个预处理步骤用一个单独的相机将所有物体的温度信息先渲染到一张RenderTexture上。实现步骤渲染温度信息到RenderTexture创建一个新的Layer例如叫做“Temperature”。给所有需要热成像显示的物体分配这个Layer。创建一个新的相机TemperatureCamera将其Culling Mask设置为仅“Temperature”层。将这个相机的Target Texture设置为一张RenderTexture例如_TemperatureRT。为这个相机下的所有物体使用一个极简的Shader这个Shader的唯一目的就是将_TemperatureTex的灰度值输出到颜色缓冲。这个Shader可以简单到只返回tex2d(_TemperatureTex, uv).r取红色通道因为灰度图RGB值相同。这样TemperatureCamera渲染完成后_TemperatureRT这张纹理的每个像素就存储了对应屏幕位置的温度值0-1范围。现在我们的后处理Shader就能采样_TemperatureRT来获取温度数据了。这个方法将动态的温度判断转换为了静态的纹理烘焙性能好但无法表现动态的温度变化。3.2 动态温度值通过脚本驱动如果需要温度随时间、状态变化如设备启动后发热静态纹理就不够了。我们需要将温度值作为动态数据传递给Shader。对于方案A每个物体单独材质很简单直接在脚本中修改材质的属性即可。// C# 脚本示例 public class DynamicHeating : MonoBehaviour { public float currentTemperature 20.0f; // 当前温度 private Material _material; void Start() { _material GetComponentRenderer().material; } void Update() { // 模拟温度上升 currentTemperature Time.deltaTime * 5.0f; // 将温度值假设0-100范围归一化到0-1传递给Shader float normalizedTemp Mathf.Clamp01(currentTemperature / 100.0f); _material.SetFloat(_Temperature, normalizedTemp); } }对于方案B全屏后处理问题变复杂了。后处理Shader面对的是整个屏幕它不知道哪个像素属于哪个物体。这时我们可以利用相机的深度纹理或自定义的G-Buffer。方法1利用深度重建世界坐标近似通过深度纹理和相机参数可以反推出像素点的世界坐标。如果我们能在脚本中维护一个“热源”列表包含位置、温度、影响半径在Shader中遍历所有热源计算当前像素点到每个热源的距离叠加温度影响。这种方法计算量随热源数量增加而增大适用于热源不多的场景。方法2使用额外的渲染通道存储ID类似于渲染温度图但这次我们渲染一个“物体ID”图。给每个动态发热的物体分配一个唯一的ID例如一个整数用一个相机渲染将ID编码成颜色写入另一张RenderTexture。在后处理Shader中采样这张ID图然后通过一个统一的数组float4 _HeatSourceData[100]来查询该ID对应的温度值。这种方法更精确但需要管理ID和数据的对应关系。实战建议对于动态效果要求不极端的情况“静态温度图 少数动态热源”的混合模式往往最实用。大部分背景和静态物体用温度图少数关键交互物体如角色、运行的机器用脚本驱动动态温度并通过方法1距离计算叠加到后处理中。4. 核心环节二编写红外热成像Shader这是项目的核心。我们将编写一个后处理Shader它接收原始场景颜色和温度数据输出热成像颜色。4.1 Shader框架搭建首先创建一个Unlit Shader因为它作为后处理效果不需要光照。Shader Hidden/ThermalImaging { Properties { _MainTex (Base (RGB), 2D) white {} // 原始场景图像 _TemperatureTex (Temperature (R), 2D) white {} // 温度数据纹理 _GradientTex (Color Ramp, 2D) white {} // 温度-颜色渐变条 _NoiseScale (Noise Scale, Float) 10.0 _NoiseAmount (Noise Amount, Range(0,0.1)) 0.02 _EdgeThreshold (Edge Threshold, Range(0,1)) 0.1 } SubShader { Pass { CGPROGRAM #pragma vertex vert_img // 使用内置的后处理顶点着色器 #pragma fragment frag #include UnityCG.cginc // 声明变量... // 片段着色器... ENDCG } } }4.2 温度-颜色映射的实现这是最关键的函数。我们根据采样到的温度值0-1从一个渐变纹理Color Ramp中查找对应的颜色。制作渐变纹理创建一张细长的纹理例如256x1从左到右绘制你想要的温度颜色渐变。在Unity中可以用代码生成也可以在PS里画好导入。在Shader中采样sampler2D _GradientTex; float4 frag (v2f_img i) : SV_Target { // 采样温度数据假设存储在红色通道 float temperature tex2D(_TemperatureTex, i.uv).r; // 加入一点随机噪声让温度显示更“真实” float noise frac(sin(dot(i.uv, float2(12.9898, 78.233))) * 43758.5453); temperature (noise * 2.0 - 1.0) * _NoiseAmount; temperature saturate(temperature); // 钳制到0-1 // 使用温度值作为UV的横坐标从渐变纹理中取色 float2 gradientUV float2(temperature, 0.5); float4 thermalColor tex2D(_GradientTex, gradientUV); return thermalColor; }这样我们就得到了一个基础的热成像色块。但此时它没有细节只是纯色块。4.3 融入场景细节边缘检测与原始图像混合纯色块看起来很假。真实的热像仪图像会保留物体的轮廓和纹理细节尤其是在温差不大的区域。我们可以通过边缘检测和与原始图像的混合来增加细节。Sobel边缘检测在温度图上进行边缘检测找出温度变化剧烈的边界。float sobel(sampler2D tex, float2 uv, float2 texelSize) { float3x3 gx float3x3(-1, 0, 1, -2, 0, 2, -1, 0, 1); float3x3 gy float3x3(-1, -2, -1, 0, 0, 0, 1, 2, 1); float edgeX 0 edgeY 0; for (int i -1; i 1; i) { for (int j -1; j 1; j) { float2 offset float2(i, j) * texelSize; float sample tex2D(tex, uv offset).r; edgeX sample * gx[i1][j1]; edgeY sample * gy[i1][j1]; } } return sqrt(edgeX*edgeX edgeY*edgeY); }在片段着色器中计算边缘强度edge sobel(_TemperatureTex, i.uv, _MainTex_TexelSize.xy)。如果edge _EdgeThreshold我们就在最终颜色上叠加强调边缘的颜色比如亮白色或黑色。与原始图像混合直接将thermalColor作为输出会完全丢失原始场景的明暗和纹理。一个改进方案是将热成像颜色与原始场景的亮度Luminance进行混合。float4 originalColor tex2D(_MainTex, i.uv); float luminance dot(originalColor.rgb, float3(0.299, 0.587, 0.114)); // 计算亮度 float4 finalColor lerp(float4(luminance, luminance, luminance, 1), thermalColor, _ThermalBlend);这里_ThermalBlend是一个混合系数0-1。为0时显示原始灰度图为1时显示纯热成像色。通常可以设置为0.7-0.9让热成像色占主导但保留灰度细节。对于检测到的边缘可以令finalColor edgeColor使其更加突出。实操心得边缘检测的计算开销不小特别是全屏每帧都算。一个优化技巧是降低采样精度先将_TemperatureTex缩放到一半分辨率在低分辨率下进行Sobel计算然后再上采样回屏幕分辨率。人眼对边缘的精确位置不敏感这样能节省大量计算。5. 核心环节三整合与RenderTexture应用现在我们有Shader了需要把它用起来并处理RenderTexture的流程。5.1 创建后处理脚本在Built-in渲染管线中我们创建一个C#脚本ThermalImagingEffect.cs将其挂载到需要显示热成像效果的相机上。using UnityEngine; [ExecuteInEditMode] [RequireComponent(typeof(Camera))] public class ThermalImagingEffect : MonoBehaviour { public Shader thermalShader; // 拖入我们写好的Shader private Material _material; public Texture2D colorRampTex; // 温度-颜色渐变纹理 public float noiseAmount 0.02f; public float edgeThreshold 0.1f; [Header(Temperature Source)] public Camera temperatureCamera; // 渲染温度信息的相机 private RenderTexture _temperatureRT; protected Material material { get { if (_material null) { if (thermalShader null || !thermalShader.isSupported) { enabled false; return null; } _material new Material(thermalShader); _material.hideFlags HideFlags.HideAndDontSave; } return _material; } } void OnEnable() { // 创建RenderTexture用于存储温度信息 if (temperatureCamera ! null) { _temperatureRT new RenderTexture(Screen.width, Screen.height, 16, RenderTextureFormat.R8); // 8位红色通道足够存储温度 _temperatureRT.Create(); temperatureCamera.targetTexture _temperatureRT; } } void OnDisable() { if (_material ! null) DestroyImmediate(_material); if (_temperatureRT ! null) { _temperatureRT.Release(); _temperatureRT null; } } // 每一帧渲染完成后调用 void OnRenderImage(RenderTexture source, RenderTexture destination) { if (material null || temperatureCamera null) { Graphics.Blit(source, destination); // 效果未准备好直接拷贝 return; } // 确保温度相机已渲染 if (!temperatureCamera.gameObject.activeInHierarchy) temperatureCamera.gameObject.SetActive(true); temperatureCamera.Render(); // 将参数传递给Shader material.SetTexture(_TemperatureTex, _temperatureRT); material.SetTexture(_GradientTex, colorRampTex); material.SetFloat(_NoiseAmount, noiseAmount); material.SetFloat(_EdgeThreshold, edgeThreshold); // 应用后处理Shader Graphics.Blit(source, destination, material); } }这个脚本做了几件事管理效果所需的Material。创建并管理用于存储温度信息的RenderTexture (_temperatureRT)。在OnRenderImage中驱动temperatureCamera渲染温度到_temperatureRT。将所有参数包括温度纹理_temperatureRT和渐变纹理colorRampTex设置给材质。调用Graphics.Blit执行最终的着色器渲染将结果输出到屏幕。5.2 实现画中画PIP热成像视角很多时候我们不需要整个屏幕都是热成像而是希望它像战术设备一样显示在屏幕的一个角落。这就需要用到多相机和RenderTexture的组合。场景设置Main Camera主相机渲染正常游戏画面到屏幕。Thermal Camera热成像专用相机。它的Culling Mask可能只包含需要热成像显示的物体层或者包含全部但会应用我们的后处理效果。关键一步将Thermal Camera的Target Texture设置为一张RenderTexture例如_ThermalViewRT。这样Thermal Camera渲染的内容就不会直接到屏幕而是到了_ThermalViewRT这张纹理上。UI显示在Unity的UI Canvas上创建一个RawImage组件。将RawImage的Texture属性设置为_ThermalViewRT。调整RawImage的位置和大小它就会实时显示热成像相机“看到”的内容。脚本联动确保Thermal Camera上挂载了我们的ThermalImagingEffect脚本。这样画中画显示的就是经过处理的热成像画面。性能考量画中画意味着每帧多渲染一个相机视角有额外的性能开销。务必控制好Thermal Camera的渲染分辨率通过_ThermalViewRT的尺寸控制和渲染范围通过Culling Mask和视锥体裁剪。一个256x256的小窗口其开销远小于全屏渲染。5.3 动态效果优化温度扩散模拟为了让效果更生动我们可以模拟温度的扩散。这需要在CPU端C#脚本或GPU端Compute Shader进行简单的物理模拟。一个简化的CPU端模拟思路适用于网格顶点数不多的物体将物体网格的每个顶点视为一个温度节点。每帧遍历所有顶点根据其与相邻顶点的温度差计算热传导。// 伪代码概念性展示 for each vertex v in mesh { float tempDiffSum 0; for each neighbor n of v { tempDiffSum (n.temperature - v.temperature); } v.nextTemperature v.temperature conductivity * (tempDiffSum / neighborCount) * Time.deltaTime; } // 交换当前温度和下一帧温度将更新后的顶点温度数据通过Mesh.vertices或ComputeBuffer传递给Shader。对于大规模模拟必须使用Compute Shader在GPU上并行计算否则CPU会不堪重负。这是进阶内容但思路是将温度数据存储在StructuredBuffer中Compute Shader每个线程处理一个数据点计算其与周围点的热交换速度极快。6. 常见问题、调试与性能优化6.1 效果不显示或显示错误问题屏幕全黑/全白或热成像颜色不对。排查检查Shader编译错误在Project窗口选中Shader查看Inspector底部是否有错误。确保#pragma指令正确包含必要的CGINCLUDE文件。检查温度数据源确保temperatureCamera正确渲染且_TemperatureRT被成功创建和赋值。可以临时将_TemperatureRT赋值给一个RawImage显示在UI上看看它是不是一张有效的灰度图。检查渐变纹理确保colorRampTex的Wrap Mode是ClampFilter Mode是Bilinear或Point。如果是Repeat模式当温度值为1时可能会采样到纹理开头导致颜色跳变。检查Shader参数传递在OnRenderImage里用Debug.Log打印material和_temperatureRT是否为空。确保所有SetTexture和SetFloat调用都执行了。6.2 性能瓶颈分析与优化热成像后处理是一个额外的全屏绘制调用其性能主要取决于Shader的复杂度和RenderTexture的处理。Profile定位使用Unity的Profiler窗口查看Camera.Render和Graphics.Blit的耗时。如果ThermalImagingEffect耗时很长问题就在Shader或RT操作上。Shader优化减少纹理采样合并采样次数。例如如果不需要单独的温度纹理可以尝试将温度信息编码到主纹理的Alpha通道或另一个颜色通道中前提是你不使用这些通道。简化数学计算用mad乘加指令优化避免不必要的pow、sin、cos。噪声函数可以用纹理查找代替。降低计算精度在移动平台将float改为half将fixed用于颜色计算。关闭不必要的特性在Shader的SubShader或Pass中设置ZTest Always、ZWrite Off、Cull Off因为后处理不需要深度测试和写入。RenderTexture优化降低分辨率这是最有效的优化。热成像效果通常不需要全分辨率。将_temperatureRT和热成像相机的Target Texture分辨率设置为屏幕的1/2或1/4能显著降低填充率和带宽消耗。在Shader中采样时注意纹理坐标的对应。选择合适的格式温度数据只需要一个通道使用RenderTextureFormat.R8或RHalf16位浮点比ARGB32节省大量内存带宽。复用RenderTexture避免在Update或OnRenderImage中频繁创建和销毁RT。在OnEnable中创建在OnDisable中释放。6.3 效果艺术性调整技巧增强真实感添加扫描线在Shader中加入随时间移动的横向细线模拟老式热像仪的CRT效果。float scanLine frac(i.uv.y * _ScreenParams.y _Time.y * _ScanSpeed);然后让scanLine很窄的区域颜色变亮或变暗。镜头畸变与噪点对UV进行轻微的桶形畸变并加上随时间变化的电视雪花噪点。温度单位与刻度在UI层在热成像画面边缘叠加一个模拟的温度刻度条从蓝到红到白并标注关键温度值。风格化处理不一定非要追求物理真实。你可以使用非真实的渐变条比如紫到黄或者将边缘检测的结果夸张化来创造一种赛博朋克或魔法视觉的风格。7. 从Built-in管线迁移到URP越来越多的项目使用URP通用渲染管线。在URP中OnRenderImage不再适用。我们需要通过创建ScriptableRendererFeature来插入后处理。创建RenderPass编写一个继承自ScriptableRenderPass的类如ThermalImagingPass。在Execute方法中使用CommandBuffer来调度我们的材质和Blit操作逻辑与之前类似。创建RendererFeature编写一个继承自ScriptableRendererFeature的类如ThermalImagingFeature。在Create方法中实例化你的ThermalImagingPass并配置其渲染时机例如在AfterRenderingOpaques之后。添加到URP Renderer在URP Asset配置的Renderer列表中找到你使用的Renderer为其添加这个ThermalImagingFeature。Shader调整URP的Shader语言是HLSL且包含文件不同如#include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl。你需要将我们的CGPROGRAM Shader转换为HLSL但核心的温度映射和边缘检测算法完全不变。迁移的主要工作量在于理解URP的渲染流程和API。一旦掌握了ScriptableRenderPass的编写模式你可以实现比Built-in管线更灵活、性能更好的后处理效果。整个流程走下来从温度数据的准备到Shader的逐像素计算再到通过RenderTexture进行合成与显示是一个完整的渲染效果实现闭环。它考验的不仅仅是对Shader语法的熟悉更是对Unity渲染管线、数据传递和性能优化的综合理解。我建议你先按照全屏后处理的方案实现一个基础版本看到热成像效果在屏幕上出现然后再逐步加入动态温度、画中画、风格化等高级特性。过程中最常遇到的坑无非是数据没传对、Shader写错了语法、或者RT没设置好耐心调试用好Frame Debugger和Profiler这两个利器问题都能迎刃而解。