SSAO Demo¶
Screen-Space Ambient Occlusion visualization.
▶ Run in browserTags: 3d
Demonstrates:
SSAO effect on a scene with many objects (columns, walls, corners)
Toggle SSAO on/off with Space key to see the difference
PBR materials with ambient occlusion darkening in crevices
Post-processing (HDR + bloom + SSAO)
Run: uv run python examples/features/3d/ssao.py
Controls: Space / Tap - Toggle SSAO on/off Drag - Orbit camera (mouse or touch) A / D - Orbit camera left / right W / S - Zoom in / out Q / E - Raise / lower camera
Source¶
1"""
2SSAO Demo: Screen-Space Ambient Occlusion visualization.
3
4Demonstrates:
5 - SSAO effect on a scene with many objects (columns, walls, corners)
6 - Toggle SSAO on/off with Space key to see the difference
7 - PBR materials with ambient occlusion darkening in crevices
8 - Post-processing (HDR + bloom + SSAO)
9
10Run: uv run python examples/features/3d/ssao.py
11
12Controls:
13 Space / Tap - Toggle SSAO on/off
14 Drag - Orbit camera (mouse or touch)
15 A / D - Orbit camera left / right
16 W / S - Zoom in / out
17 Q / E - Raise / lower camera
18"""
19
20
21import math
22
23from simvx.core import (
24 Camera3D,
25 DirectionalLight3D,
26 Input,
27 InputMap,
28 Key,
29 Material,
30 Mesh,
31 MeshInstance3D,
32 MouseButton,
33 Node3D,
34 PointLight3D,
35 Quat,
36 Text2D,
37 Vec3,
38 WorldEnvironment,
39)
40from simvx.graphics import App
41
42WIDTH, HEIGHT = 1280, 720
43
44
45class SSAOScene(Node3D):
46 def __init__(self, **kwargs):
47 super().__init__(name="SSAODemo", **kwargs)
48
49 # Camera
50 self._cam_angle = 30.0
51 self._cam_height = 8.0
52 self._cam_dist = 18.0
53 self._auto_orbit = True # gentle idle spin until the first camera input
54 self._drag_distance = 0.0 # accumulated pointer travel for tap-vs-drag
55 self.camera = self.add_child(
56 Camera3D(
57 name="Camera",
58 fov=55,
59 near=0.1,
60 far=100.0,
61 )
62 )
63 self._update_camera()
64
65 # Lighting
66 sun = self.add_child(DirectionalLight3D(name="Sun"))
67 sun.colour = (1.0, 0.95, 0.85)
68 sun.intensity = 1.2
69 sun.rotation = Quat.from_euler(math.radians(-50), math.radians(-30), 0)
70
71 fill = self.add_child(
72 PointLight3D(
73 name="Fill",
74 position=Vec3(6, 6, 6),
75 )
76 )
77 fill.colour = (0.4, 0.5, 0.8)
78 fill.intensity = 0.6
79 fill.range = 25.0
80
81 # Ground
82 ground_mat = Material(colour=(0.3, 0.3, 0.32), metallic=0.0, roughness=0.9)
83 self.add_child(
84 MeshInstance3D(
85 name="Ground",
86 mesh=Mesh.cube(1.0),
87 material=ground_mat,
88 position=Vec3(0, -0.05, 0),
89 scale=Vec3(20, 0.1, 20),
90 )
91 )
92
93 # Build a scene with many objects close together to show SSAO
94 self._build_scene()
95
96 # SSAO state via WorldEnvironment
97 self._ssao_on = True
98 self._toggle_cooldown = 0.0
99 self._env = self.add_child(WorldEnvironment(name="Env"))
100 self._env.ssao_enabled = True
101 # Thin G-buffer real normals (A9): SSAO reads the octahedral world normal
102 # from the second HDR attachment instead of reconstructing it from depth,
103 # removing the silhouette artefacts of the derivative path.
104 self._env.ssao_normals = True
105 self._env.bloom_enabled = True
106
107 # HUD
108 self._title = self.add_child(
109 Text2D(
110 text="SSAO DEMO",
111 position=(10, 8), font_scale=1.6,
112 )
113 )
114 self._status = self.add_child(
115 Text2D(
116 text="SSAO: ON",
117 position=(10, 40), font_scale=1.3,
118 )
119 )
120 self._controls = self.add_child(
121 Text2D(
122 text="SPACE/TAP:Toggle SSAO DRAG/WASD/QE:Camera",
123 position=(10, HEIGHT - 30), font_scale=1.1,
124 )
125 )
126
127 def on_ready(self):
128 InputMap.add_action("cam_left", [Key.A, Key.LEFT])
129 InputMap.add_action("cam_right", [Key.D, Key.RIGHT])
130 InputMap.add_action("cam_fwd", [Key.W, Key.UP])
131 InputMap.add_action("cam_back", [Key.S, Key.DOWN])
132 InputMap.add_action("cam_up", [Key.Q])
133 InputMap.add_action("cam_down", [Key.E])
134 InputMap.add_action("toggle_ssao", [Key.SPACE])
135
136 def _build_scene(self):
137 """Create columns, walls, and objects to demonstrate AO in crevices."""
138 wall_mat = Material(colour=(0.7, 0.68, 0.65), metallic=0.0, roughness=0.85)
139 column_mat = Material(colour=(0.6, 0.58, 0.55), metallic=0.1, roughness=0.7)
140 dark_mat = Material(colour=(0.35, 0.33, 0.30), metallic=0.0, roughness=0.95)
141 bright_mat = Material(colour=(0.85, 0.4, 0.15), metallic=0.3, roughness=0.4)
142 metal_mat = Material(colour=(0.8, 0.82, 0.85), metallic=0.9, roughness=0.1)
143
144 # Back wall
145 self.add_child(
146 MeshInstance3D(
147 name="BackWall",
148 mesh=Mesh.cube(1.0),
149 material=wall_mat,
150 position=Vec3(0, 3, -6),
151 scale=Vec3(12, 6, 0.3),
152 )
153 )
154
155 # Side walls
156 for i, x in enumerate([-6, 6]):
157 self.add_child(
158 MeshInstance3D(
159 name=f"SideWall{i}",
160 mesh=Mesh.cube(1.0),
161 material=wall_mat,
162 position=Vec3(x, 3, -3),
163 scale=Vec3(0.3, 6, 6),
164 )
165 )
166
167 # Columns along back wall
168 for i, x in enumerate([-4, -2, 0, 2, 4]):
169 self.add_child(
170 MeshInstance3D(
171 name=f"Column{i}",
172 mesh=Mesh.cylinder(0.25, 5.5, segments=16),
173 material=column_mat,
174 position=Vec3(x, 2.75, -5.5),
175 )
176 )
177 # Column base
178 self.add_child(
179 MeshInstance3D(
180 name=f"ColumnBase{i}",
181 mesh=Mesh.cube(1.0),
182 material=dark_mat,
183 position=Vec3(x, 0.15, -5.5),
184 scale=Vec3(0.7, 0.3, 0.7),
185 )
186 )
187 # Column capital
188 self.add_child(
189 MeshInstance3D(
190 name=f"ColumnCap{i}",
191 mesh=Mesh.cube(1.0),
192 material=dark_mat,
193 position=Vec3(x, 5.35, -5.5),
194 scale=Vec3(0.7, 0.3, 0.7),
195 )
196 )
197
198 # Stacked boxes in corner (shows AO between adjacent surfaces)
199 box_positions = [
200 Vec3(-4.5, 0.5, -4.5),
201 Vec3(-4.5, 1.5, -4.5),
202 Vec3(-3.5, 0.5, -4.5),
203 Vec3(-4.5, 0.5, -3.5),
204 Vec3(-4.0, 0.5, -4.0),
205 Vec3(-4.0, 1.5, -4.0),
206 ]
207 for i, pos in enumerate(box_positions):
208 mat = bright_mat if i % 3 == 0 else dark_mat
209 self.add_child(
210 MeshInstance3D(
211 name=f"StackedBox{i}",
212 mesh=Mesh.cube(1.0),
213 material=mat,
214 position=pos,
215 scale=Vec3(0.9, 0.9, 0.9),
216 )
217 )
218
219 # Spheres on the ground (shows AO at ground contact)
220 for i in range(5):
221 x = -2.0 + i * 1.5
222 r = 0.4 + (i % 3) * 0.15
223 mat = metal_mat if i % 2 == 0 else bright_mat
224 self.add_child(
225 MeshInstance3D(
226 name=f"GroundSphere{i}",
227 mesh=Mesh.sphere(r, rings=16, segments=24),
228 material=mat,
229 position=Vec3(x, r, -2.0),
230 )
231 )
232
233 # Archway (two columns + lintel)
234 for i, x in enumerate([-1.5, 1.5]):
235 self.add_child(
236 MeshInstance3D(
237 name=f"ArchColumn{i}",
238 mesh=Mesh.cylinder(0.3, 4.0, segments=16),
239 material=column_mat,
240 position=Vec3(x, 2.0, 0),
241 )
242 )
243 self.add_child(
244 MeshInstance3D(
245 name="ArchLintel",
246 mesh=Mesh.cube(1.0),
247 material=wall_mat,
248 position=Vec3(0, 4.2, 0),
249 scale=Vec3(3.6, 0.4, 0.6),
250 )
251 )
252
253 # Stepped platform (shows AO on step edges)
254 for i in range(4):
255 self.add_child(
256 MeshInstance3D(
257 name=f"Step{i}",
258 mesh=Mesh.cube(1.0),
259 material=dark_mat,
260 position=Vec3(4.0, 0.15 + i * 0.3, -3.0 + i * 0.8),
261 scale=Vec3(2.0, 0.3, 0.8),
262 )
263 )
264
265 def _update_camera(self):
266 rad = math.radians(self._cam_angle)
267 x = math.cos(rad) * self._cam_dist
268 z = math.sin(rad) * self._cam_dist
269 self.camera.position = Vec3(x, self._cam_height, z)
270 self.camera.look_at(Vec3(0, 2.5, -2))
271
272 def on_fixed_update(self, dt: float):
273 speed = 40.0
274 if Input.is_action_pressed("cam_left"):
275 self._cam_angle += speed * dt
276 self._auto_orbit = False
277 if Input.is_action_pressed("cam_right"):
278 self._cam_angle -= speed * dt
279 self._auto_orbit = False
280 if Input.is_action_pressed("cam_fwd"):
281 self._cam_dist = max(8, self._cam_dist - 10 * dt)
282 self._auto_orbit = False
283 if Input.is_action_pressed("cam_back"):
284 self._cam_dist = min(35, self._cam_dist + 10 * dt)
285 self._auto_orbit = False
286 if Input.is_action_pressed("cam_up"):
287 self._cam_height = min(20, self._cam_height + 6 * dt)
288 self._auto_orbit = False
289 if Input.is_action_pressed("cam_down"):
290 self._cam_height = max(2, self._cam_height - 6 * dt)
291 self._auto_orbit = False
292 # Gentle idle spin until the first camera input so the AO crevice
293 # darkening reads immediately even before the user touches anything.
294 if self._auto_orbit:
295 self._cam_angle += 4.0 * dt
296 self._update_camera()
297
298 def _toggle_ssao(self):
299 if self._toggle_cooldown > 0:
300 return
301 self._ssao_on = not self._ssao_on
302 self._toggle_cooldown = 0.3
303 self._env.ssao_enabled = self._ssao_on
304
305 def on_update(self, dt: float):
306 # Toggle SSAO with cooldown
307 self._toggle_cooldown = max(0, self._toggle_cooldown - dt)
308 if Input.is_action_just_pressed("toggle_ssao"):
309 self._toggle_ssao()
310
311 # Mouse / touch: drag orbits the camera, a short tap (press + release
312 # with little travel) toggles SSAO. Touch arrives as MouseButton.LEFT
313 # on web, so this keeps the demo playable on phones and tablets.
314 if Input.is_mouse_button_just_pressed(MouseButton.LEFT):
315 self._drag_distance = 0.0
316 if Input.is_mouse_button_pressed(MouseButton.LEFT):
317 delta = Input.mouse_delta
318 dx, dy = float(delta.x), float(delta.y)
319 self._drag_distance += abs(dx) + abs(dy)
320 if self._drag_distance > 6.0:
321 self._auto_orbit = False
322 self._cam_angle -= dx * 0.3
323 self._cam_height = max(2, min(20, self._cam_height + dy * 0.05))
324 if Input.is_mouse_button_just_released(MouseButton.LEFT) and self._drag_distance <= 6.0:
325 self._toggle_ssao()
326
327 self._status.text = f"SSAO: {'ON' if self._ssao_on else 'OFF'}"
328 # Pin the controls hint to the live viewport bottom (resize-aware).
329 self._controls.position = (10, self.app.height - 30)
330
331
332def main():
333 scene = SSAOScene()
334 app = App(title="SimVX SSAO Demo", width=WIDTH, height=HEIGHT, physics_fps=60)
335 app.run(scene)
336
337
338if __name__ == "__main__":
339 main()