GPU 2D shelf-packed texture atlas — one per PBR map type per GPUMemoryBatch (albedo, metallic-roughness, future normals / occlusion / emissive).

Each added image occupies a sub-rect with a configurable padding gutter (default 2 px) to mitigate bilinear bleed. Returns an AtlasTransform the caller writes to the per-mesh attribute texture; the shader applies that transform per-fragment.

Use internalFormat to switch between sRGB-decoded (albedo) and linear (MR / normals / occlusion / emissive HDR-prep) atlases.

v1 limitations:

  • Single mip level. Bilinear filtering only — no anisotropic, no mips.
  • No atlas growth. Once allocate fixes the size, overflow on addTexture returns null and the caller falls back to the sentinel.
  • Sources larger than the atlas size are auto-downscaled to fit (aspect-preserving) inside addTexture, emitting a warn-level message with the original and new dimensions. This keeps streaming scenes with unpredictable content sizes rendering with their PBR detail instead of falling through to the sentinel; the renderer is designed for continuous load/unload of varied content, so refusing a too-big texture would surface an end-user-visible artefact rather than a quietly lossy upload.
  • Same image registered twice gets a single shared sub-rect (cached by SceneTexture.id).
  • Tiling textures need to be pre-modulated into [0, 1) before quantisation; the atlas's wrap mode is CLAMP_TO_EDGE.

Coordinate convention: the atlas reserves a 4×4 white sentinel block at the top-left so untextured meshes can sample (1, 1, 1, 1) via a zero-scale UV transform — exactly the right value for both albedo (multiplies through unchanged) and metallic-roughness (passes the material's roughness/metallic through unchanged).

Constructors

  • Parameters

    • options: {
          description: string;
          gl: WebGL2RenderingContext;
          internalFormat?: number;
          mipmap?: boolean;
          padding?: number;
          sentinelColor?: [number, number, number, number];
          size?: number;
      }
      • description: string
      • gl: WebGL2RenderingContext
      • OptionalinternalFormat?: number

        WebGL internal format. Defaults to SRGB8_ALPHA8 for the colour pipeline; pass gl.RGBA8 for linear data (metallic-roughness, normal maps, etc.).

      • Optionalmipmap?: boolean

        Allocate the atlas with a full mip pyramid and sample trilinearly. Default false. When true, every successful addTexture triggers gl.generateMipmap to refresh the pyramid for the entire atlas — fine when textures upload once at load, scales with atlas size as more textures stream in.

      • Optionalpadding?: number
      • OptionalsentinelColor?: [number, number, number, number]

        Sentinel pixel colour as four bytes. Defaults to white. Override with [128, 128, 255, 255] for normal-map atlases so the untextured-fallback decodes to a flat tangent-space normal.

      • Optionalsize?: number

    Returns TextureAtlas

Properties

allocated: boolean = false

True when allocate has succeeded.

gl: WebGL2RenderingContext
internalFormat: number

WebGL internal format — selects sRGB-decoded vs linear sampling.

mipmap: boolean = false

true when the atlas was allocated with a full mip pyramid (floor(log2(size)) + 1 levels) and is sampled trilinearly. Set from the constructor option; false keeps the cheap single-level path.

onUpdated: EventEmitter<TextureAtlas, undefined> = ...

Notifies inspectors that the atlas was modified. Fires when entries are added or after webglContextRestored re-stamps everything.

padding: number

Padding (gutter) reserved around each entry.

sentinelColor: [number, number, number, number]

RGBA byte value the 4×4 sentinel block is filled with. Most atlases use white (255, 255, 255, 255) because that's the multiplicative identity for albedo / MR / occlusion. Normal-map atlases override to (128, 128, 255, 255) so the decoded tangent-space normal is (0, 0, 1) — i.e. "no perturbation" — for untextured meshes.

sentinelTransform: AtlasTransform = ...

UV transform for "no texture" — collapses every fragment to the sentinel white texel at atlas (0.5/size, 0.5/size). Initialised in allocate.

size: number

Atlas square size in pixels.

texture: WebGLTexture = null
DEFAULT_PADDING: 2

Default gutter (pixels) around each entry.

DEFAULT_PADDING_MIPMAP: 8

Gutter (pixels) around each entry on a mipmapped atlas. Each mip level halves the entry's footprint, so adjacent entries can bleed across the original level-0 boundary at higher levels — 8 covers entries down to ~16-pixel level-0 sizes cleanly. Larger entries waste a small fraction of the atlas; smaller entries (~tens of pixels) might still bleed at the tiniest mips, which is acceptable for first-cut Phase 1.

DEFAULT_SIZE: 4096

Default atlas dimension (square). 4096 means each atlas occupies 64 MB of GPU memory (×4 bytes RGBA × 3 atlas types per UV-bearing batch = ~192 MB). The trade-off is fewer batch splits on texture-heavy models like Sponza — at 2048 the same model would spawn ~5-8 batches just from atlas overflow.

Methods

  • Non-destructive shelf-pack probe — tells the batch router whether a texture would land in this atlas ("fits"), would fit in a fresh atlas of the same size but not this one ("would-fit-in-fresh-atlas", meaning "spawn a new batch"), or is too big for any atlas at all ("too-big", meaning the upload will hit the sentinel fallback — spawning a new batch wouldn't help).

    Sources larger than the atlas dimensions are auto-downscaled by addTexture, so this probe uses the post-downscale dimensions for its shelf-fit replay — meaning "too-big" is now essentially reserved for the degenerate w <= 0 || h <= 0 case. A oversize texture that triggers a downscale will still report "fits" or "would-fit-in-fresh-atlas" based on the scaled-down footprint, so the batch router doesn't pointlessly spawn a new batch (which wouldn't have helped — the downscaled copy fits in the current atlas just as well as in a fresh one).

    Already-cached entries (matched by id) always report "fits" so a SceneTexture shared by multiple meshes doesn't keep triggering batch overflow.

    Parameters

    • id: string
    • w: number
    • h: number

    Returns "fits" | "would-fit-in-fresh-atlas" | "too-big"

  • If a mip-bearing atlas has level-0 writes pending since the last flush, regenerate the pyramid and clear the dirty flag. Cheap to call when the flag is false (one branch).

    Called by the renderer immediately before binding the atlas for a draw so each atlas pays at most one regeneration per frame regardless of how many slices were written since the previous draw.

    Returns void

  • Re-upload the pixels of an already-added texture, reusing its cached sub-rect placement. Returns true if the entry exists and the upload was issued, false otherwise (id not in this atlas).

    Used by the post-finalize onSceneTextureImageDataChanged path, when a caller has mutated a SceneTexture's imageData and wants the GPU copy refreshed without rebuilding any meshes or materials. The shelf-pack state is left untouched — the atlas doesn't know whether the new pixel buffer's dimensions match the cached placement, so it's the caller's responsibility to ensure the source's width/height haven't changed.

    Parameters

    Returns boolean