// 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; buffers: Buffers; startTime: number; seed: number; centerX: number; centerY: number; avgDistance: number; selfDestroyTimeout?: number; } interface ParticleSystemManager { addSystem: (options: Partial) => 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 = { particleCount: 5, distanceLimit: 1, fadeInTime: 0.05, minLifetime: 3, maxLifetime: 3, maxStartTimeDelay: 0, selfDestroyTime: 3, minSpawnRadius: 5, maxSpawnRadius: 50, }; const DEFAULT_CONFIG: Required = { 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(); export function setupParticles( canvas: HTMLCanvasElement, options: Partial, ) { 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(); 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) { const id = generateUniqueId(); const config: Required = { ...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; }