Files
home-frontend/src/scripts/animations/fluid-byte-field.js

244 lines
8.7 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 = 2000;
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;
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;
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();
let px, py;
if (e.touches && e.touches.length > 0) {
px = e.touches[0].clientX - rect.left;
py = e.touches[0].clientY - rect.top;
} else {
px = e.clientX - rect.left;
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 step() {
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;
if (d <= 0.02) {
ctx.shadowBlur = 0;
ctx.globalAlpha = baseAlpha;
ctx.fillStyle = 'rgba(255, 255, 255)';
ctx.fillText(hex, x * CELL_SIZE + half, y * CELL_SIZE + half);
} else {
const alpha = d * 1.5;
const activeAlpha = Math.min(alpha + baseAlpha, 1.0);
ctx.globalAlpha = activeAlpha;
ctx.fillStyle = '#ffffff';
ctx.shadowBlur = 0;
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('touchmove', onPointerMove, { passive: true });
window.addEventListener('touchend', onPointerLeave);
document.addEventListener('visibilitychange', onVisibilityChange);
resize();
rafId = requestAnimationFrame(frame);
}