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

visgl / luma.gl / 27856922126

20 Jun 2026 02:04AM UTC coverage: 71.991%. First build
27856922126

Pull #2682

github

web-flow
Merge 0f579b91a into bd6f08e3a
Pull Request #2682: [codex] Add A-buffer order-independent transparency

9523 of 14925 branches covered (63.81%)

Branch coverage included in aggregate %.

25 of 135 new or added lines in 6 files covered. (18.52%)

19393 of 25241 relevant lines covered (76.83%)

4256.46 hits per line

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

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

5
import type {NumberArray4} from '@math.gl/types';
6
import {
7
  Buffer,
8
  type CommandEncoder,
9
  Device,
10
  type Framebuffer,
11
  type RenderPass,
12
  type RenderPipelineParameters,
13
  Texture,
14
  type TextureView
15
} from '@luma.gl/core';
16

17
import {ClipSpace} from '../../models/clip-space';
18
import {ShaderInputs} from '../../shader-inputs';
19
import {uid} from '../../utils/uid';
20
import {aBuffer, type ABufferShaderModuleProps} from './a-buffer';
21

22
const A_BUFFER_HEAD_POINTER_HEADER_BYTE_LENGTH = 8;
122✔
23
const A_BUFFER_HEAD_POINTER_BYTE_LENGTH = 4;
122✔
24
const A_BUFFER_FRAGMENT_BYTE_LENGTH = 12;
122✔
25
const DEFAULT_AVERAGE_FRAGMENTS_PER_PIXEL = 4;
122✔
26
const DEFAULT_MAX_FRAGMENTS_PER_PIXEL = 12;
122✔
27

28
function getABufferCompositeShader(maxFragmentsPerPixel: number): string {
NEW
29
  return /* wgsl */ `\
×
30
@fragment
31
fn fragmentMain(inputs: FragmentInputs) -> @location(0) vec4<f32> {
32
  let pixelIndex = aBuffer_getPixelIndex(inputs.Position);
33
  if (pixelIndex >= arrayLength(&headPointers.heads)) {
34
    discard;
35
  }
36

37
  var fragmentPointer = atomicLoad(&headPointers.heads[pixelIndex]);
38
  var capturedFragments: array<ABufferFragment, ${maxFragmentsPerPixel}>;
39
  var fragmentCount = 0u;
40

41
  while (
42
    fragmentPointer != A_BUFFER_EMPTY_FRAGMENT_POINTER &&
43
    fragmentCount < ${maxFragmentsPerPixel}u
44
  ) {
45
    let fragmentIndex = fragmentPointer - 1u;
46
    if (fragmentIndex >= arrayLength(&fragments.fragments)) {
47
      break;
48
    }
49
    capturedFragments[fragmentCount] = fragments.fragments[fragmentIndex];
50
    fragmentPointer = capturedFragments[fragmentCount].next;
51
    fragmentCount += 1u;
52
  }
53

54
  if (fragmentCount == 0u) {
55
    discard;
56
  }
57

58
  var sortIndex = 1u;
59
  while (sortIndex < fragmentCount) {
60
    let fragmentToInsert = capturedFragments[sortIndex];
61
    var insertIndex = sortIndex;
62
    while (insertIndex > 0u && capturedFragments[insertIndex - 1u].depth < fragmentToInsert.depth) {
63
      capturedFragments[insertIndex] = capturedFragments[insertIndex - 1u];
64
      insertIndex -= 1u;
65
    }
66
    capturedFragments[insertIndex] = fragmentToInsert;
67
    sortIndex += 1u;
68
  }
69

70
  var compositeColor = vec4<f32>(0.0);
71
  var compositeIndex = 0u;
72
  while (compositeIndex < fragmentCount) {
73
    let fragmentColor = unpack4x8unorm(capturedFragments[compositeIndex].color);
74
    compositeColor = fragmentColor + compositeColor * (1.0 - fragmentColor.a);
75
    compositeIndex += 1u;
76
  }
77

78
  return compositeColor;
79
}
80
`;
81
}
82

83
export type ABufferRendererProps = {
84
  /** Average translucent fragments allocated per pixel in each capture slice. Defaults to 4. */
85
  averageFragmentsPerPixel?: number;
86
  /** Maximum fragments sorted and composited per pixel. Defaults to 12. */
87
  maxFragmentsPerPixel?: number;
88
  /** Maximum size of each A-buffer storage buffer in bytes. Smaller values force more slices. */
89
  maxBufferByteLength?: number;
90
};
91

92
/** Result returned by {@link getABufferSupport}. */
93
export type ABufferSupport = {
94
  /** Whether the device can run {@link ABufferRenderer}. */
95
  supported: boolean;
96
  /** Explanation when `supported` is `false`. */
97
  reason?: string;
98
};
99

100
/** Storage allocation and horizontal slicing selected for an A-buffer target. */
101
export type ABufferSlicePlan = {
102
  /** Target width in device pixels. */
103
  width: number;
104
  /** Target height in device pixels. */
105
  height: number;
106
  /** Maximum number of framebuffer rows captured in one pass. */
107
  sliceHeight: number;
108
  /** Number of capture and composite passes required to render the target. */
109
  sliceCount: number;
110
  /** Number of pixels represented by the largest slice. */
111
  maxSlicePixelCount: number;
112
  /** Number of fragment records allocated for each slice. */
113
  fragmentCapacity: number;
114
  /** Size of the head-pointer storage buffer in bytes. */
115
  headPointerByteLength: number;
116
  /** Size of the fragment storage buffer in bytes. */
117
  fragmentByteLength: number;
118
};
119

120
/** Per-slice resources supplied to `ABufferRenderOptions.prepareTranslucent`. */
121
export type ABufferCaptureContext = {
122
  /** Active device command encoder, used to prepare models before the capture pass opens. */
123
  commandEncoder: CommandEncoder;
124
  /** Shader-module props that bind the current slice's A-buffer resources. */
125
  shaderModuleProps: ABufferShaderModuleProps;
126
  /** Pipeline overrides required for storage-only fragment capture. */
127
  captureParameters: Readonly<RenderPipelineParameters>;
128
};
129

130
export type ABufferRenderOptions = {
131
  /** Target color/depth framebuffer. Defaults to the current canvas framebuffer. */
132
  framebuffer?: Framebuffer | null;
133
  /** Base pass clear color. */
134
  clearColor?: NumberArray4 | false;
135
  /** Base pass clear depth. */
136
  clearDepth?: number | false;
137
  /** Prepare uploads and base-pass pipeline state before the base render pass opens. */
138
  prepareBase?: (commandEncoder: CommandEncoder) => void;
139
  /** Draw the base scene that the A-buffer composite overlays. */
140
  drawBase: (renderPass: RenderPass) => void;
141
  /** Bind A-buffer resources and prepare capture pipelines before each slice pass opens. */
142
  prepareTranslucent: (context: ABufferCaptureContext) => void;
143
  /** Draw translucent models that append fragments into the A-buffer. */
144
  drawTranslucent: (renderPass: RenderPass) => void;
145
};
146

147
type ResolvedABufferRendererProps = Required<ABufferRendererProps>;
148

149
/**
150
 * Renders exact order-independent transparency with a per-pixel linked-list A-buffer.
151
 *
152
 * The renderer first draws the opaque base scene, captures translucent fragments into storage
153
 * buffers, sorts each pixel's fragments by depth, and composites premultiplied colors over the
154
 * base color target. Large targets are split into horizontal slices to keep storage bounded.
155
 *
156
 * @note The renderer is WebGPU-only and does not submit the device command encoder.
157
 */
158
export class ABufferRenderer {
159
  /** Pipeline overrides that callers must merge after their normal translucent parameters. */
160
  static readonly captureParameters = Object.freeze({
122✔
161
    colorMask: 0,
162
    depthWriteEnabled: false,
163
    depthCompare: undefined,
164
    depthFormat: undefined,
165
    depthBias: undefined,
166
    depthBiasSlopeScale: undefined,
167
    depthBiasClamp: undefined,
168
    stencilReadMask: undefined,
169
    stencilWriteMask: undefined,
170
    stencilCompare: undefined,
171
    stencilPassOperation: undefined,
172
    stencilFailOperation: undefined,
173
    stencilDepthFailOperation: undefined
174
  } satisfies RenderPipelineParameters);
175

176
  readonly device: Device;
177
  readonly props: ResolvedABufferRendererProps;
178

179
  private readonly shaderInputs: ShaderInputs<{aBuffer: ABufferShaderModuleProps}>;
180
  private readonly compositeModel: ClipSpace;
NEW
181
  private slicePlan: ABufferSlicePlan | null = null;
×
NEW
182
  private headPointerInitBuffer: Buffer | null = null;
×
NEW
183
  private headPointers: Buffer | null = null;
×
NEW
184
  private fragments: Buffer | null = null;
×
185

186
  /** Creates an A-buffer renderer and validates the device's required capabilities. */
187
  constructor(device: Device, props: ABufferRendererProps = {}) {
×
NEW
188
    const support = getABufferSupport(device);
×
NEW
189
    if (!support.supported) {
×
NEW
190
      throw new Error(support.reason);
×
191
    }
192

NEW
193
    this.device = device;
×
NEW
194
    this.props = resolveABufferRendererProps(props);
×
NEW
195
    this.shaderInputs = new ShaderInputs<{aBuffer: ABufferShaderModuleProps}>({aBuffer});
×
NEW
196
    this.compositeModel = new ClipSpace(device, {
×
197
      id: uid('a-buffer-composite'),
198
      source: getABufferCompositeShader(this.props.maxFragmentsPerPixel),
199
      shaderInputs: this.shaderInputs,
200
      parameters: {
201
        blend: true,
202
        blendColorOperation: 'add',
203
        blendColorSrcFactor: 'one',
204
        blendColorDstFactor: 'one-minus-src-alpha',
205
        blendAlphaOperation: 'add',
206
        blendAlphaSrcFactor: 'one',
207
        blendAlphaDstFactor: 'one-minus-src-alpha'
208
      }
209
    });
210
  }
211

212
  /** Destroys owned models, shader inputs, and storage buffers. */
213
  destroy(): void {
NEW
214
    this.destroyBuffers();
×
NEW
215
    this.compositeModel.destroy();
×
NEW
216
    this.shaderInputs.destroy();
×
217
  }
218

219
  /**
220
   * Records the base, translucent capture, and composite passes.
221
   *
222
   * `prepareTranslucent` and `drawTranslucent` may run multiple times when the target is sliced.
223
   */
224
  render(options: ABufferRenderOptions): void {
225
    const framebuffer =
NEW
226
      options.framebuffer ?? this.device.getDefaultCanvasContext().getCurrentFramebuffer();
×
NEW
227
    validateABufferFramebuffer(framebuffer);
×
NEW
228
    this.resize({width: framebuffer.width, height: framebuffer.height});
×
229

NEW
230
    const commandEncoder = this.device.commandEncoder;
×
NEW
231
    options.prepareBase?.(commandEncoder);
×
NEW
232
    const basePass = this.device.beginRenderPass({
×
233
      id: 'a-buffer-base',
234
      framebuffer,
235
      clearColor: options.clearColor,
236
      clearDepth: options.clearDepth
237
    });
NEW
238
    try {
×
NEW
239
      options.drawBase(basePass);
×
240
    } finally {
NEW
241
      basePass.end();
×
242
    }
243

NEW
244
    const slicePlan = this.slicePlan!;
×
NEW
245
    const captureFramebuffer = this.device.createFramebuffer({
×
246
      id: 'a-buffer-capture-framebuffer',
247
      width: framebuffer.width,
248
      height: framebuffer.height,
249
      colorAttachments: framebuffer.colorAttachments
250
    });
NEW
251
    const opaqueDepthTexture = framebuffer.depthStencilAttachment!;
×
NEW
252
    try {
×
NEW
253
      for (let sliceIndex = 0; sliceIndex < slicePlan.sliceCount; sliceIndex++) {
×
NEW
254
        const sliceStartY = sliceIndex * slicePlan.sliceHeight;
×
NEW
255
        const sliceHeight = Math.min(slicePlan.sliceHeight, slicePlan.height - sliceStartY);
×
NEW
256
        const shaderModuleProps = this.getShaderModuleProps(sliceStartY, opaqueDepthTexture);
×
257

NEW
258
        commandEncoder.copyBufferToBuffer({
×
259
          sourceBuffer: this.headPointerInitBuffer!,
260
          destinationBuffer: this.headPointers!,
261
          size: this.headPointers!.byteLength
262
        });
263

NEW
264
        options.prepareTranslucent({
×
265
          commandEncoder,
266
          shaderModuleProps,
267
          captureParameters: ABufferRenderer.captureParameters
268
        });
NEW
269
        const capturePass = this.device.beginRenderPass({
×
270
          id: `a-buffer-capture-${sliceIndex}`,
271
          framebuffer: captureFramebuffer,
272
          parameters: {scissorRect: [0, sliceStartY, slicePlan.width, sliceHeight]},
273
          clearColor: false
274
        });
NEW
275
        try {
×
NEW
276
          options.drawTranslucent(capturePass);
×
277
        } finally {
NEW
278
          capturePass.end();
×
279
        }
280

NEW
281
        this.shaderInputs.setProps({aBuffer: shaderModuleProps});
×
NEW
282
        this.compositeModel.predraw(commandEncoder);
×
NEW
283
        const compositePass = this.device.beginRenderPass({
×
284
          id: `a-buffer-composite-${sliceIndex}`,
285
          framebuffer: captureFramebuffer,
286
          parameters: {scissorRect: [0, sliceStartY, slicePlan.width, sliceHeight]},
287
          clearColor: false
288
        });
NEW
289
        try {
×
NEW
290
          this.compositeModel.draw(compositePass);
×
291
        } finally {
NEW
292
          compositePass.end();
×
293
        }
294
      }
295
    } finally {
NEW
296
      captureFramebuffer.destroy();
×
297
    }
298
  }
299

300
  /** Reallocates storage buffers when the target dimensions or slice plan changes. */
301
  resize(size: {width: number; height: number}): void {
NEW
302
    const nextSlicePlan = getABufferSlicePlan({
×
303
      width: size.width,
304
      height: size.height,
305
      averageFragmentsPerPixel: this.props.averageFragmentsPerPixel,
306
      maxStorageBufferBindingSize: Math.min(
307
        this.device.limits.maxStorageBufferBindingSize,
308
        this.props.maxBufferByteLength
309
      ),
310
      maxBufferSize: Math.min(this.device.limits.maxBufferSize, this.props.maxBufferByteLength)
311
    });
312

NEW
313
    if (
×
314
      this.slicePlan &&
×
315
      this.slicePlan.width === nextSlicePlan.width &&
316
      this.slicePlan.height === nextSlicePlan.height &&
317
      this.slicePlan.sliceHeight === nextSlicePlan.sliceHeight
318
    ) {
NEW
319
      return;
×
320
    }
321

NEW
322
    this.destroyBuffers();
×
NEW
323
    this.slicePlan = nextSlicePlan;
×
324

NEW
325
    const headPointerInitData = new Uint32Array(nextSlicePlan.headPointerByteLength / 4);
×
326

NEW
327
    this.headPointerInitBuffer = this.device.createBuffer({
×
328
      id: uid('a-buffer-head-pointer-init'),
329
      usage: Buffer.COPY_SRC,
330
      data: headPointerInitData
331
    });
NEW
332
    this.headPointers = this.device.createBuffer({
×
333
      id: uid('a-buffer-head-pointers'),
334
      usage: Buffer.STORAGE | Buffer.COPY_DST,
335
      byteLength: nextSlicePlan.headPointerByteLength
336
    });
NEW
337
    this.fragments = this.device.createBuffer({
×
338
      id: uid('a-buffer-fragments'),
339
      usage: Buffer.STORAGE,
340
      byteLength: nextSlicePlan.fragmentByteLength
341
    });
342
  }
343

344
  private getShaderModuleProps(
345
    sliceStartY: number,
346
    opaqueDepthTexture: TextureView
347
  ): ABufferShaderModuleProps {
NEW
348
    const slicePlan = this.slicePlan!;
×
NEW
349
    return {
×
350
      isActive: true,
351
      framebufferSize: [slicePlan.width, slicePlan.height],
352
      sliceStartY,
353
      headPointers: this.headPointers!,
354
      fragments: this.fragments!,
355
      opaqueDepthTexture
356
    };
357
  }
358

359
  private destroyBuffers(): void {
NEW
360
    this.headPointerInitBuffer?.destroy();
×
NEW
361
    this.headPointers?.destroy();
×
NEW
362
    this.fragments?.destroy();
×
NEW
363
    this.headPointerInitBuffer = null;
×
NEW
364
    this.headPointers = null;
×
NEW
365
    this.fragments = null;
×
366
  }
367
}
368

369
/** Returns whether a device exposes the WebGPU storage-buffer capabilities required by A-buffer capture. */
370
export function getABufferSupport(device: Device): ABufferSupport {
NEW
371
  if (device.type !== 'webgpu') {
×
NEW
372
    return {supported: false, reason: 'A-buffer OIT requires a WebGPU device.'};
×
373
  }
NEW
374
  if (device.limits.maxStorageBuffersInFragmentStage < 2) {
×
NEW
375
    return {
×
376
      supported: false,
377
      reason: 'A-buffer OIT requires at least two fragment-stage storage buffers.'
378
    };
379
  }
380

NEW
381
  return {supported: true};
×
382
}
383

384
/**
385
 * Calculates a bounded-memory horizontal slice plan for an A-buffer target.
386
 *
387
 * @throws If the configured storage limits cannot hold one target scanline.
388
 */
389
export function getABufferSlicePlan(options: {
390
  width: number;
391
  height: number;
392
  averageFragmentsPerPixel: number;
393
  maxStorageBufferBindingSize: number;
394
  maxBufferSize: number;
395
}): ABufferSlicePlan {
396
  const {width, height, averageFragmentsPerPixel, maxStorageBufferBindingSize, maxBufferSize} =
NEW
397
    options;
×
398

NEW
399
  if (width <= 0 || height <= 0) {
×
NEW
400
    throw new Error('A-buffer target size must be positive.');
×
401
  }
402

NEW
403
  const maxBufferByteLength = Math.min(maxStorageBufferBindingSize, maxBufferSize);
×
NEW
404
  const maxSlicePixelsFromHeadPointers = Math.floor(
×
405
    (maxBufferByteLength - A_BUFFER_HEAD_POINTER_HEADER_BYTE_LENGTH) /
406
      A_BUFFER_HEAD_POINTER_BYTE_LENGTH
407
  );
NEW
408
  const maxSlicePixelsFromFragments = Math.floor(
×
409
    maxBufferByteLength / (averageFragmentsPerPixel * A_BUFFER_FRAGMENT_BYTE_LENGTH)
410
  );
NEW
411
  const maxSlicePixelCount = Math.min(maxSlicePixelsFromHeadPointers, maxSlicePixelsFromFragments);
×
412

NEW
413
  if (maxSlicePixelCount < width) {
×
NEW
414
    throw new Error(
×
415
      'A-buffer storage limits cannot fit one scanline at the configured fragment density.'
416
    );
417
  }
418

NEW
419
  const sliceHeight = Math.max(1, Math.floor(maxSlicePixelCount / width));
×
NEW
420
  const boundedSliceHeight = Math.min(sliceHeight, height);
×
NEW
421
  const boundedSlicePixelCount = width * boundedSliceHeight;
×
422

NEW
423
  return {
×
424
    width,
425
    height,
426
    sliceHeight: boundedSliceHeight,
427
    sliceCount: Math.ceil(height / boundedSliceHeight),
428
    maxSlicePixelCount: boundedSlicePixelCount,
429
    fragmentCapacity: boundedSlicePixelCount * averageFragmentsPerPixel,
430
    headPointerByteLength:
431
      A_BUFFER_HEAD_POINTER_HEADER_BYTE_LENGTH +
432
      boundedSlicePixelCount * A_BUFFER_HEAD_POINTER_BYTE_LENGTH,
433
    fragmentByteLength:
434
      boundedSlicePixelCount * averageFragmentsPerPixel * A_BUFFER_FRAGMENT_BYTE_LENGTH
435
  };
436
}
437

438
function resolveABufferRendererProps(props: ABufferRendererProps): ResolvedABufferRendererProps {
NEW
439
  const averageFragmentsPerPixel = Math.floor(
×
440
    props.averageFragmentsPerPixel ?? DEFAULT_AVERAGE_FRAGMENTS_PER_PIXEL
×
441
  );
NEW
442
  const maxFragmentsPerPixel = Math.floor(
×
443
    props.maxFragmentsPerPixel ?? DEFAULT_MAX_FRAGMENTS_PER_PIXEL
×
444
  );
NEW
445
  const maxBufferByteLength = Math.floor(props.maxBufferByteLength ?? Number.MAX_SAFE_INTEGER);
×
446

NEW
447
  if (averageFragmentsPerPixel < 1) {
×
NEW
448
    throw new Error('averageFragmentsPerPixel must be at least 1.');
×
449
  }
NEW
450
  if (maxFragmentsPerPixel < averageFragmentsPerPixel) {
×
NEW
451
    throw new Error('maxFragmentsPerPixel must be at least averageFragmentsPerPixel.');
×
452
  }
NEW
453
  if (maxBufferByteLength < 1) {
×
NEW
454
    throw new Error('maxBufferByteLength must be at least 1.');
×
455
  }
456

NEW
457
  return {averageFragmentsPerPixel, maxFragmentsPerPixel, maxBufferByteLength};
×
458
}
459

460
function validateABufferFramebuffer(framebuffer: Framebuffer): void {
NEW
461
  if (framebuffer.colorAttachments.length === 0) {
×
NEW
462
    throw new Error('A-buffer OIT requires a color attachment.');
×
463
  }
NEW
464
  if (!framebuffer.depthStencilAttachment) {
×
NEW
465
    throw new Error('A-buffer OIT requires a depth attachment.');
×
466
  }
NEW
467
  if (framebuffer.colorAttachments[0].texture.samples !== 1) {
×
NEW
468
    throw new Error('A-buffer OIT only supports single-sample color targets in v1.');
×
469
  }
NEW
470
  if (framebuffer.depthStencilAttachment.texture.samples !== 1) {
×
NEW
471
    throw new Error('A-buffer OIT only supports single-sample depth targets in v1.');
×
472
  }
NEW
473
  if (!(framebuffer.depthStencilAttachment.texture.props.usage & Texture.SAMPLE)) {
×
NEW
474
    throw new Error('A-buffer OIT requires a sampleable depth attachment.');
×
475
  }
476
}
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