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采样颜色赋予表面那叫“体积纹理映射”还不是真正的体绘制。真正的体绘制能呈现出半透明的、内部有细节的体积感这需要光线步进。其核心思想模拟光线穿过体积的过程生成射线从摄像机出发向屏幕每个像素发射一条射线。步进采样射线进入体积我们的长方体后不再是在表面停住而是以小步长Step Size一步步向内部迈进。累积颜色与透明度在每一个步进点上用当前点的三维坐标采样Texture3D得到颜色和透明度Alpha。按照从前到后的顺序将当前采样点的颜色与之前累积的结果进行混合。提前终止当累积的透明度接近完全不透明如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中做两件事动态边界判断不再假设是一个[-0.5,0.5]的固定立方体。我们可以将长方体的半尺寸_HalfSize作为一个Vector3属性传入Shader。在射线步进时判断当前点currentPos的每个分量绝对值是否小于对应的_HalfSize。纹理坐标映射将物体空间坐标范围在[-_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的纹理会暴增至64MB256256256*4字节。对于实时应用64或128的分辨率通常是性能和质量的平衡点。记得在非编辑器运行时需要用Resources.Load或AssetBundle来加载保存好的.asset文件。3.2 方法二从切片图像导入适合美术资源如果你有一系列代表3D模型不同深度切片的2D图片例如医学CT扫描的DICOM序列Unity可以将其合成为Texture3D。准备切片图像将所有切片图像如PNG命名有序slice_001.png, slice_002.png...并确保尺寸一致。导入设置将任意一张切片图导入Unity在Inspector中将Texture Shape从2D改为3D。设置切片布局Unity会显示Columns和Rows选项。这代表将所有切片平铺在一张大图上的布局。例如你有64张切片可以设置为8列8行8x864。你需要根据切片总数来调整确保 Columns * Rows 总切片数。点击ApplyUnity会自动根据布局将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是绝佳选择。思路如下创建Unlit Graph因为体绘制需要自定义混合通常从空白的Unlit Graph开始。定义属性_VolumeTex(Texture 3D): 主纹理。_HalfSize(Vector3): 长方体的半长、半宽、半高。_StepSize(Float): 光线步进步长影响精度和性能。_MaxSteps(Int): 最大步进次数防止无限循环。_Density(Float): 整体密度/透明度乘数。构建节点网络顶点阶段获取物体的模型空间位置Position节点空间设为Object和朝向摄像机的方向。片元阶段 a.初始化设置起始点为模型空间位置方向为标准化后的视线方向需转换到物体空间。 b.循环模拟使用一个For Loop节点循环次数为_MaxSteps。在循环体内 i.边界判断使用Absolute和Compare节点判断当前点各分量是否小于_HalfSize。 ii.坐标映射将当前物体空间坐标范围[-_HalfSize, _HalfSize]通过Multiply和Add节点映射到[0, 1]。 iii.采样纹理使用Sample Texture 3D节点以上一步的坐标进行采样。 iv.颜色混合实现前向Alpha混合公式AccumulatedRGB (1 - AccumulatedA) * SampleA * SampleRGBAccumulatedA (1 - AccumulatedA) * SampleA。需要用到Split、Combine和Lerp节点。 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 { QueueTransparent RenderTypeTransparent IgnoreProjectorTrue } 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 性能优化策略降低采样分辨率纹理分辨率Texture3D的尺寸是内存和采样开销的立方级增长。在视觉可接受范围内尽量使用低分辨率如32^3, 64^3。屏幕分辨率可以考虑使用半分辨率或四分之一分辨率进行体绘制渲染然后将结果上采样。这能极大降低像素着色器的调用次数。优化步进策略自适应步长在数据密度低的区域Alpha值小使用大步长在密度高的区域使用小步长。这需要预先知道数据的分布实现稍复杂。空域跳过如果Texture3D中有大片的透明区域Alpha为0可以使用一种叫做“空域跳过”的技术。一种简单实现是使用一个低分辨率的“占用图”一个3D布尔纹理或距离场在步进前先快速判断前方一段距离内是否有数据如果没有就大跳一段。利用深度缓冲提前终止在渲染不透明物体之后、透明物体之前渲染体积。开启深度测试这样对于被不透明物体遮挡的体素射线会在起点就被提前终止节省大量计算。降级方案代理几何体对于非常复杂的体积可以将其“烘焙”成多个半透明的面片如等值面提取用传统的透明渲染来代替体绘制。这在很多游戏的角色技能特效中很常见。预积分对于沿视线方向颜色变化简单的体积如均匀的雾可以预计算查找表减少实时采样次数。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中确保rayDir是normalize()后的单位向量。性能极差帧率暴跌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语法与SRPURP/HDRP不兼容。或者渲染队列和混合状态设置不对。为URP/HDRP编写对应的Shader或使用Shader Graph。确保Tags中包含RenderPipelineUniversalPipeline或HighDefinitionPipeline并使用Blend命令。通常需要编写一个自定义的Renderer Feature来插入体积渲染通道。5.3 一个实用的调试技巧在开发体绘制Shader时最有效的调试方法是可视化中间变量。例如你可以临时修改片段着色器直接返回texCoord、currentPos或step计数作为颜色来观察采样坐标是否在[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的创建、体绘制的原理、可变几何体的适配、性能优化乃至更广阔的应用场景。这套流程的价值在于其范式性一旦你理解了如何用三维坐标去驱动一个三维数据场并利用光线步进将其可视化你就掌握了解决一大类体积渲染问题的通用方法。剩下的就是根据你的具体需求去填充那个三维数据场以及设计更精美的光照和着色模型了。

相关新闻