diff --git a/docs/superpowers/plans/poc-lod-shots/view-default.png b/docs/superpowers/plans/poc-lod-shots/view-default.png new file mode 100644 index 0000000..3b97ae1 Binary files /dev/null and b/docs/superpowers/plans/poc-lod-shots/view-default.png differ diff --git a/tools/gpr_poc/main.cpp b/tools/gpr_poc/main.cpp index d0db103..08ba397 100644 --- a/tools/gpr_poc/main.cpp +++ b/tools/gpr_poc/main.cpp @@ -2224,7 +2224,6 @@ struct ViewState { vtkCamera* cam = nullptr; vtkTextActor* fpsText = nullptr; vtkRenderWindow* rw = nullptr; - Stopwatch frameTimer; double exagg = 8.0; int lastLevel = -1; // 整卷粗层 image 缓存(按 level 缓存,避免每帧重组整卷)。 @@ -2296,16 +2295,26 @@ std::size_t viewRefreshBlocks(ViewState* st) { } // interactor 回调:每次交互(旋转/缩放)结束后重选 LOD + 刷新 fps 文本。 +// +// fps 修复(Task 12d-fix3):之前用 frameTimer(上次回调到本次的墙钟)算 fps,把 +// 用户思考/不动的空闲时间也算进去,显示的是「空闲间隔」(如 0.2fps),不可信。改为 +// 松手时连渲 kFpsProbeFrames 帧、累计「实际 Render 耗时」取均值,得到真实渲染帧率。 void viewOnInteract(vtkObject*, unsigned long, void* clientData, void*) { auto* st = static_cast(clientData); // 防重入:本回调内部会 st->rw->Render(),若该 Render 再触发观察者进本回调 // 将无限递归。已在回调中则直接返回(双保险)。 if (st->inCb) return; st->inCb = true; - const double frameMs = st->frameTimer.elapsedMs(); const std::size_t blocks = viewRefreshBlocks(st); const int lvl = st->src->lastLevel(); - const double fps = frameMs > 0 ? 1000.0 / frameMs : 0.0; + + // 真实渲染帧率:连渲若干帧,只累计 Render() 本身耗时(不含空闲)。首帧含切换后 + // 的纹理上传/shader 编译,故跑 kFpsProbeFrames 帧取均值更可信。 + constexpr int kFpsProbeFrames = 3; + Stopwatch swR; + for (int i = 0; i < kFpsProbeFrames; ++i) st->rw->Render(); + const double renderMs = swR.elapsedMs() / kFpsProbeFrames; + const double fps = renderMs > 0 ? 1000.0 / renderMs : 0.0; char buf[256]; std::snprintf(buf, sizeof(buf), @@ -2313,16 +2322,58 @@ void viewOnInteract(vtkObject*, unsigned long, void* clientData, void*) { fps, lvl, blocks, st->exagg); st->fpsText->SetInput(buf); st->lastLevel = lvl; - st->rw->Render(); - st->frameTimer.reset(); + st->rw->Render(); // 末帧带上更新后的 fps 文本 st->inCb = false; } +// 默认取景宽度:沿测线取约 256 道(=4 brick 列×64)的一段作首帧局部段。整线横截面 +// 相对长度 1:34,框整卷只会看到一条隐形细带;框这个局部段,层状结构才充满视野 +// (用户可再滚轮拉远看整体——细带是物理真实,拉近看细节)。段越宽 X 越细长、截面 +// 越填不满画面;256 道是 ① cmdTune 出 lod-tuned-local.png(有清晰层状结构)的取景, +// 沿用之以保证首帧同等可读。 +constexpr int kViewDefaultLocalBricks = 4; +// ResetCamera 后再 Zoom 拉近填满画面(同 ① cmdTune 的 1.7×:默认框得偏松,留白多)。 +constexpr double kViewDefaultZoom = 1.7; + +// 建立 view 的「默认取景」:把 level0 一段局部体(沿线中段)整卷单块喂 mapper,再 +// ResetCamera 到该局部段(actor 已 SetScale(1,exagg,exagg)),置相机为能看出层状 +// 结构的角度。真窗口 / --smoke / --preview 三条路径共用此函数 → 渲的是同一画面。 +// +// 返回喂给 mapper 的块数(=1)。同步更新 st->lastLevel=0(默认即全分辨率局部段)。 +std::size_t viewSetupDefaultFrame(ViewState* st, vtkRenderer* ren) { + geopro::data::ChunkedVolumeStore& store = *st->store; + const geopro::data::StoreMeta& m = store.meta(); + const int totBx = store.bricksX(0); + const int localBx = std::min(kViewDefaultLocalBricks, totBx); + const int bx0 = std::max(0, totBx / 2 - localBx / 2); // 沿线中段 + vtkSmartPointer locImg = + buildLocalLevel0Image(store, m, bx0, localBx); + + std::vector> one{locImg}; + auto mb = makeMultiBlock(one); + st->mapper->SetInputDataObject(mb); + st->mapper->Update(); + st->cachedWholeImg = locImg; // 持有引用,避免被释放 + st->cachedWholeLevel = 0; + st->lastLevel = 0; + + // 框住局部段:用无参 ResetCamera(按 actor 的【已 SetScale(1,exagg,exagg)】缩放 + // 后包围盒框,把 exagg 后的 Y/Z 一并纳入;mapper->GetBounds() 是未缩放的,不可用), + // 相机角度沿用能看出结构的 Elevation/Azimuth,再 Zoom 拉近填满画面。 + ren->ResetCamera(); + st->cam = ren->GetActiveCamera(); + st->cam->Elevation(28.0); // 同 ① cmdTune 的取景角度 + st->cam->Azimuth(30.0); + st->cam->Zoom(kViewDefaultZoom); // 拉近填满画面,避免局部段缩在角落 + ren->ResetCameraClippingRange(); + return one.size(); +} + int cmdView(int argc, char** argv) { const Args a = parseArgs(argc, argv, 2); if (a.positional.empty()) { std::cerr << "用法: gpr_poc view [--exagg 8] [--opacity 0.5] " - "[--budget 64] [--smoke]\n"; + "[--budget 64] [--smoke] [--preview] [--frames 90]\n"; return 2; } const std::string dir = a.positional[0]; @@ -2330,13 +2381,22 @@ int cmdView(int argc, char** argv) { const double opacity = std::stod(a.get("opacity", "0.5")); const std::size_t budget = static_cast(std::stoul(a.get("budget", "64"))); - const bool smoke = a.kv.count("smoke") > 0 || - std::find(a.positional.begin(), a.positional.end(), - "--smoke") != a.positional.end(); + const int frames = std::stoi(a.get("frames", "90")); + auto hasFlag = [&](const char* name) { + return a.kv.count(name) > 0 || + std::find(a.positional.begin(), a.positional.end(), + std::string("--") + name) != a.positional.end(); + }; + const bool smoke = hasFlag("smoke"); + const bool preview = hasFlag("preview"); std::cout << "[view] storeDir=" << dir << " exagg=" << exagg << " opacity=" << opacity << " budget=" << budget - << (smoke ? " [SMOKE 离屏]" : " [真窗口交互]") << "\n"; + << (preview ? " [PREVIEW 离屏存图+测fps]" + : (smoke ? " [SMOKE 离屏]" : " [真窗口交互]")) + << "\n"; + // preview/smoke 走离屏。 + const bool offscreen = smoke || preview; const int winW = 1280, winH = 800; // 核外源(读 meta + 建 pager,不载整卷)。 @@ -2350,9 +2410,9 @@ int cmdView(int argc, char** argv) { vtkSmartPointer prop = makeTunedVolumeProperty(m.quant, cs, vmin, vmax, opacity); - // 渲染窗口:smoke 走离屏,否则真窗口。 + // 渲染窗口:preview/smoke 走离屏,否则真窗口。 vtkSmartPointer rw; - if (smoke) { + if (offscreen) { rw = makeOffscreenWindow(winW, winH); } else { rw = vtkSmartPointer::New(); @@ -2393,34 +2453,84 @@ int cmdView(int argc, char** argv) { st.rw = rw.Get(); st.exagg = exagg; - // 相机初始定向:先框整体选出工作集,再 ResetCamera 到工作集包围盒(同 renderC)。 - ren->ResetCamera(m.origin[0], m.origin[0] + m.nx * m.spacing[0], - m.origin[1], m.origin[1] + m.ny * m.spacing[1] * exagg, - m.origin[2], m.origin[2] + m.nz * m.spacing[2] * exagg); - st.cam = ren->GetActiveCamera(); - - const std::size_t warm = viewRefreshBlocks(&st); - { - double b[6]; - mapper->GetBounds(b); - if (b[0] <= b[1]) { - // 工作集包围盒需按 exagg 缩放后再框(actor 已 SetScale)。 - ren->ResetCamera(); - } - } - st.cam->Elevation(25.0); - st.cam->Azimuth(25.0); - ren->ResetCameraClippingRange(); + // 相机初始定向(修复 1):默认框「局部段」而非整卷。整线横截面 1:34,框整卷 + // 即便 exagg=8 也是一条隐形细带(看着空白);改为对准沿线中段一个 ~768 道窗口 + // 的全分辨率局部体 → 开窗第一帧就看到一段有层状结构的体。三路径共用此取景。 + const std::size_t warm = viewSetupDefaultFrame(&st, ren); rw->Render(); - std::cout << "[view] 预热: level=" << src.lastLevel() << " 视野块=" - << src.lastVisibleCount() << "/" << src.lastLevelBrickTotal() - << " 驻留=" << src.residentCount() << " 渲染块=" << warm << "\n"; + std::cout << "[view] 预热(默认局部段): level=" << st.lastLevel + << " 渲染块=" << warm << "\n"; const vtkIdType nonBlack = countNonBlackPixels(rw.Get(), winW, winH); const bool textureErr = capWin->textureError(); const bool renderedOk = !textureErr && nonBlack > 0; + if (preview) { + // 修复 2:用与真窗口完全相同的默认相机/source/exagg/传函(viewSetupDefaultFrame + // 已建好),离屏渲一帧存图 → 控制方先 Read 确认开窗默认画面非空、有结构。 + const fs::path shotDir = + fs::path("docs") / "superpowers" / "plans" / "poc-lod-shots"; + fs::create_directories(shotDir); + const std::string pngPath = (shotDir / "view-default.png").string(); + savePng(rw.Get(), pngPath); + // 结构像素计数:背景为深蓝灰(R/G≈10,B≈20),countNonBlackPixels(>10) 会把整屏 + // 背景都算「非空」,对验证「画面有结构」无意义。改为只数明显亮于背景的像素 + // (任一通道 >50),作为「确有渲出的体结构」的诚实判据。 + auto countStructPixels = [&]() -> vtkIdType { + auto px = vtkSmartPointer::New(); + rw->GetRGBACharPixelData(0, 0, winW - 1, winH - 1, /*front=*/1, px); + vtkIdType n = 0; + const vtkIdType np = px->GetNumberOfTuples(); + for (vtkIdType i = 0; i < np; ++i) { + if (px->GetComponent(i, 0) > 50 || px->GetComponent(i, 1) > 50 || + px->GetComponent(i, 2) > 50) { + ++n; + } + } + return n; + }; + const vtkIdType defStruct = countStructPixels(); + + // 旋相机 N 帧测真实 fps(非首帧:首帧含纹理上传/shader 编译已在预热完成)。 + rw->Render(); // 再预热一帧,确保管线热 + Stopwatch sw; + for (int f = 0; f < frames; ++f) { + st.cam->Azimuth(360.0 / frames); + rw->Render(); + } + const double ms = sw.elapsedMs(); + const double fps = ms > 0 ? frames * 1000.0 / ms : 0.0; + + const bool texErr2 = capWin->textureError(); + vtkOutputWindow::SetInstance(nullptr); + const bool ok = !texErr2 && defStruct > 0; + + std::cout << "\n=== view --preview 离屏默认视角验证 ===\n"; + std::cout << "默认局部段维度 : " << kViewDefaultLocalBricks + << " brick 列(沿线中段) level0\n"; + std::cout << "存图 : " << pngPath << "\n"; + std::cout << "结构像素(>50) : " << defStruct << " / " << (winW * winH) + << " (" << (100.0 * defStruct / (winW * winH)) + << "%, 已排除深蓝灰背景)\n"; + std::cout << "纹理维度错误 : " << (texErr2 ? "是(!!)" : "否") << "\n"; + std::cout << "真实渲染 fps : " << (ok ? std::to_string(fps) : "INVALID") + << " (" << frames << " 帧旋相机, 非首帧)\n"; + std::cout << "preview 结果 : " + << (ok ? "OK ✔ 默认视角有结构" : "FAIL ✘") << "\n"; + + writeMetricLine( + "view-preview,dir=" + dir + ",exagg=" + std::to_string(exagg) + + ",opacity=" + std::to_string(opacity) + + ",localBricks=" + std::to_string(kViewDefaultLocalBricks) + + ",structPixels=" + std::to_string(defStruct) + + ",fps=" + (ok ? std::to_string(fps) : "INVALID") + + ",textureErr=" + std::to_string(texErr2 ? 1 : 0) + + ",ok=" + std::to_string(ok ? 1 : 0) + + ",png=" + pngPath); + return ok ? 0 : 1; + } + if (smoke) { // 离屏 smoke:模拟一次缩放 → 验 LOD 切换 + 不崩。 const int lvlNear = src.lastLevel(); @@ -2472,7 +2582,6 @@ int cmdView(int argc, char** argv) { iren->AddObserver(vtkCommand::EndInteractionEvent, cb); std::cout << "[view] 打开真窗口。左键旋转 / 滚轮缩放(切 LOD) / q 退出。\n"; - st.frameTimer.reset(); iren->Initialize(); rw->Render(); iren->Start(); @@ -2497,7 +2606,7 @@ void usage() { " gpr_poc fps-budget [--frames 90] " "[--bricks 4,16,64,128,256]\n" " gpr_poc view [--exagg 8] [--opacity 0.5] " - "[--budget 64] [--smoke]\n"; + "[--budget 64] [--smoke] [--preview] [--frames 90]\n"; } } // namespace