feat(fx): track phase and restart per effect instance
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
+379
-152
No files matched your search
@@ -6,8 +6,8 @@
|
||||
объектов, материалы, динамические тени, небо, погоду, lightning FX и звуковые
|
||||
события. Это просмотр карты с полной графической сценой; прохождение миссии и
|
||||
игровое поведение юнитов пока не запускаются. Модели начинают с настроек узлов
|
||||
прототипа, а поддерживаемые Control-анимации и эффекты обновляются по общим
|
||||
часам сцены. Неподдерживаемые Control-треки остаются на сохранённой начальной
|
||||
прототипа. Поддерживаемые Control-анимации идут по часам сцены, а эффекты — по
|
||||
часам своих экземпляров с общим начальным моментом. Неподдерживаемые Control-треки остаются на сохранённой начальной
|
||||
позе.
|
||||
Рельеф переключает исходные LOD0/LOD1 по ячейкам относительно текущей камеры;
|
||||
под основаниями строений сохраняется подробная сетка.
|
||||
|
||||
+149
-143
@@ -759,9 +759,11 @@ struct ControlEffectInstanceKey {
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct ControlEffectRandomSample {
|
||||
struct ControlEffectFrameSample {
|
||||
phase_jitter: f32,
|
||||
accumulated_shift: [f32; 3],
|
||||
phase: Option<f32>,
|
||||
elapsed_seconds: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -799,18 +801,19 @@ impl NativeFxRandom16 {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ControlEffectRandomState {
|
||||
struct ControlEffectInstanceState {
|
||||
header_flags: u32,
|
||||
phase_jitter_amplitude: f32,
|
||||
random_shift_amplitude: [f32; 3],
|
||||
phase_and_z_rng: NativeFxRandom16,
|
||||
xy_rng: NativeFxRandom16,
|
||||
sample: ControlEffectRandomSample,
|
||||
sample: ControlEffectFrameSample,
|
||||
last_update_id: Option<SceneSampleId>,
|
||||
last_update_clock_ms: Option<u32>,
|
||||
clock: Option<fparkan_fx::clock::FxInstanceClock>,
|
||||
}
|
||||
|
||||
impl ControlEffectRandomState {
|
||||
impl ControlEffectInstanceState {
|
||||
fn new(seed: u32, header_flags: u32, phase_jitter: f32, random_shift: [f32; 3]) -> Self {
|
||||
Self {
|
||||
header_flags,
|
||||
@@ -818,9 +821,10 @@ impl ControlEffectRandomState {
|
||||
random_shift_amplitude: random_shift,
|
||||
phase_and_z_rng: NativeFxRandom16::new(seed ^ 0xA511_E9B3),
|
||||
xy_rng: NativeFxRandom16::new(seed ^ 0x63D8_3595),
|
||||
sample: ControlEffectRandomSample::default(),
|
||||
sample: ControlEffectFrameSample::default(),
|
||||
last_update_id: None,
|
||||
last_update_clock_ms: None,
|
||||
clock: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -829,7 +833,7 @@ impl ControlEffectRandomState {
|
||||
sample_id: SceneSampleId,
|
||||
control_clock_ms: u32,
|
||||
initial_phase: f32,
|
||||
) -> ControlEffectRandomSample {
|
||||
) -> ControlEffectFrameSample {
|
||||
// Native updates consume process-global RNG streams. The preview uses
|
||||
// a deterministic per-Control-row stream; sibling slots share one
|
||||
// sample and cadence, but native cross-row RNG ordering remains open.
|
||||
@@ -5310,8 +5314,7 @@ fn update_control_point_lights(
|
||||
slots: &mut [ControlEffectSlot],
|
||||
component_pose_buffers: &[Vec<Option<NodePoseBuffer>>],
|
||||
component_mount_poses: &[Vec<Option<VulkanNodePose>>],
|
||||
elapsed_seconds: f32,
|
||||
random_samples: &HashMap<ControlEffectInstanceKey, ControlEffectRandomSample>,
|
||||
random_samples: &HashMap<ControlEffectInstanceKey, ControlEffectFrameSample>,
|
||||
emit_diagnostic: bool,
|
||||
) -> Vec<(VulkanPointLight, u32)> {
|
||||
let mut lights = Vec::new();
|
||||
@@ -5323,10 +5326,10 @@ fn update_control_point_lights(
|
||||
.copied()
|
||||
.flatten();
|
||||
}
|
||||
let random_sample = random_samples
|
||||
.get(&slot.random_instance_key)
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
let Some(random_sample) = random_samples.get(&slot.random_instance_key).copied() else {
|
||||
eprintln!("render_coverage control_fx_instance_sample_missing=1 (point light)");
|
||||
continue;
|
||||
};
|
||||
let Some(world_from_effect) = control_effect_world_from_component_pose_with_random_shift(
|
||||
slot,
|
||||
component_pose_buffers,
|
||||
@@ -5335,13 +5338,9 @@ fn update_control_point_lights(
|
||||
continue;
|
||||
};
|
||||
slot.world_from_effect = world_from_effect;
|
||||
let Some(phase) = ambient_effect_phase(
|
||||
slot.time_mode,
|
||||
slot.header_flags,
|
||||
elapsed_seconds,
|
||||
slot.duration_seconds,
|
||||
)
|
||||
.map(|phase| native_command_phase(slot.header_flags, phase, random_sample.phase_jitter)) else {
|
||||
let Some(phase) = random_sample.phase.map(|phase| {
|
||||
native_command_phase(slot.header_flags, phase, random_sample.phase_jitter)
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let matrix = slot.world_from_effect;
|
||||
@@ -5542,6 +5541,7 @@ fn append_initial_control_effect_slots(
|
||||
let duration_seconds = header.duration_seconds;
|
||||
if !duration_seconds.is_finite()
|
||||
|| duration_seconds < 0.0
|
||||
|| !(duration_seconds * 1000.0).is_finite()
|
||||
|| !header.phase_jitter.is_finite()
|
||||
|| !header.random_shift.iter().all(|value| value.is_finite())
|
||||
|| !header.scale.iter().all(|value| value.is_finite())
|
||||
@@ -5550,8 +5550,7 @@ fn append_initial_control_effect_slots(
|
||||
record_skip("header_values", document.commands().len(), false, None);
|
||||
continue;
|
||||
}
|
||||
if !matches!(header.time_mode, 0 | 1 | 2)
|
||||
|| (header.time_mode != 0 && duration_seconds <= 0.0)
|
||||
if !matches!(header.time_mode, 0..=3) || (header.time_mode != 0 && duration_seconds <= 0.0)
|
||||
{
|
||||
*unsupported_sounds += sound_commands;
|
||||
record_skip(
|
||||
@@ -6013,65 +6012,70 @@ fn control_effect_instance_seed(
|
||||
)
|
||||
}
|
||||
|
||||
fn advance_control_effect_random_states(
|
||||
states: &mut HashMap<ControlEffectInstanceKey, ControlEffectRandomState>,
|
||||
fn advance_control_effect_instances(
|
||||
states: &mut HashMap<ControlEffectInstanceKey, ControlEffectInstanceState>,
|
||||
ambient_slots: &[AmbientEffectGpuSlot],
|
||||
point_light_slots: &[ControlEffectSlot],
|
||||
sound_slots: &[ControlSoundSlot],
|
||||
sample_id: SceneSampleId,
|
||||
control_clock_ms: u32,
|
||||
elapsed_seconds: f32,
|
||||
) -> HashMap<ControlEffectInstanceKey, ControlEffectRandomSample> {
|
||||
) -> HashMap<ControlEffectInstanceKey, ControlEffectFrameSample> {
|
||||
let mut specifications = HashMap::new();
|
||||
for slot in ambient_slots {
|
||||
let initial_phase = ambient_effect_phase(
|
||||
slot.time_mode,
|
||||
slot.header_flags,
|
||||
elapsed_seconds,
|
||||
slot.duration_seconds,
|
||||
)
|
||||
.map(|phase| initial_native_effect_phase(slot.header_flags, phase))
|
||||
.unwrap_or(-1.0);
|
||||
specifications.entry(slot.random_instance_key).or_insert((
|
||||
slot.random_seed,
|
||||
slot.header_flags,
|
||||
slot.phase_jitter,
|
||||
slot.random_shift,
|
||||
initial_phase,
|
||||
slot.time_mode,
|
||||
slot.duration_seconds,
|
||||
));
|
||||
}
|
||||
for slot in point_light_slots
|
||||
.iter()
|
||||
.chain(sound_slots.iter().map(|slot| &slot.effect))
|
||||
{
|
||||
let initial_phase = ambient_effect_phase(
|
||||
slot.time_mode,
|
||||
slot.header_flags,
|
||||
elapsed_seconds,
|
||||
slot.duration_seconds,
|
||||
)
|
||||
.map(|phase| initial_native_effect_phase(slot.header_flags, phase))
|
||||
.unwrap_or(-1.0);
|
||||
specifications.entry(slot.random_instance_key).or_insert((
|
||||
slot.random_seed,
|
||||
slot.header_flags,
|
||||
slot.phase_jitter,
|
||||
slot.random_shift,
|
||||
initial_phase,
|
||||
slot.time_mode,
|
||||
slot.duration_seconds,
|
||||
));
|
||||
}
|
||||
|
||||
specifications
|
||||
.into_iter()
|
||||
.map(
|
||||
|(key, (seed, flags, phase_jitter, random_shift, initial_phase))| {
|
||||
|(key, (seed, flags, phase_jitter, random_shift, mode, duration))| {
|
||||
let state = states.entry(key).or_insert_with(|| {
|
||||
ControlEffectRandomState::new(seed, flags, phase_jitter, random_shift)
|
||||
ControlEffectInstanceState::new(seed, flags, phase_jitter, random_shift)
|
||||
});
|
||||
(
|
||||
key,
|
||||
state.advance(sample_id, control_clock_ms, initial_phase),
|
||||
)
|
||||
if state.last_update_id == Some(sample_id) {
|
||||
return (key, state.sample);
|
||||
}
|
||||
let clock = state.clock.get_or_insert_with(|| {
|
||||
if flags & 0x180 != 0 {
|
||||
eprintln!("render_coverage control_fx_manager_flag_stub=1 (preview manager A8=0; instance={key:?})");
|
||||
}
|
||||
// All static-preview instances are created at scene time zero.
|
||||
fparkan_fx::clock::FxInstanceClock::new(mode, flags, duration, 0.0)
|
||||
.expect("prepared FX clock metadata")
|
||||
});
|
||||
let now_ms = control_clock_ms as f32;
|
||||
let phase = clock.update(now_ms).and_then(|()| clock.phase(now_ms, false));
|
||||
let instance_elapsed = clock.elapsed_seconds(now_ms).unwrap_or(0.0);
|
||||
if phase.is_none() && state.last_update_id.is_none() {
|
||||
eprintln!("render_coverage control_fx_clock_unsupported=1 (instance={key:?} mode={mode})");
|
||||
}
|
||||
let initial_phase = phase
|
||||
.map(|phase| initial_native_effect_phase(flags, phase))
|
||||
.unwrap_or(-1.0);
|
||||
state.advance(sample_id, control_clock_ms, initial_phase);
|
||||
state.sample.phase = phase;
|
||||
state.sample.elapsed_seconds = instance_elapsed;
|
||||
(key, state.sample)
|
||||
},
|
||||
)
|
||||
.collect()
|
||||
@@ -6203,8 +6207,7 @@ fn update_ambient_effect_geometry(
|
||||
component_pose_buffers: &[Vec<Option<NodePoseBuffer>>],
|
||||
mesh: &mut VulkanStaticMesh,
|
||||
material_animations: &mut [MaterialAnimationBinding],
|
||||
elapsed_seconds: f32,
|
||||
random_samples: &HashMap<ControlEffectInstanceKey, ControlEffectRandomSample>,
|
||||
random_samples: &HashMap<ControlEffectInstanceKey, ControlEffectFrameSample>,
|
||||
camera_world: [f32; 3],
|
||||
) -> Result<Vec<AmbientEffectGeometryUpdate>, String> {
|
||||
let mut updates = Vec::with_capacity(slots.len());
|
||||
@@ -6212,7 +6215,13 @@ fn update_ambient_effect_geometry(
|
||||
let random_sample = random_samples
|
||||
.get(&slot.random_instance_key)
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"Control FX instance sample missing: {:?}",
|
||||
slot.random_instance_key
|
||||
)
|
||||
})?;
|
||||
let elapsed_seconds = random_sample.elapsed_seconds;
|
||||
let Some(world_from_effect) = ambient_effect_world_from_component_pose_with_random_shift(
|
||||
slot,
|
||||
component_pose_buffers,
|
||||
@@ -6222,13 +6231,9 @@ fn update_ambient_effect_geometry(
|
||||
continue;
|
||||
};
|
||||
slot.world_from_effect = world_from_effect;
|
||||
let Some(phase) = ambient_effect_phase(
|
||||
slot.time_mode,
|
||||
slot.header_flags,
|
||||
elapsed_seconds,
|
||||
slot.duration_seconds,
|
||||
)
|
||||
.map(|phase| native_command_phase(slot.header_flags, phase, random_sample.phase_jitter)) else {
|
||||
let Some(phase) = random_sample.phase.map(|phase| {
|
||||
native_command_phase(slot.header_flags, phase, random_sample.phase_jitter)
|
||||
}) else {
|
||||
deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?;
|
||||
continue;
|
||||
};
|
||||
@@ -6949,42 +6954,19 @@ fn fx_material_speed_diagnostic(
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn ambient_effect_phase(
|
||||
time_mode: u32,
|
||||
header_flags: u32,
|
||||
elapsed_seconds: f32,
|
||||
duration_seconds: f32,
|
||||
) -> Option<f32> {
|
||||
if !elapsed_seconds.is_finite()
|
||||
|| elapsed_seconds < 0.0
|
||||
|| !duration_seconds.is_finite()
|
||||
|| duration_seconds < 0.0
|
||||
{
|
||||
if !elapsed_seconds.is_finite() || elapsed_seconds < 0.0 {
|
||||
return None;
|
||||
}
|
||||
let mut phase = match time_mode {
|
||||
0 => 0.0,
|
||||
// The native phase stays raw through the optional 0x200/0x20
|
||||
// transforms; the command-level 0x8120 path clamps only afterward.
|
||||
1 if duration_seconds > 0.0 => elapsed_seconds / duration_seconds,
|
||||
2 if duration_seconds > 0.0 => {
|
||||
elapsed_seconds.rem_euclid(duration_seconds) / duration_seconds
|
||||
fparkan_fx::clock::FxInstanceClock::new(time_mode, header_flags, duration_seconds, 0.0)?
|
||||
.phase(elapsed_seconds * 1000.0, false)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
if header_flags & 0x200 != 0 && duration_seconds > 0.0 {
|
||||
phase *= elapsed_seconds / duration_seconds;
|
||||
}
|
||||
if header_flags & 0x20 != 0 {
|
||||
phase = if phase >= 0.5 {
|
||||
(1.0 - phase) * 2.0
|
||||
} else {
|
||||
phase * 2.0
|
||||
};
|
||||
}
|
||||
Some(phase)
|
||||
}
|
||||
|
||||
fn rotate_unit_vector(value: [f32; 3], rotation: [f32; 4]) -> [f32; 3] {
|
||||
let [x, y, z, w] = rotation;
|
||||
let tx = 2.0 * (y * value[2] - z * value[1]);
|
||||
@@ -9422,7 +9404,7 @@ struct StaticVulkanApp {
|
||||
control_point_light_slots: Vec<ControlEffectSlot>,
|
||||
control_sound_slots: Vec<ControlSoundSlot>,
|
||||
unsupported_control_sounds: usize,
|
||||
control_effect_random_states: HashMap<ControlEffectInstanceKey, ControlEffectRandomState>,
|
||||
control_effect_instance_states: HashMap<ControlEffectInstanceKey, ControlEffectInstanceState>,
|
||||
component_pose_buffers: Vec<Vec<Option<NodePoseBuffer>>>,
|
||||
component_mount_poses: Vec<Vec<Option<VulkanNodePose>>>,
|
||||
dynamic_component_geometry: Vec<Vec<Option<DynamicComponentGeometry>>>,
|
||||
@@ -9530,7 +9512,7 @@ impl StaticVulkanApp {
|
||||
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_instance_states: HashMap::new(),
|
||||
component_pose_buffers: preview.component_pose_buffers,
|
||||
component_mount_poses: preview.component_mount_poses,
|
||||
dynamic_component_geometry: preview.dynamic_component_geometry,
|
||||
@@ -10018,20 +10000,18 @@ impl StaticVulkanApp {
|
||||
.flatten();
|
||||
}
|
||||
}
|
||||
let control_effect_random_samples = advance_control_effect_random_states(
|
||||
&mut self.control_effect_random_states,
|
||||
let control_effect_random_samples = advance_control_effect_instances(
|
||||
&mut self.control_effect_instance_states,
|
||||
&self.ambient_effect_slots,
|
||||
&self.control_point_light_slots,
|
||||
&self.control_sound_slots,
|
||||
clock.id,
|
||||
control_clock_ms,
|
||||
effect_elapsed_seconds,
|
||||
);
|
||||
let control_point_lights = update_control_point_lights(
|
||||
&mut self.control_point_light_slots,
|
||||
&self.component_pose_buffers,
|
||||
&self.component_mount_poses,
|
||||
effect_elapsed_seconds,
|
||||
&control_effect_random_samples,
|
||||
std::env::var_os("FPARKAN_CONTROL_POINT_LIGHT_DIAGNOSTIC").is_some()
|
||||
&& !self.control_point_lights_diagnostic_reported
|
||||
@@ -10045,7 +10025,6 @@ impl StaticVulkanApp {
|
||||
&self.component_pose_buffers,
|
||||
&mut self.mesh,
|
||||
&mut self.material_animations,
|
||||
effect_elapsed_seconds,
|
||||
&control_effect_random_samples,
|
||||
self.view_camera.position,
|
||||
)?;
|
||||
@@ -10088,13 +10067,7 @@ impl StaticVulkanApp {
|
||||
.get(&slot.random_instance_key)
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
let phase = ambient_effect_phase(
|
||||
slot.time_mode,
|
||||
slot.header_flags,
|
||||
effect_elapsed_seconds,
|
||||
slot.duration_seconds,
|
||||
)
|
||||
.map(|phase| {
|
||||
let phase = random_sample.phase.map(|phase| {
|
||||
native_command_phase(slot.header_flags, phase, random_sample.phase_jitter)
|
||||
});
|
||||
let evaluated = phase.and_then(|phase| {
|
||||
@@ -10103,7 +10076,7 @@ impl StaticVulkanApp {
|
||||
slot.command_index,
|
||||
&slot.world_from_effect,
|
||||
phase,
|
||||
effect_elapsed_seconds,
|
||||
random_sample.elapsed_seconds,
|
||||
self.view_camera.position,
|
||||
)
|
||||
});
|
||||
@@ -10234,13 +10207,9 @@ impl StaticVulkanApp {
|
||||
) 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 {
|
||||
let Some(phase) = random.phase.map(|phase| {
|
||||
native_command_phase(slot.header_flags, phase, random.phase_jitter)
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(event) = sound.command.update(
|
||||
@@ -13826,11 +13795,11 @@ mod tests {
|
||||
}
|
||||
|
||||
fn control_random_snapshot(
|
||||
state: &ControlEffectRandomState,
|
||||
state: &ControlEffectInstanceState,
|
||||
) -> (
|
||||
u32,
|
||||
u32,
|
||||
ControlEffectRandomSample,
|
||||
ControlEffectFrameSample,
|
||||
Option<SceneSampleId>,
|
||||
Option<u32>,
|
||||
) {
|
||||
@@ -13902,7 +13871,7 @@ mod tests {
|
||||
.sky_mesh
|
||||
.vertices
|
||||
.is_empty());
|
||||
let first_random = control_random_snapshot(&app.control_effect_random_states[&key]);
|
||||
let first_random = control_random_snapshot(&app.control_effect_instance_states[&key]);
|
||||
assert_eq!(first_random.3, Some(SceneSampleId::Live(0)));
|
||||
assert_ne!(first_random.0, 0);
|
||||
assert_ne!(first_random.1, 0);
|
||||
@@ -13915,7 +13884,7 @@ mod tests {
|
||||
.expect("renderer-free redraw does not sample simulation state");
|
||||
}
|
||||
assert_eq!(
|
||||
control_random_snapshot(&app.control_effect_random_states[&key]),
|
||||
control_random_snapshot(&app.control_effect_instance_states[&key]),
|
||||
first_random
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -13937,7 +13906,7 @@ mod tests {
|
||||
SceneSampleId::Live(1)
|
||||
);
|
||||
assert_ne!(
|
||||
control_random_snapshot(&app.control_effect_random_states[&key]),
|
||||
control_random_snapshot(&app.control_effect_instance_states[&key]),
|
||||
first_random
|
||||
);
|
||||
assert!(app.environment.as_ref().unwrap().time_seconds > first_environment_time);
|
||||
@@ -14034,7 +14003,7 @@ mod tests {
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.environment_frame = first_environment_frame.clone();
|
||||
let first_random = control_random_snapshot(&app.control_effect_random_states[&key]);
|
||||
let first_random = control_random_snapshot(&app.control_effect_instance_states[&key]);
|
||||
let first_environment_time = app.environment.as_ref().unwrap().time_seconds;
|
||||
let first_environment_system_time =
|
||||
app.environment.as_ref().unwrap().environment.time_seconds();
|
||||
@@ -14139,7 +14108,7 @@ mod tests {
|
||||
}
|
||||
assert!(reprojected_counts.iter().all(|count| *count >= 17));
|
||||
assert_eq!(
|
||||
control_random_snapshot(&app.control_effect_random_states[&key]),
|
||||
control_random_snapshot(&app.control_effect_instance_states[&key]),
|
||||
first_random
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -14197,7 +14166,7 @@ mod tests {
|
||||
.clone();
|
||||
let first_material_phase = first_frame.material_update.active_materials[&0].phase_index;
|
||||
let first_environment_time = app.environment.as_ref().unwrap().time_seconds;
|
||||
let first_random = control_random_snapshot(&app.control_effect_random_states[&key]);
|
||||
let first_random = control_random_snapshot(&app.control_effect_instance_states[&key]);
|
||||
let first_vertices = app.mesh.vertices.clone();
|
||||
assert_eq!(first_material_phase, 0);
|
||||
assert_eq!(first_random.3, Some(SceneSampleId::Replay(0)));
|
||||
@@ -14238,7 +14207,7 @@ mod tests {
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
control_random_snapshot(&app.control_effect_random_states[&key]),
|
||||
control_random_snapshot(&app.control_effect_instance_states[&key]),
|
||||
first_random
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -14272,7 +14241,7 @@ mod tests {
|
||||
(app.environment.as_ref().unwrap().time_seconds - first_environment_time - 0.5).abs()
|
||||
< 1.0e-6
|
||||
);
|
||||
let next_random = control_random_snapshot(&app.control_effect_random_states[&key]);
|
||||
let next_random = control_random_snapshot(&app.control_effect_instance_states[&key]);
|
||||
assert_eq!(next_random.3, Some(SceneSampleId::Replay(1)));
|
||||
assert_eq!(next_random.4, Some(500));
|
||||
assert_ne!(next_random, first_random);
|
||||
@@ -14308,7 +14277,7 @@ mod tests {
|
||||
next_environment_frame
|
||||
);
|
||||
assert_eq!(
|
||||
control_random_snapshot(&app.control_effect_random_states[&key]),
|
||||
control_random_snapshot(&app.control_effect_instance_states[&key]),
|
||||
next_random
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -16622,7 +16591,7 @@ mod tests {
|
||||
assert_eq!(ambient_effect_phase(2, 0, 2.25, 2.0), Some(0.125));
|
||||
assert_eq!(ambient_effect_phase(1, 0, 2.0, 2.0), Some(1.0));
|
||||
assert_eq!(ambient_effect_phase(0, 0, 0.5, 0.0), Some(0.0));
|
||||
assert_eq!(ambient_effect_phase(3, 0, 0.5, 2.0), None);
|
||||
assert_eq!(ambient_effect_phase(3, 0, 0.5, 2.0), Some(0.75));
|
||||
assert_eq!(ambient_effect_phase(2, 0x20, 0.5, 1.0), Some(1.0));
|
||||
assert_eq!(ambient_effect_phase(2, 0x20, 0.75, 1.0), Some(0.5));
|
||||
assert_eq!(ambient_effect_phase(1, 0x200, 0.5, 1.0), Some(0.25));
|
||||
@@ -16747,21 +16716,35 @@ mod tests {
|
||||
let mounts = vec![vec![None]];
|
||||
|
||||
let inactive = slot.clone();
|
||||
let random_samples = HashMap::new();
|
||||
let mut instance_states = HashMap::new();
|
||||
let random_samples = advance_control_effect_instances(
|
||||
&mut instance_states,
|
||||
&[],
|
||||
std::slice::from_ref(&slot),
|
||||
&[],
|
||||
SceneSampleId::Replay(1),
|
||||
250,
|
||||
);
|
||||
assert!(update_control_point_lights(
|
||||
&mut [inactive],
|
||||
&poses,
|
||||
&mounts,
|
||||
0.25,
|
||||
&random_samples,
|
||||
false,
|
||||
)
|
||||
.is_empty());
|
||||
let random_samples = advance_control_effect_instances(
|
||||
&mut instance_states,
|
||||
&[],
|
||||
std::slice::from_ref(&slot),
|
||||
&[],
|
||||
SceneSampleId::Replay(2),
|
||||
750,
|
||||
);
|
||||
let lights = update_control_point_lights(
|
||||
std::slice::from_mut(&mut slot),
|
||||
&poses,
|
||||
&mounts,
|
||||
0.75,
|
||||
&random_samples,
|
||||
false,
|
||||
);
|
||||
@@ -16787,67 +16770,90 @@ mod tests {
|
||||
slot.random_shift = [2.0, 4.0, 6.0];
|
||||
let sibling_slots = [slot.clone(), slot.clone()];
|
||||
let mut states = HashMap::new();
|
||||
let first = advance_control_effect_random_states(
|
||||
let first = advance_control_effect_instances(
|
||||
&mut states,
|
||||
&[],
|
||||
&sibling_slots,
|
||||
&[],
|
||||
SceneSampleId::Replay(7),
|
||||
150,
|
||||
1.5,
|
||||
1500,
|
||||
);
|
||||
assert_eq!(first.len(), 1);
|
||||
let key = slot.random_instance_key;
|
||||
let first_sample = first[&key];
|
||||
assert_eq!(first_sample.phase_jitter, 0.0, "the -1 phase skips jitter");
|
||||
assert_ne!(first_sample.accumulated_shift, [0.0; 3]);
|
||||
let mut expected_state = ControlEffectRandomState::new(
|
||||
let mut expected_state = ControlEffectInstanceState::new(
|
||||
slot.random_seed,
|
||||
slot.header_flags,
|
||||
slot.phase_jitter,
|
||||
slot.random_shift,
|
||||
);
|
||||
let expected = expected_state.advance(SceneSampleId::Replay(7), 1500, -1.0);
|
||||
assert_eq!(first_sample.phase_jitter, expected.phase_jitter);
|
||||
assert_eq!(first_sample.accumulated_shift, expected.accumulated_shift);
|
||||
assert_eq!(first_sample.phase, Some(-1.0));
|
||||
assert!((first_sample.elapsed_seconds - 1.5).abs() < 1e-6);
|
||||
assert_eq!(
|
||||
first_sample,
|
||||
expected_state.advance(SceneSampleId::Replay(7), 150, -1.0)
|
||||
);
|
||||
assert_eq!(
|
||||
advance_control_effect_random_states(
|
||||
advance_control_effect_instances(
|
||||
&mut states,
|
||||
&[],
|
||||
&sibling_slots,
|
||||
&[],
|
||||
SceneSampleId::Replay(7),
|
||||
150,
|
||||
1.5,
|
||||
1500,
|
||||
)[&key],
|
||||
first_sample,
|
||||
"a repeated read of the same captured update reuses the shared sample"
|
||||
);
|
||||
let second = advance_control_effect_random_states(
|
||||
let second = advance_control_effect_instances(
|
||||
&mut states,
|
||||
&[],
|
||||
&sibling_slots,
|
||||
&[],
|
||||
SceneSampleId::Replay(8),
|
||||
200,
|
||||
0.5,
|
||||
);
|
||||
assert_eq!(
|
||||
second[&key],
|
||||
expected_state.advance(SceneSampleId::Replay(8), 200, 1.0)
|
||||
500,
|
||||
);
|
||||
let expected = expected_state.advance(SceneSampleId::Replay(8), 500, 1.0);
|
||||
assert_eq!(second[&key].phase_jitter, expected.phase_jitter);
|
||||
assert_eq!(second[&key].accumulated_shift, expected.accumulated_shift);
|
||||
assert_eq!(second[&key].phase, Some(1.0));
|
||||
assert_eq!(second[&key].elapsed_seconds, 0.5);
|
||||
assert_ne!(second[&key].phase_jitter, first_sample.phase_jitter);
|
||||
assert_ne!(
|
||||
second[&key].accumulated_shift,
|
||||
first_sample.accumulated_shift
|
||||
);
|
||||
assert_eq!(states[&key].last_update_clock_ms, Some(200));
|
||||
assert_eq!(states[&key].last_update_clock_ms, Some(500));
|
||||
|
||||
let clock = states.get_mut(&key).unwrap().clock.as_mut().unwrap();
|
||||
clock.set_time(4000.0, 1).unwrap();
|
||||
clock.request_restart();
|
||||
let later = advance_control_effect_instances(
|
||||
&mut states,
|
||||
&[],
|
||||
&sibling_slots,
|
||||
&[],
|
||||
SceneSampleId::Replay(9),
|
||||
4500,
|
||||
);
|
||||
assert_eq!(later[&key].phase, Some(1.0));
|
||||
assert_eq!(later[&key].elapsed_seconds, 0.5);
|
||||
let restarted = advance_control_effect_instances(
|
||||
&mut states,
|
||||
&[],
|
||||
&sibling_slots,
|
||||
&[],
|
||||
SceneSampleId::Replay(10),
|
||||
6500,
|
||||
);
|
||||
assert_eq!(restarted[&key].phase, Some(0.0));
|
||||
assert_eq!(restarted[&key].elapsed_seconds, 0.0);
|
||||
|
||||
let negative_non_sentinel =
|
||||
ambient_effect_phase(1, 0x220, 1.5, 1.0).expect("post-duration transformed phase");
|
||||
assert_eq!(negative_non_sentinel, -2.5);
|
||||
let mut negative_state = ControlEffectRandomState::new(
|
||||
let mut negative_state = ControlEffectInstanceState::new(
|
||||
slot.random_seed,
|
||||
0x229,
|
||||
slot.phase_jitter,
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Per-instance time for the owner-independent Effect.dll phase modes.
|
||||
|
||||
/// The clock fields read by Effect.dll +0x5C60 and set by +0x7B00/+0x7B40.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FxInstanceClock {
|
||||
header_mode: u32,
|
||||
mode: u32,
|
||||
header_flags: u32,
|
||||
duration_ms: f32,
|
||||
origin_ms: f32,
|
||||
end_ms: f32,
|
||||
constant_phase: f32,
|
||||
restart_requested: bool,
|
||||
}
|
||||
|
||||
impl FxInstanceClock {
|
||||
/// Creates an instance at its own origin, using the document's default mode.
|
||||
/// Modes requiring an owner remain unsupported by this clock.
|
||||
pub fn new(
|
||||
mode: u32,
|
||||
header_flags: u32,
|
||||
duration_seconds: f32,
|
||||
origin_ms: f32,
|
||||
) -> Option<Self> {
|
||||
let duration_ms = duration_seconds * 1000.0;
|
||||
if mode > 3
|
||||
|| !duration_ms.is_finite()
|
||||
|| duration_ms < 0.0
|
||||
|| !origin_ms.is_finite()
|
||||
|| (mode != 0 && duration_ms == 0.0)
|
||||
|| !(origin_ms + duration_ms).is_finite()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
header_mode: mode,
|
||||
mode,
|
||||
header_flags,
|
||||
duration_ms,
|
||||
origin_ms,
|
||||
end_ms: origin_ms + duration_ms,
|
||||
constant_phase: 0.0,
|
||||
restart_requested: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Changes the caller-selected mode and epoch without changing the document.
|
||||
pub fn set_time(&mut self, now_ms: f32, mode: u32) -> Option<()> {
|
||||
if mode > 3
|
||||
|| !now_ms.is_finite()
|
||||
|| !(now_ms + self.duration_ms).is_finite()
|
||||
|| (mode != 0 && self.duration_ms == 0.0)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
self.origin_ms = now_ms;
|
||||
self.end_ms = now_ms + self.duration_ms;
|
||||
self.mode = mode;
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Sets the value used by mode zero; command evaluation clamps it later.
|
||||
pub fn set_constant_phase(&mut self, phase: f32) -> Option<()> {
|
||||
if !phase.is_finite() {
|
||||
return None;
|
||||
}
|
||||
self.constant_phase = phase;
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Requests the native deferred restart: the current lifetime finishes first.
|
||||
pub fn request_restart(&mut self) {
|
||||
self.restart_requested = true;
|
||||
}
|
||||
|
||||
/// Applies the restart portion of Effect.dll +0x8120 before phase sampling.
|
||||
/// The new lifetime starts at the update time rather than retaining overshoot.
|
||||
pub fn update(&mut self, now_ms: f32) -> Option<()> {
|
||||
if !now_ms.is_finite() {
|
||||
return None;
|
||||
}
|
||||
if self.restart_requested && self.end_ms <= now_ms {
|
||||
self.set_time(now_ms, self.header_mode)?;
|
||||
self.restart_requested = false;
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Elapsed instance time for command simulation, independent of app startup.
|
||||
pub fn elapsed_seconds(&self, now_ms: f32) -> Option<f32> {
|
||||
now_ms
|
||||
.is_finite()
|
||||
.then_some((now_ms - self.origin_ms) * 0.001)
|
||||
}
|
||||
|
||||
/// Samples the raw phase before command clamping and random phase jitter.
|
||||
/// `manager_flag_a8` is the native owner bit tested by header gates 0x80/0x100.
|
||||
pub fn phase(&self, now_ms: f32, manager_flag_a8: bool) -> Option<f32> {
|
||||
if !now_ms.is_finite() {
|
||||
return None;
|
||||
}
|
||||
if (self.header_flags & 0x80 != 0 && !manager_flag_a8)
|
||||
|| (self.header_flags & 0x100 != 0 && manager_flag_a8)
|
||||
{
|
||||
return Some(0.0);
|
||||
}
|
||||
let ratio = (now_ms - self.origin_ms) / (self.end_ms - self.origin_ms);
|
||||
let mut phase = match self.mode {
|
||||
0 => self.constant_phase,
|
||||
1 => ratio,
|
||||
2 => ratio - ratio.floor(),
|
||||
3 => 1.0 - ratio,
|
||||
_ => return None,
|
||||
};
|
||||
if self.header_flags & 0x200 != 0 {
|
||||
phase *= ratio;
|
||||
}
|
||||
if self.header_flags & 0x20 != 0 {
|
||||
if phase >= 0.5 {
|
||||
phase = 1.0 - phase;
|
||||
}
|
||||
phase *= 2.0;
|
||||
}
|
||||
phase.is_finite().then_some(phase)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn native_epoch_modes_and_unclamped_header_transforms() {
|
||||
// Effect.dll GOG +0x5C60, x87 CW 007F; native oracle uses these epochs.
|
||||
for (mode, expected) in [
|
||||
(0, [0.375, 0.375, 0.375, 0.375, 0.375]),
|
||||
(1, [0.0, 0.25, 0.5, 1.0, 1.25]),
|
||||
(2, [0.0, 0.25, 0.5, 0.0, 0.25]),
|
||||
(3, [1.0, 0.75, 0.5, 0.0, -0.25]),
|
||||
] {
|
||||
let mut clock = FxInstanceClock::new(mode, 0, 4.0, 135_000.0).unwrap();
|
||||
clock.set_constant_phase(0.375).unwrap();
|
||||
for (now, phase) in [135_000.0, 136_000.0, 137_000.0, 139_000.0, 140_000.0]
|
||||
.into_iter()
|
||||
.zip(expected)
|
||||
{
|
||||
assert_eq!(clock.phase(now, false), Some(phase));
|
||||
}
|
||||
}
|
||||
for (flags, expected) in [(0x200, 2.25), (0x20, -1.0), (0x220, -2.5)] {
|
||||
let clock = FxInstanceClock::new(1, flags, 4.0, 135_000.0).unwrap();
|
||||
assert_eq!(clock.phase(141_000.0, false), Some(expected));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caller_mode_and_deferred_restart_use_the_instance_origin() {
|
||||
let mut clock = FxInstanceClock::new(2, 0, 4.0, 135_000.0).unwrap();
|
||||
clock.set_time(136_000.0, 3).unwrap();
|
||||
clock.request_restart();
|
||||
clock.update(139_999.0).unwrap();
|
||||
assert!(clock.phase(139_999.0, false).unwrap() < 0.001);
|
||||
clock.update(140_250.0).unwrap();
|
||||
assert_eq!(clock.phase(140_250.0, false), Some(0.0));
|
||||
assert_eq!(clock.elapsed_seconds(140_750.0), Some(0.5));
|
||||
assert_eq!(clock.phase(141_250.0, false), Some(0.25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_header_gates_precede_phase_transforms() {
|
||||
for (flags, manager, expected) in [
|
||||
(0x80, false, 0.0),
|
||||
(0x80, true, 0.5),
|
||||
(0x100, false, 0.5),
|
||||
(0x100, true, 0.0),
|
||||
] {
|
||||
let clock = FxInstanceClock::new(1, flags, 2.0, 20_000.0).unwrap();
|
||||
assert_eq!(clock.phase(21_000.0, manager), Some(expected));
|
||||
}
|
||||
assert_eq!(
|
||||
FxInstanceClock::new(0, 0, 0.0, 0.0)
|
||||
.unwrap()
|
||||
.phase(100.0, false),
|
||||
Some(0.0)
|
||||
);
|
||||
assert!(FxInstanceClock::new(1, 0, 0.0, 0.0).is_none());
|
||||
assert!(FxInstanceClock::new(0, 0, f32::MAX, 0.0).is_none());
|
||||
assert!(FxInstanceClock::new(4, 0, 2.0, 0.0).is_none());
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
pub mod ambient_op3;
|
||||
pub mod ambient_sound;
|
||||
pub mod atmosphere;
|
||||
pub mod clock;
|
||||
pub mod environment;
|
||||
pub mod fx_visual;
|
||||
pub mod op7;
|
||||
|
||||
@@ -91,6 +91,9 @@ Control Op1 пока использует цвет и радиус без слу
|
||||
Control Op2 воспроизводит modes 0/2; modes 1/3, body+76 и неподдержанные
|
||||
time modes/привязки считаются как `render_coverage control_op2_unsupported`.
|
||||
Управление фазой экземпляра командами Control остаётся до его подключения в runtime.
|
||||
Часы экземпляра поддерживают modes 0–3, смену режима и отложенный перезапуск;
|
||||
preview создаёт их в момент ноль. Динамический manager A8 заменён нулём
|
||||
(`control_fx_manager_flag_stub`); очередь update с шагом 50 ms ещё не подключена.
|
||||
При расширении выбранных начальных строк все пропущенные команды считаются
|
||||
в `control_fx_unsupported`, а time modes 4–17 — в `control_fx_requires_game_state`;
|
||||
причина и владелец выводятся в лог. Неразобранные FXID считаются отдельно в `control_fx_invalid_documents`.
|
||||
|
||||
@@ -416,10 +416,23 @@ RVA `0x93D0`, ветка mode 1 с `0x9739`, сборка столбцов `0x97
|
||||
XY, ненулевой выбирает статические quad/cap координаты. Op4 вычисляет локальное
|
||||
расстояние камеры тем же component-wise projection, что использует native path.
|
||||
|
||||
FX-команды используют общий preview clock и ограниченный набор time modes:
|
||||
mode 0 оставляет фазу равной нулю, mode 1 вычисляет `elapsed / duration` без
|
||||
зажатия, mode 2 повторяет цикл. Header flags `0x200` и `0x20` преобразуют эту
|
||||
фазу; command path `0x8120` зажимает результат только после преобразований.
|
||||
FX-команды одной Control-строки используют общие часы экземпляра
|
||||
`fparkan_fx::clock::FxInstanceClock`. Время хранится в миллисекундах `f32`,
|
||||
отдельно от длительности документа: mode 0 возвращает постоянную фазу
|
||||
(при создании ноль), mode 1 — `(now − origin) / duration`, mode 2 повторяет
|
||||
цикл, mode 3 возвращает `1 − (now − origin) / duration`.
|
||||
Header flags `0x200` и `0x20` преобразуют незажатую фазу; command path
|
||||
`0x8120` зажимает результат только после преобразований.
|
||||
Вызов `set_time` меняет начало отсчёта и режим экземпляра, сохраняя режим
|
||||
документа. Запрос перезапуска (`Effect.dll` RVA `0x7B40`, instance bit `0x20`)
|
||||
срабатывает после конца текущего времени жизни: следующий update начинает
|
||||
новый цикл в своём времени и возвращает режим документа. Это отдельный флаг,
|
||||
не header `0x20`, складывающий фазу треугольником. Правила режимов, gates
|
||||
`0x80/0x100` и установления времени сверены вызовом эталонной DLL с x87 CW
|
||||
`0x007F`; отложенный переход закреплён Rust-тестом по ветви update `0x8120`.
|
||||
Все экземпляры статического preview пока создаются в момент ноль; геометрия,
|
||||
свет и звук получают одну фазу на обновление сцены. Команды Control ещё не
|
||||
меняют эти часы, а нативная очередь обновления с шагом 50 ms не моделируется.
|
||||
В [эталонной Effect.dll](original-binaries.md) RVA `0x5C60` режимы 4–17
|
||||
читают состояние владельца или провайдера; режим 14 тоже входит в эту группу.
|
||||
Пропуски при расширении выбранных начальных Control-строк учитываются по
|
||||
@@ -433,8 +446,8 @@ Effect renderer проверяет header bit `0x800` только при
|
||||
render selector 0; native AniMesh component path был захвачен с selector 1,
|
||||
поэтому preview использует selector 1 и не подавляет такие visual commands.
|
||||
Этот capture также показал `0xD00` FX entries в 53-entry manager. Динамическое
|
||||
состояние manager `A8`, связанное с header gate и фактической FX phase, пока не
|
||||
моделируется. Header bit `0x10` относится к activation lifecycle и сам по себе
|
||||
состояние manager `A8` пока заменено нулём; экземпляры с gates `0x80/0x100`
|
||||
считаются в `control_fx_manager_flag_stub`. Header bit `0x10` относится к activation lifecycle и сам по себе
|
||||
не отключает draw.
|
||||
Header jitter и random shift хранятся один раз на Control row и совместно
|
||||
применяются к его sibling-командам; отдельный детерминированный поток каждой
|
||||
|
||||
+15
-1
@@ -740,6 +740,19 @@ struct FxHeader60 {
|
||||
локальную точку опоры, scale -- базовый масштаб экземпляра. Unknown flags и
|
||||
settings ID сохраняются.
|
||||
|
||||
У каждого экземпляра эффекта есть своё начало отсчёта. Например, эффект
|
||||
длительностью четыре секунды, созданный на 135-й секунде, на 137-й находится
|
||||
в середине цикла: `(137 − 135) / 4 = 0,5`. Режим может идти вперёд,
|
||||
повторяться, идти назад или сохранять постоянную фазу. Вызывающий код может
|
||||
сменить режим экземпляра, не меняя документ. Запрос перезапуска ждёт конца
|
||||
текущего цикла и начинает новый при следующем обновлении.
|
||||
|
||||
Эти правила реализованы в `fparkan-fx::clock`. Приложение передаёт одну фазу
|
||||
геометрии, свету и звуку одной Control-строки; повторная отправка кадра не
|
||||
пересчитывает её и не расходует случайные числа повторно. В статической сцене
|
||||
все экземпляры пока рождаются в момент ноль. Управляющие команды Control и
|
||||
очередь обновления с шагом 50 миллисекунд остаются задачами игрового цикла.
|
||||
|
||||
Каждая команда начинается с `uint32_t command_word`:
|
||||
|
||||
```text
|
||||
@@ -867,7 +880,8 @@ Effect renderer подавляет header bit `0x800` только при
|
||||
render selector 0. Native AniMesh component path захвачен с selector 1, а в
|
||||
его 53-entry manager присутствуют FX entries с header `0xD00`; preview
|
||||
использует selector 1 и пропускает такие visual commands. Изменения manager
|
||||
`A8`, которые задают runtime phase/header gate, пока не моделируются. Header
|
||||
`A8`, которые задают runtime phase/header gate, пока заменены нулём с явным
|
||||
счётчиком `control_fx_manager_flag_stub`. Header
|
||||
bit `0x10` относится к activation lifecycle и не выключает draw.
|
||||
|
||||
Для visual effect renderer использует MAT0, указанный самой командой. Его
|
||||
|
||||
Reference in new issue
Block a user