Beyond Black: Building an Audio-Reactive 3D Scene

Published:

← Back to Blog


ShoggothOx's 3D modes used to place an animated surface in front of a black clear color. The surface might be a sphere, cylinder, torus, or coiled near-ring, and the audio-reactive texture could be vivid, but the composition still read as an object floating in an empty viewport. Improving that meant building a scene around the surface without hiding the visualizer, breaking deterministic video rendering, or overwhelming television-class hardware.

What began as a background effect became a small scene system shared by the web app, Android and TV builds, desktop application, and headless video renderer. It also led to a particularly useful mobile graphics investigation: the finished environment worked on desktop, yet silently vanished on a Pixel even though the torus, particles, and controls all continued to work.

From a Background to Three Scene Layers

A single monolithic effect would have been easy to prototype and hard to control. Instead, the scene is composed from three independent layers:

Each layer can be disabled independently, and black remains a deliberate safe fallback. Four presets — Black, Ambient, Energy, and Deep Space — provide useful combinations without taking away the individual controls. Cycling a preset changes the layer modes but preserves the user's particle speed, environment speed, aura size, and environment intensity.

pub struct SceneConfig {
    pub environment: EnvironmentMode,
    pub particles: ParticleMode,
    pub aura: AuraMode,
    pub particle_speed: u16,
    pub environment_speed: u16,
    pub environment_intensity: u16,
    pub aura_size: u16,
}

The same typed configuration travels through query strings, desktop CLI arguments, saved playlists, and the video renderer. Defaults make older configurations load safely when new fields appear. Unknown or retired values are ignored without discarding valid sibling settings.

Composition Order Matters

The shared renderer draws the environment first, then additive particles and aura, followed by the depth-tested 3D surface and finally the OSD. The environment writes opaque color; particles and aura add light without erasing it; and the surface remains the visual focus.

environment
  + particles
  + echo aura
  + depth-tested visualizer surface
  + on-screen display

The ordinary 2D Screen mode still bypasses these layers. That preserves its established presentation while allowing scene choices to remain configured for the next 3D mode.

Making Audio Move a Scene Without Shaking It

The first attempts at audio-reactive movement exposed an important distinction between changing a position and changing a velocity. A stateless shader often computes a coordinate from something resembling time * speed. If the current audio level directly changes speed, every particle jumps to a different position on the same frame. Modulating a random displacement has the same problem: the directions differ, but the entire field still moves discontinuously.

The solution is to integrate motion on the CPU. Audio selects target rates, those rates ease toward their targets, and persistent phase clocks advance from frame to frame:

// Audio bands choose continuous rates.
ease(
    &mut self.flow_rate,
    0.35 + 1.2 * intensity + 0.6 * bass,
);
ease(&mut self.swirl_rate, 0.30 + 1.8 * midrange);
ease(&mut self.shimmer_rate, 1.2 + 5.0 * treble);

// User controls scale future motion, not accumulated position.
self.flow_phase += dt * self.flow_rate * particle_speed;
self.env_flow_phase += dt * self.flow_rate * environment_speed;

Loud passages become wind, midrange energy stirs the field, and treble quickens shimmer. Because the shaders derive positions from continuous phases, a beat can accelerate the scene without teleporting it. Binary beat events are reserved for light flashes, while geometry breathes from smoothed intensity.

Deterministic headless rendering made this measurable rather than a matter of taste. Frame-to-frame RMSE of the background region put the original tuning at about 0.1% change per second — statistically frozen — while the final version sits near 4–5%. The same measurement separated the failed attempts from the working one: audio coupled into positions or amplitudes shows up as whole-field displacement spikes on every beat, while the phase-clock version shows a smooth swell and decay with no frame-to-frame discontinuity.

The clocks reset when a timeline restarts. That small detail preserves fixed-step determinism for regenerated videos: the same playlist and audio produce the same scene motion.

Palette Light, Not Generic Fog

A background should feel like part of the current visualizer rather than a stock nebula placed behind it. The environment therefore samples the active 256-color palette texture. One early version interpolated between two sampled RGB colors, which often crossed muddy brown or olive values. The better approach interpolates the palette coordinate and then performs one lookup:

let sample = mix(low, high, cloud_density);
let color = palette(sample);

This follows the palette designer's gradient instead of inventing a straight line through RGB space. Density shaping keeps most of the nebula in darker palette regions and reserves bright colors for filament cores. An intensity control then ranges from subtle ambience to a highly visible 600% presentation, with 400% as the current default.

Portable Randomness and Continuous Particles

The first particle prototype used a familiar sine-based float hash. The application seed pushed its argument to roughly eighteen million radians, outside the useful precision range of some GPU sine implementations. On one driver, particle positions collapsed to invalid values and every instance was clipped. Nothing crashed and nothing was logged — the layer simply was not there. Because every frontend feeds the shader the same seed, the entire particle system had been running invisibly on desktop, web, and TV alike until a headless capture went looking for it.

Particle placement now stays in integer space until the final conversion to a unit float:

fn hash_u32(value: u32) -> u32 {
    var x = value;
    x = x ^ (x >> 16u);
    x = x * 0x7feb352du;
    x = x ^ (x >> 15u);
    x = x * 0x846ca68bu;
    return x ^ (x >> 16u);
}

fn hash01(value: u32) -> f32 {
    return f32(hash_u32(value) & 0xffffffu) / 16777216.0;
}

This produces deterministic placement without depending on transcendental precision. Motion still comes from the integrated clocks, so particles remain stable while their flow responds to music.

One Scene Model Across Every Frontend

The layers are useful only if people can reach them. Power mode on the web exposes the preset, environment, particles, aura, intensity, size, and speed controls. The TV interface opens a dedicated D-pad scene submenu rather than squeezing every option into the main menu. Desktop keyboard controls cycle the curated presets, and video playlists store the same fields for deterministic offline rendering.

The TV menu deliberately navigates typed menu items rather than numeric row positions. Adding or conditionally hiding a row no longer shifts the meaning of every later index. Adaptive quality remains future work: capability probing and startup calibration should decide how much scene complexity a particular TV can sustain rather than assuming that every television is slow.

The Environment That Vanished on Pixel

With the larger system working, a Pixel 10 showed a narrower and stranger failure. The app reported WebGPU, the scene configuration contained Volumetric Nebula at high intensity, and the torus rendered normally. Particles could appear, but the environment stayed black. Chromium logged no shader, pipeline, or bind-group validation error.

We reduced the fragment shader to a constant magenta output. It still disappeared. Separating the environment into its own shader module and minimal bind-group layout ruled out unrelated scene resources, but the pass remained black. Finally, rendering the fullscreen quad as two explicit triangles revealed the clue: only one malformed triangular region appeared.

The problem was the vertex shader's dynamically indexed constant array of clip-space positions. That compact pattern worked on desktop WebGPU but was miscompiled by the Pixel's Dawn path. The robust replacement computes one oversized fullscreen triangle directly from vertex_index, with no array access:

@vertex
fn vs_environment(
    @builtin(vertex_index) vertex_index: u32,
) -> EnvironmentOutput {
    let x = f32((vertex_index << 1u) & 2u);
    let y = f32(vertex_index & 2u);
    let position = vec2<f32>(x, y) * 2.0 - 1.0;

    var out: EnvironmentOutput;
    out.position = vec4<f32>(position, 0.0, 1.0);
    out.uv = vec2<f32>(x, y);
    return out;
}

Three generated vertices cover the viewport, including the portions of the oversized triangle outside clip space. The arithmetic version filled the Pixel canvas immediately. Restoring the real palette shader brought back the nebula at both the 400% default and 600% maximum.

What We Took Away

Cross-device graphics failures are not always expensive-shader failures. This one survived a simpler fragment shader, isolated resources, and clean validation logs because the failure was in a tiny vertex generator. Constant-color probes and deliberately visible geometry made the silent corruption observable. It was the second silent failure of the project — the sine-hash collapse produced the same clean logs and the same quietly missing layer — and both were caught the same way: by rendering frames and looking at them, rather than trusting that a running pipeline meant a working one.

More broadly, making the background worthwhile required treating it as a scene rather than decoration: independent layers, shared typed configuration, palette-native color, continuous audio motion, deterministic clocks, and controls suited to each device. The black background is still there when desired, but it is now one intentional scene choice instead of the only empty space available.