Sampling Normal Maps

Sampling and Unpacking a Normal Map

Sampling reverses the packing, texture(uBumps, vUV).xyz * 2.0 - 1.0, then tbn * bump rotates the result into world space; Cover Embossing mixes it with the geometric normal only on the covers' front faces. Three conventions trip people up. Maps come OpenGL-style (green up: glTF, Blender 4,391 ) or DirectX-style (green down: Unreal Engine 133 , many texture sites), and the wrong kind lights bumps from below until you negate green. Two-channel compressed maps (BC5, ETC2 RG11) store only x and y, so the shader rebuilds z = sqrt(1.0 - dot(xy, xy)). And glTF's normalTexture.scale multiplies x and y to deepen or flatten the relief.

One embossed nameplate unpacked four ways: OpenGL green-up, a DirectX map read unconverted, z rebuilt from two channels, and scale 0.3HTMLLive
<!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: 4px 2px; }
</style>
<canvas id="c" width="600" height="200"></canvas>
<div class="names"><div>xyz * 2 - 1 (correct)</div><div>DirectX map, green not negated</div><div>RG only: z = sqrt(1 - dot(xy, xy))</div><div>normalTexture.scale 0.3</div></div>
<script>
// Height: a raised nameplate with the store's name, drawn on a canvas; then baked to normals.
const art = Object.assign(document.createElement('canvas'), { width: 160, height: 160 });
const g = art.getContext('2d');
g.fillStyle = '#000'; g.fillRect(0, 0, 160, 160);
g.fillStyle = '#777'; g.fillRect(14, 40, 132, 80);
g.fillStyle = '#fff'; g.font = 'bold 28px Georgia'; g.textAlign = 'center'; g.fillText('BookNest', 80, 90);
for (let i = 0; i < 4; i++) { g.beginPath(); g.arc(24 + (i % 2) * 112, 50 + (i >> 1) * 60, 5, 0, Math.PI * 2); g.fill(); }
const rgba = g.getImageData(0, 0, 160, 160).data;
const normals = new Uint8Array(160 * 160 * 4);
const h = (x, y) => rgba[4 * (Math.min(159, Math.max(0, y)) * 160 + Math.min(159, Math.max(0, x)))] / 255;
for (let y = 0; y < 160; y++) for (let x = 0; x < 160; x++) {
  const n = [(h(x - 1, y) - h(x + 1, y)) * 3, (h(x, y + 1) - h(x, y - 1)) * 3, 1], l = Math.hypot(...n);
  normals.set([...n.map(k => (k / l * 0.5 + 0.5) * 255), 255], 4 * (y * 160 + x));   // OpenGL style: green up
}
const directX = normals.map((v, i) => (i % 4 === 1 ? 255 - v : v));   // the same map, green down

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 = aPos; 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 uBumps; uniform int uMode; out vec4 c;
void main() {
  vec3 n;
  if (uMode == 2) {
    vec2 xy = texture(uBumps, vUV).xy * 2.0 - 1.0;      // a two-channel map: rebuild z
    n = vec3(xy, sqrt(max(0.0, 1.0 - dot(xy, xy))));
  } else {
    n = texture(uBumps, vUV).xyz * 2.0 - 1.0;          // unpack 0..1 to -1..1
    if (uMode == 3) n.xy *= 0.3;                        // glTF normalTexture.scale flattens the relief
  }
  n = normalize(n);
  vec3 l = normalize(vec3(-0.6, 0.7, 0.6));             // light from the upper left
  c = vec4(vec3(0.62, 0.45, 0.28) * (0.2 + 1.0 * max(dot(n, l), 0.0)), 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);
function texture(data) {
  const t = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, t);
  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, 160, 160, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
  gl.generateMipmap(gl.TEXTURE_2D);
  return t;
}
const glMap = texture(normals), dxMap = texture(directX);
gl.clearColor(0.97, 0.96, 0.93, 1); gl.clear(gl.COLOR_BUFFER_BIT);
[glMap, dxMap, glMap, glMap].forEach((t, i) => {
  gl.bindTexture(gl.TEXTURE_2D, t);
  gl.uniform1i(gl.getUniformLocation(program, 'uMode'), i);
  gl.viewport(i * 150 + 5, 5, 140, 190);
  gl.drawArrays(gl.TRIANGLES, 0, 6);
});
</script>