flutter3d
Showcase Changelog 38 packages API reference

Generated tangents

since 0.7.0 Scene and geometry

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');
}