Effect enginePro
The shared runtime under every Pro effect: one WebGL2 context for the whole page, theme tokens as RGBA, a frame clock that stops offscreen, a still frame under reduced motion, error diffusion in a worker and an honest progress driver.
On this page
These are libraries and hooks, not components. Every effect installs the pieces it needs, so you only add them by hand to build your own effect.
Installation
The other pieces install the same way: chatcn-pro-dither, use-chatcn-pro-theme-color, use-chatcn-pro-effect-motion, use-chatcn-pro-effect-image and effect-pause-control.
What is in it
| Piece | Import | What it does |
|---|---|---|
| Effect runtime | @/lib/chatcn-pro-effects | createEffect(canvas, { fragment, fallback }): a fullscreen triangle runner with a uniform bag and textures, drawn by one shared WebGL2 context and copied into your canvas. Pixel cap, DPR snapping, a canvas 2D fallback, GLSL snippets, a shared frame clock and one IntersectionObserver |
| Theme colors | @/hooks/use-chatcn-pro-theme-color | useEffectColors({ ink, paper }, ref) resolves any token or CSS color to RGBA where ref sits and follows theme, preset and contrast changes |
| Motion | @/hooks/use-chatcn-pro-effect-motion | useEffectMotion(ref) answers whether a loop may run; useFrameClock drives it; useProgressDriver smooths real progress |
| Dither toolkit | @/lib/chatcn-pro-dither | Bayer matrices, a 64 x 64 blue noise tile made in code, error diffusion in a worker, luminance sampling and a Geist Mono glyph atlas |
| Image plumbing | @/hooks/use-chatcn-pro-effect-image | useEffectImage(src) loads once per URL with a CORS check; useElementSize(ref) |
| Pause control | @/components/ui/chatcn/pro/effect-pause-control | The pause and play button for loops longer than 5 s (WCAG 2.2.2) |
Usage
import { createEffect, GLSL } from "@/lib/chatcn-pro-effects"
import { useEffectColors } from "@/hooks/use-chatcn-pro-theme-color"
import { useEffectMotion, useFrameClock } from "@/hooks/use-chatcn-pro-effect-motion"
const effect = createEffect(canvas, {
fragment: `${GLSL.bayer}
uniform vec4 u_ink;
uniform vec4 u_paper;
void main() {
float tone = v_uv.x;
out_color = tone > chatcnBayer(chatcnPx(), 8) ? u_paper : u_ink;
}`,
})
const { ink, paper } = useEffectColors({ ink: "var(--chatcn-fg)" }, ref)
const motion = useEffectMotion(ref)
useFrameClock((time) => effect.render(time), { running: motion.running, fps: 24 })Examples
Progress driver
Providers report progress in uneven jumps and often sit at 99% while they upload. useProgressDriver(progress, { done }) eases between events, never goes backwards, creeps slowly through a silence without passing 95%, and runs to 100% in about 300 ms once done is set. Without any progress it creeps toward 90% over expectedMs.
How it stays fast
- One context. Browsers cap live WebGL contexts at about 16. Every effect draws into one hidden WebGL2 canvas and is copied into its own 2D canvas in the same task, so a feed of 40 tiles costs one context and each tile still scrolls and clips like any element.
- Pixel cap. Backing stores use the device pixel ratio up to 2, then divide it by a whole number until the area fits
maxPixels, soimage-rendering: pixelatedupscales evenly and dither cells never come out uneven. - Frame clock. Loops share one
requestAnimationFrame, each with its own fps cap. A loop stops when its frame leaves the screen, when the tab is hidden and when the reader pauses it; time does not advance while stopped. - Off the main thread. Error diffusion is serial and cannot run in a shader, so it runs in a Web Worker made from a Blob URL, with a main thread fallback and a result cache. A theme change repaints the cached result without recomputing.
- Images load once per URL with
crossOrigin="anonymous", and a host without CORS headers falls back to the plain image instead of failing.
API
| Export | Notes |
|---|---|
createEffect(canvas, options) | fragment, fallback, maxPixels (default 1.5 MP), maxDpr (2), renderer (auto, webgl, 2d). Returns resize, setUniforms, setTexture, render(time), onRestore, dispose, mode, size |
GLSL | luminance, bayer, blueNoise, halftone, hash, simplex, cover. Fragments get v_uv, u_resolution, u_time, u_dpr and chatcnPx() |
fitBackingStore, cellPixels, coverLod | Sizing helpers behind the pixel cap |
addFrameCallback, observeVisibility, onPageVisibility | The shared loop and observers, for effects that do not use the hooks |
loadEffectImage(src) | Cached, decoded, CORS-checked. Rejects with EffectImageError |
getEffectStats() | Contexts created, live handles, frames drawn, last frame cost |
useEffectColors, useThemeColor, resolveThemeColor, subscribeTheme | Tokens to RGBA in the scope of an element |
useEffectMotion, useFrameClock, useProgressDriver, useReducedMotion | Loop permission, clock and progress |
ditherDiffuse, ditherOrdered, bayerMatrix, blueNoiseTexture, readLuminance, loadGlyphAtlas, asciiCells | The dither toolkit |
Pause control props
| Prop | Type | Notes |
|---|---|---|
paused, onPausedChange | boolean, (paused) => void | Wire to useEffectMotion |
corner | "top-start" | "top-end" | "bottom-start" | "bottom-end" | Logical, mirrors in RTL. Default bottom-end |
reveal | "hover" | "always" | hover shows it when the group/effect frame is hovered or the button has focus; always shown on touch and while paused |
label | string | Names the animation: "Pause noise field" |
Accessibility
- Any loop longer than 5 s shows the pause control: a real button with a label that says what it pauses, reachable by keyboard, with a 44 px target on touch.
- The canvas is
aria-hidden. The real content always exists: the<img>with youraltsits underneath and is what screen readers announce. - Under
forced-colors: activethe effect hides and the plain image shows with system colors. - Under
prefers-contrast: moreink and paper snap to the text and canvas tokens, with no mid tones.
Motion
- Under reduced motion
useFrameClocknever subscribes to the loop. It draws one frame at thestilltime you choose, the same picture on every visit. - Ambient loops run at 24 to 30 fps. Effects that only draw once (images, gradients) never touch the loop.