/**
* Слои частиц рендера: лёгкий 2D-рендер поверх воксельного мира — спрайты-
* билборды (THREE.Points, один draw call) и плоские квадраты, лежащие на
* земле/воде (InstancedBufferGeometry). Не воксели: без освещения, теней и
* AO — цвет и прозрачность на частицу, пересборка поз каждый кадр дёшева.
* Цвет пишется в буфер как есть (hex-компоненты без colorspace-конверсий) —
* совпадает с палитрой.
*/
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;
dispose(): void;
}
/** 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];
}
const POINT_VERT = `
attribute float aAlpha;
attribute float aSize;
varying vec3 vColor;
varying float vAlpha;
uniform float uPxScale;
void main() {
vColor = color;
vAlpha = aAlpha;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
gl_PointSize = aSize * uPxScale;
}`;
const POINT_FRAG = `
varying vec3 vColor;
varying float vAlpha;
void main() { gl_FragColor = vec4(vColor, vAlpha); }`;
const QUAD_VERT = `
attribute vec3 iPos;
attribute vec3 iColor;
attribute float iAlpha;
attribute float iSize;
varying vec3 vColor;
varying float vAlpha;
void main() {
vColor = iColor;
vAlpha = iAlpha;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position * iSize + iPos, 1.0);
}`;
const FLAGS = { transparent: true, depthWrite: false, depthTest: true };
/** Фабрика слоя спрайтов (отдельная функция — рекурсия при росте ёмкости). */
function createSpriteLayer(scene: THREE.Scene, capacity: number, pxScale: number): 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 } }, 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);
layer.update = fresh.update;
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;
},
dispose: () => { scene.remove(mesh); geo.dispose(); mat.dispose(); },
};
return layer;
}
/** Фабрика слоя плоских квадов (лежат в XZ; дымка на воде, блики). */
function createQuadLayer(scene: THREE.Scene, capacity: number): 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, ...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);
layer.update = fresh.update;
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;
},
dispose: () => { scene.remove(mesh); geo.dispose(); mat.dispose(); base.dispose(); },
};
return layer;
}
/** Публичные фабрики: стартовая ёмкость и px-масштаб спрайтов задаёт рендер. */
export { createSpriteLayer, createQuadLayer };