Skip to content

Commit b3ca855

Browse files
committed
test(canvas): OffscreenCanvas spec suite
offscreen.api, .2d, .source, .webgl, .webgpu and .transfer: the constructor and getContext rules, each context, transferToImageBitmap and convertToBlob, use as an image source by 2d, WebGL and createImageBitmap, and transferControlToOffscreen. Run with --suite offscreen.
1 parent 28771b4 commit b3ca855

2 files changed

Lines changed: 359 additions & 1 deletion

File tree

‎tools/demo/canvas/spec/index.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* adb logcat -d | grep 'SPEC|'
77
*
88
* `--es suite <prefix>` narrows the run: a group (`2d`, `webgl`, `webgpu`,
9-
* `imagebitmap`, `bitmaprenderer`, `lifecycle`, `audio` and `media` on Windows) or a single suite (`2d.path2d`).
9+
* `imagebitmap`, `bitmaprenderer`, `lifecycle`, `offscreen`, `audio` and `media` on Windows) or a single suite (`2d.path2d`).
1010
* `contextlost` (Windows) runs on its own: it removes the GPU device.
1111
*/
1212

@@ -21,6 +21,7 @@ import { registerContextLossSpec } from './contextloss';
2121
import { registerAudioSpec } from './audio';
2222
import { registerMediaSpec } from './media';
2323
import { registerLifecycleSpec } from './lifecycle';
24+
import { registerOffscreenCanvasSpec } from './offscreen';
2425

2526
declare const __WINDOWS__: boolean;
2627

@@ -38,6 +39,7 @@ export async function runSpecTests(group: SpecGroup = 'all') {
3839
registerCanvasSourceSpec();
3940
registerScalingSpec();
4041
registerLifecycleSpec();
42+
registerOffscreenCanvasSpec();
4143
if (typeof __WINDOWS__ !== 'undefined' && __WINDOWS__) {
4244
registerAudioSpec();
4345
registerMediaSpec();
Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
/**
2+
* OffscreenCanvas against the HTML spec: its own API, each context it hands out, its use as an
3+
* image source by other contexts, and `canvas.transferControlToOffscreen()`.
4+
*
5+
* Where the result is pixels, the tests read them back rather than only checking that a call did
6+
* not throw.
7+
*/
8+
9+
import { OffscreenCanvas, OffscreenCanvasRenderingContext2D } from '@nativescript/canvas';
10+
import { suite, test, skip, ok, equal, throws, rejects, makeCanvas, make2D, pixelAt } from './harness';
11+
12+
declare const navigator: any;
13+
14+
const RED: [number, number, number, number] = [255, 0, 0, 255];
15+
const GREEN: [number, number, number, number] = [0, 255, 0, 255];
16+
const BLUE: [number, number, number, number] = [0, 0, 255, 255];
17+
const CLEAR: [number, number, number, number] = [0, 0, 0, 0];
18+
19+
function pixelNear(actual: ArrayLike<number>, expected: ArrayLike<number>, tolerance = 8) {
20+
for (let i = 0; i < 4; i++) {
21+
if (Math.abs(actual[i] - expected[i]) > tolerance) {
22+
return false;
23+
}
24+
}
25+
return true;
26+
}
27+
28+
function describePixel(pixel: ArrayLike<number>) {
29+
return `[${pixel[0]}, ${pixel[1]}, ${pixel[2]}, ${pixel[3]}]`;
30+
}
31+
32+
function expectPixel(ctx: any, x: number, y: number, expected: ArrayLike<number>) {
33+
const pixel = pixelAt(ctx, x, y);
34+
ok(pixelNear(pixel, expected), `expected ${describePixel(expected)}, got ${describePixel(pixel)}`);
35+
}
36+
37+
/** An OffscreenCanvas whose 2d context is filled with `colour`. */
38+
function filled2D(width: number, height: number, colour: string) {
39+
const canvas = new OffscreenCanvas(width, height);
40+
const ctx = canvas.getContext('2d') as any;
41+
ok(ctx, 'no 2d context');
42+
ctx.fillStyle = colour;
43+
ctx.fillRect(0, 0, width, height);
44+
return { canvas, ctx };
45+
}
46+
47+
/** An OffscreenCanvas whose WebGL context is cleared to green. */
48+
function clearedWebGL(size: number, version: 'webgl' | 'webgl2' = 'webgl') {
49+
const canvas = new OffscreenCanvas(size, size);
50+
const gl = canvas.getContext(version) as any;
51+
ok(gl, `no ${version} context`);
52+
gl.viewport(0, 0, size, size);
53+
gl.clearColor(0, 1, 0, 1);
54+
gl.clear(gl.COLOR_BUFFER_BIT);
55+
return { canvas, gl };
56+
}
57+
58+
function readGL(gl: any, x = 0, y = 0) {
59+
const pixel = new Uint8Array(4);
60+
gl.readPixels(x, y, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel);
61+
return pixel;
62+
}
63+
64+
export function registerOffscreenCanvasSpec() {
65+
suite('offscreen.api', () => {
66+
test('is a global', () => {
67+
equal(typeof (global as any).OffscreenCanvas, 'function');
68+
});
69+
70+
test('keeps its size', () => {
71+
const canvas = new OffscreenCanvas(64, 32);
72+
equal(canvas.width, 64);
73+
equal(canvas.height, 32);
74+
});
75+
76+
test('toStringTag is OffscreenCanvas', () => {
77+
equal(Object.prototype.toString.call(new OffscreenCanvas(1, 1)), '[object OffscreenCanvas]');
78+
});
79+
80+
test('the constructor needs two arguments', () => {
81+
throws(() => new (OffscreenCanvas as any)(4), 'TypeError');
82+
});
83+
84+
test('a negative size is a TypeError', () => {
85+
throws(() => new OffscreenCanvas(-1, 4), 'TypeError');
86+
});
87+
88+
test('an unknown context id is a TypeError', () => {
89+
throws(() => new OffscreenCanvas(4, 4).getContext('nope' as any), 'TypeError');
90+
});
91+
92+
test('getContext returns the same context for the same id', () => {
93+
const canvas = new OffscreenCanvas(4, 4);
94+
const ctx = canvas.getContext('2d');
95+
ok(ctx, 'no 2d context');
96+
equal(canvas.getContext('2d'), ctx);
97+
});
98+
99+
test('getContext returns null for another id once it has a context', () => {
100+
const canvas = new OffscreenCanvas(4, 4);
101+
canvas.getContext('2d');
102+
equal(canvas.getContext('webgl'), null);
103+
});
104+
105+
test('transferToImageBitmap without a context is an InvalidStateError', () => {
106+
throws(() => new OffscreenCanvas(4, 4).transferToImageBitmap(), 'InvalidStateError');
107+
});
108+
109+
test('convertToBlob with a zero size is an IndexSizeError', async () => {
110+
const error = await rejects(new OffscreenCanvas(0, 4).convertToBlob());
111+
equal(error?.name, 'IndexSizeError');
112+
});
113+
});
114+
115+
suite('offscreen.2d', () => {
116+
test('the context points back at the OffscreenCanvas', () => {
117+
const canvas = new OffscreenCanvas(4, 4);
118+
equal((canvas.getContext('2d') as any).canvas, canvas);
119+
});
120+
121+
test('the context is an OffscreenCanvasRenderingContext2D', () => {
122+
ok(new OffscreenCanvas(4, 4).getContext('2d') instanceof (OffscreenCanvasRenderingContext2D as any));
123+
});
124+
125+
test('a canvas 2d context is not an OffscreenCanvasRenderingContext2D', () => {
126+
const { ctx } = make2D(4, 4);
127+
ok(!(ctx instanceof (OffscreenCanvasRenderingContext2D as any)));
128+
});
129+
130+
test('draws', () => {
131+
const { ctx } = filled2D(16, 16, 'red');
132+
expectPixel(ctx, 8, 8, RED);
133+
});
134+
135+
test('setting the size resets the context', () => {
136+
const { canvas, ctx } = filled2D(16, 16, 'red');
137+
canvas.width = 8;
138+
equal(canvas.width, 8);
139+
equal(ctx.getImageData(0, 0, 8, 16).width, 8);
140+
expectPixel(ctx, 4, 4, CLEAR);
141+
});
142+
143+
test('transferToImageBitmap hands over the frame and clears the canvas', () => {
144+
const { canvas, ctx } = filled2D(16, 8, 'red');
145+
const bitmap = canvas.transferToImageBitmap();
146+
equal(bitmap.width, 16);
147+
equal(bitmap.height, 8);
148+
expectPixel(ctx, 4, 4, CLEAR);
149+
150+
const { ctx: target } = make2D(16, 8);
151+
target.drawImage(bitmap, 0, 0);
152+
expectPixel(target, 4, 4, RED);
153+
});
154+
155+
test('convertToBlob encodes a png by default', async () => {
156+
const { canvas } = filled2D(8, 8, 'red');
157+
const blob = await canvas.convertToBlob();
158+
ok(blob.size > 0, 'empty blob');
159+
equal(blob.type, 'image/png');
160+
});
161+
162+
test('convertToBlob encodes a jpeg', async () => {
163+
const { canvas } = filled2D(8, 8, 'red');
164+
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.8 });
165+
ok(blob.size > 0, 'empty blob');
166+
equal(blob.type, 'image/jpeg');
167+
});
168+
169+
test('bitmaprenderer shows a transferred bitmap', () => {
170+
const { canvas } = filled2D(8, 8, 'red');
171+
const bitmap = canvas.transferToImageBitmap();
172+
const renderer = new OffscreenCanvas(8, 8).getContext('bitmaprenderer') as any;
173+
ok(renderer, 'no bitmaprenderer context');
174+
renderer.transferFromImageBitmap(bitmap);
175+
const { ctx: target } = make2D(8, 8);
176+
target.drawImage(renderer.canvas, 0, 0);
177+
expectPixel(target, 4, 4, RED);
178+
});
179+
});
180+
181+
suite('offscreen.source', () => {
182+
test('drawImage takes an OffscreenCanvas', () => {
183+
const { canvas } = filled2D(16, 16, 'red');
184+
const { ctx } = make2D(16, 16);
185+
ctx.drawImage(canvas, 0, 0);
186+
expectPixel(ctx, 8, 8, RED);
187+
});
188+
189+
test('createPattern takes an OffscreenCanvas', () => {
190+
const { canvas } = filled2D(4, 4, 'red');
191+
const { ctx } = make2D(16, 16);
192+
const pattern = ctx.createPattern(canvas, 'repeat');
193+
ok(pattern, 'no pattern');
194+
ctx.fillStyle = pattern;
195+
ctx.fillRect(0, 0, 16, 16);
196+
expectPixel(ctx, 10, 10, RED);
197+
});
198+
199+
test('createImageBitmap takes an OffscreenCanvas', async () => {
200+
const { canvas } = filled2D(16, 8, 'red');
201+
const bitmap = await (global as any).createImageBitmap(canvas);
202+
equal(bitmap.width, 16);
203+
equal(bitmap.height, 8);
204+
const { ctx } = make2D(16, 8);
205+
ctx.drawImage(bitmap, 0, 0);
206+
expectPixel(ctx, 4, 4, RED);
207+
});
208+
209+
test('drawImage takes a webgl OffscreenCanvas', () => {
210+
const { canvas } = clearedWebGL(16);
211+
const { ctx } = make2D(16, 16);
212+
ctx.drawImage(canvas, 0, 0);
213+
expectPixel(ctx, 8, 8, GREEN);
214+
});
215+
216+
test('texImage2D takes an OffscreenCanvas', () => {
217+
const { canvas: source } = filled2D(4, 4, 'red');
218+
const target = makeCanvas(4, 4);
219+
const gl = target.getContext('webgl') as any;
220+
ok(gl, 'no webgl context');
221+
const texture = gl.createTexture();
222+
gl.bindTexture(gl.TEXTURE_2D, texture);
223+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
224+
equal(gl.getError(), 0);
225+
226+
// Read the texture back through a framebuffer.
227+
const framebuffer = gl.createFramebuffer();
228+
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
229+
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
230+
equal(gl.checkFramebufferStatus(gl.FRAMEBUFFER), gl.FRAMEBUFFER_COMPLETE);
231+
const pixel = readGL(gl, 1, 1);
232+
ok(pixelNear(pixel, RED), `expected red, got ${describePixel(pixel)}`);
233+
});
234+
});
235+
236+
suite('offscreen.webgl', () => {
237+
for (const version of ['webgl', 'webgl2'] as const) {
238+
test(`${version} draws`, () => {
239+
const { gl } = clearedWebGL(8, version);
240+
const pixel = readGL(gl);
241+
ok(pixelNear(pixel, GREEN), `expected green, got ${describePixel(pixel)}`);
242+
});
243+
244+
test(`${version} transferToImageBitmap hands over the frame and clears the canvas`, () => {
245+
const { canvas, gl } = clearedWebGL(8, version);
246+
const bitmap = canvas.transferToImageBitmap();
247+
equal(bitmap.width, 8);
248+
const { ctx } = make2D(8, 8);
249+
ctx.drawImage(bitmap, 0, 0);
250+
expectPixel(ctx, 4, 4, GREEN);
251+
const pixel = readGL(gl);
252+
ok(pixelNear(pixel, CLEAR), `expected cleared, got ${describePixel(pixel)}`);
253+
});
254+
}
255+
256+
test('transferToImageBitmap leaves the app its own framebuffer bound', () => {
257+
const { canvas, gl } = clearedWebGL(8);
258+
const texture = gl.createTexture();
259+
gl.bindTexture(gl.TEXTURE_2D, texture);
260+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 8, 8, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
261+
const framebuffer = gl.createFramebuffer();
262+
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
263+
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
264+
canvas.transferToImageBitmap();
265+
// Still drawing into the texture: a clear lands there, not in the drawing buffer.
266+
gl.clearColor(0, 0, 1, 1);
267+
gl.clear(gl.COLOR_BUFFER_BIT);
268+
const pixel = readGL(gl);
269+
ok(pixelNear(pixel, BLUE), `expected the texture's blue, got ${describePixel(pixel)}`);
270+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
271+
const drawingBuffer = readGL(gl);
272+
ok(pixelNear(drawingBuffer, CLEAR), `the drawing buffer should stay cleared, got ${describePixel(drawingBuffer)}`);
273+
});
274+
});
275+
276+
suite('offscreen.webgpu', () => {
277+
if (!navigator?.gpu) {
278+
skip('draws', 'navigator.gpu is missing');
279+
return;
280+
}
281+
test('draws and transfers', async () => {
282+
const adapter = await navigator.gpu.requestAdapter();
283+
ok(adapter, 'no adapter');
284+
const device = await adapter.requestDevice();
285+
const canvas = new OffscreenCanvas(8, 8);
286+
const context = canvas.getContext('webgpu') as any;
287+
ok(context, 'no webgpu context');
288+
const format = navigator.gpu.getPreferredCanvasFormat();
289+
context.configure({ device, format, alphaMode: 'premultiplied' });
290+
const encoder = device.createCommandEncoder();
291+
const pass = encoder.beginRenderPass({
292+
colorAttachments: [{ view: context.getCurrentTexture().createView(), clearValue: { r: 0, g: 0, b: 1, a: 1 }, loadOp: 'clear', storeOp: 'store' }],
293+
});
294+
pass.end();
295+
device.queue.submit([encoder.finish()]);
296+
const bitmap = canvas.transferToImageBitmap();
297+
equal(bitmap.width, 8);
298+
const { ctx } = make2D(8, 8);
299+
ctx.drawImage(bitmap, 0, 0);
300+
expectPixel(ctx, 4, 4, BLUE);
301+
});
302+
});
303+
304+
suite('offscreen.transfer', () => {
305+
test('takes the canvas size', () => {
306+
const canvas = makeCanvas(20, 10) as any;
307+
const offscreen = canvas.transferControlToOffscreen();
308+
equal(offscreen.width, 20);
309+
equal(offscreen.height, 10);
310+
});
311+
312+
test('the canvas can no longer getContext', () => {
313+
const canvas = makeCanvas(4, 4) as any;
314+
canvas.transferControlToOffscreen();
315+
throws(() => canvas.getContext('2d'), 'InvalidStateError');
316+
});
317+
318+
test('the canvas can no longer be resized', () => {
319+
const canvas = makeCanvas(4, 4) as any;
320+
canvas.transferControlToOffscreen();
321+
throws(() => (canvas.width = 5), 'InvalidStateError');
322+
});
323+
324+
test('a canvas transfers once', () => {
325+
const canvas = makeCanvas(4, 4) as any;
326+
canvas.transferControlToOffscreen();
327+
throws(() => canvas.transferControlToOffscreen(), 'InvalidStateError');
328+
});
329+
330+
test('a canvas with a context cannot transfer', () => {
331+
const { canvas } = make2D(4, 4);
332+
throws(() => (canvas as any).transferControlToOffscreen(), 'InvalidStateError');
333+
});
334+
335+
test('the OffscreenCanvas draws into the canvas surface', () => {
336+
const canvas = makeCanvas(8, 8) as any;
337+
const offscreen = canvas.transferControlToOffscreen();
338+
const ctx = offscreen.getContext('2d');
339+
ctx.fillStyle = 'blue';
340+
ctx.fillRect(0, 0, 8, 8);
341+
expectPixel(ctx, 4, 4, BLUE);
342+
// The canvas shows what the OffscreenCanvas drew: it is an image of the same frame.
343+
const { ctx: target } = make2D(8, 8);
344+
target.drawImage(offscreen, 0, 0);
345+
expectPixel(target, 4, 4, BLUE);
346+
});
347+
348+
test('resizing the OffscreenCanvas resizes the canvas', () => {
349+
const canvas = makeCanvas(8, 8) as any;
350+
const offscreen = canvas.transferControlToOffscreen();
351+
offscreen.getContext('2d');
352+
offscreen.width = 30;
353+
equal(canvas.width, 30);
354+
});
355+
});
356+
}

0 commit comments

Comments
 (0)