aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md4
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.frag34
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.frag.spvbin10632 -> 14716 bytes
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.vert4
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.vert.spvbin20196 -> 20384 bytes
-rw-r--r--adapters/fparkan-render-vulkan/src/asset_mesh.rs27
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi.rs1
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/reflection.rs449
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/resources.rs8
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke.rs526
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs58
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs73
-rw-r--r--apps/fparkan-game/src/camera_capture.rs1
-rw-r--r--apps/fparkan-game/src/main.rs77
-rw-r--r--apps/fparkan-game/src/terrain_lod.rs1
-rw-r--r--crates/fparkan-render/src/lib.rs42
-rw-r--r--crates/fparkan-render/src/water.rs216
-rw-r--r--docs/appendices/knowledge-boundaries.md12
-rw-r--r--docs/reference/msh.md5
-rw-r--r--docs/tomes/05-render.md68
20 files changed, 1409 insertions, 197 deletions
diff --git a/README.md b/README.md
index dea37bc..4e16908 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,10 @@
позе.
Рельеф переключает исходные LOD0/LOD1 по ячейкам относительно текущей камеры;
под основаниями строений сохраняется подробная сетка.
+Горизонтальная вода использует плоское отражение ближайшей активной высоты;
+волны используют карту Du/Dv и часы оригинала. Камера отражения смотрит вверх
+из отражённой точки наблюдения через прямоугольник водоёма; глубина и часть
+параметров отражённого вида пока заимствуются из основного прохода.
## Запуск
diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.frag b/adapters/fparkan-render-vulkan/shaders/triangle.frag
index 6a6e752..2c71278 100644
--- a/adapters/fparkan-render-vulkan/shaders/triangle.frag
+++ b/adapters/fparkan-render-vulkan/shaders/triangle.frag
@@ -9,12 +9,15 @@ 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 vec3 in_world_position;
layout(location = 0) out vec4 out_color;
layout(set = 0, binding = 0) uniform sampler2D base_color;
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;
@@ -28,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 {
@@ -66,6 +71,34 @@ layout(push_constant) uniform MaterialConstants {
} material;
void main() {
+ int mode = int(round(material.material_mode));
+ 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;
+ if ((mode & 128) != 0) {
+ reflection_uv = plane_uv + displacement;
+ } else {
+ // shortcut: non-perspective API cameras retain the projective mirror fallback.
+ 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) {
+ discard;
+ }
+ return;
+ }
vec2 base_uv = vec2(material.base_page_x, material.base_page_y)
+ in_uv * vec2(material.base_page_w, material.base_page_h);
vec2 detail_uv = vec2(material.detail_page_x, material.detail_page_y)
@@ -78,7 +111,6 @@ void main() {
vec4 detail = texture(base_detail, detail_uv);
vec4 overlay = texture(overlay_color, overlay_uv);
vec4 overlay_detail_sample = texture(overlay_detail, overlay_detail_uv);
- int mode = int(round(material.material_mode));
int combiner_mode = mode & 3;
// Native sky mode 4 combines nebula and stars as
diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv b/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv
index 7b7c3da..6deef9d 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 078a0d1..8e04536 100644
--- a/adapters/fparkan-render-vulkan/shaders/triangle.vert
+++ b/adapters/fparkan-render-vulkan/shaders/triangle.vert
@@ -19,6 +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 vec3 out_world_position;
layout(std140, set = 0, binding = 4) uniform FrameConstants {
mat4 clip_from_world;
@@ -32,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 {
@@ -241,6 +244,7 @@ LightingTerms native_material_lighting(
}
void main() {
+ 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 4fc9a1b..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/asset_mesh.rs b/adapters/fparkan-render-vulkan/src/asset_mesh.rs
index 826e89a..d813b91 100644
--- a/adapters/fparkan-render-vulkan/src/asset_mesh.rs
+++ b/adapters/fparkan-render-vulkan/src/asset_mesh.rs
@@ -866,6 +866,7 @@ fn append_node_fallback_batch(
batch_flags: batch.batch_flags,
pipeline_state: world_pipeline_state(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
});
Ok(())
@@ -1226,6 +1227,7 @@ fn static_terrain_draw_ranges(
.ok_or(VulkanAssetMeshError::IndexOutOfRange)?;
let material_index = first_face.material_tag;
let terrain_base_only = first_face.flags.0 & 0x400 == 0;
+ let terrain_reflection = first_face.flags.0 & 0x20000 != 0;
let mut end_face = start_face + 1;
while end_face < lod0.len() {
let next_face = terrain
@@ -1234,6 +1236,10 @@ fn static_terrain_draw_ranges(
.ok_or(VulkanAssetMeshError::IndexOutOfRange)?;
if next_face.material_tag != material_index
|| (next_face.flags.0 & 0x400 == 0) != terrain_base_only
+ || (next_face.flags.0 & 0x20000 != 0) != terrain_reflection
+ || (terrain_reflection
+ && terrain.positions[usize::from(first_face.vertices[0])][2]
+ != terrain.positions[usize::from(next_face.vertices[0])][2])
{
break;
}
@@ -1253,6 +1259,7 @@ fn static_terrain_draw_ranges(
batch_flags: 0,
pipeline_state: world_pipeline_state(),
terrain_base_only,
+ terrain_reflection,
alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
});
start_face = end_face;
@@ -1299,6 +1306,7 @@ fn static_model_indices_and_ranges(
batch_flags: batch.batch_flags,
pipeline_state: world_pipeline_state(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
});
}
@@ -1506,6 +1514,7 @@ mod tests {
batch_flags: 0,
pipeline_state: world_pipeline_state(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
}]
);
@@ -2036,6 +2045,7 @@ mod tests {
batch_flags: 0,
pipeline_state: world_pipeline_state(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference:
VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
},
@@ -2047,6 +2057,7 @@ mod tests {
batch_flags: 0,
pipeline_state: world_pipeline_state(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference:
VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
},
@@ -2298,5 +2309,21 @@ mod tests {
assert!(ranges.iter().all(|range| {
range.alpha_test_reference == VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE
}));
+ terrain.faces = vec![
+ terrain_face_with_tag_and_flags(0xff04, 0x400, [0, 1, 2]),
+ terrain_face_with_tag_and_flags(0xff04, 0x20400, [0, 1, 2]),
+ terrain_face_with_tag_and_flags(0xff04, 0x20400, [0, 1, 2]),
+ terrain_face_with_tag_and_flags(0xff04, 0x20400, [3, 4, 5]),
+ ];
+ terrain.positions.extend([[0.0, 0.0, 10.0]; 3]);
+ set_terrain_lod_slots(&mut terrain, &[[0, u16::MAX]], &[[0, 4]]);
+ let assignments = static_terrain_lod0_assignments(&terrain).unwrap();
+ let ranges = static_terrain_draw_ranges(&terrain, &assignments).unwrap();
+ assert_eq!(ranges.len(), 3);
+ assert!(!ranges[0].terrain_reflection);
+ assert!(ranges[1].terrain_reflection);
+ assert_eq!(ranges[1].index_count, 6);
+ assert!(ranges[2].terrain_reflection);
+ assert_eq!(ranges[2].index_count, 3);
}
}
diff --git a/adapters/fparkan-render-vulkan/src/ffi.rs b/adapters/fparkan-render-vulkan/src/ffi.rs
index b98fa0d..030b8ac 100644
--- a/adapters/fparkan-render-vulkan/src/ffi.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi.rs
@@ -11,6 +11,7 @@
mod asset_mesh;
mod capabilities;
mod instance;
+mod reflection;
mod resources;
mod runtime;
mod smoke;
diff --git a/adapters/fparkan-render-vulkan/src/ffi/reflection.rs b/adapters/fparkan-render-vulkan/src/ffi/reflection.rs
new file mode 100644
index 0000000..62a0d1e
--- /dev/null
+++ b/adapters/fparkan-render-vulkan/src/ffi/reflection.rs
@@ -0,0 +1,449 @@
+#![allow(unsafe_code)]
+
+use super::{
+ VulkanAllocatedImage, VulkanDepthAttachment, VulkanInstanceProbe, VulkanLogicalDeviceProbe,
+ VulkanSmokeRendererError,
+};
+use ash::vk;
+
+pub(super) fn reflected_pipeline_state(
+ mut state: fparkan_render::LegacyPipelineState,
+) -> fparkan_render::LegacyPipelineState {
+ use fparkan_render::LegacyCullMode;
+ state.cull = match state.cull {
+ LegacyCullMode::Disabled => LegacyCullMode::Disabled,
+ LegacyCullMode::BackFace => LegacyCullMode::FrontFace,
+ LegacyCullMode::FrontFace => LegacyCullMode::BackFace,
+ };
+ state
+}
+
+pub(super) fn mesh_reflection_planes(mesh: &super::VulkanStaticMesh) -> Vec<Option<f32>> {
+ mesh.draw_ranges
+ .iter()
+ .map(|range| {
+ if !range.terrain_reflection || range.index_count == 0 {
+ return None;
+ }
+ let indices = &mesh.indices[range.first_index as usize
+ ..range.first_index as usize + range.index_count as usize];
+ let height = mesh.vertices[indices[0] as usize].position[2];
+ indices
+ .iter()
+ .all(|&index| (mesh.vertices[index as usize].position[2] - height).abs() <= 1.0e-4)
+ .then_some(height)
+ })
+ .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,
+}
+
+impl ReflectionResources {
+ pub fn new(
+ 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 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
+ .device
+ .create_descriptor_set_layout(&layout_info, None)
+ }
+ .map_err(|result| VulkanSmokeRendererError::VulkanOperation {
+ context: "vkCreateDescriptorSetLayout(reflection)",
+ result,
+ })?;
+ let size = vk::DescriptorPoolSize::default()
+ .ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
+ .descriptor_count(4);
+ let pool_info = vk::DescriptorPoolCreateInfo::default()
+ .max_sets(2)
+ .pool_sizes(std::slice::from_ref(&size));
+ // SAFETY: The pool owns only the two reflection descriptor sets below.
+ owned.pool =
+ unsafe { owned.device.create_descriptor_pool(&pool_info, None) }.map_err(|result| {
+ VulkanSmokeRendererError::VulkanOperation {
+ context: "vkCreateDescriptorPool(reflection)",
+ result,
+ }
+ })?;
+ let layouts = [owned.layout; 2];
+ let allocation = vk::DescriptorSetAllocateInfo::default()
+ .descriptor_pool(owned.pool)
+ .set_layouts(&layouts);
+ // SAFETY: The pool has capacity for these two live layouts.
+ let sets =
+ unsafe { owned.device.allocate_descriptor_sets(&allocation) }.map_err(|result| {
+ VulkanSmokeRendererError::VulkanOperation {
+ context: "vkAllocateDescriptorSets(reflection)",
+ result,
+ }
+ })?;
+ owned.sets.copy_from_slice(&sets);
+ let sampler = vk::SamplerCreateInfo::default()
+ .mag_filter(vk::Filter::LINEAR)
+ .min_filter(vk::Filter::LINEAR)
+ .address_mode_u(vk::SamplerAddressMode::CLAMP_TO_EDGE)
+ .address_mode_v(vk::SamplerAddressMode::CLAMP_TO_EDGE)
+ .address_mode_w(vk::SamplerAddressMode::CLAMP_TO_EDGE);
+ // SAFETY: No optional device feature is used by this sampler.
+ owned.sampler =
+ unsafe { owned.device.create_sampler(&sampler, None) }.map_err(|result| {
+ VulkanSmokeRendererError::VulkanOperation {
+ context: "vkCreateSampler(reflection)",
+ result,
+ }
+ })?;
+ // Material images can be replaced by animation, so the dummy has its own lifetime.
+ owned.dummy = Some(super::create_static_texture_image(
+ instance,
+ device,
+ command_pool,
+ &super::VulkanStaticTexture {
+ width: 1,
+ height: 1,
+ rgba8: vec![255; 4],
+ 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);
+ }
+ Ok(owned)
+ }
+
+ pub fn create_target(
+ &mut self,
+ instance: &VulkanInstanceProbe,
+ device: &VulkanLogicalDeviceProbe,
+ extent: (u32, u32),
+ format: i32,
+ depth_request: fparkan_platform::DepthStencilSupport,
+ render_pass: vk::RenderPass,
+ ) -> Result<(), VulkanSmokeRendererError> {
+ let info = vk::ImageCreateInfo::default()
+ .image_type(vk::ImageType::TYPE_2D)
+ .format(vk::Format::from_raw(format))
+ .extent(vk::Extent3D {
+ width: extent.0,
+ height: extent.1,
+ depth: 1,
+ })
+ .mip_levels(1)
+ .array_layers(1)
+ .samples(vk::SampleCountFlags::TYPE_1)
+ .tiling(vk::ImageTiling::OPTIMAL)
+ .usage(vk::ImageUsageFlags::COLOR_ATTACHMENT | vk::ImageUsageFlags::SAMPLED)
+ .sharing_mode(vk::SharingMode::EXCLUSIVE)
+ .initial_layout(vk::ImageLayout::UNDEFINED);
+ // SAFETY: This target uses the validated non-zero swapchain extent and format.
+ let image = unsafe { self.device.create_image(&info, None) }.map_err(|result| {
+ VulkanSmokeRendererError::VulkanOperation {
+ context: "vkCreateImage(reflection)",
+ result,
+ }
+ })?;
+ self.color = Some(VulkanAllocatedImage {
+ image,
+ memory: vk::DeviceMemory::null(),
+ view: vk::ImageView::null(),
+ width: extent.0,
+ height: extent.1,
+ mip_levels: 1,
+ });
+ // SAFETY: The image belongs to this device and has not been bound yet.
+ let requirements = unsafe { self.device.get_image_memory_requirements(image) };
+ let memory_type_index = super::resources::find_memory_type(
+ instance,
+ device.physical_device(),
+ requirements.memory_type_bits,
+ vk::MemoryPropertyFlags::DEVICE_LOCAL,
+ )
+ .ok_or(VulkanSmokeRendererError::MissingMemoryType {
+ context: "reflection target",
+ })?;
+ let allocation = vk::MemoryAllocateInfo::default()
+ .allocation_size(requirements.size)
+ .memory_type_index(memory_type_index);
+ // SAFETY: The allocation follows this image's memory requirements.
+ let memory =
+ unsafe { self.device.allocate_memory(&allocation, None) }.map_err(|result| {
+ VulkanSmokeRendererError::VulkanOperation {
+ context: "vkAllocateMemory(reflection)",
+ result,
+ }
+ })?;
+ self.color.as_mut().expect("color image exists").memory = memory;
+ // SAFETY: The allocation is compatible with the image and offset zero.
+ unsafe { self.device.bind_image_memory(image, memory, 0) }.map_err(|result| {
+ VulkanSmokeRendererError::VulkanOperation {
+ context: "vkBindImageMemory(reflection)",
+ result,
+ }
+ })?;
+ let view_info = vk::ImageViewCreateInfo::default()
+ .image(image)
+ .view_type(vk::ImageViewType::TYPE_2D)
+ .format(vk::Format::from_raw(format))
+ .subresource_range(super::color_subresource_range(1));
+ // SAFETY: The image is bound and the view covers its single color subresource.
+ let view =
+ unsafe { self.device.create_image_view(&view_info, None) }.map_err(|result| {
+ VulkanSmokeRendererError::VulkanOperation {
+ context: "vkCreateImageView(reflection)",
+ result,
+ }
+ })?;
+ self.color.as_mut().expect("color image exists").view = view;
+ self.depth = Some(super::create_depth_attachment(
+ instance,
+ device,
+ extent,
+ depth_request,
+ )?);
+ let attachments = [view, self.depth.as_ref().expect("depth exists").image.view];
+ let info = vk::FramebufferCreateInfo::default()
+ .render_pass(render_pass)
+ .attachments(&attachments)
+ .width(extent.0)
+ .height(extent.1)
+ .layers(1);
+ // SAFETY: The target formats and extent match the shared render pass.
+ self.framebuffer =
+ unsafe { self.device.create_framebuffer(&info, None) }.map_err(|result| {
+ VulkanSmokeRendererError::VulkanOperation {
+ context: "vkCreateFramebuffer(reflection)",
+ result,
+ }
+ })?;
+ self.write_image(self.sets[1], view);
+ Ok(())
+ }
+
+ fn write_image(&self, set: vk::DescriptorSet, view: vk::ImageView) {
+ let image = vk::DescriptorImageInfo::default()
+ .sampler(self.sampler)
+ .image_view(view)
+ .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL);
+ 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(&writes, &[]) };
+ }
+}
+
+impl Drop for ReflectionResources {
+ fn drop(&mut self) {
+ // SAFETY: The parent waits for submissions and drops this owner before the render pass/device.
+ // Null handles also cover partial construction, including failures before memory binding.
+ unsafe {
+ 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);
+ self.device.free_memory(image.memory, None);
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{VulkanStaticDrawRange, VulkanStaticMesh, VulkanStaticVertex};
+
+ #[test]
+ fn reflection_requires_a_flat_flagged_surface_and_reverses_only_culling() {
+ let mut mesh = VulkanStaticMesh {
+ vertices: vec![
+ VulkanStaticVertex {
+ position: [0.0, 0.0, 150.0],
+ color: [1.0; 3],
+ normal: [0.0, 0.0, 1.0],
+ uv: [0.0; 2],
+ detail_uv: [0.0; 2],
+ overlay_alpha: 0.0,
+ };
+ 3
+ ],
+ indices: vec![0, 1, 2],
+ draw_ranges: vec![VulkanStaticDrawRange {
+ first_index: 0,
+ index_count: 3,
+ material_index: 0,
+ lightmap_index: 255,
+ batch_flags: 0,
+ terrain_base_only: false,
+ terrain_reflection: true,
+ pipeline_state: fparkan_render::LegacyPipelineState::default(),
+ 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,
+ fparkan_render::LegacyCullMode::FrontFace,
+ ] {
+ let mut state = mesh.draw_ranges[0].pipeline_state;
+ state.cull = cull;
+ state.alpha_test = true;
+ state.depth_bias = fparkan_render::LegacyDepthBias::ProjectedDecal;
+ assert_eq!(
+ reflected_pipeline_state(reflected_pipeline_state(state)),
+ state
+ );
+ let reflected = reflected_pipeline_state(state);
+ assert_eq!(reflected.depth, state.depth);
+ assert_eq!(reflected.blend, state.blend);
+ assert_eq!(reflected.alpha_test, state.alpha_test);
+ }
+ }
+}
diff --git a/adapters/fparkan-render-vulkan/src/ffi/resources.rs b/adapters/fparkan-render-vulkan/src/ffi/resources.rs
index fe71d12..498a472 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/resources.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/resources.rs
@@ -528,7 +528,7 @@ pub(super) fn create_static_texture_image(
})
}
-/// Allocates two aligned camera slots used by the dynamic frame UBO.
+/// Allocates a main and reflected view slot for each in-flight frame.
pub(super) fn create_frame_uniform_buffer(
instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe,
@@ -545,7 +545,7 @@ pub(super) fn create_frame_uniform_buffer(
)? / alignment
* alignment;
let size = stride
- .checked_mul(super::VULKAN_FRAME_SLOT_COUNT as u64)
+ .checked_mul(2 * super::VULKAN_FRAME_SLOT_COUNT as u64)
.ok_or(VulkanSmokeRendererError::InvalidStaticMesh {
context: "frame uniform buffer size overflow",
})?;
@@ -888,7 +888,7 @@ pub(super) fn write_frame_uniform(
.ok_or(VulkanSmokeRendererError::InvariantViolation {
context: "frame uniform slot offset",
})?;
- // SAFETY: The offset is one of the two allocated aligned slots and the
+ // SAFETY: The caller supplies an allocated, fence-safe main or reflection slot; the
// mapped range is exactly one complete frame block.
let mapped = unsafe {
device.device().map_memory(
@@ -1385,7 +1385,7 @@ fn create_host_visible_buffer(
Ok(VulkanAllocatedBuffer { buffer, memory })
}
-fn find_memory_type(
+pub(super) fn find_memory_type(
instance: &VulkanInstanceProbe,
physical_device: vk::PhysicalDevice,
type_bits: u32,
diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
index 1b50914..ef19ee8 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
@@ -357,6 +357,33 @@ impl VulkanSmokeRenderer {
.map(|material| material.material_index)
.collect()
};
+ let reflection_planes = super::reflection::mesh_reflection_planes(&create_info.mesh);
+ let unsupported_nonplanar = create_info
+ .mesh
+ .draw_ranges
+ .iter()
+ .zip(&reflection_planes)
+ .filter(|(range, plane)| range.terrain_reflection && plane.is_none())
+ .count();
+ let mut plane_heights = reflection_planes
+ .iter()
+ .flatten()
+ .map(|z| z.to_bits())
+ .collect::<Vec<_>>();
+ plane_heights.sort_unstable();
+ plane_heights.dedup();
+ if !plane_heights.is_empty() {
+ eprintln!("render_coverage planar_reflection_render_context_approx={} (native shifted window and bump UV; main-view depth range, sky/fog/FX and swapchain-sized target remain approximations; non-perspective cameras use mirror fallback)", 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,
@@ -379,6 +406,7 @@ impl VulkanSmokeRenderer {
.map(|range| range.index_count)
.collect(),
draw_ranges: create_info.mesh.draw_ranges.clone(),
+ reflection_planes,
draw_texture_indices,
draw_order: (0..create_info.mesh.draw_ranges.len()).collect(),
draw_range_sort_keys: vec![None; create_info.mesh.draw_ranges.len()],
@@ -401,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(),
@@ -521,6 +549,22 @@ impl VulkanSmokeRenderer {
self.frame_uniforms
}
+ fn active_reflection_plane(&self) -> Option<f32> {
+ // shortcut: one target serves the nearest active water height; add per-plane targets
+ // when accepting maps whose visible water surfaces have different heights.
+ self.draw_ranges
+ .iter()
+ .zip(&self.reflection_planes)
+ .filter(|(range, _)| range.index_count != 0)
+ .filter_map(|(_, plane)| *plane)
+ .filter(|&z| z < self.frame_uniforms.camera_position[2])
+ .min_by(|a, b| {
+ (self.frame_uniforms.camera_position[2] - a)
+ .abs()
+ .total_cmp(&(self.frame_uniforms.camera_position[2] - b).abs())
+ })
+ }
+
/// Replaces the camera for subsequent frames without recreating GPU resources.
///
/// # Errors
@@ -1516,6 +1560,34 @@ impl VulkanSmokeRenderer {
}
self.images_in_flight[image_index_usize] = in_flight_fence;
let frame_bytes = self.frame_uniforms.to_ne_bytes();
+ let reflection_bytes = self
+ .active_reflection_plane()
+ .map(|height| {
+ let mut frame = self.frame_uniforms;
+ frame.clip_from_world = fparkan_render::water::legacy_embm_reflection_clip(
+ frame.clip_from_world,
+ frame.camera_position,
+ height,
+ frame.embm_bounds,
+ )
+ .or_else(|| {
+ fparkan_render::reflect_clip_from_world_about_z(frame.clip_from_world, height)
+ })
+ .ok_or(VulkanSmokeRendererError::InvalidStaticCamera {
+ context: "non-finite reflected clip transform",
+ })?;
+ frame.camera_position[2] = 2.0 * height - frame.camera_position[2];
+ if !frame.is_finite() {
+ return Err(VulkanSmokeRendererError::InvalidStaticCamera {
+ context: "non-finite reflected camera position",
+ });
+ }
+ let mut bytes = frame.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()?;
{
let explicit_point_lights = self.point_lights_explicit;
let legacy_point_light = self.frame_uniforms.point_light;
@@ -1541,9 +1613,18 @@ impl VulkanSmokeRenderer {
device,
&resources.frame_uniform_buffer,
resources.frame_uniform_stride,
- self.current_frame,
+ self.current_frame * 2,
&frame_bytes,
)?;
+ if let Some(bytes) = &reflection_bytes {
+ write_frame_uniform(
+ device,
+ &resources.frame_uniform_buffer,
+ resources.frame_uniform_stride,
+ self.current_frame * 2 + 1,
+ bytes,
+ )?;
+ }
write_point_light_buffer(
device,
&resources.point_light_buffer,
@@ -1686,6 +1767,9 @@ impl VulkanSmokeRenderer {
self.textures_ref()?,
&self.texture_slot_indices,
&self.draw_range_base_pipeline_states,
+ self.draw_ranges
+ .iter()
+ .any(|range| range.terrain_reflection),
self.depth_request,
reuse_command_pool,
)?,
@@ -1741,6 +1825,16 @@ impl VulkanSmokeRenderer {
self.rebuild_draw_order();
self.draw_order_dirty = false;
}
+ let reflection_plane = self.active_reflection_plane();
+ let shifted_reflection = reflection_plane.is_some_and(|height| {
+ fparkan_render::water::legacy_embm_reflection_clip(
+ self.frame_uniforms.clip_from_world,
+ self.frame_uniforms.camera_position,
+ height,
+ self.frame_uniforms.embm_bounds,
+ )
+ .is_some()
+ });
let device = self.device_ref()?;
let swapchain = self.swapchain_ref()?;
let resources = self.resources_ref()?;
@@ -1787,185 +1881,269 @@ impl VulkanSmokeRenderer {
height: swapchain.report.plan.extent.1,
},
};
- let render_pass_info = vk::RenderPassBeginInfo::default()
- .render_pass(resources.render_pass)
- .framebuffer(resources.framebuffers[image_index])
- .render_area(render_area)
- .clear_values(&clear_values);
- // SAFETY: All commands target live frame resources owned by this renderer.
- unsafe {
- device.device().cmd_begin_render_pass(
- command_buffer,
- &render_pass_info,
- vk::SubpassContents::INLINE,
- );
- let vertex_buffers = [self.vertex_buffer_ref()?.buffer];
- let offsets = [0_u64];
- device
- .device()
- .cmd_bind_vertex_buffers(command_buffer, 0, &vertex_buffers, &offsets);
- device.device().cmd_bind_index_buffer(
- command_buffer,
- self.index_buffer_ref()?.buffer,
- 0,
- vk::IndexType::UINT32,
- );
- let mut viewport_depth = None;
- for &range_index in &self.draw_order {
- let range = self.draw_ranges.get(range_index).ok_or(
- VulkanSmokeRendererError::InvariantViolation {
- context: "stable draw order range handle",
- },
- )?;
- let texture_index = *self.draw_texture_indices.get(range_index).ok_or(
- VulkanSmokeRendererError::InvariantViolation {
- context: "stable draw texture range handle",
- },
- )?;
- let pipeline = resources.pipelines.get(&range.pipeline_key()).ok_or(
- VulkanSmokeRendererError::InvariantViolation {
- context: "static draw pipeline variant",
- },
- )?;
- device.device().cmd_bind_pipeline(
+ for reflected in [true, false] {
+ if reflected && reflection_plane.is_none() {
+ continue;
+ }
+ let framebuffer = if reflected {
+ resources.reflection.framebuffer
+ } else {
+ resources.framebuffers[image_index]
+ };
+ let render_pass_info = vk::RenderPassBeginInfo::default()
+ .render_pass(resources.render_pass)
+ .framebuffer(framebuffer)
+ .render_area(render_area)
+ .clear_values(&clear_values);
+ // SAFETY: All commands target live frame resources owned by this renderer.
+ unsafe {
+ device.device().cmd_begin_render_pass(
command_buffer,
- vk::PipelineBindPoint::GRAPHICS,
- *pipeline,
+ &render_pass_info,
+ vk::SubpassContents::INLINE,
);
- let frame_offset = u32::try_from(
- resources
- .frame_uniform_stride
- .saturating_mul(u64::try_from(self.current_frame).unwrap_or(u64::MAX)),
- )
- .unwrap_or(u32::MAX);
- let material_offset = u32::try_from(
- resources
- .material_specular_stride
- .saturating_mul(u64::try_from(texture_index).unwrap_or(u64::MAX)),
- )
- .unwrap_or(u32::MAX);
- let point_light_offset = u32::try_from(
- resources
- .point_light_stride
- .saturating_mul(u64::try_from(self.current_frame).unwrap_or(u64::MAX)),
- )
- .unwrap_or(u32::MAX);
- let descriptor_set = resources.descriptor_sets.get(texture_index).ok_or(
- VulkanSmokeRendererError::InvariantViolation {
- context: "static material descriptor set",
- },
- )?;
- device.device().cmd_bind_descriptor_sets(
+ let vertex_buffers = [self.vertex_buffer_ref()?.buffer];
+ let offsets = [0_u64];
+ device.device().cmd_bind_vertex_buffers(
command_buffer,
- vk::PipelineBindPoint::GRAPHICS,
- resources.pipeline_layout,
0,
- std::slice::from_ref(descriptor_set),
- &[frame_offset, material_offset, point_light_offset],
+ &vertex_buffers,
+ &offsets,
);
- let alpha_cutoff = range.alpha_test_cutoff().to_ne_bytes();
- device.device().cmd_push_constants(
+ device.device().cmd_bind_index_buffer(
command_buffer,
- resources.pipeline_layout,
- vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
+ self.index_buffer_ref()?.buffer,
0,
- &alpha_cutoff,
- );
- let material_alpha = self.material_alphas.get(texture_index).ok_or(
- VulkanSmokeRendererError::InvariantViolation {
- context: "static material alpha",
- },
- )?;
- let diffuse_alpha_override = self
- .draw_range_diffuse_alpha_overrides
- .get(range_index)
- .ok_or(VulkanSmokeRendererError::InvariantViolation {
- context: "draw-range diffuse-alpha override",
- })?;
- let material_alphas =
- draw_material_alphas(*material_alpha, *diffuse_alpha_override)
- .map(f32::to_ne_bytes)
- .concat();
- device.device().cmd_push_constants(
- command_buffer,
- resources.pipeline_layout,
- vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
- 4,
- &material_alphas,
- );
- let material_lighting = self.material_lighting.get(texture_index).ok_or(
- VulkanSmokeRendererError::InvariantViolation {
- context: "static material lighting",
- },
- )?;
- let material_lighting = material_lighting
- .iter()
- .flatten()
- .flat_map(|value| value.to_ne_bytes())
- .collect::<Vec<_>>();
- device.device().cmd_push_constants(
- command_buffer,
- resources.pipeline_layout,
- vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
- 12,
- &material_lighting,
- );
- let material_uv_transforms = self.material_uv_transforms.get(texture_index).ok_or(
- VulkanSmokeRendererError::InvariantViolation {
- context: "static material UV transforms",
- },
- )?;
- let material_uv_transforms = material_uv_transforms
- .iter()
- .flatten()
- .flat_map(|value| value.to_ne_bytes())
- .collect::<Vec<_>>();
- device.device().cmd_push_constants(
- command_buffer,
- resources.pipeline_layout,
- vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
- 60,
- &material_uv_transforms,
+ vk::IndexType::UINT32,
);
- let material_mode = self.material_unlit.get(texture_index).ok_or(
- VulkanSmokeRendererError::InvariantViolation {
- context: "static material unlit flag",
- },
- )?;
- let material_mode = if range.terrain_base_only {
- ((*material_mode).round() as i32 | 16) as f32
- } else {
- *material_mode
- };
- let viewport = super::swapchain_resources::draw_viewport(
- swapchain.report.plan.extent,
- material_mode,
- );
- let next_viewport_depth = [viewport.min_depth, viewport.max_depth];
- if viewport_depth != Some(next_viewport_depth) {
- device
- .device()
- .cmd_set_viewport(command_buffer, 0, &[viewport]);
- viewport_depth = Some(next_viewport_depth);
- }
- let material_mode = [material_mode.to_ne_bytes()];
- device.device().cmd_push_constants(
+ device.device().cmd_bind_descriptor_sets(
command_buffer,
+ vk::PipelineBindPoint::GRAPHICS,
resources.pipeline_layout,
- vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
- 124,
- &material_mode.concat(),
- );
- device.device().cmd_draw_indexed(
- command_buffer,
- range.index_count,
1,
- range.first_index,
- 0,
- 0,
+ &[resources.reflection.sets
+ [usize::from(!reflected && reflection_plane.is_some())]],
+ &[],
);
+ let mut viewport_depth = None;
+ for &range_index in &self.draw_order {
+ let range = self.draw_ranges.get(range_index).ok_or(
+ VulkanSmokeRendererError::InvariantViolation {
+ context: "stable draw order range handle",
+ },
+ )?;
+ let texture_index = *self.draw_texture_indices.get(range_index).ok_or(
+ VulkanSmokeRendererError::InvariantViolation {
+ context: "stable draw texture range handle",
+ },
+ )?;
+ let mode = self.material_unlit[texture_index].round() as i32;
+ if reflected && (range.terrain_reflection || mode & 8 != 0) {
+ continue;
+ }
+ let state = if reflected {
+ super::reflection::reflected_pipeline_state(range.pipeline_state)
+ } else {
+ range.pipeline_state
+ };
+ let pipeline = resources.pipelines.get(&state.into()).ok_or(
+ VulkanSmokeRendererError::InvariantViolation {
+ context: "static draw pipeline variant",
+ },
+ )?;
+ device.device().cmd_bind_pipeline(
+ command_buffer,
+ vk::PipelineBindPoint::GRAPHICS,
+ *pipeline,
+ );
+ let frame_offset = u32::try_from(
+ resources.frame_uniform_stride.saturating_mul(
+ u64::try_from(self.current_frame * 2 + usize::from(reflected))
+ .unwrap_or(u64::MAX),
+ ),
+ )
+ .unwrap_or(u32::MAX);
+ let material_offset = u32::try_from(
+ resources
+ .material_specular_stride
+ .saturating_mul(u64::try_from(texture_index).unwrap_or(u64::MAX)),
+ )
+ .unwrap_or(u32::MAX);
+ let point_light_offset = u32::try_from(
+ resources
+ .point_light_stride
+ .saturating_mul(u64::try_from(self.current_frame).unwrap_or(u64::MAX)),
+ )
+ .unwrap_or(u32::MAX);
+ let descriptor_set = resources.descriptor_sets.get(texture_index).ok_or(
+ VulkanSmokeRendererError::InvariantViolation {
+ context: "static material descriptor set",
+ },
+ )?;
+ device.device().cmd_bind_descriptor_sets(
+ command_buffer,
+ vk::PipelineBindPoint::GRAPHICS,
+ resources.pipeline_layout,
+ 0,
+ std::slice::from_ref(descriptor_set),
+ &[frame_offset, material_offset, point_light_offset],
+ );
+ let alpha_cutoff = range.alpha_test_cutoff().to_ne_bytes();
+ device.device().cmd_push_constants(
+ command_buffer,
+ resources.pipeline_layout,
+ vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
+ 0,
+ &alpha_cutoff,
+ );
+ let material_alpha = self.material_alphas.get(texture_index).ok_or(
+ VulkanSmokeRendererError::InvariantViolation {
+ context: "static material alpha",
+ },
+ )?;
+ let diffuse_alpha_override = self
+ .draw_range_diffuse_alpha_overrides
+ .get(range_index)
+ .ok_or(VulkanSmokeRendererError::InvariantViolation {
+ context: "draw-range diffuse-alpha override",
+ })?;
+ let material_alphas =
+ draw_material_alphas(*material_alpha, *diffuse_alpha_override)
+ .map(f32::to_ne_bytes)
+ .concat();
+ device.device().cmd_push_constants(
+ command_buffer,
+ resources.pipeline_layout,
+ vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
+ 4,
+ &material_alphas,
+ );
+ let material_lighting = self.material_lighting.get(texture_index).ok_or(
+ VulkanSmokeRendererError::InvariantViolation {
+ context: "static material lighting",
+ },
+ )?;
+ let material_lighting = material_lighting
+ .iter()
+ .flatten()
+ .flat_map(|value| value.to_ne_bytes())
+ .collect::<Vec<_>>();
+ device.device().cmd_push_constants(
+ command_buffer,
+ resources.pipeline_layout,
+ vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
+ 12,
+ &material_lighting,
+ );
+ let material_uv_transforms = self
+ .material_uv_transforms
+ .get(texture_index)
+ .ok_or(VulkanSmokeRendererError::InvariantViolation {
+ context: "static material UV transforms",
+ })?;
+ let material_uv_transforms = material_uv_transforms
+ .iter()
+ .flatten()
+ .flat_map(|value| value.to_ne_bytes())
+ .collect::<Vec<_>>();
+ device.device().cmd_push_constants(
+ command_buffer,
+ resources.pipeline_layout,
+ vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
+ 60,
+ &material_uv_transforms,
+ );
+ let material_mode = self.material_unlit.get(texture_index).ok_or(
+ VulkanSmokeRendererError::InvariantViolation {
+ context: "static material unlit flag",
+ },
+ )?;
+ let material_mode = if range.terrain_base_only {
+ ((*material_mode).round() as i32 | 16) as f32
+ } else {
+ *material_mode
+ };
+ let material_mode = (material_mode.round() as i32
+ | if reflected { 32 } else { 0 }
+ | if !reflected
+ && reflection_plane.is_some()
+ && self.reflection_planes[range_index] == reflection_plane
+ {
+ 64 | if shifted_reflection { 128 } else { 0 }
+ } else {
+ 0
+ }) as f32;
+ let viewport = super::swapchain_resources::draw_viewport(
+ swapchain.report.plan.extent,
+ material_mode,
+ );
+ let next_viewport_depth = [viewport.min_depth, viewport.max_depth];
+ if viewport_depth != Some(next_viewport_depth) {
+ device
+ .device()
+ .cmd_set_viewport(command_buffer, 0, &[viewport]);
+ viewport_depth = Some(next_viewport_depth);
+ }
+ let material_mode = [material_mode.to_ne_bytes()];
+ device.device().cmd_push_constants(
+ command_buffer,
+ resources.pipeline_layout,
+ vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
+ 124,
+ &material_mode.concat(),
+ );
+ device.device().cmd_draw_indexed(
+ command_buffer,
+ range.index_count,
+ 1,
+ range.first_index,
+ 0,
+ 0,
+ );
+ }
+ device.device().cmd_end_render_pass(command_buffer);
+ if reflected {
+ let image = resources.reflection.color.as_ref().ok_or(
+ VulkanSmokeRendererError::InvariantViolation {
+ context: "reflection target",
+ },
+ )?;
+ let barrier = vk::ImageMemoryBarrier::default()
+ .old_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)
+ .new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
+ .src_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE)
+ .dst_access_mask(vk::AccessFlags::SHADER_READ)
+ .image(image.image)
+ .subresource_range(super::color_subresource_range(1));
+ device.device().cmd_pipeline_barrier(
+ command_buffer,
+ vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT,
+ vk::PipelineStageFlags::FRAGMENT_SHADER,
+ vk::DependencyFlags::empty(),
+ &[],
+ &[],
+ &[barrier],
+ );
+ }
}
- device.device().cmd_end_render_pass(command_buffer);
+ }
+ // SAFETY: The main pass has completed its writes to this acquired swapchain image.
+ unsafe {
+ let barrier = vk::ImageMemoryBarrier::default()
+ .old_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)
+ .new_layout(vk::ImageLayout::PRESENT_SRC_KHR)
+ .src_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE)
+ .image(resources.images[image_index])
+ .subresource_range(super::color_subresource_range(1));
+ device.device().cmd_pipeline_barrier(
+ command_buffer,
+ vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT,
+ vk::PipelineStageFlags::BOTTOM_OF_PIPE,
+ vk::DependencyFlags::empty(),
+ &[],
+ &[],
+ &[barrier],
+ );
if self.readback_enabled {
if let (Some(image), Some(readback)) = (
resources.images.get(image_index),
@@ -2028,7 +2206,7 @@ impl VulkanSmokeRenderer {
}
}
- // SAFETY: The render pass owns the attachment layout transitions for this clear-and-present path.
+ // SAFETY: Every render pass and attachment transition has been recorded in this buffer.
unsafe { device.device().end_command_buffer(command_buffer) }.map_err(|error| {
VulkanSmokeRendererError::VulkanOperation {
context: "vkEndCommandBuffer",
@@ -2720,6 +2898,7 @@ mod tests {
batch_flags: 0x108,
pipeline_state: base_state,
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 17,
},
VulkanStaticDrawRange {
@@ -2730,6 +2909,7 @@ mod tests {
batch_flags: 0,
pipeline_state: LegacyPipelineState::default(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
},
];
@@ -2886,6 +3066,7 @@ mod tests {
batch_flags: 0,
pipeline_state: LegacyPipelineState::default(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
}];
let mut capacities = [6];
@@ -3072,6 +3253,7 @@ mod tests {
batch_flags: 0,
pipeline_state: fparkan_render::LegacyPipelineState::default(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
})
.collect::<Vec<_>>();
@@ -3096,6 +3278,7 @@ mod tests {
batch_flags: 0,
pipeline_state: fparkan_render::LegacyPipelineState::default(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
},
super::super::VulkanStaticDrawRange {
@@ -3106,6 +3289,7 @@ mod tests {
batch_flags: 0,
pipeline_state: fparkan_render::LegacyPipelineState::default(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
},
super::super::VulkanStaticDrawRange {
@@ -3116,6 +3300,7 @@ mod tests {
batch_flags: 0,
pipeline_state: fparkan_render::LegacyPipelineState::default(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
},
];
@@ -3140,6 +3325,7 @@ mod tests {
batch_flags: 0,
pipeline_state: fparkan_render::LegacyPipelineState::default(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
})
.collect::<Vec<_>>();
@@ -3233,6 +3419,7 @@ mod tests {
batch_flags: 0,
pipeline_state: LegacyPipelineState::default(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
})
.collect::<Vec<_>>();
@@ -3291,6 +3478,7 @@ mod tests {
batch_flags: 0,
pipeline_state: LegacyPipelineState::default(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
})
.collect::<Vec<_>>();
diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs
index 5d6f12f..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>();
@@ -437,6 +458,8 @@ pub struct VulkanStaticDrawRange {
pub batch_flags: u16,
/// Terrain ranges that use the native lit base-only path (face flag 0x400 clear).
pub terrain_base_only: bool,
+ /// Terrain face flag 0x20000 requests a water reflection surface.
+ pub terrain_reflection: bool,
/// Backend-neutral fixed-function state for this source range.
pub pipeline_state: LegacyPipelineState,
/// Alpha-test reference in legacy 0..=255 units; dynamic material data.
@@ -771,6 +794,7 @@ impl VulkanStaticMesh {
batch_flags: 0,
pipeline_state: LegacyPipelineState::default(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
}],
}
@@ -859,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);
@@ -877,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]
@@ -1032,6 +1080,7 @@ mod static_mesh_tests {
batch_flags: 0,
pipeline_state: LegacyPipelineState::default(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
}],
};
@@ -1063,6 +1112,7 @@ mod static_mesh_tests {
batch_flags: 0,
pipeline_state: LegacyPipelineState::default(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
},
VulkanStaticDrawRange {
@@ -1073,6 +1123,7 @@ mod static_mesh_tests {
batch_flags: 0,
pipeline_state: LegacyPipelineState::default(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
},
];
@@ -1191,6 +1242,7 @@ mod static_mesh_tests {
let base = VulkanStaticMesh::smoke_triangle().draw_ranges[0];
let disabled = VulkanStaticDrawRange {
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 200,
..base
};
@@ -1200,6 +1252,7 @@ mod static_mesh_tests {
..LegacyPipelineState::default()
},
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 128,
..base
};
@@ -1211,6 +1264,7 @@ mod static_mesh_tests {
..LegacyPipelineState::default()
},
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
..base
};
@@ -1221,6 +1275,7 @@ mod static_mesh_tests {
..LegacyPipelineState::default()
},
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
..base
};
@@ -1501,6 +1556,7 @@ pub struct VulkanSmokeRenderer {
pub(super) index_capacity: usize,
pub(super) draw_range_capacities: Vec<u32>,
pub(super) draw_ranges: Vec<VulkanStaticDrawRange>,
+ pub(super) reflection_planes: Vec<Option<f32>>,
pub(super) draw_texture_indices: Vec<usize>,
/// Stable range handles used when recording the native pass order.
pub(super) draw_order: Vec<usize>,
diff --git a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs
index a44f79d..af20dfa 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs
@@ -18,6 +18,7 @@ use super::{
};
pub(super) struct VulkanSwapchainResources {
+ pub(super) reflection: super::reflection::ReflectionResources,
pub(super) image_views: Vec<vk::ImageView>,
pub(super) images: Vec<vk::Image>,
pub(super) readback_buffers: Vec<VulkanAllocatedBuffer>,
@@ -146,6 +147,7 @@ pub(super) fn ensure_point_light_capacity(
}
struct PartialSwapchainResources {
+ reflection: Option<super::reflection::ReflectionResources>,
image_views: Vec<vk::ImageView>,
readback_buffers: Vec<VulkanAllocatedBuffer>,
depth_attachments: Vec<VulkanDepthAttachment>,
@@ -180,6 +182,7 @@ pub(super) fn create_swapchain_resources(
textures: &[VulkanAllocatedImage],
texture_slot_indices: &[usize],
base_pipeline_states: &[LegacyPipelineState],
+ reflection_enabled: bool,
depth_request: DepthStencilSupport,
reuse_command_pool: bool,
) -> Result<VulkanSwapchainResources, VulkanSmokeRendererError> {
@@ -194,6 +197,7 @@ pub(super) fn create_swapchain_resources(
result: error,
})?;
let mut partial = PartialSwapchainResources {
+ reflection: None,
image_views: create_swapchain_image_views(
device,
&images,
@@ -245,6 +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,
+ 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) {
@@ -288,6 +304,12 @@ pub(super) fn create_swapchain_resources(
textures,
texture_slot_indices,
base_pipeline_states,
+ partial
+ .reflection
+ .as_ref()
+ .expect("reflection descriptors exist")
+ .layout,
+ reflection_enabled,
depth_request,
partial
.frame_uniform_buffer
@@ -319,6 +341,24 @@ pub(super) fn create_swapchain_resources(
partial.descriptor_pool = Some(descriptor_pool);
partial.sampler = Some(sampler);
partial.pipelines = pipelines;
+ if reflection_enabled {
+ if let Err(error) = partial
+ .reflection
+ .as_mut()
+ .expect("reflection descriptors exist")
+ .create_target(
+ instance,
+ device,
+ swapchain.report.plan.extent,
+ swapchain.report.plan.format.format,
+ depth_request,
+ render_pass,
+ )
+ {
+ destroy_partial_swapchain_resources(device, command_pool, partial);
+ return Err(error);
+ }
+ }
for _ in 1..images.len() {
match create_depth_attachment(
instance,
@@ -394,6 +434,10 @@ pub(super) fn create_swapchain_resources(
});
}
Ok(VulkanSwapchainResources {
+ reflection: partial
+ .reflection
+ .take()
+ .expect("reflection descriptors exist"),
image_views: partial.image_views,
images,
readback_buffers: partial.readback_buffers,
@@ -457,6 +501,8 @@ fn create_swapchain_pipeline_bundle(
textures: &[VulkanAllocatedImage],
texture_slot_indices: &[usize],
base_pipeline_states: &[LegacyPipelineState],
+ reflection_layout: vk::DescriptorSetLayout,
+ reflection_enabled: bool,
depth_request: DepthStencilSupport,
frame_uniform_buffer: &VulkanAllocatedBuffer,
material_specular_buffer: &VulkanAllocatedBuffer,
@@ -498,8 +544,8 @@ fn create_swapchain_pipeline_bundle(
unsafe { device.device().destroy_render_pass(render_pass, None) };
destroy_depth_attachment(device, &depth_attachment);
})?;
- let pipeline_layout =
- create_pipeline_layout(device, descriptor_set_layout).inspect_err(|_| {
+ let pipeline_layout = create_pipeline_layout(device, descriptor_set_layout, reflection_layout)
+ .inspect_err(|_| {
// SAFETY: The descriptor resources and render pass were created above and are rolled back on this device.
unsafe {
device.device().destroy_sampler(sampler, None);
@@ -520,6 +566,7 @@ fn create_swapchain_pipeline_bundle(
pipeline_layout,
extent,
base_pipeline_states,
+ reflection_enabled,
)
.inspect_err(|_| {
// SAFETY: These objects were created above on this live logical device and are destroyed on setup failure.
@@ -636,7 +683,7 @@ fn create_render_pass(
.load_op(vk::AttachmentLoadOp::CLEAR)
.store_op(vk::AttachmentStoreOp::STORE)
.initial_layout(vk::ImageLayout::UNDEFINED)
- .final_layout(vk::ImageLayout::PRESENT_SRC_KHR);
+ .final_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL);
let color_attachment_ref = vk::AttachmentReference::default()
.attachment(0)
.layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL);
@@ -661,7 +708,8 @@ fn create_render_pass(
.src_subpass(vk::SUBPASS_EXTERNAL)
.dst_subpass(0)
.src_stage_mask(
- vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT
+ vk::PipelineStageFlags::FRAGMENT_SHADER
+ | vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT
| vk::PipelineStageFlags::EARLY_FRAGMENT_TESTS
| vk::PipelineStageFlags::LATE_FRAGMENT_TESTS,
)
@@ -670,7 +718,11 @@ fn create_render_pass(
| vk::PipelineStageFlags::EARLY_FRAGMENT_TESTS
| vk::PipelineStageFlags::LATE_FRAGMENT_TESTS,
)
- .src_access_mask(vk::AccessFlags::empty())
+ .src_access_mask(
+ vk::AccessFlags::SHADER_READ
+ | vk::AccessFlags::COLOR_ATTACHMENT_WRITE
+ | vk::AccessFlags::DEPTH_STENCIL_ATTACHMENT_WRITE,
+ )
.dst_access_mask(
vk::AccessFlags::COLOR_ATTACHMENT_WRITE
| vk::AccessFlags::DEPTH_STENCIL_ATTACHMENT_WRITE,
@@ -694,8 +746,9 @@ fn create_render_pass(
fn create_pipeline_layout(
device: &VulkanLogicalDeviceProbe,
descriptor_set_layout: vk::DescriptorSetLayout,
+ reflection_layout: vk::DescriptorSetLayout,
) -> Result<vk::PipelineLayout, VulkanSmokeRendererError> {
- let set_layouts = [descriptor_set_layout];
+ let set_layouts = [descriptor_set_layout, reflection_layout];
// Material data occupies one 128-byte push-constant allocation shared by
// both stages; the vertex stage also reads its specular pair from the
// dynamic storage buffer at descriptor binding 5. Camera data is supplied
@@ -960,12 +1013,18 @@ fn create_graphics_pipeline_cache(
pipeline_layout: vk::PipelineLayout,
extent: (u32, u32),
base_pipeline_states: &[LegacyPipelineState],
+ reflection_enabled: bool,
) -> Result<BTreeMap<PipelineKey, vk::Pipeline>, VulkanSmokeRendererError> {
let mut pipelines = BTreeMap::new();
for state in base_pipeline_states
.iter()
.copied()
.flat_map(super::smoke::pipeline_variants)
+ .flat_map(|state| {
+ std::iter::once(state).chain(
+ reflection_enabled.then_some(super::reflection::reflected_pipeline_state(state)),
+ )
+ })
{
let key = PipelineKey::from(state);
if pipelines.contains_key(&key) {
@@ -1238,6 +1297,7 @@ pub(super) fn destroy_swapchain_resources(
command_pool: vk::CommandPool,
resources: VulkanSwapchainResources,
) {
+ drop(resources.reflection);
// SAFETY: All handles belong to this live logical device and are destroyed during renderer teardown/recreation.
unsafe {
if !resources.command_buffers.is_empty() {
@@ -1303,6 +1363,7 @@ fn destroy_partial_swapchain_resources(
command_pool: vk::CommandPool,
partial: PartialSwapchainResources,
) {
+ drop(partial.reflection);
// SAFETY: All handles in the partial bundle belong to this live logical device and are destroyed on setup failure.
unsafe {
if !partial.command_buffers.is_empty() {
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 3432bda..b57d690 100644
--- a/apps/fparkan-game/src/main.rs
+++ b/apps/fparkan-game/src/main.rs
@@ -2760,6 +2760,7 @@ fn append_environment_range(
batch_flags: 0,
pipeline_state,
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
});
Ok(EnvironmentGpuRange {
@@ -4637,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>,
@@ -7606,7 +7608,7 @@ fn project_basement_foundation(
let mut shadow_receivers = Vec::with_capacity(foundation.fragments.len());
for (fragment_index, fragment) in foundation.fragments.iter().enumerate() {
- let (material_index, base_only, vertices) = match fragment.source_face {
+ let (material_index, base_only, reflection, vertices) = match fragment.source_face {
Some(face_index) => {
let face = terrain.faces.get(face_index).ok_or_else(|| {
format!("foundation fragment references unknown terrain face {face_index}")
@@ -7654,6 +7656,7 @@ fn project_basement_foundation(
(
face.material_tag,
fragment.surface_mask.0 & 0x400 == 0,
+ fragment.surface_mask.0 & 0x20000 != 0,
vertices,
)
}
@@ -7671,7 +7674,7 @@ fn project_basement_foundation(
fragment_uv0_words,
fragment_normals,
);
- (0xff00, true, vertices)
+ (0xff00, true, false, vertices)
}
};
@@ -7684,7 +7687,13 @@ fn project_basement_foundation(
mesh.indices.push(index);
}
if let Some(range) = mesh.draw_ranges.last_mut().filter(|range| {
- range.material_index == material_index && range.terrain_base_only == base_only
+ range.material_index == material_index
+ && range.terrain_base_only == base_only
+ && range.terrain_reflection == reflection
+ && (!reflection
+ || vertices[0].position[2]
+ == mesh.vertices[mesh.indices[range.first_index as usize] as usize]
+ .position[2])
}) {
range.index_count = range
.index_count
@@ -7699,6 +7708,7 @@ fn project_basement_foundation(
batch_flags: 0,
pipeline_state,
terrain_base_only: base_only,
+ terrain_reflection: reflection,
alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
});
}
@@ -8004,9 +8014,9 @@ fn static_preview_mesh_and_materials(
let unverified_special_faces = terrain_mesh
.faces
.iter()
- .filter(|face| face.flags.0 & 8 != 0)
+ .filter(|face| face.flags.0 & 8 != 0 && face.flags.0 & 0x20000 == 0)
.count();
- eprintln!("render_coverage terrain_face8_pipeline_unverified={unverified_special_faces} (source faces across LODs use the ordinary terrain shader; native special pass and reflection/EMBM mapping pending)");
+ eprintln!("render_coverage terrain_face8_pipeline_unverified={unverified_special_faces} (non-reflective source faces across LODs; native special-pass mapping pending)");
let footprint_sources = mission_basement_footprints(assets, roots)?;
let foundation = if footprint_sources.is_empty() {
None
@@ -8545,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,
@@ -9360,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>,
@@ -9488,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,
@@ -9558,6 +9586,7 @@ impl StaticVulkanApp {
})
.count();
eprintln!("render_coverage portal_fade_packet_selection_approx={approximate_portals} (first indexed vertex; native CPU clipping selection pending)");
+ eprintln!("render_coverage msh_type16_colors_unsupported={} (authored vertex colors are not decoded; using white)", self.type16_present_ranges.len());
let randomized_lights = self
.control_point_light_slots
.iter()
@@ -10324,6 +10353,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)
@@ -11208,6 +11252,7 @@ mod tests {
batch_flags,
pipeline_state: LegacyPipelineState::default(),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
};
let mesh = VulkanStaticMesh {
@@ -12467,6 +12512,7 @@ mod tests {
LegacyDepthMode::TestReadOnly,
),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
};
let mut mesh = VulkanStaticMesh {
@@ -13149,6 +13195,7 @@ mod tests {
LegacyDepthMode::TestReadOnly,
),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
};
let mut mesh = VulkanStaticMesh {
@@ -13210,6 +13257,7 @@ mod tests {
batch_flags,
pipeline_state: world_pipeline_state(blend, LegacyDepthMode::TestReadOnly),
terrain_base_only: false,
+ terrain_reflection: false,
alpha_test_reference: 0,
};
@@ -13651,6 +13699,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 {
@@ -13673,6 +13722,7 @@ mod tests {
lightmap_index: u8::MAX,
batch_flags: 0,
terrain_base_only: false,
+ terrain_reflection: false,
pipeline_state: world_pipeline_state(
LegacyBlendMode::Opaque,
LegacyDepthMode::TestReadOnly,
@@ -15716,13 +15766,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/apps/fparkan-game/src/terrain_lod.rs b/apps/fparkan-game/src/terrain_lod.rs
index 599178c..190f660 100644
--- a/apps/fparkan-game/src/terrain_lod.rs
+++ b/apps/fparkan-game/src/terrain_lod.rs
@@ -239,6 +239,7 @@ mod tests {
lightmap_index: u8::MAX,
batch_flags: 0,
terrain_base_only: true,
+ terrain_reflection: false,
pipeline_state: super::super::world_pipeline_state(
fparkan_render::LegacyBlendMode::Opaque,
fparkan_render::LegacyDepthMode::TestWrite,
diff --git a/crates/fparkan-render/src/lib.rs b/crates/fparkan-render/src/lib.rs
index ad96204..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
@@ -349,6 +351,23 @@ pub enum LegacyDepthMode {
TestReadOnly,
}
+/// Reflects world Z before a row-vector clip transform, preserving points on the plane.
+#[must_use]
+pub fn reflect_clip_from_world_about_z(matrix: [f32; 16], height: f32) -> Option<[f32; 16]> {
+ if !height.is_finite() || !matrix.iter().all(|value| value.is_finite()) {
+ return None;
+ }
+ let mut reflected = matrix;
+ for column in 0..4 {
+ reflected[8 + column] = -matrix[8 + column];
+ reflected[12 + column] = matrix[12 + column] + 2.0 * height * matrix[8 + column];
+ }
+ reflected
+ .iter()
+ .all(|value| value.is_finite())
+ .then_some(reflected)
+}
+
/// Triangle culling behaviour represented without a graphics API type.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum LegacyCullMode {
@@ -556,6 +575,29 @@ mod tests {
result
}
+ #[test]
+ fn water_reflection_preserves_the_plane_and_reverses_height_before_projection() {
+ let matrix = [
+ 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0,
+ ];
+ let reflected = reflect_clip_from_world_about_z(matrix, 150.0).unwrap();
+ let project = |m: [f32; 16], p: [f32; 4]| -> [f32; 4] {
+ std::array::from_fn(|column| (0..4).map(|row| p[row] * m[row * 4 + column]).sum())
+ };
+ for z in [125.0, 150.0, 185.0] {
+ assert_eq!(
+ project(reflected, [7.0, 11.0, z, 1.0]),
+ project(matrix, [7.0, 11.0, 300.0 - z, 1.0])
+ );
+ }
+ assert_eq!(
+ reflect_clip_from_world_about_z(reflected, 150.0),
+ Some(matrix)
+ );
+ assert!(reflect_clip_from_world_about_z(matrix, f32::NAN).is_none());
+ assert!(reflect_clip_from_world_about_z([f32::MAX; 16], f32::MAX).is_none());
+ }
+
fn assert_matrix_approximately_identity(matrix: [f32; 16]) {
for (index, value) in matrix.into_iter().enumerate() {
let expected = if index / 4 == index % 4 { 1.0 } else { 0.0 };
diff --git a/crates/fparkan-render/src/water.rs b/crates/fparkan-render/src/water.rs
new file mode 100644
index 0000000..4f161cc
--- /dev/null
+++ b/crates/fparkan-render/src/water.rs
@@ -0,0 +1,216 @@
+//! 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],
+ ])
+}
+
+/// The EMBM camera looks up from the mirrored eye, with its window on the water rectangle.
+/// Terrain.dll+4EFF0 supplies `[min_y-eye_y, min_x-eye_x, span_y, span_x, eye_z-height]`.
+/// The ordinary perspective's depth mapping is retained; orthographic inputs use the caller's fallback.
+#[must_use]
+pub fn legacy_embm_reflection_clip(
+ main_clip: [f32; 16],
+ eye: [f32; 3],
+ height: f32,
+ bounds: [f32; 4],
+) -> Option<[f32; 16]> {
+ let span = [bounds[2] - bounds[0], bounds[3] - bounds[1]];
+ let distance = eye[2] - height;
+ if !main_clip
+ .into_iter()
+ .chain(eye)
+ .chain([height])
+ .chain(bounds)
+ .chain(span)
+ .all(f32::is_finite)
+ || distance <= 0.0
+ || span.into_iter().any(|v| v <= 0.0)
+ {
+ return None;
+ }
+ let w = [main_clip[3], main_clip[7], main_clip[11]];
+ let squared = w.into_iter().map(|v| v * v).sum::<f32>();
+ if !squared.is_finite() || squared <= f32::EPSILON {
+ return None;
+ }
+ let depth_a = (main_clip[2] * w[0] + main_clip[6] * w[1] + main_clip[10] * w[2]) / squared;
+ // shortcut: retain the main depth range until the native shifted depth matrix is verified.
+ let depth_b = (main_clip[14] - depth_a * main_clip[15]) / squared.sqrt();
+ if !depth_a.is_finite() || !depth_b.is_finite() || depth_a < 1.0 || depth_b >= 0.0 {
+ return None;
+ }
+ let z = 2.0 * height - eye[2];
+ let x_scale = -2.0 * distance / span[1];
+ let y_scale = -2.0 * distance / span[0];
+ let x_shift = 1.0 + 2.0 * (bounds[1] - eye[1]) / span[1];
+ let y_shift = 1.0 + 2.0 * (bounds[0] - eye[0]) / span[0];
+ let matrix = [
+ 0.0,
+ y_scale,
+ 0.0,
+ 0.0,
+ x_scale,
+ 0.0,
+ 0.0,
+ 0.0,
+ x_shift,
+ y_shift,
+ depth_a,
+ 1.0,
+ -x_scale * eye[1] - x_shift * z,
+ -y_scale * eye[0] - y_shift * z,
+ depth_b - depth_a * z,
+ -z,
+ ];
+ matrix.into_iter().all(f32::is_finite).then_some(matrix)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn shifted_camera_maps_native_water_corners_and_retains_perspective_depth() {
+ let projection = crate::LegacyD3d7Projection {
+ viewport: [0, 0, 1280, 1024],
+ near_plane: 0.5,
+ far_plane: 700.0,
+ field_of_view_radians: 1.3,
+ }
+ .try_direct3d7_projection_row_major()
+ .unwrap();
+ let eye = [1200.0, 800.0, 185.0];
+ let mut main = [0.0; 16];
+ main[2] = projection[10];
+ main[3] = projection[11];
+ main[14] = -eye[0] * projection[10] + projection[14];
+ main[15] = -eye[0] * projection[11];
+ let bounds = [1000.0, 500.0, 1500.0, 1200.0];
+ let matrix = legacy_embm_reflection_clip(main, eye, 150.0, bounds).unwrap();
+ let project = |point: [f32; 3]| -> [f32; 4] {
+ std::array::from_fn(|column| {
+ point[0] * matrix[column]
+ + point[1] * matrix[4 + column]
+ + point[2] * matrix[8 + column]
+ + matrix[12 + column]
+ })
+ };
+ for point in [
+ [1000.0, 500.0, 150.0],
+ [1500.0, 1200.0, 150.0],
+ [1125.0, 1025.0, 150.0],
+ ] {
+ let clip = project(point);
+ let expected = legacy_embm_uv([point[0], point[1]], bounds).unwrap();
+ for axis in 0..2 {
+ assert!((clip[axis] / clip[3] * 0.5 + 0.5 - expected[axis]).abs() < 0.00001);
+ }
+ assert_eq!(clip[3], 35.0);
+ }
+ let near = project([1200.0, 800.0, 115.5]);
+ assert!((near[2] / near[3]).abs() < 0.001);
+ let far = project([1200.0, 800.0, 815.0]);
+ assert!((far[2] / far[3] - 1.0).abs() < 0.00001);
+ assert!(legacy_embm_reflection_clip([0.0; 16], eye, 150.0, bounds).is_none());
+ assert!(legacy_embm_reflection_clip(main, eye, 185.0, bounds).is_none());
+ assert!(legacy_embm_reflection_clip(main, eye, 150.0, [1.0; 4]).is_none());
+ }
+
+ #[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 70f6259..7b71e9e 100644
--- a/docs/appendices/knowledge-boundaries.md
+++ b/docs/appendices/knowledge-boundaries.md
@@ -54,7 +54,10 @@ MSH-запрос со свободной preview-камерой; это не пÐ
([описание кадра](../reference/render-frame.md#прозрачность-порталов-msh)). Native CPU clipping может выбрать другую первую вершину;
это приближение явно считается как `portal_fade_packet_selection_approx`.
-Данные type 16 ещё не декодированы. Внутренний ракурс «Конструктора» после
+Данные type 16 ещё не декодированы и считаются в `msh_type16_colors_unsupported`;
+в статических сценах 29 миссий GOG (1736 загрузок моделей) этот поток отсутствует.
+Его декодирование отложено до первого реального ненулевого счётчика.
+Внутренний ракурс «Конструктора» после
настоящего входа игрока подтверждает исчезновение чёрной поверхности в проходе;
наружные окна обеих миссий сверены на расстояниях около 60/85/110, а ближние
окна — с внутренней стороны. Окно «Конструктора» на правом краю кадра при
@@ -73,9 +76,10 @@ MSH-запрос со свободной preview-камерой; это не пÐ
среде, перехватить DirectDraw/Direct3D calls и surface flips, сохранить state
log на минимальных сценах с одним типом материала.
-Полигоны Land.msh с флагом `0x8` пока используют обычный terrain shader; их исходные записи во всех LOD считает `terrain_face8_pipeline_unverified`.
-На водоёме «Конструктора» берег близок к оригиналу, а поверхность воды слишком светлая; точные special-pass, UV и combiner остаются открытыми.
-В GOG Terrain.dll подтверждена отдельная камера `REFLECTION`/`REFLECTION_SHIFTED` (инициализация RVA `0x13CF0`, render RVA `0x4EFF0`); Vulkan-проход отражения пока отсутствует.
+Горизонтальные грани Land.msh с флагом `0x20000` используют исходную карту Du/Dv и окно shifted-камеры, проверенное исполнением Terrain.dll. Диапазон глубины, небо/туман/FX основного вида и размер target остаются приближениями: `planar_reflection_render_context_approx`; неперспективная камера API использует обычное зеркало.
+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`; сама семантика оставшихся специальных проходов ещё не установлена.
## FXID field-level semantics
diff --git a/docs/reference/msh.md b/docs/reference/msh.md
index 130a203..873cae1 100644
--- a/docs/reference/msh.md
+++ b/docs/reference/msh.md
@@ -28,7 +28,10 @@ type 20 rare auxiliary table
Reader ищет entries по type, но сохраняет исходный порядок для roundtrip.
Поле `ModelAsset.type16_present` отмечает только наличие entry типа 16. Данные
payload сохраняются, но этот путь рендера пока не преобразует их в цвета
-вершин.
+вершин; приложение считает затронутые диапазоны в `msh_type16_colors_unsupported`.
+В статических сценах всех 29 миссий GOG ни одна из 1736 загрузок моделей
+не содержит этого потока. Это ограничение проверенного набора ресурсов;
+неиспользуемые модели и будущие игровые состояния сюда не входят.
## Node and slot selection
diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md
index cfd352a..53667c8 100644
--- a/docs/tomes/05-render.md
+++ b/docs/tomes/05-render.md
@@ -1138,6 +1138,74 @@ lightmap state передаются в `VulkanStaticMaterial`, а shader посÐ
сэмплирует эти четыре стадии. Animation clock обновляет выбранные фазы и
коэффициенты без пересборки terrain mesh, сохраняя native type-14 overlay mask.
+### Отражение на воде
+
+Поверхность воды нельзя рисовать тем же умножением текстур, что и землю.
+В проходе ландшафта GOG Terrain.dll флаг грани `0x20000` участвует в выборе
+отражения; при включённом EMBM живой водный кадр использует texture mode 10.
+Оригинал создаёт для этого отдельную камеру `REFLECTION_SHIFTED`.
+В [эталонной сборке](../reference/original-binaries.md) выбор грани находится
+в Terrain.dll RVA `0x28A50`, а подготовка EMBM-пакета — в RVA `0x1B540`.
+
+Для горизонтальной воды на высоте `h` камера отражения находится в точке
+`[eye_x, eye_y, 2h − eye_z]`. Но она не повторяет наклон основной камеры:
+смотрит вертикально вверх, а её окно лежит на прямоугольнике водоёма. Поэтому
+текстура отражения привязана к местности, а не к экрану игрока. В плоскости
+воды координаты изображения равны `U = 1 − Ynorm`, `V = 1 − Xnorm`.
+
+Terrain.dll RVA `0x4EFF0` передаёт shifted-проекции пять значений:
+`[min_y − eye_y, min_x − eye_x, span_y, span_x, eye_z − h]`. Например,
+для наблюдателя `[1200, 800, 185]`, воды `150` и границ
+`[1000, 500, 1500, 1200]` это `[-300, -200, 700, 500, 35]`.
+Матрица камеры и эти аргументы проверены исполнением оригинальной функции
+при двух положениях и разных поворотах основной камеры. Оракул подменяет
+ввод-вывод очереди, ландшафта и камеры; сам renderer и матрица глубины в эту
+проверку не входят. Vulkan-проекция сохраняет near/far основного вида;
+Rust-тест отдельно проверяет углы водоёма и границы глубины.
+
+Отражённый вид меняет сторону отсечения и отбрасывает геометрию ниже воды.
+Небо и оставшаяся сцена попадают в отдельную цветовую текстуру с собственной
+глубиной. В основном проходе водные треугольники читают её по координатам
+водоёма с добавленным смещением волн. Для неперспективной камеры API остаётся
+обычное зеркало с экранными координатами.
+
+Цветовой target переходит из attachment layout в shader-read layout после
+записи. Следующий зеркальный проход ждёт предыдущего чтения этой текстуры;
+основной и отражённый виды используют разные слоты uniform buffer. Во время
+записи отражения привязан отдельный нейтральный descriptor, чтобы проход
+не читал собственное вложение.
+
+Волны не требуют готовой текстуры из ресурсов: 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 карта хранится в двух каналах несжатой текстуры и возвращается к
+знаковым значениям перед вычислением смещения.
+
+Матрица положения камеры, её окно и координаты волн восстановлены, но весь
+контекст отражённого вида ещё не повторён. Диапазон глубины, небо, туман и FX
+заимствуются из основного вида, а размер target совпадает со swapchain.
+Эти ограничения учитываются в `planar_reflection_render_context_approx`;
+совпадение генератора волн и окна не доказывает совпадения итогового отражения.
+Один target обслуживает ближайшую
+активную высоту воды; другие высоты и негоризонтальные поверхности явно
+считаются как неподдержанные. Это промежуточный водный проход, а не полная
+приёмка воды или лавы.
+
## Реализация Vulkan
`LegacyPipelineState` хранит blend, depth, cull и alpha-test. Из этих значений