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":
// 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.
<!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>