Unity Custom Render Textures进阶:GPU驱动动态纹理与Shader实战
1. 项目概述为什么我们需要Custom Render Textures在Unity里做特效或者模拟比如流动的水面、熔岩、动态的云层甚至是复杂的物理模拟你是不是经常遇到一个头疼的问题如何让Shader的计算结果“记住”上一帧的状态并在此基础上继续计算传统的做法可能是用脚本把RenderTexture读回CPU处理完再传回去或者用多个RenderTexture来回“乒乓”交换。前者性能开销巨大后者管理起来又异常繁琐代码像一团乱麻。这就是Custom Render Textures后文简称CRT要解决的痛点。你可以把它理解为一个“自带更新逻辑的智能RenderTexture”。它不再是一个被动的数据容器而是一个能通过Shader驱动自身内容持续演化的动态纹理。官方手册说它是Render Texture的扩展这个描述太客气了。在我看来CRT是Unity提供给图形程序员和TA技术美术的一把“瑞士军刀”它把GPU驱动的数据流编程以一种相对优雅的方式引入了Unity的渲染管线。想象一下你要做一个雨滴落在水面泛起涟漪的效果。传统方式可能需要1一个RT存储当前高度图2另一个RT存储上一帧高度图用于计算速度3用脚本控制两个RT每帧交换。而用CRT你只需要创建一个CRT资产挂载一个计算涟漪的Shader Material设置更新频率为Realtime效果就出来了。所有的数据交换和迭代都在GPU上闭环完成CPU几乎零开销。这个标题里的“进阶渲染与Shader实战”点明了CRT的核心价值它不是一个简单的贴图功能而是一个渲染管线内的、可编程的模拟系统。它的“进阶”体现在对更新区域Update Zones、双缓冲Double Buffered、更新链Chaining等高级特性的支持上让你能构建出极其复杂和高效的GPU模拟。接下来我们就深入这个系统看看如何从零开始把它用到你的项目里。2. 核心概念与工作机制拆解在动手写代码之前必须吃透CRT的几个核心概念。这就像学开车你得先知道油门、刹车、方向盘是干嘛的。2.1 CRT的三大核心模块一个功能完整的CRT由三个部分紧密协作构成Custom Render Texture资产这是你在Project窗口中创建的.asset文件。它定义了纹理的“物理”属性尺寸、格式、Mipmap等和更新策略何时更新、如何初始化。更新材质Update Material这是CRT的“大脑”。你赋予CRT一个材质这个材质所绑定的Shader决定了CRT每一帧或按需像素如何变化。这个Shader是专门为CRT编写的。初始化源Initialization Source决定CRT在第一次更新前或者重置时里面装什么。可以是纯色、另一张纹理或者另一个初始化材质。2.2 更新模式Update Mode详解这是控制CRT“心跳”的关键参数在Inspector面板的Update部分。OnLoad仅在资产被加载时如场景启动时更新一次。适用于静态生成、无需运行时变化的纹理比如预先烘焙好的噪声图变体。Realtime最常用的模式。CRT会以固定的频率通过Period参数控制0表示每帧自动更新。这是实现动态模拟如流体、烟雾的基础。这里有个重要细节Realtime更新的执行时机是在Update()之后LateUpdate()之前确保你的脚本逻辑能影响本帧的CRT状态。OnDemand完全由脚本驱动。你需要调用CustomRenderTexture.Update()或CustomRenderTexture.Update(zoneIndex)来触发更新。这给了你最大的控制权可以基于游戏事件如角色踩到水面来触发局部或全局更新是性能优化的利器。2.3 双缓冲Double Buffered实现自引用的关键这是CRT最强大也最容易让人困惑的特性。勾选这个选项后CRT内部会维护两个纹理缓冲区我们姑且称之为Buffer A和Buffer B。它的工作流程是这样的假设当前帧CRT对外呈现即其他Shader采样到的是Buffer A的内容。当CRT需要更新时更新材质Update Material会把Buffer A作为输入纹理进行采样将计算结果写入Buffer B。更新完成后A和B交换。于是下一帧对外呈现的是刚计算好的Buffer B而Buffer A变成了上一帧的“旧数据”。为什么需要这个想象一下计算细胞自动机或者热扩散。每个新像素的颜色需要根据它周围像素上一帧的颜色来计算。如果没有双缓冲你在计算像素(x,y)时它左边的像素(x-1,y)可能已经被本帧的新值覆盖了导致计算结果完全错误。双缓冲保证了读取源和写入目标是分离的是进行任何依赖自身历史状态的迭代计算的前提。性能提示双缓冲涉及每帧的纹理拷贝对高分辨率纹理需谨慎使用。2.4 更新区域Update Zones精准控制的艺术默认情况下每次更新都会重绘整个CRT。但在很多情况下我们只需要更新一小块区域。比如子弹击中水面只需要在击中点周围更新涟漪角色脚踩雪地只需要更新脚下的脚印区域。Update Zones允许你定义多个矩形对于2D纹理或立方体对于3D纹理区域并分别指定中心点Center与大小Size定义区域的位置和范围。旋转Rotation定义区域的朝向。着色器通道Shader Pass可以为不同区域指定更新材质中的不同Pass实现多阶段处理。交换Swap仅对双缓冲CRT有效。可以在处理某个特定区域前强制交换缓冲区实现更复杂的更新逻辑。通过组合多个Update Zones并按顺序执行你可以构建出极其高效且灵活的更新策略。例如先处理所有“水滴溅落”的小区域Zone 1再执行一次全屏的“涟漪传播”计算Zone 2。3. 从零开始你的第一个CRT Shader理论说再多不如动手写一个。我们来创建一个最简单的CRT让它像一个简单的画板每帧在随机位置画一个点。3.1 创建资产与基础设置在Project窗口右键Create - Rendering - Custom Render Texture。命名为MyFirstCRT。在Inspector中设置尺寸为256x256Color Format选择ARGB32即可。勾选Double Buffered我们后续会用到自引用。在Initialization部分Source选择Texture and ColorInitialization Color设为黑色(0,0,0,1)。这样初始状态是全黑的画布。在Update部分Mode选择RealtimePeriod设为0每帧更新。3.2 编写核心更新Shader接下来是重头戏编写驱动这个CRT的Shader。在Project窗口创建新的Shader文件命名为CRT_Painter。Shader Custom/CRT_Painter { Properties { // 我们可以通过Material属性面板来调节画笔颜色 _PaintColor (Paint Color, Color) (1, 0, 0, 1) // 默认红色 // 这个纹理采样器是特殊的它会自动被CRT系统绑定到“上一帧的纹理”双缓冲时 _SelfTexture2D (Self Texture, 2D) white {} } SubShader { // 对于CRT更新我们通常不需要光照和混合 Lighting Off Blend Off Pass { CGPROGRAM // **关键1必须包含这个头文件** #include UnityCustomRenderTexture.cginc // **关键2使用CRT专用的顶点着色器** #pragma vertex CustomRenderTextureVertexShader #pragma fragment frag #pragma target 3.0 float4 _PaintColor; // 声明与Properties中同名的采样器系统会自动关联 sampler2D _SelfTexture2D; // **关键3使用CRT专用的片段着色器输入结构体** // v2f_customrendertexture 包含了当前像素在局部和全局纹理空间中的坐标等信息 float4 frag (v2f_customrendertexture i) : SV_Target { // 1. 首先采样“上一帧”整个纹理的状态来自_SelfTexture2D float4 previousColor tex2D(_SelfTexture2D, i.globalTexcoord.xy); // 2. 生成一个基于时间的随机位置模拟随机画点 // _Time.y是自场景加载以来的时间秒 // frac取小数部分确保值在[0,1)区间 float2 randomUV float2(frac(_Time.y * 0.5), frac(_Time.y * 0.3)); // 3. 计算当前处理的像素(i.globalTexcoord.xy)与随机点的距离 float dist distance(i.globalTexcoord.xy, randomUV); // 定义一个“笔刷”半径 float brushRadius 0.02; // 4. 混合逻辑如果当前像素在随机点附近则用画笔颜色覆盖否则保留上一帧的颜色 float paintFactor step(dist, brushRadius); // dist radius 则返回1否则返回0 float4 finalColor lerp(previousColor, _PaintColor, paintFactor); // 可选让颜色随时间缓慢变淡模拟衰减 finalColor.rgb * 0.995; return finalColor; } ENDCG } } }代码解析与注意事项#include UnityCustomRenderTexture.cginc这是CRT Shader的“入场券”提供了必需的函数和结构体。CustomRenderTextureVertexShader一个预写好的顶点着色器负责将CRT的更新区域或全屏正确映射到渲染网格上。你几乎永远不需要自己写这个。v2f_customrendertexture i这个结构体包含了localTexcoord在当前更新区域内的UV范围0-1和globalTexcoord在整个CRT纹理中的UV范围0-1。在大多数全屏更新的情况下我们使用globalTexcoord。_SelfTexture2D这是双缓冲CRT的灵魂。当CRT启用双缓冲后这个采样器会自动绑定到“上一帧”的纹理数据。通过采样它我们才能实现基于历史状态的迭代计算。注意这个变量名是固定的不能随意更改。我们的逻辑很简单每一帧在随机位置画一个红点同时让所有像素的颜色轻微衰减。由于启用了双缓冲我们采样_SelfTexture2D得到的是上一帧画完所有点并衰减后的结果从而实现了效果的累积。3.3 关联材质并运行基于刚才的Shader创建一个新的材质命名为Mat_CRT_Painter。将MyFirstCRT资产的Material属性拖拽赋值为我们刚创建的Mat_CRT_Painter。创建一个新的RawImageUI组件或一个使用Unlit/TextureShader的Quad将其Texture属性设置为MyFirstCRT。运行游戏。你应该能看到一个黑色的画布上不断有红色的点随机出现并慢慢淡去。恭喜你已经成功创建了一个自驱动、GPU加速的动态纹理系统。虽然效果简单但你已经掌握了CRT最核心的闭环Shader读取自身上一帧的状态计算新状态并写回。4. 实战进阶构建一个涟漪模拟系统现在我们来点更酷的模拟雨滴在水面激起的涟漪。这是一个经典的“反应-扩散”模拟的简化版非常适合用CRT的双缓冲特性来实现。4.1 设计模拟算法我们的模拟需要两个状态当前高度Height和速度Velocity。但为了简化并使用单个CRT我们可以将RG两个通道分别用来存储高度和速度。算法思路离散化版本如下速度更新新速度 旧速度 (周围平均高度 - 中心高度) * 扩散系数 - 旧速度 * 阻尼系数。高度更新新高度 旧高度 新速度。边界处理纹理边缘的速度和高度设为0模拟被约束的水面。4.2 实现涟漪模拟Shader创建新的Shader命名为CRT_Ripple。Shader Custom/CRT_Ripple { Properties { _Damping (Damping, Range(0.9, 0.999)) 0.99 // 阻尼控制涟漪消失速度 _Spread (Spread, Range(0.1, 0.5)) 0.25 // 扩散系数 _InputPos (Input Position, Vector) (0.5, 0.5, 0, 0) // 外部输入扰动位置UV坐标 _InputStrength (Input Strength, Float) 0.1 // 扰动强度 } SubShader { Lighting Off Blend Off Pass { CGPROGRAM #include UnityCustomRenderTexture.cginc #pragma vertex CustomRenderTextureVertexShader #pragma fragment frag #pragma target 3.0 float _Damping; float _Spread; float2 _InputPos; float _InputStrength; sampler2D _SelfTexture2D; // 双缓冲上一帧数据 (R:高度, G:速度) float4 frag (v2f_customrendertexture i) : SV_Target { // 纹理像素间隔一个Texel的大小 float2 texelSize float2(1.0 / _CustomRenderTextureWidth, 1.0 / _CustomRenderTextureHeight); // 采样上一帧自身纹理双缓冲的“旧”状态 float4 oldData tex2D(_SelfTexture2D, i.globalTexcoord.xy); float height oldData.r; float velocity oldData.g; // 采样周围四个邻居的高度用于计算拉普拉斯算子近似扩散 float heightLeft tex2D(_SelfTexture2D, i.globalTexcoord.xy float2(-texelSize.x, 0)).r; float heightRight tex2D(_SelfTexture2D, i.globalTexcoord.xy float2(texelSize.x, 0)).r; float heightUp tex2D(_SelfTexture2D, i.globalTexcoord.xy float2(0, texelSize.y)).r; float heightDown tex2D(_SelfTexture2D, i.globalTexcoord.xy float2(0, -texelSize.y)).r; // 计算周围平均高度与中心高度的差扩散力 float neighborAvg (heightLeft heightRight heightUp heightDown) * 0.25; float spreadForce (neighborAvg - height) * _Spread; // 更新速度旧速度 扩散力 - 阻尼 velocity velocity spreadForce; velocity * _Damping; // 更新高度旧高度 新速度 height velocity; // **外部输入扰动**如果当前像素在输入点附近增加高度 float distToInput distance(i.globalTexcoord.xy, _InputPos); float dropFactor 1.0 - smoothstep(0.0, 0.01, distToInput); // 在0.01半径内产生一个平滑的扰动 height dropFactor * _InputStrength; // **边界衰减**在纹理边缘强制让速度和高度归零 float2 border smoothstep(0.0, 0.05, i.globalTexcoord.xy) * (1 - smoothstep(0.95, 1.0, i.globalTexcoord.xy)); float borderMask border.x * border.y; height * borderMask; velocity * borderMask; // 输出R通道存高度G通道存速度 return float4(height, velocity, 0, 1); } ENDCG } } }4.3 创建可视化材质与交互脚本上面的CRT存储的是模拟数据高度和速度我们需要另一个Shader将其可视化比如将高度转换为颜色和法线。创建可视化Shader一个简单的Unlit Shader采样CRT的R通道高度将其映射到颜色比如蓝色深浅或用于扰动法线。创建交互脚本我们需要一个脚本来将鼠标点击或触摸位置屏幕坐标转换为CRT的UV坐标并传递给_InputPos属性。using UnityEngine; [RequireComponent(typeof(CustomRenderTexture))] public class RippleInteraction : MonoBehaviour { public Camera eventCamera; // 用于将屏幕坐标转换到世界坐标的相机 public Material rippleUpdateMat; // 引用CRT的更新材质 public string inputPosPropertyName _InputPos; // Shader中_InputPos属性的ID private CustomRenderTexture _crt; private int _inputPosId; void Start() { _crt GetComponentCustomRenderTexture(); _inputPosId Shader.PropertyToID(inputPosPropertyName); if (rippleUpdateMat null _crt.material ! null) { rippleUpdateMat _crt.material; } } void Update() { if (Input.GetMouseButton(0) rippleUpdateMat ! null eventCamera ! null) { Ray ray eventCamera.ScreenPointToRay(Input.mousePosition); RaycastHit hit; // 假设CRT被渲染在一个位于世界原点、缩放为1的Quad上 if (Physics.Raycast(ray, out hit)) { // hit.textureCoord 就是该点在Quad的UV坐标正好对应CRT的UV Vector2 uv hit.textureCoord; rippleUpdateMat.SetVector(_inputPosId, new Vector4(uv.x, uv.y, 0, 0)); // 如果CRT是OnDemand模式这里还需要触发更新 // _crt.Update(); } } // 每帧结束后清除输入位置避免持续扰动 // rippleUpdateMat.SetVector(_inputPosId, Vector4.zero); } }4.4 组装与效果优化创建新的CRT资产CRT_RippleSim尺寸512x512启用双缓冲初始化颜色为黑色0高度0速度。更新模式设为Realtime。创建材质Mat_RippleUpdate使用CRT_RippleShader并赋值给CRT的Material。创建一个用于显示结果的Quad赋予可视化材质该材质的纹理指向CRT_RippleSim。将RippleInteraction脚本挂载到CRT_RippleSim物体上并拖拽赋值eventCamera和rippleUpdateMat。运行游戏用鼠标点击屏幕上的Quad你应该能看到涟漪从点击处扩散开来。性能与效果优化点分辨率模拟精度越高分辨率越高性能消耗越大。对于全屏效果256x256往往就能有不错的效果且性能友好。更新频率不一定需要每帧更新。将CRT的Period设为0.033约30Hz或0.0167约60Hz可以在视觉差异不大的情况下节省性能。多级CRT链可以用一个低分辨率的CRT如128x128进行物理模拟计算然后用另一个高分辨率的CRT或普通Shader对低分辨率结果进行上采样和美化添加细节法线、高光等。这是非常实用的优化技巧。5. 高级特性与性能调优实战掌握了基础模拟后我们来探索CRT更高级的用法这些是构建复杂系统的关键。5.1 使用Update Zones进行局部更新在我们的涟漪例子中即使水面大部分区域是静止的我们仍然每帧更新整个纹理。这是极大的浪费。我们可以用Update Zones来优化。场景只有屏幕中央一个圆形区域内有动态涟漪周围是静止的。在CRT的Inspector中找到Update Zone List点击号添加一个区域。设置Center为(0.5, 0.5)Size为(0.3, 0.3)这定义了一个覆盖屏幕中心30%区域的更新区。将更新模式Update Mode改为OnDemand。修改交互脚本在检测到输入时只更新这个特定的Zone。// 在RippleInteraction脚本中添加 public int updateZoneIndex 0; // 对应Inspector中Update Zone列表的索引 void TriggerRippleAt(Vector2 uv) { if (_crt.updateMode CustomRenderTextureUpdateMode.OnDemand) { // 先设置材质参数如_InputPos rippleUpdateMat.SetVector(_inputPosId, new Vector4(uv.x, uv.y, 0, 0)); // 然后只更新特定的Zone _crt.Update(updateZoneIndex); // 更新索引为0的Zone // 如果需要更新多个Zone可以调用多次Update或使用Update(zoneCount, zonesArray) } }优势对于512x512的纹理只更新30% x 30%的区域相当于只计算了约9%的像素性能提升超过10倍。5.2 构建CRT更新链ChainingCRT的强大之处在于可以串联。你可以用CRT A的输出作为CRT B的输入形成多阶段的处理管线。实战案例GPU粒子系统CRT_A (Particle Position/ Velocity)存储每个粒子的位置RG通道和速度BA通道。更新Shader根据速度、力场如鼠标引力更新位置。CRT_B (Particle Data Processing)读取CRT_A的位置数据进行碰撞检测、生命周期管理等更复杂的计算。渲染阶段用一个普通的Shader读取CRT_B的最终位置数据通过DrawProcedural或Graphics.DrawMeshInstanced在屏幕上绘制粒子。设置方法创建CRT_A和CRT_B。在CRT_B的更新材质中将CRT_A作为一张普通的Texture2D类型的属性进行采样。在脚本中确保更新顺序CRT_A.Update();-CRT_B.Update();。因为CRT_B依赖CRT_A的最新结果。Unity的CRT系统在一定程度上能管理依赖但显式控制顺序更可靠。5.3 3D Custom Render Texture的应用CRT不仅限于2D纹理还支持3D纹理。这为体渲染Volume Rendering和3D模拟打开了大门。应用场景动态体积云、3D烟雾模拟、3D流体如熔岩灯效果。工作原理与2D类似但v2f_customrendertexture结构体中的globalTexcoord是一个float3z分量在[0,1]之间。你可以在Shader中采样3D噪声进行每个体素voxel的模拟计算。关键设置创建CRT时在Dimension中选择3D并设置DepthZ轴切片数量。在Shader中使用tex3D函数进行采样并使用内置变量_CustomRenderTextureDepth。// 在3D CRT的更新Shader中 float3 uvw i.globalTexcoord.xyz; float4 previousValue tex3D(_SelfTexture3D, uvw); // ... 进行3D模拟计算 ...性能警告3D纹理的像素数量是宽度 x 高度 x 深度数据量呈立方增长。一个128x128x128的3D纹理包含超过200万个体素务必使用低分辨率并谨慎考虑更新频率。6. 避坑指南与常见问题排查在实际项目中使用CRT我踩过不少坑。这里总结一下最常见的问题和解决方案。6.1 问题CRT一片漆黑或没有更新检查1材质和Shader是否正确关联。确保CRT资产的Material字段已经拖入了正确的材质并且该材质使用的Shader是专门为CRT编写的包含了#include UnityCustomRenderTexture.cginc。检查2更新模式。如果设为OnDemand但你从未在脚本中调用Update()方法它当然不会更新。如果设为Realtime检查Period是否设得过大。检查3双缓冲与_SelfTexture2D。如果你的Shader逻辑需要读取上一帧的数据大部分模拟都需要必须勾选Double Buffered并且在Shader中正确声明并采样sampler2D _SelfTexture2D对于3D是_SelfTexture3D。忘记勾选双缓冲是导致模拟无法迭代的常见原因。检查4Shader编译错误。在Console窗口查看是否有Shader编译错误。一个常见的错误是误用了tex2D采样_SelfTexture2D但CRT的格式可能是ARGBHalf或RFloat需要确保采样器类型sampler2D与纹理格式匹配。对于HDR格式可能需要使用tex2Dlod或声明为UNITY_DECLARE_TEX2D_HALF(_SelfTexture2D)。6.2 问题模拟效果“闪烁”或出现奇怪图案检查1初始状态。确保CRT的初始化颜色或纹理是正确的。例如一个流体模拟通常需要从全零平静状态开始。错误的初始化颜色如白色会导致第一帧计算就产生巨大差异。检查2数值稳定性。在Shader中进行物理模拟时数值可能溢出或变得不稳定。确保你的扩散系数、阻尼系数在合理范围内通常接近但小于1。可以加入clamp()函数将结果限制在安全范围内。检查3边界处理。模拟通常需要在纹理边缘进行特殊处理如设为固定值或镜像。忘记处理边界会导致边缘像素从纹理的另一侧Wrap Mode为Repeat时或错误颜色Clamp时采样数据从而产生传播进来的“垃圾”数据。参考4.2节中的borderMask做法。检查4竞争条件Race Condition。这是双缓冲模式下最隐蔽的Bug。确保你的Shader在写入新值时读取的是_SelfTexture2D上一帧的缓冲区而不是_MainTex或其他。在非双缓冲模式下绝对不能进行“读取-修改-写入”自身纹理的操作这会导致未定义行为。6.3 性能问题与优化策略瓶颈定位使用Unity Profiler的GPU模块查看RenderTexture.SetGlobal和DrawProcedural相关的耗时。CRT更新本身就是一个全屏或局部区域的绘制调用。优化策略1降低分辨率。这是最有效的优化。视觉上一个128x128的CRT经过模糊或上采样后在全屏显示时可能和512x512的差异并不明显。优化策略2降低更新频率。不是所有模拟都需要60FPS。将Period设置为0.0520Hz可能完全不影响视觉效果。优化策略3使用Update Zones。只更新发生变化的部分如6.1节所述。优化策略4简化Shader。减少纹理采样次数、使用更低精度的计算half代替float、避免复杂的分支判断。优化策略5避免每帧拷贝。对于双缓冲CRT如果Update Mode是Realtime且Period很小每帧的缓冲区交换会带来纹理拷贝开销。考虑是否可以改为OnDemand仅在数据真正变化时触发更新和交换。6.4 平台相关注意事项WebGL与移动端这些平台对RenderTexture的数量和尺寸更敏感。过多或过大的CRT可能导致内存不足或性能骤降。务必进行严格的目标平台测试。抗锯齿CRT本身不支持MSAA。如果最终渲染需要抗锯齿你需要将CRT的内容渲染到一个中间缓冲区然后再用后处理或通过相机进行抗锯齿。线性与伽马空间确保你的CRT颜色格式sRGB选项和Shader中的颜色计算与项目的颜色空间Gamma或Linear设置一致否则颜色会看起来不对。CRT是一个强大的工具它将GPU通用计算的能力以一种与Unity渲染管线深度集成的方式释放了出来。从简单的动态遮罩到复杂的物理模拟系统它的应用只受限于你的想象力。开始时可以从小的、确定性的效果入手如一个动态的腐蚀效果贴图逐步尝试更复杂的模拟。记住迭代和调试是学习过程的一部分多利用Frame Debugger和RenderDoc这类工具来观察每一帧CRT的实际状态是快速定位问题的好方法。