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_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(¤t_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]
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 сохраняет прежний отдельный
|
||||||
|
путь и ещё не декодирован. Это ограничивает приёмку на частично отсечённых
|
||||||
|
порталах и не позволяет считать интерьер принятой эталонной сценой.
|
||||||
|
|
||||||
## Элемент отрисовки
|
## Элемент отрисовки
|
||||||
|
|
||||||
Подготовленный элемент отрисовки содержит:
|
Подготовленный элемент отрисовки содержит:
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user