Normal mapping

vertex tangents and the derivative fallback.

▶ Run in browser

Tags: 3d

A normal map adds surface detail without adding geometry: each texel stores a direction in tangent space, and the shader bends the surface normal by it before lighting. Give a Material the map through normal_map, which takes a file path, PNG bytes or an RGBA uint8 array:

Material(colour=(0.55, 0.6, 0.7, 1.0), roughness=0.45, normal_map=normal_map)

Turning a tangent-space direction into a world one needs a tangent frame at every point. Both spheres here share one material, whose normal map is built with NumPy as diagonal sine ridges, and differ only in where that frame comes from:

  • The left sphere is a plain Mesh.sphere() with no tangent data, so the renderer reconstructs a frame per pixel from screen-space derivatives of the position and UV. It needs nothing from you, but it is faceted per pixel quad.

  • The right sphere calls Mesh.generate_tangents(), which computes per-vertex tangents from the UVs. The renderer then uses them, the same path a glTF model with a TANGENT accessor takes, and the frame is smooth and does not depend on the view.

Both spheres spin slowly about their poles so the difference shows as the ridges sweep past, and a Text3D under each one names its frame.

Source

 1# /// simvx
 2# web = { width = 1280, height = 720 }
 3# ///
 4"""Normal mapping: vertex tangents and the derivative fallback.
 5
 6A normal map adds surface detail without adding geometry: each texel stores a
 7direction in *tangent space*, and the shader bends the surface normal by it
 8before lighting. Give a ``Material`` the map through ``normal_map``, which takes
 9a file path, PNG bytes or an RGBA ``uint8`` array:
10
11    Material(colour=(0.55, 0.6, 0.7, 1.0), roughness=0.45, normal_map=normal_map)
12
13Turning a tangent-space direction into a world one needs a tangent frame at
14every point. Both spheres here share one material, whose normal map is built
15with NumPy as diagonal sine ridges, and differ only in where that frame comes
16from:
17
18- The left sphere is a plain ``Mesh.sphere()`` with no tangent data, so the
19  renderer reconstructs a frame per pixel from screen-space derivatives of the
20  position and UV. It needs nothing from you, but it is faceted per pixel quad.
21- The right sphere calls ``Mesh.generate_tangents()``, which computes per-vertex
22  tangents from the UVs. The renderer then uses them, the same path a glTF
23  model with a ``TANGENT`` accessor takes, and the frame is smooth and does
24  not depend on the view.
25
26Both spheres spin slowly about their poles so the difference shows as the
27ridges sweep past, and a ``Text3D`` under each one names its frame.
28"""
29
30import numpy as np
31
32from simvx.core import Camera3D, DirectionalLight3D, Material, Mesh, MeshInstance3D, Node3D, Text3D
33from simvx.graphics import App
34
35SPIN_SPEED = 0.35  # radians per second
36
37
38def make_ridge_normal_map(size: int = 256, ridges: int = 12, strength: float = 0.9) -> np.ndarray:
39    """Tangent-space normal map of diagonal sine ridges, as RGBA uint8.
40
41    The ridges run along u + v, so their shading shows which way the tangent
42    frame that lit them is oriented.
43    """
44    v, u = np.mgrid[0:size, 0:size].astype(np.float32) / size
45    # Height is cos(phase), so the slope along u and along v is -sin(phase).
46    slope = -np.sin((u + v) * ridges * 2.0 * np.pi) * strength
47    n = np.stack([slope, slope, np.ones_like(slope)], axis=-1)
48    n /= np.linalg.norm(n, axis=-1, keepdims=True)
49    rgb = ((n * 0.5 + 0.5) * 255.0 + 0.5).astype(np.uint8)
50    alpha = np.full((size, size, 1), 255, dtype=np.uint8)
51    return np.concatenate([rgb, alpha], axis=-1)
52
53
54class NormalMappingScene(Node3D):
55    def on_ready(self):
56        self.add_child(Camera3D(position=(0, 1.2, 4.2), fov=55, look_at=(0, 0, 0)))
57        self.add_child(DirectionalLight3D(colour=(1.0, 0.97, 0.9), intensity=1.4, direction=(4, -5, -6)))
58
59        material = Material(
60            colour=(0.55, 0.6, 0.7, 1.0), roughness=0.45, metallic=0.1, normal_map=make_ridge_normal_map()
61        )
62        derivative = Mesh.sphere(radius=1.0, rings=32, segments=32)
63        tangent = Mesh.sphere(radius=1.0, rings=32, segments=32).generate_tangents()
64
65        self.spheres = []
66        for x, mesh, caption in ((-1.3, derivative, "derivative TBN"), (1.3, tangent, "vertex tangents")):
67            self.spheres.append(self.add_child(MeshInstance3D(mesh=mesh, material=material, position=(x, 0, 0))))
68            self.add_child(Text3D(text=caption, font_scale=1.4, position=(x, -1.35, 0)))
69
70    def on_update(self, dt):
71        for sphere in self.spheres:
72            sphere.rotate_y(SPIN_SPEED * dt)
73
74
75if __name__ == "__main__":
76    App(title="Normal Mapping", width=1280, height=720).run(NormalMappingScene())