diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 1c8c8f4..eafef8d 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -1421,9 +1421,14 @@ cloud, rain, snow}` (0..1), `DEFAULT_WEATHER`, `clampWeather` (частичный ввод моста → полная валидная), `windVector` (сила 1 — 6 юнит/с), `rainPoints`/`snowPoints(spec, t, win)` — детерминированные позы слоёв от - времени цикла (якоря i·константа + падение/дрейф от вектора ветра; капля + времени цикла (колонка + падение/дрейф от вектора ветра; капля тяжёлая — дрейф ×0.6, снежинка качается и дрейфует полностью; капля - рисуется штрихом — цепочкой из 3 точек над головой). Счётчик — ПЛОТНОСТЬ + рисуется штрихом — цепочкой из 3 точек над головой). Респавн наверху — + ПОЛНЫЙ реролл колонки: sin-хэш от номера цикла × большой период + (`RAIN_SPAN` 731 / `SNOW_SPAN` 613). Сдвиг в окрестности якоря (±20) — + первый вариант — оставлял каплю падать в одной полосе: трек «горел» на + экране вечно, а поле колонн было квази-статично в мире (кадры через цикл + падения совпадали). Счётчик — ПЛОТНОСТЬ на площадь окна (`RAIN_DENSITY`/`SNOW_DENSITY` + потолок `RAIN_MAX`/ `SNOW_MAX`): окно `weatherWindow(renderer, top)` = видимый след кадра + маржа 16 — но след по высоте слоя уходит в глубину до дальней плоскости diff --git a/v2/packages/engine/src/render/__tests__/weather.test.ts b/v2/packages/engine/src/render/__tests__/weather.test.ts index 2daa5fa..0ff1173 100644 --- a/v2/packages/engine/src/render/__tests__/weather.test.ts +++ b/v2/packages/engine/src/render/__tests__/weather.test.ts @@ -79,13 +79,33 @@ }); it('респавн меняет колонку: траектория не повторяется по циклам', () => { // без ветра капля i=0 на цикле 0 и цикле+1 — на том же y (начало - // падения), но на другой колонке (sin-хэш от номера цикла) + // падения), но на другой колонке (полный реролл по RAIN_SPAN) const spec = { ...DEFAULT_WEATHER, rain: 1 }; const a = rainPoints(spec, 0, WIN)[0]!.pos; const b = rainPoints(spec, 40 / RAIN_SPEED_TEST, WIN)[0]!.pos; // ровно один цикл expect(b[1]).toBeCloseTo(a[1]); expect(b[0]).not.toBeCloseTo(a[0], 3); }); + it('поле дождя не квази-статично: через цикл переброшены почти все капли', () => { + // Сдвиг респавна в узкой окрестности якоря оставлял каплю в той же + // полосе — трек «горел» на экране вечно. Полный реролл перемещает + // подавляющее большинство капель (совпадение колоны — редкость). + const spec = { ...DEFAULT_WEATHER, rain: 1 }; + const a = rainPoints(spec, 0, WIN); + const b = rainPoints(spec, 40 / RAIN_SPEED_TEST, WIN); + let moved = 0; + for (let i = 0; i < a.length; i += 3) { // головы штрихов + if (Math.abs(a[i]!.pos[0] - b[i]!.pos[0]) > 2) moved++; + } + expect(moved).toBeGreaterThanOrEqual((a.length / 3) * 0.8); + }); + it('внутри падения колонка неизменна (капля падает по прямой)', () => { + const spec = { ...DEFAULT_WEATHER, rain: 1 }; + const a = rainPoints(spec, 3, WIN); + const b = rainPoints(spec, 3.5, WIN); + expect(b[0]!.pos[0]).toBeCloseTo(a[0]!.pos[0], 6); + expect(b[0]!.pos[2]).toBeCloseTo(a[0]!.pos[2], 6); + }); }); describe('cloudDim (облачность)', () => { diff --git a/v2/packages/engine/src/render/weather.ts b/v2/packages/engine/src/render/weather.ts index 47db7c1..77b43d9 100644 --- a/v2/packages/engine/src/render/weather.ts +++ b/v2/packages/engine/src/render/weather.ts @@ -9,9 +9,11 @@ * слоя) уходит в глубину до дальней плоскости и растёт с зумом камеры — * фиксированный список капель размазывается по нему в субпиксельную пыль. * Дождь рисуется штрихами — цепочкой точек над головой капли. Респавн - * наверху — в новой колонке (sin-хэш от номера цикла падения): без этого - * капля вечно повторяет одну траекторию, рисунок дождя зациклен. Позы - * детерминированы временем цикла — кадр-равенства гейтов работают. + * наверху — ПОЛНЫЙ реролл колонки (sin-хэш от номера цикла падения + * умножается на большой период): сдвиг в окрестности якоря оставляет каплю + * падать в той же полосе — «трек остаётся на экране», а поле колонн + * квази-статично в мире. Позы детерминированы временем цикла — + * кадр-равенства гейтов работают. */ import type { ParticleLayer } from './particles'; import type { VoxelRenderer } from './voxelRenderer'; @@ -72,13 +74,13 @@ const RAIN_ALPHA = 0.75; const RAIN_CHAIN = 3; // точек в штрихе (хвост выше головы) const RAIN_STEP = 0.7; // шаг цепочки, юниты (штрих ~1.4 юнита) -const RAIN_RESPAWN = 41; // разброс колонки при респавне (ломает повтор траектории) +const RAIN_SPAN = 731; // период переброса колонки при респавне (полный реролл) const SNOW_DENSITY = 0.007; // хлопьев на кв. юнит окна при snow = 1 const SNOW_MAX = 2000; const SNOW_TOP = 36; const SNOW_SWAY = 1.2; // размах покачивания при падении -const SNOW_RESPAWN = 31; // разброс колонки при респавне +const SNOW_SPAN = 613; // период переброса колонки при респавне (полный реролл) const SNOW_SIZE = 0.7; const SNOW_COLOR = '#eef3f6'; const SNOW_ALPHA = 0.85; @@ -97,10 +99,11 @@ return s - Math.floor(s); } -/** Позы капель: якорь + падение + дрейф ветра (капля тяжёлая — дрейф ×0.6). - * Респавн наверху случается в НОВОЙ колонке — к якорю добавляется sin-хэш - * от номера цикла падения, иначе капля вечно падает по одной траектории. - * Капля рисуется штрихом — цепочкой точек над головой (хвост бледнее). */ +/** Позы капель: колонка + падение + дрейф ветра (капля тяжёлая — дрейф ×0.6). + * Респавн наверху случается в НОВОЙ колонке: к якорю добавляется sin-хэш от + * номера цикла падения, растянутый на большой период RAIN_SPAN. Сдвиг в + * узкой окрестности якоря оставлял каплю в одной полосе — трек «горел» на + * экране вечно. Капля рисуется штрихом — цепочкой точек над головой. */ export function rainPoints( spec: WeatherSpec, t: number, win: { minX: number; maxX: number; minZ: number; maxZ: number }, @@ -113,8 +116,8 @@ const ph = i * 5.3; const cyc = Math.floor((t * speed + ph) / RAIN_TOP); // номер цикла падения const fall = RAIN_TOP - ((t * speed + ph) % RAIN_TOP); - const x = wrapV(i * 37.7 + (hash01(i, cyc) - 0.5) * RAIN_RESPAWN + t * wx * 0.6, win.minX, win.maxX); - const z = wrapV(i * 53.9 + (hash01(i * 3.7, cyc) - 0.5) * RAIN_RESPAWN + t * wz * 0.6, win.minZ, win.maxZ); + const x = wrapV(i * 37.7 + hash01(i, cyc) * RAIN_SPAN + t * wx * 0.6, win.minX, win.maxX); + const z = wrapV(i * 53.9 + hash01(i * 3.7, cyc) * RAIN_SPAN + t * wz * 0.6, win.minZ, win.maxZ); const a = edge(win, x, z) * RAIN_ALPHA; for (let k = 0; k < RAIN_CHAIN; k++) { out.push({ @@ -128,7 +131,7 @@ } /** Позы снежинок: медленное падение с покачиванием + полный дрейф ветра; - * колонка респавна — sin-хэш от номера цикла (как у дождя). */ + * колонка респавна — полный реролл по SNOW_SPAN (как у дождя). */ export function snowPoints( spec: WeatherSpec, t: number, win: { minX: number; maxX: number; minZ: number; maxZ: number }, @@ -143,8 +146,8 @@ const p = i * 2.399; // фазы покачивания — золотое сечение const fall = SNOW_TOP - ((t * speed + ph) % SNOW_TOP); const sway = Math.sin(t * 0.8 + p) * SNOW_SWAY; - const x = wrapV(i * 61.7 + (hash01(i, cyc) - 0.5) * SNOW_RESPAWN + t * wx + sway, win.minX, win.maxX); - const z = wrapV(i * 47.3 + (hash01(i * 1.7, cyc) - 0.5) * SNOW_RESPAWN + t * wz + const x = wrapV(i * 61.7 + hash01(i, cyc) * SNOW_SPAN + t * wx + sway, win.minX, win.maxX); + const z = wrapV(i * 47.3 + hash01(i * 1.7, cyc) * SNOW_SPAN + t * wz + Math.cos(t * 0.6 + p) * SNOW_SWAY * 0.6, win.minZ, win.maxZ); out.push({ pos: [x, fall, z], alpha: edge(win, x, z) * SNOW_ALPHA }); }