Add lifetime to particles
All checks were successful
publish.yml / publish (push) Successful in 1m0s

This commit is contained in:
2026-04-08 09:46:07 +02:00
parent e06a5a29b4
commit f10916532e

View File

@@ -14,10 +14,24 @@ type Particle = {
vy: number; vy: number;
size: number; size: number;
alpha: number; alpha: number;
ageMs: number;
lifetimeMs: number;
respawnDelayMs: number;
}; };
class ParticleEngine { class ParticleEngine {
private readonly connectionDistanceSq = 19600;
private readonly densityRadiusSq = 4900;
private readonly densityFadeStart = 6;
private readonly densityFadeRange = 10;
private readonly maxDensityFade = 0.75;
private readonly minLifetimeMs = 9000;
private readonly maxLifetimeMs = 24000;
private readonly minRespawnDelayMs = 140;
private readonly maxRespawnDelayMs = 900;
private readonly particles: Particle[] = []; private readonly particles: Particle[] = [];
private readonly localDensity: number[] = [];
private readonly lifeAlpha: number[] = [];
private width = 0; private width = 0;
private height = 0; private height = 0;
private dpr = 1; private dpr = 1;
@@ -25,14 +39,19 @@ class ParticleEngine {
constructor(private readonly ctx: CanvasRenderingContext2D, particleCount: number) { constructor(private readonly ctx: CanvasRenderingContext2D, particleCount: number) {
for (let i = 0; i < particleCount; i += 1) { for (let i = 0; i < particleCount; i += 1) {
this.particles.push({ const particle: Particle = {
x: Math.random(), x: Math.random(),
y: Math.random(), y: Math.random(),
vx: (Math.random() - 0.5) * 0.18, vx: (Math.random() - 0.5) * 0.24,
vy: (Math.random() - 0.5) * 0.18, vy: (Math.random() - 0.5) * 0.24,
size: 1 + Math.random() * 2.2, size: 1.2 + Math.random() * 2.8,
alpha: 0.35 + Math.random() * 0.5, alpha: 0.5 + Math.random() * 0.45,
}); ageMs: 0,
lifetimeMs: this.randomLifetimeMs(),
respawnDelayMs: 0,
};
this.resetParticle(particle, false);
this.particles.push(particle);
} }
} }
@@ -47,8 +66,9 @@ class ParticleEngine {
} }
renderFrame(deltaMs: number): void { renderFrame(deltaMs: number): void {
const step = Math.min(deltaMs, 40) / 16.6667; const clampedDeltaMs = Math.min(deltaMs, 40);
this.update(step); const step = clampedDeltaMs / 16.6667;
this.update(step, clampedDeltaMs);
this.draw(); this.draw();
} }
@@ -56,15 +76,29 @@ class ParticleEngine {
this.draw(); this.draw();
} }
private update(step: number): void { private update(step: number, deltaMs: number): void {
if (this.width === 0 || this.height === 0) { if (this.width === 0 || this.height === 0) {
return; return;
} }
const influenceRadius = Math.min(this.width, this.height) * 0.22; const influenceRadius = Math.min(this.width, this.height) * 0.3;
const influenceRadiusSq = influenceRadius * influenceRadius; const influenceRadiusSq = influenceRadius * influenceRadius;
for (const particle of this.particles) { for (const particle of this.particles) {
if (particle.respawnDelayMs > 0) {
particle.respawnDelayMs = Math.max(0, particle.respawnDelayMs - deltaMs);
if (particle.respawnDelayMs === 0) {
this.resetParticle(particle, false);
}
continue;
}
particle.ageMs += deltaMs;
if (particle.ageMs >= particle.lifetimeMs) {
particle.respawnDelayMs = this.randomRespawnDelayMs();
continue;
}
const px = particle.x * this.width; const px = particle.x * this.width;
const py = particle.y * this.height; const py = particle.y * this.height;
@@ -76,16 +110,16 @@ class ParticleEngine {
if (distanceSq > 1 && distanceSq < influenceRadiusSq) { if (distanceSq > 1 && distanceSq < influenceRadiusSq) {
const distance = Math.sqrt(distanceSq); const distance = Math.sqrt(distanceSq);
const normalized = 1 - distance / influenceRadius; const normalized = 1 - distance / influenceRadius;
const direction = this.pointer.pressed ? -1 : 1; const directionBoost = this.pointer.pressed ? -2.2 : 1.35;
const force = normalized * 0.02 * direction; const force = normalized * 0.033 * directionBoost;
particle.vx += (dx / distance) * force * step; particle.vx += (dx / distance) * force * step;
particle.vy += (dy / distance) * force * step; particle.vy += (dy / distance) * force * step;
} }
} }
particle.vx *= 0.985; particle.vx *= 0.978;
particle.vy *= 0.985; particle.vy *= 0.978;
particle.x += (particle.vx * step) / this.width; particle.x += (particle.vx * step) / this.width;
particle.y += (particle.vy * step) / this.height; particle.y += (particle.vy * step) / this.height;
@@ -118,27 +152,41 @@ class ParticleEngine {
Math.max(this.width, this.height) Math.max(this.width, this.height)
); );
gradient.addColorStop(0, 'rgba(88, 166, 255, 0.16)'); gradient.addColorStop(0, 'rgba(88, 166, 255, 0.16)');
gradient.addColorStop(0.5, 'rgba(147, 102, 255, 0.08)'); gradient.addColorStop(0.4, 'rgba(88, 166, 255, 0.2)');
gradient.addColorStop(0.7, 'rgba(147, 102, 255, 0.12)');
gradient.addColorStop(1, 'rgba(13, 17, 23, 0)'); gradient.addColorStop(1, 'rgba(13, 17, 23, 0)');
this.ctx.fillStyle = gradient; this.ctx.fillStyle = gradient;
this.ctx.fillRect(0, 0, this.width, this.height); this.ctx.fillRect(0, 0, this.width, this.height);
for (const particle of this.particles) { if (this.localDensity.length !== this.particles.length) {
const x = particle.x * this.width; this.localDensity.length = this.particles.length;
const y = particle.y * this.height; }
this.ctx.beginPath(); if (this.lifeAlpha.length !== this.particles.length) {
this.ctx.arc(x, y, particle.size, 0, Math.PI * 2); this.lifeAlpha.length = this.particles.length;
this.ctx.fillStyle = `rgba(230, 237, 243, ${particle.alpha})`; }
this.ctx.fill(); this.localDensity.fill(0);
for (let i = 0; i < this.particles.length; i += 1) {
this.lifeAlpha[i] = this.getLifeAlpha(this.particles[i]);
} }
this.ctx.lineWidth = 1; this.ctx.lineWidth = 1;
for (let i = 0; i < this.particles.length; i += 1) { for (let i = 0; i < this.particles.length; i += 1) {
const a = this.particles[i]; const a = this.particles[i];
const lifeA = this.lifeAlpha[i];
if (lifeA <= 0.01) {
continue;
}
for (let j = i + 1; j < this.particles.length; j += 1) { for (let j = i + 1; j < this.particles.length; j += 1) {
const b = this.particles[j]; const b = this.particles[j];
const lifeB = this.lifeAlpha[j];
if (lifeB <= 0.01) {
continue;
}
const ax = a.x * this.width; const ax = a.x * this.width;
const ay = a.y * this.height; const ay = a.y * this.height;
const bx = b.x * this.width; const bx = b.x * this.width;
@@ -147,9 +195,14 @@ class ParticleEngine {
const dy = ay - by; const dy = ay - by;
const distSq = dx * dx + dy * dy; const distSq = dx * dx + dy * dy;
if (distSq < 10000) { if (distSq < this.densityRadiusSq) {
const alpha = 1 - distSq / 10000; this.localDensity[i] += 1;
this.ctx.strokeStyle = `rgba(88, 166, 255, ${alpha * 0.2})`; this.localDensity[j] += 1;
}
if (distSq < this.connectionDistanceSq) {
const alpha = 1 - distSq / this.connectionDistanceSq;
this.ctx.strokeStyle = `rgba(106, 194, 255, ${alpha * 0.38 * Math.min(lifeA, lifeB)})`;
this.ctx.beginPath(); this.ctx.beginPath();
this.ctx.moveTo(ax, ay); this.ctx.moveTo(ax, ay);
this.ctx.lineTo(bx, by); this.ctx.lineTo(bx, by);
@@ -157,6 +210,61 @@ class ParticleEngine {
} }
} }
} }
for (let i = 0; i < this.particles.length; i += 1) {
const particle = this.particles[i];
const x = particle.x * this.width;
const y = particle.y * this.height;
const crowding = Math.min(
Math.max((this.localDensity[i] - this.densityFadeStart) / this.densityFadeRange, 0),
1
);
const effectiveAlpha = Math.max(
particle.alpha * this.lifeAlpha[i] * (1 - crowding * this.maxDensityFade),
0.02
);
if (effectiveAlpha <= 0.021) {
continue;
}
this.ctx.beginPath();
this.ctx.arc(x, y, particle.size, 0, Math.PI * 2);
this.ctx.fillStyle = `rgba(222, 244, 255, ${effectiveAlpha})`;
this.ctx.fill();
}
}
private randomLifetimeMs(): number {
return this.minLifetimeMs + Math.random() * (this.maxLifetimeMs - this.minLifetimeMs);
}
private randomRespawnDelayMs(): number {
return this.minRespawnDelayMs + Math.random() * (this.maxRespawnDelayMs - this.minRespawnDelayMs);
}
private resetParticle(particle: Particle, withDelay: boolean): void {
particle.x = Math.random();
particle.y = Math.random();
particle.vx = (Math.random() - 0.5) * 0.24;
particle.vy = (Math.random() - 0.5) * 0.24;
particle.size = 1.2 + Math.random() * 2.8;
particle.alpha = 0.5 + Math.random() * 0.45;
particle.ageMs = 0;
particle.lifetimeMs = this.randomLifetimeMs();
particle.respawnDelayMs = withDelay ? this.randomRespawnDelayMs() : 0;
}
private getLifeAlpha(particle: Particle): number {
if (particle.respawnDelayMs > 0 || particle.lifetimeMs <= 0) {
return 0;
}
const progress = Math.min(Math.max(particle.ageMs / particle.lifetimeMs, 0), 1);
const fadeIn = Math.min(progress / 0.25, 1);
const fadeOut = Math.min((1 - progress) / 0.32, 1);
return Math.min(fadeIn, fadeOut, 1);
} }
} }
@@ -192,7 +300,9 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
this.reducedMotion = this.mediaQuery.matches; this.reducedMotion = this.mediaQuery.matches;
if (context) { if (context) {
const particleCount = this.reducedMotion ? 32 : 88; const area = window.innerWidth * window.innerHeight;
const dynamicCount = Math.round(Math.min(170, Math.max(80, area / 14500)));
const particleCount = this.reducedMotion ? 42 : dynamicCount;
this.particleEngine = new ParticleEngine(context, particleCount); this.particleEngine = new ParticleEngine(context, particleCount);
this.resizeCanvas(); this.resizeCanvas();
this.renderInitialFrame(); this.renderInitialFrame();