Calculate Tangents for BufferGeometry in Three.js
This article explains how to manually calculate and assign tangent
vectors to a Three.js BufferGeometry to enable proper
normal mapping. You will learn how to use the built-in Three.js utility
library to compute tangents automatically using the industry-standard
MikkTSpace algorithm, as well as how to write a custom mathematical loop
to compute and assign 4D tangent attributes manually.
Why Tangents are Necessary for Normal Mapping
Normal maps store perturbation vectors in “tangent space” rather than world or object space. To transform these normal map vectors into world space during rendering, the fragment shader needs three vectors per vertex to establish a coordinate frame: the Normal, the Tangent, and the Bitangent (often calculated dynamically in the shader using the cross product of the normal and tangent).
In Three.js, tangent vectors are represented as 4-component vectors
(x, y, z, w). The w component (either
1 or -1) defines the “handedness” of the
coordinate system, which determines how the bitangent is oriented.
Method 1: Using BufferGeometryUtils (Recommended)
The most reliable way to manually trigger tangent generation in
Three.js is using BufferGeometryUtils. This utility uses
the MikkTSpace algorithm, which ensures your normal
maps align perfectly with baking software like Substance Painter or
Blender.
First, import the utility and the MikkTSpace library (packaged with Three.js):
import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js';
import { createLWJSInterface } from 'three/examples/jsm/libs/meshopt_decoder.module.js'; // or a standard MikkTSpace loaderTo compute and assign the tangents, call
computeMikkTSpaceTangents:
// Ensure your geometry has 'position', 'normal', and 'uv' attributes
BufferGeometryUtils.computeMikkTSpaceTangents(geometry, MikkTSpace);This automatically generates and assigns a 4-component
tangent attribute to your BufferGeometry.
Method 2: Pure Mathematical Calculation (Custom Implementation)
If you cannot use external utilities, you can calculate the tangents manually by looping through the triangles of your geometry. The formula uses the UV coordinates and vertex positions to find the direction of the U texture coordinate relative to the 3D triangle.
Here is the complete JavaScript function to manually calculate and assign tangents:
function calculateTangents(geometry) {
const positionAttribute = geometry.attributes.position;
const normalAttribute = geometry.attributes.normal;
const uvAttribute = geometry.attributes.uv;
const indexAttribute = geometry.index;
const vertexCount = positionAttribute.count;
const tangents = new Float32Array(vertexCount * 4);
// Temporary arrays to accumulate tangents and bitangents
const tan1 = new Array(vertexCount).fill(null).map(() => new THREE.Vector3());
const tan2 = new Array(vertexCount).fill(null).map(() => new THREE.Vector3());
const p1 = new THREE.Vector3();
const p2 = new THREE.Vector3();
const p3 = new THREE.Vector3();
const uv1 = new THREE.Vector2();
const uv2 = new THREE.Vector2();
const uv3 = new THREE.Vector2();
const sdir = new THREE.Vector3();
const tdir = new THREE.Vector3();
const triangleCount = indexAttribute ? indexAttribute.count / 3 : vertexCount / 3;
for (let i = 0; i < triangleCount; i++) {
let i1, i2, i3;
if (indexAttribute) {
i1 = indexAttribute.getX(i * 3);
i2 = indexAttribute.getX(i * 3 + 1);
i3 = indexAttribute.getX(i * 3 + 2);
} else {
i1 = i * 3;
i2 = i * 3 + 1;
i3 = i * 3 + 2;
}
p1.fromBufferAttribute(positionAttribute, i1);
p2.fromBufferAttribute(positionAttribute, i2);
p3.fromBufferAttribute(positionAttribute, i3);
uv1.fromBufferAttribute(uvAttribute, i1);
uv2.fromBufferAttribute(uvAttribute, i2);
uv3.fromBufferAttribute(uvAttribute, i3);
const x1 = p2.x - p1.x;
const x2 = p3.x - p1.x;
const y1 = p2.y - p1.y;
const y2 = p3.y - p1.y;
const z1 = p2.z - p1.z;
const z2 = p3.z - p1.z;
const s1 = uv2.x - uv1.x;
const s2 = uv3.x - uv1.x;
const t1 = uv2.y - uv1.y;
const t2 = uv3.y - uv1.y;
const r = 1.0 / (s1 * t2 - s2 * t1);
sdir.set(
(t2 * x1 - t1 * x2) * r,
(t2 * y1 - t1 * y2) * r,
(t2 * z1 - t1 * z2) * r
);
tdir.set(
(s1 * x2 - s2 * x1) * r,
(s1 * y2 - s2 * y1) * r,
(s1 * z2 - s2 * z1) * r
);
tan1[i1].add(sdir);
tan1[i2].add(sdir);
tan1[i3].add(sdir);
tan2[i1].add(tdir);
tan2[i2].add(tdir);
tan2[i3].add(tdir);
}
const n = new THREE.Vector3();
const t = new THREE.Vector3();
const temp = new THREE.Vector3();
for (let i = 0; i < vertexCount; i++) {
n.fromBufferAttribute(normalAttribute, i);
t.copy(tan1[i]);
// Gram-Schmidt orthogonalization
temp.copy(n).multiplyScalar(n.dot(t));
t.sub(temp).normalize();
// Calculate handedness (w component)
temp.crossVectors(n, t);
const w = (temp.dot(tan2[i]) < 0.0) ? -1.0 : 1.0;
tangents[i * 4] = t.x;
tangents[i * 4 + 1] = t.y;
tangents[i * 4 + 2] = t.z;
tangents[i * 4 + 3] = w;
}
// Assign the attribute to your geometry
geometry.setAttribute('tangent', new THREE.BufferAttribute(tangents, 4));
}Activating Tangent Vectors in Materials
Once you have calculated and assigned your tangents using either
method, you must instruct your MeshStandardMaterial or
MeshPhysicalMaterial to read them.
Three.js automatically detects the tangent attribute if
it exists on the geometry. However, you should ensure that your texture
mapping parameters are configured correctly:
const normalMap = new THREE.TextureLoader().load('path/to/normalMap.png');
const material = new THREE.MeshStandardMaterial({
map: colorMap,
normalMap: normalMap,
normalScale: new THREE.Vector2(1, 1) // Adjust intensity if needed
});With the tangent attribute added to your
BufferGeometry, Three.js will bypass its default
screen-space derivative approximation for normal mapping and use your
precise, calculated vertex tangents.