fix(render): fade MSH portals with camera distance
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s

This commit is contained in:
Valentin Popov committed 2026-10-31 18:01:35 +04:00
1 parent 09fcd35023
commit c31e5b7342
7 files changed
+382 -44

No files matched your search

+96 -25
View File
@@ -47,8 +47,9 @@ use fparkan_path::ResourceName;
use fparkan_platform_winit::{window_native_handles, WinitWindow, WinitWindowPlan}; use fparkan_platform_winit::{window_native_handles, WinitWindow, WinitWindowPlan};
use fparkan_prototype::{unit_component_tree, PROTOTYPE_TYPE_EXTO}; use fparkan_prototype::{unit_component_tree, PROTOTYPE_TYPE_EXTO};
use fparkan_render::{ use fparkan_render::{
LegacyBlendMode, LegacyCullMode, LegacyD3d7Projection, LegacyDepthBias, LegacyDepthMode, legacy_msh_portal_alpha, LegacyBlendMode, LegacyCullMode, LegacyD3d7Projection,
LegacyIron3dEulerTransform, LegacyPipelineState, RawCameraTransform, LegacyDepthBias, LegacyDepthMode, LegacyIron3dEulerTransform, LegacyPipelineState,
RawCameraTransform,
}; };
use fparkan_render_vulkan::{ use fparkan_render_vulkan::{
node38_pose, node38_pose_from_hierarchy, node38_pose_relative_to_root, 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 diffuse_alpha < 1.0 || (range.batch_flags & 0x108) != 0
} }
fn native_msh_diffuse_alpha_override(batch_flags: u16, type16_present: bool) -> Option<f32> { fn native_msh_diffuse_alpha_override(
if type16_present || batch_flags & 0x0008 == 0 { mesh: &VulkanStaticMesh,
return None; 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 { fn native_msh_handedness(transform: LegacyIron3dEulerTransform, scale: [f32; 3]) -> i8 {
@@ -9408,7 +9427,19 @@ impl StaticVulkanApp {
fn configure_world_draw_ranges( fn configure_world_draw_ranges(
&self, &self,
renderer: &mut VulkanSmokeRenderer, renderer: &mut VulkanSmokeRenderer,
aspect: f32,
) -> Result<(), String> { ) -> 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 { for &range_index in &self.world_range_indices {
let range = self let range = self
.mesh .mesh
@@ -9420,9 +9451,12 @@ impl StaticVulkanApp {
.get(usize::from(range.material_index)) .get(usize::from(range.material_index))
.map_or(1.0, |material| material.diffuse_alpha); .map_or(1.0, |material| material.diffuse_alpha);
let alpha_override = native_msh_diffuse_alpha_override( let alpha_override = native_msh_diffuse_alpha_override(
range.batch_flags, &self.mesh,
range_index,
self.type16_present_ranges.contains(&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); let effective_alpha = alpha_override.unwrap_or(alpha);
renderer renderer
.set_draw_range_diffuse_alpha_override(range_index, alpha_override) .set_draw_range_diffuse_alpha_override(range_index, alpha_override)
@@ -9513,6 +9547,7 @@ impl StaticVulkanApp {
world_range_index_counts: &HashMap<usize, u32>, world_range_index_counts: &HashMap<usize, u32>,
native_handedness_by_range: &HashMap<usize, i8>, native_handedness_by_range: &HashMap<usize, i8>,
eye_world: [f32; 3], eye_world: [f32; 3],
horizontal_fov: f32,
active_materials: &HashMap<usize, ActiveMaterialState>, active_materials: &HashMap<usize, ActiveMaterialState>,
renderer: &mut VulkanSmokeRenderer, renderer: &mut VulkanSmokeRenderer,
) -> Result<(), String> { ) -> Result<(), String> {
@@ -9531,9 +9566,12 @@ impl StaticVulkanApp {
|state| state.diffuse_alpha, |state| state.diffuse_alpha,
); );
let alpha_override = native_msh_diffuse_alpha_override( let alpha_override = native_msh_diffuse_alpha_override(
range.batch_flags, mesh,
range_index,
type16_present_ranges.contains(&range_index), type16_present_ranges.contains(&range_index),
); eye_world,
horizontal_fov,
)?;
let alpha = alpha_override.unwrap_or(material_alpha); let alpha = alpha_override.unwrap_or(material_alpha);
renderer renderer
.set_draw_range_diffuse_alpha_override(range_index, alpha_override) .set_draw_range_diffuse_alpha_override(range_index, alpha_override)
@@ -10120,6 +10158,7 @@ impl StaticVulkanApp {
&self.world_range_index_counts, &self.world_range_index_counts,
&self.native_handedness_by_range, &self.native_handedness_by_range,
frame.camera.position, frame.camera.position,
horizontal_fov(&frame.camera, frame.aspect),
&frame.material_update.active_materials, &frame.material_update.active_materials,
renderer, renderer,
)?; )?;
@@ -10517,7 +10556,10 @@ impl ApplicationHandler for StaticVulkanApp {
event_loop.exit(); event_loop.exit();
return; 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); self.error = Some(error);
event_loop.exit(); event_loop.exit();
return; return;
@@ -11331,23 +11373,32 @@ mod tests {
let (mesh, mut range) = facing_mesh(0x0148, &triangle); let (mesh, mut range) = facing_mesh(0x0148, &triangle);
let original_count = range.index_count; let original_count = range.index_count;
let mut alpha_override = None; let mut alpha_override = None;
let update = |range: &VulkanStaticDrawRange, alpha_override: &mut Option<f32>| { let update = |range: &VulkanStaticDrawRange,
*alpha_override = native_msh_diffuse_alpha_override(range.batch_flags, false); 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(&current_mesh, 0, false, eye_world, 1.3).unwrap();
world_draw_range_index_count( world_draw_range_index_count(
&mesh, &mesh,
range, range,
original_count, original_count,
alpha_override.unwrap_or(1.0), alpha_override.unwrap_or(1.0),
[0.0; 3], eye_world,
None, None,
) )
}; };
assert_eq!(update(&range, &mut alpha_override), 0); assert_eq!(update(&range, [0.0; 3], &mut alpha_override), 0);
range.batch_flags = 0x0108; 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; 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); assert_eq!(original_count, 3);
} }
@@ -13077,14 +13128,34 @@ mod tests {
} }
#[test] #[test]
fn native_cpu_shaded_msh_alpha_override_uses_only_the_constant_batch_rule() { fn cpu_msh_portal_alpha_updates_with_distance_and_preserves_type16() {
assert_eq!(native_msh_diffuse_alpha_override(0x0148, false), Some(0.0)); let mut mesh = VulkanStaticMesh::smoke_triangle();
assert_eq!(native_msh_diffuse_alpha_override(0x0108, false), None); for flags in [0x0148, 0x0108, 0x0118, 0x0058] {
assert_eq!(native_msh_diffuse_alpha_override(0x0118, false), None); mesh.draw_ranges[0].batch_flags = flags;
assert_eq!(native_msh_diffuse_alpha_override(0x0058, false), None); assert_eq!(
assert_eq!(native_msh_diffuse_alpha_override(0x0148, true), None); native_msh_diffuse_alpha_override(&mesh, 0, false, [0.0, -0.55, -1.0], 1.04),
assert_eq!(native_msh_diffuse_alpha_override(0x0058, true), None); Ok(Some(0.0))
assert_eq!(native_msh_diffuse_alpha_override(0x0040, false), None); );
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] #[test]
+81
View File
@@ -361,6 +361,53 @@ pub enum LegacyCullMode {
FrontFace, 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. /// Raster depth adjustment applied to one pipeline variant.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum LegacyDepthBias { pub enum LegacyDepthBias {
@@ -463,6 +510,40 @@ impl From<LegacyPipelineState> for PipelineKey {
mod tests { mod tests {
use super::*; 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] { fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] {
let mut result = [0.0; 16]; let mut result = [0.0; 16];
for row in 0..4 { for row in 0..4 {
+6 -19
View File
@@ -45,26 +45,13 @@ MSH-запрос со свободной preview-камерой; это не п
стратегической и следящей камерами не подтверждено стратегической и следящей камерами не подтверждено
([MSH](../reference/msh.md)). ([MSH](../reference/msh.md)).
Для MSH без потока цветов type 16 подтверждён только один постоянный alpha Кривые alpha и метрика расстояния порталов реализованы для MSH без type 16
override для диапазонов с CPU-цветом и raw-флагом `Batch20` `0x0008`: GOG ([описание кадра](../reference/render-frame.md#прозрачность-порталов-msh)). Native CPU clipping может выбрать другую первую вершину;
`Terrain.dll`, SHA-256 это приближение явно считается как `portal_fade_packet_selection_approx`.
`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 не реализовано. Открыты точная Данные type 16 ещё не декодированы. Внутренний ракурс «Конструктора» после
метрика расстояния, выбор первого CPU-принятого треугольника диапазона и настоящего входа игрока подтверждает исчезновение чёрной поверхности в проходе;
эквивалентность frustum/culling-отбора оригинала; эти вопросы нельзя вывести из для всей приёмки R2 ещё нужны пары обеих миссий на трёх дистанциях.
захвата заводской двери. Данные потока type 16 ещё не декодированы, поэтому
правило выше относится только к MSH-диапазонам без этого потока цветов.
Закрывающий эксперимент: запустить оригинал в совместимой Windows/DirectX Закрывающий эксперимент: запустить оригинал в совместимой Windows/DirectX
среде, перехватить DirectDraw/Direct3D calls и surface flips, сохранить state среде, перехватить DirectDraw/Direct3D calls и surface flips, сохранить state
+30
View File
@@ -41,6 +41,36 @@ geometry и projected shadows пересобираются для нового
выбирается после успешного представления кадра; пауза нулевого drawable не выбирается после успешного представления кадра; пауза нулевого 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 сохраняет прежний отдельный
путь и ещё не декодирован. Это ограничивает приёмку на частично отсечённых
порталах и не позволяет считать интерьер принятой эталонной сценой.
## Элемент отрисовки ## Элемент отрисовки
Подготовленный элемент отрисовки содержит: Подготовленный элемент отрисовки содержит:
+25
View File
@@ -499,6 +499,31 @@ Material manager кэширует разобранный MAT0 и texture handles
лучше вычислять на экземпляр материала, если random offset или локальное время лучше вычислять на экземпляр материала, если random offset или локальное время
различаются между объектами; immutable phase data остаются общими. различаются между объектами; 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: текстуры, mip-уровни и атласы
`Texm` -- основной формат изображений. Он хранится в `Textures.lib`, `Texm` -- основной формат изображений. Он хранится в `Textures.lib`,
+38
View File
@@ -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 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 converter uses the installed `System.Drawing` PNG encoder and adds no package
dependency. 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. Это значения сохранённого изображения,
без перевода в линейное цветовое пространство. Скрипт отклоняет несовпадающие
размеры, области за границей кадра и существующие выходные файлы. Совпадение
средних цветов не доказывает совпадение геометрии, поэтому пару нужно также
просмотреть. Камера и фаза должны происходить из одного нативного захвата;
интерьер снимается после настоящего входа игрока в здание.
@@ -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() }
}
}