Post Shader 2D

your own GLSL over a pure-2D scene.

▶ Run in browser

Tags: 2d post-process shader glsl environment

A PostProcessEffect is a fullscreen fragment shader added to a WorldEnvironment. It works on a 2D scene exactly as on a 3D one: the finished frame arrives as a texture and the shader decides what reaches the screen. You write only main(); the engine declares frag_colour and these uniforms for you:

  • u_colour_tex: the finished frame

  • u_resolution: the target size in pixels

  • u_time: elapsed seconds

A uniform of your own needs no declaration either: set_uniform declares it for you, with its GLSL type taken from the first value (a float gives float, a 3-tuple gives vec3, and a bare 0 gives int, so write 0.0). Setting it again every frame animates it:

effect = PostProcessEffect(RIPPLE_SHADER, order=10)
effect.set_uniform("u_strength", 0.02)
env.add_post_process(effect)

The shader here pushes a ripple out from the centre of the screen, adds a sideways scanline wobble and darkens alternate pixel rows. Its strength, u_strength, breathes slowly from on_update. The straight grid lines make the warp easy to see. effect.enabled switches the pass off without removing it.

The labels sit on a CanvasLayer, which draws after the post chain, so the text stays sharp while the world beneath it warps.

Controls: E or a click / tap toggles the effect.

Source

  1# /// simvx
  2# tags = ["2d", "post-process", "shader", "glsl", "environment"]
  3# ///
  4"""Post Shader 2D: your own GLSL over a pure-2D scene.
  5
  6A ``PostProcessEffect`` is a fullscreen fragment shader added to a
  7``WorldEnvironment``. It works on a 2D scene exactly as on a 3D one: the
  8finished frame arrives as a texture and the shader decides what reaches the
  9screen. You write only ``main()``; the engine declares ``frag_colour`` and
 10these uniforms for you:
 11
 12- ``u_colour_tex``: the finished frame
 13- ``u_resolution``: the target size in pixels
 14- ``u_time``: elapsed seconds
 15
 16A uniform of your own needs no declaration either: ``set_uniform`` declares
 17it for you, with its GLSL type taken from the first value (a float gives
 18``float``, a 3-tuple gives ``vec3``, and a bare ``0`` gives ``int``, so write
 19``0.0``). Setting it again every frame animates it:
 20
 21    effect = PostProcessEffect(RIPPLE_SHADER, order=10)
 22    effect.set_uniform("u_strength", 0.02)
 23    env.add_post_process(effect)
 24
 25The shader here pushes a ripple out from the centre of the screen, adds a
 26sideways scanline wobble and darkens alternate pixel rows. Its strength,
 27``u_strength``, breathes slowly from ``on_update``. The straight grid lines
 28make the warp easy to see. ``effect.enabled`` switches the pass off without
 29removing it.
 30
 31The labels sit on a ``CanvasLayer``, which draws after the post chain, so the
 32text stays sharp while the world beneath it warps.
 33
 34Controls: E or a click / tap toggles the effect.
 35"""
 36
 37import math
 38
 39from simvx.core import Anchor, CanvasLayer, Key, Label, Node2D, Pointer, PostProcessEffect, WorldEnvironment, on_input
 40from simvx.graphics import App
 41
 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    vec2 offset = dir * sin(dist * 48.0 - u_time * 5.0) * u_strength * exp(-dist * 2.2);
 52    offset.x /= aspect;
 53
 54    // Scanline warp: a horizontal wobble that varies down the screen.
 55    vec2 warped = uv + offset;
 56    warped.x += sin(warped.y * 28.0 + u_time * 6.0) * u_strength * 0.35;
 57
 58    float scan = 0.88 + 0.12 * sin(warped.y * u_resolution.y * 3.14159);
 59    frag_colour = vec4(texture(u_colour_tex, warped).rgb * scan, 1.0);
 60}
 61"""
 62
 63GRID = 48  # background grid pitch in pixels
 64GRID_COLOUR = (0.25, 0.3, 0.4, 1.0)
 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 PostShader2D(Node2D):
 73    dynamic = True  # the polygons move every frame
 74    input_context = {"toggle_effect": [Key.E, Pointer.PRIMARY]}
 75
 76    def on_ready(self):
 77        self.effect = PostProcessEffect(RIPPLE_SHADER, order=10)
 78        self.effect.set_uniform("u_strength", 0.02)
 79        self.add_child(WorldEnvironment()).add_post_process(self.effect)
 80
 81        hud = self.add_child(CanvasLayer())
 82        self.status = hud.add_child(Label(anchor=Anchor.TOP_LEFT, inset=12, font_size=24))
 83        hud.add_child(Label("E / click / tap: toggle effect", anchor=Anchor.BOTTOM_LEFT, inset=12))
 84
 85    @on_input("toggle_effect")
 86    def _toggle_effect(self, _event):
 87        self.effect.enabled = not self.effect.enabled
 88
 89    def on_update(self, dt):
 90        self.effect.set_uniform("u_strength", 0.02 + 0.012 * math.sin(self.tree.now * 0.8))
 91        self.status.text = f"Post shader: {'ON' if self.effect.enabled else 'OFF'}"
 92
 93    def on_draw(self, renderer):
 94        w, h = self.viewport_size
 95        for x in range(0, int(w) + 1, GRID):
 96            renderer.line((x, 0), (x, h), stroke=GRID_COLOUR)
 97        for y in range(0, int(h) + 1, GRID):
 98            renderer.line((0, y), (w, y), stroke=GRID_COLOUR)
 99
100        t = self.tree.now
101        for sides, orbit, orbit_speed, spin_speed, colour in POLYGONS:
102            cx = w / 2 + math.cos(t * orbit_speed) * orbit
103            cy = h / 2 + math.sin(t * orbit_speed) * orbit
104            angles = [t * spin_speed + i * math.tau / sides for i in range(sides)]
105            verts = [(cx + math.cos(a) * 46.0, cy + math.sin(a) * 46.0) for a in angles]
106            renderer.polygon(verts, fill=colour)
107            renderer.polygon(verts, stroke=(1.0, 1.0, 1.0, 0.9))
108
109        renderer.circle((w / 2, h / 2), 10.0, fill=(1.0, 0.9, 0.5, 1.0))
110
111
112if __name__ == "__main__":
113    App(title="Post Shader 2D", width=960, height=540).run(PostShader2D())