Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ native/ Rust implementations (one crate per platform)
ios/ Metal + UIKit + Core Audio
tvos/ Metal + UIKit + GCController
windows/ DirectX 12 + Win32 + WASAPI
linux/ Vulkan/OpenGL + X11 + PulseAudio
linux/ Vulkan/OpenGL + X11 + ALSA
android/ Vulkan/OpenGL ES + NativeActivity + AAudio
web/ WebGPU/WebGL + Canvas + Web Audio API (WASM via wasm-pack)
visionos/ Metal + UIKit-style shell (wgpu; iOS/tvOS-family port)
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,8 @@ native/ Rust implementations
macos/ Metal + AppKit + Core Audio
ios/ Metal + UIKit + Core Audio
tvos/ Metal + UIKit + GCController
windows/ DirectX 12 + Win32 + XAudio2
linux/ Vulkan/OpenGL + X11/Wayland + PulseAudio
windows/ DirectX 12 + Win32 + WASAPI
linux/ Vulkan/OpenGL + X11 + ALSA
android/ Vulkan/OpenGL ES + NativeActivity + AAudio
web/ WebGPU/WebGL + Canvas + Web Audio (WASM)

Expand Down
15 changes: 10 additions & 5 deletions docs/perf/lumen-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,17 @@ deferred), so numbering skips directly from 013/014 to 016.

## Platform matrix

| Platform | SW path | HW path | Notes |
> **As-built correction (2026-07):** the "HW path" column below describes
> *capability*, not the default. The HW ray-query path is **opt-in**
> everywhere (`BLOOM_HW_GI=1` / `BLOOM_PT` / `--pt`); SW GI is the shipping
> default on every platform. See the as-built note under the table.

| Platform | SW path | HW path (opt-in) | Notes |
|---|---|---|---|
| macOS | yes (Phase 1a) | yes after 007-prep (Metal ray-query) | Unblocked by wgpu upgrade |
| iOS | yes | yes after 007-prep | Same |
| tvOS | yes | yes after 007-prep | Same |
| Windows | yes | yes (DXR) | Works today post-upgrade |
| macOS | yes (Phase 1a) | capable after 007-prep (Metal ray-query) | Unblocked by wgpu upgrade |
| iOS | yes | capable after 007-prep | Same |
| tvOS | yes | capable after 007-prep | Same |
| Windows | yes | capable (DXR) | Works post-upgrade; opt-in only |
| Linux | yes | adapter-gated (Vulkan ray-query) | Runtime feature check; SW fallback |
| Android | yes | adapter-gated | Most Android GPUs lack RT; SW expected in practice |
| Web | yes | never | No WebGPU RT spec; SW-only permanently |
Expand Down
2 changes: 2 additions & 0 deletions docs/pt/pt-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,5 +132,7 @@ ray query by default (`BLOOM_FORCE_SW_GI` disables it).
| PT-1 | Progressive megakernel, accumulation, FFI mode plumbing, shooter toggle |
| PT-2 | Geometry megabuffer, interpolated hit attributes, texture binding array, GGX+MIS, emissive hits |
| PT-3 | Realtime mode: temporal reprojection + à-trous, half-res option, perf gate |
| PT-3b | SVGF denoiser for realtime mode — landed 2026-07-14 (`PT-3b-svgf-denoiser.md`) |
| PT-4 | ReSTIR DI (experimental flag) |
| PT-5 | Settings/editor/gameplay integration, fallback matrix, reference-diff CI hook |
| PT-6/7/8 | Skinned meshes in the TLAS, real skinned motion vectors, golden-image correctness oracle — landed 2026-07-14 (`PT-6-7-8-skinned-tlas-motion-oracle.md`) |
83 changes: 81 additions & 2 deletions native/shared/src/audio/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,15 @@ impl Reverb {
}
}

/// Hard cap on simultaneously-playing voices. The render thread NEVER grows
/// `voices` past this — a heap reallocation on the audio thread is a glitch
/// (and a priority inversion against the malloc lock). At the cap, a new play
/// steals the quietest voice instead. `voices` is preallocated to this size.
const MAX_VOICES: usize = 64;
/// Same discipline for music tracks. Practically 1-2 ever play at once; the
/// cap only exists so the render thread can never reallocate.
const MAX_MUSIC: usize = 4;

pub struct AudioRenderer {
rx: Consumer<Cmd>,
voices: Vec<Voice>,
Expand All @@ -357,8 +366,8 @@ impl AudioRenderer {
pub(super) fn new(rx: Consumer<Cmd>) -> Self {
Self {
rx,
voices: Vec::with_capacity(64),
music: Vec::with_capacity(4),
voices: Vec::with_capacity(MAX_VOICES),
music: Vec::with_capacity(MAX_MUSIC),
master: 1.0,
listener_pos: [0.0; 3],
listener_forward: [0.0, 0.0, -1.0],
Expand All @@ -380,6 +389,21 @@ impl AudioRenderer {
sound_id, voice_id, data, volume, spatial, looping,
ref_dist, max_dist, rolloff, pitch, bus, send, lowpass,
} => {
if self.voices.len() >= MAX_VOICES {
// Voice steal: drop the quietest non-stopping voice (least
// audible) so the push below can never reallocate. O(n) over
// <= MAX_VOICES, no allocation. `swap_remove` is O(1) and
// voice order does not affect the mix.
let victim = self.voices.iter().enumerate()
.filter(|(_, v)| !v.stopping)
.min_by(|(_, a), (_, b)| {
a.volume.partial_cmp(&b.volume)
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(i, _)| i)
.unwrap_or(0);
self.voices.swap_remove(victim);
}
self.voices.push(Voice {
sound_id, voice_id, data, frame_pos: 0.0, volume, spatial,
looping,
Expand Down Expand Up @@ -419,6 +443,13 @@ impl AudioRenderer {
ended: false,
},
};
if self.music.len() >= MAX_MUSIC {
// Evict the oldest track so the push can't reallocate. The
// evicted track's shared flag is cleared so its control-side
// handle reports stopped.
let old = self.music.remove(0);
old.shared.playing.store(false, Ordering::Relaxed);
}
self.music.push(MusicVoice { music_id, samples, shared, volume, looping, consumed: 0 });
}
Cmd::StopMusic { music_id } => {
Expand Down Expand Up @@ -811,3 +842,51 @@ impl AudioRenderer {
});
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::audio::spsc;

fn dummy_sound() -> Arc<SoundData> {
Arc::new(SoundData { samples: vec![0.0; 16], sample_rate: 44_100, channels: 1 })
}

fn play(id: u64, volume: f32) -> Cmd {
Cmd::PlaySound {
sound_id: id, voice_id: id, data: dummy_sound(), volume,
spatial: None, looping: false,
ref_dist: 1.0, max_dist: 0.0, rolloff: 1.0, pitch: 1.0,
bus: bus::SFX, send: 0.0, lowpass: 0.0,
}
}

// RT-safety: the render thread must never grow `voices` past its
// preallocated capacity — a malloc on the audio thread is a glitch.
#[test]
fn voices_never_exceed_cap_or_reallocate() {
let (_tx, rx) = spsc::channel::<Cmd>(8);
let mut r = AudioRenderer::new(rx);
let cap0 = r.voices.capacity();
for i in 0..(MAX_VOICES as u64 * 4) {
r.apply(play(i, 0.5 + (i % 7) as f32 * 0.01));
assert!(r.voices.len() <= MAX_VOICES);
}
assert_eq!(r.voices.len(), MAX_VOICES);
assert_eq!(r.voices.capacity(), cap0, "voices reallocated on the audio thread");
}

// At the cap, a new play steals the QUIETEST voice, not an arbitrary one.
#[test]
fn voice_steal_drops_the_quietest() {
let (_tx, rx) = spsc::channel::<Cmd>(8);
let mut r = AudioRenderer::new(rx);
for i in 0..(MAX_VOICES as u64 - 1) { r.apply(play(i, 1.0)); }
r.apply(play(9999, 0.01)); // quiet marker, brings us to the cap
assert_eq!(r.voices.len(), MAX_VOICES);
r.apply(play(10_000, 1.0)); // must evict the quiet marker
assert!(!r.voices.iter().any(|v| v.sound_id == 9999),
"the quietest voice should have been stolen");
assert_eq!(r.voices.len(), MAX_VOICES);
}
}
Loading
Loading