Taos Engine ▦ Taos: Building a Modern WebGPU Game Engine

Chapter 5: Textures

Textures provide surface detail beyond what geometry alone can express — albedo, normals, packed material channels, environment maps, and volumetric data. This chapter covers how Taos creates, loads, and packs textures; the next chapter (06-Materials) shows how materials bundle them with shaders and parameters for the renderer to consume.

5.1 2D, 3D, and Cube Textures#

Taos supports three texture dimensionalities:

2D, Cube, and 3D textures

2D textures are the most common — albedo maps, normal maps, roughness/metallic/emissive (MER) maps, and shadow maps. They are created with a single width × height size.

Cube textures are used for the sky and image-based lighting (IBL). A cube texture has 6 array layers (one per face: +X, -X, +Y, -Y, +Z, -Z):

// ── from src/renderer/render_graph/passes/sky_texture_pass.ts ──
// HDR sky cubemap texture
const skyTexture = device.createTexture({
  label: 'SkyCubemap',
  size: { width: 1024, height: 1024, depthOrArrayLayers: 6 },
  format: 'rgba16float',
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
});

Cube textures are sampled in WGSL with texture_cube<f32> using a direction vector, which can be thought of as a ray from the center of the cube that intersects through a particular texel of a side of the cube:

// ── from src/shaders/deferred_lighting.wgsl ──
@group(3) @binding(0) var sky_cube: texture_cube<f32>;
// ...
let skyColor = textureSample(sky_cube, sampler, direction);

3D textures are used for volumetric data like cloud noise. They have a depth dimension in addition to width and height. They are sampled in WGSL with a texture_3d<f32> using a normalized 3d coordinate in the range 0.0 to 1.0.

// ── from src/shaders/clouds.wgsl ──
@group(0) @binding(0) var volumeSampler: sampler;
@group(0) @binding(1) var volumeTexture3D: texture_3d<f32>;
// ...
let volumeCoords = vec3<f32>(0.5, 0.5, 0.5); // sample the center of the volume
let color: vec4<f32> = textureSample(volumeTexture3D, mySvolumeSamplerampler, volumeCoords);

5.2 Texture Loading#

Runtime Loading#

Textures are loaded at runtime using the browser's built-in image decoding. Taos's texture loading pipeline:

  1. Fetch the image file via fetch() (typically as a Blob or URL).
  2. Decode using createImageBitmap() or <img> element loading.
  3. Upload pixel data to a GPUTexture via queue.writeTexture() or copyExternalImageToTexture().

HDR environment maps (.hdr files) use a custom RGBE decoder (src/shaders/rgbe_decode.wgsl) that converts the Radiance HDR format to floating-point values on the GPU.

HDR / RGBE Environment Maps#

HDR environment maps (.hdr files) store a 360° panoramic image of a real-world lighting environment in the Radiance RGBE format — three mantissa bytes (R, G, B) plus a shared exponent (E), packing high dynamic range into 32 bits per pixel. Taos's hdr_loader.ts parses the file on the CPU and decodes it to floating-point on the GPU via a compute shader.

File Format Parsing#

The .hdr file is a text header followed by binary pixel data. The parser reads scanlines with ASCII helpers:

// ── from src/assets/hdr_loader.ts ──
const magic = readAsciiLine();
if (!magic.startsWith('#?RADIANCE') && !magic.startsWith('#?RGBE'))
  throw Error('Not a Radiance HDR file');

// Skip header key=value lines until blank line
while (readAsciiLine().length > 0) {}

// Resolution line: -Y height +X width
const m = readAsciiLine().match(/-Y\s+(\d+)\s+\+X\s+(\d+)/);
const height = parseInt(m[1], 10);
const width  = parseInt(m[2], 10);

Pixel data uses two encoding schemes detected at scanline granularity. The new RLE format stores each channel (R, G, B, E) as a separate interleaved RLE stream, flagged by the scanline prefix [2, 2, W>>8, W&255]:

// ── from src/assets/hdr_loader.ts ──
if (r === 2 && g === 2 && (b & 0x80) === 0) {
  // New RLE scanline: 4 independent RLE streams (R, G, B, E)
  const sw = (b << 8) | e;  // stored width, must match header
  readNewScanline(y);
}

Each channel stream uses run-length encoding where code > 128 means repeat the next byte code - 128 times:

// ── from src/assets/hdr_loader.ts ──
while (x < width) {
  const code = bytes[pos++];
  if (code > 128) {
    const count = code - 128;
    dst.fill(bytes[pos++], x, x + count);
    x += count;
  } else {
    dst.set(bytes.subarray(pos, pos + code), x);
    pos += code;  x += code;
  }
}

The old/uncompressed format stores raw RGBE quads, with [1, 1, 1, count] sequences indicating a pixel-repeat run.

GPU-Accelerated RGBE Decoding#

Rather than computing Math.pow(2, e - 128) per pixel on the CPU, the raw RGBE bytes are uploaded as an rgba8uint texture and decoded to rgba16float via a compute shader (src/shaders/rgbe_decode.wgsl):

// ── from src/assets/hdr_loader.ts ──
// Upload raw RGBE bytes as uint texture — CPU does no math
const srcTex = device.createTexture({
  format: 'rgba8uint',
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
});
device.queue.writeTexture(
  { texture: srcTex },
  data.buffer, { bytesPerRow: width * 4 }, { width, height },
);

// Decode via compute shader → rgba16float
const dstTex = device.createTexture({
  format: 'rgba16float',
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.STORAGE_BINDING,
});

pass.setPipeline(pipeline);
pass.setBindGroup(0, srcBG);  // rgba8uint input
pass.setBindGroup(1, dstBG);  // rgba16float output
pass.dispatchWorkgroups(ceil(width / 8), ceil(height / 8));

The decode shader expands each RGBE texel to floating-point radiance in a single arithmetic instruction:

@compute @workgroup_size(8, 8)
fn cs_decode(@builtin(global_invocation_id) id: vec3u) {
  let rgbe = textureLoad(srcTex, id.xy, 0);
  let exponent = f32(rgbe.a * 255u) - 128.0;
  textureStore(dstTex, id.xy, vec4f(vec3f(rgbe.rgb) * pow(2.0, exponent), 1.0));
}

The compute pipeline and bind group layouts are cached per-device via a WeakMap, so repeated HDR loads reuse the same compiled shader.

Equirectangular to Cubemap Extraction#

The decoded 2D equirectangular HDR texture is typically converted to a cubemap for skybox rendering and IBL pre-filtering. Taos renders six fullscreen quads, each sampling the equirectangular texture with spherical coordinates derived from the cubemap face direction:

// In the cubemap-face render pass
let dir = normalize(cubeFaceDir);
let u = 0.5 + atan2(dir.z, dir.x) / (2.0 * PI);
let v = 0.5 - asin(clamp(dir.y, -1.0, 1.0)) / PI;
let radiance = textureSample(equirectTex, sampler, vec2f(u, v));

Equirectangular HDR panorama mapped through a direction vector and spherical UV formulas into the six faces of an unfolded cubemap

This face-by-face rendering produces a 6-layer cube texture used as the sky background and as the source for the IBL irradiance and prefiltered environment maps.

OpenEXR Environment Maps#

Radiance .hdr is the classic HDR format, but professional content pipelines (rendered panoramas, light probes baked in DCC tools) far more often ship OpenEXR (.exr), which stores true 16-bit half or 32-bit float channels rather than RGBE's shared exponent. Taos's exr_loader.ts decodes EXR entirely on the CPU into a linear Float32Array, then uploads it as the same rgba16float 2D texture (with a full mip pyramid) that createHdrTexture produces — so an EXR environment drops straight into the IBL prefilter and sky-panorama paths with no downstream changes.

Format Coverage#

EXR is a far richer container than Radiance HDR, so the loader parses the scanline header (channels, compression, data window, line order) and decodes the supported subset:

  • ChannelsHALF (f16), FLOAT (f32), and UINT. Channels named R/G/B/A map to the output; a missing alpha defaults to 1.0, and a lone luminance channel Y is replicated to RGB for grayscale maps.
  • CompressionNONE, RLE, ZIP, ZIPS, PXR24, and PIZ. ZIP/ZIPS/PXR24 blocks are zlib streams decoded via the browser's native DecompressionStream('deflate') (no third-party inflate dependency); PIZ is a faithful port of the reference wavelet + Huffman decoder.
  • Unsupported — tiled, deep, and multi-part files throw with an actionable message ("re-export as scanline"), as do the lossy B44/DWAA/DWAB codecs.

Half samples are expanded to float through a precomputed 65,536-entry lookup table, avoiding a per-pixel Math.pow on multi-megapixel panoramas:

// ── from src/assets/exr_loader.ts ──
const lut = new Float32Array(65536);
for (let h = 0; h < 65536; h++) {
  const sign = (h & 0x8000) ? -1 : 1;
  const exp  = (h & 0x7c00) >> 10;
  const mant = h & 0x03ff;
  // subnormal / inf-nan / normal cases ...
  lut[h] = sign * Math.pow(2, exp - 15) * (1 + mant / 1024);
}

One Loader, Either Format#

Because both decoders emit an identical rgba16float 2D texture, environment_texture.ts exposes a single entry point that dispatches on the file extension — calling code never has to branch on the format:

// ── from src/assets/environment_texture.ts ──
export async function loadEnvironmentTexture(device: GPUDevice, url: string): Promise<Texture> {
  const buffer = await (await fetch(url)).arrayBuffer();
  if (/\.exr(\?|#|$)/i.test(url)) {
    return createExrTexture(device, await parseExr(buffer));
  }
  return createHdrTexture(device, parseHdr(buffer));
}

The decoded EXR texture is then projected face-by-face into a cubemap exactly like the Radiance path above, feeding the same sky background and IBL pre-filtering.

Block Texture Atlas#

Voxel games face a unique texturing challenge: there can be hundreds of unique block types, each with up to 6 face textures. Rather than binding individual textures per chunk, Taos packs all block textures into a texture atlas. Each block face stores its UV offset and scale within the atlas as part of its material parameters, so a fragment's local UV gets remapped into the atlas at sample time:

Block atlas with per-tile UV remapping

The atlas is built at development time via npm run build-atlas, which runs scripts/build_atlas.js.

Multiple atlas textures exist for different channel groups — all four are sampled in parallel at the same UV per fragment:

Four parallel atlases sampled at the same UV

Atlas Channels Format
Color atlas sRGB albedo Compressed or rgba8unorm
Normal atlas Tangent-space normals rgba8unorm
MER atlas Metallic (R), Emissive (G), Roughness (B) rgba8unorm
Heightmap atlas Parallax/height data r8unorm

The chunk geometry pass samples the atlas using per-vertex UV coordinates combined with per-face atlas tile parameters:

let uv = input.uv * material.uvScale + material.uvOffset;
let albedo = textureSample(albedo_map, mat_samp, uv);

5.3 Summary#

Textures are the surface-detail substrate the material system builds on:

  • Dimensionality — 2D (albedo, normal, MER, shadow maps), cube (sky, IBL), and 3D (volumetric cloud noise), each sampled by the matching WGSL texture type.
  • Runtime loading decodes images via createImageBitmap and uploads them with queue.writeTexture / copyExternalImageToTexture.
  • HDR / RGBE environment maps are parsed on the CPU and decoded to rgba16float on the GPU, then projected face-by-face into a cubemap for the sky and IBL pre-filtering.
  • OpenEXR (.exr) environment maps decode HALF/FLOAT channels with NONE/RLE/ZIP/ZIPS/PXR24/PIZ compression into the same rgba16float texture, and share a single extension-dispatched loader with the Radiance path.
  • The block atlas packs hundreds of voxel textures into a few channel atlases (color, normal, MER, height) with per-tile UV remapping.

How these textures are bound into materials — and how the deferred G-Buffer encodes the surface properties sampled from them — is the subject of Chapter 6: Materials.

Further reading:

  • src/assets/texture.tsTexture creation and upload helpers
  • src/assets/hdr_loader.ts — Radiance HDR/RGBE parser + GPU decode
  • src/shaders/rgbe_decode.wgsl — RGBE → rgba16float compute shader
  • src/assets/exr_loader.ts — OpenEXR parser (NONE/RLE/ZIP/ZIPS/PXR24/PIZ) → rgba16float texture
  • src/assets/environment_texture.tsloadEnvironmentTexture extension dispatch (.hdr / .exr)
  • scripts/build_atlas.js — block texture atlas builder