diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 6a6a93e..30e9832 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -1500,11 +1500,20 @@ юнита): фиксированный список капель размазывался по нему в субпиксельную пыль (капля 0.5 юнита при 3.3 px/юнит — 1.7 px). Wrap и edgeAlpha гасят частицы у края (как у пепла: без «появления из воздуха»). - `WeatherSystem(renderer)` — два слоя через `renderer.addParticles()` - (спрайты-билборды: ambient-тинт и свет источников применяются слоями - сами); `set(spec)` клампит и сразу гонит облачность в рендер, `update(t)` - переставляет позы, `info()` — счётчики для снапшота, `setTint` — ночь - гасит осадки. + **Многослойность** (2026-09-12, фидбек «сильно гуще, как туман»): + RAIN_LAYERS/SNOW_LAYERS = 3 слоя частиц, каждый в своей полосе глубины + (ближний 0.5..1.0 редкий и крупный, дальний 0..0.34 частый и мелкий — + стена дождя в глубине; полосы перекрываются — слоистость не видна + линиями). Суммарная плотность ~3× одиночного слоя: дождь 0.044 + (ливень ~6100 капель у игры), снег 0.022 (~3100 хлопьев). Глобальный + номер gi = i + k·7919/6271 разводит колонки слоёв — без него слои + легли бы в одни x. `rainPoints`/`snowPoints(spec, t, win, layer?)` — + без layer все слои разом (тесты/счётчики). + `WeatherSystem(renderer)` — RAIN_LAYERS/SNOW_LAYERS слоёв через + `renderer.addParticles()` (спрайты-билборды, lit: ambient-тинт и свет + источников применяются слоями сами); `set(spec)` клампит и сразу гонит + облачность в рендер, `update(t)` переставляет позы, `info()` — + счётчики для снапшота. - **Облачность в рендере** (`voxelRenderer.ts` + `daynight.ts`): `setCloud(k)` — солнце ×(1−0.6k), ambient/фон обесцвечиваются к серому и темнеют (`cloudDim(color, k, sat, dim)` — чистая, тестируется). Метод применяется diff --git a/v2/packages/engine/src/render/__tests__/weather.test.ts b/v2/packages/engine/src/render/__tests__/weather.test.ts index e5abbe6..8b80cf4 100644 --- a/v2/packages/engine/src/render/__tests__/weather.test.ts +++ b/v2/packages/engine/src/render/__tests__/weather.test.ts @@ -51,15 +51,33 @@ }); describe('rain/snow points', () => { - // окно 48×32 = 1536 кв. юнитов; плотность дождя 0.016 → 25 капель, - // каждая — штрих из 3 точек (75); снег 0.007 → 11 хлопьев - it('число точек = плотность × площадь окна × интенсивность; ноль — пусто', () => { + // окно 48×32 = 1536 кв. юнитов; дождь многослойный (3 слоя, плотности + // 0.020/0.014/0.010 → 31+22+15 = 68 капель × 3 точки штриха = 204); + // снег (0.010/0.007/0.005 → 15+11+8 = 34 хлопьев) + it('число точек = сумма слоёв (плотность × площадь × интенсивность); ноль — пусто', () => { const spec = { ...DEFAULT_WEATHER, rain: 1, snow: 0 }; - expect(rainPoints(spec, 0, WIN).length).toBe(75); - expect(snowPoints({ ...spec, snow: 1 }, 0, WIN).length).toBe(11); + expect(rainPoints(spec, 0, WIN).length).toBe(204); + expect(snowPoints({ ...spec, snow: 1 }, 0, WIN).length).toBe(34); expect(rainPoints({ ...spec, rain: 0 }, 0, WIN).length).toBe(0); expect(snowPoints({ ...spec, snow: 0 }, 0, WIN).length).toBe(0); }); + it('слои живут в своих полосах глубины и не совпадают колонками', () => { + const spec = { ...DEFAULT_WEATHER, rain: 1 }; + const a = rainPoints(spec, 0, WIN, 0); // дальний слой + const b = rainPoints(spec, 0, WIN, 2); // ближний + expect(a.length).toBeGreaterThan(0); + expect(b.length).toBeGreaterThan(0); + const sizes = (pts: ReturnType) => pts.filter((p) => p.size !== undefined).map((p) => p.size!); + // ближний слой крупнее дальнего (аэроперспектива по слоям) + expect(Math.max(...sizes(b))).toBeGreaterThan(Math.max(...sizes(a))); + // колонки слоёв разведены: совпадений позиций нет + const key = (p: ReturnType[number]) => `${Math.round(p.pos[0] * 10)}:${Math.round(p.pos[2] * 10)}`; + const keysB = new Set(b.map(key)); + expect(a.every((p) => !keysB.has(key(p)))).toBe(true); + // сумма слоёв = вызов без слоя + const all = rainPoints(spec, 0, WIN); + expect(all.length).toBe(a.length + b.length + rainPoints(spec, 0, WIN, 1).length); + }); it('детерминизм: тот же t — те же позы', () => { const spec = { ...DEFAULT_WEATHER, rain: 0.4, snow: 0.6, windStrength: 0.3 }; const a = rainPoints(spec, 3.7, WIN); @@ -176,8 +194,9 @@ } as never); sys.set({ rain: 1 }); sys.update(1); - // окно погоды — след кадра + маржа 16: (48+32)×(32+32) = 5120 кв. юнитов - expect(sys.info()).toEqual({ rainDrops: 82, snowFlakes: 0 }); + // окно погоды — след кадра + маржа 16: (48+32)×(32+32) = 5120 кв. юнитов; + // 3 слоя дождя: 0.020/0.014/0.010 × 5120 → 102+72+51 = 225 капель + expect(sys.info()).toEqual({ rainDrops: 225, snowFlakes: 0 }); sys.set({ rain: 0, snow: 0 }); // пустая погода прячет слои и счётчики expect(sys.info()).toEqual({ rainDrops: 0, snowFlakes: 0 }); }); diff --git a/v2/packages/engine/src/render/weather.ts b/v2/packages/engine/src/render/weather.ts index 90dbb49..7a84f71 100644 --- a/v2/packages/engine/src/render/weather.ts +++ b/v2/packages/engine/src/render/weather.ts @@ -12,7 +12,11 @@ * (план даль/близь) симулируется расхождением параметров частицы — камера * ортографическая, параллакса нет: далёкие капли/хлопья меньше, медленнее * и бледнее (аэроперспектива), ближние крупнее, быстрее; у дождя дальние - * штрихи короче, у снега ближние качаются шире. Респавн + * штрихи короче, у снега ближние качаются шире. Осадки МНОГОСЛОЙНЫЕ + * (фидбек «сильно гуще, тоже многослойным, как туман»): RAIN_LAYERS/SNOW_LAYERS + * слоёв частиц, каждый живёт в своей узкой полосе глубины — ближний слой + * редкий и крупный, дальний частый и мелкий (стена дождя в глубине); + * суммарная плотность в ~3× выше одиночного слоя. Респавн * наверху — ПОЛНЫЙ реролл колонки (sin-хэш от номера цикла падения * умножается на большой период): сдвиг в окрестности якоря оставляет каплю * падать в той же полосе — «трек остаётся на экране», а поле колонн @@ -72,8 +76,7 @@ // (след кадра уходит в глубину до дальней плоскости), фиксированный // список капель размазывается по нему в субпиксельную пыль. -const RAIN_DENSITY = 0.016; // капель на кв. юнит окна при rain = 1 -const RAIN_MAX = 4000; // потолок счёта (страховка от гигантских окон) +const RAIN_MAX = 9000; // потолок счёта (страховка от гигантских окон) const RAIN_TOP = 40; // высота слоя дождя (вертикальный цикл капли) const RAIN_SPEED = 31; // падение, юнит/с (×1.4 — фидбек «медленно») const RAIN_SIZE = 0.55; // точка-голова штриха ближнего плана (~2 px) @@ -83,8 +86,7 @@ const RAIN_STEP = 0.7; // шаг цепочки ближнего плана (штрих ~1.4 юнита) const RAIN_SPAN = 731; // период переброса колонки при респавне (полный реролл) -const SNOW_DENSITY = 0.007; // хлопьев на кв. юнит окна при snow = 1 -const SNOW_MAX = 2000; +const SNOW_MAX = 4000; const SNOW_TOP = 36; const SNOW_SPEED = 1.7; // падение, юнит/с (×1.4 от 1.2 — фидбек) const SNOW_SWAY = 1.2; // размах покачивания ближнего плана @@ -93,6 +95,33 @@ const SNOW_COLOR = '#eef3f6'; const SNOW_ALPHA = 0.85; +// ---- Многослойность осадков (фидбек «сильно гуще, как туман») ---- +// Каждый слой живёт в своей полосе глубины [from..to] (0 — дальний план, +// 1 — ближний) со своей плотностью: ближний слой редкий и крупный, +// дальний частый и мелкий — стена дождя читается в глубине, отдельные +// капли вблизи. Полосы перекрываются — слоистость не видна как линии. + +/** Число слоёв дождя. */ +export const RAIN_LAYERS = 3; +/** Число слоёв снега. */ +export const SNOW_LAYERS = 3; + +/** Полосы глубины слоёв (от дальнего к ближнему) и их плотность + * (капель на кв. юнит окна при rain = 1). Сумма 0.044 против 0.016 + * одиночного слоя — «сильно гуще». */ +const RAIN_BANDS: readonly { from: number; to: number; density: number }[] = [ + { from: 0.0, to: 0.34, density: 0.020 }, // дальний: стена мелкой мороси + { from: 0.24, to: 0.62, density: 0.014 }, // средний + { from: 0.5, to: 1.0, density: 0.010 }, // ближний: редкие крупные капли +]; + +/** Полосы глубины слоёв снега и плотности (сумма 0.022 против 0.007). */ +const SNOW_BANDS: readonly { from: number; to: number; density: number }[] = [ + { from: 0.0, to: 0.34, density: 0.010 }, + { from: 0.24, to: 0.62, density: 0.007 }, + { from: 0.5, to: 1.0, density: 0.005 }, +]; + const MARGIN = 16; // перекрытие за краем кадра (как у пепла) // ---- Глубина (план даль/близь) ---- @@ -127,67 +156,84 @@ } /** Позы капель: колонка + падение + дрейф ветра (капля тяжёлая — дрейф ×0.6). - * Респавн наверху случается в НОВОЙ колонке: к якорю добавляется sin-хэш от - * номера цикла падения, растянутый на большой период RAIN_SPAN. Сдвиг в - * узкой окрестности якоря оставлял каплю в одной полосе — трек «горел» на - * экране вечно. Капля рисуется штрихом — цепочкой точек над головой; - * глубина частицы масштабирует скорость/размер/альфу и длину штриха. */ + * Слои (layer undefined — все разом, для счётчиков/тестов): слой k живёт + * в своей полосе глубины, глобальный номер gi = i + k·7919 разводит колонки + * слоёв (без них слои легли бы в одни и те же x). Респавн наверху случается + * в НОВОЙ колонке: к якорю добавляется sin-хэш от номера цикла падения, + * растянутый на большой период RAIN_SPAN. Сдвиг в узкой окрестности якоря + * оставлял каплю в одной полосе — трек «горел» на экране вечно. Капля + * рисуется штрихом — цепочкой точек над головой; глубина частицы + * масштабирует скорость/размер/альфу и длину штриха. */ export function rainPoints( spec: WeatherSpec, t: number, win: { minX: number; maxX: number; minZ: number; maxZ: number }, + layer?: number, ): { pos: [number, number, number]; alpha: number; size?: number }[] { - const n = densityCount(RAIN_DENSITY, spec.rain, win, RAIN_MAX); const [wx, wz] = windVector(spec); const out: { pos: [number, number, number]; alpha: number; size?: number }[] = []; - for (let i = 0; i < n; i++) { - 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 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 * step, z], - alpha: a * (1 - k * 0.35), - size: sz * (1 - k * 0.1), - }); + const k0 = layer ?? 0; + const k1 = layer ?? RAIN_BANDS.length - 1; + for (let k = k0; k <= k1; k++) { + const band = RAIN_BANDS[k]!; + const n = densityCount(band.density, spec.rain, win, RAIN_MAX); + for (let i = 0; i < n; i++) { + const gi = i + k * 7919; // колонки слоёв не совпадают + const d = band.from + (band.to - band.from) * depth01(gi); + const speed = (RAIN_SPEED + (gi % 7) * 1.3) * dScale(1, 0.55, d); + const ph = gi * 5.3; + const cyc = Math.floor((t * speed + ph) / RAIN_TOP); // номер цикла падения + const fall = RAIN_TOP - ((t * speed + ph) % RAIN_TOP); + const x = wrapV(gi * 37.7 + hash01(gi, cyc) * RAIN_SPAN + t * wx * 0.6, win.minX, win.maxX); + const z = wrapV(gi * 53.9 + hash01(gi * 3.7, cyc) * RAIN_SPAN + t * wz * 0.6, win.minZ, win.maxZ); + 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 c = 0; c < RAIN_CHAIN; c++) { + out.push({ + pos: [x, fall + c * step, z], + alpha: a * (1 - c * 0.35), + size: sz * (1 - c * 0.1), + }); + } } } return out; } /** Позы снежинок: медленное падение с покачиванием + полный дрейф ветра; - * колонка респавна — полный реролл по SNOW_SPAN (как у дождя). Глубина - * масштабирует скорость/качание (ближние хлопья качаются шире)/размер/альфу. */ + * слои — как у дождя (SNOW_BANDS, gi = i + k·6271). Глубина масштабирует + * скорость/качание (ближние хлопья качаются шире)/размер/альфу. */ export function snowPoints( spec: WeatherSpec, t: number, win: { minX: number; maxX: number; minZ: number; maxZ: number }, + layer?: 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; size?: number }[] = []; - for (let i = 0; i < n; i++) { - 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 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) * 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), - }); + const k0 = layer ?? 0; + const k1 = layer ?? SNOW_BANDS.length - 1; + for (let k = k0; k <= k1; k++) { + const band = SNOW_BANDS[k]!; + const n = densityCount(band.density, spec.snow, win, SNOW_MAX); + for (let i = 0; i < n; i++) { + const gi = i + k * 6271; + const d = band.from + (band.to - band.from) * depth01(gi); + const speed = (SNOW_SPEED + (gi % 5) * 0.42) * dScale(1, 0.5, d); + const ph = (gi * 4.1) % SNOW_TOP; + const cyc = Math.floor((t * speed + ph) / SNOW_TOP); + const p = gi * 2.399; // фазы покачивания — золотое сечение + const fall = SNOW_TOP - ((t * speed + ph) % SNOW_TOP); + const swayAmp = SNOW_SWAY * dScale(1.3, 0.4, d); + const sway = Math.sin(t * 0.8 + p) * swayAmp; + const x = wrapV(gi * 61.7 + hash01(gi, cyc) * SNOW_SPAN + t * wx + sway, win.minX, win.maxX); + const z = wrapV(gi * 47.3 + hash01(gi * 1.7, cyc) * SNOW_SPAN + t * wz + + 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; } @@ -214,12 +260,13 @@ return { minX: b.minX - MARGIN, maxX: b.maxX + MARGIN, minZ: b.minZ - MARGIN, maxZ: b.maxZ + MARGIN }; } -/** Погода: два слоя частиц (дождь/снег) + ветер внутри их дрейфа. - * Облачность уводит в VoxelRenderer.setCloud — система её не дублирует. */ +/** Погода: многослойные осадки (RAIN_LAYERS/SNOW_LAYERS слоёв частиц, + * каждый — своя полоса глубины) + ветер внутри их дрейфа. Облачность + * уводит в VoxelRenderer.setCloud — система её не дублирует. */ export class WeatherSystem { private readonly renderer: VoxelRenderer; - private readonly rain: ParticleLayer; - private readonly snow: ParticleLayer; + private readonly rain: ParticleLayer[]; + private readonly snow: ParticleLayer[]; private spec: WeatherSpec = { ...DEFAULT_WEATHER }; private lastDrops = 0; // фактически рисуемые капли (после update) private lastFlakes = 0; @@ -228,8 +275,8 @@ this.renderer = renderer; // осадки — освещаемые частицы: видны столько, сколько света на них // попадает (день — ясно, ночь — гаснут, фонарь/лампы проявляют) - this.rain = renderer.addParticles({ lit: true }); - this.snow = renderer.addParticles({ lit: true }); + this.rain = Array.from({ length: RAIN_LAYERS }, () => renderer.addParticles({ lit: true })); + this.snow = Array.from({ length: SNOW_LAYERS }, () => renderer.addParticles({ lit: true })); } /** Применить погоду (клампится). Облачность — сразу в рендер; дождь/снег @@ -249,19 +296,27 @@ * рендер (setFog) — слои по дальности композитятся в кадре рендера. */ update(t: number): void { const win = weatherWindow(this.renderer, RAIN_TOP); - 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: f.size ?? SNOW_SIZE }))); + let drops = 0; + for (let k = 0; k < this.rain.length; k++) { + const pts = rainPoints(this.spec, t, win, k); + this.rain[k]!.update(pts.map((d) => ({ pos: d.pos, color: RAIN_COLOR, alpha: d.alpha, size: d.size }))); + drops += pts.length / RAIN_CHAIN; // штрих = одна капля + } + let flakes = 0; + for (let k = 0; k < this.snow.length; k++) { + const pts = snowPoints(this.spec, t, win, k); + this.snow[k]!.update(pts.map((f) => ({ pos: f.pos, color: SNOW_COLOR, alpha: f.alpha, size: f.size ?? SNOW_SIZE }))); + flakes += pts.length; + } this.renderer.setFog(this.spec.fog, t, windVector(this.spec)); - this.lastDrops = drops.length / RAIN_CHAIN; // штрих = одна капля - this.lastFlakes = flakes.length; + this.lastDrops = drops; + this.lastFlakes = flakes; } /** Скрыть осадки (погода выключена). */ hide(): void { - this.rain.update([]); - this.snow.update([]); + for (const l of this.rain) l.update([]); + for (const l of this.snow) l.update([]); this.lastDrops = this.lastFlakes = 0; } @@ -271,7 +326,7 @@ } dispose(): void { - this.rain.dispose(); - this.snow.dispose(); + for (const l of this.rain) l.dispose(); + for (const l of this.snow) l.dispose(); } } \ No newline at end of file