UNPKG

@fluritheme/fluri

Version:

Interactive snowfall for websites: DOM-aware snow accumulation, mouse interactions, snow physics, Canvas 2D rendering, and Christmas effects.

431 lines • 21.3 kB
import { SurfaceIndex, sweptSurfaceHit } from './collisions.js'; import { clamp, localToWorld, seededRandom } from './math.js'; import { deposit, snowYAt, melt } from './profiles.js'; import { createSurfaceFlowState, settleSurface } from './settling.js'; export class ParticleSystem { static transientLimit = 320; particles = []; random; index = new SurfaceIndex(); intensity = 0; requestedIntensity; initialized = false; clumpCount = 0; gustStrength = 0; gustRemaining = 0; flowing = new WeakMap(); contacts = new WeakMap(); sources = new WeakMap(); emission = { x: 0, y: 0, width: 1, height: 1 }; constructor(seed = 1) { this.random = seededRandom(seed); } setIntensity(amount) { if (!Number.isFinite(amount)) throw new RangeError('Snow intensity must be finite.'); this.requestedIntensity = clamp(amount, 0, 1); } gust(strength = 1, duration = 2) { if (!Number.isFinite(strength) || !Number.isFinite(duration)) throw new RangeError('Gust parameters must be finite.'); this.gustStrength = clamp(strength, -4, 4); this.gustRemaining = clamp(duration, 0, 30); } clear() { this.particles.length = 0; this.initialized = false; this.clumpCount = 0; this.intensity = 0; this.gustRemaining = 0; this.flowing = new WeakMap(); this.contacts = new WeakMap(); this.sources = new WeakMap(); } spawnClump(x, y, mass, vx, vy, source) { if (![x, y, mass, vx, vy].every(Number.isFinite) || mass <= 0) return; if (this.clumpCount >= ParticleSystem.transientLimit) { const clumps = this.particles.filter((p) => p.kind === 'clump'); // Coalesce parcels under pressure; keep both snow mass and linear momentum. let nearest = clumps[0]; let distance = Infinity; for (const candidate of clumps.slice(-32)) { const squared = (candidate.x - x) ** 2 + (candidate.y - y) ** 2; if (squared < distance) { nearest = candidate; distance = squared; } } if (nearest) { const combinedMass = nearest.mass + mass; nearest.x = (nearest.x * nearest.mass + x * mass) / combinedMass; nearest.y = (nearest.y * nearest.mass + y * mass) / combinedMass; nearest.vx = (nearest.vx * nearest.mass + vx * mass) / combinedMass; nearest.vy = (nearest.vy * nearest.mass + vy * mass) / combinedMass; nearest.mass = combinedMass; nearest.radius = clamp(Math.sqrt(combinedMass / Math.PI), 0.8, 18); return; } } const particle = { x, y, previousX: x, previousY: y, vx, vy, radius: clamp(Math.sqrt(mass / Math.PI), 0.8, 18), mass, opacity: 0.95, phase: this.random() * Math.PI * 2, depth: 1, kind: 'clump', age: 0, layer: 'foreground', }; this.particles.push(particle); this.clumpCount++; if (source) this.sources.set(particle, source); } burst(x, y, count, kind = 'flake') { if (![x, y, count].every(Number.isFinite)) return; const existingTransient = this.particles.reduce((total, particle) => total + Number(particle.kind === kind), 0); const boundedCount = Math.min(clamp(Math.floor(count), 0, 300), Math.max(0, ParticleSystem.transientLimit - existingTransient)); for (let index = 0; index < boundedCount; index++) { const angle = this.random() * Math.PI * 2; const speed = 35 + this.random() * 150; const radius = kind === 'sparkle' ? 1 + this.random() * 2 : 0.8 + this.random() * 2.5; this.particles.push({ x, y, previousX: x, previousY: y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed - 40, radius, mass: kind === 'sparkle' ? 0 : Math.PI * radius * radius, opacity: 1, phase: this.random() * Math.PI * 2, depth: 1, kind, age: 0, layer: 'foreground', }); } } step(dt, time, width, height, config, surfaces, quality = 1, emissionBounds) { const metrics = { collisionChecks: 0, deposits: 0, transfers: 0, slides: 0, slideMass: 0, }; if (!Number.isFinite(dt) || dt <= 0 || !Number.isFinite(time) || !(width > 0) || !(height > 0)) return metrics; // The engine uses fixed steps. Bounding a direct caller's step prevents resumed-tab explosions. dt = Math.min(dt, 1 / 15); this.emission = emissionBounds ?? { x: 0, y: 0, width, height }; const target = config.snow.enabled ? (this.requestedIntensity ?? config.snow.intensity) : 0; this.intensity += (target - this.intensity) * (1 - Math.exp(-dt * 2.8)); const desiredCount = Math.floor(config.snow.particleCount * clamp(quality, 0, 1) * this.intensity * Number(this.emission.width > 0 && this.emission.height > 0)); const gravity = config.snow.gravity; const wind = config.snow.wind.base * 110 + (config.snow.wind.gusts ? Math.sin(time * 0.43) * Math.sin(time * 0.17 + 1.8) * config.snow.wind.variability * 85 : 0); const gust = this.gustRemaining > 0 ? Math.sin((Math.min(1, this.gustRemaining) * Math.PI) / 2) * this.gustStrength * 160 : 0; this.gustRemaining = Math.max(0, this.gustRemaining - dt); this.clumpCount = this.particles.reduce((n, p) => n + Number(p.kind === 'clump'), 0); const accumulation = config.snow.accumulation; for (const surface of surfaces) { if (!surface.visible || surface.occluded || !surface.accumulation || !accumulation.enabled) continue; if (accumulation.meltRate > 0) melt(surface.profile, surface.binSize, accumulation.meltRate, dt); const flow = this.flowing.get(surface) ?? createSurfaceFlowState(); this.flowing.set(surface, flow); const moved = settleSurface(surface, dt, { gravity, softness: accumulation.softness, sliding: accumulation.sliding && accumulation.transferToLowerSurfaces, maxDepth: Math.min(surface.maxDepth, accumulation.maxDepth), }, flow); for (const parcel of moved.detached) { const position = localToWorld(surface, parcel.localX, parcel.localY); this.spawnClump(position.x - surface.velocity.x * dt, position.y - surface.velocity.y * dt, parcel.mass, parcel.vx, parcel.vy, surface); metrics.slides++; metrics.slideMass += parcel.mass; } for (const side of [-1, 1]) { const mass = side < 0 ? moved.leftMass : moved.rightMass; if (mass <= 0) continue; const velocity = side < 0 ? moved.leftVelocity : moved.rightVelocity; const radius = clamp(Math.sqrt(mass / Math.PI), 0.8, 18); const position = localToWorld(surface, side < 0 ? -radius - 0.75 : surface.width + radius + 0.75, snowYAt(surface, side < 0 ? 0 : surface.width) - radius * 0.25); this.spawnClump(position.x, position.y, mass, surface.velocity.x + Math.cos(surface.angle) * velocity, surface.velocity.y + Math.sin(surface.angle) * velocity + 12, surface); metrics.slides++; metrics.slideMass += mass; } } this.index.rebuild(surfaces); let flakeCount = this.particles.reduce((count, particle) => count + Number(particle.kind === 'flake'), 0); while (flakeCount < desiredCount) { this.particles.push(this.createFlake(config, !this.initialized)); flakeCount++; } this.initialized = true; let write = 0; const initialCount = this.particles.length; for (let index = 0; index < initialCount; index++) { const particle = this.particles[index]; if (!particle) continue; // Retire the excess over the smoothed target rather than abruptly hiding all snowfall. if (particle.kind === 'flake' && flakeCount > desiredCount) { flakeCount--; continue; } particle.previousX = particle.x; particle.previousY = particle.y; particle.age += dt; const contact = this.contacts.get(particle); if (contact) { const state = this.moveContact(particle, contact, dt, gravity, surfaces, config, metrics); if (state === 'attached') { this.particles[write++] = particle; continue; } if (state === 'deposited') continue; } const response = particle.kind === 'clump' ? 0.7 : 1.7 / Math.max(0.3, particle.depth); const turbulence = Math.sin(time * 0.95 + particle.phase) * config.snow.turbulence * 23; particle.vx += ((wind + gust + turbulence) * particle.depth - particle.vx) * (1 - Math.exp(-response * dt)); if (particle.kind === 'flake') { const terminal = 20 + particle.radius * 15 + particle.depth * 22; particle.vy += gravity * dt; particle.vy += (terminal - particle.vy) * (1 - Math.exp(-dt * 1.4)); } else particle.vy += gravity * dt * (particle.kind === 'clump' ? 1.5 : 0.3); particle.x += particle.vx * dt; particle.y += particle.vy * dt; let alive = true; if (particle.kind !== 'sparkle' && accumulation.enabled && (particle.kind !== 'clump' || accumulation.transferToLowerSurfaces)) { let nearest; for (const surface of this.index.query({ x: particle.previousX, y: particle.previousY }, particle, particle.radius)) { if (!surface.accumulation || surface.occluded || surface.material === 'no-accumulation') continue; if (this.sources.get(particle) === surface && particle.age < 0.2) continue; // Background flakes stay behind the page; they cannot create foreground deposits. if (config.dom.respectLayers && particle.layer === 'background' && surface.layer !== 'background') continue; metrics.collisionChecks++; // Transform the previous position into the current support's frame. const start = { x: particle.previousX + surface.velocity.x * dt, y: particle.previousY + surface.velocity.y * dt, }; const hit = sweptSurfaceHit(surface, start, particle, particle.radius); if (hit && (!nearest || hit.t < nearest.t - 1e-6 || (Math.abs(hit.t - nearest.t) <= 1e-6 && surface.paintOrder > nearest.surface.paintOrder))) nearest = hit; } if (nearest) { const surface = nearest.surface; const mass = particle.mass; const accepted = deposit(surface.profile, surface.binSize, nearest.x, mass, Math.min(surface.maxDepth, accumulation.maxDepth), surface.packing); if (accepted > 0) { metrics.deposits++; if (particle.kind === 'clump') metrics.transfers++; } const remaining = Math.max(0, mass - accepted); if (remaining < 1e-4) alive = false; else { // A full shelf supports a coherent rolling clump; it must travel to an edge. particle.mass = remaining; if (particle.kind !== 'clump') this.clumpCount++; particle.kind = 'clump'; particle.radius = clamp(Math.sqrt(remaining / Math.PI), 0.8, 18); particle.layer = surface.layer; const position = localToWorld(surface, nearest.x, snowYAt(surface, nearest.x) - particle.radius); particle.x = position.x; particle.y = position.y; const direction = nearest.x < surface.width / 2 ? -1 : 1; const tangent = (particle.vx - surface.velocity.x) * Math.cos(surface.angle) + (particle.vy - surface.velocity.y) * Math.sin(surface.angle); const normalSpeed = Math.max(0, -((particle.vx - surface.velocity.x) * nearest.normal.x + (particle.vy - surface.velocity.y) * nearest.normal.y)); this.contacts.set(particle, { surface, x: nearest.x, velocity: tangent + direction * Math.min(180, Math.max(90, normalSpeed * 0.32)), }); } } } if (particle.kind === 'sparkle') { particle.opacity = Math.max(0, 1 - particle.age / 1.8); if (particle.age >= 1.8) alive = false; } if (particle.y > this.emission.y + this.emission.height + 60 || particle.x < this.emission.x - Math.max(100, Math.abs(wind) * 2) || particle.x > this.emission.x + this.emission.width + Math.max(100, Math.abs(wind) * 2)) alive = false; if (alive) this.particles[write++] = particle; else if (particle.kind === 'flake') flakeCount--; } this.particles.length = write; this.clumpCount = this.particles.reduce((n, p) => n + Number(p.kind === 'clump'), 0); if (this.clumpCount > ParticleSystem.transientLimit) { const kept = []; let count = 0; for (const particle of this.particles) { if (particle.kind !== 'clump' || kept.length < ParticleSystem.transientLimit) { this.particles[count++] = particle; if (particle.kind === 'clump') kept.push(particle); continue; } let target = kept[0], nearest = Infinity; for (let index = Math.max(0, kept.length - 32); index < kept.length; index++) { const candidate = kept[index]; if (!candidate) continue; const d = (candidate.x - particle.x) ** 2 + (candidate.y - particle.y) ** 2; if (d < nearest) { nearest = d; target = candidate; } } if (target) { const mass = target.mass + particle.mass; target.x = (target.x * target.mass + particle.x * particle.mass) / mass; target.y = (target.y * target.mass + particle.y * particle.mass) / mass; target.vx = (target.vx * target.mass + particle.vx * particle.mass) / mass; target.vy = (target.vy * target.mass + particle.vy * particle.mass) / mass; target.mass = mass; target.radius = clamp(Math.sqrt(mass / Math.PI), 0.8, 18); } } this.particles.length = count; this.clumpCount = kept.length; } return metrics; } createFlake(config, distribute) { const depth = config.snow.parallax ? 0.45 + this.random() * 0.95 : 1; const radius = config.snow.minSize + this.random() * Math.max(0, config.snow.maxSize - config.snow.minSize); const x = this.emission.x + this.random() * this.emission.width; const y = distribute ? this.emission.y + this.random() * this.emission.height : this.emission.y - 8 - this.random() * 40; const layer = config.snow.parallax && depth < 0.65 ? 'background' : depth > 1.1 ? 'foreground' : 'content'; return { x, y, previousX: x, previousY: y, vx: config.snow.wind.base * 110, vy: 20 + radius * 15 + depth * 22, radius: radius * depth, mass: Math.PI * radius * radius * 2.4 * config.snow.accumulation.growthRate, opacity: clamp(0.35 + depth * 0.4, 0.3, 0.95), phase: this.random() * Math.PI * 2, depth, kind: 'flake', age: 0, layer, }; } moveContact(particle, contact, dt, gravity, surfaces, config, metrics) { const support = contact.surface; const tangent = { x: Math.cos(support.angle), y: Math.sin(support.angle) }; const normalLoading = gravity * Math.cos(support.angle) + support.acceleration.x * Math.sin(support.angle) - support.acceleration.y * Math.cos(support.angle); const cohesion = support.material === 'sticky' ? 180 : support.material === 'slippery' ? 10 : 45; if (normalLoading < -cohesion) { particle.vx = (support.previousVelocity?.x ?? support.velocity.x - support.acceleration.x * dt) + tangent.x * contact.velocity; particle.vy = (support.previousVelocity?.y ?? support.velocity.y - support.acceleration.y * dt) + tangent.y * contact.velocity; this.contacts.delete(particle); this.sources.set(particle, support); particle.age = 0; metrics.slides++; metrics.slideMass += particle.mass; return 'airborne'; } const relativeAcceleration = gravity * tangent.y - support.acceleration.x * tangent.x - support.acceleration.y * tangent.y; const friction = (support.material === 'slippery' ? 0.035 : support.material === 'sticky' ? 0.28 : 0.09) * Math.max(12, gravity * Math.cos(support.angle)); const oldVelocity = contact.velocity; contact.velocity += (relativeAcceleration - Math.sign(contact.velocity || relativeAcceleration) * friction) * dt; if (oldVelocity * contact.velocity < 0 && Math.abs(relativeAcceleration) < friction) contact.velocity = 0; contact.x += contact.velocity * dt; particle.vx = support.velocity.x + tangent.x * contact.velocity; particle.vy = support.velocity.y + tangent.y * contact.velocity; const position = localToWorld(support, contact.x, snowYAt(support, contact.x) - particle.radius); particle.x = position.x; particle.y = position.y; if (!surfaces.includes(support) || !support.visible || contact.x < 0 || contact.x > support.width) { this.contacts.delete(particle); this.sources.set(particle, support); particle.age = 0; metrics.slides++; metrics.slideMass += particle.mass; return 'airborne'; } const cap = Math.min(support.maxDepth, config.snow.accumulation.maxDepth); const bin = clamp(Math.floor(contact.x / support.binSize), 0, support.profile.length - 1); const localCapacity = cap * (support.packing?.[bin] ?? 1); if ((support.profile[bin] ?? 0) / support.binSize < localCapacity * 0.8 && particle.age > 0.1) { const accepted = deposit(support.profile, support.binSize, contact.x, particle.mass, cap, support.packing); if (accepted > 0) { particle.mass = Math.max(0, particle.mass - accepted); metrics.deposits++; metrics.transfers++; } if (particle.mass < 1e-4) { this.contacts.delete(particle); return 'deposited'; } particle.radius = clamp(Math.sqrt(particle.mass / Math.PI), 0.8, 18); } return 'attached'; } } //# sourceMappingURL=ParticleSystem.js.map