Custom post-process

a user GLSL effect runs on the TAA-resolved image before tonemap.

▶ Run in browser

Tags: 3d

A PostProcessEffect injects a fullscreen GLSL pass between the built-in post-processing and tonemap. It receives the current frame colour (u_colour_tex), depth (u_depth_tex), resolution and time, plus any uniforms you set. The effect below applies radial chromatic aberration and a vignette.

The scene runs with temporal anti-aliasing ON, so the custom effect samples the TAA-RESOLVED (anti-aliased) image, and tonemap samples the effect’s output: the resolve and the user effect compose correctly rather than one discarding the other. Toggling the effect off while TAA stays on returns cleanly to the plain resolved image.

u_colour_tex clamps at the frame edge by default (correct for neighbourhood taps); pass wrap="repeat" / "mirror" to PostProcessEffect for an effect that deliberately tiles the framebuffer.

Controls: E / click / tap : Toggle the custom effect on/off T : Toggle TAA on/off ESC : Quit

Usage: uv run python examples/features/3d/custom_post_process.py

Source

  1#!/usr/bin/env python3
  2"""Custom post-process: a user GLSL effect runs on the TAA-resolved image before tonemap.
  3
  4# /// simvx
  5# screenshot_frame = 40
  6# ///
  7
  8A ``PostProcessEffect`` injects a fullscreen GLSL pass between the built-in
  9post-processing and tonemap. It receives the current frame colour (``u_colour_tex``),
 10depth (``u_depth_tex``), resolution and time, plus any uniforms you set. The effect
 11below applies radial chromatic aberration and a vignette.
 12
 13The scene runs with temporal anti-aliasing ON, so the custom effect samples the
 14TAA-RESOLVED (anti-aliased) image, and tonemap samples the effect's output: the
 15resolve and the user effect compose correctly rather than one discarding the other.
 16Toggling the effect off while TAA stays on returns cleanly to the plain resolved
 17image.
 18
 19``u_colour_tex`` clamps at the frame edge by default (correct for neighbourhood
 20taps); pass ``wrap="repeat"`` / ``"mirror"`` to ``PostProcessEffect`` for an effect
 21that deliberately tiles the framebuffer.
 22
 23Controls:
 24    E / click / tap : Toggle the custom effect on/off
 25    T               : Toggle TAA on/off
 26    ESC             : Quit
 27
 28Usage:
 29    uv run python examples/features/3d/custom_post_process.py
 30"""
 31
 32import math
 33
 34from simvx.core import (
 35    Camera3D,
 36    DirectionalLight3D,
 37    Input,
 38    InputMap,
 39    Key,
 40    Material,
 41    Mesh,
 42    MeshInstance3D,
 43    MouseButton,
 44    Node,
 45    PostProcessEffect,
 46    Text2D,
 47    Vec3,
 48    WorldEnvironment,
 49)
 50from simvx.graphics import App
 51
 52WIDTH, HEIGHT = 1280, 720
 53
 54# A user fullscreen effect: radial chromatic aberration + vignette. Reads the
 55# current frame colour with a radial offset that grows toward the edges, so R/G/B
 56# separate at the corners; the vignette darkens the same falloff.
 57ABERRATION_SHADER = """
 58void main() {
 59    vec2 uv = gl_FragCoord.xy / u_resolution;
 60    vec2 centre = uv - 0.5;
 61    float r2 = dot(centre, centre);
 62    vec2 offset = centre * (u_aberration * r2);
 63    float cr = texture(u_colour_tex, uv + offset).r;
 64    float cg = texture(u_colour_tex, uv).g;
 65    float cb = texture(u_colour_tex, uv - offset).b;
 66    vec3 col = vec3(cr, cg, cb) * (1.0 - u_vignette * r2);
 67    frag_colour = vec4(col, 1.0);
 68}
 69"""
 70
 71
 72class CustomPostProcessDemo(Node):
 73    """A high-frequency 3D scene under TAA with a user chromatic-aberration effect."""
 74
 75    def __init__(self, **kwargs):
 76        super().__init__(**kwargs)
 77        self._time = 0.0
 78        self._effect_on = True
 79        self._taa_on = True
 80
 81    def on_ready(self):
 82        super().on_ready()
 83
 84        InputMap.add_action("toggle_effect", [Key.E, MouseButton.LEFT])
 85        InputMap.add_action("toggle_taa", [Key.T])
 86        InputMap.add_action("quit", [Key.ESCAPE])
 87
 88        # Static camera: the moving sphere supplies the only motion, so the frame
 89        # is deterministic under the headless fixed-step clock (golden-stable).
 90        self.camera = self.add_child(Camera3D(position=Vec3(0.0, 3.5, 8.5), look_at=Vec3(0.0, 0.6, 0.0)))
 91
 92        light = DirectionalLight3D()
 93        light.direction = Vec3(-0.5, -1.0, -0.3)
 94        light.colour = (1.0, 0.97, 0.92)
 95        light.intensity = 1.6
 96        self.add_child(light)
 97
 98        # Checker floor + a picket of thin pillars: high-frequency edges that
 99        # alias badly, so the TAA resolve the effect samples is clearly at work.
100        tile = Mesh.cube()
101        for gx in range(-6, 6):
102            for gz in range(-6, 6):
103                col = (0.85, 0.85, 0.88) if (gx + gz) % 2 == 0 else (0.12, 0.12, 0.15)
104                t = MeshInstance3D(mesh=tile, material=Material(colour=col, roughness=0.85))
105                t.position = Vec3(gx + 0.5, -0.55, gz + 0.5)
106                t.scale = Vec3(1.0, 0.1, 1.0)
107                self.add_child(t)
108
109        for i in range(-4, 5):
110            pillar = MeshInstance3D(
111                mesh=tile,
112                material=Material(colour=(0.9, 0.5 + 0.05 * i, 0.2), roughness=0.4, metallic=0.1),
113            )
114            pillar.position = Vec3(i * 1.1, 1.2, -2.0)
115            pillar.scale = Vec3(0.12, 2.4, 0.12)
116            self.add_child(pillar)
117
118        self._sphere = MeshInstance3D(
119            mesh=Mesh.sphere(),
120            material=Material(colour=(0.1, 0.5, 0.9), roughness=0.2, metallic=0.8),
121        )
122        self._sphere.position = Vec3(0.0, 1.0, 1.5)
123        self.add_child(self._sphere)
124
125        self._env = self.add_child(WorldEnvironment(name="Env"))
126        self._env.taa_enabled = self._taa_on
127
128        self._effect = PostProcessEffect(ABERRATION_SHADER, order=10)
129        self._effect.set_uniform("u_aberration", 0.28)
130        self._effect.set_uniform("u_vignette", 0.55)
131        self._env.add_post_process(self._effect)
132
133        self._hud = self.add_child(Text2D(text="", position=(12, 12), font_scale=1.8))
134        self._hud_hint = self.add_child(
135            Text2D(text="E / click / tap: Toggle effect    T: Toggle TAA", position=(12, HEIGHT - 34), font_scale=1.4)
136        )
137        self._refresh_hud()
138
139    def on_update(self, dt: float):
140        self._hud_hint.position = (12, self.app.height - 34)
141        if Input.is_action_just_pressed("quit"):
142            self.app.quit()
143            return
144
145        self._time += dt
146        # Orbit the sphere so TAA has per-object motion to reproject.
147        self._sphere.position = Vec3(math.sin(self._time * 1.5) * 2.5, 1.0, 1.5 + math.cos(self._time * 1.5) * 1.0)
148
149        if Input.is_action_just_pressed("toggle_effect"):
150            self._effect_on = not self._effect_on
151            if self._effect_on:
152                self._env.add_post_process(self._effect)
153            else:
154                self._env.remove_post_process(self._effect)
155            self._refresh_hud()
156
157        if Input.is_action_just_pressed("toggle_taa"):
158            self._taa_on = not self._taa_on
159            self._env.taa_enabled = self._taa_on
160            self._refresh_hud()
161
162    def _refresh_hud(self):
163        self._hud.text = f"Effect: {'ON' if self._effect_on else 'OFF'}    TAA: {'ON' if self._taa_on else 'OFF'}"
164
165
166if __name__ == "__main__":
167    scene = CustomPostProcessDemo(name="CustomPostProcessDemo")
168    app = App(title="Custom Post-Process Demo", width=WIDTH, height=HEIGHT)
169    app.run(scene)