Skip to content

Scene transitions

Download PNG

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.

examples/samples/29_scene_transitions.sjon
; 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])

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 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.

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.

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, fract of 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; two smoothstep thresholds 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 / d glow and a twinkle.

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.

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