const root = document.documentElement; root.classList.remove("no-js"); root.classList.add("js"); const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const revealItems = document.querySelectorAll(".reveal"); if (reducedMotion || !("IntersectionObserver" in window)) { revealItems.forEach((item) => item.classList.add("is-visible")); } else { const revealObserver = new IntersectionObserver( (entries, observer) => { entries.forEach((entry) => { if (!entry.isIntersecting) return; entry.target.classList.add("is-visible"); observer.unobserve(entry.target); }); }, { rootMargin: "0px 0px -8%", threshold: 0.08 }, ); revealItems.forEach((item) => revealObserver.observe(item)); } const year = document.querySelector("#year"); if (year) year.textContent = new Date().getFullYear().toString(); const glitchName = document.querySelector("#glitch-name"); const finePointer = window.matchMedia("(hover: hover) and (pointer: fine)").matches; if (glitchName && finePointer && !reducedMotion) { const glyphField = document.createElement("div"); const glyphs = "01ABCDEFGHIJKLMNOPQRSTUVWXYZ{}[]<>/\\#$%&*+?=;:"; const colorClasses = ["", "is-cyan", "is-lime", "is-paper"]; let active = false; let intervalId; let lastSpawn = 0; let pointerX = window.innerWidth * 0.5; let pointerY = window.innerHeight * 0.5; glyphField.className = "glitch-field"; glyphField.setAttribute("aria-hidden", "true"); document.body.append(glyphField); const randomBetween = (min, max) => min + Math.random() * (max - min); const spawnGlyph = (origin) => { if (glyphField.childElementCount >= 64) glyphField.firstElementChild?.remove(); const glyph = document.createElement("span"); const nearPointer = Boolean(origin); const x = nearPointer ? origin.x + randomBetween(-44, 44) : randomBetween(20, window.innerWidth - 20); const y = nearPointer ? origin.y + randomBetween(-30, 30) : randomBetween(40, window.innerHeight - 30); const travel = nearPointer ? 150 : 90; glyph.className = `glitch-glyph ${colorClasses[Math.floor(Math.random() * colorClasses.length)]}`.trim(); glyph.textContent = glyphs[Math.floor(Math.random() * glyphs.length)]; glyph.style.setProperty("--x", `${x}px`); glyph.style.setProperty("--y", `${y}px`); glyph.style.setProperty("--dx", `${randomBetween(-travel, travel)}px`); glyph.style.setProperty("--dy", `${randomBetween(-travel, travel)}px`); glyph.style.setProperty("--rot", `${randomBetween(-80, 80)}deg`); glyph.style.setProperty("--size", `${randomBetween(0.65, 1.45)}rem`); glyph.style.setProperty("--life", `${randomBetween(650, 1350)}ms`); glyph.addEventListener("animationend", () => glyph.remove(), { once: true }); glyphField.append(glyph); }; const burst = (count) => { const rect = glitchName.getBoundingClientRect(); const origin = { x: rect.left + rect.width * 0.5, y: rect.top + rect.height * 0.55 }; for (let index = 0; index < count; index += 1) spawnGlyph(origin); }; const startGlitch = (event) => { if (active) return; active = true; pointerX = event.clientX; pointerY = event.clientY; document.body.classList.add("glitch-active"); burst(18); intervalId = window.setInterval(() => { if (!active) return; spawnGlyph({ x: pointerX, y: pointerY }); spawnGlyph(); }, 115); }; const moveGlitch = (event) => { pointerX = event.clientX; pointerY = event.clientY; const now = performance.now(); if (now - lastSpawn < 42) return; lastSpawn = now; spawnGlyph({ x: pointerX, y: pointerY }); }; const stopGlitch = () => { active = false; document.body.classList.remove("glitch-active"); window.clearInterval(intervalId); }; glitchName.addEventListener("pointerenter", startGlitch); glitchName.addEventListener("pointermove", moveGlitch); glitchName.addEventListener("pointerleave", stopGlitch); glitchName.addEventListener("pointercancel", stopGlitch); document.addEventListener("visibilitychange", () => { if (document.hidden) stopGlitch(); }); } const scrambleHeadings = document.querySelectorAll(".section-heading h2, #contact-title"); const scrambleGlyphs = "01ABCDEFGHIJKLMNOPQRSTUVWXYZ{}[]<>/\\#$%&*+?=;:"; if (scrambleHeadings.length && !reducedMotion && "IntersectionObserver" in window) { scrambleHeadings.forEach((heading) => { heading.dataset.finalText = heading.textContent ?? ""; }); const decryptHeading = (heading, duration = 420, steps = 10) => { const finalText = heading.dataset.finalText ?? ""; const totalChars = finalText.length; let frame = 0; const timer = window.setInterval(() => { frame += 1; const revealCount = Math.floor((frame / steps) * totalChars); heading.textContent = finalText .split("") .map((char, index) => { if (char === " " || index < revealCount) return char; return scrambleGlyphs[Math.floor(Math.random() * scrambleGlyphs.length)]; }) .join(""); if (frame >= steps) { heading.textContent = finalText; window.clearInterval(timer); } }, duration / steps); }; const scrambleObserver = new IntersectionObserver( (entries, observer) => { entries.forEach((entry) => { if (!entry.isIntersecting) return; decryptHeading(entry.target); observer.unobserve(entry.target); }); }, { rootMargin: "0px 0px -4%", threshold: 0.2 }, ); scrambleHeadings.forEach((heading) => scrambleObserver.observe(heading)); } const siteHeader = document.querySelector(".site-header"); const navToggle = document.querySelector(".nav-toggle"); const primaryNav = document.querySelector("#primary-navigation"); if (siteHeader && navToggle && primaryNav) { const mobileNavQuery = window.matchMedia("(max-width: 52rem)"); const setNavOpen = (open) => { siteHeader.classList.toggle("is-nav-open", open); navToggle.setAttribute("aria-expanded", String(open)); primaryNav.toggleAttribute("inert", mobileNavQuery.matches && !open); }; const syncNavMode = () => { siteHeader.classList.remove("is-nav-open"); navToggle.setAttribute("aria-expanded", "false"); primaryNav.toggleAttribute("inert", mobileNavQuery.matches); }; navToggle.addEventListener("click", () => { setNavOpen(!siteHeader.classList.contains("is-nav-open")); }); primaryNav.querySelectorAll("a").forEach((link) => { link.addEventListener("click", () => { if (mobileNavQuery.matches) setNavOpen(false); }); }); document.addEventListener("keydown", (event) => { if (event.key === "Escape" && siteHeader.classList.contains("is-nav-open")) { setNavOpen(false); navToggle.focus(); } }); mobileNavQuery.addEventListener("change", syncNavMode); syncNavMode(); } const gooeyNav = document.querySelector(".nav-gooey"); const gooeyLinks = document.querySelectorAll(".site-nav > a:not(.nav-pill)"); if (gooeyNav && gooeyLinks.length && !reducedMotion) { const navEl = gooeyNav.parentElement; const blob = gooeyNav.querySelector(".nav-gooey-blob--lead"); const pad = 10; let litLink = null; let jumpTimeout; const moveBlob = (target) => { const navRect = navEl.getBoundingClientRect(); const rect = target.getBoundingClientRect(); blob.style.left = `${rect.left - navRect.left - pad}px`; blob.style.top = `${rect.top - navRect.top - pad}px`; blob.style.width = `${rect.width + pad * 2}px`; blob.style.height = `${rect.height + pad * 2}px`; gooeyNav.classList.add("is-active", "is-jumping"); window.clearTimeout(jumpTimeout); jumpTimeout = window.setTimeout(() => gooeyNav.classList.remove("is-jumping"), 220); litLink?.classList.remove("is-lit"); litLink = target; litLink.classList.add("is-lit"); }; gooeyLinks.forEach((link) => { link.addEventListener("pointerenter", () => moveBlob(link)); link.addEventListener("focus", () => moveBlob(link)); }); const clear = () => { gooeyNav.classList.remove("is-active"); litLink?.classList.remove("is-lit"); litLink = null; }; navEl.addEventListener("pointerleave", clear); navEl.addEventListener("focusout", (event) => { if (!navEl.contains(event.relatedTarget)) clear(); }); } // --- Liquid ASCII: a real FLIP/PIC fluid simulation (grid + particles, // incompressible pressure solve, particle-particle separation) rendered as // ASCII. No React, no registry, no CDN. Ported by hand from // javierbyte/fluid-triangle (MIT), whose solver is itself ported from // Matthias Müller's "Ten Minute Physics" FLIP fluid tutorial (MIT) — see // license text below, preserved per the MIT terms of both sources. The // obstacle's particle collider was a triangle in that fork; it's a circle // here (matches the original Ten Minute Physics demo, and is what actually // runs — the fork's grid-level triangle carving never mutated the fluid). // // Copyright 2022 Matthias Müller - Ten Minute Physics, // www.youtube.com/c/TenMinutePhysics, www.matthiasMueller.info/tenMinutePhysics // Permission is hereby granted, free of charge, to any person obtaining a // copy of this software and associated documentation files (the // "Software"), to deal in the Software without restriction, including // without limitation the rights to use, copy, modify, merge, publish, // distribute, sublicense, and/or sell copies of the Software, and to // permit persons to whom the Software is furnished to do so, subject to // the following conditions: the above copyright notice and this permission // notice shall be included in all copies or substantial portions of the // Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. (() => { const host = document.querySelector("#hero-liquid"); const pre = host?.querySelector(".hero-liquid-pre"); const heroSection = host?.closest(".hero") || host; if (!host || !pre || reducedMotion) return; const FLUID_CELL = 0; const AIR_CELL = 1; const SOLID_CELL = 2; const clamp = (x, lo, hi) => (x < lo ? lo : x > hi ? hi : x); // Letter ramps spelling "FLUID" across brightness bands, cycling // diagonally across the grid — the fluid-triangle demo's signature // touch, kept as-is since it's a genuinely nice detail for a water sim. const BASE = [ ["~", 12198], [":", 6921], ["-", 5589], ["·", 3267], [" ", 0], [" ", 0], ]; const RENDER_CHARS = [ [["F", 26574], ["F", 26574], ["f", 17490], ...BASE], [["L", 21327], ["L", 21327], ["l", 14019], ...BASE], [["U", 32973], ["U", 32973], ["u", 24093], ...BASE], [["I", 14883], ["I", 14883], ["i", 13638], ...BASE], [["D", 36198], ["D", 36198], ["d", 30762], ...BASE], ].map((set) => set .slice() .sort((a, b) => a[1] - b[1]) .map(([char]) => char) .join(""), ); class FlipFluid { constructor(density, width, height, spacing, particleRadius, maxParticles) { this.density = density; this.fNumX = Math.floor(width / spacing); this.fNumY = Math.floor(height / spacing); this.h = Math.max(width / this.fNumX, height / this.fNumY); this.fInvSpacing = 1.0 / this.h; this.fNumCells = this.fNumX * this.fNumY; this.u = new Float32Array(this.fNumCells); this.v = new Float32Array(this.fNumCells); this.du = new Float32Array(this.fNumCells); this.dv = new Float32Array(this.fNumCells); this.prevU = new Float32Array(this.fNumCells); this.prevV = new Float32Array(this.fNumCells); this.p = new Float32Array(this.fNumCells); this.s = new Float32Array(this.fNumCells); this.cellType = new Int32Array(this.fNumCells); this.cellColor = new Float32Array(3 * this.fNumCells); this.maxParticles = maxParticles; this.particlePos = new Float32Array(2 * this.maxParticles); this.particleVel = new Float32Array(2 * this.maxParticles); this.particleDensity = new Float32Array(this.fNumCells); this.particleRestDensity = 0.0; this.particleRadius = particleRadius; this.pInvSpacing = 1.0 / (2.2 * particleRadius); this.pNumX = Math.floor(width * this.pInvSpacing) + 1; this.pNumY = Math.floor(height * this.pInvSpacing) + 1; this.pNumCells = this.pNumX * this.pNumY; this.numCellParticles = new Int32Array(this.pNumCells); this.firstCellParticle = new Int32Array(this.pNumCells + 1); this.cellParticleIds = new Int32Array(maxParticles); this.numParticles = 0; } integrateParticles(dt, gravity) { for (let i = 0; i < this.numParticles; i += 1) { this.particleVel[2 * i + 1] += dt * gravity; this.particlePos[2 * i] += this.particleVel[2 * i] * dt; this.particlePos[2 * i + 1] += this.particleVel[2 * i + 1] * dt; } } pushParticlesApart(numIters) { this.numCellParticles.fill(0); for (let i = 0; i < this.numParticles; i += 1) { const xi = clamp(Math.floor(this.particlePos[2 * i] * this.pInvSpacing), 0, this.pNumX - 1); const yi = clamp(Math.floor(this.particlePos[2 * i + 1] * this.pInvSpacing), 0, this.pNumY - 1); this.numCellParticles[xi * this.pNumY + yi] += 1; } let first = 0; for (let i = 0; i < this.pNumCells; i += 1) { first += this.numCellParticles[i]; this.firstCellParticle[i] = first; } this.firstCellParticle[this.pNumCells] = first; for (let i = 0; i < this.numParticles; i += 1) { const xi = clamp(Math.floor(this.particlePos[2 * i] * this.pInvSpacing), 0, this.pNumX - 1); const yi = clamp(Math.floor(this.particlePos[2 * i + 1] * this.pInvSpacing), 0, this.pNumY - 1); const cellNr = xi * this.pNumY + yi; this.firstCellParticle[cellNr] -= 1; this.cellParticleIds[this.firstCellParticle[cellNr]] = i; } const minDist = 2.0 * this.particleRadius; const minDist2 = minDist * minDist; for (let iter = 0; iter < numIters; iter += 1) { for (let i = 0; i < this.numParticles; i += 1) { const px = this.particlePos[2 * i]; const py = this.particlePos[2 * i + 1]; const pxi = Math.floor(px * this.pInvSpacing); const pyi = Math.floor(py * this.pInvSpacing); const x0 = Math.max(pxi - 1, 0); const y0 = Math.max(pyi - 1, 0); const x1 = Math.min(pxi + 1, this.pNumX - 1); const y1 = Math.min(pyi + 1, this.pNumY - 1); for (let xi = x0; xi <= x1; xi += 1) { for (let yi = y0; yi <= y1; yi += 1) { const cellNr = xi * this.pNumY + yi; const first = this.firstCellParticle[cellNr]; const last = this.firstCellParticle[cellNr + 1]; for (let j = first; j < last; j += 1) { const id = this.cellParticleIds[j]; if (id === i) continue; let dx = this.particlePos[2 * id] - px; let dy = this.particlePos[2 * id + 1] - py; const d2 = dx * dx + dy * dy; if (d2 > minDist2 || d2 === 0.0) continue; const d = Math.sqrt(d2); const s = (0.5 * (minDist - d)) / d; dx *= s; dy *= s; this.particlePos[2 * i] -= dx; this.particlePos[2 * i + 1] -= dy; this.particlePos[2 * id] += dx; this.particlePos[2 * id + 1] += dy; } } } } } } // Circular obstacle collider — the fork's triangle, deleted, restored // to a plain circle (and given the obstacle's own velocity on contact // for a real "push" instead of just clamping to zero). handleParticleCollisions(obstacleX, obstacleY, obstacleRadius, obstacleVelX, obstacleVelY) { const h = this.h; const r = this.particleRadius; const minDist = obstacleRadius + r; const minDist2 = minDist * minDist; const minX = h + r; const maxX = (this.fNumX - 1) * h - r; const minY = h + r; const maxY = (this.fNumY - 1) * h - r; for (let i = 0; i < this.numParticles; i += 1) { let x = this.particlePos[2 * i]; let y = this.particlePos[2 * i + 1]; if (obstacleRadius > 0) { const dx = x - obstacleX; const dy = y - obstacleY; const d2 = dx * dx + dy * dy; if (d2 < minDist2) { const d = Math.sqrt(d2) || 0.0001; x = obstacleX + (dx / d) * minDist; y = obstacleY + (dy / d) * minDist; this.particleVel[2 * i] = obstacleVelX; this.particleVel[2 * i + 1] = obstacleVelY; } } if (x < minX) { x = minX; this.particleVel[2 * i] = 0.0; } if (x > maxX) { x = maxX; this.particleVel[2 * i] = 0.0; } if (y < minY) { y = minY; this.particleVel[2 * i + 1] = 0.0; } if (y > maxY) { y = maxY; this.particleVel[2 * i + 1] = 0.0; } this.particlePos[2 * i] = x; this.particlePos[2 * i + 1] = y; } } updateParticleDensity() { const n = this.fNumY; const h = this.h; const h1 = this.fInvSpacing; const h2 = 0.5 * h; const d = this.particleDensity; d.fill(0.0); for (let i = 0; i < this.numParticles; i += 1) { let x = clamp(this.particlePos[2 * i], h, (this.fNumX - 1) * h); let y = clamp(this.particlePos[2 * i + 1], h, (this.fNumY - 1) * h); const x0 = Math.floor((x - h2) * h1); const tx = (x - h2 - x0 * h) * h1; const x1 = Math.min(x0 + 1, this.fNumX - 2); const y0 = Math.floor((y - h2) * h1); const ty = (y - h2 - y0 * h) * h1; const y1 = Math.min(y0 + 1, this.fNumY - 2); const sx = 1.0 - tx; const sy = 1.0 - ty; if (x0 < this.fNumX && y0 < this.fNumY) d[x0 * n + y0] += sx * sy; if (x1 < this.fNumX && y0 < this.fNumY) d[x1 * n + y0] += tx * sy; if (x1 < this.fNumX && y1 < this.fNumY) d[x1 * n + y1] += tx * ty; if (x0 < this.fNumX && y1 < this.fNumY) d[x0 * n + y1] += sx * ty; } if (this.particleRestDensity === 0.0) { let sum = 0.0; let numFluidCells = 0; for (let i = 0; i < this.fNumCells; i += 1) { if (this.cellType[i] === FLUID_CELL) { sum += d[i]; numFluidCells += 1; } } if (numFluidCells > 0) this.particleRestDensity = sum / numFluidCells; } } transferVelocities(toGrid, flipRatio) { const n = this.fNumY; const h = this.h; const h1 = this.fInvSpacing; const h2 = 0.5 * h; if (toGrid) { this.prevU.set(this.u); this.prevV.set(this.v); this.du.fill(0.0); this.dv.fill(0.0); this.u.fill(0.0); this.v.fill(0.0); for (let i = 0; i < this.fNumCells; i += 1) { this.cellType[i] = this.s[i] === 0.0 ? SOLID_CELL : AIR_CELL; } for (let i = 0; i < this.numParticles; i += 1) { const xi = clamp(Math.floor(this.particlePos[2 * i] * h1), 0, this.fNumX - 1); const yi = clamp(Math.floor(this.particlePos[2 * i + 1] * h1), 0, this.fNumY - 1); const cellNr = xi * n + yi; if (this.cellType[cellNr] === AIR_CELL) this.cellType[cellNr] = FLUID_CELL; } } for (let component = 0; component < 2; component += 1) { const dx = component === 0 ? 0.0 : h2; const dy = component === 0 ? h2 : 0.0; const f = component === 0 ? this.u : this.v; const prevF = component === 0 ? this.prevU : this.prevV; const d = component === 0 ? this.du : this.dv; for (let i = 0; i < this.numParticles; i += 1) { let x = clamp(this.particlePos[2 * i], h, (this.fNumX - 1) * h); let y = clamp(this.particlePos[2 * i + 1], h, (this.fNumY - 1) * h); const x0 = Math.min(Math.floor((x - dx) * h1), this.fNumX - 2); const tx = (x - dx - x0 * h) * h1; const x1 = Math.min(x0 + 1, this.fNumX - 2); const y0 = Math.min(Math.floor((y - dy) * h1), this.fNumY - 2); const ty = (y - dy - y0 * h) * h1; const y1 = Math.min(y0 + 1, this.fNumY - 2); const sx = 1.0 - tx; const sy = 1.0 - ty; const d0 = sx * sy; const d1 = tx * sy; const d2 = tx * ty; const d3 = sx * ty; const nr0 = x0 * n + y0; const nr1 = x1 * n + y0; const nr2 = x1 * n + y1; const nr3 = x0 * n + y1; if (toGrid) { const pv = this.particleVel[2 * i + component]; f[nr0] += pv * d0; d[nr0] += d0; f[nr1] += pv * d1; d[nr1] += d1; f[nr2] += pv * d2; d[nr2] += d2; f[nr3] += pv * d3; d[nr3] += d3; } else { const offset = component === 0 ? n : 1; const valid0 = this.cellType[nr0] !== AIR_CELL || this.cellType[nr0 - offset] !== AIR_CELL ? 1.0 : 0.0; const valid1 = this.cellType[nr1] !== AIR_CELL || this.cellType[nr1 - offset] !== AIR_CELL ? 1.0 : 0.0; const valid2 = this.cellType[nr2] !== AIR_CELL || this.cellType[nr2 - offset] !== AIR_CELL ? 1.0 : 0.0; const valid3 = this.cellType[nr3] !== AIR_CELL || this.cellType[nr3 - offset] !== AIR_CELL ? 1.0 : 0.0; const v = this.particleVel[2 * i + component]; const dSum = valid0 * d0 + valid1 * d1 + valid2 * d2 + valid3 * d3; if (dSum > 0.0) { const picV = (valid0 * d0 * f[nr0] + valid1 * d1 * f[nr1] + valid2 * d2 * f[nr2] + valid3 * d3 * f[nr3]) / dSum; const corr = (valid0 * d0 * (f[nr0] - prevF[nr0]) + valid1 * d1 * (f[nr1] - prevF[nr1]) + valid2 * d2 * (f[nr2] - prevF[nr2]) + valid3 * d3 * (f[nr3] - prevF[nr3])) / dSum; const flipV = v + corr; this.particleVel[2 * i + component] = (1.0 - flipRatio) * picV + flipRatio * flipV; } } } if (toGrid) { for (let i = 0; i < f.length; i += 1) { if (d[i] > 0.0) f[i] /= d[i]; } for (let i = 0; i < this.fNumX; i += 1) { for (let j = 0; j < this.fNumY; j += 1) { const solid = this.cellType[i * n + j] === SOLID_CELL; if (solid || (i > 0 && this.cellType[(i - 1) * n + j] === SOLID_CELL)) this.u[i * n + j] = this.prevU[i * n + j]; if (solid || (j > 0 && this.cellType[i * n + j - 1] === SOLID_CELL)) this.v[i * n + j] = this.prevV[i * n + j]; } } } } } solveIncompressibility(numIters, dt, overRelaxation, compensateDrift) { this.p.fill(0.0); this.prevU.set(this.u); this.prevV.set(this.v); const n = this.fNumY; const cp = (this.density * this.h) / dt; for (let iter = 0; iter < numIters; iter += 1) { for (let i = 1; i < this.fNumX - 1; i += 1) { for (let j = 1; j < this.fNumY - 1; j += 1) { if (this.cellType[i * n + j] !== FLUID_CELL) continue; const center = i * n + j; const left = (i - 1) * n + j; const right = (i + 1) * n + j; const bottom = i * n + j - 1; const top = i * n + j + 1; const sx0 = this.s[left]; const sx1 = this.s[right]; const sy0 = this.s[bottom]; const sy1 = this.s[top]; const s = sx0 + sx1 + sy0 + sy1; if (s === 0.0) continue; let div = this.u[right] - this.u[center] + this.v[top] - this.v[center]; if (this.particleRestDensity > 0.0 && compensateDrift) { const compression = this.particleDensity[center] - this.particleRestDensity; if (compression > 0.0) div -= compression; } let p = (-div / s) * overRelaxation; this.p[center] += cp * p; this.u[center] -= sx0 * p; this.u[right] += sx1 * p; this.v[center] -= sy0 * p; this.v[top] += sy1 * p; } } } } setSciColor(cellNr, val, minVal, maxVal) { val = Math.min(Math.max(val, minVal), maxVal - 0.0001); const d = maxVal - minVal; val = d === 0.0 ? 0.5 : (val - minVal) / d; const m = 0.25; const num = Math.floor(val / m); const s = (val - num * m) / m; const shade = num === 0 || num === 2 ? s : 1.0 - s; this.cellColor[3 * cellNr] = shade; this.cellColor[3 * cellNr + 1] = shade; this.cellColor[3 * cellNr + 2] = shade; } updateCellColors() { this.cellColor.fill(0.0); for (let i = 0; i < this.fNumCells; i += 1) { if (this.cellType[i] === FLUID_CELL) { let d = this.particleDensity[i]; if (this.particleRestDensity > 0.0) d /= this.particleRestDensity; this.setSciColor(i, d, 0.0, 2.0); } } } // Carves the circular obstacle into the pressure grid (s + solid-face // velocities) — this is the part the fork's numX/numY typo silently // disabled; done properly here so the fluid actually parts around it. // // Only touches a small box around the obstacle (this frame's + last // frame's), instead of every interior cell in the grid — cells far // from the obstacle are always s=1 and were being redundantly reset to // that same value every single frame. Same numeric result, far less // work: this was the single biggest avoidable per-frame cost. applyObstacle(obstacleX, obstacleY, obstacleRadius, obstacleVelX, obstacleVelY) { const n = this.fNumY; const h = this.h; const r2 = obstacleRadius * obstacleRadius; const rCells = Math.max(1, Math.ceil(obstacleRadius / h) + 1); const ci = clamp(Math.round(obstacleX / h), 1, this.fNumX - 2); const cj = clamp(Math.round(obstacleY / h), 1, this.fNumY - 2); const i0 = Math.max(1, ci - rCells); const i1 = Math.min(this.fNumX - 2, ci + rCells); const j0 = Math.max(1, cj - rCells); const j1 = Math.min(this.fNumY - 2, cj + rCells); const prev = this._obstacleBox; if (prev) { for (let i = prev.i0; i <= prev.i1; i += 1) { for (let j = prev.j0; j <= prev.j1; j += 1) { this.s[i * n + j] = 1.0; } } } for (let i = i0; i <= i1; i += 1) { for (let j = j0; j <= j1; j += 1) { const idx = i * n + j; if (obstacleRadius <= 0) { this.s[idx] = 1.0; continue; } const dx = (i + 0.5) * h - obstacleX; const dy = (j + 0.5) * h - obstacleY; if (dx * dx + dy * dy < r2) { this.s[idx] = 0.0; this.u[idx] = obstacleVelX; this.u[(i + 1) * n + j] = obstacleVelX; this.v[idx] = obstacleVelY; this.v[i * n + j + 1] = obstacleVelY; } else { this.s[idx] = 1.0; } } } if (this._obstacleBox) { this._obstacleBox.i0 = i0; this._obstacleBox.i1 = i1; this._obstacleBox.j0 = j0; this._obstacleBox.j1 = j1; } else { this._obstacleBox = { i0, i1, j0, j1 }; } } simulate(dt, gravity, flipRatio, numPressureIters, numParticleIters, overRelaxation, compensateDrift, separateParticles, obstacleX, obstacleY, obstacleRadius, obstacleVelX, obstacleVelY) { this.integrateParticles(dt, gravity); if (separateParticles) this.pushParticlesApart(numParticleIters); this.handleParticleCollisions(obstacleX, obstacleY, obstacleRadius, obstacleVelX, obstacleVelY); this.applyObstacle(obstacleX, obstacleY, obstacleRadius, obstacleVelX, obstacleVelY); this.transferVelocities(true); this.updateParticleDensity(); this.solveIncompressibility(numPressureIters, dt, overRelaxation, compensateDrift); this.transferVelocities(false, flipRatio); this.updateCellColors(); } } const CELL = 10; // px per ascii cell const CROP_X = 1; const CROP_Y = 1; const SIM_HEIGHT = 2.0; const DT_START = 1.0 / 60 / 16; // very slow the instant the obstacle appears/jumps — keeps the solve stable const DT_BASE = 1.0 / 60 / 3; // resting speed const DT_RELEASE = 1.0 / 60 / 1.25; // a bit livelier while settling after a drag const scene = { gravity: -9.81, dt: DT_BASE, flipRatio: 0.9, numPressureIters: 24, numParticleIters: 2, overRelaxation: 1.9, compensateDrift: true, separateParticles: true, obstacleX: 0, obstacleY: 0, obstacleRadius: 0, obstacleTargetRadius: 0, obstacleVelX: 0, obstacleVelY: 0, fluid: null, }; let fCols = 0; let fRows = 0; let maxRadius = 0.26; let rafId = null; let visible = false; let isDown = false; let renderTick = 0; // Monospace glyph *advance width* is narrower than the font-size (for // A typical monospace font is roughly 0.6x as wide as it is tall — assuming 1 character == CELL px wide // under-filled the row by the same margin, which is why the water // stopped a good way short of the right edge even though the box itself // was full width. Measure the real rendered glyph box instead of // guessing. const measureGlyph = () => { const probe = document.createElement("span"); probe.style.whiteSpace = "pre"; probe.style.position = "absolute"; probe.style.visibility = "hidden"; probe.textContent = "MMMMMMMMMM"; pre.appendChild(probe); const box = probe.getBoundingClientRect(); pre.removeChild(probe); return { w: box.width / 10, h: box.height || CELL }; }; const setupScene = () => { const rect = host.getBoundingClientRect(); pre.style.fontFamily = "var(--mono)"; pre.style.fontSize = `${CELL}px`; pre.style.lineHeight = "1em"; const glyph = measureGlyph(); fCols = clamp(Math.floor(rect.width / glyph.w), 40, 520); fRows = clamp(Math.floor(rect.height / glyph.h), 16, 160); maxRadius = rect.width > rect.height ? 0.26 : 0.34; // The physics grid uses square cells, but ASCII glyphs are much // narrower than they are tall — so the box's raw pixel aspect ratio // (rect.width/rect.height) is the WRONG ratio to build the sim on. On // a wide screen that under-sized the grid's width by roughly half, // which is exactly the "water stops partway across" line: the fluid // grid was square-cell-correct but never had enough columns to reach // the right edge of a wide, short box. Building simWidth off the // character-grid ratio (fCols/fRows) instead makes fNumX land on the // actual column count the box needs. const simWidth = SIM_HEIGHT * (fCols / fRows); const h = SIM_HEIGHT / fRows; const density = 1000.0; const relWaterHeight = 0.62; const relWaterWidth = 1.0; const r = 0.3 * h; const dx = 2.0 * r; const dy = (Math.sqrt(3.0) / 2.0) * dx; const numX = Math.max(1, Math.floor((relWaterWidth * simWidth - 2.0 * h - 2.0 * r) / dx)); const numY = Math.max(1, Math.floor((relWaterHeight * SIM_HEIGHT - 2.0 * h - 2.0 * r) / dy)); const maxParticles = numX * numY; const f = new FlipFluid(density, simWidth, SIM_HEIGHT, h, r, maxParticles); f.numParticles = numX * numY; const xOffset = (simWidth - numX * dx) / 2; const yOffset = (SIM_HEIGHT - numY * dy) * -0.5; let p = 0; for (let i = 0; i < numX; i += 1) { for (let j = 0; j < numY; j += 1) { f.particlePos[p] = h + r + dx * i + (j % 2 === 0 ? 0.0 : r) + xOffset; f.particlePos[p + 1] = h + r + dy * j + yOffset; p += 2; } } const n = f.fNumY; for (let i = 0; i < f.fNumX; i += 1) { for (let j = 0; j < f.fNumY; j += 1) { f.s[i * n + j] = i === 0 || i === f.fNumX - 1 || j === 0 ? 0.0 : 1.0; } } scene.fluid = f; scene.obstacleX = simWidth / 2; scene.obstacleY = SIM_HEIGHT * 0.55; scene.obstacleRadius = 0; scene.obstacleTargetRadius = 0; }; const simCoordsFromEvent = (event) => { const rect = host.getBoundingClientRect(); const point = event.touches ? event.touches[0] : event; const px = point.clientX - rect.left; const py = point.clientY - rect.top; const cScale = rect.height / SIM_HEIGHT; return { x: px / cScale, y: (rect.height - py) / cScale }; }; const onPointerDown = (event) => { isDown = true; scene.dt = DT_START; scene.obstacleTargetRadius = maxRadius; const p = simCoordsFromEvent(event); scene.obstacleX = p.x; scene.obstacleY = p.y; scene.obstacleVelX = 0; scene.obstacleVelY = 0; }; const onPointerMove = (event) => { if (!isDown) return; const p = simCoordsFromEvent(event); scene.obstacleVelX = (p.x - scene.obstacleX) / scene.dt; scene.obstacleVelY = (p.y - scene.obstacleY) / scene.dt; scene.obstacleX = p.x; scene.obstacleY = p.y; }; const onPointerUp = () => { if (!isDown) return; isDown = false; scene.dt = DT_RELEASE; scene.obstacleTargetRadius = 0; scene.obstacleVelX = 0; scene.obstacleVelY = 0; }; host.addEventListener("pointerdown", onPointerDown, { passive: true }); host.addEventListener("pointermove", onPointerMove, { passive: true }); window.addEventListener("pointerup", onPointerUp, { passive: true }); host.addEventListener("pointerleave", onPointerUp, { passive: true }); const render = () => { const f = scene.fluid; const dictLen = RENDER_CHARS[0].length; const n = f.fNumY; const rows = []; for (let i = f.fNumY - CROP_Y; i > CROP_Y; i -= 1) { const chars = new Array(f.fNumX - 2 * CROP_X); let k = 0; for (let j = CROP_X; j < f.fNumX - CROP_X; j += 1) { const dict = RENDER_CHARS[(i + j + 1) % RENDER_CHARS.length]; const shade = f.cellColor[3 * (j * n + i)]; chars[k] = dict[Math.min(dictLen - 1, Math.floor(shade * dictLen))]; k += 1; } rows.push(chars.join("")); } pre.textContent = rows.join("\n"); }; const frame = () => { rafId = null; if (!visible) return; scene.obstacleRadius = (scene.obstacleRadius * 3 + scene.obstacleTargetRadius) / 4; scene.fluid.simulate( scene.dt, scene.gravity, scene.flipRatio, scene.numPressureIters, scene.numParticleIters, scene.overRelaxation, scene.compensateDrift, scene.separateParticles, scene.obstacleX, scene.obstacleY, scene.obstacleRadius, scene.obstacleVelX, scene.obstacleVelY, ); renderTick += 1; if (renderTick % 2 === 0) render(); rafId = requestAnimationFrame(frame); }; setupScene(); render(); // The glyph measurement above can happen before the real @font-face has // loaded (using a fallback font's metrics instead) — redo it once the // real font is active so the column count settles at the right value. if (document.fonts && document.fonts.ready) { document.fonts.ready.then(() => { setupScene(); render(); }); } if ("ResizeObserver" in window) { let resizeTimer = null; const resizeObserver = new ResizeObserver(() => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { setupScene(); render(); }, 200); }); resizeObserver.observe(host); } // Only real "free" saving: don't run the sim at all while it's scrolled // out of view — costs nothing visually since it can't be seen anyway. if ("IntersectionObserver" in window) { const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { visible = entry.isIntersecting; if (visible && !rafId) rafId = requestAnimationFrame(frame); }); }, { threshold: 0.05 }, ); observer.observe(host); } else { visible = true; rafId = requestAnimationFrame(frame); } })(); const copyButton = document.querySelector("#copy-discord"); const copyLabel = document.querySelector("#discord-label"); const copyStatus = document.querySelector("#copy-status"); if (copyButton && copyLabel && copyStatus) { copyButton.addEventListener("click", async () => { try { await navigator.clipboard.writeText("fi3w0"); copyLabel.textContent = "copied: fi3w0"; copyStatus.textContent = "Discord username copied to clipboard."; } catch { copyLabel.textContent = "username: fi3w0"; copyStatus.textContent = "Clipboard access was blocked. The username is fi3w0."; } window.setTimeout(() => { copyLabel.textContent = "fi3w0 - click to copy"; copyStatus.textContent = ""; }, 2600); }); }