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
fragCoord in 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_, or i + capital letter. Don’t redefine phosphorMusic*.
Do not write
#version, precision, built-in or param uniforms, or main().

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 JPG

The 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:end

03manifest.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" } }
  ]
}
FieldReqTypeNotes
phosphor✓intFormat version: 1.
id✓stringauthor.slug, lowercase [a-z0-9._-], ≤ 64. Stable across versions.
name✓string≤ 48 chars.
author✓stringDisplay name.
version✓semver"1.0.0"
descriptionstring≤ 500 chars.
type✓enumshader · photo · video · youtube
shader✓pathImage shader inside the package.
tagsstring[]≤ 8, from the Workshop tag list.
durationnumberDefault seconds in a playlist. Default 60.
feedbackboolDeclares iPrevFrame. Default false.
texturesobject[]≤ 4 → iChannel0–3. { "file", "filter": "linear"|"nearest", "wrap": "repeat"|"clamp"|"mirror" }
paramsobject[]≤ 32 user settings → u_<id>.
presetsobject[]≤ 16 { "name", "values": { id: value } }. Partial sets allowed.
inputsenum[]audio · system · location · weather.
transparentboolAlpha shows the desktop.
passesobject[]≤ 8 buffer passes.
commonpathGLSL inserted before every shader.
photoobjectPhoto scenes: { "hold": 8, "transition": 1.5, "order": "shuffle" }
videopathVideo 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).

typeGLSLControlFields
floatfloatslidermin max step
intintslider / steppermin max
boolbooltoggle
colorvec3pickerdefault "#rrggbb". Value = hex / 255, not linearized.
vec2vec2two slidersmin max (both axes)
selectintdropdownoptions: labels; value is the index
textsampler2D u_<id> + vec2 u_<id>_sizetext boxmaxLength ≤ 64, font or fontParam, distance, suggestions
font—font chooserdefault: builtin:sans|serif|mono|display, system:<name>, google:<family>[:bold]
folder—folder pickerPhoto scenes, exactly one.
video—file pickerVideo scenes.
youtube—link boxYouTube 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 1

Footers (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

UniformTypeValueNotes
iResolutionvec3px, z = 1.0Viewport size. In a pass: the buffer size.
iTimefloats, ≥ 0Seconds since this scene started (not since the session).
iTimeDeltafloatsSeconds since the previous frame.
iFrameint≥ 0Frame index since scene start.
iDatevec4y, m 0–11, d, sYear, month (0-based), day, seconds since local midnight.
iFadefloat0 – 1Fade in/out envelope. Applied by the footer; read only to do something fancier.
iSeedfloat0 – 1Random, new each session. Use to vary layouts between runs.
iMonitorint0 = primaryIndex of this monitor.
iMonitorCountint≥ 1Screens showing the scene. 1 in previews.
iMonitorRectvec4x, y, w, hThis screen in the shared desktop layout, logical px, origin bottom-left.
iDesktopvec2w, hSize of the whole layout.
iFontsampler2D1024²Built-in SDF glyph atlas (see Text & fonts).
iChannel0–3sampler2DDeclared only for each entry in manifest textures[].
iPrevFramesampler2DRGBA8Declared only with "feedback": true.
iBackdropsampler2DRGBDeclared only with "transparent": true.
iIterationint0 – 63Declared only with passes. Iteration index within the frame.
iBuffer_<id>sampler2Drgba8 | rgba16fOne per pass.
u_<id>per typeOne 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.

InputDeclaresSourceWithout data
audioiMusic* · phosphorMusic, phosphorBand, … (see Music engine)WASAPI loopback of the default output device. Never the microphone. Analysed in memory, never recorded.iMusic* all 0
systemvec4 iSystemSystem counters, sampled at 2 Hz.(0, 0, 1, 0)
locationvec3 iLocation · vec2 iSunThe city the user picked (offline GeoNames list). Sun position computed locally (NOAA).iLocation 0; iSun follows the clock
weathervec4 iWeather · vec4 iWeatherMore · int iWeatherCodeMET Norway Locationforecast, cached, refreshed ≤ every 30 min.(15,0,0,0), (0.5,0,0,0), code 0
UniformTypeUnitsComponents
iSystemvec40 – 1x CPU load · y memory in use · z battery level (1 with no battery) · w on battery (0|1).
iLocationvec3deg, deg, 0|1Latitude, longitude, known.
iSunvec2degx elevation above horizon · y azimuth clockwise from north.
iWeathervec4°C, 0–1, mm/h, m/sTemperature, cloud cover, precipitation, wind speed.
iWeatherMorevec40–1, deg, 0|1, hHumidity, wind direction (from), known, hours since reading.
iWeatherCodeint0 – 100 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 vec4 uniforms (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 (dt clamped 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

UniformTypeRangeMeaning
iMusicSpectrum[32]vec4[32]0 – 1128 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 – 1Same 128 bins with no cleanup, +4 dB/decade tilt. Available, but phosphorMusic looks better.
iMusicHits[8]vec4[8]0 – 132 onset envelopes, one per group of 4 spectrum bins. Jumps to the attack strength on a hit, decays e^(−6t).
iMusicLevelvec40 – 1x bass 20–250 Hz · y mids 250 Hz–4 kHz · z treble 4–16 kHz · w overall. Smoothed: attack 30 s⁻¹, release 6 s⁻¹.
iMusicBeatvec4x,y,z 0–1 · w countx 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 – 1Named 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 – 1Onset envelope per named band: a sudden jump over the band’s recent average, not its level. [7] is the strongest. Read with phosphorOnset.
iMusicDrumsvec40 – 1x kick · y snare · z hi-hat · w cymbal hit envelopes, from coinciding band onsets.
iMusicPeakvec40 – 1x 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-life

A 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 s

The 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 release

5Named 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

WantUse
A spectrum graph, bars, a ringphosphorMusic(x)
Something to pump on the kickiMusicBeat.x
Hi-hats / snares sparkleiMusicBeat.z, iMusicBeat.y
Discrete change every N beatsint(iMusicBeat.w) / N, or hash it
Slow mood: speed, brightnessiMusicLevel
Many emitters, each its own slicephosphorMusicHit(x) / iMusicHits
Flash on drumsiMusicDrums
Instrument-aware motionphosphorBand(PHOSPHOR_PRESENCE), phosphorOnset(…)
A cursor, spotlight or comet following the musiciMusicPeak.xy
Honest loudness, a VU meterphosphorMusicRaw(x), iMusicLevel
Shadertoy audio shaderPort 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" }
]
FieldDefaultNotes
id—[a-z][a-z0-9_]*, ≤ 24, unique.
shader—Implements mainImage.
scale10.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.
formatrgba8rgba16f: signed half float, any range.
filterlinearnearest
wrapclamprepeat, mirror
iterations11–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/iResolution are the buffer’s pixels; iFade is ignored; output is stored unclamped.
Initial state
All zero on frame 0 and on resize (scale buffers). Never carried between scenes. One set per monitor.
Memory
2 textures per buffer. A 4K rgba16f buffer 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

UniformTypeNotes
u_photoA, u_photoBsampler2DCurrent and next photo, EXIF-rotated, downscaled.
u_photoASize, u_photoBSizevec2Pixels.
u_photoMixfloat0→1 during a transition, 0 while holding.
u_photoTimefloatSeconds since the current photo became A.
u_photoHold, u_photoTransitionfloatThe user’s timing, seconds.
u_photoIndexintIncrements each photo.
u_videosampler2DVideo scenes: current frame.
u_videoSizevec2(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:

Scenetext + passes
Default8
with transparent (iBackdrop on unit 14)6

The music uniforms use no units.

15Limits

.phosphor package (zip)20 MB
Shader source, per file256 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 iterations1 – 64
pass scale / resolution0.05 – 1 / 16 – 2048 px
Shipped video≤ 512 MB, folder packages only
Performance target60 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:end

Music-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:end

Paste either into the Create tab, or into the web previewer, and it plays.