/**
* Слои частиц рендера: лёгкий 2D-рендер поверх воксельного мира — спрайты-
* билборды (THREE.Points, один draw call) и плоские квадраты, лежащие на
* земле/воде (InstancedBufferGeometry). Не воксели: без теней и AO — цвет
* и прозрачность на частицу, пересборка поз каждый кадр дёшева. Цвет пишется
* в буфер как есть (hex-компоненты без colorspace-конверсий) — совпадает
* с палитрой. Освещение: ambient — умножение на tint слоя (setTint, ночь
* гасит слои вместе со сценой); точечные источники — общий блок юниформ
* света рендера (setParticleLights): вклад лампы/фонарика в шейдере,
* частицы подсвечиваются как мир.
*/
import * as THREE from 'three';
/** Частица: позиция, цвет, прозрачность (по умолчанию 1), размер в юнитах (1). */
export interface ParticlePoint {
pos: [number, number, number];
color: string;
alpha?: number;
size?: number;
}
/** Ручка слоя частиц: поза слоя каждый кадр (до ёмкости; рост пересоздаёт). */
export interface ParticleLayer {
update(points: readonly ParticlePoint[]): void;
/** Множитель цвета всего слоя (0xRRGGBB, по умолчанию белый). */
setTint(color: number): void;
dispose(): void;
}
/** Точечный источник для частиц — снапшот света игры (лампа, очаг, окно). */
export interface ParticleLight {
pos: readonly [number, number, number];
/** Цвет 0xRRGGBB (перемножается с яркостью при упаковке). */
color: number;
/** Яркость — «множитель игры» 0..~1.5 (как у PointLight игры до GAIN). */
intensity: number;
/** Радиус действия в мировых единицах. */
radius: number;
}
/** Конус света для частиц (фонарик героя). */
export interface ParticleSpot {
pos: readonly [number, number, number];
/** Направление луча (нормируется при упаковке). */
dir: readonly [number, number, number];
color: number;
intensity: number;
/** Дальность в мировых единицах. */
distance: number;
/** Полный угол конуса (рад). */
angle: number;
/** Мягкость края 0..1. */
penumbra: number;
}
/** Кап источников в шейдере частиц (совпадает с MAX_LIGHTS игры). */
export const PARTICLE_LIGHT_MAX = 24;
/** Расширение конуса спота для частиц: объёмное свечение читается только
* широким конусом — тонкий луч едва пересекает видимый ортокамерой слой. */
export const SPOT_PARTICLE_WIDEN = 2.2;
/** Тип общего блока юниформ света (один экземпляр на все слои рендера). */
export type ParticleLightUniforms = Record<string, THREE.IUniform>;
/** Блок юниформ света частиц: живёт на рендере, все слои (и пересозданные
* при росте ёмкости) ссылаются на ТЕ ЖЕ IUniform — свет не теряется. */
export function makeParticleLightUniforms(): ParticleLightUniforms {
return {
uLightPos: { value: new Float32Array(PARTICLE_LIGHT_MAX * 3) },
uLightColor: { value: new Float32Array(PARTICLE_LIGHT_MAX * 3) },
uLightRadius: { value: new Float32Array(PARTICLE_LIGHT_MAX) },
uLightCount: { value: 0 },
uSpotPos: { value: new Float32Array(3) },
uSpotDir: { value: new Float32Array(3) },
uSpotColor: { value: new Float32Array(3) },
uSpotDist: { value: 1 },
uSpotCos: { value: 0 }, // косинус ПОЛУугла внешнего края конуса
uSpotSoft: { value: 0 }, // косинус внутреннего края (мягкость penumbra)
uSpotOn: { value: 0 },
};
}
/**
* Упаковка снапшота света в блок юниформ: цвет × яркость считается здесь —
* шейдер читает готовый вклад. Чистая функция — тестируется без WebGL.
*/
export function packParticleLights(
u: ParticleLightUniforms,
points: readonly ParticleLight[],
spot: ParticleSpot | null,
): void {
const pos = u.uLightPos!.value as Float32Array;
const col = u.uLightColor!.value as Float32Array;
const rad = u.uLightRadius!.value as Float32Array;
const n = Math.min(points.length, PARTICLE_LIGHT_MAX);
for (let i = 0; i < n; i++) {
const p = points[i]!;
pos[i * 3] = p.pos[0];
pos[i * 3 + 1] = p.pos[1];
pos[i * 3 + 2] = p.pos[2];
col[i * 3] = (((p.color >> 16) & 255) / 255) * p.intensity;
col[i * 3 + 1] = (((p.color >> 8) & 255) / 255) * p.intensity;
col[i * 3 + 2] = ((p.color & 255) / 255) * p.intensity;
rad[i] = p.radius;
}
u.uLightCount!.value = n;
if (spot) {
(u.uSpotPos!.value as Float32Array).set([...spot.pos]);
const d = Math.hypot(...spot.dir) || 1;
(u.uSpotDir!.value as Float32Array).set(spot.dir.map((v) => v / d));
(u.uSpotColor!.value as Float32Array).set([
(((spot.color >> 16) & 255) / 255) * spot.intensity,
(((spot.color >> 8) & 255) / 255) * spot.intensity,
((spot.color & 255) / 255) * spot.intensity,
]);
u.uSpotDist!.value = spot.distance;
// конус для частиц шире поверхностного: тонкий луч едва пересекает
// видимый ортокамерой слой частиц, а объёмное свечение читается
// только широким конусом (стандартный приём 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;
}
}
/** hex '#rrggbb' → компоненты 0..1. */
function rgb(hex: string): [number, number, number] {
const n = parseInt(hex.slice(1), 16);
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}
/** GLSL-фрагмент света: вклад точечных источников и фонарика в точке мира.
* Вклад умножается на цвет частицы (vColor·(tint+свет)) — красная частица
* у тёплой лампы краснеет ярче, а не белеет (аддитив дал бы белую дымку).
* Фоллофф точечных источников — квадратичный: кубический концентрировал
* вклад в внутреннем ~10% радиуса (среднее a³ по объёму сферы ≈ 1/60),
* и при игровой плотности частиц реакция была перцептивно нулевой. */
const LIGHT_FRAG = `
#define PLIGHTS ${PARTICLE_LIGHT_MAX}
uniform vec3 uLightPos[PLIGHTS];
uniform vec3 uLightColor[PLIGHTS];
uniform float uLightRadius[PLIGHTS];
uniform int uLightCount;
uniform vec3 uSpotPos;
uniform vec3 uSpotDir;
uniform vec3 uSpotColor;
uniform float uSpotDist;
uniform float uSpotCos;
uniform float uSpotSoft;
uniform float uSpotOn;
vec3 particleLight(vec3 p) {
vec3 sum = vec3(0.0);
for (int i = 0; i < PLIGHTS; i++) {
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);
}
if (uSpotOn > 0.5) {
vec3 rel = p - uSpotPos;
float d = max(length(rel), 0.0001);
float cone = smoothstep(uSpotCos, uSpotSoft, dot(rel / d, uSpotDir));
float a = clamp(1.0 - d / uSpotDist, 0.0, 1.0);
sum += uSpotColor * (cone * a * a);
}
// согласование с пулами мира: PointLight усиливается GAIN 8, а частица —
// прозрачная «пылинка», её вклад читается только с множителем
return sum * 2.0;
}`;
const POINT_VERT = `
attribute float aAlpha;
attribute float aSize;
varying vec3 vColor;
varying float vAlpha;
varying vec3 vWorld;
uniform float uPxScale;
void main() {
vColor = color;
vAlpha = aAlpha;
vWorld = position;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
gl_PointSize = aSize * uPxScale;
}`;
const POINT_FRAG = `
varying vec3 vColor;
varying float vAlpha;
varying vec3 vWorld;
uniform vec3 uTint;
${LIGHT_FRAG}
void main() { gl_FragColor = vec4(vColor * (uTint + particleLight(vWorld)), vAlpha); }`;
const QUAD_VERT = `
attribute vec3 iPos;
attribute vec3 iColor;
attribute float iAlpha;
attribute float iSize;
varying vec3 vColor;
varying float vAlpha;
varying vec3 vWorld;
void main() {
vColor = iColor;
vAlpha = iAlpha;
vWorld = position * iSize + iPos;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position * iSize + iPos, 1.0);
}`;
const FLAGS = { transparent: true, depthWrite: false, depthTest: true };
/** Внутренняя реализация спрайтового слоя БЕЗ роста ёмкости (рост — у фасада).
* lights — общий блок юниформ света рендера (ссылки на IUniform шарятся). */
function createSpriteImpl(
scene: THREE.Scene, capacity: number, pxScale: number, lights: ParticleLightUniforms,
) {
const mk = (itemSize: number): THREE.BufferAttribute =>
new THREE.BufferAttribute(new Float32Array(capacity * itemSize), itemSize).setUsage(THREE.DynamicDrawUsage);
const pos = mk(3), col = mk(3), alp = mk(1), siz = mk(1);
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', pos);
geo.setAttribute('color', col);
geo.setAttribute('aAlpha', alp);
geo.setAttribute('aSize', siz);
const mat = new THREE.ShaderMaterial({
vertexShader: POINT_VERT, fragmentShader: POINT_FRAG,
uniforms: { uPxScale: { value: pxScale }, uTint: { value: new THREE.Vector3(1, 1, 1) }, ...lights },
vertexColors: true, ...FLAGS,
});
const mesh = new THREE.Points(geo, mat);
mesh.frustumCulled = false; // bbox по инстансам не считается
scene.add(mesh);
return {
update: (points: readonly ParticlePoint[]) => {
points.forEach((p, i) => {
const [r, g, b] = rgb(p.color);
pos.setXYZ(i, p.pos[0], p.pos[1], p.pos[2]);
col.setXYZ(i, r, g, b);
alp.setX(i, p.alpha ?? 1);
siz.setX(i, p.size ?? 1);
});
geo.setDrawRange(0, points.length);
pos.needsUpdate = col.needsUpdate = alp.needsUpdate = siz.needsUpdate = true;
},
setTint: (color: number) => {
const v = mat.uniforms.uTint.value as THREE.Vector3;
v.set(((color >> 16) & 255) / 255, ((color >> 8) & 255) / 255, (color & 255) / 255);
},
dispose: () => { scene.remove(mesh); geo.dispose(); mat.dispose(); },
};
}
/** Фабрика слоя спрайтов: стабильный фасад над подменяемой реализацией.
* Грабля «сломанная цепочка редиректа»: раньше при росте ёмкости слой
* перезаписывал СВОИ методы методами свежего слоя (`layer.update =
* fresh.update`) — держатель старого слоя (WeatherSystem) продолжал
* звать update старого объекта, тот уходил в мёртвое замыкание
* предыдущей реализации и при каждом превышении ёмкости создавал
* НОВЫЙ слой с однократной записью: замороженные снимки дождя копились
* в сцене (пелена на скрине #218). Теперь фасад один на всю жизнь слоя,
* при росте подменяется только `impl` — держатели всегда в актуальном
* меше, а первый кадр после роста рисуется сразу (impl.update(points)). */
function createSpriteLayer(
scene: THREE.Scene, capacity: number, pxScale: number, lights: ParticleLightUniforms,
): ParticleLayer {
let cap = capacity;
let impl = createSpriteImpl(scene, cap, pxScale, lights);
const layer: ParticleLayer = {
update: (points) => {
if (points.length > cap) {
impl.dispose();
cap = points.length;
impl = createSpriteImpl(scene, cap, pxScale, lights);
}
impl.update(points);
},
setTint: (color) => impl.setTint(color),
dispose: () => impl.dispose(),
};
return layer;
}
/** Внутренняя реализация слоя квадов без роста ёмкости (рост — у фасада). */
function createQuadImpl(scene: THREE.Scene, capacity: number, lights: ParticleLightUniforms) {
const base = new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2); // юнит-квад в XZ
const geo = new THREE.InstancedBufferGeometry();
geo.index = base.index;
geo.setAttribute('position', base.getAttribute('position'));
const mkI = (itemSize: number): THREE.InstancedBufferAttribute =>
new THREE.InstancedBufferAttribute(new Float32Array(capacity * itemSize), itemSize).setUsage(THREE.DynamicDrawUsage);
const pos = mkI(3), col = mkI(3), alp = mkI(1), siz = mkI(1);
geo.setAttribute('iPos', pos);
geo.setAttribute('iColor', col);
geo.setAttribute('iAlpha', alp);
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,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.frustumCulled = false;
scene.add(mesh);
return {
update: (points: readonly ParticlePoint[]) => {
points.forEach((p, i) => {
const [r, g, b] = rgb(p.color);
pos.setXYZ(i, p.pos[0], p.pos[1], p.pos[2]);
col.setXYZ(i, r, g, b);
alp.setX(i, p.alpha ?? 1);
siz.setX(i, p.size ?? 1);
});
geo.instanceCount = points.length;
pos.needsUpdate = col.needsUpdate = alp.needsUpdate = siz.needsUpdate = true;
},
setTint: (color: number) => {
const v = mat.uniforms.uTint.value as THREE.Vector3;
v.set(((color >> 16) & 255) / 255, ((color >> 8) & 255) / 255, (color & 255) / 255);
},
dispose: () => { scene.remove(mesh); geo.dispose(); mat.dispose(); base.dispose(); },
};
}
/** Фабрика слоя плоских квадов (лежат в XZ; дымка на воде, блики) —
* стабильный фасад, рост ёмкости подменяет impl (см. createSpriteLayer). */
function createQuadLayer(scene: THREE.Scene, capacity: number, lights: ParticleLightUniforms): ParticleLayer {
let cap = capacity;
let impl = createQuadImpl(scene, cap, lights);
const layer: ParticleLayer = {
update: (points) => {
if (points.length > cap) {
impl.dispose();
cap = points.length;
impl = createQuadImpl(scene, cap, lights);
}
impl.update(points);
},
setTint: (color) => impl.setTint(color),
dispose: () => impl.dispose(),
};
return layer;
}
/** Публичные фабрики: стартовая ёмкость и px-масштаб спрайтов задаёт рендер. */
export { createSpriteLayer, createQuadLayer };