Post-processing

SSAO, tonemapping, FXAA and depth of field on a WorldEnvironment.

▶ Run in browser

Tags: 3d post-process ssao tonemap fxaa dof

Screen-space effects are properties of the scene’s WorldEnvironment, and each one renders the same on the desktop and in the browser. Set them when the node is built and change them at any time; a change shows on the next frame:

env = WorldEnvironment(ssao_enabled=True, ssao_radius=0.6, fxaa_enabled=True)
env.dof_enabled = True
  • SSAO (ssao_enabled) darkens creases and contact points where surrounding geometry blocks ambient light. ssao_radius is how far it looks for occluders, ssao_intensity how dark it gets and ssao_bias keeps flat surfaces from shading themselves.

  • Tonemapping maps the HDR image to the display. tonemap_mode picks the curve from TonemapMode, with tonemap_exposure and tonemap_white to tune it. This example cycles the four filmic curves (ACES, NEUTRAL, REINHARD, UCHIMURA); LINEAR is a plain clamp.

  • FXAA (fxaa_enabled) smooths jagged edges in one cheap pass.

  • Depth of field (dof_enabled) keeps a band of depth sharp and blurs the rest: dof_focus_distance is the centre of that band, dof_focus_range its depth, and dof_max_coc caps the blur as a fraction of the screen, so it looks the same at any resolution.

The scene is built for the effects: a packed cluster of cubes and spheres gives SSAO deep crevices to darken, and two rings of shapes spread over depth make the depth-of-field blur obvious. The camera is an OrbitCamera3D, which takes its own drag, stick and scroll input; on_update adds keyboard orbit, turning the camera the way its right stick does, and keyboard zoom.

Each button is Button(action=name): it presses the action’s key, so one @on_input handler serves both the key and the button, and its label shows the key’s prompt.

Controls: 1-4 / buttons - Toggle SSAO, cycle the tonemap curve, toggle FXAA, toggle depth of field Up / Down - SSAO radius Right / Left - SSAO intensity W A S D / drag - Orbit the camera Q / E / scroll - Zoom in / out

Source

  1# /// simvx
  2# tags = ["post-process", "ssao", "tonemap", "fxaa", "dof"]
  3# web = { width = 1280, height = 720 }
  4# ///
  5"""Post-processing: SSAO, tonemapping, FXAA and depth of field on a WorldEnvironment.
  6
  7Screen-space effects are properties of the scene's ``WorldEnvironment``, and
  8each one renders the same on the desktop and in the browser. Set them when the
  9node is built and change them at any time; a change shows on the next frame:
 10
 11    env = WorldEnvironment(ssao_enabled=True, ssao_radius=0.6, fxaa_enabled=True)
 12    env.dof_enabled = True
 13
 14- **SSAO** (``ssao_enabled``) darkens creases and contact points where
 15  surrounding geometry blocks ambient light. ``ssao_radius`` is how far it
 16  looks for occluders, ``ssao_intensity`` how dark it gets and ``ssao_bias``
 17  keeps flat surfaces from shading themselves.
 18- **Tonemapping** maps the HDR image to the display. ``tonemap_mode`` picks the
 19  curve from ``TonemapMode``, with ``tonemap_exposure`` and ``tonemap_white``
 20  to tune it. This example cycles the four filmic curves (``ACES``,
 21  ``NEUTRAL``, ``REINHARD``, ``UCHIMURA``); ``LINEAR`` is a plain clamp.
 22- **FXAA** (``fxaa_enabled``) smooths jagged edges in one cheap pass.
 23- **Depth of field** (``dof_enabled``) keeps a band of depth sharp and blurs
 24  the rest: ``dof_focus_distance`` is the centre of that band,
 25  ``dof_focus_range`` its depth, and ``dof_max_coc`` caps the blur as a
 26  fraction of the screen, so it looks the same at any resolution.
 27
 28The scene is built for the effects: a packed cluster of cubes and spheres gives
 29SSAO deep crevices to darken, and two rings of shapes spread over depth make the
 30depth-of-field blur obvious. The camera is an ``OrbitCamera3D``, which takes its
 31own drag, stick and scroll input; ``on_update`` adds keyboard orbit, turning
 32the camera the way its right stick does, and keyboard zoom.
 33
 34Each button is ``Button(action=name)``: it presses the action's key, so one
 35``@on_input`` handler serves both the key and the button, and its label shows
 36the key's prompt.
 37
 38Controls:
 39    1-4 / buttons   - Toggle SSAO, cycle the tonemap curve, toggle FXAA, toggle depth of field
 40    Up / Down       - SSAO radius
 41    Right / Left    - SSAO intensity
 42    W A S D / drag  - Orbit the camera
 43    Q / E / scroll  - Zoom in / out
 44"""
 45
 46import math
 47
 48import numpy as np
 49
 50from simvx.core import (
 51    Anchor,
 52    Axis1D,
 53    Axis2D,
 54    Button,
 55    CanvasLayer,
 56    DirectionalLight3D,
 57    HBoxContainer,
 58    Input,
 59    Key,
 60    Label,
 61    Material,
 62    Mesh,
 63    MeshInstance3D,
 64    Node,
 65    OrbitCamera3D,
 66    TonemapMode,
 67    WorldEnvironment,
 68    on_input,
 69)
 70from simvx.graphics import App
 71
 72TONEMAP_CURVES = [TonemapMode.ACES, TonemapMode.NEUTRAL, TonemapMode.REINHARD, TonemapMode.UCHIMURA]
 73PALETTE = [(0.85, 0.3, 0.3), (0.3, 0.75, 0.4), (0.35, 0.45, 0.85), (0.85, 0.75, 0.3), (0.8, 0.4, 0.8)]
 74
 75
 76class PostProcessing(Node):
 77    input_context = {
 78        "ssao": [Key.KEY_1],
 79        "tonemap": [Key.KEY_2],
 80        "fxaa": [Key.KEY_3],
 81        "dof": [Key.KEY_4],
 82        "ssao_radius": [Axis1D(low=Key.DOWN, high=Key.UP)],
 83        "ssao_intensity": [Axis1D(low=Key.LEFT, high=Key.RIGHT)],
 84        "orbit": [Axis2D.WASD],
 85        "zoom": [Axis1D(low=Key.E, high=Key.Q)],
 86    }
 87
 88    def on_ready(self):
 89        self.env = self.add_child(
 90            WorldEnvironment(
 91                ssao_enabled=True,
 92                ssao_radius=0.6,
 93                ssao_intensity=1.5,
 94                fxaa_enabled=True,
 95                dof_focus_distance=8.0,
 96                dof_focus_range=2.0,
 97                dof_max_coc=0.03,
 98            )
 99        )
100        self.camera = self.add_child(
101            OrbitCamera3D(
102                pivot=(0, 1, 0),
103                distance=16,
104                yaw=math.radians(35),
105                pitch=math.radians(-28),
106                min_pitch=math.radians(-80),
107                max_pitch=math.radians(10),
108                min_distance=6,
109                max_distance=40,
110                fov=55,
111            )
112        )
113        # One strong key light so the SSAO contact shadows read, and a faint blue fill.
114        self.add_child(DirectionalLight3D(intensity=2.2, direction=(-0.6, -1.0, -0.4)))
115        self.add_child(DirectionalLight3D(intensity=0.25, colour=(0.6, 0.7, 1.0), direction=(0.8, -0.6, 1.0)))
116        ground = Material(colour=(0.55, 0.55, 0.58), roughness=0.85)
117        self.add_child(MeshInstance3D(mesh=Mesh.box((40, 0.1, 40)), material=ground, position=(0, -0.05, 0)))
118
119        # Unit cubes rest at y=0.5 and the 0.6-radius spheres at y=0.6, all on the ground.
120        cube, ball = Mesh.box(), Mesh.sphere(radius=0.6)
121        cubes = [(-1, 0.5, 0), (0, 0.5, 0), (1, 0.5, 0), (-0.5, 1.5, 0), (0.5, 1.5, 0), (0, 0.5, -1)]
122        balls = [(-1.1, 0.6, 1.1), (0, 0.6, 1.1), (1.1, 0.6, 1.1)]
123        shapes = [(cube, p) for p in cubes] + [(ball, p) for p in balls]
124
125        rng = np.random.default_rng(7)
126        for i in range(16):
127            radius = 6.5 if i % 2 else 4.0
128            angle = i * math.tau / 8
129            jx, jz = rng.uniform(-0.4, 0.4, size=2)
130            x = math.cos(angle) * radius + jx
131            z = math.sin(angle) * radius + jz
132            shapes.append((ball, (x, 0.6, z)) if i % 2 else (cube, (x, 0.5, z)))
133
134        for i, (mesh, position) in enumerate(shapes):
135            material = Material(colour=PALETTE[i % len(PALETTE)], roughness=0.6, metallic=0.05)
136            self.add_child(MeshInstance3D(mesh=mesh, material=material, position=position))
137
138        hud = self.add_child(CanvasLayer())
139        self.status = hud.add_child(Label(anchor=Anchor.TOP_LEFT, inset=12, font_size=18))
140        bar = hud.add_child(HBoxContainer(anchor=Anchor.BOTTOM_LEFT, inset=12))
141        labels = {"ssao": "SSAO", "tonemap": "Tonemap", "fxaa": "FXAA", "dof": "DOF"}
142        self.buttons = {name: bar.add_child(Button(label, action=name)) for name, label in labels.items()}
143        self.show_state()
144
145        # Example help overlay: not part of the technique shown.
146        help_text = "Arrows: SSAO   WASD / drag: orbit   Q/E / scroll: zoom"
147        hud.add_child(Label(help_text, anchor=Anchor.TOP_RIGHT, inset=12, font_size=14))
148
149    @on_input("ssao")
150    def toggle_ssao(self, _event):
151        self.env.ssao_enabled = not self.env.ssao_enabled
152        self.show_state()
153
154    @on_input("tonemap")
155    def cycle_tonemap(self, _event):
156        next_curve = (TONEMAP_CURVES.index(self.env.tonemap_mode) + 1) % len(TONEMAP_CURVES)
157        self.env.tonemap_mode = TONEMAP_CURVES[next_curve]
158        self.show_state()
159
160    @on_input("fxaa")
161    def toggle_fxaa(self, _event):
162        self.env.fxaa_enabled = not self.env.fxaa_enabled
163        self.show_state()
164
165    @on_input("dof")
166    def toggle_dof(self, _event):
167        self.env.dof_enabled = not self.env.dof_enabled
168        self.show_state()
169
170    def show_state(self):
171        self.buttons["ssao"].checked = self.env.ssao_enabled
172        self.buttons["fxaa"].checked = self.env.fxaa_enabled
173        self.buttons["dof"].checked = self.env.dof_enabled
174        self.buttons["tonemap"].text = f"Tonemap: {self.env.tonemap_mode}"
175
176    def on_update(self, dt):
177        env = self.env
178        env.ssao_radius += Input.value("ssao_radius") * dt
179        env.ssao_intensity += Input.value("ssao_intensity") * 1.5 * dt
180        self.status.text = f"SSAO radius {env.ssao_radius:.2f}\nSSAO intensity {env.ssao_intensity:.2f}"
181
182        keys = Input.value("orbit")
183        speed = self.camera.stick_speed * dt
184        self.camera.orbit(-keys.x * speed, -keys.y * speed)
185        self.camera.zoom(Input.value("zoom") * 8.0 * dt)
186
187
188if __name__ == "__main__":
189    App(title="Post-Processing", width=1280, height=720).run(PostProcessing())