Unity红外热成像特效实现:从Shader到RenderTexture的全流程实战
2026/8/4 2:14:33 网站建设 项目流程

1. 项目概述:为什么要在Unity里做红外热成像?

最近在做一个模拟训练的项目,客户提了个挺有意思的需求:需要在虚拟的厂房环境里,模拟设备巡检时用热像仪看到的效果。说白了,就是得在Unity里实现一个逼真的红外热成像特效。这可不是简单贴个红色滤镜就能糊弄过去的,它涉及到对场景温度信息的模拟、基于物理的热辐射颜色映射,以及如何高效地将这套逻辑整合到实时渲染管线里。

红外热成像效果的核心,是将不可见的温度信息,转化为可见的颜色光谱。在现实中,热像仪捕捉的是物体表面发射的红外辐射强度,然后通过伪彩色(通常是铁红、彩虹、灰度等)呈现出来。在Unity中实现它,意味着我们需要自己定义一套“温度”数据,并编写Shader(着色器)来实时计算并渲染出对应的颜色。这不仅能用于严肃的工业模拟、安全培训,在游戏里也能营造出独特的科幻或战术视角,比如《使命召唤》系列里的热成像瞄准镜。

这个项目的挑战在于全流程的打通:从最底层的Shader编码,到如何将Shader效果应用到整个场景或特定物体,再到通过RenderTexture(渲染纹理)进行后期处理或画中画显示。我会结合我实际踩过的坑,把从思路到实现的每一步都拆开揉碎了讲,目标是让你看完就能动手复现一个基础版本,并理解其扩展可能性。

2. 核心思路与方案设计:模拟温度与渲染管线

2.1 效果拆解:我们到底要模拟什么?

一个合格的红外热成像模拟,需要包含以下几个视觉特征:

  1. 温度-颜色映射:这是根本。需要一个从低温到高温的连续颜色渐变条。通常低温显示为深蓝或黑色,中温是红色或黄色,高温是亮白或亮蓝(取决于配色方案)。
  2. 辐射衰减与混合:热辐射会随着距离衰减,并且高温物体会“照亮”周围的低温物体。在简化模型中,我们可以考虑根据深度或距离对温度颜色进行混合。
  3. 边缘强化与噪声:真实的热像仪图像并非绝对平滑,通常带有一定的噪声,并且高温区域的边缘比较锐利。加入适量的噪声和边缘检测,能极大提升真实感。
  4. 场景温度数据源:我们需要为场景中的物体赋予“温度”属性。最简单的方法是给物体贴一个自定义的“温度图”(一张灰度图,白色代表高温,黑色代表低温)。更动态的方法是通过脚本实时计算或修改温度值。

基于以上,我们的技术方案就清晰了:

  • 使用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中,我们通过创建ScriptableRendererFeatureScriptableRenderPass来实现类似功能。本文核心Shader逻辑完全通用,只是注入渲染管线的方式不同。为保持广泛适用性,我们先基于Built-in管线讲解。

3. 核心环节一:构建温度数据源

Shader需要知道每个像素点对应的温度。我们得先把这个数据准备好。

3.1 静态温度图:最简单直接的方案

最直观的方法是使用一张纹理来存储温度信息。我们可以利用模型的第二套UV或顶点颜色,但更通用的方法是使用一张单独的Temperature Map

  1. 制作温度图:在Photoshop等软件中,画一张与模型主纹理尺寸相同的灰度图。纯白(RGB: 1,1,1)代表最高温(例如100°C),纯黑(0,0,0)代表最低温(例如0°C)。你可以根据设备的发热部位(如发动机、散热口)来绘制白色区域。
  2. 在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 = GetComponent<Renderer>().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)中查找对应的颜色。

  1. 制作渐变纹理:创建一张细长的纹理(例如256x1),从左到右绘制你想要的温度颜色渐变。在Unity中,可以用代码生成,也可以在PS里画好导入。
  2. 在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[i+1][j+1]; edgeY += sample * gy[i+1][j+1]; } } 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); } }

这个脚本做了几件事:

  1. 管理效果所需的Material。
  2. 创建并管理用于存储温度信息的RenderTexture (_temperatureRT)
  3. OnRenderImage中,驱动temperatureCamera渲染温度到_temperatureRT
  4. 将所有参数(包括温度纹理_temperatureRT和渐变纹理colorRampTex)设置给材质。
  5. 调用Graphics.Blit执行最终的着色器渲染,将结果输出到屏幕。

5.2 实现画中画(PIP)热成像视角

很多时候,我们不需要整个屏幕都是热成像,而是希望它像战术设备一样,显示在屏幕的一个角落。这就需要用到多相机和RenderTexture的组合

  1. 场景设置

    • Main Camera:主相机,渲染正常游戏画面到屏幕。
    • Thermal Camera:热成像专用相机。它的Culling Mask可能只包含需要热成像显示的物体层,或者包含全部但会应用我们的后处理效果。关键一步:将Thermal CameraTarget Texture设置为一张RenderTexture(例如_ThermalViewRT)。
    • 这样,Thermal Camera渲染的内容就不会直接到屏幕,而是到了_ThermalViewRT这张纹理上。
  2. UI显示:在Unity的UI Canvas上,创建一个RawImage组件。将RawImageTexture属性设置为_ThermalViewRT。调整RawImage的位置和大小,它就会实时显示热成像相机“看到”的内容。

  3. 脚本联动:确保Thermal Camera上挂载了我们的ThermalImagingEffect脚本。这样,画中画显示的就是经过处理的热成像画面。

性能考量:画中画意味着每帧多渲染一个相机视角,有额外的性能开销。务必控制好Thermal Camera的渲染分辨率(通过_ThermalViewRT的尺寸控制)和渲染范围(通过Culling Mask和视锥体裁剪)。一个256x256的小窗口,其开销远小于全屏渲染。

5.3 动态效果优化:温度扩散模拟

为了让效果更生动,我们可以模拟温度的扩散。这需要在CPU端(C#脚本)或GPU端(Compute Shader)进行简单的物理模拟。

一个简化的CPU端模拟思路(适用于网格顶点数不多的物体):

  1. 将物体网格的每个顶点视为一个温度节点。
  2. 每帧,遍历所有顶点,根据其与相邻顶点的温度差,计算热传导。
// 伪代码,概念性展示 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; } // 交换当前温度和下一帧温度
  1. 将更新后的顶点温度数据,通过Mesh.verticesComputeBuffer传递给Shader。

对于大规模模拟,必须使用Compute Shader在GPU上并行计算,否则CPU会不堪重负。这是进阶内容,但思路是:将温度数据存储在StructuredBuffer中,Compute Shader每个线程处理一个数据点,计算其与周围点的热交换,速度极快。

6. 常见问题、调试与性能优化

6.1 效果不显示或显示错误

  • 问题:屏幕全黑/全白,或热成像颜色不对。
  • 排查
    1. 检查Shader编译错误:在Project窗口选中Shader,查看Inspector底部是否有错误。确保#pragma指令正确,包含必要的CGINCLUDE文件。
    2. 检查温度数据源:确保temperatureCamera正确渲染,且_TemperatureRT被成功创建和赋值。可以临时将_TemperatureRT赋值给一个RawImage显示在UI上,看看它是不是一张有效的灰度图。
    3. 检查渐变纹理:确保colorRampTex的Wrap Mode是Clamp,Filter Mode是BilinearPoint。如果是Repeat模式,当温度值为1时可能会采样到纹理开头,导致颜色跳变。
    4. 检查Shader参数传递:在OnRenderImage里用Debug.Log打印material_temperatureRT是否为空。确保所有SetTextureSetFloat调用都执行了。

6.2 性能瓶颈分析与优化

热成像后处理是一个额外的全屏绘制调用,其性能主要取决于Shader的复杂度和RenderTexture的处理。

  1. Profile定位:使用Unity的Profiler窗口,查看Camera.RenderGraphics.Blit的耗时。如果ThermalImagingEffect耗时很长,问题就在Shader或RT操作上。
  2. Shader优化
    • 减少纹理采样:合并采样次数。例如,如果不需要单独的温度纹理,可以尝试将温度信息编码到主纹理的Alpha通道或另一个颜色通道中(前提是你不使用这些通道)。
    • 简化数学计算:用mad(乘加)指令优化,避免不必要的powsincos。噪声函数可以用纹理查找代替。
    • 降低计算精度:在移动平台,将float改为half,将fixed用于颜色计算。
    • 关闭不必要的特性:在Shader的SubShaderPass中设置ZTest AlwaysZWrite OffCull Off,因为后处理不需要深度测试和写入。
  3. RenderTexture优化
    • 降低分辨率:这是最有效的优化。热成像效果通常不需要全分辨率。将_temperatureRT和热成像相机的Target Texture分辨率设置为屏幕的1/2或1/4,能显著降低填充率和带宽消耗。在Shader中采样时注意纹理坐标的对应。
    • 选择合适的格式:温度数据只需要一个通道,使用RenderTextureFormat.R8RHalf(16位浮点)比ARGB32节省大量内存带宽。
    • 复用RenderTexture:避免在UpdateOnRenderImage中频繁创建和销毁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来插入后处理。

  1. 创建RenderPass:编写一个继承自ScriptableRenderPass的类(如ThermalImagingPass)。在Execute方法中,使用CommandBuffer来调度我们的材质和Blit操作,逻辑与之前类似。
  2. 创建RendererFeature:编写一个继承自ScriptableRendererFeature的类(如ThermalImagingFeature)。在Create方法中实例化你的ThermalImagingPass,并配置其渲染时机(例如在AfterRenderingOpaques之后)。
  3. 添加到URP Renderer:在URP Asset配置的Renderer列表中找到你使用的Renderer,为其添加这个ThermalImagingFeature
  4. 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这两个利器,问题都能迎刃而解。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询