diff --git a/docs/api-reference/core/adapter.md b/docs/api-reference/core/adapter.md index 5901afa01a..ba63a220bc 100644 --- a/docs/api-reference/core/adapter.md +++ b/docs/api-reference/core/adapter.md @@ -55,8 +55,10 @@ create(props: DeviceProps): Promise; ### `attach()` -Attaches a device to a GPU device handle from this backend. +Attaches a device to a GPU device handle from this backend. The returned device borrows the handle. +Each successful attach call owns one logical device reference; release it with `device.destroy()` +or, when exclusively owned, `device.detach()`. ```ts -attach?(handle: unknown): Promise; +attach(handle: unknown, props?: DeviceProps): Promise; ``` diff --git a/docs/api-reference/core/canvas-context.md b/docs/api-reference/core/canvas-context.md index dc6f7267fc..3f0090e05e 100644 --- a/docs/api-reference/core/canvas-context.md +++ b/docs/api-reference/core/canvas-context.md @@ -48,6 +48,16 @@ const canvasContext1 = device.createCanvasContext(...); const canvasContext2 = device.createCanvasContext(...); ``` +`CanvasContext` wrappers created through a device are managed children of that device. See the +[`Device` lifecycle](./device.md#lifecycle) for ownership, reuse, attach, and detach behavior. Call +`canvasContext.destroy()` to release one wrapper early, or call `device.destroy()` to release all +remaining context wrappers during final device teardown. Destroying a wrapper stops its DOM +observers and releases backend-specific resources, but it never removes the underlying canvas. + +On WebGPU, destroying a luma.gl `CanvasContext` unconfigures its native `GPUCanvasContext`. +The destroyed luma.gl wrapper cannot be reused, but the same canvas can later be wrapped and +configured by another device. + WebGPU supports multiple `CanvasContext`s. A WebGL `Device` always has exactly one `CanvasContext` that must be created when the device is created, and a WebGL device can only render into that single canvas. This is a fundamental limitation of the WebGL API. Because of this, the `Device` class provides a `DeviceProps.createCanvasContext` property that creates a default `CanvasContext`: diff --git a/docs/api-reference/core/device.md b/docs/api-reference/core/device.md index 822939aae8..9ab3e2432d 100644 --- a/docs/api-reference/core/device.md +++ b/docs/api-reference/core/device.md @@ -32,11 +32,12 @@ const device = new luma.createDevice({type: 'webgl2', ...}); Attaching a `Device` to an externally created `WebGL2RenderingContext`. ```typescript -import {Device} from '@luma.gl/core'; +import {luma} from '@luma.gl/core'; import {Model} from '@luma.gl/engine'; +import {webgl2Adapter} from '@luma.gl/webgl'; const gl = canvas.getContext('webgl2', ...); -const device = Device.attach(gl); +const device = await luma.attachDevice(gl, {adapters: [webgl2Adapter]}); const model = new Model(device, options); ``` @@ -52,6 +53,26 @@ const {message} = await device.lost; console.error(message); ``` +## Lifecycle + +A `Device` owns the luma.gl wrappers created through it, not the underlying DOM canvases. +Created devices own their backend handle; attached devices borrow an externally created handle. + +| Operation | Wrapper behavior | Backend handle behavior | +| --- | --- | --- | +| `canvasContext.destroy()` | Destroys one canvas or presentation wrapper early. | Preserves the device and handle. | +| `device.destroy()` | Releases one logical device reference; the final release destroys all remaining wrappers. | Destroys an owned WebGPU handle, but preserves borrowed handles and WebGL contexts. | +| `device.detach()` | Requires exclusive ownership, destroys all remaining wrappers, and permanently invalidates the luma device. | Preserves and returns the backend handle. | + +Each successful `createDevice()` or `attachDevice()` acquisition owns one logical reference. +Release it with `device.destroy()`, or use `device.detach()` instead when the device has one +exclusive owner. Reusable WebGL devices and repeated attachment can return the same `Device` +instance while retaining another logical reference; wrappers remain live until the final matching +release. `device.detach()` rejects shared devices so one owner cannot invalidate another. + +Destroying or detaching a device never removes an `HTMLCanvasElement` or `OffscreenCanvas`. +A detached handle may be attached again later, but its old luma context wrappers cannot be reused. + ## Types ### `DeviceProps` @@ -274,10 +295,14 @@ Use the static `Device.create()` method to create classes. ### destroy() -Releases resources associated with this `Device`. +Releases one logical reference to this `Device`. The final release destroys every remaining +`CanvasContext` and `PresentationContext` wrapper created through it. Destroying a context +wrapper stops its canvas observers and releases backend-specific presentation resources, but does +not remove or destroy the underlying `HTMLCanvasElement` or `OffscreenCanvas`. :::info -Calling `device.destroy()` releases GPU resources immediately on WebGPU. On WebGL it will not immediately release GPU resources. +Calling `device.destroy()` releases an owned WebGPU handle immediately after the final release. +Attached WebGPU handles are borrowed and are not destroyed. On WebGL it will not immediately release GPU resources. The WebGL API does not provide a context destroy function, instead relying on garbage collection to eventually release the resources. ::: @@ -288,6 +313,17 @@ The application should not assume that destroying a device triggers a device los or that the `lost` promise is resolved before any API errors are triggered by access to the destroyed device. ::: +### detach() + +```typescript +detach(): unknown +``` + +Detaches luma.gl from this device and returns the backend handle without destroying it. +Detaching is terminal: it destroys all remaining context wrappers, clears backend association +metadata, and invalidates the luma `Device`. It requires the device to have exactly one logical +reference; call `destroy()` to release shared references first. + ### createCanvasContext()

@@ -301,6 +337,9 @@ createCanvasContext(props?: CanvasContextProps): CanvasContext Creates a new [`CanvasContext`](./canvas-context). WebGL devices can only render into the canvas they were created with. +The returned wrapper is managed by the device. The application may call `canvasContext.destroy()` +to release it early; otherwise `device.destroy()` releases it during final device teardown. + ### createPresentationContext()

diff --git a/docs/api-reference/core/luma.md b/docs/api-reference/core/luma.md index 3b75cc32ee..e3cf8cff69 100644 --- a/docs/api-reference/core/luma.md +++ b/docs/api-reference/core/luma.md @@ -151,10 +151,19 @@ luma.attachDevice(handle: WebGL2RenderingContext | GPUDevice | null, {adapters, A luma.gl Device can be attached to an externally created `WebGL2RenderingContext` or `GPUDevice`. This allows applications to use the luma.gl API to "interleave" rendering with other GPU libraries. +WebGL attachment is currently implemented. WebGPU handle attachment is reserved by this API but +is not yet implemented by `webgpuAdapter`. + - `handle` - The externally created `WebGL2RenderingContext` or `GPUDevice` that should be attached to a luma `Device`. - `adapters` - list of `Device` backend classes. Can be omitted if `luma.registerAdapters()` has been called. - `...deviceProps`: See [`DeviceProps`](./device.md#deviceprops) for device specific options. +The returned luma `Device` borrows the supplied handle. Each successful attachment owns one +logical device reference; release it with `device.destroy()`, or use `device.detach()` when the +device has one exclusive owner. Repeated attachments may return the same luma `Device` instance +with another retained reference. Detach destroys luma wrappers, preserves the external handle, and +returns it. + Note that while you cannot directly attach a luma.gl `Device` to a WebGL 1 `WebGLRenderingContext`, you may be able to work around it using `luma.enforceWebGL2()`. ### `luma.registerAdapters()` diff --git a/docs/api-reference/core/presentation-context.md b/docs/api-reference/core/presentation-context.md index ceb31daf1b..5dd1563bd1 100644 --- a/docs/api-reference/core/presentation-context.md +++ b/docs/api-reference/core/presentation-context.md @@ -43,6 +43,9 @@ presentationContext.present(); - On WebGL, all `PresentationContext` instances on a device share that single default `CanvasContext`, so they must be used sequentially. - On WebGPU, each `PresentationContext` owns its own destination `GPUCanvasContext`. - `present()` is explicit. On WebGL it performs the copy to the destination canvas. On WebGPU it submits the frame. +- `PresentationContext` wrappers are managed by the device that created them. Applications may + call `presentationContext.destroy()` to release one early; otherwise `device.destroy()` releases + all remaining wrappers without removing their destination canvases. ## Backend Behavior diff --git a/docs/upgrade-guide.md b/docs/upgrade-guide.md index 0f7b0fc031..3d18427fc0 100644 --- a/docs/upgrade-guide.md +++ b/docs/upgrade-guide.md @@ -22,6 +22,12 @@ luma.gl largely follows [SEMVER](https://semver.org) conventions. Breaking chang **@luma.gl/core** - WebGPU device creation now defaults to `DeviceProps.featureLevel: 'core'`. Applications that relied on luma.gl requesting every supported WebGPU feature and limit by default should pass `featureLevel: 'max'`. +- `Device.destroy()` now releases one logical device reference and, on final release, destroys all + `CanvasContext` and `PresentationContext` wrappers created through that device. Applications + that previously kept using a context wrapper after `device.destroy()` must keep the device alive + instead. Repeated reusable-device or `attachDevice()` acquisitions each require a matching + `destroy()` call; an exclusive owner can call `device.detach()` to tear down luma wrappers + while preserving and returning the backend handle. - Render draw state is now owned by `RenderPass`. `RenderPipelineProps.bindings`, `RenderPipelineProps.bindGroups`, `RenderPipeline.setBindings()`, and `RenderPipeline.draw()` are deprecated compatibility APIs and will be diff --git a/docs/whats-new.md b/docs/whats-new.md index e58895ccdb..10809a3095 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -15,6 +15,11 @@ Target Release Date: Q3, 2026 **@luma.gl/core** - **HTML-in-Canvas feature detection** - `device.features.has('html-in-canvas')` reports whether the active browser and backend expose the experimental DOM-to-texture rasterization path. +- **[Device and canvas lifecycle](/docs/api-reference/core/device#lifecycle)** - Devices now manage + their `CanvasContext` and `PresentationContext` wrappers, so final teardown disconnects canvas + observers and releases backend presentation resources. Reused and repeatedly attached WebGL + devices retain logical references until the final release, and the new `device.detach()` API + preserves and returns an exclusively owned backend handle. **@luma.gl/experimental** diff --git a/modules/core/src/adapter/adapter.ts b/modules/core/src/adapter/adapter.ts index d6bf0a4d54..13c98c0b0c 100644 --- a/modules/core/src/adapter/adapter.ts +++ b/modules/core/src/adapter/adapter.ts @@ -17,7 +17,10 @@ export abstract class Adapter { abstract isDeviceHandle(handle: unknown): boolean; /** Create a new device for this backend */ abstract create(props: DeviceProps): Promise; - /** Attach a Device to a valid handle for this backend (GPUDevice, WebGL2RenderingContext etc) */ + /** + * Attach a Device to a valid handle for this backend (GPUDevice, WebGL2RenderingContext etc). + * Each successful attachment owns one logical reference that must be released through the Device. + */ abstract attach(handle: unknown, props: DeviceProps): Promise; /** diff --git a/modules/core/src/adapter/canvas-surface.ts b/modules/core/src/adapter/canvas-surface.ts index fa20d481e2..ad74a2d318 100644 --- a/modules/core/src/adapter/canvas-surface.ts +++ b/modules/core/src/adapter/canvas-surface.ts @@ -194,6 +194,7 @@ export abstract class CanvasSurface { if (!this.destroyed) { this.destroyed = true; this._stopObservers(); + this.device?._unregisterCanvasSurface?.(this); // @ts-expect-error Clear the device to make sure we don't access it after destruction. this.device = null; } diff --git a/modules/core/src/adapter/device.ts b/modules/core/src/adapter/device.ts index 7732666c51..4de5bdf527 100644 --- a/modules/core/src/adapter/device.ts +++ b/modules/core/src/adapter/device.ts @@ -13,6 +13,7 @@ import type { } from '../shadertypes/texture-types/texture-formats'; import type {CanvasContext, CanvasContextProps} from './canvas-context'; import type {PresentationContext, PresentationContextProps} from './presentation-context'; +import type {CanvasSurface} from './canvas-surface'; import type {BufferProps} from './resources/buffer'; import {Buffer} from './resources/buffer'; import type {RenderPipeline, RenderPipelineProps} from './resources/render-pipeline'; @@ -444,6 +445,8 @@ export type DeviceProps = { /** @deprecated Internal, Do not use directly! Use `luma.attachDevice()` to attach to pre-created contexts/devices. */ _handle?: unknown; // WebGL2RenderingContext | GPUDevice | null; + /** @internal Whether final device teardown owns destruction of the backend handle. */ + _ownsHandle?: boolean; }; type DeviceFactories = { @@ -536,7 +539,8 @@ export abstract class Device { }, // INTERNAL - _handle: undefined! + _handle: undefined!, + _ownsHandle: true }; get [Symbol.toStringTag](): string { @@ -572,6 +576,14 @@ export abstract class Device { /** True if this device has been reused during device creation (app has multiple references) */ _reused: boolean = false; + /** Canvas and presentation wrappers created through this device. */ + private _canvasSurfaces = new Set(); + /** Number of logical owners that still need to release this device. */ + private _referenceCount = 1; + /** Whether the final device teardown has run. */ + private _destroyed = false; + /** Whether final teardown should destroy the backend handle. */ + protected readonly _ownsHandle: boolean; /** Used by other luma.gl modules to store data on the device */ private _moduleData: Record> = {}; @@ -598,10 +610,100 @@ export abstract class Device { constructor(props: DeviceProps) { this.props = {...Device.defaultProps, ...props}; this.id = this.props.id || uid(this[Symbol.toStringTag].toLowerCase()); + this._ownsHandle = this.props._ownsHandle; } + /** + * Releases one logical reference to this device. + * Final release destroys managed canvas surfaces and owned backend handles. + */ abstract destroy(): void; + /** + * Detaches luma.gl from this device and returns its backend handle without destroying it. + * @remarks Detach is terminal and requires exclusive ownership of the device reference. + */ + abstract detach(): unknown; + + /** + * Registers a canvas-backed wrapper as a child of this device. + * @internal + */ + protected _registerCanvasSurface(canvasSurface: T): T { + if (this._destroyed) { + canvasSurface.destroy(); + throw new Error('Device is destroyed'); + } + + this._canvasSurfaces.add(canvasSurface); + return canvasSurface; + } + + /** + * Removes a canvas-backed wrapper that has been explicitly destroyed. + * @internal + */ + _unregisterCanvasSurface(canvasSurface: CanvasSurface): void { + this._canvasSurfaces.delete(canvasSurface); + } + + /** + * Retains one logical reference to a shared device. + * @internal + */ + _retainDeviceReference(): void { + if (this._destroyed) { + throw new Error('Device is destroyed'); + } + + this._referenceCount++; + } + + /** + * Releases one logical reference and returns true only for the final release. + * @internal + */ + protected _releaseDeviceReference(): boolean { + if (this._destroyed || this._referenceCount === 0) { + return false; + } + + this._referenceCount--; + if (this._referenceCount > 0) { + return false; + } + + this._destroyed = true; + return true; + } + + /** + * Claims the exclusive reference required for terminal detach. + * @internal + */ + protected _detachDeviceReference(): void { + if (this._destroyed) { + throw new Error('Device is destroyed'); + } + if (this._referenceCount !== 1) { + throw new Error('Device is shared'); + } + + this._referenceCount = 0; + this._destroyed = true; + } + + /** + * Destroys canvas-backed wrappers in reverse creation order. + * @internal + */ + protected _destroyCanvasSurfaces(): void { + for (const canvasSurface of [...this._canvasSurfaces].reverse()) { + canvasSurface.destroy(); + } + this._canvasSurfaces.clear(); + } + // TODO - just expose the shadertypes decoders? getVertexFormatInfo(format: VertexFormat): VertexFormatInfo { diff --git a/modules/core/test/adapter/device.spec.ts b/modules/core/test/adapter/device.spec.ts index 739bc2a1db..534aa4c9e5 100644 --- a/modules/core/test/adapter/device.spec.ts +++ b/modules/core/test/adapter/device.spec.ts @@ -4,7 +4,12 @@ import test from '@luma.gl/devtools-extensions/tape-test-utils'; import {Buffer, Texture, luma} from '@luma.gl/core'; -import {getNullTestDevice, getTestDevices, getWebGPUTestDevice} from '@luma.gl/test-utils'; +import { + getNullTestDevice, + getTestDevices, + getWebGPUTestDevice, + NullDevice +} from '@luma.gl/test-utils'; import {webgl2Adapter} from '@luma.gl/webgl'; import {_getDefaultDebugValue} from '../../src/adapter/device'; @@ -38,6 +43,77 @@ test('Device and Resource JSON debug output stays compact', async t => { t.end(); }); +test('Device destroys managed canvas wrappers in reverse creation order', t => { + const device = new NullDevice({createCanvasContext: {width: 1, height: 1}}); + const defaultCanvasContext = device.getDefaultCanvasContext(); + const destroyedCanvasContext = device.createCanvasContext({width: 1, height: 1}); + const additionalCanvasContext = device.createCanvasContext({width: 1, height: 1}); + const destroyOrder: string[] = []; + + const wrapDestroy = (label: string, canvasContext: typeof defaultCanvasContext) => { + const destroy = canvasContext.destroy.bind(canvasContext); + canvasContext.destroy = () => { + destroyOrder.push(label); + destroy(); + }; + }; + + wrapDestroy('default', defaultCanvasContext); + wrapDestroy('destroyed', destroyedCanvasContext); + wrapDestroy('additional', additionalCanvasContext); + + destroyedCanvasContext.destroy(); + destroyedCanvasContext.destroy(); + + t.equal( + (device as any)._canvasSurfaces.size, + 2, + 'explicitly destroyed canvas wrapper unregisters from its device' + ); + + device.destroy(); + device.destroy(); + + t.deepEqual( + destroyOrder, + ['destroyed', 'destroyed', 'additional', 'default'], + 'final device teardown destroys remaining wrappers in reverse creation order' + ); + t.equal( + (defaultCanvasContext as any).device, + null, + 'final device teardown clears the default wrapper device reference' + ); + t.equal( + (additionalCanvasContext as any).device, + null, + 'final device teardown clears additional wrapper device references' + ); + t.throws( + () => device.createCanvasContext({width: 1, height: 1}), + /Device is destroyed/, + 'destroyed devices reject new canvas wrappers' + ); + t.equal( + (device as any)._canvasSurfaces.size, + 0, + 'final device teardown clears the wrapper registry' + ); + + t.end(); +}); + +test('Device#detach destroys managed canvas wrappers and returns the handle', t => { + const device = new NullDevice({createCanvasContext: {width: 1, height: 1}}); + const canvasContext = device.getDefaultCanvasContext(); + + t.equal(device.detach(), null, 'detach returns the backend handle'); + t.equal((canvasContext as any).device, null, 'detach destroys managed canvas wrappers'); + t.throws(() => device.detach(), /Device is destroyed/, 'detach is terminal'); + + t.end(); +}); + // Minimal test, extensive test in texture-formats.spec test('Device#isTextureFormatCompressed', async t => { for (const device of await getTestDevices()) { diff --git a/modules/engine/test/lib/animation-loop.spec.ts b/modules/engine/test/lib/animation-loop.spec.ts index 01efef2d30..aad5cd6d93 100644 --- a/modules/engine/test/lib/animation-loop.spec.ts +++ b/modules/engine/test/lib/animation-loop.spec.ts @@ -138,7 +138,6 @@ test('engine#AnimationLoop passes frame payload from custom animation frame prov t.equal(cancelAnimationFrameCallCount, 1, 'stopping cancels scheduled custom frame'); animationLoop.destroy(); - device.destroy(); t.end(); }); diff --git a/modules/experimental/test/webxr/webxr-camera-texture.spec.ts b/modules/experimental/test/webxr/webxr-camera-texture.spec.ts index cf0301a39c..a35d56f8d9 100644 --- a/modules/experimental/test/webxr/webxr-camera-texture.spec.ts +++ b/modules/experimental/test/webxr/webxr-camera-texture.spec.ts @@ -89,7 +89,6 @@ test('webxr#WebXRCameraTexture resolves borrowed raw camera textures once per ge } finally { gl.deleteTexture = originalDeleteTexture; originalDeleteTexture(cameraTextureHandle); - device.destroy(); } t.end(); diff --git a/modules/experimental/test/webxr/webxr-manager.spec.ts b/modules/experimental/test/webxr/webxr-manager.spec.ts index be0cbdb34e..f011a4ec44 100644 --- a/modules/experimental/test/webxr/webxr-manager.spec.ts +++ b/modules/experimental/test/webxr/webxr-manager.spec.ts @@ -80,7 +80,6 @@ test('webxr#WebXRManager resolves WebGL XR frame state without owning XR framebu gl.makeXRCompatible = originalMakeXRCompatible; gl.deleteFramebuffer = originalDeleteFramebuffer; originalDeleteFramebuffer(xrFramebufferHandle); - device.destroy(); } t.end(); @@ -123,7 +122,6 @@ test('webxr#WebXRManager accepts null XRWebGLLayer framebuffer handles', async t } finally { globalThis.XRWebGLLayer = originalXRWebGLLayer; gl.makeXRCompatible = originalMakeXRCompatible; - device.destroy(); } t.end(); diff --git a/modules/gltf/test/parsers/parse-gltf.spec.ts b/modules/gltf/test/parsers/parse-gltf.spec.ts index 95759e9014..0fc976b009 100644 --- a/modules/gltf/test/parsers/parse-gltf.spec.ts +++ b/modules/gltf/test/parsers/parse-gltf.spec.ts @@ -136,7 +136,6 @@ test('gltf#parseGLTF - box.glb integration', async t => { t.ok(vertexCounts.length > 0, 'Should have at least one model'); t.equals(vertexCounts[0], 36, 'Vertex count should be 36 (from indices)'); } finally { - webglDevice.destroy(); } t.end(); @@ -159,7 +158,6 @@ test('gltf#parseGLTF - non-indexed geometry', async t => { t.ok(vertexCounts.length > 0, 'Should have at least one model'); t.equals(vertexCounts[0], 24, 'Vertex count should be 24 (from POSITION attribute)'); } finally { - webglDevice.destroy(); } t.end(); @@ -193,7 +191,6 @@ test('gltf#parseGLTF - KHR_mesh_quantization point cloud', async t => { t.ok(vertexCounts.length > 0, 'Should have at least one model'); t.equals(vertexCounts[0], 24, 'Vertex count should be 24 (from POSITION attribute)'); } finally { - webglDevice.destroy(); } t.end(); @@ -226,7 +223,6 @@ test('gltf#parseGLTF - nonquantized.glb (float32 mesh)', async t => { t.ok(vertexCounts.length > 0, 'Should have at least one model'); t.equals(vertexCounts[0], 3072, 'Vertex count should be 3072 (from indices)'); } finally { - webglDevice.destroy(); } t.end(); @@ -262,7 +258,6 @@ test('gltf#parseGLTF - quantized.glb (snorm8x3 + uint16x3)', async t => { t.ok(vertexCounts.length > 0, 'Should have at least one model'); t.equals(vertexCounts[0], 3072, 'Vertex count should be 3072 (from indices)'); } finally { - webglDevice.destroy(); } t.end(); diff --git a/modules/test-utils/src/create-test-device.ts b/modules/test-utils/src/create-test-device.ts index afd624de05..705f3fdef9 100644 --- a/modules/test-utils/src/create-test-device.ts +++ b/modules/test-utils/src/create-test-device.ts @@ -49,6 +49,7 @@ type TestDeviceType = /** * Returns available test devices for the requested backend types. * @param types Backend types to create. `'webgpu'` preserves the legacy max-feature WebGPU test device. + * @note Returned devices are shared cached fixtures and reject destroy() and detach(). */ export async function getTestDevices( types: Readonly = ['webgl', 'webgpu'] @@ -110,19 +111,19 @@ export async function getWebGPUTestDevices( return devices.filter((device): device is WebGPUDevice => device !== null); } -/** returns WebGL device promise, if available */ +/** Returns a shared cached WebGL device. The fixture rejects destroy() and detach(). */ export async function getWebGLTestDevice(): Promise { return _refreshLostCachedTestDevice(getOrCreateWebGLTestDevicePromise, () => { testDeviceCache.webglDevicePromise = null; }); } -/** returns an offscreen WebGL device promise for presentation-context tests, if available */ +/** Returns a shared cached offscreen WebGL device for presentation-context tests. */ export async function getPresentationWebGLTestDevice(): Promise { return getOrCreatePresentationWebGLTestDevicePromise(); } -/** returns null device promise, if available */ +/** Returns a shared cached NullDevice. The fixture rejects destroy() and detach(). */ export async function getNullTestDevice(): Promise { return getOrCreateNullTestDevicePromise(); } @@ -162,6 +163,7 @@ async function makeWebGPUTestDevice( createCanvasContext: DEFAULT_CANVAS_CONTEXT_PROPS, debug: true })) as unknown as WebGPUDevice; + protectCachedTestDevice(webgpuDevice); webgpuDevice.lost.finally(() => { if (testDeviceCache.webgpuDevicePromises[featureLevel] === webgpuDeviceResolvers.promise) { delete testDeviceCache.webgpuDevicePromises[featureLevel]; @@ -187,6 +189,7 @@ async function makeWebGLTestDevice(): Promise { createCanvasContext: DEFAULT_CANVAS_CONTEXT_PROPS, debug: true })) as unknown as WebGLDevice; + protectCachedTestDevice(webglDevice); webglDevice.lost.finally(() => { if (testDeviceCache.webglDevicePromise === webglDeviceResolvers.promise) { testDeviceCache.webglDevicePromise = null; @@ -215,6 +218,7 @@ async function makePresentationWebGLTestDevice(): Promise { createCanvasContext: {canvas: new OffscreenCanvas(4, 4)}, debug: true })) as unknown as WebGLDevice; + protectCachedTestDevice(webglDevice); webglDevice.lost.finally(() => { if ( testDeviceCache.presentationWebglDevicePromise === presentationWebGLDeviceResolvers.promise @@ -241,6 +245,7 @@ async function makeNullTestDevice(): Promise { createCanvasContext: DEFAULT_CANVAS_CONTEXT_PROPS, debug: true })) as unknown as NullDevice; + protectCachedTestDevice(nullDevice); nullDeviceResolvers.resolve(nullDevice); } catch (error) { log.error(String(error))(); @@ -264,6 +269,27 @@ export async function _refreshLostCachedTestDevice(device: DeviceT): DeviceT { + Object.defineProperties(device, { + destroy: { + configurable: false, + writable: false, + value: () => { + throw new Error('Cached test devices are shared and cannot be destroyed'); + } + }, + detach: { + configurable: false, + writable: false, + value: () => { + throw new Error('Cached test devices are shared and cannot be detached'); + } + } + }); + return device; +} + function getOrCreateTestDeviceCache(): TestDeviceCache { const rootObject = globalThis as typeof globalThis & { [TEST_DEVICE_CACHE_KEY]?: TestDeviceCache; diff --git a/modules/test-utils/src/null-device/null-adapter.ts b/modules/test-utils/src/null-device/null-adapter.ts index c745bc7af2..85705285ca 100644 --- a/modules/test-utils/src/null-device/null-adapter.ts +++ b/modules/test-utils/src/null-device/null-adapter.ts @@ -24,7 +24,7 @@ export class NullAdapter extends Adapter { } async attach(handle: null, props: DeviceProps = {}): Promise { - return new NullDevice(props); + return new NullDevice({...props, _ownsHandle: false}); } async create(props: DeviceProps = {}): Promise { diff --git a/modules/test-utils/src/null-device/null-canvas-context.ts b/modules/test-utils/src/null-device/null-canvas-context.ts index 76f51e0c8a..0252739501 100644 --- a/modules/test-utils/src/null-device/null-canvas-context.ts +++ b/modules/test-utils/src/null-device/null-canvas-context.ts @@ -32,6 +32,16 @@ export class NullCanvasContext extends CanvasContext { this._startObservers(); } + override destroy(): void { + if (this.destroyed) { + return; + } + + this._framebuffer?.destroy(); + this._framebuffer = null; + super.destroy(); + } + _getCurrentFramebuffer(): NullFramebuffer { // Setting handle to null returns a reference to the default framebuffer this._framebuffer = this._framebuffer || new NullFramebuffer(this.device, {handle: null}); diff --git a/modules/test-utils/src/null-device/null-device.ts b/modules/test-utils/src/null-device/null-device.ts index df27caa159..219289e3f1 100644 --- a/modules/test-utils/src/null-device/null-device.ts +++ b/modules/test-utils/src/null-device/null-device.ts @@ -68,16 +68,32 @@ export class NullDevice extends Device { super({...props, id: props.id || 'null-device'}); const canvasContextProps = Device._getCanvasContextProps(props); - this.canvasContext = new NullCanvasContext(this, canvasContextProps); + this.canvasContext = this._registerCanvasSurface( + new NullCanvasContext(this, canvasContextProps) + ); this.lost = new Promise(_resolve => {}); this.commandEncoder = new NullCommandEncoder(this, {id: 'null-command-encoder'}); } /** - * Destroys the context - * @note Has no effect for null contexts + * Destroys managed canvas wrappers and the command encoder. */ destroy(): void { + if (!this._releaseDeviceReference()) { + return; + } + + this._finalizeDevice(); + } + + override detach(): null { + this._detachDeviceReference(); + this._finalizeDevice(); + return this.handle; + } + + private _finalizeDevice(): void { + this._destroyCanvasSurfaces(); this.commandEncoder?.destroy(); } @@ -88,7 +104,7 @@ export class NullDevice extends Device { // IMPLEMENTATION OF ABSTRACT DEVICE createCanvasContext(props: CanvasContextProps): NullCanvasContext { - return new NullCanvasContext(this, props); + return this._registerCanvasSurface(new NullCanvasContext(this, props)); } createPresentationContext(_props?: PresentationContextProps): PresentationContext { diff --git a/modules/test-utils/test/null-device/null-adapter.spec.ts b/modules/test-utils/test/null-device/null-adapter.spec.ts index 71c27a0071..63fb135e84 100644 --- a/modules/test-utils/test/null-device/null-adapter.spec.ts +++ b/modules/test-utils/test/null-device/null-adapter.spec.ts @@ -3,7 +3,7 @@ // Copyright (c) vis.gl contributors import {expect, test} from 'vitest'; -import {_refreshLostCachedTestDevice} from '../../src/create-test-device'; +import {getNullTestDevice, _refreshLostCachedTestDevice} from '../../src/create-test-device'; test('NullAdapter imports from the ESM package entry without circular init errors', async () => { // Import the local entry file directly to avoid workspace alias resolution mixing src/dist modules. @@ -43,3 +43,12 @@ test('refreshLostCachedTestDevice recreates a lost cached device', async () => { expect(refreshedDevice.id).toBe('fresh-device-1'); expect(createCount).toBe(1); }); + +test('cached test devices reject terminal lifecycle operations', async () => { + const device = await getNullTestDevice(); + + expect(Object.getOwnPropertyDescriptor(device, 'destroy')?.writable).toBe(false); + expect(Object.getOwnPropertyDescriptor(device, 'detach')?.writable).toBe(false); + expect(() => device.destroy()).toThrow(/Cached test devices.*cannot be destroyed/); + expect(() => device.detach()).toThrow(/Cached test devices.*cannot be detached/); +}); diff --git a/modules/webgl/src/adapter/webgl-adapter.ts b/modules/webgl/src/adapter/webgl-adapter.ts index 8e7bfe7892..a5a67c383e 100644 --- a/modules/webgl/src/adapter/webgl-adapter.ts +++ b/modules/webgl/src/adapter/webgl-adapter.ts @@ -53,10 +53,12 @@ export class WebGLAdapter extends Adapter { async attach(gl: Device | WebGL2RenderingContext, props: DeviceProps = {}): Promise { const {WebGLDevice} = await import('./webgl-device'); if (gl instanceof WebGLDevice) { + gl._retainDeviceReference(); return gl; } const existingDevice = WebGLDevice.getDeviceFromContext(gl as WebGL2RenderingContext | null); if (existingDevice) { + existingDevice._retainDeviceReference(); return existingDevice; } if (!isWebGL(gl)) { @@ -70,6 +72,7 @@ export class WebGLAdapter extends Adapter { return new WebGLDevice({ ...props, _handle: gl, + _ownsHandle: false, createCanvasContext: {canvas: gl.canvas, autoResize: false, ...createCanvasContext} }); } diff --git a/modules/webgl/src/adapter/webgl-canvas-context.ts b/modules/webgl/src/adapter/webgl-canvas-context.ts index 74149dff84..21927de43b 100644 --- a/modules/webgl/src/adapter/webgl-canvas-context.ts +++ b/modules/webgl/src/adapter/webgl-canvas-context.ts @@ -30,6 +30,16 @@ export class WebGLCanvasContext extends CanvasContext { this._configureDevice(); } + override destroy(): void { + if (this.destroyed) { + return; + } + + this._framebuffer?.destroy(); + this._framebuffer = null; + super.destroy(); + } + // IMPLEMENTATION OF ABSTRACT METHODS _configureDevice(): void { diff --git a/modules/webgl/src/adapter/webgl-device.ts b/modules/webgl/src/adapter/webgl-device.ts index e077eccb5d..0ff83fbb7f 100644 --- a/modules/webgl/src/adapter/webgl-device.ts +++ b/modules/webgl/src/adapter/webgl-device.ts @@ -104,6 +104,7 @@ export class WebGLDevice extends Device { readonly lost: Promise<{reason: 'destroyed'; message: string}>; private _resolveContextLost?: (value: {reason: 'destroyed'; message: string}) => void; + private _stateTracker: WebGLStateTracker | null = null; /** WebGL2 context. */ readonly gl!: WebGL2RenderingContext; @@ -161,7 +162,9 @@ export class WebGLDevice extends Device { } // Create and instrument context - this.canvasContext = new WebGLCanvasContext(this, canvasContextProps); + this.canvasContext = this._registerCanvasSurface( + new WebGLCanvasContext(this, canvasContextProps) + ); this.lost = new Promise<{reason: 'destroyed'; message: string}>(resolve => { this._resolveContextLost = resolve; @@ -217,6 +220,7 @@ export class WebGLDevice extends Device { // Destroy the orphaned canvas context that was created above (line 149) // to prevent its ResizeObserver from firing callbacks with undefined device this.canvasContext.destroy(); + device._retainDeviceReference(); device._reused = true; return device; } @@ -249,10 +253,10 @@ export class WebGLDevice extends Device { } // Install context state tracking - const glState = new WebGLStateTracker(this.gl, { + this._stateTracker = new WebGLStateTracker(this.gl, { log: (...args: any[]) => log.log(1, ...args)() }); - glState.trackState(this.gl, {copyState: false}); + this._stateTracker.trackState(this.gl, {copyState: false}); // props.debug - instrument the WebGL context with Khronos debug tools // props.debugWebGL - activate WebGL context tracing, force log level to at least 1 @@ -271,7 +275,7 @@ export class WebGLDevice extends Device { /** * Destroys the device * - * @note "Detaches" from the WebGL context unless _reuseDevices is true. + * @note Shared devices created with _reuseDevices are detached after their final release. * * @note The underlying WebGL context is not immediately destroyed, * but may be destroyed later through normal JavaScript garbage collection. @@ -279,17 +283,28 @@ export class WebGLDevice extends Device { * browser API for destroying WebGL contexts. */ destroy(): void { - this.commandEncoder?.destroy(); - // Note that deck.gl (especially in React strict mode) depends on being able - // to asynchronously create a Device against the same canvas (i.e. WebGL context) - // multiple times and getting the same device back. Since deck.gl is not aware - // of this sharing, it might call destroy() multiple times on the same device. - // Therefore we must do nothing in destroy() if props._reuseDevices is true - if (!this.props._reuseDevices && !this._reused) { - // Delete the reference to the device that we store on the WebGL context - const contextData = getWebGLContextData(this.handle); - contextData.device = null; + if (!this._releaseDeviceReference()) { + return; } + + this._finalizeDevice(); + } + + override detach(): WebGL2RenderingContext { + this._detachDeviceReference(); + this._finalizeDevice(); + return this.handle; + } + + private _finalizeDevice(): void { + this._destroyCanvasSurfaces(); + this.commandEncoder?.destroy(); + this._stateTracker?.untrackState(); + this._stateTracker = null; + + // Delete the reference to the device that we store on the WebGL context. + const contextData = getWebGLContextData(this.handle); + contextData.device = null; } get isLost(): boolean { @@ -303,7 +318,7 @@ export class WebGLDevice extends Device { } createPresentationContext(props?: PresentationContextProps): PresentationContext { - return new WebGLPresentationContext(this, props || {}); + return this._registerCanvasSurface(new WebGLPresentationContext(this, props || {})); } createBuffer(props: BufferProps | ArrayBuffer | ArrayBufferView): WEBGLBuffer { diff --git a/modules/webgl/src/context/state-tracker/webgl-state-tracker.ts b/modules/webgl/src/context/state-tracker/webgl-state-tracker.ts index fc050738c7..22e09d3feb 100644 --- a/modules/webgl/src/context/state-tracker/webgl-state-tracker.ts +++ b/modules/webgl/src/context/state-tracker/webgl-state-tracker.ts @@ -32,6 +32,7 @@ export class WebGLStateTracker { log; protected initialized = false; + protected originalFunctions: Record = {}; constructor( gl: WebGL2RenderingContext, @@ -80,17 +81,46 @@ export class WebGLStateTracker { // @ts-expect-error this.gl.lumaState = this; - installProgramSpy(gl); + this.originalFunctions.useProgram = gl.useProgram; + installProgramSpy(gl, this.originalFunctions.useProgram); // intercept all setter functions in the table for (const key in GL_HOOKED_SETTERS) { const setter = GL_HOOKED_SETTERS[key]; - installSetterSpy(gl, key, setter); + if (gl[key]) { + this.originalFunctions[key] = gl[key]; + } + installSetterSpy(gl, key, setter, this.originalFunctions[key]); } // intercept all getter functions in the table - installGetterOverride(gl, 'getParameter'); - installGetterOverride(gl, 'isEnabled'); + this.originalFunctions.getParameter = gl.getParameter; + this.originalFunctions.isEnabled = gl.isEnabled; + installGetterOverride(gl, 'getParameter', this.originalFunctions.getParameter); + installGetterOverride(gl, 'isEnabled', this.originalFunctions.isEnabled); + } + + /** Restore the native WebGL methods that were wrapped by trackState(). */ + untrackState(): void { + if (!this.initialized) { + return; + } + + for (const functionName in this.originalFunctions) { + this.gl[functionName] = this.originalFunctions[functionName]; + } + + // @ts-expect-error lumaState is luma.gl metadata on the WebGL context. + if (this.gl.lumaState === this) { + // @ts-expect-error lumaState is luma.gl metadata on the WebGL context. + this.gl.lumaState = undefined; + } + + this.originalFunctions = {}; + this.stateStack.length = 0; + this.program = null; + this.cache = null!; + this.initialized = false; } /** @@ -139,9 +169,13 @@ export class WebGLStateTracker { * @param gl * @param functionName */ -function installGetterOverride(gl: WebGL2RenderingContext, functionName: string) { +function installGetterOverride( + gl: WebGL2RenderingContext, + functionName: string, + originalGetter: Function +) { // Get the original function from the WebGL2RenderingContext - const originalGetterFunc = gl[functionName].bind(gl); + const originalGetterFunc = originalGetter.bind(gl); // Wrap it with a spy so that we can update our state cache when it gets called gl[functionName] = function get(pname) { @@ -180,7 +214,12 @@ function installGetterOverride(gl: WebGL2RenderingContext, functionName: string) * @param setter * @returns */ -function installSetterSpy(gl: WebGL2RenderingContext, functionName: string, setter: Function) { +function installSetterSpy( + gl: WebGL2RenderingContext, + functionName: string, + setter: Function, + originalSetter: Function +) { // Get the original function from the WebGL2RenderingContext if (!gl[functionName]) { // TODO - remove? @@ -188,7 +227,7 @@ function installSetterSpy(gl: WebGL2RenderingContext, functionName: string, sett return; } - const originalSetterFunc = gl[functionName].bind(gl); + const originalSetterFunc = originalSetter.bind(gl); // Wrap it with a spy so that we can update our state cache when it gets called gl[functionName] = function set(...params) { @@ -218,8 +257,8 @@ function installSetterSpy(gl: WebGL2RenderingContext, functionName: string, sett }); } -function installProgramSpy(gl: WebGL2RenderingContext): void { - const originalUseProgram = gl.useProgram.bind(gl); +function installProgramSpy(gl: WebGL2RenderingContext, originalUseProgramFunction: Function): void { + const originalUseProgram = originalUseProgramFunction.bind(gl); gl.useProgram = function useProgramLuma(handle) { const glState = WebGLStateTracker.get(gl); diff --git a/modules/webgl/test/adapter/resources/webgl-render-pass.spec.ts b/modules/webgl/test/adapter/resources/webgl-render-pass.spec.ts index 60230458f1..60e21409c6 100644 --- a/modules/webgl/test/adapter/resources/webgl-render-pass.spec.ts +++ b/modules/webgl/test/adapter/resources/webgl-render-pass.spec.ts @@ -32,7 +32,6 @@ test('WEBGLRenderPass#drawBuffers for framebuffer attachments', async t => { gl.drawBuffers = originalDrawBuffers; framebuffer.destroy(); - device.destroy(); t.end(); }); @@ -53,7 +52,6 @@ test('WEBGLRenderPass#drawBuffers for default framebuffer', async t => { t.deepEqual(drawBufferCalls[0], [GL.BACK], 'draws to GL.BACK for default framebuffer'); gl.drawBuffers = originalDrawBuffers; - device.destroy(); t.end(); }); @@ -75,7 +73,6 @@ test('WEBGLRenderPass#drawBuffers for explicit default framebuffer wrapper', asy t.deepEqual(drawBufferCalls[0], [GL.BACK], 'explicit default framebuffer still draws to GL.BACK'); gl.drawBuffers = originalDrawBuffers; - device.destroy(); t.end(); }); @@ -117,7 +114,6 @@ test('WEBGLRenderPass#drawBuffers for wrapped external WebGLFramebuffer', async framebuffer.destroy(); gl.deleteRenderbuffer(rb); gl.deleteFramebuffer(externalFbo); - device.destroy(); t.end(); }); @@ -145,6 +141,5 @@ test('WEBGLRenderPass flushes deferred default canvas resize', async t => { ); renderPass.end(); - device.destroy(); t.end(); }); diff --git a/modules/webgl/test/adapter/resources/webgl-render-pipeline.spec.ts b/modules/webgl/test/adapter/resources/webgl-render-pipeline.spec.ts index 33079fac20..3f75bb10cd 100644 --- a/modules/webgl/test/adapter/resources/webgl-render-pipeline.spec.ts +++ b/modules/webgl/test/adapter/resources/webgl-render-pipeline.spec.ts @@ -115,7 +115,6 @@ test('WEBGLRenderPipeline#uniformBlockBinding applies block indices in the corre renderPipeline.destroy(); vs.destroy(); fs.destroy(); - device.destroy(); t.end(); return; } @@ -204,7 +203,6 @@ test('WEBGLRenderPipeline#uniformBlockBinding applies block indices in the corre renderPipeline.destroy(); vs.destroy(); fs.destroy(); - device.destroy(); t.end(); }); @@ -225,7 +223,6 @@ test('WEBGLRenderPipeline initializes mixed sampler uniforms before validation', renderPipeline.destroy(); vs.destroy(); fs.destroy(); - device.destroy(); t.end(); }); @@ -242,7 +239,6 @@ test('WEBGLRenderPipeline uses indexCount for indexed draws', async t => { renderPipeline.destroy(); vs.destroy(); fs.destroy(); - device.destroy(); t.end(); return; } @@ -294,6 +290,5 @@ test('WEBGLRenderPipeline uses indexCount for indexed draws', async t => { renderPipeline.destroy(); vs.destroy(); fs.destroy(); - device.destroy(); t.end(); }); diff --git a/modules/webgl/test/adapter/resources/webgl-texture.spec.ts b/modules/webgl/test/adapter/resources/webgl-texture.spec.ts index d02f0e6045..891ddd2041 100644 --- a/modules/webgl/test/adapter/resources/webgl-texture.spec.ts +++ b/modules/webgl/test/adapter/resources/webgl-texture.spec.ts @@ -83,7 +83,6 @@ test('WEBGLTexture keeps borrowed handles read-only', async t => { gl.texParameteri = originalTexParameteri; gl.texStorage2D = originalTexStorage2D; originalDeleteTexture(textureHandle); - device.destroy(); } t.end(); diff --git a/modules/webgl/test/adapter/webgl-device.spec.ts b/modules/webgl/test/adapter/webgl-device.spec.ts index a0f800fb44..e9261e33fb 100644 --- a/modules/webgl/test/adapter/webgl-device.spec.ts +++ b/modules/webgl/test/adapter/webgl-device.spec.ts @@ -3,7 +3,8 @@ // Copyright (c) vis.gl contributors import test from '@luma.gl/devtools-extensions/tape-test-utils'; -import {webgl2Adapter} from '@luma.gl/webgl'; +import {GL} from '@luma.gl/webgl/constants'; +import {webgl2Adapter, WebGLDevice} from '@luma.gl/webgl'; // TODO - duplicates core spec? test('WebGLDevice#lost (Promise)', async t => { @@ -23,3 +24,126 @@ test('WebGLDevice#lost (Promise)', async t => { device.destroy(); }); + +test('WebGLDevice#destroy releases reused devices only after the final owner', async t => { + if (typeof document === 'undefined') { + t.pass('Document unavailable, skipped reusable WebGL device lifecycle test'); + t.end(); + return; + } + + const canvas = document.createElement('canvas'); + const firstDevice = await webgl2Adapter.create({ + createCanvasContext: {canvas}, + _reuseDevices: true, + debug: false + }); + const canvasContext = firstDevice.getDefaultCanvasContext(); + const secondDevice = await webgl2Adapter.create({ + createCanvasContext: {canvas}, + _reuseDevices: true, + debug: false + }); + + t.equal(secondDevice, firstDevice, 'reusable acquisition returns the existing device'); + + firstDevice.destroy(); + + t.equal( + (canvasContext as any).device, + firstDevice, + 'intermediate release keeps the shared canvas wrapper alive' + ); + t.equal( + WebGLDevice.getDeviceFromContext(firstDevice.handle), + firstDevice, + 'intermediate release keeps the WebGL context attached' + ); + + secondDevice.destroy(); + + t.equal((canvasContext as any).device, null, 'final release destroys the canvas wrapper'); + t.equal( + WebGLDevice.getDeviceFromContext(firstDevice.handle), + null, + 'final release detaches the WebGL context' + ); + + const thirdDevice = await webgl2Adapter.create({ + createCanvasContext: {canvas}, + _reuseDevices: true, + debug: false + }); + + t.notEqual(thirdDevice, firstDevice, 'later acquisition creates a fresh device'); + thirdDevice.destroy(); + t.end(); +}); + +test('WebGLAdapter#attach retains existing devices until final detach', async t => { + if (typeof document === 'undefined') { + t.pass('Document unavailable, skipped attached WebGL device lifecycle test'); + t.end(); + return; + } + + const canvas = document.createElement('canvas'); + const webglContext = canvas.getContext('webgl2'); + if (!webglContext) { + t.pass('WebGL2 unavailable, skipped attached WebGL device lifecycle test'); + t.end(); + return; + } + const nativeEnable = webglContext.enable.bind(webglContext); + let nativeEnableCallCount = 0; + webglContext.enable = ((capability: number) => { + nativeEnableCallCount++; + nativeEnable(capability); + }) as typeof webglContext.enable; + + const firstDevice = await webgl2Adapter.attach(webglContext); + const canvasContext = firstDevice.getDefaultCanvasContext(); + const secondDevice = await webgl2Adapter.attach(webglContext); + + t.equal(secondDevice, firstDevice, 'repeated attach returns the existing device'); + t.throws(() => firstDevice.detach(), /Device is shared/, 'shared devices cannot detach'); + + firstDevice.destroy(); + + t.equal( + (canvasContext as any).device, + firstDevice, + 'intermediate attached-device release keeps wrappers alive' + ); + + const detachedHandle = secondDevice.detach(); + + t.equal(detachedHandle, webglContext, 'detach returns the external WebGL handle'); + t.equal((canvasContext as any).device, null, 'final detach destroys luma wrappers'); + t.equal( + WebGLDevice.getDeviceFromContext(webglContext), + null, + 'final detach clears luma metadata from the external handle' + ); + + const thirdDevice = await webgl2Adapter.attach(webglContext); + const fourthDevice = await webgl2Adapter.attach(thirdDevice); + + t.equal(fourthDevice, thirdDevice, 'attaching an existing Device retains it'); + + nativeEnableCallCount = 0; + webglContext.enable(GL.BLEND); + t.equal(nativeEnableCallCount, 1, 'reattached state tracker reaches the native setter'); + webglContext.disable(GL.BLEND); + + thirdDevice.destroy(); + t.equal( + (thirdDevice.getDefaultCanvasContext() as any).device, + thirdDevice, + 'intermediate Device attachment release keeps wrappers alive' + ); + + fourthDevice.detach(); + webglContext.enable = nativeEnable; + t.end(); +}); diff --git a/modules/webgl/test/adapter/webgl-presentation-context.spec.ts b/modules/webgl/test/adapter/webgl-presentation-context.spec.ts index ad7fbe05df..7f56fd4b92 100644 --- a/modules/webgl/test/adapter/webgl-presentation-context.spec.ts +++ b/modules/webgl/test/adapter/webgl-presentation-context.spec.ts @@ -10,6 +10,157 @@ import { getWebGLTestDevice, getWebGPUTestDevice } from '@luma.gl/test-utils'; +import {webgl2Adapter} from '@luma.gl/webgl'; +import {webgpuAdapter, type WebGPUDevice} from '@luma.gl/webgpu'; + +test('WebGLDevice#destroy releases managed presentation contexts', async t => { + if (typeof OffscreenCanvas === 'undefined' || typeof document === 'undefined') { + t.pass('Canvas APIs unavailable, skipped managed WebGL presentation lifecycle test'); + t.end(); + return; + } + + const device = await webgl2Adapter.create({ + createCanvasContext: {canvas: new OffscreenCanvas(4, 4)}, + debug: false + }); + const defaultCanvasContext = device.getDefaultCanvasContext(); + const destinationCanvas = document.createElement('canvas'); + const presentationContext = device.createPresentationContext({canvas: destinationCanvas}); + + defaultCanvasContext.getCurrentFramebuffer(); + t.ok((defaultCanvasContext as any)._framebuffer, 'default context creates a framebuffer'); + + device.destroy(); + + t.equal( + (defaultCanvasContext as any)._framebuffer, + null, + 'device teardown releases the WebGL default framebuffer wrapper' + ); + t.equal( + (defaultCanvasContext as any).device, + null, + 'device teardown destroys the default canvas wrapper' + ); + t.equal( + (presentationContext as any).device, + null, + 'device teardown destroys presentation wrappers' + ); + + t.end(); +}); + +test('WebGPUDevice#destroy releases managed canvas and presentation contexts', async t => { + if (typeof document === 'undefined') { + t.pass('Document unavailable, skipped managed WebGPU context lifecycle test'); + t.end(); + return; + } + + let device: WebGPUDevice; + try { + device = await webgpuAdapter.create({ + createCanvasContext: {width: 1, height: 1}, + debug: false + }); + } catch { + t.pass('WebGPU unavailable, skipped managed WebGPU context lifecycle test'); + t.end(); + return; + } + + const defaultCanvasContext = device.getDefaultCanvasContext(); + const additionalCanvasContext = device.createCanvasContext({ + canvas: document.createElement('canvas'), + width: 1, + height: 1 + }); + const presentationContext = device.createPresentationContext({ + canvas: document.createElement('canvas'), + width: 1, + height: 1 + }); + + defaultCanvasContext.getCurrentFramebuffer(); + additionalCanvasContext.getCurrentFramebuffer(); + presentationContext.getCurrentFramebuffer(); + + device.destroy(); + + t.equal( + (defaultCanvasContext as any).device, + null, + 'device teardown destroys the default WebGPU canvas wrapper' + ); + t.equal( + (additionalCanvasContext as any).device, + null, + 'device teardown destroys additional WebGPU canvas wrappers' + ); + t.equal( + (presentationContext as any).device, + null, + 'device teardown destroys WebGPU presentation wrappers' + ); + t.equal( + (additionalCanvasContext as any).framebuffer, + null, + 'device teardown releases additional WebGPU framebuffer wrappers' + ); + t.equal( + (presentationContext as any).framebuffer, + null, + 'device teardown releases WebGPU presentation framebuffer wrappers' + ); + + t.end(); +}); + +test('WebGPUDevice#detach preserves its native handle', async t => { + if (typeof document === 'undefined') { + t.pass('Document unavailable, skipped WebGPU detach lifecycle test'); + t.end(); + return; + } + + let uncapturedErrorCallCount = 0; + let device: WebGPUDevice; + try { + device = await webgpuAdapter.create({ + createCanvasContext: {width: 1, height: 1}, + onError: () => { + uncapturedErrorCallCount++; + return true; + }, + debug: false + }); + } catch { + t.pass('WebGPU unavailable, skipped WebGPU detach lifecycle test'); + t.end(); + return; + } + + const canvasContext = device.getDefaultCanvasContext(); + const webgpuHandle = device.detach(); + webgpuHandle.dispatchEvent(new Event('uncapturederror')); + const buffer = webgpuHandle.createBuffer({ + size: 4, + usage: GPUBufferUsage.COPY_DST + }); + + t.ok(buffer, 'native WebGPU handle remains usable after detach'); + t.equal(uncapturedErrorCallCount, 0, 'detach removes the uncaptured error listener'); + t.equal((canvasContext as any).device, null, 'detach destroys luma canvas wrappers'); + + buffer.destroy(); + webgpuHandle.destroy(); + const lostInfo = await device.lost; + t.equal(lostInfo.reason, 'destroyed', 'detached device still reports native handle loss'); + t.equal(device.isLost, true, 'detached device loss state remains observable'); + t.end(); +}); test('WebGLPresentationContext delegates framebuffer sizing and present()', async t => { const device = await getPresentationWebGLTestDevice(); diff --git a/modules/webgpu/src/adapter/webgpu-canvas-context.ts b/modules/webgpu/src/adapter/webgpu-canvas-context.ts index 5c1334686a..f41dd4cc7e 100644 --- a/modules/webgpu/src/adapter/webgpu-canvas-context.ts +++ b/modules/webgpu/src/adapter/webgpu-canvas-context.ts @@ -47,6 +47,10 @@ export class WebGPUCanvasContext extends CanvasContext { /** Destroy any textures produced while configured and remove the context configuration. */ override destroy(): void { + if (this.destroyed) { + return; + } + if (this.framebuffer) { this.framebuffer.destroy(); this.framebuffer = null; diff --git a/modules/webgpu/src/adapter/webgpu-device.ts b/modules/webgpu/src/adapter/webgpu-device.ts index a153cc8d71..1c800c503a 100644 --- a/modules/webgpu/src/adapter/webgpu-device.ts +++ b/modules/webgpu/src/adapter/webgpu-device.ts @@ -93,8 +93,16 @@ export class WebGPUDevice extends Device { override canvasContext: WebGPUCanvasContext | null = null; - private _isLost: boolean = false; + private readonly _lostState = {isLost: false}; private _defaultSampler: WebGPUSampler | null = null; + private readonly _onUncapturedError = (event: Event) => { + event.preventDefault(); + // TODO is this the right way to make sure the error is an Error instance? + const errorMessage = + event instanceof GPUUncapturedErrorEvent ? event.error.message : 'Unknown WebGPU error'; + this.reportError(new Error(errorMessage), this)(); + this.debug(); + }; commandEncoder: WebGPUCommandEncoder; override get [Symbol.toStringTag](): string { @@ -121,25 +129,21 @@ export class WebGPUDevice extends Device { this.limits = getWebGPUDeviceLimits(this.handle.limits); // Listen for uncaptured WebGPU errors - device.addEventListener('uncapturederror', (event: Event) => { - event.preventDefault(); - // TODO is this the right way to make sure the error is an Error instance? - const errorMessage = - event instanceof GPUUncapturedErrorEvent ? event.error.message : 'Unknown WebGPU error'; - this.reportError(new Error(errorMessage), this)(); - this.debug(); - }); + device.addEventListener('uncapturederror', this._onUncapturedError); // "Context" loss handling + const lostState = this._lostState; this.lost = this.handle.lost.then(lostInfo => { - this._isLost = true; + lostState.isLost = true; return {reason: 'destroyed', message: lostInfo.message}; }); // Note: WebGPU devices can be created without a canvas, for compute shader purposes const canvasContextProps = Device._getCanvasContextProps(props); if (canvasContextProps) { - this.canvasContext = new WebGPUCanvasContext(this, this.adapter, canvasContextProps); + this.canvasContext = this._registerCanvasSurface( + new WebGPUCanvasContext(this, this.adapter, canvasContextProps) + ); } this.commandEncoder = this.createCommandEncoder({}); @@ -151,14 +155,32 @@ export class WebGPUDevice extends Device { // this.glslang = glsl && await loadGlslangModule(); destroy(): void { + if (!this._releaseDeviceReference()) { + return; + } + + this._finalizeDevice({destroyHandle: this._ownsHandle}); + } + + override detach(): GPUDevice { + this._detachDeviceReference(); + this._finalizeDevice({destroyHandle: false}); + return this.handle; + } + + private _finalizeDevice(options: {destroyHandle: boolean}): void { + this.handle.removeEventListener('uncapturederror', this._onUncapturedError); + this._destroyCanvasSurfaces(); this.commandEncoder?.destroy(); this._defaultSampler?.destroy(); this._defaultSampler = null; - this.handle.destroy(); + if (options.destroyHandle) { + this.handle.destroy(); + } } get isLost(): boolean { - return this._isLost; + return this._lostState.isLost; } getShaderLayout(source: string) { @@ -265,11 +287,11 @@ export class WebGPUDevice extends Device { } createCanvasContext(props: CanvasContextProps): WebGPUCanvasContext { - return new WebGPUCanvasContext(this, this.adapter, props); + return this._registerCanvasSurface(new WebGPUCanvasContext(this, this.adapter, props)); } createPresentationContext(props?: PresentationContextProps): PresentationContext { - return new WebGPUPresentationContext(this, props); + return this._registerCanvasSurface(new WebGPUPresentationContext(this, props)); } createPipelineLayout(props: PipelineLayoutProps): WebGPUPipelineLayout { @@ -520,7 +542,7 @@ export class WebGPUDevice extends Device { return ( errorMessage.includes('Instance dropped') && (!operation || errorMessage.includes(operation)) && - (this._isLost || + (this._lostState.isLost || this.info.gpu === 'software' || this.info.gpuType === 'cpu' || Boolean(this.info.fallback)) diff --git a/modules/webgpu/src/adapter/webgpu-presentation-context.ts b/modules/webgpu/src/adapter/webgpu-presentation-context.ts index 0838c85b7c..d26a27b673 100644 --- a/modules/webgpu/src/adapter/webgpu-presentation-context.ts +++ b/modules/webgpu/src/adapter/webgpu-presentation-context.ts @@ -44,6 +44,10 @@ export class WebGPUPresentationContext extends PresentationContext { } override destroy(): void { + if (this.destroyed) { + return; + } + if (this.framebuffer) { this.framebuffer.destroy(); this.framebuffer = null;