feat(render): animate water with native EMBM waves
Docs Deploy / Build and Deploy MkDocs (push) Successful in 41s

This commit is contained in:
Valentin Popov committed 2026-11-01 06:12:44 +04:00
1 parent dfa2db65cc
commit 05572c7bf4
15 files changed
+390 -42

No files matched your search

+2 -1
View File
@@ -12,7 +12,8 @@
Рельеф переключает исходные LOD0/LOD1 по ячейкам относительно текущей камеры; Рельеф переключает исходные LOD0/LOD1 по ячейкам относительно текущей камеры;
под основаниями строений сохраняется подробная сетка. под основаниями строений сохраняется подробная сетка.
Горизонтальная вода использует плоское отражение ближайшей активной высоты; Горизонтальная вода использует плоское отражение ближайшей активной высоты;
волны EMBM и shifted-камера оригинала пока не воспроизводятся. волны используют карту Du/Dv и часы оригинала, а shifted-камера отражения
пока заменена обычным зеркалом.
## Запуск ## Запуск
@@ -9,7 +9,7 @@ layout(location = 5) in vec3 in_base_diffuse;
layout(location = 6) in vec3 in_base_specular; layout(location = 6) in vec3 in_base_specular;
layout(location = 7) in vec3 in_overlay_diffuse; layout(location = 7) in vec3 in_overlay_diffuse;
layout(location = 8) in vec3 in_overlay_specular; layout(location = 8) in vec3 in_overlay_specular;
layout(location = 9) in float in_world_z; layout(location = 9) in vec3 in_world_position;
layout(location = 0) out vec4 out_color; layout(location = 0) out vec4 out_color;
layout(set = 0, binding = 0) uniform sampler2D base_color; layout(set = 0, binding = 0) uniform sampler2D base_color;
@@ -17,6 +17,7 @@ layout(set = 0, binding = 1) uniform sampler2D base_detail;
layout(set = 0, binding = 2) uniform sampler2D overlay_color; layout(set = 0, binding = 2) uniform sampler2D overlay_color;
layout(set = 0, binding = 3) uniform sampler2D overlay_detail; layout(set = 0, binding = 3) uniform sampler2D overlay_detail;
layout(set = 1, binding = 0) uniform sampler2D reflection_color; layout(set = 1, binding = 0) uniform sampler2D reflection_color;
layout(set = 1, binding = 1) uniform sampler2D embm_bump;
layout(std140, set = 0, binding = 4) uniform FrameConstants { layout(std140, set = 0, binding = 4) uniform FrameConstants {
mat4 clip_from_world; mat4 clip_from_world;
@@ -30,6 +31,8 @@ layout(std140, set = 0, binding = 4) uniform FrameConstants {
vec4 fog_color; vec4 fog_color;
vec4 fog_distances; vec4 fog_distances;
vec4 camera_position; vec4 camera_position;
vec4 embm_bounds;
vec4 embm_wave;
} frame; } frame;
layout(push_constant) uniform MaterialConstants { layout(push_constant) uniform MaterialConstants {
@@ -69,11 +72,23 @@ layout(push_constant) uniform MaterialConstants {
void main() { void main() {
int mode = int(round(material.material_mode)); int mode = int(round(material.material_mode));
if ((mode & 32) != 0 && (mode & 4) == 0 && in_world_z < frame.camera_position.w) { if ((mode & 32) != 0 && (mode & 4) == 0 && in_world_position.z < frame.camera_position.w) {
discard; discard;
} }
if ((mode & 64) != 0) { if ((mode & 64) != 0) {
vec2 reflection_uv = gl_FragCoord.xy / vec2(textureSize(reflection_color, 0)); vec2 reflection_uv = gl_FragCoord.xy / vec2(textureSize(reflection_color, 0));
vec2 span = frame.embm_bounds.zw - frame.embm_bounds.xy;
vec2 plane_uv = vec2(1.0) - (in_world_position.yx - frame.embm_bounds.yx) / span.yx;
vec2 bump_uv = plane_uv * frame.embm_wave.y + frame.embm_wave.x;
vec2 duv = max(vec2(-1.0), (texture(embm_bump, bump_uv).rg * 255.0 - 128.0) / 127.0);
vec2 displacement = duv * frame.embm_wave.zw;
// shortcut: convert native map-UV displacement to the current mirror projection;
// replace this projection when accepting the original shifted reflection camera.
vec3 displaced_world = in_world_position - vec3(displacement.yx * span, 0.0);
vec4 displaced_clip = frame.clip_from_world * vec4(displaced_world, 1.0);
if (displaced_clip.w > 0.000001) {
reflection_uv = displaced_clip.xy / displaced_clip.w * 0.5 + 0.5;
}
vec3 rgb = texture(reflection_color, reflection_uv).rgb * in_base_diffuse + in_base_specular; vec3 rgb = texture(reflection_color, reflection_uv).rgb * in_base_diffuse + in_base_specular;
out_color = vec4(mix(rgb * in_color, frame.fog_color.rgb, in_fog_factor), material.diffuse_alpha); out_color = vec4(mix(rgb * in_color, frame.fog_color.rgb, in_fog_factor), material.diffuse_alpha);
if (out_color.a < material.alpha_cutoff) { if (out_color.a < material.alpha_cutoff) {
@@ -19,7 +19,7 @@ layout(location = 5) out vec3 out_base_diffuse;
layout(location = 6) out vec3 out_base_specular; layout(location = 6) out vec3 out_base_specular;
layout(location = 7) out vec3 out_overlay_diffuse; layout(location = 7) out vec3 out_overlay_diffuse;
layout(location = 8) out vec3 out_overlay_specular; layout(location = 8) out vec3 out_overlay_specular;
layout(location = 9) out float out_world_z; layout(location = 9) out vec3 out_world_position;
layout(std140, set = 0, binding = 4) uniform FrameConstants { layout(std140, set = 0, binding = 4) uniform FrameConstants {
mat4 clip_from_world; mat4 clip_from_world;
@@ -33,6 +33,8 @@ layout(std140, set = 0, binding = 4) uniform FrameConstants {
vec4 fog_color; vec4 fog_color;
vec4 fog_distances; vec4 fog_distances;
vec4 camera_position; vec4 camera_position;
vec4 embm_bounds;
vec4 embm_wave;
} frame; } frame;
layout(std430, set = 0, binding = 5) readonly buffer MaterialSpecular { layout(std430, set = 0, binding = 5) readonly buffer MaterialSpecular {
@@ -242,7 +244,7 @@ LightingTerms native_material_lighting(
} }
void main() { void main() {
out_world_z = in_position.z; out_world_position = in_position;
out_color = in_color; out_color = in_color;
out_uv = in_uv; out_uv = in_uv;
out_detail_uv = in_detail_uv; out_detail_uv = in_detail_uv;
@@ -36,14 +36,42 @@ pub(super) fn mesh_reflection_planes(mesh: &super::VulkanStaticMesh) -> Vec<Opti
.collect() .collect()
} }
pub(super) fn mesh_reflection_bounds(mesh: &super::VulkanStaticMesh) -> Option<[f32; 4]> {
let mut bounds = [
f32::INFINITY,
f32::INFINITY,
f32::NEG_INFINITY,
f32::NEG_INFINITY,
];
for range in mesh
.draw_ranges
.iter()
.filter(|range| range.terrain_reflection)
{
for &index in &mesh.indices
[range.first_index as usize..range.first_index as usize + range.index_count as usize]
{
let position = mesh.vertices[index as usize].position;
bounds[0] = bounds[0].min(position[0]);
bounds[1] = bounds[1].min(position[1]);
bounds[2] = bounds[2].max(position[0]);
bounds[3] = bounds[3].max(position[1]);
}
}
(bounds.iter().all(|v| v.is_finite()) && bounds[2] > bounds[0] && bounds[3] > bounds[1])
.then_some(bounds)
}
/// Owned before pipeline creation; a dummy set prevents attachment feedback in the mirror pass. /// Owned before pipeline creation; a dummy set prevents attachment feedback in the mirror pass.
pub(super) struct ReflectionResources { pub(super) struct ReflectionResources {
device: ash::Device, device: ash::Device,
pub layout: vk::DescriptorSetLayout, pub layout: vk::DescriptorSetLayout,
pool: vk::DescriptorPool, pool: vk::DescriptorPool,
sampler: vk::Sampler, sampler: vk::Sampler,
bump_sampler: vk::Sampler,
pub sets: [vk::DescriptorSet; 2], pub sets: [vk::DescriptorSet; 2],
dummy: Option<VulkanAllocatedImage>, dummy: Option<VulkanAllocatedImage>,
bump: Option<VulkanAllocatedImage>,
pub color: Option<VulkanAllocatedImage>, pub color: Option<VulkanAllocatedImage>,
depth: Option<VulkanDepthAttachment>, depth: Option<VulkanDepthAttachment>,
pub framebuffer: vk::Framebuffer, pub framebuffer: vk::Framebuffer,
@@ -54,25 +82,31 @@ impl ReflectionResources {
instance: &VulkanInstanceProbe, instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe, device: &VulkanLogicalDeviceProbe,
command_pool: vk::CommandPool, command_pool: vk::CommandPool,
enabled: bool,
) -> Result<Self, VulkanSmokeRendererError> { ) -> Result<Self, VulkanSmokeRendererError> {
let mut owned = Self { let mut owned = Self {
device: device.device().clone(), device: device.device().clone(),
layout: vk::DescriptorSetLayout::null(), layout: vk::DescriptorSetLayout::null(),
pool: vk::DescriptorPool::null(), pool: vk::DescriptorPool::null(),
sampler: vk::Sampler::null(), sampler: vk::Sampler::null(),
bump_sampler: vk::Sampler::null(),
sets: [vk::DescriptorSet::null(); 2], sets: [vk::DescriptorSet::null(); 2],
dummy: None, dummy: None,
bump: None,
color: None, color: None,
depth: None, depth: None,
framebuffer: vk::Framebuffer::null(), framebuffer: vk::Framebuffer::null(),
}; };
let binding = vk::DescriptorSetLayoutBinding::default() let bindings: Vec<_> = (0..2)
.binding(0) .map(|binding| {
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) vk::DescriptorSetLayoutBinding::default()
.descriptor_count(1) .binding(binding)
.stage_flags(vk::ShaderStageFlags::FRAGMENT); .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
let layout_info = .descriptor_count(1)
vk::DescriptorSetLayoutCreateInfo::default().bindings(std::slice::from_ref(&binding)); .stage_flags(vk::ShaderStageFlags::FRAGMENT)
})
.collect();
let layout_info = vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings);
// SAFETY: The device is live; owned rolls back every handle on later failure. // SAFETY: The device is live; owned rolls back every handle on later failure.
owned.layout = unsafe { owned.layout = unsafe {
owned owned
@@ -85,7 +119,7 @@ impl ReflectionResources {
})?; })?;
let size = vk::DescriptorPoolSize::default() let size = vk::DescriptorPoolSize::default()
.ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) .ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
.descriptor_count(2); .descriptor_count(4);
let pool_info = vk::DescriptorPoolCreateInfo::default() let pool_info = vk::DescriptorPoolCreateInfo::default()
.max_sets(2) .max_sets(2)
.pool_sizes(std::slice::from_ref(&size)); .pool_sizes(std::slice::from_ref(&size));
@@ -136,6 +170,42 @@ impl ReflectionResources {
mip_levels: Vec::new(), mip_levels: Vec::new(),
}, },
)?); )?);
if enabled {
let sampler = sampler
.address_mode_u(vk::SamplerAddressMode::REPEAT)
.address_mode_v(vk::SamplerAddressMode::REPEAT)
.address_mode_w(vk::SamplerAddressMode::REPEAT);
// SAFETY: The immutable bump map uses supported linear filtering and a single mip level.
owned.bump_sampler =
unsafe { owned.device.create_sampler(&sampler, None) }.map_err(|result| {
VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateSampler(EMBM)",
result,
}
})?;
let rgba8 = fparkan_render::water::legacy_embm_bump_map()
.into_iter()
.flat_map(|duv| {
[
(i16::from(duv[0]) + 128) as u8,
(i16::from(duv[1]) + 128) as u8,
0,
255,
]
})
.collect();
owned.bump = Some(super::create_static_texture_image(
instance,
device,
command_pool,
&super::VulkanStaticTexture {
width: fparkan_render::water::EMBM_BUMP_SIZE,
height: fparkan_render::water::EMBM_BUMP_SIZE,
rgba8,
mip_levels: Vec::new(),
},
)?);
}
for set in owned.sets { for set in owned.sets {
owned.write_image(set, owned.dummy.as_ref().expect("dummy image exists").view); owned.write_image(set, owned.dummy.as_ref().expect("dummy image exists").view);
} }
@@ -255,13 +325,35 @@ impl ReflectionResources {
.sampler(self.sampler) .sampler(self.sampler)
.image_view(view) .image_view(view)
.image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL); .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL);
let write = vk::WriteDescriptorSet::default() let bump = self
.dst_set(set) .bump
.dst_binding(0) .as_ref()
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) .or(self.dummy.as_ref())
.image_info(std::slice::from_ref(&image)); .expect("dummy image exists");
let images = [
image,
vk::DescriptorImageInfo::default()
.sampler(if self.bump.is_some() {
self.bump_sampler
} else {
self.sampler
})
.image_view(bump.view)
.image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL),
];
let writes: Vec<_> = images
.iter()
.enumerate()
.map(|(binding, image)| {
vk::WriteDescriptorSet::default()
.dst_set(set)
.dst_binding(binding as u32)
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
.image_info(std::slice::from_ref(image))
})
.collect();
// SAFETY: Sets are initialized before submission; the dummy remains valid for every pass. // SAFETY: Sets are initialized before submission; the dummy remains valid for every pass.
unsafe { self.device.update_descriptor_sets(&[write], &[]) }; unsafe { self.device.update_descriptor_sets(&writes, &[]) };
} }
} }
@@ -273,12 +365,14 @@ impl Drop for ReflectionResources {
self.device.destroy_framebuffer(self.framebuffer, None); self.device.destroy_framebuffer(self.framebuffer, None);
self.device.destroy_descriptor_pool(self.pool, None); self.device.destroy_descriptor_pool(self.pool, None);
self.device.destroy_sampler(self.sampler, None); self.device.destroy_sampler(self.sampler, None);
self.device.destroy_sampler(self.bump_sampler, None);
self.device.destroy_descriptor_set_layout(self.layout, None); self.device.destroy_descriptor_set_layout(self.layout, None);
for image in self for image in self
.color .color
.iter() .iter()
.chain(self.depth.iter().map(|depth| &depth.image)) .chain(self.depth.iter().map(|depth| &depth.image))
.chain(self.dummy.iter()) .chain(self.dummy.iter())
.chain(self.bump.iter())
{ {
self.device.destroy_image_view(image.view, None); self.device.destroy_image_view(image.view, None);
self.device.destroy_image(image.image, None); self.device.destroy_image(image.image, None);
@@ -320,12 +414,19 @@ mod tests {
alpha_test_reference: 1, alpha_test_reference: 1,
}], }],
}; };
mesh.vertices[1].position = [10.0, 0.0, 150.0];
mesh.vertices[2].position = [5.0, 20.0, 150.0];
let mut unused = mesh.vertices[0];
unused.position = [-10_000.0, -10_000.0, 150.0];
mesh.vertices.push(unused);
assert_eq!(mesh_reflection_bounds(&mesh), Some([0.0, 0.0, 10.0, 20.0]));
assert_eq!(mesh_reflection_planes(&mesh), vec![Some(150.0)]); assert_eq!(mesh_reflection_planes(&mesh), vec![Some(150.0)]);
mesh.vertices[2].position[2] = 151.0; mesh.vertices[2].position[2] = 151.0;
assert_eq!(mesh_reflection_planes(&mesh), vec![None]); assert_eq!(mesh_reflection_planes(&mesh), vec![None]);
mesh.vertices[2].position[2] = 150.0; mesh.vertices[2].position[2] = 150.0;
mesh.draw_ranges[0].terrain_reflection = false; mesh.draw_ranges[0].terrain_reflection = false;
assert_eq!(mesh_reflection_planes(&mesh), vec![None]); assert_eq!(mesh_reflection_planes(&mesh), vec![None]);
assert_eq!(mesh_reflection_bounds(&mesh), None);
for cull in [ for cull in [
fparkan_render::LegacyCullMode::Disabled, fparkan_render::LegacyCullMode::Disabled,
fparkan_render::LegacyCullMode::BackFace, fparkan_render::LegacyCullMode::BackFace,
@@ -373,12 +373,17 @@ impl VulkanSmokeRenderer {
plane_heights.sort_unstable(); plane_heights.sort_unstable();
plane_heights.dedup(); plane_heights.dedup();
if !plane_heights.is_empty() { if !plane_heights.is_empty() {
eprintln!("render_coverage planar_reflection_embm_unsupported={} (flat reflection; native shifted camera and wave distortion pending)", plane_heights.len()); eprintln!("render_coverage planar_reflection_shifted_camera_unsupported={} (native bump map and clock; using a projective mirror until shifted-camera mapping is implemented)", plane_heights.len());
eprintln!("render_coverage planar_reflection_other_planes_unsupported={} (nearest active plane is reflected)", plane_heights.len().saturating_sub(1)); eprintln!("render_coverage planar_reflection_other_planes_unsupported={} (nearest active plane is reflected)", plane_heights.len().saturating_sub(1));
} }
if unsupported_nonplanar != 0 { if unsupported_nonplanar != 0 {
eprintln!("render_coverage planar_reflection_nonplanar_unsupported={unsupported_nonplanar} (ordinary terrain fallback)"); eprintln!("render_coverage planar_reflection_nonplanar_unsupported={unsupported_nonplanar} (ordinary terrain fallback)");
} }
let mut frame_uniforms = super::VulkanFrameUniforms::from_camera(create_info.camera);
if let Some(bounds) = super::reflection::mesh_reflection_bounds(&create_info.mesh) {
frame_uniforms.embm_bounds = bounds;
eprintln!("render_reflection embm_bounds={bounds:?}");
}
let mut renderer = Self { let mut renderer = Self {
instance: Some(instance), instance: Some(instance),
validation, validation,
@@ -424,7 +429,7 @@ impl VulkanSmokeRenderer {
material_specular_dirty: true, material_specular_dirty: true,
readback_enabled: false, readback_enabled: false,
camera: create_info.camera, camera: create_info.camera,
frame_uniforms: super::VulkanFrameUniforms::from_camera(create_info.camera), frame_uniforms,
point_lights: Vec::new(), point_lights: Vec::new(),
point_lights_explicit: false, point_lights_explicit: false,
frame_sync: Vec::new(), frame_sync: Vec::new(),
@@ -1571,8 +1576,8 @@ impl VulkanSmokeRenderer {
}); });
} }
let mut bytes = frame.to_ne_bytes(); let mut bytes = frame.to_ne_bytes();
let length = bytes.len(); let offset = super::smoke_types::REFLECTION_CLIP_HEIGHT_OFFSET;
bytes[length - 4..].copy_from_slice(&height.to_ne_bytes()); bytes[offset..offset + 4].copy_from_slice(&height.to_ne_bytes());
Ok(bytes) Ok(bytes)
}) })
.transpose()?; .transpose()?;
@@ -192,7 +192,8 @@ impl Default for VulkanPointLight {
/// every device. Four directional lights and one point light fit in a small /// every device. Four directional lights and one point light fit in a small
/// dynamic UBO while the push-constant range remains available for material /// dynamic UBO while the push-constant range remains available for material
/// state. /// state.
pub const VULKAN_FRAME_UNIFORM_BYTES: usize = 368; pub const VULKAN_FRAME_UNIFORM_BYTES: usize = 400;
pub(super) const REFLECTION_CLIP_HEIGHT_OFFSET: usize = 364;
/// Number of CPU/GPU frame slots shared by the swapchain renderer. /// Number of CPU/GPU frame slots shared by the swapchain renderer.
pub const VULKAN_FRAME_SLOT_COUNT: usize = 2; pub const VULKAN_FRAME_SLOT_COUNT: usize = 2;
@@ -235,6 +236,10 @@ pub struct VulkanFrameUniforms {
pub fog_end: f32, pub fog_end: f32,
/// World-space camera position used for fog distance. /// World-space camera position used for fog distance.
pub camera_position: [f32; 3], pub camera_position: [f32; 3],
/// XY bounds of the original reflective faces: min X/Y, max X/Y.
pub embm_bounds: [f32; 4],
/// Native wave scroll fraction in the ten-second cycle.
pub embm_phase: f32,
} }
impl VulkanFrameUniforms { impl VulkanFrameUniforms {
@@ -258,6 +263,8 @@ impl VulkanFrameUniforms {
fog_start: f32::MAX, fog_start: f32::MAX,
fog_end: f32::MAX, fog_end: f32::MAX,
camera_position: [0.0; 3], camera_position: [0.0; 3],
embm_bounds: [0.0, 0.0, 1.0, 1.0],
embm_phase: 0.0,
} }
} }
@@ -283,7 +290,14 @@ impl VulkanFrameUniforms {
.chain(self.fog_color) .chain(self.fog_color)
.chain([self.fog_start, self.fog_end]) .chain([self.fog_start, self.fog_end])
.chain(self.camera_position) .chain(self.camera_position)
.chain(self.embm_bounds)
.chain([self.embm_phase])
.all(f32::is_finite) .all(f32::is_finite)
&& self.embm_bounds[2] > self.embm_bounds[0]
&& self.embm_bounds[3] > self.embm_bounds[1]
&& (self.embm_bounds[2] - self.embm_bounds[0]).is_finite()
&& (self.embm_bounds[3] - self.embm_bounds[1]).is_finite()
&& (0.0..1.0).contains(&self.embm_phase)
&& (self.fog_end <= self.fog_start && (self.fog_end <= self.fog_start
|| ((self.fog_start * self.fog_start).is_finite() || ((self.fog_start * self.fog_start).is_finite()
&& (self.fog_end * self.fog_end).is_finite())) && (self.fog_end * self.fog_end).is_finite()))
@@ -364,6 +378,13 @@ impl VulkanFrameUniforms {
self.camera_position[2], self.camera_position[2],
0.0, 0.0,
]); ]);
values[92..96].copy_from_slice(&self.embm_bounds);
values[96..100].copy_from_slice(&[
self.embm_phase,
fparkan_render::water::EMBM_BUMP_TILE,
fparkan_render::water::EMBM_COEFFICIENTS[0],
fparkan_render::water::EMBM_COEFFICIENTS[1],
]);
let mut bytes = [0_u8; VULKAN_FRAME_UNIFORM_BYTES]; let mut bytes = [0_u8; VULKAN_FRAME_UNIFORM_BYTES];
for (index, value) in values.into_iter().enumerate() { for (index, value) in values.into_iter().enumerate() {
let offset = index * std::mem::size_of::<f32>(); let offset = index * std::mem::size_of::<f32>();
@@ -862,6 +883,8 @@ mod static_mesh_tests {
fog_start: 13.0, fog_start: 13.0,
fog_end: 14.0, fog_end: 14.0,
camera_position: [15.0, 16.0, 17.0], camera_position: [15.0, 16.0, 17.0],
embm_bounds: [100.0, 200.0, 300.0, 600.0],
embm_phase: 0.25,
}; };
let bytes = frame.to_ne_bytes(); let bytes = frame.to_ne_bytes();
assert_eq!(bytes.len(), VULKAN_FRAME_UNIFORM_BYTES); assert_eq!(bytes.len(), VULKAN_FRAME_UNIFORM_BYTES);
@@ -880,6 +903,28 @@ mod static_mesh_tests {
assert_eq!(read(320), 10.0); assert_eq!(read(320), 10.0);
assert_eq!(read(336), 13.0); assert_eq!(read(336), 13.0);
assert_eq!(read(352), 15.0); assert_eq!(read(352), 15.0);
assert_eq!(read(368), 100.0);
assert_eq!(read(380), 600.0);
assert_eq!(read(384), 0.25);
assert_eq!(read(388), 100.0);
assert_eq!(read(392), 0.01);
}
#[test]
fn embm_uniforms_reject_invalid_bounds_and_phase() {
for (bounds, phase) in [
([0.0; 4], 0.0),
([-f32::MAX, 0.0, f32::MAX, 1.0], 0.0),
([0.0, 0.0, 1.0, 1.0], f32::NAN),
([0.0, 0.0, 1.0, 1.0], 1.0),
] {
assert!(!VulkanFrameUniforms {
embm_bounds: bounds,
embm_phase: phase,
..Default::default()
}
.is_finite());
}
} }
#[test] #[test]
@@ -249,14 +249,18 @@ pub(super) fn create_swapchain_resources(
}; };
partial.frame_uniform_stride = frame_uniform_stride; partial.frame_uniform_stride = frame_uniform_stride;
partial.frame_uniform_buffer = Some(frame_uniform_buffer); partial.frame_uniform_buffer = Some(frame_uniform_buffer);
partial.reflection = partial.reflection = match super::reflection::ReflectionResources::new(
match super::reflection::ReflectionResources::new(instance, device, command_pool) { instance,
Ok(reflection) => Some(reflection), device,
Err(error) => { command_pool,
destroy_partial_swapchain_resources(device, command_pool, partial); reflection_enabled,
return Err(error); ) {
} Ok(reflection) => Some(reflection),
}; Err(error) => {
destroy_partial_swapchain_resources(device, command_pool, partial);
return Err(error);
}
};
let material_count = texture_slot_indices.len() / 4; let material_count = texture_slot_indices.len() / 4;
let (material_specular_buffer, material_specular_stride) = let (material_specular_buffer, material_specular_stride) =
match create_material_specular_buffer(instance, device, material_count) { match create_material_specular_buffer(instance, device, material_count) {
+1
View File
@@ -23,6 +23,7 @@ pub(super) struct LegacyCameraCapture {
pub(super) struct SkyUvClock { pub(super) struct SkyUvClock {
pub(super) verified: bool, pub(super) verified: bool,
pub(super) elapsed_milliseconds: u32, pub(super) elapsed_milliseconds: u32,
pub(super) raw_clock: Option<u32>,
} }
#[derive(Clone, Debug, Deserialize, PartialEq)] #[derive(Clone, Debug, Deserialize, PartialEq)]
+46 -6
View File
@@ -4638,6 +4638,7 @@ fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] {
} }
struct StaticPreviewScene { struct StaticPreviewScene {
fixed_water_clock_ms: Option<u32>,
mesh: VulkanStaticMesh, mesh: VulkanStaticMesh,
dynamic_vertex_start: usize, dynamic_vertex_start: usize,
terrain_lod: Option<terrain_lod::TerrainLodDraws>, terrain_lod: Option<terrain_lod::TerrainLodDraws>,
@@ -8554,7 +8555,23 @@ fn static_preview_mesh_and_materials(
}; };
let camera = view_camera.vulkan_camera(); let camera = view_camera.vulkan_camera();
let shadow_material_index = append_shadow_material(&mut materials)?; let shadow_material_index = append_shadow_material(&mut materials)?;
let fixed_water_clock_ms = legacy_camera.as_ref().and_then(|capture| {
capture
.sky_uv_clock
.as_ref()
.and_then(|clock| clock.raw_clock)
});
if legacy_camera.is_some()
&& fixed_water_clock_ms.is_none()
&& mesh
.draw_ranges
.iter()
.any(|range| range.terrain_reflection)
{
eprintln!("render_coverage captured_water_phase_unverified=1 (capture has no verified raw queue clock; using replay elapsed time)");
}
Ok(StaticPreviewScene { Ok(StaticPreviewScene {
fixed_water_clock_ms,
mesh, mesh,
dynamic_vertex_start, dynamic_vertex_start,
terrain_lod, terrain_lod,
@@ -9369,6 +9386,7 @@ fn run_static_vulkan_mode(
} }
struct StaticVulkanApp { struct StaticVulkanApp {
fixed_water_clock_ms: Option<u32>,
mesh: VulkanStaticMesh, mesh: VulkanStaticMesh,
dynamic_vertex_start: usize, dynamic_vertex_start: usize,
terrain_lod: Option<terrain_lod::TerrainLodDraws>, terrain_lod: Option<terrain_lod::TerrainLodDraws>,
@@ -9497,6 +9515,7 @@ impl StaticVulkanApp {
dynamic_shadow_casters: preview.dynamic_shadow_casters, dynamic_shadow_casters: preview.dynamic_shadow_casters,
sun_occlusion_range_indices: preview.sun_occlusion_range_indices, sun_occlusion_range_indices: preview.sun_occlusion_range_indices,
terrain: preview.terrain, terrain: preview.terrain,
fixed_water_clock_ms: preview.fixed_water_clock_ms,
world_range_indices, world_range_indices,
world_range_index_counts, world_range_index_counts,
camera: preview.camera, camera: preview.camera,
@@ -10333,6 +10352,21 @@ impl StaticVulkanApp {
&mut self.material_animations, &mut self.material_animations,
renderer, renderer,
)?; )?;
let water_clock_ms = self
.fixed_water_clock_ms
.map_or(frame.clock.time_ms, |captured| {
if self.target_frames == 0 {
captured.wrapping_add(frame.clock.time_ms)
} else {
captured
}
});
let mut uniforms = renderer.frame_uniforms();
uniforms.camera_position = frame.camera.position;
uniforms.embm_phase = fparkan_render::water::legacy_embm_phase(water_clock_ms);
renderer
.set_frame_uniforms(uniforms)
.map_err(|error| format!("update water clock uniforms: {error}"))?;
for update in &frame.effect_updates { for update in &frame.effect_updates {
renderer renderer
.set_draw_range_index_count(update.range_index, update.index_count) .set_draw_range_index_count(update.range_index, update.index_count)
@@ -13664,6 +13698,7 @@ mod tests {
}; };
let view_camera = free_camera.preview_frame(16.0 / 9.0); let view_camera = free_camera.preview_frame(16.0 / 9.0);
let preview = StaticPreviewScene { let preview = StaticPreviewScene {
fixed_water_clock_ms: None,
terrain_lod: None, terrain_lod: None,
dynamic_vertex_start: 3, dynamic_vertex_start: 3,
mesh: VulkanStaticMesh { mesh: VulkanStaticMesh {
@@ -15730,13 +15765,18 @@ mod tests {
let mut value: serde_json::Value = let mut value: serde_json::Value =
serde_json::from_slice(include_bytes!("../testdata/legacy-camera-parented.json")) serde_json::from_slice(include_bytes!("../testdata/legacy-camera-parented.json"))
.unwrap(); .unwrap();
value["sky_uv_clock"] = value["sky_uv_clock"] = serde_json::json!({"verified": true, "elapsed_milliseconds": 4051271258_u32, "raw_clock": 272342176_u32});
serde_json::json!({"verified": true, "elapsed_milliseconds": 4051271258_u32});
let capture = parse_legacy_camera_capture(&serde_json::to_vec(&value).unwrap()).unwrap(); let capture = parse_legacy_camera_capture(&serde_json::to_vec(&value).unwrap()).unwrap();
assert_eq!( let clock = capture.sky_uv_clock.unwrap();
capture.sky_uv_clock.unwrap().elapsed_milliseconds, assert_eq!(clock.elapsed_milliseconds, 4051271258);
4051271258 assert_eq!(clock.raw_clock, Some(272342176));
); value["sky_uv_clock"]
.as_object_mut()
.unwrap()
.remove("raw_clock");
let old_capture =
parse_legacy_camera_capture(&serde_json::to_vec(&value).unwrap()).unwrap();
assert_eq!(old_capture.sky_uv_clock.unwrap().raw_clock, None);
value["sky_uv_clock"]["verified"] = serde_json::json!(false); value["sky_uv_clock"]["verified"] = serde_json::json!(false);
assert!(parse_legacy_camera_capture(&serde_json::to_vec(&value).unwrap()).is_err()); assert!(parse_legacy_camera_capture(&serde_json::to_vec(&value).unwrap()).is_err());
} }
+2
View File
@@ -1,6 +1,8 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
//! Camera mathematics and graphics pipeline state. //! Camera mathematics and graphics pipeline state.
pub mod water;
/// A 64-byte transform block returned by the original Terrain camera ABI. /// A 64-byte transform block returned by the original Terrain camera ABI.
/// ///
/// The original uses two selector-dependent transform pointers. Their exact /// The original uses two selector-dependent transform pointers. Their exact
+108
View File
@@ -0,0 +1,108 @@
//! The GOG Terrain EMBM wave map and its world-space animation clock.
/// Side length of the native signed Du/Dv texture.
pub const EMBM_BUMP_SIZE: u32 = 128;
/// Native default wave tiling across the bounding rectangle of reflective faces.
pub const EMBM_BUMP_TILE: f32 = 100.0;
/// Native diagonal bump matrix, without luminance modulation.
pub const EMBM_COEFFICIENTS: [f32; 2] = [0.01, 0.01];
/// Generates the signed bytes written by Terrain.dll RVA 0x2BF50 with CW 0x007F.
#[must_use]
pub fn legacy_embm_bump_map() -> Vec<[i8; 2]> {
let mut texels = Vec::with_capacity((EMBM_BUMP_SIZE * EMBM_BUMP_SIZE) as usize);
for y in 0..EMBM_BUMP_SIZE {
for x in 0..EMBM_BUMP_SIZE {
// The native constant is slightly smaller than mathematical 4*pi.
let angle = ((y as f32 / EMBM_BUMP_SIZE as f32 - 0.5)
+ (x as f32 / EMBM_BUMP_SIZE as f32 - 0.5))
* f32::from_bits(0x4149_0fd0);
texels.push([
(angle.cos() * 64.0).round_ties_even() as i8,
(angle.sin() * 64.0).round_ties_even() as i8,
]);
}
}
texels
}
/// Queue clock modulo the native ten-second wave scroll period.
#[must_use]
pub fn legacy_embm_phase(clock_ms: u32) -> f32 {
(clock_ms % 10_000) as f32 / 10_000.0
}
/// Native reflection coordinates: U reverses world Y and V reverses world X.
#[must_use]
pub fn legacy_embm_uv(world_xy: [f32; 2], bounds: [f32; 4]) -> Option<[f32; 2]> {
let span = [bounds[2] - bounds[0], bounds[3] - bounds[1]];
if !world_xy
.into_iter()
.chain(bounds)
.chain(span)
.all(f32::is_finite)
|| span.iter().any(|&value| value <= 0.0)
{
return None;
}
Some([
1.0 - (world_xy[1] - bounds[1]) / span[1],
1.0 - (world_xy[0] - bounds[0]) / span[0],
])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wave_bytes_match_native_generation_and_clock_wraps_without_float_time_loss() {
let texels = legacy_embm_bump_map();
assert_eq!(texels.len(), 128 * 128);
assert_eq!(
&texels[..16],
&[
[64, 0],
[64, 6],
[63, 12],
[61, 19],
[59, 24],
[56, 30],
[53, 36],
[49, 41],
[45, 45],
[41, 49],
[36, 53],
[30, 56],
[24, 59],
[19, 61],
[12, 63],
[6, 64],
]
);
assert_eq!(texels[16], [0, 64]);
assert_eq!(texels[32], [-64, 0]);
assert_eq!(texels[48], [0, -64]);
let native_hash = texels
.iter()
.flatten()
.fold(0xcbf29ce484222325u64, |hash, &byte| {
(hash ^ u64::from(byte as u8)).wrapping_mul(0x100000001b3)
});
assert_eq!(native_hash, 0x0b92dc6a9e482625);
assert_eq!(legacy_embm_phase(0), 0.0);
assert_eq!(legacy_embm_phase(5_000), 0.5);
assert_eq!(legacy_embm_phase(10_000), 0.0);
assert_eq!(legacy_embm_phase(u32::MAX), 0.7295);
}
#[test]
fn mapping_uses_the_reflective_rectangle_and_keeps_axis_order() {
let bounds = [100.0, 200.0, 300.0, 600.0];
assert_eq!(legacy_embm_uv([100.0, 200.0], bounds), Some([1.0, 1.0]));
assert_eq!(legacy_embm_uv([300.0, 600.0], bounds), Some([0.0, 0.0]));
assert_eq!(legacy_embm_uv([150.0, 500.0], bounds), Some([0.25, 0.75]));
assert_eq!(legacy_embm_uv([0.0; 2], [1.0; 4]), None);
assert_eq!(legacy_embm_uv([f32::NAN, 0.0], bounds), None);
}
}
+2 -1
View File
@@ -73,7 +73,8 @@ MSH-запрос со свободной preview-камерой; это не п
среде, перехватить DirectDraw/Direct3D calls и surface flips, сохранить state среде, перехватить DirectDraw/Direct3D calls и surface flips, сохранить state
log на минимальных сценах с одним типом материала. log на минимальных сценах с одним типом материала.
Горизонтальные грани Land.msh с флагом `0x20000` используют плоское Vulkan-отражение; shifted-камера, волны и native EMBM combiner остаются заглушкой `planar_reflection_embm_unsupported`. Горизонтальные грани Land.msh с флагом `0x20000` используют Vulkan-отражение и исходную карту волн Du/Dv; перенос её смещений в обычное зеркало приближённый: `planar_reflection_shifted_camera_unsupported`.
Raw queue clock задаёт фазу волн отдельно от времени суток и sky elapsed; старые захваты без него считаются в `captured_water_phase_unverified`.
Один target отражает ближайшую активную высоту воды; остальные высоты и неплоские грани считаются в `planar_reflection_other_planes_unsupported` и `planar_reflection_nonplanar_unsupported` и сохраняют обычный terrain shader. Один target отражает ближайшую активную высоту воды; остальные высоты и неплоские грани считаются в `planar_reflection_other_planes_unsupported` и `planar_reflection_nonplanar_unsupported` и сохраняют обычный terrain shader.
`terrain_face8_pipeline_unverified` теперь считает только исходные грани с `0x8` без `0x20000`; сама семантика оставшихся специальных проходов ещё не установлена. `terrain_face8_pipeline_unverified` теперь считает только исходные грани с `0x8` без `0x20000`; сама семантика оставшихся специальных проходов ещё не установлена.
+26 -3
View File
@@ -1161,9 +1161,32 @@ lightmap state передаются в `VulkanStaticMaterial`, а shader пос
записи отражения привязан отдельный нейтральный descriptor, чтобы проход записи отражения привязан отдельный нейтральный descriptor, чтобы проход
не читал собственное вложение. не читал собственное вложение.
Такое зеркало ещё не повторяет shifted-камеру и EMBM оригинала: искажение Волны не требуют готовой текстуры из ресурсов: Terrain.dll RVA `0x2BF50`
отражения волнами пока отсутствует и учитывается в создаёт карту 128×128 со знаковыми парами Du/Dv. Для нормированных координат
`planar_reflection_embm_unsupported`. Один target обслуживает ближайшую текселя `x` и `y` угол равен `(x + y − 1) × 12,566360473632812`, а компоненты —
округлённые `64 cos(угол)` и `64 sin(угол)`. Все 32 768 байт Rust-генератора
сверены с исполнением этой функции DLL при игровом x87 CW `0x007F`.
Рябь привязана к водоёму, поэтому не плывёт вслед за экраном. Конструктор
ландшафта собирает прямоугольник всех отражающих граней; U меняется против
мировой Y, V — против X. Эти координаты повторяются сто раз, к обеим прибавляется
`(queue_clock % 10000) / 10000`. Это самостоятельный clock: время суток и
относительный clock неба не задают фазу волн. Захват хранит исходный
`sky_uv_clock.raw_clock`; ограниченный replay фиксирует его, интерактивный
продолжает с этого значения. Старые захваты без raw clock явно считаются в
`captured_water_phase_unverified`.
Карта Du/Dv смещает координаты следующей текстуры с диагональными коэффициентами
`0,01`, без изменения яркости. Это правило
[Direct3D BUMPENVMAP](https://learn.microsoft.com/en-us/windows/win32/direct3d9/bump-mapping-formulas).
В Vulkan карта хранится в двух каналах несжатой текстуры и возвращается к
знаковым значениям перед вычислением смещения.
Обычное зеркало ещё не повторяет shifted-камеру оригинала: смещения Du/Dv
сейчас переводятся из координат водоёма в текущую зеркальную проекцию.
Это приближение учитывается в `planar_reflection_shifted_camera_unsupported`;
совпадение генератора волн не доказывает совпадения итогового отражения.
Один target обслуживает ближайшую
активную высоту воды; другие высоты и негоризонтальные поверхности явно активную высоту воды; другие высоты и негоризонтальные поверхности явно
считаются как неподдержанные. Это промежуточный водный проход, а не полная считаются как неподдержанные. Это промежуточный водный проход, а не полная
приёмка воды или лавы. приёмка воды или лавы.