Post Shader 2D¶
your own GLSL over a pure-2D scene.
▶ Run in browserTags: 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 frameu_resolution: the target size in pixelsu_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())