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) {
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 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;
@@ -92,15 +94,16 @@ void main() {
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);
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)
+ in_detail_uv * vec2(material.detail_page_w, material.detail_page_h);
vec2 overlay_uv = vec2(material.overlay_page_x, material.overlay_page_y)
@@ -1936,7 +1936,16 @@ impl VulkanSmokeRenderer {
&[],
);
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(
VulkanSmokeRendererError::InvariantViolation {
context: "stable draw order range handle",
@@ -1948,11 +1957,17 @@ impl VulkanSmokeRenderer {
},
)?;
let mode = self.material_unlit[texture_index].round() as i32;
if reflected && (range.terrain_reflection || mode & 8 != 0) {
if reflected && mode & 8 != 0 {
continue;
}
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 {
range.pipeline_state
};
@@ -1998,7 +2013,12 @@ impl VulkanSmokeRenderer {
std::slice::from_ref(descriptor_set),
&[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(
command_buffer,
resources.pipeline_layout,
@@ -2017,8 +2037,13 @@ impl VulkanSmokeRenderer {
.ok_or(VulkanSmokeRendererError::InvariantViolation {
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 =
draw_material_alphas(*material_alpha, *diffuse_alpha_override)
draw_material_alphas(*material_alpha, diffuse_alpha_override)
.map(f32::to_ne_bytes)
.concat();
device.device().cmd_push_constants(
@@ -2075,6 +2100,11 @@ impl VulkanSmokeRenderer {
};
let material_mode = (material_mode.round() as i32
| if reflected { 32 } else { 0 }
| if reflected && range.terrain_reflection {
256
} else {
0
}
| if !reflected
&& reflection_plane.is_some()
&& 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.
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.
#[must_use]
pub fn legacy_embm_reflection_size(extent: [u32; 2]) -> u32 {
@@ -127,6 +142,28 @@ pub fn legacy_embm_reflection_clip(
mod tests {
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]
fn reflection_size_matches_native_renderer_dimension_vectors() {
for (extent, expected) in [
+1
View File
@@ -78,6 +78,7 @@ MSH-запрос со свободной preview-камерой; это не п
log на минимальных сценах с одним типом материала.
Горизонтальные грани 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`.
Один target отражает ближайшую активную высоту воды; остальные высоты и неплоские грани считаются в `planar_reflection_other_planes_unsupported` и `planar_reflection_nonplanar_unsupported` и сохраняют обычный terrain shader.
`terrain_face8_pipeline_unverified` теперь считает только исходные грани с `0x8` без `0x20000`; сама семантика оставшихся специальных проходов ещё не установлена.
+17
View File
@@ -404,3 +404,20 @@ D3D7 в `fparkan-render`, а затем один раз меняет знак в
GPU может содержать результат смешивания поверхностей; повторное применение
его к готовому цвету затемнило бы кадр ещё раз. Поэтому конвертер и сборщик
сравнительных пар игнорируют эту альфу, а 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 остаётся
обычное зеркало с экранными координатами.
В отражённом виде видна и сама вода. После остального мира оригинал рисует
её обычной текстурой с непрозрачностью `0,1`; этот слой приглушает отражение
и сохраняет оттенок поверхности. Подготовка ландшафта задаёт такую альфу
и отдельную категорию пакета, когда уже строится отражение. Живой захват
подтверждает те же водные вершины у зеркальной камеры и у основной камеры:
в первом случае альфа равна `0,1`, во втором — `1`.
Внутренний водный слой использует texture mode 7: цвет текстуры умножается
на diffuse, а альфа берётся только из diffuse. Затем обычное смешивание
`src × alpha + dst × (1 − alpha)` соединяет его с отражённым миром.
Проверка и запись глубины сохраняются, альфа-тест пропускает значения
не меньше `1/255`. FParkan воспроизводит этот проход после отражённой сцены
и читает исходную текстуру материала, поэтому изображение отражения
можно закончить до его чтения основной камерой.
Размер изображения отражения выбирается отдельно от размера основного кадра.
Terrain.dll в конструкторе CShade (RVA `0x278E0`, цикл с `0x27FC7`) берёт
ширину и высоту renderer через Ngi32.dll RVA `0x7FB0`. Сторона квадратной