fix(render): fade MSH portals with camera distance
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
This commit is contained in:
7 files changed
+382
-44
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 сохраняет прежний отдельный
|
||||
путь и ещё не декодирован. Это ограничивает приёмку на частично отсечённых
|
||||
порталах и не позволяет считать интерьер принятой эталонной сценой.
|
||||
|
||||
## Элемент отрисовки
|
||||
|
||||
Подготовленный элемент отрисовки содержит:
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user