Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions docs/roadmap/unified-atlas-capture.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ New header `src/external/openxr_includes/openxr/XR_EXT_atlas_capture.h`

```c
#define XR_EXT_atlas_capture 1
#define XR_EXT_atlas_capture_SPEC_VERSION 1
#define XR_EXT_atlas_capture_SPEC_VERSION 2
#define XR_EXT_ATLAS_CAPTURE_EXTENSION_NAME "XR_EXT_atlas_capture"

// Reuse the reserved 1000999xxx range (next free slot after the workspace block).
Expand All @@ -132,7 +132,7 @@ typedef struct XrAtlasCaptureInfoEXT {
XrStructureType type; // XR_TYPE_ATLAS_CAPTURE_INFO_EXT
const void* XR_MAY_ALIAS next;
XrAtlasCaptureStageEXT stage;
char pathPrefix[XR_ATLAS_CAPTURE_PATH_MAX_EXT]; // runtime appends "_atlas.png"
char pathPrefix[XR_ATLAS_CAPTURE_PATH_MAX_EXT]; // runtime appends "_atlas_<viewCount>_<cols>x<rows>.png"
} XrAtlasCaptureInfoEXT;

// Identical metadata block to XrWorkspaceCaptureResultEXT (see rationale below).
Expand All @@ -153,6 +153,23 @@ typedef XrResult (XRAPI_PTR *PFN_xrCaptureAtlasEXT)(
XrAtlasCaptureResultEXT *result); // result may be NULL if the caller wants only the PNG
```

### Filename + alpha contract (SPEC_VERSION 2, issue #425)

- **Suffix.** The runtime appends `_atlas_<viewCount>_<cols>x<rows>.png` to
`pathPrefix` (e.g. a 2-view 2×1 capture → `<prefix>_atlas_2_2x1.png`), so
consumers don't re-derive the atlas geometry. `viewCount` is the tile count
(`cols*rows` for all current layouts). Callers pass a bare `<stem>-<N>` prefix
and must **not** pre-bake the layout (avoids `..._2x1_atlas_2_2x1.png`). The
suffix is built at the EXT-contract layer — `oxr_capture.c` (in-process,
resolving the active rendering mode's tile layout) and
`comp_d3d11_service.cpp` (IPC) — **not** inside the per-API
`*_capture_atlas_to_png`, because the MCP `capture_frame` tool and the dev
trigger files write/report a verbatim full path.
- **Opaque alpha.** Every encoder forces `A=255` before PNG write
(`u_image_force_opaque_rgba8`). The swapchain alpha is undefined for display
output (the DP/weaver ignores it); left verbatim it reads back as 0 → fully
transparent → renders black.

Edit to `XR_EXT_spatial_workspace.h` (spec_version bump): add the second capture
flag so the privileged path also supports stage selection.

Expand Down
29 changes: 20 additions & 9 deletions src/external/openxr_includes/openxr/XR_EXT_atlas_capture.h
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ extern "C" {
#endif

#define XR_EXT_atlas_capture 1
#define XR_EXT_atlas_capture_SPEC_VERSION 1
// SPEC_VERSION 2: the runtime appends "_atlas_<viewCount>_<cols>x<rows>.png"
// (was a flat "_atlas.png" in v1), and the encoded PNG is always opaque
// (alpha forced to 255). See issue #425.
#define XR_EXT_atlas_capture_SPEC_VERSION 2
#define XR_EXT_ATLAS_CAPTURE_EXTENSION_NAME "XR_EXT_atlas_capture"

// Reserved 1000999xxx range, next free slot after the workspace block
Expand Down Expand Up @@ -57,9 +60,13 @@ typedef enum XrAtlasCaptureStageEXT {
/*!
* @brief Request struct for xrCaptureAtlasEXT.
*
* The runtime appends a format-specific suffix (e.g. "_atlas.png") to
* @c pathPrefix. The prefix is an in-struct char array (not a separately
* allocated string) so the same struct can cross the IPC schema unchanged.
* The runtime appends a layout-encoded suffix
* "_atlas_<viewCount>_<cols>x<rows>.png" to @c pathPrefix (e.g. a 2-view 2x1
* capture → "<pathPrefix>_atlas_2_2x1.png"), so consumers don't re-derive the
* multi-view atlas geometry. Callers should pass a bare prefix (e.g.
* "<stem>-<N>") and not pre-bake the layout, to avoid duplicating it. The
* prefix is an in-struct char array (not a separately allocated string) so the
* same struct can cross the IPC schema unchanged.
*/
typedef struct XrAtlasCaptureInfoEXT {
XrStructureType type; //!< Must be XR_TYPE_ATLAS_CAPTURE_INFO_EXT
Expand All @@ -72,9 +79,10 @@ typedef struct XrAtlasCaptureInfoEXT {
* @brief Result returned by xrCaptureAtlasEXT.
*
* Same metadata block as XrWorkspaceCaptureResultEXT minus @c viewsWritten.
* For in-process sessions @c eyeLeftM / @c eyeRightM (and @c tileColumns /
* @c tileRows) may be zero — eye-pose plumbing currently stops at the display
* processor and is only surfaced on the IPC/workspace path.
* @c tileColumns / @c tileRows are populated on both paths (from the active
* rendering mode in-process). For in-process sessions @c eyeLeftM /
* @c eyeRightM may still be zero — eye-pose plumbing currently stops at the
* display processor and is only surfaced on the IPC/workspace path.
*/
typedef struct XrAtlasCaptureResultEXT {
XrStructureType type; //!< Must be XR_TYPE_ATLAS_CAPTURE_RESULT_EXT
Expand All @@ -96,8 +104,11 @@ typedef struct XrAtlasCaptureResultEXT {
* @brief Capture the multi-view atlas the runtime composes for this session.
*
* The runtime reads the compositor's own atlas at @c info->stage and writes it
* to a PNG named after @c info->pathPrefix (the runtime appends "_atlas.png").
* If @c result is non-NULL it is filled with metadata describing the capture.
* to a PNG named after @c info->pathPrefix (the runtime appends
* "_atlas_<viewCount>_<cols>x<rows>.png"). The encoded PNG is always opaque
* (alpha forced to 255 — the swapchain's alpha is undefined for display
* output). If @c result is non-NULL it is filled with metadata describing the
* capture.
*
* Timing: the call is non-blocking and latches the request; the readback runs
* at the next composed frame (the caller's next xrEndFrame), so the PNG exists
Expand Down
50 changes: 50 additions & 0 deletions src/xrt/auxiliary/util/u_image_capture.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright 2026, DisplayXR
// SPDX-License-Identifier: BSL-1.0
/*!
* @file
* @brief Small pixel-buffer helpers shared by the atlas-capture PNG encoders.
* @ingroup aux_util
*/

#pragma once

#include <stdint.h>
#include <stddef.h>

#ifdef __cplusplus
extern "C" {
#endif

/*!
* Force every pixel's alpha to 255 (opaque) in a tightly-or-strided RGBA8
* buffer, in place.
*
* The atlas-capture readback copies the swapchain's alpha channel verbatim,
* but that alpha is *undefined for display output* — the compositor / display
* processor (weaver) never reads it, so it is typically 0. Left as-is the
* encoded PNG is fully transparent and renders black in normal image viewers
* (issue #425). The captured atlas is opaque display content, so the encoder
* forces A=255 before @c stbi_write_png.
*
* @param pixels Base of an RGBA8 buffer (byte order R,G,B,A per pixel).
* @param width Pixels per row.
* @param height Number of rows.
* @param stride_bytes Bytes per row (>= width*4; equals width*4 when tight).
*/
static inline void
u_image_force_opaque_rgba8(uint8_t *pixels, uint32_t width, uint32_t height, size_t stride_bytes)
{
if (pixels == NULL) {
return;
}
for (uint32_t y = 0; y < height; y++) {
uint8_t *row = pixels + (size_t)y * stride_bytes;
for (uint32_t x = 0; x < width; x++) {
row[(size_t)x * 4 + 3] = 255;
}
}
}

#ifdef __cplusplus
}
#endif
134 changes: 117 additions & 17 deletions src/xrt/compositor/d3d11/comp_d3d11_compositor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
#include "util/u_tiling.h"
#include "util/u_canvas.h"
#include "util/u_capture_intent.h"
#include "util/u_image_capture.h"

#ifdef XRT_BUILD_DRIVER_QWERTY
#include "qwerty_interface.h"
Expand Down Expand Up @@ -900,26 +901,21 @@ d3d11_crop_atlas_for_dp(struct comp_d3d11_compositor *c,
// compositor crops and sends to the DP) into a staging texture, then write
// @p path as PNG. D3D11 renderer uses DXGI_FORMAT_R8G8B8A8_UNORM so no
// channel swap is needed.
// Read back the @p content_w × @p content_h top-left region of @p atlas_tex
// (clamped to its actual size) and write @p path as an opaque RGBA8 PNG. The
// source may be the renderer's composited atlas OR, in zero-copy present, the
// app's own swapchain image — both already hold the laid-out multi-view atlas.
static bool
d3d11_compositor_capture_atlas_to_png(struct comp_d3d11_compositor *c, const char *path)
d3d11_capture_texture_to_png(struct comp_d3d11_compositor *c,
ID3D11Texture2D *atlas_tex,
uint32_t content_w,
uint32_t content_h,
const char *path)
{
ID3D11Texture2D *atlas_tex = static_cast<ID3D11Texture2D *>(
comp_d3d11_renderer_get_atlas_texture(c->renderer));
if (atlas_tex == nullptr || c->renderer == nullptr) {
return false;
}

uint32_t tile_columns = 1, tile_rows = 1;
comp_d3d11_renderer_get_tile_layout(c->renderer, &tile_columns, &tile_rows);
uint32_t view_w = 0, view_h = 0;
comp_d3d11_renderer_get_view_dimensions(c->renderer, &view_w, &view_h);
if (tile_columns == 0 || tile_rows == 0 || view_w == 0 || view_h == 0) {
if (atlas_tex == nullptr || content_w == 0 || content_h == 0) {
return false;
}

uint32_t content_w = tile_columns * view_w;
uint32_t content_h = tile_rows * view_h;

D3D11_TEXTURE2D_DESC adesc;
atlas_tex->GetDesc(&adesc);
if (content_w > adesc.Width) content_w = adesc.Width;
Expand Down Expand Up @@ -947,14 +943,61 @@ d3d11_compositor_capture_atlas_to_png(struct comp_d3d11_compositor *c, const cha
return false;
}

bool ok = stbi_write_png(path, (int)content_w, (int)content_h, 4,
m.pData, (int)m.RowPitch) != 0;
// Repack into a tightly-packed RGBA8 buffer (the mapped staging is
// READ-only, so we can't fix alpha in place) and force opaque: swapchain
// alpha is undefined for display output, and left as-is the PNG renders
// fully transparent/black (issue #425).
bool ok = false;
size_t tight_pitch = (size_t)content_w * 4;
uint8_t *tight = (uint8_t *)malloc(tight_pitch * content_h);
if (tight != nullptr) {
const uint8_t *src = (const uint8_t *)m.pData;
for (uint32_t y = 0; y < content_h; y++) {
memcpy(tight + (size_t)y * tight_pitch, src + (size_t)y * m.RowPitch, tight_pitch);
}
u_image_force_opaque_rgba8(tight, content_w, content_h, tight_pitch);
ok = stbi_write_png(path, (int)content_w, (int)content_h, 4, tight, (int)tight_pitch) != 0;
free(tight);
}

c->context->Unmap(staging, 0);
staging->Release();
return ok;
}

// Resolve the active content region (tile_columns·view_w × tile_rows·view_h)
// from the renderer. Returns false if the renderer hasn't been sized yet.
static bool
d3d11_compositor_content_dims(struct comp_d3d11_compositor *c, uint32_t *out_w, uint32_t *out_h)
{
if (c->renderer == nullptr) {
return false;
}
uint32_t tile_columns = 1, tile_rows = 1;
comp_d3d11_renderer_get_tile_layout(c->renderer, &tile_columns, &tile_rows);
uint32_t view_w = 0, view_h = 0;
comp_d3d11_renderer_get_view_dimensions(c->renderer, &view_w, &view_h);
if (tile_columns == 0 || tile_rows == 0 || view_w == 0 || view_h == 0) {
return false;
}
*out_w = tile_columns * view_w;
*out_h = tile_rows * view_h;
return true;
}

// Capture from the renderer's composited atlas (non-zero-copy frames).
static bool
d3d11_compositor_capture_atlas_to_png(struct comp_d3d11_compositor *c, const char *path)
{
ID3D11Texture2D *atlas_tex = static_cast<ID3D11Texture2D *>(
comp_d3d11_renderer_get_atlas_texture(c->renderer));
uint32_t content_w = 0, content_h = 0;
if (atlas_tex == nullptr || !d3d11_compositor_content_dims(c, &content_w, &content_h)) {
return false;
}
return d3d11_capture_texture_to_png(c, atlas_tex, content_w, content_h, path);
}

// Service a pending MCP capture_frame request — thin wrapper around
// Run the capture readback if the per-frame intent matches @p mode_filter.
// Mirrors vk_native_dispatch_capture / gl_compositor_dispatch_capture.
Expand All @@ -975,6 +1018,54 @@ d3d11_compositor_dispatch_capture(struct comp_d3d11_compositor *c, uint32_t mode
u_capture_intent_complete(&c->capture_intent, &c->mcp_capture, ok);
}

// Zero-copy capture: in zero-copy present the app's own swapchain image IS the
// laid-out multi-view atlas (that's why it qualifies), and the normal composite
// passes — and their PROJECTION_ONLY/POST_COMPOSE dispatch points — are skipped,
// so a pending capture would otherwise silently produce no PNG (issue #425: the
// Unity path submits a single double-wide projection layer that hits zero-copy,
// unlike the native/Unreal apps). Read directly from the same swapchain SRV the
// DP receives — NOT by forcing a re-composite, which re-projects the already-
// tiled texture and corrupts it. Stage selector is irrelevant here: zero-copy
// has a single projection layer and no window-space layers, so PROJECTION_ONLY
// and POST_COMPOSE are identical.
static void
d3d11_compositor_dispatch_capture_zerocopy(struct comp_d3d11_compositor *c, void *zc_srv)
{
if (!c->capture_intent.pending || zc_srv == nullptr) {
return;
}
ID3D11ShaderResourceView *srv = static_cast<ID3D11ShaderResourceView *>(zc_srv);
ID3D11Resource *resource = nullptr;
srv->GetResource(&resource);
ID3D11Texture2D *zc_tex = static_cast<ID3D11Texture2D *>(resource);

// Capture the whole swapchain texture: zero-copy is only eligible when the
// app's swapchain matches the active mode's atlas dimensions exactly
// (u_tiling_can_zero_copy), so the entire texture IS the multi-view atlas.
// Do NOT use the renderer's view/tile dims here — for a legacy app those are
// the 2D/3D compromise size (e.g. 1200×1080), which is smaller than the
// app's real per-view render (e.g. 1920×1080) and would crop the atlas
// (issue #425, the Unity stretched/cropped-PNG case).
bool ok = false;
if (zc_tex != nullptr) {
D3D11_TEXTURE2D_DESC zdesc;
zc_tex->GetDesc(&zdesc);
ok = d3d11_capture_texture_to_png(c, zc_tex, zdesc.Width, zdesc.Height,
c->capture_intent.path);
}
if (resource != nullptr) {
resource->Release();
}
if (ok) {
U_LOG_I("Atlas captured (zero-copy, mode=%u) to %s",
c->capture_intent.mode, c->capture_intent.path);
} else {
U_LOG_W("Atlas capture failed (zero-copy, mode=%u path=%s)",
c->capture_intent.mode, c->capture_intent.path);
}
u_capture_intent_complete(&c->capture_intent, &c->mcp_capture, ok);
}

static xrt_result_t
d3d11_compositor_layer_commit(struct xrt_compositor *xc, xrt_graphics_sync_handle_t sync_handle)
{
Expand Down Expand Up @@ -1198,6 +1289,7 @@ d3d11_compositor_layer_commit(struct xrt_compositor *xc, xrt_graphics_sync_handl
}
}


// Wait for GPU completion of all projection swapchain textures before reading.
//
// barrier_image(TO_COMP) at xrReleaseSwapchainImage inserts a Flush() then
Expand Down Expand Up @@ -1277,6 +1369,14 @@ d3d11_compositor_layer_commit(struct xrt_compositor *xc, xrt_graphics_sync_handl
}
}

// Zero-copy capture: the app's swapchain (synced by the GPU-completion
// wait above) already holds the atlas the DP will present, and the normal
// dispatch points below are gated behind !zero_copy — so service a pending
// capture here, reading the swapchain directly (issue #425).
if (zero_copy) {
d3d11_compositor_dispatch_capture_zerocopy(c, zc_srv);
}

// Render layers to atlas texture (skip if zero-copy). Split into a
// projection pass + window-space pass so a projection-only capture
// can read the atlas in between.
Expand Down
10 changes: 9 additions & 1 deletion src/xrt/compositor/d3d11_service/comp_d3d11_service.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@

#include "util/u_hud.h"
#include "util/u_tiling.h"
#include "util/u_image_capture.h"
#include <displayxr_mcp/mcp_capture.h>

#ifdef XRT_BUILD_DRIVER_QWERTY
Expand Down Expand Up @@ -10945,8 +10946,15 @@ comp_d3d11_service_capture_frame(struct xrt_system_compositor *xsysc,
src + (size_t)y * m.RowPitch,
(size_t)used_w * 4u);
}
// Swapchain alpha is undefined for display output — force opaque so the
// PNG doesn't render fully transparent/black (issue #425).
u_image_force_opaque_rgba8(buf.data(), used_w, used_h, (size_t)used_w * 4u);
// Encode the atlas geometry into the suffix so consumers don't re-derive
// it: "_atlas_<viewCount>_<cols>x<rows>.png" (issue #425). viewCount is
// the tile count (cols*rows for all current DisplayXR layouts).
char path[MAX_PATH];
snprintf(path, sizeof(path), "%s_atlas.png", path_prefix);
snprintf(path, sizeof(path), "%s_atlas_%u_%ux%u.png", path_prefix,
tile_columns * tile_rows, tile_columns, tile_rows);
if (stbi_write_png(path, (int)used_w, (int)used_h, 4,
buf.data(), (int)(used_w * 4u)) != 0) {
views_written |= want_flag;
Expand Down
4 changes: 4 additions & 0 deletions src/xrt/compositor/d3d12/comp_d3d12_compositor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
#include "util/u_tiling.h"
#include "util/u_canvas.h"
#include "util/u_capture_intent.h"
#include "util/u_image_capture.h"
#include "util/u_hud.h"
#include <displayxr_mcp/mcp_capture.h>

Expand Down Expand Up @@ -1060,6 +1061,9 @@ d3d12_compositor_capture_atlas_to_png(struct comp_d3d12_compositor *c, const cha
rb_pixels + (size_t)y * row_pitch,
tight_pitch);
}
// Swapchain alpha is undefined for display output — force opaque
// so the PNG doesn't render fully transparent/black (issue #425).
u_image_force_opaque_rgba8(tight, content_w, content_h, tight_pitch);
ok = stbi_write_png(path, (int)content_w, (int)content_h, 4,
tight, (int)tight_pitch) != 0;
free(tight);
Expand Down
5 changes: 5 additions & 0 deletions src/xrt/compositor/gl/comp_gl_compositor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
#include "util/u_tiling.h"
#include "util/u_canvas.h"
#include "util/u_capture_intent.h"
#include "util/u_image_capture.h"
#include "util/u_time.h"
#include "util/u_hud.h"
#include <displayxr_mcp/mcp_capture.h>
Expand Down Expand Up @@ -1133,6 +1134,10 @@ gl_compositor_capture_atlas_to_png(struct comp_gl_compositor *c, const char *pat
}
free(bottom_up);

// Swapchain alpha is undefined for display output — force opaque so the
// PNG doesn't render fully transparent/black (issue #425).
u_image_force_opaque_rgba8(top_down, content_w, content_h, row_pitch);

bool ok = stbi_write_png(path, (int)content_w, (int)content_h, 4, top_down, (int)row_pitch) != 0;
free(top_down);
return ok;
Expand Down
Loading
Loading