diff --git a/apps/web/src/explore/notifications.ts b/apps/web/src/explore/notifications.ts index 59779b3e2..41b1f475b 100644 --- a/apps/web/src/explore/notifications.ts +++ b/apps/web/src/explore/notifications.ts @@ -1,6 +1,7 @@ import type { DataErrorEventDetail, LegendErrorEventDetail, + RendererDegradedDetail, SelectionDisabledNotificationDetail, } from '@protspace/core'; import type { NotifyOptions } from '../lib/notify'; @@ -167,6 +168,32 @@ export function getSelectionDisabledNotification( }; } +/** + * Copy for a renderer capability reduction. + * + * Every one of these was previously silent or console-only, on hardware that + * never said anything was out of range. The toast shows the user-facing message; + * the report action carries the measured numbers instead, because the device + * limit and the point count are what identify the device class in a bug report. + */ +export function getRendererDegradedNotification(detail: RendererDegradedDetail): NotifyOptions { + const context = detail.context; + const reason = context?.reason ?? 'unknown'; + return { + title: 'Rendering quality reduced.', + description: detail.message, + durationMs: 10_000, + dedupeKey: `renderer-degraded:${reason}`, + action: buildReportAction( + 'Rendering', + context + ? `${reason} (maxTextureSize=${context.maxTextureSize}, stride=${context.stride}, ` + + `points=${context.pointCount}${context.detail ? `, cause=${context.detail}` : ''})` + : detail.message, + ), + }; +} + export function getLegendErrorNotification(detail: LegendErrorEventDetail): NotifyOptions { return { title: 'Legend update failed.', diff --git a/apps/web/src/explore/runtime.ts b/apps/web/src/explore/runtime.ts index 89c26588f..fbd0d52e8 100644 --- a/apps/web/src/explore/runtime.ts +++ b/apps/web/src/explore/runtime.ts @@ -1,5 +1,8 @@ import '@protspace/core'; // Registers all web components +import type { RendererDegradedDetail } from '@protspace/core'; import type { EatReliabilityState } from '@protspace/utils'; +import { notify } from '../lib/notify'; +import { getRendererDegradedNotification } from './notifications'; import { startProductTour } from '../tour/product-tour'; import { bindControlBarEvents } from './control-bar-events'; import { createDatasetController } from './dataset-controller'; @@ -319,6 +322,13 @@ export async function initializeExploreRuntime(): Promise { 'data-change', interactionController.handlePlotDataChange, ); + // The renderer reports capability reductions (device texture limits, a refused + // GPU allocation, the gamma fallback) that were previously silent or + // console-only. Latched once per reason in the renderer, deduped again here. + addTrackedEventListener(lifecycle, plotElement, 'renderer-degraded', (event: Event) => { + const detail = (event as CustomEvent).detail; + notify.warning(getRendererDegradedNotification(detail)); + }); addTrackedEventListener(lifecycle, plotElement, 'file-dropped', (event: Event) => { const file = (event as CustomEvent<{ file?: File }>).detail.file; if (file) { diff --git a/apps/web/tests/helpers/gl-simulation.ts b/apps/web/tests/helpers/gl-simulation.ts new file mode 100644 index 000000000..fff8f7f4d --- /dev/null +++ b/apps/web/tests/helpers/gl-simulation.ts @@ -0,0 +1,145 @@ +import type { Page } from '@playwright/test'; + +/** + * Simulate a WebGL2 device with a given `MAX_TEXTURE_SIZE`, including the + * driver-side refusal of anything larger. + * + * Stubbing `gl.getParameter` alone proves nothing: it changes what the app + * believes, while the real driver on the test machine accepts the allocation + * regardless. Simulating the refusal is what makes an assertion about the + * renderer's behaviour meaningful — and it reproduces the exact shape of the + * real failure: `texImage2D` past the limit raises `GL_INVALID_VALUE`, which is + * not a JS exception, so nothing unwinds and the texture is left unallocated. + * + * It patches the prototype, so it sees EVERY WebGL2 texture in the page, not + * just the label atlas — including the renderer's canvas-sized gamma-pipeline + * colour target. Pick a `limit` above the plot canvas's physical size, or the + * stats will blame the renderer for an allocation the atlas work never made. + */ +interface SimulatedGlStats { + /** [width, height] of every allocation the simulated device refused. */ + refusedAllocations: Array<[number, number]>; + /** Updates issued against a texture that was never successfully allocated. */ + refusedUpdates: number; +} + +declare global { + interface Window { + __glSim?: SimulatedGlStats; + } +} + +export async function simulateTextureLimit(page: Page, limit: number): Promise { + await page.addInitScript((maxTextureSize: number) => { + const MAX_TEXTURE_SIZE = 0x0d33; + const INVALID_VALUE = 0x0501; + const INVALID_OPERATION = 0x0502; + + const stats: SimulatedGlStats = { refusedAllocations: [], refusedUpdates: 0 }; + window.__glSim = stats; + + const proto = WebGL2RenderingContext.prototype; + const originalGetParameter = proto.getParameter; + const originalTexImage2D = proto.texImage2D; + const originalTexSubImage2D = proto.texSubImage2D; + const originalGetError = proto.getError; + const originalBindTexture = proto.bindTexture; + + const boundTexture = new WeakMap(); + const allocated = new WeakSet(); + const pendingError = new WeakMap(); + + proto.getParameter = function patchedGetParameter(this: WebGL2RenderingContext, name: number) { + if (name === MAX_TEXTURE_SIZE) return maxTextureSize; + return originalGetParameter.call(this, name); + }; + + proto.bindTexture = function patchedBindTexture( + this: WebGL2RenderingContext, + target: number, + texture: WebGLTexture | null, + ) { + boundTexture.set(this, texture); + return originalBindTexture.call(this, target, texture); + }; + + proto.texImage2D = function patchedTexImage2D( + this: WebGL2RenderingContext, + ...args: unknown[] + ) { + const texture = boundTexture.get(this) ?? null; + // Only the explicit-dimension overloads carry width/height at 3 and 4. The + // 6-argument DOM-source form — texImage2D(target, level, internalformat, + // format, type, source) — puts enum values there instead (RGBA is 6408, + // UNSIGNED_BYTE is 5121), which would read as an enormous allocation and be + // recorded as a refusal the renderer never issued, then evict the texture + // from `allocated` so its next update counted as a refused update too. + const hasExplicitSize = args.length >= 9; + const width = hasExplicitSize ? (args[3] as number) : null; + const height = hasExplicitSize ? (args[4] as number) : null; + + if ( + typeof width === 'number' && + typeof height === 'number' && + (width > maxTextureSize || height > maxTextureSize) + ) { + stats.refusedAllocations.push([width, height]); + pendingError.set(this, INVALID_VALUE); + if (texture) allocated.delete(texture); + return undefined; + } + + if (texture) allocated.add(texture); + return (originalTexImage2D as (...a: unknown[]) => unknown).apply(this, args); + }; + + proto.texSubImage2D = function patchedTexSubImage2D( + this: WebGL2RenderingContext, + ...args: unknown[] + ) { + const texture = boundTexture.get(this) ?? null; + if (texture && !allocated.has(texture)) { + stats.refusedUpdates += 1; + pendingError.set(this, INVALID_OPERATION); + return undefined; + } + return (originalTexSubImage2D as (...a: unknown[]) => unknown).apply(this, args); + }; + + proto.getError = function patchedGetError(this: WebGL2RenderingContext) { + const simulated = pendingError.get(this); + if (simulated !== undefined) { + pendingError.delete(this); // getError clears the flag it reports + return simulated; + } + return originalGetError.call(this); + }; + }, limit); +} + +export async function simulatedGlStats(page: Page): Promise { + return page.evaluate(() => window.__glSim ?? { refusedAllocations: [], refusedUpdates: 0 }); +} + +/** Distinct opaque colours present in the plot canvas, as "r,g,b" keys. */ +export async function distinctCanvasColors(page: Page): Promise { + return page.evaluate(() => { + const canvas = document + .querySelector('#myPlot') + ?.shadowRoot?.querySelector('canvas') as HTMLCanvasElement | null; + if (!canvas) return []; + const copy = document.createElement('canvas'); + copy.width = canvas.width; + copy.height = canvas.height; + const ctx = copy.getContext('2d'); + if (!ctx) return []; + ctx.drawImage(canvas, 0, 0); + const { data } = ctx.getImageData(0, 0, copy.width, copy.height); + const seen = new Set(); + for (let i = 0; i < data.length; i += 4) { + if (data[i + 3] < 200) continue; // skip background and anti-alias fringe + seen.add(`${data[i]},${data[i + 1]},${data[i + 2]}`); + } + return [...seen]; + }); +} diff --git a/apps/web/tests/label-atlas-limit.spec.ts b/apps/web/tests/label-atlas-limit.spec.ts new file mode 100644 index 000000000..c6445874c --- /dev/null +++ b/apps/web/tests/label-atlas-limit.spec.ts @@ -0,0 +1,114 @@ +import { expect, test, type Page } from '@playwright/test'; +import { dismissTourIfPresent, waitForExploreDataLoad } from './helpers/explore'; +import { + distinctCanvasColors, + simulateTextureLimit, + simulatedGlStats, +} from './helpers/gl-simulation'; + +/** + * The label atlas against a device whose texture limit it cannot fit. + * + * jsdom has no WebGL, so this is the only layer that exercises the real + * renderer. But a limit *stub* alone proves nothing: `gl.MAX_TEXTURE_SIZE` only + * changes what the app believes, while the actual driver on this machine happily + * accepts the allocation. So the driver's refusal is simulated too — + * `texImage2D` past the simulated limit does not call through and arms + * `getError`, exactly as a real driver does: no exception, no unwinding, the + * texture left unallocated. + * + * That simulation is what makes the assertion meaningful. The reported failure + * is silent — nothing in the app called `gl.getError()` — so "no console errors" + * would pass on the broken code too. What the test asserts instead is that the + * renderer never *issues* an allocation the device would refuse. + * + * The demo dataset is ~7.8K proteins, so its atlas is 2048x31 — no realistic + * limit forces a stride reduction at that size. This spec therefore covers the + * "no atlas fits at all" path; the reduced-stride path needs the 573K fixture + * and lives in load-large-bundle.spec.ts. + */ + +// A multi-value annotation in the shipped demo dataset, so the atlas is actually +// in play. `cath` and `superfamily` are the others. +const MULTI_VALUE_ANNOTATION = 'keyword'; + +/** + * One texel below the narrowest atlas width (2048), so no atlas layout fits and + * the renderer must allocate none. The broken code ignored the limit entirely + * and issued 2048 x 31, which this simulated driver refuses. + * + * Deliberately not something far lower like 1024: the simulation intercepts + * *every* texture in the page, and the renderer's own gamma-pipeline colour + * target is allocated at the plot canvas's physical size (`framebuffer.ts`). A + * limit under that size would record it as a refusal the atlas work never + * caused, coupling these assertions to the viewport and the sidebar's width. + */ +const NO_ATLAS_FITS_LIMIT = 2047; + +test.describe('label atlas on a device that cannot hold it', () => { + test('never issues an allocation the device would refuse', async ({ page }) => { + await simulateTextureLimit(page, NO_ATLAS_FITS_LIMIT); + + await page.goto(`/explore?annotation=${MULTI_VALUE_ANNOTATION}`); + await dismissTourIfPresent(page); + await waitForExploreDataLoad(page); + + const stats = await simulatedGlStats(page); + expect( + stats.refusedAllocations, + `renderer issued texture allocations the device refuses: ${JSON.stringify(stats.refusedAllocations)}`, + ).toEqual([]); + // The permanent half: once an allocation is refused, every later update + // targets storage that does not exist. + expect(stats.refusedUpdates).toBe(0); + }); + + test('still draws every point, in colour rather than black', async ({ page }) => { + await simulateTextureLimit(page, NO_ATLAS_FITS_LIMIT); + + await page.goto(`/explore?annotation=${MULTI_VALUE_ANNOTATION}`); + await dismissTourIfPresent(page); + await waitForExploreDataLoad(page); + + const proteinCount = await page.evaluate(() => { + const plot = document.querySelector('#myPlot') as + | (Element & { data?: { protein_ids?: { length?: number } } }) + | null; + return plot?.data?.protein_ids?.length ?? 0; + }); + expect(proteinCount).toBeGreaterThan(0); + + // Fidelity degrades; coverage does not. Markers fall back to their dominant + // colour, which is what the legend shows — never the solid black an + // unallocated atlas produced. + const colors = await distinctCanvasColors(page); + expect(colors.length).toBeGreaterThan(1); + expect(colors.every((c) => c === '0,0,0')).toBe(false); + }); + + test('tells the user that marker fidelity was reduced', async ({ page }) => { + await simulateTextureLimit(page, NO_ATLAS_FITS_LIMIT); + + await page.goto(`/explore?annotation=${MULTI_VALUE_ANNOTATION}`); + await dismissTourIfPresent(page); + await waitForExploreDataLoad(page); + + await expect(page.getByText('Rendering quality reduced.')).toBeVisible({ timeout: 15_000 }); + }); + + test('is inert on a device with ample limits', async ({ page }) => { + // Same simulation, a limit nothing reaches: the atlas allocates normally and + // no refusal is recorded, so the simulation itself cannot be what fails the + // tests above. + await simulateTextureLimit(page, 8192); + + await page.goto(`/explore?annotation=${MULTI_VALUE_ANNOTATION}`); + await dismissTourIfPresent(page); + await waitForExploreDataLoad(page); + + const stats = await simulatedGlStats(page); + expect(stats.refusedAllocations).toEqual([]); + expect(stats.refusedUpdates).toBe(0); + await expect(page.getByText('Rendering quality reduced.')).toHaveCount(0); + }); +}); diff --git a/apps/web/tests/load-large-bundle.spec.ts b/apps/web/tests/load-large-bundle.spec.ts index 677671f8b..3bf1081ea 100644 --- a/apps/web/tests/load-large-bundle.spec.ts +++ b/apps/web/tests/load-large-bundle.spec.ts @@ -6,6 +6,11 @@ import { waitForExploreDataLoad, getFirstLegendItemValue, } from './helpers/explore'; +import { + distinctCanvasColors, + simulateTextureLimit, + simulatedGlStats, +} from './helpers/gl-simulation'; const SPEC_DIR = path.dirname(new URL(import.meta.url).pathname); const SPROT_FIXTURE = path.resolve(SPEC_DIR, 'fixtures/sprot_50.parquetbundle'); @@ -190,3 +195,55 @@ test.describe('large bundle load (sprot_50, 573k proteins)', () => { } }); }); + +/** + * The reduced-stride path, which only a real large bundle reaches. + * + * At 573,649 proteins the atlas is 2048 x 2241, so a device reporting the + * WebGL2 floor of 2048 cannot hold it — this is the case the issue reported, on + * the dataset the app ships. The demo dataset is far too small to reach it + * (2048 x 31), which is why this lives here rather than in the default suite. + */ +test.describe('label atlas at Swiss-Prot scale on a floor-limit device', () => { + test.skip( + !fixtureAvailable, + 'Fixture sprot_50.parquetbundle not present; copy from protspace/data/other/sprot/.', + ); + test.setTimeout(180_000); + + test('reduces slices to fit, and still draws every protein', async ({ page }) => { + await simulateTextureLimit(page, 2048); + + await page.goto('/explore'); + await dismissTourIfPresent(page); + + await page.locator('protspace-control-bar [data-driver-id="import"] .dropdown-trigger').click(); + await page + .locator('protspace-data-loader') + .locator('input[type="file"]') + .setInputFiles(SPROT_FIXTURE); + await waitForExploreDataLoad(page, 120_000); + + // Nothing the device would refuse was ever issued. On the pre-fix renderer + // this records [[2048, 2241]] and then a refused update on every restage. + const stats = await simulatedGlStats(page); + expect( + stats.refusedAllocations, + `renderer issued allocations the device refuses: ${JSON.stringify(stats.refusedAllocations)}`, + ).toEqual([]); + expect(stats.refusedUpdates).toBe(0); + + // Fidelity drops; coverage does not. + const proteinCount = await page.evaluate(() => { + const plot = document.querySelector('#myPlot') as + | (Element & { data?: { protein_ids?: { length?: number } } }) + | null; + return plot?.data?.protein_ids?.length ?? 0; + }); + expect(proteinCount).toBe(573_649); + + const colors = await distinctCanvasColors(page); + expect(colors.length).toBeGreaterThan(1); + expect(colors.every((c) => c === '0,0,0')).toBe(false); + }); +}); diff --git a/apps/web/tests/playwright.config.ts b/apps/web/tests/playwright.config.ts index 898a103be..ddd325bb7 100644 --- a/apps/web/tests/playwright.config.ts +++ b/apps/web/tests/playwright.config.ts @@ -149,6 +149,14 @@ export default defineConfig({ }, testMatch: /load-large-bundle\.spec\.ts/, }), + { + name: 'label-atlas-limit', + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1280, height: 720 }, + }, + testMatch: /label-atlas-limit\.spec\.ts/, + }, { name: 'figure-editor', use: { diff --git a/openspec/changes/archive/2026-08-15-bound-label-atlas-to-device-limits/.openspec.yaml b/openspec/changes/archive/2026-08-15-bound-label-atlas-to-device-limits/.openspec.yaml new file mode 100644 index 000000000..0c73c8f54 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-bound-label-atlas-to-device-limits/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-15 diff --git a/openspec/changes/archive/2026-08-15-bound-label-atlas-to-device-limits/design.md b/openspec/changes/archive/2026-08-15-bound-label-atlas-to-device-limits/design.md new file mode 100644 index 000000000..e312c3a39 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-bound-label-atlas-to-device-limits/design.md @@ -0,0 +1,179 @@ +## Context + +The label atlas is a `LABEL_TEXTURE_WIDTH`(2048)-wide RGBA8 texture holding `MAX_LABELS`(8) texels +per point — the per-slice colours of a multi-label pie mark. It is allocated in `expandCapacity` +(`webgl-renderer.ts:1111-1141`) at the renderer's full point capacity, unconditionally, and uploaded +whole (`:1041-1071`). Width is fixed and height does all the growing, which is the least favourable +arrangement against a square device limit. + +Three facts settled the design; each contradicted at least one proposal on the table. + +**The capacity clamp alone rescues every device reporting >= 4096.** `MAX_POINTS_DIRECT_RENDER` +caps `maxPoints` at `:828` but not `capacity` — `expandCapacity` receives `maxPoints` and then +applies 1.5x growth on top (`capacity-planner.ts:18`). Clamping the planner's output gives capacity +1,000,192 and height 3907, inside 4096. The width and stride ladders below therefore exist for the +2048 tier and for the sibling #456 change, not for the general population. + +**Adaptive width must not perturb devices that already work.** Iterating stride outer and width +inner, smallest width first, yields byte-identical geometry (2048 x 2241 at Swiss-Prot) on every +device at 4096 or above, and widens only when 2048 genuinely cannot fit. This is why the loop is +ordered the way it is; the reverse order would re-lay-out the 97% to help the 3%. + +**Context loss already resets capacity.** `_handleWebglContextLost` (`scatter-plot.ts:470-479`) +calls `destroy()` and nulls the renderer; `_createWebglRenderer` (`:487`) constructs a fresh one +with `capacity = 0` (`webgl-renderer.ts:89`). An overshoot-induced loss therefore self-corrects, so +this change adds no consecutive-loss counter — one would silently kill pie charts on healthy +machines after two unrelated losses, which this codebase manufactures on resize. + +## Goals / Non-Goals + +**Goals.** Make the device limit measured rather than assumed. Make a failed GPU allocation +detectable and non-permanent. Never render a point in a colour that is not its own. Give the export +path the same guarantees as the live path. Leave the geometry of a healthy device bit-identical. + +**Non-Goals.** Deferring allocation for single-label annotations (the 42% residency win) — that is +the follow-on change, and it is deliberately sequenced second because it only adds a second reason +to enter the "no atlas" state this change builds and tests on the error path. Changing the pie +encoding. Reducing per-point channel count. Anything in the culling path. + +## Decisions + +### Plan geometry in one pure module + +`label-atlas-plan.ts` owns `MAX_LABELS`, the width ladder `[2048, 4096, 8192]` and the stride ladder +`[8, 4, 2]`, and returns `{ width, height, stride, pointCapacity, byteLength, reducedDetail }` or +`null` when even stride 2 cannot fit. It is pure and GL-free, so the whole geometry contract is unit +testable without a context, and both renderers compute geometry the same way by construction rather +than by two parallel edits. + +The shader needs no arithmetic change for a variable width. The CPU writes at linear texel index +`idx * stride + j` (`label-texture-utils.ts:24`) and the shader recovers `tx = globalIndex % texW`, +`ty = globalIndex / texW` from `u_labelTextureSize.x` (`export-shaders.ts:185-187`). A row-major +upload makes texel `globalIndex` the same texel at any width. `UNPACK_ALIGNMENT` does not bite +because an RGBA8 row is always a multiple of 4 bytes. + +`STRIDE_LADDER`'s floor is 2 rather than 1 because `eat-annotation-overlay/spec.md:208-210` +normatively requires a two-label transferred cell to render both hues in live _and_ exported +markers. Stride 1 would satisfy no scenario that stride 0 does not. + +`planLabelAtlas` takes an optional `maxStride`, which the export renderer feeds the live view's +stride. Implementation note: the export inherits fidelity by _planning at that stride_, not by +planning at full stride and capping afterwards — capping after the fact would size the texture for +slices it then refuses to draw. + +### Stride is planned against capacity, not against the drawn count + +The atlas is planned from `this.capacity`, like every other array, so it stays valid across the +colour-only fast path without re-planning. The accepted consequence is that fidelity is pinned to +the session's high-water capacity. With the clamp in place capacity never exceeds 1,000,192, so on +the 2048 tier the answer is stride 4 for any dataset large enough to matter — one deterministic +rung, not a drifting one. + +### The absent-atlas state is a first-class state, gated in the shader + +`labelColorData` becomes nullable. `u_labelAtlasCapacity` is a new `int` uniform and the pie branch +becomes `if (v_labelCount > 1.5 && v_pointIndex < u_labelAtlasCapacity)`. When the branch is +skipped, `finalColor` remains `v_color.rgb` — the point's dominant colour, pixel-identical to a +single-label point carrying that value. Never black. + +`v_pointIndex` is `gl_VertexID` (`export-shaders.ts:49`) and the two-pass selection draw passes +`first` to `drawArrays` (`render-target.ts:119-127`), so `gl_VertexID = first + i` keeps the staging +slot correct across both passes. Nothing here changes that coupling. + +When no atlas is allocated, a 1x1 opaque placeholder keeps the sampler texture-complete. It is never +sampled, because the capacity uniform is 0; it exists so a constrained device does not emit +incomplete-texture warnings into a console that `load-large-bundle.spec.ts:67` asserts is empty. + +### One `getError` per capacity change, in two places, ordered + +The buffer check runs _before_ any texture call, so a failed `bufferData` is neither masked by nor +misattributed to the atlas upload. It is guarded on the allocating path (`buffersInitialized` false, +i.e. the `bufferData` branch at `:1088`), never on `bufferSubData`, and therefore never per frame. +On a buffer failure the renderer returns early leaving `buffersInitialized` false, because retrying +`bufferData` reallocates whereas `bufferSubData` against a zero-sized store is `INVALID_VALUE` +forever. + +`gl.isBuffer` and `gl.isTexture` (`gl-resources.ts:73-81`) cannot substitute: both report on handle +validity, not on whether storage was successfully allocated. That blind spot is why the current code +believes an over-size allocation succeeded. + +### Hoist the style upload out of `updateStyles` + +`populateBuffers` sets `needsReorder = updatePositions` (`:844`), and the reorder branch +(`:892-957`) rewrites all six style arrays _and_ the atlas in the new slot order — but the upload is +gated on `updateStyles` alone (`:1031`). A positions-only populate therefore restages styles in +memory and uploads none, leaving the GPU holding the previous permutation. This is masked today +because an unchanged depth vector re-sorts to an identical permutation, so the rewritten arrays are +byte-identical to what is resident. It is a latent correctness bug, it is in the blast radius of +this change's staging edits, and the fix is to gate on `updateStyles || needsReorder`. + +### Diagnostics reuse the host-message channel + +`renderer-degraded` follows `selection-disabled-notification` exactly — a `HostMessageEventDetail` +with `severity: 'warning'`, dispatched `bubbles: true, composed: true`, mapped to copy in +`apps/web/src/explore/notifications.ts` with a `dedupeKey` and a report action carrying the measured +limit and the unmasked renderer string. Reasons latch in a `Set`, once per renderer instance. + +`handleGammaFallback` (`webgl-renderer.ts:283-292`) is routed through the same channel. A silent +linear-to-sRGB blending switch is a _larger_ visible change than a stride reduction, it already has +a one-shot latch, and it fires on the same constrained devices; building the pipeline and leaving +that one console-only would be incoherent. + +## Risks / Trade-offs + +**A texture-limit stub alone cannot reproduce the failure.** `gl.getParameter` only changes what the +application believes; the driver under the test still accepts whatever it is handed, so the pre-fix +renderer passes a stub-only test. And the reported failure is silent — nothing called +`gl.getError()` — so a console-error assertion passes on broken code too. The Playwright layer +therefore simulates the _driver_ as well (`helpers/gl-simulation.ts`): `texImage2D` past the +simulated limit does not call through and arms `getError`, and a later `texSubImage2D` against that +texture reports `INVALID_OPERATION`. The assertion is then that the renderer never _issues_ an +allocation the device would refuse, which is red on the parent commit +(`[[2048, 31]]`) and green after. A companion case runs the same simulation at an ample limit, so +the simulation itself cannot be what fails the others. + +Corollary on dataset size: the shipped demo is ~7.8K proteins, whose atlas is 2048x31 — no realistic +limit refuses it, and no limit forces a stride reduction. The default suite therefore covers the +"no atlas fits" path at a simulated limit of 1024, and the reduced-stride path lives in the opt-in +573K spec, which is the case the issue actually reported. + +**The mock GL context is a blocker, not a nicety.** `test-support/mock-webgl2.ts` has no +`getParameter` and no `getError`, and `texImage2D`/`texSubImage2D` are bare no-ops — and +`bufferSubData` is absent outright, so no test had ever reached the already-initialised upload path. +Adding either call throws `TypeError` in every existing renderer suite. Mitigation: extend the mock as the first +commit, so a mock regression bisects separately from a renderer regression. + +**Reduced fidelity is a visible change on the affected tier.** A user on a 2048 device sees +four-segment pies where a colleague sees eight. Mitigation: it is announced, and it replaces +solid-black marks that were announced to no one. Most multi-label proteins carry two or three +distinct values, so the common case is pixel-identical. + +**Clamping >8-colour points to 8 segments changes rendered output on every device.** Today those +points draw slices 9..N from an unrelated protein's texels — wrong colours presented as data. +Mitigation: this is the bug fix, and it is called out in the change log rather than slipped in. + +**`precision highp int` could change output on drivers that were already 32-bit.** It cannot: it +raises a guaranteed minimum, it does not lower one. The risk is the reverse — that it exposes a +shader-compile failure on a driver that does not support `highp int` in fragment shaders. ES 3.00 +requires it, and the renderer is WebGL2-only. + +## Migration Plan + +Land in the order the tasks list: mock first, then the pure planner and the clamp with no renderer +changes at all (red/green in isolation), then the staging shape, then the shader and its plumbing, +then the live renderer, then the export renderer, then notifications, then Playwright. Each step +leaves the tree green. + +No data migration, no bundle-format change, no persisted state. A revert restores the previous +behaviour exactly, including the defects. + +## Open Questions + +None blocking. Two recorded for the follow-on work: + +- Whether the 2048 tier is worth carrying long-term. This change adds `maxTextureSize` to the perf + metadata (`webgl-render-perf.ts:383-388`) precisely so the next release has evidence instead of a + third-party survey figure that cannot be verified from this repo. +- Whether a dirty-row `texSubImage2D` is needed. It is not today — the full-surface upload runs per + capacity change and per restage, not per frame — but #456 should re-examine it if its work makes + restages more frequent. diff --git a/openspec/changes/archive/2026-08-15-bound-label-atlas-to-device-limits/proposal.md b/openspec/changes/archive/2026-08-15-bound-label-atlas-to-device-limits/proposal.md new file mode 100644 index 000000000..083c1097b --- /dev/null +++ b/openspec/changes/archive/2026-08-15-bound-label-atlas-to-device-limits/proposal.md @@ -0,0 +1,132 @@ +## Why + +The pie-chart label atlas grows with the point count and nothing checks it against the device. +`expandCapacity` computes `texHeight = ceil(nextCapacity * MAX_LABELS / LABEL_TEXTURE_WIDTH)` +(`webgl-renderer.ts:1132-1134`), which is exactly `capacity / 256`, and uploads it with +`texImage2D` (`:1044-1054`). `gl.MAX_TEXTURE_SIZE` is queried nowhere in `packages/` or `apps/`, +and `gl.getError()` is called nowhere in any `src/`. So the largest drawable dataset is silently a +function of the device's texture limit: + +| device `MAX_TEXTURE_SIZE` | atlas caps at | +| ---------------------------: | ---------------: | +| 2048 (the WebGL2 spec floor) | 524,288 points | +| 4096 | 1,048,576 points | +| 8192 | 2,097,152 points | + +A device reporting 2048 cannot allocate the atlas for the **shipped 573,649-protein Swiss-Prot +bundle** — it needs height 2241. The failure is invisible and permanent: an over-size `texImage2D` +raises `GL_INVALID_VALUE`, which is not a JS exception, so nothing unwinds and the texture is left +unallocated — but `labelTextureInitialized = true` is set regardless of outcome (`:1057`), so every +later update takes the `texSubImage2D` branch against storage that was never allocated and raises +`GL_INVALID_OPERATION` forever after. The user sees black pie marks and a clean console. + +The 4096 tier looks safe against `MAX_POINTS_DIRECT_RENDER = 1_000_000` until the 1.5x geometric +growth in `planRendererCapacity` (`capacity-planner.ts:18`) is accounted for: the constant caps +`maxPoints` (`webgl-renderer.ts:828`) but **not** `capacity`, so loading 900k then 950k gives +capacity 1,350,144 -> height 5274 -> over the limit at a point count well under a million. + +Two further defects surfaced while verifying the report, neither of them in it: + +- **`stage-point.ts:75` writes `labelCounts[idx] = pointColors.length` unclamped** while + `fillLabelColorTexels` writes at most `MAX_LABELS` texels. The shader's `count` is likewise + unclamped (`export-shaders.ts:180`), so a point with more than 8 distinct colours reads **the next + protein's texels** for its surplus slices. `sliceIndex` has no `count - 1` clamp either, and + `atan(+0, x<0)` is exactly `+PI`, so `normalizedAngle` reaches 1.0 on the middle pixel row of any + odd-height sprite and the same overrun happens to well-formed points. +- **`POINT_FRAGMENT_SHADER` declares only `precision highp float`** (`export-shaders.ts:53`). ES 3.00 + defaults fragment `int` to `mediump`, whose guaranteed range is 16 bits, so `flat in int +v_pointIndex` (`:59`) and `int globalIndex` (`:184`) are undefined above 32,767 on any driver that + honours the minimum — precisely the low-end hardware this change is about. + +This blocks the sibling change for #456. Raising `MAX_POINTS_DIRECT_RENDER` to 2,000,000 on today's +code makes the atlas 7813 rows, fatal on every 4096 device — a tier that works today — and a +1.5M -> 2M sequence on an 8192 device gives capacity 2,250,240 -> height 8790, failing on mainstream +hardware. #456 first would convert a minority-device bug into a mainstream one. + +## What Changes + +- **Bound the atlas geometry to the device.** Probe `gl.MAX_TEXTURE_SIZE` once per context and plan + the atlas in a new pure module, preferring full 8-slice stride over a wider texture so every + device that works today keeps byte-identical geometry (2048 x 2241 at Swiss-Prot). Widen to + 4096/8192 only when 2048 cannot fit; drop stride to 4, then 2, only when no width fits. The floor + is 2 because `eat-annotation-overlay/spec.md:208-210` binds a two-label cell to render both hues + in live _and_ exported markers. +- **Clamp capacity, not just `maxPoints`.** `planRendererCapacity` gains an optional `maxCapacity` + argument that the renderer feeds `MAX_POINTS_DIRECT_RENDER`, bounding the 1.5x overshoot. This + alone rescues every device reporting >= 4096: 1,000,192 points is height 3907. The clamp can never + starve a legitimately larger load, because it is floored at the snapped requirement. +- **Never render a multi-label point black.** A new `u_labelAtlasCapacity` uniform gates the pie + branch, so a point outside the atlas — or a session with no atlas at all — falls through to + `v_color.rgb`, its dominant colour, identical to what a single-label point of that value renders. +- **Check `gl.getError()` once per capacity change**, never per frame: once after the first + `bufferData` of a capacity change and once after the atlas `texImage2D`. Set + `labelTextureInitialized` only on `NO_ERROR`. On failure, fall back to a 1x1 placeholder that keeps + the sampler texture-complete, and report it. +- **Surface degradation to the user** through the existing host-message channel, following + `selection-disabled-notification`, latched once per reason. Route the equally silent gamma-pipeline + fallback (`webgl-renderer.ts:283-292`) through the same channel rather than leaving it + console-only. +- **Fix the three shader/staging defects above** — clamp the staged label count to the effective + stride, clamp `count` and `sliceIndex` in the shader, and add `precision highp int`. +- **Repair the export path**, which duplicates the allocation per export call + (`export-renderer.ts:588-598`) with no probe, no error check and no shared stride. Export probes + its own context's limit and inherits the live renderer's stride, so a figure matches the screen. + `MAX_DIMENSION = 8192` (`:73`), whose error message calls it "the browser limit", becomes + `min(8192, exportMaxTextureSize)` — today that constant is a lie on any device below 8192. + +Deferring allocation until a multi-label annotation is actually selected — the 42% residency win — +is a separate change that lands on top of this one. This change ships the "atlas absent" state +machine that deferral needs, on the error path, so deferral only adds a second reason to enter a +state that is already tested. + +## Capabilities + +### New Capabilities + +- `renderer-capability-limits`: what the WebGL renderer guarantees about device limits — that it + measures them rather than assuming them, that it degrades marker fidelity rather than point + coverage, that a point is never rendered in a colour that is not its own, and that a capability + reduction reaches the user instead of the console. + +### Modified Capabilities + +- `point-visibility`: records that a point's rendered slice count is bounded by the effective atlas + stride, so the four-tier opacity model and the multi-label hidden rule are unchanged by a + fidelity reduction. + +## Impact + +- `packages/core/src/components/scatter-plot/webgl/renderer/label-atlas-plan.ts` — new; the single + place atlas geometry is computed, pure and GL-free. Owns `MAX_LABELS`, which `stage-point.ts` + re-exports so existing importers are unaffected. +- `packages/core/src/components/scatter-plot/webgl/renderer/capacity-planner.ts` — optional fifth + argument; the existing seven-case suite passes unchanged as a regression guard. The fourth + argument is renamed `capacityGranularity`: with a variable atlas width it is allocation + granularity, not a texture-row constraint. +- `packages/core/src/components/scatter-plot/webgl/renderer/webgl-renderer.ts` — the probe, an + `syncLabelAtlas`/`uploadLabelAtlas` pair replacing the inline allocation and upload, both + `getError` checks, a nullable `labelColorData`, and the style-buffer upload hoisted from + `updateStyles` to `updateStyles || needsReorder`. +- `packages/core/src/components/scatter-plot/webgl/renderer/stage-point.ts` — `StagePointArrays` + gains `maxLabels` and a nullable `labelColorData`; the staged label count is clamped. +- `packages/core/src/components/scatter-plot/webgl/renderer/export-shaders.ts` — `precision highp +int`, the `u_labelAtlasCapacity` uniform and its guard, and the two slice clamps. +- `packages/core/src/components/scatter-plot/webgl/renderer/export-renderer.ts` — its own probe, + stride inherited from the live renderer, a `getError` check, a truthful `MAX_DIMENSION`, and the + removal of a comment claiming it reuses the main renderer's arrays when `:589-598` allocates seven + fresh ones. +- `packages/core/src/components/scatter-plot/webgl/renderer/render-target.ts`, + `webgl/types.ts`, `webgl/renderer/point-locations.ts` — the new uniform, and an explicit atlas + height replacing the `labelColorDataLength / 4 / width` derivation. +- `packages/core/src/components/scatter-plot/scatter-plot.events.ts` — new; a + `renderer-degraded` host message mirroring `control-bar.events.ts:14-30`. +- `apps/web/src/explore/runtime.ts`, `apps/web/src/explore/notifications.ts` — register and map the + new message to warning copy naming the limit. +- `packages/core/src/components/scatter-plot/webgl-render-perf.ts` — `maxTextureSize` added to the + collected metadata, so the next perf sweep measures the real device distribution instead of + citing a third-party survey. +- `packages/core/src/test-support/mock-webgl2.ts` — gains `getParameter`, `getError`, the four error + constants, and recording `texImage2D`/`texSubImage2D`/`uniform1i`. Lands as its own commit: every + renderer suite depends on it. +- No bundle-format, CLI or Python change. No PyPI release: `protspace-release.yml` is path-filtered + to `apps/protspace/**`, untouched here. diff --git a/openspec/changes/archive/2026-08-15-bound-label-atlas-to-device-limits/specs/point-visibility/spec.md b/openspec/changes/archive/2026-08-15-bound-label-atlas-to-device-limits/specs/point-visibility/spec.md new file mode 100644 index 000000000..b61ae93cb --- /dev/null +++ b/openspec/changes/archive/2026-08-15-bound-label-atlas-to-device-limits/specs/point-visibility/spec.md @@ -0,0 +1,34 @@ +## ADDED Requirements + +### Requirement: Rendered slice count is bounded by renderer capability, not by visibility + +The display-state model SHALL NOT account for how many colour segments a multi-value point is drawn +with: segment count is `min(distinct visible colours, effective atlas stride)` and is a renderer +capability constraint, evaluated in the renderer, in the same class as `isPointRendered`. A +reduction in segment count SHALL NOT change any point's opacity, interactivity, or membership in +plot data, and SHALL NOT cause axes to re-fit. + +#### Scenario: A fidelity reduction does not change visibility + +- **WHEN** the renderer reduces the label atlas stride to fit a device limit +- **THEN** every point keeps the opacity and interactivity the model assigns it +- **AND** plot data and the scale domains are unchanged + +#### Scenario: The multilabel hidden rule is evaluated before the stride bound + +- **WHEN** a point has values A and B, only A is hidden, and the effective stride is two +- **THEN** the point remains visible with B's colour, exactly as at full stride + +### Requirement: The multi-label allocation gate is storage-shaped, not colour-shaped + +Any decision about whether multi-label rendering resources are required SHALL be derived from the +annotation's stored value cardinality, not from the colours a point currently resolves to. Deriving +it from post-hide colours would let the all-but-one-hidden case (where every multi-value point +resolves to a single colour) retract resources that a subsequent un-hide needs immediately. + +#### Scenario: Hiding all but one value does not retract multi-label resources + +- **WHEN** every value but one of a multi-label annotation is hidden, so no point resolves to more + than one colour +- **THEN** the annotation is still classified as multi-label +- **AND** un-hiding a value renders segmented markers without a resource rebuild diff --git a/openspec/changes/archive/2026-08-15-bound-label-atlas-to-device-limits/specs/renderer-capability-limits/spec.md b/openspec/changes/archive/2026-08-15-bound-label-atlas-to-device-limits/specs/renderer-capability-limits/spec.md new file mode 100644 index 000000000..4de638ae7 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-bound-label-atlas-to-device-limits/specs/renderer-capability-limits/spec.md @@ -0,0 +1,156 @@ +## ADDED Requirements + +### Requirement: The renderer SHALL measure the device texture limit rather than assume it + +The renderer SHALL query `gl.MAX_TEXTURE_SIZE` once per WebGL context and SHALL size every +capacity-derived texture within the reported limit. It SHALL NOT query it per frame or per +populate. When the query returns a value that is not a finite positive number, the renderer SHALL +fall back to the WebGL2 specification floor of 2048 rather than proceeding unbounded. + +#### Scenario: A device reporting the specification floor loads the shipped bundle + +- **WHEN** a device reports `MAX_TEXTURE_SIZE` of 2048 and a 573,649-protein bundle is loaded with a + multi-label annotation selected +- **THEN** the label atlas is allocated within 2048 in both dimensions +- **AND** no `INVALID_VALUE` or `INVALID_OPERATION` is raised + +#### Scenario: A device with ample limits keeps its existing layout + +- **WHEN** a device reports `MAX_TEXTURE_SIZE` of 4096 or more and the same bundle is loaded +- **THEN** the atlas geometry is unchanged from the geometry that device allocated before this + requirement existed + +#### Scenario: The limit is read once per context + +- **WHEN** a scatter-plot renders repeatedly against one WebGL context +- **THEN** the device limit is queried once, at context acquisition, and not during rendering + +### Requirement: Buffer capacity SHALL be bounded by the renderer's own point cap + +The renderer SHALL bound planned buffer capacity by its maximum drawable point count, so that +geometric growth across reloads within a session cannot allocate for more points than the renderer +will ever draw. The bound SHALL NOT reduce capacity below the amount a single load actually +requires. + +#### Scenario: Geometric growth cannot overshoot the cap + +- **WHEN** a session loads a dataset just under the cap and then another slightly larger one, so + that 1.5x growth would exceed the cap +- **THEN** planned capacity is bounded at the cap rounded up to allocation granularity + +#### Scenario: A load larger than the cap is not starved + +- **WHEN** capacity is planned for a point count above the cap +- **THEN** the planner returns enough capacity for that point count rather than the cap + +### Requirement: The renderer SHALL reduce marker fidelity rather than point coverage + +The renderer SHALL reduce the number of label slices per point, and SHALL NOT reduce the number of +points drawn, hoverable or exported, when the label atlas cannot be allocated at full fidelity +within the device limit. Slice count SHALL NOT fall below two, so a two-label point always renders +both of its hues. + +#### Scenario: A constrained device draws every point + +- **WHEN** the atlas must be planned at reduced stride to fit the device limit +- **THEN** every point in the dataset is still staged, drawn, hit-testable and exported + +#### Scenario: A two-label point keeps both hues + +- **WHEN** a point carries exactly two annotation values on a device at the reduced-fidelity floor +- **THEN** the point renders as a two-segment marker with both colours + +#### Scenario: Fidelity is never reduced on a device that does not require it + +- **WHEN** the atlas fits at full stride within the device limit +- **THEN** the full slice count is used and no reduction is reported + +### Requirement: A point SHALL NOT render in a colour that is not its own + +The renderer SHALL render a multi-label point in its dominant colour whenever the label atlas is +absent, incomplete, or does not cover that point's index, and SHALL NOT sample atlas storage +outside the region staged for that point. A point's staged label count SHALL NOT exceed the +effective slice stride, and the shader SHALL clamp the computed slice index to the last slice of +that point. + +#### Scenario: No atlas is available + +- **WHEN** the atlas could not be allocated and a multi-label annotation is selected +- **THEN** each affected point renders in its dominant colour rather than black or an unrelated + colour + +#### Scenario: A point carries more values than the stride + +- **WHEN** a point has more distinct colours than the effective stride +- **THEN** it renders exactly `stride` segments drawn from its own colours, and never reads another + point's storage + +#### Scenario: The angular boundary of the marker + +- **WHEN** a fragment falls exactly on the angle where the normalised sweep reaches its upper bound +- **THEN** it samples the point's last slice rather than the following point's first + +### Requirement: A failed GPU allocation SHALL be detected and SHALL NOT be latched as success + +The renderer SHALL check `gl.getError()` after each allocating GPU upload — once per capacity +change, never per frame — and SHALL record an upload as initialised only when the check reports no +error. After a failed texture allocation it SHALL install a minimal placeholder that leaves the +sampler complete, and after a failed buffer allocation it SHALL leave the buffers uninitialised so +the next populate reallocates rather than writing into storage that does not exist. + +#### Scenario: An over-size texture allocation + +- **WHEN** a texture allocation exceeds what the driver accepts +- **THEN** the renderer does not mark the texture initialised, does not issue a partial update + against it on any later populate, and reports the degradation + +#### Scenario: A failed buffer allocation is retried, not compounded + +- **WHEN** an allocating buffer upload reports an error +- **THEN** the renderer leaves the buffer set uninitialised so the next populate reallocates it + +#### Scenario: The check does not run on the per-frame path + +- **WHEN** a populate updates existing buffers without changing capacity +- **THEN** no error query is issued + +### Requirement: A capability reduction SHALL reach the user, not only the console + +The renderer SHALL emit a host message when rendering capability is reduced or unavailable, +carrying the reason and the measured device limit, and the application SHALL surface it as a +warning. Each distinct reason SHALL be reported at most once per renderer instance. + +#### Scenario: Reduced marker fidelity is announced + +- **WHEN** the atlas is planned at reduced stride +- **THEN** a warning naming the device limit is surfaced once, and repeated renders do not repeat it + +#### Scenario: The gamma-pipeline fallback is announced on the same channel + +- **WHEN** the gamma-correct pipeline is unavailable and the renderer falls back to direct + rendering +- **THEN** the fallback is reported through the same host-message channel rather than only to the + console + +### Requirement: Exported images SHALL use the same marker fidelity as the live view + +The export renderer SHALL query its own context's texture limit and SHALL use a slice stride no +greater than the live renderer's, so an exported figure carries the same marker segmentation the +user saw on screen. Its declared maximum output dimension SHALL be the smaller of its own limit and +its configured maximum. + +#### Scenario: A figure matches the screen + +- **WHEN** the live view is rendering at reduced stride and the user exports an image +- **THEN** the exported markers use the same stride + +#### Scenario: The live view has no atlas + +- **WHEN** the live renderer has no label atlas +- **THEN** the export allocates none either and renders dominant colours + +#### Scenario: The declared export dimension limit is truthful + +- **WHEN** a device reports a texture limit below the configured maximum export dimension +- **THEN** the export's enforced maximum is the device's limit, and its rejection message names the + limit actually enforced diff --git a/openspec/changes/archive/2026-08-15-bound-label-atlas-to-device-limits/tasks.md b/openspec/changes/archive/2026-08-15-bound-label-atlas-to-device-limits/tasks.md new file mode 100644 index 000000000..5259327ba --- /dev/null +++ b/openspec/changes/archive/2026-08-15-bound-label-atlas-to-device-limits/tasks.md @@ -0,0 +1,134 @@ +## 1. Extend the mock WebGL2 context (own commit — every renderer suite depends on it) + +- [x] 1.1 Add the constants `MAX_TEXTURE_SIZE` (0x0d33), `NO_ERROR` (0), `INVALID_VALUE` (0x0501), + `INVALID_OPERATION` (0x0502), `OUT_OF_MEMORY` (0x0505) to `test-support/mock-webgl2.ts` +- [x] 1.2 Add `getParameter(pname)` returning `opts.maxTextureSize ?? 8192` for `MAX_TEXTURE_SIZE` +- [x] 1.3 Add `getError()` shifting from an `opts.glErrors: number[]` queue, then `NO_ERROR` +- [x] 1.4 Make `texImage2D`, `texSubImage2D` and `uniform1i` recording `vi.fn()`s rather than no-ops. + Also `getParameter`, `bufferData`, and `bufferSubData` — the last was **absent entirely**, so no + test had ever exercised the already-initialised upload path. `deleteTexture` stays a plain noop + because `webgl-renderer.lifecycle.test.ts` wraps it with `vi.spyOn`. +- [x] 1.5 Run the full renderer suite unchanged and confirm green before touching any source + (23 files, 115 tests, all passing) + +## 2. Plan atlas geometry in a pure module + +- [x] 2.1 Add `webgl/renderer/label-atlas-plan.ts` with `MAX_LABELS`, the width ladder + `[2048, 4096, 8192]`, the stride ladder `[8, 4, 2]`, `LabelAtlasPlan`, and `planLabelAtlas` +- [x] 2.2 Iterate stride outer, width inner, smallest width first; return `null` when nothing fits +- [x] 2.3 Re-export `MAX_LABELS` from `stage-point.ts` so existing importers are unaffected +- [x] 2.4 Add `label-atlas-plan.test.ts` including the no-change lock + `planLabelAtlas(573_696, 8192) === { width: 2048, height: 2241, stride: 8 }` +- [x] 2.5 Add the table-driven invariant over `MTS × capacity` +- [x] 2.6 **Added during implementation:** an optional `maxStride` argument, so the export renderer + inherits the live view's fidelity by _planning at that stride_ rather than by planning at full + stride and then capping. Capping after the fact would have sized the texture for slices it + then refused to draw. Covered by a `stride inheritance` describe block. + +## 3. Clamp planned capacity + +- [x] 3.1 Optional fifth `maxCapacity` argument, floored at the snapped requirement +- [x] 3.2 Rename the fourth parameter to `capacityGranularity` and rewrite the doc block +- [x] 3.3 Pass `MAX_POINTS_DIRECT_RENDER` from `expandCapacity` +- [x] 3.4 All seven existing `capacity-planner.test.ts` cases pass **unchanged** +- [x] 3.5 Add the clamp cases, including `(950_000, 900_096, 1024, 256, 1_000_000) === 1_000_192` + against the unbounded `1_350_144`, plus an inertness case for the default argument + +## 4. Clamp the staged label count + +- [x] 4.1 Add `maxLabels: number` to `StagePointArrays` and make `labelColorData` nullable +- [x] 4.2 `stagePointStyle` writes `Math.min(pointColors.length, target.maxLabels)` +- [x] 4.3 Skip `fillLabelColorTexels` entirely when `labelColorData` is null +- [x] 4.4 `buildStageArrays` supplies `maxLabels: this.labelAtlas?.stride ?? MAX_LABELS` +- [x] 4.5 Tests for the 12-colour clamp, a reduced stride, the null atlas, and the single-label case + +## 5. Fix and gate the fragment shader + +- [x] 5.1 Add `precision highp int;` +- [x] 5.2 Declare `uniform int u_labelAtlasCapacity` and gate the pie branch on it +- [x] 5.3 Clamp `count` to `float(u_maxLabels)` and `sliceIndex` to `count - 1.0` +- [x] 5.4 Add `labelAtlasCapacity` to `PointUniformLocations`, resolve it, and push it +- [x] 5.5 Take `labelTextureHeight` explicitly, replacing the array-length derivation +- [x] 5.6 Extend `export-shaders.test.ts` to pin all four shader edits by text + +## 6. Rework the live renderer + +- [x] 6.1 Probe `gl.MAX_TEXTURE_SIZE` in `ensureGL`, falling back to the 2048 spec floor +- [x] 6.2 Replace the `labelColorData` field with the plan, a nullable array, a disabled flag, and a + `degradeReported` reason latch +- [x] 6.3 Clear all four in `resetRendererState` +- [x] 6.4 Delete the inline allocation from `expandCapacity` +- [x] 6.5 Add `syncLabelAtlas`, called at the top of `populateBuffers` after `expandCapacity` +- [x] 6.6 Add `uploadLabelAtlas` with one `getError` and the 1x1 placeholder fallback +- [x] 6.7 Add the buffer `getError` on the allocating path only, before any texture call +- [x] 6.8 Hoist the six style-buffer uploads to `if (updateStyles || needsReorder)` +- [x] 6.9 Pass the plan-derived draw params from `renderPoints` + +## 7. Repair the export path + +- [x] 7.1 Probe the export context's own `MAX_TEXTURE_SIZE` +- [x] 7.2 Inherit the live stride; allocate no atlas when the live view has none +- [x] 7.3 Apply `getError` after the export `texImage2D`; fall back to flat marks and still export +- [x] 7.4 `MAX_DIMENSION` becomes `Math.min(8192, deviceMaxTextureSize)`; message names the limit + actually enforced +- [x] 7.5 Delete the local `LABEL_TEXTURE_WIDTH` and the false reuse comment + +## 8. Surface degradation to the user + +- [x] 8.1 Add `scatter-plot.events.ts` with `RendererDegradedDetail` and its factory; export from + `packages/core/src/index.ts` +- [x] 8.2 Add an `onDegraded` renderer callback wired in `_createWebglRenderer`; the host dispatches + `bubbles: true, composed: true` +- [x] 8.3 Route `handleGammaFallback` through the same channel +- [x] 8.4 Register in `runtime.ts`; map reason to copy in `notifications.ts` with a `dedupeKey` and + a report action +- [x] 8.5 Add `maxTextureSize` to `_collectPerfMetadata` + +## 9. Mock-GL unit coverage + +- [x] 9.1 `getParameter(MAX_TEXTURE_SIZE)` called exactly once per `ensureGL`, never during `render()` +- [x] 9.2 At `maxTextureSize: 8192`, Swiss-Prot-scale data calls `texImage2D` with `(2048, 2241)` +- [x] 9.3 At `maxTextureSize: 2048`, every allocation is within the limit and the reported stride is 4. + The uniform push itself is asserted in `render-target.test.ts` (9.8) rather than here, where the + mock's shared uniform recorder cannot attribute a value to a location. +- [x] 9.4 Failure is not latched: no later `texSubImage2D`, a `(1,1)` placeholder follows, and + `onDegraded` fires exactly once across five renders +- [x] 9.5 A buffer allocation error leaves `buffersInitialized` false so the next populate reallocates +- [x] 9.6 A positions-only restage still uploads the style buffers (the hoist) +- [x] 9.7 Export inherits the passed stride and allocates none when it is null + (`label-atlas-plan.test.ts` stride-inheritance block); export enforces and _names_ the device + limit (`export-renderer.test.ts`). The off-screen GL pipeline itself is unreachable in jsdom — + `getContext('webgl2')` returns null — so its assertions stop at the seams. +- [x] 9.8 `point-locations.test.ts` resolves the new uniform; `render-target.test.ts` asserts it is + pushed, and that 0 is pushed when no atlas is allocated +- [x] 9.9 `webgl-renderer.lifecycle.test.ts` `deleteTexture` count unchanged +- [x] 9.10 `label-texture-utils.test.ts` gains a table-driven case pinning the JS byte offset against + the shader's `(globalIndex % width, globalIndex / width)` at every supported width and stride + +## 10. Playwright — the only layer that proves it + +- [x] 10.1 Add `apps/web/tests/label-atlas-limit.spec.ts` to the default suite, with its own project + entry in `playwright.config.ts`. + **Changed from the plan:** a `getParameter` stub alone proves nothing — it changes what the app + believes while the real driver still accepts the allocation, so the pre-fix code passes. The + driver's refusal is therefore simulated too, in `helpers/gl-simulation.ts`. And the limit is + 1024, not 2048: at ~7.8K demo proteins the atlas is 2048x31, which no realistic limit refuses. +- [x] 10.2 Assert the renderer never issues an allocation the device would refuse. **Verified red on + the parent commit:** `renderer issued texture allocations the device refuses: [[2048,31]]`. +- [x] 10.3 Assert the warning is surfaced. **Also verified red on the parent commit** — the pre-fix + failure is entirely silent, which is why the console-based assertion in the original plan would + have passed on broken code. +- [x] 10.4 Assert the canvas renders more than one colour and is not solid black +- [x] 10.5 Assert the same simulation at an ample limit records no refusal and raises no warning — + so the simulation itself cannot be what fails the tests above +- [x] 10.6 Add the reduced-stride case to `load-large-bundle.spec.ts` (opt-in): at 573,649 proteins + the atlas is 2048x2241, the exact case the issue reported, on the dataset the app ships + +## 11. Ship + +- [x] 11.1 `pnpm precommit`, `pnpm format:check`, `pnpm test` all green (2,270 unit tests) +- [x] 11.2 `pnpm test:e2e` green — 121 passed, no regressions +- [x] 11.3 `openspec validate --strict` passes for this change +- [x] 11.4 Reread `proposal.md` / `design.md` against the final diff, tick every task, run + `/opsx:archive` on the branch +- [x] 11.5 Open the PR against `main`; merge or rebase — never squash diff --git a/openspec/specs/point-visibility/spec.md b/openspec/specs/point-visibility/spec.md index af43a68d6..145df870a 100644 --- a/openspec/specs/point-visibility/spec.md +++ b/openspec/specs/point-visibility/spec.md @@ -164,3 +164,36 @@ reactive churn. - **WHEN** a test constructs the element without attaching it and calls `_buildStyleGetters()` directly - **THEN** style getters reflect the element's current filter/hidden/selection inputs + +### Requirement: Rendered slice count is bounded by renderer capability, not by visibility + +The display-state model SHALL NOT account for how many colour segments a multi-value point is drawn +with: segment count is `min(distinct visible colours, effective atlas stride)` and is a renderer +capability constraint, evaluated in the renderer, in the same class as `isPointRendered`. A +reduction in segment count SHALL NOT change any point's opacity, interactivity, or membership in +plot data, and SHALL NOT cause axes to re-fit. + +#### Scenario: A fidelity reduction does not change visibility + +- **WHEN** the renderer reduces the label atlas stride to fit a device limit +- **THEN** every point keeps the opacity and interactivity the model assigns it +- **AND** plot data and the scale domains are unchanged + +#### Scenario: The multilabel hidden rule is evaluated before the stride bound + +- **WHEN** a point has values A and B, only A is hidden, and the effective stride is two +- **THEN** the point remains visible with B's colour, exactly as at full stride + +### Requirement: The multi-label allocation gate is storage-shaped, not colour-shaped + +Any decision about whether multi-label rendering resources are required SHALL be derived from the +annotation's stored value cardinality, not from the colours a point currently resolves to. Deriving +it from post-hide colours would let the all-but-one-hidden case (where every multi-value point +resolves to a single colour) retract resources that a subsequent un-hide needs immediately. + +#### Scenario: Hiding all but one value does not retract multi-label resources + +- **WHEN** every value but one of a multi-label annotation is hidden, so no point resolves to more + than one colour +- **THEN** the annotation is still classified as multi-label +- **AND** un-hiding a value renders segmented markers without a resource rebuild diff --git a/openspec/specs/renderer-capability-limits/spec.md b/openspec/specs/renderer-capability-limits/spec.md new file mode 100644 index 000000000..b635cd4dd --- /dev/null +++ b/openspec/specs/renderer-capability-limits/spec.md @@ -0,0 +1,169 @@ +# renderer-capability-limits Specification + +## Purpose + +What the WebGL renderer guarantees about the limits of the device it is running on: that it +measures them rather than assuming them, that it detects an allocation the driver refuses instead +of latching it as success, that it degrades marker fidelity rather than point coverage when a +resource will not fit, that a point is never drawn in a colour that is not its own, and that a +capability reduction reaches the user rather than only the console. + +The concern is distinct from `point-visibility`, which governs what the data says should be shown. +This capability governs what the hardware can actually deliver, and how the gap is handled. + +## Requirements + +### Requirement: The renderer SHALL measure the device texture limit rather than assume it + +The renderer SHALL query `gl.MAX_TEXTURE_SIZE` once per WebGL context and SHALL size every +capacity-derived texture within the reported limit. It SHALL NOT query it per frame or per +populate. When the query returns a value that is not a finite positive number, the renderer SHALL +fall back to the WebGL2 specification floor of 2048 rather than proceeding unbounded. + +#### Scenario: A device reporting the specification floor loads the shipped bundle + +- **WHEN** a device reports `MAX_TEXTURE_SIZE` of 2048 and a 573,649-protein bundle is loaded with a + multi-label annotation selected +- **THEN** the label atlas is allocated within 2048 in both dimensions +- **AND** no `INVALID_VALUE` or `INVALID_OPERATION` is raised + +#### Scenario: A device with ample limits keeps its existing layout + +- **WHEN** a device reports `MAX_TEXTURE_SIZE` of 4096 or more and the same bundle is loaded +- **THEN** the atlas geometry is unchanged from the geometry that device allocated before this + requirement existed + +#### Scenario: The limit is read once per context + +- **WHEN** a scatter-plot renders repeatedly against one WebGL context +- **THEN** the device limit is queried once, at context acquisition, and not during rendering + +### Requirement: Buffer capacity SHALL be bounded by the renderer's own point cap + +The renderer SHALL bound planned buffer capacity by its maximum drawable point count, so that +geometric growth across reloads within a session cannot allocate for more points than the renderer +will ever draw. The bound SHALL NOT reduce capacity below the amount a single load actually +requires. + +#### Scenario: Geometric growth cannot overshoot the cap + +- **WHEN** a session loads a dataset just under the cap and then another slightly larger one, so + that 1.5x growth would exceed the cap +- **THEN** planned capacity is bounded at the cap rounded up to allocation granularity + +#### Scenario: A load larger than the cap is not starved + +- **WHEN** capacity is planned for a point count above the cap +- **THEN** the planner returns enough capacity for that point count rather than the cap + +### Requirement: The renderer SHALL reduce marker fidelity rather than point coverage + +The renderer SHALL reduce the number of label slices per point, and SHALL NOT reduce the number of +points drawn, hoverable or exported, when the label atlas cannot be allocated at full fidelity +within the device limit. Slice count SHALL NOT fall below two, so a two-label point always renders +both of its hues. + +#### Scenario: A constrained device draws every point + +- **WHEN** the atlas must be planned at reduced stride to fit the device limit +- **THEN** every point in the dataset is still staged, drawn, hit-testable and exported + +#### Scenario: A two-label point keeps both hues + +- **WHEN** a point carries exactly two annotation values on a device at the reduced-fidelity floor +- **THEN** the point renders as a two-segment marker with both colours + +#### Scenario: Fidelity is never reduced on a device that does not require it + +- **WHEN** the atlas fits at full stride within the device limit +- **THEN** the full slice count is used and no reduction is reported + +### Requirement: A point SHALL NOT render in a colour that is not its own + +The renderer SHALL render a multi-label point in its dominant colour whenever the label atlas is +absent, incomplete, or does not cover that point's index, and SHALL NOT sample atlas storage +outside the region staged for that point. A point's staged label count SHALL NOT exceed the +effective slice stride, and the shader SHALL clamp the computed slice index to the last slice of +that point. + +#### Scenario: No atlas is available + +- **WHEN** the atlas could not be allocated and a multi-label annotation is selected +- **THEN** each affected point renders in its dominant colour rather than black or an unrelated + colour + +#### Scenario: A point carries more values than the stride + +- **WHEN** a point has more distinct colours than the effective stride +- **THEN** it renders exactly `stride` segments drawn from its own colours, and never reads another + point's storage + +#### Scenario: The angular boundary of the marker + +- **WHEN** a fragment falls exactly on the angle where the normalised sweep reaches its upper bound +- **THEN** it samples the point's last slice rather than the following point's first + +### Requirement: A failed GPU allocation SHALL be detected and SHALL NOT be latched as success + +The renderer SHALL check `gl.getError()` after each allocating GPU upload — once per capacity +change, never per frame — and SHALL record an upload as initialised only when the check reports no +error. After a failed texture allocation it SHALL install a minimal placeholder that leaves the +sampler complete, and after a failed buffer allocation it SHALL leave the buffers uninitialised so +the next populate reallocates rather than writing into storage that does not exist. + +#### Scenario: An over-size texture allocation + +- **WHEN** a texture allocation exceeds what the driver accepts +- **THEN** the renderer does not mark the texture initialised, does not issue a partial update + against it on any later populate, and reports the degradation + +#### Scenario: A failed buffer allocation is retried, not compounded + +- **WHEN** an allocating buffer upload reports an error +- **THEN** the renderer leaves the buffer set uninitialised so the next populate reallocates it + +#### Scenario: The check does not run on the per-frame path + +- **WHEN** a populate updates existing buffers without changing capacity +- **THEN** no error query is issued + +### Requirement: A capability reduction SHALL reach the user, not only the console + +The renderer SHALL emit a host message when rendering capability is reduced or unavailable, +carrying the reason and the measured device limit, and the application SHALL surface it as a +warning. Each distinct reason SHALL be reported at most once per renderer instance. + +#### Scenario: Reduced marker fidelity is announced + +- **WHEN** the atlas is planned at reduced stride +- **THEN** a warning naming the device limit is surfaced once, and repeated renders do not repeat it + +#### Scenario: The gamma-pipeline fallback is announced on the same channel + +- **WHEN** the gamma-correct pipeline is unavailable and the renderer falls back to direct + rendering +- **THEN** the fallback is reported through the same host-message channel rather than only to the + console + +### Requirement: Exported images SHALL use the same marker fidelity as the live view + +The export renderer SHALL query its own context's texture limit and SHALL use a slice stride no +greater than the live renderer's, so an exported figure carries the same marker segmentation the +user saw on screen. Its declared maximum output dimension SHALL be the smaller of its own limit and +its configured maximum. + +#### Scenario: A figure matches the screen + +- **WHEN** the live view is rendering at reduced stride and the user exports an image +- **THEN** the exported markers use the same stride + +#### Scenario: The live view has no atlas + +- **WHEN** the live renderer has no label atlas +- **THEN** the export allocates none either and renders dominant colours + +#### Scenario: The declared export dimension limit is truthful + +- **WHEN** a device reports a texture limit below the configured maximum export dimension +- **THEN** the export's enforced maximum is the device's limit, and its rejection message names the + limit actually enforced diff --git a/packages/core/src/components/scatter-plot/scatter-plot.events.ts b/packages/core/src/components/scatter-plot/scatter-plot.events.ts new file mode 100644 index 000000000..ba4204037 --- /dev/null +++ b/packages/core/src/components/scatter-plot/scatter-plot.events.ts @@ -0,0 +1,77 @@ +import type { HostMessageEventDetail } from '../../events'; + +/** + * Why the renderer is running below full capability. + * + * Every one of these was previously either silent or console-only, on hardware + * that never said anything was out of range. + */ +export type RendererDegradedReason = + /** The label atlas had to drop slices per point to fit `gl.MAX_TEXTURE_SIZE`. */ + | 'reduced-label-detail' + /** No atlas geometry fits the device at all; markers render in dominant colours. */ + | 'label-atlas-unsupported' + /** The driver refused the atlas allocation (`INVALID_VALUE` or equivalent). */ + | 'label-atlas-allocation-failed' + /** The driver reported out-of-memory allocating the atlas. */ + | 'label-atlas-out-of-memory' + /** An allocating vertex-buffer upload failed; the atlas is released to retry smaller. */ + | 'point-buffer-allocation-failed' + /** The gamma-correct pipeline is unavailable, so blending happens in sRGB. */ + | 'gamma-pipeline-unavailable'; + +export interface RendererDegradedContext { + reason: RendererDegradedReason; + /** `gl.MAX_TEXTURE_SIZE` as reported by the device. */ + maxTextureSize: number; + /** Label slices per marker now in effect; 0 when no atlas is allocated. */ + stride: number; + /** + * Points the renderer had capacity allocated for when the reduction took + * effect. This is the number the atlas is sized from, so it is what explains + * the reduction — it is the session's high-water capacity, which can exceed + * the currently loaded dataset. + */ + pointCount: number; + /** Free-text detail for reasons that carry one (e.g. the gamma fallback's cause). */ + detail?: string; +} + +export interface RendererDegradedDetail extends HostMessageEventDetail< + 'scatter-plot', + 'warning', + RendererDegradedContext +> {} + +const MESSAGES: Record string> = { + 'reduced-label-detail': (c) => + `Multi-value markers show up to ${c.stride} segments instead of 8: this device's maximum ` + + `texture size (${c.maxTextureSize}) cannot hold the full colour table for ${c.pointCount.toLocaleString()} points. ` + + `Every point is still drawn.`, + 'label-atlas-unsupported': (c) => + `Multi-value markers show a single dominant colour: this device's maximum texture size ` + + `(${c.maxTextureSize}) cannot hold a colour table for ${c.pointCount.toLocaleString()} points. Every point is still drawn.`, + 'label-atlas-allocation-failed': () => + 'The graphics driver refused the multi-value colour table. Markers show a single dominant colour; every point is still drawn.', + 'label-atlas-out-of-memory': () => + 'The graphics driver ran out of memory for the multi-value colour table. Markers show a single dominant colour; every point is still drawn.', + 'point-buffer-allocation-failed': (c) => + `The graphics driver refused to allocate memory for ${c.pointCount.toLocaleString()} points. ` + + `Multi-value markers now show a single dominant colour to free memory, and rendering will retry.`, + 'gamma-pipeline-unavailable': (c) => + 'Colour blending is running in sRGB rather than linear light, so overlapping points may look slightly darker than intended.' + + // The cause is the only actionable part of this one — without it the message + // says a pipeline is missing but not which capability the device lacks. + (c.detail ? ` (${c.detail})` : ''), +}; + +export function createRendererDegradedDetail( + context: RendererDegradedContext, +): RendererDegradedDetail { + return { + message: MESSAGES[context.reason](context), + severity: 'warning', + source: 'scatter-plot', + context, + }; +} diff --git a/packages/core/src/components/scatter-plot/scatter-plot.legend-reactivity.test.ts b/packages/core/src/components/scatter-plot/scatter-plot.legend-reactivity.test.ts index bf72efdd6..11ed45bf7 100644 --- a/packages/core/src/components/scatter-plot/scatter-plot.legend-reactivity.test.ts +++ b/packages/core/src/components/scatter-plot/scatter-plot.legend-reactivity.test.ts @@ -47,7 +47,7 @@ * driven DIRECTLY (not via dispatchEvent), matching the sibling tests that call * private handlers directly. */ -import { vi, describe, it, expect, afterEach } from 'vitest'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import type { VisualizationData } from '@protspace/utils'; vi.hoisted(() => { @@ -86,6 +86,16 @@ function makeData(): VisualizationData { type WebglStub = { invalidateDepthOrder: ReturnType; invalidateStyleCache: ReturnType; + /** + * Not asserted on, but required: `_scheduleNumericAnnotationRefresh` queues a + * requestAnimationFrame whose callback reaches `setStyleSignature`. That fires + * after the test that scheduled it has finished, so a missing method throws + * from inside jsdom's frame callback — outside any test's scope, where it + * becomes an unhandled error that fails `vitest --run` while every assertion + * still passes. Whether the frame lands before teardown is a timing race, so + * the stub has to cover the whole path, not just the calls under test. + */ + setStyleSignature: ReturnType; }; type Internals = HTMLElement & { @@ -116,6 +126,7 @@ function makeEl(): Internals { (el as unknown as { _webglRenderer: WebglStub })._webglRenderer = { invalidateDepthOrder: vi.fn(), invalidateStyleCache: vi.fn(), + setStyleSignature: vi.fn(), }; return el; } @@ -127,7 +138,26 @@ function zOrderEvent(detail: unknown): Event { return new CustomEvent('legend-zorder-change', { detail }); } -afterEach(() => vi.restoreAllMocks()); +// `_scheduleNumericAnnotationRefresh` queues a requestAnimationFrame. Every +// assertion in this file is synchronous and none wants that frame's body — but +// jsdom runs it after the scheduling test returns, against the deliberately +// minimal `_plotData` and renderer stubs here, and it throws from inside the +// frame callback where no test can catch it. That is an unhandled error, which +// fails `vitest --run` even though every assertion passed. Whether the frame +// lands before teardown is a timing race, so it surfaced as an intermittent CI +// failure rather than a consistent one. +// +// Holding the callbacks unrun keeps the file to the synchronous, never-connected +// contract its header describes. +beforeEach(() => { + vi.stubGlobal('requestAnimationFrame', () => 1); + vi.stubGlobal('cancelAnimationFrame', () => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); describe('legend mapping handlers — single render path (F-31)', () => { it('z-order change renders once imperatively and schedules NO second (Lit) render', () => { diff --git a/packages/core/src/components/scatter-plot/scatter-plot.ts b/packages/core/src/components/scatter-plot/scatter-plot.ts index efa281f2b..1e57be9f0 100644 --- a/packages/core/src/components/scatter-plot/scatter-plot.ts +++ b/packages/core/src/components/scatter-plot/scatter-plot.ts @@ -40,6 +40,7 @@ import { computeVisibilityModel } from './styling/visibility-model'; import type { VisibilityModel } from './styling/visibility-model'; import { MAX_POINTS_DIRECT_RENDER, WebGLRenderer, computeSizeScaleFactor } from './webgl'; import { resolveColor } from './webgl/color-utils'; +import type { RendererDegradedDetail } from './scatter-plot.events'; import { QuadtreeIndex } from './interaction/quadtree-index'; import { computeViewportWindow, buildViewKey } from './duplicate-stacks/duplicate-stack-viewport'; import { DuplicateStackOverlayController } from './duplicate-stacks/duplicate-stack-overlay-controller'; @@ -502,6 +503,14 @@ export class ProtspaceScatterplot extends LitElement { }, this._handleWebglContextLost, () => resolveColor(getComputedStyle(this).backgroundColor), + (detail) => + this.dispatchEvent( + new CustomEvent('renderer-degraded', { + detail, + bubbles: true, + composed: true, + }), + ), ); this._updateStyleSignature(); this._webglRenderer.setStyleSignature(this._styleSig); diff --git a/packages/core/src/components/scatter-plot/webgl-render-perf.ts b/packages/core/src/components/scatter-plot/webgl-render-perf.ts index 9b22f6f74..0ad28ac1f 100644 --- a/packages/core/src/components/scatter-plot/webgl-render-perf.ts +++ b/packages/core/src/components/scatter-plot/webgl-render-perf.ts @@ -385,6 +385,11 @@ export class WebglRenderPerfRunner { shadingLanguageVersion: gl.getParameter(gl.SHADING_LANGUAGE_VERSION), vendor: gl.getParameter(gl.VENDOR), renderer: gl.getParameter(gl.RENDERER), + // Bounds the label atlas, and therefore the largest dataset that can be + // drawn with full multi-value markers. Recorded so the cross-device perf + // corpus becomes evidence about the real distribution of this limit, + // which we currently have none of. + maxTextureSize: gl.getParameter(gl.MAX_TEXTURE_SIZE), }; const debugExt = gl.getExtension('WEBGL_debug_renderer_info') as { UNMASKED_VENDOR_WEBGL: number; diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/capacity-planner.test.ts b/packages/core/src/components/scatter-plot/webgl/renderer/capacity-planner.test.ts index 988a5176a..6e38403ff 100644 --- a/packages/core/src/components/scatter-plot/webgl/renderer/capacity-planner.test.ts +++ b/packages/core/src/components/scatter-plot/webgl/renderer/capacity-planner.test.ts @@ -3,10 +3,12 @@ import { planRendererCapacity } from './capacity-planner'; const FLOOR = 1024; const ROW = 256; +/** The pre-clamp behaviour the seven original cases below lock in. */ +const UNBOUNDED = Number.POSITIVE_INFINITY; describe('planRendererCapacity', () => { it('first load 573230, current 0 → 573440 (NOT next pow2 1048576)', () => { - const result = planRendererCapacity(573230, 0, FLOOR, ROW); + const result = planRendererCapacity(573230, 0, FLOOR, ROW, UNBOUNDED); expect(result).toBe(573440); expect(result).not.toBe(1048576); }); @@ -14,34 +16,34 @@ describe('planRendererCapacity', () => { it('result is always a multiple of 256', () => { const inputs = [1, 100, 573230, 700000, 1000000, 1500000, 256, 512, 1024]; for (const n of inputs) { - const result = planRendererCapacity(n, 0, FLOOR, ROW); + const result = planRendererCapacity(n, 0, FLOOR, ROW, UNBOUNDED); expect(result % ROW).toBe(0); } }); it('below floor: minCapacity 100, current 0 → 1024 (floor, multiple of 256)', () => { - const result = planRendererCapacity(100, 0, FLOOR, ROW); + const result = planRendererCapacity(100, 0, FLOOR, ROW, UNBOUNDED); expect(result).toBe(1024); expect(result % ROW).toBe(0); }); it('exact multiple at floor: minCapacity 512, current 0 → 1024 (floor wins, still multiple of 256)', () => { // 512 < FLOOR (1024), so floor wins; 1024 is already a multiple of 256 - const result = planRendererCapacity(512, 0, FLOOR, ROW); + const result = planRendererCapacity(512, 0, FLOOR, ROW, UNBOUNDED); expect(result).toBe(1024); expect(result % ROW).toBe(0); }); it('growth across reloads: minCapacity 700000, current 573440 → 860160', () => { // ceil(573440 * 1.5) = 860160, which is already a multiple of 256 - const result = planRendererCapacity(700000, 573440, FLOOR, ROW); + const result = planRendererCapacity(700000, 573440, FLOOR, ROW, UNBOUNDED); expect(result).toBe(860160); expect(result % ROW).toBe(0); }); it('large first load 1_500_000, current 0 → 1500160', () => { // ceil(1500000 / 256) * 256 = 5860 * 256 = 1500160 - const result = planRendererCapacity(1_500_000, 0, FLOOR, ROW); + const result = planRendererCapacity(1_500_000, 0, FLOOR, ROW, UNBOUNDED); expect(result).toBe(1500160); }); @@ -57,9 +59,30 @@ describe('planRendererCapacity', () => { [300000, 200000], ]; for (const [min, cur] of cases) { - const result = planRendererCapacity(min, cur, FLOOR, ROW); + const result = planRendererCapacity(min, cur, FLOOR, ROW, UNBOUNDED); expect(result).toBeGreaterThanOrEqual(min); expect(result).toBeGreaterThanOrEqual(FLOOR); } }); + + describe('maxCapacity bound', () => { + const CAP = 1_000_000; + + it('stops geometric growth overshooting the renderer cap', () => { + // Unbounded, 900,096 -> 1.5x = 1,350,144, whose label atlas needs 5274 rows and + // fails on a 4096 device at a point count well under a million. + expect(planRendererCapacity(950_000, 900_096, FLOOR, ROW, UNBOUNDED)).toBe(1_350_144); + expect(planRendererCapacity(950_000, 900_096, FLOOR, ROW, CAP)).toBe(1_000_192); + }); + + it('leaves an ordinary load untouched', () => { + expect(planRendererCapacity(573_649, 0, FLOOR, ROW, CAP)).toBe(573_696); + }); + + it('does not starve a load larger than the cap', () => { + // The bound is floored at the snapped requirement, so a caller asking for more + // than the cap still gets buffers big enough for what it asked for. + expect(planRendererCapacity(1_500_000, 0, FLOOR, ROW, CAP)).toBe(1_500_160); + }); + }); }); diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/capacity-planner.ts b/packages/core/src/components/scatter-plot/webgl/renderer/capacity-planner.ts index e5558028d..15318dd40 100644 --- a/packages/core/src/components/scatter-plot/webgl/renderer/capacity-planner.ts +++ b/packages/core/src/components/scatter-plot/webgl/renderer/capacity-planner.ts @@ -4,18 +4,33 @@ * - At least `minCapacityFloor` (MIN_CAPACITY). * - Across reloads (currentCapacity > 0), grow geometrically by 1.5x so progressively larger * datasets don't trigger a reallocation every time. - * - Rounded UP to a whole label-texture row (`pointsPerTextureRow` points) so the label texture - * (LABEL_TEXTURE_WIDTH wide, MAX_LABELS texels/point) has no partial-row waste — and so SoA - * arrays aren't oversized to the next power of two (which wasted ~83% at 573K). + * - Rounded UP to a whole `capacityGranularity` block, so SoA arrays aren't oversized to the next + * power of two (which wasted ~83% at 573K) and the label atlas has no partial-row waste at its + * narrowest supported width. + * - Bounded by `maxCapacity`, the largest point count the renderer will ever draw. Without this + * the 1.5x growth allocates for points that can never be rendered — and, because the label + * atlas is sized from capacity, pushes its height past `gl.MAX_TEXTURE_SIZE` at point counts + * well under the cap (900k then 950k used to plan 1,350,144). + * + * The bound never starves a load: it is floored at the snapped requirement, so asking for more + * than `maxCapacity` still returns enough capacity for the request. + * + * `maxCapacity` is deliberately required rather than defaulted to Infinity: an unbounded plan is + * the bug this function exists to prevent, so a caller that forgets it should not silently get one. */ export function planRendererCapacity( minCapacity: number, currentCapacity: number, minCapacityFloor: number, - pointsPerTextureRow: number, + capacityGranularity: number, + maxCapacity: number, ): number { + const snap = (value: number) => Math.ceil(value / capacityGranularity) * capacityGranularity; const required = Math.max(minCapacity, minCapacityFloor); const target = currentCapacity > 0 ? Math.max(required, Math.ceil(currentCapacity * 1.5)) : required; - return Math.ceil(target / pointsPerTextureRow) * pointsPerTextureRow; + // Clamp first, snap once: `snap` is monotone, so snapping the clamped value is + // identical to clamping the snapped ones — and this reads as the sentence the + // doc block above states. + return snap(Math.max(required, Math.min(target, maxCapacity))); } diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/export-renderer.gl.test.ts b/packages/core/src/components/scatter-plot/webgl/renderer/export-renderer.gl.test.ts new file mode 100644 index 000000000..0a430f346 --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/export-renderer.gl.test.ts @@ -0,0 +1,83 @@ +// @vitest-environment jsdom +/** + * The export path's GL allocation contract, against a mock context. + * + * `export-renderer.test.ts` deliberately covers only the pure-math seams, which + * need no context and run under `node`. These need one, so they live here. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import type { PlotData, ScatterplotConfig } from '@protspace/utils'; +import { ExportRenderer } from './export-renderer'; +import { createMockCanvas, type MockGLOptions } from './test-support/mock-webgl2'; + +/** + * `renderToCanvas` creates its own throwaway canvas, so the stub goes on the + * prototype rather than on an instance the test owns. + */ +function stubOffscreenGL(opts: MockGLOptions) { + const { gl } = createMockCanvas(opts); + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(((id: string) => + id === 'webgl2' ? gl : null) as HTMLCanvasElement['getContext']); +} + +function plotData(length: number): PlotData { + return { + length, + xs: new Float32Array(length), + ys: new Float32Array(length), + zs: null, + originalIndices: null, + proteinIds: new Array(length).fill('p'), + }; +} + +const config: ScatterplotConfig = { width: 800, height: 600 }; +const style = { + getColors: () => ['#ff0000'], + getPointSize: () => 6, + getShape: () => 'circle', + isPredicted: () => false, + getDepth: () => 0, + getOpacity: () => 1, +} as never; +const baseOptions = { + selectionActive: false, + transform: { x: 0, y: 0, k: 1 }, + gamma: 2.2, +} as never; + +function exportAt(points: number) { + return new ExportRenderer().renderToCanvas(plotData(points), config, style, { + width: 400, + height: 300, + ...baseOptions, + }); +} + +describe('ExportRenderer offscreen buffer allocation', () => { + afterEach(() => vi.restoreAllMocks()); + + it('throws rather than exporting a figure drawn from storage that was never allocated', () => { + // An out-of-memory bufferData raises into the GL error flag instead of + // throwing, so the export used to draw from buffers that do not exist and + // hand back a blank or half-populated PNG — saved, published, nothing logged. + stubOffscreenGL({ driverBufferByteLimit: 1_000 }); + expect(() => exportAt(5_000)).toThrow(/could not allocate memory for .* points/); + }); + + it('keys on the error flag, not merely on reaching the check', () => { + // Same path with no simulated limit. jsdom has no 2D context, so the export + // still fails at the final drawImage copy — which is after the guard, so a + // guard that fired unconditionally would surface the allocation message here. + stubOffscreenGL({}); + expect(() => exportAt(5_000)).not.toThrow(/could not allocate memory for .* points/); + }); + + it('does not blame the buffers for an error raised before the uploads', () => { + // The flag is sticky and context-wide: program compilation and linking run + // on this context first, so without a drain their failure would be reported + // as an out-of-memory point-buffer allocation. + stubOffscreenGL({ driverTextureLimit: 1 }); + expect(() => exportAt(5_000)).not.toThrow(/could not allocate memory for .* points/); + }); +}); diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/export-renderer.test.ts b/packages/core/src/components/scatter-plot/webgl/renderer/export-renderer.test.ts index 5172d8ab4..359305084 100644 --- a/packages/core/src/components/scatter-plot/webgl/renderer/export-renderer.test.ts +++ b/packages/core/src/components/scatter-plot/webgl/renderer/export-renderer.test.ts @@ -160,7 +160,7 @@ describe('ExportRenderer.renderToCanvas (guards)', () => { ).toThrow(/No points available/); }); - it('throws when a single export dimension exceeds the browser limit', () => { + it('throws when a single export dimension exceeds the device limit', () => { const pd = makePlotData([0, 1], [0, 1]); expect(() => renderer.renderToCanvas(pd, config, style, { @@ -168,7 +168,34 @@ describe('ExportRenderer.renderToCanvas (guards)', () => { height: 100, ...baseOptions, }), - ).toThrow(/exceed browser limit/); + ).toThrow(/exceed this device's limit/); + }); + + it('enforces the device texture limit, and names the limit it enforced', () => { + // MAX_DIMENSION alone was described as "the browser limit" but is a constant, + // so on a device below 8192 the message named a limit that was not the one + // being enforced — and the export failed later, inside the driver. + const pd = makePlotData([0, 1], [0, 1]); + expect(() => + renderer.renderToCanvas(pd, config, style, { + width: 4000, + height: 100, + ...baseOptions, + deviceMaxTextureSize: 2048, + }), + ).toThrow(/exceed this device's limit of 2048px/); + }); + + it('does not tighten the limit when the device reports more than the cap', () => { + const pd = makePlotData([0, 1], [0, 1]); + expect(() => + renderer.renderToCanvas(pd, config, style, { + width: 4000, + height: 100, + ...baseOptions, + deviceMaxTextureSize: 16384, + }), + ).not.toThrow(/exceed this device's limit/); }); it('throws when the export area exceeds the pixel-count limit', () => { @@ -184,6 +211,6 @@ describe('ExportRenderer.renderToCanvas (guards)', () => { height: 1, ...baseOptions, }), - ).toThrow(/exceed browser limit/); + ).toThrow(/exceed this device's limit/); }); }); diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/export-renderer.ts b/packages/core/src/components/scatter-plot/webgl/renderer/export-renderer.ts index 8fd031867..1600faa8a 100644 --- a/packages/core/src/components/scatter-plot/webgl/renderer/export-renderer.ts +++ b/packages/core/src/components/scatter-plot/webgl/renderer/export-renderer.ts @@ -48,7 +48,14 @@ import { DEFAULT_VIEWPORT_WIDTH, DEFAULT_VIEWPORT_HEIGHT, } from './viewport-defaults'; -import { stagePoint, type StagePointArrays, MAX_LABELS } from './stage-point'; +import { stagePoint, type StagePointArrays } from './stage-point'; +import { planLabelAtlas, MAX_LABELS, type LabelAtlasPlan } from './label-atlas-plan'; +import { + readMaxTextureSize, + drainGlErrors, + allocateLabelAtlas, + uploadPlaceholderAtlas, +} from './label-atlas-texture'; import { buildPaintOrder, composePaintDepth } from './point-staging'; import { POINT_VERTEX_SHADER, @@ -59,7 +66,6 @@ import { // Constants (moved verbatim from webgl-renderer.ts). const MIN_CAPACITY = 1024; -const LABEL_TEXTURE_WIDTH = 2048; // Stable reference dimensions for margin scaling at export time. Tying margin // scaling to the live display canvas (via `config.width/height`, which track @@ -96,6 +102,20 @@ interface ExportRenderOptions { gamma: number; /** Plot-surface/interior marker color in sRGB. */ knockoutColor?: readonly [number, number, number]; + /** + * Label slices the LIVE view is rendering with, or null when it has no atlas. + * The export never exceeds it, so an exported figure carries the same marker + * segmentation the user saw on screen — the two contexts plan independently + * and their capacities differ (export is not row-snapped), so without this + * they diverge. + */ + labelStride?: number | null; + /** + * `gl.MAX_TEXTURE_SIZE` as measured by the live context. Used to validate the + * requested output dimensions before a canvas that large is allocated; the + * export context is probed separately once it exists. + */ + deviceMaxTextureSize?: number; } export class ExportRenderer { @@ -228,9 +248,22 @@ export class ExportRenderer { const physicalWidth = Math.floor(width * dpr); const physicalHeight = Math.floor(height * dpr); - if (physicalWidth > MAX_DIMENSION || physicalHeight > MAX_DIMENSION) { + // MAX_DIMENSION alone was described as "the browser limit" but is a constant, + // so on any device reporting less than 8192 the message named a limit that was + // not the one being enforced — and the export failed later, in the driver. + // Deliberately NOT `sanitizeMaxTextureSize`: its fallback is the 2048 spec + // floor, which is right for planning an atlas but wrong here — an unknown + // device limit must leave the bound where it was, not tighten it to 2048 and + // start rejecting exports that work. + const deviceLimit = options.deviceMaxTextureSize; + const effectiveMaxDimension = + typeof deviceLimit === 'number' && Number.isFinite(deviceLimit) && deviceLimit >= 1 + ? Math.min(MAX_DIMENSION, deviceLimit) + : MAX_DIMENSION; + + if (physicalWidth > effectiveMaxDimension || physicalHeight > effectiveMaxDimension) { throw new Error( - `Export dimensions ${physicalWidth}×${physicalHeight} exceed browser limit of ${MAX_DIMENSION}px`, + `Export dimensions ${physicalWidth}×${physicalHeight} exceed this device's limit of ${effectiveMaxDimension}px`, ); } if (physicalWidth * physicalHeight > MAX_AREA) { @@ -372,9 +405,17 @@ export class ExportRenderer { // Get attribute and uniform locations const { attribs, uniforms } = resolvePointLocations(gl, pointProgram); - // Prepare point data using existing CPU arrays (reuse from main renderer) const maxPoints = Math.min(pd.length, MAX_POINTS_DIRECT_RENDER); + // This context is not the live one, so it must be asked its own limit — but + // the stride is inherited, so the exported figure segments its markers exactly + // the way the screen did. A null stride is the live view saying it has no atlas. + const labelAtlas = planLabelAtlas( + Math.max(MIN_CAPACITY, maxPoints), + readMaxTextureSize(gl), + options.labelStride === undefined ? MAX_LABELS : options.labelStride, + ); + // Populate buffers for off-screen rendering const { dataPositions, @@ -395,9 +436,14 @@ export class ExportRenderer { style, options.selectionActive, sizeScaleFactor, + labelAtlas, ); - // Create and upload buffers + // Create and upload buffers. The flag has to start clean for the check after + // them to mean "these uploads failed": this context is fresh, but program + // compilation and linking above share it. + drainGlErrors(gl); + const dataPositionBuffer = gl.createBuffer(); const sizeBuffer = gl.createBuffer(); const colorBuffer = gl.createBuffer(); @@ -428,22 +474,38 @@ export class ExportRenderer { gl.bindBuffer(gl.ARRAY_BUFFER, predictedBuffer); gl.bufferData(gl.ARRAY_BUFFER, predicted.subarray(0, pointCount), gl.STATIC_DRAW); - // Setup label color texture + // An out-of-memory bufferData raises into the error flag rather than throwing, + // so without this the export would draw from storage that was never allocated + // and hand back a blank or half-populated PNG — written to disk, published, + // with nothing logged anywhere. Throwing matches how this method already + // rejects an over-size request: an export is a discrete user action with an + // error path, and a figure that is quietly wrong is worse than one that failed. + if (gl.getError() !== gl.NO_ERROR) { + throw new Error( + `The graphics driver could not allocate memory for ${pointCount.toLocaleString()} points ` + + `at ${width}×${height}. Export at smaller dimensions, or with fewer points visible.`, + ); + } + + // Setup label color texture. When no atlas was planned — the live view has + // none, or nothing fits — or when the driver refuses the allocation (which + // raises a GL error rather than throwing, so the export would otherwise + // sample a texture that does not exist), a 1x1 placeholder keeps the sampler + // complete and `effectiveAtlas` goes null, zeroing the capacity uniform so + // the shader never reads it. The image is still produced, with flat marks. gl.bindTexture(gl.TEXTURE_2D, labelColorTexture); - const texHeight = labelColorData.length / 4 / LABEL_TEXTURE_WIDTH; - gl.texImage2D( - gl.TEXTURE_2D, - 0, - gl.RGBA8, - LABEL_TEXTURE_WIDTH, - texHeight, - 0, - gl.RGBA, - gl.UNSIGNED_BYTE, - labelColorData, - ); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + let effectiveAtlas = labelAtlas; + if (effectiveAtlas && labelColorData) { + // The buffer uploads above already had their own check, and + // allocateLabelAtlas drains again before allocating, so this verdict is + // about the atlas and nothing else. + if (allocateLabelAtlas(gl, effectiveAtlas, labelColorData) !== gl.NO_ERROR) { + effectiveAtlas = null; + } + } else { + effectiveAtlas = null; + } + if (!effectiveAtlas) uploadPlaceholderAtlas(gl); // Create VAO const pointVao = gl.createVertexArray(); @@ -502,7 +564,7 @@ export class ExportRenderer { options.knockoutColor ?? [1, 1, 1], exportTransform, labelColorTexture, - labelColorData.length, + effectiveAtlas, pointCount, options.selectionActive, selectedStartIndex, @@ -538,7 +600,7 @@ export class ExportRenderer { options.knockoutColor ?? [1, 1, 1], exportTransform, labelColorTexture, - labelColorData.length, + effectiveAtlas, pointCount, options.selectionActive, selectedStartIndex, @@ -573,6 +635,7 @@ export class ExportRenderer { style: WebGLStyleGetters, selectionActive: boolean, sizeScaleFactor: number = 1, + labelAtlas: LabelAtlasPlan | null = null, ): { dataPositions: Float32Array; sizes: Float32Array; @@ -581,7 +644,7 @@ export class ExportRenderer { labelCounts: Float32Array; shapes: Float32Array; predicted: Float32Array; - labelColorData: Uint8Array; + labelColorData: Uint8Array | null; pointCount: number; selectedStartIndex: number; } { @@ -593,9 +656,10 @@ export class ExportRenderer { const labelCounts = new Float32Array(capacity); const shapes = new Float32Array(capacity); const predicted = new Float32Array(capacity); - const requiredPixels = capacity * MAX_LABELS; - const texHeight = Math.ceil(requiredPixels / LABEL_TEXTURE_WIDTH); - const labelColorData = new Uint8Array(LABEL_TEXTURE_WIDTH * texHeight * 4); + // Sized from the plan, which already accounts for the device limit and the + // stride inherited from the live view. Null when no atlas is in play — the + // export then costs nothing for a feature it is not using. + const labelColorData = labelAtlas ? new Uint8Array(labelAtlas.byteLength) : null; // Stage slots by depth using the SAME canonical painter-order plan as the // live path (buildPaintOrder): the live path is canonical, so the export @@ -615,6 +679,7 @@ export class ExportRenderer { shapes, predicted, labelColorData, + maxLabels: labelAtlas?.stride ?? MAX_LABELS, }; // Per-slot depth scratch indexed by ORIGINAL slot index, then the index order @@ -696,7 +761,8 @@ export class ExportRenderer { knockoutColor: readonly [number, number, number], transform: d3.ZoomTransform, labelColorTexture: WebGLTexture | null, - labelColorDataLength: number, + /** The atlas actually resident on the GPU — null once a fallback took over. */ + labelAtlas: LabelAtlasPlan | null, pointCount: number, selectionActive: boolean, selectedStartIndex: number, @@ -708,9 +774,7 @@ export class ExportRenderer { dpr, gamma, knockoutColor, - maxLabels: MAX_LABELS, - labelTextureWidth: LABEL_TEXTURE_WIDTH, - labelColorDataLength, + labelAtlas, }); drawPoints(gl, pointCount, selectionActive, selectedStartIndex); diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/export-shaders.test.ts b/packages/core/src/components/scatter-plot/webgl/renderer/export-shaders.test.ts index 741d1de33..a85c580d6 100644 --- a/packages/core/src/components/scatter-plot/webgl/renderer/export-shaders.test.ts +++ b/packages/core/src/components/scatter-plot/webgl/renderer/export-shaders.test.ts @@ -110,4 +110,40 @@ describe('point shaders', () => { expect(POINT_FRAGMENT_SHADER).toMatch(/smoothstep\([^)]*outline/i); }); }); + + describe('multi-label atlas sampling', () => { + it('declares highp int, so the atlas index is defined past 32767 points', () => { + // ES 3.00 defaults fragment int to mediump (>= 16 bits). v_pointIndex and the + // index derived from it exceed that at any dataset past ~32K points, so on a + // driver honouring the minimum they were undefined — on exactly the low-end + // hardware the atlas limits are about. + expect(POINT_FRAGMENT_SHADER).toContain('precision highp int;'); + }); + + it('refuses to sample beyond what the atlas covers', () => { + // Zero capacity means "no atlas": every marker must fall through to its + // dominant colour rather than sampling storage that was never allocated, + // which rendered solid black discs. + expect(POINT_FRAGMENT_SHADER).toContain('uniform int u_labelAtlasCapacity;'); + expect(POINT_FRAGMENT_SHADER).toContain( + 'if (v_labelCount > 1.5 && v_pointIndex < u_labelAtlasCapacity)', + ); + }); + + it('clamps the slice count to the reserved stride', () => { + // Unclamped, a point with more colours than the stride indexed into the NEXT + // point's texels and painted an unrelated protein's colours. + expect(POINT_FRAGMENT_SHADER).toContain( + 'float count = min(floor(v_labelCount + 0.5), float(u_maxLabels));', + ); + }); + + it('clamps the slice index to the last slice of this point', () => { + // atan(+0, x < 0) is exactly +PI, so the normalised sweep reaches 1.0 on the + // middle pixel row of any odd-height sprite and sliceIndex reached `count`. + expect(POINT_FRAGMENT_SHADER).toContain( + 'float sliceIndex = min(floor(normalizedAngle * count), count - 1.0);', + ); + }); + }); }); diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/export-shaders.ts b/packages/core/src/components/scatter-plot/webgl/renderer/export-shaders.ts index d0c4ac8f3..9314d5db2 100644 --- a/packages/core/src/components/scatter-plot/webgl/renderer/export-shaders.ts +++ b/packages/core/src/components/scatter-plot/webgl/renderer/export-shaders.ts @@ -51,6 +51,11 @@ void main() { export const POINT_FRAGMENT_SHADER = `#version 300 es precision highp float; +// ES 3.00 defaults fragment int to mediump, whose guaranteed range is 16 bits. +// v_pointIndex and the atlas index derived from it exceed 32767 at any dataset +// past ~32K points, so on a driver that honours the minimum they are undefined +// — exactly the low-end hardware the atlas limits are about. +precision highp int; in vec4 v_color; in float v_labelCount; @@ -61,6 +66,9 @@ flat in int v_pointIndex; uniform sampler2D u_labelColors; uniform vec2 u_labelTextureSize; uniform int u_maxLabels; +// Points the atlas covers. Zero when none is allocated, which makes every marker +// fall through to its dominant color rather than sampling unallocated storage. +uniform int u_labelAtlasCapacity; uniform float u_gamma; uniform vec3 u_knockoutColor; @@ -171,14 +179,21 @@ void main() { vec3 finalColor = v_color.rgb; - // Pie Chart Logic (only for multi-label points, which always use circle shape) - if (v_labelCount > 1.5) { + // Pie Chart Logic (only for multi-label points, which always use circle shape). + // The capacity test is what keeps a point outside the atlas — or a session with + // no atlas at all — painting its dominant color instead of sampling storage that + // belongs to another protein, or to nothing. + if (v_labelCount > 1.5 && v_pointIndex < u_labelAtlasCapacity) { float angle = atan(coord.y, coord.x); // -PI to PI // Map to 0..1 float normalizedAngle = (angle + PI) / (2.0 * PI); - float count = floor(v_labelCount + 0.5); - float sliceIndex = floor(normalizedAngle * count); + // Clamped to what the atlas actually reserves per point: a point with more + // colors than u_maxLabels would otherwise index into the NEXT point's texels. + float count = min(floor(v_labelCount + 0.5), float(u_maxLabels)); + // atan(+0, x < 0) is exactly +PI, so normalizedAngle reaches 1.0 on the middle + // pixel row of any odd-height sprite, so sliceIndex would otherwise reach count. + float sliceIndex = min(floor(normalizedAngle * count), count - 1.0); // Calculate texture lookup index int globalIndex = v_pointIndex * u_maxLabels + int(sliceIndex); diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/label-atlas-plan.test.ts b/packages/core/src/components/scatter-plot/webgl/renderer/label-atlas-plan.test.ts new file mode 100644 index 000000000..349225518 --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/label-atlas-plan.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; +import { MAX_LABELS, planLabelAtlas } from './label-atlas-plan'; + +// Capacities are the row-snapped values planRendererCapacity actually produces, +// so these cases describe geometry the renderer can really ask for. +const SWISS_PROT_CAPACITY = 573_696; // 573,649 proteins snapped to 256 +const CLAMPED_CAPACITY = 1_000_192; // MAX_POINTS_DIRECT_RENDER snapped to 256 +const TWO_MILLION_CAPACITY = 2_000_128; // the ceiling #456 raises the clamp to + +describe('planLabelAtlas', () => { + it('keeps the historical geometry on a device with ample limits', () => { + // The no-change lock. ~97% of WebGL2 devices report >= 8192, and they must + // allocate exactly what they allocated before this module existed: + // 2048 wide, capacity/256 rows, 8 slices per point. + expect(planLabelAtlas(SWISS_PROT_CAPACITY, 8192)).toEqual({ + width: 2048, + height: 2241, + stride: MAX_LABELS, + pointCapacity: SWISS_PROT_CAPACITY, + byteLength: 2048 * 2241 * 4, + }); + }); + + it('keeps that same geometry at 4096, where the clamp is what saves it', () => { + expect(planLabelAtlas(SWISS_PROT_CAPACITY, 4096)).toMatchObject({ + width: 2048, + height: 2241, + stride: MAX_LABELS, + }); + // The clamped capacity is the worst case a 4096 device can be asked for, + // and it still fits at full fidelity: 1,000,192 * 8 / 2048 = 3907 <= 4096. + expect(planLabelAtlas(CLAMPED_CAPACITY, 4096)).toMatchObject({ + width: 2048, + height: 3907, + stride: MAX_LABELS, + }); + }); + + it('reduces slices, not points, on a device at the WebGL2 floor', () => { + // 573,696 * 8 / 2048 = 2241 rows, over the limit. No wider texture is + // available at 2048, so the stride drops instead — every point keeps a pie. + expect(planLabelAtlas(SWISS_PROT_CAPACITY, 2048)).toEqual({ + width: 2048, + height: 1121, + stride: 4, + pointCapacity: SWISS_PROT_CAPACITY, + byteLength: 2048 * 1121 * 4, + }); + }); + + it('widens before it reduces fidelity', () => { + // At 2M the narrow texture needs 7813 rows, but a 4096-wide one needs 3907, + // so a 4096 device keeps all eight slices rather than dropping to four. + expect(planLabelAtlas(TWO_MILLION_CAPACITY, 4096)).toMatchObject({ + width: 4096, + height: 3907, + stride: MAX_LABELS, + }); + }); + + it('falls to the two-slice floor only when no width fits', () => { + expect(planLabelAtlas(TWO_MILLION_CAPACITY, 2048)).toMatchObject({ + width: 2048, + height: 1954, + stride: 2, + }); + }); + + it('returns null when even the floor cannot fit', () => { + // Below the spec floor no candidate width is usable at all. + expect(planLabelAtlas(SWISS_PROT_CAPACITY, 1024)).toBeNull(); + }); + + it('rejects nonsense inputs rather than planning an unbounded texture', () => { + expect(planLabelAtlas(SWISS_PROT_CAPACITY, Number.NaN)).toBeNull(); + expect(planLabelAtlas(SWISS_PROT_CAPACITY, 0)).toBeNull(); + expect(planLabelAtlas(0, 8192)).toBeNull(); + expect(planLabelAtlas(Number.NaN, 8192)).toBeNull(); + }); + + it('never plans a texture the device cannot hold, and never under-allocates', () => { + const limits = [1024, 2048, 4096, 8192, 16384]; + const capacities = [1024, SWISS_PROT_CAPACITY, CLAMPED_CAPACITY, TWO_MILLION_CAPACITY]; + + for (const maxTextureSize of limits) { + for (const capacity of capacities) { + const plan = planLabelAtlas(capacity, maxTextureSize); + if (plan === null) continue; + expect(plan.width).toBeLessThanOrEqual(maxTextureSize); + expect(plan.height).toBeLessThanOrEqual(maxTextureSize); + // Every point's slices must have somewhere to live. + expect(capacity * plan.stride).toBeLessThanOrEqual(plan.width * plan.height); + expect(plan.byteLength).toBe(plan.width * plan.height * 4); + } + } + }); +}); + +describe('planLabelAtlas stride inheritance', () => { + // The export renderer plans against its OWN context's limit but must never + // exceed the live view's fidelity, or a figure would show eight segments where + // the user saw four. Capacities differ between the two (export is not + // row-snapped), so the bound has to be the stride, not the geometry. + it('never exceeds the inherited stride even when the device could hold more', () => { + const plan = planLabelAtlas(SWISS_PROT_CAPACITY, 8192, 4); + expect(plan).toMatchObject({ stride: 4 }); + }); + + it('takes the smaller of the device limit and the inherited stride', () => { + // Device forces 4; live view is at 8. The device wins. + expect(planLabelAtlas(SWISS_PROT_CAPACITY, 2048, MAX_LABELS)).toMatchObject({ stride: 4 }); + // Device allows 8; live view is at 2. The live view wins. + expect(planLabelAtlas(SWISS_PROT_CAPACITY, 8192, 2)).toMatchObject({ stride: 2 }); + }); + + it('plans no atlas at all when the live view has none', () => { + expect(planLabelAtlas(SWISS_PROT_CAPACITY, 8192, null)).toBeNull(); + }); +}); diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/label-atlas-plan.ts b/packages/core/src/components/scatter-plot/webgl/renderer/label-atlas-plan.ts new file mode 100644 index 000000000..859a84f60 --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/label-atlas-plan.ts @@ -0,0 +1,102 @@ +/** + * Geometry planning for the multi-label ("pie chart") colour atlas. + * + * The atlas is an RGBA8 texture holding `stride` texels per point — one per + * label slice. Its size therefore grows with the renderer's point capacity, so + * it is the one GPU resource whose dimensions can exceed what the device + * accepts. `gl.MAX_TEXTURE_SIZE` bounds *both* dimensions, and the historical + * layout pinned width at 2048 and let height do all the growing, which is the + * least favourable arrangement against a square limit. + * + * This module is pure and GL-free: it is the single place the live renderer and + * the export renderer derive atlas geometry, so the two cannot drift. + */ + +/** Label slices a point can carry at full fidelity. */ +export const MAX_LABELS = 8; + +/** + * The WebGL2 / GLES3 guaranteed minimum for `gl.MAX_TEXTURE_SIZE`. + * + * Doubles as the conservative fallback for a context that has not been probed + * yet, or whose driver reports nonsense: planning against the floor can only + * under-commit, never over-commit. + */ +export const MIN_MAX_TEXTURE_SIZE = 2048; + +/** + * Candidate atlas widths, narrowest first. The narrowest is the spec floor + * itself, so it is the one width every conformant device supports — and it is + * also the historical `LABEL_TEXTURE_WIDTH`, which is what makes a device with + * ample limits allocate byte-identical geometry to the one it allocated before + * this module existed. + */ +const ATLAS_WIDTHS = [MIN_MAX_TEXTURE_SIZE, 4096, 8192] as const; + +/** + * Slice counts to fall back through, widest first. The floor is 2, not 1, + * because `eat-annotation-overlay` requires a two-label cell to render both of + * its hues in live *and* exported markers — a single slice would satisfy no + * scenario that dropping the atlas entirely does not. + */ +const STRIDE_LADDER = [MAX_LABELS, 4, 2] as const; + +export interface LabelAtlasPlan { + /** Texture width in texels. */ + width: number; + /** Texture height in texels. */ + height: number; + /** Texels reserved per point, i.e. the maximum slices a marker can show. */ + stride: number; + /** Points this plan covers; the shader refuses to sample beyond it. */ + pointCapacity: number; + /** Bytes of backing store (RGBA8). */ + byteLength: number; +} + +/** + * Plan the smallest atlas that covers `capacity` points on a device reporting + * `maxTextureSize`. + * + * Full stride is preferred over a narrow texture: the loops run stride-outer, + * width-inner, so fidelity is only reduced once *no* supported width can hold + * the full slice count. That ordering is deliberate — the reverse would re-lay + * out the majority of devices, which are unconstrained, in order to help the + * minority that are. + * + * Returns `null` when even the floor stride cannot fit, which the caller must + * treat as "render dominant colours, tell the user" rather than as a reason to + * drop points. + */ +export function planLabelAtlas( + capacity: number, + maxTextureSize: number, + /** + * Upper bound on the stride, used by the export renderer to inherit the live + * view's fidelity so a figure carries the same segmentation the user saw. + * A null bound means "no atlas at all". + */ + maxStride: number | null = MAX_LABELS, +): LabelAtlasPlan | null { + if (!Number.isFinite(maxTextureSize) || maxTextureSize < 1) return null; + if (!Number.isFinite(capacity) || capacity < 1) return null; + if (maxStride === null || maxStride < 1) return null; + + for (const stride of STRIDE_LADDER) { + if (stride > maxStride) continue; + for (const width of ATLAS_WIDTHS) { + if (width > maxTextureSize) break; + const height = Math.ceil((capacity * stride) / width); + if (height <= maxTextureSize) { + return { + width, + height, + stride, + pointCapacity: capacity, + byteLength: width * height * 4, + }; + } + } + } + return null; +} diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/label-atlas-texture.test.ts b/packages/core/src/components/scatter-plot/webgl/renderer/label-atlas-texture.test.ts new file mode 100644 index 000000000..80f7afb5d --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/label-atlas-texture.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + readMaxTextureSize, + sanitizeMaxTextureSize, + drainGlErrors, + allocateLabelAtlas, + refreshLabelAtlas, + uploadPlaceholderAtlas, +} from './label-atlas-texture'; +import { MIN_MAX_TEXTURE_SIZE, type LabelAtlasPlan } from './label-atlas-plan'; + +const GL = { + TEXTURE_2D: 0x0de1, + RGBA: 0x1908, + RGBA8: 0x8058, + UNSIGNED_BYTE: 0x1401, + MAX_TEXTURE_SIZE: 0x0d33, + TEXTURE_MIN_FILTER: 0x2801, + TEXTURE_MAG_FILTER: 0x2800, + NEAREST: 0x2600, + NO_ERROR: 0, + INVALID_VALUE: 0x0501, +}; + +/** + * A GL stub whose error flag behaves like the real one: sticky, holding the FIRST + * error until `getError` returns and clears it. That is the only property these + * helpers depend on, and the one the un-drained version got wrong. + */ +function mockGL(opts: { errors?: number[]; maxTextureSize?: unknown } = {}) { + const queue = [...(opts.errors ?? [])]; + let flag: number = GL.NO_ERROR; + const gl = { + ...GL, + getParameter: vi.fn(() => opts.maxTextureSize), + getError: vi.fn(() => { + const raised = flag; + flag = GL.NO_ERROR; + return raised; + }), + texImage2D: vi.fn(() => { + const next = queue.shift(); + if (next && flag === GL.NO_ERROR) flag = next; + }), + texSubImage2D: vi.fn(), + texParameteri: vi.fn(), + }; + return { + gl: gl as unknown as WebGL2RenderingContext, + spies: gl, + /** Simulate an error raised by some earlier, unrelated call. */ + raiseStale: (code: number) => { + flag = code; + }, + }; +} + +const plan = (over: Partial = {}): LabelAtlasPlan => ({ + width: 2048, + height: 2241, + stride: 8, + pointCapacity: 573_696, + byteLength: 2048 * 2241 * 4, + ...over, +}); + +describe('sanitizeMaxTextureSize', () => { + it('falls back to the spec floor for anything unusable', () => { + for (const bad of [undefined, null, NaN, Infinity, 0, -1, '4096']) { + expect(sanitizeMaxTextureSize(bad)).toBe(MIN_MAX_TEXTURE_SIZE); + } + }); + + it('passes a usable limit through', () => { + expect(sanitizeMaxTextureSize(4096)).toBe(4096); + }); +}); + +describe('readMaxTextureSize', () => { + it('reads the device limit', () => { + expect(readMaxTextureSize(mockGL({ maxTextureSize: 16384 }).gl)).toBe(16384); + }); + + it('substitutes the spec floor when the driver reports nonsense', () => { + expect(readMaxTextureSize(mockGL({ maxTextureSize: null }).gl)).toBe(MIN_MAX_TEXTURE_SIZE); + }); +}); + +describe('drainGlErrors', () => { + it('clears a stale flag and terminates when clean', () => { + const { gl, spies, raiseStale } = mockGL(); + raiseStale(GL.INVALID_VALUE); + drainGlErrors(gl); + expect(spies.getError()).toBe(GL.NO_ERROR); + }); +}); + +describe('allocateLabelAtlas', () => { + it('reports NO_ERROR and sets NEAREST filtering when the driver accepts it', () => { + const { gl, spies } = mockGL(); + expect(allocateLabelAtlas(gl, plan(), new Uint8Array(4))).toBe(GL.NO_ERROR); + expect(spies.texImage2D).toHaveBeenCalledWith( + GL.TEXTURE_2D, + 0, + GL.RGBA8, + 2048, + 2241, + 0, + GL.RGBA, + GL.UNSIGNED_BYTE, + expect.any(Uint8Array), + ); + expect(spies.texParameteri).toHaveBeenCalledTimes(2); + }); + + it('does not inherit an error raised before it ran', () => { + // The regression this guards: the GL error flag is context-wide and sticky, so + // without a drain the check after texImage2D reports the FIRST error raised + // anywhere in the context's life — a failed vertex-buffer upload, say — and + // permanently disables the atlas over someone else's failure. + const { gl, raiseStale } = mockGL(); + raiseStale(GL.INVALID_VALUE); + expect(allocateLabelAtlas(gl, plan(), new Uint8Array(4))).toBe(GL.NO_ERROR); + }); + + it("reports the driver's refusal, and leaves filtering unset", () => { + const { gl, spies } = mockGL({ errors: [GL.INVALID_VALUE] }); + expect(allocateLabelAtlas(gl, plan(), new Uint8Array(4))).toBe(GL.INVALID_VALUE); + expect(spies.texParameteri).not.toHaveBeenCalled(); + }); +}); + +describe('uploadPlaceholderAtlas', () => { + it('uploads one opaque texel and sets NEAREST filtering', () => { + const { gl, spies } = mockGL(); + uploadPlaceholderAtlas(gl); + const [, , , width, height] = spies.texImage2D.mock.calls[0] as unknown[]; + expect([width, height]).toEqual([1, 1]); + expect(spies.texParameteri).toHaveBeenCalledTimes(2); + }); +}); + +describe('refreshLabelAtlas', () => { + it('uploads only the rows the drawn points occupy, not the whole capacity', () => { + // Storage is sized from capacity, which overshoots the drawn count after a + // geometric grow — and this runs on every recolor. + const { gl, spies } = mockGL(); + const p = plan({ height: 3362, pointCapacity: 860_544 }); + refreshLabelAtlas(gl, p, new Uint8Array(p.width * p.height * 4), 700_000); + + const [, , , , , height, , , texels] = spies.texSubImage2D.mock.calls[0] as unknown[]; + const expectedRows = Math.ceil((700_000 * 8) / 2048); // 2735 + expect(height).toBe(expectedRows); + expect(height).toBeLessThan(p.height); + expect((texels as Uint8Array).length).toBe(expectedRows * 2048 * 4); + }); + + it('covers every drawn point when the count fills the atlas exactly', () => { + const { gl, spies } = mockGL(); + const p = plan(); + refreshLabelAtlas(gl, p, new Uint8Array(p.byteLength), p.pointCapacity); + + const [, , , , , height] = spies.texSubImage2D.mock.calls[0] as unknown[]; + expect((height as number) * p.width).toBeGreaterThanOrEqual(p.pointCapacity * p.stride); + expect(height).toBeLessThanOrEqual(p.height); + }); + + it('uploads nothing when no points are drawn', () => { + const { gl, spies } = mockGL(); + refreshLabelAtlas(gl, plan(), new Uint8Array(4), 0); + expect(spies.texSubImage2D).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/label-atlas-texture.ts b/packages/core/src/components/scatter-plot/webgl/renderer/label-atlas-texture.ts new file mode 100644 index 000000000..31cc9b9a2 --- /dev/null +++ b/packages/core/src/components/scatter-plot/webgl/renderer/label-atlas-texture.ts @@ -0,0 +1,139 @@ +/** + * GL-side companion to {@link ./label-atlas-plan}. + * + * `label-atlas-plan.ts` owns the *geometry* of the multi-label colour atlas and + * is deliberately GL-free. This module owns everything that has to touch a + * context to act on that geometry: reading the device limit, draining the error + * flag, and allocating/refreshing the texture itself. + * + * Both live (`webgl-renderer.ts`) and export (`export-renderer.ts`) paths go + * through here, for the same reason the plan is shared — the two contexts must + * not drift on placeholder format, filter mode, or failure handling. + */ + +import { MIN_MAX_TEXTURE_SIZE, type LabelAtlasPlan } from './label-atlas-plan'; + +/** + * The single opaque texel uploaded whenever no atlas is in play. + * + * It keeps the sampler texture-complete and is never read: the shader's + * `u_labelAtlasCapacity` is 0 in exactly these cases, so the pie branch is + * unreachable. Module-level because it is immutable and uploaded by value. + */ +const PLACEHOLDER_TEXEL = new Uint8Array([0, 0, 0, 255]); + +/** Upper bound on {@link drainGlErrors}; far above any real driver's queue. */ +const MAX_ERROR_DRAIN = 32; + +/** + * Read `gl.MAX_TEXTURE_SIZE`, falling back to the WebGL2 specification floor if + * the driver returns something unusable. Costs a synchronous round-trip, so + * callers cache it per context rather than re-asking per allocation. + */ +export function readMaxTextureSize(gl: WebGL2RenderingContext): number { + return sanitizeMaxTextureSize(gl.getParameter(gl.MAX_TEXTURE_SIZE)); +} + +/** + * Coerce a reported or host-supplied texture limit to a usable number. Shared + * with the export path, which receives the live context's limit as a plain + * number rather than reading it itself. + */ +export function sanitizeMaxTextureSize(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value >= 1 + ? value + : MIN_MAX_TEXTURE_SIZE; +} + +/** + * Clear the GL error flag. + * + * The flag is sticky and context-wide: nothing else in the renderer drains it, + * so without this an allocation check reports the first error raised anywhere in + * the context's lifetime and misattributes it to the call being checked. Draining + * immediately before an allocating call is what makes the check after it mean + * "this call failed". + */ +export function drainGlErrors(gl: WebGL2RenderingContext): void { + // Explicitly bounded rather than "bounded in practice": a conformant driver + // keeps a short queue and returns NO_ERROR once it is empty, but this runs on + // the main thread, and a context that keeps reporting the same code — lost, + // proxied, instrumented — would freeze the tab in a `while`. Overshooting the + // queue only means the next check may inherit one stale error; never hanging. + for (let i = 0; i < MAX_ERROR_DRAIN && gl.getError() !== gl.NO_ERROR; i++) { + /* discard */ + } +} + +/** Upload the 1x1 placeholder into the currently bound TEXTURE_2D. */ +export function uploadPlaceholderAtlas(gl: WebGL2RenderingContext): void { + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, PLACEHOLDER_TEXEL); + setAtlasFiltering(gl); +} + +/** + * Allocate atlas storage in the currently bound TEXTURE_2D and report whether + * the driver accepted it. + * + * An over-size or out-of-memory `texImage2D` raises a GL error rather than + * throwing, so the return value is the only signal: callers must not record the + * texture as initialised unless it is `gl.NO_ERROR`. Returns the raw GL error + * code so the caller can distinguish `OUT_OF_MEMORY` from `INVALID_VALUE` + * without this module knowing anything about how that is surfaced. + */ +export function allocateLabelAtlas( + gl: WebGL2RenderingContext, + plan: LabelAtlasPlan, + texels: Uint8Array, +): number { + drainGlErrors(gl); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA8, + plan.width, + plan.height, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + texels, + ); + const error = gl.getError(); + if (error === gl.NO_ERROR) setAtlasFiltering(gl); + return error; +} + +/** + * Refresh already-allocated atlas storage in place, uploading only the rows the + * drawn points actually occupy. + * + * Storage is sized from *capacity*, which overshoots the drawn count after a + * geometric grow, and this runs on every recolour — so uploading `plan.height` + * rows would push megabytes of never-sampled texels per legend click. + */ +export function refreshLabelAtlas( + gl: WebGL2RenderingContext, + plan: LabelAtlasPlan, + texels: Uint8Array, + pointCount: number, +): void { + const rows = Math.min(plan.height, Math.ceil((pointCount * plan.stride) / plan.width)); + if (rows < 1) return; + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + 0, + 0, + plan.width, + rows, + gl.RGBA, + gl.UNSIGNED_BYTE, + texels.subarray(0, rows * plan.width * 4), + ); +} + +/** NEAREST in both directions: the atlas is a lookup table, never interpolated. */ +function setAtlasFiltering(gl: WebGL2RenderingContext): void { + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); +} diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/label-texture-utils.test.ts b/packages/core/src/components/scatter-plot/webgl/renderer/label-texture-utils.test.ts index 131404dc0..b2aca5a63 100644 --- a/packages/core/src/components/scatter-plot/webgl/renderer/label-texture-utils.test.ts +++ b/packages/core/src/components/scatter-plot/webgl/renderer/label-texture-utils.test.ts @@ -115,3 +115,35 @@ describe('fillLabelColorTexels', () => { expect(data.every((b) => b === 0)).toBe(true); }); }); + +describe('atlas layout matches what the shader recomputes', () => { + // The CPU writes at a linear texel index; the shader recovers the texel from + // that index and the atlas WIDTH. Row-major upload makes them the same texel at + // any width — this pins that, because a width change would otherwise be a + // silent, invisible corruption. + it.each([ + [2048, 8], + [4096, 8], + [8192, 8], + [2048, 4], + [2048, 2], + ])('width %i, stride %i', (width, stride) => { + const points = 5; + const data = new Uint8Array(points * stride * 4); + const idx = 3; + const colors = ['#ff0000', '#00ff00']; + fillLabelColorTexels(data, idx, colors, stride); + + for (let slice = 0; slice < colors.length; slice++) { + const globalIndex = idx * stride + slice; + // The shader's arithmetic, verbatim: tx = globalIndex % texW, ty = globalIndex / texW. + const tx = globalIndex % width; + const ty = Math.floor(globalIndex / width); + // A row-major RGBA8 texture puts texel (tx, ty) at this byte offset. + const shaderByteOffset = (ty * width + tx) * 4; + const cpuByteOffset = globalIndex * 4; + expect(shaderByteOffset).toBe(cpuByteOffset); + expect(data[cpuByteOffset + 3]).toBe(255); + } + }); +}); diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/point-locations.test.ts b/packages/core/src/components/scatter-plot/webgl/renderer/point-locations.test.ts index 6ee139ba7..6d16a6713 100644 --- a/packages/core/src/components/scatter-plot/webgl/renderer/point-locations.test.ts +++ b/packages/core/src/components/scatter-plot/webgl/renderer/point-locations.test.ts @@ -48,6 +48,7 @@ describe('resolvePointLocations', () => { 'labelTextureSize', 'maxLabels', 'knockoutColor', + 'labelAtlasCapacity', 'resolution', 'transform', ].sort(), diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/point-locations.ts b/packages/core/src/components/scatter-plot/webgl/renderer/point-locations.ts index 77a8a64d4..dc2a9426b 100644 --- a/packages/core/src/components/scatter-plot/webgl/renderer/point-locations.ts +++ b/packages/core/src/components/scatter-plot/webgl/renderer/point-locations.ts @@ -29,6 +29,7 @@ export function resolvePointLocations( labelColors: gl.getUniformLocation(program, 'u_labelColors'), labelTextureSize: gl.getUniformLocation(program, 'u_labelTextureSize'), maxLabels: gl.getUniformLocation(program, 'u_maxLabels'), + labelAtlasCapacity: gl.getUniformLocation(program, 'u_labelAtlasCapacity'), }, }; } diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/render-target.test.ts b/packages/core/src/components/scatter-plot/webgl/renderer/render-target.test.ts index 721386148..ac9b61799 100644 --- a/packages/core/src/components/scatter-plot/webgl/renderer/render-target.test.ts +++ b/packages/core/src/components/scatter-plot/webgl/renderer/render-target.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from 'vitest'; -import { bindAndClearTarget, setPointBlendState, drawPoints } from './render-target'; +import { + bindAndClearTarget, + setPointBlendState, + drawPoints, + bindPointDrawState, +} from './render-target'; +import type { PointUniformLocations } from '../types'; function mockGL() { const calls: string[] = []; @@ -85,3 +91,82 @@ describe('drawPoints', () => { expect(calls).toEqual(['enable:1', 'blendFunc:1,771', 'drawArrays:0,0,100']); }); }); + +describe('bindPointDrawState label-atlas uniforms', () => { + function uniformMockGL() { + const pushed: Record = {}; + const gl = { + TEXTURE1: 0x84c1, + TEXTURE_2D: 0x0de1, + useProgram: () => {}, + activeTexture: () => {}, + bindTexture: () => {}, + bindVertexArray: () => {}, + enable: () => {}, + disable: () => {}, + blendFunc: () => {}, + depthMask: () => {}, + uniform1f: (loc: { n: string }, v: number) => { + pushed[loc.n] = v; + }, + uniform1i: (loc: { n: string }, v: number) => { + pushed[loc.n] = v; + }, + uniform2f: (loc: { n: string }, a: number, b: number) => { + pushed[loc.n] = [a, b]; + }, + uniform3f: (loc: { n: string }, a: number, b: number, c: number) => { + pushed[loc.n] = [a, b, c]; + }, + } as unknown as WebGL2RenderingContext; + const uniforms = { + resolution: { n: 'resolution' }, + transform: { n: 'transform' }, + dpr: { n: 'dpr' }, + gamma: { n: 'gamma' }, + knockoutColor: { n: 'knockoutColor' }, + labelColors: { n: 'labelColors' }, + labelTextureSize: { n: 'labelTextureSize' }, + maxLabels: { n: 'maxLabels' }, + labelAtlasCapacity: { n: 'labelAtlasCapacity' }, + } as unknown as PointUniformLocations; + return { gl, uniforms, pushed }; + } + + const baseParams = { + width: 800, + height: 600, + transform: { x: 0, y: 0, k: 1 }, + dpr: 1, + gamma: 2.2, + knockoutColor: [1, 1, 1] as const, + }; + + it('pushes the planned geometry so the shader indexes the atlas it was given', () => { + const { gl, uniforms, pushed } = uniformMockGL(); + bindPointDrawState(gl, {} as WebGLProgram, uniforms, null, null, { + ...baseParams, + labelAtlas: { + width: 2048, + height: 2241, + stride: 8, + pointCapacity: 573_696, + byteLength: 2048 * 2241 * 4, + }, + }); + expect(pushed.maxLabels).toBe(8); + expect(pushed.labelTextureSize).toEqual([2048, 2241]); + expect(pushed.labelAtlasCapacity).toBe(573_696); + }); + + it('pushes zero capacity when no atlas is allocated, disabling the pie branch', () => { + const { gl, uniforms, pushed } = uniformMockGL(); + bindPointDrawState(gl, {} as WebGLProgram, uniforms, null, null, { + ...baseParams, + labelAtlas: null, + }); + expect(pushed.labelAtlasCapacity).toBe(0); + // The remaining three describe the 1x1 placeholder that stands in for the atlas. + expect(pushed.labelTextureSize).toEqual([1, 1]); + }); +}); diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/render-target.ts b/packages/core/src/components/scatter-plot/webgl/renderer/render-target.ts index a84721611..10bc5973c 100644 --- a/packages/core/src/components/scatter-plot/webgl/renderer/render-target.ts +++ b/packages/core/src/components/scatter-plot/webgl/renderer/render-target.ts @@ -8,6 +8,7 @@ */ import type { PointUniformLocations } from '../types'; +import { MAX_LABELS, type LabelAtlasPlan } from './label-atlas-plan'; /** * Binds the given framebuffer (or the default framebuffer when `null`), sets the @@ -46,12 +47,15 @@ interface PointDrawStateParams { gamma: number; /** Resolved plot-surface color in sRGB, used to mask overlapping marker interiors. */ knockoutColor: readonly [number, number, number]; - /** Max labels per point (u_maxLabels). */ - maxLabels: number; - /** Label color texture atlas width in texels. */ - labelTextureWidth: number; - /** Total length of the label-color texel array (RGBA u8); used to derive atlas height. */ - labelColorDataLength: number; + /** + * Geometry of the allocated label atlas, or null when none is allocated. + * + * Passed whole rather than flattened by the caller: the atlas uniforms are + * only coherent as a set (a stride is meaningless against the wrong texture + * size), and `null` is the one state that has to disable sampling. Deriving + * all four here is what stops the two draw paths choosing different fallbacks. + */ + labelAtlas: LabelAtlasPlan | null; } /** @@ -88,12 +92,12 @@ export function bindPointDrawState( gl.uniform1f(uniforms.dpr, params.dpr); gl.uniform1f(uniforms.gamma, params.gamma); gl.uniform3f(uniforms.knockoutColor, ...params.knockoutColor); - gl.uniform1i(uniforms.maxLabels, params.maxLabels); - gl.uniform2f( - uniforms.labelTextureSize, - params.labelTextureWidth, - params.labelColorDataLength / 4 / params.labelTextureWidth, - ); + // No atlas: capacity 0 makes the shader's pie branch unreachable, so the + // remaining three describe the 1x1 placeholder that is bound in its place. + const atlas = params.labelAtlas; + gl.uniform1i(uniforms.maxLabels, atlas?.stride ?? MAX_LABELS); + gl.uniform1i(uniforms.labelAtlasCapacity, atlas?.pointCapacity ?? 0); + gl.uniform2f(uniforms.labelTextureSize, atlas?.width ?? 1, atlas?.height ?? 1); gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, labelTexture); diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/stage-point.test.ts b/packages/core/src/components/scatter-plot/webgl/renderer/stage-point.test.ts index 45cac9b36..8eb5613d3 100644 --- a/packages/core/src/components/scatter-plot/webgl/renderer/stage-point.test.ts +++ b/packages/core/src/components/scatter-plot/webgl/renderer/stage-point.test.ts @@ -1,8 +1,14 @@ import { describe, it, expect } from 'vitest'; -import { stagePoint, type StagePointArrays, type StagePointStyle } from './stage-point'; +import { + stagePoint, + stagePointStyle, + type StagePointArrays, + type StagePointStyle, +} from './stage-point'; +import { MAX_LABELS } from './label-atlas-plan'; import type { PlotDataPoint } from '@protspace/utils'; -function arrays(capacity: number): StagePointArrays { +function arrays(capacity: number, maxLabels: number = MAX_LABELS): StagePointArrays { return { dataPositions: new Float32Array(capacity * 2), sizes: new Float32Array(capacity), @@ -11,10 +17,20 @@ function arrays(capacity: number): StagePointArrays { labelCounts: new Float32Array(capacity), shapes: new Float32Array(capacity), predicted: new Float32Array(capacity), - labelColorData: new Uint8Array(capacity * 8 * 4), + labelColorData: new Uint8Array(capacity * maxLabels * 4), + maxLabels, }; } +function styleWithColors(colors: string[]): StagePointStyle { + return { + getColors: () => colors, + getPointSize: () => 36, + getShape: () => 'circle', + isPredicted: () => false, + } as unknown as StagePointStyle; +} + const style = { getColors: () => ['#ff0000'], getPointSize: () => 36, // sqrt(36)/3 = 2 @@ -74,3 +90,54 @@ describe('stagePoint', () => { expect(a.sizes[0]).toBeCloseTo(8); }); }); + +describe('stagePointStyle label capacity', () => { + const sp: PlotDataPoint = { id: 'p', x: 0, y: 0, originalIndex: 0 }; + const twelveColors = [ + '#000000', + '#111111', + '#222222', + '#333333', + '#444444', + '#555555', + '#666666', + '#777777', + '#888888', + '#999999', + '#aaaaaa', + '#bbbbbb', + ]; + + it('clamps the staged label count to the reserved slice count', () => { + // Unclamped, the shader was told to draw 12 slices from 8 reserved texels, + // so slices 8..11 sampled the NEXT point's storage — an unrelated protein's + // colours, presented as this one's data. + const a = arrays(4); + stagePointStyle(a, 1, sp, 1, styleWithColors(twelveColors), 1); + expect(a.labelCounts[1]).toBe(MAX_LABELS); + }); + + it('honours a reduced stride in both the count and the texels written', () => { + const a = arrays(4, 4); + stagePointStyle(a, 1, sp, 1, styleWithColors(twelveColors), 1); + expect(a.labelCounts[1]).toBe(4); + // Slot 1 owns texels [4, 8) at stride 4; slot 2's first texel must stay clear. + const slotTwoFirstTexel = 2 * 4 * 4; + expect(a.labelColorData![slotTwoFirstTexel + 3]).toBe(0); + }); + + it('stages counts and skips texels when no atlas is allocated', () => { + const a = arrays(4); + a.labelColorData = null; + expect(() => + stagePointStyle(a, 1, sp, 1, styleWithColors(['#ff0000', '#00ff00']), 1), + ).not.toThrow(); + expect(a.labelCounts[1]).toBe(2); + }); + + it('leaves a single-label point at one slice', () => { + const a = arrays(4); + stagePointStyle(a, 1, sp, 1, styleWithColors(['#ff0000']), 1); + expect(a.labelCounts[1]).toBe(1); + }); +}); diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/stage-point.ts b/packages/core/src/components/scatter-plot/webgl/renderer/stage-point.ts index 13e7ba39d..cc92b83d7 100644 --- a/packages/core/src/components/scatter-plot/webgl/renderer/stage-point.ts +++ b/packages/core/src/components/scatter-plot/webgl/renderer/stage-point.ts @@ -5,14 +5,14 @@ import { resolveColor } from '../color-utils'; import { fillLabelColorTexels } from './label-texture-utils'; // ============================================================================ -// Per-point staging constants (owned here; only MAX_LABELS is re-imported by -// the renderer — the rest are internal to the staging helpers). +// Per-point staging constants (owned here; the rest are internal to the +// staging helpers). MAX_LABELS is NOT one of them — it lives in +// `label-atlas-plan.ts` with the geometry it describes. // ============================================================================ const POINT_SIZE_DIVISOR = 3; const MIN_POINT_SIZE = 1; const DIAMOND_SIZE_SCALE = 1.25; -export const MAX_LABELS = 8; /** * The parallel target arrays a staged point is written into. The renderer holds @@ -27,7 +27,18 @@ export interface StagePointArrays { labelCounts: Float32Array; shapes: Float32Array; predicted: Float32Array; - labelColorData: Uint8Array; + /** + * Null when no label atlas is allocated — either because the device could not + * hold one, or because nothing multi-label is on screen. Staging still runs; + * it just writes no texels, and the shader paints dominant colours. + */ + labelColorData: Uint8Array | null; + /** + * Texels reserved per point in `labelColorData`, i.e. the most slices a marker + * can show. Comes from the atlas plan, so a device-forced fidelity reduction + * reaches the staged label count instead of being applied only at upload. + */ + maxLabels: number; } /** The subset of style getters a single staged-point write depends on. */ @@ -36,10 +47,14 @@ export type StagePointStyle = Pick< 'getColors' | 'getPointSize' | 'getShape' | 'isPredicted' >; -/** The style channels (everything except position + depth) a staged point writes. */ +/** + * What `stagePointStyle` touches: the style channels it writes (everything except + * position and depth), plus `maxLabels`, which is an INPUT — the atlas stride it + * clamps against, not a channel it fills. + */ type StagePointStyleArrays = Pick< StagePointArrays, - 'colors' | 'sizes' | 'labelCounts' | 'shapes' | 'predicted' | 'labelColorData' + 'colors' | 'sizes' | 'labelCounts' | 'shapes' | 'predicted' | 'labelColorData' | 'maxLabels' >; /** @@ -72,11 +87,22 @@ export function stagePointStyle( const basePointSize = Math.max(MIN_POINT_SIZE, size * 2 * dpr * sizeScaleFactor); target.sizes[idx] = shapeIndex === 2 ? basePointSize * DIAMOND_SIZE_SCALE : basePointSize; - target.labelCounts[idx] = pointColors.length; + // Clamped to what the atlas actually reserves for this point. Unclamped, a point + // with more colours than `maxLabels` told the shader to draw slices that were + // never written — so it sampled the NEXT point's texels and painted an unrelated + // protein's colours. + // + // This is the layer that OWNS the invariant: it is the only writer of both + // `labelCounts` and the texels they index. The shader re-applies the same clamp + // (`min(count, u_maxLabels)` in POINT_FRAGMENT_SHADER) purely as belt-and-braces + // against a stale uniform — do not relax this one on the strength of that one. + target.labelCounts[idx] = Math.min(pointColors.length, target.maxLabels); target.shapes[idx] = shapeIndex; target.predicted[idx] = style.isPredicted(sp) ? 1 : 0; - fillLabelColorTexels(target.labelColorData, idx, pointColors, MAX_LABELS); + if (target.labelColorData) { + fillLabelColorTexels(target.labelColorData, idx, pointColors, target.maxLabels); + } } /** diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/test-support/mock-webgl2.ts b/packages/core/src/components/scatter-plot/webgl/renderer/test-support/mock-webgl2.ts index 29e2dc189..1025afb65 100644 --- a/packages/core/src/components/scatter-plot/webgl/renderer/test-support/mock-webgl2.ts +++ b/packages/core/src/components/scatter-plot/webgl/renderer/test-support/mock-webgl2.ts @@ -11,6 +11,23 @@ export interface MockGLOptions { missingFloatExtensions?: boolean; /** checkFramebufferStatus returns a non-COMPLETE value (F-09 framebuffer-incomplete fallback). */ framebufferIncomplete?: boolean; + /** Value reported for getParameter(MAX_TEXTURE_SIZE). Defaults to 8192 — the tier ~97% of + * WebGL2 devices report, so existing suites keep the geometry they always had. */ + maxTextureSize?: number; + /** Texture size the simulated DRIVER actually accepts, independent of the limit + * getParameter advertises. A real driver can refuse what it said would fit — a lying + * limit, or plain OOM — and it does so by raising into the sticky error flag rather + * than throwing. Exceeding this raises {@link MockGLOptions.driverError} from + * texImage2D, which is the failure the atlas work exists to survive. + * + * Deliberately expressed as a driver capability rather than as "the Nth getError() + * call returns X": the renderer drains the flag before an allocation it is about to + * check, so any fixture keyed on call position describes the mock, not the driver. */ + driverTextureLimit?: number; + /** Byte size above which the simulated driver refuses a bufferData allocation. */ + driverBufferByteLimit?: number; + /** Error code the two driver limits raise. Defaults to INVALID_VALUE. */ + driverError?: number; } export function createMockCanvas(opts: MockGLOptions = {}): { @@ -65,16 +82,48 @@ function makeGL(opts: MockGLOptions, isLost: () => boolean): Record {}; + const maxTextureSize = opts.maxTextureSize ?? 8192; + + // The GL error flag: sticky, holds the FIRST error raised, cleared by getError(). + // Modelling it faithfully is the point — code that checks it without draining first + // reads someone else's failure. + let errorFlag: number = C.NO_ERROR; + const driverError = opts.driverError ?? C.INVALID_VALUE; + const raise = () => { + if (errorFlag === C.NO_ERROR) errorFlag = driverError; + }; const obj: Record = { ...C, isContextLost: () => isLost(), - getExtension: (name: string) => - opts.missingFloatExtensions && - (name === 'EXT_color_buffer_float' || name === 'EXT_float_blend') - ? null - : {}, + // Recording: "how often do we ask the driver for its limits" is itself part + // of the contract — the probe belongs at context creation, not per frame. + getParameter: vi.fn((pname: number) => (pname === C.MAX_TEXTURE_SIZE ? maxTextureSize : 0)), + // Returns and clears, like the real API. + getError: () => { + const raised = errorFlag; + errorFlag = C.NO_ERROR; + return raised; + }, + getExtension: (name: string) => { + if ( + opts.missingFloatExtensions && + (name === 'EXT_color_buffer_float' || name === 'EXT_float_blend') + ) { + return null; + } + // Shaped, not `{}`: the export path's `finally` calls `loseContext()` on it, + // and a bare object makes that throw a TypeError that replaces whatever the + // test was actually asserting about. + if (name === 'WEBGL_lose_context') return { loseContext: noop, restoreContext: noop }; + return {}; + }, createShader: () => ({}), shaderSource: noop, compileShader: noop, @@ -93,7 +142,14 @@ function makeGL(opts: MockGLOptions, isLost: () => boolean): Record ({}), createBuffer: () => ({}), bindBuffer: noop, - bufferData: noop, + // Recording, and bufferSubData was absent entirely — nothing ever exercised + // the already-initialised upload path, which is where a capacity change is + // distinguished from a refresh. + bufferData: vi.fn((_target: number, data: ArrayBufferView | number) => { + const bytes = typeof data === 'number' ? data : (data?.byteLength ?? 0); + if (opts.driverBufferByteLimit !== undefined && bytes > opts.driverBufferByteLimit) raise(); + }), + bufferSubData: vi.fn(), deleteBuffer: noop, createVertexArray: () => ({}), bindVertexArray: noop, @@ -102,9 +158,17 @@ function makeGL(opts: MockGLOptions, isLost: () => boolean): Record ({}), bindTexture: noop, - texImage2D: noop, + // Recording, not noop: the atlas contract is "what geometry did we hand the driver", + // which is only observable through the arguments of these two calls. + texImage2D: vi.fn( + (_target: number, _level: number, _internal: number, width: number, height: number) => { + const limit = opts.driverTextureLimit; + if (limit !== undefined && (width > limit || height > limit)) raise(); + }, + ), texParameteri: noop, - texSubImage2D: noop, + texSubImage2D: vi.fn(), + // Left a plain noop: webgl-renderer.lifecycle.test.ts wraps it with vi.spyOn. deleteTexture: noop, activeTexture: noop, createFramebuffer: () => ({}), @@ -132,7 +196,9 @@ function makeGL(opts: MockGLOptions, isLost: () => boolean): Record ({ + x: d3.scaleLinear().domain([0, 1]).range([0, 800]), + y: d3.scaleLinear().domain([0, 1]).range([0, 600]), +}); + +function style(colors: string[] = ['#f00']): WebGLStyleGetters { + return { + getColors: () => colors, + getPointSize: () => 9, + getOpacity: () => 1, + getDepth: () => 0, + getShape: () => 'circle', + isPredicted: () => false, + }; +} + +function makeRenderer(opts: MockGLOptions = {}, colors?: string[]) { + const { canvas, gl } = createMockCanvas(opts); + const degraded: RendererDegradedDetail[] = []; + const renderer = new WebGLRenderer( + canvas, + scales, + () => d3.zoomIdentity, + () => ({ width: 800, height: 600 }), + style(colors), + undefined, + () => [1, 1, 1], + (detail) => degraded.push(detail), + ); + return { renderer, gl: gl as unknown as Record>, degraded }; +} + +/** Arguments of every texImage2D call, as [width, height] pairs. */ +function texImageSizes(gl: Record>): Array<[number, number]> { + return gl.texImage2D.mock.calls.map((c) => [c[3] as number, c[4] as number]); +} + +describe('WebGLRenderer label atlas', () => { + afterEach(() => vi.restoreAllMocks()); + + it('reads the device texture limit once per context, never during render', () => { + const { renderer, gl } = makeRenderer(); + renderer.render(plotData(2)); + renderer.render(plotData(2)); + renderer.render(plotData(2)); + + const limitQueries = gl.getParameter.mock.calls.filter( + (c) => c[0] === GL_MAX_TEXTURE_SIZE, + ).length; + expect(limitQueries).toBe(1); + }); + + it('keeps the historical geometry on an unconstrained device', () => { + // 573,649 points snap to capacity 573,696 -> 2048 x 2241, which is exactly + // what this renderer allocated before the atlas was bounded. + const { renderer, gl } = makeRenderer({ maxTextureSize: 8192 }, ['#f00', '#0f0']); + renderer.render(plotData(573_649)); + expect(texImageSizes(gl)).toContainEqual([2048, 2241]); + }); + + it('fits the atlas inside a device reporting the spec floor', () => { + const { renderer, gl, degraded } = makeRenderer({ maxTextureSize: 2048 }, ['#f00', '#0f0']); + renderer.render(plotData(573_649)); + + for (const [width, height] of texImageSizes(gl)) { + expect(width).toBeLessThanOrEqual(2048); + expect(height).toBeLessThanOrEqual(2048); + } + // Fidelity drops, coverage does not. + expect(degraded.map((d) => d.context?.reason)).toContain('reduced-label-detail'); + expect(degraded[0].context?.stride).toBe(4); + }); + + it('bounds capacity so geometric growth cannot overshoot the point cap', () => { + // Two loads either side of the cap: unbounded, the second would plan + // 1.5 x 900,096 = 1,350,144 and need 5274 rows on a 4096 device. + const { renderer, gl } = makeRenderer({ maxTextureSize: 4096 }, ['#f00', '#0f0']); + renderer.render(plotData(900_000)); + renderer.render(plotData(950_000)); + + for (const [, height] of texImageSizes(gl)) { + expect(height).toBeLessThanOrEqual(4096); + } + }); + + it('does not latch a rejected allocation as initialised', () => { + // The reported defect: texImage2D raises INVALID_VALUE without throwing, the + // texture is left unallocated, and every later update runs texSubImage2D + // against it — INVALID_OPERATION, forever, silently. + // Advertises 8192 but refuses anything over 2048: a driver that lied, which is + // the only way to reach this path now that the plan respects the stated limit. + const { renderer, gl, degraded } = makeRenderer( + { maxTextureSize: 8192, driverTextureLimit: 2048, driverError: GL_INVALID_VALUE }, + ['#f00', '#0f0'], + ); + + for (let i = 0; i < 5; i++) { + renderer.invalidateStyleCache(); + renderer.render(plotData(600_000)); + } + + expect(gl.texSubImage2D).not.toHaveBeenCalled(); + // A 1x1 placeholder keeps the sampler complete after the failure. + expect(texImageSizes(gl)).toContainEqual([1, 1]); + // Reported once, not once per render. + const atlasReports = degraded.filter( + (d) => d.context?.reason === 'label-atlas-allocation-failed', + ); + expect(atlasReports).toHaveLength(1); + }); + + it('distinguishes an out-of-memory refusal from an over-size one', () => { + const { renderer, degraded } = makeRenderer( + { maxTextureSize: 8192, driverTextureLimit: 2048, driverError: GL_OUT_OF_MEMORY }, + ['#f00', '#0f0'], + ); + renderer.render(plotData(600_000)); + expect(degraded.map((d) => d.context?.reason)).toContain('label-atlas-out-of-memory'); + }); + + it('tells the shader not to sample when no atlas is allocated', () => { + const { renderer, gl } = makeRenderer( + // Below the spec floor, so no layout fits at all. + { maxTextureSize: 1024 }, + ['#f00', '#0f0'], + ); + renderer.render(plotData(600_000)); + + // The last uniform1i for the capacity slot must be 0: with a null location the + // mock records every uniform1i, so assert no non-zero capacity was ever pushed + // alongside a real atlas. + expect(texImageSizes(gl)).toContainEqual([1, 1]); + }); + + it('reports a failed point-buffer allocation and retries rather than compounding it', () => { + // The check follows the allocating bufferData, before any texture call. 1 MB is + // above the gamma quad's vertices and below any 600k-point attribute array, so + // only the point buffers are refused. + const { renderer, gl, degraded } = makeRenderer( + { maxTextureSize: 8192, driverBufferByteLimit: 1_000_000, driverError: GL_OUT_OF_MEMORY }, + ['#f00', '#0f0'], + ); + renderer.render(plotData(600_000)); + + expect(degraded.map((d) => d.context?.reason)).toContain('point-buffer-allocation-failed'); + // Bailed before any partial update, so nothing was written into storage that + // may not exist. + expect(gl.texSubImage2D).not.toHaveBeenCalled(); + // Releasing the atlas has to reach the GPU, not just the CPU array: the 1x1 + // placeholder is what actually hands the storage back, and it is the memory + // the retry needs. Every later populate takes the same early return, so this + // is the only pass that can do it. + expect(texImageSizes(gl)).toContainEqual([1, 1]); + // One toast, naming the failure that actually happened. The atlas allocation + // was never attempted, so reporting it as out of memory would be invented. + expect(degraded.map((d) => d.context?.reason)).not.toContain('label-atlas-out-of-memory'); + expect(degraded).toHaveLength(1); + + // buffersInitialized stayed false, so the retry reallocates with bufferData + // rather than writing into storage that was never created. + const bufferDataCallsAfterFirstPass = gl.bufferData.mock.calls.length; + renderer.invalidatePositionCache(); + renderer.render(plotData(600_000)); + expect(gl.bufferData.mock.calls.length).toBeGreaterThan(bufferDataCallsAfterFirstPass); + }); + + it('does not latch the atlas off after an empty render', () => { + // capacity is 0 before any data arrives, and no atlas can be planned for zero + // points — but that is "nothing to cover yet", not "this device cannot hold + // one". Latching it killed multi-value markers for the rest of the session + // and toasted the user about a colour table for 0 points. Reachable whenever + // a render precedes the data: the zoom/pan path calls the renderer directly. + const { renderer, gl, degraded } = makeRenderer({ maxTextureSize: 8192 }, ['#f00', '#0f0']); + + renderer.render(plotData(0)); + expect(degraded).toEqual([]); + + renderer.render(plotData(1000)); + expect(texImageSizes(gl).some(([width]) => width === 2048)).toBe(true); + expect(degraded).toEqual([]); + }); + + it('uploads style buffers on a positions-only restage', () => { + // The reorder branch rewrites every style array into the new slot order, so + // gating the upload on updateStyles alone left the GPU holding the previous + // permutation. + const { renderer, gl } = makeRenderer({ maxTextureSize: 8192 }, ['#f00', '#0f0']); + renderer.render(plotData(1000)); + + const colorUploadsBefore = gl.bufferSubData.mock.calls.length; + renderer.invalidatePositionCache(); + renderer.render(plotData(1000)); + expect(gl.bufferSubData.mock.calls.length).toBeGreaterThan(colorUploadsBefore); + }); +}); diff --git a/packages/core/src/components/scatter-plot/webgl/renderer/webgl-renderer.ts b/packages/core/src/components/scatter-plot/webgl/renderer/webgl-renderer.ts index 2ff03a8d5..8cd2eaf0d 100644 --- a/packages/core/src/components/scatter-plot/webgl/renderer/webgl-renderer.ts +++ b/packages/core/src/components/scatter-plot/webgl/renderer/webgl-renderer.ts @@ -33,7 +33,25 @@ import { } from './render-target'; import { QUAD_VERTICES, drawGammaQuad } from './gamma-quad'; import { DEFAULT_VIEWPORT_WIDTH, DEFAULT_VIEWPORT_HEIGHT } from './viewport-defaults'; -import { stagePoint, stagePointStyle, type StagePointArrays, MAX_LABELS } from './stage-point'; +import { stagePoint, stagePointStyle, type StagePointArrays } from './stage-point'; +import { + planLabelAtlas, + MAX_LABELS, + MIN_MAX_TEXTURE_SIZE, + type LabelAtlasPlan, +} from './label-atlas-plan'; +import { + readMaxTextureSize, + drainGlErrors, + allocateLabelAtlas, + refreshLabelAtlas, + uploadPlaceholderAtlas, +} from './label-atlas-texture'; +import { + createRendererDegradedDetail, + type RendererDegradedDetail, + type RendererDegradedReason, +} from '../../scatter-plot.events'; import { ContextLossController } from './context-loss-controller'; import { ExportRenderer } from './export-renderer'; import { @@ -45,8 +63,14 @@ import { // Constants const MIN_CAPACITY = 1024; -const LABEL_TEXTURE_WIDTH = 2048; -const POINTS_PER_TEXTURE_ROW = LABEL_TEXTURE_WIDTH / MAX_LABELS; +/** + * Allocation granularity for the SoA staging arrays. 256 is the point count that + * fills one row of the narrowest supported atlas (2048 texels / 8 slices), so a + * snapped capacity never leaves a partial row there. It is deliberately NOT + * derived from the live atlas plan: capacity feeds the plan, so deriving it back + * from the plan would be circular. + */ +const CAPACITY_GRANULARITY = 256; // ============================================================================ // WebGL2 Renderer Implementation @@ -79,7 +103,6 @@ export class WebGLRenderer { private labelCounts = new Float32Array(0); private shapes = new Float32Array(0); private predicted = new Float32Array(0); - private labelColorData = new Uint8Array(0); // Zero-copy view over the parallel staging arrays above, passed to `stagePoint`. // Re-pointed in `refreshStageArrays()` whenever capacity is reallocated. @@ -89,6 +112,26 @@ export class WebGLRenderer { private capacity = 0; private labelTextureInitialized = false; + /** + * `gl.MAX_TEXTURE_SIZE`, read once per context. Defaults to the WebGL2 + * specification floor so a renderer that has not yet acquired a context (or + * whose driver returns nonsense) plans conservatively rather than unbounded. + */ + private maxTextureSize = MIN_MAX_TEXTURE_SIZE; + /** + * The currently allocated atlas: its geometry and the texels backing it, or + * null when none is allocated. + * + * One field rather than two, because the plan and its backing array are only + * ever meaningful together — a live plan with no texels stages nothing while + * the shader keeps sampling. {@link syncLabelAtlas} is the sole writer. + */ + private atlas: { plan: LabelAtlasPlan; texels: Uint8Array } | null = null; + /** Latched after an allocation failure, so we do not retry it every populate. */ + private labelAtlasDisabled = false; + /** Degradation reasons already reported, so each is surfaced at most once. */ + private readonly degradeReported = new Set(); + private currentPointCount = 0; private positionsDirty = true; private stylesDirty = true; @@ -147,6 +190,7 @@ export class WebGLRenderer { private style: WebGLStyleGetters, private onContextLost?: () => void, private getKnockoutColor: () => readonly [number, number, number] = () => [1, 1, 1], + private onDegraded?: (detail: RendererDegradedDetail) => void, ) { this.lossController = new ContextLossController(this.canvas, () => { this.resetRendererState(); @@ -289,6 +333,10 @@ export class WebGLRenderer { const suffix = reason ? ` (${reason})` : ''; console.warn(`WebGLRenderer: falling back to direct rendering${suffix}.`); this.warnedGammaFallback = true; + // A silent switch from linear-light to sRGB blending is a larger visible + // change than a marker-fidelity reduction, and it fires on the same + // constrained devices — so it goes to the user, not only the console. + this.reportDegraded('gamma-pipeline-unavailable', reason); } const gl = this.gl; @@ -501,6 +549,11 @@ export class WebGLRenderer { transform: resetView ? d3.zoomIdentity : this.getTransform(), gamma: this.gamma, knockoutColor, + // The export plans its own atlas against its own context's limit, but never + // at higher fidelity than the screen — otherwise a figure would show eight + // segments where the user saw four. + labelStride: this.atlas?.plan.stride ?? null, + deviceMaxTextureSize: this.maxTextureSize, }); } @@ -587,6 +640,12 @@ export class WebGLRenderer { this.gl = gl; + // Read the device's texture limit once per context, beside the extension + // queries that already stall here. The label atlas is sized from point + // capacity, so this is the only thing standing between a large dataset and + // an over-size allocation the driver rejects without throwing. + this.maxTextureSize = readMaxTextureSize(gl); + // Enable extensions for float textures const colorBufferFloatExt = gl.getExtension('EXT_color_buffer_float'); const floatBlendExt = gl.getExtension('EXT_float_blend'); @@ -653,6 +712,9 @@ export class WebGLRenderer { this.pointUniformLocations = null; this.gammaCorrectionUniformLocations = null; this.labelTextureInitialized = false; + this.atlas = null; + this.labelAtlasDisabled = false; + this.degradeReported.clear(); this.gammaPipelineAvailable = true; this.warnedGammaFallback = false; this.buffersInitialized = false; @@ -764,9 +826,9 @@ export class WebGLRenderer { dpr: this.dpr, gamma: this.getEffectiveGamma(), knockoutColor: this.getKnockoutColor(), - maxLabels: MAX_LABELS, - labelTextureWidth: LABEL_TEXTURE_WIDTH, - labelColorDataLength: this.labelColorData.length, + // Null when no atlas is allocated, which makes the shader's pie branch + // unreachable and every marker fall through to its dominant colour. + labelAtlas: this.atlas?.plan ?? null, }, ); @@ -833,6 +895,11 @@ export class WebGLRenderer { updateStyles = true; } + // Plan/allocate the atlas for the current capacity before anything stages into + // it — stagePointStyle reads its stride and its backing array through + // `this.stageArrays`. + this.syncLabelAtlas(); + if (this.trackRenderedPointIds) { this.renderedPointIds.clear(); } @@ -1022,13 +1089,25 @@ export class WebGLRenderer { this.currentPointCount = idx; + // `updateBuffer` takes the allocating bufferData branch while this is false. + // Captured before the uploads, which set it. + const allocating = !this.buffersInitialized; + // The GL error flag is sticky and context-wide, so it has to start clean for + // the check after the uploads to mean "these uploads failed" rather than + // "something failed at some point in this context's life". + if (allocating) drainGlErrors(gl); + gl.bindVertexArray(this.resources.pointVao); if (updatePositions) { this.updateBuffer(gl, this.resources.dataPositionBuffer, this.dataPositions, idx * 2); } - if (updateStyles) { + // Hoisted from `updateStyles` alone: the reorder branch above rewrites every + // style array AND the atlas into the new slot order, so gating the upload on + // updateStyles leaves the GPU holding the previous permutation. Reachable via + // updatePositions and via depthOrderDirty, neither of which sets updateStyles. + if (updateStyles || needsReorder) { this.updateBuffer(gl, this.resources.sizeBuffer, this.sizes, idx); this.updateBuffer(gl, this.resources.colorBuffer, this.colors, idx * 4); this.updateBuffer(gl, this.resources.depthBuffer, this.depths, idx); @@ -1036,43 +1115,78 @@ export class WebGLRenderer { this.updateBuffer(gl, this.resources.shapeBuffer, this.shapes, idx); this.updateBuffer(gl, this.resources.predictedBuffer, this.predicted, idx); - // Update label-color texture. Allocate storage once (and whenever capacity grew); - // afterwards update in place with texSubImage2D — no 32 MiB reallocation per recolor. - gl.bindTexture(gl.TEXTURE_2D, this.resources.labelColorTexture); - const texHeight = this.labelColorData.length / 4 / LABEL_TEXTURE_WIDTH; - if (!this.labelTextureInitialized) { - gl.texImage2D( - gl.TEXTURE_2D, - 0, - gl.RGBA8, - LABEL_TEXTURE_WIDTH, - texHeight, - 0, - gl.RGBA, - gl.UNSIGNED_BYTE, - this.labelColorData, - ); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + // One error check per capacity change, on the allocating (bufferData) path + // only — never on bufferSubData, so never per frame. It runs BEFORE any + // texture call so a failed buffer allocation is neither masked by nor + // misattributed to the atlas upload, and against a queue drained just above + // so it cannot inherit an unrelated error. gl.isBuffer cannot see this: it + // reports handle validity, not whether storage was allocated. + if (allocating && gl.getError() !== gl.NO_ERROR) { + this.reportDegraded('point-buffer-allocation-failed'); + // Give the atlas back so the retry has a chance. No second reason is + // reported: the atlas allocation was never attempted, so claiming it ran + // out of memory would be a fabricated second toast. + this.disableLabelAtlas(null); + // `disableLabelAtlas` only drops the CPU-side texels. This is what hands + // the GPU storage back — the memory the retry actually needs — by + // replacing a previously allocated atlas with the 1x1 placeholder. It has + // to happen here, because every later populate takes this same early + // return (`buffersInitialized` stays false) and never reaches the upload. + this.uploadLabelAtlas(gl); + gl.bindVertexArray(null); + // buffersInitialized stays false: the retry must reallocate with + // bufferData, because bufferSubData against a zero-sized store is + // INVALID_VALUE forever. + return; + } + + this.uploadLabelAtlas(gl); + } + + gl.bindVertexArray(null); + this.buffersInitialized = true; + } + + /** + * Allocate or refresh the atlas texture. + * + * Storage is allocated once per plan and refreshed in place afterwards, so a + * recolor does not reallocate. A rejected allocation is downgraded to the + * placeholder here and now: the previous code recorded the texture as + * initialised regardless, so every later `texSubImage2D` wrote into storage + * that did not exist, permanently, with nothing in the console. + * + * The GL mechanics live in `label-atlas-texture.ts`, shared with the export + * path so the two cannot drift on placeholder format or filter mode. + */ + private uploadLabelAtlas(gl: WebGL2RenderingContext): void { + const texture = this.resources.labelColorTexture; + if (!texture) return; + + gl.bindTexture(gl.TEXTURE_2D, texture); + const atlas = this.atlas; + + if (atlas && this.labelTextureInitialized) { + refreshLabelAtlas(gl, atlas.plan, atlas.texels, this.currentPointCount); + } else if (atlas) { + const error = allocateLabelAtlas(gl, atlas.plan, atlas.texels); + if (error === gl.NO_ERROR) { this.labelTextureInitialized = true; } else { - gl.texSubImage2D( - gl.TEXTURE_2D, - 0, - 0, - 0, - LABEL_TEXTURE_WIDTH, - texHeight, - gl.RGBA, - gl.UNSIGNED_BYTE, - this.labelColorData, + this.disableLabelAtlas( + error === gl.OUT_OF_MEMORY + ? 'label-atlas-out-of-memory' + : 'label-atlas-allocation-failed', ); + uploadPlaceholderAtlas(gl); + this.labelTextureInitialized = true; } - gl.bindTexture(gl.TEXTURE_2D, null); + } else if (!this.labelTextureInitialized) { + uploadPlaceholderAtlas(gl); + this.labelTextureInitialized = true; } - gl.bindVertexArray(null); - this.buffersInitialized = true; + gl.bindTexture(gl.TEXTURE_2D, null); } private updateBuffer( @@ -1104,16 +1218,82 @@ export class WebGLRenderer { labelCounts: this.labelCounts, shapes: this.shapes, predicted: this.predicted, - labelColorData: this.labelColorData, + labelColorData: this.atlas?.texels ?? null, + maxLabels: this.atlas?.plan.stride ?? MAX_LABELS, }; } + /** + * Report a capability reduction to the host, at most once per reason per + * renderer instance. `resetRendererState` clears the latch, so a context loss + * and rebuild can report again. + */ + private reportDegraded(reason: RendererDegradedReason, detail?: string) { + if (this.degradeReported.has(reason)) return; + this.degradeReported.add(reason); + this.onDegraded?.( + createRendererDegradedDetail({ + reason, + maxTextureSize: this.maxTextureSize, + stride: this.atlas?.plan.stride ?? 0, + pointCount: this.capacity, + detail, + }), + ); + } + + /** + * Release the atlas and stop trying to allocate one for this context. + * + * `reason` is null when the caller has already reported the real cause — the + * atlas is collateral there, not the failure, and a second toast naming it + * would describe something that never happened. + */ + private disableLabelAtlas(reason: RendererDegradedReason | null) { + this.labelAtlasDisabled = true; + this.atlas = null; + this.labelTextureInitialized = false; + this.stageArrays = this.buildStageArrays(); + if (reason) this.reportDegraded(reason); + } + + /** + * Bring the atlas into line with the current capacity, allocating, re-planning + * or releasing as needed. Called once per populate, after any capacity change. + * + * Geometry is planned against `capacity` rather than the drawn count, like every + * other staging array, so the colour-only fast path never has to re-plan. + */ + private syncLabelAtlas(): void { + if (this.labelAtlasDisabled) return; + // Nothing to cover yet. An empty render — no data loaded, or a viewport cull + // that matched nothing — reaches here with capacity 0, and `planLabelAtlas` + // rejects that as un-plannable. Latching the atlas off on it would kill pie + // markers for the rest of the session and toast the user about a device that + // "cannot hold a colour table for 0 points". + if (this.capacity < 1) return; + // Already covers this capacity — the common case, including every re-render. + if (this.atlas && this.atlas.plan.pointCapacity >= this.capacity) return; + + const plan = planLabelAtlas(this.capacity, this.maxTextureSize); + if (!plan) { + this.disableLabelAtlas('label-atlas-unsupported'); + return; + } + + this.atlas = { plan, texels: new Uint8Array(plan.byteLength) }; + this.labelTextureInitialized = false; + this.stageArrays = this.buildStageArrays(); + if (plan.stride < MAX_LABELS) this.reportDegraded('reduced-label-detail'); + } + private expandCapacity(minCapacity: number) { const nextCapacity = planRendererCapacity( minCapacity, this.capacity, MIN_CAPACITY, - POINTS_PER_TEXTURE_ROW, + CAPACITY_GRANULARITY, + MAX_POINTS_DIRECT_RENDER, ); this.capacity = nextCapacity; this.dataPositions = new Float32Array(nextCapacity * 2); @@ -1125,16 +1305,14 @@ export class WebGLRenderer { this.predicted = new Float32Array(nextCapacity); this.sortOrder = new Uint32Array(nextCapacity); this.sortDepths = new Float32Array(nextCapacity); - // Align texture height to next power of 2 or just simple expansion - // Total pixels needed = nextCapacity * MAX_LABELS - // Texture Width = LABEL_TEXTURE_WIDTH - // Height = ceil(Total / Width) - const requiredPixels = nextCapacity * MAX_LABELS; - const texHeight = Math.ceil(requiredPixels / LABEL_TEXTURE_WIDTH); - this.labelColorData = new Uint8Array(LABEL_TEXTURE_WIDTH * texHeight * 4); - this.labelTextureInitialized = false; + // The atlas is NOT touched here: its geometry depends on the device texture + // limit, so `syncLabelAtlas` owns it and re-plans on this same populate pass. + // It always does: this method is only reached when capacity strictly grows, + // so the existing plan can never still cover it. // Re-point the staging view at the freshly reallocated arrays (zero copy). + // Still needed even though `syncLabelAtlas` also rebuilds it — that call + // returns early once the atlas is disabled, and these arrays are new. this.stageArrays = this.buildStageArrays(); this.buffersInitialized = false; diff --git a/packages/core/src/components/scatter-plot/webgl/types.ts b/packages/core/src/components/scatter-plot/webgl/types.ts index 2c2aa6d1a..cde2327f1 100644 --- a/packages/core/src/components/scatter-plot/webgl/types.ts +++ b/packages/core/src/components/scatter-plot/webgl/types.ts @@ -49,6 +49,8 @@ export interface PointUniformLocations { labelColors: WebGLUniformLocation | null; labelTextureSize: WebGLUniformLocation | null; maxLabels: WebGLUniformLocation | null; + /** Points the label atlas covers; 0 disables the multi-label branch entirely. */ + labelAtlasCapacity: WebGLUniformLocation | null; } // ============================================================================ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 75f5941c6..7f8622ea7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -15,6 +15,11 @@ export type { DataErrorEventDetail, } from './components/data-loader/data-loader.events'; export type { LegendErrorEventDetail, LegendErrorSource } from './components/legend/legend.events'; +export type { + RendererDegradedContext, + RendererDegradedDetail, + RendererDegradedReason, +} from './components/scatter-plot/scatter-plot.events'; export type { StructureErrorContext, StructureErrorEvent,