Fix material categories and transparent background ordering
Docs Deploy / Build and Deploy MkDocs (push) Successful in 40s

This commit is contained in:
2026-10-11 21:41:37 +04:00
parent bfe4db1611
commit c90ec80dc0
6 changed files with 496 additions and 53 deletions
+264 -25
View File
@@ -1,7 +1,7 @@
#![allow(unsafe_code)] #![allow(unsafe_code)]
use ash::vk; use ash::vk;
use fparkan_render::LegacyDepthMode; use fparkan_render::{LegacyDepthMode, LegacyPipelineState};
use super::smoke_types::resolve_draw_texture_indices; use super::smoke_types::resolve_draw_texture_indices;
use super::update_material_texture_descriptor; 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_range_sort_keys: vec![None; create_info.mesh.draw_ranges.len()],
draw_order_dirty: false, draw_order_dirty: false,
draw_range_world_queue: vec![None; create_info.mesh.draw_ranges.len()], 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 draw_range_base_pipeline_states: create_info
.mesh .mesh
.draw_ranges .draw_ranges
@@ -878,6 +879,74 @@ impl VulkanSmokeRenderer {
Ok(()) 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. /// Marks a draw range as belonging to the native transparent queue.
/// ///
/// The flag is deliberately independent of the blend category: native /// 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 /// depth attachment switches to depth-test/read-only. The original depth
/// state is retained so disabling the flag restores the caller's state. /// state is retained so disabling the flag restores the caller's state.
/// ///
/// The transparent flag only participates in the world draw order. The /// The transparent flag only participates in the world draw order. Without
/// backend keeps every unmarked range in its original slot, which leaves /// explicit backgrounds, opaque and transparent ranges replace the
/// the fixed screen/sky/weather pass order intact. /// 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( pub fn set_draw_range_transparency(
&mut self, &mut self,
range_index: usize, range_index: usize,
transparent: bool, transparent: bool,
) -> Result<(), VulkanSmokeRendererError> { ) -> 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 let base_state = *self
.draw_range_base_pipeline_states .draw_range_base_pipeline_states
.get(range_index) .get(range_index)
@@ -905,13 +985,7 @@ impl VulkanSmokeRenderer {
context: "dynamic draw range index is out of bounds", context: "dynamic draw range index is out of bounds",
}, },
)?; )?;
let depth = if transparent && base_state.depth != LegacyDepthMode::Disabled { let next_state = apply_world_transparency(base_state, transparent);
LegacyDepthMode::TestReadOnly
} else {
base_state.depth
};
let mut next_state = base_state;
next_state.depth = depth;
let pipeline_changed = range.pipeline_state != next_state; let pipeline_changed = range.pipeline_state != next_state;
range.pipeline_state = next_state; range.pipeline_state = next_state;
// Calling this setter opts the range into the world queue. Fixed // Calling this setter opts the range into the world queue. Fixed
@@ -1499,11 +1573,13 @@ impl VulkanSmokeRenderer {
self.rebuild_swapchain_resources(true) self.rebuild_swapchain_resources(true)
} }
/// Recomputes the stable command-recording order. Only ranges explicitly /// Recomputes the stable command-recording order. Without explicit
/// marked transparent are reordered, and they remain in the slots occupied /// backgrounds, opaque world ranges followed by the sorted transparent
/// by that queue. This preserves the caller's fixed sky/screen/weather /// world ranges replace the existing world slots. With explicit
/// pass order while matching the native descending key comparator for the /// backgrounds, opaque ranges compact into the world slots and sorted
/// world transparent queue. /// 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) { fn rebuild_draw_order(&mut self) {
let range_count = self.draw_ranges.len(); let range_count = self.draw_ranges.len();
if self.draw_order.len() != range_count { if self.draw_order.len() != range_count {
@@ -1514,6 +1590,7 @@ impl VulkanSmokeRenderer {
&self.draw_ranges, &self.draw_ranges,
&self.draw_range_world_queue, &self.draw_range_world_queue,
&self.draw_range_sort_keys, &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 /// Replays the native queue construction from the original stable range
/// order. Fixed passes stay in their original slots; world opaque ranges are /// order. Without explicit background ranges, world opaque ranges replace the
/// emitted first, followed by world transparent ranges inserted by descending /// existing world slots and world transparent ranges follow in descending
/// camera distance. Zero-index-count ranges are inactive and are omitted. /// 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( fn sorted_world_draw_order(
range_count: usize, range_count: usize,
ranges: &[super::VulkanStaticDrawRange], ranges: &[super::VulkanStaticDrawRange],
world_queue: &[Option<bool>], world_queue: &[Option<bool>],
keys: &[Option<f32>], keys: &[Option<f32>],
transparent_background_ranges: &[usize],
) -> Vec<usize> { ) -> Vec<usize> {
let mut next = (0..range_count).collect::<Vec<_>>(); let mut next = (0..range_count).collect::<Vec<_>>();
let mut world_opaque = Vec::new(); let mut world_opaque = Vec::new();
@@ -2176,6 +2284,7 @@ fn sorted_world_draw_order(
ordered_transparent.insert(low, range_index); ordered_transparent.insert(low, range_index);
} }
if transparent_background_ranges.is_empty() {
let mut world_order = world_opaque; let mut world_order = world_opaque;
world_order.extend(ordered_transparent); world_order.extend(ordered_transparent);
let mut world_cursor = 0usize; let mut world_cursor = 0usize;
@@ -2191,18 +2300,54 @@ fn sorted_world_draw_order(
} }
} }
} }
next 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),
}
}
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)] #[cfg(test)]
mod tests { mod tests {
use super::super::{VulkanDynamicDrawRange, VulkanStaticDrawRange, VulkanStaticMesh}; use super::super::{VulkanDynamicDrawRange, VulkanStaticDrawRange, VulkanStaticMesh};
use super::{ use super::{
apply_geometry_range_updates, completed_readback_buffer_index, sorted_world_draw_order, apply_geometry_range_updates, apply_world_transparency, completed_readback_buffer_index,
take_runtime_children_with_validation_snapshot, take_runtime_owners_in_dependency_order, sorted_world_draw_order, take_runtime_children_with_validation_snapshot,
validate_geometry_upload, RollbackOnDrop, 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::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
@@ -2506,6 +2651,7 @@ mod tests {
&ranges, &ranges,
&[None, Some(true), None, Some(true), Some(false)], &[None, Some(true), None, Some(true), Some(false)],
&[None, Some(2.0), None, Some(9.0), Some(-3.0)], &[None, Some(2.0), None, Some(9.0), Some(-3.0)],
&[],
); );
assert_eq!(order, vec![0, 4, 2, 3, 1]); assert_eq!(order, vec![0, 4, 2, 3, 1]);
} }
@@ -2546,6 +2692,7 @@ mod tests {
&ranges, &ranges,
&[None, Some(true), Some(true)], &[None, Some(true), Some(true)],
&[None, Some(100.0), Some(1.0)], &[None, Some(100.0), Some(1.0)],
&[],
); );
assert_eq!(order, vec![0, 1, 2]); assert_eq!(order, vec![0, 1, 2]);
} }
@@ -2563,7 +2710,99 @@ mod tests {
alpha_test_reference: 0, alpha_test_reference: 0,
}) })
.collect::<Vec<_>>(); .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]); 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());
}
} }
@@ -449,6 +449,8 @@ impl VulkanStaticDrawRange {
pub struct VulkanStaticMaterial { pub struct VulkanStaticMaterial {
/// Positional material selector used by one or more source batches. /// Positional material selector used by one or more source batches.
pub material_index: u16, 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. /// Decoded RGBA8 diffuse texture for this selector.
pub texture: VulkanStaticTexture, pub texture: VulkanStaticTexture,
/// Phase parameters 4..6, normalized directional light RGB coefficient. /// Phase parameters 4..6, normalized directional light RGB coefficient.
@@ -1003,6 +1005,7 @@ mod static_mesh_tests {
let materials = [ let materials = [
VulkanStaticMaterial { VulkanStaticMaterial {
material_index: 2, material_index: 2,
pipeline_category: 0,
texture: texture(), texture: texture(),
detail_texture: None, detail_texture: None,
overlay_texture: None, overlay_texture: None,
@@ -1028,6 +1031,7 @@ mod static_mesh_tests {
}, },
VulkanStaticMaterial { VulkanStaticMaterial {
material_index: 7, material_index: 7,
pipeline_category: 0,
texture: texture(), texture: texture(),
detail_texture: None, detail_texture: None,
overlay_texture: None, overlay_texture: None,
@@ -1379,6 +1383,8 @@ pub struct VulkanSmokeRenderer {
/// a fixed screen/sky/weather pass; `Some(false)` is world opaque and /// a fixed screen/sky/weather pass; `Some(false)` is world opaque and
/// `Some(true)` is world transparent. /// `Some(true)` is world transparent.
pub(super) draw_range_world_queue: Vec<Option<bool>>, 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. /// Pipeline state before transparent queue depth policy is applied.
pub(super) draw_range_base_pipeline_states: Vec<LegacyPipelineState>, pub(super) draw_range_base_pipeline_states: Vec<LegacyPipelineState>,
pub(super) material_alphas: Vec<[f32; 2]>, pub(super) material_alphas: Vec<[f32; 2]>,
+150 -5
View File
@@ -657,6 +657,7 @@ impl MaterialPhaseBinding {
struct MaterialVariantSet { struct MaterialVariantSet {
phase_material_indices: Vec<usize>, phase_material_indices: Vec<usize>,
range_indices: Vec<usize>, range_indices: Vec<usize>,
pipeline_category: u8,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -676,6 +677,19 @@ struct ActiveMaterialState {
diffuse_alpha: f32, 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( fn update_material_animations(
bindings: &mut [MaterialAnimationBinding], bindings: &mut [MaterialAnimationBinding],
materials: &[VulkanStaticMaterial], materials: &[VulkanStaticMaterial],
@@ -706,9 +720,13 @@ fn update_material_animations(
.map(|overlay_detail| overlay_detail.sample(binding.initial_phase_index, clock_ms)) .map(|overlay_detail| overlay_detail.sample(binding.initial_phase_index, clock_ms))
.transpose()?; .transpose()?;
let mut updated_materials = HashSet::new(); let mut updated_materials = HashSet::new();
for variant in &binding.variants { for variant in &mut binding.variants {
let material_index = selected_material_variant_index(variant, base_sample.phase_index) let (material_index, pipeline_category) =
.ok_or_else(|| "MAT0 material animation has no texture variant".to_string())?; selected_material_phase(variant, base_sample.phase_index, |index| {
materials
.get(index)
.map(|material| material.pipeline_category)
})?;
for &range_index in &variant.range_indices { for &range_index in &variant.range_indices {
active_materials.insert( active_materials.insert(
range_index, range_index,
@@ -720,7 +738,15 @@ fn update_material_animations(
renderer renderer
.set_draw_range_material(range_index, material_index) .set_draw_range_material(range_index, material_index)
.map_err(|error| format!("update MAT0 material draw selector: {error}"))?; .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) { if !updated_materials.insert(material_index) {
continue; continue;
} }
@@ -887,6 +913,37 @@ fn native_world_transparent(range: &VulkanStaticDrawRange, diffuse_alpha: f32) -
diffuse_alpha < 1.0 || (range.batch_flags & 0x108) != 0 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( fn register_material_range(
bindings: &mut [MaterialAnimationBinding], bindings: &mut [MaterialAnimationBinding],
material_index: usize, material_index: usize,
@@ -1164,6 +1221,7 @@ fn append_environment_material(
})?; })?;
materials.push(VulkanStaticMaterial { materials.push(VulkanStaticMaterial {
material_index, material_index,
pipeline_category: phase.pipeline_category,
texture: asset texture: asset
.phase_textures .phase_textures
.get(phase_index) .get(phase_index)
@@ -1201,6 +1259,7 @@ fn append_environment_material(
variants: vec![MaterialVariantSet { variants: vec![MaterialVariantSet {
phase_material_indices, phase_material_indices,
range_indices: Vec::new(), range_indices: Vec::new(),
pipeline_category: phases[0].pipeline_category,
}], }],
base: MaterialPhaseBinding { base: MaterialPhaseBinding {
document, document,
@@ -1228,6 +1287,7 @@ fn append_environment_gradient_material(
})?; })?;
materials.push(VulkanStaticMaterial { materials.push(VulkanStaticMaterial {
material_index, material_index,
pipeline_category: 0,
texture: solid_environment_texture([255, 255, 255, 255]), texture: solid_environment_texture([255, 255, 255, 255]),
directional_rgb: [1.0; 3], directional_rgb: [1.0; 3],
additive_rgb: [0.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(); let index = materials.len();
materials.push(VulkanStaticMaterial { materials.push(VulkanStaticMaterial {
material_index, material_index,
pipeline_category: 0,
texture: shadow_atlas_texture(), texture: shadow_atlas_texture(),
directional_rgb: [1.0; 3], directional_rgb: [1.0; 3],
additive_rgb: [0.0; 3], additive_rgb: [0.0; 3],
@@ -1859,6 +1920,19 @@ fn screen_gradient_quad_vertices(
} }
impl EnvironmentGpuScene { 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> { fn fixed_range_indices(&self) -> HashSet<usize> {
self.sky_layers self.sky_layers
.iter() .iter()
@@ -2289,9 +2363,9 @@ impl EnvironmentGpuScene {
&& *index != cloud_index && *index != cloud_index
})); }));
old_order.push(*shadow_range_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(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)?; let remap = reorder_draw_ranges(mesh, &old_order)?;
for layer in &mut scene.sky_layers { for layer in &mut scene.sky_layers {
layer.range.range_index = *remap layer.range.range_index = *remap
@@ -4165,6 +4239,7 @@ fn static_preview_terrain_base_materials(
})?; })?;
materials.push(VulkanStaticMaterial { materials.push(VulkanStaticMaterial {
material_index: preview_selector, material_index: preview_selector,
pipeline_category: base_phase.pipeline_category,
texture: base.clone(), texture: base.clone(),
detail_texture: Some(detail.clone()), detail_texture: Some(detail.clone()),
overlay_texture: overlay.map(|(texture, _)| texture.clone()), overlay_texture: overlay.map(|(texture, _)| texture.clone()),
@@ -4257,6 +4332,7 @@ fn static_preview_terrain_base_materials(
variants: vec![MaterialVariantSet { variants: vec![MaterialVariantSet {
phase_material_indices, phase_material_indices,
range_indices: Vec::new(), range_indices: Vec::new(),
pipeline_category: base_phases[initial_phase_index].1.pipeline_category,
}], }],
base: MaterialPhaseBinding { base: MaterialPhaseBinding {
document: base_document, document: base_document,
@@ -4369,6 +4445,7 @@ fn static_preview_component_materials(
})?; })?;
materials.push(VulkanStaticMaterial { materials.push(VulkanStaticMaterial {
material_index: preview_selector, material_index: preview_selector,
pipeline_category: material.pipeline_category,
texture, texture,
detail_texture: None, detail_texture: None,
overlay_texture: None, overlay_texture: None,
@@ -4405,6 +4482,7 @@ fn static_preview_component_materials(
variants: vec![MaterialVariantSet { variants: vec![MaterialVariantSet {
phase_material_indices, phase_material_indices,
range_indices: Vec::new(), range_indices: Vec::new(),
pipeline_category: material.pipeline_category,
}], }],
base: MaterialPhaseBinding { base: MaterialPhaseBinding {
document: material.mat0.clone(), 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" "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 let lightmap = lightmap_textures
.get(usize::from(range.lightmap_index)) .get(usize::from(range.lightmap_index))
.expect("lightmap presence checked above"); .expect("lightmap presence checked above");
@@ -4590,6 +4669,16 @@ fn append_static_preview_component_with_lightmaps(
materials.push(lightmap_material); materials.push(lightmap_material);
phase_material_indices.push(usize::from(index)); 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(|| { let bindings = material_animations.as_deref_mut().ok_or_else(|| {
"static preview lightmap animation storage is unavailable".to_string() "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 { bindings[binding_index].variants.push(MaterialVariantSet {
phase_material_indices: phase_material_indices.clone(), phase_material_indices: phase_material_indices.clone(),
range_indices: Vec::new(), range_indices: Vec::new(),
pipeline_category,
}); });
let value = (phase_material_indices, binding_index, variant_index); let value = (phase_material_indices, binding_index, variant_index);
lightmap_materials.insert(key, value.clone()); lightmap_materials.insert(key, value.clone());
@@ -4913,6 +5003,16 @@ impl StaticVulkanApp {
renderer renderer
.set_draw_range_transparency(range_index, transparent) .set_draw_range_transparency(range_index, transparent)
.map_err(|error| format!("configure world draw range {range_index}: {error}"))?; .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 renderer
.set_draw_range_index_count( .set_draw_range_index_count(
range_index, range_index,
@@ -5381,6 +5481,15 @@ impl ApplicationHandler for StaticVulkanApp {
return; return;
}; };
let gpu_initialization_started = Instant::now(); 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 { let mut renderer = match VulkanSmokeRenderer::new(&VulkanSmokeRendererCreateInfo {
application_name: "fparkan-game".to_string(), application_name: "fparkan-game".to_string(),
native_handles, native_handles,
@@ -5399,6 +5508,15 @@ impl ApplicationHandler for StaticVulkanApp {
return; 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) { if let Err(error) = self.configure_world_draw_ranges(&mut renderer) {
self.error = Some(error); self.error = Some(error);
event_loop.exit(); event_loop.exit();
@@ -5975,6 +6093,7 @@ mod tests {
variants: vec![MaterialVariantSet { variants: vec![MaterialVariantSet {
phase_material_indices: vec![10], phase_material_indices: vec![10],
range_indices: vec![0, 2], range_indices: vec![0, 2],
pipeline_category: 0,
}], }],
base: empty_binding(), base: empty_binding(),
detail: None, 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] #[test]
fn native_sun_object_source_types_map_to_world_kinds() { 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_FORT)), Some(3));
@@ -6187,6 +6330,7 @@ mod tests {
variants: vec![MaterialVariantSet { variants: vec![MaterialVariantSet {
phase_material_indices: vec![10, 11], phase_material_indices: vec![10, 11],
range_indices: Vec::new(), range_indices: Vec::new(),
pipeline_category: 0,
}], }],
base: MaterialPhaseBinding { base: MaterialPhaseBinding {
document: empty_mat0, document: empty_mat0,
@@ -6207,6 +6351,7 @@ mod tests {
bindings[binding_index].variants.push(MaterialVariantSet { bindings[binding_index].variants.push(MaterialVariantSet {
phase_material_indices: vec![20, 21], phase_material_indices: vec![20, 21],
range_indices: vec![7], range_indices: vec![7],
pipeline_category: 0,
}); });
let lightmap_variant = &bindings[binding_index].variants[1]; let lightmap_variant = &bindings[binding_index].variants[1];
+13 -4
View File
@@ -57,10 +57,12 @@ struct Phase34 {
### Pipeline category ### Pipeline category
Call site извлекает native category как `(attr1 >> 2) & 0xF` и передаёт её в Call site извлекает native category как `(attr1 >> 2) & 0xF` и сохраняет её на
`LegacyPipelineState::with_material_category`. Таблица содержит пять разрешённом phase material. World draw ranges получают состояние для начального
подтверждённых категорий; helper возвращает `None` для остальных значений и не selector при подготовке, а animated selector обновляет category вместе с
меняет уже выбранные depth и cull state. material selector. Fixed environment ranges keep their authored pipeline state.
Таблица содержит пять подтверждённых категорий; helper возвращает `None` для
остальных значений и не меняет уже выбранные depth и cull state.
| category | source/destination colour factors | alpha test | | 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 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. 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 имени Каждая phase занимает 34 байта: 18 parameter bytes и 16-byte C string имени
текстуры. Loader переводит параметры в коэффициенты так: текстуры. Loader переводит параметры в коэффициенты так:
+41
View File
@@ -40,6 +40,47 @@ end-of-render.
Подготовленный item должен ссылаться на immutable данные кадра. Изменение phase Подготовленный item должен ссылаться на immutable данные кадра. Изменение phase
или texture cache посреди прохода не должно менять уже собранную очередь. или 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 ## GOG camera dispatch evidence
For the GOG `World3D.dll` baseline with SHA-256 For the GOG `World3D.dll` baseline with SHA-256
+10 -7
View File
@@ -275,14 +275,17 @@ buffers и передаёт собранную settings-структуру да
`ret 0x0c`; `CreateCollManager` и `CreateCollObject` возвращают interface `ret 0x0c`; `CreateCollManager` и `CreateCollObject` возвращают interface
pointer с поправкой `+4` после внутренней инициализации. pointer с поправкой `+4` после внутренней инициализации.
Headless Ghidra 12.1.2 decompile GOG binary подтверждает ABI формой экспортов Инструкциям x86 в GOG `Control.dll` (`+0x32280`) и caller-у в `AniMesh.dll`
`LoadControlSystem(char*, char*, char*, char*, char*, u32, void*, i32)`, (`+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)` и `LoadPhysicalModel(u32, u32, u32)`, `CreateCollManager(u32)` и
`CreateCollObject(u32, u32)`. Первые пять параметров Control loader — строки, `CreateCollObject(u32, u32)`. `InitializeSettings` получает
а mode передаётся последним; decompiler не восстанавливает предметные имена `CreateGameSettings()` из World3D и делает virtual call slot `+0x24` с literal
остальных слов. `InitializeSettings` получает `CreateGameSettings()` из `0x15` и строкой по RVA `0x42478`.
World3D и делает virtual call slot `+0x24` с literal `0x15` и строкой по RVA
`0x42478`.
Именно update methods этих private objects, а не пять exports, остаются Именно update methods этих private objects, а не пять exports, остаются
следующим объектом динамической трассировки. Поэтому reference movement в следующим объектом динамической трассировки. Поэтому reference movement в