/**
* Слои частиц рендера: лёгкий 2D-рендер поверх воксельного мира — спрайты-
* билборды (THREE.Points, один draw call) и плоские квадраты, лежащие на
* земле/воде (InstancedBufferGeometry). Не воксели: без теней и AO — цвет
* и прозрачность на частицу, пересборка поз каждый кадр дёшева. Цвет пишется
* в буфер как есть (hex-компоненты без colorspace-конверсий) — совпадает
* с палитрой. Освещение: два режима слоя. Излучающий (по умолчанию) —
* светится сам: tint слоя (setTint) + вклад точечных источников; виден
* в темноте. Освещаемый (lit) — как мир: env-свет сцены (ambient + доля
* солнца, рендер обновляет при каждом тике света) + точечные источники,
* а видимость (альфа) — по полученному свету: ночью в темноте невидим,
* у лампы проявляется. Источники — общий блок юниформ (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 — рост ёмкости режим не теряет).
* uPxScale флипает рендер при смене режима камеры, uPersp — микс 0 (изо)
* / 1 (перспектива), uRefDist — калибровочная дистанция (viewSize):
* uPxScale·uRefDist = (H/2)/tan(fov/2) — точка на дистанции viewSize
* рисуется в изо-масштабе пикселей, дальше сжимается по 1/w. */
export type ParticlePerspUniforms = Record<string, THREE.IUniform>;
export function makeParticlePerspUniforms(): ParticlePerspUniforms {
return { uPxScale: { value: 1 }, uPersp: { value: 0 }, uRefDist: { value: 1 } };
}
/** Блок юниформ света частиц: живёт на рендере, все слои (и пересозданные
* при росте ёмкости) ссылаются на ТЕ ЖЕ 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 },
uEnv: { value: new THREE.Vector3() }, // env-свет сцены (ambient + доля солнца)
};
}
/** Доля прямого солнца в env-свете освещаемых частиц: у билборда нет
* нормали, солнце приближается к всенаправленному — берём часть. */
export const PARTICLE_ENV_SUN = 0.3;
/** Насколько полученный свет проявляет освещаемую частицу: видимость —
* clamp((max-канал(полученный свет)·GAIN)^GAMMA, 0, 1) — множитель альфы
* и цвета. Ночью в темноте received ~0 — частица невидима, у лампы — видна. */
export const PARTICLE_LIT_VIS = 1.6;
/** Нелинейность кривой видимости: степень давит СЛАБЫЙ свет — в полутьме
* (received ~0.05–0.1) линейная кривая оставляла vis ~0.1 и серые точки
* на тёмном фоне, квадрат уводит их в невидимость; яркий свет (день,
* лампа) по-прежнему насыщает vis в 1. */
export const PARTICLE_LIT_GAMMA = 2.0;
/** Зеркало шейдерной кривой видимости освещаемых частиц (чистая функция —
* тесты сверяют поведение без WebGL): maxChannel — max-канал полученного
* света, результат — множитель альфы и цвета. */
export function litVisibility(maxChannel: number): number {
const v = Math.pow(Math.max(0, maxChannel) * PARTICLE_LIT_VIS, PARTICLE_LIT_GAMMA);
return Math.min(1, Math.max(0, v));
}
/** Источник света для 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.
*/
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;
uniform float uPersp;
uniform float uRefDist;
void main() {
vColor = color;
vAlpha = aAlpha;
vWorld = position;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
// перспектива: спрайт сжимается с глубиной (uRefDist/w); в изо uPersp = 0
// и w ортокамеры = 1 — множитель ровно 1, прежнее поведение бит-в-бит
gl_PointSize = aSize * uPxScale * mix(1.0, uRefDist / max(gl_Position.w, 1e-4), uPersp);
}`;
const POINT_FRAG = `
varying vec3 vColor;
varying float vAlpha;
varying vec3 vWorld;
uniform vec3 uTint;
uniform vec3 uEnv;
${LIGHT_FRAG}
void main() {
vec3 received = particleLight(vWorld);
float vis = 1.0;
#if LIT
// освещаемый режим: полученный свет (env + источники) правит и цветом,
// и видимостью — ночью в темноте частица исчезает, у лампы проявляется.
// tint тоже гасится vis: иначе ночью цвет остаётся полным и полупрозрачные
// СЕРЫЕ точки читаются на тёмном фоне (запрос «в темноте ещё темнее»)
received += uEnv;
// грабля: число из TS в шаблоне печатается как "2" (int) — pow(int)
// в GLSL ES не компилируется, степень обязана быть float-литералом
vis = clamp(pow(max(received.r, max(received.g, received.b)) * ${PARTICLE_LIT_VIS}, ${PARTICLE_LIT_GAMMA.toFixed(1)}), 0.0, 1.0);
#endif
gl_FragColor = vec4(vColor * (uTint * vis + received), vAlpha * vis);
}`;
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 — общий блок юниформ света рендера, perspU — общий блок перспективного
* затухания (ссылки на IUniform шарятся). lit — освещаемый режим: видимость
* по полученному свету (см. шапку). */
function createSpriteImpl(
scene: THREE.Scene, capacity: number, perspU: ParticlePerspUniforms,
lights: ParticleLightUniforms, lit: boolean,
) {
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: { ...perspU, uTint: { value: new THREE.Vector3(1, 1, 1) }, ...lights },
defines: { LIT: lit ? 1 : 0 },
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, perspU: ParticlePerspUniforms,
lights: ParticleLightUniforms, lit: boolean,
): ParticleLayer {
let cap = capacity;
let tint = 0xffffff; // помним tint слоя: при росте ёмкости свежий impl
let impl = createSpriteImpl(scene, cap, perspU, lights, lit); // получит дефолт
const layer: ParticleLayer = {
update: (points) => {
if (points.length > cap) {
impl.dispose();
cap = points.length;
impl = createSpriteImpl(scene, cap, perspU, lights, lit);
impl.setTint(tint); // иначе новый impl теряет tint слоя
}
impl.update(points);
},
setTint: (color) => {
tint = color;
impl.setTint(color);
},
dispose: () => impl.dispose(),
};
return layer;
}
/** Внутренняя реализация слоя квадов без роста ёмкости (рост — у фасада).
* 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;
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 },
defines: { LIT: lit ? 1 : 0 },
...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, lit: boolean): ParticleLayer {
let cap = capacity;
let tint = 0xffffff; // помним tint: при росте ёмкости свежий impl получит его
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, lit);
impl.setTint(tint);
}
impl.update(points);
},
setTint: (color) => {
tint = color;
impl.setTint(color);
},
dispose: () => impl.dispose(),
};
return layer;
}
/** Публичные фабрики: стартовая ёмкость и px-масштаб спрайтов задаёт рендер. */
export { createSpriteLayer, createQuadLayer };