More demo and some engine work.

This commit is contained in:
Scott Duensing 2026-10-05 22:12:53 -05:00
parent 15c603224a
commit 0e443f3adb
56 changed files with 4928 additions and 828 deletions

View file

@ -9,6 +9,12 @@ SINGE 3.00
API Changes API Changes
----------- -----------
- physicsSetGravity wakes every body when gravity changes. A body the
physics had put to sleep at rest stayed asleep under the new gravity, so a
crate left on a shelf hung in the air when gravity turned sideways, and
cloth that had hung still for a while ignored a wind made of gravity.
Setting the same gravity again wakes nothing.
- onOverlayUpdate is due 15 ms after the previous call began, not 15 ms after - onOverlayUpdate is due 15 ms after the previous call began, not 15 ms after
it returned. A script that took 5 ms a frame used to run every 20 ms, and it returned. A script that took 5 ms a frame used to run every 20 ms, and
one whose work varied from frame to frame ran at an uneven pace; now the one whose work varied from frame to frame ran at an uneven pace; now the
@ -909,10 +915,14 @@ API Changes
- drawTransparent(boolean), getUserString(), mouseGetPadding() and - drawTransparent(boolean), getUserString(), mouseGetPadding() and
spriteBlend(boolean, sprite) are registered, completing the set of spriteBlend(boolean, sprite) are registered, completing the set of
names Hypseus's engine exposes. All four are compatibility answers: names Hypseus's engine exposes. drawTransparent works as it does in
Singe carries transparency in the alpha channel rather than a colour Hypseus: sprites and text normally lose colour 0, pure black included
key, sends no statistics and so has no machine ID to report, adds no even where the picture has an alpha channel, and drawTransparent(true)
offset to mouse input codes, and never has blending switched off. makes the ones loaded after it go by their alpha alone, so black
lettering and outlines stay. The other three are compatibility
answers: Singe sends no statistics and so has no machine ID to report,
adds no offset to mouse input codes, and never has blending switched
off.
- allowSocketCall() and setOverlayFullAlpha() are registered so a game - allowSocketCall() and setOverlayFullAlpha() are registered so a game
that calls them does not stop on a missing function. The first always that calls them does not stop on a missing function. The first always
@ -1149,6 +1159,27 @@ New Features
database packed into a game would not open at all, though the same game run database packed into a game would not open at all, though the same game run
loose worked. loose worked.
- A script can draw a picture a pixel at a time. spriteNew(width, height)
makes a blank sprite and spriteSetPixels fills it from a string, four bytes
a pixel, or a byte a pixel with a 256-colour palette, so turning the
palette round animates it with nothing recomputed. A 320 by 240 picture
built in Lua every frame takes a few milliseconds: plasmas, tunnels, fire,
minimaps. The result draws, turns and scales like any sprite, and
materialSetTexture puts it on a surface in 3D.
- Recording. F10 starts recording the game, picture and sound together, to
singeNNN.mp4 in the data directory beside the screenshots, and F10 again
stops it; singeRecordStart, singeRecordStop and singeIsRecording do the
same from a script, and --record=FILE records a whole run. The picture is
H.264 where the machine has a hardware encoder (VA-API, VideoToolbox, Media
Foundation, or V4L2) and MPEG-4 everywhere else; the sound is AAC. Encoding
happens on a thread of its own, so a game pays for copying each frame and
little else, and a frame that comes while the encoder is busy is dropped
from the recording rather than slowing the game. With --deterministic every
frame is kept and the sound is mixed into memory a step at a time, so the
recording matches the picture sample for sample, and on a desktop it runs
faster than real time.
- musicGetPosition(id) says where the listener is in a playing track, in - musicGetPosition(id) says where the listener is in a playing track, in
seconds: the mixer's own position, less the device queue, the machine's seconds: the mixer's own position, less the device queue, the machine's
audio calibration and the game's delay, so a picture timed to it lands with audio calibration and the game's delay, so a picture timed to it lands with
@ -1156,6 +1187,14 @@ New Features
mixer's buffers. Animating to the music no longer means counting ticks from mixer's buffers. Animating to the music no longer means counting ticks from
musicPlay and hoping they keep pace. musicPlay and hoping they keep pace.
- 3D in front of the overlay. nodeSetLayer(node, LAYER_FRONT) moves a node,
and everything parented to it, out of the scene beneath the overlay and
into a front layer drawn after it, by the same camera with the same lights,
shadows, and bloom. A spinning logo, a 3D title, or a model on a score
screen can now stand over 2D artwork instead of hiding behind it. The
overlay shows wherever the front layer draws nothing, and GUIs still go on
top. LAYER_SCENE puts a node back.
- Sprites can be added, multiplied, faded and tinted. spriteSetBlend chooses - Sprites can be added, multiplied, faded and tinted. spriteSetBlend chooses
SPRITE_ALPHA, SPRITE_ADD or SPRITE_MULTIPLY; spriteSetAlpha fades a sprite SPRITE_ALPHA, SPRITE_ADD or SPRITE_MULTIPLY; spriteSetAlpha fades a sprite
from 255 to nothing; spriteSetTint multiplies its colour, so one white from 255 to nothing; spriteSetTint multiplies its colour, so one white
@ -1165,12 +1204,14 @@ New Features
- Three demos in demos/: Neon Coast, Mirrorball and Insert Coin, each set to - Three demos in demos/: Neon Coast, Mirrorball and Insert Coin, each set to
a song and timed to it bar by bar, beat by beat and drum hit by drum hit. a song and timed to it bar by bar, beat by beat and drum hit by drum hit.
Between them they use the 3D scene with chrome, glass, bloom and fog, Between them they use the 3D scene with chrome, glass, bloom and fog,
physics, cloth and particles, video scrubbed by the music and played on physics, cloth and particles, a plasma, a tunnel and a tape's snow worked
surfaces in 3D, and sprites turned, scaled, stretched a row at a time, out a pixel at a time in Lua every frame and shown flat and on surfaces in
tinted and added. The tools that made them are in util/: demoSync.py 3D, and sprites turned, scaled, stretched a row at a time, tinted and
reads a song's beats, bars and drums, demoVideo.py precalculates the video added. Nothing in them is a recording. The tools that made them are in
effects, demoCapture.sh renders a demo to a video, and an art script per util/: demoSync.py reads a song's beats, bars and drums, demoRecord.sh
demo draws every picture. records a demo to a video with Singe's own recorder, demoCapture.sh saves
stretches of one as pictures, and an art script per demo draws every
picture.
- spriteDrawGrid can stretch the rectangle it cuts out: given x2 and y2 after - spriteDrawGrid can stretch the rectangle it cuts out: given x2 and y2 after
the rectangle, it fills that box the way spriteDraw's stretched form does. the rectangle, it fills that box the way spriteDraw's stretched form does.

View file

@ -411,6 +411,8 @@ set(SINGE_SOURCE
src/voice.h src/voice.h
src/netTrust.c src/netTrust.c
src/netTrust.h src/netTrust.h
src/record.c
src/record.h
thirdparty/tinysoundfont/tsf.h thirdparty/tinysoundfont/tsf.h
thirdparty/tinysoundfont/tml.h thirdparty/tinysoundfont/tml.h
src/render.c src/render.c

View file

@ -71,6 +71,7 @@ INPUT_CONSOLE = { SCANCODE.GRAVE }
INPUT_ACTION_4 = { SCANCODE.RSHIFT, GAMEPAD_0.BUTTON_Y, MOUSE_0.BUTTON_X1 } INPUT_ACTION_4 = { SCANCODE.RSHIFT, GAMEPAD_0.BUTTON_Y, MOUSE_0.BUTTON_X1 }
INPUT_TILT = { SCANCODE.T } INPUT_TILT = { SCANCODE.T }
INPUT_GRAB = { SCANCODE.G } INPUT_GRAB = { SCANCODE.G }
INPUT_RECORD = { SCANCODE.F10 } -- Starts and stops recording the picture and the sound to singeNNN.mp4.
-- Not a control: the engine raises this one itself when a mouse or a light gun is unplugged, so a -- Not a control: the engine raises this one itself when a mouse or a light gun is unplugged, so a
-- gun game can say so on screen. Bind a key to it as well if you want to test what the game does. -- gun game can say so on screen. Bind a key to it as well if you want to test what the game does.
INPUT_MOUSE_DISCONNECT = { } INPUT_MOUSE_DISCONNECT = { }

View file

@ -384,17 +384,24 @@ ExternalProject_Add(dav1d
singeRebuildTarget(dav1d) singeRebuildTarget(dav1d)
# Recording (src/record.c) encodes with the platform's own H.264 hardware where there is one, and
# with FFmpeg's built-in MPEG-4 everywhere; sound is AAC, and the file is MP4.
set(recordEncoders mpeg4,aac)
if(KANGAROO_OS STREQUAL "linux") if(KANGAROO_OS STREQUAL "linux")
# ARM boards decode through a V4L2 memory-to-memory device; desktops have VAAPI or VDPAU. # ARM boards decode through a V4L2 memory-to-memory device; desktops have VAAPI or VDPAU.
if(KANGAROO_ARCH MATCHES "^(aarch64|armhf)$") if(KANGAROO_ARCH MATCHES "^(aarch64|armhf)$")
set(hwaccel --enable-v4l2-m2m --enable-libdrm ${rkmppFlag}) set(hwaccel --enable-v4l2-m2m --enable-libdrm ${rkmppFlag})
string(APPEND recordEncoders ,h264_v4l2m2m)
else() else()
set(hwaccel --enable-vaapi --enable-vdpau --enable-libdrm ${rkmppFlag}) set(hwaccel --enable-vaapi --enable-vdpau --enable-libdrm ${rkmppFlag})
string(APPEND recordEncoders ,h264_vaapi)
endif() endif()
elseif(KANGAROO_OS STREQUAL "macos") elseif(KANGAROO_OS STREQUAL "macos")
set(hwaccel --enable-videotoolbox) set(hwaccel --enable-videotoolbox)
string(APPEND recordEncoders ,h264_videotoolbox)
elseif(KANGAROO_OS STREQUAL "windows") elseif(KANGAROO_OS STREQUAL "windows")
set(hwaccel --enable-d3d11va --enable-dxva2) set(hwaccel --enable-d3d11va --enable-dxva2 --enable-mediafoundation)
string(APPEND recordEncoders ,h264_mf)
else() else()
set(hwaccel "") set(hwaccel "")
endif() endif()
@ -426,7 +433,7 @@ ExternalProject_Add(ffmpeg
SOURCE_DIR ${SB_THIRDPARTY}/ffmpeg SOURCE_DIR ${SB_THIRDPARTY}/ffmpeg
BINARY_DIR ${ffmpegBinary} BINARY_DIR ${ffmpegBinary}
DEPENDS dav1d ${rkmppDep} DEPENDS dav1d ${rkmppDep}
CONFIGURE_COMMAND ${SB_ENV} ${SB_THIRDPARTY}/ffmpeg/configure --enable-static --disable-shared --disable-debug --disable-muxers ${hwaccel} --disable-encoders --disable-filters --enable-filter=bwdif --enable-filter=yadif --enable-filter=buffer --enable-filter=buffersink --enable-filter=format --disable-network --disable-devices --disable-vulkan --disable-d3d12va --disable-bzlib --disable-lzma --disable-doc --disable-programs --enable-libdav1d --enable-gpl --enable-version3 --extra-ldflags=-L${SB_PREFIX}/lib --prefix=${SB_PREFIX} --arch=${KANGAROO_ARCH} --target-os=${SINGE_CROSS_OS} ${ffmpegTools} CONFIGURE_COMMAND ${SB_ENV} ${SB_THIRDPARTY}/ffmpeg/configure --enable-static --disable-shared --disable-debug --disable-muxers --enable-muxer=mp4 ${hwaccel} --disable-encoders --enable-encoder=${recordEncoders} --disable-filters --enable-filter=bwdif --enable-filter=yadif --enable-filter=buffer --enable-filter=buffersink --enable-filter=format --disable-network --disable-devices --disable-vulkan --disable-d3d12va --disable-bzlib --disable-lzma --disable-doc --disable-programs --enable-libdav1d --enable-gpl --enable-version3 --extra-ldflags=-L${SB_PREFIX}/lib --prefix=${SB_PREFIX} --arch=${KANGAROO_ARCH} --target-os=${SINGE_CROSS_OS} ${ffmpegTools}
BUILD_COMMAND ${SB_ENV} make BUILD_COMMAND ${SB_ENV} make
INSTALL_COMMAND ${SB_ENV} make install INSTALL_COMMAND ${SB_ENV} make install
LOG_CONFIGURE ON LOG_BUILD ON LOG_INSTALL ON LOG_OUTPUT_ON_FAILURE ON LOG_CONFIGURE ON LOG_BUILD ON LOG_INSTALL ON LOG_OUTPUT_ON_FAILURE ON

View file

@ -14,8 +14,29 @@ frame rate.
Each folder has its own `games.dat`, so Singe runs a demo like any game: from this folder, Each folder has its own `games.dat`, so Singe runs a demo like any game: from this folder,
`Singe neonCoast/neonCoast.singe`. They need the 3D scene. `kit.lua` is the code all three share: `Singe neonCoast/neonCoast.singe`. They need the 3D scene. `kit.lua` is the code all three share:
the music clock, beats and bars, the envelopes and drum hits, the scene runner, and the effects the music clock, beats and bars, the envelopes and drum hits, the scene runner, and the effects
more than one demo uses (vector bobs, the sine scroller, flashes and strobes, chrome and glass more than one demo uses: vector bobs, scrollers of five kinds (sine, bounce, zoom, spin, round a
materials, a camera-filling quad, a torus knot). circle), a 3D scroller, and a perspective crawl, flashes and strobes, chrome and glass materials, a camera-filling
quad, a torus knot, and three effects worked out a pixel at a time in Lua every frame and handed to
a sprite with `spriteSetPixels`: a plasma with a cycling palette, a lookup-table tunnel, and a
tape's snow. Nothing in any of the three demos is a video.
Every demo opens on the Singe logo, faded up from black to white, held, and faded out, before its
music starts, and closes the same way on the Kangaroo Punch logo after the music ends and the
picture fades to black. The logos are rendered from assets/ into demos/art/ by util/demoLogos.sh.
They are on the demo's own clock (the opening card runs from -3.5 s to 0), so util/demoCapture.sh
takes them with a negative start or a stretch past the end.
Scroller text can be changed freely. Each scroller is given the bars it runs between, not a speed:
`K.scroller(t, font, text, y, amp, startTime, endTime, style)` works its speed out from the text so
the first letter enters as the scene starts and the last leaves as it ends. Text too short to fill
the time at a readable speed is repeated until it does; text too long to read at its speed still
ends on time and puts a line in the trace (`DEMO scroller too long to read`, with about how many
characters would fit). `K.scroller3D(t, blocks, text, y, startTime, endTime)` is timed the same
way, with solid letters from `K.blockFont(glyphs, camera, fov, cell, alpha)`: a pixel font
extruded into blocks, hung off the camera in the scene's front layer (`nodeSetLayer`), so they
stand over 2D and 3D alike. Its letters are larger, so it is allowed to go slower before it
repeats. The text round the circle is written in over the first half of its scene,
and the crawl spaces whatever lines it is given over its scene, the same way.
## How the timing works ## How the timing works
@ -45,17 +66,19 @@ steps on every beat, a tunnel whose speed the kick pushes.
| 1-3 | pads only | a tape starts: snow, the deck's lettering, the title typed out | | 1-3 | pads only | a tape starts: snow, the deck's lettering, the title typed out |
| 4-16 | bass in | the sunset grid; the chrome logotype rises at 9, the title slams in at 13 | | 4-16 | bass in | the sunset grid; the chrome logotype rises at 9, the title slams in at 13 |
| 17-18 | bass out, snare build | the logotype turns, the fog closes in, strobes on the snare roll | | 17-18 | bass out, snare build | the logotype turns, the fog closes in, strobes on the snare roll |
| 19-28 | full groove | the night drive: a streetlight passes the car on every beat | | 19-28 | full groove | the night drive, fast: two streetlights pass the car on every beat, a 3D city on the horizon; a bouncing scroller |
| 29-30 | bass out | the car takes a crest and flies, in slow motion | | 29-30 | bass out | the car flies off the crest in slow motion, the road dropping away below, and lands on bar 31 |
| 31-46 | main section | the chrome knot and glass, a cut every two bars; plasma billboards from 39 | | 31-38 | main section | the chrome knot and glass, a cut on every downbeat; a spinning scroller |
| 47-48 | filtered drop | the tape rewinds | | 39-46 | main section | slaloming round the towers of a neon city, a swerve a bar and a barrel roll every two |
| 47-48 | filtered drop | the tape rewinds the flight |
| 49-58 | busy hats | the tunnel, scrubbed by the kick; vector bobs and the scroller | | 49-58 | busy hats | the tunnel, scrubbed by the kick; vector bobs and the scroller |
| 59-60 | bass out | the tunnel stops, the bobs fall in, ARE YOU READY | | 59-60 | bass out | the tunnel stops, the bobs fall in, ARE YOU READY |
| 61-68 | loud | plasma, a rotozoomed logo, a ring of palms | | 61-64 | loud | plasma, a rotozoomed logo, a ring of palms, text written round a circle |
| 69-76 | breakdown, then the kick | a flag on the night grid, blown by the kicks once they return | | 65-68 | loud | low and fast over neon mountains toward the sun, under a perspective crawl of Singe's features |
| 69-76 | breakdown, then the kick | a cloth flag waving on the night grid, gusting with the kicks once they return; a zooming scroller |
| 77-78 | riser, dead stop | into hyperspace, white, and black for the stop | | 77-78 | riser, dead stop | into hyperspace, white, and black for the stop |
| 79-94 | finale | the road and the chrome, cut every two bars, fireworks on the snares | | 79-94 | finale | road, city, knot, and mountains, cut every two bars, fireworks on the snares; over the road the logotype and the dragon, beating its wings on the beat |
| 95- | ending | the set switches off; credits | | 95- | ending | the set switches off |
**Mirrorball** (eight-bar phrases from bar 1 at 0.79 s; the bars shorten as the song speeds up) **Mirrorball** (eight-bar phrases from bar 1 at 0.79 s; the bars shorten as the song speeds up)
@ -64,49 +87,64 @@ steps on every beat, a tunnel whose speed the kick pushes.
| 1-8 | choppy drums and bass | copper bars gated by the groove, the title dropping a letter at a time | | 1-8 | choppy drums and bass | copper bars gated by the groove, the title dropping a letter at a time |
| 9-16 | chords | the club: the ball comes down, spots sweep, the floor swaps on every beat | | 9-16 | chords | the club: the ball comes down, spots sweep, the floor swaps on every beat |
| 17-24 | lead | a rotozoomer, copper bars coloured by the note being played, the scroller | | 17-24 | lead | a rotozoomer, copper bars coloured by the note being played, the scroller |
| 25-32 | full | a mirrored kaleidoscope of plasma under vector bobs | | 25-28 | full | a mirrored kaleidoscope of plasma under vector bobs |
| 33-40 | choppy again | the club, cut on every beat, black in the gaps | | 29-32 | | a cloud of glowing dots that takes a new shape every bar |
| 33-40 | choppy again | the club, cut on every beat, black in the gaps, the lasers changing pattern every bar |
| 41-48 | denser hats | the twister, drawn a scanline at a time, over copper bars | | 41-48 | denser hats | the twister, drawn a scanline at a time, over copper bars |
| 49-56 | horns | a checkered ball bouncing on the beat; a ball drops on every snare and piles up | | 49-52 | horns | a checkered ball bouncing on the beat; a ball drops on every snare and piles up |
| 57-64 | | glenz glass in a tunnel scrubbed by the kick | | 53-56 | | an equalizer city, a band of the music to each column and its last moments running back |
| 65-72 | dense | lasers in the haze | | 57-60 | | glenz glass in a tunnel scrubbed by the kick |
| 61-64 | | a turntable, the record scratched on every snare, the camera cutting every two beats |
| 65-72 | dense | lasers in the haze: 24 beams in six patterns, a new one every two beats |
| 73-80 | | plasma and a rotozoomed logo | | 73-80 | | plasma and a rotozoomed logo |
| 81-88 | breakdown | the dark club; the floor relights a tile a beat when the bass returns | | 81-88 | breakdown | the dark club; the floor relights a tile a beat when the bass returns |
| 89-96 | build | everything back on, strobing | | 89-96 | build | everything back on, strobing |
| 97-112 | full, then final | the party, then everything at once | | 97-112 | full, then final | the party, cutting faster: club, dots, equalizer, club spinning, turntable, club |
| 113- | the last downbeat | the ball shatters; credits | | 113- | the last downbeat | the ball shatters and the lights go down |
**Insert Coin** (bar n starts at 0.0067 + (n - 1) x 1.77778 s) **Insert Coin** (bar n starts at 0.0067 + (n - 1) x 1.77778 s)
From bar 1 to bar 121, a large scroller of translucent 3D block letters (`FEATURE_TEXT` near the
top of insertCoin.singe, in the demo's own pixel font from `art/font.lua`) runs over every era,
saying what Singe is and what it can do.
| Bars | Song | Demo | | Bars | Song | Demo |
|---|---|---| |---|---|---|
| 1-4 | intro | a tube warms up; INSERT COIN, and a coin drops on the downbeat of 5 | | 1-4 | intro | a tube warms up; INSERT COIN, and a coin drops on the downbeat of 5 |
| 5-8 | drums in | 1-bit: bat and ball in phosphor, the ball meeting a bat on every beat | | 5-8 | drums in | analog: bat and ball in phosphor, the ball meeting a bat on every beat |
| 9-16 | groove | 8-bit: a formation that steps on the beat; the cannon fires on the snare | | 9-16 | groove | 8-bit: a formation that steps on the beat; the cannon fires on the snare |
| 17-24 | lead melody | 16-bit: a runner over parallax hills, jumping on the backbeat | | 17-24 | lead melody | 16-bit: a runner over parallax hills, jumping on the backbeat |
| 25-32 | lead melody | 16-bit: a kart round a track seen in perspective | | 25-32 | lead melody | 16-bit: a kart round a track seen in perspective |
| 33-40 | breakdown, riser | a tape loading | | 33-40 | breakdown, riser | a tape loading |
| 41-56 | full | 32-bit: a flat-shaded ship through rings, one a beat, snapped to a grid | | 41-48 | full | vector: glowing wireframe asteroids, split by the ship's aimed shot on every snare |
| 57-72 | heavy | now: chrome balls into a wall of glossy bricks, real physics, on the kick | | 49-56 | full | 32-bit: a flat-shaded ship weaving a winding path through gates, one a beat, banking into the turns |
| 57-64 | heavy | light gun: targets slide along three rails, and each snare flashes the screen and drops the one under the crosshair |
| 65-72 | heavy | now: chrome balls into a wall of glossy bricks, real physics, on the kick |
| 73-80 | breakdown | GAME OVER, and a countdown to continue, a number a bar | | 73-80 | breakdown | GAME OVER, and a countdown to continue, a number a bar |
| 81-96 | melody returns | the boss, firing on the kick, the hats, and the snare | | 81-88 | melody returns | versus: two fighters trade blows on the kicks and snares, ROUND 1, FIGHT!, K.O. |
| 97-112 | full | the bonus stage: coins pour in on the beat | | 89-96 | melody | the boss, firing on the kick, the hats, and the snare |
| 97-104 | full | the bonus stage: coins rain down far along the tunnel and ride its floor off the screen |
| 105-112 | full | rhythm: a note highway whose notes are the song's own kicks, snares, and hats |
| 113-120 | melody | the high score table, which is the credits | | 113-120 | melody | the high score table, which is the credits |
| 121-136 | final | every era again, two bars each, then GAME ON | | 121-136 | final | every era again, a bar each, then GAME ON |
| 137- | outro | GAME OVER, and the set switched off on the last hit | | 137- | outro | GAME OVER, and the set switched off on the last hit |
## Making them again ## Making them again
Everything in `art/`, `video/`, and `sync.lua` is generated: Everything in `art/` and `sync.lua` is generated:
- `util/neonCoastArt.py`, `util/mirrorballArt.py`, `util/insertCoinArt.py` draw each demo's - `util/neonCoastArt.py`, `util/mirrorballArt.py`, `util/insertCoinArt.py` draw each demo's
pictures (PIL and numpy). pictures (PIL and numpy).
- `util/demoVideo.py` precalculates the per-pixel effects as videos: the tunnel (every frame a - `util/neonCoastCar.sh` renders Neon Coast's car with Singe's own 3D renderer
keyframe, so a demo can seek to any frame and run the tunnel at the music's speed), the plasma, (`util/neonCoastCar.singe` builds the model): seven steering angles, each drawn over black and
and the tape's snow. over white so every pixel's true alpha comes out of the pair.
- `util/demoSync.py` writes `sync.lua` from `music.mp3`. It needs librosa, which Singe does not: - `util/demoSync.py` writes `sync.lua` from `music.mp3`. It needs librosa, which Singe does not:
`python3 -m venv env && env/bin/pip install librosa`. `python3 -m venv env && env/bin/pip install librosa`.
`util/demoRecord.sh neonCoast out.mp4` records a whole demo with its music, using Singe's own
recorder: `--deterministic` and `--record` together keep every frame and generate the sound in
step with the picture, and Neon Coast's three minutes take about two and a half on a desktop.
`util/demoCapture.sh neonCoast 0 190 [every] [out.mp4]` runs a stretch of a demo headless on a `util/demoCapture.sh neonCoast 0 190 [every] [out.mp4]` runs a stretch of a demo headless on a
clock that moves exactly one 20 ms step a frame, saves the frames, and with `every` = 1 puts the clock that moves exactly one 20 ms step a frame, saves the frames, and with `every` = 1 puts the
same stretch of the music under them in an MP4. The engine plays the music on real time, so a same stretch of the music under them in an MP4. The engine plays the music on real time, so a

BIN
demos/art/kangarooPunchLogo.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/art/singeLogo.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/insertCoin/art/coinDragon.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/insertCoin/art/coinSinge.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/insertCoin/art/fighterA.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/insertCoin/art/fighterB.png (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -0,0 +1,67 @@
-- Insert Coin's pixel font, written by util/insertCoinArt.py from its GLYPHS: rows top down, "#" lit.
return {
[" "] = { " ", " ", " ", " ", " ", " ", " " },
["!"] = { " # ", " # ", " # ", " # ", " # ", " ", " # " },
["\""] = { " # # ", " # # ", " ", " ", " ", " ", " " },
["#"] = { " # # ", "#####", " # # ", " # # ", " # # ", "#####", " # # " },
["$"] = { " # ", " ####", "# # ", " ### ", " # #", "#### ", " # " },
["%"] = { "## ", "## #", " # ", " # ", " # ", "# ##", " ##" },
["&"] = { " ## ", "# # ", "# # ", " # ", "# # #", "# # ", " ## #" },
["'"] = { " # ", " # ", " ", " ", " ", " ", " " },
["("] = { " # ", " # ", " # ", " # ", " # ", " # ", " # " },
[")"] = { " # ", " # ", " # ", " # ", " # ", " # ", " # " },
["*"] = { " ", " # ", "# # #", " ### ", "# # #", " # ", " " },
["+"] = { " ", " # ", " # ", "#####", " # ", " # ", " " },
[","] = { " ", " ", " ", " ", " ## ", " # ", " # " },
["-"] = { " ", " ", " ", "#####", " ", " ", " " },
["."] = { " ", " ", " ", " ", " ", " ## ", " ## " },
["/"] = { " ", " #", " # ", " # ", " # ", "# ", " " },
["0"] = { " ### ", "# #", "# ##", "# # #", "## #", "# #", " ### " },
["1"] = { " # ", " ## ", " # ", " # ", " # ", " # ", " ### " },
["2"] = { " ### ", "# #", " #", " # ", " # ", " # ", "#####" },
["3"] = { "#####", " # ", " # ", " # ", " #", "# #", " ### " },
["4"] = { " # ", " ## ", " # # ", "# # ", "#####", " # ", " # " },
["5"] = { "#####", "# ", "#### ", " #", " #", "# #", " ### " },
["6"] = { " ## ", " # ", "# ", "#### ", "# #", "# #", " ### " },
["7"] = { "#####", " #", " # ", " # ", " # ", " # ", " # " },
["8"] = { " ### ", "# #", "# #", " ### ", "# #", "# #", " ### " },
["9"] = { " ### ", "# #", "# #", " ####", " #", " # ", " ## " },
[":"] = { " ", " ## ", " ## ", " ", " ## ", " ## ", " " },
[";"] = { " ", " ## ", " ## ", " ", " ## ", " # ", " # " },
["<"] = { " # ", " # ", " # ", "# ", " # ", " # ", " # " },
["="] = { " ", " ", "#####", " ", "#####", " ", " " },
[">"] = { " # ", " # ", " # ", " #", " # ", " # ", " # " },
["?"] = { " ### ", "# #", " #", " # ", " # ", " ", " # " },
["@"] = { " ### ", "# #", "# ###", "# # #", "# ###", "# ", " ####" },
["A"] = { " ### ", "# #", "# #", "#####", "# #", "# #", "# #" },
["B"] = { "#### ", "# #", "# #", "#### ", "# #", "# #", "#### " },
["C"] = { " ### ", "# #", "# ", "# ", "# ", "# #", " ### " },
["D"] = { "#### ", "# #", "# #", "# #", "# #", "# #", "#### " },
["E"] = { "#####", "# ", "# ", "#### ", "# ", "# ", "#####" },
["F"] = { "#####", "# ", "# ", "#### ", "# ", "# ", "# " },
["G"] = { " ### ", "# #", "# ", "# ###", "# #", "# #", " ####" },
["H"] = { "# #", "# #", "# #", "#####", "# #", "# #", "# #" },
["I"] = { " ### ", " # ", " # ", " # ", " # ", " # ", " ### " },
["J"] = { " ###", " # ", " # ", " # ", " # ", "# # ", " ## " },
["K"] = { "# #", "# # ", "# # ", "## ", "# # ", "# # ", "# #" },
["L"] = { "# ", "# ", "# ", "# ", "# ", "# ", "#####" },
["M"] = { "# #", "## ##", "# # #", "# # #", "# #", "# #", "# #" },
["N"] = { "# #", "# #", "## #", "# # #", "# ##", "# #", "# #" },
["O"] = { " ### ", "# #", "# #", "# #", "# #", "# #", " ### " },
["P"] = { "#### ", "# #", "# #", "#### ", "# ", "# ", "# " },
["Q"] = { " ### ", "# #", "# #", "# #", "# # #", "# # ", " ## #" },
["R"] = { "#### ", "# #", "# #", "#### ", "# # ", "# # ", "# #" },
["S"] = { " ####", "# ", "# ", " ### ", " #", " #", "#### " },
["T"] = { "#####", " # ", " # ", " # ", " # ", " # ", " # " },
["U"] = { "# #", "# #", "# #", "# #", "# #", "# #", " ### " },
["V"] = { "# #", "# #", "# #", "# #", "# #", " # # ", " # " },
["W"] = { "# #", "# #", "# #", "# # #", "# # #", "# # #", " # # " },
["X"] = { "# #", "# #", " # # ", " # ", " # # ", "# #", "# #" },
["Y"] = { "# #", "# #", " # # ", " # ", " # ", " # ", " # " },
["Z"] = { "#####", " #", " # ", " # ", " # ", "# ", "#####" },
["["] = { " ### ", " # ", " # ", " # ", " # ", " # ", " ### " },
["\\"] = { " ", "# ", " # ", " # ", " # ", " #", " " },
["]"] = { " ### ", " # ", " # ", " # ", " # ", " # ", " ### " },
["^"] = { " # ", " # # ", "# #", " ", " ", " ", " " },
["_"] = { " ", " ", " ", " ", " ", " ", "#####" },
}

BIN
demos/insertCoin/art/kart.png (Stored with Git LFS)

Binary file not shown.

BIN
demos/insertCoin/art/kart1.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/insertCoin/art/kart2.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/insertCoin/art/kart3.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/insertCoin/art/kart4.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/insertCoin/art/kart5.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/insertCoin/art/target.png (Stored with Git LFS) Normal file

Binary file not shown.

File diff suppressed because it is too large Load diff

BIN
demos/insertCoin/video/plasma.mp4 (Stored with Git LFS)

Binary file not shown.

BIN
demos/insertCoin/video/tunnel.mp4 (Stored with Git LFS)

Binary file not shown.

View file

@ -3,14 +3,17 @@
-- --
-- Time is the music's. K.time() is where the listener is in the track: musicGetPosition while the -- 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 -- 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 -- (see util/demoCapture.sh), so a captured frame lands on the music added afterwards. The opening
-- logo card runs from -LOGO_TIME to 0, before the music, and the closing one after the end, so a
-- capture or a recording takes them like any other part of the demo. Everything
-- else is read off that time: beats and bars from the beat list util/demoSync.py wrote, the drums, -- 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. -- 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 = -- 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 -- 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 -- 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. -- overlap, and are drawn in list order. Every demo opens on the Singe logo before its music starts
-- and closes on the Kangaroo Punch logo after it ends (art/, from util/demoLogos.sh).
local K = {} local K = {}
@ -20,19 +23,150 @@ local HIT_LISTS = { "kicks", "snares", "hats" }
local LEVELS = 63 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 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 FONT_FILE = "Singe/FreeSansBold.ttf"
local TAIL = 2 -- Seconds the demo runs on after the music ends local TAIL = 2 -- Seconds the demo runs on after the music ends, fading to black
local LOGO_FADE = 1.0 -- The logo cards before and after a demo: seconds fading up from black ...
local LOGO_HOLD = 1.5 -- ... held ...
local LOGO_TIME = LOGO_FADE * 2 + LOGO_HOLD -- ... and the whole card, back to black
local sync = nil local sync = nil
local music = nil local music = nil
local fonts = {} local fonts = {}
local widths = {} local widths = {}
local pixel = nil local pixel = nil
local logos = {}
local startTicks = nil local startTicks = nil
local lastTime = 0 local lastTime = -math.huge
local playing = false -- Whether the music has been started (K.run)
local lastTicks = 0 local lastTicks = 0
local frames = 0 local frames = 0
local stats = { second = 0, frames = 0, worst = 0, last = 0 } local stats = { second = 0, frames = 0, worst = 0, last = 0 }
-- The scrollers' letters: each a sprite made once per font, white, so it can be tinted.
local glyphs = {}
local function glyph(font, c)
local key = font .. c
local g = glyphs[key]
if not g then
fontSelect(font)
colorForeground(255, 255, 255, 255)
fontQuality(FONT_QUALITY_BLENDED)
local s = fontToSprite(c)
g = { sprite = s, w = spriteGetWidth(s) }
glyphs[key] = g
end
return g
end
-- The block letters of K.blockFont: the shade of each way a face can point (front, top, left, right
-- and back, bottom), as a strip of greys the hue materials multiply; how many hues round the wheel;
-- how deep a letter is in blocks; and how far in front of the camera the letters rest, nearer than
-- any demo's fog starts.
local BLOCK_SHADES = { 255, 215, 175, 140, 105 }
local BLOCK_HUES = 24
local BLOCK_DEPTH = 1.6
local BLOCK_DISTANCE = 6
-- One glyph's mesh: a block for every lit cell, with only the faces no neighbour covers, in cells
-- centred on the glyph, each face's texture coordinate on its shade.
local function blockMesh(rows)
local positions = {}
local normals = {}
local uvs = {}
local indices = {}
local cols = #rows[1]
local z0 = -BLOCK_DEPTH / 2
local z1 = BLOCK_DEPTH / 2
local function lit(r, c)
return rows[r] ~= nil and rows[r]:sub(c, c) == "#"
end
local function face(shade, nx, ny, nz, corners)
local base = #positions / 3
for k = 1, 12, 3 do
positions[#positions + 1] = corners[k]
positions[#positions + 1] = corners[k + 1]
positions[#positions + 1] = corners[k + 2]
normals[#normals + 1] = nx
normals[#normals + 1] = ny
normals[#normals + 1] = nz
uvs[#uvs + 1] = (shade - 0.5) / #BLOCK_SHADES
uvs[#uvs + 1] = 0.5
end
for _, k in ipairs({ 1, 2, 3, 1, 3, 4 }) do
indices[#indices + 1] = base + k
end
end
for r = 1, #rows do
for c = 1, cols do
if lit(r, c) then
local x0 = c - 1 - cols / 2
local x1 = x0 + 1
local y1 = #rows / 2 - (r - 1)
local y0 = y1 - 1
-- Each face's corners run counter-clockwise seen from outside.
face(1, 0, 0, 1, { x0, y0, z1, x1, y0, z1, x1, y1, z1, x0, y1, z1 })
face(4, 0, 0, -1, { x1, y0, z0, x0, y0, z0, x0, y1, z0, x1, y1, z0 })
if not lit(r, c - 1) then
face(3, -1, 0, 0, { x0, y0, z0, x0, y0, z1, x0, y1, z1, x0, y1, z0 })
end
if not lit(r, c + 1) then
face(4, 1, 0, 0, { x1, y0, z1, x1, y0, z0, x1, y1, z0, x1, y1, z1 })
end
if not lit(r - 1, c) then
face(2, 0, 1, 0, { x0, y1, z1, x1, y1, z1, x1, y1, z0, x0, y1, z0 })
end
if not lit(r + 1, c) then
face(5, 0, -1, 0, { x0, y0, z0, x1, y0, z0, x1, y0, z1, x0, y0, z1 })
end
end
end
end
return meshNew(positions, normals, uvs, indices)
end
-- The pace of a scroller's text between t0 and t1, worked out once and remembered: the text to show
-- (said again as often as it needs to be to go no slower than slowest) and the speed in pixels a
-- second. The last letter is gone SCROLL_CLEAR before t1, so the words have finished before the cut.
local SCROLL_SPEED_MIN = 120 -- Slower than this a scroller drags: short text is repeated
local SCROLL_SPEED_MAX = 520 -- Faster than this it cannot be read: long text is warned about
local SCROLL_CLEAR = 1.5 -- Seconds of clear screen a scroller leaves before its scene ends
local SCROLL_3D_SLOWEST = 60 -- The 3D scroller's letters are bigger, so they may go slower
local paces = {}
local function scrollerPace(font, text, t0, t1, slowest)
local key = tostring(font) .. "|" .. t0 .. "|" .. t1 .. "|" .. (slowest or SCROLL_SPEED_MIN) .. "|" .. text
local paced = paces[key]
if not paced then
local span = math.max(t1 - t0 - SCROLL_CLEAR, 0.001)
local shown = text
local width = K.scrollerWidth(font, shown)
while (width + K.W) / span < (slowest or SCROLL_SPEED_MIN) do
shown = shown .. " " .. text
width = K.scrollerWidth(font, shown)
end
paced = { text = shown, speed = (width + K.W) / span }
if paced.speed > SCROLL_SPEED_MAX then
local fits = math.floor(#text * (SCROLL_SPEED_MAX * span - K.W) / width)
debugPrint(string.format("DEMO scroller too long to read: %d characters in %.1f s scroll at %d pixels a second; about %d would read well. It begins \"%s\"", #text, span, math.floor(paced.speed), math.max(0, fits), text:sub(1, 30)))
end
paces[key] = paced
end
return paced
end
K.W = 640 K.W = 640
K.H = 480 K.H = 480
@ -119,6 +253,48 @@ local function bobShape(kind, i, count)
end end
-- Block letters for K.scroller3D, from a pixel font: a table of glyphs, each its rows top down with
-- "#" lit, all the same size. Each glyph becomes a mesh of blocks the first time it is shown, unlit
-- and shaded by which way its faces point, so it reads as solid in any scene's light or none. The
-- letters hang off the camera in the front layer, so they stand over the overlay and the scene
-- alike wherever the camera goes. cell is a block's size in pixels as a letter rests; alpha (255
-- for solid) makes them see-through.
function K.blockFont(glyphs, camera, fov, cell, alpha)
local _, rows = next(glyphs)
local shade = spriteNew(#BLOCK_SHADES, 1)
local bytes = {}
local font = {
glyphs = glyphs,
meshes = {},
materials = {},
nodes = {},
cell = cell,
advance = (#rows[1] + 1) * cell,
units = 2 * BLOCK_DISTANCE * math.tan(math.rad(fov / 2)) / K.H,
pivot = nodeNew(camera)
}
for i, v in ipairs(BLOCK_SHADES) do
bytes[i] = string.char(v, v, v, 255)
end
spriteSetPixels(shade, table.concat(bytes))
for i = 1, BLOCK_HUES do
local m = materialNew()
local r, g, b = K.hue((i - 1) / BLOCK_HUES)
materialSetColor(m, r, g, b, alpha)
materialSetTexture(m, shade)
materialSetFilter(m, FILTER_NEAREST)
materialSetUnlit(m, true)
materialSetBlend(m, alpha < 255)
font.materials[i] = m
end
nodeSetLayer(font.pivot, LAYER_FRONT)
nodeSetVisible(font.pivot, false)
return font
end
function K.bobs(t, bar, sprite, count, spread, size, cx, cy) function K.bobs(t, bar, sprite, count, spread, size, cx, cy)
local shape = math.floor((bar - 1) / 2) % 4 local shape = math.floor((bar - 1) / 2) % 4
local next = (shape + 1) % 4 local next = (shape + 1) % 4
@ -180,6 +356,39 @@ function K.chrome(r, g, b, roughness)
end end
-- Text running round a circle of radius r about cx, cy, each letter turned to lie along it and the
-- whole of it turning at speed degrees a second. The text is written in a letter at a time over the
-- first half of t0 to t1, whatever its length, so it can be read as it goes round. Text longer than
-- the circle's edge overlaps itself: the trace says so once.
function K.circleScroller(t, font, text, cx, cy, r, speed, t0, t1)
local rate = #text / math.max((t1 - t0) / 2, 0.001)
local shown = math.min(#text, math.floor((t - t0) * rate))
if not paces[font .. "|circle|" .. r .. "|" .. text] then
paces[font .. "|circle|" .. r .. "|" .. text] = true
if K.scrollerWidth(font, text) > 2 * math.pi * r then
debugPrint(string.format("DEMO circle text too long: %d pixels of text round %d pixels of circle; cut it to about %d characters", K.scrollerWidth(font, text), math.floor(2 * math.pi * r), math.floor(#text * 2 * math.pi * r / K.scrollerWidth(font, text))))
end
end
local a = -90 + (t - t0) * speed
for i = 1, shown do
local g = glyph(font, text:sub(i, i))
local middle = a + math.deg(g.w / 2 / r)
local rad = math.rad(middle)
local cr, cg, cb = K.hue(i / #text + t * 0.15)
-- Each letter is drawn by its middle, half its own width on from where the last one ended,
-- so a narrow letter is not overlapped by its neighbours.
spriteSetTint(g.sprite, cr, cg, cb)
spriteRotate(g.sprite, math.floor(middle + 90) % 360)
spriteDraw(g.sprite, math.floor(cx + math.cos(rad) * r), math.floor(cy + math.sin(rad) * r), true)
spriteRotate(g.sprite, 0)
a = a + math.deg(g.w / r)
end
end
function K.clamp(v, lo, hi) function K.clamp(v, lo, hi)
if v < lo then if v < lo then
return lo return lo
@ -191,6 +400,42 @@ function K.clamp(v, lo, hi)
end end
-- A crawl: lines of text on a plane laid down toward the horizon, sliding away from the viewer and
-- shrinking as they go, faded into the distance. The first line starts at the bottom of the screen
-- at t0 and the last reaches the horizon at t1.
function K.crawl(t, font, lines, t0, t1, horizonY)
local spacing = 1.4
local total = #lines * spacing + 6
local along = K.clamp((t - t0) / (t1 - t0), 0, 1) * total
for i, line in ipairs(lines) do
local z = 1 + along - (i - 1) * spacing
if z > 0.35 then
local scale = 1.1 / z
local y = horizonY + (K.H - horizonY) * 1.1 / z
local w = K.scrollerWidth(font, line) * scale
local fade = K.clamp(1 - (z - 3) / 4, 0, 1)
local x = K.W / 2 - w / 2
if fade > 0 and y < K.H + 40 then
for c = 1, #line do
local g = glyph(font, line:sub(c, c))
local gw = g.w * scale
local gh = spriteGetHeight(g.sprite) * scale
spriteSetTint(g.sprite, 255, math.floor(200 + 55 * fade), 120 + math.floor(100 * (1 - fade)))
spriteSetAlpha(g.sprite, math.floor(255 * fade))
spriteDraw(g.sprite, math.floor(x), math.floor(y - gh), math.floor(x + gw), math.floor(y))
spriteSetAlpha(g.sprite, 255)
x = x + gw
end
end
end
end
end
-- How long ago the most recent hit in a list ("kicks", "snares", "hats") was at time t; huge when -- 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. -- there has been none. An effect fed exp(-age * k) flashes on every hit and dies away.
function K.decay(list, t, k) function K.decay(list, t, k)
@ -198,6 +443,34 @@ function K.decay(list, t, k)
end end
-- A flat disc of radius r facing up (+Y), a texture laid across it: a coin's face, a record.
function K.disc(r, segments)
local positions = { 0, 0, 0 }
local normals = { 0, 1, 0 }
local uvs = { 0.5, 0.5 }
local indices = {}
for i = 0, segments do
local a = i / segments * 2 * math.pi
positions[#positions + 1] = math.cos(a) * r
positions[#positions + 1] = 0
positions[#positions + 1] = math.sin(a) * r
normals[#normals + 1] = 0
normals[#normals + 1] = 1
normals[#normals + 1] = 0
uvs[#uvs + 1] = 0.5 + 0.5 * math.cos(a)
uvs[#uvs + 1] = 0.5 + 0.5 * math.sin(a)
end
for i = 1, segments do
indices[#indices + 1] = 1
indices[#indices + 1] = i + 2
indices[#indices + 1] = i + 1
end
return meshNew(positions, normals, uvs, indices)
end
function K.ease(t) function K.ease(t)
t = K.clamp(t, 0, 1) t = K.clamp(t, 0, 1)
return t * t * (3 - 2 * t) return t * t * (3 - 2 * t)
@ -344,6 +617,11 @@ function K.load(dir, width, height)
music = musicLoad(dir .. "music.mp3") music = musicLoad(dir .. "music.mp3")
end end
pixel = spriteLoadData(PIXEL_PNG) pixel = spriteLoadData(PIXEL_PNG)
-- The logos' lettering is opaque black, which the colour key would drop: they go by alpha alone.
drawTransparent(true)
logos.singe = spriteLoad(dir .. "../art/singeLogo.png")
logos.kangaroo = spriteLoad(dir .. "../art/kangarooPunchLogo.png")
drawTransparent(false)
return sync return sync
end end
@ -365,28 +643,132 @@ function K.paint(node, material)
end end
-- A palette for spriteSetPixels: 256 colours of three bytes, a smooth ramp through stops, each
-- { position 0 to 1, { r, g, b } }. cycle doubles it, so palette:sub() can take any rotation of it.
function K.palette(stops, cycle)
local out = {}
for i = 0, 255 do
local t = i / 255
local c = stops[#stops][2]
for k = 1, #stops - 1 do
local a = stops[k]
local b = stops[k + 1]
if t >= a[1] and t <= b[1] then
local f = (t - a[1]) / (b[1] - a[1])
c = { a[2][1] + (b[2][1] - a[2][1]) * f, a[2][2] + (b[2][2] - a[2][2]) * f, a[2][3] + (b[2][3] - a[2][3]) * f }
break
end
end
out[#out + 1] = string.char(math.floor(c[1]), math.floor(c[2]), math.floor(c[3]))
end
out = table.concat(out)
return cycle and (out .. out) or out
end
-- Plasma, live: four interfering waves summed from a table of sines at every pixel of a width by
-- height sprite, coloured through a cycling palette, the way the old demos did it. K.plasmaUpdate
-- draws the next picture into fx.sprite; stretch it over the screen or put it on a surface.
function K.plasmaNew(width, height, stops)
local fx = { w = width, h = height, sprite = spriteNew(width, height), palette = K.palette(stops, true), sine = {}, ring = {}, rows = {}, row = {} }
for i = 0, 511 do
fx.sine[i] = math.floor(32 + 31.5 * math.sin(i / 512 * 2 * math.pi))
end
-- Distances from the middle of a picture twice the size, so the ripple's centre can move anywhere
-- in the plasma by moving where the plasma is read from: no square root at a pixel a frame.
for y = 0, height * 2 - 1 do
for x = 0, width * 2 - 1 do
local dx = x - width
local dy = y - height
fx.ring[y * width * 2 + x] = math.floor(math.sqrt(dx * dx + dy * dy) * 9)
end
end
return fx
end
function K.plasmaUpdate(fx, t)
local sine = fx.sine
local row = fx.row
local rows = fx.rows
local w = fx.w
local a = math.floor(t * 90)
local b = math.floor(t * 61)
local c = math.floor(t * 137)
local cx = math.floor(w / 2 + math.sin(t * 0.7) * w * 0.3)
local cy = math.floor(fx.h / 2 + math.cos(t * 0.9) * fx.h * 0.3)
local char = string.char
local unpack = table.unpack
local ring = fx.ring
local span = w * 2
for y = 0, fx.h - 1 do
local sy = sine[(y * 5 + b) & 511] + sine[(y * 2 - c) & 511]
local base = (y - cy + fx.h) * span + (w - cx)
for x = 0, w - 1 do
row[x + 1] = (sine[(x * 4 + a) & 511] + sy + sine[(x * 3 + y * 3 + c) & 511] + sine[(ring[base + x] - a * 2) & 511]) & 255
end
rows[y + 1] = char(unpack(row, 1, w))
end
local turn = math.floor(t * 40) % 256
spriteSetPixels(fx.sprite, table.concat(rows, "", 1, fx.h), fx.palette:sub(turn * 3 + 1, turn * 3 + 768))
end
-- Starts the music and the clock, and takes over onOverlayUpdate to run the scenes. Under -- 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 -- 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) -- 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. -- is saved as frameNNNNNN.png, and the demo quits at to.
-- A logo card p seconds in: the screen fades from black to white with the logo coming up on it,
-- holds, and fades back to black, the way Singe's own start does. The logos' lettering is black.
local function logoCard(sprite, p)
local v = math.floor(255 * K.clamp(math.min(p, LOGO_TIME - p) / LOGO_FADE, 0, 1))
local x = (K.W - spriteGetWidth(sprite)) // 2
local y = (K.H - spriteGetHeight(sprite)) // 2
K.flash(v, v, v, 255)
spriteSetAlpha(sprite, v)
spriteDraw(sprite, x, y)
end
function K.run(scenes) function K.run(scenes)
local active = {} local active = {}
local ending = sync.duration + TAIL
if DEMO_CAPTURE then if DEMO_CAPTURE then
debugPrint(string.format("DEMO capture from %.3f to %.3f", DEMO_CAPTURE.from, DEMO_CAPTURE.to)) debugPrint(string.format("DEMO capture from %.3f to %.3f", DEMO_CAPTURE.from, DEMO_CAPTURE.to))
else
musicPlay(music)
end end
function onOverlayUpdate() function onOverlayUpdate()
local t = K.time() local t = K.time()
local bar = K.bar(t)
frames = frames + 1 frames = frames + 1
colorBackground(0, 0, 0, 0)
overlayClear()
if not DEMO_CAPTURE and not playing and t >= 0 then
musicPlay(music)
playing = true
end
-- The Singe card before the music, the demo, and the Kangaroo Punch card after it.
if t < 0 then
logoCard(logos.singe, t + LOGO_TIME)
elseif t >= ending then
logoCard(logos.kangaroo, t - ending)
else
local bar = K.bar(t)
if DEMO_STATS then if DEMO_STATS then
K.stats(t, bar) K.stats(t, bar)
end end
colorBackground(0, 0, 0, 0)
overlayClear()
for i, scene in ipairs(scenes) do for i, scene in ipairs(scenes) do
local on = bar >= scene.from and bar < scene.to local on = bar >= scene.from and bar < scene.to
@ -405,6 +787,10 @@ function K.run(scenes)
scene.draw(t, bar, (bar - scene.from) / (scene.to - scene.from)) scene.draw(t, bar, (bar - scene.from) / (scene.to - scene.from))
end end
end end
if t > sync.duration then
K.flash(0, 0, 0, math.floor(255 * K.clamp((t - sync.duration) / TAIL, 0, 1)))
end
end
if DEMO_CAPTURE then if DEMO_CAPTURE then
if frames % (DEMO_CAPTURE.every or 1) == 0 then if frames % (DEMO_CAPTURE.every or 1) == 0 then
singeScreenshot(string.format("frame%06d", frames)) singeScreenshot(string.format("frame%06d", frames))
@ -413,7 +799,7 @@ function K.run(scenes)
debugPrint(string.format("DEMO captured %d frames", frames)) debugPrint(string.format("DEMO captured %d frames", frames))
singeQuit() singeQuit()
end end
elseif t >= sync.duration + TAIL then elseif t >= ending + LOGO_TIME then
singeQuit() singeQuit()
end end
return OVERLAY_UPDATED return OVERLAY_UPDATED
@ -421,24 +807,6 @@ function K.run(scenes)
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 -- 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 -- 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 -- glow. What comes back is a pivot on the camera's axis with the quad stood up under it, so the
@ -456,46 +824,200 @@ function K.screenQuad(camera, fov, material, d, oversize)
end end
-- A sine scroller: each letter a sprite made once in the font, coloured by where it is on the -- A scroller: each letter coloured by where it is on the screen. The text enters from the right at
-- screen, riding a wave of amplitude amp. It enters from the right at t0 and runs at speed pixels -- t0 and its last letter leaves at the left at t1, whatever its length: the speed comes from the
-- a second. -- text. A text too short to fill the time at a readable speed is said again until it does, and one
local glyphs = {} -- too long to read at its speed still ends at t1, with a warning in the trace saying how much to cut.
-- style chooses how the letters move: "sine" (the default) rides a wave of amplitude amp; "bounce"
-- hops each letter in turn, higher on the kicks; "zoom" swells the letters as they pass the middle
-- of the screen; "spin" turns each letter right round as it crosses.
function K.scroller(t, font, text, y, amp, t0, t1, style)
local paced = scrollerPace(font, text, t0, t1)
local x = K.W - (t - t0) * paced.speed
local kick = K.decay("kicks", t, 6)
function K.scroller(t, font, text, y, amp, speed, t0) text = paced.text
local x = K.W - (t - t0) * speed
for i = 1, #text do for i = 1, #text do
local key = font .. text:sub(i, i) local g = glyph(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 if x > K.W then
break break
end end
if x + g.w > 0 then if x + g.w > 0 then
local r, gg, b = K.hue(x / K.W * 0.5 + t * 0.2) local r, gg, b = K.hue(x / K.W * 0.5 + t * 0.2)
local h = spriteGetHeight(g.sprite)
spriteSetTint(g.sprite, r, gg, b) spriteSetTint(g.sprite, r, gg, b)
if style == "bounce" then
local hop = math.abs(math.sin(t * 5 + i * 0.45)) * amp * (0.5 + kick)
spriteDraw(g.sprite, math.floor(x), math.floor(y - hop))
elseif style == "zoom" then
-- Laid out at the zoomed width, so the swollen letters push their neighbours apart
-- rather than piling onto them.
local mid = K.clamp(x + g.w / 2 - K.W / 2, -K.W / 2, K.W / 2)
local scale = 1 + 0.9 * math.cos(mid / K.W * math.pi) ^ 3
local w = g.w * scale
local hh = h * scale
spriteDraw(g.sprite, math.floor(x), math.floor(y + h - hh), math.floor(x + w), math.floor(y + h))
x = x + w - g.w
elseif style == "spin" then
-- Each letter spins in from the right, reads upright across the middle, and spins
-- away again at the left.
local spin = K.smooth(K.W * 0.62, K.W, x) - K.smooth(K.W * 0.38, 0, x)
spriteRotate(g.sprite, math.floor(360 * spin) % 360)
spriteDraw(g.sprite, math.floor(x + g.w / 2), math.floor(y + h / 2 + math.sin(x * 0.012 + t * 3) * amp), true)
spriteRotate(g.sprite, 0)
else
spriteDraw(g.sprite, math.floor(x), math.floor(y + math.sin(x * 0.018 + t * 4) * amp)) spriteDraw(g.sprite, math.floor(x), math.floor(y + math.sin(x * 0.018 + t * 4) * amp))
end end
end
x = x + g.w x = x + g.w
end end
end end
-- A scroller of solid letters from K.blockFont, in front of everything. The letters ride a path
-- that swings toward the viewer and back, rising and falling on a wave around the line cy, each one
-- rocking and banked along the wave, and once every four bars a spin runs through them from right to
-- left. Coloured round the wheel. Paced like K.scroller: in at t0 and out at t1, whatever the
-- text's length. K.scroller3DStop hides the letters once the scene is over.
function K.scroller3D(t, font, text, cy, t0, t1)
local paced = scrollerPace(font, text, t0, t1, SCROLL_3D_SLOWEST)
local x = K.W - (t - t0) * paced.speed
local into = (K.bar(t) - 1) % 4
local u = font.units
local used = 0
text = paced.text
nodeSetVisible(font.pivot, true)
for i = math.max(1, math.floor(-x / font.advance)), #text do
local left = x + (i - 1) * font.advance
local c = text:sub(i, i)
if left > K.W then
break
end
if font.glyphs[c] and c ~= " " and left + font.advance > 0 then
local mid = left + font.advance / 2
local wave = mid * 0.011 + t * 1.8
-- Never farther than resting: nearer only pushes a letter outward, so none shows before
-- t0 or after t1.
local near = 0.82 - 0.18 * math.sin(mid * 0.006 + t * 1.1)
local sy = cy + math.sin(wave) * 46
local roll = -math.deg(math.atan(math.cos(wave) * 0.011 * 46)) * 0.6
local spin = 360 * K.ease(K.clamp(into * 1.6 - (1 - mid / K.W) * 0.6, 0, 1))
local hue = math.floor(((mid / K.W * 0.4 + t * 0.15) % 1) * BLOCK_HUES) + 1
local node
used = used + 1
node = font.nodes[used]
if not node then
node = nodeNew(font.pivot)
nodeSetShadow(node, false)
font.nodes[used] = node
end
if not font.meshes[c] then
font.meshes[c] = blockMesh(font.glyphs[c])
end
nodeSetMesh(node, font.meshes[c], font.materials[hue])
nodeSetPosition(node, (mid - K.W / 2) * u, (K.H / 2 - sy) * u, -BLOCK_DISTANCE * near)
nodeSetRotation(node, 12 * math.cos(wave), 28 * math.sin(mid * 0.008 + t * 1.3) + spin, roll)
nodeSetScale(node, font.cell * u)
nodeSetVisible(node, true)
end
end
for i = used + 1, #font.nodes do
nodeSetVisible(font.nodes[i], false)
end
end
function K.scroller3DStop(font)
nodeSetVisible(font.pivot, false)
end
-- How wide text is in a font, in pixels: a loaded font, or block letters from K.blockFont.
function K.scrollerWidth(font, text)
local w = 0
if type(font) == "table" then
return #text * font.advance
end
for i = 1, #text do
w = w + glyph(font, text:sub(i, i)).w
end
return w
end
function K.smooth(edge0, edge1, x) function K.smooth(edge0, edge1, x)
return K.ease((x - edge0) / (edge1 - edge0)) return K.ease((x - edge0) / (edge1 - edge0))
end end
-- A tape's snow, live: grain streaked along each line, two tracking bands rolling up the picture.
function K.snowNew(width, height)
local grey = {}
for i = 0, 255 do
grey[#grey + 1] = string.char(math.floor(i * 0.95), math.floor(i * 0.9), math.min(255, math.floor(i * 1.05)))
end
return { w = width, h = height, sprite = spriteNew(width, height), palette = table.concat(grey), rows = {}, row = {} }
end
function K.snowUpdate(fx, t)
local row = fx.row
local rows = fx.rows
local w = fx.w
local random = math.random
local char = string.char
local unpack = table.unpack
local band1 = (fx.h - (t * 60) % (fx.h + 40)) + 20
local band2 = (fx.h - (t * 60 + fx.h / 2) % (fx.h + 40)) + 20
for y = 0, fx.h - 1 do
local d1 = (y - band1) * (y - band1)
local d2 = (y - band2) * (y - band2)
local lift = math.floor(70 * math.exp(-d1 / 60) + 50 * math.exp(-d2 / 20))
local gain = 0.55 + 0.45 * math.exp(-d1 / 60) + 0.6 * math.exp(-d2 / 20)
local last = 0
for x = 1, w do
local v = random() ^ 1.6 * 200
last = (v + last * 2) / 3
row[x] = math.min(255, math.floor(last * gain) + lift)
end
rows[y + 1] = char(unpack(row, 1, w))
end
spriteSetPixels(fx.sprite, table.concat(rows, "", 1, fx.h), fx.palette)
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 full-screen flash that fires on every hit in a list and dies away at rate k: a = 0 is off. -- 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) function K.strobe(t, list, k, r, g, b, a)
local v = K.decay(list, t, k) local v = K.decay(list, t, k)
@ -506,29 +1028,6 @@ function K.strobe(t, list, k, r, g, b, a)
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 -- Text in the bundled font at a point size, in a colour, left, centred ("center"), or right
-- aligned on x. -- aligned on x.
function K.text(x, y, text, size, r, g, b, align) function K.text(x, y, text, size, r, g, b, align)
@ -555,6 +1054,29 @@ function K.text(x, y, text, size, r, g, b, align)
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
-- Where the listener is in the music, in seconds. It never runs backwards, which a mixer -- 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 -- 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. -- ended it runs on with the clock.
@ -565,6 +1087,8 @@ function K.time()
startTicks = startTicks or now startTicks = startTicks or now
if DEMO_CAPTURE then if DEMO_CAPTURE then
t = DEMO_CAPTURE.from + (now - startTicks) / 1000 t = DEMO_CAPTURE.from + (now - startTicks) / 1000
elseif not playing then
t = -LOGO_TIME + (now - startTicks) / 1000
else else
t = musicGetPosition(music) t = musicGetPosition(music)
if t < 0 then if t < 0 then
@ -654,4 +1178,73 @@ function K.torusKnot(radius, tube, segments, sides)
end end
-- The tunnel, live: every pixel's angle round the middle and its depth (the inverse of its distance
-- from the middle) are worked out once into tables, and each frame looks up a texture of rings and
-- spokes at those, moved along by travel and round by turn, both 0 to 1 a lap. Shaded darker at
-- the edge and burning to white in the far middle, through the palette.
function K.tunnelNew(width, height, stops)
local fx = { w = width, h = height, sprite = spriteNew(width, height), palette = K.palette(stops), angle = {}, depth = {}, shade = {}, core = {}, texture = {}, rows = {}, row = {} }
local rings = 16
local spokes = 16
for y = 0, height - 1 do
for x = 0, width - 1 do
local dx = (x - width / 2 + 0.5) / (height / 2)
local dy = (y - height / 2 + 0.5) / (height / 2)
local r = math.sqrt(dx * dx + dy * dy) + 1e-6
local i = y * width + x
fx.angle[i] = math.floor((math.atan(dy, dx) / (2 * math.pi) + 0.5) * 256) & 255
fx.depth[i] = math.floor(0.35 / r * 256) & 255
fx.shade[i] = math.floor(K.clamp(r * 1.6, 0, 1) ^ 0.8 * 256)
fx.core[i] = math.floor(255 * math.exp(-r * 9))
end
end
-- The texture: glowing ring edges, glowing spokes, and a checker of the two, 256 by 256.
for v = 0, 255 do
local edge = math.abs(((v * rings / 256) % 1) - 0.5) * 2
local ring = math.exp(-(1 - edge) * 9)
for u = 0, 255 do
local spokeEdge = math.abs(((u * spokes / 256) % 1) - 0.5) * 2
local spoke = math.exp(-(1 - spokeEdge) * 12)
local check = ((v * rings // 256) + (u * spokes // 256)) % 2
local value = 0.18 + 0.22 * check + 0.75 * ring + 0.45 * spoke
fx.texture[v * 256 + u] = math.floor(K.clamp(value, 0, 1) * 255)
end
end
return fx
end
function K.tunnelUpdate(fx, travel, turn)
local angle = fx.angle
local depth = fx.depth
local shade = fx.shade
local core = fx.core
local texture = fx.texture
local row = fx.row
local rows = fx.rows
local w = fx.w
local dv = math.floor(travel * 256)
local du = math.floor(turn * 256)
local char = string.char
local unpack = table.unpack
local min = math.min
local i = 0
for y = 0, fx.h - 1 do
for x = 1, w do
local value = (texture[((depth[i] + dv) & 255) * 256 + ((angle[i] + du) & 255)] * shade[i]) >> 8
row[x] = min(255, value + core[i])
i = i + 1
end
rows[y + 1] = char(unpack(row, 1, w))
end
spriteSetPixels(fx.sprite, table.concat(rows, "", 1, fx.h), fx.palette)
end
return K return K

View file

@ -1,9 +1,10 @@
-- Mirrorball: a demo for Singe, to "Vintage Funky Groove" by miiira. -- Mirrorball: a demo for Singe, to "Vintage Funky Groove" by miiira.
-- --
-- A night in a nineties club, in eight-bar phrases as the song is built: a choppy intro, the ball -- A night in a nineties club, in phrases as the song is built: a choppy intro, the ball coming down
-- coming down over a lit floor, the oldschool effects the dance parties ran on (copper bars, a -- over a lit floor, the oldschool effects the dance parties ran on (copper bars, a rotozoomer, a
-- rotozoomer, a twister, vector bobs, glenz glass, a bouncing checkered ball), a laser show, a -- twister, vector bobs, glenz glass, a bouncing checkered ball), a dot cloud, an equalizer city, a
-- breakdown in the dark, and a finale in which the ball shatters on the last downbeat. -- turntable, a laser show, a breakdown in the dark, and a party that cuts faster and faster until
-- the ball shatters on the last downbeat.
-- --
-- The song speeds up as it goes, from about 111 to 117 beats a minute, so nothing here assumes a -- The song speeds up as it goes, from about 111 to 117 beats a minute, so nothing here assumes a
-- tempo: bars and beats come from the beat list in sync.lua, and everything that lands on a beat -- tempo: bars and beats come from the beat list in sync.lua, and everything that lands on a beat
@ -20,19 +21,35 @@ local BALL_Y = 6.2 -- Where the ball hangs
local BALL_R = 1.45 local BALL_R = 1.45
local FACET = 0.2 local FACET = 0.2
local SPARKLES = 70 -- Points of light the ball throws on the walls local SPARKLES = 70 -- Points of light the ball throws on the walls
local LASERS = 9 local LASERS = 24
local LASER_LEN = 60 -- How far a beam reaches
local LASER_KINDS = 6 -- Patterns the lasers step through (lasers)
local DOTS = 320 -- The dot cloud's dots: 32 round by 10 for the torus
local DOT_SHAPES = 6
local EQ_COLS = 16 -- The equalizer's bands, left to right ...
local EQ_ROWS = 12 -- ... and how many moments of history run back from the front ...
local EQ_STEP = 0.07 -- ... this many seconds apart
local RECORD_R = 4.5 -- The record on the turntable
local RECORD_RPM = 33.3
local GROOVES = 256 -- The record's texture, a side
local RAIN_MAX = 70 -- Balls that fall on the snares in the boing scene local RAIN_MAX = 70 -- Balls that fall on the snares in the boing scene
local TWIST_R = 92 -- The twister's half width, in pixels local TWIST_R = 92 -- The twister's half width, in pixels
local TWIST_ROW = 2 -- Screen rows each slice of the twister covers local TWIST_ROW = 2 -- Screen rows each slice of the twister covers
local FX_W = 320 -- The live plasma's and tunnel's size, stretched to wherever they are shown
local FX_H = 240
local TUNNEL_LAP = 4 -- The tunnel's travel counts a lap of its texture as this many passes
local PLASMA_STOPS = { { 0.00, { 20, 0, 10 } }, { 0.25, { 150, 20, 40 } }, { 0.50, { 255, 150, 30 } }, { 0.70, { 255, 230, 140 } }, { 0.85, { 255, 255, 255 } }, { 1.00, { 20, 0, 10 } } }
local TUNNEL_STOPS = { { 0.00, { 20, 0, 10 } }, { 0.30, { 150, 20, 40 } }, { 0.55, { 255, 150, 30 } }, { 0.80, { 255, 230, 140 } }, { 1.00, { 255, 255, 255 } } }
local DISCO = { { 255, 40, 80 }, { 255, 160, 20 }, { 255, 240, 60 }, { 60, 255, 120 }, { 40, 200, 255 }, { 160, 60, 255 } } local DISCO = { { 255, 40, 80 }, { 255, 160, 20 }, { 255, 240, 60 }, { 60, 255, 120 }, { 40, 200, 255 }, { 160, 60, 255 } }
local sync = K.load(DIR, W, H) local sync = K.load(DIR, W, H)
local art = {} local art = {}
local video = {}
local world = {} local world = {}
local lastT = 0 local lastT = 0
local SCROLL_TEXT = " MIRRORBALL ... A SINGE DEMO ... THIS GROOVE SPEEDS UP AS IT GOES, AND EVERY FLASH, EVERY CUT AND EVERY BOUNCE FOLLOWS IT, BEAT BY BEAT, BECAUSE THE BEATS WERE READ OUT OF THE MUSIC ... COPPER BARS, A ROTOZOOMER, A TWISTER DRAWN ONE SCANLINE AT A TIME, GLENZ GLASS AND A BOUNCING BALL: THE OLD TRICKS, ON A NEW ENGINE ... GREETINGS TO EVERYONE WHO EVER CARRIED A COMPUTER TO A PARTY ... " local SCROLL_TEXT = "MIRRORBALL ... A SINGE DEMO ... THIS GROOVE SPEEDS UP AS IT GOES, AND EVERY FLASH AND EVERY CUT FOLLOWS IT, BEAT BY BEAT ..."
local SECOND_TEXT = "COPPER BARS, A ROTOZOOMER, A TWISTER DRAWN ONE SCANLINE AT A TIME, AND GLENZ GLASS ... THE OLD TRICKS, ON A NEW ENGINE ..."
local PARTY_TEXT = "GREETINGS TO EVERYONE ON THE DANCE FLOOR ... KEEP IT FUNKY ... MIRRORBALL ... A SINGE DEMO ..."
-- Loading -------------------------------------------------------------------------------------------- -- Loading --------------------------------------------------------------------------------------------
@ -44,11 +61,9 @@ local function loadArt()
spriteQuality(art.logo, RENDER_SMOOTH) spriteQuality(art.logo, RENDER_SMOOTH)
spriteQuality(art.singe, RENDER_SMOOTH) spriteQuality(art.singe, RENDER_SMOOTH)
art.font = fontLoad("Singe/FreeSansBold.ttf", 44) art.font = fontLoad("Singe/FreeSansBold.ttf", 44)
video.plasma = videoLoad(DIR .. "video/plasma.mp4") -- The plasma and the tunnel, worked out live in Lua a frame at a time.
video.tunnel = videoLoad(DIR .. "video/tunnel.mp4") art.plasma = K.plasmaNew(FX_W, FX_H, PLASMA_STOPS)
video.tunnelFrames = videoGetFrameCount(video.tunnel) art.tunnel = K.tunnelNew(FX_W, FX_H, TUNNEL_STOPS)
videoSetVolume(video.plasma, 0, 0)
videoSetVolume(video.tunnel, 0, 0)
end end
@ -177,23 +192,29 @@ local function buildLights()
end end
-- Lasers: thin bright rods fanning out from a point high on the back wall. -- Lasers: thin bright rods, each on an arm placed and aimed every frame by the pattern (lasers),
-- in the club's colours. The rod hangs off its arm's -Z, the way nodeLookAt points.
local function buildLasers() local function buildLasers()
local mesh = meshBox(1, 1, LASER_LEN)
world.laserMaterials = {}
for i, c in ipairs(DISCO) do
local m = materialNew() local m = materialNew()
materialSetColor(m, 0, 0, 0) materialSetColor(m, 0, 0, 0)
materialSetEmissive(m, 60, 255, 90) materialSetEmissive(m, c[1], c[2], c[3])
world.laserMaterial = m world.laserMaterials[i] = m
end
world.laserFan = nodeNew() world.laserFan = nodeNew()
nodeSetPosition(world.laserFan, 0, 9, -16)
world.lasers = {} world.lasers = {}
for i = 1, LASERS do for i = 1, LASERS do
local arm = nodeNew(world.laserFan) local arm = nodeNew(world.laserFan)
local rod = nodeNew(arm) local rod = nodeNew(arm)
nodeSetMesh(rod, meshBox(0.1, 0.1, 40), m) nodeSetMesh(rod, mesh, world.laserMaterials[i % #DISCO + 1])
nodeSetPosition(rod, 0, 0, 20) nodeSetPosition(rod, 0, 0, -LASER_LEN / 2)
world.lasers[i] = arm nodeSetShadow(rod, false)
world.lasers[i] = { arm = arm, rod = rod }
end end
end end
@ -279,11 +300,13 @@ local function buildScreens()
world.roto = K.screenQuad(world.camera, FOV, roto, 2, 2.2) world.roto = K.screenQuad(world.camera, FOV, roto, 2, 2.2)
local tunnel = materialNew() local tunnel = materialNew()
materialSetVideo(tunnel, video.tunnel) world.tunnelMaterial = tunnel
materialSetTexture(tunnel, art.tunnel.sprite)
materialSetUnlit(tunnel, true) materialSetUnlit(tunnel, true)
materialSetDoubleSided(tunnel, true) materialSetDoubleSided(tunnel, true)
world.tunnel = K.screenQuad(world.camera, FOV, tunnel, 60, 1.8) world.tunnel = K.screenQuad(world.camera, FOV, tunnel, 60, 1.8)
world.kaleido = nodeNew(world.camera) world.kaleido = nodeNew(world.camera)
world.kaleidoMaterials = {}
nodeSetPosition(world.kaleido, 0, 0, -2) nodeSetPosition(world.kaleido, 0, 0, -2)
local h = 2 * 2 * math.tan(math.rad(FOV / 2)) local h = 2 * 2 * math.tan(math.rad(FOV / 2))
local w = h * W / H local w = h * W / H
@ -292,7 +315,8 @@ local function buildScreens()
local m = materialNew() local m = materialNew()
local n = nodeNew(world.kaleido) local n = nodeNew(world.kaleido)
materialSetVideo(m, video.plasma) materialSetTexture(m, art.plasma.sprite)
world.kaleidoMaterials[#world.kaleidoMaterials + 1] = m
materialSetUnlit(m, true) materialSetUnlit(m, true)
materialSetDoubleSided(m, true) materialSetDoubleSided(m, true)
materialSetTiling(m, q[3], q[4]) materialSetTiling(m, q[3], q[4])
@ -303,6 +327,182 @@ local function buildScreens()
end end
-- The dot cloud: glowing dots that gather into a new shape on every bar. Each shape is worked out
-- once, a point per dot.
local function dotShape(shape, i)
local f = (i - 0.5) / DOTS
if shape == 0 then
-- A sphere, the dots spread evenly by the golden angle.
local y = 1 - 2 * f
local r = math.sqrt(1 - y * y)
local a = i * 2.39996
return math.cos(a) * r * 3, y * 3, math.sin(a) * r * 3
elseif shape == 1 then
-- A torus, 32 round and 10 about the tube.
local a = (i % 32) / 32 * 2 * math.pi
local b = math.floor(i / 32) / 10 * 2 * math.pi
return (2.6 + math.cos(b)) * math.cos(a), math.sin(b), (2.6 + math.cos(b)) * math.sin(a)
elseif shape == 2 then
-- A cube's six faces, a grid on each.
local k = math.floor((i - 1) / 6)
local u = (k % 8) / 7 * 2 - 1
local v = math.floor(k / 8) % 8 / 7 * 2 - 1
local p = ({ { 1, u, v }, { -1, u, v }, { u, 1, v }, { u, -1, v }, { u, v, 1 }, { u, v, -1 } })[(i - 1) % 6 + 1]
return p[1] * 2.2, p[2] * 2.2, p[3] * 2.2
elseif shape == 3 then
-- Two strands of a helix.
local k = math.floor((i - 1) / 2)
local a = k * 0.25 + (i % 2) * math.pi
return math.cos(a) * 1.8, (k / (DOTS / 2) - 0.5) * 7, math.sin(a) * 1.8
elseif shape == 4 then
-- A flower of eight petals, rippling.
local a = f * 2 * math.pi
local r = 3.2 * math.cos(4 * a)
return math.cos(a) * r, 0.8 * math.sin(8 * a), math.sin(a) * r
end
-- A flat grid, which the music makes into waves (dotsUpdate).
return (i % 20) / 19 * 7 - 3.5, 0, math.floor(i / 20) / 15 * 6 - 3
end
local function buildDots()
local mesh = meshSphere(0.11, 8)
world.dots = nodeNew()
world.dotNodes = {}
world.dotMaterials = {}
world.dotShapes = {}
for i, c in ipairs(DISCO) do
local m = materialNew()
materialSetColor(m, 0, 0, 0)
materialSetEmissive(m, c[1], c[2], c[3])
world.dotMaterials[i] = m
end
for i = 1, DOTS do
local n = nodeNew(world.dots)
nodeSetMesh(n, mesh, world.dotMaterials[i % #DISCO + 1])
nodeSetShadow(n, false)
world.dotNodes[i] = n
end
for shape = 0, DOT_SHAPES - 1 do
local points = {}
for i = 1, DOTS do
points[i] = { dotShape(shape, i) }
end
world.dotShapes[shape + 1] = points
end
end
-- The equalizer: a city of bars, a band of the music to each column and the last moments of it
-- running back in the rows, so the beat rolls away from the viewer.
local function buildEqualizer()
local mesh = meshBox(0.8, 1, 0.8)
world.eq = nodeNew()
world.eqBars = {}
world.eqMaterials = {}
for c = 1, EQ_COLS do
local m = materialNew()
materialSetColor(m, 20, 20, 30)
materialSetMetallic(m, 0.6)
materialSetRoughness(m, 0.25)
world.eqMaterials[c] = m
world.eqBars[c] = {}
for r = 1, EQ_ROWS do
local n = nodeNew(world.eq)
nodeSetMesh(n, mesh, m)
world.eqBars[c][r] = n
end
end
end
-- The turntable: a record of grooves round a red label with the Singe logo on it, on a chrome
-- platter, and a tonearm resting on it. The grooves are drawn here, a pixel at a time.
local function buildTurntable()
local grooves = spriteNew(GROOVES, GROOVES)
local pixels = {}
local vinyl = materialNew()
local label = materialNew()
local black = materialNew()
local steel = K.chrome(200, 200, 210, 0.15)
for y = 0, GROOVES - 1 do
for x = 0, GROOVES - 1 do
local d = math.sqrt((x - GROOVES / 2 + 0.5) ^ 2 + (y - GROOVES / 2 + 0.5) ^ 2) / (GROOVES / 2)
local v = math.floor(18 + 10 * (0.5 + 0.5 * math.sin(d * 420)))
if d < 0.34 then
pixels[#pixels + 1] = (d > 0.32) and string.char(240, 200, 90, 255) or string.char(190, 25, 50, 255)
else
-- The gaps between the tracks are smooth, and shine.
for _, gap in ipairs({ 0.52, 0.68, 0.83 }) do
if math.abs(d - gap) < 0.008 then
v = 45
end
end
pixels[#pixels + 1] = string.char(v, v, v + 2, 255)
end
end
end
spriteSetPixels(grooves, table.concat(pixels))
materialSetTexture(vinyl, grooves)
materialSetRoughness(vinyl, 0.3)
materialSetMetallic(vinyl, 0.2)
materialSetTexture(label, art.singe)
materialSetBlend(label, true)
materialSetUnlit(label, true)
materialSetColor(black, 6, 6, 8)
materialSetRoughness(black, 0.25)
world.vinyl = nodeNew()
world.record = nodeNew(world.vinyl)
nodeSetMesh(world.record, K.disc(RECORD_R, 96), vinyl)
local logo = nodeNew(world.record)
local w = RECORD_R * 0.55
nodeSetMesh(logo, meshPlane(w, w * spriteGetHeight(art.singe) / spriteGetWidth(art.singe)), label)
nodeSetPosition(logo, 0, 0.01, 0)
local platter = nodeNew(world.vinyl)
nodeSetMesh(platter, meshCylinder(RECORD_R + 0.15, 0.3, 64), steel)
nodeSetPosition(platter, 0, -0.16, 0)
local plinth = nodeNew(world.vinyl)
nodeSetMesh(plinth, meshBox(12, 0.8, 11), black)
nodeSetPosition(plinth, 0.8, -0.72, 0)
local spindle = nodeNew(world.vinyl)
nodeSetMesh(spindle, meshCylinder(0.08, 0.4, 12), steel)
-- The tonearm turns about its post at the back right; its head rests on the record.
world.tonearm = nodeNew(world.vinyl)
nodeSetPosition(world.tonearm, 5.6, 0.55, -3.8)
local post = nodeNew(world.vinyl)
nodeSetMesh(post, meshCylinder(0.3, 0.9, 16), steel)
nodeSetPosition(post, 5.6, 0.1, -3.8)
local arm = nodeNew(world.tonearm)
nodeSetMesh(arm, meshBox(0.12, 0.12, 6.4), steel)
nodeSetPosition(arm, 0, 0, 3.2)
local head = nodeNew(world.tonearm)
nodeSetMesh(head, meshBox(0.5, 0.2, 0.9), black)
nodeSetPosition(head, 0, -0.35, 6.4)
end
local function buildWorld() local function buildWorld()
sceneEnable(true) sceneEnable(true)
sceneSetSky(DIR .. "art/club.png") sceneSetSky(DIR .. "art/club.png")
@ -319,6 +519,9 @@ local function buildWorld()
buildGlenz() buildGlenz()
buildBoing() buildBoing()
buildScreens() buildScreens()
buildDots()
buildEqualizer()
buildTurntable()
end end
@ -335,7 +538,7 @@ local function hideAll()
for _, tile in ipairs(world.tiles) do for _, tile in ipairs(world.tiles) do
nodeSetVisible(tile.node, false) nodeSetVisible(tile.node, false)
end end
setVisible({ world.ball, world.rope, world.sparkleRing, world.laserFan, world.glenz, world.boing, world.roto, world.tunnel, world.kaleido }, false) setVisible({ world.ball, world.rope, world.sparkleRing, world.laserFan, world.glenz, world.boing, world.roto, world.tunnel, world.kaleido, world.dots, world.eq, world.vinyl }, false)
for _, l in ipairs(world.spots) do for _, l in ipairs(world.spots) do
lightSetIntensity(l, 0) lightSetIntensity(l, 0)
end end
@ -343,6 +546,7 @@ local function hideAll()
lightSetIntensity(world.sun, 0) lightSetIntensity(world.sun, 0)
sceneSetFog() sceneSetFog()
sceneSetSkyIntensity(1.0) sceneSetSkyIntensity(1.0)
sceneSetBloom(0.7, 0.55)
end end
@ -428,16 +632,100 @@ local function floor(t, pattern, level)
end end
local function lasers(t, level) -- A repeatable number from 0 to 1 for a pair of numbers, so a pattern lands the same way every run.
for i, arm in ipairs(world.lasers) do local function hash(a, b)
local spread = (i - (LASERS + 1) / 2) / LASERS return (math.sin(a * 12.9898 + b * 78.233) * 43758.5453) % 1
local sweep = math.sin(t * 1.3) * 25 + spread * (60 + 30 * math.sin(t * 0.7)) end
nodeSetRotation(arm, 0, sweep, -10 + 12 * math.sin(t * 0.9 + i))
-- The lasers, stepping through their patterns perBar times a bar: a fan sweeping from the back
-- wall, scissors from the two corners, beams thrown off the turning ball, a tunnel round the camera,
-- a sheet that rolls over, and chaos, thrown somewhere new on every snare. The colours step round on
-- every beat, beams chase on the hats, and they fatten on the kicks and flare on the snares.
local function lasers(t, level, perBar)
local kind = math.floor((K.bar(t) - 1) * (perBar or 1)) % LASER_KINDS
local beat = K.beat(t)
local chase = math.floor(beat * 4)
local width = 0.05 + 0.12 * K.decay("kicks", t, 7)
local glow = level * (0.6 + 0.4 * K.decay("snares", t, 8))
local snares = K.hitCount("snares", 0, t)
local cx, cy, cz = nodeGetWorldPosition(world.camera)
for i, c in ipairs(DISCO) do
materialSetEmissive(world.laserMaterials[i], math.floor(c[1] * glow), math.floor(c[2] * glow), math.floor(c[3] * glow))
end end
local v = math.floor(255 * level * (0.6 + 0.4 * K.decay("snares", t, 8))) for i, beam in ipairs(world.lasers) do
local f = (i - 0.5) / LASERS
local ox = 0
local oy = 9
local oz = -16
local dx = 0
local dy = 0
local dz = 1
local on = true
materialSetEmissive(world.laserMaterial, v // 4, v, v // 3) if kind == 0 then
local a = math.rad(math.sin(t * 1.3) * 25 + (f - 0.5) * (110 + 40 * math.sin(t * 0.7)))
local e = math.rad(-12 + 14 * math.sin(t * 0.9 + i))
dx = math.sin(a) * math.cos(e)
dy = math.sin(e)
dz = math.cos(a) * math.cos(e)
on = (i + chase) % 3 ~= 0
elseif kind == 1 then
local side = (i % 2 == 0) and 1 or -1
local k = math.floor((i - 1) / 2)
local a = math.rad(-side * (15 + k * 5 + 30 * math.sin(t * 2.4)))
local e = math.rad(-8 - 6 * math.sin(t * 3 + k))
ox = side * 12
oy = 8
oz = -12
dx = math.sin(a) * math.cos(e)
dy = math.sin(e)
dz = math.cos(a) * math.cos(e)
elseif kind == 2 then
local y = -0.85 + 0.9 * f
local r = math.sqrt(1 - y * y)
local a = i * 2.39996 + t * 0.9
oy = BALL_Y
oz = 0
dx = math.cos(a) * r
dy = y
dz = math.sin(a) * r
elseif kind == 3 then
local a = i / LASERS * 2 * math.pi + t * 0.8
ox = math.cos(a) * 9
oy = 5 + math.sin(a) * 6
oz = -18
dx = cx + math.cos(a) * 2.5 - ox
dy = cy + math.sin(a) * 2.5 - oy
dz = cz - oz
elseif kind == 4 then
local p = math.rad((f - 0.5) * 160)
local r = math.rad(t * 70)
oy = 10
dx = math.sin(p) * math.cos(r)
dy = math.sin(p) * math.sin(r) - 0.25
dz = math.cos(p)
on = (i + chase) % 2 == 0
else
ox = ((i % 3) - 1) * 12
oy = 9 + (i % 3 == 1 and 1 or 0)
oz = -14
dx = hash(i, snares) * 2 - 1 + 0.1 * math.sin(t * 3 + i)
dy = -hash(snares, i) * 0.8
dz = 0.3 + hash(i + 7, snares)
end
nodeSetVisible(beam.arm, on)
nodeSetPosition(beam.arm, ox, oy, oz)
nodeLookAt(beam.arm, ox + dx, oy + dy, oz + dz)
nodeSetScale(beam.rod, width, width, 1)
nodeSetMaterial(beam.rod, world.laserMaterials[(i + math.floor(beat)) % #DISCO + 1])
end
end end
@ -565,6 +853,159 @@ local function shatter(t)
end end
-- Scenes placed more than once ---------------------------------------------------------------------
-- Each is a scene table without its bars; at() gives it them, and a flash of white as it starts.
local EQ_BANDS = { "kick", "kick", "kick", "bass", "bass", "bass", "level", "level", "bright", "bright", "snare", "snare", "snare", "hat", "hat", "hat" }
local function at(from, to, scene)
local placed = {}
local start = K.barTime(from)
for key, value in pairs(scene) do
placed[key] = value
end
placed.from = from
placed.to = to
placed.draw = function(t, bar, k)
scene.draw(t, bar, k)
K.flash(255, 255, 255, math.floor(255 * math.exp(-math.max(0, t - start) * 9)))
end
return placed
end
-- The dot cloud gathers into a new shape on every bar, the dots setting off one after another, and
-- swells on the kicks. The flat grid rides the music as waves.
local DOT_CLOUD = {
enter = function(t)
hideAll()
nodeSetVisible(world.dots, true)
sceneSetSkyIntensity(0.35)
end,
draw = function(t, bar, k)
local whole = math.floor(bar)
local before = (whole - 2) % DOT_SHAPES + 1
local now = (whole - 1) % DOT_SHAPES + 1
local into = (bar - whole) * 4
local pulse = 1 + 0.25 * K.decay("kicks", t, 7)
local level = K.env("level", t)
local glow = 0.6 + 0.6 * K.env("hat", t)
for i, n in ipairs(world.dotNodes) do
local m = K.ease(K.clamp(into * 1.4 - (i / DOTS) * 0.6, 0, 1))
local a = world.dotShapes[before][i]
local b = world.dotShapes[now][i]
local x = K.lerp(a[1], b[1], m) * pulse
local z = K.lerp(a[3], b[3], m) * pulse
local flat = (before == DOT_SHAPES and 1 - m or 0) + (now == DOT_SHAPES and m or 0)
nodeSetPosition(n, x, K.lerp(a[2], b[2], m) * pulse + flat * math.sin(x * 1.3 + z * 0.7 + t * 5) * 1.2 * level, z)
end
for i, c in ipairs(DISCO) do
materialSetEmissive(world.dotMaterials[i], math.floor(math.min(255, c[1] * glow)), math.floor(math.min(255, c[2] * glow)), math.floor(math.min(255, c[3] * glow)))
end
nodeSetPosition(world.dots, 0, 2.5, 0)
nodeSetRotation(world.dots, 20 * math.sin(t * 0.5), t * 40, 0)
nodeSetPosition(world.camera, 0, 3.2, 7.5)
nodeLookAt(world.camera, 0, 2.5, 0)
K.strobe(t, "kicks", 14, 255, 255, 255, 30)
end,
}
-- The equalizer city, flown over low: each column a band of the music, each row the same band a
-- moment earlier, coloured round the wheel and lit by how loud the front row is.
local EQUALIZER = {
enter = function(t)
hideAll()
nodeSetVisible(world.eq, true)
sceneSetSkyIntensity(0.7)
lightSetIntensity(world.sun, 3)
end,
draw = function(t, bar, k)
for c = 1, EQ_COLS do
local band = EQ_BANDS[c]
local tilt = 0.75 + 0.25 * math.sin(c * 1.7)
local front = K.env(band, t) * tilt
local cr, cg, cb = K.hue(c / EQ_COLS * 0.8 + t * 0.05)
local g = 0.2 + 0.9 * front
for r = 1, EQ_ROWS do
local h = 0.15 + 5.5 * K.env(band, t - (r - 1) * EQ_STEP) * tilt
local n = world.eqBars[c][r]
nodeSetPosition(n, c - EQ_COLS / 2 - 0.5, h / 2, -(r - 1))
nodeSetScale(n, 1, h, 1)
end
materialSetEmissive(world.eqMaterials[c], math.floor(math.min(255, cr * g)), math.floor(math.min(255, cg * g)), math.floor(math.min(255, cb * g)))
end
nodeSetPosition(world.camera, math.sin(t * 0.35) * 8, 3.2 + math.sin(t * 0.6), 7)
nodeLookAt(world.camera, 0, 1.2, -5)
sceneSetBloom(0.6, 0.6 + 0.4 * K.decay("kicks", t, 7))
end,
}
-- The turntable: the record turns at its speed and is scratched back and forth on every snare, the
-- tonearm creeps inward, and the camera cuts round it every two beats.
local TURNTABLE_SHOTS = {
{ -7, 2.2, 6, 0, 0, 0 }, { 0, 9, 0.1, 0, 0, 0 }, { 6.5, 1.4, 3.5, 2.5, 0.2, 1.5 }, { -1.5, 0.7, 6, 0, 0.1, 0 },
}
local TURNTABLE = {
enter = function(t)
hideAll()
nodeSetVisible(world.vinyl, true)
sceneSetSkyIntensity(0.6)
lightSetIntensity(world.sun, 2)
world.recordLast = t
end,
draw = function(t, bar, k)
local age = K.hitAge("snares", t)
local scratch = -45 * math.sin(age * 2 * math.pi * 3) * math.exp(-age * 7)
local shot = TURNTABLE_SHOTS[math.floor(K.beat(t) / 2) % #TURNTABLE_SHOTS + 1]
local drift = math.sin(t * 0.7) * 0.4
world.recordAngle = (world.recordAngle or 0) + (t - world.recordLast) * RECORD_RPM * 6
world.recordLast = t
nodeSetRotation(world.record, 0, -(world.recordAngle + scratch), 0)
nodeSetRotation(world.tonearm, 0, -21 - 6 * k, 0)
nodeSetPosition(world.camera, shot[1] + drift, shot[2], shot[3])
nodeLookAt(world.camera, shot[4], shot[5], shot[6])
spots(t, 0.8)
K.strobe(t, "kicks", 16, 255, 255, 255, 25)
end,
}
-- The party: the club with everything on. The final version turns the camera faster and the lasers
-- change twice a bar.
local function party(final)
return {
enter = function(t)
hideAll()
showClub(true)
nodeSetVisible(world.laserFan, true)
end,
draw = function(t, bar, k)
if final then
orbit(t, 9 + 3 * math.sin(t * 0.5), 2 + 2 * math.sin(t * 0.3), 0.45, BALL_Y - 1)
else
orbit(t, 13, 3.5, 0.18, BALL_Y - 1.5)
end
ballSpin(t, BALL_Y, final and 70 or 40, 1)
spots(t, 1)
lasers(t, 1, final and 2 or 1)
floor(t, math.floor((bar - 97) / 2) % 4, 1)
sceneSetBloom(0.7, 0.55 + (final and 0.4 or 0.2) * K.decay("kicks", t, 7))
K.bobs(t, bar, art.bob, 36, 0.9, 13, W / 2, 150)
K.strobe(t, "kicks", 18, 255, 255, 255, final and 50 or 25)
end,
}
end
-- The scenes ------------------------------------------------------------------------------------------- -- The scenes -------------------------------------------------------------------------------------------
local SCENES = { local SCENES = {
@ -641,20 +1082,23 @@ local SCENES = {
local note, clear = K.note(t) local note, clear = K.note(t)
copperBars(t, 6, 40, 30, note / 12, math.floor(120 + 135 * clear)) copperBars(t, 6, 40, 30, note / 12, math.floor(120 + 135 * clear))
K.scroller(t, art.font, SCROLL_TEXT, H - 80, 22, 180, K.barTime(17)) K.scroller(t, art.font, SCROLL_TEXT, H - 80, 22, K.barTime(17), K.barTime(25))
K.flash(255, 255, 255, math.floor(255 * (1 - K.smooth(17, 17.2, bar)))) K.flash(255, 255, 255, math.floor(255 * (1 - K.smooth(17, 17.2, bar))))
end, end,
}, },
-- Bars 25 to 32: a kaleidoscope of the plasma, four mirrored copies, under vector bobs that take -- Bars 25 to 28: a kaleidoscope of the plasma, four mirrored copies, under vector bobs that take
-- a new shape every two bars. -- a new shape every two bars. Then the dot cloud.
{ {
from = 25, to = 33, from = 25, to = 29,
enter = function(t) enter = function(t)
hideAll() hideAll()
nodeSetVisible(world.kaleido, true) nodeSetVisible(world.kaleido, true)
videoPlay(video.plasma)
end, end,
draw = function(t, bar, k) draw = function(t, bar, k)
K.plasmaUpdate(art.plasma, t)
for _, m in ipairs(world.kaleidoMaterials) do
materialSetTexture(m, art.plasma.sprite)
end
nodeSetPosition(world.camera, 0, 0, 0) nodeSetPosition(world.camera, 0, 0, 0)
nodeLookAt(world.camera, 0, 0, -1) nodeLookAt(world.camera, 0, 0, -1)
nodeSetRotation(world.kaleido, 0, 0, math.sin(t * 0.4) * 20) nodeSetRotation(world.kaleido, 0, 0, math.sin(t * 0.4) * 20)
@ -663,6 +1107,7 @@ local SCENES = {
K.flash(255, 255, 255, math.floor(255 * (1 - K.smooth(25, 25.2, bar)))) K.flash(255, 255, 255, math.floor(255 * (1 - K.smooth(25, 25.2, bar))))
end, end,
}, },
at(29, 33, DOT_CLOUD),
-- Bars 33 to 40: the chop comes back, and the picture cuts on every beat between the club's -- Bars 33 to 40: the chop comes back, and the picture cuts on every beat between the club's
-- corners; the gaps in the groove go black. -- corners; the gaps in the groove go black.
{ {
@ -702,15 +1147,15 @@ local SCENES = {
K.flash(14, 4, 22, 255) K.flash(14, 4, 22, 255)
copperBars(t, 14, 220, 200, t * 0.03, 200) copperBars(t, 14, 220, 200, t * 0.03, 200)
twister(t, W / 2 + math.sin(t * 0.7) * 120) twister(t, W / 2 + math.sin(t * 0.7) * 120)
K.scroller(t, art.font, SCROLL_TEXT, 30, 14, 200, K.barTime(41) - 40) K.scroller(t, art.font, SECOND_TEXT, 30, 14, K.barTime(41), K.barTime(49))
K.strobe(t, "kicks", 14, 255, 255, 255, 50) K.strobe(t, "kicks", 14, 255, 255, 255, 50)
K.flash(255, 255, 255, math.floor(255 * (1 - K.smooth(41, 41.2, bar)))) K.flash(255, 255, 255, math.floor(255 * (1 - K.smooth(41, 41.2, bar))))
end, end,
}, },
-- Bars 49 to 56: the boing ball, bouncing on every beat and crossing the room every bar, while -- Bars 49 to 52: the boing ball, bouncing on every beat and crossing the room every bar, while
-- a ball drops from the ceiling on every snare and piles up on the floor. -- a ball drops from the ceiling on every snare and piles up on the floor. Then the equalizer.
{ {
from = 49, to = 57, from = 49, to = 53,
enter = function(t) enter = function(t)
hideAll() hideAll()
showClub(false) showClub(false)
@ -747,9 +1192,10 @@ local SCENES = {
world.rain = {} world.rain = {}
end, end,
}, },
-- Bars 57 to 64: glenz glass in the tunnel, the tunnel scrubbed by the kick. at(53, 57, EQUALIZER),
-- Bars 57 to 60: glenz glass in the tunnel, the tunnel scrubbed by the kick. Then the turntable.
{ {
from = 57, to = 65, from = 57, to = 61,
enter = function(t) enter = function(t)
hideAll() hideAll()
nodeSetVisible(world.tunnel, true) nodeSetVisible(world.tunnel, true)
@ -762,9 +1208,10 @@ local SCENES = {
draw = function(t, bar, k) draw = function(t, bar, k)
nodeSetPosition(world.camera, 0, 0, 0) nodeSetPosition(world.camera, 0, 0, 0)
nodeLookAt(world.camera, 0, 0, -1) nodeLookAt(world.camera, 0, 0, -1)
world.tunnelPhase = (world.tunnelPhase + (t - world.tunnelLast) * (0.5 + 1.5 * K.decay("kicks", t, 5))) % 1 world.tunnelPhase = world.tunnelPhase + (t - world.tunnelLast) * (0.5 + 1.5 * K.decay("kicks", t, 5))
world.tunnelLast = t world.tunnelLast = t
videoSeek(video.tunnel, math.floor(world.tunnelPhase * video.tunnelFrames)) K.tunnelUpdate(art.tunnel, (world.tunnelPhase / TUNNEL_LAP) % 1, 0)
materialSetTexture(world.tunnelMaterial, art.tunnel.sprite)
nodeSetRotation(world.tunnel, 0, 0, -t * 20) nodeSetRotation(world.tunnel, 0, 0, -t * 20)
local pump = 1 + 0.2 * K.decay("kicks", t, 8) local pump = 1 + 0.2 * K.decay("kicks", t, 8)
@ -775,6 +1222,7 @@ local SCENES = {
K.flash(255, 255, 255, math.floor(255 * (1 - K.smooth(57, 57.2, bar)))) K.flash(255, 255, 255, math.floor(255 * (1 - K.smooth(57, 57.2, bar))))
end, end,
}, },
at(61, 65, TURNTABLE),
-- Bars 65 to 72: the laser show, in a haze, the camera moving through the beams. -- Bars 65 to 72: the laser show, in a haze, the camera moving through the beams.
{ {
from = 65, to = 73, from = 65, to = 73,
@ -788,7 +1236,7 @@ local SCENES = {
orbit(t, 12, 2 + math.sin(t * 0.4) * 1.5, -0.15, 5) orbit(t, 12, 2 + math.sin(t * 0.4) * 1.5, -0.15, 5)
ballSpin(t, BALL_Y, 45, 1) ballSpin(t, BALL_Y, 45, 1)
spots(t, 0.8) spots(t, 0.8)
lasers(t, 1) lasers(t, 1, 2)
floor(t, 2, 0.7) floor(t, 2, 0.7)
K.strobe(t, "snares", 20, 255, 255, 255, 60) K.strobe(t, "snares", 20, 255, 255, 255, 60)
end, end,
@ -798,10 +1246,10 @@ local SCENES = {
from = 73, to = 81, from = 73, to = 81,
enter = function(t) enter = function(t)
hideAll() hideAll()
videoPlay(video.plasma)
end, end,
draw = function(t, bar, k) draw = function(t, bar, k)
videoDraw(video.plasma, 0, 0, W - 1, H - 1) K.plasmaUpdate(art.plasma, t)
spriteDraw(art.plasma.sprite, 0, 0, W - 1, H - 1)
local punch = 1 + 0.3 * K.decay("kicks", t, 8) local punch = 1 + 0.3 * K.decay("kicks", t, 8)
for i = 1, 12 do for i = 1, 12 do
@ -863,38 +1311,26 @@ local SCENES = {
copperBars(t, 4, H - 40, 8, t * 0.1 + 0.5, math.floor(200 * k)) copperBars(t, 4, H - 40, 8, t * 0.1 + 0.5, math.floor(200 * k))
end, end,
}, },
-- Bars 97 to 112: the party, then everything at once. The floor changes pattern every phrase, -- Bars 97 to 112: the party, cutting faster as it goes: the club, the dot cloud, the equalizer,
-- the bobs and the scroller come up over the club, and in the last phrase the camera spins. -- the club with the camera spinning, the turntable, and the club again to the last downbeat.
at(97, 101, party(false)),
at(101, 103, DOT_CLOUD),
at(103, 105, EQUALIZER),
at(105, 107, party(true)),
at(107, 109, TURNTABLE),
at(109, 113, party(true)),
-- Over the whole party, whatever is cut to: the scroller, and the flash into the last downbeat.
{ {
from = 97, to = 113, from = 97, to = 113,
enter = function(t)
hideAll()
showClub(true)
nodeSetVisible(world.laserFan, true)
end,
draw = function(t, bar, k) draw = function(t, bar, k)
local final = bar >= 105 K.scroller(t, art.font, PARTY_TEXT, H - 70, 16, K.barTime(97), K.barTime(113))
if final then
orbit(t, 9 + 3 * math.sin(t * 0.5), 2 + 2 * math.sin(t * 0.3), 0.45, BALL_Y - 1)
else
orbit(t, 13, 3.5, 0.18, BALL_Y - 1.5)
end
ballSpin(t, BALL_Y, final and 70 or 40, 1)
spots(t, 1)
lasers(t, 1)
floor(t, math.floor((bar - 97) / 2) % 4, 1)
sceneSetBloom(0.7, 0.55 + (final and 0.4 or 0.2) * K.decay("kicks", t, 7))
K.bobs(t, bar, art.bob, 36, 0.9, 13, W / 2, 150)
K.scroller(t, art.font, " GREETINGS TO EVERYONE ON THE DANCE FLOOR ... KEEP IT FUNKY ... MIRRORBALL ... A SINGE DEMO ... ", H - 70, 16, 190, K.barTime(97))
K.strobe(t, "kicks", 18, 255, 255, 255, final and 50 or 25)
if bar > 112.5 then if bar > 112.5 then
K.flash(255, 255, 255, math.floor(200 * K.smooth(112.5, 113, bar))) K.flash(255, 255, 255, math.floor(200 * K.smooth(112.5, 113, bar)))
end end
end, end,
}, },
-- Bar 113 on: the last downbeat. The ball shatters, its facets rain onto the floor, the lights -- Bar 113 on: the last downbeat. The ball shatters, its facets rain onto the floor, and the
-- go down, and the credits come up. -- lights go down.
{ {
from = 113, to = 200, from = 113, to = 200,
enter = function(t) enter = function(t)
@ -916,22 +1352,6 @@ local SCENES = {
lightSetIntensity(l, math.max(0, 120 - p * 25)) lightSetIntensity(l, math.max(0, 120 - p * 25))
end end
K.flash(255, 255, 255, math.floor(255 * math.exp(-p * 3))) K.flash(255, 255, 255, math.floor(255 * math.exp(-p * 3)))
local lines = {
{ "MIRRORBALL", 56, 255, 200, 90 },
{ "music \"Vintage Funky Groove\" by miiira", 26, 255, 255, 255 },
{ "code and art Claude", 26, 255, 255, 255 },
{ "engine Singe", 26, 255, 255, 255 },
}
local y = 90
for i, line in ipairs(lines) do
local a = K.smooth(1.5 + i * 0.4, 2.1 + i * 0.4, p) * (1 - K.smooth(K.duration - K.barTime(113) - 1, K.duration - K.barTime(113) + 1, p))
if a > 0 then
K.text(W / 2, y, line[1], line[2], math.floor(line[3] * a), math.floor(line[4] * a), math.floor(line[5] * a), "center")
end
y = y + line[2] + 22
end
end, end,
}, },
-- The time at the end of each frame, for "what happened since last frame". -- The time at the end of each frame, for "what happened since last frame".

BIN
demos/mirrorball/video/plasma.mp4 (Stored with Git LFS)

Binary file not shown.

BIN
demos/mirrorball/video/tunnel.mp4 (Stored with Git LFS)

Binary file not shown.

BIN
demos/neonCoast/art/car.png (Stored with Git LFS)

Binary file not shown.

BIN
demos/neonCoast/art/facade.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/neonCoast/art/palm.png (Stored with Git LFS)

Binary file not shown.

BIN
demos/neonCoast/art/skyline.png (Stored with Git LFS)

Binary file not shown.

BIN
demos/neonCoast/art/terrain.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
demos/neonCoast/art/traffic.png (Stored with Git LFS) Normal file

Binary file not shown.

File diff suppressed because it is too large Load diff

BIN
demos/neonCoast/video/noise.mp4 (Stored with Git LFS)

Binary file not shown.

BIN
demos/neonCoast/video/plasma.mp4 (Stored with Git LFS)

Binary file not shown.

BIN
demos/neonCoast/video/tunnel.mp4 (Stored with Git LFS)

Binary file not shown.

View file

@ -582,9 +582,32 @@ rectangle is built in those same coordinates, and the rotation target is the
size of the picture rather than of the window, so the picture, the overlay, the size of the picture rather than of the window, so the picture, the overlay, the
GUIs and the mouse mapping all follow by themselves. GUIs and the mouse mapping all follow by themselves.
The other switches a user can press -- quit, pause, screenshot, the console and The other switches a user can press -- quit, pause, screenshot, recording, the
the mouse capture toggle -- are ordinary `controls.cfg` mappings and are console and the mouse capture toggle -- are ordinary `controls.cfg` mappings and
described with the `INPUT_*` names in the sample file. are described with the `INPUT_*` names in the sample file.
[[recording]]
=== Recording Video
`F10` starts recording the game, picture and sound together, and `F10` again
stops it. Each recording is a new `singeNNN.mp4` in the game's data directory,
beside the screenshots, and plays in anything that plays an MP4. The picture is
the whole window as you see it, letterbox included, and the sound is everything
the game plays: music, sound effects, and the video's own soundtrack.
Where the machine has a hardware video encoder Singe uses it, so recording costs
the game very little: H.264 through VA-API on Linux, VideoToolbox on macOS, Media
Foundation on Windows, and V4L2 on the ARM boards that have one. Anywhere else it
records MPEG-4 instead, which plays everywhere but makes bigger files. The
`Recording` line in the trace says which it used. A machine too slow to keep up
drops frames from the recording rather than slowing the game down, and the trace
says how many when the recording stops.
To record a whole run instead, start Singe with `--record=FILENAME`. Add
`--deterministic` and Singe records every frame and the exact sound that goes
with it, however long each frame takes to make, without playing anything through
the speakers. That is how to make a perfect video of a demo or an attract mode:
it usually runs faster than real time on a desktop.
== Settings and Options == Settings and Options
@ -837,11 +860,12 @@ See <<troubleshooting,When Something Goes Wrong>> for the traces.
[cols="1,2",options="header"] [cols="1,2",options="header"]
|=== |===
| Option | Purpose | Option | Purpose
| `--deterministic[=MS]` | For testing only: ignore real time and run the whole engine on a virtual clock that moves `MS` milliseconds every frame, so the same frame number always means the same moment. The disc follows the virtual clock too, its frames lasting the time they are worth, and the random generators are seeded from the same number, so a run repeats to the pixel. The value is optional and is both the step and the seed, `1` to `1000`. Default when given without one: `15`, the frame time the engine's own rate implies. Audio and pacing are meaningless in this mode; see xref:Reference.adoc#deterministic[Deterministic Test Mode]. Default: off. | `--deterministic[=MS]` | For testing only: ignore real time and run the whole engine on a virtual clock that moves `MS` milliseconds every frame, so the same frame number always means the same moment. The disc follows the virtual clock too, its frames lasting the time they are worth, and the random generators are seeded from the same number, so a run repeats to the pixel. The value is optional and is both the step and the seed, `1` to `1000`. Default when given without one: `15`, the frame time the engine's own rate implies. Pacing is meaningless in this mode, and so is the sound unless `--record` is also given, when it is generated in step with the picture; see xref:Reference.adoc#deterministic[Deterministic Test Mode]. Default: off.
| `-P`, `--pack=DIRECTORY` | Pack the game in `DIRECTORY` into the database named after the options, then exit. See xref:Reference.adoc#singlefile[Single-File Games]. | `-P`, `--pack=DIRECTORY` | Pack the game in `DIRECTORY` into the database named after the options, then exit. See xref:Reference.adoc#singlefile[Single-File Games].
| `-T`, `--patch=GAME.game` | Replace files in the packed game from the directory or patch database named after the options, then exit. | `-T`, `--patch=GAME.game` | Replace files in the packed game from the directory or patch database named after the options, then exit.
| `--perfsync` | With `--program`, wait for the GPU to finish each stage of a frame before timing the next, so the perf lines say which stage the GPU's time belongs to rather than leaving it all in `present`. Everything runs slower while it is on, so it is for measuring, not for playing; see <<perflines,Reading the Perf Lines>>. Default: off. | `--perfsync` | With `--program`, wait for the GPU to finish each stage of a frame before timing the next, so the perf lines say which stage the GPU's time belongs to rather than leaving it all in `present`. Everything runs slower while it is on, so it is for measuring, not for playing; see <<perflines,Reading the Perf Lines>>. Default: off.
| `-p`, `--program` | Trace engine activity to the console and to `trace.txt` in the data directory. The file opens with a header describing the machine; see xref:Reference.adoc#traceheader[The Trace Header]. Every five seconds it also says where the frame's time went; see <<perflines,Reading the Perf Lines>>. | `-p`, `--program` | Trace engine activity to the console and to `trace.txt` in the data directory. The file opens with a header describing the machine; see xref:Reference.adoc#traceheader[The Trace Header]. Every five seconds it also says where the frame's time went; see <<perflines,Reading the Perf Lines>>.
| `--record=FILENAME` | Record the whole run, picture and sound, to an MP4 at `FILENAME`, from the first frame until Singe quits. With `--deterministic` every frame is kept and the sound is generated a step at a time along with the picture, so the two match exactly and nothing is heard while it runs. See <<recording,Recording Video>>. Default: off.
| `-R`, `--reload` | Watch the game's loose script files and run the game again from scratch when one changes; `F5` does the same on demand. A script that fails to compile stays watched, so saving the fix reloads it. For working on a game; see Reloading While You Work. | `-R`, `--reload` | Watch the game's loose script files and run the game again from scratch when one changes; `F5` does the same on demand. A script that fails to compile stays watched, so saving the fix reloads it. For working on a game; see Reloading While You Work.
| `-t`, `--trace` | Trace every Lua API call, with the script line that made it, to the console and to `trace.txt`. The file opens with a header describing the machine; see xref:Reference.adoc#traceheader[The Trace Header]. | `-t`, `--trace` | Trace every Lua API call, with the script line that made it, to the console and to `trace.txt`. The file opens with a header describing the machine; see xref:Reference.adoc#traceheader[The Trace Header].
| `-U`, `--unpack=GAME.game` | Write the packed game's files into the directory named after the options, then exit. A database whose stored names would land outside that directory is refused. | `-U`, `--unpack=GAME.game` | Write the packed game's files into the directory named after the options, then exit. A database whose stored names would land outside that directory is refused.

View file

@ -103,7 +103,10 @@ million pixels, and a real change can hide inside that noise.
What it costs is everything that depends on real time. Audio still plays on the What it costs is everything that depends on real time. Audio still plays on the
device's own clock, which no virtual clock reaches, so sound drifts away from the device's own clock, which no virtual clock reaches, so sound drifts away from the
picture and means nothing. The frame rate means nothing either, and neither does picture and means nothing -- unless the run is recorded with `--record`, when the
sound is mixed into memory one step at a time along with the picture, so a
recording made this way is exact frame by frame and sample by sample (see
<<singerecordstart,singeRecordStart>>). The frame rate means nothing either, and neither does
anything a game measures in seconds. Never report a performance number, an audio anything a game measures in seconds. Never report a performance number, an audio
problem or a timing problem from a run in this mode, and never ship a launcher problem or a timing problem from a run in this mode, and never ship a launcher
that uses it. that uses it.
@ -1685,6 +1688,39 @@ engine is concerned: they parent, scale, and take a material's blend.
its own up axis only (trees, health bars, anything that must stay upright), its own up axis only (trees, health bars, anything that must stay upright),
and `BILLBOARD_NONE` to stop. It works on meshes too. and `BILLBOARD_NONE` to stop. It works on meshes too.
[#scenefront]
==== In Front of the Overlay
The scene normally draws beneath the overlay, so anything drawn on the
overlay covers it. `nodeSetLayer(node, LAYER_FRONT)` moves a node, and
everything parented to it, into the front layer instead: the same camera
draws it again after the overlay, lit by the same lights and finished by the
same exposure, tone curve, and bloom, so a spinning logo, a 3D title, or a
model on a high score table can stand over 2D artwork. The front layer is
its own picture, cleared to transparent, so the overlay shows wherever it
draws nothing, and nothing in the scene behind the overlay can hide it. GUIs
and the particles drawn over the overlay still go on top of it.
Front nodes still cast shadows on the scene. The sky, 3D particles, and
debug lines stay in the scene, and views (`viewNew`) draw only the scene's
nodes. The layer costs a second render pass, and only on frames with
something in it.
[source,lua]
----
-- A gold coin turning over the 2D title screen.
local coin = nodeNew()
nodeSetMesh(coin, meshCylinder(1, 0.2, 48), gold)
nodeSetRotation(coin, 90, 0, 0)
nodeSetLayer(coin, LAYER_FRONT)
function onOverlayUpdate()
overlayClear()
spriteDraw(titleArt, 0, 0)
nodeRotate(coin, 0, 2, 0)
end
----
[source,lua] [source,lua]
---- ----
local tag = nodeNew() local tag = nodeNew()
@ -2471,7 +2507,7 @@ available to `controls.cfg` and to `Framework.singe` alike:
[cols="2,3",options="header"] [cols="2,3",options="header"]
|=== |===
| Constant | Meaning | Constant | Meaning
| `SWITCH_UP` ... `SWITCH_MOUSE_DISCONNECT` | Values passed to `onInputPressed` / `onInputReleased` in `MODE_NORMAL`. `SWITCH_MOUSE_DISCONNECT` is raised by the engine itself when a mouse or light gun is unplugged, once as a press and once as a release; no key or button produces it. | `SWITCH_UP` ... `SWITCH_RECORD` | Values passed to `onInputPressed` / `onInputReleased` in `MODE_NORMAL`. `SWITCH_MOUSE_DISCONNECT` is raised by the engine itself when a mouse or light gun is unplugged, once as a press and once as a release; no key or button produces it. `SWITCH_RECORD` is the recording switch, which the engine acts on itself, as it does the screenshot switch.
| `FONT_QUALITY_SOLID`, `FONT_QUALITY_SHADED`, `FONT_QUALITY_BLENDED` | Arguments for `fontQuality`. | `FONT_QUALITY_SOLID`, `FONT_QUALITY_SHADED`, `FONT_QUALITY_BLENDED` | Arguments for `fontQuality`.
| `MODE_NORMAL`, `MODE_FULL` | Arguments for `keyboardSetMode`. | `MODE_NORMAL`, `MODE_FULL` | Arguments for `keyboardSetMode`.
| `MOUSE_SINGLE`, `MOUSE_MANY` (also `SINGLE_MOUSE`, `MANY_MOUSE`) | Arguments for `mouseSetMode`. | `MOUSE_SINGLE`, `MOUSE_MANY` (also `SINGLE_MOUSE`, `MANY_MOUSE`) | Arguments for `mouseSetMode`.
@ -2488,6 +2524,7 @@ available to `controls.cfg` and to `Framework.singe` alike:
| `PARTICLE_OVER`, `PARTICLE_UNDER` | Where 2D particles draw, for `emitterSetLayer`. | `PARTICLE_OVER`, `PARTICLE_UNDER` | Where 2D particles draw, for `emitterSetLayer`.
| `COLLIDE_NONE`, `COLLIDE_FLOOR`, `COLLIDE_SCENE` | What particles bounce off, for `emitterSetCollide`. | `COLLIDE_NONE`, `COLLIDE_FLOOR`, `COLLIDE_SCENE` | What particles bounce off, for `emitterSetCollide`.
| `BILLBOARD_NONE`, `BILLBOARD_ALL`, `BILLBOARD_Y` | How a node faces the camera, for `nodeSetBillboard`. | `BILLBOARD_NONE`, `BILLBOARD_ALL`, `BILLBOARD_Y` | How a node faces the camera, for `nodeSetBillboard`.
| `LAYER_SCENE`, `LAYER_FRONT` | Whether a node draws beneath the overlay or in front of it, for `nodeSetLayer`.
| `VEHICLE_CAR`, `VEHICLE_MOTORCYCLE`, `VEHICLE_TANK`, `VEHICLE_BOAT` | Kinds of vehicle for `vehicleNew`. | `VEHICLE_CAR`, `VEHICLE_MOTORCYCLE`, `VEHICLE_TANK`, `VEHICLE_BOAT` | Kinds of vehicle for `vehicleNew`.
| `SOFT_CLOTH`, `SOFT_BODY`, `SOFT_ROPE` | Kinds of soft body for `softNew`. | `SOFT_CLOTH`, `SOFT_BODY`, `SOFT_ROPE` | Kinds of soft body for `softNew`.
| `FILTER_LINEAR`, `FILTER_NEAREST` | Texture sampling for `materialSetFilter`. | `FILTER_LINEAR`, `FILTER_NEAREST` | Texture sampling for `materialSetFilter`.
@ -10904,6 +10941,34 @@ nodeSetSprite(icon, spriteLoad("crate.png"), 0.5)
nodeSetBillboard(icon, BILLBOARD_Y) nodeSetBillboard(icon, BILLBOARD_Y)
---- ----
[#nodesetlayer]
==== nodeSetLayer
[source,text]
----
nodeSetLayer(node, layer)
----
Chooses where in the window the node is drawn. `LAYER_SCENE`, the default,
draws it in the scene beneath the overlay; `LAYER_FRONT` draws it after the
overlay, by the same camera with the same lights, into a transparent layer
of its own. A node in the front layer takes its children with it, whatever
their own setting. Any other value aborts the script. See
<<scenefront,In Front of the Overlay>>.
*Since:* 3.00.
*See also:* <<sceneenable,sceneEnable>>, <<nodesetvisible,nodeSetVisible>>, <<emittersetlayer,emitterSetLayer>>
.Example
[source,lua]
----
-- The player's ship stands on the 2D map screen while it is open, and goes
-- back into the world when it closes.
function showMap(open)
nodeSetLayer(ship, open and LAYER_FRONT or LAYER_SCENE)
end
----
[#nodesetmaterial] [#nodesetmaterial]
==== nodeSetMaterial ==== nodeSetMaterial
@ -12023,6 +12088,8 @@ physicsSetGravity(x, y, z)
Sets the acceleration every dynamic body and player feels, in world units per second squared along each world axis. The default is `0, -9.81, 0`, Earth's gravity in meters; `0, 0, 0` is space, and a 2D game in overlay pixels wants something like `0, 900, 0` since overlay Y runs down the screen. The direction opposite to gravity is "up" for players: the way they stand, jump and climb steps. Sets the acceleration every dynamic body and player feels, in world units per second squared along each world axis. The default is `0, -9.81, 0`, Earth's gravity in meters; `0, 0, 0` is space, and a 2D game in overlay pixels wants something like `0, 900, 0` since overlay Y runs down the screen. The direction opposite to gravity is "up" for players: the way they stand, jump and climb steps.
A change of gravity wakes every body, soft bodies included, so a crate that came to rest falls when gravity turns sideways, and cloth blows when a wind made of gravity changes. Setting the same gravity again wakes nothing.
*Since:* 3.00. *Since:* 3.00.
*See also:* <<bodynew,bodyNew>>, <<physicsset2d,physicsSet2D>>, <<playersetgravityscale,playerSetGravityScale>> *See also:* <<bodynew,bodyNew>>, <<physicsset2d,physicsSet2D>>, <<playersetgravityscale,playerSetGravityScale>>
@ -13028,7 +13095,7 @@ end
=== Scene === Scene
The scene is the 3D layer drawn between the disc video and the overlay. It is off until `sceneEnable(true)`, is sized like the overlay, and clears to `sceneSetBackground` every frame. The `sceneSet*` calls set the whole layer's look: ambient light, sky, fog, exposure and tone curve, bloom, antialiasing and shadow quality; colors are integers from `0` to `255` and distances are world units. `sceneProject` and `sceneUnproject` bridge world space and overlay coordinates, and `sceneGetStats` reports last frame's work. See <<scenes3d,3D Scenes>>. The scene is the 3D layer drawn between the disc video and the overlay; nodes put in the front layer with `nodeSetLayer` are drawn again after the overlay. It is off until `sceneEnable(true)`, is sized like the overlay, and clears to `sceneSetBackground` every frame. The `sceneSet*` calls set the whole layer's look: ambient light, sky, fog, exposure and tone curve, bloom, antialiasing and shadow quality; colors are integers from `0` to `255` and distances are world units. `sceneProject` and `sceneUnproject` bridge world space and overlay coordinates, and `sceneGetStats` reports last frame's work. See <<scenes3d,3D Scenes>>.
[#sceneenable] [#sceneenable]
==== sceneEnable ==== sceneEnable
@ -14213,6 +14280,30 @@ else
end end
---- ----
[#singeisrecording]
==== singeIsRecording
[source,text]
----
recording = singeIsRecording()
----
Whether a recording is running, or has been asked for and starts with the next frame drawn. See <<singerecordstart,singeRecordStart>>.
*Returns:* boolean.
*Since:* 3.00
*See also:* <<singerecordstart,singeRecordStart>>, <<singerecordstop,singeRecordStop>>
.Example
[source,lua]
----
-- A red dot in the corner of the game's own pause screen while it is being recorded.
if singeIsRecording() then
overlayCircle(overlayGetWidth() - 12, 12, 5)
end
----
[#singepack] [#singepack]
==== singePack ==== singePack
@ -14294,6 +14385,66 @@ local folder = singeGetSystemInfo().executable:match("^(.*[/\\])")
singeRelaunch(folder .. "Singe-v3.01-Linux-x86_64") singeRelaunch(folder .. "Singe-v3.01-Linux-x86_64")
---- ----
[#singerecordstart]
==== singeRecordStart
[source,text]
----
singeRecordStart([name])
----
Starts recording the game, picture and sound together, to an MP4 in the game's data directory (see `singeGetDataPath`), from the next frame drawn. Without a name, files are named `singe000.mp4` upward, as screenshots are; with one the recording goes to that file, `.mp4` added when the name has no extension, replacing any file of that name, and a name with a directory in it aborts the script. A call while a recording is running is ignored. The switch mapped to `INPUT_RECORD` in `controls.cfg`, `F10` by default, starts and stops the same recordings.
The picture is the whole window as it is shown, letterbox included, at the window's size, exactly what a screenshot takes; the sound is the finished mix, music, effects and video soundtracks together. Both are encoded on a thread of their own, so the game pays for copying each frame back from the GPU and little else. The picture is H.264 where the machine has a hardware encoder Singe can use -- VA-API on Linux, VideoToolbox on macOS, Media Foundation on Windows, V4L2 on the ARM boards that have one -- and MPEG-4 from FFmpeg's own encoder everywhere else; the sound is AAC. The trace names the encoder when the recording starts, and counts any frames dropped when it stops.
In play the recording runs at 60 frames a second on the real clock. A frame that comes while the encoder is still busy is dropped from the recording rather than making the game wait, and the sound is moved later by the audio device's queue, so the two match as they did on the screen. Under `--deterministic` the recording runs at one frame per clock step instead, and the engine waits for the encoder rather than dropping anything; started by `--record`, a deterministic run also mixes its sound into memory a step at a time, so the recording is exact frame by frame and sample by sample.
*Parameters:*
* `name` -- optional string; a file name, not a path.
*Returns:* nothing.
*Since:* 3.00
*See also:* <<singerecordstop,singeRecordStop>>, <<singeisrecording,singeIsRecording>>, <<singescreenshot,singeScreenshot>>, <<deterministic,Deterministic Test Mode>>
.Example
[source,lua]
----
-- The attract mode records its own trailer: one loop of it, then stop.
function attractBegin()
singeRecordStart("trailer")
end
function attractEnd()
singeRecordStop()
end
----
[#singerecordstop]
==== singeRecordStop
[source,text]
----
singeRecordStop()
----
Finishes the recording at the next frame drawn: what is still waiting is encoded and the file is closed, so it plays from start to end. Ignored when nothing is recording. A recording still running when the game ends is finished the same way.
*Returns:* nothing.
*Since:* 3.00
*See also:* <<singerecordstart,singeRecordStart>>, <<singeisrecording,singeIsRecording>>
.Example
[source,lua]
----
-- Record one round of the game: from the first coin to game over.
function onGameOver()
singeRecordStop()
end
----
[#singereload] [#singereload]
==== singeReload ==== singeReload
@ -15417,7 +15568,7 @@ end
[#sprite] [#sprite]
=== Sprite === Sprite
Sprites are 2D bitmaps loaded from disk with `spriteLoad`, or rendered from text with `fontToSprite`, and drawn onto the overlay in overlay coordinates. Every `sprite*` call takes the integer handle as its first argument (Singe 2.10 put it last; see <<migrating,Migrating from Singe 2.10>>), and a handle that has been unloaded ends the script with an error. Any format SDL_image reads is accepted; an animated GIF or WEBP with two or more frames loads as an animation that sits on frame `0`, stopped and non-looping, until `spritePlay` and `spriteLoop` say otherwise. Pixels whose raw value is `0` in the image's own pixel format are drawn transparent (palette index `0`, or black in an RGB image without alpha), and a real alpha channel is honored as well. Release every sprite with `spriteUnload`, usually from <<onshutdown,onShutdown>>. Sprites are 2D bitmaps loaded from disk with `spriteLoad`, or rendered from text with `fontToSprite`, and drawn onto the overlay in overlay coordinates. Every `sprite*` call takes the integer handle as its first argument (Singe 2.10 put it last; see <<migrating,Migrating from Singe 2.10>>), and a handle that has been unloaded ends the script with an error. Any format SDL_image reads is accepted; an animated GIF or WEBP with two or more frames loads as an animation that sits on frame `0`, stopped and non-looping, until `spritePlay` and `spriteLoop` say otherwise. Colour `0` is drawn transparent, as in Hypseus: palette index `0`, or pure black, and that includes opaque black in an image with an alpha channel, whose alpha is honored as well. To keep a picture's opaque black, load it after <<drawtransparent,`drawTransparent(true)`>>. Release every sprite with `spriteUnload`, usually from <<onshutdown,onShutdown>>.
*Sprite sheets.* Hypseus Singe extends the family with sprite sheets: `spriteLoadFrames` slices a single row strip of equal frames into an animation that is not playing, and `spriteDrawFrame` draws one chosen frame (1-based) without touching playback. A sheet is otherwise an ordinary animation, so `spriteSetFrame` (0-based), `spritePlay`, `spriteDraw`, `nodeSetSprite` and `emitterSetTexture` all accept it, and `spriteFrameWidth` and `spriteFrameHeight` report the unscaled size of one frame. The entries marked "Hypseus extension" exist so games written for Hypseus Singe run unchanged. *Sprite sheets.* Hypseus Singe extends the family with sprite sheets: `spriteLoadFrames` slices a single row strip of equal frames into an animation that is not playing, and `spriteDrawFrame` draws one chosen frame (1-based) without touching playback. A sheet is otherwise an ordinary animation, so `spriteSetFrame` (0-based), `spritePlay`, `spriteDraw`, `nodeSetSprite` and `emitterSetTexture` all accept it, and `spriteFrameWidth` and `spriteFrameHeight` report the unscaled size of one frame. The entries marked "Hypseus extension" exist so games written for Hypseus Singe run unchanged.
@ -15429,12 +15580,22 @@ Sprites are 2D bitmaps loaded from disk with `spriteLoad`, or rendered from text
drawTransparent(boolean) drawTransparent(boolean)
---- ----
Hypseus extension, accepted and ignored. It decides whether Hypseus sets colour key 0 on the sprites loaded after it, which is how that engine carries transparency. Singe carries transparency in the alpha channel, as <<spriteresetcolorkey,`spriteResetColorKey`>> already describes, so there is no key to turn on or off. Hypseus extension. Decides whether colour 0 is drawn transparent on the sprites loaded, and the text rendered, after it. `false`, the default, keys colour 0 as Hypseus does: palette index 0, or pure black, and that includes opaque black in a picture that has an alpha channel, so black lettering or a black outline disappears. `true` draws them by their alpha channel alone, so opaque black stays black. Sprites already loaded keep the setting they were loaded with, and every script starts at `false`.
*Returns:* nothing. *Returns:* nothing.
*Since:* 3.00 *Since:* 3.00
.Example
[source,lua]
----
-- The logo's lettering is opaque black: load it by its alpha alone, then go back
-- to the usual key for the game's own art.
drawTransparent(true)
logo = spriteLoad("art/logo.png")
drawTransparent(false)
----
[#spriteblend] [#spriteblend]
==== spriteBlend ==== spriteBlend
@ -15990,6 +16151,31 @@ spriteLoop(explosion, false)
spritePlay(torchFlame) spritePlay(torchFlame)
---- ----
[#spritenew]
==== spriteNew
[source,text]
----
id = spriteNew(width, height)
----
Makes a blank sprite, every pixel transparent, for <<spritesetpixels,`spriteSetPixels`>> to fill: a picture the script makes itself rather than loads. Each side is 1 to 4096 pixels; anything else ends the script. The sprite is a still and does everything any other still does: it draws, turns, scales, stretches, and tints, and it can be a material's texture.
*Parameters:*
* `width`, `height` -- numbers; its size in pixels.
*Returns:* number, the sprite handle.
*Since:* 3.00
*See also:* <<spritesetpixels,spriteSetPixels>>, <<spriteloaddata,spriteLoadData>>
.Example
[source,lua]
----
local minimap = spriteNew(64, 64)
----
[#spritepause] [#spritepause]
==== spritePause ==== spritePause
@ -16331,6 +16517,72 @@ function onOverlayUpdate()
end end
---- ----
[#spritesetpixels]
==== spriteSetPixels
[source,text]
----
spriteSetPixels(id, pixels)
spriteSetPixels(id, pixels, palette)
----
Replaces every pixel of a still sprite from a string, a row at a time from the top. Without a palette the string is four bytes a pixel, red, green, blue, and alpha. With one it is a byte a pixel, each the number of a colour in the palette, which is 256 colours of three bytes (red, green, and blue, all opaque) or four (with alpha). A string of the wrong length, a palette of the wrong length, or an animated sprite ends the script.
It is how a script draws a picture a pixel at a time, fast enough to do every frame: the plasmas, tunnels, fire, and water of the old demos, a minimap, or a heightmap being edited. Building the string is the script's work, so keep the picture small and stretch it when it is drawn: a Lua loop fills a 320 by 240 picture in a few milliseconds on a desktop. Palette mode is the old way to animate cheaply: set the same pixels again with the palette turned round one colour, and everything shifts colour with nothing recomputed. A sprite that is turned or scaled is turned and scaled again with its new pixels; a material that shows the sprite does not see the change until <<materialsettexture,`materialSetTexture`>> is called with the sprite again, which uploads it afresh.
A loaded picture can be given new pixels too; it is the size it loaded at.
*Parameters:*
* `id` -- number; a still sprite.
* `pixels` -- string; `width * height * 4` bytes, or `width * height` with a palette.
* `palette` -- optional string; 768 or 1024 bytes.
*Returns:* nothing.
*Since:* 3.00
*See also:* <<spritenew,spriteNew>>, <<materialsettexture,materialSetTexture>>
.Example
[source,lua]
----
-- Fire: each pixel the average of the ones under it, cooling as it rises, through a palette from
-- black through red and yellow to white. Stretched over the bottom of the screen every frame.
local W = 160
local H = 60
local fire = spriteNew(W, H)
local heat = {}
local colour = {}
for i = 0, 255 do
colour[#colour + 1] = string.char(math.min(255, i * 3), math.max(0, math.min(255, i * 3 - 255)), math.max(0, i * 3 - 510))
end
local palette = table.concat(colour)
for i = 0, W * (H + 1) - 1 do
heat[i] = 0
end
function onOverlayUpdate()
for x = 0, W - 1 do
heat[H * W + x] = math.random(0, 255)
end
local rows = {}
for y = 0, H - 1 do
local row = {}
for x = 0, W - 1 do
local below = (y + 1) * W
local v = (heat[below + (x - 1) % W] + heat[below + x] + heat[below + (x + 1) % W] + heat[y * W + x]) // 4
heat[y * W + x] = math.max(0, v - 1)
row[x + 1] = heat[y * W + x]
end
rows[y + 1] = string.char(table.unpack(row))
end
spriteSetPixels(fire, table.concat(rows), palette)
overlayClear()
spriteDraw(fire, 0, 240, 639, 479)
return OVERLAY_UPDATED
end
----
[#spritesettint] [#spritesettint]
==== spriteSetTint ==== spriteSetTint

View file

@ -143,6 +143,7 @@ typedef enum LongOptionE {
OPT_NO_UPDATE_CHECK, OPT_NO_UPDATE_CHECK,
OPT_PERFSYNC, OPT_PERFSYNC,
OPT_QUITCHORD, OPT_QUITCHORD,
OPT_RECORD,
OPT_REFRESH, OPT_REFRESH,
OPT_SCENEQUALITY, OPT_SCENEQUALITY,
OPT_SCREEN, OPT_SCREEN,
@ -235,6 +236,7 @@ static const OptionT _options[] = {
{ OPT_NO_UPDATE_CHECK, "no_update_check", ap_no, NULL, "never look for a newer Singe from the menu", false, true }, { OPT_NO_UPDATE_CHECK, "no_update_check", ap_no, NULL, "never look for a newer Singe from the menu", false, true },
{ OPT_PERFSYNC, "perfsync", ap_no, NULL, "with -p, wait for the GPU after each stage of a frame, so the perf line says where the GPU's time goes; slows everything", false, true }, { OPT_PERFSYNC, "perfsync", ap_no, NULL, "with -p, wait for the GPU after each stage of a frame, so the perf line says where the GPU's time goes; slows everything", false, true },
{ OPT_QUITCHORD, "quitchord", ap_yes, "BUTTONS", "first gamepad buttons held together to quit whatever a game maps, such as back+start", false, true }, { OPT_QUITCHORD, "quitchord", ap_yes, "BUTTONS", "first gamepad buttons held together to quit whatever a game maps, such as back+start", false, true },
{ OPT_RECORD, "record", ap_yes, "FILENAME", "write the picture and the sound to an MP4 from start to finish; with --deterministic every frame is kept and the sound is exact", false, false },
{ OPT_REFRESH, "refresh", ap_no, NULL, "write a fresh Singe folder, keeping the old one as old-Singe-vX.YY", false, false }, { OPT_REFRESH, "refresh", ap_no, NULL, "write a fresh Singe folder, keeping the old one as old-Singe-vX.YY", false, false },
{ OPT_SCENEQUALITY, "scenequality", ap_yes, "LEVEL", "3D detail: high (the default), or low for a small GPU: half resolution, no antialiasing, no bloom", false, true }, { OPT_SCENEQUALITY, "scenequality", ap_yes, "LEVEL", "3D detail: high (the default), or low for a small GPU: half resolution, no antialiasing, no bloom", false, true },
{ OPT_SCREEN, "screen", ap_yes, "N", "open the window on display N, counting from 1", false, true }, { OPT_SCREEN, "screen", ap_yes, "N", "open the window on display N, counting from 1", false, true },
@ -797,6 +799,13 @@ static void _applyOptions(const char *exeName, ConfigT *conf, int32_t argc, cons
conf->quitChord = strdup(arg); conf->quitChord = strdup(arg);
break; break;
// Record The Run
case OPT_RECORD:
free(conf->recordFile);
conf->recordFile = strdup(arg);
utilFixPathSeparators(&conf->recordFile, false);
break;
case OPT_REFRESH: case OPT_REFRESH:
conf->refreshSupport = true; conf->refreshSupport = true;
break; break;
@ -1384,7 +1393,13 @@ static void _launcher(const char *exeName, ConfigT *conf) {
spec.format = SDL_AUDIO_S16; spec.format = SDL_AUDIO_S16;
spec.channels = MIXER_CHANNELS; spec.channels = MIXER_CHANNELS;
spec.freq = MIXER_FREQUENCY; spec.freq = MIXER_FREQUENCY;
// A deterministic run that is recorded mixes into memory, a step of sound for each step of the
// clock, so the recording's sound is exactly what went with each frame. Nothing is heard.
if ((conf->recordFile != NULL) && conf->deterministic) {
mixer = MIX_CreateMixer(&spec);
} else {
mixer = MIX_CreateMixerDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &spec); mixer = MIX_CreateMixerDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &spec);
}
if (mixer == NULL) { if (mixer == NULL) {
utilDie("%s", SDL_GetError()); utilDie("%s", SDL_GetError());
} }
@ -1397,7 +1412,7 @@ static void _launcher(const char *exeName, ConfigT *conf) {
// Start our video playback system // Start our video playback system
_mainTrace(conf, "Initializing laserdisc video"); _mainTrace(conf, "Initializing laserdisc video");
videoInit(mixer); videoInit(mixer, (conf->recordFile != NULL) && conf->deterministic);
// Finish our setup // Finish our setup
_mainTrace(conf, "Disabling screen saver"); _mainTrace(conf, "Disabling screen saver");
@ -2260,6 +2275,7 @@ ConfigT *cloneConf(const ConfigT *conf) {
c->bezelDir = _cloneString(conf->bezelDir); c->bezelDir = _cloneString(conf->bezelDir);
c->keymapFile = _cloneString(conf->keymapFile); c->keymapFile = _cloneString(conf->keymapFile);
c->soundfont = _cloneString(conf->soundfont); c->soundfont = _cloneString(conf->soundfont);
c->recordFile = _cloneString(conf->recordFile);
c->deinterlace = conf->deinterlace; c->deinterlace = conf->deinterlace;
c->audioSuffix = _cloneString(conf->audioSuffix); c->audioSuffix = _cloneString(conf->audioSuffix);
c->gamepadOrder = _cloneString(conf->gamepadOrder); c->gamepadOrder = _cloneString(conf->gamepadOrder);
@ -2358,6 +2374,7 @@ void destroyConf(ConfigT **confPointer) {
free(conf->bezelDir); free(conf->bezelDir);
free(conf->keymapFile); free(conf->keymapFile);
free(conf->soundfont); free(conf->soundfont);
free(conf->recordFile);
free(conf->audioSuffix); free(conf->audioSuffix);
free(conf->gamepadOrder); free(conf->gamepadOrder);
free(conf->quitChord); free(conf->quitChord);

View file

@ -2887,9 +2887,24 @@ void physicsSetEnabled(bool enabled) {
} }
// A body asleep under the old gravity would stay asleep under the new one, hanging where it rested,
// so a change wakes every body: a crate on a shelf falls when gravity turns sideways, and a cloth
// blows when the wind made of gravity changes. The same gravity again wakes nothing.
void physicsSetGravity(Vec3T gravity) { void physicsSetGravity(Vec3T gravity) {
if (_world != nullptr) { JPH::BodyIDVector ids;
_world->system->SetGravity(_fromVec3(gravity)); JPH::Vec3 next;
if (_world == nullptr) {
return;
}
next = _fromVec3(gravity);
if (next == _world->system->GetGravity()) {
return;
}
_world->system->SetGravity(next);
_world->system->GetBodies(ids);
if (!ids.empty()) {
_world->system->GetBodyInterface().ActivateBodies(ids.data(), (int)ids.size());
} }
} }

646
src/record.c Normal file
View file

@ -0,0 +1,646 @@
/*
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*/
// Singe: recording. The engine hands each finished frame to recordFrame, which copies it into a
// free slot of a small queue; the mixer hands each finished buffer of sound to _recordMixed on its
// own thread, which adds it to a ring. A thread of the recorder's own takes both, converts and
// encodes them, and writes the MP4. Times are the engine's own: on the real clock a frame's time
// says which output frame it is, and on a deterministic clock each step is one frame exactly, so a
// recording made with --deterministic is frame for frame what the engine drew, and its sound,
// generated a step at a time, is exactly the sound that went with it.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <libavutil/hwcontext.h>
#include <libavutil/opt.h>
#include <libswscale/swscale.h>
#include "record.h"
#include "util.h"
#define RECORD_SLOTS 8 // Frames waiting to be encoded
#define RECORD_RING_SECONDS 10 // Sound waiting to be encoded
#define RECORD_AUDIO_BITRATE 192000
#define RECORD_BITS_DIVISOR 4 // H.264 bit rate: width * height * fps / this
#define RECORD_MPEG4_QUALITY 3 // MPEG-4 quantiser: 2 is near lossless, 31 is mush
#define RECORD_GOP_SECONDS 2 // A keyframe at least this often
#define RECORD_HW_POOL 20 // Hardware frames VAAPI keeps for the encoder
// The hardware encoders, tried in order; a build has at most one or two of them. Whatever does not
// open falls through to MPEG-4, which every build has.
static const char *_hardwareEncoders[] = { "h264_vaapi", "h264_videotoolbox", "h264_mf", "h264_v4l2m2m" };
typedef struct RecordSlotS {
uint8_t *pixels;
int32_t width;
int32_t height;
int32_t pitch;
enum AVPixelFormat format;
int64_t pts;
} RecordSlotT;
typedef struct RecordS {
char *path;
int32_t width;
int32_t height;
bool live; // On the real clock: frames are dropped rather than waited for
uint64_t startMs;
AVRational frameRate;
int64_t lastPts;
int64_t dropped;
int64_t frames;
// The queue of frames, and the ring of sound, both under lock.
SDL_Mutex *lock;
SDL_Condition *wake; // Something to encode, or stopping
SDL_Condition *room; // A slot came free
RecordSlotT slots[RECORD_SLOTS];
int32_t head;
int32_t count;
float *ring;
int32_t ringSize; // Floats
int32_t ringRead;
int32_t ringFill;
int64_t audioLost;
bool stopping;
SDL_Thread *thread;
MIX_Mixer *mixer;
int32_t channels;
int32_t rate;
int32_t silence; // Frames of silence still to put first, for the latency
// The writing side, owned by the thread.
AVFormatContext *format;
AVCodecContext *video;
AVCodecContext *audio;
AVStream *videoStream;
AVStream *audioStream;
AVBufferRef *hwDevice;
AVBufferRef *hwFrames;
AVFrame *picture; // In the encoder's format
AVFrame *upload; // On the GPU, for VAAPI
AVFrame *sound;
AVPacket *packet;
struct SwsContext *scaler;
int64_t audioPts;
const char *encoderName;
} RecordT;
static RecordT *_record = NULL;
static enum AVPixelFormat _avFormat(SDL_PixelFormat format);
static void _closeEncoders(RecordT *r);
static bool _drain(RecordT *r, AVCodecContext *codec, AVStream *stream);
static bool _encodeAudio(RecordT *r, const float *interleaved, int32_t frames);
static bool _encodeVideo(RecordT *r, RecordSlotT *slot);
static bool _openAudio(RecordT *r);
static bool _openVideo(RecordT *r, const char *name);
static void _recordMixed(void *userdata, MIX_Mixer *mixer, const SDL_AudioSpec *spec, float *pcm, int32_t samples);
static void _ringTake(RecordT *r, float *out, int32_t floats);
static int32_t _writer(void *data);
// SDL's packed 32-bit formats name the bytes from the most significant; FFmpeg's name them in
// memory order, which on a little-endian machine is the other way round.
static enum AVPixelFormat _avFormat(SDL_PixelFormat format) {
switch (format) {
case SDL_PIXELFORMAT_ARGB8888:
return (SDL_BYTEORDER == SDL_LIL_ENDIAN) ? AV_PIX_FMT_BGRA : AV_PIX_FMT_ARGB;
case SDL_PIXELFORMAT_XRGB8888:
return (SDL_BYTEORDER == SDL_LIL_ENDIAN) ? AV_PIX_FMT_BGR0 : AV_PIX_FMT_0RGB;
case SDL_PIXELFORMAT_ABGR8888:
return (SDL_BYTEORDER == SDL_LIL_ENDIAN) ? AV_PIX_FMT_RGBA : AV_PIX_FMT_ABGR;
case SDL_PIXELFORMAT_XBGR8888:
return (SDL_BYTEORDER == SDL_LIL_ENDIAN) ? AV_PIX_FMT_RGB0 : AV_PIX_FMT_0BGR;
default:
return AV_PIX_FMT_NONE;
}
}
static void _closeEncoders(RecordT *r) {
avcodec_free_context(&r->video);
avcodec_free_context(&r->audio);
av_frame_free(&r->picture);
av_frame_free(&r->upload);
av_frame_free(&r->sound);
av_packet_free(&r->packet);
av_buffer_unref(&r->hwFrames);
av_buffer_unref(&r->hwDevice);
sws_freeContext(r->scaler);
r->scaler = NULL;
if (r->format != NULL) {
if (r->format->pb != NULL) {
avio_closep(&r->format->pb);
}
avformat_free_context(r->format);
r->format = NULL;
}
}
// Takes every packet the encoder has ready and writes it. False on a write error.
static bool _drain(RecordT *r, AVCodecContext *codec, AVStream *stream) {
int32_t result = 0;
while ((result = avcodec_receive_packet(codec, r->packet)) == 0) {
av_packet_rescale_ts(r->packet, codec->time_base, stream->time_base);
r->packet->stream_index = stream->index;
if (av_interleaved_write_frame(r->format, r->packet) < 0) {
return false;
}
}
return (result == AVERROR(EAGAIN)) || (result == AVERROR_EOF);
}
// Encodes frames of interleaved float sound, a whole encoder frame at a time; NULL flushes.
static bool _encodeAudio(RecordT *r, const float *interleaved, int32_t frames) {
int32_t c = 0;
int32_t i = 0;
if (interleaved == NULL) {
avcodec_send_frame(r->audio, NULL);
return _drain(r, r->audio, r->audioStream);
}
if (av_frame_make_writable(r->sound) < 0) {
return false;
}
for (c = 0; c < r->channels; c++) {
float *plane = (float *)r->sound->data[c];
for (i = 0; i < frames; i++) {
plane[i] = interleaved[i * r->channels + c];
}
for (; i < r->audio->frame_size; i++) {
plane[i] = 0.0f;
}
}
r->sound->pts = r->audioPts;
r->audioPts += r->audio->frame_size;
if (avcodec_send_frame(r->audio, r->sound) < 0) {
return false;
}
return _drain(r, r->audio, r->audioStream);
}
// Converts one frame to the encoder's format and size, and encodes it; NULL flushes.
static bool _encodeVideo(RecordT *r, RecordSlotT *slot) {
const uint8_t *source[4] = { NULL, NULL, NULL, NULL };
int32_t pitch[4] = { 0, 0, 0, 0 };
AVFrame *send = r->picture;
if (slot == NULL) {
avcodec_send_frame(r->video, NULL);
return _drain(r, r->video, r->videoStream);
}
r->scaler = sws_getCachedContext(r->scaler, slot->width, slot->height, slot->format, r->width, r->height, r->picture->format, SWS_BILINEAR, NULL, NULL, NULL);
if ((r->scaler == NULL) || (av_frame_make_writable(r->picture) < 0)) {
return false;
}
source[0] = slot->pixels;
pitch[0] = slot->pitch;
sws_scale(r->scaler, source, pitch, 0, slot->height, r->picture->data, r->picture->linesize);
if (r->hwFrames != NULL) {
av_frame_unref(r->upload);
if ((av_hwframe_get_buffer(r->hwFrames, r->upload, 0) < 0) || (av_hwframe_transfer_data(r->upload, r->picture, 0) < 0)) {
return false;
}
send = r->upload;
}
send->pts = slot->pts;
if (avcodec_send_frame(r->video, send) < 0) {
return false;
}
return _drain(r, r->video, r->videoStream);
}
static bool _openAudio(RecordT *r) {
const AVCodec *codec = avcodec_find_encoder(AV_CODEC_ID_AAC);
if (codec == NULL) {
return false;
}
r->audio = avcodec_alloc_context3(codec);
r->audio->sample_fmt = AV_SAMPLE_FMT_FLTP;
r->audio->sample_rate = r->rate;
r->audio->bit_rate = RECORD_AUDIO_BITRATE;
r->audio->time_base = (AVRational){ 1, r->rate };
av_channel_layout_default(&r->audio->ch_layout, r->channels);
if (r->format->oformat->flags & AVFMT_GLOBALHEADER) {
r->audio->flags |= AV_CODEC_FLAG_GLOBAL_HEADER;
}
if (avcodec_open2(r->audio, codec, NULL) < 0) {
return false;
}
r->audioStream = avformat_new_stream(r->format, NULL);
r->audioStream->time_base = r->audio->time_base;
avcodec_parameters_from_context(r->audioStream->codecpar, r->audio);
r->sound = av_frame_alloc();
r->sound->format = AV_SAMPLE_FMT_FLTP;
r->sound->nb_samples = r->audio->frame_size;
av_channel_layout_copy(&r->sound->ch_layout, &r->audio->ch_layout);
return av_frame_get_buffer(r->sound, 0) == 0;
}
// Opens the named video encoder at the recording's size and rate. VAAPI takes frames on the GPU,
// uploaded from NV12; the others take NV12 or planar 4:2:0 from memory.
static bool _openVideo(RecordT *r, const char *name) {
const AVCodec *codec = avcodec_find_encoder_by_name(name);
const enum AVPixelFormat *formats = NULL;
int32_t count = 0;
int32_t i = 0;
enum AVPixelFormat memory = AV_PIX_FMT_YUV420P;
bool vaapi = (strcmp(name, "h264_vaapi") == 0);
double fps = av_q2d(r->frameRate);
AVHWFramesContext *frames = NULL;
if (codec == NULL) {
return false;
}
r->video = avcodec_alloc_context3(codec);
r->video->width = r->width;
r->video->height = r->height;
r->video->time_base = av_inv_q(r->frameRate);
r->video->framerate = r->frameRate;
r->video->gop_size = (int32_t)(fps * RECORD_GOP_SECONDS);
r->video->max_b_frames = 0;
if (strcmp(name, "mpeg4") == 0) {
r->video->flags |= AV_CODEC_FLAG_QSCALE;
r->video->global_quality = FF_QP2LAMBDA * RECORD_MPEG4_QUALITY;
} else {
r->video->bit_rate = (int64_t)((double)r->width * r->height * fps / RECORD_BITS_DIVISOR);
}
if (r->format->oformat->flags & AVFMT_GLOBALHEADER) {
r->video->flags |= AV_CODEC_FLAG_GLOBAL_HEADER;
}
if (vaapi) {
memory = AV_PIX_FMT_NV12;
if (av_hwdevice_ctx_create(&r->hwDevice, AV_HWDEVICE_TYPE_VAAPI, NULL, NULL, 0) < 0) {
return false;
}
r->hwFrames = av_hwframe_ctx_alloc(r->hwDevice);
frames = (AVHWFramesContext *)r->hwFrames->data;
frames->format = AV_PIX_FMT_VAAPI;
frames->sw_format = AV_PIX_FMT_NV12;
frames->width = r->width;
frames->height = r->height;
frames->initial_pool_size = RECORD_HW_POOL;
if (av_hwframe_ctx_init(r->hwFrames) < 0) {
return false;
}
r->video->pix_fmt = AV_PIX_FMT_VAAPI;
r->video->hw_frames_ctx = av_buffer_ref(r->hwFrames);
} else {
// NV12 where the encoder takes it, as the hardware ones prefer; 4:2:0 planar otherwise.
if ((avcodec_get_supported_config(r->video, codec, AV_CODEC_CONFIG_PIX_FORMAT, 0, (const void **)&formats, &count) >= 0) && (formats != NULL)) {
memory = formats[0];
for (i = 0; i < count; i++) {
if (formats[i] == AV_PIX_FMT_NV12) {
memory = AV_PIX_FMT_NV12;
}
}
}
r->video->pix_fmt = memory;
}
if (avcodec_open2(r->video, codec, NULL) < 0) {
return false;
}
r->videoStream = avformat_new_stream(r->format, NULL);
r->videoStream->time_base = r->video->time_base;
avcodec_parameters_from_context(r->videoStream->codecpar, r->video);
r->picture = av_frame_alloc();
r->picture->format = memory;
r->picture->width = r->width;
r->picture->height = r->height;
if (av_frame_get_buffer(r->picture, 0) < 0) {
return false;
}
r->upload = av_frame_alloc();
r->encoderName = name;
return true;
}
// On the mixer's thread: each finished buffer of the mix, as interleaved floats, into the ring. A
// ring the encoder has fallen a whole RECORD_RING_SECONDS behind on loses what does not fit.
static void _recordMixed(void *userdata, MIX_Mixer *mixer, const SDL_AudioSpec *spec, float *pcm, int32_t samples) {
RecordT *r = (RecordT *)userdata;
int32_t room = 0;
int32_t write = 0;
int32_t i = 0;
(void)mixer;
(void)spec;
SDL_LockMutex(r->lock);
room = r->ringSize - r->ringFill;
if (samples > room) {
r->audioLost += samples - room;
samples = room;
}
write = (r->ringRead + r->ringFill) % r->ringSize;
for (i = 0; i < samples; i++) {
r->ring[(write + i) % r->ringSize] = pcm[i];
}
r->ringFill += samples;
SDL_SignalCondition(r->wake);
SDL_UnlockMutex(r->lock);
}
// Under the lock: floats out of the ring, oldest first.
static void _ringTake(RecordT *r, float *out, int32_t floats) {
int32_t i = 0;
for (i = 0; i < floats; i++) {
out[i] = r->ring[(r->ringRead + i) % r->ringSize];
}
r->ringRead = (r->ringRead + floats) % r->ringSize;
r->ringFill -= floats;
}
// The recorder's thread: frames and sound in turn as they arrive, then, when stopping, what is
// left, the encoders flushed, and the file finished.
static int32_t _writer(void *data) {
RecordT *r = (RecordT *)data;
int32_t chunk = r->audio->frame_size;
float *sound = (float *)calloc((size_t)chunk * r->channels, sizeof(float));
RecordSlotT slot;
bool haveSlot = false;
bool haveSound = false;
bool silent = false;
bool ok = true;
bool done = false;
while (!done) {
SDL_LockMutex(r->lock);
while ((r->count == 0) && (r->ringFill < chunk * r->channels) && (r->silence <= 0) && !r->stopping) {
SDL_WaitCondition(r->wake, r->lock);
}
silent = (r->silence > 0);
haveSound = !silent && (r->ringFill >= chunk * r->channels);
haveSlot = (r->count > 0);
if (haveSound) {
_ringTake(r, sound, chunk * r->channels);
}
if (haveSlot) {
slot = r->slots[r->head];
}
done = r->stopping && !silent && !haveSound && !haveSlot;
SDL_UnlockMutex(r->lock);
if (silent) {
memset(sound, 0, sizeof(float) * (size_t)chunk * r->channels);
ok = _encodeAudio(r, sound, chunk) && ok;
r->silence -= chunk;
}
if (haveSound) {
ok = _encodeAudio(r, sound, chunk) && ok;
}
if (haveSlot) {
ok = _encodeVideo(r, &slot) && ok;
SDL_LockMutex(r->lock);
r->head = (r->head + 1) % RECORD_SLOTS;
r->count--;
SDL_SignalCondition(r->room);
SDL_UnlockMutex(r->lock);
}
}
free(sound);
return ok ? 0 : 1;
}
void recordFrame(const SDL_Surface *surface, uint64_t milliseconds) {
RecordT *r = _record;
RecordSlotT *slot = NULL;
int64_t pts = 0;
int32_t row = 0;
enum AVPixelFormat format = AV_PIX_FMT_NONE;
if (r == NULL) {
return;
}
format = _avFormat(surface->format);
if (format == AV_PIX_FMT_NONE) {
return;
}
pts = av_rescale_q((int64_t)(milliseconds - r->startMs), (AVRational){ 1, 1000 }, av_inv_q(r->frameRate));
if (pts <= r->lastPts) {
return;
}
SDL_LockMutex(r->lock);
if (r->count == RECORD_SLOTS) {
if (r->live) {
r->dropped++;
SDL_UnlockMutex(r->lock);
return;
}
while (r->count == RECORD_SLOTS) {
SDL_WaitCondition(r->room, r->lock);
}
}
slot = &r->slots[(r->head + r->count) % RECORD_SLOTS];
SDL_UnlockMutex(r->lock);
// The slot is the main thread's until it is counted in.
if ((slot->pixels == NULL) || (slot->width != surface->w) || (slot->height != surface->h)) {
free(slot->pixels);
slot->pixels = (uint8_t *)malloc((size_t)surface->w * surface->h * 4);
}
for (row = 0; row < surface->h; row++) {
memcpy(slot->pixels + (size_t)row * surface->w * 4, (const uint8_t *)surface->pixels + (size_t)row * surface->pitch, (size_t)surface->w * 4);
}
slot->width = surface->w;
slot->height = surface->h;
slot->pitch = surface->w * 4;
slot->format = format;
slot->pts = pts;
r->lastPts = pts;
r->frames++;
SDL_LockMutex(r->lock);
r->count++;
SDL_SignalCondition(r->wake);
SDL_UnlockMutex(r->lock);
}
bool recordIsRecording(void) {
return _record != NULL;
}
const char *recordPath(void) {
return (_record != NULL) ? _record->path : NULL;
}
bool recordStart(const char *path, int32_t width, int32_t height, int32_t frameMs, MIX_Mixer *mixer, int32_t latencyMs, uint64_t milliseconds) {
RecordT *r = NULL;
SDL_AudioSpec spec;
size_t i = 0;
bool video = false;
if (_record != NULL) {
SDL_SetError("Already recording to %s.", _record->path);
return false;
}
if (!MIX_GetMixerFormat(mixer, &spec)) {
return false;
}
r = (RecordT *)calloc(1, sizeof(RecordT));
r->path = strdup(path);
r->width = width & ~1;
r->height = height & ~1;
r->live = (frameMs <= 0);
r->frameRate = r->live ? (AVRational){ RECORD_LIVE_FPS, 1 } : av_make_q(1000, frameMs);
r->startMs = milliseconds;
r->lastPts = -1;
r->mixer = mixer;
r->channels = spec.channels;
r->rate = spec.freq;
r->silence = (int32_t)((int64_t)SDL_max(latencyMs, 0) * spec.freq / 1000);
r->ringSize = spec.freq * spec.channels * RECORD_RING_SECONDS;
r->ring = (float *)calloc((size_t)r->ringSize, sizeof(float));
r->lock = SDL_CreateMutex();
r->wake = SDL_CreateCondition();
r->room = SDL_CreateCondition();
r->packet = av_packet_alloc();
if ((avformat_alloc_output_context2(&r->format, NULL, "mp4", path) < 0) || (r->format == NULL)) {
SDL_SetError("This build cannot write MP4.");
goto fail;
}
for (i = 0; (i < SDL_arraysize(_hardwareEncoders)) && !video; i++) {
video = _openVideo(r, _hardwareEncoders[i]);
if (!video) {
avcodec_free_context(&r->video);
av_frame_free(&r->picture);
av_frame_free(&r->upload);
av_buffer_unref(&r->hwFrames);
av_buffer_unref(&r->hwDevice);
if (r->videoStream != NULL) {
// A stream added for an encoder that then failed cannot be taken back out; start
// the container again.
avformat_free_context(r->format);
r->format = NULL;
r->videoStream = NULL;
avformat_alloc_output_context2(&r->format, NULL, "mp4", path);
}
}
}
if (!video && !_openVideo(r, "mpeg4")) {
SDL_SetError("No video encoder would open at %dx%d.", r->width, r->height);
goto fail;
}
if (!_openAudio(r)) {
SDL_SetError("The AAC encoder would not open.");
goto fail;
}
if ((avio_open(&r->format->pb, path, AVIO_FLAG_WRITE) < 0) || (avformat_write_header(r->format, NULL) < 0)) {
SDL_SetError("Unable to write %s.", path);
goto fail;
}
r->thread = SDL_CreateThread(_writer, "record", r);
_record = r;
MIX_SetPostMixCallback(mixer, _recordMixed, r);
utilTrace("Recording %s: %dx%d, %s at %.2f frames a second, %d Hz sound%s", path, r->width, r->height, r->encoderName, av_q2d(r->frameRate), r->rate, r->live ? "" : ", frame exact");
return true;
fail:
_closeEncoders(r);
SDL_DestroyCondition(r->room);
SDL_DestroyCondition(r->wake);
SDL_DestroyMutex(r->lock);
free(r->ring);
free(r->path);
free(r);
return false;
}
void recordStop(void) {
RecordT *r = _record;
int32_t result = 0;
int32_t i = 0;
if (r == NULL) {
return;
}
MIX_SetPostMixCallback(r->mixer, NULL, NULL);
_record = NULL;
SDL_LockMutex(r->lock);
r->stopping = true;
SDL_SignalCondition(r->wake);
SDL_UnlockMutex(r->lock);
SDL_WaitThread(r->thread, &result);
// What is left of the sound, short of a whole encoder frame, padded out with silence.
if (r->ringFill > 0) {
float *last = (float *)calloc((size_t)r->ringFill, sizeof(float));
int32_t frames = r->ringFill / r->channels;
_ringTake(r, last, r->ringFill);
_encodeAudio(r, last, frames);
free(last);
}
_encodeVideo(r, NULL);
_encodeAudio(r, NULL, 0);
av_write_trailer(r->format);
utilTrace("Recorded %s: %" PRId64 " frames, %" PRId64 " dropped, %" PRId64 " samples of sound lost%s", r->path, r->frames, r->dropped, r->audioLost, (result == 0) ? "" : ", with an encoding error");
_closeEncoders(r);
for (i = 0; i < RECORD_SLOTS; i++) {
free(r->slots[i].pixels);
}
SDL_DestroyCondition(r->room);
SDL_DestroyCondition(r->wake);
SDL_DestroyMutex(r->lock);
free(r->ring);
free(r->path);
free(r);
}
const char *recordVideoEncoder(void) {
return (_record != NULL) ? _record->encoderName : NULL;
}

68
src/record.h Normal file
View file

@ -0,0 +1,68 @@
/*
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*/
#ifndef RECORD_H
#define RECORD_H
#include <stdbool.h>
#include <stdint.h>
#include <SDL3/SDL.h>
#include <SDL3_mixer/SDL_mixer.h>
// Recording: the finished picture and the finished mix written to an MP4. The picture is H.264
// from the platform's own hardware encoder where there is one, and MPEG-4 otherwise; the sound is
// AAC. Frames are handed over on the engine's thread and the sound on the mixer's; both are
// encoded and written on a thread of the recorder's own, so recording costs the game the copy of
// each frame and little else.
#define RECORD_LIVE_FPS 60 // A recording on the real clock is this many frames a second
// Hands over one finished frame: its pixels, the time it was shown on the engine's clock, in
// milliseconds. A frame whose time falls in the same output frame as the last one is skipped. On
// the real clock a frame is dropped rather than waited for when the encoder is behind; on a
// deterministic clock it is never dropped, and the engine waits instead.
void recordFrame(const SDL_Surface *surface, uint64_t milliseconds);
bool recordIsRecording(void);
// The file being written, or NULL.
const char *recordPath(void);
// Starts writing to path: width by height (each made even), frames frameMs apart (a deterministic
// step) or 0 for RECORD_LIVE_FPS on the real clock, the sound from mixer, which is latencyMs ahead
// of the picture. The time now on the engine's clock is the start of the recording. False, with
// the reason in SDL_GetError, when it cannot start.
bool recordStart(const char *path, int32_t width, int32_t height, int32_t frameMs, MIX_Mixer *mixer, int32_t latencyMs, uint64_t milliseconds);
// Finishes the file. Safe when nothing is recording.
void recordStop(void);
// The encoder in use, for the trace: "h264_vaapi", "mpeg4", and so on, or NULL.
const char *recordVideoEncoder(void);
#endif // RECORD_H

View file

@ -283,6 +283,8 @@ typedef struct NodeS {
bool hasLight; bool hasLight;
bool visible; bool visible;
bool worldVisible; // Own flag and every ancestor's bool worldVisible; // Own flag and every ancestor's
SceneLayerE layer; // nodeSetLayer
bool worldFront; // In the front layer: its own or an ancestor's
bool shadowCaster; // Drawn into shadow maps (nodeSetShadow) bool shadowCaster; // Drawn into shadow maps (nodeSetShadow)
BillboardE billboard; // Turned to face each camera by the vertex shader BillboardE billboard; // Turned to face each camera by the vertex shader
int32_t spriteSlot; // Into the sprite records when the node shows a sprite or text, else NO_HANDLE int32_t spriteSlot; // Into the sprite records when the node shows a sprite or text, else NO_HANDLE
@ -378,7 +380,8 @@ typedef struct CameraFrameS {
SDL_GPUTexture *depth; SDL_GPUTexture *depth;
SDL_GPUTexture *softDepth; // Or NULL: no soft particles here SDL_GPUTexture *softDepth; // Or NULL: no soft particles here
SDL_GPUTexture *output; // Display texture the post pass writes SDL_GPUTexture *output; // Display texture the post pass writes
bool main; // The window's camera: stats, bloom bool main; // The window's camera: bloom, and the cascades were fitted to it
SceneLayerE layer; // Which nodes it draws: the front layer's frame has no sky, particles, or lines
} CameraFrameT; } CameraFrameT;
// A camera rendered to a texture materials can show (viewNew). // A camera rendered to a texture materials can show (viewNew).
@ -451,6 +454,10 @@ typedef struct SceneS {
SDL_GPUSampleCount sampleCount; SDL_GPUSampleCount sampleCount;
bool antialias; // Wanted; sampleCount says what the device gave bool antialias; // Wanted; sampleCount says what the device gave
SDL_Texture *composite; // The colour target as the 2D renderer sees it SDL_Texture *composite; // The colour target as the 2D renderer sees it
SDL_GPUTexture *frontColour; // The front layer's HDR target, made the first time a node is in it ...
SDL_GPUTexture *frontOutput; // ... what its post pass writes ...
SDL_Texture *frontComposite; // ... as the 2D renderer sees it ...
bool frontDrawn; // ... and whether this frame drew it
SDL_GPUShader *vertexStatic; SDL_GPUShader *vertexStatic;
SDL_GPUShader *vertexSkinned; SDL_GPUShader *vertexSkinned;
SDL_GPUShader *fragment; SDL_GPUShader *fragment;
@ -605,6 +612,7 @@ static int32_t _compareOpaque(const void *a, const void *b);
static void _computeSh(const float *rgb, int32_t width, int32_t height); static void _computeSh(const float *rgb, int32_t width, int32_t height);
static bool _createBloomPipelines(void); static bool _createBloomPipelines(void);
static bool _createBloomTargets(int32_t width, int32_t height); static bool _createBloomTargets(int32_t width, int32_t height);
static bool _createFrontTargets(void);
static bool _createLinePipeline(int32_t sampleSet); static bool _createLinePipeline(int32_t sampleSet);
static bool _createParticlePipeline(int32_t sampleSet, int32_t blend); static bool _createParticlePipeline(int32_t sampleSet, int32_t blend);
static bool _createPipeline(int32_t sampleSet, int32_t variant); static bool _createPipeline(int32_t sampleSet, int32_t variant);
@ -685,7 +693,7 @@ static bool _stageCopy(StagingT *staging);
static void _stageEnd(StagingT *staging, SDL_GPUTexture *mipmaps); static void _stageEnd(StagingT *staging, SDL_GPUTexture *mipmaps);
static void _stageTexture(const StagingT *staging, uint32_t offset, SDL_GPUTexture *texture, uint32_t level, uint32_t layer, uint32_t width, uint32_t height); static void _stageTexture(const StagingT *staging, uint32_t offset, SDL_GPUTexture *texture, uint32_t level, uint32_t layer, uint32_t width, uint32_t height);
static void _stampMesh(MeshT *mesh); static void _stampMesh(MeshT *mesh);
static void _updateWorld(int32_t node, const Mat4T *parentWorld, bool parentVisible); static void _updateWorld(int32_t node, const Mat4T *parentWorld, bool parentVisible, bool parentFront);
static SDL_GPUBuffer *_uploadBuffer(SDL_GPUBufferUsageFlags usage, const void *data, uint32_t size); static SDL_GPUBuffer *_uploadBuffer(SDL_GPUBufferUsageFlags usage, const void *data, uint32_t size);
static SDL_GPUTexture *_uploadCompressed(const Ktx2ImageT *image, bool srgb); static SDL_GPUTexture *_uploadCompressed(const Ktx2ImageT *image, bool srgb);
static SDL_GPUTexture *_uploadCube(const uint16_t *pixels, int32_t face); static SDL_GPUTexture *_uploadCube(const uint16_t *pixels, int32_t face);
@ -868,6 +876,7 @@ static int32_t _allocNode(void) {
node->mesh = NO_HANDLE; node->mesh = NO_HANDLE;
node->material = NO_HANDLE; node->material = NO_HANDLE;
node->visible = true; node->visible = true;
node->layer = LAYER_SCENE;
node->shadowCaster = true; node->shadowCaster = true;
node->used = true; node->used = true;
return x; return x;
@ -947,6 +956,7 @@ static void _cameraFrame(int32_t camera, int32_t width, int32_t height, CameraFr
frame->width = width; frame->width = width;
frame->height = height; frame->height = height;
frame->sampleSet = SAMPLE_SET_SINGLE; frame->sampleSet = SAMPLE_SET_SINGLE;
frame->layer = LAYER_SCENE;
m = frame->world.m; m = frame->world.m;
frame->right = vec3Normalize(vec3(m[0], m[1], m[2])); frame->right = vec3Normalize(vec3(m[0], m[1], m[2]));
frame->up = vec3Normalize(vec3(m[4], m[5], m[6])); frame->up = vec3Normalize(vec3(m[4], m[5], m[6]));
@ -1105,6 +1115,41 @@ static bool _createBloomTargets(int32_t width, int32_t height) {
} }
// The front layer's targets at the scene's size, made the first time a node is in it (outside a
// command buffer, like sceneResize's): its own HDR colour and display textures. The multisampled colour and the depth are the window's,
// cleared again, since the front pass runs after the scene's has finished with them.
static bool _createFrontTargets(void) {
SDL_GPUTextureCreateInfo info;
if (_scene.frontComposite != NULL) {
return true;
}
memset(&info, 0, sizeof(info));
info.type = SDL_GPU_TEXTURETYPE_2D;
info.format = rgpuGetTextureFormatFromPixelFormat(SDL_PIXELFORMAT_BGRA32);
info.usage = SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | SDL_GPU_TEXTUREUSAGE_SAMPLER;
info.width = (Uint32)_scene.renderWidth;
info.height = (Uint32)_scene.renderHeight;
info.layer_count_or_depth = 1;
info.num_levels = 1;
info.sample_count = SDL_GPU_SAMPLECOUNT_1;
_scene.frontOutput = rgpuCreateTexture(_scene.device, &info);
info.format = _scene.hdrFormat;
_scene.frontColour = rgpuCreateTexture(_scene.device, &info);
if ((_scene.frontOutput != NULL) && (_scene.frontColour != NULL)) {
_scene.frontComposite = renderWrapTexture(_scene.renderer, _scene.frontOutput, SDL_PIXELFORMAT_BGRA32, _scene.renderWidth, _scene.renderHeight);
}
if (_scene.frontComposite == NULL) {
utilTrace("Scene: no front layer: %s", SDL_GetError());
_releaseTexture(&_scene.frontOutput);
_releaseTexture(&_scene.frontColour);
return false;
}
SDL_SetTextureBlendMode(_scene.frontComposite, SDL_BLENDMODE_BLEND);
return true;
}
// Debug lines: unlit, alpha blended, depth tested against the scene but never writing it. // Debug lines: unlit, alpha blended, depth tested against the scene but never writing it.
static bool _createLinePipeline(int32_t sampleSet) { static bool _createLinePipeline(int32_t sampleSet) {
SDL_GPUGraphicsPipelineCreateInfo info; SDL_GPUGraphicsPipelineCreateInfo info;
@ -1644,6 +1689,13 @@ static void _destroyTargets(void) {
SDL_DestroyTexture(_scene.composite); SDL_DestroyTexture(_scene.composite);
_scene.composite = NULL; _scene.composite = NULL;
} }
if (_scene.frontComposite != NULL) {
SDL_DestroyTexture(_scene.frontComposite);
_scene.frontComposite = NULL;
}
_releaseTexture(&_scene.frontOutput);
_releaseTexture(&_scene.frontColour);
_scene.frontDrawn = false;
if (_scene.output != NULL) { if (_scene.output != NULL) {
rgpuReleaseTexture(_scene.device, _scene.output); rgpuReleaseTexture(_scene.device, _scene.output);
_scene.output = NULL; _scene.output = NULL;
@ -3200,6 +3252,7 @@ static void _renderCamera(SDL_GPUCommandBuffer *commands, const CameraFrameT *fr
SDL_GPUDepthStencilTargetInfo depth; SDL_GPUDepthStencilTargetInfo depth;
SDL_GPURenderPass *pass; SDL_GPURenderPass *pass;
bool *culled = _skipScratch(drawCount); bool *culled = _skipScratch(drawCount);
bool front = frame->layer == LAYER_FRONT;
int32_t x; int32_t x;
uniforms->cameraPosition[0] = frame->eye.x; uniforms->cameraPosition[0] = frame->eye.x;
@ -3210,11 +3263,19 @@ static void _renderCamera(SDL_GPUCommandBuffer *commands, const CameraFrameT *fr
uniforms->cameraForward[1] = frame->forward.y; uniforms->cameraForward[1] = frame->forward.y;
uniforms->cameraForward[2] = frame->forward.z; uniforms->cameraForward[2] = frame->forward.z;
uniforms->cameraForward[3] = frame->main ? 1.0f : 0.0f; uniforms->cameraForward[3] = frame->main ? 1.0f : 0.0f;
if (!front) {
_gatherParticles(frame->eye, frame->forward); _gatherParticles(frame->eye, frame->forward);
_uploadParticles(commands); _uploadParticles(commands);
}
_cullDraws(&frame->viewProjection, drawCount, culled); _cullDraws(&frame->viewProjection, drawCount, culled);
// Each layer draws only its own nodes.
for (x = 0; x < drawCount; x++) {
if (_scene.nodes[_scene.draws[x].node].worldFront != front) {
culled[x] = true;
}
}
_orderBlended(frame->eye, drawCount); _orderBlended(frame->eye, drawCount);
if (frame->main) { if (frame->main && !front) {
_scene.statTotal = drawCount; _scene.statTotal = drawCount;
_scene.statDrawn = 0; _scene.statDrawn = 0;
_scene.statBatches = 0; _scene.statBatches = 0;
@ -3241,9 +3302,11 @@ static void _renderCamera(SDL_GPUCommandBuffer *commands, const CameraFrameT *fr
rgpuEndRenderPass(pass); rgpuEndRenderPass(pass);
_scene.depthPrepass = false; _scene.depthPrepass = false;
} }
// The main pass. // The main pass. The front layer starts clear, so the overlay shows wherever it draws nothing.
memset(&colour, 0, sizeof(colour)); memset(&colour, 0, sizeof(colour));
if (!front) {
colour.clear_color = _scene.background; colour.clear_color = _scene.background;
}
colour.load_op = SDL_GPU_LOADOP_CLEAR; colour.load_op = SDL_GPU_LOADOP_CLEAR;
if (frame->multisampled != NULL) { if (frame->multisampled != NULL) {
colour.texture = frame->multisampled; colour.texture = frame->multisampled;
@ -3261,10 +3324,14 @@ static void _renderCamera(SDL_GPUCommandBuffer *commands, const CameraFrameT *fr
depth.stencil_load_op = SDL_GPU_LOADOP_DONT_CARE; depth.stencil_load_op = SDL_GPU_LOADOP_DONT_CARE;
depth.stencil_store_op = SDL_GPU_STOREOP_DONT_CARE; depth.stencil_store_op = SDL_GPU_STOREOP_DONT_CARE;
pass = rgpuBeginRenderPass(commands, &colour, 1, &depth); pass = rgpuBeginRenderPass(commands, &colour, 1, &depth);
if (front) {
_drawList(commands, pass, drawCount, &frame->viewProjection, frame, false, false, culled, uniforms, frame->sampleSet);
} else {
_drawSky(commands, pass, frame); _drawSky(commands, pass, frame);
_drawList(commands, pass, drawCount, &frame->viewProjection, frame, false, false, culled, uniforms, frame->sampleSet); _drawList(commands, pass, drawCount, &frame->viewProjection, frame, false, false, culled, uniforms, frame->sampleSet);
_drawParticles(commands, pass, frame, uniforms); _drawParticles(commands, pass, frame, uniforms);
_drawLines(commands, pass, frame); _drawLines(commands, pass, frame);
}
rgpuEndRenderPass(pass); rgpuEndRenderPass(pass);
_drawPost(commands, frame); _drawPost(commands, frame);
} }
@ -3525,14 +3592,15 @@ static void _stampMesh(MeshT *mesh) {
// World matrices and inherited visibility, depth first. // World matrices and inherited visibility, depth first.
static void _updateWorld(int32_t node, const Mat4T *parentWorld, bool parentVisible) { static void _updateWorld(int32_t node, const Mat4T *parentWorld, bool parentVisible, bool parentFront) {
NodeT *n = &_scene.nodes[node]; NodeT *n = &_scene.nodes[node];
int32_t child; int32_t child;
n->world = mat4Multiply(*parentWorld, mat4Compose(n->translation, n->rotation, n->scale)); n->world = mat4Multiply(*parentWorld, mat4Compose(n->translation, n->rotation, n->scale));
n->worldVisible = parentVisible && n->visible; n->worldVisible = parentVisible && n->visible;
n->worldFront = parentFront || (n->layer == LAYER_FRONT);
for (child = n->firstChild; child != NO_HANDLE; child = _scene.nodes[child].nextSibling) { for (child = n->firstChild; child != NO_HANDLE; child = _scene.nodes[child].nextSibling) {
_updateWorld(child, &n->world, n->worldVisible); _updateWorld(child, &n->world, n->worldVisible, n->worldFront);
} }
} }
@ -4995,6 +5063,16 @@ bool nodeSetBillboard(int32_t node, BillboardE mode) {
} }
// Puts the node, and with it its children, in the scene or the front layer.
bool nodeSetLayer(int32_t node, SceneLayerE layer) {
if (!nodeValid(node)) {
return false;
}
_scene.nodes[node].layer = layer;
return true;
}
// Changes the material and keeps the mesh. // Changes the material and keeps the mesh.
bool nodeSetMaterial(int32_t node, int32_t material) { bool nodeSetMaterial(int32_t node, int32_t material) {
if (!nodeValid(node)) { if (!nodeValid(node)) {
@ -5427,6 +5505,11 @@ bool sceneEnable(bool enabled) {
} }
SDL_Texture *sceneFront(void) {
return _scene.frontDrawn ? _scene.frontComposite : NULL;
}
void sceneGetSize(int32_t *width, int32_t *height) { void sceneGetSize(int32_t *width, int32_t *height) {
*width = _scene.width; *width = _scene.width;
*height = _scene.height; *height = _scene.height;
@ -5826,14 +5909,17 @@ SDL_Texture *sceneRender(void) {
int32_t slot; int32_t slot;
int32_t face; int32_t face;
int32_t layers = 0; int32_t layers = 0;
int32_t frontCount = 0;
bool front;
NodeT *node; NodeT *node;
_scene.frontDrawn = false;
if (!_scene.enabled || (_scene.colour == NULL)) { if (!_scene.enabled || (_scene.colour == NULL)) {
_scene.lineVertexCount = 0; _scene.lineVertexCount = 0;
return NULL; return NULL;
} }
// Transforms, the window's camera and the lights for this frame. // Transforms, the window's camera and the lights for this frame.
_updateWorld(SCENE_ROOT_NODE, &identity, true); _updateWorld(SCENE_ROOT_NODE, &identity, true, false);
_cameraFrame(_scene.cameraNode, _scene.renderWidth, _scene.renderHeight, &main); _cameraFrame(_scene.cameraNode, _scene.renderWidth, _scene.renderHeight, &main);
main.colour = _scene.colour; main.colour = _scene.colour;
main.multisampled = _scene.multisampled; main.multisampled = _scene.multisampled;
@ -5880,6 +5966,9 @@ SDL_Texture *sceneRender(void) {
if (!node->used || !node->worldVisible || !meshValid(node->mesh)) { if (!node->used || !node->worldVisible || !meshValid(node->mesh)) {
continue; continue;
} }
if (node->worldFront) {
frontCount++;
}
if ((node->material != NO_HANDLE) && _scene.materials[node->material].blend) { if ((node->material != NO_HANDLE) && _scene.materials[node->material].blend) {
blendedStart--; blendedStart--;
_scene.draws[blendedStart].node = x; _scene.draws[blendedStart].node = x;
@ -5894,6 +5983,9 @@ SDL_Texture *sceneRender(void) {
if (drawCount > opaqueCount) { if (drawCount > opaqueCount) {
memmove(&_scene.draws[opaqueCount], &_scene.draws[blendedStart], sizeof(DrawT) * (size_t)(drawCount - opaqueCount)); memmove(&_scene.draws[opaqueCount], &_scene.draws[blendedStart], sizeof(DrawT) * (size_t)(drawCount - opaqueCount));
} }
// The front layer's targets before the command buffer: wrapping one for SDL_Renderer is
// SDL_Renderer's own GL work, which under GLES must not land inside the scene's handover.
front = (frontCount > 0) && _createFrontTargets();
commands = rgpuAcquireCommandBuffer(_scene.device); commands = rgpuAcquireCommandBuffer(_scene.device);
if (commands == NULL) { if (commands == NULL) {
utilTrace("Scene: %s", SDL_GetError()); utilTrace("Scene: %s", SDL_GetError());
@ -5991,6 +6083,16 @@ SDL_Texture *sceneRender(void) {
_renderCamera(commands, &frame, &fragmentUniforms, drawCount); _renderCamera(commands, &frame, &fragmentUniforms, drawCount);
} }
_renderCamera(commands, &main, &fragmentUniforms, drawCount); _renderCamera(commands, &main, &fragmentUniforms, drawCount);
// The front layer: the window's camera again, into its own targets, after the scene's.
if (front) {
frame = main;
frame.colour = _scene.frontColour;
frame.output = _scene.frontOutput;
frame.softDepth = NULL;
frame.layer = LAYER_FRONT;
_renderCamera(commands, &frame, &fragmentUniforms, drawCount);
_scene.frontDrawn = true;
}
rgpuSubmitCommandBuffer(commands); rgpuSubmitCommandBuffer(commands);
_scene.lineVertexCount = 0; _scene.lineVertexCount = 0;
return _scene.composite; return _scene.composite;
@ -6281,7 +6383,7 @@ void sceneUpdateTransforms(void) {
Mat4T identity = mat4Identity(); Mat4T identity = mat4Identity();
if (_scene.nodeCount > 0) { if (_scene.nodeCount > 0) {
_updateWorld(SCENE_ROOT_NODE, &identity, true); _updateWorld(SCENE_ROOT_NODE, &identity, true, false);
} }
} }

View file

@ -67,6 +67,13 @@ typedef enum BillboardE {
BILLBOARD_Y = BILLBOARD_MODE_Y // Turns about world up only (trees, health bars) BILLBOARD_Y = BILLBOARD_MODE_Y // Turns about world up only (trees, health bars)
} BillboardE; } BillboardE;
// Where in the window's picture a node is drawn. The scene goes beneath the overlay; the front
// layer is drawn again by the same camera after the overlay, so a model can stand over 2D artwork.
typedef enum SceneLayerE {
LAYER_SCENE = 0,
LAYER_FRONT = 1
} SceneLayerE;
// A material's textures, for the compressed path. // A material's textures, for the compressed path.
typedef enum MaterialMapE { typedef enum MaterialMapE {
MAP_BASE = 0, MAP_BASE = 0,
@ -90,6 +97,7 @@ void sceneComputeNormals(SceneVertexT *vertices, int32_t vertexCount, co
void sceneComputeTangents(SceneVertexT *vertices, int32_t vertexCount, const uint32_t *indices, int32_t indexCount); void sceneComputeTangents(SceneVertexT *vertices, int32_t vertexCount, const uint32_t *indices, int32_t indexCount);
void sceneDrawLine(Vec3T from, Vec3T to, uint8_t r, uint8_t g, uint8_t b); // A world-space line for this frame only (sRGB colour) void sceneDrawLine(Vec3T from, Vec3T to, uint8_t r, uint8_t g, uint8_t b); // A world-space line for this frame only (sRGB colour)
bool sceneEnable(bool enabled); bool sceneEnable(bool enabled);
SDL_Texture *sceneFront(void); // The front layer sceneRender drew this frame, or NULL when nothing is in it
void sceneGetSize(int32_t *width, int32_t *height); void sceneGetSize(int32_t *width, int32_t *height);
void sceneGetStats(int32_t *total, int32_t *drawn, int32_t *batches, int64_t *textureBytes); void sceneGetStats(int32_t *total, int32_t *drawn, int32_t *batches, int64_t *textureBytes);
Mat4T sceneGetView(void); Mat4T sceneGetView(void);
@ -205,6 +213,7 @@ bool nodeMove(int32_t node, Vec3T delta);
int32_t nodeNew(int32_t parent); int32_t nodeNew(int32_t parent);
bool nodeRotate(int32_t node, QuatT delta); bool nodeRotate(int32_t node, QuatT delta);
bool nodeSetBillboard(int32_t node, BillboardE mode); bool nodeSetBillboard(int32_t node, BillboardE mode);
bool nodeSetLayer(int32_t node, SceneLayerE layer);
bool nodeSetMaterial(int32_t node, int32_t material); bool nodeSetMaterial(int32_t node, int32_t material);
bool nodeSetMesh(int32_t node, int32_t mesh, int32_t material); bool nodeSetMesh(int32_t node, int32_t mesh, int32_t material);
bool nodeSetMorphWeight(int32_t node, int32_t target, float weight); bool nodeSetMorphWeight(int32_t node, int32_t target, float weight);

View file

@ -76,6 +76,7 @@ SSL_CTX *lsec_checkcontext(lua_State *L, int idx);
#include "midiIo.h" #include "midiIo.h"
#include "netTrust.h" #include "netTrust.h"
#include "pack.h" #include "pack.h"
#include "record.h"
#include "util.h" #include "util.h"
#include "frameFile.h" #include "frameFile.h"
#include "vfs.h" #include "vfs.h"
@ -277,6 +278,8 @@ SDL_COMPILE_TIME_ASSERT(codeGamepadBase, CODE_GAMEPAD_BASE >= SDL_SCANCODE_RESER
#define COLOR_KEY_VALUE 0 #define COLOR_KEY_VALUE 0
#define SPRITE_SHEET_FRAMES_MIN 2 // spriteLoadFrames strip bounds, as Hypseus has them #define SPRITE_SHEET_FRAMES_MIN 2 // spriteLoadFrames strip bounds, as Hypseus has them
#define SPRITE_SHEET_FRAMES_MAX 512 #define SPRITE_SHEET_FRAMES_MAX 512
#define SPRITE_NEW_MAX 4096 // spriteNew's widest and tallest
#define PALETTE_COLOURS 256 // spriteSetPixels' palette: one colour for each byte value
#define RUMBLE_BASE_MS 150 // Duration step 1; every step doubles it #define RUMBLE_BASE_MS 150 // Duration step 1; every step doubles it
#define LUMA_WEIGHT_RED 77 // BT.601 luma weights in 1/256ths, for overlaySetMonochrome #define LUMA_WEIGHT_RED 77 // BT.601 luma weights in 1/256ths, for overlaySetMonochrome
#define LUMA_WEIGHT_GREEN 151 #define LUMA_WEIGHT_GREEN 151
@ -339,6 +342,13 @@ typedef enum RenderQualityE {
RENDER_SMOOTH = 1 RENDER_SMOOTH = 1
} RenderQualityE; } RenderQualityE;
// A recording the engine is to start or stop at the next frame it draws.
typedef enum RecordRequestE {
RECORD_REQUEST_NONE = 0,
RECORD_REQUEST_START = 1,
RECORD_REQUEST_STOP = 2
} RecordRequestE;
// How spriteSetBlend lays a sprite over the overlay. // How spriteSetBlend lays a sprite over the overlay.
typedef enum SpriteBlendE { typedef enum SpriteBlendE {
SPRITE_ALPHA = 0, // Over what is there, by the sprite's alpha SPRITE_ALPHA = 0, // Over what is there, by the sprite's alpha
@ -388,6 +398,7 @@ typedef enum InputE {
INPUT_GRAB, INPUT_GRAB,
// Added in Singe 3.00. A new switch goes on the end so the numbers a game already knows do not move. // Added in Singe 3.00. A new switch goes on the end so the numbers a game already knows do not move.
INPUT_MOUSE_DISCONNECT, INPUT_MOUSE_DISCONNECT,
INPUT_RECORD,
INPUT_COUNT INPUT_COUNT
} InputE; } InputE;
@ -682,6 +693,7 @@ typedef struct GlobalS {
int32_t canvasWidth; // World size: the disc's, or the configured canvas int32_t canvasWidth; // World size: the disc's, or the configured canvas
int32_t canvasHeight; int32_t canvasHeight;
FontQualityE fontQuality; FontQualityE fontQuality;
bool spriteKeyed; // drawTransparent: colour 0 keyed on the sprites and text made from now on
MouseModeE mouseMode; MouseModeE mouseMode;
int32_t mouseCount; int32_t mouseCount;
int32_t axisCache[AXIS_COUNT]; int32_t axisCache[AXIS_COUNT];
@ -779,6 +791,13 @@ typedef struct GlobalS {
uint64_t idleClock; // Ticks of the last input, for --idleexit uint64_t idleClock; // Ticks of the last input, for --idleexit
bool requestScreenShot; bool requestScreenShot;
char *screenshotName; // singeScreenshot's chosen file name, or NULL for the next singeNNN.png char *screenshotName; // singeScreenshot's chosen file name, or NULL for the next singeNNN.png
RecordRequestE recordRequest; // A recording to start or stop when the next frame is drawn
char *recordName; // singeRecordStart's chosen file name, or NULL for the next singeNNN.mp4
int32_t nextRecording; // First singeNNN.mp4 worth checking
bool generatedAudio; // A recorded deterministic run: the sound is mixed a step at a time
double generatedCarry; // Fractions of a sample frame the steps have not yet mixed
uint8_t *generatedBuffer; // Where it is mixed to; the recorder takes it from the mixer
int32_t generatedBufferSize;
bool wasPlayingBeforePause; bool wasPlayingBeforePause;
VideoT *videoList; VideoT *videoList;
SpriteT *spriteList; SpriteT *spriteList;
@ -890,7 +909,8 @@ static const InputNameT _inputNames[INPUT_COUNT] = {
{ "INPUT_ACTION_4", "SWITCH_BUTTON4" }, { "INPUT_ACTION_4", "SWITCH_BUTTON4" },
{ "INPUT_TILT", "SWITCH_TILT" }, { "INPUT_TILT", "SWITCH_TILT" },
{ "INPUT_GRAB", "SWITCH_GRAB" }, { "INPUT_GRAB", "SWITCH_GRAB" },
{ "INPUT_MOUSE_DISCONNECT", "SWITCH_MOUSE_DISCONNECT" } { "INPUT_MOUSE_DISCONNECT", "SWITCH_MOUSE_DISCONNECT" },
{ "INPUT_RECORD", "SWITCH_RECORD" }
}; };
// SDL numbers mouse buttons left, middle, right; scripts number them left, right, middle. // SDL numbers mouse buttons left, middle, right; scripts number them left, right, middle.
@ -982,6 +1002,7 @@ static void _fontDestroy(FontT *font);
static void _freezeGame(bool freeze); static void _freezeGame(bool freeze);
static bool _gamepadMappingFor(SDL_JoystickID id, char *mapping, size_t size); static bool _gamepadMappingFor(SDL_JoystickID id, char *mapping, size_t size);
static void _gamepadOrder(int32_t *order, int32_t count); static void _gamepadOrder(int32_t *order, int32_t count);
static void _generateAudio(void);
static void _guiEvent(int32_t gui, int32_t document, const char *id, const char *event, const char *value); static void _guiEvent(int32_t gui, int32_t document, const char *id, const char *event, const char *value);
static void _guiHandlerKey(char *buffer, size_t size, int32_t gui, int32_t document, const char *id, const char *event); static void _guiHandlerKey(char *buffer, size_t size, int32_t gui, int32_t document, const char *id, const char *event);
static bool _guiKey(SDL_Keycode key, SDL_Keymod modifiers, bool down); static bool _guiKey(SDL_Keycode key, SDL_Keymod modifiers, bool down);
@ -1106,6 +1127,8 @@ static void _quadScratch(int32_t quads, int32_t frames);
static bool _quitChordHold(int32_t scancode); static bool _quitChordHold(int32_t scancode);
static void _quitChordLetGo(int32_t scancode); static void _quitChordLetGo(int32_t scancode);
static void _readColor(lua_State *L, const char *method, SDL_Color *color, uint8_t defaultAlpha); static void _readColor(lua_State *L, const char *method, SDL_Color *color, uint8_t defaultAlpha);
static SDL_Surface *_readWindow(void);
static void _recordUpdate(void);
static void _regionAdd(RegionT *region, const SDL_Rect *area); static void _regionAdd(RegionT *region, const SDL_Rect *area);
static void _regionAddRegion(RegionT *region, const RegionT *other); static void _regionAddRegion(RegionT *region, const RegionT *other);
static void _regionClear(RegionT *region); static void _regionClear(RegionT *region);
@ -1178,6 +1201,7 @@ static void _subtitleShow(const char *text, int32_t position);
static void _subtitleUpdate(void); static void _subtitleUpdate(void);
static void _suppressHeldInput(void); static void _suppressHeldInput(void);
static uint32_t _surfaceCornerKey(SDL_Surface *surface); static uint32_t _surfaceCornerKey(SDL_Surface *surface);
static void _surfaceKey(SDL_Surface *surface);
static void _surfaceUnpack(SDL_Surface **surface); static void _surfaceUnpack(SDL_Surface **surface);
static void _takeScreenshot(void); static void _takeScreenshot(void);
static void _textCacheDrop(int32_t font); static void _textCacheDrop(int32_t font);
@ -1471,6 +1495,7 @@ static int32_t apiNodeMove(lua_State *L);
static int32_t apiNodeNew(lua_State *L); static int32_t apiNodeNew(lua_State *L);
static int32_t apiNodeRotate(lua_State *L); static int32_t apiNodeRotate(lua_State *L);
static int32_t apiNodeSetBillboard(lua_State *L); static int32_t apiNodeSetBillboard(lua_State *L);
static int32_t apiNodeSetLayer(lua_State *L);
static int32_t apiNodeSetMaterial(lua_State *L); static int32_t apiNodeSetMaterial(lua_State *L);
static int32_t apiNodeSetMesh(lua_State *L); static int32_t apiNodeSetMesh(lua_State *L);
static int32_t apiNodeSetMorph(lua_State *L); static int32_t apiNodeSetMorph(lua_State *L);
@ -1594,8 +1619,11 @@ static int32_t apiSingeGetScriptPath(lua_State *L);
static int32_t apiSingeGetTicks(lua_State *L); static int32_t apiSingeGetTicks(lua_State *L);
static int32_t apiSingeGetWidth(lua_State *L); static int32_t apiSingeGetWidth(lua_State *L);
static int32_t apiSingeHasGpu(lua_State *L); static int32_t apiSingeHasGpu(lua_State *L);
static int32_t apiSingeIsRecording(lua_State *L);
static int32_t apiSingePack(lua_State *L); static int32_t apiSingePack(lua_State *L);
static int32_t apiSingeQuit(lua_State *L); static int32_t apiSingeQuit(lua_State *L);
static int32_t apiSingeRecordStart(lua_State *L);
static int32_t apiSingeRecordStop(lua_State *L);
static int32_t apiSingeRelaunch(lua_State *L); static int32_t apiSingeRelaunch(lua_State *L);
static int32_t apiSingeReload(lua_State *L); static int32_t apiSingeReload(lua_State *L);
static int32_t apiSingeScreenshot(lua_State *L); static int32_t apiSingeScreenshot(lua_State *L);
@ -1651,6 +1679,7 @@ static int32_t apiSpriteLoad(lua_State *L);
static int32_t apiSpriteLoadData(lua_State *L); static int32_t apiSpriteLoadData(lua_State *L);
static int32_t apiSpriteLoadFrames(lua_State *L); static int32_t apiSpriteLoadFrames(lua_State *L);
static int32_t apiSpriteLoop(lua_State *L); static int32_t apiSpriteLoop(lua_State *L);
static int32_t apiSpriteNew(lua_State *L);
static int32_t apiSpritePause(lua_State *L); static int32_t apiSpritePause(lua_State *L);
static int32_t apiSpritePlay(lua_State *L); static int32_t apiSpritePlay(lua_State *L);
static int32_t apiSpriteQuality(lua_State *L); static int32_t apiSpriteQuality(lua_State *L);
@ -1662,6 +1691,7 @@ static int32_t apiSpriteScale(lua_State *L);
static int32_t apiSpriteSetAlpha(lua_State *L); static int32_t apiSpriteSetAlpha(lua_State *L);
static int32_t apiSpriteSetBlend(lua_State *L); static int32_t apiSpriteSetBlend(lua_State *L);
static int32_t apiSpriteSetFrame(lua_State *L); static int32_t apiSpriteSetFrame(lua_State *L);
static int32_t apiSpriteSetPixels(lua_State *L);
static int32_t apiSpriteSetTint(lua_State *L); static int32_t apiSpriteSetTint(lua_State *L);
static int32_t apiSpriteUnload(lua_State *L); static int32_t apiSpriteUnload(lua_State *L);
static int32_t apiSrtClear(lua_State *L); static int32_t apiSrtClear(lua_State *L);
@ -3654,15 +3684,21 @@ static void _engineAction(InputE engine) {
_global.refreshDisplay = true; _global.refreshDisplay = true;
break; break;
case INPUT_RECORD:
// Starts the next singeNNN.mp4, or stops the one being written.
_global.recordRequest = recordIsRecording() ? RECORD_REQUEST_STOP : RECORD_REQUEST_START;
_global.refreshDisplay = true;
break;
default: default:
break; break;
} }
} }
// Which engine owned switch (pause, quit, screenshot, grab) a code is mapped to, or INPUT_COUNT. // Which engine owned switch (pause, quit, screenshot, grab, record) a code is mapped to, or INPUT_COUNT.
static InputE _engineSwitch(int32_t scancode) { static InputE _engineSwitch(int32_t scancode) {
static const InputE owned[] = { INPUT_PAUSE, INPUT_QUIT, INPUT_SCREENSHOT, INPUT_GRAB }; static const InputE owned[] = { INPUT_PAUSE, INPUT_QUIT, INPUT_SCREENSHOT, INPUT_GRAB, INPUT_RECORD };
int32_t i = 0; int32_t i = 0;
int32_t index = 0; int32_t index = 0;
@ -3851,6 +3887,32 @@ static void _gamepadOrder(int32_t *order, int32_t count) {
} }
// A recorded deterministic run mixes its sound into memory, one clock step of it each loop, so the
// recording's sound is exactly what went with each frame. The recorder takes it from the mixer as
// it is mixed; what lands in the buffer is not heard by anyone.
static void _generateAudio(void) {
MIX_Mixer *mixer = videoGetMixer();
SDL_AudioSpec spec;
double want = 0.0;
int32_t bytes = 0;
if (!MIX_GetMixerFormat(mixer, &spec)) {
return;
}
want = (double)_global.conf->deterministicStep * (double)spec.freq / 1000.0 + _global.generatedCarry;
bytes = (int32_t)want * SDL_AUDIO_FRAMESIZE(spec);
_global.generatedCarry = want - (double)(int32_t)want;
if (bytes > _global.generatedBufferSize) {
free(_global.generatedBuffer);
_global.generatedBuffer = (uint8_t *)malloc((size_t)bytes);
_global.generatedBufferSize = bytes;
}
if (bytes > 0) {
MIX_Generate(mixer, _global.generatedBuffer, bytes);
}
}
// A GUI element's event, from RmlUi through gui.cpp: the function guiSetHandler registered for it. // A GUI element's event, from RmlUi through gui.cpp: the function guiSetHandler registered for it.
static void _guiEvent(int32_t gui, int32_t document, const char *id, const char *event, const char *value) { static void _guiEvent(int32_t gui, int32_t document, const char *id, const char *event, const char *value) {
lua_State *L = _global.luaContext; lua_State *L = _global.luaContext;
@ -6542,6 +6604,11 @@ static void _pushConstants(lua_State *L) {
lua_setglobal(L, "BILLBOARD_ALL"); lua_setglobal(L, "BILLBOARD_ALL");
lua_pushinteger(L, BILLBOARD_Y); lua_pushinteger(L, BILLBOARD_Y);
lua_setglobal(L, "BILLBOARD_Y"); lua_setglobal(L, "BILLBOARD_Y");
// Scene layers
lua_pushinteger(L, LAYER_SCENE);
lua_setglobal(L, "LAYER_SCENE");
lua_pushinteger(L, LAYER_FRONT);
lua_setglobal(L, "LAYER_FRONT");
// Particle collisions // Particle collisions
lua_pushinteger(L, COLLIDE_NONE); lua_pushinteger(L, COLLIDE_NONE);
lua_setglobal(L, "COLLIDE_NONE"); lua_setglobal(L, "COLLIDE_NONE");
@ -6768,6 +6835,79 @@ static void _readColor(lua_State *L, const char *method, SDL_Color *color, uint8
} }
// The whole window as it is about to be shown, letterbox included: with a logical size set, a NULL
// rect would only cover the scaled viewport, so it is switched off around the read.
static SDL_Surface *_readWindow(void) {
int32_t logicalW = 0;
int32_t logicalH = 0;
SDL_Surface *surface = NULL;
SDL_RendererLogicalPresentation mode = SDL_LOGICAL_PRESENTATION_DISABLED;
SDL_GetRenderLogicalPresentation(_global.renderer, &logicalW, &logicalH, &mode);
SDL_SetRenderLogicalPresentation(_global.renderer, 0, 0, SDL_LOGICAL_PRESENTATION_DISABLED);
surface = SDL_RenderReadPixels(_global.renderer, NULL);
SDL_SetRenderLogicalPresentation(_global.renderer, logicalW, logicalH, mode);
if (surface == NULL) {
utilDie("%s", SDL_GetError());
}
return surface;
}
// At each frame drawn: a recording started or stopped as asked, and the frame handed to one that is
// running. --record starts on the first frame and runs to the end; the record switch and
// singeRecordStart write the next singeNNN.mp4, or the named file, in the data directory.
static void _recordUpdate(void) {
char *filename = NULL;
int32_t x = 0;
SDL_Surface *surface = NULL;
if (_global.recordRequest == RECORD_REQUEST_STOP) {
_global.recordRequest = RECORD_REQUEST_NONE;
_progTrace("Stopping the recording of %s", recordPath());
recordStop();
}
if (_global.recordRequest == RECORD_REQUEST_START) {
_global.recordRequest = RECORD_REQUEST_NONE;
if (!recordIsRecording()) {
if (_global.recordName != NULL) {
filename = utilCreateString("%s%s", _global.conf->dataDir, _global.recordName);
} else if ((_global.conf->recordFile != NULL) && (_global.nextRecording == 0)) {
filename = utilCreateString("%s", _global.conf->recordFile);
} else {
for (x = _global.nextRecording; x < SCREENSHOT_MAX; x++) {
free(filename);
filename = utilCreateString("%ssinge%03d.mp4", _global.conf->dataDir, x);
if (!utilFileExists(filename)) {
break;
}
}
}
_global.nextRecording = SDL_max(_global.nextRecording, x + 1);
free(_global.recordName);
_global.recordName = NULL;
// The recording is the size of the frames the window gives back, whatever the renderer
// says its output is.
surface = _readWindow();
if (!recordStart(filename, surface->w, surface->h, utilIsDeterministic() ? _global.conf->deterministicStep : 0, videoGetMixer(), _global.generatedAudio ? 0 : videoGetAudioLatency(), utilTicks())) {
utilTrace("Unable to record %s: %s", filename, SDL_GetError());
} else {
_progTrace("Recording %s with %s", filename, recordVideoEncoder());
}
free(filename);
}
}
if (recordIsRecording()) {
if (surface == NULL) {
surface = _readWindow();
}
recordFrame(surface, utilTicks());
}
SDL_DestroySurface(surface);
}
// Adds a rectangle, or everything for NULL. A full region merges it into the rectangle it grows least. // Adds a rectangle, or everything for NULL. A full region merges it into the rectangle it grows least.
static void _regionAdd(RegionT *region, const SDL_Rect *area) { static void _regionAdd(RegionT *region, const SDL_Rect *area) {
SDL_Rect merged; SDL_Rect merged;
@ -7102,6 +7242,7 @@ static void _registerApi(lua_State *L) {
lua_register(L, "nodeNew", apiNodeNew); // 3.00 lua_register(L, "nodeNew", apiNodeNew); // 3.00
lua_register(L, "nodeRotate", apiNodeRotate); // 3.00 lua_register(L, "nodeRotate", apiNodeRotate); // 3.00
lua_register(L, "nodeSetBillboard", apiNodeSetBillboard); // 3.00 lua_register(L, "nodeSetBillboard", apiNodeSetBillboard); // 3.00
lua_register(L, "nodeSetLayer", apiNodeSetLayer); // 3.00
lua_register(L, "nodeSetMaterial", apiNodeSetMaterial); // 3.00 lua_register(L, "nodeSetMaterial", apiNodeSetMaterial); // 3.00
lua_register(L, "nodeSetMesh", apiNodeSetMesh); // 3.00 lua_register(L, "nodeSetMesh", apiNodeSetMesh); // 3.00
lua_register(L, "nodeSetMorph", apiNodeSetMorph); // 3.00 lua_register(L, "nodeSetMorph", apiNodeSetMorph); // 3.00
@ -7223,8 +7364,11 @@ static void _registerApi(lua_State *L) {
lua_register(L, "singeGetTicks", apiSingeGetTicks); // 3.00 lua_register(L, "singeGetTicks", apiSingeGetTicks); // 3.00
lua_register(L, "singeGetWidth", apiSingeGetWidth); // 1.xx lua_register(L, "singeGetWidth", apiSingeGetWidth); // 1.xx
lua_register(L, "singeHasGpu", apiSingeHasGpu); // 3.00 lua_register(L, "singeHasGpu", apiSingeHasGpu); // 3.00
lua_register(L, "singeIsRecording", apiSingeIsRecording); // 3.00
lua_register(L, "singePack", apiSingePack); // 3.00 lua_register(L, "singePack", apiSingePack); // 3.00
lua_register(L, "singeQuit", apiSingeQuit); // 1.xx RDG lua_register(L, "singeQuit", apiSingeQuit); // 1.xx RDG
lua_register(L, "singeRecordStart", apiSingeRecordStart); // 3.00
lua_register(L, "singeRecordStop", apiSingeRecordStop); // 3.00
lua_register(L, "singeRelaunch", apiSingeRelaunch); // 3.00 lua_register(L, "singeRelaunch", apiSingeRelaunch); // 3.00
lua_register(L, "singeReload", apiSingeReload); // 3.00 lua_register(L, "singeReload", apiSingeReload); // 3.00
lua_register(L, "singeSaveGeometry", apiSingeSaveGeometry); // 3.00 lua_register(L, "singeSaveGeometry", apiSingeSaveGeometry); // 3.00
@ -7284,6 +7428,7 @@ static void _registerApi(lua_State *L) {
lua_register(L, "spriteLoadData", apiSpriteLoadData); // Hypseus lua_register(L, "spriteLoadData", apiSpriteLoadData); // Hypseus
lua_register(L, "spriteLoadFrames", apiSpriteLoadFrames); // Hypseus lua_register(L, "spriteLoadFrames", apiSpriteLoadFrames); // Hypseus
lua_register(L, "spriteLoop", apiSpriteLoop); // 2.10 Handle first since 3.00. lua_register(L, "spriteLoop", apiSpriteLoop); // 2.10 Handle first since 3.00.
lua_register(L, "spriteNew", apiSpriteNew); // 3.00
lua_register(L, "spritePause", apiSpritePause); // 2.10 lua_register(L, "spritePause", apiSpritePause); // 2.10
lua_register(L, "spritePlay", apiSpritePlay); // 2.10 lua_register(L, "spritePlay", apiSpritePlay); // 2.10
lua_register(L, "spriteQuality", apiSpriteQuality); // 2.10 Handle first since 3.00. lua_register(L, "spriteQuality", apiSpriteQuality); // 2.10 Handle first since 3.00.
@ -7295,6 +7440,7 @@ static void _registerApi(lua_State *L) {
lua_register(L, "spriteSetAlpha", apiSpriteSetAlpha); // 3.00 lua_register(L, "spriteSetAlpha", apiSpriteSetAlpha); // 3.00
lua_register(L, "spriteSetBlend", apiSpriteSetBlend); // 3.00 lua_register(L, "spriteSetBlend", apiSpriteSetBlend); // 3.00
lua_register(L, "spriteSetFrame", apiSpriteSetFrame); // 2.10 Handle first since 3.00. lua_register(L, "spriteSetFrame", apiSpriteSetFrame); // 2.10 Handle first since 3.00.
lua_register(L, "spriteSetPixels", apiSpriteSetPixels); // 3.00
lua_register(L, "spriteSetTint", apiSpriteSetTint); // 3.00 lua_register(L, "spriteSetTint", apiSpriteSetTint); // 3.00
lua_register(L, "spriteUnload", apiSpriteUnload); // 2.00 lua_register(L, "spriteUnload", apiSpriteUnload); // 2.00
@ -7444,7 +7590,7 @@ static SDL_Surface *_renderText(lua_State *L, const char *method, const char *me
_luaDie(L, method, "%s", SDL_GetError()); _luaDie(L, method, "%s", SDL_GetError());
} }
_surfaceUnpack(&surface); _surfaceUnpack(&surface);
SDL_SetSurfaceColorKey(surface, true, COLOR_KEY_VALUE); _surfaceKey(surface);
return surface; return surface;
} }
@ -7484,7 +7630,7 @@ static SDL_Surface *_renderTextCached(lua_State *L, const char *method, const ch
if (_global.fontQuality == FONT_QUALITY_SHADED) { if (_global.fontQuality == FONT_QUALITY_SHADED) {
background = _global.colorBackground; background = _global.colorBackground;
} }
snprintf(_global.textCacheKey, _global.textCacheKeyBytes, "%d %d %02x%02x%02x%02x %02x%02x%02x%02x %s", _global.fontCurrent->id, (int32_t)_global.fontQuality, foreground.r, foreground.g, foreground.b, foreground.a, background.r, background.g, background.b, background.a, message); snprintf(_global.textCacheKey, _global.textCacheKeyBytes, "%d %d %d %02x%02x%02x%02x %02x%02x%02x%02x %s", _global.fontCurrent->id, (int32_t)_global.fontQuality, (int32_t)_global.spriteKeyed, foreground.r, foreground.g, foreground.b, foreground.a, background.r, background.g, background.b, background.a, message);
HASH_FIND_STR(_global.textCache, _global.textCacheKey, entry); HASH_FIND_STR(_global.textCache, _global.textCacheKey, entry);
if (entry == NULL) { if (entry == NULL) {
// Rendered first, so a font that fails raises its Lua error with nothing half added. // Rendered first, so a font that fails raises its Lua error with nothing half added.
@ -7558,6 +7704,7 @@ static void _resetScriptState(void) {
_global.colorForeground.a = SDL_ALPHA_OPAQUE; _global.colorForeground.a = SDL_ALPHA_OPAQUE;
memset(&_global.colorBackground, 0, sizeof(_global.colorBackground)); memset(&_global.colorBackground, 0, sizeof(_global.colorBackground));
_global.fontQuality = FONT_QUALITY_SOLID; _global.fontQuality = FONT_QUALITY_SOLID;
_global.spriteKeyed = true;
_global.keyboardMode = KEYBOARD_NORMAL; _global.keyboardMode = KEYBOARD_NORMAL;
_global.listenerNode = LISTENER_CAMERA; _global.listenerNode = LISTENER_CAMERA;
_global.pauseEnabled = true; _global.pauseEnabled = true;
@ -8417,7 +8564,7 @@ static void _spriteAdoptAnimation(SpriteT *sprite) {
for (x = 0; x < sprite->animation->count; x++) { for (x = 0; x < sprite->animation->count; x++) {
_surfaceUnpack(&sprite->animation->frames[x]); _surfaceUnpack(&sprite->animation->frames[x]);
SDL_SetSurfaceColorKey(sprite->animation->frames[x], true, COLOR_KEY_VALUE); _surfaceKey(sprite->animation->frames[x]);
sprite->loopMs += (uint64_t)SDL_max(sprite->animation->delays[x], ANIMATION_MIN_DELAY_MS); sprite->loopMs += (uint64_t)SDL_max(sprite->animation->delays[x], ANIMATION_MIN_DELAY_MS);
} }
sprite->originalSurface = sprite->animation->frames[0]; sprite->originalSurface = sprite->animation->frames[0];
@ -8503,7 +8650,7 @@ static SpriteT *_spriteLoadFromIO(lua_State *L, const char *method, SDL_IOStream
} else { } else {
_surfaceUnpack(&sprite->originalSurface); _surfaceUnpack(&sprite->originalSurface);
SDL_CloseIO(io); SDL_CloseIO(io);
SDL_SetSurfaceColorKey(sprite->originalSurface, true, COLOR_KEY_VALUE); _surfaceKey(sprite->originalSurface);
_spriteRegister(sprite); _spriteRegister(sprite);
return sprite; return sprite;
} }
@ -8531,7 +8678,7 @@ static SpriteT *_spriteLoadFromIO(lua_State *L, const char *method, SDL_IOStream
} }
if (sprite->animation == NULL) { if (sprite->animation == NULL) {
// An animation's frames were keyed above, this one included. // An animation's frames were keyed above, this one included.
SDL_SetSurfaceColorKey(sprite->originalSurface, true, COLOR_KEY_VALUE); _surfaceKey(sprite->originalSurface);
} }
_spriteRegister(sprite); _spriteRegister(sprite);
@ -9118,6 +9265,16 @@ static uint32_t _surfaceCornerKey(SDL_Surface *surface) {
} }
// Colour 0 drawn transparent on a new sprite or text -- palette index 0, or black whatever its alpha,
// since SDL matches a key against the colour bits alone -- unless drawTransparent(true) said to go
// by alpha alone. Hypseus keys the same way, and its games are drawn for it.
static void _surfaceKey(SDL_Surface *surface) {
if (_global.spriteKeyed) {
SDL_SetSurfaceColorKey(surface, true, COLOR_KEY_VALUE);
}
}
// Every surface the engine keeps is 8 bits per channel: SDL3_image keeps 1, 2, and 4 bit indexed // Every surface the engine keeps is 8 bits per channel: SDL3_image keeps 1, 2, and 4 bit indexed
// PNGs packed, and blits from those are unreliable, so they expand to one byte per pixel as SDL2 // PNGs packed, and blits from those are unreliable, so they expand to one byte per pixel as SDL2
// always produced; anything else that is not an 8 bit indexed, packed 8888 or byte array format // always produced; anything else that is not an 8 bit indexed, packed 8888 or byte array format
@ -9159,12 +9316,9 @@ static void _surfaceUnpack(SDL_Surface **surface) {
// Must be called before SDL_RenderPresent for the frame being captured. // Must be called before SDL_RenderPresent for the frame being captured.
static void _takeScreenshot(void) { static void _takeScreenshot(void) {
int32_t x = 0; int32_t x = 0;
int32_t logicalW = 0;
int32_t logicalH = 0;
char *filename = NULL; char *filename = NULL;
SDL_Surface *surface = NULL; SDL_Surface *surface = NULL;
SDL_Surface *rgb = NULL; SDL_Surface *rgb = NULL;
SDL_RendererLogicalPresentation mode = SDL_LOGICAL_PRESENTATION_DISABLED;
if (_global.screenshotName != NULL) { if (_global.screenshotName != NULL) {
// A shot the script named goes where it said, over whatever was there. // A shot the script named goes where it said, over whatever was there.
@ -9186,15 +9340,7 @@ static void _takeScreenshot(void) {
_global.nextScreenshot = x + 1; _global.nextScreenshot = x + 1;
} }
// Read the whole window, letterbox included: with a logical size set, a NULL rect would surface = _readWindow();
// only cover the scaled viewport, so switch it off around the read.
SDL_GetRenderLogicalPresentation(_global.renderer, &logicalW, &logicalH, &mode);
SDL_SetRenderLogicalPresentation(_global.renderer, 0, 0, SDL_LOGICAL_PRESENTATION_DISABLED);
surface = SDL_RenderReadPixels(_global.renderer, NULL);
SDL_SetRenderLogicalPresentation(_global.renderer, logicalW, logicalH, mode);
if (surface == NULL) {
utilDie("%s", SDL_GetError());
}
rgb = SDL_ConvertSurface(surface, SDL_PIXELFORMAT_RGB24); rgb = SDL_ConvertSurface(surface, SDL_PIXELFORMAT_RGB24);
if (rgb == NULL) { if (rgb == NULL) {
utilDie("%s", SDL_GetError()); utilDie("%s", SDL_GetError());
@ -10239,12 +10385,12 @@ static int32_t apiDebugPrint(lua_State *L) {
// discAudio(channel, enabled) Channel 1 is left, 2 is right. // discAudio(channel, enabled) Channel 1 is left, 2 is right.
// drawTransparent(boolean) // drawTransparent(boolean)
// Accepted and ignored. It decides whether Hypseus sets colour key 0 on the sprites loaded after // As Hypseus: true draws the sprites and text made after it by their alpha alone, and false (the
// it, which is how that engine carries transparency. Singe carries it in the alpha channel, as // start) keys colour 0 on them as well, which drops opaque black from a picture with alpha too.
// spriteResetColorKey already documents, so there is no key to turn on or off.
static int32_t apiDrawTransparent(lua_State *L) { static int32_t apiDrawTransparent(lua_State *L) {
_argCheck(L, "drawTransparent", 1, 1); _argCheck(L, "drawTransparent", 1, 1);
_argBoolean(L, "drawTransparent", 1); _global.spriteKeyed = !_argBoolean(L, "drawTransparent", 1);
_luaTrace(L, "drawTransparent", "%d", !_global.spriteKeyed);
return 0; return 0;
} }
@ -13545,6 +13691,22 @@ static int32_t apiNodeSetBillboard(lua_State *L) {
} }
// nodeSetLayer(node, LAYER_*): the node and its children drawn in the scene, or in front of the overlay
static int32_t apiNodeSetLayer(lua_State *L) {
int32_t node = 0;
int32_t layer = 0;
_argCheck(L, "nodeSetLayer", 2, 2);
node = _argNode(L, "nodeSetLayer", 1);
layer = _argInteger(L, "nodeSetLayer", 2);
if ((layer != LAYER_SCENE) && (layer != LAYER_FRONT)) {
_luaDie(L, "nodeSetLayer", "Layer must be LAYER_SCENE or LAYER_FRONT.");
}
nodeSetLayer(node, (SceneLayerE)layer);
return 0;
}
// nodeSetMaterial(node, material): a new material on whatever mesh the node has (nil for none) // nodeSetMaterial(node, material): a new material on whatever mesh the node has (nil for none)
static int32_t apiNodeSetMaterial(lua_State *L) { static int32_t apiNodeSetMaterial(lua_State *L) {
int32_t material = -1; int32_t material = -1;
@ -15436,6 +15598,15 @@ static int32_t apiSingeHasGpu(lua_State *L) {
} }
// recording = singeIsRecording()
static int32_t apiSingeIsRecording(lua_State *L) {
_argCheck(L, "singeIsRecording", 0, 0);
lua_pushboolean(L, recordIsRecording() || (_global.recordRequest == RECORD_REQUEST_START));
return 1;
}
// result = singePack(directory, database) // result = singePack(directory, database)
// //
// The packer that --pack runs, from a script: what lets an authoring tool release a game without // The packer that --pack runs, from a script: what lets an authoring tool release a game without
@ -15464,6 +15635,43 @@ static int32_t apiSingeQuit(lua_State *L) {
} }
// singeRecordStart([name]) Starts writing the picture and the sound to the next singeNNN.mp4, or to
// a file of the given name (.mp4 added when it has no extension), in the data directory, from the
// next frame drawn. Ignored while a recording is running.
static int32_t apiSingeRecordStart(lua_State *L) {
const char *name = NULL;
_argCheck(L, "singeRecordStart", 0, 1);
free(_global.recordName);
_global.recordName = NULL;
if (lua_gettop(L) >= 1) {
name = _argString(L, "singeRecordStart", 1);
if ((strchr(name, '/') != NULL) || (strchr(name, '\\') != NULL) || (name[0] == '\0')) {
_luaDie(L, "singeRecordStart", "A recording name is a file name, not a path: %s", name);
}
_global.recordName = (strrchr(name, '.') != NULL) ? utilCreateString("%s", name) : utilCreateString("%s.mp4", name);
}
_luaTrace(L, "singeRecordStart", "Recording requested: %s", (_global.recordName != NULL) ? _global.recordName : "next");
_global.recordRequest = RECORD_REQUEST_START;
_global.refreshDisplay = true;
return 0;
}
// singeRecordStop() Finishes the recording at the next frame drawn.
static int32_t apiSingeRecordStop(lua_State *L) {
_argCheck(L, "singeRecordStop", 0, 0);
_luaTrace(L, "singeRecordStop", "Stop requested.");
if (recordIsRecording()) {
_global.recordRequest = RECORD_REQUEST_STOP;
_global.refreshDisplay = true;
}
return 0;
}
// SDL ranks gamepad mappings: its own database lowest, then SDL_AddGamepadMapping, where // SDL ranks gamepad mappings: its own database lowest, then SDL_AddGamepadMapping, where
// _loadGamepadDatabase puts a gamecontrollerdb.txt, and SDL_GAMECONTROLLERCONFIG highest. A system // _loadGamepadDatabase puts a gamecontrollerdb.txt, and SDL_GAMECONTROLLERCONFIG highest. A system
// that sets that variable -- ArkOS does, with a mapping for its own pad that leaves Select and Start // that sets that variable -- ArkOS does, with a mapping for its own pad that leaves Select and Start
@ -16593,6 +16801,34 @@ static int32_t apiSpriteLoop(lua_State *L) {
} }
// id = spriteNew(width, height) A blank sprite, every pixel transparent, for spriteSetPixels to
// fill: a picture a script makes itself, a pixel at a time.
static int32_t apiSpriteNew(lua_State *L) {
int32_t width = 0;
int32_t height = 0;
SpriteT *sprite = NULL;
_argCheck(L, "spriteNew", 2, 2);
width = _argInteger(L, "spriteNew", 1);
height = _argInteger(L, "spriteNew", 2);
if ((width < 1) || (height < 1) || (width > SPRITE_NEW_MAX) || (height > SPRITE_NEW_MAX)) {
_luaDie(L, "spriteNew", "A new sprite is 1 to %d pixels each way, not %dx%d.", SPRITE_NEW_MAX, width, height);
}
sprite = _spriteNew(L, "spriteNew");
sprite->originalSurface = SDL_CreateSurface(width, height, SDL_PIXELFORMAT_RGBA32);
if (sprite->originalSurface == NULL) {
free(sprite);
_luaDie(L, "spriteNew", "%s", SDL_GetError());
}
SDL_FillSurfaceRect(sprite->originalSurface, NULL, 0);
_spriteRegister(sprite);
_luaTrace(L, "spriteNew", "%d %dx%d", sprite->id, width, height);
lua_pushinteger(L, sprite->id);
return 1;
}
// spritePause(id) // spritePause(id)
static int32_t apiSpritePause(lua_State *L) { static int32_t apiSpritePause(lua_State *L) {
SpriteT *sprite = NULL; SpriteT *sprite = NULL;
@ -16806,6 +17042,82 @@ static int32_t apiSpriteSetFrame(lua_State *L) {
} }
// spriteSetPixels(id, pixels[, palette]) Replaces every pixel of a still sprite from a string, a
// row at a time from the top: four bytes a pixel, red, green, blue, and alpha, or with a palette one
// byte a pixel, each the number of a colour in it. The palette is 256 colours of three bytes (red,
// green, blue, all opaque) or four (with alpha). Changing only the palette and setting the same
// pixels again is palette cycling.
static int32_t apiSpriteSetPixels(lua_State *L) {
SpriteT *sprite = NULL;
SDL_Surface *surface = NULL;
const uint8_t *pixels = NULL;
const uint8_t *palette = NULL;
size_t length = 0;
size_t colours = 0;
size_t count = 0;
int32_t stride = 0;
int32_t x = 0;
int32_t y = 0;
uint8_t *row = NULL;
const uint8_t *colour = NULL;
_argCheck(L, "spriteSetPixels", 2, 3);
sprite = _argSprite(L, "spriteSetPixels", 1);
pixels = (const uint8_t *)_argData(L, "spriteSetPixels", 2, &length);
surface = sprite->originalSurface;
if (sprite->animation != NULL) {
_luaDie(L, "spriteSetPixels", "Sprite %d is an animation; only a still sprite takes new pixels.", sprite->id);
}
if (surface->format != SDL_PIXELFORMAT_RGBA32) {
// A loaded picture is whatever format it decoded to; it becomes plain RGBA the first time.
surface = SDL_ConvertSurface(sprite->originalSurface, SDL_PIXELFORMAT_RGBA32);
if (surface == NULL) {
_luaDie(L, "spriteSetPixels", "%s", SDL_GetError());
}
SDL_DestroySurface(sprite->originalSurface);
if (sprite->surface == sprite->originalSurface) {
sprite->surface = surface;
}
sprite->originalSurface = surface;
}
count = (size_t)surface->w * surface->h;
if (lua_gettop(L) >= 3) {
palette = (const uint8_t *)_argData(L, "spriteSetPixels", 3, &colours);
stride = (int32_t)(colours / PALETTE_COLOURS);
if (((stride != 3) && (stride != 4)) || (colours % PALETTE_COLOURS != 0)) {
_luaDie(L, "spriteSetPixels", "A palette is %d colours of 3 or 4 bytes, not %zu bytes.", PALETTE_COLOURS, colours);
}
if (length != count) {
_luaDie(L, "spriteSetPixels", "Sprite %d is %dx%d, so it takes %zu bytes with a palette, not %zu.", sprite->id, surface->w, surface->h, count, length);
}
} else if (length != count * 4) {
_luaDie(L, "spriteSetPixels", "Sprite %d is %dx%d, so it takes %zu bytes, not %zu.", sprite->id, surface->w, surface->h, count * 4, length);
}
SDL_LockSurface(surface);
for (y = 0; y < surface->h; y++) {
row = (uint8_t *)surface->pixels + (size_t)y * surface->pitch;
if (palette == NULL) {
memcpy(row, pixels + (size_t)y * surface->w * 4, (size_t)surface->w * 4);
continue;
}
for (x = 0; x < surface->w; x++) {
colour = palette + (size_t)pixels[(size_t)y * surface->w + x] * stride;
row[x * 4] = colour[0];
row[x * 4 + 1] = colour[1];
row[x * 4 + 2] = colour[2];
row[x * 4 + 3] = (stride == 4) ? colour[3] : 255;
}
}
SDL_UnlockSurface(surface);
if (sprite->surface != sprite->originalSurface) {
_spriteRebuildSurface(sprite);
}
_luaTrace(L, "spriteSetPixels", "%d %zu bytes%s", sprite->id, length, (palette != NULL) ? " with a palette" : "");
return 0;
}
// spriteSetTint(id, r, g, b) Multiplies every pixel's colour by r, g, b / 255 on every draw of the // spriteSetTint(id, r, g, b) Multiplies every pixel's colour by r, g, b / 255 on every draw of the
// sprite: a white sprite drawn in any colour, or any sprite darkened or shifted, with no new image. // sprite: a white sprite drawn in any colour, or any sprite darkened or shifted, with no new image.
// 255, 255, 255, the default, draws it as it is. Out-of-range values are clamped. // 255, 255, 255, the default, draws it as it is. Out-of-range values are clamped.
@ -18404,6 +18716,7 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co
SDL_FRect rotateTarget; SDL_FRect rotateTarget;
SDL_FRect screenRect; SDL_FRect screenRect;
SDL_Texture *sceneTexture = NULL; SDL_Texture *sceneTexture = NULL;
SDL_Texture *frontTexture = NULL;
SDL_Surface *overlayShown = NULL; SDL_Surface *overlayShown = NULL;
int32_t presentWidth = 0; int32_t presentWidth = 0;
int32_t presentHeight = 0; int32_t presentHeight = 0;
@ -18446,6 +18759,10 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co
// --deterministic: the virtual clock and the fixed random stream, both settled before anything // --deterministic: the virtual clock and the fixed random stream, both settled before anything
// that reads either. The step doubles as the seed, so one number describes the whole run. // that reads either. The step doubles as the seed, so one number describes the whole run.
utilDeterministic(_global.conf->deterministic, (uint32_t)_global.conf->deterministicStep); utilDeterministic(_global.conf->deterministic, (uint32_t)_global.conf->deterministicStep);
if (_global.conf->recordFile != NULL) {
_global.recordRequest = RECORD_REQUEST_START;
_global.generatedAudio = _global.conf->deterministic;
}
if (_global.conf->deterministic) { if (_global.conf->deterministic) {
SDL_srand((uint64_t)_global.conf->deterministicStep); SDL_srand((uint64_t)_global.conf->deterministicStep);
_progTrace("Deterministic mode: %d ms a frame, generators seeded with %d", _global.conf->deterministicStep, _global.conf->deterministicStep); _progTrace("Deterministic mode: %d ms a frame, generators seeded with %d", _global.conf->deterministicStep, _global.conf->deterministicStep);
@ -18691,6 +19008,9 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co
// One frame of simulated time, the only place the virtual clock moves. Off it, nothing happens. // One frame of simulated time, the only place the virtual clock moves. Off it, nothing happens.
utilTickAdvance(); utilTickAdvance();
if (_global.generatedAudio) {
_generateAudio();
}
// A reload asked for by the script, F5 or a changed file happens here, between frames. // A reload asked for by the script, F5 or a changed file happens here, between frames.
if (_global.conf->reload && _watchedChanged()) { if (_global.conf->reload && _watchedChanged()) {
@ -19140,6 +19460,11 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co
} }
_overlayVideosDraw(&_global.videoRect); _overlayVideosDraw(&_global.videoRect);
SDL_RenderTexture(_global.renderer, _global.overlayTexture, NULL, &_global.videoRect); SDL_RenderTexture(_global.renderer, _global.overlayTexture, NULL, &_global.videoRect);
// The scene's front layer stands on the overlay, beneath the GUIs and the particles over it.
frontTexture = sceneFront();
if (frontTexture != NULL) {
SDL_RenderTexture(_global.renderer, frontTexture, NULL, &_global.videoRect);
}
_subtitleQueue(); _subtitleQueue();
stageStartNS = SDL_GetTicksNS(); stageStartNS = SDL_GetTicksNS();
_drawGuis(&_global.videoRect, false); _drawGuis(&_global.videoRect, false);
@ -19181,6 +19506,8 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co
_progTrace("Taking screenshot"); _progTrace("Taking screenshot");
_takeScreenshot(); _takeScreenshot();
} }
// Record it?
_recordUpdate();
// The developer's overlay goes on after the screenshot, so a reference shot is of the // The developer's overlay goes on after the screenshot, so a reference shot is of the
// game rather than of the overlay. // game rather than of the overlay.
_statsDraw(); _statsDraw();
@ -19220,6 +19547,11 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co
// End game // End game
_progTrace("Script is shutting down"); _progTrace("Script is shutting down");
_callLua("onShutdown", ""); _callLua("onShutdown", "");
recordStop();
free(_global.recordName);
_global.recordName = NULL;
free(_global.generatedBuffer);
_global.generatedBuffer = NULL;
// Anything the script saved on its way out, including in onShutdown itself. // Anything the script saved on its way out, including in onShutdown itself.
_saveWrite(); _saveWrite();

View file

@ -109,6 +109,7 @@ typedef struct ConfigS {
char *keymapFile; // --keymapfile: the controls.cfg to read in place of the four place search char *keymapFile; // --keymapfile: the controls.cfg to read in place of the four place search
char *audioSuffix; // --altaudio: the disc audio suffix discAudioSuffix takes, applied at startup char *audioSuffix; // --altaudio: the disc audio suffix discAudioSuffix takes, applied at startup
char *soundfont; // --soundfont: the .sf2 MIDI files are synthesised with, in place of the search char *soundfont; // --soundfont: the .sf2 MIDI files are synthesised with, in place of the search
char *recordFile; // --record: the MP4 the whole run is written to; with --deterministic, frame exact
DeinterlaceE deinterlace; // --deinterlace: what an interlaced picture gets, automatic by default DeinterlaceE deinterlace; // --deinterlace: what an interlaced picture gets, automatic by default
bool sceneLow; // --scenequality low: half resolution, no antialiasing and no bloom, whatever the script asks bool sceneLow; // --scenequality low: half resolution, no antialiasing and no bloom, whatever the script asks
char *gamepadOrder; // --gamepad_reorder: enumeration positions, one per gamepad slot, as Hypseus writes them char *gamepadOrder; // --gamepad_reorder: enumeration positions, one per gamepad slot, as Hypseus writes them

View file

@ -2525,7 +2525,8 @@ bool videoGetYUVPixel(int32_t playerHandle, int32_t x, int32_t y, uint8_t *luma,
} }
void videoInit(MIX_Mixer *mixer) { // generated: the mixer mixes into memory when the engine asks, so there is no device queue.
void videoInit(MIX_Mixer *mixer, bool generated) {
uint64_t started = 0; uint64_t started = 0;
// Fetch mixer settings // Fetch mixer settings
@ -2533,6 +2534,11 @@ void videoInit(MIX_Mixer *mixer) {
if (!MIX_GetMixerFormat(_mixer, &_mixSpec)) { if (!MIX_GetMixerFormat(_mixer, &_mixSpec)) {
utilDie("%s", SDL_GetError()); utilDie("%s", SDL_GetError());
} }
if (generated) {
_mixLatencyMs = 0;
utilTrace("Audio device queue: none, the sound is mixed into memory a step at a time");
return;
}
// Measure the device queue. SDL cannot report how much audio sits between the mixer and the // Measure the device queue. SDL cannot report how much audio sits between the mixer and the
// speaker, but it refills an empty device in a burst, and the burst is that amount. Nothing // speaker, but it refills an empty device in a burst, and the burst is that amount. Nothing

View file

@ -73,7 +73,7 @@ bool videoGetPixels(int32_t playerHandle, const uint8_t **pixels, int32_t
void videoGetVolume(int32_t playerHandle, int32_t *leftPercent, int32_t *rightPercent); void videoGetVolume(int32_t playerHandle, int32_t *leftPercent, int32_t *rightPercent);
int32_t videoGetWidth(int32_t playerHandle); int32_t videoGetWidth(int32_t playerHandle);
bool videoGetYUVPixel(int32_t playerHandle, int32_t x, int32_t y, uint8_t *luma, uint8_t *cb, uint8_t *cr); bool videoGetYUVPixel(int32_t playerHandle, int32_t x, int32_t y, uint8_t *luma, uint8_t *cb, uint8_t *cr);
void videoInit(MIX_Mixer *mixer); void videoInit(MIX_Mixer *mixer, bool generated);
// What every video opened from now on does about interlaced pictures. // What every video opened from now on does about interlaced pictures.
void videoSetDeinterlace(DeinterlaceE mode); void videoSetDeinterlace(DeinterlaceE mode);

View file

@ -7,7 +7,8 @@
# DEMO_CAPTURE (demos/kit.lua), so the clock moves exactly one 20 ms step a frame and every frame # DEMO_CAPTURE (demos/kit.lua), so the clock moves exactly one 20 ms step a frame and every frame
# (or every "every"th) is saved. With every = 1 the frames become out.mp4 at 50 frames a second # (or every "every"th) is saved. With every = 1 the frames become out.mp4 at 50 frames a second
# with the same stretch of the demo's music. Nothing appears on screen. The frames are left in # with the same stretch of the demo's music. Nothing appears on screen. The frames are left in
# $SCRATCH/frames for looking at; SCRATCH defaults to a folder under /tmp. # $SCRATCH/frames for looking at; SCRATCH defaults to a folder under /tmp. A whole demo to MP4 is
# far quicker with util/demoRecord.sh, which uses Singe's own recorder.
set -euo pipefail set -euo pipefail
DEMO=$1 DEMO=$1

19
util/demoLogos.sh Executable file
View file

@ -0,0 +1,19 @@
#!/bin/bash
# Renders the Singe and Kangaroo Punch logos from their layered sources in assets/ into demos/art/,
# at the demos' width, for the cards every demo opens and closes with (demos/kit.lua, K.run). The
# command is the one CMakeLists.txt renders the embedded copies with, then scaled down once here so
# the demos draw them without scaling. The closing card leaves out the Kangaroo Punch logo's
# "Proudly Presents..." line, layer 1: nothing follows it.
set -euo pipefail
REPO=$(cd "$(dirname "$0")/.." && pwd)
SIZE=1920x1080
FIT=640x360
render() {
convert -background "rgba(0,0,0,0)" -size $SIZE xc:none "$1" -flatten -resize $FIT "$2"
}
mkdir -p "$REPO/demos/art"
render "$REPO/assets/singeLogo.xcf" "$REPO/demos/art/singeLogo.png"
render "$REPO/assets/kangarooPunchLogo.xcf[0,2,3]" "$REPO/demos/art/kangarooPunchLogo.png"

24
util/demoRecord.sh Executable file
View file

@ -0,0 +1,24 @@
#!/bin/bash
# Records one of the demos, start to finish, to an MP4 with its music, using Singe's own recorder:
#
# util/demoRecord.sh neonCoast screenshots/demos/neonCoast.mp4
#
# The demos folder is copied to a scratch folder and run from there headless with --deterministic
# and --record, so every frame is kept and the sound is mixed in step with the picture. Nothing
# appears on screen and nothing is heard. SCRATCH defaults to a folder under /tmp. For stretches
# of a demo as pictures to look at, see util/demoCapture.sh.
set -euo pipefail
DEMO=$1
OUT=$(realpath -m "$2")
STEP=20
REPO=$(cd "$(dirname "$0")/.." && pwd)
BIN=$(ls "$REPO"/.builddir/Singe-v*-Linux-x86_64 | head -1)
SCRATCH=${SCRATCH:-/tmp/demoRecord-$DEMO}
rm -rf "$SCRATCH"
mkdir -p "$SCRATCH/run"
cp -r "$REPO/demos/." "$SCRATCH/run/"
cd "$SCRATCH/run"
SDL_VIDEODRIVER=offscreen SDL_AUDIO_DRIVER=dummy timeout -k 10 7200 "$BIN" -k -w -C 640x480 -x 1024 -y 768 --deterministic=$STEP --record="$OUT" -p -d "$SCRATCH/data" "$DEMO/$DEMO.singe" > "$SCRATCH/run.log" 2>&1 || true
grep -E "Recording|Recorded|rror" "$SCRATCH/run.log" || true

View file

@ -1,144 +0,0 @@
# Precalculates the demos' per-pixel effects as videos: the oldest trick in the demoscene, done with
# numpy instead of lookup tables in assembler. Singe then plays them, scrubs them, turns them, and
# puts them on 3D surfaces.
#
# python3 util/demoVideo.py tunnel out.mp4 [palette] [frames]
# python3 util/demoVideo.py plasma out.mp4 [palette] [frames] [fps]
# python3 util/demoVideo.py noise out.mp4 [frames]
#
# tunnel draws exactly one period of travel down a texture-mapped tunnel in frames frames, every one a
# keyframe, so a demo can seek to any frame at once and move through the tunnel at whatever speed the
# music says; the last frame leads back into the first. plasma is an ordinary looping video whose
# every term repeats a whole number of times in its length, so it loops without a seam. noise is a
# tape's snow and tracking bands, also looping.
import math
import subprocess
import sys
import numpy as np
W = 640
H = 480
PALETTES = {
# Neon: black, violet, magenta, orange, and a cyan accent.
"neon": [(0.00, (4, 0, 16)), (0.25, (60, 10, 120)), (0.50, (255, 30, 170)), (0.70, (255, 140, 60)), (0.85, (255, 230, 150)), (1.00, (40, 230, 255))],
# Disco: deep red, gold, and white.
"disco": [(0.00, (20, 0, 10)), (0.30, (150, 20, 40)), (0.55, (255, 150, 30)), (0.80, (255, 230, 140)), (1.00, (255, 255, 255))],
# Arcade: blue, green, and yellow.
"arcade": [(0.00, (0, 0, 20)), (0.30, (10, 40, 160)), (0.55, (20, 200, 120)), (0.80, (240, 240, 60)), (1.00, (255, 255, 255))],
}
def ramp(name, t):
stops = PALETTES[name]
t = np.clip(t, 0, 1)
out = np.zeros(t.shape + (3,))
for (t0, c0), (t1, c1) in zip(stops[:-1], stops[1:]):
m = (t >= t0) & (t <= t1)
k = ((t - t0) / (t1 - t0))[m][:, None]
out[m] = np.array(c0) * (1 - k) + np.array(c1) * k
return out
def encoder(out, fps, intra):
args = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", "%dx%d" % (W, H), "-r", str(fps), "-i", "-",
"-c:v", "libx264", "-preset", "slow", "-crf", "18", "-pix_fmt", "yuv420p", "-an"]
if intra:
args += ["-g", "1", "-keyint_min", "1"]
else:
args += ["-g", str(int(float(fps)))]
return subprocess.Popen(args + [out], stdin=subprocess.PIPE)
# Down a tunnel: each pixel's angle round the axis and its depth (inverse distance from the centre)
# index a texture of neon rings and spokes. Depth runs one texture period over the frames.
def tunnel(out, palette="neon", frames=120):
y, x = np.mgrid[0:H, 0:W].astype(np.float64)
x = (x - W / 2 + 0.5) / (H / 2)
y = (y - H / 2 + 0.5) / (H / 2)
r = np.sqrt(x * x + y * y) + 1e-6
a = np.arctan2(y, x) / (2 * math.pi)
depth = 0.35 / r
shade = np.clip(r * 1.6, 0, 1) ** 0.8
pipe = encoder(out, 50, True)
RINGS = 4
SPOKES = 16
for f in range(frames):
p = f / frames
u = depth + p / RINGS
v = a + depth * 0.15
ring = 0.5 + 0.5 * np.cos(u * RINGS * 2 * math.pi * RINGS)
edge = np.abs(np.mod(u * RINGS * RINGS, 1.0) - 0.5) * 2
spoke = np.abs(np.mod(v * SPOKES, 1.0) - 0.5) * 2
check = ((np.floor(u * RINGS * RINGS) + np.floor(v * SPOKES)) % 2)
glowRing = np.exp(-(1 - edge) * 9)
glowSpoke = np.exp(-(1 - spoke) * 12)
value = 0.18 + 0.22 * check + 0.75 * glowRing + 0.45 * glowSpoke * ring
col = ramp(palette, np.clip(value * shade, 0, 1))
# Far away the tunnel fades into a hot white core.
core = np.exp(-r * 9)[..., None]
col = col * (1 - core) + core * np.array([255, 235, 255])
pipe.stdin.write(np.clip(col, 0, 255).astype(np.uint8).tobytes())
pipe.stdin.close()
pipe.wait()
# Plasma: four interfering waves, each a whole number of turns over the loop, run through a palette
# that itself cycles a whole number of times.
def plasma(out, palette="neon", frames=400, fps=50):
y, x = np.mgrid[0:H, 0:W].astype(np.float64)
x /= W
y /= H
pipe = encoder(out, fps, False)
for f in range(frames):
p = f / frames * 2 * math.pi
v = np.sin(x * 9 + p)
v += np.sin((y * 7 + p * 2) * 1.0)
v += np.sin((x * 6 + y * 5) + p * 3)
cx = x - 0.5 + 0.3 * math.sin(p)
cy = y - 0.5 + 0.3 * math.cos(p * 2)
v += np.sin(np.sqrt(cx * cx + cy * cy) * 18 - p * 4)
t = (np.sin(v * math.pi * 0.5 + p) + 1) / 2
col = ramp(palette, t)
pipe.stdin.write(np.clip(col, 0, 255).astype(np.uint8).tobytes())
pipe.stdin.close()
pipe.wait()
# A tape's snow, with a couple of tracking bands rolling up the picture, at a quarter of the size
# and scaled up by the player.
def noise(out, frames=100):
global W, H
W, H = 320, 240
rng = np.random.default_rng(11)
pipe = encoder(out, 25, False)
rows = np.arange(H)[:, None]
for f in range(frames):
snow = rng.random((H, W)) ** 1.6 * 200
# Grain in streaks: each row's noise smeared sideways a little.
snow = (snow + np.roll(snow, 1, axis=1) + np.roll(snow, 2, axis=1)) / 3
p = f / frames
band = np.exp(-((rows - (H * (1 - p) * 2) % (H + 40) + 20) ** 2) / 60.0)
band2 = np.exp(-((rows - (H * (1 - p) * 2 + H / 2) % (H + 40) + 20) ** 2) / 20.0)
v = snow * (0.55 + 0.45 * band + 0.6 * band2) + band * 60
col = np.stack([v * 0.95, v * 0.9, v * 1.05], -1)
pipe.stdin.write(np.clip(col, 0, 255).astype(np.uint8).tobytes())
pipe.stdin.close()
pipe.wait()
def main():
kind = sys.argv[1]
out = sys.argv[2]
if kind == "tunnel":
tunnel(out, sys.argv[3] if len(sys.argv) > 3 else "neon", int(sys.argv[4]) if len(sys.argv) > 4 else 120)
elif kind == "plasma":
plasma(out, sys.argv[3] if len(sys.argv) > 3 else "neon", int(sys.argv[4]) if len(sys.argv) > 4 else 400, sys.argv[5] if len(sys.argv) > 5 else 50)
elif kind == "noise":
noise(out, int(sys.argv[3]) if len(sys.argv) > 3 else 100)
print("wrote", out)
if __name__ == "__main__":
main()

View file

@ -19,7 +19,7 @@ INK = {
"W": (255, 255, 255), "K": (20, 20, 30), "R": (230, 40, 40), "r": (140, 20, 30), "O": (255, 150, 30), "W": (255, 255, 255), "K": (20, 20, 30), "R": (230, 40, 40), "r": (140, 20, 30), "O": (255, 150, 30),
"Y": (255, 230, 60), "y": (180, 140, 20), "G": (60, 220, 90), "g": (20, 120, 50), "B": (60, 140, 255), "Y": (255, 230, 60), "y": (180, 140, 20), "G": (60, 220, 90), "g": (20, 120, 50), "B": (60, 140, 255),
"b": (30, 60, 160), "C": (80, 230, 255), "M": (230, 60, 220), "P": (150, 70, 230), "S": (200, 200, 210), "b": (30, 60, 160), "C": (80, 230, 255), "M": (230, 60, 220), "P": (150, 70, 230), "S": (200, 200, 210),
"s": (120, 120, 135), "T": (240, 190, 140), "t": (170, 110, 70), "s": (120, 120, 135), "T": (240, 190, 140), "t": (170, 110, 70), "k": (60, 60, 70), "q": (90, 90, 100),
} }
@ -246,18 +246,43 @@ SHIP = [
"................", "................",
] ]
KART = [ # The racer's kart from behind, the left half: the right is its mirror. B, b, and h are the body,
"....KKKKKKKK....", # its shade, and its highlight, which kartSheet() paints in each team's colours; the rest is fixed.
"...KRRRRRRRRK...", KART_HALF = [
"..KRRWWWWWWRRK..", "......kkkkkkkkkkkkkkkkkk",
"..KRRRRRRRRRRK..", "......khhhhhhhhhhhhhhhhh",
".KKKKKRRRRKKKKK.", "......kBBBBBBBBBBBBBBBBB",
"KKKKKRRRRRRKKKKK", "......kBBBBBBBBBBBBBBBBB",
"KsKKRRRRRRRRKKsK", "......kbbbbbbbbbbbbbbbbb",
"KsKKKKKKKKKKKKsK", "......kkkkkkkkkkkkkkkkkk",
"KKK.SSSSSSSS.KKK", "..............kk........",
"KKK.........KKKK", "..............kk......yy",
"..............kk.....yYY",
"..............kk....yYYY",
"..............kk....yKKK",
"..............kk....yKKW",
"...............hhhhhhhhh",
"..KKKKKKKKK..hBBBBBBBBBB",
".KkkkkkkkkkK.hBBBBBBBBBB",
".KkqqqqqqqkKhBBBBBBBBBBB",
".KkqSSSSSqkKhBBBBBBBbbBB",
".KkqqqqqqqkKhBBBBBBBWWWW",
".KkqSSSSSqkKhBBBBBBBWWWW",
".KkqqqqqqqkKbBBBBBBBWWWW",
".KkqSSSSSqkKbbBBBBBBBBBB",
".KkqqqqqqqkKkbbbbbbbbbbb",
".KkqSSSSSqkKkkkkkkkkkkkk",
".KkqqqqqqqkK....SSSSSSSS",
".KkqSSSSSqkK....SkSkSkSS",
".KkqqqqqqqkK....SSSSSRRR",
".KkkkkkkkkkK....SSSSSRRR",
"..KKKKKKKKK.....kkkkkkkk",
"........................",
] ]
KART_W = len(KART_HALF[0]) * 2
# The teams: the player's red first, then the rivals.
KART_TEAMS = [((230, 40, 40), (140, 20, 30)), ((40, 110, 240), (20, 50, 140)), ((250, 210, 40), (170, 120, 10)),
((40, 200, 90), (20, 110, 50)), ((180, 70, 230), (100, 30, 140)), ((250, 140, 40), (160, 70, 20))]
# The boss, drawn larger and smoothly: a core, an arm, and a turret, each turned by the demo. # The boss, drawn larger and smoothly: a core, an arm, and a turret, each turned by the demo.
@ -398,6 +423,18 @@ def font():
return img return img
# The same glyphs as a Lua table, for the 3D scroller's block letters: rows top down, "#" lit.
def fontLua():
lines = ["-- Insert Coin's pixel font, written by util/insertCoinArt.py from its GLYPHS: rows top down, \"#\" lit.", "return {"]
for ch in sorted(GLYPHS):
rows = ", ".join('"%s"' % r for r in GLYPHS[ch])
key = ch.replace("\\", "\\\\").replace('"', '\\"')
lines.append('\t["%s"] = { %s },' % (key, rows))
lines.append("}")
with open(os.path.join(OUT, "font.lua"), "w") as f:
f.write("\n".join(lines) + "\n")
# Parallax layers for the side-scroller, each tiling sideways: far mountains, near hills, and a # Parallax layers for the side-scroller, each tiling sideways: far mountains, near hills, and a
# ground strip of grass over brick. # ground strip of grass over brick.
def mountains(w=320, h=110): def mountains(w=320, h=110):
@ -525,6 +562,115 @@ def scanlines():
return img return img
def kartSheet():
frames = []
for body, shade in KART_TEAMS:
light = tuple(min(255, int(c * 1.25 + 45)) for c in body)
rows = [half + half[::-1] for half in KART_HALF]
assert all(len(r) == KART_W for r in rows)
img = Image.new("RGBA", (KART_W, len(rows)), (0, 0, 0, 0))
for y, row in enumerate(rows):
for x, c in enumerate(row):
colour = {"B": body, "b": shade, "h": light}.get(c, INK.get(c))
if colour is not None:
img.putpixel((x, y), colour + (255,))
frames.append(img)
return sheet(frames)
# A coin's face: gold, rimmed, with a picture from media/ stamped on it in raised gold. The stamp is
# every pixel of the picture darker than its white background.
def coinFace(source, size=256, fill=0.62):
from scipy import ndimage
pic = Image.open(os.path.join(REPO, "media", source)).convert("L")
box = pic.point(lambda v: 255 if v < 200 else 0).getbbox()
pic = pic.crop(box)
scale = size * fill / max(pic.size)
pic = pic.resize((int(pic.width * scale), int(pic.height * scale)), Image.LANCZOS)
stamp = np.zeros((size, size))
ox = (size - pic.width) // 2
oy = (size - pic.height) // 2
stamp[oy:oy + pic.height, ox:ox + pic.width] = np.clip((220 - np.asarray(pic, dtype=float)) / 60, 0, 1)
y, x = np.mgrid[0:size, 0:size] + 0.5
r = np.sqrt((x - size / 2) ** 2 + (y - size / 2) ** 2) / (size / 2)
base = np.array([220, 165, 40]) * (0.85 + 0.15 * (1 - r))[..., None]
rim = (r > 0.86) & (r < 0.93)
base[rim] = [255, 225, 120]
raised = np.array([255, 230, 130])
edge = np.clip(ndimage.sobel(stamp, 0) + ndimage.sobel(stamp, 1), -2, 2)[..., None]
img = base * (1 - stamp[..., None]) + raised * stamp[..., None] - edge * 25
return Image.fromarray(np.clip(img, 0, 255).astype(np.uint8), "RGB")
# The fighters: two martial artists posed by the angles of their limbs, drawn a pixel at a time at
# FIGHTER_W by FIGHTER_H with no smoothing, so they stay crisp when the demo scales them up. Angles are
# in degrees, 0 pointing the way the fighter faces and positive turning downward.
FIGHTER_W = 40
FIGHTER_H = 48
SKIN = (240, 190, 140)
POSES = {
# lean lead arm rear arm lead leg rear leg
"idle": (0, (60, -70), (75, -80), (70, 95), (110, 85)),
"punch": (10, (0, 0), (80, -85), (65, 100), (120, 80)),
"kick": (-15, (60, -70), (110, -60), (-5, 0), (100, 95)),
"hit": (-25, (200, 230), (220, 250), (80, 120), (105, 70)),
}
def fighter(pose, gi, belt):
lean, lead, rear, legA, legB = POSES[pose]
img = Image.new("RGBA", (FIGHTER_W, FIGHTER_H), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
hip = (18, 30)
torso = 14
neck = (hip[0] + torso * math.sin(math.radians(lean)), hip[1] - torso * math.cos(math.radians(lean)))
def limb(start, a1, a2, l1, l2, colour, width, end):
p1 = (start[0] + l1 * math.cos(math.radians(a1)), start[1] + l1 * math.sin(math.radians(a1)))
p2 = (p1[0] + l2 * math.cos(math.radians(a2)), p1[1] + l2 * math.sin(math.radians(a2)))
d.line([start, p1], fill=colour, width=width)
d.line([p1, p2], fill=colour, width=width)
d.ellipse([p2[0] - 1.5, p2[1] - 1.5, p2[0] + 1.5, p2[1] + 1.5], fill=end)
shade = tuple(int(c * 0.7) for c in gi) + (255,)
shoulder = (neck[0], neck[1] + 2)
limb(shoulder, rear[0], rear[1], 7, 7, shade, 3, SKIN + (255,))
limb(hip, legB[0], legB[1], 9, 9, shade, 4, (30, 30, 30, 255))
d.line([hip, neck], fill=gi + (255,), width=7)
d.line([(hip[0] - 3, hip[1] - 2), (hip[0] + 3, hip[1] - 2)], fill=belt + (255,), width=2)
limb(hip, legA[0], legA[1], 9, 9, gi + (255,), 4, (30, 30, 30, 255))
limb(shoulder, lead[0], lead[1], 7, 7, gi + (255,), 3, SKIN + (255,))
head = (neck[0] + 1, neck[1] - 5)
d.ellipse([head[0] - 4, head[1] - 4, head[0] + 4, head[1] + 4], fill=SKIN + (255,))
d.chord([head[0] - 4, head[1] - 5, head[0] + 4, head[1] + 3], 180, 360, fill=(30, 20, 20, 255))
d.point((head[0] + 2, head[1]), fill=(20, 20, 20, 255))
# A dark outline, the way sprites of the time were drawn.
a = np.array(img)
solid = a[..., 3] > 0
from scipy import ndimage
grown = ndimage.binary_dilation(solid, structure=[[0, 1, 0], [1, 1, 1], [0, 1, 0]])
a[grown & ~solid] = [20, 10, 20, 255]
return Image.fromarray(a)
def fighterSheet(gi, belt, mirror):
frames = [fighter(p, gi, belt) for p in ("idle", "punch", "kick", "hit")]
if mirror:
frames = [f.transpose(Image.FLIP_LEFT_RIGHT) for f in frames]
return sheet(frames)
# A shooting-gallery target: a bullseye of red and white rings on a wooden post.
def target(size=64):
img = Image.new("RGBA", (size, size + 28), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
d.rectangle([size // 2 - 4, size - 6, size // 2 + 3, size + 27], fill=(140, 90, 50, 255), outline=(70, 40, 20, 255))
for k, colour in enumerate([(200, 20, 30), (250, 250, 250), (200, 20, 30), (250, 250, 250), (200, 20, 30)]):
r = size // 2 - 1 - k * 6
d.ellipse([size // 2 - r, size // 2 - r, size // 2 + r, size // 2 + r], fill=colour + (255,), outline=(60, 10, 10, 255))
return img
def main(): def main():
os.makedirs(OUT, exist_ok=True) os.makedirs(OUT, exist_ok=True)
save(sheet([pixels(f) for f in INVADER_A]), "invaderA.png") save(sheet([pixels(f) for f in INVADER_A]), "invaderA.png")
@ -533,12 +679,22 @@ def main():
save(sheet([pixels(f) for f in RUNNER]), "runner.png") save(sheet([pixels(f) for f in RUNNER]), "runner.png")
save(sheet([pixels(f) for f in COIN]), "coin.png") save(sheet([pixels(f) for f in COIN]), "coin.png")
save(pixels(SHIP), "ship.png") save(pixels(SHIP), "ship.png")
save(pixels(KART), "kart.png") karts = kartSheet()
save(karts, "kart.png")
# The rivals, one picture each: a 3D material takes a whole picture.
for i in range(1, len(KART_TEAMS)):
save(karts.crop((i * KART_W, 0, i * KART_W + KART_W, karts.height)), "kart%d.png" % i)
save(coinFace("SingeText.jpeg"), "coinSinge.png")
save(fighterSheet((210, 40, 40), (20, 20, 20), False), "fighterA.png")
save(target(), "target.png")
save(fighterSheet((40, 100, 230), (240, 220, 60), True), "fighterB.png")
save(coinFace("Dragon.jpeg"), "coinDragon.png")
save(bossCore(), "bossCore.png") save(bossCore(), "bossCore.png")
save(bossArm(), "bossArm.png") save(bossArm(), "bossArm.png")
save(turret(), "turret.png") save(turret(), "turret.png")
save(bullet(), "bullet.png") save(bullet(), "bullet.png")
save(font(), "font.png") save(font(), "font.png")
fontLua()
save(mountains(), "mountains.png") save(mountains(), "mountains.png")
save(hills(), "hills.png") save(hills(), "hills.png")
save(ground(), "ground.png") save(ground(), "ground.png")

View file

@ -8,6 +8,8 @@
# floor ran to the horizon in every direction. Chrome then reflects the sun and the grid, which is # floor ran to the horizon in every direction. Chrome then reflects the sun and the grid, which is
# the whole look. # the whole look.
# #
# The car is rendered in 3D by util/neonCoastCar.sh.
#
# Usage: python3 util/neonCoastArt.py # Usage: python3 util/neonCoastArt.py
import math import math
import os import os
@ -175,27 +177,6 @@ def pts(points):
return [(x * SS, y * SS) for x, y in points] return [(x * SS, y * SS) for x, y in points]
# The car from behind, low and wide: a black body traced in magenta, a cyan glint on the rear
# window, a full-width taillight bar, and a glow under it on the road.
def car():
w, h = 176, 80
im = big(w, h)
d = ImageDraw.Draw(im)
d.ellipse(pts([(6, 64), (170, 80)]), fill=(255, 30, 180, 90))
d.rectangle(pts([(16, 58), (40, 76)]), fill=(12, 8, 16, 255))
d.rectangle(pts([(136, 58), (160, 76)]), fill=(12, 8, 16, 255))
body = [(4, 40), (18, 30), (158, 30), (172, 40), (172, 66), (4, 66)]
d.polygon(pts(body), fill=(18, 12, 30, 255), outline=(255, 40, 200, 255), width=2 * SS)
roof = [(38, 30), (52, 8), (124, 8), (138, 30)]
d.polygon(pts(roof), fill=(26, 16, 44, 255), outline=(255, 40, 200, 255), width=2 * SS)
d.polygon(pts([(46, 28), (57, 12), (119, 12), (130, 28)]), fill=(40, 120, 200, 255))
d.polygon(pts([(60, 26), (70, 13), (84, 13), (74, 26)]), fill=(150, 240, 255, 255))
d.rectangle(pts([(10, 42), (166, 50)]), fill=(255, 20, 60, 255))
d.rectangle(pts([(12, 44), (164, 48)]), fill=(255, 150, 160, 255))
d.rectangle(pts([(70, 54), (106, 60)]), fill=(230, 230, 240, 255))
return shrink(im, w, h)
def palm(): def palm():
w, h = 200, 230 w, h = 200, 230
im = big(w, h) im = big(w, h)
@ -205,39 +186,43 @@ def palm():
t = i / 40 t = i / 40
x = 100 + 18 * math.sin(t * 2.2) - 10 * t x = 100 + 18 * math.sin(t * 2.2) - 10 * t
trunk.append((x, 40 + t * 180)) trunk.append((x, 40 + t * 180))
for (x0, y0), (x1, y1) in zip(trunk[:-1], trunk[1:]): for i, ((x0, y0), (x1, y1)) in enumerate(zip(trunk[:-1], trunk[1:])):
wdt = 7 + (y0 - 40) / 180 * 5 wdt = 7 + (y0 - 40) / 180 * 5
d.line(pts([(x0, y0), (x1, y1)]), fill=(10, 4, 16, 255), width=int(wdt * SS)) # A brown trunk, ringed darker every few steps the way a palm's bark is.
shade = 0.75 if (i % 4 == 0) else 1.0
d.line(pts([(x0, y0), (x1, y1)]), fill=(int(120 * shade), int(78 * shade), int(44 * shade), 255), width=int(wdt * SS))
top = trunk[0] top = trunk[0]
dark = (10, 4, 16, 255) green = (34, 120, 52, 255) # The fronds: green, lit from behind by the sunset
for k in range(9): for k in range(9):
ang = -math.pi * 0.98 + k * math.pi * 0.98 / 8 + (0.12 if k % 2 else -0.08) ang = -math.pi * 0.98 + k * math.pi * 0.98 / 8 + (0.12 if k % 2 else -0.08)
length = 78 + 12 * math.sin(k * 1.7) length = 78 + 12 * math.sin(k * 1.7)
spine = [] spine = []
for i in range(17): for n in range(17):
t = i / 16 t = n / 16
spine.append((top[0] + math.cos(ang) * length * t, top[1] + math.sin(ang) * length * t * 0.55 + t * t * 52)) spine.append((top[0] + math.cos(ang) * length * t, top[1] + math.sin(ang) * length * t * 0.55 + t * t * 52))
for i, ((x0, y0), (x1, y1)) in enumerate(zip(spine[:-1], spine[1:])): # The frond as one feathered leaf: its outline runs out along one side and back along the
t = i / 16 # other, the edge in and out at every step so the leaflets show, widest a third of the way out.
d.line(pts([(x0, y0), (x1, y1)]), fill=dark, width=max(1, int((4 - 3 * t) * SS))) left = []
# Leaflets hang off both sides of the spine, longest near its middle, swept back and down. right = []
for n in range(len(spine) - 1):
(x0, y0), (x1, y1) = spine[n], spine[n + 1]
dx = x1 - x0 dx = x1 - x0
dy = y1 - y0 dy = y1 - y0
n = math.hypot(dx, dy) + 1e-9 m = math.hypot(dx, dy) + 1e-9
px, py = -dy / n, dx / n px, py = -dy / m, dx / m
leaf = 13 * math.sin(math.pi * min(1, t * 1.1)) + 2 t = n / (len(spine) - 1)
if i % 2: width = 15 * math.sin(math.pi * min(1, t * 1.25)) * (1.0 if n % 2 == 0 else 0.45) + 1
continue droop = width * 0.5
for side in (-1, 1): left.append((x0 + px * width, y0 + py * width + droop))
ex = x0 + side * px * leaf + dx / n * leaf * 0.6 right.append((x0 - px * width, y0 - py * width + droop))
ey = y0 + side * py * leaf + dy / n * leaf * 0.6 + leaf * 0.35 d.polygon(pts(left + [spine[-1]] + right[::-1]), fill=green)
d.line(pts([(x0, y0), (ex, ey)]), fill=dark, width=int(2.2 * SS)) d.line(pts(spine), fill=(24, 90, 38, 255), width=int(1.5 * SS))
d.ellipse(pts([(top[0] - 7, top[1] - 5), (top[0] + 7, top[1] + 7)]), fill=dark) d.ellipse(pts([(top[0] - 7, top[1] - 5), (top[0] + 7, top[1] + 7)]), fill=(90, 60, 34, 255))
# A magenta rim on the side away from the viewer's left: the sunset behind it. # A magenta rim on the side away from the viewer's left: the sunset behind it.
a = np.array(im) a = np.array(im)
alpha = a[..., 3] > 0 alpha = a[..., 3] > 0
edge = alpha & ~np.roll(alpha, -SS, axis=1) edge = alpha & ~np.roll(alpha, -SS, axis=1)
a[edge] = [230, 40, 160, 255] a[edge] = [255, 120, 170, 255]
return shrink(Image.fromarray(a), w, h) return shrink(Image.fromarray(a), w, h)
@ -251,31 +236,6 @@ def lamp():
return shrink(im, w, h) return shrink(im, w, h)
# A city along the horizon: blocks of every height, lit windows, a few towers with a red light on
# top. It tiles sideways.
def skyline():
w, h = 1280, 150
rng = np.random.default_rng(5)
im = Image.new("RGBA", (w, h), (0, 0, 0, 0))
d = ImageDraw.Draw(im)
x = 0
while x < w:
bw = int(rng.integers(26, 70))
bh = int(rng.integers(30, 140))
shade = int(rng.integers(14, 30))
d.rectangle([x, h - bh, min(w - 1, x + bw - 3), h - 1], fill=(shade, shade // 2, shade + 16, 255))
for wy in range(h - bh + 6, h - 6, 7):
for wx in range(x + 4, x + bw - 6, 6):
if rng.random() < 0.35:
c = (255, 210, 120, 255) if rng.random() < 0.7 else (80, 220, 255, 255)
d.rectangle([wx, wy, wx + 2, wy + 3], fill=c)
if bh > 110:
d.rectangle([x + bw // 2 - 1, h - bh - 14, x + bw // 2, h - bh], fill=(30, 20, 40, 255))
d.ellipse([x + bw // 2 - 3, h - bh - 18, x + bw // 2 + 3, h - bh - 12], fill=(255, 40, 60, 255))
x += bw
return im
# The night road a row at a time, as in the arcade racers: four kinds of row (light and dark road, # The night road a row at a time, as in the arcade racers: four kinds of row (light and dark road,
# light and dark ground) at HAZE levels each, fading into the violet haze at the horizon. # light and dark ground) at HAZE levels each, fading into the violet haze at the horizon.
STRIP_W = 256 STRIP_W = 256
@ -383,22 +343,63 @@ def flag():
return im return im
# A building's face: dark glass in a grid of windows, most lit warm, some cyan or magenta, some dark,
# with a floor line every row. Used as the colour and the emissive map, so the windows glow.
def facade(w=128, h=256):
rng = np.random.default_rng(12)
img = np.zeros((h, w, 4))
img[..., :3] = [10, 6, 18]
img[..., 3] = 255
for y in range(4, h - 4, 12):
img[y - 2, :, :3] = [24, 14, 36]
for x in range(4, w - 4, 10):
r = rng.random()
if r < 0.55:
c = [255, 200, 120]
elif r < 0.68:
c = [80, 230, 255]
elif r < 0.75:
c = [255, 60, 200]
else:
continue
k = 0.55 + 0.45 * rng.random()
img[y:y + 6, x:x + 6, :3] = np.array(c) * k
return Image.fromarray(img.astype(np.uint8), "RGBA")
# Terrain for the run over the mountains: ridges either side of a valley down the middle, repeating
# exactly along its length so the tiles can leapfrog for ever. Red carries the height.
def terrain(width=640, depth=256, span=3.5):
# span is the tile's width in tile depths: the ridges keep the size they have along the run however
# wide the tile is, so a wider tile is more mountains rather than the same ones stretched.
z = np.arange(depth) / depth * 2 * math.pi
x = np.linspace(-span, span, width)
X, Z = np.meshgrid(x, z)
h = np.zeros_like(X)
for k, (fx, fz, amp) in enumerate(((3, 2, 1.0), (7, 5, 0.5), (13, 9, 0.25), (23, 14, 0.12))):
h += amp * (1 - np.abs(np.sin(X * fx * 1.7 + k) * np.sin(Z * fz + k * 1.3)))
valley = np.clip((np.abs(X) - 0.12) / 0.5, 0, 1) ** 1.4
h = h / h.max() * valley
img = (np.clip(h, 0, 1) * 255).astype(np.uint8)
return Image.fromarray(np.stack([img, img, img], -1), "RGB")
def main(): def main():
os.makedirs(OUT, exist_ok=True) os.makedirs(OUT, exist_ok=True)
save(sky(), "sky.png") save(sky(), "sky.png")
save(gridTile(MAGENTA), "grid.png") save(gridTile(MAGENTA), "grid.png")
save(gridTile(CYAN, width=3.0, glow=10.0), "gridCyan.png") save(gridTile(CYAN, width=3.0, glow=10.0), "gridCyan.png")
save(mountains(), "mountains.png") save(mountains(), "mountains.png")
save(car(), "car.png")
save(palm(), "palm.png") save(palm(), "palm.png")
save(lamp(), "lamp.png") save(lamp(), "lamp.png")
save(skyline(), "skyline.png")
save(roadStrip(), "road.png") save(roadStrip(), "road.png")
save(bob(), "bob.png") save(bob(), "bob.png")
save(glow(), "glow.png") save(glow(), "glow.png")
save(chromeText("NEON COAST", 96), "logo.png") save(chromeText("NEON COAST", 96), "logo.png")
save(chromeText("SINGE", 120), "singe.png") save(chromeText("SINGE", 120), "singe.png")
save(flag(), "flag.png") save(flag(), "flag.png")
save(facade(), "facade.png")
save(terrain(), "terrain.png")
if __name__ == "__main__": if __name__ == "__main__":

60
util/neonCoastCar.sh Executable file
View file

@ -0,0 +1,60 @@
#!/bin/bash
# Renders the Neon Coast car with Singe and makes demos/neonCoast/art/car.png from it: one frame per
# steering angle, side by side, each with true alpha from a black and a white render of the same
# view. Runs headless; nothing appears on screen.
#
# util/neonCoastCar.sh
set -euo pipefail
REPO=$(cd "$(dirname "$0")/.." && pwd)
BIN=$(ls "$REPO"/.builddir/Singe-v*-Linux-x86_64 | head -1)
SCRATCH=${SCRATCH:-/tmp/neonCoastCar}
rm -rf "$SCRATCH"
mkdir -p "$SCRATCH/run/car"
cp "$REPO/util/neonCoastCar.singe" "$SCRATCH/run/car/"
# With "traffic" it makes art/traffic.png instead: the car straight from behind in other paints.
SHEET=car
if [ "${1:-}" = "traffic" ]; then
SHEET=traffic
printf 'CAR_TRAFFIC = true\ndofile(singeGetScriptPath():match("(.*[/\\\\])") .. "neonCoastCar.singe")\n' > "$SCRATCH/run/car/traffic.singe"
fi
SCRIPT=car/neonCoastCar.singe
if [ "$SHEET" = "traffic" ]; then
SCRIPT=car/traffic.singe
fi
cd "$SCRATCH/run"
SDL_VIDEODRIVER=offscreen SDL_AUDIO_DRIVER=dummy timeout -k 10 120 "$BIN" -k -s -w -C 1024x768 -x 1024 -y 768 --deterministic -d "$SCRATCH/data" "$SCRIPT" > "$SCRATCH/run.log" 2>&1 || true
python3 - "$SCRATCH/data" "$REPO/demos/neonCoast/art/$SHEET.png" <<'PY'
import glob, os, sys
import numpy as np
from PIL import Image
data, out = sys.argv[1], sys.argv[2]
shots = {os.path.basename(p)[:-4]: p for p in glob.glob(data + "/**/shot*.png", recursive=True)}
takes = sorted({int(k[4:-1]) for k in shots})
frames = []
for a in takes:
b = np.asarray(Image.open(shots["shot%02db" % a]).convert("RGB")).astype(np.float64)
w = np.asarray(Image.open(shots["shot%02dw" % a]).convert("RGB")).astype(np.float64)
# Over black a pixel is c * alpha; over white it is c * alpha + (1 - alpha) * white, white being
# whatever the background actually came out as.
white = (w[:4, :4] - b[:4, :4]).mean()
alpha = np.clip(1 - (w - b).mean(axis=2) / white, 0, 1)
colour = np.where(alpha[..., None] > 0.004, b / np.maximum(alpha[..., None], 0.004), 0)
frames.append(np.dstack([np.clip(colour, 0, 255), alpha * 255]))
# One box round every frame, so the car does not jump when the frame changes.
mask = np.zeros(frames[0].shape[:2], bool)
for f in frames:
mask |= f[..., 3] > 8
ys, xs = np.where(mask)
x0, x1, y0, y1 = xs.min() - 2, xs.max() + 3, ys.min() - 2, ys.max() + 3
width = 288
height = int((y1 - y0) * width / (x1 - x0))
sheet = Image.new("RGBA", (width * len(frames), height), (0, 0, 0, 0))
for i, f in enumerate(frames):
im = Image.fromarray(f[y0:y1, x0:x1].astype(np.uint8), "RGBA").resize((width, height), Image.LANCZOS)
sheet.paste(im, (i * width, 0))
sheet.save(out)
print(os.path.basename(out), sheet.size, "frames", len(takes))
PY

261
util/neonCoastCar.singe Normal file
View file

@ -0,0 +1,261 @@
-- Renders the Neon Coast car with Singe's own 3D renderer, for util/neonCoastCar.sh to turn into a
-- sprite sheet. The car is built here from lofted cross sections, wheels, and trim: a low wedge
-- of a sports car, our own design. Each steering angle is drawn twice, over black and over white,
-- and the pair gives every pixel's true colour and alpha, glass and soft edges included.
--
-- Run headless with --deterministic; it saves car<angle><b|w>.png and quits.
dofile("Singe/Framework.singe")
local W = 1024
local H = 768
local ANGLES = { -30, -20, -10, 0, 10, 20, 30 }
local RED = { 215, 14, 30 }
-- The traffic: the same car straight from behind in other paints (CAR_TRAFFIC set by the wrapper).
local PAINTS = { { 20, 110, 230 }, { 245, 200, 40 }, { 230, 230, 240 }, { 40, 190, 90 } }
-- Each picture to take: the car's angle and its paint.
local SHOTS = {}
if CAR_TRAFFIC then
for _, paint in ipairs(PAINTS) do
SHOTS[#SHOTS + 1] = { angle = 0, paint = paint }
end
else
for _, angle in ipairs(ANGLES) do
SHOTS[#SHOTS + 1] = { angle = angle, paint = RED }
end
end
local SETTLE = 3 -- Frames each picture is held before it is taken
local frames = 0
local function material(r, g, b, metallic, roughness)
local m = materialNew()
materialSetColor(m, r, g, b)
materialSetMetallic(m, metallic)
materialSetRoughness(m, roughness)
return m
end
-- A loft: rings of points (each a list of {x, y} at a z) joined into a closed skin, capped at both
-- ends. Normals are left to meshNew, so the skin shades smoothly.
local function loft(stations)
local positions = {}
local indices = {}
local ring = #stations[1].points
for _, s in ipairs(stations) do
for _, p in ipairs(s.points) do
positions[#positions + 1] = p[1]
positions[#positions + 1] = p[2]
positions[#positions + 1] = s.z
end
end
for i = 0, #stations - 2 do
for j = 0, ring - 1 do
local a = i * ring + j + 1
local b = i * ring + (j + 1) % ring + 1
local c = (i + 1) * ring + (j + 1) % ring + 1
local d = (i + 1) * ring + j + 1
indices[#indices + 1] = a
indices[#indices + 1] = c
indices[#indices + 1] = d
indices[#indices + 1] = a
indices[#indices + 1] = b
indices[#indices + 1] = c
end
end
-- Caps: a fan from each end ring's middle.
for _, e in ipairs({ { 0, false }, { #stations - 1, true } }) do
local s = stations[e[1] + 1]
local cx = 0
local cy = 0
for _, p in ipairs(s.points) do
cx = cx + p[1] / ring
cy = cy + p[2] / ring
end
positions[#positions + 1] = cx
positions[#positions + 1] = cy
positions[#positions + 1] = s.z
local centre = #positions / 3
for j = 0, ring - 1 do
local a = e[1] * ring + j + 1
local b = e[1] * ring + (j + 1) % ring + 1
indices[#indices + 1] = centre
if e[2] then
indices[#indices + 1] = a
indices[#indices + 1] = b
else
indices[#indices + 1] = b
indices[#indices + 1] = a
end
end
end
return meshNew(positions, nil, nil, indices)
end
-- The body's cross section at half width w, beltline belt, sill sill: a rounded shoulder, slab
-- sides tucking in at the bottom, a flat floor.
local function bodySection(z, w, belt, sill)
return {
z = z,
points = {
{ 0, belt + 0.05 }, { -w * 0.55, belt + 0.03 }, { -w * 0.88, belt - 0.01 }, { -w, belt - 0.12 },
{ -w, sill + 0.14 }, { -w * 0.93, sill }, { w * 0.93, sill }, { w, sill + 0.14 },
{ w, belt - 0.12 }, { w * 0.88, belt - 0.01 }, { w * 0.55, belt + 0.03 },
},
}
end
local function cabinSection(z, w, base, top)
return {
z = z,
points = { { 0, top }, { -w * 0.7, top - 0.03 }, { -w, base + (top - base) * 0.25 }, { -w * 1.02, base }, { w * 1.02, base }, { w, base + (top - base) * 0.25 }, { w * 0.7, top - 0.03 } },
}
end
local function part(mesh, mat, x, y, z, rx, ry, rz)
local n = nodeNew(CAR)
nodeSetMesh(n, mesh, mat)
nodeSetPosition(n, x, y, z)
nodeSetRotation(n, rx or 0, ry or 0, rz or 0)
return n
end
local function buildCar()
local paint = material(RED[1], RED[2], RED[3], 0.55, 0.14)
PAINT = paint
local glass = material(14, 18, 30, 0.9, 0.06)
local black = material(12, 12, 14, 0, 0.6)
local trim = material(30, 30, 34, 0.5, 0.35)
local chrome = material(220, 220, 230, 1, 0.12)
local tyre = material(18, 18, 20, 0, 0.85)
local lamp = material(60, 0, 0, 0, 0.3)
local bright = material(90, 10, 10, 0, 0.3)
local plate = material(230, 230, 220, 0, 0.5)
materialSetEmissive(lamp, 255, 20, 40)
materialSetEmissive(bright, 255, 170, 170)
CAR = nodeNew()
-- The body: a wedge, low and narrow at the nose, widest over the rear wheels, a flat tail.
local sill = 0.2
local body = {
bodySection(-2.25, 0.70, 0.50, sill + 0.08), bodySection(-2.05, 0.86, 0.55, sill), bodySection(-1.6, 0.94, 0.60, sill),
bodySection(-0.9, 0.97, 0.66, sill), bodySection(-0.1, 0.98, 0.70, sill), bodySection(0.7, 1.0, 0.73, sill),
bodySection(1.4, 1.02, 0.76, sill), bodySection(1.95, 1.0, 0.78, sill), bodySection(2.2, 0.97, 0.77, sill + 0.03),
}
part(loft(body), paint, 0, 0, 0)
-- The cabin: all glass, a raked screen and a sloping rear window, with a painted roof on top.
local cabin = {
cabinSection(-0.95, 0.80, 0.64, 0.66), cabinSection(-0.35, 0.74, 0.68, 1.10), cabinSection(0.45, 0.70, 0.71, 1.14),
cabinSection(1.05, 0.74, 0.74, 1.02), cabinSection(1.55, 0.84, 0.76, 0.79),
}
part(loft(cabin), glass, 0, 0, 0)
part(meshBox(1.24, 0.035, 0.85), paint, 0, 1.135, 0.08)
-- The tail: a black panel, a full-width lamp bar with brighter cores, a plate, and twin pipes.
part(meshBox(1.86, 0.30, 0.04), black, 0, 0.53, 2.205)
part(meshBox(1.78, 0.11, 0.05), lamp, 0, 0.62, 2.22)
part(meshBox(0.36, 0.06, 0.05), bright, -0.62, 0.62, 2.235)
part(meshBox(0.36, 0.06, 0.05), bright, 0.62, 0.62, 2.235)
for i = -3, 3 do
part(meshBox(0.02, 0.09, 0.03), black, i * 0.12, 0.62, 2.25)
end
part(meshBox(0.52, 0.12, 0.02), plate, 0, 0.43, 2.23)
part(meshCylinder(0.065, 0.3, 16), chrome, -0.48, 0.27, 2.2, 90, 0, 0)
part(meshCylinder(0.065, 0.3, 16), chrome, 0.48, 0.27, 2.2, 90, 0, 0)
-- A low wing across the tail on two posts.
part(meshBox(1.8, 0.05, 0.3), paint, 0, 0.97, 2.0)
part(meshBox(0.05, 0.18, 0.1), trim, -0.6, 0.87, 2.0)
part(meshBox(0.05, 0.18, 0.1), trim, 0.6, 0.87, 2.0)
-- Intake strakes along each rear flank, and black skirts under the doors.
for side = -1, 1, 2 do
for i = 0, 4 do
part(meshBox(0.04, 0.035, 1.25), black, side * 1.005, 0.40 + i * 0.065, 0.45)
end
part(meshBox(0.05, 0.12, 2.3), black, side * 0.95, 0.27, 0.05)
end
-- Mirrors.
part(meshBox(0.16, 0.08, 0.12), paint, -0.98, 0.82, -0.55)
part(meshBox(0.16, 0.08, 0.12), paint, 0.98, 0.82, -0.55)
-- Wheels: wide tyres with chrome rims, the rears wider.
for _, w in ipairs({ { -0.9, -1.35, 0.25 }, { 0.9, -1.35, 0.25 }, { -0.92, 1.38, 0.32 }, { 0.92, 1.38, 0.32 } }) do
part(meshCylinder(0.34, w[3], 28), tyre, w[1], 0.34, w[2], 0, 0, 90)
part(meshCylinder(0.22, w[3] + 0.01, 20), chrome, w[1] + (w[1] > 0 and 0.002 or -0.002), 0.34, w[2], 0, 0, 90)
end
-- Under the car, a dark floor so the light does not show through.
part(meshBox(1.8, 0.04, 4.2), black, 0, 0.2, 0)
end
sceneEnable(true)
sceneSetAmbient(70, 60, 80)
sceneSetAntialias(true)
-- No tone curve, so the white background comes out white and the matte is exact.
sceneSetTonemap(TONEMAP_NONE)
buildCar()
local key = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(key, 3, 8, -4)
nodeLookAt(key, 0, 0, 0)
lightSetIntensity(key, 2.6)
lightSetColor(key, 255, 230, 240)
local fill = lightNew(LIGHT_POINT)
nodeSetPosition(fill, -3, 3, 8)
lightSetIntensity(fill, 40)
lightSetColor(fill, 120, 160, 255)
local rim = lightNew(LIGHT_POINT)
nodeSetPosition(rim, 4, 2.5, 6)
lightSetIntensity(rim, 30)
lightSetColor(rim, 255, 80, 200)
local top = lightNew(LIGHT_POINT)
nodeSetPosition(top, 0, 6, 3)
lightSetIntensity(top, 25)
local camera = nodeNew()
cameraSet(camera)
cameraSetPerspective(26, 0.1, 100)
nodeSetPosition(camera, 0, 3.1, 11)
nodeLookAt(camera, 0, 0.55, 0)
overlaySetResolution(W, H)
function onOverlayUpdate()
local shot = frames // SETTLE
local take = SHOTS[shot // 2 + 1]
local white = shot % 2 == 1
frames = frames + 1
overlayClear()
if not take then
singeQuit()
return OVERLAY_UPDATED
end
nodeSetRotation(CAR, 0, take.angle, 0)
materialSetColor(PAINT, take.paint[1], take.paint[2], take.paint[3])
sceneSetBackground(white and 255 or 0, white and 255 or 0, white and 255 or 0, 255)
if frames % SETTLE == 0 then
singeScreenshot(string.format("shot%02d%s", shot // 2 + 1, white and "w" or "b"))
end
return OVERLAY_UPDATED
end