WEBKT

WebGPU 实战:基于 3D 纹理与 WGSL 射线步进(Raymarching)的高效体绘制指南

4 0 0 0

在医学成像(CT/MRI)、气象模拟(云层/风场)以及影视特效(烟雾/火焰)等领域,体绘制(Volume Rendering)是一项至关重要的技术。传统的 WebGL 在处理大规模三维体数据时,受限于性能瓶颈和繁琐的 API 设计。

随着 WebGPU 标准的推进,现代浏览器获得了直接访问底层 GPU 硬件的能力。本文将深入探讨如何利用 WebGPU 的 3D 纹理(Texture3D)WGSL 着色器,通过经典的高效射线步进(Raymarching)算法,在网页端实现流畅的实时体绘制。


1. 体绘制的核心原理:射线步进(Raymarching)

由于三维体数据(如 $256 \times 256 \times 256$ 的体素网格)无法直接通过传统的三角形网格光栅化进行渲染,我们通常使用射线步进法(Raymarching)

  1. 确定包围盒:绘制一个常规的 3D 立方体作为体数据的边界容器(Bounding Box)。
  2. 计算射线:对于屏幕上的每个像素,计算一条从相机出发、穿过该像素并进入立方体的射线。
  3. 求交与步进:计算射线与立方体的入点(Entry Point)和出点(Exit Point)。从入点开始,沿着射线方向以固定步长(Step Size)向出点前进。
  4. 采样与累加:在每个步进点,对 3D 纹理进行三线性插值采样,获取该位置的密度值,并结合传输函数(Transfer Function)将其转换为颜色和阻光度(Opacity),最终按照前向或后向混合公式进行颜色累加,直到射线离开立方体或阻光度达到饱和。
相机 ──> [ 穿过屏幕像素 ] ──> [ 边界框入点 ──> · 采样点 · ──> · 采样点 · ──> 边界框出点 ]

2. WebGPU 中创建与填充 3D 纹理

在 WebGPU 中,创建 3D 纹理需要显式声明 dimension: '3d'。以下是初始化一个用于存储单通道密度数据的 3D 纹理的代码片段:

// 假设体数据分辨率为 256 x 256 x 256
const volumeDims = { width: 256, height: 256, depth: 256 };

const volumeTexture = device.createTexture({
  size: [volumeDims.width, volumeDims.height, volumeDims.depth],
  dimension: '3d',
  format: 'r8unorm', // 单通道 8 位无符号归一化整数,适合存储灰度密度值
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
});

写入体数据

将 CPU 端的 Uint8Array 原始体数据(Raw Voxel Data)上传至 GPU 3D 纹理:

// voxelData 为包含 256*256*256 个字节的 Uint8Array
device.queue.writeTexture(
  { texture: volumeTexture },
  voxelData,
  {
    bytesPerRow: volumeDims.width,
    rowsPerImage: volumeDims.height,
  },
  [volumeDims.width, volumeDims.height, volumeDims.depth]
);

注意:在处理非 256 字节对齐的数据时,需要特别计算 bytesPerRow 的对齐限制(WebGPU 要求其必须是 256 的倍数)。此处宽高均为 256 完美契合。


3. 管线与资源绑定(Bind Group)

渲染 3D 纹理需要使用采样器(Sampler)。为了获得平滑的体效果,采样器必须配置为三线性过滤(Trilinear Filtering):

const volumeSampler = device.createSampler({
  magFilter: 'linear',
  minFilter: 'linear',
  magFilter: 'linear', // 启用 3D 空间内的平滑插值
});

// 定义 Bind Group 布局
const bindGroupLayout = device.createBindGroupLayout({
  entries: [
    {
      binding: 0, // 投影与视图矩阵等 Uniform 缓冲
      visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
      buffer: {}
    },
    {
      binding: 1, // 3D 纹理
      visibility: GPUShaderStage.FRAGMENT,
      texture: { viewDimension: '3d' }
    },
    {
      binding: 2, // 采样器
      visibility: GPUShaderStage.FRAGMENT,
      sampler: {}
    }
  ]
});

const bindGroup = device.createBindGroup({
  layout: bindGroupLayout,
  entries: [
    { binding: 0, resource: { buffer: uniformBuffer } },
    { binding: 1, resource: volumeTexture.createView() },
    { binding: 2, resource: volumeSampler }
  ]
});

4. 核心 WGSL 着色器实现

为了让射线步进算法更加高效,我们通常在顶点着色器中将立方体的局部坐标(Local Position,范围 [0, 1])传递给片元着色器。由于立方体局部坐标的 [x, y, z] 与 3D 纹理的 UVW 坐标完美对应,这极大简化了射线起止点的计算。

WGSL 完整实现

struct Uniforms {
    modelViewProjectionMatrix : mat4x4<f32>,
    cameraLocalPos            : vec3<f32>, // 转换到立方体局部空间下的相机位置
    stepSize                  : f32,       // 每次步进的距离
};

@group(0) @binding(0) var<uniform> uniforms : Uniforms;
@group(0) @binding(1) var volumeTex : texture_3d<f32>;
@group(0) @binding(2) var volumeSampler : sampler;

struct VertexInput {
    @location(0) position : vec3<f32>, // 局部坐标 [-0.5, 0.5]
};

struct VertexOutput {
    @builtin(position) position : vec4<f32>,
    @location(0) localPos : vec3<f32>,  // 传递给片元,映射到 [0, 1] 空间
};

@vertex
fn vs_main(input : VertexInput) -> VertexOutput {
    var output : VertexOutput;
    output.position = uniforms.modelViewProjectionMatrix * vec4<f32>(input.position, 1.0);
    // 将 [-0.5, 0.5] 变换到 [0, 1] 的纹理坐标空间
    output.localPos = input.position + vec3<f32>(0.5);
    return output;
}

// 辅助函数:计算射线与 AABB(轴对齐包围盒 [0, 1]^3)的交点
fn intersectAABB(rayOrigin : vec3<f32>, rayDir : vec3<f32>) -> vec2<f32> {
    let tMin = (vec3<f32>(0.0) - rayOrigin) / rayDir;
    let tMax = (vec3<f32>(1.0) - rayOrigin) / rayDir;
    let t1 = min(tMin, tMax);
    let t2 = max(tMin, tMax);
    let tNear = max(max(t1.x, t1.y), t1.z);
    let tFar = min(min(t2.x, t2.y), t2.z);
    return vec2<f32>(tNear, tFar);
}

@fragment
fn fs_main(input : VertexOutput) -> @location(0) vec4<f32> {
    let rayOrigin = uniforms.cameraLocalPos;
    let rayDir = normalize(input.localPos - rayOrigin);

    // 计算射线与单位立方体的相交区间
    let intersection = intersectAABB(rayOrigin, rayDir);
    
    // 如果交点不合法或完全在立方体后方,直接丢弃
    if (intersection.x > intersection.y || intersection.y < 0.0) {
        discard;
    }

    // 限制步进起点:如果相机在包围盒内部,从相机位置(t = 0)开始步进
    let tStart = max(intersection.x, 0.0);
    let tEnd = intersection.y;

    var t = tStart;
    var accumulatedColor = vec4<f32>(0.0);

    // 最大步进次数,防止死循环并控制性能
    let maxSteps = 256;
    let step = uniforms.stepSize;

    for (var i = 0; i < maxSteps; i = i + 1) {
        if (t >= tEnd || accumulatedColor.a >= 0.95) {
            break; // 越界或阻光度达到饱和,提前终止(Early Ray Termination)
        }

        // 当前采样点坐标(处于 [0, 1]^3 空间内)
        let sampleCoord = rayOrigin + rayDir * t;

        // 采样 3D 纹理,获取密度值 (R 通道)
        let density = textureSampleLevel(volumeTex, volumeSampler, sampleCoord, 0.0).r;

        if (density > 0.01) {
            // 最简易的传输函数映射:将密度值映射为颜色和透明度
            let localColor = vec4<f32>(density * 1.5, density * 0.8, density * 0.3, density);

            // 前向 Alpha 混合公式(Standard Front-to-Back Composition)
            let alpha = localColor.a * step * 10.0; // 引入校正系数调整密度饱和度
            let correctedColor = vec4<f32>(localColor.rgb * alpha, alpha);

            accumulatedColor = accumulatedColor + correctedColor * (1.0 - accumulatedColor.a);
        }

        t = t + step;
    }

    return accumulatedColor;
}

5. 关键优化技术

在实际项目中,纯粹的射线步进在大分辨率体数据下可能面临严重的性能瓶颈。以下是两种实用的实时优化手段:

5.1 早期射线终止(Early Ray Termination)

上面的着色器代码中已经引入了这一逻辑:

if (accumulatedColor.a >= 0.95) { break; }

当射线穿过不透明度极高的区域(例如骨骼或高密度云层)时,其不透明度会迅速饱和(趋于 1.0)。此时,该像素后面的任何物体都将被完全遮挡。立即跳出循环能够节约大量的 3D 纹理采样带宽。

5.2 随机抖动去带噪(Ray Jittering)

由于离散的步长限制,渲染半透明均匀体时,画面常常会出现类似“等高线”的波纹带状伪影(Banding Artifacts)。

解决方案:在每次发射射线时,为其起点(tStart)添加一个微小的随机或伪随机偏移(Jittering)。这会将规则的带状伪影打碎为高频噪声,在视觉上看起来像是自然的颗粒感,而非生硬的模型断层。

// 简易一维伪随机函数
fn rand(co: vec2<f32>) -> f32 {
    return fract(sin(dot(co, vec2<f32>(12.9898, 78.233))) * 43758.5453);
}

// 在 fs_main 中计算 tStart 时引入:
let jitter = rand(input.localPos.xy) * step;
var t = tStart + jitter;

6. 总结

WebGPU 的 3D 纹理与极其规范的 WGSL 内存模型,让网页端的体绘制开发体验上升到了全新高度。相较于 WebGL,开发者不再需要依赖繁琐的 2D Texture Atlas 进行手动寻址,同时能够利用计算着色器(Compute Shader)对 3D 纹理实施实时的滤波、形变和动力学模拟,为 Web 端的科学可视化、云游戏以及交互式 3D 交互提供了极具想象力的空间。

极客画布 WebGPU体绘制WGSL

评论点评