mrdoob/three.js · error · Error

THREE.WebGPUAttributeUtils: ReadbackBuffer must be released

Error message

THREE.WebGPUAttributeUtils: ReadbackBuffer must be released before being used again.

What it means

Thrown by WebGPUAttributeUtils.getArrayBufferAsync when a ReadbackBuffer passed as the target is still in the mapped state. The method sets target._mapped = true at the start of a readback (line 396) and only resets it to false when the buffer's 'release' or 'dispose' event fires (lines 413, 422). WebGPU forbids a buffer with MAP_READ usage from being re-used for a new copy-then-map cycle while a previous mapping is still outstanding, so the library treats a second call as a programmer error rather than queuing it.

Source

Thrown at src/renderers/webgpu/utils/WebGPUAttributeUtils.js:392

	 * @return {Promise<ArrayBuffer|ReadbackBuffer>} A promise that resolves with the buffer data when the data are ready.
	 */
	async getArrayBufferAsync( attribute, target = null, offset = 0, count = - 1 ) {

		const backend = this.backend;
		const device = backend.device;

		const data = backend.get( this._getBufferAttribute( attribute ) );
		const bufferGPU = data.buffer;
		const byteLength = count === - 1 ? bufferGPU.size - offset : count;

		let readBufferGPU;
		if ( target !== null && target.isReadbackBuffer ) {

			const readbackInfo = backend.get( target );

			if ( target._mapped === true ) {

				throw new Error( 'THREE.WebGPUAttributeUtils: ReadbackBuffer must be released before being used again.' );

			}

			target._mapped = true;

			// initialize the GPU-side read copy buffer if it is not present
			if ( readbackInfo.readBufferGPU === undefined ) {

				_bufferDescriptor.label = `${ target.name }_readback`;
				_bufferDescriptor.size = target.maxByteLength;
				_bufferDescriptor.usage = GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ;

				readBufferGPU = device.createBuffer( _bufferDescriptor );

				_bufferDescriptor.reset();

				// release / dispose
				const releaseCallback = () => {

View on GitHub (pinned to da05705fa3)

Solutions

  1. After the getArrayBufferAsync promise resolves, read target.buffer, then call target.release() before issuing the next readback with the same ReadbackBuffer.
  2. Await the previous readback promise fully before calling getArrayBufferAsync again with the same target, so the mapped buffer is consumed in order.
  3. If concurrent readbacks are needed, allocate a separate ReadbackBuffer instance per outstanding read instead of reusing one.
  4. If you no longer need the buffer, call target.dispose() to free the GPU resource and reset _mapped.

Example fix

// before
const rb = new THREE.ReadbackBuffer( byteLength );
setInterval( () => renderer.getArrayBufferAsync( attr, rb ), 16 ); // throws on 2nd tick

// after
const rb = new THREE.ReadbackBuffer( byteLength );
setInterval( async () => {
  await renderer.getArrayBufferAsync( attr, rb );
  consume( rb.buffer );
  rb.release(); // unmap so rb can be reused next tick
}, 16 );
Defensive patterns

Strategy: validation

Validate before calling

// Before reusing a ReadbackBuffer, ensure the previous readback is done and released.
function canReuseReadback( renderer, readback ) {
  return readback.isReadbackBuffer === true && readback._mapped === false;
}

// Usage:
if ( canReuseReadback( renderer, rb ) ) {
  await renderer.getArrayBufferAsync( attr, rb );
} else {
  // previous read still in flight or not released; allocate a fresh buffer
  const tmp = new THREE.ReadbackBuffer( byteLength );
  await renderer.getArrayBufferAsync( attr, tmp );
}

Type guard

import { ReadbackBuffer } from 'three/src/renderers/common/ReadbackBuffer.js';

function isAvailableReadbackBuffer( target ) {
  return target instanceof ReadbackBuffer
    && target.isReadbackBuffer === true
    && target._mapped === false;
}

Try / catch

try {
  await renderer.getArrayBufferAsync( attr, rb );
} catch ( err ) {
  if ( /ReadbackBuffer must be released/.test( err.message ) ) {
    // rb is still mapped from a prior read: release then retry once
    rb.release();
    await renderer.getArrayBufferAsync( attr, rb );
  } else {
    throw err;
  }
}

Prevention

When it happens

Trigger: Calling renderer.getArrayBufferAsync(attribute, sameReadbackBuffer) a second time before the first promise has resolved, or after it resolved but before calling target.release(). Also: reusing one ReadbackBuffer across multiple concurrent compute-shader readbacks, or keeping a reference to it in a loop that re-reads without releasing between iterations.

Common situations: Polling a storage buffer attribute each frame (e.g. GPU particle positions, transform feedback, compute output) with a single shared ReadbackBuffer and forgetting to release it after consuming target.buffer. Refactoring code that previously passed a plain ArrayBuffer (which auto-destroys each call, line 496) to reuse a ReadbackBuffer for performance without adding the release() step. Async/await ordering bugs where the next readback is dispatched before the prior await resolves.

Related errors


AI-assisted analysis of mrdoob/three.js@da05705fa3 (2026-08-12). Data as JSON: /api/errors/b6ec0400e7952ace. Report an issue: GitHub.