17010 lines
649 KiB
Rust
17010 lines
649 KiB
Rust
#![forbid(unsafe_code)]
|
|
|
|
//! `FParkan` render-planning composition root.
|
|
|
|
use fparkan_animation::{AnimationTime, NodePoseBuffer};
|
|
use fparkan_assets::{
|
|
load_material_archive_name_texture_rgba8_and_phase_with_document_from_root,
|
|
load_material_name_texture_rgba8_and_phase_with_document_from_root,
|
|
load_resource_entry_bytes_from_root,
|
|
load_standalone_wear_named_material_textures_rgba8_and_phases_with_documents_from_root,
|
|
page_uv_transform, sample_control_node_times, sample_material_normalized_phase,
|
|
sample_material_phase, ControlInstanceState, ControlSamplingError, Mat0Document, MaterialPhase,
|
|
MaterialPhaseSample, PreparedComponentControl, PreparedControlEffectRow,
|
|
PreparedControlEffectSection, PreparedControlGateProvider, PreparedControlNodeBinding,
|
|
PreparedControlPoint, PreparedControlPointList, PreparedMaterialPhase, PreparedModel,
|
|
PreparedTexture, PreparedTextureUsage, PreparedVisual, RgbaImage, StandaloneWearMaterialLoader,
|
|
};
|
|
use fparkan_fx::ambient_op3::{
|
|
evaluate_op3_type0_command, evaluate_op3_type1_command, evaluate_op3_type2_command,
|
|
};
|
|
use fparkan_fx::ambient_sound::{Op2SoundCommand, Op2SoundState};
|
|
use fparkan_fx::atmosphere::{AtmosphereFrame, TypedAtmosphere};
|
|
use fparkan_fx::environment::{
|
|
decode_env_lightning_fxid, evaluate_op1_point_light_with_basis, Camera as FxCamera,
|
|
EnvironmentFrame, EnvironmentPrimitive, EnvironmentSystem, PrecipitationKind, ScreenBillboard,
|
|
};
|
|
use fparkan_fx::fx_visual::{evaluate_f_gener_ball, evaluate_f_gener_ball_mesh};
|
|
use fparkan_fx::op7::{
|
|
evaluate_op7_emitter, Op7EmitterState, Op7RenderContext, Op7VisibilityProfile,
|
|
};
|
|
use fparkan_fx::shadow::{
|
|
ShadowCamera, ShadowCaster, ShadowFrame, ShadowLight, ShadowLodSettings, ShadowPageCache,
|
|
ShadowScene, ShadowSphere, ShadowTriangle, SHADOW_PRIMARY_DIRECTIONAL_FLAG,
|
|
};
|
|
use fparkan_fx::sky::{
|
|
sprite_half_size_pixels, SkyFrame, SkyMaterials, SkyMesh, SkyPassKind, SkySpriteFrame,
|
|
SkySystem, SunVisibility, SKY_FLARE_ROWS,
|
|
};
|
|
use fparkan_fx::smoke::{evaluate_op8_emitter, Op8DetailThresholdRange, SmokeEmitterState};
|
|
use fparkan_fx::{advance_native_fx_random_state, decode_fxid, FxCommand, FxDocument, FxOpcode};
|
|
#[cfg(test)]
|
|
use fparkan_material::{MaterialAnimationBlock, MaterialKey};
|
|
use fparkan_msh::{
|
|
node38_fallback_hierarchy, node38_sampled_hierarchy, node38_sampled_hierarchy_at_times,
|
|
selected_slot, Group, Lod, ModelAsset, NodeId, SlotId,
|
|
};
|
|
use fparkan_path::ResourceName;
|
|
use fparkan_platform_winit::{window_native_handles, WinitWindow, WinitWindowPlan};
|
|
use fparkan_prototype::{unit_component_tree, PROTOTYPE_TYPE_EXTO};
|
|
use fparkan_render::{
|
|
legacy_msh_portal_alpha, LegacyBlendMode, LegacyCullMode, LegacyD3d7Projection,
|
|
LegacyDepthBias, LegacyDepthMode, LegacyIron3dEulerTransform, LegacyPipelineState,
|
|
RawCameraTransform,
|
|
};
|
|
use fparkan_render_vulkan::{
|
|
node38_pose, node38_pose_from_hierarchy, node38_pose_relative_to_root,
|
|
node38_pose_relative_to_root_from_hierarchy, project_land_msh_to_static_mesh_in_world_space,
|
|
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses,
|
|
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses_and_mount,
|
|
project_msh_to_static_mesh_in_world_space_with_node_pose_buffer,
|
|
project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount,
|
|
project_msh_to_static_mesh_in_world_space_with_node_sampled_poses,
|
|
project_msh_to_static_mesh_in_world_space_with_node_sampled_poses_and_mount,
|
|
VulkanDynamicDrawRange, VulkanNodePose, VulkanPointLight, VulkanReadbackArtifact,
|
|
VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo,
|
|
VulkanStaticCamera, VulkanStaticDrawRange, VulkanStaticMaterial, VulkanStaticMesh,
|
|
VulkanStaticTexture, VulkanStaticTextureMip, VulkanStaticVertex,
|
|
};
|
|
use fparkan_runtime::{
|
|
create, load_mission_static_preview, load_mission_static_preview_roots, loaded_mission_assets,
|
|
loaded_mission_object_drafts, loaded_terrain, query_building_segment_world,
|
|
BasementPlacementMode, BuildingMeshNode, EngineServices, MissionAssets, MissionMode,
|
|
MissionObjectDraft, MissionRequest,
|
|
};
|
|
#[cfg(test)]
|
|
use fparkan_terrain::BasementFoundationFragment;
|
|
use fparkan_terrain::{
|
|
build_basement_foundations, BasementFootprint, BasementFoundation, BasementPlacement,
|
|
FullSurfaceMask, SurfaceQuery, TerrainLod, TerrainMaterialLayers, TerrainWorld,
|
|
};
|
|
use fparkan_vfs::DirectoryVfs;
|
|
use serde::Deserialize;
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::io::Write;
|
|
use std::num::NonZeroUsize;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
use winit::application::ApplicationHandler;
|
|
use winit::dpi::PhysicalSize as WinitPhysicalSize;
|
|
use winit::event::{DeviceEvent, DeviceId, ElementState, MouseButton, WindowEvent};
|
|
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
|
|
use winit::keyboard::{KeyCode, PhysicalKey};
|
|
use winit::window::{CursorGrabMode, Window, WindowId};
|
|
|
|
mod audio;
|
|
|
|
fn main() {
|
|
let raw_args = std::env::args().skip(1).collect::<Vec<_>>();
|
|
let code = match run(&raw_args) {
|
|
Ok(output) => {
|
|
println!("{output}");
|
|
0
|
|
}
|
|
Err(err) => {
|
|
eprintln!("{err}");
|
|
2
|
|
}
|
|
};
|
|
std::process::exit(code);
|
|
}
|
|
|
|
fn run(args: &[String]) -> Result<String, String> {
|
|
let args = Args::parse(args)?;
|
|
let services = EngineServices::new(Arc::new(DirectoryVfs::new(&args.root)));
|
|
let mut engine = create(services).map_err(|err| err.to_string())?;
|
|
let mission_assets_started = Instant::now();
|
|
let loaded = load_requested_mission(&mut engine, &args)?;
|
|
let mission_assets_duration = mission_assets_started.elapsed();
|
|
|
|
let mission_assets = loaded_mission_assets(&engine)
|
|
.ok_or_else(|| "mission assets are unavailable after loading".to_string())?;
|
|
let terrain = loaded_terrain(&engine)
|
|
.ok_or_else(|| "mission terrain is unavailable after loading".to_string())?;
|
|
let roots = loaded_mission_object_drafts(&engine)
|
|
.map(|drafts| &drafts[..args.preview_roots.get().min(drafts.len())])
|
|
.filter(|roots| !roots.is_empty())
|
|
.ok_or_else(|| {
|
|
"selected mission object drafts are unavailable after loading".to_string()
|
|
})?;
|
|
let camera = args
|
|
.legacy_camera_capture
|
|
.as_deref()
|
|
.map(load_legacy_camera_capture)
|
|
.transpose()?;
|
|
let atmosphere_seconds = selected_atmosphere_seconds(args.atmosphere_seconds, camera.as_ref());
|
|
let hold_schedule_phase = camera.is_some() && atmosphere_seconds.is_some();
|
|
let terrain_materials_duration_started = Instant::now();
|
|
let preview = static_preview_mesh_and_materials(
|
|
mission_assets,
|
|
terrain,
|
|
roots,
|
|
camera,
|
|
args.static_animation_frame,
|
|
args.static_material_phase,
|
|
&args.root,
|
|
&loaded.land_msh_path,
|
|
)?;
|
|
let terrain_materials_duration = terrain_materials_duration_started.elapsed();
|
|
// Atmosphere files are mission-local assets. Resolving them from the
|
|
// entire install tree would silently attach the first unrelated mission's
|
|
// sky to the selected map (there are many `sky.ske` files in the campaign
|
|
// tree).
|
|
let sky_started = Instant::now();
|
|
let environment = load_environment(
|
|
&args.root,
|
|
&args.mission,
|
|
atmosphere_seconds,
|
|
hold_schedule_phase,
|
|
)?;
|
|
let sky_duration = sky_started.elapsed();
|
|
let audio = match audio::GameAudio::new(Arc::new(DirectoryVfs::new(&args.root)), &args.mission)
|
|
{
|
|
Ok(audio) => Some(audio),
|
|
Err(audio::AudioError::DeviceUnavailable { message }) => {
|
|
eprintln!("audio output unavailable; continuing without sound: {message}");
|
|
None
|
|
}
|
|
Err(error) => return Err(format!("load mission audio: {error}")),
|
|
};
|
|
run_static_vulkan_mode(
|
|
preview,
|
|
environment,
|
|
audio,
|
|
args.frames,
|
|
&args.mission,
|
|
loaded.object_count,
|
|
args.readback_out.as_deref(),
|
|
args.readback_sequence_out.as_deref(),
|
|
args.validation,
|
|
args.control_clock_ms,
|
|
StartupTimings {
|
|
mission_assets: mission_assets_duration,
|
|
terrain_materials: terrain_materials_duration,
|
|
sky: sky_duration,
|
|
gpu_initialization: Duration::ZERO,
|
|
},
|
|
)
|
|
}
|
|
|
|
fn load_requested_mission(
|
|
engine: &mut fparkan_runtime::Engine,
|
|
args: &Args,
|
|
) -> Result<fparkan_runtime::LoadedMission, String> {
|
|
let request = MissionRequest {
|
|
key: args.mission.clone(),
|
|
mode: MissionMode::SingleMission,
|
|
};
|
|
if args.preview_roots.get() == 1 {
|
|
load_mission_static_preview(engine, request)
|
|
} else {
|
|
load_mission_static_preview_roots(engine, request, args.preview_roots)
|
|
}
|
|
.map_err(|err| err.to_string())
|
|
}
|
|
|
|
/// Camera state used by the interactive world-space preview.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
struct FreeFlightCamera {
|
|
position: [f32; 3],
|
|
yaw: f32,
|
|
pitch: f32,
|
|
horizontal_fov: f32,
|
|
near_plane: f32,
|
|
far_plane: f32,
|
|
move_speed: f32,
|
|
ground_clearance: f32,
|
|
}
|
|
|
|
impl FreeFlightCamera {
|
|
fn from_mesh(mesh: &VulkanStaticMesh) -> Result<Self, String> {
|
|
let mut min = [f32::INFINITY; 3];
|
|
let mut max = [f32::NEG_INFINITY; 3];
|
|
for vertex in &mesh.vertices {
|
|
for axis in 0..3 {
|
|
let value = vertex.position[axis];
|
|
if !value.is_finite() {
|
|
return Err("world preview contains a non-finite vertex".to_string());
|
|
}
|
|
min[axis] = min[axis].min(value);
|
|
max[axis] = max[axis].max(value);
|
|
}
|
|
}
|
|
if !min.iter().chain(max.iter()).all(|value| value.is_finite()) {
|
|
return Err("world preview has no finite bounds".to_string());
|
|
}
|
|
Self::from_bounds(min, max)
|
|
}
|
|
|
|
fn from_bounds(min: [f32; 3], max: [f32; 3]) -> Result<Self, String> {
|
|
if !min.iter().chain(max.iter()).all(|value| value.is_finite()) {
|
|
return Err("free-flight bounds must be finite".to_string());
|
|
}
|
|
let center = [
|
|
(min[0] + max[0]) * 0.5,
|
|
(min[1] + max[1]) * 0.5,
|
|
(min[2] + max[2]) * 0.5,
|
|
];
|
|
let radius = (0..3)
|
|
.map(|axis| (max[axis] - min[axis]).abs())
|
|
.fold(1.0_f32, f32::max);
|
|
if !radius.is_finite() || radius <= f32::EPSILON {
|
|
return Err("free-flight bounds are degenerate".to_string());
|
|
}
|
|
let position = [
|
|
center[0],
|
|
center[1] - radius * 2.5,
|
|
center[2] + radius * 1.25,
|
|
];
|
|
let direction = sub3(center, position);
|
|
let distance = length3(direction)
|
|
.ok_or_else(|| "free-flight initial camera has no direction to the map".to_string())?;
|
|
let yaw = direction[1].atan2(direction[0]);
|
|
let pitch = (direction[2] / distance).clamp(-1.0, 1.0).asin();
|
|
Ok(Self {
|
|
position,
|
|
yaw,
|
|
pitch,
|
|
horizontal_fov: 1.3,
|
|
near_plane: 0.5,
|
|
far_plane: 700.0,
|
|
// Keep the default traversal speed comfortable on a full mission
|
|
// map; Shift/Ctrl still provide the coarse speed controls.
|
|
move_speed: (radius * 0.03).clamp(4.0, 40.0),
|
|
ground_clearance: 0.0,
|
|
})
|
|
}
|
|
|
|
fn from_mesh_and_terrain(
|
|
mesh: &VulkanStaticMesh,
|
|
terrain: &TerrainWorld,
|
|
) -> Result<Self, String> {
|
|
let mut camera = Self::from_mesh(mesh)?;
|
|
let positions = terrain
|
|
.source_positions()
|
|
.ok_or_else(|| "free-flight terrain has no source positions".to_string())?;
|
|
let mut min = [f32::INFINITY; 3];
|
|
let mut max = [f32::NEG_INFINITY; 3];
|
|
for position in positions {
|
|
if !position.iter().all(|value| value.is_finite()) {
|
|
return Err("free-flight terrain contains a non-finite position".to_string());
|
|
}
|
|
for axis in 0..3 {
|
|
min[axis] = min[axis].min(position[axis]);
|
|
max[axis] = max[axis].max(position[axis]);
|
|
}
|
|
}
|
|
if !min.iter().chain(max.iter()).all(|value| value.is_finite()) {
|
|
return Err("free-flight terrain has no finite bounds".to_string());
|
|
}
|
|
let extent = (max[0] - min[0]).max(max[1] - min[1]);
|
|
if !extent.is_finite() || extent <= f32::EPSILON {
|
|
return Err("free-flight terrain has degenerate XY bounds".to_string());
|
|
}
|
|
let center = [(min[0] + max[0]) * 0.5, (min[1] + max[1]) * 0.5];
|
|
let offset = extent * 0.18;
|
|
let requested_spawn = [
|
|
(center[0] - offset).clamp(min[0], max[0]),
|
|
(center[1] - offset).clamp(min[1], max[1]),
|
|
];
|
|
let sample_offsets = [
|
|
[0.0, 0.0],
|
|
[0.18, 0.18],
|
|
[-0.18, 0.18],
|
|
[0.18, -0.18],
|
|
[-0.18, -0.18],
|
|
[0.0, 0.18],
|
|
[0.18, 0.0],
|
|
[-0.18, 0.0],
|
|
[0.0, -0.18],
|
|
];
|
|
let height_world = |xy: [f32; 2]| {
|
|
terrain
|
|
.height_at(xy)
|
|
.ok()
|
|
.flatten()
|
|
.filter(|height| height.is_finite())
|
|
};
|
|
let (spawn_xy, spawn_ground) = sample_offsets
|
|
.into_iter()
|
|
.map(|sample| {
|
|
[
|
|
(requested_spawn[0] + sample[0] * extent).clamp(min[0], max[0]),
|
|
(requested_spawn[1] + sample[1] * extent).clamp(min[1], max[1]),
|
|
]
|
|
})
|
|
.find_map(|xy| height_world(xy).map(|height| (xy, height)))
|
|
.ok_or_else(|| "free-flight terrain has no queryable spawn surface".to_string())?;
|
|
let target_ground = height_world(center).unwrap_or(spawn_ground);
|
|
let clearance = (extent * 0.005).clamp(2.0, 8.0);
|
|
camera.ground_clearance = clearance;
|
|
camera.position = [spawn_xy[0], spawn_xy[1], spawn_ground + clearance];
|
|
let target = [center[0], center[1], target_ground + 1.5];
|
|
let direction = sub3(target, camera.position);
|
|
let distance = length3(direction)
|
|
.ok_or_else(|| "free-flight terrain spawn has no view direction".to_string())?;
|
|
camera.yaw = direction[1].atan2(direction[0]);
|
|
camera.pitch = (direction[2] / distance).clamp(-1.0, 1.0).asin();
|
|
Ok(camera)
|
|
}
|
|
|
|
fn keep_above_terrain(&mut self, terrain: &TerrainWorld) -> Result<(), String> {
|
|
if !self.ground_clearance.is_finite() || self.ground_clearance < 0.0 {
|
|
return Err("free-flight ground clearance must be finite and nonnegative".to_string());
|
|
}
|
|
if let Some([min, max]) = terrain.surface_bounds() {
|
|
for axis in 0..2 {
|
|
self.position[axis] = self.position[axis].clamp(min[axis], max[axis]);
|
|
}
|
|
}
|
|
let Some(ground_height) = terrain
|
|
.height_at([self.position[0], self.position[1]])
|
|
.map_err(|error| format!("free-flight terrain height query: {error}"))?
|
|
else {
|
|
return Ok(());
|
|
};
|
|
if !ground_height.is_finite() {
|
|
return Err("free-flight terrain height is not finite".to_string());
|
|
}
|
|
let minimum_z = ground_height + self.ground_clearance;
|
|
if !minimum_z.is_finite() {
|
|
return Err("free-flight terrain floor is not finite".to_string());
|
|
}
|
|
self.position[2] = self.position[2].max(minimum_z);
|
|
Ok(())
|
|
}
|
|
fn forward(self) -> [f32; 3] {
|
|
let (sin_yaw, cos_yaw) = self.yaw.sin_cos();
|
|
let (sin_pitch, cos_pitch) = self.pitch.sin_cos();
|
|
[cos_pitch * cos_yaw, cos_pitch * sin_yaw, sin_pitch]
|
|
}
|
|
|
|
fn right(self) -> [f32; 3] {
|
|
normalize3(cross3(self.forward(), [0.0, 0.0, 1.0])).unwrap_or([1.0, 0.0, 0.0])
|
|
}
|
|
|
|
fn up(self) -> [f32; 3] {
|
|
normalize3(cross3(self.right(), self.forward())).unwrap_or([0.0, 0.0, 1.0])
|
|
}
|
|
|
|
fn vertical_fov(self, aspect: f32) -> f32 {
|
|
let aspect = if aspect.is_finite() && aspect > f32::EPSILON {
|
|
aspect
|
|
} else {
|
|
16.0 / 9.0
|
|
};
|
|
2.0 * ((self.horizontal_fov * 0.5).tan() / aspect).atan()
|
|
}
|
|
|
|
fn look_delta(&mut self, dx: f64, dy: f64) {
|
|
const SENSITIVITY: f32 = 0.0025;
|
|
let dx = dx as f32;
|
|
let dy = dy as f32;
|
|
if dx.is_finite() && dy.is_finite() {
|
|
// Moving the mouse right turns the view to the right.
|
|
self.yaw -= dx * SENSITIVITY;
|
|
self.pitch = (self.pitch - dy * SENSITIVITY).clamp(-1.5, 1.5);
|
|
}
|
|
}
|
|
|
|
fn advance(&mut self, keys: &HashSet<KeyCode>, seconds: f32) {
|
|
if !seconds.is_finite() || seconds <= 0.0 {
|
|
return;
|
|
}
|
|
let mut movement = [0.0; 3];
|
|
let forward = self.forward();
|
|
let right = self.right();
|
|
if keys.contains(&KeyCode::KeyW) {
|
|
add3_in_place(&mut movement, forward);
|
|
}
|
|
if keys.contains(&KeyCode::KeyS) {
|
|
add3_in_place(&mut movement, scale3(forward, -1.0));
|
|
}
|
|
if keys.contains(&KeyCode::KeyD) {
|
|
add3_in_place(&mut movement, right);
|
|
}
|
|
if keys.contains(&KeyCode::KeyA) {
|
|
add3_in_place(&mut movement, scale3(right, -1.0));
|
|
}
|
|
if keys.contains(&KeyCode::KeyE) {
|
|
add3_in_place(&mut movement, [0.0, 0.0, 1.0]);
|
|
}
|
|
if keys.contains(&KeyCode::KeyQ) {
|
|
add3_in_place(&mut movement, [0.0, 0.0, -1.0]);
|
|
}
|
|
let Some(direction) = normalize3(movement) else {
|
|
return;
|
|
};
|
|
let mut speed = self.move_speed;
|
|
if keys.contains(&KeyCode::ShiftLeft) || keys.contains(&KeyCode::ShiftRight) {
|
|
speed *= 4.0;
|
|
}
|
|
if keys.contains(&KeyCode::ControlLeft) || keys.contains(&KeyCode::ControlRight) {
|
|
speed *= 0.25;
|
|
}
|
|
self.position = add3(self.position, scale3(direction, speed * seconds));
|
|
}
|
|
|
|
fn vulkan_camera(self, aspect: f32) -> VulkanStaticCamera {
|
|
VulkanStaticCamera {
|
|
clip_from_world: clip_from_world_for_view(
|
|
self.position,
|
|
self.forward(),
|
|
self.right(),
|
|
self.up(),
|
|
self.vertical_fov(aspect),
|
|
self.near_plane,
|
|
self.far_plane,
|
|
aspect,
|
|
),
|
|
}
|
|
}
|
|
|
|
fn preview_frame(self, aspect: f32) -> PreviewCameraFrame {
|
|
let camera = self.vulkan_camera(aspect);
|
|
PreviewCameraFrame {
|
|
position: self.position,
|
|
forward: self.forward(),
|
|
right: self.right(),
|
|
up: self.up(),
|
|
vertical_fov: self.vertical_fov(aspect),
|
|
near_plane: self.near_plane,
|
|
far_plane: self.far_plane,
|
|
clip_from_world: camera.clip_from_world,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn preview_aspect(aspect: f32) -> f32 {
|
|
if aspect.is_finite() && aspect > f32::EPSILON {
|
|
aspect
|
|
} else {
|
|
16.0 / 9.0
|
|
}
|
|
}
|
|
|
|
fn clip_from_world_for_view(
|
|
position: [f32; 3],
|
|
forward: [f32; 3],
|
|
right: [f32; 3],
|
|
up: [f32; 3],
|
|
vertical_fov: f32,
|
|
near_plane: f32,
|
|
far_plane: f32,
|
|
aspect: f32,
|
|
) -> [f32; 16] {
|
|
let aspect = preview_aspect(aspect);
|
|
// VulkanStaticCamera carries row-major D3D-style data. GLSL interprets
|
|
// those bytes as a column-major mat4, so shader multiplication is the
|
|
// transpose of this row-vector transform: world * V * P. The camera
|
|
// basis is right-handed with +Z-up world coordinates and +Z depth.
|
|
let view = [
|
|
right[0],
|
|
up[0],
|
|
forward[0],
|
|
0.0,
|
|
right[1],
|
|
up[1],
|
|
forward[1],
|
|
0.0,
|
|
right[2],
|
|
up[2],
|
|
forward[2],
|
|
0.0,
|
|
-dot3(right, position),
|
|
-dot3(up, position),
|
|
-dot3(forward, position),
|
|
1.0,
|
|
];
|
|
let focal = (vertical_fov * 0.5).tan().recip();
|
|
let near_plane = near_plane.max(0.001);
|
|
let far_plane = far_plane.max(near_plane + 0.001);
|
|
let projection = [
|
|
focal / aspect,
|
|
0.0,
|
|
0.0,
|
|
0.0,
|
|
0.0,
|
|
-focal,
|
|
0.0,
|
|
0.0,
|
|
0.0,
|
|
0.0,
|
|
far_plane / (far_plane - near_plane),
|
|
1.0,
|
|
0.0,
|
|
0.0,
|
|
-(near_plane * far_plane) / (far_plane - near_plane),
|
|
0.0,
|
|
];
|
|
multiply_row_major(view, projection)
|
|
}
|
|
|
|
/// Immutable view data shared by the environment renderers. The optional
|
|
/// `FreeFlightCamera` remains only the input controller; captured views keep
|
|
/// their original basis and clip transform, including roll.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
struct PreviewCameraFrame {
|
|
position: [f32; 3],
|
|
forward: [f32; 3],
|
|
right: [f32; 3],
|
|
up: [f32; 3],
|
|
vertical_fov: f32,
|
|
near_plane: f32,
|
|
far_plane: f32,
|
|
clip_from_world: [f32; 16],
|
|
}
|
|
|
|
impl PreviewCameraFrame {
|
|
fn from_legacy_d3d7(
|
|
transform: RawCameraTransform,
|
|
projection: LegacyD3d7Projection,
|
|
) -> Option<Self> {
|
|
let camera = VulkanStaticCamera::from_legacy_d3d7(transform, projection)?;
|
|
let view = transform.try_direct3d7_view_row_major()?;
|
|
let width = projection.viewport[2].checked_sub(projection.viewport[0])?;
|
|
let height = projection.viewport[3].checked_sub(projection.viewport[1])?;
|
|
if width <= 0 || height <= 0 {
|
|
return None;
|
|
}
|
|
let aspect = width as f32 / height as f32;
|
|
let vertical_fov = 2.0 * ((projection.field_of_view_radians * 0.5).tan() / aspect).atan();
|
|
let frame = Self {
|
|
position: transform.translation(),
|
|
// The camera axes are the columns of Ngi32's D3D7 view matrix.
|
|
// Keeping them directly preserves native roll and avoids
|
|
// reconstructing an incomplete yaw/pitch camera.
|
|
right: [view[0], view[4], view[8]],
|
|
up: [view[1], view[5], view[9]],
|
|
forward: [view[2], view[6], view[10]],
|
|
vertical_fov,
|
|
near_plane: projection.near_plane,
|
|
far_plane: projection.far_plane,
|
|
clip_from_world: camera.clip_from_world,
|
|
};
|
|
(frame.position.iter().all(|value| value.is_finite())
|
|
&& frame.forward.iter().all(|value| value.is_finite())
|
|
&& frame.right.iter().all(|value| value.is_finite())
|
|
&& frame.up.iter().all(|value| value.is_finite())
|
|
&& frame.vertical_fov.is_finite()
|
|
&& frame.vertical_fov > 0.0
|
|
&& frame.vertical_fov < std::f32::consts::PI)
|
|
.then_some(frame)
|
|
}
|
|
|
|
fn vulkan_camera(self) -> VulkanStaticCamera {
|
|
VulkanStaticCamera {
|
|
clip_from_world: self.clip_from_world,
|
|
}
|
|
}
|
|
|
|
fn with_aspect(self, previous_aspect: f32, aspect: f32) -> Self {
|
|
let previous_aspect = preview_aspect(previous_aspect);
|
|
let aspect = preview_aspect(aspect);
|
|
let horizontal_half_fov_tangent = (self.vertical_fov * 0.5).tan() * previous_aspect;
|
|
let vertical_fov = 2.0 * (horizontal_half_fov_tangent / aspect).atan();
|
|
let clip_from_world = clip_from_world_for_view(
|
|
self.position,
|
|
self.forward,
|
|
self.right,
|
|
self.up,
|
|
vertical_fov,
|
|
self.near_plane,
|
|
self.far_plane,
|
|
aspect,
|
|
);
|
|
Self {
|
|
vertical_fov,
|
|
clip_from_world,
|
|
..self
|
|
}
|
|
}
|
|
|
|
fn forward(self) -> [f32; 3] {
|
|
self.forward
|
|
}
|
|
|
|
fn right(self) -> [f32; 3] {
|
|
self.right
|
|
}
|
|
|
|
fn up(self) -> [f32; 3] {
|
|
self.up
|
|
}
|
|
|
|
fn native_sky_heading(self) -> f32 {
|
|
// Terrain+0x4F9C0 uses atan2(forward.x, forward.y); CSky+0x481C5 adds pi later.
|
|
self.forward[0].atan2(self.forward[1])
|
|
}
|
|
}
|
|
|
|
const ENVIRONMENT_SPRITE_ROWS: [usize; 2] = [3, 4];
|
|
const ENVIRONMENT_FLARE_SLOTS: usize = 12;
|
|
const ENVIRONMENT_PARTICLE_SLOTS: usize = 1_000;
|
|
// shortcut: Effect.dll derives this from projected sphere size and a render-context
|
|
// quality table. Category 0 supplies a highest-detail mesh until that exact
|
|
// world category is available; this does not reproduce native LOD selection.
|
|
const AMBIENT_EFFECT_NATIVE_CATEGORY: u8 = 0;
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct EnvironmentGpuRange {
|
|
range_index: usize,
|
|
vertex_start: usize,
|
|
vertex_capacity: usize,
|
|
material_index: usize,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum AmbientEffectKind {
|
|
Op3Type0,
|
|
Op3Type1,
|
|
Op3Type2,
|
|
GenerBall,
|
|
SmokePool,
|
|
Op7ParticlePool,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct AmbientEffectGpuSlot {
|
|
kind: AmbientEffectKind,
|
|
time_mode: u32,
|
|
header_flags: u32,
|
|
random_instance_key: ControlEffectInstanceKey,
|
|
random_seed: u32,
|
|
phase_jitter: f32,
|
|
random_shift: [f32; 3],
|
|
owner_object_index: usize,
|
|
owner_component_index: usize,
|
|
owner_node_index: usize,
|
|
owner_component_is_mounted: bool,
|
|
root_transform: LegacyIron3dEulerTransform,
|
|
owner_scale: [f32; 3],
|
|
mount_pose: Option<VulkanNodePose>,
|
|
local_from_effect: [[f32; 4]; 4],
|
|
control_source: String,
|
|
control_table_index: usize,
|
|
control_row_index: usize,
|
|
control_point_indices: [u32; 3],
|
|
control_point_owners: [i32; 3],
|
|
command: FxCommand,
|
|
command_index: usize,
|
|
duration_seconds: f32,
|
|
world_from_effect: [[f32; 4]; 4],
|
|
range: EnvironmentGpuRange,
|
|
additional_particle_ranges: Vec<EnvironmentGpuRange>,
|
|
material_animation_index: usize,
|
|
additional_particle_material_animation_indices: Vec<usize>,
|
|
smoke_emitter_state: Option<SmokeEmitterState>,
|
|
op7_emitter_state: Option<Op7EmitterState>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
struct AmbientEffectMeshPrimitive {
|
|
vertex_start: usize,
|
|
vertex_count: usize,
|
|
index_start: usize,
|
|
index_count: usize,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct ControlEffectSlot {
|
|
time_mode: u32,
|
|
header_flags: u32,
|
|
random_instance_key: ControlEffectInstanceKey,
|
|
random_seed: u32,
|
|
phase_jitter: f32,
|
|
random_shift: [f32; 3],
|
|
duration_seconds: f32,
|
|
owner_object_index: usize,
|
|
owner_component_index: usize,
|
|
owner_node_index: usize,
|
|
owner_component_is_mounted: bool,
|
|
root_transform: LegacyIron3dEulerTransform,
|
|
owner_scale: [f32; 3],
|
|
mount_pose: Option<VulkanNodePose>,
|
|
local_from_effect: [[f32; 4]; 4],
|
|
world_from_effect: [[f32; 4]; 4],
|
|
command_index: usize,
|
|
command: FxCommand,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct ControlSoundSlot {
|
|
effect: ControlEffectSlot,
|
|
command: Op2SoundCommand,
|
|
state: Op2SoundState,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
|
struct ControlEffectInstanceKey {
|
|
object_index: usize,
|
|
component_index: usize,
|
|
table_index: usize,
|
|
row_index: usize,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
|
struct ControlEffectRandomSample {
|
|
phase_jitter: f32,
|
|
accumulated_shift: [f32; 3],
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum SceneSampleId {
|
|
Live(u64),
|
|
Replay(u64),
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
struct FrameClockSample {
|
|
id: SceneSampleId,
|
|
time_ms: u32,
|
|
delta_seconds: f32,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct NativeFxRandom16 {
|
|
state: u32,
|
|
}
|
|
|
|
impl NativeFxRandom16 {
|
|
fn new(seed: u32) -> Self {
|
|
Self {
|
|
state: if seed == 0 { 0xA341_316C } else { seed },
|
|
}
|
|
}
|
|
|
|
fn next_u16(&mut self) -> u16 {
|
|
advance_native_fx_random_state(&mut self.state)
|
|
}
|
|
|
|
fn symmetric_sample(&mut self, amplitude: f32) -> f32 {
|
|
(f32::from(self.next_u16()) / 65_536.0 - 0.5) * amplitude
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct ControlEffectRandomState {
|
|
header_flags: u32,
|
|
phase_jitter_amplitude: f32,
|
|
random_shift_amplitude: [f32; 3],
|
|
phase_and_z_rng: NativeFxRandom16,
|
|
xy_rng: NativeFxRandom16,
|
|
sample: ControlEffectRandomSample,
|
|
last_update_id: Option<SceneSampleId>,
|
|
last_update_clock_ms: Option<u32>,
|
|
}
|
|
|
|
impl ControlEffectRandomState {
|
|
fn new(seed: u32, header_flags: u32, phase_jitter: f32, random_shift: [f32; 3]) -> Self {
|
|
Self {
|
|
header_flags,
|
|
phase_jitter_amplitude: phase_jitter,
|
|
random_shift_amplitude: random_shift,
|
|
phase_and_z_rng: NativeFxRandom16::new(seed ^ 0xA511_E9B3),
|
|
xy_rng: NativeFxRandom16::new(seed ^ 0x63D8_3595),
|
|
sample: ControlEffectRandomSample::default(),
|
|
last_update_id: None,
|
|
last_update_clock_ms: None,
|
|
}
|
|
}
|
|
|
|
fn advance(
|
|
&mut self,
|
|
sample_id: SceneSampleId,
|
|
control_clock_ms: u32,
|
|
initial_phase: f32,
|
|
) -> ControlEffectRandomSample {
|
|
// Native updates consume process-global RNG streams. The preview uses
|
|
// a deterministic per-Control-row stream; sibling slots share one
|
|
// sample and cadence, but native cross-row RNG ordering remains open.
|
|
if self.last_update_id == Some(sample_id) {
|
|
return self.sample;
|
|
}
|
|
if self.header_flags & 1 != 0 && initial_phase != -1.0 {
|
|
self.sample.phase_jitter = self
|
|
.phase_and_z_rng
|
|
.symmetric_sample(self.phase_jitter_amplitude);
|
|
} else {
|
|
self.sample.phase_jitter = 0.0;
|
|
}
|
|
if self.header_flags & 8 != 0 {
|
|
let shift_z = self
|
|
.phase_and_z_rng
|
|
.symmetric_sample(self.random_shift_amplitude[2]);
|
|
let shift_y = self.xy_rng.symmetric_sample(self.random_shift_amplitude[1]);
|
|
let shift_x = self.xy_rng.symmetric_sample(self.random_shift_amplitude[0]);
|
|
self.sample.accumulated_shift[0] += shift_x;
|
|
self.sample.accumulated_shift[1] += shift_y;
|
|
self.sample.accumulated_shift[2] += shift_z;
|
|
}
|
|
self.last_update_id = Some(sample_id);
|
|
self.last_update_clock_ms = Some(control_clock_ms);
|
|
self.sample
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct PreparedEffectMaterial {
|
|
document: Mat0Document,
|
|
phase_material_indices: Vec<usize>,
|
|
phase_uv_transforms: Vec<[f32; 4]>,
|
|
phase_native_flag_40: Vec<bool>,
|
|
phase_directional_rgb: Vec<[f32; 3]>,
|
|
phase_additive_rgb: Vec<[f32; 3]>,
|
|
initial_pipeline_category: u8,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct EnvironmentMaterialAsset {
|
|
texture: VulkanStaticTexture,
|
|
phase: PreparedMaterialPhase,
|
|
document: Mat0Document,
|
|
phase_textures: Vec<VulkanStaticTexture>,
|
|
phases: Vec<PreparedMaterialPhase>,
|
|
}
|
|
|
|
/// One source MAT0 document retained for frame-time sampling. The renderer
|
|
/// receives the selected coefficients through its dynamic material state;
|
|
/// keeping this binding beside the prepared scene avoids rebuilding assets or
|
|
/// reopening archives on every redraw.
|
|
#[derive(Clone, Debug)]
|
|
struct MaterialPhaseBinding {
|
|
document: Mat0Document,
|
|
animation_block_index: usize,
|
|
wear_row_start_ms: u32,
|
|
random_state: u32,
|
|
frozen_phase_index: Option<usize>,
|
|
phase_uv_transforms: Vec<[f32; 4]>,
|
|
}
|
|
|
|
/// Texture phases for the material stages that do not select the draw
|
|
/// variant. Terrain MAT0 rows are independent: Land1 base, Land2 detail,
|
|
/// Land1 overlay, and Land2 overlay-detail can each have their own animation
|
|
/// timeline. Keeping these handles beside the binding lets the renderer
|
|
/// update the three descriptor images in place without materialising every
|
|
/// Cartesian product of phase indices.
|
|
#[derive(Clone, Debug)]
|
|
struct MaterialStageTextures {
|
|
detail: Option<Vec<VulkanStaticTexture>>,
|
|
overlay: Option<Vec<VulkanStaticTexture>>,
|
|
overlay_detail: Option<Vec<VulkanStaticTexture>>,
|
|
applied_material_phases: HashMap<usize, [usize; 3]>,
|
|
}
|
|
|
|
impl MaterialPhaseBinding {
|
|
fn sample(
|
|
&mut self,
|
|
material_index: usize,
|
|
clock_ms: u32,
|
|
) -> Result<MaterialPhaseSample, String> {
|
|
if let Some(phase_index) = self.frozen_phase_index {
|
|
let phase_index = phase_index.min(self.document.phases.len().saturating_sub(1));
|
|
let phase = self.document.phases.get(phase_index).ok_or_else(|| {
|
|
format!(
|
|
"sample MAT0 material {material_index} at clock {clock_ms}ms: no material phases"
|
|
)
|
|
})?;
|
|
return Ok(MaterialPhaseSample {
|
|
phase_index,
|
|
coefficients: phase.coefficients(),
|
|
texture_raw: phase.texture_raw,
|
|
});
|
|
}
|
|
|
|
// GetPhase's random mode asks the CRT generator for one fresh value
|
|
// per query. Animated loop/ping-pong/clamp modes do not consume the
|
|
// RNG and receive the sampler's ignored value.
|
|
let block = self
|
|
.document
|
|
.animation_blocks
|
|
.get(self.animation_block_index)
|
|
.or_else(|| self.document.animation_blocks.first());
|
|
let random_value = if block.is_some_and(|block| block.header_raw & 7 == 3) {
|
|
self.random_state = self
|
|
.random_state
|
|
.wrapping_mul(214_013)
|
|
.wrapping_add(2_531_011);
|
|
(self.random_state >> 16) & 0x7fff
|
|
} else {
|
|
0
|
|
};
|
|
sample_material_phase(
|
|
&self.document,
|
|
self.animation_block_index,
|
|
clock_ms,
|
|
self.wear_row_start_ms,
|
|
random_value,
|
|
)
|
|
.map_err(|error| {
|
|
format!(
|
|
"sample MAT0 material {} at clock {clock_ms}ms: {error}",
|
|
material_index
|
|
)
|
|
})
|
|
}
|
|
|
|
fn sample_normalized(
|
|
&self,
|
|
material_index: usize,
|
|
normalized_phase: f32,
|
|
) -> Result<MaterialPhaseSample, String> {
|
|
sample_material_normalized_phase(
|
|
&self.document,
|
|
self.animation_block_index,
|
|
normalized_phase,
|
|
)
|
|
.map_err(|error| {
|
|
format!(
|
|
"sample MAT0 material {material_index} at normalized phase {normalized_phase}: {error}"
|
|
)
|
|
})
|
|
}
|
|
|
|
fn page_uv_transform(&self, phase_index: usize) -> [f32; 4] {
|
|
self.phase_uv_transforms
|
|
.get(phase_index)
|
|
.copied()
|
|
.or_else(|| self.phase_uv_transforms.first().copied())
|
|
.unwrap_or([0.0, 0.0, 1.0, 1.0])
|
|
}
|
|
|
|
fn checked_page_uv_transform(&self, phase_index: usize) -> Result<[f32; 4], String> {
|
|
self.phase_uv_transforms
|
|
.get(phase_index)
|
|
.copied()
|
|
.ok_or_else(|| {
|
|
format!(
|
|
"MAT0 Page transform is missing phase {phase_index} of {}",
|
|
self.phase_uv_transforms.len()
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct MaterialVariantSet {
|
|
phase_material_indices: Vec<usize>,
|
|
range_indices: Vec<usize>,
|
|
pipeline_category: u8,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct MaterialAnimationBinding {
|
|
initial_phase_index: usize,
|
|
normalized_phase_override: Option<f32>,
|
|
ambient_fx_material_alpha: bool,
|
|
variants: Vec<MaterialVariantSet>,
|
|
base: MaterialPhaseBinding,
|
|
detail: Option<MaterialPhaseBinding>,
|
|
overlay: Option<MaterialPhaseBinding>,
|
|
overlay_detail: Option<MaterialPhaseBinding>,
|
|
stage_textures: Option<MaterialStageTextures>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct ActiveMaterialState {
|
|
material_index: usize,
|
|
phase_index: usize,
|
|
diffuse_alpha: f32,
|
|
additive_rgb: [f32; 3],
|
|
}
|
|
|
|
fn ambient_effect_material_alpha(is_ambient_effect: bool, sampled_opacity: f32) -> f32 {
|
|
if is_ambient_effect {
|
|
1.0
|
|
} else {
|
|
sampled_opacity
|
|
}
|
|
}
|
|
|
|
fn sample_material_animation_base(
|
|
binding: &mut MaterialAnimationBinding,
|
|
clock_ms: u32,
|
|
) -> Result<MaterialPhaseSample, String> {
|
|
match binding.normalized_phase_override {
|
|
Some(normalized_phase) => binding
|
|
.base
|
|
.sample_normalized(binding.initial_phase_index, normalized_phase),
|
|
None => binding.base.sample(binding.initial_phase_index, clock_ms),
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
struct AmbientEffectGeometryUpdate {
|
|
range_index: usize,
|
|
index_count: u32,
|
|
command_rgb: [f32; 3],
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct MaterialRangeSelection {
|
|
range_index: usize,
|
|
material_index: usize,
|
|
pipeline_category: u8,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct MaterialPropertiesUpdate {
|
|
material_index: usize,
|
|
diffuse_alpha: f32,
|
|
overlay_alpha: f32,
|
|
base_directional: [f32; 3],
|
|
base_additive: [f32; 3],
|
|
overlay_directional: Option<[f32; 3]>,
|
|
overlay_additive: Option<[f32; 3]>,
|
|
specular_rgb: [f32; 3],
|
|
specular_power: u8,
|
|
overlay_specular_rgb: Option<[f32; 3]>,
|
|
overlay_power: Option<u8>,
|
|
uv_transforms: [[f32; 4]; 4],
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct MaterialStagePhaseUpdate {
|
|
binding_index: usize,
|
|
material_index: usize,
|
|
phases: [usize; 3],
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
struct MaterialFrameUpdate {
|
|
active_materials: HashMap<usize, ActiveMaterialState>,
|
|
range_selections: Vec<MaterialRangeSelection>,
|
|
properties: Vec<MaterialPropertiesUpdate>,
|
|
stage_phases: Vec<MaterialStagePhaseUpdate>,
|
|
}
|
|
|
|
fn sample_material_animations(
|
|
bindings: &mut [MaterialAnimationBinding],
|
|
materials: &[VulkanStaticMaterial],
|
|
clock_ms: u32,
|
|
) -> Result<MaterialFrameUpdate, String> {
|
|
let mut update = MaterialFrameUpdate::default();
|
|
for (binding_index, binding) in bindings.iter_mut().enumerate() {
|
|
let base_sample = sample_material_animation_base(binding, clock_ms)?;
|
|
let material_alpha = ambient_effect_material_alpha(
|
|
binding.ambient_fx_material_alpha,
|
|
base_sample.coefficients.opacity,
|
|
);
|
|
let detail_sample = binding
|
|
.detail
|
|
.as_mut()
|
|
.map(|detail| detail.sample(binding.initial_phase_index, clock_ms))
|
|
.transpose()?;
|
|
let overlay_sample = binding
|
|
.overlay
|
|
.as_mut()
|
|
.map(|overlay| overlay.sample(binding.initial_phase_index, clock_ms))
|
|
.transpose()?;
|
|
let overlay_detail_sample = binding
|
|
.overlay_detail
|
|
.as_mut()
|
|
.map(|overlay_detail| overlay_detail.sample(binding.initial_phase_index, clock_ms))
|
|
.transpose()?;
|
|
let mut updated_materials = HashSet::new();
|
|
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 {
|
|
update.active_materials.insert(
|
|
range_index,
|
|
ActiveMaterialState {
|
|
material_index,
|
|
phase_index: base_sample.phase_index,
|
|
diffuse_alpha: material_alpha,
|
|
additive_rgb: base_sample.coefficients.additive_rgb,
|
|
},
|
|
);
|
|
update.range_selections.push(MaterialRangeSelection {
|
|
range_index,
|
|
material_index,
|
|
pipeline_category,
|
|
});
|
|
}
|
|
variant.pipeline_category = pipeline_category;
|
|
if !updated_materials.insert(material_index) {
|
|
continue;
|
|
}
|
|
let material = materials.get(material_index).ok_or_else(|| {
|
|
format!("MAT0 material animation selector {material_index} is out of bounds")
|
|
})?;
|
|
let base_coefficients = base_sample.coefficients;
|
|
let overlay_coefficients = overlay_sample.map(|sample| sample.coefficients);
|
|
let (base_directional, base_additive, overlay_directional, overlay_additive) =
|
|
if material.lightmap_mode {
|
|
// Terrain mode 3 samples the lightmap in the secondary
|
|
// stage and uses the authored directional RGB as additive.
|
|
([0.0; 3], base_coefficients.directional_rgb, None, None)
|
|
} else {
|
|
(
|
|
base_coefficients.directional_rgb,
|
|
base_coefficients.additive_rgb,
|
|
overlay_coefficients.map(|coefficients| coefficients.directional_rgb),
|
|
overlay_coefficients.map(|coefficients| coefficients.additive_rgb),
|
|
)
|
|
};
|
|
|
|
if !material.lightmap_mode {
|
|
if let Some(stage_textures) = binding.stage_textures.as_ref() {
|
|
let stage_phases = [
|
|
detail_sample.map_or(0, |sample| sample.phase_index),
|
|
overlay_sample.map_or(0, |sample| sample.phase_index),
|
|
overlay_detail_sample.map_or(0, |sample| sample.phase_index),
|
|
];
|
|
if stage_textures
|
|
.applied_material_phases
|
|
.get(&material_index)
|
|
.copied()
|
|
!= Some(stage_phases)
|
|
{
|
|
update.stage_phases.push(MaterialStagePhaseUpdate {
|
|
binding_index,
|
|
material_index,
|
|
phases: stage_phases,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
let overlay_uv = overlay_sample
|
|
.map(|sample| {
|
|
binding
|
|
.overlay
|
|
.as_ref()
|
|
.map_or([0.0, 0.0, 1.0, 1.0], |overlay| {
|
|
overlay.page_uv_transform(sample.phase_index)
|
|
})
|
|
})
|
|
.or(material.overlay_uv_transform)
|
|
.unwrap_or([0.0, 0.0, 1.0, 1.0]);
|
|
let detail_uv = if material.lightmap_mode {
|
|
material.detail_uv_transform
|
|
} else {
|
|
detail_sample
|
|
.map(|sample| {
|
|
binding
|
|
.detail
|
|
.as_ref()
|
|
.map_or(material.detail_uv_transform, |detail| {
|
|
detail.page_uv_transform(sample.phase_index)
|
|
})
|
|
})
|
|
.or(Some(material.detail_uv_transform))
|
|
.unwrap_or([0.0, 0.0, 1.0, 1.0])
|
|
};
|
|
let overlay_detail_uv = if material.lightmap_mode {
|
|
material
|
|
.overlay_detail_uv_transform
|
|
.unwrap_or([0.0, 0.0, 1.0, 1.0])
|
|
} else {
|
|
overlay_detail_sample
|
|
.map(|sample| {
|
|
binding.overlay_detail.as_ref().map_or(
|
|
material
|
|
.overlay_detail_uv_transform
|
|
.unwrap_or([0.0, 0.0, 1.0, 1.0]),
|
|
|overlay_detail| overlay_detail.page_uv_transform(sample.phase_index),
|
|
)
|
|
})
|
|
.or(material.overlay_detail_uv_transform)
|
|
.unwrap_or([0.0, 0.0, 1.0, 1.0])
|
|
};
|
|
let base_uv = binding
|
|
.base
|
|
.checked_page_uv_transform(base_sample.phase_index)?;
|
|
update.properties.push(MaterialPropertiesUpdate {
|
|
material_index,
|
|
diffuse_alpha: material_alpha.clamp(0.0, 1.0),
|
|
overlay_alpha: overlay_coefficients
|
|
.map_or(0.0, |coefficients| coefficients.opacity.clamp(0.0, 1.0)),
|
|
base_directional,
|
|
base_additive,
|
|
overlay_directional,
|
|
overlay_additive,
|
|
specular_rgb: base_coefficients.specular_rgb,
|
|
specular_power: base_coefficients.power,
|
|
overlay_specular_rgb: overlay_coefficients
|
|
.map(|coefficients| coefficients.specular_rgb),
|
|
overlay_power: overlay_coefficients.map(|coefficients| coefficients.power),
|
|
uv_transforms: [base_uv, detail_uv, overlay_uv, overlay_detail_uv],
|
|
});
|
|
}
|
|
}
|
|
Ok(update)
|
|
}
|
|
|
|
fn apply_material_frame_update(
|
|
update: &MaterialFrameUpdate,
|
|
bindings: &mut [MaterialAnimationBinding],
|
|
renderer: &mut VulkanSmokeRenderer,
|
|
) -> Result<(), String> {
|
|
for selection in &update.range_selections {
|
|
renderer
|
|
.set_draw_range_material(selection.range_index, selection.material_index)
|
|
.map_err(|error| format!("update MAT0 material draw selector: {error}"))?;
|
|
renderer
|
|
.set_draw_range_material_category(selection.range_index, selection.pipeline_category)
|
|
.map_err(|error| format!("update MAT0 material pipeline category: {error}"))?;
|
|
}
|
|
for properties in &update.properties {
|
|
renderer
|
|
.set_material_alphas(
|
|
properties.material_index,
|
|
properties.diffuse_alpha,
|
|
properties.overlay_alpha,
|
|
)
|
|
.map_err(|error| format!("update MAT0 material alpha: {error}"))?;
|
|
renderer
|
|
.set_material_lighting_with_overlay(
|
|
properties.material_index,
|
|
properties.base_directional,
|
|
properties.base_additive,
|
|
properties.overlay_directional,
|
|
properties.overlay_additive,
|
|
)
|
|
.map_err(|error| format!("update MAT0 material lighting: {error}"))?;
|
|
renderer
|
|
.set_material_specular(
|
|
properties.material_index,
|
|
properties.specular_rgb,
|
|
properties.specular_power,
|
|
properties.overlay_specular_rgb,
|
|
properties.overlay_power,
|
|
)
|
|
.map_err(|error| format!("update MAT0 material specular: {error}"))?;
|
|
renderer
|
|
.set_material_uv_transforms(properties.material_index, properties.uv_transforms)
|
|
.map_err(|error| format!("update MAT0 material Page transform: {error}"))?;
|
|
}
|
|
for stage_update in &update.stage_phases {
|
|
let stage_textures = bindings
|
|
.get_mut(stage_update.binding_index)
|
|
.and_then(|binding| binding.stage_textures.as_mut())
|
|
.ok_or_else(|| "MAT0 stage texture binding disappeared".to_string())?;
|
|
if stage_textures
|
|
.applied_material_phases
|
|
.get(&stage_update.material_index)
|
|
.copied()
|
|
== Some(stage_update.phases)
|
|
{
|
|
continue;
|
|
}
|
|
let textures = [
|
|
stage_textures
|
|
.detail
|
|
.as_ref()
|
|
.and_then(|textures| textures.get(stage_update.phases[0])),
|
|
stage_textures
|
|
.overlay
|
|
.as_ref()
|
|
.and_then(|textures| textures.get(stage_update.phases[1])),
|
|
stage_textures
|
|
.overlay_detail
|
|
.as_ref()
|
|
.and_then(|textures| textures.get(stage_update.phases[2])),
|
|
];
|
|
for (stage, texture) in textures.into_iter().enumerate() {
|
|
if let Some(texture) = texture {
|
|
renderer
|
|
.update_material_texture(stage_update.material_index, stage + 1, texture)
|
|
.map_err(|error| match stage {
|
|
0 => format!("update MAT0 detail texture: {error}"),
|
|
1 => format!("update MAT0 overlay texture: {error}"),
|
|
_ => format!("update MAT0 overlay detail texture: {error}"),
|
|
})?;
|
|
}
|
|
}
|
|
stage_textures
|
|
.applied_material_phases
|
|
.insert(stage_update.material_index, stage_update.phases);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn native_world_transparent(range: &VulkanStaticDrawRange, diffuse_alpha: f32) -> bool {
|
|
diffuse_alpha < 1.0 || (range.batch_flags & 0x108) != 0
|
|
}
|
|
|
|
fn native_msh_diffuse_alpha_override(
|
|
mesh: &VulkanStaticMesh,
|
|
range_index: usize,
|
|
type16_present: bool,
|
|
eye_world: [f32; 3],
|
|
horizontal_fov: f32,
|
|
) -> Result<Option<f32>, String> {
|
|
let range = mesh
|
|
.draw_ranges
|
|
.get(range_index)
|
|
.ok_or_else(|| "portal draw range is out of bounds".to_string())?;
|
|
if type16_present || range.batch_flags & 0x0008 == 0 {
|
|
return Ok(None);
|
|
}
|
|
let distance = if range.batch_flags & 0x0050 == 0x0040 {
|
|
0.0
|
|
} else {
|
|
// shortcut: use the first indexed vertex; replace with native CPU clipping selection when clipped-portal captures are available.
|
|
first_indexed_vertex_distance(mesh, range_index, eye_world)?
|
|
};
|
|
legacy_msh_portal_alpha(range.batch_flags, distance, horizontal_fov)
|
|
.map(Some)
|
|
.ok_or_else(|| "portal distance or field of view is invalid".to_string())
|
|
}
|
|
|
|
fn native_msh_handedness(transform: LegacyIron3dEulerTransform, scale: [f32; 3]) -> i8 {
|
|
let Some(origin) = transform.try_transform_scaled_point([0.0; 3], scale) else {
|
|
return 0;
|
|
};
|
|
let Some(x_axis) = transform
|
|
.try_transform_scaled_point([1.0, 0.0, 0.0], scale)
|
|
.map(|point| sub3(point, origin))
|
|
else {
|
|
return 0;
|
|
};
|
|
let Some(y_axis) = transform
|
|
.try_transform_scaled_point([0.0, 1.0, 0.0], scale)
|
|
.map(|point| sub3(point, origin))
|
|
else {
|
|
return 0;
|
|
};
|
|
let Some(z_axis) = transform
|
|
.try_transform_scaled_point([0.0, 0.0, 1.0], scale)
|
|
.map(|point| sub3(point, origin))
|
|
else {
|
|
return 0;
|
|
};
|
|
// Node and mount poses in the preview are unit-quaternion rotations, so
|
|
// they have determinant +1 and preserve this exact object-transform sign.
|
|
let determinant_sign = dot3(x_axis, cross3(y_axis, z_axis));
|
|
if !determinant_sign.is_finite() || determinant_sign == 0.0 {
|
|
0
|
|
} else if determinant_sign < 0.0 {
|
|
-1
|
|
} else {
|
|
1
|
|
}
|
|
}
|
|
|
|
fn native_msh_cull_mode(batch_flags: u16, handedness: i8) -> LegacyCullMode {
|
|
if batch_flags & 0x2 != 0 || batch_flags & 0x4000 != 0 || handedness == 0 {
|
|
LegacyCullMode::Disabled
|
|
} else if handedness > 0 {
|
|
// The Y-negated projection maps native d > 0 faces to Vulkan's back
|
|
// under the renderer's CLOCKWISE front-face convention.
|
|
LegacyCullMode::FrontFace
|
|
} else {
|
|
LegacyCullMode::BackFace
|
|
}
|
|
}
|
|
|
|
fn native_msh_first_triangle_visible(
|
|
mesh: &VulkanStaticMesh,
|
|
range: &VulkanStaticDrawRange,
|
|
eye_world: [f32; 3],
|
|
handedness: i8,
|
|
) -> bool {
|
|
if range.batch_flags & 0x2 != 0 || range.batch_flags & 0x4000 == 0 {
|
|
return true;
|
|
}
|
|
if handedness == 0 {
|
|
return false;
|
|
}
|
|
let Some(indices) = usize::try_from(range.first_index)
|
|
.ok()
|
|
.and_then(|first| mesh.indices.get(first..first.checked_add(3)?))
|
|
else {
|
|
return false;
|
|
};
|
|
let Some(v0) = usize::try_from(indices[0])
|
|
.ok()
|
|
.and_then(|index| mesh.vertices.get(index))
|
|
else {
|
|
return false;
|
|
};
|
|
let Some(v1) = usize::try_from(indices[1])
|
|
.ok()
|
|
.and_then(|index| mesh.vertices.get(index))
|
|
else {
|
|
return false;
|
|
};
|
|
let Some(v2) = usize::try_from(indices[2])
|
|
.ok()
|
|
.and_then(|index| mesh.vertices.get(index))
|
|
else {
|
|
return false;
|
|
};
|
|
let [v0, v1, v2] = [v0.position, v1.position, v2.position];
|
|
let face = cross3(sub3(v1, v0), sub3(v2, v0));
|
|
let facing = dot3(sub3(eye_world, v0), face) * f32::from(handedness);
|
|
facing.is_finite() && facing > 0.0
|
|
}
|
|
|
|
fn world_draw_range_index_count(
|
|
mesh: &VulkanStaticMesh,
|
|
range: &VulkanStaticDrawRange,
|
|
original_index_count: u32,
|
|
diffuse_alpha: f32,
|
|
eye_world: [f32; 3],
|
|
native_msh_handedness: Option<i8>,
|
|
) -> u32 {
|
|
if !(diffuse_alpha > 0.0) {
|
|
return 0;
|
|
}
|
|
if native_msh_handedness.is_some_and(|handedness| {
|
|
!native_msh_first_triangle_visible(mesh, range, eye_world, handedness)
|
|
}) {
|
|
return 0;
|
|
}
|
|
original_index_count
|
|
}
|
|
|
|
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,
|
|
range_index: usize,
|
|
) {
|
|
for binding in bindings {
|
|
if let Some(variant) = binding.variants.iter_mut().find(|variant| {
|
|
variant
|
|
.phase_material_indices
|
|
.get(binding.initial_phase_index)
|
|
.copied()
|
|
== Some(material_index)
|
|
}) {
|
|
variant.range_indices.push(range_index);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn find_material_animation_variant(
|
|
bindings: &[MaterialAnimationBinding],
|
|
material_index: usize,
|
|
) -> Option<(usize, Vec<usize>)> {
|
|
bindings
|
|
.iter()
|
|
.enumerate()
|
|
.find_map(|(binding_index, binding)| {
|
|
binding.variants.iter().find_map(|variant| {
|
|
(variant
|
|
.phase_material_indices
|
|
.get(binding.initial_phase_index)
|
|
.copied()
|
|
== Some(material_index))
|
|
.then(|| (binding_index, variant.phase_material_indices.clone()))
|
|
})
|
|
})
|
|
}
|
|
|
|
fn selected_material_variant_index(
|
|
variant: &MaterialVariantSet,
|
|
phase_index: usize,
|
|
) -> Option<usize> {
|
|
variant.phase_material_indices.get(phase_index).copied()
|
|
}
|
|
|
|
fn remap_material_animation_ranges(
|
|
bindings: &mut [MaterialAnimationBinding],
|
|
remap: &[usize],
|
|
) -> Result<(), String> {
|
|
for binding in bindings {
|
|
for variant in &mut binding.variants {
|
|
for range_index in &mut variant.range_indices {
|
|
*range_index = *remap
|
|
.get(*range_index)
|
|
.ok_or_else(|| "MAT0 draw range remap is out of bounds".to_string())?;
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn phase_texture_name(phase: &MaterialPhase) -> Option<ResourceName> {
|
|
let length = phase
|
|
.texture_raw
|
|
.iter()
|
|
.position(|byte| *byte == 0)
|
|
.unwrap_or(phase.texture_raw.len());
|
|
(length != 0).then(|| ResourceName(phase.texture_raw[..length].to_vec()))
|
|
}
|
|
|
|
fn phase_uv_transforms_from_assets(
|
|
document: &Mat0Document,
|
|
assets: &MissionAssets,
|
|
) -> Vec<[f32; 4]> {
|
|
document
|
|
.phases
|
|
.iter()
|
|
.map(|phase| {
|
|
phase_texture_name(phase)
|
|
.and_then(|name| {
|
|
assets
|
|
.textures
|
|
.iter()
|
|
.find(|texture| {
|
|
texture.usage == PreparedTextureUsage::Diffuse
|
|
&& texture.source.name == name
|
|
})
|
|
.and_then(|texture| {
|
|
page_uv_transform(
|
|
texture.texm.width(),
|
|
texture.texm.height(),
|
|
&texture.texm.page_rects(),
|
|
i8::from_ne_bytes([phase.parameters[17]]),
|
|
)
|
|
.ok()
|
|
})
|
|
})
|
|
.unwrap_or([0.0, 0.0, 1.0, 1.0])
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn phase_uv_transforms_from_standalone(
|
|
loader: &mut StandaloneWearMaterialLoader,
|
|
wear_path: &Path,
|
|
material_index: u16,
|
|
document: &Mat0Document,
|
|
) -> Result<Vec<[f32; 4]>, String> {
|
|
document
|
|
.phases
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(phase_index, phase)| {
|
|
if phase_texture_name(phase).is_none() {
|
|
return Ok([0.0, 0.0, 1.0, 1.0]);
|
|
}
|
|
loader
|
|
.load(
|
|
wear_path,
|
|
material_index,
|
|
u16::try_from(phase_index).unwrap_or(u16::MAX),
|
|
)
|
|
.map(|(_, prepared)| prepared.page_uv_transform)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct EnvironmentSkyLayer {
|
|
kind: SkyPassKind,
|
|
stage: Option<usize>,
|
|
secondary_stage: Option<usize>,
|
|
material_index: usize,
|
|
diffuse_alpha: f32,
|
|
static_directional_rgb: [f32; 3],
|
|
static_additive_rgb: [f32; 3],
|
|
translation_z: f32,
|
|
range: EnvironmentGpuRange,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct EnvironmentSpriteLayer {
|
|
row: usize,
|
|
material_index: usize,
|
|
diffuse_alpha: f32,
|
|
flare_index: Option<usize>,
|
|
range: EnvironmentGpuRange,
|
|
}
|
|
|
|
/// Fixed-capacity GPU allocations for the CPU-owned sky and environment
|
|
/// output. The Vulkan renderer only receives one shared vertex update and
|
|
/// smaller draw counts each frame; descriptor images and index allocations
|
|
/// stay stable while the camera and weather move.
|
|
#[derive(Clone, Debug)]
|
|
struct EnvironmentGpuScene {
|
|
material_animations: Vec<MaterialAnimationBinding>,
|
|
sky_layers: Vec<EnvironmentSkyLayer>,
|
|
sprites: Vec<EnvironmentSpriteLayer>,
|
|
rain: EnvironmentGpuRange,
|
|
snow: EnvironmentGpuRange,
|
|
lightning: EnvironmentGpuRange,
|
|
lightning_material_index: usize,
|
|
}
|
|
|
|
fn world_pipeline_state(blend: LegacyBlendMode, depth: LegacyDepthMode) -> LegacyPipelineState {
|
|
LegacyPipelineState {
|
|
blend,
|
|
depth,
|
|
..LegacyPipelineState::default()
|
|
}
|
|
}
|
|
|
|
fn projected_shadow_pipeline_state() -> LegacyPipelineState {
|
|
LegacyPipelineState {
|
|
blend: LegacyBlendMode::SourceAlpha,
|
|
depth: LegacyDepthMode::TestReadOnly,
|
|
depth_bias: LegacyDepthBias::ProjectedDecal,
|
|
..LegacyPipelineState::default()
|
|
}
|
|
}
|
|
|
|
fn environment_vertex(position: [f32; 3], color: [f32; 3], uv: [f32; 2]) -> VulkanStaticVertex {
|
|
environment_vertex_with_alpha(position, color, 1.0, uv)
|
|
}
|
|
|
|
fn environment_vertex_with_alpha(
|
|
position: [f32; 3],
|
|
color: [f32; 3],
|
|
alpha: f32,
|
|
uv: [f32; 2],
|
|
) -> VulkanStaticVertex {
|
|
environment_vertex_with_alpha_normal(position, color, alpha, uv, [0.0, 0.0, 1.0])
|
|
}
|
|
|
|
fn environment_vertex_with_alpha_normal(
|
|
position: [f32; 3],
|
|
color: [f32; 3],
|
|
alpha: f32,
|
|
uv: [f32; 2],
|
|
normal: [f32; 3],
|
|
) -> VulkanStaticVertex {
|
|
VulkanStaticVertex {
|
|
position,
|
|
color,
|
|
normal,
|
|
uv,
|
|
detail_uv: [0.0, 0.0],
|
|
overlay_alpha: alpha.clamp(0.0, 1.0),
|
|
}
|
|
}
|
|
|
|
fn sky_normal(raw: [i8; 3]) -> [f32; 3] {
|
|
// Ngi32's cloud-light decode uses the signed-byte scale 1/128 and keeps
|
|
// the authored vector length. Renormalizing (or using 1/127) changes the
|
|
// edge values and shifts the native cloud shading.
|
|
[
|
|
f32::from(raw[0]) / 128.0,
|
|
f32::from(raw[1]) / 128.0,
|
|
f32::from(raw[2]) / 128.0,
|
|
]
|
|
}
|
|
|
|
fn solid_environment_texture(rgba: [u8; 4]) -> VulkanStaticTexture {
|
|
let mip = VulkanStaticTextureMip {
|
|
width: 1,
|
|
height: 1,
|
|
rgba8: rgba.to_vec(),
|
|
};
|
|
VulkanStaticTexture {
|
|
width: 1,
|
|
height: 1,
|
|
rgba8: mip.rgba8.clone(),
|
|
mip_levels: vec![mip],
|
|
}
|
|
}
|
|
|
|
fn vulkan_texture_from_rgba_mips(
|
|
mips: Vec<RgbaImage>,
|
|
label: &str,
|
|
) -> Result<VulkanStaticTexture, String> {
|
|
let first = mips
|
|
.first()
|
|
.ok_or_else(|| format!("{label} has no mip zero"))?;
|
|
Ok(VulkanStaticTexture {
|
|
width: first.width,
|
|
height: first.height,
|
|
rgba8: first.rgba8.clone(),
|
|
mip_levels: mips
|
|
.into_iter()
|
|
.map(|mip| VulkanStaticTextureMip {
|
|
width: mip.width,
|
|
height: mip.height,
|
|
rgba8: mip.rgba8,
|
|
})
|
|
.collect(),
|
|
})
|
|
}
|
|
|
|
fn append_environment_material(
|
|
materials: &mut Vec<VulkanStaticMaterial>,
|
|
material_names: &mut HashMap<String, usize>,
|
|
material_assets: &HashMap<String, EnvironmentMaterialAsset>,
|
|
material_animations: &mut Vec<MaterialAnimationBinding>,
|
|
name: &str,
|
|
fallback_row: usize,
|
|
) -> Result<usize, String> {
|
|
if let Some(index) = material_names.get(name).copied() {
|
|
return Ok(index);
|
|
}
|
|
let asset = material_assets.get(name).ok_or_else(|| {
|
|
format!("environment material {name:?} is missing from resolved material.lib/MAT0 assets")
|
|
})?;
|
|
let phases = if asset.phases.is_empty() {
|
|
vec![asset.phase]
|
|
} else {
|
|
asset.phases.clone()
|
|
};
|
|
let first_material_index = materials.len();
|
|
let mut phase_material_indices = Vec::with_capacity(phases.len());
|
|
for (phase_index, phase) in phases.iter().enumerate() {
|
|
let index = materials.len();
|
|
let material_index = u16::try_from(index).map_err(|_| {
|
|
"environment exceeds the available 16-bit material selector space".to_string()
|
|
})?;
|
|
materials.push(VulkanStaticMaterial {
|
|
material_index,
|
|
pipeline_category: phase.pipeline_category,
|
|
texture: asset
|
|
.phase_textures
|
|
.get(phase_index)
|
|
.cloned()
|
|
.unwrap_or_else(|| asset.texture.clone()),
|
|
directional_rgb: phase.directional_rgb,
|
|
additive_rgb: phase.additive_rgb,
|
|
uv_transform: phase.page_uv_transform,
|
|
detail_texture: Some(solid_environment_texture([128, 128, 128, 255])),
|
|
overlay_texture: None,
|
|
overlay_detail_texture: None,
|
|
diffuse_alpha: phase.diffuse_alpha.clamp(0.0, 1.0),
|
|
overlay_diffuse_alpha: None,
|
|
overlay_directional_rgb: None,
|
|
overlay_additive_rgb: None,
|
|
specular_rgb: phase.specular_rgb,
|
|
specular_power: phase.power,
|
|
overlay_specular_rgb: None,
|
|
overlay_specular_power: None,
|
|
detail_uv_transform: [0.0, 0.0, 1.0, 1.0],
|
|
overlay_uv_transform: None,
|
|
overlay_detail_uv_transform: None,
|
|
// Native cloud draws carry shade/fog flags. The remaining sky rows
|
|
// are unlit overlays; all rows still retain their decoded normals.
|
|
unlit: fallback_row != 2,
|
|
sky_nebula_stars: false,
|
|
lightmap_mode: false,
|
|
sky_far_depth: matches!(fallback_row, 0..=2),
|
|
screen_tl: ENVIRONMENT_SPRITE_ROWS.contains(&fallback_row),
|
|
});
|
|
phase_material_indices.push(index);
|
|
}
|
|
let document = asset.document.clone();
|
|
material_animations.push(MaterialAnimationBinding {
|
|
initial_phase_index: 0,
|
|
normalized_phase_override: None,
|
|
ambient_fx_material_alpha: false,
|
|
variants: vec![MaterialVariantSet {
|
|
phase_material_indices,
|
|
range_indices: Vec::new(),
|
|
pipeline_category: phases[0].pipeline_category,
|
|
}],
|
|
base: MaterialPhaseBinding {
|
|
document,
|
|
animation_block_index: 0,
|
|
wear_row_start_ms: 0,
|
|
random_state: first_material_index as u32,
|
|
frozen_phase_index: None,
|
|
phase_uv_transforms: phases.iter().map(|phase| phase.page_uv_transform).collect(),
|
|
},
|
|
detail: None,
|
|
overlay: None,
|
|
overlay_detail: None,
|
|
stage_textures: None,
|
|
});
|
|
material_names.insert(name.to_owned(), first_material_index);
|
|
Ok(first_material_index)
|
|
}
|
|
|
|
fn prepare_effect_material(
|
|
root: &Path,
|
|
archive: &str,
|
|
name: &str,
|
|
materials: &mut Vec<VulkanStaticMaterial>,
|
|
cache: &mut HashMap<String, PreparedEffectMaterial>,
|
|
) -> Result<PreparedEffectMaterial, String> {
|
|
let key = format!("{archive}/{name}");
|
|
if let Some(prepared) = cache.get(&key) {
|
|
return Ok(prepared.clone());
|
|
}
|
|
let (first_mips, first_phase, document) =
|
|
load_material_archive_name_texture_rgba8_and_phase_with_document_from_root(
|
|
root, archive, name, 0,
|
|
)
|
|
.map_err(|error| format!("resolve FX MAT0 {key}: {error}"))?;
|
|
if document.phases.is_empty() {
|
|
return Err(format!("FX MAT0 {key} has no phases"));
|
|
}
|
|
|
|
let mut phase_material_indices = Vec::with_capacity(document.phases.len());
|
|
let mut phase_uv_transforms = Vec::with_capacity(document.phases.len());
|
|
let mut phase_native_flag_40 = Vec::with_capacity(document.phases.len());
|
|
let mut phase_directional_rgb = Vec::with_capacity(document.phases.len());
|
|
let mut phase_additive_rgb = Vec::with_capacity(document.phases.len());
|
|
for phase_index in 0..document.phases.len() {
|
|
let (mips, phase) = if phase_index == 0 {
|
|
(first_mips.clone(), first_phase)
|
|
} else {
|
|
let phase_index_u16 = u16::try_from(phase_index)
|
|
.map_err(|_| format!("FX MAT0 {key} phase index exceeds u16"))?;
|
|
let (mips, phase, _document) =
|
|
load_material_archive_name_texture_rgba8_and_phase_with_document_from_root(
|
|
root,
|
|
archive,
|
|
name,
|
|
phase_index_u16,
|
|
)
|
|
.map_err(|error| format!("resolve FX MAT0 {key} phase {phase_index}: {error}"))?;
|
|
(mips, phase)
|
|
};
|
|
let texture = vulkan_texture_from_rgba_mips(mips, "FX material")?;
|
|
let index = materials.len();
|
|
let material_index = u16::try_from(index).map_err(|_| {
|
|
"FX effects exceed the available 16-bit material selector space".to_string()
|
|
})?;
|
|
materials.push(VulkanStaticMaterial {
|
|
material_index,
|
|
pipeline_category: phase.pipeline_category,
|
|
texture,
|
|
directional_rgb: phase.directional_rgb,
|
|
additive_rgb: phase.additive_rgb,
|
|
uv_transform: phase.page_uv_transform,
|
|
detail_texture: Some(solid_environment_texture([128, 128, 128, 255])),
|
|
overlay_texture: None,
|
|
overlay_detail_texture: None,
|
|
// FX descriptor opacity is carried by each frame's vertex alpha.
|
|
// Keep MAT0 alpha at one so it does not multiply the opacity a
|
|
// second time.
|
|
diffuse_alpha: 1.0,
|
|
overlay_diffuse_alpha: None,
|
|
overlay_directional_rgb: None,
|
|
overlay_additive_rgb: None,
|
|
specular_rgb: phase.specular_rgb,
|
|
specular_power: phase.power,
|
|
overlay_specular_rgb: None,
|
|
overlay_specular_power: None,
|
|
detail_uv_transform: [0.0, 0.0, 1.0, 1.0],
|
|
overlay_uv_transform: None,
|
|
overlay_detail_uv_transform: None,
|
|
unlit: true,
|
|
sky_nebula_stars: false,
|
|
lightmap_mode: false,
|
|
sky_far_depth: false,
|
|
screen_tl: false,
|
|
});
|
|
phase_material_indices.push(index);
|
|
phase_uv_transforms.push(phase.page_uv_transform);
|
|
phase_native_flag_40.push(phase.native_flag_40);
|
|
phase_directional_rgb.push(phase.directional_rgb);
|
|
phase_additive_rgb.push(phase.additive_rgb);
|
|
}
|
|
let prepared = PreparedEffectMaterial {
|
|
initial_pipeline_category: materials[phase_material_indices[0]].pipeline_category,
|
|
document,
|
|
phase_material_indices,
|
|
phase_uv_transforms,
|
|
phase_native_flag_40,
|
|
phase_directional_rgb,
|
|
phase_additive_rgb,
|
|
};
|
|
cache.insert(key, prepared.clone());
|
|
Ok(prepared)
|
|
}
|
|
|
|
fn append_effect_material_animation(
|
|
prepared: &PreparedEffectMaterial,
|
|
range_index: usize,
|
|
bindings: &mut Vec<MaterialAnimationBinding>,
|
|
) -> usize {
|
|
let binding_index = bindings.len();
|
|
bindings.push(MaterialAnimationBinding {
|
|
initial_phase_index: 0,
|
|
normalized_phase_override: None,
|
|
ambient_fx_material_alpha: true,
|
|
variants: vec![MaterialVariantSet {
|
|
phase_material_indices: prepared.phase_material_indices.clone(),
|
|
range_indices: vec![range_index],
|
|
pipeline_category: prepared.initial_pipeline_category,
|
|
}],
|
|
base: MaterialPhaseBinding {
|
|
document: prepared.document.clone(),
|
|
animation_block_index: 0,
|
|
wear_row_start_ms: 0,
|
|
random_state: u32::try_from(binding_index).unwrap_or(u32::MAX),
|
|
frozen_phase_index: None,
|
|
phase_uv_transforms: prepared.phase_uv_transforms.clone(),
|
|
},
|
|
detail: None,
|
|
overlay: None,
|
|
overlay_detail: None,
|
|
stage_textures: None,
|
|
});
|
|
binding_index
|
|
}
|
|
|
|
fn append_environment_gradient_material(
|
|
materials: &mut Vec<VulkanStaticMaterial>,
|
|
) -> Result<usize, String> {
|
|
let index = materials.len();
|
|
let material_index = u16::try_from(index).map_err(|_| {
|
|
"environment exceeds the available 16-bit material selector space".to_string()
|
|
})?;
|
|
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],
|
|
uv_transform: [0.0, 0.0, 1.0, 1.0],
|
|
detail_texture: Some(solid_environment_texture([128, 128, 128, 255])),
|
|
overlay_texture: None,
|
|
overlay_detail_texture: None,
|
|
diffuse_alpha: 1.0,
|
|
overlay_diffuse_alpha: None,
|
|
overlay_directional_rgb: None,
|
|
overlay_additive_rgb: None,
|
|
specular_rgb: [0.0; 3],
|
|
specular_power: 0,
|
|
overlay_specular_rgb: None,
|
|
overlay_specular_power: None,
|
|
detail_uv_transform: [0.0, 0.0, 1.0, 1.0],
|
|
overlay_uv_transform: None,
|
|
overlay_detail_uv_transform: None,
|
|
unlit: true,
|
|
sky_nebula_stars: false,
|
|
lightmap_mode: false,
|
|
sky_far_depth: true,
|
|
screen_tl: false,
|
|
});
|
|
Ok(index)
|
|
}
|
|
|
|
fn shadow_atlas_texture() -> VulkanStaticTexture {
|
|
let rgba8 = vec![0_u8; 256 * 256 * 4];
|
|
VulkanStaticTexture {
|
|
width: 256,
|
|
height: 256,
|
|
rgba8: rgba8.clone(),
|
|
mip_levels: vec![VulkanStaticTextureMip {
|
|
width: 256,
|
|
height: 256,
|
|
rgba8,
|
|
}],
|
|
}
|
|
}
|
|
|
|
fn append_shadow_material(materials: &mut Vec<VulkanStaticMaterial>) -> Result<usize, String> {
|
|
let material_index = u16::try_from(materials.len()).map_err(|_| {
|
|
"shadow material selector exceeds the available 16-bit material selector space".to_string()
|
|
})?;
|
|
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],
|
|
uv_transform: [0.0, 0.0, 1.0, 1.0],
|
|
detail_texture: Some(solid_environment_texture([255, 255, 255, 255])),
|
|
overlay_texture: None,
|
|
overlay_detail_texture: None,
|
|
diffuse_alpha: 1.0,
|
|
overlay_diffuse_alpha: None,
|
|
overlay_directional_rgb: None,
|
|
overlay_additive_rgb: None,
|
|
specular_rgb: [0.0; 3],
|
|
specular_power: 0,
|
|
overlay_specular_rgb: None,
|
|
overlay_specular_power: None,
|
|
detail_uv_transform: [0.0, 0.0, 1.0, 1.0],
|
|
overlay_uv_transform: None,
|
|
overlay_detail_uv_transform: None,
|
|
// Combiner mode 1 keeps the atlas alpha and the projected receiver's
|
|
// per-vertex alpha while bypassing world lighting/fog.
|
|
unlit: true,
|
|
sky_nebula_stars: false,
|
|
lightmap_mode: false,
|
|
sky_far_depth: false,
|
|
screen_tl: false,
|
|
});
|
|
Ok(index)
|
|
}
|
|
|
|
fn shadow_placeholder_vertices() -> Vec<VulkanStaticVertex> {
|
|
[
|
|
environment_vertex_with_alpha([0.0, 0.0, 0.0], [1.0; 3], 0.0, [0.0, 0.0]),
|
|
environment_vertex_with_alpha([1.0, 0.0, 0.0], [1.0; 3], 0.0, [1.0, 0.0]),
|
|
environment_vertex_with_alpha([0.0, 1.0, 0.0], [1.0; 3], 0.0, [0.0, 1.0]),
|
|
environment_vertex_with_alpha([1.0, 1.0, 0.0], [1.0; 3], 0.0, [1.0, 1.0]),
|
|
]
|
|
.to_vec()
|
|
}
|
|
|
|
fn shadow_triangle(positions: [[f32; 3]; 3], mask: u32) -> ShadowTriangle {
|
|
let normal = normalize3(cross3(
|
|
sub3(positions[1], positions[0]),
|
|
sub3(positions[2], positions[0]),
|
|
))
|
|
.unwrap_or([0.0, 0.0, 1.0]);
|
|
ShadowTriangle {
|
|
positions,
|
|
normal,
|
|
mask,
|
|
}
|
|
}
|
|
|
|
fn terrain_shadow_receivers(
|
|
terrain: &fparkan_terrain_format::LandMeshDocument,
|
|
) -> Result<Vec<ShadowTriangle>, String> {
|
|
let assignments = terrain
|
|
.face_lod_assignments()
|
|
.map_err(|error| format!("select terrain LOD0 shadow receivers: {error}"))?;
|
|
assignments
|
|
.into_iter()
|
|
.filter(|assignment| assignment.lod == TerrainLod::Lod0)
|
|
.map(|assignment| {
|
|
let face = terrain.faces.get(assignment.face_index).ok_or_else(|| {
|
|
format!(
|
|
"terrain LOD assignment references missing face {}",
|
|
assignment.face_index
|
|
)
|
|
})?;
|
|
let positions = source_terrain_face_positions(terrain, assignment.face_index)?;
|
|
if !positions.iter().flatten().all(|value| value.is_finite()) {
|
|
return Err("terrain shadow receiver contains a non-finite position".to_string());
|
|
}
|
|
Ok(shadow_triangle(positions, face.flags.0))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn source_terrain_face_positions(
|
|
terrain: &fparkan_terrain_format::LandMeshDocument,
|
|
face_index: usize,
|
|
) -> Result<[[f32; 3]; 3], String> {
|
|
let face = terrain
|
|
.faces
|
|
.get(face_index)
|
|
.ok_or_else(|| format!("terrain LOD assignment references missing face {face_index}"))?;
|
|
let [first, second, third] = face.vertices;
|
|
Ok([
|
|
terrain
|
|
.positions
|
|
.get(usize::from(first))
|
|
.copied()
|
|
.ok_or_else(|| {
|
|
format!("terrain face {face_index} references missing vertex {first}")
|
|
})?,
|
|
terrain
|
|
.positions
|
|
.get(usize::from(second))
|
|
.copied()
|
|
.ok_or_else(|| {
|
|
format!("terrain face {face_index} references missing vertex {second}")
|
|
})?,
|
|
terrain
|
|
.positions
|
|
.get(usize::from(third))
|
|
.copied()
|
|
.ok_or_else(|| {
|
|
format!("terrain face {face_index} references missing vertex {third}")
|
|
})?,
|
|
])
|
|
}
|
|
|
|
fn static_mesh_shadow_triangles(mesh: &VulkanStaticMesh) -> Vec<ShadowTriangle> {
|
|
mesh.indices
|
|
.chunks_exact(3)
|
|
.filter_map(|triangle| {
|
|
let positions = [
|
|
mesh.vertices
|
|
.get(usize::try_from(triangle[0]).ok()?)?
|
|
.position,
|
|
mesh.vertices
|
|
.get(usize::try_from(triangle[1]).ok()?)?
|
|
.position,
|
|
mesh.vertices
|
|
.get(usize::try_from(triangle[2]).ok()?)?
|
|
.position,
|
|
];
|
|
positions
|
|
.iter()
|
|
.flatten()
|
|
.all(|value| value.is_finite())
|
|
.then_some(shadow_triangle(positions, 0))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn static_mesh_shadow_triangles_in_index_range(
|
|
mesh: &VulkanStaticMesh,
|
|
first_index: usize,
|
|
index_count: usize,
|
|
) -> Result<Vec<ShadowTriangle>, String> {
|
|
if index_count % 3 != 0 {
|
|
return Err(format!(
|
|
"dynamic shadow index range {first_index}..{} is not triangle-aligned",
|
|
first_index.saturating_add(index_count)
|
|
));
|
|
}
|
|
let end = first_index
|
|
.checked_add(index_count)
|
|
.filter(|end| *end <= mesh.indices.len())
|
|
.ok_or_else(|| {
|
|
format!(
|
|
"dynamic shadow index range {first_index}..{} is out of bounds",
|
|
first_index.saturating_add(index_count)
|
|
)
|
|
})?;
|
|
mesh.indices[first_index..end]
|
|
.chunks_exact(3)
|
|
.map(|triangle| {
|
|
let positions =
|
|
triangle
|
|
.iter()
|
|
.map(|index| {
|
|
mesh.vertices
|
|
.get(usize::try_from(*index).map_err(|_| {
|
|
"dynamic shadow vertex index exceeds usize".to_string()
|
|
})?)
|
|
.map(|vertex| vertex.position)
|
|
.ok_or_else(|| {
|
|
format!("dynamic shadow vertex index {index} is out of bounds")
|
|
})
|
|
})
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
let positions: [[f32; 3]; 3] = positions
|
|
.try_into()
|
|
.map_err(|_| "dynamic shadow triangle did not have three vertices".to_string())?;
|
|
if !positions.iter().flatten().all(|value| value.is_finite()) {
|
|
return Err("dynamic shadow triangle contains a non-finite position".to_string());
|
|
}
|
|
Ok(shadow_triangle(positions, 0))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn refresh_dynamic_shadow_casters(
|
|
casters: &mut [ShadowCaster],
|
|
dynamic_casters: &[DynamicShadowCaster],
|
|
mesh: &VulkanStaticMesh,
|
|
pose_buffers: &[Vec<Option<NodePoseBuffer>>],
|
|
mount_poses: &[Vec<Option<VulkanNodePose>>],
|
|
) -> Result<(), String> {
|
|
for dynamic in dynamic_casters {
|
|
let caster = casters.get_mut(dynamic.caster_index).ok_or_else(|| {
|
|
format!(
|
|
"dynamic shadow caster {} is outside the aggregate caster table",
|
|
dynamic.caster_index
|
|
)
|
|
})?;
|
|
let mut triangles = Vec::new();
|
|
for &range_index in &dynamic.component_draw_range_indices {
|
|
let range = mesh.draw_ranges.get(range_index).ok_or_else(|| {
|
|
format!(
|
|
"dynamic shadow caster {} references missing draw range {range_index}",
|
|
dynamic.caster_index
|
|
)
|
|
})?;
|
|
triangles.extend(static_mesh_shadow_triangles_in_index_range(
|
|
mesh,
|
|
usize::try_from(range.first_index)
|
|
.map_err(|_| "dynamic shadow draw range index exceeds usize".to_string())?,
|
|
usize::try_from(range.index_count)
|
|
.map_err(|_| "dynamic shadow draw range count exceeds usize".to_string())?,
|
|
)?);
|
|
}
|
|
if triangles.is_empty() {
|
|
return Err(format!(
|
|
"dynamic shadow caster {} lost its component triangles",
|
|
dynamic.caster_index
|
|
));
|
|
}
|
|
let component_spheres = dynamic
|
|
.component_spheres
|
|
.iter()
|
|
.filter_map(|source| {
|
|
let mount_pose = if source.mounted {
|
|
mount_poses
|
|
.get(dynamic.object_index)
|
|
.and_then(|items| items.get(source.component_index))
|
|
.copied()
|
|
.flatten()
|
|
} else {
|
|
None
|
|
};
|
|
if source.mounted && mount_pose.is_none() {
|
|
return None;
|
|
}
|
|
let node1_pose = if source.mounted {
|
|
pose_buffers
|
|
.get(dynamic.object_index)
|
|
.and_then(|items| items.get(source.component_index))
|
|
.and_then(Option::as_ref)
|
|
.and_then(|hierarchy| {
|
|
node38_pose_relative_to_root_from_hierarchy(hierarchy, 1)
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
mounted_shadow_sphere(source.center, source.radius, mount_pose, node1_pose)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
if component_spheres.len() != dynamic.component_spheres.len() {
|
|
return Err(format!(
|
|
"dynamic shadow caster {} lost one or more component sphere poses",
|
|
dynamic.caster_index
|
|
));
|
|
}
|
|
let mut refreshed = aggregate_shadow_caster(
|
|
caster.id,
|
|
caster.native_kind,
|
|
dynamic.root_transform,
|
|
dynamic.owner_scale,
|
|
triangles,
|
|
&component_spheres,
|
|
)
|
|
.ok_or_else(|| {
|
|
format!(
|
|
"dynamic shadow caster {} could not be rebuilt from current component poses",
|
|
dynamic.caster_index
|
|
)
|
|
})?;
|
|
// shortcut: animated native sphere parity is unverified. Rebuild from
|
|
// the startup native bound, the current native bound, and current
|
|
// triangles so the result is conservative and independent of history.
|
|
refreshed.sphere = shadow_sphere_enclosing_current_geometry(
|
|
dynamic.initial_sphere,
|
|
refreshed.sphere,
|
|
&refreshed.triangles,
|
|
)?;
|
|
*caster = refreshed;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn shadow_sphere_enclosing_current_geometry(
|
|
baseline: ShadowSphere,
|
|
current_native: ShadowSphere,
|
|
triangles: &[ShadowTriangle],
|
|
) -> Result<ShadowSphere, String> {
|
|
let mut sphere = baseline;
|
|
if !sphere.radius.is_finite()
|
|
|| sphere.radius < 0.0
|
|
|| !sphere.center.iter().all(|value| value.is_finite())
|
|
|| !current_native.radius.is_finite()
|
|
|| current_native.radius < 0.0
|
|
|| !current_native.center.iter().all(|value| value.is_finite())
|
|
{
|
|
return Err("animated shadow caster has an invalid bounding sphere".to_string());
|
|
}
|
|
|
|
let offset = sub3(current_native.center, sphere.center);
|
|
let distance = offset[0].hypot(offset[1]).hypot(offset[2]);
|
|
if !distance.is_finite() {
|
|
return Err("animated shadow sphere distance is non-finite".to_string());
|
|
}
|
|
let required = distance + current_native.radius;
|
|
if !required.is_finite() {
|
|
return Err("animated shadow sphere radius is non-finite".to_string());
|
|
}
|
|
if current_native.radius >= distance + sphere.radius {
|
|
sphere = current_native;
|
|
} else if required > sphere.radius {
|
|
let next_radius = sphere.radius + 0.5 * (required - sphere.radius);
|
|
let shift = scale3(offset, (next_radius - sphere.radius) / distance);
|
|
sphere.center = add3(sphere.center, shift);
|
|
sphere.radius = next_radius;
|
|
}
|
|
|
|
for point in triangles.iter().flat_map(|triangle| triangle.positions) {
|
|
if !point.iter().all(|value| value.is_finite()) {
|
|
return Err("animated shadow triangle has a non-finite position".to_string());
|
|
}
|
|
let offset = sub3(point, sphere.center);
|
|
let distance = offset[0].hypot(offset[1]).hypot(offset[2]);
|
|
if !distance.is_finite() {
|
|
return Err("animated shadow point distance is non-finite".to_string());
|
|
}
|
|
if distance <= sphere.radius {
|
|
continue;
|
|
}
|
|
let next_radius = sphere.radius + 0.5 * (distance - sphere.radius);
|
|
let center_shift = scale3(offset, (next_radius - sphere.radius) / distance);
|
|
sphere.center = add3(sphere.center, center_shift);
|
|
sphere.radius = next_radius;
|
|
}
|
|
|
|
Ok(sphere)
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct ShadowComponentSphere {
|
|
center: [f32; 3],
|
|
radius: f32,
|
|
}
|
|
|
|
fn mounted_shadow_sphere(
|
|
center: [f32; 3],
|
|
radius: f32,
|
|
mount_pose: Option<VulkanNodePose>,
|
|
node1_pose: Option<VulkanNodePose>,
|
|
) -> Option<ShadowComponentSphere> {
|
|
if !center.iter().all(|value| value.is_finite()) || !radius.is_finite() || radius <= 0.0 {
|
|
return None;
|
|
}
|
|
let center = mount_pose.map_or(center, |parent_pose| {
|
|
// The native mounted-unit sphere query keeps node 1's accumulated
|
|
// rotation but replaces its translation with the accumulated parent
|
|
// translation. This is deliberately different from the render mount
|
|
// composition, which also carries node 1's translation.
|
|
let rotation = node1_pose.map_or(parent_pose.rotation, |node_pose| {
|
|
multiply_unit_quaternions(parent_pose.rotation, node_pose.rotation)
|
|
});
|
|
add3(
|
|
parent_pose.translation,
|
|
rotate_unit_vector(center, rotation),
|
|
)
|
|
});
|
|
center
|
|
.iter()
|
|
.all(|value| value.is_finite())
|
|
.then_some(ShadowComponentSphere { center, radius })
|
|
}
|
|
|
|
fn aggregate_shadow_caster(
|
|
id: u32,
|
|
native_kind: u32,
|
|
transform: LegacyIron3dEulerTransform,
|
|
scale: [f32; 3],
|
|
triangles: Vec<ShadowTriangle>,
|
|
component_spheres: &[ShadowComponentSphere],
|
|
) -> Option<ShadowCaster> {
|
|
if triangles.is_empty() {
|
|
return None;
|
|
}
|
|
let weight = component_spheres
|
|
.iter()
|
|
.map(|sphere| sphere.radius)
|
|
.sum::<f32>();
|
|
if !weight.is_finite() || weight <= f32::EPSILON {
|
|
return None;
|
|
}
|
|
// Native 9510 aggregates visible root/EXTO spheres by radius-weighted
|
|
// center, then encloses every component sphere around that center.
|
|
let aggregate_center = component_spheres.iter().fold([0.0; 3], |sum, sphere| {
|
|
add3(sum, scale3(sphere.center, sphere.radius))
|
|
});
|
|
let aggregate_center = scale3(aggregate_center, weight.recip());
|
|
let radius = component_spheres
|
|
.iter()
|
|
.map(|sphere| length3(sub3(sphere.center, aggregate_center)).unwrap_or(0.0) + sphere.radius)
|
|
.fold(0.0, f32::max);
|
|
let max_scale = scale.iter().copied().fold(f32::NEG_INFINITY, f32::max);
|
|
if !aggregate_center.iter().all(|value| value.is_finite())
|
|
|| !radius.is_finite()
|
|
|| radius <= f32::EPSILON
|
|
|| !max_scale.is_finite()
|
|
|| max_scale <= 0.0
|
|
{
|
|
return None;
|
|
}
|
|
// The native scale flag applies to the aggregate exactly once. The
|
|
// mission Euler transform then rotates/translates that scaled sphere.
|
|
let scaled_center = [
|
|
aggregate_center[0] * scale[0],
|
|
aggregate_center[1] * scale[1],
|
|
aggregate_center[2] * scale[2],
|
|
];
|
|
let center = transform.try_transform_scaled_point(scaled_center, [1.0; 3])?;
|
|
let radius = radius * max_scale;
|
|
if !center.iter().all(|value| value.is_finite()) || !radius.is_finite() {
|
|
return None;
|
|
}
|
|
let world_axes = transform
|
|
.try_row_major()
|
|
.map(|matrix| {
|
|
[
|
|
[matrix[0], matrix[4], matrix[8]],
|
|
[matrix[1], matrix[5], matrix[9]],
|
|
[matrix[2], matrix[6], matrix[10]],
|
|
]
|
|
})
|
|
.unwrap_or([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
|
|
Some(ShadowCaster {
|
|
id,
|
|
world_axes,
|
|
sphere: ShadowSphere { center, radius },
|
|
native_kind,
|
|
triangles,
|
|
})
|
|
}
|
|
|
|
fn shadow_lights(
|
|
sky_frame: Option<&SkyFrame<'_>>,
|
|
environment_frame: Option<&EnvironmentFrame>,
|
|
renderer: &VulkanSmokeRenderer,
|
|
control_point_lights: &[(VulkanPointLight, u32)],
|
|
) -> Vec<ShadowLight> {
|
|
let mut lights = Vec::new();
|
|
if let Some(sky_frame) = sky_frame {
|
|
lights.extend(sky_frame.directional_lights.into_iter().enumerate().map(
|
|
|(index, light)| {
|
|
ShadowLight::Directional {
|
|
direction: light.direction,
|
|
rgb: light.color,
|
|
active: light.active,
|
|
// CSun's constructor marks both the sun (slot 0) and
|
|
// moon (slot 2) as primary. ShadowReference selects the
|
|
// first one for kind 3 and accumulates both active
|
|
// primary directions for ordinary objects (kind 4).
|
|
flags: (index == 0 || index == 2)
|
|
.then_some(SHADOW_PRIMARY_DIRECTIONAL_FLAG)
|
|
.unwrap_or(0),
|
|
}
|
|
},
|
|
));
|
|
} else {
|
|
let uniforms = renderer.frame_uniforms();
|
|
lights.extend(
|
|
uniforms
|
|
.directional_lights
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(index, light)| ShadowLight::Directional {
|
|
direction: light.direction,
|
|
rgb: light.rgb,
|
|
active: light.active,
|
|
flags: (index == 0 || index == 2)
|
|
.then_some(SHADOW_PRIMARY_DIRECTIONAL_FLAG)
|
|
.unwrap_or(0),
|
|
}),
|
|
);
|
|
}
|
|
if let Some(environment_frame) = environment_frame {
|
|
lights.extend(
|
|
environment_frame
|
|
.point_lights
|
|
.iter()
|
|
.map(|light| ShadowLight::Point {
|
|
position: light.position,
|
|
rgb: [light.color[0], light.color[1], light.color[2]],
|
|
range: light.range,
|
|
coefficients: light.attenuation,
|
|
active: light.active,
|
|
flags: 0,
|
|
}),
|
|
);
|
|
} else {
|
|
let point = renderer.frame_uniforms().point_light;
|
|
lights.push(ShadowLight::Point {
|
|
position: point.position,
|
|
rgb: point.rgb,
|
|
range: point.range,
|
|
coefficients: point.coefficients,
|
|
active: point.active,
|
|
flags: 0,
|
|
});
|
|
}
|
|
lights.extend(
|
|
control_point_lights
|
|
.iter()
|
|
.map(|(light, flags)| ShadowLight::Point {
|
|
position: light.position,
|
|
rgb: light.rgb,
|
|
range: light.range,
|
|
coefficients: light.coefficients,
|
|
active: light.active,
|
|
flags: *flags,
|
|
}),
|
|
);
|
|
lights
|
|
}
|
|
|
|
/// Native `Terrain30737` expands each caster sphere by 100 world units before
|
|
/// testing it against the six planes in the current world frustum. Filtering
|
|
/// here matters because `CShade` only accepts its first twenty candidates;
|
|
/// off-screen objects must not consume that budget.
|
|
const SHADOW_FRUSTUM_RADIUS_MARGIN: f32 = 100.0;
|
|
|
|
fn native_shadow_caster_visible(caster: &ShadowCaster, clip_from_world: [f32; 16]) -> bool {
|
|
let radius = caster.sphere.radius + SHADOW_FRUSTUM_RADIUS_MARGIN;
|
|
if !radius.is_finite() || radius < 0.0 || !caster.sphere.center.iter().all(|v| v.is_finite()) {
|
|
return false;
|
|
}
|
|
|
|
// The camera matrix is row-major D3D-style storage. A world point is
|
|
// transformed by p * M, so each clip coordinate uses one matrix column.
|
|
// The six Vulkan clip half-spaces are x+w, -x+w, y+w, -y+w, z, and w-z.
|
|
let planes = [
|
|
[
|
|
clip_from_world[0] + clip_from_world[3],
|
|
clip_from_world[4] + clip_from_world[7],
|
|
clip_from_world[8] + clip_from_world[11],
|
|
clip_from_world[12] + clip_from_world[15],
|
|
],
|
|
[
|
|
-clip_from_world[0] + clip_from_world[3],
|
|
-clip_from_world[4] + clip_from_world[7],
|
|
-clip_from_world[8] + clip_from_world[11],
|
|
-clip_from_world[12] + clip_from_world[15],
|
|
],
|
|
[
|
|
clip_from_world[1] + clip_from_world[3],
|
|
clip_from_world[5] + clip_from_world[7],
|
|
clip_from_world[9] + clip_from_world[11],
|
|
clip_from_world[13] + clip_from_world[15],
|
|
],
|
|
[
|
|
-clip_from_world[1] + clip_from_world[3],
|
|
-clip_from_world[5] + clip_from_world[7],
|
|
-clip_from_world[9] + clip_from_world[11],
|
|
-clip_from_world[13] + clip_from_world[15],
|
|
],
|
|
[
|
|
clip_from_world[2],
|
|
clip_from_world[6],
|
|
clip_from_world[10],
|
|
clip_from_world[14],
|
|
],
|
|
[
|
|
clip_from_world[3] - clip_from_world[2],
|
|
clip_from_world[7] - clip_from_world[6],
|
|
clip_from_world[11] - clip_from_world[10],
|
|
clip_from_world[15] - clip_from_world[14],
|
|
],
|
|
];
|
|
planes.into_iter().all(|plane| {
|
|
let normal_length =
|
|
(plane[0].mul_add(plane[0], plane[1].mul_add(plane[1], plane[2] * plane[2]))).sqrt();
|
|
if !normal_length.is_finite() || normal_length <= f32::EPSILON {
|
|
return false;
|
|
}
|
|
let distance = plane[0].mul_add(
|
|
caster.sphere.center[0],
|
|
plane[1].mul_add(
|
|
caster.sphere.center[1],
|
|
plane[2].mul_add(caster.sphere.center[2], plane[3]),
|
|
),
|
|
) / normal_length;
|
|
distance.is_finite() && distance > -radius
|
|
})
|
|
}
|
|
|
|
fn native_visible_shadow_casters(
|
|
casters: &[ShadowCaster],
|
|
clip_from_world: [f32; 16],
|
|
) -> Vec<ShadowCaster> {
|
|
casters
|
|
.iter()
|
|
.filter(|caster| native_shadow_caster_visible(caster, clip_from_world))
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
fn append_shadow_frame_geometry(
|
|
mesh: &mut VulkanStaticMesh,
|
|
shadow_range: EnvironmentGpuRange,
|
|
shadow_base_vertex: usize,
|
|
shadow_base_index: usize,
|
|
frame: &ShadowFrame,
|
|
) -> Result<VulkanDynamicDrawRange, String> {
|
|
if shadow_base_vertex > mesh.vertices.len() || shadow_base_index > mesh.indices.len() {
|
|
return Err("shadow dynamic base is outside the shared mesh".to_string());
|
|
}
|
|
mesh.vertices.truncate(shadow_base_vertex);
|
|
mesh.indices.truncate(shadow_base_index);
|
|
let vertex_base = u32::try_from(mesh.vertices.len())
|
|
.map_err(|_| "shadow vertex base exceeds u32".to_string())?;
|
|
let first_index = u32::try_from(mesh.indices.len())
|
|
.map_err(|_| "shadow index base exceeds u32".to_string())?;
|
|
if frame.indices.iter().any(|&index| {
|
|
usize::try_from(index)
|
|
.ok()
|
|
.is_none_or(|index| index >= frame.vertices.len())
|
|
}) {
|
|
return Err("shadow frame index exceeds its vertex stream".to_string());
|
|
}
|
|
mesh.vertices.extend(frame.vertices.iter().map(|vertex| {
|
|
environment_vertex_with_alpha_normal(
|
|
vertex.position,
|
|
[1.0; 3],
|
|
vertex.alpha.clamp(0.0, 1.0),
|
|
vertex.uv,
|
|
vertex.normal,
|
|
)
|
|
}));
|
|
mesh.indices.extend(
|
|
frame
|
|
.indices
|
|
.iter()
|
|
.map(|&index| {
|
|
vertex_base
|
|
.checked_add(index)
|
|
.ok_or_else(|| "shadow index exceeds u32".to_string())
|
|
})
|
|
.collect::<Result<Vec<_>, _>>()?,
|
|
);
|
|
let index_count = u32::try_from(frame.indices.len())
|
|
.map_err(|_| "shadow index count exceeds u32".to_string())?;
|
|
if let Some(range) = mesh.draw_ranges.get_mut(shadow_range.range_index) {
|
|
range.first_index = first_index;
|
|
range.index_count = index_count;
|
|
}
|
|
Ok(VulkanDynamicDrawRange {
|
|
range_index: shadow_range.range_index,
|
|
first_index,
|
|
index_count,
|
|
})
|
|
}
|
|
|
|
fn shadow_atlas_texture_from_frame(frame: &ShadowFrame) -> VulkanStaticTexture {
|
|
VulkanStaticTexture {
|
|
width: 256,
|
|
height: 256,
|
|
rgba8: frame.atlas_rgba8.clone(),
|
|
mip_levels: vec![VulkanStaticTextureMip {
|
|
width: 256,
|
|
height: 256,
|
|
rgba8: frame.atlas_rgba8.clone(),
|
|
}],
|
|
}
|
|
}
|
|
|
|
fn append_environment_range(
|
|
mesh: &mut VulkanStaticMesh,
|
|
material_index: usize,
|
|
vertices: Vec<VulkanStaticVertex>,
|
|
local_indices: Vec<u32>,
|
|
pipeline_state: LegacyPipelineState,
|
|
) -> Result<EnvironmentGpuRange, String> {
|
|
if vertices.is_empty() || local_indices.is_empty() || !local_indices.len().is_multiple_of(3) {
|
|
return Err("environment geometry range is empty or not triangle-aligned".to_string());
|
|
}
|
|
let vertex_start = mesh.vertices.len();
|
|
let first_index = mesh.indices.len();
|
|
let material_index = u16::try_from(material_index)
|
|
.map_err(|_| "environment material selector exceeds u16".to_string())?;
|
|
let vertex_start_u32 = u32::try_from(vertex_start)
|
|
.map_err(|_| "environment vertex allocation exceeds u32".to_string())?;
|
|
for index in local_indices {
|
|
mesh.indices.push(
|
|
vertex_start_u32
|
|
.checked_add(index)
|
|
.ok_or_else(|| "environment index exceeds u32".to_string())?,
|
|
);
|
|
}
|
|
let index_count = u32::try_from(mesh.indices.len() - first_index)
|
|
.map_err(|_| "environment index allocation exceeds u32".to_string())?;
|
|
let vertex_capacity = vertices.len();
|
|
mesh.vertices.extend(vertices);
|
|
let first_index = u32::try_from(first_index)
|
|
.map_err(|_| "environment first index exceeds u32".to_string())?;
|
|
let range_index = mesh.draw_ranges.len();
|
|
mesh.draw_ranges
|
|
.push(fparkan_render_vulkan::VulkanStaticDrawRange {
|
|
first_index,
|
|
index_count,
|
|
material_index,
|
|
lightmap_index: u8::MAX,
|
|
batch_flags: 0,
|
|
pipeline_state,
|
|
terrain_base_only: false,
|
|
alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
|
|
});
|
|
Ok(EnvironmentGpuRange {
|
|
range_index,
|
|
vertex_start,
|
|
vertex_capacity,
|
|
material_index: usize::from(material_index),
|
|
})
|
|
}
|
|
|
|
/// Reorders indexed draw segments without breaking the static mesh contract.
|
|
///
|
|
/// Draw ranges are required to cover one contiguous index buffer. Changing
|
|
/// only their vector order therefore is insufficient when a background pass
|
|
/// must run before mission geometry: the corresponding index segments have to
|
|
/// move with them and every range receives a new `first_index`.
|
|
fn reorder_draw_ranges(
|
|
mesh: &mut VulkanStaticMesh,
|
|
old_order: &[usize],
|
|
) -> Result<Vec<usize>, String> {
|
|
if old_order.len() != mesh.draw_ranges.len() {
|
|
return Err("draw-range reorder does not cover the whole mesh".to_string());
|
|
}
|
|
let mut seen = vec![false; mesh.draw_ranges.len()];
|
|
let old_ranges = mesh.draw_ranges.clone();
|
|
let old_indices = mesh.indices.clone();
|
|
let mut indices = Vec::with_capacity(old_indices.len());
|
|
let mut ranges = Vec::with_capacity(old_ranges.len());
|
|
let mut remap = vec![usize::MAX; old_ranges.len()];
|
|
for (new_index, &old_index) in old_order.iter().enumerate() {
|
|
if old_index >= old_ranges.len() || seen[old_index] {
|
|
return Err("draw-range reorder contains a duplicate or invalid range".to_string());
|
|
}
|
|
seen[old_index] = true;
|
|
let old_range = old_ranges[old_index];
|
|
let first = usize::try_from(old_range.first_index)
|
|
.map_err(|_| "draw-range first index exceeds addressable memory".to_string())?;
|
|
let count = usize::try_from(old_range.index_count)
|
|
.map_err(|_| "draw-range index count exceeds addressable memory".to_string())?;
|
|
let end = first
|
|
.checked_add(count)
|
|
.ok_or_else(|| "draw-range index segment overflows address space".to_string())?;
|
|
let segment = old_indices
|
|
.get(first..end)
|
|
.ok_or_else(|| "draw-range index segment is outside the mesh".to_string())?;
|
|
let first_index = u32::try_from(indices.len())
|
|
.map_err(|_| "reordered index buffer exceeds u32".to_string())?;
|
|
indices.extend_from_slice(segment);
|
|
let mut range = old_range;
|
|
range.first_index = first_index;
|
|
ranges.push(range);
|
|
remap[old_index] = new_index;
|
|
}
|
|
if seen.iter().any(|seen| !seen) || indices.len() != old_indices.len() {
|
|
return Err("draw-range reorder does not cover the whole index buffer".to_string());
|
|
}
|
|
mesh.indices = indices;
|
|
mesh.draw_ranges = ranges;
|
|
Ok(remap)
|
|
}
|
|
|
|
fn remap_dynamic_shadow_caster_draw_ranges(
|
|
dynamic_casters: &mut [DynamicShadowCaster],
|
|
range_remap: &[usize],
|
|
) -> Result<(), String> {
|
|
let remapped = dynamic_casters
|
|
.iter()
|
|
.map(|dynamic| {
|
|
dynamic
|
|
.component_draw_range_indices
|
|
.iter()
|
|
.map(|&range_index| {
|
|
range_remap.get(range_index).copied().ok_or_else(|| {
|
|
format!(
|
|
"dynamic shadow caster {} references missing pre-reorder draw range {range_index}",
|
|
dynamic.caster_index
|
|
)
|
|
})
|
|
})
|
|
.collect::<Result<Vec<_>, _>>()
|
|
})
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
for (dynamic, ranges) in dynamic_casters.iter_mut().zip(remapped) {
|
|
dynamic.component_draw_range_indices = ranges;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn quad_indices(slot_count: usize) -> Result<Vec<u32>, String> {
|
|
let mut indices = Vec::with_capacity(
|
|
slot_count
|
|
.checked_mul(6)
|
|
.ok_or_else(|| "environment quad index count overflow".to_string())?,
|
|
);
|
|
for slot in 0..slot_count {
|
|
let base = u32::try_from(
|
|
slot.checked_mul(4)
|
|
.ok_or_else(|| "environment quad vertex count overflow".to_string())?,
|
|
)
|
|
.map_err(|_| "environment quad vertex count exceeds u32".to_string())?;
|
|
indices.extend_from_slice(&[base, base + 1, base + 2, base + 2, base + 1, base + 3]);
|
|
}
|
|
Ok(indices)
|
|
}
|
|
|
|
fn placeholder_quad_vertices(
|
|
camera: &PreviewCameraFrame,
|
|
slot_count: usize,
|
|
size: f32,
|
|
) -> Vec<VulkanStaticVertex> {
|
|
let center = add3(
|
|
camera.position,
|
|
scale3(camera.forward(), camera.far_plane * 0.5),
|
|
);
|
|
let right = scale3(camera.right(), size);
|
|
let up = scale3(camera.up(), size);
|
|
(0..slot_count)
|
|
.flat_map(|_| {
|
|
[
|
|
environment_vertex(sub3(sub3(center, right), up), [1.0; 3], [0.0, 1.0]),
|
|
environment_vertex(add3(sub3(center, up), right), [1.0; 3], [1.0, 1.0]),
|
|
environment_vertex(add3(sub3(center, right), up), [1.0; 3], [0.0, 0.0]),
|
|
environment_vertex(add3(add3(center, right), up), [1.0; 3], [1.0, 0.0]),
|
|
]
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn screen_gradient_quad_vertices(
|
|
camera: &PreviewCameraFrame,
|
|
viewport: [f32; 2],
|
|
color: [f32; 3],
|
|
) -> [VulkanStaticVertex; 4] {
|
|
let width = viewport[0].max(1.0);
|
|
let height = viewport[1].max(1.0);
|
|
let aspect = width / height;
|
|
let fov = camera.vertical_fov;
|
|
// The quad is placed close to the camera and drawn with depth disabled.
|
|
// It therefore covers the complete projected viewport while the later
|
|
// dome, terrain, and object ranges provide their own depth-tested detail.
|
|
let distance = 1.0_f32;
|
|
let half_height = distance * (fov * 0.5).tan() * 1.05;
|
|
let half_width = half_height * aspect;
|
|
let center = add3(camera.position, scale3(camera.forward(), distance));
|
|
let right = scale3(camera.right(), half_width);
|
|
let up = scale3(camera.up(), half_height);
|
|
[
|
|
environment_vertex(sub3(sub3(center, right), up), color, [0.0, 1.0]),
|
|
environment_vertex(add3(sub3(center, up), right), color, [1.0, 1.0]),
|
|
environment_vertex(add3(sub3(center, right), up), color, [0.0, 0.0]),
|
|
environment_vertex(add3(add3(center, right), up), color, [1.0, 0.0]),
|
|
]
|
|
}
|
|
|
|
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()
|
|
.map(|layer| layer.range.range_index)
|
|
.chain(self.sprites.iter().map(|sprite| sprite.range.range_index))
|
|
.chain([
|
|
self.rain.range_index,
|
|
self.snow.range_index,
|
|
self.lightning.range_index,
|
|
])
|
|
.collect()
|
|
}
|
|
|
|
fn new(
|
|
mesh: &mut VulkanStaticMesh,
|
|
native_handedness_by_range: &mut HashMap<usize, i8>,
|
|
type16_present_ranges: &mut HashSet<usize>,
|
|
materials: &mut Vec<VulkanStaticMaterial>,
|
|
preview_material_animations: &mut Vec<MaterialAnimationBinding>,
|
|
ambient_effect_slots: &mut [AmbientEffectGpuSlot],
|
|
dynamic_shadow_casters: &mut [DynamicShadowCaster],
|
|
sun_occlusion_range_indices: &mut Vec<usize>,
|
|
environment_materials: &SkyMaterials,
|
|
material_assets: &HashMap<String, EnvironmentMaterialAsset>,
|
|
sky_mesh: &SkyMesh,
|
|
camera: &PreviewCameraFrame,
|
|
shadow_range_index: &mut usize,
|
|
) -> Result<Self, String> {
|
|
let mut names = HashMap::new();
|
|
let mut material_animations = Vec::new();
|
|
let mut material_indices = [None; 9];
|
|
let fallback_names = SkyMaterials::default();
|
|
for row in 0..9 {
|
|
let name = environment_materials
|
|
.material_name(row)
|
|
.or_else(|| fallback_names.material_name(row))
|
|
.unwrap_or("ENVIRONMENT");
|
|
let index = append_environment_material(
|
|
materials,
|
|
&mut names,
|
|
material_assets,
|
|
&mut material_animations,
|
|
name,
|
|
row,
|
|
)?;
|
|
material_indices[row] = Some(index);
|
|
}
|
|
let gradient_material_index = append_environment_gradient_material(materials)?;
|
|
let nebula_material_index = material_indices[0]
|
|
.ok_or_else(|| "nebula material selector is unavailable".to_string())?;
|
|
let stars_material_index = material_indices[1]
|
|
.ok_or_else(|| "stars material selector is unavailable".to_string())?;
|
|
// Native sky mode 4 uses one descriptor set: TEX0 is the nebula and
|
|
// TEX1 is the independently animated stars texture whose alpha chooses
|
|
// the contribution. Keep the stars MAT0 binding as the nebula binding's
|
|
// detail stage; do not form a Cartesian product of the two phase rows.
|
|
let (nebula_binding_index, nebula_phase_materials) =
|
|
find_material_animation_variant(&material_animations, nebula_material_index)
|
|
.ok_or_else(|| {
|
|
format!("MAT0 sky material {nebula_material_index} has no phase variants")
|
|
})?;
|
|
let (stars_binding_index, stars_phase_materials) =
|
|
find_material_animation_variant(&material_animations, stars_material_index)
|
|
.ok_or_else(|| {
|
|
format!("MAT0 sky material {stars_material_index} has no phase variants")
|
|
})?;
|
|
if stars_phase_materials.is_empty() {
|
|
return Err("stars material has no phase variants".to_string());
|
|
}
|
|
let stars_descriptors = stars_phase_materials
|
|
.iter()
|
|
.map(|&material_index| {
|
|
let material = materials.get(material_index).ok_or_else(|| {
|
|
format!("stars material descriptor {material_index} is unavailable")
|
|
})?;
|
|
Ok((material.texture.clone(), material.uv_transform))
|
|
})
|
|
.collect::<Result<Vec<_>, String>>()?;
|
|
let stars_binding = material_animations
|
|
.get(stars_binding_index)
|
|
.ok_or_else(|| "stars material animation binding is unavailable".to_string())?
|
|
.base
|
|
.clone();
|
|
let stars_stage_textures = stars_descriptors
|
|
.iter()
|
|
.map(|(texture, _)| texture.clone())
|
|
.collect();
|
|
let stars_initial_uv = stars_descriptors
|
|
.first()
|
|
.map(|(_, uv_transform)| *uv_transform)
|
|
.unwrap_or([0.0, 0.0, 1.0, 1.0]);
|
|
for &material_index in &nebula_phase_materials {
|
|
let stars_texture = stars_descriptors
|
|
.first()
|
|
.map(|(texture, _)| texture.clone())
|
|
.ok_or_else(|| "stars material descriptor is unavailable".to_string())?;
|
|
let nebula = materials.get_mut(material_index).ok_or_else(|| {
|
|
format!("nebula material descriptor {material_index} is unavailable")
|
|
})?;
|
|
nebula.detail_texture = Some(stars_texture);
|
|
nebula.detail_uv_transform = stars_initial_uv;
|
|
nebula.sky_nebula_stars = true;
|
|
}
|
|
let nebula_binding = material_animations
|
|
.get_mut(nebula_binding_index)
|
|
.ok_or_else(|| "nebula material animation binding is unavailable".to_string())?;
|
|
nebula_binding.detail = Some(stars_binding);
|
|
nebula_binding.stage_textures = Some(MaterialStageTextures {
|
|
detail: Some(stars_stage_textures),
|
|
overlay: None,
|
|
overlay_detail: None,
|
|
// Force TEX1 to the independently sampled stars phase on the first
|
|
// frame, including when the initial star descriptor is already set.
|
|
applied_material_phases: HashMap::new(),
|
|
});
|
|
let lightning_material_index = append_environment_material(
|
|
materials,
|
|
&mut names,
|
|
material_assets,
|
|
&mut material_animations,
|
|
"env_lightning",
|
|
3,
|
|
)?;
|
|
|
|
let mut scene = Self {
|
|
material_animations,
|
|
sky_layers: Vec::with_capacity(4),
|
|
sprites: Vec::with_capacity(ENVIRONMENT_SPRITE_ROWS.len() + ENVIRONMENT_FLARE_SLOTS),
|
|
rain: EnvironmentGpuRange {
|
|
range_index: 0,
|
|
vertex_start: 0,
|
|
vertex_capacity: 0,
|
|
material_index: material_indices[8].unwrap_or(0),
|
|
},
|
|
snow: EnvironmentGpuRange {
|
|
range_index: 0,
|
|
vertex_start: 0,
|
|
vertex_capacity: 0,
|
|
material_index: material_indices[7].unwrap_or(0),
|
|
},
|
|
lightning: EnvironmentGpuRange {
|
|
range_index: 0,
|
|
vertex_start: 0,
|
|
vertex_capacity: 0,
|
|
material_index: lightning_material_index,
|
|
},
|
|
lightning_material_index,
|
|
};
|
|
|
|
let screen_range = append_environment_range(
|
|
mesh,
|
|
gradient_material_index,
|
|
screen_gradient_quad_vertices(camera, [16.0, 9.0], [1.0; 3]).to_vec(),
|
|
quad_indices(1)?,
|
|
world_pipeline_state(LegacyBlendMode::Opaque, LegacyDepthMode::Disabled),
|
|
)?;
|
|
scene.sky_layers.push(EnvironmentSkyLayer {
|
|
kind: SkyPassKind::ScreenGradient,
|
|
stage: None,
|
|
secondary_stage: None,
|
|
material_index: gradient_material_index,
|
|
diffuse_alpha: 1.0,
|
|
static_directional_rgb: [1.0; 3],
|
|
static_additive_rgb: [0.0; 3],
|
|
translation_z: 0.0,
|
|
range: screen_range,
|
|
});
|
|
|
|
let nebula_material = materials
|
|
.get(nebula_material_index)
|
|
.ok_or_else(|| "nebula material descriptor is unavailable".to_string())?;
|
|
let nebula_vertices = sky_mesh
|
|
.vertices
|
|
.iter()
|
|
.map(|vertex| {
|
|
let color = vertex.color_rgba();
|
|
environment_vertex_with_alpha_normal(
|
|
add3(vertex.position, camera.position),
|
|
[color[0], color[1], color[2]],
|
|
color[3],
|
|
vertex.uv[0],
|
|
sky_normal(vertex.normal),
|
|
)
|
|
})
|
|
.collect();
|
|
let nebula_range = append_environment_range(
|
|
mesh,
|
|
nebula_material_index,
|
|
nebula_vertices,
|
|
sky_mesh.indices.clone(),
|
|
world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly),
|
|
)?;
|
|
register_material_range(
|
|
&mut scene.material_animations,
|
|
nebula_material_index,
|
|
nebula_range.range_index,
|
|
);
|
|
scene.sky_layers.push(EnvironmentSkyLayer {
|
|
kind: SkyPassKind::NebulaStars,
|
|
stage: Some(0),
|
|
secondary_stage: Some(1),
|
|
material_index: nebula_material_index,
|
|
diffuse_alpha: nebula_material.diffuse_alpha,
|
|
static_directional_rgb: nebula_material.directional_rgb,
|
|
static_additive_rgb: nebula_material.additive_rgb,
|
|
translation_z: 0.0,
|
|
range: nebula_range,
|
|
});
|
|
|
|
let dome_vertices = sky_mesh
|
|
.vertices
|
|
.iter()
|
|
.map(|vertex| {
|
|
let color = vertex.color_rgba();
|
|
environment_vertex_with_alpha_normal(
|
|
add3(vertex.position, camera.position),
|
|
[color[0], color[1], color[2]],
|
|
color[3],
|
|
[0.0, 0.0],
|
|
sky_normal(vertex.normal),
|
|
)
|
|
})
|
|
.collect();
|
|
let dome_range = append_environment_range(
|
|
mesh,
|
|
gradient_material_index,
|
|
dome_vertices,
|
|
sky_mesh.indices.clone(),
|
|
world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly),
|
|
)?;
|
|
scene.sky_layers.push(EnvironmentSkyLayer {
|
|
kind: SkyPassKind::DomeGradient,
|
|
stage: None,
|
|
secondary_stage: None,
|
|
material_index: gradient_material_index,
|
|
diffuse_alpha: 1.0,
|
|
static_directional_rgb: [1.0; 3],
|
|
static_additive_rgb: [0.0; 3],
|
|
translation_z: 0.0,
|
|
range: dome_range,
|
|
});
|
|
|
|
let clouds_material_index = material_indices[2]
|
|
.ok_or_else(|| "cloud material selector is unavailable".to_string())?;
|
|
let clouds_material = materials
|
|
.get(clouds_material_index)
|
|
.ok_or_else(|| "cloud material descriptor is unavailable".to_string())?;
|
|
let clouds_vertices = sky_mesh
|
|
.vertices
|
|
.iter()
|
|
.map(|vertex| {
|
|
let color = vertex.color_rgba();
|
|
environment_vertex_with_alpha_normal(
|
|
add3(add3(vertex.position, camera.position), [0.0, 0.0, -5000.0]),
|
|
[color[0], color[1], color[2]],
|
|
color[3],
|
|
vertex.uv[2],
|
|
sky_normal(vertex.normal),
|
|
)
|
|
})
|
|
.collect();
|
|
let clouds_range = append_environment_range(
|
|
mesh,
|
|
clouds_material_index,
|
|
clouds_vertices,
|
|
sky_mesh.indices.clone(),
|
|
world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly),
|
|
)?;
|
|
register_material_range(
|
|
&mut scene.material_animations,
|
|
clouds_material_index,
|
|
clouds_range.range_index,
|
|
);
|
|
scene.sky_layers.push(EnvironmentSkyLayer {
|
|
kind: SkyPassKind::Clouds,
|
|
stage: Some(2),
|
|
secondary_stage: None,
|
|
material_index: clouds_material_index,
|
|
diffuse_alpha: clouds_material.diffuse_alpha,
|
|
static_directional_rgb: clouds_material.directional_rgb,
|
|
static_additive_rgb: clouds_material.additive_rgb,
|
|
translation_z: -5000.0,
|
|
range: clouds_range,
|
|
});
|
|
|
|
let sprite_size = (camera.far_plane * 0.01).max(1.0);
|
|
for row in ENVIRONMENT_SPRITE_ROWS {
|
|
let material_index = material_indices[row]
|
|
.ok_or_else(|| format!("sky sprite row {row} has no material selector"))?;
|
|
let range = append_environment_range(
|
|
mesh,
|
|
material_index,
|
|
placeholder_quad_vertices(camera, 1, sprite_size),
|
|
quad_indices(1)?,
|
|
world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly),
|
|
)?;
|
|
register_material_range(
|
|
&mut scene.material_animations,
|
|
material_index,
|
|
range.range_index,
|
|
);
|
|
scene.sprites.push(EnvironmentSpriteLayer {
|
|
row,
|
|
material_index,
|
|
diffuse_alpha: materials
|
|
.get(material_index)
|
|
.map_or(1.0, |material| material.diffuse_alpha),
|
|
flare_index: None,
|
|
range,
|
|
});
|
|
}
|
|
|
|
for flare_index in 0..ENVIRONMENT_FLARE_SLOTS {
|
|
let row = SKY_FLARE_ROWS[flare_index];
|
|
let material_index = material_indices[row]
|
|
.ok_or_else(|| format!("sky flare row {row} has no material selector"))?;
|
|
let range = append_environment_range(
|
|
mesh,
|
|
material_index,
|
|
placeholder_quad_vertices(camera, 1, sprite_size),
|
|
quad_indices(1)?,
|
|
world_pipeline_state(LegacyBlendMode::Additive, LegacyDepthMode::TestReadOnly),
|
|
)?;
|
|
register_material_range(
|
|
&mut scene.material_animations,
|
|
material_index,
|
|
range.range_index,
|
|
);
|
|
scene.sprites.push(EnvironmentSpriteLayer {
|
|
row,
|
|
material_index,
|
|
diffuse_alpha: materials
|
|
.get(material_index)
|
|
.map_or(1.0, |material| material.diffuse_alpha),
|
|
flare_index: Some(flare_index),
|
|
range,
|
|
});
|
|
}
|
|
|
|
let particle_indices = quad_indices(ENVIRONMENT_PARTICLE_SLOTS)?;
|
|
scene.rain = append_environment_range(
|
|
mesh,
|
|
material_indices[8]
|
|
.ok_or_else(|| "rain material selector is unavailable".to_string())?,
|
|
placeholder_quad_vertices(camera, ENVIRONMENT_PARTICLE_SLOTS, 0.25),
|
|
particle_indices.clone(),
|
|
world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly),
|
|
)?;
|
|
register_material_range(
|
|
&mut scene.material_animations,
|
|
scene.rain.material_index,
|
|
scene.rain.range_index,
|
|
);
|
|
scene.snow = append_environment_range(
|
|
mesh,
|
|
material_indices[7]
|
|
.ok_or_else(|| "snow material selector is unavailable".to_string())?,
|
|
placeholder_quad_vertices(camera, ENVIRONMENT_PARTICLE_SLOTS, 0.25),
|
|
particle_indices,
|
|
world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly),
|
|
)?;
|
|
register_material_range(
|
|
&mut scene.material_animations,
|
|
scene.snow.material_index,
|
|
scene.snow.range_index,
|
|
);
|
|
scene.lightning = append_environment_range(
|
|
mesh,
|
|
lightning_material_index,
|
|
placeholder_quad_vertices(camera, 1, sprite_size),
|
|
quad_indices(1)?,
|
|
world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly),
|
|
)?;
|
|
register_material_range(
|
|
&mut scene.material_animations,
|
|
scene.lightning.material_index,
|
|
scene.lightning.range_index,
|
|
);
|
|
|
|
// Preserve the native sky order while keeping the indexed mesh
|
|
// contiguous. The screen gradient must be the first draw, and the
|
|
// cloud dome follows all directional sprites. Reordering the range
|
|
// metadata alone would leave first_index values pointing into the old
|
|
// segment order, so move each index segment together with its range.
|
|
let range_for_kind = |kind: SkyPassKind| {
|
|
scene
|
|
.sky_layers
|
|
.iter()
|
|
.find(|layer| layer.kind == kind)
|
|
.map(|layer| layer.range.range_index)
|
|
};
|
|
let screen_index = range_for_kind(SkyPassKind::ScreenGradient)
|
|
.ok_or_else(|| "screen gradient range was not allocated".to_string())?;
|
|
let nebula_index = range_for_kind(SkyPassKind::NebulaStars)
|
|
.ok_or_else(|| "nebula sky range was not allocated".to_string())?;
|
|
let dome_index = range_for_kind(SkyPassKind::DomeGradient)
|
|
.ok_or_else(|| "dome gradient range was not allocated".to_string())?;
|
|
let cloud_index = range_for_kind(SkyPassKind::Clouds)
|
|
.ok_or_else(|| "cloud sky range was not allocated".to_string())?;
|
|
let sprite_indices = scene
|
|
.sprites
|
|
.iter()
|
|
.map(|sprite| sprite.range.range_index)
|
|
.collect::<Vec<_>>();
|
|
if sprite_indices.is_empty() {
|
|
return Err("sky sprite ranges were not allocated".to_string());
|
|
}
|
|
let rain_index = scene.rain.range_index;
|
|
let snow_index = scene.snow.range_index;
|
|
let lightning_index = scene.lightning.range_index;
|
|
let environment_indices = [
|
|
screen_index,
|
|
nebula_index,
|
|
dome_index,
|
|
cloud_index,
|
|
rain_index,
|
|
snow_index,
|
|
lightning_index,
|
|
*shadow_range_index,
|
|
]
|
|
.into_iter()
|
|
.chain(sprite_indices.iter().copied())
|
|
.collect::<HashSet<_>>();
|
|
let old_range_count = mesh.draw_ranges.len();
|
|
let mut old_order = Vec::with_capacity(old_range_count);
|
|
old_order.push(screen_index);
|
|
old_order.extend((0..old_range_count).filter(|index| {
|
|
!environment_indices.contains(index)
|
|
&& *index != screen_index
|
|
&& *index != nebula_index
|
|
&& *index != dome_index
|
|
&& *index != cloud_index
|
|
}));
|
|
old_order.push(*shadow_range_index);
|
|
old_order.extend([nebula_index, dome_index, cloud_index]);
|
|
old_order.extend(sprite_indices.iter().copied());
|
|
old_order.extend([rain_index, snow_index, lightning_index]);
|
|
let remap = reorder_draw_ranges(mesh, &old_order)?;
|
|
let mut remapped_handedness = HashMap::with_capacity(native_handedness_by_range.len());
|
|
for (old_range_index, handedness) in std::mem::take(native_handedness_by_range) {
|
|
let new_range_index = remap.get(old_range_index).copied().ok_or_else(|| {
|
|
format!("native MSH draw range remap is missing range {old_range_index}")
|
|
})?;
|
|
remapped_handedness.insert(new_range_index, handedness);
|
|
}
|
|
*native_handedness_by_range = remapped_handedness;
|
|
let mut remapped_type16_present_ranges =
|
|
HashSet::with_capacity(type16_present_ranges.len());
|
|
for old_range_index in std::mem::take(type16_present_ranges) {
|
|
let new_range_index = remap.get(old_range_index).copied().ok_or_else(|| {
|
|
format!("type-16 MSH draw range remap is missing range {old_range_index}")
|
|
})?;
|
|
remapped_type16_present_ranges.insert(new_range_index);
|
|
}
|
|
*type16_present_ranges = remapped_type16_present_ranges;
|
|
remap_dynamic_shadow_caster_draw_ranges(dynamic_shadow_casters, &remap)?;
|
|
for layer in &mut scene.sky_layers {
|
|
layer.range.range_index = *remap
|
|
.get(layer.range.range_index)
|
|
.ok_or_else(|| "sky draw range remap is out of bounds".to_string())?;
|
|
}
|
|
for sprite in &mut scene.sprites {
|
|
sprite.range.range_index = *remap
|
|
.get(sprite.range.range_index)
|
|
.ok_or_else(|| "sky sprite draw range remap is out of bounds".to_string())?;
|
|
}
|
|
scene.rain.range_index = *remap
|
|
.get(scene.rain.range_index)
|
|
.ok_or_else(|| "rain draw range remap is out of bounds".to_string())?;
|
|
scene.snow.range_index = *remap
|
|
.get(scene.snow.range_index)
|
|
.ok_or_else(|| "snow draw range remap is out of bounds".to_string())?;
|
|
scene.lightning.range_index = *remap
|
|
.get(scene.lightning.range_index)
|
|
.ok_or_else(|| "lightning draw range remap is out of bounds".to_string())?;
|
|
remap_material_animation_ranges(&mut scene.material_animations, &remap)?;
|
|
remap_material_animation_ranges(preview_material_animations, &remap)?;
|
|
for slot in ambient_effect_slots {
|
|
slot.range.range_index = *remap
|
|
.get(slot.range.range_index)
|
|
.ok_or_else(|| "ambient FX draw range remap is out of bounds".to_string())?;
|
|
for range in &mut slot.additional_particle_ranges {
|
|
range.range_index = *remap.get(range.range_index).ok_or_else(|| {
|
|
"ambient FX particle draw range remap is out of bounds".to_string()
|
|
})?;
|
|
}
|
|
}
|
|
for range_index in sun_occlusion_range_indices {
|
|
*range_index = *remap
|
|
.get(*range_index)
|
|
.ok_or_else(|| "sun occlusion range remap is out of bounds".to_string())?;
|
|
}
|
|
*shadow_range_index = *remap
|
|
.get(*shadow_range_index)
|
|
.ok_or_else(|| "shadow draw range remap is out of bounds".to_string())?;
|
|
Ok(scene)
|
|
}
|
|
|
|
fn update(
|
|
&mut self,
|
|
mesh: &mut VulkanStaticMesh,
|
|
renderer: &mut VulkanSmokeRenderer,
|
|
material_update: &MaterialFrameUpdate,
|
|
sky_frame: &SkyFrame,
|
|
sky_mesh: &SkyMesh,
|
|
environment_frame: &EnvironmentFrame,
|
|
camera: &PreviewCameraFrame,
|
|
viewport: [f32; 2],
|
|
terrain: &TerrainWorld,
|
|
sun_occlusion_range_indices: &[usize],
|
|
) -> Result<(), String> {
|
|
let active_materials = &material_update.active_materials;
|
|
// Resolve the native glare once for the frame. CSky feeds the
|
|
// boosted primary color back into the light manager and uses the
|
|
// positive boost delta for both the screen-gradient horizon floor and
|
|
// the dome palette clamp.
|
|
let projected_sun =
|
|
project_direction_pixels(camera, sky_frame.sun.geometry_direction(), viewport)
|
|
.unwrap_or([f32::NAN; 2]);
|
|
let optics = sky_frame
|
|
.sun_optics(SunVisibility {
|
|
projected_sun,
|
|
viewport,
|
|
view_forward: camera.forward(),
|
|
// Flares use the same terrain/object ray query as the native
|
|
// visibility gate. The sun sprite itself deliberately stays
|
|
// independent of this test, matching the native layer's
|
|
// `requires_occlusion` split.
|
|
unoccluded: native_sun_unoccluded_with_world(
|
|
terrain,
|
|
mesh,
|
|
sun_occlusion_range_indices,
|
|
camera,
|
|
sky_frame.sun.geometry_direction(),
|
|
),
|
|
})
|
|
.ok();
|
|
let glare_rgb_delta = optics.map_or([0.0; 3], |optics| {
|
|
std::array::from_fn(|index| {
|
|
(optics.primary_color[index] - sky_frame.sun.color[index]).max(0.0)
|
|
})
|
|
});
|
|
let gradient = sky_frame.screen_gradient(camera.native_sky_heading(), glare_rgb_delta);
|
|
if let Some(optics) = optics {
|
|
let mut uniforms = renderer.frame_uniforms();
|
|
if sky_frame.sun.active {
|
|
uniforms.directional_lights[0].rgb = optics.primary_color;
|
|
}
|
|
renderer
|
|
.set_frame_uniforms(uniforms)
|
|
.map_err(|error| format!("update glare light uniforms: {error}"))?;
|
|
}
|
|
let gradient_rgba = gradient.color.rgba();
|
|
for layer in &self.sky_layers {
|
|
let range = layer.range;
|
|
let destination = mesh
|
|
.vertices
|
|
.get_mut(range.vertex_start..range.vertex_start + range.vertex_capacity)
|
|
.ok_or_else(|| "sky vertex allocation is out of bounds".to_string())?;
|
|
let pass_frame = sky_frame
|
|
.passes
|
|
.iter()
|
|
.find(|pass| pass.kind == layer.kind)
|
|
.ok_or_else(|| format!("sky frame is missing {:?} pass", layer.kind))?;
|
|
match layer.kind {
|
|
SkyPassKind::ScreenGradient => {
|
|
if destination.len() != 4 {
|
|
return Err("screen gradient vertex allocation changed".to_string());
|
|
}
|
|
destination.copy_from_slice(&screen_gradient_quad_vertices(
|
|
camera,
|
|
viewport,
|
|
[gradient_rgba[0], gradient_rgba[1], gradient_rgba[2]],
|
|
));
|
|
}
|
|
SkyPassKind::NebulaStars | SkyPassKind::DomeGradient | SkyPassKind::Clouds => {
|
|
if destination.len() != sky_mesh.vertices.len() {
|
|
return Err(
|
|
"sky mesh vertex count changed after GPU allocation".to_string()
|
|
);
|
|
}
|
|
for (target, source) in destination.iter_mut().zip(&sky_mesh.vertices) {
|
|
let source_color = match layer.kind {
|
|
// Native mode 4 receives a white DIFFUSE and
|
|
// chooses nebula/stars through TEX1 alpha.
|
|
SkyPassKind::NebulaStars | SkyPassKind::Clouds => [1.0; 4],
|
|
SkyPassKind::DomeGradient => {
|
|
gradient.clamp_dome_color(source.color).rgba()
|
|
}
|
|
SkyPassKind::ScreenGradient => unreachable!(),
|
|
};
|
|
target.position = add3(
|
|
add3(source.position, camera.position),
|
|
[0.0, 0.0, layer.translation_z],
|
|
);
|
|
target.color = [source_color[0], source_color[1], source_color[2]];
|
|
target.uv = layer
|
|
.stage
|
|
.and_then(|stage| source.uv.get(stage).copied())
|
|
.unwrap_or([0.0, 0.0]);
|
|
target.detail_uv = layer
|
|
.secondary_stage
|
|
.and_then(|stage| source.uv.get(stage).copied())
|
|
.unwrap_or([0.0, 0.0]);
|
|
target.normal = sky_normal(source.normal);
|
|
target.overlay_alpha = source_color[3];
|
|
}
|
|
}
|
|
}
|
|
let material_index = active_materials
|
|
.get(&range.range_index)
|
|
.map(|state| state.material_index)
|
|
.unwrap_or(layer.material_index);
|
|
if !active_materials.contains_key(&range.range_index) {
|
|
renderer
|
|
.set_material_alphas(material_index, layer.diffuse_alpha.clamp(0.0, 1.0), 0.0)
|
|
.map_err(|error| format!("update sky material alpha: {error}"))?;
|
|
}
|
|
if layer.kind == SkyPassKind::Clouds {
|
|
let directional_rgb = pass_frame
|
|
.material_color
|
|
.unwrap_or(layer.static_directional_rgb);
|
|
renderer
|
|
.set_material_lighting(
|
|
material_index,
|
|
directional_rgb,
|
|
layer.static_additive_rgb,
|
|
)
|
|
.map_err(|error| format!("update cloud material lighting: {error}"))?;
|
|
}
|
|
renderer
|
|
.set_draw_range_index_count(
|
|
range.range_index,
|
|
if layer.kind == SkyPassKind::ScreenGradient {
|
|
6
|
|
} else {
|
|
u32::try_from(sky_mesh.indices.len())
|
|
.map_err(|_| "sky index count exceeds u32".to_string())?
|
|
},
|
|
)
|
|
.map_err(|error| format!("update sky draw range: {error}"))?;
|
|
}
|
|
|
|
// Flares share their source material row, so their packed alpha lives
|
|
// in each vertex. Upload the shared phase alpha once before emitting
|
|
// the twelve fixed-capacity ranges.
|
|
let mut flare_materials = HashMap::new();
|
|
for sprite in &self.sprites {
|
|
if sprite.flare_index.is_some()
|
|
&& !active_materials.contains_key(&sprite.range.range_index)
|
|
{
|
|
flare_materials.insert(sprite.material_index, sprite.diffuse_alpha);
|
|
}
|
|
}
|
|
for (material_index, diffuse_alpha) in flare_materials {
|
|
renderer
|
|
.set_material_alphas(material_index, diffuse_alpha.clamp(0.0, 1.0), 0.0)
|
|
.map_err(|error| format!("update sky flare alpha: {error}"))?;
|
|
}
|
|
for sprite in &self.sprites {
|
|
let mut active = false;
|
|
let mut vertices = None;
|
|
let mut material_alpha = 0.0;
|
|
if let Some(flare_index) = sprite.flare_index {
|
|
if let Some(flare) =
|
|
optics.and_then(|optics| optics.flares.get(flare_index).copied())
|
|
{
|
|
let direction = screen_ray_from_pixels(camera, flare.center_pixels, viewport);
|
|
active = flare.active && direction.is_some() && flare.color[3] > 0.0;
|
|
if let Some(direction) = direction {
|
|
vertices = Some(sprite_quad_vertices_pixels(
|
|
camera,
|
|
direction,
|
|
(camera.far_plane * 0.5).max(camera.near_plane * 2.0),
|
|
flare.half_size_pixels,
|
|
flare.half_size_pixels,
|
|
viewport,
|
|
[flare.color[0], flare.color[1], flare.color[2]],
|
|
flare.color[3],
|
|
flare.uv,
|
|
));
|
|
}
|
|
}
|
|
} else if let Some(frame) = sprite_frame_for_row(sky_frame, sprite.row) {
|
|
let projected = project_direction_pixels(camera, frame.direction, viewport);
|
|
active = frame.active
|
|
&& frame.intensity > 0.0
|
|
&& projected.is_some_and(|center| {
|
|
center[0] >= 0.0
|
|
&& center[0] <= viewport[0]
|
|
&& center[1] >= 0.0
|
|
&& center[1] <= viewport[1]
|
|
});
|
|
if active {
|
|
if let Ok([half_width, half_height]) = sprite_half_size_pixels(
|
|
frame.size_values,
|
|
viewport,
|
|
horizontal_fov(camera, viewport[0] / viewport[1].max(1.0)),
|
|
) {
|
|
if ENVIRONMENT_SPRITE_ROWS.contains(&sprite.row) {
|
|
if let Some(center_pixels) = projected {
|
|
vertices = Some(screen_tl_quad_vertices_pixels(
|
|
center_pixels,
|
|
half_width,
|
|
half_height,
|
|
viewport,
|
|
frame.color,
|
|
frame.alpha,
|
|
frame.uv,
|
|
));
|
|
} else {
|
|
active = false;
|
|
}
|
|
} else {
|
|
vertices = Some(sprite_quad_vertices_pixels(
|
|
camera,
|
|
frame.direction,
|
|
(camera.far_plane * 0.5).max(camera.near_plane * 2.0),
|
|
half_width,
|
|
half_height,
|
|
viewport,
|
|
frame.color,
|
|
frame.alpha,
|
|
frame.uv,
|
|
));
|
|
}
|
|
} else {
|
|
active = false;
|
|
}
|
|
}
|
|
material_alpha = if active {
|
|
// The packed sprite alpha is carried by the vertex. The
|
|
// MAT0 phase alpha remains the material multiplier;
|
|
// intensity controls lifecycle visibility, not a second
|
|
// alpha fade.
|
|
sprite.diffuse_alpha
|
|
} else {
|
|
0.0
|
|
};
|
|
}
|
|
if active {
|
|
if let Some(vertices) = vertices {
|
|
let destination = mesh
|
|
.vertices
|
|
.get_mut(sprite.range.vertex_start..sprite.range.vertex_start + 4)
|
|
.ok_or_else(|| {
|
|
"sky sprite vertex allocation is out of bounds".to_string()
|
|
})?;
|
|
destination.copy_from_slice(&vertices);
|
|
} else {
|
|
active = false;
|
|
}
|
|
}
|
|
if sprite.flare_index.is_none() {
|
|
let material_index = active_materials
|
|
.get(&sprite.range.range_index)
|
|
.map(|state| state.material_index)
|
|
.unwrap_or(sprite.material_index);
|
|
// The animation update above has already restored the
|
|
// sampled phase alpha for an active animated material. A
|
|
// hidden sprite still needs an explicit zero, while a static
|
|
// sprite uses its authored material alpha when visible.
|
|
let should_write_alpha =
|
|
!active || !active_materials.contains_key(&sprite.range.range_index);
|
|
if should_write_alpha {
|
|
renderer
|
|
.set_material_alphas(material_index, material_alpha.clamp(0.0, 1.0), 0.0)
|
|
.map_err(|error| format!("update sky sprite alpha: {error}"))?;
|
|
}
|
|
}
|
|
renderer
|
|
.set_draw_range_index_count(sprite.range.range_index, if active { 6 } else { 0 })
|
|
.map_err(|error| format!("update sky sprite draw range: {error}"))?;
|
|
}
|
|
|
|
let mut rain_count = 0usize;
|
|
let mut snow_count = 0usize;
|
|
let mut lightning = None;
|
|
for primitive in &environment_frame.primitives {
|
|
match primitive {
|
|
EnvironmentPrimitive::Particle {
|
|
kind,
|
|
color,
|
|
screen,
|
|
..
|
|
} => {
|
|
let (range, slot) = match kind {
|
|
PrecipitationKind::Rain if rain_count < ENVIRONMENT_PARTICLE_SLOTS => {
|
|
let slot = rain_count;
|
|
rain_count += 1;
|
|
(self.rain, slot)
|
|
}
|
|
PrecipitationKind::Snow if snow_count < ENVIRONMENT_PARTICLE_SLOTS => {
|
|
let slot = snow_count;
|
|
snow_count += 1;
|
|
(self.snow, slot)
|
|
}
|
|
_ => continue,
|
|
};
|
|
let vertices =
|
|
precipitation_quad_vertices(*kind, screen, *color, camera, viewport);
|
|
let start = range.vertex_start + slot * 4;
|
|
let destination = mesh.vertices.get_mut(start..start + 4).ok_or_else(|| {
|
|
"precipitation vertex allocation is out of bounds".to_string()
|
|
})?;
|
|
destination.copy_from_slice(&vertices);
|
|
}
|
|
EnvironmentPrimitive::Lightning(bolt) if lightning.is_none() => {
|
|
lightning = Some(bolt);
|
|
}
|
|
EnvironmentPrimitive::Lightning(_) => {}
|
|
}
|
|
}
|
|
renderer
|
|
.set_draw_range_index_count(
|
|
self.rain.range_index,
|
|
u32::try_from(rain_count * 6)
|
|
.map_err(|_| "rain draw count exceeds u32".to_string())?,
|
|
)
|
|
.map_err(|error| format!("update rain draw range: {error}"))?;
|
|
renderer
|
|
.set_draw_range_index_count(
|
|
self.snow.range_index,
|
|
u32::try_from(snow_count * 6)
|
|
.map_err(|_| "snow draw count exceeds u32".to_string())?,
|
|
)
|
|
.map_err(|error| format!("update snow draw range: {error}"))?;
|
|
|
|
let lightning_alpha = if let Some(bolt) = lightning {
|
|
let vertices = lightning_quad_vertices(bolt);
|
|
let destination = mesh
|
|
.vertices
|
|
.get_mut(self.lightning.vertex_start..self.lightning.vertex_start + 4)
|
|
.ok_or_else(|| "lightning vertex allocation is out of bounds".to_string())?;
|
|
destination.copy_from_slice(&vertices);
|
|
bolt.intensity.clamp(0.0, 1.0)
|
|
} else {
|
|
0.0
|
|
};
|
|
let lightning_material_index = active_materials
|
|
.get(&self.lightning.range_index)
|
|
.map(|state| state.material_index)
|
|
.unwrap_or(self.lightning_material_index);
|
|
renderer
|
|
.set_material_alphas(lightning_material_index, lightning_alpha, 0.0)
|
|
.map_err(|error| format!("update lightning material alpha: {error}"))?;
|
|
renderer
|
|
.set_draw_range_index_count(
|
|
self.lightning.range_index,
|
|
if lightning.is_some() { 6 } else { 0 },
|
|
)
|
|
.map_err(|error| format!("update lightning draw range: {error}"))?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn sprite_frame_for_row<'frame, 'materials>(
|
|
sky_frame: &'frame SkyFrame<'materials>,
|
|
row: usize,
|
|
) -> Option<SkySpriteFrame<'materials>> {
|
|
sky_frame
|
|
.sprites
|
|
.iter()
|
|
.copied()
|
|
.find(|sprite| sprite.row == row)
|
|
}
|
|
|
|
fn horizontal_fov(camera: &PreviewCameraFrame, aspect: f32) -> f32 {
|
|
let aspect = if aspect.is_finite() && aspect > 0.0 {
|
|
aspect
|
|
} else {
|
|
16.0 / 9.0
|
|
};
|
|
2.0 * ((camera.vertical_fov * 0.5).tan() * aspect).atan()
|
|
}
|
|
|
|
/// Tests the camera-to-sun segment against the validated mission terrain.
|
|
///
|
|
/// The native CSun caller starts a half-unit in front of the camera along the
|
|
/// normalized geometry direction, then queries the finite segment ending at
|
|
/// the sun center. Terrain source positions and captured-camera positions both
|
|
/// use the `Land.msh` coordinate units, so the ray is queried without a Z
|
|
/// conversion. Its parameter remains normalized to the finite segment and is
|
|
/// therefore independent of world distance.
|
|
#[cfg(test)]
|
|
fn native_sun_unoccluded(
|
|
terrain: &TerrainWorld,
|
|
camera: &PreviewCameraFrame,
|
|
direction: [f32; 3],
|
|
) -> bool {
|
|
let empty_mesh = VulkanStaticMesh {
|
|
vertices: Vec::new(),
|
|
indices: Vec::new(),
|
|
draw_ranges: Vec::new(),
|
|
};
|
|
native_sun_unoccluded_with_world(terrain, &empty_mesh, &[], camera, direction)
|
|
}
|
|
|
|
fn native_sun_unoccluded_with_world(
|
|
terrain: &TerrainWorld,
|
|
world_mesh: &VulkanStaticMesh,
|
|
sun_occlusion_range_indices: &[usize],
|
|
camera: &PreviewCameraFrame,
|
|
direction: [f32; 3],
|
|
) -> bool {
|
|
// Native CSun requests object kinds 1, 3, 4, and 10 (the aggregate
|
|
// object mask is 0x41A) and excludes full surface flag 0x20. The preview
|
|
// range list is built from roots whose raw mission kind is one of those
|
|
// four eligible kinds; fixed atmosphere ranges are remapped out before
|
|
// this call.
|
|
let Some(direction) = normalize3(direction) else {
|
|
return false;
|
|
};
|
|
let sun_center = add3(
|
|
camera.position,
|
|
scale3(
|
|
direction,
|
|
(camera.far_plane * 0.5).max(camera.near_plane * 2.0),
|
|
),
|
|
);
|
|
let start = add3(camera.position, scale3(direction, 0.5));
|
|
let segment = sub3(sun_center, start);
|
|
if !sun_center
|
|
.iter()
|
|
.chain(start.iter())
|
|
.chain(segment.iter())
|
|
.all(|value| value.is_finite())
|
|
{
|
|
return false;
|
|
}
|
|
if dot3(segment, segment) <= f32::EPSILON {
|
|
return true;
|
|
}
|
|
if let Ok(Some(hit)) = terrain.raycast_excluding(
|
|
start,
|
|
segment,
|
|
FullSurfaceMask(0),
|
|
FullSurfaceMask(0x0000_0020),
|
|
) {
|
|
if hit.distance.is_finite() && hit.distance <= 1.0 {
|
|
return false;
|
|
}
|
|
}
|
|
!world_mesh_sun_occluded(world_mesh, sun_occlusion_range_indices, start, segment)
|
|
}
|
|
|
|
fn world_mesh_sun_occluded(
|
|
mesh: &VulkanStaticMesh,
|
|
range_indices: &[usize],
|
|
origin: [f32; 3],
|
|
direction: [f32; 3],
|
|
) -> bool {
|
|
for &range_index in range_indices {
|
|
let Some(range) = mesh.draw_ranges.get(range_index).copied() else {
|
|
continue;
|
|
};
|
|
// The native world query receives the compact exclusion bit 0x8,
|
|
// which is the world-space representation of full surface flag 0x20.
|
|
if range.batch_flags & 0x0008 != 0 {
|
|
continue;
|
|
}
|
|
let Ok(first_index) = usize::try_from(range.first_index) else {
|
|
continue;
|
|
};
|
|
let Ok(index_count) = usize::try_from(range.index_count) else {
|
|
continue;
|
|
};
|
|
let Some(indices) = mesh
|
|
.indices
|
|
.get(first_index..first_index.saturating_add(index_count))
|
|
else {
|
|
continue;
|
|
};
|
|
for triangle in indices.chunks_exact(3) {
|
|
let Ok(a) = usize::try_from(triangle[0]) else {
|
|
continue;
|
|
};
|
|
let Ok(b) = usize::try_from(triangle[1]) else {
|
|
continue;
|
|
};
|
|
let Ok(c) = usize::try_from(triangle[2]) else {
|
|
continue;
|
|
};
|
|
let (Some(&a), Some(&b), Some(&c)) = (
|
|
mesh.vertices.get(a),
|
|
mesh.vertices.get(b),
|
|
mesh.vertices.get(c),
|
|
) else {
|
|
continue;
|
|
};
|
|
if segment_intersects_triangle(origin, direction, a.position, b.position, c.position) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
fn segment_intersects_triangle(
|
|
origin: [f32; 3],
|
|
direction: [f32; 3],
|
|
a: [f32; 3],
|
|
b: [f32; 3],
|
|
c: [f32; 3],
|
|
) -> bool {
|
|
let edge1 = sub3(b, a);
|
|
let edge2 = sub3(c, a);
|
|
let pvec = cross3(direction, edge2);
|
|
let determinant = dot3(edge1, pvec);
|
|
if !determinant.is_finite() || determinant.abs() <= f32::EPSILON {
|
|
return false;
|
|
}
|
|
let inverse = determinant.recip();
|
|
let tvec = sub3(origin, a);
|
|
let u = dot3(tvec, pvec) * inverse;
|
|
if !u.is_finite() || !(0.0..=1.0).contains(&u) {
|
|
return false;
|
|
}
|
|
let qvec = cross3(tvec, edge1);
|
|
let v = dot3(direction, qvec) * inverse;
|
|
if !v.is_finite() || v < 0.0 || u + v > 1.0 {
|
|
return false;
|
|
}
|
|
let distance = dot3(edge2, qvec) * inverse;
|
|
distance.is_finite() && (0.0..=1.0).contains(&distance)
|
|
}
|
|
|
|
/// Replays Terrain's native packet callback: a draw range is keyed by the
|
|
/// camera distance to its first indexed vertex.
|
|
fn first_indexed_vertex_distance(
|
|
mesh: &VulkanStaticMesh,
|
|
range_index: usize,
|
|
camera_position: [f32; 3],
|
|
) -> Result<f32, String> {
|
|
let range: VulkanStaticDrawRange = *mesh
|
|
.draw_ranges
|
|
.get(range_index)
|
|
.ok_or_else(|| "world draw range index is out of bounds".to_string())?;
|
|
let first_index = usize::try_from(range.first_index)
|
|
.map_err(|_| "world draw range first index is not addressable".to_string())?;
|
|
let vertex_index =
|
|
usize::try_from(*mesh.indices.get(first_index).ok_or_else(|| {
|
|
"world draw range first index is outside the index buffer".to_string()
|
|
})?)
|
|
.map_err(|_| "world draw range vertex index is not addressable".to_string())?;
|
|
let vertex = mesh
|
|
.vertices
|
|
.get(vertex_index)
|
|
.ok_or_else(|| "world draw range vertex index is outside the vertex buffer".to_string())?;
|
|
let delta = sub3(vertex.position, camera_position);
|
|
let distance = dot3(delta, delta).sqrt();
|
|
distance
|
|
.is_finite()
|
|
.then_some(distance)
|
|
.ok_or_else(|| "world draw range sort distance is non-finite".to_string())
|
|
}
|
|
|
|
fn project_direction_pixels(
|
|
camera: &PreviewCameraFrame,
|
|
direction: [f32; 3],
|
|
viewport: [f32; 2],
|
|
) -> Option<[f32; 2]> {
|
|
let direction = normalize3(direction)?;
|
|
let width = viewport[0];
|
|
let height = viewport[1];
|
|
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
|
|
return None;
|
|
}
|
|
let depth = dot3(camera.forward(), direction);
|
|
if !depth.is_finite() || depth <= 1.0e-4 {
|
|
return None;
|
|
}
|
|
let aspect = width / height;
|
|
let half_vertical = (camera.vertical_fov * 0.5).tan();
|
|
let half_horizontal = half_vertical * aspect;
|
|
let ndc_x = dot3(camera.right(), direction) / (depth * half_horizontal);
|
|
let ndc_y = dot3(camera.up(), direction) / (depth * half_vertical);
|
|
let pixels = [(ndc_x * 0.5 + 0.5) * width, (0.5 - ndc_y * 0.5) * height];
|
|
pixels
|
|
.iter()
|
|
.all(|value| value.is_finite())
|
|
.then_some(pixels)
|
|
}
|
|
|
|
fn screen_ray_from_pixels(
|
|
camera: &PreviewCameraFrame,
|
|
pixels: [f32; 2],
|
|
viewport: [f32; 2],
|
|
) -> Option<[f32; 3]> {
|
|
if !pixels.iter().all(|value| value.is_finite())
|
|
|| !viewport.iter().all(|value| value.is_finite())
|
|
|| viewport[0] <= 0.0
|
|
|| viewport[1] <= 0.0
|
|
{
|
|
return None;
|
|
}
|
|
let aspect = viewport[0] / viewport[1];
|
|
let half_vertical = (camera.vertical_fov * 0.5).tan();
|
|
let half_horizontal = half_vertical * aspect;
|
|
let ndc_x = pixels[0] / viewport[0] * 2.0 - 1.0;
|
|
let ndc_y = 1.0 - pixels[1] / viewport[1] * 2.0;
|
|
normalize3(add3(
|
|
add3(
|
|
camera.forward(),
|
|
scale3(camera.right(), ndc_x * half_horizontal),
|
|
),
|
|
scale3(camera.up(), ndc_y * half_vertical),
|
|
))
|
|
}
|
|
|
|
fn sprite_quad_vertices_pixels(
|
|
camera: &PreviewCameraFrame,
|
|
direction: [f32; 3],
|
|
distance: f32,
|
|
half_width_pixels: f32,
|
|
half_height_pixels: f32,
|
|
viewport: [f32; 2],
|
|
color: [f32; 3],
|
|
alpha: f32,
|
|
uv: [[f32; 2]; 4],
|
|
) -> [VulkanStaticVertex; 4] {
|
|
let direction = normalize3(direction).unwrap_or(camera.forward());
|
|
let center = add3(camera.position, scale3(direction, distance));
|
|
let depth = dot3(camera.forward(), direction)
|
|
.mul_add(distance, 0.0)
|
|
.max(camera.near_plane.max(0.001));
|
|
let width = viewport[0].max(1.0);
|
|
let height = viewport[1].max(1.0);
|
|
let aspect = width / height;
|
|
let vertical_fov = camera.vertical_fov;
|
|
let horizontal_fov_radians = horizontal_fov(camera, aspect);
|
|
let world_per_pixel_x = 2.0 * depth * (horizontal_fov_radians * 0.5).tan() / width;
|
|
let world_per_pixel_y = 2.0 * depth * (vertical_fov * 0.5).tan() / height;
|
|
let right = scale3(
|
|
camera.right(),
|
|
half_width_pixels.max(0.0) * world_per_pixel_x,
|
|
);
|
|
let up = scale3(camera.up(), half_height_pixels.max(0.0) * world_per_pixel_y);
|
|
// Native sun and flare UVs are ordered left-top, left-bottom, right-bottom,
|
|
// right-top. The shared index order is bottom-left, bottom-right, top-left,
|
|
// top-right.
|
|
let uv = [uv[1], uv[2], uv[0], uv[3]];
|
|
[
|
|
environment_vertex_with_alpha(sub3(sub3(center, right), up), color, alpha, uv[0]),
|
|
environment_vertex_with_alpha(add3(sub3(center, up), right), color, alpha, uv[1]),
|
|
environment_vertex_with_alpha(add3(sub3(center, right), up), color, alpha, uv[2]),
|
|
environment_vertex_with_alpha(add3(add3(center, right), up), color, alpha, uv[3]),
|
|
]
|
|
}
|
|
|
|
fn screen_tl_quad_vertices_pixels(
|
|
center_pixels: [f32; 2],
|
|
half_width_pixels: f32,
|
|
half_height_pixels: f32,
|
|
viewport: [f32; 2],
|
|
color: [f32; 3],
|
|
alpha: f32,
|
|
uv: [[f32; 2]; 4],
|
|
) -> [VulkanStaticVertex; 4] {
|
|
let width = viewport[0].max(1.0);
|
|
let height = viewport[1].max(1.0);
|
|
let half_width = half_width_pixels.max(0.0);
|
|
let half_height = half_height_pixels.max(0.0);
|
|
let pixel_corners = [
|
|
[
|
|
center_pixels[0] - half_width,
|
|
center_pixels[1] + half_height,
|
|
],
|
|
[
|
|
center_pixels[0] + half_width,
|
|
center_pixels[1] + half_height,
|
|
],
|
|
[
|
|
center_pixels[0] - half_width,
|
|
center_pixels[1] - half_height,
|
|
],
|
|
[
|
|
center_pixels[0] + half_width,
|
|
center_pixels[1] - half_height,
|
|
],
|
|
];
|
|
// Positive-height Vulkan viewports map NDC -Y to top-down screen pixels.
|
|
let uv = [uv[1], uv[2], uv[0], uv[3]];
|
|
std::array::from_fn(|index| {
|
|
let pixel = pixel_corners[index];
|
|
let position = [
|
|
pixel[0] * (2.0 / width) - 1.0,
|
|
pixel[1] * (2.0 / height) - 1.0,
|
|
0.999,
|
|
];
|
|
environment_vertex_with_alpha(position, color, alpha, uv[index])
|
|
})
|
|
}
|
|
|
|
fn precipitation_quad_vertices(
|
|
kind: PrecipitationKind,
|
|
screen: &ScreenBillboard,
|
|
color: [f32; 4],
|
|
camera: &PreviewCameraFrame,
|
|
viewport: [f32; 2],
|
|
) -> [VulkanStaticVertex; 4] {
|
|
let rgb = [color[0], color[1], color[2]];
|
|
let depths = match kind {
|
|
PrecipitationKind::Rain => [
|
|
screen.tail_depth,
|
|
screen.tail_depth,
|
|
screen.head_depth,
|
|
screen.head_depth,
|
|
],
|
|
PrecipitationKind::Snow => [screen.head_depth; 4],
|
|
};
|
|
std::array::from_fn(|index| {
|
|
environment_vertex_with_alpha(
|
|
world_from_ndc(camera, screen.corners[index], depths[index], viewport),
|
|
rgb,
|
|
color[3],
|
|
screen.uv[index],
|
|
)
|
|
})
|
|
}
|
|
|
|
fn world_from_ndc(
|
|
camera: &PreviewCameraFrame,
|
|
ndc: [f32; 2],
|
|
depth: f32,
|
|
viewport: [f32; 2],
|
|
) -> [f32; 3] {
|
|
let depth = if depth.is_finite() {
|
|
depth.max(camera.near_plane.max(0.001))
|
|
} else {
|
|
camera.near_plane.max(0.001)
|
|
};
|
|
let aspect = viewport[0].max(1.0) / viewport[1].max(1.0);
|
|
let half_vertical = (camera.vertical_fov * 0.5).tan();
|
|
let half_horizontal = half_vertical * aspect;
|
|
let offset = add3(
|
|
scale3(camera.right(), ndc[0] * half_horizontal),
|
|
scale3(camera.up(), ndc[1] * half_vertical),
|
|
);
|
|
add3(
|
|
camera.position,
|
|
scale3(add3(camera.forward(), offset), depth),
|
|
)
|
|
}
|
|
|
|
fn lightning_quad_vertices(
|
|
bolt: &fparkan_fx::environment::LightningBolt,
|
|
) -> [VulkanStaticVertex; 4] {
|
|
std::array::from_fn(|index| {
|
|
environment_vertex(bolt.quad.vertices[index], [1.0; 3], bolt.quad.uv[index])
|
|
})
|
|
}
|
|
|
|
/// CPU atmosphere state shared by the interactive preview and the renderer's
|
|
/// per-frame light/fog block. The sky mesh and weather primitives stay on the
|
|
/// same update clock, so camera movement cannot leave environment audio or
|
|
/// lighting one frame behind.
|
|
struct DynamicEnvironment {
|
|
schedule: TypedAtmosphere,
|
|
sky: SkySystem,
|
|
environment: EnvironmentSystem,
|
|
materials: SkyMaterials,
|
|
material_assets: HashMap<String, EnvironmentMaterialAsset>,
|
|
schedule_offset_seconds: f32,
|
|
time_seconds: f32,
|
|
fixed_schedule_seconds: Option<f32>,
|
|
fixed_phase_reported: bool,
|
|
}
|
|
|
|
struct DynamicEnvironmentFrame {
|
|
atmosphere: AtmosphereFrame,
|
|
sky_mesh: SkyMesh,
|
|
environment_frame: EnvironmentFrame,
|
|
absolute_time_seconds: f32,
|
|
}
|
|
|
|
fn environment_camera_for_preview(
|
|
camera: &PreviewCameraFrame,
|
|
aspect: f32,
|
|
viewport: [f32; 2],
|
|
) -> FxCamera {
|
|
FxCamera::with_projection(
|
|
camera.position,
|
|
camera.forward(),
|
|
camera.right(),
|
|
camera.up(),
|
|
camera.vertical_fov,
|
|
aspect,
|
|
)
|
|
.with_viewport(viewport)
|
|
}
|
|
|
|
struct SceneFrameUpdate {
|
|
clock: FrameClockSample,
|
|
camera: PreviewCameraFrame,
|
|
viewport: [f32; 2],
|
|
aspect: f32,
|
|
control_point_lights: Vec<(VulkanPointLight, u32)>,
|
|
control_vulkan_lights: Vec<VulkanPointLight>,
|
|
effect_updates: Vec<AmbientEffectGeometryUpdate>,
|
|
world_sort_keys: Vec<(usize, f32)>,
|
|
ambient_sort_keys: Vec<(usize, f32)>,
|
|
material_update: MaterialFrameUpdate,
|
|
environment_material_update: Option<MaterialFrameUpdate>,
|
|
environment: Option<DynamicEnvironmentFrame>,
|
|
}
|
|
|
|
impl DynamicEnvironment {
|
|
fn update_cpu(
|
|
&mut self,
|
|
dt_seconds: f32,
|
|
camera: &PreviewCameraFrame,
|
|
aspect: f32,
|
|
viewport: [f32; 2],
|
|
mut audio: Option<&mut audio::GameAudio>,
|
|
) -> Result<DynamicEnvironmentFrame, String> {
|
|
let dt_seconds = if dt_seconds.is_finite() {
|
|
dt_seconds.max(0.0)
|
|
} else {
|
|
0.0
|
|
};
|
|
let (time_seconds, absolute_time_seconds) = environment_sample_time(
|
|
self.time_seconds,
|
|
self.schedule_offset_seconds,
|
|
dt_seconds,
|
|
self.fixed_schedule_seconds,
|
|
);
|
|
self.time_seconds = time_seconds;
|
|
if let Some(seconds) = self.fixed_schedule_seconds {
|
|
if !self.fixed_phase_reported {
|
|
eprintln!(
|
|
"CAPTURE_PHASE_SAMPLE absolute_seconds={seconds:.6} schedule_frozen=true"
|
|
);
|
|
self.fixed_phase_reported = true;
|
|
}
|
|
}
|
|
let atmosphere = self.schedule.sample(absolute_time_seconds);
|
|
let mut sky_mesh = self.sky.mesh().clone();
|
|
let sky_frame =
|
|
SkyFrame::from_atmosphere(&atmosphere, &self.materials, absolute_time_seconds);
|
|
sky_mesh.update_colors(&sky_frame.sky);
|
|
if let Some(audio) = audio.as_mut() {
|
|
let primary_rgb = if sky_frame.sun.active {
|
|
sky_frame.sun.color
|
|
} else if sky_frame.moon.active {
|
|
sky_frame.moon.color
|
|
} else {
|
|
[0.0; 3]
|
|
};
|
|
audio
|
|
.update_ambience(dt_seconds, primary_rgb)
|
|
.map_err(|error| format!("update ambient variation: {error}"))?;
|
|
}
|
|
|
|
let fx_camera = environment_camera_for_preview(camera, aspect, viewport);
|
|
let environment_frame = self.environment.update_atmosphere_with_materials(
|
|
dt_seconds,
|
|
&atmosphere,
|
|
&self.materials,
|
|
fx_camera,
|
|
);
|
|
if let Some(audio) = audio.as_mut() {
|
|
for sound in &environment_frame.sounds {
|
|
audio
|
|
.handle_sound_event(sound)
|
|
.map_err(|error| format!("play environment sound: {error}"))?;
|
|
}
|
|
}
|
|
Ok(DynamicEnvironmentFrame {
|
|
atmosphere,
|
|
sky_mesh,
|
|
environment_frame,
|
|
absolute_time_seconds,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn load_environment(
|
|
root: &Path,
|
|
mission: &str,
|
|
atmosphere_seconds: Option<f32>,
|
|
hold_schedule_phase: bool,
|
|
) -> Result<Option<DynamicEnvironment>, String> {
|
|
let mission_dir = mission_asset_directory(root, mission)?;
|
|
let Some(sky_path) = find_mission_file(&mission_dir, "sky.ske")? else {
|
|
return Ok(None);
|
|
};
|
|
let sky_bytes = std::fs::read(&sky_path)
|
|
.map_err(|error| format!("read atmosphere {}: {error}", sky_path.display()))?;
|
|
let schedule = TypedAtmosphere::parse(&sky_bytes)
|
|
.map_err(|error| format!("parse atmosphere {}: {error}", sky_path.display()))?;
|
|
|
|
let sky_wea_path = match find_sibling_file(&sky_path, "sky.wea") {
|
|
Some(path) => Some(path),
|
|
None => find_mission_file(&mission_dir, "sky.wea")?,
|
|
}
|
|
.ok_or_else(|| format!("selected atmosphere {} has no sky.wea", sky_path.display()))?;
|
|
let materials = SkyMaterials::parse(
|
|
&std::fs::read(&sky_wea_path)
|
|
.map_err(|error| format!("read sky materials {}: {error}", sky_wea_path.display()))?,
|
|
)
|
|
.map_err(|error| format!("parse sky materials {}: {error}", sky_wea_path.display()))?;
|
|
let mut sky_material_loader = StandaloneWearMaterialLoader::new(root);
|
|
let mut material_assets =
|
|
load_standalone_wear_named_material_textures_rgba8_and_phases_with_documents_from_root(
|
|
root,
|
|
&sky_wea_path,
|
|
0,
|
|
)
|
|
.map_err(|error| {
|
|
format!(
|
|
"resolve sky material WEAR {}: {error}",
|
|
sky_wea_path.display()
|
|
)
|
|
})?
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(
|
|
|(material_index, (name, mips, phase, document))| -> Result<_, String> {
|
|
let initial_phase = phase;
|
|
let mut phase_textures = Vec::with_capacity(document.phases.len());
|
|
let mut phases = Vec::with_capacity(document.phases.len());
|
|
for phase_index in 0..document.phases.len() {
|
|
let phase_index_u16 = u16::try_from(phase_index).unwrap_or(u16::MAX);
|
|
let (phase_mips, sampled_phase) = if phase_index == 0 {
|
|
(mips.clone(), initial_phase)
|
|
} else {
|
|
sky_material_loader
|
|
.load(
|
|
&sky_wea_path,
|
|
u16::try_from(material_index).unwrap_or(u16::MAX),
|
|
phase_index_u16,
|
|
)
|
|
.map_err(|error| {
|
|
format!("resolve sky material phase {phase_index}: {error}")
|
|
})?
|
|
};
|
|
phase_textures.push(vulkan_texture_from_rgba_mips(phase_mips, "sky material")?);
|
|
phases.push(sampled_phase);
|
|
}
|
|
let texture = phase_textures
|
|
.first()
|
|
.cloned()
|
|
.ok_or_else(|| "sky material has no MAT0 phases".to_string())?;
|
|
Ok((
|
|
String::from_utf8_lossy(&name).into_owned(),
|
|
EnvironmentMaterialAsset {
|
|
texture,
|
|
phase: initial_phase,
|
|
document,
|
|
phase_textures,
|
|
phases,
|
|
},
|
|
))
|
|
},
|
|
)
|
|
.collect::<Result<HashMap<_, _>, String>>()?;
|
|
let effect_bytes = load_resource_entry_bytes_from_root(root, "effects.rlb", "env_lightning")
|
|
.map_err(|error| format!("load effects.rlb/env_lightning: {error}"))?;
|
|
let lightning_effect = decode_env_lightning_fxid(effect_bytes)
|
|
.map_err(|error| format!("decode effects.rlb/env_lightning: {error}"))?;
|
|
if !lightning_effect
|
|
.visual
|
|
.archive
|
|
.eq_ignore_ascii_case("material.lib")
|
|
{
|
|
return Err(format!(
|
|
"env_lightning visual archive must be material.lib, got {:?}",
|
|
lightning_effect.visual.archive
|
|
));
|
|
}
|
|
let (lightning_mips, lightning_phase, lightning_document) =
|
|
load_material_name_texture_rgba8_and_phase_with_document_from_root(
|
|
root,
|
|
&lightning_effect.visual.name,
|
|
0,
|
|
)
|
|
.map_err(|error| {
|
|
format!(
|
|
"resolve env_lightning MAT0 {}: {error}",
|
|
lightning_effect.visual.name
|
|
)
|
|
})?;
|
|
let mut lightning_phase_textures = vec![vulkan_texture_from_rgba_mips(
|
|
lightning_mips,
|
|
"env_lightning material",
|
|
)?];
|
|
let mut lightning_phases = vec![lightning_phase];
|
|
for phase_index in 1..lightning_document.phases.len() {
|
|
let (phase_mips, phase, _document) =
|
|
load_material_name_texture_rgba8_and_phase_with_document_from_root(
|
|
root,
|
|
&lightning_effect.visual.name,
|
|
u16::try_from(phase_index).unwrap_or(u16::MAX),
|
|
)
|
|
.map_err(|error| {
|
|
format!(
|
|
"resolve env_lightning MAT0 phase {phase_index} {}: {error}",
|
|
lightning_effect.visual.name
|
|
)
|
|
})?;
|
|
lightning_phase_textures.push(vulkan_texture_from_rgba_mips(
|
|
phase_mips,
|
|
"env_lightning material",
|
|
)?);
|
|
lightning_phases.push(phase);
|
|
}
|
|
let lightning_texture = lightning_phase_textures
|
|
.first()
|
|
.cloned()
|
|
.ok_or_else(|| "env_lightning MAT0 has no phases".to_string())?;
|
|
material_assets.insert(
|
|
lightning_effect.visual.name.clone(),
|
|
EnvironmentMaterialAsset {
|
|
texture: lightning_texture,
|
|
phase: lightning_phase,
|
|
document: lightning_document,
|
|
phase_textures: lightning_phase_textures,
|
|
phases: lightning_phases,
|
|
},
|
|
);
|
|
let initial_offset_seconds = schedule.initial_offset_seconds() as f32;
|
|
let sky = SkySystem::new(Default::default(), materials.clone())
|
|
.map_err(|error| format!("build sky geometry: {error}"))?;
|
|
let time_seconds = atmosphere_seconds
|
|
.map(|seconds| seconds - initial_offset_seconds)
|
|
.unwrap_or(0.0);
|
|
let fixed_schedule_seconds = if hold_schedule_phase {
|
|
atmosphere_seconds
|
|
} else {
|
|
None
|
|
};
|
|
let mut environment = EnvironmentSystem::new(0x4650_4152_4B41_4E_u64);
|
|
environment.set_lightning_effect(lightning_effect);
|
|
Ok(Some(DynamicEnvironment {
|
|
schedule,
|
|
sky,
|
|
environment,
|
|
materials,
|
|
material_assets,
|
|
schedule_offset_seconds: initial_offset_seconds,
|
|
time_seconds,
|
|
fixed_schedule_seconds,
|
|
fixed_phase_reported: false,
|
|
}))
|
|
}
|
|
|
|
fn environment_sample_time(
|
|
time_seconds: f32,
|
|
schedule_offset_seconds: f32,
|
|
dt_seconds: f32,
|
|
fixed_schedule_seconds: Option<f32>,
|
|
) -> (f32, f32) {
|
|
if let Some(absolute_seconds) = fixed_schedule_seconds {
|
|
return (time_seconds, absolute_seconds);
|
|
}
|
|
|
|
let next_time_seconds = if time_seconds.is_finite() {
|
|
time_seconds + dt_seconds
|
|
} else {
|
|
0.0
|
|
};
|
|
(
|
|
next_time_seconds,
|
|
schedule_offset_seconds + next_time_seconds,
|
|
)
|
|
}
|
|
|
|
/// Returns the host directory containing the selected mission's assets.
|
|
/// Mission keys are the same relative paths accepted by the runtime VFS; the
|
|
/// host root is only used here for the small set of loose mission-local files
|
|
/// that are not archive entries (`sky.ske` and `sky.wea`).
|
|
fn mission_asset_directory(root: &Path, mission: &str) -> Result<PathBuf, String> {
|
|
let mission_path = Path::new(mission);
|
|
if mission_path.is_absolute() {
|
|
return Err("mission environment path must be relative to --root".to_string());
|
|
}
|
|
let directory = mission_path
|
|
.parent()
|
|
.filter(|parent| !parent.as_os_str().is_empty())
|
|
.unwrap_or_else(|| Path::new("."));
|
|
let directory = root.join(directory);
|
|
if !directory.is_dir() {
|
|
return Err(format!(
|
|
"selected mission asset directory does not exist: {}",
|
|
directory.display()
|
|
));
|
|
}
|
|
Ok(directory)
|
|
}
|
|
|
|
/// Finds one mission-local loose environment file. A direct sibling wins;
|
|
/// nested `env`, `environment`, or `planet` layouts are accepted only when
|
|
/// they contain a single matching file, so a second unrelated candidate never
|
|
/// changes the selected mission silently.
|
|
fn find_mission_file(root: &Path, name: &str) -> Result<Option<PathBuf>, String> {
|
|
let direct = root.join(name);
|
|
if direct.is_file() {
|
|
return Ok(Some(direct));
|
|
}
|
|
let mut stack = vec![root.to_path_buf()];
|
|
let mut matches = Vec::new();
|
|
while let Some(directory) = stack.pop() {
|
|
let Ok(entries) = std::fs::read_dir(&directory) else {
|
|
continue;
|
|
};
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
let Ok(file_type) = entry.file_type() else {
|
|
continue;
|
|
};
|
|
if file_type.is_symlink() {
|
|
continue;
|
|
}
|
|
if file_type.is_dir() {
|
|
stack.push(path);
|
|
} else if file_type.is_file()
|
|
&& path
|
|
.file_name()
|
|
.is_some_and(|file_name| file_name.eq_ignore_ascii_case(name))
|
|
{
|
|
matches.push(path);
|
|
}
|
|
}
|
|
}
|
|
matches.sort();
|
|
match matches.as_slice() {
|
|
[] => Ok(None),
|
|
[path] => Ok(Some(path.clone())),
|
|
_ => Err(format!(
|
|
"selected mission has multiple {name} files: {}",
|
|
matches
|
|
.iter()
|
|
.map(|path| path.display().to_string())
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn find_sibling_file(source: &Path, name: &str) -> Option<PathBuf> {
|
|
source
|
|
.parent()
|
|
.map(|parent| parent.join(name))
|
|
.filter(|path| path.is_file())
|
|
}
|
|
|
|
fn add3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
|
|
[left[0] + right[0], left[1] + right[1], left[2] + right[2]]
|
|
}
|
|
|
|
fn add3_in_place(target: &mut [f32; 3], value: [f32; 3]) {
|
|
*target = add3(*target, value);
|
|
}
|
|
|
|
fn sub3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
|
|
[left[0] - right[0], left[1] - right[1], left[2] - right[2]]
|
|
}
|
|
|
|
fn scale3(value: [f32; 3], factor: f32) -> [f32; 3] {
|
|
[value[0] * factor, value[1] * factor, value[2] * factor]
|
|
}
|
|
|
|
fn multiply3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
|
|
[left[0] * right[0], left[1] * right[1], left[2] * right[2]]
|
|
}
|
|
|
|
fn dot3(left: [f32; 3], right: [f32; 3]) -> f32 {
|
|
left[0].mul_add(right[0], left[1].mul_add(right[1], left[2] * right[2]))
|
|
}
|
|
|
|
fn cross3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
|
|
[
|
|
left[1] * right[2] - left[2] * right[1],
|
|
left[2] * right[0] - left[0] * right[2],
|
|
left[0] * right[1] - left[1] * right[0],
|
|
]
|
|
}
|
|
|
|
fn length3(value: [f32; 3]) -> Option<f32> {
|
|
let length = dot3(value, value).sqrt();
|
|
length
|
|
.is_finite()
|
|
.then_some(length)
|
|
.filter(|length| *length > f32::EPSILON)
|
|
}
|
|
|
|
fn normalize3(value: [f32; 3]) -> Option<[f32; 3]> {
|
|
let inverse = length3(value)?.recip();
|
|
Some(scale3(value, inverse))
|
|
}
|
|
|
|
fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] {
|
|
let mut result = [0.0; 16];
|
|
for row in 0..4 {
|
|
for column in 0..4 {
|
|
result[row * 4 + column] = (0..4)
|
|
.map(|inner| left[row * 4 + inner] * right[inner * 4 + column])
|
|
.sum();
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
struct StaticPreviewScene {
|
|
mesh: VulkanStaticMesh,
|
|
native_handedness_by_range: HashMap<usize, i8>,
|
|
type16_present_ranges: HashSet<usize>,
|
|
materials: Vec<VulkanStaticMaterial>,
|
|
material_animations: Vec<MaterialAnimationBinding>,
|
|
ambient_effect_slots: Vec<AmbientEffectGpuSlot>,
|
|
control_point_light_slots: Vec<ControlEffectSlot>,
|
|
control_sound_slots: Vec<ControlSoundSlot>,
|
|
unsupported_control_sounds: usize,
|
|
component_pose_buffers: Vec<Vec<Option<NodePoseBuffer>>>,
|
|
component_mount_poses: Vec<Vec<Option<VulkanNodePose>>>,
|
|
dynamic_component_geometry: Vec<Vec<Option<DynamicComponentGeometry>>>,
|
|
building_collision_bindings: Vec<BuildingCollisionBinding>,
|
|
control_animation_instances: Vec<Vec<Option<ControlAnimationInstance>>>,
|
|
dynamic_shadow_casters: Vec<DynamicShadowCaster>,
|
|
sun_occlusion_range_indices: Vec<usize>,
|
|
shadow_casters: Vec<ShadowCaster>,
|
|
shadow_receivers: Vec<ShadowTriangle>,
|
|
shadow_material_index: usize,
|
|
terrain: Arc<TerrainWorld>,
|
|
camera: VulkanStaticCamera,
|
|
view_camera: PreviewCameraFrame,
|
|
captured_viewport: Option<CapturedViewport>,
|
|
free_camera: Option<FreeFlightCamera>,
|
|
camera_mode: &'static str,
|
|
mesh_components: usize,
|
|
terrain_components: usize,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct ControlAnimationInstance {
|
|
control: PreparedComponentControl,
|
|
model: ModelAsset,
|
|
gate_values: Vec<Option<f32>>,
|
|
state: ControlInstanceState,
|
|
last_sampled_node_times: Vec<Option<f32>>,
|
|
reported_unsupported_fallback: bool,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct DynamicComponentGeometry {
|
|
model: ModelAsset,
|
|
root_transform: LegacyIron3dEulerTransform,
|
|
owner_scale: [f32; 3],
|
|
parent_mount: Option<(usize, usize)>,
|
|
vertex_start: usize,
|
|
vertex_count: usize,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct BuildingCollisionBinding {
|
|
object_index: usize,
|
|
component_index: usize,
|
|
model_source: String,
|
|
model: Arc<ModelAsset>,
|
|
root_transform: LegacyIron3dEulerTransform,
|
|
owner_scale: [f32; 3],
|
|
mounted: bool,
|
|
selected_nodes: Vec<(NodeId, SlotId)>,
|
|
}
|
|
|
|
fn building_collision_binding(
|
|
object_index: usize,
|
|
component_index: usize,
|
|
model_source: String,
|
|
model: Arc<ModelAsset>,
|
|
root_transform: LegacyIron3dEulerTransform,
|
|
owner_scale: [f32; 3],
|
|
mounted: bool,
|
|
) -> Result<BuildingCollisionBinding, String> {
|
|
let context = || {
|
|
format!(
|
|
"building collision object {object_index} component {component_index} model {model_source}"
|
|
)
|
|
};
|
|
if model.node_stride != 38 || model.node_count == 0 || model.animation.is_none() {
|
|
return Err(format!("{} has no complete Node38 pose data", context()));
|
|
}
|
|
if model.triangle_descriptors.is_none() {
|
|
return Err(format!("{} has no type-7 collision descriptors", context()));
|
|
}
|
|
if root_transform.try_row_major().is_none()
|
|
|| !owner_scale.iter().all(|value| value.is_finite())
|
|
|| owner_scale.contains(&0.0)
|
|
{
|
|
return Err(format!("{} has an invalid world transform", context()));
|
|
}
|
|
let first_node = usize::from(mounted);
|
|
let mut selected_nodes = Vec::new();
|
|
for node_index in first_node..model.node_count {
|
|
let node = NodeId(
|
|
u32::try_from(node_index)
|
|
.map_err(|_| format!("{} node index exceeds u32", context()))?,
|
|
);
|
|
let Some(slot) = selected_slot(&model, node, Lod(0), Group(0)) else {
|
|
continue;
|
|
};
|
|
let slot_index = usize::try_from(slot.0)
|
|
.map_err(|_| format!("{} node {} has an invalid slot id", context(), node.0))?;
|
|
let slot_data = model.slots.get(slot_index).ok_or_else(|| {
|
|
format!(
|
|
"{} node {} selects missing slot {}",
|
|
context(),
|
|
node.0,
|
|
slot.0
|
|
)
|
|
})?;
|
|
let batch_start = usize::from(slot_data.batch_start);
|
|
let batch_end = batch_start
|
|
.checked_add(usize::from(slot_data.batch_count))
|
|
.ok_or_else(|| format!("{} node {} batch range overflows", context(), node.0))?;
|
|
let batches = model.batches.get(batch_start..batch_end).ok_or_else(|| {
|
|
format!(
|
|
"{} node {} batch range {batch_start}..{batch_end} is invalid",
|
|
context(),
|
|
node.0
|
|
)
|
|
})?;
|
|
if batches.iter().any(|batch| batch.index_count != 0) {
|
|
selected_nodes.push((node, slot));
|
|
}
|
|
}
|
|
Ok(BuildingCollisionBinding {
|
|
object_index,
|
|
component_index,
|
|
model_source,
|
|
model,
|
|
root_transform,
|
|
owner_scale,
|
|
mounted,
|
|
selected_nodes,
|
|
})
|
|
}
|
|
|
|
fn building_collision_nodes(
|
|
binding: &BuildingCollisionBinding,
|
|
pose_buffer: Option<&NodePoseBuffer>,
|
|
mount_pose: Option<VulkanNodePose>,
|
|
) -> Result<Vec<BuildingMeshNode>, String> {
|
|
let context = || {
|
|
format!(
|
|
"building collision object {} component {} model {}",
|
|
binding.object_index, binding.component_index, binding.model_source
|
|
)
|
|
};
|
|
if binding.mounted != mount_pose.is_some() {
|
|
return Err(format!(
|
|
"{} has {} mount pose, expected {}",
|
|
context(),
|
|
if mount_pose.is_some() { "a" } else { "no" },
|
|
if binding.mounted { "a" } else { "no" }
|
|
));
|
|
}
|
|
binding
|
|
.selected_nodes
|
|
.iter()
|
|
.map(|(node, slot)| {
|
|
let node_index =
|
|
usize::try_from(node.0).map_err(|_| format!("{} node id is invalid", context()))?;
|
|
let pose = match (pose_buffer, binding.mounted) {
|
|
(Some(hierarchy), true) => {
|
|
node38_pose_relative_to_root_from_hierarchy(hierarchy, node_index)
|
|
}
|
|
(Some(hierarchy), false) => node38_pose_from_hierarchy(hierarchy, node_index),
|
|
(None, true) => node38_pose_relative_to_root(&binding.model, None, node_index),
|
|
(None, false) => node38_pose(&binding.model, None, node_index),
|
|
}
|
|
.ok_or_else(|| format!("{} node {} has no current Node38 pose", context(), node.0))?;
|
|
let world_from_node = component_node_world_matrix(
|
|
binding.root_transform,
|
|
binding.owner_scale,
|
|
mount_pose,
|
|
pose,
|
|
)
|
|
.ok_or_else(|| format!("{} node {} has an invalid world matrix", context(), node.0))?;
|
|
Ok(BuildingMeshNode {
|
|
node: *node,
|
|
slot: *slot,
|
|
world_from_node,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn component_node_world_matrix(
|
|
root_transform: LegacyIron3dEulerTransform,
|
|
owner_scale: [f32; 3],
|
|
mount_pose: Option<VulkanNodePose>,
|
|
node_pose: VulkanNodePose,
|
|
) -> Option<[f32; 16]> {
|
|
if !owner_scale
|
|
.iter()
|
|
.chain(node_pose.translation.iter())
|
|
.chain(node_pose.rotation.iter())
|
|
.chain(mount_pose.iter().flat_map(|pose| pose.translation.iter()))
|
|
.chain(mount_pose.iter().flat_map(|pose| pose.rotation.iter()))
|
|
.all(|value| value.is_finite())
|
|
{
|
|
return None;
|
|
}
|
|
let pose_matrix = |pose: VulkanNodePose| {
|
|
let [x, y, z] = [
|
|
rotate_unit_vector([1.0, 0.0, 0.0], pose.rotation),
|
|
rotate_unit_vector([0.0, 1.0, 0.0], pose.rotation),
|
|
rotate_unit_vector([0.0, 0.0, 1.0], pose.rotation),
|
|
];
|
|
[
|
|
x[0],
|
|
y[0],
|
|
z[0],
|
|
pose.translation[0],
|
|
x[1],
|
|
y[1],
|
|
z[1],
|
|
pose.translation[1],
|
|
x[2],
|
|
y[2],
|
|
z[2],
|
|
pose.translation[2],
|
|
0.0,
|
|
0.0,
|
|
0.0,
|
|
1.0,
|
|
]
|
|
};
|
|
let scale = [
|
|
owner_scale[0],
|
|
0.0,
|
|
0.0,
|
|
0.0,
|
|
0.0,
|
|
owner_scale[1],
|
|
0.0,
|
|
0.0,
|
|
0.0,
|
|
0.0,
|
|
owner_scale[2],
|
|
0.0,
|
|
0.0,
|
|
0.0,
|
|
0.0,
|
|
1.0,
|
|
];
|
|
let mut world = multiply_row_major(root_transform.try_row_major()?, scale);
|
|
if let Some(mount) = mount_pose {
|
|
world = multiply_row_major(world, pose_matrix(mount));
|
|
}
|
|
world = multiply_row_major(world, pose_matrix(node_pose));
|
|
world.iter().all(|value| value.is_finite()).then_some(world)
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct DynamicShadowCaster {
|
|
caster_index: usize,
|
|
object_index: usize,
|
|
root_transform: LegacyIron3dEulerTransform,
|
|
owner_scale: [f32; 3],
|
|
initial_sphere: ShadowSphere,
|
|
component_draw_range_indices: Vec<usize>,
|
|
component_spheres: Vec<DynamicShadowSphereSource>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct DynamicShadowSphereSource {
|
|
component_index: usize,
|
|
center: [f32; 3],
|
|
radius: f32,
|
|
mounted: bool,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
struct CapturedViewport {
|
|
/// Original D3D7 viewport rectangle `(left, top, right, bottom)`. The
|
|
/// preview window renders `extent` and should be compared with this crop.
|
|
rect: [i32; 4],
|
|
extent: [u32; 2],
|
|
}
|
|
|
|
fn validate_captured_drawable_extent(
|
|
captured: CapturedViewport,
|
|
actual: [u32; 2],
|
|
) -> Result<(), String> {
|
|
if actual == captured.extent {
|
|
return Ok(());
|
|
}
|
|
Err(format!(
|
|
"legacy camera viewport crop {:?} requires a {}x{} drawable, but the window provides {}x{}; refusing to compare a stretched render",
|
|
captured.rect, captured.extent[0], captured.extent[1], actual[0], actual[1]
|
|
))
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default)]
|
|
struct StartupTimings {
|
|
mission_assets: Duration,
|
|
terrain_materials: Duration,
|
|
sky: Duration,
|
|
gpu_initialization: Duration,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default)]
|
|
struct ShadowFrameEvidence {
|
|
visible_casters: usize,
|
|
projected_indices: usize,
|
|
}
|
|
|
|
fn elapsed_ms(duration: Duration) -> f64 {
|
|
duration.as_secs_f64() * 1_000.0
|
|
}
|
|
|
|
fn unit_component_is_visible(index: usize, source_type: Option<u32>) -> bool {
|
|
index == 0 || source_type == Some(PROTOTYPE_TYPE_EXTO)
|
|
}
|
|
|
|
fn native_sun_object_kind_eligible(source_type: Option<u32>) -> bool {
|
|
native_shadow_kind(source_type).is_some()
|
|
}
|
|
|
|
const PROTOTYPE_TYPE_FORT: u32 = u32::from_le_bytes(*b"FORT");
|
|
const PROTOTYPE_TYPE_BTLU: u32 = u32::from_le_bytes(*b"BTLU");
|
|
const PROTOTYPE_TYPE_STAT: u32 = u32::from_le_bytes(*b"STAT");
|
|
|
|
// shortcut: the preview does not load shade.cfg yet, so use CShade's native
|
|
// defaults until the settings loader is implemented.
|
|
const NATIVE_DEFAULT_ROB_SHADOWS_ON: bool = true;
|
|
const NATIVE_DEFAULT_BLD_SHADOWS_ON: bool = false;
|
|
|
|
/// Maps the resolved registry type to the native object-kind selector used by
|
|
/// CSun/CShade. The mission object's raw kind is an unrelated TMA field; the
|
|
/// renderer receives the registry provenance from the prepared visual.
|
|
fn native_shadow_kind(source_type: Option<u32>) -> Option<u32> {
|
|
match source_type {
|
|
Some(PROTOTYPE_TYPE_FORT) => Some(3),
|
|
Some(PROTOTYPE_TYPE_BTLU) => Some(4),
|
|
Some(PROTOTYPE_TYPE_STAT) => Some(10),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn native_shadow_caster_enabled(native_kind: u32) -> bool {
|
|
if native_kind == 4 {
|
|
NATIVE_DEFAULT_ROB_SHADOWS_ON
|
|
} else {
|
|
NATIVE_DEFAULT_BLD_SHADOWS_ON
|
|
}
|
|
}
|
|
|
|
fn compose_unit_pose(parent: VulkanNodePose, child: VulkanNodePose) -> VulkanNodePose {
|
|
let rotated = rotate_unit_vector(child.translation, parent.rotation);
|
|
VulkanNodePose {
|
|
translation: [
|
|
parent.translation[0] + rotated[0],
|
|
parent.translation[1] + rotated[1],
|
|
parent.translation[2] + rotated[2],
|
|
],
|
|
rotation: multiply_unit_quaternions(parent.rotation, child.rotation),
|
|
}
|
|
}
|
|
|
|
fn control_type4_local_matrix(
|
|
points: [&PreparedControlPoint; 3],
|
|
owner_scale: [f32; 3],
|
|
) -> Option<[[f32; 4]; 4]> {
|
|
if !owner_scale.iter().all(|value| value.is_finite()) {
|
|
return None;
|
|
}
|
|
let positions = points.map(|point| multiply3(point.position, owner_scale));
|
|
let directions = points.map(|point| multiply3(point.direction, owner_scale));
|
|
if !positions
|
|
.iter()
|
|
.flatten()
|
|
.chain(directions.iter().flatten())
|
|
.all(|value| value.is_finite())
|
|
{
|
|
return None;
|
|
}
|
|
let reciprocal_three = f32::from_bits(0x3EAA_AAAB);
|
|
let translation = std::array::from_fn(|axis| {
|
|
(positions[0][axis] + positions[1][axis] + positions[2][axis]) * reciprocal_three
|
|
});
|
|
if !translation.iter().all(|value| value.is_finite()) {
|
|
return None;
|
|
}
|
|
|
|
let [d0, d1, d2] = directions;
|
|
if d0 == d1 && d1 == d2 {
|
|
let magnitude = finite_length3(d0)?;
|
|
if magnitude <= 0.0 {
|
|
return None;
|
|
}
|
|
let basis = control_3ef0_basis(scale3(d0, magnitude.recip()), translation)?;
|
|
let scaled = std::array::from_fn(|row| {
|
|
let mut result = basis[row];
|
|
for column in 0..3 {
|
|
result[column] *= magnitude;
|
|
}
|
|
result
|
|
});
|
|
return scaled
|
|
.iter()
|
|
.flatten()
|
|
.all(|value| value.is_finite())
|
|
.then_some(scaled);
|
|
}
|
|
let (s, a) = if d0 == d2 || d1 == d2 {
|
|
(d0, d1)
|
|
} else if d0 == d1 {
|
|
(d0, d2)
|
|
} else {
|
|
let matrix = control_matrix_from_columns(d0, d1, d2, translation);
|
|
return matrix
|
|
.iter()
|
|
.flatten()
|
|
.all(|value| value.is_finite())
|
|
.then_some(matrix);
|
|
};
|
|
|
|
// In a repeated-direction row d0 is always the native S vector; the
|
|
// other vector selected above is A for helper 0x4070.
|
|
let cross = cross3(a, s);
|
|
let cross_length = finite_length3(cross)?;
|
|
if cross_length <= 0.0 {
|
|
return control_3ef0_basis(s, translation);
|
|
}
|
|
let u = scale3(cross, cross_length.recip());
|
|
let v = cross3(s, u);
|
|
let matrix = control_matrix_from_columns(s, u, v, translation);
|
|
matrix
|
|
.iter()
|
|
.flatten()
|
|
.all(|value| value.is_finite())
|
|
.then_some(matrix)
|
|
}
|
|
|
|
fn control_3ef0_basis(axis: [f32; 3], translation: [f32; 3]) -> Option<[[f32; 4]; 4]> {
|
|
if !axis
|
|
.iter()
|
|
.chain(translation.iter())
|
|
.all(|value| value.is_finite())
|
|
{
|
|
return None;
|
|
}
|
|
let mut u = if axis[0] == 0.0 {
|
|
[1.0, 0.0, 0.0]
|
|
} else if axis[1] == 0.0 {
|
|
[0.0, 1.0, 0.0]
|
|
} else {
|
|
[-axis[1], axis[0], 0.0]
|
|
};
|
|
if axis[2] != 0.0 {
|
|
let u_length = finite_length3(u)?;
|
|
if u_length <= 0.0 {
|
|
return None;
|
|
}
|
|
u = scale3(u, u_length.recip());
|
|
}
|
|
let v = cross3(axis, u);
|
|
let matrix = control_matrix_from_columns(axis, u, v, translation);
|
|
matrix
|
|
.iter()
|
|
.flatten()
|
|
.all(|value| value.is_finite())
|
|
.then_some(matrix)
|
|
}
|
|
|
|
fn control_matrix_from_columns(
|
|
first: [f32; 3],
|
|
second: [f32; 3],
|
|
third: [f32; 3],
|
|
translation: [f32; 3],
|
|
) -> [[f32; 4]; 4] {
|
|
[
|
|
[first[0], second[0], third[0], translation[0]],
|
|
[first[1], second[1], third[1], translation[1]],
|
|
[first[2], second[2], third[2], translation[2]],
|
|
[0.0, 0.0, 0.0, 1.0],
|
|
]
|
|
}
|
|
|
|
fn finite_length3(value: [f32; 3]) -> Option<f32> {
|
|
let length = dot3(value, value).sqrt();
|
|
length.is_finite().then_some(length)
|
|
}
|
|
|
|
fn control_type4_effect_local_matrix(
|
|
points: [&PreparedControlPoint; 3],
|
|
effect_scale: [f32; 3],
|
|
) -> Option<[[f32; 4]; 4]> {
|
|
if !effect_scale.iter().all(|value| value.is_finite()) {
|
|
return None;
|
|
}
|
|
let mut local = control_type4_local_matrix(points, [1.0; 3])?;
|
|
for (column, scale) in effect_scale.into_iter().enumerate() {
|
|
for row in &mut local[..3] {
|
|
row[column] *= scale;
|
|
}
|
|
}
|
|
local
|
|
.iter()
|
|
.flatten()
|
|
.all(|value| value.is_finite())
|
|
.then_some(local)
|
|
}
|
|
|
|
fn control_effect_owner_node_pose(
|
|
hierarchy: Option<&NodePoseBuffer>,
|
|
component_is_mounted: bool,
|
|
owner_node_index: usize,
|
|
) -> Option<VulkanNodePose> {
|
|
match hierarchy {
|
|
Some(hierarchy) if component_is_mounted => {
|
|
node38_pose_relative_to_root_from_hierarchy(hierarchy, owner_node_index)
|
|
}
|
|
Some(hierarchy) => node38_pose_from_hierarchy(hierarchy, owner_node_index),
|
|
None if owner_node_index == 0 => Some(VulkanNodePose {
|
|
translation: [0.0; 3],
|
|
rotation: [0.0, 0.0, 0.0, 1.0],
|
|
}),
|
|
None => None,
|
|
}
|
|
}
|
|
|
|
fn node_pose_row_major(pose: VulkanNodePose) -> [[f32; 4]; 4] {
|
|
let basis = [
|
|
rotate_unit_vector([1.0, 0.0, 0.0], pose.rotation),
|
|
rotate_unit_vector([0.0, 1.0, 0.0], pose.rotation),
|
|
rotate_unit_vector([0.0, 0.0, 1.0], pose.rotation),
|
|
];
|
|
control_matrix_from_columns(basis[0], basis[1], basis[2], pose.translation)
|
|
}
|
|
|
|
fn component_mount_matrix(mount_pose: VulkanNodePose) -> [[f32; 4]; 4] {
|
|
node_pose_row_major(mount_pose)
|
|
}
|
|
|
|
fn component_scale_matrix(scale: [f32; 3]) -> [[f32; 4]; 4] {
|
|
[
|
|
[scale[0], 0.0, 0.0, 0.0],
|
|
[0.0, scale[1], 0.0, 0.0],
|
|
[0.0, 0.0, scale[2], 0.0],
|
|
[0.0, 0.0, 0.0, 1.0],
|
|
]
|
|
}
|
|
|
|
fn matrix_to_row_major(matrix: [[f32; 4]; 4]) -> [f32; 16] {
|
|
std::array::from_fn(|index| matrix[index / 4][index % 4])
|
|
}
|
|
|
|
fn control_type4_world_from_effect(
|
|
local_from_effect: [[f32; 4]; 4],
|
|
owner_scale: [f32; 3],
|
|
root_transform: LegacyIron3dEulerTransform,
|
|
mount_pose: Option<VulkanNodePose>,
|
|
owner_node_pose: VulkanNodePose,
|
|
) -> Option<[[f32; 4]; 4]> {
|
|
if !owner_scale
|
|
.iter()
|
|
.chain(local_from_effect.iter().flatten())
|
|
.chain(owner_node_pose.translation.iter())
|
|
.chain(owner_node_pose.rotation.iter())
|
|
.all(|value| value.is_finite())
|
|
{
|
|
return None;
|
|
}
|
|
let root = root_transform.try_row_major()?;
|
|
let mut world_flat = multiply_row_major(
|
|
root,
|
|
matrix_to_row_major(component_scale_matrix(owner_scale)),
|
|
);
|
|
if let Some(mount) = mount_pose {
|
|
world_flat = multiply_row_major(
|
|
world_flat,
|
|
matrix_to_row_major(component_mount_matrix(mount)),
|
|
);
|
|
}
|
|
world_flat = multiply_row_major(
|
|
world_flat,
|
|
matrix_to_row_major(node_pose_row_major(owner_node_pose)),
|
|
);
|
|
world_flat = multiply_row_major(world_flat, matrix_to_row_major(local_from_effect));
|
|
let world =
|
|
std::array::from_fn(|row| std::array::from_fn(|column| world_flat[row * 4 + column]));
|
|
world
|
|
.iter()
|
|
.flatten()
|
|
.all(|value| value.is_finite())
|
|
.then_some(world)
|
|
}
|
|
|
|
fn control_type4_world_from_effect_with_random_shift(
|
|
mut local_from_effect: [[f32; 4]; 4],
|
|
random_shift: [f32; 3],
|
|
owner_scale: [f32; 3],
|
|
root_transform: LegacyIron3dEulerTransform,
|
|
mount_pose: Option<VulkanNodePose>,
|
|
owner_node_pose: VulkanNodePose,
|
|
) -> Option<[[f32; 4]; 4]> {
|
|
for (axis, value) in random_shift.into_iter().enumerate() {
|
|
local_from_effect[axis][3] += value;
|
|
}
|
|
control_type4_world_from_effect(
|
|
local_from_effect,
|
|
owner_scale,
|
|
root_transform,
|
|
mount_pose,
|
|
owner_node_pose,
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn ambient_effect_world_from_component_pose(
|
|
slot: &AmbientEffectGpuSlot,
|
|
component_pose_buffers: &[Vec<Option<NodePoseBuffer>>],
|
|
) -> Option<[[f32; 4]; 4]> {
|
|
ambient_effect_world_from_component_pose_with_random_shift(
|
|
slot,
|
|
component_pose_buffers,
|
|
[0.0; 3],
|
|
)
|
|
}
|
|
|
|
fn ambient_effect_world_from_component_pose_with_random_shift(
|
|
slot: &AmbientEffectGpuSlot,
|
|
component_pose_buffers: &[Vec<Option<NodePoseBuffer>>],
|
|
random_shift: [f32; 3],
|
|
) -> Option<[[f32; 4]; 4]> {
|
|
let hierarchy = component_pose_buffers
|
|
.get(slot.owner_object_index)
|
|
.and_then(|components| components.get(slot.owner_component_index))
|
|
.and_then(Option::as_ref);
|
|
let owner_node_pose = control_effect_owner_node_pose(
|
|
hierarchy,
|
|
slot.owner_component_is_mounted,
|
|
slot.owner_node_index,
|
|
)?;
|
|
control_type4_world_from_effect_with_random_shift(
|
|
slot.local_from_effect,
|
|
random_shift,
|
|
slot.owner_scale,
|
|
slot.root_transform,
|
|
slot.mount_pose,
|
|
owner_node_pose,
|
|
)
|
|
}
|
|
|
|
fn control_effect_world_from_component_pose_with_random_shift(
|
|
slot: &ControlEffectSlot,
|
|
component_pose_buffers: &[Vec<Option<NodePoseBuffer>>],
|
|
random_shift: [f32; 3],
|
|
) -> Option<[[f32; 4]; 4]> {
|
|
let hierarchy = component_pose_buffers
|
|
.get(slot.owner_object_index)
|
|
.and_then(|components| components.get(slot.owner_component_index))
|
|
.and_then(Option::as_ref);
|
|
let owner_node_pose = control_effect_owner_node_pose(
|
|
hierarchy,
|
|
slot.owner_component_is_mounted,
|
|
slot.owner_node_index,
|
|
)?;
|
|
control_type4_world_from_effect_with_random_shift(
|
|
slot.local_from_effect,
|
|
random_shift,
|
|
slot.owner_scale,
|
|
slot.root_transform,
|
|
slot.mount_pose,
|
|
owner_node_pose,
|
|
)
|
|
}
|
|
|
|
fn update_control_point_lights(
|
|
slots: &mut [ControlEffectSlot],
|
|
component_pose_buffers: &[Vec<Option<NodePoseBuffer>>],
|
|
component_mount_poses: &[Vec<Option<VulkanNodePose>>],
|
|
elapsed_seconds: f32,
|
|
random_samples: &HashMap<ControlEffectInstanceKey, ControlEffectRandomSample>,
|
|
emit_diagnostic: bool,
|
|
) -> Vec<(VulkanPointLight, u32)> {
|
|
let mut lights = Vec::new();
|
|
for slot in slots {
|
|
if slot.owner_component_is_mounted {
|
|
slot.mount_pose = component_mount_poses
|
|
.get(slot.owner_object_index)
|
|
.and_then(|components| components.get(slot.owner_component_index))
|
|
.copied()
|
|
.flatten();
|
|
}
|
|
let random_sample = random_samples
|
|
.get(&slot.random_instance_key)
|
|
.copied()
|
|
.unwrap_or_default();
|
|
let Some(world_from_effect) = control_effect_world_from_component_pose_with_random_shift(
|
|
slot,
|
|
component_pose_buffers,
|
|
random_sample.accumulated_shift,
|
|
) else {
|
|
continue;
|
|
};
|
|
slot.world_from_effect = world_from_effect;
|
|
let Some(phase) = ambient_effect_phase(
|
|
slot.time_mode,
|
|
slot.header_flags,
|
|
elapsed_seconds,
|
|
slot.duration_seconds,
|
|
)
|
|
.map(|phase| native_command_phase(slot.header_flags, phase, random_sample.phase_jitter)) else {
|
|
continue;
|
|
};
|
|
let matrix = slot.world_from_effect;
|
|
let origin = [matrix[0][3], matrix[1][3], matrix[2][3]];
|
|
let basis = [
|
|
[matrix[0][0], matrix[1][0], matrix[2][0]],
|
|
[matrix[0][1], matrix[1][1], matrix[2][1]],
|
|
[matrix[0][2], matrix[1][2], matrix[2][2]],
|
|
];
|
|
let Some(frame) = evaluate_op1_point_light_with_basis(&slot.command, phase, origin, basis)
|
|
else {
|
|
continue;
|
|
};
|
|
if !frame.active || frame.light_selector != 1 {
|
|
continue;
|
|
}
|
|
if emit_diagnostic {
|
|
eprintln!(
|
|
"control_point_light_detail object={} component={} table={} row={} command={} header_flags=0x{:08X} effect_type={} native_flags=0x{:08X} selector={} command_time={} phase={:.4} local_position_start={:?} local_position_end={:?} world_origin={:?} output_position={:?} rgb={:?} alpha={:.3} range={} attenuation={:?} effect_factor={}",
|
|
slot.owner_object_index,
|
|
slot.owner_component_index,
|
|
slot.random_instance_key.table_index,
|
|
slot.random_instance_key.row_index,
|
|
slot.command_index,
|
|
slot.header_flags,
|
|
frame.effect_type,
|
|
frame.native_flags,
|
|
frame.light_selector,
|
|
frame.command_time,
|
|
phase,
|
|
fx_command_vec3(&slot.command, 12),
|
|
fx_command_vec3(&slot.command, 24),
|
|
[matrix[0][3], matrix[1][3], matrix[2][3]],
|
|
frame.position,
|
|
frame.color,
|
|
frame.color[3],
|
|
frame.range,
|
|
frame.attenuation,
|
|
frame.effect_factor,
|
|
);
|
|
}
|
|
lights.push((
|
|
VulkanPointLight {
|
|
position: frame.position,
|
|
rgb: [frame.color[0], frame.color[1], frame.color[2]],
|
|
range: frame.range,
|
|
coefficients: frame.attenuation,
|
|
active: true,
|
|
},
|
|
frame.native_flags,
|
|
));
|
|
}
|
|
lights
|
|
}
|
|
|
|
fn fx_command_vec3(command: &FxCommand, offset: usize) -> Option<[f32; 3]> {
|
|
let mut result = [0.0; 3];
|
|
for (axis, value) in result.iter_mut().enumerate() {
|
|
let start = offset.checked_add(axis.checked_mul(4)?)?;
|
|
let bytes = command.raw_body.get(start..start.checked_add(4)?)?;
|
|
*value = f32::from_le_bytes(bytes.try_into().ok()?);
|
|
}
|
|
Some(result)
|
|
}
|
|
|
|
fn report_control_point_lights_once(
|
|
reported: &mut bool,
|
|
control_clock_ms: u32,
|
|
target_clock_ms: Option<u32>,
|
|
environment_count: usize,
|
|
raw_control_lights: &[(VulkanPointLight, u32)],
|
|
ordinary_control_lights: &[VulkanPointLight],
|
|
) {
|
|
if *reported || std::env::var_os("FPARKAN_CONTROL_POINT_LIGHT_DIAGNOSTIC").is_none() {
|
|
return;
|
|
}
|
|
if target_clock_ms.is_some_and(|target| control_clock_ms < target) {
|
|
return;
|
|
}
|
|
eprintln!(
|
|
"control_point_lights clock_ms={control_clock_ms} environment_count={environment_count} control_raw_count={} control_ordinary_count={} raw_flags={:?}",
|
|
raw_control_lights.len(),
|
|
ordinary_control_lights.len(),
|
|
raw_control_lights.iter().map(|(_, flags)| *flags).collect::<Vec<_>>(),
|
|
);
|
|
*reported = true;
|
|
}
|
|
|
|
fn ordinary_control_point_lights(
|
|
raw_control_lights: &[(VulkanPointLight, u32)],
|
|
) -> Vec<VulkanPointLight> {
|
|
raw_control_lights
|
|
.iter()
|
|
.filter(|(light, flags)| light.active && flags & 0x8000_0000 == 0)
|
|
.map(|(light, _)| *light)
|
|
.collect()
|
|
}
|
|
|
|
fn initial_native_effect_phase(header_flags: u32, phase: f32) -> f32 {
|
|
if header_flags & 0x80 != 0 {
|
|
0.0
|
|
} else {
|
|
phase
|
|
}
|
|
}
|
|
|
|
fn native_command_phase(header_flags: u32, phase: f32, random_phase_jitter: f32) -> f32 {
|
|
let initial_phase = initial_native_effect_phase(header_flags, phase);
|
|
if initial_phase == -1.0 {
|
|
-1.0
|
|
} else {
|
|
(initial_phase
|
|
+ if header_flags & 1 != 0 {
|
|
random_phase_jitter
|
|
} else {
|
|
0.0
|
|
})
|
|
.clamp(0.0, 1.0)
|
|
}
|
|
}
|
|
|
|
fn control_row_points<'a>(
|
|
point_list: &'a PreparedControlPointList,
|
|
row: &PreparedControlEffectRow,
|
|
) -> Option<[&'a PreparedControlPoint; 3]> {
|
|
let mut result = Vec::with_capacity(3);
|
|
for offset in row.point_offsets {
|
|
result.push(point_list.points.get(usize::try_from(offset).ok()?)?);
|
|
}
|
|
result.try_into().ok()
|
|
}
|
|
|
|
fn append_initial_control_effect_slots(
|
|
root: &Path,
|
|
mission_root: &MissionObjectDraft,
|
|
object_index: usize,
|
|
component_index: usize,
|
|
control: &PreparedComponentControl,
|
|
component_pose_buffer: Option<&NodePoseBuffer>,
|
|
mount_pose: Option<VulkanNodePose>,
|
|
mesh: &mut VulkanStaticMesh,
|
|
materials: &mut Vec<VulkanStaticMaterial>,
|
|
material_animations: &mut Vec<MaterialAnimationBinding>,
|
|
effect_documents: &mut HashMap<String, FxDocument>,
|
|
effect_materials: &mut HashMap<String, PreparedEffectMaterial>,
|
|
slots: &mut Vec<AmbientEffectGpuSlot>,
|
|
point_light_slots: &mut Vec<ControlEffectSlot>,
|
|
sound_slots: &mut Vec<ControlSoundSlot>,
|
|
unsupported_sounds: &mut usize,
|
|
) -> Result<(), String> {
|
|
let Some(point_list) = control.point_list.as_ref() else {
|
|
return Ok(());
|
|
};
|
|
for row in
|
|
selected_initial_control_effect_rows(control.initial_effect_group, &control.effect_section)
|
|
{
|
|
let group_index = row.table_index;
|
|
let archive = fixed_effect_field(&row.resource.archive);
|
|
let name = fixed_effect_field(&row.resource.name);
|
|
let points = control_row_points(point_list, row).ok_or_else(|| {
|
|
format!(
|
|
"Control group {group_index} row {} references a missing CPT point {:?}",
|
|
row.row_index, row.point_offsets
|
|
)
|
|
})?;
|
|
let key = format!("{archive}/{name}");
|
|
if !effect_documents.contains_key(&key) {
|
|
let bytes = load_resource_entry_bytes_from_root(root, &archive, &name)
|
|
.map_err(|error| format!("load Control FX {key}: {error}"))?;
|
|
let Ok(document) = decode_fxid(bytes) else {
|
|
continue;
|
|
};
|
|
effect_documents.insert(key.clone(), document);
|
|
}
|
|
let document = effect_documents
|
|
.get(&key)
|
|
.ok_or_else(|| format!("Control FX {key} was not cached"))?;
|
|
let header = document.header();
|
|
let sound_commands = document
|
|
.commands()
|
|
.iter()
|
|
.filter(|command| command.opcode == FxOpcode::Op2)
|
|
.count();
|
|
let duration_seconds = header.duration_seconds;
|
|
if !duration_seconds.is_finite()
|
|
|| duration_seconds < 0.0
|
|
|| !matches!(header.time_mode, 0 | 1 | 2)
|
|
|| (header.time_mode != 0 && duration_seconds <= 0.0)
|
|
|| !header.phase_jitter.is_finite()
|
|
|| !header.random_shift.iter().all(|value| value.is_finite())
|
|
|| !header.scale.iter().all(|value| value.is_finite())
|
|
{
|
|
// Control can reference action effects whose header timing and
|
|
// scale conventions are outside this ambient subset. Leave them
|
|
// unexpanded until their lifetime and transform contract are
|
|
// understood rather than failing the whole mission preview.
|
|
*unsupported_sounds += sound_commands;
|
|
continue;
|
|
}
|
|
let owner_node_index =
|
|
usize::try_from(points[0].raw_signed_field_04)
|
|
.ok()
|
|
.filter(|owner_node| {
|
|
component_pose_buffer
|
|
.is_some_and(|hierarchy| *owner_node < hierarchy.poses.len())
|
|
|| (*owner_node == 0 && component_pose_buffer.is_none())
|
|
});
|
|
let Some(owner_node_index) = owner_node_index else {
|
|
*unsupported_sounds += sound_commands;
|
|
continue;
|
|
};
|
|
let owner_node_pose = control_effect_owner_node_pose(
|
|
component_pose_buffer,
|
|
mount_pose.is_some(),
|
|
owner_node_index,
|
|
);
|
|
let Some(owner_node_pose) = owner_node_pose else {
|
|
*unsupported_sounds += sound_commands;
|
|
continue;
|
|
};
|
|
let root_transform = LegacyIron3dEulerTransform {
|
|
translation: mission_root.position,
|
|
orientation_radians: mission_root.orientation_raw,
|
|
};
|
|
let local_from_effect = control_type4_effect_local_matrix(points, header.scale)
|
|
.ok_or_else(|| format!("Control FX {key} has an invalid type-4 transform"))?;
|
|
let world_from_effect = control_type4_world_from_effect(
|
|
local_from_effect,
|
|
mission_root.scale,
|
|
root_transform,
|
|
mount_pose,
|
|
owner_node_pose,
|
|
)
|
|
.ok_or_else(|| format!("Control FX {key} has an invalid owner transform"))?;
|
|
let control_source = format!(
|
|
"{}/{}",
|
|
control.source.archive.as_str(),
|
|
String::from_utf8_lossy(&control.source.name.0)
|
|
);
|
|
let random_instance_key = ControlEffectInstanceKey {
|
|
object_index,
|
|
component_index,
|
|
table_index: row.table_index,
|
|
row_index: row.row_index,
|
|
};
|
|
let random_seed = control_effect_instance_seed(
|
|
object_index,
|
|
component_index,
|
|
row.table_index,
|
|
row.row_index,
|
|
&control_source,
|
|
);
|
|
|
|
for (command_index, command) in document.commands().iter().enumerate() {
|
|
if ambient_effect_op1_supported(command) || command.opcode == FxOpcode::Op2 {
|
|
let slot = ControlEffectSlot {
|
|
time_mode: header.time_mode,
|
|
header_flags: header.flags,
|
|
random_instance_key,
|
|
random_seed,
|
|
phase_jitter: header.phase_jitter,
|
|
random_shift: header.random_shift,
|
|
duration_seconds,
|
|
owner_object_index: object_index,
|
|
owner_component_index: component_index,
|
|
owner_node_index,
|
|
owner_component_is_mounted: mount_pose.is_some(),
|
|
root_transform,
|
|
owner_scale: mission_root.scale,
|
|
mount_pose,
|
|
local_from_effect,
|
|
world_from_effect,
|
|
command_index,
|
|
command: command.clone(),
|
|
};
|
|
if command.opcode == FxOpcode::Op2 {
|
|
if let Some(sound) = Op2SoundCommand::from_command(command) {
|
|
sound_slots.push(ControlSoundSlot {
|
|
effect: slot,
|
|
command: sound,
|
|
state: Op2SoundState::default(),
|
|
});
|
|
} else {
|
|
*unsupported_sounds += 1;
|
|
eprintln!("unsupported Control Op2 object={object_index} component={component_index} effect={key} command={command_index} (mode, backend parameter or malformed body)");
|
|
}
|
|
} else {
|
|
point_light_slots.push(slot);
|
|
}
|
|
continue;
|
|
}
|
|
let Some(kind) = ambient_effect_command_kind(command) else {
|
|
continue;
|
|
};
|
|
if !ambient_effect_render_selector_allows_header(
|
|
header.flags,
|
|
CONTROL_COMPONENT_RENDER_SELECTOR,
|
|
) {
|
|
continue;
|
|
}
|
|
let Some(reference) = command.resource_refs.first() else {
|
|
continue;
|
|
};
|
|
let material_archive = String::from_utf8_lossy(reference.archive_name()).into_owned();
|
|
let material_name = String::from_utf8_lossy(reference.resource_name()).into_owned();
|
|
if material_archive.is_empty() || material_name.is_empty() {
|
|
continue;
|
|
}
|
|
let cache_key = format!("{material_archive}/{material_name}");
|
|
if !effect_materials.contains_key(&cache_key) {
|
|
let _prepared = prepare_effect_material(
|
|
root,
|
|
&material_archive,
|
|
&material_name,
|
|
materials,
|
|
effect_materials,
|
|
)?;
|
|
}
|
|
let prepared = effect_materials
|
|
.get(&cache_key)
|
|
.ok_or_else(|| format!("FX MAT0 {cache_key} was not cached"))?;
|
|
if !ambient_effect_tint_supported(
|
|
&prepared.phase_native_flag_40,
|
|
&prepared.phase_directional_rgb,
|
|
&prepared.phase_additive_rgb,
|
|
) {
|
|
continue;
|
|
}
|
|
let first_material = *prepared
|
|
.phase_material_indices
|
|
.first()
|
|
.ok_or_else(|| format!("FX MAT0 {cache_key} has no phase variants"))?;
|
|
let op9_mesh_template =
|
|
if kind == AmbientEffectKind::GenerBall && command.opcode == FxOpcode::Op9 {
|
|
let Some(template) = evaluate_f_gener_ball_mesh(
|
|
command,
|
|
command_index,
|
|
&world_from_effect,
|
|
0.0,
|
|
0.0,
|
|
[0.0; 3],
|
|
AMBIENT_EFFECT_NATIVE_CATEGORY,
|
|
) else {
|
|
continue;
|
|
};
|
|
Some(template)
|
|
} else {
|
|
None
|
|
};
|
|
let op9_primitive_layout = op9_mesh_template
|
|
.as_ref()
|
|
.map(ambient_effect_mesh_primitive_layout)
|
|
.transpose()?;
|
|
let placeholder: [VulkanStaticVertex; 4] = std::array::from_fn(|index| {
|
|
environment_vertex_with_alpha(
|
|
[0.0; 3],
|
|
[1.0; 3],
|
|
0.0,
|
|
[[0.0, 0.99], [0.0, 0.0], [0.99, 0.0], [0.99, 0.99]][index],
|
|
)
|
|
});
|
|
let pipeline_state = ambient_effect_pipeline_state(prepared.initial_pipeline_category)
|
|
.ok_or_else(|| {
|
|
format!(
|
|
"FX MAT0 {cache_key} has unsupported pipeline category {}",
|
|
prepared.initial_pipeline_category
|
|
)
|
|
})?;
|
|
let pool_capacity = match kind {
|
|
AmbientEffectKind::SmokePool => ambient_effect_pool_capacity(command)
|
|
.ok_or_else(|| format!("FX Op8 {cache_key} has an invalid pool size"))?,
|
|
AmbientEffectKind::Op7ParticlePool => ambient_effect_op7_range_capacity(command)
|
|
.ok_or_else(|| format!("FX Op7 {cache_key} has an invalid particle pool"))?,
|
|
_ => 1,
|
|
};
|
|
let range_capacity = op9_primitive_layout
|
|
.as_ref()
|
|
.map_or(pool_capacity, Vec::len);
|
|
let mut particle_ranges = Vec::with_capacity(range_capacity);
|
|
let mut particle_material_animation_indices = Vec::with_capacity(range_capacity);
|
|
for _ in 0..pool_capacity {
|
|
if let (Some(template), Some(primitives)) =
|
|
(&op9_mesh_template, &op9_primitive_layout)
|
|
{
|
|
for primitive in primitives {
|
|
let vertices = ambient_effect_mesh_primitive_vertices(template, *primitive);
|
|
let indices = ambient_effect_mesh_primitive_indices(template, *primitive)?;
|
|
let range = append_environment_range(
|
|
mesh,
|
|
first_material,
|
|
vertices,
|
|
indices,
|
|
pipeline_state,
|
|
)?;
|
|
let material_animation_index = append_effect_material_animation(
|
|
prepared,
|
|
range.range_index,
|
|
material_animations,
|
|
);
|
|
particle_ranges.push(range);
|
|
particle_material_animation_indices.push(material_animation_index);
|
|
}
|
|
} else {
|
|
let range = append_environment_range(
|
|
mesh,
|
|
first_material,
|
|
placeholder.to_vec(),
|
|
vec![0, 1, 2, 2, 3, 0],
|
|
pipeline_state,
|
|
)?;
|
|
let material_animation_index = append_effect_material_animation(
|
|
prepared,
|
|
range.range_index,
|
|
material_animations,
|
|
);
|
|
particle_ranges.push(range);
|
|
particle_material_animation_indices.push(material_animation_index);
|
|
}
|
|
}
|
|
let range = particle_ranges.remove(0);
|
|
let material_animation_index = particle_material_animation_indices.remove(0);
|
|
let smoke_emitter_state = (kind == AmbientEffectKind::SmokePool).then(|| {
|
|
let seed = ambient_effect_seed(
|
|
object_index,
|
|
component_index,
|
|
row.table_index,
|
|
row.row_index,
|
|
command_index,
|
|
&control_source,
|
|
);
|
|
SmokeEmitterState::new(seed, seed, seed)
|
|
});
|
|
let op7_emitter_state = (kind == AmbientEffectKind::Op7ParticlePool).then(|| {
|
|
let seed = ambient_effect_seed(
|
|
object_index,
|
|
component_index,
|
|
row.table_index,
|
|
row.row_index,
|
|
command_index,
|
|
&control_source,
|
|
);
|
|
Op7EmitterState::new(seed, seed)
|
|
});
|
|
slots.push(AmbientEffectGpuSlot {
|
|
kind,
|
|
time_mode: header.time_mode,
|
|
header_flags: header.flags,
|
|
random_instance_key,
|
|
random_seed,
|
|
phase_jitter: header.phase_jitter,
|
|
random_shift: header.random_shift,
|
|
owner_object_index: object_index,
|
|
owner_component_index: component_index,
|
|
owner_node_index,
|
|
owner_component_is_mounted: mount_pose.is_some(),
|
|
root_transform,
|
|
owner_scale: mission_root.scale,
|
|
mount_pose,
|
|
local_from_effect,
|
|
control_source: control_source.clone(),
|
|
control_table_index: row.table_index,
|
|
control_row_index: row.row_index,
|
|
control_point_indices: row.point_offsets,
|
|
control_point_owners: points.map(|point| point.raw_signed_field_04),
|
|
command: command.clone(),
|
|
command_index,
|
|
duration_seconds,
|
|
world_from_effect,
|
|
range,
|
|
additional_particle_ranges: particle_ranges,
|
|
material_animation_index,
|
|
additional_particle_material_animation_indices: particle_material_animation_indices,
|
|
smoke_emitter_state,
|
|
op7_emitter_state,
|
|
});
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn selected_initial_control_effect_rows<'a>(
|
|
group_index: Option<usize>,
|
|
section: &'a PreparedControlEffectSection,
|
|
) -> Vec<&'a PreparedControlEffectRow> {
|
|
let (Some(group_index), PreparedControlEffectSection::Parsed(rows)) = (group_index, section)
|
|
else {
|
|
return Vec::new();
|
|
};
|
|
rows.iter()
|
|
.filter(|row| {
|
|
row.table_index == group_index && ambient_effect_row_unconditionally_active(row)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn ambient_effect_row_unconditionally_active(row: &PreparedControlEffectRow) -> bool {
|
|
// Control22C0 returns true when none of the predicate bits are set. The
|
|
// two row-chain bits have separate dispatch semantics and stay excluded.
|
|
row.raw_condition[0] & 0xF000_0000 == 0
|
|
}
|
|
|
|
fn ambient_effect_command_kind(command: &FxCommand) -> Option<AmbientEffectKind> {
|
|
let body = command.raw_body.as_ref();
|
|
let mode = body
|
|
.get(..4)
|
|
.map(|bytes| u32::from_le_bytes(bytes.try_into().expect("four byte FX body mode")));
|
|
let body_c4 = body
|
|
.get(0xc4..0xc8)
|
|
.map(|bytes| u32::from_le_bytes(bytes.try_into().expect("four byte FX C4 field")));
|
|
match (command.opcode, mode, body_c4) {
|
|
(FxOpcode::Op3, Some(0), _) if command.raw_body.len() >= 0xC4 => {
|
|
Some(AmbientEffectKind::Op3Type0)
|
|
}
|
|
(FxOpcode::Op3, Some(1), _) if command.raw_body.len() >= 0xC4 => {
|
|
Some(AmbientEffectKind::Op3Type1)
|
|
}
|
|
(FxOpcode::Op3, Some(2), _) => Some(AmbientEffectKind::Op3Type2),
|
|
(FxOpcode::Op9, Some(2), Some(0..=2)) => Some(AmbientEffectKind::GenerBall),
|
|
(FxOpcode::Op7, _, _) if ambient_effect_op7_range_capacity(command).is_some() => {
|
|
Some(AmbientEffectKind::Op7ParticlePool)
|
|
}
|
|
(FxOpcode::Op8, _, _) if ambient_effect_pool_capacity(command).is_some() => {
|
|
Some(AmbientEffectKind::SmokePool)
|
|
}
|
|
(FxOpcode::Op4, Some(0), Some(denominator_bits))
|
|
if f32::from_bits(denominator_bits).is_finite()
|
|
&& f32::from_bits(denominator_bits) > 0.0 =>
|
|
{
|
|
Some(AmbientEffectKind::GenerBall)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
const CONTROL_COMPONENT_RENDER_SELECTOR: u32 = 1;
|
|
|
|
fn ambient_effect_render_selector_allows_header(header_flags: u32, render_selector: u32) -> bool {
|
|
// Effect.dll 0x7D10 suppresses header flag 0x800 only for selector zero.
|
|
// The native AniMesh component path was observed calling the manager with
|
|
// selector one while its 0xD00 f_smalllight_y entries were present.
|
|
render_selector != 0 || header_flags & 0x800 == 0
|
|
}
|
|
|
|
fn ambient_effect_op1_supported(command: &FxCommand) -> bool {
|
|
if command.opcode != FxOpcode::Op1 {
|
|
return false;
|
|
}
|
|
let Some(effect_type) = command
|
|
.raw_body
|
|
.get(..4)
|
|
.and_then(|bytes| bytes.try_into().ok())
|
|
.map(u32::from_le_bytes)
|
|
else {
|
|
return false;
|
|
};
|
|
command.raw_body.len() >= 132 && matches!(effect_type, 1 | 2 | 5 | 6 | 7)
|
|
}
|
|
|
|
fn ambient_effect_pool_capacity(command: &FxCommand) -> Option<usize> {
|
|
if command.opcode != FxOpcode::Op8 {
|
|
return None;
|
|
}
|
|
let pool_count = usize::try_from(u32::from_le_bytes(
|
|
command.raw_body.get(0x20..0x24)?.try_into().ok()?,
|
|
))
|
|
.ok()?;
|
|
(1..=4096).contains(&pool_count).then_some(pool_count)
|
|
}
|
|
|
|
fn ambient_effect_op7_range_capacity(command: &FxCommand) -> Option<usize> {
|
|
if command.opcode != FxOpcode::Op7 || command.raw_body.len() < 0xCC {
|
|
return None;
|
|
}
|
|
let read_u32 = |offset: usize| -> Option<u32> {
|
|
Some(u32::from_le_bytes(
|
|
command.raw_body.get(offset..offset + 4)?.try_into().ok()?,
|
|
))
|
|
};
|
|
let read_f32 = |offset| read_u32(offset).map(f32::from_bits);
|
|
// The evaluator uses the traced default world scalar of 10 for this
|
|
// Control path; the command's acceleration is supported when finite.
|
|
let material_phase_factor = read_f32(0x1C)?;
|
|
if !matches!(read_u32(0x00)?, 0 | 1)
|
|
|| !material_phase_factor.is_finite()
|
|
|| material_phase_factor == 0.0
|
|
|| !read_f32(0x88)?.is_finite()
|
|
{
|
|
return None;
|
|
}
|
|
let pool_count = usize::try_from(read_u32(0x20)?).ok()?;
|
|
let copies_per_group = usize::try_from(read_u32(0x24)?).ok()?;
|
|
let capacity = pool_count.checked_mul(copies_per_group)?;
|
|
(1..=4096).contains(&capacity).then_some(capacity)
|
|
}
|
|
|
|
fn ambient_effect_seed(
|
|
object_index: usize,
|
|
component_index: usize,
|
|
table_index: usize,
|
|
row_index: usize,
|
|
command_index: usize,
|
|
source: &str,
|
|
) -> u32 {
|
|
let mut hash = 0x811C_9DC5_u32;
|
|
for byte in source.bytes().chain(
|
|
[
|
|
object_index,
|
|
component_index,
|
|
table_index,
|
|
row_index,
|
|
command_index,
|
|
]
|
|
.into_iter()
|
|
.flat_map(|value| (value as u64).to_le_bytes()),
|
|
) {
|
|
hash = (hash ^ u32::from(byte)).wrapping_mul(0x0100_0193);
|
|
}
|
|
if hash == 0 {
|
|
1
|
|
} else {
|
|
hash
|
|
}
|
|
}
|
|
|
|
fn control_effect_instance_seed(
|
|
object_index: usize,
|
|
component_index: usize,
|
|
table_index: usize,
|
|
row_index: usize,
|
|
source: &str,
|
|
) -> u32 {
|
|
ambient_effect_seed(
|
|
object_index,
|
|
component_index,
|
|
table_index,
|
|
row_index,
|
|
0,
|
|
source,
|
|
)
|
|
}
|
|
|
|
fn advance_control_effect_random_states(
|
|
states: &mut HashMap<ControlEffectInstanceKey, ControlEffectRandomState>,
|
|
ambient_slots: &[AmbientEffectGpuSlot],
|
|
point_light_slots: &[ControlEffectSlot],
|
|
sound_slots: &[ControlSoundSlot],
|
|
sample_id: SceneSampleId,
|
|
control_clock_ms: u32,
|
|
elapsed_seconds: f32,
|
|
) -> HashMap<ControlEffectInstanceKey, ControlEffectRandomSample> {
|
|
let mut specifications = HashMap::new();
|
|
for slot in ambient_slots {
|
|
let initial_phase = ambient_effect_phase(
|
|
slot.time_mode,
|
|
slot.header_flags,
|
|
elapsed_seconds,
|
|
slot.duration_seconds,
|
|
)
|
|
.map(|phase| initial_native_effect_phase(slot.header_flags, phase))
|
|
.unwrap_or(-1.0);
|
|
specifications.entry(slot.random_instance_key).or_insert((
|
|
slot.random_seed,
|
|
slot.header_flags,
|
|
slot.phase_jitter,
|
|
slot.random_shift,
|
|
initial_phase,
|
|
));
|
|
}
|
|
for slot in point_light_slots
|
|
.iter()
|
|
.chain(sound_slots.iter().map(|slot| &slot.effect))
|
|
{
|
|
let initial_phase = ambient_effect_phase(
|
|
slot.time_mode,
|
|
slot.header_flags,
|
|
elapsed_seconds,
|
|
slot.duration_seconds,
|
|
)
|
|
.map(|phase| initial_native_effect_phase(slot.header_flags, phase))
|
|
.unwrap_or(-1.0);
|
|
specifications.entry(slot.random_instance_key).or_insert((
|
|
slot.random_seed,
|
|
slot.header_flags,
|
|
slot.phase_jitter,
|
|
slot.random_shift,
|
|
initial_phase,
|
|
));
|
|
}
|
|
|
|
specifications
|
|
.into_iter()
|
|
.map(
|
|
|(key, (seed, flags, phase_jitter, random_shift, initial_phase))| {
|
|
let state = states.entry(key).or_insert_with(|| {
|
|
ControlEffectRandomState::new(seed, flags, phase_jitter, random_shift)
|
|
});
|
|
(
|
|
key,
|
|
state.advance(sample_id, control_clock_ms, initial_phase),
|
|
)
|
|
},
|
|
)
|
|
.collect()
|
|
}
|
|
|
|
fn ambient_effect_ranges(slot: &AmbientEffectGpuSlot) -> Vec<(&EnvironmentGpuRange, usize)> {
|
|
std::iter::once((&slot.range, slot.material_animation_index))
|
|
.chain(
|
|
slot.additional_particle_ranges
|
|
.iter()
|
|
.zip(&slot.additional_particle_material_animation_indices)
|
|
.map(|(range, &binding)| (range, binding)),
|
|
)
|
|
.collect()
|
|
}
|
|
|
|
fn ambient_effect_quad_center(vertices: &[[f32; 3]; 4]) -> [f32; 3] {
|
|
std::array::from_fn(|axis| vertices.iter().map(|vertex| vertex[axis]).sum::<f32>() / 4.0)
|
|
}
|
|
|
|
fn ambient_effect_quad_size(vertices: &[[f32; 3]; 4]) -> [f32; 2] {
|
|
let edge_length = |a: [f32; 3], b: [f32; 3]| {
|
|
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
|
|
};
|
|
[
|
|
edge_length(vertices[0], vertices[1]),
|
|
edge_length(vertices[1], vertices[2]),
|
|
]
|
|
}
|
|
|
|
fn ambient_effect_projected_bounds(
|
|
vertices: &[[f32; 3]; 4],
|
|
clip_from_world: [f32; 16],
|
|
viewport: [u32; 2],
|
|
) -> Option<[f32; 4]> {
|
|
let mut points = [[0.0; 2]; 4];
|
|
for (index, vertex) in vertices.iter().enumerate() {
|
|
let clip = [
|
|
vertex[0] * clip_from_world[0]
|
|
+ vertex[1] * clip_from_world[4]
|
|
+ vertex[2] * clip_from_world[8]
|
|
+ clip_from_world[12],
|
|
vertex[0] * clip_from_world[1]
|
|
+ vertex[1] * clip_from_world[5]
|
|
+ vertex[2] * clip_from_world[9]
|
|
+ clip_from_world[13],
|
|
vertex[0] * clip_from_world[2]
|
|
+ vertex[1] * clip_from_world[6]
|
|
+ vertex[2] * clip_from_world[10]
|
|
+ clip_from_world[14],
|
|
vertex[0] * clip_from_world[3]
|
|
+ vertex[1] * clip_from_world[7]
|
|
+ vertex[2] * clip_from_world[11]
|
|
+ clip_from_world[15],
|
|
];
|
|
if !clip.iter().all(|value| value.is_finite()) || clip[3] <= f32::EPSILON {
|
|
return None;
|
|
}
|
|
points[index] = [
|
|
(clip[0] / clip[3] + 1.0) * 0.5 * viewport[0] as f32,
|
|
(clip[1] / clip[3] + 1.0) * 0.5 * viewport[1] as f32,
|
|
];
|
|
}
|
|
Some([
|
|
points
|
|
.iter()
|
|
.map(|point| point[0])
|
|
.fold(f32::INFINITY, f32::min),
|
|
points
|
|
.iter()
|
|
.map(|point| point[1])
|
|
.fold(f32::INFINITY, f32::min),
|
|
points
|
|
.iter()
|
|
.map(|point| point[0])
|
|
.fold(f32::NEG_INFINITY, f32::max),
|
|
points
|
|
.iter()
|
|
.map(|point| point[1])
|
|
.fold(f32::NEG_INFINITY, f32::max),
|
|
])
|
|
}
|
|
|
|
fn ambient_effect_range_for(
|
|
slot: &AmbientEffectGpuSlot,
|
|
range_index: usize,
|
|
) -> Option<&EnvironmentGpuRange> {
|
|
std::iter::once(&slot.range)
|
|
.chain(slot.additional_particle_ranges.iter())
|
|
.find(|range| range.range_index == range_index)
|
|
}
|
|
|
|
fn ambient_effect_pipeline_state(category: u8) -> Option<LegacyPipelineState> {
|
|
world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly)
|
|
.with_material_category(category)
|
|
}
|
|
|
|
fn ambient_effect_tint_supported(
|
|
phase_native_flag_40: &[bool],
|
|
phase_directional_rgb: &[[f32; 3]],
|
|
phase_additive_rgb: &[[f32; 3]],
|
|
) -> bool {
|
|
!phase_native_flag_40.is_empty()
|
|
&& phase_native_flag_40.len() == phase_directional_rgb.len()
|
|
&& phase_native_flag_40.len() == phase_additive_rgb.len()
|
|
&& phase_native_flag_40.iter().all(|flag| !flag)
|
|
&& phase_directional_rgb
|
|
.iter()
|
|
.zip(phase_additive_rgb)
|
|
.all(|(directional, additive)| {
|
|
directional
|
|
.iter()
|
|
.chain(additive)
|
|
.all(|channel| channel.is_finite() && (0.0..=1.0).contains(channel))
|
|
&& (0..3).all(|channel| additive[channel] > 0.0 || directional[channel] == 0.0)
|
|
})
|
|
}
|
|
|
|
fn fixed_effect_field(field: &[u8; 32]) -> String {
|
|
let length = field
|
|
.iter()
|
|
.position(|byte| *byte == 0)
|
|
.unwrap_or(field.len());
|
|
String::from_utf8_lossy(&field[..length]).trim().to_owned()
|
|
}
|
|
|
|
fn update_ambient_effect_geometry(
|
|
slots: &mut [AmbientEffectGpuSlot],
|
|
component_pose_buffers: &[Vec<Option<NodePoseBuffer>>],
|
|
mesh: &mut VulkanStaticMesh,
|
|
material_animations: &mut [MaterialAnimationBinding],
|
|
elapsed_seconds: f32,
|
|
random_samples: &HashMap<ControlEffectInstanceKey, ControlEffectRandomSample>,
|
|
camera_world: [f32; 3],
|
|
) -> Result<Vec<AmbientEffectGeometryUpdate>, String> {
|
|
let mut updates = Vec::with_capacity(slots.len());
|
|
for slot in slots {
|
|
let random_sample = random_samples
|
|
.get(&slot.random_instance_key)
|
|
.copied()
|
|
.unwrap_or_default();
|
|
let Some(world_from_effect) = ambient_effect_world_from_component_pose_with_random_shift(
|
|
slot,
|
|
component_pose_buffers,
|
|
random_sample.accumulated_shift,
|
|
) else {
|
|
deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?;
|
|
continue;
|
|
};
|
|
slot.world_from_effect = world_from_effect;
|
|
let Some(phase) = ambient_effect_phase(
|
|
slot.time_mode,
|
|
slot.header_flags,
|
|
elapsed_seconds,
|
|
slot.duration_seconds,
|
|
)
|
|
.map(|phase| native_command_phase(slot.header_flags, phase, random_sample.phase_jitter)) else {
|
|
deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?;
|
|
continue;
|
|
};
|
|
if slot.kind == AmbientEffectKind::GenerBall && slot.command.opcode == FxOpcode::Op9 {
|
|
let Some(frame) = evaluate_f_gener_ball_mesh(
|
|
&slot.command,
|
|
slot.command_index,
|
|
&slot.world_from_effect,
|
|
phase,
|
|
elapsed_seconds,
|
|
camera_world,
|
|
AMBIENT_EFFECT_NATIVE_CATEGORY,
|
|
) else {
|
|
deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?;
|
|
continue;
|
|
};
|
|
let primitives = ambient_effect_mesh_primitive_layout(&frame)?;
|
|
let ranges = ambient_effect_ranges(slot);
|
|
if ranges.len() != primitives.len() {
|
|
return Err(format!(
|
|
"ambient Op9 draw range count changed ({} != {})",
|
|
ranges.len(),
|
|
primitives.len()
|
|
));
|
|
}
|
|
for (primitive, (range, material_animation_index)) in primitives.into_iter().zip(ranges)
|
|
{
|
|
let binding = material_animations
|
|
.get_mut(material_animation_index)
|
|
.ok_or_else(|| "ambient FX material binding is out of bounds".to_string())?;
|
|
binding.normalized_phase_override = Some(frame.material_phase);
|
|
write_ambient_effect_mesh_primitive(mesh, range, &frame, primitive)?;
|
|
updates.push(AmbientEffectGeometryUpdate {
|
|
range_index: range.range_index,
|
|
index_count: if frame.active && frame.color[3] > 0.0 {
|
|
u32::try_from(primitive.index_count)
|
|
.map_err(|_| "ambient Op9 index count exceeds u32".to_string())?
|
|
} else {
|
|
0
|
|
},
|
|
command_rgb: [frame.color[0], frame.color[1], frame.color[2]],
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
let frames = match (slot.kind, slot.command.opcode) {
|
|
(AmbientEffectKind::Op3Type0, FxOpcode::Op3) => evaluate_op3_type0_command(
|
|
&slot.command,
|
|
slot.command_index,
|
|
&slot.world_from_effect,
|
|
elapsed_seconds,
|
|
phase,
|
|
camera_world,
|
|
)
|
|
.into_iter()
|
|
.collect(),
|
|
(AmbientEffectKind::Op7ParticlePool, FxOpcode::Op7) => {
|
|
let Some(state) = slot.op7_emitter_state.as_mut() else {
|
|
deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?;
|
|
continue;
|
|
};
|
|
let Some(frames) = evaluate_op7_emitter(
|
|
&slot.command,
|
|
slot.command_index,
|
|
&slot.world_from_effect,
|
|
camera_world,
|
|
phase,
|
|
elapsed_seconds,
|
|
Op7RenderContext {
|
|
detail_level: 0,
|
|
// At detail level zero, [0,1) cutoffs with zero bias
|
|
// are all visible. The native quality preset remains
|
|
// unresolved, but no particle is culled here.
|
|
visibility: Op7VisibilityProfile {
|
|
width: 1.0,
|
|
bias: 0.0,
|
|
},
|
|
// Terrain.dll initializes the native world's world
|
|
// scalar to 10.0. An engine integration can supply the
|
|
// live value after it exposes the world setting.
|
|
world_scalar: 10.0,
|
|
},
|
|
state,
|
|
) else {
|
|
deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?;
|
|
continue;
|
|
};
|
|
fx_material_speed_diagnostic(slot, 0xAC, elapsed_seconds, phase, &frames);
|
|
frames
|
|
}
|
|
(AmbientEffectKind::SmokePool, FxOpcode::Op8) => {
|
|
let Some(state) = slot.smoke_emitter_state.as_mut() else {
|
|
deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?;
|
|
continue;
|
|
};
|
|
let frames = evaluate_op8_emitter(
|
|
&slot.command,
|
|
slot.command_index,
|
|
&slot.world_from_effect,
|
|
camera_world,
|
|
elapsed_seconds,
|
|
phase,
|
|
0,
|
|
Op8DetailThresholdRange::new(1.0, 1.0)
|
|
.expect("ordered finite Op8 material threshold"),
|
|
state,
|
|
)
|
|
.unwrap_or_default();
|
|
smoke_fr_02_diagnostic(slot, elapsed_seconds, phase, &frames);
|
|
fx_material_speed_diagnostic(slot, 0xD4, elapsed_seconds, phase, &frames);
|
|
frames
|
|
}
|
|
(AmbientEffectKind::Op3Type1, FxOpcode::Op3) => evaluate_op3_type1_command(
|
|
&slot.command,
|
|
slot.command_index,
|
|
&slot.world_from_effect,
|
|
elapsed_seconds,
|
|
phase,
|
|
camera_world,
|
|
)
|
|
.into_iter()
|
|
.collect(),
|
|
(AmbientEffectKind::Op3Type2, FxOpcode::Op3) => evaluate_op3_type2_command(
|
|
&slot.command,
|
|
slot.command_index,
|
|
&slot.world_from_effect,
|
|
elapsed_seconds,
|
|
phase,
|
|
0,
|
|
)
|
|
.into_iter()
|
|
.collect(),
|
|
(AmbientEffectKind::GenerBall, FxOpcode::Op4) => evaluate_f_gener_ball(
|
|
&slot.command,
|
|
slot.command_index,
|
|
&slot.world_from_effect,
|
|
phase,
|
|
elapsed_seconds,
|
|
camera_world,
|
|
)
|
|
.into_iter()
|
|
.collect(),
|
|
_ => Vec::new(),
|
|
};
|
|
let ranges = ambient_effect_ranges(slot);
|
|
if frames.is_empty() {
|
|
deactivate_ambient_effect_slot(slot, material_animations, &mut updates)?;
|
|
continue;
|
|
}
|
|
let variable_pool = slot.kind == AmbientEffectKind::Op7ParticlePool;
|
|
let aligned_frames = align_ambient_effect_frames(frames, ranges.len(), variable_pool)?;
|
|
for ((range, material_animation_index), frame) in ranges.into_iter().zip(aligned_frames) {
|
|
if let Some(frame) = frame {
|
|
let binding = material_animations
|
|
.get_mut(material_animation_index)
|
|
.ok_or_else(|| "ambient FX material binding is out of bounds".to_string())?;
|
|
binding.normalized_phase_override = Some(frame.material_phase);
|
|
write_ambient_effect_frame(mesh, range, &frame)?;
|
|
updates.push(AmbientEffectGeometryUpdate {
|
|
range_index: range.range_index,
|
|
index_count: if frame.active && frame.color[3] > 0.0 {
|
|
6
|
|
} else {
|
|
0
|
|
},
|
|
command_rgb: [frame.color[0], frame.color[1], frame.color[2]],
|
|
});
|
|
} else {
|
|
deactivate_ambient_effect_range(
|
|
range,
|
|
material_animation_index,
|
|
material_animations,
|
|
&mut updates,
|
|
)?;
|
|
}
|
|
}
|
|
}
|
|
Ok(updates)
|
|
}
|
|
|
|
fn align_ambient_effect_frames(
|
|
frames: Vec<fparkan_fx::fx_visual::EffectQuadFrame>,
|
|
range_count: usize,
|
|
variable_pool: bool,
|
|
) -> Result<Vec<Option<fparkan_fx::fx_visual::EffectQuadFrame>>, String> {
|
|
if (variable_pool && frames.len() > range_count)
|
|
|| (!variable_pool && frames.len() != range_count)
|
|
{
|
|
return Err(format!(
|
|
"ambient FX frame pool has {} particles but {range_count} draw ranges",
|
|
frames.len()
|
|
));
|
|
}
|
|
let active_count = frames.len();
|
|
let mut aligned: Vec<_> = frames.into_iter().map(Some).collect();
|
|
aligned.extend(std::iter::repeat_with(|| None).take(range_count - active_count));
|
|
Ok(aligned)
|
|
}
|
|
|
|
fn deactivate_ambient_effect_slot(
|
|
slot: &AmbientEffectGpuSlot,
|
|
material_animations: &mut [MaterialAnimationBinding],
|
|
updates: &mut Vec<AmbientEffectGeometryUpdate>,
|
|
) -> Result<(), String> {
|
|
for (range, material_animation_index) in ambient_effect_ranges(slot) {
|
|
deactivate_ambient_effect_range(
|
|
range,
|
|
material_animation_index,
|
|
material_animations,
|
|
updates,
|
|
)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn deactivate_ambient_effect_range(
|
|
range: &EnvironmentGpuRange,
|
|
material_animation_index: usize,
|
|
material_animations: &mut [MaterialAnimationBinding],
|
|
updates: &mut Vec<AmbientEffectGeometryUpdate>,
|
|
) -> Result<(), String> {
|
|
let binding = material_animations
|
|
.get_mut(material_animation_index)
|
|
.ok_or_else(|| "ambient FX material binding is out of bounds".to_string())?;
|
|
binding.normalized_phase_override = Some(0.0);
|
|
updates.push(AmbientEffectGeometryUpdate {
|
|
range_index: range.range_index,
|
|
index_count: 0,
|
|
command_rgb: [0.0; 3],
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
fn write_ambient_effect_frame(
|
|
mesh: &mut VulkanStaticMesh,
|
|
range: &EnvironmentGpuRange,
|
|
frame: &fparkan_fx::fx_visual::EffectQuadFrame,
|
|
) -> Result<(), String> {
|
|
let end = range
|
|
.vertex_start
|
|
.checked_add(range.vertex_capacity)
|
|
.ok_or_else(|| "ambient FX vertex range overflows".to_string())?;
|
|
let destination = mesh
|
|
.vertices
|
|
.get_mut(range.vertex_start..end)
|
|
.ok_or_else(|| "ambient FX vertex allocation is out of bounds".to_string())?;
|
|
if destination.len() != 4 {
|
|
return Err("ambient FX quad allocation changed after GPU preparation".to_string());
|
|
}
|
|
for (index, target) in destination.iter_mut().enumerate() {
|
|
*target = environment_vertex_with_alpha(
|
|
frame.vertices[index],
|
|
[frame.color[0], frame.color[1], frame.color[2]],
|
|
frame.color[3].clamp(0.0, 1.0),
|
|
frame.uv[index],
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn ambient_effect_mesh_primitive_layout(
|
|
frame: &fparkan_fx::fx_visual::EffectMeshFrame,
|
|
) -> Result<Vec<AmbientEffectMeshPrimitive>, String> {
|
|
if frame.vertices.len() != frame.uv.len() || frame.indices.is_empty() {
|
|
return Err("ambient Op9 mesh has mismatched vertices/UVs or no indices".to_string());
|
|
}
|
|
let quad_count = frame
|
|
.indices
|
|
.len()
|
|
.checked_sub(frame.vertices.len())
|
|
.filter(|difference| difference.is_multiple_of(2))
|
|
.map(|difference| difference / 2)
|
|
.ok_or_else(|| "ambient Op9 mesh is not a native quad/cap layout".to_string())?;
|
|
let quad_vertex_count = quad_count
|
|
.checked_mul(4)
|
|
.ok_or_else(|| "ambient Op9 quad vertex count overflows".to_string())?;
|
|
let quad_index_count = quad_count
|
|
.checked_mul(6)
|
|
.ok_or_else(|| "ambient Op9 quad index count overflows".to_string())?;
|
|
let cap_vertex_count = frame
|
|
.vertices
|
|
.len()
|
|
.checked_sub(quad_vertex_count)
|
|
.filter(|count| count.is_multiple_of(3))
|
|
.ok_or_else(|| "ambient Op9 mesh has an invalid cap vertex count".to_string())?;
|
|
let cap_count = cap_vertex_count / 3;
|
|
let cap_index_count = cap_count
|
|
.checked_mul(3)
|
|
.ok_or_else(|| "ambient Op9 cap index count overflows".to_string())?;
|
|
if quad_index_count + cap_index_count != frame.indices.len() {
|
|
return Err("ambient Op9 quad and cap index counts do not match the frame".to_string());
|
|
}
|
|
|
|
// The native generator appends all quads first, then its triangle cap fan.
|
|
let mut primitives = Vec::with_capacity(quad_count + cap_count);
|
|
for quad in 0..quad_count {
|
|
let vertex_start = quad * 4;
|
|
let index_start = quad * 6;
|
|
let base = u16::try_from(vertex_start)
|
|
.map_err(|_| "ambient Op9 quad vertex exceeds u16".to_string())?;
|
|
let expected = [base, base + 1, base + 2, base + 2, base + 3, base];
|
|
if frame.indices.get(index_start..index_start + expected.len()) != Some(&expected) {
|
|
return Err("ambient Op9 quad topology changed from native ordering".to_string());
|
|
}
|
|
primitives.push(AmbientEffectMeshPrimitive {
|
|
vertex_start,
|
|
vertex_count: 4,
|
|
index_start,
|
|
index_count: 6,
|
|
});
|
|
}
|
|
for cap in 0..cap_count {
|
|
let vertex_start = quad_vertex_count + cap * 3;
|
|
let index_start = quad_index_count + cap * 3;
|
|
let base = u16::try_from(vertex_start)
|
|
.map_err(|_| "ambient Op9 cap vertex exceeds u16".to_string())?;
|
|
let expected = [base, base + 1, base + 2];
|
|
if frame.indices.get(index_start..index_start + expected.len()) != Some(&expected) {
|
|
return Err("ambient Op9 cap topology changed from native ordering".to_string());
|
|
}
|
|
primitives.push(AmbientEffectMeshPrimitive {
|
|
vertex_start,
|
|
vertex_count: 3,
|
|
index_start,
|
|
index_count: 3,
|
|
});
|
|
}
|
|
if primitives.is_empty() {
|
|
return Err("ambient Op9 mesh contains no native draw primitives".to_string());
|
|
}
|
|
Ok(primitives)
|
|
}
|
|
|
|
fn ambient_effect_mesh_primitive_vertices(
|
|
frame: &fparkan_fx::fx_visual::EffectMeshFrame,
|
|
primitive: AmbientEffectMeshPrimitive,
|
|
) -> Vec<VulkanStaticVertex> {
|
|
let end = primitive.vertex_start + primitive.vertex_count;
|
|
frame.vertices[primitive.vertex_start..end]
|
|
.iter()
|
|
.zip(&frame.uv[primitive.vertex_start..end])
|
|
.map(|(position, uv)| {
|
|
environment_vertex_with_alpha(
|
|
*position,
|
|
[frame.color[0], frame.color[1], frame.color[2]],
|
|
frame.color[3].clamp(0.0, 1.0),
|
|
*uv,
|
|
)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn ambient_effect_mesh_primitive_indices(
|
|
frame: &fparkan_fx::fx_visual::EffectMeshFrame,
|
|
primitive: AmbientEffectMeshPrimitive,
|
|
) -> Result<Vec<u32>, String> {
|
|
let index_end = primitive.index_start + primitive.index_count;
|
|
frame.indices[primitive.index_start..index_end]
|
|
.iter()
|
|
.map(|index| {
|
|
usize::from(*index)
|
|
.checked_sub(primitive.vertex_start)
|
|
.and_then(|local| u32::try_from(local).ok())
|
|
.ok_or_else(|| {
|
|
"ambient Op9 primitive index is outside its vertex block".to_string()
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn write_ambient_effect_mesh_primitive(
|
|
mesh: &mut VulkanStaticMesh,
|
|
range: &EnvironmentGpuRange,
|
|
frame: &fparkan_fx::fx_visual::EffectMeshFrame,
|
|
primitive: AmbientEffectMeshPrimitive,
|
|
) -> Result<(), String> {
|
|
if range.vertex_capacity != primitive.vertex_count {
|
|
return Err("ambient Op9 primitive vertex allocation changed".to_string());
|
|
}
|
|
let vertex_end = range
|
|
.vertex_start
|
|
.checked_add(range.vertex_capacity)
|
|
.ok_or_else(|| "ambient Op9 vertex range overflows".to_string())?;
|
|
let destination = mesh
|
|
.vertices
|
|
.get_mut(range.vertex_start..vertex_end)
|
|
.ok_or_else(|| "ambient Op9 vertex allocation is out of bounds".to_string())?;
|
|
let draw_range = mesh
|
|
.draw_ranges
|
|
.get(range.range_index)
|
|
.ok_or_else(|| "ambient Op9 draw range is out of bounds".to_string())?;
|
|
let index_start = usize::try_from(draw_range.first_index)
|
|
.map_err(|_| "ambient Op9 first index exceeds usize".to_string())?;
|
|
let index_end = index_start
|
|
.checked_add(primitive.index_count)
|
|
.ok_or_else(|| "ambient Op9 index range overflows".to_string())?;
|
|
let current_indices = mesh
|
|
.indices
|
|
.get(index_start..index_end)
|
|
.ok_or_else(|| "ambient Op9 index allocation is out of bounds".to_string())?;
|
|
let frame_indices = frame
|
|
.indices
|
|
.get(primitive.index_start..primitive.index_start + primitive.index_count)
|
|
.ok_or_else(|| "ambient Op9 source primitive index range is out of bounds".to_string())?;
|
|
for (current, local) in current_indices.iter().zip(frame_indices) {
|
|
let primitive_local = usize::from(*local)
|
|
.checked_sub(primitive.vertex_start)
|
|
.ok_or_else(|| "ambient Op9 source index leaves its primitive".to_string())?;
|
|
let expected = u32::try_from(range.vertex_start)
|
|
.ok()
|
|
.and_then(|base| base.checked_add(u32::try_from(primitive_local).ok()?))
|
|
.ok_or_else(|| "ambient Op9 primitive index exceeds u32".to_string())?;
|
|
if *current != expected {
|
|
return Err("ambient Op9 primitive topology changed after GPU preparation".to_string());
|
|
}
|
|
}
|
|
let frame_vertex_end = primitive
|
|
.vertex_start
|
|
.checked_add(primitive.vertex_count)
|
|
.ok_or_else(|| "ambient Op9 source primitive vertex range overflows".to_string())?;
|
|
let frame_vertices = frame
|
|
.vertices
|
|
.get(primitive.vertex_start..frame_vertex_end)
|
|
.ok_or_else(|| "ambient Op9 source primitive vertex range is out of bounds".to_string())?;
|
|
let frame_uvs = frame
|
|
.uv
|
|
.get(primitive.vertex_start..frame_vertex_end)
|
|
.ok_or_else(|| "ambient Op9 source primitive UV range is out of bounds".to_string())?;
|
|
for ((target, position), uv) in destination.iter_mut().zip(frame_vertices).zip(frame_uvs) {
|
|
*target = environment_vertex_with_alpha(
|
|
*position,
|
|
[frame.color[0], frame.color[1], frame.color[2]],
|
|
frame.color[3].clamp(0.0, 1.0),
|
|
*uv,
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn ambient_effect_vertex_rgb(command_rgb: [f32; 3], material_additive_rgb: [f32; 3]) -> [f32; 3] {
|
|
std::array::from_fn(|channel| command_rgb[channel] * material_additive_rgb[channel])
|
|
}
|
|
|
|
fn apply_ambient_effect_material_tints(
|
|
slots: &[AmbientEffectGpuSlot],
|
|
mesh: &mut VulkanStaticMesh,
|
|
updates: &[AmbientEffectGeometryUpdate],
|
|
active_materials: &HashMap<usize, ActiveMaterialState>,
|
|
) -> Result<(), String> {
|
|
for update in updates.iter().filter(|update| update.index_count > 0) {
|
|
let range = slots
|
|
.iter()
|
|
.find_map(|slot| ambient_effect_range_for(slot, update.range_index))
|
|
.ok_or_else(|| "ambient FX draw range has no owning slot".to_string())?;
|
|
let material = active_materials
|
|
.get(&update.range_index)
|
|
.ok_or_else(|| "ambient FX draw range has no active MAT0 phase".to_string())?;
|
|
let vertex_rgb = ambient_effect_vertex_rgb(update.command_rgb, material.additive_rgb);
|
|
let end = range
|
|
.vertex_start
|
|
.checked_add(range.vertex_capacity)
|
|
.ok_or_else(|| "ambient FX vertex range overflows".to_string())?;
|
|
let vertices = mesh
|
|
.vertices
|
|
.get_mut(range.vertex_start..end)
|
|
.ok_or_else(|| "ambient FX vertex allocation is out of bounds".to_string())?;
|
|
for vertex in vertices {
|
|
vertex.color = vertex_rgb;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn ambient_effect_draw_sort_keys(
|
|
mesh: &VulkanStaticMesh,
|
|
slots: &[AmbientEffectGpuSlot],
|
|
camera_world: [f32; 3],
|
|
) -> Result<Vec<(usize, f32)>, String> {
|
|
slots.iter().try_fold(Vec::new(), |mut keys, slot| {
|
|
for (range, _) in ambient_effect_ranges(slot) {
|
|
keys.push((
|
|
range.range_index,
|
|
first_indexed_vertex_distance(mesh, range.range_index, camera_world)?,
|
|
));
|
|
}
|
|
Ok(keys)
|
|
})
|
|
}
|
|
|
|
fn world_draw_range_indices(
|
|
draw_range_count: usize,
|
|
fixed_range_indices: &HashSet<usize>,
|
|
) -> Vec<usize> {
|
|
(0..draw_range_count)
|
|
.filter(|range_index| !fixed_range_indices.contains(range_index))
|
|
.collect()
|
|
}
|
|
|
|
// TEMPORARY diagnostic for ROADMAP.md A.4 (smoke_fr_02 speed). Enabled by
|
|
// FPARKAN_DIAG_SMOKE; identifies the emitter by its Op8 body (pool 30,
|
|
// interval 0.08) and reports spawn rate, particle lifetime and rise speed
|
|
// once per two seconds of effect clock. Remove after the investigation.
|
|
#[derive(Default)]
|
|
struct SmokeDiagnosticTrack {
|
|
window_start: Option<f32>,
|
|
last_born: f32,
|
|
spawns: usize,
|
|
calls: usize,
|
|
max_age: f32,
|
|
sum_age: f64,
|
|
sum_z: f64,
|
|
sum_age_z: f64,
|
|
sum_age_age: f64,
|
|
samples: usize,
|
|
}
|
|
|
|
fn smoke_fr_02_diagnostic(
|
|
slot: &AmbientEffectGpuSlot,
|
|
clock: f32,
|
|
phase: f32,
|
|
frames: &[fparkan_fx::fx_visual::EffectQuadFrame],
|
|
) {
|
|
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
|
static TRACKS: std::sync::OnceLock<
|
|
std::sync::Mutex<HashMap<(usize, usize, usize, usize, usize), SmokeDiagnosticTrack>>,
|
|
> = std::sync::OnceLock::new();
|
|
if !*ENABLED.get_or_init(|| std::env::var_os("FPARKAN_DIAG_SMOKE").is_some()) {
|
|
return;
|
|
}
|
|
let body = slot.command.raw_body.as_ref();
|
|
let read_u32 = |offset: usize| {
|
|
body.get(offset..offset + 4)
|
|
.map(|bytes| u32::from_le_bytes(bytes.try_into().expect("four bytes")))
|
|
};
|
|
if read_u32(0x20) != Some(30) || read_u32(0x14).map(f32::from_bits) != Some(0.08) {
|
|
return;
|
|
}
|
|
let key = (
|
|
slot.owner_object_index,
|
|
slot.owner_component_index,
|
|
slot.control_table_index,
|
|
slot.control_row_index,
|
|
slot.command_index,
|
|
);
|
|
let mut tracks = TRACKS
|
|
.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
|
|
.lock()
|
|
.expect("smoke diagnostic lock");
|
|
let slot_count = tracks.len() + usize::from(!tracks.contains_key(&key));
|
|
let track = tracks.entry(key).or_default();
|
|
let window_start = *track.window_start.get_or_insert(clock);
|
|
track.calls += 1;
|
|
for frame in frames.iter().filter(|frame| frame.active) {
|
|
let born = clock - frame.elapsed_seconds;
|
|
if born > track.last_born + 1e-4 {
|
|
track.spawns += 1;
|
|
track.last_born = track.last_born.max(born);
|
|
}
|
|
let z = frame.vertices.iter().map(|vertex| vertex[2]).sum::<f32>() * 0.25;
|
|
let age = f64::from(frame.elapsed_seconds);
|
|
track.max_age = track.max_age.max(frame.elapsed_seconds);
|
|
track.sum_age += age;
|
|
track.sum_z += f64::from(z);
|
|
track.sum_age_z += age * f64::from(z);
|
|
track.sum_age_age += age * age;
|
|
track.samples += 1;
|
|
}
|
|
let window = clock - window_start;
|
|
if window < 2.0 {
|
|
return;
|
|
}
|
|
let n = track.samples as f64;
|
|
let rise_per_second = if n > 1.0 {
|
|
(n * track.sum_age_z - track.sum_age * track.sum_z)
|
|
/ (n * track.sum_age_age - track.sum_age * track.sum_age)
|
|
} else {
|
|
0.0
|
|
};
|
|
let axis_lengths: [f32; 3] = std::array::from_fn(|column| {
|
|
(0..3)
|
|
.map(|row| slot.world_from_effect[row][column].powi(2))
|
|
.sum::<f32>()
|
|
.sqrt()
|
|
});
|
|
eprintln!(
|
|
"smoke_diag slots={slot_count} key={key:?} clock={clock:.3} window={window:.3}s calls={} calls_per_s={:.1} spawns={} spawns_per_s={:.2} active_now={} max_age={:.3}s rise_per_s={rise_per_second:.2} rise_per_life_2_4s={:.1} phase={phase:.3} emitter=[{:.2},{:.2},{:.2}] effect_axes={axis_lengths:.3?}",
|
|
track.calls,
|
|
track.calls as f32 / window,
|
|
track.spawns,
|
|
track.spawns as f32 / window,
|
|
frames.iter().filter(|frame| frame.active).count(),
|
|
track.max_age,
|
|
rise_per_second * 2.4,
|
|
slot.world_from_effect[0][3],
|
|
slot.world_from_effect[1][3],
|
|
slot.world_from_effect[2][3],
|
|
);
|
|
*track = SmokeDiagnosticTrack {
|
|
window_start: Some(clock),
|
|
last_born: track.last_born,
|
|
..SmokeDiagnosticTrack::default()
|
|
};
|
|
}
|
|
|
|
// TEMPORARY diagnostic for ROADMAP.md A.4 (animal "aura" speed). Enabled by
|
|
// FPARKAN_DIAG_FX_MATERIAL=<material name>, for example smoke_yg. Reports the
|
|
// effect-phase rate, call rate, particle lifetime and rise per particle life
|
|
// for every Op7/Op8 slot whose command uses that MAT0. Remove after the
|
|
// investigation.
|
|
#[derive(Default)]
|
|
struct FxSpeedDiagnosticTrack {
|
|
window_start: Option<f32>,
|
|
calls: usize,
|
|
phase_travel: f32,
|
|
last_phase: Option<f32>,
|
|
max_age: f32,
|
|
sum_t: f64,
|
|
sum_z: f64,
|
|
sum_tz: f64,
|
|
sum_tt: f64,
|
|
samples: usize,
|
|
}
|
|
|
|
fn fx_material_speed_diagnostic(
|
|
slot: &AmbientEffectGpuSlot,
|
|
material_name_offset: usize,
|
|
clock: f32,
|
|
phase: f32,
|
|
frames: &[fparkan_fx::fx_visual::EffectQuadFrame],
|
|
) {
|
|
static WANTED: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
|
|
static TRACKS: std::sync::OnceLock<
|
|
std::sync::Mutex<HashMap<(usize, usize, usize, usize, usize), FxSpeedDiagnosticTrack>>,
|
|
> = std::sync::OnceLock::new();
|
|
let Some(wanted) = WANTED
|
|
.get_or_init(|| {
|
|
std::env::var("FPARKAN_DIAG_FX_MATERIAL")
|
|
.ok()
|
|
.map(|name| name.to_ascii_lowercase())
|
|
})
|
|
.as_deref()
|
|
else {
|
|
return;
|
|
};
|
|
let body = slot.command.raw_body.as_ref();
|
|
let Some(name) = body.get(material_name_offset..material_name_offset + 32) else {
|
|
return;
|
|
};
|
|
let name = name.split(|byte| *byte == 0).next().unwrap_or_default();
|
|
if !name.eq_ignore_ascii_case(wanted.as_bytes()) {
|
|
return;
|
|
}
|
|
let key = (
|
|
slot.owner_object_index,
|
|
slot.owner_component_index,
|
|
slot.control_table_index,
|
|
slot.control_row_index,
|
|
slot.command_index,
|
|
);
|
|
let mut tracks = TRACKS
|
|
.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
|
|
.lock()
|
|
.expect("fx diagnostic lock");
|
|
let slot_count = tracks.len() + usize::from(!tracks.contains_key(&key));
|
|
let track = tracks.entry(key).or_default();
|
|
let window_start = *track.window_start.get_or_insert(clock);
|
|
track.calls += 1;
|
|
if let Some(last_phase) = track.last_phase {
|
|
let delta = phase - last_phase;
|
|
track.phase_travel += if delta < -0.5 { delta + 1.0 } else { delta };
|
|
}
|
|
track.last_phase = Some(phase);
|
|
for frame in frames.iter().filter(|frame| frame.active) {
|
|
// Op8 reports the particle age in seconds; Op7 reports its normalized
|
|
// particle age, so the regression is per second or per life.
|
|
let t = if slot.command.opcode == FxOpcode::Op8 {
|
|
frame.elapsed_seconds
|
|
} else {
|
|
frame.normalized_time
|
|
};
|
|
let z = frame.vertices.iter().map(|vertex| vertex[2]).sum::<f32>() * 0.25;
|
|
track.max_age = track.max_age.max(t);
|
|
track.sum_t += f64::from(t);
|
|
track.sum_z += f64::from(z);
|
|
track.sum_tz += f64::from(t) * f64::from(z);
|
|
track.sum_tt += f64::from(t) * f64::from(t);
|
|
track.samples += 1;
|
|
}
|
|
let window = clock - window_start;
|
|
if window < 3.0 {
|
|
return;
|
|
}
|
|
let n = track.samples as f64;
|
|
let slope = if n > 1.0 {
|
|
(n * track.sum_tz - track.sum_t * track.sum_z)
|
|
/ (n * track.sum_tt - track.sum_t * track.sum_t)
|
|
} else {
|
|
0.0
|
|
};
|
|
eprintln!(
|
|
"fx_diag material={wanted} op={:?} slots={slot_count} key={key:?} clock={clock:.3} window={window:.3}s calls_per_s={:.1} phase_rate_per_s={:.4} active_now={} max_age={:.3} rise_slope={slope:.2} ({}) duration={:.2} mode={} owner=[{:.1},{:.1},{:.1}]",
|
|
slot.command.opcode,
|
|
track.calls as f32 / window,
|
|
track.phase_travel / window,
|
|
frames.iter().filter(|frame| frame.active).count(),
|
|
track.max_age,
|
|
if slot.command.opcode == FxOpcode::Op8 { "units/s, age in s" } else { "units/life, age normalized" },
|
|
slot.duration_seconds,
|
|
slot.time_mode,
|
|
slot.world_from_effect[0][3],
|
|
slot.world_from_effect[1][3],
|
|
slot.world_from_effect[2][3],
|
|
);
|
|
*track = FxSpeedDiagnosticTrack {
|
|
window_start: Some(clock),
|
|
last_phase: track.last_phase,
|
|
..FxSpeedDiagnosticTrack::default()
|
|
};
|
|
}
|
|
|
|
fn ambient_effect_phase(
|
|
time_mode: u32,
|
|
header_flags: u32,
|
|
elapsed_seconds: f32,
|
|
duration_seconds: f32,
|
|
) -> Option<f32> {
|
|
if !elapsed_seconds.is_finite()
|
|
|| elapsed_seconds < 0.0
|
|
|| !duration_seconds.is_finite()
|
|
|| duration_seconds < 0.0
|
|
{
|
|
return None;
|
|
}
|
|
let mut phase = match time_mode {
|
|
0 => 0.0,
|
|
// The native phase stays raw through the optional 0x200/0x20
|
|
// transforms; the command-level 0x8120 path clamps only afterward.
|
|
1 if duration_seconds > 0.0 => elapsed_seconds / duration_seconds,
|
|
2 if duration_seconds > 0.0 => {
|
|
elapsed_seconds.rem_euclid(duration_seconds) / duration_seconds
|
|
}
|
|
_ => return None,
|
|
};
|
|
if header_flags & 0x200 != 0 && duration_seconds > 0.0 {
|
|
phase *= elapsed_seconds / duration_seconds;
|
|
}
|
|
if header_flags & 0x20 != 0 {
|
|
phase = if phase >= 0.5 {
|
|
(1.0 - phase) * 2.0
|
|
} else {
|
|
phase * 2.0
|
|
};
|
|
}
|
|
Some(phase)
|
|
}
|
|
|
|
fn rotate_unit_vector(value: [f32; 3], rotation: [f32; 4]) -> [f32; 3] {
|
|
let [x, y, z, w] = rotation;
|
|
let tx = 2.0 * (y * value[2] - z * value[1]);
|
|
let ty = 2.0 * (z * value[0] - x * value[2]);
|
|
let tz = 2.0 * (x * value[1] - y * value[0]);
|
|
[
|
|
value[0] + w * tx + (y * tz - z * ty),
|
|
value[1] + w * ty + (z * tx - x * tz),
|
|
value[2] + w * tz + (x * ty - y * tx),
|
|
]
|
|
}
|
|
|
|
fn multiply_unit_quaternions(left: [f32; 4], right: [f32; 4]) -> [f32; 4] {
|
|
let [lx, ly, lz, lw] = left;
|
|
let [rx, ry, rz, rw] = right;
|
|
[
|
|
lw * rx + lx * rw + ly * rz - lz * ry,
|
|
lw * ry - lx * rz + ly * rw + lz * rx,
|
|
lw * rz + lx * ry - ly * rx + lz * rw,
|
|
lw * rw - lx * rx - ly * ry - lz * rz,
|
|
]
|
|
}
|
|
|
|
/// Projects the mission terrain plus every MSH component of the selected roots
|
|
/// into one shared world-space mesh.
|
|
///
|
|
/// Terrain resolves its packed low-byte base and high-byte overlay through
|
|
/// map-local `Land1.wea`, then pairs each layer with its `Land2.wea`
|
|
/// microtexture and type-18 UV stream. Type-14 alpha remains a continuous
|
|
/// interpolated overlay mask. MSH component hierarchy, animation sampling,
|
|
/// lightmaps, and gameplay visibility are handled by the preview bridge below.
|
|
|
|
fn static_preview_node_times(
|
|
node_count: usize,
|
|
bindings: &[PreparedControlNodeBinding],
|
|
) -> Result<Vec<Option<AnimationTime>>, String> {
|
|
let mut node_times = vec![Some(AnimationTime(0.0)); node_count];
|
|
for binding in bindings {
|
|
// Disabled rows are filtered while loading CTLD, but preserve the
|
|
// native guard here in case a caller constructs MissionAssets itself.
|
|
if binding.flags & 4 != 0 {
|
|
continue;
|
|
}
|
|
if binding.node_index >= node_count {
|
|
return Err(format!(
|
|
"control binding node {} is outside model node count {node_count}",
|
|
binding.node_index
|
|
));
|
|
}
|
|
if !binding.frame_a.is_finite()
|
|
|| !binding.frame_b.is_finite()
|
|
|| !binding.initial_blend.is_finite()
|
|
|| binding.frame_a < 0.0
|
|
|| binding.frame_b < 0.0
|
|
{
|
|
return Err(format!(
|
|
"control binding for node {} contains an invalid frame or blend",
|
|
binding.node_index
|
|
));
|
|
}
|
|
|
|
let mut blend = binding.initial_blend;
|
|
if binding.flags & 1 != 0 {
|
|
if blend > 1.0 {
|
|
blend -= 1.0;
|
|
}
|
|
if blend < 0.0 {
|
|
blend += 1.0;
|
|
}
|
|
} else {
|
|
blend = blend.clamp(0.0, 1.0);
|
|
}
|
|
if binding.flags & 2 != 0 {
|
|
blend = 1.0 - blend;
|
|
}
|
|
let sample_time = (1.0 - blend) * binding.frame_a + blend * binding.frame_b;
|
|
if !sample_time.is_finite() || sample_time < 0.0 {
|
|
return Err(format!(
|
|
"control binding for node {} produced a negative or non-finite sample time unsupported by the static sampler",
|
|
binding.node_index
|
|
));
|
|
}
|
|
// Source order is significant: native initialization writes each
|
|
// enabled row in turn, so a later row for the same node wins.
|
|
node_times[binding.node_index] = Some(AnimationTime(sample_time));
|
|
}
|
|
Ok(node_times)
|
|
}
|
|
|
|
fn static_preview_component_pose_buffer(
|
|
model: &ModelAsset,
|
|
bindings: &[PreparedControlNodeBinding],
|
|
static_animation_frame: Option<u16>,
|
|
) -> Result<Option<NodePoseBuffer>, String> {
|
|
if let Some(frame) = static_animation_frame {
|
|
return Ok(node38_sampled_hierarchy(model, frame));
|
|
}
|
|
let node_times = static_preview_node_times(model.node_count, bindings)?;
|
|
Ok(node38_sampled_hierarchy_at_times(model, &node_times)
|
|
.or_else(|| node38_fallback_hierarchy(model)))
|
|
}
|
|
|
|
fn initial_control_gate_values(control: &PreparedComponentControl) -> Vec<Option<f32>> {
|
|
control
|
|
.typed_records
|
|
.iter()
|
|
.filter(|record| record.tag == 21)
|
|
.map(|record| match &record.gate_provider {
|
|
PreparedControlGateProvider::Resolved {
|
|
initial_type1_value,
|
|
..
|
|
} if initial_type1_value.is_finite() => Some(*initial_type1_value),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn control_clock_ms_for_frame(target_ms: u32, frame_index: u64, target_frames: u64) -> u32 {
|
|
if target_frames <= 1 {
|
|
return target_ms;
|
|
}
|
|
let last_frame = u128::from(target_frames - 1);
|
|
let frame = u128::from(frame_index).min(last_frame);
|
|
((u128::from(target_ms) * frame) / last_frame) as u32
|
|
}
|
|
|
|
fn replay_environment_delta_seconds(last_applied_ms: u32, current_ms: u32) -> f32 {
|
|
current_ms.saturating_sub(last_applied_ms) as f32 / 1_000.0
|
|
}
|
|
|
|
fn control_clock_mode_label(target_ms: Option<u32>) -> String {
|
|
match target_ms {
|
|
None => "realtime".to_string(),
|
|
Some(0) => "fixed-0ms".to_string(),
|
|
Some(target_ms) => format!("ramp-0-to-{target_ms}ms"),
|
|
}
|
|
}
|
|
|
|
fn write_readback_sequence_frame(
|
|
directory: &Path,
|
|
frame_index: u64,
|
|
control_clock_ms: u32,
|
|
artifact: &VulkanReadbackArtifact,
|
|
) -> Result<PathBuf, String> {
|
|
std::fs::create_dir_all(directory).map_err(|error| {
|
|
format!(
|
|
"create readback sequence directory {}: {error}",
|
|
directory.display()
|
|
)
|
|
})?;
|
|
let path = directory.join(format!(
|
|
"frame-{frame_index:06}-clock-{control_clock_ms:010}ms-{}x{}.rgba",
|
|
artifact.width, artifact.height,
|
|
));
|
|
let mut file = std::fs::OpenOptions::new()
|
|
.write(true)
|
|
.create_new(true)
|
|
.open(&path)
|
|
.map_err(|error| format!("create readback frame {}: {error}", path.display()))?;
|
|
file.write_all(&artifact.bytes)
|
|
.map_err(|error| format!("write readback frame {}: {error}", path.display()))?;
|
|
Ok(path)
|
|
}
|
|
|
|
fn quaternion_yaw_z_degrees(rotation: [f32; 4]) -> Option<f32> {
|
|
if !rotation.iter().all(|value| value.is_finite()) {
|
|
return None;
|
|
}
|
|
let [x, y, z, w] = rotation;
|
|
let radians = (2.0 * (w * z + x * y)).atan2(1.0 - 2.0 * (y * y + z * z));
|
|
radians.is_finite().then_some(radians.to_degrees())
|
|
}
|
|
|
|
fn emit_control_animation_capture_diagnostics(
|
|
instances: &[Vec<Option<ControlAnimationInstance>>],
|
|
pose_buffers: &[Vec<Option<NodePoseBuffer>>],
|
|
control_clock_ms: u32,
|
|
) {
|
|
for (object_index, components) in instances.iter().enumerate() {
|
|
for (component_index, instance) in components.iter().enumerate() {
|
|
let Some(instance) = instance.as_ref() else {
|
|
continue;
|
|
};
|
|
let mut gate_index = 0;
|
|
for record in instance
|
|
.control
|
|
.typed_records
|
|
.iter()
|
|
.filter(|record| record.tag == 21)
|
|
{
|
|
let mapped_row = record
|
|
.values
|
|
.first()
|
|
.and_then(|row| usize::try_from(*row).ok());
|
|
let timeline = mapped_row.and_then(|row| instance.control.timeline_rows.get(row));
|
|
let node_index = timeline.map(|row| row.node_index);
|
|
let sampled_time = node_index
|
|
.and_then(|node| instance.last_sampled_node_times.get(node))
|
|
.copied()
|
|
.flatten();
|
|
let pose = pose_buffers
|
|
.get(object_index)
|
|
.and_then(|items| items.get(component_index))
|
|
.and_then(Option::as_ref)
|
|
.and_then(|hierarchy| node_index.and_then(|node| hierarchy.poses.get(node)));
|
|
let rotation = pose.map(|pose| pose.rotation);
|
|
let yaw_z_degrees = rotation.and_then(quaternion_yaw_z_degrees);
|
|
let gate_value = instance.gate_values.get(gate_index).copied().flatten();
|
|
eprintln!(
|
|
"control_animation_capture object={object_index} component={component_index} clock_ms={control_clock_ms} gate_value={gate_value:?} gate_provider={:?} row={mapped_row:?} node={node_index:?} sampled_time={sampled_time:?} rotation={rotation:?} yaw_z_degrees={yaw_z_degrees:?}",
|
|
record.gate_provider,
|
|
);
|
|
gate_index += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn update_control_animation_pose_buffers(
|
|
instances: &mut [Vec<Option<ControlAnimationInstance>>],
|
|
pose_buffers: &mut [Vec<Option<NodePoseBuffer>>],
|
|
native_clock_ms: u32,
|
|
) -> Result<(), String> {
|
|
for (object_index, components) in instances.iter_mut().enumerate() {
|
|
let Some(object_pose_buffers) = pose_buffers.get_mut(object_index) else {
|
|
continue;
|
|
};
|
|
for (component_index, instance) in components.iter_mut().enumerate() {
|
|
let Some(instance) = instance.as_mut() else {
|
|
continue;
|
|
};
|
|
let node_times: Vec<Option<AnimationTime>> = match sample_control_node_times(
|
|
&instance.control,
|
|
native_clock_ms,
|
|
&instance.gate_values,
|
|
&mut instance.state,
|
|
) {
|
|
Ok(times) => {
|
|
instance.last_sampled_node_times = times.clone();
|
|
times
|
|
.into_iter()
|
|
.map(|time| time.map(AnimationTime))
|
|
.collect()
|
|
}
|
|
Err(
|
|
error @ (ControlSamplingError::UnsupportedTag21Mode { .. }
|
|
| ControlSamplingError::UnsupportedVariableMapping { .. }
|
|
| ControlSamplingError::MissingGateProvider { .. }
|
|
| ControlSamplingError::GateProviderKeyOutOfRange { .. }
|
|
| ControlSamplingError::MissingGateProviderValue { .. }
|
|
| ControlSamplingError::GateProviderCountMismatch { .. }),
|
|
) => {
|
|
if !instance.reported_unsupported_fallback {
|
|
eprintln!(
|
|
"control_animation_fallback object={object_index} component={component_index} error={error:?}"
|
|
);
|
|
instance.reported_unsupported_fallback = true;
|
|
}
|
|
let fallback_times = static_preview_node_times(
|
|
instance.control.model_node_count,
|
|
&instance.control.bindings,
|
|
)
|
|
.map_err(|fallback| {
|
|
format!(
|
|
"control animation fallback for object {object_index} component {component_index}: {fallback}"
|
|
)
|
|
})?;
|
|
instance.last_sampled_node_times = fallback_times
|
|
.iter()
|
|
.map(|time| time.map(|time| time.0))
|
|
.collect();
|
|
fallback_times
|
|
}
|
|
Err(error) => {
|
|
return Err(format!(
|
|
"control animation sample for object {object_index} component {component_index}: {error:?}"
|
|
));
|
|
}
|
|
};
|
|
let hierarchy = node38_sampled_hierarchy_at_times(&instance.model, &node_times)
|
|
.or_else(|| node38_fallback_hierarchy(&instance.model));
|
|
if let Some(hierarchy) = hierarchy {
|
|
if let Some(pose_buffer) = object_pose_buffers.get_mut(component_index) {
|
|
*pose_buffer = Some(hierarchy);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn control_tree_chain_has_tag21(
|
|
component_index: usize,
|
|
tree: Option<&[fparkan_prototype::UnitComponentTreeNode]>,
|
|
controls: &[Option<PreparedComponentControl>],
|
|
) -> bool {
|
|
let mut current = Some(component_index);
|
|
let mut remaining = controls.len();
|
|
while let Some(index) = current {
|
|
if controls
|
|
.get(index)
|
|
.and_then(Option::as_ref)
|
|
.is_some_and(|control| control.typed_records.iter().any(|record| record.tag == 21))
|
|
{
|
|
return true;
|
|
}
|
|
if remaining == 0 {
|
|
return false;
|
|
}
|
|
remaining -= 1;
|
|
current = tree
|
|
.and_then(|nodes| nodes.get(index))
|
|
.and_then(|node| node.parent_index);
|
|
}
|
|
false
|
|
}
|
|
|
|
fn update_dynamic_component_geometry(
|
|
geometry: &[Vec<Option<DynamicComponentGeometry>>],
|
|
pose_buffers: &[Vec<Option<NodePoseBuffer>>],
|
|
mount_poses: &mut [Vec<Option<VulkanNodePose>>],
|
|
mesh: &mut VulkanStaticMesh,
|
|
) -> Result<(), String> {
|
|
let identity_pose = VulkanNodePose {
|
|
translation: [0.0; 3],
|
|
rotation: [0.0, 0.0, 0.0, 1.0],
|
|
};
|
|
for (object_index, components) in geometry.iter().enumerate() {
|
|
for (component_index, component) in components.iter().enumerate() {
|
|
let Some(component) = component else {
|
|
continue;
|
|
};
|
|
if let Some((parent_index, socket)) = component.parent_mount {
|
|
let parent_mounted = mount_poses
|
|
.get(object_index)
|
|
.and_then(|items| items.get(parent_index))
|
|
.ok_or_else(|| {
|
|
format!("dynamic component {object_index}:{component_index} has no parent mount slot")
|
|
})?
|
|
.is_some();
|
|
let parent_hierarchy = pose_buffers
|
|
.get(object_index)
|
|
.and_then(|items| items.get(parent_index))
|
|
.and_then(Option::as_ref)
|
|
.ok_or_else(|| {
|
|
format!(
|
|
"dynamic component {object_index}:{component_index} has no parent pose"
|
|
)
|
|
})?;
|
|
let parent_socket = if parent_mounted {
|
|
node38_pose_relative_to_root_from_hierarchy(parent_hierarchy, socket)
|
|
} else {
|
|
node38_pose_from_hierarchy(parent_hierarchy, socket)
|
|
}
|
|
.ok_or_else(|| {
|
|
format!("dynamic component {object_index}:{component_index} parent socket {socket} is unavailable")
|
|
})?;
|
|
let parent_root = mount_poses
|
|
.get(object_index)
|
|
.and_then(|items| items.get(parent_index))
|
|
.copied()
|
|
.flatten()
|
|
.unwrap_or(identity_pose);
|
|
let mount = mount_poses
|
|
.get_mut(object_index)
|
|
.and_then(|items| items.get_mut(component_index))
|
|
.ok_or_else(|| {
|
|
format!(
|
|
"dynamic component {object_index}:{component_index} has no mount slot"
|
|
)
|
|
})?;
|
|
*mount = Some(compose_unit_pose(parent_root, parent_socket));
|
|
}
|
|
let hierarchy = pose_buffers
|
|
.get(object_index)
|
|
.and_then(|items| items.get(component_index))
|
|
.and_then(Option::as_ref)
|
|
.ok_or_else(|| {
|
|
format!(
|
|
"dynamic component {object_index}:{component_index} has no sampled pose"
|
|
)
|
|
})?;
|
|
let mount = mount_poses
|
|
.get(object_index)
|
|
.and_then(|items| items.get(component_index))
|
|
.copied()
|
|
.flatten();
|
|
let projected = match mount {
|
|
Some(mount) => {
|
|
project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount(
|
|
&component.model,
|
|
component.root_transform,
|
|
component.owner_scale,
|
|
hierarchy,
|
|
mount,
|
|
)
|
|
}
|
|
None => project_msh_to_static_mesh_in_world_space_with_node_pose_buffer(
|
|
&component.model,
|
|
component.root_transform,
|
|
component.owner_scale,
|
|
hierarchy,
|
|
),
|
|
}
|
|
.map_err(|error| {
|
|
format!("reproject dynamic component {object_index}:{component_index}: {error}")
|
|
})?;
|
|
if projected.vertices.len() != component.vertex_count {
|
|
return Err(format!(
|
|
"dynamic component {object_index}:{component_index} changed vertex count from {} to {}",
|
|
component.vertex_count,
|
|
projected.vertices.len()
|
|
));
|
|
}
|
|
let end = component
|
|
.vertex_start
|
|
.checked_add(component.vertex_count)
|
|
.filter(|end| *end <= mesh.vertices.len())
|
|
.ok_or_else(|| {
|
|
format!(
|
|
"dynamic component {object_index}:{component_index} vertex span is invalid"
|
|
)
|
|
})?;
|
|
mesh.vertices[component.vertex_start..end].copy_from_slice(&projected.vertices);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
struct MissionBasementFootprint {
|
|
object_index: usize,
|
|
outer: Vec<[f32; 2]>,
|
|
inner: Vec<[f32; 3]>,
|
|
flatten_inner_to_mean_outer_z: bool,
|
|
}
|
|
|
|
fn apply_basement_placement_deltas(
|
|
roots: &[MissionObjectDraft],
|
|
footprints: &[MissionBasementFootprint],
|
|
placements: &[BasementPlacement],
|
|
) -> Result<Vec<MissionObjectDraft>, String> {
|
|
if footprints.len() != placements.len() {
|
|
return Err("basement placement results are not aligned with mission roots".to_string());
|
|
}
|
|
let mut adjusted_roots = roots.to_vec();
|
|
for (footprint, placement) in footprints.iter().zip(placements) {
|
|
let root = adjusted_roots
|
|
.get_mut(footprint.object_index)
|
|
.ok_or_else(|| "basement footprint references an unknown mission root".to_string())?;
|
|
root.position = root.root_position_with_basement_delta(placement.placement_delta);
|
|
}
|
|
Ok(adjusted_roots)
|
|
}
|
|
|
|
fn mission_basement_footprints(
|
|
assets: &MissionAssets,
|
|
roots: &[MissionObjectDraft],
|
|
) -> Result<Vec<MissionBasementFootprint>, String> {
|
|
let mut footprints = Vec::new();
|
|
for (object_index, mission_root) in roots.iter().enumerate() {
|
|
let Some(mode) = mission_root.basement_placement_mode() else {
|
|
continue;
|
|
};
|
|
let Some(component_bas) = assets
|
|
.object_component_bas
|
|
.get(object_index)
|
|
.and_then(|components| components.first())
|
|
.and_then(Option::as_ref)
|
|
else {
|
|
continue;
|
|
};
|
|
let geometry = &component_bas.geometry;
|
|
let bas_label = format!(
|
|
"{}:{}",
|
|
component_bas.source.archive.as_str(),
|
|
String::from_utf8_lossy(&component_bas.source.name.0)
|
|
);
|
|
// shortcut: the installed Iron Strategy corpus has one inner and one
|
|
// outer ring per eligible DAT. Native iterates both arrays, so reject
|
|
// unsupported multiplicity until the multi-ring semantics are ported.
|
|
if geometry.first.len() != 1 || geometry.second.len() != 1 {
|
|
return Err(format!(
|
|
"eligible mission object {object_index} from {bas_label} has {} inner and {} outer BAS rings; one of each is supported",
|
|
geometry.first.len(),
|
|
geometry.second.len()
|
|
));
|
|
}
|
|
let transform = LegacyIron3dEulerTransform {
|
|
translation: mission_root.position,
|
|
orientation_radians: mission_root.orientation_raw,
|
|
};
|
|
let transform_points = |points: &[[f32; 3]], ring_name: &str| {
|
|
let mut transformed_points = Vec::with_capacity(points.len());
|
|
for &point in points {
|
|
let transformed = transform
|
|
.try_transform_scaled_point(point, mission_root.scale)
|
|
.ok_or_else(|| {
|
|
format!(
|
|
"building BAS {ring_name} ring on mission object {object_index} from {bas_label} has a non-finite transformed point"
|
|
)
|
|
})?;
|
|
transformed_points.push(transformed);
|
|
}
|
|
Ok::<_, String>(transformed_points)
|
|
};
|
|
let inner_record = &geometry.first[0];
|
|
let outer_record = &geometry.second[0];
|
|
for (ring_name, declared_count, points) in [
|
|
("inner", inner_record.declared_count, &inner_record.points),
|
|
("outer", outer_record.declared_count, &outer_record.points),
|
|
] {
|
|
let expected_point_count = usize::try_from(declared_count)
|
|
.ok()
|
|
.and_then(|count| count.checked_add(1))
|
|
.ok_or_else(|| format!("building BAS {ring_name} count overflows: {bas_label}"))?;
|
|
if usize::try_from(declared_count).unwrap_or(0) < 3
|
|
|| points.len() != expected_point_count
|
|
{
|
|
return Err(format!(
|
|
"building BAS {ring_name} ring on mission object {object_index} from {bas_label} has count {declared_count} and {} points",
|
|
points.len()
|
|
));
|
|
}
|
|
}
|
|
let inner = transform_points(&inner_record.points, "inner")?;
|
|
let outer_3d = transform_points(&outer_record.points, "outer")?;
|
|
footprints.push(MissionBasementFootprint {
|
|
object_index,
|
|
outer: outer_3d.iter().map(|point| [point[0], point[1]]).collect(),
|
|
inner,
|
|
flatten_inner_to_mean_outer_z: mode == BasementPlacementMode::FlattenInnerAndAdjustRoot,
|
|
});
|
|
}
|
|
Ok(footprints)
|
|
}
|
|
|
|
fn interpolate_static_terrain_vertex(
|
|
position: [f32; 3],
|
|
source: [VulkanStaticVertex; 3],
|
|
) -> Result<VulkanStaticVertex, String> {
|
|
let point = [f64::from(position[0]), f64::from(position[1])];
|
|
let a = [
|
|
f64::from(source[0].position[0]),
|
|
f64::from(source[0].position[1]),
|
|
];
|
|
let b = [
|
|
f64::from(source[1].position[0]),
|
|
f64::from(source[1].position[1]),
|
|
];
|
|
let c = [
|
|
f64::from(source[2].position[0]),
|
|
f64::from(source[2].position[1]),
|
|
];
|
|
let denominator = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
|
if !denominator.is_finite() || denominator.abs() <= 1.0e-10 {
|
|
return Err("terrain source triangle has no stable XY barycentric coordinates".to_string());
|
|
}
|
|
let second_weight =
|
|
((point[0] - a[0]) * (c[1] - a[1]) - (point[1] - a[1]) * (c[0] - a[0])) / denominator;
|
|
let third_weight =
|
|
((b[0] - a[0]) * (point[1] - a[1]) - (b[1] - a[1]) * (point[0] - a[0])) / denominator;
|
|
let weights = [
|
|
1.0 - second_weight - third_weight,
|
|
second_weight,
|
|
third_weight,
|
|
];
|
|
if !weights.iter().all(|weight| weight.is_finite()) {
|
|
return Err("terrain split vertex has invalid barycentric coordinates".to_string());
|
|
}
|
|
let blend2 = |values: [[f32; 2]; 3]| {
|
|
[
|
|
(0..3)
|
|
.map(|index| values[index][0] * weights[index] as f32)
|
|
.sum(),
|
|
(0..3)
|
|
.map(|index| values[index][1] * weights[index] as f32)
|
|
.sum(),
|
|
]
|
|
};
|
|
let blend3 = |values: [[f32; 3]; 3]| {
|
|
std::array::from_fn(|axis| {
|
|
(0..3)
|
|
.map(|index| values[index][axis] * weights[index] as f32)
|
|
.sum()
|
|
})
|
|
};
|
|
let overlay_alpha = (0..3)
|
|
.map(|index| source[index].overlay_alpha * weights[index] as f32)
|
|
.sum::<f32>();
|
|
if !overlay_alpha.is_finite() {
|
|
return Err(format!(
|
|
"terrain split vertex overlay alpha is non-finite: position={position:?}, weights={weights:?}, source_position={:?}, source_alpha={:?}, result={overlay_alpha}",
|
|
source.map(|vertex| vertex.position),
|
|
source.map(|vertex| vertex.overlay_alpha)
|
|
));
|
|
}
|
|
const OVERLAY_ALPHA_ROUNDING_TOLERANCE: f32 = 1.0e-5;
|
|
if overlay_alpha < -OVERLAY_ALPHA_ROUNDING_TOLERANCE
|
|
|| overlay_alpha > 1.0 + OVERLAY_ALPHA_ROUNDING_TOLERANCE
|
|
{
|
|
return Err(format!(
|
|
"terrain split vertex overlay alpha exceeds barycentric rounding tolerance: position={position:?}, weights={weights:?}, source_position={:?}, source_alpha={:?}, result={overlay_alpha}",
|
|
source.map(|vertex| vertex.position),
|
|
source.map(|vertex| vertex.overlay_alpha)
|
|
));
|
|
}
|
|
let vertex = VulkanStaticVertex {
|
|
position,
|
|
color: blend3(source.map(|vertex| vertex.color)),
|
|
normal: blend3(source.map(|vertex| vertex.normal)),
|
|
uv: blend2(source.map(|vertex| vertex.uv)),
|
|
detail_uv: blend2(source.map(|vertex| vertex.detail_uv)),
|
|
overlay_alpha: overlay_alpha.clamp(0.0, 1.0),
|
|
};
|
|
if !vertex
|
|
.position
|
|
.iter()
|
|
.chain(vertex.color.iter())
|
|
.chain(vertex.normal.iter())
|
|
.chain(vertex.uv.iter())
|
|
.chain(vertex.detail_uv.iter())
|
|
.chain(std::iter::once(&vertex.overlay_alpha))
|
|
.all(|value| value.is_finite())
|
|
{
|
|
return Err(
|
|
"terrain split vertex contains a non-finite interpolated attribute".to_string(),
|
|
);
|
|
}
|
|
Ok(vertex)
|
|
}
|
|
|
|
fn project_basement_foundation(
|
|
terrain: &fparkan_terrain_format::LandMeshDocument,
|
|
source_mesh: &VulkanStaticMesh,
|
|
foundation: &mut BasementFoundation,
|
|
) -> Result<(VulkanStaticMesh, Vec<ShadowTriangle>), String> {
|
|
let pipeline_state = source_mesh
|
|
.draw_ranges
|
|
.first()
|
|
.map(|range| range.pipeline_state)
|
|
.ok_or_else(|| "terrain source mesh has no LOD0 draw ranges".to_string())?;
|
|
let (normals, uv0_words) = canonicalize_basement_band_vertices(foundation)?;
|
|
let mut mesh = VulkanStaticMesh {
|
|
vertices: Vec::new(),
|
|
indices: Vec::new(),
|
|
draw_ranges: Vec::new(),
|
|
};
|
|
let mut shadow_receivers = Vec::with_capacity(foundation.fragments.len());
|
|
|
|
for (fragment_index, fragment) in foundation.fragments.iter().enumerate() {
|
|
let (material_index, base_only, vertices) = match fragment.source_face {
|
|
Some(face_index) => {
|
|
let face = terrain.faces.get(face_index).ok_or_else(|| {
|
|
format!("foundation fragment references unknown terrain face {face_index}")
|
|
})?;
|
|
if face.flags != fragment.surface_mask {
|
|
return Err(format!(
|
|
"foundation fragment for terrain face {face_index} changed its surface mask"
|
|
));
|
|
}
|
|
let source_vertices: [VulkanStaticVertex; 3] = face
|
|
.vertices
|
|
.map(|vertex| {
|
|
source_mesh
|
|
.vertices
|
|
.get(usize::from(vertex))
|
|
.copied()
|
|
.ok_or_else(|| {
|
|
format!(
|
|
"terrain face {face_index} references an unknown projected vertex"
|
|
)
|
|
})
|
|
})
|
|
.into_iter()
|
|
.collect::<Result<Vec<_>, _>>()?
|
|
.try_into()
|
|
.map_err(|_| "terrain face did not have three vertices".to_string())?;
|
|
let vertices = fragment
|
|
.positions
|
|
.map(|position| {
|
|
source_vertices
|
|
.iter()
|
|
.find(|vertex| vertex.position == position)
|
|
.copied()
|
|
.map(Ok)
|
|
.unwrap_or_else(|| {
|
|
interpolate_static_terrain_vertex(position, source_vertices)
|
|
})
|
|
})
|
|
.into_iter()
|
|
.collect::<Result<Vec<_>, _>>()?
|
|
.try_into()
|
|
.map_err(|_| {
|
|
"foundation source fragment did not have three vertices".to_string()
|
|
})?;
|
|
(
|
|
face.material_tag,
|
|
fragment.surface_mask.0 & 0x400 == 0,
|
|
vertices,
|
|
)
|
|
}
|
|
None => {
|
|
let fragment_normals = normals
|
|
.get(fragment_index)
|
|
.and_then(Option::as_ref)
|
|
.ok_or_else(|| "basement band face has no native vertex normals".to_string())?;
|
|
let fragment_uv0_words = uv0_words
|
|
.get(fragment_index)
|
|
.and_then(Option::as_ref)
|
|
.ok_or_else(|| "basement band face lost its original UV words".to_string())?;
|
|
let vertices = basement_band_vertices(
|
|
fragment.positions,
|
|
fragment_uv0_words,
|
|
fragment_normals,
|
|
);
|
|
(0xff00, true, vertices)
|
|
}
|
|
};
|
|
|
|
let first_index = u32::try_from(mesh.indices.len())
|
|
.map_err(|_| "basement terrain index buffer exceeds u32".to_string())?;
|
|
for vertex in vertices {
|
|
let index = u32::try_from(mesh.vertices.len())
|
|
.map_err(|_| "basement terrain vertex buffer exceeds u32".to_string())?;
|
|
mesh.vertices.push(vertex);
|
|
mesh.indices.push(index);
|
|
}
|
|
if let Some(range) = mesh.draw_ranges.last_mut().filter(|range| {
|
|
range.material_index == material_index && range.terrain_base_only == base_only
|
|
}) {
|
|
range.index_count = range
|
|
.index_count
|
|
.checked_add(3)
|
|
.ok_or_else(|| "basement terrain draw range exceeds u32".to_string())?;
|
|
} else {
|
|
mesh.draw_ranges.push(VulkanStaticDrawRange {
|
|
first_index,
|
|
index_count: 3,
|
|
material_index,
|
|
lightmap_index: u8::MAX,
|
|
batch_flags: 0,
|
|
pipeline_state,
|
|
terrain_base_only: base_only,
|
|
alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
|
|
});
|
|
}
|
|
shadow_receivers.push(shadow_triangle(fragment.positions, fragment.surface_mask.0));
|
|
}
|
|
|
|
Ok((mesh, shadow_receivers))
|
|
}
|
|
|
|
fn prepare_static_terrain_geometry(
|
|
terrain: &fparkan_terrain_format::LandMeshDocument,
|
|
source_mesh: VulkanStaticMesh,
|
|
terrain_world: &TerrainWorld,
|
|
foundation: Option<BasementFoundation>,
|
|
) -> Result<(VulkanStaticMesh, Vec<ShadowTriangle>, TerrainWorld), String> {
|
|
let Some(mut foundation) = foundation else {
|
|
return Ok((
|
|
source_mesh,
|
|
terrain_shadow_receivers(terrain)?,
|
|
terrain_world.clone(),
|
|
));
|
|
};
|
|
let (mesh, shadow_receivers) =
|
|
project_basement_foundation(terrain, &source_mesh, &mut foundation)?;
|
|
let terrain_query = terrain_world
|
|
.clone()
|
|
.with_basement_foundation(foundation)
|
|
.map_err(|error| format!("install canonical terrain query surfaces: {error}"))?;
|
|
Ok((mesh, shadow_receivers, terrain_query))
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct BasementBandVertex {
|
|
position: [f32; 3],
|
|
uv0: [i16; 2],
|
|
// The native callback leaves UV1 unwritten; zero is a deterministic
|
|
// assumption for matching generated vertices and is not native parity.
|
|
detail_uv: [i16; 2],
|
|
alpha: f32,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct BasementBandVertexGroup {
|
|
position: [f32; 3],
|
|
uv0: [i16; 2],
|
|
detail_uv: [i16; 2],
|
|
alpha: f32,
|
|
face_normal_sum: [i64; 3],
|
|
}
|
|
|
|
fn canonicalize_basement_band_vertices(
|
|
foundation: &mut BasementFoundation,
|
|
) -> Result<(Vec<Option<[[f32; 3]; 3]>>, Vec<Option<[[i16; 2]; 3]>>), String> {
|
|
let mut group_ids_by_fragment = vec![None; foundation.fragments.len()];
|
|
let mut uv0_words_by_fragment = vec![None; foundation.fragments.len()];
|
|
let mut groups = Vec::<BasementBandVertexGroup>::new();
|
|
let mut buckets = HashMap::<(i32, i32, i16, i16), Vec<usize>>::new();
|
|
|
|
// shortcut: installed-corpus probing found no exact UV0 weld key between
|
|
// generated bands and clipped source fragments. Extend the index to source
|
|
// vertices if a future corpus reaches that key.
|
|
for (fragment_index, fragment) in foundation.fragments.iter_mut().enumerate() {
|
|
if fragment.source_face.is_some() {
|
|
continue;
|
|
}
|
|
// Preserve the input UV words: canonicalizing a welded position can
|
|
// otherwise change this triangle's per-face UV origin.
|
|
let vertices = basement_band_uv_vertices(fragment.positions)?;
|
|
let mut group_ids = [0_usize; 3];
|
|
let mut uv0_words = [[0_i16; 2]; 3];
|
|
for (corner, vertex) in vertices.into_iter().enumerate() {
|
|
uv0_words[corner] = vertex.uv0;
|
|
let cell = basement_vertex_cell(vertex.position)?;
|
|
let mut candidates = Vec::new();
|
|
for dx in -1..=1 {
|
|
for dy in -1..=1 {
|
|
let (Some(x), Some(y)) = (cell.0.checked_add(dx), cell.1.checked_add(dy))
|
|
else {
|
|
continue;
|
|
};
|
|
if let Some(bucket) = buckets.get(&(x, y, vertex.uv0[0], vertex.uv0[1])) {
|
|
candidates.extend(bucket.iter().copied());
|
|
}
|
|
}
|
|
}
|
|
candidates.sort_unstable();
|
|
let group_index = candidates
|
|
.into_iter()
|
|
.find(|&index| {
|
|
let existing = groups[index];
|
|
existing.uv0 == vertex.uv0
|
|
&& existing.detail_uv == vertex.detail_uv
|
|
&& existing.alpha.to_bits() == vertex.alpha.to_bits()
|
|
&& basement_vertex_xy_distance(existing.position, vertex.position) < 0.01
|
|
})
|
|
.unwrap_or_else(|| {
|
|
let index = groups.len();
|
|
groups.push(BasementBandVertexGroup {
|
|
position: vertex.position,
|
|
uv0: vertex.uv0,
|
|
detail_uv: vertex.detail_uv,
|
|
alpha: vertex.alpha,
|
|
face_normal_sum: [0; 3],
|
|
});
|
|
buckets
|
|
.entry((cell.0, cell.1, vertex.uv0[0], vertex.uv0[1]))
|
|
.or_default()
|
|
.push(index);
|
|
index
|
|
});
|
|
fragment.positions[corner] = groups[group_index].position;
|
|
group_ids[corner] = group_index;
|
|
}
|
|
group_ids_by_fragment[fragment_index] = Some(group_ids);
|
|
uv0_words_by_fragment[fragment_index] = Some(uv0_words);
|
|
}
|
|
|
|
for (fragment_index, fragment) in foundation.fragments.iter().enumerate() {
|
|
if fragment.source_face.is_some() {
|
|
continue;
|
|
}
|
|
let Some(face_normal) = packed_basement_face_normal(fragment.positions)? else {
|
|
continue;
|
|
};
|
|
let group_ids = group_ids_by_fragment[fragment_index]
|
|
.ok_or_else(|| "basement band face has no welded vertex groups".to_string())?;
|
|
// The native normal postpass visits each indexed face once per vertex,
|
|
// even if malformed geometry gives two corners the same vertex index.
|
|
for group_index in group_ids.into_iter().collect::<HashSet<_>>() {
|
|
for axis in 0..3 {
|
|
groups[group_index].face_normal_sum[axis] += i64::from(face_normal[axis]);
|
|
}
|
|
}
|
|
}
|
|
|
|
let normalized = groups
|
|
.iter()
|
|
.map(|group| normalized_native_vertex_normal(group.face_normal_sum))
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
Ok((
|
|
group_ids_by_fragment
|
|
.into_iter()
|
|
.map(|group_ids| group_ids.map(|ids| ids.map(|group| normalized[group])))
|
|
.collect(),
|
|
uv0_words_by_fragment,
|
|
))
|
|
}
|
|
|
|
fn basement_vertex_cell(position: [f32; 3]) -> Result<(i32, i32), String> {
|
|
let x = (position[0] / 0.01).floor();
|
|
let y = (position[1] / 0.01).floor();
|
|
if !x.is_finite()
|
|
|| !y.is_finite()
|
|
|| x < i32::MIN as f32
|
|
|| x > i32::MAX as f32
|
|
|| y < i32::MIN as f32
|
|
|| y > i32::MAX as f32
|
|
{
|
|
return Err("basement band vertex is outside the native weld grid".to_string());
|
|
}
|
|
Ok((x as i32, y as i32))
|
|
}
|
|
|
|
fn basement_vertex_xy_distance(left: [f32; 3], right: [f32; 3]) -> f32 {
|
|
((left[0] - right[0]).powi(2) + (left[1] - right[1]).powi(2)).sqrt()
|
|
}
|
|
|
|
fn basement_band_uv_vertices(positions: [[f32; 3]; 3]) -> Result<[BasementBandVertex; 3], String> {
|
|
let raw_uv = positions.map(|point| [point[0] * 0.066_f32, point[1] * 0.066_f32]);
|
|
if !raw_uv.iter().flatten().all(|value| value.is_finite()) {
|
|
return Err("basement band UV is non-finite".to_string());
|
|
}
|
|
let origin = std::array::from_fn::<_, 2, _>(|axis| {
|
|
raw_uv
|
|
.iter()
|
|
.map(|uv| uv[axis])
|
|
.fold(f32::INFINITY, f32::min)
|
|
.floor()
|
|
});
|
|
let mut result = [BasementBandVertex {
|
|
position: [0.0; 3],
|
|
uv0: [0; 2],
|
|
detail_uv: [0; 2],
|
|
alpha: 1.0,
|
|
}; 3];
|
|
for index in 0..3 {
|
|
let mut uv0 = [0_i16; 2];
|
|
for axis in 0..2 {
|
|
let scaled = ((raw_uv[index][axis] - origin[axis]) * 1024.0_f32).round_ties_even();
|
|
if !scaled.is_finite() || scaled < i64::MIN as f32 || scaled > i64::MAX as f32 {
|
|
return Err("basement band UV exceeds the native conversion range".to_string());
|
|
}
|
|
// Native FISTP stores the low signed 16-bit word from its integer result.
|
|
uv0[axis] = (scaled as i64) as i16;
|
|
}
|
|
result[index] = BasementBandVertex {
|
|
position: positions[index],
|
|
uv0,
|
|
detail_uv: [0; 2],
|
|
alpha: 1.0,
|
|
};
|
|
}
|
|
Ok(result)
|
|
}
|
|
|
|
fn packed_basement_face_normal(positions: [[f32; 3]; 3]) -> Result<Option<[i16; 3]>, String> {
|
|
let [a, b, c] = positions.map(|point| point.map(f64::from));
|
|
let first = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
|
|
let second = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
|
|
let cross = [
|
|
first[1] * second[2] - first[2] * second[1],
|
|
first[2] * second[0] - first[0] * second[2],
|
|
first[0] * second[1] - first[1] * second[0],
|
|
];
|
|
let length = cross.iter().map(|value| value * value).sum::<f64>().sqrt();
|
|
if !length.is_finite() {
|
|
return Err("basement band face normal is non-finite".to_string());
|
|
}
|
|
if length == 0.0 {
|
|
return Ok(None);
|
|
}
|
|
Ok(Some(cross.map(|value| {
|
|
((value / length * 32767.0).round_ties_even()) as i16
|
|
})))
|
|
}
|
|
|
|
fn normalized_native_vertex_normal(face_normal_sum: [i64; 3]) -> Result<[f32; 3], String> {
|
|
let normal = face_normal_sum.map(|value| value as f64);
|
|
let length = normal.iter().map(|value| value * value).sum::<f64>().sqrt();
|
|
if !length.is_finite() {
|
|
return Err("basement band vertex has a non-finite native face-normal sum".to_string());
|
|
}
|
|
if length == 0.0 {
|
|
return Ok([0.0; 3]);
|
|
}
|
|
Ok(normal.map(|value| {
|
|
let packed = (value / length * 127.0)
|
|
.round_ties_even()
|
|
.clamp(-127.0, 127.0) as i8;
|
|
f32::from(packed) / 128.0
|
|
}))
|
|
}
|
|
|
|
fn basement_band_vertices(
|
|
positions: [[f32; 3]; 3],
|
|
uv0_words: &[[i16; 2]; 3],
|
|
normals: &[[f32; 3]; 3],
|
|
) -> [VulkanStaticVertex; 3] {
|
|
std::array::from_fn(|index| VulkanStaticVertex {
|
|
position: positions[index],
|
|
color: [1.0; 3],
|
|
normal: normals[index],
|
|
uv: uv0_words[index].map(|value| f32::from(value) / 1024.0),
|
|
// The native callback leaves the secondary UV words unwritten. The
|
|
// renderer uses zero here as a deterministic assumption; generated
|
|
// faces take the base-only path, so the detail coordinate is not sampled.
|
|
detail_uv: [0.0; 2],
|
|
overlay_alpha: 1.0,
|
|
})
|
|
}
|
|
|
|
fn static_preview_mesh_and_materials(
|
|
assets: &MissionAssets,
|
|
terrain: &TerrainWorld,
|
|
roots: &[MissionObjectDraft],
|
|
legacy_camera: Option<LegacyCameraCapture>,
|
|
static_animation_frame: Option<u16>,
|
|
static_material_phase: Option<u16>,
|
|
root: &std::path::Path,
|
|
land_msh_path: &str,
|
|
) -> Result<StaticPreviewScene, String> {
|
|
let terrain_mesh = terrain
|
|
.source_mesh()
|
|
.ok_or_else(|| "runtime terrain does not retain its validated source mesh".to_string())?;
|
|
let mut mesh = VulkanStaticMesh {
|
|
vertices: Vec::new(),
|
|
indices: Vec::new(),
|
|
draw_ranges: Vec::new(),
|
|
};
|
|
let mut native_handedness_by_range = HashMap::new();
|
|
let mut type16_present_ranges = HashSet::new();
|
|
let mut materials = Vec::new();
|
|
let mut material_animations = Vec::new();
|
|
let mut ambient_effect_slots = Vec::new();
|
|
let mut control_point_light_slots = Vec::new();
|
|
let mut control_sound_slots = Vec::new();
|
|
let mut unsupported_control_sounds = 0;
|
|
let mut effect_documents = HashMap::new();
|
|
let mut effect_materials = HashMap::new();
|
|
let mut component_pose_buffers_by_object = Vec::with_capacity(roots.len());
|
|
let mut component_mount_poses_by_object = Vec::with_capacity(roots.len());
|
|
let mut dynamic_component_geometry_by_object = Vec::with_capacity(roots.len());
|
|
let mut building_collision_bindings = Vec::new();
|
|
let mut collision_models =
|
|
HashMap::<fparkan_assets::AssetId<PreparedModel>, Arc<ModelAsset>>::new();
|
|
let mut control_animation_instances_by_object = Vec::with_capacity(roots.len());
|
|
let mut dynamic_shadow_casters = Vec::new();
|
|
let mut sun_occlusion_range_indices = Vec::new();
|
|
let mut shadow_casters = Vec::new();
|
|
let mut terrain_material_loader = StandaloneWearMaterialLoader::new(root);
|
|
let source_terrain_component = project_land_msh_to_static_mesh_in_world_space(terrain_mesh)
|
|
.map_err(|err| format!("project mission terrain for Vulkan: {err}"))?;
|
|
let footprint_sources = mission_basement_footprints(assets, roots)?;
|
|
let foundation = if footprint_sources.is_empty() {
|
|
None
|
|
} else {
|
|
let footprints = footprint_sources
|
|
.iter()
|
|
.map(|footprint| BasementFootprint {
|
|
outer: &footprint.outer,
|
|
inner: &footprint.inner,
|
|
flatten_inner_to_mean_outer_z: footprint.flatten_inner_to_mean_outer_z,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
Some(
|
|
build_basement_foundations(terrain_mesh, &footprints)
|
|
.map_err(|error| format!("prepare mission basement foundations: {error}"))?,
|
|
)
|
|
};
|
|
let effective_roots = foundation
|
|
.as_ref()
|
|
.map(|foundation| {
|
|
apply_basement_placement_deltas(roots, &footprint_sources, &foundation.placements)
|
|
})
|
|
.transpose()?;
|
|
// Every visual, mounted component, effect, and shadow caster below uses this
|
|
// same root transform, so the native delta is applied once to the hierarchy.
|
|
let roots = effective_roots.as_deref().unwrap_or(roots);
|
|
let (terrain_component, shadow_receivers, terrain_query) = prepare_static_terrain_geometry(
|
|
terrain_mesh,
|
|
source_terrain_component,
|
|
terrain,
|
|
foundation,
|
|
)?;
|
|
let terrain_materials = static_preview_terrain_base_materials(
|
|
&mut terrain_material_loader,
|
|
root,
|
|
land_msh_path,
|
|
&terrain_component,
|
|
&mut materials,
|
|
&mut material_animations,
|
|
static_material_phase,
|
|
)?;
|
|
append_static_preview_component(
|
|
&mut mesh,
|
|
terrain_component,
|
|
&terrain_materials,
|
|
Some(&mut material_animations),
|
|
)?;
|
|
let mut mesh_components = 0;
|
|
for (object_index, mission_root) in roots.iter().enumerate() {
|
|
let visual_ids = assets.visuals_for_object(object_index);
|
|
let records = &mission_root.unit_components;
|
|
let tree = if records.is_empty() {
|
|
None
|
|
} else {
|
|
Some(unit_component_tree(records).map_err(|error| {
|
|
format!("decode unit component tree for mission object {object_index}: {error}")
|
|
})?)
|
|
};
|
|
if let Some(tree) = tree.as_ref() {
|
|
if tree.len() != visual_ids.len() {
|
|
return Err(format!(
|
|
"mission object {object_index} unit tree has {} records but {} visuals",
|
|
tree.len(),
|
|
visual_ids.len()
|
|
));
|
|
}
|
|
}
|
|
let component_refs = visual_ids
|
|
.iter()
|
|
.map(|visual_id| {
|
|
let visual = assets.visual_by_id(*visual_id).ok_or_else(|| {
|
|
format!(
|
|
"static preview root {object_index} references unknown visual {visual_id:?}"
|
|
)
|
|
})?;
|
|
let model = visual
|
|
.model_id
|
|
.map(|model_id| {
|
|
assets.model_by_id(model_id).ok_or_else(|| {
|
|
format!(
|
|
"static preview visual {visual_id:?} references unknown model {model_id:?}"
|
|
)
|
|
})
|
|
})
|
|
.transpose()?;
|
|
Ok((visual, model))
|
|
})
|
|
.collect::<Result<Vec<_>, String>>()?;
|
|
let component_controls = assets.object_component_controls.get(object_index);
|
|
if component_controls.is_some_and(|controls| controls.len() != component_refs.len()) {
|
|
return Err(format!(
|
|
"mission object {object_index} has {} visuals but {} component controls",
|
|
component_refs.len(),
|
|
component_controls.map_or(0, Vec::len)
|
|
));
|
|
}
|
|
let root_native_kind = component_refs
|
|
.first()
|
|
.and_then(|(visual, _)| native_shadow_kind(visual.source_type));
|
|
let root_sun_eligible = native_sun_object_kind_eligible(
|
|
component_refs
|
|
.first()
|
|
.and_then(|(visual, _)| visual.source_type),
|
|
);
|
|
let mut root_shadow_triangles = Vec::new();
|
|
let mut root_shadow_spheres = Vec::new();
|
|
let mut root_shadow_sphere_sources = Vec::new();
|
|
let mut root_shadow_draw_range_indices = Vec::new();
|
|
let mut component_mount_poses = vec![None; component_refs.len()];
|
|
let mut component_pose_buffers = vec![None; component_refs.len()];
|
|
let mut dynamic_component_geometry = vec![None; component_refs.len()];
|
|
let mut control_animation_instances = vec![None; component_refs.len()];
|
|
for (component_index, (visual, model)) in component_refs.iter().enumerate() {
|
|
let should_render = tree.as_ref().map_or(true, |_| {
|
|
unit_component_is_visible(component_index, visual.source_type)
|
|
});
|
|
let control = component_controls
|
|
.and_then(|controls| controls.get(component_index))
|
|
.and_then(Option::as_ref);
|
|
let Some(model) = *model else {
|
|
if legacy_camera.is_none() && root_native_kind == Some(3) && should_render {
|
|
return Err(format!(
|
|
"building collision object {object_index} component {component_index} has no model"
|
|
));
|
|
}
|
|
if component_index == 0 {
|
|
if let Some(control) = control {
|
|
append_initial_control_effect_slots(
|
|
root,
|
|
mission_root,
|
|
object_index,
|
|
component_index,
|
|
control,
|
|
None,
|
|
None,
|
|
&mut mesh,
|
|
&mut materials,
|
|
&mut material_animations,
|
|
&mut effect_documents,
|
|
&mut effect_materials,
|
|
&mut ambient_effect_slots,
|
|
&mut control_point_light_slots,
|
|
&mut control_sound_slots,
|
|
&mut unsupported_control_sounds,
|
|
)?;
|
|
}
|
|
}
|
|
continue;
|
|
};
|
|
let component_bindings = component_controls
|
|
.and_then(|controls| controls.get(component_index))
|
|
.and_then(Option::as_ref)
|
|
.map_or(&[][..], |control| control.bindings.as_slice());
|
|
if static_animation_frame.is_none() {
|
|
if let Some(control) = control {
|
|
control_animation_instances[component_index] = Some(ControlAnimationInstance {
|
|
control: control.clone(),
|
|
model: model.validated.clone(),
|
|
gate_values: initial_control_gate_values(control),
|
|
state: ControlInstanceState::default(),
|
|
last_sampled_node_times: Vec::new(),
|
|
reported_unsupported_fallback: false,
|
|
});
|
|
}
|
|
}
|
|
component_pose_buffers[component_index] = static_preview_component_pose_buffer(
|
|
&model.validated,
|
|
component_bindings,
|
|
static_animation_frame,
|
|
)?;
|
|
let timeline_moves_component = static_animation_frame.is_none()
|
|
&& control_tree_chain_has_tag21(
|
|
component_index,
|
|
tree.as_deref(),
|
|
component_controls.map_or(&[][..], |controls| controls.as_slice()),
|
|
);
|
|
// Native AniMesh only resolves global MSH node sockets for EXTO
|
|
// geometry. Internal INTO components use controller/equipment
|
|
// slots, whose `parent_or_link` values are not MSH node indices.
|
|
// Do not ask the MSH hierarchy to resolve hidden internal parts.
|
|
let mount_pose = if should_render {
|
|
if let Some(tree) = tree.as_ref() {
|
|
let record = records.get(component_index).ok_or_else(|| {
|
|
format!("unit component {component_index} is outside decoded records")
|
|
})?;
|
|
let parent_index = tree[component_index].parent_index;
|
|
match parent_index {
|
|
None => None,
|
|
Some(parent_index) => {
|
|
let parent_visible = component_refs.get(parent_index).is_some_and(
|
|
|(parent_visual, _)| {
|
|
unit_component_is_visible(
|
|
parent_index,
|
|
parent_visual.source_type,
|
|
)
|
|
},
|
|
);
|
|
if !parent_visible {
|
|
None
|
|
} else {
|
|
let parent_model = component_refs
|
|
.get(parent_index)
|
|
.and_then(|(_, model)| *model)
|
|
.ok_or_else(|| {
|
|
format!(
|
|
"unit component {component_index} parent {parent_index} has no model for mount socket"
|
|
)
|
|
})?;
|
|
let parent_hierarchy = component_pose_buffers
|
|
.get(parent_index)
|
|
.and_then(Option::as_ref);
|
|
let parent_is_mounted =
|
|
component_mount_poses[parent_index].is_some();
|
|
let socket =
|
|
usize::try_from(record.parent_or_link).map_err(|_| {
|
|
format!(
|
|
"unit component {component_index} has negative mount socket {}",
|
|
record.parent_or_link
|
|
)
|
|
})?;
|
|
let parent_socket = match parent_hierarchy {
|
|
Some(hierarchy) if parent_is_mounted => {
|
|
node38_pose_relative_to_root_from_hierarchy(
|
|
hierarchy, socket,
|
|
)
|
|
}
|
|
Some(hierarchy) => {
|
|
node38_pose_from_hierarchy(hierarchy, socket)
|
|
}
|
|
None if parent_is_mounted => node38_pose_relative_to_root(
|
|
&parent_model.validated,
|
|
static_animation_frame,
|
|
socket,
|
|
),
|
|
None => node38_pose(
|
|
&parent_model.validated,
|
|
static_animation_frame,
|
|
socket,
|
|
),
|
|
}
|
|
.ok_or_else(|| {
|
|
format!(
|
|
"unit component {component_index} mount socket {socket} is absent from parent {parent_index} (parent source={:?}, node_count={}, slots={}, animation={})",
|
|
parent_model.source,
|
|
parent_model.validated.node_count,
|
|
parent_model.validated.slots.len(),
|
|
parent_model.validated.animation.is_some(),
|
|
)
|
|
})?;
|
|
let parent_root_pose = component_mount_poses[parent_index]
|
|
.unwrap_or(VulkanNodePose {
|
|
translation: [0.0; 3],
|
|
rotation: [0.0, 0.0, 0.0, 1.0],
|
|
});
|
|
Some(compose_unit_pose(parent_root_pose, parent_socket))
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
if let Some(mount_pose) = mount_pose {
|
|
component_mount_poses[component_index] = Some(mount_pose);
|
|
}
|
|
// External tree components have a recovered mount pose; flat
|
|
// mission components and the root use the mission pose. Hidden
|
|
// INTO slots use controller links rather than MSH sockets, so
|
|
// their Control owner transform remains unresolved here.
|
|
let owner_pose_available =
|
|
component_index == 0 || tree.is_none() || mount_pose.is_some();
|
|
if owner_pose_available {
|
|
if let Some(control) = control {
|
|
append_initial_control_effect_slots(
|
|
root,
|
|
mission_root,
|
|
object_index,
|
|
component_index,
|
|
control,
|
|
component_pose_buffers[component_index].as_ref(),
|
|
mount_pose,
|
|
&mut mesh,
|
|
&mut materials,
|
|
&mut material_animations,
|
|
&mut effect_documents,
|
|
&mut effect_materials,
|
|
&mut ambient_effect_slots,
|
|
&mut control_point_light_slots,
|
|
&mut control_sound_slots,
|
|
&mut unsupported_control_sounds,
|
|
)?;
|
|
}
|
|
}
|
|
|
|
let transform = LegacyIron3dEulerTransform {
|
|
translation: mission_root.position,
|
|
orientation_radians: mission_root.orientation_raw,
|
|
};
|
|
if legacy_camera.is_none() && root_native_kind == Some(3) && should_render {
|
|
let shared_model = collision_models
|
|
.entry(model.id)
|
|
.or_insert_with(|| Arc::new(model.validated.clone()))
|
|
.clone();
|
|
building_collision_bindings.push(building_collision_binding(
|
|
object_index,
|
|
component_index,
|
|
format!("{:?}", model.source),
|
|
shared_model,
|
|
transform,
|
|
mission_root.scale,
|
|
mount_pose.is_some(),
|
|
)?);
|
|
}
|
|
if should_render {
|
|
let pose_buffer = component_pose_buffers[component_index].as_ref();
|
|
let mut component = match (pose_buffer, mount_pose, static_animation_frame) {
|
|
(Some(poses), Some(mount), _) => {
|
|
project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount(
|
|
&model.validated,
|
|
transform,
|
|
mission_root.scale,
|
|
poses,
|
|
mount,
|
|
)
|
|
}
|
|
(Some(poses), None, _) => {
|
|
project_msh_to_static_mesh_in_world_space_with_node_pose_buffer(
|
|
&model.validated,
|
|
transform,
|
|
mission_root.scale,
|
|
poses,
|
|
)
|
|
}
|
|
(None, Some(mount), Some(frame)) => {
|
|
project_msh_to_static_mesh_in_world_space_with_node_sampled_poses_and_mount(
|
|
&model.validated,
|
|
transform,
|
|
mission_root.scale,
|
|
frame,
|
|
mount,
|
|
)
|
|
}
|
|
(None, Some(mount), None) => {
|
|
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses_and_mount(
|
|
&model.validated,
|
|
transform,
|
|
mission_root.scale,
|
|
mount,
|
|
)
|
|
}
|
|
(None, None, Some(frame)) => {
|
|
project_msh_to_static_mesh_in_world_space_with_node_sampled_poses(
|
|
&model.validated,
|
|
transform,
|
|
mission_root.scale,
|
|
frame,
|
|
)
|
|
}
|
|
(None, None, None) => {
|
|
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses(
|
|
&model.validated,
|
|
transform,
|
|
mission_root.scale,
|
|
)
|
|
}
|
|
}
|
|
.map_err(|err| format!("project mission MSH for Vulkan: {err}"))?;
|
|
let component_handedness = native_msh_handedness(transform, mission_root.scale);
|
|
for range in &mut component.draw_ranges {
|
|
range.pipeline_state.cull =
|
|
native_msh_cull_mode(range.batch_flags, component_handedness);
|
|
}
|
|
let component_shadow_triangles = static_mesh_shadow_triangles(&component);
|
|
if root_native_kind.is_some() {
|
|
root_shadow_triangles.extend(component_shadow_triangles);
|
|
let node1_pose = mount_pose.and_then(|_| match pose_buffer {
|
|
Some(poses) => node38_pose_relative_to_root_from_hierarchy(poses, 1),
|
|
None => node38_pose_relative_to_root(
|
|
&model.validated,
|
|
static_animation_frame,
|
|
1,
|
|
),
|
|
});
|
|
if let Some(sphere) = mounted_shadow_sphere(
|
|
model.validated.bounding_sphere_center,
|
|
model.validated.bounding_sphere_radius,
|
|
mount_pose,
|
|
node1_pose,
|
|
) {
|
|
root_shadow_spheres.push(sphere);
|
|
root_shadow_sphere_sources.push(DynamicShadowSphereSource {
|
|
component_index,
|
|
center: model.validated.bounding_sphere_center,
|
|
radius: model.validated.bounding_sphere_radius,
|
|
mounted: mount_pose.is_some(),
|
|
});
|
|
}
|
|
}
|
|
let selector_remap = static_preview_component_materials(
|
|
assets,
|
|
visual,
|
|
&component,
|
|
static_material_phase,
|
|
&mut materials,
|
|
&mut material_animations,
|
|
)?;
|
|
let lightmap_textures = static_preview_lightmap_textures(assets, visual)?;
|
|
let range_start = mesh.draw_ranges.len();
|
|
let vertex_start = mesh.vertices.len();
|
|
let vertex_count = component.vertices.len();
|
|
append_static_preview_component_with_lightmaps(
|
|
&mut mesh,
|
|
component,
|
|
&selector_remap,
|
|
Some(&mut materials),
|
|
&lightmap_textures,
|
|
Some(&mut material_animations),
|
|
)?;
|
|
for range_index in range_start..mesh.draw_ranges.len() {
|
|
native_handedness_by_range.insert(range_index, component_handedness);
|
|
if model.validated.type16_present {
|
|
type16_present_ranges.insert(range_index);
|
|
}
|
|
}
|
|
if root_native_kind.is_some() {
|
|
root_shadow_draw_range_indices.extend(range_start..mesh.draw_ranges.len());
|
|
}
|
|
if timeline_moves_component {
|
|
let parent_mount = tree
|
|
.as_ref()
|
|
.and_then(|nodes| nodes.get(component_index))
|
|
.and_then(|node| node.parent_index)
|
|
.filter(|parent_index| {
|
|
component_mount_poses[*parent_index].is_some()
|
|
|| component_refs.get(*parent_index).is_some_and(
|
|
|(parent_visual, _)| {
|
|
unit_component_is_visible(
|
|
*parent_index,
|
|
parent_visual.source_type,
|
|
)
|
|
},
|
|
)
|
|
})
|
|
.map(|parent_index| {
|
|
let socket = usize::try_from(records[component_index].parent_or_link)
|
|
.expect("mount socket was validated above");
|
|
(parent_index, socket)
|
|
});
|
|
dynamic_component_geometry[component_index] = Some(DynamicComponentGeometry {
|
|
model: model.validated.clone(),
|
|
root_transform: transform,
|
|
owner_scale: mission_root.scale,
|
|
parent_mount,
|
|
vertex_start,
|
|
vertex_count,
|
|
});
|
|
}
|
|
if root_sun_eligible {
|
|
sun_occlusion_range_indices.extend(range_start..mesh.draw_ranges.len());
|
|
}
|
|
mesh_components += 1;
|
|
}
|
|
}
|
|
if let Some(native_kind) =
|
|
root_native_kind.filter(|kind| native_shadow_caster_enabled(*kind))
|
|
{
|
|
let caster_id = u32::try_from(shadow_casters.len())
|
|
.map_err(|_| "shadow caster id exceeds u32".to_string())?;
|
|
let root_transform = LegacyIron3dEulerTransform {
|
|
translation: mission_root.position,
|
|
orientation_radians: mission_root.orientation_raw,
|
|
};
|
|
if let Some(caster) = aggregate_shadow_caster(
|
|
caster_id,
|
|
native_kind,
|
|
root_transform,
|
|
mission_root.scale,
|
|
root_shadow_triangles,
|
|
&root_shadow_spheres,
|
|
) {
|
|
if dynamic_component_geometry.iter().any(Option::is_some) {
|
|
let component_draw_range_indices = root_shadow_draw_range_indices
|
|
.into_iter()
|
|
.filter(|range_index| {
|
|
mesh.draw_ranges
|
|
.get(*range_index)
|
|
.is_some_and(|range| range.index_count != 0)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
if !component_draw_range_indices.is_empty() {
|
|
dynamic_shadow_casters.push(DynamicShadowCaster {
|
|
caster_index: shadow_casters.len(),
|
|
object_index,
|
|
root_transform,
|
|
owner_scale: mission_root.scale,
|
|
initial_sphere: caster.sphere,
|
|
component_draw_range_indices,
|
|
component_spheres: root_shadow_sphere_sources.clone(),
|
|
});
|
|
}
|
|
}
|
|
shadow_casters.push(caster);
|
|
}
|
|
}
|
|
component_pose_buffers_by_object.push(component_pose_buffers);
|
|
component_mount_poses_by_object.push(component_mount_poses);
|
|
dynamic_component_geometry_by_object.push(dynamic_component_geometry);
|
|
control_animation_instances_by_object.push(control_animation_instances);
|
|
}
|
|
if mesh_components == 0 {
|
|
return Err("selected static preview roots have no mesh-backed visual".to_string());
|
|
}
|
|
let free_camera = if legacy_camera.is_none() {
|
|
Some(FreeFlightCamera::from_mesh_and_terrain(&mesh, terrain)?)
|
|
} else {
|
|
None
|
|
};
|
|
let captured_viewport = legacy_camera
|
|
.as_ref()
|
|
.map(LegacyCameraCapture::captured_viewport)
|
|
.transpose()?;
|
|
let view_camera = if let Some(capture) = legacy_camera.as_ref() {
|
|
capture.preview_camera_frame()?
|
|
} else {
|
|
free_camera
|
|
.map(|camera| camera.preview_frame(16.0 / 9.0))
|
|
.ok_or_else(|| "static preview camera is unavailable".to_string())?
|
|
};
|
|
let camera = view_camera.vulkan_camera();
|
|
let shadow_material_index = append_shadow_material(&mut materials)?;
|
|
Ok(StaticPreviewScene {
|
|
mesh,
|
|
native_handedness_by_range,
|
|
type16_present_ranges,
|
|
materials,
|
|
material_animations,
|
|
ambient_effect_slots,
|
|
control_point_light_slots,
|
|
control_sound_slots,
|
|
unsupported_control_sounds,
|
|
component_pose_buffers: component_pose_buffers_by_object,
|
|
component_mount_poses: component_mount_poses_by_object,
|
|
dynamic_component_geometry: dynamic_component_geometry_by_object,
|
|
building_collision_bindings,
|
|
control_animation_instances: control_animation_instances_by_object,
|
|
dynamic_shadow_casters,
|
|
sun_occlusion_range_indices,
|
|
shadow_casters,
|
|
shadow_receivers,
|
|
shadow_material_index,
|
|
terrain: Arc::new(terrain_query),
|
|
camera,
|
|
view_camera,
|
|
captured_viewport,
|
|
free_camera,
|
|
camera_mode: if legacy_camera.is_some() {
|
|
"legacy-d3d7-capture"
|
|
} else {
|
|
"free-flight"
|
|
},
|
|
mesh_components,
|
|
terrain_components: 1,
|
|
})
|
|
}
|
|
|
|
fn static_preview_terrain_base_materials(
|
|
loader: &mut StandaloneWearMaterialLoader,
|
|
root: &std::path::Path,
|
|
land_msh_path: &str,
|
|
mesh: &VulkanStaticMesh,
|
|
materials: &mut Vec<VulkanStaticMaterial>,
|
|
material_animations: &mut Vec<MaterialAnimationBinding>,
|
|
static_material_phase: Option<u16>,
|
|
) -> Result<Vec<(u16, u16)>, String> {
|
|
let land_path = root.join(land_msh_path);
|
|
let land_parent = land_path
|
|
.parent()
|
|
.ok_or_else(|| format!("terrain mesh has no parent path: {}", land_path.display()))?;
|
|
let land1_path = land_parent.join("Land1.wea");
|
|
let land2_path = land_parent.join("Land2.wea");
|
|
let mut packed_tags = mesh
|
|
.draw_ranges
|
|
.iter()
|
|
.map(|range| range.material_index)
|
|
.collect::<Vec<_>>();
|
|
packed_tags.sort_unstable();
|
|
packed_tags.dedup();
|
|
packed_tags
|
|
.into_iter()
|
|
.map(|packed_tag| {
|
|
let layers = TerrainMaterialLayers::from_packed_tag(packed_tag);
|
|
let base_document = loader
|
|
.material_document(&land1_path, layers.base_selection().material_index)
|
|
.map_err(|err| format!("resolve terrain base MAT0 document: {err}"))?;
|
|
let detail_document = loader
|
|
.material_document(&land2_path, layers.detail_base_selection().material_index)
|
|
.map_err(|err| format!("resolve terrain detail MAT0 document: {err}"))?;
|
|
let overlay_selection = layers.overlay_selection();
|
|
let overlay_detail_selection = layers.detail_overlay_selection();
|
|
let overlay_document = overlay_selection
|
|
.map(|selection| loader.material_document(&land1_path, selection.material_index))
|
|
.transpose()
|
|
.map_err(|err| format!("resolve terrain overlay MAT0 document: {err}"))?;
|
|
let overlay_detail_document = overlay_detail_selection
|
|
.map(|selection| loader.material_document(&land2_path, selection.material_index))
|
|
.transpose()
|
|
.map_err(|err| format!("resolve terrain overlay detail MAT0 document: {err}"))?;
|
|
let base_phases = load_standalone_phase_set(
|
|
loader,
|
|
&land1_path,
|
|
layers.base_selection().material_index,
|
|
&base_document,
|
|
)?;
|
|
let detail_phases = load_standalone_phase_set(
|
|
loader,
|
|
&land2_path,
|
|
layers.detail_base_selection().material_index,
|
|
&detail_document,
|
|
)?;
|
|
let overlay_phases = overlay_selection
|
|
.map(|selection| {
|
|
load_standalone_phase_set(
|
|
loader,
|
|
&land1_path,
|
|
selection.material_index,
|
|
overlay_document.as_ref().expect("overlay document"),
|
|
)
|
|
})
|
|
.transpose()?;
|
|
let overlay_detail_phases = overlay_detail_selection
|
|
.map(|selection| {
|
|
load_standalone_phase_set(
|
|
loader,
|
|
&land2_path,
|
|
selection.material_index,
|
|
overlay_detail_document
|
|
.as_ref()
|
|
.expect("overlay detail document"),
|
|
)
|
|
})
|
|
.transpose()?;
|
|
let phase_count = base_phases.len();
|
|
if phase_count == 0 || detail_phases.is_empty() {
|
|
return Err(format!(
|
|
"terrain material tag 0x{packed_tag:04x} has incomplete phase tables"
|
|
));
|
|
}
|
|
if overlay_phases.as_ref().is_some_and(Vec::is_empty)
|
|
|| overlay_detail_phases.as_ref().is_some_and(Vec::is_empty)
|
|
{
|
|
return Err(format!(
|
|
"terrain material tag 0x{packed_tag:04x} has an empty optional phase table"
|
|
));
|
|
}
|
|
let initial_phase_index =
|
|
usize::from(static_material_phase.unwrap_or(0)).min(phase_count.saturating_sub(1));
|
|
let initial_detail_phase_index = usize::from(static_material_phase.unwrap_or(0))
|
|
.min(detail_phases.len().saturating_sub(1));
|
|
let initial_overlay_phase_index = overlay_phases.as_ref().map(|phases| {
|
|
usize::from(static_material_phase.unwrap_or(0)).min(phases.len().saturating_sub(1))
|
|
});
|
|
let initial_overlay_detail_phase_index = overlay_detail_phases.as_ref().map(|phases| {
|
|
usize::from(static_material_phase.unwrap_or(0)).min(phases.len().saturating_sub(1))
|
|
});
|
|
let mut phase_material_indices = Vec::with_capacity(phase_count);
|
|
for phase_index in 0..phase_count {
|
|
let (base, base_phase) = &base_phases[phase_index];
|
|
// The base draw selector follows the base MAT0 timeline. The
|
|
// other descriptor stages start at their own initial phase;
|
|
// frame-time sampling below can then advance them separately.
|
|
let (detail, detail_phase) = &detail_phases[initial_detail_phase_index];
|
|
let overlay = overlay_phases
|
|
.as_ref()
|
|
.zip(initial_overlay_phase_index)
|
|
.map(|(phases, phase_index)| &phases[phase_index]);
|
|
let overlay_detail = overlay_detail_phases
|
|
.as_ref()
|
|
.zip(initial_overlay_detail_phase_index)
|
|
.map(|(phases, phase_index)| &phases[phase_index]);
|
|
let preview_selector = u16::try_from(materials.len()).map_err(|_| {
|
|
"static preview exceeds the available 16-bit material selector space"
|
|
.to_string()
|
|
})?;
|
|
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()),
|
|
overlay_detail_texture: overlay_detail.map(|(texture, _)| texture.clone()),
|
|
diffuse_alpha: base_phase.diffuse_alpha,
|
|
overlay_diffuse_alpha: overlay.map(|(_, phase)| phase.diffuse_alpha),
|
|
directional_rgb: base_phase.directional_rgb,
|
|
additive_rgb: base_phase.additive_rgb,
|
|
uv_transform: base_phase.page_uv_transform,
|
|
overlay_directional_rgb: overlay.map(|(_, phase)| phase.directional_rgb),
|
|
overlay_additive_rgb: overlay.map(|(_, phase)| phase.additive_rgb),
|
|
specular_rgb: base_phase.specular_rgb,
|
|
specular_power: base_phase.power,
|
|
overlay_specular_rgb: overlay.map(|(_, phase)| phase.specular_rgb),
|
|
overlay_specular_power: overlay.map(|(_, phase)| phase.power),
|
|
detail_uv_transform: detail_phase.page_uv_transform,
|
|
overlay_uv_transform: overlay.map(|(_, phase)| phase.page_uv_transform),
|
|
overlay_detail_uv_transform: overlay_detail
|
|
.map(|(_, phase)| phase.page_uv_transform),
|
|
unlit: false,
|
|
sky_nebula_stars: false,
|
|
lightmap_mode: false,
|
|
sky_far_depth: false,
|
|
screen_tl: false,
|
|
});
|
|
phase_material_indices.push(usize::from(preview_selector));
|
|
}
|
|
let base_uvs = phase_uv_transforms_from_standalone(
|
|
loader,
|
|
&land1_path,
|
|
layers.base_selection().material_index,
|
|
&base_document,
|
|
)?;
|
|
let detail_uvs = phase_uv_transforms_from_standalone(
|
|
loader,
|
|
&land2_path,
|
|
layers.detail_base_selection().material_index,
|
|
&detail_document,
|
|
)?;
|
|
let overlay_uvs = match (&overlay_document, layers.overlay_selection()) {
|
|
(Some(document), Some(selection)) => phase_uv_transforms_from_standalone(
|
|
loader,
|
|
&land1_path,
|
|
selection.material_index,
|
|
document,
|
|
)?,
|
|
_ => Vec::new(),
|
|
};
|
|
let overlay_detail_uvs =
|
|
match (&overlay_detail_document, layers.detail_overlay_selection()) {
|
|
(Some(document), Some(selection)) => phase_uv_transforms_from_standalone(
|
|
loader,
|
|
&land2_path,
|
|
selection.material_index,
|
|
document,
|
|
)?,
|
|
_ => Vec::new(),
|
|
};
|
|
let stage_textures = MaterialStageTextures {
|
|
detail: Some(
|
|
detail_phases
|
|
.iter()
|
|
.map(|(texture, _)| texture.clone())
|
|
.collect(),
|
|
),
|
|
overlay: overlay_phases
|
|
.as_ref()
|
|
.map(|phases| phases.iter().map(|(texture, _)| texture.clone()).collect()),
|
|
overlay_detail: overlay_detail_phases
|
|
.as_ref()
|
|
.map(|phases| phases.iter().map(|(texture, _)| texture.clone()).collect()),
|
|
applied_material_phases: phase_material_indices
|
|
.iter()
|
|
.copied()
|
|
.map(|material_index| {
|
|
(
|
|
material_index,
|
|
[
|
|
initial_detail_phase_index,
|
|
initial_overlay_phase_index.unwrap_or(0),
|
|
initial_overlay_detail_phase_index.unwrap_or(0),
|
|
],
|
|
)
|
|
})
|
|
.collect(),
|
|
};
|
|
material_animations.push(MaterialAnimationBinding {
|
|
initial_phase_index: static_material_phase
|
|
.map_or(0, usize::from)
|
|
.min(base_document.phases.len().saturating_sub(1)),
|
|
normalized_phase_override: None,
|
|
ambient_fx_material_alpha: false,
|
|
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,
|
|
animation_block_index: 0,
|
|
wear_row_start_ms: 0,
|
|
random_state: packed_tag as u32,
|
|
frozen_phase_index: static_material_phase.map(usize::from),
|
|
phase_uv_transforms: base_uvs,
|
|
},
|
|
detail: Some(MaterialPhaseBinding {
|
|
document: detail_document,
|
|
animation_block_index: 0,
|
|
wear_row_start_ms: 0,
|
|
random_state: packed_tag as u32 ^ 0x5A5A_A5A5,
|
|
frozen_phase_index: static_material_phase.map(usize::from),
|
|
phase_uv_transforms: detail_uvs,
|
|
}),
|
|
overlay: overlay_document.map(|document| MaterialPhaseBinding {
|
|
document,
|
|
animation_block_index: 0,
|
|
wear_row_start_ms: 0,
|
|
random_state: packed_tag as u32 ^ 0xA5A5_5A5A,
|
|
frozen_phase_index: static_material_phase.map(usize::from),
|
|
phase_uv_transforms: overlay_uvs,
|
|
}),
|
|
overlay_detail: overlay_detail_document.map(|document| MaterialPhaseBinding {
|
|
document,
|
|
animation_block_index: 0,
|
|
wear_row_start_ms: 0,
|
|
random_state: packed_tag as u32 ^ 0xC3C3_3C3C,
|
|
frozen_phase_index: static_material_phase.map(usize::from),
|
|
phase_uv_transforms: overlay_detail_uvs,
|
|
}),
|
|
stage_textures: Some(stage_textures),
|
|
});
|
|
let preview_selector =
|
|
u16::try_from(materials.len() - phase_count + initial_phase_index)
|
|
.map_err(|_| "terrain material selector exceeds u16".to_string())?;
|
|
Ok((packed_tag, preview_selector))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn static_preview_component_materials(
|
|
assets: &MissionAssets,
|
|
visual: &PreparedVisual,
|
|
mesh: &VulkanStaticMesh,
|
|
static_material_phase: Option<u16>,
|
|
materials: &mut Vec<VulkanStaticMaterial>,
|
|
material_animations: &mut Vec<MaterialAnimationBinding>,
|
|
) -> Result<Vec<(u16, u16)>, String> {
|
|
let mut source_selectors = mesh
|
|
.draw_ranges
|
|
.iter()
|
|
.map(|range| range.material_index)
|
|
.collect::<Vec<_>>();
|
|
source_selectors.sort_unstable();
|
|
source_selectors.dedup();
|
|
source_selectors
|
|
.into_iter()
|
|
.map(|source_selector| {
|
|
let material_id = visual
|
|
.material_ids
|
|
.get(usize::from(source_selector))
|
|
.ok_or_else(|| {
|
|
format!(
|
|
"static preview MSH batch selector {source_selector} has no prepared WEAR material"
|
|
)
|
|
})?;
|
|
let material = assets.material_by_id(*material_id).ok_or_else(|| {
|
|
format!("static preview prepared material {source_selector} is unavailable")
|
|
})?;
|
|
let initial_phase_index = usize::from(static_material_phase.unwrap_or(0))
|
|
.min(material.mat0.phases.len().saturating_sub(1));
|
|
if material.mat0.phases.is_empty() {
|
|
return Err(format!(
|
|
"static preview material {source_selector} has no MAT0 phases"
|
|
));
|
|
}
|
|
let mut phase_material_indices = Vec::with_capacity(material.mat0.phases.len());
|
|
for phase in &material.mat0.phases {
|
|
let texture_name = phase_texture_name(phase);
|
|
let prepared_texture = texture_name.as_ref().and_then(|name| {
|
|
assets.textures.iter().find(|texture| {
|
|
texture.usage == PreparedTextureUsage::Diffuse
|
|
&& texture.source.name == *name
|
|
})
|
|
});
|
|
// Empty MAT0 texture names remain valid untextured materials.
|
|
let texture = prepared_texture
|
|
.map(|texture| {
|
|
prepared_texture_to_vulkan(texture, "static preview diffuse texture")
|
|
})
|
|
.transpose()?
|
|
.unwrap_or_else(|| solid_environment_texture([255, 255, 255, 255]));
|
|
let coefficients = phase.coefficients();
|
|
let uv_transform = if let Some(texture) = prepared_texture {
|
|
page_uv_transform(
|
|
texture.texm.width(),
|
|
texture.texm.height(),
|
|
&texture.texm.page_rects(),
|
|
coefficients.page_index,
|
|
)?
|
|
} else {
|
|
[0.0, 0.0, 1.0, 1.0]
|
|
};
|
|
let preview_selector = u16::try_from(materials.len()).map_err(|_| {
|
|
"static preview exceeds the available 16-bit material selector space"
|
|
.to_string()
|
|
})?;
|
|
materials.push(VulkanStaticMaterial {
|
|
material_index: preview_selector,
|
|
pipeline_category: material.pipeline_category,
|
|
texture,
|
|
detail_texture: None,
|
|
overlay_texture: None,
|
|
overlay_detail_texture: None,
|
|
diffuse_alpha: coefficients.opacity,
|
|
overlay_diffuse_alpha: None,
|
|
directional_rgb: coefficients.directional_rgb,
|
|
additive_rgb: coefficients.additive_rgb,
|
|
uv_transform,
|
|
overlay_directional_rgb: None,
|
|
overlay_additive_rgb: None,
|
|
specular_rgb: coefficients.specular_rgb,
|
|
specular_power: coefficients.power,
|
|
overlay_specular_rgb: None,
|
|
overlay_specular_power: None,
|
|
detail_uv_transform: [0.0, 0.0, 1.0, 1.0],
|
|
overlay_uv_transform: None,
|
|
overlay_detail_uv_transform: None,
|
|
unlit: false,
|
|
sky_nebula_stars: false,
|
|
lightmap_mode: false,
|
|
sky_far_depth: false,
|
|
screen_tl: false,
|
|
});
|
|
phase_material_indices.push(usize::from(preview_selector));
|
|
}
|
|
let preview_selector = u16::try_from(
|
|
*phase_material_indices
|
|
.get(initial_phase_index)
|
|
.ok_or_else(|| "MAT0 phase variant table is empty".to_string())?,
|
|
)
|
|
.map_err(|_| "MAT0 phase variant selector exceeds u16".to_string())?;
|
|
material_animations.push(MaterialAnimationBinding {
|
|
initial_phase_index,
|
|
normalized_phase_override: None,
|
|
ambient_fx_material_alpha: false,
|
|
variants: vec![MaterialVariantSet {
|
|
phase_material_indices,
|
|
range_indices: Vec::new(),
|
|
pipeline_category: material.pipeline_category,
|
|
}],
|
|
base: MaterialPhaseBinding {
|
|
document: material.mat0.clone(),
|
|
animation_block_index: 0,
|
|
wear_row_start_ms: 0,
|
|
random_state: material_id.raw() as u32,
|
|
frozen_phase_index: static_material_phase.map(usize::from),
|
|
phase_uv_transforms: phase_uv_transforms_from_assets(&material.mat0, assets),
|
|
},
|
|
detail: None,
|
|
overlay: None,
|
|
overlay_detail: None,
|
|
stage_textures: None,
|
|
});
|
|
Ok((source_selector, preview_selector))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn prepared_texture_to_vulkan(
|
|
texture: &PreparedTexture,
|
|
label: &str,
|
|
) -> Result<VulkanStaticTexture, String> {
|
|
let mip_images = (0..texture.texm.mip_count())
|
|
.map(|level| texture.decode_mip_rgba8(u32::try_from(level).unwrap_or(u32::MAX)))
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|err| format!("decode {label} {:?}: {err}", texture.source.name))?;
|
|
let image = mip_images
|
|
.first()
|
|
.ok_or_else(|| format!("{label} {:?} has no mip zero", texture.source.name))?;
|
|
Ok(VulkanStaticTexture {
|
|
width: image.width,
|
|
height: image.height,
|
|
rgba8: image.rgba8.clone(),
|
|
mip_levels: mip_images
|
|
.into_iter()
|
|
.map(|image| VulkanStaticTextureMip {
|
|
width: image.width,
|
|
height: image.height,
|
|
rgba8: image.rgba8,
|
|
})
|
|
.collect(),
|
|
})
|
|
}
|
|
|
|
fn static_preview_lightmap_textures(
|
|
assets: &MissionAssets,
|
|
visual: &PreparedVisual,
|
|
) -> Result<Vec<VulkanStaticTexture>, String> {
|
|
visual
|
|
.lightmap_ids
|
|
.iter()
|
|
.map(|id| {
|
|
let texture = assets
|
|
.textures
|
|
.iter()
|
|
.find(|texture| {
|
|
texture.id == *id && texture.usage == PreparedTextureUsage::Lightmap
|
|
})
|
|
.ok_or_else(|| format!("static preview lightmap {id:?} is unavailable"))?;
|
|
prepared_texture_to_vulkan(texture, "static preview lightmap")
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn append_static_preview_component(
|
|
target: &mut VulkanStaticMesh,
|
|
component: VulkanStaticMesh,
|
|
selector_remap: &[(u16, u16)],
|
|
material_animations: Option<&mut Vec<MaterialAnimationBinding>>,
|
|
) -> Result<(), String> {
|
|
append_static_preview_component_with_lightmaps(
|
|
target,
|
|
component,
|
|
selector_remap,
|
|
None,
|
|
&[],
|
|
material_animations,
|
|
)
|
|
}
|
|
|
|
fn append_static_preview_component_with_lightmaps(
|
|
target: &mut VulkanStaticMesh,
|
|
component: VulkanStaticMesh,
|
|
selector_remap: &[(u16, u16)],
|
|
mut materials: Option<&mut Vec<VulkanStaticMaterial>>,
|
|
lightmap_textures: &[VulkanStaticTexture],
|
|
mut material_animations: Option<&mut Vec<MaterialAnimationBinding>>,
|
|
) -> Result<(), String> {
|
|
let vertex_base = u32::try_from(target.vertices.len())
|
|
.map_err(|_| "static preview vertex count exceeds u32".to_string())?;
|
|
let first_index_base = u32::try_from(target.indices.len())
|
|
.map_err(|_| "static preview index count exceeds u32".to_string())?;
|
|
target
|
|
.vertices
|
|
.len()
|
|
.checked_add(component.vertices.len())
|
|
.ok_or_else(|| "static preview vertex count exceeds addressable memory".to_string())?;
|
|
target.vertices.extend(component.vertices);
|
|
target.indices.extend(
|
|
component
|
|
.indices
|
|
.into_iter()
|
|
.map(|index| {
|
|
index
|
|
.checked_add(vertex_base)
|
|
.ok_or_else(|| "static preview vertex index exceeds u32".to_string())
|
|
})
|
|
.collect::<Result<Vec<_>, _>>()?,
|
|
);
|
|
// A lightmapped range must keep the lightmap material family when MAT0
|
|
// advances to another phase. The range's original selector identifies
|
|
// the base binding, while the lightmap selector identifies the secondary
|
|
// texture. Keep the generated phase family and the binding/variant it
|
|
// belongs to so update_material_animations can rebind every frame.
|
|
let mut lightmap_materials: HashMap<(usize, u8), (Vec<usize>, usize, usize)> = HashMap::new();
|
|
for range in component.draw_ranges {
|
|
let source_material_index = selector_remap
|
|
.iter()
|
|
.find_map(|(source, preview)| (*source == range.material_index).then_some(*preview))
|
|
.ok_or_else(|| {
|
|
format!(
|
|
"static preview component has no material remap for selector {}",
|
|
range.material_index
|
|
)
|
|
})?;
|
|
let (material_index, lightmap_variant) = if lightmap_textures
|
|
.get(usize::from(range.lightmap_index))
|
|
.is_some()
|
|
{
|
|
let key = (usize::from(source_material_index), range.lightmap_index);
|
|
let (phase_material_indices, binding_index, variant_index) = if let Some(existing) =
|
|
lightmap_materials.get(&key)
|
|
{
|
|
existing.clone()
|
|
} else {
|
|
let bindings = material_animations.as_deref().ok_or_else(|| {
|
|
"static preview lightmap animation storage is unavailable".to_string()
|
|
})?;
|
|
let (binding_index, source_phase_indices) =
|
|
find_material_animation_variant(bindings, usize::from(source_material_index))
|
|
.ok_or_else(|| {
|
|
format!(
|
|
"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");
|
|
let materials = materials.as_mut().ok_or_else(|| {
|
|
"static preview lightmap material storage is unavailable".to_string()
|
|
})?;
|
|
let mut phase_material_indices = Vec::with_capacity(source_phase_indices.len());
|
|
for source_phase_index in source_phase_indices {
|
|
let mut lightmap_material = materials
|
|
.get(source_phase_index)
|
|
.cloned()
|
|
.ok_or_else(|| {
|
|
format!(
|
|
"static preview material selector {source_phase_index} is unavailable"
|
|
)
|
|
})?;
|
|
let original_directional = lightmap_material.directional_rgb;
|
|
lightmap_material.directional_rgb = [0.0; 3];
|
|
lightmap_material.additive_rgb = original_directional;
|
|
lightmap_material.detail_texture = Some(lightmap.clone());
|
|
lightmap_material.detail_uv_transform = [0.0, 0.0, 1.0, 1.0];
|
|
lightmap_material.overlay_texture = None;
|
|
lightmap_material.overlay_detail_texture = None;
|
|
lightmap_material.overlay_diffuse_alpha = None;
|
|
lightmap_material.overlay_directional_rgb = None;
|
|
lightmap_material.overlay_additive_rgb = None;
|
|
lightmap_material.overlay_uv_transform = None;
|
|
lightmap_material.overlay_detail_uv_transform = None;
|
|
lightmap_material.unlit = false;
|
|
lightmap_material.sky_nebula_stars = false;
|
|
lightmap_material.lightmap_mode = true;
|
|
let index = u16::try_from(materials.len()).map_err(|_| {
|
|
"static preview exceeds the available 16-bit material selector space"
|
|
.to_string()
|
|
})?;
|
|
lightmap_material.material_index = index;
|
|
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()
|
|
})?;
|
|
let variant_index = bindings[binding_index].variants.len();
|
|
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());
|
|
value
|
|
};
|
|
let initial_material = *phase_material_indices
|
|
.get(
|
|
material_animations
|
|
.as_deref()
|
|
.and_then(|bindings| bindings.get(binding_index))
|
|
.map_or(0, |binding| binding.initial_phase_index),
|
|
)
|
|
.ok_or_else(|| "static preview lightmap phase table is empty".to_string())?;
|
|
(
|
|
u16::try_from(initial_material).map_err(|_| {
|
|
"static preview lightmap material selector exceeds u16".to_string()
|
|
})?,
|
|
Some((binding_index, variant_index)),
|
|
)
|
|
} else {
|
|
(source_material_index, None)
|
|
};
|
|
let target_range_index = target.draw_ranges.len();
|
|
target
|
|
.draw_ranges
|
|
.push(fparkan_render_vulkan::VulkanStaticDrawRange {
|
|
first_index: first_index_base
|
|
.checked_add(range.first_index)
|
|
.ok_or_else(|| "static preview index range exceeds u32".to_string())?,
|
|
material_index,
|
|
..range
|
|
});
|
|
if let Some(bindings) = material_animations.as_deref_mut() {
|
|
if let Some((binding_index, variant_index)) = lightmap_variant {
|
|
bindings
|
|
.get_mut(binding_index)
|
|
.and_then(|binding| binding.variants.get_mut(variant_index))
|
|
.ok_or_else(|| {
|
|
"static preview lightmap animation variant is missing".to_string()
|
|
})?
|
|
.range_indices
|
|
.push(target_range_index);
|
|
} else {
|
|
register_material_range(
|
|
bindings,
|
|
usize::from(source_material_index),
|
|
target_range_index,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn load_standalone_vulkan_texture(
|
|
loader: &mut StandaloneWearMaterialLoader,
|
|
wear_path: &Path,
|
|
material_index: u16,
|
|
phase_index: u16,
|
|
) -> Result<(VulkanStaticTexture, PreparedMaterialPhase), String> {
|
|
let (mips, phase) = loader.load(wear_path, material_index, phase_index)?;
|
|
let first = mips
|
|
.first()
|
|
.ok_or_else(|| "standalone TEXM mip loader returned no mip zero".to_string())?;
|
|
Ok((
|
|
VulkanStaticTexture {
|
|
width: first.width,
|
|
height: first.height,
|
|
rgba8: first.rgba8.clone(),
|
|
mip_levels: mips
|
|
.into_iter()
|
|
.map(|image| VulkanStaticTextureMip {
|
|
width: image.width,
|
|
height: image.height,
|
|
rgba8: image.rgba8,
|
|
})
|
|
.collect(),
|
|
},
|
|
phase,
|
|
))
|
|
}
|
|
|
|
fn load_standalone_phase_set(
|
|
loader: &mut StandaloneWearMaterialLoader,
|
|
wear_path: &Path,
|
|
material_index: u16,
|
|
document: &Mat0Document,
|
|
) -> Result<Vec<(VulkanStaticTexture, PreparedMaterialPhase)>, String> {
|
|
(0..document.phases.len())
|
|
.map(|phase_index| {
|
|
load_standalone_vulkan_texture(
|
|
loader,
|
|
wear_path,
|
|
material_index,
|
|
u16::try_from(phase_index)
|
|
.map_err(|_| "MAT0 phase index exceeds u16".to_string())?,
|
|
)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn run_static_vulkan_mode(
|
|
preview: StaticPreviewScene,
|
|
environment: Option<DynamicEnvironment>,
|
|
audio: Option<audio::GameAudio>,
|
|
target_frames: u64,
|
|
mission: &str,
|
|
object_count: usize,
|
|
readback_out: Option<&std::path::Path>,
|
|
readback_sequence_out: Option<&std::path::Path>,
|
|
enable_validation: bool,
|
|
control_clock_target_ms: Option<u32>,
|
|
startup_timings: StartupTimings,
|
|
) -> Result<String, String> {
|
|
let event_loop = EventLoop::new().map_err(|err| format!("winit event loop: {err}"))?;
|
|
event_loop.set_control_flow(ControlFlow::Poll);
|
|
let mut preview = preview;
|
|
let mut environment = environment;
|
|
// Reserve the projected-shadow draw range before the environment builder
|
|
// reorders its fixed passes. The range itself remains a stable draw slot;
|
|
// frame geometry is appended after every static environment allocation.
|
|
let mut shadow_range = append_environment_range(
|
|
&mut preview.mesh,
|
|
preview.shadow_material_index,
|
|
shadow_placeholder_vertices(),
|
|
quad_indices(1)?,
|
|
projected_shadow_pipeline_state(),
|
|
)?;
|
|
let environment_gpu = match environment.as_mut() {
|
|
Some(environment) => {
|
|
let camera = preview.view_camera;
|
|
let environment_materials = environment.materials.clone();
|
|
let material_assets = environment.material_assets.clone();
|
|
let sky_mesh = environment.sky.mesh().clone();
|
|
Some(EnvironmentGpuScene::new(
|
|
&mut preview.mesh,
|
|
&mut preview.native_handedness_by_range,
|
|
&mut preview.type16_present_ranges,
|
|
&mut preview.materials,
|
|
&mut preview.material_animations,
|
|
&mut preview.ambient_effect_slots,
|
|
&mut preview.dynamic_shadow_casters,
|
|
&mut preview.sun_occlusion_range_indices,
|
|
&environment_materials,
|
|
&material_assets,
|
|
&sky_mesh,
|
|
&camera,
|
|
&mut shadow_range.range_index,
|
|
)?)
|
|
}
|
|
None => None,
|
|
};
|
|
let shadow_base_vertex = preview.mesh.vertices.len();
|
|
let shadow_base_index = preview.mesh.indices.len();
|
|
let mut app = StaticVulkanApp::new(
|
|
preview,
|
|
environment,
|
|
environment_gpu,
|
|
shadow_range,
|
|
shadow_base_vertex,
|
|
shadow_base_index,
|
|
audio,
|
|
target_frames,
|
|
mission,
|
|
object_count,
|
|
readback_out.map(std::path::Path::to_path_buf),
|
|
readback_sequence_out.map(std::path::Path::to_path_buf),
|
|
enable_validation,
|
|
control_clock_target_ms,
|
|
startup_timings,
|
|
);
|
|
if let Err(err) = event_loop.run_app(&mut app) {
|
|
app.error = Some(format!("winit event loop: {err}"));
|
|
}
|
|
app.finish()
|
|
}
|
|
|
|
struct StaticVulkanApp {
|
|
mesh: VulkanStaticMesh,
|
|
native_handedness_by_range: HashMap<usize, i8>,
|
|
type16_present_ranges: HashSet<usize>,
|
|
materials: Vec<VulkanStaticMaterial>,
|
|
material_animations: Vec<MaterialAnimationBinding>,
|
|
ambient_effect_slots: Vec<AmbientEffectGpuSlot>,
|
|
control_point_light_slots: Vec<ControlEffectSlot>,
|
|
control_sound_slots: Vec<ControlSoundSlot>,
|
|
unsupported_control_sounds: usize,
|
|
control_effect_random_states: HashMap<ControlEffectInstanceKey, ControlEffectRandomState>,
|
|
component_pose_buffers: Vec<Vec<Option<NodePoseBuffer>>>,
|
|
component_mount_poses: Vec<Vec<Option<VulkanNodePose>>>,
|
|
dynamic_component_geometry: Vec<Vec<Option<DynamicComponentGeometry>>>,
|
|
building_collision_bindings: Vec<BuildingCollisionBinding>,
|
|
control_animation_instances: Vec<Vec<Option<ControlAnimationInstance>>>,
|
|
dynamic_shadow_casters: Vec<DynamicShadowCaster>,
|
|
sun_occlusion_range_indices: Vec<usize>,
|
|
terrain: Arc<TerrainWorld>,
|
|
world_range_indices: Vec<usize>,
|
|
world_range_index_counts: HashMap<usize, u32>,
|
|
camera: VulkanStaticCamera,
|
|
view_camera: PreviewCameraFrame,
|
|
captured_viewport: Option<CapturedViewport>,
|
|
free_camera: Option<FreeFlightCamera>,
|
|
environment: Option<DynamicEnvironment>,
|
|
environment_gpu: Option<EnvironmentGpuScene>,
|
|
shadow_range: EnvironmentGpuRange,
|
|
shadow_base_vertex: usize,
|
|
shadow_base_index: usize,
|
|
shadow_material_index: usize,
|
|
shadow_casters: Vec<ShadowCaster>,
|
|
shadow_receivers: Vec<ShadowTriangle>,
|
|
shadow_cache: ShadowPageCache,
|
|
shadow_last_evidence: ShadowFrameEvidence,
|
|
last_environment_control_clock_ms: u32,
|
|
control_point_lights_diagnostic_reported: bool,
|
|
simulation_last_tick: Instant,
|
|
live_clock_paused_for_zero_extent: bool,
|
|
simulation_elapsed: Duration,
|
|
simulation_time_ms: u32,
|
|
simulation_update_generation: u64,
|
|
pending_scene_update: Option<SceneFrameUpdate>,
|
|
environment_primitive_count: usize,
|
|
pressed_keys: HashSet<KeyCode>,
|
|
mouse_look: bool,
|
|
audio: Option<audio::GameAudio>,
|
|
camera_mode: &'static str,
|
|
mesh_components: usize,
|
|
terrain_components: usize,
|
|
target_frames: u64,
|
|
control_clock_target_ms: Option<u32>,
|
|
mission: String,
|
|
object_count: usize,
|
|
readback_out: Option<PathBuf>,
|
|
readback_sequence_out: Option<PathBuf>,
|
|
enable_validation: bool,
|
|
startup_timings: StartupTimings,
|
|
window_id: Option<WindowId>,
|
|
window: Option<Window>,
|
|
renderer: Option<VulkanSmokeRenderer>,
|
|
frames_presented: u64,
|
|
output: Option<String>,
|
|
error: Option<String>,
|
|
}
|
|
|
|
impl StaticVulkanApp {
|
|
fn new(
|
|
preview: StaticPreviewScene,
|
|
environment: Option<DynamicEnvironment>,
|
|
environment_gpu: Option<EnvironmentGpuScene>,
|
|
shadow_range: EnvironmentGpuRange,
|
|
shadow_base_vertex: usize,
|
|
shadow_base_index: usize,
|
|
audio: Option<audio::GameAudio>,
|
|
target_frames: u64,
|
|
mission: &str,
|
|
object_count: usize,
|
|
readback_out: Option<PathBuf>,
|
|
readback_sequence_out: Option<PathBuf>,
|
|
enable_validation: bool,
|
|
control_clock_target_ms: Option<u32>,
|
|
startup_timings: StartupTimings,
|
|
) -> Self {
|
|
let mut fixed_range_indices = environment_gpu
|
|
.as_ref()
|
|
.map(EnvironmentGpuScene::fixed_range_indices)
|
|
.unwrap_or_default();
|
|
fixed_range_indices.extend(preview.ambient_effect_slots.iter().flat_map(|slot| {
|
|
std::iter::once(slot.range.range_index).chain(
|
|
slot.additional_particle_ranges
|
|
.iter()
|
|
.map(|range| range.range_index),
|
|
)
|
|
}));
|
|
fixed_range_indices.insert(shadow_range.range_index);
|
|
let world_range_indices =
|
|
world_draw_range_indices(preview.mesh.draw_ranges.len(), &fixed_range_indices);
|
|
let world_range_index_counts = preview
|
|
.mesh
|
|
.draw_ranges
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(range_index, _)| !fixed_range_indices.contains(range_index))
|
|
.map(|(range_index, range)| (range_index, range.index_count))
|
|
.collect();
|
|
Self {
|
|
mesh: preview.mesh,
|
|
native_handedness_by_range: preview.native_handedness_by_range,
|
|
type16_present_ranges: preview.type16_present_ranges,
|
|
materials: preview.materials,
|
|
material_animations: preview.material_animations,
|
|
ambient_effect_slots: preview.ambient_effect_slots,
|
|
control_point_light_slots: preview.control_point_light_slots,
|
|
control_sound_slots: preview.control_sound_slots,
|
|
unsupported_control_sounds: preview.unsupported_control_sounds,
|
|
control_effect_random_states: HashMap::new(),
|
|
component_pose_buffers: preview.component_pose_buffers,
|
|
component_mount_poses: preview.component_mount_poses,
|
|
dynamic_component_geometry: preview.dynamic_component_geometry,
|
|
building_collision_bindings: preview.building_collision_bindings,
|
|
control_animation_instances: preview.control_animation_instances,
|
|
dynamic_shadow_casters: preview.dynamic_shadow_casters,
|
|
sun_occlusion_range_indices: preview.sun_occlusion_range_indices,
|
|
terrain: preview.terrain,
|
|
world_range_indices,
|
|
world_range_index_counts,
|
|
camera: preview.camera,
|
|
view_camera: preview.view_camera,
|
|
captured_viewport: preview.captured_viewport,
|
|
free_camera: preview.free_camera,
|
|
environment,
|
|
environment_gpu,
|
|
shadow_base_vertex,
|
|
shadow_base_index,
|
|
shadow_material_index: preview.shadow_material_index,
|
|
shadow_range,
|
|
shadow_casters: preview.shadow_casters,
|
|
shadow_receivers: preview.shadow_receivers,
|
|
shadow_cache: ShadowPageCache::default(),
|
|
shadow_last_evidence: ShadowFrameEvidence::default(),
|
|
last_environment_control_clock_ms: 0,
|
|
control_point_lights_diagnostic_reported: false,
|
|
simulation_last_tick: Instant::now(),
|
|
live_clock_paused_for_zero_extent: false,
|
|
simulation_elapsed: Duration::ZERO,
|
|
simulation_time_ms: 0,
|
|
simulation_update_generation: 0,
|
|
pending_scene_update: None,
|
|
environment_primitive_count: 0,
|
|
pressed_keys: HashSet::new(),
|
|
mouse_look: false,
|
|
audio,
|
|
camera_mode: preview.camera_mode,
|
|
mesh_components: preview.mesh_components,
|
|
terrain_components: preview.terrain_components,
|
|
target_frames,
|
|
control_clock_target_ms,
|
|
mission: mission.to_string(),
|
|
object_count,
|
|
readback_out,
|
|
readback_sequence_out,
|
|
enable_validation,
|
|
startup_timings,
|
|
window_id: None,
|
|
window: None,
|
|
renderer: None,
|
|
frames_presented: 0,
|
|
output: None,
|
|
error: None,
|
|
}
|
|
}
|
|
|
|
fn configure_world_draw_ranges(
|
|
&self,
|
|
renderer: &mut VulkanSmokeRenderer,
|
|
aspect: f32,
|
|
) -> Result<(), String> {
|
|
let approximate_portals = self
|
|
.world_range_indices
|
|
.iter()
|
|
.filter(|&&index| {
|
|
let flags = self.mesh.draw_ranges[index].batch_flags;
|
|
flags & 8 != 0
|
|
&& flags & 0x50 != 0x40
|
|
&& !self.type16_present_ranges.contains(&index)
|
|
})
|
|
.count();
|
|
eprintln!("render_coverage portal_fade_packet_selection_approx={approximate_portals} (first indexed vertex; native CPU clipping selection pending)");
|
|
let randomized_lights = self
|
|
.control_point_light_slots
|
|
.iter()
|
|
.filter(|slot| {
|
|
[92..108, 116..120].into_iter().any(|range| {
|
|
slot.command.raw_body.get(range).is_some_and(|bytes| {
|
|
bytes
|
|
.chunks_exact(4)
|
|
.any(|word| f32::from_le_bytes(word.try_into().unwrap()) != 0.0)
|
|
})
|
|
})
|
|
})
|
|
.count();
|
|
eprintln!("render_coverage control_op1_jitter_unsupported={randomized_lights} (using authored color and radius; native RNG sampling pending)");
|
|
eprintln!("render_coverage control_op2_supported={} control_op2_unsupported={} (unsupported timing, backend mode or parameters are skipped)", self.control_sound_slots.len(), self.unsupported_control_sounds);
|
|
eprintln!("render_coverage fog_pass_eligibility_approx={} (global world fog and horizon RGB; native shade-state/color verification pending)", self.world_range_indices.len());
|
|
for &range_index in &self.world_range_indices {
|
|
let range = self
|
|
.mesh
|
|
.draw_ranges
|
|
.get(range_index)
|
|
.ok_or_else(|| "world draw range index is out of bounds".to_string())?;
|
|
let alpha = self
|
|
.materials
|
|
.get(usize::from(range.material_index))
|
|
.map_or(1.0, |material| material.diffuse_alpha);
|
|
let alpha_override = native_msh_diffuse_alpha_override(
|
|
&self.mesh,
|
|
range_index,
|
|
self.type16_present_ranges.contains(&range_index),
|
|
self.view_camera.position,
|
|
horizontal_fov(&self.view_camera, aspect),
|
|
)?;
|
|
let effective_alpha = alpha_override.unwrap_or(alpha);
|
|
renderer
|
|
.set_draw_range_diffuse_alpha_override(range_index, alpha_override)
|
|
.map_err(|error| {
|
|
format!("configure world draw range {range_index} alpha: {error}")
|
|
})?;
|
|
let transparent = native_world_transparent(range, effective_alpha);
|
|
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,
|
|
world_draw_range_index_count(
|
|
&self.mesh,
|
|
range,
|
|
range.index_count,
|
|
effective_alpha,
|
|
self.view_camera.position,
|
|
self.native_handedness_by_range.get(&range_index).copied(),
|
|
),
|
|
)
|
|
.map_err(|error| {
|
|
format!("configure world draw range {range_index} count: {error}")
|
|
})?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn configure_ambient_effect_draw_ranges(
|
|
&self,
|
|
renderer: &mut VulkanSmokeRenderer,
|
|
) -> Result<(), String> {
|
|
for slot in &self.ambient_effect_slots {
|
|
for (effect_range, _) in ambient_effect_ranges(slot) {
|
|
let range = self
|
|
.mesh
|
|
.draw_ranges
|
|
.get(effect_range.range_index)
|
|
.ok_or_else(|| "ambient FX draw range index is out of bounds".to_string())?;
|
|
let category = self
|
|
.materials
|
|
.get(usize::from(range.material_index))
|
|
.ok_or_else(|| "ambient FX draw range has no MAT0 selector".to_string())?
|
|
.pipeline_category;
|
|
renderer
|
|
.set_draw_range_transparency(effect_range.range_index, true)
|
|
.map_err(|error| {
|
|
format!(
|
|
"configure ambient FX draw range {}: {error}",
|
|
effect_range.range_index
|
|
)
|
|
})?;
|
|
renderer
|
|
.set_draw_range_material_category(effect_range.range_index, category)
|
|
.map_err(|error| format!("configure ambient FX material category: {error}"))?;
|
|
}
|
|
}
|
|
for (range_index, sort_key) in ambient_effect_draw_sort_keys(
|
|
&self.mesh,
|
|
&self.ambient_effect_slots,
|
|
self.view_camera.position,
|
|
)? {
|
|
renderer
|
|
.set_draw_range_sort_key(range_index, sort_key)
|
|
.map_err(|error| {
|
|
format!("configure ambient FX draw range {range_index} sort key: {error}")
|
|
})?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn update_world_transparency(
|
|
mesh: &VulkanStaticMesh,
|
|
materials: &[VulkanStaticMaterial],
|
|
world_range_indices: &[usize],
|
|
type16_present_ranges: &HashSet<usize>,
|
|
world_range_index_counts: &HashMap<usize, u32>,
|
|
native_handedness_by_range: &HashMap<usize, i8>,
|
|
eye_world: [f32; 3],
|
|
horizontal_fov: f32,
|
|
active_materials: &HashMap<usize, ActiveMaterialState>,
|
|
renderer: &mut VulkanSmokeRenderer,
|
|
) -> Result<(), String> {
|
|
for &range_index in world_range_indices {
|
|
let range = mesh
|
|
.draw_ranges
|
|
.get(range_index)
|
|
.ok_or_else(|| "world draw range index is out of bounds".to_string())?;
|
|
let state = active_materials.get(&range_index);
|
|
let material_alpha = state.map_or_else(
|
|
|| {
|
|
materials
|
|
.get(usize::from(range.material_index))
|
|
.map_or(1.0, |material| material.diffuse_alpha)
|
|
},
|
|
|state| state.diffuse_alpha,
|
|
);
|
|
let alpha_override = native_msh_diffuse_alpha_override(
|
|
mesh,
|
|
range_index,
|
|
type16_present_ranges.contains(&range_index),
|
|
eye_world,
|
|
horizontal_fov,
|
|
)?;
|
|
let alpha = alpha_override.unwrap_or(material_alpha);
|
|
renderer
|
|
.set_draw_range_diffuse_alpha_override(range_index, alpha_override)
|
|
.map_err(|error| format!("update world draw range {range_index} alpha: {error}"))?;
|
|
renderer
|
|
.set_draw_range_transparency(range_index, native_world_transparent(range, alpha))
|
|
.map_err(|error| format!("update world draw range {range_index}: {error}"))?;
|
|
let original_index_count = *world_range_index_counts
|
|
.get(&range_index)
|
|
.ok_or_else(|| "world draw range count is missing".to_string())?;
|
|
let count = world_draw_range_index_count(
|
|
mesh,
|
|
range,
|
|
original_index_count,
|
|
alpha,
|
|
eye_world,
|
|
native_handedness_by_range.get(&range_index).copied(),
|
|
);
|
|
renderer
|
|
.set_draw_range_index_count(range_index, count)
|
|
.map_err(|error| format!("update world draw range {range_index} count: {error}"))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn world_draw_sort_keys(&self, camera_position: [f32; 3]) -> Result<Vec<(usize, f32)>, String> {
|
|
let mut keys = Vec::with_capacity(self.world_range_indices.len());
|
|
for &range_index in &self.world_range_indices {
|
|
let sort_key = first_indexed_vertex_distance(&self.mesh, range_index, camera_position)?;
|
|
keys.push((range_index, sort_key));
|
|
}
|
|
Ok(keys)
|
|
}
|
|
|
|
fn update_free_camera(&mut self, delta_seconds: f32, viewport: [f32; 2]) -> Result<(), String> {
|
|
let Some(mut free_camera) = self.free_camera else {
|
|
return Ok(());
|
|
};
|
|
let aspect = viewport[0] / viewport[1].max(1.0);
|
|
let safe_position = free_camera.position;
|
|
free_camera.advance(&self.pressed_keys, delta_seconds.clamp(0.0, 0.1));
|
|
free_camera.keep_above_terrain(&self.terrain)?;
|
|
if free_camera.position != safe_position
|
|
&& self.building_segment_blocked([safe_position, free_camera.position])?
|
|
{
|
|
free_camera.position = safe_position;
|
|
}
|
|
let view_camera = free_camera.preview_frame(aspect);
|
|
let camera = view_camera.vulkan_camera();
|
|
self.free_camera = Some(free_camera);
|
|
self.view_camera = view_camera;
|
|
self.camera = camera;
|
|
Ok(())
|
|
}
|
|
|
|
fn building_segment_blocked(&self, world_segment: [[f32; 3]; 2]) -> Result<bool, String> {
|
|
for binding in &self.building_collision_bindings {
|
|
if binding.selected_nodes.is_empty() {
|
|
continue;
|
|
}
|
|
let pose_buffer = self
|
|
.component_pose_buffers
|
|
.get(binding.object_index)
|
|
.and_then(|components| components.get(binding.component_index))
|
|
.and_then(Option::as_ref);
|
|
let mount_pose = self
|
|
.component_mount_poses
|
|
.get(binding.object_index)
|
|
.and_then(|components| components.get(binding.component_index))
|
|
.copied()
|
|
.flatten();
|
|
let nodes = building_collision_nodes(binding, pose_buffer, mount_pose)?;
|
|
if query_building_segment_world(&binding.model, &nodes, world_segment)
|
|
.map_err(|error| {
|
|
format!(
|
|
"query building collision object {} component {} model {}: {error}",
|
|
binding.object_index, binding.component_index, binding.model_source
|
|
)
|
|
})?
|
|
.is_some()
|
|
{
|
|
return Ok(true);
|
|
}
|
|
}
|
|
Ok(false)
|
|
}
|
|
|
|
fn sample_frame_clock(&mut self) -> FrameClockSample {
|
|
if let Some(target_ms) = self.control_clock_target_ms {
|
|
let time_ms =
|
|
control_clock_ms_for_frame(target_ms, self.frames_presented, self.target_frames);
|
|
self.simulation_elapsed = Duration::from_millis(u64::from(time_ms));
|
|
self.simulation_time_ms = time_ms;
|
|
return FrameClockSample {
|
|
id: SceneSampleId::Replay(self.frames_presented),
|
|
time_ms,
|
|
delta_seconds: replay_environment_delta_seconds(
|
|
self.last_environment_control_clock_ms,
|
|
time_ms,
|
|
),
|
|
};
|
|
}
|
|
self.sample_live_frame_clock_at(Instant::now())
|
|
}
|
|
|
|
fn pending_or_sample_frame_clock(&mut self) -> FrameClockSample {
|
|
match self.pending_scene_update.as_ref() {
|
|
Some(pending) => pending.clock,
|
|
None => self.sample_frame_clock(),
|
|
}
|
|
}
|
|
|
|
fn sample_live_frame_clock_at(&mut self, now: Instant) -> FrameClockSample {
|
|
let elapsed = now.saturating_duration_since(self.simulation_last_tick);
|
|
self.simulation_last_tick = now;
|
|
self.simulation_elapsed = self.simulation_elapsed.saturating_add(elapsed);
|
|
self.simulation_time_ms = self.simulation_elapsed.as_millis() as u32;
|
|
let sample_id = SceneSampleId::Live(self.simulation_update_generation);
|
|
self.simulation_update_generation = self.simulation_update_generation.saturating_add(1);
|
|
FrameClockSample {
|
|
id: sample_id,
|
|
time_ms: self.simulation_time_ms,
|
|
delta_seconds: elapsed.as_secs_f32(),
|
|
}
|
|
}
|
|
|
|
fn pause_live_clock_at(&mut self, now: Instant) {
|
|
self.live_clock_paused_for_zero_extent = true;
|
|
self.simulation_last_tick = now;
|
|
}
|
|
|
|
fn resume_live_clock_at(&mut self, now: Instant) {
|
|
if self.live_clock_paused_for_zero_extent {
|
|
self.simulation_last_tick = now;
|
|
self.live_clock_paused_for_zero_extent = false;
|
|
}
|
|
}
|
|
|
|
fn update_shadow(
|
|
mesh: &mut VulkanStaticMesh,
|
|
shadow_cache: &mut ShadowPageCache,
|
|
shadow_casters: &[ShadowCaster],
|
|
shadow_receivers: &[ShadowTriangle],
|
|
shadow_range: EnvironmentGpuRange,
|
|
shadow_base_vertex: usize,
|
|
shadow_base_index: usize,
|
|
shadow_material_index: usize,
|
|
uploaded_shadow_atlas: &mut VulkanStaticTexture,
|
|
renderer: &mut VulkanSmokeRenderer,
|
|
control_point_lights: &[(VulkanPointLight, u32)],
|
|
sky_frame: Option<&SkyFrame<'_>>,
|
|
environment_frame: Option<&EnvironmentFrame>,
|
|
shadow_camera: Option<ShadowCamera>,
|
|
) -> Result<ShadowFrameEvidence, String> {
|
|
let lights = shadow_lights(sky_frame, environment_frame, renderer, control_point_lights);
|
|
let visible_casters = native_visible_shadow_casters(
|
|
shadow_casters,
|
|
renderer.frame_uniforms().clip_from_world,
|
|
);
|
|
let frame = shadow_cache
|
|
.build(ShadowScene {
|
|
lights: &lights,
|
|
casters: &visible_casters,
|
|
receivers: shadow_receivers,
|
|
fade: 1.0,
|
|
smooth_passes: 1,
|
|
camera: shadow_camera,
|
|
lod: ShadowLodSettings::default(),
|
|
})
|
|
.map_err(|error| format!("build projected shadow frame: {error}"))?;
|
|
let geometry = append_shadow_frame_geometry(
|
|
mesh,
|
|
shadow_range,
|
|
shadow_base_vertex,
|
|
shadow_base_index,
|
|
&frame,
|
|
)?;
|
|
let atlas = shadow_atlas_texture_from_frame(&frame);
|
|
if atlas != *uploaded_shadow_atlas {
|
|
renderer
|
|
.update_material_texture(shadow_material_index, 0, &atlas)
|
|
.map_err(|error| format!("upload projected shadow atlas: {error}"))?;
|
|
*uploaded_shadow_atlas = atlas;
|
|
}
|
|
renderer
|
|
.update_mesh_geometry(&mesh.vertices, &mesh.indices, &[geometry])
|
|
.map_err(|error| format!("upload environment and shadow geometry: {error}"))?;
|
|
Ok(ShadowFrameEvidence {
|
|
visible_casters: visible_casters.len(),
|
|
projected_indices: frame.indices.len(),
|
|
})
|
|
}
|
|
|
|
fn prepare_scene_update(
|
|
&mut self,
|
|
clock: FrameClockSample,
|
|
drawable_viewport: [f32; 2],
|
|
) -> Result<(), String> {
|
|
let viewport = self.captured_viewport.map_or_else(
|
|
|| [drawable_viewport[0].max(1.0), drawable_viewport[1].max(1.0)],
|
|
|viewport| [viewport.extent[0] as f32, viewport.extent[1] as f32],
|
|
);
|
|
let aspect = viewport[0] / viewport[1].max(1.0);
|
|
if let Some((pending_id, pending_viewport)) = self
|
|
.pending_scene_update
|
|
.as_ref()
|
|
.map(|pending| (pending.clock.id, pending.viewport))
|
|
{
|
|
if pending_id == clock.id {
|
|
if pending_viewport != viewport {
|
|
self.refresh_pending_scene_viewport(viewport, aspect)?;
|
|
}
|
|
return Ok(());
|
|
}
|
|
}
|
|
let control_clock_ms = clock.time_ms;
|
|
let effect_elapsed_seconds = control_clock_ms as f32 / 1000.0;
|
|
// CTLD's validated NDP defaults seed the native query gate. This
|
|
// preview does not process mission Action1 updates, so it retains that
|
|
// initial provider value for the capture.
|
|
update_control_animation_pose_buffers(
|
|
&mut self.control_animation_instances,
|
|
&mut self.component_pose_buffers,
|
|
control_clock_ms,
|
|
)?;
|
|
update_dynamic_component_geometry(
|
|
&self.dynamic_component_geometry,
|
|
&self.component_pose_buffers,
|
|
&mut self.component_mount_poses,
|
|
&mut self.mesh,
|
|
)?;
|
|
self.update_free_camera(clock.delta_seconds, viewport)?;
|
|
refresh_dynamic_shadow_casters(
|
|
&mut self.shadow_casters,
|
|
&self.dynamic_shadow_casters,
|
|
&self.mesh,
|
|
&self.component_pose_buffers,
|
|
&self.component_mount_poses,
|
|
)?;
|
|
for slot in &mut self.ambient_effect_slots {
|
|
if slot.owner_component_is_mounted {
|
|
slot.mount_pose = self
|
|
.component_mount_poses
|
|
.get(slot.owner_object_index)
|
|
.and_then(|components| components.get(slot.owner_component_index))
|
|
.copied()
|
|
.flatten();
|
|
}
|
|
}
|
|
let control_effect_random_samples = advance_control_effect_random_states(
|
|
&mut self.control_effect_random_states,
|
|
&self.ambient_effect_slots,
|
|
&self.control_point_light_slots,
|
|
&self.control_sound_slots,
|
|
clock.id,
|
|
control_clock_ms,
|
|
effect_elapsed_seconds,
|
|
);
|
|
let control_point_lights = update_control_point_lights(
|
|
&mut self.control_point_light_slots,
|
|
&self.component_pose_buffers,
|
|
&self.component_mount_poses,
|
|
effect_elapsed_seconds,
|
|
&control_effect_random_samples,
|
|
std::env::var_os("FPARKAN_CONTROL_POINT_LIGHT_DIAGNOSTIC").is_some()
|
|
&& !self.control_point_lights_diagnostic_reported
|
|
&& self
|
|
.control_clock_target_ms
|
|
.is_none_or(|target_ms| control_clock_ms >= target_ms),
|
|
);
|
|
let control_vulkan_lights = ordinary_control_point_lights(&control_point_lights);
|
|
let effect_updates = update_ambient_effect_geometry(
|
|
&mut self.ambient_effect_slots,
|
|
&self.component_pose_buffers,
|
|
&mut self.mesh,
|
|
&mut self.material_animations,
|
|
effect_elapsed_seconds,
|
|
&control_effect_random_samples,
|
|
self.view_camera.position,
|
|
)?;
|
|
let world_sort_keys = self.world_draw_sort_keys(self.view_camera.position)?;
|
|
let ambient_sort_keys = ambient_effect_draw_sort_keys(
|
|
&self.mesh,
|
|
&self.ambient_effect_slots,
|
|
self.view_camera.position,
|
|
)?;
|
|
let camera = self.view_camera;
|
|
let material_update = sample_material_animations(
|
|
&mut self.material_animations,
|
|
&self.materials,
|
|
clock.time_ms,
|
|
)?;
|
|
let active_materials = &material_update.active_materials;
|
|
apply_ambient_effect_material_tints(
|
|
&self.ambient_effect_slots,
|
|
&mut self.mesh,
|
|
&effect_updates,
|
|
&material_update.active_materials,
|
|
)?;
|
|
let diagnostic_mode = std::env::var("FPARKAN_AMBIENT_FX_DIAGNOSTIC").ok();
|
|
if diagnostic_mode.is_some() {
|
|
let generator_capture_is_final = diagnostic_mode.as_deref() == Some("generator")
|
|
&& self
|
|
.control_clock_target_ms
|
|
.is_none_or(|target_ms| control_clock_ms >= target_ms);
|
|
for update in &effect_updates {
|
|
let Some((slot, range)) = self.ambient_effect_slots.iter().find_map(|slot| {
|
|
ambient_effect_range_for(slot, update.range_index).map(|range| (slot, range))
|
|
}) else {
|
|
continue;
|
|
};
|
|
if generator_capture_is_final
|
|
&& slot.kind == AmbientEffectKind::GenerBall
|
|
&& slot.control_source == "fortif.rlb/fr_l_gener.ctl"
|
|
{
|
|
let random_sample = control_effect_random_samples
|
|
.get(&slot.random_instance_key)
|
|
.copied()
|
|
.unwrap_or_default();
|
|
let phase = ambient_effect_phase(
|
|
slot.time_mode,
|
|
slot.header_flags,
|
|
effect_elapsed_seconds,
|
|
slot.duration_seconds,
|
|
)
|
|
.map(|phase| {
|
|
native_command_phase(slot.header_flags, phase, random_sample.phase_jitter)
|
|
});
|
|
let evaluated = phase.and_then(|phase| {
|
|
evaluate_f_gener_ball(
|
|
&slot.command,
|
|
slot.command_index,
|
|
&slot.world_from_effect,
|
|
phase,
|
|
effect_elapsed_seconds,
|
|
self.view_camera.position,
|
|
)
|
|
});
|
|
let expected_vertices = evaluated.as_ref().map(|frame| frame.vertices);
|
|
let expected_center =
|
|
expected_vertices.map(|vertices| ambient_effect_quad_center(&vertices));
|
|
let expected_size =
|
|
expected_vertices.map(|vertices| ambient_effect_quad_size(&vertices));
|
|
let expected_screen = expected_vertices.and_then(|vertices| {
|
|
ambient_effect_projected_bounds(
|
|
&vertices,
|
|
self.view_camera.clip_from_world,
|
|
self.captured_viewport
|
|
.map_or([1280, 1024], |viewport| viewport.extent),
|
|
)
|
|
});
|
|
let actual_vertices = self
|
|
.mesh
|
|
.vertices
|
|
.get(range.vertex_start..range.vertex_start.saturating_add(4));
|
|
let actual_center: Option<[f32; 3]> = actual_vertices.map(|vertices| {
|
|
std::array::from_fn(|axis| {
|
|
vertices
|
|
.iter()
|
|
.map(|vertex| vertex.position[axis])
|
|
.sum::<f32>()
|
|
/ 4.0
|
|
})
|
|
});
|
|
let actual_rgb = actual_vertices
|
|
.and_then(|vertices| vertices.first())
|
|
.map(|vertex| vertex.color);
|
|
let actual_alpha = actual_vertices
|
|
.and_then(|vertices| vertices.first())
|
|
.map(|vertex| vertex.overlay_alpha);
|
|
let material = active_materials.get(&update.range_index);
|
|
eprintln!(
|
|
"ambient_fx_generator_diagnostic elapsed={effect_elapsed_seconds:.3} clock_ms={control_clock_ms} owner_object={} owner_component={} source={} table={} row={} points={:?} opcode={:?} command={} range={} submitted_indices={} effect_phase={phase:?} evaluator_active={:?} fx_mat_phase={:?} fx_alpha={:?} expected_center={expected_center:?} expected_size={expected_size:?} expected_screen={expected_screen:?} actual_center={actual_center:?} actual_rgb={actual_rgb:?} actual_alpha={actual_alpha:?} mat_selector={:?} mat_phase_index={:?} sampled_mat_rgb={:?}",
|
|
slot.owner_object_index,
|
|
slot.owner_component_index,
|
|
slot.control_source,
|
|
slot.control_table_index,
|
|
slot.control_row_index,
|
|
slot.control_point_indices,
|
|
slot.command.opcode,
|
|
slot.command_index,
|
|
update.range_index,
|
|
update.index_count,
|
|
evaluated.as_ref().map(|frame| frame.active),
|
|
evaluated.as_ref().map(|frame| frame.material_phase),
|
|
evaluated.as_ref().map(|frame| frame.color[3]),
|
|
material.map(|material| material.material_index),
|
|
material.map(|material| material.phase_index),
|
|
material.map(|material| material.additive_rgb),
|
|
);
|
|
continue;
|
|
}
|
|
if diagnostic_mode.as_deref() == Some("generator")
|
|
|| slot.control_source != "animals.rlb/a_a_l3.ctl"
|
|
|| update.index_count == 0
|
|
{
|
|
continue;
|
|
}
|
|
let start = range.vertex_start;
|
|
let vertices = self
|
|
.mesh
|
|
.vertices
|
|
.get(start..start.saturating_add(range.vertex_capacity))
|
|
.unwrap_or_default();
|
|
let center = if vertices.is_empty() {
|
|
[0.0; 3]
|
|
} else {
|
|
std::array::from_fn(|axis| {
|
|
vertices
|
|
.iter()
|
|
.map(|vertex| vertex.position[axis])
|
|
.sum::<f32>()
|
|
/ vertices.len() as f32
|
|
})
|
|
};
|
|
let color = vertices
|
|
.first()
|
|
.map(|vertex| vertex.color)
|
|
.unwrap_or([0.0; 3]);
|
|
let alpha = vertices
|
|
.first()
|
|
.map(|vertex| vertex.overlay_alpha)
|
|
.unwrap_or(0.0);
|
|
eprintln!(
|
|
"ambient_fx_diagnostic elapsed={effect_elapsed_seconds:.3} source={} table={} row={} points={:?} owners={:?} parent_translation={:?} opcode={:?} command={} range={} active_indices={} center={center:?} command_rgb={:?} sampled_mat_rgb={color:?} alpha={alpha:.3}",
|
|
slot.control_source,
|
|
slot.control_table_index,
|
|
slot.control_row_index,
|
|
slot.control_point_indices,
|
|
slot.control_point_owners,
|
|
[slot.world_from_effect[0][3], slot.world_from_effect[1][3], slot.world_from_effect[2][3]],
|
|
slot.command.opcode,
|
|
slot.command_index,
|
|
update.range_index,
|
|
update.index_count,
|
|
update.command_rgb,
|
|
);
|
|
}
|
|
}
|
|
|
|
if let Some(audio) = self.audio.as_mut() {
|
|
audio
|
|
.update_listener(camera.position, camera.forward, camera.up)
|
|
.map_err(|error| format!("update audio listener: {error}"))?;
|
|
for (id, sound) in self.control_sound_slots.iter_mut().enumerate() {
|
|
let slot = &mut sound.effect;
|
|
if slot.owner_component_is_mounted {
|
|
slot.mount_pose = self
|
|
.component_mount_poses
|
|
.get(slot.owner_object_index)
|
|
.and_then(|components| components.get(slot.owner_component_index))
|
|
.copied()
|
|
.flatten();
|
|
}
|
|
let random = control_effect_random_samples
|
|
.get(&slot.random_instance_key)
|
|
.copied()
|
|
.unwrap_or_default();
|
|
let Some(matrix) = control_effect_world_from_component_pose_with_random_shift(
|
|
slot,
|
|
&self.component_pose_buffers,
|
|
random.accumulated_shift,
|
|
) else {
|
|
continue;
|
|
};
|
|
let Some(phase) = ambient_effect_phase(
|
|
slot.time_mode,
|
|
slot.header_flags,
|
|
effect_elapsed_seconds,
|
|
slot.duration_seconds,
|
|
)
|
|
.map(|phase| native_command_phase(slot.header_flags, phase, random.phase_jitter)) else {
|
|
continue;
|
|
};
|
|
if let Some(event) = sound.command.update(
|
|
&mut sound.state,
|
|
phase,
|
|
matrix,
|
|
audio.control_sound_is_playing(id),
|
|
) {
|
|
audio
|
|
.handle_control_sound(id, &event)
|
|
.map_err(|error| format!("play Control sound {id}: {error}"))?;
|
|
}
|
|
}
|
|
}
|
|
let environment = if let Some(environment) = self.environment.as_mut() {
|
|
Some(environment.update_cpu(
|
|
clock.delta_seconds,
|
|
&camera,
|
|
aspect,
|
|
viewport,
|
|
self.audio.as_mut(),
|
|
)?)
|
|
} else {
|
|
None
|
|
};
|
|
self.environment_primitive_count = environment
|
|
.as_ref()
|
|
.map_or(0, |frame| frame.environment_frame.primitives.len());
|
|
if matches!(clock.id, SceneSampleId::Replay(_)) {
|
|
self.last_environment_control_clock_ms = control_clock_ms;
|
|
}
|
|
let environment_material_update =
|
|
if let Some(environment_gpu) = self.environment_gpu.as_mut() {
|
|
Some(sample_material_animations(
|
|
&mut environment_gpu.material_animations,
|
|
&self.materials,
|
|
clock.time_ms,
|
|
)?)
|
|
} else {
|
|
None
|
|
};
|
|
let environment_point_light_count = environment.as_ref().map_or(0, |frame| {
|
|
frame
|
|
.environment_frame
|
|
.point_lights
|
|
.iter()
|
|
.filter(|light| light.active)
|
|
.count()
|
|
});
|
|
report_control_point_lights_once(
|
|
&mut self.control_point_lights_diagnostic_reported,
|
|
control_clock_ms,
|
|
self.control_clock_target_ms,
|
|
environment_point_light_count,
|
|
&control_point_lights,
|
|
&control_vulkan_lights,
|
|
);
|
|
self.pending_scene_update = Some(SceneFrameUpdate {
|
|
clock,
|
|
camera,
|
|
viewport,
|
|
aspect,
|
|
control_point_lights,
|
|
control_vulkan_lights,
|
|
effect_updates,
|
|
world_sort_keys,
|
|
ambient_sort_keys,
|
|
material_update,
|
|
environment_material_update,
|
|
environment,
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
fn refresh_pending_scene_viewport(
|
|
&mut self,
|
|
viewport: [f32; 2],
|
|
aspect: f32,
|
|
) -> Result<(), String> {
|
|
let Some((previous_camera, previous_aspect)) = self
|
|
.pending_scene_update
|
|
.as_ref()
|
|
.map(|pending| (pending.camera, pending.aspect))
|
|
else {
|
|
return Ok(());
|
|
};
|
|
let camera = previous_camera.with_aspect(previous_aspect, aspect);
|
|
self.view_camera = camera;
|
|
self.camera = camera.vulkan_camera();
|
|
let environment_camera = environment_camera_for_preview(&camera, aspect, viewport);
|
|
if let Some(environment) = self
|
|
.pending_scene_update
|
|
.as_mut()
|
|
.and_then(|pending| pending.environment.as_mut())
|
|
{
|
|
environment
|
|
.environment_frame
|
|
.reproject_for_camera(environment_camera);
|
|
}
|
|
let pending = self
|
|
.pending_scene_update
|
|
.as_mut()
|
|
.expect("pending scene update was checked above");
|
|
pending.camera = camera;
|
|
pending.viewport = viewport;
|
|
pending.aspect = aspect;
|
|
Ok(())
|
|
}
|
|
|
|
fn render_scene_update(&mut self) -> Result<(), String> {
|
|
let Some(frame) = self.pending_scene_update.as_ref() else {
|
|
return Ok(());
|
|
};
|
|
let Some(renderer) = self.renderer.as_mut() else {
|
|
return Ok(());
|
|
};
|
|
renderer
|
|
.set_camera(frame.camera.vulkan_camera())
|
|
.map_err(|error| format!("apply sampled scene camera: {error}"))?;
|
|
apply_material_frame_update(
|
|
&frame.material_update,
|
|
&mut self.material_animations,
|
|
renderer,
|
|
)?;
|
|
for update in &frame.effect_updates {
|
|
renderer
|
|
.set_draw_range_index_count(update.range_index, update.index_count)
|
|
.map_err(|error| {
|
|
format!(
|
|
"update ambient FX draw range {}: {error}",
|
|
update.range_index
|
|
)
|
|
})?;
|
|
}
|
|
for &(range_index, sort_key) in &frame.world_sort_keys {
|
|
renderer
|
|
.set_draw_range_sort_key(range_index, sort_key)
|
|
.map_err(|error| {
|
|
format!("update dynamic world draw range {range_index} sort key: {error}")
|
|
})?;
|
|
}
|
|
for &(range_index, sort_key) in &frame.ambient_sort_keys {
|
|
renderer
|
|
.set_draw_range_sort_key(range_index, sort_key)
|
|
.map_err(|error| {
|
|
format!("update ambient FX draw range {range_index} sort key: {error}")
|
|
})?;
|
|
}
|
|
Self::update_world_transparency(
|
|
&self.mesh,
|
|
&self.materials,
|
|
&self.world_range_indices,
|
|
&self.type16_present_ranges,
|
|
&self.world_range_index_counts,
|
|
&self.native_handedness_by_range,
|
|
frame.camera.position,
|
|
horizontal_fov(&frame.camera, frame.aspect),
|
|
&frame.material_update.active_materials,
|
|
renderer,
|
|
)?;
|
|
let shadow_camera = ShadowCamera {
|
|
position: frame.camera.position,
|
|
viewport_width: frame.viewport[0],
|
|
horizontal_fov: horizontal_fov(&frame.camera, frame.aspect),
|
|
};
|
|
if let Some(sampled_environment) = frame.environment.as_ref() {
|
|
let environment = self
|
|
.environment
|
|
.as_ref()
|
|
.ok_or_else(|| "sampled atmosphere lost its owner".to_string())?;
|
|
let sky_frame = SkyFrame::from_atmosphere(
|
|
&sampled_environment.atmosphere,
|
|
&environment.materials,
|
|
sampled_environment.absolute_time_seconds,
|
|
);
|
|
let environment_frame = &sampled_environment.environment_frame;
|
|
let mut uniforms = renderer.frame_uniforms();
|
|
uniforms.clip_from_world = frame.camera.clip_from_world;
|
|
for (target, source) in uniforms
|
|
.directional_lights
|
|
.iter_mut()
|
|
.zip(sky_frame.directional_lights)
|
|
{
|
|
target.direction = source.direction;
|
|
target.rgb = source.color;
|
|
target.coefficients = [0.0, 0.0, 1.0];
|
|
target.active = source.active;
|
|
}
|
|
uniforms.lighting_floor = sky_frame.lighting_floor;
|
|
let fog_rgb = sky_frame
|
|
.screen_gradient(frame.camera.native_sky_heading(), [0.0; 3])
|
|
.color
|
|
.rgba();
|
|
uniforms.fog_color = [fog_rgb[0], fog_rgb[1], fog_rgb[2]];
|
|
uniforms.fog_start = sky_frame.fog.start.max(0.0);
|
|
uniforms.fog_end = sky_frame.fog.end.max(uniforms.fog_start);
|
|
uniforms.camera_position = frame.camera.position;
|
|
renderer
|
|
.set_frame_uniforms(uniforms)
|
|
.map_err(|error| format!("update sky frame uniforms: {error}"))?;
|
|
let mut uniforms = renderer.frame_uniforms();
|
|
uniforms.point_light = environment_frame
|
|
.point_lights
|
|
.iter()
|
|
.find(|light| light.active)
|
|
.map(|light| VulkanPointLight {
|
|
position: light.position,
|
|
rgb: [light.color[0], light.color[1], light.color[2]],
|
|
range: light.range,
|
|
coefficients: light.attenuation,
|
|
active: true,
|
|
})
|
|
.unwrap_or_default();
|
|
renderer
|
|
.set_frame_uniforms(uniforms)
|
|
.map_err(|error| format!("update environment point-light uniforms: {error}"))?;
|
|
let mut point_lights = environment_frame
|
|
.point_lights
|
|
.iter()
|
|
.filter(|light| light.active)
|
|
.map(|light| VulkanPointLight {
|
|
position: light.position,
|
|
rgb: [light.color[0], light.color[1], light.color[2]],
|
|
range: light.range,
|
|
coefficients: light.attenuation,
|
|
active: true,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
point_lights.extend(frame.control_vulkan_lights.iter().copied());
|
|
renderer
|
|
.set_point_lights(&point_lights)
|
|
.map_err(|error| format!("update complete scene point-light set: {error}"))?;
|
|
if let Some(environment_gpu) = self.environment_gpu.as_mut() {
|
|
let material_update = frame
|
|
.environment_material_update
|
|
.as_ref()
|
|
.ok_or_else(|| "sampled environment material state is missing".to_string())?;
|
|
apply_material_frame_update(
|
|
material_update,
|
|
&mut environment_gpu.material_animations,
|
|
renderer,
|
|
)?;
|
|
environment_gpu.update(
|
|
&mut self.mesh,
|
|
renderer,
|
|
material_update,
|
|
&sky_frame,
|
|
&sampled_environment.sky_mesh,
|
|
environment_frame,
|
|
&frame.camera,
|
|
frame.viewport,
|
|
&self.terrain,
|
|
&self.sun_occlusion_range_indices,
|
|
)?;
|
|
}
|
|
let shadow_material = self
|
|
.materials
|
|
.get_mut(self.shadow_material_index)
|
|
.ok_or_else(|| "projected shadow material is out of bounds".to_string())?;
|
|
self.shadow_last_evidence = Self::update_shadow(
|
|
&mut self.mesh,
|
|
&mut self.shadow_cache,
|
|
&self.shadow_casters,
|
|
&self.shadow_receivers,
|
|
self.shadow_range,
|
|
self.shadow_base_vertex,
|
|
self.shadow_base_index,
|
|
self.shadow_material_index,
|
|
&mut shadow_material.texture,
|
|
renderer,
|
|
&frame.control_point_lights,
|
|
Some(&sky_frame),
|
|
Some(environment_frame),
|
|
Some(shadow_camera),
|
|
)?;
|
|
} else {
|
|
renderer
|
|
.set_point_lights(&frame.control_vulkan_lights)
|
|
.map_err(|error| format!("update complete scene point-light set: {error}"))?;
|
|
let shadow_material = self
|
|
.materials
|
|
.get_mut(self.shadow_material_index)
|
|
.ok_or_else(|| "projected shadow material is out of bounds".to_string())?;
|
|
self.shadow_last_evidence = Self::update_shadow(
|
|
&mut self.mesh,
|
|
&mut self.shadow_cache,
|
|
&self.shadow_casters,
|
|
&self.shadow_receivers,
|
|
self.shadow_range,
|
|
self.shadow_base_vertex,
|
|
self.shadow_base_index,
|
|
self.shadow_material_index,
|
|
&mut shadow_material.texture,
|
|
renderer,
|
|
&frame.control_point_lights,
|
|
None,
|
|
None,
|
|
Some(shadow_camera),
|
|
)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn record_presented_scene_frame(&mut self) -> u64 {
|
|
let frame_index = self.frames_presented;
|
|
self.frames_presented = self.frames_presented.saturating_add(1);
|
|
self.pending_scene_update = None;
|
|
frame_index
|
|
}
|
|
|
|
fn set_mouse_look(&mut self, active: bool) {
|
|
self.mouse_look = active;
|
|
if let Some(window) = self.window.as_ref() {
|
|
if active {
|
|
// Relative device motion keeps the camera usable at the edge
|
|
// of the window. Locked grab is unavailable on a few window
|
|
// backends, so confined grab is a safe fallback.
|
|
if window.set_cursor_grab(CursorGrabMode::Locked).is_err() {
|
|
let _ = window.set_cursor_grab(CursorGrabMode::Confined);
|
|
}
|
|
} else {
|
|
let _ = window.set_cursor_grab(CursorGrabMode::None);
|
|
}
|
|
window.set_cursor_visible(!active);
|
|
}
|
|
}
|
|
|
|
fn schedule_next_redraw(&self) {
|
|
if let Some(window) = self.window.as_ref() {
|
|
window.request_redraw();
|
|
}
|
|
}
|
|
|
|
fn complete(&mut self, event_loop: &ActiveEventLoop) {
|
|
if let Some(audio) = self.audio.as_mut() {
|
|
audio.shutdown();
|
|
}
|
|
let Some(renderer) = self.renderer.take() else {
|
|
self.error = Some("native Vulkan renderer was not initialized".to_string());
|
|
event_loop.exit();
|
|
return;
|
|
};
|
|
let report = match renderer.shutdown() {
|
|
Ok(report) => report,
|
|
Err(err) => {
|
|
self.error = Some(err.to_string());
|
|
event_loop.exit();
|
|
return;
|
|
}
|
|
};
|
|
self.window.take();
|
|
if self.enable_validation
|
|
&& (report.validation.warning_count != 0 || report.validation.error_count != 0)
|
|
{
|
|
self.error = Some(format!(
|
|
"native Vulkan validation must stay clean (warnings={}, errors={}, vuids={:?})",
|
|
report.validation.warning_count,
|
|
report.validation.error_count,
|
|
report.validation.vuids,
|
|
));
|
|
event_loop.exit();
|
|
return;
|
|
}
|
|
let validation_status = if self.enable_validation {
|
|
"clean"
|
|
} else {
|
|
"disabled"
|
|
};
|
|
let readback_path = match (&self.readback_out, &report.readback_artifact) {
|
|
(Some(path), Some(artifact)) => {
|
|
if let Some(parent) = path.parent() {
|
|
if let Err(err) = std::fs::create_dir_all(parent) {
|
|
self.error = Some(format!("{}: {err}", parent.display()));
|
|
event_loop.exit();
|
|
return;
|
|
}
|
|
}
|
|
if let Err(err) = std::fs::write(path, &artifact.bytes) {
|
|
self.error = Some(format!("{}: {err}", path.display()));
|
|
event_loop.exit();
|
|
return;
|
|
}
|
|
Some(path.display().to_string())
|
|
}
|
|
(Some(_), None) => {
|
|
self.error =
|
|
Some("native Vulkan renderer produced no synchronized readback".to_string());
|
|
event_loop.exit();
|
|
return;
|
|
}
|
|
(None, _) => None,
|
|
};
|
|
let readback_format = report
|
|
.readback_artifact
|
|
.as_ref()
|
|
.map_or(report.renderer_report.swapchain_image_format, |artifact| {
|
|
artifact.format
|
|
});
|
|
if let Some(control_clock_ms) = self.control_clock_target_ms {
|
|
emit_control_animation_capture_diagnostics(
|
|
&self.control_animation_instances,
|
|
&self.component_pose_buffers,
|
|
control_clock_ms,
|
|
);
|
|
}
|
|
let captured_crop = self.captured_viewport.map_or_else(
|
|
|| "none".to_string(),
|
|
|viewport| {
|
|
format!(
|
|
"{:?} ({}x{})",
|
|
viewport.rect, viewport.extent[0], viewport.extent[1]
|
|
)
|
|
},
|
|
);
|
|
self.output = Some(format!(
|
|
"rendered mission {}: {} frames, {} objects, {} mesh components, {} terrain components, camera={}, captured_viewport_crop={}, control_clock_ms={}, control_clock_mode={}, materials={}, environment_primitives={}, shadow_casters={}, shadow_indices={}, swapchain_recreates={}, validation={}, swapchain_format={}, readback_format={}, readback={}, readback_sequence={}, startup_ms={{mission_assets:{:.1},terrain_materials:{:.1},sky:{:.1},gpu_initialization:{:.1}}}",
|
|
self.mission,
|
|
self.frames_presented,
|
|
self.object_count,
|
|
self.mesh_components,
|
|
self.terrain_components,
|
|
self.camera_mode,
|
|
captured_crop,
|
|
self.control_clock_target_ms
|
|
.map_or_else(|| "realtime".to_string(), |clock| clock.to_string()),
|
|
control_clock_mode_label(self.control_clock_target_ms),
|
|
self.materials.len(),
|
|
self.environment_primitive_count,
|
|
self.shadow_last_evidence.visible_casters,
|
|
self.shadow_last_evidence.projected_indices,
|
|
report.swapchain_recreate_count,
|
|
validation_status,
|
|
report.renderer_report.swapchain_image_format,
|
|
readback_format,
|
|
readback_path.as_deref().unwrap_or("none"),
|
|
self.readback_sequence_out
|
|
.as_deref()
|
|
.map_or_else(|| "none".to_string(), |path| path.display().to_string()),
|
|
elapsed_ms(self.startup_timings.mission_assets),
|
|
elapsed_ms(self.startup_timings.terrain_materials),
|
|
elapsed_ms(self.startup_timings.sky),
|
|
elapsed_ms(self.startup_timings.gpu_initialization),
|
|
));
|
|
event_loop.exit();
|
|
}
|
|
|
|
fn finish(self) -> Result<String, String> {
|
|
self.output.ok_or_else(|| {
|
|
self.error.unwrap_or_else(|| {
|
|
"native Vulkan mode exited before producing a report".to_string()
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
impl ApplicationHandler for StaticVulkanApp {
|
|
fn device_event(
|
|
&mut self,
|
|
_event_loop: &ActiveEventLoop,
|
|
_device_id: DeviceId,
|
|
event: DeviceEvent,
|
|
) {
|
|
if self.mouse_look {
|
|
if let DeviceEvent::MouseMotion { delta } = event {
|
|
if let Some(camera) = self.free_camera.as_mut() {
|
|
camera.look_delta(delta.0, delta.1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
|
if self.window.is_some() {
|
|
return;
|
|
}
|
|
let plan = match WinitWindowPlan::smoke().validate() {
|
|
Ok(plan) => plan,
|
|
Err(err) => {
|
|
self.error = Some(err.to_string());
|
|
event_loop.exit();
|
|
return;
|
|
}
|
|
};
|
|
let (width, height) = self
|
|
.captured_viewport
|
|
.map(|viewport| (viewport.extent[0], viewport.extent[1]))
|
|
.unwrap_or((plan.width, plan.height));
|
|
let attributes = Window::default_attributes()
|
|
.with_title("FParkan mission")
|
|
.with_inner_size(WinitPhysicalSize::new(width, height))
|
|
// The captured clip matrix is exact for the recorded viewport
|
|
// aspect. Keep that drawable fixed; compare it with the same
|
|
// (left, top, right, bottom) crop in the original capture.
|
|
.with_resizable(self.captured_viewport.is_none());
|
|
let window = match event_loop.create_window(attributes) {
|
|
Ok(window) => window,
|
|
Err(err) => {
|
|
self.error = Some(format!("winit window: {err}"));
|
|
event_loop.exit();
|
|
return;
|
|
}
|
|
};
|
|
let size = window.inner_size();
|
|
if let Some(captured_viewport) = self.captured_viewport {
|
|
if let Err(error) =
|
|
validate_captured_drawable_extent(captured_viewport, [size.width, size.height])
|
|
{
|
|
self.error = Some(error);
|
|
event_loop.exit();
|
|
return;
|
|
}
|
|
}
|
|
let Some(native_handles) = window_native_handles(&window) else {
|
|
self.error = Some("winit window does not expose native handles".to_string());
|
|
event_loop.exit();
|
|
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,
|
|
drawable_extent: (size.width.max(1), size.height.max(1)),
|
|
render_request: WinitWindow::default_render_request(),
|
|
enable_validation: self.enable_validation,
|
|
mesh: self.mesh.clone(),
|
|
camera: self.camera,
|
|
materials: self.materials.clone(),
|
|
bootstrap_progress: None,
|
|
}) {
|
|
Ok(renderer) => renderer,
|
|
Err(error) => {
|
|
self.error = Some(error.to_string());
|
|
event_loop.exit();
|
|
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,
|
|
size.width.max(1) as f32 / size.height.max(1) as f32,
|
|
) {
|
|
self.error = Some(error);
|
|
event_loop.exit();
|
|
return;
|
|
}
|
|
if let Err(error) = self.configure_ambient_effect_draw_ranges(&mut renderer) {
|
|
self.error = Some(error);
|
|
event_loop.exit();
|
|
return;
|
|
}
|
|
match self.world_draw_sort_keys(self.view_camera.position) {
|
|
Ok(sort_keys) => {
|
|
for (range_index, sort_key) in sort_keys {
|
|
if let Err(error) = renderer.set_draw_range_sort_key(range_index, sort_key) {
|
|
self.error = Some(format!(
|
|
"update world draw range {range_index} sort key: {error}"
|
|
));
|
|
event_loop.exit();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
Err(error) => {
|
|
self.error = Some(error);
|
|
event_loop.exit();
|
|
return;
|
|
}
|
|
}
|
|
self.startup_timings.gpu_initialization = gpu_initialization_started.elapsed();
|
|
renderer.set_readback_enabled(
|
|
self.readback_out.is_some() || self.readback_sequence_out.is_some(),
|
|
);
|
|
self.window_id = Some(window.id());
|
|
self.window = Some(window);
|
|
self.renderer = Some(renderer);
|
|
self.simulation_last_tick = Instant::now();
|
|
self.live_clock_paused_for_zero_extent = false;
|
|
self.pending_scene_update = None;
|
|
let drawable_size = self
|
|
.window
|
|
.as_ref()
|
|
.map(Window::inner_size)
|
|
.unwrap_or_default();
|
|
if let Err(error) = self.update_free_camera(
|
|
0.0,
|
|
[drawable_size.width as f32, drawable_size.height as f32],
|
|
) {
|
|
self.error = Some(error);
|
|
event_loop.exit();
|
|
return;
|
|
}
|
|
self.schedule_next_redraw();
|
|
}
|
|
|
|
fn window_event(
|
|
&mut self,
|
|
event_loop: &ActiveEventLoop,
|
|
window_id: WindowId,
|
|
event: WindowEvent,
|
|
) {
|
|
if Some(window_id) != self.window_id {
|
|
return;
|
|
}
|
|
match event {
|
|
WindowEvent::CloseRequested => {
|
|
self.complete(event_loop);
|
|
}
|
|
WindowEvent::Focused(focused) => {
|
|
if !focused {
|
|
self.pressed_keys.clear();
|
|
self.set_mouse_look(false);
|
|
}
|
|
if let Some(audio) = self.audio.as_mut() {
|
|
audio.on_focus_changed(focused);
|
|
}
|
|
}
|
|
WindowEvent::KeyboardInput { event, .. } => {
|
|
if let PhysicalKey::Code(code) = event.physical_key {
|
|
if code == KeyCode::Escape && event.state == ElementState::Pressed {
|
|
if self.mouse_look {
|
|
self.set_mouse_look(false);
|
|
} else {
|
|
self.complete(event_loop);
|
|
}
|
|
} else if event.state == ElementState::Pressed {
|
|
self.pressed_keys.insert(code);
|
|
} else {
|
|
self.pressed_keys.remove(&code);
|
|
}
|
|
}
|
|
}
|
|
WindowEvent::MouseInput { state, button, .. } => {
|
|
if button == MouseButton::Right {
|
|
self.set_mouse_look(state == ElementState::Pressed);
|
|
}
|
|
}
|
|
WindowEvent::CursorMoved { .. } => {}
|
|
WindowEvent::Resized(size) => {
|
|
// Queued creation resize events can predate the current physical drawable.
|
|
let drawable_size = if self.captured_viewport.is_some() {
|
|
self.window.as_ref().map(Window::inner_size).unwrap_or(size)
|
|
} else {
|
|
size
|
|
};
|
|
if let Some(captured_viewport) = self.captured_viewport {
|
|
if drawable_size.width != 0 && drawable_size.height != 0 {
|
|
if let Err(error) = validate_captured_drawable_extent(
|
|
captured_viewport,
|
|
[drawable_size.width, drawable_size.height],
|
|
) {
|
|
self.error = Some(error);
|
|
event_loop.exit();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
if let Some(renderer) = self.renderer.as_mut() {
|
|
renderer.request_resize((drawable_size.width, drawable_size.height));
|
|
}
|
|
if drawable_size.width == 0 || drawable_size.height == 0 {
|
|
event_loop.set_control_flow(ControlFlow::Wait);
|
|
self.pause_live_clock_at(Instant::now());
|
|
} else {
|
|
event_loop.set_control_flow(ControlFlow::Poll);
|
|
self.resume_live_clock_at(Instant::now());
|
|
// The pending simulation sample is reused; its view and
|
|
// viewport-dependent geometry refresh before the retry.
|
|
}
|
|
}
|
|
WindowEvent::RedrawRequested => {
|
|
if self.pending_scene_update.is_none() {
|
|
return;
|
|
}
|
|
if let Err(error) = self.render_scene_update() {
|
|
self.error = Some(error);
|
|
event_loop.exit();
|
|
return;
|
|
}
|
|
let diagnostic_clock_ms = self
|
|
.pending_scene_update
|
|
.as_ref()
|
|
.map_or(self.simulation_time_ms, |frame| frame.clock.time_ms);
|
|
let outcome = {
|
|
let Some(renderer) = self.renderer.as_mut() else {
|
|
self.error = Some("native Vulkan renderer was not initialized".to_string());
|
|
event_loop.exit();
|
|
return;
|
|
};
|
|
renderer.draw_frame()
|
|
};
|
|
match outcome {
|
|
Ok(VulkanSmokeFrameOutcome::Presented) => {
|
|
let frame_index = self.record_presented_scene_frame();
|
|
if let Some(directory) = self.readback_sequence_out.as_deref() {
|
|
let artifact = match self
|
|
.renderer
|
|
.as_mut()
|
|
.expect("renderer presented a frame")
|
|
.readback_last_submitted_frame()
|
|
{
|
|
Ok(Some(artifact)) => artifact,
|
|
Ok(None) => {
|
|
self.error = Some(
|
|
"native Vulkan renderer produced no synchronized sequence readback"
|
|
.to_string(),
|
|
);
|
|
event_loop.exit();
|
|
return;
|
|
}
|
|
Err(error) => {
|
|
self.error =
|
|
Some(format!("read back frame {frame_index}: {error}"));
|
|
event_loop.exit();
|
|
return;
|
|
}
|
|
};
|
|
if let Err(error) = write_readback_sequence_frame(
|
|
directory,
|
|
frame_index,
|
|
diagnostic_clock_ms,
|
|
&artifact,
|
|
) {
|
|
self.error = Some(error);
|
|
event_loop.exit();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
Ok(VulkanSmokeFrameOutcome::Recreated) => {}
|
|
Ok(VulkanSmokeFrameOutcome::ZeroExtent) => {
|
|
if self.window.as_ref().is_some_and(|window| {
|
|
let size = window.inner_size();
|
|
size.width == 0 || size.height == 0
|
|
}) {
|
|
event_loop.set_control_flow(ControlFlow::Wait);
|
|
self.pause_live_clock_at(Instant::now());
|
|
}
|
|
}
|
|
Err(err) => {
|
|
self.error = Some(err.to_string());
|
|
event_loop.exit();
|
|
return;
|
|
}
|
|
}
|
|
if self.target_frames != 0 && self.frames_presented >= self.target_frames {
|
|
self.complete(event_loop);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
|
if self.output.is_some() || self.error.is_some() {
|
|
return;
|
|
}
|
|
let Some(window) = self.window.as_ref() else {
|
|
return;
|
|
};
|
|
let size = window.inner_size();
|
|
if size.width == 0 || size.height == 0 {
|
|
// Wait for a resize instead of spinning simulation updates while
|
|
// the window has no drawable surface.
|
|
event_loop.set_control_flow(ControlFlow::Wait);
|
|
self.pause_live_clock_at(Instant::now());
|
|
return;
|
|
}
|
|
event_loop.set_control_flow(ControlFlow::Poll);
|
|
self.resume_live_clock_at(Instant::now());
|
|
let clock = self.pending_or_sample_frame_clock();
|
|
if let Err(error) =
|
|
self.prepare_scene_update(clock, [size.width as f32, size.height as f32])
|
|
{
|
|
self.error = Some(error);
|
|
event_loop.exit();
|
|
return;
|
|
}
|
|
self.schedule_next_redraw();
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
struct Args {
|
|
root: PathBuf,
|
|
mission: String,
|
|
frames: u64,
|
|
atmosphere_seconds: Option<f32>,
|
|
control_clock_ms: Option<u32>,
|
|
validation: bool,
|
|
readback_out: Option<PathBuf>,
|
|
readback_sequence_out: Option<PathBuf>,
|
|
preview_roots: NonZeroUsize,
|
|
legacy_camera_capture: Option<PathBuf>,
|
|
static_animation_frame: Option<u16>,
|
|
static_material_phase: Option<u16>,
|
|
}
|
|
|
|
impl Args {
|
|
fn parse(args: &[String]) -> Result<Self, String> {
|
|
let mut root = None;
|
|
let mut mission = None;
|
|
// Zero means keep the interactive preview open until the user closes
|
|
// it. A bounded `--frames N` remains available for smoke/readback
|
|
// runs and validation scripts.
|
|
let mut frames = 0;
|
|
let mut atmosphere_seconds = None;
|
|
let mut control_clock_ms = None;
|
|
let mut validation = false;
|
|
let mut readback_out = None;
|
|
let mut readback_sequence_out = None;
|
|
// A native static-Vulkan invocation is the usable mission preview, so
|
|
// its default scope covers every root. `PreviewRoots` is clamped by the
|
|
// runtime to the decoded mission length; an explicit --preview-roots N
|
|
// remains available for bounded diagnostic work.
|
|
let mut preview_roots = NonZeroUsize::MAX;
|
|
let mut legacy_camera_capture = None;
|
|
let mut static_animation_frame = None;
|
|
let mut static_material_phase = None;
|
|
let mut iter = args.iter();
|
|
while let Some(arg) = iter.next() {
|
|
match arg.as_str() {
|
|
"--root" => {
|
|
root = Some(
|
|
iter.next()
|
|
.map(PathBuf::from)
|
|
.ok_or_else(|| "--root requires a path".to_string())?,
|
|
);
|
|
}
|
|
"--mission" => {
|
|
mission = Some(
|
|
iter.next()
|
|
.cloned()
|
|
.ok_or_else(|| "--mission requires a path".to_string())?,
|
|
);
|
|
}
|
|
"--frames" => {
|
|
frames = iter
|
|
.next()
|
|
.ok_or_else(|| "--frames requires a value".to_string())?
|
|
.parse()
|
|
.map_err(|_| "--frames must be an integer".to_string())?;
|
|
}
|
|
"--atmosphere-seconds" => {
|
|
let value = iter
|
|
.next()
|
|
.ok_or_else(|| "--atmosphere-seconds requires a value".to_string())?
|
|
.parse::<f32>()
|
|
.map_err(|_| "--atmosphere-seconds must be a number".to_string())?;
|
|
if !value.is_finite() || value < 0.0 {
|
|
return Err(
|
|
"--atmosphere-seconds must be finite and non-negative".to_string()
|
|
);
|
|
}
|
|
atmosphere_seconds = Some(value);
|
|
}
|
|
"--control-clock-ms" => {
|
|
control_clock_ms = Some(
|
|
iter.next()
|
|
.ok_or_else(|| "--control-clock-ms requires a value".to_string())?
|
|
.parse()
|
|
.map_err(|_| "--control-clock-ms must be a u32 integer".to_string())?,
|
|
);
|
|
}
|
|
"--validation" => {
|
|
validation = true;
|
|
}
|
|
"--readback-out" => {
|
|
readback_out = Some(
|
|
iter.next()
|
|
.map(PathBuf::from)
|
|
.ok_or_else(|| "--readback-out requires a path".to_string())?,
|
|
);
|
|
}
|
|
"--readback-sequence-out" => {
|
|
readback_sequence_out =
|
|
Some(iter.next().map(PathBuf::from).ok_or_else(|| {
|
|
"--readback-sequence-out requires a path".to_string()
|
|
})?);
|
|
}
|
|
"--preview-roots" => {
|
|
preview_roots = iter
|
|
.next()
|
|
.ok_or_else(|| "--preview-roots requires a value".to_string())?
|
|
.parse()
|
|
.map_err(|_| "--preview-roots must be a non-zero integer".to_string())?;
|
|
}
|
|
"--legacy-camera-capture" => {
|
|
legacy_camera_capture =
|
|
Some(iter.next().map(PathBuf::from).ok_or_else(|| {
|
|
"--legacy-camera-capture requires a path".to_string()
|
|
})?);
|
|
}
|
|
"--static-animation-frame" => {
|
|
static_animation_frame = Some(
|
|
iter.next()
|
|
.ok_or_else(|| "--static-animation-frame requires a value".to_string())?
|
|
.parse()
|
|
.map_err(|_| {
|
|
"--static-animation-frame must be a u16 integer".to_string()
|
|
})?,
|
|
);
|
|
}
|
|
"--static-material-phase" => {
|
|
static_material_phase = Some(
|
|
iter.next()
|
|
.ok_or_else(|| "--static-material-phase requires a value".to_string())?
|
|
.parse()
|
|
.map_err(|_| {
|
|
"--static-material-phase must be a u16 integer".to_string()
|
|
})?,
|
|
);
|
|
}
|
|
_ => return Err(usage()),
|
|
}
|
|
}
|
|
let root = root.ok_or_else(|| "missing --root".to_string())?;
|
|
let mission = mission.ok_or_else(|| "missing --mission".to_string())?;
|
|
if control_clock_ms.is_some() && frames < 2 {
|
|
return Err(
|
|
"--control-clock-ms requires --frames of at least 2 to replay the animation clock"
|
|
.to_string(),
|
|
);
|
|
}
|
|
if readback_sequence_out.is_some() && control_clock_ms.is_none() {
|
|
return Err(
|
|
"--readback-sequence-out requires --control-clock-ms for a reproducible sequence"
|
|
.to_string(),
|
|
);
|
|
}
|
|
Ok(Self {
|
|
root,
|
|
mission,
|
|
frames,
|
|
atmosphere_seconds,
|
|
control_clock_ms,
|
|
validation,
|
|
readback_out,
|
|
readback_sequence_out,
|
|
preview_roots,
|
|
legacy_camera_capture,
|
|
static_animation_frame,
|
|
static_material_phase,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, PartialEq)]
|
|
struct LegacyCameraCapture {
|
|
schema: String,
|
|
render_input_usable: Option<bool>,
|
|
selector0_words: [u32; 16],
|
|
parented_camera: Option<bool>,
|
|
effective_world_matrix_space: Option<String>,
|
|
effective_world_matrix_words: Option<[u32; 16]>,
|
|
viewport: [i32; 4],
|
|
near_plane: f32,
|
|
far_plane: f32,
|
|
field_of_view_radians: f32,
|
|
atmosphere_seconds: Option<f32>,
|
|
}
|
|
|
|
fn selected_atmosphere_seconds(
|
|
command_line: Option<f32>,
|
|
capture: Option<&LegacyCameraCapture>,
|
|
) -> Option<f32> {
|
|
command_line.or_else(|| capture.and_then(|capture| capture.atmosphere_seconds))
|
|
}
|
|
|
|
impl LegacyCameraCapture {
|
|
fn preview_matrix_words(&self) -> Result<[u32; 16], String> {
|
|
match (
|
|
self.effective_world_matrix_space.as_deref(),
|
|
self.effective_world_matrix_words,
|
|
) {
|
|
(Some("world"), Some(words)) => Ok(words),
|
|
(Some(_), _) => Err(
|
|
"legacy camera effective_world_matrix_space must be 'world'".to_string(),
|
|
),
|
|
(None, Some(_)) => Err(
|
|
"legacy camera effective_world_matrix_words requires an explicit world-space marker"
|
|
.to_string(),
|
|
),
|
|
(None, None) if self.parented_camera == Some(true) => Err(
|
|
"parented legacy camera requires an effective world-space matrix".to_string(),
|
|
),
|
|
(None, None) => Ok(self.selector0_words),
|
|
}
|
|
}
|
|
|
|
fn captured_viewport(&self) -> Result<CapturedViewport, String> {
|
|
let width = self.viewport[2]
|
|
.checked_sub(self.viewport[0])
|
|
.filter(|width| *width > 0)
|
|
.ok_or_else(|| "legacy camera capture has an invalid viewport width".to_string())?;
|
|
let height = self.viewport[3]
|
|
.checked_sub(self.viewport[1])
|
|
.filter(|height| *height > 0)
|
|
.ok_or_else(|| "legacy camera capture has an invalid viewport height".to_string())?;
|
|
Ok(CapturedViewport {
|
|
rect: self.viewport,
|
|
extent: [
|
|
u32::try_from(width)
|
|
.map_err(|_| "legacy camera viewport width exceeds u32".to_string())?,
|
|
u32::try_from(height)
|
|
.map_err(|_| "legacy camera viewport height exceeds u32".to_string())?,
|
|
],
|
|
})
|
|
}
|
|
|
|
fn preview_camera_frame(&self) -> Result<PreviewCameraFrame, String> {
|
|
let frame = PreviewCameraFrame::from_legacy_d3d7(
|
|
RawCameraTransform {
|
|
words: self.preview_matrix_words()?,
|
|
},
|
|
LegacyD3d7Projection {
|
|
viewport: self.viewport,
|
|
near_plane: self.near_plane,
|
|
far_plane: self.far_plane,
|
|
field_of_view_radians: self.field_of_view_radians,
|
|
},
|
|
)
|
|
.ok_or_else(|| "legacy camera capture contains an invalid D3D7 camera".to_string())?;
|
|
self.captured_viewport()?;
|
|
Ok(frame)
|
|
}
|
|
}
|
|
|
|
fn load_legacy_camera_capture(path: &std::path::Path) -> Result<LegacyCameraCapture, String> {
|
|
let bytes = std::fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
|
|
parse_legacy_camera_capture(&bytes).map_err(|err| format!("{}: {err}", path.display()))
|
|
}
|
|
|
|
fn parse_legacy_camera_capture(bytes: &[u8]) -> Result<LegacyCameraCapture, String> {
|
|
// Windows PowerShell 5.1 writes redirected text as UTF-16LE with a BOM.
|
|
// `capture-original-camera.ps1` intentionally emits plain JSON, so accept
|
|
// that normal hand-off format as well as UTF-8 without making the caller
|
|
// re-encode a read-only capture file.
|
|
let json = decode_legacy_camera_capture_json(bytes)?;
|
|
let capture: LegacyCameraCapture =
|
|
serde_json::from_str(&json).map_err(|err| format!("invalid legacy camera JSON: {err}"))?;
|
|
if capture.schema != "fparkan-legacy-camera-v1" {
|
|
return Err("unsupported legacy camera capture schema".to_string());
|
|
}
|
|
if capture.effective_world_matrix_space.is_some()
|
|
!= capture.effective_world_matrix_words.is_some()
|
|
{
|
|
return Err(
|
|
"effective_world_matrix_space and effective_world_matrix_words must be supplied together"
|
|
.to_string(),
|
|
);
|
|
}
|
|
if capture.render_input_usable == Some(false) {
|
|
return Err("legacy camera capture is marked render_input_usable=false".to_string());
|
|
}
|
|
if capture
|
|
.atmosphere_seconds
|
|
.is_some_and(|seconds| !seconds.is_finite() || seconds < 0.0)
|
|
{
|
|
return Err(
|
|
"legacy camera capture atmosphere_seconds must be finite and non-negative".to_string(),
|
|
);
|
|
}
|
|
capture.preview_camera_frame()?;
|
|
Ok(capture)
|
|
}
|
|
|
|
fn decode_legacy_camera_capture_json(bytes: &[u8]) -> Result<String, String> {
|
|
let decode_utf16 = |words: Vec<u16>| {
|
|
String::from_utf16(&words)
|
|
.map_err(|err| format!("invalid UTF-16 legacy camera JSON: {err}"))
|
|
};
|
|
match bytes {
|
|
[0xff, 0xfe, rest @ ..] => {
|
|
let chunks = rest.chunks_exact(2);
|
|
if !chunks.remainder().is_empty() {
|
|
return Err("invalid UTF-16LE legacy camera JSON length".to_string());
|
|
}
|
|
decode_utf16(
|
|
chunks
|
|
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
|
|
.collect(),
|
|
)
|
|
}
|
|
[0xfe, 0xff, rest @ ..] => {
|
|
let chunks = rest.chunks_exact(2);
|
|
if !chunks.remainder().is_empty() {
|
|
return Err("invalid UTF-16BE legacy camera JSON length".to_string());
|
|
}
|
|
decode_utf16(
|
|
chunks
|
|
.map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]]))
|
|
.collect(),
|
|
)
|
|
}
|
|
[0xef, 0xbb, 0xbf, rest @ ..] => std::str::from_utf8(rest)
|
|
.map(str::to_owned)
|
|
.map_err(|err| format!("invalid UTF-8 legacy camera JSON: {err}")),
|
|
_ => std::str::from_utf8(bytes)
|
|
.map(str::to_owned)
|
|
.map_err(|err| format!("invalid UTF-8 legacy camera JSON: {err}")),
|
|
}
|
|
}
|
|
|
|
fn usage() -> String {
|
|
"usage: fparkan-game --root <path> --mission <path> [--frames <n>] [--atmosphere-seconds <seconds>] [--control-clock-ms <u32>] [--validation] [--preview-roots <non-zero n>] [--legacy-camera-capture <path>] [--static-animation-frame <u16>] [--static-material-phase <u16>] [--readback-out <path>] [--readback-sequence-out <directory>]\nstatic preview uses saved per-component CTLD defaults, with uncontrolled nodes at frame 0; --static-animation-frame overrides all components globally. --control-clock-ms replays one shared MSH/FX clock from zero to the selected time over the requested frames; --readback-sequence-out captures each submitted frame and requires that clock.".to_string()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use fparkan_animation::{AnimKey24, Pose};
|
|
use fparkan_assets::{PreparedControlTimelineRow, PreparedControlTypedRecord};
|
|
use fparkan_fx::environment::{WeatherInput, WeatherState};
|
|
use fparkan_msh::{Batch, ModelAnimation, Slot};
|
|
use fparkan_path::{normalize_relative, PathPolicy};
|
|
use fparkan_resource::{resource_name, ResourceKey};
|
|
|
|
fn facing_mesh(
|
|
batch_flags: u16,
|
|
positions: &[[f32; 3]],
|
|
) -> (VulkanStaticMesh, VulkanStaticDrawRange) {
|
|
let range = VulkanStaticDrawRange {
|
|
first_index: 0,
|
|
index_count: u32::try_from(positions.len()).expect("fixture length fits u32"),
|
|
material_index: 0,
|
|
lightmap_index: 0,
|
|
batch_flags,
|
|
pipeline_state: LegacyPipelineState::default(),
|
|
terrain_base_only: false,
|
|
alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
|
|
};
|
|
let mesh = VulkanStaticMesh {
|
|
vertices: positions
|
|
.iter()
|
|
.copied()
|
|
.map(|position| VulkanStaticVertex {
|
|
position,
|
|
color: [1.0; 3],
|
|
normal: [0.0, 0.0, 1.0],
|
|
uv: [0.0; 2],
|
|
detail_uv: [0.0; 2],
|
|
overlay_alpha: 0.0,
|
|
})
|
|
.collect(),
|
|
indices: (0..positions.len())
|
|
.map(|index| u32::try_from(index).expect("fixture index fits u32"))
|
|
.collect(),
|
|
draw_ranges: vec![range],
|
|
};
|
|
(mesh, range)
|
|
}
|
|
|
|
fn native_front_triangle() -> [[f32; 3]; 3] {
|
|
[[-0.2, -0.2, -1.0], [0.2, -0.2, -1.0], [0.0, 0.2, -1.0]]
|
|
}
|
|
|
|
fn camera_face_triangle(camera: PreviewCameraFrame, mirror: bool) -> [[f32; 3]; 3] {
|
|
let center = add3(camera.position, scale3(camera.forward, 10.0));
|
|
let mirror = if mirror { -1.0 } else { 1.0 };
|
|
[
|
|
add3(
|
|
add3(center, scale3(camera.right, -2.0 * mirror)),
|
|
scale3(camera.up, -2.0),
|
|
),
|
|
add3(
|
|
add3(center, scale3(camera.right, 2.0 * mirror)),
|
|
scale3(camera.up, -2.0),
|
|
),
|
|
add3(center, scale3(camera.up, 2.0)),
|
|
]
|
|
}
|
|
|
|
fn projected_vulkan_area(camera: PreviewCameraFrame, triangle: [[f32; 3]; 3]) -> f32 {
|
|
let screen = triangle.map(|point| {
|
|
let clip =
|
|
row_vector_transform([point[0], point[1], point[2], 1.0], camera.clip_from_world);
|
|
assert!(clip[3] > 0.0 && clip.iter().all(|value| value.is_finite()));
|
|
// This is the positive-height viewport transform used by the Vulkan renderer.
|
|
[
|
|
(clip[0] / clip[3] + 1.0) * 0.5,
|
|
(clip[1] / clip[3] + 1.0) * 0.5,
|
|
]
|
|
});
|
|
let shoelace = (0..3)
|
|
.map(|index| {
|
|
let next = (index + 1) % 3;
|
|
screen[index][0] * screen[next][1] - screen[next][0] * screen[index][1]
|
|
})
|
|
.sum::<f32>()
|
|
* 0.5;
|
|
// Vulkan's framebuffer signed-area definition is the negative shoelace area.
|
|
-shoelace
|
|
}
|
|
|
|
#[test]
|
|
fn native_msh_cull_mode_matches_legacy_and_free_flight_camera_projection() {
|
|
let identity = RawCameraTransform {
|
|
words: [
|
|
1.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
1.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
1.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
1.0_f32.to_bits(),
|
|
],
|
|
};
|
|
let projection = LegacyD3d7Projection {
|
|
viewport: [0, 0, 1280, 1024],
|
|
near_plane: 0.5,
|
|
far_plane: 700.0,
|
|
field_of_view_radians: 1.3,
|
|
};
|
|
let legacy = PreviewCameraFrame::from_legacy_d3d7(identity, projection)
|
|
.expect("valid legacy camera projection");
|
|
let free_flight = FreeFlightCamera {
|
|
position: legacy.position,
|
|
yaw: 0.0,
|
|
pitch: 0.0,
|
|
horizontal_fov: 1.3,
|
|
near_plane: 0.5,
|
|
far_plane: 700.0,
|
|
move_speed: 1.0,
|
|
ground_clearance: 0.0,
|
|
}
|
|
.preview_frame(1280.0 / 1024.0);
|
|
|
|
for camera in [legacy, free_flight] {
|
|
let front = camera_face_triangle(camera, false);
|
|
let back = [front[0], front[2], front[1]];
|
|
let front_d = dot3(
|
|
sub3(camera.position, front[0]),
|
|
cross3(sub3(front[1], front[0]), sub3(front[2], front[0])),
|
|
);
|
|
let back_d = dot3(
|
|
sub3(camera.position, back[0]),
|
|
cross3(sub3(back[1], back[0]), sub3(back[2], back[0])),
|
|
);
|
|
assert!(front_d > 0.0);
|
|
assert!(back_d < 0.0);
|
|
|
|
let front_vk_area = projected_vulkan_area(camera, front);
|
|
let back_vk_area = projected_vulkan_area(camera, back);
|
|
// Vulkan CLOCKWISE front faces have negative framebuffer area.
|
|
// The native-visible d>0 face is therefore a Vulkan back face.
|
|
assert!(front_vk_area > 0.0, "front area: {front_vk_area}");
|
|
assert!(back_vk_area < 0.0, "back area: {back_vk_area}");
|
|
assert_eq!(native_msh_cull_mode(0, 1), LegacyCullMode::FrontFace);
|
|
|
|
let mirrored_front = camera_face_triangle(camera, true);
|
|
let mirrored_d = dot3(
|
|
sub3(camera.position, mirrored_front[0]),
|
|
cross3(
|
|
sub3(mirrored_front[1], mirrored_front[0]),
|
|
sub3(mirrored_front[2], mirrored_front[0]),
|
|
),
|
|
);
|
|
assert!(mirrored_d < 0.0);
|
|
assert!(projected_vulkan_area(camera, mirrored_front) < 0.0);
|
|
assert_eq!(native_msh_cull_mode(0, -1), LegacyCullMode::BackFace);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn native_msh_handedness_uses_full_placement_transform_and_handles_singular_scale() {
|
|
let rotated = LegacyIron3dEulerTransform {
|
|
translation: [8.0, -4.0, 3.0],
|
|
orientation_radians: [0.4, -0.7, 1.2],
|
|
};
|
|
assert_eq!(native_msh_handedness(rotated, [2.0, 3.0, 4.0]), 1);
|
|
assert_eq!(native_msh_handedness(rotated, [-2.0, 3.0, 4.0]), -1);
|
|
assert_eq!(native_msh_handedness(rotated, [0.0, 3.0, 4.0]), 0);
|
|
assert_eq!(native_msh_cull_mode(0, 0), LegacyCullMode::Disabled);
|
|
}
|
|
|
|
#[test]
|
|
fn native_msh_4000_samples_only_first_triangle_and_raw_two_sided_bypasses_it() {
|
|
let front = native_front_triangle();
|
|
let back = front.map(|point| [-point[0], point[1], point[2]]);
|
|
let front_then_back = [front[0], front[1], front[2], back[0], back[1], back[2]];
|
|
let (mesh, range) = facing_mesh(0x4000, &front_then_back);
|
|
assert!(native_msh_first_triangle_visible(
|
|
&mesh, &range, [0.0; 3], 1
|
|
));
|
|
|
|
let back_then_front = [back[0], back[1], back[2], front[0], front[1], front[2]];
|
|
let (mesh, range) = facing_mesh(0x4000, &back_then_front);
|
|
assert!(!native_msh_first_triangle_visible(
|
|
&mesh, &range, [0.0; 3], 1
|
|
));
|
|
|
|
let (mesh, range) = facing_mesh(0x4002, &back_then_front);
|
|
assert!(native_msh_first_triangle_visible(
|
|
&mesh, &range, [0.0; 3], 1
|
|
));
|
|
assert_eq!(
|
|
native_msh_cull_mode(range.batch_flags, 1),
|
|
LegacyCullMode::Disabled
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn world_draw_range_count_restores_after_alpha_and_msh_facing_changes() {
|
|
let triangle = native_front_triangle();
|
|
let (mesh, range) = facing_mesh(0x4000, &triangle);
|
|
let original_count = range.index_count;
|
|
|
|
assert_eq!(
|
|
world_draw_range_index_count(&mesh, &range, original_count, 0.0, [0.0; 3], Some(1)),
|
|
0
|
|
);
|
|
assert_eq!(
|
|
world_draw_range_index_count(
|
|
&mesh,
|
|
&range,
|
|
original_count,
|
|
1.0,
|
|
[0.0, 0.0, -2.0],
|
|
Some(1),
|
|
),
|
|
0
|
|
);
|
|
assert_eq!(
|
|
world_draw_range_index_count(&mesh, &range, original_count, 1.0, [0.0; 3], Some(1)),
|
|
original_count
|
|
);
|
|
assert_eq!(
|
|
world_draw_range_index_count(
|
|
&mesh,
|
|
&range,
|
|
original_count,
|
|
1.0,
|
|
[0.0, 0.0, -2.0],
|
|
None,
|
|
),
|
|
original_count
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn world_draw_range_count_clears_and_reapplies_constant_alpha_override() {
|
|
let triangle = native_front_triangle();
|
|
let (mesh, mut range) = facing_mesh(0x0148, &triangle);
|
|
let original_count = range.index_count;
|
|
let mut alpha_override = None;
|
|
let update = |range: &VulkanStaticDrawRange,
|
|
eye_world: [f32; 3],
|
|
alpha_override: &mut Option<f32>| {
|
|
let mut current_mesh = mesh.clone();
|
|
current_mesh.draw_ranges[0] = *range;
|
|
*alpha_override =
|
|
native_msh_diffuse_alpha_override(¤t_mesh, 0, false, eye_world, 1.3).unwrap();
|
|
world_draw_range_index_count(
|
|
&mesh,
|
|
range,
|
|
original_count,
|
|
alpha_override.unwrap_or(1.0),
|
|
eye_world,
|
|
None,
|
|
)
|
|
};
|
|
|
|
assert_eq!(update(&range, [0.0; 3], &mut alpha_override), 0);
|
|
range.batch_flags = 0x0108;
|
|
assert_eq!(update(&range, [0.0; 3], &mut alpha_override), 0);
|
|
assert_eq!(
|
|
update(&range, [150.0; 3], &mut alpha_override),
|
|
original_count
|
|
);
|
|
range.batch_flags = 0x0148;
|
|
assert_eq!(update(&range, [150.0; 3], &mut alpha_override), 0);
|
|
assert_eq!(original_count, 3);
|
|
}
|
|
|
|
fn identity_matrix() -> [[f32; 4]; 4] {
|
|
[
|
|
[1.0, 0.0, 0.0, 0.0],
|
|
[0.0, 1.0, 0.0, 0.0],
|
|
[0.0, 0.0, 1.0, 0.0],
|
|
[0.0, 0.0, 0.0, 1.0],
|
|
]
|
|
}
|
|
|
|
fn node38_fixture(parent: u16, map_start: u16, fallback: u16, drawable: bool) -> Vec<u8> {
|
|
let mut node = Vec::with_capacity(38);
|
|
node.extend_from_slice(&[0, 0]);
|
|
node.extend_from_slice(&parent.to_le_bytes());
|
|
node.extend_from_slice(&map_start.to_le_bytes());
|
|
node.extend_from_slice(&fallback.to_le_bytes());
|
|
for slot in 0..15 {
|
|
let value = if drawable && slot == 0 { 0 } else { u16::MAX };
|
|
node.extend_from_slice(&value.to_le_bytes());
|
|
}
|
|
node
|
|
}
|
|
|
|
fn animated_socket_model() -> ModelAsset {
|
|
let mut nodes = node38_fixture(u16::MAX, 0, 1, true);
|
|
nodes.extend(node38_fixture(0, u16::MAX, 2, false));
|
|
ModelAsset {
|
|
node_stride: 38,
|
|
node_count: 2,
|
|
nodes_raw: nodes,
|
|
bounding_sphere_center: [0.0; 3],
|
|
bounding_sphere_radius: 2.0,
|
|
slots: vec![Slot {
|
|
tri_start: 0,
|
|
tri_count: 1,
|
|
batch_start: 0,
|
|
batch_count: 1,
|
|
aabb_min: [-1.0; 3],
|
|
aabb_max: [1.0; 3],
|
|
sphere_center: [0.0; 3],
|
|
sphere_radius: 1.0,
|
|
opaque: [0; 5],
|
|
}],
|
|
positions: vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
|
|
normals: Some(vec![[127, 0, 0, 0]; 3]),
|
|
uv0: None,
|
|
uv1: None,
|
|
type16_present: false,
|
|
indices: vec![0, 1, 2],
|
|
batches: vec![Batch {
|
|
batch_flags: 0,
|
|
material_index_hi: 0,
|
|
material_index: 0,
|
|
lightmap_index: 0,
|
|
local_batch_index: 0,
|
|
index_count: 3,
|
|
index_start: 0,
|
|
vertex_count: 3,
|
|
base_vertex: 0,
|
|
}],
|
|
triangle_descriptors: None,
|
|
node_names: None,
|
|
animation: Some(ModelAnimation {
|
|
keys: vec![
|
|
AnimKey24 {
|
|
time: AnimationTime(0.0),
|
|
pose: Pose::default(),
|
|
},
|
|
AnimKey24 {
|
|
time: AnimationTime(1.0),
|
|
pose: Pose {
|
|
translation: [0.0; 3],
|
|
rotation: [
|
|
0.0,
|
|
0.0,
|
|
std::f32::consts::FRAC_1_SQRT_2,
|
|
std::f32::consts::FRAC_1_SQRT_2,
|
|
],
|
|
},
|
|
},
|
|
AnimKey24 {
|
|
time: AnimationTime(0.0),
|
|
pose: Pose {
|
|
translation: [0.0, 2.0, 0.0],
|
|
rotation: [0.0, 0.0, 0.0, 1.0],
|
|
},
|
|
},
|
|
],
|
|
frame_map: vec![0, 1],
|
|
frame_count: 2,
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn static_child_model() -> ModelAsset {
|
|
let mut nodes = node38_fixture(u16::MAX, u16::MAX, 0, false);
|
|
nodes.extend(node38_fixture(0, u16::MAX, 0, true));
|
|
ModelAsset {
|
|
node_stride: 38,
|
|
node_count: 2,
|
|
nodes_raw: nodes,
|
|
bounding_sphere_center: [0.0; 3],
|
|
bounding_sphere_radius: 1.0,
|
|
slots: vec![Slot {
|
|
tri_start: 0,
|
|
tri_count: 1,
|
|
batch_start: 0,
|
|
batch_count: 1,
|
|
aabb_min: [-1.0; 3],
|
|
aabb_max: [1.0; 3],
|
|
sphere_center: [0.0; 3],
|
|
sphere_radius: 1.0,
|
|
opaque: [0; 5],
|
|
}],
|
|
positions: vec![[0.0, 0.0, 0.0]; 3],
|
|
normals: Some(vec![[0, 0, 127, 0]; 3]),
|
|
uv0: None,
|
|
uv1: None,
|
|
type16_present: false,
|
|
indices: vec![0, 1, 2],
|
|
batches: vec![Batch {
|
|
batch_flags: 0,
|
|
material_index_hi: 0,
|
|
material_index: 0,
|
|
lightmap_index: 0,
|
|
local_batch_index: 0,
|
|
index_count: 3,
|
|
index_start: 0,
|
|
vertex_count: 3,
|
|
base_vertex: 0,
|
|
}],
|
|
triangle_descriptors: None,
|
|
node_names: None,
|
|
animation: Some(ModelAnimation {
|
|
keys: vec![AnimKey24 {
|
|
time: AnimationTime(0.0),
|
|
pose: Pose::default(),
|
|
}],
|
|
frame_map: vec![],
|
|
frame_count: 0,
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn collision_fixture_model(root_drawable: bool) -> ModelAsset {
|
|
let mut nodes = node38_fixture(u16::MAX, u16::MAX, 0, root_drawable);
|
|
nodes.extend(node38_fixture(0, 0, 1, true));
|
|
ModelAsset {
|
|
node_stride: 38,
|
|
node_count: 2,
|
|
nodes_raw: nodes,
|
|
bounding_sphere_center: [0.0; 3],
|
|
bounding_sphere_radius: 3.0,
|
|
slots: vec![Slot {
|
|
tri_start: 0,
|
|
tri_count: 1,
|
|
batch_start: 0,
|
|
batch_count: 1,
|
|
aabb_min: [-2.0, 0.0, -2.0],
|
|
aabb_max: [2.0, 0.0, 2.0],
|
|
sphere_center: [0.0; 3],
|
|
sphere_radius: 2.0,
|
|
opaque: [0; 5],
|
|
}],
|
|
positions: vec![[-2.0, 0.0, -2.0], [0.0, 0.0, 2.0], [2.0, 0.0, -2.0]],
|
|
normals: Some(vec![[0, 127, 0, 0]; 3]),
|
|
uv0: None,
|
|
uv1: None,
|
|
type16_present: false,
|
|
indices: vec![0, 1, 2],
|
|
batches: vec![Batch {
|
|
batch_flags: 0,
|
|
material_index_hi: 0,
|
|
material_index: 0,
|
|
lightmap_index: 0,
|
|
local_batch_index: 0,
|
|
index_count: 3,
|
|
index_start: 0,
|
|
vertex_count: 3,
|
|
base_vertex: 0,
|
|
}],
|
|
triangle_descriptors: Some(vec![fparkan_msh::TriangleDescriptor {
|
|
tri_flags: 0,
|
|
opaque: [0; 4],
|
|
normal: [0, 32767, 0],
|
|
}]),
|
|
node_names: None,
|
|
animation: Some(ModelAnimation {
|
|
keys: vec![
|
|
AnimKey24 {
|
|
time: AnimationTime(0.0),
|
|
pose: Pose::default(),
|
|
},
|
|
AnimKey24 {
|
|
time: AnimationTime(1.0),
|
|
pose: Pose {
|
|
translation: [20.0, 0.0, 0.0],
|
|
rotation: [0.0, 0.0, 0.0, 1.0],
|
|
},
|
|
},
|
|
],
|
|
frame_map: vec![0, 1],
|
|
frame_count: 2,
|
|
}),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn mounted_static_building_binding_matches_renderer_and_hides_root_node() {
|
|
let model = collision_fixture_model(true);
|
|
let binding = building_collision_binding(
|
|
4,
|
|
5,
|
|
"static fixture model".to_string(),
|
|
Arc::new(model.clone()),
|
|
LegacyIron3dEulerTransform {
|
|
translation: [13.0, -4.0, 9.0],
|
|
orientation_radians: [0.4, -0.3, 0.7],
|
|
},
|
|
[2.0, 3.0, 0.5],
|
|
true,
|
|
)
|
|
.expect("static building geometry still creates a collision binding");
|
|
assert_eq!(
|
|
binding.selected_nodes,
|
|
vec![(fparkan_msh::NodeId(1), fparkan_msh::SlotId(0))]
|
|
);
|
|
|
|
let root = VulkanNodePose {
|
|
translation: [1.0, 2.0, 3.0],
|
|
rotation: [0.0, 0.0, 0.29552022, 0.9553365],
|
|
};
|
|
let moving_node = compose_unit_pose(
|
|
root,
|
|
VulkanNodePose {
|
|
translation: [2.0, -1.0, 0.5],
|
|
rotation: [0.0, 0.19866933, 0.0, 0.9800666],
|
|
},
|
|
);
|
|
let hierarchy = NodePoseBuffer {
|
|
poses: [root, moving_node]
|
|
.into_iter()
|
|
.map(|pose| Pose {
|
|
translation: pose.translation,
|
|
rotation: pose.rotation,
|
|
})
|
|
.collect(),
|
|
};
|
|
let mount = VulkanNodePose {
|
|
translation: [5.0, 7.0, 11.0],
|
|
rotation: [0.0, 0.0, 0.38268343, 0.9238795],
|
|
};
|
|
let nodes = building_collision_nodes(&binding, Some(&hierarchy), Some(mount))
|
|
.expect("mounted collision matrices");
|
|
assert_eq!(nodes.len(), 1);
|
|
assert_eq!(nodes[0].node, fparkan_msh::NodeId(1));
|
|
|
|
let projected = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount(
|
|
&model,
|
|
binding.root_transform,
|
|
binding.owner_scale,
|
|
&hierarchy,
|
|
mount,
|
|
)
|
|
.expect("mounted renderer projection");
|
|
assert_eq!(projected.vertices.len(), 3);
|
|
for (actual, source) in projected.vertices.iter().zip(&model.positions) {
|
|
let matrix = nodes[0].world_from_node;
|
|
let expected = [
|
|
matrix[0] * source[0] + matrix[1] * source[1] + matrix[2] * source[2] + matrix[3],
|
|
matrix[4] * source[0] + matrix[5] * source[1] + matrix[6] * source[2] + matrix[7],
|
|
matrix[8] * source[0] + matrix[9] * source[1] + matrix[10] * source[2] + matrix[11],
|
|
];
|
|
for axis in 0..3 {
|
|
assert!((actual.position[axis] - expected[axis]).abs() < 1.0e-4);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn doorway_collision_model() -> ModelAsset {
|
|
let mut nodes = node38_fixture(u16::MAX, u16::MAX, 0, false);
|
|
nodes.extend(node38_fixture(0, u16::MAX, 0, true));
|
|
ModelAsset {
|
|
node_stride: 38,
|
|
node_count: 2,
|
|
nodes_raw: nodes,
|
|
bounding_sphere_center: [0.0; 3],
|
|
bounding_sphere_radius: 5.0,
|
|
slots: vec![Slot {
|
|
tri_start: 0,
|
|
tri_count: 4,
|
|
batch_start: 0,
|
|
batch_count: 1,
|
|
aabb_min: [-4.0, 0.0, -2.0],
|
|
aabb_max: [4.0, 0.0, 2.0],
|
|
sphere_center: [0.0; 3],
|
|
sphere_radius: 5.0,
|
|
opaque: [0; 5],
|
|
}],
|
|
positions: vec![
|
|
[-4.0, 0.0, -2.0],
|
|
[-1.0, 0.0, -2.0],
|
|
[-1.0, 0.0, 2.0],
|
|
[-4.0, 0.0, 2.0],
|
|
[1.0, 0.0, -2.0],
|
|
[4.0, 0.0, -2.0],
|
|
[4.0, 0.0, 2.0],
|
|
[1.0, 0.0, 2.0],
|
|
],
|
|
normals: None,
|
|
uv0: None,
|
|
uv1: None,
|
|
type16_present: false,
|
|
indices: vec![0, 3, 2, 0, 2, 1, 4, 7, 6, 4, 6, 5],
|
|
batches: vec![Batch {
|
|
batch_flags: 0,
|
|
material_index_hi: 0,
|
|
material_index: 0,
|
|
lightmap_index: 0,
|
|
local_batch_index: 0,
|
|
index_count: 12,
|
|
index_start: 0,
|
|
vertex_count: 8,
|
|
base_vertex: 0,
|
|
}],
|
|
triangle_descriptors: Some(
|
|
(0..4)
|
|
.map(|_| fparkan_msh::TriangleDescriptor {
|
|
tri_flags: 0,
|
|
opaque: [0; 4],
|
|
normal: [0, 32767, 0],
|
|
})
|
|
.collect(),
|
|
),
|
|
node_names: None,
|
|
animation: Some(ModelAnimation {
|
|
keys: vec![AnimKey24 {
|
|
time: AnimationTime(0.0),
|
|
pose: Pose::default(),
|
|
}],
|
|
frame_map: Vec::new(),
|
|
frame_count: 0,
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn collision_test_app(
|
|
model: Arc<ModelAsset>,
|
|
root_transform: LegacyIron3dEulerTransform,
|
|
position: [f32; 3],
|
|
) -> StaticVulkanApp {
|
|
let (mut app, _) = clock_test_app(None);
|
|
let hierarchy = static_preview_component_pose_buffer(&model, &[], None)
|
|
.expect("fixture static preview pose")
|
|
.expect("fixture Node38 pose");
|
|
app.component_pose_buffers = vec![vec![Some(hierarchy)]];
|
|
app.component_mount_poses = vec![vec![None]];
|
|
app.dynamic_component_geometry = vec![vec![None]];
|
|
app.control_animation_instances = vec![vec![None]];
|
|
app.building_collision_bindings = vec![building_collision_binding(
|
|
0,
|
|
0,
|
|
"collision fixture".to_string(),
|
|
Arc::clone(&model),
|
|
root_transform,
|
|
[1.0; 3],
|
|
false,
|
|
)
|
|
.expect("fixture building collision binding")];
|
|
app.free_camera = Some(FreeFlightCamera {
|
|
position,
|
|
yaw: -std::f32::consts::FRAC_PI_2,
|
|
pitch: 0.0,
|
|
horizontal_fov: 1.3,
|
|
near_plane: 0.5,
|
|
far_plane: 700.0,
|
|
move_speed: 60.0,
|
|
ground_clearance: 0.0,
|
|
});
|
|
app.pressed_keys.insert(KeyCode::KeyW);
|
|
app
|
|
}
|
|
|
|
#[test]
|
|
fn high_speed_free_flight_stops_at_static_building_wall() {
|
|
let mut app = collision_test_app(
|
|
Arc::new(collision_fixture_model(false)),
|
|
LegacyIron3dEulerTransform {
|
|
translation: [0.0, 5.0, 0.0],
|
|
orientation_radians: [0.0; 3],
|
|
},
|
|
[0.0, 8.0, 0.0],
|
|
);
|
|
app.update_free_camera(0.1, [1280.0, 720.0])
|
|
.expect("continuous building query");
|
|
assert_eq!(app.free_camera.unwrap().position, [0.0, 8.0, 0.0]);
|
|
}
|
|
|
|
#[test]
|
|
fn small_nonzero_building_scale_remains_queryable() {
|
|
let model = Arc::new(collision_fixture_model(false));
|
|
let binding = building_collision_binding(
|
|
0,
|
|
0,
|
|
"tiny-scale fixture".to_string(),
|
|
Arc::clone(&model),
|
|
LegacyIron3dEulerTransform {
|
|
translation: [0.0, 5.0, 0.0],
|
|
orientation_radians: [0.0; 3],
|
|
},
|
|
[1.0e-8, 1.0, 1.0],
|
|
false,
|
|
)
|
|
.expect("small nonzero scale is invertible");
|
|
let hierarchy = static_preview_component_pose_buffer(&model, &[], None)
|
|
.expect("fixture static preview pose")
|
|
.expect("fixture Node38 pose");
|
|
let nodes = building_collision_nodes(&binding, Some(&hierarchy), None)
|
|
.expect("small-scale collision matrix");
|
|
assert!(query_building_segment_world(
|
|
&binding.model,
|
|
&nodes,
|
|
[[0.0, 8.0, 0.0], [0.0, 2.0, 0.0]],
|
|
)
|
|
.expect("small-scale query")
|
|
.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn free_flight_passes_through_authored_doorway() {
|
|
let mut app = collision_test_app(
|
|
Arc::new(doorway_collision_model()),
|
|
LegacyIron3dEulerTransform {
|
|
translation: [0.0, 5.0, 0.0],
|
|
orientation_radians: [0.0; 3],
|
|
},
|
|
[0.0, 8.0, 0.0],
|
|
);
|
|
app.update_free_camera(0.1, [1280.0, 720.0])
|
|
.expect("doorway query");
|
|
let position = app.free_camera.unwrap().position;
|
|
assert!((position[1] - 2.0).abs() < 1.0e-4);
|
|
}
|
|
|
|
#[test]
|
|
fn shared_building_models_keep_each_placed_instance_query_separate() {
|
|
let model = Arc::new(collision_fixture_model(false));
|
|
let far = building_collision_binding(
|
|
0,
|
|
0,
|
|
"shared fixture".to_string(),
|
|
Arc::clone(&model),
|
|
LegacyIron3dEulerTransform {
|
|
translation: [0.0, 100.0, 0.0],
|
|
orientation_radians: [0.0; 3],
|
|
},
|
|
[1.0; 3],
|
|
false,
|
|
)
|
|
.expect("first placed building");
|
|
let near = building_collision_binding(
|
|
1,
|
|
0,
|
|
"shared fixture".to_string(),
|
|
Arc::clone(&model),
|
|
LegacyIron3dEulerTransform {
|
|
translation: [0.0, 5.0, 0.0],
|
|
orientation_radians: [0.0; 3],
|
|
},
|
|
[1.0; 3],
|
|
false,
|
|
)
|
|
.expect("second placed building");
|
|
assert!(Arc::ptr_eq(&far.model, &near.model));
|
|
|
|
let (mut app, _) = clock_test_app(None);
|
|
let hierarchy = static_preview_component_pose_buffer(&model, &[], None)
|
|
.expect("shared fixture static pose")
|
|
.expect("shared fixture Node38 pose");
|
|
app.component_pose_buffers = vec![vec![Some(hierarchy.clone())], vec![Some(hierarchy)]];
|
|
app.component_mount_poses = vec![vec![None], vec![None]];
|
|
app.building_collision_bindings = vec![far, near];
|
|
app.free_camera.as_mut().unwrap().position = [0.0, 8.0, 0.0];
|
|
app.free_camera.as_mut().unwrap().yaw = -std::f32::consts::FRAC_PI_2;
|
|
app.free_camera.as_mut().unwrap().pitch = 0.0;
|
|
app.free_camera.as_mut().unwrap().move_speed = 60.0;
|
|
app.pressed_keys.insert(KeyCode::KeyW);
|
|
app.update_free_camera(0.1, [1280.0, 720.0])
|
|
.expect("both placed buildings are queried independently");
|
|
assert_eq!(app.free_camera.unwrap().position, [0.0, 8.0, 0.0]);
|
|
}
|
|
|
|
#[test]
|
|
fn collision_uses_updated_control_pose_and_retries_do_not_move_again() {
|
|
let model = Arc::new(collision_fixture_model(false));
|
|
let mut app = collision_test_app(
|
|
Arc::clone(&model),
|
|
LegacyIron3dEulerTransform {
|
|
translation: [0.0, 5.0, 0.0],
|
|
orientation_radians: [0.0; 3],
|
|
},
|
|
[0.0, 8.0, 0.0],
|
|
);
|
|
let mut control = socket_timeline_control();
|
|
control.timeline_rows[0].node_index = 1;
|
|
app.control_animation_instances[0][0] = Some(ControlAnimationInstance {
|
|
control,
|
|
model: (*model).clone(),
|
|
gate_values: vec![Some(0.0)],
|
|
state: ControlInstanceState::default(),
|
|
last_sampled_node_times: Vec::new(),
|
|
reported_unsupported_fallback: false,
|
|
});
|
|
|
|
let first = FrameClockSample {
|
|
id: SceneSampleId::Replay(0),
|
|
time_ms: 0,
|
|
delta_seconds: 0.1,
|
|
};
|
|
app.prepare_scene_update(first, [1280.0, 720.0])
|
|
.expect("closed moving wall blocks the first frame");
|
|
let safe_position = app.free_camera.unwrap().position;
|
|
let closed_pose = app.component_pose_buffers[0][0].as_ref().unwrap().poses[1].translation;
|
|
assert_eq!(safe_position, [0.0, 8.0, 0.0]);
|
|
|
|
app.control_animation_instances[0][0]
|
|
.as_mut()
|
|
.unwrap()
|
|
.gate_values[0] = Some(1.0);
|
|
app.prepare_scene_update(first, [1024.0, 768.0])
|
|
.expect("pending retry only refreshes its viewport");
|
|
assert_eq!(app.free_camera.unwrap().position, safe_position);
|
|
assert_eq!(
|
|
app.component_pose_buffers[0][0].as_ref().unwrap().poses[1].translation,
|
|
closed_pose
|
|
);
|
|
|
|
app.prepare_scene_update(
|
|
FrameClockSample {
|
|
id: SceneSampleId::Replay(1),
|
|
time_ms: 1000,
|
|
delta_seconds: 0.1,
|
|
},
|
|
[1024.0, 768.0],
|
|
)
|
|
.expect("the next sampled pose opens the path");
|
|
let open_pose = app.component_pose_buffers[0][0].as_ref().unwrap().poses[1].translation;
|
|
assert!(open_pose[0] > 2.0, "door node did not move: {open_pose:?}");
|
|
assert!((app.free_camera.unwrap().position[1] - 2.0).abs() < 1.0e-4);
|
|
}
|
|
|
|
#[test]
|
|
fn missing_free_camera_keeps_legacy_view_without_querying_buildings() {
|
|
let (mut app, _) = clock_test_app(None);
|
|
app.free_camera = None;
|
|
app.building_collision_bindings
|
|
.push(BuildingCollisionBinding {
|
|
object_index: 0,
|
|
component_index: 0,
|
|
model_source: "legacy fixture".to_string(),
|
|
model: Arc::new(collision_fixture_model(false)),
|
|
root_transform: LegacyIron3dEulerTransform {
|
|
translation: [0.0; 3],
|
|
orientation_radians: [0.0; 3],
|
|
},
|
|
owner_scale: [1.0; 3],
|
|
mounted: false,
|
|
selected_nodes: vec![(NodeId(u32::MAX), SlotId(u32::MAX))],
|
|
});
|
|
let camera = app.camera;
|
|
let view_camera = app.view_camera;
|
|
app.update_free_camera(0.1, [1024.0, 768.0])
|
|
.expect("legacy camera skips free-flight collision");
|
|
assert_eq!(app.camera, camera);
|
|
assert_eq!(app.view_camera, view_camera);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires licensed corpus"]
|
|
fn licensed_plant_wall_and_door_use_the_app_camera_bridge() {
|
|
use std::path::PathBuf;
|
|
|
|
const BODY_WALL: [[f32; 3]; 2] = [[-10.5, 22.16, 2.5], [-10.5, 19.16, 2.5]];
|
|
const BODY_DOOR: [[f32; 3]; 2] = [[0.5, 22.16, 2.5], [0.5, 19.16, 2.5]];
|
|
let root = PathBuf::from(
|
|
std::env::var_os("FPARKAN_CORPUS_PART1_ROOT")
|
|
.expect("FPARKAN_CORPUS_PART1_ROOT is required for licensed tests"),
|
|
);
|
|
let mission = "MISSIONS/Autodemo.00/data.tma";
|
|
let args = Args::parse(&[
|
|
"--root".to_string(),
|
|
root.to_string_lossy().into_owned(),
|
|
"--mission".to_string(),
|
|
mission.to_string(),
|
|
])
|
|
.expect("licensed AutoDemo app arguments");
|
|
let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new(&args.root))))
|
|
.expect("create licensed AutoDemo engine");
|
|
let loaded = load_requested_mission(&mut engine, &args).expect("load AutoDemo mission");
|
|
let assets = loaded_mission_assets(&engine).expect("AutoDemo assets");
|
|
let terrain = loaded_terrain(&engine).expect("AutoDemo terrain");
|
|
let roots = loaded_mission_object_drafts(&engine).expect("AutoDemo object drafts");
|
|
let preview = static_preview_mesh_and_materials(
|
|
assets,
|
|
terrain,
|
|
roots,
|
|
None,
|
|
None,
|
|
None,
|
|
&args.root,
|
|
&loaded.land_msh_path,
|
|
)
|
|
.expect("build AutoDemo static preview");
|
|
let mut component_bindings = preview
|
|
.building_collision_bindings
|
|
.iter()
|
|
.map(|binding| (binding.object_index, binding.component_index))
|
|
.collect::<Vec<_>>();
|
|
component_bindings.sort_unstable();
|
|
assert_eq!(
|
|
component_bindings,
|
|
vec![(4, 0), (4, 5), (5, 0), (5, 5), (6, 0), (7, 0)]
|
|
);
|
|
|
|
let shadow_range = EnvironmentGpuRange {
|
|
range_index: preview.mesh.draw_ranges.len(),
|
|
vertex_start: 0,
|
|
vertex_capacity: 0,
|
|
material_index: preview.shadow_material_index,
|
|
};
|
|
let mut app = StaticVulkanApp::new(
|
|
preview,
|
|
None,
|
|
None,
|
|
shadow_range,
|
|
0,
|
|
0,
|
|
None,
|
|
2,
|
|
mission,
|
|
loaded.object_count,
|
|
None,
|
|
None,
|
|
false,
|
|
None,
|
|
StartupTimings::default(),
|
|
);
|
|
let plant = app
|
|
.building_collision_bindings
|
|
.iter()
|
|
.find(|binding| binding.object_index == 4 && binding.component_index == 0)
|
|
.cloned()
|
|
.expect("static PLANT root collision binding");
|
|
let hierarchy = app.component_pose_buffers[4][0]
|
|
.as_ref()
|
|
.expect("PLANT current root pose");
|
|
let plant_nodes =
|
|
building_collision_nodes(&plant, Some(hierarchy), app.component_mount_poses[4][0])
|
|
.expect("PLANT current collision nodes");
|
|
let plant_node = plant_nodes
|
|
.iter()
|
|
.copied()
|
|
.find(|node| node.node == NodeId(1) && node.slot == SlotId(0))
|
|
.expect("native fixture node and slot");
|
|
let body_to_world = |point| {
|
|
plant
|
|
.root_transform
|
|
.try_transform_scaled_point(point, plant.owner_scale)
|
|
.expect("finite placed-body transform")
|
|
};
|
|
let world_wall = BODY_WALL.map(body_to_world);
|
|
let world_door = BODY_DOOR.map(body_to_world);
|
|
let direct_wall_hit = query_building_segment_world(&plant.model, &[plant_node], world_wall)
|
|
.expect("direct PLANT body-space query")
|
|
.expect("verified PLANT wall hit");
|
|
assert_eq!(direct_wall_hit.node, NodeId(1));
|
|
assert_eq!(direct_wall_hit.slot, SlotId(0));
|
|
assert_eq!(direct_wall_hit.batch_index, 5);
|
|
assert_eq!(direct_wall_hit.triangle_descriptor_index, 62);
|
|
let closed_door_hit = query_building_segment_world(&plant.model, &plant_nodes, world_door)
|
|
.expect("linked PLANT door query")
|
|
.expect("the default Node3 door blocks the center path");
|
|
assert_eq!(closed_door_hit.node, NodeId(3));
|
|
assert_eq!(closed_door_hit.batch_index, 100);
|
|
assert_eq!(closed_door_hit.triangle_descriptor_index, 855);
|
|
let terrain_bounds = app
|
|
.terrain
|
|
.surface_bounds()
|
|
.expect("AutoDemo terrain surface bounds");
|
|
for point in world_wall.into_iter().chain(world_door) {
|
|
for axis in 0..2 {
|
|
assert!(
|
|
(terrain_bounds[0][axis]..=terrain_bounds[1][axis]).contains(&point[axis]),
|
|
"native fixture endpoint {point:?} is outside terrain bounds {terrain_bounds:?}"
|
|
);
|
|
}
|
|
let floor = app
|
|
.terrain
|
|
.height_at([point[0], point[1]])
|
|
.expect("fixture terrain query");
|
|
if let Some(floor) = floor {
|
|
assert!(
|
|
point[2] >= floor,
|
|
"native fixture height {} is below terrain {floor} at {point:?}",
|
|
point[2]
|
|
);
|
|
} else {
|
|
let foundation = app
|
|
.terrain
|
|
.basement_foundation()
|
|
.expect("missing terrain height needs a BAS foundation");
|
|
assert!(!foundation.fragments.is_empty());
|
|
let source_floor = terrain
|
|
.height_at([point[0], point[1]])
|
|
.expect("source fixture terrain query")
|
|
.expect("BAS-cut fixture endpoint has source Land.msh terrain");
|
|
eprintln!(
|
|
"native fixture endpoint {point:?} has source Land.msh floor {source_floor} but no BAS-foundation surface"
|
|
);
|
|
}
|
|
let mut constrained = FreeFlightCamera {
|
|
position: point,
|
|
yaw: 0.0,
|
|
pitch: 0.0,
|
|
horizontal_fov: 1.3,
|
|
near_plane: 0.5,
|
|
far_plane: 700.0,
|
|
move_speed: 0.0,
|
|
ground_clearance: 0.0,
|
|
};
|
|
constrained
|
|
.keep_above_terrain(&app.terrain)
|
|
.expect("terrain constraint preserves native fixture height");
|
|
assert_eq!(constrained.position, point);
|
|
}
|
|
|
|
set_collision_test_segment(&mut app, world_wall);
|
|
let zero_delta = FrameClockSample {
|
|
id: SceneSampleId::Replay(0),
|
|
time_ms: 0,
|
|
delta_seconds: 0.0,
|
|
};
|
|
app.prepare_scene_update(zero_delta, [1280.0, 720.0])
|
|
.expect("zero-delta resume initialization");
|
|
assert_eq!(app.free_camera.unwrap().position, world_wall[0]);
|
|
assert_eq!(app.record_presented_scene_frame(), 0);
|
|
|
|
let wall_frame = FrameClockSample {
|
|
id: SceneSampleId::Replay(1),
|
|
time_ms: 0,
|
|
delta_seconds: 0.1,
|
|
};
|
|
app.prepare_scene_update(wall_frame, [1280.0, 720.0])
|
|
.expect("high-speed wall movement through the app update path");
|
|
assert_eq!(app.free_camera.unwrap().position, world_wall[0]);
|
|
app.pressed_keys.clear();
|
|
app.pressed_keys.insert(KeyCode::KeyD);
|
|
app.prepare_scene_update(wall_frame, [1024.0, 768.0])
|
|
.expect("retry of the pending wall frame");
|
|
assert_eq!(app.free_camera.unwrap().position, world_wall[0]);
|
|
assert_eq!(app.record_presented_scene_frame(), 1);
|
|
|
|
set_collision_test_segment(&mut app, world_door);
|
|
app.prepare_scene_update(
|
|
FrameClockSample {
|
|
id: SceneSampleId::Replay(2),
|
|
time_ms: 0,
|
|
delta_seconds: 0.1,
|
|
},
|
|
[1024.0, 768.0],
|
|
)
|
|
.expect("the closed Node3 door blocks through the app update path");
|
|
assert_eq!(app.free_camera.unwrap().position, world_door[0]);
|
|
assert_eq!(app.record_presented_scene_frame(), 2);
|
|
|
|
let control = assets
|
|
.object_component_controls
|
|
.get(4)
|
|
.and_then(|components| components.first())
|
|
.and_then(Option::as_ref)
|
|
.expect("PLANT root retains its CTLD control");
|
|
let node3_binding = control
|
|
.bindings
|
|
.iter()
|
|
.find(|binding| binding.node_index == 3)
|
|
.expect("PLANT CTLD binding for the authored Node3 door");
|
|
assert_eq!(node3_binding.frame_a, 0.0);
|
|
assert_eq!(node3_binding.frame_b, 1.0);
|
|
assert_eq!(node3_binding.initial_blend, 0.0);
|
|
assert_eq!(
|
|
control
|
|
.typed_records
|
|
.iter()
|
|
.filter(|record| record.tag == 21)
|
|
.count(),
|
|
0,
|
|
"the PLANT control has no tag-21 record to advance Node3 from its initial frame"
|
|
);
|
|
let mut open_node_times =
|
|
static_preview_node_times(plant.model.node_count, &control.bindings)
|
|
.expect("actual initial PLANT CTLD node times");
|
|
assert_eq!(open_node_times[3], Some(AnimationTime(0.0)));
|
|
// This samples the door's authored frame B directly; it does not simulate a mission gate.
|
|
open_node_times[3] = Some(AnimationTime(1.0));
|
|
let open_hierarchy = node38_sampled_hierarchy_at_times(&plant.model, &open_node_times)
|
|
.expect("sample the actual PLANT model at its authored Node3 frame B");
|
|
app.component_pose_buffers[4][0] = Some(open_hierarchy);
|
|
update_dynamic_component_geometry(
|
|
&app.dynamic_component_geometry,
|
|
&app.component_pose_buffers,
|
|
&mut app.component_mount_poses,
|
|
&mut app.mesh,
|
|
)
|
|
.expect("update the scene hierarchy for the authored door pose");
|
|
let open_hierarchy = app.component_pose_buffers[4][0]
|
|
.as_ref()
|
|
.expect("sampled PLANT root pose");
|
|
let open_nodes = building_collision_nodes(
|
|
&plant,
|
|
Some(open_hierarchy),
|
|
app.component_mount_poses[4][0],
|
|
)
|
|
.expect("sampled PLANT collision nodes");
|
|
assert!(
|
|
query_building_segment_world(&plant.model, &open_nodes, world_door)
|
|
.expect("open PLANT door query")
|
|
.is_none(),
|
|
"the authored Node3 time-1 pose leaves the doorway ray clear"
|
|
);
|
|
|
|
set_collision_test_segment(&mut app, world_door);
|
|
app.update_free_camera(0.1, [1024.0, 768.0])
|
|
.expect("open doorway movement through the app camera update");
|
|
let position = app.free_camera.unwrap().position;
|
|
for axis in 0..3 {
|
|
assert!((position[axis] - world_door[1][axis]).abs() < 1.0e-4);
|
|
}
|
|
}
|
|
|
|
fn set_collision_test_segment(app: &mut StaticVulkanApp, segment: [[f32; 3]; 2]) {
|
|
let delta = sub3(segment[1], segment[0]);
|
|
let horizontal_distance = (delta[0] * delta[0] + delta[1] * delta[1]).sqrt();
|
|
app.free_camera = Some(FreeFlightCamera {
|
|
position: segment[0],
|
|
yaw: delta[1].atan2(delta[0]),
|
|
pitch: delta[2].atan2(horizontal_distance),
|
|
horizontal_fov: 1.3,
|
|
near_plane: 0.5,
|
|
far_plane: 700.0,
|
|
move_speed: length3(delta).expect("nonzero native test segment") / 0.1,
|
|
ground_clearance: 0.0,
|
|
});
|
|
app.pressed_keys.clear();
|
|
app.pressed_keys.insert(KeyCode::KeyW);
|
|
}
|
|
|
|
fn socket_timeline_control() -> PreparedComponentControl {
|
|
let mut fixed_words = [0; 44];
|
|
fixed_words[2] = 0;
|
|
fixed_words[6] = u32::MAX;
|
|
fixed_words[9] = 1.0_f32.to_bits();
|
|
fixed_words[10] = 1.0_f32.to_bits();
|
|
PreparedComponentControl {
|
|
source: ResourceKey {
|
|
archive: normalize_relative(b"test.rlb", PathPolicy::HostCompatible)
|
|
.expect("archive path"),
|
|
name: resource_name(b"animated.ctl"),
|
|
type_id: None,
|
|
},
|
|
native_profile_words: [0; 27],
|
|
model_node_count: 2,
|
|
bindings: vec![],
|
|
timeline_rows: vec![PreparedControlTimelineRow {
|
|
row_index: 0,
|
|
node_index: 0,
|
|
frame_a: 0.0,
|
|
frame_b: 1.0,
|
|
initial_blend: 0.0,
|
|
raw_parameters: [u32::MAX, u32::MAX, 1.0_f32.to_bits(), 0],
|
|
flags: 0,
|
|
}],
|
|
typed_records: vec![PreparedControlTypedRecord {
|
|
tag: 21,
|
|
fixed_words,
|
|
values: vec![0],
|
|
string_bytes: vec![],
|
|
gate_provider: PreparedControlGateProvider::Resolved {
|
|
key: 0,
|
|
ndp_source: ResourceKey {
|
|
archive: normalize_relative(b"test.rlb", PathPolicy::HostCompatible)
|
|
.expect("NDP archive path"),
|
|
name: resource_name(b"animated.ndp"),
|
|
type_id: None,
|
|
},
|
|
row_count: 1,
|
|
initial_type1_value: 1.0,
|
|
},
|
|
}],
|
|
node_data: None,
|
|
effect_section: PreparedControlEffectSection::Absent,
|
|
initial_effect_group: None,
|
|
point_list: None,
|
|
}
|
|
}
|
|
|
|
fn control_point(position: [f32; 3], direction: [f32; 3]) -> PreparedControlPoint {
|
|
PreparedControlPoint {
|
|
raw_flags: 0,
|
|
raw_signed_field_04: 0,
|
|
raw_signed_field_08: 0,
|
|
position,
|
|
direction,
|
|
name: [0; 32],
|
|
}
|
|
}
|
|
|
|
fn assert_matrix_columns(matrix: [[f32; 4]; 4], expected: [[f32; 3]; 3]) {
|
|
for (column, expected) in expected.into_iter().enumerate() {
|
|
for row in 0..3 {
|
|
assert!(
|
|
(matrix[row][column] - expected[row]).abs() < 1.0e-5,
|
|
"column {column} row {row}: {} != {}",
|
|
matrix[row][column],
|
|
expected[row]
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn write_effect_u32(body: &mut [u8], offset: usize, value: u32) {
|
|
body[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
|
|
}
|
|
|
|
fn write_effect_f32(body: &mut [u8], offset: usize, value: f32) {
|
|
body[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
|
|
}
|
|
|
|
fn write_effect_vec3(body: &mut [u8], offset: usize, value: [f32; 3]) {
|
|
for (axis, component) in value.into_iter().enumerate() {
|
|
write_effect_f32(body, offset + axis * 4, component);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn tag21_clock_moves_msh_vertices_normals_child_socket_and_fx_anchor() {
|
|
let parent_model = animated_socket_model();
|
|
let child_model = static_child_model();
|
|
let identity_transform = LegacyIron3dEulerTransform {
|
|
translation: [0.0; 3],
|
|
orientation_radians: [0.0; 3],
|
|
};
|
|
let initial_parent_pose =
|
|
node38_sampled_hierarchy_at_times(&parent_model, &[Some(AnimationTime(0.0)), None])
|
|
.expect("initial parent pose");
|
|
let initial_child_pose =
|
|
node38_fallback_hierarchy(&child_model).expect("static child pose");
|
|
let initial_mount =
|
|
node38_pose_from_hierarchy(&initial_parent_pose, 1).expect("parent socket pose");
|
|
let parent_mesh = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer(
|
|
&parent_model,
|
|
identity_transform,
|
|
[1.0; 3],
|
|
&initial_parent_pose,
|
|
)
|
|
.expect("initial parent mesh");
|
|
let child_mesh = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount(
|
|
&child_model,
|
|
identity_transform,
|
|
[1.0; 3],
|
|
&initial_child_pose,
|
|
initial_mount,
|
|
)
|
|
.expect("initial mounted child mesh");
|
|
let parent_vertex_count = parent_mesh.vertices.len();
|
|
assert_eq!(parent_vertex_count, 3);
|
|
assert_eq!(child_mesh.vertices.len(), 3);
|
|
let mut vertices = parent_mesh
|
|
.vertices
|
|
.into_iter()
|
|
.chain(child_mesh.vertices)
|
|
.collect::<Vec<_>>();
|
|
let environment_vertex_start =
|
|
u32::try_from(vertices.len()).expect("environment vertex base fits u32");
|
|
let mut environment_vertices = shadow_placeholder_vertices();
|
|
for vertex in &mut environment_vertices {
|
|
vertex.position[0] += 1000.0;
|
|
}
|
|
vertices.extend(environment_vertices);
|
|
let mut indices = vec![0, 1, 2, 3, 4, 5];
|
|
indices.extend(
|
|
quad_indices(1)
|
|
.expect("environment quad indices")
|
|
.into_iter()
|
|
.map(|index| index + environment_vertex_start),
|
|
);
|
|
let draw_range = |first_index, index_count| VulkanStaticDrawRange {
|
|
first_index,
|
|
index_count,
|
|
material_index: 0,
|
|
lightmap_index: u8::MAX,
|
|
batch_flags: 0,
|
|
pipeline_state: world_pipeline_state(
|
|
LegacyBlendMode::Opaque,
|
|
LegacyDepthMode::TestReadOnly,
|
|
),
|
|
terrain_base_only: false,
|
|
alpha_test_reference: 0,
|
|
};
|
|
let mut mesh = VulkanStaticMesh {
|
|
vertices,
|
|
indices,
|
|
draw_ranges: vec![draw_range(0, 3), draw_range(3, 3), draw_range(6, 6)],
|
|
};
|
|
let control = socket_timeline_control();
|
|
let gate_values = initial_control_gate_values(&control);
|
|
let mut shadow_casters = vec![test_shadow_caster(0, [0.0; 3])];
|
|
shadow_casters[0].triangles = static_mesh_shadow_triangles_in_index_range(&mesh, 0, 6)
|
|
.expect("initial caster triangle geometry");
|
|
let dynamic_shadow_casters = [DynamicShadowCaster {
|
|
caster_index: 0,
|
|
object_index: 0,
|
|
root_transform: identity_transform,
|
|
owner_scale: [1.0; 3],
|
|
initial_sphere: shadow_casters[0].sphere,
|
|
component_draw_range_indices: vec![0, 1],
|
|
component_spheres: vec![
|
|
DynamicShadowSphereSource {
|
|
component_index: 0,
|
|
center: [0.0; 3],
|
|
radius: 1.0,
|
|
mounted: false,
|
|
},
|
|
DynamicShadowSphereSource {
|
|
component_index: 1,
|
|
center: [0.0; 3],
|
|
radius: 1.0,
|
|
mounted: true,
|
|
},
|
|
],
|
|
}];
|
|
let mut instances = vec![vec![
|
|
Some(ControlAnimationInstance {
|
|
control,
|
|
model: parent_model.clone(),
|
|
gate_values,
|
|
state: ControlInstanceState::default(),
|
|
last_sampled_node_times: Vec::new(),
|
|
reported_unsupported_fallback: false,
|
|
}),
|
|
None,
|
|
]];
|
|
let mut poses = vec![vec![Some(initial_parent_pose), Some(initial_child_pose)]];
|
|
let mut mounts = vec![vec![None, Some(initial_mount)]];
|
|
let geometry = vec![vec![
|
|
Some(DynamicComponentGeometry {
|
|
model: parent_model,
|
|
root_transform: identity_transform,
|
|
owner_scale: [1.0; 3],
|
|
parent_mount: None,
|
|
vertex_start: 0,
|
|
vertex_count: parent_vertex_count,
|
|
}),
|
|
Some(DynamicComponentGeometry {
|
|
model: child_model,
|
|
root_transform: identity_transform,
|
|
owner_scale: [1.0; 3],
|
|
parent_mount: Some((0, 1)),
|
|
vertex_start: parent_vertex_count,
|
|
vertex_count: 3,
|
|
}),
|
|
]];
|
|
|
|
// The fixture's resolved NDP row supplies the native initial value 1.0.
|
|
update_control_animation_pose_buffers(&mut instances, &mut poses, 0)
|
|
.expect("initial active timeline sample");
|
|
let first_root_pose = poses[0][0].as_ref().expect("first root pose").poses[0];
|
|
update_dynamic_component_geometry(&geometry, &poses, &mut mounts, &mut mesh)
|
|
.expect("initial geometry update");
|
|
let range_remap = reorder_draw_ranges(&mut mesh, &[2, 0, 1]).expect("environment reorder");
|
|
let mut dynamic_shadow_casters = dynamic_shadow_casters;
|
|
remap_dynamic_shadow_caster_draw_ranges(&mut dynamic_shadow_casters, &range_remap)
|
|
.expect("dynamic shadow draw-range remap");
|
|
refresh_dynamic_shadow_casters(
|
|
&mut shadow_casters,
|
|
&dynamic_shadow_casters,
|
|
&mesh,
|
|
&poses,
|
|
&mounts,
|
|
)
|
|
.expect("initial caster geometry refresh");
|
|
let encloses_triangles = |sphere: ShadowSphere, triangles: &[ShadowTriangle]| {
|
|
triangles
|
|
.iter()
|
|
.flat_map(|triangle| triangle.positions)
|
|
.all(|point| {
|
|
let offset = sub3(point, sphere.center);
|
|
let distance = offset[0].hypot(offset[1]).hypot(offset[2]);
|
|
distance.is_finite() && distance <= sphere.radius + 1.0e-4
|
|
})
|
|
};
|
|
let first_shadow_sphere = shadow_casters[0].sphere;
|
|
assert!(encloses_triangles(
|
|
first_shadow_sphere,
|
|
&shadow_casters[0].triangles
|
|
));
|
|
assert_eq!(
|
|
dynamic_shadow_casters[0].component_draw_range_indices,
|
|
[1, 2]
|
|
);
|
|
assert_eq!(shadow_casters[0].triangles.len(), 2);
|
|
assert_eq!(
|
|
shadow_casters[0].triangles[0].positions[0],
|
|
mesh.vertices[0].position
|
|
);
|
|
assert_eq!(
|
|
shadow_casters[0].triangles[1].positions[0],
|
|
mesh.vertices[parent_vertex_count].position
|
|
);
|
|
let first_shadow_triangles = shadow_casters[0].triangles.clone();
|
|
let first_parent_vertex = mesh.vertices[0];
|
|
let first_child_vertex = mesh.vertices[parent_vertex_count];
|
|
let first_mount = mounts[0][1].expect("initial child mount");
|
|
let first_fx_anchor = control_type4_world_from_effect(
|
|
identity_matrix(),
|
|
[1.0; 3],
|
|
identity_transform,
|
|
Some(first_mount),
|
|
control_effect_owner_node_pose(poses[0][1].as_ref(), true, 0)
|
|
.expect("mounted FX owner pose"),
|
|
)
|
|
.expect("initial FX anchor");
|
|
|
|
update_control_animation_pose_buffers(&mut instances, &mut poses, 225)
|
|
.expect("later active timeline sample");
|
|
let later_root_pose = poses[0][0].as_ref().expect("later root pose").poses[0];
|
|
assert_ne!(first_root_pose, later_root_pose);
|
|
update_dynamic_component_geometry(&geometry, &poses, &mut mounts, &mut mesh)
|
|
.expect("later geometry update");
|
|
refresh_dynamic_shadow_casters(
|
|
&mut shadow_casters,
|
|
&dynamic_shadow_casters,
|
|
&mesh,
|
|
&poses,
|
|
&mounts,
|
|
)
|
|
.expect("later caster geometry refresh");
|
|
let later_shadow_sphere = shadow_casters[0].sphere;
|
|
let later_shadow_triangles = shadow_casters[0].triangles.clone();
|
|
assert!(encloses_triangles(
|
|
later_shadow_sphere,
|
|
&later_shadow_triangles
|
|
));
|
|
assert_ne!(first_shadow_triangles, shadow_casters[0].triangles);
|
|
assert_eq!(
|
|
shadow_casters[0].triangles[0].positions[0],
|
|
mesh.vertices[0].position
|
|
);
|
|
let later_parent_vertex = mesh.vertices[0];
|
|
let later_child_vertex = mesh.vertices[parent_vertex_count];
|
|
let later_mount = mounts[0][1].expect("later child mount");
|
|
let later_fx_anchor = control_type4_world_from_effect(
|
|
identity_matrix(),
|
|
[1.0; 3],
|
|
identity_transform,
|
|
Some(later_mount),
|
|
control_effect_owner_node_pose(poses[0][1].as_ref(), true, 0)
|
|
.expect("mounted FX owner pose"),
|
|
)
|
|
.expect("later FX anchor");
|
|
|
|
assert_ne!(first_parent_vertex.position, later_parent_vertex.position);
|
|
assert_ne!(first_parent_vertex.normal, later_parent_vertex.normal);
|
|
assert_ne!(first_mount.translation, later_mount.translation);
|
|
assert_eq!(first_child_vertex.position, first_mount.translation);
|
|
assert_eq!(later_child_vertex.position, later_mount.translation);
|
|
assert_ne!(first_fx_anchor[0][3], later_fx_anchor[0][3]);
|
|
assert_ne!(first_fx_anchor[1][3], later_fx_anchor[1][3]);
|
|
|
|
update_control_animation_pose_buffers(&mut instances, &mut poses, 225)
|
|
.expect("repeat the same timeline sample");
|
|
update_dynamic_component_geometry(&geometry, &poses, &mut mounts, &mut mesh)
|
|
.expect("repeat the same geometry update");
|
|
refresh_dynamic_shadow_casters(
|
|
&mut shadow_casters,
|
|
&dynamic_shadow_casters,
|
|
&mesh,
|
|
&poses,
|
|
&mounts,
|
|
)
|
|
.expect("repeat the same caster refresh");
|
|
assert_eq!(shadow_casters[0].sphere, later_shadow_sphere);
|
|
assert_eq!(shadow_casters[0].triangles, later_shadow_triangles);
|
|
|
|
update_control_animation_pose_buffers(&mut instances, &mut poses, 0)
|
|
.expect("return to the initial timeline sample");
|
|
update_dynamic_component_geometry(&geometry, &poses, &mut mounts, &mut mesh)
|
|
.expect("return to the initial geometry");
|
|
refresh_dynamic_shadow_casters(
|
|
&mut shadow_casters,
|
|
&dynamic_shadow_casters,
|
|
&mesh,
|
|
&poses,
|
|
&mounts,
|
|
)
|
|
.expect("rebuild the initial caster after a later pose");
|
|
assert_eq!(shadow_casters[0].sphere, first_shadow_sphere);
|
|
assert_eq!(shadow_casters[0].triangles, first_shadow_triangles);
|
|
}
|
|
|
|
#[test]
|
|
fn visible_exto_parent_refreshes_child_mount_without_parent_geometry() {
|
|
let make_record = |kind, immediate_child_count| fparkan_prototype::UnitComponentRecord {
|
|
archive_raw: [0; 32],
|
|
resource_raw: [0; 32],
|
|
kind,
|
|
parent_or_link: 1,
|
|
description_raw: [0; 32],
|
|
tail0: 0,
|
|
immediate_child_count,
|
|
};
|
|
let records = vec![
|
|
make_record(0, 1),
|
|
make_record(PROTOTYPE_TYPE_EXTO, 1),
|
|
make_record(PROTOTYPE_TYPE_EXTO, 0),
|
|
];
|
|
let tree = unit_component_tree(&records).expect("valid three-record component tree");
|
|
assert_eq!(tree[2].parent_index, Some(1));
|
|
assert!(tree[1].parent_index.expect("parent has root") < 2);
|
|
assert!(unit_component_is_visible(1, Some(records[1].kind)));
|
|
|
|
let identity_transform = LegacyIron3dEulerTransform {
|
|
translation: [0.0; 3],
|
|
orientation_radians: [0.0; 3],
|
|
};
|
|
let mut parent_model = animated_socket_model();
|
|
parent_model.slots.clear();
|
|
parent_model.positions.clear();
|
|
parent_model.normals = None;
|
|
parent_model.uv0 = None;
|
|
parent_model.uv1 = None;
|
|
parent_model.indices.clear();
|
|
parent_model.batches.clear();
|
|
let initial_parent_pose =
|
|
node38_sampled_hierarchy_at_times(&parent_model, &[Some(AnimationTime(0.0)), None])
|
|
.expect("animated parent hierarchy without mesh geometry");
|
|
let parent_mesh = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer(
|
|
&parent_model,
|
|
identity_transform,
|
|
[1.0; 3],
|
|
&initial_parent_pose,
|
|
)
|
|
.expect("parent with no geometry projects successfully");
|
|
assert!(parent_mesh.vertices.is_empty());
|
|
|
|
let child_model = static_child_model();
|
|
let child_pose = node38_fallback_hierarchy(&child_model).expect("static child pose");
|
|
let initial_mount =
|
|
node38_pose_from_hierarchy(&initial_parent_pose, 1).expect("visible parent socket");
|
|
let mut mounts = vec![vec![None, None, Some(initial_mount)]];
|
|
let parent_mount = tree[2]
|
|
.parent_index
|
|
.filter(|parent_index| {
|
|
mounts[0][*parent_index].is_some()
|
|
|| unit_component_is_visible(*parent_index, Some(records[*parent_index].kind))
|
|
})
|
|
.map(|parent_index| {
|
|
(
|
|
parent_index,
|
|
usize::try_from(records[2].parent_or_link).expect("valid EXTO parent socket"),
|
|
)
|
|
});
|
|
assert_eq!(parent_mount, Some((1, 1)));
|
|
|
|
let child_mesh = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount(
|
|
&child_model,
|
|
identity_transform,
|
|
[1.0; 3],
|
|
&child_pose,
|
|
initial_mount,
|
|
)
|
|
.expect("initial child mesh");
|
|
let mut mesh = VulkanStaticMesh {
|
|
vertices: child_mesh.vertices,
|
|
indices: child_mesh.indices,
|
|
draw_ranges: Vec::new(),
|
|
};
|
|
let control = socket_timeline_control();
|
|
let controls = vec![None, Some(control.clone()), None];
|
|
assert!(control_tree_chain_has_tag21(2, Some(&tree), &controls));
|
|
let mut instances = vec![vec![
|
|
None,
|
|
Some(ControlAnimationInstance {
|
|
gate_values: initial_control_gate_values(&control),
|
|
control,
|
|
model: parent_model,
|
|
state: ControlInstanceState::default(),
|
|
last_sampled_node_times: Vec::new(),
|
|
reported_unsupported_fallback: false,
|
|
}),
|
|
None,
|
|
]];
|
|
let mut poses = vec![vec![None, Some(initial_parent_pose), Some(child_pose)]];
|
|
let geometry = vec![vec![
|
|
None,
|
|
None,
|
|
Some(DynamicComponentGeometry {
|
|
model: child_model,
|
|
root_transform: identity_transform,
|
|
owner_scale: [1.0; 3],
|
|
parent_mount,
|
|
vertex_start: 0,
|
|
vertex_count: mesh.vertices.len(),
|
|
}),
|
|
]];
|
|
|
|
update_control_animation_pose_buffers(&mut instances, &mut poses, 0)
|
|
.expect("initial visible parent timeline sample");
|
|
update_dynamic_component_geometry(&geometry, &poses, &mut mounts, &mut mesh)
|
|
.expect("initial child mount refresh");
|
|
let initial_child_vertex = mesh.vertices[0].position;
|
|
let initial_child_mount = mounts[0][2].expect("initial child mount");
|
|
assert_eq!(initial_child_vertex, initial_child_mount.translation);
|
|
|
|
update_control_animation_pose_buffers(&mut instances, &mut poses, 225)
|
|
.expect("later visible parent timeline sample");
|
|
update_dynamic_component_geometry(&geometry, &poses, &mut mounts, &mut mesh)
|
|
.expect("child mount refresh without parent geometry descriptor");
|
|
let later_child_mount = mounts[0][2].expect("later child mount");
|
|
assert_ne!(
|
|
initial_child_mount.translation,
|
|
later_child_mount.translation
|
|
);
|
|
assert_eq!(mesh.vertices[0].position, later_child_mount.translation);
|
|
}
|
|
|
|
#[test]
|
|
fn dynamic_shadow_sphere_union_keeps_the_containing_input_sphere() {
|
|
let small = ShadowSphere {
|
|
center: [0.0; 3],
|
|
radius: 1.0,
|
|
};
|
|
let large = ShadowSphere {
|
|
center: [1.0, 0.0, 0.0],
|
|
radius: 10.0,
|
|
};
|
|
|
|
assert_eq!(
|
|
shadow_sphere_enclosing_current_geometry(small, large, &[])
|
|
.expect("large current native sphere contains baseline"),
|
|
large
|
|
);
|
|
assert_eq!(
|
|
shadow_sphere_enclosing_current_geometry(large, small, &[])
|
|
.expect("large baseline sphere contains current native sphere"),
|
|
large
|
|
);
|
|
}
|
|
|
|
fn endpoint_op3_command() -> FxCommand {
|
|
let mut body = vec![0; 0xc4];
|
|
write_effect_u32(&mut body, 0, 2);
|
|
write_effect_f32(&mut body, 0x04, 0.0);
|
|
write_effect_f32(&mut body, 0x08, 1.0);
|
|
write_effect_f32(&mut body, 0x0c, 1.0);
|
|
write_effect_f32(&mut body, 0x10, 0.0);
|
|
write_effect_f32(&mut body, 0x14, 1.0);
|
|
write_effect_f32(&mut body, 0x18, 1.0);
|
|
write_effect_f32(&mut body, 0x1c, 0.0);
|
|
write_effect_f32(&mut body, 0x20, 1.0);
|
|
write_effect_vec3(&mut body, 0x24, [0.0; 3]);
|
|
write_effect_vec3(&mut body, 0x30, [0.0; 3]);
|
|
write_effect_vec3(&mut body, 0x3c, [1.0; 3]);
|
|
write_effect_vec3(&mut body, 0x48, [0.0, 0.0, 1.0]);
|
|
write_effect_vec3(&mut body, 0x54, [0.0, 0.0, 1.0]);
|
|
write_effect_vec3(&mut body, 0x60, [1.0; 3]);
|
|
write_effect_vec3(&mut body, 0x6c, [1.0; 3]);
|
|
write_effect_vec3(&mut body, 0x78, [1.0; 3]);
|
|
body[0x84..0x84 + 11].copy_from_slice(b"effects.rlb");
|
|
let name = b"f_smalllight_y";
|
|
body[0xa4..0xa4 + name.len()].copy_from_slice(name);
|
|
FxCommand {
|
|
word: 3,
|
|
opcode: FxOpcode::Op3,
|
|
native_flag: false,
|
|
raw_body: Arc::from(body),
|
|
resource_refs: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn endpoint_op1_command(effect_type: u32, start: f32, end: f32) -> FxCommand {
|
|
let mut body = vec![0; 132];
|
|
write_effect_u32(&mut body, 0, effect_type);
|
|
write_effect_f32(&mut body, 4, start);
|
|
write_effect_f32(&mut body, 8, end);
|
|
write_effect_vec3(&mut body, 12, [1.0, 0.0, 0.0]);
|
|
write_effect_vec3(&mut body, 24, [3.0, 0.0, 0.0]);
|
|
write_effect_vec3(&mut body, 36, [0.0, 1.0, 0.0]);
|
|
write_effect_vec3(&mut body, 48, [0.0, 1.0, 0.0]);
|
|
for (index, value) in [0.0, 1.0, 0.5, 1.0].into_iter().enumerate() {
|
|
write_effect_f32(&mut body, 60 + index * 4, value);
|
|
}
|
|
for (index, value) in [0.5, 0.0, 1.0, 1.0].into_iter().enumerate() {
|
|
write_effect_f32(&mut body, 76 + index * 4, value);
|
|
}
|
|
write_effect_f32(&mut body, 108, 10.0);
|
|
write_effect_f32(&mut body, 112, 20.0);
|
|
write_effect_f32(&mut body, 116, 1.0);
|
|
write_effect_vec3(&mut body, 120, [1.0, 0.5, 0.25]);
|
|
FxCommand {
|
|
word: 1,
|
|
opcode: FxOpcode::Op1,
|
|
native_flag: false,
|
|
raw_body: Arc::from(body),
|
|
resource_refs: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn endpoint_gener_ball_command(opcode: FxOpcode) -> FxCommand {
|
|
let is_op9 = opcode == FxOpcode::Op9;
|
|
let mut body = vec![0; if is_op9 { 0xcc } else { 0xc8 }];
|
|
write_effect_u32(&mut body, 0, if is_op9 { 2 } else { 0 });
|
|
write_effect_f32(&mut body, 0x04, 0.0);
|
|
write_effect_f32(&mut body, 0x08, 1.0);
|
|
write_effect_f32(&mut body, 0x0c, 1.0);
|
|
write_effect_f32(&mut body, 0x10, 0.0);
|
|
write_effect_f32(&mut body, 0x14, 1.0);
|
|
write_effect_f32(&mut body, 0x18, 1.0);
|
|
write_effect_f32(&mut body, 0x1c, 0.75);
|
|
write_effect_f32(&mut body, 0x20, 1.0);
|
|
write_effect_vec3(&mut body, 0x24, [0.0; 3]);
|
|
write_effect_vec3(&mut body, 0x30, [0.0; 3]);
|
|
write_effect_vec3(&mut body, 0x3c, [1.0; 3]);
|
|
write_effect_vec3(&mut body, 0x48, [0.0, 1.0, 0.0]);
|
|
write_effect_vec3(&mut body, 0x54, [0.0, 1.0, 0.0]);
|
|
write_effect_vec3(&mut body, 0x60, [1.0; 3]);
|
|
write_effect_vec3(&mut body, 0x6c, [1.0; 3]);
|
|
write_effect_vec3(&mut body, 0x78, [1.0; 3]);
|
|
body[0x84..0x84 + 12].copy_from_slice(b"material.lib");
|
|
let name = if is_op9 {
|
|
b"laser_y_hit".as_slice()
|
|
} else {
|
|
b"glow_y"
|
|
};
|
|
body[0xa4..0xa4 + name.len()].copy_from_slice(name);
|
|
if is_op9 {
|
|
write_effect_u32(&mut body, 0xc4, 0);
|
|
} else {
|
|
write_effect_f32(&mut body, 0xc4, 500.0);
|
|
}
|
|
FxCommand {
|
|
word: if is_op9 { 9 } else { 4 },
|
|
opcode,
|
|
native_flag: false,
|
|
raw_body: Arc::from(body),
|
|
resource_refs: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn endpoint_op8_command(pool_count: u32) -> FxCommand {
|
|
let mut body = vec![0; 0xf4];
|
|
write_effect_u32(&mut body, 0x20, pool_count);
|
|
FxCommand {
|
|
word: 8,
|
|
opcode: FxOpcode::Op8,
|
|
native_flag: false,
|
|
raw_body: Arc::from(body),
|
|
resource_refs: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn endpoint_op7_command(
|
|
mode: u32,
|
|
material_phase_factor: f32,
|
|
acceleration: f32,
|
|
pool_count: u32,
|
|
copies_per_group: u32,
|
|
) -> FxCommand {
|
|
let mut body = vec![0; 0xCC];
|
|
write_effect_u32(&mut body, 0x00, mode);
|
|
write_effect_f32(&mut body, 0x1C, material_phase_factor);
|
|
write_effect_u32(&mut body, 0x20, pool_count);
|
|
write_effect_u32(&mut body, 0x24, copies_per_group);
|
|
write_effect_f32(&mut body, 0x88, acceleration);
|
|
FxCommand {
|
|
word: 7,
|
|
opcode: FxOpcode::Op7,
|
|
native_flag: false,
|
|
raw_body: Arc::from(body),
|
|
resource_refs: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn endpoint_effect_frame(command_index: usize) -> fparkan_fx::fx_visual::EffectQuadFrame {
|
|
fparkan_fx::fx_visual::EffectQuadFrame {
|
|
command_index,
|
|
opcode: FxOpcode::Op7,
|
|
material: fparkan_fx::environment::EffectResource {
|
|
archive: "material.lib".to_owned(),
|
|
name: "smoke_yg".to_owned(),
|
|
},
|
|
vertices: [[0.0; 3]; 4],
|
|
uv: [[0.0; 2]; 4],
|
|
color: [1.0; 4],
|
|
material_phase: 0.25,
|
|
elapsed_seconds: 0.5,
|
|
normalized_time: 0.5,
|
|
active: true,
|
|
native_flag_base: None,
|
|
}
|
|
}
|
|
|
|
fn strings(values: &[&str]) -> Vec<String> {
|
|
values.iter().map(|value| (*value).to_string()).collect()
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires licensed corpus"]
|
|
fn licensed_corpus_part1_prepares_every_mission() {
|
|
prepare_licensed_corpus("FPARKAN_CORPUS_PART1_ROOT", 29);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires licensed corpus"]
|
|
fn licensed_corpus_part2_prepares_every_mission() {
|
|
prepare_licensed_corpus("FPARKAN_CORPUS_PART2_ROOT", 31);
|
|
}
|
|
|
|
fn prepare_licensed_corpus(variable: &str, expected_missions: usize) {
|
|
let root = std::env::var_os(variable)
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| panic!("{variable} is required"));
|
|
assert!(
|
|
root.is_dir(),
|
|
"licensed corpus root is missing: {}",
|
|
root.display()
|
|
);
|
|
|
|
let mut missions = Vec::new();
|
|
let mut directories = vec![root.clone()];
|
|
while let Some(directory) = directories.pop() {
|
|
let entries = std::fs::read_dir(&directory)
|
|
.unwrap_or_else(|error| panic!("read {}: {error}", directory.display()));
|
|
for entry in entries {
|
|
let path = entry
|
|
.unwrap_or_else(|error| {
|
|
panic!("read entry in {}: {error}", directory.display())
|
|
})
|
|
.path();
|
|
if path.is_dir() {
|
|
directories.push(path);
|
|
} else if path
|
|
.file_name()
|
|
.is_some_and(|name| name.eq_ignore_ascii_case("data.tma"))
|
|
{
|
|
let relative = path
|
|
.strip_prefix(&root)
|
|
.expect("mission path is under corpus root")
|
|
.to_str()
|
|
.expect("mission path is UTF-8")
|
|
.replace('\\', "/");
|
|
missions.push(relative);
|
|
}
|
|
}
|
|
}
|
|
missions.sort();
|
|
assert_eq!(
|
|
missions.len(),
|
|
expected_missions,
|
|
"installed mission count for {variable}"
|
|
);
|
|
|
|
let vfs: Arc<dyn fparkan_vfs::Vfs> = Arc::new(DirectoryVfs::new(&root));
|
|
let mut prepared = 0usize;
|
|
let mut environment_missions = 0usize;
|
|
for mission in missions {
|
|
let mut engine = create(EngineServices::new(vfs.clone())).expect("engine");
|
|
let loaded = load_mission_static_preview_roots(
|
|
&mut engine,
|
|
MissionRequest {
|
|
key: mission.clone(),
|
|
mode: MissionMode::SingleMission,
|
|
},
|
|
NonZeroUsize::MAX,
|
|
)
|
|
.unwrap_or_else(|error| panic!("prepare runtime mission {mission}: {error}"));
|
|
let assets = loaded_mission_assets(&engine).expect("mission assets");
|
|
let terrain = loaded_terrain(&engine).expect("mission terrain");
|
|
let roots = loaded_mission_object_drafts(&engine).expect("mission object drafts");
|
|
let preview = static_preview_mesh_and_materials(
|
|
assets,
|
|
terrain,
|
|
roots,
|
|
None,
|
|
None,
|
|
None,
|
|
&root,
|
|
&loaded.land_msh_path,
|
|
)
|
|
.unwrap_or_else(|error| panic!("prepare renderer mission {mission}: {error}"));
|
|
assert!(!preview.mesh.vertices.is_empty(), "{mission}: empty mesh");
|
|
assert!(
|
|
!preview.materials.is_empty(),
|
|
"{mission}: empty material set"
|
|
);
|
|
|
|
if load_environment(&root, &mission, None, false)
|
|
.unwrap_or_else(|error| panic!("prepare environment mission {mission}: {error}"))
|
|
.is_some()
|
|
{
|
|
environment_missions += 1;
|
|
}
|
|
|
|
// `GameAudio::new` resolves and decodes every sample declared by
|
|
// mission.cfg before opening the OS device. A headless CI host is
|
|
// therefore still a useful resource check: only the final device
|
|
// step may be unavailable.
|
|
match audio::GameAudio::new(vfs.clone(), &mission) {
|
|
Ok(mut audio) => audio.shutdown(),
|
|
Err(audio::AudioError::DeviceUnavailable { .. }) => {}
|
|
Err(error) => panic!("prepare audio mission {mission}: {error}"),
|
|
}
|
|
prepared += 1;
|
|
}
|
|
|
|
assert_eq!(prepared, expected_missions);
|
|
assert_eq!(environment_missions, expected_missions);
|
|
}
|
|
|
|
#[test]
|
|
fn draw_range_reorder_remaps_all_material_animation_bindings() {
|
|
let range = |first_index, material_index| VulkanStaticDrawRange {
|
|
first_index,
|
|
index_count: 3,
|
|
material_index,
|
|
lightmap_index: u8::MAX,
|
|
batch_flags: 0,
|
|
pipeline_state: world_pipeline_state(
|
|
LegacyBlendMode::Opaque,
|
|
LegacyDepthMode::TestReadOnly,
|
|
),
|
|
terrain_base_only: false,
|
|
alpha_test_reference: 0,
|
|
};
|
|
let mut mesh = VulkanStaticMesh {
|
|
vertices: Vec::new(),
|
|
indices: (0..9).collect(),
|
|
draw_ranges: vec![range(0, 10), range(3, 20), range(6, 30)],
|
|
};
|
|
let empty_binding = || MaterialPhaseBinding {
|
|
document: Mat0Document {
|
|
version: 0,
|
|
animation_block_count: 0,
|
|
phases: Vec::new(),
|
|
prefix: Vec::new(),
|
|
header_opaque: [0; 2],
|
|
animation_blocks: Vec::new(),
|
|
},
|
|
animation_block_index: 0,
|
|
wear_row_start_ms: 0,
|
|
random_state: 0,
|
|
frozen_phase_index: Some(0),
|
|
phase_uv_transforms: Vec::new(),
|
|
};
|
|
let mut bindings = vec![MaterialAnimationBinding {
|
|
initial_phase_index: 0,
|
|
normalized_phase_override: None,
|
|
ambient_fx_material_alpha: false,
|
|
variants: vec![MaterialVariantSet {
|
|
phase_material_indices: vec![10],
|
|
range_indices: vec![0, 2],
|
|
pipeline_category: 0,
|
|
}],
|
|
base: empty_binding(),
|
|
detail: None,
|
|
overlay: None,
|
|
overlay_detail: None,
|
|
stage_textures: None,
|
|
}];
|
|
|
|
let remap = reorder_draw_ranges(&mut mesh, &[2, 0, 1]).expect("reorder");
|
|
remap_material_animation_ranges(&mut bindings, &remap).expect("binding remap");
|
|
|
|
assert_eq!(
|
|
mesh.indices,
|
|
(6..9).chain(0..3).chain(3..6).collect::<Vec<_>>()
|
|
);
|
|
assert_eq!(mesh.draw_ranges[0].material_index, 30);
|
|
assert_eq!(mesh.draw_ranges[1].material_index, 10);
|
|
assert_eq!(mesh.draw_ranges[2].material_index, 20);
|
|
assert_eq!(bindings[0].variants[0].range_indices, vec![1, 0]);
|
|
}
|
|
|
|
#[test]
|
|
fn native_world_transparency_uses_sampled_alpha_and_native_batch_flags() {
|
|
let range = |blend, batch_flags| VulkanStaticDrawRange {
|
|
first_index: 0,
|
|
index_count: 3,
|
|
material_index: 0,
|
|
lightmap_index: u8::MAX,
|
|
batch_flags,
|
|
pipeline_state: world_pipeline_state(blend, LegacyDepthMode::TestReadOnly),
|
|
terrain_base_only: false,
|
|
alpha_test_reference: 0,
|
|
};
|
|
|
|
// Transparency is driven by the native predicate. The pipeline blend
|
|
// enum is an independent rendering detail and does not qualify an
|
|
// opaque range on its own.
|
|
assert!(!native_world_transparent(
|
|
&range(LegacyBlendMode::SourceAlpha, 0),
|
|
1.0
|
|
));
|
|
assert!(native_world_transparent(
|
|
&range(LegacyBlendMode::Opaque, 0),
|
|
0.999
|
|
));
|
|
assert!(native_world_transparent(
|
|
&range(LegacyBlendMode::Opaque, 0x100),
|
|
1.0
|
|
));
|
|
assert!(native_world_transparent(
|
|
&range(LegacyBlendMode::Opaque, 0x8),
|
|
1.0
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn cpu_msh_portal_alpha_updates_with_distance_and_preserves_type16() {
|
|
let mut mesh = VulkanStaticMesh::smoke_triangle();
|
|
for flags in [0x0148, 0x0108, 0x0118, 0x0058] {
|
|
mesh.draw_ranges[0].batch_flags = flags;
|
|
assert_eq!(
|
|
native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0, -0.55, -1.0], 1.04),
|
|
Ok(Some(0.0))
|
|
);
|
|
assert_eq!(
|
|
native_msh_diffuse_alpha_override(&mesh, 0, true, [0.0, -0.55, -1.0], 1.04),
|
|
Ok(None)
|
|
);
|
|
}
|
|
mesh.draw_ranges[0].batch_flags = 0x0118;
|
|
let alpha = native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0, -0.55, -25.0], 1.04)
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!((alpha - 0.5).abs() < 0.000_001);
|
|
assert_eq!(
|
|
native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0, -0.55, -60.0], 1.04),
|
|
Ok(Some(1.0))
|
|
);
|
|
assert!(native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0; 3], 0.0).is_err());
|
|
mesh.draw_ranges[0].batch_flags = 0x0040;
|
|
assert_eq!(
|
|
native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0; 3], 1.04),
|
|
Ok(None)
|
|
);
|
|
}
|
|
|
|
#[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_shadow_kind_mapping_keeps_sun_eligibility_separate_from_caster_defaults() {
|
|
assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_FORT)), Some(3));
|
|
assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_BTLU)), Some(4));
|
|
assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_STAT)), Some(10));
|
|
assert!(!native_shadow_caster_enabled(3));
|
|
assert!(native_shadow_caster_enabled(4));
|
|
assert!(!native_shadow_caster_enabled(10));
|
|
assert!(native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_FORT)));
|
|
assert!(native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_BTLU)));
|
|
assert!(native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_STAT)));
|
|
assert!(!native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_EXTO)));
|
|
assert!(!native_sun_object_kind_eligible(None));
|
|
}
|
|
|
|
fn test_shadow_caster(id: u32, center: [f32; 3]) -> ShadowCaster {
|
|
ShadowCaster {
|
|
id,
|
|
world_axes: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
|
|
sphere: ShadowSphere {
|
|
center,
|
|
radius: 0.0,
|
|
},
|
|
native_kind: 4,
|
|
triangles: Vec::new(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn shadow_frustum_filters_before_the_native_twenty_caster_budget() {
|
|
let casters = [
|
|
test_shadow_caster(1, [202.0, 0.0, 0.0]),
|
|
test_shadow_caster(2, [0.0, 0.0, 0.0]),
|
|
];
|
|
let visible =
|
|
native_visible_shadow_casters(&casters, VulkanStaticCamera::default().clip_from_world);
|
|
assert_eq!(
|
|
visible.iter().map(|caster| caster.id).collect::<Vec<_>>(),
|
|
[2]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn shadow_frustum_keeps_the_native_hundred_unit_sphere_margin() {
|
|
let matrix = VulkanStaticCamera::default().clip_from_world;
|
|
assert!(native_shadow_caster_visible(
|
|
&test_shadow_caster(1, [100.99, 0.0, 0.0]),
|
|
matrix
|
|
));
|
|
assert!(!native_shadow_caster_visible(
|
|
&test_shadow_caster(2, [101.0, 0.0, 0.0]),
|
|
matrix
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn projected_shadow_alone_uses_decal_depth_bias() {
|
|
let shadow = projected_shadow_pipeline_state();
|
|
assert_eq!(shadow.blend, LegacyBlendMode::SourceAlpha);
|
|
assert_eq!(shadow.depth, LegacyDepthMode::TestReadOnly);
|
|
assert_eq!(shadow.depth_bias, LegacyDepthBias::ProjectedDecal);
|
|
assert_eq!(
|
|
world_pipeline_state(LegacyBlendMode::Opaque, LegacyDepthMode::TestWrite).depth_bias,
|
|
LegacyDepthBias::Disabled
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn shadow_atlas_texture_equality_includes_pixels_and_mip_layout() {
|
|
let uploaded = shadow_atlas_texture();
|
|
assert_eq!(shadow_atlas_texture(), uploaded);
|
|
|
|
let mut changed_rgba = uploaded.clone();
|
|
changed_rgba.rgba8[0] ^= 1;
|
|
assert_ne!(changed_rgba, uploaded);
|
|
|
|
let mut changed_mip_pixels = uploaded.clone();
|
|
changed_mip_pixels.mip_levels[0].rgba8[0] ^= 1;
|
|
assert_ne!(changed_mip_pixels, uploaded);
|
|
|
|
let mut changed_mip = uploaded.clone();
|
|
changed_mip.mip_levels[0].width -= 1;
|
|
assert_ne!(changed_mip, uploaded);
|
|
}
|
|
|
|
#[test]
|
|
fn mounted_shadow_bounds_ignore_child_translation_and_scale_aggregate_once() {
|
|
let parent = VulkanNodePose {
|
|
translation: [10.0, 20.0, 30.0],
|
|
rotation: [0.0, 0.0, 0.0, 1.0],
|
|
};
|
|
let node1 = VulkanNodePose {
|
|
// Native bounds replace this translation with the accumulated
|
|
// parent translation while retaining node 1's rotation.
|
|
translation: [99.0, -77.0, 42.0],
|
|
rotation: [0.0, 0.0, (0.5_f32).sqrt(), (0.5_f32).sqrt()],
|
|
};
|
|
let first = mounted_shadow_sphere([2.0, 0.0, 0.0], 2.0, Some(parent), Some(node1))
|
|
.expect("finite mounted sphere");
|
|
assert!((first.center[0] - 10.0).abs() < 1.0e-5);
|
|
assert!((first.center[1] - 22.0).abs() < 1.0e-5);
|
|
assert!((first.center[2] - 30.0).abs() < 1.0e-5);
|
|
|
|
let second = ShadowComponentSphere {
|
|
center: [12.0, 22.0, 30.0],
|
|
radius: 1.0,
|
|
};
|
|
let caster = aggregate_shadow_caster(
|
|
7,
|
|
4,
|
|
LegacyIron3dEulerTransform {
|
|
translation: [1.0, 2.0, 3.0],
|
|
orientation_radians: [0.0; 3],
|
|
},
|
|
[2.0, 3.0, 4.0],
|
|
vec![shadow_triangle(
|
|
[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
|
|
0,
|
|
)],
|
|
&[first, second],
|
|
)
|
|
.expect("aggregate caster");
|
|
let nearly_equal = |left: f32, right: f32| assert!((left - right).abs() < 1.0e-4);
|
|
nearly_equal(caster.sphere.center[0], 22.333_334);
|
|
nearly_equal(caster.sphere.center[1], 68.0);
|
|
nearly_equal(caster.sphere.center[2], 123.0);
|
|
nearly_equal(caster.sphere.radius, 10.666_667);
|
|
}
|
|
|
|
#[test]
|
|
fn internal_components_do_not_enter_msh_socket_mount_path() {
|
|
assert!(unit_component_is_visible(0, None));
|
|
assert!(!unit_component_is_visible(1, None));
|
|
assert!(unit_component_is_visible(1, Some(PROTOTYPE_TYPE_EXTO)));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_required_args() {
|
|
assert_eq!(
|
|
Args::parse(&strings(&[
|
|
"--root",
|
|
"testdata/IS",
|
|
"--mission",
|
|
"MISSIONS/Autodemo.00/data.tma",
|
|
"--frames",
|
|
"3",
|
|
])),
|
|
Ok(Args {
|
|
root: PathBuf::from("testdata/IS"),
|
|
mission: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
|
frames: 3,
|
|
atmosphere_seconds: None,
|
|
control_clock_ms: None,
|
|
validation: false,
|
|
readback_out: None,
|
|
readback_sequence_out: None,
|
|
preview_roots: NonZeroUsize::MAX,
|
|
legacy_camera_capture: None,
|
|
static_animation_frame: None,
|
|
static_material_phase: None,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn defaults_to_interactive_frame_loop() {
|
|
let parsed = Args::parse(&strings(&[
|
|
"--root",
|
|
"testdata/IS",
|
|
"--mission",
|
|
"MISSIONS/Autodemo.00/data.tma",
|
|
]))
|
|
.expect("default interactive arguments");
|
|
assert_eq!(parsed.frames, 0);
|
|
assert_eq!(parsed.preview_roots, NonZeroUsize::MAX);
|
|
assert_eq!(parsed.static_animation_frame, None);
|
|
assert_eq!(parsed.control_clock_ms, None);
|
|
}
|
|
|
|
#[test]
|
|
fn control_clock_replays_one_shared_clock_from_zero_to_target() {
|
|
assert_eq!(control_clock_ms_for_frame(500, 0, 101), 0);
|
|
assert_eq!(control_clock_ms_for_frame(500, 50, 101), 250);
|
|
assert_eq!(control_clock_ms_for_frame(500, 100, 101), 500);
|
|
assert_eq!(control_clock_ms_for_frame(500, 1000, 101), 500);
|
|
assert_eq!(control_clock_ms_for_frame(500, 0, 1), 500);
|
|
assert_eq!(control_clock_mode_label(Some(0)), "fixed-0ms");
|
|
assert_eq!(control_clock_mode_label(Some(500)), "ramp-0-to-500ms");
|
|
assert_eq!(control_clock_mode_label(None), "realtime");
|
|
}
|
|
|
|
fn clock_test_app(
|
|
control_clock_target_ms: Option<u32>,
|
|
) -> (StaticVulkanApp, ControlEffectInstanceKey) {
|
|
let phase = |additive_red| MaterialPhase {
|
|
parameters: [
|
|
additive_red,
|
|
0,
|
|
0,
|
|
100,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
],
|
|
texture_raw: [0; 16],
|
|
};
|
|
let document = Mat0Document {
|
|
version: 0,
|
|
animation_block_count: 1,
|
|
phases: vec![phase(0), phase(255)],
|
|
prefix: Vec::new(),
|
|
header_opaque: [0; 2],
|
|
animation_blocks: vec![MaterialAnimationBlock {
|
|
header_raw: 0,
|
|
keys: vec![
|
|
MaterialKey {
|
|
k0: 0,
|
|
k1: 500,
|
|
k2: 0,
|
|
},
|
|
MaterialKey {
|
|
k0: 1,
|
|
k1: 1000,
|
|
k2: 0,
|
|
},
|
|
],
|
|
bytes: Vec::new(),
|
|
}],
|
|
};
|
|
let material_animation = MaterialAnimationBinding {
|
|
initial_phase_index: 0,
|
|
normalized_phase_override: None,
|
|
ambient_fx_material_alpha: false,
|
|
variants: vec![MaterialVariantSet {
|
|
phase_material_indices: vec![0, 1],
|
|
range_indices: vec![0],
|
|
pipeline_category: 0,
|
|
}],
|
|
base: MaterialPhaseBinding {
|
|
document,
|
|
animation_block_index: 0,
|
|
wear_row_start_ms: 0,
|
|
random_state: 0,
|
|
frozen_phase_index: None,
|
|
phase_uv_transforms: vec![[0.0, 0.0, 1.0, 1.0]; 2],
|
|
},
|
|
detail: None,
|
|
overlay: None,
|
|
overlay_detail: None,
|
|
stage_textures: None,
|
|
};
|
|
let mut materials = Vec::new();
|
|
append_environment_gradient_material(&mut materials)
|
|
.expect("first animated material variant");
|
|
append_environment_gradient_material(&mut materials)
|
|
.expect("second animated material variant");
|
|
|
|
let key = ControlEffectInstanceKey {
|
|
object_index: 0,
|
|
component_index: 0,
|
|
table_index: 0,
|
|
row_index: 0,
|
|
};
|
|
let control_slot = ControlEffectSlot {
|
|
time_mode: 1,
|
|
header_flags: 9,
|
|
random_instance_key: key,
|
|
random_seed: 0x1234_5678,
|
|
phase_jitter: 0.25,
|
|
random_shift: [2.0, 3.0, 4.0],
|
|
duration_seconds: 1.0,
|
|
owner_object_index: 0,
|
|
owner_component_index: 0,
|
|
owner_node_index: 0,
|
|
owner_component_is_mounted: false,
|
|
root_transform: LegacyIron3dEulerTransform {
|
|
translation: [10.0, 20.0, 30.0],
|
|
orientation_radians: [0.0; 3],
|
|
},
|
|
owner_scale: [1.0; 3],
|
|
mount_pose: None,
|
|
local_from_effect: [
|
|
[1.0, 0.0, 0.0, 1.0],
|
|
[0.0, 1.0, 0.0, 2.0],
|
|
[0.0, 0.0, 1.0, 3.0],
|
|
[0.0, 0.0, 0.0, 1.0],
|
|
],
|
|
world_from_effect: [[0.0; 4]; 4],
|
|
command_index: 0,
|
|
command: endpoint_op1_command(6, 0.5, 1.0),
|
|
};
|
|
|
|
let sky_materials = SkyMaterials::default();
|
|
let sky =
|
|
SkySystem::new(Default::default(), sky_materials.clone()).expect("test sky topology");
|
|
let environment = DynamicEnvironment {
|
|
schedule: TypedAtmosphere {
|
|
marker: 0,
|
|
version: 0,
|
|
tracks: Vec::new(),
|
|
trailer: Default::default(),
|
|
},
|
|
sky,
|
|
environment: EnvironmentSystem::new(0x4650_4152_4B41_4E),
|
|
materials: sky_materials,
|
|
material_assets: HashMap::new(),
|
|
schedule_offset_seconds: 0.0,
|
|
time_seconds: 0.0,
|
|
fixed_schedule_seconds: None,
|
|
fixed_phase_reported: false,
|
|
};
|
|
|
|
let mut land = fparkan_terrain_format::LandMeshDocument {
|
|
streams: Vec::new(),
|
|
nodes_raw: Vec::new(),
|
|
slots: fparkan_terrain_format::TerrainSlotTable {
|
|
header_raw: Vec::new(),
|
|
slots_raw: Vec::new(),
|
|
},
|
|
positions: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [0.0, 10.0, 0.0]],
|
|
normals: Vec::new(),
|
|
uv0: Vec::new(),
|
|
accelerator: Vec::new(),
|
|
aux14: Vec::new(),
|
|
aux18: Vec::new(),
|
|
faces: vec![fparkan_terrain_format::TerrainFace28 {
|
|
flags: FullSurfaceMask(1),
|
|
material_tag: 0,
|
|
aux_tag: 0,
|
|
vertices: [0, 1, 2],
|
|
neighbors: [None, None, None],
|
|
tail_raw: [0; 8],
|
|
raw: [0; 28],
|
|
}],
|
|
};
|
|
set_test_terrain_lod_slots(&mut land, &[[0, u16::MAX]], &[[0, 1]]);
|
|
let terrain =
|
|
Arc::new(TerrainWorld::from_land_msh(&land).expect("clock test LOD0 terrain"));
|
|
let free_camera = FreeFlightCamera {
|
|
position: [2.0, 2.0, 5.0],
|
|
yaw: 0.25,
|
|
pitch: -0.1,
|
|
horizontal_fov: 1.3,
|
|
near_plane: 0.5,
|
|
far_plane: 700.0,
|
|
move_speed: 4.0,
|
|
ground_clearance: 0.0,
|
|
};
|
|
let view_camera = free_camera.preview_frame(16.0 / 9.0);
|
|
let preview = StaticPreviewScene {
|
|
mesh: VulkanStaticMesh {
|
|
vertices: [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]
|
|
.into_iter()
|
|
.map(|position| VulkanStaticVertex {
|
|
position,
|
|
color: [1.0; 3],
|
|
normal: [0.0, 0.0, 1.0],
|
|
uv: [0.0; 2],
|
|
detail_uv: [0.0; 2],
|
|
overlay_alpha: 0.0,
|
|
})
|
|
.collect(),
|
|
indices: vec![0, 1, 2],
|
|
draw_ranges: vec![VulkanStaticDrawRange {
|
|
first_index: 0,
|
|
index_count: 3,
|
|
material_index: 0,
|
|
lightmap_index: u8::MAX,
|
|
batch_flags: 0,
|
|
terrain_base_only: false,
|
|
pipeline_state: world_pipeline_state(
|
|
LegacyBlendMode::Opaque,
|
|
LegacyDepthMode::TestReadOnly,
|
|
),
|
|
alpha_test_reference: 0,
|
|
}],
|
|
},
|
|
native_handedness_by_range: HashMap::new(),
|
|
type16_present_ranges: HashSet::new(),
|
|
materials,
|
|
material_animations: vec![material_animation],
|
|
ambient_effect_slots: Vec::new(),
|
|
control_point_light_slots: vec![control_slot],
|
|
control_sound_slots: Vec::new(),
|
|
unsupported_control_sounds: 0,
|
|
component_pose_buffers: vec![vec![None]],
|
|
component_mount_poses: vec![vec![None]],
|
|
dynamic_component_geometry: Vec::new(),
|
|
building_collision_bindings: Vec::new(),
|
|
control_animation_instances: Vec::new(),
|
|
dynamic_shadow_casters: Vec::new(),
|
|
sun_occlusion_range_indices: Vec::new(),
|
|
shadow_casters: Vec::new(),
|
|
shadow_receivers: Vec::new(),
|
|
shadow_material_index: 0,
|
|
terrain: terrain.clone(),
|
|
camera: view_camera.vulkan_camera(),
|
|
view_camera,
|
|
captured_viewport: None,
|
|
free_camera: Some(free_camera),
|
|
camera_mode: "test",
|
|
mesh_components: 0,
|
|
terrain_components: 0,
|
|
};
|
|
let app = StaticVulkanApp::new(
|
|
preview,
|
|
Some(environment),
|
|
None,
|
|
EnvironmentGpuRange {
|
|
range_index: 1,
|
|
vertex_start: 3,
|
|
vertex_capacity: 0,
|
|
material_index: 0,
|
|
},
|
|
3,
|
|
3,
|
|
None,
|
|
2,
|
|
"clock-test",
|
|
1,
|
|
None,
|
|
None,
|
|
false,
|
|
control_clock_target_ms,
|
|
StartupTimings::default(),
|
|
);
|
|
(app, key)
|
|
}
|
|
|
|
fn control_random_snapshot(
|
|
state: &ControlEffectRandomState,
|
|
) -> (
|
|
u32,
|
|
u32,
|
|
ControlEffectRandomSample,
|
|
Option<SceneSampleId>,
|
|
Option<u32>,
|
|
) {
|
|
(
|
|
state.phase_and_z_rng.state,
|
|
state.xy_rng.state,
|
|
state.sample,
|
|
state.last_update_id,
|
|
state.last_update_clock_ms,
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn live_clock_samples_have_increasing_ids_and_monotonic_time() {
|
|
let (mut app, _) = clock_test_app(None);
|
|
let first = app.sample_frame_clock();
|
|
let second = app.sample_frame_clock();
|
|
assert_eq!(first.id, SceneSampleId::Live(0));
|
|
assert_eq!(second.id, SceneSampleId::Live(1));
|
|
assert!(second.time_ms >= first.time_ms);
|
|
assert!(second.delta_seconds >= 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn zero_extent_resume_does_not_add_minimized_time_to_live_clock() {
|
|
let (mut app, _) = clock_test_app(None);
|
|
let started = Instant::now();
|
|
app.simulation_last_tick = started;
|
|
app.pause_live_clock_at(started);
|
|
assert!(app.live_clock_paused_for_zero_extent);
|
|
|
|
let restored = started + Duration::from_secs(600);
|
|
app.resume_live_clock_at(restored);
|
|
assert!(!app.live_clock_paused_for_zero_extent);
|
|
assert_eq!(app.simulation_last_tick, restored);
|
|
|
|
let resumed = app.sample_live_frame_clock_at(restored + Duration::from_millis(16));
|
|
assert_eq!(resumed.id, SceneSampleId::Live(0));
|
|
assert_eq!(resumed.time_ms, 16);
|
|
assert!((resumed.delta_seconds - 0.016).abs() < 1.0e-6);
|
|
assert_eq!(app.simulation_elapsed, Duration::from_millis(16));
|
|
}
|
|
|
|
#[test]
|
|
fn live_cpu_updates_sample_materials_effect_rng_and_environment_without_renderer() {
|
|
let (mut app, key) = clock_test_app(None);
|
|
app.render_scene_update()
|
|
.expect("an early redraw without a packet is a no-op");
|
|
assert!(app.pending_scene_update.is_none());
|
|
assert_eq!(app.frames_presented, 0);
|
|
assert_eq!(app.simulation_update_generation, 0);
|
|
|
|
let mut first = app.sample_frame_clock();
|
|
assert_eq!(first.id, SceneSampleId::Live(0));
|
|
first.time_ms = 0;
|
|
first.delta_seconds = 0.1;
|
|
app.prepare_scene_update(first, [1280.0, 720.0])
|
|
.expect("prepare a live CPU frame without a renderer");
|
|
assert!(app.renderer.is_none());
|
|
let first_frame = app.pending_scene_update.as_ref().expect("sampled frame");
|
|
assert_eq!(
|
|
first_frame.material_update.active_materials[&0].phase_index,
|
|
0
|
|
);
|
|
assert!(!first_frame
|
|
.environment
|
|
.as_ref()
|
|
.unwrap()
|
|
.sky_mesh
|
|
.vertices
|
|
.is_empty());
|
|
let first_random = control_random_snapshot(&app.control_effect_random_states[&key]);
|
|
assert_eq!(first_random.3, Some(SceneSampleId::Live(0)));
|
|
assert_ne!(first_random.0, 0);
|
|
assert_ne!(first_random.1, 0);
|
|
let first_environment_time = app.environment.as_ref().unwrap().time_seconds;
|
|
assert!(first_environment_time > 0.0);
|
|
let first_vertices = app.mesh.vertices.clone();
|
|
|
|
for _ in 0..2 {
|
|
app.render_scene_update()
|
|
.expect("renderer-free redraw does not sample simulation state");
|
|
}
|
|
assert_eq!(
|
|
control_random_snapshot(&app.control_effect_random_states[&key]),
|
|
first_random
|
|
);
|
|
assert_eq!(
|
|
app.environment.as_ref().unwrap().time_seconds,
|
|
first_environment_time
|
|
);
|
|
assert_eq!(app.mesh.vertices, first_vertices);
|
|
assert_eq!(app.simulation_update_generation, 1);
|
|
|
|
let mut next = app.sample_frame_clock();
|
|
assert_eq!(next.id, SceneSampleId::Live(1));
|
|
next.time_ms = 500;
|
|
next.delta_seconds = 0.5;
|
|
app.prepare_scene_update(next, [1280.0, 720.0])
|
|
.expect("replace the latest live packet");
|
|
assert_eq!(app.simulation_update_generation, 2);
|
|
assert_eq!(
|
|
app.pending_scene_update.as_ref().unwrap().clock.id,
|
|
SceneSampleId::Live(1)
|
|
);
|
|
assert_ne!(
|
|
control_random_snapshot(&app.control_effect_random_states[&key]),
|
|
first_random
|
|
);
|
|
assert!(app.environment.as_ref().unwrap().time_seconds > first_environment_time);
|
|
assert_eq!(
|
|
app.pending_scene_update
|
|
.as_ref()
|
|
.unwrap()
|
|
.material_update
|
|
.active_materials[&0]
|
|
.phase_index,
|
|
1
|
|
);
|
|
assert_eq!(
|
|
app.pending_scene_update.as_ref().unwrap().clock.time_ms,
|
|
500
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn live_pending_resize_reuses_225ms_packet_across_zero_extent_pause() {
|
|
let (mut app, key) = clock_test_app(None);
|
|
let started = Instant::now();
|
|
app.simulation_last_tick = started;
|
|
app.pressed_keys.insert(KeyCode::KeyE);
|
|
|
|
let first = app.sample_live_frame_clock_at(started + Duration::from_millis(225));
|
|
assert_eq!(first.id, SceneSampleId::Live(0));
|
|
assert_eq!(first.time_ms, 225);
|
|
assert!((first.delta_seconds - 0.225).abs() < 1.0e-6);
|
|
app.prepare_scene_update(first, [1280.0, 720.0])
|
|
.expect("sample one 225 ms live packet");
|
|
let first_frame = app.pending_scene_update.as_ref().unwrap();
|
|
let first_camera = first_frame.camera;
|
|
let expected_resized_camera = app
|
|
.free_camera
|
|
.expect("free-flight input controller")
|
|
.preview_frame(4.0 / 3.0);
|
|
let first_effect_updates = first_frame.effect_updates.clone();
|
|
let first_material_phase = first_frame.material_update.active_materials[&0].phase_index;
|
|
let weather = WeatherInput {
|
|
rain: WeatherState::new(true, 0.03, [0.7, 0.8, 1.0, 0.6]),
|
|
snow: WeatherState::new(true, 0.03, [0.9, 0.95, 1.0, 0.8]),
|
|
..WeatherInput::clear()
|
|
};
|
|
let mut weather_system = EnvironmentSystem::new(0xC1_225);
|
|
let first_environment_frame = weather_system.update(
|
|
0.225,
|
|
weather,
|
|
environment_camera_for_preview(&first_camera, 16.0 / 9.0, [1280.0, 720.0]),
|
|
);
|
|
let mut precipitation_counts = [0usize; 2];
|
|
for primitive in &first_environment_frame.primitives {
|
|
if let EnvironmentPrimitive::Particle { kind, .. } = primitive {
|
|
let slot = match kind {
|
|
PrecipitationKind::Rain => 0,
|
|
PrecipitationKind::Snow => 1,
|
|
};
|
|
precipitation_counts[slot] += 1;
|
|
}
|
|
}
|
|
assert!(precipitation_counts.iter().all(|count| *count >= 17));
|
|
app.pending_scene_update
|
|
.as_mut()
|
|
.unwrap()
|
|
.environment
|
|
.as_mut()
|
|
.unwrap()
|
|
.environment_frame = first_environment_frame.clone();
|
|
let first_random = control_random_snapshot(&app.control_effect_random_states[&key]);
|
|
let first_environment_time = app.environment.as_ref().unwrap().time_seconds;
|
|
let first_environment_system_time =
|
|
app.environment.as_ref().unwrap().environment.time_seconds();
|
|
let first_vertices = app.mesh.vertices.clone();
|
|
assert!((first_camera.position[2] - 5.4).abs() < 1.0e-5);
|
|
assert_eq!(app.simulation_elapsed, Duration::from_millis(225));
|
|
assert_eq!(app.simulation_update_generation, 1);
|
|
|
|
app.pause_live_clock_at(started + Duration::from_millis(250));
|
|
let restored = started + Duration::from_secs(10);
|
|
app.resume_live_clock_at(restored);
|
|
app.free_camera.as_mut().unwrap().look_delta(64.0, -24.0);
|
|
let controller_after_input = app.free_camera.unwrap();
|
|
let retry = app.pending_or_sample_frame_clock();
|
|
assert_eq!(retry, first);
|
|
assert_eq!(app.simulation_update_generation, 1);
|
|
assert_eq!(app.simulation_elapsed, Duration::from_millis(225));
|
|
assert_eq!(app.simulation_last_tick, restored);
|
|
|
|
app.prepare_scene_update(retry, [1024.0, 768.0])
|
|
.expect("refresh only the pending packet's viewport");
|
|
let retry_frame = app.pending_scene_update.as_ref().unwrap();
|
|
assert_eq!(retry_frame.clock, first);
|
|
assert_eq!(retry_frame.viewport, [1024.0, 768.0]);
|
|
assert_eq!(retry_frame.aspect, 4.0 / 3.0);
|
|
assert_eq!(retry_frame.camera.position, first_camera.position);
|
|
assert_eq!(retry_frame.camera.forward, first_camera.forward);
|
|
assert_eq!(retry_frame.camera.right, first_camera.right);
|
|
assert_eq!(retry_frame.camera.up, first_camera.up);
|
|
assert_eq!(
|
|
retry_frame.camera.position,
|
|
expected_resized_camera.position
|
|
);
|
|
assert_eq!(retry_frame.camera.forward, expected_resized_camera.forward);
|
|
assert_eq!(retry_frame.camera.right, expected_resized_camera.right);
|
|
assert_eq!(retry_frame.camera.up, expected_resized_camera.up);
|
|
assert!(
|
|
(retry_frame.camera.vertical_fov - expected_resized_camera.vertical_fov).abs() < 1.0e-6
|
|
);
|
|
assert!(retry_frame
|
|
.camera
|
|
.clip_from_world
|
|
.iter()
|
|
.zip(expected_resized_camera.clip_from_world)
|
|
.all(|(actual, expected)| (actual - expected).abs() < 1.0e-5));
|
|
assert_ne!(
|
|
retry_frame.camera.clip_from_world,
|
|
first_camera.clip_from_world
|
|
);
|
|
assert_eq!(app.free_camera, Some(controller_after_input));
|
|
assert_eq!(retry_frame.effect_updates, first_effect_updates);
|
|
assert_eq!(
|
|
retry_frame.material_update.active_materials[&0].phase_index,
|
|
first_material_phase
|
|
);
|
|
let retry_environment_frame = &retry_frame.environment.as_ref().unwrap().environment_frame;
|
|
assert_eq!(
|
|
retry_environment_frame.point_lights,
|
|
first_environment_frame.point_lights
|
|
);
|
|
assert_eq!(
|
|
retry_environment_frame.sounds,
|
|
first_environment_frame.sounds
|
|
);
|
|
let mut reprojected_counts = [0usize; 2];
|
|
for (sampled, refreshed) in first_environment_frame
|
|
.primitives
|
|
.iter()
|
|
.zip(&retry_environment_frame.primitives)
|
|
{
|
|
let (
|
|
EnvironmentPrimitive::Particle {
|
|
kind: sampled_kind,
|
|
position: sampled_position,
|
|
velocity: sampled_velocity,
|
|
size: sampled_size,
|
|
screen: sampled_screen,
|
|
..
|
|
},
|
|
EnvironmentPrimitive::Particle {
|
|
kind,
|
|
position,
|
|
velocity,
|
|
size,
|
|
screen,
|
|
..
|
|
},
|
|
) = (sampled, refreshed)
|
|
else {
|
|
panic!("rain and snow fixture contains only precipitation particles");
|
|
};
|
|
assert_eq!(kind, sampled_kind);
|
|
assert_eq!(position, sampled_position);
|
|
assert_eq!(velocity, sampled_velocity);
|
|
assert_ne!(screen, sampled_screen);
|
|
let slot = match kind {
|
|
PrecipitationKind::Rain => 0,
|
|
PrecipitationKind::Snow => 1,
|
|
};
|
|
reprojected_counts[slot] += 1;
|
|
assert_ne!(size, sampled_size);
|
|
}
|
|
assert!(reprojected_counts.iter().all(|count| *count >= 17));
|
|
assert_eq!(
|
|
control_random_snapshot(&app.control_effect_random_states[&key]),
|
|
first_random
|
|
);
|
|
assert_eq!(
|
|
app.environment.as_ref().unwrap().time_seconds,
|
|
first_environment_time
|
|
);
|
|
assert_eq!(
|
|
app.environment.as_ref().unwrap().environment.time_seconds(),
|
|
first_environment_system_time
|
|
);
|
|
assert_eq!(app.mesh.vertices, first_vertices);
|
|
|
|
assert_eq!(app.record_presented_scene_frame(), 0);
|
|
let next = app.sample_live_frame_clock_at(restored + Duration::from_millis(16));
|
|
assert_eq!(next.id, SceneSampleId::Live(1));
|
|
assert_eq!(next.time_ms, 241);
|
|
assert!((next.delta_seconds - 0.016).abs() < 1.0e-6);
|
|
assert_eq!(app.simulation_elapsed, Duration::from_millis(241));
|
|
app.prepare_scene_update(next, [1024.0, 768.0])
|
|
.expect("the next sample adopts mouse input accumulated during retry");
|
|
let next_camera = app.pending_scene_update.as_ref().unwrap().camera;
|
|
assert_ne!(next_camera.forward, first_camera.forward);
|
|
assert_eq!(next_camera.forward, controller_after_input.forward());
|
|
}
|
|
|
|
#[test]
|
|
fn replay_retries_keep_one_packet_until_presented_then_advance_with_new_viewport() {
|
|
let (mut app, key) = clock_test_app(Some(500));
|
|
app.render_scene_update()
|
|
.expect("redraw without a pending packet does nothing");
|
|
assert_eq!(app.simulation_update_generation, 0);
|
|
|
|
let first = app.sample_frame_clock();
|
|
assert_eq!(
|
|
first,
|
|
FrameClockSample {
|
|
id: SceneSampleId::Replay(0),
|
|
time_ms: 0,
|
|
delta_seconds: 0.0,
|
|
}
|
|
);
|
|
app.prepare_scene_update(first, [1280.0, 720.0])
|
|
.expect("prepare the first replay frame");
|
|
let first_frame = app.pending_scene_update.as_ref().unwrap();
|
|
let first_camera = first_frame.camera;
|
|
let first_viewport = first_frame.viewport;
|
|
let first_aspect = first_frame.aspect;
|
|
let first_effect_updates = first_frame.effect_updates.clone();
|
|
let first_environment_mesh = first_frame.environment.as_ref().unwrap().sky_mesh.clone();
|
|
let first_environment_frame = first_frame
|
|
.environment
|
|
.as_ref()
|
|
.unwrap()
|
|
.environment_frame
|
|
.clone();
|
|
let first_material_phase = first_frame.material_update.active_materials[&0].phase_index;
|
|
let first_environment_time = app.environment.as_ref().unwrap().time_seconds;
|
|
let first_random = control_random_snapshot(&app.control_effect_random_states[&key]);
|
|
let first_vertices = app.mesh.vertices.clone();
|
|
assert_eq!(first_material_phase, 0);
|
|
assert_eq!(first_random.3, Some(SceneSampleId::Replay(0)));
|
|
assert_eq!(first_random.4, Some(0));
|
|
assert!(!first_environment_mesh.vertices.is_empty());
|
|
|
|
for _ in 0..2 {
|
|
app.render_scene_update()
|
|
.expect("renderer-free replay redraw is a retry");
|
|
let retry = app.sample_frame_clock();
|
|
assert_eq!(retry.id, SceneSampleId::Replay(0));
|
|
app.prepare_scene_update(retry, [1024.0, 768.0])
|
|
.expect("a resize retry reuses the current replay packet");
|
|
}
|
|
let retry_frame = app.pending_scene_update.as_ref().unwrap();
|
|
assert_eq!(retry_frame.camera.position, first_camera.position);
|
|
assert_eq!(retry_frame.camera.forward, first_camera.forward);
|
|
assert_eq!(retry_frame.camera.up, first_camera.up);
|
|
assert_eq!(retry_frame.viewport, [1024.0, 768.0]);
|
|
assert_eq!(retry_frame.aspect, 4.0 / 3.0);
|
|
assert_ne!(
|
|
retry_frame.camera.clip_from_world,
|
|
first_camera.clip_from_world
|
|
);
|
|
assert_ne!(retry_frame.viewport, first_viewport);
|
|
assert_ne!(retry_frame.aspect, first_aspect);
|
|
assert_eq!(retry_frame.effect_updates, first_effect_updates);
|
|
assert_eq!(
|
|
retry_frame.environment.as_ref().unwrap().sky_mesh,
|
|
first_environment_mesh
|
|
);
|
|
assert_eq!(
|
|
retry_frame.environment.as_ref().unwrap().environment_frame,
|
|
first_environment_frame
|
|
);
|
|
assert_eq!(
|
|
retry_frame.material_update.active_materials[&0].phase_index,
|
|
0
|
|
);
|
|
assert_eq!(
|
|
control_random_snapshot(&app.control_effect_random_states[&key]),
|
|
first_random
|
|
);
|
|
assert_eq!(
|
|
app.environment.as_ref().unwrap().time_seconds,
|
|
first_environment_time
|
|
);
|
|
assert_eq!(app.mesh.vertices, first_vertices);
|
|
assert_eq!(app.frames_presented, 0);
|
|
|
|
assert_eq!(app.record_presented_scene_frame(), 0);
|
|
assert_eq!(app.frames_presented, 1);
|
|
assert!(app.pending_scene_update.is_none());
|
|
let next = app.sample_frame_clock();
|
|
assert_eq!(next.id, SceneSampleId::Replay(1));
|
|
assert_eq!(next.time_ms, 500);
|
|
assert_eq!(next.delta_seconds, 0.5);
|
|
app.prepare_scene_update(next, [1024.0, 768.0])
|
|
.expect("the next presented replay frame samples the resized view");
|
|
let next_frame = app.pending_scene_update.as_ref().unwrap();
|
|
assert_eq!(next_frame.viewport, [1024.0, 768.0]);
|
|
assert_eq!(next_frame.aspect, 4.0 / 3.0);
|
|
assert_ne!(
|
|
next_frame.camera.clip_from_world,
|
|
first_camera.clip_from_world
|
|
);
|
|
assert_eq!(
|
|
next_frame.material_update.active_materials[&0].phase_index,
|
|
1
|
|
);
|
|
assert!(
|
|
(app.environment.as_ref().unwrap().time_seconds - first_environment_time - 0.5).abs()
|
|
< 1.0e-6
|
|
);
|
|
let next_random = control_random_snapshot(&app.control_effect_random_states[&key]);
|
|
assert_eq!(next_random.3, Some(SceneSampleId::Replay(1)));
|
|
assert_eq!(next_random.4, Some(500));
|
|
assert_ne!(next_random, first_random);
|
|
let next_camera = next_frame.camera;
|
|
let next_environment_mesh = next_frame.environment.as_ref().unwrap().sky_mesh.clone();
|
|
let next_environment_frame = next_frame
|
|
.environment
|
|
.as_ref()
|
|
.unwrap()
|
|
.environment_frame
|
|
.clone();
|
|
|
|
app.render_scene_update()
|
|
.expect("renderer-free draw does not advance the replay packet");
|
|
let repeated = app.sample_frame_clock();
|
|
assert_eq!(repeated.id, SceneSampleId::Replay(1));
|
|
app.prepare_scene_update(repeated, [800.0, 600.0])
|
|
.expect("duplicate replay frame is idempotent");
|
|
let repeated_frame = app.pending_scene_update.as_ref().unwrap();
|
|
assert_eq!(repeated_frame.viewport, [800.0, 600.0]);
|
|
assert_eq!(repeated_frame.aspect, 4.0 / 3.0);
|
|
assert_eq!(repeated_frame.camera, next_camera);
|
|
assert_eq!(
|
|
repeated_frame.environment.as_ref().unwrap().sky_mesh,
|
|
next_environment_mesh
|
|
);
|
|
assert_eq!(
|
|
repeated_frame
|
|
.environment
|
|
.as_ref()
|
|
.unwrap()
|
|
.environment_frame,
|
|
next_environment_frame
|
|
);
|
|
assert_eq!(
|
|
control_random_snapshot(&app.control_effect_random_states[&key]),
|
|
next_random
|
|
);
|
|
assert_eq!(
|
|
app.environment.as_ref().unwrap().time_seconds,
|
|
first_environment_time + 0.5
|
|
);
|
|
assert_eq!(app.frames_presented, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn replay_environment_delta_uses_last_applied_clock_and_keeps_duplicates_at_zero() {
|
|
let initial = control_clock_ms_for_frame(500, 0, 101);
|
|
assert_eq!(initial, 0);
|
|
assert_eq!(replay_environment_delta_seconds(0, initial), 0.0);
|
|
|
|
let first = control_clock_ms_for_frame(500, 1, 101);
|
|
let repeated = control_clock_ms_for_frame(500, 1, 101);
|
|
assert_eq!(replay_environment_delta_seconds(initial, first), 0.005);
|
|
assert_eq!(replay_environment_delta_seconds(first, repeated), 0.0);
|
|
let next = control_clock_ms_for_frame(500, 2, 101);
|
|
assert_eq!(replay_environment_delta_seconds(first, next), 0.005);
|
|
assert_eq!(
|
|
replay_environment_delta_seconds(0, control_clock_ms_for_frame(500, 0, 1)),
|
|
0.5
|
|
);
|
|
assert_eq!(replay_environment_delta_seconds(next, 0), 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_effect_screen_bounds_follow_vulkan_viewport_y_direction() {
|
|
let vertices = [
|
|
[-0.5, 0.25, 0.5],
|
|
[-0.5, 0.75, 0.5],
|
|
[0.5, 0.75, 0.5],
|
|
[0.5, 0.25, 0.5],
|
|
];
|
|
let bounds = ambient_effect_projected_bounds(
|
|
&vertices,
|
|
[
|
|
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
|
|
],
|
|
[100, 100],
|
|
)
|
|
.expect("visible clip-space quad");
|
|
assert_eq!(bounds, [25.0, 62.5, 75.0, 87.5]);
|
|
}
|
|
|
|
#[test]
|
|
fn native_tl_sprite_pixels_map_to_current_vulkan_viewport_at_z_999() {
|
|
let uv = [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]];
|
|
let small = screen_tl_quad_vertices_pixels(
|
|
[160.0, 120.0],
|
|
40.0,
|
|
30.0,
|
|
[640.0, 480.0],
|
|
[1.0; 3],
|
|
1.0,
|
|
uv,
|
|
);
|
|
let resized = screen_tl_quad_vertices_pixels(
|
|
[320.0, 240.0],
|
|
80.0,
|
|
60.0,
|
|
[1280.0, 960.0],
|
|
[1.0; 3],
|
|
1.0,
|
|
uv,
|
|
);
|
|
|
|
for (small, resized) in small.iter().zip(resized) {
|
|
for axis in 0..2 {
|
|
assert!((small.position[axis] - resized.position[axis]).abs() < 1.0e-6);
|
|
}
|
|
assert_eq!(small.position[2], 0.999);
|
|
assert_eq!(resized.position[2], 0.999);
|
|
}
|
|
for (actual, expected) in [
|
|
(small[0].position, [-0.625, -0.375, 0.999]),
|
|
(small[2].position, [-0.625, -0.625, 0.999]),
|
|
] {
|
|
for axis in 0..3 {
|
|
assert!((actual[axis] - expected[axis]).abs() < 1.0e-6);
|
|
}
|
|
}
|
|
assert_eq!(small[0].uv, [0.0, 1.0]);
|
|
assert_eq!(small[2].uv, [0.0, 0.0]);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_control_clock_capture() {
|
|
let parsed = Args::parse(&strings(&[
|
|
"--root",
|
|
"testdata/IS",
|
|
"--mission",
|
|
"MISSIONS/Autodemo.00/data.tma",
|
|
"--frames",
|
|
"101",
|
|
"--control-clock-ms",
|
|
"500",
|
|
]))
|
|
.expect("controlled clock capture arguments");
|
|
assert_eq!(parsed.control_clock_ms, Some(500));
|
|
assert!(Args::parse(&strings(&[
|
|
"--root",
|
|
"testdata/IS",
|
|
"--mission",
|
|
"MISSIONS/Autodemo.00/data.tma",
|
|
"--control-clock-ms",
|
|
"500",
|
|
]))
|
|
.expect_err("replay needs multiple frames")
|
|
.contains("at least 2"));
|
|
}
|
|
|
|
#[test]
|
|
fn lightmap_ranges_stay_on_the_decorated_variant_when_phase_changes() {
|
|
let empty_mat0 = Mat0Document {
|
|
version: 0,
|
|
animation_block_count: 0,
|
|
phases: Vec::new(),
|
|
prefix: Vec::new(),
|
|
header_opaque: [0; 2],
|
|
animation_blocks: Vec::new(),
|
|
};
|
|
let mut bindings = vec![MaterialAnimationBinding {
|
|
initial_phase_index: 0,
|
|
normalized_phase_override: None,
|
|
ambient_fx_material_alpha: false,
|
|
variants: vec![MaterialVariantSet {
|
|
phase_material_indices: vec![10, 11],
|
|
range_indices: Vec::new(),
|
|
pipeline_category: 0,
|
|
}],
|
|
base: MaterialPhaseBinding {
|
|
document: empty_mat0,
|
|
animation_block_index: 0,
|
|
wear_row_start_ms: 0,
|
|
random_state: 0,
|
|
frozen_phase_index: Some(0),
|
|
phase_uv_transforms: Vec::new(),
|
|
},
|
|
detail: None,
|
|
overlay: None,
|
|
overlay_detail: None,
|
|
stage_textures: None,
|
|
}];
|
|
let (binding_index, source_phases) =
|
|
find_material_animation_variant(&bindings, 10).expect("base material variant");
|
|
assert_eq!(source_phases, vec![10, 11]);
|
|
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];
|
|
assert_eq!(lightmap_variant.range_indices, vec![7]);
|
|
assert_eq!(
|
|
selected_material_variant_index(lightmap_variant, 0),
|
|
Some(20)
|
|
);
|
|
assert_eq!(
|
|
selected_material_variant_index(lightmap_variant, 1),
|
|
Some(21)
|
|
);
|
|
}
|
|
|
|
fn row_vector_transform(point: [f32; 4], matrix: [f32; 16]) -> [f32; 4] {
|
|
let mut result = [0.0; 4];
|
|
for column in 0..4 {
|
|
result[column] = (0..4)
|
|
.map(|row| point[row] * matrix[row * 4 + column])
|
|
.sum();
|
|
}
|
|
result
|
|
}
|
|
|
|
#[test]
|
|
fn free_flight_camera_uses_right_handed_z_up_basis_and_positive_depth() {
|
|
let camera = FreeFlightCamera {
|
|
position: [0.0, 0.0, 0.0],
|
|
yaw: 0.0,
|
|
pitch: 0.0,
|
|
horizontal_fov: std::f32::consts::FRAC_PI_2,
|
|
near_plane: 1.0,
|
|
far_plane: 11.0,
|
|
move_speed: 1.0,
|
|
ground_clearance: 0.0,
|
|
};
|
|
|
|
let nearly_equal = |left: f32, right: f32| assert!((left - right).abs() < 1.0e-5);
|
|
assert_eq!(camera.forward(), [1.0, 0.0, 0.0]);
|
|
assert_eq!(camera.right(), [0.0, -1.0, 0.0]);
|
|
assert_eq!(camera.up(), [0.0, 0.0, 1.0]);
|
|
let matrix = camera.vulkan_camera(1.0).clip_from_world;
|
|
let near = row_vector_transform([1.0, 0.0, 0.0, 1.0], matrix);
|
|
let far = row_vector_transform([11.0, 0.0, 0.0, 1.0], matrix);
|
|
let camera_right = row_vector_transform([1.0, -1.0, 0.0, 1.0], matrix);
|
|
let world_up = row_vector_transform([1.0, 0.0, 1.0, 1.0], matrix);
|
|
nearly_equal(near[0], 0.0);
|
|
nearly_equal(near[1], 0.0);
|
|
nearly_equal(near[2], 0.0);
|
|
nearly_equal(near[3], 1.0);
|
|
assert!(camera_right[0] / camera_right[3] > 0.0);
|
|
assert!(world_up[1] / world_up[3] < 0.0);
|
|
nearly_equal(far[2] / far[3], 1.0);
|
|
}
|
|
|
|
#[test]
|
|
fn free_flight_camera_uses_native_horizontal_fov_for_every_viewport_aspect() {
|
|
let horizontal_fov = 1.3_f32;
|
|
let camera =
|
|
FreeFlightCamera::from_bounds([0.0; 3], [100.0; 3]).expect("finite free-flight bounds");
|
|
assert_eq!(camera.horizontal_fov, horizontal_fov);
|
|
assert_eq!(camera.near_plane, 0.5);
|
|
assert_eq!(camera.far_plane, 700.0);
|
|
|
|
let assert_edges = |frame: PreviewCameraFrame, aspect: f32| {
|
|
let expected_vertical_fov = 2.0 * ((horizontal_fov * 0.5).tan() / aspect).atan();
|
|
assert!((frame.vertical_fov - expected_vertical_fov).abs() < 1.0e-5);
|
|
let depth = 10.0;
|
|
let center = add3(frame.position, scale3(frame.forward, depth));
|
|
let right_edge = add3(
|
|
center,
|
|
scale3(frame.right, depth * (horizontal_fov * 0.5).tan()),
|
|
);
|
|
let top_edge = add3(
|
|
center,
|
|
scale3(frame.up, depth * (expected_vertical_fov * 0.5).tan()),
|
|
);
|
|
let center_clip = row_vector_transform(
|
|
[center[0], center[1], center[2], 1.0],
|
|
frame.clip_from_world,
|
|
);
|
|
let right_clip = row_vector_transform(
|
|
[right_edge[0], right_edge[1], right_edge[2], 1.0],
|
|
frame.clip_from_world,
|
|
);
|
|
let top_clip = row_vector_transform(
|
|
[top_edge[0], top_edge[1], top_edge[2], 1.0],
|
|
frame.clip_from_world,
|
|
);
|
|
assert!(center_clip[3] > 0.0 && right_clip[3] > 0.0 && top_clip[3] > 0.0);
|
|
assert!(
|
|
center_clip[0].abs() < 5.0e-4,
|
|
"aspect {aspect}: {center_clip:?}"
|
|
);
|
|
assert!(
|
|
center_clip[1].abs() < 5.0e-4,
|
|
"aspect {aspect}: {center_clip:?}"
|
|
);
|
|
assert!(
|
|
(right_clip[0] / right_clip[3] - 1.0).abs() < 1.0e-4,
|
|
"aspect {aspect}: {right_clip:?}"
|
|
);
|
|
assert!(
|
|
(top_clip[1] / top_clip[3] + 1.0).abs() < 1.0e-4,
|
|
"aspect {aspect}: {top_clip:?}"
|
|
);
|
|
};
|
|
|
|
for aspect in [0.05, 0.75, 4.0 / 3.0, 16.0 / 9.0, 20.0] {
|
|
assert_edges(camera.preview_frame(aspect), aspect);
|
|
|
|
let projection = LegacyD3d7Projection {
|
|
viewport: [0, 0, 1000, (1000.0 / aspect).round() as i32],
|
|
near_plane: 0.5,
|
|
far_plane: 700.0,
|
|
field_of_view_radians: horizontal_fov,
|
|
};
|
|
let raw_transform = RawCameraTransform {
|
|
words: [
|
|
1.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
1.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
1.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
1.0_f32.to_bits(),
|
|
],
|
|
};
|
|
let captured = PreviewCameraFrame::from_legacy_d3d7(raw_transform, projection)
|
|
.expect("valid D3D7 projection");
|
|
assert_edges(
|
|
captured,
|
|
projection.viewport[2] as f32 / projection.viewport[3] as f32,
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn direction_projection_accepts_front_offscreen_rays_but_rejects_horizon() {
|
|
let camera = FreeFlightCamera {
|
|
position: [0.0; 3],
|
|
yaw: 0.0,
|
|
pitch: 0.0,
|
|
horizontal_fov: 1.3,
|
|
near_plane: 0.5,
|
|
far_plane: 700.0,
|
|
move_speed: 1.0,
|
|
ground_clearance: 0.0,
|
|
}
|
|
.preview_frame(16.0 / 9.0);
|
|
let viewport = [1280.0, 720.0];
|
|
let offscreen = project_direction_pixels(&camera, [0.2, 0.979_795_9, 0.0], viewport)
|
|
.expect("front-facing directions are not rejected by the near distance");
|
|
assert!(offscreen[0] < 0.0 || offscreen[0] > viewport[0]);
|
|
assert!(project_direction_pixels(&camera, [1.0e-5, 1.0, 0.0], viewport).is_none());
|
|
assert!(project_direction_pixels(&camera, [-1.0, 0.0, 0.0], viewport).is_none());
|
|
|
|
let ray = screen_ray_from_pixels(&camera, [viewport[0] * 1.1, viewport[1] * 0.5], viewport)
|
|
.expect("an offscreen pixel still has a view ray");
|
|
let ray_pixels = project_direction_pixels(&camera, ray, viewport)
|
|
.expect("the offscreen ray remains in the front hemisphere");
|
|
assert!((ray_pixels[0] - viewport[0] * 1.1).abs() < 1.0e-3);
|
|
assert!((ray_pixels[1] - viewport[1] * 0.5).abs() < 1.0e-3);
|
|
}
|
|
|
|
#[test]
|
|
fn native_sky_heading_selects_matching_gradient_sectors_for_both_camera_origins() {
|
|
use fparkan_fx::atmosphere::{PackedColor, SkySample, SKY_COLOR_COUNT};
|
|
|
|
let mut colors = [PackedColor(0xFF00_0000); SKY_COLOR_COUNT];
|
|
colors[..4].copy_from_slice(&[
|
|
PackedColor(0xFF11_0000),
|
|
PackedColor(0xFF00_2200),
|
|
PackedColor(0xFF00_0033),
|
|
PackedColor(0xFF44_4400),
|
|
]);
|
|
let sample = SkySample {
|
|
colors,
|
|
values: [0.0; 2],
|
|
packed: [PackedColor(0); 2],
|
|
};
|
|
let projection = LegacyD3d7Projection {
|
|
viewport: [0, 0, 1280, 720],
|
|
near_plane: 0.5,
|
|
far_plane: 700.0,
|
|
field_of_view_radians: 1.596_851_4,
|
|
};
|
|
|
|
let cardinal_forwards: [([f32; 3], usize); 4] = [
|
|
([0.0, 1.0, 0.0], 0),
|
|
([1.0, 0.0, 0.0], 1),
|
|
([0.0, -1.0, 0.0], 2),
|
|
([-1.0, 0.0, 0.0], 3),
|
|
];
|
|
for (forward, sector) in cardinal_forwards {
|
|
let free_flight = FreeFlightCamera {
|
|
position: [0.0; 3],
|
|
yaw: forward[1].atan2(forward[0]),
|
|
pitch: 0.0,
|
|
horizontal_fov: projection.field_of_view_radians,
|
|
near_plane: projection.near_plane,
|
|
far_plane: projection.far_plane,
|
|
move_speed: 1.0,
|
|
ground_clearance: 0.0,
|
|
}
|
|
.preview_frame(1280.0 / 720.0);
|
|
|
|
let right = normalize3(cross3(forward, [0.0, 0.0, 1.0])).expect("camera right");
|
|
let up = normalize3(cross3(right, forward)).expect("camera up");
|
|
let raw_transform = RawCameraTransform {
|
|
words: [
|
|
forward[0].to_bits(),
|
|
(-right[0]).to_bits(),
|
|
up[0].to_bits(),
|
|
0.0_f32.to_bits(),
|
|
forward[1].to_bits(),
|
|
(-right[1]).to_bits(),
|
|
up[1].to_bits(),
|
|
0.0_f32.to_bits(),
|
|
forward[2].to_bits(),
|
|
(-right[2]).to_bits(),
|
|
up[2].to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
1.0_f32.to_bits(),
|
|
],
|
|
};
|
|
let captured = PreviewCameraFrame::from_legacy_d3d7(raw_transform, projection)
|
|
.expect("captured camera frame");
|
|
|
|
for (origin, camera) in [("free flight", free_flight), ("captured", captured)] {
|
|
let gradient = fparkan_fx::sky::screen_gradient_frame(
|
|
&sample,
|
|
camera.native_sky_heading(),
|
|
[0.0; 3],
|
|
);
|
|
assert_eq!(
|
|
gradient.color, sample.colors[sector],
|
|
"{origin} camera heading for forward {forward:?}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn captured_native_camera_selects_view_dependent_screen_horizon_color() {
|
|
use fparkan_fx::atmosphere::{PackedColor, SkySample, SKY_COLOR_COUNT};
|
|
|
|
// Compact camera fields from the native 1493.337-second AutoDemo capture.
|
|
let capture = parse_legacy_camera_capture(
|
|
br#"{"schema":"fparkan-legacy-camera-v1","render_input_usable":true,"parented_camera":true,"effective_world_matrix_space":"world","effective_world_matrix_words":[1052770830,1064129158,991881820,1141874407,3211610778,1052772554,3144770972,1145115441,3146624790,3127057462,1065352580,1087889619,0,0,0,1065353216],"selector0_words":[1063123992,3204380634,996787387,3130169600,1056894806,1063125889,990301923,3129510813,3146181515,3070164857,1065352633,1080608413,0,0,0,1065353216],"viewport":[0,0,1280,1024],"near_plane":5.0,"far_plane":700.0,"field_of_view_radians":1.3,"atmosphere_seconds":1493.337}"#,
|
|
)
|
|
.expect("valid native camera fixture");
|
|
let camera = capture.preview_camera_frame().expect("valid camera frame");
|
|
|
|
let mut colors = [PackedColor(0xFF00_0000); SKY_COLOR_COUNT];
|
|
colors[..4].copy_from_slice(&[
|
|
PackedColor(0xFF38_3864),
|
|
PackedColor(0xFF38_3864),
|
|
PackedColor(0xFF40_4064),
|
|
PackedColor(0xFF38_3864),
|
|
]);
|
|
let sample = SkySample {
|
|
colors,
|
|
values: [0.0; 2],
|
|
packed: [PackedColor(0); 2],
|
|
};
|
|
let horizon_color =
|
|
fparkan_fx::sky::screen_gradient_frame(&sample, camera.native_sky_heading(), [0.0; 3])
|
|
.color;
|
|
|
|
assert_eq!(capture.atmosphere_seconds, Some(1493.337));
|
|
assert_eq!(horizon_color, PackedColor(0xFF3E_3E64));
|
|
}
|
|
|
|
#[test]
|
|
fn sky_normals_keep_native_signed_byte_scale_without_renormalizing() {
|
|
assert_eq!(sky_normal([-128, 127, -64]), [-1.0, 127.0 / 128.0, -0.5]);
|
|
}
|
|
|
|
fn set_test_terrain_lod_slots(
|
|
terrain: &mut fparkan_terrain_format::LandMeshDocument,
|
|
node_slots: &[[u16; 2]],
|
|
face_ranges: &[[u16; 2]],
|
|
) {
|
|
const NODE_STRIDE: usize = 38;
|
|
let mut nodes = vec![u8::MAX; node_slots.len() * NODE_STRIDE];
|
|
for (node_index, [lod0_slot, lod1_slot]) in node_slots.iter().copied().enumerate() {
|
|
let offset = node_index * NODE_STRIDE + 8;
|
|
nodes[offset..offset + 2].copy_from_slice(&lod0_slot.to_le_bytes());
|
|
nodes[offset + 2..offset + 4].copy_from_slice(&lod1_slot.to_le_bytes());
|
|
}
|
|
let mut slots = vec![[0_u8; 68]; face_ranges.len()];
|
|
for (slot, [start, count]) in slots.iter_mut().zip(face_ranges.iter().copied()) {
|
|
slot[..2].copy_from_slice(&start.to_le_bytes());
|
|
slot[2..4].copy_from_slice(&count.to_le_bytes());
|
|
}
|
|
terrain.nodes_raw = nodes;
|
|
terrain.slots.slots_raw = slots;
|
|
}
|
|
|
|
fn mission_root_for_basement(position: [f32; 3], raw_after_name: u32) -> MissionObjectDraft {
|
|
MissionObjectDraft {
|
|
original_id: None,
|
|
resource_name_raw: b"BUILDS/example.dat".to_vec(),
|
|
raw_after_resource: 0,
|
|
identity_or_clan_raw: 0,
|
|
raw_after_name,
|
|
unit_dat_header_flags: Some(0x8000_0000),
|
|
position,
|
|
orientation_raw: [0.0; 3],
|
|
scale: [1.0; 3],
|
|
visual_ids: Vec::new(),
|
|
properties: Vec::new(),
|
|
unit_components: Vec::new(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn basement_delta_updates_one_common_root_for_mesh_and_attached_component() {
|
|
let roots = [
|
|
mission_root_for_basement([1.0, 2.0, 100.0], 0),
|
|
mission_root_for_basement([3.0, 4.0, 200.0], 1),
|
|
];
|
|
let footprints = [
|
|
MissionBasementFootprint {
|
|
object_index: 0,
|
|
outer: Vec::new(),
|
|
inner: Vec::new(),
|
|
flatten_inner_to_mean_outer_z: true,
|
|
},
|
|
MissionBasementFootprint {
|
|
object_index: 1,
|
|
outer: Vec::new(),
|
|
inner: Vec::new(),
|
|
flatten_inner_to_mean_outer_z: false,
|
|
},
|
|
];
|
|
let placements = [
|
|
BasementPlacement {
|
|
placement_delta: 12.5,
|
|
},
|
|
BasementPlacement {
|
|
placement_delta: 25.0,
|
|
},
|
|
];
|
|
let adjusted = apply_basement_placement_deltas(&roots, &footprints, &placements)
|
|
.expect("aligned native placements");
|
|
assert_eq!(adjusted[0].position, [1.0, 2.0, 87.5]);
|
|
assert_eq!(adjusted[1].position, roots[1].position);
|
|
|
|
let transform = LegacyIron3dEulerTransform {
|
|
translation: adjusted[0].position,
|
|
orientation_radians: adjusted[0].orientation_raw,
|
|
};
|
|
assert_eq!(
|
|
transform.try_transform_scaled_point([0.0, 0.0, 0.0], adjusted[0].scale),
|
|
Some([1.0, 2.0, 87.5])
|
|
);
|
|
assert_eq!(
|
|
transform.try_transform_scaled_point([0.0, 0.0, 4.0], adjusted[0].scale),
|
|
Some([1.0, 2.0, 91.5]),
|
|
"the attached component inherits the root shift once"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn basement_projection_shadows_and_queries_share_generated_base_only_faces() {
|
|
let mut land = fparkan_terrain_format::LandMeshDocument {
|
|
streams: Vec::new(),
|
|
nodes_raw: Vec::new(),
|
|
slots: fparkan_terrain_format::TerrainSlotTable {
|
|
header_raw: Vec::new(),
|
|
slots_raw: Vec::new(),
|
|
},
|
|
positions: vec![[0.0, 0.0, 5.0], [20.0, 0.0, 5.0], [0.0, 20.0, 5.0]],
|
|
normals: vec![[127, 0, 0, 0], [0, 127, 0, 0], [0, 0, 127, 0]],
|
|
uv0: vec![[256, 128], [1024, 256], [512, 1536]],
|
|
accelerator: Vec::new(),
|
|
aux14: Vec::new(),
|
|
aux18: Vec::new(),
|
|
faces: vec![fparkan_terrain_format::TerrainFace28 {
|
|
flags: FullSurfaceMask(0x400),
|
|
material_tag: 0xff00,
|
|
aux_tag: 0,
|
|
vertices: [0, 1, 2],
|
|
neighbors: [None, None, None],
|
|
tail_raw: [0; 8],
|
|
raw: [0; 28],
|
|
}],
|
|
};
|
|
set_test_terrain_lod_slots(&mut land, &[[0, u16::MAX]], &[[0, 1]]);
|
|
let source_mesh = project_land_msh_to_static_mesh_in_world_space(&land)
|
|
.expect("project one LOD0 terrain face");
|
|
let source_world = TerrainWorld::from_land_msh(&land).expect("source terrain");
|
|
let (empty_mesh, empty_shadows, empty_query) =
|
|
prepare_static_terrain_geometry(&land, source_mesh.clone(), &source_world, None)
|
|
.expect("keep unmodified terrain when there are no foundations");
|
|
assert_eq!(empty_mesh, source_mesh);
|
|
assert_eq!(empty_query.surface_count(), source_world.surface_count());
|
|
assert_eq!(empty_shadows.len(), 1);
|
|
assert!(empty_mesh
|
|
.vertices
|
|
.iter()
|
|
.any(|vertex| vertex.normal != [0.0; 3]));
|
|
assert!(empty_mesh
|
|
.vertices
|
|
.iter()
|
|
.any(|vertex| vertex.uv != [0.0; 2]));
|
|
let band_positions = [
|
|
[-18.25, -18.25, 5.0],
|
|
[-2.25, -18.25, 5.0],
|
|
[-18.25, -2.25, 5.0],
|
|
];
|
|
let mut foundation = BasementFoundation {
|
|
fragments: vec![
|
|
BasementFoundationFragment {
|
|
source_face: Some(0),
|
|
surface_mask: FullSurfaceMask(0x400),
|
|
positions: [[0.0, 0.0, 5.0], [20.0, 0.0, 5.0], [0.0, 20.0, 5.0]],
|
|
},
|
|
BasementFoundationFragment {
|
|
source_face: None,
|
|
surface_mask: FullSurfaceMask(0x8300),
|
|
positions: band_positions,
|
|
},
|
|
],
|
|
placements: Vec::new(),
|
|
};
|
|
let (render_mesh, shadow_receivers) =
|
|
project_basement_foundation(&land, &source_mesh, &mut foundation)
|
|
.expect("project canonical basement faces");
|
|
let expected = foundation.fragments.clone();
|
|
assert_eq!(render_mesh.draw_ranges.len(), 2);
|
|
assert!(render_mesh
|
|
.draw_ranges
|
|
.iter()
|
|
.all(|range| range.material_index == 0xff00));
|
|
assert!(!render_mesh.draw_ranges[0].terrain_base_only);
|
|
assert!(render_mesh.draw_ranges[1].terrain_base_only);
|
|
assert_eq!(render_mesh.draw_ranges[1].first_index, 3);
|
|
assert_eq!(shadow_receivers.len(), expected.len());
|
|
|
|
for (triangle_index, fragment) in expected.iter().enumerate() {
|
|
let indices: [u32; 3] = render_mesh.indices[triangle_index * 3..triangle_index * 3 + 3]
|
|
.try_into()
|
|
.expect("projected foundation triangle");
|
|
let positions = indices.map(|index| render_mesh.vertices[index as usize].position);
|
|
assert_eq!(positions, fragment.positions);
|
|
assert_eq!(
|
|
shadow_receivers[triangle_index].positions,
|
|
fragment.positions
|
|
);
|
|
assert_eq!(
|
|
shadow_receivers[triangle_index].mask,
|
|
fragment.surface_mask.0
|
|
);
|
|
}
|
|
assert_eq!(
|
|
basement_band_uv_vertices(band_positions)
|
|
.expect("native per-face UVs")
|
|
.map(|vertex| vertex.uv0),
|
|
[[815, 815], [1896, 815], [815, 1896]],
|
|
"negative coordinates use one native floor origin per triangle"
|
|
);
|
|
assert!(render_mesh.vertices[3..6]
|
|
.iter()
|
|
.flat_map(|vertex| vertex.uv)
|
|
.all(|value| (value * 1024.0).fract() == 0.0));
|
|
|
|
let query_world = source_world
|
|
.with_basement_foundation(foundation)
|
|
.expect("install canonical geometry");
|
|
assert_eq!(query_world.surface_count(), expected.len());
|
|
assert_eq!(
|
|
query_world
|
|
.basement_foundation()
|
|
.expect("installed foundation")
|
|
.fragments,
|
|
expected
|
|
);
|
|
assert_eq!(query_world.height_at([-14.0, -14.0]).unwrap(), Some(5.0));
|
|
}
|
|
|
|
#[test]
|
|
fn basement_band_normals_sum_packed_faces_for_native_welded_vertices() {
|
|
let mut foundation = BasementFoundation {
|
|
fragments: vec![
|
|
BasementFoundationFragment {
|
|
source_face: None,
|
|
surface_mask: FullSurfaceMask(0x8300),
|
|
positions: [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
|
|
},
|
|
BasementFoundationFragment {
|
|
source_face: None,
|
|
surface_mask: FullSurfaceMask(0x8300),
|
|
positions: [[0.005, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 1.0, 1.0]],
|
|
},
|
|
],
|
|
placements: Vec::new(),
|
|
};
|
|
let (normals, uv0_words) = canonicalize_basement_band_vertices(&mut foundation)
|
|
.expect("native packed normal pass");
|
|
let first = normals[0].expect("first synthetic face");
|
|
let second = normals[1].expect("second synthetic face");
|
|
assert_eq!(first[0], second[0], "sub-centimeter XY vertices are welded");
|
|
assert_eq!(foundation.fragments[1].positions[0], [0.0, 0.0, 0.0]);
|
|
assert_eq!(
|
|
first[2], second[1],
|
|
"shared vertices use both packed face normals"
|
|
);
|
|
assert_eq!(uv0_words[1].expect("second face UV words")[0], [0, 0]);
|
|
assert_eq!(first[0][1], 0.0);
|
|
assert_ne!(first[0], [0.0, 0.0, 127.0 / 128.0]);
|
|
}
|
|
|
|
#[test]
|
|
fn basement_face_normal_distinguishes_degenerate_from_non_finite() {
|
|
assert_eq!(
|
|
packed_basement_face_normal([[0.0; 3], [1.0; 3], [2.0; 3]])
|
|
.expect("zero normal is a native zero contribution"),
|
|
None
|
|
);
|
|
assert!(packed_basement_face_normal([
|
|
[f32::NAN, 0.0, 0.0],
|
|
[1.0, 0.0, 0.0],
|
|
[0.0, 1.0, 0.0]
|
|
])
|
|
.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn c00_m02_world_terrain_edge_rounding_keeps_overlay_alpha_in_range() {
|
|
// DATA/MAPS/Tut_2/Land.msh face 2525: this split point lands about
|
|
// 1.35e-6 outside one source edge after the contour kernel's f32
|
|
// world-coordinate output is converted back to f64 barycentrics.
|
|
let source = [
|
|
VulkanStaticVertex {
|
|
position: [815.20465, 281.67642, 320.0],
|
|
color: [1.0; 3],
|
|
normal: [0.0, 0.0, 1.0],
|
|
uv: [0.0; 2],
|
|
detail_uv: [0.0; 2],
|
|
overlay_alpha: 1.0,
|
|
},
|
|
VulkanStaticVertex {
|
|
position: [775.4386, 308.18713, 360.00003],
|
|
color: [1.0; 3],
|
|
normal: [0.0, 0.0, 1.0],
|
|
uv: [0.0; 2],
|
|
detail_uv: [0.0; 2],
|
|
overlay_alpha: 1.0,
|
|
},
|
|
VulkanStaticVertex {
|
|
position: [775.4386, 288.3041, 361.6667],
|
|
color: [1.0; 3],
|
|
normal: [0.0, 0.0, 1.0],
|
|
uv: [0.0; 2],
|
|
detail_uv: [0.0; 2],
|
|
overlay_alpha: 1.0,
|
|
},
|
|
];
|
|
let split_position = [800.6648, 291.3697, 334.62543];
|
|
let point = [f64::from(split_position[0]), f64::from(split_position[1])];
|
|
let a = [
|
|
f64::from(source[0].position[0]),
|
|
f64::from(source[0].position[1]),
|
|
];
|
|
let b = [
|
|
f64::from(source[1].position[0]),
|
|
f64::from(source[1].position[1]),
|
|
];
|
|
let c = [
|
|
f64::from(source[2].position[0]),
|
|
f64::from(source[2].position[1]),
|
|
];
|
|
let denominator = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
|
let second_weight =
|
|
((point[0] - a[0]) * (c[1] - a[1]) - (point[1] - a[1]) * (c[0] - a[0])) / denominator;
|
|
let third_weight =
|
|
((b[0] - a[0]) * (point[1] - a[1]) - (b[1] - a[1]) * (point[0] - a[0])) / denominator;
|
|
let raw_overlay_alpha = [
|
|
1.0 - second_weight - third_weight,
|
|
second_weight,
|
|
third_weight,
|
|
]
|
|
.map(|weight| weight as f32)
|
|
.into_iter()
|
|
.sum::<f32>();
|
|
assert!(raw_overlay_alpha > 1.0 && raw_overlay_alpha <= 1.0 + 1.0e-5);
|
|
let vertex = interpolate_static_terrain_vertex(split_position, source)
|
|
.expect("bounded alpha rounding at a contour split");
|
|
assert_eq!(vertex.overlay_alpha, 1.0);
|
|
assert!((0.0..=1.0).contains(&vertex.overlay_alpha));
|
|
}
|
|
|
|
#[test]
|
|
fn sun_visibility_uses_a_valid_ray_and_fails_open_without_surfaces() {
|
|
let camera = FreeFlightCamera {
|
|
position: [10.0, 20.0, 30.0],
|
|
yaw: 0.0,
|
|
pitch: 0.0,
|
|
horizontal_fov: 1.0,
|
|
near_plane: 0.1,
|
|
far_plane: 100.0,
|
|
move_speed: 1.0,
|
|
ground_clearance: 0.0,
|
|
}
|
|
.preview_frame(16.0 / 9.0);
|
|
let terrain = TerrainWorld::default();
|
|
assert!(native_sun_unoccluded(&terrain, &camera, [1.0, 0.0, 0.0]));
|
|
assert!(!native_sun_unoccluded(&terrain, &camera, [0.0, 0.0, 0.0]));
|
|
|
|
let mut land = fparkan_terrain_format::LandMeshDocument {
|
|
streams: Vec::new(),
|
|
nodes_raw: Vec::new(),
|
|
slots: fparkan_terrain_format::TerrainSlotTable {
|
|
header_raw: Vec::new(),
|
|
slots_raw: Vec::new(),
|
|
},
|
|
// Nonzero source Z is shared by the camera, terrain and shadows.
|
|
positions: vec![[0.0, 0.0, 32.0], [1.0, 0.0, 32.0], [0.0, 1.0, 32.0]],
|
|
normals: Vec::new(),
|
|
uv0: Vec::new(),
|
|
accelerator: Vec::new(),
|
|
aux14: Vec::new(),
|
|
aux18: Vec::new(),
|
|
faces: vec![fparkan_terrain_format::TerrainFace28 {
|
|
flags: FullSurfaceMask(1),
|
|
material_tag: 0,
|
|
aux_tag: 0,
|
|
vertices: [0, 1, 2],
|
|
neighbors: [None, None, None],
|
|
tail_raw: [0; 8],
|
|
raw: [0; 28],
|
|
}],
|
|
};
|
|
set_test_terrain_lod_slots(&mut land, &[[0, u16::MAX]], &[[0, 1]]);
|
|
let shadow_receivers = terrain_shadow_receivers(&land).expect("raw-height receivers");
|
|
assert_eq!(shadow_receivers[0].positions[0], [0.0, 0.0, 32.0]);
|
|
let terrain = TerrainWorld::from_land_msh(&land).expect("synthetic terrain");
|
|
assert_eq!(terrain.height_at([0.25, 0.25]).unwrap(), Some(32.0));
|
|
let floor_camera =
|
|
FreeFlightCamera::from_mesh_and_terrain(&VulkanStaticMesh::smoke_triangle(), &terrain)
|
|
.expect("camera above nonzero terrain");
|
|
assert_eq!(floor_camera.position[2], 34.0);
|
|
assert_eq!(floor_camera.ground_clearance, 2.0);
|
|
let blocked_camera = FreeFlightCamera {
|
|
position: [0.25, 0.25, 34.0],
|
|
yaw: 0.0,
|
|
pitch: 0.0,
|
|
horizontal_fov: 1.0,
|
|
near_plane: 0.1,
|
|
far_plane: 100.0,
|
|
move_speed: 1.0,
|
|
ground_clearance: 0.0,
|
|
}
|
|
.preview_frame(16.0 / 9.0);
|
|
assert!(!native_sun_unoccluded(
|
|
&terrain,
|
|
&blocked_camera,
|
|
[0.0, 0.0, -1.0]
|
|
));
|
|
|
|
// The same hit is beyond a short native sun segment, so it must not
|
|
// occlude the light even though the terrain contains a surface.
|
|
let short_segment_camera = PreviewCameraFrame {
|
|
far_plane: 1.0,
|
|
..blocked_camera
|
|
};
|
|
assert!(native_sun_unoccluded(
|
|
&terrain,
|
|
&short_segment_camera,
|
|
[0.0, 0.0, -1.0]
|
|
));
|
|
|
|
let world_mesh = VulkanStaticMesh::smoke_triangle();
|
|
assert!(!native_sun_unoccluded_with_world(
|
|
&TerrainWorld::default(),
|
|
&world_mesh,
|
|
&[0],
|
|
&blocked_camera,
|
|
[0.0, 0.0, -1.0]
|
|
));
|
|
assert!(native_sun_unoccluded_with_world(
|
|
&TerrainWorld::default(),
|
|
&world_mesh,
|
|
&[],
|
|
&blocked_camera,
|
|
[0.0, 0.0, -1.0]
|
|
));
|
|
|
|
let mut excluded_land = fparkan_terrain_format::LandMeshDocument {
|
|
streams: Vec::new(),
|
|
nodes_raw: Vec::new(),
|
|
slots: fparkan_terrain_format::TerrainSlotTable {
|
|
header_raw: Vec::new(),
|
|
slots_raw: Vec::new(),
|
|
},
|
|
positions: vec![[0.0, 0.0, 32.0], [1.0, 0.0, 32.0], [0.0, 1.0, 32.0]],
|
|
normals: Vec::new(),
|
|
uv0: Vec::new(),
|
|
accelerator: Vec::new(),
|
|
aux14: Vec::new(),
|
|
aux18: Vec::new(),
|
|
faces: vec![fparkan_terrain_format::TerrainFace28 {
|
|
flags: FullSurfaceMask(0x20),
|
|
material_tag: 0,
|
|
aux_tag: 0,
|
|
vertices: [0, 1, 2],
|
|
neighbors: [None, None, None],
|
|
tail_raw: [0; 8],
|
|
raw: [0; 28],
|
|
}],
|
|
};
|
|
set_test_terrain_lod_slots(&mut excluded_land, &[[0, u16::MAX]], &[[0, 1]]);
|
|
let excluded_terrain =
|
|
TerrainWorld::from_land_msh(&excluded_land).expect("excluded terrain");
|
|
assert!(native_sun_unoccluded_with_world(
|
|
&excluded_terrain,
|
|
&world_mesh,
|
|
&[],
|
|
&blocked_camera,
|
|
[0.0, 0.0, -1.0]
|
|
));
|
|
|
|
let mut excluded_world_mesh = world_mesh.clone();
|
|
excluded_world_mesh.draw_ranges[0].batch_flags = 0x0008;
|
|
assert!(native_sun_unoccluded_with_world(
|
|
&TerrainWorld::default(),
|
|
&excluded_world_mesh,
|
|
&[0],
|
|
&blocked_camera,
|
|
[0.0, 0.0, -1.0]
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn free_flight_camera_keeps_clearance_after_descending_and_moving_uphill() {
|
|
let mut land = fparkan_terrain_format::LandMeshDocument {
|
|
streams: Vec::new(),
|
|
nodes_raw: Vec::new(),
|
|
slots: fparkan_terrain_format::TerrainSlotTable {
|
|
header_raw: Vec::new(),
|
|
slots_raw: Vec::new(),
|
|
},
|
|
positions: vec![[0.0, 0.0, 32.0], [1.0, 0.0, 34.0], [0.0, 1.0, 36.0]],
|
|
normals: Vec::new(),
|
|
uv0: Vec::new(),
|
|
accelerator: Vec::new(),
|
|
aux14: Vec::new(),
|
|
aux18: Vec::new(),
|
|
faces: vec![fparkan_terrain_format::TerrainFace28 {
|
|
flags: FullSurfaceMask(1),
|
|
material_tag: 0,
|
|
aux_tag: 0,
|
|
vertices: [0, 1, 2],
|
|
neighbors: [None, None, None],
|
|
tail_raw: [0; 8],
|
|
raw: [0; 28],
|
|
}],
|
|
};
|
|
set_test_terrain_lod_slots(&mut land, &[[0, u16::MAX]], &[[0, 1]]);
|
|
let terrain = TerrainWorld::from_land_msh(&land).expect("sloped terrain");
|
|
assert_eq!(terrain.height_at([0.25, 0.25]).unwrap(), Some(33.5));
|
|
assert_eq!(terrain.height_at([0.75, 0.25]).unwrap(), Some(34.5));
|
|
|
|
let mut camera = FreeFlightCamera {
|
|
position: [0.25, 0.25, 37.0],
|
|
yaw: 0.0,
|
|
pitch: 0.0,
|
|
horizontal_fov: 1.0,
|
|
near_plane: 0.1,
|
|
far_plane: 100.0,
|
|
move_speed: 4.0,
|
|
ground_clearance: 2.0,
|
|
};
|
|
let mut keys = HashSet::from([KeyCode::KeyQ]);
|
|
camera.advance(&keys, 1.0);
|
|
camera
|
|
.keep_above_terrain(&terrain)
|
|
.expect("floor query after descent");
|
|
assert_eq!(camera.position, [0.25, 0.25, 35.5]);
|
|
|
|
keys.clear();
|
|
keys.insert(KeyCode::KeyW);
|
|
camera.move_speed = 1.0;
|
|
camera.advance(&keys, 0.5);
|
|
camera
|
|
.keep_above_terrain(&terrain)
|
|
.expect("floor query after uphill movement");
|
|
assert_eq!(camera.position, [0.75, 0.25, 36.5]);
|
|
|
|
let mut outside_camera = FreeFlightCamera {
|
|
position: [2.0, -2.0, -17.0],
|
|
ground_clearance: 2.0,
|
|
..camera
|
|
};
|
|
outside_camera
|
|
.keep_above_terrain(&terrain)
|
|
.expect("clamp to the map before sampling the floor");
|
|
assert_eq!(outside_camera.position, [1.0, 0.0, 36.0]);
|
|
|
|
outside_camera.position = [-2.0, 2.0, 100.0];
|
|
outside_camera
|
|
.keep_above_terrain(&terrain)
|
|
.expect("clamp the other map edges without limiting altitude");
|
|
assert_eq!(outside_camera.position, [0.0, 1.0, 100.0]);
|
|
|
|
outside_camera.move_speed = 40.0;
|
|
outside_camera.advance(&HashSet::from([KeyCode::KeyW, KeyCode::ShiftLeft]), 0.1);
|
|
outside_camera
|
|
.keep_above_terrain(&terrain)
|
|
.expect("accelerated movement still stops at the map edge");
|
|
assert_eq!(outside_camera.position, [1.0, 1.0, 100.0]);
|
|
|
|
let mut unsupported_camera = FreeFlightCamera {
|
|
position: [0.25, 0.25, 35.5],
|
|
ground_clearance: 2.0,
|
|
..camera
|
|
};
|
|
assert!(unsupported_camera
|
|
.keep_above_terrain(&TerrainWorld::default())
|
|
.expect_err("terrain query errors must propagate")
|
|
.contains("terrain height query"));
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires the original GOG installation"]
|
|
fn free_flight_camera_stops_at_original_map_edges_under_accelerated_movement() {
|
|
let root = std::env::var_os("FPARKAN_CORPUS_PART1_ROOT")
|
|
.expect("set FPARKAN_CORPUS_PART1_ROOT to the original GOG installation");
|
|
let mut engine =
|
|
create(EngineServices::new(Arc::new(DirectoryVfs::new(root)))).expect("engine");
|
|
load_mission_static_preview(
|
|
&mut engine,
|
|
MissionRequest {
|
|
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
|
mode: MissionMode::SingleMission,
|
|
},
|
|
)
|
|
.expect("original mission");
|
|
let terrain = loaded_terrain(&engine).expect("original terrain");
|
|
let [min, max] = terrain.surface_bounds().expect("original map bounds");
|
|
let keys = HashSet::from([KeyCode::KeyW, KeyCode::ShiftLeft]);
|
|
for (axis, yaw, edge) in [
|
|
(0, 0.0, max[0]),
|
|
(0, std::f32::consts::PI, min[0]),
|
|
(1, std::f32::consts::FRAC_PI_2, max[1]),
|
|
(1, -std::f32::consts::FRAC_PI_2, min[1]),
|
|
] {
|
|
let mut camera = FreeFlightCamera::from_bounds(min, max).expect("camera");
|
|
camera.position = [
|
|
(min[0] + max[0]) * 0.5,
|
|
(min[1] + max[1]) * 0.5,
|
|
max[2] + 10.0,
|
|
];
|
|
camera.yaw = yaw;
|
|
camera.pitch = 0.0;
|
|
for _ in 0..512 {
|
|
camera.advance(&keys, 0.1);
|
|
camera.keep_above_terrain(terrain).expect("map constraint");
|
|
for axis in 0..2 {
|
|
assert!((min[axis]..=max[axis]).contains(&camera.position[axis]));
|
|
}
|
|
}
|
|
assert_eq!(camera.position[axis], edge);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn world_sort_key_uses_the_first_indexed_vertex() {
|
|
let mesh = VulkanStaticMesh::smoke_triangle();
|
|
let distance = first_indexed_vertex_distance(&mesh, 0, [0.0, -0.55, -3.0])
|
|
.expect("first indexed vertex distance");
|
|
assert!((distance - 3.0).abs() < 1.0e-6);
|
|
}
|
|
|
|
#[test]
|
|
fn free_flight_camera_advances_wasd_and_vertical_controls() {
|
|
let mut camera = FreeFlightCamera {
|
|
position: [0.0, 0.0, 0.0],
|
|
yaw: 0.0,
|
|
pitch: 0.0,
|
|
horizontal_fov: 1.0,
|
|
near_plane: 0.1,
|
|
far_plane: 100.0,
|
|
move_speed: 2.0,
|
|
ground_clearance: 0.0,
|
|
};
|
|
let mut keys = HashSet::new();
|
|
keys.insert(KeyCode::KeyW);
|
|
keys.insert(KeyCode::KeyD);
|
|
camera.advance(&keys, 1.0);
|
|
let diagonal = 2.0 / 2.0_f32.sqrt();
|
|
assert!((camera.position[0] - diagonal).abs() < 1.0e-5);
|
|
assert!((camera.position[1] + diagonal).abs() < 1.0e-5);
|
|
assert_eq!(camera.position[2], 0.0);
|
|
|
|
keys.clear();
|
|
keys.insert(KeyCode::KeyE);
|
|
keys.insert(KeyCode::ShiftLeft);
|
|
camera.advance(&keys, 0.5);
|
|
assert!((camera.position[2] - 4.0).abs() < 1.0e-5);
|
|
}
|
|
|
|
#[test]
|
|
fn free_flight_mouse_right_turns_toward_previous_right_and_vertical_is_world_z() {
|
|
let mut camera = FreeFlightCamera {
|
|
position: [0.0, 0.0, 0.0],
|
|
yaw: 0.0,
|
|
pitch: 0.6,
|
|
horizontal_fov: 1.0,
|
|
near_plane: 0.1,
|
|
far_plane: 100.0,
|
|
move_speed: 2.0,
|
|
ground_clearance: 0.0,
|
|
};
|
|
let old_right = camera.right();
|
|
let old_forward = camera.forward();
|
|
camera.look_delta(100.0, 0.0);
|
|
assert!(dot3(camera.forward(), old_right) > 0.0);
|
|
assert!(dot3(camera.forward(), old_forward) < 1.0);
|
|
|
|
let mut keys = HashSet::new();
|
|
keys.insert(KeyCode::KeyE);
|
|
camera.advance(&keys, 0.5);
|
|
assert!((camera.position[0]).abs() < 1.0e-5);
|
|
assert!((camera.position[1]).abs() < 1.0e-5);
|
|
assert!((camera.position[2] - 1.0).abs() < 1.0e-5);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_validation_flag() {
|
|
let parsed = Args::parse(&strings(&[
|
|
"--root",
|
|
"testdata/IS",
|
|
"--mission",
|
|
"MISSIONS/Autodemo.00/data.tma",
|
|
"--validation",
|
|
]))
|
|
.expect("valid validation flag");
|
|
assert!(parsed.validation);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_nonzero_static_preview_root_count() {
|
|
let parsed = Args::parse(&strings(&[
|
|
"--root",
|
|
"testdata/IS",
|
|
"--mission",
|
|
"MISSIONS/Autodemo.00/data.tma",
|
|
"--preview-roots",
|
|
"2",
|
|
]))
|
|
.expect("valid static preview arguments");
|
|
assert_eq!(
|
|
parsed.preview_roots,
|
|
NonZeroUsize::new(2).expect("non-zero literal")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_readback_output() {
|
|
let parsed = Args::parse(&strings(&[
|
|
"--root",
|
|
"testdata/IS",
|
|
"--mission",
|
|
"MISSIONS/Autodemo.00/data.tma",
|
|
"--readback-out",
|
|
"target/frame.raw",
|
|
]))
|
|
.expect("valid readback output arguments");
|
|
assert_eq!(parsed.readback_out, Some(PathBuf::from("target/frame.raw")));
|
|
}
|
|
|
|
#[test]
|
|
fn controlled_readback_sequence_requires_and_parses_replay_clock() {
|
|
let parsed = Args::parse(&strings(&[
|
|
"--root",
|
|
"testdata/IS",
|
|
"--mission",
|
|
"MISSIONS/Autodemo.00/data.tma",
|
|
"--frames",
|
|
"11",
|
|
"--control-clock-ms",
|
|
"0",
|
|
"--readback-sequence-out",
|
|
"target/frames",
|
|
]))
|
|
.expect("valid fixed-clock readback sequence");
|
|
assert_eq!(
|
|
parsed.readback_sequence_out,
|
|
Some(PathBuf::from("target/frames"))
|
|
);
|
|
assert_eq!(parsed.control_clock_ms, Some(0));
|
|
|
|
let error = Args::parse(&strings(&[
|
|
"--root",
|
|
"testdata/IS",
|
|
"--mission",
|
|
"MISSIONS/Autodemo.00/data.tma",
|
|
"--frames",
|
|
"11",
|
|
"--readback-sequence-out",
|
|
"target/frames",
|
|
]))
|
|
.expect_err("sequence captures need deterministic clock input");
|
|
assert!(error.contains("requires --control-clock-ms"));
|
|
}
|
|
|
|
#[test]
|
|
fn readback_sequence_writes_clock_named_frame_without_overwrite() {
|
|
let nonce = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.expect("system clock after epoch")
|
|
.as_nanos();
|
|
let directory = std::env::temp_dir().join(format!(
|
|
"fparkan-readback-sequence-{}-{nonce}",
|
|
std::process::id()
|
|
));
|
|
let artifact = VulkanReadbackArtifact {
|
|
format: 37,
|
|
width: 2,
|
|
height: 1,
|
|
bytes: vec![1, 2, 3, 4, 5, 6, 7, 8],
|
|
};
|
|
let path = write_readback_sequence_frame(&directory, 3, 50, &artifact)
|
|
.expect("write frame artifact");
|
|
assert_eq!(
|
|
path.file_name().and_then(|name| name.to_str()),
|
|
Some("frame-000003-clock-0000000050ms-2x1.rgba")
|
|
);
|
|
assert_eq!(
|
|
std::fs::read(&path).expect("read frame artifact"),
|
|
artifact.bytes
|
|
);
|
|
assert!(write_readback_sequence_frame(&directory, 3, 50, &artifact).is_err());
|
|
std::fs::remove_dir_all(directory).expect("remove frame artifact directory");
|
|
}
|
|
|
|
#[test]
|
|
fn parses_static_animation_frame() {
|
|
let parsed = Args::parse(&strings(&[
|
|
"--root",
|
|
"testdata/IS",
|
|
"--mission",
|
|
"MISSIONS/Autodemo.00/data.tma",
|
|
"--static-animation-frame",
|
|
"12",
|
|
]))
|
|
.expect("valid static animation frame");
|
|
assert_eq!(parsed.static_animation_frame, Some(12));
|
|
}
|
|
|
|
#[test]
|
|
fn static_preview_control_defaults_apply_native_flags_and_last_row_wins() {
|
|
let binding =
|
|
|node_index, frame_a, frame_b, initial_blend, flags| PreparedControlNodeBinding {
|
|
node_index,
|
|
frame_a,
|
|
frame_b,
|
|
initial_blend,
|
|
flags,
|
|
};
|
|
let times = static_preview_node_times(
|
|
6,
|
|
&[
|
|
binding(1, 1.0, 3.0, 0.0, 0),
|
|
binding(2, 1.0, 3.0, 1.25, 1),
|
|
binding(3, 1.0, 3.0, -0.5, 1),
|
|
binding(4, 1.0, 3.0, -0.5, 0),
|
|
binding(1, 1.0, 3.0, 0.25, 2),
|
|
binding(usize::MAX, f32::NAN, f32::INFINITY, f32::NAN, 4),
|
|
],
|
|
)
|
|
.expect("valid static CTLD defaults");
|
|
let values = times
|
|
.iter()
|
|
.map(|time| time.map(|time| time.0))
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
values,
|
|
vec![
|
|
Some(0.0),
|
|
Some(2.5),
|
|
Some(1.5),
|
|
Some(2.0),
|
|
Some(1.0),
|
|
Some(0.0)
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn static_preview_rejects_negative_derived_control_sample_time() {
|
|
let error = static_preview_node_times(
|
|
1,
|
|
&[PreparedControlNodeBinding {
|
|
node_index: 0,
|
|
frame_a: 0.0,
|
|
frame_b: 2.0,
|
|
initial_blend: -2.0,
|
|
flags: 1,
|
|
}],
|
|
)
|
|
.expect_err("one wrap still leaves an unsupported negative sample time");
|
|
assert!(error.contains("negative or non-finite sample time"));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_static_material_phase() {
|
|
let parsed = Args::parse(&strings(&[
|
|
"--root",
|
|
"testdata/IS",
|
|
"--mission",
|
|
"MISSIONS/Autodemo.00/data.tma",
|
|
"--static-material-phase",
|
|
"2",
|
|
]))
|
|
.expect("valid static material phase");
|
|
assert_eq!(parsed.static_material_phase, Some(2));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_zero_static_preview_root_count() {
|
|
let error = Args::parse(&strings(&[
|
|
"--root",
|
|
"testdata/IS",
|
|
"--mission",
|
|
"MISSIONS/Autodemo.00/data.tma",
|
|
"--preview-roots",
|
|
"0",
|
|
]));
|
|
assert_eq!(
|
|
error,
|
|
Err("--preview-roots must be a non-zero integer".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_legacy_camera_capture_path() {
|
|
let valid = Args::parse(&strings(&[
|
|
"--root",
|
|
"testdata/IS",
|
|
"--mission",
|
|
"MISSIONS/Autodemo.00/data.tma",
|
|
"--legacy-camera-capture",
|
|
"target/camera.json",
|
|
]))
|
|
.expect("valid legacy camera arguments");
|
|
assert_eq!(
|
|
valid.legacy_camera_capture,
|
|
Some(PathBuf::from("target/camera.json"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_camera_capture_parses_exact_d3d7_inputs() {
|
|
let words = [
|
|
0.0_f32.to_bits(),
|
|
(-1.0_f32).to_bits(),
|
|
0.0_f32.to_bits(),
|
|
10.0_f32.to_bits(),
|
|
1.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
20.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
1.0_f32.to_bits(),
|
|
30.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
1.0_f32.to_bits(),
|
|
];
|
|
let json = format!(
|
|
"{{\"schema\":\"fparkan-legacy-camera-v1\",\"render_input_usable\":true,\"atmosphere_seconds\":56.227,\"selector0_words\":{:?},\"viewport\":[23,17,1047,785],\"near_plane\":0.5,\"far_plane\":700.0,\"field_of_view_radians\":1.3}}",
|
|
words
|
|
);
|
|
|
|
let capture = parse_legacy_camera_capture(json.as_bytes()).expect("valid legacy camera");
|
|
let camera = capture.preview_camera_frame().expect("valid preview frame");
|
|
|
|
assert_eq!(capture.render_input_usable, Some(true));
|
|
assert_eq!(capture.atmosphere_seconds, Some(56.227));
|
|
assert!(camera.clip_from_world.iter().all(|value| value.is_finite()));
|
|
assert_eq!(camera.clip_from_world[0], 0.65_f32.cos());
|
|
assert_eq!(capture.viewport, [23, 17, 1047, 785]);
|
|
assert_eq!(
|
|
capture.captured_viewport().unwrap().rect,
|
|
[23, 17, 1047, 785]
|
|
);
|
|
assert_eq!(capture.captured_viewport().unwrap().extent, [1024, 768]);
|
|
assert!((horizontal_fov(&camera, 4.0 / 3.0) - 1.3).abs() < 1.0e-5);
|
|
assert_eq!(
|
|
camera.vulkan_camera().clip_from_world,
|
|
camera.clip_from_world
|
|
);
|
|
let mut utf16le = vec![0xff, 0xfe];
|
|
utf16le.extend(json.encode_utf16().flat_map(u16::to_le_bytes));
|
|
assert_eq!(
|
|
parse_legacy_camera_capture(&utf16le)
|
|
.expect("PowerShell UTF-16LE capture must be accepted")
|
|
.preview_camera_frame()
|
|
.expect("UTF-16 preview frame")
|
|
.clip_from_world,
|
|
camera.clip_from_world
|
|
);
|
|
assert_eq!(
|
|
parse_legacy_camera_capture(br#"{"schema":"unknown","selector0_words":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"viewport":[0,0,1,1],"near_plane":0.1,"far_plane":1.0,"field_of_view_radians":1.0}"#),
|
|
Err("unsupported legacy camera capture schema".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parented_camera_preview_uses_effective_world_matrix_and_legacy_uses_local() {
|
|
let local_words = [
|
|
1065353216, 0, 0, 1065353216, 0, 1065353216, 0, 1073741824, 0, 0, 1065353216,
|
|
1077936128, 0, 0, 0, 1065353216,
|
|
];
|
|
let world_words = [
|
|
1065353216, 0, 0, 1092616192, 0, 1065353216, 0, 1101004800, 0, 0, 1065353216,
|
|
1106247680, 0, 0, 0, 1065353216,
|
|
];
|
|
let make_json = |extra: &str| {
|
|
format!(
|
|
"{{\"schema\":\"fparkan-legacy-camera-v1\",\"render_input_usable\":true,\"selector0_words\":{:?},{}\"viewport\":[0,0,1280,1024],\"near_plane\":0.5,\"far_plane\":700.0,\"field_of_view_radians\":1.04}}",
|
|
local_words, extra
|
|
)
|
|
};
|
|
let legacy = parse_legacy_camera_capture(make_json("").as_bytes())
|
|
.expect("legacy camera without parent metadata");
|
|
assert_eq!(legacy.preview_matrix_words().unwrap(), local_words);
|
|
assert_eq!(
|
|
legacy.preview_camera_frame().unwrap().position,
|
|
[1.0, 2.0, 3.0]
|
|
);
|
|
|
|
let parented = parse_legacy_camera_capture(
|
|
make_json(&format!(
|
|
"\"parented_camera\":true,\"effective_world_matrix_space\":\"world\",\"effective_world_matrix_words\":{:?},",
|
|
world_words
|
|
))
|
|
.as_bytes(),
|
|
)
|
|
.expect("parented camera with effective world matrix");
|
|
assert_eq!(parented.preview_matrix_words().unwrap(), world_words);
|
|
let preview = parented.preview_camera_frame().unwrap();
|
|
assert_eq!(preview.position, [10.0, 20.0, 30.0]);
|
|
assert_ne!(
|
|
preview.position,
|
|
legacy.preview_camera_frame().unwrap().position
|
|
);
|
|
|
|
let missing_world = make_json("\"parented_camera\":true,");
|
|
assert!(parse_legacy_camera_capture(missing_world.as_bytes())
|
|
.expect_err("parented local-only input must be rejected")
|
|
.contains("parented legacy camera requires an effective world-space matrix"));
|
|
let missing_marker = make_json(&format!(
|
|
"\"parented_camera\":true,\"effective_world_matrix_words\":{:?},",
|
|
world_words
|
|
));
|
|
assert!(parse_legacy_camera_capture(missing_marker.as_bytes())
|
|
.expect_err("world matrix requires space marker")
|
|
.contains("effective_world_matrix_space and effective_world_matrix_words"));
|
|
}
|
|
|
|
#[test]
|
|
fn parented_camera_preview_uses_effective_world_matrix_fixture() {
|
|
let fixture = include_bytes!("../testdata/legacy-camera-parented.json");
|
|
let capture = parse_legacy_camera_capture(fixture).expect("valid parented camera fixture");
|
|
let parse_value = |value: serde_json::Value| {
|
|
let bytes = serde_json::to_vec(&value).expect("serialize camera fixture");
|
|
parse_legacy_camera_capture(&bytes)
|
|
};
|
|
assert_eq!(capture.parented_camera, Some(true));
|
|
assert_eq!(
|
|
capture.preview_camera_frame().unwrap().position,
|
|
[10.0, 20.0, 30.0]
|
|
);
|
|
assert_eq!(
|
|
capture.preview_matrix_words().unwrap(),
|
|
capture.effective_world_matrix_words.unwrap()
|
|
);
|
|
|
|
let mut missing_world: serde_json::Value =
|
|
serde_json::from_slice(fixture).expect("valid fixture JSON");
|
|
missing_world
|
|
.as_object_mut()
|
|
.unwrap()
|
|
.remove("effective_world_matrix_words");
|
|
let missing_world = serde_json::to_vec(&missing_world).unwrap();
|
|
assert!(parse_legacy_camera_capture(&missing_world)
|
|
.expect_err("incomplete world matrix pair")
|
|
.contains("effective_world_matrix_space and effective_world_matrix_words"));
|
|
|
|
let mut parented_without_world: serde_json::Value =
|
|
serde_json::from_slice(fixture).expect("valid fixture JSON");
|
|
let parented_without_world = parented_without_world.as_object_mut().unwrap();
|
|
parented_without_world.remove("effective_world_matrix_space");
|
|
parented_without_world.remove("effective_world_matrix_words");
|
|
assert!(
|
|
parse_value(serde_json::Value::Object(parented_without_world.clone()))
|
|
.expect_err("parented camera without its world matrix")
|
|
.contains("parented legacy camera requires an effective world-space matrix")
|
|
);
|
|
|
|
let mut invalid_marker: serde_json::Value =
|
|
serde_json::from_slice(fixture).expect("valid fixture JSON");
|
|
invalid_marker["effective_world_matrix_space"] =
|
|
serde_json::Value::String("local".to_string());
|
|
assert!(parse_value(invalid_marker)
|
|
.expect_err("unknown matrix space")
|
|
.contains("effective_world_matrix_space must be 'world'"));
|
|
|
|
let mut legacy: serde_json::Value =
|
|
serde_json::from_slice(fixture).expect("valid fixture JSON");
|
|
let legacy = legacy.as_object_mut().unwrap();
|
|
legacy.remove("parented_camera");
|
|
legacy.remove("effective_world_matrix_space");
|
|
legacy.remove("effective_world_matrix_words");
|
|
let legacy = parse_value(serde_json::Value::Object(legacy.clone()))
|
|
.expect("older local-only camera capture");
|
|
assert_eq!(
|
|
legacy.preview_camera_frame().unwrap().position,
|
|
[1.0, 2.0, 3.0]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn captured_atmosphere_is_default_and_cli_time_takes_precedence() {
|
|
let capture = LegacyCameraCapture {
|
|
schema: "fparkan-legacy-camera-v1".to_string(),
|
|
render_input_usable: None,
|
|
selector0_words: [0; 16],
|
|
parented_camera: None,
|
|
effective_world_matrix_space: None,
|
|
effective_world_matrix_words: None,
|
|
viewport: [0, 0, 1, 1],
|
|
near_plane: 0.1,
|
|
far_plane: 1.0,
|
|
field_of_view_radians: 1.0,
|
|
atmosphere_seconds: Some(56.227),
|
|
};
|
|
|
|
assert_eq!(
|
|
selected_atmosphere_seconds(None, Some(&capture)),
|
|
Some(56.227)
|
|
);
|
|
assert_eq!(
|
|
selected_atmosphere_seconds(Some(100.0), Some(&capture)),
|
|
Some(100.0)
|
|
);
|
|
assert_eq!(selected_atmosphere_seconds(None, None), None);
|
|
}
|
|
|
|
#[test]
|
|
fn captured_environment_holds_exact_phase_while_free_time_advances() {
|
|
let (fixed_relative, fixed_absolute) =
|
|
environment_sample_time(0.227, 56.0, 0.25, Some(56.227));
|
|
assert_eq!(fixed_relative, 0.227);
|
|
assert_eq!(fixed_absolute, 56.227);
|
|
|
|
let (free_relative, free_absolute) = environment_sample_time(0.227, 56.0, 0.25, None);
|
|
assert_eq!(free_relative, 0.477);
|
|
assert_eq!(free_absolute, 56.477);
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_camera_capture_rejects_invalid_phase_and_unusable_input() {
|
|
let base = r#"{"schema":"fparkan-legacy-camera-v1","selector0_words":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"viewport":[0,0,1,1],"near_plane":0.1,"far_plane":1.0,"field_of_view_radians":1.0}"#;
|
|
let invalid_phase = base.replace(
|
|
"\"near_plane\"",
|
|
"\"atmosphere_seconds\":-1.0,\"near_plane\"",
|
|
);
|
|
assert!(parse_legacy_camera_capture(invalid_phase.as_bytes())
|
|
.expect_err("negative captured phase")
|
|
.contains("atmosphere_seconds must be finite and non-negative"));
|
|
|
|
let unusable = base.replace(
|
|
"\"near_plane\"",
|
|
"\"render_input_usable\":false,\"near_plane\"",
|
|
);
|
|
assert!(parse_legacy_camera_capture(unusable.as_bytes())
|
|
.expect_err("explicitly unusable camera capture")
|
|
.contains("render_input_usable=false"));
|
|
}
|
|
|
|
#[test]
|
|
fn captured_camera_frame_keeps_native_roll_basis() {
|
|
let roll = 0.37_f32;
|
|
let (sin, cos) = roll.sin_cos();
|
|
let transform = RawCameraTransform {
|
|
words: [
|
|
1.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
cos.to_bits(),
|
|
(-sin).to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
sin.to_bits(),
|
|
cos.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
0.0_f32.to_bits(),
|
|
1.0_f32.to_bits(),
|
|
],
|
|
};
|
|
let projection = LegacyD3d7Projection {
|
|
viewport: [0, 0, 800, 600],
|
|
near_plane: 0.1,
|
|
far_plane: 500.0,
|
|
field_of_view_radians: 1.0,
|
|
};
|
|
let view = transform
|
|
.try_direct3d7_view_row_major()
|
|
.expect("valid native camera basis");
|
|
let frame = PreviewCameraFrame::from_legacy_d3d7(transform, projection)
|
|
.expect("valid captured frame");
|
|
|
|
assert_eq!(frame.forward, [view[2], view[6], view[10]]);
|
|
assert_eq!(frame.right, [view[0], view[4], view[8]]);
|
|
assert_eq!(frame.up, [view[1], view[5], view[9]]);
|
|
assert!(frame.right[2].abs() > 0.3, "roll must tilt the right axis");
|
|
assert!(frame.up[1].abs() > 0.3, "roll must tilt the up axis");
|
|
assert_eq!(
|
|
frame.clip_from_world,
|
|
VulkanStaticCamera::from_legacy_d3d7(transform, projection)
|
|
.expect("legacy clip matrix")
|
|
.clip_from_world
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn captured_camera_refuses_a_different_drawable_extent() {
|
|
let captured = CapturedViewport {
|
|
rect: [23, 17, 1047, 785],
|
|
extent: [1024, 768],
|
|
};
|
|
assert!(validate_captured_drawable_extent(captured, [1024, 768]).is_ok());
|
|
let error = validate_captured_drawable_extent(captured, [1280, 720])
|
|
.expect_err("a mismatched surface must not stretch the captured view");
|
|
assert!(error.contains("[23, 17, 1047, 785]"));
|
|
assert!(error.contains("1024x768"));
|
|
assert!(error.contains("1280x720"));
|
|
}
|
|
|
|
#[test]
|
|
fn static_preview_component_merge_offsets_indices_and_remaps_local_selectors(
|
|
) -> Result<(), String> {
|
|
let mut merged = VulkanStaticMesh {
|
|
vertices: Vec::new(),
|
|
indices: Vec::new(),
|
|
draw_ranges: Vec::new(),
|
|
};
|
|
append_static_preview_component(
|
|
&mut merged,
|
|
VulkanStaticMesh::smoke_triangle(),
|
|
&[(0, 4)],
|
|
None,
|
|
)?;
|
|
append_static_preview_component(
|
|
&mut merged,
|
|
VulkanStaticMesh::smoke_triangle(),
|
|
&[(0, 9)],
|
|
None,
|
|
)?;
|
|
|
|
assert_eq!(merged.vertices.len(), 6);
|
|
assert_eq!(merged.indices, vec![0, 1, 2, 3, 4, 5]);
|
|
assert_eq!(merged.draw_ranges.len(), 2);
|
|
assert_eq!(merged.draw_ranges[0].first_index, 0);
|
|
assert_eq!(merged.draw_ranges[0].material_index, 4);
|
|
assert_eq!(merged.draw_ranges[1].first_index, 3);
|
|
assert_eq!(merged.draw_ranges[1].material_index, 9);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn static_preview_component_merge_keeps_indices_above_u16() -> Result<(), String> {
|
|
let vertex = fparkan_render_vulkan::VulkanStaticVertex {
|
|
position: [0.0, 0.0, 0.0],
|
|
color: [1.0, 1.0, 1.0],
|
|
normal: [0.0, 0.0, 1.0],
|
|
uv: [0.0, 0.0],
|
|
detail_uv: [0.0, 0.0],
|
|
overlay_alpha: 0.0,
|
|
};
|
|
let mut merged = VulkanStaticMesh {
|
|
vertices: vec![vertex; usize::from(u16::MAX) + 1],
|
|
indices: Vec::new(),
|
|
draw_ranges: Vec::new(),
|
|
};
|
|
|
|
append_static_preview_component(
|
|
&mut merged,
|
|
VulkanStaticMesh::smoke_triangle(),
|
|
&[(0, 0)],
|
|
None,
|
|
)?;
|
|
|
|
assert_eq!(merged.indices, vec![65_536, 65_537, 65_538]);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn precipitation_upload_reconstructs_native_ndc_depths_and_uvs() {
|
|
let camera = FreeFlightCamera {
|
|
position: [10.0, 20.0, 30.0],
|
|
yaw: 0.0,
|
|
pitch: 0.0,
|
|
horizontal_fov: std::f32::consts::FRAC_PI_2,
|
|
near_plane: 0.1,
|
|
far_plane: 100.0,
|
|
move_speed: 1.0,
|
|
ground_clearance: 0.0,
|
|
}
|
|
.preview_frame(1.0);
|
|
let screen = ScreenBillboard {
|
|
world_head: [0.0; 3],
|
|
world_tail: [0.0; 3],
|
|
head: [0.0, 0.0],
|
|
tail: [0.0, 0.0],
|
|
corners: [[-0.25, -0.5], [0.25, -0.5], [-0.25, 0.5], [0.25, 0.5]],
|
|
uv: [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6], [0.7, 0.8]],
|
|
half_size: 12.0,
|
|
head_depth: 8.0,
|
|
tail_depth: 4.0,
|
|
};
|
|
let vertices = precipitation_quad_vertices(
|
|
PrecipitationKind::Rain,
|
|
&screen,
|
|
[1.0, 0.5, 0.25, 0.75],
|
|
&camera,
|
|
[100.0, 100.0],
|
|
);
|
|
assert_eq!(vertices[0].uv, screen.uv[0]);
|
|
assert_eq!(vertices[3].uv, screen.uv[3]);
|
|
assert!(
|
|
(dot3(
|
|
sub3(vertices[0].position, camera.position),
|
|
camera.forward()
|
|
) - 4.0)
|
|
.abs()
|
|
< 1.0e-5
|
|
);
|
|
assert!(
|
|
(dot3(
|
|
sub3(vertices[3].position, camera.position),
|
|
camera.forward()
|
|
) - 8.0)
|
|
.abs()
|
|
< 1.0e-5
|
|
);
|
|
assert_eq!(vertices[0].overlay_alpha, 0.75);
|
|
}
|
|
|
|
#[test]
|
|
fn sprite_upload_remaps_native_left_top_uv_order_to_quad_positions() {
|
|
let camera = FreeFlightCamera {
|
|
position: [0.0; 3],
|
|
yaw: 0.0,
|
|
pitch: 0.0,
|
|
horizontal_fov: 1.0,
|
|
near_plane: 0.1,
|
|
far_plane: 100.0,
|
|
move_speed: 1.0,
|
|
ground_clearance: 0.0,
|
|
}
|
|
.preview_frame(1.0);
|
|
let uv = [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [7.0, 8.0]];
|
|
let vertices = sprite_quad_vertices_pixels(
|
|
&camera,
|
|
camera.forward(),
|
|
10.0,
|
|
1.0,
|
|
1.0,
|
|
[100.0, 100.0],
|
|
[1.0; 3],
|
|
1.0,
|
|
uv,
|
|
);
|
|
assert_eq!(
|
|
vertices.map(|vertex| vertex.uv),
|
|
[uv[1], uv[2], uv[0], uv[3]]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn control_type4_basis_branches_preserve_anisotropic_owner_scale() {
|
|
let owner_scale = [2.0, 3.0, 4.0];
|
|
let equal = [
|
|
control_point([0.0; 3], [1.0, 1.0, 0.0]),
|
|
control_point([0.0; 3], [1.0, 1.0, 0.0]),
|
|
control_point([0.0; 3], [1.0, 1.0, 0.0]),
|
|
];
|
|
let equal_matrix =
|
|
control_type4_local_matrix([&equal[0], &equal[1], &equal[2]], owner_scale).unwrap();
|
|
assert_matrix_columns(
|
|
equal_matrix,
|
|
[
|
|
[2.0, 3.0, 0.0],
|
|
[-3.0, 2.0, 0.0],
|
|
[0.0, 0.0, 13.0_f32.sqrt()],
|
|
],
|
|
);
|
|
|
|
let unique = [
|
|
control_point([1.0; 3], [1.0, 2.0, 3.0]),
|
|
control_point([2.0; 3], [4.0, 5.0, 6.0]),
|
|
control_point([3.0; 3], [-1.0, 1.0, 2.0]),
|
|
];
|
|
let unique_matrix =
|
|
control_type4_local_matrix([&unique[0], &unique[1], &unique[2]], owner_scale).unwrap();
|
|
assert_matrix_columns(
|
|
unique_matrix,
|
|
[[2.0, 6.0, 12.0], [8.0, 15.0, 24.0], [-2.0, 3.0, 8.0]],
|
|
);
|
|
assert_eq!(
|
|
[
|
|
unique_matrix[0][3],
|
|
unique_matrix[1][3],
|
|
unique_matrix[2][3]
|
|
],
|
|
[4.0, 6.0, 8.0]
|
|
);
|
|
|
|
for (directions, expected) in [
|
|
(
|
|
[[1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]],
|
|
[[2.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 2.0]],
|
|
),
|
|
(
|
|
[[0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]],
|
|
[[0.0, 0.0, 4.0], [0.0, -1.0, 0.0], [4.0, 0.0, 0.0]],
|
|
),
|
|
(
|
|
[[0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0]],
|
|
[[0.0, 3.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 0.0, 3.0]],
|
|
),
|
|
(
|
|
[[1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]],
|
|
[[2.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 2.0]],
|
|
),
|
|
] {
|
|
let repeated = directions.map(|direction| control_point([0.0; 3], direction));
|
|
let matrix =
|
|
control_type4_local_matrix([&repeated[0], &repeated[1], &repeated[2]], owner_scale)
|
|
.unwrap();
|
|
assert_matrix_columns(matrix, expected);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn control_type4_world_matrix_rotates_basis_and_keeps_scale_off_translation() {
|
|
let point = control_point([1.0, 0.0, 0.0], [1.0, 0.0, 0.0]);
|
|
let local =
|
|
control_type4_effect_local_matrix([&point, &point, &point], [2.0, 3.0, 4.0]).unwrap();
|
|
let matrix = control_type4_world_from_effect(
|
|
local,
|
|
[2.0, 3.0, 4.0],
|
|
LegacyIron3dEulerTransform {
|
|
translation: [10.0, 20.0, 30.0],
|
|
orientation_radians: [0.0, 0.0, std::f32::consts::FRAC_PI_2],
|
|
},
|
|
None,
|
|
VulkanNodePose {
|
|
translation: [0.0; 3],
|
|
rotation: [0.0, 0.0, 0.0, 1.0],
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
assert_matrix_columns(
|
|
matrix,
|
|
[[0.0, 4.0, 0.0], [-9.0, 0.0, 0.0], [0.0, 0.0, 16.0]],
|
|
);
|
|
for (actual, expected) in [matrix[0][3], matrix[1][3], matrix[2][3]]
|
|
.into_iter()
|
|
.zip([10.0, 22.0, 30.0])
|
|
{
|
|
assert!((actual - expected).abs() < 1.0e-5);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn mounted_control_effect_uses_component_pose_before_owner_world_transform() {
|
|
let point = control_point([1.0, 0.0, 0.0], [1.0, 0.0, 0.0]);
|
|
let mount_pose = VulkanNodePose {
|
|
translation: [1.0, 2.0, 3.0],
|
|
rotation: [
|
|
0.0,
|
|
0.0,
|
|
std::f32::consts::FRAC_1_SQRT_2,
|
|
std::f32::consts::FRAC_1_SQRT_2,
|
|
],
|
|
};
|
|
let owner_node_pose = VulkanNodePose {
|
|
translation: [0.0; 3],
|
|
rotation: [0.0, 0.0, 0.0, 1.0],
|
|
};
|
|
let local = control_type4_effect_local_matrix([&point, &point, &point], [1.0; 3]).unwrap();
|
|
let matrix = control_type4_world_from_effect(
|
|
local,
|
|
[2.0, 3.0, 4.0],
|
|
LegacyIron3dEulerTransform {
|
|
translation: [10.0, 20.0, 30.0],
|
|
orientation_radians: [0.0; 3],
|
|
},
|
|
Some(mount_pose),
|
|
owner_node_pose,
|
|
)
|
|
.unwrap();
|
|
|
|
for (actual, expected) in [matrix[0][3], matrix[1][3], matrix[2][3]]
|
|
.into_iter()
|
|
.zip([12.0, 29.0, 42.0])
|
|
{
|
|
assert!((actual - expected).abs() < 1.0e-5);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn plant_node23_control_points_match_captured_native_world_positions() {
|
|
let root_transform = LegacyIron3dEulerTransform {
|
|
translation: [323.55048, 806.97266, 0.36829287],
|
|
orientation_radians: [0.0, 0.0, 1.6262363],
|
|
};
|
|
let owner_node_pose = VulkanNodePose {
|
|
translation: [0.0, -68.15999, 21.12],
|
|
rotation: [0.0, 0.0, 0.0, 1.0],
|
|
};
|
|
let points = [
|
|
(
|
|
[-12.964036, 12.991658, -21.119995],
|
|
[379.35242, 797.0855, 0.3682986],
|
|
),
|
|
(
|
|
[13.49774, 12.991658, -21.119995],
|
|
[377.8861, 823.5066, 0.3682986],
|
|
),
|
|
(
|
|
[9.507235, -3.006884, -21.119993],
|
|
[394.08118, 820.40875, 0.3683005],
|
|
),
|
|
(
|
|
[-9.080112, -3.2843084, -21.119991],
|
|
[395.38815, 801.86536, 0.3683024],
|
|
),
|
|
];
|
|
|
|
for (local_position, expected_world) in points {
|
|
let point = control_point(local_position, [1.0, 0.0, 0.0]);
|
|
let local = control_type4_effect_local_matrix([&point, &point, &point], [1.0; 3])
|
|
.expect("CPT descriptor local matrix");
|
|
let world = control_type4_world_from_effect(
|
|
local,
|
|
[1.0; 3],
|
|
root_transform,
|
|
None,
|
|
owner_node_pose,
|
|
)
|
|
.expect("attached Control owner matrix");
|
|
for axis in 0..3 {
|
|
assert!(
|
|
(world[axis][3] - expected_world[axis]).abs() < 0.1,
|
|
"axis {axis}: actual {} expected {}",
|
|
world[axis][3],
|
|
expected_world[axis]
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_effect_world_refreshes_from_each_component_pose_buffer() {
|
|
let mut poses = vec![fparkan_animation::Pose::default(); 24];
|
|
poses[23].translation = [0.0, -68.15999, 21.12];
|
|
let mut slot = AmbientEffectGpuSlot {
|
|
kind: AmbientEffectKind::Op3Type2,
|
|
time_mode: 1,
|
|
header_flags: 0,
|
|
random_instance_key: ControlEffectInstanceKey {
|
|
object_index: 0,
|
|
component_index: 0,
|
|
table_index: 8,
|
|
row_index: 6,
|
|
},
|
|
random_seed: 1,
|
|
phase_jitter: 0.0,
|
|
random_shift: [0.0; 3],
|
|
owner_object_index: 0,
|
|
owner_component_index: 0,
|
|
owner_node_index: 23,
|
|
owner_component_is_mounted: false,
|
|
root_transform: LegacyIron3dEulerTransform {
|
|
translation: [323.55048, 806.97266, 0.36829287],
|
|
orientation_radians: [0.0, 0.0, 1.6262363],
|
|
},
|
|
owner_scale: [1.0; 3],
|
|
mount_pose: None,
|
|
local_from_effect: [
|
|
[1.0, 0.0, 0.0, -12.964036],
|
|
[0.0, 1.0, 0.0, 12.991658],
|
|
[0.0, 0.0, 1.0, -21.119995],
|
|
[0.0, 0.0, 0.0, 1.0],
|
|
],
|
|
control_source: String::new(),
|
|
control_table_index: 8,
|
|
control_row_index: 6,
|
|
control_point_indices: [104; 3],
|
|
control_point_owners: [23; 3],
|
|
command: endpoint_op3_command(),
|
|
command_index: 0,
|
|
duration_seconds: 1.0,
|
|
world_from_effect: [[0.0; 4]; 4],
|
|
range: EnvironmentGpuRange {
|
|
range_index: 0,
|
|
vertex_start: 0,
|
|
vertex_capacity: 4,
|
|
material_index: 0,
|
|
},
|
|
additional_particle_ranges: Vec::new(),
|
|
material_animation_index: 0,
|
|
additional_particle_material_animation_indices: Vec::new(),
|
|
smoke_emitter_state: None,
|
|
op7_emitter_state: None,
|
|
};
|
|
let mut component_pose_buffers = vec![vec![Some(NodePoseBuffer { poses })]];
|
|
let first = ambient_effect_world_from_component_pose(&slot, &component_pose_buffers)
|
|
.expect("first sampled owner pose");
|
|
assert!((first[0][3] - 379.35242).abs() < 0.1 && (first[1][3] - 797.0855).abs() < 0.1);
|
|
|
|
let pose = &mut component_pose_buffers[0][0].as_mut().unwrap().poses[23];
|
|
pose.translation = [
|
|
pose.translation[0] + 1.0,
|
|
pose.translation[1] + 2.0,
|
|
pose.translation[2] + 3.0,
|
|
];
|
|
let second = ambient_effect_world_from_component_pose(&slot, &component_pose_buffers)
|
|
.expect("next sampled owner pose");
|
|
slot.world_from_effect = second;
|
|
assert_ne!(first, slot.world_from_effect);
|
|
let expected_delta = rotate_unit_vector(
|
|
[1.0, 2.0, 3.0],
|
|
[
|
|
0.0,
|
|
0.0,
|
|
(1.6262363_f32 * 0.5).sin(),
|
|
(1.6262363_f32 * 0.5).cos(),
|
|
],
|
|
);
|
|
for (axis, expected) in expected_delta.into_iter().enumerate() {
|
|
let delta = slot.world_from_effect[axis][3] - first[axis][3];
|
|
assert!((delta - expected).abs() < 0.1, "axis {axis}: {delta}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn mode1_control_effects_hold_active_with_full_opacity_after_duration() {
|
|
let phase = native_command_phase(
|
|
0,
|
|
ambient_effect_phase(1, 0, 1.25, 1.0).expect("mode 1 phase"),
|
|
0.0,
|
|
);
|
|
assert_eq!(phase, 1.0);
|
|
let identity = [
|
|
[1.0, 0.0, 0.0, 0.0],
|
|
[0.0, 1.0, 0.0, 0.0],
|
|
[0.0, 0.0, 1.0, 0.0],
|
|
[0.0, 0.0, 0.0, 1.0],
|
|
];
|
|
|
|
let lamp =
|
|
evaluate_op3_type2_command(&endpoint_op3_command(), 0, &identity, 1.25, phase, 0)
|
|
.expect("lamp endpoint");
|
|
assert!(lamp.active);
|
|
assert_eq!(lamp.color[3], 1.0);
|
|
|
|
let op9 = endpoint_gener_ball_command(FxOpcode::Op9);
|
|
let generated = evaluate_f_gener_ball_mesh(
|
|
&op9,
|
|
0,
|
|
&identity,
|
|
phase,
|
|
1.25,
|
|
[0.0, 0.0, 500.0],
|
|
AMBIENT_EFFECT_NATIVE_CATEGORY,
|
|
)
|
|
.expect("generator sphere endpoint");
|
|
assert!(generated.active);
|
|
assert_eq!(generated.color[3], 1.0);
|
|
assert_eq!(generated.vertices.len(), 88);
|
|
assert_eq!(generated.indices.len(), 120);
|
|
assert_eq!(generated.native_category, 0);
|
|
|
|
let op4 = endpoint_gener_ball_command(FxOpcode::Op4);
|
|
let orb = evaluate_f_gener_ball(&op4, 0, &identity, phase, 1.25, [0.0, 0.0, 500.0])
|
|
.expect("opcode 4 billboard endpoint");
|
|
assert!(orb.active);
|
|
assert_eq!(orb.color[3], 1.0);
|
|
}
|
|
|
|
#[test]
|
|
fn op9_mesh_bridge_allocates_one_range_per_quad_and_cap() {
|
|
let command = endpoint_gener_ball_command(FxOpcode::Op9);
|
|
let identity = [
|
|
[1.0, 0.0, 0.0, 0.0],
|
|
[0.0, 1.0, 0.0, 0.0],
|
|
[0.0, 0.0, 1.0, 0.0],
|
|
[0.0, 0.0, 0.0, 1.0],
|
|
];
|
|
let frame = evaluate_f_gener_ball_mesh(
|
|
&command,
|
|
0,
|
|
&identity,
|
|
0.5,
|
|
0.5,
|
|
[0.0, 0.0, 500.0],
|
|
AMBIENT_EFFECT_NATIVE_CATEGORY,
|
|
)
|
|
.expect("category-zero Op9 mesh");
|
|
let primitives = ambient_effect_mesh_primitive_layout(&frame)
|
|
.expect("native Op9 quad/cap primitive layout");
|
|
assert_eq!(primitives.len(), 24);
|
|
assert_eq!(
|
|
primitives
|
|
.iter()
|
|
.filter(|item| item.vertex_count == 4)
|
|
.count(),
|
|
16
|
|
);
|
|
assert_eq!(
|
|
primitives
|
|
.iter()
|
|
.filter(|item| item.vertex_count == 3)
|
|
.count(),
|
|
8
|
|
);
|
|
|
|
let mut mesh = VulkanStaticMesh {
|
|
vertices: Vec::new(),
|
|
indices: Vec::new(),
|
|
draw_ranges: Vec::new(),
|
|
};
|
|
let ranges = primitives
|
|
.iter()
|
|
.copied()
|
|
.map(|primitive| {
|
|
let range = append_environment_range(
|
|
&mut mesh,
|
|
0,
|
|
ambient_effect_mesh_primitive_vertices(&frame, primitive),
|
|
ambient_effect_mesh_primitive_indices(&frame, primitive)
|
|
.expect("primitive-local indices"),
|
|
LegacyPipelineState::default(),
|
|
)
|
|
.expect("allocate one native primitive range");
|
|
write_ambient_effect_mesh_primitive(&mut mesh, &range, &frame, primitive)
|
|
.expect("write native primitive frame");
|
|
range
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
assert_eq!(ranges.len(), 24);
|
|
assert_eq!(mesh.draw_ranges.len(), 24);
|
|
assert_eq!(mesh.indices.len(), 120);
|
|
assert!(mesh.draw_ranges.iter().all(|range| {
|
|
range.alpha_test_reference == VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE
|
|
}));
|
|
assert_eq!(
|
|
mesh.draw_ranges
|
|
.iter()
|
|
.map(|range| range.index_count)
|
|
.sum::<u32>(),
|
|
120
|
|
);
|
|
for (range, primitive) in ranges.iter().zip(primitives) {
|
|
let vertices =
|
|
&mesh.vertices[range.vertex_start..range.vertex_start + range.vertex_capacity];
|
|
assert_eq!(range.vertex_capacity, primitive.vertex_count);
|
|
for (offset, vertex) in vertices.iter().enumerate() {
|
|
assert_eq!(
|
|
vertex.position,
|
|
frame.vertices[primitive.vertex_start + offset]
|
|
);
|
|
assert_eq!(vertex.uv, frame.uv[primitive.vertex_start + offset]);
|
|
assert_eq!(vertex.overlay_alpha, frame.color[3]);
|
|
}
|
|
let range_indices = &mesh.draw_ranges[range.range_index];
|
|
assert_eq!(range_indices.index_count as usize, primitive.index_count);
|
|
let first_index = range_indices.first_index as usize;
|
|
assert_eq!(
|
|
mesh.indices[first_index],
|
|
u32::try_from(range.vertex_start).unwrap()
|
|
);
|
|
let camera = [10.0, -5.0, 2.0];
|
|
let expected = sub3(frame.vertices[primitive.vertex_start], camera);
|
|
let expected = dot3(expected, expected).sqrt();
|
|
let actual = first_indexed_vertex_distance(&mesh, range.range_index, camera)
|
|
.expect("native Op9 primitive distance key");
|
|
assert!((actual - expected).abs() < 1e-5);
|
|
}
|
|
|
|
for selector in [1, 2] {
|
|
let mut command = command.clone();
|
|
write_effect_u32(Arc::make_mut(&mut command.raw_body), 0xC4, selector);
|
|
let frame = evaluate_f_gener_ball_mesh(
|
|
&command,
|
|
0,
|
|
&identity,
|
|
0.5,
|
|
0.5,
|
|
[0.0, 0.0, 500.0],
|
|
AMBIENT_EFFECT_NATIVE_CATEGORY,
|
|
)
|
|
.expect("Op9 selector frame");
|
|
assert!(!ambient_effect_mesh_primitive_layout(&frame)
|
|
.expect("selector native quad/cap layout")
|
|
.is_empty());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn mode2_control_effects_repeat_and_reset_at_header_duration() {
|
|
assert_eq!(ambient_effect_phase(2, 0, 1.0, 2.0), Some(0.5));
|
|
assert_eq!(ambient_effect_phase(2, 0, 2.0, 2.0), Some(0.0));
|
|
assert_eq!(ambient_effect_phase(2, 0, 2.25, 2.0), Some(0.125));
|
|
assert_eq!(ambient_effect_phase(1, 0, 2.0, 2.0), Some(1.0));
|
|
assert_eq!(ambient_effect_phase(0, 0, 0.5, 0.0), Some(0.0));
|
|
assert_eq!(ambient_effect_phase(3, 0, 0.5, 2.0), None);
|
|
assert_eq!(ambient_effect_phase(2, 0x20, 0.5, 1.0), Some(1.0));
|
|
assert_eq!(ambient_effect_phase(2, 0x20, 0.75, 1.0), Some(0.5));
|
|
assert_eq!(ambient_effect_phase(1, 0x200, 0.5, 1.0), Some(0.25));
|
|
assert_eq!(ambient_effect_phase(1, 0x220, 0.5, 1.0), Some(0.5));
|
|
assert_eq!(ambient_effect_phase(1, 0x220, 1.5, 1.0), Some(-2.5));
|
|
assert_eq!(ambient_effect_phase(1, 0x20, 1.5, 1.0), Some(-1.0));
|
|
}
|
|
|
|
#[test]
|
|
fn control_lamp_accepts_only_supported_bodies_and_keeps_mat0_state() {
|
|
assert_eq!(
|
|
ambient_effect_command_kind(&endpoint_op3_command()),
|
|
Some(AmbientEffectKind::Op3Type2)
|
|
);
|
|
let mut type0 = endpoint_op3_command();
|
|
write_effect_u32(Arc::make_mut(&mut type0.raw_body), 0, 0);
|
|
assert_eq!(
|
|
ambient_effect_command_kind(&type0),
|
|
Some(AmbientEffectKind::Op3Type0)
|
|
);
|
|
assert!(ambient_effect_render_selector_allows_header(0x10, 0));
|
|
assert!(!ambient_effect_render_selector_allows_header(0x800, 0));
|
|
assert!(!ambient_effect_render_selector_allows_header(0xD00, 0));
|
|
assert!(ambient_effect_render_selector_allows_header(0xD00, 1));
|
|
assert!(ambient_effect_render_selector_allows_header(0xD00, 2));
|
|
for opcode in [FxOpcode::Op9, FxOpcode::Op4] {
|
|
assert_eq!(
|
|
ambient_effect_command_kind(&endpoint_gener_ball_command(opcode)),
|
|
Some(AmbientEffectKind::GenerBall),
|
|
"{opcode:?}"
|
|
);
|
|
}
|
|
for selector in [1, 2] {
|
|
let mut op9 = endpoint_gener_ball_command(FxOpcode::Op9);
|
|
write_effect_u32(Arc::make_mut(&mut op9.raw_body), 0xC4, selector);
|
|
assert_eq!(
|
|
ambient_effect_command_kind(&op9),
|
|
Some(AmbientEffectKind::GenerBall),
|
|
"Op9 selector {selector}"
|
|
);
|
|
}
|
|
let mut invalid_op9 = endpoint_gener_ball_command(FxOpcode::Op9);
|
|
write_effect_u32(Arc::make_mut(&mut invalid_op9.raw_body), 0xC4, 3);
|
|
assert_eq!(ambient_effect_command_kind(&invalid_op9), None);
|
|
assert_eq!(
|
|
ambient_effect_command_kind(&endpoint_op8_command(6)),
|
|
Some(AmbientEffectKind::SmokePool)
|
|
);
|
|
for pool_count in [0, 4097] {
|
|
assert_eq!(
|
|
ambient_effect_command_kind(&endpoint_op8_command(pool_count)),
|
|
None
|
|
);
|
|
}
|
|
for raw_mode in [0, 1] {
|
|
let op7 = endpoint_op7_command(raw_mode, 1.5, 0.0, 3, 2);
|
|
assert_eq!(
|
|
ambient_effect_command_kind(&op7),
|
|
Some(AmbientEffectKind::Op7ParticlePool),
|
|
"raw mode {raw_mode}"
|
|
);
|
|
assert_eq!(ambient_effect_op7_range_capacity(&op7), Some(6));
|
|
}
|
|
let accelerated_op7 = endpoint_op7_command(1, 1.5, 0.25, 3, 2);
|
|
assert_eq!(
|
|
ambient_effect_command_kind(&accelerated_op7),
|
|
Some(AmbientEffectKind::Op7ParticlePool)
|
|
);
|
|
assert_eq!(ambient_effect_op7_range_capacity(&accelerated_op7), Some(6));
|
|
for unsupported in [
|
|
endpoint_op7_command(2, 1.5, 0.0, 3, 2),
|
|
endpoint_op7_command(0, 1.5, f32::NAN, 3, 2),
|
|
endpoint_op7_command(0, 0.0, 0.0, 3, 2),
|
|
endpoint_op7_command(0, f32::NAN, 0.0, 3, 2),
|
|
endpoint_op7_command(0, 1.5, 0.0, u32::MAX, 2),
|
|
] {
|
|
assert_eq!(ambient_effect_command_kind(&unsupported), None);
|
|
}
|
|
|
|
let state = ambient_effect_pipeline_state(2).expect("native additive MAT0 category");
|
|
assert_eq!(state.blend, LegacyBlendMode::Additive);
|
|
assert!(state.alpha_test);
|
|
assert_eq!(state.depth, LegacyDepthMode::TestReadOnly);
|
|
}
|
|
|
|
#[test]
|
|
fn op1_control_lights_use_native_phase_owner_transform_and_flags() {
|
|
let mut slot = ControlEffectSlot {
|
|
time_mode: 1,
|
|
header_flags: 0,
|
|
random_instance_key: ControlEffectInstanceKey {
|
|
object_index: 0,
|
|
component_index: 0,
|
|
table_index: 0,
|
|
row_index: 0,
|
|
},
|
|
random_seed: 1,
|
|
phase_jitter: 0.0,
|
|
random_shift: [0.0; 3],
|
|
duration_seconds: 1.0,
|
|
owner_object_index: 0,
|
|
owner_component_index: 0,
|
|
owner_node_index: 0,
|
|
owner_component_is_mounted: false,
|
|
root_transform: LegacyIron3dEulerTransform {
|
|
translation: [10.0, 20.0, 30.0],
|
|
orientation_radians: [0.0; 3],
|
|
},
|
|
owner_scale: [1.0; 3],
|
|
mount_pose: None,
|
|
local_from_effect: [
|
|
[1.0, 0.0, 0.0, 1.0],
|
|
[0.0, 1.0, 0.0, 2.0],
|
|
[0.0, 0.0, 1.0, 3.0],
|
|
[0.0, 0.0, 0.0, 1.0],
|
|
],
|
|
world_from_effect: [[0.0; 4]; 4],
|
|
command_index: 0,
|
|
command: endpoint_op1_command(6, 0.5, 1.0),
|
|
};
|
|
let poses = vec![vec![None]];
|
|
let mounts = vec![vec![None]];
|
|
|
|
let inactive = slot.clone();
|
|
let random_samples = HashMap::new();
|
|
assert!(update_control_point_lights(
|
|
&mut [inactive],
|
|
&poses,
|
|
&mounts,
|
|
0.25,
|
|
&random_samples,
|
|
false,
|
|
)
|
|
.is_empty());
|
|
let lights = update_control_point_lights(
|
|
std::slice::from_mut(&mut slot),
|
|
&poses,
|
|
&mounts,
|
|
0.75,
|
|
&random_samples,
|
|
false,
|
|
);
|
|
assert_eq!(lights.len(), 1);
|
|
let (light, flags) = lights[0];
|
|
assert_eq!(light.position, [13.0, 22.0, 33.0]);
|
|
assert_eq!(light.rgb, [0.25, 0.5, 0.75]);
|
|
assert_eq!(light.range, 15.0);
|
|
assert_eq!(light.coefficients, [1.0, 0.5, 0.25]);
|
|
assert_eq!(flags, 0xA000_0000);
|
|
assert_eq!(
|
|
[
|
|
slot.world_from_effect[0][3],
|
|
slot.world_from_effect[1][3],
|
|
slot.world_from_effect[2][3]
|
|
],
|
|
[11.0, 22.0, 33.0]
|
|
);
|
|
|
|
slot.header_flags = 0x29;
|
|
slot.random_seed = 0x1234_5678;
|
|
slot.phase_jitter = 0.4;
|
|
slot.random_shift = [2.0, 4.0, 6.0];
|
|
let sibling_slots = [slot.clone(), slot.clone()];
|
|
let mut states = HashMap::new();
|
|
let first = advance_control_effect_random_states(
|
|
&mut states,
|
|
&[],
|
|
&sibling_slots,
|
|
&[],
|
|
SceneSampleId::Replay(7),
|
|
150,
|
|
1.5,
|
|
);
|
|
assert_eq!(first.len(), 1);
|
|
let key = slot.random_instance_key;
|
|
let first_sample = first[&key];
|
|
assert_eq!(first_sample.phase_jitter, 0.0, "the -1 phase skips jitter");
|
|
assert_ne!(first_sample.accumulated_shift, [0.0; 3]);
|
|
let mut expected_state = ControlEffectRandomState::new(
|
|
slot.random_seed,
|
|
slot.header_flags,
|
|
slot.phase_jitter,
|
|
slot.random_shift,
|
|
);
|
|
assert_eq!(
|
|
first_sample,
|
|
expected_state.advance(SceneSampleId::Replay(7), 150, -1.0)
|
|
);
|
|
assert_eq!(
|
|
advance_control_effect_random_states(
|
|
&mut states,
|
|
&[],
|
|
&sibling_slots,
|
|
&[],
|
|
SceneSampleId::Replay(7),
|
|
150,
|
|
1.5,
|
|
)[&key],
|
|
first_sample,
|
|
"a repeated read of the same captured update reuses the shared sample"
|
|
);
|
|
let second = advance_control_effect_random_states(
|
|
&mut states,
|
|
&[],
|
|
&sibling_slots,
|
|
&[],
|
|
SceneSampleId::Replay(8),
|
|
200,
|
|
0.5,
|
|
);
|
|
assert_eq!(
|
|
second[&key],
|
|
expected_state.advance(SceneSampleId::Replay(8), 200, 1.0)
|
|
);
|
|
assert_ne!(second[&key].phase_jitter, first_sample.phase_jitter);
|
|
assert_ne!(
|
|
second[&key].accumulated_shift,
|
|
first_sample.accumulated_shift
|
|
);
|
|
assert_eq!(states[&key].last_update_clock_ms, Some(200));
|
|
|
|
let negative_non_sentinel =
|
|
ambient_effect_phase(1, 0x220, 1.5, 1.0).expect("post-duration transformed phase");
|
|
assert_eq!(negative_non_sentinel, -2.5);
|
|
let mut negative_state = ControlEffectRandomState::new(
|
|
slot.random_seed,
|
|
0x229,
|
|
slot.phase_jitter,
|
|
slot.random_shift,
|
|
);
|
|
assert_eq!(
|
|
native_command_phase(0x229, negative_non_sentinel, 0.4),
|
|
0.0,
|
|
"a phase below -1 clamps to zero rather than using the -1 sentinel"
|
|
);
|
|
assert_ne!(
|
|
negative_state
|
|
.advance(SceneSampleId::Live(1), 150, negative_non_sentinel)
|
|
.phase_jitter,
|
|
0.0,
|
|
"a non-sentinel negative phase still consumes the jitter sample"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn op1_generic_diffuse_list_excludes_bit31_and_clears_inactive_lights() {
|
|
let light = |position, active| VulkanPointLight {
|
|
position,
|
|
rgb: [0.25, 0.5, 0.75],
|
|
range: 15.0,
|
|
coefficients: [1.0, 0.5, 0.25],
|
|
active,
|
|
};
|
|
let raw_records = vec![
|
|
(light([1.0, 2.0, 3.0], true), 0xA000_0000),
|
|
(light([4.0, 5.0, 6.0], true), 0x2000_0000),
|
|
(light([7.0, 8.0, 9.0], true), 0x8000_0000),
|
|
(light([10.0, 11.0, 12.0], false), 0x0000_0000),
|
|
];
|
|
|
|
let ordinary = ordinary_control_point_lights(&raw_records);
|
|
assert_eq!(ordinary.len(), 1);
|
|
assert_eq!(ordinary[0].position, [4.0, 5.0, 6.0]);
|
|
assert_eq!(raw_records.len(), 4);
|
|
assert_eq!(
|
|
raw_records
|
|
.iter()
|
|
.map(|(_, flags)| *flags)
|
|
.collect::<Vec<_>>(),
|
|
vec![0xA000_0000, 0x2000_0000, 0x8000_0000, 0]
|
|
);
|
|
|
|
assert!(ordinary_control_point_lights(&[]).is_empty());
|
|
assert!(
|
|
ordinary_control_point_lights(&[(light([13.0, 14.0, 15.0], false), 0x2000_0000)])
|
|
.is_empty()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn op1_control_slot_accepts_supported_bodies_with_instance_random_flags() {
|
|
let command = endpoint_op1_command(6, 0.0, 1.0);
|
|
assert!(ambient_effect_op1_supported(&command));
|
|
assert!(!ambient_effect_op1_supported(&endpoint_op1_command(
|
|
4, 0.0, 1.0
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn op1_phase_preserves_native_sentinel_and_initial_manager_gate() {
|
|
assert_eq!(native_command_phase(0, -1.0, 0.4), -1.0);
|
|
assert_eq!(native_command_phase(0x100, 0.625, 0.0), 0.625);
|
|
assert_eq!(native_command_phase(0x80, 0.625, 0.4), 0.0);
|
|
assert_eq!(native_command_phase(0x81, -1.0, 0.4), 0.4);
|
|
assert_eq!(native_command_phase(0, -0.25, 0.0), 0.0);
|
|
assert_eq!(native_command_phase(0, 1.25, 0.0), 1.0);
|
|
assert_eq!(native_command_phase(1, 0.5, 0.25), 0.75);
|
|
assert_eq!(native_command_phase(1, 0.9, 0.25), 1.0);
|
|
let flags_200_20 =
|
|
ambient_effect_phase(1, 0x220, 1.5, 1.0).expect("triangular post-duration phase");
|
|
assert_eq!(flags_200_20, -2.5);
|
|
assert_eq!(native_command_phase(0x220, flags_200_20, 0.4), 0.0);
|
|
let raw_minus_one =
|
|
ambient_effect_phase(1, 0x20, 1.5, 1.0).expect("triangular sentinel phase");
|
|
assert_eq!(raw_minus_one, -1.0);
|
|
assert_eq!(native_command_phase(0x21, raw_minus_one, 0.4), -1.0);
|
|
assert_eq!(native_command_phase(0xA1, raw_minus_one, 0.4), 0.4);
|
|
}
|
|
|
|
#[test]
|
|
fn op7_variable_frames_clear_inactive_draw_ranges() {
|
|
let aligned = align_ambient_effect_frames(
|
|
vec![endpoint_effect_frame(3), endpoint_effect_frame(7)],
|
|
4,
|
|
true,
|
|
)
|
|
.expect("variable Op7 output fits its fixed range allocation");
|
|
assert_eq!(aligned.len(), 4);
|
|
assert_eq!(
|
|
aligned[0].as_ref().map(|frame| frame.command_index),
|
|
Some(3)
|
|
);
|
|
assert_eq!(
|
|
aligned[1].as_ref().map(|frame| frame.command_index),
|
|
Some(7)
|
|
);
|
|
assert!(aligned[2].is_none() && aligned[3].is_none());
|
|
|
|
assert!(align_ambient_effect_frames(vec![endpoint_effect_frame(0)], 4, false,).is_err());
|
|
assert!(align_ambient_effect_frames(vec![endpoint_effect_frame(0); 5], 4, true,).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn active_control_fx_vertex_color_uses_sampled_mat0_additive_rgb() {
|
|
let mut slot = AmbientEffectGpuSlot {
|
|
kind: AmbientEffectKind::Op3Type2,
|
|
time_mode: 1,
|
|
header_flags: 0,
|
|
random_instance_key: ControlEffectInstanceKey {
|
|
object_index: 0,
|
|
component_index: 0,
|
|
table_index: 0,
|
|
row_index: 0,
|
|
},
|
|
random_seed: 1,
|
|
phase_jitter: 0.0,
|
|
random_shift: [0.0; 3],
|
|
owner_object_index: 0,
|
|
owner_component_index: 0,
|
|
owner_node_index: 0,
|
|
owner_component_is_mounted: false,
|
|
root_transform: LegacyIron3dEulerTransform {
|
|
translation: [0.0; 3],
|
|
orientation_radians: [0.0; 3],
|
|
},
|
|
owner_scale: [1.0; 3],
|
|
mount_pose: None,
|
|
local_from_effect: [
|
|
[1.0, 0.0, 0.0, 0.0],
|
|
[0.0, 1.0, 0.0, 0.0],
|
|
[0.0, 0.0, 1.0, 0.0],
|
|
[0.0, 0.0, 0.0, 1.0],
|
|
],
|
|
control_source: String::new(),
|
|
control_table_index: 0,
|
|
control_row_index: 0,
|
|
control_point_indices: [0; 3],
|
|
control_point_owners: [0; 3],
|
|
command: endpoint_op3_command(),
|
|
command_index: 0,
|
|
duration_seconds: 1.0,
|
|
world_from_effect: [
|
|
[1.0, 0.0, 0.0, 0.0],
|
|
[0.0, 1.0, 0.0, 0.0],
|
|
[0.0, 0.0, 1.0, 0.0],
|
|
[0.0, 0.0, 0.0, 1.0],
|
|
],
|
|
range: EnvironmentGpuRange {
|
|
range_index: 0,
|
|
vertex_start: 0,
|
|
vertex_capacity: 4,
|
|
material_index: 0,
|
|
},
|
|
additional_particle_ranges: vec![EnvironmentGpuRange {
|
|
range_index: 1,
|
|
vertex_start: 4,
|
|
vertex_capacity: 4,
|
|
material_index: 1,
|
|
}],
|
|
material_animation_index: 0,
|
|
additional_particle_material_animation_indices: vec![1],
|
|
smoke_emitter_state: None,
|
|
op7_emitter_state: None,
|
|
};
|
|
assert_eq!(
|
|
ambient_effect_ranges(&slot)
|
|
.iter()
|
|
.map(|(range, binding)| (range.range_index, *binding))
|
|
.collect::<Vec<_>>(),
|
|
vec![(0, 0), (1, 1)]
|
|
);
|
|
let mut mesh = VulkanStaticMesh {
|
|
vertices: vec![environment_vertex([0.0; 3], [1.0; 3], [0.0; 2]); 8],
|
|
indices: Vec::new(),
|
|
draw_ranges: Vec::new(),
|
|
};
|
|
let updates = [
|
|
AmbientEffectGeometryUpdate {
|
|
range_index: 0,
|
|
index_count: 6,
|
|
command_rgb: [1.0; 3],
|
|
},
|
|
AmbientEffectGeometryUpdate {
|
|
range_index: 1,
|
|
index_count: 6,
|
|
command_rgb: [1.0; 3],
|
|
},
|
|
];
|
|
let mut active_materials = HashMap::from([
|
|
(
|
|
0,
|
|
ActiveMaterialState {
|
|
material_index: 0,
|
|
phase_index: 0,
|
|
diffuse_alpha: 1.0,
|
|
additive_rgb: [0.9882353, 1.0, 0.16862746],
|
|
},
|
|
),
|
|
(
|
|
1,
|
|
ActiveMaterialState {
|
|
material_index: 1,
|
|
phase_index: 0,
|
|
diffuse_alpha: 1.0,
|
|
additive_rgb: [0.9882353, 1.0, 0.16862746],
|
|
},
|
|
),
|
|
]);
|
|
|
|
apply_ambient_effect_material_tints(
|
|
&[slot.clone()],
|
|
&mut mesh,
|
|
&updates,
|
|
&active_materials,
|
|
)
|
|
.expect("apply sampled yellow MAT0 color");
|
|
assert!(mesh
|
|
.vertices
|
|
.iter()
|
|
.all(|vertex| vertex.color == [0.9882353, 1.0, 0.16862746]));
|
|
|
|
for material in active_materials.values_mut() {
|
|
material.additive_rgb = [0.1, 0.7, 0.2];
|
|
}
|
|
slot.kind = AmbientEffectKind::SmokePool;
|
|
apply_ambient_effect_material_tints(&[slot], &mut mesh, &updates, &active_materials)
|
|
.expect("apply next sampled MAT0 color");
|
|
assert!(mesh
|
|
.vertices
|
|
.iter()
|
|
.all(|vertex| vertex.color == [0.1, 0.7, 0.2]));
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_fx_uses_vertex_opacity_without_multiplying_mat0_opacity() {
|
|
let mat0_opacity = ambient_effect_material_alpha(true, 0.4);
|
|
let effect_opacity = 0.5;
|
|
let texture_alpha = 0.25;
|
|
assert_eq!(mat0_opacity, 1.0);
|
|
assert_eq!(mat0_opacity * effect_opacity, 0.5);
|
|
assert_eq!(texture_alpha * mat0_opacity * effect_opacity, 0.125);
|
|
assert_eq!(ambient_effect_material_alpha(false, 0.4), 0.4);
|
|
}
|
|
|
|
#[test]
|
|
fn material_animation_cpu_packet_uses_sampled_phase_without_renderer() {
|
|
let phase = |additive_red| MaterialPhase {
|
|
parameters: [
|
|
additive_red,
|
|
0,
|
|
0,
|
|
100,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
],
|
|
texture_raw: [0; 16],
|
|
};
|
|
let document = Mat0Document {
|
|
version: 0,
|
|
animation_block_count: 1,
|
|
phases: vec![phase(0), phase(255)],
|
|
prefix: Vec::new(),
|
|
header_opaque: [0; 2],
|
|
animation_blocks: vec![MaterialAnimationBlock {
|
|
header_raw: 0,
|
|
keys: vec![
|
|
MaterialKey {
|
|
k0: 0,
|
|
k1: 500,
|
|
k2: 0,
|
|
},
|
|
MaterialKey {
|
|
k0: 1,
|
|
k1: 1000,
|
|
k2: 0,
|
|
},
|
|
],
|
|
bytes: Vec::new(),
|
|
}],
|
|
};
|
|
let mut binding = MaterialAnimationBinding {
|
|
initial_phase_index: 0,
|
|
normalized_phase_override: Some(0.75),
|
|
ambient_fx_material_alpha: true,
|
|
variants: Vec::new(),
|
|
base: MaterialPhaseBinding {
|
|
document,
|
|
animation_block_index: 0,
|
|
wear_row_start_ms: 0,
|
|
random_state: 0,
|
|
frozen_phase_index: None,
|
|
phase_uv_transforms: vec![[0.0, 0.0, 1.0, 1.0]; 2],
|
|
},
|
|
detail: None,
|
|
overlay: None,
|
|
overlay_detail: None,
|
|
stage_textures: None,
|
|
};
|
|
|
|
let effect_sample = sample_material_animation_base(&mut binding, 0)
|
|
.expect("sample the effect's native normalized MAT0 phase");
|
|
assert_eq!(effect_sample.phase_index, 1);
|
|
assert_eq!(effect_sample.coefficients.additive_rgb, [1.0, 0.0, 0.0]);
|
|
|
|
binding.normalized_phase_override = None;
|
|
let world_sample = sample_material_animation_base(&mut binding, 0)
|
|
.expect("sample ordinary clock-driven material phase");
|
|
assert_eq!(world_sample.phase_index, 0);
|
|
assert_eq!(world_sample.coefficients.additive_rgb, [0.0, 0.0, 0.0]);
|
|
|
|
binding.variants.push(MaterialVariantSet {
|
|
phase_material_indices: vec![0, 0],
|
|
range_indices: vec![7],
|
|
pipeline_category: 0,
|
|
});
|
|
let mut materials = Vec::new();
|
|
append_environment_gradient_material(&mut materials)
|
|
.expect("prepare test material without a renderer");
|
|
let update =
|
|
sample_material_animations(std::slice::from_mut(&mut binding), &materials, 500)
|
|
.expect("produce a CPU material-frame packet without a renderer");
|
|
assert_eq!(update.active_materials[&7].material_index, 0);
|
|
assert_eq!(update.active_materials[&7].phase_index, 1);
|
|
assert_eq!(update.range_selections[0].range_index, 7);
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_fx_tint_requires_a_proven_ambient_mat0_phase() {
|
|
assert!(ambient_effect_tint_supported(
|
|
&[false, false],
|
|
&[[0.0; 3], [0.2; 3]],
|
|
&[[1.0, 1.0, 1.0], [0.2, 0.4, 0.8]],
|
|
));
|
|
assert!(!ambient_effect_tint_supported(&[], &[], &[]));
|
|
assert!(!ambient_effect_tint_supported(&[false], &[], &[]));
|
|
assert!(!ambient_effect_tint_supported(
|
|
&[true],
|
|
&[[0.0; 3]],
|
|
&[[1.0; 3]]
|
|
));
|
|
assert!(!ambient_effect_tint_supported(
|
|
&[false],
|
|
&[[0.1, 0.0, 0.0]],
|
|
&[[0.0; 3]],
|
|
));
|
|
assert!(!ambient_effect_tint_supported(
|
|
&[false],
|
|
&[[0.0; 3]],
|
|
&[[f32::NAN, 1.0, 1.0]]
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_fx_ranges_are_excluded_from_world_alpha_and_count_updates() {
|
|
let fixed_ranges = HashSet::from([1, 4, 7]);
|
|
let world_ranges = world_draw_range_indices(9, &fixed_ranges);
|
|
assert_eq!(world_ranges, vec![0, 2, 3, 5, 6, 8]);
|
|
assert!(!world_ranges.contains(&7));
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_control_rows_accept_unconditional_low_words_but_skip_state_gates_and_chains() {
|
|
let mut row = PreparedControlEffectRow {
|
|
table_index: 2,
|
|
row_index: 0,
|
|
raw_condition: [0, 7, 9],
|
|
point_offsets: [0, 1, 2],
|
|
selector: 0,
|
|
resource: fparkan_assets::PreparedControlResourcePair {
|
|
archive: [0; 32],
|
|
name: [0; 32],
|
|
},
|
|
};
|
|
assert!(ambient_effect_row_unconditionally_active(&row));
|
|
for condition_flag in [0x1000_0000, 0x2000_0000, 0x4000_0000, 0x8000_0000] {
|
|
row.raw_condition[0] = condition_flag;
|
|
assert!(!ambient_effect_row_unconditionally_active(&row));
|
|
}
|
|
}
|
|
}
|