diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 901dfbd..928431d 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -1431,7 +1431,14 @@ `rainPoints`/`snowPoints(spec, t, win)` — детерминированные позы слоёв от времени цикла (колонка + падение/дрейф от вектора ветра; капля тяжёлая — дрейф ×0.6, снежинка качается и дрейфует полностью; капля - рисуется штрихом — цепочкой из 3 точек над головой). Респавн наверху — + рисуется штрихом — цепочкой из 3 точек над головой). **Глубина осадков** + (план даль/близь): камера ортографическая, параллакса нет — глубина + симулируется расхождением параметров частицы от стабильной «глубины» + d∈[0..1] (sin-хэш, лёгкий уклон в дальний план — масса читается вдали): + дальнее меньше/медленнее/бледнее (аэроперспектива), ближнее крупнее/ + быстрее; у дождя дальние штрихи короче, у снега ближние качаются шире. + Скорости падения ×1.4 (дождь 31 юнит/с, снег 1.7 — фидбек «медленно»). + Респавн наверху — ПОЛНЫЙ реролл колонки: sin-хэш от номера цикла × большой период (`RAIN_SPAN` 731 / `SNOW_SPAN` 613). Сдвиг в окрестности якоря (±20) — первый вариант — оставлял каплю падать в одной полосе: трек «горел» на diff --git a/v2/packages/engine/src/render/__tests__/weather.test.ts b/v2/packages/engine/src/render/__tests__/weather.test.ts index 0ff1173..fa6140a 100644 --- a/v2/packages/engine/src/render/__tests__/weather.test.ts +++ b/v2/packages/engine/src/render/__tests__/weather.test.ts @@ -7,7 +7,19 @@ } from '../weather'; const WIN = { minX: 0, maxX: 48, minZ: 0, maxZ: 32 }; -const RAIN_SPEED_TEST = 22; // базовая скорость капли (i=0 — без надбавки) + +/** Моменты респавна головы капли drop (y подпрыгивает с низа к верху слоя — + * обычное падение за шаг скана даёт сдвиг в доли юнита, не прыжок). */ +function headWraps(spec: ReturnType, drop: number, tMax = 8): number[] { + const wraps: number[] = []; + let prev = -1; + for (let t = 0; t <= tMax; t += 0.002) { + const y = rainPoints(spec, t, WIN)[drop * 3]!.pos[1]; + if (prev >= 0 && y - prev > 20) wraps.push(t); + prev = y; + } + return wraps; +} describe('clampWeather', () => { it('частичный ввод дополняется дефолтом', () => { @@ -78,21 +90,25 @@ for (const p of snowPoints(spec, 13, WIN)) expect(p.pos[1]).toBeGreaterThanOrEqual(0); }); it('респавн меняет колонку: траектория не повторяется по циклам', () => { - // без ветра капля i=0 на цикле 0 и цикле+1 — на том же y (начало - // падения), но на другой колонке (полный реролл по 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); + const w = headWraps(spec, 0); + expect(w.length).toBeGreaterThanOrEqual(2); + const a = rainPoints(spec, w[0]!, WIN)[0]!.pos; + const b = rainPoints(spec, w[1]!, WIN)[0]!.pos; + expect(b[1]).toBeCloseTo(a[1], 0); // та же фаза — верх слоя + expect(b[0]).not.toBeCloseTo(a[0], 3); // но новая колонка (реролл) }); it('поле дождя не квази-статично: через цикл переброшены почти все капли', () => { // Сдвиг респавна в узкой окрестности якоря оставлял каплю в той же // полосе — трек «горел» на экране вечно. Полный реролл перемещает // подавляющее большинство капель (совпадение колоны — редкость). + // 5 с ≥ 2 полных циклов любой капли (слой 40, скорость ≥ ~17) — + // к этому моменту у всех переброшен номер цикла падения. const spec = { ...DEFAULT_WEATHER, rain: 1 }; const a = rainPoints(spec, 0, WIN); - const b = rainPoints(spec, 40 / RAIN_SPEED_TEST, WIN); + const b = rainPoints(spec, 5, 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++; @@ -100,11 +116,24 @@ expect(moved).toBeGreaterThanOrEqual((a.length / 3) * 0.8); }); it('внутри падения колонка неизменна (капля падает по прямой)', () => { + // от сразу после респавна до середины падения — цикл не сменится + // (длина цикла ≥ ~1 с, взяли 0.5) 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); + const w = headWraps(spec, 0); + const a = rainPoints(spec, w[0]! + 0.05, WIN)[0]!.pos; + const b = rainPoints(spec, w[0]! + 0.55, WIN)[0]!.pos; + expect(b[0]).toBeCloseTo(a[0]!, 6); + expect(b[2]).toBeCloseTo(a[2]!, 6); + }); + it('глубина: в поле есть дальний план (бледнее/мельче) и ближний (крупнее)', () => { + const spec = { ...DEFAULT_WEATHER, rain: 1, snow: 1 }; + const drops = rainPoints(spec, 2, WIN); + const sizes = drops.map((p) => p.size ?? 0); + expect(Math.max(...sizes)).toBeGreaterThan(Math.min(...sizes) * 2); + expect(Math.min(...drops.map((p) => p.alpha))).toBeLessThan(Math.max(...drops.map((p) => p.alpha)) * 0.5); + const flakes = snowPoints(spec, 2, WIN); + const fsz = flakes.map((p) => p.size ?? 0); + expect(Math.max(...fsz)).toBeGreaterThan(Math.min(...fsz) * 2); }); }); diff --git a/v2/packages/engine/src/render/weather.ts b/v2/packages/engine/src/render/weather.ts index 77b43d9..c4ca0ba 100644 --- a/v2/packages/engine/src/render/weather.ts +++ b/v2/packages/engine/src/render/weather.ts @@ -8,7 +8,11 @@ * Счётчик капель — плотность на площадь окна: окно (след кадра по высоте * слоя) уходит в глубину до дальней плоскости и растёт с зумом камеры — * фиксированный список капель размазывается по нему в субпиксельную пыль. - * Дождь рисуется штрихами — цепочкой точек над головой капли. Респавн + * Дождь рисуется штрихами — цепочкой точек над головой капли. Глубина + * (план даль/близь) симулируется расхождением параметров частицы — камера + * ортографическая, параллакса нет: далёкие капли/хлопья меньше, медленнее + * и бледнее (аэроперспектива), ближние крупнее, быстрее; у дождя дальние + * штрихи короче, у снега ближние качаются шире. Респавн * наверху — ПОЛНЫЙ реролл колонки (sin-хэш от номера цикла падения * умножается на большой период): сдвиг в окрестности якоря оставляет каплю * падать в той же полосе — «трек остаётся на экране», а поле колонн @@ -68,18 +72,19 @@ const RAIN_DENSITY = 0.016; // капель на кв. юнит окна при rain = 1 const RAIN_MAX = 4000; // потолок счёта (страховка от гигантских окон) const RAIN_TOP = 40; // высота слоя дождя (вертикальный цикл капли) -const RAIN_SPEED = 22; // падение, юнит/с -const RAIN_SIZE = 0.55; // точка-голова штриха (~2 px при 3.3 px/юнит) +const RAIN_SPEED = 31; // падение, юнит/с (×1.4 — фидбек «медленно») +const RAIN_SIZE = 0.55; // точка-голова штриха ближнего плана (~2 px) const RAIN_COLOR = '#a8bacb'; const RAIN_ALPHA = 0.75; const RAIN_CHAIN = 3; // точек в штрихе (хвост выше головы) -const RAIN_STEP = 0.7; // шаг цепочки, юниты (штрих ~1.4 юнита) +const RAIN_STEP = 0.7; // шаг цепочки ближнего плана (штрих ~1.4 юнита) 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_SPEED = 1.7; // падение, юнит/с (×1.4 от 1.2 — фидбек) +const SNOW_SWAY = 1.2; // размах покачивания ближнего плана const SNOW_SPAN = 613; // период переброса колонки при респавне (полный реролл) const SNOW_SIZE = 0.7; const SNOW_COLOR = '#eef3f6'; @@ -87,6 +92,25 @@ const MARGIN = 16; // перекрытие за краем кадра (как у пепла) +// ---- Глубина (план даль/близь) ---- +// Камера ортографическая — параллакса от глубины нет, поэтому глубина +// частицы симулируется расхождением параметров (стандартный приём 2.5D): +// далёкое — меньше, медленнее и бледнее (аэроперспектива), ближнее — +// крупнее, быстрее и насыщеннее; у дождя дальние штрихи короче, у снега +// ближние хлопья качаются шире. Глубина стабильна на частицу (это её +// «план»), респавн перебрасывает только колонку. Лёгкий уклон в дальний +// план — масса осадков читается вдали, отдельные капли вблизи. + +/** Глубина частицы 0..1 (0 — дальний план, 1 — ближний), по номеру. */ +function depth01(i: number): number { + return Math.pow(hash01(i, 91.7), 1.3); +} + +/** Масштаб глубины: [дальний, ближний] × глубина. */ +function dScale(near: number, far: number, d: number): number { + return far + (near - far) * d; +} + /** Плотность → счётчик: площадь окна × интенсивность, с потолком. */ function densityCount(density: number, k: number, win: { minX: number; maxX: number; minZ: number; maxZ: number }, max: number): number { const area = (win.maxX - win.minX) * (win.maxZ - win.minZ); @@ -103,7 +127,8 @@ * Респавн наверху случается в НОВОЙ колонке: к якорю добавляется sin-хэш от * номера цикла падения, растянутый на большой период RAIN_SPAN. Сдвиг в * узкой окрестности якоря оставлял каплю в одной полосе — трек «горел» на - * экране вечно. Капля рисуется штрихом — цепочкой точек над головой. */ + * экране вечно. Капля рисуется штрихом — цепочкой точек над головой; + * глубина частицы масштабирует скорость/размер/альфу и длину штриха. */ export function rainPoints( spec: WeatherSpec, t: number, win: { minX: number; maxX: number; minZ: number; maxZ: number }, @@ -112,18 +137,21 @@ const [wx, wz] = windVector(spec); const out: { pos: [number, number, number]; alpha: number; size?: number }[] = []; for (let i = 0; i < n; i++) { - const speed = RAIN_SPEED + (i % 7) * 1.3; // разлёт скоростей — стена дождя + const d = depth01(i); + const speed = (RAIN_SPEED + (i % 7) * 1.3) * dScale(1, 0.55, d); 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) * 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; + const step = RAIN_STEP * dScale(1.2, 0.5, d); // ближний штрих длиннее + const sz = RAIN_SIZE * dScale(1.15, 0.45, d); + const a = edge(win, x, z) * RAIN_ALPHA * dScale(1, 0.4, d); for (let k = 0; k < RAIN_CHAIN; k++) { out.push({ - pos: [x, fall + k * RAIN_STEP, z], + pos: [x, fall + k * step, z], alpha: a * (1 - k * 0.35), - size: RAIN_SIZE - k * 0.1, + size: sz * (1 - k * 0.1), }); } } @@ -131,25 +159,32 @@ } /** Позы снежинок: медленное падение с покачиванием + полный дрейф ветра; - * колонка респавна — полный реролл по SNOW_SPAN (как у дождя). */ + * колонка респавна — полный реролл по SNOW_SPAN (как у дождя). Глубина + * масштабирует скорость/качание (ближние хлопья качаются шире)/размер/альфу. */ export function snowPoints( spec: WeatherSpec, t: number, win: { minX: number; maxX: number; minZ: number; maxZ: number }, -): { pos: [number, number, number]; alpha: number }[] { +): { pos: [number, number, number]; alpha: number; size?: number }[] { const n = densityCount(SNOW_DENSITY, spec.snow, win, SNOW_MAX); const [wx, wz] = windVector(spec); - const out: { pos: [number, number, number]; alpha: number }[] = []; + const out: { pos: [number, number, number]; alpha: number; size?: number }[] = []; for (let i = 0; i < n; i++) { - const speed = 1.2 + (i % 5) * 0.3; + const d = depth01(i); + const speed = (SNOW_SPEED + (i % 5) * 0.42) * dScale(1, 0.5, d); const ph = (i * 4.1) % SNOW_TOP; const cyc = Math.floor((t * speed + ph) / SNOW_TOP); 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 swayAmp = SNOW_SWAY * dScale(1.3, 0.4, d); + const sway = Math.sin(t * 0.8 + p) * swayAmp; 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 }); + + Math.cos(t * 0.6 + p) * swayAmp * 0.6, win.minZ, win.maxZ); + out.push({ + pos: [x, fall, z], + alpha: edge(win, x, z) * SNOW_ALPHA * dScale(1, 0.45, d), + size: SNOW_SIZE * dScale(1.2, 0.5, d), + }); } return out; } @@ -211,7 +246,7 @@ const drops = rainPoints(this.spec, t, win); this.rain.update(drops.map((d) => ({ pos: d.pos, color: RAIN_COLOR, alpha: d.alpha, size: d.size }))); const flakes = snowPoints(this.spec, t, win); - this.snow.update(flakes.map((f) => ({ pos: f.pos, color: SNOW_COLOR, alpha: f.alpha, size: SNOW_SIZE }))); + this.snow.update(flakes.map((f) => ({ pos: f.pos, color: SNOW_COLOR, alpha: f.alpha, size: f.size ?? SNOW_SIZE }))); this.lastDrops = drops.length / RAIN_CHAIN; // штрих = одна капля this.lastFlakes = flakes.length; }