Add lifetime to particles
All checks were successful
publish.yml / publish (push) Successful in 1m0s
All checks were successful
publish.yml / publish (push) Successful in 1m0s
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user