TelegramPWA/src/util/particles.ts
2025-07-29 14:33:44 +02:00

690 lines
21 KiB
TypeScript

// GPU-Accelerated Particle System Library
import generateUniqueId from './generateUniqueId.ts';
import { getIsInBackground } from '../hooks/window/useBackgroundMode.ts';
export interface ParticleConfig {
width?: number;
height?: number;
particleCount?: number;
color?: Color | ColorPair;
speed?: number;
baseSize?: number;
minSpawnRadius?: number;
maxSpawnRadius?: number;
distanceLimit?: number;
fadeInTime?: number;
fadeOutTime?: number;
minLifetime?: number;
maxLifetime?: number;
maxStartTimeDelay?: number;
edgeFadeZone?: number;
centerShift?: readonly [number, number];
accelerationFactor?: number;
selfDestroyTime?: number;
}
interface Locations {
attributes: {
startPosition: number;
velocity: number;
startTime: number;
lifetime: number;
size: number;
baseOpacity: number;
color: number;
};
uniforms: {
resolution: WebGLUniformLocation | null;
time: WebGLUniformLocation | null;
canvasWidth: WebGLUniformLocation | null;
canvasHeight: WebGLUniformLocation | null;
accelerationFactor: WebGLUniformLocation | null;
fadeInTime: WebGLUniformLocation | null;
fadeOutTime: WebGLUniformLocation | null;
edgeFadeZone: WebGLUniformLocation | null;
rotationMatrices: WebGLUniformLocation | null;
spawnCenter: WebGLUniformLocation | null;
};
}
interface Buffers {
startPosition: WebGLBuffer | null;
velocity: WebGLBuffer | null;
startTime: WebGLBuffer | null;
lifetime: WebGLBuffer | null;
size: WebGLBuffer | null;
baseOpacity: WebGLBuffer | null;
color: WebGLBuffer | null;
}
interface ParticleSystem {
id: string;
config: Required<ParticleConfig>;
buffers: Buffers;
startTime: number;
seed: number;
centerX: number;
centerY: number;
avgDistance: number;
selfDestroyTimeout?: number;
}
interface ParticleSystemManager {
addSystem: (options: Partial<ParticleConfig>) => NoneToVoidFunction;
}
type Color = readonly [number, number, number];
type ColorPair = readonly [Color, Color];
export const PARTICLE_COLORS = {
blue: [0, 152 / 255, 234 / 255] as Color,
blueGradient: [
[1 / 255, 88 / 255, 175 / 255],
[103 / 255, 208 / 255, 255 / 255],
] as ColorPair,
purple: [150 / 255, 111 / 255, 254 / 255] as Color,
purpleGradient: [
[107 / 255, 147 / 255, 255 / 255],
[228 / 255, 106 / 255, 206 / 255],
] as ColorPair,
gold: [255 / 255, 191 / 255, 10 / 255] as Color,
goldGradient: [
[253 / 255, 235 / 255, 50 / 255],
[215 / 255, 89 / 255, 2 / 255],
] as ColorPair,
};
export const PARTICLE_BURST_PARAMS: Partial<ParticleConfig> = {
particleCount: 5,
distanceLimit: 1,
fadeInTime: 0.05,
minLifetime: 3,
maxLifetime: 3,
maxStartTimeDelay: 0,
selfDestroyTime: 3,
minSpawnRadius: 5,
maxSpawnRadius: 50,
};
const DEFAULT_CONFIG: Required<ParticleConfig> = {
width: 350,
height: 230,
particleCount: 100,
color: [0, 152 / 255, 234 / 255], // #0098EA (TON)
speed: 18,
baseSize: 6,
minSpawnRadius: 35,
maxSpawnRadius: 70,
distanceLimit: 0.7,
fadeInTime: 0.25,
fadeOutTime: 1,
minLifetime: 4,
maxLifetime: 6,
maxStartTimeDelay: 3,
edgeFadeZone: 50,
centerShift: [0, 0],
accelerationFactor: 3,
selfDestroyTime: 0,
};
const SIZE_SMALL = 0.67;
const SIZE_MEDIUM = 1.33;
const SIZE_LARGE = 2.2;
const canvasManagers = new Map<HTMLCanvasElement, ParticleSystemManager>();
export function setupParticles(
canvas: HTMLCanvasElement,
options: Partial<ParticleConfig>,
) {
let manager = canvasManagers.get(canvas);
if (!manager) {
manager = createParticleSystemManager(canvas);
canvasManagers.set(canvas, manager);
}
return manager.addSystem(options);
}
function createParticleSystemManager(canvas: HTMLCanvasElement) {
const gl = canvas.getContext('webgl', {
alpha: true,
antialias: false,
preserveDrawingBuffer: false,
})!;
if (!gl) {
throw new Error('WebGL not supported');
}
const vertexShader = createShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER_SOURCE);
const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER_SOURCE);
if (!vertexShader || !fragmentShader) {
throw new Error('Failed to create shaders');
}
const program = createProgram(gl, vertexShader, fragmentShader)!;
if (!program) {
throw new Error('Failed to create shader program');
}
const dpr = window.devicePixelRatio || 1;
const systems = new Map<string, ParticleSystem>();
const locations: Locations = {
attributes: {
startPosition: gl.getAttribLocation(program, 'a_startPosition'),
velocity: gl.getAttribLocation(program, 'a_velocity'),
startTime: gl.getAttribLocation(program, 'a_startTime'),
lifetime: gl.getAttribLocation(program, 'a_lifetime'),
size: gl.getAttribLocation(program, 'a_size'),
baseOpacity: gl.getAttribLocation(program, 'a_baseOpacity'),
color: gl.getAttribLocation(program, 'a_color'),
},
uniforms: {
resolution: gl.getUniformLocation(program, 'u_resolution'),
time: gl.getUniformLocation(program, 'u_time'),
canvasWidth: gl.getUniformLocation(program, 'u_canvasWidth'),
canvasHeight: gl.getUniformLocation(program, 'u_canvasHeight'),
accelerationFactor: gl.getUniformLocation(program, 'u_accelerationFactor'),
fadeInTime: gl.getUniformLocation(program, 'u_fadeInTime'),
fadeOutTime: gl.getUniformLocation(program, 'u_fadeOutTime'),
edgeFadeZone: gl.getUniformLocation(program, 'u_edgeFadeZone'),
rotationMatrices: gl.getUniformLocation(program, 'u_rotationMatrices'),
spawnCenter: gl.getUniformLocation(program, 'u_spawnCenter'),
},
};
let animationId: number | undefined;
let unsubscribeFromIsInBackground: NoneToVoidFunction | undefined = undefined;
function initParticleData(system: ParticleSystem): void {
const rng = new SeededRandom(system.seed);
const { config } = system;
const startPositions = new Float32Array(config.particleCount * 2);
const velocities = new Float32Array(config.particleCount * 2);
const startTimes = new Float32Array(config.particleCount);
const lifetimes = new Float32Array(config.particleCount);
const sizes = new Float32Array(config.particleCount);
const baseOpacities = new Float32Array(config.particleCount);
const colors = new Float32Array(config.particleCount * 3); // RGB for each particle
for (let i = 0; i < config.particleCount; i++) {
const angle = rng.next() * Math.PI * 2;
const spawnRadius = rng.nextBetween(config.minSpawnRadius, config.maxSpawnRadius);
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const spawnX = system.centerX + cos * spawnRadius;
const spawnY = system.centerY + sin * spawnRadius;
startPositions[i * 2] = spawnX * dpr;
startPositions[i * 2 + 1] = spawnY * dpr;
lifetimes[i] = rng.nextBetween(config.minLifetime, config.maxLifetime);
startTimes[i] = rng.next() * config.maxStartTimeDelay;
const travelDist = rng.nextBetween(
system.avgDistance * config.distanceLimit * 0.5,
system.avgDistance * config.distanceLimit,
);
// Calculate speed based on travel distance and lifetime
const speed = (travelDist / lifetimes[i]) * dpr;
velocities[i * 2] = cos * speed;
velocities[i * 2 + 1] = sin * speed;
const sizeVariant = rng.next();
if (sizeVariant < 0.3) {
sizes[i] = config.baseSize * SIZE_SMALL * dpr;
} else if (sizeVariant < 0.7) {
sizes[i] = config.baseSize * SIZE_MEDIUM * dpr;
} else {
sizes[i] = config.baseSize * SIZE_LARGE * dpr;
}
baseOpacities[i] = rng.nextBetween(0.3, 0.8);
const particleColor = resolveColor(config.color, rng);
colors[i * 3] = particleColor[0];
colors[i * 3 + 1] = particleColor[1];
colors[i * 3 + 2] = particleColor[2];
}
gl.bindBuffer(gl.ARRAY_BUFFER, system.buffers.startPosition);
gl.bufferData(gl.ARRAY_BUFFER, startPositions, gl.STATIC_DRAW);
gl.bindBuffer(gl.ARRAY_BUFFER, system.buffers.velocity);
gl.bufferData(gl.ARRAY_BUFFER, velocities, gl.STATIC_DRAW);
gl.bindBuffer(gl.ARRAY_BUFFER, system.buffers.startTime);
gl.bufferData(gl.ARRAY_BUFFER, startTimes, gl.STATIC_DRAW);
gl.bindBuffer(gl.ARRAY_BUFFER, system.buffers.lifetime);
gl.bufferData(gl.ARRAY_BUFFER, lifetimes, gl.STATIC_DRAW);
gl.bindBuffer(gl.ARRAY_BUFFER, system.buffers.size);
gl.bufferData(gl.ARRAY_BUFFER, sizes, gl.STATIC_DRAW);
gl.bindBuffer(gl.ARRAY_BUFFER, system.buffers.baseOpacity);
gl.bufferData(gl.ARRAY_BUFFER, baseOpacities, gl.STATIC_DRAW);
gl.bindBuffer(gl.ARRAY_BUFFER, system.buffers.color);
gl.bufferData(gl.ARRAY_BUFFER, colors, gl.STATIC_DRAW);
}
function initCanvas(): void {
// Find the max canvas size from all systems
let maxWidth = 0;
let maxHeight = 0;
systems.forEach((system) => {
maxWidth = Math.max(maxWidth, system.config.width);
maxHeight = Math.max(maxHeight, system.config.height);
});
// Default to first system's size if no systems yet
if (systems.size === 0) {
maxWidth = DEFAULT_CONFIG.width;
maxHeight = DEFAULT_CONFIG.height;
}
if (canvas.width !== maxWidth * dpr || canvas.height !== maxHeight * dpr) {
canvas.width = maxWidth * dpr;
canvas.height = maxHeight * dpr;
canvas.style.width = maxWidth + 'px';
canvas.style.height = maxHeight + 'px';
}
gl.viewport(0, 0, canvas.width, canvas.height);
}
function initWebGLState(): void {
gl.useProgram(program);
// Set static uniforms that will be updated per system
gl.uniform2f(locations.uniforms.resolution, canvas.width, canvas.height);
gl.uniformMatrix2fv(locations.uniforms.rotationMatrices, false, getRotations());
// Set blending state
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
// Set clear color
gl.clearColor(0, 0, 0, 0);
}
function render(currentTime: number): void {
if (!animationId) return;
gl.clear(gl.COLOR_BUFFER_BIT);
// Render all systems
systems.forEach((system) => {
const systemTime = (currentTime - system.startTime) / 1000;
// Set uniforms for this system
gl.uniform1f(locations.uniforms.time, systemTime);
gl.uniform1f(locations.uniforms.canvasWidth, system.config.width * dpr);
gl.uniform1f(locations.uniforms.canvasHeight, system.config.height * dpr);
gl.uniform1f(locations.uniforms.accelerationFactor, system.config.accelerationFactor);
gl.uniform1f(locations.uniforms.fadeInTime, system.config.fadeInTime);
gl.uniform1f(locations.uniforms.fadeOutTime, system.config.fadeOutTime);
gl.uniform1f(locations.uniforms.edgeFadeZone, system.config.edgeFadeZone * dpr);
gl.uniform2f(locations.uniforms.spawnCenter, system.centerX * dpr, system.centerY * dpr);
// Bind attributes for this system
gl.bindBuffer(gl.ARRAY_BUFFER, system.buffers.startPosition);
gl.enableVertexAttribArray(locations.attributes.startPosition);
gl.vertexAttribPointer(locations.attributes.startPosition, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, system.buffers.velocity);
gl.enableVertexAttribArray(locations.attributes.velocity);
gl.vertexAttribPointer(locations.attributes.velocity, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, system.buffers.startTime);
gl.enableVertexAttribArray(locations.attributes.startTime);
gl.vertexAttribPointer(locations.attributes.startTime, 1, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, system.buffers.lifetime);
gl.enableVertexAttribArray(locations.attributes.lifetime);
gl.vertexAttribPointer(locations.attributes.lifetime, 1, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, system.buffers.size);
gl.enableVertexAttribArray(locations.attributes.size);
gl.vertexAttribPointer(locations.attributes.size, 1, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, system.buffers.baseOpacity);
gl.enableVertexAttribArray(locations.attributes.baseOpacity);
gl.vertexAttribPointer(locations.attributes.baseOpacity, 1, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, system.buffers.color);
gl.enableVertexAttribArray(locations.attributes.color);
gl.vertexAttribPointer(locations.attributes.color, 3, gl.FLOAT, false, 0, 0);
// Draw particles for this system
gl.drawArrays(gl.POINTS, 0, system.config.particleCount);
});
animationId = requestAnimationFrame(render);
}
function addSystem(options: Partial<ParticleConfig>) {
const id = generateUniqueId();
const config: Required<ParticleConfig> = { ...DEFAULT_CONFIG, ...options };
const buffers: Buffers = {
startPosition: gl.createBuffer(),
velocity: gl.createBuffer(),
startTime: gl.createBuffer(),
lifetime: gl.createBuffer(),
size: gl.createBuffer(),
baseOpacity: gl.createBuffer(),
color: gl.createBuffer(),
};
const system: ParticleSystem = {
id,
config,
buffers,
startTime: performance.now(),
seed: Math.floor(Math.random() * 1000000),
centerX: config.width / 2 + config.centerShift[0],
centerY: config.height / 2 + config.centerShift[1],
avgDistance: (config.width / 2 + config.height / 2) / 2,
};
systems.set(id, system);
initParticleData(system);
initCanvas();
if (config.selfDestroyTime) {
system.selfDestroyTimeout = window.setTimeout(() => {
removeSystem(id);
}, config.selfDestroyTime * 1000);
}
if (systems.size === 1) {
initWebGLState();
unsubscribeFromIsInBackground = getIsInBackground.subscribe(() => {
const isActive = !getIsInBackground();
if (isActive && !animationId) {
animationId = requestAnimationFrame(render);
} else if (!isActive && animationId) {
cancelAnimationFrame(animationId);
animationId = undefined;
}
});
animationId = requestAnimationFrame(render);
}
return () => removeSystem(id);
}
function removeSystem(id: string): void {
const system = systems.get(id);
if (!system) return;
if (system.selfDestroyTimeout) {
clearTimeout(system.selfDestroyTimeout);
}
Object.values(system.buffers).forEach((buffer) => {
if (buffer) gl.deleteBuffer(buffer);
});
systems.delete(id);
if (systems.size === 0) {
destroy();
}
}
function destroy(): void {
if (animationId !== undefined) {
cancelAnimationFrame(animationId);
animationId = undefined;
}
unsubscribeFromIsInBackground?.();
systems.clear();
gl.deleteProgram(program);
gl.deleteShader(vertexShader!);
gl.deleteShader(fragmentShader!);
canvasManagers.delete(canvas);
}
return { addSystem };
}
const VERTEX_SHADER_SOURCE = `
attribute vec2 a_startPosition;
attribute vec2 a_velocity;
attribute float a_startTime;
attribute float a_lifetime;
attribute float a_size;
attribute float a_baseOpacity;
attribute vec3 a_color;
uniform vec2 u_resolution;
uniform float u_time;
uniform float u_canvasWidth;
uniform float u_canvasHeight;
uniform float u_accelerationFactor;
uniform float u_fadeInTime;
uniform float u_fadeOutTime;
uniform float u_edgeFadeZone;
uniform mat2 u_rotationMatrices[18];
uniform vec2 u_spawnCenter;
varying float v_opacity;
varying vec3 v_color;
void main() {
float totalAge = u_time - a_startTime;
float age = mod(totalAge, a_lifetime);
// For the initial animation, fade in all particles
float globalFadeIn = min(u_time / u_fadeInTime, 1.0);
float lifeRatio = age / a_lifetime;
// Calculate rotation based on completed lifecycles
float lifecycleCount = floor(totalAge / a_lifetime);
int rotationIndex = int(mod(lifecycleCount, 18.0));
// Get rotation matrix
mat2 rotationMatrix = u_rotationMatrices[rotationIndex];
// Rotate start position around spawn center
vec2 startOffset = a_startPosition - u_spawnCenter;
vec2 rotatedStartOffset = rotationMatrix * startOffset;
vec2 rotatedStartPosition = u_spawnCenter + rotatedStartOffset;
// Apply rotation matrix to velocity
vec2 rotatedVelocity = rotationMatrix * a_velocity;
// Apply shoot-out effect: fast initial speed that slows down
float speedMultiplier = 1.0 + u_accelerationFactor * exp(-3.0 * lifeRatio);
vec2 position = rotatedStartPosition + rotatedVelocity * age * speedMultiplier;
float opacity = 1.0;
if (lifeRatio < u_fadeInTime / a_lifetime) {
opacity = (lifeRatio * a_lifetime) / u_fadeInTime;
} else if (lifeRatio > 1.0 - u_fadeOutTime / a_lifetime) {
opacity = (1.0 - lifeRatio) * a_lifetime / u_fadeOutTime;
}
opacity *= a_baseOpacity * globalFadeIn;
float distToLeft = position.x;
float distToRight = u_canvasWidth - position.x;
float distToTop = position.y;
float distToBottom = u_canvasHeight - position.y;
float distToEdge = min(min(distToLeft, distToRight), min(distToTop, distToBottom));
if (distToEdge < u_edgeFadeZone) {
opacity *= distToEdge / u_edgeFadeZone;
}
vec2 clipSpace = ((position / u_resolution) * 2.0 - 1.0) * vec2(1, -1);
gl_Position = vec4(clipSpace, 0, 1);
gl_PointSize = a_size;
v_opacity = opacity;
v_color = a_color;
}
`;
const FRAGMENT_SHADER_SOURCE = `
precision mediump float;
varying float v_opacity;
varying vec3 v_color;
void main() {
vec2 coord = gl_PointCoord - vec2(0.5);
// Create a four-pointed star
float absX = abs(coord.x);
float absY = abs(coord.y);
// Star parameters
float innerSize = 0.12; // Size of center square
float armLength = 0.45; // Length of star arms
float armWidth = 0.08; // Half-width of star arms at base
float dist = 1.0; // Default to outside
// Center square
if (absX <= innerSize && absY <= innerSize) {
dist = max(absX, absY) - innerSize;
}
// Horizontal arms (left and right points)
else if (absY <= armWidth && absX <= armLength) {
// Taper the arms - they get narrower toward the tips
float normalizedX = (absX - innerSize) / (armLength - innerSize);
float taperFactor = 1.0 - normalizedX * 0.8; // Taper to 20% of original width
float currentArmWidth = armWidth * taperFactor;
dist = absY - currentArmWidth;
}
// Vertical arms (top and bottom points)
else if (absX <= armWidth && absY <= armLength) {
// Taper the arms - they get narrower toward the tips
float normalizedY = (absY - innerSize) / (armLength - innerSize);
float taperFactor = 1.0 - normalizedY * 0.8; // Taper to 20% of original width
float currentArmWidth = armWidth * taperFactor;
dist = absX - currentArmWidth;
}
// Use smoothstep for anti-aliasing to reduce subpixel artifacts
float alpha = 1.0 - smoothstep(-0.01, 0.01, dist);
if (alpha <= 0.0) {
discard;
}
gl_FragColor = vec4(v_color * v_opacity * alpha, v_opacity * alpha);
}
`;
function createShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | undefined {
const shader = gl.createShader(type);
if (!shader) return undefined;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
gl.deleteShader(shader);
return undefined;
}
return shader;
}
function createProgram(gl: WebGLRenderingContext,
vertexShader: WebGLShader,
fragmentShader: WebGLShader): WebGLProgram | undefined {
const program = gl.createProgram();
if (!program) return undefined;
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
gl.deleteProgram(program);
return undefined;
}
return program;
}
class SeededRandom {
private seed: number;
constructor(seed: number) {
this.seed = seed;
}
next(): number {
this.seed = (this.seed * 9301 + 49297) % 233280;
return this.seed / 233280;
}
nextBetween(min: number, max: number): number {
return min + (max - min) * this.next();
}
}
let rotationsCache: Float32Array | undefined;
function getRotations(): Float32Array {
if (!rotationsCache) {
const ROTATION_COUNT = 18; // n = [0..17]
const ROTATION_ANGLE_DEGREES = 220;
rotationsCache = new Float32Array(ROTATION_COUNT * 4); // mat2 = 4 floats
for (let i = 0; i < ROTATION_COUNT; i++) {
const angle = (ROTATION_ANGLE_DEGREES * Math.PI / 180) * i;
const cos = Math.cos(angle);
const sin = Math.sin(angle);
// mat2 in column-major order: [cos, sin, -sin, cos]
rotationsCache[i * 4] = cos;
rotationsCache[i * 4 + 1] = sin;
rotationsCache[i * 4 + 2] = -sin;
rotationsCache[i * 4 + 3] = cos;
}
}
return rotationsCache;
}
function resolveColor(colorDefinition: Color | ColorPair, rng: SeededRandom) {
if (Array.isArray(colorDefinition[0])) {
const [color1, color2] = colorDefinition as ColorPair;
return [
rng.nextBetween(color1[0], color2[0]),
rng.nextBetween(color1[1], color2[1]),
rng.nextBetween(color1[2], color2[2]),
] as Color;
}
return colorDefinition as Color;
}