-- The demo kit: what the three demos share. A demo loads it with dofile(DIR .. "../kit.lua") and -- gets back one table. -- -- Time is the music's. K.time() is where the listener is in the track: musicGetPosition while the -- demo plays, or a clock that moves exactly one frame a frame when DEMO_CAPTURE asks for a capture -- (see util/demoCapture.sh), so a captured frame lands on the music added afterwards. Everything -- else is read off that time: beats and bars from the beat list util/demoSync.py wrote, the drums, -- the bass, the loudness and the brightness as envelopes from 0 to 1, and the drum hits as lists. -- -- A demo is a list of scenes, each with the bars it spans: { from = 17, to = 33, enter = fn, draw = -- fn, leave = fn }. K.run calls enter once as the scene starts, draw every frame it is on with the -- time, the bar, and how far through it is from 0 to 1, and leave once as it ends. Scenes may -- overlap, and are drawn in list order. local K = {} local ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" local ENVELOPES = { "kick", "snare", "hat", "bass", "level", "bright", "noteClear" } local HIT_LISTS = { "kicks", "snares", "hats" } local LEVELS = 63 local PIXEL_PNG = "\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\x0b\x49\x44\x41\x54\x78\x9c\x63\xf8\x0f\x04\x00\x09\xfb\x03\xfd\xfb\x5e\x6b\x2b\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82" local FONT_FILE = "Singe/FreeSansBold.ttf" local TAIL = 2 -- Seconds the demo runs on after the music ends local sync = nil local music = nil local fonts = {} local widths = {} local pixel = nil local startTicks = nil local lastTime = 0 local lastTicks = 0 local frames = 0 local stats = { second = 0, frames = 0, worst = 0, last = 0 } K.W = 640 K.H = 480 -- The fractional bar at time t, counting from 1: bar 1 starts on the beat the sync data names. function K.bar(t) return (K.beat(t) - (sync.barStart - 1)) / 4 + 1 end -- When bar b starts; fractional bars land between beats. function K.barTime(b) return K.beatTime((b - 1) * 4 + (sync.barStart - 1)) end -- The fractional beat at time t, counting the first beat of the list as 0. Before the first beat -- and after the last the spacing of the nearest two carries on. function K.beat(t) local beats = sync.beats local n = #beats local lo = 1 local hi = n if t <= beats[1] then return (t - beats[1]) / (beats[2] - beats[1]) end if t >= beats[n] then return n - 1 + (t - beats[n]) / (beats[n] - beats[n - 1]) end while hi - lo > 1 do local mid = (lo + hi) // 2 if beats[mid] <= t then lo = mid else hi = mid end end return lo - 1 + (t - beats[lo]) / (beats[hi] - beats[lo]) end -- When fractional beat i falls. function K.beatTime(i) local beats = sync.beats local n = #beats local whole = math.floor(i) local part = i - whole if whole < 0 then return beats[1] + i * (beats[2] - beats[1]) end if whole >= n - 1 then return beats[n] + (i - (n - 1)) * (beats[n] - beats[n - 1]) end return beats[whole + 1] + part * (beats[whole + 2] - beats[whole + 1]) end -- Vector bobs: count balls on a shape that changes every two bars (a ring, a helix, a sphere, a -- flower), turned in 3D and drawn back to front at the size their distance gives them, each tinted -- round the colour wheel. sprite is a white shaded ball; spread scales the shape; size is a ball's -- radius in pixels at the shape's middle distance. local BOB_DEPTH = 5 local function bobShape(kind, i, count) local k = (i - 1) / count local a = k * 2 * math.pi if kind == 0 then return math.cos(a) * 1.6, math.sin(a * 3) * 0.3, math.sin(a) * 1.6 elseif kind == 1 then return math.cos(a * 3) * 1.0, (k - 0.5) * 3.2, math.sin(a * 3) * 1.0 elseif kind == 2 then local phi = math.acos(1 - 2 * (i - 0.5) / count) local theta = math.pi * (1 + math.sqrt(5)) * i return math.cos(theta) * math.sin(phi) * 1.5, math.cos(phi) * 1.5, math.sin(theta) * math.sin(phi) * 1.5 end local r = 1 + 0.5 * math.sin(a * 5) return math.cos(a) * r * 1.4, math.sin(a) * r * 1.4, math.sin(a * 5) * 0.4 end function K.bobs(t, bar, sprite, count, spread, size, cx, cy) local shape = math.floor((bar - 1) / 2) % 4 local next = (shape + 1) % 4 local morph = K.smooth(0.75, 1.0, ((bar - 1) / 2) % 1) local ay = t * 0.9 local ax = t * 0.5 local pts = {} local pump = 1 + 0.25 * K.decay("kicks", t, 8) for i = 1, count do local x1, y1, z1 = bobShape(shape, i, count) local x2, y2, z2 = bobShape(next, i, count) local x = K.lerp(x1, x2, morph) * spread * pump local y = K.lerp(y1, y2, morph) * spread * pump local z = K.lerp(z1, z2, morph) * spread * pump local x3 = x * math.cos(ay) + z * math.sin(ay) local z3 = -x * math.sin(ay) + z * math.cos(ay) local y3 = y * math.cos(ax) - z3 * math.sin(ax) local z4 = y * math.sin(ax) + z3 * math.cos(ax) pts[i] = { x = x3, y = y3, z = z4 + BOB_DEPTH, i = i } end table.sort(pts, function(a, b) return a.z > b.z end) for _, p in ipairs(pts) do local s = BOB_DEPTH / p.z local r = size * s local px = cx + p.x * s * 60 local py = cy + p.y * s * 60 local cr, cg, cb = K.hue(p.i / count + t * 0.1) spriteSetTint(sprite, cr, cg, cb) spriteDraw(sprite, math.floor(px - r), math.floor(py - r), math.floor(px + r), math.floor(py + r)) end spriteSetTint(sprite, 255, 255, 255) end -- A filled rectangle, corners inclusive, in any colour and alpha: the kit's one-pixel sprite -- stretched and tinted. blend is a SPRITE_ constant, alpha by default. function K.box(x1, y1, x2, y2, r, g, b, a, blend) if x2 < x1 or y2 < y1 then return end spriteSetTint(pixel, r, g, b) spriteSetAlpha(pixel, a or 255) spriteSetBlend(pixel, blend or SPRITE_ALPHA) spriteDraw(pixel, math.floor(x1), math.floor(y1), math.floor(x2), math.floor(y2)) end -- A chrome material: metal, polished to roughness, tinted r, g, b. It needs a sky to reflect. function K.chrome(r, g, b, roughness) local m = materialNew() materialSetColor(m, r, g, b) materialSetMetallic(m, 1) materialSetRoughness(m, roughness) return m end function K.clamp(v, lo, hi) if v < lo then return lo end if v > hi then return hi end return v end -- How long ago the most recent hit in a list ("kicks", "snares", "hats") was at time t; huge when -- there has been none. An effect fed exp(-age * k) flashes on every hit and dies away. function K.decay(list, t, k) return math.exp(-K.hitAge(list, t) * k) end function K.ease(t) t = K.clamp(t, 0, 1) return t * t * (3 - 2 * t) end function K.easeIn(t) t = K.clamp(t, 0, 1) return t * t * t end function K.easeInOut(a, b, t) return a + (b - a) * (0.5 - math.cos(K.clamp(t, 0, 1) * math.pi) / 2) end function K.easeOut(t) t = 1 - K.clamp(t, 0, 1) return 1 - t * t * t end -- An envelope's value at time t, 0 to 1: "kick", "snare", "hat", "bass", "level", "bright", or -- "noteClear". function K.env(name, t) local curve = sync[name] local f = t * sync.rate local i = math.floor(f) if i < 0 then return curve[1] end if i >= #curve - 1 then return curve[#curve] end return curve[i + 1] + (f - i) * (curve[i + 2] - curve[i + 1]) end -- The whole screen in one colour: a flash, a fade, or a tint. function K.flash(r, g, b, a, blend) K.box(0, 0, K.W - 1, K.H - 1, r, g, b, a, blend) end -- Coloured glass: see-through by alpha, polished, drawn after everything solid. function K.glass(r, g, b, alpha) local m = materialNew() materialSetColor(m, r, g, b, alpha or 90) materialSetBlend(m, true) materialSetRoughness(m, 0.05) return m end -- Seconds since the most recent hit in a list at time t. function K.hitAge(list, t) local hits = sync[list] local lo = 0 local hi = #hits + 1 while hi - lo > 1 do local mid = (lo + hi) // 2 if hits[mid] <= t then lo = mid else hi = mid end end if lo == 0 then return math.huge end return t - hits[lo] end -- How many hits in a list fall after t0 and up to t1: what a frame's worth of time held. function K.hitCount(list, t0, t1) local count = 0 for _, h in ipairs(sync[list]) do if h > t1 then break end if h > t0 then count = count + 1 end end return count end -- A full-bright colour from a hue, 0 to 1 round the wheel. function K.hue(h) local f = (h % 1) * 6 local i = math.floor(f) local p = f - i local c = ({ { 1, p, 0 }, { 1 - p, 1, 0 }, { 0, 1, p }, { 0, 1 - p, 1 }, { p, 0, 1 }, { 1, 0, 1 - p } })[i + 1] return math.floor(c[1] * 255), math.floor(c[2] * 255), math.floor(c[3] * 255) end function K.lerp(a, b, t) return a + (b - a) * t end -- Reads the demo's sync data and its music, makes the overlay, and loads the kit's own pixel. -- Envelopes become arrays of numbers from 0 to 1 here, once. function K.load(dir, width, height) local value = {} K.W = width or K.W K.H = height or K.H overlaySetResolution(K.W, K.H) for i = 1, #ALPHABET do value[ALPHABET:byte(i)] = (i - 1) / LEVELS end sync = dofile(dir .. "sync.lua") for _, name in ipairs(ENVELOPES) do local text = sync[name] local curve = {} for i = 1, #text do curve[i] = value[text:byte(i)] end sync[name] = curve end local notes = {} for i = 1, #sync.note do notes[i] = math.floor(value[sync.note:byte(i)] * LEVELS + 0.5) end sync.note = notes for _, name in ipairs(HIT_LISTS) do sync[name] = sync[name] or {} end K.duration = sync.duration if not DEMO_CAPTURE then music = musicLoad(dir .. "music.mp3") end pixel = spriteLoadData(PIXEL_PNG) return sync end -- The strongest pitch class at time t (0 is C) and how clearly it stands out, 0 to 1. function K.note(t) local i = K.clamp(math.floor(t * sync.rate) + 1, 1, #sync.note) return sync.note[i], sync.noteClear[i] end -- Every mesh node under a model instance gets the material. function K.paint(node, material) nodeSetMaterial(node, material) for _, child in ipairs(nodeGetChildren(node)) do K.paint(child, material) end end -- Starts the music and the clock, and takes over onOverlayUpdate to run the scenes. Under -- DEMO_CAPTURE = { from = seconds, to = seconds, every = frames } nothing plays: the clock starts -- at from and moves by the frame (run Singe with --deterministic), every frame (or every "every"th) -- is saved as frameNNNNNN.png, and the demo quits at to. function K.run(scenes) local active = {} if DEMO_CAPTURE then debugPrint(string.format("DEMO capture from %.3f to %.3f", DEMO_CAPTURE.from, DEMO_CAPTURE.to)) else musicPlay(music) end function onOverlayUpdate() local t = K.time() local bar = K.bar(t) frames = frames + 1 if DEMO_STATS then K.stats(t, bar) end colorBackground(0, 0, 0, 0) overlayClear() for i, scene in ipairs(scenes) do local on = bar >= scene.from and bar < scene.to if on and not active[i] then active[i] = true if scene.enter then scene.enter(t) end elseif not on and active[i] then active[i] = false if scene.leave then scene.leave(t) end end if on and scene.draw then scene.draw(t, bar, (bar - scene.from) / (scene.to - scene.from)) end end if DEMO_CAPTURE then if frames % (DEMO_CAPTURE.every or 1) == 0 then singeScreenshot(string.format("frame%06d", frames)) end if t >= DEMO_CAPTURE.to then debugPrint(string.format("DEMO captured %d frames", frames)) singeQuit() end elseif t >= sync.duration + TAIL then singeQuit() end return OVERLAY_UPDATED end end -- Under DEMO_STATS, once a second: frames drawn in the last second, and the longest gap between two. function K.stats(t, bar) local now = singeGetTicks() if stats.last > 0 then stats.worst = math.max(stats.worst, now - stats.last) end stats.last = now stats.frames = stats.frames + 1 if now - stats.second >= 1000 then debugPrint(string.format("DEMO t %6.2f bar %6.2f %3d fps worst %3d ms", t, bar, stats.frames, stats.worst)) stats.second = now stats.frames = 0 stats.worst = 0 end end -- A flat quad that exactly fills the camera's view at distance d (oversize makes it larger, to stay -- covering while it turns): for a video over, or as, the whole picture in 3D, where it can turn and -- glow. What comes back is a pivot on the camera's axis with the quad stood up under it, so the -- pivot's roll, nodeSetRotation(q, 0, 0, angle), turns the quad in its own plane. It starts hidden. function K.screenQuad(camera, fov, material, d, oversize) local h = 2 * d * math.tan(math.rad(fov / 2)) * (oversize or 1) local pivot = nodeNew(camera) local quad = nodeNew(pivot) nodeSetPosition(pivot, 0, 0, -d) nodeSetMesh(quad, meshPlane(h * K.W / K.H, h), material) nodeSetRotation(quad, 90, 0, 0) nodeSetVisible(pivot, false) return pivot end -- A sine scroller: each letter a sprite made once in the font, coloured by where it is on the -- screen, riding a wave of amplitude amp. It enters from the right at t0 and runs at speed pixels -- a second. local glyphs = {} function K.scroller(t, font, text, y, amp, speed, t0) local x = K.W - (t - t0) * speed for i = 1, #text do local key = font .. text:sub(i, i) local g = glyphs[key] if not g then fontSelect(font) colorForeground(255, 255, 255, 255) fontQuality(FONT_QUALITY_BLENDED) local s = fontToSprite(text:sub(i, i)) g = { sprite = s, w = spriteGetWidth(s) } glyphs[key] = g end if x > K.W then break end if x + g.w > 0 then local r, gg, b = K.hue(x / K.W * 0.5 + t * 0.2) spriteSetTint(g.sprite, r, gg, b) spriteDraw(g.sprite, math.floor(x), math.floor(y + math.sin(x * 0.018 + t * 4) * amp)) end x = x + g.w end end function K.smooth(edge0, edge1, x) return K.ease((x - edge0) / (edge1 - edge0)) end -- A full-screen flash that fires on every hit in a list and dies away at rate k: a = 0 is off. function K.strobe(t, list, k, r, g, b, a) local v = K.decay(list, t, k) if v > 0.02 and (a or 255) > 0 then K.flash(r, g, b, math.floor((a or 255) * v)) end end -- How wide text is in the bundled font at a point size, in pixels; measured once and remembered. function K.textWidth(text, size) local key = size .. ":" .. text local w = widths[key] if not w then local font = fonts[size] if not font then font = fontLoad(FONT_FILE, size) fonts[size] = font end fontSelect(font) local sprite = fontToSprite(text) w = spriteGetWidth(sprite) widths[key] = w spriteUnload(sprite) end return w end -- Text in the bundled font at a point size, in a colour, left, centred ("center"), or right -- aligned on x. function K.text(x, y, text, size, r, g, b, align) local font = fonts[size] if not font then font = fontLoad(FONT_FILE, size) fonts[size] = font end fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) colorForeground(r, g, b, 255) if align then local w = K.textWidth(text, size) fontSelect(font) if align == "center" then x = x - w // 2 else x = x - w end end fontPrint(math.floor(x), math.floor(y), text) end -- Where the listener is in the music, in seconds. It never runs backwards, which a mixer -- position read just after a buffer moved can otherwise do by a millisecond, and once the music has -- ended it runs on with the clock. function K.time() local now = singeGetTicks() local t = 0 startTicks = startTicks or now if DEMO_CAPTURE then t = DEMO_CAPTURE.from + (now - startTicks) / 1000 else t = musicGetPosition(music) if t < 0 then t = (lastTime > 0) and (lastTime + (now - lastTicks) / 1000) or 0 end end lastTime = math.max(lastTime, t) lastTicks = now return lastTime end -- A (2, 3) torus knot as a tube, built vertex by vertex for meshNew: the curve, a frame along it, and a ring -- of vertices round each point. function K.torusKnot(radius, tube, segments, sides) local positions = {} local normals = {} local uvs = {} local indices = {} local function curve(phi) local r = radius * (1 + 0.42 * math.cos(3 * phi)) return r * math.cos(2 * phi), r * math.sin(2 * phi), radius * 0.42 * math.sin(3 * phi) end for i = 0, segments - 1 do local phi = i / segments * 2 * math.pi local px, py, pz = curve(phi) local qx, qy, qz = curve(phi + 0.01) local tx = qx - px local ty = qy - py local tz = qz - pz local tl = math.sqrt(tx * tx + ty * ty + tz * tz) tx = tx / tl ty = ty / tl tz = tz / tl -- A normal that never lines up with the tangent: the knot never runs straight up the z axis. local nx = ty * 1 - tz * 0 local ny = tz * 0 - tx * 1 local nz = tx * 0 - ty * 0 local nl = math.sqrt(nx * nx + ny * ny + nz * nz) + 1e-9 nx = nx / nl ny = ny / nl nz = nz / nl local bx = ty * nz - tz * ny local by = tz * nx - tx * nz local bz = tx * ny - ty * nx for j = 0, sides - 1 do local a = j / sides * 2 * math.pi local cx = math.cos(a) * nx + math.sin(a) * bx local cy = math.cos(a) * ny + math.sin(a) * by local cz = math.cos(a) * nz + math.sin(a) * bz positions[#positions + 1] = px + cx * tube positions[#positions + 1] = py + cy * tube positions[#positions + 1] = pz + cz * tube normals[#normals + 1] = cx normals[#normals + 1] = cy normals[#normals + 1] = cz uvs[#uvs + 1] = i / segments uvs[#uvs + 1] = j / sides end end for i = 0, segments - 1 do local i2 = (i + 1) % segments for j = 0, sides - 1 do local j2 = (j + 1) % sides local a = i * sides + j + 1 local b = i2 * sides + j + 1 local c = i2 * sides + j2 + 1 local d = i * sides + j2 + 1 indices[#indices + 1] = a indices[#indices + 1] = b indices[#indices + 1] = c indices[#indices + 1] = a indices[#indices + 1] = c indices[#indices + 1] = d end end return meshNew(positions, normals, uvs, indices) end return K