PBR Texture Sets

Baking and Loading PBR Texture Sets

A PBR material usually arrives as a texture set, which glTF 2.0 packs so that one read serves three parameters:

A glTF-style PBR texture set
Map Channels Color space
Base color (+ alpha) RGB(A) sRGB (srgb: true)
Normal tangent-space xyz linear
Occlusion, roughness, metallic ("ORM") R, G, B linear
Emissive RGB sRGB

Load each with GLH.texture(), bind each to its own unit (Multi-Texturing), and read orm.g as roughness and orm.b as metallic. Baking computes such maps offline: normals and occlusion from a high-polygon sculpt, or everything from a procedural graph (PBR Authoring Tools). Ship them as KTX2 (Compressed Textures), UASTC for normal maps.

A leather cover's texture set baked in code (base colour, normal, ORM) and the PBR result it drivesHTMLLive
<!doctype html>
<style>
  body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
  canvas { display: block; width: 100%; max-width: 600px; }
  .names { display: flex; max-width: 600px; text-align: center; font-family: monospace; font-size: 10px; }
  .names div { flex: 1; padding: 2px; }
</style>
<canvas id="c" width="600" height="240"></canvas>
<div class="names"><div>base colour (sRGB)</div><div>normal (linear)</div><div>ORM: occlusion, roughness, metallic</div><div>lit with all three</div></div>
<script>
const W = 128, H = 192;
// "Bake" the set: a height field (leather grain + raised title band) drives normal and occlusion.
const title = Object.assign(document.createElement('canvas'), { width: W, height: H });
const tg = title.getContext('2d');
tg.fillStyle = '#000'; tg.fillRect(0, 0, W, H);
tg.fillStyle = '#fff'; tg.fillRect(12, 40, 104, 3); tg.fillRect(12, 96, 104, 3);
tg.font = 'bold 22px Georgia'; tg.textAlign = 'center'; tg.fillText('Salt and', 64, 66); tg.fillText('Saffron', 64, 90);
const foil = tg.getImageData(0, 0, W, H).data;               // white = gold foil
const noise = (x, y) => { const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453; return s - Math.floor(s); };
const height = (x, y) => { x = Math.min(W - 1, Math.max(0, x)); y = Math.min(H - 1, Math.max(0, y));
  return foil[4 * (y * W + x)] / 255 * 0.6 + noise(x >> 1, y >> 1) * 0.15; };
const base = new ImageData(W, H), normal = new ImageData(W, H), orm = new ImageData(W, H);
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
  const i = 4 * (y * W + x), gold = foil[i] > 128;
  base.data.set(gold ? [224, 170, 60, 255] : [120 + noise(x, y) * 20, 34, 30, 255], i);
  const n = [(height(x - 1, y) - height(x + 1, y)) * 3, (height(x, y + 1) - height(x, y - 1)) * 3, 1], l = Math.hypot(...n);
  normal.data.set([...n.map(k => (k / l * 0.5 + 0.5) * 255), 255], i);
  const occlusion = 1 - Math.max(0, height(x - 2, y) + height(x + 2, y) - 2 * height(x, y)) * 0.8;   // crevices darker
  orm.data.set([occlusion * 255, (gold ? 0.25 : 0.75) * 255, gold ? 255 : 0, 255], i);   // R occlusion, G roughness, B metallic
}

const gl = document.getElementById('c').getContext('webgl2');
const program = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, `#version 300 es
layout(location = 0) in vec2 aPos; out vec2 vUV;
void main() { vUV = vec2(aPos.x, 1.0 - aPos.y); gl_Position = vec4(aPos * 2.0 - 1.0, 0.0, 1.0); }`], [gl.FRAGMENT_SHADER, `#version 300 es
precision highp float;
in vec2 vUV; uniform sampler2D uBase, uNormal, uORM; uniform int uShow; uniform vec3 uLight; out vec4 c;
const float PI = 3.14159265;
void main() {
  if (uShow == 0) { c = vec4(pow(texture(uBase, vUV).rgb, vec3(1.0 / 2.2)), 1.0); return; }   // sRGB view
  if (uShow == 1) { c = texture(uNormal, vUV); return; }
  if (uShow == 2) { c = texture(uORM, vUV); return; }
  vec3 albedo = texture(uBase, vUV).rgb;                 // an SRGB8_ALPHA8 texture: already linear
  vec3 orm = texture(uORM, vUV).rgb;                     // one read, three parameters
  float roughness = orm.g, metallic = orm.b;
  vec3 n = normalize(texture(uNormal, vUV).xyz * 2.0 - 1.0);   // baked with +y = up the image = up the screen
  vec3 p = vec3(vUV.x * 2.0 - 1.0, 1.0 - vUV.y * 2.0, 0.0), v = vec3(0, 0, 1), l = normalize(uLight - p), h = normalize(v + l);
  float nl = max(dot(n, l), 0.0), nv = max(dot(n, v), 1e-4), nh = max(dot(n, h), 0.0), a2 = pow(roughness, 4.0);
  float d = a2 / (PI * pow(nh * nh * (a2 - 1.0) + 1.0, 2.0)), k = (roughness + 1.0) * (roughness + 1.0) / 8.0;
  float g = nl / (nl * (1.0 - k) + k) * nv / (nv * (1.0 - k) + k);
  vec3 f0 = mix(vec3(0.04), albedo, metallic), f = f0 + (1.0 - f0) * pow(1.0 - max(dot(h, v), 0.0), 5.0);
  vec3 color = ((1.0 - f) * (1.0 - metallic) * albedo / PI + d * g * f / (4.0 * nv * max(nl, 1e-4))) * nl * 4.0;
  color += albedo * 0.12 * orm.r + f0 * 0.25 * orm.r;    // ambient, darkened by the occlusion channel
  color = color / (1.0 + color);
  c = vec4(pow(color, vec3(1.0 / 2.2)), 1.0);
}`]]) {
  const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);
  if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
  gl.attachShader(program, s);
}
gl.linkProgram(program); gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1]), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(0);
// Each map on its own unit, with the right colour space: only base colour is sRGB.
[[base, gl.SRGB8_ALPHA8, 'uBase'], [normal, gl.RGBA8, 'uNormal'], [orm, gl.RGBA8, 'uORM']].forEach(([data, format, name], unit) => {
  gl.activeTexture(gl.TEXTURE0 + unit);
  gl.bindTexture(gl.TEXTURE_2D, gl.createTexture());
  gl.texImage2D(gl.TEXTURE_2D, 0, format, gl.RGBA, gl.UNSIGNED_BYTE, data);
  gl.generateMipmap(gl.TEXTURE_2D);
  gl.uniform1i(gl.getUniformLocation(program, name), unit);
});
gl.clearColor(0.97, 0.96, 0.93, 1);
function frame(ms) {
  const a = ms * 0.001;
  gl.uniform3f(gl.getUniformLocation(program, 'uLight'), Math.cos(a) * 1.4, Math.sin(a) * 1.2 + 0.3, 1.0);
  gl.clear(gl.COLOR_BUFFER_BIT);
  for (let i = 0; i < 4; i++) {
    gl.uniform1i(gl.getUniformLocation(program, 'uShow'), i);
    gl.viewport(i * 150 + 15, 5, 120, 230);
    gl.drawArrays(gl.TRIANGLES, 0, 6);
  }
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>