Der Loader zieht jetzt die baseColorTexture jedes Materials aus dem BIN-Chunk und dekodiert sie (PNG, via png-Crate) → model::Texture mit Pixeln. Die Namenskonvention "Materialname = Dateiname" und der assets/textures/-Pfad für Welt-Texturen entfallen; assets/textures/ bleibt nur noch für UI/Fonts/Platzhalter. - engine::image: neuer, geteilter RGBA8-Image-Typ (aus tga.rs gezogen) plus PNG-Decoder. tga.rs liefert weiter denselben Typ. - gltf: material -> texture -> image -> bufferView auflösen, Bild aus BIN dekodieren; fehlt ein Glied, bleibt image None (Platzhalter). KHR_texture_transform (Blenders Mapping-Node) wird beim Laden in die UVs eingebacken. - model::Model: materials: Vec<String> -> textures: Vec<Texture>. - render: die Pixel je Schlüssel aus den Modellen sammeln (erstes Vorkommen gewinnt, Schlüssel ohne Bild -> Platzhalter). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0196AM4UyAJEXVtPvp5DdKr8
126 lines
4.7 KiB
Rust
126 lines
4.7 KiB
Rust
//! Ambient-Emitter aus Blender-Modellen: Empties mit `sound`-Custom-Property
|
|
//! sind positionale Dauerschleifen — `sound = <name>` löst zu
|
|
//! `assets/sounds/{name}.wav` auf (macht der Frontend-Treiber), `radius`
|
|
//! (Meter, optional) bestimmt die Distance ramp.
|
|
//!
|
|
//! Headless wie `trigger`: hier lebt nur die *Logik* — wer sendet wo, und
|
|
//! wie laut ist das gerade für den Hörer ([`Emitters::gains`], lineare
|
|
//! Rampe [`distance_gain`]). Gerätezugriff und Mixing sind Sache des
|
|
//! Frontend-Treibers (render::audio), der die Gains jeden Frame zieht und
|
|
//! auf seine Voices legt — die Engine ruft nie ins Frontend.
|
|
//!
|
|
//! Einmal-SFX laufen nicht hier, sondern über den Signal-Dispatcher
|
|
//! (`play_sound <name>` → `Action::PlaySound` → `Session::take_sounds`).
|
|
|
|
use crate::engine::model::Model;
|
|
|
|
/// Custom-Property-Schlüssel, der ein Empty zum Ambient-Emitter macht
|
|
/// (Wert = Sound-Name). Emitter loopen immer; „an/aus" macht die Autorin
|
|
/// über Distanz/Radius oder (später) per Signal.
|
|
pub const SOUND_PROP: &str = "sound";
|
|
/// Optionaler Hörradius in Metern (Custom Property am selben Empty).
|
|
pub const RADIUS_PROP: &str = "radius";
|
|
const DEFAULT_RADIUS: f32 = 8.0;
|
|
|
|
pub struct Emitter {
|
|
pub sound: String,
|
|
pos: [f32; 3],
|
|
radius: f32,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct Emitters {
|
|
list: Vec<Emitter>,
|
|
}
|
|
|
|
impl Emitters {
|
|
pub fn new() -> Self { Self::default() }
|
|
|
|
/// Emitter eines Modells übernehmen: alle Empties mit `sound`-Property.
|
|
pub fn add_model(&mut self, model: &Model) {
|
|
for e in &model.empties {
|
|
let Some(sound) = e.props.get(SOUND_PROP) else { continue; };
|
|
let radius = e.props.get(RADIUS_PROP)
|
|
.and_then(|r| r.parse::<f32>().ok())
|
|
.filter(|r| *r > 0.0)
|
|
.unwrap_or(DEFAULT_RADIUS);
|
|
self.list.push(Emitter { sound: sound.clone(), pos: e.pos, radius });
|
|
}
|
|
}
|
|
|
|
/// Die Emitter in fester Reihenfolge (Voice-Zuordnung des Treibers).
|
|
pub fn list(&self) -> &[Emitter] { &self.list }
|
|
|
|
/// Aktueller Gain je Emitter (parallel zu [`Emitters::list`]) für einen
|
|
/// Hörer bei `listener` — jeden Frame ziehen, auf die Voices legen.
|
|
pub fn gains(&self, listener: [f32; 3]) -> Vec<f32> {
|
|
self.list.iter().map(|e| {
|
|
let d = [
|
|
e.pos[0] - listener[0],
|
|
e.pos[1] - listener[1],
|
|
e.pos[2] - listener[2],
|
|
];
|
|
let dist = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
|
distance_gain(dist, e.radius)
|
|
}).collect()
|
|
}
|
|
}
|
|
|
|
/// Lineare Distance ramp: volle Lautstärke am Emitter, Stille ab `radius`.
|
|
/// Bewusst linear statt 1/d² — bei Zimmer-Skalen klingt die gerade Rampe
|
|
/// kontrollierbarer, und die Autorin kann per `radius` direkt denken.
|
|
pub fn distance_gain(dist: f32, radius: f32) -> f32 {
|
|
(1.0 - dist / radius).clamp(0.0, 1.0)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::engine::model::{Empty, Props};
|
|
|
|
fn model(empties: Vec<Empty>) -> Model {
|
|
Model { objects: Vec::new(), empties, textures: Vec::new() }
|
|
}
|
|
|
|
fn empty(name: &str, pos: [f32; 3], props: &[(&str, &str)]) -> Empty {
|
|
let mut p = Props::new();
|
|
for (k, v) in props { p.insert((*k).into(), (*v).into()); }
|
|
Empty { name: name.into(), pos, props: p }
|
|
}
|
|
|
|
#[test]
|
|
fn collects_sound_empties_with_radius() {
|
|
let mut em = Emitters::new();
|
|
em.add_model(&model(vec![
|
|
empty("Radio", [5.0, 0.0, 0.0], &[("sound", "hum"), ("radius", "4")]),
|
|
empty("spawn", [0.0, 0.0, 0.0], &[("role", "spawn")]),
|
|
empty("Bach", [9.0, 0.0, 0.0], &[("sound", "wasser")]),
|
|
]));
|
|
assert_eq!(em.list().len(), 2, "nur Empties mit sound-Property");
|
|
assert_eq!(em.list()[0].sound, "hum");
|
|
assert_eq!(em.list()[1].radius, DEFAULT_RADIUS);
|
|
}
|
|
|
|
#[test]
|
|
fn gains_ramp_linearly_to_radius() {
|
|
let mut em = Emitters::new();
|
|
em.add_model(&model(vec![
|
|
empty("Radio", [0.0, 0.0, 0.0], &[("sound", "hum"), ("radius", "4")]),
|
|
]));
|
|
assert_eq!(em.gains([0.0, 0.0, 0.0]), vec![1.0]);
|
|
assert_eq!(em.gains([2.0, 0.0, 0.0]), vec![0.5]);
|
|
assert_eq!(em.gains([4.0, 0.0, 0.0]), vec![0.0]);
|
|
assert_eq!(em.gains([9.0, 0.0, 0.0]), vec![0.0], "hinter dem Radius still");
|
|
}
|
|
|
|
#[test]
|
|
fn broken_radius_falls_back_to_default() {
|
|
let mut em = Emitters::new();
|
|
em.add_model(&model(vec![
|
|
empty("A", [0.0; 3], &[("sound", "x"), ("radius", "kaputt")]),
|
|
empty("B", [0.0; 3], &[("sound", "y"), ("radius", "-2")]),
|
|
]));
|
|
assert!(em.list().iter().all(|e| e.radius == DEFAULT_RADIUS));
|
|
}
|
|
}
|