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.
Comment on lines +41 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Align the opt-in claim with the actual platform behavior.

Line 43 says HW ray-query is opt-in everywhere, but docs/pt/pt-roadmap.md Lines 105-110 says macOS/Linux request ray query by default and only Windows uses the explicit opt-in gates. Narrow this note to the platforms that implement opt-in gating, or update the platform behavior so both roadmaps describe the same contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/perf/lumen-roadmap.md` around lines 41 - 44, Align the as-built
correction with the platform behavior documented in the PT roadmap: either
narrow the opt-in statement to platforms gated by BLOOM_HW_GI, BLOOM_PT, or
--pt, or update the platform defaults so macOS/Linux and Windows share the same
contract. Ensure the “HW path” capability-versus-default clarification remains
accurate across both roadmap documents.


| 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`) |
97 changes: 97 additions & 0 deletions native/ios/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -807,11 +807,92 @@ pub extern "C" fn bloom_window_should_close() -> f64 {
if engine().should_close { 1.0 } else { 0.0 }
}

/// Poll the first connected game controller (MFi / Xbox / PlayStation) through
/// the GameController framework and feed it into the shared input state.
///
/// iOS had NO gamepad support before this despite iPhone/iPad supporting MFi and
/// console controllers — the shared gamepad FFI read `engine().input`, which
/// nothing populated. Resolved dynamically via the ObjC runtime so no
/// GameController link dep is needed; a no-op when absent. Identical to the
/// macOS poll (compile-verified there); button/axis indices match the
/// tvOS/visionOS map. Values are ObjC `float`, read as `f32` (reading them as
/// `f64` is an ABI mismatch).
///
/// NOTE: compile-verified on the macOS twin; not yet run on an iOS device.
fn poll_game_controllers() {
use objc2::runtime::{AnyClass, AnyObject, Bool};
macro_rules! btn_down {
($btn:expr, $eng:expr, $idx:expr) => {{
let b: Retained<AnyObject> = $btn;
let pressed: Bool = msg_send![&*b, isPressed];
if pressed.as_bool() { $eng.input.set_gamepad_button_down($idx); }
}};
}
unsafe {
let gc_cls = match AnyClass::get(c"GCController") {
Some(c) => c,
None => return,
};
let controllers: Retained<AnyObject> = msg_send![gc_cls, controllers];
let count: usize = msg_send![&*controllers, count];
if count == 0 {
return; // no pad: leave any injected gamepad state untouched
}
let controller: Retained<AnyObject> = msg_send![&*controllers, objectAtIndex: 0usize];
let extended: *mut AnyObject = msg_send![&*controller, extendedGamepad];
if extended.is_null() {
return;
}
let extended: &AnyObject = &*extended;

let eng = engine();
eng.input.reset_gamepad();
eng.input.gamepad_available = true;

let ls: Retained<AnyObject> = msg_send![extended, leftThumbstick];
let ls_x: Retained<AnyObject> = msg_send![&*ls, xAxis];
let ls_y: Retained<AnyObject> = msg_send![&*ls, yAxis];
let lx: f32 = msg_send![&*ls_x, value];
let ly: f32 = msg_send![&*ls_y, value];
eng.input.set_gamepad_axis(0, lx);
eng.input.set_gamepad_axis(1, -ly);
let rs: Retained<AnyObject> = msg_send![extended, rightThumbstick];
let rs_x: Retained<AnyObject> = msg_send![&*rs, xAxis];
let rs_y: Retained<AnyObject> = msg_send![&*rs, yAxis];
let rx: f32 = msg_send![&*rs_x, value];
let ry: f32 = msg_send![&*rs_y, value];
eng.input.set_gamepad_axis(2, rx);
eng.input.set_gamepad_axis(3, -ry);

btn_down!(msg_send![extended, buttonA], eng, 0);
btn_down!(msg_send![extended, buttonB], eng, 1);
btn_down!(msg_send![extended, buttonX], eng, 2);
btn_down!(msg_send![extended, buttonY], eng, 3);
btn_down!(msg_send![extended, leftShoulder], eng, 4);
btn_down!(msg_send![extended, rightShoulder], eng, 5);
let dpad: Retained<AnyObject> = msg_send![extended, dpad];
btn_down!(msg_send![&*dpad, up], eng, 12);
btn_down!(msg_send![&*dpad, down], eng, 13);
btn_down!(msg_send![&*dpad, left], eng, 14);
btn_down!(msg_send![&*dpad, right], eng, 15);

let lt: Retained<AnyObject> = msg_send![extended, leftTrigger];
let rt: Retained<AnyObject> = msg_send![extended, rightTrigger];
let ltv: f32 = msg_send![&*lt, value];
let rtv: f32 = msg_send![&*rt, value];
eng.input.set_gamepad_axis(4, ltv);
eng.input.set_gamepad_axis(5, rtv);
}
}

#[no_mangle]
pub extern "C" fn bloom_begin_drawing() {
// No run loop pumping needed — UIApplicationMain handles the main run loop
// on its own thread. The game runs on the game thread.

// Poll a connected controller before either begin_frame path below.
poll_game_controllers();

// Update drawable size to match actual view bounds (handles orientation changes)
unsafe {
if let Some(view) = &UI_VIEW {
Expand Down Expand Up @@ -1010,6 +1091,22 @@ unsafe extern "C" fn audio_render_callback(
#[no_mangle]
pub extern "C" fn bloom_init_audio() {
unsafe {
// Configure the audio session BEFORE bringing up RemoteIO. Without a
// category the session defaults to SoloAmbient: audio is silenced by
// the ring/silent switch and stops when the screen locks (audit gap).
// `Playback` keeps game audio audible over the silent switch. Done via
// the ObjC runtime so the crate needs no AVFAudio link dependency; the
// category constants' NSString VALUES equal their symbol names, so we
// build the string directly rather than linking the extern constant.
// NOTE: written blind (no iOS SDK here) — verify on device.
if let Some(av_cls) = objc2::runtime::AnyClass::get(c"AVAudioSession") {
let session: Retained<objc2::runtime::AnyObject> = msg_send![av_cls, sharedInstance];
let cat = objc2_foundation::NSString::from_str("AVAudioSessionCategoryPlayback");
let null_err: *mut *mut objc2::runtime::AnyObject = std::ptr::null_mut();
let _: objc2::runtime::Bool = msg_send![&*session, setCategory: &*cat, error: null_err];
let _: objc2::runtime::Bool = msg_send![&*session, setActive: true, error: null_err];
}

// Hand the render half to the audio thread before the callback
// can fire. Idempotent: a second init keeps the existing renderer.
if AUDIO_RENDERER.is_none() {
Expand Down
93 changes: 93 additions & 0 deletions native/macos/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,95 @@ pub extern "C" fn bloom_window_should_close() -> f64 {
if engine().should_close { 1.0 } else { 0.0 }
}

/// Poll the first connected game controller (MFi / Xbox / PlayStation) through
/// the GameController framework and feed it into the shared input state.
///
/// Resolved dynamically via the ObjC runtime (`AnyClass::get`) so the crate
/// needs no GameController link dependency; a no-op when the framework or a
/// controller is absent. macOS had NO gamepad support before this — the FFI
/// read `engine().input`, which nothing populated, so `isGamepadAvailable`
/// was permanently false (audit finding). Button/axis indices match the
/// tvOS/visionOS map so a game's controls carry across Apple targets.
///
/// `GCControllerAxisInput.value` / `GCControllerButtonInput.value` are ObjC
/// `float`, so they are read as `f32` (reading them as `f64` is an ABI
/// mismatch — float and double return in different-width registers).
fn poll_game_controllers() {
use objc2::runtime::{AnyClass, AnyObject, Bool};
// Read `isPressed` off a `GCControllerButtonInput` and, if set, mark the
// mapped button down. `$btn` is any expression yielding the button object.
macro_rules! btn_down {
($btn:expr, $eng:expr, $idx:expr) => {{
let b: Retained<AnyObject> = $btn;
let pressed: Bool = msg_send![&*b, isPressed];
if pressed.as_bool() { $eng.input.set_gamepad_button_down($idx); }
}};
}
unsafe {
let gc_cls = match AnyClass::get(c"GCController") {
Some(c) => c,
None => return, // framework not present
};
let controllers: Retained<AnyObject> = msg_send![gc_cls, controllers];
let count: usize = msg_send![&*controllers, count];
if count == 0 {
return; // no pad: leave any injected gamepad state untouched
}
let controller: Retained<AnyObject> = msg_send![&*controllers, objectAtIndex: 0usize];
let extended: *mut AnyObject = msg_send![&*controller, extendedGamepad];
if extended.is_null() {
return; // e.g. a Siri Remote micro-gamepad; macOS games want a real pad
}
let extended: &AnyObject = &*extended;

let eng = engine();
// Clear last frame's polled inputs first: a poll only SETS the
// currently-pressed buttons/axes, so without this a released button
// stays down (begin_frame does not reset gamepad state).
eng.input.reset_gamepad();
eng.input.gamepad_available = true;

// Thumbsticks -> axes 0..3 (Y inverted so screen-up is +1).
let ls: Retained<AnyObject> = msg_send![extended, leftThumbstick];
let ls_x: Retained<AnyObject> = msg_send![&*ls, xAxis];
let ls_y: Retained<AnyObject> = msg_send![&*ls, yAxis];
let lx: f32 = msg_send![&*ls_x, value];
let ly: f32 = msg_send![&*ls_y, value];
eng.input.set_gamepad_axis(0, lx);
eng.input.set_gamepad_axis(1, -ly);
let rs: Retained<AnyObject> = msg_send![extended, rightThumbstick];
let rs_x: Retained<AnyObject> = msg_send![&*rs, xAxis];
let rs_y: Retained<AnyObject> = msg_send![&*rs, yAxis];
let rx: f32 = msg_send![&*rs_x, value];
let ry: f32 = msg_send![&*rs_y, value];
eng.input.set_gamepad_axis(2, rx);
eng.input.set_gamepad_axis(3, -ry);

// Face buttons A/B/X/Y -> 0/1/2/3.
btn_down!(msg_send![extended, buttonA], eng, 0);
btn_down!(msg_send![extended, buttonB], eng, 1);
btn_down!(msg_send![extended, buttonX], eng, 2);
btn_down!(msg_send![extended, buttonY], eng, 3);
// Shoulders -> 4/5.
btn_down!(msg_send![extended, leftShoulder], eng, 4);
btn_down!(msg_send![extended, rightShoulder], eng, 5);
// D-pad -> 12/13/14/15 (up/down/left/right).
let dpad: Retained<AnyObject> = msg_send![extended, dpad];
btn_down!(msg_send![&*dpad, up], eng, 12);
btn_down!(msg_send![&*dpad, down], eng, 13);
btn_down!(msg_send![&*dpad, left], eng, 14);
btn_down!(msg_send![&*dpad, right], eng, 15);

// Analog triggers -> axes 4/5.
let lt: Retained<AnyObject> = msg_send![extended, leftTrigger];
let rt: Retained<AnyObject> = msg_send![extended, rightTrigger];
let ltv: f32 = msg_send![&*lt, value];
let rtv: f32 = msg_send![&*rt, value];
eng.input.set_gamepad_axis(4, ltv);
eng.input.set_gamepad_axis(5, rtv);
}
}

#[no_mangle]
pub extern "C" fn bloom_begin_drawing() {
// Poll events
Expand Down Expand Up @@ -643,6 +732,10 @@ pub extern "C" fn bloom_begin_drawing() {
_ => {},
}

// Poll a connected game controller (no-op if none) before begin_frame
// snapshots input for the edge detectors.
poll_game_controllers();

engine().begin_frame();
}

Expand Down
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