From 30be24e38e459f294e394f2fac782d836bce96b7 Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Thu, 15 Oct 2026 08:57:45 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=BE=D0=B4=D0=B0:=20=D0=B7=D0=B0=D1=82?= =?UTF-8?q?=D1=83=D1=85=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=20=D0=B2?= =?UTF-8?q?=D0=BE=D0=BB=D0=BD=D0=BE=D0=B2=D0=BE=D0=BC=D1=83=20=D1=87=D0=B8?= =?UTF-8?q?=D1=81=D0=BB=D1=83=20=D0=B8=20=D0=BE=D1=82=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=BA=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=BF=D0=BE=20=D0=BF=D0=BB?= =?UTF-8?q?=D0=BE=D1=81=D0=BA=D0=BE=D0=B9=20=D0=BF=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=85=D0=BD=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Затухание было 1/(1 + d²·0.00012) — на 900 юнитах это 0,01, и озеро с высоты читалось зеркальной плитой. Алиасинг бьёт только по мелким волнам: дальше пикселя. Заменено на затухание по волновому числу каждой волны отдельно, local = 1/(1 + (k·d·0.01)²). Волна длиной 46 юнитов на 900 юнитах ещё видна (0,4), длиной 2,7 — уже нет (0,002). discard считался по волновой нормали: на скользящих углах dot(n, view) проваливался в ноль и поверхность выбивалась дырками, край озера рвался зубцом. Теперь facing берётся по плоской поверхности — это вопрос геометрии, а волновая нормаль идёт только в френель и блик. --- crates/render/src/water.wgsl | 33 +++++++++++++++++---------------- docs/PARITY.md | 22 ++++++++++++++++++++++ 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/crates/render/src/water.wgsl b/crates/render/src/water.wgsl index f46b23c..1c46754 100644 --- a/crates/render/src/water.wgsl +++ b/crates/render/src/water.wgsl @@ -57,11 +57,11 @@ fn wave(p: vec2, dir: vec2, freq: f32, speed: f32, time: f32) -> f32 { /// сжимается, ложбина растягивается. Именно это делает воду похожей на воду; /// ровная синусоида читается как гофрированный металл. Отсюда и требование из /// референсов: «Gerstner, not sine». -fn gerstner_normal(p: vec2, time: f32, falloff: f32) -> vec3 { +fn gerstner_normal(p: vec2, time: f32, distance: f32) -> vec3 { // Длина волны в юнитах, амплитуда, крутизна (0 — чистый синус, ближе к 1 — // острее гребень) и скорость. Длины идут от крупных к мелким. let lengths = array(46.0, 23.0, 11.0, 5.5, 2.7); - let amps = array(1.30, 0.60, 0.26, 0.11, 0.045); + let amps = array(1.70, 0.78, 0.33, 0.14, 0.06); let steeps = array(0.60, 0.55, 0.50, 0.40, 0.30); let speeds = array(0.55, 0.80, 1.15, 1.60, 2.10); let dirs = array, 5>( @@ -77,9 +77,11 @@ fn gerstner_normal(p: vec2, time: f32, falloff: f32) -> vec3 { for (var i = 0u; i < 5u; i = i + 1u) { let dir = normalize(dirs[i]); let k = 6.28318530718 / lengths[i]; - // Мелкие волны должны умирать на расстоянии быстрее крупных, иначе - // вдали поверхность рассыпается в серо-голубой шум. - let local = select(falloff, falloff * falloff, i >= 3u); + // Алиасинг бьёт по МЕЛКИМ волнам: на далёком расстоянии они уже + // меньше пикселя и превращаются в шум. Крупные должны доживать до + // горизонта, иначе сверху вода читалась как зеркальная плита (замер: + // прежнее затухание 1/(1+d²·0.00012) на 900 юнитах давало 0,01). + let local = 1.0 / (1.0 + pow(k * distance * 0.01, 2.0)); let amp = amps[i] * local; let qa = steeps[i] * amp; let phase = k * dot(dir, p) - time * speeds[i] * k; @@ -108,11 +110,7 @@ fn gerstner_normal(p: vec2, time: f32, falloff: f32) -> vec3 { /// Нормаль поверхности воды. fn ripple_normal(p: vec3, base: vec3, time: f32, distance: f32) -> vec3 { - // Затухание подобрано замером кадра: при линейном затухании рябь не гасла - // до горизонта и давала череду полос. Дальше ~150 юнитов рябь уже не - // читается, а не гаснет — это алиасинг. - let falloff = 1.0 / (1.0 + distance * distance * 0.00012); - let n = gerstner_normal(p.xz, time, falloff); + let n = gerstner_normal(p.xz, time, distance); // Совсем мелкая рябь поверх — только вблизи, иначе мерцает. let micro = 1.0 / (1.0 + distance * 0.08); let detail = @@ -130,19 +128,22 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { // Время приходит из буфера камеры: иначе рябь стояла бы на месте, а если // выводить его из позиции — картинка дёргалась бы при повороте. let n = ripple_normal(in.world_pos, base, camera.time.x, distance); - let facing = dot(n, view); - // Вода — две грани (поверхность и изнанка); рисуем только лицевую. + // Лицевая ли грань — вопрос к ГЕОМЕТРИИ, поэтому считаем по плоской + // поверхности, а не по волновой нормали. Иначе на скользящих углах волна + // наклоняет нормаль достаточно, чтобы facing провалился в ноль, и поверхность + // выбивалась дырками — край озера рвался зубцом. + let facing = dot(base, view); if (facing <= 0.02) { discard; } - // Френель: насколько поверхность «повёрнута» от взгляда. На горизонтальном - // взгляде вода почти зеркалит небо, сверху — видно дно. - let fresnel = clamp(pow(1.0 - clamp(facing, 0.0, 1.0), 4.0), 0.02, 1.0); + // Френель по волновой нормали: рябь должна дёргать отражение неба. + let facing_n = clamp(dot(n, view), 0.0, 1.0); + let fresnel = clamp(pow(1.0 - facing_n, 4.0), 0.02, 1.0); let deep = textureSample(base_texture, base_sampler, in.uv).rgb * in.light; // Отражение неба: чем ниже угол, тем темнее и холоднее отражение. let sky_grazing = vec3(0.70, 0.80, 0.92); let sky_steep = vec3(0.42, 0.58, 0.74); - let sky = mix(sky_steep, sky_grazing, clamp(facing, 0.0, 1.0)); + let sky = mix(sky_steep, sky_grazing, facing_n); // Блик от солнца: узкий, как у воды, а не матовая подсветка. let sun_dir = normalize(vec3(0.35, 0.45, 0.82)); let reflect = reflect(-view, n); diff --git a/docs/PARITY.md b/docs/PARITY.md index ab1460f..f3a0afc 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -1820,3 +1820,25 @@ setpos 1296.484375 5623.283203 3195.700195; setang 28.374243 -133.538101 0 выше 1000 отсутствует (0 треугольников), рядом с камерой выше 2000 — тоже 0. Значит закрывающая плита — не рельеф, и причина не найдена. Записано здесь честно; E11.3 этот дефект не объясняет и не вызван им. + +### E11.6. Вода: затухание и отбраковка по волновой нормали + +Две правки, обе найдены замером кадра, а не правдоподобием. + +**Затухание бьло по всем волнам сразу.** Было `1/(1 + d²·0.00012)` — на 900 +юнитах это 0,01, и с высоты озеро читалось зеркальной плитой. Алиасинг бьёт +только по **мелким** волнам: они дальше пикселя. Заменено на затухание по +волновому числу каждой волны отдельно: + +``` +local = 1 / (1 + (k · distance · 0.01)²) +``` + +При таком на 900 юнитах волна длиной 46 юнитов ещё видна (0,4), а длиной +2,7 — уже нет (0,002). + +**`discard` считался по волновой нормали.** Вопрос «лицевая ли грань» +относится к геометрии, а не к микрорельефу: на скользящих углах волна +наклоняла нормаль так, что `dot(n, view)` проваливался в ноль, и поверхность +выбивалась дырками — край озера рвался зубцом. Теперь `facing` берётся по +плоской поверхности, а волновая нормаль идёт только в френель и блик.