From 9f0c4b8b8ab926fc8d802f992ee6909dd0cdfd7a Mon Sep 17 00:00:00 2001 From: Ib Green Date: Thu, 16 Apr 2026 06:22:34 -0400 Subject: [PATCH 1/3] chore(core) Adopt luma CanvasContext --- modules/core/src/lib/deck-picker.ts | 52 +++++++++++----------- modules/core/src/lib/deck.ts | 47 +++++++++++--------- modules/google-maps/src/utils.ts | 3 +- modules/mapbox/src/deck-utils.ts | 5 ++- test/modules/core/lib/deck.spec.ts | 67 +++++++++++++++++++++++++++++ 5 files changed, 123 insertions(+), 51 deletions(-) diff --git a/modules/core/src/lib/deck-picker.ts b/modules/core/src/lib/deck-picker.ts index 5b48426c930..e2a33b08f8d 100644 --- a/modules/core/src/lib/deck-picker.ts +++ b/modules/core/src/lib/deck-picker.ts @@ -186,10 +186,10 @@ export default class DeckPicker { } } - // Resize it to current canvas size (this is a noop if size hasn't changed) - const {canvas} = this.device.getDefaultCanvasContext(); - this.pickingFBO?.resize({width: canvas.width, height: canvas.height}); - this.depthFBO?.resize({width: canvas.width, height: canvas.height}); + // Picking buffers must track the active drawing buffer, not raw canvas element dimensions. + const [width, height] = this.device.getDefaultCanvasContext().getDrawingBufferSize(); + this.pickingFBO?.resize({width, height}); + this.depthFBO?.resize({width, height}); } /** Preliminary filtering of the layers list. Skid picking pass if no layer is pickable. */ @@ -223,8 +223,9 @@ export default class DeckPicker { result: PickingInfo[]; emptyInfo: PickingInfo; }> { - // @ts-expect-error TODO - assuming WebGL context - const pixelRatio = this.device.canvasContext.cssToDeviceRatio(); + const canvasContext = this.device.getDefaultCanvasContext(); + // Picking starts in CSS pixels, so use the canvas context's current conversion ratio. + const pixelRatio = canvasContext.cssToDeviceRatio(); const pickableLayers = this._getPickable(layers); @@ -239,9 +240,8 @@ export default class DeckPicker { // Convert from canvas top-left to WebGL bottom-left coordinates // Top-left coordinates [x, y] to bottom-left coordinates [deviceX, deviceY] - // And compensate for pixelRatio - // @ts-expect-error TODO - assuming WebGL context - const devicePixelRange = this.device.canvasContext.cssToDevicePixels([x, y], true); + // And compensate for the context's current CSS-to-device ratio. + const devicePixelRange = canvasContext.cssToDevicePixels([x, y], true); const devicePixel = [ devicePixelRange.x + Math.floor(devicePixelRange.width / 2), devicePixelRange.y + Math.floor(devicePixelRange.height / 2) @@ -387,8 +387,9 @@ export default class DeckPicker { result: PickingInfo[]; emptyInfo: PickingInfo; } { - // @ts-expect-error TODO - assuming WebGL context - const pixelRatio = this.device.canvasContext.cssToDeviceRatio(); + const canvasContext = this.device.getDefaultCanvasContext(); + // Keep the sync picking path aligned with the same canvas context state used for drawing. + const pixelRatio = canvasContext.cssToDeviceRatio(); const pickableLayers = this._getPickable(layers); @@ -403,9 +404,8 @@ export default class DeckPicker { // Convert from canvas top-left to WebGL bottom-left coordinates // Top-left coordinates [x, y] to bottom-left coordinates [deviceX, deviceY] - // And compensate for pixelRatio - // @ts-expect-error TODO - assuming WebGL context - const devicePixelRange = this.device.canvasContext.cssToDevicePixels([x, y], true); + // And compensate for the context's current CSS-to-device ratio. + const devicePixelRange = canvasContext.cssToDevicePixels([x, y], true); const devicePixel = [ devicePixelRange.x + Math.floor(devicePixelRange.width / 2), devicePixelRange.y + Math.floor(devicePixelRange.height / 2) @@ -556,19 +556,17 @@ export default class DeckPicker { this._resizeBuffer(); // Convert from canvas top-left to WebGL bottom-left coordinates - // And compensate for pixelRatio - // @ts-expect-error TODO - assuming WebGL context - const pixelRatio = this.device.canvasContext.cssToDeviceRatio(); - // @ts-expect-error TODO - assuming WebGL context - const leftTop = this.device.canvasContext.cssToDevicePixels([x, y], true); + // And compensate for the context's current CSS-to-device ratio. + const canvasContext = this.device.getDefaultCanvasContext(); + const pixelRatio = canvasContext.cssToDeviceRatio(); + const leftTop = canvasContext.cssToDevicePixels([x, y], true); // take left and top (y inverted in device pixels) from start location const deviceLeft = leftTop.x; const deviceTop = leftTop.y + leftTop.height; // take right and bottom (y inverted in device pixels) from end location - // @ts-expect-error TODO - assuming WebGL context - const rightBottom = this.device.canvasContext.cssToDevicePixels([x + width, y + height], true); + const rightBottom = canvasContext.cssToDevicePixels([x + width, y + height], true); const deviceRight = rightBottom.x + rightBottom.width; const deviceBottom = rightBottom.y; @@ -663,19 +661,17 @@ export default class DeckPicker { this._resizeBuffer(); // Convert from canvas top-left to WebGL bottom-left coordinates - // And compensate for pixelRatio - // @ts-expect-error TODO - assuming WebGL context - const pixelRatio = this.device.canvasContext.cssToDeviceRatio(); - // @ts-expect-error TODO - assuming WebGL context - const leftTop = this.device.canvasContext.cssToDevicePixels([x, y], true); + // And compensate for the context's current CSS-to-device ratio. + const canvasContext = this.device.getDefaultCanvasContext(); + const pixelRatio = canvasContext.cssToDeviceRatio(); + const leftTop = canvasContext.cssToDevicePixels([x, y], true); // take left and top (y inverted in device pixels) from start location const deviceLeft = leftTop.x; const deviceTop = leftTop.y + leftTop.height; // take right and bottom (y inverted in device pixels) from end location - // @ts-expect-error TODO - assuming WebGL context - const rightBottom = this.device.canvasContext.cssToDevicePixels([x + width, y + height], true); + const rightBottom = canvasContext.cssToDevicePixels([x + width, y + height], true); const deviceRight = rightBottom.x + rightBottom.width; const deviceBottom = rightBottom.y; diff --git a/modules/core/src/lib/deck.ts b/modules/core/src/lib/deck.ts index dae456644d8..a1bc19bb79a 100644 --- a/modules/core/src/lib/deck.ts +++ b/modules/core/src/lib/deck.ts @@ -405,11 +405,9 @@ export default class Deck { _cachePipelines: true, ...this.props.deviceProps, onResize: (canvasContext, info) => { - // Sync drawing buffer dimensions with externally-managed canvas - const {width, height} = canvasContext.canvas; - canvasContext.setDrawingBufferSize(width, height); - - this._needsRedraw = 'Canvas resized'; + // Attached contexts still emit resize through luma's CanvasContext. + // Deck only mirrors that state into viewport dimensions and redraw flags. + this._onCanvasContextResize(canvasContext); userOnResize?.(canvasContext, info); } }); @@ -1054,15 +1052,20 @@ export default class Deck { } } - /** If canvas size has changed, reads out the new size and update */ - private _updateCanvasSize(): void { + /** + * Sync Deck viewport dimensions from the active canvas context. + * luma.gl owns resize observation, DPR tracking and drawing buffer sizing. + */ + private _updateCanvasSize( + canvasContext: {getCSSSize(): [number, number]} | null = this.device?.getDefaultCanvasContext?.() || null + ): void { const {canvas} = this; - if (!canvas) { - return; - } - // Fallback to width/height when clientWidth/clientHeight are undefined (OffscreenCanvas). - const newWidth = canvas.clientWidth ?? canvas.width; - const newHeight = canvas.clientHeight ?? canvas.height; + const [newWidth, newHeight] = canvasContext + // The canvas context owns the authoritative CSS size after resize/DPR observation. + ? canvasContext.getCSSSize() + : // Fallback to width/height when there is no default canvas context available yet. + [canvas?.clientWidth ?? canvas?.width ?? 0, canvas?.clientHeight ?? canvas?.height ?? 0]; + if (newWidth !== this.width || newHeight !== this.height) { // @ts-expect-error private assign to read-only property this.width = newWidth; @@ -1075,6 +1078,12 @@ export default class Deck { } } + private _onCanvasContextResize(canvasContext: {getCSSSize(): [number, number]}): void { + // luma owns resize detection; Deck reacts by invalidating redraw and updating view state. + this._needsRedraw = 'Canvas resized'; + this._updateCanvasSize(canvasContext); + } + private _createAnimationLoop( deviceOrPromise: Device | Promise, props: DeckProps @@ -1149,10 +1158,9 @@ export default class Deck { autoResize: true }, onResize: (canvasContext, info) => { - // Set redraw flag when luma.gl's CanvasContext detects a resize - // This restores pre-9.2 behavior where resize automatically triggered redraws - this._needsRedraw = 'Canvas resized'; - // Call user's onResize if provided + // Deck-created canvases follow the same contract as attached canvases: + // luma updates canvas state, Deck updates viewport bookkeeping and callbacks. + this._onCanvasContextResize(canvasContext); userOnResize?.(canvasContext, info); } }); @@ -1383,7 +1391,8 @@ export default class Deck { this.setProps(this.props); - this._updateCanvasSize(); + // Seed the initial Deck width/height from the current canvas context before onLoad fires. + this._updateCanvasSize(this.device.getDefaultCanvasContext()); this.props.onLoad(); } @@ -1447,8 +1456,6 @@ export default class Deck { } } - this._updateCanvasSize(); - this._updateCursor(); // Update layers if needed (e.g. some async prop has loaded) diff --git a/modules/google-maps/src/utils.ts b/modules/google-maps/src/utils.ts index dfadc394ed4..ed999ddf84f 100644 --- a/modules/google-maps/src/utils.ts +++ b/modules/google-maps/src/utils.ts @@ -46,7 +46,8 @@ export function createDeckInstance( const newDeck = new Deck({ ...props, - // Default to true for high-DPI displays, but allow user override + // The basemap owns the shared canvas in interleaved mode; Deck only forwards the preferred DPR. + // In non-interleaved mode this still feeds the luma canvas context that Deck creates. useDevicePixels: props.useDevicePixels ?? true, style: props.interleaved ? null : {pointerEvents: 'none'}, parent: getContainer(overlay, props.style), diff --git a/modules/mapbox/src/deck-utils.ts b/modules/mapbox/src/deck-utils.ts index bc49aa7a4a3..9daf4411a51 100644 --- a/modules/mapbox/src/deck-utils.ts +++ b/modules/mapbox/src/deck-utils.ts @@ -51,8 +51,9 @@ export function getDeckInstance({ }; deckProps.views ||= getDefaultView(map); - // deck is using the WebGLContext created by mapbox, - // block deck from setting the canvas size, and use the map's viewState to drive deck. + // deck is using the WebGLContext created by mapbox. + // The map and its attached luma canvas context own canvas sizing and DPR state here. + // Deck only follows view state and avoids trying to size the shared canvas itself. Object.assign(deckProps, { width: null, height: null, diff --git a/test/modules/core/lib/deck.spec.ts b/test/modules/core/lib/deck.spec.ts index e4c0d9a7280..47925f1608a 100644 --- a/test/modules/core/lib/deck.spec.ts +++ b/test/modules/core/lib/deck.spec.ts @@ -134,6 +134,73 @@ test('Deck#abort', async () => { console.log('Deck initialization aborted'); }); +test('Deck#canvas context resize drives Deck dimensions', async () => { + const resizeEvents: Array<{width: number; height: number}> = []; + const deck = new Deck({ + device, + width: 1, + height: 1, + viewState: {longitude: 0, latitude: 0, zoom: 0}, + layers: [], + onResize: dimensions => resizeEvents.push(dimensions) + }); + + await waitForRender(deck); + + const canvasContext = device.getDefaultCanvasContext(); + const originalGetCSSSize = canvasContext.getCSSSize.bind(canvasContext); + const nextSize: [number, number] = [17, 19]; + + try { + canvasContext.getCSSSize = () => nextSize; + resizeEvents.length = 0; + + // Call the internal resize hook directly so the test verifies Deck's reaction to luma state. + // @ts-expect-error testing private resize hook + deck._onCanvasContextResize(canvasContext); + + expect(deck.width, 'Deck width comes from canvas context CSS size').toBe(nextSize[0]); + expect(deck.height, 'Deck height comes from canvas context CSS size').toBe(nextSize[1]); + expect(resizeEvents, 'Deck onResize fires from canvas context resize').toEqual([ + {width: nextSize[0], height: nextSize[1]} + ]); + expect(deck.needsRedraw(), 'resize invalidates redraw').toBeTruthy(); + } finally { + canvasContext.getCSSSize = originalGetCSSSize; + deck.finalize(); + } +}); + +test('Deck#useDevicePixels forwards to canvas context', async () => { + const deck = new Deck({ + device, + width: 1, + height: 1, + viewState: {longitude: 0, latitude: 0, zoom: 0}, + layers: [] + }); + + await waitForRender(deck); + + const canvasContext = device.getDefaultCanvasContext(); + const initialUseDevicePixels = canvasContext.props.useDevicePixels; + + try { + // Deck.setProps should only forward the preference into luma's canvas context. + deck.setProps({useDevicePixels: false}); + expect(canvasContext.props.useDevicePixels, 'canvas context useDevicePixels updated').toBe( + false + ); + + // Numeric overrides should flow through unchanged so luma can size the drawing buffer. + deck.setProps({useDevicePixels: 2}); + expect(canvasContext.props.useDevicePixels, 'numeric DPR override is forwarded').toBe(2); + } finally { + canvasContext.setProps({useDevicePixels: initialUseDevicePixels}); + deck.finalize(); + } +}); + test('Deck#no views', async () => { await new Promise((resolve, reject) => { const deck = new Deck({ From 0000d5abaf16ad3a0df617f1810c05b917b1a3ee Mon Sep 17 00:00:00 2001 From: Ib Green Date: Thu, 16 Apr 2026 08:11:11 -0400 Subject: [PATCH 2/3] fix --- modules/core/src/lib/deck.ts | 19 ++++++++++++--- test/modules/core/lib/deck.spec.ts | 37 ++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/modules/core/src/lib/deck.ts b/modules/core/src/lib/deck.ts index a1bc19bb79a..e91eb7adb81 100644 --- a/modules/core/src/lib/deck.ts +++ b/modules/core/src/lib/deck.ts @@ -346,6 +346,7 @@ export default class Deck { private _metricsCounter: number = 0; private _hoverPickSequence: number = 0; private _pointerDownPickSequence: number = 0; + private _pollCanvasContextSize: boolean = false; private _needsRedraw: false | string = 'Initial render'; private _pickRequest: { @@ -386,6 +387,9 @@ export default class Deck { // See if we already have a device if (props.device) { this.device = props.device; + // External Device ownership means Deck cannot wrap luma's onResize callback. + // Keep a render-loop poll so viewport dimensions still follow CanvasContext state. + this._pollCanvasContextSize = true; } let deviceOrPromise: Device | Promise | null = this.device; @@ -1057,12 +1061,14 @@ export default class Deck { * luma.gl owns resize observation, DPR tracking and drawing buffer sizing. */ private _updateCanvasSize( - canvasContext: {getCSSSize(): [number, number]} | null = this.device?.getDefaultCanvasContext?.() || null + canvasContext: { + getCSSSize(): [number, number]; + } | null = this.device?.getDefaultCanvasContext?.() || null ): void { const {canvas} = this; const [newWidth, newHeight] = canvasContext - // The canvas context owns the authoritative CSS size after resize/DPR observation. - ? canvasContext.getCSSSize() + ? // The canvas context owns the authoritative CSS size after resize/DPR observation. + canvasContext.getCSSSize() : // Fallback to width/height when there is no default canvas context available yet. [canvas?.clientWidth ?? canvas?.width ?? 0, canvas?.clientHeight ?? canvas?.height ?? 0]; @@ -1456,6 +1462,13 @@ export default class Deck { } } + if (this._pollCanvasContextSize) { + // Callers that hand Deck an existing Device keep luma's CanvasContext as the source + // of truth, but Deck does not own that context's onResize wiring. Poll the context + // once per frame so width/height stay in sync without falling back to DOM reads. + this._updateCanvasSize(); + } + this._updateCursor(); // Update layers if needed (e.g. some async prop has loaded) diff --git a/test/modules/core/lib/deck.spec.ts b/test/modules/core/lib/deck.spec.ts index 47925f1608a..0d2dc02a7a9 100644 --- a/test/modules/core/lib/deck.spec.ts +++ b/test/modules/core/lib/deck.spec.ts @@ -201,6 +201,43 @@ test('Deck#useDevicePixels forwards to canvas context', async () => { } }); +test('Deck#render frame syncs provided device canvas context size', async () => { + const resizeEvents: Array<{width: number; height: number}> = []; + const deck = new Deck({ + device, + width: 1, + height: 1, + viewState: {longitude: 0, latitude: 0, zoom: 0}, + layers: [], + onResize: dimensions => resizeEvents.push(dimensions) + }); + + await waitForRender(deck); + + const canvasContext = device.getDefaultCanvasContext(); + const originalGetCSSSize = canvasContext.getCSSSize.bind(canvasContext); + const nextSize: [number, number] = [23, 29]; + + try { + canvasContext.getCSSSize = () => nextSize; + resizeEvents.length = 0; + + // Provided Device instances do not route luma's onResize callback through Deck. + // The render loop still refreshes dimensions from CanvasContext so view state stays current. + // @ts-expect-error testing private render loop + deck._onRenderFrame(); + + expect(deck.width, 'Deck width is refreshed during render frame').toBe(nextSize[0]); + expect(deck.height, 'Deck height is refreshed during render frame').toBe(nextSize[1]); + expect(resizeEvents, 'Deck onResize fires from render-frame canvas context sync').toEqual([ + {width: nextSize[0], height: nextSize[1]} + ]); + } finally { + canvasContext.getCSSSize = originalGetCSSSize; + deck.finalize(); + } +}); + test('Deck#no views', async () => { await new Promise((resolve, reject) => { const deck = new Deck({ From df0dd767ca8a6533e684d1573f2fd81273237bfc Mon Sep 17 00:00:00 2001 From: Chris Gervang Date: Fri, 8 May 2026 12:33:51 -0700 Subject: [PATCH 3/3] fix(core): restore drawing buffer sync for attached GL contexts on resize luma's webgl2Adapter.attach() sets autoResize: false, so the drawing buffer dimensions are never updated automatically when the canvas resizes. Without the explicit setDrawingBufferSize call, interleaved mode (MapboxOverlay/MapLibre) renders into a stale-sized framebuffer after window resize. Co-Authored-By: Claude Opus 4.6 --- modules/core/src/lib/deck.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/core/src/lib/deck.ts b/modules/core/src/lib/deck.ts index 10e06848257..5972a3b160a 100644 --- a/modules/core/src/lib/deck.ts +++ b/modules/core/src/lib/deck.ts @@ -409,8 +409,9 @@ export default class Deck { _cachePipelines: true, ...this.props.deviceProps, onResize: (canvasContext, info) => { - // Attached contexts still emit resize through luma's CanvasContext. - // Deck only mirrors that state into viewport dimensions and redraw flags. + // autoResize is disabled for attached contexts — sync the drawing buffer manually. + const {width, height} = canvasContext.canvas; + canvasContext.setDrawingBufferSize(width, height); this._onCanvasContextResize(canvasContext); userOnResize?.(canvasContext, info); }