diff options
| -rw-r--r-- | README.md | 5 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/shaders/triangle.frag | 15 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/shaders/triangle.frag.spv | bin | 14512 -> 14716 bytes | |||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/ffi/reflection.rs | 5 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/ffi/smoke.rs | 57 | ||||
| -rw-r--r-- | apps/fparkan-game/src/main.rs | 42 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/lib.rs | 16 | ||||
| -rw-r--r-- | crates/fparkan-render/src/water.rs | 137 | ||||
| -rw-r--r-- | docs/appendices/knowledge-boundaries.md | 5 | ||||
| -rw-r--r-- | docs/reference/environment-effects.md | 9 | ||||
| -rw-r--r-- | docs/reference/render-frame.md | 6 | ||||
| -rw-r--r-- | docs/tomes/05-render.md | 48 | ||||
| -rw-r--r-- | tools/native-frame-capture/README.md | 6 | ||||
| -rw-r--r-- | tools/native-frame-capture/compare-frames.ps1 | 26 | ||||
| -rw-r--r-- | tools/native-frame-capture/convert-viewer-readback.ps1 | 14 |
15 files changed, 336 insertions, 55 deletions
@@ -12,8 +12,9 @@ Рельеф переключает исходные LOD0/LOD1 по ячейкам относительно текущей камеры; под основаниями строений сохраняется подробная сетка. Горизонтальная вода использует плоское отражение ближайшей активной высоты; -волны используют карту Du/Dv и часы оригинала, а shifted-камера отражения -пока заменена обычным зеркалом. +волны используют карту Du/Dv и часы оригинала. Камера отражения смотрит вверх +из отражённой точки наблюдения через прямоугольник водоёма; глубина и часть +параметров отражённого вида пока заимствуются из основного прохода. ## Запуск diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.frag b/adapters/fparkan-render-vulkan/shaders/triangle.frag index 5d10152..2c71278 100644 --- a/adapters/fparkan-render-vulkan/shaders/triangle.frag +++ b/adapters/fparkan-render-vulkan/shaders/triangle.frag @@ -82,12 +82,15 @@ void main() { vec2 bump_uv = plane_uv * frame.embm_wave.y + frame.embm_wave.x; vec2 duv = max(vec2(-1.0), (texture(embm_bump, bump_uv).rg * 255.0 - 128.0) / 127.0); vec2 displacement = duv * frame.embm_wave.zw; - // shortcut: convert native map-UV displacement to the current mirror projection; - // replace this projection when accepting the original shifted reflection camera. - vec3 displaced_world = in_world_position - vec3(displacement.yx * span, 0.0); - vec4 displaced_clip = frame.clip_from_world * vec4(displaced_world, 1.0); - if (displaced_clip.w > 0.000001) { - reflection_uv = displaced_clip.xy / displaced_clip.w * 0.5 + 0.5; + if ((mode & 128) != 0) { + reflection_uv = plane_uv + displacement; + } else { + // shortcut: non-perspective API cameras retain the projective mirror fallback. + vec3 displaced_world = in_world_position - vec3(displacement.yx * span, 0.0); + vec4 displaced_clip = frame.clip_from_world * vec4(displaced_world, 1.0); + if (displaced_clip.w > 0.000001) { + reflection_uv = displaced_clip.xy / displaced_clip.w * 0.5 + 0.5; + } } vec3 rgb = texture(reflection_color, reflection_uv).rgb * in_base_diffuse + in_base_specular; out_color = vec4(mix(rgb * in_color, frame.fog_color.rgb, in_fog_factor), material.diffuse_alpha); diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv b/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv Binary files differindex de3ace6..6deef9d 100644 --- a/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv +++ b/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv diff --git a/adapters/fparkan-render-vulkan/src/ffi/reflection.rs b/adapters/fparkan-render-vulkan/src/ffi/reflection.rs index 62a0d1e..1a9c9ae 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/reflection.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/reflection.rs @@ -221,6 +221,9 @@ impl ReflectionResources { depth_request: fparkan_platform::DepthStencilSupport, render_pass: vk::RenderPass, ) -> Result<(), VulkanSmokeRendererError> { + let side = fparkan_render::water::legacy_embm_reflection_size([extent.0, extent.1]); + let extent = (side, side); + eprintln!("render_reflection target_extent={side}x{side}"); let info = vk::ImageCreateInfo::default() .image_type(vk::ImageType::TYPE_2D) .format(vk::Format::from_raw(format)) @@ -236,7 +239,7 @@ impl ReflectionResources { .usage(vk::ImageUsageFlags::COLOR_ATTACHMENT | vk::ImageUsageFlags::SAMPLED) .sharing_mode(vk::SharingMode::EXCLUSIVE) .initial_layout(vk::ImageLayout::UNDEFINED); - // SAFETY: This target uses the validated non-zero swapchain extent and format. + // SAFETY: The validated swapchain format is retained; the square side is in 2..=1024. let image = unsafe { self.device.create_image(&info, None) }.map_err(|result| { VulkanSmokeRendererError::VulkanOperation { context: "vkCreateImage(reflection)", diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs index bd745ad..a016645 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs @@ -373,7 +373,7 @@ impl VulkanSmokeRenderer { plane_heights.sort_unstable(); plane_heights.dedup(); if !plane_heights.is_empty() { - eprintln!("render_coverage planar_reflection_shifted_camera_unsupported={} (native bump map and clock; using a projective mirror until shifted-camera mapping is implemented)", plane_heights.len()); + eprintln!("render_coverage planar_reflection_render_context_approx={} (native shifted window, target size and bump UV; main-view depth range and sky/fog/FX remain approximations; non-perspective cameras use mirror fallback)", plane_heights.len()); eprintln!("render_coverage planar_reflection_other_planes_unsupported={} (nearest active plane is reflected)", plane_heights.len().saturating_sub(1)); } if unsupported_nonplanar != 0 { @@ -1564,11 +1564,18 @@ impl VulkanSmokeRenderer { .active_reflection_plane() .map(|height| { let mut frame = self.frame_uniforms; - frame.clip_from_world = + frame.clip_from_world = fparkan_render::water::legacy_embm_reflection_clip( + frame.clip_from_world, + frame.camera_position, + height, + frame.embm_bounds, + ) + .or_else(|| { fparkan_render::reflect_clip_from_world_about_z(frame.clip_from_world, height) - .ok_or(VulkanSmokeRendererError::InvalidStaticCamera { - context: "non-finite reflected clip transform", - })?; + }) + .ok_or(VulkanSmokeRendererError::InvalidStaticCamera { + context: "non-finite reflected clip transform", + })?; frame.camera_position[2] = 2.0 * height - frame.camera_position[2]; if !frame.is_finite() { return Err(VulkanSmokeRendererError::InvalidStaticCamera { @@ -1819,6 +1826,15 @@ impl VulkanSmokeRenderer { self.draw_order_dirty = false; } let reflection_plane = self.active_reflection_plane(); + let shifted_reflection = reflection_plane.is_some_and(|height| { + fparkan_render::water::legacy_embm_reflection_clip( + self.frame_uniforms.clip_from_world, + self.frame_uniforms.camera_position, + height, + self.frame_uniforms.embm_bounds, + ) + .is_some() + }); let device = self.device_ref()?; let swapchain = self.swapchain_ref()?; let resources = self.resources_ref()?; @@ -1858,17 +1874,27 @@ impl VulkanSmokeRenderer { }, }, ]; - let render_area = vk::Rect2D { - offset: vk::Offset2D { x: 0, y: 0 }, - extent: vk::Extent2D { - width: swapchain.report.plan.extent.0, - height: swapchain.report.plan.extent.1, - }, - }; for reflected in [true, false] { if reflected && reflection_plane.is_none() { continue; } + let extent = if reflected { + let target = resources.reflection.color.as_ref().ok_or( + VulkanSmokeRendererError::InvariantViolation { + context: "active reflection without a color target", + }, + )?; + (target.width, target.height) + } else { + swapchain.report.plan.extent + }; + let render_area = vk::Rect2D { + offset: vk::Offset2D { x: 0, y: 0 }, + extent: vk::Extent2D { + width: extent.0, + height: extent.1, + }, + }; let framebuffer = if reflected { resources.reflection.framebuffer } else { @@ -2053,14 +2079,11 @@ impl VulkanSmokeRenderer { && reflection_plane.is_some() && self.reflection_planes[range_index] == reflection_plane { - 64 + 64 | if shifted_reflection { 128 } else { 0 } } else { 0 }) as f32; - let viewport = super::swapchain_resources::draw_viewport( - swapchain.report.plan.extent, - material_mode, - ); + let viewport = super::swapchain_resources::draw_viewport(extent, material_mode); let next_viewport_depth = [viewport.min_depth, viewport.max_depth]; if viewport_depth != Some(next_viewport_depth) { device diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index b57d690..860922b 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -5498,6 +5498,8 @@ fn append_initial_control_effect_slots( let Some(point_list) = control.point_list.as_ref() else { return Ok(()); }; + let mut unsupported_commands = 0; + let mut state_dependent_commands = 0; for row in selected_initial_control_effect_rows(control.initial_effect_group, &control.effect_section) { @@ -5511,10 +5513,22 @@ fn append_initial_control_effect_slots( ) })?; let key = format!("{archive}/{name}"); + let mut record_skip = |reason: &str, + count: usize, + needs_state: bool, + command: Option<usize>| { + if needs_state { + state_dependent_commands += count; + } else { + unsupported_commands += count; + } + eprintln!("unexpanded Control FX reason:{reason} count:{count} needs_game_state:{needs_state} object:{object_index} component:{component_index} source:{key} table:{} row:{} command:{command:?}", row.table_index, row.row_index); + }; 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 { + eprintln!("render_coverage control_fx_invalid_documents=1 (object:{object_index} component:{component_index} source:{key})"); continue; }; effect_documents.insert(key.clone(), document); @@ -5531,17 +5545,24 @@ fn append_initial_control_effect_slots( 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; + record_skip("header_values", document.commands().len(), false, None); + continue; + } + if !matches!(header.time_mode, 0 | 1 | 2) + || (header.time_mode != 0 && duration_seconds <= 0.0) + { + *unsupported_sounds += sound_commands; + record_skip( + "time_mode", + document.commands().len(), + fparkan_fx::fx_time_mode_requires_owner_state(header.time_mode), + None, + ); continue; } let owner_node_index = @@ -5554,6 +5575,7 @@ fn append_initial_control_effect_slots( }); let Some(owner_node_index) = owner_node_index else { *unsupported_sounds += sound_commands; + record_skip("owner_node", document.commands().len(), false, None); continue; }; let owner_node_pose = control_effect_owner_node_pose( @@ -5563,6 +5585,7 @@ fn append_initial_control_effect_slots( ); let Some(owner_node_pose) = owner_node_pose else { *unsupported_sounds += sound_commands; + record_skip("owner_pose", document.commands().len(), false, None); continue; }; let root_transform = LegacyIron3dEulerTransform { @@ -5629,6 +5652,7 @@ fn append_initial_control_effect_slots( }); } else { *unsupported_sounds += 1; + record_skip("sound_body", 1, false, Some(command_index)); eprintln!("unsupported Control Op2 object={object_index} component={component_index} effect={key} command={command_index} (mode, backend parameter or malformed body)"); } } else { @@ -5637,6 +5661,7 @@ fn append_initial_control_effect_slots( continue; } let Some(kind) = ambient_effect_command_kind(command) else { + record_skip("opcode_or_body", 1, false, Some(command_index)); continue; }; if !ambient_effect_render_selector_allows_header( @@ -5646,11 +5671,13 @@ fn append_initial_control_effect_slots( continue; } let Some(reference) = command.resource_refs.first() else { + record_skip("material_reference", 1, false, Some(command_index)); 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() { + record_skip("empty_material_reference", 1, false, Some(command_index)); continue; } let cache_key = format!("{material_archive}/{material_name}"); @@ -5671,6 +5698,7 @@ fn append_initial_control_effect_slots( &prepared.phase_directional_rgb, &prepared.phase_additive_rgb, ) { + record_skip("material_tint", 1, false, Some(command_index)); continue; } let first_material = *prepared @@ -5688,6 +5716,7 @@ fn append_initial_control_effect_slots( [0.0; 3], AMBIENT_EFFECT_NATIVE_CATEGORY, ) else { + record_skip("mesh_template", 1, false, Some(command_index)); continue; }; Some(template) @@ -5822,6 +5851,7 @@ fn append_initial_control_effect_slots( }); } } + eprintln!("render_coverage control_fx_unsupported={unsupported_commands} control_fx_requires_game_state={state_dependent_commands}"); Ok(()) } diff --git a/crates/fparkan-fx/src/lib.rs b/crates/fparkan-fx/src/lib.rs index af7408a..8a567de 100644 --- a/crates/fparkan-fx/src/lib.rs +++ b/crates/fparkan-fx/src/lib.rs @@ -67,6 +67,12 @@ pub struct FxHeader { pub scale: [f32; 3], } +/// Effect.dll RVA 0x5C60 reads an owner/provider for modes 4 through 17, including mode 14. +#[must_use] +pub const fn fx_time_mode_requires_owner_state(mode: u32) -> bool { + matches!(mode, 4..=17) +} + /// FX opcode. #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] pub enum FxOpcode { @@ -624,6 +630,16 @@ mod tests { use std::path::{Path, PathBuf}; #[test] + fn phase_sources_distinguish_elapsed_damage_and_progress_modes() { + for mode in [0, 1, 2, 3, 18, u32::MAX] { + assert!(!fx_time_mode_requires_owner_state(mode)); + } + for mode in [4, 5, 9, 13, 14, 15, 16, 17] { + assert!(fx_time_mode_requires_owner_state(mode)); + } + } + + #[test] fn decodes_synthetic_opcodes_and_refs() { let mut bytes = header(2); bytes.extend_from_slice(&command_with_ref_at( diff --git a/crates/fparkan-render/src/water.rs b/crates/fparkan-render/src/water.rs index 0c7ad2a..58a2817 100644 --- a/crates/fparkan-render/src/water.rs +++ b/crates/fparkan-render/src/water.rs @@ -7,6 +7,17 @@ pub const EMBM_BUMP_TILE: f32 = 100.0; /// Native diagonal bump matrix, without luminance modulation. pub const EMBM_COEFFICIENTS: [f32; 2] = [0.01, 0.01]; +/// Square reflection size chosen by Terrain.dll RVA 0x27FC7 from renderer dimensions. +#[must_use] +pub fn legacy_embm_reflection_size(extent: [u32; 2]) -> u32 { + let limit = extent[0].min(extent[1]).min(1024); + let mut side = 2; + while side * 2 <= limit { + side *= 2; + } + side +} + /// Generates the signed bytes written by Terrain.dll RVA 0x2BF50 with CW 0x007F. #[must_use] pub fn legacy_embm_bump_map() -> Vec<[i8; 2]> { @@ -51,11 +62,137 @@ pub fn legacy_embm_uv(world_xy: [f32; 2], bounds: [f32; 4]) -> Option<[f32; 2]> ]) } +/// The EMBM camera looks up from the mirrored eye, with its window on the water rectangle. +/// Terrain.dll+4EFF0 supplies `[min_y-eye_y, min_x-eye_x, span_y, span_x, eye_z-height]`. +/// The ordinary perspective's depth mapping is retained; orthographic inputs use the caller's fallback. +#[must_use] +pub fn legacy_embm_reflection_clip( + main_clip: [f32; 16], + eye: [f32; 3], + height: f32, + bounds: [f32; 4], +) -> Option<[f32; 16]> { + let span = [bounds[2] - bounds[0], bounds[3] - bounds[1]]; + let distance = eye[2] - height; + if !main_clip + .into_iter() + .chain(eye) + .chain([height]) + .chain(bounds) + .chain(span) + .all(f32::is_finite) + || distance <= 0.0 + || span.into_iter().any(|v| v <= 0.0) + { + return None; + } + let w = [main_clip[3], main_clip[7], main_clip[11]]; + let squared = w.into_iter().map(|v| v * v).sum::<f32>(); + if !squared.is_finite() || squared <= f32::EPSILON { + return None; + } + let depth_a = (main_clip[2] * w[0] + main_clip[6] * w[1] + main_clip[10] * w[2]) / squared; + // shortcut: retain the main depth range until the native shifted depth matrix is verified. + let depth_b = (main_clip[14] - depth_a * main_clip[15]) / squared.sqrt(); + if !depth_a.is_finite() || !depth_b.is_finite() || depth_a < 1.0 || depth_b >= 0.0 { + return None; + } + let z = 2.0 * height - eye[2]; + let x_scale = -2.0 * distance / span[1]; + let y_scale = -2.0 * distance / span[0]; + let x_shift = 1.0 + 2.0 * (bounds[1] - eye[1]) / span[1]; + let y_shift = 1.0 + 2.0 * (bounds[0] - eye[0]) / span[0]; + let matrix = [ + 0.0, + y_scale, + 0.0, + 0.0, + x_scale, + 0.0, + 0.0, + 0.0, + x_shift, + y_shift, + depth_a, + 1.0, + -x_scale * eye[1] - x_shift * z, + -y_scale * eye[0] - y_shift * z, + depth_b - depth_a * z, + -z, + ]; + matrix.into_iter().all(f32::is_finite).then_some(matrix) +} + #[cfg(test)] mod tests { use super::*; #[test] + fn reflection_size_matches_native_renderer_dimension_vectors() { + for (extent, expected) in [ + ([1280, 1024], 1024), + ([1280, 720], 512), + ([800, 600], 512), + ([640, 480], 256), + ([1920, 1080], 1024), + ([2048, 2048], 1024), + ([4, 7], 4), + ([3, 300], 2), + ([1, 1], 2), + ([700, 700], 512), + ] { + assert_eq!(legacy_embm_reflection_size(extent), expected, "{extent:?}"); + } + } + + #[test] + fn shifted_camera_maps_native_water_corners_and_retains_perspective_depth() { + let projection = crate::LegacyD3d7Projection { + viewport: [0, 0, 1280, 1024], + near_plane: 0.5, + far_plane: 700.0, + field_of_view_radians: 1.3, + } + .try_direct3d7_projection_row_major() + .unwrap(); + let eye = [1200.0, 800.0, 185.0]; + let mut main = [0.0; 16]; + main[2] = projection[10]; + main[3] = projection[11]; + main[14] = -eye[0] * projection[10] + projection[14]; + main[15] = -eye[0] * projection[11]; + let bounds = [1000.0, 500.0, 1500.0, 1200.0]; + let matrix = legacy_embm_reflection_clip(main, eye, 150.0, bounds).unwrap(); + let project = |point: [f32; 3]| -> [f32; 4] { + std::array::from_fn(|column| { + point[0] * matrix[column] + + point[1] * matrix[4 + column] + + point[2] * matrix[8 + column] + + matrix[12 + column] + }) + }; + for point in [ + [1000.0, 500.0, 150.0], + [1500.0, 1200.0, 150.0], + [1125.0, 1025.0, 150.0], + ] { + let clip = project(point); + let expected = legacy_embm_uv([point[0], point[1]], bounds).unwrap(); + for axis in 0..2 { + assert!((clip[axis] / clip[3] * 0.5 + 0.5 - expected[axis]).abs() < 0.00001); + } + assert_eq!(clip[3], 35.0); + } + let near = project([1200.0, 800.0, 115.5]); + assert!((near[2] / near[3]).abs() < 0.001); + let far = project([1200.0, 800.0, 815.0]); + assert!((far[2] / far[3] - 1.0).abs() < 0.00001); + assert!(legacy_embm_reflection_clip([0.0; 16], eye, 150.0, bounds).is_none()); + assert!(legacy_embm_reflection_clip(main, eye, 185.0, bounds).is_none()); + assert!(legacy_embm_reflection_clip(main, eye, 150.0, [1.0; 4]).is_none()); + } + + #[test] fn wave_bytes_match_native_generation_and_clock_wraps_without_float_time_loss() { let texels = legacy_embm_bump_map(); assert_eq!(texels.len(), 128 * 128); diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index d632bf3..a60fa68 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -76,7 +76,7 @@ MSH-запрос со свободной preview-камерой; это не пÐ среде, перехватить DirectDraw/Direct3D calls и surface flips, сохранить state log на минимальных сценах с одним типом материала. -Горизонтальные грани Land.msh с флагом `0x20000` используют Vulkan-отражение и исходную карту волн Du/Dv; перенос её смещений в обычное зеркало приближённый: `planar_reflection_shifted_camera_unsupported`. +Горизонтальные грани Land.msh с флагом `0x20000` используют исходную карту Du/Dv, окно shifted-камеры и квадратный размер текстуры, проверенные исполнением DLL. Диапазон глубины, небо/туман/FX основного вида и соответствие размеров при обрезанном capture остаются приближениями: `planar_reflection_render_context_approx`; неперспективная камера API использует обычное зеркало. Raw queue clock задаёт фазу волн отдельно от времени суток и sky elapsed; старые захваты без него считаются в `captured_water_phase_unverified`. Один target отражает ближайшую активную высоту воды; остальные высоты и неплоские грани считаются в `planar_reflection_other_planes_unsupported` и `planar_reflection_nonplanar_unsupported` и сохраняют обычный terrain shader. `terrain_face8_pipeline_unverified` теперь считает только исходные грани с `0x8` без `0x20000`; сама семантика оставшихся специальных проходов ещё не установлена. @@ -90,6 +90,9 @@ Control Op1 пока использует цвет и радиус без слу Control Op2 воспроизводит modes 0/2; modes 1/3, body+76 и неподдержанные time modes/привязки считаются как `render_coverage control_op2_unsupported`. Управление фазой экземпляра командами Control остаётся до его подключения в runtime. +При расширении выбранных начальных строк все пропущенные команды считаются +в `control_fx_unsupported`, а time modes 4–17 — в `control_fx_requires_game_state`; +причина и владелец выводятся в лог. Неразобранные FXID считаются отдельно в `control_fx_invalid_documents`. Размеры команд, resource references, lifecycle, flags families и используемые time modes известны. Не закрыто значение каждого поля body opcodes 1--10, diff --git a/docs/reference/environment-effects.md b/docs/reference/environment-effects.md index f3ecfb4..677c159 100644 --- a/docs/reference/environment-effects.md +++ b/docs/reference/environment-effects.md @@ -420,6 +420,15 @@ FX-команды используют общий preview clock и ограниÑ mode 0 оставляет фазу равной нулю, mode 1 вычисляет `elapsed / duration` без зажатия, mode 2 повторяет цикл. Header flags `0x200` и `0x20` преобразуют эту фазу; command path `0x8120` зажимает результат только после преобразований. +В [эталонной Effect.dll](original-binaries.md) RVA `0x5C60` режимы 4–17 +читают состояние владельца или провайдера; режим 14 тоже входит в эту группу. +Пропуски при расширении выбранных начальных Control-строк учитываются по +командам: `control_fx_requires_game_state` для этих режимов и +`control_fx_unsupported` для остальных непреобразованных команд. Диагностика +сохраняет причину, ресурс, объект, компонент, строку и номер команды. +`control_fx_invalid_documents` отдельно считает неразобранные FXID, где число +команд неизвестно. Счётчики суммируются по компонентам миссии; они не заменяют +учёт ещё не выбранных Control-групп и условных строк в фазе G. Effect renderer проверяет header bit `0x800` только при render selector 0; native AniMesh component path был захвачен с selector 1, поэтому preview использует selector 1 и не подавляет такие visual commands. diff --git a/docs/reference/render-frame.md b/docs/reference/render-frame.md index df3f248..80796a3 100644 --- a/docs/reference/render-frame.md +++ b/docs/reference/render-frame.md @@ -348,3 +348,9 @@ D3D7 в `fparkan-render`, а затем один раз меняет знак в каналов не определяет. Последовательность не содержит структурированных ID видимых объектов, списка элементов отрисовки, ключей конвейера, матриц или хэшей промежуточных буферов. + +При преобразовании кадра в PNG сохраняется исходный RGB, а прозрачность +выключается: окно показывает готовое непрозрачное изображение. Альфа-канал +GPU может содержать результат смешивания поверхностей; повторное применение +его к готовому цвету затемнило бы кадр ещё раз. Поэтому конвертер и сборщик +сравнительных пар игнорируют эту альфу, а raw-файл сохраняют без изменений. diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index a7a1396..4aba919 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -1147,13 +1147,39 @@ lightmap state передаются в `VulkanStaticMaterial`, а shader посÐ В [эталонной сборке](../reference/original-binaries.md) выбор грани находится в Terrain.dll RVA `0x28A50`, а подготовка EMBM-пакета — в RVA `0x1B540`. -Первый Vulkan-проход строит плоское зеркало. Для горизонтальной воды на -высоте `h` отражённая координата точки равна `z′ = 2h − z`. Это преобразование -применяется перед обычной проекцией. Оно меняет порядок вершин треугольника, -поэтому проход меняет сторону отсечения и отбрасывает геометрию ниже воды. +Для горизонтальной воды на высоте `h` камера отражения находится в точке +`[eye_x, eye_y, 2h − eye_z]`. Но она не повторяет наклон основной камеры: +смотрит вертикально вверх, а её окно лежит на прямоугольнике водоёма. Поэтому +текстура отражения привязана к местности, а не к экрану игрока. В плоскости +воды координаты изображения равны `U = 1 − Ynorm`, `V = 1 − Xnorm`. + +Terrain.dll RVA `0x4EFF0` передаёт shifted-проекции пять значений: +`[min_y − eye_y, min_x − eye_x, span_y, span_x, eye_z − h]`. Например, +для наблюдателя `[1200, 800, 185]`, воды `150` и границ +`[1000, 500, 1500, 1200]` это `[-300, -200, 700, 500, 35]`. +Матрица камеры и эти аргументы проверены исполнением оригинальной функции +при двух положениях и разных поворотах основной камеры. Оракул подменяет +ввод-вывод очереди, ландшафта и камеры; сам renderer и матрица глубины в эту +проверку не входят. Vulkan-проекция сохраняет near/far основного вида; +Rust-тест отдельно проверяет углы водоёма и границы глубины. + +Отражённый вид меняет сторону отсечения и отбрасывает геометрию ниже воды. Небо и оставшаяся сцена попадают в отдельную цветовую текстуру с собственной -глубиной. В основном проходе водные треугольники читают её в экранной -координате: точки самой плоскости не сдвигаются при отражении. +глубиной. В основном проходе водные треугольники читают её по координатам +водоёма с добавленным смещением волн. Для неперспективной камеры API остаётся +обычное зеркало с экранными координатами. + +Размер изображения отражения выбирается отдельно от размера основного кадра. +Terrain.dll в конструкторе CShade (RVA `0x278E0`, цикл с `0x27FC7`) берёт +ширину и высоту renderer через Ngi32.dll RVA `0x7FB0`. Сторона квадратной +текстуры — наибольшая степень двойки, не превосходящая меньшую из этих +величин и 1024; нижний предел равен 2. Например, экран 1280×1024 даёт +отражение 1024×1024, а 1280×720 — 512×512. Getter размеров и исходный цикл +выбора проверены исполнением DLL на десяти входах; создание нативной текстуры +в этом оракуле пропущено. Vulkan создаёт цвет и глубину с этой стороной и +задаёт каждому проходу собственные viewport и область очистки. Так уменьшение +отражения не обрезает основной кадр и воспроизводит исходный масштаб его +пикселей, от которого зависит чёткость берегов и ряби. Цветовой target переходит из attachment layout в shader-read layout после записи. Следующий зеркальный проход ждёт предыдущего чтения этой текстуры; @@ -1182,10 +1208,12 @@ lightmap state передаются в `VulkanStaticMaterial`, а shader посÐ В Vulkan карта хранится в двух каналах несжатой текстуры и возвращается к знаковым значениям перед вычислением смещения. -Обычное зеркало ещё не повторяет shifted-камеру оригинала: смещения Du/Dv -сейчас переводятся из координат водоёма в текущую зеркальную проекцию. -Это приближение учитывается в `planar_reflection_shifted_camera_unsupported`; -совпадение генератора волн не доказывает совпадения итогового отражения. +Матрица положения камеры, её окно, размер текстуры и координаты волн восстановлены, но весь +контекст отражённого вида ещё не повторён. Диапазон глубины, небо, туман и FX +заимствуются из основного вида. Для захвата с обрезанным viewport ещё не +подтверждено соответствие размера окна FParkan полному renderer оригинала. +Эти ограничения учитываются в `planar_reflection_render_context_approx`; +совпадение генератора волн и окна не доказывает совпадения итогового отражения. Один target обслуживает ближайшую активную высоту воды; другие высоты и негоризонтальные поверхности явно считаются как неподдержанные. Это промежуточный водный проход, а не полная diff --git a/tools/native-frame-capture/README.md b/tools/native-frame-capture/README.md index d8f621c..1f7166b 100644 --- a/tools/native-frame-capture/README.md +++ b/tools/native-frame-capture/README.md @@ -117,6 +117,12 @@ the verified value for the run above, not a universal surface format. The converter uses the installed `System.Drawing` PNG encoder and adds no package dependency. +PNG exports treat the captured frame as opaque and preserve its stored RGB. +Framebuffer alpha is a rendering result, not image transparency; applying it +again when displaying the PNG would darken surfaces a second time. Raw files +retain all four original channels. The comparison script also ignores alpha +in older PNG exports, without changing the measured RGB values. + ## Сравнение областей кадра `compare-frames.ps1` собирает пару «оригинал / FParkan» без масштабирования и diff --git a/tools/native-frame-capture/compare-frames.ps1 b/tools/native-frame-capture/compare-frames.ps1 index 2a83bf0..b8d3855 100644 --- a/tools/native-frame-capture/compare-frames.ps1 +++ b/tools/native-frame-capture/compare-frames.ps1 @@ -41,7 +41,21 @@ if ($SelfTest) { try { Get-MeanRgb $test ([System.Drawing.Rectangle]::new(1, 0, 2, 1)) | Out-Null } catch { $rejected = $true } if (-not $rejected) { throw 'Out-of-bounds region was accepted.' } - Write-Output 'self-test: exact region RGB means and bounds rejection OK' + $test.SetPixel(0, 0, [System.Drawing.Color]::FromArgb(0, 31, 16, 24)) + $test.SetPixel(1, 0, [System.Drawing.Color]::FromArgb(64, 31, 16, 24)) + $opaque = $test.Clone([System.Drawing.Rectangle]::new(0, 0, 2, 1), [System.Drawing.Imaging.PixelFormat]::Format32bppRgb) + $pair = [System.Drawing.Bitmap]::new(2, 1) + $graphics = [System.Drawing.Graphics]::FromImage($pair) + try { + $graphics.Clear([System.Drawing.Color]::Black) + $graphics.DrawImageUnscaled($opaque, 0, 0) + foreach ($x in @(0, 1)) { + if ($pair.GetPixel($x, 0).ToArgb() -ne [System.Drawing.Color]::FromArgb(31, 16, 24).ToArgb()) { + throw 'Comparison applied framebuffer alpha to stored RGB.' + } + } + } finally { $graphics.Dispose(); $pair.Dispose(); $opaque.Dispose() } + Write-Output 'self-test: exact region RGB means, opaque presentation and bounds rejection OK' } finally { $test.Dispose() } return } @@ -63,19 +77,21 @@ foreach ($value in $viewport) { } $crop = [System.Drawing.Rectangle]::new($viewport[0], $viewport[1], $viewport[2] - $viewport[0], $viewport[3] - $viewport[1]) $native = [System.Drawing.Bitmap]::new((Resolve-Path -LiteralPath $NativePng).Path) -$viewer = $nativeCrop = $pair = $graphics = $null +$viewer = $viewerOpaque = $nativeCrop = $pair = $graphics = $null try { $viewer = [System.Drawing.Bitmap]::new((Resolve-Path -LiteralPath $ViewerPng).Path) if ($crop.Width -le 0 -or $crop.Height -le 0 -or $crop.Right -gt $native.Width -or $crop.Bottom -gt $native.Height -or $viewer.Width -ne $crop.Width -or $viewer.Height -ne $crop.Height) { throw 'Native crop and viewer dimensions must match without scaling.' } - $nativeCrop = $native.Clone($crop, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb) + # Captured frames are opaque presentations; framebuffer alpha is not PNG opacity. + $nativeCrop = $native.Clone($crop, [System.Drawing.Imaging.PixelFormat]::Format32bppRgb) + $viewerOpaque = $viewer.Clone([System.Drawing.Rectangle]::new(0, 0, $viewer.Width, $viewer.Height), [System.Drawing.Imaging.PixelFormat]::Format32bppRgb) $pair = [System.Drawing.Bitmap]::new($viewer.Width * 2, $viewer.Height + 24) $graphics = [System.Drawing.Graphics]::FromImage($pair) $graphics.Clear([System.Drawing.Color]::Black) $graphics.DrawImageUnscaled($nativeCrop, 0, 24) - $graphics.DrawImageUnscaled($viewer, $viewer.Width, 24) + $graphics.DrawImageUnscaled($viewerOpaque, $viewer.Width, 24) $font = [System.Drawing.SystemFonts]::DefaultFont $graphics.DrawString('Original', $font, [System.Drawing.Brushes]::White, 4, 4) $graphics.DrawString('FParkan', $font, [System.Drawing.Brushes]::White, $viewer.Width + 4, 4) @@ -100,7 +116,7 @@ try { ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $jsonPath -Encoding UTF8 Write-Output "Wrote $pngPath and $jsonPath" } finally { - foreach ($resource in @($graphics, $pair, $nativeCrop, $viewer, $native)) { + foreach ($resource in @($graphics, $pair, $nativeCrop, $viewerOpaque, $viewer, $native)) { if ($null -ne $resource) { $resource.Dispose() } } } diff --git a/tools/native-frame-capture/convert-viewer-readback.ps1 b/tools/native-frame-capture/convert-viewer-readback.ps1 index 3bafe14..17c136a 100644 --- a/tools/native-frame-capture/convert-viewer-readback.ps1 +++ b/tools/native-frame-capture/convert-viewer-readback.ps1 @@ -118,7 +118,7 @@ function Save-BgraPngToStream { $bitmap = New-Object System.Drawing.Bitmap( $Width, $Height, - [System.Drawing.Imaging.PixelFormat]::Format32bppArgb + [System.Drawing.Imaging.PixelFormat]::Format32bppRgb ) $rectangle = New-Object System.Drawing.Rectangle(0, 0, $Width, $Height) $bitmapData = $null @@ -126,7 +126,7 @@ function Save-BgraPngToStream { $bitmapData = $bitmap.LockBits( $rectangle, [System.Drawing.Imaging.ImageLockMode]::WriteOnly, - [System.Drawing.Imaging.PixelFormat]::Format32bppArgb + [System.Drawing.Imaging.PixelFormat]::Format32bppRgb ) $rowBytes = $Width * 4 @@ -201,12 +201,12 @@ function Get-CameraExtent { function Invoke-SelfTest { $rgba = [byte[]]@( - 255, 0, 0, 255, 0, 255, 0, 255, - 0, 0, 255, 255, 255, 255, 255, 255 + 255, 0, 0, 0, 0, 255, 0, 64, + 0, 0, 255, 128, 255, 255, 255, 255 ) $expectedBgra = [byte[]]@( - 0, 0, 255, 255, 0, 255, 0, 255, - 255, 0, 0, 255, 255, 255, 255, 255 + 0, 0, 255, 0, 0, 255, 0, 64, + 255, 0, 0, 128, 255, 255, 255, 255 ) $rgbaResult = Convert-ReadbackToBgra -Source $rgba -Format 37 -Width 2 -Height 2 @@ -303,7 +303,7 @@ function Invoke-SelfTest { $stream.Dispose() } - 'Self-check passed (2x2 RGBA/BGRA PNG, byte-length/64 MiB limits, viewport-coordinate bounds, and cropped pixel_capture bounds).' + 'Self-check passed (opaque 2x2 RGBA/BGRA PNG, byte-length/64 MiB limits, viewport-coordinate bounds, and cropped pixel_capture bounds).' } if ($SelfTest) { |
