import { ShaderRegistry } from "./shader-registry"; import { BufferPool } from "./buffer-pool"; export type WebGLCanvas = HTMLCanvasElement | OffscreenCanvas; export interface WebGLContextManagerOptions { /** * If `true`, compile + link every shader in `SHADER_MANIFEST` * during construction (and again after a context-loss/restore * cycle). Trades ~30-100ms of init time for elimination of the * compile/link cost on the first-frame path of every chart type * that ends up rendered. Default `false` keeps the original * lazy-compile behavior — programs are compiled on first * `getOrCreate(name, ...)` call from a glyph's render path. */ precompile?: boolean; } export declare class WebGLContextManager { private _canvas; private _gl; private _isWebGL2; private _shaders; private _buffers; private _uploadedCount; private _cssWidth; private _cssHeight; private _dpr; private _precompile; private _frameCallback; /** * Per-instance `MessageChannel` used by `_yieldToTask` to resume a * polling `awaitGpuFence` loop on the next task. Allocated lazily — * the polling path is rarely hit when the GPU is idle, and many * `WebGLContextManager` instances never need one. * * Must be per-instance: a module-level singleton races when two * managers poll concurrently. Both call sites assign * `port1.onmessage = resolve`, the second assignment overwrites the * first, and the first poll's promise never settles — leaving its * `awaitGpuFence` hung. The hang propagates up through the render * scheduler's `present` → `uploadAndRender` → the worker's * `uploadChunkAck` → the host's `with_typed_arrays` callback, * stalling `draw()` indefinitely. (Now that fence waits across * different `WebGLContextManager`s run in parallel inside one * scheduler drain, the per-instance discipline matters even * more — concurrent poll loops are the common case, not the * exception.) * * Cost of per-instance allocation: one extra `MessageChannel` (two * `MessagePort`s, ~negligible bytes, zero idle CPU) per chart on * top of the per-chart proxy channel that the transport already * holds. Bounded by chart count; safe even for pathological pages * with hundreds of charts. The alternative — allocating a fresh * channel on every `_yieldToTask` call — would churn ports on every * fence poll (potentially many per frame on slow GPUs), which is * far worse for the structured-clone subsystem than holding one * port pair for the manager's lifetime. */ private _yieldChannel; constructor(canvas: WebGLCanvas, options?: WebGLContextManagerOptions); get gl(): WebGL2RenderingContext | WebGLRenderingContext; get isWebGL2(): boolean; get shaders(): ShaderRegistry; get bufferPool(): BufferPool; get uploadedCount(): number; set uploadedCount(count: number); /** * Resize the GL canvas's bitmap to match the host's CSS layout. The * Host is responsible for measuring the DOM element (or otherwise * deciding the target CSS size) and the device pixel ratio — the * manager itself does not touch DOM, so the same code path works * whether the canvas is an `HTMLCanvasElement` (in-process) or an * `OffscreenCanvas` (in-process via transfer, or in a worker). */ resize(cssWidth: number, cssHeight: number, dpr: number): void; /** * Pending dimensions to apply at the start of the next render. * `null` when no resize is queued. See {@link requestResize} / * {@link applyPendingResize}. */ private _pendingResize; /** * Record a dimension change to be applied at the start of the * next render's Phase 1, *before* `_fullRender` runs. The actual * `canvas.width = N` assignment (which clears the drawing buffer * per the WebGL spec) happens inside `applyPendingResize()`, * paired in the same synchronous task as the paint that fills * the new buffer. * * Why split the dimension change off from the existing * {@link resize} method: in direct / in-process modes the * GL canvas IS the host's visible canvas, and `canvas.width = N` * is immediately observable to the browser's compositor as a * cleared buffer. If the resize lands in the message handler * (one task) but the matching `_fullRender` lands in the next * RAF (a later task), the compositor cycles between them and * presents one full frame of empty canvas — visible flicker. * Deferring the dimension change to the same RAF as the paint * eliminates the inter-frame gap; both happen inside Phase 1's * un-yielded loop. * * Multiple `requestResize` calls before the next render coalesce * to last-write-wins — five rapid width changes from a window * drag produce one resize+paint, not five. */ requestResize(cssWidth: number, cssHeight: number, dpr: number): void; /** * Apply any pending dimension change recorded by * {@link requestResize}. Called by the scheduler's Phase 1 * (immediately before each entry's `fullRender`) and by the * `snapshotPng` bypass path. Returns `true` when a resize was * applied, `false` when there was nothing pending — useful for * callers that want to skip a no-op render. */ applyPendingResize(): boolean; /** * Last CSS width passed to `resize()`. */ get cssWidth(): number; /** * Last CSS height passed to `resize()`. */ get cssHeight(): number; /** * Last device pixel ratio passed to `resize()`. */ get dpr(): number; clear(): void; /** * Register a per-frame hook invoked at the end of each render. In * blit-mode rendering, the worker installs a callback that * transfers an `ImageBitmap` from `_canvas` (an `OffscreenCanvas`) * back to the host so the visible display canvas can `drawImage` * it. In direct mode the callback is left null and `endFrame` is a * no-op. * * Pass `null` to detach. */ setFrameCallback(cb: ((bitmap: ImageBitmap) => void) | null): void; /** * Called by chart impls at the bottom of `_fullRender` (and any * other path that produces a complete frame). When a frame * callback is registered AND the GL surface is an * `OffscreenCanvas`, ship its current contents as an * `ImageBitmap` to the host. Otherwise no-op — direct-mode * rendering has nothing to ship; the visible canvas already holds * the drawing buffer. */ endFrame(): void; /** * Resolve when every GL command submitted up to this call has been * executed by the GPU. * * On WebGL2 this issues a `fenceSync(SYNC_GPU_COMMANDS_COMPLETE)` * and polls `clientWaitSync` with a zero timeout, yielding to the * task queue between polls. The first poll passes * `SYNC_FLUSH_COMMANDS_BIT` so the fence becomes reachable without * a separate `gl.flush()`. * * On WebGL1 there is no fenceSync; we fall back to the blocking * `gl.finish()`. This is acceptable in a worker — never call this * from the main thread on a heavy frame. * * Used as a per-frame "GPU is idle" barrier so callers can serialize * follow-on work (`endFrame` snapshot, present roundtrip, the next * chunk upload) against actual GPU completion instead of the * implicit, implementation-defined timing of `transferToImageBitmap`. */ awaitGpuFence(): Promise; ensureBufferCapacity(totalRows: number): void; /** * Yield to the task queue between fence polls. We avoid * `setTimeout(0)` because Chromium clamps nested `setTimeout` to * ~4ms in workers, which would inflate the measured cost of * `awaitGpuFence`. A reused per-instance `MessageChannel` lands the * resume in the next task with sub-ms latency. * * `addEventListener(..., { once: true })` is used over * `port1.onmessage = ...` so concurrent in-flight resumes (should * any path ever introduce them) cannot clobber each other's * resolvers — the previous module-level singleton lost a resolver * on every overlap and hung one chart's `draw()` indefinitely. */ private _yieldToTask; destroy(): void; }