multi-gauge 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.
@@ -0,0 +1,153 @@
1
+ import { MultiGaugeError } from '../errors.js';
2
+ import { IconCache } from './IconCache.js';
3
+ import { SHAPE_SHADER, TEXTURE_SHADER } from './shaders.js';
4
+ import { TextAtlas } from './TextAtlas.js';
5
+
6
+ /** One WebGPU device and one set of immutable pipelines for the page. */
7
+ export class SharedGpuRuntime {
8
+ static #promise;
9
+ #adapter;
10
+ #device;
11
+ #format;
12
+ #shapeLayout;
13
+ #textureLayout;
14
+ #shapePipeline;
15
+ #texturePipeline;
16
+ #atlas;
17
+ #icons;
18
+ #clients = new Set();
19
+ #generation = 0;
20
+ #recovering = false;
21
+
22
+ static get() {
23
+ if (!this.#promise) {
24
+ const runtime = new SharedGpuRuntime();
25
+ this.#promise = runtime.#initialize().then(() => runtime);
26
+ }
27
+ return this.#promise;
28
+ }
29
+
30
+ get device() {
31
+ return this.#device;
32
+ }
33
+
34
+ get format() {
35
+ return this.#format;
36
+ }
37
+
38
+ get shapeLayout() {
39
+ return this.#shapeLayout;
40
+ }
41
+
42
+ get textureLayout() {
43
+ return this.#textureLayout;
44
+ }
45
+
46
+ get shapePipeline() {
47
+ return this.#shapePipeline;
48
+ }
49
+
50
+ get texturePipeline() {
51
+ return this.#texturePipeline;
52
+ }
53
+
54
+ get atlas() {
55
+ return this.#atlas;
56
+ }
57
+
58
+ get icons() {
59
+ return this.#icons;
60
+ }
61
+
62
+ get generation() {
63
+ return this.#generation;
64
+ }
65
+
66
+ register(client) {
67
+ this.#clients.add(client);
68
+ }
69
+
70
+ unregister(client) {
71
+ this.#clients.delete(client);
72
+ }
73
+
74
+ async #initialize() {
75
+ if (!globalThis.navigator?.gpu) {
76
+ throw new MultiGaugeError('WebGPU is not available in this browser.');
77
+ }
78
+ this.#adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' });
79
+ if (!this.#adapter) {
80
+ throw new MultiGaugeError('WebGPU is available, but no suitable GPU adapter was found.');
81
+ }
82
+ this.#device = await this.#adapter.requestDevice();
83
+ this.#format = navigator.gpu.getPreferredCanvasFormat();
84
+ this.#createSharedResources();
85
+ this.#atlas = new TextAtlas(this.#device, globalThis.devicePixelRatio || 1);
86
+ await this.#atlas.initialize();
87
+ this.#icons = new IconCache(this.#device);
88
+ this.#generation += 1;
89
+ const observedDevice = this.#device;
90
+ observedDevice.lost.then((info) => this.#handleLoss(observedDevice, info));
91
+ }
92
+
93
+ #createSharedResources() {
94
+ this.#shapeLayout = this.#device.createBindGroupLayout({
95
+ label: 'MultiGauge shape bindings',
96
+ entries: [
97
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'read-only-storage' } },
98
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'read-only-storage' } },
99
+ { binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: 'uniform' } }
100
+ ]
101
+ });
102
+ this.#textureLayout = this.#device.createBindGroupLayout({
103
+ label: 'MultiGauge texture bindings',
104
+ entries: [
105
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } },
106
+ { binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: 'uniform' } },
107
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } },
108
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } }
109
+ ]
110
+ });
111
+ const blend = {
112
+ color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha', operation: 'add' },
113
+ alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' }
114
+ };
115
+ const shapeModule = this.#device.createShaderModule({ label: 'MultiGauge shapes', code: SHAPE_SHADER });
116
+ this.#shapePipeline = this.#device.createRenderPipeline({
117
+ label: 'MultiGauge shape pipeline',
118
+ layout: this.#device.createPipelineLayout({ bindGroupLayouts: [this.#shapeLayout] }),
119
+ vertex: { module: shapeModule, entryPoint: 'vertexMain' },
120
+ fragment: { module: shapeModule, entryPoint: 'fragmentMain', targets: [{ format: this.#format, blend }] },
121
+ primitive: { topology: 'triangle-list' }
122
+ });
123
+ const textureModule = this.#device.createShaderModule({ label: 'MultiGauge textures', code: TEXTURE_SHADER });
124
+ this.#texturePipeline = this.#device.createRenderPipeline({
125
+ label: 'MultiGauge texture pipeline',
126
+ layout: this.#device.createPipelineLayout({ bindGroupLayouts: [this.#textureLayout] }),
127
+ vertex: { module: textureModule, entryPoint: 'vertexMain' },
128
+ fragment: { module: textureModule, entryPoint: 'fragmentMain', targets: [{ format: this.#format, blend }] },
129
+ primitive: { topology: 'triangle-list' }
130
+ });
131
+ }
132
+
133
+ async #handleLoss(device, info) {
134
+ if (device !== this.#device || info.reason === 'destroyed' || this.#recovering) {
135
+ return;
136
+ }
137
+ this.#recovering = true;
138
+ try {
139
+ this.#atlas?.destroy();
140
+ this.#icons?.destroy();
141
+ await this.#initialize();
142
+ for (const client of this.#clients) {
143
+ client.onRuntimeRestored();
144
+ }
145
+ } catch (error) {
146
+ for (const client of this.#clients) {
147
+ client.onRuntimeError(error);
148
+ }
149
+ } finally {
150
+ this.#recovering = false;
151
+ }
152
+ }
153
+ }
@@ -0,0 +1,69 @@
1
+ /** URL-keyed icon textures shared across all panels on the device. */
2
+ export class IconCache {
3
+ #device;
4
+ #entries = new Map();
5
+
6
+ constructor(device) {
7
+ this.#device = device;
8
+ }
9
+
10
+ async acquire(input) {
11
+ if (!input) {
12
+ return null;
13
+ }
14
+ const url = input instanceof URL ? input.href : new URL(String(input), document.baseURI).href;
15
+ let entry = this.#entries.get(url);
16
+ if (!entry) {
17
+ entry = { refs: 0, promise: this.#load(url) };
18
+ this.#entries.set(url, entry);
19
+ }
20
+ entry.refs += 1;
21
+ return entry.promise;
22
+ }
23
+
24
+ release(input) {
25
+ if (!input) {
26
+ return;
27
+ }
28
+ const url = input instanceof URL ? input.href : new URL(String(input), document.baseURI).href;
29
+ const entry = this.#entries.get(url);
30
+ if (!entry) {
31
+ return;
32
+ }
33
+ entry.refs -= 1;
34
+ if (entry.refs <= 0) {
35
+ entry.promise.then((icon) => icon.texture.destroy()).catch(() => {});
36
+ this.#entries.delete(url);
37
+ }
38
+ }
39
+
40
+ destroy() {
41
+ for (const entry of this.#entries.values()) {
42
+ entry.promise.then((icon) => icon.texture.destroy()).catch(() => {});
43
+ }
44
+ this.#entries.clear();
45
+ }
46
+
47
+ async #load(url) {
48
+ const response = await fetch(url);
49
+ if (!response.ok) {
50
+ throw new Error(`Unable to load icon: ${url}`);
51
+ }
52
+ const bitmap = await createImageBitmap(await response.blob(), { premultiplyAlpha: 'premultiply' });
53
+ const width = bitmap.width;
54
+ const height = bitmap.height;
55
+ const texture = this.#device.createTexture({
56
+ label: `MultiGauge icon ${url}`,
57
+ size: [bitmap.width, bitmap.height],
58
+ format: 'rgba8unorm',
59
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT
60
+ });
61
+ this.#device.queue.copyExternalImageToTexture(
62
+ { source: bitmap },
63
+ { texture },
64
+ [bitmap.width, bitmap.height]
65
+ );
66
+ bitmap.close();
67
+ return { url, texture, view: texture.createView(), width, height };
68
+ }
69
+ }
@@ -0,0 +1,370 @@
1
+ import {
2
+ buildDynamicText,
3
+ buildDynamicValues,
4
+ buildStaticScene,
5
+ dynamicTextKey,
6
+ TEXT_INSTANCE_SIZE
7
+ } from './SceneBuilder.js';
8
+ import { color } from '../theme.js';
9
+
10
+ const STORAGE = () => GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST;
11
+
12
+ /** Canvas-specific resources and command encoding. Pipelines live in SharedGpuRuntime. */
13
+ export class Renderer {
14
+ #canvas;
15
+ #context;
16
+ #runtime;
17
+ #invalidate;
18
+ #buffers = Object.create(null);
19
+ #capacities = Object.create(null);
20
+ #shapeBindGroup;
21
+ #staticTextBindGroup;
22
+ #dynamicTextBindGroup;
23
+ #iconBindGroup;
24
+ #shapeCount = 0;
25
+ #staticTextCount = 0;
26
+ #dynamicTextCount = 0;
27
+ #dynamicValuesStaging = new Float32Array(8);
28
+ #dynamicTextStaging = new Float32Array(16);
29
+ #dynamicTextScratch = [];
30
+ #dynamicTextKey;
31
+ #width = 1;
32
+ #height = 1;
33
+ #pixelRatio = 1;
34
+ #iconUrl;
35
+ #icon;
36
+ #iconRect;
37
+ #iconTheme;
38
+ #iconLoading = false;
39
+ #iconRequest = 0;
40
+ #cellLayouts = new Map();
41
+ #destroyed = false;
42
+ #stats = {
43
+ renders: 0,
44
+ writeBufferCalls: 0,
45
+ drawCalls: 0,
46
+ lastFrameTime: 0,
47
+ averageFrameTime: 0
48
+ };
49
+
50
+ constructor(canvas, runtime, invalidate) {
51
+ this.#canvas = canvas;
52
+ this.#runtime = runtime;
53
+ this.#invalidate = invalidate;
54
+ this.#context = canvas.getContext('webgpu');
55
+ if (!this.#context) {
56
+ throw new Error('Unable to create a WebGPU canvas context.');
57
+ }
58
+ this.#runtime.register(this);
59
+ this.#configure();
60
+ this.#createBuffers();
61
+ }
62
+
63
+ get stats() {
64
+ return { ...this.#stats };
65
+ }
66
+
67
+ resize(width, height, pixelRatio = globalThis.devicePixelRatio || 1) {
68
+ const nextWidth = Math.max(1, width);
69
+ const nextHeight = Math.max(1, height);
70
+ const nextRatio = Math.max(1, pixelRatio);
71
+ const changed = nextWidth !== this.#width || nextHeight !== this.#height || nextRatio !== this.#pixelRatio;
72
+ this.#width = nextWidth;
73
+ this.#height = nextHeight;
74
+ this.#pixelRatio = nextRatio;
75
+ const physicalWidth = Math.max(1, Math.round(nextWidth * nextRatio));
76
+ const physicalHeight = Math.max(1, Math.round(nextHeight * nextRatio));
77
+ if (this.#canvas.width !== physicalWidth || this.#canvas.height !== physicalHeight) {
78
+ this.#canvas.width = physicalWidth;
79
+ this.#canvas.height = physicalHeight;
80
+ }
81
+ this.#write('uniform', new Float32Array([nextWidth, nextHeight, 0, 0]));
82
+ return changed;
83
+ }
84
+
85
+ rebuildStatic(gauges, rectangles, header, theme, panelLayout) {
86
+ const scene = buildStaticScene(
87
+ this.#runtime.atlas,
88
+ gauges,
89
+ rectangles,
90
+ header,
91
+ theme,
92
+ panelLayout
93
+ );
94
+ this.#shapeCount = scene.shapes.length / 20;
95
+ this.#staticTextCount = scene.text.length / TEXT_INSTANCE_SIZE;
96
+ this.#cellLayouts = scene.cellLayouts;
97
+ this.#dynamicTextKey = undefined;
98
+ const shapeChanged = this.#ensure('shape', scene.shapes.length * 4, STORAGE());
99
+ const textChanged = this.#ensure('staticText', scene.text.length * 4, STORAGE());
100
+ this.#write('shape', new Float32Array(scene.shapes));
101
+ this.#write('staticText', new Float32Array(scene.text));
102
+ if (shapeChanged || textChanged || !this.#shapeBindGroup) {
103
+ this.#createBindGroups();
104
+ }
105
+ this.#setIcon(header?.icon, theme, scene.iconRect);
106
+ }
107
+
108
+ updateDynamic(gauges, theme) {
109
+ const valueLength = Math.max(8, gauges.length * 8);
110
+ if (this.#dynamicValuesStaging.length !== valueLength) {
111
+ this.#dynamicValuesStaging = new Float32Array(valueLength);
112
+ }
113
+ buildDynamicValues(gauges, theme, this.#dynamicValuesStaging);
114
+ const dynamicChanged = this.#ensure('dynamic', valueLength * 4, STORAGE());
115
+ this.#write('dynamic', this.#dynamicValuesStaging);
116
+
117
+ const nextTextKey = dynamicTextKey(gauges);
118
+ let textChanged = false;
119
+ if (nextTextKey !== this.#dynamicTextKey) {
120
+ buildDynamicText(
121
+ this.#runtime.atlas,
122
+ gauges,
123
+ this.#cellLayouts,
124
+ theme,
125
+ this.#dynamicTextScratch
126
+ );
127
+ const textLength = this.#dynamicTextScratch.length;
128
+ this.#dynamicTextCount = textLength / TEXT_INSTANCE_SIZE;
129
+ if (this.#dynamicTextStaging.length < textLength) {
130
+ let capacity = this.#dynamicTextStaging.length;
131
+ while (capacity < textLength) {
132
+ capacity *= 2;
133
+ }
134
+ this.#dynamicTextStaging = new Float32Array(capacity);
135
+ }
136
+ for (let index = 0; index < textLength; index += 1) {
137
+ this.#dynamicTextStaging[index] = this.#dynamicTextScratch[index];
138
+ }
139
+ textChanged = this.#ensure('dynamicText', textLength * 4, STORAGE());
140
+ this.#write('dynamicText', this.#dynamicTextStaging, textLength * 4);
141
+ this.#dynamicTextKey = nextTextKey;
142
+ }
143
+ if (dynamicChanged || textChanged || !this.#dynamicTextBindGroup) {
144
+ this.#createBindGroups();
145
+ }
146
+ }
147
+
148
+ render(theme) {
149
+ if (this.#destroyed) {
150
+ return;
151
+ }
152
+ const started = performance.now();
153
+ const device = this.#runtime.device;
154
+ const encoder = device.createCommandEncoder({ label: 'MultiGauge frame' });
155
+ const background = color(theme.background);
156
+ const pass = encoder.beginRenderPass({
157
+ label: 'MultiGauge canvas pass',
158
+ colorAttachments: [{
159
+ view: this.#context.getCurrentTexture().createView(),
160
+ clearValue: { r: background[0], g: background[1], b: background[2], a: background[3] },
161
+ loadOp: 'clear',
162
+ storeOp: 'store'
163
+ }]
164
+ });
165
+ let draws = 0;
166
+ if (this.#shapeCount > 0) {
167
+ pass.setPipeline(this.#runtime.shapePipeline);
168
+ pass.setBindGroup(0, this.#shapeBindGroup);
169
+ pass.draw(6, this.#shapeCount);
170
+ draws += 1;
171
+ }
172
+ pass.setPipeline(this.#runtime.texturePipeline);
173
+ if (this.#staticTextCount > 0) {
174
+ pass.setBindGroup(0, this.#staticTextBindGroup);
175
+ pass.draw(6, this.#staticTextCount);
176
+ draws += 1;
177
+ }
178
+ if (this.#dynamicTextCount > 0) {
179
+ pass.setBindGroup(0, this.#dynamicTextBindGroup);
180
+ pass.draw(6, this.#dynamicTextCount);
181
+ draws += 1;
182
+ }
183
+ if (this.#iconBindGroup) {
184
+ pass.setBindGroup(0, this.#iconBindGroup);
185
+ pass.draw(6, 1);
186
+ draws += 1;
187
+ }
188
+ pass.end();
189
+ device.queue.submit([encoder.finish()]);
190
+ const frameTime = performance.now() - started;
191
+ this.#stats.renders += 1;
192
+ this.#stats.drawCalls += draws;
193
+ this.#stats.lastFrameTime = frameTime;
194
+ this.#stats.averageFrameTime += (frameTime - this.#stats.averageFrameTime) / this.#stats.renders;
195
+ }
196
+
197
+ onRuntimeRestored() {
198
+ if (this.#destroyed) {
199
+ return;
200
+ }
201
+ for (const key of Object.keys(this.#buffers)) {
202
+ this.#buffers[key] = null;
203
+ this.#capacities[key] = 0;
204
+ }
205
+ this.#shapeBindGroup = null;
206
+ this.#staticTextBindGroup = null;
207
+ this.#dynamicTextBindGroup = null;
208
+ this.#dynamicTextKey = undefined;
209
+ this.#iconBindGroup = null;
210
+ this.#icon = null;
211
+ this.#iconLoading = false;
212
+ this.#configure();
213
+ this.#createBuffers();
214
+ this.#invalidate(true);
215
+ }
216
+
217
+ onRuntimeError(error) {
218
+ console.error('MultiGauge could not recover its WebGPU device.', error);
219
+ }
220
+
221
+ destroy() {
222
+ if (this.#destroyed) {
223
+ return;
224
+ }
225
+ this.#destroyed = true;
226
+ this.#runtime.unregister(this);
227
+ this.#runtime.icons.release(this.#iconUrl);
228
+ for (const buffer of Object.values(this.#buffers)) {
229
+ buffer?.destroy();
230
+ }
231
+ this.#context.unconfigure();
232
+ }
233
+
234
+ #configure() {
235
+ this.#context.configure({
236
+ device: this.#runtime.device,
237
+ format: this.#runtime.format,
238
+ alphaMode: 'premultiplied'
239
+ });
240
+ }
241
+
242
+ #createBuffers() {
243
+ this.#ensure('shape', 16, STORAGE());
244
+ this.#ensure('dynamic', 32, STORAGE());
245
+ this.#ensure('staticText', 16, STORAGE());
246
+ this.#ensure('dynamicText', 16, STORAGE());
247
+ this.#ensure('uniform', 16, GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
248
+ this.#ensure('icon', TEXT_INSTANCE_SIZE * 4, STORAGE());
249
+ this.#createBindGroups();
250
+ }
251
+
252
+ #ensure(name, byteLength, usage) {
253
+ const required = Math.max(16, Math.ceil(byteLength / 16) * 16);
254
+ if (this.#buffers[name] && this.#capacities[name] >= required) {
255
+ return false;
256
+ }
257
+ this.#buffers[name]?.destroy();
258
+ let capacity = 16;
259
+ while (capacity < required) {
260
+ capacity *= 2;
261
+ }
262
+ this.#buffers[name] = this.#runtime.device.createBuffer({
263
+ label: `MultiGauge ${name}`,
264
+ size: capacity,
265
+ usage
266
+ });
267
+ this.#capacities[name] = capacity;
268
+ return true;
269
+ }
270
+
271
+ #write(name, data, byteLength = data.byteLength) {
272
+ if (byteLength === 0) {
273
+ return;
274
+ }
275
+ const source = ArrayBuffer.isView(data) ? data.buffer : data;
276
+ const sourceOffset = ArrayBuffer.isView(data) ? data.byteOffset : 0;
277
+ this.#runtime.device.queue.writeBuffer(
278
+ this.#buffers[name],
279
+ 0,
280
+ source,
281
+ sourceOffset,
282
+ byteLength
283
+ );
284
+ this.#stats.writeBufferCalls += 1;
285
+ }
286
+
287
+ #createBindGroups() {
288
+ const device = this.#runtime.device;
289
+ this.#shapeBindGroup = device.createBindGroup({
290
+ layout: this.#runtime.shapeLayout,
291
+ entries: [
292
+ { binding: 0, resource: { buffer: this.#buffers.shape } },
293
+ { binding: 1, resource: { buffer: this.#buffers.dynamic } },
294
+ { binding: 2, resource: { buffer: this.#buffers.uniform } }
295
+ ]
296
+ });
297
+ this.#staticTextBindGroup = this.#textBindGroup(this.#buffers.staticText, this.#runtime.atlas.view);
298
+ this.#dynamicTextBindGroup = this.#textBindGroup(this.#buffers.dynamicText, this.#runtime.atlas.view);
299
+ if (this.#icon) {
300
+ this.#iconBindGroup = this.#textBindGroup(this.#buffers.icon, this.#icon.view);
301
+ }
302
+ }
303
+
304
+ #textBindGroup(buffer, view) {
305
+ return this.#runtime.device.createBindGroup({
306
+ layout: this.#runtime.textureLayout,
307
+ entries: [
308
+ { binding: 0, resource: { buffer } },
309
+ { binding: 1, resource: { buffer: this.#buffers.uniform } },
310
+ { binding: 2, resource: view },
311
+ { binding: 3, resource: this.#runtime.atlas.sampler }
312
+ ]
313
+ });
314
+ }
315
+
316
+ #setIcon(url, theme, rect) {
317
+ this.#iconRect = rect;
318
+ this.#iconTheme = theme;
319
+ if (url === this.#iconUrl) {
320
+ if (this.#icon) {
321
+ this.#writeIcon(rect, theme);
322
+ }
323
+ if (this.#icon || this.#iconLoading || !url) {
324
+ return;
325
+ }
326
+ } else {
327
+ this.#runtime.icons.release(this.#iconUrl);
328
+ this.#iconUrl = url;
329
+ this.#icon = null;
330
+ this.#iconBindGroup = null;
331
+ }
332
+ const request = ++this.#iconRequest;
333
+ if (!url) {
334
+ return;
335
+ }
336
+ this.#iconLoading = true;
337
+ this.#runtime.icons.acquire(url).then((icon) => {
338
+ if (this.#destroyed || request !== this.#iconRequest) {
339
+ this.#runtime.icons.release(url);
340
+ return;
341
+ }
342
+ this.#iconLoading = false;
343
+ this.#icon = icon;
344
+ this.#writeIcon(this.#iconRect, this.#iconTheme);
345
+ this.#iconBindGroup = this.#textBindGroup(this.#buffers.icon, icon.view);
346
+ this.#invalidate(false);
347
+ }).catch((error) => {
348
+ if (request === this.#iconRequest) {
349
+ this.#iconLoading = false;
350
+ }
351
+ console.warn(error.message);
352
+ });
353
+ }
354
+
355
+ #writeIcon(rect, theme) {
356
+ if (!rect) {
357
+ return;
358
+ }
359
+ const rgba = color(theme.accent);
360
+ this.#write('icon', new Float32Array([
361
+ rect.x + rect.width / 2,
362
+ rect.y + rect.height / 2,
363
+ rect.width / 2,
364
+ rect.height / 2,
365
+ 0, 0, 1, 1,
366
+ ...rgba,
367
+ 1, 0, 0, 0
368
+ ]));
369
+ }
370
+ }