perf(signal-viewer): GPU-accelerated (WebGL2) waveform rendering for smooth pan/zoom#31
Merged
Conversation
Stage 1 of ADR 0019's WebGL2 hybrid waveform renderer: the pure, GL-free core helpers plus their shader sources, exhaustively unit-tested (the in-sandbox correctness proof; jsdom has no WebGL2). - waveformTransform: data-space -> clip-space affine, pinned exactly to the Canvas2D pixel mapping (X via plotLeft/viewport, Y via stripTop+16 / stripHeight-8 insets). Pan = X offset, zoom = X scale; DPR-independent. - envelopeGeometry: triangle-strip from per-column min/max (upper=max, lower=min at column centre c+0.5), 1.2px min-thickness clamp, NaN columns -> primitive-restart run breaks. Guarantees a 1-sample spike reaches its extreme vertex (extrema-preservation contract). - lineGeometry: instanced per-segment endpoints from the LTTB polyline; NaN endpoints skip the instance (mirrors firstPoint break). Width expansion in the shader (screen-space, zoom-invariant). - laneScissor: device-px, DPR-aware, bottom-left-origin scissor replicating the load-bearing per-lane clip; pinned to computeLaneLayout. - glsl/: envelope strip + instanced-line programs (perpendicular quad expansion, SDF feather for 1.2px round-joined AA). 41 new unit tests, all green. https://claude.ai/code/session_012CzEJ1kUhwobqVTnVusLcb
Stage 1 GL-context class for ADR 0019. Self-contained; NOT yet wired into SignalViewer (Stage 2). No feature flag (owner decision) — WebGL2 is the intended default with automatic Canvas2D fallback. - Constructor obtains webgl2 (antialias, premultipliedAlpha, no preserveDrawingBuffer); throws typed WebGLUnavailableError on null so the host can fall back to Canvas2D. - API for Stage-2: resize(cssW, cssH, dpr) keeps DPR 2 (buffer = css*dpr, never reduced); uploadLanes(lanes) builds geometry via the pure helpers and uploads to STATIC buffers (data load / LOD change, not per frame); render(viewport, laneStates) sets transform/colour uniforms + per-lane gl.scissor and draws — no per-frame upload; dispose() frees resources. - Context-loss handling: webglcontextlost (preventDefault) + restored (recompile programs, re-upload retained lanes); onContextLost / onContextRestored callbacks for the host. - Theme colours passed as resolved RGBA uniforms (no getComputedStyle here). - Envelope drawn as TRIANGLE_STRIP with WebGL2's permanent fixed-index primitive restart; line drawn as instanced quads (lineWidthPx = 1.2*dpr). Validated by typecheck/lint; GL paths are exercised by the CI pixel-diff fidelity gate and production (no WebGL in the sandbox). https://claude.ai/code/session_012CzEJ1kUhwobqVTnVusLcb
Stage 2 of the WebGL2 hybrid waveform renderer (ADR 0019). Prepares the Canvas2D SignalRenderer to act as the hybrid's chrome layer AND its permanent automatic fallback: - `setChromeOnly(bool)` / `isChromeOnly()`: in chrome-only mode the renderer draws everything except the dense-CPAP waveform itself (backgrounds, grid, markers/washes, axis text, ribbon, step/sparse, wearable lines), but ONLY for lanes that carry WebGL geometry (`webglLane`). Lanes without it (e.g. before the decimation pyramid lands) still draw their polyline here so the waveform is never invisible. Default is off → byte-identical to before, so the fallback path and all existing tests are unchanged. - `renderSync()`: synchronous (non-rAF) base paint, used by the compositor on pan-settle to repaint chrome and clear its CSS pan-translate in one tick (flash-free). - `getCanvasElement()`: exposes the base canvas so the compositor can CSS-translate the chrome during a drag without re-rendering it. - `SignalChannel.webglLane`: optional per-lane WebGL geometry source (whole pyramid level in a stable absolute-ms domain). The Canvas2D path ignores it. The chrome/waveform split is governed by the shared pure predicate `isDenseCpapWaveform`. https://claude.ai/code/session_012CzEJ1kUhwobqVTnVusLcb
…parser Stage 2 of ADR 0019. The deterministic, GL-free decisions of the hybrid renderer live in `hybridWaveformPlan.ts` so they can be fully unit-tested in the headless sandbox (the GL draw is validated by the CI pixel-diff gate): - `isDenseCpapWaveform` — the single source of truth for the chrome/waveform split (both layers consult it, so they can never disagree about a lane). - `waveformModeForChannel` — envelope-vs-line selection, matching the Canvas2D samples-per-pixel threshold exactly. - `laneUploadSignature` / `needsReupload` — LOD-change detection. A re-upload is triggered ONLY by level / mode / plot-width / (envelope) physRange changes; pan and zoom within a level leave the signature unchanged → uniform-only frame. - `levelToColumnEnvelope` — reinterprets a whole extrema-preserving pyramid level as a per-column min/max band in the stable absolute-ms domain (NaN pairs become gap columns). - ms X-step + valuePerPx helpers. `cssColor.ts` parses resolved theme colour strings (hex / rgb()/rgba()) to RGBA for the WebGL colour uniforms, with no getComputedStyle (the renderer's "no getComputedStyle inside" contract). https://claude.ai/code/session_012CzEJ1kUhwobqVTnVusLcb
Stage 2 of ADR 0019. Composes three pixel-aligned layers at DPR 2: z0 Canvas2D chrome (SignalRenderer in chrome-only mode) z1 WebGL2 waveform (WebGLWaveformRenderer) — dense-CPAP lanes only z2 Canvas2D crosshair overlay (unchanged) Drop-in for the host's prior direct SignalRenderer use: same render / renderOverlay / resize / setOverlayCanvas / getValuesAtTime / dispose surface, delegating hit-testing to the inner Canvas2D renderer so BOTH paths hit-test identically. Uniform-only pan/zoom: WebGL geometry is the WHOLE chosen pyramid level in a stable absolute-ms domain, so a within-level pan/zoom frame is a uniform + scissor draw with NO re-upload (re-upload triggered only by the upload signature changing: level / mode / plot-width / envelope physRange / lane set). Chrome-layer per-frame-upload trap (the whole point) solved via CSS-translate: during an active drag the chrome canvas is CSS-translated to follow the pan (beginPan/renderDuringPan/endPan) and is NOT re-rendered, so it never re-uploads its large DPR-2 texture; the WebGL layer pans via uniforms. endPan repaints chrome synchronously then clears the translate (flash-free). Net per-frame upload during a drag ≈ 0. Automatic fallback (no feature flag): tries WebGL2 on construction; on WebGLUnavailableError (or any GL init failure) runs the inner SignalRenderer in full-draw mode — identical behaviour. On webglcontextlost it switches to full Canvas2D for the duration and on webglcontextrestored re-uploads and resumes, so the chart is never blank. Tests: HybridSignalRenderer fallback/delegation behaviour (jsdom has no WebGL2, so it exercises the exact Canvas2D fallback path) and the chrome-only split at the SignalRenderer level via op-counting. https://claude.ai/code/session_012CzEJ1kUhwobqVTnVusLcb
Stage 2 of ADR 0019. Wires HybridSignalRenderer into the Signal Viewer as the default waveform renderer with automatic Canvas2D fallback (no feature flag). - Adds a transparent WebGL2 waveform `<canvas>` layered between the base chrome canvas and the crosshair overlay (`.waveformCanvas`, pointer-events:none, aria-hidden). Sized at DPR 2 by the renderer, pixel-aligned with the base. - Constructs HybridSignalRenderer once both the base and waveform canvases are mounted (tryInitRenderer handles either mount order). Falls back to Canvas2D automatically when WebGL2 is unavailable. - buildCpapChannel now also attaches `webglLane`: the WHOLE chosen pyramid level (matching the SAME level/threshold the Canvas2D path uses) in a stable absolute-ms domain, so the WebGL layer pans/zooms via uniforms without re-uploading. The Canvas2D path is untouched (still consumes the pre-sliced data/envelope), so the fallback stays byte-identical. - Pan hot path drives the CSS-translate-chrome + WebGL-uniform path (beginPan/renderRangeDuringPan/endPan via the shared rAF scheduler), keeping the chrome layer off the per-frame texture-upload path during a drag. - Colours resolved to RGBA via the existing theme path (parseCssColorToRgba), re-resolved on theme change with the channel's resolved colour. - Crosshair overlay, hit-testing, keyboard cursor, lane headers, scroll and all interactions are unchanged (delegated to the inner Canvas2D renderer), so they behave identically on both the WebGL and fallback paths. https://claude.ai/code/session_012CzEJ1kUhwobqVTnVusLcb
…ke/gap spec Dev-only side-by-side harness (/__fidelity__, tree-shaken from prod) feeds one synthetic dataset through both the Canvas2D reference (SignalRenderer) and the WebGL HybridSignalRenderer at DPR 2. Playwright spec (RUN_FIDELITY=1) asserts pixel-diff (~10/255, AA-tolerant), SSIM >= 0.98, zero-tolerance spike-survival, gap-break, and per-lane scissor clipping across viewports. Adds a chromium-fidelity Playwright project with SwiftShader WebGL2 flags and a test-e2e-fidelity CI job gating the build.
- envelope GLSL: correct comments to match the MSAA-only fragment (no phantom edge-feather); document u_viewport/v_devicePos as reserved. - WebGLWaveformRenderer: drop the dead context-lost no-op block in uploadLanes (host re-drives upload on restore). - SignalRenderer: replace the odd void 0 chrome-skip body with an explanatory comment (lint no-empty ignores comment-only blocks). - CHANGELOG: note the GPU/WebGL2 waveform rendering change.
…ocked GL renderer
The WebGL-active orchestration in HybridSignalRenderer was only exercised on
the Canvas2D fallback path (jsdom has no WebGL2). Add a sibling Vitest suite
that mocks WebGLWaveformRenderer with a pure stub via vi.mock('../webgl'),
keeping the real WebGLUnavailableError and layout constants, so the pure-TS
compositor logic is verified in-sandbox.
Covers: WebGL path engaged (chrome-only + uploadLanes/render), construction
failure fallback (WebGLUnavailableError and generic Error), context-lost
fallback + repaint, context-restored re-upload/resume, the re-upload signature
gate (zero re-uploads on pan/zoom within a level; exactly one on mode/LOD/
resize/physRange changes), and hit-test delegation parity across both paths.
https://claude.ai/code/session_012CzEJ1kUhwobqVTnVusLcb
The async summary load called setLoading(false) in its finally with no unmount guard, so a DB read resolving after the component unmounted set state on a dead component — surfacing as a flaky 'window is not defined' unhandled rejection from Dashboard.test.tsx that could fail the unit-test job. Add an effect cleanup that bumps requestIdRef; the existing requestId guards then bail, so no setState fires post-teardown.
Thread an optional `preserveDrawingBuffer?: boolean` from HybridSignalRenderer (new HybridRendererOptions) down to WebGLWaveformRenderer's WebGL2 context creation. Default is `false`, matching the existing production behaviour — a preserved drawing buffer disables the browser swap-instead-of-copy fast path and costs per-frame performance, which ADR 0019 forbids on the shipped path. The option exists solely so the dev/test fidelity harness can opt in to deterministic off-screen pixel read-back under headless Chromium / SwiftShader. Backward-compatible: a small, default-off optional param; all existing call sites are unchanged. https://claude.ai/code/session_012CzEJ1kUhwobqVTnVusLcb
First CI run of the gate (chromium-fidelity, SwiftShader) failed in probeWebglLit with a 30s evaluate timeout then a blank readback: the WebGL canvas read back with zero lit pixels at the spike column. Root cause is the preserveDrawingBuffer:false blank-readback race in headless Chromium — reading the WebGL canvas (drawImage onto a 2D scratch) after the frame can return an empty buffer. Fixes: - Harness constructs the hybrid renderer with preserveDrawingBuffer:true (dev/test-only; production stays false) so the WebGL buffer survives compositing and is readable. - Harness publishes window.__fidelity.renderWebglNow(); every in-page read-back (readRegion, probeWebglLit) calls it to re-issue a synchronous WebGL draw in the SAME JS task immediately before capture, guaranteeing a populated buffer at read time (belt-and-suspenders). - Heavy per-viewport pixel tests get a 90s timeout so a slow-but-correct SwiftShader run is not killed. - Distinct, categorised failure messages: [WEBGL INACTIVE] (no GPU path, hard fail, no skip), [WEBGL BLANK READBACK] (active but entire region blank — now a genuine SwiftShader no-output to escalate, not a race), and [FIDELITY MISMATCH] (extreme lost / diff / SSIM). Project scoping (playwright.config.ts): - chromium-fidelity now runs ONLY webgl-fidelity-gate.spec.ts via testMatch, so the gate is fast under SwiftShader instead of dragging the whole suite through software GL. - chromium/firefox/webkit testIgnore the fidelity spec so the normal matrix runs everything except the gate (it remains RUN_FIDELITY-gated). https://claude.ai/code/session_012CzEJ1kUhwobqVTnVusLcb
The WebGL min/max envelope under-rendered a single-sample spike at the most-decimated (whole-night) zoom: the fidelity gate's +59.5 L/min Flow spike reached only ~+37 (~38% of its height above centre), so its topmost lit pixel landed at device y≈98 instead of the expected y≈49.3. Root cause is a column-RESOLUTION mismatch, not data loss. The min/max pyramid preserves the spike's extreme at every level, and the WebGL column envelope did carry it as a column max. But `levelToColumnEnvelope` paired the whole chosen pyramid level's elements 1:2 into ~levelLen/2 columns — ~2x plotWidthColumns, i.e. ~0.16 device px each. The spike thus became a sub-pixel-wide triangle peak that the GPU rasterizer's pixel-centre sampling stepped over, so the topmost lit pixel only reached the envelope of the spike's neighbours. The Canvas2D reference never had this problem because `columnEnvelopeInto` reduces to exactly plotWidthColumns (~one column per device pixel), making the spike a ~2px-wide column that always rasterizes to its full extreme. Fix: make the WebGL envelope match the reference's column resolution. `levelToColumnEnvelope` now takes a target column count and reduces the whole level to that many per-pixel-columns via the same forward per-column min/max fold `columnEnvelopeInto` uses; the caller passes plotWidthColumns. Each column now spans wholeLevelSpanMs / columns, so its clip-X matches the reference's `plotLeft + (c + 0.5) * (plotWidth / columns)` mapping. Extrema preservation is now a rasterized guarantee, not just a data-level one. The existing envelopeGeometry spike-survival test passed throughout because it exercises the geometry builder in isolation with a few well-resolved columns; it never reproduced the level->pixel-column collapse the integrated path produces. Add envelopeSpikeIntegration.test.ts to cover that seam end-to-end (dataset -> pyramid -> level selection -> levelToColumnEnvelope -> buildEnvelopeGeometry -> vertex Y -> device px), asserting the spike/notch reach +/-59.5 at the gate's expected device-Y and match the Canvas2D reference. Update hybridWaveformPlan unit tests for the new signatures. DPR 2, the Canvas2D reference/fallback, and the upload signatures are unchanged; no new deps, no feature flag. https://claude.ai/code/session_012CzEJ1kUhwobqVTnVusLcb
…bust) The spike/notch extreme-survival check compared the WebGL reached-extreme against an analytic physToDeviceY(extreme ± 0.5) with a tight ±4 device-px bound. Under software SwiftShader, sub-pixel AA fade plus the lit-pixel threshold leave a band-edge lit pixel a couple CSS px shy of the analytic ideal (asymmetric top vs bottom), so the notch tripped the gate by 0.7px even though no data was lost. Make the extreme check reference-relative — the gate's true invariant. Probe the same extreme column on BOTH canvases and assert WebGL tracks the Canvas2D reference's reached-extreme (immune to AA-vs-analytic mismatch), while keeping a loose analytic sanity bound + correct-half check to still catch a totally lost or squashed/shifted extreme. A new chroma-based reference-lit probe detects the opaque reference waveform without depending on the background shade. Also make gap-break reference-relative, and drop describe `serial` mode so one CI run surfaces every view's divergence at once (CI pins workers:1, so views still run one-at-a-time on a single SwiftShader context). https://claude.ai/code/session_012CzEJ1kUhwobqVTnVusLcb
…aller regions, no retries) The chromium-fidelity gate ground on for 25+ min in CI under software SwiftShader: 6 views, each marshalling full-plot-rect getImageData reads (~2000x1200 device px) over CDP, with 90s timeouts and 2 retries. Make it fast and reliable without losing essential fidelity coverage: - Cut views 6 -> 4: keep all (whole-night ENVELOPE mode, where the spike-attenuation bug lived; contains spike+notch+gap), 5m (LINE mode + transition), spike (narrow line-mode spike/notch survival), gap (the only view exercising the gap-break guard). Drop 1h (viewport identical to all) and 1m (redundant second line-mode window). - Scope the region pixel-diff + SSIM to the Flow lane band (~1/3 plot height) and column-stride it (stride 3, ~3x fewer columns) -> roughly an order of magnitude fewer pixels marshalled per view. - chromium-fidelity: retries 2 -> 0 (a fidelity mismatch is deterministic; retrying only burns slow re-runs). Per-test timeout 90s -> 40s in-spec, 60s project cap. - Bound the harness-ready wait (15s) so a missing/broken /__fidelity__ route fails fast with a clear message instead of hanging to timeout. Spike-attenuation (~49 device px short, fails the reference-relative extreme check by ~8x) and gross divergence (band shift far exceeds the 0.5% mismatch budget / 0.98 SSIM floor on the sampled grid) are still caught. Loud failure categories preserved. https://claude.ai/code/session_012CzEJ1kUhwobqVTnVusLcb
kabaka
added a commit
that referenced
this pull request
Jun 16, 2026
…ce) (#32) The WebGL2 hybrid renderer from #31 never activated in the live Signal Viewer: tryInitRenderer constructed HybridSignalRenderer as soon as the base chrome canvas mounted, tolerating a null waveform canvas. Since the base <canvas> precedes the waveform <canvas> in the DOM, its ref callback fired first, so the renderer was always built with waveform=null and pinned to the Canvas2D fallback for the view's lifetime — WebGL2 never ran, and pan/zoom performance was unchanged. A production Edge trace confirmed the Canvas2D full-canvas GPU re-upload pattern. Require both canvases before constructing. Adds SignalViewer.webglWiring.test (verified red pre-fix / green post-fix) — the integration seam the fidelity gate could not cover because it constructs renderers directly. QA approved; all CI green across Chromium/Firefox/WebKit + the fidelity gate.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
A real-browser (Edge) trace of a drag-pan proved pan/zoom is GPU-bound, not main-thread bound: in a 1,213 ms drag the renderer main thread was busy only 74 ms, but the GPU process main thread was busy 1,127 ms (~93%) → ~9 fps, 31 dropped frames, ~300 MB GPU memory. Root cause: the signal canvas is sized to the full stacked-lane height at devicePixelRatio 2, and a 2D canvas re-uploads its entire backing texture to the GPU on every change — so redrawing the whole canvas (plus a second full-size crosshair overlay) every pan frame saturates GPU upload bandwidth. The prior main-thread optimizations (#29 coalescing/overlay, #30 envelope/buffers) couldn't help because the main thread was never the limiter. (See ADR 0019.)
What
Render the dense CPAP waveform lanes through WebGL2, keeping everything else on Canvas2D — a hybrid:
gl.scissorreproduces the load-bearing clip.SignalRendererin newchromeOnlymode) — backgrounds, grid, axis labels, event/detection washes, hypnogram ribbon, sparse/step + wearable lanes. To avoid this layer reintroducing the per-frame texture upload, it is CSS-translated during an active drag (no re-render) and repainted once on settle; wheel-zoom repaints it at most once per coalesced notch.Net effect during a drag: WebGL ≈ 0 upload (uniform draw), chrome = 0 (CSS-translated) → the measured 1,127 ms GPU bottleneck is eliminated.
Fidelity — the hard constraint (correctness > performance)
The displayed waveform is intended to be visually identical at full DPR 2 (more-faithful zoomed-out min/max envelope; exact zoomed-in line). This is enforced by an automated fidelity gate (
tests/e2e/webgl-fidelity-gate.spec.ts+ a dev-only harness that feeds the sameSignalChannelto both renderers): per-viewport pixel-diff (~10/255, AA-tolerant) + SSIM ≥ 0.98 + zero-tolerance spike-survival + gap-break + scissor-clip checks, run in a dedicated SwiftShader-WebGL2 CI job (test-e2e-fidelity) that gates the build. The extrema-preservation contract lives outside the renderer (pyramid/worker geometry, reused unchanged), so spikes/notches cannot be hidden.Reviews & tests
preserveDrawingBuffer:false, noreadPixels/toDataURLin prod), static GLSL (no shader injection), ReDoS-safe colour parsing, bounds-safe geometry, no new dependencies.Honest caveats
preserveDrawingBuffer:falsereadback under CI's compositor. Both surface as loud gate failures (never false passes); mitigations ready (drop--enable-features=Vulkan; force a sync render /preserveDrawingBuffer:trueunder test).Deferred (follow-up, non-blocking)
Windowing the level-0 (fully-zoomed-in) line upload to viewport+overscan — currently a one-time whole-night instance-buffer build on entering max zoom; correct and bounded, but could cause a single hitch on that step.
https://claude.ai/code/session_012CzEJ1kUhwobqVTnVusLcb
Generated by Claude Code