Normal mapping

vertex-tangent TBN with a derivative fallback.

▶ Run in browser

Tags: 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()