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 ec03d240bd9..5972a3b160a 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; @@ -405,11 +409,10 @@ export default class Deck { _cachePipelines: true, ...this.props.deviceProps, onResize: (canvasContext, info) => { - // Sync drawing buffer dimensions with externally-managed canvas + // autoResize is disabled for attached contexts — sync the drawing buffer manually. const {width, height} = canvasContext.canvas; canvasContext.setDrawingBufferSize(width, height); - - this._needsRedraw = 'Canvas resized'; + this._onCanvasContextResize(canvasContext); userOnResize?.(canvasContext, info); } }); @@ -1054,15 +1057,22 @@ 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 +1085,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 +1165,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 +1398,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,7 +1463,12 @@ export default class Deck { } } - this._updateCanvasSize(); + 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(); 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 8aea478d154..6f02c5ba893 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..0d2dc02a7a9 100644 --- a/test/modules/core/lib/deck.spec.ts +++ b/test/modules/core/lib/deck.spec.ts @@ -134,6 +134,110 @@ 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#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({