@lineandvertexsoftware/renderer-webgpu 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,970 @@
1
+ import { initWebGPU } from "./initDevice.js";
2
+ import scatterWGSL from "./shaders/scatter/scatter.wgsl?raw";
3
+ import lineWGSL from "./shaders/line/line.wgsl?raw";
4
+ import pickWGSL from "./shaders/scatter/scatter_pick.wgsl?raw";
5
+ import hoverWGSL from "./shaders/scatter/scatter_hover.wgsl?raw";
6
+ export class WebGPURenderer {
7
+ canvas;
8
+ device;
9
+ context;
10
+ format;
11
+ scatterPipeline;
12
+ linePipeline;
13
+ hoverPipeline;
14
+ uniformsBuf;
15
+ hoverUniformsBuf;
16
+ bindGroup;
17
+ lineBindGroup;
18
+ hoverBindGroup;
19
+ quadBuf;
20
+ lineQuadBuf;
21
+ markerLayers = [];
22
+ lineLayers = [];
23
+ // ---- Per-layer uniform buffers (FIX for color issue) ----
24
+ markerUniformsBufs = [];
25
+ markerBindGroups = [];
26
+ lineUniformsBufs = [];
27
+ lineBindGroups = [];
28
+ // ---- pick ----
29
+ pickPipeline;
30
+ pickUniformsBufs = []; // Pool of buffers, one per layer
31
+ pickBindGroups = []; // Corresponding bind groups
32
+ pickTex;
33
+ pickTexW = 0;
34
+ pickTexH = 0;
35
+ pickReadback;
36
+ pickReadbackSize = 0;
37
+ // ---- hover highlight ----
38
+ hoverBuf;
39
+ hoverActive = false;
40
+ hoverInnerRgba = [0, 0, 0, 0];
41
+ hoverOutlineRgba = [0, 0, 0, 0];
42
+ hoverSizePx = 6;
43
+ // ---- LOD ----
44
+ enableLOD = true;
45
+ lodThreshold = 50000; // Start LOD when total points > this
46
+ // ---- Performance stats ----
47
+ stats = {
48
+ lastRenderMs: 0,
49
+ avgRenderMs: 0,
50
+ lastPickMs: 0,
51
+ avgPickMs: 0,
52
+ frameCount: 0,
53
+ effectiveSampledPoints: 0
54
+ };
55
+ renderTimes = [];
56
+ pickTimes = [];
57
+ maxTimeSamples = 60;
58
+ async mount(init) {
59
+ this.canvas = init.canvas;
60
+ const { device, context, format } = await initWebGPU(this.canvas);
61
+ this.device = device;
62
+ this.context = context;
63
+ this.format = format;
64
+ this.context.configure({
65
+ device: this.device,
66
+ format: this.format,
67
+ alphaMode: "premultiplied"
68
+ });
69
+ this.createPipelines();
70
+ this.createQuadBuffer();
71
+ this.createLineQuadBuffer();
72
+ this.createHoverBuffer();
73
+ this.createUniforms();
74
+ }
75
+ setLayers(scene) {
76
+ // Clean up old marker resources
77
+ for (const m of this.markerLayers)
78
+ m.buf.destroy();
79
+ for (const buf of this.markerUniformsBufs)
80
+ buf.destroy();
81
+ this.markerLayers = [];
82
+ this.markerUniformsBufs = [];
83
+ this.markerBindGroups = [];
84
+ // Clean up old line resources
85
+ for (const l of this.lineLayers)
86
+ l.buf.destroy();
87
+ for (const buf of this.lineUniformsBufs)
88
+ buf.destroy();
89
+ this.lineLayers = [];
90
+ this.lineUniformsBufs = [];
91
+ this.lineBindGroups = [];
92
+ // Clean up old pick uniforms/bind groups (bind groups reference marker buffers)
93
+ for (const buf of this.pickUniformsBufs)
94
+ buf.destroy();
95
+ this.pickUniformsBufs = [];
96
+ this.pickBindGroups = [];
97
+ // Create marker layers with per-layer uniform buffers
98
+ for (const m of scene.markers) {
99
+ const count = m.points01.length / 2;
100
+ const buf = this.device.createBuffer({
101
+ size: m.points01.byteLength,
102
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
103
+ });
104
+ this.device.queue.writeBuffer(buf, 0, m.points01.buffer, m.points01.byteOffset, m.points01.byteLength);
105
+ this.markerLayers.push({ buf, count, pointSizePx: m.pointSizePx, rgba: m.rgba, baseId: m.baseId });
106
+ // Create dedicated uniform buffer for this layer
107
+ const uniformBuf = this.device.createBuffer({
108
+ size: 80,
109
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
110
+ });
111
+ this.markerUniformsBufs.push(uniformBuf);
112
+ const bindGroup = this.device.createBindGroup({
113
+ layout: this.scatterPipeline.getBindGroupLayout(0),
114
+ entries: [
115
+ { binding: 0, resource: { buffer: uniformBuf } },
116
+ { binding: 1, resource: { buffer: buf } }
117
+ ]
118
+ });
119
+ this.markerBindGroups.push(bindGroup);
120
+ }
121
+ // Create line layers with per-layer uniform buffers
122
+ for (const l of scene.lines) {
123
+ const segments = this.buildLineSegments(l.points01);
124
+ const segmentCount = segments.length / 4;
125
+ if (segmentCount < 1)
126
+ continue;
127
+ const buf = this.device.createBuffer({
128
+ size: segments.byteLength,
129
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST
130
+ });
131
+ this.device.queue.writeBuffer(buf, 0, segments.buffer, segments.byteOffset, segments.byteLength);
132
+ const { dashPattern, dashCount } = this.resolveDashPattern(l.dash);
133
+ this.lineLayers.push({
134
+ buf,
135
+ segmentCount,
136
+ rgba: l.rgba,
137
+ widthPx: Math.max(0.5, l.widthPx ?? 1),
138
+ dashPattern,
139
+ dashCount
140
+ });
141
+ // Create dedicated uniform buffer for this layer
142
+ const uniformBuf = this.device.createBuffer({
143
+ size: 96,
144
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
145
+ });
146
+ this.lineUniformsBufs.push(uniformBuf);
147
+ const bindGroup = this.device.createBindGroup({
148
+ layout: this.linePipeline.getBindGroupLayout(0),
149
+ entries: [{ binding: 0, resource: { buffer: uniformBuf } }]
150
+ });
151
+ this.lineBindGroups.push(bindGroup);
152
+ }
153
+ }
154
+ setHoverHighlight(args) {
155
+ if (!args) {
156
+ this.hoverActive = false;
157
+ return;
158
+ }
159
+ this.hoverActive = true;
160
+ this.hoverSizePx = args.sizePx ?? 7;
161
+ this.hoverInnerRgba = args.innerRgba;
162
+ this.hoverOutlineRgba = args.outlineRgba ?? [0, 0, 0, 0.55];
163
+ const a = new Float32Array([args.point01[0], args.point01[1]]);
164
+ this.device.queue.writeBuffer(this.hoverBuf, 0, a);
165
+ }
166
+ setLOD(enabled) {
167
+ this.enableLOD = enabled;
168
+ }
169
+ getStats() {
170
+ return { ...this.stats };
171
+ }
172
+ destroy() {
173
+ for (const m of this.markerLayers)
174
+ m.buf.destroy();
175
+ for (const l of this.lineLayers)
176
+ l.buf.destroy();
177
+ for (const buf of this.markerUniformsBufs)
178
+ buf.destroy();
179
+ for (const buf of this.lineUniformsBufs)
180
+ buf.destroy();
181
+ for (const buf of this.pickUniformsBufs)
182
+ buf.destroy();
183
+ this.uniformsBuf?.destroy?.();
184
+ this.hoverUniformsBuf?.destroy?.();
185
+ this.quadBuf?.destroy?.();
186
+ this.lineQuadBuf?.destroy?.();
187
+ this.hoverBuf?.destroy?.();
188
+ this.pickTex?.destroy?.();
189
+ if (this.pickReadback) {
190
+ try {
191
+ if (this.pickReadback.mapState === "mapped") {
192
+ this.pickReadback.unmap();
193
+ }
194
+ }
195
+ catch {
196
+ // best-effort cleanup
197
+ }
198
+ this.pickReadback.destroy();
199
+ }
200
+ this.context?.unconfigure?.();
201
+ this.markerLayers = [];
202
+ this.lineLayers = [];
203
+ this.markerUniformsBufs = [];
204
+ this.markerBindGroups = [];
205
+ this.lineUniformsBufs = [];
206
+ this.lineBindGroups = [];
207
+ this.pickUniformsBufs = [];
208
+ this.pickBindGroups = [];
209
+ this.pickTexW = 0;
210
+ this.pickTexH = 0;
211
+ this.pickReadbackSize = 0;
212
+ this.hoverActive = false;
213
+ this.renderTimes = [];
214
+ this.pickTimes = [];
215
+ }
216
+ render(frame) {
217
+ const t0 = performance.now();
218
+ const w = Math.max(1, Math.floor(frame.width * frame.dpr));
219
+ const h = Math.max(1, Math.floor(frame.height * frame.dpr));
220
+ if (this.canvas.width !== w || this.canvas.height !== h) {
221
+ this.canvas.width = w;
222
+ this.canvas.height = h;
223
+ this.context.configure({ device: this.device, format: this.format, alphaMode: "premultiplied" });
224
+ }
225
+ const plotW = Math.max(1, frame.width - frame.padding.l - frame.padding.r);
226
+ const plotH = Math.max(1, frame.height - frame.padding.t - frame.padding.b);
227
+ const canvasSize = [w, h];
228
+ const plotOrigin = [frame.padding.l * frame.dpr, frame.padding.t * frame.dpr];
229
+ const plotSize = [plotW * frame.dpr, plotH * frame.dpr];
230
+ const zoom = [frame.zoom.k, frame.zoom.x * frame.dpr, frame.zoom.y * frame.dpr];
231
+ // Calculate LOD stride based on zoom level
232
+ const totalPoints = this.markerLayers.reduce((sum, l) => sum + l.count, 0);
233
+ const lodStride = this.calculateLODStride(totalPoints, frame.zoom.k);
234
+ const view = this.context.getCurrentTexture().createView();
235
+ const encoder = this.device.createCommandEncoder();
236
+ const pass = encoder.beginRenderPass({
237
+ colorAttachments: [
238
+ {
239
+ view,
240
+ // Transparent clear so SVG grid underlay remains visible beneath traces.
241
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
242
+ loadOp: "clear",
243
+ storeOp: "store"
244
+ }
245
+ ]
246
+ });
247
+ pass.setScissorRect(Math.floor(frame.padding.l * frame.dpr), // x
248
+ Math.floor(frame.padding.t * frame.dpr), // y
249
+ Math.floor(plotW * frame.dpr), // width
250
+ Math.floor(plotH * frame.dpr) // height
251
+ );
252
+ // Lines - now using per-layer bind groups
253
+ for (let i = 0; i < this.lineLayers.length; i++) {
254
+ const layer = this.lineLayers[i];
255
+ if (layer.segmentCount < 1)
256
+ continue;
257
+ this.writeLineUniformsToBuffer(this.lineUniformsBufs[i], {
258
+ canvasSize,
259
+ plotOrigin,
260
+ plotSize,
261
+ zoom,
262
+ lineWidthPx: layer.widthPx * frame.dpr,
263
+ rgba: layer.rgba,
264
+ dashPattern: [
265
+ layer.dashPattern[0] * frame.dpr,
266
+ layer.dashPattern[1] * frame.dpr,
267
+ layer.dashPattern[2] * frame.dpr,
268
+ layer.dashPattern[3] * frame.dpr
269
+ ],
270
+ dashCount: layer.dashCount
271
+ });
272
+ pass.setBindGroup(0, this.lineBindGroups[i]);
273
+ pass.setPipeline(this.linePipeline);
274
+ pass.setVertexBuffer(0, this.lineQuadBuf);
275
+ pass.setVertexBuffer(1, layer.buf);
276
+ pass.draw(6, layer.segmentCount, 0, 0);
277
+ }
278
+ let effectiveSampledPoints = 0;
279
+ // Markers with LOD - now using per-layer bind groups
280
+ for (let i = 0; i < this.markerLayers.length; i++) {
281
+ const layer = this.markerLayers[i];
282
+ if (layer.count < 1)
283
+ continue;
284
+ this.writeUniformsToBuffer(this.markerUniformsBufs[i], {
285
+ canvasSize,
286
+ plotOrigin,
287
+ plotSize,
288
+ zoom,
289
+ pointSizePx: layer.pointSizePx * frame.dpr,
290
+ rgba: layer.rgba,
291
+ pointCount: layer.count,
292
+ lodStride,
293
+ lodOffset: this.calculateLODOffset(layer.baseId, lodStride)
294
+ });
295
+ pass.setBindGroup(0, this.markerBindGroups[i]);
296
+ pass.setPipeline(this.scatterPipeline);
297
+ pass.setVertexBuffer(0, this.quadBuf);
298
+ // Apply LOD by reducing instance count
299
+ const lodOffset = this.calculateLODOffset(layer.baseId, lodStride);
300
+ const instanceCount = this.calculateLODInstanceCount(layer.count, lodStride, lodOffset);
301
+ effectiveSampledPoints += instanceCount;
302
+ pass.draw(6, instanceCount, 0, 0);
303
+ }
304
+ // Hover highlight on top (single pass with ring shader)
305
+ if (this.hoverActive) {
306
+ this.writeHoverUniforms({
307
+ canvasSize,
308
+ plotOrigin,
309
+ plotSize,
310
+ zoom,
311
+ pointSizePx: this.hoverSizePx * frame.dpr,
312
+ innerRgba: this.hoverInnerRgba,
313
+ outlineRgba: this.hoverOutlineRgba
314
+ });
315
+ pass.setBindGroup(0, this.hoverBindGroup);
316
+ pass.setPipeline(this.hoverPipeline);
317
+ pass.setVertexBuffer(0, this.quadBuf);
318
+ pass.setVertexBuffer(1, this.hoverBuf);
319
+ pass.draw(6, 1, 0, 0);
320
+ }
321
+ pass.end();
322
+ this.device.queue.submit([encoder.finish()]);
323
+ // Update stats
324
+ const elapsed = performance.now() - t0;
325
+ this.stats.lastRenderMs = elapsed;
326
+ this.stats.frameCount++;
327
+ this.stats.effectiveSampledPoints = effectiveSampledPoints;
328
+ this.renderTimes.push(elapsed);
329
+ if (this.renderTimes.length > this.maxTimeSamples)
330
+ this.renderTimes.shift();
331
+ this.stats.avgRenderMs = this.renderTimes.reduce((a, b) => a + b, 0) / this.renderTimes.length;
332
+ }
333
+ async captureFrameImageData(frame) {
334
+ const w = Math.max(1, Math.floor(frame.width * frame.dpr));
335
+ const h = Math.max(1, Math.floor(frame.height * frame.dpr));
336
+ const targetTex = this.device.createTexture({
337
+ size: { width: w, height: h },
338
+ format: this.format,
339
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC
340
+ });
341
+ const bytesPerRow = Math.ceil((w * 4) / 256) * 256;
342
+ const readback = this.device.createBuffer({
343
+ size: bytesPerRow * h,
344
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
345
+ });
346
+ const plotW = Math.max(1, frame.width - frame.padding.l - frame.padding.r);
347
+ const plotH = Math.max(1, frame.height - frame.padding.t - frame.padding.b);
348
+ const canvasSize = [w, h];
349
+ const plotOrigin = [frame.padding.l * frame.dpr, frame.padding.t * frame.dpr];
350
+ const plotSize = [plotW * frame.dpr, plotH * frame.dpr];
351
+ const zoom = [frame.zoom.k, frame.zoom.x * frame.dpr, frame.zoom.y * frame.dpr];
352
+ const totalPoints = this.markerLayers.reduce((sum, l) => sum + l.count, 0);
353
+ const lodStride = this.calculateLODStride(totalPoints, frame.zoom.k);
354
+ const encoder = this.device.createCommandEncoder();
355
+ const pass = encoder.beginRenderPass({
356
+ colorAttachments: [
357
+ {
358
+ view: targetTex.createView(),
359
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
360
+ loadOp: "clear",
361
+ storeOp: "store"
362
+ }
363
+ ]
364
+ });
365
+ pass.setScissorRect(Math.floor(frame.padding.l * frame.dpr), Math.floor(frame.padding.t * frame.dpr), Math.floor(plotW * frame.dpr), Math.floor(plotH * frame.dpr));
366
+ for (let i = 0; i < this.lineLayers.length; i++) {
367
+ const layer = this.lineLayers[i];
368
+ if (layer.segmentCount < 1)
369
+ continue;
370
+ this.writeLineUniformsToBuffer(this.lineUniformsBufs[i], {
371
+ canvasSize,
372
+ plotOrigin,
373
+ plotSize,
374
+ zoom,
375
+ lineWidthPx: layer.widthPx * frame.dpr,
376
+ rgba: layer.rgba,
377
+ dashPattern: [
378
+ layer.dashPattern[0] * frame.dpr,
379
+ layer.dashPattern[1] * frame.dpr,
380
+ layer.dashPattern[2] * frame.dpr,
381
+ layer.dashPattern[3] * frame.dpr
382
+ ],
383
+ dashCount: layer.dashCount
384
+ });
385
+ pass.setBindGroup(0, this.lineBindGroups[i]);
386
+ pass.setPipeline(this.linePipeline);
387
+ pass.setVertexBuffer(0, this.lineQuadBuf);
388
+ pass.setVertexBuffer(1, layer.buf);
389
+ pass.draw(6, layer.segmentCount, 0, 0);
390
+ }
391
+ for (let i = 0; i < this.markerLayers.length; i++) {
392
+ const layer = this.markerLayers[i];
393
+ if (layer.count < 1)
394
+ continue;
395
+ const lodOffset = this.calculateLODOffset(layer.baseId, lodStride);
396
+ this.writeUniformsToBuffer(this.markerUniformsBufs[i], {
397
+ canvasSize,
398
+ plotOrigin,
399
+ plotSize,
400
+ zoom,
401
+ pointSizePx: layer.pointSizePx * frame.dpr,
402
+ rgba: layer.rgba,
403
+ pointCount: layer.count,
404
+ lodStride,
405
+ lodOffset
406
+ });
407
+ pass.setBindGroup(0, this.markerBindGroups[i]);
408
+ pass.setPipeline(this.scatterPipeline);
409
+ pass.setVertexBuffer(0, this.quadBuf);
410
+ pass.draw(6, this.calculateLODInstanceCount(layer.count, lodStride, lodOffset), 0, 0);
411
+ }
412
+ if (this.hoverActive) {
413
+ this.writeHoverUniforms({
414
+ canvasSize,
415
+ plotOrigin,
416
+ plotSize,
417
+ zoom,
418
+ pointSizePx: this.hoverSizePx * frame.dpr,
419
+ innerRgba: this.hoverInnerRgba,
420
+ outlineRgba: this.hoverOutlineRgba
421
+ });
422
+ pass.setBindGroup(0, this.hoverBindGroup);
423
+ pass.setPipeline(this.hoverPipeline);
424
+ pass.setVertexBuffer(0, this.quadBuf);
425
+ pass.setVertexBuffer(1, this.hoverBuf);
426
+ pass.draw(6, 1, 0, 0);
427
+ }
428
+ pass.end();
429
+ encoder.copyTextureToBuffer({ texture: targetTex, origin: { x: 0, y: 0 } }, { buffer: readback, bytesPerRow }, { width: w, height: h });
430
+ this.device.queue.submit([encoder.finish()]);
431
+ await readback.mapAsync(GPUMapMode.READ);
432
+ const mapped = new Uint8Array(readback.getMappedRange());
433
+ const rgba = new Uint8ClampedArray(w * h * 4);
434
+ const isBgra = String(this.format).startsWith("bgra8");
435
+ for (let y = 0; y < h; y++) {
436
+ const rowOffset = y * bytesPerRow;
437
+ const outOffset = y * w * 4;
438
+ for (let x = 0; x < w; x++) {
439
+ const src = rowOffset + x * 4;
440
+ const dst = outOffset + x * 4;
441
+ if (isBgra) {
442
+ rgba[dst + 0] = mapped[src + 2];
443
+ rgba[dst + 1] = mapped[src + 1];
444
+ rgba[dst + 2] = mapped[src + 0];
445
+ rgba[dst + 3] = mapped[src + 3];
446
+ }
447
+ else {
448
+ rgba[dst + 0] = mapped[src + 0];
449
+ rgba[dst + 1] = mapped[src + 1];
450
+ rgba[dst + 2] = mapped[src + 2];
451
+ rgba[dst + 3] = mapped[src + 3];
452
+ }
453
+ }
454
+ }
455
+ readback.unmap();
456
+ readback.destroy();
457
+ targetTex.destroy();
458
+ return new ImageData(rgba, w, h);
459
+ }
460
+ async pick(frame, xCss, yCss) {
461
+ const t0 = performance.now();
462
+ const W = 5;
463
+ const H = 5;
464
+ this.ensurePickTarget(W, H);
465
+ const bytesPerRow = this.ensurePickReadback(W, H);
466
+ const encoder = this.device.createCommandEncoder();
467
+ const view = this.pickTex.createView();
468
+ const pass = encoder.beginRenderPass({
469
+ colorAttachments: [{
470
+ view,
471
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
472
+ loadOp: "clear",
473
+ storeOp: "store"
474
+ }]
475
+ });
476
+ const plotW = Math.max(1, frame.width - frame.padding.l - frame.padding.r);
477
+ const plotH = Math.max(1, frame.height - frame.padding.t - frame.padding.b);
478
+ const cx = Math.floor(xCss * frame.dpr);
479
+ const cy = Math.floor(yCss * frame.dpr);
480
+ const hw = Math.floor(W / 2);
481
+ const hh = Math.floor(H / 2);
482
+ const canvasSize = [W, H];
483
+ const plotOriginX = (frame.padding.l * frame.dpr) - (cx - hw);
484
+ const plotOriginY = (frame.padding.t * frame.dpr) - (cy - hh);
485
+ const plotOrigin = [plotOriginX, plotOriginY];
486
+ const plotSize = [plotW * frame.dpr, plotH * frame.dpr];
487
+ const zoom = [frame.zoom.k, frame.zoom.x * frame.dpr, frame.zoom.y * frame.dpr];
488
+ const totalPoints = this.markerLayers.reduce((sum, l) => sum + l.count, 0);
489
+ const lodStride = this.calculateLODStride(totalPoints, frame.zoom.k);
490
+ for (let i = 0; i < this.markerLayers.length; i++) {
491
+ const layer = this.markerLayers[i];
492
+ if (layer.count < 1)
493
+ continue;
494
+ const lodOffset = this.calculateLODOffset(layer.baseId, lodStride);
495
+ const { buffer, bindGroup } = this.ensurePickBuffer(i, layer.buf);
496
+ this.writePickUniforms(buffer, {
497
+ canvasSize,
498
+ plotOrigin,
499
+ plotSize,
500
+ zoom,
501
+ pointSizePx: layer.pointSizePx * frame.dpr,
502
+ baseId: layer.baseId,
503
+ pointCount: layer.count,
504
+ lodStride,
505
+ lodOffset
506
+ });
507
+ pass.setBindGroup(0, bindGroup);
508
+ pass.setPipeline(this.pickPipeline);
509
+ pass.setVertexBuffer(0, this.quadBuf);
510
+ const instanceCount = this.calculateLODInstanceCount(layer.count, lodStride, lodOffset);
511
+ pass.draw(6, instanceCount, 0, 0);
512
+ }
513
+ pass.end();
514
+ encoder.copyTextureToBuffer({ texture: this.pickTex, origin: { x: 0, y: 0 } }, { buffer: this.pickReadback, bytesPerRow }, { width: W, height: H });
515
+ this.device.queue.submit([encoder.finish()]);
516
+ await this.pickReadback.mapAsync(GPUMapMode.READ);
517
+ const data = new Uint8Array(this.pickReadback.getMappedRange().slice(0));
518
+ this.pickReadback.unmap();
519
+ const centerIdx = (Math.floor(H / 2)) * bytesPerRow + (Math.floor(W / 2)) * 4;
520
+ const r = data[centerIdx];
521
+ const g = data[centerIdx + 1];
522
+ const b = data[centerIdx + 2];
523
+ const id = r | (g << 8) | (b << 16);
524
+ // Update stats
525
+ const elapsed = performance.now() - t0;
526
+ this.stats.lastPickMs = elapsed;
527
+ this.pickTimes.push(elapsed);
528
+ if (this.pickTimes.length > this.maxTimeSamples)
529
+ this.pickTimes.shift();
530
+ this.stats.avgPickMs = this.pickTimes.reduce((a, b) => a + b, 0) / this.pickTimes.length;
531
+ return id;
532
+ }
533
+ calculateLODStride(totalPoints, zoomK) {
534
+ if (!this.enableLOD || totalPoints <= this.lodThreshold)
535
+ return 1;
536
+ const baseStride = Math.ceil(totalPoints / this.lodThreshold);
537
+ const zoomAdjusted = Math.max(1, Math.ceil(baseStride / zoomK));
538
+ return Math.max(1, zoomAdjusted);
539
+ }
540
+ calculateLODOffset(baseId, stride) {
541
+ if (stride <= 1)
542
+ return 0;
543
+ const hash = ((baseId >>> 0) * 2654435761) >>> 0;
544
+ return hash % stride;
545
+ }
546
+ calculateLODInstanceCount(total, stride, offset) {
547
+ if (total <= 0)
548
+ return 0;
549
+ if (stride <= 1)
550
+ return total;
551
+ if (offset >= total)
552
+ return 0;
553
+ return Math.floor((total - 1 - offset) / stride) + 1;
554
+ }
555
+ // ----------------------------
556
+ // Pipelines
557
+ // ----------------------------
558
+ createPipelines() {
559
+ // Scatter pipeline
560
+ {
561
+ const module = this.device.createShaderModule({ code: scatterWGSL });
562
+ this.scatterPipeline = this.device.createRenderPipeline({
563
+ layout: "auto",
564
+ vertex: {
565
+ module,
566
+ entryPoint: "vs_main",
567
+ buffers: [
568
+ {
569
+ arrayStride: 2 * 4,
570
+ stepMode: "vertex",
571
+ attributes: [{ shaderLocation: 0, offset: 0, format: "float32x2" }]
572
+ }
573
+ ]
574
+ },
575
+ fragment: {
576
+ module,
577
+ entryPoint: "fs_main",
578
+ targets: [
579
+ {
580
+ format: this.format,
581
+ blend: {
582
+ color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
583
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" }
584
+ }
585
+ }
586
+ ]
587
+ },
588
+ primitive: { topology: "triangle-list" }
589
+ });
590
+ }
591
+ // Line pipeline
592
+ {
593
+ const module = this.device.createShaderModule({ code: lineWGSL });
594
+ this.linePipeline = this.device.createRenderPipeline({
595
+ layout: "auto",
596
+ vertex: {
597
+ module,
598
+ entryPoint: "vs_main",
599
+ buffers: [
600
+ {
601
+ arrayStride: 2 * 4,
602
+ stepMode: "vertex",
603
+ attributes: [{ shaderLocation: 0, offset: 0, format: "float32x2" }]
604
+ },
605
+ {
606
+ arrayStride: 4 * 4,
607
+ stepMode: "instance",
608
+ attributes: [
609
+ { shaderLocation: 1, offset: 0, format: "float32x2" },
610
+ { shaderLocation: 2, offset: 2 * 4, format: "float32x2" }
611
+ ]
612
+ }
613
+ ]
614
+ },
615
+ fragment: {
616
+ module,
617
+ entryPoint: "fs_main",
618
+ targets: [
619
+ {
620
+ format: this.format,
621
+ blend: {
622
+ color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
623
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" }
624
+ }
625
+ }
626
+ ]
627
+ },
628
+ primitive: { topology: "triangle-list" }
629
+ });
630
+ }
631
+ // Hover pipeline (ring shader)
632
+ {
633
+ const module = this.device.createShaderModule({ code: hoverWGSL });
634
+ this.hoverPipeline = this.device.createRenderPipeline({
635
+ layout: "auto",
636
+ vertex: {
637
+ module,
638
+ entryPoint: "vs_main",
639
+ buffers: [
640
+ {
641
+ arrayStride: 2 * 4,
642
+ stepMode: "vertex",
643
+ attributes: [{ shaderLocation: 0, offset: 0, format: "float32x2" }]
644
+ },
645
+ {
646
+ arrayStride: 2 * 4,
647
+ stepMode: "instance",
648
+ attributes: [{ shaderLocation: 1, offset: 0, format: "float32x2" }]
649
+ }
650
+ ]
651
+ },
652
+ fragment: {
653
+ module,
654
+ entryPoint: "fs_main",
655
+ targets: [
656
+ {
657
+ format: this.format,
658
+ blend: {
659
+ color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
660
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" }
661
+ }
662
+ }
663
+ ]
664
+ },
665
+ primitive: { topology: "triangle-list" }
666
+ });
667
+ }
668
+ // Pick pipeline -> rgba8unorm offscreen
669
+ {
670
+ const module = this.device.createShaderModule({ code: pickWGSL });
671
+ this.pickPipeline = this.device.createRenderPipeline({
672
+ layout: "auto",
673
+ vertex: {
674
+ module,
675
+ entryPoint: "vs_main",
676
+ buffers: [
677
+ {
678
+ arrayStride: 2 * 4,
679
+ stepMode: "vertex",
680
+ attributes: [{ shaderLocation: 0, offset: 0, format: "float32x2" }]
681
+ }
682
+ ]
683
+ },
684
+ fragment: {
685
+ module,
686
+ entryPoint: "fs_main",
687
+ targets: [{ format: "rgba8unorm" }]
688
+ },
689
+ primitive: { topology: "triangle-list" }
690
+ });
691
+ // Pick uniform buffers created dynamically per-layer in ensurePickBuffer()
692
+ }
693
+ }
694
+ createQuadBuffer() {
695
+ const quad = new Float32Array([
696
+ -0.5, -0.5,
697
+ 0.5, -0.5,
698
+ 0.5, 0.5,
699
+ -0.5, -0.5,
700
+ 0.5, 0.5,
701
+ -0.5, 0.5
702
+ ]);
703
+ this.quadBuf = this.device.createBuffer({
704
+ size: quad.byteLength,
705
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST
706
+ });
707
+ this.device.queue.writeBuffer(this.quadBuf, 0, quad);
708
+ }
709
+ createLineQuadBuffer() {
710
+ const quad = new Float32Array([
711
+ 0, -1,
712
+ 1, -1,
713
+ 1, 1,
714
+ 0, -1,
715
+ 1, 1,
716
+ 0, 1
717
+ ]);
718
+ this.lineQuadBuf = this.device.createBuffer({
719
+ size: quad.byteLength,
720
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST
721
+ });
722
+ this.device.queue.writeBuffer(this.lineQuadBuf, 0, quad);
723
+ }
724
+ createHoverBuffer() {
725
+ this.hoverBuf = this.device.createBuffer({
726
+ size: 8,
727
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST
728
+ });
729
+ }
730
+ createUniforms() {
731
+ // Main uniform buffer - kept for backwards compatibility but not used in render loop
732
+ this.uniformsBuf = this.device.createBuffer({
733
+ size: 96,
734
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
735
+ });
736
+ this.bindGroup = this.device.createBindGroup({
737
+ layout: this.scatterPipeline.getBindGroupLayout(0),
738
+ entries: [{ binding: 0, resource: { buffer: this.uniformsBuf } }]
739
+ });
740
+ this.lineBindGroup = this.device.createBindGroup({
741
+ layout: this.linePipeline.getBindGroupLayout(0),
742
+ entries: [{ binding: 0, resource: { buffer: this.uniformsBuf } }]
743
+ });
744
+ // Hover uniform buffer - 80 bytes for inner + outline colors
745
+ this.hoverUniformsBuf = this.device.createBuffer({
746
+ size: 80,
747
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
748
+ });
749
+ this.hoverBindGroup = this.device.createBindGroup({
750
+ layout: this.hoverPipeline.getBindGroupLayout(0),
751
+ entries: [{ binding: 0, resource: { buffer: this.hoverUniformsBuf } }]
752
+ });
753
+ }
754
+ // Write uniforms to a specific buffer (used for per-layer rendering)
755
+ writeUniformsToBuffer(buffer, args) {
756
+ // Updated to match new shader layout with proper vec3 alignment
757
+ const u = new Float32Array(20);
758
+ // 0..1 canvasSize (offset 0)
759
+ u[0] = args.canvasSize[0];
760
+ u[1] = args.canvasSize[1];
761
+ // 2..3 plotOrigin (offset 8)
762
+ u[2] = args.plotOrigin[0];
763
+ u[3] = args.plotOrigin[1];
764
+ // 4..5 plotSize (offset 16)
765
+ u[4] = args.plotSize[0];
766
+ u[5] = args.plotSize[1];
767
+ // 6..7 padding (offset 24) - NEW! For vec3 alignment
768
+ u[6] = 0;
769
+ u[7] = 0;
770
+ // 8..10 zoom (vec3, offset 32) - NOW PROPERLY ALIGNED!
771
+ u[8] = args.zoom[0];
772
+ u[9] = args.zoom[1];
773
+ u[10] = args.zoom[2];
774
+ // 11 pointSizePx (offset 44)
775
+ u[11] = args.pointSizePx;
776
+ // 12..15 rgba (vec4, offset 48)
777
+ u[12] = args.rgba[0];
778
+ u[13] = args.rgba[1];
779
+ u[14] = args.rgba[2];
780
+ u[15] = args.rgba[3];
781
+ const u32 = new Uint32Array(u.buffer);
782
+ u32[16] = args.pointCount ?? 0;
783
+ u32[17] = args.lodStride ?? 1;
784
+ u32[18] = args.lodOffset ?? 0;
785
+ u32[19] = 0;
786
+ this.device.queue.writeBuffer(buffer, 0, u);
787
+ }
788
+ writeLineUniformsToBuffer(buffer, args) {
789
+ const u = new Float32Array(24);
790
+ u[0] = args.canvasSize[0];
791
+ u[1] = args.canvasSize[1];
792
+ u[2] = args.plotOrigin[0];
793
+ u[3] = args.plotOrigin[1];
794
+ u[4] = args.plotSize[0];
795
+ u[5] = args.plotSize[1];
796
+ u[6] = 0;
797
+ u[7] = 0;
798
+ u[8] = args.zoom[0];
799
+ u[9] = args.zoom[1];
800
+ u[10] = args.zoom[2];
801
+ u[11] = args.lineWidthPx;
802
+ u[12] = args.rgba[0];
803
+ u[13] = args.rgba[1];
804
+ u[14] = args.rgba[2];
805
+ u[15] = args.rgba[3];
806
+ u[16] = args.dashPattern[0];
807
+ u[17] = args.dashPattern[1];
808
+ u[18] = args.dashPattern[2];
809
+ u[19] = args.dashPattern[3];
810
+ u[20] = args.dashCount;
811
+ u[21] = 0;
812
+ u[22] = 0;
813
+ u[23] = 0;
814
+ this.device.queue.writeBuffer(buffer, 0, u);
815
+ }
816
+ // Legacy method - writes to shared buffer (kept for compatibility)
817
+ writeUniforms(args) {
818
+ this.writeUniformsToBuffer(this.uniformsBuf, args);
819
+ }
820
+ writeHoverUniforms(args) {
821
+ const u = new Float32Array(20);
822
+ u[0] = args.canvasSize[0];
823
+ u[1] = args.canvasSize[1];
824
+ u[2] = args.plotOrigin[0];
825
+ u[3] = args.plotOrigin[1];
826
+ u[4] = args.plotSize[0];
827
+ u[5] = args.plotSize[1];
828
+ u[6] = 0; // padding
829
+ u[7] = 0;
830
+ u[8] = args.zoom[0];
831
+ u[9] = args.zoom[1];
832
+ u[10] = args.zoom[2];
833
+ u[11] = args.pointSizePx;
834
+ // Inner color (offset 48)
835
+ u[12] = args.innerRgba[0];
836
+ u[13] = args.innerRgba[1];
837
+ u[14] = args.innerRgba[2];
838
+ u[15] = args.innerRgba[3];
839
+ // Outline color (offset 64)
840
+ u[16] = args.outlineRgba[0];
841
+ u[17] = args.outlineRgba[1];
842
+ u[18] = args.outlineRgba[2];
843
+ u[19] = args.outlineRgba[3];
844
+ this.device.queue.writeBuffer(this.hoverUniformsBuf, 0, u);
845
+ }
846
+ resolveDashPattern(dash) {
847
+ if (!dash || dash === "solid") {
848
+ return { dashPattern: [0, 0, 0, 0], dashCount: 0 };
849
+ }
850
+ if (dash === "dash") {
851
+ return { dashPattern: [8, 6, 0, 0], dashCount: 2 };
852
+ }
853
+ if (dash === "dot") {
854
+ return { dashPattern: [2, 4, 0, 0], dashCount: 2 };
855
+ }
856
+ if (dash === "dashdot") {
857
+ return { dashPattern: [8, 4, 2, 4], dashCount: 4 };
858
+ }
859
+ const cleaned = dash
860
+ .map((v) => Number(v))
861
+ .filter((v) => Number.isFinite(v) && v > 0)
862
+ .slice(0, 4);
863
+ if (cleaned.length === 0) {
864
+ return { dashPattern: [0, 0, 0, 0], dashCount: 0 };
865
+ }
866
+ if (cleaned.length === 1) {
867
+ return { dashPattern: [cleaned[0], cleaned[0], 0, 0], dashCount: 2 };
868
+ }
869
+ const dashPattern = [
870
+ cleaned[0] ?? 0,
871
+ cleaned[1] ?? 0,
872
+ cleaned[2] ?? 0,
873
+ cleaned[3] ?? 0
874
+ ];
875
+ const dashCount = cleaned.length;
876
+ return { dashPattern, dashCount };
877
+ }
878
+ buildLineSegments(points01) {
879
+ const n = Math.floor(points01.length / 2);
880
+ if (n < 2)
881
+ return new Float32Array(0);
882
+ const out = new Float32Array((n - 1) * 4);
883
+ let oi = 0;
884
+ for (let i = 0; i < n - 1; i++) {
885
+ const ax = points01[i * 2 + 0];
886
+ const ay = points01[i * 2 + 1];
887
+ const bx = points01[(i + 1) * 2 + 0];
888
+ const by = points01[(i + 1) * 2 + 1];
889
+ if (!Number.isFinite(ax) || !Number.isFinite(ay) || !Number.isFinite(bx) || !Number.isFinite(by))
890
+ continue;
891
+ out[oi + 0] = ax;
892
+ out[oi + 1] = ay;
893
+ out[oi + 2] = bx;
894
+ out[oi + 3] = by;
895
+ oi += 4;
896
+ }
897
+ if (oi === out.length)
898
+ return out;
899
+ return out.slice(0, oi);
900
+ }
901
+ ensurePickTarget(w, h) {
902
+ if (this.pickTex && this.pickTexW === w && this.pickTexH === h)
903
+ return;
904
+ this.pickTex?.destroy?.();
905
+ this.pickTexW = w;
906
+ this.pickTexH = h;
907
+ this.pickTex = this.device.createTexture({
908
+ size: { width: w, height: h },
909
+ format: "rgba8unorm",
910
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC
911
+ });
912
+ }
913
+ ensurePickReadback(w, h) {
914
+ const bytesPerRow = Math.ceil((w * 4) / 256) * 256;
915
+ const requiredSize = bytesPerRow * h;
916
+ if (this.pickReadback && this.pickReadbackSize >= requiredSize)
917
+ return bytesPerRow;
918
+ this.pickReadback?.destroy?.();
919
+ this.pickReadbackSize = requiredSize;
920
+ this.pickReadback = this.device.createBuffer({
921
+ size: requiredSize,
922
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
923
+ });
924
+ return bytesPerRow;
925
+ }
926
+ ensurePickBuffer(index, layerBuf) {
927
+ if (!this.pickUniformsBufs[index]) {
928
+ const buffer = this.device.createBuffer({
929
+ size: 64,
930
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
931
+ });
932
+ const bindGroup = this.device.createBindGroup({
933
+ layout: this.pickPipeline.getBindGroupLayout(0),
934
+ entries: [
935
+ { binding: 0, resource: { buffer } },
936
+ { binding: 1, resource: { buffer: layerBuf } }
937
+ ]
938
+ });
939
+ this.pickUniformsBufs[index] = buffer;
940
+ this.pickBindGroups[index] = bindGroup;
941
+ }
942
+ return {
943
+ buffer: this.pickUniformsBufs[index],
944
+ bindGroup: this.pickBindGroups[index]
945
+ };
946
+ }
947
+ writePickUniforms(buffer, args) {
948
+ const f = new Float32Array(16);
949
+ f[0] = args.canvasSize[0];
950
+ f[1] = args.canvasSize[1];
951
+ f[2] = args.plotOrigin[0];
952
+ f[3] = args.plotOrigin[1];
953
+ f[4] = args.plotSize[0];
954
+ f[5] = args.plotSize[1];
955
+ f[6] = 0; // padding
956
+ f[7] = 0;
957
+ f[8] = args.zoom[0];
958
+ f[9] = args.zoom[1];
959
+ f[10] = args.zoom[2];
960
+ f[11] = args.pointSizePx;
961
+ // u32 fields at offsets 48..60 (float indices 12..15)
962
+ const u32 = new Uint32Array(f.buffer);
963
+ u32[12] = args.baseId >>> 0;
964
+ u32[13] = args.pointCount >>> 0;
965
+ u32[14] = args.lodStride >>> 0;
966
+ u32[15] = args.lodOffset >>> 0;
967
+ this.device.queue.writeBuffer(buffer, 0, f);
968
+ }
969
+ }
970
+ //# sourceMappingURL=WebGPURenderer.js.map