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"

  • 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