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(); }); } // --- ASCIIText, ported from React Bits (vanilla JS, three.js loaded on // demand). Replaces the flat "TIDE" watermark on the TideWM card with a // rippling, wave-distorted ASCII render once the card scrolls into view. // The plain-text watermark stays in the DOM as the fallback if WebGL or the // three.js CDN load ever fails. --- (() => { const targets = [ { host: document.querySelector("#tide-ascii"), ancestorSelector: ".current-project", readyClass: "has-tide-ascii", text: "TIDE", enableWaves: true, asciiFontSize: 11, textFontSize: 220, }, { // Flux is a media-editing tool, not a water compositor — keep its // watermark shape still and crisp (no mesh ripple), but let the // glyphs themselves flicker/decode for a "processing feed" feel. host: document.querySelector("#flux-ascii"), ancestorSelector: ".project-visual-flux", readyClass: "has-flux-ascii", text: "FLUX", enableWaves: false, charJitter: true, asciiFontSize: 8, textFontSize: 240, }, ] .map((t) => ({ ...t, ancestor: t.host?.closest(t.ancestorSelector) })) .filter((t) => t.host && t.ancestor); if (!targets.length || reducedMotion) return; let hasWebGL = false; try { const testCanvas = document.createElement("canvas"); hasWebGL = Boolean(testCanvas.getContext("webgl") || testCanvas.getContext("experimental-webgl")); } catch { hasWebGL = false; } if (!hasWebGL) return; const THREE_CDN_URL = "https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"; let threeLoadPromise = null; const loadThree = () => { if (window.THREE) return Promise.resolve(); if (!threeLoadPromise) { threeLoadPromise = new Promise((resolve, reject) => { const script = document.createElement("script"); script.src = THREE_CDN_URL; script.onload = () => resolve(); script.onerror = () => reject(new Error("three.js failed to load")); document.head.append(script); }); } return threeLoadPromise; }; const mapRange = (n, start, stop, start2, stop2) => ((n - start) / (stop - start)) * (stop2 - start2) + start2; const vertexShader = ` varying vec2 vUv; uniform float uTime; uniform float uEnableWaves; void main() { vUv = uv; float time = uTime * 5.0; float waveFactor = uEnableWaves; vec3 transformed = position; transformed.x += sin(time + position.y) * 0.5 * waveFactor; transformed.y += cos(time + position.z) * 0.15 * waveFactor; transformed.z += sin(time + position.x) * waveFactor; gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0); } `; const fragmentShader = ` varying vec2 vUv; uniform float uTime; uniform float uEnableWaves; uniform sampler2D uTexture; void main() { float time = uTime; vec2 pos = vUv; float amount = mix(0.006, 0.01, uEnableWaves); float r = texture2D(uTexture, pos + cos(time * 2.0 - time + pos.x) * amount).r; float g = texture2D(uTexture, pos + tan(time * 0.5 + pos.x - time) * amount).g; float b = texture2D(uTexture, pos - cos(time * 2.0 + time + pos.y) * amount).b; float a = texture2D(uTexture, pos).a; gl_FragColor = vec4(r, g, b, a); } `; class AsciiFilter { constructor(renderer, { fontSize, fontFamily, charset, charJitter } = {}) { this.renderer = renderer; this.domElement = document.createElement("div"); this.domElement.className = "tide-ascii-inner"; this.pre = document.createElement("pre"); this.pre.className = "tide-ascii-pre"; this.domElement.append(this.pre); this.canvas = document.createElement("canvas"); this.canvas.className = "tide-ascii-canvas"; this.context = this.canvas.getContext("2d"); this.domElement.append(this.canvas); this.fontSize = fontSize ?? 8; this.fontFamily = fontFamily ?? "'IBM Plex Mono', monospace"; this.charset = charset ?? " .'`^\",:;Il!i~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$"; // Optional per-cell character flicker — a subtle "decoding" glitch // where a handful of glyphs jump to a denser/lighter char and settle // back, independent of any mesh/wave motion. this.charJitter = charJitter ?? false; this.context.imageSmoothingEnabled = false; } setSize(width, height) { if (!width || !height) return; this.width = width; this.height = height; this.renderer.setSize(width, height); this.reset(); } reset() { this.context.font = `${this.fontSize}px ${this.fontFamily}`; const charWidth = this.context.measureText("A").width; this.cols = Math.max(1, Math.floor(this.width / (this.fontSize * (charWidth / this.fontSize)))); this.rows = Math.max(1, Math.floor(this.height / this.fontSize)); this.canvas.width = this.cols; this.canvas.height = this.rows; this.pre.style.fontFamily = this.fontFamily; this.pre.style.fontSize = `${this.fontSize}px`; } render(scene, camera, time) { this.renderer.render(scene, camera); const w = this.canvas.width; const h = this.canvas.height; this.context.clearRect(0, 0, w, h); if (this.context && w && h) this.context.drawImage(this.renderer.domElement, 0, 0, w, h); this.asciify(this.context, w, h, time ?? 0); } asciify(ctx, w, h, time = 0) { if (!w || !h) return; const imgData = ctx.getImageData(0, 0, w, h).data; const jitter = this.charJitter; const frameBucket = Math.floor(time * 7); const last = this.charset.length - 1; let str = ""; for (let y = 0; y < h; y += 1) { for (let x = 0; x < w; x += 1) { const i = x * 4 + y * 4 * w; const [r, g, b, a] = [imgData[i], imgData[i + 1], imgData[i + 2], imgData[i + 3]]; if (a === 0) { str += " "; continue; } const gray = (0.3 * r + 0.6 * g + 0.1 * b) / 255; let idx = this.charset.length - Math.floor((1 - gray) * last) - 1; if (jitter) { const seed = Math.sin(x * 12.9898 + y * 78.233 + frameBucket * 37.719) * 43758.5453; const rnd = seed - Math.floor(seed); if (rnd > 0.85) { const swing = Math.floor(((rnd - 0.85) / 0.15) * 8) - 4; idx = Math.min(last, Math.max(0, idx + swing)); } } str += this.charset[idx]; } str += "\n"; } this.pre.textContent = str; } dispose() {} } class CanvasTxt { constructor(txt, { fontSize = 200, fontFamily = "Arial", color = "#fdf9f3" } = {}) { this.canvas = document.createElement("canvas"); this.context = this.canvas.getContext("2d"); this.txt = txt; this.font = `600 ${fontSize}px ${fontFamily}`; this.color = color; } resize() { this.context.font = this.font; const metrics = this.context.measureText(this.txt); this.canvas.width = Math.ceil(metrics.width) + 20; this.canvas.height = Math.ceil((metrics.actualBoundingBoxAscent || 0) + (metrics.actualBoundingBoxDescent || 0)) + 20; } render() { this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); this.context.fillStyle = this.color; this.context.font = this.font; const metrics = this.context.measureText(this.txt); this.context.fillText(this.txt, 10, 10 + metrics.actualBoundingBoxAscent); } get width() { return this.canvas.width; } get height() { return this.canvas.height; } get texture() { return this.canvas; } } class CanvAscii { constructor({ text, asciiFontSize, textFontSize, textColor, planeBaseHeight, enableWaves, charJitter }, container, width, height) { this.textString = text; this.asciiFontSize = asciiFontSize; this.textFontSize = textFontSize; this.textColor = textColor; this.planeBaseHeight = planeBaseHeight; this.container = container; this.width = width; this.height = height; this.enableWaves = enableWaves; this.charJitter = charJitter; this.camera = new window.THREE.PerspectiveCamera(45, this.width / this.height, 1, 1000); this.camera.position.z = 30; this.scene = new window.THREE.Scene(); } async init() { try { await document.fonts.load(`600 200px "IBM Plex Mono"`); } catch { // fall back to default font metrics if the webfont isn't ready yet } this.setMesh(); this.setRenderer(); } setMesh() { const THREE = window.THREE; this.textCanvas = new CanvasTxt(this.textString, { fontSize: this.textFontSize, fontFamily: "IBM Plex Mono", color: this.textColor, }); this.textCanvas.resize(); this.textCanvas.render(); this.texture = new THREE.CanvasTexture(this.textCanvas.texture); this.texture.minFilter = THREE.NearestFilter; const textAspect = this.textCanvas.width / this.textCanvas.height; const planeW = this.planeBaseHeight * textAspect; this.geometry = new THREE.PlaneGeometry(planeW, this.planeBaseHeight, 36, 36); this.material = new THREE.ShaderMaterial({ vertexShader, fragmentShader, transparent: true, uniforms: { uTime: { value: 0 }, uTexture: { value: this.texture }, uEnableWaves: { value: this.enableWaves ? 1 : 0 }, }, }); this.mesh = new THREE.Mesh(this.geometry, this.material); this.scene.add(this.mesh); } setRenderer() { const THREE = window.THREE; this.renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true }); this.renderer.setPixelRatio(1); this.renderer.setClearColor(0x000000, 0); this.filter = new AsciiFilter(this.renderer, { fontSize: this.asciiFontSize, charJitter: this.charJitter }); this.container.append(this.filter.domElement); this.setSize(this.width, this.height); } setSize(w, h) { if (!w || !h) return; this.width = w; this.height = h; this.camera.aspect = w / h; this.camera.updateProjectionMatrix(); this.filter.setSize(w, h); } animate() { const frame = () => { this.animationFrameId = requestAnimationFrame(frame); this.renderFrame(); }; frame(); } renderFrame() { const time = Date.now() * 0.001; this.mesh.material.uniforms.uTime.value = Math.sin(time); this.filter.render(this.scene, this.camera, time); } dispose() { if (this.animationFrameId) cancelAnimationFrame(this.animationFrameId); this.filter?.dispose(); this.filter?.domElement?.remove(); this.renderer?.dispose(); } } const mount = async ({ host, ancestor, readyClass, text, enableWaves, charJitter, asciiFontSize, textFontSize }) => { try { await loadThree(); } catch { return; } if (!window.THREE) return; const rect = host.getBoundingClientRect(); if (rect.width < 20 || rect.height < 20) return; let instance; try { instance = new CanvAscii( { text, asciiFontSize: asciiFontSize ?? 11, textFontSize: textFontSize ?? 220, textColor: "#eaf9f7", planeBaseHeight: 8, enableWaves: enableWaves ?? true, charJitter: charJitter ?? false, }, host, rect.width, rect.height, ); await instance.init(); instance.animate(); } catch { instance?.dispose(); return; } host.classList.add("is-ready"); ancestor.classList.add(readyClass); if ("ResizeObserver" in window) { const resizeObserver = new ResizeObserver((entries) => { const entry = entries[0]; if (!entry) return; const { width, height } = entry.contentRect; if (width > 20 && height > 20) instance.setSize(width, height); }); resizeObserver.observe(host); } }; targets.forEach((target) => { const observer = new IntersectionObserver( (entries, obs) => { entries.forEach((entry) => { if (!entry.isIntersecting) return; obs.disconnect(); mount(target); }); }, { rootMargin: "200px" }, ); observer.observe(target.host); }); })(); // --- 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 // IBM Plex Mono, roughly 0.6x) — 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); }); }