1. 为什么体绘制不是“把模型画得更厚一点”——从一个被反复误解的开场说起
很多人第一次在Unity里听说“体绘制”(Volume Rendering),下意识反应是:“哦,就是给模型加个体积感?用个厚涂Shader或者SSS模拟一下皮肤透光?”——我当年也是这么想的,直到在某跨平台系统开发中,需要实时渲染一组CT扫描数据生成的肺部结构,才被现实狠狠教育了一回。体绘制根本不是“加厚”,而是在三维空间中对连续介质进行采样、着色与合成的数学过程。它处理的对象不是三角面片,而是由体素(Voxel)构成的三维标量场或矢量场,比如医学影像里的密度值、气象模拟中的温度分布、流体仿真中的速度向量。Unity默认的前向/延迟渲染管线压根不提供原生体素访问能力,所有体绘制逻辑必须手动写进Shader里,从Ray Marching步进、采样纹理、计算光照到Alpha混合,每一步都绕不开GPU并行计算的本质约束。
这个标题里的“初探”,绝不是指“写个简单颜色渐变就完事”。它意味着你要直面三个硬核门槛:第一,理解光线如何在三维体数据中穿行并被吸收/散射(即体渲染方程的离散化实现);第二,把原本存储在CPU内存或磁盘上的体数据(通常是RAW、NRRD或DICOM格式)高效加载进GPU纹理,且不能爆显存;第三,在每帧毫秒级预算内完成数百次甚至上千次的体素采样与插值运算,还要兼顾移动端GPU的寄存器压力和分支预测惩罚。关键词里虽然空着,但实际项目中必然绕不开Ray Marching步长控制、三线性插值(Trilinear Interpolation)、体数据纹理格式(3D Texture vs Texture3DArray)、Alpha混合顺序(Front-to-Back vs Back-to-Front)这四个核心锚点。它们不是可选项,而是决定你最终画面是“能跑起来”还是“能稳定输出临床级精度”的分水岭。我见过太多人卡在第一步:用固定步长做Ray Marching,结果靠近摄像机的结构锯齿严重,远处又糊成一片,最后归咎于“Unity不支持体绘制”,其实只是没理解采样密度与视角深度之间的非线性关系。
提示:体绘制Shader不是美术Shader的延伸,它是科学可视化与实时图形学的交叉地带。它的输入不是贴图坐标,而是世界空间中的射线起点与方向;它的输出不是像素颜色,而是沿射线积分后的光学厚度与辐射亮度。如果你习惯用“调参数出效果”的思路来写它,大概率会在第5次修改_AlphaCutoff后意识到——问题不在参数,而在积分步数与体素分辨率的根本矛盾。
2. Ray Marching不是“沿着光线走几步”,而是构建三维采样网格的精密工程
很多初学者把Ray Marching想象成“从摄像机出发,每隔一段距离采样一次体数据”,这就像把造桥说成“往河里扔几块石头”。真正的问题在于:步长(Step Size)如何随深度动态变化?采样点如何精准落在体素中心?当射线斜穿体数据时,如何避免因步长过大导致的体素跳跃(Voxel Skipping)?这些问题的答案,直接决定了你渲染出的血管分支是否连贯、肿瘤边缘是否锐利、金属内部气孔是否可分辨。
我们先看最基础的固定步长实现:
float3 rayOrigin = mul(unity_WorldToObject, float4(worldPos, 1)).xyz; float3 rayDir = normalize(mul(unity_WorldToObject, float4(viewDir, 0)).xyz); float t = 0; float4 finalColor = float4(0, 0, 0, 0); for (int i = 0; i < _MaxSteps; i++) { float3 samplePos = rayOrigin + rayDir * t; // 采样体数据纹理 float density = tex3D(_VolumeTex, samplePos).r; // 简单着色:密度越高越白 float3 color = lerp(float3(0.1, 0.1, 0.2), float3(1, 1, 1), density); // Alpha混合:经典Front-to-Back finalColor.rgb += (1 - finalColor.a) * color * density * _StepSize; finalColor.a += (1 - finalColor.a) * density * _StepSize; t += _StepSize; }这段代码看似简洁,实则埋了三颗雷。第一颗雷在t += _StepSize:当体数据分辨率为256³,而视锥体深度达10米时,固定步长0.02米会导致近处采样过密(相邻采样点间距远小于体素尺寸),远处采样过疏(一步跨过多个体素)。我实测过,某模拟项目X中,固定步长0.01在近处产生明显波纹噪声,而步长0.05在远处直接丢失直径小于0.3mm的毛细血管。解决方案是采用自适应步长(Adaptive Step Size),其核心思想是:步长应与当前采样点到体数据边界的距离成正比。具体实现上,我们预先计算体数据在世界空间中的包围盒(AABB),然后在每次迭代中调用distance(samplePos, aabbCenter)获取到中心距离,再映射为步长:
// 预计算体数据AABB的世界空间参数 float3 volumeMin = _VolumeBounds.xyz; float3 volumeMax = _VolumeBounds.wyz; float3 aabbCenter = (volumeMin + volumeMax) * 0.5; float maxDistance = length(volumeMax - aabbCenter); float distanceToCenter = length(samplePos - aabbCenter); float adaptiveStep = _BaseStep * (1.0 + distanceToCenter / maxDistance); t += adaptiveStep;第二颗雷在tex3D(_VolumeTex, samplePos):Unity的Texture3D默认使用最近邻插值(Point Filter),这意味着当samplePos落在两个体素之间时,只会取最近的那个体素值,造成严重的块状伪影。医学影像分析要求亚体素精度,必须启用三线性插值(Trilinear Interpolation)。但Unity的ShaderLab不直接暴露纹理采样滤波器开关,需在C#脚本中设置:
volumeTexture.filterMode = FilterMode.Bilinear; // 注意:Bilinear在3D纹理中实际启用了Trilinear volumeTexture.wrapMode = TextureWrapMode.Clamp;第三颗雷在循环次数_MaxSteps:它不能是一个拍脑袋的数字。真实项目中,我们通过体数据分辨率与视锥体尺寸反推理论最大步数。公式为:MaxSteps = ceil( (maxDepthInWorldUnits) / (minVoxelSizeInWorldUnits) )
其中minVoxelSizeInWorldUnits=(worldSizeX / volumeResX),假设体数据在X轴长度为2米,分辨率为256,则最小体素尺寸为0.0078米。若视锥体最远平面为10米,则理论最大步数为ceil(10 / 0.0078) ≈ 1282。但GPU Shader循环次数受硬件限制(如移动端常限512次),因此必须引入早期退出(Early Exit)机制:当累积Alpha值超过0.99,或当前采样点已超出体数据AABB,立即跳出循环。我在某高校实验室的CT可视化Demo中,将_MaxSteps设为512,配合早期退出,实测98%的像素在200步内完成合成,帧率从18FPS提升至42FPS。
注意:Ray Marching的步数不是越多越好。过多步数会显著增加GPU寄存器压力,尤其在Adreno GPU上,分支预测失败率飙升。我的经验是:先用固定步长调试着色逻辑,再切换自适应步长,最后用
#pragma target 3.5确保支持动态分支,并在Shader中添加[loop]属性显式提示编译器优化循环。
3. 体数据纹理不是“把图片拖进去就行”,而是显存与精度的平衡术
Unity里创建一个Texture3D对象,表面看只是new Texture3D(resX, resY, resZ, TextureFormat.R8, false)一行代码,但背后涉及CPU-GPU内存拷贝、纹理压缩、Mipmap生成、显存对齐等一整套底层机制。我曾在一个工业检测项目中,因忽略纹理格式选择,导致256³的R8格式体数据在iOS设备上触发显存溢出,App直接闪退。问题根源在于:R8格式虽节省带宽,但单通道8位精度无法表达CT值(Hounsfield Unit)的-1024到+3071范围,强制截断后所有骨骼结构变成纯白,软组织细节全失。
体数据纹理的核心矛盾在于:高精度(如R16、R32F)保障科学准确性,低带宽(R8、RG8)保证实时性能。我们通过一张对比表厘清选型逻辑:
| 纹理格式 | 精度范围 | 显存占用(256³) | 兼容性 | 适用场景 |
|---|---|---|---|---|
TextureFormat.R8 | 0-255 | 16MB | 全平台 | 低保真预览、草图阶段 |
TextureFormat.R16 | 0-65535 | 32MB | PC/Android高端 | 工业CT、中等精度医学影像 |
TextureFormat.R32F | ±3.4×10³⁸ | 64MB | PC/部分高端移动GPU | 高精度流体仿真、科研级可视化 |
TextureFormat.RGBA32 | 4×32位浮点 | 256MB | 仅PC | 多通道体数据(如速度矢量场) |
关键发现是:R16格式是绝大多数项目的甜点选择。它用32MB显存换来了65535级灰度,足以覆盖CT值全范围(经线性映射后),且在Adreno 640、Mali-G77及Apple A13以上芯片均原生支持。但R16有个隐藏陷阱:Unity默认将R16纹理解释为无符号整数(UNORM),而CT值含负数。解决方案是在C#端加载时做偏移映射:
// 将CT值[-1024, 3071]映射到[0, 65535] float[] rawValues = LoadRawData(); // 原始CT数组 ushort[] mappedValues = new ushort[rawValues.Length]; for (int i = 0; i < rawValues.Length; i++) { float normalized = (rawValues[i] + 1024f) / (3071f + 1024f); // 归一化到[0,1] mappedValues[i] = (ushort)(normalized * 65535f); } volumeTexture.SetPixelData(mappedValues, 0);更致命的是纹理尺寸对齐问题。OpenGL ES(iOS/Android)要求3D纹理尺寸必须是2的幂次方,且各轴尺寸需严格相等(如128³、256³)。但真实CT数据常为512×512×120,直接填充会浪费大量显存。我们的做法是:用Texture3DArray替代单张Texture3D。将Z轴切片按每16层一组,打包成Texture3DArray,每层为512×512的2D纹理。这样既保持各层分辨率,又规避了非2的幂次方限制。Shader中采样代码变为:
// Texture3DArray采样:layerIndex为Z轴索引除以每组层数 float density = tex3Dlod(_VolumeArray, float4(samplePos.xy, layerIndex, 0)).r;实测表明,Texture3DArray在iOS Metal后端下,显存占用比填充至256³的单张Texture3D降低63%,且采样性能无损。某导师指导的脑部MRI可视化项目,正是靠此方案将200GB原始数据压缩至可交互的1.2GB体数据包。
提示:体数据纹理加载完成后,务必调用
volumeTexture.Apply()强制提交到GPU。我踩过的坑是:在协程中异步加载后忘记Apply,导致Shader始终读取到未初始化的黑色纹理,调试三天才发现是这一行缺失。
4. 从“能显示”到“能诊断”的质变:光照模型与传递函数的实战抉择
当体绘制Shader能稳定输出灰度图像后,下一个分水岭是:你渲染出的画面,是供程序员确认流程的“能看”,还是供医生判断病灶的“能诊”?这取决于光照模型与传递函数(Transfer Function)的设计。很多教程止步于“密度越高越亮”,但这在真实场景中完全失效——肺部CT中,空气(HU≈-1000)与脂肪(HU≈-100)密度差900,但视觉上都是暗区;而钙化斑(HU≈1000)与骨骼(HU≈2000)密度差1000,却都呈现高亮。人眼感知的是相对对比度,而非绝对密度值。
传递函数的本质,是建立体素标量值(Scalar Value)到RGBA颜色的非线性映射。最简方案是分段线性函数(Piecewise Linear TF),用几个控制点定义映射曲线。例如肺部CT常用配置:
| HU值区间 | Alpha值 | RGB颜色 | 作用 |
|---|---|---|---|
| [-1000, -900] | 0.0 | (0,0,0) | 空气,完全透明 |
| [-900, -500] | 0.1→0.3 | (0.2,0.2,0.3) | 支气管,低透明度蓝色 |
| [-500, -100] | 0.3→0.8 | (0.4,0.5,0.6) | 肺实质,中等透明度灰蓝 |
| [-100, 50] | 0.8→0.0 | (0.8,0.8,0.8) | 血管,高亮白色 |
| [50, 3000] | 0.0 | (0,0,0) | 骨骼外区域,屏蔽干扰 |
这个TF的关键洞察是:并非所有高密度区域都需要高Alpha。血管在HU=50时已足够凸显,继续提高Alpha反而会遮盖其内部血流信号。我们在Shader中用smoothstep实现平滑过渡:
float alpha = 0; alpha += smoothstep(-900, -500, density) * 0.2; alpha += smoothstep(-500, -100, density) * 0.5; alpha += smoothstep(-100, 50, density) * 0.8; // 最终Alpha钳制在[0,1] alpha = saturate(alpha);但仅靠TF不够。真实组织有光学特性:光线穿过软组织会发生散射,导致边缘模糊;穿过致密骨骼则产生强反射。为此,我们引入Phong光照模型的体绘制变体。传统Phong计算顶点法线,而体绘制中法线需从密度梯度(Gradient)计算:
// 计算密度梯度(中心差分法) float3 grad = float3( tex3D(_VolumeTex, samplePos + float3(_StepSize,0,0)).r - tex3D(_VolumeTex, samplePos - float3(_StepSize,0,0)).r, tex3D(_VolumeTex, samplePos + float3(0,_StepSize,0)).r - tex3D(_VolumeTex, samplePos - float3(0,_StepSize,0)).r, tex3D(_VolumeTex, samplePos + float3(0,0,_StepSize)).r - tex3D(_VolumeTex, samplePos - float3(0,0,_StepSize)).r ) / (2.0 * _StepSize); // 归一化得到表面法线 float3 normal = normalize(grad); // 标准Phong光照 float3 lightDir = normalize(_WorldLightPos - worldPos); float NdotL = saturate(dot(normal, lightDir)); float3 diffuse = _LightColor * NdotL; float3 viewDir = normalize(_WorldCameraPos - worldPos); float3 reflectDir = reflect(-lightDir, normal); float spec = pow(saturate(dot(reflectDir, viewDir)), _Shininess); float3 specular = _LightColor * spec; float3 litColor = lerp(baseColor, baseColor * diffuse + specular, _LightIntensity);这段代码的代价是:每次采样需额外6次体数据读取(3轴±偏移)。为平衡性能,我们采用梯度幅度阈值(Gradient Magnitude Threshold):仅当length(grad) > 0.1时才计算完整光照,否则用漫反射近似。某医疗设备公司合作项目中,此优化使Shader ALU指令数降低37%,在骁龙8 Gen2上维持60FPS。
注意:传递函数必须与体数据预处理同步。我们曾因CT数据未做窗宽窗位(Window Level)调整,直接使用原始HU值,导致TF曲线全部失效。正确流程是:在C#端加载时,先执行
windowLevelTransform = (huValue - windowCenter) / windowWidth * 0.5 + 0.5,将HU值映射到[0,1],再送入Shader。这是临床软件的硬性规范。
5. 踩坑实录:从黑屏、闪烁到Z-fighting的完整排查链路
写完Shader,导入Unity,第一眼看到黑屏——这是90%初学者的起点。但黑屏背后有至少7种互斥原因,必须按确定性顺序排查。我整理了一份真实排错日志,还原某次耗时17小时的故障定位过程。
阶段一:确认基础通路(耗时23分钟)
- 检查材质球是否赋值了正确的Shader,确认
_VolumeTex纹理引用非空; - 在Shader中临时返回
float4(1,0,0,1),验证Shader能否正常编译并输出红色——成功,排除语法错误; - 将
tex3D采样改为tex2D(_MainTex, uv),确认UV坐标正确——输出预期纹理,排除坐标系错误。
阶段二:体数据纹理诊断(耗时1小时42分钟)
- 在C#中打印
volumeTexture.width/height/depth,发现为0——定位到Texture3D构造后未调用Apply(); - 修复Apply后,屏幕仍黑,检查
volumeTexture.format,发现为TextureFormat.Automatic,强制设为R16; - 启用
Graphics.Blit将体数据纹理直接Blit到屏幕,看到一片纯灰(非黑),确认纹理数据已载入,但全为同一值——追查数据加载逻辑,发现CT值映射时windowCenter误设为0,导致所有值被压缩至[0,0.01]区间。
阶段三:Ray Marching逻辑验证(耗时3小时56分钟)
- 在Shader中输出
rayOrigin与rayDir,发现rayDir为(0,0,0)——定位到C#中Camera.WorldToViewportPoint转换错误,未归一化方向向量; - 修复方向后,出现严重闪烁,逐帧观察发现:偶数帧显示,奇数帧黑屏。启用Frame Debugger,发现
_VolumeBounds参数在帧间跳变——检查C#脚本,发现OnRenderObject中重复设置了_VolumeBounds,且未用Material.SetVector而是Material.SetFloat,导致XYZW分量被错误覆盖。
阶段四:深度测试与混合模式(耗时8小时11分钟)
- 解决闪烁后,物体呈现半透明但边缘锯齿严重。开启ZTest Always,锯齿消失,确认是深度测试冲突;
- 查阅Unity文档,发现
ZWrite Off与Blend SrcAlpha OneMinusSrcAlpha组合在某些GPU上不兼容,改用Blend One OneMinusSrcAlpha; - 新问题:多个体绘制对象叠加时,近处对象完全遮挡远处对象。启用
ZWrite On后,远处对象彻底消失——这才是真正的Z-fighting。根源在于:Ray Marching生成的片元深度(SV_Depth)未被正确写入。解决方案是在Shader中显式输出深度:
float4 frag(v2f i) : SV_Target { // ... Ray Marching计算finalColor ... float depth = LinearEyeDepth(i.worldPos.z); // 将世界深度转为标准化设备坐标 return float4(finalColor.rgb, finalColor.a, depth, 0); }并添加#pragma target 4.0支持SV_Depth语义。至此,所有体绘制对象按正确深度排序,叠加自然。
阶段五:移动端适配收尾(耗时2小时28分钟)
- iOS设备上出现绿色噪点,Frame Debugger显示Alpha通道异常。发现Metal后端对
R16纹理的Alpha解释与OpenGL不同,强制将Shader中density读取改为tex3D(_VolumeTex, pos).rrrr; - Android低端机帧率骤降,Profile显示
tex3D调用占GPU时间78%。启用#pragma enable_d3d11_debug_symbols,发现三线性插值未生效——检查C#端,filterMode设为Bilinear但anisoLevel为0,补上volumeTexture.anisoLevel = 1。
经验总结:体绘制Shader的调试,本质是GPU管线状态的逆向工程。每一次黑屏/闪烁/错位,都是某个隐式状态(纹理格式、深度写入、混合模式)与显式代码不匹配的信号。不要迷信“改一个参数就好”,必须用Frame Debugger逐帧抓取GPU状态,像解剖电路板一样定位每一处焊点虚接。
6. 超越Demo:体绘制在工业检测与教育模拟中的落地形态
当Shader能稳定运行后,真正的挑战才开始:如何让它脱离“技术Demo”身份,成为解决实际问题的工具?我参与的两个落地项目,揭示了体绘制在不同场景下的变形逻辑。
第一个是某工业检测公司的铸件内部缺陷识别系统。客户提供的X射线CT数据为1024×1024×800体素,单文件超4GB。直接加载必崩。我们的方案是:分块流式加载(Chunked Streaming)。将体数据沿Z轴切成80块(每块10层),每块生成独立Texture3D,用对象池管理活跃块。Shader中根据摄像机视锥体,动态计算可见Z范围,只采样对应块的纹理。C#端维护一个Dictionary<int, Texture3D>缓存最近使用的10块,LRU淘汰策略。关键创新在于:Shader中用#define CHUNK_COUNT 80宏定义块总数,采样时int chunkIndex = (int)(samplePos.z * CHUNK_COUNT),避免分支判断。该方案使1024³数据在RTX 3060上以35FPS交互,缺陷定位精度达0.1mm。
第二个是某高校的虚拟解剖教学系统。学生需旋转观察心脏瓣膜开合。固定体绘制无法表现运动。我们采用时序体数据(Time-Varying Volume)方案:将4D CT序列(3D空间+时间)编码为Texture3DArray,每层为一帧。Shader中引入时间变量_Time.y,用lerp在相邻两帧间插值:
float frameIndex = _Time.y * _FrameRate; int floorFrame = (int)frameIndex; int ceilFrame = min(floorFrame + 1, _TotalFrames - 1); float t = frac(frameIndex); float density = lerp( tex3Dlod(_VolumeArray, float4(samplePos.xy, floorFrame, 0)).r, tex3Dlod(_VolumeArray, float4(samplePos.xy, ceilFrame, 0)).r, t );为降低显存,我们将4D数据压缩为R8G8双通道:R8存当前帧密度,G8存下一帧密度差值。解压在Shader中完成,显存占用降低58%。
这两个案例指向同一结论:体绘制Shader的生命力,不在于炫技的算法,而在于与业务数据流的无缝咬合。它必须能接受任意分辨率的输入,能响应实时交互指令(如切割平面、ROI框选),能与现有渲染管线共存(如与URP的Lit Shader混合)。某开发者曾问我:“有没有现成的Asset Store插件?”我的回答是:没有。因为每个真实体数据都有其独特的尺度、精度、动态范围与业务语义,通用插件只能解决10%的共性问题,剩下90%必须亲手缝合。这也是“初探”二字的深意——它不是终点,而是你开始理解数据、硬件与人类视觉感知三者博弈关系的起点。
我在实际使用中发现,最有效的学习方式不是抄Shader代码,而是用Excel手动画一条传递函数曲线,然后对着CT图像手动标出肺、血管、骨骼对应的HU值区间,再反推Shader中smoothstep的参数。这个笨办法逼你直面数据本质,比调100次参数更有价值。