#12163·pixijs

WebGPU renderer recreates GPU buffers every frame (GpuBufferSystem has no pooling): ~30 createBuffer/frame in a steady-state scene

Author: kevinsprolesCreated Aug 27, 2026Updated Aug 27, 2026

Current Behavior

The WebGPU renderer allocates fresh GPU buffers with device.createBuffer every frame in any workload where display objects or geometries are (re)created over time — GpuBufferSystem has no pooling/reuse: createGPUBuffer always allocates, and released buffers are destroyed (or, in 8.3.4, retained forever — see below) instead of being recycled.

Measured with a steady-state census (wrapping GPUDevice.prototype.createBuffer) on a headless Node.js server-side rendering workload (each job builds a scene, renders it plus two render-to-texture passes, tears it down; caches warm, single renderer, pixi 8.3.4):

  • 32 createBuffer calls/frame, ~2.9 MB/frame, of which
    • ~31 via GpuBufferSystem.createGPUBuffer, reached from updateBuffer12/frame at ~627 KB + 8/frame at ~919 KB: batch/geometry buffers with identical sizes frame over frame being re-allocated, and
    • 11/frame tiny (~1 KB) uniform buffers via getGPUBuffer.
  • CPU profile shares: createGPUBuffer 7.0% self time, batcher build 8.1%, GC 8.6% (allocation-fed).
  • On Vulkan each createBuffer costs ~ what it does on Metal, and buffer uploads contend device-wide when several processes share a discrete GPU — so the churn is doubly expensive there.

I checked src/rendering/renderers/gpu/buffer/GpuBufferSystem.ts on current main (v8.20.x): the structure changed (unload now goes through GCManagedHash), but createGPUBuffer still unconditionally calls device.createBuffer and unload destroys — no reuse, so the churn stands.

Two related observations from the same investigation:

  1. 8.3.4 retention: in the repro below, GPUBuffer.destroy is called zero times across 16 frames while GpuBufferSystem._managedBuffers grows to 258 entries and BatcherPipe._batches to 144 — per-frame batch geometries are retained until renderer destroy. Main's GC-managed unload presumably fixes the retention, but destroy-then-recreate is exactly the pattern pooling should absorb.
  2. Companion (one-liner scale): GpuUniformBatchPipe._uploadBindGroups creates its own command encoder and issues a separate queue.submit per render() call. Batching [uploadCB, frameCB] into one submit is order-safe (runner order renderEndpostrender); we have run that fused form under a production-scale load with byte-identical output.

Expected Behavior

Steady-state rendering of a scene whose buffer sizes don't change should perform ~0 device.createBuffer calls per frame: released GPUBuffers should be pooled by size(+usage) class in GpuBufferSystem and reused, and batch geometry buffers reused across frames/instruction sets.

As validation that this is safe and sufficient, I have a small candidate patch to GpuBufferSystem (pool released buffers in a Map keyed by ${size}:${usage}, reuse in createGPUBuffer with queue.writeBuffer for initial contents, never pool MAP_READ/MAP_WRITE, byte-budget cap, drained on destroyAll/destroy). On the repro below it takes steady state from 24 createBuffer/frame → 0/frame, with the final render-target readback byte-identical (SHA-256) to pristine. Happy to open a PR against main if the direction is agreeable.

Steps to Reproduce

Self-contained headless repro (Node 22 + the webgpu npm package, npm i [email protected] webgpu) — the mechanism is renderer-side, so the same census reproduces in a browser by wrapping GPUDevice.prototype.createBuffer in devtools:

repro-buffer-churn.mjs
// Repro: PixiJS v8 (8.3.4) WebGPU renderer re-creates GPU buffers on every frame
// for any scene whose display objects are (re)built per frame — GpuBufferSystem
// destroys eagerly and pools nothing, so identical-size buffers are re-allocated
// via device.createBuffer each frame.
//
// Headless Node via the `webgpu` (Dawn) package:  npm i [email protected] webgpu
// (Counts reproduce in a browser too — just wrap GPUDevice.prototype.createBuffer.)
import { create, globals } from "webgpu";
Object.assign(globalThis, globals);
const gpu = create([]);

// ---- census: wrap createBuffer, attribute each call to the frame that made it
let frame = -1;
const perFrame = [];
const origCreate = globals.GPUDevice.prototype.createBuffer;
globals.GPUDevice.prototype.createBuffer = function (desc) {
  if (frame >= 0) {
    const f = (perFrame[frame] ??= { calls: 0, bytes: 0, sizes: new Map() });
    f.calls++; f.bytes += desc.size;
    f.sizes.set(desc.size, (f.sizes.get(desc.size) ?? 0) + 1);
  }
  return origCreate.call(this, desc);
};

// ---- minimal browser-env shims so the WebGPU renderer initializes in Node
Object.defineProperty(globalThis, "navigator", { value: { userAgent: "node", gpu }, configurable: true });
globalThis.self = globalThis; // + WorkerGlobalScope -> pixi picks its webworker env (no `document` needed)
globalThis.WorkerGlobalScope = function WorkerGlobalScope() {};
globalThis.location = new URL("http://localhost/");
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 16);
globalThis.cancelAnimationFrame = clearTimeout;
const glStub = { // satisfies the WebGL probe in maxRecommendedTextures/checkMaxIfStatementsInShader
  getParameter: () => 16, createShader: () => ({}), shaderSource() {}, compileShader() {},
  getShaderParameter: () => true, deleteShader() {}, isContextLost: () => false, getExtension: () => null,
  getShaderPrecisionFormat: () => ({ precision: 23, rangeMin: 127, rangeMax: 127 }),
  FRAGMENT_SHADER: 0x8b30, COMPILE_STATUS: 0x8b81, MAX_TEXTURE_IMAGE_UNITS: 0x8872,
};
let device; // captured so the canvas-context stub can allocate its backing texture
class StubCanvas {
  constructor(w = 1, h = 1) { this.width = w; this.height = h; }
  getContext(kind) {
    if (kind !== "webgpu") return glStub;
    let tex = null;
    return {
      canvas: this, configure() {}, unconfigure() {},
      getCurrentTexture: () => (tex ??= device.createTexture({
        size: { width: this.width || 1, height: this.height || 1 }, format: "bgra8unorm",
        usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
      })),
    };
  }
  addEventListener() {} removeEventListener() {}
}
globalThis.OffscreenCanvas = StubCanvas;
const rd = globals.GPUAdapter.prototype.requestDevice;
globals.GPUAdapter.prototype.requestDevice = async function (...a) { return (device = await rd.apply(this, a)); };
// Dawn's WebIDL is stricter than Chrome's: pixi passes string indices to these.
for (const m of ["setVertexBuffer", "setBindGroup"]) {
  const o = globals.GPURenderPassEncoder.prototype[m];
  globals.GPURenderPassEncoder.prototype[m] = function (i, ...r) { return o.call(this, Number(i), ...r); };
}

// ---- scene: 8 render groups x 200 sprites, rebuilt each frame (as a server-side
// per-request workload, or any UI that recreates display objects), plus two
// render-to-texture passes. All buffer sizes are IDENTICAL frame over frame.
const { WebGPURenderer, Container, Sprite, Texture, RenderTexture, BufferImageSource, DOMAdapter, WebWorkerAdapter } =
  await import("pixi.js");
DOMAdapter.set(WebWorkerAdapter);
const renderer = new WebGPURenderer();
await renderer.init({ width: 1024, height: 1024, canvas: new StubCanvas(1024, 1024) });

const pixels = new Uint8Array(64 * 64 * 4).map((_, i) => (i * 7) % 256);
const tex = new Texture({ source: new BufferImageSource({ resource: pixels, width: 64, height: 64 }) });
const rt1 = RenderTexture.create({ width: 512, height: 512 });
const rt2 = RenderTexture.create({ width: 512, height: 512 });
function buildStage() {
  const stage = new Container();
  for (let g = 0; g < 8; g++) {
    const group = new Container({ isRenderGroup: true });
    for (let i = 0; i < 200; i++) {
      const s = new Sprite(tex);
      s.position.set((i % 20) * 50, ((i / 20) | 0) * 50 + g * 5);
      s.rotation = (g * 200 + i) * 0.01;
      group.addChild(s);
    }
    stage.addChild(group);
  }
  return stage;
}
for (let i = 0; i < 32; i++) {
  frame = i;
  const stage = buildStage();
  renderer.render({ container: stage, target: rt1 });
  renderer.render({ container: stage, target: rt2 });
  renderer.render({ container: stage }); // main pass
  stage.destroy({ children: true });
}
for (const [i, f] of perFrame.entries()) {
  const sizes = [...f.sizes].sort((a, b) => b[0] * b[1] - a[0] * a[1]).slice(0, 4)
    .map(([sz, n]) => `${n}x ${(sz / 1024).toFixed(1)}KB`).join(", ");
  if (i < 3 || i >= perFrame.length - 3) console.log(`frame ${String(i).padStart(2)}: ${String(f.calls).padStart(3)} createBuffer, ${(f.bytes / 1048576).toFixed(2)} MB  [${sizes}]`);
  else if (i === 3) console.log("  ...");
}
process.exit(0);

Output on 8.3.4 (macOS/Metal and Linux/Vulkan agree on counts):

frame  0:  18 createBuffer, 0.66 MB  [2x 256.0KB, 8x 18.8KB, 8x 2.3KB]
frame  1:  16 createBuffer, 0.16 MB  [8x 18.8KB, 8x 2.3KB]
frame  2:  16 createBuffer, 0.16 MB  [8x 18.8KB, 8x 2.3KB]
  ...
frame 31:  16 createBuffer, 0.16 MB  [8x 18.8KB, 8x 2.3KB]

Every steady-state frame re-allocates the same 8×18.8 KB attribute + 8×2.3 KB index buffers (one pair per render group). Scene complexity scales the number linearly (the production-scale workload above sits at ~32/frame, ~2.9 MB/frame).

Environment

  • pixi.js: 8.3.4 (measured); GpuBufferSystem on main (v8.20.1) confirmed to still allocate per call
  • Runtime: Node.js 22 headless via [email protected] (Dawn); also applies in Chrome — the allocation pattern is pixi-side
  • OS/GPU: reproduced on macOS (Apple M4, Metal) and Linux (NVIDIA RTX 4060 Ti / RTX 4090, Vulkan)