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 SJON, the S-expression format pngine compiles (one form per WebGPU resource or operation), is 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 …). The scenes, the schedule and the transitions are all WGSL, WebGPU’s shading language, in one fragment 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). Evaluating both scenes on every frame, even while the mask sits at 0 or 1, is a cost I accept on purpose: on a fullscreen pass it is the simplest way to get an arbitrary per-pixel transition without a second render target.

An exercise: raise TRANSITION_DURATION to 2.5 and recompile. Every transition stretches with it, because transitionProgress is derived from the three schedule constants alone.

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