@fxshelf/glyph-rain
Version:
Matrix-style glyph rain with cursor stir and drop-head lighting for React and Vue. WebGL2, zero animation-lib deps. Copy-as-source friendly.
515 lines (473 loc) • 15.1 kB
text/typescript
import { buildAtlas } from './atlas'
import { FRAG, VERT } from './shaders'
import { supportsHtmlInCanvas } from './supports'
import {
DEFAULTS,
type ElementImageContext,
type GlyphRainElements,
type GlyphRainInstance,
type GlyphRainOptions,
type PaintableCanvas,
} from './types'
export function createGlyphRain(
elements: GlyphRainElements,
options: GlyphRainOptions = {},
): GlyphRainInstance | null {
const config = { ...DEFAULTS, ...options }
const { source, content, output } = elements
const maybeGl = output.getContext('webgl2', {
alpha: true,
depth: false,
stencil: false,
antialias: false,
premultipliedAlpha: true,
})
if (!maybeGl || maybeGl.isContextLost()) return null
const gl: WebGL2RenderingContext = maybeGl
const sourceCtx = source.getContext('2d') as ElementImageContext | null
const paintable = source as PaintableCanvas
const htmlInCanvas = Boolean(
sourceCtx &&
typeof sourceCtx.drawElementImage === 'function' &&
typeof paintable.requestPaint === 'function',
)
let contentDirty = false
let pageLum = 0
let wake = () => {}
function readPageLum(): number {
try {
const probe = document.createElement('canvas')
probe.width = probe.height = 1
const pctx = probe.getContext('2d', { willReadFrequently: true })
if (!pctx) return 0
let el: Element | null = content
while (el instanceof Element) {
const bgColor = getComputedStyle(el).backgroundColor
if (bgColor && bgColor !== 'transparent') {
pctx.clearRect(0, 0, 1, 1)
pctx.fillStyle = bgColor
pctx.fillRect(0, 0, 1, 1)
const d = pctx.getImageData(0, 0, 1, 1).data
if (d[3]! > 128) {
return (0.2126 * d[0]! + 0.7152 * d[1]! + 0.0722 * d[2]!) / 255
}
}
el = el.parentElement
}
} catch {
/* ignore */
}
return 0
}
if (htmlInCanvas) {
paintable.onpaint = () => {
try {
sourceCtx!.reset()
sourceCtx!.drawElementImage!(content, 0, 0)
contentDirty = true
wake()
} catch {
/* ignore */
}
}
}
function compile(type: number, text: string): WebGLShader {
const shader = gl.createShader(type)!
gl.shaderSource(shader, text)
gl.compileShader(shader)
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error('GlyphRain shader error:', gl.getShaderInfoLog(shader))
}
return shader
}
const vertexShader = compile(gl.VERTEX_SHADER, VERT)
const fragmentShader = compile(gl.FRAGMENT_SHADER, FRAG)
const program = gl.createProgram()!
gl.attachShader(program, vertexShader)
gl.attachShader(program, fragmentShader)
gl.linkProgram(program)
const uniforms: Record<string, WebGLUniformLocation> = {}
const uniformCount = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS)
for (let i = 0; i < uniformCount; i++) {
const info = gl.getActiveUniform(program, i)!
uniforms[info.name] = gl.getUniformLocation(program, info.name)!
}
const quad = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, quad)
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
gl.STATIC_DRAW,
)
gl.enableVertexAttribArray(0)
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0)
const contentTexture = gl.createTexture()!
gl.bindTexture(gl.TEXTURE_2D, contentTexture)
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,
1,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
new Uint8Array([0, 0, 0, 0]),
)
const atlasTexture = gl.createTexture()!
let atlasCount = 1
let atlasGrid = 1
let atlasCharset = ''
function syncAtlas() {
if (config.charset === atlasCharset) return
atlasCharset = config.charset
const atlas = buildAtlas(config.charset)
atlasCount = atlas.count
atlasGrid = atlas.grid
gl.bindTexture(gl.TEXTURE_2D, atlasTexture)
gl.texParameteri(
gl.TEXTURE_2D,
gl.TEXTURE_MIN_FILTER,
gl.LINEAR_MIPMAP_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,
atlas.canvas,
)
gl.generateMipmap(gl.TEXTURE_2D)
}
syncAtlas()
let dpr = 1
function syncCanvasSize() {
dpr = Math.min(window.devicePixelRatio || 1, 2)
const width = Math.max(1, Math.round(output.clientWidth * dpr))
const height = Math.max(1, Math.round(output.clientHeight * dpr))
if (output.width !== width || output.height !== height) {
output.width = width
output.height = height
}
if (htmlInCanvas) {
const cssWidth = Math.max(1, Math.round(source.clientWidth))
const cssHeight = Math.max(1, Math.round(source.clientHeight))
if (
source.width !== cssWidth * dpr ||
source.height !== cssHeight * dpr
) {
source.width = cssWidth * dpr
source.height = cssHeight * dpr
}
paintable.requestPaint!()
}
}
syncCanvasSize()
function uploadContent() {
if (!htmlInCanvas || !contentDirty) return
contentDirty = false
pageLum = readPageLum()
gl.bindTexture(gl.TEXTURE_2D, contentTexture)
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
gl.RGBA,
gl.UNSIGNED_BYTE,
source,
)
sourceCtx!.clearRect(0, 0, source.width, source.height)
}
let time = 7.3
const WAKE_RES = 256
const wakeCharge = new Float32Array(WAKE_RES)
const wakeField = new Float32Array(WAKE_RES * 2)
let wakeLive = false
let wakeTouched = false
let pointerX = 0
let tracking = false
const wakeTexture = gl.createTexture()!
gl.bindTexture(gl.TEXTURE_2D, wakeTexture)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
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.RG32F,
WAKE_RES,
1,
0,
gl.RG,
gl.FLOAT,
wakeField,
)
function stirAmount(): number {
return Math.min(Math.max(config.stir, 0), 1)
}
function wakeSpan(): number {
const width = Math.max(output.clientWidth, 1)
const px = Math.min(Math.max(config.stirRadius, 8), 2000)
return Math.max(px / width, 1 / WAKE_RES)
}
function stepWake(delta: number) {
const stir = stirAmount()
const settleT = Math.min(Math.max(config.settle, 0.05), 8)
const decay = Math.exp(-delta / settleT)
const span = wakeSpan()
const drive = stir > 0.001 && !reducedMotion
const track = drive && tracking
let live = false
for (let i = 0; i < WAKE_RES; i++) {
let charge = wakeCharge[i]! * decay
if (track) {
const d = Math.abs((i + 0.5) / WAKE_RES - pointerX) / span
if (d < 1) {
const t = 1 - d
const target = t * t * (3 - 2 * t)
if (target > charge) charge = target
}
}
if (charge < 1e-4) charge = 0
wakeCharge[i] = charge
if (charge > 0) {
live = true
if (drive) {
wakeField[i * 2]! += delta * stir * 2.2 * charge
wakeTouched = true
}
}
wakeField[i * 2 + 1] = charge
}
if (!live && !wakeLive) return
wakeLive = live
gl.bindTexture(gl.TEXTURE_2D, wakeTexture)
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
WAKE_RES,
1,
gl.RG,
gl.FLOAT,
wakeField,
)
}
function render() {
uploadContent()
gl.useProgram(program)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, contentTexture)
gl.uniform1i(uniforms.uContent!, 0)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, atlasTexture)
gl.uniform1i(uniforms.uAtlas!, 1)
gl.activeTexture(gl.TEXTURE2)
gl.bindTexture(gl.TEXTURE_2D, wakeTexture)
gl.uniform1i(uniforms.uWake!, 2)
gl.uniform2f(uniforms.uResolution!, output.width, output.height)
gl.uniform1f(uniforms.uTime!, time)
gl.uniform1f(uniforms.uCell!, Math.min(Math.max(config.cell, 8), 64) * dpr)
gl.uniform1f(uniforms.uGlyphCount!, atlasCount)
gl.uniform1f(uniforms.uAtlasGrid!, atlasGrid)
gl.uniform3f(
uniforms.uColor!,
config.color[0],
config.color[1],
config.color[2],
)
gl.uniform3f(
uniforms.uHeadColor!,
config.headColor[0],
config.headColor[1],
config.headColor[2],
)
gl.uniform1f(uniforms.uSpeed!, Math.min(Math.max(config.speed, 0.05), 3))
gl.uniform1f(
uniforms.uSpeedVar!,
Math.min(Math.max(config.speedVariance, 0), 1),
)
gl.uniform1f(uniforms.uDensity!, Math.min(Math.max(config.density, 0), 1))
gl.uniform1f(uniforms.uTrail!, Math.min(Math.max(config.trail, 0.2), 3))
gl.uniform1f(uniforms.uGlow!, Math.min(Math.max(config.glow, 0), 3))
gl.uniform1f(uniforms.uMutate!, Math.min(Math.max(config.mutate, 0), 4))
gl.uniform1f(uniforms.uFlicker!, Math.min(Math.max(config.flicker, 0), 1))
gl.uniform1f(
uniforms.uLayers!,
Math.round(Math.min(Math.max(config.layers, 1), 3)),
)
gl.uniform1f(uniforms.uDim!, Math.min(Math.max(config.dim, 0), 1))
gl.uniform1f(uniforms.uLight!, Math.min(Math.max(config.light, 0), 3))
gl.uniform1f(
uniforms.uLightRadius!,
Math.min(Math.max(config.lightRadius, 20), 600) * dpr,
)
gl.uniform1f(uniforms.uLightHeight!, Math.max(config.lightHeight, 4) * dpr)
gl.uniform1f(uniforms.uRelief!, Math.min(Math.max(config.relief, 0), 2))
gl.uniform1f(uniforms.uStir!, wakeTouched ? stirAmount() : 0)
gl.uniform1f(uniforms.uScroll!, content.scrollTop * dpr)
gl.uniform1f(uniforms.uPageLum!, pageLum)
gl.uniform1f(uniforms.uHasContent!, htmlInCanvas ? 1 : 0)
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
gl.viewport(0, 0, output.width, output.height)
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
}
let raf = 0
let lastTime = performance.now()
let destroyed = false
let running = false
let visible = true
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
let reducedMotion = motionQuery.matches
function frame(now: number) {
if (destroyed) return
if (!visible) {
running = false
return
}
const delta = Math.min((now - lastTime) / 1000, 1 / 30)
lastTime = now
if (!reducedMotion) time += delta
stepWake(delta)
render()
if (reducedMotion && !contentDirty) {
running = false
return
}
raf = requestAnimationFrame(frame)
}
function start() {
if (destroyed || running || !visible) return
running = true
lastTime = performance.now()
raf = requestAnimationFrame(frame)
}
wake = start
start()
function onMotionChange() {
reducedMotion = motionQuery.matches
if (reducedMotion) {
tracking = false
wakeCharge.fill(0)
for (let i = 0; i < WAKE_RES; i++) wakeField[i * 2 + 1] = 0
wakeLive = true
}
start()
}
motionQuery.addEventListener('change', onMotionChange)
content.addEventListener('scroll', start, { passive: true })
const pointerHost = output.parentElement ?? output
function pointerNorm(event: PointerEvent): number {
const box = output.getBoundingClientRect()
if (box.width < 1) return -1
return (event.clientX - box.left) / box.width
}
function onPointerMove(event: PointerEvent) {
if (reducedMotion) return
const x = pointerNorm(event)
if (x < 0) return
pointerX = x
tracking = true
start()
}
function onPointerLeave() {
tracking = false
}
function onPointerDown(event: PointerEvent) {
if (reducedMotion || stirAmount() <= 0.001) return
const x = pointerNorm(event)
if (x < 0) return
pointerX = x
tracking = true
const span = wakeSpan() * 1.8
for (let i = 0; i < WAKE_RES; i++) {
const d = Math.abs((i + 0.5) / WAKE_RES - x) / span
if (d >= 1) continue
const t = 1 - d
const burst = t * t * (3 - 2 * t)
if (burst > wakeCharge[i]!) wakeCharge[i] = burst
}
start()
}
pointerHost.addEventListener('pointermove', onPointerMove, { passive: true })
pointerHost.addEventListener('pointerleave', onPointerLeave, { passive: true })
pointerHost.addEventListener('pointercancel', onPointerLeave, { passive: true })
pointerHost.addEventListener('pointerdown', onPointerDown, { passive: true })
const observer = new ResizeObserver(() => {
syncCanvasSize()
start()
})
observer.observe(output)
observer.observe(content)
const intersection = new IntersectionObserver((entries) => {
visible = entries[entries.length - 1]?.isIntersecting ?? true
if (visible) start()
})
intersection.observe(output)
return {
setOptions(next) {
let changed = false
for (const [key, value] of Object.entries(next)) {
const prev = config[key as keyof typeof config]
if (Array.isArray(value) && Array.isArray(prev)) {
if (
value.length !== prev.length ||
value.some((item, i) => item !== prev[i])
) {
changed = true
break
}
} else if (prev !== value) {
changed = true
break
}
}
Object.assign(config, next)
if (!changed) return
syncAtlas()
syncCanvasSize()
start()
},
resize() {
syncCanvasSize()
start()
},
destroy() {
destroyed = true
cancelAnimationFrame(raf)
observer.disconnect()
intersection.disconnect()
motionQuery.removeEventListener('change', onMotionChange)
content.removeEventListener('scroll', start)
pointerHost.removeEventListener('pointermove', onPointerMove)
pointerHost.removeEventListener('pointerleave', onPointerLeave)
pointerHost.removeEventListener('pointercancel', onPointerLeave)
pointerHost.removeEventListener('pointerdown', onPointerDown)
gl.deleteTexture(contentTexture)
gl.deleteTexture(atlasTexture)
gl.deleteTexture(wakeTexture)
gl.deleteProgram(program)
gl.deleteShader(vertexShader)
gl.deleteShader(fragmentShader)
gl.deleteBuffer(quad)
if (htmlInCanvas) paintable.onpaint = null
},
}
}
export { supportsHtmlInCanvas }
export type {
GlyphRainElements,
GlyphRainInstance,
GlyphRainOptions,
} from './types'
export { DEFAULT_CHARSET, DEFAULTS } from './types'