Skip to content

Commit 6d591a3

Browse files
fix: prevent viewport export clipping at high resolutions (#1311)
The viewport export renders once and reads back immediately. The VkSplat renderer sizes its per-frame visible-primitive and tile-instance scratch from deferred, one-frame-late high-water marks, so the first render at a new viewpoint/resolution can exceed them and render capacity-clamped (the depth/tile-ordered tail is dropped). That produces the curved clip that worsens with resolution. The live viewport hides this because it self-heals on the next frame, but a one-shot export saves the clamped frame; re-exporting from the same view works because the marks have since grown. Drive that self-heal synchronously for one-shot captures: re-render the Preview slot until the renderer confirms the previous pass produced complete, unclamped content (bounded by a max pass count), then read back. A new VksplatViewportRenderer::previewCaptureSettled() signal reports this from the deferred poll, rejecting the macro warm-up frame and requiring the steady-state chain; a pass>=1 guard keeps the signal tied to the current view so the tiled path stays correct. Settling is scoped to the export capture overloads, leaving film-strip thumbnails, asset previews, and depth captures on single-render behavior. Closes #1304 Co-authored-by: Oz <oz-agent@warp.dev>
1 parent 42ccb95 commit 6d591a3

4 files changed

Lines changed: 93 additions & 17 deletions

File tree

src/visualizer/rendering/rendering_manager.hpp

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -550,7 +550,8 @@ namespace lfs::vis {
550550
std::optional<bool> orthographic_override,
551551
std::optional<float> ortho_scale_override,
552552
std::optional<glm::vec3> background_color_override,
553-
PreviewImageReadback readback);
553+
PreviewImageReadback readback,
554+
bool settle_capacity = false);
554555
[[nodiscard]] std::expected<void, std::string> renderPreviewImageToPreviewSlotWithState(
555556
SceneManager* scene_manager,
556557
const lfs::core::SplatData& model,
@@ -567,7 +568,8 @@ namespace lfs::vis {
567568
std::optional<bool> orthographic_override,
568569
std::optional<float> ortho_scale_override,
569570
std::optional<glm::vec3> background_color_override,
570-
std::optional<bool> transparent_background_override);
571+
std::optional<bool> transparent_background_override,
572+
bool settle_capacity = false);
571573
[[nodiscard]] std::expected<void, std::string> renderDepthCaptureToPreviewSlotWithState(
572574
SceneManager* scene_manager,
573575
const lfs::core::SplatData& model,

src/visualizer/rendering/rendering_manager_viewport.cpp

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ namespace lfs::vis {
2626
constexpr std::size_t kMaxNativePreviewPixelStateBytes =
2727
(std::size_t{4} << 30) - (std::size_t{64} << 20);
2828
constexpr float kMaxValidDepth = 1e9f;
29+
// Upper bound on the synchronous capacity self-heal passes a one-shot
30+
// preview/export capture will run before reading back (see
31+
// renderPreviewImageToPreviewSlotWithState). Typical convergence is
32+
// 2-4 passes; the cap only guards a pathological non-converging case.
33+
constexpr int kMaxPreviewSettlePasses = 8;
2934

3035
[[nodiscard]] std::optional<std::shared_lock<std::shared_mutex>> acquireLiveModelRenderLock(
3136
const SceneManager* const scene_manager) {
@@ -572,7 +577,8 @@ namespace lfs::vis {
572577
orthographic_override,
573578
ortho_scale_override,
574579
background_color_override,
575-
PreviewImageReadback::UInt8Rgb);
580+
PreviewImageReadback::UInt8Rgb,
581+
/*settle_capacity=*/true);
576582
}
577583

578584
std::shared_ptr<lfs::core::Tensor> RenderingManager::renderPreviewImageRgba8(SceneManager* const scene_manager,
@@ -624,7 +630,8 @@ namespace lfs::vis {
624630
orthographic_override,
625631
ortho_scale_override,
626632
std::nullopt,
627-
PreviewImageReadback::UInt8Rgba);
633+
PreviewImageReadback::UInt8Rgba,
634+
/*settle_capacity=*/true);
628635
}
629636

630637
std::shared_ptr<lfs::core::Tensor> RenderingManager::renderPreviewImage(const lfs::core::SplatData& model,
@@ -787,7 +794,8 @@ namespace lfs::vis {
787794
std::optional<bool> orthographic_override,
788795
std::optional<float> ortho_scale_override,
789796
std::optional<glm::vec3> background_color_override,
790-
const PreviewImageReadback readback) {
797+
const PreviewImageReadback readback,
798+
const bool settle_capacity) {
791799
const auto readback_config =
792800
previewImageReadbackConfig(readback, background_color_override.has_value());
793801

@@ -807,7 +815,8 @@ namespace lfs::vis {
807815
orthographic_override,
808816
ortho_scale_override,
809817
background_color_override,
810-
readback_config.transparent_background_override);
818+
readback_config.transparent_background_override,
819+
settle_capacity);
811820
if (!rendered) {
812821
LOG_ERROR("Gaussian preview image render failed: {}", rendered.error());
813822
return {};
@@ -852,7 +861,8 @@ namespace lfs::vis {
852861
std::optional<bool> orthographic_override,
853862
std::optional<float> ortho_scale_override,
854863
std::optional<glm::vec3> background_color_override,
855-
std::optional<bool> transparent_background_override) {
864+
std::optional<bool> transparent_background_override,
865+
const bool settle_capacity) {
856866
if (width <= 0 || height <= 0) {
857867
return std::unexpected("invalid preview render dimensions");
858868
}
@@ -922,15 +932,34 @@ namespace lfs::vis {
922932
vksplat_viewport_renderer_ = std::make_unique<VksplatViewportRenderer>();
923933
}
924934

925-
auto render_result = vksplat_viewport_renderer_->render(
926-
*last_vulkan_context_,
927-
model,
928-
request,
929-
false,
930-
VksplatViewportRenderer::OutputSlot::Preview,
931-
false);
932-
if (!render_result) {
933-
return std::unexpected(render_result.error());
935+
// One-shot preview/export captures read the image back immediately, so
936+
// they cannot rely on the interactive viewport's one-frame capacity
937+
// self-heal. The renderer sizes per-frame scratch (visible-primitive and
938+
// tile-instance capacity) from deferred, one-frame-late high-water marks;
939+
// the first render at a new viewpoint/resolution — e.g. a high-res export
940+
// after the live viewport established marks at a smaller size — can clamp
941+
// the depth/tile-ordered tail, dropping content along an irregular edge.
942+
// Re-render the Preview slot until the renderer confirms the previous
943+
// pass produced complete, unclamped content (each pass grows the marks
944+
// via the deferred readback). The pass >= 1 guard ensures the settle
945+
// signal reflects this exact view (critical for the tiled path, where
946+
// each tile is a different sub-view); max_passes bounds a pathological
947+
// case. Non-settling callers (e.g. depth capture) render exactly once.
948+
const int max_passes = settle_capacity ? kMaxPreviewSettlePasses : 1;
949+
for (int pass = 0; pass < max_passes; ++pass) {
950+
auto render_result = vksplat_viewport_renderer_->render(
951+
*last_vulkan_context_,
952+
model,
953+
request,
954+
false,
955+
VksplatViewportRenderer::OutputSlot::Preview,
956+
false);
957+
if (!render_result) {
958+
return std::unexpected(render_result.error());
959+
}
960+
if (pass >= 1 && vksplat_viewport_renderer_->previewCaptureSettled()) {
961+
break;
962+
}
934963
}
935964
return {};
936965
}
@@ -1000,7 +1029,8 @@ namespace lfs::vis {
10001029
orthographic_override,
10011030
ortho_scale_override,
10021031
background_color_override,
1003-
readback_config.transparent_background_override);
1032+
readback_config.transparent_background_override,
1033+
/*settle_capacity=*/true);
10041034
if (!rendered) {
10051035
LOG_TRACE("Gaussian preview tiled render failed at tile y={} height={}: {}",
10061036
tile_y,

src/visualizer/rendering/vksplat_viewport_renderer.cpp

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6069,7 +6069,12 @@ namespace lfs::vis {
60696069
if (auto ok = waitForRingSlot(ring_slot, "render"); !ok) {
60706070
return std::unexpected(ok.error());
60716071
}
6072+
// Track whether each deferred capacity readback produced fresh stats
6073+
// this frame; feeds the one-shot-capture settle signal computed below.
6074+
bool visibility_stats_polled = false;
6075+
bool instance_stats_polled = false;
60726076
if (const auto visibility_stats = renderer_.pollDeferredPrimitiveVisibilityStats()) {
6077+
visibility_stats_polled = true;
60736078
const double ratio = visibility_stats->num_splats == 0
60746079
? 0.0
60756080
: static_cast<double>(visibility_stats->visible_count) /
@@ -6097,6 +6102,7 @@ namespace lfs::vis {
60976102
}
60986103
}
60996104
if (const auto macro_stats = renderer_.pollDeferredMacroInstanceStats()) {
6105+
instance_stats_polled = true;
61006106
// One frame stale; drives the capacity high-water mark. A clamped
61016107
// frame (raw > clamped) grows the mark so the next frames render
61026108
// complete content.
@@ -6114,6 +6120,30 @@ namespace lfs::vis {
61146120
macro_stats->instance_count);
61156121
}
61166122
}
6123+
{
6124+
// Settle signal for one-shot preview/export captures. The interactive
6125+
// loop tolerates a capacity-clamped frame because it self-heals on the
6126+
// next frame; a single-shot capture reads back immediately, so it must
6127+
// not present a clamped (partial) frame. Mark "settled" only when the
6128+
// deferred readback of the previous render — which must have used the
6129+
// same steady-state chain the next pass will use, so a warm-up frame is
6130+
// rejected — reports complete, unclamped content.
6131+
const bool config_uses_macro =
6132+
!request.gut && renderer_.supportsFloat16Storage() &&
6133+
!synchronize_input_upload && !depth_capture_mode_;
6134+
const bool stats_complete =
6135+
visibility_stats_polled && (!config_uses_macro || instance_stats_polled);
6136+
const bool clamp_observed =
6137+
visible_clamp_pending_ || (config_uses_macro && instance_clamp_pending_);
6138+
// last_render_used_macro_chain_ still reflects the just-polled
6139+
// (previous) render here; it is updated for the current render only
6140+
// after rasterization. Requiring it to match the steady-state chain
6141+
// rejects the legacy warm-up frame as a convergence point.
6142+
const bool observed_matches_steady_state =
6143+
last_render_used_macro_chain_ == config_uses_macro;
6144+
last_preview_capture_settled_ =
6145+
stats_complete && !clamp_observed && observed_matches_steady_state;
6146+
}
61176147
if (const auto lod_stats = renderer_.pollDeferredLodSelectionStats()) {
61186148
gpu_lod_last_candidate_count_ = lod_stats->candidate_count;
61196149
gpu_lod_last_overflow_count_ = lod_stats->overflow_count;

src/visualizer/rendering/vksplat_viewport_renderer.hpp

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,13 @@ namespace lfs::vis {
214214
};
215215
[[nodiscard]] GpuLodSelectionStatus gpuLodSelectionStatus() const;
216216

217+
// True when the most recent render's start-of-frame deferred poll
218+
// confirmed the previously rendered frame produced complete, unclamped
219+
// content using the steady-state rasterizer chain. One-shot preview/
220+
// export captures poll this to avoid reading back a capacity-clamped
221+
// (partial) frame; see RenderingManager::renderPreviewImageToPreviewSlotWithState.
222+
[[nodiscard]] bool previewCaptureSettled() const { return last_preview_capture_settled_; }
223+
217224
private:
218225
struct ComposePipeline;
219226
struct InputBindingResult {
@@ -544,6 +551,13 @@ namespace lfs::vis {
544551
// keeps frames scheduled while idle so the capacity self-heal converges.
545552
bool visible_clamp_pending_ = false;
546553
bool instance_clamp_pending_ = false;
554+
// Set each render from the start-of-frame deferred poll: true once a
555+
// representative frame (one that used the same steady-state rasterizer
556+
// chain the next pass will use) is confirmed complete and unclamped.
557+
// Drives the synchronous capacity self-heal used by one-shot preview/
558+
// export captures, which cannot tolerate the interactive loop's
559+
// one-frame clamp transient.
560+
bool last_preview_capture_settled_ = false;
547561

548562
// Fallback CUDA-backed input buffers for models that are not already
549563
// backed by Vulkan-external tensor storage. Direct Vulkan-external

0 commit comments

Comments
 (0)