Normal mapping¶
vertex-tangent TBN with a derivative fallback.
▶ Run in browserTags: 3d
Two spheres share one procedurally generated normal map (diagonal ridges).
The left sphere has no tangent data, so the renderer reconstructs a tangent
frame from screen-space derivatives. The right sphere calls
Mesh.generate_tangents(): its authored per-vertex tangents ride the
extras vertex stream and select the tangent normal-mapping pipeline, the
same path glTF models with TANGENT accessors use. Both paths light the
ridges; the vertex-tangent frame is smooth and view-independent where the
derivative frame is faceted per pixel-quad.
Controls: Escape - Quit
Run: uv run python examples/features/3d/normal_mapping.py
Source¶
1"""Normal mapping: vertex-tangent TBN with a derivative fallback.
2
3# /// simvx
4# web = { root = "NormalMappingScene", width = 1280, height = 720 }
5# ///
6
7Two spheres share one procedurally generated normal map (diagonal ridges).
8The left sphere has no tangent data, so the renderer reconstructs a tangent
9frame from screen-space derivatives. The right sphere calls
10``Mesh.generate_tangents()``: its authored per-vertex tangents ride the
11extras vertex stream and select the tangent normal-mapping pipeline, the
12same path glTF models with TANGENT accessors use. Both paths light the
13ridges; the vertex-tangent frame is smooth and view-independent where the
14derivative frame is faceted per pixel-quad.
15
16Controls:
17 Escape - Quit
18
19Run: uv run python examples/features/3d/normal_mapping.py
20"""
21
22import numpy as np
23
24from simvx.core import (
25 Camera3D,
26 DirectionalLight3D,
27 Input,
28 InputMap,
29 Key,
30 Material,
31 Mesh,
32 MeshInstance3D,
33 Node3D,
34 Text2D,
35)
36from simvx.graphics import App
37
38WIDTH, HEIGHT = 1280, 720
39
40
41def make_ridge_normal_map(size: int = 256, ridges: int = 12, strength: float = 0.9) -> np.ndarray:
42 """Tangent-space normal map of diagonal sine ridges, RGBA uint8.
43
44 The ridge direction runs along u+v, so the shading orientation directly
45 exposes which tangent frame reconstructed it.
46 """
47 yy, xx = np.mgrid[0:size, 0:size].astype(np.float32) / size
48 phase = (xx + yy) * ridges * 2.0 * np.pi
49 # Height h = cos(phase); slope along u and v are equal: -sin(phase) * k.
50 slope = -np.sin(phase) * strength
51 n = np.stack([slope, slope, np.ones_like(slope)], axis=-1)
52 n /= np.linalg.norm(n, axis=-1, keepdims=True)
53 rgb = ((n * 0.5 + 0.5) * 255.0 + 0.5).astype(np.uint8)
54 alpha = np.full((size, size, 1), 255, dtype=np.uint8)
55 return np.concatenate([rgb, alpha], axis=-1)
56
57
58class NormalMappingScene(Node3D):
59 def on_ready(self):
60 InputMap.add_action("quit", [Key.ESCAPE])
61
62 self.add_child(Camera3D(position=(0, -4.2, 1.2), fov=55, look_at=(0, 0, 0), up=(0, 0, 1)))
63
64 sun = DirectionalLight3D(position=(-4, -6, 5))
65 sun.colour = (1.0, 0.97, 0.9)
66 sun.intensity = 1.4
67 sun.look_at((0, 0, 0))
68 self.add_child(sun)
69
70 normal_map = make_ridge_normal_map()
71 material = Material(colour=(0.55, 0.6, 0.7, 1.0), roughness=0.45, metallic=0.1, normal_map=normal_map)
72
73 # Left: no tangents -> screen-space derivative TBN.
74 self._derivative_sphere = self.add_child(MeshInstance3D(
75 mesh=Mesh.sphere(radius=1.0, rings=32, segments=32),
76 material=material,
77 position=(-1.3, 0, 0),
78 ))
79
80 # Right: generated per-vertex tangents -> vertex-tangent TBN
81 # (extras vertex stream + tangent pipeline variant).
82 self._tangent_sphere = self.add_child(MeshInstance3D(
83 mesh=Mesh.sphere(radius=1.0, rings=32, segments=32).generate_tangents(),
84 material=material,
85 position=(1.3, 0, 0),
86 ))
87
88 self._left_label = self.add_child(Text2D(text="derivative TBN", align="centre", font_scale=1.4))
89 self._right_label = self.add_child(Text2D(text="vertex tangents", align="centre", font_scale=1.4))
90
91 def on_update(self, dt: float):
92 if Input.is_action_just_pressed("quit"):
93 self.app.quit()
94 return
95 # Slow spin makes the tangent-frame difference read in motion: the
96 # vertex-tangent shading stays smooth while the derivative frame
97 # shows per-pixel-quad faceting as the ridges sweep past.
98 self._derivative_sphere.rotate_z(0.35 * dt)
99 self._tangent_sphere.rotate_z(0.35 * dt)
100 # Pin the comparison labels under each sphere from the live window
101 # size so they stay on screen at any resolution.
102 w, h = self.app.width, self.app.height
103 self._left_label.position = (w * 0.30, h * 0.86)
104 self._right_label.position = (w * 0.70, h * 0.86)
105
106
107def main():
108 scene = NormalMappingScene()
109 app = App(title="SimVX Normal Mapping", width=WIDTH, height=HEIGHT)
110 app.run(scene)
111
112
113if __name__ == "__main__":
114 main()