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

visgl / luma.gl / 28614092174

02 Jul 2026 06:51PM UTC coverage: 71.941% (+0.4%) from 71.566%
28614092174

Pull #2709

github

web-flow
Merge ec3444d29 into a9b583d51
Pull Request #2709: [codex] Add graph-native GPU data analysis

10762 of 16860 branches covered (63.83%)

Branch coverage included in aggregate %.

511 of 571 new or added lines in 9 files covered. (89.49%)

1 existing line in 1 file now uncovered.

21074 of 27393 relevant lines covered (76.93%)

5474.46 hits per line

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

82.01
/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, textureFormatDecoder} 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
} from '@luma.gl/tables';
23

24
/** GPU buffer use declared by one graph node. */
25
export type GraphBufferUsage =
26
  | 'storage-read'
27
  | 'storage-write'
28
  | 'storage-read-write'
29
  | 'uniform'
30
  | 'copy-source'
31
  | 'copy-destination'
32
  | 'indirect'
33
  | 'vertex'
34
  | 'index';
35

36
/** GPU texture use declared by one graph node. */
37
export type GraphTextureUsage =
38
  | 'sampled'
39
  | 'storage-read'
40
  | 'storage-write'
41
  | 'storage-read-write'
42
  | 'render-attachment'
43
  | 'copy-source'
44
  | 'copy-destination';
45

46
/** Buffer accepted as a fixed or per-encoding graph import. */
47
export type GraphImportedBuffer = Buffer | DynamicBuffer;
48

49
/** Texture accepted as a fixed or per-encoding graph import. */
50
export type GraphImportedTexture = Texture | DynamicTexture;
51

52
/** Descriptor for one imported or transient graph buffer. */
53
export type GraphBufferDescriptor = {
54
  id: string;
55
  byteLength: number;
56
  usage: number;
57
};
58

59
export type GraphTextureDimension = '1d' | '2d' | '2d-array' | 'cube' | 'cube-array' | '3d';
60
export type GraphTextureAspect = 'all' | 'stencil-only' | 'depth-only';
61

62
/** Descriptor for one fixed-size imported or transient graph texture. */
63
export type GraphTextureDescriptor<Format extends TextureFormat = TextureFormat> = {
64
  id: string;
65
  format: Format;
66
  width: number;
67
  height: number;
68
  usage: number;
69
  dimension?: GraphTextureDimension;
70
  depth?: number;
71
  mipLevels?: number;
72
  samples?: number;
73
};
74

75
type NormalizedGraphTextureDescriptor<Format extends TextureFormat = TextureFormat> = {
76
  id: string;
77
  format: Format;
78
  width: number;
79
  height: number;
80
  usage: number;
81
  dimension: GraphTextureDimension;
82
  depth: number;
83
  mipLevels: number;
84
  samples: number;
85
};
86

87
/** Opaque logical buffer tracked by a {@link GPUCommandGraph}. */
88
export class GraphBufferHandle {
89
  readonly id: string;
90
  readonly byteLength: number;
91
  readonly usage: number;
92
  readonly transient: boolean;
93
  /** @internal */
94
  readonly graph: GPUCommandGraph<any>;
95
  /** @internal */
96
  readonly defaultBuffer?: GraphImportedBuffer;
97

98
  /** @internal */
99
  constructor(
100
    graph: GPUCommandGraph<any>,
101
    descriptor: GraphBufferDescriptor,
102
    transient: boolean,
103
    defaultBuffer?: GraphImportedBuffer
104
  ) {
105
    this.graph = graph;
452✔
106
    this.id = descriptor.id;
452✔
107
    this.byteLength = descriptor.byteLength;
452✔
108
    this.usage = descriptor.usage;
452✔
109
    this.transient = transient;
452✔
110
    this.defaultBuffer = defaultBuffer;
452✔
111
  }
112
}
113

114
/** Typed logical range within one graph buffer. */
115
export class GraphBufferView<T extends GPUVectorFormat = GPUVectorFormat> {
116
  readonly buffer: GraphBufferHandle;
117
  readonly format: T;
118
  readonly length: number;
119
  readonly byteOffset: number;
120
  readonly byteStride: number;
121
  readonly rowByteLength: number;
122

123
  /** @internal */
124
  constructor(
125
    buffer: GraphBufferHandle,
126
    props: {
127
      format: T;
128
      length: number;
129
      byteOffset: number;
130
      byteStride: number;
131
      rowByteLength: number;
132
    }
133
  ) {
134
    this.buffer = buffer;
449✔
135
    this.format = props.format;
449✔
136
    this.length = props.length;
449✔
137
    this.byteOffset = props.byteOffset;
449✔
138
    this.byteStride = props.byteStride;
449✔
139
    this.rowByteLength = props.rowByteLength;
449✔
140
  }
141
}
142

143
/** Opaque logical texture tracked by a {@link GPUCommandGraph}. */
144
export class GraphTextureHandle<Format extends TextureFormat = TextureFormat> {
145
  readonly id: string;
146
  readonly format: Format;
147
  readonly width: number;
148
  readonly height: number;
149
  readonly usage: number;
150
  readonly dimension: GraphTextureDimension;
151
  readonly depth: number;
152
  readonly mipLevels: number;
153
  readonly samples: number;
154
  readonly transient: boolean;
155
  /** @internal */
156
  readonly graph: GPUCommandGraph<any>;
157
  /** @internal */
158
  readonly defaultTexture?: GraphImportedTexture;
159

160
  /** @internal */
161
  constructor(
162
    graph: GPUCommandGraph<any>,
163
    descriptor: NormalizedGraphTextureDescriptor<Format>,
164
    transient: boolean,
165
    defaultTexture?: GraphImportedTexture
166
  ) {
167
    this.graph = graph;
11✔
168
    this.id = descriptor.id;
11✔
169
    this.format = descriptor.format;
11✔
170
    this.width = descriptor.width;
11✔
171
    this.height = descriptor.height;
11✔
172
    this.usage = descriptor.usage;
11✔
173
    this.dimension = descriptor.dimension;
11✔
174
    this.depth = descriptor.depth;
11✔
175
    this.mipLevels = descriptor.mipLevels;
11✔
176
    this.samples = descriptor.samples;
11✔
177
    this.transient = transient;
11✔
178
    this.defaultTexture = defaultTexture;
11✔
179
  }
180
}
181

182
export type GraphTextureViewProps = {
183
  dimension?: GraphTextureDimension;
184
  aspect?: GraphTextureAspect;
185
  baseMipLevel?: number;
186
  mipLevelCount?: number;
187
  baseArrayLayer?: number;
188
  arrayLayerCount?: number;
189
};
190

191
/** Logical mip, layer, and aspect range within one graph texture. */
192
export class GraphTextureView<Format extends TextureFormat = TextureFormat> {
193
  readonly texture: GraphTextureHandle<Format>;
194
  readonly format: Format;
195
  readonly dimension: GraphTextureDimension;
196
  readonly aspect: GraphTextureAspect;
197
  readonly baseMipLevel: number;
198
  readonly mipLevelCount: number;
199
  readonly baseArrayLayer: number;
200
  readonly arrayLayerCount: number;
201
  readonly width: number;
202
  readonly height: number;
203
  readonly depth: number;
204

205
  /** @internal */
206
  constructor(
207
    texture: GraphTextureHandle<Format>,
208
    props: Required<GraphTextureViewProps> & {width: number; height: number; depth: number}
209
  ) {
210
    this.texture = texture;
10✔
211
    this.format = texture.format;
10✔
212
    this.dimension = props.dimension;
10✔
213
    this.aspect = props.aspect;
10✔
214
    this.baseMipLevel = props.baseMipLevel;
10✔
215
    this.mipLevelCount = props.mipLevelCount;
10✔
216
    this.baseArrayLayer = props.baseArrayLayer;
10✔
217
    this.arrayLayerCount = props.arrayLayerCount;
10✔
218
    this.width = props.width;
10✔
219
    this.height = props.height;
10✔
220
    this.depth = props.depth;
10✔
221
  }
222
}
223

224
/** One buffer use declared by a graph node. */
225
export type GraphBufferUse = {
226
  buffer: GraphBufferHandle | GraphBufferView;
227
  usage: GraphBufferUsage;
228
};
229

230
/** One texture or texture-view use declared by a graph node. */
231
export type GraphTextureUse = {
232
  texture: GraphTextureHandle | GraphTextureView;
233
  usage: GraphTextureUsage;
234
};
235

236
/** One resource use declared by a graph node. */
237
export type GraphResourceUse = GraphBufferUse | GraphTextureUse;
238

239
/** Graph-owned texture attachments resolved into a framebuffer for a render node. */
240
export type GraphRenderPassAttachments = {
241
  colorAttachments: GraphTextureView[];
242
  depthStencilAttachment?: GraphTextureView;
243
};
244

245
/** Context available while compiling one graph node. */
246
export type GPUCommandGraphCompileContext = {
247
  device: Device;
248
};
249

250
/** Context shared by every executable graph node. */
251
export type GPUCommandGraphEncodeContext<Parameters> = {
252
  commandEncoder: CommandEncoder;
253
  parameters: Parameters;
254
  getBuffer: (buffer: GraphBufferHandle | GraphBufferView) => Buffer;
255
  getTexture: (texture: GraphTextureHandle | GraphTextureView) => Texture;
256
  getTextureView: (texture: GraphTextureHandle | GraphTextureView) => TextureView;
257
};
258

259
/** Compiled compute-pass callback. */
260
export type GPUCommandGraphComputeExecutable<Parameters> = {
261
  encode: (context: GPUCommandGraphEncodeContext<Parameters> & {computePass: ComputePass}) => void;
262
  destroy?: () => void;
263
};
264

265
/** Compiled render-pass callback. */
266
export type GPUCommandGraphRenderExecutable<Parameters> = {
267
  getRenderPassProps?: (context: GPUCommandGraphEncodeContext<Parameters>) => RenderPassProps;
268
  encode: (context: GPUCommandGraphEncodeContext<Parameters> & {renderPass: RenderPass}) => void;
269
  destroy?: () => void;
270
};
271

272
/** Compiled command-encoder callback used for copies and other pass-independent commands. */
273
export type GPUCommandGraphCopyExecutable<Parameters> = {
274
  encode: (context: GPUCommandGraphEncodeContext<Parameters>) => void;
275
  destroy?: () => void;
276
};
277

278
type GPUCommandGraphNodeBase = {
279
  id: string;
280
  resources?: GraphResourceUse[];
281
  dependsOn?: string[];
282
};
283

284
export type GPUCommandGraphComputeNode<Parameters> = GPUCommandGraphNodeBase & {
285
  type: 'compute';
286
  compile: (context: GPUCommandGraphCompileContext) => GPUCommandGraphComputeExecutable<Parameters>;
287
};
288

289
export type GPUCommandGraphRenderNode<Parameters> = GPUCommandGraphNodeBase & {
290
  type: 'render';
291
  attachments?: GraphRenderPassAttachments;
292
  compile: (context: GPUCommandGraphCompileContext) => GPUCommandGraphRenderExecutable<Parameters>;
293
};
294

295
export type GPUCommandGraphCopyNode<Parameters> = GPUCommandGraphNodeBase & {
296
  type: 'copy';
297
  compile: (context: GPUCommandGraphCompileContext) => GPUCommandGraphCopyExecutable<Parameters>;
298
};
299

300
export type GPUCommandGraphNode<Parameters> =
301
  | GPUCommandGraphComputeNode<Parameters>
302
  | GPUCommandGraphRenderNode<Parameters>
303
  | GPUCommandGraphCopyNode<Parameters>;
304

305
/** Resource-allocation and scheduling statistics for one compiled graph. */
306
export type GPUCommandGraphStats = {
307
  nodeOrder: string[];
308
  logicalTransientBufferCount: number;
309
  physicalTransientBufferCount: number;
310
  logicalTransientBytes: number;
311
  physicalTransientBytes: number;
312
  reusedTransientBytes: number;
313
  reusePercentage: number;
314
  logicalTransientTextureCount: number;
315
  physicalTransientTextureCount: number;
316
  logicalTransientTextureBytes: number;
317
  physicalTransientTextureBytes: number;
318
  reusedTransientTextureBytes: number;
319
  textureReusePercentage: number;
320
};
321

322
/** Options supplied while encoding one compiled graph. */
323
export type GPUCommandGraphEncodeOptions<Parameters> = {
324
  parameters: Parameters;
325
  buffers?: Record<string, GraphImportedBuffer>;
326
  textures?: Record<string, GraphImportedTexture>;
327
};
328

329
type CompiledNode<Parameters> = {
330
  node: GPUCommandGraphNode<Parameters>;
331
  executable:
332
    | GPUCommandGraphComputeExecutable<Parameters>
333
    | GPUCommandGraphRenderExecutable<Parameters>
334
    | GPUCommandGraphCopyExecutable<Parameters>;
335
};
336

337
type BufferTransientAllocation = {
338
  byteLength: number;
339
  usage: number;
340
  lastUse: number;
341
  handles: GraphBufferHandle[];
342
  buffer?: Buffer;
343
};
344

345
type TextureTransientAllocation = {
346
  descriptor: NormalizedGraphTextureDescriptor;
347
  byteLength: number;
348
  lastUse: number;
349
  handles: GraphTextureHandle[];
350
  texture?: Texture;
351
};
352

353
type CachedTextureView = {
354
  logicalView: GraphTextureView;
355
  texture: Texture;
356
  view: TextureView;
357
};
358

359
type CachedFramebuffer = {
360
  nodeId: string;
361
  colorAttachments: TextureView[];
362
  depthStencilAttachment?: TextureView;
363
  framebuffer: Framebuffer;
364
};
365

366
/**
367
 * Declarative WebGPU command graph with explicit resource access and ownership.
368
 *
369
 * The graph compiles resource hazards, transient lifetimes, and node resources,
370
 * but encoding and submission remain controlled by the application.
371
 */
372
export class GPUCommandGraph<Parameters = void> {
373
  readonly device: Device;
374
  readonly id: string;
375

376
  private readonly buffers = new Map<string, GraphBufferHandle>();
71✔
377
  private readonly textures = new Map<string, GraphTextureHandle>();
71✔
378
  private readonly nodes: GPUCommandGraphNode<Parameters>[] = [];
71✔
379
  private readonly nodeIds = new Set<string>();
71✔
380
  private compiled = false;
71✔
381

382
  constructor(device: Device, props: {id?: string} = {}) {
35✔
383
    if (device.type !== 'webgpu') {
71!
384
      throw new Error('GPUCommandGraph requires a WebGPU device');
×
385
    }
386
    this.device = device;
71✔
387
    this.id = props.id ?? 'gpu-command-graph';
71✔
388
  }
389

390
  /** Declares a caller-owned buffer that can be supplied now or for each encoding. */
391
  importBuffer(
392
    descriptor: GraphBufferDescriptor,
393
    defaultBuffer?: GraphImportedBuffer
394
  ): GraphBufferHandle {
395
    this.assertMutable();
131✔
396
    validateGraphBufferDescriptor(descriptor);
131✔
397
    if (defaultBuffer) {
131✔
398
      validateImportedBuffer(defaultBuffer, descriptor, this.device);
130✔
399
    }
400
    return this.addBuffer(new GraphBufferHandle(this, descriptor, false, defaultBuffer));
130✔
401
  }
402

403
  /** Declares one graph-owned scratch buffer. */
404
  createTransientBuffer(descriptor: GraphBufferDescriptor): GraphBufferHandle {
405
    this.assertMutable();
322✔
406
    validateGraphBufferDescriptor(descriptor);
322✔
407
    return this.addBuffer(new GraphBufferHandle(this, descriptor, true));
322✔
408
  }
409

410
  /** Creates one typed range over a graph buffer. */
411
  createBufferView<T extends GPUVectorFormat>(
412
    buffer: GraphBufferHandle,
413
    props: {
414
      format: T;
415
      length: number;
416
      byteOffset?: number;
417
      byteStride?: number;
418
      rowByteLength?: number;
419
    }
420
  ): GraphBufferView<T> {
421
    this.assertBuffer(buffer);
450✔
422
    const formatInfo = getGPUVectorFormatInfo(props.format);
450✔
423
    const byteOffset = props.byteOffset ?? 0;
450✔
424
    const rowByteLength = props.rowByteLength ?? formatInfo.byteLength;
450✔
425
    const byteStride = props.byteStride ?? rowByteLength;
450✔
426
    validateGraphBufferView(buffer, {
450✔
427
      length: props.length,
428
      byteOffset,
429
      byteStride,
430
      rowByteLength
431
    });
432
    return new GraphBufferView(buffer, {
449✔
433
      format: props.format,
434
      length: props.length,
435
      byteOffset,
436
      byteStride,
437
      rowByteLength
438
    });
439
  }
440

441
  /** Imports one borrowed GPUData range and returns its typed graph view. */
442
  importGPUData<T extends GPUVectorFormat>(id: string, data: GPUData<T>): GraphBufferView<T> {
443
    if (!data.format) {
1!
444
      throw new Error(`GPUCommandGraph import "${id}" requires GPUData.format`);
×
445
    }
446
    const coreBuffer = getCoreBuffer(data.buffer);
1✔
447
    const handle = this.importBuffer(
1✔
448
      {id, byteLength: coreBuffer.byteLength, usage: coreBuffer.usage},
449
      data.buffer
450
    );
451
    return this.createBufferView(handle, {
1✔
452
      format: data.format,
453
      length: data.length,
454
      byteOffset: data.byteOffset,
455
      byteStride: data.byteStride,
456
      rowByteLength: data.rowByteLength
457
    });
458
  }
459

460
  /** Imports one packed, single-chunk GPUVector. */
461
  importGPUVector<T extends GPUVectorFormat>(id: string, vector: GPUVector<T>): GraphBufferView<T> {
462
    const [data, ...remainingData] = vector.data;
×
463
    if (!data || remainingData.length > 0) {
×
464
      throw new Error(`GPUCommandGraph import "${id}" requires exactly one GPUVector chunk`);
×
465
    }
466
    if (vector.bufferLayout) {
×
467
      throw new Error(`GPUCommandGraph import "${id}" does not accept interleaved GPUVector data`);
×
468
    }
469
    return this.importGPUData(id, data);
×
470
  }
471

472
  /** Declares a caller-owned fixed-size texture that can be supplied now or while encoding. */
473
  importTexture<Format extends TextureFormat>(
474
    descriptor: GraphTextureDescriptor<Format>,
475
    defaultTexture?: GraphImportedTexture
476
  ): GraphTextureHandle<Format> {
477
    this.assertMutable();
3✔
478
    const normalizedDescriptor = normalizeGraphTextureDescriptor(descriptor, this.device);
3✔
479
    if (defaultTexture) {
3!
480
      validateImportedTexture(defaultTexture, normalizedDescriptor, this.device);
3✔
481
    }
482
    return this.addTexture(
2✔
483
      new GraphTextureHandle(this, normalizedDescriptor, false, defaultTexture)
484
    );
485
  }
486

487
  /** Declares one graph-owned fixed-size transient texture. */
488
  createTransientTexture<Format extends TextureFormat>(
489
    descriptor: GraphTextureDescriptor<Format>
490
  ): GraphTextureHandle<Format> {
491
    this.assertMutable();
10✔
492
    const normalizedDescriptor = normalizeGraphTextureDescriptor(descriptor, this.device);
10✔
493
    return this.addTexture(new GraphTextureHandle(this, normalizedDescriptor, true));
9✔
494
  }
495

496
  /** Creates one logical mip, layer, and aspect range over a graph texture. */
497
  createTextureView<Format extends TextureFormat>(
498
    texture: GraphTextureHandle<Format>,
499
    props: GraphTextureViewProps = {}
6✔
500
  ): GraphTextureView<Format> {
501
    this.assertTexture(texture);
11✔
502
    const normalizedProps = normalizeGraphTextureView(texture, props);
11✔
503
    return new GraphTextureView(texture, normalizedProps);
10✔
504
  }
505

506
  addComputePass(node: Omit<GPUCommandGraphComputeNode<Parameters>, 'type'>): void {
507
    this.addNode({...node, type: 'compute'});
450✔
508
  }
509

510
  addRenderPass(node: Omit<GPUCommandGraphRenderNode<Parameters>, 'type'>): void {
511
    if (node.attachments) {
2!
512
      this.validateRenderAttachments(node.id, node.attachments);
2✔
513
    }
514
    const attachmentUses: GraphTextureUse[] = node.attachments
2!
515
      ? [
516
          ...node.attachments.colorAttachments.map(texture => ({
3✔
517
            texture,
518
            usage: 'render-attachment' as const
519
          })),
520
          ...(node.attachments.depthStencilAttachment
2✔
521
            ? [
522
                {
523
                  texture: node.attachments.depthStencilAttachment,
524
                  usage: 'render-attachment' as const
525
                }
526
              ]
527
            : [])
528
        ]
529
      : [];
530
    this.addNode({
2✔
531
      ...node,
532
      resources: [...(node.resources ?? []), ...attachmentUses],
3✔
533
      type: 'render'
534
    });
535
  }
536

537
  addCopyPass(node: Omit<GPUCommandGraphCopyNode<Parameters>, 'type'>): void {
538
    this.addNode({...node, type: 'copy'});
3✔
539
  }
540

541
  /** Compiles scheduling, transient allocations, and executable node resources. */
542
  compile(): CompiledGPUCommandGraph<Parameters> {
543
    this.assertMutable();
61✔
544
    this.compiled = true;
61✔
545
    const nodeOrder = getNodeOrder(this.nodes);
61✔
546
    const bufferPlan = getBufferTransientAllocationPlan(nodeOrder, this.buffers.values());
59✔
547
    const texturePlan = getTextureTransientAllocationPlan(nodeOrder, this.textures.values());
59✔
548
    const transientBuffers = new Map<GraphBufferHandle, Buffer>();
59✔
549
    const transientTextures = new Map<GraphTextureHandle, Texture>();
59✔
550

551
    for (const allocation of bufferPlan) {
59✔
552
      allocation.buffer = this.device.createBuffer({
54✔
553
        id: `${this.id}-transient-buffer-${bufferPlan.indexOf(allocation)}`,
554
        byteLength: allocation.byteLength,
555
        usage: allocation.usage
556
      });
557
      for (const handle of allocation.handles) {
54✔
558
        transientBuffers.set(handle, allocation.buffer);
303✔
559
      }
560
    }
561
    for (const allocation of texturePlan) {
59✔
562
      allocation.texture = this.device.createTexture({
6✔
563
        ...allocation.descriptor,
564
        id: `${this.id}-transient-texture-${texturePlan.indexOf(allocation)}`
565
      });
566
      for (const handle of allocation.handles) {
6✔
567
        transientTextures.set(handle, allocation.texture);
7✔
568
      }
569
    }
570

571
    const compiledNodes: CompiledNode<Parameters>[] = [];
59✔
572
    try {
59✔
573
      for (const node of nodeOrder) {
59✔
574
        compiledNodes.push({node, executable: node.compile({device: this.device})});
449✔
575
      }
576
    } catch (error) {
577
      for (const compiledNode of compiledNodes) {
×
578
        compiledNode.executable.destroy?.();
×
579
      }
NEW
580
      for (const allocation of bufferPlan) {
×
581
        allocation.buffer?.destroy();
×
582
      }
NEW
583
      for (const allocation of texturePlan) {
×
NEW
584
        allocation.texture?.destroy();
×
585
      }
UNCOV
586
      throw error;
×
587
    }
588

589
    const logicalTransientBytes = Array.from(this.buffers.values())
59✔
590
      .filter(buffer => buffer.transient)
433✔
591
      .reduce((sum, buffer) => sum + buffer.byteLength, 0);
303✔
592
    const physicalTransientBytes = bufferPlan.reduce(
59✔
593
      (sum, allocation) => sum + allocation.byteLength,
54✔
594
      0
595
    );
596
    const reusedTransientBytes = Math.max(0, logicalTransientBytes - physicalTransientBytes);
59✔
597
    const logicalTransientTextureBytes = Array.from(this.textures.values())
59✔
598
      .filter(texture => texture.transient)
9✔
599
      .reduce((sum, texture) => sum + getTextureByteLength(texture), 0);
7✔
600
    const physicalTransientTextureBytes = texturePlan.reduce(
59✔
601
      (sum, allocation) => sum + allocation.byteLength,
6✔
602
      0
603
    );
604
    const reusedTransientTextureBytes = Math.max(
59✔
605
      0,
606
      logicalTransientTextureBytes - physicalTransientTextureBytes
607
    );
608
    const stats: GPUCommandGraphStats = {
59✔
609
      nodeOrder: nodeOrder.map(node => node.id),
449✔
610
      logicalTransientBufferCount: Array.from(this.buffers.values()).filter(
611
        buffer => buffer.transient
433✔
612
      ).length,
613
      physicalTransientBufferCount: bufferPlan.length,
614
      logicalTransientBytes,
615
      physicalTransientBytes,
616
      reusedTransientBytes,
617
      reusePercentage:
618
        logicalTransientBytes > 0 ? (reusedTransientBytes / logicalTransientBytes) * 100 : 0,
59✔
619
      logicalTransientTextureCount: Array.from(this.textures.values()).filter(
620
        texture => texture.transient
9✔
621
      ).length,
622
      physicalTransientTextureCount: texturePlan.length,
623
      logicalTransientTextureBytes,
624
      physicalTransientTextureBytes,
625
      reusedTransientTextureBytes,
626
      textureReusePercentage:
627
        logicalTransientTextureBytes > 0
59✔
628
          ? (reusedTransientTextureBytes / logicalTransientTextureBytes) * 100
629
          : 0
630
    };
631

632
    return new CompiledGPUCommandGraph({
59✔
633
      device: this.device,
634
      id: this.id,
635
      buffers: new Map(this.buffers),
636
      textures: new Map(this.textures),
637
      compiledNodes,
638
      transientBuffers,
639
      transientTextures,
640
      bufferTransientAllocations: bufferPlan,
641
      textureTransientAllocations: texturePlan,
642
      stats
643
    });
644
  }
645

646
  private addNode(node: GPUCommandGraphNode<Parameters>): void {
647
    this.assertMutable();
455✔
648
    if (!node.id) {
455!
649
      throw new Error('GPUCommandGraph node id is required');
×
650
    }
651
    if (this.nodeIds.has(node.id)) {
455!
652
      throw new Error(`GPUCommandGraph node id "${node.id}" is already in use`);
×
653
    }
654
    for (const resource of node.resources ?? []) {
455✔
655
      if (isGraphBufferUse(resource)) {
1,249✔
656
        const buffer = getBufferHandle(resource.buffer);
1,232✔
657
        this.assertBuffer(buffer);
1,232✔
658
        validateBufferUseAgainstDescriptor(buffer, resource.usage);
1,232✔
659
      } else {
660
        const texture = getTextureHandle(resource.texture);
17✔
661
        this.assertTexture(texture);
17✔
662
        validateTextureUseAgainstDescriptor(texture, resource.usage);
17✔
663
        validateTextureViewForUsage(resource.texture, resource.usage);
16✔
664
      }
665
    }
666
    this.nodeIds.add(node.id);
453✔
667
    this.nodes.push(node);
453✔
668
  }
669

670
  private addBuffer(buffer: GraphBufferHandle): GraphBufferHandle {
671
    if (this.buffers.has(buffer.id) || this.textures.has(buffer.id)) {
452!
NEW
672
      throw new Error(`GPUCommandGraph resource id "${buffer.id}" is already in use`);
×
673
    }
674
    this.buffers.set(buffer.id, buffer);
452✔
675
    return buffer;
452✔
676
  }
677

678
  private addTexture<Format extends TextureFormat>(
679
    texture: GraphTextureHandle<Format>
680
  ): GraphTextureHandle<Format> {
681
    if (this.buffers.has(texture.id) || this.textures.has(texture.id)) {
11!
NEW
682
      throw new Error(`GPUCommandGraph resource id "${texture.id}" is already in use`);
×
683
    }
684
    this.textures.set(texture.id, texture);
11✔
685
    return texture;
11✔
686
  }
687

688
  private assertBuffer(buffer: GraphBufferHandle): void {
689
    if (buffer.graph !== this || this.buffers.get(buffer.id) !== buffer) {
1,682!
690
      throw new Error(`Graph buffer "${buffer.id}" does not belong to ${this.id}`);
×
691
    }
692
  }
693

694
  private assertTexture(texture: GraphTextureHandle): void {
695
    if (texture.graph !== this || this.textures.get(texture.id) !== texture) {
32!
NEW
696
      throw new Error(`Graph texture "${texture.id}" does not belong to ${this.id}`);
×
697
    }
698
  }
699

700
  private assertMutable(): void {
701
    if (this.compiled) {
982!
702
      throw new Error(`GPUCommandGraph "${this.id}" has already been compiled`);
×
703
    }
704
  }
705

706
  private validateRenderAttachments(id: string, attachments: GraphRenderPassAttachments): void {
707
    if (attachments.colorAttachments.length === 0 && !attachments.depthStencilAttachment) {
2!
NEW
708
      throw new Error(`GPUCommandGraph render node "${id}" requires at least one attachment`);
×
709
    }
710
    const allAttachments = [
2✔
711
      ...attachments.colorAttachments,
712
      ...(attachments.depthStencilAttachment ? [attachments.depthStencilAttachment] : [])
2✔
713
    ];
714
    for (const attachment of allAttachments) {
2✔
715
      this.assertTexture(attachment.texture);
4✔
716
      if (
4!
717
        attachment.dimension !== '2d' ||
12✔
718
        attachment.mipLevelCount !== 1 ||
719
        attachment.arrayLayerCount !== 1
720
      ) {
NEW
721
        throw new Error(
×
722
          `GPUCommandGraph render node "${id}" attachments must be single-mip, single-layer 2d views`
723
        );
724
      }
725
    }
726
    const [first, ...remaining] = allAttachments;
2✔
727
    for (const attachment of remaining) {
2✔
728
      if (
2!
729
        attachment.width !== first.width ||
6✔
730
        attachment.height !== first.height ||
731
        attachment.texture.samples !== first.texture.samples
732
      ) {
NEW
733
        throw new Error(
×
734
          `GPUCommandGraph render node "${id}" attachments must have matching extent and samples`
735
        );
736
      }
737
    }
738
  }
739
}
740

741
/** Executable, fixed-capacity command graph. */
742
export class CompiledGPUCommandGraph<Parameters = void> {
743
  readonly device: Device;
744
  readonly id: string;
745
  readonly stats: GPUCommandGraphStats;
746

747
  private readonly buffers: Map<string, GraphBufferHandle>;
748
  private readonly textures: Map<string, GraphTextureHandle>;
749
  private readonly compiledNodes: CompiledNode<Parameters>[];
750
  private readonly transientBuffers: Map<GraphBufferHandle, Buffer>;
751
  private readonly transientTextures: Map<GraphTextureHandle, Texture>;
752
  private readonly bufferTransientAllocations: BufferTransientAllocation[];
753
  private readonly textureTransientAllocations: TextureTransientAllocation[];
754
  private readonly cachedTextureViews: CachedTextureView[] = [];
59✔
755
  private readonly cachedFramebuffers: CachedFramebuffer[] = [];
59✔
756
  private destroyed = false;
59✔
757

758
  /** @internal */
759
  constructor(props: {
760
    device: Device;
761
    id: string;
762
    buffers: Map<string, GraphBufferHandle>;
763
    textures: Map<string, GraphTextureHandle>;
764
    compiledNodes: CompiledNode<Parameters>[];
765
    transientBuffers: Map<GraphBufferHandle, Buffer>;
766
    transientTextures: Map<GraphTextureHandle, Texture>;
767
    bufferTransientAllocations: BufferTransientAllocation[];
768
    textureTransientAllocations: TextureTransientAllocation[];
769
    stats: GPUCommandGraphStats;
770
  }) {
771
    this.device = props.device;
59✔
772
    this.id = props.id;
59✔
773
    this.buffers = props.buffers;
59✔
774
    this.textures = props.textures;
59✔
775
    this.compiledNodes = props.compiledNodes;
59✔
776
    this.transientBuffers = props.transientBuffers;
59✔
777
    this.transientTextures = props.transientTextures;
59✔
778
    this.bufferTransientAllocations = props.bufferTransientAllocations;
59✔
779
    this.textureTransientAllocations = props.textureTransientAllocations;
59✔
780
    this.stats = props.stats;
59✔
781
  }
782

783
  /** Records every graph node into a caller-owned command encoder. */
784
  encode(commandEncoder: CommandEncoder, options: GPUCommandGraphEncodeOptions<Parameters>): void {
785
    if (this.destroyed) {
68!
786
      throw new Error(`CompiledGPUCommandGraph "${this.id}" has been destroyed`);
×
787
    }
788
    if (commandEncoder.device !== this.device) {
68!
789
      throw new Error('GPUCommandGraph command encoder must belong to the graph device');
×
790
    }
791

792
    const importedBuffers = this.resolveImportedBuffers(options.buffers ?? {});
68✔
793
    const importedTextures = this.resolveImportedTextures(options.textures ?? {});
66✔
794
    const getBuffer = (bufferOrView: GraphBufferHandle | GraphBufferView): Buffer => {
65✔
795
      const handle = getBufferHandle(bufferOrView);
1,245✔
796
      const buffer = handle.transient
1,245✔
797
        ? this.transientBuffers.get(handle)
798
        : importedBuffers.get(handle);
799
      if (!buffer) {
1,245!
800
        throw new Error(`GPUCommandGraph buffer "${handle.id}" is not bound`);
×
801
      }
802
      return buffer;
1,245✔
803
    };
804
    const getTexture = (textureOrView: GraphTextureHandle | GraphTextureView): Texture => {
65✔
805
      const handle = getTextureHandle(textureOrView);
21✔
806
      const texture = handle.transient
21✔
807
        ? this.transientTextures.get(handle)
808
        : importedTextures.get(handle);
809
      if (!texture) {
21!
NEW
810
        throw new Error(`GPUCommandGraph texture "${handle.id}" is not bound`);
×
811
      }
812
      return texture;
21✔
813
    };
814
    const getTextureView = (textureOrView: GraphTextureHandle | GraphTextureView): TextureView => {
65✔
815
      const texture = getTexture(textureOrView);
18✔
816
      if (textureOrView instanceof GraphTextureHandle || isDefaultGraphTextureView(textureOrView)) {
18!
817
        return texture.view;
18✔
818
      }
NEW
819
      const cached = this.cachedTextureViews.find(
×
NEW
820
        entry => entry.logicalView === textureOrView && entry.texture === texture
×
821
      );
NEW
822
      if (cached) {
×
NEW
823
        return cached.view;
×
824
      }
NEW
825
      const view = texture.createView({
×
826
        format: textureOrView.format,
827
        dimension: textureOrView.dimension,
828
        aspect: textureOrView.aspect,
829
        baseMipLevel: textureOrView.baseMipLevel,
830
        mipLevelCount: textureOrView.mipLevelCount,
831
        baseArrayLayer: textureOrView.baseArrayLayer,
832
        arrayLayerCount: textureOrView.arrayLayerCount
833
      });
NEW
834
      this.cachedTextureViews.push({logicalView: textureOrView, texture, view});
×
NEW
835
      return view;
×
836
    };
837

838
    const baseContext: GPUCommandGraphEncodeContext<Parameters> = {
65✔
839
      commandEncoder,
840
      parameters: options.parameters,
841
      getBuffer,
842
      getTexture,
843
      getTextureView
844
    };
845

846
    for (const {node, executable} of this.compiledNodes) {
65✔
847
      switch (node.type) {
457✔
848
        case 'compute': {
849
          const computePass = commandEncoder.beginComputePass({id: node.id});
448✔
850
          computePass.pushDebugGroup(node.id);
448✔
851
          try {
448✔
852
            (executable as GPUCommandGraphComputeExecutable<Parameters>).encode({
448✔
853
              ...baseContext,
854
              computePass
855
            });
856
          } finally {
857
            computePass.popDebugGroup();
448✔
858
            computePass.end();
448✔
859
          }
860
          break;
448✔
861
        }
862
        case 'render': {
863
          const renderExecutable = executable as GPUCommandGraphRenderExecutable<Parameters>;
5✔
864
          const renderPassProps = renderExecutable.getRenderPassProps?.(baseContext) ?? {
5✔
865
            id: node.id
866
          };
867
          if (node.attachments && renderPassProps.framebuffer !== undefined) {
5!
NEW
868
            throw new Error(
×
869
              `GPUCommandGraph render node "${node.id}" cannot supply framebuffer with graph attachments`
870
            );
871
          }
872
          const framebuffer = node.attachments
5!
873
            ? this.getFramebuffer(node.id, node.attachments, getTextureView)
874
            : undefined;
875
          const renderPass = commandEncoder.beginRenderPass({
5✔
876
            ...renderPassProps,
877
            ...(framebuffer ? {framebuffer} : {})
5!
878
          });
879
          renderPass.pushDebugGroup(node.id);
5✔
880
          try {
5✔
881
            renderExecutable.encode({...baseContext, renderPass});
5✔
882
          } finally {
883
            renderPass.popDebugGroup();
5✔
884
            renderPass.end();
5✔
885
          }
886
          break;
5✔
887
        }
888
        case 'copy':
889
          (executable as GPUCommandGraphCopyExecutable<Parameters>).encode(baseContext);
4✔
890
          break;
3✔
891
      }
892
    }
893
  }
894

895
  /** Releases compiled node resources and graph-owned transient resources. */
896
  destroy(): void {
897
    if (this.destroyed) {
59!
898
      return;
×
899
    }
900
    for (const {executable} of this.compiledNodes) {
59✔
901
      executable.destroy?.();
449✔
902
    }
903
    for (const cached of this.cachedFramebuffers) {
59✔
904
      cached.framebuffer.destroy();
2✔
905
    }
906
    for (const cached of this.cachedTextureViews) {
59✔
NEW
907
      cached.view.destroy();
×
908
    }
909
    for (const allocation of this.bufferTransientAllocations) {
59✔
910
      allocation.buffer?.destroy();
54✔
911
    }
912
    for (const allocation of this.textureTransientAllocations) {
59✔
913
      allocation.texture?.destroy();
6✔
914
    }
915
    this.destroyed = true;
59✔
916
  }
917

918
  private resolveImportedBuffers(
919
    overrides: Record<string, GraphImportedBuffer>
920
  ): Map<GraphBufferHandle, Buffer> {
921
    const resolved = new Map<GraphBufferHandle, Buffer>();
68✔
922
    for (const [id, handle] of this.buffers) {
68✔
923
      if (handle.transient) {
447✔
924
        continue;
300✔
925
      }
926
      const importedBuffer = overrides[id] ?? handle.defaultBuffer;
147✔
927
      if (!importedBuffer) {
147✔
928
        throw new Error(`GPUCommandGraph imported buffer "${id}" is required`);
1✔
929
      }
930
      validateImportedBuffer(importedBuffer, handle, this.device);
146✔
931
      resolved.set(handle, getCoreBuffer(importedBuffer));
145✔
932
    }
933
    for (const id of Object.keys(overrides)) {
66✔
934
      const handle = this.buffers.get(id);
2✔
935
      if (!handle || handle.transient) {
2!
936
        throw new Error(`GPUCommandGraph has no imported buffer named "${id}"`);
×
937
      }
938
    }
939
    return resolved;
66✔
940
  }
941

942
  private resolveImportedTextures(
943
    overrides: Record<string, GraphImportedTexture>
944
  ): Map<GraphTextureHandle, Texture> {
945
    const resolved = new Map<GraphTextureHandle, Texture>();
66✔
946
    for (const [id, handle] of this.textures) {
66✔
947
      if (handle.transient) {
18✔
948
        continue;
13✔
949
      }
950
      const importedTexture = overrides[id] ?? handle.defaultTexture;
5✔
951
      if (!importedTexture) {
5!
NEW
952
        throw new Error(`GPUCommandGraph imported texture "${id}" is required`);
×
953
      }
954
      validateImportedTexture(importedTexture, handle, this.device);
5✔
955
      resolved.set(handle, getCoreTexture(importedTexture));
4✔
956
    }
957
    for (const id of Object.keys(overrides)) {
65✔
958
      const handle = this.textures.get(id);
1✔
959
      if (!handle || handle.transient) {
1!
NEW
960
        throw new Error(`GPUCommandGraph has no imported texture named "${id}"`);
×
961
      }
962
    }
963
    return resolved;
65✔
964
  }
965

966
  private getFramebuffer(
967
    nodeId: string,
968
    attachments: GraphRenderPassAttachments,
969
    getTextureView: (texture: GraphTextureView) => TextureView
970
  ): Framebuffer {
971
    const colorAttachments = attachments.colorAttachments.map(getTextureView);
5✔
972
    const depthStencilAttachment = attachments.depthStencilAttachment
5✔
973
      ? getTextureView(attachments.depthStencilAttachment)
974
      : undefined;
975
    const cached = this.cachedFramebuffers.find(
5✔
976
      entry =>
977
        entry.nodeId === nodeId &&
3✔
978
        entry.depthStencilAttachment === depthStencilAttachment &&
979
        entry.colorAttachments.length === colorAttachments.length &&
980
        entry.colorAttachments.every((view, index) => view === colorAttachments[index])
6✔
981
    );
982
    if (cached) {
5✔
983
      return cached.framebuffer;
3✔
984
    }
985
    const firstLogicalAttachment =
986
      attachments.colorAttachments[0] ?? attachments.depthStencilAttachment!;
2!
987
    const framebuffer = this.device.createFramebuffer({
2✔
988
      id: `${this.id}-${nodeId}-framebuffer-${this.cachedFramebuffers.length}`,
989
      width: firstLogicalAttachment.width,
990
      height: firstLogicalAttachment.height,
991
      colorAttachments,
992
      depthStencilAttachment: depthStencilAttachment ?? null
3✔
993
    });
994
    this.cachedFramebuffers.push({
2✔
995
      nodeId,
996
      colorAttachments,
997
      depthStencilAttachment,
998
      framebuffer
999
    });
1000
    return framebuffer;
2✔
1001
  }
1002
}
1003

1004
function getBufferHandle(buffer: GraphBufferHandle | GraphBufferView): GraphBufferHandle {
1005
  return buffer instanceof GraphBufferView ? buffer.buffer : buffer;
4,939✔
1006
}
1007

1008
function getTextureHandle(texture: GraphTextureHandle | GraphTextureView): GraphTextureHandle {
1009
  return texture instanceof GraphTextureView ? texture.texture : texture;
80✔
1010
}
1011

1012
function getCoreBuffer(buffer: GraphImportedBuffer): Buffer {
1013
  return buffer instanceof DynamicBuffer ? buffer.buffer : buffer;
422✔
1014
}
1015

1016
function getCoreTexture(texture: GraphImportedTexture): Texture {
1017
  if (texture instanceof DynamicTexture) {
12!
NEW
1018
    if (!texture.isReady) {
×
NEW
1019
      throw new Error(`GPUCommandGraph dynamic texture "${texture.id}" is not ready`);
×
1020
    }
NEW
1021
    return texture.texture;
×
1022
  }
1023
  return texture;
12✔
1024
}
1025

1026
function isGraphBufferUse(resource: GraphResourceUse): resource is GraphBufferUse {
1027
  return 'buffer' in resource;
4,986✔
1028
}
1029

1030
function validateGraphBufferDescriptor(descriptor: GraphBufferDescriptor): void {
1031
  if (!descriptor.id) {
453!
1032
    throw new Error('GPUCommandGraph buffer id is required');
×
1033
  }
1034
  if (!Number.isSafeInteger(descriptor.byteLength) || descriptor.byteLength < 0) {
453!
1035
    throw new Error(`GPUCommandGraph buffer "${descriptor.id}" requires a valid byteLength`);
×
1036
  }
1037
  if (!Number.isSafeInteger(descriptor.usage) || descriptor.usage <= 0) {
453!
1038
    throw new Error(`GPUCommandGraph buffer "${descriptor.id}" requires buffer usage flags`);
×
1039
  }
1040
}
1041

1042
function normalizeGraphTextureDescriptor<Format extends TextureFormat>(
1043
  descriptor: GraphTextureDescriptor<Format>,
1044
  device: Device
1045
): NormalizedGraphTextureDescriptor<Format> {
1046
  if (!descriptor.id) {
13!
NEW
1047
    throw new Error('GPUCommandGraph texture id is required');
×
1048
  }
1049
  const dimension = descriptor.dimension ?? '2d';
13✔
1050
  const depth = dimension === 'cube' ? 6 : (descriptor.depth ?? 1);
13!
1051
  const mipLevels = descriptor.mipLevels ?? 1;
13✔
1052
  const samples = descriptor.samples ?? 1;
13✔
1053
  for (const [name, value] of Object.entries({
13✔
1054
    width: descriptor.width,
1055
    height: descriptor.height,
1056
    depth,
1057
    mipLevels,
1058
    samples
1059
  })) {
1060
    if (!Number.isSafeInteger(value) || value <= 0) {
61✔
1061
      throw new Error(
1✔
1062
        `GPUCommandGraph texture "${descriptor.id}" ${name} must be a positive safe integer`
1063
      );
1064
    }
1065
  }
1066
  if (!Number.isSafeInteger(descriptor.usage) || descriptor.usage <= 0) {
12!
NEW
1067
    throw new Error(`GPUCommandGraph texture "${descriptor.id}" requires texture usage flags`);
×
1068
  }
1069
  if (!device.isTextureFormatSupported(descriptor.format)) {
12!
NEW
1070
    throw new Error(
×
1071
      `GPUCommandGraph texture "${descriptor.id}" format ${descriptor.format} is unsupported`
1072
    );
1073
  }
1074
  if (dimension === '1d' && (descriptor.height !== 1 || depth !== 1)) {
12!
NEW
1075
    throw new Error(`GPUCommandGraph 1d texture "${descriptor.id}" requires height and depth 1`);
×
1076
  }
1077
  if (dimension === 'cube' && descriptor.width !== descriptor.height) {
12!
NEW
1078
    throw new Error(`GPUCommandGraph cube texture "${descriptor.id}" must be square`);
×
1079
  }
1080
  if (dimension === 'cube-array' && (descriptor.width !== descriptor.height || depth % 6 !== 0)) {
12!
NEW
1081
    throw new Error(
×
1082
      `GPUCommandGraph cube-array texture "${descriptor.id}" must be square with depth divisible by 6`
1083
    );
1084
  }
1085
  if (mipLevels > device.getMipLevelCount(descriptor.width, descriptor.height, depth)) {
12!
NEW
1086
    throw new Error(`GPUCommandGraph texture "${descriptor.id}" declares too many mip levels`);
×
1087
  }
1088
  return {
12✔
1089
    id: descriptor.id,
1090
    format: descriptor.format,
1091
    width: descriptor.width,
1092
    height: descriptor.height,
1093
    usage: descriptor.usage,
1094
    dimension,
1095
    depth,
1096
    mipLevels,
1097
    samples
1098
  };
1099
}
1100

1101
function normalizeGraphTextureView<Format extends TextureFormat>(
1102
  texture: GraphTextureHandle<Format>,
1103
  props: GraphTextureViewProps
1104
): Required<GraphTextureViewProps> & {width: number; height: number; depth: number} {
1105
  const dimension = props.dimension ?? texture.dimension;
11✔
1106
  const aspect = props.aspect ?? 'all';
11✔
1107
  const baseMipLevel = props.baseMipLevel ?? 0;
11✔
1108
  const mipLevelCount = props.mipLevelCount ?? texture.mipLevels - baseMipLevel;
11✔
1109
  const baseArrayLayer = props.baseArrayLayer ?? 0;
11✔
1110
  const maximumArrayLayerCount = texture.dimension === '3d' ? 1 : texture.depth;
11!
1111
  const arrayLayerCount = props.arrayLayerCount ?? maximumArrayLayerCount - baseArrayLayer;
11✔
1112
  for (const [name, value] of Object.entries({
11✔
1113
    baseMipLevel,
1114
    mipLevelCount,
1115
    baseArrayLayer,
1116
    arrayLayerCount
1117
  })) {
1118
    if (!Number.isSafeInteger(value) || value < 0) {
44!
NEW
1119
      throw new Error(`Graph texture view ${name} must be a non-negative safe integer`);
×
1120
    }
1121
  }
1122
  if (mipLevelCount === 0 || baseMipLevel + mipLevelCount > texture.mipLevels) {
11✔
1123
    throw new Error(`Graph texture view exceeds texture "${texture.id}" mip levels`);
1✔
1124
  }
1125
  if (
10!
1126
    arrayLayerCount === 0 ||
30!
1127
    baseArrayLayer + arrayLayerCount > maximumArrayLayerCount ||
1128
    (texture.dimension === '3d' && (baseArrayLayer !== 0 || arrayLayerCount !== 1))
1129
  ) {
NEW
1130
    throw new Error(`Graph texture view exceeds texture "${texture.id}" array layers`);
×
1131
  }
1132
  const width = Math.max(1, texture.width >> baseMipLevel);
10✔
1133
  const height = texture.dimension === '1d' ? 1 : Math.max(1, texture.height >> baseMipLevel);
10!
1134
  const depth =
1135
    texture.dimension === '3d' ? Math.max(1, texture.depth >> baseMipLevel) : arrayLayerCount;
10!
1136
  return {
10✔
1137
    dimension,
1138
    aspect,
1139
    baseMipLevel,
1140
    mipLevelCount,
1141
    baseArrayLayer,
1142
    arrayLayerCount,
1143
    width,
1144
    height,
1145
    depth
1146
  };
1147
}
1148

1149
function validateGraphBufferView(
1150
  buffer: GraphBufferHandle,
1151
  props: {length: number; byteOffset: number; byteStride: number; rowByteLength: number}
1152
): void {
1153
  for (const [name, value] of Object.entries(props)) {
450✔
1154
    if (!Number.isSafeInteger(value) || value < 0) {
1,800!
1155
      throw new Error(`Graph buffer view ${name} must be a non-negative safe integer`);
×
1156
    }
1157
  }
1158
  if (props.length > 1 && props.byteStride === 0) {
450!
1159
    throw new Error('Graph buffer view byteStride must be positive for multiple rows');
×
1160
  }
1161
  if (props.rowByteLength > props.byteStride && props.length > 1) {
450!
1162
    throw new Error('Graph buffer view rowByteLength cannot exceed byteStride');
×
1163
  }
1164
  const byteLength =
1165
    props.length === 0 ? 0 : (props.length - 1) * props.byteStride + props.rowByteLength;
450✔
1166
  if (props.byteOffset + byteLength > buffer.byteLength) {
450✔
1167
    throw new Error(`Graph buffer view exceeds buffer "${buffer.id}" byte length`);
1✔
1168
  }
1169
}
1170

1171
function validateImportedBuffer(
1172
  importedBuffer: GraphImportedBuffer,
1173
  descriptor: Pick<GraphBufferDescriptor, 'id' | 'byteLength' | 'usage'>,
1174
  device: Device
1175
): void {
1176
  const buffer = getCoreBuffer(importedBuffer);
276✔
1177
  if (buffer.device !== device) {
276✔
1178
    throw new Error(`GPUCommandGraph buffer "${descriptor.id}" belongs to another device`);
1✔
1179
  }
1180
  if (buffer.byteLength < descriptor.byteLength) {
275✔
1181
    throw new Error(`GPUCommandGraph buffer "${descriptor.id}" is smaller than compiled capacity`);
1✔
1182
  }
1183
  if ((buffer.usage & descriptor.usage) !== descriptor.usage) {
274!
1184
    throw new Error(`GPUCommandGraph buffer "${descriptor.id}" has incompatible usage flags`);
×
1185
  }
1186
}
1187

1188
function validateImportedTexture(
1189
  importedTexture: GraphImportedTexture,
1190
  descriptor: NormalizedGraphTextureDescriptor,
1191
  device: Device
1192
): void {
1193
  const texture = getCoreTexture(importedTexture);
8✔
1194
  if (texture.device !== device) {
8✔
1195
    throw new Error(`GPUCommandGraph texture "${descriptor.id}" belongs to another device`);
1✔
1196
  }
1197
  for (const [name, expected, actual] of [
7✔
1198
    ['format', descriptor.format, texture.format],
1199
    ['dimension', descriptor.dimension, texture.dimension],
1200
    ['width', descriptor.width, texture.width],
1201
    ['height', descriptor.height, texture.height],
1202
    ['depth', descriptor.depth, texture.depth],
1203
    ['mipLevels', descriptor.mipLevels, texture.mipLevels],
1204
    ['samples', descriptor.samples, texture.samples]
1205
  ] as const) {
1206
    if (actual !== expected) {
45✔
1207
      throw new Error(
1✔
1208
        `GPUCommandGraph texture "${descriptor.id}" has incompatible ${name} (${actual} !== ${expected})`
1209
      );
1210
    }
1211
  }
1212
  if ((texture.props.usage & descriptor.usage) !== descriptor.usage) {
6!
NEW
1213
    throw new Error(`GPUCommandGraph texture "${descriptor.id}" has incompatible usage flags`);
×
1214
  }
1215
}
1216

1217
function validateBufferUseAgainstDescriptor(
1218
  buffer: GraphBufferHandle,
1219
  usage: GraphBufferUsage
1220
): void {
1221
  const requiredUsage = getRequiredBufferUsage(usage);
1,232✔
1222
  if ((buffer.usage & requiredUsage) !== requiredUsage) {
1,232✔
1223
    throw new Error(
1✔
1224
      `GPUCommandGraph buffer "${buffer.id}" does not declare usage required by ${usage}`
1225
    );
1226
  }
1227
}
1228

1229
function validateTextureUseAgainstDescriptor(
1230
  texture: GraphTextureHandle,
1231
  usage: GraphTextureUsage
1232
): void {
1233
  const requiredUsage = getRequiredTextureUsage(usage);
17✔
1234
  if ((texture.usage & requiredUsage) !== requiredUsage) {
17✔
1235
    throw new Error(
1✔
1236
      `GPUCommandGraph texture "${texture.id}" does not declare usage required by ${usage}`
1237
    );
1238
  }
1239
}
1240

1241
function validateTextureViewForUsage(
1242
  textureOrView: GraphTextureHandle | GraphTextureView,
1243
  usage: GraphTextureUsage
1244
): void {
1245
  if (
16!
1246
    textureOrView instanceof GraphTextureView &&
33✔
1247
    usage.startsWith('storage-') &&
1248
    textureOrView.mipLevelCount !== 1
1249
  ) {
NEW
1250
    throw new Error('GPUCommandGraph storage texture views must contain exactly one mip level');
×
1251
  }
1252
}
1253

1254
function getRequiredBufferUsage(usage: GraphBufferUsage): number {
1255
  switch (usage) {
1,232!
1256
    case 'storage-read':
1257
    case 'storage-write':
1258
    case 'storage-read-write':
1259
      return Buffer.STORAGE;
1,231✔
1260
    case 'uniform':
1261
      return Buffer.UNIFORM;
×
1262
    case 'copy-source':
1263
      return Buffer.COPY_SRC;
×
1264
    case 'copy-destination':
1265
      return Buffer.COPY_DST;
1✔
1266
    case 'indirect':
1267
      return Buffer.INDIRECT;
×
1268
    case 'vertex':
1269
      return Buffer.VERTEX;
×
1270
    case 'index':
1271
      return Buffer.INDEX;
×
1272
  }
1273
}
1274

1275
function getRequiredTextureUsage(usage: GraphTextureUsage): number {
1276
  switch (usage) {
17!
1277
    case 'sampled':
1278
      return Texture.SAMPLE;
4✔
1279
    case 'storage-read':
1280
    case 'storage-write':
1281
    case 'storage-read-write':
1282
      return Texture.STORAGE;
7✔
1283
    case 'render-attachment':
1284
      return Texture.RENDER;
5✔
1285
    case 'copy-source':
1286
      return Texture.COPY_SRC;
1✔
1287
    case 'copy-destination':
NEW
1288
      return Texture.COPY_DST;
×
1289
  }
1290
}
1291

1292
function isBufferReadUsage(usage: GraphBufferUsage): boolean {
1293
  return (
1,231✔
1294
    usage === 'storage-read' ||
4,316✔
1295
    usage === 'storage-read-write' ||
1296
    usage === 'uniform' ||
1297
    usage === 'copy-source' ||
1298
    usage === 'indirect' ||
1299
    usage === 'vertex' ||
1300
    usage === 'index'
1301
  );
1302
}
1303

1304
function isBufferWriteUsage(usage: GraphBufferUsage): boolean {
1305
  return (
1,231✔
1306
    usage === 'storage-write' || usage === 'storage-read-write' || usage === 'copy-destination'
2,614✔
1307
  );
1308
}
1309

1310
function isTextureReadUsage(usage: GraphTextureUsage): boolean {
1311
  return (
6✔
1312
    usage === 'sampled' ||
15✔
1313
    usage === 'storage-read' ||
1314
    usage === 'storage-read-write' ||
1315
    usage === 'render-attachment' ||
1316
    usage === 'copy-source'
1317
  );
1318
}
1319

1320
function isTextureWriteUsage(usage: GraphTextureUsage): boolean {
1321
  return (
5✔
1322
    usage === 'storage-write' ||
7!
1323
    usage === 'storage-read-write' ||
1324
    usage === 'render-attachment' ||
1325
    usage === 'copy-destination'
1326
  );
1327
}
1328

1329
function getNodeOrder<Parameters>(
1330
  nodes: GPUCommandGraphNode<Parameters>[]
1331
): GPUCommandGraphNode<Parameters>[] {
1332
  const nodeById = new Map(nodes.map(node => [node.id, node]));
453✔
1333
  const dependencies = new Map<string, Set<string>>();
61✔
1334
  const lastBufferWriter = new Map<GraphBufferHandle, string>();
61✔
1335
  const activeBufferReaders = new Map<GraphBufferHandle, Set<string>>();
61✔
1336
  const textureHistory = new Map<
61✔
1337
    GraphTextureHandle,
1338
    {nodeId: string; resource: GraphTextureUse}[]
1339
  >();
1340

1341
  for (const node of nodes) {
61✔
1342
    const nodeDependencies = new Set(node.dependsOn ?? []);
453✔
1343
    for (const dependency of nodeDependencies) {
453✔
1344
      if (!nodeById.has(dependency)) {
6!
1345
        throw new Error(
×
1346
          `GPUCommandGraph node "${node.id}" depends on missing node "${dependency}"`
1347
        );
1348
      }
1349
    }
1350
    for (const resource of node.resources ?? []) {
453✔
1351
      if (isGraphBufferUse(resource)) {
1,247✔
1352
        const handle = getBufferHandle(resource.buffer);
1,231✔
1353
        if (isBufferReadUsage(resource.usage)) {
1,231✔
1354
          const writer = lastBufferWriter.get(handle);
730✔
1355
          if (writer) {
730✔
1356
            nodeDependencies.add(writer);
645✔
1357
          }
1358
          const readers = activeBufferReaders.get(handle) ?? new Set<string>();
730✔
1359
          readers.add(node.id);
730✔
1360
          activeBufferReaders.set(handle, readers);
730✔
1361
        }
1362
        if (isBufferWriteUsage(resource.usage)) {
1,231✔
1363
          const writer = lastBufferWriter.get(handle);
580✔
1364
          if (writer) {
580✔
1365
            nodeDependencies.add(writer);
217✔
1366
          }
1367
          for (const reader of activeBufferReaders.get(handle) ?? []) {
580✔
1368
            if (reader !== node.id) {
277✔
1369
              nodeDependencies.add(reader);
198✔
1370
            }
1371
          }
1372
          activeBufferReaders.set(handle, new Set());
580✔
1373
          lastBufferWriter.set(handle, node.id);
580✔
1374
        }
1375
      } else {
1376
        const handle = getTextureHandle(resource.texture);
16✔
1377
        const history = textureHistory.get(handle) ?? [];
16✔
1378
        for (const previous of history) {
16✔
1379
          if (
6✔
1380
            previous.nodeId !== node.id &&
23!
1381
            textureUsesOverlap(previous.resource, resource) &&
1382
            ((isTextureReadUsage(resource.usage) && isTextureWriteUsage(previous.resource.usage)) ||
1383
              (isTextureWriteUsage(resource.usage) &&
1384
                (isTextureReadUsage(previous.resource.usage) ||
1385
                  isTextureWriteUsage(previous.resource.usage))))
1386
          ) {
1387
            nodeDependencies.add(previous.nodeId);
5✔
1388
          }
1389
        }
1390
        history.push({nodeId: node.id, resource});
16✔
1391
        textureHistory.set(handle, history);
16✔
1392
      }
1393
    }
1394
    nodeDependencies.delete(node.id);
453✔
1395
    dependencies.set(node.id, nodeDependencies);
453✔
1396
  }
1397

1398
  const insertionIndex = new Map(nodes.map((node, index) => [node.id, index]));
453✔
1399
  const remaining = new Map(
61✔
1400
    Array.from(dependencies, ([id, values]) => [id, new Set(values)] as const)
453✔
1401
  );
1402
  const ordered: GPUCommandGraphNode<Parameters>[] = [];
61✔
1403
  while (remaining.size > 0) {
61✔
1404
    const ready = Array.from(remaining)
447✔
1405
      .filter(([, values]) => values.size === 0)
26,085✔
1406
      .map(([id]) => id)
449✔
1407
      .sort((left, right) => insertionIndex.get(left)! - insertionIndex.get(right)!);
4✔
1408
    if (ready.length === 0) {
447✔
1409
      throw new Error('GPUCommandGraph contains a dependency cycle');
2✔
1410
    }
1411
    for (const id of ready) {
445✔
1412
      ordered.push(nodeById.get(id)!);
449✔
1413
      remaining.delete(id);
449✔
1414
      for (const values of remaining.values()) {
449✔
1415
        values.delete(id);
25,640✔
1416
      }
1417
    }
1418
  }
1419
  return ordered;
59✔
1420
}
1421

1422
function textureUsesOverlap(left: GraphTextureUse, right: GraphTextureUse): boolean {
1423
  const leftHandle = getTextureHandle(left.texture);
6✔
1424
  const rightHandle = getTextureHandle(right.texture);
6✔
1425
  if (leftHandle !== rightHandle) {
6!
NEW
1426
    return false;
×
1427
  }
1428
  const leftRange = getTextureSubresourceRange(left.texture);
6✔
1429
  const rightRange = getTextureSubresourceRange(right.texture);
6✔
1430
  return (
6✔
1431
    aspectsOverlap(leftRange.aspect, rightRange.aspect) &&
17✔
1432
    intervalsOverlap(
1433
      leftRange.baseMipLevel,
1434
      leftRange.mipLevelCount,
1435
      rightRange.baseMipLevel,
1436
      rightRange.mipLevelCount
1437
    ) &&
1438
    intervalsOverlap(
1439
      leftRange.baseArrayLayer,
1440
      leftRange.arrayLayerCount,
1441
      rightRange.baseArrayLayer,
1442
      rightRange.arrayLayerCount
1443
    )
1444
  );
1445
}
1446

1447
function getTextureSubresourceRange(texture: GraphTextureHandle | GraphTextureView): {
1448
  aspect: GraphTextureAspect;
1449
  baseMipLevel: number;
1450
  mipLevelCount: number;
1451
  baseArrayLayer: number;
1452
  arrayLayerCount: number;
1453
} {
1454
  if (texture instanceof GraphTextureView) {
12✔
1455
    return texture;
8✔
1456
  }
1457
  return {
4✔
1458
    aspect: 'all',
1459
    baseMipLevel: 0,
1460
    mipLevelCount: texture.mipLevels,
1461
    baseArrayLayer: 0,
1462
    arrayLayerCount: texture.dimension === '3d' ? 1 : texture.depth
4!
1463
  };
1464
}
1465

1466
function aspectsOverlap(left: GraphTextureAspect, right: GraphTextureAspect): boolean {
1467
  return left === 'all' || right === 'all' || left === right;
6!
1468
}
1469

1470
function intervalsOverlap(
1471
  leftStart: number,
1472
  leftCount: number,
1473
  rightStart: number,
1474
  rightCount: number
1475
): boolean {
1476
  return leftStart < rightStart + rightCount && rightStart < leftStart + leftCount;
11✔
1477
}
1478

1479
function getBufferTransientAllocationPlan<Parameters>(
1480
  nodes: GPUCommandGraphNode<Parameters>[],
1481
  buffers: Iterable<GraphBufferHandle>
1482
): BufferTransientAllocation[] {
1483
  const lifetimes = getResourceLifetimes(nodes, resource =>
59✔
1484
    isGraphBufferUse(resource) ? getBufferHandle(resource.buffer) : null
1,245✔
1485
  );
1486
  const allocations: BufferTransientAllocation[] = [];
59✔
1487
  const transientBuffers = Array.from(buffers)
59✔
1488
    .filter(buffer => buffer.transient)
433✔
1489
    .map(buffer => ({buffer, lifetime: lifetimes.get(buffer)}))
303✔
1490
    .sort(
1491
      (left, right) =>
443✔
1492
        (left.lifetime?.firstUse ?? Number.MAX_SAFE_INTEGER) -
443!
1493
        (right.lifetime?.firstUse ?? Number.MAX_SAFE_INTEGER)
443!
1494
    );
1495

1496
  for (const {buffer, lifetime} of transientBuffers) {
59✔
1497
    if (!lifetime) {
303!
1498
      continue;
×
1499
    }
1500
    let allocation = allocations
303✔
1501
      .filter(candidate => candidate.lastUse < lifetime.firstUse)
1,517✔
1502
      .sort((left, right) => left.byteLength - right.byteLength)[0];
428✔
1503
    if (!allocation) {
303✔
1504
      allocation = {byteLength: 0, usage: 0, lastUse: -1, handles: []};
54✔
1505
      allocations.push(allocation);
54✔
1506
    }
1507
    allocation.byteLength = Math.max(allocation.byteLength, buffer.byteLength);
303✔
1508
    allocation.usage |= buffer.usage;
303✔
1509
    allocation.lastUse = lifetime.lastUse;
303✔
1510
    allocation.handles.push(buffer);
303✔
1511
  }
1512
  return allocations;
59✔
1513
}
1514

1515
function getTextureTransientAllocationPlan<Parameters>(
1516
  nodes: GPUCommandGraphNode<Parameters>[],
1517
  textures: Iterable<GraphTextureHandle>
1518
): TextureTransientAllocation[] {
1519
  const lifetimes = getResourceLifetimes(nodes, resource =>
59✔
1520
    isGraphBufferUse(resource) ? null : getTextureHandle(resource.texture)
1,245✔
1521
  );
1522
  const allocations: TextureTransientAllocation[] = [];
59✔
1523
  const transientTextures = Array.from(textures)
59✔
1524
    .filter(texture => texture.transient)
9✔
1525
    .map(texture => ({texture, lifetime: lifetimes.get(texture)}))
7✔
1526
    .sort(
1527
      (left, right) =>
3✔
1528
        (left.lifetime?.firstUse ?? Number.MAX_SAFE_INTEGER) -
3!
1529
        (right.lifetime?.firstUse ?? Number.MAX_SAFE_INTEGER)
3!
1530
    );
1531

1532
  for (const {texture, lifetime} of transientTextures) {
59✔
1533
    if (!lifetime) {
7!
NEW
1534
      continue;
×
1535
    }
1536
    let allocation = allocations.find(
7✔
1537
      candidate =>
1538
        candidate.lastUse < lifetime.firstUse &&
4✔
1539
        areTextureDescriptorsCompatible(candidate.descriptor, texture)
1540
    );
1541
    if (!allocation) {
7✔
1542
      const descriptor = getNormalizedTextureDescriptor(texture);
6✔
1543
      allocation = {
6✔
1544
        descriptor,
1545
        byteLength: getTextureByteLength(texture),
1546
        lastUse: -1,
1547
        handles: []
1548
      };
1549
      allocations.push(allocation);
6✔
1550
    }
1551
    allocation.descriptor.usage |= texture.usage;
7✔
1552
    allocation.lastUse = lifetime.lastUse;
7✔
1553
    allocation.handles.push(texture);
7✔
1554
  }
1555
  return allocations;
59✔
1556
}
1557

1558
function getResourceLifetimes<Parameters, Resource extends object>(
1559
  nodes: GPUCommandGraphNode<Parameters>[],
1560
  getResource: (resource: GraphResourceUse) => Resource | null
1561
): Map<Resource, {firstUse: number; lastUse: number}> {
1562
  const lifetimes = new Map<Resource, {firstUse: number; lastUse: number}>();
118✔
1563
  nodes.forEach((node, nodeIndex) => {
118✔
1564
    for (const resourceUse of node.resources ?? []) {
898!
1565
      const resource = getResource(resourceUse);
2,490✔
1566
      if (!resource || !('transient' in resource) || !resource.transient) {
2,490✔
1567
        continue;
1,558✔
1568
      }
1569
      const lifetime = lifetimes.get(resource);
932✔
1570
      if (lifetime) {
932✔
1571
        lifetime.lastUse = nodeIndex;
622✔
1572
      } else {
1573
        lifetimes.set(resource, {firstUse: nodeIndex, lastUse: nodeIndex});
310✔
1574
      }
1575
    }
1576
  });
1577
  return lifetimes;
118✔
1578
}
1579

1580
function getNormalizedTextureDescriptor(
1581
  texture: GraphTextureHandle
1582
): NormalizedGraphTextureDescriptor {
1583
  return {
6✔
1584
    id: texture.id,
1585
    format: texture.format,
1586
    width: texture.width,
1587
    height: texture.height,
1588
    usage: texture.usage,
1589
    dimension: texture.dimension,
1590
    depth: texture.depth,
1591
    mipLevels: texture.mipLevels,
1592
    samples: texture.samples
1593
  };
1594
}
1595

1596
function areTextureDescriptorsCompatible(
1597
  descriptor: NormalizedGraphTextureDescriptor,
1598
  texture: GraphTextureHandle
1599
): boolean {
1600
  return (
1✔
1601
    descriptor.format === texture.format &&
7✔
1602
    descriptor.width === texture.width &&
1603
    descriptor.height === texture.height &&
1604
    descriptor.dimension === texture.dimension &&
1605
    descriptor.depth === texture.depth &&
1606
    descriptor.mipLevels === texture.mipLevels &&
1607
    descriptor.samples === texture.samples
1608
  );
1609
}
1610

1611
function getTextureByteLength(texture: GraphTextureHandle): number {
1612
  let byteLength = 0;
13✔
1613
  for (let mipLevel = 0; mipLevel < texture.mipLevels; mipLevel++) {
13✔
1614
    byteLength += textureFormatDecoder.computeMemoryLayout({
15✔
1615
      format: texture.format,
1616
      width: Math.max(1, texture.width >> mipLevel),
1617
      height: texture.dimension === '1d' ? 1 : Math.max(1, texture.height >> mipLevel),
15!
1618
      depth: texture.dimension === '3d' ? Math.max(1, texture.depth >> mipLevel) : texture.depth,
15!
1619
      byteAlignment: 1
1620
    }).byteLength;
1621
  }
1622
  return byteLength * texture.samples;
13✔
1623
}
1624

1625
function isDefaultGraphTextureView(view: GraphTextureView): boolean {
1626
  const texture = view.texture;
18✔
1627
  return (
18✔
1628
    view.dimension === texture.dimension &&
108✔
1629
    view.aspect === 'all' &&
1630
    view.baseMipLevel === 0 &&
1631
    view.mipLevelCount === texture.mipLevels &&
1632
    view.baseArrayLayer === 0 &&
1633
    view.arrayLayerCount === (texture.dimension === '3d' ? 1 : texture.depth)
18!
1634
  );
1635
}
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