IBL¶
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())