PHOSPHORformat 1GLSL ES 3.00ANGLE · WebGL2
Phosphor reference.
Everything the AFK engine exposes to a scene: the package and manifest schema, the shader contract, every built-in uniform, the live data inputs and the native music analysis engine. Written as an API reference, complete enough to write a valid scene from this page alone.
01Overview
A Phosphor scene is a manifest plus one or more GLSL ES 3.00 fragment shaders that implement Shadertoy’smainImage. The engine owns everything else: windows, the GL context (ANGLE on Direct3D 11 natively, WebGL2 in the previewer), timing, fades, multiple monitors, text rasterization, photo and video decoding, and the gathering and analysis of live data. Scenes are small because the heavy lifting is native.
- Entry point
void mainImage(out vec4 fragColor, in vec2 fragCoord)- Coordinates
fragCoordin pixels, origin bottom-left. All engine textures are bottom-left origin.- Color
- Framebuffer is not sRGB-encoded. Write display values; color params arrive unlinearized.
- Output
- RGB ×
iFade. Alpha ignored unless"transparent": true. - No input
- No
iMouse, no keyboard. Any input ends the screensaver. - Reserved
- Identifiers starting
phosphor_,u_, ori+ capital letter. Don’t redefinephosphorMusic*. - Do not write
#version,precision, built-in or paramuniforms, ormain().
02Package & paste bundle
A scene is a folder. A .phosphor file is that folder zipped (store or deflate, no nesting).
my-scene/
manifest.json required
scene.frag required (path set by "shader")
common.glsl optional ("common")
*.frag optional, one per pass
preview.png required to publish to the Workshop, 16:9, ≥ 640×360
textures/ optional, PNG or JPGThe paste bundle is the text form, for chat replies and copy/paste. It carries no binary files, so bundled scenes are procedural. Text before the first marker and after //== phosphor:end is ignored, so a whole chat message pastes cleanly.
//== phosphor:manifest.json
{ ...manifest... }
//== phosphor:scene.frag
...glsl...
//== phosphor:common.glsl
...optional shared glsl...
//== phosphor:end03manifest.json
{
"phosphor": 1,
"id": "llamalabs.starfield-drift",
"name": "Starfield Drift",
"author": "Llama Labs",
"version": "1.0.0",
"description": "A slow drift through a warm starfield.",
"type": "shader",
"shader": "scene.frag",
"tags": ["space", "calm"],
"duration": 90,
"feedback": false,
"inputs": ["audio"],
"textures": [],
"params": [
{ "id": "speed", "type": "float", "label": "Speed", "default": 0.4, "min": 0.0, "max": 2.0, "step": 0.01 },
{ "id": "tint", "type": "color", "label": "Tint", "default": "#ffd9a0" },
{ "id": "count", "type": "int", "label": "Layers", "default": 4, "min": 1, "max": 8 }
],
"presets": [
{ "name": "Hyperdrive", "values": { "speed": 1.8, "tint": "#a0c8ff" } }
]
}| Field | Req | Type | Notes |
|---|---|---|---|
phosphor | ✓ | int | Format version: 1. |
id | ✓ | string | author.slug, lowercase [a-z0-9._-], ≤ 64. Stable across versions. |
name | ✓ | string | ≤ 48 chars. |
author | ✓ | string | Display name. |
version | ✓ | semver | "1.0.0" |
description | string | ≤ 500 chars. | |
type | ✓ | enum | shader · photo · video · youtube |
shader | ✓ | path | Image shader inside the package. |
tags | string[] | ≤ 8, from the Workshop tag list. | |
duration | number | Default seconds in a playlist. Default 60. | |
feedback | bool | Declares iPrevFrame. Default false. | |
textures | object[] | ≤ 4 → iChannel0–3. { "file", "filter": "linear"|"nearest", "wrap": "repeat"|"clamp"|"mirror" } | |
params | object[] | ≤ 32 user settings → u_<id>. | |
presets | object[] | ≤ 16 { "name", "values": { id: value } }. Partial sets allowed. | |
inputs | enum[] | audio · system · location · weather. | |
transparent | bool | Alpha shows the desktop. | |
passes | object[] | ≤ 8 buffer passes. | |
common | path | GLSL inserted before every shader. | |
photo | object | Photo scenes: { "hold": 8, "transition": 1.5, "order": "shuffle" } | |
video | path | Video scenes: a shipped file (else a video param). |
04Parameters
Each param is a control in the app and a uniform u_<id>. Every param needs a valid default within its range. Optional on any param:group (section heading) and help (tooltip).
| type | GLSL | Control | Fields |
|---|---|---|---|
float | float | slider | min max step |
int | int | slider / stepper | min max |
bool | bool | toggle | |
color | vec3 | picker | default "#rrggbb". Value = hex / 255, not linearized. |
vec2 | vec2 | two sliders | min max (both axes) |
select | int | dropdown | options: labels; value is the index |
text | sampler2D u_<id> + vec2 u_<id>_size | text box | maxLength ≤ 64, font or fontParam, distance, suggestions |
font | — | font chooser | default: builtin:sans|serif|mono|display, system:<name>, google:<family>[:bold] |
folder | — | folder picker | Photo scenes, exactly one. |
video | — | file picker | Video scenes. |
youtube | — | link box | YouTube scenes, exactly one. |
05Shader contract
The engine assembles each shader as header + (#line 1 1 + common + #line 1 0) + scene source + footer. #linekeeps compiler errors on your file’s line numbers. The header is part of the format: the previewer emits it byte-for-byte identically.
Header (generated)
#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
uniform vec3 iResolution; // viewport px, z = 1.0
uniform float iTime; // seconds since this scene started
uniform float iTimeDelta; // seconds since last frame
uniform int iFrame; // frame index since scene start
uniform vec4 iDate; // year, month (0-11), day, seconds since midnight
uniform float iFade; // 0 to 1: fade in at start, fade out at end
uniform float iSeed; // random 0 to 1, new each session
uniform int iMonitor; // monitor index, 0 = primary
uniform int iMonitorCount; // screens showing the scene
uniform vec4 iMonitorRect; // this screen in the shared layout
uniform vec2 iDesktop; // the layout's size
uniform sampler2D iFont; // built-in glyph atlas
// ...uniforms for each declared input (audio: the music block)...
uniform sampler2D iChannel0; // only if textures[0] exists (same for 1 to 3)
uniform sampler2D iPrevFrame;// only if feedback: true
uniform sampler2D iBackdrop; // only if transparent: true
uniform int iIteration; // only with passes
uniform sampler2D iBuffer_<id>; // one per pass, in passes order
// ...one uniform per param, u_<id>...
out vec4 phosphor_FragColor;
#line 1Footers (appended)
// Image shader, opaque scene
void main() {
vec4 c = vec4(0.0);
mainImage(c, gl_FragCoord.xy);
phosphor_FragColor = vec4(c.rgb * iFade, 1.0);
}
// Image shader, "transparent": true (straight alpha over the desktop)
void main() {
vec4 c = vec4(0.0);
mainImage(c, gl_FragCoord.xy);
vec3 desk = texture(iBackdrop, gl_FragCoord.xy / iResolution.xy).rgb;
phosphor_FragColor = vec4(mix(desk, c.rgb, clamp(c.a, 0.0, 1.0)) * iFade, 1.0);
}
// Pass shader: output stored unchanged, all four channels
void main() {
vec4 c = vec4(0.0);
mainImage(c, gl_FragCoord.xy);
phosphor_FragColor = c;
}06Built-in uniforms
| Uniform | Type | Value | Notes |
|---|---|---|---|
iResolution | vec3 | px, z = 1.0 | Viewport size. In a pass: the buffer size. |
iTime | float | s, ≥ 0 | Seconds since this scene started (not since the session). |
iTimeDelta | float | s | Seconds since the previous frame. |
iFrame | int | ≥ 0 | Frame index since scene start. |
iDate | vec4 | y, m 0–11, d, s | Year, month (0-based), day, seconds since local midnight. |
iFade | float | 0 – 1 | Fade in/out envelope. Applied by the footer; read only to do something fancier. |
iSeed | float | 0 – 1 | Random, new each session. Use to vary layouts between runs. |
iMonitor | int | 0 = primary | Index of this monitor. |
iMonitorCount | int | ≥ 1 | Screens showing the scene. 1 in previews. |
iMonitorRect | vec4 | x, y, w, h | This screen in the shared desktop layout, logical px, origin bottom-left. |
iDesktop | vec2 | w, h | Size of the whole layout. |
iFont | sampler2D | 1024² | Built-in SDF glyph atlas (see Text & fonts). |
iChannel0–3 | sampler2D | Declared only for each entry in manifest textures[]. | |
iPrevFrame | sampler2D | RGBA8 | Declared only with "feedback": true. |
iBackdrop | sampler2D | RGB | Declared only with "transparent": true. |
iIteration | int | 0 – 63 | Declared only with passes. Iteration index within the frame. |
iBuffer_<id> | sampler2D | rgba8 | rgba16f | One per pass. |
u_<id> | per type | One per param (see Parameters). |
Spanning monitors. Work in desktop coordinates to draw one picture across every screen:vec2 desk = iMonitorRect.xy + fragCoord / iResolution.xy * iMonitorRect.zw; Each screen still renders at its own resolution.
07Live inputs
List what the scene reads in "inputs". Only listed inputs are declared (reading an unlisted one is a compile error) and only listed inputs are collected. Every input has defined values when there’s no data, so scenes never branch on availability unless they want to.
| Input | Declares | Source | Without data |
|---|---|---|---|
audio | iMusic* · phosphorMusic, phosphorBand, … (see Music engine) | WASAPI loopback of the default output device. Never the microphone. Analysed in memory, never recorded. | iMusic* all 0 |
system | vec4 iSystem | System counters, sampled at 2 Hz. | (0, 0, 1, 0) |
location | vec3 iLocation · vec2 iSun | The city the user picked (offline GeoNames list). Sun position computed locally (NOAA). | iLocation 0; iSun follows the clock |
weather | vec4 iWeather · vec4 iWeatherMore · int iWeatherCode | MET Norway Locationforecast, cached, refreshed ≤ every 30 min. | (15,0,0,0), (0.5,0,0,0), code 0 |
| Uniform | Type | Units | Components |
|---|---|---|---|
iSystem | vec4 | 0 – 1 | x CPU load · y memory in use · z battery level (1 with no battery) · w on battery (0|1). |
iLocation | vec3 | deg, deg, 0|1 | Latitude, longitude, known. |
iSun | vec2 | deg | x elevation above horizon · y azimuth clockwise from north. |
iWeather | vec4 | °C, 0–1, mm/h, m/s | Temperature, cloud cover, precipitation, wind speed. |
iWeatherMore | vec4 | 0–1, deg, 0|1, h | Humidity, wind direction (from), known, hours since reading. |
iWeatherCode | int | 0 – 10 | 0 unknown · 1 clear · 2 partly cloudy · 3 cloudy · 4 fog · 5 drizzle · 6 rain · 7 heavy rain · 8 sleet · 9 snow · 10 thunder. |
08Music engine
A raw FFT is mostly the wash of the whole mix, so audio-reactive scenes used to spend hundreds of lines on smoothing, normalisation and beat detection, each a little differently. The engine does that work natively, once per frame, and hands every scene the same pre-processed signals: a cleaned log-frequency spectrum, smoothed levels and beats, named mixing bands with onsets, kick/snare/hi-hat/cymbal detection and a peak that chases the hits across the spectrum. A raw spectrum is there too, for honest loudness.
- Enabled by
"inputs": ["audio"].- Cost
- 80
vec4uniforms (inside the GLES 3.0 minimum of 224). No samplers, no texture units. - Shared
- One processor (
phosphor::music) runs in the engine and, via WASM, in the web previewer: identical numbers in both. - Frame-rate free
- Every time constant is in seconds (
dtclamped to 0.25 s). 30 fps and 144 fps produce the same envelopes. - Silence
- All
iMusic*are exactly 0. Invalid input or a device sample-rate change resets to 0. - Deterministic
- Still mode and the parity check feed the engine’s synthetic sample music, so audio scenes render reproducibly.
Uniforms
| Uniform | Type | Range | Meaning |
|---|---|---|---|
iMusicSpectrum[32] | vec4[32] | 0 – 1 | 128 log-spaced bins, packed 4 per vec4. Cleaned: what stands out of the mix (notes, voices, kicks), auto-gained, peak-held. The one to draw. |
iMusicRaw[32] | vec4[32] | 0 – 1 | Same 128 bins with no cleanup, +4 dB/decade tilt. Available, but phosphorMusic looks better. |
iMusicHits[8] | vec4[8] | 0 – 1 | 32 onset envelopes, one per group of 4 spectrum bins. Jumps to the attack strength on a hit, decays e^(−6t). |
iMusicLevel | vec4 | 0 – 1 | x bass 20–250 Hz · y mids 250 Hz–4 kHz · z treble 4–16 kHz · w overall. Smoothed: attack 30 s⁻¹, release 6 s⁻¹. |
iMusicBeat | vec4 | x,y,z 0–1 · w count | x bass hit (strongest sub/punch onset) · y mid hit (low mid/mid) · z treble hit (high/air) · w number of bass hits so far, mod 65536. |
iMusicBands[2] | vec4[2] | 0 – 1 | Named mixing bands (sub … air, then all), each normalized against its own running range so the highs move as much as the bass. Read with phosphorBand. |
iMusicOnsets[2] | vec4[2] | 0 – 1 | Onset envelope per named band: a sudden jump over the band’s recent average, not its level. [7] is the strongest. Read with phosphorOnset. |
iMusicDrums | vec4 | 0 – 1 | x kick · y snare · z hi-hat · w cymbal hit envelopes, from coinciding band onsets. |
iMusicPeak | vec4 | 0 – 1 | x where hits are landing (log position, like phosphorMusic), gliding from hit to hit · y its strength · z dominant frequency (loudest point, gliding) · w its level. |
Packed arrays: bin i is iMusicSpectrum[i / 4][i % 4]. Read them through the functions below unless you need an exact bin.
Functions
float phosphorMusic(float x)- Cleaned spectrum at x ∈ [0, 1] (0 = 141 Hz, 1 = 8.9 kHz, logarithmic). Smoothstep-interpolated between bins, so curves drawn from it never stair-step.
float phosphorMusicRaw(float x)- Raw spectrum at x, linear interpolation. Not recommended for visuals.
float phosphorMusicHit(float x)- Onset envelope of the band containing x (32 bands, nearest).
float phosphorBand(int b)- Level of a named band: PHOSPHOR_SUB 20–60 Hz, PUNCH 60–150, LOWMID 150–400, MID 400–2k, PRESENCE 2–5k, HIGH 5–10k, AIR 10–20k, ALL the mean.
float phosphorOnset(int b)- Onset envelope of a named band (ALL: the strongest).
Exact declarations added to the header
uniform vec4 iMusicSpectrum[32];
uniform vec4 iMusicHits[8];
uniform vec4 iMusicLevel;
uniform vec4 iMusicBeat;
uniform vec4 iMusicBands[2];
uniform vec4 iMusicOnsets[2];
uniform vec4 iMusicDrums;
uniform vec4 iMusicPeak;
const int PHOSPHOR_SUB = 0, PHOSPHOR_PUNCH = 1, PHOSPHOR_LOWMID = 2, PHOSPHOR_MID = 3,
PHOSPHOR_PRESENCE = 4, PHOSPHOR_HIGH = 5, PHOSPHOR_AIR = 6, PHOSPHOR_ALL = 7;
float phosphorBand(int b) { b = clamp(b, 0, 7); return iMusicBands[b / 4][b % 4]; }
float phosphorOnset(int b) { b = clamp(b, 0, 7); return iMusicOnsets[b / 4][b % 4]; }
float phosphorMusic(float x) {
float p = clamp(x, 0.0, 1.0) * 128.0 - 0.5;
int a = clamp(int(floor(p)), 0, 127), b = clamp(int(floor(p)) + 1, 0, 127);
return mix(iMusicSpectrum[a / 4][a % 4], iMusicSpectrum[b / 4][b % 4], smoothstep(0.0, 1.0, fract(p)));
}
float phosphorMusicHit(float x) {
int i = clamp(int(x * 32.0), 0, 31);
return iMusicHits[i / 4][i % 4];
}Signal pipeline
Input: an AnalyserNode-equivalent 512-bin byte spectrum (fftSize 1024, Blackman window, smoothing 0.8, −100…−30 dB) and the raw samples, at the device’s sample rate.
1Log resampling
bin i ∈ [0, 128) x = (i + 0.5) / 128
centre frequency f(x) = 140.625 Hz · (8906.25 / 140.625)^x // 141 Hz … 8.9 kHz, ≈ 6 octaves
FFT lookup p = f · 1024 / sampleRate, linear between FFT bins (Hz-based: 44.1 and 48 kHz agree)
raw[i] min(1, fft(p) · (0.9 + 0.4·x)) // tilt toward the treble
hit band j ∈ [0, 32) spectrum bins 4j … 4j+3 // phosphorMusicHit(x) → j = floor(32x)2Cleaning → iMusicSpectrum
raw ─┬─ floor[i] ← one-pole follower, rate 0.4 s⁻¹ (τ ≈ 2.5 s) // the steady wash of the mix
├─ local[i] ← Gaussian over ±18 bins (step 3) // the spectral neighbourhood
└─ contrast[i] = max(raw − floor, 0) + max(raw − local, 0) // new in time + peaked in frequency
gain = max(0.05, max(contrast), gain · e^(−0.3·dt)) // auto gain, slow release
spectrum[i] = clamp(blur₁₃(contrast)[i] / gain · 1.3, 0, 1)^1.3 // 13-tap Gaussian, gamma
spectrum[i] = max(spectrum[i], previous · e^(−6·dt)) // peak hold, ≈ 115 ms half-lifeA sustained pad sinks into floor within a few seconds and disappears; a note that starts, or a peak that stands above its neighbours, shows. The automatic gain means quiet and loud tracks fill the same range, so scenes never need a sensitivity slider. Users set how much cleanup phosphorMusic gets with the app’s Noise reduction setting (default 100%; 0% is the raw spectrum).
3Onsets → iMusicHits, iMusicBeat
per hit band (32):
envelope *= e^(−6·dt)
fire = armed ∧ level > 0.08 ∧ level > average + 0.055
fire → envelope = smoothstep(0.04, 0.18, level − average); armed = false
¬fire ∧ ¬armed ∧ level < average + 0.025 ∧ envelope < 0.2 → armed = true // hysteresis
average += (level − average) · (1 − e^(−2.5·dt)) // τ = 0.4 sThe hit bands read the cleaned spectrum. iMusicBeat groups the named-band onsets (step 5) into bass, mids and treble, and counts bass hits in .w. Hysteresis means a held note fires once and a new attack re-fires only after the band drops back.
4Levels → iMusicLevel
level += (target − level) · (1 − e^(−k·dt)), k = 30 rising, 6 falling // fast attack, soft release5Named bands, drums → iMusicBands, iMusicOnsets, iMusicDrums
Each named band filters the raw samples (a 4th-order band-pass), so a held bass note doesn’t wobble the way a short FFT does. Levels are scaled between a slow floor and peak per band; onsets fire when a band jumps well above its ~0.08 s average. Drums come from onsets landing within 45 ms of each other: kick is punch and sub together, snare is low-mid body with presence crack and high wires, hi-hat is highs without presence, cymbal is air still ringing 100 ms later.
6Chased peak → iMusicPeak
When hit bands fire, the strongest one becomes the target and .x glides there (rate 14 s⁻¹); .y jumps on the hit and fades (4 s⁻¹). A fill down the toms or a run up the keys reads as one point travelling across the spectrum. .zw track the loudest point of the cleaned spectrum the same way.
Patterns
| Want | Use |
|---|---|
| A spectrum graph, bars, a ring | phosphorMusic(x) |
| Something to pump on the kick | iMusicBeat.x |
| Hi-hats / snares sparkle | iMusicBeat.z, iMusicBeat.y |
| Discrete change every N beats | int(iMusicBeat.w) / N, or hash it |
| Slow mood: speed, brightness | iMusicLevel |
| Many emitters, each its own slice | phosphorMusicHit(x) / iMusicHits |
| Flash on drums | iMusicDrums |
| Instrument-aware motion | phosphorBand(PHOSPHOR_PRESENCE), phosphorOnset(…) |
| A cursor, spotlight or comet following the music | iMusicPeak.xy |
| Honest loudness, a VU meter | phosphorMusicRaw(x), iMusicLevel |
| Shadertoy audio shader | Port texture(iChannel0, …) to phosphorMusic(x) |
// 64 bars across the screen, cleaned spectrum, bass on the left.
vec2 uv = fragCoord / iResolution.xy;
float x = (floor(uv.x * 64.0) + 0.5) / 64.0;
float h = phosphorMusic(x);
float bar = step(uv.y, h) * step(0.15, fract(uv.x * 64.0));
fragColor = vec4(mix(vec3(0.1, 0.4, 1.0), vec3(1.0, 0.5, 0.1), x) * bar, 1.0);// Pulse on the kick, and pick a new palette every 8 bars of kicks.
float kick = iMusicBeat.x; // 1 on the hit, falls to ~0 in 0.4 s
float scale = 1.0 + 0.15 * kick;
int section = int(iMusicBeat.w) / 32; // changes after 32 bass hits
vec3 pal = 0.5 + 0.5 * cos(6.2831 * (float(section) * 0.37 + vec3(0.0, 0.33, 0.67)));// Slow, continuous motion from smoothed levels: never jittery.
float energy = iMusicLevel.w; // overall
float drift = iTime * (0.2 + 0.8 * iMusicLevel.x);
float shimmer = iMusicLevel.z; // treble → sparkle amount
// Silence: every iMusic* value is exactly 0, so give scenes an idle animation.
float alive = smoothstep(0.0, 0.05, energy);// One particle per hit band; each fires on its own slice of the music.
vec2 q = fragCoord / iResolution.y;
vec3 col = vec3(0.0);
for (int j = 0; j < 32; j++) {
float x = (float(j) + 0.5) / 32.0;
float e = phosphorMusicHit(x); // or iMusicHits[j / 4][j % 4]
if (e < 0.01) continue;
vec2 p = vec2(x * iResolution.x / iResolution.y, 0.1 + 0.6 * (1.0 - e));
col += vec3(1.0, 0.6, 0.2) * e * 0.002 / dot(q - p, q - p);
}// Integrate beats in a 1x1 rgba16f pass to get state that outlives the envelope.
// manifest: { "id": "beat", "shader": "beat.frag", "resolution": 16, "format": "rgba16f", "filter": "nearest" }
void mainImage(out vec4 o, in vec2 fragCoord) {
vec4 s = texelFetch(iBuffer_beat, ivec2(0), 0);
s.x += iTimeDelta * (0.3 + 2.0 * iMusicLevel.x); // phase that speeds up with the bass
s.y = max(s.y * exp(-iTimeDelta * 1.5), iMusicBeat.x); // longer, slower kick glow
o = s;
}Give every audio scene an idle motion and a Music strength float param (0–1, default 1) that scales the reaction. Sound input can be off in Settings, and lots of screensaver time is silent.
09Buffer passes
Up to 8 offscreen passes run in order before the image shader: Shadertoy’s Buffer A–D. Each writes iBuffer_<id>, readable from every shader.
"common": "common.glsl",
"passes": [
{ "id": "velocity", "shader": "velocity.frag", "resolution": 128, "format": "rgba16f" },
{ "id": "pressure", "shader": "pressure.frag", "resolution": 128, "format": "rgba16f", "iterations": 20 },
{ "id": "dye", "shader": "dye.frag", "scale": 1.0, "format": "rgba16f" }
]| Field | Default | Notes |
|---|---|---|
id | — | [a-z][a-z0-9_]*, ≤ 24, unique. |
shader | — | Implements mainImage. |
scale | 1 | 0.05–1 of the output. Follows the render scale. |
resolution | — | 16–2048: shorter side in px. Fixed cost, kept across resizes. Use for simulation state. |
format | rgba8 | rgba16f: signed half float, any range. |
filter | linear | nearest |
wrap | clamp | repeat, mirror |
iterations | 1 | 1–64 runs per frame; iIteration counts them. |
- Read rule
- Every read returns the buffer’s latest result. Earlier passes: this frame. Itself and later passes: previous iteration, else previous frame.
- In a pass
fragCoord/iResolutionare the buffer’s pixels;iFadeis ignored; output is stored unclamped.- Initial state
- All zero on frame 0 and on resize (
scalebuffers). Never carried between scenes. One set per monitor. - Memory
- 2 textures per buffer. A 4K
rgba16fbuffer at scale 1 is 127 MB per monitor.
10Feedback
"feedback": true declares iPrevFrame: what mainImage wrote last frame (RGBA8, linear, clamp), before iFade, so trails survive fades. Black, alpha 0 on frame 0 and after a resize. The one-buffer special case of passes.
11Transparent scenes
"transparent": true: the scene is mixed over a still copy of the desktop by its straight alpha. Start from vec4(0.0) and set alpha where you draw, or sample iBackdrop at fragCoord / iResolution.xy to warp the desktop and output alpha 1. Where no desktop exists (previews, previewer) the engine supplies a stand-in desktop. Shader, photo and video scenes only.
12Text & fonts
A text param is rasterized by the engine into a single-line texture: white glyphs, coverage in alpha, transparent background, 128 px cap height, bottom-left origin. Keep aspect with u_<id>_size.
- distance: true
- RGBA16F. R is a signed distance field: 0.5 on the edge, 0/1 at 32 texels; distance in texels (positive outside) =
(0.5 − r) · 64. Within 7 texels, G/B hold the outward unit normal (vec2(g, −b)in texture space). - Placeholders
{time} {time12} {hm} {hm12} {date} {weekday} {month} {day} {year}. Photo:{photoDate},{photoWeekday}… Weather input:{temp} {tempC} {tempF} {high} {low} {wind} {humidity} {weather} {place}.- iFont
- 1024² atlas, 16×16 cells of 64 px. Cell = ASCII code (32–126); 128–190 = half-width katakana U+FF61–FF9F. R = SDF, 0.5 edge.
float glyph(vec2 uv, int ch) { // uv 0..1 inside the cell
vec2 cell = vec2(ch % 16, 15 - ch / 16);
float d = texture(iFont, (cell + clamp(uv, 0.0, 1.0)) / 16.0).r;
return smoothstep(0.45, 0.55, d);
}13Photo & video
| Uniform | Type | Notes |
|---|---|---|
u_photoA, u_photoB | sampler2D | Current and next photo, EXIF-rotated, downscaled. |
u_photoASize, u_photoBSize | vec2 | Pixels. |
u_photoMix | float | 0→1 during a transition, 0 while holding. |
u_photoTime | float | Seconds since the current photo became A. |
u_photoHold, u_photoTransition | float | The user’s timing, seconds. |
u_photoIndex | int | Increments each photo. |
u_video | sampler2D | Video scenes: current frame. |
u_videoSize | vec2 | (0, 0) until the first frame: draw a backdrop. |
YouTube scenes play in the official embedded player over the window; their shader is the backdrop shown before it starts and in previews.
14Texture units
Units 0–7 hold text params; passes take units from 8 up. Text params + passes must fit in:
| Scene | text + passes |
|---|---|
| Default | 8 |
with transparent (iBackdrop on unit 14) | 6 |
The music uniforms use no units.
15Limits
| .phosphor package (zip) | 20 MB |
| Shader source, per file | 256 KB |
| Scene id | [a-z0-9._-], ≤ 64, author.slug |
| name | ≤ 48 chars |
| description | ≤ 500 chars |
| tags | ≤ 8 |
| params | ≤ 32 · id [a-z][a-z0-9_]*, ≤ 24 |
| presets | ≤ 16 · name 1–32, unique ignoring case |
| textures | ≤ 4 · PNG/JPG · ≤ 4096 px/side |
| text params | ≤ 8 (minus transparent, passes; see Texture units) |
| text maxLength | ≤ 64 |
| text suggestions | ≤ 16 |
| passes | ≤ 8 · Σ iterations ≤ 128/frame · ≤ 4096 px/side |
| pass iterations | 1 – 64 |
| pass scale / resolution | 0.05 – 1 / 16 – 2048 px |
| Shipped video | ≤ 512 MB, folder packages only |
| Performance target | 60 fps at 1080p on Intel Iris Xe class (≈ 0.8 ms on an RTX 3090) |
Valid means: the manifest parses and matches this schema, every shader compiles and links with the generated header (ANGLE or WebGL2), every default is in range, and limits hold. The same validator, compiled to WASM, runs in the previewer and the app. Publishing also renders 300 frames at 1080p without device loss.
16Versioning
Everything on this page is format 1, the format AFK Screensaver launches with. Format 1 scenes will always load. Later features will raise the number, and an older engine refuses a newer scene with a clear message instead of rendering it wrong.
17Complete examples
Minimal scene
//== phosphor:manifest.json
{
"phosphor": 1,
"id": "example.warm-plasma",
"name": "Warm Plasma",
"author": "Example",
"version": "1.0.0",
"type": "shader",
"shader": "scene.frag",
"tags": ["abstract", "calm"],
"params": [
{ "id": "speed", "type": "float", "label": "Speed", "default": 0.5, "min": 0.0, "max": 2.0, "step": 0.01 },
{ "id": "tint", "type": "color", "label": "Tint", "default": "#ff9a3c" }
]
}
//== phosphor:scene.frag
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = (2.0 * fragCoord - iResolution.xy) / iResolution.y;
float t = iTime * u_speed + iSeed * 100.0;
float v = sin(uv.x * 3.0 + t) + sin(uv.y * 4.0 - t * 1.3) + sin(length(uv) * 5.0 - t);
fragColor = vec4(u_tint * (0.55 + 0.45 * sin(v * 2.0)), 1.0);
}
//== phosphor:endMusic-reactive scene
//== phosphor:manifest.json
{
"phosphor": 1,
"id": "example.kick-ring",
"name": "Kick Ring",
"author": "Example",
"version": "1.0.0",
"type": "shader",
"shader": "scene.frag",
"tags": ["abstract", "energetic"],
"inputs": ["audio"],
"params": [
{ "id": "color", "type": "color", "label": "Color", "default": "#3dd6ff" },
{ "id": "react", "type": "float", "label": "Music strength", "default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01 }
]
}
//== phosphor:scene.frag
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = (2.0 * fragCoord - iResolution.xy) / iResolution.y;
float a = atan(uv.y, uv.x);
float x = abs(a) / 3.14159; // mirrored ring, bass at the right
float idle = 0.05 + 0.05 * sin(iTime * 2.0); // keep moving in silence
float s = max(phosphorMusic(x) * u_react, idle);
float r = 0.45 + 0.1 * iMusicBeat.x * u_react + 0.25 * s;
float ring = 0.004 / abs(length(uv) - r);
vec3 col = u_color * ring * (0.6 + 0.8 * iMusicLevel.w);
col += vec3(1.0, 0.5, 0.2) * phosphorMusicHit(x) * 0.002 / abs(length(uv) - r - 0.05);
fragColor = vec4(col, 1.0);
}
//== phosphor:endPaste either into the Create tab, or into the web previewer, and it plays.