Post-processing¶

SSAO, tonemapping, FXAA and depth of field via WorldEnvironment.

â–¶ Run in browser

Tags: 3d post-process ssao tonemap fxaa dof

Every effect is a WorldEnvironment Property, with the same result on desktop and in the browser:

  • ssao_enabled with tunable ssao_radius / ssao_bias / ssao_intensity (a dedicated compute pass).

  • tonemap_mode (aces / neutral / reinhard / uchimura) with tonemap_white and tonemap_exposure.

  • fxaa_enabled.

  • dof_enabled, focused by dof_focus_distance / dof_focus_range and scaled by the resolution-aware dof_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()