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 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:
- Fetch the image file via
fetch()(typically as aBlobor URL). - Decode using
createImageBitmap()or<img>element loading. - Upload pixel data to a
GPUTextureviaqueue.writeTexture()orcopyExternalImageToTexture().
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));
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:
- Channels —
HALF(f16),FLOAT(f32), andUINT. Channels namedR/G/B/Amap to the output; a missing alpha defaults to 1.0, and a lone luminance channelYis replicated to RGB for grayscale maps. - Compression —
NONE,RLE,ZIP,ZIPS,PXR24, andPIZ. ZIP/ZIPS/PXR24 blocks are zlib streams decoded via the browser's nativeDecompressionStream('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/DWABcodecs.
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:
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:
| 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
createImageBitmapand uploads them withqueue.writeTexture/copyExternalImageToTexture. - HDR / RGBE environment maps are parsed on the CPU and decoded to
rgba16floaton 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 samergba16floattexture, 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.ts—Texturecreation and upload helperssrc/assets/hdr_loader.ts— Radiance HDR/RGBE parser + GPU decodesrc/shaders/rgbe_decode.wgsl— RGBE →rgba16floatcompute shadersrc/assets/exr_loader.ts— OpenEXR parser (NONE/RLE/ZIP/ZIPS/PXR24/PIZ) →rgba16floattexturesrc/assets/environment_texture.ts—loadEnvironmentTextureextension dispatch (.hdr/.exr)scripts/build_atlas.js— block texture atlas builder