diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 928431d..14cc812 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -1401,14 +1401,25 @@ (цвет × яркость считается на CPU, чистая — юнит-тесты), `VoxelRenderer. setParticleLights(points, spot?)`. Блок шарится по ССЫЛКЕ на IUniform между всеми слоями (и пересозданными при росте ёмкости) — свет не теряется. - Шейдер (общий LIGHT_FRAG для спрайтов и квадов): вклад `lightColor·(a³)` — + Шейдер (общий LIGHT_FRAG для спрайтов и квадов): вклад `lightColor·(a²)` — умножается на цвет частицы `vColor·(uTint + light)` — красная частица у - тёплой лампы краснеет ярче, а не белеет (аддитив дал бы белую дымку); - конус — smoothstep по dot(нормированный rel, dir) между cos-ами внешнего/ - внутреннего края (penumbra); итог ×2.0 — согласование с пулами мира - (PointLight усилен GAIN 8, прозрачной пылинке нужен множитель, иначе - вклад субпиксельный). В вершинниках появился varying vWorld (позиция - частицы в мире — для расчёта света). + тёплой лампы краснеет ярче, а не белеет (аддитив дал бы белую дымку). + Грабля: кубический фоллофф (a³) концентрировал вклад в внутреннем ~10% + радиуса (среднее a³ по объёму сферы ≈ 1/60) — при игровой плотности + частиц реакция на лампы была перцептивно нулевой (+1.4% яркости), + хотя юниформы доходили и шейдер был исправен; диагноз — хирургический + A/B в живой игре (flip uSpotOn при замороженном времени, renderOnce); + квадратичный фоллофф делает реакцию отчётливой (капли в пятне лампы + тёплые и яркие). Конус фонарика для частиц в 2.2× ШИРЕ поверхностного + (`SPOT_PARTICLE_WIDEN`, полуугол клампится под прямым): тонкий луч + едва пересекает видимый ортокамерой слой частиц, а объёмное свечение + читается только широким конусом (стандартный приём volumetric-света) — + иначе луч подсвечивал ~20 капель (76 px diff), глаз не ловил; smoothstep + по dot(нормированный rel, dir) между cos-ами внешнего/внутреннего края + (penumbra); итог ×2.0 — согласование с пулами мира (PointLight усилен + GAIN 8, прозрачной пылинке нужен множитель, иначе вклад субпиксельный). + В вершинниках появился varying vWorld (позиция частицы в мире — для + расчёта света). - **Игра** (`lighting.ts`): dep `particleLights(points, spot)` — каждый тик снапшот активных источников (запись handles хранит свет для частиц: цвет/радиус/позиция + текущая яркость мерцания) и конус фонарика (тот же diff --git a/v2/packages/engine/src/render/__tests__/particleLights.test.ts b/v2/packages/engine/src/render/__tests__/particleLights.test.ts index 184364e..9a385ad 100644 --- a/v2/packages/engine/src/render/__tests__/particleLights.test.ts +++ b/v2/packages/engine/src/render/__tests__/particleLights.test.ts @@ -3,7 +3,7 @@ * (цвет × яркость, кап), фонарик (нормировка направления, конус), сброс. */ import { describe, expect, it } from 'vitest'; -import { makeParticleLightUniforms, packParticleLights, PARTICLE_LIGHT_MAX } from '../particles'; +import { makeParticleLightUniforms, packParticleLights, PARTICLE_LIGHT_MAX, SPOT_PARTICLE_WIDEN } from '../particles'; import type { ParticleLight, ParticleSpot } from '../particles'; describe('свет частиц (packParticleLights)', () => { @@ -54,7 +54,10 @@ expect(u.uSpotOn!.value).toBe(1); const dir = u.uSpotDir!.value as Float32Array; expect([dir[0], dir[1], dir[2]]).toEqual([0, -1, 0]); // нормировка - expect(u.uSpotCos!.value).toBeCloseTo(Math.cos(Math.PI / 8)); + // конус для частиц шире поверхностного (объёмное свечение читается + // только широким конусом), полуугол клампится под прямым + const half = Math.min((Math.PI / 8) * SPOT_PARTICLE_WIDEN, Math.PI / 2 - 0.05); + expect(u.uSpotCos!.value).toBeCloseTo(Math.cos(half)); // внутренний край — уже угол, косинус больше внешнего expect(u.uSpotSoft!.value).toBeGreaterThan(u.uSpotCos!.value); const c = u.uSpotColor!.value as Float32Array; @@ -62,6 +65,16 @@ expect(c[1]).toBeCloseTo((0xf2 / 255) * 0.8); }); + it('фонарик с гигантским углом: полуугол не превосходит прямого', () => { + const u = makeParticleLightUniforms(); + packParticleLights(u, [], { + pos: [0, 0, 0], dir: [0, -1, 0], color: 0xffffff, intensity: 1, + distance: 10, angle: Math.PI, penumbra: 0.5, + }); + expect(u.uSpotCos!.value).toBeCloseTo(Math.cos(Math.PI / 2 - 0.05)); + expect(u.uSpotCos!.value).toBeGreaterThan(0); // край конуса валиден + }); + it('сброс: погасший фонарик гасит uSpotOn, счётчик обнуляется', () => { const u = makeParticleLightUniforms(); packParticleLights(u, [], { diff --git a/v2/packages/engine/src/render/particles.ts b/v2/packages/engine/src/render/particles.ts index 0387313..15118a4 100644 --- a/v2/packages/engine/src/render/particles.ts +++ b/v2/packages/engine/src/render/particles.ts @@ -56,6 +56,10 @@ /** Кап источников в шейдере частиц (совпадает с MAX_LIGHTS игры). */ export const PARTICLE_LIGHT_MAX = 24; +/** Расширение конуса спота для частиц: объёмное свечение читается только + * широким конусом — тонкий луч едва пересекает видимый ортокамерой слой. */ +export const SPOT_PARTICLE_WIDEN = 2.2; + /** Тип общего блока юниформ света (один экземпляр на все слои рендера). */ export type ParticleLightUniforms = Record; @@ -111,8 +115,12 @@ ((spot.color & 255) / 255) * spot.intensity, ]); u.uSpotDist!.value = spot.distance; - u.uSpotCos!.value = Math.cos(spot.angle / 2); - u.uSpotSoft!.value = Math.cos((spot.angle / 2) * (1 - spot.penumbra)); + // конус для частиц шире поверхностного: тонкий луч едва пересекает + // видимый ортокамерой слой частиц, а объёмное свечение читается + // только широким конусом (стандартный приём volumetric-света) + const half = Math.min((spot.angle / 2) * SPOT_PARTICLE_WIDEN, Math.PI / 2 - 0.05); + u.uSpotCos!.value = Math.cos(half); + u.uSpotSoft!.value = Math.cos(half * (1 - spot.penumbra)); u.uSpotOn!.value = 1; } else { u.uSpotOn!.value = 0; @@ -127,7 +135,10 @@ /** GLSL-фрагмент света: вклад точечных источников и фонарика в точке мира. * Вклад умножается на цвет частицы (vColor·(tint+свет)) — красная частица - * у тёплой лампы краснеет ярче, а не белеет (аддитив дал бы белую дымку). */ + * у тёплой лампы краснеет ярче, а не белеет (аддитив дал бы белую дымку). + * Фоллофф точечных источников — квадратичный: кубический концентрировал + * вклад в внутреннем ~10% радиуса (среднее a³ по объёму сферы ≈ 1/60), + * и при игровой плотности частиц реакция была перцептивно нулевой. */ const LIGHT_FRAG = ` #define PLIGHTS ${PARTICLE_LIGHT_MAX} uniform vec3 uLightPos[PLIGHTS]; @@ -147,7 +158,7 @@ if (i >= uLightCount) break; float d = distance(p, uLightPos[i]); float a = clamp(1.0 - d / uLightRadius[i], 0.0, 1.0); - sum += uLightColor[i] * (a * a * a); + sum += uLightColor[i] * (a * a); } if (uSpotOn > 0.5) { vec3 rel = p - uSpotPos;