@aethervtt/shard-gpu 0.0.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.
- package/LICENSE +21 -0
- package/README.md +3 -0
- package/dist/buffer.d.ts +32 -0
- package/dist/buffer.js +64 -0
- package/dist/caches.d.ts +36 -0
- package/dist/caches.js +111 -0
- package/dist/context.d.ts +66 -0
- package/dist/context.js +173 -0
- package/dist/errors.d.ts +4 -0
- package/dist/errors.js +16 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +21 -0
- package/dist/key.d.ts +5 -0
- package/dist/key.js +27 -0
- package/dist/node.d.ts +5 -0
- package/dist/node.js +20 -0
- package/dist/probe.d.ts +38 -0
- package/dist/probe.js +80 -0
- package/package.json +45 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Patrick Dias
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
package/dist/buffer.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { GpuContext } from './context.js';
|
|
2
|
+
export interface GpuBufferOptions {
|
|
3
|
+
label: string;
|
|
4
|
+
usage: GPUBufferUsageFlags;
|
|
5
|
+
/** Initial size in bytes. Default 256. */
|
|
6
|
+
size?: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* A GPU buffer that grows. `ensureCapacity` doubles the size when needed; `version` increments
|
|
10
|
+
* every time the underlying `GPUBuffer` is replaced, so bind groups built on it know to rebuild.
|
|
11
|
+
* Contents are not preserved across growth or device loss: rewrite them each frame.
|
|
12
|
+
*/
|
|
13
|
+
export declare class GpuBuffer {
|
|
14
|
+
readonly label: string;
|
|
15
|
+
readonly usage: GPUBufferUsageFlags;
|
|
16
|
+
version: number;
|
|
17
|
+
private current;
|
|
18
|
+
private size;
|
|
19
|
+
private generation;
|
|
20
|
+
private readonly gpu;
|
|
21
|
+
constructor(gpu: GpuContext, options: GpuBufferOptions);
|
|
22
|
+
get buffer(): GPUBuffer;
|
|
23
|
+
get byteLength(): number;
|
|
24
|
+
/** Grows to at least `bytes`. Returns true if the buffer was replaced. */
|
|
25
|
+
ensureCapacity(bytes: number): boolean;
|
|
26
|
+
/** Writes a TypedArray (or a slice of it, in elements) at a byte offset, growing if needed. */
|
|
27
|
+
write(data: ArrayBufferView & ArrayLike<number>, byteOffset?: number, start?: number, count?: number): void;
|
|
28
|
+
destroy(): void;
|
|
29
|
+
private create;
|
|
30
|
+
/** After device loss the old buffer is gone; make a new one on the new device. */
|
|
31
|
+
private checkGeneration;
|
|
32
|
+
}
|
package/dist/buffer.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
class GpuBuffer {
|
|
2
|
+
label;
|
|
3
|
+
usage;
|
|
4
|
+
version = 0;
|
|
5
|
+
current;
|
|
6
|
+
size;
|
|
7
|
+
generation;
|
|
8
|
+
gpu;
|
|
9
|
+
constructor(gpu, options) {
|
|
10
|
+
this.gpu = gpu;
|
|
11
|
+
this.label = options.label;
|
|
12
|
+
this.usage = options.usage | GPUBufferUsage.COPY_DST;
|
|
13
|
+
this.size = align4(Math.max(16, options.size ?? 256));
|
|
14
|
+
this.generation = gpu.generation;
|
|
15
|
+
this.current = this.create(this.size);
|
|
16
|
+
}
|
|
17
|
+
get buffer() {
|
|
18
|
+
this.checkGeneration();
|
|
19
|
+
return this.current;
|
|
20
|
+
}
|
|
21
|
+
get byteLength() {
|
|
22
|
+
return this.size;
|
|
23
|
+
}
|
|
24
|
+
/** Grows to at least `bytes`. Returns true if the buffer was replaced. */
|
|
25
|
+
ensureCapacity(bytes) {
|
|
26
|
+
this.checkGeneration();
|
|
27
|
+
if (bytes <= this.size) return false;
|
|
28
|
+
let size = this.size;
|
|
29
|
+
while (size < bytes) size *= 2;
|
|
30
|
+
this.current.destroy();
|
|
31
|
+
this.size = size;
|
|
32
|
+
this.current = this.create(size);
|
|
33
|
+
this.version++;
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
/** Writes a TypedArray (or a slice of it, in elements) at a byte offset, growing if needed. */
|
|
37
|
+
write(data, byteOffset = 0, start = 0, count) {
|
|
38
|
+
const elements = count ?? data.length - start;
|
|
39
|
+
const bytesPerElement = data.BYTES_PER_ELEMENT;
|
|
40
|
+
const bytes = align4(elements * bytesPerElement);
|
|
41
|
+
this.ensureCapacity(byteOffset + bytes);
|
|
42
|
+
if (elements > 0)
|
|
43
|
+
this.gpu.device.queue.writeBuffer(this.current, byteOffset, data, start, elements);
|
|
44
|
+
}
|
|
45
|
+
destroy() {
|
|
46
|
+
this.current.destroy();
|
|
47
|
+
}
|
|
48
|
+
create(size) {
|
|
49
|
+
return this.gpu.device.createBuffer({ label: this.label, size, usage: this.usage });
|
|
50
|
+
}
|
|
51
|
+
/** After device loss the old buffer is gone; make a new one on the new device. */
|
|
52
|
+
checkGeneration() {
|
|
53
|
+
if (this.generation === this.gpu.generation) return;
|
|
54
|
+
this.generation = this.gpu.generation;
|
|
55
|
+
this.current = this.create(this.size);
|
|
56
|
+
this.version++;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function align4(n) {
|
|
60
|
+
return n + 3 & ~3;
|
|
61
|
+
}
|
|
62
|
+
export {
|
|
63
|
+
GpuBuffer
|
|
64
|
+
};
|
package/dist/caches.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ShardError } from '@aethervtt/shard-core';
|
|
2
|
+
import type { GpuContext } from './context.js';
|
|
3
|
+
/**
|
|
4
|
+
* Render and compute pipelines, created asynchronously and cached by descriptor. `render()` returns
|
|
5
|
+
* undefined until the pipeline is ready; callers skip that draw for the frame instead of stalling.
|
|
6
|
+
*/
|
|
7
|
+
export declare class PipelineCache {
|
|
8
|
+
/** Draws skipped because their pipeline was still compiling. Reset by the renderer each frame. */
|
|
9
|
+
skipped: number;
|
|
10
|
+
private readonly render_;
|
|
11
|
+
private readonly compute_;
|
|
12
|
+
private readonly gpu;
|
|
13
|
+
constructor(gpu: GpuContext);
|
|
14
|
+
render(descriptor: GPURenderPipelineDescriptor): GPURenderPipeline | undefined;
|
|
15
|
+
compute(descriptor: GPUComputePipelineDescriptor): GPUComputePipeline | undefined;
|
|
16
|
+
/** Pipelines still compiling. */
|
|
17
|
+
get pending(): number;
|
|
18
|
+
/** Resolves when every pipeline requested so far has finished compiling (or failed). */
|
|
19
|
+
whenIdle(): Promise<void>;
|
|
20
|
+
/** Errors from pipelines that failed to compile, by label. */
|
|
21
|
+
failures(): ShardError[];
|
|
22
|
+
clear(): void;
|
|
23
|
+
private get;
|
|
24
|
+
}
|
|
25
|
+
/** Bind group layouts, pipeline layouts, and samplers: equal descriptors give the same object. */
|
|
26
|
+
export declare class LayoutCache {
|
|
27
|
+
private readonly bindGroupLayouts;
|
|
28
|
+
private readonly pipelineLayouts;
|
|
29
|
+
private readonly samplers;
|
|
30
|
+
private readonly gpu;
|
|
31
|
+
constructor(gpu: GpuContext);
|
|
32
|
+
bindGroupLayout(descriptor: GPUBindGroupLayoutDescriptor): GPUBindGroupLayout;
|
|
33
|
+
pipelineLayout(descriptor: GPUPipelineLayoutDescriptor): GPUPipelineLayout;
|
|
34
|
+
sampler(descriptor?: GPUSamplerDescriptor): GPUSampler;
|
|
35
|
+
clear(): void;
|
|
36
|
+
}
|
package/dist/caches.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { toShardError } from "./errors.js";
|
|
2
|
+
import { descriptorKey } from "./key.js";
|
|
3
|
+
class PipelineCache {
|
|
4
|
+
/** Draws skipped because their pipeline was still compiling. Reset by the renderer each frame. */
|
|
5
|
+
skipped = 0;
|
|
6
|
+
render_ = /* @__PURE__ */ new Map();
|
|
7
|
+
compute_ = /* @__PURE__ */ new Map();
|
|
8
|
+
gpu;
|
|
9
|
+
constructor(gpu) {
|
|
10
|
+
this.gpu = gpu;
|
|
11
|
+
}
|
|
12
|
+
render(descriptor) {
|
|
13
|
+
return this.get(this.render_, descriptor, (d) => this.gpu.device.createRenderPipelineAsync(d));
|
|
14
|
+
}
|
|
15
|
+
compute(descriptor) {
|
|
16
|
+
return this.get(this.compute_, descriptor, (d) => this.gpu.device.createComputePipelineAsync(d));
|
|
17
|
+
}
|
|
18
|
+
/** Pipelines still compiling. */
|
|
19
|
+
get pending() {
|
|
20
|
+
let n = 0;
|
|
21
|
+
for (const s of this.render_.values()) if (s.status === "pending") n++;
|
|
22
|
+
for (const s of this.compute_.values()) if (s.status === "pending") n++;
|
|
23
|
+
return n;
|
|
24
|
+
}
|
|
25
|
+
/** Resolves when every pipeline requested so far has finished compiling (or failed). */
|
|
26
|
+
async whenIdle() {
|
|
27
|
+
const pending = [];
|
|
28
|
+
for (const s of [...this.render_.values(), ...this.compute_.values()]) {
|
|
29
|
+
if (s.status === "pending") pending.push(s.promise);
|
|
30
|
+
}
|
|
31
|
+
await Promise.all(pending);
|
|
32
|
+
}
|
|
33
|
+
/** Errors from pipelines that failed to compile, by label. */
|
|
34
|
+
failures() {
|
|
35
|
+
const out = [];
|
|
36
|
+
for (const s of [...this.render_.values(), ...this.compute_.values()]) {
|
|
37
|
+
if (s.status === "failed") out.push(s.error);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
clear() {
|
|
42
|
+
this.render_.clear();
|
|
43
|
+
this.compute_.clear();
|
|
44
|
+
}
|
|
45
|
+
get(map, descriptor, create) {
|
|
46
|
+
const key = descriptorKey(descriptor);
|
|
47
|
+
const state = map.get(key);
|
|
48
|
+
if (state?.status === "ready") return state.pipeline;
|
|
49
|
+
if (state) {
|
|
50
|
+
this.skipped++;
|
|
51
|
+
return void 0;
|
|
52
|
+
}
|
|
53
|
+
const promise = create(descriptor).then(
|
|
54
|
+
(pipeline) => {
|
|
55
|
+
map.set(key, { status: "ready", pipeline });
|
|
56
|
+
},
|
|
57
|
+
(err) => {
|
|
58
|
+
const error = toShardError(err, descriptor.label);
|
|
59
|
+
map.set(key, { status: "failed", error });
|
|
60
|
+
this.gpu.reportError(error);
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
map.set(key, { status: "pending", promise });
|
|
64
|
+
this.skipped++;
|
|
65
|
+
return void 0;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
class LayoutCache {
|
|
69
|
+
bindGroupLayouts = /* @__PURE__ */ new Map();
|
|
70
|
+
pipelineLayouts = /* @__PURE__ */ new Map();
|
|
71
|
+
samplers = /* @__PURE__ */ new Map();
|
|
72
|
+
gpu;
|
|
73
|
+
constructor(gpu) {
|
|
74
|
+
this.gpu = gpu;
|
|
75
|
+
}
|
|
76
|
+
bindGroupLayout(descriptor) {
|
|
77
|
+
return cached(
|
|
78
|
+
this.bindGroupLayouts,
|
|
79
|
+
descriptor,
|
|
80
|
+
() => this.gpu.device.createBindGroupLayout(descriptor)
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
pipelineLayout(descriptor) {
|
|
84
|
+
return cached(
|
|
85
|
+
this.pipelineLayouts,
|
|
86
|
+
descriptor,
|
|
87
|
+
() => this.gpu.device.createPipelineLayout(descriptor)
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
sampler(descriptor = {}) {
|
|
91
|
+
return cached(this.samplers, descriptor, () => this.gpu.device.createSampler(descriptor));
|
|
92
|
+
}
|
|
93
|
+
clear() {
|
|
94
|
+
this.bindGroupLayouts.clear();
|
|
95
|
+
this.pipelineLayouts.clear();
|
|
96
|
+
this.samplers.clear();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function cached(map, descriptor, create) {
|
|
100
|
+
const key = descriptorKey(descriptor);
|
|
101
|
+
let value = map.get(key);
|
|
102
|
+
if (!value) {
|
|
103
|
+
value = create();
|
|
104
|
+
map.set(key, value);
|
|
105
|
+
}
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
export {
|
|
109
|
+
LayoutCache,
|
|
110
|
+
PipelineCache
|
|
111
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { ShardError } from '@aethervtt/shard-core';
|
|
2
|
+
import { LayoutCache, PipelineCache } from './caches.js';
|
|
3
|
+
import { type GpuErrorListener } from './errors.js';
|
|
4
|
+
export interface CreateGpuContextOptions {
|
|
5
|
+
/** Omit for offscreen/headless rendering. */
|
|
6
|
+
canvas?: HTMLCanvasElement | OffscreenCanvas;
|
|
7
|
+
/** The WebGPU entry point. Defaults to `navigator.gpu`; in Node pass the `webgpu` package's. */
|
|
8
|
+
gpu?: GPU;
|
|
9
|
+
/** Requested if the adapter supports them. */
|
|
10
|
+
features?: GPUFeatureName[];
|
|
11
|
+
/** Fails with `gpu/missing-feature` if the adapter lacks any. */
|
|
12
|
+
requiredFeatures?: GPUFeatureName[];
|
|
13
|
+
powerPreference?: GPUPowerPreference;
|
|
14
|
+
/**
|
|
15
|
+
* Request block-compressed texture formats (BC, ASTC, ETC2) when the adapter has them. Default
|
|
16
|
+
* true; Basis textures transcode to whichever the device ends up with.
|
|
17
|
+
*/
|
|
18
|
+
compressedTextures?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface DeviceLostInfo {
|
|
21
|
+
reason: string;
|
|
22
|
+
message: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The adapter, device, and optional canvas, plus the caches every renderer needs. After a device
|
|
26
|
+
* loss, `recreate()` gets a new device and bumps `generation`; `GpuBuffer`s and caches rebuild
|
|
27
|
+
* against it.
|
|
28
|
+
*/
|
|
29
|
+
export declare class GpuContext {
|
|
30
|
+
adapter: GPUAdapter;
|
|
31
|
+
device: GPUDevice;
|
|
32
|
+
readonly format: GPUTextureFormat;
|
|
33
|
+
readonly canvas: HTMLCanvasElement | OffscreenCanvas | undefined;
|
|
34
|
+
readonly context: GPUCanvasContext | undefined;
|
|
35
|
+
readonly features: ReadonlySet<string>;
|
|
36
|
+
readonly pipelines: PipelineCache;
|
|
37
|
+
readonly layouts: LayoutCache;
|
|
38
|
+
/** Increments when the device is replaced. */
|
|
39
|
+
generation: number;
|
|
40
|
+
/** Canvas pixels per CSS pixel (devicePixelRatio) at the last resize; 1 without a canvas. */
|
|
41
|
+
pixelRatio: number;
|
|
42
|
+
/** Most recent GPU errors, newest last. */
|
|
43
|
+
readonly errors: ShardError[];
|
|
44
|
+
private readonly options;
|
|
45
|
+
private readonly errorListeners;
|
|
46
|
+
private readonly lostListeners;
|
|
47
|
+
constructor(options: CreateGpuContextOptions, adapter: GPUAdapter, device: GPUDevice, format: GPUTextureFormat, context: GPUCanvasContext | undefined);
|
|
48
|
+
/** Match the canvas backing size to its CSS size. Returns true if it changed. */
|
|
49
|
+
resize(): boolean;
|
|
50
|
+
onError(listener: GpuErrorListener): () => void;
|
|
51
|
+
onDeviceLost(listener: (info: DeviceLostInfo) => void): () => void;
|
|
52
|
+
reportError(error: ShardError): void;
|
|
53
|
+
/**
|
|
54
|
+
* Runs `fn` inside a validation error scope. Errors are reported asynchronously (WebGPU
|
|
55
|
+
* resolves error scopes later) with `label` naming the object.
|
|
56
|
+
*/
|
|
57
|
+
validate<T>(label: string, fn: () => T): T;
|
|
58
|
+
/** Gets a fresh adapter and device after a loss, and reconfigures the canvas. */
|
|
59
|
+
recreate(): Promise<void>;
|
|
60
|
+
/** For tests and debugging: destroys the device and reports it as an unexpected loss. */
|
|
61
|
+
simulateDeviceLoss(): void;
|
|
62
|
+
destroy(): void;
|
|
63
|
+
private attach;
|
|
64
|
+
private handleLoss;
|
|
65
|
+
}
|
|
66
|
+
export declare function createGpuContext(options?: CreateGpuContextOptions): Promise<GpuContext>;
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { ShardError } from "@aethervtt/shard-core";
|
|
2
|
+
import { LayoutCache, PipelineCache } from "./caches.js";
|
|
3
|
+
import { toShardError } from "./errors.js";
|
|
4
|
+
const COMPRESSED_TEXTURE_FEATURES = [
|
|
5
|
+
"texture-compression-bc",
|
|
6
|
+
"texture-compression-astc",
|
|
7
|
+
"texture-compression-etc2"
|
|
8
|
+
];
|
|
9
|
+
const RENDERER_FEATURES = [
|
|
10
|
+
"rg11b10ufloat-renderable",
|
|
11
|
+
"indirect-first-instance",
|
|
12
|
+
"float32-filterable"
|
|
13
|
+
];
|
|
14
|
+
const MAX_ERRORS = 50;
|
|
15
|
+
class GpuContext {
|
|
16
|
+
adapter;
|
|
17
|
+
device;
|
|
18
|
+
format;
|
|
19
|
+
canvas;
|
|
20
|
+
context;
|
|
21
|
+
features;
|
|
22
|
+
pipelines;
|
|
23
|
+
layouts;
|
|
24
|
+
/** Increments when the device is replaced. */
|
|
25
|
+
generation = 0;
|
|
26
|
+
/** Canvas pixels per CSS pixel (devicePixelRatio) at the last resize; 1 without a canvas. */
|
|
27
|
+
pixelRatio = 1;
|
|
28
|
+
/** Most recent GPU errors, newest last. */
|
|
29
|
+
errors = [];
|
|
30
|
+
options;
|
|
31
|
+
errorListeners = /* @__PURE__ */ new Set();
|
|
32
|
+
lostListeners = /* @__PURE__ */ new Set();
|
|
33
|
+
constructor(options, adapter, device, format, context) {
|
|
34
|
+
this.options = options;
|
|
35
|
+
this.adapter = adapter;
|
|
36
|
+
this.device = device;
|
|
37
|
+
this.format = format;
|
|
38
|
+
this.canvas = options.canvas;
|
|
39
|
+
this.context = context;
|
|
40
|
+
this.features = new Set(device.features);
|
|
41
|
+
this.pipelines = new PipelineCache(this);
|
|
42
|
+
this.layouts = new LayoutCache(this);
|
|
43
|
+
this.attach(device);
|
|
44
|
+
}
|
|
45
|
+
/** Match the canvas backing size to its CSS size. Returns true if it changed. */
|
|
46
|
+
resize() {
|
|
47
|
+
const canvas = this.canvas;
|
|
48
|
+
if (!canvas || !("clientWidth" in canvas)) return false;
|
|
49
|
+
const dpr = globalThis.devicePixelRatio ?? 1;
|
|
50
|
+
this.pixelRatio = dpr;
|
|
51
|
+
const width = Math.max(1, Math.floor(canvas.clientWidth * dpr));
|
|
52
|
+
const height = Math.max(1, Math.floor(canvas.clientHeight * dpr));
|
|
53
|
+
if (canvas.width === width && canvas.height === height) return false;
|
|
54
|
+
canvas.width = width;
|
|
55
|
+
canvas.height = height;
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
onError(listener) {
|
|
59
|
+
this.errorListeners.add(listener);
|
|
60
|
+
return () => this.errorListeners.delete(listener);
|
|
61
|
+
}
|
|
62
|
+
onDeviceLost(listener) {
|
|
63
|
+
this.lostListeners.add(listener);
|
|
64
|
+
return () => this.lostListeners.delete(listener);
|
|
65
|
+
}
|
|
66
|
+
reportError(error) {
|
|
67
|
+
this.errors.push(error);
|
|
68
|
+
if (this.errors.length > MAX_ERRORS) this.errors.shift();
|
|
69
|
+
for (const listener of this.errorListeners) listener(error);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Runs `fn` inside a validation error scope. Errors are reported asynchronously (WebGPU
|
|
73
|
+
* resolves error scopes later) with `label` naming the object.
|
|
74
|
+
*/
|
|
75
|
+
validate(label, fn) {
|
|
76
|
+
this.device.pushErrorScope("validation");
|
|
77
|
+
const result = fn();
|
|
78
|
+
void this.device.popErrorScope().then((error) => {
|
|
79
|
+
if (error) this.reportError(toShardError(error, label));
|
|
80
|
+
});
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
/** Gets a fresh adapter and device after a loss, and reconfigures the canvas. */
|
|
84
|
+
async recreate() {
|
|
85
|
+
const { adapter, device } = await requestDevice(this.options);
|
|
86
|
+
this.adapter = adapter;
|
|
87
|
+
this.device = device;
|
|
88
|
+
this.context?.configure(canvasConfig(device, this.format));
|
|
89
|
+
this.pipelines.clear();
|
|
90
|
+
this.layouts.clear();
|
|
91
|
+
this.generation++;
|
|
92
|
+
this.attach(device);
|
|
93
|
+
}
|
|
94
|
+
/** For tests and debugging: destroys the device and reports it as an unexpected loss. */
|
|
95
|
+
simulateDeviceLoss() {
|
|
96
|
+
this.handleLoss({ reason: "unknown", message: "Simulated device loss" });
|
|
97
|
+
this.device.destroy();
|
|
98
|
+
}
|
|
99
|
+
destroy() {
|
|
100
|
+
this.lostListeners.clear();
|
|
101
|
+
this.device.destroy();
|
|
102
|
+
}
|
|
103
|
+
attach(device) {
|
|
104
|
+
device.addEventListener("uncapturederror", (event) => {
|
|
105
|
+
this.reportError(toShardError(event.error, void 0));
|
|
106
|
+
});
|
|
107
|
+
void device.lost.then((info) => {
|
|
108
|
+
if (info.reason !== "destroyed" && device === this.device) {
|
|
109
|
+
this.handleLoss({ reason: info.reason, message: info.message });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
handleLoss(info) {
|
|
114
|
+
for (const listener of this.lostListeners) listener(info);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function canvasConfig(device, format) {
|
|
118
|
+
return {
|
|
119
|
+
device,
|
|
120
|
+
format,
|
|
121
|
+
alphaMode: "opaque",
|
|
122
|
+
// COPY_SRC lets the renderer capture screenshots straight from the swapchain.
|
|
123
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
async function requestDevice(options) {
|
|
127
|
+
const gpu = options.gpu ?? globalThis.navigator?.gpu;
|
|
128
|
+
if (!gpu) {
|
|
129
|
+
throw new ShardError("gpu/unsupported", "WebGPU is not available in this environment", {
|
|
130
|
+
hint: "Use a browser or webview with WebGPU, or pass `gpu` (e.g. from the `webgpu` package in Node)."
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
const adapter = await gpu.requestAdapter({
|
|
134
|
+
powerPreference: options.powerPreference ?? "high-performance"
|
|
135
|
+
});
|
|
136
|
+
if (!adapter) throw new ShardError("gpu/no-adapter", "No WebGPU adapter was found");
|
|
137
|
+
const missing = (options.requiredFeatures ?? []).filter((f) => !adapter.features.has(f));
|
|
138
|
+
if (missing.length > 0) {
|
|
139
|
+
throw new ShardError(
|
|
140
|
+
"gpu/missing-feature",
|
|
141
|
+
`The GPU lacks required features: ${missing.join(", ")}`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
const requiredFeatures = [
|
|
145
|
+
...options.requiredFeatures ?? [],
|
|
146
|
+
...[
|
|
147
|
+
...options.features ?? [],
|
|
148
|
+
...RENDERER_FEATURES,
|
|
149
|
+
...options.compressedTextures === false ? [] : COMPRESSED_TEXTURE_FEATURES
|
|
150
|
+
].filter((f, i, all) => all.indexOf(f) === i && adapter.features.has(f))
|
|
151
|
+
];
|
|
152
|
+
const device = await adapter.requestDevice({ label: "shard", requiredFeatures });
|
|
153
|
+
return { adapter, device };
|
|
154
|
+
}
|
|
155
|
+
async function createGpuContext(options = {}) {
|
|
156
|
+
const { adapter, device } = await requestDevice(options);
|
|
157
|
+
const gpu = options.gpu ?? globalThis.navigator.gpu;
|
|
158
|
+
const format = gpu.getPreferredCanvasFormat();
|
|
159
|
+
let context;
|
|
160
|
+
if (options.canvas) {
|
|
161
|
+
const ctx = options.canvas.getContext("webgpu");
|
|
162
|
+
if (!ctx) throw new ShardError("gpu/no-context", "Could not get a WebGPU canvas context");
|
|
163
|
+
ctx.configure(canvasConfig(device, format));
|
|
164
|
+
context = ctx;
|
|
165
|
+
}
|
|
166
|
+
const result = new GpuContext(options, adapter, device, format, context);
|
|
167
|
+
result.resize();
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
export {
|
|
171
|
+
GpuContext,
|
|
172
|
+
createGpuContext
|
|
173
|
+
};
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { ShardError } from '@aethervtt/shard-core';
|
|
2
|
+
export type GpuErrorListener = (error: ShardError) => void;
|
|
3
|
+
/** Turns a WebGPU error into a ShardError that names the object involved. */
|
|
4
|
+
export declare function toShardError(error: GPUError | Error, label: string | undefined): ShardError;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ShardError } from "@aethervtt/shard-core";
|
|
2
|
+
function toShardError(error, label) {
|
|
3
|
+
const what = label ? `"${label}"` : "an unlabeled GPU object";
|
|
4
|
+
const isOom = typeof GPUOutOfMemoryError !== "undefined" && error instanceof GPUOutOfMemoryError;
|
|
5
|
+
return new ShardError(
|
|
6
|
+
isOom ? "gpu/out-of-memory" : "gpu/validation",
|
|
7
|
+
`WebGPU error in ${what}: ${error.message}`,
|
|
8
|
+
{
|
|
9
|
+
path: label,
|
|
10
|
+
hint: label ? void 0 : "Give every GPU object a label so errors can name it."
|
|
11
|
+
}
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
export {
|
|
15
|
+
toShardError
|
|
16
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { GpuBuffer, type GpuBufferOptions } from './buffer.js';
|
|
2
|
+
export { LayoutCache, PipelineCache } from './caches.js';
|
|
3
|
+
export { type CreateGpuContextOptions, createGpuContext, type DeviceLostInfo, GpuContext, } from './context.js';
|
|
4
|
+
export { type GpuErrorListener, toShardError } from './errors.js';
|
|
5
|
+
export { descriptorKey } from './key.js';
|
|
6
|
+
export { type ProbeWebGpuOptions, probeWebGpu, type WebGpuSupport, type WebGpuUnsupportedReason, } from './probe.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { GpuBuffer } from "./buffer.js";
|
|
2
|
+
import { LayoutCache, PipelineCache } from "./caches.js";
|
|
3
|
+
import {
|
|
4
|
+
createGpuContext,
|
|
5
|
+
GpuContext
|
|
6
|
+
} from "./context.js";
|
|
7
|
+
import { toShardError } from "./errors.js";
|
|
8
|
+
import { descriptorKey } from "./key.js";
|
|
9
|
+
import {
|
|
10
|
+
probeWebGpu
|
|
11
|
+
} from "./probe.js";
|
|
12
|
+
export {
|
|
13
|
+
GpuBuffer,
|
|
14
|
+
GpuContext,
|
|
15
|
+
LayoutCache,
|
|
16
|
+
PipelineCache,
|
|
17
|
+
createGpuContext,
|
|
18
|
+
descriptorKey,
|
|
19
|
+
probeWebGpu,
|
|
20
|
+
toShardError
|
|
21
|
+
};
|
package/dist/key.d.ts
ADDED
package/dist/key.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const ids = /* @__PURE__ */ new WeakMap();
|
|
2
|
+
let nextId = 1;
|
|
3
|
+
function idOf(obj) {
|
|
4
|
+
let id = ids.get(obj);
|
|
5
|
+
if (id === void 0) {
|
|
6
|
+
id = nextId++;
|
|
7
|
+
ids.set(obj, id);
|
|
8
|
+
}
|
|
9
|
+
return id;
|
|
10
|
+
}
|
|
11
|
+
function isPlain(value) {
|
|
12
|
+
const proto = Object.getPrototypeOf(value);
|
|
13
|
+
return proto === Object.prototype || proto === null || Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
function descriptorKey(descriptor) {
|
|
16
|
+
return JSON.stringify(descriptor, (key, value) => {
|
|
17
|
+
if (key === "label") return void 0;
|
|
18
|
+
if (value !== null && typeof value === "object" && !isPlain(value)) {
|
|
19
|
+
if (ArrayBuffer.isView(value)) return Array.from(value);
|
|
20
|
+
return `#${idOf(value)}`;
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
export {
|
|
26
|
+
descriptorKey
|
|
27
|
+
};
|
package/dist/node.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type CreateGpuContextOptions, type GpuContext } from './context.js';
|
|
2
|
+
/** Defines `GPUBufferUsage`, `GPUTextureUsage`, etc. on globalThis, as browsers do. */
|
|
3
|
+
export declare function installWebGpuGlobals(): void;
|
|
4
|
+
export declare function nodeGpu(): GPU;
|
|
5
|
+
export declare function createNodeGpuContext(options?: Omit<CreateGpuContextOptions, 'gpu'>): Promise<GpuContext>;
|
package/dist/node.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { create, globals } from "webgpu";
|
|
2
|
+
import { createGpuContext } from "./context.js";
|
|
3
|
+
let installed = false;
|
|
4
|
+
function installWebGpuGlobals() {
|
|
5
|
+
if (installed) return;
|
|
6
|
+
Object.assign(globalThis, globals);
|
|
7
|
+
installed = true;
|
|
8
|
+
}
|
|
9
|
+
function nodeGpu() {
|
|
10
|
+
installWebGpuGlobals();
|
|
11
|
+
return create([]);
|
|
12
|
+
}
|
|
13
|
+
function createNodeGpuContext(options = {}) {
|
|
14
|
+
return createGpuContext({ ...options, gpu: nodeGpu() });
|
|
15
|
+
}
|
|
16
|
+
export {
|
|
17
|
+
createNodeGpuContext,
|
|
18
|
+
installWebGpuGlobals,
|
|
19
|
+
nodeGpu
|
|
20
|
+
};
|
package/dist/probe.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ShardError } from '@aethervtt/shard-core';
|
|
2
|
+
export interface ProbeWebGpuOptions {
|
|
3
|
+
/** The WebGPU entry point. Defaults to `navigator.gpu`; in Node pass the `webgpu` package's. */
|
|
4
|
+
gpu?: GPU;
|
|
5
|
+
/** Features the app can't run without. Missing any makes the result unsupported. */
|
|
6
|
+
requiredFeatures?: GPUFeatureName[];
|
|
7
|
+
powerPreference?: GPUPowerPreference;
|
|
8
|
+
/** Gives up after this long: some drivers never answer `requestAdapter`. Default 5000 ms. */
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
}
|
|
11
|
+
export type WebGpuUnsupportedReason = 'no-webgpu' | 'no-adapter' | 'missing-feature' | 'device-failed' | 'timeout';
|
|
12
|
+
export interface WebGpuSupport {
|
|
13
|
+
supported: boolean;
|
|
14
|
+
/** Why not, when unsupported. */
|
|
15
|
+
reason?: WebGpuUnsupportedReason;
|
|
16
|
+
/** The same failure as a ShardError, with a hint a host can show. */
|
|
17
|
+
error?: ShardError;
|
|
18
|
+
adapter?: {
|
|
19
|
+
vendor: string;
|
|
20
|
+
architecture: string;
|
|
21
|
+
description: string;
|
|
22
|
+
};
|
|
23
|
+
/** Every feature the adapter offers. */
|
|
24
|
+
features: string[];
|
|
25
|
+
/** Required features the adapter lacks. */
|
|
26
|
+
missing: string[];
|
|
27
|
+
limits?: {
|
|
28
|
+
maxTextureDimension2D: number;
|
|
29
|
+
maxBufferSize: number;
|
|
30
|
+
maxStorageBufferBindingSize: number;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Checks, before mounting anything, whether this client can run Shard: WebGPU present, an
|
|
35
|
+
* adapter, the required features, and a device that really opens (it's destroyed again). Never
|
|
36
|
+
* throws, so a host can show an honest message instead of a blank canvas.
|
|
37
|
+
*/
|
|
38
|
+
export declare function probeWebGpu(options?: ProbeWebGpuOptions): Promise<WebGpuSupport>;
|
package/dist/probe.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { ShardError } from "@aethervtt/shard-core";
|
|
2
|
+
const HINTS = {
|
|
3
|
+
"no-webgpu": "This browser has no WebGPU. Use a current Chrome, Edge, or Safari, or enable WebGPU in its settings.",
|
|
4
|
+
"no-adapter": "WebGPU is present but found no usable GPU. It may be blocklisted or disabled.",
|
|
5
|
+
"missing-feature": "The GPU lacks a feature this app requires.",
|
|
6
|
+
"device-failed": "The GPU was found but refused to create a device.",
|
|
7
|
+
timeout: "The GPU did not answer in time. Try reloading, or updating the graphics driver."
|
|
8
|
+
};
|
|
9
|
+
const MESSAGES = {
|
|
10
|
+
"no-webgpu": "WebGPU is not available in this environment",
|
|
11
|
+
"no-adapter": "No WebGPU adapter was found",
|
|
12
|
+
"missing-feature": "The GPU lacks required features",
|
|
13
|
+
"device-failed": "Could not create a WebGPU device",
|
|
14
|
+
timeout: "WebGPU did not respond in time"
|
|
15
|
+
};
|
|
16
|
+
async function probeWebGpu(options = {}) {
|
|
17
|
+
const gpu = options.gpu ?? globalThis.navigator?.gpu;
|
|
18
|
+
const required = options.requiredFeatures ?? [];
|
|
19
|
+
if (!gpu) return unsupported("no-webgpu", [], []);
|
|
20
|
+
const timeoutMs = options.timeoutMs ?? 5e3;
|
|
21
|
+
let timer;
|
|
22
|
+
const timeout = new Promise((resolve) => {
|
|
23
|
+
timer = setTimeout(() => resolve("timeout"), timeoutMs);
|
|
24
|
+
});
|
|
25
|
+
try {
|
|
26
|
+
const adapter = await Promise.race([
|
|
27
|
+
gpu.requestAdapter({ powerPreference: options.powerPreference ?? "high-performance" }),
|
|
28
|
+
timeout
|
|
29
|
+
]).catch(() => null);
|
|
30
|
+
if (adapter === "timeout") return unsupported("timeout", [], []);
|
|
31
|
+
if (!adapter) return unsupported("no-adapter", [], []);
|
|
32
|
+
const features = [...adapter.features].map(String).sort();
|
|
33
|
+
const missing = required.filter((f) => !adapter.features.has(f));
|
|
34
|
+
const info = adapter.info;
|
|
35
|
+
const base = {
|
|
36
|
+
features,
|
|
37
|
+
missing,
|
|
38
|
+
adapter: {
|
|
39
|
+
vendor: info?.vendor ?? "",
|
|
40
|
+
architecture: info?.architecture ?? "",
|
|
41
|
+
description: info?.description ?? ""
|
|
42
|
+
},
|
|
43
|
+
limits: {
|
|
44
|
+
maxTextureDimension2D: adapter.limits.maxTextureDimension2D,
|
|
45
|
+
maxBufferSize: adapter.limits.maxBufferSize,
|
|
46
|
+
maxStorageBufferBindingSize: adapter.limits.maxStorageBufferBindingSize
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
if (missing.length > 0) {
|
|
50
|
+
return {
|
|
51
|
+
...unsupported("missing-feature", features, missing, missing.join(", ")),
|
|
52
|
+
adapter: base.adapter,
|
|
53
|
+
limits: base.limits
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const device = await Promise.race([
|
|
57
|
+
adapter.requestDevice({ label: "shard-probe", requiredFeatures: required }),
|
|
58
|
+
timeout
|
|
59
|
+
]).catch(() => null);
|
|
60
|
+
if (device === "timeout") return { ...unsupported("timeout", features, []), ...base };
|
|
61
|
+
if (!device) return { ...unsupported("device-failed", features, []), ...base };
|
|
62
|
+
device.destroy();
|
|
63
|
+
return { supported: true, ...base };
|
|
64
|
+
} finally {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function unsupported(reason, features, missing, detail) {
|
|
69
|
+
const message = detail ? `${MESSAGES[reason]}: ${detail}` : MESSAGES[reason];
|
|
70
|
+
return {
|
|
71
|
+
supported: false,
|
|
72
|
+
reason,
|
|
73
|
+
error: new ShardError(`gpu/${reason}`, message, { hint: HINTS[reason] }),
|
|
74
|
+
features,
|
|
75
|
+
missing
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export {
|
|
79
|
+
probeWebGpu
|
|
80
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aethervtt/shard-gpu",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./node": {
|
|
13
|
+
"types": "./dist/node.d.ts",
|
|
14
|
+
"default": "./dist/node.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"LICENSE",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@aethervtt/shard-core": "0.0.1"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/aethervtt/shard.git",
|
|
28
|
+
"directory": "packages/gpu"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public",
|
|
32
|
+
"provenance": true
|
|
33
|
+
},
|
|
34
|
+
"shard": {
|
|
35
|
+
"buildSha": "d6c5c6aef4675575c878b02cf2c8f738b4190229"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"webgpu": "0.6.1"
|
|
39
|
+
},
|
|
40
|
+
"peerDependenciesMeta": {
|
|
41
|
+
"webgpu": {
|
|
42
|
+
"optional": true
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|