Post shader

full-screen user GLSL over a pure-2D scene.

▶ Run in browser

Tags: 2d post-process shader glsl environment

A WorldEnvironment plus a PostProcessEffect runs a body-only GLSL fragment over the finished 2D frame: three orbiting polygons over a line grid, warped by an animated ripple with a scanline flicker. The shader ABI is automatic: u_time, u_resolution, u_colour_tex (and u_depth_tex) arrive as uniforms, output goes to frag_colour, and any extra uniform is live via set_uniform each frame (the ripple strength breathes this way). Screen-space HUD text (Text2D) composites after the effect, so it stays crisp while the world warps.

What it demonstrates

  • PostProcessEffect with body-only GLSL on a scene with no 3D content.

  • The automatic uniforms and the frag_colour output convention.

  • Animating a user uniform per frame with set_uniform.

  • Toggling the pass live via effect.enabled.

Controls: E - Toggle the post-process effect ESC - Quit

Run: uv run python examples/features/2d/post_shader.py Headless self-check: uv run python examples/features/2d/post_shader.py –test

Source

  1"""Post shader: full-screen user GLSL over a pure-2D scene.
  2
  3A ``WorldEnvironment`` plus a ``PostProcessEffect`` runs a body-only GLSL
  4fragment over the finished 2D frame: three orbiting polygons over a line grid,
  5warped by an animated ripple with a scanline flicker. The shader ABI is
  6automatic: ``u_time``, ``u_resolution``, ``u_colour_tex`` (and ``u_depth_tex``)
  7arrive as uniforms, output goes to ``frag_colour``, and any extra uniform is
  8live via ``set_uniform`` each frame (the ripple strength breathes this way).
  9Screen-space HUD text (``Text2D``) composites after the effect, so it stays
 10crisp while the world warps.
 11
 12# /// simvx
 13# tags = ["2d", "post-process", "shader", "glsl", "environment"]
 14# ///
 15
 16## What it demonstrates
 17- ``PostProcessEffect`` with body-only GLSL on a scene with no 3D content.
 18- The automatic uniforms and the ``frag_colour`` output convention.
 19- Animating a user uniform per frame with ``set_uniform``.
 20- Toggling the pass live via ``effect.enabled``.
 21
 22Controls:
 23  E   - Toggle the post-process effect
 24  ESC - Quit
 25
 26Run: uv run python examples/features/2d/post_shader.py
 27Headless self-check: uv run python examples/features/2d/post_shader.py --test
 28"""
 29
 30import math
 31
 32from simvx.core import Input, Key, Node2D, PostProcessEffect, Text2D, WorldEnvironment
 33from simvx.graphics import App
 34
 35WIDTH, HEIGHT = 960, 540
 36CENTRE = (WIDTH / 2, HEIGHT / 2)
 37GRID = 48  # background grid pitch in pixels
 38
 39# Ripple + scanline warp. Everything the shader reads beyond u_strength is
 40# supplied automatically: u_time (seconds), u_resolution (pixels) and
 41# u_colour_tex (the finished frame). Output goes to frag_colour.
 42RIPPLE_SHADER = """
 43void main() {
 44    vec2 uv = gl_FragCoord.xy / u_resolution;
 45    float aspect = u_resolution.x / u_resolution.y;
 46    vec2 centre = (uv - 0.5) * vec2(aspect, 1.0);
 47    float dist = length(centre);
 48    vec2 dir = dist > 1e-4 ? centre / dist : vec2(0.0);
 49
 50    // Concentric ripple radiating from the middle, fading with distance.
 51    float ripple = sin(dist * 48.0 - u_time * 5.0) * u_strength * exp(-dist * 2.2);
 52    vec2 offset = dir * ripple;
 53    offset.x /= aspect;
 54
 55    // Scanline warp: a horizontal wobble that varies down the screen.
 56    vec2 warped = uv + offset;
 57    warped.x += sin(warped.y * 28.0 + u_time * 6.0) * u_strength * 0.35;
 58
 59    vec3 col = texture(u_colour_tex, warped).rgb;
 60    float scan = 0.88 + 0.12 * sin(warped.y * u_resolution.y * 3.14159);
 61    frag_colour = vec4(col * scan, 1.0);
 62}
 63"""
 64
 65POLYGONS = (  # (sides, orbit radius, orbit speed, spin speed, colour)
 66    (3, 150.0, 0.9, 1.7, (1.0, 0.55, 0.25, 1.0)),
 67    (5, 95.0, -1.3, -1.1, (0.35, 0.85, 0.45, 1.0)),
 68    (6, 200.0, 0.55, 0.8, (0.4, 0.6, 1.0, 1.0)),
 69)
 70
 71
 72class PostShaderDemo(Node2D):
 73    """Orbiting polygons under an animated ripple/scanline post shader."""
 74
 75    dynamic = True  # the polygons and the grid redraw every frame
 76
 77    input_actions = {
 78        "toggle_effect": [Key.E],
 79        "quit": [Key.ESCAPE],
 80    }
 81
 82    def on_ready(self):
 83        self._time = 0.0
 84
 85        self._env = self.add_child(WorldEnvironment(name="Env"))
 86        self._fx = PostProcessEffect(RIPPLE_SHADER, order=10)
 87        self._fx.set_uniform("u_strength", 0.0)
 88        self._env.add_post_process(self._fx)
 89
 90        # Screen-space HUD: composited after the post chain, so the text is
 91        # readable however hard the shader warps the world beneath it.
 92        self._hud = self.add_child(Text2D(text="", position=(12, 12), font_scale=1.8))
 93        self.add_child(Text2D(text="E: toggle effect    ESC: quit", position=(12, HEIGHT - 32), font_scale=1.4))
 94        self._refresh_hud()
 95
 96    def on_update(self, dt: float):
 97        if Input.is_action_just_pressed("quit"):
 98            self.app.quit()
 99            return
100        if Input.is_action_just_pressed("toggle_effect"):
101            self._fx.enabled = not self._fx.enabled
102            self._refresh_hud()
103
104        self._time += dt
105        # A user uniform animated per frame: the ripple strength breathes.
106        self._fx.set_uniform("u_strength", 0.02 + 0.012 * math.sin(self._time * 0.8))
107
108    def _refresh_hud(self):
109        self._hud.text = f"Post shader: {'ON' if self._fx.enabled else 'OFF'}"
110
111    def on_draw(self, renderer):
112        # A line grid: straight edges make the warp unmistakable.
113        for x in range(0, WIDTH + 1, GRID):
114            renderer.draw_line((x, 0), (x, HEIGHT), colour=(0.25, 0.3, 0.4, 1.0))
115        for y in range(0, HEIGHT + 1, GRID):
116            renderer.draw_line((0, y), (WIDTH, y), colour=(0.25, 0.3, 0.4, 1.0))
117
118        # Orbiting, spinning polygons.
119        for sides, orbit_r, orbit_speed, spin_speed, colour in POLYGONS:
120            angle = self._time * orbit_speed
121            cx = CENTRE[0] + math.cos(angle) * orbit_r
122            cy = CENTRE[1] + math.sin(angle) * orbit_r
123            spin = self._time * spin_speed
124            step = math.tau / sides
125            verts = [
126                (cx + math.cos(spin + i * step) * 46.0, cy + math.sin(spin + i * step) * 46.0) for i in range(sides)
127            ]
128            renderer.draw_polygon(verts, colour=colour, filled=True)
129            renderer.draw_polygon(verts, colour=(1.0, 1.0, 1.0, 0.9), filled=False)
130
131        renderer.draw_circle(CENTRE, 10.0, colour=(1.0, 0.9, 0.5, 1.0), filled=True)
132
133
134def _selftest() -> bool:
135    """Headless: same frame with the effect on and off must differ in pixels.
136
137    Two runs under the deterministic fixed-step clock capture the same frame
138    index; run B disables the effect through the real input path (E), so the
139    pixel difference in the central region isolates the post shader.
140    """
141    import numpy as np
142
143    from simvx.core.testing import InputSimulator
144    from simvx.graphics.testing import assert_not_blank, save_png
145
146    CAPTURE = 60
147
148    ok = True
149
150    def check(label: str, passed: bool, detail: str) -> None:
151        nonlocal ok
152        ok = ok and passed
153        print(f"{'ok  ' if passed else 'FAIL'} {label}: {detail}")
154
155    # Run A: effect on throughout; sample the animated uniform at two frames.
156    scene_a = PostShaderDemo(name="PostShaderDemo")
157    strengths: list[float] = []
158
159    def frame_a(idx: int, _t: float) -> bool:
160        if idx in (10, 40):
161            strengths.append(float(scene_a._fx.get_uniform("u_strength")))
162        return True
163
164    frames_a = App(title="post_shader A", width=WIDTH, height=HEIGHT, visible=False).run_headless(
165        scene_a, frames=CAPTURE + 1, on_frame=frame_a, capture_frames=[CAPTURE]
166    )
167
168    # Run B: E disables the effect through the action map early on.
169    sim = InputSimulator()
170    scene_b = PostShaderDemo(name="PostShaderDemo")
171
172    def frame_b(idx: int, _t: float) -> bool:
173        if idx == 5:
174            sim.press_key(Key.E)
175        elif idx == 6:
176            sim.release_key(Key.E)
177        return True
178
179    frames_b = App(title="post_shader B", width=WIDTH, height=HEIGHT, visible=False).run_headless(
180        scene_b, frames=CAPTURE + 1, on_frame=frame_b, capture_frames=[CAPTURE]
181    )
182
183    a, b = np.asarray(frames_a[0]), np.asarray(frames_b[0])
184    assert_not_blank(a)
185    assert_not_blank(b)
186    save_png(a, "/tmp/post_shader_on.png")
187    save_png(b, "/tmp/post_shader_off.png")
188
189    check(
190        "the effect is registered on the environment",
191        scene_a._fx in scene_a._env.get_post_processes(),
192        f"{len(scene_a._env.get_post_processes())} effect(s) registered",
193    )
194    check(
195        "set_uniform animates u_strength between frames",
196        len(strengths) == 2 and strengths[0] != strengths[1],
197        f"u_strength {strengths}",
198    )
199    check(
200        "E toggled the effect off through the action map",
201        scene_b._fx.enabled is False,
202        f"enabled={scene_b._fx.enabled}",
203    )
204
205    # Compare the central region only, clear of both HUD lines, so the
206    # difference measured is the shader's warp rather than the HUD text.
207    ys, xs = slice(HEIGHT // 4, 3 * HEIGHT // 4), slice(WIDTH // 4, 3 * WIDTH // 4)
208    diff = float(np.abs(a[ys, xs].astype(np.int32) - b[ys, xs].astype(np.int32)).mean())
209    check("the shader visibly changes the frame", diff > 0.5, f"mean abs diff {diff:.2f} over the central region")
210
211    print("screenshots: /tmp/post_shader_on.png /tmp/post_shader_off.png")
212    print("SELFTEST:", "PASS" if ok else "FAIL")
213    return ok
214
215
216if __name__ == "__main__":
217    import sys
218
219    if "--test" in sys.argv:
220        sys.exit(0 if _selftest() else 1)
221    App(title="2D Post Shader", width=WIDTH, height=HEIGHT).run(PostShaderDemo())