/**
* Слои частиц рендера: лёгкий 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 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;
u.uSpotCos!.value = Math.cos(spot.angle / 2);
u.uSpotSoft!.value = Math.cos((spot.angle / 2) * (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+свет)) — красная частица
* у тёплой лампы краснеет ярче, а не белеет (аддитив дал бы белую дымку). */
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 * 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 createSpriteLayer(
scene: THREE.Scene, capacity: number, pxScale: number, lights: ParticleLightUniforms,
): ParticleLayer {
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);
const layer: ParticleLayer = {
update: (points) => {
if (points.length > capacity) {
scene.remove(mesh); geo.dispose(); mat.dispose();
const fresh = createSpriteLayer(scene, points.length, pxScale, lights);
layer.update = fresh.update;
layer.setTint = fresh.setTint;
layer.dispose = fresh.dispose;
fresh.update(points); // грабля: редирект без позы терял первый кадр
return;
}
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) => {
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(); },
};
return layer;
}
/** Фабрика слоя плоских квадов (лежат в XZ; дымка на воде, блики). */
function createQuadLayer(scene: THREE.Scene, capacity: number, lights: ParticleLightUniforms): ParticleLayer {
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);
const layer: ParticleLayer = {
update: (points) => {
if (points.length > capacity) {
scene.remove(mesh); geo.dispose(); mat.dispose(); base.dispose();
const fresh = createQuadLayer(scene, points.length, lights);
layer.update = fresh.update;
layer.setTint = fresh.setTint;
layer.dispose = fresh.dispose;
fresh.update(points); // грабля: редирект без позы терял первый кадр
return;
}
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) => {
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(); },
};
return layer;
}
/** Публичные фабрики: стартовая ёмкость и px-масштаб спрайтов задаёт рендер. */
export { createSpriteLayer, createQuadLayer };