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
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"""
29
30
31import math
32import sys
33
34import numpy as np
35
36from simvx.core import (
37 Camera3D,
38 DirectionalLight3D,
39 Input,
40 InputMap,
41 Key,
42 Material,
43 Mesh,
44 MeshInstance3D,
45 Node,
46 Text2D,
47 WorldEnvironment,
48)
49from simvx.graphics import App
50
51TONEMAP_MODES = ["aces", "neutral", "reinhard", "uchimura"]
52
53
54class PostProcessParity(Node):
55 def on_ready(self):
56 InputMap.add_action("orbit_left", [Key.A])
57 InputMap.add_action("orbit_right", [Key.D])
58 InputMap.add_action("pitch_up", [Key.W])
59 InputMap.add_action("pitch_down", [Key.S])
60 InputMap.add_action("zoom_in", [Key.Q])
61 InputMap.add_action("zoom_out", [Key.E])
62 InputMap.add_action("toggle_ssao", [Key.KEY_1])
63 InputMap.add_action("cycle_tonemap", [Key.KEY_2])
64 InputMap.add_action("toggle_fxaa", [Key.KEY_3])
65 InputMap.add_action("toggle_dof", [Key.KEY_4])
66 InputMap.add_action("radius_up", [Key.UP])
67 InputMap.add_action("radius_down", [Key.DOWN])
68 InputMap.add_action("intensity_up", [Key.RIGHT])
69 InputMap.add_action("intensity_down", [Key.LEFT])
70 InputMap.add_action("quit", [Key.ESCAPE])
71
72 self._yaw = 35.0
73 self._pitch = 28.0
74 self._distance = 16.0
75 self._target = (0.0, 1.0, 0.0)
76 self._tonemap_idx = 0
77
78 self._cam = Camera3D(name="Camera", fov=55, near=0.1, far=200.0)
79 self.add_child(self._cam)
80
81 # WorldEnvironment: every knob below is now honoured on desktop.
82 env = self.add_child(WorldEnvironment())
83 env.ssao_enabled = True
84 env.ssao_radius = 0.6
85 env.ssao_bias = 0.025
86 env.ssao_intensity = 1.5
87 env.tonemap_mode = "aces"
88 env.tonemap_white = 1.0
89 env.tonemap_exposure = 1.0
90 env.fxaa_enabled = True
91 env.dof_enabled = False
92 env.dof_focus_distance = 8.0
93 env.dof_focus_range = 2.0
94 env.dof_max_coc = 0.03
95 env.bloom_enabled = False
96 env.sky_mode = "colour"
97 self._env = env
98
99 # Lighting: a single strong key light so SSAO contact shadows read.
100 key = DirectionalLight3D(name="KeyLight", intensity=2.2)
101 key.look_at((-0.6, -1.0, -0.4))
102 self.add_child(key)
103 fill = DirectionalLight3D(name="FillLight", intensity=0.25, colour=(0.6, 0.7, 1.0))
104 fill.look_at((0.8, -0.6, 1.0))
105 self.add_child(fill)
106
107 # Ground plane.
108 ground = MeshInstance3D(name="Ground", mesh=Mesh.cube())
109 ground.material = Material(colour=(0.55, 0.55, 0.58), roughness=0.85, metallic=0.0)
110 ground.scale = (40.0, 0.1, 40.0)
111 ground.position = (0.0, -0.05, 0.0)
112 self.add_child(ground)
113
114 # A cluster of touching boxes + spheres: crevices give SSAO plenty of
115 # contact occlusion to darken, and the depth spread shows DoF.
116 rng = np.random.default_rng(7)
117 palette = [
118 (0.85, 0.3, 0.3), (0.3, 0.75, 0.4), (0.35, 0.45, 0.85),
119 (0.85, 0.75, 0.3), (0.8, 0.4, 0.8),
120 ]
121 for i in range(24):
122 colour = palette[i % len(palette)]
123 mat = Material(colour=colour, roughness=0.6, metallic=0.05)
124 mesh = Mesh.cube() if i % 2 == 0 else Mesh.sphere(radius=0.6)
125 obj = MeshInstance3D(name=f"Obj{i}", mesh=mesh, material=mat)
126 ring = 3.0 + (i % 3) * 2.5
127 angle = i * math.pi * 2 / 8
128 obj.position = (
129 math.cos(angle) * ring + rng.uniform(-0.4, 0.4),
130 0.6 + (i % 3) * 0.3,
131 math.sin(angle) * ring + rng.uniform(-0.4, 0.4),
132 )
133 self.add_child(obj)
134
135 self._hud = self.add_child(Text2D(name="HUD", text="", font_scale=1.8, x=12.0, y=12.0))
136 self._update_camera()
137
138 def on_process(self, dt):
139 if Input.is_action_just_pressed("quit"):
140 self.app.quit()
141 return
142
143 if Input.is_action_pressed("orbit_left"):
144 self._yaw += 60.0 * dt
145 if Input.is_action_pressed("orbit_right"):
146 self._yaw -= 60.0 * dt
147 if Input.is_action_pressed("pitch_up"):
148 self._pitch = min(80.0, self._pitch + 30.0 * dt)
149 if Input.is_action_pressed("pitch_down"):
150 self._pitch = max(-10.0, self._pitch - 30.0 * dt)
151 if Input.is_action_pressed("zoom_in"):
152 self._distance = max(6.0, self._distance - 8.0 * dt)
153 if Input.is_action_pressed("zoom_out"):
154 self._distance = min(40.0, self._distance + 8.0 * dt)
155
156 env = self._env
157 if Input.is_action_just_pressed("toggle_ssao"):
158 env.ssao_enabled = not env.ssao_enabled
159 if Input.is_action_just_pressed("cycle_tonemap"):
160 self._tonemap_idx = (self._tonemap_idx + 1) % len(TONEMAP_MODES)
161 env.tonemap_mode = TONEMAP_MODES[self._tonemap_idx]
162 if Input.is_action_just_pressed("toggle_fxaa"):
163 env.fxaa_enabled = not env.fxaa_enabled
164 if Input.is_action_just_pressed("toggle_dof"):
165 env.dof_enabled = not env.dof_enabled
166
167 if Input.is_action_pressed("radius_up"):
168 env.ssao_radius = min(5.0, env.ssao_radius + 1.0 * dt)
169 if Input.is_action_pressed("radius_down"):
170 env.ssao_radius = max(0.0, env.ssao_radius - 1.0 * dt)
171 if Input.is_action_pressed("intensity_up"):
172 env.ssao_intensity = min(5.0, env.ssao_intensity + 1.5 * dt)
173 if Input.is_action_pressed("intensity_down"):
174 env.ssao_intensity = max(0.0, env.ssao_intensity - 1.5 * dt)
175
176 self._update_camera()
177 self._update_hud()
178
179 def _update_camera(self):
180 yaw_rad = math.radians(self._yaw)
181 pitch_rad = math.radians(self._pitch)
182 cp = math.cos(pitch_rad)
183 x = self._target[0] + self._distance * cp * math.sin(yaw_rad)
184 y = self._target[1] + self._distance * math.sin(pitch_rad)
185 z = self._target[2] + self._distance * cp * math.cos(yaw_rad)
186 self._cam.position = (x, y, z)
187 self._cam.look_at(self._target)
188
189 def _update_hud(self):
190 env = self._env
191 lines = [
192 "Post-Process Parity (WorldEnvironment -> desktop)",
193 f"[1] SSAO: {'ON' if env.ssao_enabled else 'OFF'} "
194 f"radius={env.ssao_radius:.2f} (Up/Down) intensity={env.ssao_intensity:.2f} (L/R)",
195 f"[2] Tonemap: {env.tonemap_mode} white={env.tonemap_white:.2f}",
196 f"[3] FXAA: {'ON' if env.fxaa_enabled else 'OFF'}",
197 f"[4] DoF: {'ON' if env.dof_enabled else 'OFF'} max_coc={env.dof_max_coc:.3f}",
198 "A/D orbit W/S pitch Q/E zoom Esc quit",
199 ]
200 self._hud.text = "\n".join(lines)
201
202
203def main() -> None:
204 if "--test" in sys.argv:
205 # Headless smoke test: render a few frames, assert non-blank output.
206 from simvx.graphics.testing import assert_not_blank
207
208 app = App(title="PostProcessParity", width=640, height=480, visible=False)
209 frames = app.run_headless(PostProcessParity(), frames=4, capture_frames=[3])
210 assert frames, "no frame captured"
211 assert_not_blank(frames[0])
212 print("post_process_parity --test OK")
213 return
214 app = App(title="PostProcessParity", width=1280, height=720)
215 app.run(PostProcessParity())
216
217
218if __name__ == "__main__":
219 main()