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
+276 -37
View File
@@ -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;
}
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]>,
+150 -5
View File
@@ -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];
+13 -4
View File
@@ -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 переводит параметры в коэффициенты так:
+41
View File
@@ -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
+10 -7
View File
@@ -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 в