diff options
| author | Valentin Popov <valentin@popov.link> | 2026-10-11 12:12:50 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-10-11 12:12:50 +0300 |
| commit | a8faba8aad441e87f8a8b94c148c01f9c075d621 (patch) | |
| tree | 97aa4972990edba9991ff2e125edac3a7883f50f /apps/fparkan-game/src/main.rs | |
| parent | aa51f3574d3d4d4e0fa59653eedd718fceb5d1bd (diff) | |
| download | fparkan-a8faba8aad441e87f8a8b94c148c01f9c075d621.tar.xz fparkan-a8faba8aad441e87f8a8b94c148c01f9c075d621.zip | |
feat: complete map viewer scene and static CTL pose preview
Complete the interactive mission viewer with environment rendering, audio events, dynamic shadows, free-flight camera controls, and per-component CTL pose sampling.
Diffstat (limited to 'apps/fparkan-game/src/main.rs')
| -rw-r--r-- | apps/fparkan-game/src/main.rs | 5822 |
1 files changed, 5589 insertions, 233 deletions
diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index 2130bf5..7405458 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -2,40 +2,75 @@ //! `FParkan` render-planning composition root. +use fparkan_animation::{AnimationTime, NodePoseBuffer}; use fparkan_assets::{ - load_standalone_wear_material_texture_mip0_rgba8_from_root, PreparedTextureUsage, - PreparedVisual, + 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_material_phase, Mat0Document, MaterialPhase, MaterialPhaseSample, + PreparedControlNodeBinding, PreparedMaterialPhase, PreparedTexture, PreparedTextureUsage, + PreparedVisual, RgbaImage, StandaloneWearMaterialLoader, +}; +use fparkan_fx::atmosphere::TypedAtmosphere; +use fparkan_fx::environment::{ + decode_env_lightning_fxid, Camera as FxCamera, EnvironmentFrame, EnvironmentPrimitive, + EnvironmentSystem, PrecipitationKind, ScreenBillboard, +}; +use fparkan_fx::shadow::{ + ShadowCamera, ShadowCaster, ShadowFrame, ShadowLight, ShadowLodSettings, ShadowPageCache, + ShadowScene, ShadowSphere, ShadowTriangle, SHADOW_PRIMARY_DIRECTIONAL_FLAG, +}; +use fparkan_fx::sky::{ + sprite_half_size_pixels, SkyFrame, SkyMaterials, SkyMesh, SkyPassKind, SkySpriteFrame, + SkySystem, SunVisibility, SKY_FLARE_ROWS, +}; +use fparkan_msh::{ + node38_fallback_hierarchy, node38_sampled_hierarchy, node38_sampled_hierarchy_at_times, + ModelAsset, }; use fparkan_path::ResourceName; use fparkan_platform_winit::{window_native_handles, WinitWindow, WinitWindowPlan}; -use fparkan_render::{LegacyD3d7Projection, LegacyIron3dEulerTransform, RawCameraTransform}; +use fparkan_prototype::{unit_component_tree, PROTOTYPE_TYPE_EXTO}; +use fparkan_render::{ + LegacyBlendMode, LegacyD3d7Projection, LegacyDepthMode, LegacyIron3dEulerTransform, + LegacyPipelineState, RawCameraTransform, +}; use fparkan_render_vulkan::{ + node38_pose, node38_pose_from_hierarchy, node38_pose_relative_to_root, + node38_pose_relative_to_root_from_hierarchy, project_land_msh_to_static_mesh_in_legacy_world_space, - project_land_msh_to_static_mesh_in_xy_frame, project_msh_to_static_mesh_in_world_space_with_node_fallback_poses, + project_msh_to_static_mesh_in_world_space_with_node_fallback_poses_and_mount, + project_msh_to_static_mesh_in_world_space_with_node_pose_buffer, + 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_xy_frame_with_node_fallback_poses, - project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses, VulkanSmokeFrameOutcome, - VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanStaticCamera, VulkanStaticMaterial, - VulkanStaticMesh, VulkanStaticTexture, VulkanStaticXyFrame, + 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, }; use fparkan_runtime::{ create, load_mission_static_preview, load_mission_static_preview_roots, loaded_mission_assets, loaded_mission_object_drafts, loaded_terrain, EngineServices, MissionAssets, MissionObjectDraft, MissionRequest, }; -use fparkan_terrain::TerrainMaterialLayers; -use fparkan_terrain::TerrainWorld; +use fparkan_terrain::{FullSurfaceMask, SurfaceQuery, TerrainMaterialLayers, TerrainWorld}; use fparkan_vfs::DirectoryVfs; use serde::Deserialize; +use std::collections::{HashMap, HashSet}; use std::num::NonZeroUsize; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::time::{Duration, Instant}; use winit::application::ApplicationHandler; use winit::dpi::PhysicalSize as WinitPhysicalSize; -use winit::event::WindowEvent; +use winit::event::{DeviceEvent, DeviceId, ElementState, MouseButton, WindowEvent}; use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; -use winit::window::{Window, WindowId}; +use winit::keyboard::{KeyCode, PhysicalKey}; +use winit::window::{CursorGrabMode, Window, WindowId}; + +mod audio; fn main() { let raw_args = std::env::args().skip(1).collect::<Vec<_>>(); @@ -56,7 +91,9 @@ fn run(args: &[String]) -> Result<String, String> { let args = Args::parse(args)?; let services = EngineServices::new(Arc::new(DirectoryVfs::new(&args.root))); let mut engine = create(services).map_err(|err| err.to_string())?; + let mission_assets_started = Instant::now(); let loaded = load_requested_mission(&mut engine, &args)?; + let mission_assets_duration = mission_assets_started.elapsed(); let mission_assets = loaded_mission_assets(&engine) .ok_or_else(|| "mission assets are unavailable after loading".to_string())?; @@ -73,6 +110,7 @@ fn run(args: &[String]) -> Result<String, String> { .as_deref() .map(load_legacy_camera_capture) .transpose()?; + let terrain_materials_duration_started = Instant::now(); let preview = static_preview_mesh_and_materials( mission_assets, terrain, @@ -83,13 +121,38 @@ fn run(args: &[String]) -> Result<String, String> { &args.root, &loaded.land_msh_path, )?; + let terrain_materials_duration = terrain_materials_duration_started.elapsed(); + // Atmosphere files are mission-local assets. Resolving them from the + // entire install tree would silently attach the first unrelated mission's + // sky to the selected map (there are many `sky.ske` files in the campaign + // tree). + let sky_started = Instant::now(); + let environment = load_environment(&args.root, &args.mission, args.atmosphere_seconds)?; + let sky_duration = sky_started.elapsed(); + let audio = match audio::GameAudio::new(Arc::new(DirectoryVfs::new(&args.root)), &args.mission) + { + Ok(audio) => Some(audio), + Err(audio::AudioError::DeviceUnavailable { message }) => { + eprintln!("audio output unavailable; continuing without sound: {message}"); + None + } + Err(error) => return Err(format!("load mission audio: {error}")), + }; run_static_vulkan_mode( preview, + environment, + audio, args.frames, &args.mission, loaded.object_count, args.readback_out.as_deref(), args.validation, + StartupTimings { + mission_assets: mission_assets_duration, + terrain_materials: terrain_materials_duration, + sky: sky_duration, + gpu_initialization: Duration::ZERO, + }, ) } @@ -108,25 +171,3349 @@ fn load_requested_mission( .map_err(|err| err.to_string()) } -/// Static geometry and descriptors belonging to the selected preview roots. +/// Camera state used by the interactive world-space preview. +#[derive(Clone, Copy, Debug, PartialEq)] +struct FreeFlightCamera { + position: [f32; 3], + yaw: f32, + pitch: f32, + vertical_fov: f32, + near_plane: f32, + far_plane: f32, + move_speed: f32, +} + +impl FreeFlightCamera { + fn from_mesh(mesh: &VulkanStaticMesh) -> Result<Self, String> { + let mut min = [f32::INFINITY; 3]; + let mut max = [f32::NEG_INFINITY; 3]; + for vertex in &mesh.vertices { + for axis in 0..3 { + let value = vertex.position[axis]; + if !value.is_finite() { + return Err("world preview contains a non-finite vertex".to_string()); + } + min[axis] = min[axis].min(value); + max[axis] = max[axis].max(value); + } + } + if !min.iter().chain(max.iter()).all(|value| value.is_finite()) { + return Err("world preview has no finite bounds".to_string()); + } + Self::from_bounds(min, max) + } + + fn from_bounds(min: [f32; 3], max: [f32; 3]) -> Result<Self, String> { + if !min.iter().chain(max.iter()).all(|value| value.is_finite()) { + return Err("free-flight bounds must be finite".to_string()); + } + let center = [ + (min[0] + max[0]) * 0.5, + (min[1] + max[1]) * 0.5, + (min[2] + max[2]) * 0.5, + ]; + let radius = (0..3) + .map(|axis| (max[axis] - min[axis]).abs()) + .fold(1.0_f32, f32::max); + if !radius.is_finite() || radius <= f32::EPSILON { + return Err("free-flight bounds are degenerate".to_string()); + } + let position = [ + center[0], + center[1] - radius * 2.5, + center[2] + radius * 1.25, + ]; + let direction = sub3(center, position); + let distance = length3(direction) + .ok_or_else(|| "free-flight initial camera has no direction to the map".to_string())?; + let yaw = direction[1].atan2(direction[0]); + let pitch = (direction[2] / distance).clamp(-1.0, 1.0).asin(); + Ok(Self { + position, + yaw, + pitch, + vertical_fov: std::f32::consts::FRAC_PI_3, + near_plane: (radius * 0.01).clamp(0.05, 0.1), + far_plane: (radius * 32.0).max(100.0), + // Keep the default traversal speed comfortable on a full mission + // map; Shift/Ctrl still provide the coarse speed controls. + move_speed: (radius * 0.03).clamp(4.0, 40.0), + }) + } + + fn from_mesh_and_terrain( + mesh: &VulkanStaticMesh, + terrain: &TerrainWorld, + ) -> Result<Self, String> { + let mut camera = Self::from_mesh(mesh)?; + let positions = terrain + .source_positions() + .ok_or_else(|| "free-flight terrain has no source positions".to_string())?; + let mut min = [f32::INFINITY; 3]; + let mut max = [f32::NEG_INFINITY; 3]; + for position in positions { + if !position.iter().all(|value| value.is_finite()) { + return Err("free-flight terrain contains a non-finite position".to_string()); + } + for axis in 0..3 { + min[axis] = min[axis].min(position[axis]); + max[axis] = max[axis].max(position[axis]); + } + } + if !min.iter().chain(max.iter()).all(|value| value.is_finite()) { + return Err("free-flight terrain has no finite bounds".to_string()); + } + let extent = (max[0] - min[0]).max(max[1] - min[1]); + if !extent.is_finite() || extent <= f32::EPSILON { + return Err("free-flight terrain has degenerate XY bounds".to_string()); + } + let center = [(min[0] + max[0]) * 0.5, (min[1] + max[1]) * 0.5]; + let offset = extent * 0.18; + let requested_spawn = [ + (center[0] - offset).clamp(min[0], max[0]), + (center[1] - offset).clamp(min[1], max[1]), + ]; + let sample_offsets = [ + [0.0, 0.0], + [0.18, 0.18], + [-0.18, 0.18], + [0.18, -0.18], + [-0.18, -0.18], + [0.0, 0.18], + [0.18, 0.0], + [-0.18, 0.0], + [0.0, -0.18], + ]; + let height_world = |xy: [f32; 2]| { + terrain + .height_at(xy) + .ok() + .flatten() + .filter(|height| height.is_finite()) + .map(|height| height / 32.0) + }; + let (spawn_xy, spawn_ground) = sample_offsets + .into_iter() + .map(|sample| { + [ + (requested_spawn[0] + sample[0] * extent).clamp(min[0], max[0]), + (requested_spawn[1] + sample[1] * extent).clamp(min[1], max[1]), + ] + }) + .find_map(|xy| height_world(xy).map(|height| (xy, height))) + .ok_or_else(|| "free-flight terrain has no queryable spawn surface".to_string())?; + let target_ground = height_world(center).unwrap_or(spawn_ground); + let clearance = (extent * 0.005).clamp(2.0, 8.0); + camera.position = [spawn_xy[0], spawn_xy[1], spawn_ground + clearance]; + let target = [center[0], center[1], target_ground + 1.5]; + let direction = sub3(target, camera.position); + let distance = length3(direction) + .ok_or_else(|| "free-flight terrain spawn has no view direction".to_string())?; + camera.yaw = direction[1].atan2(direction[0]); + camera.pitch = (direction[2] / distance).clamp(-1.0, 1.0).asin(); + Ok(camera) + } + + fn forward(self) -> [f32; 3] { + let (sin_yaw, cos_yaw) = self.yaw.sin_cos(); + let (sin_pitch, cos_pitch) = self.pitch.sin_cos(); + [cos_pitch * cos_yaw, cos_pitch * sin_yaw, sin_pitch] + } + + fn right(self) -> [f32; 3] { + normalize3(cross3(self.forward(), [0.0, 0.0, 1.0])).unwrap_or([1.0, 0.0, 0.0]) + } + + fn up(self) -> [f32; 3] { + normalize3(cross3(self.right(), self.forward())).unwrap_or([0.0, 0.0, 1.0]) + } + + fn look_delta(&mut self, dx: f64, dy: f64) { + const SENSITIVITY: f32 = 0.0025; + let dx = dx as f32; + let dy = dy as f32; + if dx.is_finite() && dy.is_finite() { + // Moving the mouse right turns the view to the right. + self.yaw -= dx * SENSITIVITY; + self.pitch = (self.pitch - dy * SENSITIVITY).clamp(-1.5, 1.5); + } + } + + fn advance(&mut self, keys: &HashSet<KeyCode>, seconds: f32) { + if !seconds.is_finite() || seconds <= 0.0 { + return; + } + let mut movement = [0.0; 3]; + let forward = self.forward(); + let right = self.right(); + if keys.contains(&KeyCode::KeyW) { + add3_in_place(&mut movement, forward); + } + if keys.contains(&KeyCode::KeyS) { + add3_in_place(&mut movement, scale3(forward, -1.0)); + } + if keys.contains(&KeyCode::KeyD) { + add3_in_place(&mut movement, right); + } + if keys.contains(&KeyCode::KeyA) { + add3_in_place(&mut movement, scale3(right, -1.0)); + } + if keys.contains(&KeyCode::KeyE) { + add3_in_place(&mut movement, [0.0, 0.0, 1.0]); + } + if keys.contains(&KeyCode::KeyQ) { + add3_in_place(&mut movement, [0.0, 0.0, -1.0]); + } + let Some(direction) = normalize3(movement) else { + return; + }; + let mut speed = self.move_speed; + if keys.contains(&KeyCode::ShiftLeft) || keys.contains(&KeyCode::ShiftRight) { + speed *= 4.0; + } + if keys.contains(&KeyCode::ControlLeft) || keys.contains(&KeyCode::ControlRight) { + speed *= 0.25; + } + self.position = add3(self.position, scale3(direction, speed * seconds)); + } + + fn vulkan_camera(self, aspect: f32) -> VulkanStaticCamera { + let aspect = if aspect.is_finite() && aspect > f32::EPSILON { + aspect + } else { + 16.0 / 9.0 + }; + let forward = self.forward(); + let right = self.right(); + let up = self.up(); + // VulkanStaticCamera carries row-major D3D-style data. GLSL interprets + // those bytes as a column-major mat4, so shader multiplication is the + // transpose of this row-vector transform: world * V * P. The camera + // basis is right-handed with +Z-up world coordinates and +Z depth. + let view = [ + right[0], + up[0], + forward[0], + 0.0, + right[1], + up[1], + forward[1], + 0.0, + right[2], + up[2], + forward[2], + 0.0, + -dot3(right, self.position), + -dot3(up, self.position), + -dot3(forward, self.position), + 1.0, + ]; + let half_fov = (self.vertical_fov * 0.5).clamp(0.05, 1.5); + let focal = half_fov.tan().recip(); + let near_plane = self.near_plane.max(0.001); + let far_plane = self.far_plane.max(near_plane + 0.001); + let projection = [ + focal / aspect, + 0.0, + 0.0, + 0.0, + 0.0, + -focal, + 0.0, + 0.0, + 0.0, + 0.0, + far_plane / (far_plane - near_plane), + 1.0, + 0.0, + 0.0, + -(near_plane * far_plane) / (far_plane - near_plane), + 0.0, + ]; + VulkanStaticCamera { + clip_from_world: multiply_row_major(view, projection), + } + } +} + +const ENVIRONMENT_SPRITE_ROWS: [usize; 2] = [3, 4]; +const ENVIRONMENT_FLARE_SLOTS: usize = 12; +const ENVIRONMENT_PARTICLE_SLOTS: usize = 1_000; + +#[derive(Clone, Copy, Debug)] +struct EnvironmentGpuRange { + range_index: usize, + vertex_start: usize, + vertex_capacity: usize, + material_index: usize, +} + +#[derive(Clone, Debug)] +struct EnvironmentMaterialAsset { + texture: VulkanStaticTexture, + phase: PreparedMaterialPhase, + document: Mat0Document, + phase_textures: Vec<VulkanStaticTexture>, + phases: Vec<PreparedMaterialPhase>, +} + +/// One source MAT0 document retained for frame-time sampling. The renderer +/// receives the selected coefficients through its dynamic material state; +/// keeping this binding beside the prepared scene avoids rebuilding assets or +/// reopening archives on every redraw. +#[derive(Clone, Debug)] +struct MaterialPhaseBinding { + document: Mat0Document, + animation_block_index: usize, + wear_row_start_ms: u32, + random_state: u32, + frozen_phase_index: Option<usize>, + phase_uv_transforms: Vec<[f32; 4]>, +} + +/// Texture phases for the material stages that do not select the draw +/// variant. Terrain MAT0 rows are independent: Land1 base, Land2 detail, +/// Land1 overlay, and Land2 overlay-detail can each have their own animation +/// timeline. Keeping these handles beside the binding lets the renderer +/// update the three descriptor images in place without materialising every +/// Cartesian product of phase indices. +#[derive(Clone, Debug)] +struct MaterialStageTextures { + detail: Option<Vec<VulkanStaticTexture>>, + overlay: Option<Vec<VulkanStaticTexture>>, + overlay_detail: Option<Vec<VulkanStaticTexture>>, + applied_material_phases: HashMap<usize, [usize; 3]>, +} + +impl MaterialPhaseBinding { + fn sample( + &mut self, + material_index: usize, + clock_ms: u32, + ) -> Result<MaterialPhaseSample, String> { + if let Some(phase_index) = self.frozen_phase_index { + let phase_index = phase_index.min(self.document.phases.len().saturating_sub(1)); + let phase = self.document.phases.get(phase_index).ok_or_else(|| { + format!( + "sample MAT0 material {material_index} at clock {clock_ms}ms: no material phases" + ) + })?; + return Ok(MaterialPhaseSample { + phase_index, + coefficients: phase.coefficients(), + texture_raw: phase.texture_raw, + }); + } + + // GetPhase's random mode asks the CRT generator for one fresh value + // per query. Animated loop/ping-pong/clamp modes do not consume the + // RNG and receive the sampler's ignored value. + let block = self + .document + .animation_blocks + .get(self.animation_block_index) + .or_else(|| self.document.animation_blocks.first()); + let random_value = if block.is_some_and(|block| block.header_raw & 7 == 3) { + self.random_state = self + .random_state + .wrapping_mul(214_013) + .wrapping_add(2_531_011); + (self.random_state >> 16) & 0x7fff + } else { + 0 + }; + sample_material_phase( + &self.document, + self.animation_block_index, + clock_ms, + self.wear_row_start_ms, + random_value, + ) + .map_err(|error| { + format!( + "sample MAT0 material {} at clock {clock_ms}ms: {error}", + material_index + ) + }) + } + + fn page_uv_transform(&self, phase_index: usize) -> [f32; 4] { + self.phase_uv_transforms + .get(phase_index) + .copied() + .or_else(|| self.phase_uv_transforms.first().copied()) + .unwrap_or([0.0, 0.0, 1.0, 1.0]) + } + + fn checked_page_uv_transform(&self, phase_index: usize) -> Result<[f32; 4], String> { + self.phase_uv_transforms + .get(phase_index) + .copied() + .ok_or_else(|| { + format!( + "MAT0 Page transform is missing phase {phase_index} of {}", + self.phase_uv_transforms.len() + ) + }) + } +} + +#[derive(Clone, Debug)] +struct MaterialVariantSet { + phase_material_indices: Vec<usize>, + range_indices: Vec<usize>, +} + +#[derive(Clone, Debug)] +struct MaterialAnimationBinding { + initial_phase_index: usize, + variants: Vec<MaterialVariantSet>, + base: MaterialPhaseBinding, + detail: Option<MaterialPhaseBinding>, + overlay: Option<MaterialPhaseBinding>, + overlay_detail: Option<MaterialPhaseBinding>, + stage_textures: Option<MaterialStageTextures>, +} + +#[derive(Clone, Copy, Debug)] +struct ActiveMaterialState { + material_index: usize, + diffuse_alpha: f32, +} + +fn update_material_animations( + bindings: &mut [MaterialAnimationBinding], + materials: &[VulkanStaticMaterial], + renderer: &mut VulkanSmokeRenderer, + clock_ms: u32, +) -> Result<HashMap<usize, ActiveMaterialState>, String> { + // Keep the resolved selector beside the draw range for this frame. The + // renderer's material arrays are keyed by selector, so any later + // environment pass that writes alpha or lighting must target the phase + // variant selected above rather than the material that happened to be + // active when the range was allocated. + let mut active_materials = HashMap::new(); + for binding in bindings { + let base_sample = binding.base.sample(binding.initial_phase_index, clock_ms)?; + let detail_sample = binding + .detail + .as_mut() + .map(|detail| detail.sample(binding.initial_phase_index, clock_ms)) + .transpose()?; + let overlay_sample = binding + .overlay + .as_mut() + .map(|overlay| overlay.sample(binding.initial_phase_index, clock_ms)) + .transpose()?; + let overlay_detail_sample = binding + .overlay_detail + .as_mut() + .map(|overlay_detail| overlay_detail.sample(binding.initial_phase_index, clock_ms)) + .transpose()?; + let mut updated_materials = HashSet::new(); + for variant in &binding.variants { + let material_index = selected_material_variant_index(variant, base_sample.phase_index) + .ok_or_else(|| "MAT0 material animation has no texture variant".to_string())?; + for &range_index in &variant.range_indices { + active_materials.insert( + range_index, + ActiveMaterialState { + material_index, + diffuse_alpha: base_sample.coefficients.opacity, + }, + ); + renderer + .set_draw_range_material(range_index, material_index) + .map_err(|error| format!("update MAT0 material draw selector: {error}"))?; + } + if !updated_materials.insert(material_index) { + continue; + } + let material = materials.get(material_index).ok_or_else(|| { + format!("MAT0 material animation selector {material_index} is out of bounds") + })?; + let base_coefficients = base_sample.coefficients; + let overlay_coefficients = overlay_sample.map(|sample| sample.coefficients); + 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. + ([0.0; 3], base_coefficients.directional_rgb, None, None) + } else { + ( + base_coefficients.directional_rgb, + base_coefficients.additive_rgb, + overlay_coefficients.map(|coefficients| coefficients.directional_rgb), + overlay_coefficients.map(|coefficients| coefficients.additive_rgb), + ) + }; + renderer + .set_material_alphas( + material_index, + base_coefficients.opacity.clamp(0.0, 1.0), + overlay_coefficients + .map_or(0.0, |coefficients| coefficients.opacity.clamp(0.0, 1.0)), + ) + .map_err(|error| format!("update MAT0 material alpha: {error}"))?; + renderer + .set_material_lighting_with_overlay( + material_index, + base_directional, + base_additive, + overlay_directional, + overlay_additive, + ) + .map_err(|error| format!("update MAT0 material lighting: {error}"))?; + renderer + .set_material_specular( + material_index, + base_coefficients.specular_rgb, + base_coefficients.power, + overlay_coefficients.map(|coefficients| coefficients.specular_rgb), + overlay_coefficients.map(|coefficients| coefficients.power), + ) + .map_err(|error| format!("update MAT0 material specular: {error}"))?; + + // Detail and overlay textures have independent MAT0 phase + // selectors. Their descriptor images are updated only when a + // selected base material sees a new stage tuple; this keeps the + // steady-state frame path from idling Vulkan for every draw. + if !material.lightmap_mode { + if let Some(stage_textures) = binding.stage_textures.as_mut() { + 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) + { + let detail_texture = stage_textures + .detail + .as_ref() + .and_then(|textures| textures.get(stage_phases[0])) + .cloned(); + let overlay_texture = stage_textures + .overlay + .as_ref() + .and_then(|textures| textures.get(stage_phases[1])) + .cloned(); + let overlay_detail_texture = stage_textures + .overlay_detail + .as_ref() + .and_then(|textures| textures.get(stage_phases[2])) + .cloned(); + if let Some(texture) = detail_texture.as_ref() { + renderer + .update_material_texture(material_index, 1, texture) + .map_err(|error| format!("update MAT0 detail texture: {error}"))?; + } + if let Some(texture) = overlay_texture.as_ref() { + renderer + .update_material_texture(material_index, 2, texture) + .map_err(|error| format!("update MAT0 overlay texture: {error}"))?; + } + if let Some(texture) = overlay_detail_texture.as_ref() { + renderer + .update_material_texture(material_index, 3, texture) + .map_err(|error| { + format!("update MAT0 overlay detail texture: {error}") + })?; + } + stage_textures + .applied_material_phases + .insert(material_index, stage_phases); + } + } + } + let overlay_uv = overlay_sample + .map(|sample| { + binding + .overlay + .as_ref() + .map_or([0.0, 0.0, 1.0, 1.0], |overlay| { + overlay.page_uv_transform(sample.phase_index) + }) + }) + .or(material.overlay_uv_transform) + .unwrap_or([0.0, 0.0, 1.0, 1.0]); + let detail_uv = if material.lightmap_mode { + material.detail_uv_transform + } else { + detail_sample + .map(|sample| { + binding + .detail + .as_ref() + .map_or(material.detail_uv_transform, |detail| { + detail.page_uv_transform(sample.phase_index) + }) + }) + .or(Some(material.detail_uv_transform)) + .unwrap_or([0.0, 0.0, 1.0, 1.0]) + }; + let overlay_detail_uv = if material.lightmap_mode { + material + .overlay_detail_uv_transform + .unwrap_or([0.0, 0.0, 1.0, 1.0]) + } else { + overlay_detail_sample + .map(|sample| { + binding.overlay_detail.as_ref().map_or( + material + .overlay_detail_uv_transform + .unwrap_or([0.0, 0.0, 1.0, 1.0]), + |overlay_detail| overlay_detail.page_uv_transform(sample.phase_index), + ) + }) + .or(material.overlay_detail_uv_transform) + .unwrap_or([0.0, 0.0, 1.0, 1.0]) + }; + let base_uv = binding + .base + .checked_page_uv_transform(base_sample.phase_index)?; + renderer + .set_material_uv_transforms( + material_index, + [base_uv, detail_uv, overlay_uv, overlay_detail_uv], + ) + .map_err(|error| format!("update MAT0 material Page transform: {error}"))?; + } + } + Ok(active_materials) +} + +fn native_world_transparent(range: &VulkanStaticDrawRange, diffuse_alpha: f32) -> bool { + diffuse_alpha < 1.0 || (range.batch_flags & 0x108) != 0 +} + +fn register_material_range( + bindings: &mut [MaterialAnimationBinding], + material_index: usize, + range_index: usize, +) { + for binding in bindings { + if let Some(variant) = binding.variants.iter_mut().find(|variant| { + variant + .phase_material_indices + .get(binding.initial_phase_index) + .copied() + == Some(material_index) + }) { + variant.range_indices.push(range_index); + return; + } + } +} + +fn find_material_animation_variant( + bindings: &[MaterialAnimationBinding], + material_index: usize, +) -> Option<(usize, Vec<usize>)> { + bindings + .iter() + .enumerate() + .find_map(|(binding_index, binding)| { + binding.variants.iter().find_map(|variant| { + (variant + .phase_material_indices + .get(binding.initial_phase_index) + .copied() + == Some(material_index)) + .then(|| (binding_index, variant.phase_material_indices.clone())) + }) + }) +} + +fn selected_material_variant_index( + variant: &MaterialVariantSet, + phase_index: usize, +) -> Option<usize> { + variant.phase_material_indices.get(phase_index).copied() +} + +fn remap_material_animation_ranges( + bindings: &mut [MaterialAnimationBinding], + remap: &[usize], +) -> Result<(), String> { + for binding in bindings { + for variant in &mut binding.variants { + for range_index in &mut variant.range_indices { + *range_index = *remap + .get(*range_index) + .ok_or_else(|| "MAT0 draw range remap is out of bounds".to_string())?; + } + } + } + Ok(()) +} + +fn phase_texture_name(phase: &MaterialPhase) -> Option<ResourceName> { + let length = phase + .texture_raw + .iter() + .position(|byte| *byte == 0) + .unwrap_or(phase.texture_raw.len()); + (length != 0).then(|| ResourceName(phase.texture_raw[..length].to_vec())) +} + +fn phase_uv_transforms_from_assets( + document: &Mat0Document, + assets: &MissionAssets, +) -> Vec<[f32; 4]> { + document + .phases + .iter() + .map(|phase| { + phase_texture_name(phase) + .and_then(|name| { + assets + .textures + .iter() + .find(|texture| { + texture.usage == PreparedTextureUsage::Diffuse + && texture.source.name == name + }) + .and_then(|texture| { + page_uv_transform( + texture.texm.width(), + texture.texm.height(), + &texture.texm.page_rects(), + i8::from_ne_bytes([phase.parameters[17]]), + ) + .ok() + }) + }) + .unwrap_or([0.0, 0.0, 1.0, 1.0]) + }) + .collect() +} + +fn phase_uv_transforms_from_standalone( + loader: &mut StandaloneWearMaterialLoader, + wear_path: &Path, + material_index: u16, + document: &Mat0Document, +) -> Result<Vec<[f32; 4]>, String> { + document + .phases + .iter() + .enumerate() + .map(|(phase_index, phase)| { + if phase_texture_name(phase).is_none() { + return Ok([0.0, 0.0, 1.0, 1.0]); + } + loader + .load( + wear_path, + material_index, + u16::try_from(phase_index).unwrap_or(u16::MAX), + ) + .map(|(_, prepared)| prepared.page_uv_transform) + }) + .collect() +} + +#[derive(Clone, Copy, Debug)] +struct EnvironmentSkyLayer { + kind: SkyPassKind, + stage: Option<usize>, + secondary_stage: Option<usize>, + material_index: usize, + diffuse_alpha: f32, + static_directional_rgb: [f32; 3], + static_additive_rgb: [f32; 3], + translation_z: f32, + range: EnvironmentGpuRange, +} + +#[derive(Clone, Copy, Debug)] +struct EnvironmentSpriteLayer { + row: usize, + material_index: usize, + diffuse_alpha: f32, + flare_index: Option<usize>, + range: EnvironmentGpuRange, +} + +/// Fixed-capacity GPU allocations for the CPU-owned sky and environment +/// output. The Vulkan renderer only receives one shared vertex update and +/// smaller draw counts each frame; descriptor images and index allocations +/// stay stable while the camera and weather move. +#[derive(Clone, Debug)] +struct EnvironmentGpuScene { + material_animations: Vec<MaterialAnimationBinding>, + sky_layers: Vec<EnvironmentSkyLayer>, + sprites: Vec<EnvironmentSpriteLayer>, + rain: EnvironmentGpuRange, + snow: EnvironmentGpuRange, + lightning: EnvironmentGpuRange, + lightning_material_index: usize, +} + +fn world_pipeline_state(blend: LegacyBlendMode, depth: LegacyDepthMode) -> LegacyPipelineState { + LegacyPipelineState { + blend, + depth, + ..LegacyPipelineState::default() + } +} + +fn environment_vertex(position: [f32; 3], color: [f32; 3], uv: [f32; 2]) -> VulkanStaticVertex { + environment_vertex_with_alpha(position, color, 1.0, uv) +} + +fn environment_vertex_with_alpha( + position: [f32; 3], + color: [f32; 3], + alpha: f32, + uv: [f32; 2], +) -> VulkanStaticVertex { + environment_vertex_with_alpha_normal(position, color, alpha, uv, [0.0, 0.0, 1.0]) +} + +fn environment_vertex_with_alpha_normal( + position: [f32; 3], + color: [f32; 3], + alpha: f32, + uv: [f32; 2], + normal: [f32; 3], +) -> VulkanStaticVertex { + VulkanStaticVertex { + position, + color, + normal, + uv, + detail_uv: [0.0, 0.0], + overlay_alpha: alpha.clamp(0.0, 1.0), + } +} + +fn sky_normal(raw: [i8; 3]) -> [f32; 3] { + // Ngi32's cloud-light decode uses the signed-byte scale 1/128 and keeps + // the authored vector length. Renormalizing (or using 1/127) changes the + // edge values and shifts the native cloud shading. + [ + f32::from(raw[0]) / 128.0, + f32::from(raw[1]) / 128.0, + f32::from(raw[2]) / 128.0, + ] +} + +fn solid_environment_texture(rgba: [u8; 4]) -> VulkanStaticTexture { + let mip = VulkanStaticTextureMip { + width: 1, + height: 1, + rgba8: rgba.to_vec(), + }; + VulkanStaticTexture { + width: 1, + height: 1, + rgba8: mip.rgba8.clone(), + mip_levels: vec![mip], + } +} + +fn vulkan_texture_from_rgba_mips( + mips: Vec<RgbaImage>, + label: &str, +) -> Result<VulkanStaticTexture, String> { + let first = mips + .first() + .ok_or_else(|| format!("{label} has no mip zero"))?; + Ok(VulkanStaticTexture { + width: first.width, + height: first.height, + rgba8: first.rgba8.clone(), + mip_levels: mips + .into_iter() + .map(|mip| VulkanStaticTextureMip { + width: mip.width, + height: mip.height, + rgba8: mip.rgba8, + }) + .collect(), + }) +} + +fn append_environment_material( + materials: &mut Vec<VulkanStaticMaterial>, + material_names: &mut HashMap<String, usize>, + material_assets: &HashMap<String, EnvironmentMaterialAsset>, + material_animations: &mut Vec<MaterialAnimationBinding>, + name: &str, + fallback_row: usize, +) -> Result<usize, String> { + if let Some(index) = material_names.get(name).copied() { + return Ok(index); + } + let asset = material_assets.get(name).ok_or_else(|| { + format!("environment material {name:?} is missing from resolved material.lib/MAT0 assets") + })?; + let phases = if asset.phases.is_empty() { + vec![asset.phase] + } else { + asset.phases.clone() + }; + let first_material_index = materials.len(); + let mut phase_material_indices = Vec::with_capacity(phases.len()); + for (phase_index, phase) in phases.iter().enumerate() { + let index = materials.len(); + let material_index = u16::try_from(index).map_err(|_| { + "environment exceeds the available 16-bit material selector space".to_string() + })?; + materials.push(VulkanStaticMaterial { + material_index, + texture: asset + .phase_textures + .get(phase_index) + .cloned() + .unwrap_or_else(|| asset.texture.clone()), + 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, + diffuse_alpha: phase.diffuse_alpha.clamp(0.0, 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, + // Native cloud draws carry shade/fog flags. The remaining sky rows + // are unlit overlays; all rows still retain their decoded normals. + unlit: fallback_row != 2, + sky_nebula_stars: false, + lightmap_mode: false, + sky_far_depth: matches!(fallback_row, 0..=2), + }); + phase_material_indices.push(index); + } + let document = asset.document.clone(); + material_animations.push(MaterialAnimationBinding { + initial_phase_index: 0, + variants: vec![MaterialVariantSet { + phase_material_indices, + range_indices: Vec::new(), + }], + base: MaterialPhaseBinding { + document, + animation_block_index: 0, + wear_row_start_ms: 0, + random_state: first_material_index as u32, + frozen_phase_index: None, + phase_uv_transforms: phases.iter().map(|phase| phase.page_uv_transform).collect(), + }, + detail: None, + overlay: None, + overlay_detail: None, + stage_textures: None, + }); + material_names.insert(name.to_owned(), first_material_index); + Ok(first_material_index) +} + +fn append_environment_gradient_material( + materials: &mut Vec<VulkanStaticMaterial>, +) -> Result<usize, String> { + let index = materials.len(); + let material_index = u16::try_from(index).map_err(|_| { + "environment exceeds the available 16-bit material selector space".to_string() + })?; + materials.push(VulkanStaticMaterial { + material_index, + texture: solid_environment_texture([255, 255, 255, 255]), + directional_rgb: [1.0; 3], + additive_rgb: [0.0; 3], + uv_transform: [0.0, 0.0, 1.0, 1.0], + detail_texture: Some(solid_environment_texture([128, 128, 128, 255])), + overlay_texture: None, + overlay_detail_texture: None, + diffuse_alpha: 1.0, + overlay_diffuse_alpha: None, + overlay_directional_rgb: None, + overlay_additive_rgb: None, + specular_rgb: [0.0; 3], + specular_power: 0, + 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: true, + }); + Ok(index) +} + +fn shadow_atlas_texture() -> VulkanStaticTexture { + let rgba8 = vec![0_u8; 256 * 256 * 4]; + VulkanStaticTexture { + width: 256, + height: 256, + rgba8: rgba8.clone(), + mip_levels: vec![VulkanStaticTextureMip { + width: 256, + height: 256, + rgba8, + }], + } +} + +fn append_shadow_material(materials: &mut Vec<VulkanStaticMaterial>) -> Result<usize, String> { + let material_index = u16::try_from(materials.len()).map_err(|_| { + "shadow material selector exceeds the available 16-bit material selector space".to_string() + })?; + let index = materials.len(); + materials.push(VulkanStaticMaterial { + material_index, + texture: shadow_atlas_texture(), + directional_rgb: [1.0; 3], + additive_rgb: [0.0; 3], + uv_transform: [0.0, 0.0, 1.0, 1.0], + detail_texture: Some(solid_environment_texture([255, 255, 255, 255])), + overlay_texture: None, + overlay_detail_texture: None, + diffuse_alpha: 1.0, + overlay_diffuse_alpha: None, + overlay_directional_rgb: None, + overlay_additive_rgb: None, + specular_rgb: [0.0; 3], + specular_power: 0, + 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, + // Combiner mode 1 keeps the atlas alpha and the projected receiver's + // per-vertex alpha while bypassing world lighting/fog. + unlit: true, + sky_nebula_stars: false, + lightmap_mode: false, + sky_far_depth: false, + }); + Ok(index) +} + +fn shadow_placeholder_vertices() -> Vec<VulkanStaticVertex> { + [ + environment_vertex_with_alpha([0.0, 0.0, 0.0], [1.0; 3], 0.0, [0.0, 0.0]), + environment_vertex_with_alpha([1.0, 0.0, 0.0], [1.0; 3], 0.0, [1.0, 0.0]), + environment_vertex_with_alpha([0.0, 1.0, 0.0], [1.0; 3], 0.0, [0.0, 1.0]), + environment_vertex_with_alpha([1.0, 1.0, 0.0], [1.0; 3], 0.0, [1.0, 1.0]), + ] + .to_vec() +} + +fn shadow_triangle(positions: [[f32; 3]; 3], mask: u32) -> ShadowTriangle { + let normal = normalize3(cross3( + sub3(positions[1], positions[0]), + sub3(positions[2], positions[0]), + )) + .unwrap_or([0.0, 0.0, 1.0]); + ShadowTriangle { + positions, + normal, + mask, + } +} + +fn terrain_shadow_receivers( + terrain: &fparkan_terrain_format::LandMeshDocument, +) -> Result<Vec<ShadowTriangle>, String> { + terrain + .faces + .iter() + .map(|face| { + let positions = face.vertices.map(|index| { + let source = terrain + .positions + .get(usize::from(index)) + .copied() + .unwrap_or([0.0; 3]); + [source[0], source[1], source[2] / 32.0] + }); + 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 static_mesh_shadow_triangles(mesh: &VulkanStaticMesh) -> Vec<ShadowTriangle> { + mesh.indices + .chunks_exact(3) + .filter_map(|triangle| { + let positions = [ + mesh.vertices + .get(usize::try_from(triangle[0]).ok()?)? + .position, + mesh.vertices + .get(usize::try_from(triangle[1]).ok()?)? + .position, + mesh.vertices + .get(usize::try_from(triangle[2]).ok()?)? + .position, + ]; + positions + .iter() + .flatten() + .all(|value| value.is_finite()) + .then_some(shadow_triangle(positions, 0)) + }) + .collect() +} + +#[derive(Clone, Copy, Debug)] +struct ShadowComponentSphere { + center: [f32; 3], + radius: f32, +} + +fn mounted_shadow_sphere( + center: [f32; 3], + radius: f32, + mount_pose: Option<VulkanNodePose>, + node1_pose: Option<VulkanNodePose>, +) -> Option<ShadowComponentSphere> { + if !center.iter().all(|value| value.is_finite()) || !radius.is_finite() || radius <= 0.0 { + return None; + } + let center = mount_pose.map_or(center, |parent_pose| { + // The native mounted-unit sphere query keeps node 1's accumulated + // rotation but replaces its translation with the accumulated parent + // translation. This is deliberately different from the render mount + // composition, which also carries node 1's translation. + let rotation = node1_pose.map_or(parent_pose.rotation, |node_pose| { + multiply_unit_quaternions(parent_pose.rotation, node_pose.rotation) + }); + add3( + parent_pose.translation, + rotate_unit_vector(center, rotation), + ) + }); + center + .iter() + .all(|value| value.is_finite()) + .then_some(ShadowComponentSphere { center, radius }) +} + +fn aggregate_shadow_caster( + id: u32, + native_kind: u32, + transform: LegacyIron3dEulerTransform, + scale: [f32; 3], + triangles: Vec<ShadowTriangle>, + component_spheres: &[ShadowComponentSphere], +) -> Option<ShadowCaster> { + if triangles.is_empty() { + return None; + } + let weight = component_spheres + .iter() + .map(|sphere| sphere.radius) + .sum::<f32>(); + if !weight.is_finite() || weight <= f32::EPSILON { + return None; + } + // Native 9510 aggregates visible root/EXTO spheres by radius-weighted + // center, then encloses every component sphere around that center. + let aggregate_center = component_spheres.iter().fold([0.0; 3], |sum, sphere| { + add3(sum, scale3(sphere.center, sphere.radius)) + }); + let aggregate_center = scale3(aggregate_center, weight.recip()); + let radius = component_spheres + .iter() + .map(|sphere| length3(sub3(sphere.center, aggregate_center)).unwrap_or(0.0) + sphere.radius) + .fold(0.0, f32::max); + let max_scale = scale.iter().copied().fold(f32::NEG_INFINITY, f32::max); + if !aggregate_center.iter().all(|value| value.is_finite()) + || !radius.is_finite() + || radius <= f32::EPSILON + || !max_scale.is_finite() + || max_scale <= 0.0 + { + return None; + } + // The native scale flag applies to the aggregate exactly once. The + // mission Euler transform then rotates/translates that scaled sphere. + let scaled_center = [ + aggregate_center[0] * scale[0], + aggregate_center[1] * scale[1], + aggregate_center[2] * scale[2], + ]; + let center = transform.try_transform_scaled_point(scaled_center, [1.0; 3])?; + let radius = radius * max_scale; + if !center.iter().all(|value| value.is_finite()) || !radius.is_finite() { + return None; + } + let world_axes = transform + .try_row_major() + .map(|matrix| { + [ + [matrix[0], matrix[4], matrix[8]], + [matrix[1], matrix[5], matrix[9]], + [matrix[2], matrix[6], matrix[10]], + ] + }) + .unwrap_or([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); + Some(ShadowCaster { + id, + world_axes, + sphere: ShadowSphere { center, radius }, + native_kind, + triangles, + }) +} + +fn shadow_lights( + sky_frame: Option<&SkyFrame<'_>>, + environment_frame: Option<&EnvironmentFrame>, + renderer: &VulkanSmokeRenderer, +) -> Vec<ShadowLight> { + let mut lights = Vec::new(); + if let Some(sky_frame) = sky_frame { + lights.extend(sky_frame.directional_lights.into_iter().enumerate().map( + |(index, light)| { + ShadowLight::Directional { + direction: light.direction, + rgb: light.color, + active: light.active, + // CSun's constructor marks both the sun (slot 0) and + // moon (slot 2) as primary. ShadowReference selects the + // first one for kind 3 and accumulates both active + // primary directions for ordinary objects (kind 4). + flags: (index == 0 || index == 2) + .then_some(SHADOW_PRIMARY_DIRECTIONAL_FLAG) + .unwrap_or(0), + } + }, + )); + } else { + let uniforms = renderer.frame_uniforms(); + lights.extend( + uniforms + .directional_lights + .into_iter() + .enumerate() + .map(|(index, light)| ShadowLight::Directional { + direction: light.direction, + rgb: light.rgb, + active: light.active, + flags: (index == 0 || index == 2) + .then_some(SHADOW_PRIMARY_DIRECTIONAL_FLAG) + .unwrap_or(0), + }), + ); + } + if let Some(environment_frame) = environment_frame { + lights.extend( + environment_frame + .point_lights + .iter() + .map(|light| ShadowLight::Point { + position: light.position, + rgb: [light.color[0], light.color[1], light.color[2]], + range: light.range, + coefficients: light.attenuation, + active: light.active, + flags: 0, + }), + ); + } else { + let point = renderer.frame_uniforms().point_light; + lights.push(ShadowLight::Point { + position: point.position, + rgb: point.rgb, + range: point.range, + coefficients: point.coefficients, + active: point.active, + flags: 0, + }); + } + lights +} + +/// Native `Terrain30737` expands each caster sphere by 100 world units before +/// testing it against the six planes in the current world frustum. Filtering +/// here matters because `CShade` only accepts its first twenty candidates; +/// off-screen objects must not consume that budget. +const SHADOW_FRUSTUM_RADIUS_MARGIN: f32 = 100.0; + +fn native_shadow_caster_visible(caster: &ShadowCaster, clip_from_world: [f32; 16]) -> bool { + let radius = caster.sphere.radius + SHADOW_FRUSTUM_RADIUS_MARGIN; + if !radius.is_finite() || radius < 0.0 || !caster.sphere.center.iter().all(|v| v.is_finite()) { + return false; + } + + // The camera matrix is row-major D3D-style storage. A world point is + // transformed by p * M, so each clip coordinate uses one matrix column. + // The six Vulkan clip half-spaces are x+w, -x+w, y+w, -y+w, z, and w-z. + let planes = [ + [ + clip_from_world[0] + clip_from_world[3], + clip_from_world[4] + clip_from_world[7], + clip_from_world[8] + clip_from_world[11], + clip_from_world[12] + clip_from_world[15], + ], + [ + -clip_from_world[0] + clip_from_world[3], + -clip_from_world[4] + clip_from_world[7], + -clip_from_world[8] + clip_from_world[11], + -clip_from_world[12] + clip_from_world[15], + ], + [ + clip_from_world[1] + clip_from_world[3], + clip_from_world[5] + clip_from_world[7], + clip_from_world[9] + clip_from_world[11], + clip_from_world[13] + clip_from_world[15], + ], + [ + -clip_from_world[1] + clip_from_world[3], + -clip_from_world[5] + clip_from_world[7], + -clip_from_world[9] + clip_from_world[11], + -clip_from_world[13] + clip_from_world[15], + ], + [ + clip_from_world[2], + clip_from_world[6], + clip_from_world[10], + clip_from_world[14], + ], + [ + clip_from_world[3] - clip_from_world[2], + clip_from_world[7] - clip_from_world[6], + clip_from_world[11] - clip_from_world[10], + clip_from_world[15] - clip_from_world[14], + ], + ]; + planes.into_iter().all(|plane| { + let normal_length = + (plane[0].mul_add(plane[0], plane[1].mul_add(plane[1], plane[2] * plane[2]))).sqrt(); + if !normal_length.is_finite() || normal_length <= f32::EPSILON { + return false; + } + let distance = plane[0].mul_add( + caster.sphere.center[0], + plane[1].mul_add( + caster.sphere.center[1], + plane[2].mul_add(caster.sphere.center[2], plane[3]), + ), + ) / normal_length; + distance.is_finite() && distance > -radius + }) +} + +fn native_visible_shadow_casters( + casters: &[ShadowCaster], + clip_from_world: [f32; 16], +) -> Vec<ShadowCaster> { + casters + .iter() + .filter(|caster| native_shadow_caster_visible(caster, clip_from_world)) + .cloned() + .collect() +} + +fn append_shadow_frame_geometry( + mesh: &mut VulkanStaticMesh, + shadow_range: EnvironmentGpuRange, + shadow_base_vertex: usize, + shadow_base_index: usize, + frame: &ShadowFrame, +) -> Result<VulkanDynamicDrawRange, String> { + if shadow_base_vertex > mesh.vertices.len() || shadow_base_index > mesh.indices.len() { + return Err("shadow dynamic base is outside the shared mesh".to_string()); + } + mesh.vertices.truncate(shadow_base_vertex); + mesh.indices.truncate(shadow_base_index); + let vertex_base = u32::try_from(mesh.vertices.len()) + .map_err(|_| "shadow vertex base exceeds u32".to_string())?; + let first_index = u32::try_from(mesh.indices.len()) + .map_err(|_| "shadow index base exceeds u32".to_string())?; + if frame.indices.iter().any(|&index| { + usize::try_from(index) + .ok() + .is_none_or(|index| index >= frame.vertices.len()) + }) { + return Err("shadow frame index exceeds its vertex stream".to_string()); + } + mesh.vertices.extend(frame.vertices.iter().map(|vertex| { + environment_vertex_with_alpha_normal( + vertex.position, + [1.0; 3], + vertex.alpha.clamp(0.0, 1.0), + vertex.uv, + vertex.normal, + ) + })); + mesh.indices.extend( + frame + .indices + .iter() + .map(|&index| { + vertex_base + .checked_add(index) + .ok_or_else(|| "shadow index exceeds u32".to_string()) + }) + .collect::<Result<Vec<_>, _>>()?, + ); + let index_count = u32::try_from(frame.indices.len()) + .map_err(|_| "shadow index count exceeds u32".to_string())?; + if let Some(range) = mesh.draw_ranges.get_mut(shadow_range.range_index) { + range.first_index = first_index; + range.index_count = index_count; + } + Ok(VulkanDynamicDrawRange { + range_index: shadow_range.range_index, + first_index, + index_count, + }) +} + +fn shadow_atlas_texture_from_frame(frame: &ShadowFrame) -> VulkanStaticTexture { + VulkanStaticTexture { + width: 256, + height: 256, + rgba8: frame.atlas_rgba8.clone(), + mip_levels: vec![VulkanStaticTextureMip { + width: 256, + height: 256, + rgba8: frame.atlas_rgba8.clone(), + }], + } +} + +fn append_environment_range( + mesh: &mut VulkanStaticMesh, + material_index: usize, + vertices: Vec<VulkanStaticVertex>, + local_indices: Vec<u32>, + pipeline_state: LegacyPipelineState, +) -> Result<EnvironmentGpuRange, String> { + if vertices.is_empty() || local_indices.is_empty() || !local_indices.len().is_multiple_of(3) { + return Err("environment geometry range is empty or not triangle-aligned".to_string()); + } + let vertex_start = mesh.vertices.len(); + let first_index = mesh.indices.len(); + let material_index = u16::try_from(material_index) + .map_err(|_| "environment material selector exceeds u16".to_string())?; + let vertex_start_u32 = u32::try_from(vertex_start) + .map_err(|_| "environment vertex allocation exceeds u32".to_string())?; + for index in local_indices { + mesh.indices.push( + vertex_start_u32 + .checked_add(index) + .ok_or_else(|| "environment index exceeds u32".to_string())?, + ); + } + let index_count = u32::try_from(mesh.indices.len() - first_index) + .map_err(|_| "environment index allocation exceeds u32".to_string())?; + let vertex_capacity = vertices.len(); + mesh.vertices.extend(vertices); + let first_index = u32::try_from(first_index) + .map_err(|_| "environment first index exceeds u32".to_string())?; + let range_index = mesh.draw_ranges.len(); + mesh.draw_ranges + .push(fparkan_render_vulkan::VulkanStaticDrawRange { + first_index, + index_count, + material_index, + lightmap_index: u8::MAX, + batch_flags: 0, + pipeline_state, + alpha_test_reference: 0, + }); + Ok(EnvironmentGpuRange { + range_index, + vertex_start, + vertex_capacity, + material_index: usize::from(material_index), + }) +} + +/// Reorders indexed draw segments without breaking the static mesh contract. +/// +/// Draw ranges are required to cover one contiguous index buffer. Changing +/// only their vector order therefore is insufficient when a background pass +/// must run before mission geometry: the corresponding index segments have to +/// move with them and every range receives a new `first_index`. +fn reorder_draw_ranges( + mesh: &mut VulkanStaticMesh, + old_order: &[usize], +) -> Result<Vec<usize>, String> { + if old_order.len() != mesh.draw_ranges.len() { + return Err("draw-range reorder does not cover the whole mesh".to_string()); + } + let mut seen = vec![false; mesh.draw_ranges.len()]; + let old_ranges = mesh.draw_ranges.clone(); + let old_indices = mesh.indices.clone(); + let mut indices = Vec::with_capacity(old_indices.len()); + let mut ranges = Vec::with_capacity(old_ranges.len()); + let mut remap = vec![usize::MAX; old_ranges.len()]; + for (new_index, &old_index) in old_order.iter().enumerate() { + if old_index >= old_ranges.len() || seen[old_index] { + return Err("draw-range reorder contains a duplicate or invalid range".to_string()); + } + seen[old_index] = true; + let old_range = old_ranges[old_index]; + let first = usize::try_from(old_range.first_index) + .map_err(|_| "draw-range first index exceeds addressable memory".to_string())?; + let count = usize::try_from(old_range.index_count) + .map_err(|_| "draw-range index count exceeds addressable memory".to_string())?; + let end = first + .checked_add(count) + .ok_or_else(|| "draw-range index segment overflows address space".to_string())?; + let segment = old_indices + .get(first..end) + .ok_or_else(|| "draw-range index segment is outside the mesh".to_string())?; + let first_index = u32::try_from(indices.len()) + .map_err(|_| "reordered index buffer exceeds u32".to_string())?; + indices.extend_from_slice(segment); + let mut range = old_range; + range.first_index = first_index; + ranges.push(range); + remap[old_index] = new_index; + } + if seen.iter().any(|seen| !seen) || indices.len() != old_indices.len() { + return Err("draw-range reorder does not cover the whole index buffer".to_string()); + } + mesh.indices = indices; + mesh.draw_ranges = ranges; + Ok(remap) +} + +fn quad_indices(slot_count: usize) -> Result<Vec<u32>, String> { + let mut indices = Vec::with_capacity( + slot_count + .checked_mul(6) + .ok_or_else(|| "environment quad index count overflow".to_string())?, + ); + for slot in 0..slot_count { + let base = u32::try_from( + slot.checked_mul(4) + .ok_or_else(|| "environment quad vertex count overflow".to_string())?, + ) + .map_err(|_| "environment quad vertex count exceeds u32".to_string())?; + indices.extend_from_slice(&[base, base + 1, base + 2, base + 2, base + 1, base + 3]); + } + Ok(indices) +} + +fn placeholder_quad_vertices( + camera: &FreeFlightCamera, + slot_count: usize, + size: f32, +) -> Vec<VulkanStaticVertex> { + let center = add3( + camera.position, + scale3(camera.forward(), camera.far_plane * 0.5), + ); + let right = scale3(camera.right(), size); + let up = scale3(camera.up(), size); + (0..slot_count) + .flat_map(|_| { + [ + environment_vertex(sub3(sub3(center, right), up), [1.0; 3], [0.0, 1.0]), + environment_vertex(add3(sub3(center, up), right), [1.0; 3], [1.0, 1.0]), + environment_vertex(add3(sub3(center, right), up), [1.0; 3], [0.0, 0.0]), + environment_vertex(add3(add3(center, right), up), [1.0; 3], [1.0, 0.0]), + ] + }) + .collect() +} + +fn screen_gradient_quad_vertices( + camera: &FreeFlightCamera, + viewport: [f32; 2], + color: [f32; 3], +) -> [VulkanStaticVertex; 4] { + let width = viewport[0].max(1.0); + let height = viewport[1].max(1.0); + let aspect = width / height; + let fov = camera.vertical_fov.clamp(0.05, 3.0); + // The quad is placed close to the camera and drawn with depth disabled. + // It therefore covers the complete projected viewport while the later + // dome, terrain, and object ranges provide their own depth-tested detail. + let distance = 1.0_f32; + let half_height = distance * (fov * 0.5).tan() * 1.05; + let half_width = half_height * aspect; + let center = add3(camera.position, scale3(camera.forward(), distance)); + let right = scale3(camera.right(), half_width); + let up = scale3(camera.up(), half_height); + [ + environment_vertex(sub3(sub3(center, right), up), color, [0.0, 1.0]), + environment_vertex(add3(sub3(center, up), right), color, [1.0, 1.0]), + environment_vertex(add3(sub3(center, right), up), color, [0.0, 0.0]), + environment_vertex(add3(add3(center, right), up), color, [1.0, 0.0]), + ] +} + +impl EnvironmentGpuScene { + fn fixed_range_indices(&self) -> HashSet<usize> { + self.sky_layers + .iter() + .map(|layer| layer.range.range_index) + .chain(self.sprites.iter().map(|sprite| sprite.range.range_index)) + .chain([ + self.rain.range_index, + self.snow.range_index, + self.lightning.range_index, + ]) + .collect() + } + + fn new( + mesh: &mut VulkanStaticMesh, + materials: &mut Vec<VulkanStaticMaterial>, + preview_material_animations: &mut Vec<MaterialAnimationBinding>, + sun_occlusion_range_indices: &mut Vec<usize>, + environment_materials: &SkyMaterials, + material_assets: &HashMap<String, EnvironmentMaterialAsset>, + sky_mesh: &SkyMesh, + camera: &FreeFlightCamera, + shadow_range_index: &mut usize, + ) -> Result<Self, String> { + let mut names = HashMap::new(); + let mut material_animations = Vec::new(); + let mut material_indices = [None; 9]; + let fallback_names = SkyMaterials::default(); + for row in 0..9 { + let name = environment_materials + .material_name(row) + .or_else(|| fallback_names.material_name(row)) + .unwrap_or("ENVIRONMENT"); + let index = append_environment_material( + materials, + &mut names, + material_assets, + &mut material_animations, + name, + row, + )?; + material_indices[row] = Some(index); + } + let gradient_material_index = append_environment_gradient_material(materials)?; + let nebula_material_index = material_indices[0] + .ok_or_else(|| "nebula material selector is unavailable".to_string())?; + let stars_material_index = material_indices[1] + .ok_or_else(|| "stars material selector is unavailable".to_string())?; + // Native sky mode 4 uses one descriptor set: TEX0 is the nebula and + // TEX1 is the independently animated stars texture whose alpha chooses + // the contribution. Keep the stars MAT0 binding as the nebula binding's + // detail stage; do not form a Cartesian product of the two phase rows. + let (nebula_binding_index, nebula_phase_materials) = + find_material_animation_variant(&material_animations, nebula_material_index) + .ok_or_else(|| { + format!("MAT0 sky material {nebula_material_index} has no phase variants") + })?; + let (stars_binding_index, stars_phase_materials) = + find_material_animation_variant(&material_animations, stars_material_index) + .ok_or_else(|| { + format!("MAT0 sky material {stars_material_index} has no phase variants") + })?; + if stars_phase_materials.is_empty() { + return Err("stars material has no phase variants".to_string()); + } + let stars_descriptors = stars_phase_materials + .iter() + .map(|&material_index| { + let material = materials.get(material_index).ok_or_else(|| { + format!("stars material descriptor {material_index} is unavailable") + })?; + Ok((material.texture.clone(), material.uv_transform)) + }) + .collect::<Result<Vec<_>, String>>()?; + let stars_binding = material_animations + .get(stars_binding_index) + .ok_or_else(|| "stars material animation binding is unavailable".to_string())? + .base + .clone(); + let stars_stage_textures = stars_descriptors + .iter() + .map(|(texture, _)| texture.clone()) + .collect(); + let stars_initial_uv = stars_descriptors + .first() + .map(|(_, uv_transform)| *uv_transform) + .unwrap_or([0.0, 0.0, 1.0, 1.0]); + for &material_index in &nebula_phase_materials { + let stars_texture = stars_descriptors + .first() + .map(|(texture, _)| texture.clone()) + .ok_or_else(|| "stars material descriptor is unavailable".to_string())?; + let nebula = materials.get_mut(material_index).ok_or_else(|| { + format!("nebula material descriptor {material_index} is unavailable") + })?; + nebula.detail_texture = Some(stars_texture); + nebula.detail_uv_transform = stars_initial_uv; + nebula.sky_nebula_stars = true; + } + let nebula_binding = material_animations + .get_mut(nebula_binding_index) + .ok_or_else(|| "nebula material animation binding is unavailable".to_string())?; + nebula_binding.detail = Some(stars_binding); + nebula_binding.stage_textures = Some(MaterialStageTextures { + detail: Some(stars_stage_textures), + overlay: None, + overlay_detail: None, + // Force TEX1 to the independently sampled stars phase on the first + // frame, including when the initial star descriptor is already set. + applied_material_phases: HashMap::new(), + }); + let lightning_material_index = append_environment_material( + materials, + &mut names, + material_assets, + &mut material_animations, + "env_lightning", + 3, + )?; + + let mut scene = Self { + material_animations, + sky_layers: Vec::with_capacity(4), + sprites: Vec::with_capacity(ENVIRONMENT_SPRITE_ROWS.len() + ENVIRONMENT_FLARE_SLOTS), + rain: EnvironmentGpuRange { + range_index: 0, + vertex_start: 0, + vertex_capacity: 0, + material_index: material_indices[8].unwrap_or(0), + }, + snow: EnvironmentGpuRange { + range_index: 0, + vertex_start: 0, + vertex_capacity: 0, + material_index: material_indices[7].unwrap_or(0), + }, + lightning: EnvironmentGpuRange { + range_index: 0, + vertex_start: 0, + vertex_capacity: 0, + material_index: lightning_material_index, + }, + lightning_material_index, + }; + + let screen_range = append_environment_range( + mesh, + gradient_material_index, + screen_gradient_quad_vertices(camera, [16.0, 9.0], [1.0; 3]).to_vec(), + quad_indices(1)?, + world_pipeline_state(LegacyBlendMode::Opaque, LegacyDepthMode::Disabled), + )?; + scene.sky_layers.push(EnvironmentSkyLayer { + kind: SkyPassKind::ScreenGradient, + stage: None, + secondary_stage: None, + material_index: gradient_material_index, + diffuse_alpha: 1.0, + static_directional_rgb: [1.0; 3], + static_additive_rgb: [0.0; 3], + translation_z: 0.0, + range: screen_range, + }); + + let nebula_material = materials + .get(nebula_material_index) + .ok_or_else(|| "nebula material descriptor is unavailable".to_string())?; + let nebula_vertices = sky_mesh + .vertices + .iter() + .map(|vertex| { + let color = vertex.color_rgba(); + environment_vertex_with_alpha_normal( + add3(vertex.position, camera.position), + [color[0], color[1], color[2]], + color[3], + vertex.uv[0], + sky_normal(vertex.normal), + ) + }) + .collect(); + let nebula_range = append_environment_range( + mesh, + nebula_material_index, + nebula_vertices, + sky_mesh.indices.clone(), + world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), + )?; + register_material_range( + &mut scene.material_animations, + nebula_material_index, + nebula_range.range_index, + ); + scene.sky_layers.push(EnvironmentSkyLayer { + kind: SkyPassKind::NebulaStars, + stage: Some(0), + secondary_stage: Some(1), + material_index: nebula_material_index, + diffuse_alpha: nebula_material.diffuse_alpha, + static_directional_rgb: nebula_material.directional_rgb, + static_additive_rgb: nebula_material.additive_rgb, + translation_z: 0.0, + range: nebula_range, + }); + + let dome_vertices = sky_mesh + .vertices + .iter() + .map(|vertex| { + let color = vertex.color_rgba(); + environment_vertex_with_alpha_normal( + add3(vertex.position, camera.position), + [color[0], color[1], color[2]], + color[3], + [0.0, 0.0], + sky_normal(vertex.normal), + ) + }) + .collect(); + let dome_range = append_environment_range( + mesh, + gradient_material_index, + dome_vertices, + sky_mesh.indices.clone(), + world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), + )?; + scene.sky_layers.push(EnvironmentSkyLayer { + kind: SkyPassKind::DomeGradient, + stage: None, + secondary_stage: None, + material_index: gradient_material_index, + diffuse_alpha: 1.0, + static_directional_rgb: [1.0; 3], + static_additive_rgb: [0.0; 3], + translation_z: 0.0, + range: dome_range, + }); + + let clouds_material_index = material_indices[2] + .ok_or_else(|| "cloud material selector is unavailable".to_string())?; + let clouds_material = materials + .get(clouds_material_index) + .ok_or_else(|| "cloud material descriptor is unavailable".to_string())?; + let clouds_vertices = sky_mesh + .vertices + .iter() + .map(|vertex| { + let color = vertex.color_rgba(); + environment_vertex_with_alpha_normal( + add3(add3(vertex.position, camera.position), [0.0, 0.0, -5000.0]), + [color[0], color[1], color[2]], + color[3], + vertex.uv[2], + sky_normal(vertex.normal), + ) + }) + .collect(); + let clouds_range = append_environment_range( + mesh, + clouds_material_index, + clouds_vertices, + sky_mesh.indices.clone(), + world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), + )?; + register_material_range( + &mut scene.material_animations, + clouds_material_index, + clouds_range.range_index, + ); + scene.sky_layers.push(EnvironmentSkyLayer { + kind: SkyPassKind::Clouds, + stage: Some(2), + secondary_stage: None, + material_index: clouds_material_index, + diffuse_alpha: clouds_material.diffuse_alpha, + static_directional_rgb: clouds_material.directional_rgb, + static_additive_rgb: clouds_material.additive_rgb, + translation_z: -5000.0, + range: clouds_range, + }); + + let sprite_size = (camera.far_plane * 0.01).max(1.0); + for row in ENVIRONMENT_SPRITE_ROWS { + let material_index = material_indices[row] + .ok_or_else(|| format!("sky sprite row {row} has no material selector"))?; + let range = append_environment_range( + mesh, + material_index, + placeholder_quad_vertices(camera, 1, sprite_size), + quad_indices(1)?, + world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), + )?; + register_material_range( + &mut scene.material_animations, + material_index, + range.range_index, + ); + scene.sprites.push(EnvironmentSpriteLayer { + row, + material_index, + diffuse_alpha: materials + .get(material_index) + .map_or(1.0, |material| material.diffuse_alpha), + flare_index: None, + range, + }); + } + + for flare_index in 0..ENVIRONMENT_FLARE_SLOTS { + let row = SKY_FLARE_ROWS[flare_index]; + let material_index = material_indices[row] + .ok_or_else(|| format!("sky flare row {row} has no material selector"))?; + let range = append_environment_range( + mesh, + material_index, + placeholder_quad_vertices(camera, 1, sprite_size), + quad_indices(1)?, + world_pipeline_state(LegacyBlendMode::Additive, LegacyDepthMode::TestReadOnly), + )?; + register_material_range( + &mut scene.material_animations, + material_index, + range.range_index, + ); + scene.sprites.push(EnvironmentSpriteLayer { + row, + material_index, + diffuse_alpha: materials + .get(material_index) + .map_or(1.0, |material| material.diffuse_alpha), + flare_index: Some(flare_index), + range, + }); + } + + let particle_indices = quad_indices(ENVIRONMENT_PARTICLE_SLOTS)?; + scene.rain = append_environment_range( + mesh, + material_indices[8] + .ok_or_else(|| "rain material selector is unavailable".to_string())?, + placeholder_quad_vertices(camera, ENVIRONMENT_PARTICLE_SLOTS, 0.25), + particle_indices.clone(), + world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), + )?; + register_material_range( + &mut scene.material_animations, + scene.rain.material_index, + scene.rain.range_index, + ); + scene.snow = append_environment_range( + mesh, + material_indices[7] + .ok_or_else(|| "snow material selector is unavailable".to_string())?, + placeholder_quad_vertices(camera, ENVIRONMENT_PARTICLE_SLOTS, 0.25), + particle_indices, + world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), + )?; + register_material_range( + &mut scene.material_animations, + scene.snow.material_index, + scene.snow.range_index, + ); + scene.lightning = append_environment_range( + mesh, + lightning_material_index, + placeholder_quad_vertices(camera, 1, sprite_size), + quad_indices(1)?, + world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), + )?; + register_material_range( + &mut scene.material_animations, + scene.lightning.material_index, + scene.lightning.range_index, + ); + + // Preserve the native sky order while keeping the indexed mesh + // contiguous. The screen gradient must be the first draw, and the + // cloud dome follows all directional sprites. Reordering the range + // metadata alone would leave first_index values pointing into the old + // segment order, so move each index segment together with its range. + let range_for_kind = |kind: SkyPassKind| { + scene + .sky_layers + .iter() + .find(|layer| layer.kind == kind) + .map(|layer| layer.range.range_index) + }; + let screen_index = range_for_kind(SkyPassKind::ScreenGradient) + .ok_or_else(|| "screen gradient range was not allocated".to_string())?; + let nebula_index = range_for_kind(SkyPassKind::NebulaStars) + .ok_or_else(|| "nebula sky range was not allocated".to_string())?; + let dome_index = range_for_kind(SkyPassKind::DomeGradient) + .ok_or_else(|| "dome gradient range was not allocated".to_string())?; + let cloud_index = range_for_kind(SkyPassKind::Clouds) + .ok_or_else(|| "cloud sky range was not allocated".to_string())?; + let sprite_indices = scene + .sprites + .iter() + .map(|sprite| sprite.range.range_index) + .collect::<Vec<_>>(); + if sprite_indices.is_empty() { + return Err("sky sprite ranges were not allocated".to_string()); + } + let rain_index = scene.rain.range_index; + let snow_index = scene.snow.range_index; + let lightning_index = scene.lightning.range_index; + let environment_indices = [ + screen_index, + nebula_index, + dome_index, + cloud_index, + rain_index, + snow_index, + lightning_index, + *shadow_range_index, + ] + .into_iter() + .chain(sprite_indices.iter().copied()) + .collect::<HashSet<_>>(); + let old_range_count = mesh.draw_ranges.len(); + let mut old_order = Vec::with_capacity(old_range_count); + old_order.push(screen_index); + old_order.extend((0..old_range_count).filter(|index| { + !environment_indices.contains(index) + && *index != screen_index + && *index != nebula_index + && *index != dome_index + && *index != cloud_index + })); + old_order.push(*shadow_range_index); + old_order.extend([nebula_index, dome_index]); + old_order.extend(sprite_indices.iter().copied()); + old_order.extend([cloud_index, rain_index, snow_index, lightning_index]); + let remap = reorder_draw_ranges(mesh, &old_order)?; + for layer in &mut scene.sky_layers { + layer.range.range_index = *remap + .get(layer.range.range_index) + .ok_or_else(|| "sky draw range remap is out of bounds".to_string())?; + } + for sprite in &mut scene.sprites { + sprite.range.range_index = *remap + .get(sprite.range.range_index) + .ok_or_else(|| "sky sprite draw range remap is out of bounds".to_string())?; + } + scene.rain.range_index = *remap + .get(scene.rain.range_index) + .ok_or_else(|| "rain draw range remap is out of bounds".to_string())?; + scene.snow.range_index = *remap + .get(scene.snow.range_index) + .ok_or_else(|| "snow draw range remap is out of bounds".to_string())?; + scene.lightning.range_index = *remap + .get(scene.lightning.range_index) + .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 range_index in sun_occlusion_range_indices { + *range_index = *remap + .get(*range_index) + .ok_or_else(|| "sun occlusion range remap is out of bounds".to_string())?; + } + *shadow_range_index = *remap + .get(*shadow_range_index) + .ok_or_else(|| "shadow draw range remap is out of bounds".to_string())?; + Ok(scene) + } + + fn update( + &mut self, + mesh: &mut VulkanStaticMesh, + materials: &[VulkanStaticMaterial], + renderer: &mut VulkanSmokeRenderer, + sky_frame: &SkyFrame, + sky_mesh: &SkyMesh, + environment_frame: &EnvironmentFrame, + camera: &FreeFlightCamera, + viewport: [f32; 2], + material_clock_ms: u32, + terrain: &TerrainWorld, + sun_occlusion_range_indices: &[usize], + ) -> Result<(), String> { + let active_materials = update_material_animations( + &mut self.material_animations, + materials, + renderer, + material_clock_ms, + )?; + // Resolve the native glare once for the frame. CSky feeds the + // boosted primary color back into the light manager and uses the + // positive boost delta for both the screen-gradient horizon floor and + // the dome palette clamp. + let projected_sun = + project_direction_pixels(camera, sky_frame.sun.geometry_direction(), viewport) + .unwrap_or([f32::NAN; 2]); + let optics = sky_frame + .sun_optics(SunVisibility { + projected_sun, + viewport, + view_forward: camera.forward(), + // Flares use the same terrain/object ray query as the native + // visibility gate. The sun sprite itself deliberately stays + // independent of this test, matching the native layer's + // `requires_occlusion` split. + unoccluded: native_sun_unoccluded_with_world( + terrain, + mesh, + sun_occlusion_range_indices, + camera, + sky_frame.sun.geometry_direction(), + ), + }) + .ok(); + let glare_rgb_delta = optics.map_or([0.0; 3], |optics| { + std::array::from_fn(|index| { + (optics.primary_color[index] - sky_frame.sun.color[index]).max(0.0) + }) + }); + let gradient = sky_frame.screen_gradient(camera.yaw, glare_rgb_delta); + if let Some(optics) = optics { + let mut uniforms = renderer.frame_uniforms(); + if sky_frame.sun.active { + uniforms.directional_lights[0].rgb = optics.primary_color; + } + renderer + .set_frame_uniforms(uniforms) + .map_err(|error| format!("update glare light uniforms: {error}"))?; + } + let gradient_rgba = gradient.color.rgba(); + for layer in &self.sky_layers { + let range = layer.range; + let destination = mesh + .vertices + .get_mut(range.vertex_start..range.vertex_start + range.vertex_capacity) + .ok_or_else(|| "sky vertex allocation is out of bounds".to_string())?; + let pass_frame = sky_frame + .passes + .iter() + .find(|pass| pass.kind == layer.kind) + .ok_or_else(|| format!("sky frame is missing {:?} pass", layer.kind))?; + match layer.kind { + SkyPassKind::ScreenGradient => { + if destination.len() != 4 { + return Err("screen gradient vertex allocation changed".to_string()); + } + destination.copy_from_slice(&screen_gradient_quad_vertices( + camera, + viewport, + [gradient_rgba[0], gradient_rgba[1], gradient_rgba[2]], + )); + } + SkyPassKind::NebulaStars | SkyPassKind::DomeGradient | SkyPassKind::Clouds => { + if destination.len() != sky_mesh.vertices.len() { + return Err( + "sky mesh vertex count changed after GPU allocation".to_string() + ); + } + for (target, source) in destination.iter_mut().zip(&sky_mesh.vertices) { + let source_color = match layer.kind { + // Native mode 4 receives a white DIFFUSE and + // chooses nebula/stars through TEX1 alpha. + SkyPassKind::NebulaStars | SkyPassKind::Clouds => [1.0; 4], + SkyPassKind::DomeGradient => { + gradient.clamp_dome_color(source.color).rgba() + } + SkyPassKind::ScreenGradient => unreachable!(), + }; + target.position = add3( + add3(source.position, camera.position), + [0.0, 0.0, layer.translation_z], + ); + target.color = [source_color[0], source_color[1], source_color[2]]; + target.uv = layer + .stage + .and_then(|stage| source.uv.get(stage).copied()) + .unwrap_or([0.0, 0.0]); + target.detail_uv = layer + .secondary_stage + .and_then(|stage| source.uv.get(stage).copied()) + .unwrap_or([0.0, 0.0]); + target.normal = sky_normal(source.normal); + target.overlay_alpha = source_color[3]; + } + } + } + let material_index = active_materials + .get(&range.range_index) + .map(|state| state.material_index) + .unwrap_or(layer.material_index); + if !active_materials.contains_key(&range.range_index) { + renderer + .set_material_alphas(material_index, layer.diffuse_alpha.clamp(0.0, 1.0), 0.0) + .map_err(|error| format!("update sky material alpha: {error}"))?; + } + if layer.kind == SkyPassKind::Clouds { + let directional_rgb = pass_frame + .material_color + .unwrap_or(layer.static_directional_rgb); + renderer + .set_material_lighting( + material_index, + directional_rgb, + layer.static_additive_rgb, + ) + .map_err(|error| format!("update cloud material lighting: {error}"))?; + } + renderer + .set_draw_range_index_count( + range.range_index, + if layer.kind == SkyPassKind::ScreenGradient { + 6 + } else { + u32::try_from(sky_mesh.indices.len()) + .map_err(|_| "sky index count exceeds u32".to_string())? + }, + ) + .map_err(|error| format!("update sky draw range: {error}"))?; + } + + // Flares share their source material row, so their packed alpha lives + // in each vertex. Upload the shared phase alpha once before emitting + // the twelve fixed-capacity ranges. + let mut flare_materials = HashMap::new(); + for sprite in &self.sprites { + if sprite.flare_index.is_some() + && !active_materials.contains_key(&sprite.range.range_index) + { + flare_materials.insert(sprite.material_index, sprite.diffuse_alpha); + } + } + for (material_index, diffuse_alpha) in flare_materials { + renderer + .set_material_alphas(material_index, diffuse_alpha.clamp(0.0, 1.0), 0.0) + .map_err(|error| format!("update sky flare alpha: {error}"))?; + } + for sprite in &self.sprites { + let mut active = false; + let mut vertices = None; + let mut material_alpha = 0.0; + if let Some(flare_index) = sprite.flare_index { + if let Some(flare) = + optics.and_then(|optics| optics.flares.get(flare_index).copied()) + { + let direction = screen_ray_from_pixels(camera, flare.center_pixels, viewport); + active = flare.active && direction.is_some() && flare.color[3] > 0.0; + if let Some(direction) = direction { + vertices = Some(sprite_quad_vertices_pixels( + camera, + direction, + (camera.far_plane * 0.5).max(camera.near_plane * 2.0), + flare.half_size_pixels, + flare.half_size_pixels, + viewport, + [flare.color[0], flare.color[1], flare.color[2]], + flare.color[3], + flare.uv, + )); + } + } + } else if let Some(frame) = sprite_frame_for_row(sky_frame, sprite.row) { + let projected = project_direction_pixels(camera, frame.direction, viewport); + active = frame.active + && frame.intensity > 0.0 + && projected.is_some_and(|center| { + center[0] >= 0.0 + && center[0] <= viewport[0] + && center[1] >= 0.0 + && center[1] <= viewport[1] + }); + if active { + if let Ok([half_width, half_height]) = sprite_half_size_pixels( + frame.size_values, + viewport, + horizontal_fov(camera, viewport[0] / viewport[1].max(1.0)), + ) { + vertices = Some(sprite_quad_vertices_pixels( + camera, + frame.direction, + (camera.far_plane * 0.5).max(camera.near_plane * 2.0), + half_width, + half_height, + viewport, + frame.color, + frame.alpha, + frame.uv, + )); + } else { + active = false; + } + } + material_alpha = if active { + // The packed sprite alpha is carried by the vertex. The + // MAT0 phase alpha remains the material multiplier; + // intensity controls lifecycle visibility, not a second + // alpha fade. + sprite.diffuse_alpha + } else { + 0.0 + }; + } + if active { + if let Some(vertices) = vertices { + let destination = mesh + .vertices + .get_mut(sprite.range.vertex_start..sprite.range.vertex_start + 4) + .ok_or_else(|| { + "sky sprite vertex allocation is out of bounds".to_string() + })?; + destination.copy_from_slice(&vertices); + } else { + active = false; + } + } + if sprite.flare_index.is_none() { + let material_index = active_materials + .get(&sprite.range.range_index) + .map(|state| state.material_index) + .unwrap_or(sprite.material_index); + // The animation update above has already restored the + // sampled phase alpha for an active animated material. A + // hidden sprite still needs an explicit zero, while a static + // sprite uses its authored material alpha when visible. + let should_write_alpha = + !active || !active_materials.contains_key(&sprite.range.range_index); + if should_write_alpha { + renderer + .set_material_alphas(material_index, material_alpha.clamp(0.0, 1.0), 0.0) + .map_err(|error| format!("update sky sprite alpha: {error}"))?; + } + } + renderer + .set_draw_range_index_count(sprite.range.range_index, if active { 6 } else { 0 }) + .map_err(|error| format!("update sky sprite draw range: {error}"))?; + } + + let mut rain_count = 0usize; + let mut snow_count = 0usize; + let mut lightning = None; + for primitive in &environment_frame.primitives { + match primitive { + EnvironmentPrimitive::Particle { + kind, + color, + screen, + .. + } => { + let (range, slot) = match kind { + PrecipitationKind::Rain if rain_count < ENVIRONMENT_PARTICLE_SLOTS => { + let slot = rain_count; + rain_count += 1; + (self.rain, slot) + } + PrecipitationKind::Snow if snow_count < ENVIRONMENT_PARTICLE_SLOTS => { + let slot = snow_count; + snow_count += 1; + (self.snow, slot) + } + _ => continue, + }; + let vertices = + precipitation_quad_vertices(*kind, screen, *color, camera, viewport); + let start = range.vertex_start + slot * 4; + let destination = mesh.vertices.get_mut(start..start + 4).ok_or_else(|| { + "precipitation vertex allocation is out of bounds".to_string() + })?; + destination.copy_from_slice(&vertices); + } + EnvironmentPrimitive::Lightning(bolt) if lightning.is_none() => { + lightning = Some(bolt); + } + EnvironmentPrimitive::Lightning(_) => {} + } + } + renderer + .set_draw_range_index_count( + self.rain.range_index, + u32::try_from(rain_count * 6) + .map_err(|_| "rain draw count exceeds u32".to_string())?, + ) + .map_err(|error| format!("update rain draw range: {error}"))?; + renderer + .set_draw_range_index_count( + self.snow.range_index, + u32::try_from(snow_count * 6) + .map_err(|_| "snow draw count exceeds u32".to_string())?, + ) + .map_err(|error| format!("update snow draw range: {error}"))?; + + let lightning_alpha = if let Some(bolt) = lightning { + let vertices = lightning_quad_vertices(bolt); + let destination = mesh + .vertices + .get_mut(self.lightning.vertex_start..self.lightning.vertex_start + 4) + .ok_or_else(|| "lightning vertex allocation is out of bounds".to_string())?; + destination.copy_from_slice(&vertices); + bolt.intensity.clamp(0.0, 1.0) + } else { + 0.0 + }; + let lightning_material_index = active_materials + .get(&self.lightning.range_index) + .map(|state| state.material_index) + .unwrap_or(self.lightning_material_index); + renderer + .set_material_alphas(lightning_material_index, lightning_alpha, 0.0) + .map_err(|error| format!("update lightning material alpha: {error}"))?; + renderer + .set_draw_range_index_count( + self.lightning.range_index, + if lightning.is_some() { 6 } else { 0 }, + ) + .map_err(|error| format!("update lightning draw range: {error}"))?; + Ok(()) + } +} + +fn sprite_frame_for_row<'frame, 'materials>( + sky_frame: &'frame SkyFrame<'materials>, + row: usize, +) -> Option<SkySpriteFrame<'materials>> { + sky_frame + .sprites + .iter() + .copied() + .find(|sprite| sprite.row == row) +} + +fn horizontal_fov(camera: &FreeFlightCamera, aspect: f32) -> f32 { + let aspect = if aspect.is_finite() && aspect > 0.0 { + aspect + } else { + 16.0 / 9.0 + }; + 2.0 * ((camera.vertical_fov.clamp(0.05, 3.0) * 0.5).tan() * aspect).atan() +} + +/// Tests the camera-to-sun segment against the validated mission terrain. +/// +/// The native CSun caller starts a half-unit in front of the camera along the +/// normalized geometry direction, then queries the finite segment ending at +/// the sun center. `Land.msh` stores Z in the native 32x height unit while the +/// renderer's world projection divides it by 32, so only the Z components are +/// scaled before the terrain query. The ray parameter remains normalized to +/// the finite segment and is therefore independent of world distance. +#[cfg(test)] +fn native_sun_unoccluded( + terrain: &TerrainWorld, + camera: &FreeFlightCamera, + direction: [f32; 3], +) -> bool { + let empty_mesh = VulkanStaticMesh { + vertices: Vec::new(), + indices: Vec::new(), + draw_ranges: Vec::new(), + }; + native_sun_unoccluded_with_world(terrain, &empty_mesh, &[], camera, direction) +} + +fn native_sun_unoccluded_with_world( + terrain: &TerrainWorld, + world_mesh: &VulkanStaticMesh, + sun_occlusion_range_indices: &[usize], + camera: &FreeFlightCamera, + direction: [f32; 3], +) -> bool { + // Native CSun requests object kinds 1, 3, 4, and 10 (the aggregate + // object mask is 0x41A) and excludes full surface flag 0x20. The preview + // range list is built from roots whose raw mission kind is one of those + // four eligible kinds; fixed atmosphere ranges are remapped out before + // this call. + let Some(direction) = normalize3(direction) else { + return false; + }; + let sun_center = add3( + camera.position, + scale3( + direction, + (camera.far_plane * 0.5).max(camera.near_plane * 2.0), + ), + ); + let start = add3(camera.position, scale3(direction, 0.5)); + let segment = sub3(sun_center, start); + if !sun_center + .iter() + .chain(start.iter()) + .chain(segment.iter()) + .all(|value| value.is_finite()) + { + return false; + } + if dot3(segment, segment) <= f32::EPSILON { + return true; + } + let origin = [start[0], start[1], start[2] * 32.0]; + let terrain_segment = [segment[0], segment[1], segment[2] * 32.0]; + if let Ok(Some(hit)) = terrain.raycast_excluding( + origin, + terrain_segment, + FullSurfaceMask(0), + FullSurfaceMask(0x0000_0020), + ) { + if hit.distance.is_finite() && hit.distance <= 1.0 { + return false; + } + } + !world_mesh_sun_occluded(world_mesh, sun_occlusion_range_indices, start, segment) +} + +fn world_mesh_sun_occluded( + mesh: &VulkanStaticMesh, + range_indices: &[usize], + origin: [f32; 3], + direction: [f32; 3], +) -> bool { + for &range_index in range_indices { + let Some(range) = mesh.draw_ranges.get(range_index).copied() else { + continue; + }; + // The native world query receives the compact exclusion bit 0x8, + // which is the world-space representation of full surface flag 0x20. + if range.batch_flags & 0x0008 != 0 { + continue; + } + let Ok(first_index) = usize::try_from(range.first_index) else { + continue; + }; + let Ok(index_count) = usize::try_from(range.index_count) else { + continue; + }; + let Some(indices) = mesh + .indices + .get(first_index..first_index.saturating_add(index_count)) + else { + continue; + }; + for triangle in indices.chunks_exact(3) { + let Ok(a) = usize::try_from(triangle[0]) else { + continue; + }; + let Ok(b) = usize::try_from(triangle[1]) else { + continue; + }; + let Ok(c) = usize::try_from(triangle[2]) else { + continue; + }; + let (Some(&a), Some(&b), Some(&c)) = ( + mesh.vertices.get(a), + mesh.vertices.get(b), + mesh.vertices.get(c), + ) else { + continue; + }; + if segment_intersects_triangle(origin, direction, a.position, b.position, c.position) { + return true; + } + } + } + false +} + +fn segment_intersects_triangle( + origin: [f32; 3], + direction: [f32; 3], + a: [f32; 3], + b: [f32; 3], + c: [f32; 3], +) -> bool { + let edge1 = sub3(b, a); + let edge2 = sub3(c, a); + let pvec = cross3(direction, edge2); + let determinant = dot3(edge1, pvec); + if !determinant.is_finite() || determinant.abs() <= f32::EPSILON { + return false; + } + let inverse = determinant.recip(); + let tvec = sub3(origin, a); + let u = dot3(tvec, pvec) * inverse; + if !u.is_finite() || !(0.0..=1.0).contains(&u) { + return false; + } + let qvec = cross3(tvec, edge1); + let v = dot3(direction, qvec) * inverse; + if !v.is_finite() || v < 0.0 || u + v > 1.0 { + return false; + } + let distance = dot3(edge2, qvec) * inverse; + 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. +fn first_indexed_vertex_distance( + mesh: &VulkanStaticMesh, + range_index: usize, + camera_position: [f32; 3], +) -> Result<f32, String> { + let range: VulkanStaticDrawRange = *mesh + .draw_ranges + .get(range_index) + .ok_or_else(|| "world draw range index is out of bounds".to_string())?; + let first_index = usize::try_from(range.first_index) + .map_err(|_| "world draw range first index is not addressable".to_string())?; + let vertex_index = + usize::try_from(*mesh.indices.get(first_index).ok_or_else(|| { + "world draw range first index is outside the index buffer".to_string() + })?) + .map_err(|_| "world draw range vertex index is not addressable".to_string())?; + let vertex = mesh + .vertices + .get(vertex_index) + .ok_or_else(|| "world draw range vertex index is outside the vertex buffer".to_string())?; + let delta = sub3(vertex.position, camera_position); + let distance = dot3(delta, delta).sqrt(); + distance + .is_finite() + .then_some(distance) + .ok_or_else(|| "world draw range sort distance is non-finite".to_string()) +} + +fn project_direction_pixels( + camera: &FreeFlightCamera, + direction: [f32; 3], + viewport: [f32; 2], +) -> Option<[f32; 2]> { + let direction = normalize3(direction)?; + let width = viewport[0]; + let height = viewport[1]; + if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 { + return None; + } + let depth = dot3(camera.forward(), direction); + if !depth.is_finite() || depth <= camera.near_plane.max(0.001) { + return None; + } + let aspect = width / height; + let half_vertical = (camera.vertical_fov.clamp(0.05, 3.0) * 0.5).tan(); + let half_horizontal = half_vertical * aspect; + let ndc_x = dot3(camera.right(), direction) / (depth * half_horizontal); + let ndc_y = dot3(camera.up(), direction) / (depth * half_vertical); + let pixels = [(ndc_x * 0.5 + 0.5) * width, (0.5 - ndc_y * 0.5) * height]; + pixels + .iter() + .all(|value| value.is_finite()) + .then_some(pixels) +} + +fn screen_ray_from_pixels( + camera: &FreeFlightCamera, + pixels: [f32; 2], + viewport: [f32; 2], +) -> Option<[f32; 3]> { + if !pixels.iter().all(|value| value.is_finite()) + || !viewport.iter().all(|value| value.is_finite()) + || viewport[0] <= 0.0 + || viewport[1] <= 0.0 + { + return None; + } + let aspect = viewport[0] / viewport[1]; + let half_vertical = (camera.vertical_fov.clamp(0.05, 3.0) * 0.5).tan(); + let half_horizontal = half_vertical * aspect; + let ndc_x = pixels[0] / viewport[0] * 2.0 - 1.0; + let ndc_y = 1.0 - pixels[1] / viewport[1] * 2.0; + normalize3(add3( + add3( + camera.forward(), + scale3(camera.right(), ndc_x * half_horizontal), + ), + scale3(camera.up(), ndc_y * half_vertical), + )) +} + +fn sprite_quad_vertices_pixels( + camera: &FreeFlightCamera, + direction: [f32; 3], + distance: f32, + half_width_pixels: f32, + half_height_pixels: f32, + viewport: [f32; 2], + color: [f32; 3], + alpha: f32, + uv: [[f32; 2]; 4], +) -> [VulkanStaticVertex; 4] { + let direction = normalize3(direction).unwrap_or(camera.forward()); + let center = add3(camera.position, scale3(direction, distance)); + let depth = dot3(camera.forward(), direction) + .mul_add(distance, 0.0) + .max(camera.near_plane.max(0.001)); + let width = viewport[0].max(1.0); + let height = viewport[1].max(1.0); + let aspect = width / height; + let vertical_fov = camera.vertical_fov.clamp(0.05, 3.0); + let horizontal_fov_radians = horizontal_fov(camera, aspect); + let world_per_pixel_x = 2.0 * depth * (horizontal_fov_radians * 0.5).tan() / width; + let world_per_pixel_y = 2.0 * depth * (vertical_fov * 0.5).tan() / height; + let right = scale3( + camera.right(), + half_width_pixels.max(0.0) * world_per_pixel_x, + ); + let up = scale3(camera.up(), half_height_pixels.max(0.0) * world_per_pixel_y); + // Native sun and flare UVs are ordered left-top, left-bottom, right-bottom, + // right-top. The shared index order is bottom-left, bottom-right, top-left, + // top-right. + let uv = [uv[1], uv[2], uv[0], uv[3]]; + [ + environment_vertex_with_alpha(sub3(sub3(center, right), up), color, alpha, uv[0]), + environment_vertex_with_alpha(add3(sub3(center, up), right), color, alpha, uv[1]), + environment_vertex_with_alpha(add3(sub3(center, right), up), color, alpha, uv[2]), + environment_vertex_with_alpha(add3(add3(center, right), up), color, alpha, uv[3]), + ] +} + +fn precipitation_quad_vertices( + kind: PrecipitationKind, + screen: &ScreenBillboard, + color: [f32; 4], + camera: &FreeFlightCamera, + viewport: [f32; 2], +) -> [VulkanStaticVertex; 4] { + let rgb = [color[0], color[1], color[2]]; + let depths = match kind { + PrecipitationKind::Rain => [ + screen.tail_depth, + screen.tail_depth, + screen.head_depth, + screen.head_depth, + ], + PrecipitationKind::Snow => [screen.head_depth; 4], + }; + std::array::from_fn(|index| { + environment_vertex_with_alpha( + world_from_ndc(camera, screen.corners[index], depths[index], viewport), + rgb, + color[3], + screen.uv[index], + ) + }) +} + +fn world_from_ndc( + camera: &FreeFlightCamera, + ndc: [f32; 2], + depth: f32, + viewport: [f32; 2], +) -> [f32; 3] { + let depth = if depth.is_finite() { + depth.max(camera.near_plane.max(0.001)) + } else { + camera.near_plane.max(0.001) + }; + let aspect = viewport[0].max(1.0) / viewport[1].max(1.0); + let half_vertical = (camera.vertical_fov.clamp(0.05, 3.0) * 0.5).tan(); + let half_horizontal = half_vertical * aspect; + let offset = add3( + scale3(camera.right(), ndc[0] * half_horizontal), + scale3(camera.up(), ndc[1] * half_vertical), + ); + add3( + camera.position, + scale3(add3(camera.forward(), offset), depth), + ) +} + +fn lightning_quad_vertices( + bolt: &fparkan_fx::environment::LightningBolt, +) -> [VulkanStaticVertex; 4] { + std::array::from_fn(|index| { + environment_vertex(bolt.quad.vertices[index], [1.0; 3], bolt.quad.uv[index]) + }) +} + +/// CPU atmosphere state shared by the interactive preview and the renderer's +/// 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. +struct DynamicEnvironment { + schedule: TypedAtmosphere, + sky: SkySystem, + environment: EnvironmentSystem, + materials: SkyMaterials, + material_assets: HashMap<String, EnvironmentMaterialAsset>, + schedule_offset_seconds: f32, + time_seconds: f32, +} + +impl DynamicEnvironment { + fn update( + &mut self, + dt_seconds: f32, + camera: &FreeFlightCamera, + aspect: f32, + viewport: [f32; 2], + renderer: &mut VulkanSmokeRenderer, + mut audio: Option<&mut audio::GameAudio>, + ) -> Result<(SkyFrame<'_>, SkyMesh, EnvironmentFrame), String> { + let dt_seconds = if dt_seconds.is_finite() { + dt_seconds.max(0.0) + } else { + 0.0 + }; + self.time_seconds = if self.time_seconds.is_finite() { + self.time_seconds + dt_seconds + } else { + 0.0 + }; + let absolute_time_seconds = self.schedule_offset_seconds + self.time_seconds; + let atmosphere = self.schedule.sample(absolute_time_seconds); + let mut sky_mesh = self.sky.mesh().clone(); + let sky_frame = + SkyFrame::from_atmosphere(&atmosphere, &self.materials, absolute_time_seconds); + sky_mesh.update_colors(&sky_frame.sky); + if let Some(audio) = audio.as_mut() { + let primary_rgb = if sky_frame.sun.active { + sky_frame.sun.color + } else if sky_frame.moon.active { + sky_frame.moon.color + } else { + [0.0; 3] + }; + audio + .update_ambience(dt_seconds, primary_rgb) + .map_err(|error| format!("update ambient variation: {error}"))?; + } + + let mut uniforms = renderer.frame_uniforms(); + uniforms.clip_from_world = camera.vulkan_camera(aspect).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; + uniforms.fog_color = sky_frame.fog.color; + 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 = FxCamera::with_projection( + camera.position, + camera.forward(), + camera.right(), + camera.up(), + camera.vertical_fov, + aspect, + ) + .with_viewport(viewport); + let environment_frame = self.environment.update_atmosphere_with_materials( + dt_seconds, + &atmosphere, + &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 + .handle_sound_event(sound) + .map_err(|error| format!("play environment sound: {error}"))?; + } + } + Ok((sky_frame, sky_mesh, environment_frame)) + } +} + +fn load_environment( + root: &Path, + mission: &str, + atmosphere_seconds: Option<f32>, +) -> Result<Option<DynamicEnvironment>, String> { + let mission_dir = mission_asset_directory(root, mission)?; + let Some(sky_path) = find_mission_file(&mission_dir, "sky.ske")? else { + return Ok(None); + }; + let sky_bytes = std::fs::read(&sky_path) + .map_err(|error| format!("read atmosphere {}: {error}", sky_path.display()))?; + let schedule = TypedAtmosphere::parse(&sky_bytes) + .map_err(|error| format!("parse atmosphere {}: {error}", sky_path.display()))?; + + let sky_wea_path = match find_sibling_file(&sky_path, "sky.wea") { + Some(path) => Some(path), + None => find_mission_file(&mission_dir, "sky.wea")?, + } + .ok_or_else(|| format!("selected atmosphere {} has no sky.wea", sky_path.display()))?; + let materials = SkyMaterials::parse( + &std::fs::read(&sky_wea_path) + .map_err(|error| format!("read sky materials {}: {error}", sky_wea_path.display()))?, + ) + .map_err(|error| format!("parse sky materials {}: {error}", sky_wea_path.display()))?; + let mut sky_material_loader = StandaloneWearMaterialLoader::new(root); + let mut material_assets = + load_standalone_wear_named_material_textures_rgba8_and_phases_with_documents_from_root( + root, + &sky_wea_path, + 0, + ) + .map_err(|error| { + format!( + "resolve sky material WEAR {}: {error}", + sky_wea_path.display() + ) + })? + .into_iter() + .enumerate() + .map( + |(material_index, (name, mips, phase, document))| -> Result<_, String> { + let initial_phase = phase; + let mut phase_textures = Vec::with_capacity(document.phases.len()); + let mut phases = Vec::with_capacity(document.phases.len()); + for phase_index in 0..document.phases.len() { + let phase_index_u16 = u16::try_from(phase_index).unwrap_or(u16::MAX); + let (phase_mips, sampled_phase) = if phase_index == 0 { + (mips.clone(), initial_phase) + } else { + sky_material_loader + .load( + &sky_wea_path, + u16::try_from(material_index).unwrap_or(u16::MAX), + phase_index_u16, + ) + .map_err(|error| { + format!("resolve sky material phase {phase_index}: {error}") + })? + }; + phase_textures.push(vulkan_texture_from_rgba_mips(phase_mips, "sky material")?); + phases.push(sampled_phase); + } + let texture = phase_textures + .first() + .cloned() + .ok_or_else(|| "sky material has no MAT0 phases".to_string())?; + Ok(( + String::from_utf8_lossy(&name).into_owned(), + EnvironmentMaterialAsset { + texture, + phase: initial_phase, + document, + phase_textures, + phases, + }, + )) + }, + ) + .collect::<Result<HashMap<_, _>, String>>()?; + let effect_bytes = load_resource_entry_bytes_from_root(root, "effects.rlb", "env_lightning") + .map_err(|error| format!("load effects.rlb/env_lightning: {error}"))?; + let lightning_effect = decode_env_lightning_fxid(effect_bytes) + .map_err(|error| format!("decode effects.rlb/env_lightning: {error}"))?; + if !lightning_effect + .visual + .archive + .eq_ignore_ascii_case("material.lib") + { + return Err(format!( + "env_lightning visual archive must be material.lib, got {:?}", + lightning_effect.visual.archive + )); + } + let (lightning_mips, lightning_phase, lightning_document) = + load_material_name_texture_rgba8_and_phase_with_document_from_root( + root, + &lightning_effect.visual.name, + 0, + ) + .map_err(|error| { + format!( + "resolve env_lightning MAT0 {}: {error}", + lightning_effect.visual.name + ) + })?; + let mut lightning_phase_textures = vec![vulkan_texture_from_rgba_mips( + lightning_mips, + "env_lightning material", + )?]; + let mut lightning_phases = vec![lightning_phase]; + for phase_index in 1..lightning_document.phases.len() { + let (phase_mips, phase, _document) = + load_material_name_texture_rgba8_and_phase_with_document_from_root( + root, + &lightning_effect.visual.name, + u16::try_from(phase_index).unwrap_or(u16::MAX), + ) + .map_err(|error| { + format!( + "resolve env_lightning MAT0 phase {phase_index} {}: {error}", + lightning_effect.visual.name + ) + })?; + lightning_phase_textures.push(vulkan_texture_from_rgba_mips( + phase_mips, + "env_lightning material", + )?); + lightning_phases.push(phase); + } + let lightning_texture = lightning_phase_textures + .first() + .cloned() + .ok_or_else(|| "env_lightning MAT0 has no phases".to_string())?; + material_assets.insert( + lightning_effect.visual.name.clone(), + EnvironmentMaterialAsset { + texture: lightning_texture, + phase: lightning_phase, + document: lightning_document, + phase_textures: lightning_phase_textures, + phases: lightning_phases, + }, + ); + let initial_offset_seconds = schedule.initial_offset_seconds() as f32; + let sky = SkySystem::new(Default::default(), materials.clone()) + .map_err(|error| format!("build sky geometry: {error}"))?; + let time_seconds = atmosphere_seconds + .map(|seconds| seconds - initial_offset_seconds) + .unwrap_or(0.0); + let mut environment = EnvironmentSystem::new(0x4650_4152_4B41_4E_u64); + environment.set_lightning_effect(lightning_effect); + Ok(Some(DynamicEnvironment { + schedule, + sky, + environment, + materials, + material_assets, + schedule_offset_seconds: initial_offset_seconds, + time_seconds, + })) +} + +/// Returns the host directory containing the selected mission's assets. +/// Mission keys are the same relative paths accepted by the runtime VFS; the +/// host root is only used here for the small set of loose mission-local files +/// that are not archive entries (`sky.ske` and `sky.wea`). +fn mission_asset_directory(root: &Path, mission: &str) -> Result<PathBuf, String> { + let mission_path = Path::new(mission); + if mission_path.is_absolute() { + return Err("mission environment path must be relative to --root".to_string()); + } + let directory = mission_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let directory = root.join(directory); + if !directory.is_dir() { + return Err(format!( + "selected mission asset directory does not exist: {}", + directory.display() + )); + } + Ok(directory) +} + +/// Finds one mission-local loose environment file. A direct sibling wins; +/// nested `env`, `environment`, or `planet` layouts are accepted only when +/// they contain a single matching file, so a second unrelated candidate never +/// changes the selected mission silently. +fn find_mission_file(root: &Path, name: &str) -> Result<Option<PathBuf>, String> { + let direct = root.join(name); + if direct.is_file() { + return Ok(Some(direct)); + } + let mut stack = vec![root.to_path_buf()]; + let mut matches = Vec::new(); + while let Some(directory) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&directory) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_symlink() { + continue; + } + if file_type.is_dir() { + stack.push(path); + } else if file_type.is_file() + && path + .file_name() + .is_some_and(|file_name| file_name.eq_ignore_ascii_case(name)) + { + matches.push(path); + } + } + } + matches.sort(); + match matches.as_slice() { + [] => Ok(None), + [path] => Ok(Some(path.clone())), + _ => Err(format!( + "selected mission has multiple {name} files: {}", + matches + .iter() + .map(|path| path.display().to_string()) + .collect::<Vec<_>>() + .join(", ") + )), + } +} + +fn find_sibling_file(source: &Path, name: &str) -> Option<PathBuf> { + source + .parent() + .map(|parent| parent.join(name)) + .filter(|path| path.is_file()) +} + +fn add3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [left[0] + right[0], left[1] + right[1], left[2] + right[2]] +} + +fn add3_in_place(target: &mut [f32; 3], value: [f32; 3]) { + *target = add3(*target, value); +} + +fn sub3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [left[0] - right[0], left[1] - right[1], left[2] - right[2]] +} + +fn scale3(value: [f32; 3], factor: f32) -> [f32; 3] { + [value[0] * factor, value[1] * factor, value[2] * factor] +} + +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])) +} + +fn cross3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [ + left[1] * right[2] - left[2] * right[1], + left[2] * right[0] - left[0] * right[2], + left[0] * right[1] - left[1] * right[0], + ] +} + +fn length3(value: [f32; 3]) -> Option<f32> { + let length = dot3(value, value).sqrt(); + length + .is_finite() + .then_some(length) + .filter(|length| *length > f32::EPSILON) +} + +fn normalize3(value: [f32; 3]) -> Option<[f32; 3]> { + let inverse = length3(value)?.recip(); + Some(scale3(value, inverse)) +} + +fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] { + let mut result = [0.0; 16]; + for row in 0..4 { + for column in 0..4 { + result[row * 4 + column] = (0..4) + .map(|inner| left[row * 4 + inner] * right[inner * 4 + column]) + .sum(); + } + } + result +} + struct StaticPreviewScene { mesh: VulkanStaticMesh, materials: Vec<VulkanStaticMaterial>, + material_animations: Vec<MaterialAnimationBinding>, + sun_occlusion_range_indices: Vec<usize>, + shadow_casters: Vec<ShadowCaster>, + shadow_receivers: Vec<ShadowTriangle>, + shadow_material_index: usize, + terrain: Arc<TerrainWorld>, camera: VulkanStaticCamera, + free_camera: Option<FreeFlightCamera>, camera_mode: &'static str, mesh_components: usize, terrain_components: usize, } -/// Projects the mission terrain plus every MSH component of the explicitly -/// bounded static-preview roots into one diagnostic XY frame. +#[derive(Clone, Copy, Debug, Default)] +struct StartupTimings { + mission_assets: Duration, + terrain_materials: Duration, + sky: Duration, + gpu_initialization: Duration, +} + +#[derive(Clone, Copy, Debug, Default)] +struct ShadowFrameEvidence { + visible_casters: usize, + projected_indices: usize, +} + +fn elapsed_ms(duration: Duration) -> f64 { + duration.as_secs_f64() * 1_000.0 +} + +fn unit_component_is_visible(index: usize, source_type: Option<u32>) -> bool { + index == 0 || source_type == Some(PROTOTYPE_TYPE_EXTO) +} + +fn native_sun_object_kind_eligible(source_type: Option<u32>) -> bool { + native_shadow_kind(source_type).is_some() +} + +const PROTOTYPE_TYPE_FORT: u32 = u32::from_le_bytes(*b"FORT"); +const PROTOTYPE_TYPE_BTLU: u32 = u32::from_le_bytes(*b"BTLU"); +const PROTOTYPE_TYPE_STAT: u32 = u32::from_le_bytes(*b"STAT"); + +/// Maps the resolved registry type to the native object-kind selector used by +/// CSun/CShade. The mission object's raw kind is an unrelated TMA field; the +/// renderer receives the registry provenance from the prepared visual. +fn native_shadow_kind(source_type: Option<u32>) -> Option<u32> { + match source_type { + Some(PROTOTYPE_TYPE_FORT) => Some(3), + Some(PROTOTYPE_TYPE_BTLU) => Some(4), + Some(PROTOTYPE_TYPE_STAT) => Some(10), + _ => None, + } +} + +fn compose_unit_pose(parent: VulkanNodePose, child: VulkanNodePose) -> VulkanNodePose { + let rotated = rotate_unit_vector(child.translation, parent.rotation); + VulkanNodePose { + translation: [ + parent.translation[0] + rotated[0], + parent.translation[1] + rotated[1], + parent.translation[2] + rotated[2], + ], + rotation: multiply_unit_quaternions(parent.rotation, child.rotation), + } +} + +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]); + let ty = 2.0 * (z * value[0] - x * value[2]); + let tz = 2.0 * (x * value[1] - y * value[0]); + [ + value[0] + w * tx + (y * tz - z * ty), + value[1] + w * ty + (z * tx - x * tz), + value[2] + w * tz + (x * ty - y * tx), + ] +} + +fn multiply_unit_quaternions(left: [f32; 4], right: [f32; 4]) -> [f32; 4] { + let [lx, ly, lz, lw] = left; + let [rx, ry, rz, rw] = right; + [ + lw * rx + lx * rw + ly * rz - lz * ry, + lw * ry - lx * rz + ly * rw + lz * rx, + lw * rz + lx * ry - ly * rx + lz * rw, + lw * rw - lx * rx - ly * ry - lz * rz, + ] +} + +/// Projects the mission terrain plus every MSH component of the selected roots +/// into one shared world-space mesh. /// -/// This intentionally takes only the first MAT0 texture request for each MSH -/// batch selector. Terrain maps its proven low-byte packed tag channel through -/// the map-local `Land2.wea` table. The high-byte `Land1.wea` channel is -/// retained on draw ranges but awaits a recovered blend equation; orientation, -/// later material phases, animation, lightmaps and gameplay visibility remain -/// outside this bridge. +/// Terrain resolves its packed low-byte base and high-byte overlay through +/// map-local `Land1.wea`, then pairs each layer with its `Land2.wea` +/// microtexture and type-18 UV stream. Type-14 alpha remains a continuous +/// interpolated overlay mask. MSH component hierarchy, animation sampling, +/// lightmaps, and gameplay visibility are handled by the preview bridge below. + +fn static_preview_node_times( + node_count: usize, + bindings: &[PreparedControlNodeBinding], +) -> Result<Vec<Option<AnimationTime>>, String> { + let mut node_times = vec![Some(AnimationTime(0.0)); node_count]; + for binding in bindings { + // Disabled rows are filtered while loading CTLD, but preserve the + // native guard here in case a caller constructs MissionAssets itself. + if binding.flags & 4 != 0 { + continue; + } + if binding.node_index >= node_count { + return Err(format!( + "control binding node {} is outside model node count {node_count}", + binding.node_index + )); + } + if !binding.frame_a.is_finite() + || !binding.frame_b.is_finite() + || !binding.initial_blend.is_finite() + || binding.frame_a < 0.0 + || binding.frame_b < 0.0 + { + return Err(format!( + "control binding for node {} contains an invalid frame or blend", + binding.node_index + )); + } + + let mut blend = binding.initial_blend; + if binding.flags & 1 != 0 { + if blend > 1.0 { + blend -= 1.0; + } + if blend < 0.0 { + blend += 1.0; + } + } else { + blend = blend.clamp(0.0, 1.0); + } + if binding.flags & 2 != 0 { + blend = 1.0 - blend; + } + let sample_time = (1.0 - blend) * binding.frame_a + blend * binding.frame_b; + if !sample_time.is_finite() || sample_time < 0.0 { + return Err(format!( + "control binding for node {} produced a negative or non-finite sample time unsupported by the static sampler", + binding.node_index + )); + } + // Source order is significant: native initialization writes each + // enabled row in turn, so a later row for the same node wins. + node_times[binding.node_index] = Some(AnimationTime(sample_time)); + } + Ok(node_times) +} + +fn static_preview_component_pose_buffer( + model: &ModelAsset, + bindings: &[PreparedControlNodeBinding], + static_animation_frame: Option<u16>, +) -> Result<Option<NodePoseBuffer>, String> { + if let Some(frame) = static_animation_frame { + return Ok(node38_sampled_hierarchy(model, frame)); + } + let node_times = static_preview_node_times(model.node_count, bindings)?; + Ok(node38_sampled_hierarchy_at_times(model, &node_times) + .or_else(|| node38_fallback_hierarchy(model))) +} fn static_preview_mesh_and_materials( assets: &MissionAssets, @@ -141,197 +3528,373 @@ fn static_preview_mesh_and_materials( let terrain_mesh = terrain .source_mesh() .ok_or_else(|| "runtime terrain does not retain its validated source mesh".to_string())?; - let xy_frame = legacy_camera - .is_none() - .then(|| static_preview_xy_frame(assets, terrain, roots)) - .transpose()?; let mut mesh = VulkanStaticMesh { vertices: Vec::new(), indices: Vec::new(), draw_ranges: Vec::new(), }; let mut materials = Vec::new(); - let terrain_component = if legacy_camera.is_some() { - project_land_msh_to_static_mesh_in_legacy_world_space(terrain_mesh) - } else { - let frame = xy_frame.ok_or_else(|| "missing diagnostic XY frame".to_string())?; - project_land_msh_to_static_mesh_in_xy_frame(terrain_mesh, frame) - } - .map_err(|err| format!("project mission terrain for Vulkan: {err}"))?; + let mut material_animations = Vec::new(); + let mut sun_occlusion_range_indices = Vec::new(); + let mut shadow_casters = Vec::new(); + let shadow_receivers = terrain_shadow_receivers(terrain_mesh)?; + let mut terrain_material_loader = StandaloneWearMaterialLoader::new(root); + let terrain_component = project_land_msh_to_static_mesh_in_legacy_world_space(terrain_mesh) + .map_err(|err| format!("project mission terrain for Vulkan: {err}"))?; let terrain_materials = static_preview_terrain_base_materials( + &mut terrain_material_loader, root, land_msh_path, &terrain_component, &mut materials, + &mut material_animations, + static_material_phase, + )?; + append_static_preview_component( + &mut mesh, + terrain_component, + &terrain_materials, + Some(&mut material_animations), )?; - append_static_preview_component(&mut mesh, terrain_component, &terrain_materials)?; let mut mesh_components = 0; - for (object_index, root) in roots.iter().enumerate() { - for visual_id in assets.visuals_for_object(object_index) { - let visual = assets.visual_by_id(*visual_id).ok_or_else(|| { - format!( - "static preview root {object_index} references unknown visual {visual_id:?}" - ) - })?; - let Some(model_id) = visual.model_id else { + for (object_index, mission_root) in roots.iter().enumerate() { + let visual_ids = assets.visuals_for_object(object_index); + let records = &mission_root.unit_components; + let tree = if records.is_empty() { + None + } else { + Some(unit_component_tree(records).map_err(|error| { + format!("decode unit component tree for mission object {object_index}: {error}") + })?) + }; + if let Some(tree) = tree.as_ref() { + if tree.len() != visual_ids.len() { + return Err(format!( + "mission object {object_index} unit tree has {} records but {} visuals", + tree.len(), + visual_ids.len() + )); + } + } + let component_refs = visual_ids + .iter() + .map(|visual_id| { + let visual = assets.visual_by_id(*visual_id).ok_or_else(|| { + format!( + "static preview root {object_index} references unknown visual {visual_id:?}" + ) + })?; + let model = visual + .model_id + .map(|model_id| { + assets.model_by_id(model_id).ok_or_else(|| { + format!( + "static preview visual {visual_id:?} references unknown model {model_id:?}" + ) + }) + }) + .transpose()?; + Ok((visual, model)) + }) + .collect::<Result<Vec<_>, String>>()?; + let component_controls = assets.object_component_controls.get(object_index); + if component_controls.is_some_and(|controls| controls.len() != component_refs.len()) { + return Err(format!( + "mission object {object_index} has {} visuals but {} component controls", + component_refs.len(), + component_controls.map_or(0, Vec::len) + )); + } + let root_native_kind = component_refs + .first() + .and_then(|(visual, _)| native_shadow_kind(visual.source_type)); + let root_sun_eligible = native_sun_object_kind_eligible( + component_refs + .first() + .and_then(|(visual, _)| visual.source_type), + ); + let mut root_shadow_triangles = Vec::new(); + let mut root_shadow_spheres = Vec::new(); + let mut component_mount_poses = vec![None; component_refs.len()]; + let mut component_pose_buffers = vec![None; component_refs.len()]; + for (component_index, (visual, model)) in component_refs.iter().enumerate() { + let Some(model) = *model else { continue; }; - let model = assets.model_by_id(model_id).ok_or_else(|| { - format!("static preview visual {visual_id:?} references unknown model {model_id:?}") - })?; - let transform = LegacyIron3dEulerTransform { - translation: root.position, - orientation_radians: root.orientation_raw, - }; - let component = if legacy_camera.is_some() { - if let Some(frame) = static_animation_frame { - project_msh_to_static_mesh_in_world_space_with_node_sampled_poses( - &model.validated, - transform, - root.scale, - frame, - ) + let component_bindings = component_controls + .and_then(|controls| controls.get(component_index)) + .and_then(Option::as_ref) + .map_or(&[][..], |control| control.bindings.as_slice()); + component_pose_buffers[component_index] = static_preview_component_pose_buffer( + &model.validated, + component_bindings, + static_animation_frame, + )?; + let should_render = tree.as_ref().map_or(true, |_| { + unit_component_is_visible(component_index, visual.source_type) + }); + // Native AniMesh only resolves global MSH node sockets for EXTO + // geometry. Internal INTO components use controller/equipment + // slots, whose `parent_or_link` values are not MSH node indices. + // Do not ask the MSH hierarchy to resolve hidden internal parts. + let mount_pose = if should_render { + if let Some(tree) = tree.as_ref() { + let record = records.get(component_index).ok_or_else(|| { + format!("unit component {component_index} is outside decoded records") + })?; + let parent_index = tree[component_index].parent_index; + match parent_index { + None => None, + Some(parent_index) => { + let parent_visible = component_refs.get(parent_index).is_some_and( + |(parent_visual, _)| { + unit_component_is_visible( + parent_index, + parent_visual.source_type, + ) + }, + ); + if !parent_visible { + None + } else { + let parent_model = component_refs + .get(parent_index) + .and_then(|(_, model)| *model) + .ok_or_else(|| { + format!( + "unit component {component_index} parent {parent_index} has no model for mount socket" + ) + })?; + let parent_hierarchy = component_pose_buffers + .get(parent_index) + .and_then(Option::as_ref); + let parent_is_mounted = + component_mount_poses[parent_index].is_some(); + let socket = + usize::try_from(record.parent_or_link).map_err(|_| { + format!( + "unit component {component_index} has negative mount socket {}", + record.parent_or_link + ) + })?; + let parent_socket = match parent_hierarchy { + Some(hierarchy) if parent_is_mounted => { + node38_pose_relative_to_root_from_hierarchy( + hierarchy, socket, + ) + } + Some(hierarchy) => { + node38_pose_from_hierarchy(hierarchy, socket) + } + None if parent_is_mounted => node38_pose_relative_to_root( + &parent_model.validated, + static_animation_frame, + socket, + ), + None => node38_pose( + &parent_model.validated, + static_animation_frame, + socket, + ), + } + .ok_or_else(|| { + format!( + "unit component {component_index} mount socket {socket} is absent from parent {parent_index} (parent source={:?}, node_count={}, slots={}, animation={})", + parent_model.source, + parent_model.validated.node_count, + parent_model.validated.slots.len(), + parent_model.validated.animation.is_some(), + ) + })?; + let parent_root_pose = component_mount_poses[parent_index] + .unwrap_or(VulkanNodePose { + translation: [0.0; 3], + rotation: [0.0, 0.0, 0.0, 1.0], + }); + Some(compose_unit_pose(parent_root_pose, parent_socket)) + } + } + } } else { - project_msh_to_static_mesh_in_world_space_with_node_fallback_poses( - &model.validated, - transform, - root.scale, - ) + None } } else { - let frame = xy_frame.ok_or_else(|| "missing diagnostic XY frame".to_string())?; - if let Some(animation_frame) = static_animation_frame { - project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses( - &model.validated, - frame, - transform, - root.scale, - animation_frame, - ) - } else { - project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses( - &model.validated, - frame, - transform, - root.scale, - ) + None + }; + if let Some(mount_pose) = mount_pose { + component_mount_poses[component_index] = Some(mount_pose); + } + + let transform = LegacyIron3dEulerTransform { + translation: mission_root.position, + orientation_radians: mission_root.orientation_raw, + }; + if should_render { + let pose_buffer = component_pose_buffers[component_index].as_ref(); + let component = match (pose_buffer, mount_pose, static_animation_frame) { + (Some(poses), Some(mount), _) => { + project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount( + &model.validated, + transform, + mission_root.scale, + poses, + mount, + ) + } + (Some(poses), None, _) => { + project_msh_to_static_mesh_in_world_space_with_node_pose_buffer( + &model.validated, + transform, + mission_root.scale, + poses, + ) + } + (None, Some(mount), Some(frame)) => { + project_msh_to_static_mesh_in_world_space_with_node_sampled_poses_and_mount( + &model.validated, + transform, + mission_root.scale, + frame, + mount, + ) + } + (None, Some(mount), None) => { + project_msh_to_static_mesh_in_world_space_with_node_fallback_poses_and_mount( + &model.validated, + transform, + mission_root.scale, + mount, + ) + } + (None, None, Some(frame)) => { + project_msh_to_static_mesh_in_world_space_with_node_sampled_poses( + &model.validated, + transform, + mission_root.scale, + frame, + ) + } + (None, None, None) => { + project_msh_to_static_mesh_in_world_space_with_node_fallback_poses( + &model.validated, + transform, + mission_root.scale, + ) + } + } + .map_err(|err| format!("project mission MSH for Vulkan: {err}"))?; + let component_shadow_triangles = static_mesh_shadow_triangles(&component); + if root_native_kind.is_some() { + root_shadow_triangles.extend(component_shadow_triangles); + let node1_pose = mount_pose.and_then(|_| match pose_buffer { + Some(poses) => node38_pose_relative_to_root_from_hierarchy(poses, 1), + None => node38_pose_relative_to_root( + &model.validated, + static_animation_frame, + 1, + ), + }); + if let Some(sphere) = mounted_shadow_sphere( + model.validated.bounding_sphere_center, + model.validated.bounding_sphere_radius, + mount_pose, + node1_pose, + ) { + root_shadow_spheres.push(sphere); + } } + let selector_remap = static_preview_component_materials( + assets, + visual, + &component, + static_material_phase, + &mut materials, + &mut material_animations, + )?; + let lightmap_textures = static_preview_lightmap_textures(assets, visual)?; + let range_start = mesh.draw_ranges.len(); + append_static_preview_component_with_lightmaps( + &mut mesh, + component, + &selector_remap, + Some(&mut materials), + &lightmap_textures, + Some(&mut material_animations), + )?; + if root_sun_eligible { + sun_occlusion_range_indices.extend(range_start..mesh.draw_ranges.len()); + } + mesh_components += 1; + } + } + if let Some(native_kind) = root_native_kind { + let caster_id = u32::try_from(shadow_casters.len()) + .map_err(|_| "shadow caster id exceeds u32".to_string())?; + if let Some(caster) = aggregate_shadow_caster( + caster_id, + native_kind, + LegacyIron3dEulerTransform { + translation: mission_root.position, + orientation_radians: mission_root.orientation_raw, + }, + mission_root.scale, + root_shadow_triangles, + &root_shadow_spheres, + ) { + shadow_casters.push(caster); } - .map_err(|err| format!("project mission MSH for Vulkan: {err}"))?; - let selector_remap = static_preview_component_materials( - assets, - visual, - &component, - static_material_phase, - &mut materials, - )?; - append_static_preview_component(&mut mesh, component, &selector_remap)?; - mesh_components += 1; } } if mesh_components == 0 { return Err("selected static preview roots have no mesh-backed visual".to_string()); } - let camera = legacy_camera.unwrap_or_default(); + let free_camera = if legacy_camera.is_none() { + Some(FreeFlightCamera::from_mesh_and_terrain(&mesh, terrain)?) + } else { + None + }; + let camera = legacy_camera.unwrap_or_else(|| { + free_camera + .map(|camera| camera.vulkan_camera(16.0 / 9.0)) + .unwrap_or_default() + }); + let shadow_material_index = append_shadow_material(&mut materials)?; Ok(StaticPreviewScene { mesh, materials, + material_animations, + sun_occlusion_range_indices, + shadow_casters, + shadow_receivers, + shadow_material_index, + terrain: Arc::new(terrain.clone()), camera, + free_camera, camera_mode: if legacy_camera.is_some() { "legacy-d3d7-capture" } else { - "diagnostic-xy" + "free-flight" }, mesh_components, terrain_components: 1, }) } -fn static_preview_xy_frame( - assets: &MissionAssets, - terrain: &TerrainWorld, - roots: &[MissionObjectDraft], -) -> Result<VulkanStaticXyFrame, String> { - let mut min_x = f32::INFINITY; - let mut max_x = f32::NEG_INFINITY; - let mut min_y = f32::INFINITY; - let mut max_y = f32::NEG_INFINITY; - let terrain_positions = terrain - .source_positions() - .ok_or_else(|| "runtime terrain does not retain source positions".to_string())?; - for position in terrain_positions { - extend_static_preview_xy_bounds(*position, &mut min_x, &mut max_x, &mut min_y, &mut max_y)?; - } - for (object_index, root) in roots.iter().enumerate() { - if !root - .position - .iter() - .chain(root.scale.iter()) - .all(|value| value.is_finite()) - { - return Err(format!( - "static preview root {object_index} has a non-finite position or scale" - )); - } - for visual_id in assets.visuals_for_object(object_index) { - let visual = assets.visual_by_id(*visual_id).ok_or_else(|| { - format!( - "static preview root {object_index} references unknown visual {visual_id:?}" - ) - })?; - let Some(model_id) = visual.model_id else { - continue; - }; - let model = assets.model_by_id(model_id).ok_or_else(|| { - format!("static preview visual {visual_id:?} references unknown model {model_id:?}") - })?; - for position in &model.validated.positions { - let position = LegacyIron3dEulerTransform { - translation: root.position, - orientation_radians: root.orientation_raw, - } - .try_transform_scaled_point(*position, root.scale) - .ok_or_else(|| { - "static preview contains a non-finite transformed XY position".to_string() - })?; - extend_static_preview_xy_bounds( - position, &mut min_x, &mut max_x, &mut min_y, &mut max_y, - )?; - } - } - } - VulkanStaticXyFrame::from_bounds(min_x, max_x, min_y, max_y) - .map_err(|err| format!("build static preview XY frame: {err}")) -} - -fn extend_static_preview_xy_bounds( - position: [f32; 3], - min_x: &mut f32, - max_x: &mut f32, - min_y: &mut f32, - max_y: &mut f32, -) -> Result<(), String> { - if !position.iter().all(|value| value.is_finite()) { - return Err("static preview contains a non-finite XY position".to_string()); - } - *min_x = min_x.min(position[0]); - *max_x = max_x.max(position[0]); - *min_y = min_y.min(position[1]); - *max_y = max_y.max(position[1]); - Ok(()) -} - fn static_preview_terrain_base_materials( + loader: &mut StandaloneWearMaterialLoader, root: &std::path::Path, land_msh_path: &str, mesh: &VulkanStaticMesh, materials: &mut Vec<VulkanStaticMaterial>, + material_animations: &mut Vec<MaterialAnimationBinding>, + static_material_phase: Option<u16>, ) -> Result<Vec<(u16, u16)>, String> { let land_path = root.join(land_msh_path); - let wear_path = land_path + let land_parent = land_path .parent() - .ok_or_else(|| format!("terrain mesh has no parent path: {}", land_path.display()))? - .join("Land2.wea"); + .ok_or_else(|| format!("terrain mesh has no parent path: {}", land_path.display()))?; + let land1_path = land_parent.join("Land1.wea"); + let land2_path = land_parent.join("Land2.wea"); let mut packed_tags = mesh .draw_ranges .iter() @@ -342,29 +3905,231 @@ fn static_preview_terrain_base_materials( packed_tags .into_iter() .map(|packed_tag| { - let selection = TerrainMaterialLayers::from_packed_tag(packed_tag).land2_selection(); - let image = load_standalone_wear_material_texture_mip0_rgba8_from_root( - root, - &wear_path, - selection.material_index, - ) - .map_err(|err| { - format!( - "resolve terrain base material tag 0x{packed_tag:04x} through {}: {err}", - wear_path.display() - ) - })?; - let preview_selector = u16::try_from(materials.len()).map_err(|_| { - "static preview exceeds the available 16-bit material selector space".to_string() - })?; - materials.push(VulkanStaticMaterial { - material_index: preview_selector, - texture: VulkanStaticTexture { - width: image.width, - height: image.height, - rgba8: image.rgba8, + let layers = TerrainMaterialLayers::from_packed_tag(packed_tag); + let base_document = loader + .material_document(&land1_path, layers.base_selection().material_index) + .map_err(|err| format!("resolve terrain base MAT0 document: {err}"))?; + let detail_document = loader + .material_document(&land2_path, layers.detail_base_selection().material_index) + .map_err(|err| format!("resolve terrain detail MAT0 document: {err}"))?; + let overlay_selection = layers.overlay_selection(); + let overlay_detail_selection = layers.detail_overlay_selection(); + let overlay_document = overlay_selection + .map(|selection| loader.material_document(&land1_path, selection.material_index)) + .transpose() + .map_err(|err| format!("resolve terrain overlay MAT0 document: {err}"))?; + let overlay_detail_document = overlay_detail_selection + .map(|selection| loader.material_document(&land2_path, selection.material_index)) + .transpose() + .map_err(|err| format!("resolve terrain overlay detail MAT0 document: {err}"))?; + let base_phases = load_standalone_phase_set( + loader, + &land1_path, + layers.base_selection().material_index, + &base_document, + )?; + let detail_phases = load_standalone_phase_set( + loader, + &land2_path, + layers.detail_base_selection().material_index, + &detail_document, + )?; + let overlay_phases = overlay_selection + .map(|selection| { + load_standalone_phase_set( + loader, + &land1_path, + selection.material_index, + overlay_document.as_ref().expect("overlay document"), + ) + }) + .transpose()?; + let overlay_detail_phases = overlay_detail_selection + .map(|selection| { + load_standalone_phase_set( + loader, + &land2_path, + selection.material_index, + overlay_detail_document + .as_ref() + .expect("overlay detail document"), + ) + }) + .transpose()?; + let phase_count = base_phases.len(); + if phase_count == 0 || detail_phases.is_empty() { + return Err(format!( + "terrain material tag 0x{packed_tag:04x} has incomplete phase tables" + )); + } + if overlay_phases.as_ref().is_some_and(Vec::is_empty) + || overlay_detail_phases.as_ref().is_some_and(Vec::is_empty) + { + return Err(format!( + "terrain material tag 0x{packed_tag:04x} has an empty optional phase table" + )); + } + let initial_phase_index = + usize::from(static_material_phase.unwrap_or(0)).min(phase_count.saturating_sub(1)); + let initial_detail_phase_index = usize::from(static_material_phase.unwrap_or(0)) + .min(detail_phases.len().saturating_sub(1)); + let initial_overlay_phase_index = overlay_phases.as_ref().map(|phases| { + usize::from(static_material_phase.unwrap_or(0)).min(phases.len().saturating_sub(1)) + }); + let initial_overlay_detail_phase_index = overlay_detail_phases.as_ref().map(|phases| { + usize::from(static_material_phase.unwrap_or(0)).min(phases.len().saturating_sub(1)) + }); + let mut phase_material_indices = Vec::with_capacity(phase_count); + for phase_index in 0..phase_count { + let (base, base_phase) = &base_phases[phase_index]; + // The base draw selector follows the base MAT0 timeline. The + // other descriptor stages start at their own initial phase; + // frame-time sampling below can then advance them separately. + let (detail, detail_phase) = &detail_phases[initial_detail_phase_index]; + let overlay = overlay_phases + .as_ref() + .zip(initial_overlay_phase_index) + .map(|(phases, phase_index)| &phases[phase_index]); + let overlay_detail = overlay_detail_phases + .as_ref() + .zip(initial_overlay_detail_phase_index) + .map(|(phases, phase_index)| &phases[phase_index]); + let preview_selector = u16::try_from(materials.len()).map_err(|_| { + "static preview exceeds the available 16-bit material selector space" + .to_string() + })?; + materials.push(VulkanStaticMaterial { + material_index: preview_selector, + texture: base.clone(), + detail_texture: Some(detail.clone()), + overlay_texture: overlay.map(|(texture, _)| texture.clone()), + overlay_detail_texture: overlay_detail.map(|(texture, _)| texture.clone()), + diffuse_alpha: base_phase.diffuse_alpha, + overlay_diffuse_alpha: overlay.map(|(_, phase)| phase.diffuse_alpha), + directional_rgb: base_phase.directional_rgb, + additive_rgb: base_phase.additive_rgb, + uv_transform: base_phase.page_uv_transform, + overlay_directional_rgb: overlay.map(|(_, phase)| phase.directional_rgb), + overlay_additive_rgb: overlay.map(|(_, phase)| phase.additive_rgb), + specular_rgb: base_phase.specular_rgb, + specular_power: base_phase.power, + overlay_specular_rgb: overlay.map(|(_, phase)| phase.specular_rgb), + overlay_specular_power: overlay.map(|(_, phase)| phase.power), + detail_uv_transform: detail_phase.page_uv_transform, + overlay_uv_transform: overlay.map(|(_, phase)| phase.page_uv_transform), + overlay_detail_uv_transform: overlay_detail + .map(|(_, phase)| phase.page_uv_transform), + unlit: false, + sky_nebula_stars: false, + lightmap_mode: false, + sky_far_depth: false, + }); + phase_material_indices.push(usize::from(preview_selector)); + } + let base_uvs = phase_uv_transforms_from_standalone( + loader, + &land1_path, + layers.base_selection().material_index, + &base_document, + )?; + let detail_uvs = phase_uv_transforms_from_standalone( + loader, + &land2_path, + layers.detail_base_selection().material_index, + &detail_document, + )?; + let overlay_uvs = match (&overlay_document, layers.overlay_selection()) { + (Some(document), Some(selection)) => phase_uv_transforms_from_standalone( + loader, + &land1_path, + selection.material_index, + document, + )?, + _ => Vec::new(), + }; + let overlay_detail_uvs = + match (&overlay_detail_document, layers.detail_overlay_selection()) { + (Some(document), Some(selection)) => phase_uv_transforms_from_standalone( + loader, + &land2_path, + selection.material_index, + document, + )?, + _ => Vec::new(), + }; + let stage_textures = MaterialStageTextures { + detail: Some( + detail_phases + .iter() + .map(|(texture, _)| texture.clone()) + .collect(), + ), + overlay: overlay_phases + .as_ref() + .map(|phases| phases.iter().map(|(texture, _)| texture.clone()).collect()), + overlay_detail: overlay_detail_phases + .as_ref() + .map(|phases| phases.iter().map(|(texture, _)| texture.clone()).collect()), + applied_material_phases: phase_material_indices + .iter() + .copied() + .map(|material_index| { + ( + material_index, + [ + initial_detail_phase_index, + initial_overlay_phase_index.unwrap_or(0), + initial_overlay_detail_phase_index.unwrap_or(0), + ], + ) + }) + .collect(), + }; + material_animations.push(MaterialAnimationBinding { + initial_phase_index: static_material_phase + .map_or(0, usize::from) + .min(base_document.phases.len().saturating_sub(1)), + variants: vec![MaterialVariantSet { + phase_material_indices, + range_indices: Vec::new(), + }], + base: MaterialPhaseBinding { + document: base_document, + animation_block_index: 0, + wear_row_start_ms: 0, + random_state: packed_tag as u32, + frozen_phase_index: static_material_phase.map(usize::from), + phase_uv_transforms: base_uvs, }, + detail: Some(MaterialPhaseBinding { + document: detail_document, + animation_block_index: 0, + wear_row_start_ms: 0, + random_state: packed_tag as u32 ^ 0x5A5A_A5A5, + frozen_phase_index: static_material_phase.map(usize::from), + phase_uv_transforms: detail_uvs, + }), + overlay: overlay_document.map(|document| MaterialPhaseBinding { + document, + animation_block_index: 0, + wear_row_start_ms: 0, + random_state: packed_tag as u32 ^ 0xA5A5_5A5A, + frozen_phase_index: static_material_phase.map(usize::from), + phase_uv_transforms: overlay_uvs, + }), + overlay_detail: overlay_detail_document.map(|document| MaterialPhaseBinding { + document, + animation_block_index: 0, + wear_row_start_ms: 0, + random_state: packed_tag as u32 ^ 0xC3C3_3C3C, + frozen_phase_index: static_material_phase.map(usize::from), + phase_uv_transforms: overlay_detail_uvs, + }), + stage_textures: Some(stage_textures), }); + let preview_selector = + u16::try_from(materials.len() - phase_count + initial_phase_index) + .map_err(|_| "terrain material selector exceeds u16".to_string())?; Ok((packed_tag, preview_selector)) }) .collect() @@ -376,6 +4141,7 @@ fn static_preview_component_materials( mesh: &VulkanStaticMesh, static_material_phase: Option<u16>, materials: &mut Vec<VulkanStaticMaterial>, + material_animations: &mut Vec<MaterialAnimationBinding>, ) -> Result<Vec<(u16, u16)>, String> { let mut source_selectors = mesh .draw_ranges @@ -398,44 +4164,143 @@ fn static_preview_component_materials( let material = assets.material_by_id(*material_id).ok_or_else(|| { format!("static preview prepared material {source_selector} is unavailable") })?; - let texture_name = static_material_phase - .and_then(|phase_index| { - let phase = material.mat0.phases.get(usize::from(phase_index))?; - let length = phase.texture_raw.iter().position(|byte| *byte == 0).unwrap_or(phase.texture_raw.len()); - (length != 0).then(|| ResourceName(phase.texture_raw[..length].to_vec())) - }) - .or_else(|| material.texture_requests.first().cloned()) - .ok_or_else(|| { - format!("static preview material {source_selector} has no MAT0 diffuse texture") + let initial_phase_index = usize::from(static_material_phase.unwrap_or(0)) + .min(material.mat0.phases.len().saturating_sub(1)); + if material.mat0.phases.is_empty() { + return Err(format!( + "static preview material {source_selector} has no MAT0 phases" + )); + } + let mut phase_material_indices = Vec::with_capacity(material.mat0.phases.len()); + for phase in &material.mat0.phases { + let texture_name = phase_texture_name(phase); + let prepared_texture = texture_name.as_ref().and_then(|name| { + assets.textures.iter().find(|texture| { + texture.usage == PreparedTextureUsage::Diffuse + && texture.source.name == *name + }) + }); + // Empty MAT0 texture names remain valid untextured materials. + let texture = prepared_texture + .map(|texture| { + prepared_texture_to_vulkan(texture, "static preview diffuse texture") + }) + .transpose()? + .unwrap_or_else(|| solid_environment_texture([255, 255, 255, 255])); + let coefficients = phase.coefficients(); + let uv_transform = if let Some(texture) = prepared_texture { + page_uv_transform( + texture.texm.width(), + texture.texm.height(), + &texture.texm.page_rects(), + coefficients.page_index, + )? + } else { + [0.0, 0.0, 1.0, 1.0] + }; + let preview_selector = u16::try_from(materials.len()).map_err(|_| { + "static preview exceeds the available 16-bit material selector space" + .to_string() })?; + materials.push(VulkanStaticMaterial { + material_index: preview_selector, + texture, + detail_texture: None, + overlay_texture: None, + overlay_detail_texture: None, + diffuse_alpha: coefficients.opacity, + overlay_diffuse_alpha: None, + directional_rgb: coefficients.directional_rgb, + additive_rgb: coefficients.additive_rgb, + uv_transform, + overlay_directional_rgb: None, + overlay_additive_rgb: None, + specular_rgb: coefficients.specular_rgb, + specular_power: coefficients.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: false, + sky_nebula_stars: false, + lightmap_mode: false, + sky_far_depth: false, + }); + phase_material_indices.push(usize::from(preview_selector)); + } + let preview_selector = u16::try_from( + *phase_material_indices + .get(initial_phase_index) + .ok_or_else(|| "MAT0 phase variant table is empty".to_string())?, + ) + .map_err(|_| "MAT0 phase variant selector exceeds u16".to_string())?; + material_animations.push(MaterialAnimationBinding { + initial_phase_index, + variants: vec![MaterialVariantSet { + phase_material_indices, + range_indices: Vec::new(), + }], + base: MaterialPhaseBinding { + document: material.mat0.clone(), + animation_block_index: 0, + wear_row_start_ms: 0, + random_state: material_id.raw() as u32, + frozen_phase_index: static_material_phase.map(usize::from), + phase_uv_transforms: phase_uv_transforms_from_assets(&material.mat0, assets), + }, + detail: None, + overlay: None, + overlay_detail: None, + stage_textures: None, + }); + Ok((source_selector, preview_selector)) + }) + .collect() +} + +fn prepared_texture_to_vulkan( + texture: &PreparedTexture, + label: &str, +) -> Result<VulkanStaticTexture, String> { + let mip_images = (0..texture.texm.mip_count()) + .map(|level| texture.decode_mip_rgba8(u32::try_from(level).unwrap_or(u32::MAX))) + .collect::<Result<Vec<_>, _>>() + .map_err(|err| format!("decode {label} {:?}: {err}", texture.source.name))?; + let image = mip_images + .first() + .ok_or_else(|| format!("{label} {:?} has no mip zero", texture.source.name))?; + Ok(VulkanStaticTexture { + width: image.width, + height: image.height, + rgba8: image.rgba8.clone(), + mip_levels: mip_images + .into_iter() + .map(|image| VulkanStaticTextureMip { + width: image.width, + height: image.height, + rgba8: image.rgba8, + }) + .collect(), + }) +} + +fn static_preview_lightmap_textures( + assets: &MissionAssets, + visual: &PreparedVisual, +) -> Result<Vec<VulkanStaticTexture>, String> { + visual + .lightmap_ids + .iter() + .map(|id| { let texture = assets .textures .iter() .find(|texture| { - texture.usage == PreparedTextureUsage::Diffuse && texture.source.name == texture_name + texture.id == *id && texture.usage == PreparedTextureUsage::Lightmap }) - .ok_or_else(|| { - format!( - "static preview diffuse texture {texture_name:?} for material {source_selector} is unavailable" - ) - })?; - let image = texture.decode_mip_rgba8(0).map_err(|err| { - format!( - "decode static preview diffuse texture {texture_name:?} for material {source_selector}: {err}" - ) - })?; - let preview_selector = u16::try_from(materials.len()).map_err(|_| { - "static preview exceeds the available 16-bit material selector space".to_string() - })?; - materials.push(VulkanStaticMaterial { - material_index: preview_selector, - texture: VulkanStaticTexture { - width: image.width, - height: image.height, - rgba8: image.rgba8, - }, - }); - Ok((source_selector, preview_selector)) + .ok_or_else(|| format!("static preview lightmap {id:?} is unavailable"))?; + prepared_texture_to_vulkan(texture, "static preview lightmap") }) .collect() } @@ -444,6 +4309,25 @@ fn append_static_preview_component( target: &mut VulkanStaticMesh, component: VulkanStaticMesh, selector_remap: &[(u16, u16)], + material_animations: Option<&mut Vec<MaterialAnimationBinding>>, +) -> Result<(), String> { + append_static_preview_component_with_lightmaps( + target, + component, + selector_remap, + None, + &[], + material_animations, + ) +} + +fn append_static_preview_component_with_lightmaps( + target: &mut VulkanStaticMesh, + component: VulkanStaticMesh, + selector_remap: &[(u16, u16)], + mut materials: Option<&mut Vec<VulkanStaticMaterial>>, + lightmap_textures: &[VulkanStaticTexture], + mut material_animations: Option<&mut Vec<MaterialAnimationBinding>>, ) -> Result<(), String> { let vertex_base = u32::try_from(target.vertices.len()) .map_err(|_| "static preview vertex count exceeds u32".to_string())?; @@ -466,8 +4350,14 @@ fn append_static_preview_component( }) .collect::<Result<Vec<_>, _>>()?, ); + // A lightmapped range must keep the lightmap material family when MAT0 + // advances to another phase. The range's original selector identifies + // the base binding, while the lightmap selector identifies the secondary + // texture. Keep the generated phase family and the binding/variant it + // belongs to so update_material_animations can rebind every frame. + let mut lightmap_materials: HashMap<(usize, u8), (Vec<usize>, usize, usize)> = HashMap::new(); for range in component.draw_ranges { - let material_index = selector_remap + let source_material_index = selector_remap .iter() .find_map(|(source, preview)| (*source == range.material_index).then_some(*preview)) .ok_or_else(|| { @@ -476,6 +4366,95 @@ fn append_static_preview_component( range.material_index ) })?; + let (material_index, lightmap_variant) = if lightmap_textures + .get(usize::from(range.lightmap_index)) + .is_some() + { + let key = (usize::from(source_material_index), range.lightmap_index); + let (phase_material_indices, binding_index, variant_index) = if let Some(existing) = + lightmap_materials.get(&key) + { + existing.clone() + } else { + let bindings = material_animations.as_deref().ok_or_else(|| { + "static preview lightmap animation storage is unavailable".to_string() + })?; + let (binding_index, source_phase_indices) = + find_material_animation_variant(bindings, usize::from(source_material_index)) + .ok_or_else(|| { + format!( + "static preview material selector {source_material_index} has no MAT0 animation binding" + ) + })?; + let lightmap = lightmap_textures + .get(usize::from(range.lightmap_index)) + .expect("lightmap presence checked above"); + let materials = materials.as_mut().ok_or_else(|| { + "static preview lightmap material storage is unavailable".to_string() + })?; + let mut phase_material_indices = Vec::with_capacity(source_phase_indices.len()); + for source_phase_index in source_phase_indices { + let mut lightmap_material = materials + .get(source_phase_index) + .cloned() + .ok_or_else(|| { + format!( + "static preview material selector {source_phase_index} is unavailable" + ) + })?; + let original_directional = lightmap_material.directional_rgb; + lightmap_material.directional_rgb = [0.0; 3]; + lightmap_material.additive_rgb = original_directional; + lightmap_material.detail_texture = Some(lightmap.clone()); + lightmap_material.detail_uv_transform = [0.0, 0.0, 1.0, 1.0]; + lightmap_material.overlay_texture = None; + lightmap_material.overlay_detail_texture = None; + lightmap_material.overlay_diffuse_alpha = None; + lightmap_material.overlay_directional_rgb = None; + lightmap_material.overlay_additive_rgb = None; + lightmap_material.overlay_uv_transform = None; + lightmap_material.overlay_detail_uv_transform = None; + lightmap_material.unlit = false; + lightmap_material.sky_nebula_stars = false; + lightmap_material.lightmap_mode = true; + let index = u16::try_from(materials.len()).map_err(|_| { + "static preview exceeds the available 16-bit material selector space" + .to_string() + })?; + lightmap_material.material_index = index; + materials.push(lightmap_material); + phase_material_indices.push(usize::from(index)); + } + let bindings = material_animations.as_deref_mut().ok_or_else(|| { + "static preview lightmap animation storage is unavailable".to_string() + })?; + let variant_index = bindings[binding_index].variants.len(); + bindings[binding_index].variants.push(MaterialVariantSet { + phase_material_indices: phase_material_indices.clone(), + range_indices: Vec::new(), + }); + let value = (phase_material_indices, binding_index, variant_index); + lightmap_materials.insert(key, value.clone()); + value + }; + let initial_material = *phase_material_indices + .get( + material_animations + .as_deref() + .and_then(|bindings| bindings.get(binding_index)) + .map_or(0, |binding| binding.initial_phase_index), + ) + .ok_or_else(|| "static preview lightmap phase table is empty".to_string())?; + ( + u16::try_from(initial_material).map_err(|_| { + "static preview lightmap material selector exceeds u16".to_string() + })?, + Some((binding_index, variant_index)), + ) + } else { + (source_material_index, None) + }; + let target_range_index = target.draw_ranges.len(); target .draw_ranges .push(fparkan_render_vulkan::VulkanStaticDrawRange { @@ -485,27 +4464,146 @@ fn append_static_preview_component( material_index, ..range }); + if let Some(bindings) = material_animations.as_deref_mut() { + if let Some((binding_index, variant_index)) = lightmap_variant { + bindings + .get_mut(binding_index) + .and_then(|binding| binding.variants.get_mut(variant_index)) + .ok_or_else(|| { + "static preview lightmap animation variant is missing".to_string() + })? + .range_indices + .push(target_range_index); + } else { + register_material_range( + bindings, + usize::from(source_material_index), + target_range_index, + ); + } + } } Ok(()) } +fn load_standalone_vulkan_texture( + loader: &mut StandaloneWearMaterialLoader, + wear_path: &Path, + material_index: u16, + phase_index: u16, +) -> Result<(VulkanStaticTexture, PreparedMaterialPhase), String> { + let (mips, phase) = loader.load(wear_path, material_index, phase_index)?; + let first = mips + .first() + .ok_or_else(|| "standalone TEXM mip loader returned no mip zero".to_string())?; + Ok(( + VulkanStaticTexture { + width: first.width, + height: first.height, + rgba8: first.rgba8.clone(), + mip_levels: mips + .into_iter() + .map(|image| VulkanStaticTextureMip { + width: image.width, + height: image.height, + rgba8: image.rgba8, + }) + .collect(), + }, + phase, + )) +} + +fn load_standalone_phase_set( + loader: &mut StandaloneWearMaterialLoader, + wear_path: &Path, + material_index: u16, + document: &Mat0Document, +) -> Result<Vec<(VulkanStaticTexture, PreparedMaterialPhase)>, String> { + (0..document.phases.len()) + .map(|phase_index| { + load_standalone_vulkan_texture( + loader, + wear_path, + material_index, + u16::try_from(phase_index) + .map_err(|_| "MAT0 phase index exceeds u16".to_string())?, + ) + }) + .collect() +} + fn run_static_vulkan_mode( preview: StaticPreviewScene, + environment: Option<DynamicEnvironment>, + audio: Option<audio::GameAudio>, target_frames: u64, mission: &str, object_count: usize, readback_out: Option<&std::path::Path>, enable_validation: bool, + startup_timings: StartupTimings, ) -> Result<String, String> { let event_loop = EventLoop::new().map_err(|err| format!("winit event loop: {err}"))?; event_loop.set_control_flow(ControlFlow::Poll); + let mut preview = preview; + let mut environment = environment; + // Reserve the projected-shadow draw range before the environment builder + // reorders its fixed passes. The range itself remains a stable draw slot; + // frame geometry is appended after every static environment allocation. + let mut shadow_range = append_environment_range( + &mut preview.mesh, + preview.shadow_material_index, + shadow_placeholder_vertices(), + quad_indices(1)?, + world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), + )?; + let environment_gpu = match environment.as_mut() { + Some(environment) => { + let camera = preview.free_camera.unwrap_or_else(|| { + FreeFlightCamera::from_mesh(&preview.mesh).unwrap_or(FreeFlightCamera { + position: [0.0; 3], + yaw: 0.0, + pitch: 0.0, + vertical_fov: std::f32::consts::FRAC_PI_3, + near_plane: 0.1, + far_plane: 100_000.0, + move_speed: 1.0, + }) + }); + let environment_materials = environment.materials.clone(); + let material_assets = environment.material_assets.clone(); + let sky_mesh = environment.sky.mesh().clone(); + Some(EnvironmentGpuScene::new( + &mut preview.mesh, + &mut preview.materials, + &mut preview.material_animations, + &mut preview.sun_occlusion_range_indices, + &environment_materials, + &material_assets, + &sky_mesh, + &camera, + &mut shadow_range.range_index, + )?) + } + None => None, + }; + let shadow_base_vertex = preview.mesh.vertices.len(); + let shadow_base_index = preview.mesh.indices.len(); let mut app = StaticVulkanApp::new( preview, + environment, + environment_gpu, + shadow_range, + shadow_base_vertex, + shadow_base_index, + audio, target_frames, mission, object_count, readback_out.map(std::path::Path::to_path_buf), enable_validation, + startup_timings, ); if let Err(err) = event_loop.run_app(&mut app) { app.error = Some(format!("winit event loop: {err}")); @@ -516,7 +4614,32 @@ fn run_static_vulkan_mode( struct StaticVulkanApp { mesh: VulkanStaticMesh, materials: Vec<VulkanStaticMaterial>, + material_animations: Vec<MaterialAnimationBinding>, + sun_occlusion_range_indices: Vec<usize>, + terrain: Arc<TerrainWorld>, + world_range_indices: Vec<usize>, + world_range_index_counts: HashMap<usize, u32>, camera: VulkanStaticCamera, + free_camera: Option<FreeFlightCamera>, + environment: Option<DynamicEnvironment>, + environment_gpu: Option<EnvironmentGpuScene>, + shadow_range: EnvironmentGpuRange, + shadow_base_vertex: usize, + shadow_base_index: usize, + shadow_material_index: usize, + shadow_casters: Vec<ShadowCaster>, + shadow_receivers: Vec<ShadowTriangle>, + shadow_cache: ShadowPageCache, + shadow_last_evidence: ShadowFrameEvidence, + environment_last_tick: Instant, + material_last_tick: Instant, + material_elapsed: Duration, + material_time_ms: u32, + environment_primitive_count: usize, + pressed_keys: HashSet<KeyCode>, + last_tick: Instant, + mouse_look: bool, + audio: Option<audio::GameAudio>, camera_mode: &'static str, mesh_components: usize, terrain_components: usize, @@ -525,6 +4648,7 @@ struct StaticVulkanApp { object_count: usize, readback_out: Option<PathBuf>, enable_validation: bool, + startup_timings: StartupTimings, window_id: Option<WindowId>, window: Option<Window>, renderer: Option<VulkanSmokeRenderer>, @@ -536,16 +4660,64 @@ struct StaticVulkanApp { impl StaticVulkanApp { fn new( preview: StaticPreviewScene, + environment: Option<DynamicEnvironment>, + environment_gpu: Option<EnvironmentGpuScene>, + shadow_range: EnvironmentGpuRange, + shadow_base_vertex: usize, + shadow_base_index: usize, + audio: Option<audio::GameAudio>, target_frames: u64, mission: &str, object_count: usize, readback_out: Option<PathBuf>, enable_validation: bool, + startup_timings: StartupTimings, ) -> Self { + let mut fixed_range_indices = environment_gpu + .as_ref() + .map(EnvironmentGpuScene::fixed_range_indices) + .unwrap_or_default(); + fixed_range_indices.insert(shadow_range.range_index); + let world_range_indices = (0..preview.mesh.draw_ranges.len()) + .filter(|range_index| !fixed_range_indices.contains(range_index)) + .collect(); + let world_range_index_counts = preview + .mesh + .draw_ranges + .iter() + .enumerate() + .filter(|(range_index, _)| !fixed_range_indices.contains(range_index)) + .map(|(range_index, range)| (range_index, range.index_count)) + .collect(); Self { mesh: preview.mesh, materials: preview.materials, + material_animations: preview.material_animations, + sun_occlusion_range_indices: preview.sun_occlusion_range_indices, + terrain: preview.terrain, + world_range_indices, + world_range_index_counts, camera: preview.camera, + free_camera: preview.free_camera, + environment, + environment_gpu, + shadow_base_vertex, + shadow_base_index, + shadow_material_index: preview.shadow_material_index, + shadow_range, + shadow_casters: preview.shadow_casters, + shadow_receivers: preview.shadow_receivers, + shadow_cache: ShadowPageCache::default(), + shadow_last_evidence: ShadowFrameEvidence::default(), + environment_last_tick: Instant::now(), + material_last_tick: Instant::now(), + material_elapsed: Duration::ZERO, + material_time_ms: 0, + environment_primitive_count: 0, + pressed_keys: HashSet::new(), + last_tick: Instant::now(), + mouse_look: false, + audio, camera_mode: preview.camera_mode, mesh_components: preview.mesh_components, terrain_components: preview.terrain_components, @@ -554,6 +4726,7 @@ impl StaticVulkanApp { object_count, readback_out, enable_validation, + startup_timings, window_id: None, window: None, renderer: None, @@ -563,6 +4736,313 @@ impl StaticVulkanApp { } } + fn configure_world_draw_ranges( + &self, + renderer: &mut VulkanSmokeRenderer, + ) -> Result<(), String> { + for &range_index in &self.world_range_indices { + let range = self + .mesh + .draw_ranges + .get(range_index) + .ok_or_else(|| "world draw range index is out of bounds".to_string())?; + let alpha = self + .materials + .get(usize::from(range.material_index)) + .map_or(1.0, |material| material.diffuse_alpha); + let transparent = native_world_transparent(range, alpha); + renderer + .set_draw_range_transparency(range_index, transparent) + .map_err(|error| format!("configure world draw range {range_index}: {error}"))?; + renderer + .set_draw_range_index_count( + range_index, + if alpha > 0.0 { range.index_count } else { 0 }, + ) + .map_err(|error| { + format!("configure world draw range {range_index} count: {error}") + })?; + } + Ok(()) + } + + fn update_world_transparency( + mesh: &VulkanStaticMesh, + materials: &[VulkanStaticMaterial], + world_range_indices: &[usize], + world_range_index_counts: &HashMap<usize, u32>, + active_materials: &HashMap<usize, ActiveMaterialState>, + renderer: &mut VulkanSmokeRenderer, + ) -> Result<(), String> { + for &range_index in world_range_indices { + let range = mesh + .draw_ranges + .get(range_index) + .ok_or_else(|| "world draw range index is out of bounds".to_string())?; + let state = active_materials.get(&range_index); + let alpha = state.map_or_else( + || { + materials + .get(usize::from(range.material_index)) + .map_or(1.0, |material| material.diffuse_alpha) + }, + |state| state.diffuse_alpha, + ); + renderer + .set_draw_range_transparency(range_index, native_world_transparent(range, alpha)) + .map_err(|error| format!("update world draw range {range_index}: {error}"))?; + let count = if alpha > 0.0 { + *world_range_index_counts + .get(&range_index) + .ok_or_else(|| "world draw range count is missing".to_string())? + } else { + 0 + }; + renderer + .set_draw_range_index_count(range_index, count) + .map_err(|error| format!("update world draw range {range_index} count: {error}"))?; + } + Ok(()) + } + + fn world_draw_sort_keys(&self, camera_position: [f32; 3]) -> Result<Vec<(usize, f32)>, String> { + let mut keys = Vec::with_capacity(self.world_range_indices.len()); + for &range_index in &self.world_range_indices { + let sort_key = first_indexed_vertex_distance(&self.mesh, range_index, camera_position)?; + keys.push((range_index, sort_key)); + } + Ok(keys) + } + + fn update_free_camera(&mut self) -> Result<(), String> { + if self.free_camera.is_none() { + return Ok(()); + } + let now = Instant::now(); + let elapsed = now + .saturating_duration_since(self.last_tick) + .min(Duration::from_millis(100)); + self.last_tick = now; + let Some(window) = self.window.as_ref() else { + return Ok(()); + }; + let size = window.inner_size(); + let aspect = if size.height == 0 { + 16.0 / 9.0 + } else { + size.width as f32 / size.height as f32 + }; + let (camera, listener_position, listener_forward, listener_up) = { + let free_camera = self + .free_camera + .as_mut() + .ok_or_else(|| "free-flight camera disappeared".to_string())?; + free_camera.advance(&self.pressed_keys, elapsed.as_secs_f32()); + ( + free_camera.vulkan_camera(aspect), + free_camera.position, + free_camera.forward(), + free_camera.up(), + ) + }; + self.camera = camera; + let sort_keys = self.world_draw_sort_keys(listener_position)?; + if let Some(renderer) = self.renderer.as_mut() { + renderer + .set_camera(camera) + .map_err(|error| format!("update free-flight camera: {error}"))?; + for (range_index, sort_key) in sort_keys { + renderer + .set_draw_range_sort_key(range_index, sort_key) + .map_err(|error| { + format!("update world draw range {range_index} sort key: {error}") + })?; + } + } + if let Some(audio) = self.audio.as_mut() { + audio + .update_listener(listener_position, listener_forward, listener_up) + .map_err(|error| format!("update audio listener: {error}"))?; + } + Ok(()) + } + + fn update_material_clock(&mut self) { + let now = Instant::now(); + let elapsed = now + .saturating_duration_since(self.material_last_tick) + .min(Duration::from_millis(100)); + self.material_last_tick = now; + self.material_elapsed = self.material_elapsed.saturating_add(elapsed); + // Keep the native u32 millisecond clock while flooring only the + // accumulated duration. This preserves sub-millisecond frame deltas + // instead of dropping them once per redraw. + self.material_time_ms = self.material_elapsed.as_millis() as u32; + } + + fn update_shadow( + mesh: &mut VulkanStaticMesh, + shadow_cache: &mut ShadowPageCache, + shadow_casters: &[ShadowCaster], + shadow_receivers: &[ShadowTriangle], + shadow_range: EnvironmentGpuRange, + shadow_base_vertex: usize, + shadow_base_index: usize, + shadow_material_index: usize, + renderer: &mut VulkanSmokeRenderer, + sky_frame: Option<&SkyFrame<'_>>, + environment_frame: Option<&EnvironmentFrame>, + shadow_camera: Option<ShadowCamera>, + ) -> Result<ShadowFrameEvidence, String> { + let lights = shadow_lights(sky_frame, environment_frame, renderer); + let visible_casters = native_visible_shadow_casters( + shadow_casters, + renderer.frame_uniforms().clip_from_world, + ); + let frame = shadow_cache + .build(ShadowScene { + lights: &lights, + casters: &visible_casters, + receivers: shadow_receivers, + fade: 1.0, + smooth_passes: 1, + camera: shadow_camera, + lod: ShadowLodSettings::default(), + }) + .map_err(|error| format!("build projected shadow frame: {error}"))?; + let geometry = append_shadow_frame_geometry( + mesh, + shadow_range, + shadow_base_vertex, + shadow_base_index, + &frame, + )?; + let atlas = shadow_atlas_texture_from_frame(&frame); + renderer + .update_material_texture(shadow_material_index, 0, &atlas) + .map_err(|error| format!("upload projected shadow atlas: {error}"))?; + renderer + .update_mesh_geometry(&mesh.vertices, &mesh.indices, &[geometry]) + .map_err(|error| format!("upload environment and shadow geometry: {error}"))?; + Ok(ShadowFrameEvidence { + visible_casters: visible_casters.len(), + projected_indices: frame.indices.len(), + }) + } + + fn update_environment(&mut self) -> Result<(), String> { + let Some(renderer) = self.renderer.as_mut() else { + return Ok(()); + }; + let viewport = self + .window + .as_ref() + .map(|window| { + let size = window.inner_size(); + [size.width.max(1) as f32, size.height.max(1) as f32] + }) + .unwrap_or([1280.0, 720.0]); + let aspect = viewport[0] / viewport[1].max(1.0); + let now = Instant::now(); + let dt = now + .saturating_duration_since(self.environment_last_tick) + .min(Duration::from_millis(100)) + .as_secs_f32(); + self.environment_last_tick = now; + let active_materials = update_material_animations( + &mut self.material_animations, + &self.materials, + renderer, + self.material_time_ms, + )?; + Self::update_world_transparency( + &self.mesh, + &self.materials, + &self.world_range_indices, + &self.world_range_index_counts, + &active_materials, + renderer, + )?; + let free_camera = self.free_camera; + if let (Some(camera), Some(environment)) = (free_camera, self.environment.as_mut()) { + let (sky_frame, sky_mesh, environment_frame) = + environment.update(dt, &camera, aspect, viewport, renderer, self.audio.as_mut())?; + self.environment_primitive_count = environment_frame.primitives.len(); + if let Some(environment_gpu) = self.environment_gpu.as_mut() { + environment_gpu.update( + &mut self.mesh, + &self.materials, + renderer, + &sky_frame, + &sky_mesh, + &environment_frame, + &camera, + viewport, + self.material_time_ms, + &self.terrain, + &self.sun_occlusion_range_indices, + )?; + } + let shadow_camera = ShadowCamera { + position: camera.position, + viewport_width: viewport[0], + horizontal_fov: horizontal_fov(&camera, aspect), + }; + self.shadow_last_evidence = Self::update_shadow( + &mut self.mesh, + &mut self.shadow_cache, + &self.shadow_casters, + &self.shadow_receivers, + self.shadow_range, + self.shadow_base_vertex, + self.shadow_base_index, + self.shadow_material_index, + renderer, + Some(&sky_frame), + Some(&environment_frame), + Some(shadow_camera), + )?; + } else { + let shadow_camera = free_camera.map(|camera| ShadowCamera { + position: camera.position, + viewport_width: viewport[0], + horizontal_fov: horizontal_fov(&camera, aspect), + }); + self.shadow_last_evidence = Self::update_shadow( + &mut self.mesh, + &mut self.shadow_cache, + &self.shadow_casters, + &self.shadow_receivers, + self.shadow_range, + self.shadow_base_vertex, + self.shadow_base_index, + self.shadow_material_index, + renderer, + None, + None, + shadow_camera, + )?; + } + Ok(()) + } + + fn set_mouse_look(&mut self, active: bool) { + self.mouse_look = active; + if let Some(window) = self.window.as_ref() { + if active { + // Relative device motion keeps the camera usable at the edge + // of the window. Locked grab is unavailable on a few window + // backends, so confined grab is a safe fallback. + if window.set_cursor_grab(CursorGrabMode::Locked).is_err() { + let _ = window.set_cursor_grab(CursorGrabMode::Confined); + } + } else { + let _ = window.set_cursor_grab(CursorGrabMode::None); + } + window.set_cursor_visible(!active); + } + } + fn schedule_next_redraw(&self) { if let Some(window) = self.window.as_ref() { window.request_redraw(); @@ -570,6 +5050,9 @@ impl StaticVulkanApp { } fn complete(&mut self, event_loop: &ActiveEventLoop) { + if let Some(audio) = self.audio.as_mut() { + audio.shutdown(); + } let Some(renderer) = self.renderer.take() else { self.error = Some("native Vulkan renderer was not initialized".to_string()); event_loop.exit(); @@ -588,8 +5071,10 @@ impl StaticVulkanApp { && (report.validation.warning_count != 0 || report.validation.error_count != 0) { self.error = Some(format!( - "native Vulkan validation must stay clean (warnings={}, errors={})", - report.validation.warning_count, report.validation.error_count + "native Vulkan validation must stay clean (warnings={}, errors={}, vuids={:?})", + report.validation.warning_count, + report.validation.error_count, + report.validation.vuids, )); event_loop.exit(); return; @@ -623,8 +5108,14 @@ impl StaticVulkanApp { } (None, _) => None, }; + let readback_format = report + .readback_artifact + .as_ref() + .map_or(report.renderer_report.swapchain_image_format, |artifact| { + artifact.format + }); self.output = Some(format!( - "rendered mission {}: {} frames, {} objects, {} mesh components, {} terrain components, camera={}, materials={}, validation={}, readback={}", + "rendered mission {}: {} frames, {} objects, {} mesh components, {} terrain components, camera={}, materials={}, environment_primitives={}, shadow_casters={}, shadow_indices={}, swapchain_recreates={}, validation={}, swapchain_format={}, readback_format={}, readback={}, startup_ms={{mission_assets:{:.1},terrain_materials:{:.1},sky:{:.1},gpu_initialization:{:.1}}}", self.mission, self.frames_presented, self.object_count, @@ -632,8 +5123,18 @@ impl StaticVulkanApp { self.terrain_components, self.camera_mode, self.materials.len(), + self.environment_primitive_count, + self.shadow_last_evidence.visible_casters, + self.shadow_last_evidence.projected_indices, + report.swapchain_recreate_count, validation_status, + report.renderer_report.swapchain_image_format, + readback_format, readback_path.as_deref().unwrap_or("none"), + elapsed_ms(self.startup_timings.mission_assets), + elapsed_ms(self.startup_timings.terrain_materials), + elapsed_ms(self.startup_timings.sky), + elapsed_ms(self.startup_timings.gpu_initialization), )); event_loop.exit(); } @@ -648,6 +5149,21 @@ impl StaticVulkanApp { } impl ApplicationHandler for StaticVulkanApp { + fn device_event( + &mut self, + _event_loop: &ActiveEventLoop, + _device_id: DeviceId, + event: DeviceEvent, + ) { + if self.mouse_look { + if let DeviceEvent::MouseMotion { delta } = event { + if let Some(camera) = self.free_camera.as_mut() { + camera.look_delta(delta.0, delta.1); + } + } + } + } + fn resumed(&mut self, event_loop: &ActiveEventLoop) { if self.window.is_some() { return; @@ -661,7 +5177,7 @@ impl ApplicationHandler for StaticVulkanApp { } }; let attributes = Window::default_attributes() - .with_title("FParkan static mission Vulkan") + .with_title("FParkan mission") .with_inner_size(WinitPhysicalSize::new(plan.width, plan.height)); let window = match event_loop.create_window(attributes) { Ok(window) => window, @@ -677,7 +5193,8 @@ impl ApplicationHandler for StaticVulkanApp { return; }; let size = window.inner_size(); - let renderer = match VulkanSmokeRenderer::new(&VulkanSmokeRendererCreateInfo { + let gpu_initialization_started = Instant::now(); + let mut renderer = match VulkanSmokeRenderer::new(&VulkanSmokeRendererCreateInfo { application_name: "fparkan-game".to_string(), native_handles, drawable_extent: (size.width.max(1), size.height.max(1)), @@ -695,9 +5212,47 @@ impl ApplicationHandler for StaticVulkanApp { return; } }; + if let Err(error) = self.configure_world_draw_ranges(&mut renderer) { + self.error = Some(error); + event_loop.exit(); + return; + } + if let Some(free_camera) = self.free_camera { + match self.world_draw_sort_keys(free_camera.position) { + Ok(sort_keys) => { + for (range_index, sort_key) in sort_keys { + if let Err(error) = renderer.set_draw_range_sort_key(range_index, sort_key) + { + self.error = Some(format!( + "update world draw range {range_index} sort key: {error}" + )); + event_loop.exit(); + return; + } + } + } + Err(error) => { + self.error = Some(error); + event_loop.exit(); + return; + } + } + } + self.startup_timings.gpu_initialization = gpu_initialization_started.elapsed(); + renderer.set_readback_enabled(self.readback_out.is_some()); self.window_id = Some(window.id()); self.renderer = Some(renderer); self.window = Some(window); + self.last_tick = Instant::now(); + self.environment_last_tick = Instant::now(); + self.material_last_tick = Instant::now(); + self.material_elapsed = Duration::ZERO; + self.material_time_ms = 0; + if let Err(error) = self.update_free_camera() { + self.error = Some(error); + event_loop.exit(); + return; + } self.schedule_next_redraw(); } @@ -714,12 +5269,53 @@ impl ApplicationHandler for StaticVulkanApp { WindowEvent::CloseRequested => { self.complete(event_loop); } + WindowEvent::Focused(focused) => { + if !focused { + self.pressed_keys.clear(); + self.set_mouse_look(false); + } + if let Some(audio) = self.audio.as_mut() { + audio.on_focus_changed(focused); + } + } + WindowEvent::KeyboardInput { event, .. } => { + if let PhysicalKey::Code(code) = event.physical_key { + if code == KeyCode::Escape && event.state == ElementState::Pressed { + if self.mouse_look { + self.set_mouse_look(false); + } else { + self.complete(event_loop); + } + } else if event.state == ElementState::Pressed { + self.pressed_keys.insert(code); + } else { + self.pressed_keys.remove(&code); + } + } + } + WindowEvent::MouseInput { state, button, .. } => { + if button == MouseButton::Right { + self.set_mouse_look(state == ElementState::Pressed); + } + } + WindowEvent::CursorMoved { .. } => {} WindowEvent::Resized(size) => { if let Some(renderer) = self.renderer.as_mut() { renderer.request_resize((size.width, size.height)); } } WindowEvent::RedrawRequested => { + if let Err(error) = self.update_free_camera() { + self.error = Some(error); + event_loop.exit(); + return; + } + self.update_material_clock(); + if let Err(error) = self.update_environment() { + self.error = Some(error); + event_loop.exit(); + return; + } let Some(renderer) = self.renderer.as_mut() else { self.error = Some("native Vulkan renderer was not initialized".to_string()); event_loop.exit(); @@ -738,7 +5334,7 @@ impl ApplicationHandler for StaticVulkanApp { return; } } - if self.frames_presented >= self.target_frames { + if self.target_frames != 0 && self.frames_presented >= self.target_frames { self.complete(event_loop); } else { self.schedule_next_redraw(); @@ -755,11 +5351,12 @@ impl ApplicationHandler for StaticVulkanApp { } } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, PartialEq)] struct Args { root: PathBuf, mission: String, frames: u64, + atmosphere_seconds: Option<f32>, validation: bool, readback_out: Option<PathBuf>, preview_roots: NonZeroUsize, @@ -772,7 +5369,11 @@ impl Args { fn parse(args: &[String]) -> Result<Self, String> { let mut root = None; let mut mission = None; - let mut frames = 1; + // Zero means keep the interactive preview open until the user closes + // it. A bounded `--frames N` remains available for smoke/readback + // runs and validation scripts. + let mut frames = 0; + let mut atmosphere_seconds = None; let mut validation = false; let mut readback_out = None; // A native static-Vulkan invocation is the usable mission preview, so @@ -807,6 +5408,19 @@ impl Args { .parse() .map_err(|_| "--frames must be an integer".to_string())?; } + "--atmosphere-seconds" => { + let value = iter + .next() + .ok_or_else(|| "--atmosphere-seconds requires a value".to_string())? + .parse::<f32>() + .map_err(|_| "--atmosphere-seconds must be a number".to_string())?; + if !value.is_finite() || value < 0.0 { + return Err( + "--atmosphere-seconds must be finite and non-negative".to_string() + ); + } + atmosphere_seconds = Some(value); + } "--validation" => { validation = true; } @@ -855,13 +5469,11 @@ impl Args { } let root = root.ok_or_else(|| "missing --root".to_string())?; let mission = mission.ok_or_else(|| "missing --mission".to_string())?; - if frames == 0 { - return Err("--frames must be greater than zero".to_string()); - } Ok(Self { root, mission, frames, + atmosphere_seconds, validation, readback_out, preview_roots, @@ -950,7 +5562,7 @@ fn decode_legacy_camera_capture_json(bytes: &[u8]) -> Result<String, String> { } fn usage() -> String { - "usage: fparkan-game --root <path> --mission <path> [--frames <n>] [--validation] [--preview-roots <non-zero n>] [--legacy-camera-capture <path>] [--static-animation-frame <u16>] [--static-material-phase <u16>] [--readback-out <path>]".to_string() + "usage: fparkan-game --root <path> --mission <path> [--frames <n>] [--atmosphere-seconds <seconds>] [--validation] [--preview-roots <non-zero n>] [--legacy-camera-capture <path>] [--static-animation-frame <u16>] [--static-material-phase <u16>] [--readback-out <path>]\nstatic preview uses saved per-component CTLD defaults, with uncontrolled nodes at frame 0; --static-animation-frame overrides all components globally.".to_string() } #[cfg(test)] @@ -962,6 +5574,314 @@ mod tests { } #[test] + #[ignore = "requires licensed corpus"] + fn licensed_corpus_part1_prepares_every_mission() { + prepare_licensed_corpus("FPARKAN_CORPUS_PART1_ROOT", 29); + } + + #[test] + #[ignore = "requires licensed corpus"] + fn licensed_corpus_part2_prepares_every_mission() { + prepare_licensed_corpus("FPARKAN_CORPUS_PART2_ROOT", 31); + } + + fn prepare_licensed_corpus(variable: &str, expected_missions: usize) { + let root = std::env::var_os(variable) + .map(PathBuf::from) + .unwrap_or_else(|| panic!("{variable} is required")); + assert!( + root.is_dir(), + "licensed corpus root is missing: {}", + root.display() + ); + + let mut missions = Vec::new(); + let mut directories = vec![root.clone()]; + while let Some(directory) = directories.pop() { + let entries = std::fs::read_dir(&directory) + .unwrap_or_else(|error| panic!("read {}: {error}", directory.display())); + for entry in entries { + let path = entry + .unwrap_or_else(|error| { + panic!("read entry in {}: {error}", directory.display()) + }) + .path(); + if path.is_dir() { + directories.push(path); + } else if path + .file_name() + .is_some_and(|name| name.eq_ignore_ascii_case("data.tma")) + { + let relative = path + .strip_prefix(&root) + .expect("mission path is under corpus root") + .to_str() + .expect("mission path is UTF-8") + .replace('\\', "/"); + missions.push(relative); + } + } + } + missions.sort(); + assert_eq!( + missions.len(), + expected_missions, + "installed mission count for {variable}" + ); + + let vfs: Arc<dyn fparkan_vfs::Vfs> = Arc::new(DirectoryVfs::new(&root)); + let mut prepared = 0usize; + let mut environment_missions = 0usize; + for mission in missions { + let mut engine = create(EngineServices::new(vfs.clone())).expect("engine"); + let loaded = load_mission_static_preview_roots( + &mut engine, + MissionRequest { + key: mission.clone(), + }, + NonZeroUsize::MAX, + ) + .unwrap_or_else(|error| panic!("prepare runtime mission {mission}: {error}")); + let assets = loaded_mission_assets(&engine).expect("mission assets"); + let terrain = loaded_terrain(&engine).expect("mission terrain"); + let roots = loaded_mission_object_drafts(&engine).expect("mission object drafts"); + let preview = static_preview_mesh_and_materials( + assets, + terrain, + roots, + None, + None, + None, + &root, + &loaded.land_msh_path, + ) + .unwrap_or_else(|error| panic!("prepare renderer mission {mission}: {error}")); + assert!(!preview.mesh.vertices.is_empty(), "{mission}: empty mesh"); + assert!( + !preview.materials.is_empty(), + "{mission}: empty material set" + ); + + if load_environment(&root, &mission, None) + .unwrap_or_else(|error| panic!("prepare environment mission {mission}: {error}")) + .is_some() + { + environment_missions += 1; + } + + // `GameAudio::new` resolves and decodes every sample declared by + // mission.cfg before opening the OS device. A headless CI host is + // therefore still a useful resource check: only the final device + // step may be unavailable. + match audio::GameAudio::new(vfs.clone(), &mission) { + Ok(mut audio) => audio.shutdown(), + Err(audio::AudioError::DeviceUnavailable { .. }) => {} + Err(error) => panic!("prepare audio mission {mission}: {error}"), + } + prepared += 1; + } + + assert_eq!(prepared, expected_missions); + assert_eq!(environment_missions, expected_missions); + } + + #[test] + fn draw_range_reorder_remaps_all_material_animation_bindings() { + let range = |first_index, material_index| VulkanStaticDrawRange { + first_index, + index_count: 3, + material_index, + lightmap_index: u8::MAX, + batch_flags: 0, + pipeline_state: world_pipeline_state( + LegacyBlendMode::Opaque, + LegacyDepthMode::TestReadOnly, + ), + alpha_test_reference: 0, + }; + let mut mesh = VulkanStaticMesh { + vertices: Vec::new(), + indices: (0..9).collect(), + draw_ranges: vec![range(0, 10), range(3, 20), range(6, 30)], + }; + let empty_binding = || MaterialPhaseBinding { + document: Mat0Document { + version: 0, + animation_block_count: 0, + phases: Vec::new(), + prefix: Vec::new(), + header_opaque: [0; 2], + animation_blocks: Vec::new(), + }, + animation_block_index: 0, + wear_row_start_ms: 0, + random_state: 0, + frozen_phase_index: Some(0), + phase_uv_transforms: Vec::new(), + }; + let mut bindings = vec![MaterialAnimationBinding { + initial_phase_index: 0, + variants: vec![MaterialVariantSet { + phase_material_indices: vec![10], + range_indices: vec![0, 2], + }], + base: empty_binding(), + detail: None, + overlay: None, + overlay_detail: None, + stage_textures: None, + }]; + + let remap = reorder_draw_ranges(&mut mesh, &[2, 0, 1]).expect("reorder"); + remap_material_animation_ranges(&mut bindings, &remap).expect("binding remap"); + + assert_eq!( + mesh.indices, + (6..9).chain(0..3).chain(3..6).collect::<Vec<_>>() + ); + assert_eq!(mesh.draw_ranges[0].material_index, 30); + assert_eq!(mesh.draw_ranges[1].material_index, 10); + assert_eq!(mesh.draw_ranges[2].material_index, 20); + assert_eq!(bindings[0].variants[0].range_indices, vec![1, 0]); + } + + #[test] + fn native_world_transparency_uses_sampled_alpha_and_native_batch_flags() { + let range = |blend, batch_flags| VulkanStaticDrawRange { + first_index: 0, + index_count: 3, + material_index: 0, + lightmap_index: u8::MAX, + batch_flags, + pipeline_state: world_pipeline_state(blend, LegacyDepthMode::TestReadOnly), + alpha_test_reference: 0, + }; + + // Transparency is driven by the native predicate. The pipeline blend + // enum is an independent rendering detail and does not qualify an + // opaque range on its own. + assert!(!native_world_transparent( + &range(LegacyBlendMode::SourceAlpha, 0), + 1.0 + )); + assert!(native_world_transparent( + &range(LegacyBlendMode::Opaque, 0), + 0.999 + )); + assert!(native_world_transparent( + &range(LegacyBlendMode::Opaque, 0x100), + 1.0 + )); + assert!(native_world_transparent( + &range(LegacyBlendMode::Opaque, 0x8), + 1.0 + )); + } + + #[test] + fn native_sun_object_source_types_map_to_world_kinds() { + assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_FORT)), Some(3)); + assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_BTLU)), Some(4)); + assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_STAT)), Some(10)); + assert!(native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_FORT))); + assert!(native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_BTLU))); + assert!(native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_STAT))); + assert!(!native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_EXTO))); + assert!(!native_sun_object_kind_eligible(None)); + } + + fn test_shadow_caster(id: u32, center: [f32; 3]) -> ShadowCaster { + ShadowCaster { + id, + world_axes: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + sphere: ShadowSphere { + center, + radius: 0.0, + }, + native_kind: 4, + triangles: Vec::new(), + } + } + + #[test] + fn shadow_frustum_filters_before_the_native_twenty_caster_budget() { + let casters = [ + test_shadow_caster(1, [202.0, 0.0, 0.0]), + test_shadow_caster(2, [0.0, 0.0, 0.0]), + ]; + let visible = + native_visible_shadow_casters(&casters, VulkanStaticCamera::default().clip_from_world); + assert_eq!( + visible.iter().map(|caster| caster.id).collect::<Vec<_>>(), + [2] + ); + } + + #[test] + fn shadow_frustum_keeps_the_native_hundred_unit_sphere_margin() { + let matrix = VulkanStaticCamera::default().clip_from_world; + assert!(native_shadow_caster_visible( + &test_shadow_caster(1, [100.99, 0.0, 0.0]), + matrix + )); + assert!(!native_shadow_caster_visible( + &test_shadow_caster(2, [101.0, 0.0, 0.0]), + matrix + )); + } + + #[test] + fn mounted_shadow_bounds_ignore_child_translation_and_scale_aggregate_once() { + let parent = VulkanNodePose { + translation: [10.0, 20.0, 30.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }; + let node1 = VulkanNodePose { + // Native bounds replace this translation with the accumulated + // parent translation while retaining node 1's rotation. + translation: [99.0, -77.0, 42.0], + rotation: [0.0, 0.0, (0.5_f32).sqrt(), (0.5_f32).sqrt()], + }; + let first = mounted_shadow_sphere([2.0, 0.0, 0.0], 2.0, Some(parent), Some(node1)) + .expect("finite mounted sphere"); + assert!((first.center[0] - 10.0).abs() < 1.0e-5); + assert!((first.center[1] - 22.0).abs() < 1.0e-5); + assert!((first.center[2] - 30.0).abs() < 1.0e-5); + + let second = ShadowComponentSphere { + center: [12.0, 22.0, 30.0], + radius: 1.0, + }; + let caster = aggregate_shadow_caster( + 7, + 4, + LegacyIron3dEulerTransform { + translation: [1.0, 2.0, 3.0], + orientation_radians: [0.0; 3], + }, + [2.0, 3.0, 4.0], + vec![shadow_triangle( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + 0, + )], + &[first, second], + ) + .expect("aggregate caster"); + let nearly_equal = |left: f32, right: f32| assert!((left - right).abs() < 1.0e-4); + nearly_equal(caster.sphere.center[0], 22.333_334); + nearly_equal(caster.sphere.center[1], 68.0); + nearly_equal(caster.sphere.center[2], 123.0); + nearly_equal(caster.sphere.radius, 10.666_667); + } + + #[test] + fn internal_components_do_not_enter_msh_socket_mount_path() { + assert!(unit_component_is_visible(0, None)); + assert!(!unit_component_is_visible(1, None)); + assert!(unit_component_is_visible(1, Some(PROTOTYPE_TYPE_EXTO))); + } + + #[test] fn parses_required_args() { assert_eq!( Args::parse(&strings(&[ @@ -976,6 +5896,7 @@ mod tests { root: PathBuf::from("testdata/IS"), mission: "MISSIONS/Autodemo.00/data.tma".to_string(), frames: 3, + atmosphere_seconds: None, validation: false, readback_out: None, preview_roots: NonZeroUsize::MAX, @@ -987,6 +5908,301 @@ mod tests { } #[test] + fn defaults_to_interactive_frame_loop() { + let parsed = Args::parse(&strings(&[ + "--root", + "testdata/IS", + "--mission", + "MISSIONS/Autodemo.00/data.tma", + ])) + .expect("default interactive arguments"); + assert_eq!(parsed.frames, 0); + assert_eq!(parsed.preview_roots, NonZeroUsize::MAX); + assert_eq!(parsed.static_animation_frame, None); + } + + #[test] + fn lightmap_ranges_stay_on_the_decorated_variant_when_phase_changes() { + let empty_mat0 = Mat0Document { + version: 0, + animation_block_count: 0, + phases: Vec::new(), + prefix: Vec::new(), + header_opaque: [0; 2], + animation_blocks: Vec::new(), + }; + let mut bindings = vec![MaterialAnimationBinding { + initial_phase_index: 0, + variants: vec![MaterialVariantSet { + phase_material_indices: vec![10, 11], + range_indices: Vec::new(), + }], + base: MaterialPhaseBinding { + document: empty_mat0, + animation_block_index: 0, + wear_row_start_ms: 0, + random_state: 0, + frozen_phase_index: Some(0), + phase_uv_transforms: Vec::new(), + }, + detail: None, + overlay: None, + overlay_detail: None, + stage_textures: None, + }]; + let (binding_index, source_phases) = + find_material_animation_variant(&bindings, 10).expect("base material variant"); + assert_eq!(source_phases, vec![10, 11]); + bindings[binding_index].variants.push(MaterialVariantSet { + phase_material_indices: vec![20, 21], + range_indices: vec![7], + }); + + let lightmap_variant = &bindings[binding_index].variants[1]; + assert_eq!(lightmap_variant.range_indices, vec![7]); + assert_eq!( + selected_material_variant_index(lightmap_variant, 0), + Some(20) + ); + assert_eq!( + selected_material_variant_index(lightmap_variant, 1), + Some(21) + ); + } + + fn row_vector_transform(point: [f32; 4], matrix: [f32; 16]) -> [f32; 4] { + let mut result = [0.0; 4]; + for column in 0..4 { + result[column] = (0..4) + .map(|row| point[row] * matrix[row * 4 + column]) + .sum(); + } + result + } + + #[test] + fn free_flight_camera_uses_right_handed_z_up_basis_and_positive_depth() { + let camera = FreeFlightCamera { + position: [0.0, 0.0, 0.0], + yaw: 0.0, + pitch: 0.0, + vertical_fov: std::f32::consts::FRAC_PI_2, + near_plane: 1.0, + far_plane: 11.0, + move_speed: 1.0, + }; + + let nearly_equal = |left: f32, right: f32| assert!((left - right).abs() < 1.0e-5); + assert_eq!(camera.forward(), [1.0, 0.0, 0.0]); + assert_eq!(camera.right(), [0.0, -1.0, 0.0]); + assert_eq!(camera.up(), [0.0, 0.0, 1.0]); + let matrix = camera.vulkan_camera(1.0).clip_from_world; + let near = row_vector_transform([1.0, 0.0, 0.0, 1.0], matrix); + let far = row_vector_transform([11.0, 0.0, 0.0, 1.0], matrix); + let camera_right = row_vector_transform([1.0, -1.0, 0.0, 1.0], matrix); + let world_up = row_vector_transform([1.0, 0.0, 1.0, 1.0], matrix); + nearly_equal(near[0], 0.0); + nearly_equal(near[1], 0.0); + nearly_equal(near[2], 0.0); + nearly_equal(near[3], 1.0); + assert!(camera_right[0] / camera_right[3] > 0.0); + assert!(world_up[1] / world_up[3] < 0.0); + nearly_equal(far[2] / far[3], 1.0); + } + + #[test] + fn sky_normals_keep_native_signed_byte_scale_without_renormalizing() { + assert_eq!(sky_normal([-128, 127, -64]), [-1.0, 127.0 / 128.0, -0.5]); + } + + #[test] + fn sun_visibility_uses_a_valid_ray_and_fails_open_without_surfaces() { + let camera = FreeFlightCamera { + position: [10.0, 20.0, 30.0], + yaw: 0.0, + pitch: 0.0, + vertical_fov: 1.0, + near_plane: 0.1, + far_plane: 100.0, + move_speed: 1.0, + }; + let terrain = TerrainWorld::default(); + assert!(native_sun_unoccluded(&terrain, &camera, [1.0, 0.0, 0.0])); + assert!(!native_sun_unoccluded(&terrain, &camera, [0.0, 0.0, 0.0])); + + let terrain = TerrainWorld::from_land_msh(&fparkan_terrain_format::LandMeshDocument { + streams: Vec::new(), + nodes_raw: Vec::new(), + slots: fparkan_terrain_format::TerrainSlotTable { + header_raw: Vec::new(), + slots_raw: Vec::new(), + }, + // The native Land.msh height is 32; renderer world Z is 1. + positions: vec![[0.0, 0.0, 32.0], [1.0, 0.0, 32.0], [0.0, 1.0, 32.0]], + normals: Vec::new(), + uv0: Vec::new(), + accelerator: Vec::new(), + aux14: Vec::new(), + aux18: Vec::new(), + faces: vec![fparkan_terrain_format::TerrainFace28 { + flags: FullSurfaceMask(1), + material_tag: 0, + aux_tag: 0, + vertices: [0, 1, 2], + neighbors: [None, None, None], + tail_raw: [0; 8], + raw: [0; 28], + }], + }) + .expect("synthetic sun-occluder terrain"); + let blocked_camera = FreeFlightCamera { + position: [0.25, 0.25, 2.0], + yaw: 0.0, + pitch: 0.0, + vertical_fov: 1.0, + near_plane: 0.1, + far_plane: 100.0, + move_speed: 1.0, + }; + assert!(!native_sun_unoccluded( + &terrain, + &blocked_camera, + [0.0, 0.0, -1.0] + )); + + // The same hit is beyond a short native sun segment, so it must not + // occlude the light even though the terrain contains a surface. + let short_segment_camera = FreeFlightCamera { + far_plane: 1.0, + ..blocked_camera + }; + assert!(native_sun_unoccluded( + &terrain, + &short_segment_camera, + [0.0, 0.0, -1.0] + )); + + let world_mesh = VulkanStaticMesh::smoke_triangle(); + assert!(!native_sun_unoccluded_with_world( + &TerrainWorld::default(), + &world_mesh, + &[0], + &blocked_camera, + [0.0, 0.0, -1.0] + )); + assert!(native_sun_unoccluded_with_world( + &TerrainWorld::default(), + &world_mesh, + &[], + &blocked_camera, + [0.0, 0.0, -1.0] + )); + + let excluded_terrain = + TerrainWorld::from_land_msh(&fparkan_terrain_format::LandMeshDocument { + streams: Vec::new(), + nodes_raw: Vec::new(), + slots: fparkan_terrain_format::TerrainSlotTable { + header_raw: Vec::new(), + slots_raw: Vec::new(), + }, + positions: vec![[0.0, 0.0, 32.0], [1.0, 0.0, 32.0], [0.0, 1.0, 32.0]], + normals: Vec::new(), + uv0: Vec::new(), + accelerator: Vec::new(), + aux14: Vec::new(), + aux18: Vec::new(), + faces: vec![fparkan_terrain_format::TerrainFace28 { + flags: FullSurfaceMask(0x20), + material_tag: 0, + aux_tag: 0, + vertices: [0, 1, 2], + neighbors: [None, None, None], + tail_raw: [0; 8], + raw: [0; 28], + }], + }) + .expect("excluded terrain"); + assert!(native_sun_unoccluded_with_world( + &excluded_terrain, + &world_mesh, + &[], + &blocked_camera, + [0.0, 0.0, -1.0] + )); + + let mut excluded_world_mesh = world_mesh.clone(); + excluded_world_mesh.draw_ranges[0].batch_flags = 0x0008; + assert!(native_sun_unoccluded_with_world( + &TerrainWorld::default(), + &excluded_world_mesh, + &[0], + &blocked_camera, + [0.0, 0.0, -1.0] + )); + } + + #[test] + fn world_sort_key_uses_the_first_indexed_vertex() { + let mesh = VulkanStaticMesh::smoke_triangle(); + let distance = first_indexed_vertex_distance(&mesh, 0, [0.0, -0.55, -3.0]) + .expect("first indexed vertex distance"); + assert!((distance - 3.0).abs() < 1.0e-6); + } + + #[test] + fn free_flight_camera_advances_wasd_and_vertical_controls() { + let mut camera = FreeFlightCamera { + position: [0.0, 0.0, 0.0], + yaw: 0.0, + pitch: 0.0, + vertical_fov: 1.0, + near_plane: 0.1, + far_plane: 100.0, + move_speed: 2.0, + }; + let mut keys = HashSet::new(); + keys.insert(KeyCode::KeyW); + keys.insert(KeyCode::KeyD); + camera.advance(&keys, 1.0); + let diagonal = 2.0 / 2.0_f32.sqrt(); + assert!((camera.position[0] - diagonal).abs() < 1.0e-5); + assert!((camera.position[1] + diagonal).abs() < 1.0e-5); + assert_eq!(camera.position[2], 0.0); + + keys.clear(); + keys.insert(KeyCode::KeyE); + keys.insert(KeyCode::ShiftLeft); + camera.advance(&keys, 0.5); + assert!((camera.position[2] - 4.0).abs() < 1.0e-5); + } + + #[test] + fn free_flight_mouse_right_turns_toward_previous_right_and_vertical_is_world_z() { + let mut camera = FreeFlightCamera { + position: [0.0, 0.0, 0.0], + yaw: 0.0, + pitch: 0.6, + vertical_fov: 1.0, + near_plane: 0.1, + far_plane: 100.0, + move_speed: 2.0, + }; + let old_right = camera.right(); + let old_forward = camera.forward(); + camera.look_delta(100.0, 0.0); + assert!(dot3(camera.forward(), old_right) > 0.0); + assert!(dot3(camera.forward(), old_forward) < 1.0); + + let mut keys = HashSet::new(); + keys.insert(KeyCode::KeyE); + camera.advance(&keys, 0.5); + assert!((camera.position[0]).abs() < 1.0e-5); + assert!((camera.position[1]).abs() < 1.0e-5); + assert!((camera.position[2] - 1.0).abs() < 1.0e-5); + } + + #[test] fn parses_validation_flag() { let parsed = Args::parse(&strings(&[ "--root", @@ -1045,6 +6261,61 @@ mod tests { } #[test] + fn static_preview_control_defaults_apply_native_flags_and_last_row_wins() { + let binding = + |node_index, frame_a, frame_b, initial_blend, flags| PreparedControlNodeBinding { + node_index, + frame_a, + frame_b, + initial_blend, + flags, + }; + let times = static_preview_node_times( + 6, + &[ + binding(1, 1.0, 3.0, 0.0, 0), + binding(2, 1.0, 3.0, 1.25, 1), + binding(3, 1.0, 3.0, -0.5, 1), + binding(4, 1.0, 3.0, -0.5, 0), + binding(1, 1.0, 3.0, 0.25, 2), + binding(usize::MAX, f32::NAN, f32::INFINITY, f32::NAN, 4), + ], + ) + .expect("valid static CTLD defaults"); + let values = times + .iter() + .map(|time| time.map(|time| time.0)) + .collect::<Vec<_>>(); + assert_eq!( + values, + vec![ + Some(0.0), + Some(2.5), + Some(1.5), + Some(2.0), + Some(1.0), + Some(0.0) + ] + ); + } + + #[test] + fn static_preview_rejects_negative_derived_control_sample_time() { + let error = static_preview_node_times( + 1, + &[PreparedControlNodeBinding { + node_index: 0, + frame_a: 0.0, + frame_b: 2.0, + initial_blend: -2.0, + flags: 1, + }], + ) + .expect_err("one wrap still leaves an unsupported negative sample time"); + assert!(error.contains("negative or non-finite sample time")); + } + + #[test] fn parses_static_material_phase() { let parsed = Args::parse(&strings(&[ "--root", @@ -1146,11 +6417,13 @@ mod tests { &mut merged, VulkanStaticMesh::smoke_triangle(), &[(0, 4)], + None, )?; append_static_preview_component( &mut merged, VulkanStaticMesh::smoke_triangle(), &[(0, 9)], + None, )?; assert_eq!(merged.vertices.len(), 6); @@ -1168,7 +6441,10 @@ mod tests { let vertex = fparkan_render_vulkan::VulkanStaticVertex { position: [0.0, 0.0, 0.0], color: [1.0, 1.0, 1.0], + normal: [0.0, 0.0, 1.0], uv: [0.0, 0.0], + detail_uv: [0.0, 0.0], + overlay_alpha: 0.0, }; let mut merged = VulkanStaticMesh { vertices: vec![vertex; usize::from(u16::MAX) + 1], @@ -1180,9 +6456,89 @@ mod tests { &mut merged, VulkanStaticMesh::smoke_triangle(), &[(0, 0)], + None, )?; assert_eq!(merged.indices, vec![65_536, 65_537, 65_538]); Ok(()) } + + #[test] + fn precipitation_upload_reconstructs_native_ndc_depths_and_uvs() { + let camera = FreeFlightCamera { + position: [10.0, 20.0, 30.0], + yaw: 0.0, + pitch: 0.0, + vertical_fov: std::f32::consts::FRAC_PI_2, + near_plane: 0.1, + far_plane: 100.0, + move_speed: 1.0, + }; + let screen = ScreenBillboard { + world_head: [0.0; 3], + world_tail: [0.0; 3], + head: [0.0, 0.0], + tail: [0.0, 0.0], + corners: [[-0.25, -0.5], [0.25, -0.5], [-0.25, 0.5], [0.25, 0.5]], + uv: [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6], [0.7, 0.8]], + half_size: 12.0, + head_depth: 8.0, + tail_depth: 4.0, + }; + let vertices = precipitation_quad_vertices( + PrecipitationKind::Rain, + &screen, + [1.0, 0.5, 0.25, 0.75], + &camera, + [100.0, 100.0], + ); + assert_eq!(vertices[0].uv, screen.uv[0]); + assert_eq!(vertices[3].uv, screen.uv[3]); + assert!( + (dot3( + sub3(vertices[0].position, camera.position), + camera.forward() + ) - 4.0) + .abs() + < 1.0e-5 + ); + assert!( + (dot3( + sub3(vertices[3].position, camera.position), + camera.forward() + ) - 8.0) + .abs() + < 1.0e-5 + ); + assert_eq!(vertices[0].overlay_alpha, 0.75); + } + + #[test] + fn sprite_upload_remaps_native_left_top_uv_order_to_quad_positions() { + let camera = FreeFlightCamera { + position: [0.0; 3], + yaw: 0.0, + pitch: 0.0, + vertical_fov: 1.0, + near_plane: 0.1, + far_plane: 100.0, + move_speed: 1.0, + }; + let uv = [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [7.0, 8.0]]; + let vertices = sprite_quad_vertices_pixels( + &camera, + camera.forward(), + 10.0, + 1.0, + 1.0, + [100.0, 100.0], + [1.0; 3], + 1.0, + uv, + ); + assert_eq!( + vertices.map(|vertex| vertex.uv), + [uv[1], uv[2], uv[0], uv[3]] + ); + } } |
