diff --git a/crates/canvas-2d/src/lib.rs b/crates/canvas-2d/src/lib.rs index f0c5585f2..7ffd9726b 100644 --- a/crates/canvas-2d/src/lib.rs +++ b/crates/canvas-2d/src/lib.rs @@ -86,12 +86,14 @@ pub fn bytes_to_data_url( "data:,".to_string() } -// use native format -pub fn bytes_to_data_n32_url( +/// `bytes` are RGBA, or BGRA when `bgra`: the source texture's format sets the order, not the +/// platform. +pub fn bytes_to_data_rgba8_url( width: i32, height: i32, bytes: &[u8], row_bytes: usize, + bgra: bool, format: &str, quality: c_uint, ) -> String { @@ -101,11 +103,7 @@ pub fn bytes_to_data_n32_url( encoded_prefix.push_str(format); encoded_prefix.push_str(";base64,"); - #[cfg(any(target_os = "ios", target_os = "macos", target_os = "visionos", target_os = "tvos"))] - let fmt = ColorType::BGRA8888; - - #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "visionos", target_os = "tvos")))] - let fmt = ColorType::RGBA8888; + let fmt = if bgra { ColorType::BGRA8888 } else { ColorType::RGBA8888 }; let image_info = ImageInfo::new((width, height), fmt, AlphaType::Unpremul, None); if let Some(image) = images::raster_from_data(&image_info, data, row_bytes) { diff --git a/crates/canvas-c/src/webgpu/gpu_canvas_context.rs b/crates/canvas-c/src/webgpu/gpu_canvas_context.rs index 14977adcb..f6c9405dd 100644 --- a/crates/canvas-c/src/webgpu/gpu_canvas_context.rs +++ b/crates/canvas-c/src/webgpu/gpu_canvas_context.rs @@ -482,11 +482,12 @@ fn to_data_url_with_texture( let row_bytes = round_up_to_256_u64((4 * width) as u64) as usize; #[cfg(feature = "2d")] - let encoded = Some(canvas_2d::bytes_to_data_n32_url( + let encoded = Some(canvas_2d::bytes_to_data_rgba8_url( width as i32, height as i32, bytes, row_bytes, + is_bgra, format, quality, )); diff --git a/crates/canvas-webgl/src/webgl.rs b/crates/canvas-webgl/src/webgl.rs index e53fa9c22..c9889be34 100644 --- a/crates/canvas-webgl/src/webgl.rs +++ b/crates/canvas-webgl/src/webgl.rs @@ -2303,7 +2303,8 @@ pub fn canvas_native_webgl_read_drawing_buffer(source: &mut WebGLState) -> (i32, gl_bindings::GetIntegerv(gl_bindings::PIXEL_PACK_BUFFER_BINDING, &mut pack_buffer); gl_bindings::BindBuffer(gl_bindings::PIXEL_PACK_BUFFER, 0); } - gl_bindings::BindFramebuffer(gl_bindings::FRAMEBUFFER, 0); + // The drawing buffer: framebuffer 0, or on iOS the view's own (what binding null means). + canvas_native_webgl_bind_frame_buffer(gl_bindings::FRAMEBUFFER, 0, source); gl_bindings::PixelStorei(gl_bindings::PACK_ALIGNMENT, 4); gl_bindings::ReadPixels( 0, diff --git a/packages/canvas-polyfill/window.ts b/packages/canvas-polyfill/window.ts index 5be3ad449..75dd52616 100644 --- a/packages/canvas-polyfill/window.ts +++ b/packages/canvas-polyfill/window.ts @@ -8,7 +8,7 @@ import { XMLDocument } from './DOM/XMLDocument'; import { DOMPointReadOnly, DOMPoint } from './DOM/DOMPointReadOnly'; import { Device, fromObject, View } from '@nativescript/core'; import { AbortController, AbortSignal } from '@nativescript/core/abortcontroller'; -import { CanvasRenderingContext2D, WebGLRenderingContext, WebGL2RenderingContext, ImageData, ImageBitmap } from '@nativescript/canvas'; +import { CanvasRenderingContext2D, WebGLRenderingContext, WebGL2RenderingContext, ImageData, ImageBitmap, OffscreenCanvas, OffscreenCanvasRenderingContext2D } from '@nativescript/canvas'; import { HTMLCollection } from './DOM/HTMLCollection'; import { HTMLUnknownElement } from './DOM/HTMLUnknownElement'; import { Navigator } from './navigator'; @@ -24,6 +24,8 @@ import { hookGamepadListeners } from './gamepad'; (global as any).window.HTMLImageElement = (global as any).HTMLImageElement = (global as any).HTMLImageElement || HTMLImageElement; (global as any).window.Image = (global as any).Image = (global as any).Image || HTMLImageElement; (global as any).window.ImageBitmap = (global as any).ImageBitmap = (global as any).ImageBitmap || ImageBitmap; +(global as any).window.OffscreenCanvas = (global as any).OffscreenCanvas = (global as any).OffscreenCanvas || OffscreenCanvas; +(global as any).window.OffscreenCanvasRenderingContext2D = (global as any).OffscreenCanvasRenderingContext2D = (global as any).OffscreenCanvasRenderingContext2D || OffscreenCanvasRenderingContext2D; (global as any).window.HTMLVideoElement = (global as any).HTMLVideoElement = (global as any).HTMLVideoElement || HTMLVideoElement; (global as any).window.Video = (global as any).Video = (global as any).Video || HTMLVideoElement; (global as any).window.HTMLAudioElement = (global as any).HTMLAudioElement = (global as any).HTMLAudioElement || HTMLAudioElement; diff --git a/packages/canvas/Canvas/common.ts b/packages/canvas/Canvas/common.ts index 2be03705c..a0d1cb56a 100644 --- a/packages/canvas/Canvas/common.ts +++ b/packages/canvas/Canvas/common.ts @@ -480,6 +480,12 @@ export function lengthToDevicePixels(value: any, parent: any, isWidth: boolean): let warnedReady = false; +function domError(name: string, message: string) { + const error: any = new Error(message); + error.name = name; + return error; +} + @CSSType('Canvas') export abstract class CanvasBase extends ContainerView implements ICanvasBase { /** @deprecated The canvas is usable as soon as it exists; call getContext() directly. */ @@ -1308,7 +1314,54 @@ export abstract class CanvasBase extends ContainerView implements ICanvasBase { this[attrib] = value ?? attrib; } - public abstract getContext(type: string, contextAttributes?: any): CanvasRenderingContext | null; + /** Set by `transferControlToOffscreen()`: the canvas then only shows its OffscreenCanvas's frames. */ + protected _transferredToOffscreen = false; + + /** Builds the OffscreenCanvas a transferred canvas hands out; set by the OffscreenCanvas module. */ + static _offscreenFromPlaceholder: ((canvas: CanvasBase) => any) | null = null; + + /** getContext() without the transfer check, for this canvas's OffscreenCanvas. */ + public abstract _getContext(type: string, contextAttributes?: any): CanvasRenderingContext | null; + + /** @internal */ + public abstract _hasContext(): boolean; + + /** Sets the drawing buffer's size, as `width`/`height` do but without the transfer check. */ + public abstract _resizeBitmap(width: number, height: number): void; + + public getContext(type: string, contextAttributes?: any): CanvasRenderingContext | null { + if (this._transferredToOffscreen) { + throw domError('InvalidStateError', "Failed to execute 'getContext' on 'HTMLCanvasElement': Cannot get context from a canvas that has transferred its control to offscreen."); + } + return this._getContext(type, contextAttributes); + } + + /** + * Hands drawing over to an OffscreenCanvas, which draws into this canvas's surface so its frames + * still show here. The canvas can't have a context, and can't be transferred twice. + */ + transferControlToOffscreen() { + if (this._transferredToOffscreen) { + throw domError('InvalidStateError', "Failed to execute 'transferControlToOffscreen' on 'HTMLCanvasElement': Cannot transfer control from a canvas for more than one time."); + } + if (this._hasContext()) { + throw domError('InvalidStateError', "Failed to execute 'transferControlToOffscreen' on 'HTMLCanvasElement': Cannot transfer control from a canvas that has a rendering context."); + } + const create = CanvasBase._offscreenFromPlaceholder; + if (!create) { + throw new Error('OffscreenCanvas is unavailable: import it from @nativescript/canvas.'); + } + const offscreen = create(this); + this._transferredToOffscreen = true; + return offscreen; + } + + /** A transferred canvas's size belongs to its OffscreenCanvas. */ + protected __assertCanResize(name: 'width' | 'height') { + if (this._transferredToOffscreen) { + throw domError('InvalidStateError', `Failed to set the '${name}' property on 'HTMLCanvasElement': Cannot resize canvas after call to transferControlToOffscreen().`); + } + } public abstract getBoundingClientRect(): { x: number; diff --git a/packages/canvas/Canvas/index.android.ts b/packages/canvas/Canvas/index.android.ts index 0be2f39f9..f1e58257f 100644 --- a/packages/canvas/Canvas/index.android.ts +++ b/packages/canvas/Canvas/index.android.ts @@ -313,11 +313,13 @@ export class Canvas extends CanvasBase { } set width(value: any) { + this.__assertCanResize('width'); this.__setSurfaceWidth(value); this.__resetAfterResize(); } set height(value: any) { + this.__assertCanResize('height'); this.__setSurfaceHeight(value); this.__resetAfterResize(); } @@ -532,7 +534,16 @@ export class Canvas extends CanvasBase { } } - getContext(type: string, contextAttributes?: any): CanvasRenderingContext2D | WebGLRenderingContext | WebGL2RenderingContext | GPUCanvasContext | null { + _hasContext(): boolean { + return this._contextType !== ContextType.None; + } + + _resizeBitmap(width: number, height: number) { + this.__setSurfaceWidth(width); + this.__setSurfaceHeight(height); + } + + _getContext(type: string, contextAttributes?: any): CanvasRenderingContext2D | WebGLRenderingContext | WebGL2RenderingContext | GPUCanvasContext | null { if (!this._canvas) { return null; } diff --git a/packages/canvas/Canvas/index.d.ts b/packages/canvas/Canvas/index.d.ts index 3046c492e..247346ff1 100644 --- a/packages/canvas/Canvas/index.d.ts +++ b/packages/canvas/Canvas/index.d.ts @@ -5,6 +5,7 @@ import { WebGLRenderingContext } from '../WebGL/WebGLRenderingContext'; import { WebGL2RenderingContext } from '../WebGL2/WebGL2RenderingContext'; import { GPUCanvasContext } from '../WebGPU'; import { ImageBitmapRenderingContext } from '../ImageBitmapRenderingContext'; +import type { OffscreenCanvas } from '../OffscreenCanvas'; import { LengthPercentage } from '@nativescript/core/css/parser'; export declare function createSVGMatrix(): DOMMatrix; @@ -41,6 +42,9 @@ export declare class Canvas extends CanvasBase { /** Default for `getContext('2d', { threaded })`: rasterize on a shared render thread (Android, iOS, Windows). */ static threaded2D: boolean; + /** Default for `getContext('webgl' | 'webgl2', { threaded })`: run the context on the WebGL thread (Android). */ + static threadedWebGL: boolean; + surfaceOnTop: boolean; static forceGL: boolean; @@ -55,6 +59,13 @@ export declare class Canvas extends CanvasBase { toDataURL(type?: string, encoderOptions?: number): any; + /** + * Hands drawing over to an OffscreenCanvas that draws into this canvas's surface. Throws an + * `InvalidStateError` if the canvas already has a context or was transferred; afterwards its + * getContext() and width/height setters throw too. + */ + transferControlToOffscreen(): OffscreenCanvas; + getContext(type: '2d', options?: any): CanvasRenderingContext2D | null; getContext(type: 'bitmaprenderer', options?: { alpha?: boolean }): ImageBitmapRenderingContext | null; diff --git a/packages/canvas/Canvas/index.ios.ts b/packages/canvas/Canvas/index.ios.ts index 9168816de..2967b7c54 100644 --- a/packages/canvas/Canvas/index.ios.ts +++ b/packages/canvas/Canvas/index.ios.ts @@ -198,12 +198,14 @@ export class Canvas extends CanvasBase { // @ts-ignore set width(value: any) { + this.__assertCanResize('width'); this.__setSurfaceWidth(value); this.__resetAfterResize(); } // @ts-ignore set height(value: any) { + this.__assertCanResize('height'); this.__setSurfaceHeight(value); this.__resetAfterResize(); } @@ -519,7 +521,16 @@ export class Canvas extends CanvasBase { return context; } - getContext(type: string, options?: any): CanvasRenderingContext2D | WebGLRenderingContext | WebGL2RenderingContext | GPUCanvasContext | null { + _hasContext(): boolean { + return this._contextType !== ContextType.None; + } + + _resizeBitmap(width: number, height: number) { + this.__setSurfaceWidth(width); + this.__setSurfaceHeight(height); + } + + _getContext(type: string, options?: any): CanvasRenderingContext2D | WebGLRenderingContext | WebGL2RenderingContext | GPUCanvasContext | null { if (!this._canvas) { return null; } diff --git a/packages/canvas/Canvas/napi-host.ts b/packages/canvas/Canvas/napi-host.ts index 182b9c57f..fb3029e36 100644 --- a/packages/canvas/Canvas/napi-host.ts +++ b/packages/canvas/Canvas/napi-host.ts @@ -264,6 +264,7 @@ export abstract class NapiCanvas extends CanvasBase { // @ts-ignore set width(value: any) { + this.__assertCanResize('width'); this.__setSurfaceWidth(value); this.__resetAfterResize(); } @@ -278,6 +279,7 @@ export abstract class NapiCanvas extends CanvasBase { // @ts-ignore set height(value: any) { + this.__assertCanResize('height'); this.__setSurfaceHeight(value); this.__resetAfterResize(); } @@ -518,7 +520,16 @@ export abstract class NapiCanvas extends CanvasBase { return context; } - getContext(type: string, options?: any): CanvasRenderingContext2D | WebGLRenderingContext | WebGL2RenderingContext | GPUCanvasContext | null { + _hasContext(): boolean { + return this._contextType !== ContextType.None; + } + + _resizeBitmap(width: number, height: number) { + this.__setSurfaceWidth(width); + this.__setSurfaceHeight(height); + } + + _getContext(type: string, options?: any): CanvasRenderingContext2D | WebGLRenderingContext | WebGL2RenderingContext | GPUCanvasContext | null { if (!this._canvas || typeof type !== 'string') { return null; } diff --git a/packages/canvas/Canvas/utils.ts b/packages/canvas/Canvas/utils.ts index 3f9a01efc..5689602e4 100644 --- a/packages/canvas/Canvas/utils.ts +++ b/packages/canvas/Canvas/utils.ts @@ -188,3 +188,12 @@ export function holdBackAnimationFramesWhileBehind() { target.window.cancelAnimationFrame = cancelAnimationFrame; } } + +/** + * An OffscreenCanvas, which image sources (drawImage, createPattern, createImageBitmap, texImage2D, + * copyExternalImageToTexture) take like a Canvas: both hand their context's native object as `native`. + * Checked by tag so the consumers need not import the OffscreenCanvas module. + */ +export function isOffscreenCanvas(value: any): boolean { + return !!value && typeof value === 'object' && value[Symbol.toStringTag] === 'OffscreenCanvas'; +} diff --git a/packages/canvas/Canvas2D/CanvasRenderingContext2D/index.ts b/packages/canvas/Canvas2D/CanvasRenderingContext2D/index.ts index 8cda8ea1c..c534ca5ec 100644 --- a/packages/canvas/Canvas2D/CanvasRenderingContext2D/index.ts +++ b/packages/canvas/Canvas2D/CanvasRenderingContext2D/index.ts @@ -12,6 +12,7 @@ import { DOMMatrix } from '../DOMMatrix'; import type { CanvasRenderingContext } from '../../common'; import { NAPI_HOST } from '../../platform'; import { imageAssetFor } from '../../platform/index'; +import { isOffscreenCanvas } from '../../Canvas/utils'; declare const NSCCanvasRenderingContext2D; const REPETITIONS = ['repeat', 'repeat-x', 'repeat-y', 'no-repeat']; @@ -789,7 +790,7 @@ export class CanvasRenderingContext2D implements CanvasRenderingContext { return null; } else if (image instanceof ImageAsset) { img = image.native; - } else if (image instanceof Canvas) { + } else if ((image instanceof Canvas || isOffscreenCanvas(image))) { img = (image as any).native; } else if (image && typeof image.tagName === 'string' && (image.tagName === 'IMG' || image.tagName === 'IMAGE')) { if (__ANDROID__ && image._image instanceof android.graphics.Bitmap) { @@ -907,7 +908,7 @@ export class CanvasRenderingContext2D implements CanvasRenderingContext { } else if (__APPLE__ && image instanceof UIImage) { drawNativeImage(args, image, this); return; - } else if (image instanceof Canvas) { + } else if ((image instanceof Canvas || isOffscreenCanvas(image))) { image = (image as any).native; } else if (image && typeof image.tagName === 'string' && (image.tagName === 'IMG' || image.tagName === 'IMAGE')) { if (image._imageSource instanceof ImageSource && !NAPI_HOST) { @@ -1032,7 +1033,7 @@ export class CanvasRenderingContext2D implements CanvasRenderingContext { isNativeSource = true; } else if (__APPLE__ && image instanceof UIImage) { isNativeSource = true; - } else if (image instanceof Canvas) { + } else if ((image instanceof Canvas || isOffscreenCanvas(image))) { if (__APPLE__) { image = _canvasToImageAssetIOS(image as Canvas); } else { diff --git a/packages/canvas/ImageBitmap/index.ts b/packages/canvas/ImageBitmap/index.ts index 0c177a002..bc157dc82 100644 --- a/packages/canvas/ImageBitmap/index.ts +++ b/packages/canvas/ImageBitmap/index.ts @@ -6,6 +6,7 @@ import { ImageSource } from '@nativescript/core'; import { Helpers } from '../helpers'; import { NAPI_HOST } from '../platform'; import { imageAssetFor } from '../platform/index'; +import { isOffscreenCanvas } from '../Canvas/utils'; function parseOptions(options) { if (__ANDROID__) { @@ -61,7 +62,7 @@ function invalidSource(source: any) { /** `undefined` for an unrecognised source, so the caller rejects instead of hanging. */ function resolveSource(source: any): any { source = fromSvgSource(source); - if (source instanceof Canvas) { + if ((source instanceof Canvas || isOffscreenCanvas(source))) { return (source as any).native; } if (source instanceof ImageBitmap) { diff --git a/packages/canvas/OffscreenCanvas/index.ts b/packages/canvas/OffscreenCanvas/index.ts new file mode 100644 index 000000000..2acaa349d --- /dev/null +++ b/packages/canvas/OffscreenCanvas/index.ts @@ -0,0 +1,487 @@ +/** + * `OffscreenCanvas`: a canvas with no view. + * + * It draws through the same native contexts as `Canvas`, on a detached `Canvas` made at the + * first getContext(), or, after `canvas.transferControlToOffscreen()`, on that canvas's own + * surface so the frames show in its view. The contexts belong to the UI thread, so it cannot + * move to a Worker yet. + */ +import { Canvas } from '../Canvas'; +import { CanvasBase } from '../Canvas/common'; +import { CanvasRenderingContext2D } from '../Canvas2D/CanvasRenderingContext2D'; +import { ImageBitmap } from '../ImageBitmap'; +import { ImageBitmapRenderingContext } from '../ImageBitmapRenderingContext'; +import { handleContextOptions } from '../Canvas/utils'; +import { Helpers } from '../helpers'; + +export type OffscreenRenderingContextId = '2d' | 'bitmaprenderer' | 'webgl' | 'webgl2' | 'webgpu'; + +const CONTEXT_IDS: OffscreenRenderingContextId[] = ['2d', 'bitmaprenderer', 'webgl', 'webgl2', 'webgpu']; + +const ENCODE_TYPES = ['image/png', 'image/jpeg', 'image/webp']; + +/** Forwarded from the host canvas, which fires them on GPU device loss. */ +const HOST_EVENTS = ['contextlost', 'contextrestored', 'webglcontextlost', 'webglcontextrestored']; + +export interface ImageEncodeOptions { + type?: string; + quality?: number; +} + +function domError(name: string, message: string) { + const error: any = new Error(message); + error.name = name; + return error; +} + +/** WebIDL `[EnforceRange] unsigned long long`. */ +function enforceRange(value: any, prefix: string): number { + const number = Number(value); + if (!Number.isFinite(number)) { + throw new TypeError(`${prefix}: Value is not a finite number.`); + } + const integer = Math.trunc(number); + if (integer < 0 || integer > Number.MAX_SAFE_INTEGER) { + throw new TypeError(`${prefix}: Value is outside the 'unsigned long long' value range.`); + } + return integer; +} + +/** A 2D context's bitmap. The callback runs synchronously for a context source. */ +function bitmapFrom2D(native: any): ImageBitmap | null { + let result = null; + global.CanvasModule.createImageBitmap(native, (_error, value) => { + result = value ?? null; + }); + return ImageBitmap.fromNative(result); +} + +function bitmapFromPixels(width: number, height: number, pixels: Uint8Array, premultiplied: boolean): ImageBitmap | null { + const asset = new global.CanvasModule.ImageAsset(); + if (!asset.fromBytesSync(width, height, pixels, premultiplied)) { + return null; + } + return ImageBitmap.fromNative(global.CanvasModule.ImageBitmap.fromAsset(asset)); +} + +const FRAMEBUFFER = 0x8d40; +const FRAMEBUFFER_BINDING = 0x8ca6; +const PIXEL_PACK_BUFFER = 0x88eb; +const PIXEL_PACK_BUFFER_BINDING = 0x88ed; +const RGBA = 0x1908; +const UNSIGNED_BYTE = 0x1401; + +/** The drawing buffer (not a bound framebuffer), top row first. */ +function bitmapFromWebGL(gl: any): ImageBitmap | null { + const width = gl.drawingBufferWidth; + const height = gl.drawingBufferHeight; + if (!width || !height) { + return null; + } + const framebuffer = gl.getParameter(FRAMEBUFFER_BINDING); + const packBuffer = gl._type === 'webgl2' ? gl.getParameter(PIXEL_PACK_BUFFER_BINDING) : null; + if (framebuffer) { + gl.bindFramebuffer(FRAMEBUFFER, null); + } + if (packBuffer) { + gl.bindBuffer(PIXEL_PACK_BUFFER, null); + } + const rows = new Uint8Array(width * height * 4); + gl.native.readPixels(0, 0, width, height, RGBA, UNSIGNED_BYTE, rows); + if (framebuffer) { + gl.bindFramebuffer(FRAMEBUFFER, framebuffer); + } + if (packBuffer) { + gl.bindBuffer(PIXEL_PACK_BUFFER, packBuffer); + } + + const stride = width * 4; + const pixels = new Uint8Array(rows.length); + for (let y = 0; y < height; y++) { + pixels.set(rows.subarray((height - 1 - y) * stride, (height - y) * stride), y * stride); + } + const attributes = gl.getContextAttributes?.() ?? {}; + return bitmapFromPixels(width, height, pixels, attributes.alpha !== false && attributes.premultipliedAlpha !== false); +} + +function bitmapFromDataURL(url: string): ImageBitmap | null { + const comma = typeof url === 'string' ? url.indexOf(',') : -1; + if (comma < 0 || comma === url.length - 1) { + return null; + } + const [, buffer] = Helpers.base64Decode(url.substring(comma + 1)); + const asset = new global.CanvasModule.ImageAsset(); + if (!buffer || !asset.fromEncodedBytesSync(new Uint8Array(buffer))) { + return null; + } + return ImageBitmap.fromNative(global.CanvasModule.ImageBitmap.fromAsset(asset)); +} + +/** Transparent black, or opaque black without alpha. A clip set outside save() still bounds it. */ +function clear2D(ctx: any, width: number, height: number) { + ctx.save(); + ctx.resetTransform(); + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = 'copy'; + ctx.filter = 'none'; + ctx.shadowColor = 'rgba(0, 0, 0, 0)'; + if (ctx.getContextAttributes?.().alpha === false) { + ctx.fillStyle = '#000000'; + ctx.fillRect(0, 0, width, height); + } else { + ctx.clearRect(0, 0, width, height); + } + ctx.restore(); +} + +const SCISSOR_TEST = 0x0c11; +const COLOR_CLEAR_VALUE = 0x0c22; +const COLOR_WRITEMASK = 0x0c23; +const DEPTH_CLEAR_VALUE = 0x0b73; +const DEPTH_WRITEMASK = 0x0b72; +const STENCIL_CLEAR_VALUE = 0x0b91; +const STENCIL_WRITEMASK = 0x0b98; +const CLEAR_ALL = 0x4000 | 0x0100 | 0x0400; + +/** Clears the drawing buffer as a present would, leaving the app's GL state as it was. */ +function clearWebGL(gl: any) { + const native = gl.native; + const framebuffer = gl.getParameter(FRAMEBUFFER_BINDING); + const scissor = native.isEnabled(SCISSOR_TEST); + const color = native.getParameter(COLOR_CLEAR_VALUE); + const colorMask = native.getParameter(COLOR_WRITEMASK); + const depth = native.getParameter(DEPTH_CLEAR_VALUE); + const depthMask = native.getParameter(DEPTH_WRITEMASK); + const stencil = native.getParameter(STENCIL_CLEAR_VALUE); + const stencilMask = native.getParameter(STENCIL_WRITEMASK); + + if (framebuffer) { + gl.bindFramebuffer(FRAMEBUFFER, null); + } + if (scissor) { + native.disable(SCISSOR_TEST); + } + native.colorMask(true, true, true, true); + native.depthMask(true); + native.stencilMask(0xffffffff); + native.clearColor(0, 0, 0, 0); + native.clearDepth(1); + native.clearStencil(0); + native.clear(CLEAR_ALL); + + native.clearColor(color[0], color[1], color[2], color[3]); + native.colorMask(colorMask[0], colorMask[1], colorMask[2], colorMask[3]); + native.clearDepth(depth); + native.depthMask(depthMask); + native.clearStencil(stencil); + native.stencilMask(stencilMask); + if (scissor) { + native.enable(SCISSOR_TEST); + } + if (framebuffer) { + gl.bindFramebuffer(FRAMEBUFFER, framebuffer); + } +} + +export class OffscreenCanvas { + private _width: number; + private _height: number; + /** Detached, or the placeholder canvas. Made at the first getContext(). */ + private _host: Canvas | null = null; + private _contextId: OffscreenRenderingContextId | null = null; + private _context: any = null; + private _listeners: Map | null = null; + + oncontextlost: ((event: any) => any) | null = null; + oncontextrestored: ((event: any) => any) | null = null; + + constructor(width: number, height: number) { + if (arguments.length < 2) { + throw new TypeError(`Failed to construct 'OffscreenCanvas': 2 arguments required, but only ${arguments.length} present.`); + } + this._width = enforceRange(width, "Failed to construct 'OffscreenCanvas'"); + this._height = enforceRange(height, "Failed to construct 'OffscreenCanvas'"); + } + + /** @internal `canvas.transferControlToOffscreen()`: draws into the canvas's own surface. */ + static _fromPlaceholder(canvas: Canvas): OffscreenCanvas { + const offscreen = new OffscreenCanvas(canvas.width, canvas.height); + offscreen._attachHost(canvas); + return offscreen; + } + + get [Symbol.toStringTag]() { + return 'OffscreenCanvas'; + } + + get width(): number { + return this._width; + } + + set width(value: number) { + this._width = enforceRange(value, "Failed to set the 'width' property on 'OffscreenCanvas'"); + this._resize(); + } + + get height(): number { + return this._height; + } + + set height(value: number) { + this._height = enforceRange(value, "Failed to set the 'height' property on 'OffscreenCanvas'"); + this._resize(); + } + + /** The context's native object, which drawImage, createImageBitmap, texImage2D and copyExternalImageToTexture read. */ + get native() { + return this._context?.native ?? null; + } + + /** @internal */ + get _canvasHost(): Canvas | null { + return this._host; + } + + getContext(contextId: '2d', options?: any): CanvasRenderingContext2D | null; + getContext(contextId: 'bitmaprenderer', options?: { alpha?: boolean }): ImageBitmapRenderingContext | null; + getContext(contextId: 'webgl' | 'webgl2' | 'webgpu', options?: any): any; + getContext(contextId: OffscreenRenderingContextId, options?: any): any { + const id = String(contextId) as OffscreenRenderingContextId; + if (CONTEXT_IDS.indexOf(id) === -1) { + throw new TypeError(`Failed to execute 'getContext' on 'OffscreenCanvas': The provided value '${id}' is not a valid enum value of type OffscreenRenderingContextType.`); + } + if (this._context) { + return this._contextId === id ? this._context : null; + } + + const host = this._ensureHost(); + let context: any = null; + if (id === 'bitmaprenderer') { + // Backed by a 2D surface that is never handed out, as Canvas does it. + const backing = host._getContext('2d', options); + if (backing) { + context = new ImageBitmapRenderingContext(this as any, backing, handleContextOptions('bitmaprenderer', options)); + } + } else { + context = host._getContext(id, options); + if (context) { + Object.defineProperty(context, 'canvas', { value: this, configurable: true }); + } + } + + if (context) { + this._context = context; + this._contextId = id; + } + return context; + } + + transferToImageBitmap(): ImageBitmap { + const context = this._context; + if (!context) { + throw domError('InvalidStateError', "Failed to execute 'transferToImageBitmap' on 'OffscreenCanvas': Cannot transfer an ImageBitmap from an OffscreenCanvas with no context"); + } + let bitmap: ImageBitmap | null = null; + switch (this._contextId) { + case '2d': + bitmap = bitmapFrom2D(context.native); + clear2D(context, this._width, this._height); + break; + case 'bitmaprenderer': + bitmap = bitmapFrom2D(context.native); + context.transferFromImageBitmap(null); + break; + case 'webgl': + case 'webgl2': + bitmap = bitmapFromWebGL(context); + clearWebGL(context); + break; + case 'webgpu': + bitmap = bitmapFromDataURL(context.__toDataURL('image/png', 1)); + // The next getCurrentTexture() starts a new frame. + try { + if (context.native?.hasCurrentTexture !== false) { + context.presentSurface(); + } + } catch (e) {} + break; + } + if (!bitmap) { + throw domError('UnknownError', "Failed to execute 'transferToImageBitmap' on 'OffscreenCanvas': The canvas bitmap could not be read."); + } + return bitmap; + } + + convertToBlob(options?: ImageEncodeOptions): Promise { + return new Promise((resolve, reject) => { + if (this._width === 0 || this._height === 0) { + reject(domError('IndexSizeError', "Failed to execute 'convertToBlob' on 'OffscreenCanvas': The size of the OffscreenCanvas is zero.")); + return; + } + const requested = typeof options?.type === 'string' ? options.type.toLowerCase() : 'image/png'; + const type = ENCODE_TYPES.indexOf(requested) === -1 ? 'image/png' : requested; + const quality = typeof options?.quality === 'number' && options.quality >= 0 && options.quality <= 1 ? options.quality : 0.92; + + let url: string; + try { + url = this._toDataURL(type, quality); + } catch (e) { + reject(e); + return; + } + const comma = typeof url === 'string' ? url.indexOf(',') : -1; + if (comma < 0 || comma === url.length - 1) { + reject(domError('EncodingError', "Failed to execute 'convertToBlob' on 'OffscreenCanvas': Encoding the image failed.")); + return; + } + // The encoder may have fallen back to another format. + const mime = /^data:([^;,]+)/.exec(url)?.[1] ?? type; + Helpers.base64DecodeAsync(url.substring(comma + 1)) + .then((decoded) => { + const buffer = decoded?.[1]; + if (!buffer) { + reject(domError('EncodingError', "Failed to execute 'convertToBlob' on 'OffscreenCanvas': Encoding the image failed.")); + return; + } + resolve(new Blob([buffer], { type: mime })); + }) + .catch(reject); + }); + } + + addEventListener(type: string, listener: any, options?: boolean | { once?: boolean }) { + if (!listener) { + return; + } + this._listeners ??= new Map(); + const entries = this._listeners.get(type) ?? []; + if (entries.some((entry) => entry.listener === listener)) { + return; + } + entries.push({ listener, once: typeof options === 'object' && !!options?.once }); + this._listeners.set(type, entries); + } + + removeEventListener(type: string, listener: any) { + const entries = this._listeners?.get(type); + if (!entries) { + return; + } + const index = entries.findIndex((entry) => entry.listener === listener); + if (index !== -1) { + entries.splice(index, 1); + } + } + + dispatchEvent(event: any): boolean { + const type = event?.type; + if (typeof type !== 'string') { + throw new TypeError("Failed to execute 'dispatchEvent' on 'EventTarget': parameter 1 is not of type 'Event'."); + } + const call = (listener: any) => { + try { + if (typeof listener === 'function') { + listener.call(this, event); + } else { + listener?.handleEvent?.(event); + } + } catch (e) { + console.error(e); + } + }; + const handler = (this as any)[`on${type}`]; + if (typeof handler === 'function') { + call(handler); + } + const entries = this._listeners?.get(type); + if (entries) { + for (const entry of entries.slice()) { + if (entry.once) { + this.removeEventListener(type, entry.listener); + } + call(entry.listener); + } + } + return !event.defaultPrevented; + } + + private _ensureHost(): Canvas { + if (!this._host) { + const host = Canvas.createCustomView(); + host._resizeBitmap(this._width, this._height); + this._attachHost(host); + } + return this._host; + } + + private _attachHost(host: Canvas) { + this._host = host; + const ref = new WeakRef(this); + for (const type of HOST_EVENTS) { + host.on(type, (args: any) => ref.deref()?._forwardHostEvent(type, args)); + } + } + + private _forwardHostEvent(type: string, args: any) { + let defaultPrevented = false; + const event = { + type, + target: this, + currentTarget: this, + cancelable: true, + get defaultPrevented() { + return defaultPrevented; + }, + preventDefault() { + defaultPrevented = true; + args?.preventDefault?.(); + }, + stopPropagation() {}, + }; + this.dispatchEvent(event); + } + + /** Per spec a 2D context resets even when the size is unchanged. */ + private _resize() { + if (!this._host) { + return; + } + this._host._resizeBitmap(this._width, this._height); + if (this._contextId === '2d') { + this._context.reset(); + } + } + + private _toDataURL(type: string, quality: number): string { + if (this._contextId === 'webgpu') { + return this._context.__toDataURL(type, quality); + } + const native = this.native; + if (native) { + return native.__toDataURL(type, quality); + } + // No context: the bitmap is transparent black. + const scratch = Canvas.createCustomView(); + try { + scratch._resizeBitmap(this._width, this._height); + return (scratch._getContext('2d', { threaded: false }) as any)?.native?.__toDataURL(type, quality) ?? 'data:,'; + } finally { + try { + scratch.disposeNativeView(); + } catch (e) {} + } + } +} + +CanvasBase._offscreenFromPlaceholder = (canvas) => OffscreenCanvas._fromPlaceholder(canvas as Canvas); + +/** Only a 2D context whose canvas is an OffscreenCanvas matches; there is no separate class. */ +export class OffscreenCanvasRenderingContext2D { + private constructor() { + throw new TypeError('Illegal constructor'); + } + + static [Symbol.hasInstance](value: any) { + return value instanceof CanvasRenderingContext2D && value.canvas instanceof OffscreenCanvas; + } +} diff --git a/packages/canvas/README.md b/packages/canvas/README.md index 5660ad76e..f9ac304d3 100644 --- a/packages/canvas/README.md +++ b/packages/canvas/README.md @@ -82,6 +82,30 @@ export function canvasReady(args) { } ``` +### OffscreenCanvas + +A canvas with no view: draw with any context, then use it as an image (`drawImage`, +`createPattern`, `createImageBitmap`, `texImage2D`, `copyExternalImageToTexture`), hand its +frame over with `transferToImageBitmap()`, or encode it with `convertToBlob()`. + +```typescript +import { OffscreenCanvas } from '@nativescript/canvas'; + +const sprite = new OffscreenCanvas(64, 64); +const ctx = sprite.getContext('2d'); +ctx.fillStyle = 'tomato'; +ctx.fillRect(0, 0, 64, 64); + +// Later, on a visible canvas: +visible.getContext('2d').drawImage(sprite, 0, 0); + +const blob = await sprite.convertToBlob({ type: 'image/png' }); +``` + +`canvas.transferControlToOffscreen()` returns an OffscreenCanvas that draws into that canvas's +surface, so its frames show in the view. It runs on the UI thread like any canvas; it can't move to +a Worker yet. With the polyfill, `OffscreenCanvas` is also a global. + ## WebGPU _Note_ min ios support 11 | min android support 27 diff --git a/packages/canvas/WebGL/WebGLRenderingContext/index.ts b/packages/canvas/WebGL/WebGLRenderingContext/index.ts index bd064fbe2..d1b257f24 100644 --- a/packages/canvas/WebGL/WebGLRenderingContext/index.ts +++ b/packages/canvas/WebGL/WebGLRenderingContext/index.ts @@ -44,6 +44,7 @@ import { ImageBitmap } from '../../ImageBitmap'; import { Helpers } from '../../helpers'; import { NAPI_HOST, POINTER_CONTEXT_HOST } from '../../platform'; import { imageAssetFor } from '../../platform/index'; +import { isOffscreenCanvas } from '../../Canvas/utils'; enum ContextType { None, @@ -54,7 +55,37 @@ enum ContextType { let ctor; declare const NSCWebGLRenderingContext; +const FRAMEBUFFER = 0x8d40; +const READ_FRAMEBUFFER = 0x8ca8; +const DRAW_FRAMEBUFFER = 0x8ca9; +const FRAMEBUFFER_BINDING = 0x8ca6; +const READ_FRAMEBUFFER_BINDING = 0x8caa; +const PIXEL_PACK_BUFFER = 0x88eb; +const PIXEL_PACK_BUFFER_BINDING = 0x88ed; + export class WebGLRenderingContextBase extends WebGLRenderingCommon { + /** + * What this context last bound, which getParameter hands back as the web does. The native side + * reports bare names, which bind nothing when passed back, and on iOS names the view's own + * framebuffer when none is bound. + */ + protected _drawFramebuffer: WebGLFramebuffer | null = null; + protected _readFramebuffer: WebGLFramebuffer | null = null; + protected _pixelPackBuffer: WebGLBuffer | null = null; + + /** A binding getParameter answers from what was bound; `undefined` for any other pname. */ + protected _trackedBinding(pname: number): WebGLFramebuffer | WebGLBuffer | null | undefined { + switch (pname) { + case FRAMEBUFFER_BINDING: + return this._drawFramebuffer; + case READ_FRAMEBUFFER_BINDING: + return this._type === 'webgl2' ? this._readFramebuffer : undefined; + case PIXEL_PACK_BUFFER_BINDING: + return this._type === 'webgl2' ? this._pixelPackBuffer : undefined; + default: + return undefined; + } + } public static isDebug = false; public static filter: 'both' | 'error' | 'args' = 'both'; _context; @@ -138,12 +169,21 @@ export class WebGLRenderingContextBase extends WebGLRenderingCommon { bindBuffer(target: number, buffer: WebGLBuffer): void { const value = buffer ? buffer.native : 0; this.native.bindBuffer(target, value); + if (target === PIXEL_PACK_BUFFER) { + this._pixelPackBuffer = buffer ?? null; + } } @profile bindFramebuffer(target: number, framebuffer: WebGLFramebuffer): void { const value = framebuffer ? framebuffer.native : 0; this.native.bindFramebuffer(target, value); + if (target === FRAMEBUFFER || target === DRAW_FRAMEBUFFER) { + this._drawFramebuffer = framebuffer ?? null; + } + if (target === FRAMEBUFFER || target === READ_FRAMEBUFFER) { + this._readFramebuffer = framebuffer ?? null; + } } @profile @@ -299,12 +339,22 @@ export class WebGLRenderingContextBase extends WebGLRenderingCommon { deleteBuffer(buffer: WebGLBuffer): void { const value = buffer.native; this.native.deleteBuffer(value); + if (this._pixelPackBuffer === buffer) { + this._pixelPackBuffer = null; + } } @profile deleteFramebuffer(frameBuffer: WebGLFramebuffer): void { const value = frameBuffer.native; this.native.deleteFramebuffer(value); + // Deleting a bound framebuffer unbinds it. + if (this._drawFramebuffer === frameBuffer) { + this._drawFramebuffer = null; + } + if (this._readFramebuffer === frameBuffer) { + this._readFramebuffer = null; + } } @profile @@ -588,6 +638,10 @@ export class WebGLRenderingContextBase extends WebGLRenderingCommon { @profile getParameter(pname: number): number[] | number | WebGLBuffer | WebGLProgram | WebGLFramebuffer | WebGLRenderbuffer | WebGLTexture | Uint32Array | Int32Array | Float32Array | string | null { + const tracked = this._trackedBinding(pname); + if (tracked !== undefined) { + return tracked; + } const value = this.native.getParameter(pname); return this._handleGetParameter(pname, value); } @@ -847,7 +901,7 @@ export class WebGLRenderingContextBase extends WebGLRenderingCommon { this.native.texImage2D(target, level, internalformat, width, height, border, format, type ?? internalformat, pixels.native); } else if (pixels instanceof ImageBitmap) { this.native.texImage2D(target, level, internalformat, width, height, border, format, type ?? internalformat, pixels.native); - } else if (pixels instanceof Canvas) { + } else if ((pixels instanceof Canvas || isOffscreenCanvas(pixels))) { this.native.texImage2D(target, level, internalformat, width, height, border, format, type ?? internalformat, pixels.native); } else if (__ANDROID__ && pixels instanceof android.graphics.Bitmap) { (org).nativescript.canvas.NSCWebGLRenderingContext.texImage2D(java.lang.Long.valueOf(this._contextPtr), target, level, internalformat, width, height, pixels, this.native.__flipY); @@ -903,7 +957,7 @@ export class WebGLRenderingContextBase extends WebGLRenderingCommon { this.native.texImage2D(target, level, internalformat, width, height, border.native); } else if (border instanceof ImageBitmap) { this.native.texImage2D(target, level, internalformat, width, height, border.native); - } else if (border instanceof Canvas) { + } else if ((border instanceof Canvas || isOffscreenCanvas(border))) { this.native.texImage2D(target, level, internalformat, width, height, border.native); } else if (__ANDROID__ && border instanceof android.graphics.Bitmap) { (org).nativescript.canvas.NSCWebGLRenderingContext.texImage2D(java.lang.Long.valueOf(this._contextPtr), target, level, internalformat, width, height, border, this.native.__flipY); @@ -983,7 +1037,7 @@ export class WebGLRenderingContextBase extends WebGLRenderingCommon { } } else if (format instanceof ImageAsset) { this.native.texSubImage2D(target, level, xoffset, yoffset, width, height, format.native); - } else if (format instanceof Canvas) { + } else if ((format instanceof Canvas || isOffscreenCanvas(format))) { this.native.texSubImage2D(target, level, xoffset, yoffset, width, height, format.native); } else if (format instanceof ImageBitmap) { this.native.texSubImage2D(target, level, xoffset, yoffset, width, height, format.native); diff --git a/packages/canvas/WebGL2/WebGL2RenderingContext/index.ts b/packages/canvas/WebGL2/WebGL2RenderingContext/index.ts index 35c921015..6c038121b 100644 --- a/packages/canvas/WebGL2/WebGL2RenderingContext/index.ts +++ b/packages/canvas/WebGL2/WebGL2RenderingContext/index.ts @@ -22,6 +22,7 @@ import { ImageBitmap } from '../../ImageBitmap'; import { Helpers } from '../../helpers'; import { NAPI_HOST, POINTER_CONTEXT_HOST } from '../../platform'; import { imageAssetFor } from '../../platform/index'; +import { isOffscreenCanvas } from '../../Canvas/utils'; export class WebGL2RenderingContext extends WebGL2RenderingContextBase { static { @@ -264,6 +265,10 @@ export class WebGL2RenderingContext extends WebGL2RenderingContextBase { //@ts-ignore getParameter(pname: number): number[] | number | WebGLBuffer | WebGLProgram | WebGLFramebuffer | WebGLRenderbuffer | WebGLTexture | Uint32Array | Int32Array | Float32Array | string | null { + const tracked = this._trackedBinding(pname); + if (tracked !== undefined) { + return tracked; + } const value = this.native.getParameter(pname); switch (pname) { case this.COPY_READ_BUFFER_BINDING: @@ -414,7 +419,7 @@ export class WebGL2RenderingContext extends WebGL2RenderingContextBase { this.native.texImage3D(target, level, internalformat, width, height, depth, border, format, type, NAPI_HOST ? imageAssetFor(source)?.native : source.android); } else if (source instanceof ImageAsset) { this.native.texImage3D(target, level, internalformat, width, height, depth, border, format, type, source.native); - } else if (source instanceof Canvas) { + } else if ((source instanceof Canvas || isOffscreenCanvas(source))) { this.native.texImage3D(target, level, internalformat, width, height, depth, border, format, type, source.native); } else if (source instanceof ImageBitmap) { this.native.texImage3D(target, level, internalformat, width, height, depth, border, format, type, source.native); @@ -467,7 +472,7 @@ export class WebGL2RenderingContext extends WebGL2RenderingContextBase { this.native.texSubImage3D(target, level, xoffset, yoffset, zoffset, width, height, depth, format, type, NAPI_HOST ? imageAssetFor(srcData)?.native : srcData.android); } else if (srcData instanceof ImageAsset) { this.native.texSubImage3D(target, level, xoffset, yoffset, zoffset, width, height, depth, format, type, srcData.native); - } else if (srcData instanceof Canvas) { + } else if ((srcData instanceof Canvas || isOffscreenCanvas(srcData))) { this.native.texSubImage3D(target, level, xoffset, yoffset, zoffset, width, height, depth, format, type, srcData.native); } else if (srcData instanceof ImageBitmap) { this.native.texSubImage3D(target, level, xoffset, yoffset, zoffset, width, height, depth, format, type, srcData.native); diff --git a/packages/canvas/WebGPU/GPUQueue.ts b/packages/canvas/WebGPU/GPUQueue.ts index 14764c389..718c92347 100644 --- a/packages/canvas/WebGPU/GPUQueue.ts +++ b/packages/canvas/WebGPU/GPUQueue.ts @@ -5,6 +5,7 @@ import { GPUBuffer } from './GPUBuffer'; import { GPUCommandBuffer } from './GPUCommandBuffer'; import { GPUImageCopyExternalImage, GPUImageCopyTexture, GPUImageCopyTextureTagged, GPUImageDataLayout } from './Interfaces'; import { GPUExtent3D } from './Types'; +import { isOffscreenCanvas } from '../Canvas/utils'; export class GPUQueue { [native_]: any; @@ -96,7 +97,7 @@ export class GPUQueue { } else if (source.source?._type === '2d' || source.source?._type?.indexOf('webgl') > -1 || source.source?._type === 'webgpu') { _keepAlive = source.source; src.source = (source.source as any).native; - } else if (source.source instanceof Canvas) { + } else if ((source.source instanceof Canvas || isOffscreenCanvas(source.source))) { _keepAlive = source.source; src.source = source.source.native; } else if (typeof source.source.tagName === 'string' && source.source.tagName === 'CANVAS') { diff --git a/packages/canvas/index.ts b/packages/canvas/index.ts index d8926e274..4fdc85c09 100644 --- a/packages/canvas/index.ts +++ b/packages/canvas/index.ts @@ -4,6 +4,7 @@ import { TextEncoder } from './TextEncoder'; import { TextDecoder } from './TextDecoder'; import { ImageBitmap } from './ImageBitmap'; import { ImageBitmapRenderingContext } from './ImageBitmapRenderingContext'; +import { OffscreenCanvas, OffscreenCanvasRenderingContext2D } from './OffscreenCanvas'; import { ImageAsset } from './ImageAsset'; import { CanvasPattern, CanvasGradient, Path2D, ImageData, DOMMatrix } from './Canvas2D'; @@ -16,6 +17,7 @@ import { GPUBufferUsage, GPUMapMode, GPUShaderStage, GPUTextureUsage } from './W export * from './Canvas2D'; export * from './ImageBitmap'; export * from './ImageBitmapRenderingContext'; +export * from './OffscreenCanvas'; export * from './ImageAsset'; export * from './TextEncoder'; export * from './TextDecoder'; @@ -97,6 +99,18 @@ Object.defineProperty(global, 'FontFace', { writable: true, }); +Object.defineProperty(global, 'OffscreenCanvas', { + value: OffscreenCanvas, + configurable: true, + writable: true, +}); + +Object.defineProperty(global, 'OffscreenCanvasRenderingContext2D', { + value: OffscreenCanvasRenderingContext2D, + configurable: true, + writable: true, +}); + Object.defineProperty(global, 'CanvasRenderingContext2D', { value: CanvasRenderingContext2D, configurable: true, diff --git a/packages/canvas/platforms/ios/src/cpp/Helpers.h b/packages/canvas/platforms/ios/src/cpp/Helpers.h index 98e41fd38..8f7a72279 100644 --- a/packages/canvas/platforms/ios/src/cpp/Helpers.h +++ b/packages/canvas/platforms/ios/src/cpp/Helpers.h @@ -129,7 +129,8 @@ CopyFastOneByteStringToScratch(const v8::FastOneByteString &str, std::vector &value) { if (value.IsEmpty()) { return {}; @@ -148,10 +149,10 @@ ConvertFromV8StringView(v8::Isolate *isolate, const v8::Local &value) return {}; } - return {*result}; + return {val, static_cast(result.length())}; } -inline static std::string_view +inline static std::string ConvertFromV8StringViewValue(v8::Isolate *isolate, v8::Local value) { if (value.IsEmpty()) { return {}; @@ -170,7 +171,7 @@ ConvertFromV8StringViewValue(v8::Isolate *isolate, v8::Local value) { return {}; } - return {*result}; + return {val, static_cast(result.length())}; } diff --git a/packages/canvas/platforms/windows/arm64/canvasnative.node b/packages/canvas/platforms/windows/arm64/canvasnative.node index 59cccb6ce..0f87ef36f 100644 Binary files a/packages/canvas/platforms/windows/arm64/canvasnative.node and b/packages/canvas/platforms/windows/arm64/canvasnative.node differ diff --git a/packages/canvas/platforms/windows/x64/canvasnative.node b/packages/canvas/platforms/windows/x64/canvasnative.node index ed0811ca2..1b72231a1 100644 Binary files a/packages/canvas/platforms/windows/x64/canvasnative.node and b/packages/canvas/platforms/windows/x64/canvasnative.node differ diff --git a/tools/demo/canvas/spec/index.ts b/tools/demo/canvas/spec/index.ts index ac29b60fc..f998f84da 100644 --- a/tools/demo/canvas/spec/index.ts +++ b/tools/demo/canvas/spec/index.ts @@ -6,7 +6,7 @@ * adb logcat -d | grep 'SPEC|' * * `--es suite ` narrows the run: a group (`2d`, `webgl`, `webgpu`, - * `imagebitmap`, `bitmaprenderer`, `lifecycle`, `audio` and `media` on Windows) or a single suite (`2d.path2d`). + * `imagebitmap`, `bitmaprenderer`, `lifecycle`, `offscreen`, `audio` and `media` on Windows) or a single suite (`2d.path2d`). * `contextlost` (Windows) runs on its own: it removes the GPU device. */ @@ -21,6 +21,7 @@ import { registerContextLossSpec } from './contextloss'; import { registerAudioSpec } from './audio'; import { registerMediaSpec } from './media'; import { registerLifecycleSpec } from './lifecycle'; +import { registerOffscreenCanvasSpec } from './offscreen'; declare const __WINDOWS__: boolean; @@ -38,6 +39,7 @@ export async function runSpecTests(group: SpecGroup = 'all') { registerCanvasSourceSpec(); registerScalingSpec(); registerLifecycleSpec(); + registerOffscreenCanvasSpec(); if (typeof __WINDOWS__ !== 'undefined' && __WINDOWS__) { registerAudioSpec(); registerMediaSpec(); diff --git a/tools/demo/canvas/spec/offscreen.ts b/tools/demo/canvas/spec/offscreen.ts new file mode 100644 index 000000000..808786dfd --- /dev/null +++ b/tools/demo/canvas/spec/offscreen.ts @@ -0,0 +1,356 @@ +/** + * OffscreenCanvas against the HTML spec: its own API, each context it hands out, its use as an + * image source by other contexts, and `canvas.transferControlToOffscreen()`. + * + * Where the result is pixels, the tests read them back rather than only checking that a call did + * not throw. + */ + +import { OffscreenCanvas, OffscreenCanvasRenderingContext2D } from '@nativescript/canvas'; +import { suite, test, skip, ok, equal, throws, rejects, makeCanvas, make2D, pixelAt } from './harness'; + +declare const navigator: any; + +const RED: [number, number, number, number] = [255, 0, 0, 255]; +const GREEN: [number, number, number, number] = [0, 255, 0, 255]; +const BLUE: [number, number, number, number] = [0, 0, 255, 255]; +const CLEAR: [number, number, number, number] = [0, 0, 0, 0]; + +function pixelNear(actual: ArrayLike, expected: ArrayLike, tolerance = 8) { + for (let i = 0; i < 4; i++) { + if (Math.abs(actual[i] - expected[i]) > tolerance) { + return false; + } + } + return true; +} + +function describePixel(pixel: ArrayLike) { + return `[${pixel[0]}, ${pixel[1]}, ${pixel[2]}, ${pixel[3]}]`; +} + +function expectPixel(ctx: any, x: number, y: number, expected: ArrayLike) { + const pixel = pixelAt(ctx, x, y); + ok(pixelNear(pixel, expected), `expected ${describePixel(expected)}, got ${describePixel(pixel)}`); +} + +/** An OffscreenCanvas whose 2d context is filled with `colour`. */ +function filled2D(width: number, height: number, colour: string) { + const canvas = new OffscreenCanvas(width, height); + const ctx = canvas.getContext('2d') as any; + ok(ctx, 'no 2d context'); + ctx.fillStyle = colour; + ctx.fillRect(0, 0, width, height); + return { canvas, ctx }; +} + +/** An OffscreenCanvas whose WebGL context is cleared to green. */ +function clearedWebGL(size: number, version: 'webgl' | 'webgl2' = 'webgl') { + const canvas = new OffscreenCanvas(size, size); + const gl = canvas.getContext(version) as any; + ok(gl, `no ${version} context`); + gl.viewport(0, 0, size, size); + gl.clearColor(0, 1, 0, 1); + gl.clear(gl.COLOR_BUFFER_BIT); + return { canvas, gl }; +} + +function readGL(gl: any, x = 0, y = 0) { + const pixel = new Uint8Array(4); + gl.readPixels(x, y, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel); + return pixel; +} + +export function registerOffscreenCanvasSpec() { + suite('offscreen.api', () => { + test('is a global', () => { + equal(typeof (global as any).OffscreenCanvas, 'function'); + }); + + test('keeps its size', () => { + const canvas = new OffscreenCanvas(64, 32); + equal(canvas.width, 64); + equal(canvas.height, 32); + }); + + test('toStringTag is OffscreenCanvas', () => { + equal(Object.prototype.toString.call(new OffscreenCanvas(1, 1)), '[object OffscreenCanvas]'); + }); + + test('the constructor needs two arguments', () => { + throws(() => new (OffscreenCanvas as any)(4), 'TypeError'); + }); + + test('a negative size is a TypeError', () => { + throws(() => new OffscreenCanvas(-1, 4), 'TypeError'); + }); + + test('an unknown context id is a TypeError', () => { + throws(() => new OffscreenCanvas(4, 4).getContext('nope' as any), 'TypeError'); + }); + + test('getContext returns the same context for the same id', () => { + const canvas = new OffscreenCanvas(4, 4); + const ctx = canvas.getContext('2d'); + ok(ctx, 'no 2d context'); + equal(canvas.getContext('2d'), ctx); + }); + + test('getContext returns null for another id once it has a context', () => { + const canvas = new OffscreenCanvas(4, 4); + canvas.getContext('2d'); + equal(canvas.getContext('webgl'), null); + }); + + test('transferToImageBitmap without a context is an InvalidStateError', () => { + throws(() => new OffscreenCanvas(4, 4).transferToImageBitmap(), 'InvalidStateError'); + }); + + test('convertToBlob with a zero size is an IndexSizeError', async () => { + const error = await rejects(new OffscreenCanvas(0, 4).convertToBlob()); + equal(error?.name, 'IndexSizeError'); + }); + }); + + suite('offscreen.2d', () => { + test('the context points back at the OffscreenCanvas', () => { + const canvas = new OffscreenCanvas(4, 4); + equal((canvas.getContext('2d') as any).canvas, canvas); + }); + + test('the context is an OffscreenCanvasRenderingContext2D', () => { + ok(new OffscreenCanvas(4, 4).getContext('2d') instanceof (OffscreenCanvasRenderingContext2D as any)); + }); + + test('a canvas 2d context is not an OffscreenCanvasRenderingContext2D', () => { + const { ctx } = make2D(4, 4); + ok(!(ctx instanceof (OffscreenCanvasRenderingContext2D as any))); + }); + + test('draws', () => { + const { ctx } = filled2D(16, 16, 'red'); + expectPixel(ctx, 8, 8, RED); + }); + + test('setting the size resets the context', () => { + const { canvas, ctx } = filled2D(16, 16, 'red'); + canvas.width = 8; + equal(canvas.width, 8); + equal(ctx.getImageData(0, 0, 8, 16).width, 8); + expectPixel(ctx, 4, 4, CLEAR); + }); + + test('transferToImageBitmap hands over the frame and clears the canvas', () => { + const { canvas, ctx } = filled2D(16, 8, 'red'); + const bitmap = canvas.transferToImageBitmap(); + equal(bitmap.width, 16); + equal(bitmap.height, 8); + expectPixel(ctx, 4, 4, CLEAR); + + const { ctx: target } = make2D(16, 8); + target.drawImage(bitmap, 0, 0); + expectPixel(target, 4, 4, RED); + }); + + test('convertToBlob encodes a png by default', async () => { + const { canvas } = filled2D(8, 8, 'red'); + const blob = await canvas.convertToBlob(); + ok(blob.size > 0, 'empty blob'); + equal(blob.type, 'image/png'); + }); + + test('convertToBlob encodes a jpeg', async () => { + const { canvas } = filled2D(8, 8, 'red'); + const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.8 }); + ok(blob.size > 0, 'empty blob'); + equal(blob.type, 'image/jpeg'); + }); + + test('bitmaprenderer shows a transferred bitmap', () => { + const { canvas } = filled2D(8, 8, 'red'); + const bitmap = canvas.transferToImageBitmap(); + const renderer = new OffscreenCanvas(8, 8).getContext('bitmaprenderer') as any; + ok(renderer, 'no bitmaprenderer context'); + renderer.transferFromImageBitmap(bitmap); + const { ctx: target } = make2D(8, 8); + target.drawImage(renderer.canvas, 0, 0); + expectPixel(target, 4, 4, RED); + }); + }); + + suite('offscreen.source', () => { + test('drawImage takes an OffscreenCanvas', () => { + const { canvas } = filled2D(16, 16, 'red'); + const { ctx } = make2D(16, 16); + ctx.drawImage(canvas, 0, 0); + expectPixel(ctx, 8, 8, RED); + }); + + test('createPattern takes an OffscreenCanvas', () => { + const { canvas } = filled2D(4, 4, 'red'); + const { ctx } = make2D(16, 16); + const pattern = ctx.createPattern(canvas, 'repeat'); + ok(pattern, 'no pattern'); + ctx.fillStyle = pattern; + ctx.fillRect(0, 0, 16, 16); + expectPixel(ctx, 10, 10, RED); + }); + + test('createImageBitmap takes an OffscreenCanvas', async () => { + const { canvas } = filled2D(16, 8, 'red'); + const bitmap = await (global as any).createImageBitmap(canvas); + equal(bitmap.width, 16); + equal(bitmap.height, 8); + const { ctx } = make2D(16, 8); + ctx.drawImage(bitmap, 0, 0); + expectPixel(ctx, 4, 4, RED); + }); + + test('drawImage takes a webgl OffscreenCanvas', () => { + const { canvas } = clearedWebGL(16); + const { ctx } = make2D(16, 16); + ctx.drawImage(canvas, 0, 0); + expectPixel(ctx, 8, 8, GREEN); + }); + + test('texImage2D takes an OffscreenCanvas', () => { + const { canvas: source } = filled2D(4, 4, 'red'); + const target = makeCanvas(4, 4); + const gl = target.getContext('webgl') as any; + ok(gl, 'no webgl context'); + const texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source); + equal(gl.getError(), 0); + + // Read the texture back through a framebuffer. + const framebuffer = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0); + equal(gl.checkFramebufferStatus(gl.FRAMEBUFFER), gl.FRAMEBUFFER_COMPLETE); + const pixel = readGL(gl, 1, 1); + ok(pixelNear(pixel, RED), `expected red, got ${describePixel(pixel)}`); + }); + }); + + suite('offscreen.webgl', () => { + for (const version of ['webgl', 'webgl2'] as const) { + test(`${version} draws`, () => { + const { gl } = clearedWebGL(8, version); + const pixel = readGL(gl); + ok(pixelNear(pixel, GREEN), `expected green, got ${describePixel(pixel)}`); + }); + + test(`${version} transferToImageBitmap hands over the frame and clears the canvas`, () => { + const { canvas, gl } = clearedWebGL(8, version); + const bitmap = canvas.transferToImageBitmap(); + equal(bitmap.width, 8); + const { ctx } = make2D(8, 8); + ctx.drawImage(bitmap, 0, 0); + expectPixel(ctx, 4, 4, GREEN); + const pixel = readGL(gl); + ok(pixelNear(pixel, CLEAR), `expected cleared, got ${describePixel(pixel)}`); + }); + } + + test('transferToImageBitmap leaves the app its own framebuffer bound', () => { + const { canvas, gl } = clearedWebGL(8); + const texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 8, 8, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + const framebuffer = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0); + canvas.transferToImageBitmap(); + // Still drawing into the texture: a clear lands there, not in the drawing buffer. + gl.clearColor(0, 0, 1, 1); + gl.clear(gl.COLOR_BUFFER_BIT); + const pixel = readGL(gl); + ok(pixelNear(pixel, BLUE), `expected the texture's blue, got ${describePixel(pixel)}`); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + const drawingBuffer = readGL(gl); + ok(pixelNear(drawingBuffer, CLEAR), `the drawing buffer should stay cleared, got ${describePixel(drawingBuffer)}`); + }); + }); + + suite('offscreen.webgpu', () => { + if (!navigator?.gpu) { + skip('draws', 'navigator.gpu is missing'); + return; + } + test('draws and transfers', async () => { + const adapter = await navigator.gpu.requestAdapter(); + ok(adapter, 'no adapter'); + const device = await adapter.requestDevice(); + const canvas = new OffscreenCanvas(8, 8); + const context = canvas.getContext('webgpu') as any; + ok(context, 'no webgpu context'); + const format = navigator.gpu.getPreferredCanvasFormat(); + context.configure({ device, format, alphaMode: 'premultiplied' }); + const encoder = device.createCommandEncoder(); + const pass = encoder.beginRenderPass({ + colorAttachments: [{ view: context.getCurrentTexture().createView(), clearValue: { r: 0, g: 0, b: 1, a: 1 }, loadOp: 'clear', storeOp: 'store' }], + }); + pass.end(); + device.queue.submit([encoder.finish()]); + const bitmap = canvas.transferToImageBitmap(); + equal(bitmap.width, 8); + const { ctx } = make2D(8, 8); + ctx.drawImage(bitmap, 0, 0); + expectPixel(ctx, 4, 4, BLUE); + }); + }); + + suite('offscreen.transfer', () => { + test('takes the canvas size', () => { + const canvas = makeCanvas(20, 10) as any; + const offscreen = canvas.transferControlToOffscreen(); + equal(offscreen.width, 20); + equal(offscreen.height, 10); + }); + + test('the canvas can no longer getContext', () => { + const canvas = makeCanvas(4, 4) as any; + canvas.transferControlToOffscreen(); + throws(() => canvas.getContext('2d'), 'InvalidStateError'); + }); + + test('the canvas can no longer be resized', () => { + const canvas = makeCanvas(4, 4) as any; + canvas.transferControlToOffscreen(); + throws(() => (canvas.width = 5), 'InvalidStateError'); + }); + + test('a canvas transfers once', () => { + const canvas = makeCanvas(4, 4) as any; + canvas.transferControlToOffscreen(); + throws(() => canvas.transferControlToOffscreen(), 'InvalidStateError'); + }); + + test('a canvas with a context cannot transfer', () => { + const { canvas } = make2D(4, 4); + throws(() => (canvas as any).transferControlToOffscreen(), 'InvalidStateError'); + }); + + test('the OffscreenCanvas draws into the canvas surface', () => { + const canvas = makeCanvas(8, 8) as any; + const offscreen = canvas.transferControlToOffscreen(); + const ctx = offscreen.getContext('2d'); + ctx.fillStyle = 'blue'; + ctx.fillRect(0, 0, 8, 8); + expectPixel(ctx, 4, 4, BLUE); + // The canvas shows what the OffscreenCanvas drew: it is an image of the same frame. + const { ctx: target } = make2D(8, 8); + target.drawImage(offscreen, 0, 0); + expectPixel(target, 4, 4, BLUE); + }); + + test('resizing the OffscreenCanvas resizes the canvas', () => { + const canvas = makeCanvas(8, 8) as any; + const offscreen = canvas.transferControlToOffscreen(); + offscreen.getContext('2d'); + offscreen.width = 30; + equal(canvas.width, 30); + }); + }); +} diff --git a/tools/demo/canvas/spec/webgl.ts b/tools/demo/canvas/spec/webgl.ts index 7b5b29d43..66ea6e21d 100644 --- a/tools/demo/canvas/spec/webgl.ts +++ b/tools/demo/canvas/spec/webgl.ts @@ -203,6 +203,48 @@ function registerCommon(version: 1 | 2) { }); suite(`${label}.objects`, () => { + test('getParameter(FRAMEBUFFER_BINDING) returns the bound framebuffer', () => { + const { gl } = makeGL(version); + equal(gl.getParameter(gl.FRAMEBUFFER_BINDING), null, 'nothing bound yet'); + const framebuffer = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); + equal(gl.getParameter(gl.FRAMEBUFFER_BINDING), framebuffer); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + equal(gl.getParameter(gl.FRAMEBUFFER_BINDING), null, 'after unbinding'); + }); + + test('a framebuffer from getParameter binds again', () => { + const { gl } = makeGL(version); + const texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 4, 4, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + const framebuffer = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0); + // Save, draw to the canvas, restore: the usual state-preserving pattern. + const saved = gl.getParameter(gl.FRAMEBUFFER_BINDING); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.bindFramebuffer(gl.FRAMEBUFFER, saved); + gl.clearColor(1, 0, 0, 1); + gl.clear(gl.COLOR_BUFFER_BIT); + const pixel = new Uint8Array(4); + gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel); + arrayEqual(pixel, [255, 0, 0, 255], 'the clear should land in the texture'); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel); + arrayEqual(pixel, [0, 0, 0, 0], 'the drawing buffer should be untouched'); + }); + + test('deleting the bound framebuffer unbinds it', () => { + const { gl } = makeGL(version); + const framebuffer = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); + gl.deleteFramebuffer(framebuffer); + equal(gl.getParameter(gl.FRAMEBUFFER_BINDING), null); + }); + test('createBuffer / isBuffer / deleteBuffer', () => { const { gl } = makeGL(version); const buffer = gl.createBuffer(); @@ -480,6 +522,19 @@ export function registerWebGLSpec() { registerCommon(2); suite('webgl2.only', () => { + test('the read and draw framebuffer bindings are separate', () => { + const { gl } = makeGL(2); + const read = gl.createFramebuffer(); + const draw = gl.createFramebuffer(); + gl.bindFramebuffer(gl.READ_FRAMEBUFFER, read); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, draw); + equal(gl.getParameter(gl.READ_FRAMEBUFFER_BINDING), read); + equal(gl.getParameter(gl.DRAW_FRAMEBUFFER_BINDING), draw); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + equal(gl.getParameter(gl.READ_FRAMEBUFFER_BINDING), null, 'FRAMEBUFFER sets both'); + equal(gl.getParameter(gl.DRAW_FRAMEBUFFER_BINDING), null, 'FRAMEBUFFER sets both'); + }); + test('WebGL2 reports a WebGL 2.0 version string', () => { const { gl } = makeGL(2); const version = gl.getParameter(gl.VERSION) as string;