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

visgl / luma.gl / 28670075378

03 Jul 2026 03:32PM UTC coverage: 72.289% (-0.001%) from 72.29%
28670075378

Pull #2722

github

web-flow
Merge fb3fb0737 into aeb41a2cd
Pull Request #2722: [codex] Extract and document GPU command graph compiler

10843 of 16885 branches covered (64.22%)

Branch coverage included in aggregate %.

175 of 186 new or added lines in 3 files covered. (94.09%)

90 existing lines in 8 files now uncovered.

21205 of 27448 relevant lines covered (77.26%)

5607.53 hits per line

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

80.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, Texture} from '@luma.gl/core';
6
import type {
7
  CommandEncoder,
8
  ComputePass,
9
  Device,
10
  Framebuffer,
11
  RenderPass,
12
  RenderPassProps,
13
  TextureFormat,
14
  TextureView
15
} from '@luma.gl/core';
16
import {DynamicBuffer, DynamicTexture} from '@luma.gl/engine';
17
import {
18
  GPUData,
19
  type GPUVector,
20
  type GPUVectorFormat,
21
  getGPUVectorFormatInfo,
22
  isValueListGPUVectorFormat,
23
  isVertexListGPUVectorFormat
24
} from '@luma.gl/tables';
25
import {
26
  compileGPUCommandGraph,
27
  getBufferHandle,
28
  getTextureHandle,
29
  isGraphBufferUse,
30
  type BufferTransientAllocation,
31
  type CompiledNode,
32
  type GPUCommandGraphCompilation,
33
  type TextureTransientAllocation
34
} from './gpu-command-graph-compiler';
35

36
/**
37
 * Access mode for a buffer used by a command-graph node.
38
 *
39
 * The compiler uses this declaration to infer read-after-write, write-after-read, and
40
 * write-after-write dependencies. It also validates that the physical buffer has the required
41
 * WebGPU usage flag.
42
 */
43
export type GraphBufferUsage =
44
  | 'storage-read'
45
  | 'storage-write'
46
  | 'storage-read-write'
47
  | 'uniform'
48
  | 'copy-source'
49
  | 'copy-destination'
50
  | 'indirect'
51
  | 'vertex'
52
  | 'index';
53

54
/**
55
 * Access mode for a texture or texture view used by a command-graph node.
56
 *
57
 * Texture hazards are inferred only when the declared mip, layer, and aspect ranges overlap.
58
 */
59
export type GraphTextureUsage =
60
  | 'sampled'
61
  | 'storage-read'
62
  | 'storage-write'
63
  | 'storage-read-write'
64
  | 'render-attachment'
65
  | 'copy-source'
66
  | 'copy-destination';
67

68
/** Caller-owned buffer accepted as a fixed or per-encoding graph import. */
69
export type GraphImportedBuffer = Buffer | DynamicBuffer;
70

71
/** Caller-owned texture accepted as a fixed or per-encoding graph import. */
72
export type GraphImportedTexture = Texture | DynamicTexture;
73

74
/** Descriptor for one imported or transient graph buffer. */
75
export type GraphBufferDescriptor = {
76
  /** Graph-wide resource identifier. */
77
  id: string;
78
  /** Required capacity in bytes. */
79
  byteLength: number;
80
  /** Bitwise union of the required luma.gl buffer usage flags. */
81
  usage: number;
82
};
83

84
/** Logical texture dimension supported by the command graph. */
85
export type GraphTextureDimension = '1d' | '2d' | '2d-array' | 'cube' | 'cube-array' | '3d';
86

87
/** Depth/stencil aspect selected by a {@link GraphTextureView}. */
88
export type GraphTextureAspect = 'all' | 'stencil-only' | 'depth-only';
89

90
/** Descriptor for one fixed-size imported or transient graph texture. */
91
export type GraphTextureDescriptor<Format extends TextureFormat = TextureFormat> = {
92
  /** Graph-wide resource identifier. */
93
  id: string;
94
  /** Physical texel format. */
95
  format: Format;
96
  /** Width of mip level zero. */
97
  width: number;
98
  /** Height of mip level zero. */
99
  height: number;
100
  /** Bitwise union of the required luma.gl texture usage flags. */
101
  usage: number;
102
  /** Texture dimension. Defaults to `'2d'`. */
103
  dimension?: GraphTextureDimension;
104
  /** Array-layer count or depth for a 3D texture. Defaults to `1`. */
105
  depth?: number;
106
  /** Number of mip levels. Defaults to `1`. */
107
  mipLevels?: number;
108
  /** Multisample count. Defaults to `1`. */
109
  samples?: number;
110
};
111

112
type NormalizedGraphTextureDescriptor<Format extends TextureFormat = TextureFormat> = {
113
  id: string;
114
  format: Format;
115
  width: number;
116
  height: number;
117
  usage: number;
118
  dimension: GraphTextureDimension;
119
  depth: number;
120
  mipLevels: number;
121
  samples: number;
122
};
123

124
/**
125
 * Opaque logical buffer tracked by a {@link GPUCommandGraph}.
126
 *
127
 * A handle describes capacity, usage, and ownership but does not expose a concrete buffer. The
128
 * concrete buffer is resolved while encoding a {@link CompiledGPUCommandGraph}.
129
 */
130
export class GraphBufferHandle {
131
  /** Graph-wide resource identifier. */
132
  readonly id: string;
133
  /** Required capacity in bytes. */
134
  readonly byteLength: number;
135
  /** Required luma.gl buffer usage flags. */
136
  readonly usage: number;
137
  /** Whether the graph owns and may alias the physical allocation. */
138
  readonly transient: boolean;
139
  /** @internal */
140
  readonly graph: GPUCommandGraph<any>;
141
  /** @internal */
142
  readonly defaultBuffer?: GraphImportedBuffer;
143

144
  /** @internal */
145
  constructor(
146
    graph: GPUCommandGraph<any>,
147
    descriptor: GraphBufferDescriptor,
148
    transient: boolean,
149
    defaultBuffer?: GraphImportedBuffer
150
  ) {
151
    this.graph = graph;
482✔
152
    this.id = descriptor.id;
482✔
153
    this.byteLength = descriptor.byteLength;
482✔
154
    this.usage = descriptor.usage;
482✔
155
    this.transient = transient;
482✔
156
    this.defaultBuffer = defaultBuffer;
482✔
157
  }
158
}
159

160
/**
161
 * Typed logical range within one {@link GraphBufferHandle}.
162
 *
163
 * A data view mirrors one fixed-width `GPUData` chunk: it carries layout metadata but does not own
164
 * the underlying buffer. Hazards remain buffer-granular, so two views of the same handle alias.
165
 */
166
export class GraphDataView<T extends GPUVectorFormat = GPUVectorFormat> {
167
  /** Logical buffer containing the range. */
168
  readonly buffer: GraphBufferHandle;
169
  /** Stored GPU value format. */
170
  readonly format: T;
171
  /** Number of logical rows. */
172
  readonly length: number;
173
  /** Byte offset of the first row in the logical buffer. */
174
  readonly byteOffset: number;
175
  /** Byte distance between consecutive rows. */
176
  readonly byteStride: number;
177
  /** Number of bytes occupied by one row. */
178
  readonly rowByteLength: number;
179

180
  /** @internal */
181
  constructor(
182
    buffer: GraphBufferHandle,
183
    props: {
184
      format: T;
185
      length: number;
186
      byteOffset: number;
187
      byteStride: number;
188
      rowByteLength: number;
189
    }
190
  ) {
191
    this.buffer = buffer;
493✔
192
    this.format = props.format;
493✔
193
    this.length = props.length;
493✔
194
    this.byteOffset = props.byteOffset;
493✔
195
    this.byteStride = props.byteStride;
493✔
196
    this.rowByteLength = props.rowByteLength;
493✔
197
  }
198
}
199

200
/**
201
 * Ordered graph data views that preserve one fixed-width `GPUVector`'s chunk boundaries.
202
 *
203
 * A vector view is metadata and an ordered list only. Nodes declare uses of individual
204
 * {@link GraphDataView} chunks so that the compiler continues to track physical buffer hazards.
205
 */
206
export class GraphVectorView<T extends GPUVectorFormat = GPUVectorFormat> {
207
  /** Identifier supplied to {@link GPUCommandGraph.importGPUVector}. */
208
  readonly id: string;
209
  /** Source vector name. */
210
  readonly name: string;
211
  /** Canonical format shared by every chunk. */
212
  readonly format: T;
213
  /** Total logical row count across all chunks. */
214
  readonly length: number;
215
  /** Total flattened value count across all chunks. */
216
  readonly valueLength: number;
217
  /** Source vector stride in format components. */
218
  readonly stride: number;
219
  /** Source vector byte stride. */
220
  readonly byteStride: number;
221
  /** Source vector bytes occupied by one row. */
222
  readonly rowByteLength: number;
223
  /** Imported chunks in source order. */
224
  readonly data: readonly GraphDataView<T>[];
225

226
  /** @internal */
227
  constructor(props: {
228
    id: string;
229
    name: string;
230
    format: T;
231
    length: number;
232
    valueLength: number;
233
    stride: number;
234
    byteStride: number;
235
    rowByteLength: number;
236
    data: readonly GraphDataView<T>[];
237
  }) {
238
    this.id = props.id;
6✔
239
    this.name = props.name;
6✔
240
    this.format = props.format;
6✔
241
    this.length = props.length;
6✔
242
    this.valueLength = props.valueLength;
6✔
243
    this.stride = props.stride;
6✔
244
    this.byteStride = props.byteStride;
6✔
245
    this.rowByteLength = props.rowByteLength;
6✔
246
    this.data = props.data;
6✔
247
  }
248
}
249

250
/**
251
 * Opaque logical texture tracked by a {@link GPUCommandGraph}.
252
 *
253
 * The descriptor is fixed at graph construction. Imported textures must match it exactly at each
254
 * encoding; transient textures are created and owned by the compiled graph.
255
 */
256
export class GraphTextureHandle<Format extends TextureFormat = TextureFormat> {
257
  /** Graph-wide resource identifier. */
258
  readonly id: string;
259
  /** Physical texel format. */
260
  readonly format: Format;
261
  /** Width of mip level zero. */
262
  readonly width: number;
263
  /** Height of mip level zero. */
264
  readonly height: number;
265
  /** Required luma.gl texture usage flags. */
266
  readonly usage: number;
267
  /** Logical texture dimension. */
268
  readonly dimension: GraphTextureDimension;
269
  /** Array-layer count or depth for a 3D texture. */
270
  readonly depth: number;
271
  /** Number of mip levels. */
272
  readonly mipLevels: number;
273
  /** Multisample count. */
274
  readonly samples: number;
275
  /** Whether the graph owns and may alias the physical allocation. */
276
  readonly transient: boolean;
277
  /** @internal */
278
  readonly graph: GPUCommandGraph<any>;
279
  /** @internal */
280
  readonly defaultTexture?: GraphImportedTexture;
281

282
  /** @internal */
283
  constructor(
284
    graph: GPUCommandGraph<any>,
285
    descriptor: NormalizedGraphTextureDescriptor<Format>,
286
    transient: boolean,
287
    defaultTexture?: GraphImportedTexture
288
  ) {
289
    this.graph = graph;
11✔
290
    this.id = descriptor.id;
11✔
291
    this.format = descriptor.format;
11✔
292
    this.width = descriptor.width;
11✔
293
    this.height = descriptor.height;
11✔
294
    this.usage = descriptor.usage;
11✔
295
    this.dimension = descriptor.dimension;
11✔
296
    this.depth = descriptor.depth;
11✔
297
    this.mipLevels = descriptor.mipLevels;
11✔
298
    this.samples = descriptor.samples;
11✔
299
    this.transient = transient;
11✔
300
    this.defaultTexture = defaultTexture;
11✔
301
  }
302
}
303

304
/** Subresource range selected by {@link GPUCommandGraph.createTextureView}. */
305
export type GraphTextureViewProps = {
306
  /** View dimension. Defaults to the dimension implied by the texture. */
307
  dimension?: GraphTextureDimension;
308
  /** Depth/stencil aspect. Defaults to `'all'`. */
309
  aspect?: GraphTextureAspect;
310
  /** First visible mip level. Defaults to `0`. */
311
  baseMipLevel?: number;
312
  /** Number of visible mip levels. Defaults to all remaining levels. */
313
  mipLevelCount?: number;
314
  /** First visible array layer. Defaults to `0`. */
315
  baseArrayLayer?: number;
316
  /** Number of visible array layers. Defaults to all remaining layers. */
317
  arrayLayerCount?: number;
318
};
319

320
/** Logical mip, layer, and aspect range within one graph texture. */
321
export class GraphTextureView<Format extends TextureFormat = TextureFormat> {
322
  /** Logical texture containing the selected range. */
323
  readonly texture: GraphTextureHandle<Format>;
324
  /** Physical texel format inherited from the texture. */
325
  readonly format: Format;
326
  /** View dimension. */
327
  readonly dimension: GraphTextureDimension;
328
  /** Selected depth/stencil aspect. */
329
  readonly aspect: GraphTextureAspect;
330
  /** First selected mip level. */
331
  readonly baseMipLevel: number;
332
  /** Number of selected mip levels. */
333
  readonly mipLevelCount: number;
334
  /** First selected array layer. */
335
  readonly baseArrayLayer: number;
336
  /** Number of selected array layers. */
337
  readonly arrayLayerCount: number;
338
  /** Width of the first selected mip. */
339
  readonly width: number;
340
  /** Height of the first selected mip. */
341
  readonly height: number;
342
  /** Depth or layer count of the selected range. */
343
  readonly depth: number;
344

345
  /** @internal */
346
  constructor(
347
    texture: GraphTextureHandle<Format>,
348
    props: Required<GraphTextureViewProps> & {width: number; height: number; depth: number}
349
  ) {
350
    this.texture = texture;
10✔
351
    this.format = texture.format;
10✔
352
    this.dimension = props.dimension;
10✔
353
    this.aspect = props.aspect;
10✔
354
    this.baseMipLevel = props.baseMipLevel;
10✔
355
    this.mipLevelCount = props.mipLevelCount;
10✔
356
    this.baseArrayLayer = props.baseArrayLayer;
10✔
357
    this.arrayLayerCount = props.arrayLayerCount;
10✔
358
    this.width = props.width;
10✔
359
    this.height = props.height;
10✔
360
    this.depth = props.depth;
10✔
361
  }
362
}
363

364
/** One buffer use declared by a graph node. */
365
export type GraphBufferUse = {
366
  /** Whole logical buffer or typed range whose physical buffer is used. */
367
  buffer: GraphBufferHandle | GraphDataView;
368
  /** Access performed by the node. */
369
  usage: GraphBufferUsage;
370
};
371

372
/** One texture or texture-view use declared by a graph node. */
373
export type GraphTextureUse = {
374
  /** Whole logical texture or selected subresource range used by the node. */
375
  texture: GraphTextureHandle | GraphTextureView;
376
  /** Access performed by the node. */
377
  usage: GraphTextureUsage;
378
};
379

380
/** One resource use declared by a graph node. */
381
export type GraphResourceUse = GraphBufferUse | GraphTextureUse;
382

383
/** Graph-owned texture attachments resolved into a framebuffer for a render node. */
384
export type GraphRenderPassAttachments = {
385
  /** Color attachment views in render-target order. */
386
  colorAttachments: GraphTextureView[];
387
  /** Optional depth/stencil attachment view. */
388
  depthStencilAttachment?: GraphTextureView;
389
};
390

391
/** Context available while compiling one graph node. */
392
export type GPUCommandGraphCompileContext = {
393
  /** WebGPU device that owns the graph. */
394
  device: Device;
395
};
396

397
/** Context shared by every executable graph node. */
398
export type GPUCommandGraphEncodeContext<Parameters> = {
399
  /** Caller-owned encoder receiving the graph's commands. */
400
  commandEncoder: CommandEncoder;
401
  /** Per-encoding application parameters. */
402
  parameters: Parameters;
403
  /** Resolves a logical buffer or data view to its concrete buffer. */
404
  getBuffer: (buffer: GraphBufferHandle | GraphDataView) => Buffer;
405
  /** Resolves a logical texture or view to its concrete texture. */
406
  getTexture: (texture: GraphTextureHandle | GraphTextureView) => Texture;
407
  /** Resolves and caches the concrete texture view for a logical texture or view. */
408
  getTextureView: (texture: GraphTextureHandle | GraphTextureView) => TextureView;
409
};
410

411
/** Compiled compute-pass callback. */
412
export type GPUCommandGraphComputeExecutable<Parameters> = {
413
  /** Records commands into the compute pass opened by the graph. */
414
  encode: (context: GPUCommandGraphEncodeContext<Parameters> & {computePass: ComputePass}) => void;
415
  /** Releases node-owned compiled resources. */
416
  destroy?: () => void;
417
};
418

419
/** Compiled render-pass callback. */
420
export type GPUCommandGraphRenderExecutable<Parameters> = {
421
  /** Returns per-encoding render-pass options other than graph-declared attachments. */
422
  getRenderPassProps?: (context: GPUCommandGraphEncodeContext<Parameters>) => RenderPassProps;
423
  /** Records commands into the render pass opened by the graph. */
424
  encode: (context: GPUCommandGraphEncodeContext<Parameters> & {renderPass: RenderPass}) => void;
425
  /** Releases node-owned compiled resources. */
426
  destroy?: () => void;
427
};
428

429
/** Compiled command-encoder callback used for copies and other pass-independent commands. */
430
export type GPUCommandGraphCopyExecutable<Parameters> = {
431
  /** Records commands directly into the caller-owned encoder. */
432
  encode: (context: GPUCommandGraphEncodeContext<Parameters>) => void;
433
  /** Releases node-owned compiled resources. */
434
  destroy?: () => void;
435
};
436

437
type GPUCommandGraphNodeBase = {
438
  /** Graph-wide node identifier. */
439
  id: string;
440
  /** Resources read or written by the node, used for validation and hazard inference. */
441
  resources?: GraphResourceUse[];
442
  /** Explicit predecessor node identifiers in addition to inferred resource dependencies. */
443
  dependsOn?: string[];
444
};
445

446
/** Compute node compiled once and encoded into a graph-owned compute pass. */
447
export type GPUCommandGraphComputeNode<Parameters> = GPUCommandGraphNodeBase & {
448
  /** Node discriminator. */
449
  type: 'compute';
450
  /** Creates reusable node resources and the encode callback. */
451
  compile: (context: GPUCommandGraphCompileContext) => GPUCommandGraphComputeExecutable<Parameters>;
452
};
453

454
/** Render node compiled once and encoded into a graph-owned render pass. */
455
export type GPUCommandGraphRenderNode<Parameters> = GPUCommandGraphNodeBase & {
456
  /** Node discriminator. */
457
  type: 'render';
458
  /** Optional graph-managed render attachments. */
459
  attachments?: GraphRenderPassAttachments;
460
  /** Creates reusable node resources and the encode callback. */
461
  compile: (context: GPUCommandGraphCompileContext) => GPUCommandGraphRenderExecutable<Parameters>;
462
};
463

464
/** Copy or pass-independent node compiled once and encoded directly on the command encoder. */
465
export type GPUCommandGraphCopyNode<Parameters> = GPUCommandGraphNodeBase & {
466
  /** Node discriminator. */
467
  type: 'copy';
468
  /** Creates reusable node resources and the encode callback. */
469
  compile: (context: GPUCommandGraphCompileContext) => GPUCommandGraphCopyExecutable<Parameters>;
470
};
471

472
/** Any node accepted by a {@link GPUCommandGraph}. */
473
export type GPUCommandGraphNode<Parameters> =
474
  | GPUCommandGraphComputeNode<Parameters>
475
  | GPUCommandGraphRenderNode<Parameters>
476
  | GPUCommandGraphCopyNode<Parameters>;
477

478
/** Resource-allocation and scheduling statistics for one compiled graph. */
479
export type GPUCommandGraphStats = {
480
  /** Stable topological node order used for encoding. */
481
  nodeOrder: string[];
482
  /** Number of logical transient buffer handles used by nodes. */
483
  logicalTransientBufferCount: number;
484
  /** Number of physical transient buffers allocated after lifetime reuse. */
485
  physicalTransientBufferCount: number;
486
  /** Sum of the capacities of logical transient buffers. */
487
  logicalTransientBytes: number;
488
  /** Sum of physical transient buffer allocation capacities. */
489
  physicalTransientBytes: number;
490
  /** Logical buffer bytes avoided through allocation reuse. */
491
  reusedTransientBytes: number;
492
  /** Percentage of logical buffer bytes avoided through reuse. */
493
  reusePercentage: number;
494
  /** Number of logical transient texture handles used by nodes. */
495
  logicalTransientTextureCount: number;
496
  /** Number of physical transient textures allocated after lifetime reuse. */
497
  physicalTransientTextureCount: number;
498
  /** Estimated bytes across all logical transient textures. */
499
  logicalTransientTextureBytes: number;
500
  /** Estimated bytes across physical transient texture allocations. */
501
  physicalTransientTextureBytes: number;
502
  /** Estimated logical texture bytes avoided through allocation reuse. */
503
  reusedTransientTextureBytes: number;
504
  /** Percentage of estimated logical texture bytes avoided through reuse. */
505
  textureReusePercentage: number;
506
};
507

508
/** Options supplied while encoding one compiled graph. */
509
export type GPUCommandGraphEncodeOptions<Parameters> = {
510
  /** Application data forwarded to every node encode callback. */
511
  parameters: Parameters;
512
  /** Per-encoding imported-buffer replacements keyed by graph resource ID. */
513
  buffers?: Record<string, GraphImportedBuffer>;
514
  /** Per-encoding imported-texture replacements keyed by graph resource ID. */
515
  textures?: Record<string, GraphImportedTexture>;
516
};
517

518
type CachedTextureView = {
519
  logicalView: GraphTextureView;
520
  texture: Texture;
521
  view: TextureView;
522
};
523

524
type CachedFramebuffer = {
525
  nodeId: string;
526
  colorAttachments: TextureView[];
527
  depthStencilAttachment?: TextureView;
528
  framebuffer: Framebuffer;
529
};
530

531
/**
532
 * Declarative WebGPU command graph with explicit resource access and ownership.
533
 *
534
 * The graph compiles resource hazards, transient lifetimes, and node resources,
535
 * but encoding and submission remain controlled by the application.
536
 */
537
export class GPUCommandGraph<Parameters = void> {
538
  /** WebGPU device that owns compilation and transient resources. */
539
  readonly device: Device;
540
  /** Identifier used as a prefix for graph-owned GPU resources. */
541
  readonly id: string;
542

543
  private readonly buffers = new Map<string, GraphBufferHandle>();
78✔
544
  private readonly textures = new Map<string, GraphTextureHandle>();
78✔
545
  private readonly tableBufferHandles = new Map<Buffer, GraphBufferHandle>();
78✔
546
  private readonly nodes: GPUCommandGraphNode<Parameters>[] = [];
78✔
547
  private readonly nodeIds = new Set<string>();
78✔
548
  private compiled = false;
78✔
549

550
  /**
551
   * Creates a mutable graph definition.
552
   *
553
   * @param device WebGPU device used to compile and execute the graph.
554
   * @param props Optional graph identity.
555
   * @throws If `device` is not a WebGPU device.
556
   */
557
  constructor(device: Device, props: {id?: string} = {}) {
41✔
558
    if (device.type !== 'webgpu') {
78!
UNCOV
559
      throw new Error('GPUCommandGraph requires a WebGPU device');
×
560
    }
561
    this.device = device;
78✔
562
    this.id = props.id ?? 'gpu-command-graph';
78✔
563
  }
564

565
  /**
566
   * Declares a caller-owned buffer that can be supplied now or for each encoding.
567
   *
568
   * @param descriptor Required capacity and usage.
569
   * @param defaultBuffer Optional default binding used when an encoding supplies no override.
570
   * @returns An opaque logical handle used by graph nodes and data views.
571
   */
572
  importBuffer(
573
    descriptor: GraphBufferDescriptor,
574
    defaultBuffer?: GraphImportedBuffer
575
  ): GraphBufferHandle {
576
    this.assertMutable();
149✔
577
    validateGraphBufferDescriptor(descriptor);
149✔
578
    if (defaultBuffer) {
149✔
579
      validateImportedBuffer(defaultBuffer, descriptor, this.device);
148✔
580
    }
581
    return this.addBuffer(new GraphBufferHandle(this, descriptor, false, defaultBuffer));
148✔
582
  }
583

584
  /**
585
   * Declares one graph-owned scratch buffer.
586
   *
587
   * Compatible transient buffers with disjoint compiled lifetimes may share a physical allocation.
588
   */
589
  createTransientBuffer(descriptor: GraphBufferDescriptor): GraphBufferHandle {
590
    this.assertMutable();
334✔
591
    validateGraphBufferDescriptor(descriptor);
334✔
592
    return this.addBuffer(new GraphBufferHandle(this, descriptor, true));
334✔
593
  }
594

595
  /**
596
   * Creates one typed range over a graph buffer.
597
   *
598
   * The view is non-owning. Its layout is validated against the logical buffer capacity.
599
   */
600
  createDataView<T extends GPUVectorFormat>(
601
    buffer: GraphBufferHandle,
602
    props: {
603
      format: T;
604
      length: number;
605
      byteOffset?: number;
606
      byteStride?: number;
607
      rowByteLength?: number;
608
    }
609
  ): GraphDataView<T> {
610
    this.assertBuffer(buffer);
494✔
611
    const formatInfo = getGPUVectorFormatInfo(props.format);
494✔
612
    const byteOffset = props.byteOffset ?? 0;
494✔
613
    const rowByteLength = props.rowByteLength ?? formatInfo.byteLength;
494✔
614
    const byteStride = props.byteStride ?? rowByteLength;
494✔
615
    validateGraphDataView(buffer, {
494✔
616
      length: props.length,
617
      byteOffset,
618
      byteStride,
619
      rowByteLength
620
    });
621
    return new GraphDataView(buffer, {
493✔
622
      format: props.format,
623
      length: props.length,
624
      byteOffset,
625
      byteStride,
626
      rowByteLength
627
    });
628
  }
629

630
  /**
631
   * Imports one borrowed `GPUData` chunk and returns a typed view preserving its layout.
632
   *
633
   * The graph never destroys the imported buffer.
634
   */
635
  importGPUData<T extends GPUVectorFormat>(id: string, data: GPUData<T>): GraphDataView<T> {
636
    return this.importGPUDataView(id, data);
2✔
637
  }
638

639
  /**
640
   * Imports all chunks of one fixed-width `GPUVector` without packing them.
641
   *
642
   * Shared physical buffers map to one graph handle while each chunk retains its own offset and
643
   * layout. Interleaved and variable-length vectors are rejected.
644
   */
645
  importGPUVector<T extends GPUVectorFormat>(id: string, vector: GPUVector<T>): GraphVectorView<T> {
646
    if (vector.bufferLayout) {
8✔
647
      throw new Error(`GPUCommandGraph import "${id}" does not accept interleaved GPUVector data`);
1✔
648
    }
649
    const format = vector.format ?? vector.data[0]?.format;
7!
650
    if (!format) {
7!
UNCOV
651
      throw new Error(`GPUCommandGraph import "${id}" requires GPUVector.format`);
×
652
    }
653
    if (isVertexListGPUVectorFormat(format) || isValueListGPUVectorFormat(format)) {
7✔
654
      throw new Error(`GPUCommandGraph import "${id}" requires a fixed-width GPUVector format`);
1✔
655
    }
656
    const data = vector.data.map((chunk, chunkIndex) => {
6✔
657
      if (chunk.format !== format) {
14!
UNCOV
658
        throw new Error(`GPUCommandGraph import "${id}" requires matching GPUVector chunk formats`);
×
659
      }
660
      const chunkId = vector.data.length === 1 ? id : `${id}-chunk-${chunkIndex}`;
14!
661
      return this.importGPUDataView(chunkId, chunk);
14✔
662
    });
663
    return new GraphVectorView({
6✔
664
      id,
665
      name: vector.name,
666
      format,
667
      length: vector.length,
668
      valueLength: vector.valueLength,
669
      stride: vector.stride,
670
      byteStride: vector.byteStride,
671
      rowByteLength: vector.rowByteLength,
672
      data
673
    });
674
  }
675

676
  /**
677
   * Declares a caller-owned fixed-size texture supplied now or while encoding.
678
   *
679
   * Replacements must exactly match format, dimension, extent, mip count, and sample count.
680
   */
681
  importTexture<Format extends TextureFormat>(
682
    descriptor: GraphTextureDescriptor<Format>,
683
    defaultTexture?: GraphImportedTexture
684
  ): GraphTextureHandle<Format> {
685
    this.assertMutable();
3✔
686
    const normalizedDescriptor = normalizeGraphTextureDescriptor(descriptor, this.device);
3✔
687
    if (defaultTexture) {
3!
688
      validateImportedTexture(defaultTexture, normalizedDescriptor, this.device);
3✔
689
    }
690
    return this.addTexture(
2✔
691
      new GraphTextureHandle(this, normalizedDescriptor, false, defaultTexture)
692
    );
693
  }
694

695
  /**
696
   * Declares one graph-owned fixed-size transient texture.
697
   *
698
   * Descriptor-compatible textures with disjoint compiled lifetimes may share an allocation.
699
   */
700
  createTransientTexture<Format extends TextureFormat>(
701
    descriptor: GraphTextureDescriptor<Format>
702
  ): GraphTextureHandle<Format> {
703
    this.assertMutable();
10✔
704
    const normalizedDescriptor = normalizeGraphTextureDescriptor(descriptor, this.device);
10✔
705
    return this.addTexture(new GraphTextureHandle(this, normalizedDescriptor, true));
9✔
706
  }
707

708
  /**
709
   * Creates one logical mip, layer, and aspect range over a graph texture.
710
   *
711
   * The normalized range is used for texture hazard inference and concrete view creation.
712
   */
713
  createTextureView<Format extends TextureFormat>(
714
    texture: GraphTextureHandle<Format>,
715
    props: GraphTextureViewProps = {}
6✔
716
  ): GraphTextureView<Format> {
717
    this.assertTexture(texture);
11✔
718
    const normalizedProps = normalizeGraphTextureView(texture, props);
11✔
719
    return new GraphTextureView(texture, normalizedProps);
10✔
720
  }
721

722
  /**
723
   * Adds a compute node.
724
   *
725
   * The graph opens and closes the compute pass; the compiled executable only records commands.
726
   * Declared resource uses participate in automatic dependency inference.
727
   */
728
  addComputePass(node: Omit<GPUCommandGraphComputeNode<Parameters>, 'type'>): void {
729
    this.addNode({...node, type: 'compute'});
467✔
730
  }
731

732
  /**
733
   * Adds a render node.
734
   *
735
   * Graph attachments are validated, added to the node's resource uses, and resolved to a cached
736
   * framebuffer at encode time. The graph opens and closes the render pass.
737
   */
738
  addRenderPass(node: Omit<GPUCommandGraphRenderNode<Parameters>, 'type'>): void {
739
    if (node.attachments) {
2!
740
      this.validateRenderAttachments(node.id, node.attachments);
2✔
741
    }
742
    const attachmentUses: GraphTextureUse[] = node.attachments
2!
743
      ? [
744
          ...node.attachments.colorAttachments.map(texture => ({
3✔
745
            texture,
746
            usage: 'render-attachment' as const
747
          })),
748
          ...(node.attachments.depthStencilAttachment
2✔
749
            ? [
750
                {
751
                  texture: node.attachments.depthStencilAttachment,
752
                  usage: 'render-attachment' as const
753
                }
754
              ]
755
            : [])
756
        ]
757
      : [];
758
    this.addNode({
2✔
759
      ...node,
760
      resources: [...(node.resources ?? []), ...attachmentUses],
3✔
761
      type: 'render'
762
    });
763
  }
764

765
  /**
766
   * Adds a copy or pass-independent node.
767
   *
768
   * Its executable records directly into the caller-owned command encoder.
769
   */
770
  addCopyPass(node: Omit<GPUCommandGraphCopyNode<Parameters>, 'type'>): void {
771
    this.addNode({...node, type: 'copy'});
6✔
772
  }
773

774
  /**
775
   * Compiles scheduling, transient allocations, and executable node resources.
776
   *
777
   * Compilation freezes this graph. A graph can be compiled only once.
778
   *
779
   * @returns An executable graph that owns compiled node state and transient allocations.
780
   */
781
  compile(): CompiledGPUCommandGraph<Parameters> {
782
    this.assertMutable();
67✔
783
    this.compiled = true;
67✔
784
    return new CompiledGPUCommandGraph(
67✔
785
      compileGPUCommandGraph({
786
        device: this.device,
787
        id: this.id,
788
        buffers: this.buffers,
789
        textures: this.textures,
790
        nodes: this.nodes
791
      })
792
    );
793
  }
794

795
  private addNode(node: GPUCommandGraphNode<Parameters>): void {
796
    this.assertMutable();
475✔
797
    if (!node.id) {
475!
UNCOV
798
      throw new Error('GPUCommandGraph node id is required');
×
799
    }
800
    if (this.nodeIds.has(node.id)) {
475!
UNCOV
801
      throw new Error(`GPUCommandGraph node id "${node.id}" is already in use`);
×
802
    }
803
    for (const resource of node.resources ?? []) {
475✔
804
      if (isGraphBufferUse(resource)) {
1,294✔
805
        const buffer = getBufferHandle(resource.buffer);
1,277✔
806
        this.assertBuffer(buffer);
1,277✔
807
        validateBufferUseAgainstDescriptor(buffer, resource.usage);
1,277✔
808
      } else {
809
        const texture = getTextureHandle(resource.texture);
17✔
810
        this.assertTexture(texture);
17✔
811
        validateTextureUseAgainstDescriptor(texture, resource.usage);
17✔
812
        validateTextureViewForUsage(resource.texture, resource.usage);
16✔
813
      }
814
    }
815
    this.nodeIds.add(node.id);
473✔
816
    this.nodes.push(node);
473✔
817
  }
818

819
  private addBuffer(buffer: GraphBufferHandle): GraphBufferHandle {
820
    if (this.buffers.has(buffer.id) || this.textures.has(buffer.id)) {
482!
UNCOV
821
      throw new Error(`GPUCommandGraph resource id "${buffer.id}" is already in use`);
×
822
    }
823
    this.buffers.set(buffer.id, buffer);
482✔
824
    return buffer;
482✔
825
  }
826

827
  private addTexture<Format extends TextureFormat>(
828
    texture: GraphTextureHandle<Format>
829
  ): GraphTextureHandle<Format> {
830
    if (this.buffers.has(texture.id) || this.textures.has(texture.id)) {
11!
UNCOV
831
      throw new Error(`GPUCommandGraph resource id "${texture.id}" is already in use`);
×
832
    }
833
    this.textures.set(texture.id, texture);
11✔
834
    return texture;
11✔
835
  }
836

837
  private importGPUDataView<T extends GPUVectorFormat>(
838
    id: string,
839
    data: GPUData<T>
840
  ): GraphDataView<T> {
841
    if (!data.format) {
16!
UNCOV
842
      throw new Error(`GPUCommandGraph import "${id}" requires GPUData.format`);
×
843
    }
844
    const coreBuffer = getCoreBuffer(data.buffer);
16✔
845
    let handle = this.tableBufferHandles.get(coreBuffer);
16✔
846
    if (!handle) {
16✔
847
      handle = this.importBuffer(
14✔
848
        {id, byteLength: coreBuffer.byteLength, usage: coreBuffer.usage},
849
        data.buffer
850
      );
851
      this.tableBufferHandles.set(coreBuffer, handle);
14✔
852
    }
853
    return this.createDataView(handle, {
16✔
854
      format: data.format,
855
      length: data.length,
856
      byteOffset: data.byteOffset,
857
      byteStride: data.byteStride,
858
      rowByteLength: data.rowByteLength
859
    });
860
  }
861

862
  private assertBuffer(buffer: GraphBufferHandle): void {
863
    if (buffer.graph !== this || this.buffers.get(buffer.id) !== buffer) {
1,771!
UNCOV
864
      throw new Error(`Graph buffer "${buffer.id}" does not belong to ${this.id}`);
×
865
    }
866
  }
867

868
  private assertTexture(texture: GraphTextureHandle): void {
869
    if (texture.graph !== this || this.textures.get(texture.id) !== texture) {
32!
UNCOV
870
      throw new Error(`Graph texture "${texture.id}" does not belong to ${this.id}`);
×
871
    }
872
  }
873

874
  private assertMutable(): void {
875
    if (this.compiled) {
1,038!
UNCOV
876
      throw new Error(`GPUCommandGraph "${this.id}" has already been compiled`);
×
877
    }
878
  }
879

880
  private validateRenderAttachments(id: string, attachments: GraphRenderPassAttachments): void {
881
    if (attachments.colorAttachments.length === 0 && !attachments.depthStencilAttachment) {
2!
UNCOV
882
      throw new Error(`GPUCommandGraph render node "${id}" requires at least one attachment`);
×
883
    }
884
    const allAttachments = [
2✔
885
      ...attachments.colorAttachments,
886
      ...(attachments.depthStencilAttachment ? [attachments.depthStencilAttachment] : [])
2✔
887
    ];
888
    for (const attachment of allAttachments) {
2✔
889
      this.assertTexture(attachment.texture);
4✔
890
      if (
4!
891
        attachment.dimension !== '2d' ||
12✔
892
        attachment.mipLevelCount !== 1 ||
893
        attachment.arrayLayerCount !== 1
894
      ) {
UNCOV
895
        throw new Error(
×
896
          `GPUCommandGraph render node "${id}" attachments must be single-mip, single-layer 2d views`
897
        );
898
      }
899
    }
900
    const [first, ...remaining] = allAttachments;
2✔
901
    for (const attachment of remaining) {
2✔
902
      if (
2!
903
        attachment.width !== first.width ||
6✔
904
        attachment.height !== first.height ||
905
        attachment.texture.samples !== first.texture.samples
906
      ) {
UNCOV
907
        throw new Error(
×
908
          `GPUCommandGraph render node "${id}" attachments must have matching extent and samples`
909
        );
910
      }
911
    }
912
  }
913
}
914

915
/**
916
 * Executable, fixed-capacity command graph.
917
 *
918
 * The compiled graph owns transient allocations, compiled node state, and cached views and
919
 * framebuffers. Imported buffers and textures remain caller-owned.
920
 */
921
export class CompiledGPUCommandGraph<Parameters = void> {
922
  /** WebGPU device that owns the compiled resources. */
923
  readonly device: Device;
924
  /** Identifier inherited from the graph definition. */
925
  readonly id: string;
926
  /** Scheduling and transient-allocation statistics. */
927
  readonly stats: GPUCommandGraphStats;
928

929
  private readonly buffers: Map<string, GraphBufferHandle>;
930
  private readonly textures: Map<string, GraphTextureHandle>;
931
  private readonly compiledNodes: CompiledNode<Parameters>[];
932
  private readonly transientBuffers: Map<GraphBufferHandle, Buffer>;
933
  private readonly transientTextures: Map<GraphTextureHandle, Texture>;
934
  private readonly bufferTransientAllocations: BufferTransientAllocation[];
935
  private readonly textureTransientAllocations: TextureTransientAllocation[];
936
  private readonly cachedTextureViews: CachedTextureView[] = [];
65✔
937
  private readonly cachedFramebuffers: CachedFramebuffer[] = [];
65✔
938
  private destroyed = false;
65✔
939

940
  /** @internal */
941
  constructor(props: GPUCommandGraphCompilation<Parameters>) {
942
    this.device = props.device;
65✔
943
    this.id = props.id;
65✔
944
    this.buffers = props.buffers;
65✔
945
    this.textures = props.textures;
65✔
946
    this.compiledNodes = props.compiledNodes;
65✔
947
    this.transientBuffers = props.transientBuffers;
65✔
948
    this.transientTextures = props.transientTextures;
65✔
949
    this.bufferTransientAllocations = props.bufferTransientAllocations;
65✔
950
    this.textureTransientAllocations = props.textureTransientAllocations;
65✔
951
    this.stats = props.stats;
65✔
952
  }
953

954
  /**
955
   * Records every graph node into a caller-owned command encoder.
956
   *
957
   * Imported resources are resolved from per-encoding overrides first, then from defaults supplied
958
   * at graph construction. This method records only; it does not finish or submit the encoder.
959
   *
960
   * @param commandEncoder Encoder that receives all graph commands.
961
   * @param options Per-encoding parameters and optional imported-resource replacements.
962
   */
963
  encode(commandEncoder: CommandEncoder, options: GPUCommandGraphEncodeOptions<Parameters>): void {
964
    if (this.destroyed) {
73!
UNCOV
965
      throw new Error(`CompiledGPUCommandGraph "${this.id}" has been destroyed`);
×
966
    }
967
    if (commandEncoder.device !== this.device) {
73!
968
      throw new Error('GPUCommandGraph command encoder must belong to the graph device');
×
969
    }
970

971
    const importedBuffers = this.resolveImportedBuffers(options.buffers ?? {});
73✔
972
    const importedTextures = this.resolveImportedTextures(options.textures ?? {});
71✔
973
    const getBuffer = (bufferOrView: GraphBufferHandle | GraphDataView): Buffer => {
70✔
974
      const handle = getBufferHandle(bufferOrView);
1,288✔
975
      const buffer = handle.transient
1,288✔
976
        ? this.transientBuffers.get(handle)
977
        : importedBuffers.get(handle);
978
      if (!buffer) {
1,288!
UNCOV
979
        throw new Error(`GPUCommandGraph buffer "${handle.id}" is not bound`);
×
980
      }
981
      return buffer;
1,288✔
982
    };
983
    const getTexture = (textureOrView: GraphTextureHandle | GraphTextureView): Texture => {
70✔
984
      const handle = getTextureHandle(textureOrView);
21✔
985
      const texture = handle.transient
21✔
986
        ? this.transientTextures.get(handle)
987
        : importedTextures.get(handle);
988
      if (!texture) {
21!
UNCOV
989
        throw new Error(`GPUCommandGraph texture "${handle.id}" is not bound`);
×
990
      }
991
      return texture;
21✔
992
    };
993
    const getTextureView = (textureOrView: GraphTextureHandle | GraphTextureView): TextureView => {
70✔
994
      const texture = getTexture(textureOrView);
18✔
995
      if (textureOrView instanceof GraphTextureHandle || isDefaultGraphTextureView(textureOrView)) {
18!
996
        return texture.view;
18✔
997
      }
UNCOV
998
      const cached = this.cachedTextureViews.find(
×
UNCOV
999
        entry => entry.logicalView === textureOrView && entry.texture === texture
×
1000
      );
UNCOV
1001
      if (cached) {
×
UNCOV
1002
        return cached.view;
×
1003
      }
1004
      const view = texture.createView({
×
1005
        format: textureOrView.format,
1006
        dimension: textureOrView.dimension,
1007
        aspect: textureOrView.aspect,
1008
        baseMipLevel: textureOrView.baseMipLevel,
1009
        mipLevelCount: textureOrView.mipLevelCount,
1010
        baseArrayLayer: textureOrView.baseArrayLayer,
1011
        arrayLayerCount: textureOrView.arrayLayerCount
1012
      });
UNCOV
1013
      this.cachedTextureViews.push({logicalView: textureOrView, texture, view});
×
UNCOV
1014
      return view;
×
1015
    };
1016

1017
    const baseContext: GPUCommandGraphEncodeContext<Parameters> = {
70✔
1018
      commandEncoder,
1019
      parameters: options.parameters,
1020
      getBuffer,
1021
      getTexture,
1022
      getTextureView
1023
    };
1024

1025
    for (const {node, executable} of this.compiledNodes) {
70✔
1026
      switch (node.type) {
474✔
1027
        case 'compute': {
1028
          const computePass = commandEncoder.beginComputePass({id: node.id});
465✔
1029
          computePass.pushDebugGroup(node.id);
465✔
1030
          try {
465✔
1031
            (executable as GPUCommandGraphComputeExecutable<Parameters>).encode({
465✔
1032
              ...baseContext,
1033
              computePass
1034
            });
1035
          } finally {
1036
            computePass.popDebugGroup();
465✔
1037
            computePass.end();
465✔
1038
          }
1039
          break;
465✔
1040
        }
1041
        case 'render': {
1042
          const renderExecutable = executable as GPUCommandGraphRenderExecutable<Parameters>;
5✔
1043
          const renderPassProps = renderExecutable.getRenderPassProps?.(baseContext) ?? {
5✔
1044
            id: node.id
1045
          };
1046
          if (node.attachments && renderPassProps.framebuffer !== undefined) {
5!
UNCOV
1047
            throw new Error(
×
1048
              `GPUCommandGraph render node "${node.id}" cannot supply framebuffer with graph attachments`
1049
            );
1050
          }
1051
          const framebuffer = node.attachments
5!
1052
            ? this.getFramebuffer(node.id, node.attachments, getTextureView)
1053
            : undefined;
1054
          const renderPass = commandEncoder.beginRenderPass({
5✔
1055
            ...renderPassProps,
1056
            ...(framebuffer ? {framebuffer} : {})
5!
1057
          });
1058
          renderPass.pushDebugGroup(node.id);
5✔
1059
          try {
5✔
1060
            renderExecutable.encode({...baseContext, renderPass});
5✔
1061
          } finally {
1062
            renderPass.popDebugGroup();
5✔
1063
            renderPass.end();
5✔
1064
          }
1065
          break;
5✔
1066
        }
1067
        case 'copy':
1068
          (executable as GPUCommandGraphCopyExecutable<Parameters>).encode(baseContext);
4✔
1069
          break;
3✔
1070
      }
1071
    }
1072
  }
1073

1074
  /**
1075
   * Releases compiled node state, cached views and framebuffers, and graph-owned transients.
1076
   *
1077
   * Imported resources are borrowed and are never destroyed. Repeated calls are safe.
1078
   */
1079
  destroy(): void {
1080
    if (this.destroyed) {
65!
UNCOV
1081
      return;
×
1082
    }
1083
    for (const {executable} of this.compiledNodes) {
65✔
1084
      executable.destroy?.();
469✔
1085
    }
1086
    for (const cached of this.cachedFramebuffers) {
65✔
1087
      cached.framebuffer.destroy();
2✔
1088
    }
1089
    for (const cached of this.cachedTextureViews) {
65✔
UNCOV
1090
      cached.view.destroy();
×
1091
    }
1092
    for (const allocation of this.bufferTransientAllocations) {
65✔
1093
      allocation.buffer?.destroy();
66✔
1094
    }
1095
    for (const allocation of this.textureTransientAllocations) {
65✔
1096
      allocation.texture?.destroy();
6✔
1097
    }
1098
    this.destroyed = true;
65✔
1099
  }
1100

1101
  private resolveImportedBuffers(
1102
    overrides: Record<string, GraphImportedBuffer>
1103
  ): Map<GraphBufferHandle, Buffer> {
1104
    const resolved = new Map<GraphBufferHandle, Buffer>();
73✔
1105
    for (const [id, handle] of this.buffers) {
73✔
1106
      if (handle.transient) {
475✔
1107
        continue;
312✔
1108
      }
1109
      const importedBuffer = overrides[id] ?? handle.defaultBuffer;
163✔
1110
      if (!importedBuffer) {
163✔
1111
        throw new Error(`GPUCommandGraph imported buffer "${id}" is required`);
1✔
1112
      }
1113
      validateImportedBuffer(importedBuffer, handle, this.device);
162✔
1114
      resolved.set(handle, getCoreBuffer(importedBuffer));
161✔
1115
    }
1116
    for (const id of Object.keys(overrides)) {
71✔
1117
      const handle = this.buffers.get(id);
2✔
1118
      if (!handle || handle.transient) {
2!
UNCOV
1119
        throw new Error(`GPUCommandGraph has no imported buffer named "${id}"`);
×
1120
      }
1121
    }
1122
    return resolved;
71✔
1123
  }
1124

1125
  private resolveImportedTextures(
1126
    overrides: Record<string, GraphImportedTexture>
1127
  ): Map<GraphTextureHandle, Texture> {
1128
    const resolved = new Map<GraphTextureHandle, Texture>();
71✔
1129
    for (const [id, handle] of this.textures) {
71✔
1130
      if (handle.transient) {
18✔
1131
        continue;
13✔
1132
      }
1133
      const importedTexture = overrides[id] ?? handle.defaultTexture;
5✔
1134
      if (!importedTexture) {
5!
UNCOV
1135
        throw new Error(`GPUCommandGraph imported texture "${id}" is required`);
×
1136
      }
1137
      validateImportedTexture(importedTexture, handle, this.device);
5✔
1138
      resolved.set(handle, getCoreTexture(importedTexture));
4✔
1139
    }
1140
    for (const id of Object.keys(overrides)) {
70✔
1141
      const handle = this.textures.get(id);
1✔
1142
      if (!handle || handle.transient) {
1!
UNCOV
1143
        throw new Error(`GPUCommandGraph has no imported texture named "${id}"`);
×
1144
      }
1145
    }
1146
    return resolved;
70✔
1147
  }
1148

1149
  private getFramebuffer(
1150
    nodeId: string,
1151
    attachments: GraphRenderPassAttachments,
1152
    getTextureView: (texture: GraphTextureView) => TextureView
1153
  ): Framebuffer {
1154
    const colorAttachments = attachments.colorAttachments.map(getTextureView);
5✔
1155
    const depthStencilAttachment = attachments.depthStencilAttachment
5✔
1156
      ? getTextureView(attachments.depthStencilAttachment)
1157
      : undefined;
1158
    const cached = this.cachedFramebuffers.find(
5✔
1159
      entry =>
1160
        entry.nodeId === nodeId &&
3✔
1161
        entry.depthStencilAttachment === depthStencilAttachment &&
1162
        entry.colorAttachments.length === colorAttachments.length &&
1163
        entry.colorAttachments.every((view, index) => view === colorAttachments[index])
6✔
1164
    );
1165
    if (cached) {
5✔
1166
      return cached.framebuffer;
3✔
1167
    }
1168
    const firstLogicalAttachment =
1169
      attachments.colorAttachments[0] ?? attachments.depthStencilAttachment!;
2!
1170
    const framebuffer = this.device.createFramebuffer({
2✔
1171
      id: `${this.id}-${nodeId}-framebuffer-${this.cachedFramebuffers.length}`,
1172
      width: firstLogicalAttachment.width,
1173
      height: firstLogicalAttachment.height,
1174
      colorAttachments,
1175
      depthStencilAttachment: depthStencilAttachment ?? null
3✔
1176
    });
1177
    this.cachedFramebuffers.push({
2✔
1178
      nodeId,
1179
      colorAttachments,
1180
      depthStencilAttachment,
1181
      framebuffer
1182
    });
1183
    return framebuffer;
2✔
1184
  }
1185
}
1186

1187
/** Unwraps a dynamic import to the concrete buffer used for validation and encoding. */
1188
function getCoreBuffer(buffer: GraphImportedBuffer): Buffer {
1189
  return buffer instanceof DynamicBuffer ? buffer.buffer : buffer;
487✔
1190
}
1191

1192
/** Unwraps a ready dynamic import to the concrete texture used for validation and encoding. */
1193
function getCoreTexture(texture: GraphImportedTexture): Texture {
1194
  if (texture instanceof DynamicTexture) {
12!
UNCOV
1195
    if (!texture.isReady) {
×
UNCOV
1196
      throw new Error(`GPUCommandGraph dynamic texture "${texture.id}" is not ready`);
×
1197
    }
UNCOV
1198
    return texture.texture;
×
1199
  }
1200
  return texture;
12✔
1201
}
1202

1203
/** Validates graph identity, capacity, and usage fields for a logical buffer. */
1204
function validateGraphBufferDescriptor(descriptor: GraphBufferDescriptor): void {
1205
  if (!descriptor.id) {
483!
UNCOV
1206
    throw new Error('GPUCommandGraph buffer id is required');
×
1207
  }
1208
  if (!Number.isSafeInteger(descriptor.byteLength) || descriptor.byteLength < 0) {
483!
UNCOV
1209
    throw new Error(`GPUCommandGraph buffer "${descriptor.id}" requires a valid byteLength`);
×
1210
  }
1211
  if (!Number.isSafeInteger(descriptor.usage) || descriptor.usage <= 0) {
483!
UNCOV
1212
    throw new Error(`GPUCommandGraph buffer "${descriptor.id}" requires buffer usage flags`);
×
1213
  }
1214
}
1215

1216
/** Applies texture defaults and validates limits and dimension-specific invariants. */
1217
function normalizeGraphTextureDescriptor<Format extends TextureFormat>(
1218
  descriptor: GraphTextureDescriptor<Format>,
1219
  device: Device
1220
): NormalizedGraphTextureDescriptor<Format> {
1221
  if (!descriptor.id) {
13!
UNCOV
1222
    throw new Error('GPUCommandGraph texture id is required');
×
1223
  }
1224
  const dimension = descriptor.dimension ?? '2d';
13✔
1225
  const depth = dimension === 'cube' ? 6 : (descriptor.depth ?? 1);
13!
1226
  const mipLevels = descriptor.mipLevels ?? 1;
13✔
1227
  const samples = descriptor.samples ?? 1;
13✔
1228
  for (const [name, value] of Object.entries({
13✔
1229
    width: descriptor.width,
1230
    height: descriptor.height,
1231
    depth,
1232
    mipLevels,
1233
    samples
1234
  })) {
1235
    if (!Number.isSafeInteger(value) || value <= 0) {
61✔
1236
      throw new Error(
1✔
1237
        `GPUCommandGraph texture "${descriptor.id}" ${name} must be a positive safe integer`
1238
      );
1239
    }
1240
  }
1241
  if (!Number.isSafeInteger(descriptor.usage) || descriptor.usage <= 0) {
12!
UNCOV
1242
    throw new Error(`GPUCommandGraph texture "${descriptor.id}" requires texture usage flags`);
×
1243
  }
1244
  if (!device.isTextureFormatSupported(descriptor.format)) {
12!
UNCOV
1245
    throw new Error(
×
1246
      `GPUCommandGraph texture "${descriptor.id}" format ${descriptor.format} is unsupported`
1247
    );
1248
  }
1249
  if (dimension === '1d' && (descriptor.height !== 1 || depth !== 1)) {
12!
UNCOV
1250
    throw new Error(`GPUCommandGraph 1d texture "${descriptor.id}" requires height and depth 1`);
×
1251
  }
1252
  if (dimension === 'cube' && descriptor.width !== descriptor.height) {
12!
UNCOV
1253
    throw new Error(`GPUCommandGraph cube texture "${descriptor.id}" must be square`);
×
1254
  }
1255
  if (dimension === 'cube-array' && (descriptor.width !== descriptor.height || depth % 6 !== 0)) {
12!
UNCOV
1256
    throw new Error(
×
1257
      `GPUCommandGraph cube-array texture "${descriptor.id}" must be square with depth divisible by 6`
1258
    );
1259
  }
1260
  if (mipLevels > device.getMipLevelCount(descriptor.width, descriptor.height, depth)) {
12!
UNCOV
1261
    throw new Error(`GPUCommandGraph texture "${descriptor.id}" declares too many mip levels`);
×
1262
  }
1263
  return {
12✔
1264
    id: descriptor.id,
1265
    format: descriptor.format,
1266
    width: descriptor.width,
1267
    height: descriptor.height,
1268
    usage: descriptor.usage,
1269
    dimension,
1270
    depth,
1271
    mipLevels,
1272
    samples
1273
  };
1274
}
1275

1276
/** Applies view defaults, validates subresource bounds, and computes the selected extent. */
1277
function normalizeGraphTextureView<Format extends TextureFormat>(
1278
  texture: GraphTextureHandle<Format>,
1279
  props: GraphTextureViewProps
1280
): Required<GraphTextureViewProps> & {width: number; height: number; depth: number} {
1281
  const dimension = props.dimension ?? texture.dimension;
11✔
1282
  const aspect = props.aspect ?? 'all';
11✔
1283
  const baseMipLevel = props.baseMipLevel ?? 0;
11✔
1284
  const mipLevelCount = props.mipLevelCount ?? texture.mipLevels - baseMipLevel;
11✔
1285
  const baseArrayLayer = props.baseArrayLayer ?? 0;
11✔
1286
  const maximumArrayLayerCount = texture.dimension === '3d' ? 1 : texture.depth;
11!
1287
  const arrayLayerCount = props.arrayLayerCount ?? maximumArrayLayerCount - baseArrayLayer;
11✔
1288
  for (const [name, value] of Object.entries({
11✔
1289
    baseMipLevel,
1290
    mipLevelCount,
1291
    baseArrayLayer,
1292
    arrayLayerCount
1293
  })) {
1294
    if (!Number.isSafeInteger(value) || value < 0) {
44!
UNCOV
1295
      throw new Error(`Graph texture view ${name} must be a non-negative safe integer`);
×
1296
    }
1297
  }
1298
  if (mipLevelCount === 0 || baseMipLevel + mipLevelCount > texture.mipLevels) {
11✔
1299
    throw new Error(`Graph texture view exceeds texture "${texture.id}" mip levels`);
1✔
1300
  }
1301
  if (
10!
1302
    arrayLayerCount === 0 ||
30!
1303
    baseArrayLayer + arrayLayerCount > maximumArrayLayerCount ||
1304
    (texture.dimension === '3d' && (baseArrayLayer !== 0 || arrayLayerCount !== 1))
1305
  ) {
UNCOV
1306
    throw new Error(`Graph texture view exceeds texture "${texture.id}" array layers`);
×
1307
  }
1308
  const width = Math.max(1, texture.width >> baseMipLevel);
10✔
1309
  const height = texture.dimension === '1d' ? 1 : Math.max(1, texture.height >> baseMipLevel);
10!
1310
  const depth =
1311
    texture.dimension === '3d' ? Math.max(1, texture.depth >> baseMipLevel) : arrayLayerCount;
10!
1312
  return {
10✔
1313
    dimension,
1314
    aspect,
1315
    baseMipLevel,
1316
    mipLevelCount,
1317
    baseArrayLayer,
1318
    arrayLayerCount,
1319
    width,
1320
    height,
1321
    depth
1322
  };
1323
}
1324

1325
/** Validates a strided logical range against its buffer capacity. */
1326
function validateGraphDataView(
1327
  buffer: GraphBufferHandle,
1328
  props: {length: number; byteOffset: number; byteStride: number; rowByteLength: number}
1329
): void {
1330
  for (const [name, value] of Object.entries(props)) {
494✔
1331
    if (!Number.isSafeInteger(value) || value < 0) {
1,976!
UNCOV
1332
      throw new Error(`Graph data view ${name} must be a non-negative safe integer`);
×
1333
    }
1334
  }
1335
  if (props.length > 1 && props.byteStride === 0) {
494!
UNCOV
1336
    throw new Error('Graph data view byteStride must be positive for multiple rows');
×
1337
  }
1338
  if (props.rowByteLength > props.byteStride && props.length > 1) {
494!
UNCOV
1339
    throw new Error('Graph data view rowByteLength cannot exceed byteStride');
×
1340
  }
1341
  const byteLength =
1342
    props.length === 0 ? 0 : (props.length - 1) * props.byteStride + props.rowByteLength;
494✔
1343
  if (props.byteOffset + byteLength > buffer.byteLength) {
494✔
1344
    throw new Error(`Graph data view exceeds buffer "${buffer.id}" byte length`);
1✔
1345
  }
1346
}
1347

1348
/** Validates an imported buffer's device, capacity, and usage against its logical descriptor. */
1349
function validateImportedBuffer(
1350
  importedBuffer: GraphImportedBuffer,
1351
  descriptor: Pick<GraphBufferDescriptor, 'id' | 'byteLength' | 'usage'>,
1352
  device: Device
1353
): void {
1354
  const buffer = getCoreBuffer(importedBuffer);
310✔
1355
  if (buffer.device !== device) {
310✔
1356
    throw new Error(`GPUCommandGraph buffer "${descriptor.id}" belongs to another device`);
1✔
1357
  }
1358
  if (buffer.byteLength < descriptor.byteLength) {
309✔
1359
    throw new Error(`GPUCommandGraph buffer "${descriptor.id}" is smaller than compiled capacity`);
1✔
1360
  }
1361
  if ((buffer.usage & descriptor.usage) !== descriptor.usage) {
308!
UNCOV
1362
    throw new Error(`GPUCommandGraph buffer "${descriptor.id}" has incompatible usage flags`);
×
1363
  }
1364
}
1365

1366
/** Validates an imported texture's exact shape and format plus required usage flags. */
1367
function validateImportedTexture(
1368
  importedTexture: GraphImportedTexture,
1369
  descriptor: NormalizedGraphTextureDescriptor,
1370
  device: Device
1371
): void {
1372
  const texture = getCoreTexture(importedTexture);
8✔
1373
  if (texture.device !== device) {
8✔
1374
    throw new Error(`GPUCommandGraph texture "${descriptor.id}" belongs to another device`);
1✔
1375
  }
1376
  for (const [name, expected, actual] of [
7✔
1377
    ['format', descriptor.format, texture.format],
1378
    ['dimension', descriptor.dimension, texture.dimension],
1379
    ['width', descriptor.width, texture.width],
1380
    ['height', descriptor.height, texture.height],
1381
    ['depth', descriptor.depth, texture.depth],
1382
    ['mipLevels', descriptor.mipLevels, texture.mipLevels],
1383
    ['samples', descriptor.samples, texture.samples]
1384
  ] as const) {
1385
    if (actual !== expected) {
45✔
1386
      throw new Error(
1✔
1387
        `GPUCommandGraph texture "${descriptor.id}" has incompatible ${name} (${actual} !== ${expected})`
1388
      );
1389
    }
1390
  }
1391
  if ((texture.props.usage & descriptor.usage) !== descriptor.usage) {
6!
UNCOV
1392
    throw new Error(`GPUCommandGraph texture "${descriptor.id}" has incompatible usage flags`);
×
1393
  }
1394
}
1395

1396
/** Checks that a logical buffer descriptor permits a node's declared access mode. */
1397
function validateBufferUseAgainstDescriptor(
1398
  buffer: GraphBufferHandle,
1399
  usage: GraphBufferUsage
1400
): void {
1401
  const requiredUsage = getRequiredBufferUsage(usage);
1,277✔
1402
  if ((buffer.usage & requiredUsage) !== requiredUsage) {
1,277✔
1403
    throw new Error(
1✔
1404
      `GPUCommandGraph buffer "${buffer.id}" does not declare usage required by ${usage}`
1405
    );
1406
  }
1407
}
1408

1409
/** Checks that a logical texture descriptor permits a node's declared access mode. */
1410
function validateTextureUseAgainstDescriptor(
1411
  texture: GraphTextureHandle,
1412
  usage: GraphTextureUsage
1413
): void {
1414
  const requiredUsage = getRequiredTextureUsage(usage);
17✔
1415
  if ((texture.usage & requiredUsage) !== requiredUsage) {
17✔
1416
    throw new Error(
1✔
1417
      `GPUCommandGraph texture "${texture.id}" does not declare usage required by ${usage}`
1418
    );
1419
  }
1420
}
1421

1422
/** Validates view restrictions imposed by the declared texture access mode. */
1423
function validateTextureViewForUsage(
1424
  textureOrView: GraphTextureHandle | GraphTextureView,
1425
  usage: GraphTextureUsage
1426
): void {
1427
  if (
16!
1428
    textureOrView instanceof GraphTextureView &&
33✔
1429
    usage.startsWith('storage-') &&
1430
    textureOrView.mipLevelCount !== 1
1431
  ) {
UNCOV
1432
    throw new Error('GPUCommandGraph storage texture views must contain exactly one mip level');
×
1433
  }
1434
}
1435

1436
/** Maps a graph access mode to its required luma.gl buffer usage flag. */
1437
function getRequiredBufferUsage(usage: GraphBufferUsage): number {
1438
  switch (usage) {
1,277!
1439
    case 'storage-read':
1440
    case 'storage-write':
1441
    case 'storage-read-write':
1442
      return Buffer.STORAGE;
1,276✔
1443
    case 'uniform':
UNCOV
1444
      return Buffer.UNIFORM;
×
1445
    case 'copy-source':
UNCOV
1446
      return Buffer.COPY_SRC;
×
1447
    case 'copy-destination':
1448
      return Buffer.COPY_DST;
1✔
1449
    case 'indirect':
UNCOV
1450
      return Buffer.INDIRECT;
×
1451
    case 'vertex':
UNCOV
1452
      return Buffer.VERTEX;
×
1453
    case 'index':
UNCOV
1454
      return Buffer.INDEX;
×
1455
  }
1456
}
1457

1458
/** Maps a graph access mode to its required luma.gl texture usage flag. */
1459
function getRequiredTextureUsage(usage: GraphTextureUsage): number {
1460
  switch (usage) {
17!
1461
    case 'sampled':
1462
      return Texture.SAMPLE;
4✔
1463
    case 'storage-read':
1464
    case 'storage-write':
1465
    case 'storage-read-write':
1466
      return Texture.STORAGE;
7✔
1467
    case 'render-attachment':
1468
      return Texture.RENDER;
5✔
1469
    case 'copy-source':
1470
      return Texture.COPY_SRC;
1✔
1471
    case 'copy-destination':
UNCOV
1472
      return Texture.COPY_DST;
×
1473
  }
1474
}
1475

1476
/** Returns whether a logical view is exactly the texture's default full-resource view. */
1477
function isDefaultGraphTextureView(view: GraphTextureView): boolean {
1478
  const texture = view.texture;
18✔
1479
  return (
18✔
1480
    view.dimension === texture.dimension &&
108✔
1481
    view.aspect === 'all' &&
1482
    view.baseMipLevel === 0 &&
1483
    view.mipLevelCount === texture.mipLevels &&
1484
    view.baseArrayLayer === 0 &&
1485
    view.arrayLayerCount === (texture.dimension === '3d' ? 1 : texture.depth)
18!
1486
  );
1487
}
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