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) │ └────────────────┘ │
└─────────────────────┘ └──────────────────────┘
核心思路:
- 每个库使用独立的 WebGPU 设备
- 通过 CPU 内存(Float32Array)作为中间桥梁
- 利用 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 内存桥接数据,往往比强行共享设备更加稳定和高效。