forked from vyla-entertainment/home-frontend
314 lines
12 KiB
JavaScript
314 lines
12 KiB
JavaScript
import { prefersReducedMotion } from '../utils/dom.js';
|
|
import { smoothstep, diffuse, project, advect } from './fluid-solver.js';
|
|
|
|
const CELL_SIZE = 28;
|
|
const FONT_SIZE = 11;
|
|
const DT = 1 / 30;
|
|
const FADE_RATE = 0.6;
|
|
const MAX_ALPHA = 0.85;
|
|
const ADVECT_SCALE = 0.5;
|
|
const SPLASH_RADIUS_PX = 28;
|
|
const SPLASH_FORCE = 0.5;
|
|
const DIFFUSION_ITERATIONS = 2;
|
|
const PROJECT_ITERATIONS = 3;
|
|
const VELOCITY_DAMP = 0.1;
|
|
const DYE_DAMP = 0.1;
|
|
const TRAIL_LIFETIME_MS = 184;
|
|
const TRAIL_RADIUS_PX = 100;
|
|
const TRAIL_EASE = 2.5;
|
|
const TRAIL_STRENGTH = 3;
|
|
const TRAIL_FORCE_DECAY = 1.2;
|
|
const TRAIL_WOBBLE = 0.14;
|
|
const DRAG_THRESHOLD_PX = 12;
|
|
const MAX_TRAIL_POINTS = 24;
|
|
|
|
export function initFluidByteField(canvasId) {
|
|
const canvas = document.getElementById(canvasId);
|
|
if (!canvas || prefersReducedMotion()) return;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
const container = canvas.parentElement ?? canvas;
|
|
const video = container.querySelector('.hero-atmosphere-video');
|
|
if (video) {
|
|
video.crossOrigin = 'anonymous';
|
|
}
|
|
|
|
const procCanvas = document.createElement('canvas');
|
|
const procCtx = procCanvas.getContext('2d', { willReadFrequently: true });
|
|
|
|
let cols = 0;
|
|
let rows = 0;
|
|
let width = 0;
|
|
let height = 0;
|
|
let velX = new Float32Array(0);
|
|
let velY = new Float32Array(0);
|
|
let dye = new Float32Array(0);
|
|
let velX0 = new Float32Array(0);
|
|
let velY0 = new Float32Array(0);
|
|
let dye0 = new Float32Array(0);
|
|
let glyphs = new Uint8Array(0);
|
|
|
|
let lastPointerX = -9999;
|
|
let lastPointerY = -9999;
|
|
let lastPointerTime = 0;
|
|
let rafId = 0;
|
|
let lastFrameTime = 0;
|
|
let accumulator = 0;
|
|
let running = true;
|
|
|
|
const trails = [];
|
|
let dragStartX = 0;
|
|
let dragStartY = 0;
|
|
let dragging = false;
|
|
|
|
function resize() {
|
|
const rect = container.getBoundingClientRect();
|
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
width = Math.max(1, Math.round(rect.width));
|
|
height = Math.max(1, Math.round(rect.height));
|
|
cols = Math.max(3, Math.ceil(width / CELL_SIZE));
|
|
rows = Math.max(3, Math.ceil(height / CELL_SIZE));
|
|
canvas.width = Math.round(width * dpr);
|
|
canvas.height = Math.round(height * dpr);
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
|
|
procCanvas.width = cols;
|
|
procCanvas.height = rows;
|
|
|
|
const size = cols * rows;
|
|
velX = new Float32Array(size);
|
|
velY = new Float32Array(size);
|
|
dye = new Float32Array(size);
|
|
velX0 = new Float32Array(size);
|
|
velY0 = new Float32Array(size);
|
|
dye0 = new Float32Array(size);
|
|
glyphs = new Uint8Array(size);
|
|
for (let i = 0; i < size; i++) glyphs[i] = Math.floor(Math.random() * 256);
|
|
}
|
|
|
|
function splash(cellX, cellY, forceX, forceY, dyeAmount, radiusCells) {
|
|
const rSq = radiusCells * radiusCells;
|
|
const xMin = Math.max(1, Math.floor(cellX - radiusCells));
|
|
const xMax = Math.min(cols - 2, Math.ceil(cellX + radiusCells));
|
|
const yMin = Math.max(1, Math.floor(cellY - radiusCells));
|
|
const yMax = Math.min(rows - 2, Math.ceil(cellY + radiusCells));
|
|
for (let y = yMin; y <= yMax; y++) {
|
|
for (let x = xMin; x <= xMax; x++) {
|
|
const dx = x - cellX;
|
|
const dy = y - cellY;
|
|
const distSq = dx * dx + dy * dy;
|
|
if (distSq > rSq) continue;
|
|
const falloff = smoothstep(1 - Math.sqrt(distSq) / radiusCells);
|
|
const idx = x + y * cols;
|
|
velX[idx] += forceX * falloff;
|
|
velY[idx] += forceY * falloff;
|
|
dye[idx] = Math.min(2.0, dye[idx] + dyeAmount * falloff);
|
|
if (Math.random() < 0.04 * falloff) glyphs[idx] = Math.floor(Math.random() * 256);
|
|
}
|
|
}
|
|
}
|
|
|
|
function onPointerMove(e) {
|
|
const rect = container.getBoundingClientRect();
|
|
const px = e.clientX - rect.left;
|
|
const py = e.clientY - rect.top;
|
|
if (cols > 0 && lastPointerX > -9000 && px >= 0 && py >= 0 && px <= rect.width && py <= rect.height) {
|
|
const elapsed = Math.min(0.05, Math.max(0.001, (e.timeStamp - lastPointerTime) / 1000));
|
|
const fx = (px - lastPointerX) / CELL_SIZE / elapsed * ADVECT_SCALE;
|
|
const fy = (py - lastPointerY) / CELL_SIZE / elapsed * ADVECT_SCALE;
|
|
splash(px / CELL_SIZE, py / CELL_SIZE, fx, fy, SPLASH_FORCE, SPLASH_RADIUS_PX / CELL_SIZE);
|
|
}
|
|
lastPointerX = px;
|
|
lastPointerY = py;
|
|
lastPointerTime = e.timeStamp;
|
|
}
|
|
|
|
function onPointerLeave() {
|
|
lastPointerX = -9999;
|
|
lastPointerY = -9999;
|
|
}
|
|
|
|
function onPointerDown(e) {
|
|
const rect = container.getBoundingClientRect();
|
|
const px = e.clientX - rect.left;
|
|
const py = e.clientY - rect.top;
|
|
dragStartX = px;
|
|
dragStartY = py;
|
|
dragging = px >= 0 && py >= 0 && px <= rect.width && py <= rect.height;
|
|
}
|
|
|
|
function onPointerUp(e) {
|
|
if (!dragging) return;
|
|
dragging = false;
|
|
const rect = container.getBoundingClientRect();
|
|
const px = e.clientX - rect.left;
|
|
const py = e.clientY - rect.top;
|
|
if (Math.hypot(px - dragStartX, py - dragStartY) > DRAG_THRESHOLD_PX) return;
|
|
trails.push({ x: px, y: py, age: 0, seed: Math.random() * Math.PI * 2 });
|
|
if (trails.length > MAX_TRAIL_POINTS) trails.shift();
|
|
}
|
|
|
|
function applyTrails() {
|
|
if (!trails.length) return;
|
|
const lifetimeSec = TRAIL_LIFETIME_MS / 1000;
|
|
const cellsPerRadius = TRAIL_RADIUS_PX / CELL_SIZE;
|
|
for (let i = trails.length - 1; i >= 0; i--) {
|
|
const trail = trails[i];
|
|
trail.age += DT;
|
|
const progress = trail.age / lifetimeSec;
|
|
if (progress >= 1) {
|
|
trails.splice(i, 1);
|
|
continue;
|
|
}
|
|
const eased = 1 - Math.pow(1 - progress, TRAIL_EASE);
|
|
const ringRadius = eased * TRAIL_RADIUS_PX / CELL_SIZE;
|
|
const remaining = 1 - progress;
|
|
const intensity = 0.35 + 0.65 * eased;
|
|
const force = TRAIL_STRENGTH * Math.pow(remaining, TRAIL_FORCE_DECAY);
|
|
const dyeAmount = 0.2 * Math.pow(remaining, 2);
|
|
const cx = trail.x / CELL_SIZE;
|
|
const cy = trail.y / CELL_SIZE;
|
|
const seed = trail.seed;
|
|
const band = ringRadius * 1.3 + cellsPerRadius;
|
|
const xMin = Math.max(1, Math.floor(cx - band));
|
|
const xMax = Math.min(cols - 2, Math.ceil(cx + band));
|
|
const yMin = Math.max(1, Math.floor(cy - band));
|
|
const yMax = Math.min(rows - 2, Math.ceil(cy + band));
|
|
for (let y = yMin; y <= yMax; y++) {
|
|
for (let x = xMin; x <= xMax; x++) {
|
|
const dx = x - cx;
|
|
const dy = y - cy;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
const angle = Math.atan2(dy, dx);
|
|
const wobble = 0.5 * Math.sin(6 * angle + seed) + 0.25 * Math.sin(3 * angle + 0.05 * dist + 0.3 * seed) + 0.5;
|
|
const wobbleAmount = TRAIL_WOBBLE * (2 * wobble - 1) * intensity;
|
|
const targetRadius = ringRadius * Math.min(1.6, Math.max(0.6, 1 + wobbleAmount));
|
|
const ringDist = Math.abs(dist - targetRadius) / cellsPerRadius;
|
|
if (ringDist >= 1) continue;
|
|
const falloff = smoothstep(1 - ringDist);
|
|
const forceMul = Math.min(2.2, Math.max(0.4, 1 + wobbleAmount));
|
|
const invDist = dist > 0.001 ? 1 / dist : 0;
|
|
const idx = x + y * cols;
|
|
velX[idx] += dx * invDist * force * forceMul * falloff;
|
|
velY[idx] += dy * invDist * force * forceMul * falloff;
|
|
dye[idx] = Math.min(1.5, dye[idx] + dyeAmount * forceMul * falloff);
|
|
if (Math.random() < 0.03 * falloff) glyphs[idx] = Math.floor(Math.random() * 256);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function step() {
|
|
applyTrails();
|
|
const visc = 1.5 * DT;
|
|
velX0.set(velX);
|
|
diffuse(velX, velX0, visc, cols, rows, DT, DIFFUSION_ITERATIONS);
|
|
velY0.set(velY);
|
|
diffuse(velY, velY0, visc, cols, rows, DT, DIFFUSION_ITERATIONS);
|
|
project(velX, velY, velX0, velY0, cols, rows, PROJECT_ITERATIONS);
|
|
velX0.set(velX);
|
|
velY0.set(velY);
|
|
const velDamp = 1 / (1 + DT * VELOCITY_DAMP);
|
|
advect(velX, velX0, velX0, velY0, velDamp, cols, rows, DT);
|
|
advect(velY, velY0, velX0, velY0, velDamp, cols, rows, DT);
|
|
project(velX, velY, velX0, velY0, cols, rows, PROJECT_ITERATIONS);
|
|
dye0.set(dye);
|
|
diffuse(dye, dye0, visc * DT, cols, rows, DT, DIFFUSION_ITERATIONS);
|
|
dye0.set(dye);
|
|
advect(dye, dye0, velX, velY, 1 / (1 + DT * DYE_DAMP), cols, rows, DT);
|
|
|
|
const size = cols * rows;
|
|
for (let i = 0; i < size; i++) {
|
|
if (Math.random() < 0.0005) {
|
|
glyphs[i] = Math.floor(Math.random() * 256);
|
|
}
|
|
}
|
|
}
|
|
|
|
function render() {
|
|
ctx.clearRect(0, 0, width, height);
|
|
ctx.font = `700 ${FONT_SIZE}px ui-monospace, SFMono-Regular, Consolas, monospace`;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
|
|
let videoData = null;
|
|
if (video && video.readyState >= 2) {
|
|
try {
|
|
procCtx.drawImage(video, 0, 0, cols, rows);
|
|
videoData = procCtx.getImageData(0, 0, cols, rows).data;
|
|
} catch (e) { }
|
|
}
|
|
|
|
const half = CELL_SIZE / 2;
|
|
for (let y = 0; y < rows; y++) {
|
|
for (let x = 0; x < cols; x++) {
|
|
const idx = x + y * cols;
|
|
const d = dye[idx];
|
|
const hex = glyphs[idx].toString(16).toUpperCase().padStart(2, '0');
|
|
|
|
let videoLuminance = 0;
|
|
if (videoData) {
|
|
const r = videoData[idx * 4];
|
|
const g = videoData[idx * 4 + 1];
|
|
const b = videoData[idx * 4 + 2];
|
|
videoLuminance = (r * 0.299 + g * 0.587 + b * 0.114) / 255;
|
|
}
|
|
|
|
const baseAlpha = 0.04 + videoLuminance * 0.22;
|
|
if (d <= 0.02) {
|
|
ctx.shadowBlur = 0;
|
|
ctx.globalAlpha = baseAlpha;
|
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.45)';
|
|
ctx.fillText(hex, x * CELL_SIZE + half, y * CELL_SIZE + half);
|
|
} else {
|
|
const alpha = d * FADE_RATE;
|
|
const activeAlpha = Math.min(alpha + baseAlpha, MAX_ALPHA);
|
|
ctx.globalAlpha = activeAlpha;
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.shadowColor = '#ffffff';
|
|
ctx.fillText(hex, x * CELL_SIZE + half, y * CELL_SIZE + half);
|
|
}
|
|
}
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
ctx.shadowBlur = 0;
|
|
}
|
|
|
|
function frame(now) {
|
|
if (!running) return;
|
|
rafId = requestAnimationFrame(frame);
|
|
const delta = lastFrameTime ? (now - lastFrameTime) / 1000 : DT;
|
|
lastFrameTime = now;
|
|
accumulator += Math.min(delta, 0.1);
|
|
let steps = 0;
|
|
while (accumulator >= DT && steps < 3) {
|
|
step();
|
|
accumulator -= DT;
|
|
steps++;
|
|
}
|
|
if (steps === 0) return;
|
|
render();
|
|
}
|
|
|
|
function onVisibilityChange() {
|
|
running = !document.hidden;
|
|
if (running) {
|
|
lastFrameTime = 0;
|
|
accumulator = 0;
|
|
cancelAnimationFrame(rafId);
|
|
rafId = requestAnimationFrame(frame);
|
|
}
|
|
}
|
|
|
|
new ResizeObserver(resize).observe(container);
|
|
window.addEventListener('pointermove', onPointerMove, { passive: true });
|
|
window.addEventListener('pointerleave', onPointerLeave);
|
|
window.addEventListener('pointerdown', onPointerDown, { passive: true });
|
|
window.addEventListener('pointerup', onPointerUp, { passive: true });
|
|
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
|
|
resize();
|
|
rafId = requestAnimationFrame(frame);
|
|
}
|