fix(render): draw translucent water into planar reflections
Docs Deploy / Build and Deploy MkDocs (push) Successful in 37s

This commit is contained in:
Valentin Popov committed 2026-11-01 14:39:37 +04:00
1 parent bbd31fc253
commit 55e98d7ff6
7 files changed
+112 -9

No files matched your search

@@ -75,7 +75,9 @@ void main() {
if ((mode & 32) != 0 && (mode & 4) == 0 && in_world_position.z < frame.camera_position.w) { if ((mode & 32) != 0 && (mode & 4) == 0 && in_world_position.z < frame.camera_position.w) {
discard; discard;
} }
if ((mode & 64) != 0) { vec2 base_uv = vec2(material.base_page_x, material.base_page_y)
+ in_uv * vec2(material.base_page_w, material.base_page_h);
if ((mode & (64 | 256)) != 0) {
vec2 reflection_uv = gl_FragCoord.xy / vec2(textureSize(reflection_color, 0)); vec2 reflection_uv = gl_FragCoord.xy / vec2(textureSize(reflection_color, 0));
vec2 span = frame.embm_bounds.zw - frame.embm_bounds.xy; vec2 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 plane_uv = vec2(1.0) - (in_world_position.yx - frame.embm_bounds.yx) / span.yx;
@@ -92,15 +94,16 @@ void main() {
reflection_uv = displaced_clip.xy / displaced_clip.w * 0.5 + 0.5; reflection_uv = displaced_clip.xy / displaced_clip.w * 0.5 + 0.5;
} }
} }
vec3 rgb = texture(reflection_color, reflection_uv).rgb * in_base_diffuse + in_base_specular; vec3 water_color = (mode & 256) != 0
? texture(base_color, base_uv).rgb
: texture(reflection_color, reflection_uv).rgb;
vec3 rgb = water_color * in_base_diffuse + in_base_specular;
out_color = vec4(mix(rgb * in_color, frame.fog_color.rgb, in_fog_factor), material.diffuse_alpha); out_color = vec4(mix(rgb * in_color, frame.fog_color.rgb, in_fog_factor), material.diffuse_alpha);
if (out_color.a < material.alpha_cutoff) { if (out_color.a < material.alpha_cutoff) {
discard; discard;
} }
return; 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) vec2 detail_uv = vec2(material.detail_page_x, material.detail_page_y)
+ in_detail_uv * vec2(material.detail_page_w, material.detail_page_h); + in_detail_uv * vec2(material.detail_page_w, material.detail_page_h);
vec2 overlay_uv = vec2(material.overlay_page_x, material.overlay_page_y) vec2 overlay_uv = vec2(material.overlay_page_x, material.overlay_page_y)
@@ -1936,7 +1936,16 @@ impl VulkanSmokeRenderer {
&[], &[],
); );
let mut viewport_depth = None; let mut viewport_depth = None;
for &range_index in &self.draw_order { // Native reflection queues the translucent water after the reflected world.
let draw_order =
self.draw_order
.iter()
.copied()
.filter(|&index| !reflected || !self.draw_ranges[index].terrain_reflection)
.chain(self.draw_order.iter().copied().filter(|&index| {
reflected && self.draw_ranges[index].terrain_reflection
}));
for range_index in draw_order {
let range = self.draw_ranges.get(range_index).ok_or( let range = self.draw_ranges.get(range_index).ok_or(
VulkanSmokeRendererError::InvariantViolation { VulkanSmokeRendererError::InvariantViolation {
context: "stable draw order range handle", context: "stable draw order range handle",
@@ -1948,11 +1957,17 @@ impl VulkanSmokeRenderer {
}, },
)?; )?;
let mode = self.material_unlit[texture_index].round() as i32; let mode = self.material_unlit[texture_index].round() as i32;
if reflected && (range.terrain_reflection || mode & 8 != 0) { if reflected && mode & 8 != 0 {
continue; continue;
} }
let state = if reflected { let state = if reflected {
super::reflection::reflected_pipeline_state(range.pipeline_state) let state =
super::reflection::reflected_pipeline_state(range.pipeline_state);
if range.terrain_reflection {
fparkan_render::water::legacy_reflected_water_pipeline(state)
} else {
state
}
} else { } else {
range.pipeline_state range.pipeline_state
}; };
@@ -1998,7 +2013,12 @@ impl VulkanSmokeRenderer {
std::slice::from_ref(descriptor_set), std::slice::from_ref(descriptor_set),
&[frame_offset, material_offset, point_light_offset], &[frame_offset, material_offset, point_light_offset],
); );
let alpha_cutoff = range.alpha_test_cutoff().to_ne_bytes(); let alpha_cutoff = if state.alpha_test {
f32::from(range.alpha_test_reference) / 255.0
} else {
0.0
}
.to_ne_bytes();
device.device().cmd_push_constants( device.device().cmd_push_constants(
command_buffer, command_buffer,
resources.pipeline_layout, resources.pipeline_layout,
@@ -2017,8 +2037,13 @@ impl VulkanSmokeRenderer {
.ok_or(VulkanSmokeRendererError::InvariantViolation { .ok_or(VulkanSmokeRendererError::InvariantViolation {
context: "draw-range diffuse-alpha override", context: "draw-range diffuse-alpha override",
})?; })?;
let diffuse_alpha_override = if reflected && range.terrain_reflection {
Some(fparkan_render::water::REFLECTED_WATER_ALPHA)
} else {
*diffuse_alpha_override
};
let material_alphas = let material_alphas =
draw_material_alphas(*material_alpha, *diffuse_alpha_override) draw_material_alphas(*material_alpha, diffuse_alpha_override)
.map(f32::to_ne_bytes) .map(f32::to_ne_bytes)
.concat(); .concat();
device.device().cmd_push_constants( device.device().cmd_push_constants(
@@ -2075,6 +2100,11 @@ impl VulkanSmokeRenderer {
}; };
let material_mode = (material_mode.round() as i32 let material_mode = (material_mode.round() as i32
| if reflected { 32 } else { 0 } | if reflected { 32 } else { 0 }
| if reflected && range.terrain_reflection {
256
} else {
0
}
| if !reflected | if !reflected
&& reflection_plane.is_some() && reflection_plane.is_some()
&& self.reflection_planes[range_index] == reflection_plane && self.reflection_planes[range_index] == reflection_plane
+37
View File
@@ -7,6 +7,21 @@ pub const EMBM_BUMP_TILE: f32 = 100.0;
/// Native diagonal bump matrix, without luminance modulation. /// Native diagonal bump matrix, without luminance modulation.
pub const EMBM_COEFFICIENTS: [f32; 2] = [0.01, 0.01]; pub const EMBM_COEFFICIENTS: [f32; 2] = [0.01, 0.01];
/// Terrain draws ordinary textured water into its reflection with this alpha.
pub const REFLECTED_WATER_ALPHA: f32 = 0.1;
/// Native packet category 4 enables source-alpha blending without changing depth.
#[must_use]
pub fn legacy_reflected_water_pipeline(
state: crate::LegacyPipelineState,
) -> crate::LegacyPipelineState {
crate::LegacyPipelineState {
blend: crate::LegacyBlendMode::SourceAlpha,
alpha_test: true,
..state
}
}
/// Square reflection size chosen by Terrain.dll RVA 0x27FC7 from renderer dimensions. /// Square reflection size chosen by Terrain.dll RVA 0x27FC7 from renderer dimensions.
#[must_use] #[must_use]
pub fn legacy_embm_reflection_size(extent: [u32; 2]) -> u32 { pub fn legacy_embm_reflection_size(extent: [u32; 2]) -> u32 {
@@ -127,6 +142,28 @@ pub fn legacy_embm_reflection_clip(
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn reflected_water_blends_material_alpha_and_preserves_depth_and_cull() {
use crate::{LegacyBlendMode, LegacyCullMode, LegacyDepthMode, LegacyPipelineState};
for depth in [
LegacyDepthMode::Disabled,
LegacyDepthMode::TestWrite,
LegacyDepthMode::TestReadOnly,
] {
let original = LegacyPipelineState {
depth,
cull: LegacyCullMode::FrontFace,
..LegacyPipelineState::default()
};
let fallback = legacy_reflected_water_pipeline(original);
assert_eq!(fallback.blend, LegacyBlendMode::SourceAlpha);
assert!(fallback.alpha_test);
assert_eq!(fallback.depth, original.depth);
assert_eq!(fallback.cull, original.cull);
}
assert_eq!(REFLECTED_WATER_ALPHA.to_bits(), 0x3dcc_cccd);
}
#[test] #[test]
fn reflection_size_matches_native_renderer_dimension_vectors() { fn reflection_size_matches_native_renderer_dimension_vectors() {
for (extent, expected) in [ for (extent, expected) in [
+1
View File
@@ -78,6 +78,7 @@ MSH-запрос со свободной preview-камерой; это не п
log на минимальных сценах с одним типом материала. log на минимальных сценах с одним типом материала.
Горизонтальные грани Land.msh с флагом `0x20000` используют исходную карту Du/Dv, окно shifted-камеры и квадратный размер текстуры, проверенные исполнением DLL. Диапазон глубины, небо/туман/FX основного вида и соответствие размеров при обрезанном capture остаются приближениями: `planar_reflection_render_context_approx`; неперспективная камера API использует обычное зеркало. Горизонтальные грани Land.msh с флагом `0x20000` используют исходную карту Du/Dv, окно shifted-камеры и квадратный размер текстуры, проверенные исполнением DLL. Диапазон глубины, небо/туман/FX основного вида и соответствие размеров при обрезанном capture остаются приближениями: `planar_reflection_render_context_approx`; неперспективная камера API использует обычное зеркало.
Обычный полупрозрачный слой воды внутри отражения восстановлен по живым пакетам и таблицам Ngi32. Ближняя вода в согласованных кадрах ещё отличается по яркости; полный контекст отражённого вида остаётся открытым.
Raw queue clock задаёт фазу волн отдельно от времени суток и sky elapsed; старые захваты без него считаются в `captured_water_phase_unverified`. Raw queue clock задаёт фазу волн отдельно от времени суток и sky elapsed; старые захваты без него считаются в `captured_water_phase_unverified`.
Один target отражает ближайшую активную высоту воды; остальные высоты и неплоские грани считаются в `planar_reflection_other_planes_unsupported` и `planar_reflection_nonplanar_unsupported` и сохраняют обычный terrain shader. Один target отражает ближайшую активную высоту воды; остальные высоты и неплоские грани считаются в `planar_reflection_other_planes_unsupported` и `planar_reflection_nonplanar_unsupported` и сохраняют обычный terrain shader.
`terrain_face8_pipeline_unverified` теперь считает только исходные грани с `0x8` без `0x20000`; сама семантика оставшихся специальных проходов ещё не установлена. `terrain_face8_pipeline_unverified` теперь считает только исходные грани с `0x8` без `0x20000`; сама семантика оставшихся специальных проходов ещё не установлена.
+17
View File
@@ -404,3 +404,20 @@ D3D7 в `fparkan-render`, а затем один раз меняет знак в
GPU может содержать результат смешивания поверхностей; повторное применение GPU может содержать результат смешивания поверхностей; повторное применение
его к готовому цвету затемнило бы кадр ещё раз. Поэтому конвертер и сборщик его к готовому цвету затемнило бы кадр ещё раз. Поэтому конвертер и сборщик
сравнительных пар игнорируют эту альфу, а raw-файл сохраняют без изменений. сравнительных пар игнорируют эту альфу, а raw-файл сохраняют без изменений.
### Вода внутри отражённого вида
В эталонных DLL из [таблицы сборки](original-binaries.md) Terrain RVA
`0x28A50` задаёт водному пакету material alpha `0,1` и packet category `4`,
когда уже строится отражение. Живой CShade-захват подтверждает одинаковые
водные вершины при `eye_z=115` и `185`, высоте воды `150`: отражённые пакеты
имеют alpha `0,1`, основные — `1`. Это общий проход водных граней,
а не поправка цвета карты.
Ngi32 mode 7, descriptors RVA `0x36C14` / `0x36C40`, содержит одну текстурную
стадию: `COLOR = TEXTURE × DIFFUSE`, `ALPHA = DIFFUSE`. Packet category 4
в таблице RVA `0x346E0 + category × 40` включает SRCALPHA / INVSRCALPHA
и альфа-тест GREATEREQUAL. Это отдельная таблица от MAT0 categories.
Depth enable/write передаются из байтов пакета `+0x12C/+0x12D` отдельно;
в захваченном водном проходе оба равны `1`. Начальные ZFUNC=LESSEQUAL,
ALPHAREF=1 задаются конструктором Ngi32; категория их не меняет.
+15
View File
@@ -1197,6 +1197,21 @@ Rust-тест отдельно проверяет углы водоёма и г
водоёма с добавленным смещением волн. Для неперспективной камеры API остаётся водоёма с добавленным смещением волн. Для неперспективной камеры API остаётся
обычное зеркало с экранными координатами. обычное зеркало с экранными координатами.
В отражённом виде видна и сама вода. После остального мира оригинал рисует
её обычной текстурой с непрозрачностью `0,1`; этот слой приглушает отражение
и сохраняет оттенок поверхности. Подготовка ландшафта задаёт такую альфу
и отдельную категорию пакета, когда уже строится отражение. Живой захват
подтверждает те же водные вершины у зеркальной камеры и у основной камеры:
в первом случае альфа равна `0,1`, во втором — `1`.
Внутренний водный слой использует texture mode 7: цвет текстуры умножается
на diffuse, а альфа берётся только из diffuse. Затем обычное смешивание
`src × alpha + dst × (1 − alpha)` соединяет его с отражённым миром.
Проверка и запись глубины сохраняются, альфа-тест пропускает значения
не меньше `1/255`. FParkan воспроизводит этот проход после отражённой сцены
и читает исходную текстуру материала, поэтому изображение отражения
можно закончить до его чтения основной камерой.
Размер изображения отражения выбирается отдельно от размера основного кадра. Размер изображения отражения выбирается отдельно от размера основного кадра.
Terrain.dll в конструкторе CShade (RVA `0x278E0`, цикл с `0x27FC7`) берёт Terrain.dll в конструкторе CShade (RVA `0x278E0`, цикл с `0x27FC7`) берёт
ширину и высоту renderer через Ngi32.dll RVA `0x7FB0`. Сторона квадратной ширину и высоту renderer через Ngi32.dll RVA `0x7FB0`. Сторона квадратной