Quick answer: AsyncGPUReadback returns empty data because the request is issued before the GPU finishes rendering to the target texture. Move the request to after the relevant render pass completes, match the texture format explicitly, and confirm you are reading from the active render target.

Here is how to fix Unity AsyncGPUReadback returning empty data. You set up an AsyncGPUReadback.Request() to pull pixel data from a RenderTexture, the callback fires with hasError = false, and the NativeArray is full of zeros. No errors in the console, no exceptions, just a buffer of nothing. You increase the texture size, try different formats, add delays — still zeros. The readback system is working correctly; it is faithfully reading whatever is in the texture at the moment you ask, and at that moment, the texture is empty because the GPU has not written to it yet.

The Symptom

You call AsyncGPUReadback.Request() on a RenderTexture and register a callback or poll with Update(). The request completes without error. You call GetData<Color32>() or GetData<byte>() and receive a valid NativeArray with the expected length. But every value is zero — black pixels, empty data, no useful information. The texture appears correctly on screen if you bind it to a material or preview it in the Inspector, so the data clearly exists on the GPU at some point. It is just not there when you read it.

In some cases, the readback returns partial data — a few rows of valid pixels at the top and zeros for the rest. This is a strong clue that you are reading mid-render: the GPU has written part of the texture but not all of it when the readback captures the state.

What Causes This

Timing: reading before the render pass completes. This is the cause in the vast majority of cases. AsyncGPUReadback.Request() captures the texture contents at the moment the GPU processes the request in its command stream. If you issue the request in Update() or LateUpdate(), it gets submitted to the GPU before the current frame’s render passes have executed. The GPU processes the readback, finds the texture still contains the previous frame’s data (or nothing, if this is the first frame), and returns that. The actual render happens after the readback in the GPU’s command queue.

Format mismatch. If you request a readback with a GraphicsFormat that does not match the RenderTexture’s internal format, some graphics APIs (particularly Vulkan and Metal) will silently return zeroed data instead of raising an error. For example, requesting R8G8B8A8_UNorm from a texture that uses R16G16B16A16_SFloat may produce zeros on some platforms while working correctly on others.

Reading from a stale or unbound render target. If you hold a reference to a RenderTexture that was the camera’s target last frame but has since been swapped (common in double-buffered setups or when using temporary RenderTextures), the readback reads from the old texture, which may have been cleared or released. The reference is still valid — the texture object exists — but it no longer contains the data you expect.

Compute shader dispatch not completed. If the data you want to read is written by a compute shader, the readback can outrun the dispatch. Compute shader dispatches are asynchronous on the GPU. Unless you explicitly insert a barrier or issue the readback after the dispatch in the same command buffer, the readback may execute before the compute shader finishes writing.

The Fix

Step 1: Request readback at the correct pipeline stage. In the Built-in Render Pipeline, use OnRenderImage() or a CommandBuffer attached to the appropriate camera event. In URP, create a custom ScriptableRenderPass and issue the request inside the Execute() method after the target pass has completed.

using UnityEngine;
using UnityEngine.Rendering;
using Unity.Collections;

public class CorrectReadback : MonoBehaviour
{
    private RenderTexture rt;
    private bool readbackPending = false;

    void Start()
    {
        rt = new RenderTexture(256, 256, 0,
            RenderTextureFormat.ARGB32);
        rt.Create();
        GetComponent<Camera>().targetTexture = rt;
    }

    // OnRenderImage runs AFTER the camera has rendered
    void OnRenderImage(RenderTexture src, RenderTexture dest)
    {
        Graphics.Blit(src, dest);

        if (!readbackPending)
        {
            readbackPending = true;
            AsyncGPUReadback.Request(src, 0,
                TextureFormat.RGBA32, OnReadbackComplete);
        }
    }

    void OnReadbackComplete(AsyncGPUReadbackRequest request)
    {
        readbackPending = false;

        if (request.hasError)
        {
            Debug.LogError("GPU readback error");
            return;
        }

        NativeArray<Color32> data = request.GetData<Color32>();
        Debug.Log($"Readback: {data.Length} pixels, " +
            $"first pixel: {data[0]}");
    }
}

The key insight is that OnRenderImage is called after the camera has finished rendering to src. The texture is guaranteed to contain the rendered frame at that point. If you move this same Request() call to Update(), it will execute before the camera renders and return the previous frame’s data — or zeros if the texture was just created.

Step 2: Match the readback format to the texture format. Always specify the format explicitly rather than relying on defaults. If your RenderTexture uses RenderTextureFormat.ARGBFloat, request the readback as GraphicsFormat.R32G32B32A32_SFloat, not as RGBA32.

// Correct: match the format exactly
AsyncGPUReadback.Request(rt, 0,
    rt.graphicsFormat, OnReadbackComplete);

// Incorrect: format mismatch may return zeros on some APIs
// AsyncGPUReadback.Request(rt, 0,
//     GraphicsFormat.R8G8B8A8_UNorm, OnReadbackComplete);

Using rt.graphicsFormat directly ensures the readback always matches, even if you change the texture format later. This eliminates an entire class of silent failures.

URP and HDRP: ScriptableRenderPass Approach

In URP, you do not have access to OnRenderImage. Instead, create a ScriptableRenderPass that injects after the pass that writes to your target texture. Set the renderPassEvent to RenderPassEvent.AfterRendering or a specific event that follows your target pass. Issue the readback request inside the Execute() method. This guarantees the GPU command stream places your readback after the render commands.

For HDRP, the same principle applies: use a CustomPass with the injection point set to AfterPostProcess or whichever point comes after your target data is written. The readback inside the custom pass execute method will be correctly ordered in the GPU command queue.

Compute Shader Readback

If you are reading data written by a compute shader, use a CommandBuffer to sequence the dispatch and readback together. This ensures GPU-side ordering without relying on CPU-side timing.

Create the CommandBuffer, call DispatchCompute() on it, then call RequestAsyncReadback() on the same buffer. Execute the buffer with Graphics.ExecuteCommandBuffer(). The GPU processes these commands in order, so the readback always happens after the dispatch.

Without the CommandBuffer approach, the compute dispatch and readback are separate GPU submissions that may execute in any order. The readback might complete before the compute shader even starts, returning stale or empty buffer contents.

“AsyncGPUReadback does not wait for anything. It reads what is in the texture right now on the GPU timeline, not what will be there after rendering completes. Your job is to make sure ‘right now’ is the right moment.”

Related Issues

If your readback works but causes frame rate drops, see GPU Readback Performance Stalls for strategies to spread readback costs across frames. If the RenderTexture itself is not being rendered to at all, check Render Texture Showing Black for camera target and clear flag configuration.

Use rt.graphicsFormat in the Request call to guarantee format matching across all platforms.