Skip to content

Commit af669a5

Browse files
farbod-nvclaude
andcommitted
viz: ProjectionLayer + in-loop frame protocol refactor
Adds ProjectionLayer for full-view RGBD content (gsplat, nvblox, neural reconstruction) and refactors VizSession's frame loop so the poses fed to renderers match the poses submitted to OpenXR. ## ProjectionLayer In-loop contract: info = session.begin_frame() # xrWaitFrame + Begin + LocateViews color, depth = renderer.render(info.views) # render against THIS frame's views layer.submit(color, depth) # publish for this frame session.end_frame() # composite + xrEndFrame The runtime / CloudXR paces the app via xrWaitFrame; if the renderer takes 30 ms, the app runs at ~30 fps and the runtime compositor reprojects the last submitted frame at display rate. Storage: per-slot (color, depth) DeviceImages (7-slot mailbox per QuadLayer's pattern). Fragment shader samples color + depth and writes gl_FragDepth so QuadLayer / future OverlayLayer Z-composite correctly in the shared RT. Stereo: paired (left, right) slot storage; submit() ships both eyes on one CUDA stream. In kXr, a visible ProjectionLayer that doesn't submit for the current frame is skipped at record() time so stale RGBD never gets composited under a new projection-layer pose. The freshness gate is off in kWindow / kOffscreen where no XR pose mismatch is possible. Single XrCompositionLayerProjection covers QuadLayers + ProjectionLayer together — CloudXR fast-paths when only ProjectionLayer is present, and squashes per-layer-timewarped when mixed (Monado's compute-shader layer accumulator handles this automatically). ## VizSession frame-loop refactor ``VizSession::begin_frame`` previously returned placeholder identity views; the real per-eye XR poses were only acquired later inside ``VizCompositor::render`` via ``backend_->begin_frame``. That meant renderers calling ``submit`` against the returned FrameInfo were rendering against the wrong pose. Moves backend frame acquisition into ``VizSession::begin_frame``: acquired Frame is stored in ``current_backend_frame_`` and consumed by ``end_frame``. ``FrameInfo.views`` now carries the real xrLocateViews output, so the in-loop pattern above is pose-correct by construction. VizCompositor::render takes the acquired Frame as a parameter; the existing FrameGuard still owns end_frame/abort_frame protocol balance. Adds Frame::predicted_display_time_ns so XR's ``last_frame_state_.predictedDisplayTime`` flows through to ``FrameInfo.predicted_display_time``. ## LayerBase::on_frame_begin New virtual called from VizSession::begin_frame on every registered layer (default no-op). ProjectionLayer uses it to clear its submitted-this-frame freshness flag. ## Minor changes - ``DeviceImage`` allows PixelFormat::kD32F (was rejected as "reserved for ProjectionLayer"). kD32F + mip_levels > 1 rejected explicitly. - Python bindings: expose ``ViewInfo`` and ``FrameInfo.views``. No ``ProjectionViewSnapshot`` / ``predicted_views`` / ``get_last_frame_info`` — the renderer reads ``info.views`` returned by ``begin_frame``. ## Tests - 13 C++ Catch2 tests (4 unit + 9 GPU): config validation, slot/view allocation, idempotent destroy, submit shape/format/dimension validation, mono+depth mailbox advance, stereo paired submit, no-depth path, on_frame_begin/submitted-this-frame toggling. - 8 Python pytest tests covering the same surface plus the in-loop submit pattern via begin_frame/end_frame. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 187e8ac commit af669a5

22 files changed

Lines changed: 2012 additions & 65 deletions

src/viz/core/cpp/device_image.cpp

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -127,13 +127,17 @@ std::unique_ptr<DeviceImage> DeviceImage::create(const VkContext& ctx,
127127
{
128128
throw std::invalid_argument("DeviceImage: resolution must be non-zero");
129129
}
130-
if (format != PixelFormat::kRGBA8)
130+
if (format != PixelFormat::kRGBA8 && format != PixelFormat::kD32F)
131131
{
132-
// kD32F is reserved for ProjectionLayer's depth path. The
133-
// CUDA-Vulkan interop contract for a depth image (sample
134-
// semantics, layout transitions, color-space view) is not
135-
// worked out yet, so refuse to half-build it.
136-
throw std::invalid_argument("DeviceImage: only PixelFormat::kRGBA8 is supported");
132+
throw std::invalid_argument("DeviceImage: unsupported PixelFormat");
133+
}
134+
if (format == PixelFormat::kD32F && mip_levels > 1)
135+
{
136+
// Depth + mip chain is meaningless (filtering depth between mip
137+
// levels produces incorrect occlusion) and we'd have to
138+
// special-case the blit-down pipeline. Reject explicitly rather
139+
// than silently allocating the chain.
140+
throw std::invalid_argument("DeviceImage: kD32F does not support mip_levels > 1");
137141
}
138142
// mip_levels == 0 -> auto-compute full chain to 1x1.
139143
if (mip_levels == 0)

src/viz/layers/cpp/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ cmake_minimum_required(VERSION 3.20)
1010
# viz/layers_tests/.
1111
add_library(viz_layers STATIC
1212
quad_layer.cpp
13+
projection_layer.cpp
1314
inc/viz/layers/quad_layer.hpp
15+
inc/viz/layers/projection_layer.hpp
1416
)
1517

1618
target_include_directories(viz_layers
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
#pragma once
5+
6+
#include <viz/core/device_image.hpp>
7+
#include <viz/core/viz_buffer.hpp>
8+
#include <viz/core/viz_types.hpp>
9+
#include <viz/session/layer_base.hpp>
10+
#include <vulkan/vulkan.h>
11+
12+
#include <array>
13+
#include <atomic>
14+
#include <cstdint>
15+
#include <cuda_runtime.h>
16+
#include <memory>
17+
#include <optional>
18+
#include <string>
19+
#include <vector>
20+
21+
namespace viz
22+
{
23+
24+
class VkContext;
25+
26+
// ProjectionLayer: full-view RGBD composited into the shared render
27+
// target. Designed for renderers (gsplat, nvblox, neural reconstruction)
28+
// that produce (color, depth) buffers per frame.
29+
//
30+
// Frame loop contract — IMPORTANT:
31+
//
32+
// info = session.begin_frame() // xrLocateViews
33+
// color, depth = renderer.render(info.views) // render against THIS frame's views
34+
// layer.submit(color, depth) // publish for THIS frame
35+
// session.end_frame() // composite + xrEndFrame
36+
//
37+
// ``submit()`` MUST be called between ``begin_frame()`` and
38+
// ``end_frame()``. The renderer MUST render against
39+
// ``info.views[i].pose`` (the predicted-display-time pose for this
40+
// frame). The runtime / CloudXR paces the application via xrWaitFrame;
41+
// if the renderer takes longer than display rate, the runtime's
42+
// compositor reprojects the last submitted frame at display rate while
43+
// the app's framerate matches the renderer's speed.
44+
//
45+
// In ``kXr``, a visible ProjectionLayer that does NOT receive a
46+
// ``submit()`` for the current frame is SKIPPED at record time (the
47+
// layer's region of the shared RT keeps the clear color). This prevents
48+
// the runtime from compositing yesterday's RGBD content under today's
49+
// projection-layer pose, which would produce a visible reprojection
50+
// error. In ``kWindow`` / ``kOffscreen`` the freshness gate is off —
51+
// the most recent publish stays on screen until replaced (the QuadLayer
52+
// pattern), since no XR pose mismatch is possible.
53+
//
54+
// Mailbox: kSlotCount per-eye (color, depth) DeviceImage pairs. submit()
55+
// picks a slot that's neither ``latest_`` nor in any ``in_use_`` entry,
56+
// memcpys + signals cuda_done_writing on the caller's stream, blocks on
57+
// cudaStreamSynchronize so the caller can re-use source buffers
58+
// immediately, then atomically promotes the slot to ``latest_``.
59+
// record_pre_render_pass promotes ``latest_`` to ``in_use_[slot]``.
60+
//
61+
// Stereo: when Config::stereo is true, the layer allocates paired
62+
// (left, right) storage per slot. submit() must ship both eyes on a
63+
// single CUDA stream; stream ordering keeps the pair atomic. In kXr
64+
// view 0 (left eye) samples the left buffer, view 1 (right eye) the
65+
// right. In kWindow / kOffscreen the left buffer is sampled.
66+
//
67+
// Memory (per layer):
68+
// mono 1024² RGBA8+D32F: 7 slots × 1024² × 8 B ≈ 56 MB
69+
// stereo 1024² RGBA8+D32F: ≈ 112 MB
70+
// stereo 2048² RGBA8+D32F: ≈ 448 MB
71+
class ProjectionLayer : public LayerBase
72+
{
73+
public:
74+
// Sized to cover backend image counts up to 5, leave one free slot.
75+
static constexpr uint32_t kMaxFramesInFlight = 5;
76+
static constexpr uint32_t kSlotCount = kMaxFramesInFlight + 2;
77+
78+
struct Config
79+
{
80+
std::string name = "ProjectionLayer";
81+
Resolution view_resolution{};
82+
PixelFormat color_format = PixelFormat::kRGBA8;
83+
84+
// nullopt → no depth buffer allocated; ProjectionLayer always
85+
// writes gl_FragDepth = 1.0 (far). Without depth, this layer
86+
// loses Z-compositing with QuadLayer. Useful for renderers that
87+
// genuinely have no depth (sky / background fills).
88+
std::optional<PixelFormat> depth_format = PixelFormat::kD32F;
89+
90+
// true → per-eye paired storage. submit MUST ship both eyes.
91+
// In kWindow / kOffscreen the LEFT buffer is sampled; in kXr
92+
// view 0 → LEFT, view 1 → RIGHT.
93+
bool stereo = false;
94+
};
95+
96+
ProjectionLayer(const VkContext& ctx, VkRenderPass render_pass, Config config);
97+
~ProjectionLayer() override;
98+
void destroy();
99+
100+
ProjectionLayer(const ProjectionLayer&) = delete;
101+
ProjectionLayer& operator=(const ProjectionLayer&) = delete;
102+
103+
// Publish a frame. Each buffer is a CUDA-linear VizBuffer (kDevice
104+
// space) matching the layer's resolution and the relevant format
105+
// (color → color_format, depth → kD32F). Validated against the
106+
// config; mismatch throws std::invalid_argument.
107+
//
108+
// Mono no-depth: submit(color)
109+
// Mono with depth: submit(color, &depth)
110+
// Stereo no-depth: submit(left_color, nullptr, &right_color, nullptr)
111+
// Stereo with depth: submit(left_color, &left_depth, &right_color, &right_depth)
112+
//
113+
// submit() does one cudaMemcpy2DToArrayAsync per provided buffer
114+
// on ``stream``, signals cuda_done_writing on the same stream, then
115+
// BLOCKS on cudaStreamSynchronize so the caller can re-use source
116+
// buffers immediately. Cost: ~0.5 ms / 1024² color + depth on a
117+
// discrete GPU.
118+
//
119+
// Marks the layer "fresh for this frame" so record() will draw it.
120+
// VizSession::begin_frame clears the flag at the start of each
121+
// frame; a renderer that doesn't submit will see its content
122+
// skipped in kXr.
123+
//
124+
// GIL: pybind binding releases the GIL across this whole call.
125+
void submit(const VizBuffer& left_color,
126+
const VizBuffer* left_depth = nullptr,
127+
const VizBuffer* right_color = nullptr,
128+
const VizBuffer* right_depth = nullptr,
129+
cudaStream_t stream = 0);
130+
131+
// LayerBase contract.
132+
void on_frame_begin() override; // clears submitted_this_frame_ flag
133+
void record_pre_render_pass(VkCommandBuffer cmd, uint32_t in_flight_slot) override;
134+
void record(VkCommandBuffer cmd,
135+
const std::vector<ViewInfo>& views,
136+
const RenderTarget& target,
137+
uint32_t in_flight_slot) override;
138+
139+
// cuda_done_writing waits for color + depth of every active view in
140+
// the in-use slot. kSlotNone → empty vector.
141+
std::vector<LayerBase::WaitSemaphore> get_wait_semaphores() const override;
142+
143+
// Accessors.
144+
Resolution view_resolution() const noexcept;
145+
PixelFormat color_format() const noexcept;
146+
std::optional<PixelFormat> depth_format() const noexcept;
147+
bool is_stereo() const noexcept;
148+
uint32_t view_count() const noexcept;
149+
150+
// Diagnostic — nullptr outside valid ranges.
151+
const DeviceImage* color_image(uint32_t slot, uint32_t view) const noexcept;
152+
const DeviceImage* depth_image(uint32_t slot, uint32_t view) const noexcept;
153+
154+
private:
155+
static constexpr uint8_t kSlotNone = 0xFF;
156+
157+
void init();
158+
void create_sampler();
159+
void create_descriptor_set_layout();
160+
void create_pipeline_layout();
161+
void create_pipeline();
162+
void create_descriptor_pool();
163+
void allocate_descriptor_sets();
164+
void update_descriptor_sets();
165+
166+
uint8_t pick_free_slot() const noexcept;
167+
168+
void validate_submit_buffer(const VizBuffer& buf, PixelFormat expected_format, const char* label) const;
169+
void enqueue_copy(const VizBuffer& src, DeviceImage& dst, cudaStream_t stream) const;
170+
171+
const VkContext* ctx_ = nullptr;
172+
VkRenderPass render_pass_ = VK_NULL_HANDLE; // borrowed
173+
Config config_;
174+
uint32_t view_count_ = 1;
175+
bool has_depth_ = true;
176+
177+
std::array<std::vector<std::unique_ptr<DeviceImage>>, kSlotCount> slots_color_;
178+
std::array<std::vector<std::unique_ptr<DeviceImage>>, kSlotCount> slots_depth_;
179+
180+
VkSampler color_sampler_ = VK_NULL_HANDLE;
181+
VkSampler depth_sampler_ = VK_NULL_HANDLE;
182+
VkDescriptorSetLayout descriptor_set_layout_ = VK_NULL_HANDLE;
183+
VkPipelineLayout pipeline_layout_ = VK_NULL_HANDLE;
184+
VkPipeline pipeline_with_depth_ = VK_NULL_HANDLE;
185+
VkPipeline pipeline_no_depth_ = VK_NULL_HANDLE;
186+
187+
VkDescriptorPool descriptor_pool_ = VK_NULL_HANDLE;
188+
std::array<std::vector<VkDescriptorSet>, kSlotCount> descriptor_sets_;
189+
190+
// Mailbox.
191+
std::atomic<uint8_t> latest_{ kSlotNone };
192+
std::array<std::atomic<uint8_t>, kMaxFramesInFlight> in_use_{};
193+
std::atomic<uint8_t> last_in_use_slot_{ kSlotNone };
194+
195+
// Set by submit(), cleared by on_frame_begin(). record() consults
196+
// this in kXr to gate stale-RGBD-under-new-pose composites.
197+
std::atomic<bool> submitted_this_frame_{ false };
198+
};
199+
200+
} // namespace viz

0 commit comments

Comments
 (0)