The VJ's EFFECT surface is a small renderer of its own. An effect is a
`.splash` DOCUMENT — engine choice, stages, fields, parameters and now the
SHADERS THEMSELVES live in the document rather than in Rust. That is the
shape this commit introduces (dev has never seen an intermediate one): a
document is the whole effect, and the Rust side is the engine families that
documents draw with.
The engine families: particles and emitters, GPU sim swarms and fluid, static
meshes (firefly synchrony, harmonograph loom, domino liturgy), tiles, flock,
clouds, city, pipes, stock charts, an SDF raymarcher with a subclassable
`scene_sdf`, and a mountain-jet endless range with a beat-pulsed fighter.
Three things make them a system rather than a demo reel:
- SIM FIELDS — a float simulation-texture primitive, so an engine can carry
GPU state across frames (wind, particles, fluid) instead of being a pure
function of the clock.
- CONTENT COUPLING — `content:` in a document and `input0` on every engine's
tex0, so an effect can consume the program video: drapes, backdrops,
mirrors, billboards, chrome, frescoes, canopy, glass, silk, wall, swarm,
pen and mosaic families all take the picture playing behind them.
- MUSIC BINDING — shaders read the beat, so the whole library moves with the
track rather than on its own clock.
Roughly a hundred seeded preset documents ship with it, each with a lazily
rendered animated thumbnail (rendered 4x and box-downscaled, 16-tap SSAA);
the thumbnail pass went from four minutes to eleven seconds. CONTRACT.md is
the document contract and its verify recipe; IDEAS.md is the campaign tracker.
91 lines
4.3 KiB
Text
91 lines
4.3 KiB
Text
// SCAN SERMON — the pure-document effect: NO engine geometry at all, the
|
|
// whole frame is the fragment function in this file.
|
|
//
|
|
// This is the pattern to copy when an author wants a look no engine has:
|
|
// pick `engine: "screen"`, declare a `shader: draw.DrawVjFxScreen` block,
|
|
// and write `fx_color(uv, content, cmix)`. The engine contributes nothing
|
|
// but the quad and the clock — every pixel below is doc-authored. The
|
|
// stage chain still runs on top, so bloom/feedback/warps compose with it.
|
|
//
|
|
// The look: a broadcast scan head sweeping down the frame, tearing the
|
|
// video into bands that lag behind it, with the beat driving the tear and
|
|
// a phosphor rgb-split on the leading edge.
|
|
{
|
|
name: "Scan Sermon"
|
|
engine: "screen"
|
|
seed: 5
|
|
|
|
input0: "test"
|
|
|
|
speed: 1.0
|
|
beat_pulse: 0.6
|
|
beat_rate: 1.0
|
|
bar_beats: 4
|
|
glow: 1.0
|
|
// PERFORMANCE DIALS — each default reproduces the stock look exactly
|
|
// (mid-knob multipliers are 1.0 at 0.5).
|
|
p0: 0.5 p1: 0.5 p2: 0.5
|
|
dials: [
|
|
{name: "TEAR", bind: "p0", default: 0.5},
|
|
{name: "BANDS", bind: "p1", default: 0.5},
|
|
{name: "SPLIT", bind: "p2", default: 0.5}
|
|
]
|
|
|
|
color_bg: #x03040a
|
|
color_a: #x30ffd0
|
|
color_b: #xff3a86
|
|
color_c: #xfff0d0
|
|
|
|
stages: [
|
|
{kind: "bloom", threshold: 0.55, strength: 1.15, levels: 3}
|
|
]
|
|
|
|
// THE LOOK LIVES HERE. There is no engine shading under this at all:
|
|
// whatever this function returns IS the frame.
|
|
// Inputs: uv = screen uv (0,0 top-left), content = input0 at uv,
|
|
// cmix = the pre-gated content strength (0 without real channel video,
|
|
// so a look can tell the fallback pattern from a live clip).
|
|
// Signals: self.time_beat (time, beat, phase, pulse),
|
|
// self.sig (bar, bpm, energy, dt), self.user (p0..p3),
|
|
// self.col_a/b/c/bg, self.fog (density, glow, content, -).
|
|
shader: draw.DrawVjFxScreen {
|
|
fx_color: fn(uv: vec2, content: vec4, cmix: float) -> vec4 {
|
|
// The scan head: one sweep per bar, wrapping.
|
|
let head = fract(self.sig.x)
|
|
// Horizontal bands; more of them as BANDS opens.
|
|
let bands = 6.0 + 26.0 * clamp(self.user.y, 0.0, 1.0)
|
|
let band = floor(uv.y * bands)
|
|
let band01 = fract(uv.y * bands)
|
|
// Distance BELOW the head, wrapped: a band the head just
|
|
// crossed is hot and displaced, and it settles as it ages.
|
|
let age = fract(uv.y - head + 1.0)
|
|
let hot = pow(1.0 - age, 9.0)
|
|
// Tear: each band slides by its own hash, hardest right after
|
|
// the head passes and on the beat pulse.
|
|
let hash = fract(sin(band * 12.9898 + 3.0) * 43758.5453)
|
|
let drive = (0.30 + 1.7 * clamp(self.user.x, 0.0, 1.0))
|
|
* (0.25 + self.time_beat.w)
|
|
let slide = (hash - 0.5) * hot * drive * 0.35
|
|
let suv = clamp(vec2(fract(uv.x + slide), uv.y), vec2(0.0, 0.0), vec2(1.0, 1.0))
|
|
// Phosphor split: the channels separate along the tear.
|
|
let sp = (0.002 + 0.03 * clamp(self.user.z, 0.0, 1.0)) * (0.2 + hot)
|
|
let r = self.tex0.sample_as_bgra(clamp(suv + vec2(sp, 0.0), vec2(0.0, 0.0), vec2(1.0, 1.0)))
|
|
let g = self.tex0.sample_as_bgra(suv)
|
|
let b = self.tex0.sample_as_bgra(clamp(suv - vec2(sp, 0.0), vec2(0.0, 0.0), vec2(1.0, 1.0)))
|
|
let mut rgb = vec3(r.x, g.y, b.z)
|
|
// Scanline gate inside each band + a dark seam between bands.
|
|
let line = 0.86 + 0.14 * sin(uv.y * 900.0)
|
|
let seam = smoothstep(0.0, 0.06, band01) * smoothstep(0.0, 0.06, 1.0 - band01)
|
|
rgb = rgb * (line * (0.35 + 0.65 * seam))
|
|
// The head itself: a bar of col_a with a col_c core.
|
|
let edge = pow(1.0 - age, 60.0)
|
|
rgb = rgb + self.col_a.xyz * (hot * 0.35) + self.col_c.xyz * (edge * 0.9)
|
|
// A band that has not been scanned yet sits in col_b's dark.
|
|
rgb = rgb.mix(self.col_bg.xyz + self.col_b.xyz * 0.06, (1.0 - hot) * 0.22)
|
|
// Without real content (cmix 0) the fallback pattern is all
|
|
// there is, so lift it a little to keep the frame alive.
|
|
let lift = 1.0 + (1.0 - clamp(cmix, 0.0, 1.0)) * 0.25
|
|
return vec4(rgb * (self.fog.y * lift), 1.0)
|
|
}
|
|
}
|
|
}
|