Skip to content

Turn Synesthesia's audio orb into a charge cloud - #230

Merged
nulltask merged 29 commits into
develfrom
feature/synesthesia-charge-cloud
Oct 11, 2026
Merged

nulltask merged 29 commits into
develfrom
feature/synesthesia-charge-cloud

Conversation

@nulltask

@nulltask nulltask commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

Summary

Synesthesia's audio orb is rebuilt as a charge cloud: small dark orbs hang in a fluid of particles that is simulated on the GPU and reacts to the music. I tuned the behaviour against a reference video of a classic music visualizer, stepped frame by frame (its first 33 frames at 1/60 s, plus two 3 s windows around beat hits at 1/20 s).

Orbs

  • There are five orbs, each listening to its own slice of the 16 spectrum bands.
  • Each orb is a light. A hit fires a flash within one frame; the flash falls off in about 0.1 s and leaves a glow that fades over about a second. While it flashes, the orb throws light shafts.
  • An orb reacts to how far its band stands above its own running average (about 2.5 s), not to raw loudness. A sustained loud passage therefore settles back down, and only real jumps light the orb up.
  • After a flash, an orb waits 0.18 s before it can flash again.
  • The timing comes from stepOrbLight, a pure function with its own tests.

Particles

  • About 150,000 particles on the select screen and about 37,000 in the idle monitor. Positions and velocities live in rgba32float textures and are stepped by fragment shaders.
  • Charge: a particle is drawn to orbs of the opposite sign and driven off by orbs of its own sign. Each orb also spins nearby particles round its axis.
  • Spectrum: each particle is tuned to one band, and that band's level scales how hard the charge pulls or pushes it.
  • Flocking: every frame each particle splats into a 32³ velocity/density grid. When it updates, it reads the grid trilinearly, steers toward the local mean velocity (alignment), and moves along the density gradient toward a target density (cohesion and separation). This is what folds the cloud into smoky sheets.
  • Lighting: particles are lit by the orbs, pink to white near a flaring orb and deep indigo elsewhere. The light a particle receives saturates softly instead of adding up without limit.
  • Texture: particles are drawn as the same crisp square dots as the skin's other point clouds (0.6–2 px, by weight and depth) and shimmer with the highs.
  • Brightness: each dot's opacity is scaled by the cloud's on-screen size, so the select screen and the idle monitor look alike.
  • Particles behind an orb are hidden in the particle shader. The orbs are analytic spheres drawn by a fragment shader.

The rest of the skin's particles

  • Every other Synesthesia particle (floor, pyramids, rivers, dust, schools, star trails, hit sparks) now shares the cloud's fine grain. This is done in ShapeBatch:
    • a square point breaks into 1–5 grains of 0.6–1.1 px (grainsFor)
    • a segment breaks into a trail of such grains about 1.6 px apart, up to 32 per segment (trailGrainsFor)
    • grain alpha is set so a cluster or trail gives off about the same light as the original shape
  • The select screen holds 60 fps in headless Chrome.

Fish react to kicks

  • Each fish puffs up and flashes white on every kick drum, then is back to normal within about 0.15 s.
  • The kick envelope (stepKick) reads the three lowest bands and fires only on the attack: either the low end leaps up within a step, or an onset lands while the low end stands above its running average. A sustained bass line therefore cannot hold the fish swollen, and kicks are at least 0.12 s apart.
  • Strokes routed to the grain renderer now spread over up to three lanes across their width, so a fatter stroke reads wider instead of only brighter.

Fix: gameplay dust, floor and rivers jumped

  • Gameplay placed these at seconds × speed. The dust's speed follows the music's level, so every change in level teleported the motes instead of accelerating them. The floor and rivers jumped the same way on entering a zone.
  • They now move by accumulated travel, as the select screen already did.

Fix: idle monitor

  • The gameplay chrome cleared the monitor cloud's clock on every frame, so the monitor's simulation never stepped and its lights never decayed. The monitor now reacts to the music the same way as the select screen.

pixi-kit

  • New queueGpuPass(). A skin can queue GPU work while it builds a frame, and the surface runs that work on its own renderer right before drawing the frame. This is how the simulation steps.

Removed

These were used only by the old orb:

  • the CPU particle shell, its fibres, the smaller background orb on the select screen, and the black moon
  • the helpers drawMagnetoOrb, orbitParticles, orbitPosition and reflectedLight, plus their tests

Test plan

  • vp check
  • vp test run: all tests pass
  • New unit tests for queueGpuPass / runGpuPasses, chargeOrbs and stepOrbLight
  • Visual check in a scratch harness driven by synthetic audio: the cloud forms folded sheets, flashes and glows fire per orb, and the monitor clip works
  • Frame time held at 60 fps with up to 262k particles in headless Chrome on an Apple Silicon Mac (this measures rAF intervals, not GPU time)
  • Check the select screen and the idle monitor with real music
  • Check that hit sparks still read well during play

🤖 Generated with Claude Code

Skins built on pixi-kit had no way to reach the renderer, so they
could not step a simulation in render textures. queueGpuPass() lets
a skin queue work while it builds a frame; the surface runs the queued
passes on its own renderer right before drawing that frame. A failing
pass is reported and does not stop the rest.
Five small dark orbs hang in a GPU-simulated fluid of particles. Each
orb listens to its own slice of the spectrum and is a light: a hit
fires a one-frame flash with light shafts that falls off in about
0.1 s, then a glow that fades over about a second. The particles are
lit by the orbs (pink to white near a flaring orb, deep indigo
elsewhere) and moved by charge: drawn to orbs of the opposite sign,
driven off by their own, spun round each orb's axis, with each
particle tuned to one spectrum band.

The particles also flock through a coarse 32^3 velocity/density grid
that every particle splats into each frame (alignment toward the
local mean velocity, cohesion/separation around a target density),
so the cloud folds into smoky sheets instead of scattering.

The select screen's foreground orb and the gameplay idle monitor now
use it. The CPU particle shell, its fibres, the smaller background
orb and the black moon go, along with the helpers only they used.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
be-music-player-demo 0a756c1 Commit Preview URL

Branch Preview URL
Oct 11 2026, 05:23 PM

@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Exports Benchmark

  • Base SHA: 97f1b811a28e
  • Head SHA: 0a756c1f154a
  • Comparable cases: 93
  • Regression threshold: 8.00%
  • Overall verdict uses the median change across cases.
  • Per-case lists compare median ops/s, not mean.
  • Base runs: median of 3
  • Head runs: median of 3

Summary

Metric Value
Overall unchanged
Median change -0.10%
Mean change -0.57%
Cases improved (>= threshold) 0
Cases regressed (<= -threshold) 2
Cases unchanged 91
Cases excluded (sub-timer-resolution) 214
Head benchmarked cases 307
Head skipped cases 22

Top Regressions

API Base median ops/s Head median ops/s Change
player-web.buildAudioBus 518672.20 390625.00 -24.69%
player-web.setMasterVolume 2260.51 1935.65 -14.37%

Top Improvements

No improvement over threshold.

Excluded (sub-timer-resolution)

Per-call latency at or below 0.001ms on at least one side — the reported time is measurement noise, not the case's real cost, so no percent change is shown.

API Base median ops/s Head median ops/s
audio-renderer.createTimingBuildContext 1923076.92 1923076.92
chart.compareEvents 10416666.69 10416666.69
chart.createBeatResolver 20833332.60 20833332.60
chart.eventToBeat 13888889.10 13888889.10
chart.exWavVolumeCentibelsToLinearGain 15625000.04 15625000.04
chart.getMeasureBeats 20833332.60 20833332.60
chart.isBmsBgmVolumeChangeChannel 17857142.61 17857142.61
chart.isBmsDynamicVolumeChangeChannel 17857142.61 17857142.61
chart.isBmsKeyVolumeChangeChannel 17857142.61 17857142.61
chart.isBmsLongNoteChannel 17857143.77 20833332.60
chart.isLandmineChannel 20833332.60 20833332.60
chart.isPlayableChannel 20833332.60 20833332.60

…exture

Loud passages washed the cloud out: the orb lights followed raw band
energy, so a loud mix kept every orb lit and flashing on every beat,
and the light piled up on the particles without limit.

- Each orb now reacts to how far its band stands above its own running
  average (about 2.5 s), so a sustained loud passage settles back down
  and only real jumps light it up.
- After a flash an orb waits 0.18 s before it can flash again.
- The light a particle receives saturates softly instead of adding up.

The particles are now drawn as the same crisp square dots as the
skin's other point clouds (0.6-2 px by weight and depth) instead of
1 px strokes, shimmering with the highs, with their opacity scaled by
the cloud's on-screen size so the select screen and the monitor match.
The colours are unchanged.
The gameplay chrome cleared the monitor cloud's clock at the top of
every frame, so each update saw no elapsed time: the simulation never
stepped and the orb lights never decayed, leaving the monitor frozen
and lit instead of reacting like the select screen's cloud. The clock
now carries on across frames; the cloud already clamps each step, so
frames spent hidden behind a BGA just resume.
…fine grain

The charge cloud is drawn as dense, pixel-sized dots, while the rest of
the skin's particles were larger squares and thick strokes. Everything
already goes through ShapeBatch, so the change is made there for square
points and segments routed to the GPU point layer:

- A square point breaks into 1-5 grains of 0.6-1.1 px, laid on a
  golden-angle spiral within the point (grainsFor).
- A segment breaks into a trail of such grains about 1.6 px apart, up
  to 32 per segment (trailGrainsFor).
- Grain alpha is set so a cluster or trail gives off about the light
  the original shape did.

Floor, pyramids, rivers, dust, schools, star trails and hit sparks in
select, gameplay and result all pick it up; the select screen holds
60 fps in headless Chrome.
Each fish now puffs up and flashes white on a kick: a fatter body, a
bigger head and a slightly longer stretch, gone again within about
0.15 s. A kick envelope (stepKick) reads the three lowest spectrum
bands and fires only on the attack - the low end leaping up within a
step, or an onset while the low end stands above its running average -
so a sustained bass line or the analyser's slow release cannot hold
the fish swollen. Kicks are at least 0.12 s apart.

Segments routed to the grain renderer now lay their grains in up to
three parallel lanes across the stroke's width, so a fatter stroke
reads wider instead of only brighter.

The select screen drives its schools with the envelope.
The gameplay chrome placed its dust, floor points and rivers at
seconds x speed. The dust's speed follows the music's level and the
key presses, and all three speed up with the zone tier, so any change
of speed moved every particle by the whole elapsed time times the
change: the dust flickered and teleported with the music instead of
accelerating, and the floor and rivers jumped on entering a zone.
Each layer now accumulates the distance travelled at the current
speed, as the select screen already did; particleRiverPoint takes the
distance as an optional argument.

The gameplay schools are also driven by the kick envelope now, like
the select screen's.
…charge-cloud

# Conflicts:
#	packages/player-web/src/skins/pixi-kit/index.ts
…charge-cloud

# Conflicts:
#	packages/player-web/src/scene/core/gameplay.ts
The newer Oxlint from vite-plus 1.1 flags new Array(n).fill(...) (unicorn/no-new-array).
Each boid is drawn as a manta outline (nose, beating wingtips, tail root, and a short tail whip) laid out in world space from its heading, so the wings tilt with the camera. Wingbeats run out of step across a school, and a kick spreads the wings wider and beats them harder.
Each hit now also throws up to 140 (plus 16 per combo tier) fine, dimmer grains that spread sideways along the judgement line and rise at most about a lane's width, with their own frame budget so the upward fountain keeps its full count. The extra particles thicken the burst where it lands without covering the lanes above.
@nulltask
nulltask merged commit 28a54d9 into devel Oct 11, 2026
8 checks passed
@nulltask
nulltask deleted the feature/synesthesia-charge-cloud branch October 11, 2026 17:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant