IBL

Play Demo

IBL (Image-Based Lighting) demo — metallic spheres with environment lighting.

Demonstrates:

  • Procedural skybox cubemap providing ambient IBL

  • IBL pass generating irradiance, prefiltered specular, and BRDF LUT

  • Row of spheres with varying roughness (0.05 to 0.95)

  • Row of spheres with varying metallic (0.0 to 1.0)

  • Directional light for direct illumination

Controls: Escape - Quit

Run: uv run python packages/graphics/examples/3d_ibl.py

Source Code

  1"""IBL (Image-Based Lighting) demo — metallic spheres with environment lighting.
  2
  3Demonstrates:
  4  - Procedural skybox cubemap providing ambient IBL
  5  - IBL pass generating irradiance, prefiltered specular, and BRDF LUT
  6  - Row of spheres with varying roughness (0.05 to 0.95)
  7  - Row of spheres with varying metallic (0.0 to 1.0)
  8  - Directional light for direct illumination
  9
 10Controls:
 11    Escape  - Quit
 12
 13Run: uv run python packages/graphics/examples/3d_ibl.py
 14"""
 15
 16import math
 17
 18from simvx.core import (
 19    Camera3D,
 20    DirectionalLight3D,
 21    Input,
 22    InputMap,
 23    Key,
 24    Material,
 25    Mesh,
 26    MeshInstance3D,
 27    Node,
 28    Text2D,
 29    Vec3,
 30)
 31from simvx.graphics import App
 32
 33SPHERE_COUNT = 7
 34SPACING = 2.5
 35
 36
 37class IBLScene(Node):
 38    def ready(self):
 39        InputMap.add_action("quit", [Key.ESCAPE])
 40
 41        # Generate a deep-blue procedural cubemap and hand it to the renderer.
 42        # The engine owns the cubemap after this call and destroys it at
 43        # shutdown. On backends without a Vulkan device (e.g. the Pyodide web
 44        # runtime), load_cubemap is unavailable and we silently fall back to
 45        # direct lighting only.
 46        self._setup_ibl()
 47
 48        # Camera looking at the sphere rows. Scene is Z-up (ground at Z=-2.5,
 49        # sphere rows at Z=2.0 and Z=-1.5), so pass an explicit ``up=(0,0,1)``
 50        # to ``look_at`` or the default +Y up rolls the camera as it orbits.
 51        cam = self.add_child(Camera3D(
 52            position=(0, -12, 4), fov=55, near=0.1, far=100.0,
 53            look_at=Vec3(0, 0, 1.0), up=Vec3(0, 0, 1),
 54        ))
 55
 56        # Directional light (sun) -- moderate intensity so IBL contribution is visible
 57        sun = DirectionalLight3D(position=(5, -8, 10))
 58        sun.colour = (1.0, 0.98, 0.92)
 59        sun.intensity = 1.0
 60        sun.look_at(Vec3(0, 0, 0))
 61        self.add_child(sun)
 62
 63        # Higher tessellation keeps the sharp end (low roughness) from aliasing
 64        # its specular highlight across individual faces, which reads as a flash.
 65        sphere_mesh = Mesh.sphere(0.9, rings=48, segments=64)
 66
 67        # Top row: varying roughness (metallic = 1.0). Minimum 0.15 so the
 68        # leftmost sphere still shows a tight highlight but the specular lobe
 69        # is wide enough to sample multiple prefiltered-specular mip taps per
 70        # pixel rather than strobing a single mip tap.
 71        for i in range(SPHERE_COUNT):
 72            t = i / max(SPHERE_COUNT - 1, 1)
 73            roughness = 0.15 + t * 0.8
 74            mat = Material(colour=(0.9, 0.6, 0.2, 1.0), metallic=1.0, roughness=roughness)
 75            x = (i - SPHERE_COUNT // 2) * SPACING
 76            self.add_child(MeshInstance3D(mesh=sphere_mesh, material=mat, position=(x, 0, 2.0)))
 77
 78        # Bottom row: varying metallic (roughness = 0.25)
 79        for i in range(SPHERE_COUNT):
 80            t = i / max(SPHERE_COUNT - 1, 1)
 81            mat = Material(colour=(0.8, 0.1, 0.1, 1.0), metallic=t, roughness=0.25)
 82            x = (i - SPHERE_COUNT // 2) * SPACING
 83            self.add_child(MeshInstance3D(mesh=sphere_mesh, material=mat, position=(x, 0, -1.5)))
 84
 85        # Ground plane
 86        ground_mat = Material(colour=(0.15, 0.15, 0.18, 1.0), metallic=0.0, roughness=0.9)
 87        self.add_child(
 88            MeshInstance3D(
 89                mesh=Mesh.cube(1.0),
 90                material=ground_mat,
 91                position=(0, 0, -2.5),
 92                scale=Vec3(25, 25, 0.2),
 93            )
 94        )
 95
 96        # Labels
 97        self.add_child(Text2D(text="IBL Demo: Image-Based Lighting", x=10, y=10, font_scale=1.8))
 98        self.add_child(Text2D(text="Top: Gold (metallic=1.0), roughness 0.15 -> 0.95", x=10, y=45, font_scale=1.2))
 99        self.add_child(Text2D(text="Bottom: Red (roughness=0.25), metallic 0.0 -> 1.0", x=10, y=70, font_scale=1.2))
100        self.add_child(Text2D(text="Skybox provides ambient environment lighting via IBL", x=10, y=695, font_scale=1.0))
101
102        self._time = 0.0
103        self._cam = cam
104
105    def _setup_ibl(self) -> None:
106        """Generate a skybox cubemap + run the IBL precompute passes.
107
108        Uses the public :meth:`Engine.load_cubemap` and
109        :meth:`Renderer.set_skybox` API. Web exports skip this (no
110        Vulkan device) and fall back to direct lighting only.
111        """
112        engine = self.app.engine
113        if engine is None or not hasattr(engine, "load_cubemap"):
114            return  # Web runtime — no Vulkan device
115
116        # Load a deep-blue procedural cubemap.
117        handle = engine.load_cubemap(colour=(0.15, 0.22, 0.35))
118        engine.renderer.set_skybox(handle)
119
120        # Run the IBL precompute (irradiance + prefiltered specular + BRDF LUT)
121        # so metallic spheres pick up environment lighting, not just the
122        # skybox backdrop. The cubemap view/sampler live on the handle.
123        try:
124            from simvx.graphics.renderer.ibl_pass import IBLPass
125        except ImportError:
126            return  # IBL module not bundled (web export)
127
128        ibl = IBLPass(engine)
129        ibl.setup()
130        ibl.process_cubemap(handle.view, handle.sampler)
131        ibl.cleanup()
132
133    def process(self, dt):
134        if Input.is_action_just_pressed("quit"):
135            self.app.quit()
136            return
137        # Gentle horizontal camera orbit in the XY plane (Z stays fixed so the
138        # ground stays at the bottom of the view). Explicit up=(0,0,1)
139        # prevents the default +Y up from rolling the view.
140        self._time += dt * 0.15
141        dist = 14.0
142        self._cam.position = Vec3(
143            math.sin(self._time) * dist,
144            -math.cos(self._time) * dist,
145            4.0,
146        )
147        self._cam.look_at(Vec3(0, 0, 0.5), up=Vec3(0, 0, 1))
148
149
150if __name__ == "__main__":
151    App(title="IBL Demo", width=1280, height=720).run(IBLScene())