diff options
| author | Valentin Popov <valentin@popov.link> | 2026-10-31 17:01:35 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-10-31 17:01:35 +0300 |
| commit | c31e5b734252c9191afe7809f7ef32f85f68aa0f (patch) | |
| tree | f9426c0fbd0babadb911d4dcad50d4a70434d89b | |
| parent | 09fcd350234656f726988cd6ee46840ed99b618d (diff) | |
| download | fparkan-c31e5b734252c9191afe7809f7ef32f85f68aa0f.tar.xz fparkan-c31e5b734252c9191afe7809f7ef32f85f68aa0f.zip | |
fix(render): fade MSH portals with camera distance
| -rw-r--r-- | apps/fparkan-game/src/main.rs | 121 | ||||
| -rw-r--r-- | crates/fparkan-render/src/lib.rs | 81 | ||||
| -rw-r--r-- | docs/appendices/knowledge-boundaries.md | 27 | ||||
| -rw-r--r-- | docs/reference/render-frame.md | 30 | ||||
| -rw-r--r-- | docs/tomes/05-render.md | 25 | ||||
| -rw-r--r-- | tools/native-frame-capture/README.md | 38 | ||||
| -rw-r--r-- | tools/native-frame-capture/compare-frames.ps1 | 106 |
7 files changed, 383 insertions, 45 deletions
diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index 6384ab7..33ed782 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -47,8 +47,9 @@ 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::{ - LegacyBlendMode, LegacyCullMode, LegacyD3d7Projection, LegacyDepthBias, LegacyDepthMode, - LegacyIron3dEulerTransform, LegacyPipelineState, RawCameraTransform, + 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, @@ -1334,11 +1335,29 @@ fn native_world_transparent(range: &VulkanStaticDrawRange, diffuse_alpha: f32) - diffuse_alpha < 1.0 || (range.batch_flags & 0x108) != 0 } -fn native_msh_diffuse_alpha_override(batch_flags: u16, type16_present: bool) -> Option<f32> { - if type16_present || batch_flags & 0x0008 == 0 { - return None; +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); } - (batch_flags & 0x0040 != 0 && batch_flags & 0x0010 == 0).then_some(0.0) + 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 { @@ -9408,7 +9427,19 @@ impl StaticVulkanApp { 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)"); for &range_index in &self.world_range_indices { let range = self .mesh @@ -9420,9 +9451,12 @@ impl StaticVulkanApp { .get(usize::from(range.material_index)) .map_or(1.0, |material| material.diffuse_alpha); let alpha_override = native_msh_diffuse_alpha_override( - range.batch_flags, + &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) @@ -9513,6 +9547,7 @@ impl StaticVulkanApp { 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> { @@ -9531,9 +9566,12 @@ impl StaticVulkanApp { |state| state.diffuse_alpha, ); let alpha_override = native_msh_diffuse_alpha_override( - range.batch_flags, + 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) @@ -10120,6 +10158,7 @@ impl StaticVulkanApp { &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, )?; @@ -10517,7 +10556,10 @@ impl ApplicationHandler for StaticVulkanApp { event_loop.exit(); return; } - if let Err(error) = self.configure_world_draw_ranges(&mut renderer) { + if let Err(error) = self.configure_world_draw_ranges( + &mut renderer, + size.width.max(1) as f32 / size.height.max(1) as f32, + ) { self.error = Some(error); event_loop.exit(); return; @@ -11331,23 +11373,32 @@ mod tests { let (mesh, mut range) = facing_mesh(0x0148, &triangle); let original_count = range.index_count; let mut alpha_override = None; - let update = |range: &VulkanStaticDrawRange, alpha_override: &mut Option<f32>| { - *alpha_override = native_msh_diffuse_alpha_override(range.batch_flags, false); + 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), - [0.0; 3], + eye_world, None, ) }; - assert_eq!(update(&range, &mut alpha_override), 0); + assert_eq!(update(&range, [0.0; 3], &mut alpha_override), 0); range.batch_flags = 0x0108; - assert_eq!(update(&range, &mut alpha_override), original_count); + 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, &mut alpha_override), 0); + assert_eq!(update(&range, [150.0; 3], &mut alpha_override), 0); assert_eq!(original_count, 3); } @@ -13077,14 +13128,34 @@ mod tests { } #[test] - fn native_cpu_shaded_msh_alpha_override_uses_only_the_constant_batch_rule() { - assert_eq!(native_msh_diffuse_alpha_override(0x0148, false), Some(0.0)); - assert_eq!(native_msh_diffuse_alpha_override(0x0108, false), None); - assert_eq!(native_msh_diffuse_alpha_override(0x0118, false), None); - assert_eq!(native_msh_diffuse_alpha_override(0x0058, false), None); - assert_eq!(native_msh_diffuse_alpha_override(0x0148, true), None); - assert_eq!(native_msh_diffuse_alpha_override(0x0058, true), None); - assert_eq!(native_msh_diffuse_alpha_override(0x0040, false), None); + 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] diff --git a/crates/fparkan-render/src/lib.rs b/crates/fparkan-render/src/lib.rs index c4873a0..ad96204 100644 --- a/crates/fparkan-render/src/lib.rs +++ b/crates/fparkan-render/src/lib.rs @@ -361,6 +361,53 @@ pub enum LegacyCullMode { FrontFace, } +/// CPU diffuse alpha for a colourless MSH batch carrying flag `0x0008`. +/// +/// `packet_distance` is the distance to the packet's first accepted vertex, +/// after its object transform. Flag `0x0010` takes precedence over `0x0040`. +/// Returns `None` for invalid distance or projection inputs. Callers must keep +/// the material alpha for batches with a type-16 colour stream or without `0x0008`. +#[must_use] +pub fn legacy_msh_portal_alpha( + batch_flags: u16, + packet_distance: f32, + horizontal_fov: f32, +) -> Option<f32> { + if !packet_distance.is_finite() + || packet_distance < 0.0 + || !horizontal_fov.is_finite() + || horizontal_fov <= 0.0 + { + return None; + } + let (distance, near, far, slope) = if batch_flags & 0x0010 != 0 { + ( + packet_distance.sqrt(), + 2.6 / horizontal_fov, + 7.799_999_7 / horizontal_fov, + (1.0 / (7.799_999_7 - 2.6)) * horizontal_fov, + ) + } else if batch_flags & 0x0040 != 0 { + return Some(0.0); + } else { + let near = 75.0 * 1.3; + let far = 95.0 * 1.3; + ( + packet_distance, + horizontal_fov.recip() * near, + horizontal_fov.recip() * far, + (1.0 / (far - near)) * horizontal_fov, + ) + }; + Some(if distance < near { + 0.0 + } else if distance > far { + 1.0 + } else { + ((distance - near) * slope).clamp(0.0, 1.0) + }) +} + /// Raster depth adjustment applied to one pipeline variant. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum LegacyDepthBias { @@ -463,6 +510,40 @@ impl From<LegacyPipelineState> for PipelineKey { mod tests { use super::*; + #[test] + fn portal_alpha_matches_native_packet_distance_vectors() { + // GOG Terrain+1B0F0 with its real +246A0/+247B0 packet methods, + // Ngi32 RSQRTSS dispatch, x87 CW=007F, FOV=1.04. + for (distance, flags, expected) in [ + (0.499_938_96, 0x18, 0.0), + (4.999_542, 0x18, 0.0), + (9.999_084, 0x18, 0.132_426_6), + (25.000_572, 0x18, 0.500_011_44), + (59.999_084, 0x18, 1.0), + (85.012_48, 0x08, 0.0), + (109.994_125, 0x08, 0.649_765_2), + (25.000_572, 0x58, 0.500_011_44), + (110.0, 0x48, 0.0), + ] { + let actual = legacy_msh_portal_alpha(flags, distance, 1.04).unwrap(); + assert!( + (actual - expected).abs() < 0.000_002, + "{distance}: {actual}" + ); + } + assert_eq!(legacy_msh_portal_alpha(8, 60.0, 1.3), Some(0.0)); + assert!((legacy_msh_portal_alpha(8, 85.0, 1.3).unwrap() - 0.5).abs() < 0.000_001); + assert_eq!(legacy_msh_portal_alpha(8, 110.0, 1.3), Some(1.0)); + for (distance, fov) in [ + (f32::NAN, 1.3), + (-1.0, 1.3), + (1.0, 0.0), + (1.0, f32::INFINITY), + ] { + assert_eq!(legacy_msh_portal_alpha(8, distance, fov), None); + } + } + fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] { let mut result = [0.0; 16]; for row in 0..4 { diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index bb29727..f587743 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -45,26 +45,13 @@ MSH-запрос со свободной preview-камерой; это не пÐ стратегической и следящей камерами не подтверждено ([MSH](../reference/msh.md)). -Для MSH без потока цветов type 16 подтверждён только один постоянный alpha -override для диапазонов с CPU-цветом и raw-флагом `Batch20` `0x0008`: GOG -`Terrain.dll`, SHA-256 -`af87d1b2e728a0be73c52be3b44cc196ab46da7799f25a15d40f8c9b0b425ead`, RVA -`0x1B0F0`, задаёт alpha `0`, если установлен bit `0x0040` и сброшен `0x0010`. -Это покрывает `0x0148`; `0x0108`, `0x0118` и `0x0058` сохраняют alpha MAT0, -причём в `0x0058` bit `0x0010` имеет приоритет. Поток type 16 или отсутствие -`0x0008` исключают override. Проверка заводской сцены с фиксированной нативной -камерой подтвердила на одном кадре входа восстановление X-образного рисунка -двери, который виден и в оригинале; прежний Vulkan-readback показывал на этом -месте сплошной чёрный прямоугольник. Сравнение выполнено при времени сцены -2000 мс и трёх кадрах, запуск с Vulkan validation завершился без предупреждений -и ошибок. Это подтверждает только структуру проёма в этой сцене: правильность -освещения и полный попиксельный паритет не установлены. - -Общее fade-поведение по расстоянию в preview не реализовано. Открыты точная -метрика расстояния, выбор первого CPU-принятого треугольника диапазона и -эквивалентность frustum/culling-отбора оригинала; эти вопросы нельзя вывести из -захвата заводской двери. Данные потока type 16 ещё не декодированы, поэтому -правило выше относится только к MSH-диапазонам без этого потока цветов. +Кривые alpha и метрика расстояния порталов реализованы для MSH без type 16 +([описание кадра](../reference/render-frame.md#прозрачность-порталов-msh)). Native CPU clipping может выбрать другую первую вершину; +это приближение явно считается как `portal_fade_packet_selection_approx`. + +Данные type 16 ещё не декодированы. Внутренний ракурс «Конструктора» после +настоящего входа игрока подтверждает исчезновение чёрной поверхности в проходе; +для всей приёмки R2 ещё нужны пары обеих миссий на трёх дистанциях. Закрывающий эксперимент: запустить оригинал в совместимой Windows/DirectX среде, перехватить DirectDraw/Direct3D calls и surface flips, сохранить state diff --git a/docs/reference/render-frame.md b/docs/reference/render-frame.md index 0a0fb4d..08d7c37 100644 --- a/docs/reference/render-frame.md +++ b/docs/reference/render-frame.md @@ -41,6 +41,36 @@ geometry и projected shadows пересобираются для нового Ñ выбирается после успешного представления кадра; пауза нулевого drawable не добавляет время. +## Прозрачность порталов MSH + +GOG `Terrain.dll` (SHA-256 +`af87d1b2e728a0be73c52be3b44cc196ab46da7799f25a15d40f8c9b0b425ead`), +RVA `0x1B0F0`, заменяет diffuse alpha пакета без type 16 с batch bit `0x0008`. +При `0x0010` аргумент кривой — `sqrt(distance)`, границы — `2.6 / fov` и +`7.7999997 / fov`, наклон — `fov / (7.7999997 - 2.6)`. Без этого бита +`0x0040` задаёт ноль; иначе границы — `75 * 1.3 / fov`, `95 * 1.3 / fov`, +наклон — `fov / (95 * 1.3 - 75 * 1.3)`. До ближней границы alpha равна нулю, +после дальней — единице. Внутри перехода alpha равна произведению смещения от +ближней границы на наклон. Это замена alpha MAT0, а не дополнительный множитель. + +Метод расстояния `0x246A0` через `0x247B0` читает первую индексированную +вершину пакета, преобразует её в мир и вычисляет квадрат расстояния до камеры. +Текущий Ngi32 dispatch `FastProc+0x2C` указывает на RVA `0x1D6D0`: +`RSQRTSS` возвращает приближение обратного корня, которое умножается на квадрат +расстояния. Получается приближённое евклидово расстояние; дополнительный `FSQRT` +ветви `0x0010` выполняется уже над ним. Хеш Ngi32 — +`bab9840d94f4e4e74ffc26677724fa896cf4823845504d09a9e025f80016edf5`. +Скаляр камеры в шестом float getter `0x4F5F0` и поле CShade `+0xBB4` +проверены в работающем оригинале: оба содержат текущий горизонтальный FOV. + +`legacy_msh_portal_alpha` хранит кривую в `fparkan-render`. Приложение пока +берёт первую индексированную вершину своего диапазона и обычный `sqrt`, +поэтому выбор вершины после native CPU clipping и точность `RSQRTSS` не +воспроизводятся. Счётчик `render_coverage portal_fade_packet_selection_approx` +показывает число таких диапазонов. Поток type 16 сохраняет прежний отдельный +путь и ещё не декодирован. Это ограничивает приёмку на частично отсечённых +порталах и не позволяет считать интерьер принятой эталонной сценой. + ## Элемент отрисовки Подготовленный элемент отрисовки содержит: diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index 212f2ca..295e718 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -499,6 +499,31 @@ Material manager кэширует разобранный MAT0 и texture handles лучше вычислять на экземпляр материала, если random offset или локальное время различаются между объектами; immutable phase data остаются общими. +### Почему проём становится прозрачным вблизи + +Портал здания — поверхность, которая закрывает интерьер издалека и постепенно +исчезает при приближении камеры. Если оставить ей постоянную непрозрачность, +вместо окна или перехода получится тёмный прямоугольник. Прозрачность материала +здесь заменяется значением, вычисленным для всего пакета треугольников; alpha +текстуры продолжает участвовать в смешивании. + +Для MSH без потока цветов type 16 правило включает флаг batch `0x0008`. +В обычной ветви при горизонтальном FOV `1.3` поверхность прозрачна до расстояния +75 и непрозрачна после 95; между ними alpha растёт линейно. При другом FOV обе +границы умножаются на `1.3 / FOV`. Расстояние берётся до первой вершины первого +принятого треугольника, а не до центра здания. + +Флаг `0x0010` выбирает другую кривую: сравнивается квадратный корень уже +вычисленного расстояния, границы равны `2.6 / FOV` и `7.7999997 / FOV`. +Например, при FOV `1.04` поверхность на расстоянии 25 имеет alpha примерно +`0.5`, а на расстоянии 5 полностью исчезает. Флаг `0x0040` без `0x0010` +сразу задаёт нулевую alpha; при наличии обоих побеждает `0x0010`. + +FParkan пересчитывает эту alpha при каждом обновлении камеры, после анимации +модели. Нулевое значение временно выключает диапазон индексов; при удалении +камеры диапазон восстанавливается. Подробности оригинального вычисления и +пока приближённого выбора вершины приведены в [описании кадра](../reference/render-frame.md#прозрачность-порталов-msh). + ## Texm: текстуры, mip-уровни и атласы `Texm` -- основной формат изображений. Он хранится в `Textures.lib`, diff --git a/tools/native-frame-capture/README.md b/tools/native-frame-capture/README.md index 428f8c2..75f47e1 100644 --- a/tools/native-frame-capture/README.md +++ b/tools/native-frame-capture/README.md @@ -110,3 +110,41 @@ or `44`/`50` for BGRA. Pass the viewer log's `readback_format` value; `37` is 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. + +## Сравнение областей кадра + +`compare-frames.ps1` собирает пару «оригинал / FParkan» без масштабирования и +считает средний RGB каждой указанной области. Координаты областей относятся к +viewport, то есть к PNG просмотрщика. Оригинальный PNG сначала обрезается по +`viewport` из JSON камеры. Имена выбираются для конкретной сцены: например, +`sky`, `terrain_near`, `terrain_far`, `facade`, `opening`; невидимые в этом +ракурсе области добавлять не нужно. Панели HUD и движущиеся объекты следует +оставлять вне измеряемых прямоугольников. + +Пример `regions.json` для интерьера 1280 × 1024: + +```json +[ + {"name":"ceiling_near","rect":[250,50,550,170]}, + {"name":"wall","rect":[120,400,200,150]}, + {"name":"opening","rect":[650,550,220,100]} +] +``` + +```powershell +& '.\tools\native-frame-capture\compare-frames.ps1' -SelfTest +& '.\tools\native-frame-capture\compare-frames.ps1' ` + -NativePng 'target\captures\r1\constructor\interior.png' ` + -ViewerPng 'target\captures\r1\constructor\interior-after.png' ` + -CameraJson 'target\captures\r1\constructor\interior.json' ` + -RegionsJson 'target\captures\r1\constructor\interior-regions.json' ` + -OutputPrefix 'target\captures\r1\constructor\interior-comparison' +``` + +Результат — PNG с парой и отмеченными областями, а также JSON со средними +значениями каналов в диапазоне 0–255. Это значения сохранённого изображения, +без перевода в линейное цветовое пространство. Скрипт отклоняет несовпадающие +размеры, области за границей кадра и существующие выходные файлы. Совпадение +средних цветов не доказывает совпадение геометрии, поэтому пару нужно также +просмотреть. Камера и фаза должны происходить из одного нативного захвата; +интерьер снимается после настоящего входа игрока в здание. diff --git a/tools/native-frame-capture/compare-frames.ps1 b/tools/native-frame-capture/compare-frames.ps1 new file mode 100644 index 0000000..2a83bf0 --- /dev/null +++ b/tools/native-frame-capture/compare-frames.ps1 @@ -0,0 +1,106 @@ +[CmdletBinding(DefaultParameterSetName = 'Compare')] +param( + [Parameter(Mandatory = $true, ParameterSetName = 'Compare')][string]$NativePng, + [Parameter(Mandatory = $true, ParameterSetName = 'Compare')][string]$ViewerPng, + [Parameter(Mandatory = $true, ParameterSetName = 'Compare')][string]$CameraJson, + [Parameter(Mandatory = $true, ParameterSetName = 'Compare')][string]$RegionsJson, + [Parameter(Mandatory = $true, ParameterSetName = 'Compare')][string]$OutputPrefix, + [Parameter(Mandatory = $true, ParameterSetName = 'SelfTest')][switch]$SelfTest +) + +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Drawing + +function Get-MeanRgb { + param([System.Drawing.Bitmap]$Bitmap, [System.Drawing.Rectangle]$Region) + if ($Region.X -lt 0 -or $Region.Y -lt 0 -or $Region.Width -le 0 -or $Region.Height -le 0 -or + $Region.Right -gt $Bitmap.Width -or $Region.Bottom -gt $Bitmap.Height) { + throw 'Region must be a nonempty rectangle within the image.' + } + $sum = [long[]]@(0, 0, 0) + for ($y = $Region.Top; $y -lt $Region.Bottom; $y++) { + for ($x = $Region.Left; $x -lt $Region.Right; $x++) { + $pixel = $Bitmap.GetPixel($x, $y) + $sum[0] += $pixel.R + $sum[1] += $pixel.G + $sum[2] += $pixel.B + } + } + $count = [long]$Region.Width * $Region.Height + return ,@($sum | ForEach-Object { [Math]::Round($_ / $count, 4) }) +} + +if ($SelfTest) { + $test = [System.Drawing.Bitmap]::new(2, 1) + try { + $test.SetPixel(0, 0, [System.Drawing.Color]::FromArgb(10, 20, 30)) + $test.SetPixel(1, 0, [System.Drawing.Color]::FromArgb(30, 40, 50)) + $mean = Get-MeanRgb $test ([System.Drawing.Rectangle]::new(0, 0, 2, 1)) + if (($mean -join ',') -ne '20,30,40') { throw 'Mean RGB self-test failed.' } + $rejected = $false + 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' + } finally { $test.Dispose() } + return +} + +$pngPath = [System.IO.Path]::GetFullPath($OutputPrefix + '.png') +$jsonPath = [System.IO.Path]::GetFullPath($OutputPrefix + '.json') +if ((Test-Path -LiteralPath $pngPath) -or (Test-Path -LiteralPath $jsonPath)) { + throw 'Comparison output already exists; choose a new prefix.' +} +$camera = Get-Content -LiteralPath $CameraJson -Raw | ConvertFrom-Json +if ($camera.schema -ne 'fparkan-legacy-camera-v1' -or $camera.viewport.Count -ne 4) { + throw 'Expected a legacy camera JSON with a four-value viewport.' +} +$viewport = @($camera.viewport) +foreach ($value in $viewport) { + if ($value -lt 0 -or $value -gt [int]::MaxValue -or [Math]::Truncate($value) -ne $value) { + throw 'Viewport coordinates must be nonnegative integers.' + } +} +$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 +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) + $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) + $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) + $regions = @(Get-Content -LiteralPath $RegionsJson -Raw | ConvertFrom-Json) + if ($regions.Count -eq 0) { throw 'At least one named region is required.' } + $report = foreach ($region in $regions) { + if ([string]::IsNullOrWhiteSpace($region.name) -or $region.rect.Count -ne 4) { throw 'Each region needs name and rect [x,y,width,height].' } + foreach ($value in $region.rect) { + if ($value -lt 0 -or $value -gt [int]::MaxValue -or [Math]::Truncate($value) -ne $value) { throw 'Region coordinates must be nonnegative integers.' } + } + $rect = [System.Drawing.Rectangle]::new($region.rect[0], $region.rect[1], $region.rect[2], $region.rect[3]) + $originalMean = Get-MeanRgb $nativeCrop $rect + $viewerMean = Get-MeanRgb $viewer $rect + foreach ($offset in @(0, $viewer.Width)) { + $graphics.DrawRectangle([System.Drawing.Pens]::Yellow, $rect.X + $offset, $rect.Y + 24, $rect.Width, $rect.Height) + $graphics.DrawString($region.name, $font, [System.Drawing.Brushes]::Yellow, $rect.X + $offset, $rect.Y + 24) + } + [pscustomobject]@{ name = $region.name; rect = $region.rect; original_rgb = $originalMean; fparkan_rgb = $viewerMean } + } + $pair.Save($pngPath, [System.Drawing.Imaging.ImageFormat]::Png) + [pscustomobject]@{ native = $NativePng; viewer = $ViewerPng; camera = $CameraJson; rgb_encoding = 'stored image bytes, 0..255'; regions = @($report) } | + 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)) { + if ($null -ne $resource) { $resource.Dispose() } + } +} |
