diff options
| author | 2026-02-25 01:32:51 +0800 | |
|---|---|---|
| committer | 2026-02-25 01:32:51 +0800 | |
| commit | 66668d85d603c5841d755a6023aa1925559fc6d4 (patch) | |
| tree | 485464148c76b0021efb55b7d2afd1c3004ceee0 /packages/ui/src/lib/effects | |
| parent | a6773bd092db654360c599ca6b0108ea0e456e8c (diff) | |
| download | DropOut-66668d85d603c5841d755a6023aa1925559fc6d4.tar.gz DropOut-66668d85d603c5841d755a6023aa1925559fc6d4.zip | |
chore(workspace): replace legacy codes
Diffstat (limited to 'packages/ui/src/lib/effects')
| -rw-r--r-- | packages/ui/src/lib/effects/ConstellationEffect.ts | 162 | ||||
| -rw-r--r-- | packages/ui/src/lib/effects/SaturnEffect.ts | 303 |
2 files changed, 131 insertions, 334 deletions
diff --git a/packages/ui/src/lib/effects/ConstellationEffect.ts b/packages/ui/src/lib/effects/ConstellationEffect.ts deleted file mode 100644 index d2db529..0000000 --- a/packages/ui/src/lib/effects/ConstellationEffect.ts +++ /dev/null @@ -1,162 +0,0 @@ -export class ConstellationEffect { - private canvas: HTMLCanvasElement; - private ctx: CanvasRenderingContext2D; - private width: number = 0; - private height: number = 0; - private particles: Particle[] = []; - private animationId: number = 0; - private mouseX: number = -1000; - private mouseY: number = -1000; - - // Configuration - private readonly particleCount = 100; - private readonly connectionDistance = 150; - private readonly particleSpeed = 0.5; - - constructor(canvas: HTMLCanvasElement) { - this.canvas = canvas; - this.ctx = canvas.getContext("2d", { alpha: true })!; - - // Bind methods - this.animate = this.animate.bind(this); - this.handleMouseMove = this.handleMouseMove.bind(this); - - // Initial setup - this.resize(window.innerWidth, window.innerHeight); - this.initParticles(); - - // Mouse interaction - window.addEventListener("mousemove", this.handleMouseMove); - - // Start animation - this.animate(); - } - - resize(width: number, height: number) { - const dpr = window.devicePixelRatio || 1; - this.width = width; - this.height = height; - - this.canvas.width = width * dpr; - this.canvas.height = height * dpr; - this.canvas.style.width = `${width}px`; - this.canvas.style.height = `${height}px`; - - this.ctx.scale(dpr, dpr); - - // Re-initialize if screen size changes significantly to maintain density - if (this.particles.length === 0) { - this.initParticles(); - } - } - - private initParticles() { - this.particles = []; - // Adjust density based on screen area - const area = this.width * this.height; - const density = Math.floor(area / 15000); // 1 particle per 15000px² - const count = Math.min(Math.max(density, 50), 200); // Clamp between 50 and 200 - - for (let i = 0; i < count; i++) { - this.particles.push(new Particle(this.width, this.height, this.particleSpeed)); - } - } - - private handleMouseMove(e: MouseEvent) { - const rect = this.canvas.getBoundingClientRect(); - this.mouseX = e.clientX - rect.left; - this.mouseY = e.clientY - rect.top; - } - - animate() { - this.ctx.clearRect(0, 0, this.width, this.height); - - // Update and draw particles - this.particles.forEach((p) => { - p.update(this.width, this.height); - p.draw(this.ctx); - }); - - // Draw lines - this.drawConnections(); - - this.animationId = requestAnimationFrame(this.animate); - } - - private drawConnections() { - this.ctx.lineWidth = 1; - - for (let i = 0; i < this.particles.length; i++) { - const p1 = this.particles[i]; - - // Connect to mouse if close - const distMouse = Math.hypot(p1.x - this.mouseX, p1.y - this.mouseY); - if (distMouse < this.connectionDistance + 50) { - const alpha = 1 - distMouse / (this.connectionDistance + 50); - this.ctx.strokeStyle = `rgba(255, 255, 255, ${alpha * 0.4})`; // Brighter near mouse - this.ctx.beginPath(); - this.ctx.moveTo(p1.x, p1.y); - this.ctx.lineTo(this.mouseX, this.mouseY); - this.ctx.stroke(); - - // Gently attract to mouse - if (distMouse > 10) { - p1.x += (this.mouseX - p1.x) * 0.005; - p1.y += (this.mouseY - p1.y) * 0.005; - } - } - - // Connect to other particles - for (let j = i + 1; j < this.particles.length; j++) { - const p2 = this.particles[j]; - const dist = Math.hypot(p1.x - p2.x, p1.y - p2.y); - - if (dist < this.connectionDistance) { - const alpha = 1 - dist / this.connectionDistance; - this.ctx.strokeStyle = `rgba(255, 255, 255, ${alpha * 0.15})`; - this.ctx.beginPath(); - this.ctx.moveTo(p1.x, p1.y); - this.ctx.lineTo(p2.x, p2.y); - this.ctx.stroke(); - } - } - } - } - - destroy() { - cancelAnimationFrame(this.animationId); - window.removeEventListener("mousemove", this.handleMouseMove); - } -} - -class Particle { - x: number; - y: number; - vx: number; - vy: number; - size: number; - - constructor(w: number, h: number, speed: number) { - this.x = Math.random() * w; - this.y = Math.random() * h; - this.vx = (Math.random() - 0.5) * speed; - this.vy = (Math.random() - 0.5) * speed; - this.size = Math.random() * 2 + 1; - } - - update(w: number, h: number) { - this.x += this.vx; - this.y += this.vy; - - // Bounce off walls - if (this.x < 0 || this.x > w) this.vx *= -1; - if (this.y < 0 || this.y > h) this.vy *= -1; - } - - draw(ctx: CanvasRenderingContext2D) { - ctx.fillStyle = "rgba(255, 255, 255, 0.4)"; - ctx.beginPath(); - ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); - ctx.fill(); - } -} diff --git a/packages/ui/src/lib/effects/SaturnEffect.ts b/packages/ui/src/lib/effects/SaturnEffect.ts index 357da9d..497a340 100644 --- a/packages/ui/src/lib/effects/SaturnEffect.ts +++ b/packages/ui/src/lib/effects/SaturnEffect.ts @@ -1,46 +1,61 @@ -// Optimized Saturn Effect for low-end hardware -// Uses TypedArrays for memory efficiency and reduced particle density +/** + * Ported SaturnEffect for the React UI (ui-new). + * Adapted from the original Svelte implementation but written as a standalone + * TypeScript class that manages a 2D canvas particle effect resembling a + * rotating "Saturn" with rings. Designed to be instantiated and controlled + * from a React component (e.g. ParticleBackground). + * + * Usage: + * const effect = new SaturnEffect(canvasElement); + * effect.handleMouseDown(clientX); + * effect.handleMouseMove(clientX); + * effect.handleMouseUp(); + * // on resize: + * effect.resize(width, height); + * // on unmount: + * effect.destroy(); + */ export class SaturnEffect { private canvas: HTMLCanvasElement; private ctx: CanvasRenderingContext2D; - private width: number = 0; - private height: number = 0; - - // Data-oriented design for performance - // xyz: Float32Array where [i*3, i*3+1, i*3+2] corresponds to x, y, z - private xyz: Float32Array | null = null; - // types: Uint8Array where 0 = planet, 1 = ring - private types: Uint8Array | null = null; - private count: number = 0; - - private animationId: number = 0; - private angle: number = 0; - private scaleFactor: number = 1; - - // Mouse interaction properties - private isDragging: boolean = false; - private lastMouseX: number = 0; - private lastMouseTime: number = 0; - private mouseVelocities: number[] = []; // Store recent velocities for averaging - - // Rotation speed control - private readonly baseSpeed: number = 0.005; // Original rotation speed - private currentSpeed: number = 0.005; // Current rotation speed (can be modified by mouse) - private rotationDirection: number = 1; // 1 for clockwise, -1 for counter-clockwise - private readonly speedDecayRate: number = 0.992; // How fast speed returns to normal (closer to 1 = slower decay) - private readonly minSpeedMultiplier: number = 1; // Minimum speed is baseSpeed - private readonly maxSpeedMultiplier: number = 50; // Maximum speed is 50x baseSpeed - private isStopped: boolean = false; // Whether the user has stopped the rotation + private width = 0; + private height = 0; + + // Particle storage + private xyz: Float32Array | null = null; // interleaved x,y,z + private types: Uint8Array | null = null; // 0 = planet, 1 = ring + private count = 0; + + // Animation + private animationId = 0; + private angle = 0; + private scaleFactor = 1; + + // Interaction + private isDragging = false; + private lastMouseX = 0; + private lastMouseTime = 0; + private mouseVelocities: number[] = []; + + // Speed control + private readonly baseSpeed = 0.005; + private currentSpeed = 0.005; + private rotationDirection = 1; + private readonly speedDecayRate = 0.992; + private readonly minSpeedMultiplier = 1; + private readonly maxSpeedMultiplier = 50; + private isStopped = false; constructor(canvas: HTMLCanvasElement) { this.canvas = canvas; - this.ctx = canvas.getContext("2d", { - alpha: true, - desynchronized: false, // default is usually fine, 'desynchronized' can help latency but might flicker - })!; + const ctx = canvas.getContext("2d", { alpha: true, desynchronized: false }); + if (!ctx) { + throw new Error("Failed to get 2D context for SaturnEffect"); + } + this.ctx = ctx; - // Initial resize will set up everything + // Initialize size & particles this.resize(window.innerWidth, window.innerHeight); this.initParticles(); @@ -48,9 +63,7 @@ export class SaturnEffect { this.animate(); } - // Public methods for external mouse event handling - // These can be called from any element that wants to control the Saturn rotation - + // External interaction handlers (accept clientX) handleMouseDown(clientX: number) { this.isDragging = true; this.lastMouseX = clientX; @@ -60,26 +73,18 @@ export class SaturnEffect { handleMouseMove(clientX: number) { if (!this.isDragging) return; - - const currentTime = performance.now(); - const deltaTime = currentTime - this.lastMouseTime; - - if (deltaTime > 0) { - const deltaX = clientX - this.lastMouseX; - const velocity = deltaX / deltaTime; // pixels per millisecond - - // Store recent velocities (keep last 5 for smoothing) + const now = performance.now(); + const dt = now - this.lastMouseTime; + if (dt > 0) { + const dx = clientX - this.lastMouseX; + const velocity = dx / dt; this.mouseVelocities.push(velocity); - if (this.mouseVelocities.length > 5) { - this.mouseVelocities.shift(); - } - - // Apply direct rotation while dragging - this.angle += deltaX * 0.002; + if (this.mouseVelocities.length > 5) this.mouseVelocities.shift(); + // Rotate directly while dragging for immediate feedback + this.angle += dx * 0.002; } - this.lastMouseX = clientX; - this.lastMouseTime = currentTime; + this.lastMouseTime = now; } handleMouseUp() { @@ -90,174 +95,130 @@ export class SaturnEffect { } handleTouchStart(clientX: number) { - this.isDragging = true; - this.lastMouseX = clientX; - this.lastMouseTime = performance.now(); - this.mouseVelocities = []; + this.handleMouseDown(clientX); } handleTouchMove(clientX: number) { - if (!this.isDragging) return; - - const currentTime = performance.now(); - const deltaTime = currentTime - this.lastMouseTime; - - if (deltaTime > 0) { - const deltaX = clientX - this.lastMouseX; - const velocity = deltaX / deltaTime; - - this.mouseVelocities.push(velocity); - if (this.mouseVelocities.length > 5) { - this.mouseVelocities.shift(); - } - - this.angle += deltaX * 0.002; - } - - this.lastMouseX = clientX; - this.lastMouseTime = currentTime; + this.handleMouseMove(clientX); } handleTouchEnd() { - if (this.isDragging && this.mouseVelocities.length > 0) { - this.applyFlingVelocity(); - } - this.isDragging = false; - } - - private applyFlingVelocity() { - // Calculate average velocity from recent samples - const avgVelocity = - this.mouseVelocities.reduce((a, b) => a + b, 0) / this.mouseVelocities.length; - - // Threshold for considering it a "fling" (pixels per millisecond) - const flingThreshold = 0.3; - // Threshold for considering the rotation as "stopped" by user - const stopThreshold = 0.1; - - if (Math.abs(avgVelocity) > flingThreshold) { - // User flung it - start rotating again - this.isStopped = false; - - // Determine new direction based on fling direction - const newDirection = avgVelocity > 0 ? 1 : -1; - - // If direction changed, update it permanently - if (newDirection !== this.rotationDirection) { - this.rotationDirection = newDirection; - } - - // Calculate speed boost based on fling strength - // Map velocity to speed multiplier (stronger fling = faster rotation) - const speedMultiplier = Math.min( - this.maxSpeedMultiplier, - this.minSpeedMultiplier + Math.abs(avgVelocity) * 10, - ); - - this.currentSpeed = this.baseSpeed * speedMultiplier; - } else if (Math.abs(avgVelocity) < stopThreshold) { - // User gently released - keep it stopped - this.isStopped = true; - this.currentSpeed = 0; - } - // If velocity is between stopThreshold and flingThreshold, - // keep current state (don't change isStopped) + this.handleMouseUp(); } + // Resize canvas & scale (call on window resize) resize(width: number, height: number) { const dpr = window.devicePixelRatio || 1; this.width = width; this.height = height; - this.canvas.width = width * dpr; - this.canvas.height = height * dpr; + // Update canvas pixel size and CSS size + this.canvas.width = Math.max(1, Math.floor(width * dpr)); + this.canvas.height = Math.max(1, Math.floor(height * dpr)); this.canvas.style.width = `${width}px`; this.canvas.style.height = `${height}px`; + // Reset transform and scale for devicePixelRatio + this.ctx.setTransform(1, 0, 0, 1, 0, 0); // reset this.ctx.scale(dpr, dpr); - // Dynamic scaling based on screen size const minDim = Math.min(width, height); - this.scaleFactor = minDim * 0.45; + this.scaleFactor = Math.max(1, minDim * 0.45); } - initParticles() { - // Significantly reduced particle count for CPU optimization - // Planet: 1800 -> 1000 - // Rings: 5000 -> 2500 - // Total approx 3500 vs 6800 previously (approx 50% reduction) + // Initialize particle arrays with reduced counts to keep performance reasonable + private initParticles() { + // Tuned particle counts for reasonable performance across platforms const planetCount = 1000; const ringCount = 2500; this.count = planetCount + ringCount; - // Use TypedArrays for better memory locality this.xyz = new Float32Array(this.count * 3); this.types = new Uint8Array(this.count); let idx = 0; - // 1. Planet - for (let i = 0; i < planetCount; i++) { + // Planet points + for (let i = 0; i < planetCount; i++, idx++) { const theta = Math.random() * Math.PI * 2; const phi = Math.acos(Math.random() * 2 - 1); const r = 1.0; - // x, y, z this.xyz[idx * 3] = r * Math.sin(phi) * Math.cos(theta); this.xyz[idx * 3 + 1] = r * Math.sin(phi) * Math.sin(theta); this.xyz[idx * 3 + 2] = r * Math.cos(phi); - this.types[idx] = 0; // 0 for planet - idx++; + this.types[idx] = 0; } - // 2. Rings + // Ring points const ringInner = 1.4; const ringOuter = 2.3; - - for (let i = 0; i < ringCount; i++) { + for (let i = 0; i < ringCount; i++, idx++) { const angle = Math.random() * Math.PI * 2; const dist = Math.sqrt( - Math.random() * (ringOuter * ringOuter - ringInner * ringInner) + ringInner * ringInner, + Math.random() * (ringOuter * ringOuter - ringInner * ringInner) + + ringInner * ringInner, ); - // x, y, z this.xyz[idx * 3] = dist * Math.cos(angle); this.xyz[idx * 3 + 1] = (Math.random() - 0.5) * 0.05; this.xyz[idx * 3 + 2] = dist * Math.sin(angle); - this.types[idx] = 1; // 1 for ring - idx++; + this.types[idx] = 1; } } - animate() { + // Map fling/velocity samples to a rotation speed and direction + private applyFlingVelocity() { + if (this.mouseVelocities.length === 0) return; + const avg = + this.mouseVelocities.reduce((a, b) => a + b, 0) / + this.mouseVelocities.length; + const flingThreshold = 0.3; + const stopThreshold = 0.1; + + if (Math.abs(avg) > flingThreshold) { + this.isStopped = false; + const newDir = avg > 0 ? 1 : -1; + if (newDir !== this.rotationDirection) this.rotationDirection = newDir; + const multiplier = Math.min( + this.maxSpeedMultiplier, + this.minSpeedMultiplier + Math.abs(avg) * 10, + ); + this.currentSpeed = this.baseSpeed * multiplier; + } else if (Math.abs(avg) < stopThreshold) { + this.isStopped = true; + this.currentSpeed = 0; + } + } + + // Main render loop + private animate() { + // Clear with full alpha to allow layering over background this.ctx.clearRect(0, 0, this.width, this.height); - // Normal blending + // Standard composition this.ctx.globalCompositeOperation = "source-over"; - // Update rotation speed - decay towards base speed while maintaining direction + // Update rotation speed (decay) if (!this.isDragging && !this.isStopped) { if (this.currentSpeed > this.baseSpeed) { - // Gradually decay speed back to base speed this.currentSpeed = - this.baseSpeed + (this.currentSpeed - this.baseSpeed) * this.speedDecayRate; - - // Snap to base speed when close enough + this.baseSpeed + + (this.currentSpeed - this.baseSpeed) * this.speedDecayRate; if (this.currentSpeed - this.baseSpeed < 0.00001) { this.currentSpeed = this.baseSpeed; } } - - // Apply rotation with current speed and direction this.angle += this.currentSpeed * this.rotationDirection; } + // Center positions const cx = this.width * 0.6; const cy = this.height * 0.5; - // Pre-calculate rotation matrices + // Pre-calc rotations const rotationY = this.angle; const rotationX = 0.4; const rotationZ = 0.15; @@ -272,29 +233,27 @@ export class SaturnEffect { const fov = 1500; const scaleFactor = this.scaleFactor; - if (!this.xyz || !this.types) return; + if (!this.xyz || !this.types) { + this.animationId = requestAnimationFrame(this.animate); + return; + } + // Loop particles for (let i = 0; i < this.count; i++) { const x = this.xyz[i * 3]; const y = this.xyz[i * 3 + 1]; const z = this.xyz[i * 3 + 2]; - // Apply Scale + // Scale to screen const px = x * scaleFactor; const py = y * scaleFactor; const pz = z * scaleFactor; - // 1. Rotate Y + // Rotate Y then X then Z const x1 = px * cosY - pz * sinY; const z1 = pz * cosY + px * sinY; - // y1 = py - - // 2. Rotate X const y2 = py * cosX - z1 * sinX; const z2 = z1 * cosX + py * sinX; - // x2 = x1 - - // 3. Rotate Z const x3 = x1 * cosZ - y2 * sinZ; const y3 = y2 * cosZ + x1 * sinZ; const z3 = z2; @@ -305,28 +264,23 @@ export class SaturnEffect { const x2d = cx + x3 * scale; const y2d = cy + y3 * scale; - // Size calculation - slightly larger dots to compensate for lower count - // Previously Planet 2.0 -> 2.4, Ring 1.3 -> 1.5 const type = this.types[i]; const sizeBase = type === 0 ? 2.4 : 1.5; const size = sizeBase * scale; - // Opacity let alpha = scale * scale * scale; if (alpha > 1) alpha = 1; - if (alpha < 0.15) continue; // Skip very faint particles for performance + if (alpha < 0.15) continue; - // Optimization: Planet color vs Ring color if (type === 0) { - // Planet: Warm White + // Planet: warm-ish this.ctx.fillStyle = `rgba(255, 240, 220, ${alpha})`; } else { - // Ring: Cool White + // Ring: cool-ish this.ctx.fillStyle = `rgba(220, 240, 255, ${alpha})`; } - // Render as squares (fillRect) instead of circles (arc) - // This is significantly faster for software rendering and reduces GPU usage. + // Render as small rectangles (faster than arc) this.ctx.fillRect(x2d, y2d, size, size); } } @@ -334,7 +288,12 @@ export class SaturnEffect { this.animationId = requestAnimationFrame(this.animate); } + // Stop animations and release resources destroy() { - cancelAnimationFrame(this.animationId); + if (this.animationId) { + cancelAnimationFrame(this.animationId); + this.animationId = 0; + } + // Intentionally do not null out arrays to allow reuse if desired. } } |