23rd
uiascii-fluid-sveltesource: stale · stale
ASCII Fluid
Mouse-trail WebGL fluid background quantized to a clean ASCII brightness ramp - zero deps, theme-aware.
npx shadcn add "https://23rd.dev/r/ascii-fluid-svelte.json"free
This source snapshot is older than our freshness window and couldn't be re-verified just now — it may be outdated.
Visual style
minimal
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Files1 · source cached 10/6/2026, 4:38:17 AM
registry/ascii-fluid/ascii-fluid.svelte
<script module lang="ts">
export type AsciiFluidTheme = "light" | "dark" | "auto"
export type AsciiFluidOptions = {
/**
* Brightness ramp (sparse → dense). Default is a full letter/symbol map.
*/
charset?: string
/** Glyph cell size in CSS pixels. Default `12` */
cellSize?: number
/** Ink color (hex). Default follows theme. */
color?: string
/** Stage color (hex). Default follows theme. */
backgroundColor?: string
/** Mouse trail / fluid force. Default `1` */
force?: number
/** How quickly trails fade (higher = vanish sooner). Default `0.05` */
dissipation?: number
/** Trail brush size 0–1. Default `0.55` */
brush?: number
/** Soft ambient swirl when idle. Default `true` */
animate?: boolean
/** Follow pointer. Default `true` */
interactive?: boolean
/**
* Palette mode. Default `auto` follows shadcn / next-themes
* (`html.dark` class).
*/
theme?: AsciiFluidTheme
}
export type AsciiFluidInstance = {
setOptions: (options: Partial<AsciiFluidOptions>) => void
destroy: () => void
}
/** Sparse → dense brightness ramp (letters + symbols). */
export const DEFAULT_CHARSET =
" .'`^\",:;Il!i><~+_-?][}{1)(|\\/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$"
const LIGHT = { ink: "#18181b", paper: "#fafafa" }
const DARK = { ink: "#e4e4e7", paper: "#09090b" }
const VERT = `
attribute vec2 a_position;
varying vec2 v_uv;
void main() {
v_uv = a_position * 0.5 + 0.5;
gl_Position = vec4(a_position, 0.0, 1.0);
}
`
// Velocity / dye stored biased in RGBA8: enc(v) = v * 0.05 + 0.5
const FRAG_SPLAT = `
precision highp float;
varying vec2 v_uv;
uniform sampler2D u_target;
uniform vec2 u_point;
uniform vec3 u_color;
uniform float u_radius;
uniform float u_aspect;
uniform float u_velocityField;
vec2 dec(vec2 e) { return (e - 0.5) / 0.05; }
vec2 enc(vec2 v) { return clamp(v * 0.05 + 0.5, 0.0, 1.0); }
void main() {
vec2 p = v_uv - u_point;
p.x *= u_aspect;
float d = exp(-dot(p, p) / max(u_radius, 0.0001));
vec3 base = texture2D(u_target, v_uv).xyz;
if (u_velocityField > 0.5) {
vec2 next = dec(base.xy) + u_color.xy * d;
gl_FragColor = vec4(enc(next), 0.5, 1.0);
} else {
gl_FragColor = vec4(base + u_color * d, 1.0);
}
}
`
const FRAG_ADVECT = `
precision highp float;
varying vec2 v_uv;
uniform sampler2D u_velocity;
uniform sampler2D u_source;
uniform vec2 u_texel;
uniform float u_dt;
uniform float u_dissipation;
uniform float u_velocityField;
vec2 dec(vec2 e) { return (e - 0.5) / 0.05; }
vec2 enc(vec2 v) { return clamp(v * 0.05 + 0.5, 0.0, 1.0); }
void main() {
vec2 vel = dec(texture2D(u_velocity, v_uv).xy);
vec2 coord = v_uv - u_dt * vel * u_texel * 110.0;
vec4 src = texture2D(u_source, clamp(coord, 0.0, 1.0));
if (u_velocityField > 0.5) {
vec2 next = dec(src.xy) * u_dissipation;
gl_FragColor = vec4(enc(next), 0.5, 1.0);
} else {
gl_FragColor = vec4(src.xyz * u_dissipation, 1.0);
}
}
`
const FRAG_DIVERGENCE = `
precision highp float;
varying vec2 v_uv;
uniform sampler2D u_velocity;
uniform vec2 u_texel;
vec2 dec(vec2 e) { return (e - 0.5) / 0.05; }
void main() {
float L = dec(texture2D(u_velocity, v_uv - vec2(u_texel.x, 0.0)).xy).x;
float R = dec(texture2D(u_velocity, v_uv + vec2(u_texel.x, 0.0)).xy).x;
float B = dec(texture2D(u_velocity, v_uv - vec2(0.0, u_texel.y)).xy).y;
float T = dec(texture2D(u_velocity, v_uv + vec2(0.0, u_texel.y)).xy).y;
float div = 0.5 * ((R - L) + (T - B));
gl_FragColor = vec4(div * 0.05 + 0.5, 0.0, 0.0, 1.0);
}
`
const FRAG_PRESSURE = `
precision highp float;
varying vec2 v_uv;
uniform sampler2D u_pressure;
uniform sampler2D u_divergence;
uniform vec2 u_texel;
float dec1(float e) { return (e - 0.5) / 0.05; }
float enc1(float v) { return clamp(v * 0.05 + 0.5, 0.0, 1.0); }
void main() {
float L = dec1(texture2D(u_pressure, v_uv - vec2(u_texel.x, 0.0)).x);
float R = dec1(texture2D(u_pressure, v_uv + vec2(u_texel.x, 0.0)).x);
float B = dec1(texture2D(u_pressure, v_uv - vec2(0.0, u_texel.y)).x);
float T = dec1(texture2D(u_pressure, v_uv + vec2(0.0, u_texel.y)).x);
float C = dec1(texture2D(u_divergence, v_uv).x);
float p = (L + R + B + T - C) * 0.25;
gl_FragColor = vec4(enc1(p), 0.0, 0.0, 1.0);
}
`
const FRAG_GRADIENT = `
precision highp float;
varying vec2 v_uv;
uniform sampler2D u_pressure;
uniform sampler2D u_velocity;
uniform vec2 u_texel;
float dec1(float e) { return (e - 0.5) / 0.05; }
vec2 dec(vec2 e) { return (e - 0.5) / 0.05; }
vec2 enc(vec2 v) { return clamp(v * 0.05 + 0.5, 0.0, 1.0); }
void main() {
float L = dec1(texture2D(u_pressure, v_uv - vec2(u_texel.x, 0.0)).x);
float R = dec1(texture2D(u_pressure, v_uv + vec2(u_texel.x, 0.0)).x);
float B = dec1(texture2D(u_pressure, v_uv - vec2(0.0, u_texel.y)).x);
float T = dec1(texture2D(u_pressure, v_uv + vec2(0.0, u_texel.y)).x);
vec2 vel = dec(texture2D(u_velocity, v_uv).xy);
vel -= vec2(R - L, T - B) * 0.5;
gl_FragColor = vec4(enc(vel), 0.5, 1.0);
}
`
const FRAG_DISPLAY = `
precision highp float;
varying vec2 v_uv;
uniform sampler2D u_dye;
uniform sampler2D u_atlas;
uniform vec2 u_resolution;
uniform vec2 u_cell;
uniform float u_charCount;
uniform vec3 u_ink;
uniform vec3 u_paper;
uniform float u_time;
uniform float u_animate;
float hash21(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
void main() {
vec2 pixel = v_uv * u_resolution;
vec2 cell = floor(pixel / u_cell);
vec2 cellUv = (cell + 0.5) * u_cell / u_resolution;
float dens = clamp(texture2D(u_dye, cellUv).x, 0.0, 1.0);
// Soft neighborhood sample for a low-res glow halo under the glyphs
vec2 texel = u_cell / u_resolution;
float glow =
dens * 0.40 +
texture2D(u_dye, cellUv + vec2( texel.x, 0.0)).x * 0.15 +
texture2D(u_dye, cellUv - vec2( texel.x, 0.0)).x * 0.15 +
texture2D(u_dye, cellUv + vec2(0.0, texel.y)).x * 0.15 +
texture2D(u_dye, cellUv - vec2(0.0, texel.y)).x * 0.15;
glow = clamp(glow, 0.0, 1.0);
glow = pow(glow, 1.35);
float lit = dens;
if (u_animate > 0.5) {
float flicker = hash21(cell + floor(u_time * 10.0)) - 0.5;
lit = clamp(lit + flicker * 0.05, 0.0, 1.0);
}
float idx = min(floor(lit * (u_charCount - 0.001)), u_charCount - 1.0);
vec2 local = fract(pixel / u_cell);
float u0 = (idx + local.x) / u_charCount;
float glyph = texture2D(u_atlas, vec2(u0, local.y)).r;
float alpha = glyph * smoothstep(0.02, 0.12, dens);
// Paper → soft ink wash → sharp ASCII on top
float wash = glow * 0.22;
vec3 col = mix(u_paper, u_ink, wash);
col = mix(col, u_ink, clamp(alpha, 0.0, 1.0));
gl_FragColor = vec4(col, 1.0);
}
`
function isDev() {
return (
typeof process !== "undefined" && process.env?.NODE_ENV !== "production"
)
}
function hexToRgb(hex: string): [number, number, number] {
const h = hex.replace("#", "").trim()
const full =
h.length === 3
? h
.split("")
.map((c) => c + c)
.join("")
: h.padEnd(6, "0").slice(0, 6)
const n = Number.parseInt(full, 16)
if (Number.isNaN(n)) return [0.1, 0.1, 0.12]
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]
}
function isDarkTheme(): boolean {
if (typeof document === "undefined") return false
const root = document.documentElement
if (root.classList.contains("dark")) return true
if (root.classList.contains("light")) return false
const dataTheme = root.getAttribute("data-theme")
if (dataTheme === "dark") return true
if (dataTheme === "light") return false
return window.matchMedia("(prefers-color-scheme: dark)").matches
}
function resolveDark(theme: AsciiFluidTheme): boolean {
if (theme === "dark") return true
if (theme === "light") return false
return isDarkTheme()
}
function compile(gl: WebGLRenderingContext, type: number, source: string) {
const shader = gl.createShader(type)
if (!shader) return null
gl.shaderSource(shader, source)
gl.compileShader(shader)
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
if (isDev()) {
console.warn(
"AsciiFluid: shader failed to compile\n",
gl.getShaderInfoLog(shader)
)
}
gl.deleteShader(shader)
return null
}
return shader
}
function createProgram(
gl: WebGLRenderingContext,
vs: WebGLShader,
fragSource: string
) {
const fs = compile(gl, gl.FRAGMENT_SHADER, fragSource)
if (!fs) return null
const program = gl.createProgram()
if (!program) {
gl.deleteShader(fs)
return null
}
gl.attachShader(program, vs)
gl.attachShader(program, fs)
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
if (isDev()) {
console.warn(
"AsciiFluid: program failed to link\n",
gl.getProgramInfoLog(program)
)
}
gl.deleteProgram(program)
gl.deleteShader(fs)
return null
}
return { program, fs }
}
type FBO = {
tex: WebGLTexture
fbo: WebGLFramebuffer
w: number
h: number
}
function createFBO(
gl: WebGLRenderingContext,
w: number,
h: number,
filter: number
): FBO | null {
const tex = gl.createTexture()
const fbo = gl.createFramebuffer()
if (!tex || !fbo) return null
gl.bindTexture(gl.TEXTURE_2D, tex)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
w,
h,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
null
)
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo)
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
tex,
0
)
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
return { tex, fbo, w, h }
}
function createDoubleFBO(
gl: WebGLRenderingContext,
w: number,
h: number,
filter: number
) {
const a = createFBO(gl, w, h, filter)
const b = createFBO(gl, w, h, filter)
if (!a || !b) return null
return {
read: a,
write: b,
swap() {
const t = this.read
this.read = this.write
this.write = t
},
}
}
function buildAtlas(
gl: WebGLRenderingContext,
charset: string
): { tex: WebGLTexture; count: number } | null {
const count = Math.max(charset.length, 1)
const size = 64
const atlasCanvas = document.createElement("canvas")
atlasCanvas.width = size * count
atlasCanvas.height = size
const ctx = atlasCanvas.getContext("2d")
if (!ctx) return null
ctx.fillStyle = "#000"
ctx.fillRect(0, 0, atlasCanvas.width, atlasCanvas.height)
ctx.fillStyle = "#fff"
ctx.font = `700 ${Math.floor(size * 0.72)}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`
ctx.textAlign = "center"
ctx.textBaseline = "middle"
for (let i = 0; i < count; i++) {
const ch = charset[i] ?? " "
if (ch === " ") continue
ctx.fillText(ch, size * (i + 0.5), size * 0.55)
}
const tex = gl.createTexture()
if (!tex) return null
gl.bindTexture(gl.TEXTURE_2D, tex)
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
gl.RGBA,
gl.UNSIGNED_BYTE,
atlasCanvas
)
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0)
return { tex, count }
}
/**
* ASCII fluid background — pointer trails leave ink that swirls and
* quantizes to a clean brightness-mapped glyph field. Zero deps.
*/
export function createAsciiFluid(
canvas: HTMLCanvasElement,
initial: AsciiFluidOptions = {}
): AsciiFluidInstance | null {
let options: Required<
Pick<
AsciiFluidOptions,
| "charset"
| "cellSize"
| "force"
| "dissipation"
| "brush"
| "animate"
| "interactive"
| "theme"
>
> &
AsciiFluidOptions = {
charset: DEFAULT_CHARSET,
cellSize: 12,
force: 1,
dissipation: 0.05,
brush: 0.55,
animate: true,
interactive: true,
theme: "auto",
...initial,
}
const mouse = {
x: 0.5,
y: 0.5,
dx: 0,
dy: 0,
moved: false,
inside: false,
}
let reduce = false
let currentCharset = ""
const gl = canvas.getContext("webgl", {
alpha: false,
antialias: false,
depth: false,
stencil: false,
powerPreference: "high-performance",
preserveDrawingBuffer: false,
})
if (!gl) return null
const vs = compile(gl, gl.VERTEX_SHADER, VERT)
if (!vs) return null
const splat = createProgram(gl, vs, FRAG_SPLAT)
const advect = createProgram(gl, vs, FRAG_ADVECT)
const divergence = createProgram(gl, vs, FRAG_DIVERGENCE)
const pressure = createProgram(gl, vs, FRAG_PRESSURE)
const gradient = createProgram(gl, vs, FRAG_GRADIENT)
const display = createProgram(gl, vs, FRAG_DISPLAY)
if (!splat || !advect || !divergence || !pressure || !gradient || !display) {
return null
}
const buf = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buf)
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
gl.STATIC_DRAW
)
const bindQuad = (program: WebGLProgram) => {
gl.useProgram(program)
const loc = gl.getAttribLocation(program, "a_position")
gl.enableVertexAttribArray(loc)
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)
}
const SIM = 180
const velocity = createDoubleFBO(gl, SIM, SIM, gl.LINEAR)
const dye = createDoubleFBO(gl, SIM, SIM, gl.LINEAR)
const pressureFbo = createDoubleFBO(gl, SIM, SIM, gl.NEAREST)
const divergenceFbo = createFBO(gl, SIM, SIM, gl.NEAREST)
if (!velocity || !dye || !pressureFbo || !divergenceFbo) return null
const initialAtlas = buildAtlas(gl, options.charset)
if (!initialAtlas) return null
let atlas = initialAtlas
currentCharset = options.charset
const blit = (target: FBO | null) => {
if (target) {
gl.bindFramebuffer(gl.FRAMEBUFFER, target.fbo)
gl.viewport(0, 0, target.w, target.h)
} else {
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
gl.viewport(0, 0, canvas.width, canvas.height)
}
gl.drawArrays(gl.TRIANGLES, 0, 6)
}
const clearFbo = (fbo: FBO, r = 0, g = 0, b = 0) => {
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo.fbo)
gl.viewport(0, 0, fbo.w, fbo.h)
gl.clearColor(r, g, b, 1)
gl.clear(gl.COLOR_BUFFER_BIT)
}
clearFbo(velocity.read, 0.5, 0.5, 0.5)
clearFbo(velocity.write, 0.5, 0.5, 0.5)
clearFbo(dye.read)
clearFbo(dye.write)
let raf = 0
let running = true
let last = performance.now()
const start = last
const resize = () => {
const parent = canvas.parentElement
if (!parent) return
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const w = parent.clientWidth
const h = parent.clientHeight
if (w <= 0 || h <= 0) return
canvas.width = Math.max(1, Math.floor(w * dpr))
canvas.height = Math.max(1, Math.floor(h * dpr))
canvas.style.width = `${w}px`
canvas.style.height = `${h}px`
}
resize()
const ro = new ResizeObserver(resize)
if (canvas.parentElement) ro.observe(canvas.parentElement)
const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)")
const onReduce = () => {
reduce = mqReduce.matches
}
onReduce()
mqReduce.addEventListener("change", onReduce)
const onPointer = (e: PointerEvent) => {
if (!options.interactive) return
const parent = canvas.parentElement
if (!parent) return
const rect = parent.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) return
const x = (e.clientX - rect.left) / rect.width
const y = 1 - (e.clientY - rect.top) / rect.height
mouse.inside = x >= 0 && x <= 1 && y >= 0 && y <= 1
mouse.dx = x - mouse.x
mouse.dy = y - mouse.y
mouse.x = x
mouse.y = y
mouse.moved = true
}
const onLeave = () => {
mouse.inside = false
}
window.addEventListener("pointermove", onPointer, { passive: true })
const parentEl = canvas.parentElement
parentEl?.addEventListener("pointerleave", onLeave, { passive: true })
const tick = (now: number) => {
if (!running) return
const dt = Math.min((now - last) / 1000, 0.033)
last = now
const time = (now - start) / 1000
const p = options
if (p.charset !== currentCharset) {
const next = buildAtlas(gl, p.charset)
if (next) {
gl.deleteTexture(atlas.tex)
atlas = next
currentCharset = p.charset
}
}
const dark = resolveDark(p.theme)
const ink = hexToRgb(p.color ?? (dark ? DARK.ink : LIGHT.ink))
const paper = hexToRgb(
p.backgroundColor ?? (dark ? DARK.paper : LIGHT.paper)
)
const texel = [1 / SIM, 1 / SIM] as const
const aspect = canvas.width / Math.max(canvas.height, 1)
const brushR = 0.00012 + Math.max(0.05, Math.min(1, p.brush)) * 0.0011
if (p.interactive && mouse.moved && mouse.inside && !reduce) {
const speed = Math.hypot(mouse.dx, mouse.dy)
const strength = p.force * (18 + speed * 120)
// Velocity trail
bindQuad(splat.program)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)
gl.uniform1i(gl.getUniformLocation(splat.program, "u_target"), 0)
gl.uniform2f(
gl.getUniformLocation(splat.program, "u_point"),
mouse.x,
mouse.y
)
gl.uniform3f(
gl.getUniformLocation(splat.program, "u_color"),
mouse.dx * strength,
mouse.dy * strength,
0
)
gl.uniform1f(gl.getUniformLocation(splat.program, "u_radius"), brushR)
gl.uniform1f(gl.getUniformLocation(splat.program, "u_aspect"), aspect)
gl.uniform1f(gl.getUniformLocation(splat.program, "u_velocityField"), 1)
blit(velocity.write)
velocity.swap()
// Dye trail — denser with speed so fast moves write brighter glyphs
const dyeAmt = Math.min(1.4, 0.45 + speed * 8) * p.force
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, dye.read.tex)
gl.uniform1i(gl.getUniformLocation(splat.program, "u_target"), 0)
gl.uniform2f(
gl.getUniformLocation(splat.program, "u_point"),
mouse.x,
mouse.y
)
gl.uniform3f(
gl.getUniformLocation(splat.program, "u_color"),
dyeAmt,
0,
0
)
gl.uniform1f(
gl.getUniformLocation(splat.program, "u_radius"),
brushR * 1.15
)
gl.uniform1f(gl.getUniformLocation(splat.program, "u_aspect"), aspect)
gl.uniform1f(gl.getUniformLocation(splat.program, "u_velocityField"), 0)
blit(dye.write)
dye.swap()
mouse.moved = false
mouse.dx = 0
mouse.dy = 0
}
// Soft ambient swirl so the field never fully dies when idle
if (p.animate && !reduce && !mouse.inside) {
const ax = Math.sin(time * 0.55) * 0.22
const ay = Math.cos(time * 0.42) * 0.22
bindQuad(splat.program)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)
gl.uniform1i(gl.getUniformLocation(splat.program, "u_target"), 0)
gl.uniform2f(
gl.getUniformLocation(splat.program, "u_point"),
0.5 + Math.sin(time * 0.23) * 0.22,
0.5 + Math.cos(time * 0.19) * 0.18
)
gl.uniform3f(gl.getUniformLocation(splat.program, "u_color"), ax, ay, 0)
gl.uniform1f(gl.getUniformLocation(splat.program, "u_radius"), 0.0018)
gl.uniform1f(gl.getUniformLocation(splat.program, "u_aspect"), aspect)
gl.uniform1f(gl.getUniformLocation(splat.program, "u_velocityField"), 1)
blit(velocity.write)
velocity.swap()
}
if (!reduce) {
bindQuad(advect.program)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)
gl.uniform1i(gl.getUniformLocation(advect.program, "u_velocity"), 0)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)
gl.uniform1i(gl.getUniformLocation(advect.program, "u_source"), 1)
gl.uniform2f(
gl.getUniformLocation(advect.program, "u_texel"),
texel[0],
texel[1]
)
gl.uniform1f(gl.getUniformLocation(advect.program, "u_dt"), dt)
gl.uniform1f(
gl.getUniformLocation(advect.program, "u_dissipation"),
1 - Math.min(0.18, p.dissipation * 2.5)
)
gl.uniform1f(gl.getUniformLocation(advect.program, "u_velocityField"), 1)
blit(velocity.write)
velocity.swap()
bindQuad(divergence.program)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)
gl.uniform1i(gl.getUniformLocation(divergence.program, "u_velocity"), 0)
gl.uniform2f(
gl.getUniformLocation(divergence.program, "u_texel"),
texel[0],
texel[1]
)
blit(divergenceFbo)
clearFbo(pressureFbo.read, 0.5, 0.5, 0.5)
clearFbo(pressureFbo.write, 0.5, 0.5, 0.5)
bindQuad(pressure.program)
for (let i = 0; i < 14; i++) {
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, pressureFbo.read.tex)
gl.uniform1i(gl.getUniformLocation(pressure.program, "u_pressure"), 0)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, divergenceFbo.tex)
gl.uniform1i(gl.getUniformLocation(pressure.program, "u_divergence"), 1)
gl.uniform2f(
gl.getUniformLocation(pressure.program, "u_texel"),
texel[0],
texel[1]
)
blit(pressureFbo.write)
pressureFbo.swap()
}
bindQuad(gradient.program)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, pressureFbo.read.tex)
gl.uniform1i(gl.getUniformLocation(gradient.program, "u_pressure"), 0)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)
gl.uniform1i(gl.getUniformLocation(gradient.program, "u_velocity"), 1)
gl.uniform2f(
gl.getUniformLocation(gradient.program, "u_texel"),
texel[0],
texel[1]
)
blit(velocity.write)
velocity.swap()
bindQuad(advect.program)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)
gl.uniform1i(gl.getUniformLocation(advect.program, "u_velocity"), 0)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, dye.read.tex)
gl.uniform1i(gl.getUniformLocation(advect.program, "u_source"), 1)
gl.uniform2f(
gl.getUniformLocation(advect.program, "u_texel"),
texel[0],
texel[1]
)
gl.uniform1f(gl.getUniformLocation(advect.program, "u_dt"), dt)
gl.uniform1f(
gl.getUniformLocation(advect.program, "u_dissipation"),
1 - Math.min(0.22, Math.max(0.02, p.dissipation))
)
gl.uniform1f(gl.getUniformLocation(advect.program, "u_velocityField"), 0)
blit(dye.write)
dye.swap()
}
bindQuad(display.program)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, dye.read.tex)
gl.uniform1i(gl.getUniformLocation(display.program, "u_dye"), 0)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, atlas.tex)
gl.uniform1i(gl.getUniformLocation(display.program, "u_atlas"), 1)
gl.uniform2f(
gl.getUniformLocation(display.program, "u_resolution"),
canvas.width,
canvas.height
)
const cell =
Math.max(7, p.cellSize) * Math.min(window.devicePixelRatio || 1, 2)
gl.uniform2f(gl.getUniformLocation(display.program, "u_cell"), cell, cell)
gl.uniform1f(
gl.getUniformLocation(display.program, "u_charCount"),
atlas.count
)
gl.uniform3f(
gl.getUniformLocation(display.program, "u_ink"),
ink[0],
ink[1],
ink[2]
)
gl.uniform3f(
gl.getUniformLocation(display.program, "u_paper"),
paper[0],
paper[1],
paper[2]
)
gl.uniform1f(gl.getUniformLocation(display.program, "u_time"), time)
gl.uniform1f(
gl.getUniformLocation(display.program, "u_animate"),
p.animate && !reduce ? 1 : 0
)
blit(null)
raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return {
setOptions(next) {
options = { ...options, ...next }
},
destroy() {
running = false
cancelAnimationFrame(raf)
ro.disconnect()
mqReduce.removeEventListener("change", onReduce)
window.removeEventListener("pointermove", onPointer)
parentEl?.removeEventListener("pointerleave", onLeave)
gl.deleteProgram(splat.program)
gl.deleteProgram(advect.program)
gl.deleteProgram(divergence.program)
gl.deleteProgram(pressure.program)
gl.deleteProgram(gradient.program)
gl.deleteProgram(display.program)
gl.deleteShader(vs)
gl.deleteShader(splat.fs)
gl.deleteShader(advect.fs)
gl.deleteShader(divergence.fs)
gl.deleteShader(pressure.fs)
gl.deleteShader(gradient.fs)
gl.deleteShader(display.fs)
gl.deleteBuffer(buf)
gl.deleteTexture(atlas.tex)
for (const f of [
velocity.read,
velocity.write,
dye.read,
dye.write,
pressureFbo.read,
pressureFbo.write,
divergenceFbo,
]) {
gl.deleteTexture(f.tex)
gl.deleteFramebuffer(f.fbo)
}
},
}
}
</script>
<script lang="ts">
import { onMount } from "svelte"
function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(" ")
}
interface Props extends AsciiFluidOptions {
class?: string
}
let {
class: className = "",
charset = DEFAULT_CHARSET,
cellSize = 12,
color,
backgroundColor,
force = 1,
dissipation = 0.05,
brush = 0.55,
animate = true,
interactive = true,
theme = "auto",
}: Props = $props()
let canvas: HTMLCanvasElement | undefined = $state()
let instance: AsciiFluidInstance | null = null
onMount(() => {
if (!canvas) return
instance = createAsciiFluid(canvas, {
charset,
cellSize,
color,
backgroundColor,
force,
dissipation,
brush,
animate,
interactive,
theme,
})
return () => {
instance?.destroy()
instance = null
}
})
$effect(() => {
instance?.setOptions({
charset,
cellSize,
color,
backgroundColor,
force,
dissipation,
brush,
animate,
interactive,
theme,
})
})
</script>
<div
data-slot="ascii-fluid"
aria-hidden="true"
class={cn("pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit] [mask-image:linear-gradient(#000,#000)]", className)}
>
<!-- Firefox can hand an opaque WebGL canvas straight to the system
compositor, which ignores rounded clips. The mask on the root keeps it
in the page's own layer, so the parent's radius holds. -->
<canvas bind:this={canvas} class="absolute inset-0 size-full rounded-[inherit]"></canvas>
</div>