@plasius/gpu-renderer 0.1.0

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.
package/src/index.d.ts ADDED
@@ -0,0 +1,90 @@
1
+ export type RendererColor = string | [number, number, number, number?];
2
+
3
+ export interface RendererSnapshot {
4
+ running: boolean;
5
+ frame: number;
6
+ lastTimestamp: number;
7
+ format: string;
8
+ width: number;
9
+ height: number;
10
+ xrActive: boolean;
11
+ }
12
+
13
+ export interface RendererHooks {
14
+ onBeforeEncode?: (event: {
15
+ frame: number;
16
+ timestamp: number;
17
+ device: GPUDevice;
18
+ context: GPUCanvasContext;
19
+ encoder: GPUCommandEncoder;
20
+ pass: GPURenderPassEncoder;
21
+ canvas: HTMLCanvasElement;
22
+ }) => void;
23
+ onAfterSubmit?: (event: {
24
+ frame: number;
25
+ timestamp: number;
26
+ device: GPUDevice;
27
+ context: GPUCanvasContext;
28
+ canvas: HTMLCanvasElement;
29
+ }) => void;
30
+ }
31
+
32
+ export interface CreateGpuRendererOptions extends RendererHooks {
33
+ canvas?: HTMLCanvasElement | string;
34
+ navigator?: Navigator | { gpu?: GPU };
35
+ document?: Document;
36
+ powerPreference?: GPUPowerPreference;
37
+ alpha?: boolean;
38
+ format?: GPUTextureFormat;
39
+ clearColor?: RendererColor;
40
+ requestAnimationFrame?: (cb: FrameRequestCallback) => number;
41
+ cancelAnimationFrame?: (id: number) => void;
42
+ }
43
+
44
+ export interface GpuRenderer {
45
+ canvas: HTMLCanvasElement;
46
+ context: GPUCanvasContext;
47
+ device: GPUDevice;
48
+ format: GPUTextureFormat | string;
49
+ renderOnce(timestamp?: number): { frame: number; timestamp: number };
50
+ start(): boolean;
51
+ stop(): boolean;
52
+ resize(cssWidth: number, cssHeight: number, devicePixelRatio?: number): {
53
+ width: number;
54
+ height: number;
55
+ };
56
+ setClearColor(value: RendererColor): [number, number, number, number];
57
+ setXrActive(active: boolean): void;
58
+ getSnapshot(): RendererSnapshot;
59
+ bindXrManager(
60
+ xrManager: {
61
+ subscribe: (listener: (state: { activeSession: XRSession | null }) => void) => () => void;
62
+ getState?: () => { activeSession: XRSession | null };
63
+ store?: { getSnapshot: () => { activeSession: XRSession | null } };
64
+ },
65
+ bindOptions?: {
66
+ onSessionStart?: (session: XRSession, renderer: GpuRenderer) => void;
67
+ onSessionEnd?: (renderer: GpuRenderer) => void;
68
+ }
69
+ ): () => void;
70
+ destroy(): void;
71
+ }
72
+
73
+ export function supportsWebGpu(options?: { navigator?: Navigator | { gpu?: GPU } }): boolean;
74
+
75
+ export function createGpuRenderer(options?: CreateGpuRendererOptions): Promise<GpuRenderer>;
76
+
77
+ export function bindRendererToXrManager(
78
+ renderer: Pick<GpuRenderer, "setXrActive">,
79
+ xrManager: {
80
+ subscribe: (listener: (state: { activeSession: XRSession | null }) => void) => () => void;
81
+ getState?: () => { activeSession: XRSession | null };
82
+ store?: { getSnapshot: () => { activeSession: XRSession | null } };
83
+ },
84
+ options?: {
85
+ onSessionStart?: (session: XRSession, renderer: Pick<GpuRenderer, "setXrActive">) => void;
86
+ onSessionEnd?: (renderer: Pick<GpuRenderer, "setXrActive">) => void;
87
+ }
88
+ ): () => void;
89
+
90
+ export const defaultRendererClearColor: readonly [number, number, number, number];
package/src/index.js ADDED
@@ -0,0 +1,382 @@
1
+ const DEFAULT_CLEAR_COLOR = Object.freeze([0.07, 0.11, 0.18, 1.0]);
2
+ const DEFAULT_CANVAS_SELECTOR = "canvas[data-plasius-gpu-renderer]";
3
+
4
+ function clamp01(value) {
5
+ return Math.min(1, Math.max(0, value));
6
+ }
7
+
8
+ function parseHexChannel(channel) {
9
+ return parseInt(channel, 16) / 255;
10
+ }
11
+
12
+ function normalizeColor(value) {
13
+ if (Array.isArray(value)) {
14
+ const [r = 0, g = 0, b = 0, a = 1] = value;
15
+ return [clamp01(Number(r) || 0), clamp01(Number(g) || 0), clamp01(Number(b) || 0), clamp01(Number(a) || 0)];
16
+ }
17
+
18
+ if (typeof value === "string") {
19
+ const trimmed = value.trim();
20
+ if (/^#[0-9a-f]{3}$/i.test(trimmed)) {
21
+ const r = trimmed[1];
22
+ const g = trimmed[2];
23
+ const b = trimmed[3];
24
+ return [
25
+ parseHexChannel(r + r),
26
+ parseHexChannel(g + g),
27
+ parseHexChannel(b + b),
28
+ 1,
29
+ ];
30
+ }
31
+ if (/^#[0-9a-f]{6}$/i.test(trimmed)) {
32
+ return [
33
+ parseHexChannel(trimmed.slice(1, 3)),
34
+ parseHexChannel(trimmed.slice(3, 5)),
35
+ parseHexChannel(trimmed.slice(5, 7)),
36
+ 1,
37
+ ];
38
+ }
39
+ }
40
+
41
+ return [...DEFAULT_CLEAR_COLOR];
42
+ }
43
+
44
+ function now() {
45
+ if (typeof performance !== "undefined" && typeof performance.now === "function") {
46
+ return performance.now();
47
+ }
48
+ return Date.now();
49
+ }
50
+
51
+ function readNavigator(navigatorOverride) {
52
+ const currentNavigator = navigatorOverride ?? globalThis.navigator;
53
+ if (!currentNavigator || typeof currentNavigator !== "object") {
54
+ throw new Error("Navigator unavailable. Provide a browser-like navigator object.");
55
+ }
56
+ return currentNavigator;
57
+ }
58
+
59
+ function readDocument(documentOverride) {
60
+ const doc = documentOverride ?? globalThis.document;
61
+ if (!doc || typeof doc !== "object") {
62
+ throw new Error("Document unavailable. Provide a browser-like document object.");
63
+ }
64
+ return doc;
65
+ }
66
+
67
+ function resolveCanvas(canvasOrSelector, documentOverride) {
68
+ if (canvasOrSelector && typeof canvasOrSelector === "object") {
69
+ return canvasOrSelector;
70
+ }
71
+
72
+ const doc = readDocument(documentOverride);
73
+ const selector =
74
+ typeof canvasOrSelector === "string" && canvasOrSelector.trim().length > 0
75
+ ? canvasOrSelector
76
+ : DEFAULT_CANVAS_SELECTOR;
77
+ const resolved = doc.querySelector(selector);
78
+ if (!resolved) {
79
+ throw new Error(`Unable to find canvas for selector \"${selector}\".`);
80
+ }
81
+ return resolved;
82
+ }
83
+
84
+ function readGpu(navigatorOverride) {
85
+ const currentNavigator = readNavigator(navigatorOverride);
86
+ const gpu = currentNavigator.gpu;
87
+ if (!gpu || typeof gpu.requestAdapter !== "function") {
88
+ throw new Error("WebGPU runtime unavailable. navigator.gpu is missing.");
89
+ }
90
+ return gpu;
91
+ }
92
+
93
+ function configureContext(context, device, format, alphaMode) {
94
+ if (typeof context.configure !== "function") {
95
+ throw new Error("Canvas WebGPU context does not support configure().");
96
+ }
97
+ context.configure({
98
+ device,
99
+ format,
100
+ alphaMode,
101
+ });
102
+ }
103
+
104
+ function createRenderPassDescriptor(view, clearColor) {
105
+ return {
106
+ colorAttachments: [
107
+ {
108
+ view,
109
+ loadOp: "clear",
110
+ clearValue: {
111
+ r: clearColor[0],
112
+ g: clearColor[1],
113
+ b: clearColor[2],
114
+ a: clearColor[3],
115
+ },
116
+ storeOp: "store",
117
+ },
118
+ ],
119
+ };
120
+ }
121
+
122
+ export function supportsWebGpu(options = {}) {
123
+ try {
124
+ const gpu = readGpu(options.navigator);
125
+ return Boolean(gpu);
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+
131
+ export async function createGpuRenderer(options = {}) {
132
+ const {
133
+ canvas,
134
+ navigator: navigatorOverride,
135
+ document: documentOverride,
136
+ powerPreference = "high-performance",
137
+ alpha = true,
138
+ format,
139
+ clearColor = DEFAULT_CLEAR_COLOR,
140
+ requestAnimationFrame = globalThis.requestAnimationFrame?.bind(globalThis),
141
+ cancelAnimationFrame = globalThis.cancelAnimationFrame?.bind(globalThis),
142
+ onBeforeEncode,
143
+ onAfterSubmit,
144
+ } = options;
145
+
146
+ const gpu = readGpu(navigatorOverride);
147
+ const adapter = await gpu.requestAdapter({ powerPreference });
148
+ if (!adapter) {
149
+ throw new Error("Unable to obtain GPU adapter.");
150
+ }
151
+
152
+ const device = await adapter.requestDevice();
153
+ const targetCanvas = resolveCanvas(canvas, documentOverride);
154
+ const context = targetCanvas.getContext?.("webgpu");
155
+ if (!context) {
156
+ throw new Error("Unable to obtain WebGPU canvas context.");
157
+ }
158
+
159
+ const resolvedFormat =
160
+ format ||
161
+ (typeof gpu.getPreferredCanvasFormat === "function"
162
+ ? gpu.getPreferredCanvasFormat()
163
+ : "bgra8unorm");
164
+
165
+ configureContext(context, device, resolvedFormat, alpha ? "premultiplied" : "opaque");
166
+
167
+ let running = false;
168
+ let destroyed = false;
169
+ let frame = 0;
170
+ let lastTimestamp = 0;
171
+ let rafId = null;
172
+ let clear = normalizeColor(clearColor);
173
+ let xrActive = false;
174
+ let detachXrBinding = null;
175
+
176
+ const renderOnce = (timestamp = now()) => {
177
+ if (destroyed) {
178
+ throw new Error("Renderer was destroyed.");
179
+ }
180
+
181
+ const texture = context.getCurrentTexture?.();
182
+ if (!texture || typeof texture.createView !== "function") {
183
+ throw new Error("WebGPU context returned an invalid current texture.");
184
+ }
185
+
186
+ const encoder = device.createCommandEncoder({
187
+ label: `plasius.gpu-renderer.frame.${frame}`,
188
+ });
189
+ const view = texture.createView();
190
+
191
+ const pass = encoder.beginRenderPass(createRenderPassDescriptor(view, clear));
192
+
193
+ if (typeof onBeforeEncode === "function") {
194
+ onBeforeEncode({
195
+ frame,
196
+ timestamp,
197
+ device,
198
+ context,
199
+ encoder,
200
+ pass,
201
+ canvas: targetCanvas,
202
+ });
203
+ }
204
+
205
+ if (typeof pass.end === "function") {
206
+ pass.end();
207
+ }
208
+
209
+ const commandBuffer = encoder.finish();
210
+ device.queue.submit([commandBuffer]);
211
+
212
+ frame += 1;
213
+ lastTimestamp = timestamp;
214
+
215
+ if (typeof onAfterSubmit === "function") {
216
+ onAfterSubmit({
217
+ frame,
218
+ timestamp,
219
+ device,
220
+ context,
221
+ canvas: targetCanvas,
222
+ });
223
+ }
224
+
225
+ return {
226
+ frame,
227
+ timestamp,
228
+ };
229
+ };
230
+
231
+ const tick = (timestamp) => {
232
+ if (!running || destroyed) {
233
+ return;
234
+ }
235
+ renderOnce(timestamp);
236
+ if (typeof requestAnimationFrame === "function") {
237
+ rafId = requestAnimationFrame(tick);
238
+ }
239
+ };
240
+
241
+ const start = () => {
242
+ if (destroyed) {
243
+ throw new Error("Renderer was destroyed.");
244
+ }
245
+ if (running) {
246
+ return false;
247
+ }
248
+ running = true;
249
+ if (typeof requestAnimationFrame === "function") {
250
+ rafId = requestAnimationFrame(tick);
251
+ } else {
252
+ renderOnce();
253
+ }
254
+ return true;
255
+ };
256
+
257
+ const stop = () => {
258
+ if (!running) {
259
+ return false;
260
+ }
261
+ running = false;
262
+ if (rafId !== null && typeof cancelAnimationFrame === "function") {
263
+ cancelAnimationFrame(rafId);
264
+ }
265
+ rafId = null;
266
+ return true;
267
+ };
268
+
269
+ const resize = (cssWidth, cssHeight, devicePixelRatio = globalThis.devicePixelRatio ?? 1) => {
270
+ const width = Math.max(1, Math.floor(cssWidth * devicePixelRatio));
271
+ const height = Math.max(1, Math.floor(cssHeight * devicePixelRatio));
272
+ targetCanvas.width = width;
273
+ targetCanvas.height = height;
274
+ if (targetCanvas.style) {
275
+ targetCanvas.style.width = `${Math.max(1, Math.floor(cssWidth))}px`;
276
+ targetCanvas.style.height = `${Math.max(1, Math.floor(cssHeight))}px`;
277
+ }
278
+ return { width, height };
279
+ };
280
+
281
+ const setClearColor = (value) => {
282
+ clear = normalizeColor(value);
283
+ return [...clear];
284
+ };
285
+
286
+ const setXrActive = (active) => {
287
+ xrActive = Boolean(active);
288
+ };
289
+
290
+ const getSnapshot = () => {
291
+ const width = Number(targetCanvas.width) || 0;
292
+ const height = Number(targetCanvas.height) || 0;
293
+ return {
294
+ running,
295
+ frame,
296
+ lastTimestamp,
297
+ format: resolvedFormat,
298
+ width,
299
+ height,
300
+ xrActive,
301
+ };
302
+ };
303
+
304
+ const renderer = {
305
+ canvas: targetCanvas,
306
+ context,
307
+ device,
308
+ format: resolvedFormat,
309
+ renderOnce,
310
+ start,
311
+ stop,
312
+ resize,
313
+ setClearColor,
314
+ setXrActive,
315
+ getSnapshot,
316
+ bindXrManager(xrManager, bindOptions = {}) {
317
+ if (detachXrBinding) {
318
+ detachXrBinding();
319
+ }
320
+ detachXrBinding = bindRendererToXrManager(renderer, xrManager, bindOptions);
321
+ return detachXrBinding;
322
+ },
323
+ destroy() {
324
+ stop();
325
+ destroyed = true;
326
+ if (detachXrBinding) {
327
+ detachXrBinding();
328
+ detachXrBinding = null;
329
+ }
330
+ if (typeof context.unconfigure === "function") {
331
+ context.unconfigure();
332
+ }
333
+ },
334
+ };
335
+
336
+ return renderer;
337
+ }
338
+
339
+ function snapshotFromXrManager(xrManager) {
340
+ if (xrManager && typeof xrManager.getState === "function") {
341
+ return xrManager.getState();
342
+ }
343
+ if (xrManager?.store && typeof xrManager.store.getSnapshot === "function") {
344
+ return xrManager.store.getSnapshot();
345
+ }
346
+ return null;
347
+ }
348
+
349
+ export function bindRendererToXrManager(renderer, xrManager, options = {}) {
350
+ if (!xrManager || typeof xrManager.subscribe !== "function") {
351
+ throw new Error("XR manager must expose subscribe(listener). Use @plasius/gpu-xr createXrManager().");
352
+ }
353
+
354
+ const { onSessionStart, onSessionEnd } = options;
355
+ let previousSession = null;
356
+
357
+ const applyState = (state) => {
358
+ const session = state?.activeSession ?? null;
359
+ if (session === previousSession) {
360
+ return;
361
+ }
362
+
363
+ previousSession = session;
364
+
365
+ if (typeof renderer.setXrActive === "function") {
366
+ renderer.setXrActive(Boolean(session));
367
+ }
368
+
369
+ if (session && typeof onSessionStart === "function") {
370
+ onSessionStart(session, renderer);
371
+ }
372
+
373
+ if (!session && typeof onSessionEnd === "function") {
374
+ onSessionEnd(renderer);
375
+ }
376
+ };
377
+
378
+ applyState(snapshotFromXrManager(xrManager));
379
+ return xrManager.subscribe(applyState);
380
+ }
381
+
382
+ export const defaultRendererClearColor = DEFAULT_CLEAR_COLOR;