aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-10-11 20:41:37 +0300
committerValentin Popov <valentin@popov.link>2026-10-11 20:41:37 +0300
commitc90ec80dc0cc90e76b78f84d98d1ac9c5f02c4eb (patch)
tree727a76e3c32e0c6b94a2294bc29673fa2c1174b3
parentbfe4db16114d415475451ae49557c45833c0541e (diff)
downloadfparkan-c90ec80dc0cc90e76b78f84d98d1ac9c5f02c4eb.tar.xz
fparkan-c90ec80dc0cc90e76b78f84d98d1ac9c5f02c4eb.zip
Fix material categories and transparent background ordering
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke.rs313
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs6
-rw-r--r--apps/fparkan-game/src/main.rs155
-rw-r--r--docs/reference/materials.md17
-rw-r--r--docs/reference/render-frame.md41
-rw-r--r--docs/tomes/06-behavior.md17
6 files changed, 496 insertions, 53 deletions
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<bool>,
+) -> 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<bool>],
keys: &[Option<f32>],
+ transparent_background_ranges: &[usize],
) -> Vec<usize> {
let mut next = (0..range_count).collect::<Vec<_>>();
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;
+ }
+
+ // 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),
+ }
}
- next
+ 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::<Vec<_>>();
- 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::<Vec<_>>();
+ 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<Option<bool>>,
+ /// Fixed sky background ranges placed between world opaque and transparent.
+ pub(super) world_transparent_background_ranges: Vec<usize>,
/// Pipeline state before transparent queue depth policy is applied.
pub(super) draw_range_base_pipeline_states: Vec<LegacyPipelineState>,
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<usize>,
range_indices: Vec<usize>,
+ 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<u8>,
+) -> 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<VulkanStaticMaterial>) -> Result<u
let index = materials.len();
materials.push(VulkanStaticMaterial {
material_index,
+ pipeline_category: 0,
texture: shadow_atlas_texture(),
directional_rgb: [1.0; 3],
additive_rgb: [0.0; 3],
@@ -1859,6 +1920,19 @@ fn screen_gradient_quad_vertices(
}
impl EnvironmentGpuScene {
+ fn transparent_background_range_indices(&self) -> Vec<usize> {
+ 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<usize> {
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,
@@ -6030,6 +6149,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));
assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_BTLU)), Some(4));
@@ -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 в