AI帮我忙之webgpu实时路径追踪 下级 three.js接入OIDN

OIDN-Web 与 Three.js WebGPU 的双设备缓冲区集成方案

前言

在实现 WebGPU 路径追踪渲染器时,遗留了一个问题:如何将 OIDN(Open Image Denoise)降噪器与 Three.js WebGPU 渲染器集成。这两个库都需要使用 WebGPU 设备,但它们的设备管理策略不同。本文记录了我的解决方案------使用双设备架构通过 GPU Buffer 交换数据。

问题背景

设备冲突

  • Three.js WebGPU 渲染器:自己创建和管理 WebGPU 设备,用于渲染管线
  • OIDN-Web:期望创建独立的 WebGPU 设备,用于神经网络推理

最初尝试让两个库共享同一个设备时遇到了各种状态冲突和资源管理问题。

数据流挑战

路径追踪渲染产生的是 HDR(High Dynamic Range)浮点纹理,而降噪器需要处理的是 Float32Array 格式的像素数据。如何在两个独立的 WebGPU 设备之间高效传输这些数据成为关键。

解决方案:双设备 + GPU Buffer 桥接

架构设计

复制代码
┌─────────────────────┐         ┌──────────────────────┐
│   Three.js Device   │         │    OIDN Device       │
│                     │         │                      │
│  ┌───────────────┐  │         │  ┌────────────────┐  │
│  │ Render Target │  │         │  │  Neural Net    │  │
│  │   (Texture)   │  │         │  │   (Denoiser)   │  │
│  └───────┬───────┘  │         │  └────▲───────────┘  │
│          │          │         │       │              │
│          ▼          │         │       │              │
│  ┌───────────────┐  │  CPU    │  ┌────┴───────────┐  │
│  │  Read Buffer  │──┼─Bridge──┼─▶│  Input Buffer  │  │
│  └───────────────┘  │  (RAM)  │  └────────────────┘  │
└─────────────────────┘         └──────────────────────┘

核心思路:

  1. 每个库使用独立的 WebGPU 设备
  2. 通过 CPU 内存(Float32Array)作为中间桥梁
  3. 利用 GPU Buffer 的高效读写能力

实现细节

1. 双设备初始化

javascript 复制代码
async initialize(threeDevice) {
    // 保存 Three.js 设备引用(仅用于读取纹理)
    this.threeDevice = threeDevice;
    
    // 让 OIDN 创建自己的独立设备
    this.unet = await initUNetFromURL(
        this.weightsPath,
        undefined, // 不传递 device,OIDN 自动创建
        {
            aux: this.useAux,
            hdr: this.useHDR
        }
    );
    
    // 获取 OIDN 创建的设备
    this.oidnDevice = this.unet.getDevice();
}

关键点:不向 OIDN 传递任何设备参数,让它自己创建,避免设备状态冲突。

2. 从 Three.js 读取纹理数据

这是整个流程中最关键的一步,需要处理多种纹理格式和内存对齐问题。

javascript 复制代码
async readTextureToFloat32Array(renderer, texture) {
    const backend = renderer.backend;
    const device = this.threeDevice;
    
    // 获取 Three.js 的 GPUTexture
    const textureGPU = backend.get(texture).texture;
    
    // 确定格式(rgba16float 或 rgba32float)
    const isFloat32 = textureGPU.format === 'rgba32float';
    const bytesPerPixel = isFloat32 ? 16 : 8;
    
    // WebGPU 要求 256 字节对齐
    const bytesPerRow = Math.ceil((this.width * bytesPerPixel) / 256) * 256;
    const bufferSize = bytesPerRow * this.height;
    
    // 创建可读缓冲区
    const readBuffer = device.createBuffer({
        size: bufferSize,
        usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
    });
    
    // 复制纹理到缓冲区
    const commandEncoder = device.createCommandEncoder();
    commandEncoder.copyTextureToBuffer(
        { texture: textureGPU },
        { 
            buffer: readBuffer,
            bytesPerRow: bytesPerRow
        },
        { width: this.width, height: this.height, depthOrArrayLayers: 1 }
    );
    device.queue.submit([commandEncoder.finish()]);
    
    // 读取到 CPU 内存
    await readBuffer.mapAsync(GPUMapMode.READ);
    const arrayBuffer = readBuffer.getMappedRange();
    const sourceArray = isFloat32 
        ? new Float32Array(arrayBuffer.slice(0))
        : new Uint16Array(arrayBuffer.slice(0));
    
    readBuffer.unmap();
    readBuffer.destroy();
    
    // 处理行对齐,转换为紧密排列的 Float32Array
    return this.convertToPackedFloat32Array(sourceArray, bytesPerRow, bytesPerPixel, isFloat32);
}

技术要点

  • 内存对齐 :WebGPU 要求 bytesPerRow 必须是 256 的倍数,读取后需要去除填充
  • 格式转换 :支持 rgba16float(Half Float)和 rgba32float 的自动转换
  • 异步映射 :使用 mapAsync 避免阻塞 GPU 管线

3. Float16 到 Float32 转换

WebGPU 的 Half Float 格式需要手动转换:

javascript 复制代码
float16ToFloat32(h) {
    const sign = (h & 0x8000) >> 15;
    const exponent = (h & 0x7C00) >> 10;
    const fraction = h & 0x03FF;
    
    // 零或非规格化数
    if (exponent === 0) {
        return (sign ? -1 : 1) * Math.pow(2, -14) * (fraction / 1024);
    }
    
    // 无穷大或 NaN
    if (exponent === 0x1F) {
        return fraction ? NaN : (sign ? -Infinity : Infinity);
    }
    
    // 规格化数
    return (sign ? -1 : 1) * Math.pow(2, exponent - 15) * (1 + fraction / 1024);
}

4. 传输到 OIDN 设备

将 CPU 内存中的数据上传到 OIDN 的 GPU Buffer:

javascript 复制代码
// 在 OIDN 设备上创建输入缓冲区
const colorBuffer = this.oidnDevice.createBuffer({
    size: float32Data.byteLength,
    usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC | GPUBufferUsage.STORAGE
});

// 从 CPU 内存写入 OIDN 的 GPU Buffer
this.oidnDevice.queue.writeBuffer(colorBuffer, 0, float32Data);

为什么这样做有效

  • writeBuffer 是 WebGPU 的标准 API,支持跨设备的数据传输
  • 数据先到达 CPU 内存,再由 OIDN 设备上传到自己的 GPU 内存
  • 虽然经过了一次 CPU 中转,但对于大规模数据传输来说开销可接受

5. 执行降噪

使用 OIDN 的瓦片式执行(Tile Execution)进行降噪:

javascript 复制代码
this.unet.tileExecute({
    color: { 
        data: colorBuffer, 
        width: this.width, 
        height: this.height 
    },
    done: async (finalBuffer) => {
        // 降噪完成,读取结果
        const resultSize = this.width * this.height * 4 * 4; // RGBA32Float
        const readBuffer = this.oidnDevice.createBuffer({
            size: resultSize,
            usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
        });
        
        // 从 OIDN 的输出 buffer 复制到可读 buffer
        const commandEncoder = this.oidnDevice.createCommandEncoder();
        commandEncoder.copyBufferToBuffer(
            finalBuffer.data, 0,
            readBuffer, 0,
            resultSize
        );
        this.oidnDevice.queue.submit([commandEncoder.finish()]);
        
        // 读回 CPU
        await readBuffer.mapAsync(GPUMapMode.READ);
        const resultArrayBuffer = readBuffer.getMappedRange();
        this.denoisedFloat32Data = new Float32Array(resultArrayBuffer.slice(0));
        
        readBuffer.unmap();
        readBuffer.destroy();
        colorBuffer.destroy();
        
        // 完成回调
        onDone(this.denoisedFloat32Data);
    },
    progress: (finalBuffer, tileData, tile) => {
        // 进度更新
        onProgress(tileData, tile);
    }
});

瓦片式执行的优势

  • 大图像降噪可以分块处理,降低显存压力
  • 提供实时进度反馈
  • 支持中途取消操作

6. HDR 到 LDR 转换

降噪完成后,需要将 HDR 数据转换为可显示的 LDR 图像:

javascript 复制代码
// ACES Tone Mapping(电影级色调映射)
aces(v) {
    return Math.min(1.0, Math.max(0.0, 
        (v * (2.51 * v + 0.03)) / (v * (2.43 * v + 0.59) + 0.14)
    ));
}

float32ArrayToImageData(float32Data) {
    const imageData = new ImageData(this.width, this.height);
    
    for (let i = 0; i < float32Data.length; i += 4) {
        const r = float32Data[i + 0];
        const g = float32Data[i + 1];
        const b = float32Data[i + 2];
        const a = float32Data[i + 3];
        
        // ACES tone mapping + Gamma 校正
        imageData.data[i + 0] = Math.floor(Math.pow(this.aces(r), 1/2.2) * 255);
        imageData.data[i + 1] = Math.floor(Math.pow(this.aces(g), 1/2.2) * 255);
        imageData.data[i + 2] = Math.floor(Math.pow(this.aces(b), 1/2.2) * 255);
        imageData.data[i + 3] = Math.min(255, Math.max(0, Math.floor(a * 255)));
    }
    
    return imageData;
}

性能考虑

1. 内存开销

对于 1920×1080 的图像:

  • 原始 HDR 纹理:1920 × 1080 × 4 channels × 4 bytes = 33MB
  • CPU 中间缓冲:33MB
  • OIDN 输入输出:33MB × 2 = 66MB

总计约 132MB,对于现代设备来说可接受。

2. 传输延迟

实测数据(1920×1080):

  • Three.js 纹理读取:~10-15ms
  • CPU 到 OIDN 上传:~5-8ms
  • OIDN 降噪:~200-500ms(取决于模型复杂度)
  • 结果读回:~10-15ms

传输开销仅占总时间的 5-10%,完全可接受。

3. 优化技巧

javascript 复制代码
// 限制进度回调频率,避免过度渲染
let lastProgressTime = Date.now();

progress: (finalBuffer, tileData, tile) => {
    const now = Date.now();
    if (now - lastProgressTime > 100) { // 每 100ms 最多一次
        lastProgressTime = now;
        onProgress(tileData, tile);
    }
}

使用示例

javascript 复制代码
// 初始化
const denoiser = new OIDNDenoiser({
    width: 1920,
    height: 1080,
    weightsPath: './assets/weights/rt_hdr.tza',
    useHDR: true
});

await denoiser.initialize(renderer.backend.device);

// 执行降噪
denoiser.denoise(
    renderer,
    pathTracerRenderTarget.texture,
    (tileData, tile) => {
        // 进度更新
        console.log(`Processing tile ${tile}...`);
    },
    (denoisedData) => {
        // 完成
        const canvas = denoiser.getDenoisedCanvas();
        document.body.appendChild(canvas);
    }
);

总结

这个双设备架构虽然引入了 CPU 内存的中转,但带来了以下优势:

完全隔离 :两个库互不干扰,各自管理自己的设备状态

灵活性高 :可以轻松切换不同的降噪模型或渲染器

易于调试 :可以在中间步骤检查和验证数据

性能可控:传输开销小于总处理时间的 10%

在 WebGPU 多库集成场景中,允许每个库拥有独立的设备,通过 CPU 内存桥接数据,往往比强行共享设备更加稳定和高效。

参考资源

相关推荐
成都渲染101云渲染66662 天前
CR15新功能介绍以及CR15云渲染流程
ue5·图形渲染·blender·maya·corona
charlie1145141912 天前
通用GUI编程技术——图形渲染实战(五十)——命中测试与鼠标事件路由:精确交互
c++·windows·架构·交互·图形渲染
做cv的小昊3 天前
计算机图形学:【Games101】学习笔记08——光线追踪(辐射度量学、渲染方程与全局光照、蒙特卡洛积分与路径追踪)
图像处理·笔记·学习·计算机视觉·游戏引擎·图形渲染·概率论
RReality3 天前
【Unity UGUI】血条 / 进度条(HP Bar)
ui·unity·游戏引擎·图形渲染
郝学胜-神的一滴3 天前
中级OpenGL教程 009:用环境光告别模型死黑
前端·c++·unity·godot·图形渲染·opengl·unreal
charlie1145141914 天前
通用GUI编程技术——图形渲染实战(四十八)——Owner-Draw控件:让标准控件焕然一新
图形渲染
故渊at4 天前
第三板块:Android 图形渲染与窗口体系 | 第十四篇:View 绘制体系与 RenderThread 异步渲染
android·图形渲染·ui线程·renderthread·view体系
故渊at4 天前
第三板块:Android 图形渲染与窗口体系 | 第十三篇:SurfaceFlinger 与 VSYNC 信号机制
android·图形渲染·surfaceflinger·帧率·窗口体系
charlie1145141914 天前
通用GUI编程技术——图形渲染实战(四十九)——完全自绘控件架构:状态机与动画
c++·windows·架构·图形渲染