Custom post-process

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

📄 Docs only

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