#12151·pixijs

WebGPU renderer allocates per draw call: for-in over null-prototype maps in the encoder hot path

Author: sefabaserCreated Aug 19, 2026Updated Aug 25, 2026
Labelsenv: webgpustatus: needs triagescope: rendering

Version: 8.19.0 (the loops are still present on dev)

GpuEncoderSystem._setShaderBindGroups, _syncBindGroup, setGeometry, and BindGroup._touch iterate objects created with Object.create(null) (BindGroup.resources, shader.groups, the map returned by getBufferNamesToBind) using for-in, once per draw call. Null-prototype objects with numeric keys are dictionary-mode in V8, so every for-in allocates a fresh key enumeration.

In my game (Electron, WebGPU renderer), Chrome's allocation sampler attributes roughly 40 MB per 10 seconds of garbage to these frames — about 2x the total garbage of the same scene on the WebGL renderer — with _setShaderBindGroups, setBindGroup, and setGeometry as the top allocation sites:

11.4 MB  _setShaderBindGroups @ GpuEncoderSystem  <- draw <- execute <- executeInstructions
 9.1 MB  setBindGroup @ GpuEncoderSystem          <- _setShaderBindGroups <- draw <- execute
 5.2 MB  setGeometry @ GpuEncoderSystem           <- draw <- execute <- executeInstructions

Fix that worked for me: cache the key list on the object (Object.keys once), iterate by index, and invalidate the cache in BindGroup.setResource / Shader.addResource; setGeometry can cache numeric keys on the getBufferNamesToBind result (which also avoids the per-draw parseInt and passes numbers rather than strings to GPURenderPassEncoder.setBindGroup).

Patching just these four methods took our per-frame GC from ~108 MB/10s to ~46 MB/10s with identical rendering.

If it helps, this is the temporary solution that I am using now (calling apply on game create):

import * as Pixi from 'pixi.js';

interface TouchedResource {
  _gcLastUsed: number;
  _touched: number;
  isUniformGroup?: boolean;
}

interface KeyCachedBindGroup {
  resources: Record<string, TouchedResource>;
  _keyCache?: string[] | undefined;
}

interface GroupKeyCachedShader {
  groups: Record<number, Pixi.BindGroup>;
  gpuProgram: Pixi.GpuProgram;
  _groupKeyCache?: number[] | undefined;
}

interface KeyCachedBufferNames {
  [index: number]: string;
  _keyCache?: number[];
}

/**
 * PixiJS 8.19 iterates `Object.create(null)` maps with `for-in` on every WebGPU draw call (bind
 * group touch/sync, shader groups, vertex buffer binding). Such maps are dictionary-mode objects,
 * so every loop allocates a fresh key enumeration, which dominates the renderer's per-frame
 * garbage. These patches replace the enumerations with key arrays cached on the objects,
 * invalidated where the maps can change.
 */
export class PixiGcPatches {
  static apply(): void {
    PixiGcPatches._patchBindGroup();
    PixiGcPatches._patchEncoder();
    PixiGcPatches._patchShader();
  }

  private static _patchBindGroup(): void {
    let prototype = Pixi.BindGroup.prototype as unknown as KeyCachedBindGroup & {
      _touch(now: number, tick: number): void;
      setResource(resource: unknown, index: number): void;
    };

    prototype._touch = function (now: number, tick: number): void {
      let keys = PixiGcPatches._bindGroupKeys(this);
      for (let i = 0; i < keys.length; i++) {
        let resource = this.resources[keys[i]!]!;
        resource._gcLastUsed = now;
        resource._touched = tick;
      }
    };

    let originalSetResource = prototype.setResource;
    prototype.setResource = function (resource: unknown, index: number): void {
      this._keyCache = undefined;
      originalSetResource.call(this, resource, index);
    };
  }

  private static _patchEncoder(): void {
    let prototype = Pixi.GpuEncoderSystem.prototype as unknown as {
      _renderer: {
        ubo: { updateUniformGroup(group: TouchedResource): void };
        pipeline: { getBufferNamesToBind(geometry: Pixi.Geometry, program: Pixi.GpuProgram): KeyCachedBufferNames };
      };
      _setVertexBuffer(index: number, buffer: unknown): void;
      _setIndexBuffer(buffer: unknown): void;
      setBindGroup(index: number, bindGroup: Pixi.BindGroup, program: Pixi.GpuProgram): void;
      _syncBindGroup(bindGroup: KeyCachedBindGroup): void;
      _setShaderBindGroups(shader: GroupKeyCachedShader, skipSync?: boolean): void;
      setGeometry(geometry: Pixi.Geometry, program: Pixi.GpuProgram): void;
    };

    prototype._syncBindGroup = function (bindGroup: KeyCachedBindGroup): void {
      let keys = PixiGcPatches._bindGroupKeys(bindGroup);
      for (let i = 0; i < keys.length; i++) {
        let resource = bindGroup.resources[keys[i]!]!;
        if (resource.isUniformGroup) {
          this._renderer.ubo.updateUniformGroup(resource);
        }
      }
    };

    prototype._setShaderBindGroups = function (shader: GroupKeyCachedShader, skipSync?: boolean): void {
      let keys = shader._groupKeyCache ?? (shader._groupKeyCache = PixiGcPatches._numberKeys(shader.groups));
      for (let i = 0; i < keys.length; i++) {
        let index = keys[i]!;
        let bindGroup = shader.groups[index]!;
        if (!skipSync) {
          this._syncBindGroup(bindGroup as unknown as KeyCachedBindGroup);
        }
        this.setBindGroup(index, bindGroup, shader.gpuProgram);
      }
    };

    prototype.setGeometry = function (geometry: Pixi.Geometry, program: Pixi.GpuProgram): void {
      let buffersToBind = this._renderer.pipeline.getBufferNamesToBind(geometry, program);
      let keys = buffersToBind._keyCache ?? PixiGcPatches._cacheBufferKeys(buffersToBind);
      for (let i = 0; i < keys.length; i++) {
        let index = keys[i]!;
        this._setVertexBuffer(index, geometry.attributes[buffersToBind[index]!]!.buffer);
      }
      if (geometry.indexBuffer) {
        this._setIndexBuffer(geometry.indexBuffer);
      }
    };
  }

  private static _patchShader(): void {
    let prototype = Pixi.Shader.prototype as unknown as GroupKeyCachedShader & {
      addResource(name: string, groupIndex: number, bindIndex: number): void;
    };

    let originalAddResource = prototype.addResource;
    prototype.addResource = function (name: string, groupIndex: number, bindIndex: number): void {
      this._groupKeyCache = undefined;
      originalAddResource.call(this, name, groupIndex, bindIndex);
    };
  }

  private static _bindGroupKeys(bindGroup: KeyCachedBindGroup): string[] {
    return (bindGroup._keyCache ??= Object.keys(bindGroup.resources));
  }

  private static _cacheBufferKeys(buffersToBind: KeyCachedBufferNames): number[] {
    let keys = PixiGcPatches._numberKeys(buffersToBind);
    Object.defineProperty(buffersToBind, '_keyCache', { value: keys });
    return keys;
  }

  private static _numberKeys(map: object): number[] {
    let names = Object.keys(map);
    let keys: number[] = [];
    for (let i = 0; i < names.length; i++) {
      keys.push(Number(names[i]));
    }

    return keys;
  }
}