diff --git a/v2/apps/game/src/airFx.ts b/v2/apps/game/src/airFx.ts index 713d6f0..9efb4d8 100644 --- a/v2/apps/game/src/airFx.ts +++ b/v2/apps/game/src/airFx.ts @@ -31,7 +31,7 @@ private readonly renderer: VoxelRenderer, private readonly kind: AirFxKind, ) { - this.layer = renderer.addParticles(); + this.layer = renderer.addParticles({ lit: true }); // листья/пыль видны по свету сцены } /** Снапшот для моста: профиль и число частиц. */ @@ -77,11 +77,6 @@ this.layer.update(points); } - /** Тинт освещения (ночь гасит листья/пыль, как сцену — мост lighting). */ - setTint(color: number): void { - this.layer.setTint(color); - } - /** Скрыть слой (мост setAsh / область без профиля). */ hide(): void { this.layer.update([]); diff --git a/v2/apps/game/src/ash.ts b/v2/apps/game/src/ash.ts index e739c57..b8e7fa2 100644 --- a/v2/apps/game/src/ash.ts +++ b/v2/apps/game/src/ash.ts @@ -25,7 +25,7 @@ private readonly layer: ParticleLayer; constructor(private readonly renderer: VoxelRenderer) { - this.layer = renderer.addParticles(); + this.layer = renderer.addParticles({ lit: true }); // пепел виден по свету сцены } /** Поза облака: время цикла (окно — из цели камеры рендерера). */ @@ -52,11 +52,6 @@ this.layer.update(points); } - /** Тинт освещения (ночь гасит пепел, как сцену — мост lighting). */ - setTint(color: number): void { - this.layer.setTint(color); - } - /** Скрыть облако (область без атмосферы). */ hide(): void { this.layer.update([]); diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts index ad15cfb..a2daa41 100644 --- a/v2/apps/game/src/main.ts +++ b/v2/apps/game/src/main.ts @@ -512,14 +512,13 @@ timeSeconds: () => elapsed, lamp: HERO_LAMP, flashlight: FLASHLIGHT, - // частицы — в системе освещения: ночь гасит пепел/листья/дымку/блики - // (мотыльки самосветятся — AmbientParticles тинтит только дымку) + // частицы — в системе освещения: пепел/листья/пыль/дождь/снег теперь + // освещаемые слои (lit) — день/ночь читают из env-света рендера сами; + // tint остался самосветящимся: дымка прудов (мотыльки светятся сами, + // AmbientParticles тинтит только дымку), блики воды particleTint: (c) => { - if (areaAsh) ash.setTint(c); - airFx?.setTint(c); ambient?.setTint(c); shimmer?.setTint(c); - weather.setTint(c); // ночь гасит дождь/снег вместе со сценой }, // частицы подсвечиваются источниками в шейдере (общий блок юниформ // рендера — пепел у очага/колокольчика, луч фонарика на пыли) diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts index d51c3d4..ae8226d 100644 --- a/v2/packages/engine/src/index.ts +++ b/v2/packages/engine/src/index.ts @@ -23,7 +23,7 @@ export { meshChunk, chunkBounds } from './world/worldMesher'; export { Chunk, CHUNK_SX, CHUNK_SZ } from './world/chunk'; export { VoxelRenderer, worldTileAtlas } from './render/voxelRenderer'; -export { createSpriteLayer, createQuadLayer, makeParticleLightUniforms, packParticleLights, PARTICLE_LIGHT_MAX } from './render/particles'; +export { createSpriteLayer, createQuadLayer, makeParticleLightUniforms, packParticleLights, particleEnv, PARTICLE_LIGHT_MAX, PARTICLE_ENV_SUN, PARTICLE_LIT_VIS } from './render/particles'; export { postPass } from './render/postShader'; export type { PostOptions, PostPassSpec } from './render/postShader'; export type { PostPassHandle } from './render/voxelRenderer'; @@ -70,7 +70,7 @@ export type { Scene, FadeOverlay, SceneTransition } from './core/scene'; export { CutsceneRunner, validateCutscenes } from './core/cutscene'; export type { CutsceneStep, CutsceneSnapshot, CutsceneCamera } from './core/cutscene'; -export { ISO_YAW, SHADOW_BLUR_MAX, SHADOW_CLOUD_FADE, shadowBlurTexels, shadowIntensity } from './render/voxelRenderer'; +export { ISO_YAW, SHADOW_BLUR_MAX, SHADOW_CLOUD_FADE, OVERCAST_TOP_LIGHT, shadowBlurTexels, shadowIntensity, zenithIntensity } from './render/voxelRenderer'; export { validateDialogue, evalConditions } from './dialogue/graph'; export type { DialogueGraph, DialogueNode, DialogueChoice, DialogueConditions, VarCondition, diff --git a/v2/packages/engine/src/render/__tests__/particleEnv.test.ts b/v2/packages/engine/src/render/__tests__/particleEnv.test.ts new file mode 100644 index 0000000..5ba84c9 --- /dev/null +++ b/v2/packages/engine/src/render/__tests__/particleEnv.test.ts @@ -0,0 +1,45 @@ +/** + * Тесты env-света частиц: чистая функция particleEnv (ambient целиком + + * доля солнца) и константы режима lit. Освещаемые частицы видят ровно + * столько света, сколько даёт сцена, — рендер пересчитывает при тиках. + */ +import { describe, expect, it } from 'vitest'; +import { particleEnv, PARTICLE_ENV_SUN, PARTICLE_LIT_VIS } from '../particles'; + +describe('particleEnv (env-свет освещаемых частиц)', () => { + it('ambient входит целиком с интенсивностью', () => { + const [r, g, b] = particleEnv( + { r: 0.5, g: 0.6, b: 0.7, intensity: 0.5 }, + { r: 0, g: 0, b: 0, intensity: 0 }, + ); + expect(r).toBeCloseTo(0.25); + expect(g).toBeCloseTo(0.3); + expect(b).toBeCloseTo(0.35); + }); + + it('солнце входит с долей PARTICLE_ENV_SUN', () => { + const [r] = particleEnv( + { r: 0, g: 0, b: 0, intensity: 0 }, + { r: 1, g: 1, b: 1, intensity: 2 }, + ); + expect(r).toBeCloseTo(2 * PARTICLE_ENV_SUN); + }); + + it('ночь: тёмный ambient + погасшее солнце — свет почти нулевой', () => { + const [r, g, b] = particleEnv( + { r: 0.04, g: 0.04, b: 0.07, intensity: 0.55 }, + { r: 1, g: 1, b: 1, intensity: 0.08 }, + ); + const luma = r * 0.3 + g * 0.6 + b * 0.1; + expect(luma).toBeLessThan(0.1); // в темноте частица невидима + }); + + it('день: ambient + солнце дают received, насыщающий видимость', () => { + const [r, g, b] = particleEnv( + { r: 0.58, g: 0.58, b: 0.62, intensity: 0.55 }, + { r: 1, g: 0.9, b: 0.8, intensity: 2.0 }, + ); + const luma = r * 0.3 + g * 0.6 + b * 0.1; + expect(luma * PARTICLE_LIT_VIS).toBeGreaterThan(1); // vis = 1 — как раньше + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/render/__tests__/particleLayers.test.ts b/v2/packages/engine/src/render/__tests__/particleLayers.test.ts index 04b1e6f..da96308 100644 --- a/v2/packages/engine/src/render/__tests__/particleLayers.test.ts +++ b/v2/packages/engine/src/render/__tests__/particleLayers.test.ts @@ -17,8 +17,8 @@ * стабильн: сколько бы ни было ростов, в сцене ровно один меш. */ describe('слои частиц: рост ёмкости не плодит меши (#218)', () => { const factories: [string, (scene: THREE.Scene, cap: number) => ParticleLayer][] = [ - ['спрайты', (s, cap) => createSpriteLayer(s, cap, 3, lights)], - ['квады', (s, cap) => createQuadLayer(s, cap, lights)], + ['спрайты', (s, cap) => createSpriteLayer(s, cap, 3, lights, false)], + ['квады', (s, cap) => createQuadLayer(s, cap, lights, false)], ]; for (const [name, make] of factories) { it(`${name}: превышение ёмкости подменяет меш, а не плодит слои`, () => { diff --git a/v2/packages/engine/src/render/particles.ts b/v2/packages/engine/src/render/particles.ts index 15118a4..d4c15d9 100644 --- a/v2/packages/engine/src/render/particles.ts +++ b/v2/packages/engine/src/render/particles.ts @@ -4,10 +4,12 @@ * земле/воде (InstancedBufferGeometry). Не воксели: без теней и AO — цвет * и прозрачность на частицу, пересборка поз каждый кадр дёшева. Цвет пишется * в буфер как есть (hex-компоненты без colorspace-конверсий) — совпадает - * с палитрой. Освещение: ambient — умножение на tint слоя (setTint, ночь - * гасит слои вместе со сценой); точечные источники — общий блок юниформ - * света рендера (setParticleLights): вклад лампы/фонарика в шейдере, - * частицы подсвечиваются как мир. + * с палитрой. Освещение: два режима слоя. Излучающий (по умолчанию) — + * светится сам: tint слоя (setTint) + вклад точечных источников; виден + * в темноте. Освещаемый (lit) — как мир: env-свет сцены (ambient + доля + * солнца, рендер обновляет при каждом тике света) + точечные источники, + * а видимость (альфа) — по полученному свету: ночью в темноте невидим, + * у лампы проявляется. Источники — общий блок юниформ (setParticleLights). */ import * as THREE from 'three'; @@ -78,9 +80,39 @@ uSpotCos: { value: 0 }, // косинус ПОЛУугла внешнего края конуса uSpotSoft: { value: 0 }, // косинус внутреннего края (мягкость penumbra) uSpotOn: { value: 0 }, + uEnv: { value: new THREE.Vector3() }, // env-свет сцены (ambient + доля солнца) }; } +/** Доля прямого солнца в env-свете освещаемых частиц: у билборда нет + * нормали, солнце приближается к всенаправленному — берём часть. */ +export const PARTICLE_ENV_SUN = 0.3; + +/** Насколько полученный свет проявляет освещаемую частицу: видимость — + * clamp(max-канал(полученный свет)·GAIN, 0, 1) — множитель альфы. + * Ночью в темноте received ~0 — частица невидима, у лампы — видна. */ +export const PARTICLE_LIT_VIS = 1.6; + +/** Источник света для particleEnv: цветовые компоненты 0..1 + яркость + * (подходит THREE.Color/свет three — поля r/g/b/intensity). */ +export interface RgbIntensity { + readonly r: number; + readonly g: number; + readonly b: number; + readonly intensity: number; +} + +/** Env-свет сцены для освещаемых частиц: ambient целиком + доля прямого + * солнца (частица получает свет со всех сторон). Чистая функция — + * рендер вызывает при каждом обновлении света, тестируется без WebGL. */ +export function particleEnv(amb: RgbIntensity, sun: RgbIntensity): [number, number, number] { + return [ + amb.r * amb.intensity + sun.r * sun.intensity * PARTICLE_ENV_SUN, + amb.g * amb.intensity + sun.g * sun.intensity * PARTICLE_ENV_SUN, + amb.b * amb.intensity + sun.b * sun.intensity * PARTICLE_ENV_SUN, + ]; +} + /** * Упаковка снапшота света в блок юниформ: цвет × яркость считается здесь — * шейдер читает готовый вклад. Чистая функция — тестируется без WebGL. @@ -191,8 +223,19 @@ varying float vAlpha; varying vec3 vWorld; uniform vec3 uTint; +uniform vec3 uEnv; ${LIGHT_FRAG} -void main() { gl_FragColor = vec4(vColor * (uTint + particleLight(vWorld)), vAlpha); }`; +void main() { + vec3 received = particleLight(vWorld); + float vis = 1.0; +#if LIT + // освещаемый режим: полученный свет (env + источники) правит и цветом, + // и видимостью — ночью в темноте частица исчезает, у лампы проявляется + received += uEnv; + vis = clamp(max(received.r, max(received.g, received.b)) * ${PARTICLE_LIT_VIS}, 0.0, 1.0); +#endif + gl_FragColor = vec4(vColor * (uTint + received), vAlpha * vis); +}`; const QUAD_VERT = ` attribute vec3 iPos; @@ -212,9 +255,11 @@ const FLAGS = { transparent: true, depthWrite: false, depthTest: true }; /** Внутренняя реализация спрайтового слоя БЕЗ роста ёмкости (рост — у фасада). - * lights — общий блок юниформ света рендера (ссылки на IUniform шарятся). */ + * lights — общий блок юниформ света рендера (ссылки на IUniform шарятся). + * lit — освещаемый режим: видимость по полученному свету (см. шапку). */ function createSpriteImpl( scene: THREE.Scene, capacity: number, pxScale: number, lights: ParticleLightUniforms, + lit: boolean, ) { const mk = (itemSize: number): THREE.BufferAttribute => new THREE.BufferAttribute(new Float32Array(capacity * itemSize), itemSize).setUsage(THREE.DynamicDrawUsage); @@ -227,6 +272,7 @@ const mat = new THREE.ShaderMaterial({ vertexShader: POINT_VERT, fragmentShader: POINT_FRAG, uniforms: { uPxScale: { value: pxScale }, uTint: { value: new THREE.Vector3(1, 1, 1) }, ...lights }, + defines: { LIT: lit ? 1 : 0 }, vertexColors: true, ...FLAGS, }); const mesh = new THREE.Points(geo, mat); @@ -264,15 +310,16 @@ * меше, а первый кадр после роста рисуется сразу (impl.update(points)). */ function createSpriteLayer( scene: THREE.Scene, capacity: number, pxScale: number, lights: ParticleLightUniforms, + lit: boolean, ): ParticleLayer { let cap = capacity; - let impl = createSpriteImpl(scene, cap, pxScale, lights); + let impl = createSpriteImpl(scene, cap, pxScale, lights, lit); const layer: ParticleLayer = { update: (points) => { if (points.length > cap) { impl.dispose(); cap = points.length; - impl = createSpriteImpl(scene, cap, pxScale, lights); + impl = createSpriteImpl(scene, cap, pxScale, lights, lit); } impl.update(points); }, @@ -282,8 +329,9 @@ return layer; } -/** Внутренняя реализация слоя квадов без роста ёмкости (рост — у фасада). */ -function createQuadImpl(scene: THREE.Scene, capacity: number, lights: ParticleLightUniforms) { +/** Внутренняя реализация слоя квадов без роста ёмкости (рост — у фасада). + * lit — освещаемый режим (см. шапку). */ +function createQuadImpl(scene: THREE.Scene, capacity: number, lights: ParticleLightUniforms, lit: boolean) { const base = new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2); // юнит-квад в XZ const geo = new THREE.InstancedBufferGeometry(); geo.index = base.index; @@ -297,7 +345,9 @@ geo.setAttribute('iSize', siz); const mat = new THREE.ShaderMaterial({ vertexShader: QUAD_VERT, fragmentShader: POINT_FRAG, - uniforms: { uTint: { value: new THREE.Vector3(1, 1, 1) }, ...lights }, ...FLAGS, + uniforms: { uTint: { value: new THREE.Vector3(1, 1, 1) }, ...lights }, + defines: { LIT: lit ? 1 : 0 }, + ...FLAGS, }); const mesh = new THREE.Mesh(geo, mat); mesh.frustumCulled = false; @@ -324,15 +374,15 @@ /** Фабрика слоя плоских квадов (лежат в XZ; дымка на воде, блики) — * стабильный фасад, рост ёмкости подменяет impl (см. createSpriteLayer). */ -function createQuadLayer(scene: THREE.Scene, capacity: number, lights: ParticleLightUniforms): ParticleLayer { +function createQuadLayer(scene: THREE.Scene, capacity: number, lights: ParticleLightUniforms, lit: boolean): ParticleLayer { let cap = capacity; - let impl = createQuadImpl(scene, cap, lights); + let impl = createQuadImpl(scene, cap, lights, lit); const layer: ParticleLayer = { update: (points) => { if (points.length > cap) { impl.dispose(); cap = points.length; - impl = createQuadImpl(scene, cap, lights); + impl = createQuadImpl(scene, cap, lights, lit); } impl.update(points); }, diff --git a/v2/packages/engine/src/render/weather.ts b/v2/packages/engine/src/render/weather.ts index c4ca0ba..d7c9a62 100644 --- a/v2/packages/engine/src/render/weather.ts +++ b/v2/packages/engine/src/render/weather.ts @@ -223,8 +223,10 @@ constructor(renderer: VoxelRenderer) { this.renderer = renderer; - this.rain = renderer.addParticles(); - this.snow = renderer.addParticles(); + // осадки — освещаемые частицы: видны столько, сколько света на них + // попадает (день — ясно, ночь — гаснут, фонарь/лампы проявляют) + this.rain = renderer.addParticles({ lit: true }); + this.snow = renderer.addParticles({ lit: true }); } /** Применить погоду (клампится). Облачность — сразу в рендер; дождь/снег @@ -251,12 +253,6 @@ this.lastFlakes = flakes.length; } - /** Тинт освещения: ночь гасит осадки вместе со сценой (как пепел/листья). */ - setTint(color: number): void { - this.rain.setTint(color); - this.snow.setTint(color); - } - /** Скрыть осадки (погода выключена). */ hide(): void { this.rain.update([]);