@driftengine/native-host 4.3.0 → 4.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The host's frames, in the order a browser runs one: the page's animation frames, the frame
3
+ * itself, the task's microtask checkpoint, then the present.
4
+ *
5
+ * **The checkpoint before the present is the part that is easy to lose.** A browser puts the canvas
6
+ * on the screen when the task ends, after every microtask queued in it has run, and the WebGPU
7
+ * renderer relies on that: whatever is drawn after `endFrame` is submitted on a microtask, since
8
+ * there is no second `endFrame` to hang it on. A host that presents as soon as the frame returns
9
+ * blits the canvas before that submission, so the window never shows an overlay while the canvas
10
+ * texture, which a capture reads back, holds it — a loading screen seen by every capture and by
11
+ * nobody at the machine.
12
+ *
13
+ * Taken as steps rather than as a window, so the order can be held by a test without a display.
14
+ */
15
+ /** One frame's call: the page's clock in milliseconds, and how many frames were drawn before it. */
16
+ export interface FrameCall {
17
+ readonly now: number;
18
+ readonly drawn: number;
19
+ }
20
+ /** The device's error scope around a frame, where there is a device to scope. */
21
+ export interface FrameErrors {
22
+ open(): void;
23
+ close(): void;
24
+ }
25
+ export interface FrameSteps {
26
+ /** Stop after this many; unbounded when absent. */
27
+ readonly frames?: number;
28
+ /** Whether to draw another: false once the window has closed or the game has asked to quit. */
29
+ readonly open: () => boolean;
30
+ /** The page's clock, read once a frame. */
31
+ readonly clock: () => number;
32
+ /** Where a held clock moves, before anything reads it. */
33
+ readonly before?: (drawn: number) => void;
34
+ /** The page's animation-frame callbacks, which a browser runs before it paints. */
35
+ readonly animationFrames: (now: number) => void;
36
+ /** The device's error scope for this frame, or null before the canvas has a device. */
37
+ readonly errors: () => FrameErrors | null;
38
+ /**
39
+ * Draw the frame, or answer with a promise when there is nothing to draw until it settles — a
40
+ * scene still mounting. The loop waits for it, presents nothing for that frame and does not count
41
+ * it, rather than spinning through empty frames: that burned a core between two scenes, and spent
42
+ * a run's frame budget before the next scene had drawn once.
43
+ */
44
+ readonly frame?: (call: FrameCall) => void | Promise<void>;
45
+ /** Put the canvas on the screen. */
46
+ readonly present: () => void;
47
+ readonly after?: (call: FrameCall) => void;
48
+ }
49
+ /** Draw until `open` says stop or `frames` are drawn, and answer how many were. */
50
+ export declare function runFrames(steps: FrameSteps): Promise<number>;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The host's frames, in the order a browser runs one: the page's animation frames, the frame
3
+ * itself, the task's microtask checkpoint, then the present.
4
+ *
5
+ * **The checkpoint before the present is the part that is easy to lose.** A browser puts the canvas
6
+ * on the screen when the task ends, after every microtask queued in it has run, and the WebGPU
7
+ * renderer relies on that: whatever is drawn after `endFrame` is submitted on a microtask, since
8
+ * there is no second `endFrame` to hang it on. A host that presents as soon as the frame returns
9
+ * blits the canvas before that submission, so the window never shows an overlay while the canvas
10
+ * texture, which a capture reads back, holds it — a loading screen seen by every capture and by
11
+ * nobody at the machine.
12
+ *
13
+ * Taken as steps rather than as a window, so the order can be held by a test without a display.
14
+ */
15
+ /** Draw until `open` says stop or `frames` are drawn, and answer how many were. */
16
+ export async function runFrames(steps) {
17
+ const frames = steps.frames ?? Number.POSITIVE_INFINITY;
18
+ let drawn = 0;
19
+ while (steps.open() && drawn < frames) {
20
+ steps.before?.(drawn);
21
+ const now = steps.clock();
22
+ steps.animationFrames(now);
23
+ const errors = steps.errors();
24
+ errors?.open();
25
+ const waiting = steps.frame?.({ now, drawn });
26
+ if (waiting !== undefined) {
27
+ errors?.close();
28
+ await waiting;
29
+ continue;
30
+ }
31
+ /*
32
+ * The microtask checkpoint, then the present. A macrotask is the one boundary Node guarantees
33
+ * every microtask has run by, chained ones included. What it gives up: the device's and SDL's
34
+ * callbacks may take a turn between the frame and the present, where a browser runs nothing
35
+ * else. Both only queue work; neither draws.
36
+ */
37
+ await new Promise((resolve) => setImmediate(resolve));
38
+ steps.present();
39
+ errors?.close();
40
+ steps.after?.({ now, drawn });
41
+ drawn += 1;
42
+ /* A task between frames, so SDL's events and the device's callbacks get their turn. */
43
+ await new Promise((resolve) => setImmediate(resolve));
44
+ }
45
+ return drawn;
46
+ }
package/dist/images.js CHANGED
@@ -20,7 +20,7 @@
20
20
  import { readFileSync } from 'node:fs';
21
21
  import { createRequire } from 'node:module';
22
22
  import decodeJpeg, { init as initJpeg } from '@jsquash/jpeg/decode.js';
23
- import { decodePng } from '@driftengine/core/scripts/png.mjs';
23
+ import { decodePng, rgbaOf } from '@driftengine/core/scripts/png.mjs';
24
24
  const HOST_BITMAP = Symbol('driftengine host bitmap');
25
25
  export function isHostBitmap(value) {
26
26
  return typeof value === 'object' && value !== null && HOST_BITMAP in value;
@@ -35,21 +35,6 @@ function bitmap(width, height, data, premultiplied) {
35
35
  [HOST_BITMAP]: true,
36
36
  };
37
37
  }
38
- /** Four bytes a pixel out of three or four. */
39
- function rgba(pixels, channels, count) {
40
- const out = new Uint8ClampedArray(count * 4);
41
- if (channels === 4) {
42
- out.set(pixels.subarray(0, count * 4));
43
- return out;
44
- }
45
- for (let at = 0; at < count; at += 1) {
46
- out[at * 4] = pixels[at * channels];
47
- out[at * 4 + 1] = pixels[at * channels + 1];
48
- out[at * 4 + 2] = pixels[at * channels + 2];
49
- out[at * 4 + 3] = 255;
50
- }
51
- return out;
52
- }
53
38
  let jpegReady = null;
54
39
  /** MozJPEG's module, compiled once from the file its package ships. */
55
40
  function readyJpeg() {
@@ -78,8 +63,8 @@ export async function hostCreateImageBitmap(source, options = {}) {
78
63
  }
79
64
  const bytes = new Uint8Array(await source.arrayBuffer());
80
65
  if (source.type === 'image/png') {
81
- const png = decodePng(Buffer.from(bytes));
82
- const pixels = rgba(png.pixels, png.channels, png.width * png.height);
66
+ const png = rgbaOf(decodePng(Buffer.from(bytes)));
67
+ const pixels = new Uint8ClampedArray(png.rgba.buffer, png.rgba.byteOffset, png.rgba.length);
83
68
  return bitmap(png.width, png.height, pixels, premultiplied);
84
69
  }
85
70
  if (source.type === 'image/jpeg') {
package/dist/index.d.ts CHANGED
@@ -21,3 +21,4 @@ export type { GameEntry, GameOptions } from './game.ts';
21
21
  export { configDirectory, startHost } from './runtime.ts';
22
22
  export type { FrameCall, HostOptions, NativeHost, RunOptions } from './runtime.ts';
23
23
  export { tileFiles } from './tileFiles.ts';
24
+ export type { PresentMode } from './window.ts';
package/dist/runtime.d.ts CHANGED
@@ -16,8 +16,9 @@
16
16
  import { type AudioDefaults } from './audio.ts';
17
17
  import type { NativeBridge } from './bridge.ts';
18
18
  import type { NativeCanvas } from './canvas.ts';
19
+ import { type FrameCall } from './frameLoop.ts';
19
20
  import { HostPage } from './page.ts';
20
- import { HostWindow } from './window.ts';
21
+ import { HostWindow, type PresentMode } from './window.ts';
21
22
  import type { WindowMode } from './windowMode.ts';
22
23
  export interface HostOptions {
23
24
  readonly title: string;
@@ -37,17 +38,15 @@ export interface HostOptions {
37
38
  /** `location.search`, for a game that reads its address. */
38
39
  readonly query?: string;
39
40
  readonly audio?: AudioDefaults;
41
+ /** How frames reach the screen; `fifo` by default. See `HostWindowOptions.presentMode`. */
42
+ readonly presentMode?: PresentMode;
40
43
  }
41
- export interface FrameCall {
42
- /** The page's clock for this frame, in milliseconds. */
43
- readonly now: number;
44
- /** Frames drawn before this one. */
45
- readonly drawn: number;
46
- }
44
+ export type { FrameCall } from './frameLoop.ts';
47
45
  export interface RunOptions {
48
46
  readonly frames?: number;
49
47
  readonly before?: (drawn: number) => void;
50
- readonly frame?: (call: FrameCall) => void;
48
+ /** Draw, or answer with a promise to be waited for when there is nothing to draw: see `runFrames`. */
49
+ readonly frame?: (call: FrameCall) => void | Promise<void>;
51
50
  readonly after?: (call: FrameCall) => void;
52
51
  }
53
52
  export interface NativeHost {
@@ -58,7 +57,8 @@ export interface NativeHost {
58
57
  /**
59
58
  * Draw until the window closes, the game asks to quit, or `frames` have been drawn. Each frame
60
59
  * calls `before` (where a held clock moves), runs the page's animation frames, calls `frame`,
61
- * puts the canvas on the screen and calls `after`. Answers how many were drawn.
60
+ * lets the microtasks it queued run, puts the canvas on the screen and calls `after`. Answers how
61
+ * many were drawn.
62
62
  */
63
63
  run(options?: RunOptions): Promise<number>;
64
64
  /** Flush the store and close the window. */
package/dist/runtime.js CHANGED
@@ -20,6 +20,7 @@ import sdl from '@kmamal/sdl';
20
20
  import { BRIDGE_KEY } from '@driftengine/package/bridge';
21
21
  import { installAudio } from './audio.js';
22
22
  import { errorsOf } from './device.js';
23
+ import { runFrames } from './frameLoop.js';
23
24
  import { hostFetch } from './files.js';
24
25
  import { HostGamepads } from './gamepads.js';
25
26
  import { installGpu, installLocation } from './globals.js';
@@ -46,6 +47,7 @@ export function startHost(options) {
46
47
  hidden: options.hidden === true,
47
48
  mode: options.mode,
48
49
  resizable: options.resizable,
50
+ presentMode: options.presentMode,
49
51
  });
50
52
  installGpu(window.gpu);
51
53
  installLocation(options.query ?? '');
@@ -73,27 +75,20 @@ export function startHost(options) {
73
75
  page,
74
76
  canvas: window.canvas,
75
77
  bridge,
76
- async run(run = {}) {
77
- const frames = run.frames ?? Number.POSITIVE_INFINITY;
78
- let drawn = 0;
79
- while (!window.isClosed && !quitting && drawn < frames) {
80
- run.before?.(drawn);
81
- const now = performance.now();
82
- /* The page's animation frames first, as a browser runs them before it paints. */
83
- page.runFrame(now);
84
- const context = window.canvas.getContext('webgpu');
85
- const configuration = context.getConfiguration();
86
- const errors = configuration === null ? null : errorsOf(configuration.device);
87
- errors?.open();
88
- run.frame?.({ now, drawn });
89
- window.present();
90
- errors?.close();
91
- run.after?.({ now, drawn });
92
- drawn += 1;
93
- /* A task between frames, so SDL's events and the device's callbacks get their turn. */
94
- await new Promise((resolve) => setImmediate(resolve));
95
- }
96
- return drawn;
78
+ /* In a browser's order, the microtask checkpoint before the present included: see `frameLoop.ts`. */
79
+ run(run = {}) {
80
+ return runFrames({
81
+ ...run,
82
+ open: () => !window.isClosed && !quitting,
83
+ clock: () => performance.now(),
84
+ animationFrames: (now) => page.runFrame(now),
85
+ errors: () => {
86
+ const context = window.canvas.getContext('webgpu');
87
+ const configuration = context.getConfiguration();
88
+ return configuration === null ? null : errorsOf(configuration.device);
89
+ },
90
+ present: () => window.present(),
91
+ });
97
92
  },
98
93
  async close() {
99
94
  bridge.flushSync();
package/dist/window.d.ts CHANGED
@@ -28,7 +28,20 @@ export interface HostWindowOptions {
28
28
  /** As a packaged game's manifest says; windowed and resizable when it says nothing. */
29
29
  readonly mode?: WindowMode;
30
30
  readonly resizable?: boolean;
31
+ /**
32
+ * How a frame reaches the screen: `fifo`, the default, waits for the display's next refresh.
33
+ *
34
+ * **Named because the pacing is the compositor's, not the engine's.** The binding renders into
35
+ * X11 windows only, so a Wayland session runs this through XWayland, and what `fifo` waits for
36
+ * there differs by window: measured on one session, a hidden window held 58 fps where a visible
37
+ * one drew a light scene at 250. A caller measuring the engine wants the wait out of the way.
38
+ * `mailbox` never waits and the display takes the newest frame at its own rate, at the cost of
39
+ * drawing frames nobody sees; `immediate` also tears.
40
+ */
41
+ readonly presentMode?: PresentMode;
31
42
  }
43
+ /** The binding's swap-chain modes, as `renderGPUDeviceToWindow` takes them. */
44
+ export type PresentMode = 'fifo' | 'fifoRelaxed' | 'immediate' | 'mailbox';
32
45
  export declare class HostWindow {
33
46
  readonly canvas: NativeCanvas;
34
47
  readonly gpu: GPU;
@@ -37,6 +50,7 @@ export declare class HostWindow {
37
50
  private device;
38
51
  private renderer;
39
52
  private presenter;
53
+ private readonly presentMode;
40
54
  private closed;
41
55
  constructor(options: HostWindowOptions);
42
56
  /**
package/dist/window.js CHANGED
@@ -33,6 +33,7 @@ export class HostWindow {
33
33
  device = null;
34
34
  renderer = null;
35
35
  presenter = null;
36
+ presentMode;
36
37
  closed = false;
37
38
  constructor(options) {
38
39
  this.window = sdl.video.createWindow({
@@ -43,6 +44,7 @@ export class HostWindow {
43
44
  visible: options.hidden !== true,
44
45
  webgpu: true,
45
46
  });
47
+ this.presentMode = options.presentMode ?? 'fifo';
46
48
  this.canvas = new NativeCanvas(this.window.pixelWidth, this.window.pixelHeight);
47
49
  this.instance = gpuModule.create([]);
48
50
  this.gpu = nativeGpu(this.instance);
@@ -158,6 +160,7 @@ export class HostWindow {
158
160
  this.renderer = gpuModule.renderGPUDeviceToWindow({
159
161
  device: device,
160
162
  window: this.window,
163
+ presentMode: this.presentMode,
161
164
  });
162
165
  this.presenter = new Presenter(device, this.renderer.getCurrentTexture().format);
163
166
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftengine/native-host",
3
- "version": "4.3.0",
3
+ "version": "4.4.1",
4
4
  "description": "The engine on a native window and a native WebGPU device, with no browser between them",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -37,13 +37,13 @@
37
37
  "node-web-audio-api": "2.2.0"
38
38
  },
39
39
  "devDependencies": {
40
- "@driftengine/audio": "4.3.0"
40
+ "@driftengine/audio": "4.4.1"
41
41
  },
42
42
  "peerDependencies": {
43
- "@driftengine/core": "4.3.0",
44
- "@driftengine/package": "4.3.0",
45
- "@driftengine/texture": "4.3.0",
46
- "@driftengine/ui2d": "4.3.0"
43
+ "@driftengine/core": "4.4.1",
44
+ "@driftengine/package": "4.4.1",
45
+ "@driftengine/texture": "4.4.1",
46
+ "@driftengine/ui2d": "4.4.1"
47
47
  },
48
48
  "peerDependenciesMeta": {
49
49
  "@driftengine/texture": {
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The host's frames, in the order a browser runs one: the page's animation frames, the frame
3
+ * itself, the task's microtask checkpoint, then the present.
4
+ *
5
+ * **The checkpoint before the present is the part that is easy to lose.** A browser puts the canvas
6
+ * on the screen when the task ends, after every microtask queued in it has run, and the WebGPU
7
+ * renderer relies on that: whatever is drawn after `endFrame` is submitted on a microtask, since
8
+ * there is no second `endFrame` to hang it on. A host that presents as soon as the frame returns
9
+ * blits the canvas before that submission, so the window never shows an overlay while the canvas
10
+ * texture, which a capture reads back, holds it — a loading screen seen by every capture and by
11
+ * nobody at the machine.
12
+ *
13
+ * Taken as steps rather than as a window, so the order can be held by a test without a display.
14
+ */
15
+
16
+ /** One frame's call: the page's clock in milliseconds, and how many frames were drawn before it. */
17
+ export interface FrameCall {
18
+ readonly now: number;
19
+ readonly drawn: number;
20
+ }
21
+
22
+ /** The device's error scope around a frame, where there is a device to scope. */
23
+ export interface FrameErrors {
24
+ open(): void;
25
+ close(): void;
26
+ }
27
+
28
+ export interface FrameSteps {
29
+ /** Stop after this many; unbounded when absent. */
30
+ readonly frames?: number;
31
+ /** Whether to draw another: false once the window has closed or the game has asked to quit. */
32
+ readonly open: () => boolean;
33
+ /** The page's clock, read once a frame. */
34
+ readonly clock: () => number;
35
+ /** Where a held clock moves, before anything reads it. */
36
+ readonly before?: (drawn: number) => void;
37
+ /** The page's animation-frame callbacks, which a browser runs before it paints. */
38
+ readonly animationFrames: (now: number) => void;
39
+ /** The device's error scope for this frame, or null before the canvas has a device. */
40
+ readonly errors: () => FrameErrors | null;
41
+ /**
42
+ * Draw the frame, or answer with a promise when there is nothing to draw until it settles — a
43
+ * scene still mounting. The loop waits for it, presents nothing for that frame and does not count
44
+ * it, rather than spinning through empty frames: that burned a core between two scenes, and spent
45
+ * a run's frame budget before the next scene had drawn once.
46
+ */
47
+ readonly frame?: (call: FrameCall) => void | Promise<void>;
48
+ /** Put the canvas on the screen. */
49
+ readonly present: () => void;
50
+ readonly after?: (call: FrameCall) => void;
51
+ }
52
+
53
+ /** Draw until `open` says stop or `frames` are drawn, and answer how many were. */
54
+ export async function runFrames(steps: FrameSteps): Promise<number> {
55
+ const frames = steps.frames ?? Number.POSITIVE_INFINITY;
56
+ let drawn = 0;
57
+ while (steps.open() && drawn < frames) {
58
+ steps.before?.(drawn);
59
+ const now = steps.clock();
60
+ steps.animationFrames(now);
61
+ const errors = steps.errors();
62
+ errors?.open();
63
+ const waiting = steps.frame?.({ now, drawn });
64
+ if (waiting !== undefined) {
65
+ errors?.close();
66
+ await waiting;
67
+ continue;
68
+ }
69
+ /*
70
+ * The microtask checkpoint, then the present. A macrotask is the one boundary Node guarantees
71
+ * every microtask has run by, chained ones included. What it gives up: the device's and SDL's
72
+ * callbacks may take a turn between the frame and the present, where a browser runs nothing
73
+ * else. Both only queue work; neither draws.
74
+ */
75
+ await new Promise((resolve) => setImmediate(resolve));
76
+ steps.present();
77
+ errors?.close();
78
+ steps.after?.({ now, drawn });
79
+ drawn += 1;
80
+ /* A task between frames, so SDL's events and the device's callbacks get their turn. */
81
+ await new Promise((resolve) => setImmediate(resolve));
82
+ }
83
+ return drawn;
84
+ }
package/src/images.ts CHANGED
@@ -23,7 +23,7 @@ import { createRequire } from 'node:module';
23
23
 
24
24
  import decodeJpeg, { init as initJpeg } from '@jsquash/jpeg/decode.js';
25
25
 
26
- import { decodePng } from '@driftengine/core/scripts/png.mjs';
26
+ import { decodePng, rgbaOf } from '@driftengine/core/scripts/png.mjs';
27
27
 
28
28
  /** What a host hands back for an image: its size and four bytes a pixel, top row first. */
29
29
  export interface HostBitmap {
@@ -63,22 +63,6 @@ function bitmap(
63
63
  } as HostBitmap;
64
64
  }
65
65
 
66
- /** Four bytes a pixel out of three or four. */
67
- function rgba(pixels: Uint8Array, channels: number, count: number): Uint8ClampedArray {
68
- const out = new Uint8ClampedArray(count * 4);
69
- if (channels === 4) {
70
- out.set(pixels.subarray(0, count * 4));
71
- return out;
72
- }
73
- for (let at = 0; at < count; at += 1) {
74
- out[at * 4] = pixels[at * channels] as number;
75
- out[at * 4 + 1] = pixels[at * channels + 1] as number;
76
- out[at * 4 + 2] = pixels[at * channels + 2] as number;
77
- out[at * 4 + 3] = 255;
78
- }
79
- return out;
80
- }
81
-
82
66
  let jpegReady: Promise<void> | null = null;
83
67
 
84
68
  /** MozJPEG's module, compiled once from the file its package ships. */
@@ -112,8 +96,8 @@ export async function hostCreateImageBitmap(
112
96
  }
113
97
  const bytes = new Uint8Array(await source.arrayBuffer());
114
98
  if (source.type === 'image/png') {
115
- const png = decodePng(Buffer.from(bytes));
116
- const pixels = rgba(png.pixels, png.channels, png.width * png.height);
99
+ const png = rgbaOf(decodePng(Buffer.from(bytes)));
100
+ const pixels = new Uint8ClampedArray(png.rgba.buffer, png.rgba.byteOffset, png.rgba.length);
117
101
  return bitmap(png.width, png.height, pixels, premultiplied);
118
102
  }
119
103
  if (source.type === 'image/jpeg') {
package/src/index.ts CHANGED
@@ -22,3 +22,4 @@ export type { GameEntry, GameOptions } from './game.ts';
22
22
  export { configDirectory, startHost } from './runtime.ts';
23
23
  export type { FrameCall, HostOptions, NativeHost, RunOptions } from './runtime.ts';
24
24
  export { tileFiles } from './tileFiles.ts';
25
+ export type { PresentMode } from './window.ts';
package/src/runtime.ts CHANGED
@@ -25,13 +25,14 @@ import { type AudioDefaults, installAudio } from './audio.ts';
25
25
  import type { NativeBridge } from './bridge.ts';
26
26
  import type { NativeCanvas } from './canvas.ts';
27
27
  import { errorsOf } from './device.ts';
28
+ import { type FrameCall, runFrames } from './frameLoop.ts';
28
29
  import { hostFetch } from './files.ts';
29
30
  import { HostGamepads, type JoystickModule, type PadModule } from './gamepads.ts';
30
31
  import { installGpu, installLocation } from './globals.ts';
31
32
  import { installImages } from './images.ts';
32
33
  import { HostPage } from './page.ts';
33
34
  import { installThreads } from './threads.ts';
34
- import { HostWindow } from './window.ts';
35
+ import { HostWindow, type PresentMode } from './window.ts';
35
36
  import type { WindowMode } from './windowMode.ts';
36
37
 
37
38
  export interface HostOptions {
@@ -52,19 +53,17 @@ export interface HostOptions {
52
53
  /** `location.search`, for a game that reads its address. */
53
54
  readonly query?: string;
54
55
  readonly audio?: AudioDefaults;
56
+ /** How frames reach the screen; `fifo` by default. See `HostWindowOptions.presentMode`. */
57
+ readonly presentMode?: PresentMode;
55
58
  }
56
59
 
57
- export interface FrameCall {
58
- /** The page's clock for this frame, in milliseconds. */
59
- readonly now: number;
60
- /** Frames drawn before this one. */
61
- readonly drawn: number;
62
- }
60
+ export type { FrameCall } from './frameLoop.ts';
63
61
 
64
62
  export interface RunOptions {
65
63
  readonly frames?: number;
66
64
  readonly before?: (drawn: number) => void;
67
- readonly frame?: (call: FrameCall) => void;
65
+ /** Draw, or answer with a promise to be waited for when there is nothing to draw: see `runFrames`. */
66
+ readonly frame?: (call: FrameCall) => void | Promise<void>;
68
67
  readonly after?: (call: FrameCall) => void;
69
68
  }
70
69
 
@@ -76,7 +75,8 @@ export interface NativeHost {
76
75
  /**
77
76
  * Draw until the window closes, the game asks to quit, or `frames` have been drawn. Each frame
78
77
  * calls `before` (where a held clock moves), runs the page's animation frames, calls `frame`,
79
- * puts the canvas on the screen and calls `after`. Answers how many were drawn.
78
+ * lets the microtasks it queued run, puts the canvas on the screen and calls `after`. Answers how
79
+ * many were drawn.
80
80
  */
81
81
  run(options?: RunOptions): Promise<number>;
82
82
  /** Flush the store and close the window. */
@@ -102,6 +102,7 @@ export function startHost(options: HostOptions): NativeHost {
102
102
  hidden: options.hidden === true,
103
103
  mode: options.mode,
104
104
  resizable: options.resizable,
105
+ presentMode: options.presentMode,
105
106
  });
106
107
  installGpu(window.gpu);
107
108
  installLocation(options.query ?? '');
@@ -132,27 +133,20 @@ export function startHost(options: HostOptions): NativeHost {
132
133
  page,
133
134
  canvas: window.canvas,
134
135
  bridge,
135
- async run(run = {}) {
136
- const frames = run.frames ?? Number.POSITIVE_INFINITY;
137
- let drawn = 0;
138
- while (!window.isClosed && !quitting && drawn < frames) {
139
- run.before?.(drawn);
140
- const now = performance.now();
141
- /* The page's animation frames first, as a browser runs them before it paints. */
142
- page.runFrame(now);
143
- const context = window.canvas.getContext('webgpu') as GPUCanvasContext;
144
- const configuration = context.getConfiguration();
145
- const errors = configuration === null ? null : errorsOf(configuration.device);
146
- errors?.open();
147
- run.frame?.({ now, drawn });
148
- window.present();
149
- errors?.close();
150
- run.after?.({ now, drawn });
151
- drawn += 1;
152
- /* A task between frames, so SDL's events and the device's callbacks get their turn. */
153
- await new Promise((resolve) => setImmediate(resolve));
154
- }
155
- return drawn;
136
+ /* In a browser's order, the microtask checkpoint before the present included: see `frameLoop.ts`. */
137
+ run(run = {}) {
138
+ return runFrames({
139
+ ...run,
140
+ open: () => !window.isClosed && !quitting,
141
+ clock: () => performance.now(),
142
+ animationFrames: (now) => page.runFrame(now),
143
+ errors: () => {
144
+ const context = window.canvas.getContext('webgpu') as GPUCanvasContext;
145
+ const configuration = context.getConfiguration();
146
+ return configuration === null ? null : errorsOf(configuration.device);
147
+ },
148
+ present: () => window.present(),
149
+ });
156
150
  },
157
151
  async close() {
158
152
  bridge.flushSync();
package/src/window.ts CHANGED
@@ -47,8 +47,22 @@ export interface HostWindowOptions {
47
47
  /** As a packaged game's manifest says; windowed and resizable when it says nothing. */
48
48
  readonly mode?: WindowMode;
49
49
  readonly resizable?: boolean;
50
+ /**
51
+ * How a frame reaches the screen: `fifo`, the default, waits for the display's next refresh.
52
+ *
53
+ * **Named because the pacing is the compositor's, not the engine's.** The binding renders into
54
+ * X11 windows only, so a Wayland session runs this through XWayland, and what `fifo` waits for
55
+ * there differs by window: measured on one session, a hidden window held 58 fps where a visible
56
+ * one drew a light scene at 250. A caller measuring the engine wants the wait out of the way.
57
+ * `mailbox` never waits and the display takes the newest frame at its own rate, at the cost of
58
+ * drawing frames nobody sees; `immediate` also tears.
59
+ */
60
+ readonly presentMode?: PresentMode;
50
61
  }
51
62
 
63
+ /** The binding's swap-chain modes, as `renderGPUDeviceToWindow` takes them. */
64
+ export type PresentMode = 'fifo' | 'fifoRelaxed' | 'immediate' | 'mailbox';
65
+
52
66
  export class HostWindow {
53
67
  readonly canvas: NativeCanvas;
54
68
  readonly gpu: GPU;
@@ -57,6 +71,7 @@ export class HostWindow {
57
71
  private device: GPUDevice | null = null;
58
72
  private renderer: WindowRenderer | null = null;
59
73
  private presenter: Presenter | null = null;
74
+ private readonly presentMode: PresentMode;
60
75
  private closed = false;
61
76
 
62
77
  constructor(options: HostWindowOptions) {
@@ -68,6 +83,7 @@ export class HostWindow {
68
83
  visible: options.hidden !== true,
69
84
  webgpu: true,
70
85
  });
86
+ this.presentMode = options.presentMode ?? 'fifo';
71
87
  this.canvas = new NativeCanvas(this.window.pixelWidth, this.window.pixelHeight);
72
88
  this.instance = gpuModule.create([]);
73
89
  this.gpu = nativeGpu(this.instance as unknown as Parameters<typeof nativeGpu>[0]);
@@ -186,6 +202,7 @@ export class HostWindow {
186
202
  this.renderer = gpuModule.renderGPUDeviceToWindow({
187
203
  device: device as unknown as DawnDevice,
188
204
  window: this.window,
205
+ presentMode: this.presentMode,
189
206
  });
190
207
  this.presenter = new Presenter(device, this.renderer.getCurrentTexture().format);
191
208
  }