WEBKT

WebGPU 显存泄露踩坑:如何优雅地管理 GPUTexture 生命周期

9 0 0 0

在从 WebGL 转向 WebGPU 的过程中,很多开发者会遇到一个诡异的现象:JS 内存监控(Heap Size)一切正常,但页面运行一段时间后浏览器标签页直接崩溃(OOM),或者显卡风扇开始狂转。

这背后的罪魁祸首,往往是未被正确释放的 GPUTexture

WebGPU 赋予了开发者更接近底层的硬件控制权,但也意味着我们必须亲自接管内存管理的接力棒。本文将深入探讨 WebGPU 纹理内存的生命周期管理,分析显存泄露的本质,并提供一套可直接用于生产环境的纹理释放与复用最佳实践。


为什么不能指望 JS 的垃圾回收(GC)?

在传统的 Web 开发中,我们习惯了“垃圾回收”这一保护伞。只要一个 JS 对象没有被引用,V8 等引擎迟早会帮我们把它清理掉。

然而,在 WebGPU 中,这种机制失效了。

function createAndForgetTexture(device) {
    const texture = device.createTexture({
        size: [2048, 2048, 1],
        format: 'rgba8unorm',
        usage: GPUTextureUsage.RENDER_ATTACHMENT
    });
    // 函数执行完毕,texture 变量超出作用域,JS 侧失去了对它的引用
}

在上面的代码中,texture 作为 JS 对象确实很快就会被垃圾回收。但问题在于:

  1. 体积不对称性GPUTexture 的 JS 包装对象(Wrapper)可能只有几十个字节。而它在 GPU 端申请的实际显存(VRAM)可能高达 16MB(2048 * 2048 * 4 字节)。
  2. GC 触发滞后:垃圾回收器是根据 JS 堆内存 的压力来决定何时启动的。因为 JS 侧只增加了几十字节的负担,GC 可能会认为当前内存非常宽裕,从而迟迟不触发回收。
  3. 显存撑爆:在 GC 慢悠悠地准备启动之前,几百个 16MB 的真实显存资源已经把 GPU 的 VRAM 挤爆了,导致 GPU 驱动直接重置或浏览器崩溃。

黄金法则任何通过 device.createTexture() 创建的纹理,都必须由你手动、显式地释放。


显式销毁的核心 API

WebGPU 提供了非常直接的销毁方法:GPUTexture.destroy()

const texture = device.createTexture({ ... });

// ... 在渲染循环中使用该纹理 ...

// 当纹理不再需要时(例如切换场景、卸载模型)
texture.destroy();

销毁时的连锁反应

当你调用 texture.destroy() 时,会发生以下几件事:

  • 立即失效:该纹理在 CPU 侧(JS 中)立即变为无效状态。任何后续试图将该纹理写入 BindGroup 或提交到 Render Pass 的操作都会抛出 Validation Error。
  • 显存释放:浏览器会将该纹理占用的 GPU 内存标记为可回收。需要注意的是,实际的物理显存释放可能由浏览器底层异步完成,但它不会再占用你的配额。
  • 视图(GPUTextureView)的处理:你通过 texture.createView() 创建的纹理视图不需要(也没有).destroy() 方法。当其母体 GPUTexture 被销毁时,所有的视图会自动失效并被释放。

容易被忽视的特殊纹理:Canvas Texture

我们最常打交道的另一种纹理是 Canvas 渲染上下文返回的:

const context = canvas.getContext('webgpu');
const currentTexture = context.getCurrentTexture();

千万不要对 currentTexture 调用 .destroy()

  • context.getCurrentTexture() 返回的纹理生命周期完全由浏览器控制。
  • 它的生命周期仅限于当前帧(Current Frame)。在当前帧的渲染通道提交后,浏览器会自动回收或复用该纹理。
  • 如果你强行对其调用 destroy(),会导致浏览器在后续渲染呈现(Present)时找不到缓冲,从而抛出严重错误。

工业级实践:纹理生命周期的三大设计模式

在复杂的 3D 引擎或高频数据可视化应用中,频繁地 createTexturedestroy 会导致严重的显存碎片化和主线程卡顿(因为创建纹理需要与 GPU 驱动进行同步同步交互)。

我们需要通过巧妙的设计模式来规避这些问题。

模式一:引用计数管理器(Reference Counting)

当一个纹理(如材质贴图)被多个 3D 网格(Mesh)共享时,你无法简单地在某个网格销毁时连带销毁纹理。此时需要引入引用计数。

class ManagedTexture {
    constructor(device, descriptor) {
        this.device = device;
        this.texture = device.createTexture(descriptor);
        this.refCount = 1;
    }

    addRef() {
        this.refCount++;
        return this;
    }

    decRef() {
        this.refCount--;
        if (this.refCount <= 0) {
            this.texture.destroy();
            this.texture = null;
            console.log("Texture VRAM released successfully.");
            return true; // 已销毁
        }
        return false; // 仍有引用
    }
}

// 使用场景
const rawTextureDesc = { size: [1024, 1024], format: 'rgba8unorm', usage: GPUTextureUsage.TEXTURE_BINDING };
const sharedTex = new ManagedTexture(device, rawTextureDesc);

// Mesh A 使用
const useInMeshA = sharedTex.addRef();
// Mesh B 使用
const useInMeshB = sharedTex.addRef();

// Mesh A 销毁时
useInMeshA.decRef(); // refCount = 2 -> 1,不触发销毁
// Mesh B 销毁时
useInMeshB.decRef(); // refCount = 1 -> 0,自动执行 texture.destroy()

模式二:动态尺寸纹理池(Texture Pool)

对于后处理(Post-processing)、阴影贴图(Shadow Maps)或 G-Buffer 等临时渲染介质,它们的尺寸通常与窗口大小挂钩。当窗口缩放时,旧纹理必须废弃。

使用一个纹理池可以避免频繁创建的开销,实现显存的平滑复用。

class TexturePool {
    constructor(device) {
        this.device = device;
        // 存储空闲纹理的桶,Key 可以是纹理规格的 Hash
        this.pool = new Map();
    }

    _getHash(desc) {
        const [w, h, d] = desc.size;
        return `${w}x${h}x${d}_${desc.format}_${desc.usage}`;
    }

    acquire(desc) {
        const hash = this._getHash(desc);
        if (this.pool.has(hash)) {
            const list = this.pool.get(hash);
            if (list.length > 0) {
                return list.pop(); // 复用已有纹理
            }
        }
        // 池中无可用,创建新纹理
        return this.device.createTexture(desc);
    }

    release(desc, texture) {
        const hash = this._getHash(desc);
        if (!this.pool.has(hash)) {
            this.pool.set(hash, []);
        }
        this.pool.get(hash).push(texture);
    }

    // 在上下文丢失或应用关闭时清空池子
    clear() {
        for (const [hash, list] of this.pool.entries()) {
            list.forEach(tex => tex.destroy());
        }
        this.pool.clear();
    }
}

模式三:双缓冲(Ping-Pong)纹理的优雅切换

在做 GPGPU 粒子模拟、流体计算(Physically Based Simulation)或渐进式渲染时,我们需要“读写交替”的两个纹理。

class PingPongBuffer {
    constructor(device, desc) {
        this.textureA = device.createTexture(desc);
        this.textureB = device.createTexture(desc);
        this.current = this.textureA;
        this.next = this.textureB;
    }

    swap() {
        const temp = this.current;
        this.current = this.next;
        this.next = temp;
    }

    destroy() {
        this.textureA.destroy();
        this.textureB.destroy();
    }
}

在模拟周期结束或组件卸载时,直接调用 pingPong.destroy(),确保两个缓冲区的显存同时干净利落地交还给 GPU。


进阶:如何处理设备丢失(GPUDevice Lost)?

在 WebGPU 开发中,显卡驱动崩溃、多显卡切换或系统休眠都会导致 GPUDevice 丢失。

device.lost.then((info) => {
    console.warn(`GPUDevice lost: ${info.message}`);
    // 此时,你之前在这个 device 上创建的所有 GPUTexture 都已经自动失效
    // 你不需要(也无法)对它们调用 .destroy() 
    
    // 我们需要做的是:
    // 1. 清空所有的纹理池、引用计数器
    // 2. 重新初始化 WebGPU 上下文
    // 3. 重新加载/创建所有的硬资源
});

一旦设备丢失,之前的所有显存都会被底层静默释放。此时如果调用 .destroy() 虽不会报错,但已经失去了意义。重点是及时清空 JS 侧的缓存引用,防止它们阻碍 JS 堆内存的释放。


如何排查显存泄漏?

当你怀疑应用存在显存泄露时,可以通过以下步骤进行诊断:

  1. Chrome 任务管理器
    Shift + Esc 打开 Chrome 自带的任务管理器,关注 GPU 内存(GPU Memory)这一列。如果在场景切换后,该数值没有回落,说明存在显存泄漏。
  2. 使用 WebGPU 抓帧工具
    安装 Chrome 插件如 Spector.js(正在逐步完善对 WebGPU 的支持)或使用 Chrome DevTools。
  3. 注入代理(Monkey Patch)进行监控
    在开发环境下,可以重写 createTexturedestroy 来监控泄露源头:
const originalCreate = GPUDevice.prototype.createTexture;
const originalDestroy = GPUTexture.prototype.destroy;

let activeTextures = 0;

GPUDevice.prototype.createTexture = function(descriptor) {
    activeTextures++;
    console.log(`[VRAM Tracker] Texture Created. Active count: ${activeTextures}`);
    return originalCreate.call(this, descriptor);
};

GPUTexture.prototype.destroy = function() {
    activeTextures--;
    console.log(`[VRAM Tracker] Texture Destroyed. Active count: ${activeTextures}`);
    originalDestroy.call(this);
};

通过这一层代理,你可以清晰地观察到纹理的创建与销毁是否成对出现。如果在执行了清理逻辑后 activeTextures 依然不断攀升,就需要检查是否有引用遗留或者忘记调用 destroy() 了。

总结

WebGPU 将底层控制权交还给前端开发者的同时,也带回了 C/C++ 时代的经典命题——谁申请,谁释放

对于 GPUTexture 这种动辄占用数兆至数十兆显存的庞然大物,务必摒弃“依靠 JS 垃圾回收自动解决”的侥幸心理。建立严谨的显式销毁机制、引入纹理复用池,并在设备丢失时做好重置预案,是写出高性能、无内存抖动 WebGPU 应用的必修课。

图形微光 WebGPU显存优化前端性能

评论点评