import { EnginePresetMode, PresetMode } from '../presets'; import { RevealState } from './reveal'; /** * Shared Three.js renderer driving every on the page. * * Architecture: * 1. One module-scope SharedRenderer is lazily created on first card mount. * It owns a single offscreen + THREE.WebGLRenderer + ortho * camera + ShaderMaterial + plane mesh. * 2. Each card registers an Instance: a visible 2D canvas + its own * uniform cache + dirty flag + reveal/cycle state. Per frame the loop * sizes the SHARED GL canvas to the largest active card (grow-only — * never shrinks), then for each visible/unpaused instance: sets * u_resolution to the instance's pixel size, sets the GL viewport + * scissor to the bottom-left iw×ih sub-rect (the only sub-rect where * `gl_FragCoord / u_resolution` lands in [0..1]), renders, and copies * that sub-rect into the visible 2D canvas via drawImage. * This avoids reallocating the GL drawing buffer every frame when * multiple cards of different sizes share the renderer — the original * "setSize per instance per frame" churn was exhausting Chrome's GPU * memory budget over the course of a few minutes of continuous * animation and crashing the WebGL context. * 3. IntersectionObserver pauses offscreen instances; when no instances * are active the rAF loop is cancelled entirely (re-armed by the next * visibility / pause / size change), so a fully-offscreen page costs * essentially nothing. * 4. WebGL context loss is handled: on `webglcontextlost` the rAF is * cancelled and uniforms marked dirty; on `webglcontextrestored` the * renderer recovers and resumes. * 5. Last unmount disposes everything (geometry, material, renderer). * * This mirrors the metal-fx shared-renderer pattern but built around Three.js * (peer dep) so the GLSL from image.html drops in unchanged. */ import * as THREE from 'three'; /** Instance handle returned by `createInstance`. Each owns one. */ export interface Instance { /** Visible 2D canvas painted into the host wrapper. */ canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D; /** CSS-px size (synced from ResizeObserver). */ cssWidth: number; cssHeight: number; /** GL render DPR cached at creation; refreshed on resize. Caps at * `maxDpr` (default 1.25) — this is the cheap-fragment-shader knob. */ dpr: number; /** Display DPR for the visible 2D canvas (and reveal image). Caps at * `MAX_CANVAS_DPR` (default 2). May exceed `dpr`: when it does, the * low-DPR GL framebuffer is upscaled into the visible canvas with * `imageSmoothingEnabled = false` so the cell mosaic stays crisp at * no extra GL cost, while the reveal image — drawn directly into * this canvas with smoothing enabled — gets the device's full pixel * density. Fixes blur on iOS Safari/Firefox where DPR=3. */ canvasDpr: number; /** Active preset mode block (dark or light). * * Typed as `EnginePresetMode` (the wider engine-internal shape) * even though the public `setInstancePreset` accepts a narrower * `PresetMode` — the cast is sound because the runtime objects * exported via PRESETS always carry the dot-mode fields. */ preset: EnginePresetMode; /** Optional override for `preset.cardBg` (#rrggbb). When set, this color is * used as the shader's `u_cardBg` and forwarded to the reveal helper so * background-derived shader logic stays in sync with the host card color. */ cardBgOverride: string | null; /** Optional per-slot palette override (CSS colors, up to 7 entries). A slot * with a value replaces the preset color verbatim (bypassing the card-bg * linked remap); missing/undefined slots fall back to the preset palette. * Lets consumers re-tint the running effect (e.g. from an image) without * authoring a whole preset. */ colorsOverride: (string | null | undefined)[] | null; /** Strength multiplier 0..1 (drives `canvas.style.opacity`). */ strength: number; /** Animation-clock multiplier on top of the preset's baked speed. Scales * the per-frame time delta, so drift, churn and flicker all speed up or * slow down together. 1 = preset tempo. */ speedMul: number; /** Pixel-cell on-screen size multiplier. 1 = preset default, 0.5 = cells at * half size (finer / zoomed out), 2 = double size (chunkier / zoomed in). * Applied by dividing the shader grid's base cell count so both the shader * mosaic and the reveal dissolve share the same scaled grid. */ pixelScale: number; /** True when in viewport. */ visible: boolean; /** True when the user has paused this instance. */ paused: boolean; /** Dirty flag - true until uniforms have been re-uploaded. */ uniformsDirty: boolean; /** Reveal state (image overlay drawn on top of shader). Null when no overlay. */ reveal: RevealState | null; /** Per-instance accumulated time so each card animates at its own preset.speed. */ accumulatedTime: number; /** When created (ms) — used for first-tick init only. */ startedAtMs: number; } interface SharedRenderer { glCanvas: HTMLCanvasElement; /** WebGL2 context if available, otherwise WebGL1. */ gl: WebGLRenderingContext | WebGL2RenderingContext; renderer: THREE.WebGLRenderer; scene: THREE.Scene; camera: THREE.OrthographicCamera; material: THREE.ShaderMaterial; geometry: THREE.PlaneGeometry; mesh: THREE.Mesh; uniforms: Record; instances: Set; /** 0 means the rAF loop is currently asleep (no active instances). * The loop is re-armed by `wakeLoop()` whenever an instance becomes * visible/unpaused or its size grows past the current GL canvas. */ rafId: number; lastFrameMs: number; lastTickMs: number; /** Last instance whose uniforms were uploaded to `uniforms`. Used to skip * re-uploading full uniform blocks when the same clean instance renders * again back-to-back (e.g. single-card page, manual repaint). Null after * destroy or when the next instance can't trust this cache. */ lastInstance: Instance | null; /** WebGL context loss handlers — kept so we can detach on dispose. */ onContextLost: ((e: Event) => void) | null; onContextRestored: (() => void) | null; contextLost: boolean; } /** Hex (#rrggbb) currently in effect for this instance, accounting for override. */ export declare function effectiveCardBg(inst: Instance): string; /** * Palette colour `index` (0–6) as [r,g,b] in 0–1, after the card-linked remap * (port of image.html's card-variable behaviour). * * Any preset colour authored to equal the preset's OWN canonical `cardBg` is * treated as a "card variable": when the consumer overrides `cardBg`, those * colours follow the new surface instead of staying at the preset default * (otherwise e.g. sweep-gradient's #0f0f0f swatches read as dark blocks on a * #1B1B1B demo card). With no override the effective bg equals the preset bg, * so the comparison is a no-op and the raw colour is returned unchanged. * * Shared by the shader uniform upload and the reveal mask so the effect and * its reveal silhouette stay in lockstep. */ export declare function effectivePaletteColor(inst: Instance, index: number): [number, number, number]; /** * Force a single repaint of an instance regardless of its pause state. * Used by React sync effects (preset / cardBg changes etc.) so paused cards * still reflect prop edits live in the playground without resuming the * animation loop. Skips if the instance is offscreen or zero-sized. */ export declare function renderInstanceOnce(inst: Instance): void; /** Hook for cycle scheduler so it can advance phase timers in lockstep. */ export declare function setAfterTick(cb: (() => void) | null): void; /** Public API */ export interface CreateInstanceOptions { canvas: HTMLCanvasElement; cssWidth: number; cssHeight: number; preset: PresetMode; strength?: number; /** Animation-clock multiplier (see `Instance.speedMul`). @default 1 */ speed?: number; cardBg?: string | null; /** Pixel-cell size multiplier (see `Instance.pixelScale`). @default 1 */ pixelScale?: number; } /** * Resolve the effective `cellSize` (0..1 preset value) for an instance's * `pixelScale`. The shader derives the mosaic grid from * `gridCounts(6 + cellSize * 74)`, so scaling the on-screen cell *size* by * `pixelScale` means dividing that base count by `pixelScale` (smaller cells * = more of them). Returning a re-derived `cellSize` keeps the shader and the * reveal dissolve — which both compute `6 + cellSize * 74` independently — * perfectly in lockstep without threading an extra uniform. * * The result can fall below 0 (very chunky) or above 1 (very fine); the * shader's `gridCounts` floor of 2 cells keeps that safe. */ export declare function effectiveCellSize(cellSize: number, pixelScale: number): number; export declare function createInstance(opts: CreateInstanceOptions): Instance; export declare function destroyInstance(inst: Instance): void; export declare function updateInstanceSize(inst: Instance, cssWidth: number, cssHeight: number): void; export declare function setInstancePreset(inst: Instance, preset: PresetMode): void; export declare function setInstanceCardBg(inst: Instance, cardBg: string | null): void; /** Per-slot palette override (see `Instance.colorsOverride`). Pass null to * restore the preset palette. */ export declare function setInstanceColors(inst: Instance, colors: (string | null | undefined)[] | null): void; export declare function setInstanceVisible(inst: Instance, visible: boolean): void; export declare function setInstancePaused(inst: Instance, paused: boolean): void; export declare function setInstanceStrength(inst: Instance, strength: number): void; export declare function setInstanceSpeed(inst: Instance, speed: number): void; export declare function setInstancePixelScale(inst: Instance, pixelScale: number): void; /** * Cap the rAF tick rate (frames per second). Applies globally to every * instance sharing the renderer. Values are clamped to a sensible range so * a callsite can't accidentally starve the loop or burn CPU at >60. * * @example setFrameRate(10) // default — best for slow-drift presets * @example setFrameRate(15) // smoother for faster custom presets * @example setFrameRate(30) // restore the original cap */ export declare function setFrameRate(fps: number): void; /** * Cap the device-pixel-ratio used by the GL canvas. Lower values mean * fewer fragments to shade per frame (~quadratic GPU saving). Updates the * shared `THREE.WebGLRenderer.setPixelRatio` AND every live instance's * cached `inst.dpr` so the next render uses the new ratio. Existing * instances are forced to re-upload uniforms because the change invalidates * the per-instance `u_dpr` value used by the shader's CSS-px calculations. * * Values are clamped to [1, 4]. Pass `1` for the strongest GPU saving on * retina displays; the default is `1.25`. */ export declare function setMaxDpr(dpr: number): void; /** Read the current cap (frames per second). */ export declare function getFrameRate(): number; /** Read the current MAX_DPR cap. */ export declare function getMaxDpr(): number; /** Internal helper for the reveal sampler — exposes the shared renderer to * reveal.ts so it can momentarily re-render with a different dotMode and read * pixels back via gl.readPixels. */ /** Swap the fragment stage every instance renders with, or `null` for the * bundled one. The material is shared, so this is page-wide. Compile * errors surface from three.js on the next render — validate the source * on a scratch context first if it comes from somewhere untrusted. */ export declare function setSharedFragmentShader(src: string | null): void; export declare function withSharedRenderer(fn: (s: SharedRenderer) => T): T; export type { SharedRenderer };