singe/demos/kit.lua

657 lines
18 KiB
Lua

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