Screen-Space Ambient Occlusion¶
contact shadows where surfaces meet.
▶ Run in browserTags: 3d
Ambient light reaches a corner, a crevice or the ground under a ball less easily than an open wall. Screen-space ambient occlusion (SSAO) estimates this after the scene is drawn: for each pixel it samples the depth buffer in a small hemisphere around the surface and darkens the shaded colour by how much nearby geometry blocks it. The result is the soft darkening that makes objects sit on the ground instead of floating over it.
SSAO is a post-process, switched on by the scene’s WorldEnvironment:
env = WorldEnvironment(ssao_enabled=True, ssao_normals=True)
ssao_enabledturns the pass on; it is off by default.ssao_normalshas the renderer write real surface normals into a thin G-buffer for SSAO to read, instead of reconstructing them from depth, which removes dark fringes along silhouettes.ssao_radius(world units),ssao_intensityandssao_biastune how far the samples reach, how dark the result gets and how much self-shadowing is suppressed on flat surfaces.
The scene is a courtyard built to occlude: walls meeting the ground, columns
with bases and capitals, a stack of boxes in a corner, balls resting on the
floor, an archway and a flight of steps. Toggle SSAO to see every contact
lose its shadow. The OrbitCamera3D turns slowly on its own until the
player first moves it; toggling SSAO leaves it turning. The button is
Button(action="ssao"), which presses the same action as Space, so one
@on_input("ssao") handler serves both.
Controls: Space / button - Toggle SSAO A / D / drag - Orbit the camera (left / right arrows too) W / S / scroll - Zoom in / out (up / down arrows too) Q / E - Raise / lower the camera
Source¶
1"""Screen-Space Ambient Occlusion: contact shadows where surfaces meet.
2
3Ambient light reaches a corner, a crevice or the ground under a ball less
4easily than an open wall. Screen-space ambient occlusion (SSAO) estimates this
5after the scene is drawn: for each pixel it samples the depth buffer in a small
6hemisphere around the surface and darkens the shaded colour by how much nearby
7geometry blocks it. The result is the soft darkening that makes objects sit on
8the ground instead of floating over it.
9
10SSAO is a post-process, switched on by the scene's ``WorldEnvironment``:
11
12 env = WorldEnvironment(ssao_enabled=True, ssao_normals=True)
13
14- ``ssao_enabled`` turns the pass on; it is off by default.
15- ``ssao_normals`` has the renderer write real surface normals into a thin
16 G-buffer for SSAO to read, instead of reconstructing them from depth, which
17 removes dark fringes along silhouettes.
18- ``ssao_radius`` (world units), ``ssao_intensity`` and ``ssao_bias`` tune how
19 far the samples reach, how dark the result gets and how much self-shadowing
20 is suppressed on flat surfaces.
21
22The scene is a courtyard built to occlude: walls meeting the ground, columns
23with bases and capitals, a stack of boxes in a corner, balls resting on the
24floor, an archway and a flight of steps. Toggle SSAO to see every contact
25lose its shadow. The ``OrbitCamera3D`` turns slowly on its own until the
26player first moves it; toggling SSAO leaves it turning. The button is
27``Button(action="ssao")``, which presses the same action as Space, so one
28``@on_input("ssao")`` handler serves both.
29
30Controls:
31 Space / button - Toggle SSAO
32 A / D / drag - Orbit the camera (left / right arrows too)
33 W / S / scroll - Zoom in / out (up / down arrows too)
34 Q / E - Raise / lower the camera
35"""
36
37import math
38
39from simvx.core import (
40 Anchor,
41 Axis1D,
42 Button,
43 CanvasLayer,
44 DirectionalLight3D,
45 Input,
46 Key,
47 KeyEvent,
48 Label,
49 Material,
50 Mesh,
51 MeshInstance3D,
52 Node3D,
53 OrbitCamera3D,
54 PointerEvent,
55 PointerPhase,
56 PointLight3D,
57 ScrollEvent,
58 WorldEnvironment,
59 on_input,
60)
61from simvx.graphics import App
62
63KEY_ORBIT_SPEED = math.radians(40) # radians per second
64ZOOM_SPEED = 10.0 # world units per second
65IDLE_SPIN = math.radians(4)
66
67
68class SSAODemo(Node3D):
69 input_context = {
70 "ssao": [Key.SPACE],
71 "orbit": [Axis1D(low=Key.A, high=Key.D), Axis1D(low=Key.LEFT, high=Key.RIGHT)],
72 "zoom": [Axis1D(low=Key.S, high=Key.W), Axis1D(low=Key.DOWN, high=Key.UP)],
73 "tilt": [Axis1D(low=Key.Q, high=Key.E)],
74 }
75
76 def on_ready(self):
77 self.idle = True
78 self.env = self.add_child(WorldEnvironment(ssao_enabled=True, ssao_normals=True, bloom_enabled=True))
79 self.camera = self.add_child(
80 OrbitCamera3D(
81 pivot=(0, 2.5, -2),
82 distance=20,
83 yaw=math.radians(55),
84 pitch=math.radians(-16),
85 min_distance=8,
86 max_distance=35,
87 min_pitch=math.radians(-60),
88 max_pitch=math.radians(-2),
89 fov=55,
90 )
91 )
92 self.add_child(DirectionalLight3D(colour=(1.0, 0.95, 0.85), intensity=1.2, direction=(0.32, -0.77, -0.56)))
93 self.add_child(PointLight3D(colour=(0.4, 0.5, 0.8), intensity=0.6, range=25.0, position=(6, 6, 6)))
94 self.build_courtyard()
95
96 hud = self.add_child(CanvasLayer())
97 self.button = hud.add_child(Button("SSAO", action="ssao", checked=True, anchor=Anchor.TOP_LEFT, inset=12))
98
99 # Example help overlay: not part of the technique shown.
100 help_text = "Space: SSAO A/D / drag: orbit W/S / scroll: zoom Q/E: raise / lower"
101 hud.add_child(Label(help_text, anchor=Anchor.BOTTOM_LEFT, inset=12, font_size=14))
102
103 def build_courtyard(self):
104 """Walls, columns, a box stack, balls, an arch and steps: every one a place where surfaces meet."""
105 wall = Material(colour=(0.7, 0.68, 0.65), roughness=0.85)
106 stone = Material(colour=(0.6, 0.58, 0.55), metallic=0.1, roughness=0.7)
107 dark = Material(colour=(0.35, 0.33, 0.30), roughness=0.95)
108 orange = Material(colour=(0.85, 0.4, 0.15), metallic=0.3, roughness=0.4)
109 chrome = Material(colour=(0.8, 0.82, 0.85), metallic=0.9, roughness=0.1)
110
111 def add(mesh, material, position):
112 self.add_child(MeshInstance3D(mesh=mesh, material=material, position=position))
113
114 add(Mesh.box((20, 0.1, 20)), Material(colour=(0.3, 0.3, 0.32), roughness=0.9), (0, -0.05, 0))
115 add(Mesh.box((12, 6, 0.3)), wall, (0, 3, -6))
116 for x in (-6, 6):
117 add(Mesh.box((0.3, 6, 6)), wall, (x, 3, -3))
118
119 column, block = Mesh.cylinder(0.25, 5.5, segments=16), Mesh.box((0.7, 0.3, 0.7))
120 for x in (-4, -2, 0, 2, 4):
121 add(column, stone, (x, 2.75, -5.5))
122 add(block, dark, (x, 0.15, -5.5))
123 add(block, dark, (x, 5.35, -5.5))
124
125 crate = Mesh.box(0.9)
126 stack = [
127 (-4.5, 0.5, -4.5),
128 (-4.5, 1.5, -4.5),
129 (-3.5, 0.5, -4.5),
130 (-4.5, 0.5, -3.5),
131 (-4, 0.5, -4),
132 (-4, 1.5, -4),
133 ]
134 for i, position in enumerate(stack):
135 add(crate, orange if i % 3 == 0 else dark, position)
136
137 for i in range(5):
138 radius = 0.4 + (i % 3) * 0.15
139 add(
140 Mesh.sphere(radius, rings=16, segments=24), chrome if i % 2 == 0 else orange, (-2 + i * 1.5, radius, -2)
141 )
142
143 arch_column = Mesh.cylinder(0.3, 4.0, segments=16)
144 for x in (-1.5, 1.5):
145 add(arch_column, stone, (x, 2, 0))
146 add(Mesh.box((3.6, 0.4, 0.6)), wall, (0, 4.2, 0))
147
148 step = Mesh.box((2, 0.3, 0.8))
149 for i in range(4):
150 add(step, dark, (4, 0.15 + i * 0.3, -3 + i * 0.8))
151
152 @on_input("ssao")
153 def toggle_ssao(self, _event):
154 self.env.ssao_enabled = not self.env.ssao_enabled
155 self.button.checked = self.env.ssao_enabled
156 return True
157
158 @on_input
159 def take_camera(self, event):
160 # The first key, press or scroll nothing claimed hands the camera to the player.
161 # The SSAO toggle claims its press, so the view keeps turning while the reader compares.
162 match event:
163 case KeyEvent(pressed=True) | PointerEvent(phase=PointerPhase.DOWN) | ScrollEvent():
164 self.idle = False
165
166 def on_update(self, dt):
167 spin = -IDLE_SPIN if self.idle else 0.0
168 self.camera.orbit(
169 (Input.value("orbit") * KEY_ORBIT_SPEED + spin) * dt, Input.value("tilt") * KEY_ORBIT_SPEED * dt
170 )
171 self.camera.zoom(Input.value("zoom") * ZOOM_SPEED * dt)
172
173
174if __name__ == "__main__":
175 App(title="SSAO", width=1280, height=720).run(SSAODemo())