diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs index e8e18ac..c3565e1 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs @@ -1,7 +1,7 @@ #![allow(unsafe_code)] use ash::vk; -use fparkan_render::LegacyDepthMode; +use fparkan_render::{LegacyDepthMode, LegacyPipelineState}; use super::smoke_types::resolve_draw_texture_indices; use super::update_material_texture_descriptor; @@ -434,6 +434,7 @@ impl VulkanSmokeRenderer { draw_range_sort_keys: vec![None; create_info.mesh.draw_ranges.len()], draw_order_dirty: false, draw_range_world_queue: vec![None; create_info.mesh.draw_ranges.len()], + world_transparent_background_ranges: Vec::new(), draw_range_base_pipeline_states: create_info .mesh .draw_ranges @@ -878,6 +879,74 @@ impl VulkanSmokeRenderer { Ok(()) } + /// Applies the active MAT0 phase's blend and alpha-test category to one + /// range while retaining its authored depth and cull state. + pub fn set_draw_range_material_category( + &mut self, + range_index: usize, + category: u8, + ) -> Result<(), VulkanSmokeRendererError> { + let base_state = *self + .draw_range_base_pipeline_states + .get(range_index) + .ok_or(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic draw range index is out of bounds", + })?; + let world_queue = self + .draw_range_world_queue + .get(range_index) + .copied() + .ok_or(VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic draw range index is out of bounds", + })?; + // Fixed sky, sprite, weather, and shadow ranges own explicit states. + // MAT0 categories apply only after a range has entered the world queue. + let (next_base, next_state) = + world_material_pipeline_states(base_state, category, world_queue).ok_or( + VulkanSmokeRendererError::InvalidStaticMesh { + context: "MAT0 pipeline category is outside the native 0..=4 table", + }, + )?; + let range = self.draw_ranges.get_mut(range_index).ok_or( + VulkanSmokeRendererError::InvalidStaticMesh { + context: "dynamic draw range index is out of bounds", + }, + )?; + self.draw_range_base_pipeline_states[range_index] = next_base; + let pipeline_changed = range.pipeline_state != next_state; + range.pipeline_state = next_state; + if pipeline_changed { + self.rebuild_pipeline_resources()?; + } + Ok(()) + } + + /// Places nebula, dome, and cloud ranges between opaque and transparent + /// world draws. The list is explicit so unrelated fixed passes keep their + /// established order. + pub fn set_world_transparent_background_ranges( + &mut self, + range_indices: &[usize], + ) -> Result<(), VulkanSmokeRendererError> { + let mut seen = vec![false; self.draw_ranges.len()]; + for &range_index in range_indices { + let Some(slot) = seen.get_mut(range_index) else { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "world background range index is out of bounds", + }); + }; + if *slot || self.draw_range_world_queue[range_index].is_some() { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "world background range is duplicated or already queued", + }); + } + *slot = true; + } + self.world_transparent_background_ranges = range_indices.to_vec(); + self.draw_order_dirty = true; + Ok(()) + } + /// Marks a draw range as belonging to the native transparent queue. /// /// The flag is deliberately independent of the blend category: native @@ -886,14 +955,25 @@ impl VulkanSmokeRenderer { /// depth attachment switches to depth-test/read-only. The original depth /// state is retained so disabling the flag restores the caller's state. /// - /// The transparent flag only participates in the world draw order. The - /// backend keeps every unmarked range in its original slot, which leaves - /// the fixed screen/sky/weather pass order intact. + /// The transparent flag only participates in the world draw order. Without + /// explicit backgrounds, opaque and transparent ranges replace the + /// existing world slots. With explicit backgrounds, opaque ranges compact + /// into those slots and the sorted transparent ranges are inserted after + /// the last background; unrelated fixed ranges keep relative order but + /// may shift slots. pub fn set_draw_range_transparency( &mut self, range_index: usize, transparent: bool, ) -> Result<(), VulkanSmokeRendererError> { + if self + .world_transparent_background_ranges + .contains(&range_index) + { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "world background range cannot join the world queue", + }); + } let base_state = *self .draw_range_base_pipeline_states .get(range_index) @@ -905,13 +985,7 @@ impl VulkanSmokeRenderer { context: "dynamic draw range index is out of bounds", }, )?; - let depth = if transparent && base_state.depth != LegacyDepthMode::Disabled { - LegacyDepthMode::TestReadOnly - } else { - base_state.depth - }; - let mut next_state = base_state; - next_state.depth = depth; + let next_state = apply_world_transparency(base_state, transparent); let pipeline_changed = range.pipeline_state != next_state; range.pipeline_state = next_state; // Calling this setter opts the range into the world queue. Fixed @@ -1499,11 +1573,13 @@ impl VulkanSmokeRenderer { self.rebuild_swapchain_resources(true) } - /// Recomputes the stable command-recording order. Only ranges explicitly - /// marked transparent are reordered, and they remain in the slots occupied - /// by that queue. This preserves the caller's fixed sky/screen/weather - /// pass order while matching the native descending key comparator for the - /// world transparent queue. + /// Recomputes the stable command-recording order. Without explicit + /// backgrounds, opaque world ranges followed by the sorted transparent + /// world ranges replace the existing world slots. With explicit + /// backgrounds, opaque ranges compact into the world slots and sorted + /// transparent ranges are inserted after the last background; unrelated + /// fixed ranges keep relative order but may shift slots. The transparent + /// queue matches the native descending key comparator. fn rebuild_draw_order(&mut self) { let range_count = self.draw_ranges.len(); if self.draw_order.len() != range_count { @@ -1514,6 +1590,7 @@ impl VulkanSmokeRenderer { &self.draw_ranges, &self.draw_range_world_queue, &self.draw_range_sort_keys, + &self.world_transparent_background_ranges, ); } @@ -2109,15 +2186,46 @@ fn apply_geometry_range_updates( } } +fn apply_world_transparency( + base_state: LegacyPipelineState, + transparent: bool, +) -> LegacyPipelineState { + if transparent && base_state.depth != LegacyDepthMode::Disabled { + LegacyPipelineState { + depth: LegacyDepthMode::TestReadOnly, + ..base_state + } + } else { + base_state + } +} + +fn world_material_pipeline_states( + original: LegacyPipelineState, + category: u8, + world_queue: Option, +) -> Option<(LegacyPipelineState, LegacyPipelineState)> { + let categorized = original.with_material_category(category)?; + let Some(transparent) = world_queue else { + return Some((original, original)); + }; + let base = categorized; + Some((base, apply_world_transparency(base, transparent))) +} + /// Replays the native queue construction from the original stable range -/// order. Fixed passes stay in their original slots; world opaque ranges are -/// emitted first, followed by world transparent ranges inserted by descending -/// camera distance. Zero-index-count ranges are inactive and are omitted. +/// order. Without explicit background ranges, world opaque ranges replace the +/// existing world slots and world transparent ranges follow in descending +/// camera distance order. With explicit backgrounds, opaque ranges are +/// compacted into world positions and sorted transparent ranges are inserted +/// after the last active background; fixed passes keep their relative order but +/// can shift slots. Zero-index-count ranges are inactive and are omitted. fn sorted_world_draw_order( range_count: usize, ranges: &[super::VulkanStaticDrawRange], world_queue: &[Option], keys: &[Option], + transparent_background_ranges: &[usize], ) -> Vec { let mut next = (0..range_count).collect::>(); let mut world_opaque = Vec::new(); @@ -2176,33 +2284,70 @@ fn sorted_world_draw_order( ordered_transparent.insert(low, range_index); } - let mut world_order = world_opaque; - world_order.extend(ordered_transparent); - let mut world_cursor = 0usize; - for slot in &mut next { - if world_queue.get(*slot).copied().flatten().is_some() - && ranges - .get(*slot) - .is_some_and(|range| range.index_count != 0) - { - if let Some(&replacement) = world_order.get(world_cursor) { - *slot = replacement; - world_cursor += 1; + if transparent_background_ranges.is_empty() { + let mut world_order = world_opaque; + world_order.extend(ordered_transparent); + let mut world_cursor = 0usize; + for slot in &mut next { + if world_queue.get(*slot).copied().flatten().is_some() + && ranges + .get(*slot) + .is_some_and(|range| range.index_count != 0) + { + if let Some(&replacement) = world_order.get(world_cursor) { + *slot = replacement; + world_cursor += 1; + } } } + return next; } - next + + // Fixed sky backgrounds are not world-queue members. Remove the world + // transparent slots, compact opaque ranges into the world slots, then + // insert the sorted transparent queue after the explicit background group. + let mut opaque_cursor = 0usize; + let mut next_without_transparency = Vec::with_capacity(range_count); + for slot in 0..range_count { + let active_world = ranges.get(slot).is_some_and(|range| range.index_count != 0); + match world_queue.get(slot).copied().flatten() { + Some(true) if active_world => {} + Some(false) if active_world => { + if let Some(&replacement) = world_opaque.get(opaque_cursor) { + next_without_transparency.push(replacement); + opaque_cursor += 1; + } else { + next_without_transparency.push(slot); + } + } + _ => next_without_transparency.push(slot), + } + } + let insertion = transparent_background_ranges + .iter() + .filter_map(|background| { + next_without_transparency + .iter() + .position(|range| range == background) + }) + .max() + .map(|index| index + 1); + if let Some(insertion) = insertion { + next_without_transparency.splice(insertion..insertion, ordered_transparent); + } + next_without_transparency } #[cfg(test)] mod tests { use super::super::{VulkanDynamicDrawRange, VulkanStaticDrawRange, VulkanStaticMesh}; use super::{ - apply_geometry_range_updates, completed_readback_buffer_index, sorted_world_draw_order, - take_runtime_children_with_validation_snapshot, take_runtime_owners_in_dependency_order, - validate_geometry_upload, RollbackOnDrop, + apply_geometry_range_updates, apply_world_transparency, completed_readback_buffer_index, + sorted_world_draw_order, take_runtime_children_with_validation_snapshot, + take_runtime_owners_in_dependency_order, validate_geometry_upload, + world_material_pipeline_states, RollbackOnDrop, }; - use fparkan_render::{LegacyBlendMode, LegacyDepthMode, LegacyPipelineState}; + use fparkan_render::{LegacyBlendMode, LegacyCullMode, LegacyDepthMode, LegacyPipelineState}; use std::cell::RefCell; use std::rc::Rc; @@ -2506,6 +2651,7 @@ mod tests { &ranges, &[None, Some(true), None, Some(true), Some(false)], &[None, Some(2.0), None, Some(9.0), Some(-3.0)], + &[], ); assert_eq!(order, vec![0, 4, 2, 3, 1]); } @@ -2546,6 +2692,7 @@ mod tests { &ranges, &[None, Some(true), Some(true)], &[None, Some(100.0), Some(1.0)], + &[], ); assert_eq!(order, vec![0, 1, 2]); } @@ -2563,7 +2710,99 @@ mod tests { alpha_test_reference: 0, }) .collect::>(); - let order = sorted_world_draw_order(4, &ranges, &[Some(true); 4], &[Some(1.0); 4]); + let order = sorted_world_draw_order(4, &ranges, &[Some(true); 4], &[Some(1.0); 4], &[]); assert_eq!(order, vec![2, 3, 1, 0]); } + + #[test] + fn tree_canopy_queue_follows_sky_and_precedes_foreground_effects() { + const SCREEN_GRADIENT: usize = 0; + const TERRAIN: usize = 1; + const CANOPY_NEAR: usize = 2; + const BUILDING: usize = 3; + const CANOPY_FAR: usize = 4; + const SHADOW: usize = 5; + const NEBULA: usize = 6; + const DOME: usize = 7; + const CLOUDS: usize = 8; + const SUN: usize = 9; + const WEATHER: usize = 10; + let ranges = (0..11) + .map(|index| VulkanStaticDrawRange { + first_index: index * 3, + index_count: 3, + material_index: 0, + lightmap_index: u8::MAX, + batch_flags: 0, + pipeline_state: LegacyPipelineState::default(), + alpha_test_reference: 0, + }) + .collect::>(); + let mut world_queue = vec![None; ranges.len()]; + world_queue[TERRAIN] = Some(false); + world_queue[CANOPY_NEAR] = Some(true); + world_queue[BUILDING] = Some(false); + world_queue[CANOPY_FAR] = Some(true); + let mut sort_keys = vec![None; ranges.len()]; + sort_keys[CANOPY_NEAR] = Some(2.0); + sort_keys[CANOPY_FAR] = Some(9.0); + let order = sorted_world_draw_order( + ranges.len(), + &ranges, + &world_queue, + &sort_keys, + &[NEBULA, DOME, CLOUDS], + ); + assert_eq!( + order, + vec![ + SCREEN_GRADIENT, + TERRAIN, + BUILDING, + SHADOW, + NEBULA, + DOME, + CLOUDS, + CANOPY_FAR, + CANOPY_NEAR, + SUN, + WEATHER, + ] + ); + } + + #[test] + fn material_category_and_transparency_preserve_world_state_in_either_order() { + let original = LegacyPipelineState { + blend: LegacyBlendMode::Opaque, + depth: LegacyDepthMode::TestWrite, + cull: LegacyCullMode::FrontFace, + alpha_test: false, + }; + let (base, category_first) = + world_material_pipeline_states(original, 1, Some(true)).expect("category 1"); + let transparency_first = apply_world_transparency(original, true) + .with_material_category(1) + .expect("category 1 after transparency"); + assert_eq!(category_first, transparency_first); + assert_eq!(base.blend, LegacyBlendMode::SourceAlpha); + assert!(base.alpha_test); + assert_eq!(base.depth, LegacyDepthMode::TestWrite); + assert_eq!(base.cull, LegacyCullMode::FrontFace); + assert_eq!(category_first.depth, LegacyDepthMode::TestReadOnly); + assert_eq!(category_first.cull, LegacyCullMode::FrontFace); + + let fixed_state = LegacyPipelineState { + blend: LegacyBlendMode::Additive, + depth: LegacyDepthMode::Disabled, + cull: LegacyCullMode::Disabled, + alpha_test: false, + }; + assert_eq!( + world_material_pipeline_states(fixed_state, 1, None), + Some((fixed_state, fixed_state)) + ); + assert!(world_material_pipeline_states(original, 5, Some(false)).is_none()); + assert!(world_material_pipeline_states(fixed_state, 5, None).is_none()); + } } diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs index 75877ad..c41eff9 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs @@ -449,6 +449,8 @@ impl VulkanStaticDrawRange { pub struct VulkanStaticMaterial { /// Positional material selector used by one or more source batches. pub material_index: u16, + /// Native MAT0 pipeline category selected for this phase (0..=4). + pub pipeline_category: u8, /// Decoded RGBA8 diffuse texture for this selector. pub texture: VulkanStaticTexture, /// Phase parameters 4..6, normalized directional light RGB coefficient. @@ -1003,6 +1005,7 @@ mod static_mesh_tests { let materials = [ VulkanStaticMaterial { material_index: 2, + pipeline_category: 0, texture: texture(), detail_texture: None, overlay_texture: None, @@ -1028,6 +1031,7 @@ mod static_mesh_tests { }, VulkanStaticMaterial { material_index: 7, + pipeline_category: 0, texture: texture(), detail_texture: None, overlay_texture: None, @@ -1379,6 +1383,8 @@ pub struct VulkanSmokeRenderer { /// a fixed screen/sky/weather pass; `Some(false)` is world opaque and /// `Some(true)` is world transparent. pub(super) draw_range_world_queue: Vec>, + /// Fixed sky background ranges placed between world opaque and transparent. + pub(super) world_transparent_background_ranges: Vec, /// Pipeline state before transparent queue depth policy is applied. pub(super) draw_range_base_pipeline_states: Vec, pub(super) material_alphas: Vec<[f32; 2]>, diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index c974b6b..abfb6da 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -657,6 +657,7 @@ impl MaterialPhaseBinding { struct MaterialVariantSet { phase_material_indices: Vec, range_indices: Vec, + pipeline_category: u8, } #[derive(Clone, Debug)] @@ -676,6 +677,19 @@ struct ActiveMaterialState { diffuse_alpha: f32, } +fn selected_material_phase( + variant: &MaterialVariantSet, + phase_index: usize, + pipeline_category_for: impl FnOnce(usize) -> Option, +) -> Result<(usize, u8), String> { + let material_index = selected_material_variant_index(variant, phase_index) + .ok_or_else(|| "MAT0 material animation has no texture variant".to_string())?; + let pipeline_category = pipeline_category_for(material_index).ok_or_else(|| { + format!("MAT0 material animation selector {material_index} is out of bounds") + })?; + Ok((material_index, pipeline_category)) +} + fn update_material_animations( bindings: &mut [MaterialAnimationBinding], materials: &[VulkanStaticMaterial], @@ -706,9 +720,13 @@ fn update_material_animations( .map(|overlay_detail| overlay_detail.sample(binding.initial_phase_index, clock_ms)) .transpose()?; let mut updated_materials = HashSet::new(); - for variant in &binding.variants { - 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 variant in &mut binding.variants { + let (material_index, pipeline_category) = + selected_material_phase(variant, base_sample.phase_index, |index| { + materials + .get(index) + .map(|material| material.pipeline_category) + })?; for &range_index in &variant.range_indices { active_materials.insert( range_index, @@ -720,7 +738,15 @@ fn update_material_animations( renderer .set_draw_range_material(range_index, material_index) .map_err(|error| format!("update MAT0 material draw selector: {error}"))?; + if variant.pipeline_category != pipeline_category { + renderer + .set_draw_range_material_category(range_index, pipeline_category) + .map_err(|error| { + format!("update MAT0 material pipeline category: {error}") + })?; + } } + variant.pipeline_category = pipeline_category; if !updated_materials.insert(material_index) { continue; } @@ -887,6 +913,37 @@ fn native_world_transparent(range: &VulkanStaticDrawRange, diffuse_alpha: f32) - diffuse_alpha < 1.0 || (range.batch_flags & 0x108) != 0 } +fn configure_world_material_categories( + mesh: &mut VulkanStaticMesh, + materials: &[VulkanStaticMaterial], + world_range_indices: &[usize], +) -> Result<(), String> { + for &range_index in world_range_indices { + let range = mesh + .draw_ranges + .get_mut(range_index) + .ok_or_else(|| "world material range index is out of bounds".to_string())?; + let material = materials + .get(usize::from(range.material_index)) + .ok_or_else(|| { + format!( + "world material selector {} is out of bounds", + range.material_index + ) + })?; + range.pipeline_state = range + .pipeline_state + .with_material_category(material.pipeline_category) + .ok_or_else(|| { + format!( + "world material selector {} has unsupported native pipeline category {}", + range.material_index, material.pipeline_category + ) + })?; + } + Ok(()) +} + fn register_material_range( bindings: &mut [MaterialAnimationBinding], material_index: usize, @@ -1164,6 +1221,7 @@ fn append_environment_material( })?; materials.push(VulkanStaticMaterial { material_index, + pipeline_category: phase.pipeline_category, texture: asset .phase_textures .get(phase_index) @@ -1201,6 +1259,7 @@ fn append_environment_material( variants: vec![MaterialVariantSet { phase_material_indices, range_indices: Vec::new(), + pipeline_category: phases[0].pipeline_category, }], base: MaterialPhaseBinding { document, @@ -1228,6 +1287,7 @@ fn append_environment_gradient_material( })?; materials.push(VulkanStaticMaterial { material_index, + pipeline_category: 0, texture: solid_environment_texture([255, 255, 255, 255]), directional_rgb: [1.0; 3], additive_rgb: [0.0; 3], @@ -1275,6 +1335,7 @@ fn append_shadow_material(materials: &mut Vec) -> Result Vec { + self.sky_layers + .iter() + .filter(|layer| { + matches!( + layer.kind, + SkyPassKind::NebulaStars | SkyPassKind::DomeGradient | SkyPassKind::Clouds + ) + }) + .map(|layer| layer.range.range_index) + .collect() + } + fn fixed_range_indices(&self) -> HashSet { self.sky_layers .iter() @@ -2289,9 +2363,9 @@ impl EnvironmentGpuScene { && *index != cloud_index })); old_order.push(*shadow_range_index); - old_order.extend([nebula_index, dome_index]); + old_order.extend([nebula_index, dome_index, cloud_index]); old_order.extend(sprite_indices.iter().copied()); - old_order.extend([cloud_index, rain_index, snow_index, lightning_index]); + old_order.extend([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 @@ -4165,6 +4239,7 @@ fn static_preview_terrain_base_materials( })?; materials.push(VulkanStaticMaterial { material_index: preview_selector, + pipeline_category: base_phase.pipeline_category, texture: base.clone(), detail_texture: Some(detail.clone()), overlay_texture: overlay.map(|(texture, _)| texture.clone()), @@ -4257,6 +4332,7 @@ fn static_preview_terrain_base_materials( variants: vec![MaterialVariantSet { phase_material_indices, range_indices: Vec::new(), + pipeline_category: base_phases[initial_phase_index].1.pipeline_category, }], base: MaterialPhaseBinding { document: base_document, @@ -4369,6 +4445,7 @@ fn static_preview_component_materials( })?; materials.push(VulkanStaticMaterial { material_index: preview_selector, + pipeline_category: material.pipeline_category, texture, detail_texture: None, overlay_texture: None, @@ -4405,6 +4482,7 @@ fn static_preview_component_materials( variants: vec![MaterialVariantSet { phase_material_indices, range_indices: Vec::new(), + pipeline_category: material.pipeline_category, }], base: MaterialPhaseBinding { document: material.mat0.clone(), @@ -4551,6 +4629,7 @@ fn append_static_preview_component_with_lightmaps( "static preview material selector {source_material_index} has no MAT0 animation binding" ) })?; + let initial_phase_index = bindings[binding_index].initial_phase_index; let lightmap = lightmap_textures .get(usize::from(range.lightmap_index)) .expect("lightmap presence checked above"); @@ -4590,6 +4669,16 @@ fn append_static_preview_component_with_lightmaps( materials.push(lightmap_material); phase_material_indices.push(usize::from(index)); } + let initial_selector = phase_material_indices + .get(initial_phase_index) + .copied() + .ok_or_else(|| "static preview lightmap phase table is empty".to_string())?; + let pipeline_category = materials + .get(initial_selector) + .map(|material| material.pipeline_category) + .ok_or_else(|| { + "static preview lightmap material selector is unavailable".to_string() + })?; let bindings = material_animations.as_deref_mut().ok_or_else(|| { "static preview lightmap animation storage is unavailable".to_string() })?; @@ -4597,6 +4686,7 @@ fn append_static_preview_component_with_lightmaps( bindings[binding_index].variants.push(MaterialVariantSet { phase_material_indices: phase_material_indices.clone(), range_indices: Vec::new(), + pipeline_category, }); let value = (phase_material_indices, binding_index, variant_index); lightmap_materials.insert(key, value.clone()); @@ -4913,6 +5003,16 @@ impl StaticVulkanApp { renderer .set_draw_range_transparency(range_index, transparent) .map_err(|error| format!("configure world draw range {range_index}: {error}"))?; + let category = self + .materials + .get(usize::from(range.material_index)) + .ok_or_else(|| "world draw range has no material selector".to_string())? + .pipeline_category; + renderer + .set_draw_range_material_category(range_index, category) + .map_err(|error| { + format!("configure world material category {range_index}: {error}") + })?; renderer .set_draw_range_index_count( range_index, @@ -5381,6 +5481,15 @@ impl ApplicationHandler for StaticVulkanApp { return; }; let gpu_initialization_started = Instant::now(); + if let Err(error) = configure_world_material_categories( + &mut self.mesh, + &self.materials, + &self.world_range_indices, + ) { + self.error = Some(error); + event_loop.exit(); + return; + } let mut renderer = match VulkanSmokeRenderer::new(&VulkanSmokeRendererCreateInfo { application_name: "fparkan-game".to_string(), native_handles, @@ -5399,6 +5508,15 @@ impl ApplicationHandler for StaticVulkanApp { return; } }; + let background_ranges = self.environment_gpu.as_ref().map_or_else( + Vec::new, + EnvironmentGpuScene::transparent_background_range_indices, + ); + if let Err(error) = renderer.set_world_transparent_background_ranges(&background_ranges) { + self.error = Some(format!("configure world background ranges: {error}")); + event_loop.exit(); + return; + } if let Err(error) = self.configure_world_draw_ranges(&mut renderer) { self.error = Some(error); event_loop.exit(); @@ -5975,6 +6093,7 @@ mod tests { variants: vec![MaterialVariantSet { phase_material_indices: vec![10], range_indices: vec![0, 2], + pipeline_category: 0, }], base: empty_binding(), detail: None, @@ -6029,6 +6148,30 @@ mod tests { )); } + #[test] + fn material_animation_selects_pipeline_category_with_active_phase() { + let variant = MaterialVariantSet { + phase_material_indices: vec![10, 11], + range_indices: vec![3], + pipeline_category: 1, + }; + let category_for = |material_index| match material_index { + 10 => Some(1), + 11 => Some(2), + _ => None, + }; + + assert_eq!( + selected_material_phase(&variant, 0, category_for), + Ok((10, 1)) + ); + assert_eq!( + selected_material_phase(&variant, 1, category_for), + Ok((11, 2)) + ); + assert!(selected_material_phase(&variant, 2, category_for).is_err()); + } + #[test] fn native_sun_object_source_types_map_to_world_kinds() { assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_FORT)), Some(3)); @@ -6187,6 +6330,7 @@ mod tests { variants: vec![MaterialVariantSet { phase_material_indices: vec![10, 11], range_indices: Vec::new(), + pipeline_category: 0, }], base: MaterialPhaseBinding { document: empty_mat0, @@ -6207,6 +6351,7 @@ mod tests { bindings[binding_index].variants.push(MaterialVariantSet { phase_material_indices: vec![20, 21], range_indices: vec![7], + pipeline_category: 0, }); let lightmap_variant = &bindings[binding_index].variants[1]; diff --git a/docs/reference/materials.md b/docs/reference/materials.md index bfeccf4..c148bc8 100644 --- a/docs/reference/materials.md +++ b/docs/reference/materials.md @@ -57,10 +57,12 @@ struct Phase34 { ### Pipeline category -Call site извлекает native category как `(attr1 >> 2) & 0xF` и передаёт её в -`LegacyPipelineState::with_material_category`. Таблица содержит пять -подтверждённых категорий; helper возвращает `None` для остальных значений и не -меняет уже выбранные depth и cull state. +Call site извлекает native category как `(attr1 >> 2) & 0xF` и сохраняет её на +разрешённом phase material. World draw ranges получают состояние для начального +selector при подготовке, а animated selector обновляет category вместе с +material selector. Fixed environment ranges keep their authored pipeline state. +Таблица содержит пять подтверждённых категорий; helper возвращает `None` для +остальных значений и не меняет уже выбранные depth и cull state. | category | source/destination colour factors | alpha test | | ---: | --- | --- | @@ -75,6 +77,13 @@ material category. Alpha reference remains draw-range data, while the Vulkan pipeline key includes the blend mode, depth mode, cull mode and alpha-test variant so ranges with different native state do not share a pipeline. +World transparency is a separate draw-queue decision. A world range joins that +queue when sampled diffuse opacity is below one or any bit in the native +`0x108` batch-flag mask is set; the MAT0 category alone does not make it +transparent. A queued range +with a depth attachment tests depth without writing it. Unsupported categories +fail world-range setup instead of silently falling back to opaque state. + Каждая phase занимает 34 байта: 18 parameter bytes и 16-byte C string имени текстуры. Loader переводит параметры в коэффициенты так: diff --git a/docs/reference/render-frame.md b/docs/reference/render-frame.md index 2c356dc..015f9f3 100644 --- a/docs/reference/render-frame.md +++ b/docs/reference/render-frame.md @@ -40,6 +40,47 @@ end-of-render. Подготовленный item должен ссылаться на immutable данные кадра. Изменение phase или texture cache посреди прохода не должно менять уже собранную очередь. +## MAT0 state and transparent backgrounds + +For mission world ranges, the selected MAT0 material supplies its native +pipeline category. The category sets blend and alpha-test state while preserving +the range's depth and cull state; an animated material selector updates the +category with the selector. Queue membership is independent: sampled opacity +below one or native batch flags `0x108` mark a world range transparent, and a +queued range with depth enabled tests depth without writing it. Fixed +environment ranges keep their explicit states. + +The fixed-camera preview explicitly marks nebula/stars, dome and cloud ranges as +transparent backgrounds. Opaque world ranges stay in their world slots; the +transparent world queue is sorted back-to-front and inserted immediately after +the last marked background. Other fixed ranges retain their relative order. +Thus cloud layers can be drawn before transparent foliage, while later sun, +weather and other foreground ranges keep their existing relative order. This +is a narrow queue rule, not a reordering of every sky or effects pass. + +## Regression evidence + +`cargo test -p fparkan-render-vulkan --lib material_category_and_transparency_preserve_world_state_in_either_order --offline` +checks that category 1 remains source-alpha with alpha testing, retains depth +and cull settings in either update order, and rejects unsupported categories. +`cargo test -p fparkan-render-vulkan --lib tree_canopy_queue_follows_sky_and_precedes_foreground_effects --offline` +checks the explicit queue order: screen gradient, opaque terrain/building, +shadow, nebula/dome/clouds, far-to-near transparent canopy, then sun/weather. + +Viewer-only paired readbacks used `--frames 3 --validation +--legacy-camera-capture` with the pre-fix and patched builds. On AutoDemo TMA +object 4 (`wor_l_n1.dat`, `fr_l_plant`), the 1280x720 camera at eye +`[464.319, 904.719, 38.0]`, aimed at `[374.319, 814.719, 12.0]` with FOV +`0.62`, changed the two black facade panels to tinted, textured portal +surfaces. On Mission.01 object 12 (`s_tree_04`), the 1280x1024 camera at eye +`[492.63855, 312.90244, 46.5]` aimed at +`[492.63855, 442.90244, 55.0]` crosses the canopy against open sky; paired +three-frame captures at the same synthetic atmosphere phase (600 seconds) show +the patched build restoring the missing upper/right fronds along that sky +boundary. Both runs reported clean Vulkan validation. These captures verify our +preview behavior and do not prove the native renderer's pass order, portal +transmittance or exact color. + ## GOG camera dispatch evidence For the GOG `World3D.dll` baseline with SHA-256 diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md index a3fa711..8b5b09f 100644 --- a/docs/tomes/06-behavior.md +++ b/docs/tomes/06-behavior.md @@ -275,14 +275,17 @@ buffers и передаёт собранную settings-структуру да `ret 0x0c`; `CreateCollManager` и `CreateCollObject` возвращают interface pointer с поправкой `+4` после внутренней инициализации. -Headless Ghidra 12.1.2 decompile GOG binary подтверждает ABI формой экспортов -`LoadControlSystem(char*, char*, char*, char*, char*, u32, void*, i32)`, +Инструкциям x86 в GOG `Control.dll` (`+0x32280`) и caller-у в `AniMesh.dll` +(`+0x32e7`) соответствует ABI `LoadControlSystem(char*, char*, char*, char*, +char*, char*, void*, i32)`: шесть C-string pointers, resource context и mode. +Функция заканчивается `ret 0x20`, то есть снимает все восемь 32-bit stack +arguments; arg7 передаётся constructor-у как context, arg8 выбирает mode. +Прежняя декомпиляция ошибочно определяла шестой строковый аргумент как `u32`. +Остальные exports имеют формы `LoadPhysicalModel(u32, u32, u32)`, `CreateCollManager(u32)` и -`CreateCollObject(u32, u32)`. Первые пять параметров Control loader — строки, -а mode передаётся последним; decompiler не восстанавливает предметные имена -остальных слов. `InitializeSettings` получает `CreateGameSettings()` из -World3D и делает virtual call slot `+0x24` с literal `0x15` и строкой по RVA -`0x42478`. +`CreateCollObject(u32, u32)`. `InitializeSettings` получает +`CreateGameSettings()` из World3D и делает virtual call slot `+0x24` с literal +`0x15` и строкой по RVA `0x42478`. Именно update methods этих private objects, а не пять exports, остаются следующим объектом динамической трассировки. Поэтому reference movement в