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