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_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(&current_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]
+81
View File
@@ -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 {
+6 -19
View File
@@ -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 завершился без предупреждений
и ошибок. Это подтверждает только структуру проёма в этой сцене: правильность
освещения и полный попиксельный паритет не установлены.
Кривые alpha и метрика расстояния порталов реализованы для MSH без type 16
([описание кадра](../reference/render-frame.md#прозрачность-порталов-msh)). Native CPU clipping может выбрать другую первую вершину;
это приближение явно считается как `portal_fade_packet_selection_approx`.
Общее fade-поведение по расстоянию в preview не реализовано. Открыты точная
метрика расстояния, выбор первого CPU-принятого треугольника диапазона и
эквивалентность frustum/culling-отбора оригинала; эти вопросы нельзя вывести из
захвата заводской двери. Данные потока type 16 ещё не декодированы, поэтому
правило выше относится только к MSH-диапазонам без этого потока цветов.
Данные type 16 ещё не декодированы. Внутренний ракурс «Конструктора» после
настоящего входа игрока подтверждает исчезновение чёрной поверхности в проходе;
для всей приёмки R2 ещё нужны пары обеих миссий на трёх дистанциях.
Закрывающий эксперимент: запустить оригинал в совместимой Windows/DirectX
среде, перехватить DirectDraw/Direct3D calls и surface flips, сохранить state
+30
View File
@@ -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 сохраняет прежний отдельный
путь и ещё не декодирован. Это ограничивает приёмку на частично отсечённых
порталах и не позволяет считать интерьер принятой эталонной сценой.
## Элемент отрисовки
Подготовленный элемент отрисовки содержит:
+25
View File
@@ -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`,
+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
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. Это значения сохранённого изображения,
без перевода в линейное цветовое пространство. Скрипт отклоняет несовпадающие
размеры, области за границей кадра и существующие выходные файлы. Совпадение
средних цветов не доказывает совпадение геометрии, поэтому пару нужно также
просмотреть. Камера и фаза должны происходить из одного нативного захвата;
интерьер снимается после настоящего входа игрока в здание.
@@ -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() }
}
}