A music-synced Gaussian-splat demoscene engine (Bevy + Vulkan) that flies a camera around 3D
splats while they morph into one another β a title, two faces, a dog, a whole city β entirely
without CUDA or ROCm. The domain model + vocabulary live in DOMAIN.md.
Developed and previewed on a modest AMD Ryzen AI 7 PRO 350 / Radeon 860M iGPU (CPU + Vulkan /
Mesa RADV, openSUSE Tumbleweed); the released demo targets bigger metal at showtime, so it can afford
heavier clouds and the sh3 view-dependent profile (see build profiles). Demoscene
spirit, all-AMD dev box. πͺ©
cargo +nightly run --release # the default demo β a splat assembles out of a ball cloud
cargo run --release -- <show.show> # run a .show config (or --production <name>)
cargo run --release -- <show.show> --validate # dry-run: print the resolved timeline + exit
# free-orbit: β/β yaw Β· β/β pitch Β· W/S zoom Β· A/D & Q/E pan Β· M mark waypoint Β· Space restart Β· F11/F fs
./record.sh out.mp4 # render the whole timeline to ./out.mp4It's one sequence engine: every run is a timeline of parts (splat-text or splats) that
each assemble out of a ball cloud, then morph into the next (per-Gaussian, on the GPU), with
HDR bloom on black. A show is a .show file β the config β that you run with martin <show>
(or --production <name>); it expands into MARTIN_* env vars (the internal IR), which a CLI
layers run-mode flags on top of (--record/--shot/--validate/β¦). Precedence: **CLI flag > env
.show[settings] > default**. Built on Bevy 0.19 +bevy_gaussian_splatting8.0.1 (our fork β themartin-tightcutbranch ofannejan/bevy_gaussian_splatting, a git dep), wgpu β Vulkan, nightly toolchain (a dated pin,nightly-2026-08-28, not rolling).
USAGE.mdβ the CLI +.showfile format + theMARTIN_*env reference.ART-DIRECTION.mdβ how to shoot and prep good splats for the demo (capture recipe, lighting, the two splat "flavours", cleanup).productions/β one folder per demo (showbook, show, bundle, captures); the engine stays theme-agnostic. Current: intro (the bundled showcase), camping (in design β see its SHOWBOOK), and the aerial-city demos austin / nyc / cities (local Google-imagery captures, not shippable β seepipeline/AERIAL-CITIES.md). Shared building blocks:parts/.
The repo ships a CUDA-free splat-creation pipeline (pipeline/) that turns a phone video
or a folder of photos into the .ply assets the demo renders:
./pipeline/splat-setup.sh # once: builds COLMAP (CPU) + Brush (Vulkan)
./pipeline/splat.sh my_video.mp4 # or: ./pipeline/splat.sh ./photos/
VIEWER=1 ./pipeline/splat.sh ./photos/ # watch training live in Brush's window| Script | What it does |
|---|---|
pipeline/splat-setup.sh |
One-time: installs COLMAP build deps via zypper, builds COLMAP (CUDA off) and Brush (wgpu/Vulkan), symlinks ~/.local/bin/brush. |
pipeline/splat.sh |
video | image-dir β ffmpeg frames β COLMAP CPU SfM + undistort β Brush training β .ply. |
pipeline/mesh-splat.sh |
A mesh (.obj/.dae/.stl/.ply/.glb) β Blender (EEVEE) orbital renders with known poses (no COLMAP) β Brush training β a "proper" .ply. The offline bake for when a mesh matters (vs the in-engine mesh: sampler). Needs Blender (BLENDER=blender-5.0). |
Capture quality is 90% of the result β see ART-DIRECTION.md for
the full recipe (and the single-image TRELLIS
shortcut). View / clean / compress any .ply at https://superspl.at/editor.
Two reference clouds from Mitchell Mosure (the upstream author) β fetched on demand (they're >100 MB so they're gitignored, not committed):
./pipeline/fetch-demo-assets.sh # β assets/go_trimmed.ply + assets/trellis.glb
MARTIN_PLY=assets/go_trimmed.ply cargo r-sh3 # multi-view photogrammetry (SH3 glints)
MARTIN_GLB=assets/trellis.glb cargo r-sh0 # a KHR_gaussian_splatting glTF scene (camera auto-frames it)The go-board is a real multi-view capture (its sh3 glint is visible β cargo r-sh3); the trellis is a
single-image TRELLIS export (flat SH, so sh0 loses nothing). See build profiles.
No capture, no python β synthesize an abstract cloud straight to a .ply with the splatgen tool (it
shares the exact generator build.rs uses to auto-make the demo splats, so the clouds morph cleanly
with everything else):
cargo run --release --bin splatgen -- list # sphere cube torus ring helix galaxy star wave knot mobius supershape lsystem fern menger shell tent pine flame rocket saturn ufo terrain moon mountains
cargo run --release --bin splatgen -- lsystem assets/tree.ply # a 3D L-system plant
cargo run --release --bin splatgen -- torus # β torus.ply
MARTIN_PLY=assets/tree.ply cargo run --release # then view / use it in a show (splat:tree.ply)The fractal/organic shapes: lsystem (3D branching tree, brown trunk β green canopy), fern (an
arching fractal-plant frond), menger (a depth-3 Menger sponge), shell (a log-spiral nautilus). The
camping set: tent (ridge tent), pine (a tiered conifer), flame (a campfire tongue β pairs with
MARTIN_PARTICLES=embers), terrain (rolling ground/hills so scenes aren't floating), moon (a grey
cratered moon). The space set: rocket (a cartoon rocket β white hull, red nose+fins, a hot exhaust
plume; nose points +Y so a travel: toward +Y reads as a launch), saturn (a banded ringed planet),
ufo (a flying saucer β metal disc, cyan glass dome, alternating rim lights). The rest are parametric
solids
(sphere/cube/torus/ring/helix/galaxy/star/wave/knot/
mobius/supershape). All ~140k splats, deterministic (fixed seed β reproducible .ply).
With nothing set, cargo +nightly run --release plays the intro production
(productions/intro/intro.show) β the very same showcase CI bakes
into the downloadable single binary, so a fresh git clone runs exactly what the download shows. Its
procedural splats are synthesized by build.rs on first build (no python/numpy), so the clone needs
no extra step. (The older effect-catalogue demo still lives at assets/demo.show,
built only from shipped assets β MARTIN_SHOW=assets/demo.show cargo run --release.) Point it
at your own splat with MARTIN_PLY=assets/your.ply cargo run --release, then chain it into a
MARTIN_SEQ to morph between splats β e.g. splat:a.ply; splat:b.ply β a per-Gaussian
GaussianInterpolate blend where each source is paired to the
target by Morton (Z-order) spatial sort, so particles flow into their nearest part of the
target (no teleporting) and colours/positions lerp together (e.g. two Martins β one dog: each
becomes a half of the dog). A front-facing camera sway keeps the hollow back of single-image
splats out of frame (MARTIN_YAW=<rad> pins the angle for inspection). Export
uncompressed/standard PLY from SuperSplat β the loader rejects the compressed format
(missing required properties). Linux build deps: systemd-devel (libudev) + alsa (and a
Vulkan/RADV driver).
Prebuilt binaries: GitHub Actions builds release binaries for Linux, Windows, and macOS
on every push β grab them from the artifacts of the latest
build run. The release
binary is ~75 MB (strip + thin LTO); use cargo run --release to show it off (the 1.8 GiB
build is debug-only, for fast iteration).
A .show file sets these (its [settings] block + sections); the same knobs are also MARTIN_*
env vars you can combine ad-hoc for a quick experiment. All splat positions/scales are particles in
the same system, so any of these morphs into any other. Full reference in USAGE.md.
| Env var | Effect |
|---|---|
MARTIN_SHOW=show.show |
Unified scene file β one file with settings + a [reel] ([seq]) + a [compose] stage + a music-timed [camera] track (keyframes can anchor to a music section, t=@@drop). A kind = intro|demo setting declares the production kind, and a [scenes] block can author the show as an arc of named scenes that flattens to [reel] (see DOMAIN.md). Expands into the env vars below (which still override it). The recommended way to author a whole show; see assets/example.show. |
MARTIN_VALIDATE=1 |
Dry-run β parse the show, print the resolved timeline (part cue times, effects, compose, camera) and exit, no render. A fast authoring check. |
MARTIN_PLY=/abs/x.ply |
Load a splat (sets the asset folder β its parent becomes the asset root). |
MARTIN_SEQ="β¦" |
Timeline β a chain of parts that morph into one another (see below). Use splat:/text: parts to load splats and titles. |
MARTIN_COMPOSE=stage.compose |
Composition β many objects on one stage at once, placed + spinning/bobbing/drifting, fading in on the music, camera auto-orbiting (vs the morph timeline). Example: assets/examples/stage.show. |
MARTIN_FPS=1 / I key |
Log FPS + splat count (the I key toggles it live + logs a snapshot). |
~name (per-part token) |
How each part arrives: morph/swarm/ball/fade/explode/implode/drop/swirl, or the shader ones typewriter/wipe/sparkle/slither/vortex/outline/pen-write. swarm = like morph but the splats flock along curled paths between the two scenes (the @_,_,N value tunes the swarm strength). |
MARTIN_DEFORM=wave |
A scene-wide persistent deform field held the whole part (wave/cloth/ripple/twist/wind/turbulence/pulse/jitter/spiral) β great on a wall: of text, or to gently wobble a whole splat scene while you fly around it; applies to compose objects too. Per-part ^name wins. |
^name:amp (per-part token) |
Scale a part's deform amplitude (^wave:0.3 β a gentle wobble on a big scene; 1 = default). |
mesh:model.dae (part) |
A mesh: part (.dae/.obj/.stl/.ply) is surface-sampled into flat, normal-aligned gaussians, coloured from the diffuse texture (sampled at the UV), else vertex/material colour. The per-part disk:<f> token tunes the splat-disk overlap. |
MARTIN_MORPH_COUNT=250000 |
Gaussian budget (0=max ~1.15M β 20 fps; 250k β 60 fps on the iGPU). |
MARTIN_ZOOM=1.5 |
Camera closeness (>1 = closer / more zoomed in, <1 = pull back). |
MARTIN_ROT=rx,ry,rz |
Orient the cloud (euler degrees) β e.g. stand a COLMAP scene upright. |
MARTIN_YAW=1.4 MARTIN_PITCH=0.1 |
Seed the free-orbit camera angle (radians); MARTIN_YAW also holds it (no sway) when recording. |
MARTIN_WAYPOINTS=path.json |
Where the M-key camera waypoints are logged / read (default waypoints.json) β fly + mark to author a camera path. |
MARTIN_FLY=2 |
Fly the camera through the marked waypoints. Recording: the path fills each part (longer hold = slower flyby), flowing through the morph. Live: <secs> = pace. |
MARTIN_FPS=1 |
Log frame time / FPS. |
MARTIN_SERVE=1 |
Live control bridge (default port 7878): boot the show windowed, render offscreen, and drive the camera + clock live over a line-JSON TCP protocol (camera/seek/pause/play/step/screenshot/dump_camera/state) β author + inspect without reloading. A stdio MCP server (martin mcp) proxies these as native tools for an MCP client (e.g. Claude Code). See USAGE. |
MARTIN_RECORD=/dir |
Dump one PNG per frame (used by record.sh). |
MARTIN_PREVIEW_FPS=8 |
Render the timeline at N fps instead of 60 β far fewer frames for a fast preview (rendering frames is the slow part). Timing + audio sync stay correct; record.sh muxes at the same fps. |
MARTIN_RASTER=position |
Debug-shading view for the whole show (color/depth/normal/position/classification/flow/velocity) β the fork's RasterizeMode. Per-part raster:<mode> token overrides it. position colours by XYZ (rainbow). |
MARTIN_SHOT=/x.png MARTIN_SHOT_AT=<s> |
Headless screenshot at time s, then exit. |
MARTIN_FULLSCREEN=1 |
Start borderless-fullscreen; toggle live with F11 / F. |
MARTIN_FLASH=0.6 |
Over-bright bloom flash on each part cut (0 = off). |
MARTIN_SYNTH_WAV=/x.wav |
Render the bundled deFEEST synth to a WAV and exit (mux onto a recording). |
MARTIN_MUTE=1 |
Silence the live synth (it plays in the window by default; recordings still mux the WAV). |
MARTIN_SCORE=score.txt |
Load a tracker-DSL score file (tempo / sections / drum patterns / dynamics) β drives the synth and the @@anchors. Editable default ships at assets/score.txt. A score can also pick each voice's CHARACTER via the instrument-palette knobs (leadsw/basssw/padsw/drumsw, even per-section) β see DOMAIN.md. |
MARTIN_SCORE_DUMP=score.txt |
Export the built-in score as an editable file and exit. |
MARTIN_SEQ is a ;-separated list of parts (or a path to a file of them, one per line;
# comments allowed). Each part morphs into the next, through a ball cloud:
text:STRING # splat-text (glowing)
image:logo.png # a PNG (in the MARTIN_PLY folder), rasterized to gaussians
svg:logo.svg # an SVG, rasterized (vector β pixels) into gaussians β any vector art
mesh:model.dae # a 3D mesh (.dae/.obj/.stl/.ply), surface-sampled into gaussians
glb:badge.glb # a real glTF mesh: rendered crisp, then DISSOLVES into its own splats
shader:warp # a fullscreen-effect INTERLUDE (warp/plasma/tunnel/stars/rings/grid/kaleido/bolt); splats clear
splat:a.ply # a splat (filename in the MARTIN_PLY folder)
splat:a.ply+b.ply # several splats, auto-arranged side by side
β¦any partβ¦ @hold,morph,bulge ~transition ^deform out:departure @@anchor # timing Β· arrival Β· deform Β· departure Β· cue
The trailing ~transition picks how a part arrives β data-only ball (default), fade,
explode, implode, drop, swirl, extrude, helix, fold, zoom, morph, or the per-particle shader transitions
typewriter, wipe, sparkle, slither, vortex, outline, pen-write (great for text). The ball is just one
of many; the design + the shader fork are in DESIGN.md / SHADER-BLUEPRINT.md.
The optional @@anchor pins a part's start to the music clock (Cinder's ported synth/score,
src/{audio,score}.rs): @@drop (a section), @@bar32, @@beat:64, or @@12.5 seconds β so
the visuals lock to the track. MARTIN_SYNTH_WAV renders that synth to a WAV; mux it onto a
recording with ffmpeg for a video-with-sound (see USAGE.md β Music).
Example β the full show (title β dog β greetings β credits):
MARTIN_PLY=assets/doggo.ply \
MARTIN_SEQ="text:MARTIN GAUS @2,3,0; splat:doggo.ply @2,3,0.9; text:GREETINGS @1.5,3,0.9; text:CODE ANNEJAN @2,3,0.6" \
cargo +nightly run --release
# ./record.sh out.mp4 renders the whole timeline to videoPose a show's [stage]/[compose] props and the camera in Blender's viewport, then read them
straight back into the .show β full transform (position Β· rotation Β· scale Β· camera) round-trips 1:1
with martin. It's pipeline/blender_bridge.py, driven over the blender-mcp MCP server. In Blender:
exec(open('/path/to/martin/pipeline/blender_bridge.py').read())
bridge_import('productions/camping/campsite-max.show', cam_anchor='climax') # spawn [stage] + set camera
# β¦drag / rotate / scale props, orbit the camera (camera-lock is ON); scale the PARTICLE.embers
# cube to dial the campfire ember volumeβ¦
bridge_export('productions/camping/campsite-max.show') # patch @pos/*scale/rot + particle volume back
bridge_shot('/tmp/x.png') # save a shot (DISPLAY=:0 xdg-open to view)Export patches only @pos/*scale/rot (+ particle_origin/particle_spread) on each prop's source
line, so every other show token is preserved. Poseable = [stage]/[compose] props; programmatic
[reel]/path:/travel: content is temporal (load one reel frame as a backdrop= to pose over it).
Full reference: the script docstring + the "Blender β martin bridge" section of AGENTS.md.
The spherical-harmonic degree is a compile-time choice (one-hot in the splat crate), exposed as two build profiles. Synthetic content (text/morph) looks identical in both β only real captures differ:
| profile | what | when |
|---|---|---|
sh0 (default) |
one flat colour per splat β lean, fast | the synthetic demo; the AMD iGPU dev box |
sh3 |
full degree-3 view-dependent colour (specular glint) | real captures (camping footage β Brush); the showtime machine |
cargo b-sh0 # = cargo build --release β target/release/martin (sh0, default)
cargo b-sh3 # sh3 build into target/sh3/release/martin (both binaries coexist; r-sh0 / r-sh3 to run)sh3 costs ~16Γ the per-splat colour data in VRAM for captures (synthetic clouds compress that away),
so the lean sh0 stays the default; flip to sh3 once camping captures land. The aliases live in
.cargo/config.toml.
Ship a whole show as one self-contained executable β assets baked in, no files, no env vars:
./pipeline/bundle.sh # or: cargo build --release --features bundle
./target/release/martin # runs the baked-in show anywherecargo build --release --features bundle is the pipeline: build.rs reads bundle.toml
(the show β a seq/compose + optional score, logo, morph_count), auto-collects every
.ply/PNG the show references, lz4-compresses them into the binary, and bakes the show string in.
At startup the binary self-extracts the assets to a temp dir (reused across relaunches) and plays
the show, with a loader screen (logo + progress bar) while the splats decompress. Env vars still
override the baked-in defaults (e.g. MARTIN_LOOP=1). Fonts and the default score are already
compiled into martin, so only splats (and any logo PNG) ship. Edit bundle.toml to pick the show;
its .ply must be present locally at build time (they're git-ignored).
./pipeline/release.sh builds a single self-contained binary (the show + all its assets baked
in) from bundle.toml, then verifies it self-extracts and plays:
./pipeline/release.sh # β target/release/martin (one file, no assets, no env)
./target/release/martin # plays the baked-in show anywhereCI bakes the intro production into the download (productions/intro/bundle.toml β intro.show);
the root bundle.toml defaults to assets/demo.show. Either stays self-contained by using only
light assets β procedural demo shapes + a few tracked meshes + text, no 500 MB photogrammetry
scenes. A bundle lands around ~180 MB (Bevy base ~75 MB + lz4-compressed splats). To shrink it: ship
fewer / downsampled .ply (the real lever β splat floats dominate), or trim the show.
Portability (run on other distros). A binary linked against this dev box's glibc (openSUSE
Tumbleweed = bleeding-edge) fails on older distros with version 'GLIBC_2.xx' not found. So
release.sh links against an old glibc via cargo-zigbuild
when zig + cargo-zigbuild are on PATH β built here, runs on Ubuntu 20.04+/Debian 11+/Mint 20+
(glibc β₯ TARGET_GLIBC, default 2.31; the GPU/audio/window libs are dlopen'd at runtime and present
on any desktop). One-time setup: cargo install cargo-zigbuild and put zig on PATH. Cross-OS
(Windows/macOS): run release.sh on each, or use GitHub Actions, then gh release upload.
Splats, captures, run outputs, and the external COLMAP/Brush checkouts are git-ignored (multi-GB binaries). Only source/tools are tracked.