Scene transitions
A twenty-second loop of four scenes, each a self-contained fragment-shader effect, joined by transitions that differ per scene: the plasma fades into the tunnel, the tunnel wipes into the metaballs, the metaballs dissolve into the starfield, and the starfield opens a circle back onto the plasma. Both the outgoing and the incoming scene are evaluated for every pixel and mixed by a transition mask; nothing is rendered to an intermediate texture.
; Scene transitions: a fullscreen triangle cycling through four procedural; scenes (plasma, tunnel, metaballs, starfield), each 5 s long, with a 1 s; transition into the next: fade, horizontal wipe, noise dissolve or circle; reveal, chosen by the outgoing scene. Both scenes are evaluated per pixel and; mixed by the transition mask. Driven by a pngine-inputs uniform.
(shader-module :name shader :code """ struct Uniforms { time: f32, width: f32, height: f32, aspect: f32, } @group(0) @binding(0) var<uniform> u: Uniforms;
const PI: f32 = 3.14159265359; const SCENE_DURATION: f32 = 5.0; const TRANSITION_DURATION: f32 = 1.0; const NUM_SCENES: u32 = 4u;
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f { let x = f32(i & 1u) * 4.0 - 1.0; let y = f32((i >> 1u) & 1u) * 4.0 - 1.0; return vec4f(x, y, 0.0, 1.0); }
// Scene 0: Plasma fn scene0(uv: vec2f, t: f32) -> vec3f { let p = uv * 10.0; let v1 = sin(p.x + t); let v2 = sin(p.y + t * 0.7); let v3 = sin(p.x + p.y + t * 0.5); let v4 = sin(sqrt(p.x * p.x + p.y * p.y) + t); let v = (v1 + v2 + v3 + v4) * 0.25;
return vec3f( sin(v * PI) * 0.5 + 0.5, sin(v * PI + PI * 0.666) * 0.5 + 0.5, sin(v * PI + PI * 1.333) * 0.5 + 0.5 ); }
// Scene 1: Tunnel fn scene1(uv: vec2f, t: f32) -> vec3f { let centered = uv - 0.5; let dist = length(centered); let angle = atan2(centered.y, centered.x);
let tunnel = fract(1.0 / (dist + 0.1) - t * 0.5); let stripes = abs(fract(angle / PI * 4.0 + t) - 0.5) * 2.0;
let intensity = tunnel * stripes; return vec3f( intensity * 0.2, intensity * 0.8, intensity * 1.0 ); }
// Scene 2: Metaballs fn scene2(uv: vec2f, t: f32) -> vec3f { let p = (uv - 0.5) * 4.0; var sum = 0.0;
for (var i = 0; i < 5; i++) { let fi = f32(i); let center = vec2f( sin(t * (1.0 + fi * 0.3) + fi * 1.2) * 1.5, cos(t * (0.8 + fi * 0.2) + fi * 0.8) * 1.5 ); let d = length(p - center); sum += 0.5 / (d + 0.1); }
let edge = smoothstep(1.8, 2.0, sum); let fill = smoothstep(2.0, 2.5, sum);
return mix( vec3f(0.1, 0.0, 0.2), mix(vec3f(1.0, 0.3, 0.5), vec3f(1.0, 0.9, 0.7), fill), edge ); }
// Scene 3: Starfield fn scene3(uv: vec2f, t: f32) -> vec3f { var color = vec3f(0.0, 0.0, 0.05);
for (var layer = 0; layer < 3; layer++) { let fl = f32(layer); let speed = 0.2 + fl * 0.15; let scale = 20.0 + fl * 10.0;
let p = uv * scale + vec2f(0.0, t * speed); let cell = floor(p); let local = fract(p) - 0.5;
// Pseudo-random star position let hash = fract(sin(dot(cell, vec2f(12.9898, 78.233))) * 43758.5453); let starPos = vec2f(hash, fract(hash * 13.37)) - 0.5; let d = length(local - starPos * 0.8);
let brightness = 0.02 / (d + 0.01); let twinkle = sin(t * 5.0 + hash * 100.0) * 0.5 + 0.5;
color += vec3f(brightness * (0.5 + twinkle * 0.5)) * (0.3 + fl * 0.3); }
return min(color, vec3f(1.0)); }
// Transition effects fn transitionFade(progress: f32) -> f32 { return progress; }
fn transitionWipe(uv: vec2f, progress: f32) -> f32 { return step(uv.x, progress); }
fn transitionDissolve(uv: vec2f, progress: f32) -> f32 { let noise = fract(sin(dot(uv * 100.0, vec2f(12.9898, 78.233))) * 43758.5453); return step(noise, progress); }
fn transitionCircle(uv: vec2f, progress: f32) -> f32 { let centered = uv - 0.5; let dist = length(centered); return step(dist, progress * 0.8); }
@fragment fn fs(@builtin(position) pos: vec4f) -> @location(0) vec4f { let uv = vec2f(pos.x / u.width, pos.y / u.height); let t = u.time;
// Calculate current and next scene let totalCycle = SCENE_DURATION * f32(NUM_SCENES); let cycleTime = fract(t / totalCycle) * totalCycle;
let currentScene = u32(cycleTime / SCENE_DURATION) % NUM_SCENES; let nextScene = (currentScene + 1u) % NUM_SCENES;
let sceneProgress = fract(cycleTime / SCENE_DURATION); let transitionProgress = smoothstep( 1.0 - TRANSITION_DURATION / SCENE_DURATION, 1.0, sceneProgress );
// Render both scenes var currentColor = vec3f(0.0); var nextColor = vec3f(0.0);
if (currentScene == 0u) { currentColor = scene0(uv, t); } else if (currentScene == 1u) { currentColor = scene1(uv, t); } else if (currentScene == 2u) { currentColor = scene2(uv, t); } else { currentColor = scene3(uv, t); }
if (nextScene == 0u) { nextColor = scene0(uv, t); } else if (nextScene == 1u) { nextColor = scene1(uv, t); } else if (nextScene == 2u) { nextColor = scene2(uv, t); } else { nextColor = scene3(uv, t); }
// Apply transition based on scene var blend = 0.0; if (currentScene == 0u) { blend = transitionFade(transitionProgress); } else if (currentScene == 1u) { blend = transitionWipe(uv, transitionProgress); } else if (currentScene == 2u) { blend = transitionDissolve(uv, transitionProgress); } else { blend = transitionCircle(uv, transitionProgress); }
let color = mix(currentColor, nextColor, blend);
return vec4f(color, 1.0); } """)
(render-pipeline :name pipeline :layout auto (vertex :module shader :entry vs) (fragment :module shader :entry fs (target :format preferred-canvas-format)))
(buffer :name uniforms :size 16 :usage [uniform copy-dst])
(queue :name writeUniforms (write-buffer :buffer uniforms :offset 0 :data pngine-inputs))
(bind-group :name bindings :layout pipeline :group 0 (entry :binding 0 :buffer uniforms))
(render-pass :name mainPass (color-attachment :view context-current-texture :clear-value [0 0 0 1] :load-op clear :store-op store) :pipeline pipeline :bind-groups [bindings] (draw :vertex-count 3))
(frame :name main :perform [writeUniforms mainPass])examples/samples/29_scene_transitions.sjon in the pngine repository.
How it works
Section titled “How it works”The document is SJON, the S-expression format pngine compiles: each form is
one WebGPU resource or operation, and the shader text inside
(shader-module …) is plain WGSL, WebGPU’s shading language.
The scaffold
Section titled “The scaffold”The fullscreen scaffold from Gradient background:
one shader module, a (render-pipeline …) with :layout auto targeting the
canvas format, a 16-byte uniform buffer refilled every frame from
pngine-inputs (the built-in time/width/height/aspect source), a
bind group, a (render-pass …) drawing three vertices, and a (frame …).
Everything else is in the shader.
The timeline
Section titled “The timeline”Three module constants set the schedule: SCENE_DURATION = 5,
TRANSITION_DURATION = 1, NUM_SCENES = 4. fs folds time into a
20-second cycle, derives currentScene and nextScene, and computes
transitionProgress as a smoothstep over the last second of the current
scene: 0 for the first four seconds, then rising to 1.
The scenes
Section titled “The scenes”Each scene is a function (uv, t) -> vec3f:
- Plasma: four sines, averaged, through the three-sines palette.
- Tunnel:
1 / (dist + 0.1)gives the depth into the tunnel,fractof it minus time makes rings rush inward, and stripes from the angle add spokes. - Metaballs: five moving centres contribute
0.5 / (d + 0.1)each; twosmoothstepthresholds on the sum give an outline and a fill. - Starfield: three layers of cells at increasing scale and speed, one
hashed star per cell with a
1 / dglow and a twinkle.
The transitions
Section titled “The transitions”Each returns a mask in [0, 1]: transitionFade is the progress itself,
transitionWipe is step(uv.x, progress), a vertical edge sweeping across,
transitionDissolve compares a per-pixel hash against progress so pixels
flip in random order, and transitionCircle is step(dist, 0.8 progress),
a disc growing from the centre. The final colour is
mix(currentColor, nextColor, blend). Both scenes cost every frame even
when the mask is 0 or 1; on a fullscreen pass this is the simplest way to
get an arbitrary per-pixel transition without a second render target.
In the specifications
Section titled “In the specifications”| What the sample uses | WebGPU | WGSL |
|---|---|---|
| Fullscreen triangle and one draw | draw() |
vertex_index, position |
| Uniform buffer, bind group | writeBuffer(), bind group creation |
uniform address space |
| Schedule and selection | const declarations, if statement, u32() conversion, integer % |
|
| Scenes | user-defined functions, for statement, fract, floor, atan2, length, min |
|
| Transitions | smoothstep, step, mix, sin, dot |
Related
Section titled “Related”- Color cycling switches patterns without a transition; Shader art (plasma) is scene 0 on its own.
- To sequence scenes from JavaScript instead, see
setFramein the JavaScript API and the(frame …)form. - Forms:
(shader-module …),(render-pass …),(queue …).