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

visgl / luma.gl / 28672873963

03 Jul 2026 04:33PM UTC coverage: 72.309% (+0.02%) from 72.287%
28672873963

Pull #2727

github

web-flow
Merge e0b612467 into fca0f30c0
Pull Request #2727: [codex] Package OIT resolves as shader pass pipelines

10848 of 16895 branches covered (64.21%)

Branch coverage included in aggregate %.

21 of 44 new or added lines in 5 files covered. (47.73%)

1 existing line in 1 file now uncovered.

21218 of 27451 relevant lines covered (77.29%)

5696.68 hits per line

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

4.86
/modules/experimental/src/oit/a-buffer-renderer.ts
1
// luma.gl
2
// SPDX-License-Identifier: MIT
3
// Copyright (c) vis.gl contributors
4

5
import {
6
  Buffer,
7
  type CommandEncoder,
8
  Device,
9
  type RenderPass,
10
  type RenderPipelineParameters,
11
  Texture,
12
  type TextureView
13
} from '@luma.gl/core';
14
import {ShaderPassRenderer} from '@luma.gl/engine';
15
import {type ABufferShaderModuleProps} from './a-buffer';
16
import {createABufferResolveShaderPassPipeline} from './a-buffer-resolve-shader-pass-pipeline';
17

18
const A_BUFFER_HEAD_POINTER_HEADER_BYTE_LENGTH = 8;
11✔
19
const A_BUFFER_HEAD_POINTER_BYTE_LENGTH = 4;
11✔
20
const A_BUFFER_FRAGMENT_BYTE_LENGTH = 12;
11✔
21
const DEFAULT_AVERAGE_FRAGMENTS_PER_PIXEL = 4;
11✔
22
const DEFAULT_MAX_FRAGMENTS_PER_PIXEL = 12;
11✔
23
let nextABufferResourceId = 0;
11✔
24

25
function makeABufferResourceId(prefix: string): string {
26
  nextABufferResourceId += 1;
×
27
  return `${prefix}-${nextABufferResourceId}`;
×
28
}
29

30
export type ABufferRendererProps = {
31
  /** Average translucent fragments allocated per pixel in each capture slice. Defaults to 4. */
32
  averageFragmentsPerPixel?: number;
33
  /** Maximum fragments sorted and composited per pixel. Defaults to 12. */
34
  maxFragmentsPerPixel?: number;
35
  /** Maximum size of each A-buffer storage buffer in bytes. Smaller values force more slices. */
36
  maxBufferByteLength?: number;
37
};
38

39
/** Result returned by {@link getABufferSupport}. */
40
export type ABufferSupport = {
41
  /** Whether the device can run {@link ABufferRenderer}. */
42
  supported: boolean;
43
  /** Explanation when `supported` is `false`. */
44
  reason?: string;
45
};
46

47
/** Storage allocation and horizontal slicing selected for an A-buffer target. */
48
export type ABufferSlicePlan = {
49
  /** Target width in device pixels. */
50
  width: number;
51
  /** Target height in device pixels. */
52
  height: number;
53
  /** Maximum number of framebuffer rows captured in one pass. */
54
  sliceHeight: number;
55
  /** Number of capture and resolve passes required to render the target. */
56
  sliceCount: number;
57
  /** Number of pixels represented by the largest slice. */
58
  maxSlicePixelCount: number;
59
  /** Number of fragment records allocated for each slice. */
60
  fragmentCapacity: number;
61
  /** Size of the head-pointer storage buffer in bytes. */
62
  headPointerByteLength: number;
63
  /** Size of the fragment storage buffer in bytes. */
64
  fragmentByteLength: number;
65
};
66

67
/** Per-slice resources supplied to `ABufferRenderOptions.prepareTranslucent`. */
68
export type ABufferCaptureContext = {
69
  /** Active device command encoder, used to prepare models before the capture pass opens. */
70
  commandEncoder: CommandEncoder;
71
  /** Shader-module props that bind the current slice's A-buffer resources. */
72
  shaderModuleProps: ABufferShaderModuleProps;
73
  /** Pipeline overrides required for storage-only fragment capture. */
74
  captureParameters: Readonly<RenderPipelineParameters>;
75
};
76

77
export type ABufferRenderOptions = {
78
  /** Opaque color to resolve captured translucent fragments over. */
79
  sourceTexture: Texture;
80
  /** Sampleable opaque depth generated alongside `sourceTexture`. */
81
  opaqueDepthTexture: TextureView;
82
  /** Bind A-buffer resources and prepare capture pipelines before each slice pass opens. */
83
  prepareTranslucent: (context: ABufferCaptureContext) => void;
84
  /** Draw translucent models that append fragments into the A-buffer. */
85
  drawTranslucent: (renderPass: RenderPass) => void;
86
};
87

88
type ResolvedABufferRendererProps = Required<ABufferRendererProps>;
89

90
/**
91
 * Captures exact order-independent transparency and resolves each slice through a ShaderPassPipeline.
92
 *
93
 * The renderer captures translucent fragments into storage buffers, sorts each pixel's fragments
94
 * by depth, and resolves premultiplied colors over the supplied opaque source. Large targets are
95
 * split into horizontal slices to keep storage bounded.
96
 *
97
 * @note The renderer is WebGPU-only and does not submit the device command encoder.
98
 */
99
export class ABufferRenderer {
100
  /** Pipeline overrides that callers must merge after their normal translucent parameters. */
101
  static readonly captureParameters = Object.freeze({
11✔
102
    colorMask: 0,
103
    depthWriteEnabled: false,
104
    depthCompare: undefined,
105
    depthFormat: undefined,
106
    depthBias: undefined,
107
    depthBiasSlopeScale: undefined,
108
    depthBiasClamp: undefined,
109
    stencilReadMask: undefined,
110
    stencilWriteMask: undefined,
111
    stencilCompare: undefined,
112
    stencilPassOperation: undefined,
113
    stencilFailOperation: undefined,
114
    stencilDepthFailOperation: undefined
115
  } satisfies RenderPipelineParameters);
116

117
  readonly device: Device;
118
  readonly props: ResolvedABufferRendererProps;
119

120
  private readonly resolveRenderer: ShaderPassRenderer;
UNCOV
121
  private slicePlan: ABufferSlicePlan | null = null;
×
122
  private headPointerInitBuffer: Buffer | null = null;
×
123
  private headPointers: Buffer | null = null;
×
124
  private fragments: Buffer | null = null;
×
125

126
  /** Creates an A-buffer renderer and validates the device's required capabilities. */
127
  constructor(device: Device, props: ABufferRendererProps = {}) {
×
128
    const support = getABufferSupport(device);
×
129
    if (!support.supported) {
×
130
      throw new Error(support.reason);
×
131
    }
132

133
    this.device = device;
×
134
    this.props = resolveABufferRendererProps(props);
×
NEW
135
    this.resolveRenderer = new ShaderPassRenderer(device, {
×
136
      shaderPasses: [
137
        createABufferResolveShaderPassPipeline({
138
          maxFragmentsPerPixel: this.props.maxFragmentsPerPixel
139
        })
140
      ]
141
    });
142
  }
143

144
  /** Destroys the resolve pipeline and owned storage buffers. */
145
  destroy(): void {
146
    this.destroyBuffers();
×
NEW
147
    this.resolveRenderer.destroy();
×
148
  }
149

150
  /**
151
   * Captures translucent slices and resolves each over the previous color result.
152
   *
153
   * `prepareTranslucent` and `drawTranslucent` may run multiple times when the target is sliced.
154
   */
155
  render(options: ABufferRenderOptions): Texture {
NEW
156
    validateABufferRenderOptions(options);
×
NEW
157
    const {sourceTexture, opaqueDepthTexture} = options;
×
NEW
158
    this.resize({width: sourceTexture.width, height: sourceTexture.height});
×
159
    const commandEncoder = this.device.commandEncoder;
×
160
    const slicePlan = this.slicePlan!;
×
161
    const captureFramebuffer = this.device.createFramebuffer({
×
162
      id: 'a-buffer-capture-framebuffer',
163
      width: sourceTexture.width,
164
      height: sourceTexture.height,
165
      colorAttachments: [sourceTexture]
166
    });
NEW
167
    let resolvedTexture = sourceTexture;
×
168
    try {
×
169
      for (let sliceIndex = 0; sliceIndex < slicePlan.sliceCount; sliceIndex++) {
×
170
        const sliceStartY = sliceIndex * slicePlan.sliceHeight;
×
171
        const sliceHeight = Math.min(slicePlan.sliceHeight, slicePlan.height - sliceStartY);
×
172
        const shaderModuleProps = this.getShaderModuleProps(sliceStartY, opaqueDepthTexture);
×
173

174
        commandEncoder.copyBufferToBuffer({
×
175
          sourceBuffer: this.headPointerInitBuffer!,
176
          destinationBuffer: this.headPointers!,
177
          size: this.headPointers!.byteLength
178
        });
179

180
        options.prepareTranslucent({
×
181
          commandEncoder,
182
          shaderModuleProps,
183
          captureParameters: ABufferRenderer.captureParameters
184
        });
185
        const capturePass = this.device.beginRenderPass({
×
186
          id: `a-buffer-capture-${sliceIndex}`,
187
          framebuffer: captureFramebuffer,
188
          parameters: {scissorRect: [0, sliceStartY, slicePlan.width, sliceHeight]},
189
          clearColor: false
190
        });
191
        try {
×
192
          options.drawTranslucent(capturePass);
×
193
        } finally {
194
          capturePass.end();
×
195
        }
196

NEW
197
        resolvedTexture = this.resolveRenderer.renderToTexture({
×
198
          sourceTexture: resolvedTexture,
199
          bindings: {
200
            headPointers: this.headPointers!,
201
            fragments: this.fragments!
202
          },
203
          uniforms: {
204
            aBufferResolve: {
205
              framebufferSize: [slicePlan.width, slicePlan.height],
206
              sliceStartY,
207
              sliceHeight
208
            }
209
          }
210
        })!;
211
      }
212
    } finally {
213
      captureFramebuffer.destroy();
×
214
    }
NEW
215
    return resolvedTexture;
×
216
  }
217

218
  /** Reallocates storage buffers when the target dimensions or slice plan changes. */
219
  resize(size: {width: number; height: number}): void {
220
    const nextSlicePlan = getABufferSlicePlan({
×
221
      width: size.width,
222
      height: size.height,
223
      averageFragmentsPerPixel: this.props.averageFragmentsPerPixel,
224
      maxStorageBufferBindingSize: Math.min(
225
        this.device.limits.maxStorageBufferBindingSize,
226
        this.props.maxBufferByteLength
227
      ),
228
      maxBufferSize: Math.min(this.device.limits.maxBufferSize, this.props.maxBufferByteLength)
229
    });
230

231
    if (
×
232
      this.slicePlan &&
×
233
      this.slicePlan.width === nextSlicePlan.width &&
234
      this.slicePlan.height === nextSlicePlan.height &&
235
      this.slicePlan.sliceHeight === nextSlicePlan.sliceHeight
236
    ) {
237
      return;
×
238
    }
239

240
    this.destroyBuffers();
×
241
    this.slicePlan = nextSlicePlan;
×
NEW
242
    this.resolveRenderer.resize([size.width, size.height]);
×
243

244
    const headPointerInitData = new Uint32Array(nextSlicePlan.headPointerByteLength / 4);
×
245

246
    this.headPointerInitBuffer = this.device.createBuffer({
×
247
      id: makeABufferResourceId('a-buffer-head-pointer-init'),
248
      usage: Buffer.COPY_SRC,
249
      data: headPointerInitData
250
    });
251
    this.headPointers = this.device.createBuffer({
×
252
      id: makeABufferResourceId('a-buffer-head-pointers'),
253
      usage: Buffer.STORAGE | Buffer.COPY_DST,
254
      byteLength: nextSlicePlan.headPointerByteLength
255
    });
256
    this.fragments = this.device.createBuffer({
×
257
      id: makeABufferResourceId('a-buffer-fragments'),
258
      usage: Buffer.STORAGE,
259
      byteLength: nextSlicePlan.fragmentByteLength
260
    });
261
  }
262

263
  private getShaderModuleProps(
264
    sliceStartY: number,
265
    opaqueDepthTexture: TextureView
266
  ): ABufferShaderModuleProps {
267
    const slicePlan = this.slicePlan!;
×
268
    return {
×
269
      isActive: true,
270
      framebufferSize: [slicePlan.width, slicePlan.height],
271
      sliceStartY,
272
      headPointers: this.headPointers!,
273
      fragments: this.fragments!,
274
      opaqueDepthTexture
275
    };
276
  }
277

278
  private destroyBuffers(): void {
279
    this.headPointerInitBuffer?.destroy();
×
280
    this.headPointers?.destroy();
×
281
    this.fragments?.destroy();
×
282
    this.headPointerInitBuffer = null;
×
283
    this.headPointers = null;
×
284
    this.fragments = null;
×
285
  }
286
}
287

288
/** Returns whether a device exposes the WebGPU storage-buffer capabilities required by A-buffer capture. */
289
export function getABufferSupport(device: Device): ABufferSupport {
290
  if (device.type !== 'webgpu') {
×
291
    return {supported: false, reason: 'A-buffer OIT requires a WebGPU device.'};
×
292
  }
293
  if (device.limits.maxStorageBuffersInFragmentStage < 2) {
×
294
    return {
×
295
      supported: false,
296
      reason: 'A-buffer OIT requires at least two fragment-stage storage buffers.'
297
    };
298
  }
299

300
  return {supported: true};
×
301
}
302

303
/**
304
 * Calculates a bounded-memory horizontal slice plan for an A-buffer target.
305
 *
306
 * @throws If the configured storage limits cannot hold one target scanline.
307
 */
308
export function getABufferSlicePlan(options: {
309
  width: number;
310
  height: number;
311
  averageFragmentsPerPixel: number;
312
  maxStorageBufferBindingSize: number;
313
  maxBufferSize: number;
314
}): ABufferSlicePlan {
315
  const {width, height, averageFragmentsPerPixel, maxStorageBufferBindingSize, maxBufferSize} =
316
    options;
×
317

318
  if (width <= 0 || height <= 0) {
×
319
    throw new Error('A-buffer target size must be positive.');
×
320
  }
321

322
  const maxBufferByteLength = Math.min(maxStorageBufferBindingSize, maxBufferSize);
×
323
  const maxSlicePixelsFromHeadPointers = Math.floor(
×
324
    (maxBufferByteLength - A_BUFFER_HEAD_POINTER_HEADER_BYTE_LENGTH) /
325
      A_BUFFER_HEAD_POINTER_BYTE_LENGTH
326
  );
327
  const maxSlicePixelsFromFragments = Math.floor(
×
328
    maxBufferByteLength / (averageFragmentsPerPixel * A_BUFFER_FRAGMENT_BYTE_LENGTH)
329
  );
330
  const maxSlicePixelCount = Math.min(maxSlicePixelsFromHeadPointers, maxSlicePixelsFromFragments);
×
331

332
  if (maxSlicePixelCount < width) {
×
333
    throw new Error(
×
334
      'A-buffer storage limits cannot fit one scanline at the configured fragment density.'
335
    );
336
  }
337

338
  const sliceHeight = Math.max(1, Math.floor(maxSlicePixelCount / width));
×
339
  const boundedSliceHeight = Math.min(sliceHeight, height);
×
340
  const boundedSlicePixelCount = width * boundedSliceHeight;
×
341

342
  return {
×
343
    width,
344
    height,
345
    sliceHeight: boundedSliceHeight,
346
    sliceCount: Math.ceil(height / boundedSliceHeight),
347
    maxSlicePixelCount: boundedSlicePixelCount,
348
    fragmentCapacity: boundedSlicePixelCount * averageFragmentsPerPixel,
349
    headPointerByteLength:
350
      A_BUFFER_HEAD_POINTER_HEADER_BYTE_LENGTH +
351
      boundedSlicePixelCount * A_BUFFER_HEAD_POINTER_BYTE_LENGTH,
352
    fragmentByteLength:
353
      boundedSlicePixelCount * averageFragmentsPerPixel * A_BUFFER_FRAGMENT_BYTE_LENGTH
354
  };
355
}
356

357
function resolveABufferRendererProps(props: ABufferRendererProps): ResolvedABufferRendererProps {
358
  const averageFragmentsPerPixel = Math.floor(
×
359
    props.averageFragmentsPerPixel ?? DEFAULT_AVERAGE_FRAGMENTS_PER_PIXEL
×
360
  );
361
  const maxFragmentsPerPixel = Math.floor(
×
362
    props.maxFragmentsPerPixel ?? DEFAULT_MAX_FRAGMENTS_PER_PIXEL
×
363
  );
364
  const maxBufferByteLength = Math.floor(props.maxBufferByteLength ?? Number.MAX_SAFE_INTEGER);
×
365

366
  if (averageFragmentsPerPixel < 1) {
×
367
    throw new Error('averageFragmentsPerPixel must be at least 1.');
×
368
  }
369
  if (maxFragmentsPerPixel < averageFragmentsPerPixel) {
×
370
    throw new Error('maxFragmentsPerPixel must be at least averageFragmentsPerPixel.');
×
371
  }
372
  if (maxBufferByteLength < 1) {
×
373
    throw new Error('maxBufferByteLength must be at least 1.');
×
374
  }
375

376
  return {averageFragmentsPerPixel, maxFragmentsPerPixel, maxBufferByteLength};
×
377
}
378

379
function validateABufferRenderOptions(options: ABufferRenderOptions): void {
NEW
380
  const {sourceTexture, opaqueDepthTexture} = options;
×
NEW
381
  if (sourceTexture.samples !== 1 || opaqueDepthTexture.texture.samples !== 1) {
×
NEW
382
    throw new Error('A-buffer OIT only supports single-sample source textures.');
×
383
  }
NEW
384
  if (!(sourceTexture.props.usage & Texture.SAMPLE)) {
×
NEW
385
    throw new Error('A-buffer sourceTexture must be sampleable.');
×
386
  }
NEW
387
  if (!(sourceTexture.props.usage & Texture.RENDER)) {
×
NEW
388
    throw new Error('A-buffer sourceTexture must be renderable.');
×
389
  }
NEW
390
  if (!(opaqueDepthTexture.texture.props.usage & Texture.SAMPLE)) {
×
NEW
391
    throw new Error('A-buffer opaqueDepthTexture must be sampleable.');
×
392
  }
NEW
393
  if (
×
394
    sourceTexture.width !== opaqueDepthTexture.texture.width ||
×
395
    sourceTexture.height !== opaqueDepthTexture.texture.height
396
  ) {
NEW
397
    throw new Error('A-buffer source and opaque depth dimensions must match.');
×
398
  }
399
}
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