Post-processing¶
SSAO, tonemapping, FXAA and depth of field via WorldEnvironment.
â–¶ Run in browserTags: 3d post-process ssao tonemap fxaa dof
Every effect is a WorldEnvironment Property, with the same result on desktop
and in the browser:
ssao_enabledwith tunablessao_radius/ssao_bias/ssao_intensity(a dedicated compute pass).tonemap_mode(aces / neutral / reinhard / uchimura) withtonemap_whiteandtonemap_exposure.fxaa_enabled.dof_enabled, focused bydof_focus_distance/dof_focus_rangeand scaled by the resolution-awaredof_max_coc.
The scene is built for the effects: a packed cluster of cubes and spheres gives SSAO deep crevices to darken, and the outer rings spread over depth so the depth-of-field blur is obvious.
Controls: A / D - Orbit camera left / right W / S - Pitch camera up / down Q / E - Zoom in / out 1 - Toggle SSAO 2 - Cycle tonemap operator 3 - Toggle FXAA 4 - Toggle depth of field Up / Down - SSAO radius +/- Left / Right- SSAO intensity -/+ Escape - Quit
On-screen buttons (mouse / touch) mirror the 1-4 toggles.
Source¶
1"""Post-processing: SSAO, tonemapping, FXAA and depth of field via WorldEnvironment.
2
3# /// simvx
4# tags = ["post-process", "ssao", "tonemap", "fxaa", "dof"]
5# web = { width = 1280, height = 720 }
6# ///
7
8Every effect is a ``WorldEnvironment`` Property, with the same result on desktop
9and in the browser:
10
11 - ``ssao_enabled`` with tunable ``ssao_radius`` / ``ssao_bias`` /
12 ``ssao_intensity`` (a dedicated compute pass).
13 - ``tonemap_mode`` (aces / neutral / reinhard / uchimura) with
14 ``tonemap_white`` and ``tonemap_exposure``.
15 - ``fxaa_enabled``.
16 - ``dof_enabled``, focused by ``dof_focus_distance`` / ``dof_focus_range`` and
17 scaled by the resolution-aware ``dof_max_coc``.
18
19The scene is built for the effects: a packed cluster of cubes and spheres gives
20SSAO deep crevices to darken, and the outer rings spread over depth so the
21depth-of-field blur is obvious.
22
23Controls:
24 A / D - Orbit camera left / right
25 W / S - Pitch camera up / down
26 Q / E - Zoom in / out
27 1 - Toggle SSAO
28 2 - Cycle tonemap operator
29 3 - Toggle FXAA
30 4 - Toggle depth of field
31 Up / Down - SSAO radius +/-
32 Left / Right- SSAO intensity -/+
33 Escape - Quit
34
35On-screen buttons (mouse / touch) mirror the 1-4 toggles.
36"""
37
38import math
39
40import numpy as np
41
42from simvx.core import (
43 AnchorPreset,
44 Button,
45 Camera3D,
46 Colour,
47 DirectionalLight3D,
48 Input,
49 InputMap,
50 Key,
51 Material,
52 Mesh,
53 MeshInstance3D,
54 Node,
55 Panel,
56 Text2D,
57 Vec2,
58 WorldEnvironment,
59)
60from simvx.graphics import App
61
62TONEMAP_MODES = ["aces", "neutral", "reinhard", "uchimura"]
63
64
65class PostProcessParity(Node):
66 def on_ready(self):
67 InputMap.add_action("orbit_left", [Key.A])
68 InputMap.add_action("orbit_right", [Key.D])
69 InputMap.add_action("pitch_up", [Key.W])
70 InputMap.add_action("pitch_down", [Key.S])
71 InputMap.add_action("zoom_in", [Key.Q])
72 InputMap.add_action("zoom_out", [Key.E])
73 InputMap.add_action("toggle_ssao", [Key.KEY_1])
74 InputMap.add_action("cycle_tonemap", [Key.KEY_2])
75 InputMap.add_action("toggle_fxaa", [Key.KEY_3])
76 InputMap.add_action("toggle_dof", [Key.KEY_4])
77 InputMap.add_action("radius_up", [Key.UP])
78 InputMap.add_action("radius_down", [Key.DOWN])
79 InputMap.add_action("intensity_up", [Key.RIGHT])
80 InputMap.add_action("intensity_down", [Key.LEFT])
81 InputMap.add_action("quit", [Key.ESCAPE])
82
83 self._yaw = 35.0
84 self._pitch = 28.0
85 self._distance = 16.0
86 self._target = (0.0, 1.0, 0.0)
87 self._tonemap_idx = 0
88
89 self._cam = Camera3D(name="Camera", fov=55, near=0.1, far=200.0)
90 self.add_child(self._cam)
91
92 # WorldEnvironment: every knob below drives the post-process chain.
93 env = self.add_child(WorldEnvironment())
94 env.ssao_enabled = True
95 env.ssao_radius = 0.6
96 env.ssao_bias = 0.025
97 env.ssao_intensity = 1.5
98 env.tonemap_mode = "aces"
99 env.tonemap_white = 1.0
100 env.tonemap_exposure = 1.0
101 env.fxaa_enabled = True
102 env.dof_enabled = False
103 env.dof_focus_distance = 8.0
104 env.dof_focus_range = 2.0
105 env.dof_max_coc = 0.03
106 env.bloom_enabled = False
107 env.sky_mode = "colour"
108 self._env = env
109
110 # Lighting: a single strong key light so SSAO contact shadows read.
111 key = DirectionalLight3D(name="KeyLight", intensity=2.2)
112 key.look_at((-0.6, -1.0, -0.4))
113 self.add_child(key)
114 fill = DirectionalLight3D(name="FillLight", intensity=0.25, colour=(0.6, 0.7, 1.0))
115 fill.look_at((0.8, -0.6, 1.0))
116 self.add_child(fill)
117
118 # Ground plane.
119 ground = MeshInstance3D(name="Ground", mesh=Mesh.cube())
120 ground.material = Material(colour=(0.55, 0.55, 0.58), roughness=0.85, metallic=0.0)
121 ground.scale = (40.0, 0.1, 40.0)
122 ground.position = (0.0, -0.05, 0.0)
123 self.add_child(ground)
124
125 palette = [
126 (0.85, 0.3, 0.3),
127 (0.3, 0.75, 0.4),
128 (0.35, 0.45, 0.85),
129 (0.85, 0.75, 0.3),
130 (0.8, 0.4, 0.8),
131 ]
132
133 # A packed central cluster: cubes seated on the ground with shared
134 # faces, spheres nestled against them. The crevices give SSAO obvious
135 # contact occlusion to darken when toggled.
136 cluster = [
137 # (mesh, position): unit cubes rest at y=0.5, r=0.6 spheres at 0.6.
138 ("cube", (-1.0, 0.5, 0.0)),
139 ("cube", (0.0, 0.5, 0.0)),
140 ("cube", (1.0, 0.5, 0.0)),
141 ("cube", (-0.5, 1.5, 0.0)),
142 ("cube", (0.5, 1.5, 0.0)),
143 ("cube", (0.0, 0.5, -1.0)),
144 ("sphere", (-1.1, 0.6, 1.1)),
145 ("sphere", (0.0, 0.6, 1.1)),
146 ("sphere", (1.1, 0.6, 1.1)),
147 ]
148 for i, (kind, position) in enumerate(cluster):
149 mat = Material(colour=palette[i % len(palette)], roughness=0.6, metallic=0.05)
150 mesh = Mesh.cube() if kind == "cube" else Mesh.sphere(radius=0.6)
151 obj = MeshInstance3D(name=f"Cluster{i}", mesh=mesh, material=mat)
152 obj.position = position
153 self.add_child(obj)
154
155 # Outer rings, also seated on the ground: their depth spread shows DoF.
156 rng = np.random.default_rng(7)
157 for i in range(16):
158 colour = palette[i % len(palette)]
159 mat = Material(colour=colour, roughness=0.6, metallic=0.05)
160 is_cube = i % 2 == 0
161 mesh = Mesh.cube() if is_cube else Mesh.sphere(radius=0.6)
162 obj = MeshInstance3D(name=f"Ring{i}", mesh=mesh, material=mat)
163 ring = 4.0 + (i % 2) * 2.5
164 angle = i * math.pi * 2 / 8
165 obj.position = (
166 math.cos(angle) * ring + rng.uniform(-0.4, 0.4),
167 0.5 if is_cube else 0.6,
168 math.sin(angle) * ring + rng.uniform(-0.4, 0.4),
169 )
170 self.add_child(obj)
171
172 self._hud = self.add_child(Text2D(name="HUD", text="", font_scale=1.4, position=(12.0, 12.0)))
173 self._build_buttons()
174 self._update_camera()
175 self._update_hud()
176
177 def _build_buttons(self):
178 """Bottom-left bar of touch-friendly buttons mirroring the 1-4 keys.
179
180 The bar is anchored (never positioned from module constants) so it
181 tracks the window edge on resize; touch arrives as a left click.
182 """
183 btn_w, btn_h, gap, gutter = 108.0, 34.0, 6.0, 12.0
184 actions = [
185 ("ssao", self._toggle_ssao),
186 ("tonemap", self._cycle_tonemap),
187 ("fxaa", self._toggle_fxaa),
188 ("dof", self._toggle_dof),
189 ]
190 bar_w = gap + (btn_w + gap) * len(actions)
191 bar = Panel(name="ToggleBar")
192 bar.set_anchor_preset(AnchorPreset.BOTTOM_LEFT)
193 bar.margin_left = gutter
194 bar.margin_right = bar_w + gutter
195 bar.margin_top = -(btn_h + 12.0 + gutter)
196 bar.margin_bottom = -gutter
197 bar.bg_colour = Colour((0.0, 0.0, 0.0, 0.45))
198 self.add_child(bar)
199
200 self._buttons = {}
201 for i, (key, handler) in enumerate(actions):
202 btn = Button("", name=f"Btn_{key}", on_press=handler)
203 btn.position = Vec2(gap + (btn_w + gap) * i, 6.0)
204 btn.size = Vec2(btn_w, btn_h)
205 bar.add_child(btn)
206 self._buttons[key] = btn
207
208 def _toggle_ssao(self):
209 self._env.ssao_enabled = not self._env.ssao_enabled
210
211 def _cycle_tonemap(self):
212 self._tonemap_idx = (self._tonemap_idx + 1) % len(TONEMAP_MODES)
213 self._env.tonemap_mode = TONEMAP_MODES[self._tonemap_idx]
214
215 def _toggle_fxaa(self):
216 self._env.fxaa_enabled = not self._env.fxaa_enabled
217
218 def _toggle_dof(self):
219 self._env.dof_enabled = not self._env.dof_enabled
220
221 def on_update(self, dt):
222 if Input.is_action_just_pressed("quit"):
223 self.app.quit()
224 return
225
226 if Input.is_action_pressed("orbit_left"):
227 self._yaw += 60.0 * dt
228 if Input.is_action_pressed("orbit_right"):
229 self._yaw -= 60.0 * dt
230 if Input.is_action_pressed("pitch_up"):
231 self._pitch = min(80.0, self._pitch + 30.0 * dt)
232 if Input.is_action_pressed("pitch_down"):
233 self._pitch = max(-10.0, self._pitch - 30.0 * dt)
234 if Input.is_action_pressed("zoom_in"):
235 self._distance = max(6.0, self._distance - 8.0 * dt)
236 if Input.is_action_pressed("zoom_out"):
237 self._distance = min(40.0, self._distance + 8.0 * dt)
238
239 env = self._env
240 if Input.is_action_just_pressed("toggle_ssao"):
241 self._toggle_ssao()
242 if Input.is_action_just_pressed("cycle_tonemap"):
243 self._cycle_tonemap()
244 if Input.is_action_just_pressed("toggle_fxaa"):
245 self._toggle_fxaa()
246 if Input.is_action_just_pressed("toggle_dof"):
247 self._toggle_dof()
248
249 if Input.is_action_pressed("radius_up"):
250 env.ssao_radius = min(5.0, env.ssao_radius + 1.0 * dt)
251 if Input.is_action_pressed("radius_down"):
252 env.ssao_radius = max(0.0, env.ssao_radius - 1.0 * dt)
253 if Input.is_action_pressed("intensity_up"):
254 env.ssao_intensity = min(5.0, env.ssao_intensity + 1.5 * dt)
255 if Input.is_action_pressed("intensity_down"):
256 env.ssao_intensity = max(0.0, env.ssao_intensity - 1.5 * dt)
257
258 self._update_camera()
259 self._update_hud()
260
261 def _update_camera(self):
262 yaw_rad = math.radians(self._yaw)
263 pitch_rad = math.radians(self._pitch)
264 cp = math.cos(pitch_rad)
265 x = self._target[0] + self._distance * cp * math.sin(yaw_rad)
266 y = self._target[1] + self._distance * math.sin(pitch_rad)
267 z = self._target[2] + self._distance * cp * math.cos(yaw_rad)
268 self._cam.position = (x, y, z)
269 self._cam.look_at(self._target)
270
271 def _update_hud(self):
272 env = self._env
273 lines = [
274 "Post-processing (WorldEnvironment)",
275 f"[1] SSAO: {'ON' if env.ssao_enabled else 'OFF'}",
276 f" radius={env.ssao_radius:.2f} (Up/Down)",
277 f" intensity={env.ssao_intensity:.2f} (Left/Right)",
278 f"[2] Tonemap: {env.tonemap_mode} white={env.tonemap_white:.2f}",
279 f"[3] FXAA: {'ON' if env.fxaa_enabled else 'OFF'}",
280 f"[4] DoF: {'ON' if env.dof_enabled else 'OFF'} max_coc={env.dof_max_coc:.3f}",
281 "A/D orbit W/S pitch Q/E zoom Esc quit",
282 ]
283 self._hud.text = "\n".join(lines)
284 self._buttons["ssao"].text = f"SSAO {'ON' if env.ssao_enabled else 'OFF'}"
285 self._buttons["tonemap"].text = env.tonemap_mode
286 self._buttons["fxaa"].text = f"FXAA {'ON' if env.fxaa_enabled else 'OFF'}"
287 self._buttons["dof"].text = f"DoF {'ON' if env.dof_enabled else 'OFF'}"
288
289
290def main() -> None:
291 app = App(title="SimVX Post-Processing", width=1280, height=720)
292 app.run(PostProcessParity())
293
294
295if __name__ == "__main__":
296 main()