aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md3
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.frag19
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.frag.spvbin12292 -> 14512 bytes
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.vert6
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.vert.spvbin20300 -> 20384 bytes
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/reflection.rs129
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke.rs13
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs47
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs20
-rw-r--r--apps/fparkan-game/src/camera_capture.rs1
-rw-r--r--apps/fparkan-game/src/main.rs52
-rw-r--r--crates/fparkan-render/src/lib.rs2
-rw-r--r--crates/fparkan-render/src/water.rs108
-rw-r--r--docs/appendices/knowledge-boundaries.md3
-rw-r--r--docs/tomes/05-render.md29
15 files changed, 390 insertions, 42 deletions
diff --git a/README.md b/README.md
index eaf7636..1aac809 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,8 @@
Рельеф переключает исходные LOD0/LOD1 по ячейкам относительно текущей камеры;
под основаниями строений сохраняется подробная сетка.
Горизонтальная вода использует плоское отражение ближайшей активной высоты;
-волны EMBM и shifted-камера оригинала пока не воспроизводятся.
+волны используют карту Du/Dv и часы оригинала, а shifted-камера отражения
+пока заменена обычным зеркалом.
## Запуск
diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.frag b/adapters/fparkan-render-vulkan/shaders/triangle.frag
index ec0acdb..5d10152 100644
--- a/adapters/fparkan-render-vulkan/shaders/triangle.frag
+++ b/adapters/fparkan-render-vulkan/shaders/triangle.frag
@@ -9,7 +9,7 @@ layout(location = 5) in vec3 in_base_diffuse;
layout(location = 6) in vec3 in_base_specular;
layout(location = 7) in vec3 in_overlay_diffuse;
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(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 = 3) uniform sampler2D overlay_detail;
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 {
mat4 clip_from_world;
@@ -30,6 +31,8 @@ layout(std140, set = 0, binding = 4) uniform FrameConstants {
vec4 fog_color;
vec4 fog_distances;
vec4 camera_position;
+ vec4 embm_bounds;
+ vec4 embm_wave;
} frame;
layout(push_constant) uniform MaterialConstants {
@@ -69,11 +72,23 @@ layout(push_constant) uniform MaterialConstants {
void main() {
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;
}
if ((mode & 64) != 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;
out_color = vec4(mix(rgb * in_color, frame.fog_color.rgb, in_fog_factor), material.diffuse_alpha);
if (out_color.a < material.alpha_cutoff) {
diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv b/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv
index 84c8a0c..de3ace6 100644
--- a/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv
+++ b/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv
Binary files differ
diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.vert b/adapters/fparkan-render-vulkan/shaders/triangle.vert
index 889a379..8e04536 100644
--- a/adapters/fparkan-render-vulkan/shaders/triangle.vert
+++ b/adapters/fparkan-render-vulkan/shaders/triangle.vert
@@ -19,7 +19,7 @@ layout(location = 5) out vec3 out_base_diffuse;
layout(location = 6) out vec3 out_base_specular;
layout(location = 7) out vec3 out_overlay_diffuse;
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 {
mat4 clip_from_world;
@@ -33,6 +33,8 @@ layout(std140, set = 0, binding = 4) uniform FrameConstants {
vec4 fog_color;
vec4 fog_distances;
vec4 camera_position;
+ vec4 embm_bounds;
+ vec4 embm_wave;
} frame;
layout(std430, set = 0, binding = 5) readonly buffer MaterialSpecular {
@@ -242,7 +244,7 @@ LightingTerms native_material_lighting(
}
void main() {
- out_world_z = in_position.z;
+ out_world_position = in_position;
out_color = in_color;
out_uv = in_uv;
out_detail_uv = in_detail_uv;
diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv b/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv
index ff28c23..439e2a0 100644
--- a/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv
+++ b/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv
Binary files differ
diff --git a/adapters/fparkan-render-vulkan/src/ffi/reflection.rs b/adapters/fparkan-render-vulkan/src/ffi/reflection.rs
index eb4025e..62a0d1e 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/reflection.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/reflection.rs
@@ -36,14 +36,42 @@ pub(super) fn mesh_reflection_planes(mesh: &super::VulkanStaticMesh) -> Vec<Opti
.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.
pub(super) struct ReflectionResources {
device: ash::Device,
pub layout: vk::DescriptorSetLayout,
pool: vk::DescriptorPool,
sampler: vk::Sampler,
+ bump_sampler: vk::Sampler,
pub sets: [vk::DescriptorSet; 2],
dummy: Option<VulkanAllocatedImage>,
+ bump: Option<VulkanAllocatedImage>,
pub color: Option<VulkanAllocatedImage>,
depth: Option<VulkanDepthAttachment>,
pub framebuffer: vk::Framebuffer,
@@ -54,25 +82,31 @@ impl ReflectionResources {
instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe,
command_pool: vk::CommandPool,
+ enabled: bool,
) -> Result<Self, VulkanSmokeRendererError> {
let mut owned = Self {
device: device.device().clone(),
layout: vk::DescriptorSetLayout::null(),
pool: vk::DescriptorPool::null(),
sampler: vk::Sampler::null(),
+ bump_sampler: vk::Sampler::null(),
sets: [vk::DescriptorSet::null(); 2],
dummy: None,
+ bump: None,
color: None,
depth: None,
framebuffer: vk::Framebuffer::null(),
};
- let binding = vk::DescriptorSetLayoutBinding::default()
- .binding(0)
- .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
- .descriptor_count(1)
- .stage_flags(vk::ShaderStageFlags::FRAGMENT);
- let layout_info =
- vk::DescriptorSetLayoutCreateInfo::default().bindings(std::slice::from_ref(&binding));
+ let bindings: Vec<_> = (0..2)
+ .map(|binding| {
+ vk::DescriptorSetLayoutBinding::default()
+ .binding(binding)
+ .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
+ .descriptor_count(1)
+ .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.
owned.layout = unsafe {
owned
@@ -85,7 +119,7 @@ impl ReflectionResources {
})?;
let size = vk::DescriptorPoolSize::default()
.ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
- .descriptor_count(2);
+ .descriptor_count(4);
let pool_info = vk::DescriptorPoolCreateInfo::default()
.max_sets(2)
.pool_sizes(std::slice::from_ref(&size));
@@ -136,6 +170,42 @@ impl ReflectionResources {
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 {
owned.write_image(set, owned.dummy.as_ref().expect("dummy image exists").view);
}
@@ -255,13 +325,35 @@ impl ReflectionResources {
.sampler(self.sampler)
.image_view(view)
.image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL);
- let write = vk::WriteDescriptorSet::default()
- .dst_set(set)
- .dst_binding(0)
- .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
- .image_info(std::slice::from_ref(&image));
+ let bump = self
+ .bump
+ .as_ref()
+ .or(self.dummy.as_ref())
+ .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.
- 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_descriptor_pool(self.pool, 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);
for image in self
.color
.iter()
.chain(self.depth.iter().map(|depth| &depth.image))
.chain(self.dummy.iter())
+ .chain(self.bump.iter())
{
self.device.destroy_image_view(image.view, None);
self.device.destroy_image(image.image, None);
@@ -320,12 +414,19 @@ mod tests {
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)]);
mesh.vertices[2].position[2] = 151.0;
assert_eq!(mesh_reflection_planes(&mesh), vec![None]);
mesh.vertices[2].position[2] = 150.0;
mesh.draw_ranges[0].terrain_reflection = false;
assert_eq!(mesh_reflection_planes(&mesh), vec![None]);
+ assert_eq!(mesh_reflection_bounds(&mesh), None);
for cull in [
fparkan_render::LegacyCullMode::Disabled,
fparkan_render::LegacyCullMode::BackFace,
diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
index 5968a47..bd745ad 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
@@ -373,12 +373,17 @@ impl VulkanSmokeRenderer {
plane_heights.sort_unstable();
plane_heights.dedup();
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));
}
if unsupported_nonplanar != 0 {
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 {
instance: Some(instance),
validation,
@@ -424,7 +429,7 @@ impl VulkanSmokeRenderer {
material_specular_dirty: true,
readback_enabled: false,
camera: create_info.camera,
- frame_uniforms: super::VulkanFrameUniforms::from_camera(create_info.camera),
+ frame_uniforms,
point_lights: Vec::new(),
point_lights_explicit: false,
frame_sync: Vec::new(),
@@ -1571,8 +1576,8 @@ impl VulkanSmokeRenderer {
});
}
let mut bytes = frame.to_ne_bytes();
- let length = bytes.len();
- bytes[length - 4..].copy_from_slice(&height.to_ne_bytes());
+ let offset = super::smoke_types::REFLECTION_CLIP_HEIGHT_OFFSET;
+ bytes[offset..offset + 4].copy_from_slice(&height.to_ne_bytes());
Ok(bytes)
})
.transpose()?;
diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs
index bc8aedd..90fd7b4 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs
@@ -192,7 +192,8 @@ impl Default for VulkanPointLight {
/// every device. Four directional lights and one point light fit in a small
/// dynamic UBO while the push-constant range remains available for material
/// 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.
pub const VULKAN_FRAME_SLOT_COUNT: usize = 2;
@@ -235,6 +236,10 @@ pub struct VulkanFrameUniforms {
pub fog_end: f32,
/// World-space camera position used for fog distance.
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 {
@@ -258,6 +263,8 @@ impl VulkanFrameUniforms {
fog_start: f32::MAX,
fog_end: f32::MAX,
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_start, self.fog_end])
.chain(self.camera_position)
+ .chain(self.embm_bounds)
+ .chain([self.embm_phase])
.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_start * self.fog_start).is_finite()
&& (self.fog_end * self.fog_end).is_finite()))
@@ -364,6 +378,13 @@ impl VulkanFrameUniforms {
self.camera_position[2],
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];
for (index, value) in values.into_iter().enumerate() {
let offset = index * std::mem::size_of::<f32>();
@@ -862,6 +883,8 @@ mod static_mesh_tests {
fog_start: 13.0,
fog_end: 14.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();
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(336), 13.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]
diff --git a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs
index 4365cd2..af20dfa 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs
@@ -249,14 +249,18 @@ pub(super) fn create_swapchain_resources(
};
partial.frame_uniform_stride = frame_uniform_stride;
partial.frame_uniform_buffer = Some(frame_uniform_buffer);
- partial.reflection =
- match super::reflection::ReflectionResources::new(instance, device, command_pool) {
- Ok(reflection) => Some(reflection),
- Err(error) => {
- destroy_partial_swapchain_resources(device, command_pool, partial);
- return Err(error);
- }
- };
+ partial.reflection = match super::reflection::ReflectionResources::new(
+ instance,
+ device,
+ command_pool,
+ reflection_enabled,
+ ) {
+ 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_specular_buffer, material_specular_stride) =
match create_material_specular_buffer(instance, device, material_count) {
diff --git a/apps/fparkan-game/src/camera_capture.rs b/apps/fparkan-game/src/camera_capture.rs
index bc16807..dccb738 100644
--- a/apps/fparkan-game/src/camera_capture.rs
+++ b/apps/fparkan-game/src/camera_capture.rs
@@ -23,6 +23,7 @@ pub(super) struct LegacyCameraCapture {
pub(super) struct SkyUvClock {
pub(super) verified: bool,
pub(super) elapsed_milliseconds: u32,
+ pub(super) raw_clock: Option<u32>,
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs
index 920468f..5494d65 100644
--- a/apps/fparkan-game/src/main.rs
+++ b/apps/fparkan-game/src/main.rs
@@ -4638,6 +4638,7 @@ fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] {
}
struct StaticPreviewScene {
+ fixed_water_clock_ms: Option<u32>,
mesh: VulkanStaticMesh,
dynamic_vertex_start: usize,
terrain_lod: Option<terrain_lod::TerrainLodDraws>,
@@ -8554,7 +8555,23 @@ fn static_preview_mesh_and_materials(
};
let camera = view_camera.vulkan_camera();
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 {
+ fixed_water_clock_ms,
mesh,
dynamic_vertex_start,
terrain_lod,
@@ -9369,6 +9386,7 @@ fn run_static_vulkan_mode(
}
struct StaticVulkanApp {
+ fixed_water_clock_ms: Option<u32>,
mesh: VulkanStaticMesh,
dynamic_vertex_start: usize,
terrain_lod: Option<terrain_lod::TerrainLodDraws>,
@@ -9497,6 +9515,7 @@ impl StaticVulkanApp {
dynamic_shadow_casters: preview.dynamic_shadow_casters,
sun_occlusion_range_indices: preview.sun_occlusion_range_indices,
terrain: preview.terrain,
+ fixed_water_clock_ms: preview.fixed_water_clock_ms,
world_range_indices,
world_range_index_counts,
camera: preview.camera,
@@ -10333,6 +10352,21 @@ impl StaticVulkanApp {
&mut self.material_animations,
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 {
renderer
.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 preview = StaticPreviewScene {
+ fixed_water_clock_ms: None,
terrain_lod: None,
dynamic_vertex_start: 3,
mesh: VulkanStaticMesh {
@@ -15730,13 +15765,18 @@ mod tests {
let mut value: serde_json::Value =
serde_json::from_slice(include_bytes!("../testdata/legacy-camera-parented.json"))
.unwrap();
- value["sky_uv_clock"] =
- serde_json::json!({"verified": true, "elapsed_milliseconds": 4051271258_u32});
+ value["sky_uv_clock"] = serde_json::json!({"verified": true, "elapsed_milliseconds": 4051271258_u32, "raw_clock": 272342176_u32});
let capture = parse_legacy_camera_capture(&serde_json::to_vec(&value).unwrap()).unwrap();
- assert_eq!(
- capture.sky_uv_clock.unwrap().elapsed_milliseconds,
- 4051271258
- );
+ let clock = capture.sky_uv_clock.unwrap();
+ assert_eq!(clock.elapsed_milliseconds, 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);
assert!(parse_legacy_camera_capture(&serde_json::to_vec(&value).unwrap()).is_err());
}
diff --git a/crates/fparkan-render/src/lib.rs b/crates/fparkan-render/src/lib.rs
index 2ad4789..db0171f 100644
--- a/crates/fparkan-render/src/lib.rs
+++ b/crates/fparkan-render/src/lib.rs
@@ -1,6 +1,8 @@
#![forbid(unsafe_code)]
//! Camera mathematics and graphics pipeline state.
+pub mod water;
+
/// A 64-byte transform block returned by the original Terrain camera ABI.
///
/// The original uses two selector-dependent transform pointers. Their exact
diff --git a/crates/fparkan-render/src/water.rs b/crates/fparkan-render/src/water.rs
new file mode 100644
index 0000000..0c7ad2a
--- /dev/null
+++ b/crates/fparkan-render/src/water.rs
@@ -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);
+ }
+}
diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md
index a48bec5..1af4ad5 100644
--- a/docs/appendices/knowledge-boundaries.md
+++ b/docs/appendices/knowledge-boundaries.md
@@ -73,7 +73,8 @@ MSH-запрос со свободной preview-камерой; это не пÐ
среде, перехватить DirectDraw/Direct3D calls и surface flips, сохранить state
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.
`terrain_face8_pipeline_unverified` теперь считает только исходные грани с `0x8` без `0x20000`; сама семантика оставшихся специальных проходов ещё не установлена.
diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md
index e374ef0..a7a1396 100644
--- a/docs/tomes/05-render.md
+++ b/docs/tomes/05-render.md
@@ -1161,9 +1161,32 @@ lightmap state передаются в `VulkanStaticMaterial`, а shader посÐ
записи отражения привязан отдельный нейтральный descriptor, чтобы проход
не читал собственное вложение.
-Такое зеркало ещё не повторяет shifted-камеру и EMBM оригинала: искажение
-отражения волнами пока отсутствует и учитывается в
-`planar_reflection_embm_unsupported`. Один target обслуживает ближайшую
+Волны не требуют готовой текстуры из ресурсов: Terrain.dll RVA `0x2BF50`
+создаёт карту 128×128 со знаковыми парами Du/Dv. Для нормированных координат
+текселя `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 обслуживает ближайшую
активную высоту воды; другие высоты и негоризонтальные поверхности явно
считаются как неподдержанные. Это промежуточный водный проход, а не полная
приёмка воды или лавы.