Skip to content

Commit

Permalink
feat: Dynamically load GPU backends (#2089)
Browse files Browse the repository at this point in the history
  • Loading branch information
ibgreen authored Sep 22, 2024
1 parent 2182c5c commit 728a13f
Show file tree
Hide file tree
Showing 9 changed files with 183 additions and 140 deletions.
2 changes: 1 addition & 1 deletion docs/api-reference/core/luma.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ Note: A specific device type is available and supported if both of the following
### `luma.attachDevice()`

```ts
luma.attachDevice({handle: WebGL2RenderingContext | GPUDevice, adapters, ...deviceProps}: AttachDeviceProps);
luma.attachDevice(handle: WebGL2RenderingContext | GPUDevice | null, {adapters, ...deviceProps}: AttachDeviceProps);
```

A luma.gl Device can be attached to an externally created `WebGL2RenderingContext` or `GPUDevice`.
Expand Down
37 changes: 36 additions & 1 deletion modules/core/src/adapter/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors

import {isBrowser} from '@probe.gl/env';
import {Device, DeviceProps} from './device';

/**
Expand All @@ -10,7 +11,41 @@ import {Device, DeviceProps} from './device';
export abstract class Adapter {
// new (props: DeviceProps): Device; Constructor isn't used
abstract type: string;
/** Check if this backend is supported */
abstract isSupported(): boolean;
/** Check if the given handle is a valid device handle for this backend */
abstract isDeviceHandle(handle: unknown): boolean;
/** Create a new device for this backend */
abstract create(props: DeviceProps): Promise<Device>;
abstract attach?(handle: unknown): Promise<Device>;
/** Attach a Device to a valid handle for this backend (GPUDevice, WebGL2RenderingContext etc) */
abstract attach(handle: unknown, props: DeviceProps): Promise<Device>;

/**
* Page load promise
* Resolves when the DOM is loaded.
* @note Since are be limitations on number of `load` event listeners,
* it is recommended avoid calling this accessor until actually needed.
* I.e. we don't call it unless you know that you will be looking up a string in the DOM.
*/
get pageLoaded(): Promise<void> {
return getPageLoadPromise();
}
}

// HELPER FUNCTIONS

const isPage: boolean = isBrowser() && typeof document !== 'undefined';
const isPageLoaded: () => boolean = () => isPage && document.readyState === 'complete';
let pageLoadPromise: Promise<void> | null = null;

/** Returns a promise that resolves when the page is loaded */
function getPageLoadPromise(): Promise<void> {
if (!pageLoadPromise) {
if (isPageLoaded() || typeof window === 'undefined') {
pageLoadPromise = Promise.resolve();
} else {
pageLoadPromise = new Promise(resolve => window.addEventListener('load', () => resolve()));
}
}
return pageLoadPromise;
}
199 changes: 91 additions & 108 deletions modules/core/src/adapter/luma.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,12 @@
// Copyright (c) vis.gl contributors

import type {Log} from '@probe.gl/log';
import {isBrowser} from '@probe.gl/env';
import type {DeviceProps} from './device';
import {Device} from './device';
import {Adapter} from './adapter';
import {StatsManager, lumaStats} from '../utils/stats-manager';
import {log} from '../utils/log';

const isPage: boolean = isBrowser() && typeof document !== 'undefined';
const isPageLoaded: () => boolean = () => isPage && document.readyState === 'complete';

declare global {
// eslint-disable-next-line no-var
var luma: Luma;
Expand All @@ -29,15 +25,15 @@ export type CreateDeviceProps = {
type?: 'webgl' | 'webgpu' | 'null' | 'unknown' | 'best-available';
/** List of adapters. Will also search any pre-registered adapters */
adapters?: Adapter[];
/** Whether to wait for page to be loaded */
/**
* Whether to wait for page to be loaded so that CanvasContext's can access the DOM.
* The browser only supports one 'load' event listener so it may be necessary for the application to set this to false to avoid conflicts.
*/
waitForPageLoad?: boolean;
} & DeviceProps;

/** Properties for attaching an existing WebGL context or WebGPU device to a new luma Device */
export type AttachDeviceProps = {
type?: 'webgl' | 'webgpu' | 'null' | 'unknown' | 'best-available';
/** Externally created WebGL context or WebGPU device */
handle: unknown; // WebGL2RenderingContext | GPUDevice | null;
/** List of adapters. Will also search any pre-registered adapters */
adapters?: Adapter[];
} & DeviceProps;
Expand All @@ -55,17 +51,6 @@ export class Luma {
waitForPageLoad: true
};

/**
* Page load promise
* Get a 'lazy' promise that resolves when the DOM is loaded.
* @note Since there may be limitations on number of `load` event listeners,
* it is recommended avoid calling this function until actually needed.
* I.e. don't call it until you know that you will be looking up a string in the DOM.
*/
static pageLoaded: Promise<void> = getPageLoadPromise().then(() => {
log.probe(2, 'DOM is loaded')();
});

/** Global stats for all devices */
readonly stats: StatsManager = lumaStats;

Expand Down Expand Up @@ -104,6 +89,42 @@ export class Luma {
globalThis.luma = this;
}

/** Creates a device. Asynchronously. */
async createDevice(props_: CreateDeviceProps = {}): Promise<Device> {
const props: Required<CreateDeviceProps> = {...Luma.defaultProps, ...props_};

const adapter = this.selectAdapter(props.type, props.adapters);
if (!adapter) {
throw new Error(ERROR_MESSAGE);
}

// Wait for page to load so that CanvasContext's can access the DOM.
if (props.waitForPageLoad) {
await adapter.pageLoaded;
}

return await adapter.create(props);
}

/**
* Attach to an existing GPU API handle (WebGL2RenderingContext or GPUDevice).
* @param handle Externally created WebGL context or WebGPU device
*/
async attachDevice(handle: unknown, props: AttachDeviceProps): Promise<Device> {
const type = this._getTypeFromHandle(handle, props.adapters);

const adapter = type && this.selectAdapter(type, props.adapters);
if (!adapter) {
throw new Error(ERROR_MESSAGE);
}

return await adapter?.attach?.(handle, props);
}

/**
* Global adapter registration.
* @deprecated Use props.adapters instead
*/
registerAdapters(adapters: Adapter[]): void {
for (const deviceClass of adapters) {
this.preregisteredAdapters.set(deviceClass.type, deviceClass);
Expand All @@ -112,17 +133,17 @@ export class Luma {

/** Get type strings for supported Devices */
getSupportedAdapters(adapters: Adapter[] = []): string[] {
const adapterMap = this.getAdapterMap(adapters);
const adapterMap = this._getAdapterMap(adapters);
return Array.from(adapterMap)
.map(([, adapter]) => adapter)
.filter(adapter => adapter.isSupported?.())
.map(adapter => adapter.type);
}

/** Get type strings for best available Device */
getBestAvailableAdapter(adapters: Adapter[] = []): 'webgpu' | 'webgl' | 'null' | null {
getBestAvailableAdapterType(adapters: Adapter[] = []): 'webgpu' | 'webgl' | 'null' | null {
const KNOWN_ADAPTERS: ('webgpu' | 'webgl' | 'null')[] = ['webgpu', 'webgl', 'null'];
const adapterMap = this.getAdapterMap(adapters);
const adapterMap = this._getAdapterMap(adapters);
for (const type of KNOWN_ADAPTERS) {
if (adapterMap.get(type)?.isSupported?.()) {
return type;
Expand All @@ -131,107 +152,81 @@ export class Luma {
return null;
}

setDefaultDeviceProps(props: CreateDeviceProps): void {
Object.assign(Luma.defaultProps, props);
}

/** Creates a device. Asynchronously. */
async createDevice(props: CreateDeviceProps = {}): Promise<Device> {
props = {...Luma.defaultProps, ...props};

if (props.waitForPageLoad) {
// || props.createCanvasContext) {
await Luma.pageLoaded;
}

const adapterMap = this.getAdapterMap(props.adapters);

let type: string = props.type || '';
/** Select adapter of type from registered adapters */
selectAdapter(type: string, adapters: Adapter[] = []): Adapter | null {
let selectedType: string | null = type;
if (type === 'best-available') {
type = this.getBestAvailableAdapter(props.adapters) || type;
selectedType = this.getBestAvailableAdapterType(adapters);
}

const adapters = this.getAdapterMap(props.adapters) || adapterMap;

const adapter = adapters.get(type);
const device = await adapter?.create?.(props);
if (device) {
return device;
}

throw new Error(ERROR_MESSAGE);
}

/** Attach to an existing GPU API handle (WebGL2RenderingContext or GPUDevice). */
async attachDevice(props: AttachDeviceProps): Promise<Device> {
const adapters = this.getAdapterMap(props.adapters);

// WebGL
let type = 'unknown';
if (props.handle instanceof WebGL2RenderingContext) {
type = 'webgl';
}

if (props.createCanvasContext) {
await Luma.pageLoaded;
}

// TODO - WebGPU does not yet seem to have a stable in-browser API, so we "sniff" instead
// if (props.handle instanceof GPUDevice) {
if ((props.handle as any)?.queue) {
const WebGPUDevice = adapters.get('webgpu') as any;
if (WebGPUDevice) {
return (await WebGPUDevice.attach(props.handle)) as Device;
}
}

// null
if (props.handle === null) {
type = 'null';
}

const adapter = adapters.get(type);
const device = await adapter?.attach?.(null);
if (device) {
return device;
}

throw new Error(ERROR_MESSAGE);
const adapterMap = this._getAdapterMap(adapters);
return (selectedType && adapterMap.get(selectedType)) || null;
}

/**
* Override `HTMLCanvasContext.getCanvas()` to always create WebGL2 contexts with additional WebGL1 compatibility.
* Useful when attaching luma to a context from an external library does not support creating WebGL2 contexts.
*/
enforceWebGL2(enforce: boolean = true, adapters: Adapter[] = []): void {
const adapterMap = this.getAdapterMap(adapters);
const adapterMap = this._getAdapterMap(adapters);
const webgl2Adapter = adapterMap.get('webgl');
if (!webgl2Adapter) {
log.warn('enforceWebGL2: webgl adapter not found')();
}
(webgl2Adapter as any)?.enforceWebGL2?.(enforce);
}

// DEPRECATED

/** @deprecated */
setDefaultDeviceProps(props: CreateDeviceProps): void {
Object.assign(Luma.defaultProps, props);
}

// HELPERS

/** Convert a list of adapters to a map */
protected getAdapterMap(adapters: Adapter[] = []): Map<string, Adapter> {
protected _getAdapterMap(adapters: Adapter[] = []): Map<string, Adapter> {
const map = new Map(this.preregisteredAdapters);
for (const adapter of adapters) {
map.set(adapter.type, adapter);
}
return map;
}

// DEPRECATED
/** Get type of a handle (for attachDevice) */
protected _getTypeFromHandle(
handle: unknown,
adapters: Adapter[] = []
): 'webgpu' | 'webgl' | 'null' | null {
// TODO - delegate handle identification to adapters

/** @deprecated Use registerAdapters */
registerDevices(deviceClasses: any[]): void {
log.warn('luma.registerDevices() is deprecated, use luma.registerAdapters() instead');
for (const deviceClass of deviceClasses) {
const adapter = deviceClass.adapter as Adapter;
if (adapter) {
this.preregisteredAdapters.set(adapter.type, adapter);
}
// WebGL
if (handle instanceof WebGL2RenderingContext) {
return 'webgl';
}

if (typeof GPUDevice !== 'undefined' && handle instanceof GPUDevice) {
return 'webgpu';
}

// TODO - WebGPU does not yet seem to have a stable in-browser API, so we "sniff" for members instead
if ((handle as any)?.queue) {
return 'webgpu';
}

// null
if (handle === null) {
return 'null';
}

if (handle instanceof WebGLRenderingContext) {
log.warn('WebGL1 is not supported', handle)();
} else {
log.warn('Unknown handle type', handle)();
}

return null;
}
}

Expand All @@ -241,15 +236,3 @@ export class Luma {
* Run-time selection of the first available Device
*/
export const luma = new Luma();

// HELPER FUNCTIONS

/** Returns a promise that resolves when the page is loaded */
function getPageLoadPromise(): Promise<void> {
if (isPageLoaded() || typeof window === 'undefined') {
return Promise.resolve();
}
return new Promise(resolve => {
window.addEventListener('load', () => resolve());
});
}
14 changes: 7 additions & 7 deletions modules/core/test/adapter/luma.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
// Copyright (c) vis.gl contributors

import test from 'tape-promise/tape';
import {nullAdapter, NullDevice} from '@luma.gl/test-utils';
import {nullAdapter} from '@luma.gl/test-utils';
import {luma} from '@luma.gl/core';

test('luma#attachDevice', async t => {
const device = await luma.attachDevice({handle: null, adapters: [nullAdapter]});
const device = await luma.attachDevice(null, {adapters: [nullAdapter]});
t.equal(device.type, 'null', 'info.vendor ok');
t.equal(device.info.vendor, 'no one', 'info.vendor ok');
t.equal(device.info.renderer, 'none', 'info.renderer ok');
Expand All @@ -22,8 +22,8 @@ test('luma#createDevice', async t => {
t.end();
});

test('luma#registerDevices', async t => {
luma.registerDevices([NullDevice]);
test('luma#registerAdapters', async t => {
luma.registerAdapters([nullAdapter]);
const device = await luma.createDevice({type: 'null'});
t.equal(device.type, 'null', 'info.vendor ok');
t.equal(device.info.vendor, 'no one', 'info.vendor ok');
Expand All @@ -37,12 +37,12 @@ test('luma#getSupportedAdapters', async t => {
t.ok(types.includes('null'), 'null device is supported');
});

test('luma#getBestAvailableDeviceType', async t => {
test('luma#getBestAvailableAdapterType', async t => {
luma.registerAdapters([nullAdapter]);
// Somewhat dummy test, as tests rely on test utils registering webgl and webgpu devices
// But they might not be supported on all devices.
const types = luma.getBestAvailableAdapter();
t.ok(typeof types === 'string', 'does not crash');
const type = luma.getBestAvailableAdapterType();
t.ok(typeof type === 'string', 'does not crash');
});

// To suppress @typescript-eslint/unbound-method
Expand Down
Loading

0 comments on commit 728a13f

Please sign in to comment.