diff --git a/docs/superpowers/plans/poc-lod-shots/view-default.png b/docs/superpowers/plans/poc-lod-shots/view-default.png index 93de6f6..e1670db 100644 Binary files a/docs/superpowers/plans/poc-lod-shots/view-default.png and b/docs/superpowers/plans/poc-lod-shots/view-default.png differ diff --git a/src/render/source/AsyncRegionBuilder.cpp b/src/render/source/AsyncRegionBuilder.cpp index 2efc2f6..3aa0bed 100644 --- a/src/render/source/AsyncRegionBuilder.cpp +++ b/src/render/source/AsyncRegionBuilder.cpp @@ -42,6 +42,12 @@ vtkSmartPointer AsyncRegionBuilder::takeLatest() { return std::move(ready_); } +vtkSmartPointer AsyncRegionBuilder::takeLatest(int& outLevel) { + std::lock_guard lk(mutex_); + if (ready_) outLevel = readyLevel_; // 仅有新结果时回传 level + return std::move(ready_); +} + void AsyncRegionBuilder::workerLoop() { std::unique_lock lk(mutex_); for (;;) { @@ -72,8 +78,9 @@ void AsyncRegionBuilder::workerLoop() { } // publish:在锁内把所有权 move 进 ready_(旧 ready_ 若未取走在此处锁内释放)。 - // 所有 refcount 增减均在锁内/单线程独占完成。 + // 所有 refcount 增减均在锁内/单线程独占完成。随结果一同发布其 level。 ready_ = std::move(built); + readyLevel_ = target.level; building_ = false; } } diff --git a/src/render/source/AsyncRegionBuilder.hpp b/src/render/source/AsyncRegionBuilder.hpp index 5800afc..63468bc 100644 --- a/src/render/source/AsyncRegionBuilder.hpp +++ b/src/render/source/AsyncRegionBuilder.hpp @@ -44,6 +44,11 @@ class AsyncRegionBuilder { // 永不阻塞主线程:仅在锁内做指针移动。 vtkSmartPointer takeLatest(); + // 同上,并通过 outLevel 回传该就绪结果对应的 target.level(仅当返回非空时有效; + // 返回空时 outLevel 不变)。供调用方同步 UI 的 LOD 显示(C3-2 ViewAdaptive 用)。 + // 非破坏式重载:无参版行为不变。 + vtkSmartPointer takeLatest(int& outLevel); + // 是否有在建/排队(供 UI/测试)。 bool hasPending() const; @@ -63,6 +68,7 @@ class AsyncRegionBuilder { bool hasDesired_ = false; // 是否有未消费的期望 bool building_ = false; // worker 当前是否在建 vtkSmartPointer ready_; // 已就绪、待主线程取走的最新结果 + int readyLevel_ = 0; // ready_ 对应 target.level(随 ready_ 一同发布) std::atomic stop_{false}; // 析构置位,唤醒 worker 退出 int maxTextureDim_ = 16384; diff --git a/src/render/source/RegionReorganizer.cpp b/src/render/source/RegionReorganizer.cpp index 953ca8c..4f17c5f 100644 --- a/src/render/source/RegionReorganizer.cpp +++ b/src/render/source/RegionReorganizer.cpp @@ -10,16 +10,6 @@ namespace geopro::render { -namespace { - -// 该 level 某轴体素维度 = ceil(n / 2^level),至少 1(与 C1/store 同口径)。 -int dimAtLevel(int n, int level) { - const int d = (n + (1 << level) - 1) >> level; - return d > 0 ? d : 1; -} - -} // namespace - vtkSmartPointer reorganizeRegion( const geopro::data::ChunkedVolumeStore& store, const RegionTarget& target, int maxTextureDim) { @@ -28,9 +18,10 @@ vtkSmartPointer reorganizeRegion( const double exagg = target.exagg > 0 ? target.exagg : 1.0; const int brick = meta.brick; - const int dimLx = dimAtLevel(meta.nx, level); - const int dimLy = dimAtLevel(meta.ny, level); - const int dimLz = dimAtLevel(meta.nz, level); + // C2 MEDIUM:维度一律取自 store.dims(level)(单一真源),不自算 ceil(n/2^level), + // 防 store 降采样规则漂移时本侧公式失同步。store.dims 是金字塔实际落盘的权威维度。 + int dimLx = 0, dimLy = 0, dimLz = 0; + store.dims(level, dimLx, dimLy, dimLz); // 重组单纹理某轴范围(与 C1 hpp 契约逐字一致): // 起点 = b0*brick;终点 = min(b1*brick, dimL, 起点 + maxTextureDim)。 diff --git a/src/render/source/ViewAdaptiveVolumeSource.cpp b/src/render/source/ViewAdaptiveVolumeSource.cpp index 11260cd..0266d1e 100644 --- a/src/render/source/ViewAdaptiveVolumeSource.cpp +++ b/src/render/source/ViewAdaptiveVolumeSource.cpp @@ -1,14 +1,19 @@ #include "source/ViewAdaptiveVolumeSource.hpp" -#include +#include -#include "source/RegionReorganizer.hpp" +#include namespace geopro::render { ViewAdaptiveVolumeSource::ViewAdaptiveVolumeSource(const std::string& storeDir, double exagg) - : store_(storeDir), meta_(store_.meta()), exagg_(exagg > 0 ? exagg : 1.0) {} + : store_(storeDir), + meta_(store_.meta()), + exagg_(exagg > 0 ? exagg : 1.0), + builder_(storeDir) { // 后台 builder 用同一 storeDir(独立打开,线程独占) + builder_.setMaxTextureDim(maxTextureDim_); +} VolumeView ViewAdaptiveVolumeSource::volumeView() const { VolumeView v{}; @@ -29,10 +34,7 @@ VolumeView ViewAdaptiveVolumeSource::volumeView() const { } void ViewAdaptiveVolumeSource::update(vtkCamera* cam) { - if (cam == nullptr) { - current_ = nullptr; - return; - } + if (cam == nullptr) return; // 无相机:不提交目标(保留上一就绪结果) CameraView c{}; cam->GetPosition(c.pos); cam->GetFocalPoint(c.focal); @@ -47,12 +49,12 @@ void ViewAdaptiveVolumeSource::updateView(const CameraView& cam, const VolumeView& vol) { const LodSelection sel = selectLod(vol, cam, maxTextureDim_); if (sel.empty) { - current_ = nullptr; + // 空选区(体在视锥外):不提交目标,保留上一就绪结果(拖动出界时不闪空)。 return; } - lastLevel_ = sel.level; - // C2 重组改委托公共重组核 reorganizeRegion(DRY,C3 AsyncRegionBuilder 同源)。 + // C3-2:只【提交目标】给后台 builder,不在主线程重组(不阻塞)。维度/裁剪由 + // reorganizeRegion 内按 store.dims 处理;本侧只传 brick 区间 + level + exagg。 RegionTarget target{}; target.level = sel.level; target.bx0 = sel.bx0; @@ -62,13 +64,29 @@ void ViewAdaptiveVolumeSource::updateView(const CameraView& cam, target.bz0 = sel.bz0; target.bz1 = sel.bz1; target.exagg = exagg_; - current_ = reorganizeRegion(store_, target, maxTextureDim_); + builder_.requestTarget(target); // 与在建/已建相同则忽略;否则唤醒 worker 重建 +} + +void ViewAdaptiveVolumeSource::pullLatest() const { + // 非阻塞:取最新已就绪。有新结果则换上 current_ 并同步 lastLevel_;否则沿用上一张。 + int level = lastLevel_; + vtkSmartPointer latest = builder_.takeLatest(level); + if (latest) { + current_ = std::move(latest); + lastLevel_ = level; + } } std::vector> ViewAdaptiveVolumeSource::currentImages() const { + pullLatest(); if (current_ == nullptr) return {}; return {current_}; } +vtkImageData* ViewAdaptiveVolumeSource::sliceSource() const { + pullLatest(); + return current_.Get(); +} + } // namespace geopro::render diff --git a/src/render/source/ViewAdaptiveVolumeSource.hpp b/src/render/source/ViewAdaptiveVolumeSource.hpp index 794c73c..2896156 100644 --- a/src/render/source/ViewAdaptiveVolumeSource.hpp +++ b/src/render/source/ViewAdaptiveVolumeSource.hpp @@ -7,19 +7,24 @@ #include "data/store/ChunkedVolumeStore.hpp" #include "lod/ViewAdaptiveLodPolicy.hpp" +#include "source/AsyncRegionBuilder.hpp" #include "source/IVolumeRenderSource.hpp" class vtkCamera; namespace geopro::render { -// C2 实现:视野自适应单纹理体绘制数据源。 +// C2/C3-2 实现:视野自适应单纹理体绘制数据源(异步重组)。 // // 用 C1 selectLod(VolumeView,CameraView,maxTextureDim) 选 LOD level + 视野内 brick // 区间,从 ChunkedVolumeStore 把【当前视野区域】重组为【单张 VTK_SHORT // vtkImageData】(各轴 ≤maxTextureDim,由 C1 硬约束保证),带世界 origin/spacing // (按 level + 垂向夸张 exagg)。 // +// C3-2 异步集成:updateView 只【提交目标】给内含的 AsyncRegionBuilder +//(不阻塞、不在主线程重组),currentImages 取【最新已就绪】结果(没就绪就用上一 +// 张)。这样拖动/缩放时主线程不被解压+重组卡住——后台备好新纹理后下一帧自然换上。 +// // 与 B(WholeVolumeSource 整卷单图)/旧 C(OutOfCoreSource MultiBlock 多块)的区别: // - 远观 → C1 选粗层、区间≈全体 → 重组整卷粗纹理(一张); // - 近观 → C1 选细层、区间为视锥内小块 → 重组视野子体(一张); @@ -30,6 +35,12 @@ namespace geopro::render { //(构造 vtkImageData 无需渲染管线)→ headless 可测。update(vtkCamera*) 仅把相机 // 参数填成 CameraView 再调 updateView。viewportH/aspect 经 setter 注入(vtkCamera // 不自带视口像素高/宽高比)。 +// +// 线程契约:本类的【公共方法只由主/渲染线程调用】(VTK 渲染循环单线程)。唯一的跨 +// 线程边界在内含的 AsyncRegionBuilder:worker 线程独占 builder 自己的 store 实例做 +// 重组(与本类 store_ 是不同实例),主线程经 builder 的 mutex 保护的 takeLatest 取 +// 结果——vtkImageData 的 refcount 增减全发生在锁内或主线程单线程,无跨线程竞争。 +// current_/lastLevel_ 为 mutable,仅由主线程在 currentImages/sliceSource 内更新。 class ViewAdaptiveVolumeSource : public IVolumeRenderSource { public: // storeDir:含金字塔的分块 store。exagg:垂向夸张(烘焙进 y/z 的 spacing/origin)。 @@ -42,14 +53,17 @@ class ViewAdaptiveVolumeSource : public IVolumeRenderSource { // GL_MAX_3D_TEXTURE_SIZE 上限走 maxTextureDim_(默认 16384)。 void update(vtkCamera* cam) override; - // 可测缝:纯数值核——选层选区 + 重组单图。headless 可测。 + // 可测缝:选层选区 → 把目标【提交】给后台 builder(不阻塞、不在主线程重组)。 + // headless 可测。空选区 → 不提交(保留上一就绪结果)。 void updateView(const CameraView& cam, const VolumeView& vol); - // 当前视野区域单图(empty → 空 vector,场景不渲)。 + // 当前视野区域单图(取最新已就绪:先 builder.takeLatest(),有新结果则换上并更新 + // lastLevel_,否则沿用上一张;从未就绪 → 空 vector)。 + // 非阻塞:仅在 builder 锁内做指针移动。current_/lastLevel_ 为 mutable(懒取最新)。 std::vector> currentImages() const override; - // reslice 源 = 当前单图(empty → nullptr)。 - vtkImageData* sliceSource() const override { return current_.Get(); } + // reslice 源 = 当前最新就绪单图(empty → nullptr)。先拉一次最新就绪再返回。 + vtkImageData* sliceSource() const override; // 供 UI 显示当前 LOD level。 int lastLevel() const { return lastLevel_; } @@ -62,13 +76,23 @@ class ViewAdaptiveVolumeSource : public IVolumeRenderSource { void setViewportHeight(int h) { viewportH_ = h > 0 ? h : viewportH_; } void setAspect(double aspect) { aspect_ = aspect > 0 ? aspect : aspect_; } - // 单张 3D 纹理各轴上限(GL_MAX_3D_TEXTURE_SIZE)。 - void setMaxTextureDim(int dim) { maxTextureDim_ = dim > 0 ? dim : maxTextureDim_; } + // 单张 3D 纹理各轴上限(GL_MAX_3D_TEXTURE_SIZE)。同步给后台 builder(重组用同 + // 一上限)。须在 updateView 前设。 + void setMaxTextureDim(int dim) { + if (dim > 0) { + maxTextureDim_ = dim; + builder_.setMaxTextureDim(dim); + } + } private: // 由 meta + exagg 填 VolumeView(spacing 已含 exagg 于 y/z)。 VolumeView volumeView() const; + // 从 builder 拉一次最新就绪结果:有新结果则更新 current_/lastLevel_。 + // const(仅刷新 mutable 缓存),供 currentImages/sliceSource 共用(DRY)。 + void pullLatest() const; + geopro::data::ChunkedVolumeStore store_; geopro::data::StoreMeta meta_; double exagg_ = 1.0; @@ -76,8 +100,13 @@ class ViewAdaptiveVolumeSource : public IVolumeRenderSource { int viewportH_ = 1080; double aspect_ = 1280.0 / 800.0; - vtkSmartPointer current_; // 当前视野区域单图(empty 时为空指针) - int lastLevel_ = 0; + // 后台重组器:updateView 提交目标,currentImages/sliceSource 非阻塞取最新就绪。 + // 须在 store_ 之后、current_ 之前声明(构造顺序无依赖,但语义上属重组核心)。 + mutable AsyncRegionBuilder builder_; + + // 最新已就绪单图 + 其 level(mutable:currentImages/sliceSource const 内懒取最新)。 + mutable vtkSmartPointer current_; // 空指针 = 从未就绪 + mutable int lastLevel_ = 0; }; } // namespace geopro::render diff --git a/tests/render/test_view_adaptive_source.cpp b/tests/render/test_view_adaptive_source.cpp index c6cb412..8adefad 100644 --- a/tests/render/test_view_adaptive_source.cpp +++ b/tests/render/test_view_adaptive_source.cpp @@ -1,23 +1,31 @@ -// ViewAdaptiveVolumeSource(C2) headless 测试:用 C1 selectLod 选层选区,从分块 -// 存储重组当前视野区域为【单张 VTK_SHORT vtkImageData】(各轴 ≤16384,世界 +// ViewAdaptiveVolumeSource(C2→C3-2) headless 测试:用 C1 selectLod 选层选区,把 +// 当前视野区域【异步】重组为【单张 VTK_SHORT vtkImageData】(各轴 ≤16384,世界 // origin/spacing 按 level+exagg)。核心 updateView(CameraView,VolumeView) 不需真 // vtkCamera/GL 上下文——构造 vtkImageData 不需渲染管线。 // +// C3-2 异步集成:updateView 只提交目标(不阻塞、不在主线程重组),currentImages +// 取最新已就绪(没就绪用上一张)。测试需在 updateView 后【轮询 currentImages +// 直到非空(带超时)】再断言内容。 +// // 验:远观粗层 / 近观细层 / 各轴 ≤16384 / VTK_SHORT / 重组体素与 store 对应 -// level+区间位置一致(不错位)/ empty 情形空。 +// level+区间位置一致(不错位)/ empty 情形空 / updateView 不阻塞 / +// 维度取自 store.dims(单一真源,奇数维一致)/ 最终就绪内容 == reorganizeRegion。 #include "render/source/ViewAdaptiveVolumeSource.hpp" #include "core/algo/GprVolumeBuilder.hpp" #include "data/store/ChunkedVolumeStore.hpp" #include "lod/ViewAdaptiveLodPolicy.hpp" +#include "render/source/RegionReorganizer.hpp" #include #include #include +#include #include #include +#include #include using namespace geopro; @@ -92,9 +100,21 @@ CameraView lookFromX(const data::StoreMeta& m, double dist, double fovYDeg = 30. return c; } +// 异步轮询:updateView 后 currentImages 可能还没就绪——轮询直到非空(带超时)。 +// 返回首张就绪 image(超时仍空 → 返回 nullptr,由调用方 ASSERT)。 +vtkSmartPointer pollReady(render::ViewAdaptiveVolumeSource& src, + int maxTries = 2000, int sleepMs = 2) { + for (int i = 0; i < maxTries; ++i) { + auto imgs = src.currentImages(); + if (!imgs.empty() && imgs[0] != nullptr) return imgs[0]; + std::this_thread::sleep_for(std::chrono::milliseconds(sleepMs)); + } + return nullptr; +} + } // namespace -// ── 远观:选粗层、单图、各轴 ≤16384、VTK_SHORT ─────────────────────────────── +// ── 远观:选粗层、单图、各轴 ≤16384、VTK_SHORT(异步轮询就绪后断言)───────── TEST(ViewAdaptiveVolumeSource, FarViewCoarseLevelSingleTexture) { const auto dir = (std::filesystem::temp_directory_path() / "gpr_va_far").string(); @@ -108,12 +128,14 @@ TEST(ViewAdaptiveVolumeSource, FarViewCoarseLevelSingleTexture) { const CameraView far = lookFromX(src.meta(), 8000.0); src.updateView(far, vol); + auto img = pollReady(src); + ASSERT_NE(img.Get(), nullptr); + // 就绪后 currentImages 仍是单张。 auto imgs = src.currentImages(); ASSERT_EQ(imgs.size(), 1u); - ASSERT_NE(imgs[0].Get(), nullptr); - EXPECT_EQ(imgs[0]->GetScalarType(), VTK_SHORT); + EXPECT_EQ(img->GetScalarType(), VTK_SHORT); int d[3]; - imgs[0]->GetDimensions(d); + img->GetDimensions(d); EXPECT_LE(d[0], 16384); EXPECT_LE(d[1], 16384); EXPECT_LE(d[2], 16384); @@ -134,11 +156,11 @@ TEST(ViewAdaptiveVolumeSource, NearViewFineLevel) { const CameraView near = lookFromX(src.meta(), 8.0, 20.0, 1080); src.updateView(near, vol); - auto imgs = src.currentImages(); - ASSERT_EQ(imgs.size(), 1u); + auto img = pollReady(src); + ASSERT_NE(img.Get(), nullptr); EXPECT_EQ(src.lastLevel(), 0); // 近 → 最细 int d[3]; - imgs[0]->GetDimensions(d); + img->GetDimensions(d); EXPECT_LE(d[0], 16384); EXPECT_LE(d[1], 16384); EXPECT_LE(d[2], 16384); @@ -157,9 +179,9 @@ TEST(ViewAdaptiveVolumeSource, ReconstructedVoxelsMatchStore) { const CameraView far = lookFromX(src.meta(), 8000.0); src.updateView(far, vol); - auto imgs = src.currentImages(); - ASSERT_EQ(imgs.size(), 1u); - vtkImageData* img = imgs[0]; + auto pimg = pollReady(src); + ASSERT_NE(pimg.Get(), nullptr); + vtkImageData* img = pimg.Get(); const int level = src.lastLevel(); data::ChunkedVolumeStore store(dir); @@ -241,14 +263,14 @@ TEST(ViewAdaptiveVolumeSource, WorldOriginSpacingWithExagg) { const CameraView far = lookFromX(src.meta(), 8000.0); src.updateView(far, vol); - auto imgs = src.currentImages(); - ASSERT_EQ(imgs.size(), 1u); + auto img = pollReady(src); + ASSERT_NE(img.Get(), nullptr); const data::StoreMeta& m = src.meta(); const int level = src.lastLevel(); const double sc = static_cast(1 << level); double sp[3], org[3]; - imgs[0]->GetSpacing(sp); - imgs[0]->GetOrigin(org); + img->GetSpacing(sp); + img->GetOrigin(org); // spacing:x 不夸张,y/z ×exagg。 EXPECT_DOUBLE_EQ(sp[0], m.spacing[0] * sc); EXPECT_DOUBLE_EQ(sp[1], m.spacing[1] * sc * exagg); @@ -259,7 +281,7 @@ TEST(ViewAdaptiveVolumeSource, WorldOriginSpacingWithExagg) { EXPECT_DOUBLE_EQ(org[2], m.origin[2]); } -// ── empty:体完全在视锥外 → currentImages 空 ─────────────────────────────── +// ── empty:体完全在视锥外 → currentImages 空(从未就绪)────────────────────── TEST(ViewAdaptiveVolumeSource, EmptyWhenBehindCamera) { const auto dir = (std::filesystem::temp_directory_path() / "gpr_va_empty").string(); @@ -271,7 +293,11 @@ TEST(ViewAdaptiveVolumeSource, EmptyWhenBehindCamera) { c.focal[0] = c.pos[0] + 1000.0; // 视线朝 +X,体在身后 → 视锥外 src.updateView(c, vol); - EXPECT_TRUE(src.currentImages().empty()); + // empty 选区 → 不提交目标;从未就绪 → currentImages 恒空(短轮询确认无意外结果)。 + for (int i = 0; i < 20; ++i) { + EXPECT_TRUE(src.currentImages().empty()); + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } EXPECT_EQ(src.sliceSource(), nullptr); } @@ -288,8 +314,125 @@ TEST(ViewAdaptiveVolumeSource, SingleLevelStore) { const CameraView c = lookFromX(src.meta(), 500.0); src.updateView(c, vol); - auto imgs = src.currentImages(); - ASSERT_EQ(imgs.size(), 1u); + auto img = pollReady(src); + ASSERT_NE(img.Get(), nullptr); EXPECT_EQ(src.lastLevel(), 0); - EXPECT_EQ(imgs[0]->GetScalarType(), VTK_SHORT); + EXPECT_EQ(img->GetScalarType(), VTK_SHORT); +} + +// ── C3-2 异步:updateView 立即返回 + 最终就绪内容 == reorganizeRegion 同步结果 ── +TEST(ViewAdaptiveVolumeSource, AsyncUpdateEventuallyReady) { + const auto dir = + (std::filesystem::temp_directory_path() / "gpr_va_async").string(); + makePyramidStore(dir, 200, 80, 60, 1, 2, 3, 0.5, 0.5, 0.2, 64, 3); + + render::ViewAdaptiveVolumeSource src(dir, 1.0); + const VolumeView vol = volumeViewOf(src.meta(), src.levelCount(), 1.0); + const CameraView far = lookFromX(src.meta(), 8000.0); + src.updateView(far, vol); // 立即返回(不阻塞、不在主线程重组) + + auto img = pollReady(src); + ASSERT_NE(img.Get(), nullptr); + int d[3]; + img->GetDimensions(d); + EXPECT_LE(d[0], 16384); + EXPECT_LE(d[1], 16384); + EXPECT_LE(d[2], 16384); + EXPECT_EQ(img->GetScalarType(), VTK_SHORT); + + // 最终就绪内容 == 同步 reorganizeRegion(同 target) 结果(位置/几何一致)。 + // 用 selectLod 复算同一 target,与异步源选的同 level/区间。 + const render::LodSelection sel = render::selectLod(vol, far, 16384); + ASSERT_FALSE(sel.empty); + EXPECT_EQ(sel.level, src.lastLevel()); + render::RegionTarget t{}; + t.level = sel.level; + t.bx0 = sel.bx0; t.bx1 = sel.bx1; + t.by0 = sel.by0; t.by1 = sel.by1; + t.bz0 = sel.bz0; t.bz1 = sel.bz1; + t.exagg = 1.0; + data::ChunkedVolumeStore store(dir); + auto sync = render::reorganizeRegion(store, t, 16384); + ASSERT_NE(sync.Get(), nullptr); + + int ds[3]; + sync->GetDimensions(ds); + EXPECT_EQ(d[0], ds[0]); + EXPECT_EQ(d[1], ds[1]); + EXPECT_EQ(d[2], ds[2]); + double oa[3], os[3], sa[3], ss[3]; + img->GetOrigin(oa); + img->GetSpacing(sa); + sync->GetOrigin(os); + sync->GetSpacing(ss); + for (int i = 0; i < 3; ++i) { + EXPECT_DOUBLE_EQ(oa[i], os[i]); + EXPECT_DOUBLE_EQ(sa[i], ss[i]); + } + auto* aAsync = vtkShortArray::SafeDownCast(img->GetPointData()->GetScalars()); + auto* aSync = vtkShortArray::SafeDownCast(sync->GetPointData()->GetScalars()); + ASSERT_NE(aAsync, nullptr); + ASSERT_NE(aSync, nullptr); + ASSERT_EQ(aAsync->GetNumberOfTuples(), aSync->GetNumberOfTuples()); + // 抽查若干体素逐一相等(含端点/中点)。 + const vtkIdType n = aAsync->GetNumberOfTuples(); + for (vtkIdType id : {vtkIdType(0), n / 3, n / 2, n - 1}) { + if (id < 0 || id >= n) continue; + EXPECT_EQ(aAsync->GetValue(id), aSync->GetValue(id)) << "id=" << id; + } +} + +// ── C3-2 非阻塞:updateView 本身耗时极短(不含重组)即便较大 store ───────────── +TEST(ViewAdaptiveVolumeSource, UpdateDoesNotBlock) { + const auto dir = + (std::filesystem::temp_directory_path() / "gpr_va_noblock").string(); + // 较大 store:同步重组该粗层整卷会有明显耗时;updateView 只提交目标应近 0。 + makePyramidStore(dir, 512, 256, 192, 0, 0, 0, 0.5, 0.5, 0.2, 64, 3); + + render::ViewAdaptiveVolumeSource src(dir, 1.0); + const VolumeView vol = volumeViewOf(src.meta(), src.levelCount(), 1.0); + const CameraView far = lookFromX(src.meta(), 20000.0); + + const auto t0 = std::chrono::steady_clock::now(); + src.updateView(far, vol); // 应立即返回(仅 selectLod + requestTarget) + const auto t1 = std::chrono::steady_clock::now(); + const double ms = + std::chrono::duration(t1 - t0).count(); + EXPECT_LT(ms, 50.0) << "updateView 阻塞了?耗时 " << ms << "ms"; + + // 仍能最终就绪(后台重组完成)。 + auto img = pollReady(src, 5000, 2); + ASSERT_NE(img.Get(), nullptr); +} + +// ── C3-2 维度取自 store.dims(单一真源):奇数维 store 验重组 dims == store.dims ── +// 全卷请求某粗 level,重组单图各轴 == store.dims(level)(而非自算公式)。奇数维 +// 多级降采样下,store.dims 是唯一权威;本测试钉死「重组维度跟随 store.dims」。 +TEST(ViewAdaptiveVolumeSource, UsesStoreDimsNotSelfComputed) { + const auto dir = + (std::filesystem::temp_directory_path() / "gpr_va_storedims").string(); + // 奇数维度(含多次 ceil 降采样):199×83×61,3 级金字塔。 + makePyramidStore(dir, 199, 83, 61, 0, 0, 0, 1.0, 1.0, 1.0, 64, 3); + + render::ViewAdaptiveVolumeSource src(dir, 1.0); + const VolumeView vol = volumeViewOf(src.meta(), src.levelCount(), 1.0); + // 极远 → 选最粗层、区间≈全卷,重组整卷该层 → 各轴应正好 == store.dims(level)。 + const CameraView far = lookFromX(src.meta(), 1.0e7); + src.updateView(far, vol); + + auto img = pollReady(src); + ASSERT_NE(img.Get(), nullptr); + const int level = src.lastLevel(); + ASSERT_GT(level, 0); // 极远 → 粗层(多级降采样,奇数维有意义) + + int sdx = 0, sdy = 0, sdz = 0; + data::ChunkedVolumeStore store(dir); + store.dims(level, sdx, sdy, sdz); + + int d[3]; + img->GetDimensions(d); + // 全卷区间(各轴 ≤16384,store.dims 远小于此)→ 重组维度恒 == store.dims。 + EXPECT_EQ(d[0], sdx) << "x 维度未跟随 store.dims"; + EXPECT_EQ(d[1], sdy) << "y 维度未跟随 store.dims"; + EXPECT_EQ(d[2], sdz) << "z 维度未跟随 store.dims"; } diff --git a/tools/gpr_poc/main.cpp b/tools/gpr_poc/main.cpp index 44cfbc1..11800a3 100644 --- a/tools/gpr_poc/main.cpp +++ b/tools/gpr_poc/main.cpp @@ -12,6 +12,7 @@ // gpr_poc renderB [--frames 120] —— 离屏体绘制/切片 fps 基准 #include +#include #include #include #include @@ -20,6 +21,7 @@ #include #include #include +#include #include #include "Probe.hpp" @@ -2792,22 +2794,41 @@ struct ViewState { bool inCb = false; }; -// 单纹理刷新:source->update(cam) 选 LOD + 重组当前视野区域单图,喂单 -// SmartVolumeMapper。返回喂入的块数(恒为 1 单纹理;视锥外 → 0 不渲)。 -// 同步刷新 st->lastLevel(fps 文本用)。 -std::size_t viewRefreshSingle(ViewState* st) { - st->source->update(st->cam); - st->lastLevel = st->source->lastLevel(); - auto imgs = st->source->currentImages(); - if (imgs.empty() || imgs[0] == nullptr) { - return 0; // 视锥外:不更新输入(保留上一帧),由调用方决定是否渲。 - } +// C3-2 非阻塞拉取:把最新已就绪单图喂 mapper(若有新结果)。不阻塞主线程—— +// 后台 builder 没新结果就沿用上一帧(拖动跟手的关键)。返回 1=喂了新图,0=无变化。 +std::size_t viewPickLatest(ViewState* st) { + auto imgs = st->source->currentImages(); // 内部 takeLatest(非阻塞) + if (imgs.empty() || imgs[0] == nullptr) return 0; // 无新结果:保留上一帧 + if (imgs[0] == st->currentImg) return 0; // 同一张:无需重喂 st->currentImg = imgs[0]; + st->lastLevel = st->source->lastLevel(); st->mapper->SetInputData(st->currentImg); st->mapper->Update(); return 1; } +// 单纹理刷新(C3-2 异步):source->update(cam) 只【提交目标】(非阻塞),随后非阻塞 +// 拉一次最新就绪。拖动中主线程不被重组卡住——新纹理由后台备好、下一帧/定时器换上。 +// 返回当前喂入的块数(1=有就绪单图,0=尚无就绪/视锥外)。 +std::size_t viewRefreshSingle(ViewState* st) { + st->source->update(st->cam); // 提交目标,立即返回 + viewPickLatest(st); + return st->currentImg != nullptr ? 1 : 0; +} + +// 阻塞式刷新:提交目标后【轮询到就绪】再返回(带超时)。仅用于 preview/smoke/默认 +// 取景这类「需要保证拿到一张图」的离屏/初始化场景——交互路径绝不用此(会卡主线程)。 +std::size_t viewRefreshBlocking(ViewState* st, int maxTries = 3000, + int sleepMs = 2) { + st->source->update(st->cam); // 提交目标 + for (int i = 0; i < maxTries; ++i) { + if (viewPickLatest(st)) return 1; + if (st->currentImg != nullptr) return 1; // 已有上一就绪且无新结果 + std::this_thread::sleep_for(std::chrono::milliseconds(sleepMs)); + } + return st->currentImg != nullptr ? 1 : 0; +} + // interactor 回调:每次交互(旋转/缩放)结束后重选 LOD + 刷新 fps 文本。 // // fps 修复(Task 12d-fix3):之前用 frameTimer(上次回调到本次的墙钟)算 fps,把 @@ -2842,6 +2863,25 @@ void viewOnInteract(vtkObject*, unsigned long, void* clientData, void*) { st->inCb = false; } +// C3-2 拖动跟手核心:交互进行中(旋转/缩放每次相机变化)只【提交目标】(非阻塞), +// 绝不在主线程重组——主线程立刻继续响应输入,画面用上一张已就绪纹理(跟手)。 +void viewOnInteracting(vtkObject*, unsigned long, void* clientData, void*) { + auto* st = static_cast(clientData); + st->source->update(st->cam); // 提交最新视野目标,立即返回(supersede 旧目标) +} + +// C3-2 定时器:周期性非阻塞拉取后台已就绪的新纹理换上 → 拖动中/松手后新 LOD 备好 +// 即自然显示,主线程从不被重组卡住。无新结果则什么也不做(不重渲、不抖)。 +void viewOnTimer(vtkObject* caller, unsigned long, void* clientData, void*) { + auto* st = static_cast(clientData); + if (st->inCb) return; // 与 fps 探针回调互斥,避免重入 + if (viewPickLatest(st)) { + st->ren->ResetCameraClippingRange(); + st->rw->Render(); // 仅在确有新纹理时重渲 + } + (void)caller; +} + // 默认取景宽度:沿测线取约 256 道(=4 brick 列×64)的一段作首帧局部段。整线横截面 // 相对长度 1:34,框整卷只会看到一条隐形细带;框这个局部段,层状结构才充满视野 // (用户可再滚轮拉远看整体——细带是物理真实,拉近看细节)。段越宽 X 越细长、截面 @@ -2891,8 +2931,8 @@ std::size_t viewSetupDefaultFrame(ViewState* st, vtkRenderer* ren) { st->cam->SetViewUp(0, 0, 1); ren->ResetCameraClippingRange(); - // 源选层选区 + 重组单图喂 mapper。 - const std::size_t blocks = viewRefreshSingle(st); + // 源选层选区 + 重组单图喂 mapper。初始化场景需保证拿到首图 → 阻塞轮询到就绪。 + const std::size_t blocks = viewRefreshBlocking(st); // 框住局部段:用无参 ResetCamera(按 actor 的【已 SetScale(1,exagg,exagg)】缩放 // 后包围盒框),相机角度沿用能看出结构的 Elevation/Azimuth,再 Zoom 拉近填满画面。 @@ -3162,12 +3202,12 @@ int cmdView(int argc, char** argv) { std::size_t warm = viewSetupDefaultFrame(&st, ren); rw->Render(); - // 拉近预览:在默认取景基础上拉近相机,再走 viewRefreshSingle(与真窗口缩放后 - // 完全相同的单纹理路径,选 level0 局部子区域),验证「拉近后」单图非空、完整。 + // 拉近预览:在默认取景基础上拉近相机,再走阻塞刷新(与真窗口缩放后完全相同的 + // 单纹理选区路径,level0 局部子区域),轮询到就绪验证「拉近后」单图非空、完整。 if (nearPreview) { st.cam->Dolly(2.5); // 拉近 ren->ResetCameraClippingRange(); - warm = viewRefreshSingle(&st); + warm = viewRefreshBlocking(&st); rw->Render(); } @@ -3250,12 +3290,12 @@ int cmdView(int argc, char** argv) { const int lvlNear = st.lastLevel; st.cam->Dolly(0.02); // 大幅拉远 → 期望切到粗 LOD(整卷粗层单纹理) ren->ResetCameraClippingRange(); - const std::size_t blocksFar = viewRefreshSingle(&st); + const std::size_t blocksFar = viewRefreshBlocking(&st); const int lvlFar = st.lastLevel; rw->Render(); st.cam->Dolly(50.0); // 拉近回来 → 期望切回细 LOD(level0 局部子区域) ren->ResetCameraClippingRange(); - viewRefreshSingle(&st); + viewRefreshBlocking(&st); const int lvlNear2 = st.lastLevel; rw->Render(); const vtkIdType nb2 = countNonBlackPixels(rw.Get(), winW, winH); @@ -3290,13 +3330,26 @@ int cmdView(int argc, char** argv) { vtkNew cb; cb->SetCallback(viewOnInteract); cb->SetClientData(&st); - // EndInteraction:旋转/缩放松手后重选 LOD + 刷 fps(仅松手触发一次,不自激)。 + // EndInteraction:旋转/缩放松手后提交新目标 + 刷 fps(仅松手触发一次,不自激)。 // 注意:绝不可在 rw 的 EndEvent 上注册——回调内部 Render() 会再触发 EndEvent // 形成无限递归重渲(窗口卡死、fps≈0)。fps 文本在松手时刷新即可。 iren->AddObserver(vtkCommand::EndInteractionEvent, cb); + // C3-2:拖动进行中持续提交目标(非阻塞),主线程不被重组卡住 → 跟手。 + vtkNew cbInteract; + cbInteract->SetCallback(viewOnInteracting); + cbInteract->SetClientData(&st); + iren->AddObserver(vtkCommand::InteractionEvent, cbInteract); + + // C3-2:周期定时器非阻塞拉取后台已就绪纹理换上(新 LOD 备好即显示,拖动不卡)。 + vtkNew cbTimer; + cbTimer->SetCallback(viewOnTimer); + cbTimer->SetClientData(&st); + iren->AddObserver(vtkCommand::TimerEvent, cbTimer); + std::cout << "[view] 打开真窗口。左键旋转 / 滚轮缩放(切 LOD) / q 退出。\n"; iren->Initialize(); + iren->CreateRepeatingTimer(33); // ~30Hz 拉取后台就绪纹理(不阻塞主线程) rw->Render(); iren->Start();