diff --git a/README.md b/README.md index d67cc98..663d4cd 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,10 @@ интерактивную сцену выбранной карты: terrain, все доступные MSH-компоненты объектов, материалы, динамические тени, небо, погоду, lightning FX и звуковые события. Это просмотр карты с полной графической сценой; прохождение миссии и -игровое поведение юнитов пока не запускаются, а модели показываются в статической -позе, заданной сохранёнными настройками узлов прототипа. +игровое поведение юнитов пока не запускаются. Модели начинают с настроек узлов +прототипа, а поддерживаемые Control-анимации и эффекты обновляются по общим +часам сцены. Неподдерживаемые Control-треки остаются на сохранённой начальной +позе. ## Запуск @@ -46,23 +48,21 @@ cargo run --release -p fparkan-game -- ` луна, flares, молнии, дождь и снег, point lights, динамические тени и связанные звуковые события обновляются в каждом кадре. -`--frames N` ограничивает запуск числом представленных кадров для -smoke/readback-проверок; -значение `0` означает бесконечный интерактивный запуск. `--control-clock-ms N` -воспроизводит общий clock анимации MSH и материалов вместе с окружением от нуля -до `N` за `--frames` кадров и требует не менее двух кадров. Пересоздание -swapchain повторяет текущий sample времени и состояние мира; перед повторной -отправкой обновляются camera projection и viewport-зависимая геометрия. Это не -продвигает сцену или replay clock. Счётчик и replay clock переходят дальше -после представленного кадра. -`--legacy-camera-capture` читает сохранённый захват камеры и запускает -воспроизводимый режим без -free-flight управления. `--static-animation-frame ` задаёт один кадр для -всех узлов. Если параметр не указан, приложение берёт сохранённые настройки -узлов из явной ссылки прототипа на `.ctl`; для узлов без такой настройки -используется кадр `0`. Статическая поза не запускает игровой Control, AI или -полное поведение юнитов. -`--static-material-phase ` выбирает фиксированную фазу материала. +`--frames N` ограничивает запуск числом кадров для smoke/readback-проверок; +значение `0` означает бесконечный интерактивный запуск. `--legacy-camera-capture` +читает сохранённый захват камеры и запускает воспроизводимый режим без +free-flight управления. В обычном запуске накопленный runtime clock обновляет +MSH Control-анимацию, поддержанные Control FX и анимацию материалов. +`--control-clock-ms ` вместе с `--frames N` (не меньше двух) воспроизводит +этот clock от нуля до заданного миллисекундного значения за выбранные кадры. + +`--static-animation-frame ` задаёт один кадр для всех MSH-узлов и +выключает выбор MSH-позы из Control clock. Без override приложение берёт +начальные настройки узлов из явных `.ctl` bindings, затем применяет +поддержанные Control-треки. Узлы без настройки остаются на кадре `0`. Это не +запускает AI, миссионные события или полное поведение юнитов. +`--static-material-phase ` фиксирует фазу MSH и terrain материалов; фаза +материала Control FX остаётся частью его normalized command timeline. `--readback-out target/frame.raw` сохраняет последний кадр GPU. `--validation` включает Vulkan validation layers и завершает запуск с ошибкой при предупреждении или ошибке. Readback сохраняет байты в формате surface без преобразования; diff --git a/apps/fparkan-game/Cargo.toml b/apps/fparkan-game/Cargo.toml index bf0f540..4cb0326 100644 --- a/apps/fparkan-game/Cargo.toml +++ b/apps/fparkan-game/Cargo.toml @@ -14,6 +14,7 @@ fparkan-resource = { path = "../../crates/fparkan-resource" } fparkan-render = { path = "../../crates/fparkan-render" } fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit" } fparkan-msh = { path = "../../crates/fparkan-msh" } +fparkan-material = { path = "../../crates/fparkan-material" } fparkan-prototype = { path = "../../crates/fparkan-prototype" } fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" } fparkan-runtime = { path = "../../crates/fparkan-runtime" } @@ -24,6 +25,3 @@ rodio = { version = "0.23.0", default-features = false, features = ["playback", serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" winit = { version = "0.30", default-features = false, features = ["rwh_06"] } - -[dev-dependencies] -fparkan-material = { path = "../../crates/fparkan-material" } diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index 0dff5ea..6384ab7 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -4,19 +4,28 @@ use fparkan_animation::{AnimationTime, NodePoseBuffer}; use fparkan_assets::{ + load_material_archive_name_texture_rgba8_and_phase_with_document_from_root, load_material_name_texture_rgba8_and_phase_with_document_from_root, load_resource_entry_bytes_from_root, load_standalone_wear_named_material_textures_rgba8_and_phases_with_documents_from_root, - page_uv_transform, sample_control_node_times, sample_material_phase, ControlInstanceState, - ControlSamplingError, Mat0Document, MaterialPhase, MaterialPhaseSample, - PreparedComponentControl, PreparedControlGateProvider, PreparedControlNodeBinding, - PreparedMaterialPhase, PreparedModel, PreparedTexture, PreparedTextureUsage, PreparedVisual, - RgbaImage, StandaloneWearMaterialLoader, + page_uv_transform, sample_control_node_times, sample_material_normalized_phase, + sample_material_phase, ControlInstanceState, ControlSamplingError, Mat0Document, MaterialPhase, + MaterialPhaseSample, PreparedComponentControl, PreparedControlEffectRow, + PreparedControlEffectSection, PreparedControlGateProvider, PreparedControlNodeBinding, + PreparedControlPoint, PreparedControlPointList, PreparedMaterialPhase, PreparedModel, + PreparedTexture, PreparedTextureUsage, PreparedVisual, RgbaImage, StandaloneWearMaterialLoader, }; -use fparkan_fx::atmosphere::TypedAtmosphere; +use fparkan_fx::ambient_op3::{ + evaluate_op3_type0_command, evaluate_op3_type1_command, evaluate_op3_type2_command, +}; +use fparkan_fx::atmosphere::{AtmosphereFrame, TypedAtmosphere}; use fparkan_fx::environment::{ - decode_env_lightning_fxid, Camera as FxCamera, EnvironmentFrame, EnvironmentPrimitive, - EnvironmentSystem, PrecipitationKind, ScreenBillboard, + decode_env_lightning_fxid, evaluate_op1_point_light_with_basis, Camera as FxCamera, + EnvironmentFrame, EnvironmentPrimitive, EnvironmentSystem, PrecipitationKind, ScreenBillboard, +}; +use fparkan_fx::fx_visual::{evaluate_f_gener_ball, evaluate_f_gener_ball_mesh}; +use fparkan_fx::op7::{ + evaluate_op7_emitter, Op7EmitterState, Op7RenderContext, Op7VisibilityProfile, }; use fparkan_fx::shadow::{ ShadowCamera, ShadowCaster, ShadowFrame, ShadowLight, ShadowLodSettings, ShadowPageCache, @@ -26,15 +35,19 @@ use fparkan_fx::sky::{ sprite_half_size_pixels, SkyFrame, SkyMaterials, SkyMesh, SkyPassKind, SkySpriteFrame, SkySystem, SunVisibility, SKY_FLARE_ROWS, }; +use fparkan_fx::smoke::{evaluate_op8_emitter, Op8DetailThresholdRange, SmokeEmitterState}; +use fparkan_fx::{advance_native_fx_random_state, decode_fxid, FxCommand, FxDocument, FxOpcode}; +#[cfg(test)] +use fparkan_material::{MaterialAnimationBlock, MaterialKey}; use fparkan_msh::{ node38_fallback_hierarchy, node38_sampled_hierarchy, node38_sampled_hierarchy_at_times, selected_slot, Group, Lod, ModelAsset, NodeId, SlotId, }; use fparkan_path::ResourceName; use fparkan_platform_winit::{window_native_handles, WinitWindow, WinitWindowPlan}; -use fparkan_prototype::{unit_component_tree, UnitComponentTreeNode, PROTOTYPE_TYPE_EXTO}; +use fparkan_prototype::{unit_component_tree, PROTOTYPE_TYPE_EXTO}; use fparkan_render::{ - LegacyBlendMode, LegacyCullMode, LegacyD3d7Projection, LegacyDepthMode, + LegacyBlendMode, LegacyCullMode, LegacyD3d7Projection, LegacyDepthBias, LegacyDepthMode, LegacyIron3dEulerTransform, LegacyPipelineState, RawCameraTransform, }; use fparkan_render_vulkan::{ @@ -46,22 +59,27 @@ use fparkan_render_vulkan::{ project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount, project_msh_to_static_mesh_in_world_space_with_node_sampled_poses, project_msh_to_static_mesh_in_world_space_with_node_sampled_poses_and_mount, - VulkanDynamicDrawRange, VulkanNodePose, VulkanPointLight, VulkanSmokeFrameOutcome, - VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanStaticCamera, VulkanStaticDrawRange, - VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture, VulkanStaticTextureMip, - VulkanStaticVertex, + VulkanDynamicDrawRange, VulkanNodePose, VulkanPointLight, VulkanReadbackArtifact, + VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, + VulkanStaticCamera, VulkanStaticDrawRange, VulkanStaticMaterial, VulkanStaticMesh, + VulkanStaticTexture, VulkanStaticTextureMip, VulkanStaticVertex, }; use fparkan_runtime::{ create, load_mission_static_preview, load_mission_static_preview_roots, loaded_mission_assets, - loaded_mission_object_drafts, loaded_terrain, query_building_segment_world, BuildingMeshNode, - EngineServices, MissionAssets, MissionMode, MissionObjectDraft, MissionRequest, + loaded_mission_object_drafts, loaded_terrain, query_building_segment_world, + BasementPlacementMode, BuildingMeshNode, EngineServices, MissionAssets, MissionMode, + MissionObjectDraft, MissionRequest, }; +#[cfg(test)] +use fparkan_terrain::BasementFoundationFragment; use fparkan_terrain::{ + build_basement_foundations, BasementFootprint, BasementFoundation, BasementPlacement, FullSurfaceMask, SurfaceQuery, TerrainLod, TerrainMaterialLayers, TerrainWorld, }; use fparkan_vfs::DirectoryVfs; use serde::Deserialize; use std::collections::{HashMap, HashSet}; +use std::io::Write; use std::num::NonZeroUsize; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -153,11 +171,12 @@ fn run(args: &[String]) -> Result { environment, audio, args.frames, - args.control_clock_ms, &args.mission, loaded.object_count, args.readback_out.as_deref(), + args.readback_sequence_out.as_deref(), args.validation, + args.control_clock_ms, StartupTimings { mission_assets: mission_assets_duration, terrain_materials: terrain_materials_duration, @@ -353,7 +372,6 @@ impl FreeFlightCamera { self.position[2] = self.position[2].max(minimum_z); Ok(()) } - fn forward(self) -> [f32; 3] { let (sin_yaw, cos_yaw) = self.yaw.sin_cos(); let (sin_pitch, cos_pitch) = self.pitch.sin_cos(); @@ -622,6 +640,10 @@ impl PreviewCameraFrame { const ENVIRONMENT_SPRITE_ROWS: [usize; 2] = [3, 4]; const ENVIRONMENT_FLARE_SLOTS: usize = 12; const ENVIRONMENT_PARTICLE_SLOTS: usize = 1_000; +// shortcut: Effect.dll derives this from projected sphere size and a render-context +// quality table. Category 0 supplies a highest-detail mesh until that exact +// world category is available; this does not reproduce native LOD selection. +const AMBIENT_EFFECT_NATIVE_CATEGORY: u8 = 0; #[derive(Clone, Copy, Debug)] struct EnvironmentGpuRange { @@ -631,6 +653,200 @@ struct EnvironmentGpuRange { material_index: usize, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AmbientEffectKind { + Op3Type0, + Op3Type1, + Op3Type2, + GenerBall, + SmokePool, + Op7ParticlePool, +} + +#[derive(Clone, Debug)] +struct AmbientEffectGpuSlot { + kind: AmbientEffectKind, + time_mode: u32, + header_flags: u32, + random_instance_key: ControlEffectInstanceKey, + random_seed: u32, + phase_jitter: f32, + random_shift: [f32; 3], + owner_object_index: usize, + owner_component_index: usize, + owner_node_index: usize, + owner_component_is_mounted: bool, + root_transform: LegacyIron3dEulerTransform, + owner_scale: [f32; 3], + mount_pose: Option, + local_from_effect: [[f32; 4]; 4], + control_source: String, + control_table_index: usize, + control_row_index: usize, + control_point_indices: [u32; 3], + control_point_owners: [i32; 3], + command: FxCommand, + command_index: usize, + duration_seconds: f32, + world_from_effect: [[f32; 4]; 4], + range: EnvironmentGpuRange, + additional_particle_ranges: Vec, + material_animation_index: usize, + additional_particle_material_animation_indices: Vec, + smoke_emitter_state: Option, + op7_emitter_state: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct AmbientEffectMeshPrimitive { + vertex_start: usize, + vertex_count: usize, + index_start: usize, + index_count: usize, +} + +#[derive(Clone, Debug)] +struct ControlPointLightSlot { + time_mode: u32, + header_flags: u32, + random_instance_key: ControlEffectInstanceKey, + random_seed: u32, + phase_jitter: f32, + random_shift: [f32; 3], + duration_seconds: f32, + owner_object_index: usize, + owner_component_index: usize, + owner_node_index: usize, + owner_component_is_mounted: bool, + root_transform: LegacyIron3dEulerTransform, + owner_scale: [f32; 3], + mount_pose: Option, + local_from_effect: [[f32; 4]; 4], + world_from_effect: [[f32; 4]; 4], + command_index: usize, + command: FxCommand, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +struct ControlEffectInstanceKey { + object_index: usize, + component_index: usize, + table_index: usize, + row_index: usize, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct ControlEffectRandomSample { + phase_jitter: f32, + accumulated_shift: [f32; 3], +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SceneSampleId { + Live(u64), + Replay(u64), +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct FrameClockSample { + id: SceneSampleId, + time_ms: u32, + delta_seconds: f32, +} + +#[derive(Clone, Debug)] +struct NativeFxRandom16 { + state: u32, +} + +impl NativeFxRandom16 { + fn new(seed: u32) -> Self { + Self { + state: if seed == 0 { 0xA341_316C } else { seed }, + } + } + + fn next_u16(&mut self) -> u16 { + advance_native_fx_random_state(&mut self.state) + } + + fn symmetric_sample(&mut self, amplitude: f32) -> f32 { + (f32::from(self.next_u16()) / 65_536.0 - 0.5) * amplitude + } +} + +#[derive(Clone, Debug)] +struct ControlEffectRandomState { + header_flags: u32, + phase_jitter_amplitude: f32, + random_shift_amplitude: [f32; 3], + phase_and_z_rng: NativeFxRandom16, + xy_rng: NativeFxRandom16, + sample: ControlEffectRandomSample, + last_update_id: Option, + last_update_clock_ms: Option, +} + +impl ControlEffectRandomState { + fn new(seed: u32, header_flags: u32, phase_jitter: f32, random_shift: [f32; 3]) -> Self { + Self { + header_flags, + phase_jitter_amplitude: phase_jitter, + random_shift_amplitude: random_shift, + phase_and_z_rng: NativeFxRandom16::new(seed ^ 0xA511_E9B3), + xy_rng: NativeFxRandom16::new(seed ^ 0x63D8_3595), + sample: ControlEffectRandomSample::default(), + last_update_id: None, + last_update_clock_ms: None, + } + } + + fn advance( + &mut self, + sample_id: SceneSampleId, + control_clock_ms: u32, + initial_phase: f32, + ) -> ControlEffectRandomSample { + // 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. + if self.last_update_id == Some(sample_id) { + return self.sample; + } + if self.header_flags & 1 != 0 && initial_phase != -1.0 { + self.sample.phase_jitter = self + .phase_and_z_rng + .symmetric_sample(self.phase_jitter_amplitude); + } else { + self.sample.phase_jitter = 0.0; + } + if self.header_flags & 8 != 0 { + let shift_z = self + .phase_and_z_rng + .symmetric_sample(self.random_shift_amplitude[2]); + let shift_y = self.xy_rng.symmetric_sample(self.random_shift_amplitude[1]); + let shift_x = self.xy_rng.symmetric_sample(self.random_shift_amplitude[0]); + self.sample.accumulated_shift[0] += shift_x; + self.sample.accumulated_shift[1] += shift_y; + self.sample.accumulated_shift[2] += shift_z; + } + self.last_update_id = Some(sample_id); + self.last_update_clock_ms = Some(control_clock_ms); + self.sample + } +} + +#[derive(Clone, Debug)] +struct PreparedEffectMaterial { + document: Mat0Document, + phase_material_indices: Vec, + phase_uv_transforms: Vec<[f32; 4]>, + phase_native_flag_40: Vec, + phase_directional_rgb: Vec<[f32; 3]>, + phase_additive_rgb: Vec<[f32; 3]>, + initial_pipeline_category: u8, +} + #[derive(Clone, Debug)] struct EnvironmentMaterialAsset { texture: VulkanStaticTexture, @@ -720,6 +936,23 @@ impl MaterialPhaseBinding { }) } + fn sample_normalized( + &self, + material_index: usize, + normalized_phase: f32, + ) -> Result { + sample_material_normalized_phase( + &self.document, + self.animation_block_index, + normalized_phase, + ) + .map_err(|error| { + format!( + "sample MAT0 material {material_index} at normalized phase {normalized_phase}: {error}" + ) + }) + } + fn page_uv_transform(&self, phase_index: usize) -> [f32; 4] { self.phase_uv_transforms .get(phase_index) @@ -745,11 +978,14 @@ impl MaterialPhaseBinding { struct MaterialVariantSet { phase_material_indices: Vec, range_indices: Vec, + pipeline_category: u8, } #[derive(Clone, Debug)] struct MaterialAnimationBinding { initial_phase_index: usize, + normalized_phase_override: Option, + ambient_fx_material_alpha: bool, variants: Vec, base: MaterialPhaseBinding, detail: Option, @@ -758,10 +994,39 @@ struct MaterialAnimationBinding { stage_textures: Option, } -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Copy, Debug)] struct ActiveMaterialState { material_index: usize, + phase_index: usize, diffuse_alpha: f32, + additive_rgb: [f32; 3], +} + +fn ambient_effect_material_alpha(is_ambient_effect: bool, sampled_opacity: f32) -> f32 { + if is_ambient_effect { + 1.0 + } else { + sampled_opacity + } +} + +fn sample_material_animation_base( + binding: &mut MaterialAnimationBinding, + clock_ms: u32, +) -> Result { + match binding.normalized_phase_override { + Some(normalized_phase) => binding + .base + .sample_normalized(binding.initial_phase_index, normalized_phase), + None => binding.base.sample(binding.initial_phase_index, clock_ms), + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct AmbientEffectGeometryUpdate { + range_index: usize, + index_count: u32, + command_rgb: [f32; 3], } fn selected_material_phase( @@ -777,14 +1042,14 @@ fn selected_material_phase( Ok((material_index, pipeline_category)) } -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Copy, Debug)] struct MaterialRangeSelection { range_index: usize, material_index: usize, pipeline_category: u8, } -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Copy, Debug)] struct MaterialPropertiesUpdate { material_index: usize, diffuse_alpha: f32, @@ -800,14 +1065,14 @@ struct MaterialPropertiesUpdate { uv_transforms: [[f32; 4]; 4], } -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Copy, Debug)] struct MaterialStagePhaseUpdate { binding_index: usize, material_index: usize, phases: [usize; 3], } -#[derive(Clone, Debug, Default, PartialEq)] +#[derive(Clone, Debug, Default)] struct MaterialFrameUpdate { active_materials: HashMap, range_selections: Vec, @@ -822,7 +1087,11 @@ fn sample_material_animations( ) -> Result { let mut update = MaterialFrameUpdate::default(); for (binding_index, binding) in bindings.iter_mut().enumerate() { - let base_sample = binding.base.sample(binding.initial_phase_index, clock_ms)?; + let base_sample = sample_material_animation_base(binding, clock_ms)?; + let material_alpha = ambient_effect_material_alpha( + binding.ambient_fx_material_alpha, + base_sample.coefficients.opacity, + ); let detail_sample = binding .detail .as_mut() @@ -839,7 +1108,7 @@ fn sample_material_animations( .map(|overlay_detail| overlay_detail.sample(binding.initial_phase_index, clock_ms)) .transpose()?; let mut updated_materials = HashSet::new(); - for variant in &binding.variants { + for variant in &mut binding.variants { let (material_index, pipeline_category) = selected_material_phase(variant, base_sample.phase_index, |index| { materials @@ -851,7 +1120,9 @@ fn sample_material_animations( range_index, ActiveMaterialState { material_index, - diffuse_alpha: base_sample.coefficients.opacity, + phase_index: base_sample.phase_index, + diffuse_alpha: material_alpha, + additive_rgb: base_sample.coefficients.additive_rgb, }, ); update.range_selections.push(MaterialRangeSelection { @@ -860,6 +1131,7 @@ fn sample_material_animations( pipeline_category, }); } + variant.pipeline_category = pipeline_category; if !updated_materials.insert(material_index) { continue; } @@ -871,9 +1143,7 @@ fn sample_material_animations( let (base_directional, base_additive, overlay_directional, overlay_additive) = if material.lightmap_mode { // Terrain mode 3 samples the lightmap in the secondary - // stage and uses the authored directional RGB as its - // multiplicative lightmap coefficient. It has no normal - // lighting or overlay stage. + // stage and uses the authored directional RGB as additive. ([0.0; 3], base_coefficients.directional_rgb, None, None) } else { ( @@ -883,16 +1153,27 @@ fn sample_material_animations( overlay_coefficients.map(|coefficients| coefficients.additive_rgb), ) }; - if !material.lightmap_mode && binding.stage_textures.is_some() { - update.stage_phases.push(MaterialStagePhaseUpdate { - binding_index, - material_index, - phases: [ + + if !material.lightmap_mode { + if let Some(stage_textures) = binding.stage_textures.as_ref() { + let stage_phases = [ detail_sample.map_or(0, |sample| sample.phase_index), overlay_sample.map_or(0, |sample| sample.phase_index), overlay_detail_sample.map_or(0, |sample| sample.phase_index), - ], - }); + ]; + if stage_textures + .applied_material_phases + .get(&material_index) + .copied() + != Some(stage_phases) + { + update.stage_phases.push(MaterialStagePhaseUpdate { + binding_index, + material_index, + phases: stage_phases, + }); + } + } } let overlay_uv = overlay_sample .map(|sample| { @@ -942,7 +1223,7 @@ fn sample_material_animations( .checked_page_uv_transform(base_sample.phase_index)?; update.properties.push(MaterialPropertiesUpdate { material_index, - diffuse_alpha: base_coefficients.opacity.clamp(0.0, 1.0), + diffuse_alpha: material_alpha.clamp(0.0, 1.0), overlay_alpha: overlay_coefficients .map_or(0.0, |coefficients| coefficients.opacity.clamp(0.0, 1.0)), base_directional, @@ -1370,6 +1651,15 @@ fn world_pipeline_state(blend: LegacyBlendMode, depth: LegacyDepthMode) -> Legac } } +fn projected_shadow_pipeline_state() -> LegacyPipelineState { + LegacyPipelineState { + blend: LegacyBlendMode::SourceAlpha, + depth: LegacyDepthMode::TestReadOnly, + depth_bias: LegacyDepthBias::ProjectedDecal, + ..LegacyPipelineState::default() + } +} + fn environment_vertex(position: [f32; 3], color: [f32; 3], uv: [f32; 2]) -> VulkanStaticVertex { environment_vertex_with_alpha(position, color, 1.0, uv) } @@ -1511,9 +1801,12 @@ fn append_environment_material( let document = asset.document.clone(); material_animations.push(MaterialAnimationBinding { initial_phase_index: 0, + normalized_phase_override: None, + ambient_fx_material_alpha: false, variants: vec![MaterialVariantSet { phase_material_indices, range_indices: Vec::new(), + pipeline_category: phases[0].pipeline_category, }], base: MaterialPhaseBinding { document, @@ -1532,6 +1825,132 @@ fn append_environment_material( Ok(first_material_index) } +fn prepare_effect_material( + root: &Path, + archive: &str, + name: &str, + materials: &mut Vec, + cache: &mut HashMap, +) -> Result { + let key = format!("{archive}/{name}"); + if let Some(prepared) = cache.get(&key) { + return Ok(prepared.clone()); + } + let (first_mips, first_phase, document) = + load_material_archive_name_texture_rgba8_and_phase_with_document_from_root( + root, archive, name, 0, + ) + .map_err(|error| format!("resolve FX MAT0 {key}: {error}"))?; + if document.phases.is_empty() { + return Err(format!("FX MAT0 {key} has no phases")); + } + + let mut phase_material_indices = Vec::with_capacity(document.phases.len()); + let mut phase_uv_transforms = Vec::with_capacity(document.phases.len()); + let mut phase_native_flag_40 = Vec::with_capacity(document.phases.len()); + let mut phase_directional_rgb = Vec::with_capacity(document.phases.len()); + let mut phase_additive_rgb = Vec::with_capacity(document.phases.len()); + for phase_index in 0..document.phases.len() { + let (mips, phase) = if phase_index == 0 { + (first_mips.clone(), first_phase) + } else { + let phase_index_u16 = u16::try_from(phase_index) + .map_err(|_| format!("FX MAT0 {key} phase index exceeds u16"))?; + let (mips, phase, _document) = + load_material_archive_name_texture_rgba8_and_phase_with_document_from_root( + root, + archive, + name, + phase_index_u16, + ) + .map_err(|error| format!("resolve FX MAT0 {key} phase {phase_index}: {error}"))?; + (mips, phase) + }; + let texture = vulkan_texture_from_rgba_mips(mips, "FX material")?; + let index = materials.len(); + let material_index = u16::try_from(index).map_err(|_| { + "FX effects exceed the available 16-bit material selector space".to_string() + })?; + materials.push(VulkanStaticMaterial { + material_index, + pipeline_category: phase.pipeline_category, + texture, + directional_rgb: phase.directional_rgb, + additive_rgb: phase.additive_rgb, + uv_transform: phase.page_uv_transform, + detail_texture: Some(solid_environment_texture([128, 128, 128, 255])), + overlay_texture: None, + overlay_detail_texture: None, + // FX descriptor opacity is carried by each frame's vertex alpha. + // Keep MAT0 alpha at one so it does not multiply the opacity a + // second time. + diffuse_alpha: 1.0, + overlay_diffuse_alpha: None, + overlay_directional_rgb: None, + overlay_additive_rgb: None, + specular_rgb: phase.specular_rgb, + specular_power: phase.power, + overlay_specular_rgb: None, + overlay_specular_power: None, + detail_uv_transform: [0.0, 0.0, 1.0, 1.0], + overlay_uv_transform: None, + overlay_detail_uv_transform: None, + unlit: true, + sky_nebula_stars: false, + lightmap_mode: false, + sky_far_depth: false, + screen_tl: false, + }); + phase_material_indices.push(index); + phase_uv_transforms.push(phase.page_uv_transform); + phase_native_flag_40.push(phase.native_flag_40); + phase_directional_rgb.push(phase.directional_rgb); + phase_additive_rgb.push(phase.additive_rgb); + } + let prepared = PreparedEffectMaterial { + initial_pipeline_category: materials[phase_material_indices[0]].pipeline_category, + document, + phase_material_indices, + phase_uv_transforms, + phase_native_flag_40, + phase_directional_rgb, + phase_additive_rgb, + }; + cache.insert(key, prepared.clone()); + Ok(prepared) +} + +fn append_effect_material_animation( + prepared: &PreparedEffectMaterial, + range_index: usize, + bindings: &mut Vec, +) -> usize { + let binding_index = bindings.len(); + bindings.push(MaterialAnimationBinding { + initial_phase_index: 0, + normalized_phase_override: None, + ambient_fx_material_alpha: true, + variants: vec![MaterialVariantSet { + phase_material_indices: prepared.phase_material_indices.clone(), + range_indices: vec![range_index], + pipeline_category: prepared.initial_pipeline_category, + }], + base: MaterialPhaseBinding { + document: prepared.document.clone(), + animation_block_index: 0, + wear_row_start_ms: 0, + random_state: u32::try_from(binding_index).unwrap_or(u32::MAX), + frozen_phase_index: None, + phase_uv_transforms: prepared.phase_uv_transforms.clone(), + }, + detail: None, + overlay: None, + overlay_detail: None, + stage_textures: None, + }); + binding_index +} + fn append_environment_gradient_material( materials: &mut Vec, ) -> Result { @@ -1648,53 +2067,58 @@ fn terrain_shadow_receivers( ) -> Result, String> { let assignments = terrain .face_lod_assignments() - .map_err(|error| error.to_string())?; - let mut receivers = Vec::with_capacity( - assignments - .iter() - .filter(|assignment| assignment.lod == TerrainLod::Lod0) - .count(), - ); - for assignment in assignments { - if assignment.lod != TerrainLod::Lod0 { - continue; - } - let face = terrain.faces.get(assignment.face_index).ok_or_else(|| { - format!( - "terrain LOD0 references unknown face {}", - assignment.face_index - ) - })?; - let positions = [ - terrain_shadow_vertex_position(terrain, assignment.face_index, face.vertices[0])?, - terrain_shadow_vertex_position(terrain, assignment.face_index, face.vertices[1])?, - terrain_shadow_vertex_position(terrain, assignment.face_index, face.vertices[2])?, - ]; - receivers.push(shadow_triangle(positions, face.flags.0)); - } - Ok(receivers) + .map_err(|error| format!("select terrain LOD0 shadow receivers: {error}"))?; + assignments + .into_iter() + .filter(|assignment| assignment.lod == TerrainLod::Lod0) + .map(|assignment| { + let face = terrain.faces.get(assignment.face_index).ok_or_else(|| { + format!( + "terrain LOD assignment references missing face {}", + assignment.face_index + ) + })?; + let positions = source_terrain_face_positions(terrain, assignment.face_index)?; + if !positions.iter().flatten().all(|value| value.is_finite()) { + return Err("terrain shadow receiver contains a non-finite position".to_string()); + } + Ok(shadow_triangle(positions, face.flags.0)) + }) + .collect() } -fn terrain_shadow_vertex_position( +fn source_terrain_face_positions( terrain: &fparkan_terrain_format::LandMeshDocument, face_index: usize, - vertex_index: u16, -) -> Result<[f32; 3], String> { - let position = terrain - .positions - .get(usize::from(vertex_index)) - .copied() - .ok_or_else(|| { - format!( - "terrain LOD0 shadow face {face_index} references unknown vertex {vertex_index}" - ) - })?; - if !position.iter().all(|value| value.is_finite()) { - return Err(format!( - "terrain LOD0 shadow face {face_index} has a non-finite vertex {vertex_index}" - )); - } - Ok(position) +) -> Result<[[f32; 3]; 3], String> { + let face = terrain + .faces + .get(face_index) + .ok_or_else(|| format!("terrain LOD assignment references missing face {face_index}"))?; + let [first, second, third] = face.vertices; + Ok([ + terrain + .positions + .get(usize::from(first)) + .copied() + .ok_or_else(|| { + format!("terrain face {face_index} references missing vertex {first}") + })?, + terrain + .positions + .get(usize::from(second)) + .copied() + .ok_or_else(|| { + format!("terrain face {face_index} references missing vertex {second}") + })?, + terrain + .positions + .get(usize::from(third)) + .copied() + .ok_or_else(|| { + format!("terrain face {face_index} references missing vertex {third}") + })?, + ]) } fn static_mesh_shadow_triangles(mesh: &VulkanStaticMesh) -> Vec { @@ -1721,46 +2145,205 @@ fn static_mesh_shadow_triangles(mesh: &VulkanStaticMesh) -> Vec .collect() } -fn expand_shadow_sphere_to_triangles( - sphere: &mut ShadowSphere, +fn static_mesh_shadow_triangles_in_index_range( + mesh: &VulkanStaticMesh, + first_index: usize, + index_count: usize, +) -> Result, String> { + if index_count % 3 != 0 { + return Err(format!( + "dynamic shadow index range {first_index}..{} is not triangle-aligned", + first_index.saturating_add(index_count) + )); + } + let end = first_index + .checked_add(index_count) + .filter(|end| *end <= mesh.indices.len()) + .ok_or_else(|| { + format!( + "dynamic shadow index range {first_index}..{} is out of bounds", + first_index.saturating_add(index_count) + ) + })?; + mesh.indices[first_index..end] + .chunks_exact(3) + .map(|triangle| { + let positions = + triangle + .iter() + .map(|index| { + mesh.vertices + .get(usize::try_from(*index).map_err(|_| { + "dynamic shadow vertex index exceeds usize".to_string() + })?) + .map(|vertex| vertex.position) + .ok_or_else(|| { + format!("dynamic shadow vertex index {index} is out of bounds") + }) + }) + .collect::, _>>()?; + let positions: [[f32; 3]; 3] = positions + .try_into() + .map_err(|_| "dynamic shadow triangle did not have three vertices".to_string())?; + if !positions.iter().flatten().all(|value| value.is_finite()) { + return Err("dynamic shadow triangle contains a non-finite position".to_string()); + } + Ok(shadow_triangle(positions, 0)) + }) + .collect() +} + +fn refresh_dynamic_shadow_casters( + casters: &mut [ShadowCaster], + dynamic_casters: &[DynamicShadowCaster], + mesh: &VulkanStaticMesh, + pose_buffers: &[Vec>], + mount_poses: &[Vec>], +) -> Result<(), String> { + for dynamic in dynamic_casters { + let caster = casters.get_mut(dynamic.caster_index).ok_or_else(|| { + format!( + "dynamic shadow caster {} is outside the aggregate caster table", + dynamic.caster_index + ) + })?; + let mut triangles = Vec::new(); + for &range_index in &dynamic.component_draw_range_indices { + let range = mesh.draw_ranges.get(range_index).ok_or_else(|| { + format!( + "dynamic shadow caster {} references missing draw range {range_index}", + dynamic.caster_index + ) + })?; + triangles.extend(static_mesh_shadow_triangles_in_index_range( + mesh, + usize::try_from(range.first_index) + .map_err(|_| "dynamic shadow draw range index exceeds usize".to_string())?, + usize::try_from(range.index_count) + .map_err(|_| "dynamic shadow draw range count exceeds usize".to_string())?, + )?); + } + if triangles.is_empty() { + return Err(format!( + "dynamic shadow caster {} lost its component triangles", + dynamic.caster_index + )); + } + let component_spheres = dynamic + .component_spheres + .iter() + .filter_map(|source| { + let mount_pose = if source.mounted { + mount_poses + .get(dynamic.object_index) + .and_then(|items| items.get(source.component_index)) + .copied() + .flatten() + } else { + None + }; + if source.mounted && mount_pose.is_none() { + return None; + } + let node1_pose = if source.mounted { + pose_buffers + .get(dynamic.object_index) + .and_then(|items| items.get(source.component_index)) + .and_then(Option::as_ref) + .and_then(|hierarchy| { + node38_pose_relative_to_root_from_hierarchy(hierarchy, 1) + }) + } else { + None + }; + mounted_shadow_sphere(source.center, source.radius, mount_pose, node1_pose) + }) + .collect::>(); + if component_spheres.len() != dynamic.component_spheres.len() { + return Err(format!( + "dynamic shadow caster {} lost one or more component sphere poses", + dynamic.caster_index + )); + } + let mut refreshed = aggregate_shadow_caster( + caster.id, + caster.native_kind, + dynamic.root_transform, + dynamic.owner_scale, + triangles, + &component_spheres, + ) + .ok_or_else(|| { + format!( + "dynamic shadow caster {} could not be rebuilt from current component poses", + dynamic.caster_index + ) + })?; + // shortcut: animated native sphere parity is unverified. Rebuild from + // the startup native bound, the current native bound, and current + // triangles so the result is conservative and independent of history. + refreshed.sphere = shadow_sphere_enclosing_current_geometry( + dynamic.initial_sphere, + refreshed.sphere, + &refreshed.triangles, + )?; + *caster = refreshed; + } + Ok(()) +} + +fn shadow_sphere_enclosing_current_geometry( + baseline: ShadowSphere, + current_native: ShadowSphere, triangles: &[ShadowTriangle], -) -> Result { +) -> Result { + let mut sphere = baseline; if !sphere.radius.is_finite() || sphere.radius < 0.0 || !sphere.center.iter().all(|value| value.is_finite()) + || !current_native.radius.is_finite() + || current_native.radius < 0.0 + || !current_native.center.iter().all(|value| value.is_finite()) { return Err("animated shadow caster has an invalid bounding sphere".to_string()); } - let mut changed = false; + + let offset = sub3(current_native.center, sphere.center); + let distance = offset[0].hypot(offset[1]).hypot(offset[2]); + if !distance.is_finite() { + return Err("animated shadow sphere distance is non-finite".to_string()); + } + let required = distance + current_native.radius; + if !required.is_finite() { + return Err("animated shadow sphere radius is non-finite".to_string()); + } + if current_native.radius >= distance + sphere.radius { + sphere = current_native; + } else if required > sphere.radius { + let next_radius = sphere.radius + 0.5 * (required - sphere.radius); + let shift = scale3(offset, (next_radius - sphere.radius) / distance); + sphere.center = add3(sphere.center, shift); + sphere.radius = next_radius; + } + for point in triangles.iter().flat_map(|triangle| triangle.positions) { if !point.iter().all(|value| value.is_finite()) { return Err("animated shadow triangle has a non-finite position".to_string()); } let offset = sub3(point, sphere.center); - let distance = length3(offset) - .ok_or_else(|| "animated shadow point distance is non-finite".to_string())?; + let distance = offset[0].hypot(offset[1]).hypot(offset[2]); + if !distance.is_finite() { + return Err("animated shadow point distance is non-finite".to_string()); + } if distance <= sphere.radius { continue; } - let next_radius = 0.5 * (sphere.radius + distance); - let center_shift = if distance > f32::EPSILON { - scale3(offset, (next_radius - sphere.radius) / distance) - } else { - [0.0; 3] - }; + let next_radius = sphere.radius + 0.5 * (distance - sphere.radius); + let center_shift = scale3(offset, (next_radius - sphere.radius) / distance); sphere.center = add3(sphere.center, center_shift); sphere.radius = next_radius; - changed = true; } - Ok(changed) -} -fn shadow_sphere_enclosing_triangles( - baseline: ShadowSphere, - triangles: &[ShadowTriangle], -) -> Result { - let mut sphere = baseline; - expand_shadow_sphere_to_triangles(&mut sphere, triangles)?; Ok(sphere) } @@ -1870,6 +2453,7 @@ fn shadow_lights( sky_frame: Option<&SkyFrame<'_>>, environment_frame: Option<&EnvironmentFrame>, renderer: &VulkanSmokeRenderer, + control_point_lights: &[(VulkanPointLight, u32)], ) -> Vec { let mut lights = Vec::new(); if let Some(sky_frame) = sky_frame { @@ -1931,6 +2515,18 @@ fn shadow_lights( flags: 0, }); } + lights.extend( + control_point_lights + .iter() + .map(|(light, flags)| ShadowLight::Point { + position: light.position, + rgb: light.rgb, + range: light.range, + coefficients: light.coefficients, + active: light.active, + flags: *flags, + }), + ); lights } @@ -2184,6 +2780,33 @@ fn reorder_draw_ranges( Ok(remap) } +fn remap_dynamic_shadow_caster_draw_ranges( + dynamic_casters: &mut [DynamicShadowCaster], + range_remap: &[usize], +) -> Result<(), String> { + let remapped = dynamic_casters + .iter() + .map(|dynamic| { + dynamic + .component_draw_range_indices + .iter() + .map(|&range_index| { + range_remap.get(range_index).copied().ok_or_else(|| { + format!( + "dynamic shadow caster {} references missing pre-reorder draw range {range_index}", + dynamic.caster_index + ) + }) + }) + .collect::, _>>() + }) + .collect::, _>>()?; + for (dynamic, ranges) in dynamic_casters.iter_mut().zip(remapped) { + dynamic.component_draw_range_indices = ranges; + } + Ok(()) +} + fn quad_indices(slot_count: usize) -> Result, String> { let mut indices = Vec::with_capacity( slot_count @@ -2283,6 +2906,8 @@ impl EnvironmentGpuScene { type16_present_ranges: &mut HashSet, materials: &mut Vec, preview_material_animations: &mut Vec, + ambient_effect_slots: &mut [AmbientEffectGpuSlot], + dynamic_shadow_casters: &mut [DynamicShadowCaster], sun_occlusion_range_indices: &mut Vec, environment_materials: &SkyMaterials, material_assets: &HashMap, @@ -2717,6 +3342,7 @@ impl EnvironmentGpuScene { remapped_type16_present_ranges.insert(new_range_index); } *type16_present_ranges = remapped_type16_present_ranges; + remap_dynamic_shadow_caster_draw_ranges(dynamic_shadow_casters, &remap)?; for layer in &mut scene.sky_layers { layer.range.range_index = *remap .get(layer.range.range_index) @@ -2738,6 +3364,16 @@ impl EnvironmentGpuScene { .ok_or_else(|| "lightning draw range remap is out of bounds".to_string())?; remap_material_animation_ranges(&mut scene.material_animations, &remap)?; remap_material_animation_ranges(preview_material_animations, &remap)?; + for slot in ambient_effect_slots { + slot.range.range_index = *remap + .get(slot.range.range_index) + .ok_or_else(|| "ambient FX draw range remap is out of bounds".to_string())?; + for range in &mut slot.additional_particle_ranges { + range.range_index = *remap.get(range.range_index).ok_or_else(|| { + "ambient FX particle draw range remap is out of bounds".to_string() + })?; + } + } for range_index in sun_occlusion_range_indices { *range_index = *remap .get(*range_index) @@ -3278,8 +3914,8 @@ fn segment_intersects_triangle( distance.is_finite() && (0.0..=1.0).contains(&distance) } -/// Replays the native draw-item callback: one whole indexed batch is keyed by -/// the camera distance to its first indexed vertex. +/// Replays Terrain's native packet callback: a draw range is keyed by the +/// camera distance to its first indexed vertex. fn first_indexed_vertex_distance( mesh: &VulkanStaticMesh, range_index: usize, @@ -3508,9 +4144,20 @@ fn lightning_quad_vertices( /// per-frame light/fog block. The sky mesh and weather primitives stay on the /// same update clock, so camera movement cannot leave environment audio or /// lighting one frame behind. -#[derive(Clone, Debug, PartialEq)] +struct DynamicEnvironment { + schedule: TypedAtmosphere, + sky: SkySystem, + environment: EnvironmentSystem, + materials: SkyMaterials, + material_assets: HashMap, + schedule_offset_seconds: f32, + time_seconds: f32, + fixed_schedule_seconds: Option, + fixed_phase_reported: bool, +} + struct DynamicEnvironmentFrame { - atmosphere: fparkan_fx::atmosphere::AtmosphereFrame, + atmosphere: AtmosphereFrame, sky_mesh: SkyMesh, environment_frame: EnvironmentFrame, absolute_time_seconds: f32, @@ -3532,26 +4179,28 @@ fn environment_camera_for_preview( .with_viewport(viewport) } -struct DynamicEnvironment { - schedule: TypedAtmosphere, - sky: SkySystem, - environment: EnvironmentSystem, - materials: SkyMaterials, - material_assets: HashMap, - schedule_offset_seconds: f32, - time_seconds: f32, - fixed_schedule_seconds: Option, - fixed_phase_reported: bool, +struct SceneFrameUpdate { + clock: FrameClockSample, + camera: PreviewCameraFrame, + viewport: [f32; 2], + aspect: f32, + control_point_lights: Vec<(VulkanPointLight, u32)>, + control_vulkan_lights: Vec, + effect_updates: Vec, + world_sort_keys: Vec<(usize, f32)>, + ambient_sort_keys: Vec<(usize, f32)>, + material_update: MaterialFrameUpdate, + environment_material_update: Option, + environment: Option, } impl DynamicEnvironment { - fn update( + fn update_cpu( &mut self, dt_seconds: f32, camera: &PreviewCameraFrame, aspect: f32, viewport: [f32; 2], - renderer: &mut VulkanSmokeRenderer, mut audio: Option<&mut audio::GameAudio>, ) -> Result { let dt_seconds = if dt_seconds.is_finite() { @@ -3592,31 +4241,6 @@ impl DynamicEnvironment { .map_err(|error| format!("update ambient variation: {error}"))?; } - let mut uniforms = renderer.frame_uniforms(); - uniforms.clip_from_world = camera.clip_from_world; - for (target, source) in uniforms - .directional_lights - .iter_mut() - .zip(sky_frame.directional_lights) - { - target.direction = source.direction; - target.rgb = source.color; - target.coefficients = [0.0, 0.0, 1.0]; - target.active = source.active; - } - uniforms.lighting_floor = sky_frame.lighting_floor; - let fog_rgb = sky_frame - .screen_gradient(camera.native_sky_heading(), [0.0; 3]) - .color - .rgba(); - uniforms.fog_color = [fog_rgb[0], fog_rgb[1], fog_rgb[2]]; - uniforms.fog_start = sky_frame.fog.start.max(0.0); - uniforms.fog_end = sky_frame.fog.end.max(uniforms.fog_start); - uniforms.camera_position = camera.position; - renderer - .set_frame_uniforms(uniforms) - .map_err(|error| format!("update sky frame uniforms: {error}"))?; - let fx_camera = environment_camera_for_preview(camera, aspect, viewport); let environment_frame = self.environment.update_atmosphere_with_materials( dt_seconds, @@ -3624,22 +4248,6 @@ impl DynamicEnvironment { &self.materials, fx_camera, ); - let mut uniforms = renderer.frame_uniforms(); - uniforms.point_light = environment_frame - .point_lights - .iter() - .find(|light| light.active) - .map(|light| VulkanPointLight { - position: light.position, - rgb: [light.color[0], light.color[1], light.color[2]], - range: light.range, - coefficients: light.attenuation, - active: true, - }) - .unwrap_or_default(); - renderer - .set_frame_uniforms(uniforms) - .map_err(|error| format!("update environment point-light uniforms: {error}"))?; if let Some(audio) = audio.as_mut() { for sound in &environment_frame.sounds { audio @@ -3942,6 +4550,10 @@ fn scale3(value: [f32; 3], factor: f32) -> [f32; 3] { [value[0] * factor, value[1] * factor, value[2] * factor] } +fn multiply3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [left[0] * right[0], left[1] * right[1], left[2] * right[2]] +} + fn dot3(left: [f32; 3], right: [f32; 3]) -> f32 { left[0].mul_add(right[0], left[1].mul_add(right[1], left[2] * right[2])) } @@ -3985,19 +4597,19 @@ struct StaticPreviewScene { type16_present_ranges: HashSet, materials: Vec, material_animations: Vec, + ambient_effect_slots: Vec, + control_point_light_slots: Vec, + component_pose_buffers: Vec>>, + component_mount_poses: Vec>>, + dynamic_component_geometry: Vec>>, + building_collision_bindings: Vec, + control_animation_instances: Vec>>, + dynamic_shadow_casters: Vec, sun_occlusion_range_indices: Vec, shadow_casters: Vec, - shadow_sphere_baselines: Vec, shadow_receivers: Vec, shadow_material_index: usize, terrain: Arc, - component_pose_buffers: Vec>>, - component_mount_poses: Vec>>, - control_animation_instances: Vec, - dynamic_component_geometry: Vec, - building_collision_bindings: Vec, - dynamic_component_mounts: Vec, - shadow_caster_indices_by_object: Vec>, camera: VulkanStaticCamera, view_camera: PreviewCameraFrame, captured_viewport: Option, @@ -4007,25 +4619,24 @@ struct StaticPreviewScene { terrain_components: usize, } -#[derive(Clone, Debug)] +#[derive(Clone)] struct ControlAnimationInstance { - object_index: usize, - component_index: usize, - model: Arc, control: PreparedComponentControl, + model: ModelAsset, + gate_values: Vec>, state: ControlInstanceState, + last_sampled_node_times: Vec>, + reported_unsupported_fallback: bool, } -#[derive(Clone, Debug)] +#[derive(Clone)] struct DynamicComponentGeometry { - object_index: usize, - component_index: usize, - model: Arc, - transform: LegacyIron3dEulerTransform, - scale: [f32; 3], + model: ModelAsset, + root_transform: LegacyIron3dEulerTransform, + owner_scale: [f32; 3], + parent_mount: Option<(usize, usize)>, vertex_start: usize, vertex_count: usize, - shadow_triangle_range: Option>, } #[derive(Clone)] @@ -4230,12 +4841,23 @@ fn component_node_world_matrix( world.iter().all(|value| value.is_finite()).then_some(world) } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct DynamicComponentMount { +#[derive(Clone, Debug)] +struct DynamicShadowCaster { + caster_index: usize, object_index: usize, + root_transform: LegacyIron3dEulerTransform, + owner_scale: [f32; 3], + initial_sphere: ShadowSphere, + component_draw_range_indices: Vec, + component_spheres: Vec, +} + +#[derive(Clone, Copy, Debug)] +struct DynamicShadowSphereSource { component_index: usize, - parent_index: usize, - socket: usize, + center: [f32; 3], + radius: f32, + mounted: bool, } #[derive(Clone, Copy, Debug, PartialEq)] @@ -4326,6 +4948,1939 @@ fn compose_unit_pose(parent: VulkanNodePose, child: VulkanNodePose) -> VulkanNod } } +fn control_type4_local_matrix( + points: [&PreparedControlPoint; 3], + owner_scale: [f32; 3], +) -> Option<[[f32; 4]; 4]> { + if !owner_scale.iter().all(|value| value.is_finite()) { + return None; + } + let positions = points.map(|point| multiply3(point.position, owner_scale)); + let directions = points.map(|point| multiply3(point.direction, owner_scale)); + if !positions + .iter() + .flatten() + .chain(directions.iter().flatten()) + .all(|value| value.is_finite()) + { + return None; + } + let reciprocal_three = f32::from_bits(0x3EAA_AAAB); + let translation = std::array::from_fn(|axis| { + (positions[0][axis] + positions[1][axis] + positions[2][axis]) * reciprocal_three + }); + if !translation.iter().all(|value| value.is_finite()) { + return None; + } + + let [d0, d1, d2] = directions; + if d0 == d1 && d1 == d2 { + let magnitude = finite_length3(d0)?; + if magnitude <= 0.0 { + return None; + } + let basis = control_3ef0_basis(scale3(d0, magnitude.recip()), translation)?; + let scaled = std::array::from_fn(|row| { + let mut result = basis[row]; + for column in 0..3 { + result[column] *= magnitude; + } + result + }); + return scaled + .iter() + .flatten() + .all(|value| value.is_finite()) + .then_some(scaled); + } + let (s, a) = if d0 == d2 || d1 == d2 { + (d0, d1) + } else if d0 == d1 { + (d0, d2) + } else { + let matrix = control_matrix_from_columns(d0, d1, d2, translation); + return matrix + .iter() + .flatten() + .all(|value| value.is_finite()) + .then_some(matrix); + }; + + // In a repeated-direction row d0 is always the native S vector; the + // other vector selected above is A for helper 0x4070. + let cross = cross3(a, s); + let cross_length = finite_length3(cross)?; + if cross_length <= 0.0 { + return control_3ef0_basis(s, translation); + } + let u = scale3(cross, cross_length.recip()); + let v = cross3(s, u); + let matrix = control_matrix_from_columns(s, u, v, translation); + matrix + .iter() + .flatten() + .all(|value| value.is_finite()) + .then_some(matrix) +} + +fn control_3ef0_basis(axis: [f32; 3], translation: [f32; 3]) -> Option<[[f32; 4]; 4]> { + if !axis + .iter() + .chain(translation.iter()) + .all(|value| value.is_finite()) + { + return None; + } + let mut u = if axis[0] == 0.0 { + [1.0, 0.0, 0.0] + } else if axis[1] == 0.0 { + [0.0, 1.0, 0.0] + } else { + [-axis[1], axis[0], 0.0] + }; + if axis[2] != 0.0 { + let u_length = finite_length3(u)?; + if u_length <= 0.0 { + return None; + } + u = scale3(u, u_length.recip()); + } + let v = cross3(axis, u); + let matrix = control_matrix_from_columns(axis, u, v, translation); + matrix + .iter() + .flatten() + .all(|value| value.is_finite()) + .then_some(matrix) +} + +fn control_matrix_from_columns( + first: [f32; 3], + second: [f32; 3], + third: [f32; 3], + translation: [f32; 3], +) -> [[f32; 4]; 4] { + [ + [first[0], second[0], third[0], translation[0]], + [first[1], second[1], third[1], translation[1]], + [first[2], second[2], third[2], translation[2]], + [0.0, 0.0, 0.0, 1.0], + ] +} + +fn finite_length3(value: [f32; 3]) -> Option { + let length = dot3(value, value).sqrt(); + length.is_finite().then_some(length) +} + +fn control_type4_effect_local_matrix( + points: [&PreparedControlPoint; 3], + effect_scale: [f32; 3], +) -> Option<[[f32; 4]; 4]> { + if !effect_scale.iter().all(|value| value.is_finite()) { + return None; + } + let mut local = control_type4_local_matrix(points, [1.0; 3])?; + for (column, scale) in effect_scale.into_iter().enumerate() { + for row in &mut local[..3] { + row[column] *= scale; + } + } + local + .iter() + .flatten() + .all(|value| value.is_finite()) + .then_some(local) +} + +fn control_effect_owner_node_pose( + hierarchy: Option<&NodePoseBuffer>, + component_is_mounted: bool, + owner_node_index: usize, +) -> Option { + match hierarchy { + Some(hierarchy) if component_is_mounted => { + node38_pose_relative_to_root_from_hierarchy(hierarchy, owner_node_index) + } + Some(hierarchy) => node38_pose_from_hierarchy(hierarchy, owner_node_index), + None if owner_node_index == 0 => Some(VulkanNodePose { + translation: [0.0; 3], + rotation: [0.0, 0.0, 0.0, 1.0], + }), + None => None, + } +} + +fn node_pose_row_major(pose: VulkanNodePose) -> [[f32; 4]; 4] { + let basis = [ + rotate_unit_vector([1.0, 0.0, 0.0], pose.rotation), + rotate_unit_vector([0.0, 1.0, 0.0], pose.rotation), + rotate_unit_vector([0.0, 0.0, 1.0], pose.rotation), + ]; + control_matrix_from_columns(basis[0], basis[1], basis[2], pose.translation) +} + +fn component_mount_matrix(mount_pose: VulkanNodePose) -> [[f32; 4]; 4] { + node_pose_row_major(mount_pose) +} + +fn component_scale_matrix(scale: [f32; 3]) -> [[f32; 4]; 4] { + [ + [scale[0], 0.0, 0.0, 0.0], + [0.0, scale[1], 0.0, 0.0], + [0.0, 0.0, scale[2], 0.0], + [0.0, 0.0, 0.0, 1.0], + ] +} + +fn matrix_to_row_major(matrix: [[f32; 4]; 4]) -> [f32; 16] { + std::array::from_fn(|index| matrix[index / 4][index % 4]) +} + +fn control_type4_world_from_effect( + local_from_effect: [[f32; 4]; 4], + owner_scale: [f32; 3], + root_transform: LegacyIron3dEulerTransform, + mount_pose: Option, + owner_node_pose: VulkanNodePose, +) -> Option<[[f32; 4]; 4]> { + if !owner_scale + .iter() + .chain(local_from_effect.iter().flatten()) + .chain(owner_node_pose.translation.iter()) + .chain(owner_node_pose.rotation.iter()) + .all(|value| value.is_finite()) + { + return None; + } + let root = root_transform.try_row_major()?; + let mut world_flat = multiply_row_major( + root, + matrix_to_row_major(component_scale_matrix(owner_scale)), + ); + if let Some(mount) = mount_pose { + world_flat = multiply_row_major( + world_flat, + matrix_to_row_major(component_mount_matrix(mount)), + ); + } + world_flat = multiply_row_major( + world_flat, + matrix_to_row_major(node_pose_row_major(owner_node_pose)), + ); + world_flat = multiply_row_major(world_flat, matrix_to_row_major(local_from_effect)); + let world = + std::array::from_fn(|row| std::array::from_fn(|column| world_flat[row * 4 + column])); + world + .iter() + .flatten() + .all(|value| value.is_finite()) + .then_some(world) +} + +fn control_type4_world_from_effect_with_random_shift( + mut local_from_effect: [[f32; 4]; 4], + random_shift: [f32; 3], + owner_scale: [f32; 3], + root_transform: LegacyIron3dEulerTransform, + mount_pose: Option, + owner_node_pose: VulkanNodePose, +) -> Option<[[f32; 4]; 4]> { + for (axis, value) in random_shift.into_iter().enumerate() { + local_from_effect[axis][3] += value; + } + control_type4_world_from_effect( + local_from_effect, + owner_scale, + root_transform, + mount_pose, + owner_node_pose, + ) +} + +#[cfg(test)] +fn ambient_effect_world_from_component_pose( + slot: &AmbientEffectGpuSlot, + component_pose_buffers: &[Vec>], +) -> Option<[[f32; 4]; 4]> { + ambient_effect_world_from_component_pose_with_random_shift( + slot, + component_pose_buffers, + [0.0; 3], + ) +} + +fn ambient_effect_world_from_component_pose_with_random_shift( + slot: &AmbientEffectGpuSlot, + component_pose_buffers: &[Vec>], + random_shift: [f32; 3], +) -> Option<[[f32; 4]; 4]> { + let hierarchy = component_pose_buffers + .get(slot.owner_object_index) + .and_then(|components| components.get(slot.owner_component_index)) + .and_then(Option::as_ref); + let owner_node_pose = control_effect_owner_node_pose( + hierarchy, + slot.owner_component_is_mounted, + slot.owner_node_index, + )?; + control_type4_world_from_effect_with_random_shift( + slot.local_from_effect, + random_shift, + slot.owner_scale, + slot.root_transform, + slot.mount_pose, + owner_node_pose, + ) +} + +fn control_point_light_world_from_component_pose_with_random_shift( + slot: &ControlPointLightSlot, + component_pose_buffers: &[Vec>], + random_shift: [f32; 3], +) -> Option<[[f32; 4]; 4]> { + let hierarchy = component_pose_buffers + .get(slot.owner_object_index) + .and_then(|components| components.get(slot.owner_component_index)) + .and_then(Option::as_ref); + let owner_node_pose = control_effect_owner_node_pose( + hierarchy, + slot.owner_component_is_mounted, + slot.owner_node_index, + )?; + control_type4_world_from_effect_with_random_shift( + slot.local_from_effect, + random_shift, + slot.owner_scale, + slot.root_transform, + slot.mount_pose, + owner_node_pose, + ) +} + +fn update_control_point_lights( + slots: &mut [ControlPointLightSlot], + component_pose_buffers: &[Vec>], + component_mount_poses: &[Vec>], + elapsed_seconds: f32, + random_samples: &HashMap, + emit_diagnostic: bool, +) -> Vec<(VulkanPointLight, u32)> { + let mut lights = Vec::new(); + for slot in slots { + if slot.owner_component_is_mounted { + slot.mount_pose = component_mount_poses + .get(slot.owner_object_index) + .and_then(|components| components.get(slot.owner_component_index)) + .copied() + .flatten(); + } + let random_sample = random_samples + .get(&slot.random_instance_key) + .copied() + .unwrap_or_default(); + let Some(world_from_effect) = + control_point_light_world_from_component_pose_with_random_shift( + slot, + component_pose_buffers, + random_sample.accumulated_shift, + ) + else { + 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 { + continue; + }; + let matrix = slot.world_from_effect; + let origin = [matrix[0][3], matrix[1][3], matrix[2][3]]; + let basis = [ + [matrix[0][0], matrix[1][0], matrix[2][0]], + [matrix[0][1], matrix[1][1], matrix[2][1]], + [matrix[0][2], matrix[1][2], matrix[2][2]], + ]; + let Some(frame) = evaluate_op1_point_light_with_basis(&slot.command, phase, origin, basis) + else { + continue; + }; + if !frame.active || frame.light_selector != 1 { + continue; + } + if emit_diagnostic { + eprintln!( + "control_point_light_detail object={} component={} table={} row={} command={} header_flags=0x{:08X} effect_type={} native_flags=0x{:08X} selector={} command_time={} phase={:.4} local_position_start={:?} local_position_end={:?} world_origin={:?} output_position={:?} rgb={:?} alpha={:.3} range={} attenuation={:?} effect_factor={}", + slot.owner_object_index, + slot.owner_component_index, + slot.random_instance_key.table_index, + slot.random_instance_key.row_index, + slot.command_index, + slot.header_flags, + frame.effect_type, + frame.native_flags, + frame.light_selector, + frame.command_time, + phase, + fx_command_vec3(&slot.command, 12), + fx_command_vec3(&slot.command, 24), + [matrix[0][3], matrix[1][3], matrix[2][3]], + frame.position, + frame.color, + frame.color[3], + frame.range, + frame.attenuation, + frame.effect_factor, + ); + } + lights.push(( + VulkanPointLight { + position: frame.position, + rgb: [frame.color[0], frame.color[1], frame.color[2]], + range: frame.range, + coefficients: frame.attenuation, + active: true, + }, + frame.native_flags, + )); + } + lights +} + +fn fx_command_vec3(command: &FxCommand, offset: usize) -> Option<[f32; 3]> { + let mut result = [0.0; 3]; + for (axis, value) in result.iter_mut().enumerate() { + let start = offset.checked_add(axis.checked_mul(4)?)?; + let bytes = command.raw_body.get(start..start.checked_add(4)?)?; + *value = f32::from_le_bytes(bytes.try_into().ok()?); + } + Some(result) +} + +fn report_control_point_lights_once( + reported: &mut bool, + control_clock_ms: u32, + target_clock_ms: Option, + environment_count: usize, + raw_control_lights: &[(VulkanPointLight, u32)], + ordinary_control_lights: &[VulkanPointLight], +) { + if *reported || std::env::var_os("FPARKAN_CONTROL_POINT_LIGHT_DIAGNOSTIC").is_none() { + return; + } + if target_clock_ms.is_some_and(|target| control_clock_ms < target) { + return; + } + eprintln!( + "control_point_lights clock_ms={control_clock_ms} environment_count={environment_count} control_raw_count={} control_ordinary_count={} raw_flags={:?}", + raw_control_lights.len(), + ordinary_control_lights.len(), + raw_control_lights.iter().map(|(_, flags)| *flags).collect::>(), + ); + *reported = true; +} + +fn ordinary_control_point_lights( + raw_control_lights: &[(VulkanPointLight, u32)], +) -> Vec { + raw_control_lights + .iter() + .filter(|(light, flags)| light.active && flags & 0x8000_0000 == 0) + .map(|(light, _)| *light) + .collect() +} + +fn initial_native_effect_phase(header_flags: u32, phase: f32) -> f32 { + if header_flags & 0x80 != 0 { + 0.0 + } else { + phase + } +} + +fn native_command_phase(header_flags: u32, phase: f32, random_phase_jitter: f32) -> f32 { + let initial_phase = initial_native_effect_phase(header_flags, phase); + if initial_phase == -1.0 { + -1.0 + } else { + (initial_phase + + if header_flags & 1 != 0 { + random_phase_jitter + } else { + 0.0 + }) + .clamp(0.0, 1.0) + } +} + +fn control_row_points<'a>( + point_list: &'a PreparedControlPointList, + row: &PreparedControlEffectRow, +) -> Option<[&'a PreparedControlPoint; 3]> { + let mut result = Vec::with_capacity(3); + for offset in row.point_offsets { + result.push(point_list.points.get(usize::try_from(offset).ok()?)?); + } + result.try_into().ok() +} + +fn append_initial_control_effect_slots( + root: &Path, + mission_root: &MissionObjectDraft, + object_index: usize, + component_index: usize, + control: &PreparedComponentControl, + component_pose_buffer: Option<&NodePoseBuffer>, + mount_pose: Option, + mesh: &mut VulkanStaticMesh, + materials: &mut Vec, + material_animations: &mut Vec, + effect_documents: &mut HashMap, + effect_materials: &mut HashMap, + slots: &mut Vec, + point_light_slots: &mut Vec, +) -> Result<(), String> { + let Some(point_list) = control.point_list.as_ref() else { + return Ok(()); + }; + for row in + selected_initial_control_effect_rows(control.initial_effect_group, &control.effect_section) + { + let group_index = row.table_index; + let archive = fixed_effect_field(&row.resource.archive); + let name = fixed_effect_field(&row.resource.name); + let points = control_row_points(point_list, row).ok_or_else(|| { + format!( + "Control group {group_index} row {} references a missing CPT point {:?}", + row.row_index, row.point_offsets + ) + })?; + let key = format!("{archive}/{name}"); + 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 { + continue; + }; + effect_documents.insert(key.clone(), document); + } + let document = effect_documents + .get(&key) + .ok_or_else(|| format!("Control FX {key} was not cached"))?; + let header = document.header(); + 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) + || !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. + continue; + } + let owner_node_index = + usize::try_from(points[0].raw_signed_field_04) + .ok() + .filter(|owner_node| { + component_pose_buffer + .is_some_and(|hierarchy| *owner_node < hierarchy.poses.len()) + || (*owner_node == 0 && component_pose_buffer.is_none()) + }); + let Some(owner_node_index) = owner_node_index else { + continue; + }; + let owner_node_pose = control_effect_owner_node_pose( + component_pose_buffer, + mount_pose.is_some(), + owner_node_index, + ); + let Some(owner_node_pose) = owner_node_pose else { + continue; + }; + let root_transform = LegacyIron3dEulerTransform { + translation: mission_root.position, + orientation_radians: mission_root.orientation_raw, + }; + let local_from_effect = control_type4_effect_local_matrix(points, header.scale) + .ok_or_else(|| format!("Control FX {key} has an invalid type-4 transform"))?; + let world_from_effect = control_type4_world_from_effect( + local_from_effect, + mission_root.scale, + root_transform, + mount_pose, + owner_node_pose, + ) + .ok_or_else(|| format!("Control FX {key} has an invalid owner transform"))?; + let control_source = format!( + "{}/{}", + control.source.archive.as_str(), + String::from_utf8_lossy(&control.source.name.0) + ); + let random_instance_key = ControlEffectInstanceKey { + object_index, + component_index, + table_index: row.table_index, + row_index: row.row_index, + }; + let random_seed = control_effect_instance_seed( + object_index, + component_index, + row.table_index, + row.row_index, + &control_source, + ); + + for (command_index, command) in document.commands().iter().enumerate() { + if ambient_effect_op1_supported(command) { + point_light_slots.push(ControlPointLightSlot { + time_mode: header.time_mode, + header_flags: header.flags, + random_instance_key, + random_seed, + phase_jitter: header.phase_jitter, + random_shift: header.random_shift, + duration_seconds, + owner_object_index: object_index, + owner_component_index: component_index, + owner_node_index, + owner_component_is_mounted: mount_pose.is_some(), + root_transform, + owner_scale: mission_root.scale, + mount_pose, + local_from_effect, + world_from_effect, + command_index, + command: command.clone(), + }); + continue; + } + let Some(kind) = ambient_effect_command_kind(command) else { + continue; + }; + if !ambient_effect_render_selector_allows_header( + header.flags, + CONTROL_COMPONENT_RENDER_SELECTOR, + ) { + continue; + } + let Some(reference) = command.resource_refs.first() else { + 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() { + continue; + } + let cache_key = format!("{material_archive}/{material_name}"); + if !effect_materials.contains_key(&cache_key) { + let _prepared = prepare_effect_material( + root, + &material_archive, + &material_name, + materials, + effect_materials, + )?; + } + let prepared = effect_materials + .get(&cache_key) + .ok_or_else(|| format!("FX MAT0 {cache_key} was not cached"))?; + if !ambient_effect_tint_supported( + &prepared.phase_native_flag_40, + &prepared.phase_directional_rgb, + &prepared.phase_additive_rgb, + ) { + continue; + } + let first_material = *prepared + .phase_material_indices + .first() + .ok_or_else(|| format!("FX MAT0 {cache_key} has no phase variants"))?; + let op9_mesh_template = + if kind == AmbientEffectKind::GenerBall && command.opcode == FxOpcode::Op9 { + let Some(template) = evaluate_f_gener_ball_mesh( + command, + command_index, + &world_from_effect, + 0.0, + 0.0, + [0.0; 3], + AMBIENT_EFFECT_NATIVE_CATEGORY, + ) else { + continue; + }; + Some(template) + } else { + None + }; + let op9_primitive_layout = op9_mesh_template + .as_ref() + .map(ambient_effect_mesh_primitive_layout) + .transpose()?; + let placeholder: [VulkanStaticVertex; 4] = std::array::from_fn(|index| { + environment_vertex_with_alpha( + [0.0; 3], + [1.0; 3], + 0.0, + [[0.0, 0.99], [0.0, 0.0], [0.99, 0.0], [0.99, 0.99]][index], + ) + }); + let pipeline_state = ambient_effect_pipeline_state(prepared.initial_pipeline_category) + .ok_or_else(|| { + format!( + "FX MAT0 {cache_key} has unsupported pipeline category {}", + prepared.initial_pipeline_category + ) + })?; + let pool_capacity = match kind { + AmbientEffectKind::SmokePool => ambient_effect_pool_capacity(command) + .ok_or_else(|| format!("FX Op8 {cache_key} has an invalid pool size"))?, + AmbientEffectKind::Op7ParticlePool => ambient_effect_op7_range_capacity(command) + .ok_or_else(|| format!("FX Op7 {cache_key} has an invalid particle pool"))?, + _ => 1, + }; + let range_capacity = op9_primitive_layout + .as_ref() + .map_or(pool_capacity, Vec::len); + let mut particle_ranges = Vec::with_capacity(range_capacity); + let mut particle_material_animation_indices = Vec::with_capacity(range_capacity); + for _ in 0..pool_capacity { + if let (Some(template), Some(primitives)) = + (&op9_mesh_template, &op9_primitive_layout) + { + for primitive in primitives { + let vertices = ambient_effect_mesh_primitive_vertices(template, *primitive); + let indices = ambient_effect_mesh_primitive_indices(template, *primitive)?; + let range = append_environment_range( + mesh, + first_material, + vertices, + indices, + pipeline_state, + )?; + let material_animation_index = append_effect_material_animation( + prepared, + range.range_index, + material_animations, + ); + particle_ranges.push(range); + particle_material_animation_indices.push(material_animation_index); + } + } else { + let range = append_environment_range( + mesh, + first_material, + placeholder.to_vec(), + vec![0, 1, 2, 2, 3, 0], + pipeline_state, + )?; + let material_animation_index = append_effect_material_animation( + prepared, + range.range_index, + material_animations, + ); + particle_ranges.push(range); + particle_material_animation_indices.push(material_animation_index); + } + } + let range = particle_ranges.remove(0); + let material_animation_index = particle_material_animation_indices.remove(0); + let smoke_emitter_state = (kind == AmbientEffectKind::SmokePool).then(|| { + let seed = ambient_effect_seed( + object_index, + component_index, + row.table_index, + row.row_index, + command_index, + &control_source, + ); + SmokeEmitterState::new(seed, seed, seed) + }); + let op7_emitter_state = (kind == AmbientEffectKind::Op7ParticlePool).then(|| { + let seed = ambient_effect_seed( + object_index, + component_index, + row.table_index, + row.row_index, + command_index, + &control_source, + ); + Op7EmitterState::new(seed, seed) + }); + slots.push(AmbientEffectGpuSlot { + kind, + time_mode: header.time_mode, + header_flags: header.flags, + random_instance_key, + random_seed, + phase_jitter: header.phase_jitter, + random_shift: header.random_shift, + owner_object_index: object_index, + owner_component_index: component_index, + owner_node_index, + owner_component_is_mounted: mount_pose.is_some(), + root_transform, + owner_scale: mission_root.scale, + mount_pose, + local_from_effect, + control_source: control_source.clone(), + control_table_index: row.table_index, + control_row_index: row.row_index, + control_point_indices: row.point_offsets, + control_point_owners: points.map(|point| point.raw_signed_field_04), + command: command.clone(), + command_index, + duration_seconds, + world_from_effect, + range, + additional_particle_ranges: particle_ranges, + material_animation_index, + additional_particle_material_animation_indices: particle_material_animation_indices, + smoke_emitter_state, + op7_emitter_state, + }); + } + } + Ok(()) +} + +fn selected_initial_control_effect_rows<'a>( + group_index: Option, + section: &'a PreparedControlEffectSection, +) -> Vec<&'a PreparedControlEffectRow> { + let (Some(group_index), PreparedControlEffectSection::Parsed(rows)) = (group_index, section) + else { + return Vec::new(); + }; + rows.iter() + .filter(|row| { + row.table_index == group_index && ambient_effect_row_unconditionally_active(row) + }) + .collect() +} + +fn ambient_effect_row_unconditionally_active(row: &PreparedControlEffectRow) -> bool { + // Control22C0 returns true when none of the predicate bits are set. The + // two row-chain bits have separate dispatch semantics and stay excluded. + row.raw_condition[0] & 0xF000_0000 == 0 +} + +fn ambient_effect_command_kind(command: &FxCommand) -> Option { + let body = command.raw_body.as_ref(); + let mode = body + .get(..4) + .map(|bytes| u32::from_le_bytes(bytes.try_into().expect("four byte FX body mode"))); + let body_c4 = body + .get(0xc4..0xc8) + .map(|bytes| u32::from_le_bytes(bytes.try_into().expect("four byte FX C4 field"))); + match (command.opcode, mode, body_c4) { + (FxOpcode::Op3, Some(0), _) if command.raw_body.len() >= 0xC4 => { + Some(AmbientEffectKind::Op3Type0) + } + (FxOpcode::Op3, Some(1), _) if command.raw_body.len() >= 0xC4 => { + Some(AmbientEffectKind::Op3Type1) + } + (FxOpcode::Op3, Some(2), _) => Some(AmbientEffectKind::Op3Type2), + (FxOpcode::Op9, Some(2), Some(0..=2)) => Some(AmbientEffectKind::GenerBall), + (FxOpcode::Op7, _, _) if ambient_effect_op7_range_capacity(command).is_some() => { + Some(AmbientEffectKind::Op7ParticlePool) + } + (FxOpcode::Op8, _, _) if ambient_effect_pool_capacity(command).is_some() => { + Some(AmbientEffectKind::SmokePool) + } + (FxOpcode::Op4, Some(0), Some(denominator_bits)) + if f32::from_bits(denominator_bits).is_finite() + && f32::from_bits(denominator_bits) > 0.0 => + { + Some(AmbientEffectKind::GenerBall) + } + _ => None, + } +} + +const CONTROL_COMPONENT_RENDER_SELECTOR: u32 = 1; + +fn ambient_effect_render_selector_allows_header(header_flags: u32, render_selector: u32) -> bool { + // Effect.dll 0x7D10 suppresses header flag 0x800 only for selector zero. + // The native AniMesh component path was observed calling the manager with + // selector one while its 0xD00 f_smalllight_y entries were present. + render_selector != 0 || header_flags & 0x800 == 0 +} + +fn ambient_effect_op1_supported(command: &FxCommand) -> bool { + if command.opcode != FxOpcode::Op1 { + return false; + } + let Some(effect_type) = command + .raw_body + .get(..4) + .and_then(|bytes| bytes.try_into().ok()) + .map(u32::from_le_bytes) + else { + return false; + }; + command.raw_body.len() >= 132 && matches!(effect_type, 1 | 2 | 5 | 6 | 7) +} + +fn ambient_effect_pool_capacity(command: &FxCommand) -> Option { + if command.opcode != FxOpcode::Op8 { + return None; + } + let pool_count = usize::try_from(u32::from_le_bytes( + command.raw_body.get(0x20..0x24)?.try_into().ok()?, + )) + .ok()?; + (1..=4096).contains(&pool_count).then_some(pool_count) +} + +fn ambient_effect_op7_range_capacity(command: &FxCommand) -> Option { + if command.opcode != FxOpcode::Op7 || command.raw_body.len() < 0xCC { + return None; + } + let read_u32 = |offset: usize| -> Option { + Some(u32::from_le_bytes( + command.raw_body.get(offset..offset + 4)?.try_into().ok()?, + )) + }; + let read_f32 = |offset| read_u32(offset).map(f32::from_bits); + // The evaluator uses the traced default world scalar of 10 for this + // Control path; the command's acceleration is supported when finite. + let material_phase_factor = read_f32(0x1C)?; + if !matches!(read_u32(0x00)?, 0 | 1) + || !material_phase_factor.is_finite() + || material_phase_factor == 0.0 + || !read_f32(0x88)?.is_finite() + { + return None; + } + let pool_count = usize::try_from(read_u32(0x20)?).ok()?; + let copies_per_group = usize::try_from(read_u32(0x24)?).ok()?; + let capacity = pool_count.checked_mul(copies_per_group)?; + (1..=4096).contains(&capacity).then_some(capacity) +} + +fn ambient_effect_seed( + object_index: usize, + component_index: usize, + table_index: usize, + row_index: usize, + command_index: usize, + source: &str, +) -> u32 { + let mut hash = 0x811C_9DC5_u32; + for byte in source.bytes().chain( + [ + object_index, + component_index, + table_index, + row_index, + command_index, + ] + .into_iter() + .flat_map(|value| (value as u64).to_le_bytes()), + ) { + hash = (hash ^ u32::from(byte)).wrapping_mul(0x0100_0193); + } + if hash == 0 { + 1 + } else { + hash + } +} + +fn control_effect_instance_seed( + object_index: usize, + component_index: usize, + table_index: usize, + row_index: usize, + source: &str, +) -> u32 { + ambient_effect_seed( + object_index, + component_index, + table_index, + row_index, + 0, + source, + ) +} + +fn advance_control_effect_random_states( + states: &mut HashMap, + ambient_slots: &[AmbientEffectGpuSlot], + point_light_slots: &[ControlPointLightSlot], + sample_id: SceneSampleId, + control_clock_ms: u32, + elapsed_seconds: f32, +) -> HashMap { + 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, + )); + } + for slot in point_light_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, + )); + } + + specifications + .into_iter() + .map( + |(key, (seed, flags, phase_jitter, random_shift, initial_phase))| { + let state = states.entry(key).or_insert_with(|| { + ControlEffectRandomState::new(seed, flags, phase_jitter, random_shift) + }); + ( + key, + state.advance(sample_id, control_clock_ms, initial_phase), + ) + }, + ) + .collect() +} + +fn ambient_effect_ranges(slot: &AmbientEffectGpuSlot) -> Vec<(&EnvironmentGpuRange, usize)> { + std::iter::once((&slot.range, slot.material_animation_index)) + .chain( + slot.additional_particle_ranges + .iter() + .zip(&slot.additional_particle_material_animation_indices) + .map(|(range, &binding)| (range, binding)), + ) + .collect() +} + +fn ambient_effect_quad_center(vertices: &[[f32; 3]; 4]) -> [f32; 3] { + std::array::from_fn(|axis| vertices.iter().map(|vertex| vertex[axis]).sum::() / 4.0) +} + +fn ambient_effect_quad_size(vertices: &[[f32; 3]; 4]) -> [f32; 2] { + let edge_length = |a: [f32; 3], b: [f32; 3]| { + ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt() + }; + [ + edge_length(vertices[0], vertices[1]), + edge_length(vertices[1], vertices[2]), + ] +} + +fn ambient_effect_projected_bounds( + vertices: &[[f32; 3]; 4], + clip_from_world: [f32; 16], + viewport: [u32; 2], +) -> Option<[f32; 4]> { + let mut points = [[0.0; 2]; 4]; + for (index, vertex) in vertices.iter().enumerate() { + let clip = [ + vertex[0] * clip_from_world[0] + + vertex[1] * clip_from_world[4] + + vertex[2] * clip_from_world[8] + + clip_from_world[12], + vertex[0] * clip_from_world[1] + + vertex[1] * clip_from_world[5] + + vertex[2] * clip_from_world[9] + + clip_from_world[13], + vertex[0] * clip_from_world[2] + + vertex[1] * clip_from_world[6] + + vertex[2] * clip_from_world[10] + + clip_from_world[14], + vertex[0] * clip_from_world[3] + + vertex[1] * clip_from_world[7] + + vertex[2] * clip_from_world[11] + + clip_from_world[15], + ]; + if !clip.iter().all(|value| value.is_finite()) || clip[3] <= f32::EPSILON { + return None; + } + points[index] = [ + (clip[0] / clip[3] + 1.0) * 0.5 * viewport[0] as f32, + (clip[1] / clip[3] + 1.0) * 0.5 * viewport[1] as f32, + ]; + } + Some([ + points + .iter() + .map(|point| point[0]) + .fold(f32::INFINITY, f32::min), + points + .iter() + .map(|point| point[1]) + .fold(f32::INFINITY, f32::min), + points + .iter() + .map(|point| point[0]) + .fold(f32::NEG_INFINITY, f32::max), + points + .iter() + .map(|point| point[1]) + .fold(f32::NEG_INFINITY, f32::max), + ]) +} + +fn ambient_effect_range_for( + slot: &AmbientEffectGpuSlot, + range_index: usize, +) -> Option<&EnvironmentGpuRange> { + std::iter::once(&slot.range) + .chain(slot.additional_particle_ranges.iter()) + .find(|range| range.range_index == range_index) +} + +fn ambient_effect_pipeline_state(category: u8) -> Option { + world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly) + .with_material_category(category) +} + +fn ambient_effect_tint_supported( + phase_native_flag_40: &[bool], + phase_directional_rgb: &[[f32; 3]], + phase_additive_rgb: &[[f32; 3]], +) -> bool { + !phase_native_flag_40.is_empty() + && phase_native_flag_40.len() == phase_directional_rgb.len() + && phase_native_flag_40.len() == phase_additive_rgb.len() + && phase_native_flag_40.iter().all(|flag| !flag) + && phase_directional_rgb + .iter() + .zip(phase_additive_rgb) + .all(|(directional, additive)| { + directional + .iter() + .chain(additive) + .all(|channel| channel.is_finite() && (0.0..=1.0).contains(channel)) + && (0..3).all(|channel| additive[channel] > 0.0 || directional[channel] == 0.0) + }) +} + +fn fixed_effect_field(field: &[u8; 32]) -> String { + let length = field + .iter() + .position(|byte| *byte == 0) + .unwrap_or(field.len()); + String::from_utf8_lossy(&field[..length]).trim().to_owned() +} + +fn update_ambient_effect_geometry( + slots: &mut [AmbientEffectGpuSlot], + component_pose_buffers: &[Vec>], + mesh: &mut VulkanStaticMesh, + material_animations: &mut [MaterialAnimationBinding], + elapsed_seconds: f32, + random_samples: &HashMap, + camera_world: [f32; 3], +) -> Result, String> { + let mut updates = Vec::with_capacity(slots.len()); + for slot in slots { + let random_sample = random_samples + .get(&slot.random_instance_key) + .copied() + .unwrap_or_default(); + let Some(world_from_effect) = ambient_effect_world_from_component_pose_with_random_shift( + slot, + component_pose_buffers, + random_sample.accumulated_shift, + ) else { + deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?; + 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 { + deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?; + continue; + }; + if slot.kind == AmbientEffectKind::GenerBall && slot.command.opcode == FxOpcode::Op9 { + let Some(frame) = evaluate_f_gener_ball_mesh( + &slot.command, + slot.command_index, + &slot.world_from_effect, + phase, + elapsed_seconds, + camera_world, + AMBIENT_EFFECT_NATIVE_CATEGORY, + ) else { + deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?; + continue; + }; + let primitives = ambient_effect_mesh_primitive_layout(&frame)?; + let ranges = ambient_effect_ranges(slot); + if ranges.len() != primitives.len() { + return Err(format!( + "ambient Op9 draw range count changed ({} != {})", + ranges.len(), + primitives.len() + )); + } + for (primitive, (range, material_animation_index)) in primitives.into_iter().zip(ranges) + { + let binding = material_animations + .get_mut(material_animation_index) + .ok_or_else(|| "ambient FX material binding is out of bounds".to_string())?; + binding.normalized_phase_override = Some(frame.material_phase); + write_ambient_effect_mesh_primitive(mesh, range, &frame, primitive)?; + updates.push(AmbientEffectGeometryUpdate { + range_index: range.range_index, + index_count: if frame.active && frame.color[3] > 0.0 { + u32::try_from(primitive.index_count) + .map_err(|_| "ambient Op9 index count exceeds u32".to_string())? + } else { + 0 + }, + command_rgb: [frame.color[0], frame.color[1], frame.color[2]], + }); + } + continue; + } + let frames = match (slot.kind, slot.command.opcode) { + (AmbientEffectKind::Op3Type0, FxOpcode::Op3) => evaluate_op3_type0_command( + &slot.command, + slot.command_index, + &slot.world_from_effect, + elapsed_seconds, + phase, + camera_world, + ) + .into_iter() + .collect(), + (AmbientEffectKind::Op7ParticlePool, FxOpcode::Op7) => { + let Some(state) = slot.op7_emitter_state.as_mut() else { + deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?; + continue; + }; + let Some(frames) = evaluate_op7_emitter( + &slot.command, + slot.command_index, + &slot.world_from_effect, + camera_world, + phase, + elapsed_seconds, + Op7RenderContext { + detail_level: 0, + // At detail level zero, [0,1) cutoffs with zero bias + // are all visible. The native quality preset remains + // unresolved, but no particle is culled here. + visibility: Op7VisibilityProfile { + width: 1.0, + bias: 0.0, + }, + // Terrain.dll initializes the native world's world + // scalar to 10.0. An engine integration can supply the + // live value after it exposes the world setting. + world_scalar: 10.0, + }, + state, + ) else { + deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?; + continue; + }; + fx_material_speed_diagnostic(slot, 0xAC, elapsed_seconds, phase, &frames); + frames + } + (AmbientEffectKind::SmokePool, FxOpcode::Op8) => { + let Some(state) = slot.smoke_emitter_state.as_mut() else { + deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?; + continue; + }; + let frames = evaluate_op8_emitter( + &slot.command, + slot.command_index, + &slot.world_from_effect, + camera_world, + elapsed_seconds, + phase, + 0, + Op8DetailThresholdRange::new(1.0, 1.0) + .expect("ordered finite Op8 material threshold"), + state, + ) + .unwrap_or_default(); + smoke_fr_02_diagnostic(slot, elapsed_seconds, phase, &frames); + fx_material_speed_diagnostic(slot, 0xD4, elapsed_seconds, phase, &frames); + frames + } + (AmbientEffectKind::Op3Type1, FxOpcode::Op3) => evaluate_op3_type1_command( + &slot.command, + slot.command_index, + &slot.world_from_effect, + elapsed_seconds, + phase, + camera_world, + ) + .into_iter() + .collect(), + (AmbientEffectKind::Op3Type2, FxOpcode::Op3) => evaluate_op3_type2_command( + &slot.command, + slot.command_index, + &slot.world_from_effect, + elapsed_seconds, + phase, + 0, + ) + .into_iter() + .collect(), + (AmbientEffectKind::GenerBall, FxOpcode::Op4) => evaluate_f_gener_ball( + &slot.command, + slot.command_index, + &slot.world_from_effect, + phase, + elapsed_seconds, + camera_world, + ) + .into_iter() + .collect(), + _ => Vec::new(), + }; + let ranges = ambient_effect_ranges(slot); + if frames.is_empty() { + deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?; + continue; + } + let variable_pool = slot.kind == AmbientEffectKind::Op7ParticlePool; + let aligned_frames = align_ambient_effect_frames(frames, ranges.len(), variable_pool)?; + for ((range, material_animation_index), frame) in ranges.into_iter().zip(aligned_frames) { + if let Some(frame) = frame { + let binding = material_animations + .get_mut(material_animation_index) + .ok_or_else(|| "ambient FX material binding is out of bounds".to_string())?; + binding.normalized_phase_override = Some(frame.material_phase); + write_ambient_effect_frame(mesh, range, &frame)?; + updates.push(AmbientEffectGeometryUpdate { + range_index: range.range_index, + index_count: if frame.active && frame.color[3] > 0.0 { + 6 + } else { + 0 + }, + command_rgb: [frame.color[0], frame.color[1], frame.color[2]], + }); + } else { + deactivate_ambient_effect_range( + range, + material_animation_index, + material_animations, + &mut updates, + )?; + } + } + } + Ok(updates) +} + +fn align_ambient_effect_frames( + frames: Vec, + range_count: usize, + variable_pool: bool, +) -> Result>, String> { + if (variable_pool && frames.len() > range_count) + || (!variable_pool && frames.len() != range_count) + { + return Err(format!( + "ambient FX frame pool has {} particles but {range_count} draw ranges", + frames.len() + )); + } + let active_count = frames.len(); + let mut aligned: Vec<_> = frames.into_iter().map(Some).collect(); + aligned.extend(std::iter::repeat_with(|| None).take(range_count - active_count)); + Ok(aligned) +} + +fn deactivate_ambient_effect_slot( + slot: &AmbientEffectGpuSlot, + material_animations: &mut [MaterialAnimationBinding], + updates: &mut Vec, +) -> Result<(), String> { + for (range, material_animation_index) in ambient_effect_ranges(slot) { + deactivate_ambient_effect_range( + range, + material_animation_index, + material_animations, + updates, + )?; + } + Ok(()) +} + +fn deactivate_ambient_effect_range( + range: &EnvironmentGpuRange, + material_animation_index: usize, + material_animations: &mut [MaterialAnimationBinding], + updates: &mut Vec, +) -> Result<(), String> { + let binding = material_animations + .get_mut(material_animation_index) + .ok_or_else(|| "ambient FX material binding is out of bounds".to_string())?; + binding.normalized_phase_override = Some(0.0); + updates.push(AmbientEffectGeometryUpdate { + range_index: range.range_index, + index_count: 0, + command_rgb: [0.0; 3], + }); + Ok(()) +} + +fn write_ambient_effect_frame( + mesh: &mut VulkanStaticMesh, + range: &EnvironmentGpuRange, + frame: &fparkan_fx::fx_visual::EffectQuadFrame, +) -> Result<(), String> { + let end = range + .vertex_start + .checked_add(range.vertex_capacity) + .ok_or_else(|| "ambient FX vertex range overflows".to_string())?; + let destination = mesh + .vertices + .get_mut(range.vertex_start..end) + .ok_or_else(|| "ambient FX vertex allocation is out of bounds".to_string())?; + if destination.len() != 4 { + return Err("ambient FX quad allocation changed after GPU preparation".to_string()); + } + for (index, target) in destination.iter_mut().enumerate() { + *target = environment_vertex_with_alpha( + frame.vertices[index], + [frame.color[0], frame.color[1], frame.color[2]], + frame.color[3].clamp(0.0, 1.0), + frame.uv[index], + ); + } + Ok(()) +} + +fn ambient_effect_mesh_primitive_layout( + frame: &fparkan_fx::fx_visual::EffectMeshFrame, +) -> Result, String> { + if frame.vertices.len() != frame.uv.len() || frame.indices.is_empty() { + return Err("ambient Op9 mesh has mismatched vertices/UVs or no indices".to_string()); + } + let quad_count = frame + .indices + .len() + .checked_sub(frame.vertices.len()) + .filter(|difference| difference.is_multiple_of(2)) + .map(|difference| difference / 2) + .ok_or_else(|| "ambient Op9 mesh is not a native quad/cap layout".to_string())?; + let quad_vertex_count = quad_count + .checked_mul(4) + .ok_or_else(|| "ambient Op9 quad vertex count overflows".to_string())?; + let quad_index_count = quad_count + .checked_mul(6) + .ok_or_else(|| "ambient Op9 quad index count overflows".to_string())?; + let cap_vertex_count = frame + .vertices + .len() + .checked_sub(quad_vertex_count) + .filter(|count| count.is_multiple_of(3)) + .ok_or_else(|| "ambient Op9 mesh has an invalid cap vertex count".to_string())?; + let cap_count = cap_vertex_count / 3; + let cap_index_count = cap_count + .checked_mul(3) + .ok_or_else(|| "ambient Op9 cap index count overflows".to_string())?; + if quad_index_count + cap_index_count != frame.indices.len() { + return Err("ambient Op9 quad and cap index counts do not match the frame".to_string()); + } + + // The native generator appends all quads first, then its triangle cap fan. + let mut primitives = Vec::with_capacity(quad_count + cap_count); + for quad in 0..quad_count { + let vertex_start = quad * 4; + let index_start = quad * 6; + let base = u16::try_from(vertex_start) + .map_err(|_| "ambient Op9 quad vertex exceeds u16".to_string())?; + let expected = [base, base + 1, base + 2, base + 2, base + 3, base]; + if frame.indices.get(index_start..index_start + expected.len()) != Some(&expected) { + return Err("ambient Op9 quad topology changed from native ordering".to_string()); + } + primitives.push(AmbientEffectMeshPrimitive { + vertex_start, + vertex_count: 4, + index_start, + index_count: 6, + }); + } + for cap in 0..cap_count { + let vertex_start = quad_vertex_count + cap * 3; + let index_start = quad_index_count + cap * 3; + let base = u16::try_from(vertex_start) + .map_err(|_| "ambient Op9 cap vertex exceeds u16".to_string())?; + let expected = [base, base + 1, base + 2]; + if frame.indices.get(index_start..index_start + expected.len()) != Some(&expected) { + return Err("ambient Op9 cap topology changed from native ordering".to_string()); + } + primitives.push(AmbientEffectMeshPrimitive { + vertex_start, + vertex_count: 3, + index_start, + index_count: 3, + }); + } + if primitives.is_empty() { + return Err("ambient Op9 mesh contains no native draw primitives".to_string()); + } + Ok(primitives) +} + +fn ambient_effect_mesh_primitive_vertices( + frame: &fparkan_fx::fx_visual::EffectMeshFrame, + primitive: AmbientEffectMeshPrimitive, +) -> Vec { + let end = primitive.vertex_start + primitive.vertex_count; + frame.vertices[primitive.vertex_start..end] + .iter() + .zip(&frame.uv[primitive.vertex_start..end]) + .map(|(position, uv)| { + environment_vertex_with_alpha( + *position, + [frame.color[0], frame.color[1], frame.color[2]], + frame.color[3].clamp(0.0, 1.0), + *uv, + ) + }) + .collect() +} + +fn ambient_effect_mesh_primitive_indices( + frame: &fparkan_fx::fx_visual::EffectMeshFrame, + primitive: AmbientEffectMeshPrimitive, +) -> Result, String> { + let index_end = primitive.index_start + primitive.index_count; + frame.indices[primitive.index_start..index_end] + .iter() + .map(|index| { + usize::from(*index) + .checked_sub(primitive.vertex_start) + .and_then(|local| u32::try_from(local).ok()) + .ok_or_else(|| { + "ambient Op9 primitive index is outside its vertex block".to_string() + }) + }) + .collect() +} + +fn write_ambient_effect_mesh_primitive( + mesh: &mut VulkanStaticMesh, + range: &EnvironmentGpuRange, + frame: &fparkan_fx::fx_visual::EffectMeshFrame, + primitive: AmbientEffectMeshPrimitive, +) -> Result<(), String> { + if range.vertex_capacity != primitive.vertex_count { + return Err("ambient Op9 primitive vertex allocation changed".to_string()); + } + let vertex_end = range + .vertex_start + .checked_add(range.vertex_capacity) + .ok_or_else(|| "ambient Op9 vertex range overflows".to_string())?; + let destination = mesh + .vertices + .get_mut(range.vertex_start..vertex_end) + .ok_or_else(|| "ambient Op9 vertex allocation is out of bounds".to_string())?; + let draw_range = mesh + .draw_ranges + .get(range.range_index) + .ok_or_else(|| "ambient Op9 draw range is out of bounds".to_string())?; + let index_start = usize::try_from(draw_range.first_index) + .map_err(|_| "ambient Op9 first index exceeds usize".to_string())?; + let index_end = index_start + .checked_add(primitive.index_count) + .ok_or_else(|| "ambient Op9 index range overflows".to_string())?; + let current_indices = mesh + .indices + .get(index_start..index_end) + .ok_or_else(|| "ambient Op9 index allocation is out of bounds".to_string())?; + let frame_indices = frame + .indices + .get(primitive.index_start..primitive.index_start + primitive.index_count) + .ok_or_else(|| "ambient Op9 source primitive index range is out of bounds".to_string())?; + for (current, local) in current_indices.iter().zip(frame_indices) { + let primitive_local = usize::from(*local) + .checked_sub(primitive.vertex_start) + .ok_or_else(|| "ambient Op9 source index leaves its primitive".to_string())?; + let expected = u32::try_from(range.vertex_start) + .ok() + .and_then(|base| base.checked_add(u32::try_from(primitive_local).ok()?)) + .ok_or_else(|| "ambient Op9 primitive index exceeds u32".to_string())?; + if *current != expected { + return Err("ambient Op9 primitive topology changed after GPU preparation".to_string()); + } + } + let frame_vertex_end = primitive + .vertex_start + .checked_add(primitive.vertex_count) + .ok_or_else(|| "ambient Op9 source primitive vertex range overflows".to_string())?; + let frame_vertices = frame + .vertices + .get(primitive.vertex_start..frame_vertex_end) + .ok_or_else(|| "ambient Op9 source primitive vertex range is out of bounds".to_string())?; + let frame_uvs = frame + .uv + .get(primitive.vertex_start..frame_vertex_end) + .ok_or_else(|| "ambient Op9 source primitive UV range is out of bounds".to_string())?; + for ((target, position), uv) in destination.iter_mut().zip(frame_vertices).zip(frame_uvs) { + *target = environment_vertex_with_alpha( + *position, + [frame.color[0], frame.color[1], frame.color[2]], + frame.color[3].clamp(0.0, 1.0), + *uv, + ); + } + Ok(()) +} + +fn ambient_effect_vertex_rgb(command_rgb: [f32; 3], material_additive_rgb: [f32; 3]) -> [f32; 3] { + std::array::from_fn(|channel| command_rgb[channel] * material_additive_rgb[channel]) +} + +fn apply_ambient_effect_material_tints( + slots: &[AmbientEffectGpuSlot], + mesh: &mut VulkanStaticMesh, + updates: &[AmbientEffectGeometryUpdate], + active_materials: &HashMap, +) -> Result<(), String> { + for update in updates.iter().filter(|update| update.index_count > 0) { + let range = slots + .iter() + .find_map(|slot| ambient_effect_range_for(slot, update.range_index)) + .ok_or_else(|| "ambient FX draw range has no owning slot".to_string())?; + let material = active_materials + .get(&update.range_index) + .ok_or_else(|| "ambient FX draw range has no active MAT0 phase".to_string())?; + let vertex_rgb = ambient_effect_vertex_rgb(update.command_rgb, material.additive_rgb); + let end = range + .vertex_start + .checked_add(range.vertex_capacity) + .ok_or_else(|| "ambient FX vertex range overflows".to_string())?; + let vertices = mesh + .vertices + .get_mut(range.vertex_start..end) + .ok_or_else(|| "ambient FX vertex allocation is out of bounds".to_string())?; + for vertex in vertices { + vertex.color = vertex_rgb; + } + } + Ok(()) +} + +fn ambient_effect_draw_sort_keys( + mesh: &VulkanStaticMesh, + slots: &[AmbientEffectGpuSlot], + camera_world: [f32; 3], +) -> Result, String> { + slots.iter().try_fold(Vec::new(), |mut keys, slot| { + for (range, _) in ambient_effect_ranges(slot) { + keys.push(( + range.range_index, + first_indexed_vertex_distance(mesh, range.range_index, camera_world)?, + )); + } + Ok(keys) + }) +} + +fn world_draw_range_indices( + draw_range_count: usize, + fixed_range_indices: &HashSet, +) -> Vec { + (0..draw_range_count) + .filter(|range_index| !fixed_range_indices.contains(range_index)) + .collect() +} + +// TEMPORARY diagnostic for ROADMAP.md A.4 (smoke_fr_02 speed). Enabled by +// FPARKAN_DIAG_SMOKE; identifies the emitter by its Op8 body (pool 30, +// interval 0.08) and reports spawn rate, particle lifetime and rise speed +// once per two seconds of effect clock. Remove after the investigation. +#[derive(Default)] +struct SmokeDiagnosticTrack { + window_start: Option, + last_born: f32, + spawns: usize, + calls: usize, + max_age: f32, + sum_age: f64, + sum_z: f64, + sum_age_z: f64, + sum_age_age: f64, + samples: usize, +} + +fn smoke_fr_02_diagnostic( + slot: &AmbientEffectGpuSlot, + clock: f32, + phase: f32, + frames: &[fparkan_fx::fx_visual::EffectQuadFrame], +) { + static ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); + static TRACKS: std::sync::OnceLock< + std::sync::Mutex>, + > = std::sync::OnceLock::new(); + if !*ENABLED.get_or_init(|| std::env::var_os("FPARKAN_DIAG_SMOKE").is_some()) { + return; + } + let body = slot.command.raw_body.as_ref(); + let read_u32 = |offset: usize| { + body.get(offset..offset + 4) + .map(|bytes| u32::from_le_bytes(bytes.try_into().expect("four bytes"))) + }; + if read_u32(0x20) != Some(30) || read_u32(0x14).map(f32::from_bits) != Some(0.08) { + return; + } + let key = ( + slot.owner_object_index, + slot.owner_component_index, + slot.control_table_index, + slot.control_row_index, + slot.command_index, + ); + let mut tracks = TRACKS + .get_or_init(|| std::sync::Mutex::new(HashMap::new())) + .lock() + .expect("smoke diagnostic lock"); + let slot_count = tracks.len() + usize::from(!tracks.contains_key(&key)); + let track = tracks.entry(key).or_default(); + let window_start = *track.window_start.get_or_insert(clock); + track.calls += 1; + for frame in frames.iter().filter(|frame| frame.active) { + let born = clock - frame.elapsed_seconds; + if born > track.last_born + 1e-4 { + track.spawns += 1; + track.last_born = track.last_born.max(born); + } + let z = frame.vertices.iter().map(|vertex| vertex[2]).sum::() * 0.25; + let age = f64::from(frame.elapsed_seconds); + track.max_age = track.max_age.max(frame.elapsed_seconds); + track.sum_age += age; + track.sum_z += f64::from(z); + track.sum_age_z += age * f64::from(z); + track.sum_age_age += age * age; + track.samples += 1; + } + let window = clock - window_start; + if window < 2.0 { + return; + } + let n = track.samples as f64; + let rise_per_second = if n > 1.0 { + (n * track.sum_age_z - track.sum_age * track.sum_z) + / (n * track.sum_age_age - track.sum_age * track.sum_age) + } else { + 0.0 + }; + let axis_lengths: [f32; 3] = std::array::from_fn(|column| { + (0..3) + .map(|row| slot.world_from_effect[row][column].powi(2)) + .sum::() + .sqrt() + }); + eprintln!( + "smoke_diag slots={slot_count} key={key:?} clock={clock:.3} window={window:.3}s calls={} calls_per_s={:.1} spawns={} spawns_per_s={:.2} active_now={} max_age={:.3}s rise_per_s={rise_per_second:.2} rise_per_life_2_4s={:.1} phase={phase:.3} emitter=[{:.2},{:.2},{:.2}] effect_axes={axis_lengths:.3?}", + track.calls, + track.calls as f32 / window, + track.spawns, + track.spawns as f32 / window, + frames.iter().filter(|frame| frame.active).count(), + track.max_age, + rise_per_second * 2.4, + slot.world_from_effect[0][3], + slot.world_from_effect[1][3], + slot.world_from_effect[2][3], + ); + *track = SmokeDiagnosticTrack { + window_start: Some(clock), + last_born: track.last_born, + ..SmokeDiagnosticTrack::default() + }; +} + +// TEMPORARY diagnostic for ROADMAP.md A.4 (animal "aura" speed). Enabled by +// FPARKAN_DIAG_FX_MATERIAL=, for example smoke_yg. Reports the +// effect-phase rate, call rate, particle lifetime and rise per particle life +// for every Op7/Op8 slot whose command uses that MAT0. Remove after the +// investigation. +#[derive(Default)] +struct FxSpeedDiagnosticTrack { + window_start: Option, + calls: usize, + phase_travel: f32, + last_phase: Option, + max_age: f32, + sum_t: f64, + sum_z: f64, + sum_tz: f64, + sum_tt: f64, + samples: usize, +} + +fn fx_material_speed_diagnostic( + slot: &AmbientEffectGpuSlot, + material_name_offset: usize, + clock: f32, + phase: f32, + frames: &[fparkan_fx::fx_visual::EffectQuadFrame], +) { + static WANTED: std::sync::OnceLock> = std::sync::OnceLock::new(); + static TRACKS: std::sync::OnceLock< + std::sync::Mutex>, + > = std::sync::OnceLock::new(); + let Some(wanted) = WANTED + .get_or_init(|| { + std::env::var("FPARKAN_DIAG_FX_MATERIAL") + .ok() + .map(|name| name.to_ascii_lowercase()) + }) + .as_deref() + else { + return; + }; + let body = slot.command.raw_body.as_ref(); + let Some(name) = body.get(material_name_offset..material_name_offset + 32) else { + return; + }; + let name = name.split(|byte| *byte == 0).next().unwrap_or_default(); + if !name.eq_ignore_ascii_case(wanted.as_bytes()) { + return; + } + let key = ( + slot.owner_object_index, + slot.owner_component_index, + slot.control_table_index, + slot.control_row_index, + slot.command_index, + ); + let mut tracks = TRACKS + .get_or_init(|| std::sync::Mutex::new(HashMap::new())) + .lock() + .expect("fx diagnostic lock"); + let slot_count = tracks.len() + usize::from(!tracks.contains_key(&key)); + let track = tracks.entry(key).or_default(); + let window_start = *track.window_start.get_or_insert(clock); + track.calls += 1; + if let Some(last_phase) = track.last_phase { + let delta = phase - last_phase; + track.phase_travel += if delta < -0.5 { delta + 1.0 } else { delta }; + } + track.last_phase = Some(phase); + for frame in frames.iter().filter(|frame| frame.active) { + // Op8 reports the particle age in seconds; Op7 reports its normalized + // particle age, so the regression is per second or per life. + let t = if slot.command.opcode == FxOpcode::Op8 { + frame.elapsed_seconds + } else { + frame.normalized_time + }; + let z = frame.vertices.iter().map(|vertex| vertex[2]).sum::() * 0.25; + track.max_age = track.max_age.max(t); + track.sum_t += f64::from(t); + track.sum_z += f64::from(z); + track.sum_tz += f64::from(t) * f64::from(z); + track.sum_tt += f64::from(t) * f64::from(t); + track.samples += 1; + } + let window = clock - window_start; + if window < 3.0 { + return; + } + let n = track.samples as f64; + let slope = if n > 1.0 { + (n * track.sum_tz - track.sum_t * track.sum_z) + / (n * track.sum_tt - track.sum_t * track.sum_t) + } else { + 0.0 + }; + eprintln!( + "fx_diag material={wanted} op={:?} slots={slot_count} key={key:?} clock={clock:.3} window={window:.3}s calls_per_s={:.1} phase_rate_per_s={:.4} active_now={} max_age={:.3} rise_slope={slope:.2} ({}) duration={:.2} mode={} owner=[{:.1},{:.1},{:.1}]", + slot.command.opcode, + track.calls as f32 / window, + track.phase_travel / window, + frames.iter().filter(|frame| frame.active).count(), + track.max_age, + if slot.command.opcode == FxOpcode::Op8 { "units/s, age in s" } else { "units/life, age normalized" }, + slot.duration_seconds, + slot.time_mode, + slot.world_from_effect[0][3], + slot.world_from_effect[1][3], + slot.world_from_effect[2][3], + ); + *track = FxSpeedDiagnosticTrack { + window_start: Some(clock), + last_phase: track.last_phase, + ..FxSpeedDiagnosticTrack::default() + }; +} + +fn ambient_effect_phase( + time_mode: u32, + header_flags: u32, + elapsed_seconds: f32, + duration_seconds: f32, +) -> Option { + if !elapsed_seconds.is_finite() + || elapsed_seconds < 0.0 + || !duration_seconds.is_finite() + || duration_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) +} + 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]); @@ -4443,163 +6998,890 @@ fn initial_control_gate_values(control: &PreparedComponentControl) -> Vec