Skip to content

Commit

Permalink
chore: Modernize CanvasContext size tracking (#2237)
Browse files Browse the repository at this point in the history
  • Loading branch information
ibgreen authored Sep 1, 2024
1 parent 3e6e589 commit 5f428cc
Show file tree
Hide file tree
Showing 30 changed files with 423 additions and 230 deletions.
4 changes: 4 additions & 0 deletions examples/tutorials/hello-triangle-geometry/app.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// luma.gl
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors

import {Buffer} from '@luma.gl/core';
import {AnimationLoopTemplate, AnimationProps, Model} from '@luma.gl/engine';

Expand Down
2 changes: 1 addition & 1 deletion examples/tutorials/hello-triangle-geometry/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import {webgpuAdapter} from '@luma.gl/webgpu';
import AnimationLoopTemplate from './app.ts';

makeAnimationLoop(AnimationLoopTemplate, {adapters: [/* webgpuAdapter, */ webgl2Adapter]}).start();
makeAnimationLoop(AnimationLoopTemplate, {adapters: [webgpuAdapter, webgl2Adapter]}).start();
</script>
<body>
</body>
1 change: 1 addition & 0 deletions examples/tutorials/hello-two-cubes/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export default class AppAnimationLoopTemplate extends AnimationLoopTemplate {
const renderPass = device.beginRenderPass({clearColor: [0, 0, 0, 1]});
this.cubeModel.setBindings({app: this.uniformBuffer1});
this.cubeModel.draw(renderPass);

this.cubeModel.setBindings({app: this.uniformBuffer2});
this.cubeModel.draw(renderPass);
renderPass.end();
Expand Down
150 changes: 106 additions & 44 deletions modules/core/src/adapter/canvas-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {Device} from './device';
import type {Framebuffer} from './resources/framebuffer';
import {log} from '../utils/log';
import {uid} from '../utils/uid';
import type {TextureFormat} from '../gpu-type-utils/texture-formats';
import type {TextureFormat, DepthStencilTextureFormat} from '../gpu-type-utils/texture-formats';

/** Properties for a CanvasContext */
export type CanvasContextProps = {
Expand Down Expand Up @@ -64,8 +64,8 @@ export abstract class CanvasContext {
/** Default stencil format for depth textures */
abstract readonly depthStencilFormat: TextureFormat;

width: number = 1;
height: number = 1;
drawingBufferWidth: number = 1;
drawingBufferHeight: number = 1;

readonly resizeObserver: ResizeObserver | undefined;

Expand All @@ -85,8 +85,8 @@ export abstract class CanvasContext {
if (!isBrowser()) {
this.id = 'node-canvas-context';
this.type = 'node';
this.width = this.props.width;
this.height = this.props.height;
this.drawingBufferWidth = this.props.width;
this.drawingBufferWidth = this.props.height;
// TODO - does this prevent app from using jsdom style polyfills?
this.canvas = null!;
return;
Expand Down Expand Up @@ -119,24 +119,46 @@ export abstract class CanvasContext {
}

// React to size changes
if (this.canvas instanceof HTMLCanvasElement && props.autoResize) {
this.resizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
if (entry.target === this.canvas) {
this.update();
}
}
});
this.resizeObserver.observe(this.canvas);
if (props.autoResize && this.canvas instanceof HTMLCanvasElement) {
this.resizeObserver = new ResizeObserver(entries => this._handleResize(entries));
try {
this.resizeObserver.observe(this.canvas, {box: 'device-pixel-content-box'});
} catch {
// Safari fallback
this.resizeObserver.observe(this.canvas, {box: 'content-box'});
}
}
}

/** Returns a framebuffer with properly resized current 'swap chain' textures */
abstract getCurrentFramebuffer(): Framebuffer;
abstract getCurrentFramebuffer(options?: {
depthStencilFormat?: DepthStencilTextureFormat | false;
}): Framebuffer;

/** Resized the canvas. Note: Has no effect if props.autoResize is true */
abstract resize(options?: {
width?: number;
height?: number;
useDevicePixels?: boolean | number;
}): void;

// SIZE METHODS

/** Get the drawing buffer size (number of pixels GPU is rendering into, can be different from CSS size) */
getDrawingBufferSize(): [number, number] {
return [this.drawingBufferWidth, this.drawingBufferHeight];
}

/** Returns the biggest allowed framebuffer size. @todo Allow the application to limit this? */
getMaxDrawingBufferSize(): [number, number] {
const maxTextureDimension = this.device.limits.maxTextureDimension2D;
return [maxTextureDimension, maxTextureDimension];
}

/**
* Returns the current DPR, if props.useDevicePixels is true
* Device refers to physical
* Returns the current DPR (number of physical pixels per CSS pixel), if props.useDevicePixels is true
* @note This can be a fractional (non-integer) number, e.g. when the user zooms in the browser.
* @note This function handles the non-HTML canvas cases
*/
getDevicePixelRatio(useDevicePixels?: boolean | number): number {
if (typeof OffscreenCanvas !== 'undefined' && this.canvas instanceof OffscreenCanvas) {
Expand Down Expand Up @@ -167,16 +189,18 @@ export abstract class CanvasContext {
getPixelSize(): [number, number] {
switch (this.type) {
case 'node':
return [this.width, this.height];
return [this.drawingBufferWidth, this.drawingBufferHeight];
case 'offscreen-canvas':
return [this.canvas.width, this.canvas.height];
case 'html-canvas':
const dpr = this.getDevicePixelRatio();
const canvas = this.canvas as HTMLCanvasElement;
// If not attached to DOM client size can be 0
return canvas.parentElement
? [canvas.clientWidth * dpr, canvas.clientHeight * dpr]
: [this.canvas.width, this.canvas.height];
// If not attached to DOM client size can be 0.
// Note: Avoiding issues with checking parentElement/parentNode.
if (document.body.contains(canvas)) {
return [canvas.clientWidth * dpr, canvas.clientHeight * dpr];
}
return [this.canvas.width, this.canvas.height];
default:
throw new Error(this.type);
}
Expand Down Expand Up @@ -223,7 +247,7 @@ export abstract class CanvasContext {
* Use devicePixelRatio to set canvas width and height
* @note this is a raw port of luma.gl v8 code. Might be worth a review
*/
setDevicePixelRatio(
_setDevicePixelRatio(
devicePixelRatio: number,
options: {width?: number; height?: number} = {}
): void {
Expand Down Expand Up @@ -284,27 +308,12 @@ export abstract class CanvasContext {
}
}

// PRIVATE

/** @todo Major hack done to port the CSS methods above, base canvas context should not depend on WebGL */
getDrawingBufferSize(): [number, number] {
// @ts-expect-error This only works for WebGL
const gl = this.device.gl;
if (!gl) {
// use default device pixel ratio
throw new Error('canvas size');
}
return [gl.drawingBufferWidth, gl.drawingBufferHeight];
}
// SUBCLASS OVERRIDES

abstract resize(options?: {
width?: number;
height?: number;
useDevicePixels?: boolean | number;
}): void;
/** Performs platform specific updates (WebGPU vs WebGL) */
protected abstract updateSize(size: [width: number, height: number]): void;

/** Perform platform specific updates (WebGPU vs WebGL) */
protected abstract update(): void;
// IMPLEMENTATION

/**
* Allows subclass constructor to override the canvas id for auto created canvases.
Expand All @@ -315,18 +324,71 @@ export abstract class CanvasContext {
this.htmlCanvas.id = id;
}
}

/** Sets up a resize observer */
protected _handleResize(entries: ResizeObserverEntry[]) {
for (const entry of entries) {
if (entry.target === this.canvas) {
this._handleObservedSizeChange(entry);
}
}
}

/**
* Reacts to an observed resize by using the most accurate pixel size information the browser can provide
* @see https://web.dev/articles/device-pixel-content-box
* @see https://webgpufundamentals.org/webgpu/lessons/webgpu-resizing-the-canvas.html
*/
protected _handleObservedSizeChange(entry: ResizeObserverEntry) {
// Use the most accurate drawing buffer size information the current browser can provide
// Note: content box sizes are guaranteed to be integers
// Note: Safari falls back to contentBoxSize
const boxWidth =
entry.devicePixelContentBoxSize?.[0].inlineSize ||
entry.contentBoxSize[0].inlineSize * devicePixelRatio;

const boxHeight =
entry.devicePixelContentBoxSize?.[0].blockSize ||
entry.contentBoxSize[0].blockSize * devicePixelRatio;

// Make sure we don't overflow the maximum supported texture size
const [maxPixelWidth, maxPixelHeight] = this.getMaxDrawingBufferSize();
const pixelWidth = Math.max(1, Math.min(boxWidth, maxPixelWidth));
const pixelHeight = Math.max(1, Math.min(boxHeight, maxPixelHeight));

// Update the canvas drawing buffer size
// TODO - This does not account for props.useDevicePixels
this.canvas.width = pixelWidth;
this.canvas.height = pixelHeight;

// Update our drawing buffer size variables, saving the old values for logging
const [oldWidth, oldHeight] = this.getDrawingBufferSize();
this.drawingBufferWidth = pixelWidth;
this.drawingBufferHeight = pixelHeight;

// Inform the subclass
this.updateSize([pixelWidth, pixelHeight]);

// Log the resize
log.log(1, `${this} Resized ${oldWidth}x${oldHeight} => ${pixelWidth}x${pixelHeight}px`)();

// TODO - trigger rerender
// this.device.triggerRerender();
}
}

// HELPER FUNCTIONS

/** Get a container element from a string or DOM element */
function getContainer(container: HTMLElement | string | null): HTMLElement {
if (typeof container === 'string') {
const element = document.getElementById(container);
if (!element) {
throw new Error(`${container} is not an HTML element`);
}
return element;
} else if (container) {
}
if (container) {
return container;
}
return document.body;
Expand Down Expand Up @@ -354,7 +416,7 @@ function createCanvas(props: CanvasContextProps) {
}

/**
*
* Scales pixels linearly, handles edge cases
* @param pixel
* @param ratio
* @param width
Expand Down
5 changes: 4 additions & 1 deletion modules/core/src/adapter/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,8 @@ export type DeviceProps = {
debugShaders?: 'never' | 'errors' | 'warnings' | 'always';
/** Renders a small version of updated Framebuffers into the primary canvas context. Can be set in console luma.log.set('debug-framebuffers', true) */
debugFramebuffers?: boolean;
/** Traces resource caching, reuse, and destroys in the PipelineFactory */
debugFactories?: boolean;
/** WebGL specific - Trace WebGL calls (instruments WebGL2RenderingContext at the expense of performance). Can be set in console luma.log.set('debug-webgl', true) */
debugWebGL?: boolean;
/** WebGL specific - Initialize the SpectorJS WebGL debugger. Can be set in console luma.log.set('debug-spectorjs', true) */
Expand Down Expand Up @@ -295,6 +297,7 @@ export abstract class Device {
debug: log.get('debug') || undefined!,
debugShaders: log.get('debug-shaders') || undefined!,
debugFramebuffers: Boolean(log.get('debug-framebuffers')),
debugFactories: Boolean(log.get('debug-factories')),
debugWebGL: Boolean(log.get('debug-webgl')),
debugSpectorJS: undefined!, // Note: log setting is queried by the spector.js code
debugSpectorJSUrl: undefined!,
Expand Down Expand Up @@ -540,7 +543,7 @@ export abstract class Device {
} else if (props.data instanceof Uint16Array) {
newProps.indexType = 'uint16';
} else {
log.warn('indices buffer content must be of integer type')();
log.warn('indices buffer content must be of type uint16 or uint32')();
}
}
return newProps;
Expand Down
17 changes: 8 additions & 9 deletions modules/core/src/adapter/resources/vertex-array.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ import {
import type {Device} from '../device';
import type {Buffer} from './buffer';
import type {RenderPass} from './render-pass';
import type {RenderPipeline} from './render-pipeline';
import {Resource, ResourceProps} from './resource';
import {ShaderLayout} from '../types/shader-layout';
import {BufferLayout} from '../types/buffer-layout';

/** Properties for initializing a VertexArray */
export type VertexArrayProps = ResourceProps & {
renderPipeline: RenderPipeline | null;
shaderLayout: ShaderLayout;
bufferLayout: BufferLayout[];
};

/**
Expand All @@ -28,7 +30,8 @@ export type VertexArrayProps = ResourceProps & {
export abstract class VertexArray extends Resource<VertexArrayProps> {
static override defaultProps: Required<VertexArrayProps> = {
...Resource.defaultProps,
renderPipeline: null
shaderLayout: undefined!,
bufferLayout: []
};

override get [Symbol.toStringTag](): string {
Expand All @@ -49,13 +52,9 @@ export abstract class VertexArray extends Resource<VertexArrayProps> {
super(device, props, VertexArray.defaultProps);
this.maxVertexAttributes = device.limits.maxVertexAttributes;
this.attributes = new Array(this.maxVertexAttributes).fill(null);
const {shaderLayout, bufferLayout} = props.renderPipeline || {};
if (!shaderLayout || !bufferLayout) {
throw new Error('VertexArray');
}
this.attributeInfos = getAttributeInfosByLocation(
shaderLayout,
bufferLayout,
props.shaderLayout,
props.bufferLayout,
this.maxVertexAttributes
);
}
Expand Down
4 changes: 2 additions & 2 deletions modules/core/test/adapter/canvas-context.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,11 @@ test('CanvasContext#getDevicePixelRatio', async t => {
// @ts-expect-error
class TestCanvasContext extends CanvasContext {
// @ts-expect-error
readonly device = {};
readonly device = {limits: {maxTextureDimension2D: 1024}};
getCurrentFramebuffer(): Framebuffer {
throw new Error('test');
}
update() {}
updateSize() {}
}

test('CanvasContext', t => {
Expand Down
2 changes: 1 addition & 1 deletion modules/core/test/adapter/resources/buffer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ test('WEBGLBuffer#construction', async t => {
buffer.destroy();

// TODO - buffer could check for integer ELEMENT_ARRAY_BUFFER types
buffer = webglDevice.createBuffer({usage: Buffer.INDEX, data: new Float32Array([1, 2, 3])});
buffer = webglDevice.createBuffer({usage: Buffer.INDEX, data: new Uint32Array([1, 2, 3])});
t.ok(
buffer.glTarget === GL.ELEMENT_ARRAY_BUFFER,
`${webglDevice.info.type} Buffer(ELEMENT_ARRAY_BUFFER) successful`
Expand Down
4 changes: 2 additions & 2 deletions modules/core/test/adapter/resources/compute-pipeline.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const source = /* WGSL*/ `\
}
`;

test.skip('ComputePipeline construct/delete', async t => {
test.skip('ComputePipeline#construct/delete', async t => {
await getTestDevices();
if (webgpuDevice) {
const shader = webgpuDevice.createShader({source});
Expand All @@ -31,7 +31,7 @@ test.skip('ComputePipeline construct/delete', async t => {
t.end();
});

test('ComputePipeline compute', async t => {
test('ComputePipeline#compute', async t => {
await getTestDevices();
if (webgpuDevice) {
const shader = webgpuDevice.createShader({source});
Expand Down
6 changes: 4 additions & 2 deletions modules/core/test/adapter/resources/vertex-array.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import {VertexArray} from '@luma.gl/core';

test('VertexArray construct/delete', t => {
for (const device of [webglDevice]) {
const renderPipeline = device.createRenderPipeline({});
const vertexArray = device.createVertexArray({renderPipeline});
const vertexArray = device.createVertexArray({
shaderLayout: {attributes: [], bindings: []},
bufferLayout: []
});
t.ok(vertexArray instanceof VertexArray, 'VertexArray construction successful');

vertexArray.destroy();
Expand Down
Loading

0 comments on commit 5f428cc

Please sign in to comment.