Unity体绘制实战:用Texture3D为可变长方体实现动态体积着色

1. 项目概述:为什么我们需要告别传统贴图?

在Unity里给一个长方体上色,听起来像是新手教程里的第一课——创建一个Cube,拖个材质球,贴张图,完事。但如果你做的不是静态的箱子,而是一个尺寸、比例、甚至形状都可能动态变化的“可变长方体”呢?比如一个根据数据实时伸缩的数据可视化柱体、一个可被玩家捏合变形的软体、或者一个需要内部结构渐变显示的医疗模型。这时,传统的那张2D贴图就立刻捉襟见肘了。

传统2D贴图是“裹”在模型表面的,它只有UV两个维度。当模型拉伸、挤压时,贴图要么跟着拉伸变形,纹理变得模糊或怪异;要么保持纹理密度,但会出现接缝错位、重复图案不自然的问题。更别提想要表现物体内部的颜色变化了,2D贴图根本无从下手。这就是我们项目标题里提到的“传统贴图限制”。

Texture3D,或者说3D纹理,就是打破这个限制的钥匙。你可以把它想象成一个微型的、有颜色的“果冻”。这个果冻本身就是一个三维数据块,每一个最小的颜色单元(体素,Voxel)都有其固定的三维坐标(X, Y, Z)。当我们渲染那个可变长方体时,不再是简单地用表面UV去采样一张平面图,而是用长方体内部每一个点的三维坐标,去这个“颜色果冻”里捞取对应的颜色值。这样,无论长方体怎么变,内部的颜色数据都是基于三维空间定义的,纹理再也不会因为模型变形而“撕裂”或“拉伸”,实现了真正意义上的体积着色。

体绘制(Volume Rendering)正是利用Texture3D这类体积数据,通过光线步进(Raymarching)等算法,将三维数据可视化的技术。它不仅能处理颜色,还能处理密度、温度、压力等任何标量场数据。我们这个项目,就是一次轻量级的体绘制实战:聚焦于使用Texture3D,为一个可变长方体赋予灵活、稳定且富有内部细节的色彩。

2. 核心原理:Texture3D与体绘制的基石

要玩转Texture3D,得先理解它和传统Texture2D的根本区别,以及体绘制是如何工作的。

2.1 Texture3D的本质:一个三维数组

Texture2D在内存中是宽度x高度的二维像素阵列。Texture3D则是宽度x高度x深度的三维体素阵列。每个体素存储一个颜色值(如RGBA)。在Shader中,我们使用tex3D函数,并传入一个三维向量(通常各分量在[0,1]范围)来采样它。

// 在Shader中声明一个3D纹理 sampler3D _VolumeTex; // 在片元着色器中采样,pos是一个三维向量,表示在纹理空间中的位置 float4 color = tex3D(_VolumeTex, pos);

这个pos的巧妙之处在于,它通常来自于被渲染物体的物体空间坐标。对于一个中心在原点、边长为1的立方体,其顶点坐标范围是[-0.5, 0.5]。我们只需将其偏移0.5,映射到[0,1]范围,即可作为3D纹理的采样坐标。

// 假设objectPos是物体空间中的位置(范围[-0.5, 0.5]) float3 texCoord = objectPos + 0.5; float4 voxelColor = tex3D(_MainTex, texCoord);

为什么这能解决可变长方体的问题?因为无论你在外部如何缩放、扭曲这个长方体模型,其内部任意一点的物体空间坐标与纹理空间的对应关系是固定的。缩放操作相当于改变了从物体空间到世界空间的变换矩阵,但物体空间本身的坐标和纹理采样坐标的映射没变。颜色数据被“固化”在三维空间里,而非模型表面。

2.2 体绘制的核心:光线步进算法

仅仅把Texture3D采样颜色赋予表面,那叫“体积纹理映射”,还不是真正的体绘制。真正的体绘制能呈现出半透明的、内部有细节的体积感,这需要光线步进

其核心思想模拟光线穿过体积的过程:

  1. 生成射线:从摄像机出发,向屏幕每个像素发射一条射线。
  2. 步进采样:射线进入体积(我们的长方体)后,不再是在表面停住,而是以小步长(Step Size)一步步向内部迈进。
  3. 累积颜色与透明度:在每一个步进点上,用当前点的三维坐标采样Texture3D,得到颜色和透明度(Alpha)。按照从前到后的顺序,将当前采样点的颜色与之前累积的结果进行混合。
  4. 提前终止:当累积的透明度接近完全不透明(如Alpha > 0.99),或者射线穿出体积边界时,停止步进,输出最终累积的颜色。

这个过程在片元着色器中通过一个循环实现。这也是为什么体绘制比较消耗性能的原因——一个像素可能需要进行数十甚至上百次纹理采样和颜色混合计算。

float4 Raymarch(float3 rayStart, float3 rayDir) { float4 finalColor = float4(0, 0, 0, 0); // 初始为完全透明 float3 currentPos = rayStart; for (int i = 0; i < _Steps; i++) { // 1. 判断是否在体积边界内 if (!InsideVolume(currentPos)) break; // 2. 计算纹理坐标并采样 float3 texCoord = WorldToTexCoord(currentPos); float4 sampleColor = tex3D(_VolumeTex, texCoord); sampleColor.a *= _Density; // 用密度系数调整透明度 // 3. 前向Alpha混合 finalColor.rgb += (1.0 - finalColor.a) * sampleColor.a * sampleColor.rgb; finalColor.a += (1.0 - finalColor.a) * sampleColor.a; // 4. 提前终止:如果已经几乎不透明 if (finalColor.a > 0.99) break; // 5. 向前步进 currentPos += rayDir * _StepSize; } return finalColor; }

2.3 可变长方体的实现思路

对于可变长方体,我们需要在Shader中做两件事:

  1. 动态边界判断:不再假设是一个[-0.5,0.5]的固定立方体。我们可以将长方体的半尺寸(_HalfSize)作为一个Vector3属性传入Shader。在射线步进时,判断当前点currentPos的每个分量绝对值是否小于对应的_HalfSize
  2. 纹理坐标映射:将物体空间坐标(范围在[-_HalfSize.x, _HalfSize.x]等)线性映射到[0,1]的纹理空间。这保证了无论长方体如何缩放,纹理都能“填满”整个体积。
// 动态边界判断 bool InsideBox(float3 pos, float3 halfSize) { return all(abs(pos) < halfSize); } // 动态纹理坐标映射 float3 WorldToTexCoord(float3 worldPos, float3 halfSize) { // 假设worldPos是物体空间下的位置 // 从 [-halfSize, halfSize] 映射到 [0, 1] return (worldPos / halfSize) * 0.5 + 0.5; }

这样,我们只需要在脚本中修改_HalfSize和模型变换,就能实时控制长方体的大小和比例,而其内部的Texture3D着色会自适应地、无缝地跟随变化。

3. 实战准备:创建与烘焙你的第一个Texture3D

理论懂了,动手才是关键。创建Texture3D有两种主流方式:从脚本程序化生成,或从切片图像序列导入。

3.1 方法一:脚本程序化生成(灵活,适合算法数据)

这是最灵活的方式,适合根据数学公式、噪声函数或模拟数据生成纹理。Unity提供了Texture3D类。

using UnityEngine; #if UNITY_EDITOR using UnityEditor; #endif public class Texture3DGenerator : MonoBehaviour { [MenuItem("Tools/Generate Gradient Texture3D")] static void CreateGradientTexture3D() { int size = 64; // 纹理分辨率,长宽高都是64 TextureFormat format = TextureFormat.RGBA32; // 格式,支持透明度 TextureWrapMode wrapMode = TextureWrapMode.Clamp; // 循环模式,Clamp防止边缘采样溢出 // 1. 创建Texture3D实例 Texture3D texture = new Texture3D(size, size, size, format, false); texture.wrapMode = wrapMode; texture.filterMode = FilterMode.Bilinear; // 双线性过滤,使过渡平滑 // 2. 准备颜色数组,大小为 size * size * size Color[] colors = new Color[size * size * size]; // 3. 填充颜色数据(这里创建一个三维渐变) float invSize = 1.0f / (size - 1); for (int z = 0; z < size; z++) { int zOffset = z * size * size; for (int y = 0; y < size; y++) { int yOffset = y * size; for (int x = 0; x < size; x++) { // R通道随X变化,G通道随Y变化,B通道随Z变化 float r = x * invSize; float g = y * invSize; float b = z * invSize; // 可以在这里加入更复杂的逻辑,比如Perlin噪声 // float noise = Mathf.PerlinNoise(x * 0.1f, y * 0.1f); // b = noise * z * invSize; colors[x + yOffset + zOffset] = new Color(r, g, b, 1.0f); } } } // 4. 应用颜色数据 texture.SetPixels(colors); texture.Apply(); // 将数据上传至GPU // 5. 保存为Asset #if UNITY_EDITOR string path = "Assets/GeneratedTexture3D.asset"; AssetDatabase.CreateAsset(texture, path); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); Debug.Log($"Texture3D saved to: {path}"); #endif } }

实操心得size的选择需要权衡。32x32x32的RGBA32纹理占用约128KB内存,而256^3的纹理会暴增至64MB(256256256*4字节)。对于实时应用,64或128的分辨率通常是性能和质量的平衡点。记得在非编辑器运行时,需要用Resources.LoadAssetBundle来加载保存好的.asset文件。

3.2 方法二:从切片图像导入(适合美术资源)

如果你有一系列代表3D模型不同深度切片的2D图片(例如,医学CT扫描的DICOM序列),Unity可以将其合成为Texture3D。

  1. 准备切片图像:将所有切片图像(如PNG)命名有序(slice_001.png, slice_002.png...),并确保尺寸一致。
  2. 导入设置:将任意一张切片图导入Unity,在Inspector中,将Texture Shape2D改为3D
  3. 设置切片布局:Unity会显示ColumnsRows选项。这代表将所有切片平铺在一张大图上的布局。例如,你有64张切片,可以设置为8列8行(8x8=64)。你需要根据切片总数来调整,确保 Columns * Rows >= 总切片数。
  4. 点击Apply:Unity会自动根据布局将2D切片序列“堆叠”成一个3D纹理。

注意事项:这种方式对图像序列的排列顺序和一致性要求极高。如果顺序错乱或尺寸不一,生成的3D纹理会是错误的。通常建议编写一个编辑器脚本,自动排序并设置参数,更为可靠。

3.3 在编辑器中预览Texture3D

生成或导入后,在Project窗口选中Texture3D资源,Inspector底部会提供三种预览模式:

  • 体积模式:显示为一个半透明的立方体,可以直观看到内部的颜色分布。
  • 切片模式:可以分别拖动X、Y、Z轴的滑块,查看该轴向上的横截面。
  • SDF模式:如果纹理数据表示的是有向距离场(SDF),会以此模式渲染。

善用这些预览工具,可以非常方便地调试你生成的Texture3D数据是否正确。

4. Shader编写:实现可变长方体的体绘制着色器

接下来是核心环节:编写一个Unity Shader Graph(或手写HLSL),实现支持可变长方体的体绘制。

4.1 Shader Graph实现方案(适用于URP/HDRP)

对于不习惯手写代码的开发者,Shader Graph是绝佳选择。思路如下:

  1. 创建Unlit Graph:因为体绘制需要自定义混合,通常从空白的Unlit Graph开始。
  2. 定义属性
    • _VolumeTex(Texture 3D): 主纹理。
    • _HalfSize(Vector3): 长方体的半长、半宽、半高。
    • _StepSize(Float): 光线步进步长,影响精度和性能。
    • _MaxSteps(Int): 最大步进次数,防止无限循环。
    • _Density(Float): 整体密度/透明度乘数。
  3. 构建节点网络
    • 顶点阶段:获取物体的模型空间位置(Position节点,空间设为Object)和朝向摄像机的方向。
    • 片元阶段: a.初始化:设置起始点为模型空间位置,方向为标准化后的视线方向(需转换到物体空间)。 b.循环模拟:使用一个For Loop节点,循环次数为_MaxSteps。在循环体内: i.边界判断:使用AbsoluteCompare节点,判断当前点各分量是否小于_HalfSize。 ii.坐标映射:将当前物体空间坐标(范围[-_HalfSize, _HalfSize])通过MultiplyAdd节点映射到[0, 1]。 iii.采样纹理:使用Sample Texture 3D节点,以上一步的坐标进行采样。 iv.颜色混合:实现前向Alpha混合公式:AccumulatedRGB += (1 - AccumulatedA) * SampleA * SampleRGBAccumulatedA += (1 - AccumulatedA) * SampleA。需要用到SplitCombineLerp节点。 v.步进CurrentPos += RayDir * _StepSize。 vi.提前终止:判断AccumulatedA是否大于阈值(如0.99),如果是,则Break循环。 c.输出:将循环结束后的AccumulatedColor连接到Fragment输出的Color

踩坑记录:Shader Graph的For Loop节点在2021.3之前的版本可能不稳定,且循环内节点过多会导致编译错误或性能低下。对于复杂体绘制,手写HLSL代码通常更可控、性能更好。

4.2 手写HLSL代码实现(更高性能与灵活性)

这里提供一个基于Built-in RP的简化版Shader代码框架,它清晰地展示了所有关键步骤。

Shader "Custom/VolumeRenderingBox" { Properties { _VolumeTex ("3D Texture", 3D) = "white" {} _HalfSize ("Box Half Size", Vector) = (0.5, 0.5, 0.5) _StepSize ("Ray Step Size", Range(0.001, 0.05)) = 0.01 _MaxSteps ("Max Steps", Int) = 128 _Density ("Density", Float) = 1.0 _Color ("Tint Color", Color) = (1,1,1,1) } SubShader { Tags { "Queue"="Transparent" "RenderType"="Transparent" "IgnoreProjector"="True" } Blend SrcAlpha OneMinusSrcAlpha // 标准透明混合 Cull Off // 关闭剔除,因为射线可能从内部开始 ZWrite Off // 关闭深度写入,透明物体排序复杂,通常用Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; }; struct v2f { float4 pos : SV_POSITION; float3 objectViewDir : TEXCOORD0; // 视线方向(物体空间) float3 objectPos : TEXCOORD1; // 射线起点(物体空间) }; sampler3D _VolumeTex; float3 _HalfSize; float _StepSize; int _MaxSteps; float _Density; float4 _Color; v2f vert (appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); // 顶点在物体空间的位置,作为射线起点 o.objectPos = v.vertex.xyz; // 计算视线方向(世界空间->物体空间) float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; float3 worldViewDir = normalize(worldPos - _WorldSpaceCameraPos); o.objectViewDir = normalize(mul((float3x3)unity_WorldToObject, worldViewDir)); return o; } // 前向Alpha混合函数 float4 blendUnder(float4 dst, float4 src) { float4 result; result.rgb = dst.rgb + (1.0 - dst.a) * src.a * src.rgb; result.a = dst.a + (1.0 - dst.a) * src.a; return result; } fixed4 frag (v2f i) : SV_Target { float3 rayOrigin = i.objectPos; float3 rayDir = normalize(i.objectViewDir); float4 finalColor = float4(0, 0, 0, 0); float3 currentPos = rayOrigin; // 主步进循环 for (int step = 0; step < _MaxSteps; step++) { // 1. 动态边界检测 float3 absPos = abs(currentPos); if (absPos.x > _HalfSize.x || absPos.y > _HalfSize.y || absPos.z > _HalfSize.z) { // 如果射线起点在盒子外,且第一次检测就出去了,直接开始步进 // 如果步进过程中出去了,直接跳出循环 if (step > 0) break; // 如果起点在盒子外,则计算射线与盒子的交点作为新的起点 // 这里简化处理:如果起点在外且方向朝外,直接返回透明 float3 invDir = 1.0 / (rayDir + 1e-6); // 防止除零 float3 t1 = (-_HalfSize - rayOrigin) * invDir; float3 t2 = (_HalfSize - rayOrigin) * invDir; float3 tmin = min(t1, t2); float3 tmax = max(t1, t2); float tnear = max(max(tmin.x, tmin.y), tmin.z); float tfar = min(min(tmax.x, tmax.y), tmax.z); if (tnear > tfar || tfar < 0.0) break; // 无交点 rayOrigin = rayOrigin + rayDir * tnear; // 将起点移至交点 currentPos = rayOrigin; continue; } // 2. 将物体空间坐标映射到纹理空间 [0,1] float3 texCoord = (currentPos / _HalfSize) * 0.5 + 0.5; // 3. 采样3D纹理 float4 sampledColor = tex3D(_VolumeTex, texCoord); sampledColor *= _Color; // 应用色调 sampledColor.a *= _Density; // 应用密度 // 4. 混合颜色 finalColor = blendUnder(finalColor, sampledColor); // 5. 提前终止:接近不透明 if (finalColor.a > 0.99) break; // 6. 向前步进 currentPos += rayDir * _StepSize; } return finalColor; } ENDCG } } FallBack "Transparent/VertexLit" }

关键点解析

  • 动态边界 (_HalfSize):我们在片段着色器中用_HalfSize代替固定的0.5,实现了对任意尺寸长方体的支持。
  • 射线与盒子求交:在for循环开始前,我们添加了一段简化的射线-包围盒求交代码。这确保了即使摄像机在盒子外部,也能正确渲染。这是实现“可变”且“从任意角度观看”的关键。
  • 性能控制_StepSize_MaxSteps是性能与质量的杠杆。步长越小、步数越多,质量越高,消耗也越大。需要根据目标平台和盒子大小进行调优。

5. 性能优化与常见问题排查

体绘制是出了名的性能杀手。在移动端或需要渲染大量体积物体时,优化至关重要。

5.1 性能优化策略

  1. 降低采样分辨率
    • 纹理分辨率:Texture3D的尺寸是内存和采样开销的立方级增长。在视觉可接受范围内,尽量使用低分辨率(如32^3, 64^3)。
    • 屏幕分辨率:可以考虑使用半分辨率或四分之一分辨率进行体绘制渲染,然后将结果上采样。这能极大降低像素着色器的调用次数。
  2. 优化步进策略
    • 自适应步长:在数据密度低的区域(Alpha值小)使用大步长,在密度高的区域使用小步长。这需要预先知道数据的分布,实现稍复杂。
    • 空域跳过:如果Texture3D中有大片的透明区域(Alpha为0),可以使用一种叫做“空域跳过”的技术。一种简单实现是使用一个低分辨率的“占用图”(一个3D布尔纹理或距离场),在步进前先快速判断前方一段距离内是否有数据,如果没有就大跳一段。
  3. 利用深度缓冲提前终止
    • 在渲染不透明物体之后、透明物体之前渲染体积。开启深度测试,这样对于被不透明物体遮挡的体素,射线会在起点就被提前终止,节省大量计算。
  4. 降级方案
    • 代理几何体:对于非常复杂的体积,可以将其“烘焙”成多个半透明的面片(如等值面提取),用传统的透明渲染来代替体绘制。这在很多游戏的角色技能特效中很常见。
    • 预积分:对于沿视线方向颜色变化简单的体积(如均匀的雾),可以预计算查找表,减少实时采样次数。

5.2 常见问题与解决方案速查表

问题现象可能原因解决方案
渲染全黑或全透明1. Texture3D未正确赋值给Shader。
2. 射线步进起点/方向计算错误,导致从未进入体积。
3. Texture3D数据本身全黑或全透明。
1. 检查Material是否引用了正确的Texture3D资源。
2. 在Shader中输出调试颜色(如将起点位置作为颜色),检查计算逻辑。确保_HalfSize设置正确。
3. 在Inspector中预览Texture3D,检查数据。
渲染结果有块状或条纹瑕疵1. Texture3D分辨率过低,过滤模式不当。
2. 步长(_StepSize)太大,导致采样不足,出现“欠采样”条纹。
3. 射线方向未归一化,导致步进距离不一致。
1. 提高Texture3D分辨率,或将Filter Mode设为Trilinear(三线性过滤)。
2. 减小_StepSize,增加_MaxSteps。注意性能平衡。
3. 在Shader中确保rayDirnormalize()后的单位向量。
性能极差,帧率暴跌1._MaxSteps设置过高(如512)。
2. Texture3D尺寸过大(如256^3)。
3. 在片段着色器中进行了过于复杂的每步计算。
1. 从64或128开始尝试,逐步增加直到质量可接受。
2. 尝试64^3或更小的纹理。考虑使用压缩格式如BC7。
3. 简化循环内计算,将不变的计算移到循环外。考虑使用上面提到的优化策略。
颜色混合错误,看起来“发白”或顺序不对Alpha混合公式错误。标准的“前向Alpha混合”是dst.rgb += (1-dst.a) * src.a * src.rgb。顺序必须是从前往后严格检查混合代码。确保在步进循环中,是从射线前端(离相机近)开始采样并向后累积。
长方体变形时,纹理“滑动”或错位纹理坐标映射公式未考虑动态的_HalfSize。仍然使用了固定的+0.5偏移。确保纹理坐标映射是(currentPos / _HalfSize) * 0.5 + 0.5,这样能保证纹理始终自适应地填充整个动态长方体空间。
在URP/HDRP中无效Built-in RP的Shader语法与SRP(URP/HDRP)不兼容。或者渲染队列和混合状态设置不对。为URP/HDRP编写对应的Shader或使用Shader Graph。确保Tags中包含"RenderPipeline"="UniversalPipeline"(或"HighDefinitionPipeline"),并使用Blend命令。通常需要编写一个自定义的Renderer Feature来插入体积渲染通道。

5.3 一个实用的调试技巧

在开发体绘制Shader时,最有效的调试方法是可视化中间变量。例如,你可以临时修改片段着色器,直接返回texCoordcurrentPosstep计数作为颜色,来观察采样坐标是否在[0,1]范围内、射线是否在正确步进。

// 调试:将纹理坐标的X分量作为红色输出 return float4(texCoord.x, 0, 0, 1); // 调试:将步进次数可视化(越白表示步进越多) return float4(step / (float)_MaxSteps, step / (float)_MaxSteps, step / (float)_MaxSteps, 1);

通过这种方式,你可以像用X光一样透视你的渲染逻辑,快速定位问题是出在坐标映射、边界判断还是步进循环上。

6. 扩展应用:不止于上色

掌握了给可变长方体贴上Texture3D颜色的基础后,这个技术的想象力可以大大扩展。它本质上是一种在三维空间内定义和查询数据的能力。

  • 数据可视化:将科学计算、仿真模拟(如流体、应力场、温度场)的结果存入Texture3D的R、G、B、A通道,然后用体绘制呈现出来。_HalfSize可以对应实际数据的物理尺寸。
  • 动态侵蚀效果:用一个Texture3D来存储物体的“健康值”或“腐蚀程度”。在Shader中采样这个值来决定片元的颜色或是否被裁剪。通过脚本动态修改Texture3D的数据,就能实现物体被逐渐腐蚀、融化的动态效果。
  • 软体与变形体的内部着色:结合顶点着色器或几何着色器对模型进行变形,同时使用其原始的或变形后的物体空间坐标去采样Texture3D,可以实现随着物体扭曲,内部颜色结构也相应变化的效果,比如一个被拧毛巾的内部条纹。
  • 简易云朵与烟雾:将3D噪声图写入Texture3D,配合适当的混合和光照模型(如Henyey-Greenstein相位函数),可以生成动态的体积云或烟雾效果。通过改变_HalfSize和模型变换,可以轻松控制云团的大小和形状。

实现这些扩展的关键在于,你不仅把Texture3D当作颜色查询表,更把它当作一个三维数据场。颜色(RGBA)只是数据的可视化表现形式。你可以用R通道存储密度,G通道存储温度,然后在Shader里根据这些数据,用复杂的转换函数计算出最终显示的颜色。这打开了实时体积渲染和科学可视化的一扇大门。

从“给长方体上色”这个简单需求出发,我们深入到了Texture3D的创建、体绘制的原理、可变几何体的适配、性能优化乃至更广阔的应用场景。这套流程的价值在于其范式性:一旦你理解了如何用三维坐标去驱动一个三维数据场,并利用光线步进将其可视化,你就掌握了解决一大类体积渲染问题的通用方法。剩下的,就是根据你的具体需求,去填充那个三维数据场,以及设计更精美的光照和着色模型了。