Fix material categories and transparent background ordering
Docs Deploy / Build and Deploy MkDocs (push) Successful in 40s
Docs Deploy / Build and Deploy MkDocs (push) Successful in 40s
This commit is contained in:
@@ -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,6 +2284,7 @@ fn sorted_world_draw_order(
|
||||
ordered_transparent.insert(low, range_index);
|
||||
}
|
||||
|
||||
if transparent_background_ranges.is_empty() {
|
||||
let mut world_order = world_opaque;
|
||||
world_order.extend(ordered_transparent);
|
||||
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)]
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]>,
|
||||
|
||||
@@ -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,
|
||||
@@ -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];
|
||||
|
||||
@@ -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 переводит параметры в коэффициенты так:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 в
|
||||
|
||||
Reference in New Issue
Block a user