PostProcessParity

Desktop parity for WorldEnvironment post-process knobs.

▶ Run in browser

Tags: 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()