Post shader¶
full-screen user GLSL over a pure-2D scene.
▶ Run in browserTags: 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¶
PostProcessEffectwith body-only GLSL on a scene with no 3D content.The automatic uniforms and the
frag_colouroutput 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())