Tangent Space

Tangent Space and the TBN Matrix

The TBN matrix rotates a tangent-space normal into world space; its columns are the tangent T (direction of increasing u), bitangent B (increasing v) and normal N. Models usually carry tangents (glTF's TANGENT, computed with MikkTSpace so bakers and renderers agree). Without them, the fragment shader can derive T and B from the screen-space derivatives dFdx() and dFdy(), Christian Schüler's "cotangent frame":

webgl-helper.js, nineteenth part: a per-pixel tangent frame from derivativesJavaScript
// Section 3.14: GLSL that builds the tangent frame (T, B, N) of a fragment from screen-space
// derivatives of its position and uv, so meshes need no tangent attribute
GLH.tangentFrame = `
  mat3 tangentFrame(vec3 n, vec3 position, vec2 uv) {
    vec3 dp1 = dFdx(position), dp2 = dFdy(position);
    vec2 duv1 = dFdx(uv), duv2 = dFdy(uv);
    vec3 t = cross(dp2, n) * duv1.x + cross(n, dp1) * duv2.x;   // solves for d position / du
    vec3 b = cross(dp2, n) * duv1.y + cross(n, dp1) * duv2.y;   // and d position / dv
    float scale = inversesqrt(max(dot(t, t), dot(b, b)));
    return mat3(t * scale, b * scale, n);
  }`;

tbn * bump then turns a sampled normal into world space. Derivatives exist only in fragment shaders and are undefined inside divergent branches (Texture Lookup Functions), so call it before any if.

A per-pixel tangent frame from dFdx and dFdy on a globe: T, B and N as colours, and a bump pattern rotated by the TBN matrixHTMLLive
<!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; }
  .names div { flex: 1; padding: 4px 2px; }
</style>
<canvas id="c" width="600" height="160"></canvas>
<div class="names"><div>T (increasing u)</div><div>B (increasing v)</div><div>N</div><div>tbn * bump, lit</div></div>
<script>
function sphere(rings, segments) {            // position (= normal) and uv, indexed
  const vertices = [], indices = [];
  for (let i = 0; i <= rings; i++) for (let j = 0; j <= segments; j++) {
    const t = i / rings * Math.PI, p = j / segments * 2 * Math.PI;
    vertices.push(Math.sin(t) * Math.cos(p), Math.cos(t), -Math.sin(t) * Math.sin(p), j / segments, 1 - i / rings);
  }
  for (let i = 0; i < rings; i++) for (let j = 0; j < segments; j++) {
    const a = i * (segments + 1) + j, b = a + segments + 1;
    indices.push(a, b, a + 1, a + 1, b, b + 1);
  }
  return { vertices: new Float32Array(vertices), indices: new Uint16Array(indices) };
}
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 vec3 aPosition; layout(location = 1) in vec2 aUV;
uniform float uTurn; out vec3 vPosition, vNormal; out vec2 vUV;
void main() {
  float c = cos(uTurn), s = sin(uTurn);
  mat3 turn = mat3(c, 0.0, -s, 0.0, 1.0, 0.0, s, 0.0, c);
  vPosition = turn * aPosition; vNormal = vPosition; vUV = aUV;
  gl_Position = vec4(vPosition.xy * 0.85, -vPosition.z * 0.5, 1.0);
}`], [gl.FRAGMENT_SHADER, `#version 300 es
precision highp float;
in vec3 vPosition, vNormal; in vec2 vUV; uniform int uShow; out vec4 c;
// The cotangent frame: solve for d position / du and d position / dv from screen-space derivatives.
mat3 tangentFrame(vec3 n, vec3 position, vec2 uv) {
  vec3 dp1 = dFdx(position), dp2 = dFdy(position);
  vec2 duv1 = dFdx(uv), duv2 = dFdy(uv);
  vec3 t = cross(dp2, n) * duv1.x + cross(n, dp1) * duv2.x;
  vec3 b = cross(dp2, n) * duv1.y + cross(n, dp1) * duv2.y;
  float scale = inversesqrt(max(dot(t, t), dot(b, b)));
  return mat3(t * scale, b * scale, n);
}
void main() {
  vec3 n = normalize(vNormal);
  mat3 tbn = tangentFrame(n, vPosition, vUV);          // before any branch: derivatives need it
  vec2 cell = fract(vUV * vec2(16.0, 8.0)) - 0.5;      // a grid of round studs in tangent space
  vec3 bump = normalize(vec3(-cell * 1.6 * step(length(cell), 0.35), 1.0));
  if (uShow < 3) { c = vec4(normalize(tbn[uShow]) * 0.5 + 0.5, 1.0); return; }
  vec3 bumped = normalize(tbn * bump);                 // tangent space -> world space
  float light = max(dot(bumped, normalize(vec3(-0.5, 0.6, 0.7))), 0.0);
  c = vec4(vec3(0.88, 0.6, 0.06) * (0.2 + 0.9 * light), 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);
const globe = sphere(32, 48);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, globe.vertices, gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 20, 0); gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 20, 12); gl.enableVertexAttribArray(1);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, globe.indices, gl.STATIC_DRAW);
gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE); gl.enable(gl.SCISSOR_TEST);
function frame(ms) {
  gl.uniform1f(gl.getUniformLocation(program, 'uTurn'), ms * 0.0004);
  for (let i = 0; i < 4; i++) {
    gl.viewport(i * 150 + 5, 5, 150, 150); gl.scissor(i * 150, 0, 150, 160);
    gl.clearColor(0.97, 0.96, 0.93, 1);
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
    gl.uniform1i(gl.getUniformLocation(program, 'uShow'), i);
    gl.drawElements(gl.TRIANGLES, globe.indices.length, gl.UNSIGNED_SHORT, 0);
  }
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>