From 2bb87cfcae0654c254a8ee507e8a566ec6a50beb Mon Sep 17 00:00:00 2001 From: Joe Blau Date: Tue, 29 Sep 2026 19:32:16 -0700 Subject: [PATCH] feat: complete GPU footprint labels and backend benchmarks --- packages/chart/PERFORMANCE.md | 46 +++ packages/chart/bench/backends.bench.mjs | 62 ++++ .../bench/results/2026-09-29-backends.json | 266 ++++++++++++++++++ packages/chart/bench/run-browser.mjs | 13 +- packages/chart/bench/verify-glyphs.mjs | 49 ++++ packages/chart/src/core/chart.ts | 8 +- packages/chart/src/dom.ts | 36 +++ packages/chart/src/render/gl/backend.ts | 78 ++++- packages/chart/src/render/gl/glyph-atlas.ts | 62 ++++ packages/chart/src/render/renderer.ts | 2 + packages/chart/src/series/footprint.ts | 11 +- packages/chart/src/series/types.ts | 3 + packages/chart/test/glyph-atlas.test.ts | 111 ++++++++ 13 files changed, 730 insertions(+), 17 deletions(-) create mode 100644 packages/chart/bench/backends.bench.mjs create mode 100644 packages/chart/bench/results/2026-09-29-backends.json create mode 100644 packages/chart/bench/verify-glyphs.mjs create mode 100644 packages/chart/src/render/gl/glyph-atlas.ts create mode 100644 packages/chart/test/glyph-atlas.test.ts diff --git a/packages/chart/PERFORMANCE.md b/packages/chart/PERFORMANCE.md index 9bb4bfe..05e0dfc 100644 --- a/packages/chart/PERFORMANCE.md +++ b/packages/chart/PERFORMANCE.md @@ -1,5 +1,51 @@ # Performance measurements +## Order-flow backend audit — September 29, 2026 + +The WebGL2 backend now uses an instanced glyph atlas for dense footprint +bid×ask labels, alongside its candle/heatmap quads. The atlas caches the 15 +characters needed by the compact numeric formatter, rasterizes once per pixel +ratio, and releases its texture/canvas on destroy. Missing atlas canvases, +failed texture allocation, unsupported text and unsupported colors keep the +Canvas2D label path. Axes and other text continue to use Canvas2D. Separate RGB +and alpha blend factors preserve opacity when the GL buffer is composited. + +Measured in headless Chrome 154 on an Apple M3 Max with 128 GiB RAM, at +1,264 × 900 CSS pixels and 1× backing ratio. Each workload has ten warmup +operations and 90 animation-frame samples. There are 1,440 stored one-minute +candles; depth history has 50 levels per side, one snapshot per minute. The +144,000-point case retains and displays a complete 24-hour session. The harness +asserts that the requested backend and all retained snapshots are present. + +CPU milliseconds: median (p95). Animation FPS is computed from rAF intervals; +it is not compositor FPS, GPU raster time or a guarantee on other hardware or +at finer depth-history resolution. The old mock backend benchmark remains a +CPU-only measurement. + +| Retained depth points | Workload | Canvas2D CPU ms | WebGL2 CPU ms | Canvas2D animation FPS | WebGL2 animation FPS | +| ---: | --- | ---: | ---: | ---: | ---: | +| 1,000 | pan | 0.5 (0.6) | 0.3 (0.4) | 60.0 | 60.0 | +| 1,000 | zoom | 0.5 (0.6) | 0.3 (0.4) | 60.0 | 60.0 | +| 50,000 | pan | 15.4 (17.5) | 4.4 (4.7) | 57.4 | 60.0 | +| 50,000 | zoom | 15.1 (17.4) | 4.4 (4.5) | 58.0 | 60.0 | +| 144,000 | pan | 39.3 (42.3) | 11.9 (12.2) | 24.1 | 60.0 | +| 144,000 | zoom | 38.9 (42.1) | 12.2 (12.8) | 24.3 | 60.0 | + +The full-session WebGL2 runs had zero intervals above 25 ms; Canvas2D had 89 +in each run. Raw measurements, browser version and timestamp are saved in +[the backend results](bench/results/2026-09-29-backends.json). + +Reproduce with Chrome installed (or set `CHART_BENCH_BROWSER`): + +```sh +npm run bench:browser --workspace @bloxwap/chart -- --backends=true --frames=90 --output=/tmp/chart-backends.json +node packages/chart/bench/run-browser.mjs --glyphs=true +``` + +The glyph browser check compares painted bounds, RGB and alpha with Canvas2D +at 1×/2× and repeats the frame to check atlas reuse and GL errors. The library +suite passes 1,855 tests with 100% line, branch and function coverage. + ## Rendering and interaction audit — September 26, 2026 Compared commit `9acee8d` with this working tree on the same Apple M3 Max diff --git a/packages/chart/bench/backends.bench.mjs b/packages/chart/bench/backends.bench.mjs new file mode 100644 index 0000000..289b1c0 --- /dev/null +++ b/packages/chart/bench/backends.bench.mjs @@ -0,0 +1,62 @@ +/** Real Canvas2D/WebGL2 pan/zoom, with a full day of time-bucketed depth. */ +export async function runBackends({ frames = 90 } = {}) { + if (!Number.isInteger(frames) || frames < 3) throw new Error('frames must be an integer >= 3'); + const { createChart, attachHeatmap, DepthHistory } = await import('/dist/index.js'); + const data = Array.from({ length: 1440 }, (_, i) => ({ + time: 1700000040 + i * 60, open: 100, high: 113, low: 87, + close: 100 + Math.sin(i / 10), volume: 100, + })); + const summarize = values => { + const sorted = [...values].sort((a, b) => a - b); + return { p50: sorted[Math.floor(sorted.length * .5)], p95: sorted[Math.floor(sorted.length * .95)], max: sorted.at(-1) }; + }; + const results = []; + for (const buckets of [10, 500, 1440]) for (const renderer of ['canvas2d', 'webgl2']) { + const canvas = document.createElement('canvas'); + canvas.width = 1264; canvas.height = 900; + document.body.append(canvas); + const chart = createChart({ container: canvas, renderer, pixelRatio: 1, + config: { data, wasm: false, heatmap: { enabled: false, bucketMs: 60000, maxBuckets: buckets, maxLevels: 50 } } }); + const history = new DepthHistory({ bucketMs: 60000, maxBuckets: buckets, maxLevels: 50 }); + const heatmap = attachHeatmap(chart, { history }); + try { + if (chart.renderBackend !== renderer) throw new Error(`Requested ${renderer}, got ${chart.renderBackend}`); + chart.batch(() => { + for (let i = 0; i < buckets; i++) { + const bids = [], asks = []; + for (let j = 0; j < 50; j++) { + bids.push([99.75 - j * .25, 1 + (i * 7 + j * 11) % 100]); + asks.push([100.25 + j * .25, 1 + (i * 11 + j * 7) % 100]); + } + const book = { time: data[data.length - buckets + i].time * 1000, bids, asks }; + history.record(book); + chart.setDepth(book); + } + }); + chart.scale.zoomToRange(data.length - buckets, data.length - 1); + if (history.length !== buckets) throw new Error('Incomplete retained depth history'); + for (const [workload, run] of Object.entries({ + pan: i => chart.scale.scrollBy(i % 2 ? 1 : -1), + zoom: i => chart.scale.zoom(i % 2 ? 1.01 : 1 / 1.01, 600), + })) { + for (let i = 0; i < 10; i++) run(i); + const cpu = [], intervals = []; + let previous; + for (let i = 0; i < frames; i++) await new Promise(resolve => requestAnimationFrame(time => { + if (previous !== undefined) intervals.push(time - previous); + previous = time; + const start = performance.now(); run(i); cpu.push(performance.now() - start); + resolve(); + })); + const frameIntervalMs = summarize(intervals); + results.push({ renderer, workload, retainedDepthPoints: buckets * 100, buckets, + bucketMs: 60000, sessionHours: buckets / 60, visibleBars: chart.scale.visibleRange().to - chart.scale.visibleRange().from, + cpuMs: summarize(cpu), frameIntervalMs, + animationFps: 1000 / (intervals.reduce((sum, ms) => sum + ms, 0) / intervals.length), + framesOver25Ms: intervals.filter(ms => ms > 25).length }); + } + } finally { heatmap.remove(); chart.destroy(); canvas.remove(); } + } + return { userAgent: navigator.userAgent, frames, pixelRatio: 1, + measurement: 'CPU submission and animation-frame cadence; not compositor FPS or GPU raster time', results }; +} diff --git a/packages/chart/bench/results/2026-09-29-backends.json b/packages/chart/bench/results/2026-09-29-backends.json new file mode 100644 index 0000000..e1eebe3 --- /dev/null +++ b/packages/chart/bench/results/2026-09-29-backends.json @@ -0,0 +1,266 @@ +{ + "measuredAt": "2026-09-30T02:30:40.091Z", + "headless": true, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "frames": 90, + "pixelRatio": 1, + "measurement": "CPU submission and animation-frame cadence; not compositor FPS or GPU raster time", + "results": [ + { + "renderer": "canvas2d", + "workload": "pan", + "retainedDepthPoints": 1000, + "buckets": 10, + "bucketMs": 60000, + "sessionHours": 0.16666666666666666, + "visibleBars": 11, + "cpuMs": { + "p50": 0.5, + "p95": 0.6000000238418579, + "max": 0.7000000476837158 + }, + "frameIntervalMs": { + "p50": 16.699999999999932, + "p95": 16.799999999999955, + "max": 16.80000000000001 + }, + "animationFps": 60.00539374325782, + "framesOver25Ms": 0 + }, + { + "renderer": "canvas2d", + "workload": "zoom", + "retainedDepthPoints": 1000, + "buckets": 10, + "bucketMs": 60000, + "sessionHours": 0.16666666666666666, + "visibleBars": 12, + "cpuMs": { + "p50": 0.5, + "p95": 0.6000000238418579, + "max": 1 + }, + "frameIntervalMs": { + "p50": 16.699999999999818, + "p95": 16.799999999999955, + "max": 16.800000000000182 + }, + "animationFps": 60.00539374325783, + "framesOver25Ms": 0 + }, + { + "renderer": "webgl2", + "workload": "pan", + "retainedDepthPoints": 1000, + "buckets": 10, + "bucketMs": 60000, + "sessionHours": 0.16666666666666666, + "visibleBars": 11, + "cpuMs": { + "p50": 0.30000007152557373, + "p95": 0.40000009536743164, + "max": 0.6000000238418579 + }, + "frameIntervalMs": { + "p50": 16.699999999999818, + "p95": 16.700000000000728, + "max": 16.800000000000182 + }, + "animationFps": 60.001348344906624, + "framesOver25Ms": 0 + }, + { + "renderer": "webgl2", + "workload": "zoom", + "retainedDepthPoints": 1000, + "buckets": 10, + "bucketMs": 60000, + "sessionHours": 0.16666666666666666, + "visibleBars": 12, + "cpuMs": { + "p50": 0.30000007152557373, + "p95": 0.3999999761581421, + "max": 0.40000009536743164 + }, + "frameIntervalMs": { + "p50": 16.699999999999818, + "p95": 16.800000000000182, + "max": 16.800000000000182 + }, + "animationFps": 59.997303491977874, + "framesOver25Ms": 0 + }, + { + "renderer": "canvas2d", + "workload": "pan", + "retainedDepthPoints": 50000, + "buckets": 500, + "bucketMs": 60000, + "sessionHours": 8.333333333333334, + "visibleBars": 501, + "cpuMs": { + "p50": 15.399999976158142, + "p95": 17.5, + "max": 20.100000023841858 + }, + "frameIntervalMs": { + "p50": 16.699999999999818, + "p95": 16.800000000000182, + "max": 33.399999999999636 + }, + "animationFps": 57.4230595522292, + "framesOver25Ms": 4 + }, + { + "renderer": "canvas2d", + "workload": "zoom", + "retainedDepthPoints": 50000, + "buckets": 500, + "bucketMs": 60000, + "sessionHours": 8.333333333333334, + "visibleBars": 501, + "cpuMs": { + "p50": 15.100000023841858, + "p95": 17.399999976158142, + "max": 19.09999990463257 + }, + "frameIntervalMs": { + "p50": 16.69999999999891, + "p95": 16.799999999999272, + "max": 33.399999999999636 + }, + "animationFps": 58.04474010304575, + "framesOver25Ms": 3 + }, + { + "renderer": "webgl2", + "workload": "pan", + "retainedDepthPoints": 50000, + "buckets": 500, + "bucketMs": 60000, + "sessionHours": 8.333333333333334, + "visibleBars": 501, + "cpuMs": { + "p50": 4.400000095367432, + "p95": 4.700000047683716, + "max": 4.899999976158142 + }, + "frameIntervalMs": { + "p50": 16.69999999999891, + "p95": 16.700000000000728, + "max": 16.80000000000109 + }, + "animationFps": 60.00539374325787, + "framesOver25Ms": 0 + }, + { + "renderer": "webgl2", + "workload": "zoom", + "retainedDepthPoints": 50000, + "buckets": 500, + "bucketMs": 60000, + "sessionHours": 8.333333333333334, + "visibleBars": 501, + "cpuMs": { + "p50": 4.399999976158142, + "p95": 4.5, + "max": 4.699999928474426 + }, + "frameIntervalMs": { + "p50": 16.69999999999891, + "p95": 16.700000000000728, + "max": 16.80000000000109 + }, + "animationFps": 60.00134834490666, + "framesOver25Ms": 0 + }, + { + "renderer": "canvas2d", + "workload": "pan", + "retainedDepthPoints": 144000, + "buckets": 1440, + "bucketMs": 60000, + "sessionHours": 24, + "visibleBars": 1440, + "cpuMs": { + "p50": 39.299999952316284, + "p95": 42.299999952316284, + "max": 42.799999952316284 + }, + "frameIntervalMs": { + "p50": 33.400000000001455, + "p95": 50, + "max": 50.10000000000218 + }, + "animationFps": 24.05535434347802, + "framesOver25Ms": 89 + }, + { + "renderer": "canvas2d", + "workload": "zoom", + "retainedDepthPoints": 144000, + "buckets": 1440, + "bucketMs": 60000, + "sessionHours": 24, + "visibleBars": 1440, + "cpuMs": { + "p50": 38.90000009536743, + "p95": 42.10000002384186, + "max": 42.60000002384186 + }, + "frameIntervalMs": { + "p50": 33.400000000001455, + "p95": 50.10000000000218, + "max": 50.10000000000218 + }, + "animationFps": 24.27383062866494, + "framesOver25Ms": 89 + }, + { + "renderer": "webgl2", + "workload": "pan", + "retainedDepthPoints": 144000, + "buckets": 1440, + "bucketMs": 60000, + "sessionHours": 24, + "visibleBars": 1440, + "cpuMs": { + "p50": 11.899999976158142, + "p95": 12.200000047683716, + "max": 12.5 + }, + "frameIntervalMs": { + "p50": 16.69999999999709, + "p95": 16.700000000000728, + "max": 16.799999999999272 + }, + "animationFps": 60.00134834490666, + "framesOver25Ms": 0 + }, + { + "renderer": "webgl2", + "workload": "zoom", + "retainedDepthPoints": 144000, + "buckets": 1440, + "bucketMs": 60000, + "sessionHours": 24, + "visibleBars": 1440, + "cpuMs": { + "p50": 12.200000047683716, + "p95": 12.800000071525574, + "max": 13.099999904632568 + }, + "frameIntervalMs": { + "p50": 16.700000000000728, + "p95": 16.799999999999272, + "max": 16.80000000000291 + }, + "animationFps": 60.00134834490666, + "framesOver25Ms": 0 + } + ], + "hardware": { + "cpu": "Apple M3 Max", + "memoryGiB": 128 + } +} diff --git a/packages/chart/bench/run-browser.mjs b/packages/chart/bench/run-browser.mjs index 2d049f2..794a0a1 100644 --- a/packages/chart/bench/run-browser.mjs +++ b/packages/chart/bench/run-browser.mjs @@ -30,21 +30,28 @@ try { const errors = []; page.on('pageerror', error => errors.push(error.message)); await page.goto(`http://127.0.0.1:${server.address().port}`); - if (options.verify === 'true') { + if (options.glyphs === 'true') { + console.log(await page.evaluate(async () => (await import('/bench/verify-glyphs.mjs')).verifyGlyphs())); + if (errors.length) throw new Error(errors.join('\n')); + } else if (options.verify === 'true') { const result = await page.evaluate(async () => (await import('/bench/verify-browser.mjs')).verifyBrowser()); console.log(result); await verifyPlayground(page, `http://127.0.0.1:${server.address().port}`); if (errors.length) throw new Error(errors.join('\n')); } else { const result = await page.evaluate(async options => { + if (options.backends === 'true') { + const { runBackends } = await import('/bench/backends.bench.mjs'); + return runBackends({ frames: Number(options.frames ?? 90) }); + } const { runInteractions } = await import('/bench/interactions.bench.mjs'); return runInteractions({ base: options.baseline ? '/baseline/' : '/dist/', frames: Number(options.frames ?? 90) }); }, options); if (errors.length) throw new Error(errors.join('\n')); const report = { measuredAt: new Date().toISOString(), headless: options.headed !== 'true', ...result }; if (options.output) await writeFile(options.output, JSON.stringify(report, null, 2) + '\n'); - console.table(result.results.map(({ pixelRatio, visible, workload, cpuMs, frameIntervalMs, framesOver25Ms }) => ({ - pixelRatio, visible, workload, cpuP50: cpuMs.p50.toFixed(2), cpuP95: cpuMs.p95.toFixed(2), + console.table(result.results.map(({ renderer, retainedDepthPoints, animationFps, pixelRatio, visible, workload, cpuMs, frameIntervalMs, framesOver25Ms }) => ({ + ...(renderer ? { renderer, retainedDepthPoints, animationFps: animationFps.toFixed(1) } : { pixelRatio, visible }), workload, cpuP50: cpuMs.p50.toFixed(2), cpuP95: cpuMs.p95.toFixed(2), intervalP95: frameIntervalMs.p95.toFixed(2), framesOver25Ms, }))); } diff --git a/packages/chart/bench/verify-glyphs.mjs b/packages/chart/bench/verify-glyphs.mjs new file mode 100644 index 0000000..50afc27 --- /dev/null +++ b/packages/chart/bench/verify-glyphs.mjs @@ -0,0 +1,49 @@ +/** Pixel checks on real WebGL2: label placement, alpha and atlas reuse at 1×/2×. */ +export async function verifyGlyphs() { + const { GLBackend } = await import('/dist/render/gl/backend.js'); + for (const ratio of [1, 2]) { + const gpuCanvas = document.createElement('canvas'); + const backend = GLBackend.create(gpuCanvas, () => document.createElement('canvas')); + if (!backend) throw new Error('WebGL2 unavailable'); + backend.resize(300 * ratio, 80 * ratio); + const host = document.createElement('canvas'); + host.width = 300 * ratio; host.height = 80 * ratio; + const ctx = host.getContext('2d'); + ctx.scale(ratio, ratio); + const reference = document.createElement('canvas'); + reference.width = host.width; reference.height = host.height; + const ref = reference.getContext('2d'); + ref.scale(ratio, ratio); + ref.font = '10px sans-serif'; ref.textAlign = 'center'; ref.textBaseline = 'middle'; + ref.fillStyle = '#ff0000'; ref.globalAlpha = .5; + ref.fillText('12.40×3.50K', 150, 40); + const bounds = canvas => { + const pixels = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data; + let left = Infinity, right = -Infinity, top = Infinity, bottom = -Infinity, maxAlpha = 0; + for (let y = 0; y < canvas.height; y++) for (let x = 0; x < canvas.width; x++) { + const at = (y * canvas.width + x) * 4; + if (pixels[at + 3] > 10) { + left = Math.min(left, x); right = Math.max(right, x); top = Math.min(top, y); bottom = Math.max(bottom, y); + maxAlpha = Math.max(maxAlpha, pixels[at + 3]); + if (pixels[at] < 250 || pixels[at + 1] > 5 || pixels[at + 2] > 5) throw new Error('Incorrect glyph color'); + } + } + return [left, right, top, bottom, maxAlpha]; + }; + try { + const expected = bounds(reference); + for (let i = 0; i < 2; i++) { + ctx.clearRect(0, 0, 300, 80); + const frame = backend.beginFrame(ratio, 300, 80); + if (!frame.text('12.40×3.50K', 150, 40, '#ff0000', .5)) throw new Error('Glyph fallback'); + frame.composite(ctx); + const actual = bounds(host); + if (actual.some((value, index) => !Number.isFinite(value) || Math.abs(value - expected[index]) > 2 * ratio)) { + throw new Error(`Glyph pixel mismatch at ${ratio}×: ${actual} vs ${expected}`); + } + if (gpuCanvas.getContext('webgl2').getError() !== 0) throw new Error('WebGL error'); + } + } finally { backend.dispose(); } + } + return 'GPU glyph placement, color, alpha and repeated-frame checks passed at 1×/2×'; +} diff --git a/packages/chart/src/core/chart.ts b/packages/chart/src/core/chart.ts index 5e80801..5c09118 100644 --- a/packages/chart/src/core/chart.ts +++ b/packages/chart/src/core/chart.ts @@ -79,8 +79,9 @@ export interface CreateChartOptions { /** * Render backend preference. `'webgl2'` paints the main pane's dense * geometry (heatmap cells, candle bodies and wicks) as instanced quads on an - * offscreen WebGL2 canvas composited at the series layer; text, axes and - * overlays stay on Canvas2D. When the host cannot provide a WebGL2 context + * offscreen WebGL2 canvas composited at the series layer. Footprint labels + * use a GPU glyph atlas; axes and other text/overlays stay on Canvas2D. + * When the host cannot provide a WebGL2 context * (workers, SSR, tests) or pipeline setup fails, the chart silently uses * `'canvas2d'` — see {@link Chart.renderBackend}. Default `'canvas2d'`. */ @@ -1278,7 +1279,8 @@ export class Chart { const glCanvas = ownerDocument?.createElement !== undefined ? ownerDocument.createElement('canvas') : this.snapshotDocument?.createCanvas(this.canvas.width, this.canvas.height); - const backend = glCanvas !== undefined ? GLBackend.create(glCanvas) : null; + const backend = glCanvas !== undefined ? GLBackend.create(glCanvas, () => ownerDocument?.createElement !== undefined + ? ownerDocument.createElement('canvas') : this.snapshotDocument!.createCanvas(1, 1)) : null; if (backend === null) { this.glFailed = true; return null; diff --git a/packages/chart/src/dom.ts b/packages/chart/src/dom.ts index 7291a39..a497c92 100644 --- a/packages/chart/src/dom.ts +++ b/packages/chart/src/dom.ts @@ -91,10 +91,25 @@ export interface WebGL2Like { readonly DYNAMIC_DRAW: number; readonly FLOAT: number; readonly BLEND: number; + readonly ONE: number; readonly SRC_ALPHA: number; readonly ONE_MINUS_SRC_ALPHA: number; readonly SCISSOR_TEST: number; readonly COLOR_BUFFER_BIT: number; + readonly TEXTURE_2D: number; + readonly TEXTURE_MIN_FILTER: number; + readonly TEXTURE_MAG_FILTER: number; + readonly TEXTURE_WRAP_S: number; + readonly TEXTURE_WRAP_T: number; + readonly LINEAR: number; + readonly CLAMP_TO_EDGE: number; + readonly RGBA: number; + readonly UNSIGNED_BYTE: number; + createTexture(): unknown; + bindTexture(target: number, texture: unknown): void; + texParameteri(target: number, pname: number, param: number): void; + texImage2D(target: number, level: number, internalformat: number, format: number, type: number, source: unknown): void; + deleteTexture(texture: unknown): void; createShader(type: number): unknown; shaderSource(shader: unknown, source: string): void; compileShader(shader: unknown): void; @@ -125,6 +140,7 @@ export interface WebGL2Like { enable(cap: number): void; disable(cap: number): void; blendFunc(sfactor: number, dfactor: number): void; + blendFuncSeparate(srcRGB: number, dstRGB: number, srcAlpha: number, dstAlpha: number): void; clearColor(red: number, green: number, blue: number, alpha: number): void; clear(mask: number): void; drawArraysInstanced(mode: number, first: number, count: number, instanceCount: number): void; @@ -365,10 +381,27 @@ export class MockContextWebGL2 implements WebGL2Like { readonly DYNAMIC_DRAW = 0x88e8; readonly FLOAT = 0x1406; readonly BLEND = 0x0be2; + readonly ONE = 1; readonly SRC_ALPHA = 0x0302; readonly ONE_MINUS_SRC_ALPHA = 0x0303; readonly SCISSOR_TEST = 0x0c11; readonly COLOR_BUFFER_BIT = 0x4000; + readonly TEXTURE_2D = 0x0de1; + readonly TEXTURE_MIN_FILTER = 0x2801; + readonly TEXTURE_MAG_FILTER = 0x2800; + readonly TEXTURE_WRAP_S = 0x2802; + readonly TEXTURE_WRAP_T = 0x2803; + readonly LINEAR = 0x2601; + readonly CLAMP_TO_EDGE = 0x812f; + readonly RGBA = 0x1908; + readonly UNSIGNED_BYTE = 0x1401; + createTexture(): unknown { this.record('createTexture', []); return this.handle(); } + bindTexture(target: number, texture: unknown): void { this.record('bindTexture', [target, texture]); } + texParameteri(target: number, pname: number, param: number): void { this.record('texParameteri', [target, pname, param]); } + texImage2D(target: number, level: number, internalformat: number, format: number, type: number, source: unknown): void { + this.record('texImage2D', [target, level, internalformat, format, type, source]); + } + deleteTexture(texture: unknown): void { this.record('deleteTexture', [texture]); } /** Returned by `getShaderParameter(COMPILE_STATUS)`; clear to simulate a compile failure. */ compileStatus = true; @@ -498,6 +531,9 @@ export class MockContextWebGL2 implements WebGL2Like { blendFunc(sfactor: number, dfactor: number): void { this.record('blendFunc', [sfactor, dfactor]); } + blendFuncSeparate(srcRGB: number, dstRGB: number, srcAlpha: number, dstAlpha: number): void { + this.record('blendFuncSeparate', [srcRGB, dstRGB, srcAlpha, dstAlpha]); + } clearColor(red: number, green: number, blue: number, alpha: number): void { this.record('clearColor', [red, green, blue, alpha]); } diff --git a/packages/chart/src/render/gl/backend.ts b/packages/chart/src/render/gl/backend.ts index 826c259..37384ab 100644 --- a/packages/chart/src/render/gl/backend.ts +++ b/packages/chart/src/render/gl/backend.ts @@ -1,10 +1,10 @@ /** * WebGL2 geometry backend. The dense layers (candle bodies/wicks, heatmap * cells) are packed into instanced axis-aligned quads — one base quad per - * instance, corners derived from `gl_VertexID`, so a frame is one buffer + * instance, corners derived from `gl_VertexID`, so each dense pass is one buffer * upload and one instanced draw — then composited into the host canvas with * `drawImage`, letting the Canvas2D pass paint text, axes and overlays on top - * (hybrid compositing: GL never rasterizes glyphs). + * and the footprint glyph atlas paints dense bid×ask labels in GL. * * The GL canvas is allocated by the caller through the injected DOM (the host * canvas's `ownerDocument` or the injected `ChartDocument`) and is never @@ -13,6 +13,8 @@ * @module */ +import { GlyphAtlas } from './glyph-atlas.js'; +import { parseColor } from '../../color.js'; import type { Canvas2DLike, ChartCanvas, WebGL2Like } from '../../dom.js'; /** Render backend identifiers; see `CreateChartOptions.renderer`. */ @@ -32,16 +34,20 @@ export interface GLQuadSink { export interface GLFrame extends GLQuadSink { /** Flushes pending quads and composites the GL canvas; a no-op when no quads were submitted. */ composite(ctx: Canvas2DLike): void; + /** Centered 10px footprint label via the glyph atlas; false keeps the Canvas2D fallback. */ + text?(text: string, x: number, y: number, color: string, alpha: number): boolean; } -const FLOATS_PER_QUAD = 8; +const FLOATS_PER_QUAD = 12; const QUAD_STRIDE_BYTES = FLOATS_PER_QUAD * 4; const VERTEX_SHADER_SOURCE = `#version 300 es layout(location = 0) in vec4 aRect; layout(location = 1) in vec4 aColor; +layout(location = 2) in vec4 aUv; uniform vec2 uResolution; out vec4 vColor; +out vec2 vUv; void main() { vec2 corner = vec2( float(gl_VertexID == 1 || gl_VertexID == 4 || gl_VertexID == 5), @@ -50,14 +56,17 @@ void main() { vec2 clip = position / uResolution * 2.0 - 1.0; gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0); vColor = aColor; + vUv = aUv.xy + corner * aUv.zw; }`; const FRAGMENT_SHADER_SOURCE = `#version 300 es precision mediump float; in vec4 vColor; +in vec2 vUv; +uniform sampler2D uAtlas; out vec4 fragColor; void main() { - fragColor = vColor; + fragColor = vec4(vColor.rgb, vColor.a * (vUv.x < 0.0 ? 1.0 : texture(uAtlas, vUv).a)); }`; function compileShader(gl: WebGL2Like, type: number, source: string): unknown { @@ -106,6 +115,10 @@ export class GLBackend implements GLQuadSink { private staging = new Float32Array(FLOATS_PER_QUAD * 1024); private count = 0; private pixelRatio = 1; + private atlas: GlyphAtlas | null = null; + private texture: unknown = null; + private atlasAttempted = false; + private makeAtlasCanvas: (() => ChartCanvas | undefined) | undefined; private constructor(canvas: ChartCanvas, gl: WebGL2Like, program: unknown, buffer: unknown, vao: unknown, resolution: unknown) { this.canvas = canvas; @@ -117,7 +130,7 @@ export class GLBackend implements GLQuadSink { } /** Acquires a WebGL2 context on `canvas` and builds the quad pipeline; `null` on any failure. */ - static create(canvas: ChartCanvas): GLBackend | null { + static create(canvas: ChartCanvas, makeAtlasCanvas?: () => ChartCanvas | undefined): GLBackend | null { const gl = canvas.getContext('webgl2', { alpha: true, antialias: false, preserveDrawingBuffer: false }) as WebGL2Like | null; if (gl === null || gl === undefined || typeof gl.createShader !== 'function') return null; try { @@ -133,10 +146,16 @@ export class GLBackend implements GLQuadSink { gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 4, gl.FLOAT, false, QUAD_STRIDE_BYTES, 16); gl.vertexAttribDivisor(1, 1); + gl.enableVertexAttribArray(2); + gl.vertexAttribPointer(2, 4, gl.FLOAT, false, QUAD_STRIDE_BYTES, 32); + gl.vertexAttribDivisor(2, 1); gl.enable(gl.BLEND); - gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + // Keep alpha linear when the premultiplied drawing buffer is composited into 2D. + gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA); gl.useProgram(program); - return new GLBackend(canvas, gl, program, buffer, vao, gl.getUniformLocation(program, 'uResolution')); + const backend = new GLBackend(canvas, gl, program, buffer, vao, gl.getUniformLocation(program, 'uResolution')); + backend.makeAtlasCanvas = makeAtlasCanvas; + return backend; } catch { return null; } @@ -173,6 +192,7 @@ export class GLBackend implements GLQuadSink { return { quad: (x, y, w, h, r, g, b, a) => this.quad(x, y, w, h, r, g, b, a), composite: (ctx) => this.composite(ctx), + text: (text, x, y, color, alpha) => this.text(text, x, y, color, alpha), }; } @@ -193,9 +213,51 @@ export class GLBackend implements GLQuadSink { this.staging[o + 5] = g; this.staging[o + 6] = b; this.staging[o + 7] = a; + this.staging[o + 8] = -1; + this.staging[o + 9] = -1; + this.staging[o + 10] = 0; + this.staging[o + 11] = 0; this.count++; } + private text(text: string, x: number, y: number, color: string, alpha: number): boolean { + const parsed = parseColor(color); + if (parsed === null) return false; + if (!this.atlasAttempted) { + this.atlasAttempted = true; + const canvas = this.makeAtlasCanvas?.(); + this.atlas = canvas === undefined ? null : GlyphAtlas.create(canvas); + if (this.atlas !== null) { + const gl = this.gl; + this.texture = gl.createTexture(); + if (this.texture === null) { + this.atlas.dispose(); + this.atlas = null; + return false; + } + gl.bindTexture(gl.TEXTURE_2D, this.texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + } + } + if (this.atlas === null) return false; + if (this.atlas.prepare(this.pixelRatio)) { + const gl = this.gl; + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this.atlas.canvas); + } + const d = parsed.space === 'srgb' ? 255 : 1; + return this.atlas.draw(text, x, y, (left, top, width, height, u, du) => { + this.quad(left, top, width, height, parsed.r / d, parsed.g / d, parsed.b / d, parsed.a * alpha); + const offset = (this.count - 1) * FLOATS_PER_QUAD; + this.staging[offset + 8] = u; + this.staging[offset + 9] = 0; + this.staging[offset + 10] = du; + this.staging[offset + 11] = 1; + }); + } + /** Uploads and draws the staged quads; returns whether anything was drawn. */ private flush(): boolean { if (this.count === 0) return false; @@ -217,6 +279,8 @@ export class GLBackend implements GLQuadSink { /** Releases GL resources and drops the canvas bitmap. */ dispose(): void { const gl = this.gl; + if (this.texture !== null) gl.deleteTexture(this.texture); + this.atlas?.dispose(); gl.deleteBuffer(this.buffer); gl.deleteVertexArray(this.vao); gl.deleteProgram(this.program); diff --git a/packages/chart/src/render/gl/glyph-atlas.ts b/packages/chart/src/render/gl/glyph-atlas.ts new file mode 100644 index 0000000..5b469cd --- /dev/null +++ b/packages/chart/src/render/gl/glyph-atlas.ts @@ -0,0 +1,62 @@ +/** Fixed, bounded atlas for the footprint's 10px bid×ask labels. */ +import type { ChartCanvas, Canvas2DLike } from '../../dom.js'; + +const GLYPHS = '0123456789.KM×-'; +const CELL = 24; + +export class GlyphAtlas { + readonly canvas: ChartCanvas; + private readonly ctx: Canvas2DLike; + private widths: number[] = []; + private ratio = 0; + + private constructor(canvas: ChartCanvas, ctx: Canvas2DLike) { + this.canvas = canvas; + this.ctx = ctx; + } + + static create(canvas: ChartCanvas): GlyphAtlas | null { + const ctx = canvas.getContext('2d'); + return ctx === null ? null : new GlyphAtlas(canvas, ctx); + } + + /** Re-rasterizes only when the backing pixel ratio changes. */ + prepare(ratio: number): boolean { + if (ratio === this.ratio) return false; + this.ratio = ratio; + const cell = Math.ceil(CELL * ratio); + this.canvas.width = cell * GLYPHS.length; + this.canvas.height = cell; + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + this.ctx.font = `${10 * ratio}px sans-serif`; + this.ctx.fillStyle = '#ffffff'; + this.ctx.textAlign = 'left'; + this.ctx.textBaseline = 'middle'; + this.widths = []; + for (let i = 0; i < GLYPHS.length; i++) { + const glyph = GLYPHS[i]!; + this.widths.push(this.ctx.measureText(glyph).width / ratio); + this.ctx.fillText(glyph, i * cell + 2 * ratio, cell / 2); + } + return true; + } + + /** Emits centered, middle-aligned glyph quads; unsupported text emits none. */ + draw(text: string, x: number, y: number, + emit: (x: number, y: number, width: number, height: number, u: number, du: number) => void): boolean { + const indices = Array.from(text, glyph => GLYPHS.indexOf(glyph)); + if (indices.includes(-1)) return false; + const cell = this.canvas.height / this.ratio; + let left = x - indices.reduce((sum, index) => sum + this.widths[index]!, 0) / 2; + for (const index of indices) { + emit(left - 2, y - cell / 2, cell, cell, index / GLYPHS.length, 1 / GLYPHS.length); + left += this.widths[index]!; + } + return true; + } + + dispose(): void { + this.canvas.width = 0; + this.canvas.height = 0; + } +} diff --git a/packages/chart/src/render/renderer.ts b/packages/chart/src/render/renderer.ts index dab08d3..16e795e 100644 --- a/packages/chart/src/render/renderer.ts +++ b/packages/chart/src/render/renderer.ts @@ -223,7 +223,9 @@ function renderLayers(ctx: Canvas2DLike, view: RenderView, overlay: boolean): vo mainPane.priceScale, config.series, view.liveCandle, + gl, ); + gl?.composite(ctx); } drawMarkers(ctx, view, mainPane.priceScale); // Layer 4a: main-pane indicators overlay the series. diff --git a/packages/chart/src/series/footprint.ts b/packages/chart/src/series/footprint.ts index dc089ab..3d88a55 100644 --- a/packages/chart/src/series/footprint.ts +++ b/packages/chart/src/series/footprint.ts @@ -15,6 +15,7 @@ * @module */ +import type { GLFrame } from '../render/gl/backend.js'; import type { SeriesConfig } from '../config.js'; import { barDelta, @@ -147,7 +148,7 @@ function drawLevelBars(ctx: Canvas2DLike, rows: readonly CellRow[], x: number, w } /** Zoomed in: a `sell × buy` label per level over a faint delta-signed tint. */ -function drawTextCells(ctx: Canvas2DLike, rows: readonly CellRow[], x: number, w: number, o: ResolvedFootprintOptions): void { +function drawTextCells(ctx: Canvas2DLike, rows: readonly CellRow[], x: number, w: number, o: ResolvedFootprintOptions, gl?: GLFrame): void { const alpha = ctx.globalAlpha; ctx.font = '10px sans-serif'; ctx.textAlign = 'center'; @@ -160,7 +161,9 @@ function drawTextCells(ctx: Canvas2DLike, rows: readonly CellRow[], x: number, w ctx.fillRect(x, y0, w, h); ctx.globalAlpha = alpha; ctx.fillStyle = o.textColor ?? (up ? o.upColor : o.downColor); - ctx.fillText(`${formatFootprintSize(level.sellSize)}×${formatFootprintSize(level.buySize)}`, x + w / 2, (y0 + y1) / 2); + const label = `${formatFootprintSize(level.sellSize)}×${formatFootprintSize(level.buySize)}`; + const labelX = x + w / 2, labelY = (y0 + y1) / 2; + if (!gl?.text?.(label, labelX, labelY, ctx.fillStyle as string, alpha)) ctx.fillText(label, labelX, labelY); } } @@ -172,7 +175,7 @@ function drawTextCells(ctx: Canvas2DLike, rows: readonly CellRow[], x: number, w */ export function createFootprintSeries(source: FootprintSeriesSource, options: FootprintSeriesOptions = {}): SeriesDrawFn { const resolve = typeof source === 'function' ? source : () => source; - return (ctx, candles, range, timeScale, priceScale, config) => { + return (ctx, candles, range, timeScale, priceScale, config, _liveCandle, gl) => { const store = resolve(); if (store === null) return; const o = resolveFootprintOptions(options, config); @@ -183,7 +186,7 @@ export function createFootprintSeries(source: FootprintSeriesSource, options: Fo const { rows, cellPx } = cellRows(bar, priceScale); const x = Math.round(timeScale.indexToX(i, candles.length) - w / 2); const lod = footprintLod(timeScale.barSpacing, cellPx, o.display, o.thresholds); - if (lod === 'text') drawTextCells(ctx, rows, x, w, o); + if (lod === 'text') drawTextCells(ctx, rows, x, w, o, gl); else if (lod === 'bars') drawLevelBars(ctx, rows, x, w, o); else drawDeltaColumn(ctx, bar, rows, x, w, o); } diff --git a/packages/chart/src/series/types.ts b/packages/chart/src/series/types.ts index fad2b35..9ee74d7 100644 --- a/packages/chart/src/series/types.ts +++ b/packages/chart/src/series/types.ts @@ -4,6 +4,7 @@ * @module */ +import type { GLFrame } from '../render/gl/backend.js'; import type { Candle } from '../core/data.js'; import type { PriceScale, TimeScale, VisibleRange } from '../core/scale.js'; import type { SeriesConfig } from '../config.js'; @@ -19,4 +20,6 @@ export type SeriesDrawFn = ( config: SeriesConfig, /** Interpolated prices for the last candle only; the array stays authoritative. */ liveCandle?: Candle, + /** Optional main-pane GPU frame; custom renderers may submit geometry or footprint labels. */ + gl?: GLFrame, ) => void; diff --git a/packages/chart/test/glyph-atlas.test.ts b/packages/chart/test/glyph-atlas.test.ts new file mode 100644 index 0000000..8efdaf9 --- /dev/null +++ b/packages/chart/test/glyph-atlas.test.ts @@ -0,0 +1,111 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { GlyphAtlas } from '../dist/render/gl/glyph-atlas.js'; +import { GLBackend } from '../dist/render/gl/backend.js'; +import { MockCanvas, MockContext2D, MockContextWebGL2, MockGLCanvas, MockGLDocument } from '../dist/dom.js'; +import { createChart, createFootprintSeries } from '../dist/index.js'; + +describe('footprint glyph atlas', () => { + it('rasterizes a bounded character set once per ratio and centers its quads', () => { + const canvas = new MockCanvas(1, 1); + const atlas = GlyphAtlas.create(canvas)!; + assert.equal(atlas.prepare(1), true); + assert.equal(atlas.prepare(1), false); + assert.equal(canvas.context.countCalls('fillText'), 15); + const quads: number[][] = []; + assert.equal(atlas.draw('1×2', 50, 30, (...quad) => quads.push(quad)), true); + assert.equal(quads.length, 3); + const advance = canvas.context.measureText('1').width; + assert.equal(quads[0]![0], 50 - advance * 1.5 - 2); + assert.equal(quads[0]![1], 18); + assert.equal(quads[2]![0] - quads[0]![0], advance * 2); + quads.length = 0; + assert.equal(atlas.draw('oops', 0, 0, (...quad) => quads.push(quad)), false); + assert.equal(quads.length, 0); + assert.equal(atlas.draw('', 0, 0, (...quad) => quads.push(quad)), true); + assert.equal(atlas.prepare(2), true); + assert.equal(canvas.height, 48); + atlas.dispose(); + assert.equal(canvas.width, 0); + assert.equal(canvas.height, 0); + }); + + it('keeps labels on Canvas2D when there is no atlas canvas or 2D context', () => { + class NoContext extends MockCanvas { + override getContext(): null { return null; } + } + assert.equal(GlyphAtlas.create(new NoContext()), null); + for (const factory of [undefined, () => undefined, () => new NoContext()]) { + const canvas = new MockGLCanvas(100, 100); + const backend = GLBackend.create(canvas, factory)!; + const frame = backend.beginFrame(1, 100, 100); + assert.equal(frame.text!('1×2', 50, 50, '#ffffff', 1), false); + assert.equal(frame.text!('1×2', 50, 50, '#ffffff', 1), false); + backend.dispose(); + } + }); + + it('uploads once per ratio, rejects unsupported labels/colors, and releases the texture', () => { + const canvas = new MockGLCanvas(100, 100); + const atlasCanvas = new MockCanvas(1, 1); + const backend = GLBackend.create(canvas, () => atlasCanvas)!; + let frame = backend.beginFrame(1, 100, 100); + assert.equal(frame.text!('1', 50, 50, 'no-such-color', 1), false); + assert.equal(frame.text!('1×2', 50, 50, '#ffffff', .5), true); + assert.equal(frame.text!('2×1', 50, 60, 'color(display-p3 1 0 0)', 1), true); + assert.equal(frame.text!('unknown', 0, 0, '#ffffff', 1), false); + assert.equal(canvas.gl.countCalls('texImage2D'), 1); + const ctx = new MockContext2D(); + frame.composite(ctx); + assert.equal(canvas.gl.callsNamed('drawArraysInstanced').at(-1)![4], 6); + frame = backend.beginFrame(2, 100, 100); + frame.text!('1', 50, 50, '#ffffff', 1); + frame.composite(ctx); + assert.equal(canvas.gl.countCalls('texImage2D'), 2); + assert.equal(ctx.countCalls('drawImage'), 2); + backend.dispose(); + assert.equal(canvas.gl.countCalls('deleteTexture'), 1); + assert.equal(atlasCanvas.width, 0); + }); + + it('falls back without leaking an atlas when texture allocation fails', () => { + class NoTexture extends MockContextWebGL2 { + override createTexture(): null { return null; } + } + const glCanvas = new MockGLCanvas(100, 100); + Object.defineProperty(glCanvas, 'gl', { value: new NoTexture() }); + const atlasCanvas = new MockCanvas(1, 1); + const backend = GLBackend.create(glCanvas, () => atlasCanvas)!; + const frame = backend.beginFrame(1, 100, 100); + assert.equal(frame.text!('1', 50, 50, '#ffffff', 1), false); + assert.equal(frame.text!('1', 50, 50, '#ffffff', 1), false); + assert.equal(atlasCanvas.width, 0); + assert.equal(atlasCanvas.height, 0); + backend.dispose(); + }); + + for (const fromOwner of [false, true]) it(`renders a registered footprint (${fromOwner ? 'owner document' : 'injected document'}) through GPU labels and keeps axes on Canvas2D`, () => { + const doc = new MockGLDocument(); + const host = fromOwner + ? { container: Object.assign(doc.createCanvas(640, 400), { + ownerDocument: { createElement: () => doc.createCanvas(1, 1) }, + }) } + : { document: doc }; + const chart = createChart({ ...host, renderer: 'webgl2', config: { + width: 640, height: 400, wasm: false, + data: [{ time: 1, open: 100, high: 102, low: 98, close: 101 }], + series: { type: 'footprint' }, + } }); + chart.registerSeries('footprint', createFootprintSeries({ bar: () => ({ + time: 1000, buySize: 2, sellSize: 1, + levels: [{ price: 100, buySize: 2, sellSize: 1 }], + }) }, { lodThresholds: { textBarWidth: 0, textCellHeight: 0 } })); + const gl = doc.created[1]!.gl; + assert.equal(gl.countCalls('texImage2D'), 1); + assert.ok(gl.countCalls('drawArraysInstanced') > 0); + assert.ok(doc.created[0]!.context.countCalls('fillText') > 0); + chart.render(); + assert.equal(gl.countCalls('texImage2D'), 1); + chart.destroy(); + }); +});