Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Dynamically load backends #2089

Merged
merged 4 commits into from
Sep 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading