aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/packages/ui/src/lib/effects/SaturnEffect.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/ui/src/lib/effects/SaturnEffect.ts')
-rw-r--r--packages/ui/src/lib/effects/SaturnEffect.ts340
1 files changed, 340 insertions, 0 deletions
diff --git a/packages/ui/src/lib/effects/SaturnEffect.ts b/packages/ui/src/lib/effects/SaturnEffect.ts
new file mode 100644
index 0000000..357da9d
--- /dev/null
+++ b/packages/ui/src/lib/effects/SaturnEffect.ts
@@ -0,0 +1,340 @@
+// Optimized Saturn Effect for low-end hardware
+// Uses TypedArrays for memory efficiency and reduced particle density
+
+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
+
+ 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
+ })!;
+
+ // Initial resize will set up everything
+ this.resize(window.innerWidth, window.innerHeight);
+ this.initParticles();
+
+ this.animate = this.animate.bind(this);
+ this.animate();
+ }
+
+ // Public methods for external mouse event handling
+ // These can be called from any element that wants to control the Saturn rotation
+
+ handleMouseDown(clientX: number) {
+ this.isDragging = true;
+ this.lastMouseX = clientX;
+ this.lastMouseTime = performance.now();
+ this.mouseVelocities = [];
+ }
+
+ 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)
+ this.mouseVelocities.push(velocity);
+ if (this.mouseVelocities.length > 5) {
+ this.mouseVelocities.shift();
+ }
+
+ // Apply direct rotation while dragging
+ this.angle += deltaX * 0.002;
+ }
+
+ this.lastMouseX = clientX;
+ this.lastMouseTime = currentTime;
+ }
+
+ handleMouseUp() {
+ if (this.isDragging && this.mouseVelocities.length > 0) {
+ this.applyFlingVelocity();
+ }
+ this.isDragging = false;
+ }
+
+ handleTouchStart(clientX: number) {
+ this.isDragging = true;
+ this.lastMouseX = clientX;
+ this.lastMouseTime = performance.now();
+ this.mouseVelocities = [];
+ }
+
+ 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;
+ }
+
+ 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)
+ }
+
+ 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);
+
+ // Dynamic scaling based on screen size
+ const minDim = Math.min(width, height);
+ this.scaleFactor = 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)
+ 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++) {
+ 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++;
+ }
+
+ // 2. Rings
+ const ringInner = 1.4;
+ const ringOuter = 2.3;
+
+ for (let i = 0; i < ringCount; i++) {
+ const angle = Math.random() * Math.PI * 2;
+ const dist = Math.sqrt(
+ 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++;
+ }
+ }
+
+ animate() {
+ this.ctx.clearRect(0, 0, this.width, this.height);
+
+ // Normal blending
+ this.ctx.globalCompositeOperation = "source-over";
+
+ // Update rotation speed - decay towards base speed while maintaining direction
+ 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
+ if (this.currentSpeed - this.baseSpeed < 0.00001) {
+ this.currentSpeed = this.baseSpeed;
+ }
+ }
+
+ // Apply rotation with current speed and direction
+ this.angle += this.currentSpeed * this.rotationDirection;
+ }
+
+ const cx = this.width * 0.6;
+ const cy = this.height * 0.5;
+
+ // Pre-calculate rotation matrices
+ const rotationY = this.angle;
+ const rotationX = 0.4;
+ const rotationZ = 0.15;
+
+ const sinY = Math.sin(rotationY);
+ const cosY = Math.cos(rotationY);
+ const sinX = Math.sin(rotationX);
+ const cosX = Math.cos(rotationX);
+ const sinZ = Math.sin(rotationZ);
+ const cosZ = Math.cos(rotationZ);
+
+ const fov = 1500;
+ const scaleFactor = this.scaleFactor;
+
+ if (!this.xyz || !this.types) return;
+
+ 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
+ const px = x * scaleFactor;
+ const py = y * scaleFactor;
+ const pz = z * scaleFactor;
+
+ // 1. Rotate Y
+ 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;
+
+ const scale = fov / (fov + z3);
+
+ if (z3 > -fov) {
+ 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
+
+ // Optimization: Planet color vs Ring color
+ if (type === 0) {
+ // Planet: Warm White
+ this.ctx.fillStyle = `rgba(255, 240, 220, ${alpha})`;
+ } else {
+ // Ring: Cool White
+ 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.
+ this.ctx.fillRect(x2d, y2d, size, size);
+ }
+ }
+
+ this.animationId = requestAnimationFrame(this.animate);
+ }
+
+ destroy() {
+ cancelAnimationFrame(this.animationId);
+ }
+}