aboutsummaryrefslogtreecommitdiff
path: root/apps/fparkan-game
diff options
context:
space:
mode:
Diffstat (limited to 'apps/fparkan-game')
-rw-r--r--apps/fparkan-game/src/camera_capture.rs1
-rw-r--r--apps/fparkan-game/src/main.rs629
-rw-r--r--apps/fparkan-game/src/terrain_lod.rs2
3 files changed, 435 insertions, 197 deletions
diff --git a/apps/fparkan-game/src/camera_capture.rs b/apps/fparkan-game/src/camera_capture.rs
index bc16807..dccb738 100644
--- a/apps/fparkan-game/src/camera_capture.rs
+++ b/apps/fparkan-game/src/camera_capture.rs
@@ -23,6 +23,7 @@ pub(super) struct LegacyCameraCapture {
pub(super) struct SkyUvClock {
pub(super) verified: bool,
pub(super) elapsed_milliseconds: u32,
+ pub(super) raw_clock: Option<u32>,
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs
index 3432bda..80585e1 100644
--- a/apps/fparkan-game/src/main.rs
+++ b/apps/fparkan-game/src/main.rs
@@ -61,8 +61,9 @@ use fparkan_render::{
RawCameraTransform,
};
use fparkan_render_vulkan::{
- node38_pose, node38_pose_from_hierarchy, node38_pose_relative_to_root,
- node38_pose_relative_to_root_from_hierarchy, project_land_msh_to_static_mesh_in_world_space,
+ legacy_packet_distance_squared, node38_pose, node38_pose_from_hierarchy,
+ node38_pose_relative_to_root, node38_pose_relative_to_root_from_hierarchy,
+ project_land_msh_to_static_mesh_in_world_space,
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses,
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses_and_mount,
project_msh_to_static_mesh_in_world_space_with_node_pose_buffer,
@@ -131,7 +132,6 @@ fn run(args: &[String]) -> Result<String, String> {
.ok_or_else(|| "mission terrain is unavailable after loading".to_string())?;
let roots = loaded_mission_object_drafts(&engine)
.map(|drafts| &drafts[..args.preview_roots.get().min(drafts.len())])
- .filter(|roots| !roots.is_empty())
.ok_or_else(|| {
"selected mission object drafts are unavailable after loading".to_string()
})?;
@@ -723,6 +723,7 @@ struct AmbientEffectMeshPrimitive {
#[derive(Clone, Debug)]
struct ControlEffectSlot {
+ light_owner_scope: u32,
time_mode: u32,
header_flags: u32,
random_instance_key: ControlEffectInstanceKey,
@@ -759,9 +760,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 +802,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 +822,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 +834,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.
@@ -1374,7 +1379,7 @@ fn native_msh_diffuse_alpha_override(
let distance = if range.batch_flags & 0x0050 == 0x0040 {
0.0
} else {
- // shortcut: use the first indexed vertex; replace with native CPU clipping selection when clipped-portal captures are available.
+ // The native alpha callback reads the input packet before renderer clipping.
first_indexed_vertex_distance(mesh, range_index, eye_world)?
};
legacy_msh_portal_alpha(range.batch_flags, distance, horizontal_fov)
@@ -2753,6 +2758,7 @@ fn append_environment_range(
let range_index = mesh.draw_ranges.len();
mesh.draw_ranges
.push(fparkan_render_vulkan::VulkanStaticDrawRange {
+ owner_scope: 0,
first_index,
index_count,
material_index,
@@ -2760,6 +2766,7 @@ fn append_environment_range(
batch_flags: 0,
pipeline_state,
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
});
Ok(EnvironmentGpuRange {
@@ -3978,10 +3985,7 @@ fn first_indexed_vertex_distance(
.get(vertex_index)
.ok_or_else(|| "world draw range vertex index is outside the vertex buffer".to_string())?;
let delta = sub3(vertex.position, camera_position);
- let distance = dot3(delta, delta).sqrt();
- distance
- .is_finite()
- .then_some(distance)
+ legacy_packet_distance_squared(dot3(delta, delta))
.ok_or_else(|| "world draw range sort distance is non-finite".to_string())
}
@@ -4637,6 +4641,7 @@ fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] {
}
struct StaticPreviewScene {
+ fixed_water_clock_ms: Option<u32>,
mesh: VulkanStaticMesh,
dynamic_vertex_start: usize,
terrain_lod: Option<terrain_lod::TerrainLodDraws>,
@@ -5311,8 +5316,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();
@@ -5324,10 +5328,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,
@@ -5336,13 +5340,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;
@@ -5386,6 +5386,11 @@ fn update_control_point_lights(
}
lights.push((
VulkanPointLight {
+ owner_scope: if frame.native_flags & 0x8000_0000 != 0 {
+ slot.light_owner_scope
+ } else {
+ 0
+ },
position: frame.position,
rgb: [frame.color[0], frame.color[1], frame.color[2]],
range: frame.range,
@@ -5431,12 +5436,12 @@ fn report_control_point_lights_once(
*reported = true;
}
-fn ordinary_control_point_lights(
+fn active_control_point_lights(
raw_control_lights: &[(VulkanPointLight, u32)],
) -> Vec<VulkanPointLight> {
raw_control_lights
.iter()
- .filter(|(light, flags)| light.active && flags & 0x8000_0000 == 0)
+ .filter(|(light, _)| light.active)
.map(|(light, _)| *light)
.collect()
}
@@ -5496,6 +5501,13 @@ fn append_initial_control_effect_slots(
let Some(point_list) = control.point_list.as_ref() else {
return Ok(());
};
+ let light_owner_scope = u32::try_from(object_index)
+ .ok()
+ .and_then(|index| index.checked_add(1))
+ .ok_or_else(|| "Control light owner exceeds GPU scope range".to_string())?;
+ let initial_range_count = mesh.draw_ranges.len();
+ let mut unsupported_commands = 0;
+ let mut state_dependent_commands = 0;
for row in
selected_initial_control_effect_rows(control.initial_effect_group, &control.effect_section)
{
@@ -5509,10 +5521,22 @@ fn append_initial_control_effect_slots(
)
})?;
let key = format!("{archive}/{name}");
+ let mut record_skip = |reason: &str,
+ count: usize,
+ needs_state: bool,
+ command: Option<usize>| {
+ if needs_state {
+ state_dependent_commands += count;
+ } else {
+ unsupported_commands += count;
+ }
+ eprintln!("unexpanded Control FX reason:{reason} count:{count} needs_game_state:{needs_state} object:{object_index} component:{component_index} source:{key} table:{} row:{} command:{command:?}", row.table_index, row.row_index);
+ };
if !effect_documents.contains_key(&key) {
let bytes = load_resource_entry_bytes_from_root(root, &archive, &name)
.map_err(|error| format!("load Control FX {key}: {error}"))?;
let Ok(document) = decode_fxid(bytes) else {
+ eprintln!("render_coverage control_fx_invalid_documents=1 (object:{object_index} component:{component_index} source:{key})");
continue;
};
effect_documents.insert(key.clone(), document);
@@ -5529,17 +5553,24 @@ fn append_initial_control_effect_slots(
let duration_seconds = header.duration_seconds;
if !duration_seconds.is_finite()
|| duration_seconds < 0.0
- || !matches!(header.time_mode, 0 | 1 | 2)
- || (header.time_mode != 0 && 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())
{
- // Control can reference action effects whose header timing and
- // scale conventions are outside this ambient subset. Leave them
- // unexpanded until their lifetime and transform contract are
- // understood rather than failing the whole mission preview.
*unsupported_sounds += sound_commands;
+ record_skip("header_values", document.commands().len(), false, None);
+ continue;
+ }
+ if !matches!(header.time_mode, 0..=3) || (header.time_mode != 0 && duration_seconds <= 0.0)
+ {
+ *unsupported_sounds += sound_commands;
+ record_skip(
+ "time_mode",
+ document.commands().len(),
+ fparkan_fx::fx_time_mode_requires_owner_state(header.time_mode),
+ None,
+ );
continue;
}
let owner_node_index =
@@ -5552,6 +5583,7 @@ fn append_initial_control_effect_slots(
});
let Some(owner_node_index) = owner_node_index else {
*unsupported_sounds += sound_commands;
+ record_skip("owner_node", document.commands().len(), false, None);
continue;
};
let owner_node_pose = control_effect_owner_node_pose(
@@ -5561,6 +5593,7 @@ fn append_initial_control_effect_slots(
);
let Some(owner_node_pose) = owner_node_pose else {
*unsupported_sounds += sound_commands;
+ record_skip("owner_pose", document.commands().len(), false, None);
continue;
};
let root_transform = LegacyIron3dEulerTransform {
@@ -5599,6 +5632,7 @@ fn append_initial_control_effect_slots(
for (command_index, command) in document.commands().iter().enumerate() {
if ambient_effect_op1_supported(command) || command.opcode == FxOpcode::Op2 {
let slot = ControlEffectSlot {
+ light_owner_scope,
time_mode: header.time_mode,
header_flags: header.flags,
random_instance_key,
@@ -5627,6 +5661,7 @@ fn append_initial_control_effect_slots(
});
} else {
*unsupported_sounds += 1;
+ record_skip("sound_body", 1, false, Some(command_index));
eprintln!("unsupported Control Op2 object={object_index} component={component_index} effect={key} command={command_index} (mode, backend parameter or malformed body)");
}
} else {
@@ -5635,6 +5670,7 @@ fn append_initial_control_effect_slots(
continue;
}
let Some(kind) = ambient_effect_command_kind(command) else {
+ record_skip("opcode_or_body", 1, false, Some(command_index));
continue;
};
if !ambient_effect_render_selector_allows_header(
@@ -5644,11 +5680,13 @@ fn append_initial_control_effect_slots(
continue;
}
let Some(reference) = command.resource_refs.first() else {
+ record_skip("material_reference", 1, false, Some(command_index));
continue;
};
let material_archive = String::from_utf8_lossy(reference.archive_name()).into_owned();
let material_name = String::from_utf8_lossy(reference.resource_name()).into_owned();
if material_archive.is_empty() || material_name.is_empty() {
+ record_skip("empty_material_reference", 1, false, Some(command_index));
continue;
}
let cache_key = format!("{material_archive}/{material_name}");
@@ -5669,6 +5707,7 @@ fn append_initial_control_effect_slots(
&prepared.phase_directional_rgb,
&prepared.phase_additive_rgb,
) {
+ record_skip("material_tint", 1, false, Some(command_index));
continue;
}
let first_material = *prepared
@@ -5686,6 +5725,7 @@ fn append_initial_control_effect_slots(
[0.0; 3],
AMBIENT_EFFECT_NATIVE_CATEGORY,
) else {
+ record_skip("mesh_template", 1, false, Some(command_index));
continue;
};
Some(template)
@@ -5820,6 +5860,10 @@ fn append_initial_control_effect_slots(
});
}
}
+ for range in &mut mesh.draw_ranges[initial_range_count..] {
+ range.owner_scope = light_owner_scope;
+ }
+ eprintln!("render_coverage control_fx_unsupported={unsupported_commands} control_fx_requires_game_state={state_dependent_commands}");
Ok(())
}
@@ -5984,65 +6028,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()
@@ -6174,8 +6223,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());
@@ -6183,7 +6231,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,
@@ -6193,13 +6247,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;
};
@@ -6920,42 +6970,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
- }
- _ => 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)
+ fparkan_fx::clock::FxInstanceClock::new(time_mode, header_flags, duration_seconds, 0.0)?
+ .phase(elapsed_seconds * 1000.0, false)
}
-
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]);
@@ -7606,7 +7633,7 @@ fn project_basement_foundation(
let mut shadow_receivers = Vec::with_capacity(foundation.fragments.len());
for (fragment_index, fragment) in foundation.fragments.iter().enumerate() {
- let (material_index, base_only, vertices) = match fragment.source_face {
+ let (material_index, base_only, reflection, vertices) = match fragment.source_face {
Some(face_index) => {
let face = terrain.faces.get(face_index).ok_or_else(|| {
format!("foundation fragment references unknown terrain face {face_index}")
@@ -7654,6 +7681,7 @@ fn project_basement_foundation(
(
face.material_tag,
fragment.surface_mask.0 & 0x400 == 0,
+ fragment.surface_mask.0 & 0x20000 != 0,
vertices,
)
}
@@ -7671,7 +7699,7 @@ fn project_basement_foundation(
fragment_uv0_words,
fragment_normals,
);
- (0xff00, true, vertices)
+ (0xff00, true, false, vertices)
}
};
@@ -7684,7 +7712,13 @@ fn project_basement_foundation(
mesh.indices.push(index);
}
if let Some(range) = mesh.draw_ranges.last_mut().filter(|range| {
- range.material_index == material_index && range.terrain_base_only == base_only
+ range.material_index == material_index
+ && range.terrain_base_only == base_only
+ && range.terrain_reflection == reflection
+ && (!reflection
+ || vertices[0].position[2]
+ == mesh.vertices[mesh.indices[range.first_index as usize] as usize]
+ .position[2])
}) {
range.index_count = range
.index_count
@@ -7692,6 +7726,7 @@ fn project_basement_foundation(
.ok_or_else(|| "basement terrain draw range exceeds u32".to_string())?;
} else {
mesh.draw_ranges.push(VulkanStaticDrawRange {
+ owner_scope: 0,
first_index,
index_count: 3,
material_index,
@@ -7699,6 +7734,7 @@ fn project_basement_foundation(
batch_flags: 0,
pipeline_state,
terrain_base_only: base_only,
+ terrain_reflection: reflection,
alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
});
}
@@ -8004,9 +8040,9 @@ fn static_preview_mesh_and_materials(
let unverified_special_faces = terrain_mesh
.faces
.iter()
- .filter(|face| face.flags.0 & 8 != 0)
+ .filter(|face| face.flags.0 & 8 != 0 && face.flags.0 & 0x20000 == 0)
.count();
- eprintln!("render_coverage terrain_face8_pipeline_unverified={unverified_special_faces} (source faces across LODs use the ordinary terrain shader; native special pass and reflection/EMBM mapping pending)");
+ eprintln!("render_coverage terrain_face8_pipeline_unverified={unverified_special_faces} (non-reflective source faces across LODs; native special-pass mapping pending)");
let footprint_sources = mission_basement_footprints(assets, roots)?;
let foundation = if footprint_sources.is_empty() {
None
@@ -8384,6 +8420,10 @@ fn static_preview_mesh_and_materials(
.map_err(|err| format!("project mission MSH for Vulkan: {err}"))?;
let component_handedness = native_msh_handedness(transform, mission_root.scale);
for range in &mut component.draw_ranges {
+ range.owner_scope = u32::try_from(object_index)
+ .ok()
+ .and_then(|index| index.checked_add(1))
+ .ok_or_else(|| "MSH light owner exceeds GPU scope range".to_string())?;
range.pipeline_state.cull =
native_msh_cull_mode(range.batch_flags, component_handedness);
}
@@ -8524,9 +8564,6 @@ fn static_preview_mesh_and_materials(
dynamic_component_geometry_by_object.push(dynamic_component_geometry);
control_animation_instances_by_object.push(control_animation_instances);
}
- if mesh_components == 0 {
- return Err("selected static preview roots have no mesh-backed visual".to_string());
- }
let free_camera = if legacy_camera.is_none() {
Some(FreeFlightCamera::from_mesh_and_terrain(&mesh, terrain)?)
} else {
@@ -8545,7 +8582,23 @@ fn static_preview_mesh_and_materials(
};
let camera = view_camera.vulkan_camera();
let shadow_material_index = append_shadow_material(&mut materials)?;
+ let fixed_water_clock_ms = legacy_camera.as_ref().and_then(|capture| {
+ capture
+ .sky_uv_clock
+ .as_ref()
+ .and_then(|clock| clock.raw_clock)
+ });
+ if legacy_camera.is_some()
+ && fixed_water_clock_ms.is_none()
+ && mesh
+ .draw_ranges
+ .iter()
+ .any(|range| range.terrain_reflection)
+ {
+ eprintln!("render_coverage captured_water_phase_unverified=1 (capture has no verified raw queue clock; using replay elapsed time)");
+ }
Ok(StaticPreviewScene {
+ fixed_water_clock_ms,
mesh,
dynamic_vertex_start,
terrain_lod,
@@ -9360,6 +9413,7 @@ fn run_static_vulkan_mode(
}
struct StaticVulkanApp {
+ fixed_water_clock_ms: Option<u32>,
mesh: VulkanStaticMesh,
dynamic_vertex_start: usize,
terrain_lod: Option<terrain_lod::TerrainLodDraws>,
@@ -9371,7 +9425,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>>>,
@@ -9479,7 +9533,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,
@@ -9488,6 +9542,7 @@ impl StaticVulkanApp {
dynamic_shadow_casters: preview.dynamic_shadow_casters,
sun_occlusion_range_indices: preview.sun_occlusion_range_indices,
terrain: preview.terrain,
+ fixed_water_clock_ms: preview.fixed_water_clock_ms,
world_range_indices,
world_range_index_counts,
camera: preview.camera,
@@ -9547,17 +9602,7 @@ impl StaticVulkanApp {
horizontal_fov(&self.view_camera, aspect),
)?;
}
- let approximate_portals = self
- .world_range_indices
- .iter()
- .filter(|&&index| {
- let flags = self.mesh.draw_ranges[index].batch_flags;
- flags & 8 != 0
- && flags & 0x50 != 0x40
- && !self.type16_present_ranges.contains(&index)
- })
- .count();
- eprintln!("render_coverage portal_fade_packet_selection_approx={approximate_portals} (first indexed vertex; native CPU clipping selection pending)");
+ eprintln!("render_coverage msh_type16_colors_unsupported={} (authored vertex colors are not decoded; using white)", self.type16_present_ranges.len());
let randomized_lights = self
.control_point_light_slots
.iter()
@@ -9976,20 +10021,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
@@ -9997,13 +10040,12 @@ impl StaticVulkanApp {
.control_clock_target_ms
.is_none_or(|target_ms| control_clock_ms >= target_ms),
);
- let control_vulkan_lights = ordinary_control_point_lights(&control_point_lights);
+ let control_vulkan_lights = active_control_point_lights(&control_point_lights);
let effect_updates = update_ambient_effect_geometry(
&mut self.ambient_effect_slots,
&self.component_pose_buffers,
&mut self.mesh,
&mut self.material_animations,
- effect_elapsed_seconds,
&control_effect_random_samples,
self.view_camera.position,
)?;
@@ -10046,13 +10088,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| {
@@ -10061,7 +10097,7 @@ impl StaticVulkanApp {
slot.command_index,
&slot.world_from_effect,
phase,
- effect_elapsed_seconds,
+ random_sample.elapsed_seconds,
self.view_camera.position,
)
});
@@ -10192,13 +10228,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(
@@ -10324,6 +10356,21 @@ impl StaticVulkanApp {
&mut self.material_animations,
renderer,
)?;
+ let water_clock_ms = self
+ .fixed_water_clock_ms
+ .map_or(frame.clock.time_ms, |captured| {
+ if self.target_frames == 0 {
+ captured.wrapping_add(frame.clock.time_ms)
+ } else {
+ captured
+ }
+ });
+ let mut uniforms = renderer.frame_uniforms();
+ uniforms.camera_position = frame.camera.position;
+ uniforms.embm_phase = fparkan_render::water::legacy_embm_phase(water_clock_ms);
+ renderer
+ .set_frame_uniforms(uniforms)
+ .map_err(|error| format!("update water clock uniforms: {error}"))?;
for update in &frame.effect_updates {
renderer
.set_draw_range_index_count(update.range_index, update.index_count)
@@ -10407,6 +10454,7 @@ impl StaticVulkanApp {
.iter()
.find(|light| light.active)
.map(|light| VulkanPointLight {
+ owner_scope: 0,
position: light.position,
rgb: [light.color[0], light.color[1], light.color[2]],
range: light.range,
@@ -10422,6 +10470,7 @@ impl StaticVulkanApp {
.iter()
.filter(|light| light.active)
.map(|light| VulkanPointLight {
+ owner_scope: 0,
position: light.position,
rgb: [light.color[0], light.color[1], light.color[2]],
range: light.range,
@@ -11201,6 +11250,7 @@ mod tests {
positions: &[[f32; 3]],
) -> (VulkanStaticMesh, VulkanStaticDrawRange) {
let range = VulkanStaticDrawRange {
+ owner_scope: 0,
first_index: 0,
index_count: u32::try_from(positions.len()).expect("fixture length fits u32"),
material_index: 0,
@@ -11208,6 +11258,7 @@ mod tests {
batch_flags,
pipeline_state: LegacyPipelineState::default(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
};
let mesh = VulkanStaticMesh {
@@ -12457,6 +12508,7 @@ mod tests {
.map(|index| index + environment_vertex_start),
);
let draw_range = |first_index, index_count| VulkanStaticDrawRange {
+ owner_scope: 0,
first_index,
index_count,
material_index: 0,
@@ -12467,6 +12519,7 @@ mod tests {
LegacyDepthMode::TestReadOnly,
),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
};
let mut mesh = VulkanStaticMesh {
@@ -13091,6 +13144,35 @@ mod tests {
#[test]
#[ignore = "requires installed GOG resources; set FPARKAN_GAME_ROOT"]
+ fn gog_static_preview_accepts_terrain_without_placed_objects() {
+ let root = std::env::var_os("FPARKAN_GAME_ROOT")
+ .map(PathBuf::from)
+ .unwrap_or_else(|| PathBuf::from(r"C:\GOG Games\Parkan - Iron Strategy"));
+ let path = "DATA/MAPS/41/Land.msh";
+ let bytes = std::fs::read(root.join(path)).expect("original map mesh");
+ let document = fparkan_assets::decode_nres_payload(Arc::from(bytes)).unwrap();
+ let land = fparkan_terrain_format::decode_land_msh(&document).unwrap();
+ let terrain = TerrainWorld::from_land_msh(&land).unwrap();
+ let preview = static_preview_mesh_and_materials(
+ &MissionAssets::default(),
+ &terrain,
+ &[],
+ None,
+ None,
+ None,
+ &root,
+ path,
+ )
+ .expect("terrain is a complete scene without object meshes");
+ assert_eq!(preview.mesh_components, 0);
+ assert_eq!(preview.terrain_components, 1);
+ assert!(!preview.mesh.vertices.is_empty());
+ assert!(!preview.mesh.indices.is_empty());
+ assert!(!preview.materials.is_empty());
+ }
+
+ #[test]
+ #[ignore = "requires installed GOG resources; set FPARKAN_GAME_ROOT"]
fn gog_terrain_detail_uses_multitexture_animation_block() {
let root = std::env::var_os("FPARKAN_GAME_ROOT")
.map(PathBuf::from)
@@ -13139,6 +13221,7 @@ mod tests {
#[test]
fn draw_range_reorder_remaps_all_material_animation_bindings() {
let range = |first_index, material_index| VulkanStaticDrawRange {
+ owner_scope: 0,
first_index,
index_count: 3,
material_index,
@@ -13149,6 +13232,7 @@ mod tests {
LegacyDepthMode::TestReadOnly,
),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
};
let mut mesh = VulkanStaticMesh {
@@ -13203,6 +13287,7 @@ mod tests {
#[test]
fn native_world_transparency_uses_sampled_alpha_and_native_batch_flags() {
let range = |blend, batch_flags| VulkanStaticDrawRange {
+ owner_scope: 0,
first_index: 0,
index_count: 3,
material_index: 0,
@@ -13210,6 +13295,7 @@ mod tests {
batch_flags,
pipeline_state: world_pipeline_state(blend, LegacyDepthMode::TestReadOnly),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
};
@@ -13252,7 +13338,8 @@ mod tests {
let alpha = native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0, -0.55, -25.0], 1.04)
.unwrap()
.unwrap();
- assert!((alpha - 0.5).abs() < 0.000_001);
+ // Native RSQRTSS perturbs the distance before the extra square root.
+ assert!((alpha - 0.5).abs() < 0.000_3);
assert_eq!(
native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0, -0.55, -60.0], 1.04),
Ok(Some(1.0))
@@ -13564,6 +13651,7 @@ mod tests {
row_index: 0,
};
let control_slot = ControlEffectSlot {
+ light_owner_scope: 1,
time_mode: 1,
header_flags: 9,
random_instance_key: key,
@@ -13651,6 +13739,7 @@ mod tests {
};
let view_camera = free_camera.preview_frame(16.0 / 9.0);
let preview = StaticPreviewScene {
+ fixed_water_clock_ms: None,
terrain_lod: None,
dynamic_vertex_start: 3,
mesh: VulkanStaticMesh {
@@ -13667,12 +13756,14 @@ mod tests {
.collect(),
indices: vec![0, 1, 2],
draw_ranges: vec![VulkanStaticDrawRange {
+ owner_scope: 0,
first_index: 0,
index_count: 3,
material_index: 0,
lightmap_index: u8::MAX,
batch_flags: 0,
terrain_base_only: false,
+ terrain_reflection: false,
pipeline_state: world_pipeline_state(
LegacyBlendMode::Opaque,
LegacyDepthMode::TestReadOnly,
@@ -13733,11 +13824,11 @@ mod tests {
}
fn control_random_snapshot(
- state: &ControlEffectRandomState,
+ state: &ControlEffectInstanceState,
) -> (
u32,
u32,
- ControlEffectRandomSample,
+ ControlEffectFrameSample,
Option<SceneSampleId>,
Option<u32>,
) {
@@ -13809,7 +13900,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);
@@ -13822,7 +13913,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!(
@@ -13844,7 +13935,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);
@@ -13941,7 +14032,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();
@@ -14046,7 +14137,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!(
@@ -14104,7 +14195,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)));
@@ -14145,7 +14236,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!(
@@ -14179,7 +14270,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);
@@ -14215,7 +14306,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!(
@@ -15255,7 +15346,97 @@ mod tests {
let mesh = VulkanStaticMesh::smoke_triangle();
let distance = first_indexed_vertex_distance(&mesh, 0, [0.0, -0.55, -3.0])
.expect("first indexed vertex distance");
- assert!((distance - 3.0).abs() < 1.0e-6);
+ assert!((distance - 3.0).abs() < 1.0e-3);
+ }
+
+ #[test]
+ #[ignore = "requires the original GOG installation and native SSE reference"]
+ fn gog_portal_alpha_keeps_the_reference_vertex_outside_the_viewport() {
+ let root = std::env::var_os("FPARKAN_CORPUS_PART1_ROOT")
+ .expect("set FPARKAN_CORPUS_PART1_ROOT to the original GOG installation");
+ let mut engine =
+ create(EngineServices::new(Arc::new(DirectoryVfs::new(root)))).expect("engine");
+ load_mission_static_preview_roots(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/Autodemo.00/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ },
+ NonZeroUsize::new(usize::MAX).unwrap(),
+ )
+ .expect("original AutoDemo");
+ let assets = loaded_mission_assets(&engine).expect("mission assets");
+ let root = &loaded_mission_object_drafts(&engine).expect("mission objects")[4];
+ let visual = assets
+ .visual_by_id(assets.object_visuals[4][0])
+ .expect("factory visual");
+ let model = assets.model_by_id(visual.model_id.unwrap()).unwrap();
+ let mesh = project_msh_to_static_mesh_in_world_space_with_node_sampled_poses(
+ &model.validated,
+ LegacyIron3dEulerTransform {
+ translation: root.position,
+ orientation_radians: root.orientation_raw,
+ },
+ root.scale,
+ 0,
+ )
+ .expect("factory mesh");
+ // GOG capture: yaw/roll expose only part of the window; alpha was read
+ // after Terrain's callback stored it, before the renderer consumed it.
+ let transform = RawCameraTransform {
+ words: [
+ 3_204_541_090,
+ 3_209_392_467,
+ 1_051_461_443,
+ 1_135_728_591,
+ 1_063_051_501,
+ 3_203_294_891,
+ 1_045_010_100,
+ 1_143_538_729,
+ 0,
+ 1_053_254_103,
+ 1_064_028_839,
+ 1_093_589_271,
+ 0,
+ 0,
+ 0,
+ 1_065_353_216,
+ ],
+ };
+ let camera = VulkanStaticCamera::from_legacy_d3d7(
+ transform,
+ LegacyD3d7Projection {
+ viewport: [0, 0, 800, 600],
+ near_plane: 0.5,
+ far_plane: 700.0,
+ field_of_view_radians: 1.04,
+ },
+ )
+ .expect("native edge camera");
+ let range = mesh.draw_ranges[33];
+ assert_eq!(range.batch_flags, 0x0108);
+ let clip_vertex = |offset: usize| -> [f32; 4] {
+ let index = mesh.indices[range.first_index as usize + offset] as usize;
+ let [x, y, z] = mesh.vertices[index].position;
+ let point = [x, y, z, 1.0];
+ std::array::from_fn(|column| {
+ (0..4)
+ .map(|row| point[row] * camera.clip_from_world[row * 4 + column])
+ .sum()
+ })
+ };
+ let outside = clip_vertex(0);
+ let inside = clip_vertex(1);
+ assert!(outside[0] > outside[3]);
+ assert!(inside[0].abs() < inside[3]);
+ let distance = first_indexed_vertex_distance(&mesh, 33, transform.translation())
+ .expect("portal reference distance");
+ assert!((distance - f32::from_bits(0x42D5_7EE4)).abs() < 2.0e-5);
+ let alpha =
+ native_msh_diffuse_alpha_override(&mesh, 33, false, transform.translation(), 1.04)
+ .expect("portal alpha")
+ .expect("portal override");
+ assert!((alpha - f32::from_bits(0x3F05_190C)).abs() < 1.0e-6);
}
#[test]
@@ -15716,13 +15897,18 @@ mod tests {
let mut value: serde_json::Value =
serde_json::from_slice(include_bytes!("../testdata/legacy-camera-parented.json"))
.unwrap();
- value["sky_uv_clock"] =
- serde_json::json!({"verified": true, "elapsed_milliseconds": 4051271258_u32});
+ value["sky_uv_clock"] = serde_json::json!({"verified": true, "elapsed_milliseconds": 4051271258_u32, "raw_clock": 272342176_u32});
let capture = parse_legacy_camera_capture(&serde_json::to_vec(&value).unwrap()).unwrap();
- assert_eq!(
- capture.sky_uv_clock.unwrap().elapsed_milliseconds,
- 4051271258
- );
+ let clock = capture.sky_uv_clock.unwrap();
+ assert_eq!(clock.elapsed_milliseconds, 4051271258);
+ assert_eq!(clock.raw_clock, Some(272342176));
+ value["sky_uv_clock"]
+ .as_object_mut()
+ .unwrap()
+ .remove("raw_clock");
+ let old_capture =
+ parse_legacy_camera_capture(&serde_json::to_vec(&value).unwrap()).unwrap();
+ assert_eq!(old_capture.sky_uv_clock.unwrap().raw_clock, None);
value["sky_uv_clock"]["verified"] = serde_json::json!(false);
assert!(parse_legacy_camera_capture(&serde_json::to_vec(&value).unwrap()).is_err());
}
@@ -16405,7 +16591,7 @@ mod tests {
let expected = dot3(expected, expected).sqrt();
let actual = first_indexed_vertex_distance(&mesh, range.range_index, camera)
.expect("native Op9 primitive distance key");
- assert!((actual - expected).abs() < 1e-5);
+ assert!((actual - expected).abs() < expected * 0.000_3 + 1e-5);
}
for selector in [1, 2] {
@@ -16434,7 +16620,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));
@@ -16523,6 +16709,7 @@ mod tests {
#[test]
fn op1_control_lights_use_native_phase_owner_transform_and_flags() {
let mut slot = ControlEffectSlot {
+ light_owner_scope: 1,
time_mode: 1,
header_flags: 0,
random_instance_key: ControlEffectInstanceKey {
@@ -16559,21 +16746,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,
);
@@ -16583,6 +16784,10 @@ mod tests {
assert_eq!(light.rgb, [0.25, 0.5, 0.75]);
assert_eq!(light.range, 15.0);
assert_eq!(light.coefficients, [1.0, 0.5, 0.25]);
+ assert_eq!(
+ light.owner_scope, 1,
+ "native local light reaches its owning root"
+ );
assert_eq!(flags, 0xA000_0000);
assert_eq!(
[
@@ -16599,67 +16804,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,
@@ -16680,8 +16908,9 @@ mod tests {
}
#[test]
- fn op1_generic_diffuse_list_excludes_bit31_and_clears_inactive_lights() {
- let light = |position, active| VulkanPointLight {
+ fn op1_diffuse_list_retains_owner_lights_and_clears_inactive_lights() {
+ let light = |position, owner_scope, active| VulkanPointLight {
+ owner_scope,
position,
rgb: [0.25, 0.5, 0.75],
range: 15.0,
@@ -16689,15 +16918,21 @@ mod tests {
active,
};
let raw_records = vec![
- (light([1.0, 2.0, 3.0], true), 0xA000_0000),
- (light([4.0, 5.0, 6.0], true), 0x2000_0000),
- (light([7.0, 8.0, 9.0], true), 0x8000_0000),
- (light([10.0, 11.0, 12.0], false), 0x0000_0000),
+ (light([1.0, 2.0, 3.0], 5, true), 0xA000_0000),
+ (light([4.0, 5.0, 6.0], 0, true), 0x2000_0000),
+ (light([7.0, 8.0, 9.0], 2, true), 0x8000_0000),
+ (light([10.0, 11.0, 12.0], 0, false), 0x0000_0000),
];
- let ordinary = ordinary_control_point_lights(&raw_records);
- assert_eq!(ordinary.len(), 1);
- assert_eq!(ordinary[0].position, [4.0, 5.0, 6.0]);
+ let active = active_control_point_lights(&raw_records);
+ assert_eq!(active.len(), 3);
+ assert_eq!(
+ active
+ .iter()
+ .map(|light| light.owner_scope)
+ .collect::<Vec<_>>(),
+ [5, 0, 2]
+ );
assert_eq!(raw_records.len(), 4);
assert_eq!(
raw_records
@@ -16707,9 +16942,9 @@ mod tests {
vec![0xA000_0000, 0x2000_0000, 0x8000_0000, 0]
);
- assert!(ordinary_control_point_lights(&[]).is_empty());
+ assert!(active_control_point_lights(&[]).is_empty());
assert!(
- ordinary_control_point_lights(&[(light([13.0, 14.0, 15.0], false), 0x2000_0000)])
+ active_control_point_lights(&[(light([13.0, 14.0, 15.0], 0, false), 0x2000_0000)])
.is_empty()
);
}
diff --git a/apps/fparkan-game/src/terrain_lod.rs b/apps/fparkan-game/src/terrain_lod.rs
index 599178c..1dd707d 100644
--- a/apps/fparkan-game/src/terrain_lod.rs
+++ b/apps/fparkan-game/src/terrain_lod.rs
@@ -233,12 +233,14 @@ mod tests {
#[test]
fn cell_ranges_keep_material_state_and_separate_generated_bands() {
let range = VulkanStaticDrawRange {
+ owner_scope: 0,
first_index: 0,
index_count: 12,
material_index: 7,
lightmap_index: u8::MAX,
batch_flags: 0,
terrain_base_only: true,
+ terrain_reflection: false,
pipeline_state: super::super::world_pipeline_state(
fparkan_render::LegacyBlendMode::Opaque,
fparkan_render::LegacyDepthMode::TestWrite,