• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

visgl / luma.gl / 28205988640

25 Jun 2026 11:03PM UTC coverage: 71.259% (+0.8%) from 70.501%
28205988640

Pull #2701

github

web-flow
Merge 4980c2ae8 into cb506267f
Pull Request #2701: [codex] Add experimental GPU command graph

10146 of 16126 branches covered (62.92%)

Branch coverage included in aggregate %.

395 of 483 new or added lines in 14 files covered. (81.78%)

9 existing lines in 2 files now uncovered.

20263 of 26548 relevant lines covered (76.33%)

4347.15 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

77.0
/modules/experimental/src/gpu-primitives/gpu-command-graph.ts
1
// luma.gl
2
// SPDX-License-Identifier: MIT
3
// Copyright (c) vis.gl contributors
4

5
import {Buffer} from '@luma.gl/core';
6
import type {CommandEncoder, ComputePass, Device, RenderPass, RenderPassProps} from '@luma.gl/core';
7
import {DynamicBuffer} from '@luma.gl/engine';
8
import {
9
  GPUData,
10
  type GPUVector,
11
  type GPUVectorFormat,
12
  getGPUVectorFormatInfo
13
} from '@luma.gl/tables';
14

15
/** GPU buffer use declared by one graph node. */
16
export type GraphBufferUsage =
17
  | 'storage-read'
18
  | 'storage-write'
19
  | 'storage-read-write'
20
  | 'uniform'
21
  | 'copy-source'
22
  | 'copy-destination'
23
  | 'indirect'
24
  | 'vertex'
25
  | 'index';
26

27
/** Buffer accepted as a fixed or per-encoding graph import. */
28
export type GraphImportedBuffer = Buffer | DynamicBuffer;
29

30
/** Descriptor for one imported or transient graph buffer. */
31
export type GraphBufferDescriptor = {
32
  id: string;
33
  byteLength: number;
34
  usage: number;
35
};
36

37
/** Opaque logical buffer tracked by a {@link GPUCommandGraph}. */
38
export class GraphBufferHandle {
39
  readonly id: string;
40
  readonly byteLength: number;
41
  readonly usage: number;
42
  readonly transient: boolean;
43
  /** @internal */
44
  readonly graph: GPUCommandGraph<any>;
45
  /** @internal */
46
  readonly defaultBuffer?: GraphImportedBuffer;
47

48
  /** @internal */
49
  constructor(
50
    graph: GPUCommandGraph<any>,
51
    descriptor: GraphBufferDescriptor,
52
    transient: boolean,
53
    defaultBuffer?: GraphImportedBuffer
54
  ) {
55
    this.graph = graph;
60✔
56
    this.id = descriptor.id;
60✔
57
    this.byteLength = descriptor.byteLength;
60✔
58
    this.usage = descriptor.usage;
60✔
59
    this.transient = transient;
60✔
60
    this.defaultBuffer = defaultBuffer;
60✔
61
  }
62
}
63

64
/** Typed logical range within one graph buffer. */
65
export class GraphBufferView<T extends GPUVectorFormat = GPUVectorFormat> {
66
  readonly buffer: GraphBufferHandle;
67
  readonly format: T;
68
  readonly length: number;
69
  readonly byteOffset: number;
70
  readonly byteStride: number;
71
  readonly rowByteLength: number;
72

73
  /** @internal */
74
  constructor(
75
    buffer: GraphBufferHandle,
76
    props: {
77
      format: T;
78
      length: number;
79
      byteOffset: number;
80
      byteStride: number;
81
      rowByteLength: number;
82
    }
83
  ) {
84
    this.buffer = buffer;
54✔
85
    this.format = props.format;
54✔
86
    this.length = props.length;
54✔
87
    this.byteOffset = props.byteOffset;
54✔
88
    this.byteStride = props.byteStride;
54✔
89
    this.rowByteLength = props.rowByteLength;
54✔
90
  }
91
}
92

93
/** One resource use declared by a graph node. */
94
export type GraphBufferUse = {
95
  buffer: GraphBufferHandle | GraphBufferView;
96
  usage: GraphBufferUsage;
97
};
98

99
/** Context available while compiling one graph node. */
100
export type GPUCommandGraphCompileContext = {
101
  device: Device;
102
};
103

104
/** Context shared by every executable graph node. */
105
export type GPUCommandGraphEncodeContext<Parameters> = {
106
  commandEncoder: CommandEncoder;
107
  parameters: Parameters;
108
  getBuffer: (buffer: GraphBufferHandle | GraphBufferView) => Buffer;
109
};
110

111
/** Compiled compute-pass callback. */
112
export type GPUCommandGraphComputeExecutable<Parameters> = {
113
  encode: (context: GPUCommandGraphEncodeContext<Parameters> & {computePass: ComputePass}) => void;
114
  destroy?: () => void;
115
};
116

117
/** Compiled render-pass callback. */
118
export type GPUCommandGraphRenderExecutable<Parameters> = {
119
  getRenderPassProps?: (context: GPUCommandGraphEncodeContext<Parameters>) => RenderPassProps;
120
  encode: (context: GPUCommandGraphEncodeContext<Parameters> & {renderPass: RenderPass}) => void;
121
  destroy?: () => void;
122
};
123

124
/** Compiled command-encoder callback used for copies and other pass-independent commands. */
125
export type GPUCommandGraphCopyExecutable<Parameters> = {
126
  encode: (context: GPUCommandGraphEncodeContext<Parameters>) => void;
127
  destroy?: () => void;
128
};
129

130
type GPUCommandGraphNodeBase = {
131
  id: string;
132
  resources?: GraphBufferUse[];
133
  dependsOn?: string[];
134
};
135

136
export type GPUCommandGraphComputeNode<Parameters> = GPUCommandGraphNodeBase & {
137
  type: 'compute';
138
  compile: (context: GPUCommandGraphCompileContext) => GPUCommandGraphComputeExecutable<Parameters>;
139
};
140

141
export type GPUCommandGraphRenderNode<Parameters> = GPUCommandGraphNodeBase & {
142
  type: 'render';
143
  compile: (context: GPUCommandGraphCompileContext) => GPUCommandGraphRenderExecutable<Parameters>;
144
};
145

146
export type GPUCommandGraphCopyNode<Parameters> = GPUCommandGraphNodeBase & {
147
  type: 'copy';
148
  compile: (context: GPUCommandGraphCompileContext) => GPUCommandGraphCopyExecutable<Parameters>;
149
};
150

151
export type GPUCommandGraphNode<Parameters> =
152
  | GPUCommandGraphComputeNode<Parameters>
153
  | GPUCommandGraphRenderNode<Parameters>
154
  | GPUCommandGraphCopyNode<Parameters>;
155

156
/** Resource-allocation and scheduling statistics for one compiled graph. */
157
export type GPUCommandGraphStats = {
158
  nodeOrder: string[];
159
  logicalTransientBufferCount: number;
160
  physicalTransientBufferCount: number;
161
  logicalTransientBytes: number;
162
  physicalTransientBytes: number;
163
  reusedTransientBytes: number;
164
  reusePercentage: number;
165
};
166

167
/** Options supplied while encoding one compiled graph. */
168
export type GPUCommandGraphEncodeOptions<Parameters> = {
169
  parameters: Parameters;
170
  buffers?: Record<string, GraphImportedBuffer>;
171
};
172

173
type CompiledNode<Parameters> = {
174
  node: GPUCommandGraphNode<Parameters>;
175
  executable:
176
    | GPUCommandGraphComputeExecutable<Parameters>
177
    | GPUCommandGraphRenderExecutable<Parameters>
178
    | GPUCommandGraphCopyExecutable<Parameters>;
179
};
180

181
type TransientAllocation = {
182
  byteLength: number;
183
  usage: number;
184
  lastUse: number;
185
  handles: GraphBufferHandle[];
186
  buffer?: Buffer;
187
};
188

189
/**
190
 * Declarative WebGPU command graph with explicit buffer access and ownership.
191
 *
192
 * The graph compiles resource hazards, transient lifetimes, and node resources,
193
 * but encoding and submission remain controlled by the application.
194
 */
195
export class GPUCommandGraph<Parameters = void> {
196
  readonly device: Device;
197
  readonly id: string;
198

199
  private readonly buffers = new Map<string, GraphBufferHandle>();
22✔
200
  private readonly nodes: GPUCommandGraphNode<Parameters>[] = [];
22✔
201
  private readonly nodeIds = new Set<string>();
22✔
202
  private compiled = false;
22✔
203

204
  constructor(device: Device, props: {id?: string} = {}) {
5✔
205
    if (device.type !== 'webgpu') {
22!
NEW
206
      throw new Error('GPUCommandGraph requires a WebGPU device');
×
207
    }
208
    this.device = device;
22✔
209
    this.id = props.id ?? 'gpu-command-graph';
22✔
210
  }
211

212
  /** Declares a caller-owned buffer that can be supplied now or for each encoding. */
213
  importBuffer(
214
    descriptor: GraphBufferDescriptor,
215
    defaultBuffer?: GraphImportedBuffer
216
  ): GraphBufferHandle {
217
    this.assertMutable();
45✔
218
    validateGraphBufferDescriptor(descriptor);
45✔
219
    if (defaultBuffer) {
45✔
220
      validateImportedBuffer(defaultBuffer, descriptor, this.device);
44✔
221
    }
222
    return this.addBuffer(new GraphBufferHandle(this, descriptor, false, defaultBuffer));
44✔
223
  }
224

225
  /** Declares one graph-owned scratch buffer. */
226
  createTransientBuffer(descriptor: GraphBufferDescriptor): GraphBufferHandle {
227
    this.assertMutable();
16✔
228
    validateGraphBufferDescriptor(descriptor);
16✔
229
    return this.addBuffer(new GraphBufferHandle(this, descriptor, true));
16✔
230
  }
231

232
  /** Creates one typed range over a graph buffer. */
233
  createBufferView<T extends GPUVectorFormat>(
234
    buffer: GraphBufferHandle,
235
    props: {
236
      format: T;
237
      length: number;
238
      byteOffset?: number;
239
      byteStride?: number;
240
      rowByteLength?: number;
241
    }
242
  ): GraphBufferView<T> {
243
    this.assertBuffer(buffer);
55✔
244
    const formatInfo = getGPUVectorFormatInfo(props.format);
55✔
245
    const byteOffset = props.byteOffset ?? 0;
55✔
246
    const rowByteLength = props.rowByteLength ?? formatInfo.byteLength;
55✔
247
    const byteStride = props.byteStride ?? rowByteLength;
55✔
248
    validateGraphBufferView(buffer, {
55✔
249
      length: props.length,
250
      byteOffset,
251
      byteStride,
252
      rowByteLength
253
    });
254
    return new GraphBufferView(buffer, {
54✔
255
      format: props.format,
256
      length: props.length,
257
      byteOffset,
258
      byteStride,
259
      rowByteLength
260
    });
261
  }
262

263
  /** Imports one borrowed GPUData range and returns its typed graph view. */
264
  importGPUData<T extends GPUVectorFormat>(id: string, data: GPUData<T>): GraphBufferView<T> {
265
    if (!data.format) {
1!
NEW
266
      throw new Error(`GPUCommandGraph import "${id}" requires GPUData.format`);
×
267
    }
268
    const coreBuffer = getCoreBuffer(data.buffer);
1✔
269
    const handle = this.importBuffer(
1✔
270
      {id, byteLength: coreBuffer.byteLength, usage: coreBuffer.usage},
271
      data.buffer
272
    );
273
    return this.createBufferView(handle, {
1✔
274
      format: data.format,
275
      length: data.length,
276
      byteOffset: data.byteOffset,
277
      byteStride: data.byteStride,
278
      rowByteLength: data.rowByteLength
279
    });
280
  }
281

282
  /** Imports one packed, single-chunk GPUVector. */
283
  importGPUVector<T extends GPUVectorFormat>(id: string, vector: GPUVector<T>): GraphBufferView<T> {
NEW
284
    const [data, ...remainingData] = vector.data;
×
NEW
285
    if (!data || remainingData.length > 0) {
×
NEW
286
      throw new Error(`GPUCommandGraph import "${id}" requires exactly one GPUVector chunk`);
×
287
    }
NEW
288
    if (vector.bufferLayout) {
×
NEW
289
      throw new Error(`GPUCommandGraph import "${id}" does not accept interleaved GPUVector data`);
×
290
    }
NEW
291
    return this.importGPUData(id, data);
×
292
  }
293

294
  addComputePass(node: Omit<GPUCommandGraphComputeNode<Parameters>, 'type'>): void {
295
    this.addNode({...node, type: 'compute'});
33✔
296
  }
297

298
  addRenderPass(node: Omit<GPUCommandGraphRenderNode<Parameters>, 'type'>): void {
NEW
299
    this.addNode({...node, type: 'render'});
×
300
  }
301

302
  addCopyPass(node: Omit<GPUCommandGraphCopyNode<Parameters>, 'type'>): void {
303
    this.addNode({...node, type: 'copy'});
2✔
304
  }
305

306
  /** Compiles scheduling, transient allocations, and executable node resources. */
307
  compile(): CompiledGPUCommandGraph<Parameters> {
308
    this.assertMutable();
20✔
309
    this.compiled = true;
20✔
310
    const nodeOrder = getNodeOrder(this.nodes);
20✔
311
    const transientPlan = getTransientAllocationPlan(nodeOrder, this.buffers.values());
19✔
312
    const transientBuffers = new Map<GraphBufferHandle, Buffer>();
19✔
313
    for (const allocation of transientPlan) {
19✔
314
      allocation.buffer = this.device.createBuffer({
14✔
315
        id: `${this.id}-transient-${transientPlan.indexOf(allocation)}`,
316
        byteLength: allocation.byteLength,
317
        usage: allocation.usage
318
      });
319
      for (const handle of allocation.handles) {
14✔
320
        transientBuffers.set(handle, allocation.buffer);
15✔
321
      }
322
    }
323

324
    const compiledNodes: CompiledNode<Parameters>[] = [];
19✔
325
    try {
19✔
326
      for (const node of nodeOrder) {
19✔
327
        compiledNodes.push({node, executable: node.compile({device: this.device})});
32✔
328
      }
329
    } catch (error) {
NEW
330
      for (const compiledNode of compiledNodes) {
×
NEW
331
        compiledNode.executable.destroy?.();
×
332
      }
NEW
333
      for (const allocation of transientPlan) {
×
NEW
334
        allocation.buffer?.destroy();
×
335
      }
NEW
336
      throw error;
×
337
    }
338

339
    const logicalTransientBytes = Array.from(this.buffers.values())
19✔
340
      .filter(buffer => buffer.transient)
59✔
341
      .reduce((sum, buffer) => sum + buffer.byteLength, 0);
15✔
342
    const physicalTransientBytes = transientPlan.reduce(
19✔
343
      (sum, allocation) => sum + allocation.byteLength,
14✔
344
      0
345
    );
346
    const reusedTransientBytes = Math.max(0, logicalTransientBytes - physicalTransientBytes);
19✔
347
    const stats: GPUCommandGraphStats = {
19✔
348
      nodeOrder: nodeOrder.map(node => node.id),
32✔
349
      logicalTransientBufferCount: Array.from(this.buffers.values()).filter(
350
        buffer => buffer.transient
59✔
351
      ).length,
352
      physicalTransientBufferCount: transientPlan.length,
353
      logicalTransientBytes,
354
      physicalTransientBytes,
355
      reusedTransientBytes,
356
      reusePercentage:
357
        logicalTransientBytes > 0 ? (reusedTransientBytes / logicalTransientBytes) * 100 : 0
19✔
358
    };
359

360
    return new CompiledGPUCommandGraph({
19✔
361
      device: this.device,
362
      id: this.id,
363
      buffers: new Map(this.buffers),
364
      compiledNodes,
365
      transientBuffers,
366
      transientAllocations: transientPlan,
367
      stats
368
    });
369
  }
370

371
  private addNode(node: GPUCommandGraphNode<Parameters>): void {
372
    this.assertMutable();
35✔
373
    if (!node.id) {
35!
NEW
374
      throw new Error('GPUCommandGraph node id is required');
×
375
    }
376
    if (this.nodeIds.has(node.id)) {
35!
NEW
377
      throw new Error(`GPUCommandGraph node id "${node.id}" is already in use`);
×
378
    }
379
    for (const resource of node.resources ?? []) {
35✔
380
      const buffer = getHandle(resource.buffer);
76✔
381
      this.assertBuffer(buffer);
76✔
382
      validateUseAgainstDescriptor(buffer, resource.usage);
76✔
383
    }
384
    this.nodeIds.add(node.id);
34✔
385
    this.nodes.push(node);
34✔
386
  }
387

388
  private addBuffer(buffer: GraphBufferHandle): GraphBufferHandle {
389
    if (this.buffers.has(buffer.id)) {
60!
NEW
390
      throw new Error(`GPUCommandGraph buffer id "${buffer.id}" is already in use`);
×
391
    }
392
    this.buffers.set(buffer.id, buffer);
60✔
393
    return buffer;
60✔
394
  }
395

396
  private assertBuffer(buffer: GraphBufferHandle): void {
397
    if (buffer.graph !== this || this.buffers.get(buffer.id) !== buffer) {
131!
NEW
398
      throw new Error(`Graph buffer "${buffer.id}" does not belong to ${this.id}`);
×
399
    }
400
  }
401

402
  private assertMutable(): void {
403
    if (this.compiled) {
116!
NEW
404
      throw new Error(`GPUCommandGraph "${this.id}" has already been compiled`);
×
405
    }
406
  }
407
}
408

409
/** Executable, fixed-capacity command graph. */
410
export class CompiledGPUCommandGraph<Parameters = void> {
411
  readonly device: Device;
412
  readonly id: string;
413
  readonly stats: GPUCommandGraphStats;
414

415
  private readonly buffers: Map<string, GraphBufferHandle>;
416
  private readonly compiledNodes: CompiledNode<Parameters>[];
417
  private readonly transientBuffers: Map<GraphBufferHandle, Buffer>;
418
  private readonly transientAllocations: TransientAllocation[];
419
  private destroyed = false;
19✔
420

421
  /** @internal */
422
  constructor(props: {
423
    device: Device;
424
    id: string;
425
    buffers: Map<string, GraphBufferHandle>;
426
    compiledNodes: CompiledNode<Parameters>[];
427
    transientBuffers: Map<GraphBufferHandle, Buffer>;
428
    transientAllocations: TransientAllocation[];
429
    stats: GPUCommandGraphStats;
430
  }) {
431
    this.device = props.device;
19✔
432
    this.id = props.id;
19✔
433
    this.buffers = props.buffers;
19✔
434
    this.compiledNodes = props.compiledNodes;
19✔
435
    this.transientBuffers = props.transientBuffers;
19✔
436
    this.transientAllocations = props.transientAllocations;
19✔
437
    this.stats = props.stats;
19✔
438
  }
439

440
  /** Records every graph node into a caller-owned command encoder. */
441
  encode(commandEncoder: CommandEncoder, options: GPUCommandGraphEncodeOptions<Parameters>): void {
442
    if (this.destroyed) {
19!
NEW
443
      throw new Error(`CompiledGPUCommandGraph "${this.id}" has been destroyed`);
×
444
    }
445
    if (commandEncoder.device !== this.device) {
19!
NEW
446
      throw new Error('GPUCommandGraph command encoder must belong to the graph device');
×
447
    }
448

449
    const importedBuffers = this.resolveImportedBuffers(options.buffers ?? {});
19✔
450
    const getBuffer = (bufferOrView: GraphBufferHandle | GraphBufferView): Buffer => {
17✔
451
      const handle = getHandle(bufferOrView);
69✔
452
      const buffer = handle.transient
69✔
453
        ? this.transientBuffers.get(handle)
454
        : importedBuffers.get(handle);
455
      if (!buffer) {
69!
NEW
456
        throw new Error(`GPUCommandGraph buffer "${handle.id}" is not bound`);
×
457
      }
458
      return buffer;
69✔
459
    };
460

461
    const baseContext: GPUCommandGraphEncodeContext<Parameters> = {
17✔
462
      commandEncoder,
463
      parameters: options.parameters,
464
      getBuffer
465
    };
466

467
    for (const {node, executable} of this.compiledNodes) {
17✔
468
      switch (node.type) {
27!
469
        case 'compute': {
470
          const computePass = commandEncoder.beginComputePass({id: node.id});
27✔
471
          computePass.pushDebugGroup(node.id);
27✔
472
          try {
27✔
473
            (executable as GPUCommandGraphComputeExecutable<Parameters>).encode({
27✔
474
              ...baseContext,
475
              computePass
476
            });
477
          } finally {
478
            computePass.popDebugGroup();
27✔
479
            computePass.end();
27✔
480
          }
481
          break;
27✔
482
        }
483
        case 'render': {
NEW
484
          const renderExecutable = executable as GPUCommandGraphRenderExecutable<Parameters>;
×
NEW
485
          const renderPass = commandEncoder.beginRenderPass(
×
486
            renderExecutable.getRenderPassProps?.(baseContext) ?? {id: node.id}
×
487
          );
NEW
488
          renderPass.pushDebugGroup(node.id);
×
NEW
489
          try {
×
NEW
490
            renderExecutable.encode({...baseContext, renderPass});
×
491
          } finally {
NEW
492
            renderPass.popDebugGroup();
×
NEW
493
            renderPass.end();
×
494
          }
NEW
495
          break;
×
496
        }
497
        case 'copy':
NEW
498
          (executable as GPUCommandGraphCopyExecutable<Parameters>).encode(baseContext);
×
NEW
499
          break;
×
500
      }
501
    }
502
  }
503

504
  /** Releases compiled node resources and graph-owned transient buffers. */
505
  destroy(): void {
506
    if (this.destroyed) {
19!
NEW
507
      return;
×
508
    }
509
    for (const {executable} of this.compiledNodes) {
19✔
510
      executable.destroy?.();
32✔
511
    }
512
    for (const allocation of this.transientAllocations) {
19✔
513
      allocation.buffer?.destroy();
14✔
514
    }
515
    this.destroyed = true;
19✔
516
  }
517

518
  private resolveImportedBuffers(
519
    overrides: Record<string, GraphImportedBuffer>
520
  ): Map<GraphBufferHandle, Buffer> {
521
    const resolved = new Map<GraphBufferHandle, Buffer>();
19✔
522
    for (const [id, handle] of this.buffers) {
19✔
523
      if (handle.transient) {
57✔
524
        continue;
11✔
525
      }
526
      const importedBuffer = overrides[id] ?? handle.defaultBuffer;
46✔
527
      if (!importedBuffer) {
46✔
528
        throw new Error(`GPUCommandGraph imported buffer "${id}" is required`);
1✔
529
      }
530
      validateImportedBuffer(importedBuffer, handle, this.device);
45✔
531
      resolved.set(handle, getCoreBuffer(importedBuffer));
44✔
532
    }
533
    for (const id of Object.keys(overrides)) {
17✔
NEW
534
      const handle = this.buffers.get(id);
×
NEW
535
      if (!handle || handle.transient) {
×
NEW
536
        throw new Error(`GPUCommandGraph has no imported buffer named "${id}"`);
×
537
      }
538
    }
539
    return resolved;
17✔
540
  }
541
}
542

543
function getHandle(buffer: GraphBufferHandle | GraphBufferView): GraphBufferHandle {
544
  return buffer instanceof GraphBufferView ? buffer.buffer : buffer;
295✔
545
}
546

547
function getCoreBuffer(buffer: GraphImportedBuffer): Buffer {
548
  return buffer instanceof DynamicBuffer ? buffer.buffer : buffer;
134✔
549
}
550

551
function validateGraphBufferDescriptor(descriptor: GraphBufferDescriptor): void {
552
  if (!descriptor.id) {
61!
NEW
553
    throw new Error('GPUCommandGraph buffer id is required');
×
554
  }
555
  if (!Number.isSafeInteger(descriptor.byteLength) || descriptor.byteLength < 0) {
61!
NEW
556
    throw new Error(`GPUCommandGraph buffer "${descriptor.id}" requires a valid byteLength`);
×
557
  }
558
  if (!Number.isSafeInteger(descriptor.usage) || descriptor.usage <= 0) {
61!
NEW
559
    throw new Error(`GPUCommandGraph buffer "${descriptor.id}" requires buffer usage flags`);
×
560
  }
561
}
562

563
function validateGraphBufferView(
564
  buffer: GraphBufferHandle,
565
  props: {length: number; byteOffset: number; byteStride: number; rowByteLength: number}
566
): void {
567
  for (const [name, value] of Object.entries(props)) {
55✔
568
    if (!Number.isSafeInteger(value) || value < 0) {
220!
NEW
569
      throw new Error(`Graph buffer view ${name} must be a non-negative safe integer`);
×
570
    }
571
  }
572
  if (props.length > 1 && props.byteStride === 0) {
55!
NEW
573
    throw new Error('Graph buffer view byteStride must be positive for multiple rows');
×
574
  }
575
  if (props.rowByteLength > props.byteStride && props.length > 1) {
55!
NEW
576
    throw new Error('Graph buffer view rowByteLength cannot exceed byteStride');
×
577
  }
578
  const byteLength =
579
    props.length === 0 ? 0 : (props.length - 1) * props.byteStride + props.rowByteLength;
55✔
580
  if (props.byteOffset + byteLength > buffer.byteLength) {
55✔
581
    throw new Error(`Graph buffer view exceeds buffer "${buffer.id}" byte length`);
1✔
582
  }
583
}
584

585
function validateImportedBuffer(
586
  importedBuffer: GraphImportedBuffer,
587
  descriptor: Pick<GraphBufferDescriptor, 'id' | 'byteLength' | 'usage'>,
588
  device: Device
589
): void {
590
  const buffer = getCoreBuffer(importedBuffer);
89✔
591
  if (buffer.device !== device) {
89✔
592
    throw new Error(`GPUCommandGraph buffer "${descriptor.id}" belongs to another device`);
1✔
593
  }
594
  if (buffer.byteLength < descriptor.byteLength) {
88✔
595
    throw new Error(`GPUCommandGraph buffer "${descriptor.id}" is smaller than compiled capacity`);
1✔
596
  }
597
  if ((buffer.usage & descriptor.usage) !== descriptor.usage) {
87!
NEW
598
    throw new Error(`GPUCommandGraph buffer "${descriptor.id}" has incompatible usage flags`);
×
599
  }
600
}
601

602
function validateUseAgainstDescriptor(buffer: GraphBufferHandle, usage: GraphBufferUsage): void {
603
  const requiredUsage = getRequiredBufferUsage(usage);
76✔
604
  if ((buffer.usage & requiredUsage) !== requiredUsage) {
76✔
605
    throw new Error(
1✔
606
      `GPUCommandGraph buffer "${buffer.id}" does not declare usage required by ${usage}`
607
    );
608
  }
609
}
610

611
function getRequiredBufferUsage(usage: GraphBufferUsage): number {
612
  switch (usage) {
76!
613
    case 'storage-read':
614
    case 'storage-write':
615
    case 'storage-read-write':
616
      return Buffer.STORAGE;
76✔
617
    case 'uniform':
NEW
618
      return Buffer.UNIFORM;
×
619
    case 'copy-source':
NEW
620
      return Buffer.COPY_SRC;
×
621
    case 'copy-destination':
NEW
622
      return Buffer.COPY_DST;
×
623
    case 'indirect':
NEW
624
      return Buffer.INDIRECT;
×
625
    case 'vertex':
NEW
626
      return Buffer.VERTEX;
×
627
    case 'index':
NEW
628
      return Buffer.INDEX;
×
629
  }
630
}
631

632
function isReadUsage(usage: GraphBufferUsage): boolean {
633
  return (
75✔
634
    usage === 'storage-read' ||
282✔
635
    usage === 'storage-read-write' ||
636
    usage === 'uniform' ||
637
    usage === 'copy-source' ||
638
    usage === 'indirect' ||
639
    usage === 'vertex' ||
640
    usage === 'index'
641
  );
642
}
643

644
function isWriteUsage(usage: GraphBufferUsage): boolean {
645
  return (
75✔
646
    usage === 'storage-write' || usage === 'storage-read-write' || usage === 'copy-destination'
154✔
647
  );
648
}
649

650
function getNodeOrder<Parameters>(
651
  nodes: GPUCommandGraphNode<Parameters>[]
652
): GPUCommandGraphNode<Parameters>[] {
653
  const nodeById = new Map(nodes.map(node => [node.id, node]));
34✔
654
  const dependencies = new Map<string, Set<string>>();
20✔
655
  const lastWriter = new Map<GraphBufferHandle, string>();
20✔
656
  const activeReaders = new Map<GraphBufferHandle, Set<string>>();
20✔
657

658
  for (const node of nodes) {
20✔
659
    const nodeDependencies = new Set(node.dependsOn ?? []);
34✔
660
    for (const dependency of nodeDependencies) {
34✔
661
      if (!nodeById.has(dependency)) {
2!
NEW
662
        throw new Error(
×
663
          `GPUCommandGraph node "${node.id}" depends on missing node "${dependency}"`
664
        );
665
      }
666
    }
667
    for (const resource of node.resources ?? []) {
34✔
668
      const handle = getHandle(resource.buffer);
75✔
669
      if (isReadUsage(resource.usage)) {
75✔
670
        const writer = lastWriter.get(handle);
41✔
671
        if (writer) {
41✔
672
          nodeDependencies.add(writer);
17✔
673
        }
674
        const readers = activeReaders.get(handle) ?? new Set<string>();
41✔
675
        readers.add(node.id);
41✔
676
        activeReaders.set(handle, readers);
41✔
677
      }
678
      if (isWriteUsage(resource.usage)) {
75✔
679
        const writer = lastWriter.get(handle);
37✔
680
        if (writer) {
37✔
681
          nodeDependencies.add(writer);
3✔
682
        }
683
        for (const reader of activeReaders.get(handle) ?? []) {
37✔
684
          if (reader !== node.id) {
3!
NEW
685
            nodeDependencies.add(reader);
×
686
          }
687
        }
688
        activeReaders.set(handle, new Set());
37✔
689
        lastWriter.set(handle, node.id);
37✔
690
      }
691
    }
692
    nodeDependencies.delete(node.id);
34✔
693
    dependencies.set(node.id, nodeDependencies);
34✔
694
  }
695

696
  const insertionIndex = new Map(nodes.map((node, index) => [node.id, index]));
34✔
697
  const remaining = new Map(
20✔
698
    Array.from(dependencies, ([id, values]) => [id, new Set(values)] as const)
34✔
699
  );
700
  const ordered: GPUCommandGraphNode<Parameters>[] = [];
20✔
701
  while (remaining.size > 0) {
20✔
702
    const ready = Array.from(remaining)
31✔
703
      .filter(([, values]) => values.size === 0)
50✔
704
      .map(([id]) => id)
32✔
705
      .sort((left, right) => insertionIndex.get(left)! - insertionIndex.get(right)!);
2✔
706
    if (ready.length === 0) {
31✔
707
      throw new Error('GPUCommandGraph contains a dependency cycle');
1✔
708
    }
709
    for (const id of ready) {
30✔
710
      ordered.push(nodeById.get(id)!);
32✔
711
      remaining.delete(id);
32✔
712
      for (const values of remaining.values()) {
32✔
713
        values.delete(id);
20✔
714
      }
715
    }
716
  }
717
  return ordered;
19✔
718
}
719

720
function getTransientAllocationPlan<Parameters>(
721
  nodes: GPUCommandGraphNode<Parameters>[],
722
  buffers: Iterable<GraphBufferHandle>
723
): TransientAllocation[] {
724
  const lifetimes = new Map<GraphBufferHandle, {firstUse: number; lastUse: number}>();
19✔
725
  nodes.forEach((node, nodeIndex) => {
19✔
726
    for (const resource of node.resources ?? []) {
32!
727
      const handle = getHandle(resource.buffer);
75✔
728
      if (!handle.transient) {
75✔
729
        continue;
46✔
730
      }
731
      const lifetime = lifetimes.get(handle);
29✔
732
      if (lifetime) {
29✔
733
        lifetime.lastUse = nodeIndex;
14✔
734
      } else {
735
        lifetimes.set(handle, {firstUse: nodeIndex, lastUse: nodeIndex});
15✔
736
      }
737
    }
738
  });
739

740
  const allocations: TransientAllocation[] = [];
19✔
741
  const transientBuffers = Array.from(buffers)
19✔
742
    .filter(buffer => buffer.transient)
59✔
743
    .map(buffer => ({buffer, lifetime: lifetimes.get(buffer)}))
15✔
744
    .sort(
745
      (left, right) =>
5✔
746
        (left.lifetime?.firstUse ?? Number.MAX_SAFE_INTEGER) -
5!
747
        (right.lifetime?.firstUse ?? Number.MAX_SAFE_INTEGER)
5!
748
    );
749

750
  for (const {buffer, lifetime} of transientBuffers) {
19✔
751
    if (!lifetime) {
15!
NEW
752
      continue;
×
753
    }
754
    let allocation = allocations
15✔
755
      .filter(candidate => candidate.lastUse < lifetime.firstUse)
5✔
NEW
756
      .sort((left, right) => left.byteLength - right.byteLength)[0];
×
757
    if (!allocation) {
15✔
758
      allocation = {byteLength: 0, usage: 0, lastUse: -1, handles: []};
14✔
759
      allocations.push(allocation);
14✔
760
    }
761
    allocation.byteLength = Math.max(allocation.byteLength, buffer.byteLength);
15✔
762
    allocation.usage |= buffer.usage;
15✔
763
    allocation.lastUse = lifetime.lastUse;
15✔
764
    allocation.handles.push(buffer);
15✔
765
  }
766
  return allocations;
19✔
767
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc