feat(fx): play ambient Control sound commands
Docs Deploy / Build and Deploy MkDocs (push) Successful in 38s
Docs Deploy / Build and Deploy MkDocs (push) Successful in 38s
This commit is contained in:
7 files changed
+569
-20
No files matched your search
@@ -629,6 +629,7 @@ pub(crate) struct GameAudio {
|
|||||||
music: Player,
|
music: Player,
|
||||||
variations: Player,
|
variations: Player,
|
||||||
weather_loop: NativeSpatialPlayer,
|
weather_loop: NativeSpatialPlayer,
|
||||||
|
control_sounds: BTreeMap<usize, NativeSpatialPlayer>,
|
||||||
one_shots: Vec<SpatialOneShot>,
|
one_shots: Vec<SpatialOneShot>,
|
||||||
library: AudioLibrary,
|
library: AudioLibrary,
|
||||||
ambience: AmbienceScheduler,
|
ambience: AmbienceScheduler,
|
||||||
@@ -672,6 +673,7 @@ impl GameAudio {
|
|||||||
music,
|
music,
|
||||||
variations,
|
variations,
|
||||||
weather_loop,
|
weather_loop,
|
||||||
|
control_sounds: BTreeMap::new(),
|
||||||
one_shots: Vec::new(),
|
one_shots: Vec::new(),
|
||||||
library,
|
library,
|
||||||
ambience: AmbienceScheduler::new(random_seed(), random_seed()),
|
ambience: AmbienceScheduler::new(random_seed(), random_seed()),
|
||||||
@@ -705,6 +707,11 @@ impl GameAudio {
|
|||||||
self.weather_loop
|
self.weather_loop
|
||||||
.set_emitter_position(self.weather_position);
|
.set_emitter_position(self.weather_position);
|
||||||
self.retain_active_one_shots();
|
self.retain_active_one_shots();
|
||||||
|
self.control_sounds.retain(|_, player| !player.empty());
|
||||||
|
for player in self.control_sounds.values() {
|
||||||
|
player.set_left_ear_position(left);
|
||||||
|
player.set_right_ear_position(right);
|
||||||
|
}
|
||||||
for one_shot in &self.one_shots {
|
for one_shot in &self.one_shots {
|
||||||
one_shot.player.set_left_ear_position(left);
|
one_shot.player.set_left_ear_position(left);
|
||||||
one_shot.player.set_right_ear_position(right);
|
one_shot.player.set_right_ear_position(right);
|
||||||
@@ -825,19 +832,74 @@ impl GameAudio {
|
|||||||
SoundAction::OneShot => {
|
SoundAction::OneShot => {
|
||||||
self.retain_active_one_shots();
|
self.retain_active_one_shots();
|
||||||
let asset = self.dynamic_sound(&event.archive, &event.name)?;
|
let asset = self.dynamic_sound(&event.archive, &event.name)?;
|
||||||
self.one_shots.push(self.new_spatial_one_shot(
|
self.one_shots.push(self.new_spatial_voice(
|
||||||
&asset,
|
&asset,
|
||||||
event.position,
|
event.position,
|
||||||
volume,
|
volume,
|
||||||
min_distance,
|
min_distance,
|
||||||
max_distance,
|
max_distance,
|
||||||
frequency_ratio,
|
frequency_ratio,
|
||||||
|
false,
|
||||||
)?);
|
)?);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Each Control command owns its source; it must not replace the rain
|
||||||
|
/// loop or another instance using the same sample.
|
||||||
|
pub(crate) fn handle_control_sound(
|
||||||
|
&mut self,
|
||||||
|
id: usize,
|
||||||
|
event: &SoundEvent,
|
||||||
|
) -> Result<(), AudioError> {
|
||||||
|
validate_point(event.position, "Control emitter")?;
|
||||||
|
let frequency_ratio = if event.frequency_ratio.is_finite() && event.frequency_ratio > 0.0 {
|
||||||
|
event.frequency_ratio
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
match event.action {
|
||||||
|
SoundAction::StartLoop | SoundAction::OneShot => {
|
||||||
|
let asset = self.dynamic_sound(&event.archive, &event.name)?;
|
||||||
|
let voice = self.new_spatial_voice(
|
||||||
|
&asset,
|
||||||
|
event.position,
|
||||||
|
event.volume,
|
||||||
|
event.min_distance,
|
||||||
|
event.max_distance,
|
||||||
|
frequency_ratio,
|
||||||
|
event.action == SoundAction::StartLoop,
|
||||||
|
)?;
|
||||||
|
if let Some(previous) = self.control_sounds.insert(id, voice.player) {
|
||||||
|
previous.stop();
|
||||||
|
}
|
||||||
|
eprintln!("control_sound_start id={id} resource={}:{} loop={} position={:?} range={}/{} frequency={}", event.archive, event.name, event.action == SoundAction::StartLoop, event.position, event.min_distance, event.max_distance, event.frequency_ratio);
|
||||||
|
}
|
||||||
|
SoundAction::SetLoopVolume => {
|
||||||
|
if let Some(player) = self.control_sounds.get(&id) {
|
||||||
|
player.set_emitter_position(event.position);
|
||||||
|
player.set_sound_parameters(event.min_distance, event.max_distance);
|
||||||
|
player.set_volume(event.volume);
|
||||||
|
player.set_speed(frequency_ratio);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SoundAction::StopLoop => {
|
||||||
|
if let Some(player) = self.control_sounds.remove(&id) {
|
||||||
|
player.stop();
|
||||||
|
eprintln!("control_sound_stop id={id}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn control_sound_is_playing(&self, id: usize) -> bool {
|
||||||
|
self.control_sounds
|
||||||
|
.get(&id)
|
||||||
|
.is_some_and(|player| !player.empty())
|
||||||
|
}
|
||||||
|
|
||||||
/// Selects one of the original MUSIC/Track02..10.ogg files and loops it.
|
/// Selects one of the original MUSIC/Track02..10.ogg files and loops it.
|
||||||
pub(crate) fn play_music_track(&mut self, track: u8) -> Result<(), AudioError> {
|
pub(crate) fn play_music_track(&mut self, track: u8) -> Result<(), AudioError> {
|
||||||
let bytes = self.library.music_bytes(track)?;
|
let bytes = self.library.music_bytes(track)?;
|
||||||
@@ -875,6 +937,13 @@ impl GameAudio {
|
|||||||
one_shot.player.play();
|
one_shot.player.play();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for player in self.control_sounds.values() {
|
||||||
|
if paused {
|
||||||
|
player.pause();
|
||||||
|
} else {
|
||||||
|
player.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Focus callback used by the window event loop.
|
/// Focus callback used by the window event loop.
|
||||||
@@ -888,6 +957,9 @@ impl GameAudio {
|
|||||||
self.music.stop();
|
self.music.stop();
|
||||||
self.variations.stop();
|
self.variations.stop();
|
||||||
self.weather_loop.stop();
|
self.weather_loop.stop();
|
||||||
|
for player in self.control_sounds.values() {
|
||||||
|
player.stop();
|
||||||
|
}
|
||||||
for one_shot in &self.one_shots {
|
for one_shot in &self.one_shots {
|
||||||
one_shot.player.stop();
|
one_shot.player.stop();
|
||||||
}
|
}
|
||||||
@@ -904,7 +976,7 @@ impl GameAudio {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new_spatial_one_shot(
|
fn new_spatial_voice(
|
||||||
&self,
|
&self,
|
||||||
asset: &SoundAsset,
|
asset: &SoundAsset,
|
||||||
emitter: [f32; 3],
|
emitter: [f32; 3],
|
||||||
@@ -912,6 +984,7 @@ impl GameAudio {
|
|||||||
min_distance: f32,
|
min_distance: f32,
|
||||||
max_distance: f32,
|
max_distance: f32,
|
||||||
frequency_ratio: f32,
|
frequency_ratio: f32,
|
||||||
|
looping: bool,
|
||||||
) -> Result<SpatialOneShot, AudioError> {
|
) -> Result<SpatialOneShot, AudioError> {
|
||||||
validate_point(emitter, "emitter")?;
|
validate_point(emitter, "emitter")?;
|
||||||
let (left, right) = ear_positions(self.listener)?;
|
let (left, right) = ear_positions(self.listener)?;
|
||||||
@@ -919,7 +992,12 @@ impl GameAudio {
|
|||||||
player.set_sound_parameters(min_distance, max_distance);
|
player.set_sound_parameters(min_distance, max_distance);
|
||||||
player.set_volume(volume);
|
player.set_volume(volume);
|
||||||
player.set_speed(frequency_ratio);
|
player.set_speed(frequency_ratio);
|
||||||
player.append(decoder_for(asset)?);
|
let source = decoder_for(asset)?;
|
||||||
|
if looping {
|
||||||
|
player.append(source.repeat_infinite());
|
||||||
|
} else {
|
||||||
|
player.append(source);
|
||||||
|
}
|
||||||
if self.paused {
|
if self.paused {
|
||||||
player.pause();
|
player.pause();
|
||||||
} else {
|
} else {
|
||||||
@@ -1695,6 +1773,42 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.expect("update original rain loop volume");
|
.expect("update original rain loop volume");
|
||||||
assert_eq!(audio.weather_loop.volume(), 0.5);
|
assert_eq!(audio.weather_loop.volume(), 0.5);
|
||||||
|
let mut machine = SoundEvent {
|
||||||
|
archive: "sounds.lib".to_string(),
|
||||||
|
name: "f_gener.wav".to_string(),
|
||||||
|
position: [1.0, 2.0, 3.0],
|
||||||
|
volume: 0.0,
|
||||||
|
min_distance: 15.0,
|
||||||
|
max_distance: 100.0,
|
||||||
|
frequency_ratio: 1.0,
|
||||||
|
action: SoundAction::StartLoop,
|
||||||
|
};
|
||||||
|
audio
|
||||||
|
.handle_control_sound(0, &machine)
|
||||||
|
.expect("first machine");
|
||||||
|
audio
|
||||||
|
.handle_control_sound(1, &machine)
|
||||||
|
.expect("second machine");
|
||||||
|
assert!(audio.control_sound_is_playing(0));
|
||||||
|
assert!(audio.control_sound_is_playing(1));
|
||||||
|
assert!(!audio.weather_loop.empty());
|
||||||
|
audio.set_paused(true);
|
||||||
|
assert!(audio
|
||||||
|
.control_sounds
|
||||||
|
.values()
|
||||||
|
.all(|player| player.is_paused()));
|
||||||
|
audio.set_paused(false);
|
||||||
|
assert!(audio
|
||||||
|
.control_sounds
|
||||||
|
.values()
|
||||||
|
.all(|player| !player.is_paused()));
|
||||||
|
machine.action = SoundAction::StopLoop;
|
||||||
|
audio
|
||||||
|
.handle_control_sound(0, &machine)
|
||||||
|
.expect("stop one machine");
|
||||||
|
assert!(!audio.control_sound_is_playing(0));
|
||||||
|
assert!(audio.control_sound_is_playing(1));
|
||||||
|
assert!(!audio.weather_loop.empty());
|
||||||
audio
|
audio
|
||||||
.update_listener([10.0, 20.0, 30.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0])
|
.update_listener([10.0, 20.0, 30.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0])
|
||||||
.expect("move listener");
|
.expect("move listener");
|
||||||
|
|||||||
+113
-17
@@ -18,6 +18,7 @@ use fparkan_assets::{
|
|||||||
use fparkan_fx::ambient_op3::{
|
use fparkan_fx::ambient_op3::{
|
||||||
evaluate_op3_type0_command, evaluate_op3_type1_command, evaluate_op3_type2_command,
|
evaluate_op3_type0_command, evaluate_op3_type1_command, evaluate_op3_type2_command,
|
||||||
};
|
};
|
||||||
|
use fparkan_fx::ambient_sound::{Op2SoundCommand, Op2SoundState};
|
||||||
use fparkan_fx::atmosphere::{AtmosphereFrame, TypedAtmosphere};
|
use fparkan_fx::atmosphere::{AtmosphereFrame, TypedAtmosphere};
|
||||||
use fparkan_fx::environment::{
|
use fparkan_fx::environment::{
|
||||||
decode_env_lightning_fxid, evaluate_op1_point_light_with_basis, Camera as FxCamera,
|
decode_env_lightning_fxid, evaluate_op1_point_light_with_basis, Camera as FxCamera,
|
||||||
@@ -707,7 +708,7 @@ struct AmbientEffectMeshPrimitive {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
struct ControlPointLightSlot {
|
struct ControlEffectSlot {
|
||||||
time_mode: u32,
|
time_mode: u32,
|
||||||
header_flags: u32,
|
header_flags: u32,
|
||||||
random_instance_key: ControlEffectInstanceKey,
|
random_instance_key: ControlEffectInstanceKey,
|
||||||
@@ -728,6 +729,13 @@ struct ControlPointLightSlot {
|
|||||||
command: FxCommand,
|
command: FxCommand,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct ControlSoundSlot {
|
||||||
|
effect: ControlEffectSlot,
|
||||||
|
command: Op2SoundCommand,
|
||||||
|
state: Op2SoundState,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
struct ControlEffectInstanceKey {
|
struct ControlEffectInstanceKey {
|
||||||
object_index: usize,
|
object_index: usize,
|
||||||
@@ -4617,7 +4625,9 @@ struct StaticPreviewScene {
|
|||||||
materials: Vec<VulkanStaticMaterial>,
|
materials: Vec<VulkanStaticMaterial>,
|
||||||
material_animations: Vec<MaterialAnimationBinding>,
|
material_animations: Vec<MaterialAnimationBinding>,
|
||||||
ambient_effect_slots: Vec<AmbientEffectGpuSlot>,
|
ambient_effect_slots: Vec<AmbientEffectGpuSlot>,
|
||||||
control_point_light_slots: Vec<ControlPointLightSlot>,
|
control_point_light_slots: Vec<ControlEffectSlot>,
|
||||||
|
control_sound_slots: Vec<ControlSoundSlot>,
|
||||||
|
unsupported_control_sounds: usize,
|
||||||
component_pose_buffers: Vec<Vec<Option<NodePoseBuffer>>>,
|
component_pose_buffers: Vec<Vec<Option<NodePoseBuffer>>>,
|
||||||
component_mount_poses: Vec<Vec<Option<VulkanNodePose>>>,
|
component_mount_poses: Vec<Vec<Option<VulkanNodePose>>>,
|
||||||
dynamic_component_geometry: Vec<Vec<Option<DynamicComponentGeometry>>>,
|
dynamic_component_geometry: Vec<Vec<Option<DynamicComponentGeometry>>>,
|
||||||
@@ -5253,8 +5263,8 @@ fn ambient_effect_world_from_component_pose_with_random_shift(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn control_point_light_world_from_component_pose_with_random_shift(
|
fn control_effect_world_from_component_pose_with_random_shift(
|
||||||
slot: &ControlPointLightSlot,
|
slot: &ControlEffectSlot,
|
||||||
component_pose_buffers: &[Vec<Option<NodePoseBuffer>>],
|
component_pose_buffers: &[Vec<Option<NodePoseBuffer>>],
|
||||||
random_shift: [f32; 3],
|
random_shift: [f32; 3],
|
||||||
) -> Option<[[f32; 4]; 4]> {
|
) -> Option<[[f32; 4]; 4]> {
|
||||||
@@ -5278,7 +5288,7 @@ fn control_point_light_world_from_component_pose_with_random_shift(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn update_control_point_lights(
|
fn update_control_point_lights(
|
||||||
slots: &mut [ControlPointLightSlot],
|
slots: &mut [ControlEffectSlot],
|
||||||
component_pose_buffers: &[Vec<Option<NodePoseBuffer>>],
|
component_pose_buffers: &[Vec<Option<NodePoseBuffer>>],
|
||||||
component_mount_poses: &[Vec<Option<VulkanNodePose>>],
|
component_mount_poses: &[Vec<Option<VulkanNodePose>>],
|
||||||
elapsed_seconds: f32,
|
elapsed_seconds: f32,
|
||||||
@@ -5298,13 +5308,11 @@ fn update_control_point_lights(
|
|||||||
.get(&slot.random_instance_key)
|
.get(&slot.random_instance_key)
|
||||||
.copied()
|
.copied()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let Some(world_from_effect) =
|
let Some(world_from_effect) = control_effect_world_from_component_pose_with_random_shift(
|
||||||
control_point_light_world_from_component_pose_with_random_shift(
|
|
||||||
slot,
|
slot,
|
||||||
component_pose_buffers,
|
component_pose_buffers,
|
||||||
random_sample.accumulated_shift,
|
random_sample.accumulated_shift,
|
||||||
)
|
) else {
|
||||||
else {
|
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
slot.world_from_effect = world_from_effect;
|
slot.world_from_effect = world_from_effect;
|
||||||
@@ -5461,7 +5469,9 @@ fn append_initial_control_effect_slots(
|
|||||||
effect_documents: &mut HashMap<String, FxDocument>,
|
effect_documents: &mut HashMap<String, FxDocument>,
|
||||||
effect_materials: &mut HashMap<String, PreparedEffectMaterial>,
|
effect_materials: &mut HashMap<String, PreparedEffectMaterial>,
|
||||||
slots: &mut Vec<AmbientEffectGpuSlot>,
|
slots: &mut Vec<AmbientEffectGpuSlot>,
|
||||||
point_light_slots: &mut Vec<ControlPointLightSlot>,
|
point_light_slots: &mut Vec<ControlEffectSlot>,
|
||||||
|
sound_slots: &mut Vec<ControlSoundSlot>,
|
||||||
|
unsupported_sounds: &mut usize,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let Some(point_list) = control.point_list.as_ref() else {
|
let Some(point_list) = control.point_list.as_ref() else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -5491,6 +5501,11 @@ fn append_initial_control_effect_slots(
|
|||||||
.get(&key)
|
.get(&key)
|
||||||
.ok_or_else(|| format!("Control FX {key} was not cached"))?;
|
.ok_or_else(|| format!("Control FX {key} was not cached"))?;
|
||||||
let header = document.header();
|
let header = document.header();
|
||||||
|
let sound_commands = document
|
||||||
|
.commands()
|
||||||
|
.iter()
|
||||||
|
.filter(|command| command.opcode == FxOpcode::Op2)
|
||||||
|
.count();
|
||||||
let duration_seconds = header.duration_seconds;
|
let duration_seconds = header.duration_seconds;
|
||||||
if !duration_seconds.is_finite()
|
if !duration_seconds.is_finite()
|
||||||
|| duration_seconds < 0.0
|
|| duration_seconds < 0.0
|
||||||
@@ -5504,6 +5519,7 @@ fn append_initial_control_effect_slots(
|
|||||||
// scale conventions are outside this ambient subset. Leave them
|
// scale conventions are outside this ambient subset. Leave them
|
||||||
// unexpanded until their lifetime and transform contract are
|
// unexpanded until their lifetime and transform contract are
|
||||||
// understood rather than failing the whole mission preview.
|
// understood rather than failing the whole mission preview.
|
||||||
|
*unsupported_sounds += sound_commands;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let owner_node_index =
|
let owner_node_index =
|
||||||
@@ -5515,6 +5531,7 @@ fn append_initial_control_effect_slots(
|
|||||||
|| (*owner_node == 0 && component_pose_buffer.is_none())
|
|| (*owner_node == 0 && component_pose_buffer.is_none())
|
||||||
});
|
});
|
||||||
let Some(owner_node_index) = owner_node_index else {
|
let Some(owner_node_index) = owner_node_index else {
|
||||||
|
*unsupported_sounds += sound_commands;
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let owner_node_pose = control_effect_owner_node_pose(
|
let owner_node_pose = control_effect_owner_node_pose(
|
||||||
@@ -5523,6 +5540,7 @@ fn append_initial_control_effect_slots(
|
|||||||
owner_node_index,
|
owner_node_index,
|
||||||
);
|
);
|
||||||
let Some(owner_node_pose) = owner_node_pose else {
|
let Some(owner_node_pose) = owner_node_pose else {
|
||||||
|
*unsupported_sounds += sound_commands;
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let root_transform = LegacyIron3dEulerTransform {
|
let root_transform = LegacyIron3dEulerTransform {
|
||||||
@@ -5559,8 +5577,8 @@ fn append_initial_control_effect_slots(
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (command_index, command) in document.commands().iter().enumerate() {
|
for (command_index, command) in document.commands().iter().enumerate() {
|
||||||
if ambient_effect_op1_supported(command) {
|
if ambient_effect_op1_supported(command) || command.opcode == FxOpcode::Op2 {
|
||||||
point_light_slots.push(ControlPointLightSlot {
|
let slot = ControlEffectSlot {
|
||||||
time_mode: header.time_mode,
|
time_mode: header.time_mode,
|
||||||
header_flags: header.flags,
|
header_flags: header.flags,
|
||||||
random_instance_key,
|
random_instance_key,
|
||||||
@@ -5579,7 +5597,21 @@ fn append_initial_control_effect_slots(
|
|||||||
world_from_effect,
|
world_from_effect,
|
||||||
command_index,
|
command_index,
|
||||||
command: command.clone(),
|
command: command.clone(),
|
||||||
|
};
|
||||||
|
if command.opcode == FxOpcode::Op2 {
|
||||||
|
if let Some(sound) = Op2SoundCommand::from_command(command) {
|
||||||
|
sound_slots.push(ControlSoundSlot {
|
||||||
|
effect: slot,
|
||||||
|
command: sound,
|
||||||
|
state: Op2SoundState::default(),
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
*unsupported_sounds += 1;
|
||||||
|
eprintln!("unsupported Control Op2 object={object_index} component={component_index} effect={key} command={command_index} (mode, backend parameter or malformed body)");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
point_light_slots.push(slot);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(kind) = ambient_effect_command_kind(command) else {
|
let Some(kind) = ambient_effect_command_kind(command) else {
|
||||||
@@ -5935,7 +5967,8 @@ fn control_effect_instance_seed(
|
|||||||
fn advance_control_effect_random_states(
|
fn advance_control_effect_random_states(
|
||||||
states: &mut HashMap<ControlEffectInstanceKey, ControlEffectRandomState>,
|
states: &mut HashMap<ControlEffectInstanceKey, ControlEffectRandomState>,
|
||||||
ambient_slots: &[AmbientEffectGpuSlot],
|
ambient_slots: &[AmbientEffectGpuSlot],
|
||||||
point_light_slots: &[ControlPointLightSlot],
|
point_light_slots: &[ControlEffectSlot],
|
||||||
|
sound_slots: &[ControlSoundSlot],
|
||||||
sample_id: SceneSampleId,
|
sample_id: SceneSampleId,
|
||||||
control_clock_ms: u32,
|
control_clock_ms: u32,
|
||||||
elapsed_seconds: f32,
|
elapsed_seconds: f32,
|
||||||
@@ -5958,7 +5991,10 @@ fn advance_control_effect_random_states(
|
|||||||
initial_phase,
|
initial_phase,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
for slot in point_light_slots {
|
for slot in point_light_slots
|
||||||
|
.iter()
|
||||||
|
.chain(sound_slots.iter().map(|slot| &slot.effect))
|
||||||
|
{
|
||||||
let initial_phase = ambient_effect_phase(
|
let initial_phase = ambient_effect_phase(
|
||||||
slot.time_mode,
|
slot.time_mode,
|
||||||
slot.header_flags,
|
slot.header_flags,
|
||||||
@@ -7928,6 +7964,8 @@ fn static_preview_mesh_and_materials(
|
|||||||
let mut material_animations = Vec::new();
|
let mut material_animations = Vec::new();
|
||||||
let mut ambient_effect_slots = Vec::new();
|
let mut ambient_effect_slots = Vec::new();
|
||||||
let mut control_point_light_slots = Vec::new();
|
let mut control_point_light_slots = Vec::new();
|
||||||
|
let mut control_sound_slots = Vec::new();
|
||||||
|
let mut unsupported_control_sounds = 0;
|
||||||
let mut effect_documents = HashMap::new();
|
let mut effect_documents = HashMap::new();
|
||||||
let mut effect_materials = HashMap::new();
|
let mut effect_materials = HashMap::new();
|
||||||
let mut component_pose_buffers_by_object = Vec::with_capacity(roots.len());
|
let mut component_pose_buffers_by_object = Vec::with_capacity(roots.len());
|
||||||
@@ -8085,6 +8123,8 @@ fn static_preview_mesh_and_materials(
|
|||||||
&mut effect_materials,
|
&mut effect_materials,
|
||||||
&mut ambient_effect_slots,
|
&mut ambient_effect_slots,
|
||||||
&mut control_point_light_slots,
|
&mut control_point_light_slots,
|
||||||
|
&mut control_sound_slots,
|
||||||
|
&mut unsupported_control_sounds,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8231,6 +8271,8 @@ fn static_preview_mesh_and_materials(
|
|||||||
&mut effect_materials,
|
&mut effect_materials,
|
||||||
&mut ambient_effect_slots,
|
&mut ambient_effect_slots,
|
||||||
&mut control_point_light_slots,
|
&mut control_point_light_slots,
|
||||||
|
&mut control_sound_slots,
|
||||||
|
&mut unsupported_control_sounds,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8479,6 +8521,8 @@ fn static_preview_mesh_and_materials(
|
|||||||
material_animations,
|
material_animations,
|
||||||
ambient_effect_slots,
|
ambient_effect_slots,
|
||||||
control_point_light_slots,
|
control_point_light_slots,
|
||||||
|
control_sound_slots,
|
||||||
|
unsupported_control_sounds,
|
||||||
component_pose_buffers: component_pose_buffers_by_object,
|
component_pose_buffers: component_pose_buffers_by_object,
|
||||||
component_mount_poses: component_mount_poses_by_object,
|
component_mount_poses: component_mount_poses_by_object,
|
||||||
dynamic_component_geometry: dynamic_component_geometry_by_object,
|
dynamic_component_geometry: dynamic_component_geometry_by_object,
|
||||||
@@ -9262,7 +9306,9 @@ struct StaticVulkanApp {
|
|||||||
materials: Vec<VulkanStaticMaterial>,
|
materials: Vec<VulkanStaticMaterial>,
|
||||||
material_animations: Vec<MaterialAnimationBinding>,
|
material_animations: Vec<MaterialAnimationBinding>,
|
||||||
ambient_effect_slots: Vec<AmbientEffectGpuSlot>,
|
ambient_effect_slots: Vec<AmbientEffectGpuSlot>,
|
||||||
control_point_light_slots: Vec<ControlPointLightSlot>,
|
control_point_light_slots: Vec<ControlEffectSlot>,
|
||||||
|
control_sound_slots: Vec<ControlSoundSlot>,
|
||||||
|
unsupported_control_sounds: usize,
|
||||||
control_effect_random_states: HashMap<ControlEffectInstanceKey, ControlEffectRandomState>,
|
control_effect_random_states: HashMap<ControlEffectInstanceKey, ControlEffectRandomState>,
|
||||||
component_pose_buffers: Vec<Vec<Option<NodePoseBuffer>>>,
|
component_pose_buffers: Vec<Vec<Option<NodePoseBuffer>>>,
|
||||||
component_mount_poses: Vec<Vec<Option<VulkanNodePose>>>,
|
component_mount_poses: Vec<Vec<Option<VulkanNodePose>>>,
|
||||||
@@ -9367,6 +9413,8 @@ impl StaticVulkanApp {
|
|||||||
material_animations: preview.material_animations,
|
material_animations: preview.material_animations,
|
||||||
ambient_effect_slots: preview.ambient_effect_slots,
|
ambient_effect_slots: preview.ambient_effect_slots,
|
||||||
control_point_light_slots: preview.control_point_light_slots,
|
control_point_light_slots: preview.control_point_light_slots,
|
||||||
|
control_sound_slots: preview.control_sound_slots,
|
||||||
|
unsupported_control_sounds: preview.unsupported_control_sounds,
|
||||||
control_effect_random_states: HashMap::new(),
|
control_effect_random_states: HashMap::new(),
|
||||||
component_pose_buffers: preview.component_pose_buffers,
|
component_pose_buffers: preview.component_pose_buffers,
|
||||||
component_mount_poses: preview.component_mount_poses,
|
component_mount_poses: preview.component_mount_poses,
|
||||||
@@ -9454,6 +9502,7 @@ impl StaticVulkanApp {
|
|||||||
})
|
})
|
||||||
.count();
|
.count();
|
||||||
eprintln!("render_coverage control_op1_jitter_unsupported={randomized_lights} (using authored color and radius; native RNG sampling pending)");
|
eprintln!("render_coverage control_op1_jitter_unsupported={randomized_lights} (using authored color and radius; native RNG sampling pending)");
|
||||||
|
eprintln!("render_coverage control_op2_supported={} control_op2_unsupported={} (unsupported timing, backend mode or parameters are skipped)", self.control_sound_slots.len(), self.unsupported_control_sounds);
|
||||||
eprintln!("render_coverage fog_pass_eligibility_approx={} (global world fog and horizon RGB; native shade-state/color verification pending)", self.world_range_indices.len());
|
eprintln!("render_coverage fog_pass_eligibility_approx={} (global world fog and horizon RGB; native shade-state/color verification pending)", self.world_range_indices.len());
|
||||||
for &range_index in &self.world_range_indices {
|
for &range_index in &self.world_range_indices {
|
||||||
let range = self
|
let range = self
|
||||||
@@ -9840,6 +9889,7 @@ impl StaticVulkanApp {
|
|||||||
&mut self.control_effect_random_states,
|
&mut self.control_effect_random_states,
|
||||||
&self.ambient_effect_slots,
|
&self.ambient_effect_slots,
|
||||||
&self.control_point_light_slots,
|
&self.control_point_light_slots,
|
||||||
|
&self.control_sound_slots,
|
||||||
clock.id,
|
clock.id,
|
||||||
control_clock_ms,
|
control_clock_ms,
|
||||||
effect_elapsed_seconds,
|
effect_elapsed_seconds,
|
||||||
@@ -10030,6 +10080,47 @@ impl StaticVulkanApp {
|
|||||||
audio
|
audio
|
||||||
.update_listener(camera.position, camera.forward, camera.up)
|
.update_listener(camera.position, camera.forward, camera.up)
|
||||||
.map_err(|error| format!("update audio listener: {error}"))?;
|
.map_err(|error| format!("update audio listener: {error}"))?;
|
||||||
|
for (id, sound) in self.control_sound_slots.iter_mut().enumerate() {
|
||||||
|
let slot = &mut sound.effect;
|
||||||
|
if slot.owner_component_is_mounted {
|
||||||
|
slot.mount_pose = self
|
||||||
|
.component_mount_poses
|
||||||
|
.get(slot.owner_object_index)
|
||||||
|
.and_then(|components| components.get(slot.owner_component_index))
|
||||||
|
.copied()
|
||||||
|
.flatten();
|
||||||
|
}
|
||||||
|
let random = control_effect_random_samples
|
||||||
|
.get(&slot.random_instance_key)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let Some(matrix) = control_effect_world_from_component_pose_with_random_shift(
|
||||||
|
slot,
|
||||||
|
&self.component_pose_buffers,
|
||||||
|
random.accumulated_shift,
|
||||||
|
) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(phase) = ambient_effect_phase(
|
||||||
|
slot.time_mode,
|
||||||
|
slot.header_flags,
|
||||||
|
effect_elapsed_seconds,
|
||||||
|
slot.duration_seconds,
|
||||||
|
)
|
||||||
|
.map(|phase| native_command_phase(slot.header_flags, phase, random.phase_jitter)) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Some(event) = sound.command.update(
|
||||||
|
&mut sound.state,
|
||||||
|
phase,
|
||||||
|
matrix,
|
||||||
|
audio.control_sound_is_playing(id),
|
||||||
|
) {
|
||||||
|
audio
|
||||||
|
.handle_control_sound(id, &event)
|
||||||
|
.map_err(|error| format!("play Control sound {id}: {error}"))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let environment = if let Some(environment) = self.environment.as_mut() {
|
let environment = if let Some(environment) = self.environment.as_mut() {
|
||||||
Some(environment.update_cpu(
|
Some(environment.update_cpu(
|
||||||
@@ -13471,7 +13562,7 @@ mod tests {
|
|||||||
table_index: 0,
|
table_index: 0,
|
||||||
row_index: 0,
|
row_index: 0,
|
||||||
};
|
};
|
||||||
let control_slot = ControlPointLightSlot {
|
let control_slot = ControlEffectSlot {
|
||||||
time_mode: 1,
|
time_mode: 1,
|
||||||
header_flags: 9,
|
header_flags: 9,
|
||||||
random_instance_key: key,
|
random_instance_key: key,
|
||||||
@@ -13591,6 +13682,8 @@ mod tests {
|
|||||||
material_animations: vec![material_animation],
|
material_animations: vec![material_animation],
|
||||||
ambient_effect_slots: Vec::new(),
|
ambient_effect_slots: Vec::new(),
|
||||||
control_point_light_slots: vec![control_slot],
|
control_point_light_slots: vec![control_slot],
|
||||||
|
control_sound_slots: Vec::new(),
|
||||||
|
unsupported_control_sounds: 0,
|
||||||
component_pose_buffers: vec![vec![None]],
|
component_pose_buffers: vec![vec![None]],
|
||||||
component_mount_poses: vec![vec![None]],
|
component_mount_poses: vec![vec![None]],
|
||||||
dynamic_component_geometry: Vec::new(),
|
dynamic_component_geometry: Vec::new(),
|
||||||
@@ -16379,7 +16472,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn op1_control_lights_use_native_phase_owner_transform_and_flags() {
|
fn op1_control_lights_use_native_phase_owner_transform_and_flags() {
|
||||||
let mut slot = ControlPointLightSlot {
|
let mut slot = ControlEffectSlot {
|
||||||
time_mode: 1,
|
time_mode: 1,
|
||||||
header_flags: 0,
|
header_flags: 0,
|
||||||
random_instance_key: ControlEffectInstanceKey {
|
random_instance_key: ControlEffectInstanceKey {
|
||||||
@@ -16460,6 +16553,7 @@ mod tests {
|
|||||||
&mut states,
|
&mut states,
|
||||||
&[],
|
&[],
|
||||||
&sibling_slots,
|
&sibling_slots,
|
||||||
|
&[],
|
||||||
SceneSampleId::Replay(7),
|
SceneSampleId::Replay(7),
|
||||||
150,
|
150,
|
||||||
1.5,
|
1.5,
|
||||||
@@ -16484,6 +16578,7 @@ mod tests {
|
|||||||
&mut states,
|
&mut states,
|
||||||
&[],
|
&[],
|
||||||
&sibling_slots,
|
&sibling_slots,
|
||||||
|
&[],
|
||||||
SceneSampleId::Replay(7),
|
SceneSampleId::Replay(7),
|
||||||
150,
|
150,
|
||||||
1.5,
|
1.5,
|
||||||
@@ -16495,6 +16590,7 @@ mod tests {
|
|||||||
&mut states,
|
&mut states,
|
||||||
&[],
|
&[],
|
||||||
&sibling_slots,
|
&sibling_slots,
|
||||||
|
&[],
|
||||||
SceneSampleId::Replay(8),
|
SceneSampleId::Replay(8),
|
||||||
200,
|
200,
|
||||||
0.5,
|
0.5,
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
//! Opcode-2 sound commands, independent of an audio device.
|
||||||
|
|
||||||
|
use crate::environment::{SoundAction, SoundEvent};
|
||||||
|
use crate::{FxCommand, FxOpcode};
|
||||||
|
|
||||||
|
/// A validated ordinary Op2 command. Modes 1/3 request an unverified backend
|
||||||
|
/// flag and are left to the caller's unsupported-command report.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Op2SoundCommand {
|
||||||
|
looping: bool,
|
||||||
|
start: f32,
|
||||||
|
end: f32,
|
||||||
|
position: [f32; 3],
|
||||||
|
position_delta: [f32; 3],
|
||||||
|
min_distance: f32,
|
||||||
|
max_distance: f32,
|
||||||
|
frequency: f32,
|
||||||
|
frequency_delta: f32,
|
||||||
|
archive: String,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Playback crossing state belongs to a command instance, even when several
|
||||||
|
/// instances share the same sample.
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct Op2SoundState {
|
||||||
|
previous_phase: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Op2SoundCommand {
|
||||||
|
/// Decodes the supported native modes 0 (once) and 2 (loop).
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_command(command: &FxCommand) -> Option<Self> {
|
||||||
|
let body = command.raw_body.as_ref();
|
||||||
|
if command.opcode != FxOpcode::Op2 || body.len() != 144 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mode = u32::from_le_bytes(body[..4].try_into().ok()?);
|
||||||
|
// shortcut: backend flag 0x200 and body+76 need a native playback
|
||||||
|
// comparison before modes 1/3 or nonzero backend parameters are enabled.
|
||||||
|
if !matches!(mode, 0 | 2) || body[76..80] != [0; 4] {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let float = |offset| {
|
||||||
|
let value = f32::from_le_bytes(body[offset..offset + 4].try_into().ok()?);
|
||||||
|
value.is_finite().then_some(value)
|
||||||
|
};
|
||||||
|
let vector = |offset| Some([float(offset)?, float(offset + 4)?, float(offset + 8)?]);
|
||||||
|
let start = float(4)?;
|
||||||
|
let end = float(8)?;
|
||||||
|
let position = vector(12)?;
|
||||||
|
let position_end = vector(24)?;
|
||||||
|
let min_distance = float(60)?;
|
||||||
|
let max_distance = float(64)?;
|
||||||
|
let frequency = float(68)?;
|
||||||
|
let frequency_end = float(72)?;
|
||||||
|
let position_delta = std::array::from_fn(|axis| position_end[axis] - position[axis]);
|
||||||
|
let frequency_delta = frequency_end - frequency;
|
||||||
|
if end <= start
|
||||||
|
|| !(end - start).is_finite()
|
||||||
|
|| !position_delta.iter().all(|value: &f32| value.is_finite())
|
||||||
|
|| !frequency_delta.is_finite()
|
||||||
|
|| min_distance < 0.0
|
||||||
|
|| max_distance < min_distance
|
||||||
|
|| frequency <= 0.0
|
||||||
|
|| frequency_end <= 0.0
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let text = |bytes: &[u8]| {
|
||||||
|
let end = bytes
|
||||||
|
.iter()
|
||||||
|
.position(|&byte| byte == 0)
|
||||||
|
.unwrap_or(bytes.len());
|
||||||
|
(!bytes[..end].is_empty()).then(|| String::from_utf8_lossy(&bytes[..end]).into_owned())
|
||||||
|
};
|
||||||
|
Some(Self {
|
||||||
|
looping: mode == 2,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
position,
|
||||||
|
position_delta,
|
||||||
|
min_distance,
|
||||||
|
max_distance,
|
||||||
|
frequency,
|
||||||
|
frequency_delta,
|
||||||
|
archive: text(&body[80..112])?,
|
||||||
|
name: text(&body[112..144])?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluates Effect.dll's phase crossings and current source parameters.
|
||||||
|
/// `playing` comes from the audio owner, since a one-shot can finish between
|
||||||
|
/// updates. The matrix maps local command coordinates into world space.
|
||||||
|
#[must_use]
|
||||||
|
pub fn update(
|
||||||
|
&self,
|
||||||
|
state: &mut Op2SoundState,
|
||||||
|
phase: f32,
|
||||||
|
world_from_effect: [[f32; 4]; 4],
|
||||||
|
playing: bool,
|
||||||
|
) -> Option<SoundEvent> {
|
||||||
|
if !phase.is_finite() || !world_from_effect.iter().flatten().all(|v| v.is_finite()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let action = if self.looping {
|
||||||
|
if phase >= self.start && phase <= self.end {
|
||||||
|
if playing {
|
||||||
|
SoundAction::SetLoopVolume
|
||||||
|
} else {
|
||||||
|
SoundAction::StartLoop
|
||||||
|
}
|
||||||
|
} else if playing {
|
||||||
|
SoundAction::StopLoop
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let crossing_phase = if phase == 1.0 { 0.0 } else { phase };
|
||||||
|
let crossed = state.previous_phase <= crossing_phase
|
||||||
|
&& state.previous_phase <= self.start
|
||||||
|
&& self.start < crossing_phase;
|
||||||
|
state.previous_phase = crossing_phase;
|
||||||
|
if crossed {
|
||||||
|
SoundAction::OneShot
|
||||||
|
} else if playing {
|
||||||
|
SoundAction::SetLoopVolume
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let t = (phase - self.start) / (self.end - self.start);
|
||||||
|
let local: [f32; 3] =
|
||||||
|
std::array::from_fn(|axis| self.position[axis] + t * self.position_delta[axis]);
|
||||||
|
let position = std::array::from_fn(|axis| {
|
||||||
|
let row = world_from_effect[axis];
|
||||||
|
row[0] * local[0] + row[1] * local[1] + row[2] * local[2] + row[3]
|
||||||
|
});
|
||||||
|
let frequency_ratio = self.frequency + t * self.frequency_delta;
|
||||||
|
if !position.iter().all(|value| value.is_finite()) || !frequency_ratio.is_finite() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(SoundEvent {
|
||||||
|
archive: self.archive.clone(),
|
||||||
|
name: self.name.clone(),
|
||||||
|
position,
|
||||||
|
volume: 1.0,
|
||||||
|
min_distance: self.min_distance,
|
||||||
|
max_distance: self.max_distance,
|
||||||
|
frequency_ratio,
|
||||||
|
action,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
const IDENTITY: [[f32; 4]; 4] = [
|
||||||
|
[1.0, 0.0, 0.0, 0.0],
|
||||||
|
[0.0, 1.0, 0.0, 0.0],
|
||||||
|
[0.0, 0.0, 1.0, 0.0],
|
||||||
|
[0.0, 0.0, 0.0, 1.0],
|
||||||
|
];
|
||||||
|
|
||||||
|
fn command(mode: u32) -> FxCommand {
|
||||||
|
let mut body = vec![0; 144];
|
||||||
|
body[..4].copy_from_slice(&mode.to_le_bytes());
|
||||||
|
for (offset, value) in [
|
||||||
|
(4, 0.25_f32),
|
||||||
|
(8, 0.75),
|
||||||
|
(60, 12.0),
|
||||||
|
(64, 80.0),
|
||||||
|
(68, 1.0),
|
||||||
|
(72, 2.0),
|
||||||
|
] {
|
||||||
|
body[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
|
||||||
|
}
|
||||||
|
body[80..90].copy_from_slice(b"sounds.lib");
|
||||||
|
body[112..122].copy_from_slice(b"f_defl.wav");
|
||||||
|
FxCommand {
|
||||||
|
word: 2,
|
||||||
|
opcode: FxOpcode::Op2,
|
||||||
|
native_flag: false,
|
||||||
|
raw_body: Arc::from(body),
|
||||||
|
resource_refs: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loop_has_inclusive_boundaries_and_updates_without_restarting() {
|
||||||
|
let command = Op2SoundCommand::from_command(&command(2)).unwrap();
|
||||||
|
let mut state = Op2SoundState::default();
|
||||||
|
assert!(command.update(&mut state, 0.0, IDENTITY, false).is_none());
|
||||||
|
assert_eq!(
|
||||||
|
command
|
||||||
|
.update(&mut state, 0.25, IDENTITY, false)
|
||||||
|
.unwrap()
|
||||||
|
.action,
|
||||||
|
SoundAction::StartLoop
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
command
|
||||||
|
.update(&mut state, 0.75, IDENTITY, true)
|
||||||
|
.unwrap()
|
||||||
|
.action,
|
||||||
|
SoundAction::SetLoopVolume
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
command
|
||||||
|
.update(&mut state, 0.8, IDENTITY, true)
|
||||||
|
.unwrap()
|
||||||
|
.action,
|
||||||
|
SoundAction::StopLoop
|
||||||
|
);
|
||||||
|
assert!(command.update(&mut state, 0.8, IDENTITY, false).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_shot_crossing_is_strict_and_rearms_after_phase_one() {
|
||||||
|
let command = Op2SoundCommand::from_command(&command(0)).unwrap();
|
||||||
|
let mut state = Op2SoundState::default();
|
||||||
|
assert!(command.update(&mut state, 0.25, IDENTITY, false).is_none());
|
||||||
|
assert_eq!(
|
||||||
|
command
|
||||||
|
.update(&mut state, 0.3, IDENTITY, false)
|
||||||
|
.unwrap()
|
||||||
|
.action,
|
||||||
|
SoundAction::OneShot
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
command
|
||||||
|
.update(&mut state, 0.5, IDENTITY, true)
|
||||||
|
.unwrap()
|
||||||
|
.action,
|
||||||
|
SoundAction::SetLoopVolume
|
||||||
|
);
|
||||||
|
assert!(command.update(&mut state, 0.6, IDENTITY, false).is_none());
|
||||||
|
assert!(command.update(&mut state, 1.0, IDENTITY, false).is_none());
|
||||||
|
assert_eq!(
|
||||||
|
command
|
||||||
|
.update(&mut state, 0.3, IDENTITY, false)
|
||||||
|
.unwrap()
|
||||||
|
.action,
|
||||||
|
SoundAction::OneShot
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parameters_keep_native_constant_ranges_and_transform_the_position() {
|
||||||
|
let mut source = command(2);
|
||||||
|
let mut body = source.raw_body.to_vec();
|
||||||
|
body[24..28].copy_from_slice(&4.0_f32.to_le_bytes());
|
||||||
|
source.raw_body = Arc::from(body);
|
||||||
|
let command = Op2SoundCommand::from_command(&source).unwrap();
|
||||||
|
let mut matrix = IDENTITY;
|
||||||
|
matrix[0] = [0.0, -1.0, 0.0, 10.0];
|
||||||
|
matrix[1] = [2.0, 0.0, 0.0, 20.0];
|
||||||
|
let event = command
|
||||||
|
.update(&mut Op2SoundState::default(), 0.5, matrix, false)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(event.position, [10.0, 24.0, 0.0]);
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
event.min_distance,
|
||||||
|
event.max_distance,
|
||||||
|
event.frequency_ratio
|
||||||
|
),
|
||||||
|
(12.0, 80.0, 1.5)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(event.archive.as_str(), event.name.as_str()),
|
||||||
|
("sounds.lib", "f_defl.wav")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backend_modes_and_malformed_commands_are_explicitly_unsupported() {
|
||||||
|
assert!(Op2SoundCommand::from_command(&command(1)).is_none());
|
||||||
|
let mut source = command(2);
|
||||||
|
let mut body = source.raw_body.to_vec();
|
||||||
|
body[8..12].copy_from_slice(&f32::NAN.to_le_bytes());
|
||||||
|
source.raw_body = Arc::from(body);
|
||||||
|
assert!(Op2SoundCommand::from_command(&source).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
//! are backed by runtime-captured evidence.
|
//! are backed by runtime-captured evidence.
|
||||||
|
|
||||||
pub mod ambient_op3;
|
pub mod ambient_op3;
|
||||||
|
pub mod ambient_sound;
|
||||||
pub mod atmosphere;
|
pub mod atmosphere;
|
||||||
pub mod environment;
|
pub mod environment;
|
||||||
pub mod fx_visual;
|
pub mod fx_visual;
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ Control Op1 пока использует цвет и радиус без слу
|
|||||||
это явное приближение считается в `render_coverage control_op1_jitter_unsupported`.
|
это явное приближение считается в `render_coverage control_op1_jitter_unsupported`.
|
||||||
Масштаб радиуса длиной преобразованного направления и нижняя замена 0,01 восстановлены.
|
Масштаб радиуса длиной преобразованного направления и нижняя замена 0,01 восстановлены.
|
||||||
|
|
||||||
|
Control Op2 воспроизводит modes 0/2; modes 1/3, body+76 и неподдержанные
|
||||||
|
time modes/привязки считаются как `render_coverage control_op2_unsupported`.
|
||||||
|
Управление фазой экземпляра командами Control остаётся до его подключения в runtime.
|
||||||
|
|
||||||
Размеры команд, resource references, lifecycle, flags families и используемые
|
Размеры команд, resource references, lifecycle, flags families и используемые
|
||||||
time modes известны. Не закрыто значение каждого поля body opcodes 1--10,
|
time modes известны. Не закрыто значение каждого поля body opcodes 1--10,
|
||||||
отсутствующий во всех проверенных каталогах opcode 6 и точные формулы редких
|
отсутствующий во всех проверенных каталогах opcode 6 и точные формулы редких
|
||||||
|
|||||||
@@ -218,6 +218,41 @@ OneShot -> воспроизвести разовый звук молни
|
|||||||
отдельный spatial source, применяет DirectSound range/pan и текущий listener.
|
отдельный spatial source, применяет DirectSound range/pan и текущий listener.
|
||||||
Отсутствие устройства вывода не должно останавливать симуляцию.
|
Отсутствие устройства вывода не должно останавливать симуляцию.
|
||||||
|
|
||||||
|
### Звуки Control
|
||||||
|
|
||||||
|
Команда Op2 может принадлежать не погоде, а экземпляру эффекта на узле модели.
|
||||||
|
`fparkan-fx::ambient_sound` хранит её временное окно и предыдущее значение
|
||||||
|
фазы. В режиме 0 разовый звук начинается при строгом пересечении начала окна:
|
||||||
|
`previous <= start && start < phase`, если фаза движется вперёд. Значение
|
||||||
|
`phase == 1` для этой проверки заменяется нулём и подготавливает следующий
|
||||||
|
цикл. Режим 2 включает loop на обеих границах окна; внутри окна уже работающий
|
||||||
|
источник обновляется, а не запускается заново. Выход за окно останавливает его.
|
||||||
|
|
||||||
|
Позиция интерполируется между двумя локальными точками и проходит через ту же
|
||||||
|
матрицу владельца, что и графические команды. Минимальная и максимальная
|
||||||
|
дистанции остаются постоянными; частота интерполируется по фазе окна.
|
||||||
|
Каждая команда получает отдельный источник звука, даже если ресурс совпадает
|
||||||
|
с ресурсом соседней команды. Дождь сохраняет свой loop. Перемещение слушателя,
|
||||||
|
потеря фокуса и завершение приложения затрагивают все эти источники.
|
||||||
|
|
||||||
|
Эти ветви восстановлены в GOG `Effect.dll` (сборка из
|
||||||
|
[таблицы бинарников](original-binaries.md)): init RVA `0x12D10`, update
|
||||||
|
`0x12EB0`. Живой loop `sounds.lib:f_defl.wav` передаёт в звуковой backend
|
||||||
|
диапазон `12/80`, частоту `1` и мировую позицию из матрицы владельца.
|
||||||
|
В режиме 2 признак loop берётся из первого DWORD body, а не из command flag.
|
||||||
|
|
||||||
|
Обычное создание эффекта Control сохраняет time mode заголовка и задаёт
|
||||||
|
постоянную фазу нулём. Raw selector строки служит ID экземпляра, а не номером
|
||||||
|
режима времени. Поэтому `f_comp_sfx` с time mode 0 и началом окна `0.001`
|
||||||
|
остаётся тихим до управляющей команды Control. Preview такие команды ещё не
|
||||||
|
исполняет. Периодические звуки с time mode 2 уже следуют общим часам и текущей
|
||||||
|
позе узла. `render_coverage control_op2_supported` считает подготовленные
|
||||||
|
команды, а `control_sound_start/stop` показывает фактические переходы.
|
||||||
|
|
||||||
|
Неизвестные backend modes 1/3, ненулевое поле body+76, неподдержанное время
|
||||||
|
или привязка владельца пропускаются с `render_coverage control_op2_unsupported`.
|
||||||
|
Они не заменяются разовым звуком или произвольным loop.
|
||||||
|
|
||||||
## Цвет солнца
|
## Цвет солнца
|
||||||
|
|
||||||
Солнце берёт цвет спрайта и прозрачность бликов из `palette[10]` расписания
|
Солнце берёт цвет спрайта и прозрачность бликов из `palette[10]` расписания
|
||||||
|
|||||||
@@ -845,6 +845,17 @@ projected shadows. Радиус источника масштабируется
|
|||||||
синий свет завода почти незаметным. Неположительный итог заменяется на 0,01.
|
синий свет завода почти незаметным. Неположительный итог заменяется на 0,01.
|
||||||
Случайные добавки к цвету и радиусу пока явно считаются в
|
Случайные добавки к цвету и радиусу пока явно считаются в
|
||||||
`render_coverage control_op1_jitter_unsupported`.
|
`render_coverage control_op1_jitter_unsupported`.
|
||||||
|
|
||||||
|
Эффект может одновременно рисовать поверхность и издавать звук. Для Op2
|
||||||
|
приложение создаёт отдельный звуковой источник у каждого экземпляра команды:
|
||||||
|
несколько одинаковых механизмов слышны одновременно и не заменяют дождь.
|
||||||
|
Позиция следует анимируемому узлу, а фаза включает, обновляет и останавливает
|
||||||
|
loop. Обычная строка Control создаёт экземпляр с нулевой постоянной фазой;
|
||||||
|
звуки с таким режимом ожидают отдельной управляющей команды. Периодические
|
||||||
|
звуки работают по часам уже в preview. Устройство вывода остаётся в приложении,
|
||||||
|
а правила переходов и параметров — в `fparkan-fx::ambient_sound`
|
||||||
|
([подробнее](../reference/environment-effects.md#звуки-control)).
|
||||||
|
|
||||||
Туман также зависит от расстояния нелинейно: между ближней и дальней границами
|
Туман также зависит от расстояния нелинейно: между ближней и дальней границами
|
||||||
движок интерполирует по квадрату дистанции. При границах 0 и 700 точка в 350
|
движок интерполирует по квадрату дистанции. При границах 0 и 700 точка в 350
|
||||||
получает четверть тумана, а не половину. Это сохраняет контраст среднего плана.
|
получает четверть тумана, а не половину. Это сохраняет контраст среднего плана.
|
||||||
|
|||||||
Reference in new issue
Block a user