Generated tangents
Open the live demo · Read the source · View on GitHub
A normal map is read in the surface's own tangent space, not in world space. Without a tangent that follows the surface, every point samples the map along the same fixed direction, wherever the surface itself is facing.
Step 1: Start from a mesh with no tangent #
The torus is built without asking for a tangent attribute. This is the shape a format with no tangent record, such as OBJ, would hand back.
final MeshData undressed = const TorusShape(
radius: 0.85,
tubeRadius: 0.34,
segments: 40,
tubeSegments: 20,
).build(layout: VertexLayout.positionNormalTexcoord);
Step 2: Two ways to add one back #
convertedTo fills a missing tangent with the same constant value at every
vertex, because that is the only safe default for an attribute it knows
nothing about. withGeneratedTangents instead derives a tangent per vertex
from the UV parametrization, so it turns with the surface.
_flatMesh = undressed.convertedTo(VertexLayout.standard);
_generatedMesh = undressed.withGeneratedTangents(
target: VertexLayout.standard,
);
Step 3: Light both with the same tilted normal map #
The normal map here is one solid, tilted direction, encoded as a single colour. On the torus with generated tangents, that tilt follows the tube around the ring, so the highlight curves with the surface. On the flat-tangent copy the tilt stays locked to one direction in space, and the highlight looks wrong wherever the tube has turned away from it.
final TextureHandle tilt = SolidColorTexture(
Vector4(0.78, 0.5, 0.86, 1.0),
).upload(context.device);
Material bumpMaterial(String name) => Material(
name: name,
baseColor: Vector4(0.6, 0.64, 0.7, 1.0),
metallic: 0.85,
roughness: 0.2,
normal: tilt,
);
Step 4: Check the tangents actually vary #
The page reads the tangent attribute back out of both meshes. The flat copy must hold the same value at every vertex; the generated copy must not.
final int stride = VertexLayout.standard.floatsPerVertex;
final int tangentOffset = VertexLayout.standard.floatOffsetOf(
VertexLayout.tangent.name,
);
bool tangentVaries(MeshData mesh) {
double? firstX;
for (var v = 0; v < mesh.vertexCount; v++) {
final double x = mesh.vertices[v * stride + tangentOffset];
firstX ??= x;
if ((x - firstX).abs() > 1e-4) return true;
}
return false;
}
if (tangentVaries(_flatMesh) ||
!tangentVaries(_generatedMesh) ||
frame.drawCalls < 2) {
throw StateError('the generated tangents did not follow the surface');
}