From cf1c06cde84f75f2fb2b69b8a6152e989d0ca311 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Fri, 26 Jun 2026 23:25:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(gpr):=20=E4=B8=89=E7=BB=B4=E4=BD=93=20LOD?= =?UTF-8?q?=20=E5=A4=9A=E7=BA=BF=E6=B8=B2=E6=9F=93=20+=20=E5=85=A8?= =?UTF-8?q?=E5=B1=80=E5=88=87=E7=89=87(=E6=B7=B1=E5=BA=A6/=E6=A8=AA?= =?UTF-8?q?=E5=88=87/=E9=A1=BA=E8=B7=AF)=20+=20=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 渲染架构改 LOD 中心:各线独立 mapper + 视野自适应 LOD,弃 multi-volume 单遍。 实测确诊多线卡顿真因是"没用 LOD、渲整卷大贴图"(passcost 排除固定开销; overlapStat 实测重叠 ~9× 非 20×;ESS 实测仅 ~2× 不解决重叠),非渲染器问题。 切片(view-all --slice [updown|leftright|frontback]): - 深度 C-scan:逐线整张水平片(深度共面→拼成完整 C-scan,全覆盖、原生分辨率) - 横切/顺路:全局世界面 reslice 各线到同一面 + blend(竖直面几何上每线只切细断面) - ↑↓ 整片扫过 / [ ] 体透明度 / v 体显隐 / --sliceAt 跳位 其他:通道插值(2.5cm,从.ord读)接入 gpr_poc;--bgSuppress 压背景突出反射; slice 命令复用桌面端 SliceTool 切单线。 诊断命令:ess-stat(空块潜力)/--overlapStat(重叠层数)/passcost(N遍vs重叠隔离)。 分析文档:性能确诊(否定 ESS/OSPRay,LOD 为通用解)。 --- ...-gpr-3d-render-perf-ANALYSIS-for-review.md | 210 +++ ...26-06-26-gpr-multibackend-ess-rendering.md | 132 ++ src/io/gpr/Gpr3dvVolumeBridge.cpp | 51 +- src/io/gpr/Gpr3dvVolumeBridge.hpp | 6 +- src/io/gpr/GprGeometry.cpp | 43 + src/io/gpr/GprGeometry.hpp | 16 + tests/io/gpr/test_gpr3dv_volume_bridge.cpp | 37 +- tests/io/gpr/test_gpr_geometry.cpp | 65 + tools/gpr_poc/main.cpp | 1190 +++++++++++++++-- 9 files changed, 1632 insertions(+), 118 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-26-gpr-3d-render-perf-ANALYSIS-for-review.md create mode 100644 docs/superpowers/specs/2026-06-26-gpr-multibackend-ess-rendering.md diff --git a/docs/superpowers/specs/2026-06-26-gpr-3d-render-perf-ANALYSIS-for-review.md b/docs/superpowers/specs/2026-06-26-gpr-3d-render-perf-ANALYSIS-for-review.md new file mode 100644 index 0000000..9e2bed9 --- /dev/null +++ b/docs/superpowers/specs/2026-06-26-gpr-3d-render-perf-ANALYSIS-for-review.md @@ -0,0 +1,210 @@ +# GPR 多通道三维体渲染性能问题 — 分析文档(供外部专家评审) + +> 自包含技术文档。读者无需了解本代码库内部,只需具备 GPU 体绘制 / VTK 基础。 +> 目的:把"探地雷达(GPR)多通道阵列数据渲成可交互三维体"遇到的性能问题、已试方案、实测数据、 +> 待定关键点完整呈现,供外部专家判断方向。 + +--- + +## 1. 背景与系统 + +- 桌面端 C++ 应用(Qt6 + **VTK 9.6.2**),渲染探地雷达(GPR)采集的地下三维数据,要求**可交互**(旋转/缩放)。 +- 渲染用 VTK 的 `vtkGPUVolumeRayCastMapper`(OpenGL GPU 光线投射体绘制)。 +- 当前测试机 GPU:32 个着色器纹理单元(典型独显/中端)。 + +## 2. 数据特征(关键,决定一切) + +多通道阵列 GPR,一次采集一条"测线(line)": +- **道(trace)**:一个位置一根天线的垂直回波,深度方向 **821 采样**。 +- **通道(channel)**:阵列横向并排的多对天线,本数据 **14 通道**;**相邻通道横向间距 ≈ 10.5cm**(来自 `.ord` 文件真实偏移 -0.686…+0.686m,跨度 1.37m)。 +- **沿测线道间距 ≈ 4.9cm**(比横向通道间距细 ~2 倍)。 +- 一条测线:沿路 **~45305 道**,覆盖 **~2.2km**(一条南北向道路)。 +- **共 20 条测线 = 同一条路来回扫 20 趟**(车载,每趟阵列覆盖约 1.4m 宽,多趟铺横向)。 + +**单条线 = 一个三维体**:X=沿测线(~45305)、Y=通道(14)、Z=深度(821)。 +**关键业务约束(来自现场专家)**: +- 通道太稀(10.5cm)→ 需**线内通道间插值**加密(相邻真实天线之间插,物理成立); +- **绝不做"测线之间"的插值**(车与车之间是真实物理空隙,插出来"信号全是假的",工程上不可接受); +- 多条测线"分开各自插值,渲染可以合到一起"。 + +GPR 数据的统计特征(实测,见 §6):**~91% 体素近零(反射层之间是空的),但反射层横贯整个深度分布**(不是集中一坨)。 + +## 3. 已建成并验证可用的功能(不是问题所在) + +1. **线内通道插值**:读 `.ord` 真实横向偏移,规则化到 2.5cm 网格、相邻通道线性插值(不跨线)。 + 实测 Y 由 14 加密到 **56**。有单元测试。 +2. **多体单遍合成**:20 条独立体(各自插值)作为一个 `vtkGPUVolumeRayCastMapper` 的多个端口注册进 + `vtkMultiVolume`,**单遍 ray-cast 合成**(而非每条体一遍)。已验证。 +3. **纹理单元上限自动退避**:单个 multi-volume 同时挂的体数受 GPU 每着色器纹理单元上限制约 + (每体约吃 4 个单元 → 32 单元机上**一个包最多 7 体**,第 8 体报错并丢体)。已实现"渲一帧→报错则 + 每包减 1 重建重渲"的自动退避(强制 K=12 → 自动退避到 7,无丢体)。 +4. **运行时换贴图边界**(确定性测试结论):给某端口**就地换贴图**——若**保持包围盒不变**(同范围、 + 只改 Y 密度)则 multi-volume 算得对;若**改包围盒**(任意子区域、origin/范围/spacing 变)则破坏 + 其缓存 `TexToBBox` → 体断开/消失。 +5. 通道维 LOD、统一传函、色标图例等。 + +## 4. 核心问题:性能 + +- **20 条密体(Y=56)总览,交互极卡**:静止 ~1.7 fps,旋转/缩放掉到 < 1 fps(GPU 100%)。 +- 渲染**视觉正确**(雷达剖面纹理清晰、横向连续、合成无误),纯属性能。 +- 现有提速手段都是**"交互时降质"**方向(降屏幕分辨率、加粗采样步长)——**损可见质量**,治标。 + 用户明确要求:"有没有不损可见质量的根本性提速(业界最佳实践)?" + +## 5. 已分析/已试的所有方案(含理由与状态) + +| # | 方案 | 理由 | 状态 / 结论 | +|---|---|---|---| +| A | multi-volume 单遍合成 | 把"N 体=N 遍 ray-cast"降到分包遍数 | ✅ 已实现。但单遍内每步仍要在重叠体里逐个采样 | +| B | 纹理单元自动退避分包 | 绕开 GPU 纹理单元上限、不丢体 | ✅ 已实现(K=7/包,20 条=3 包)。代价:**跨包重叠合成不正确(接缝)** | +| C | 交互降屏幕采样(ImageSampleDistance) | VTK AutoAdjust 标准手段 | ✅ 已做。**损质量**;且 AutoAdjust 只降屏幕、不降沿光线步长 | +| D | 交互手动加粗沿光线步长(SampleDistance) | 通道插值后 Y 密→自动步长极细→巨卡;这才是大头 | ✅ 已实现(`--sampleDist`/`--dragSampleMul`)。**损质量;且用户尚未实测**(见 §7 待定) | +| E | 通道维 LOD(远疏近密换 Y 平面子集)| 保包围盒换贴图(#4 验证安全) | ✅ 已实现。但**只减纹理内存、不减每步重叠体采样次数 → 对此瓶颈几乎无效** | +| F | **装箱单体(binning)**:各线先逐线插值,再把**真实道**摆进一个总览网格体(空隙透明、不跨线插值)| 一个体一遍、每步采 1 次 → ~20×;真实数据无假信号 | ⚠️ 技术可行、合规,但**用户否决**:装箱合并后**总览里分不出各线**,而用户要"一起渲染时仍能逐线区分/查看"→ 合并即失去意义 | +| G | **空体素跳过 ESS(换 OSPRay/ANARI 后端)**:跳过透明背景块 | 业界对稀疏体的头号提速、不损质量 | ❌ **实测对本数据收益有限**(见 §6):保质量阈值下仅 ~2×,且**ESS 跳的是空区、不解决"重叠"**。VTK 库存 mapper 无自动 ESS;OSPRay 本环境未编、vcpkg 无包 | +| H | 减少同屏体数(只渲选中 ≤7 条)| 真实工作流本就是选几条,1 包 1 遍 | ✅ 免费、永远有效(使用方式,非技术) | + +## 6. 关键实测数据 + +### 6.1 ESS(空体素跳过)潜力——零依赖实测,决定要不要上 OSPRay +对一条真实测线密体(5702×56×789),按块算 min/max,统计"整块落在近零透明带"的占比(=ESS 可跳块): + +| 透明带半宽(相对半值域) | 8³ 块可跳占比 | 理论提速上限 1/(1−占比) | 说明 | +|---|---|---|---| +| 5% | 8% | 1.1× | 极保守 | +| **10%(保质量,不丢弱反射)** | **52%** | **~2.1×** | — | +| 20% | 80% | ~4.9× | 开始把弱反射当背景丢 | +| 30%(激进)| 90% | ~10× | 明显损质量 | + +- 体素层面 **91% 近零**,但块层面(ESS 实际粒度)保质量阈值下**只能跳 ~52% → 理论 ~2×**。 +- 原因:**反射层横贯整个深度分布**,多数块里总混着信号、跳不掉。要 10× 须用激进阈值(损质量)。 +- **更关键**:ESS 跳"空区",**不减少"重叠"**——在有信号的块里仍要逐个采样所有重叠体。 + +### 6.2 其它实测 +- multi-volume 纹理单元上限:本机 7 体/包(32 单元),第 8 体报 "Hardware does not support the number of textures"。 +- 体维度示例:coarse 4 → ~11000×56×793/线;coarse 32 → ~1400×56×780/线。 +- 全 20 条 dense(coarse 32):底图 level 0、Y=56、分 3 包,**渲染正确**;交互 ~1.7fps(**未加 D 方案的旧构建**)。 + +## 7. 关键点——已实测,结论修正(原假设两条都被推翻) + +### 7.1 "重叠几层"——实测:**平均 ~8.7 层、最大 15 层(不是 ~2–3,也不是 20)** +纯几何测(各线世界 AABB 投到 X-Y 俯视 footprint、细网格统计每格覆盖层数,`--overlapStat`): +- footprint:横向 X≈37m、沿路 Y≈2.2km; +- **有体覆盖处平均重叠 8.74 层,最大 15 层**;穿 12–14 个体的格子占 ~42%。 +- **结论修正**:原"~2–3 层"假设**错**(开发团队和外部专家都猜偏了);**重叠是真实的大瓶颈**。 +- **且这 9 层是冗余**:20 趟是同一条路反复扫,同一地下点被测了 ~9 次 → 这 9 个重叠体在该处**都非空** + (同一地下结构)→ **ESS 在重叠区一个都跳不掉**(再次印证 ESS 不解决重叠)。 + +### 7.2 "采样 vs 重叠谁是大头"——实测:**采样瓶颈,fps 线性正比于步长** +20 条密体、静止近景、离屏(步长越大越快越糙): + +| sampleDist | fps | 相对 | +|---|---|---| +| 0.2(≈自动细)| 1.3 | 1× | +| 0.5 | 3.2 | 2.5× | +| 1.0 | 5.9 | 4.5× | +| 2.0 | 11.3 | 8.7× | +| 4.0 | 20.9 | 16× | + +- **步长翻倍→fps 翻倍 → GPU 是采样瓶颈**。总开销 ≈ 光线数 × (光线长/步长) × ~9 个重叠体。 +- D 方案(手动步长)**确实直接、强力提速**;但**保质量的步长(≈ Nyquist,0.5×体素)下仍只 ~2 fps** + ——因为 **9× 冗余重叠**把它乘了回去。要到交互级(10+fps) 得把步长粗到 ~2.0(欠采样、损 Z 薄层)。 + +### 7.3 合并诊断(两测合起来) +**慢 = 采样密度 × ~9 倍冗余重叠,两者都真实。** +- D 方案(粗化采样):提速强,但保质量步长下被 9× 重叠压回 ~2fps;要交互须损质量。 +- **唯一"保质量又快"的,是去掉那 9× 冗余重叠**(同路重扫的同一地下点):合并/装箱(取真实道、不跨线 + 插值)→ 一个体一遍 → ~9× 提速、且 Nyquist 步长下也能交互、**零质量损失**(冗余测量本就该合并降噪)。 +- **但这与用户"保持 20 条可区分"直接冲突**——而这 9 层在物理上是**冗余测量**(同一地下结构扫了 9 遍), + 保持它们"可区分"的工程价值存疑。 + +### 7.4 CPU OSPRay vs GPU(仍未测) +ESS 对本数据 ~2× 且不解决 9× 重叠;OSPRay 主要 CPU、对手是 GPU 数千核。**很可能换 OSPRay 比现状还慢**, +且为 ~2× 重编整个 VTK 投入产出极差。不建议在去掉冗余重叠之前考虑。 + +### 7.5 "多线为何卡"的根因确诊(passcost,决定架构)——结论:**不是固定开销,是没用 LOD** +> 背景:最初 P11/P12 是"各线独立 mapper + 视野 LOD",实测仍 0.5fps。需确诊卡在三个嫌疑哪个: +> ①LOD 选区没削小 ②N 遍固定开销 ③重叠没摊掉。`passcost` 命令:N 个独立 GPU mapper 各渲一个 64³ 小体 +> (模拟 LOD 削过的小区),分"铺开/不重叠"与"叠在一起/重叠",测离屏稳态 fps vs N。 + +| N | 铺开(不重叠) fps | 叠加(重叠) fps | +|---|---|---| +| 1 | 177 | 204 | +| 5 | 162 | 43 | +| 10 | 144 | 22 | +| **20** | **78** | **11** | + +**判读(决定性):** +- **嫌疑 ②(N 遍固定开销)排除**:20 个独立 mapper 铺开仍 **78fps**(177→78,远非线性)。 + → **各线独立 mapper 架构上完全可行,固定开销温和。"multi-volume 单遍 ⊥ 视野 LOD"这个不可兼得不致命**—— + 放弃单遍、回独立 mapper 并不慢。 +- **嫌疑 ③(重叠)真实但小体下可控**:叠加随 N ~1/N(每条光线乘 N),但 **20 层 64³ 叠加仍 11fps**(可用), + 再叠屏幕降采样更快。 +- **嫌疑 ①(选区没削小)= 真凶**:passcost 小体 20 层叠加=11fps,而真实 view-all 只 1.7fps——差距全在 + **贴图大小**:当前渲的是**整卷底图**(~11000×56×200 ≈ 上亿体素/条),**根本没用视野 LOD 把它削成小区**。 + 这是**最好结局:可修,不动地基,只需真正用上 LOD**。 + +**对架构的直接含义**:本会话引入的 **multi-volume 单遍是错误取舍**——为"单遍"关掉了 LOD、改固定整卷贴图, +导致大贴图 × 9 层重叠 = 1.7fps。而 passcost 证明独立 mapper 够快,**根本不必为单遍牺牲 LOD**。 + +## 8. 部署约束(硬件不确定,跨厂商) + +客户机配置未知(可能无独显,或 N卡/A卡/Intel)。**没有任何单一渲染器能在 N 卡和 A 卡上都做"GPU+ESS"**—— +GPU 体光追渲染器全厂商锁定(N 卡→NVIDIA VisRTX/OptiX/IndeX;Intel→OSPRay-GPU;A 卡→基本无成熟方案)。 +跨厂商唯一通用的是 **OSPRay-CPU(免显卡、任意 x86)** 或 **OpenGL(任意 GPU、但无 ESS=现状)**。 +若上多后端,需"OSPRay-CPU 保底 + 探测到 N卡/Intel独显时升对应 GPU 后端 + OpenGL 终极兜底"。 + +## 9. 关键问题——大多已被实测回答 + +1. ~~有没有不损质量的根本性提速法~~ **有,且是通用解:LOD(视野自适应多分辨率)**——让 GPU 单帧实际 + ray-cast 的体素量**与数据总量解耦、只与"屏幕能看清的量"挂钩**(Task 12c 单体已验证 752/380fps)。 + §7.5 passcost 证明它**对多线也成立**(独立 mapper 开销温和,20 条铺开 78fps)。 +2. ~~"卡"的主因~~ **已确诊(§7.5):不是 N 遍固定开销(排除),是【当前根本没用 LOD、渲整卷大贴图】(嫌疑①)。** + 本会话引入的 multi-volume 单遍为"单遍"关掉了 LOD → 大贴图 × 9 层重叠 → 1.7fps。 +3. **9 层重叠的正确定位**(外部专家纠偏 + passcost 印证):它只是**这批数据(同路重扫 20 趟)的特例倍数**, + **不是渲染本质问题**。本质是"单帧采样量 > GPU 吞吐",通用解是 LOD(扛任意大数据:无重叠但更大也能扛)。 + 9 层重叠在 LOD 之上降级为"一个被摊薄的常数因子"(passcost:20 层小体叠加仍 11fps)。 + **不要让渲染架构围绕这个特例设计。** +4. ESS/OSPRay/多后端:**继续埋掉**——ESS 对本数据 ~2× 且不解决重叠、CPU 对手是 GPU,且**它解决的是 LOD + 已经解决的通用问题**,投入产出差。 + +## 10. 最终结论(passcost 确诊后,架构清晰) +- **渲染架构 = LOD 中心(视野自适应、单帧量与总量解耦)。** 这是扛"任意大数据"的通用根本解, + Task 12c 单体已验证、§7.5 passcost 多线也成立。 +- **本会话的 multi-volume 单遍是错误取舍**:为"单遍合成"牺牲了 LOD、改固定整卷大贴图,正是当前 1.7fps 的 + 直接原因。passcost 证明独立 mapper 开销温和(20 条 78fps)→ **根本不必为单遍弃 LOD**。 +- **正解 = 各线独立 mapper + 视野 LOD(逐线用 Task 12c 引擎)+ 停手才重建**(不每帧重建,避免 P11/P12 + 那种"20 条每帧重建上传"的 thrash——那才是 0.5fps 的另一半原因,与稳态 ray-cast 无关,已被 P13 思路解决)。 + 让每条线只渲视野内小区 → 即使 9 层叠加也可用。 +- **9 层重叠 = LOD 之上的可选应用层优化**(对同路重扫冗余可"合并/降噪",顺带省 9×),**不进渲染地基**。 + 用户要逐条区分就不合并(靠 LOD 摊薄),要纯总览就合并。 +- **采样步长(D 方案)= LOD 框架内的质量旋钮**,非独立根本解。 +- **ESS/OSPRay/多后端:不做**(不解决 LOD 已解决的通用问题,对本数据收益差)。 + +→ **下一步(确诊已完成,可开工)**:把多线总览从"multi-volume 单遍固定整卷"改回"各线独立 mapper + + 视野 LOD + 停手重建",让单帧渲染量随视野走、与 20 条总量解耦;实测多线总览是否达交互级。 + 这是顺着通用 LOD 框架、被 passcost 数据支撑的明确方向——不再围着 9 层重叠这个特例转。 + +--- + +### 附:相关已落地代码 / 诊断工具(如专家要复现) +- 通道插值:`src/io/gpr/GprGeometry.cpp::planChannelInterpolation` + `Gpr3dvVolumeBridge.cpp` +- 多体合成/退避/质量控制/通道 LOD:`tools/gpr_poc/main.cpp::cmdViewAll` +- **诊断命令**(`tools/gpr_poc/main.cpp`,可直接跑复现 §6/§7 的数): + - `gpr_poc ess-stat `:ESS 空块潜力(§6.1) + - `gpr_poc view-all --overlapStat`:实测重叠层数(§7.1) + - `gpr_poc view-all --sampleDist D`:步长↔fps(§7.2) + - `gpr_poc passcost --size 64 --overlap 0|1`:N 遍开销 vs 重叠 隔离测(§7.5) +- 数据/插值口径 spec:`docs/superpowers/specs/2026-06-25-gpr-line-channel-interpolation-and-multivolume.md` +- 多后端 ESS 架构 spec(**结论:不做,见本文 §10**):`docs/superpowers/specs/2026-06-26-gpr-multibackend-ess-rendering.md` + +--- + +## 摘要(一页结论,供决策) +1. **现象**:20 条通道插值密体总览,~1.7fps、交互更卡。视觉正确,纯性能。 +2. **确诊**(passcost 隔离测):**不是 N 遍固定开销**(20 独立 mapper 铺开 78fps,排除); + 是**当前根本没用视野 LOD、在渲整卷大贴图**(× 9 层重叠)。本会话的 multi-volume 单遍为"单遍" + 牺牲了 LOD,是直接原因。 +3. **通用根本解 = LOD**(单帧渲染量与数据总量解耦),扛任意大数据;Task 12c 单体 752fps、passcost 多线 + 也成立。9 层重叠只是**本批数据的特例倍数**,是 LOD 之上一个可摊薄/可选合并的因子,**不是架构核心**。 +4. **正解**:各线独立 mapper + 视野 LOD + 停手才重建(弃 multi-volume 单遍)。 +5. **明确否定**:ESS/OSPRay/多后端(对本数据 ~2×、不解决重叠、解决的是 LOD 已解决的通用问题)。 diff --git a/docs/superpowers/specs/2026-06-26-gpr-multibackend-ess-rendering.md b/docs/superpowers/specs/2026-06-26-gpr-multibackend-ess-rendering.md new file mode 100644 index 0000000..fb7a6d0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-26-gpr-multibackend-ess-rendering.md @@ -0,0 +1,132 @@ +# ⚠️ 本 spec 已被实测推翻,勿照此实现 + +> **结论(见 `2026-06-26-gpr-3d-render-perf-ANALYSIS-for-review.md` §10):ESS/OSPRay/多后端【不做】。** +> 实测:ESS 对本数据 ~2× 且不解决重叠;passcost 确诊"多线卡"的真因是【没用视野 LOD、渲整卷大贴图】, +> 不是固定开销。**正解 = LOD 中心(各线独立 mapper + 视野 LOD + 停手重建),见下方实现计划。** +> 本文余下"多后端/ESS"内容仅作历史记录。 + +--- + +## 实现计划(LOD 中心多线总览 — 已确诊、可执行) + +**目标**:把 `cmdViewAll` 从"multi-volume 单遍 + 固定整卷大贴图"改为"各线独立 mapper + 视野 LOD + +停手才重建",使单帧渲染量随视野走、与 20 条总量解耦。 + +**改动步骤(`tools/gpr_poc/main.cpp::cmdViewAll`)**: +1. `PlacedSource` 加回**每线自己的 `vtkSmartVolumeMapper`**(GPU 模式);删 multi-volume 用法 + (multiMapper/multiVol/port 不再需要,但可暂留不碍)。 +2. **装配**:删 `buildBundles` + 退避(无 multi-volume 即无纹理单元上限);改为逐线 + `mapper->SetInputData(baseImage)` → `volume->SetMapper(mapper)` → `ren->AddVolume(volume)`, + 各线 mapper 收进 `mappers` 向量(供质量控制)。 +3. **开 LOD**:`gViewAllBaseOnly = false`(启用引擎选区换图);引擎换的是"改包围盒的子区域", + 各线独立 mapper 下**安全**(无 multi-volume 可破坏)。 +4. **关 channel LOD**:`gChanLod = false`——引擎金字塔已逐级降 Y,无需单独抽 Y 平面。 +5. `viewAllPickOneLine`:`ps.mapper->SetInputData(ps.currentImg); ps.mapper->Update();`(非 multiMapper 端口)。 +6. **停手才重建(已有,确认接线)**:拖动中(`viewAllOnInteracting`)只降质+重置裁剪、**不提交引擎目标**; + 松手(`viewAllOnInteract`)/定时器 idle 才 `viewAllSubmitTargets`(提交 LOD 目标)+ `viewAllPickOneLine` + (拉就绪区域换上)。避免 P11/P12"每帧 20 条重建上传"thrash。 +7. **质量旋钮保留**:`--sampleDist`/`--maxImgSample`/`--dragSampleMul` 作 LOD 框架内的交互降质兜底。 +8. **总览级别**:引擎 `selectLod` 按屏幕像素选层——拉远时全路映射到少量像素 → 自动选粗层(小贴图)→ + 20 条小贴图即可用;拉近 → 小区域细层。**确认 selectLod 多线下确实选到粗层**(若没有,调 + selectLod 的屏幕像素阈值——这是 §7.5 嫌疑①的修复点)。 + +**验收**:离屏看各线底图 level 随相机距离变(远→粗/小、近→细/小区);真窗口测 20 条总览交互级 fps、 +拖动跟手、松手清晰、过档位无明显卡顿(外部专家提示重点盯"停手重建过渡手感")。 + +--- + +# GPR 三维体渲染:多后端 + 空体素跳过(ESS) 加速架构 — Spec(2026-06-26,已废) + +> 解决"20 个重叠密体在 VTK 库存 OpenGL mapper 上又卡又只能降质"的根本性方案。 +> 关联:`docs/superpowers/specs/2026-06-25-gpr-line-channel-interpolation-and-multivolume.md`(数据/插值口径)。 + +--- + +## 0. 一句话目标 +用**空体素跳过(ESS)** 这一不损可见质量的业界技术,把"逐线分开的多个密体合并渲染"做到**不卡**; +并以**多渲染后端 + 自动适配**覆盖客户侧未知/多厂商硬件(含无显卡)。 + +--- + +## 1. 问题与根因 +- 现状渲染 = VTK `vtkGPUVolumeRayCastMapper`(OpenGL)。**任意 GPU 可跑(最通用),但无 ESS**。 +- 20 个重叠半透明密体:每条光线每步在 20 体各采一次、光线又长 → 几十亿次纹理查找/帧 → 1.7fps、交互更卡。 +- 通道插值后 Y 加密到 2.5cm,使自动沿光线步长更细 → 更卡。 +- **已尝试的"交互降质"(屏幕降采样 + 沿光线步长加粗)是治标**,损可见质量。 + +## 2. 根本方案:ESS(不损质量) +GPR 体 ~90% 是近零背景(反射层之间空)。ESS 用 min/max 加速结构**跳过"在传函里全透明"的块**, +对稀疏数据常 5–50× 提速、**零质量损失**。但 **VTK 库存 GPU mapper 不做自动 ESS**(仅有受限的 +`UseDepthPass` 等高线跳过)。→ 真 ESS 必须**换专业体渲染后端**(其底层自带 ESS + 正确合成多重叠体)。 + +## 3. 关键事实:跨厂商 GPU 加速不存在单一方案 +GPU 体光追渲染器全是**厂商锁定**: + +| 后端 | 硬件 | ESS | 跨厂商 | 角色 | +|---|---|---|---|---| +| OpenGL(vtkGPUVolumeRayCastMapper,现状)| 任意 GPU(N/A/Intel) | ❌ | ✅ | **终极兜底** | +| OSPRay(CPU,Embree/ISPC)| 任意 x86 CPU(免显卡)| ✅ | ✅ | **通用基线** | +| OSPRay-GPU(SYCL/oneAPI)| 仅 Intel Arc/数据中心卡 | ✅ | ❌ | Intel 独显 | +| ANARI + VisRTX(OptiX)| 仅 NVIDIA | ✅ | ❌ | N 卡 | +| AMD GPU 体渲染+ESS | — | — | ❌ 无成熟方案 | — | + +**结论**:没有"同时 N/A 卡的 GPU-ESS"。**面向未知客户机,OSPRay-CPU 是最稳通用选择**(免显卡、ESS、质量不降)。 + +## 4. 渲染后端架构(多后端 + 自动适配 + 手动覆盖 + 兼容灰掉) + +### 4.1 用户可见选项(按硬件/结果命名,不暴露库名) +| 用户看到 | 背后实现 | 适配硬件 | +|---|---|---| +| **自动(推荐)** | 探测后选下面之一 | — | +| GPU 加速(N卡)| ANARI + VisRTX | NVIDIA 独显 | +| GPU 加速(Intel)| OSPRay-GPU | Intel Arc 独显 | +| CPU(通用,免显卡)| OSPRay-CPU | 任意 CPU | +| 通用 GPU(兼容)| OpenGL(现状 mapper)| 任意 GPU(兜底)| + +### 4.2 自动探测逻辑 +``` +if NVIDIA 独显: → VisRTX(GPU) +elif Intel Arc 独显: → OSPRay-GPU +elif AMD(独显/核显) 或 Intel 核显 或 无显卡 或 探测失败: + → OSPRay-CPU(默认) // 核显一律走 CPU:弱+共享内存+多不被GPU后端支持 +// 强力 A 卡可手动选"通用 OpenGL"用其 GPU(无 ESS),但不作默认 +``` +- **手动覆盖**:用户可自选,但**只列出与当前硬件兼容的项**(不兼容灰掉,如 A 卡上禁 VisRTX)。 +- **集显建议**:一律 OSPRay-CPU(CPU+ESS 比让弱核显硬渲更稳更快)。 + +### 4.3 部署策略(一句话) +**OSPRay-CPU 保底通用;探测到 N卡/Intel Arc 时升对应 GPU 后端;A 卡/核显吃 CPU 基线;OpenGL 终极兜底。** + +## 5. 上 ESS 后端后,废弃哪些 +- ❌ **装箱单体(binning)**:当初为绕 OpenGL 无 ESS 才提(代价=丢"逐线分开")。ESS 后端让**逐线分开 + 的多体也快** → 不需要装箱。**逐线分开 + 不造假 + 不卡,三者兼得。** +- ❌ **交互降质权宜**(屏幕/沿光线步长加粗):ESS 后端有自带的自适应/渐进式细化,基本不用;保留作任何后端的兜底。 +- ❌ 自写 ESS shader / 预积分 / UseDepthPass:后端自带,无需自行实现。 +- ✅ **保留**:"选几条 ds(≤7)" 是使用方式(非技术),永远有效。 + +## 6. 实现要点(工程) +- VTK 需**重编**带:`RenderingRayTracing`(OSPRay)、`RenderingAnari`(ANARI/VisRTX) 模块 + 依赖 + (OSPRay/Embree/ISPC/OpenVKL;ANARI-SDK/VisRTX)。用户已确认工程量无所谓。 +- 渲染层抽象一个 `IVolumeRenderBackend`,运行时按 §4 选具体 mapper: + `vtkGPUVolumeRayCastMapper`(OpenGL) / `vtkOSPRayPass`+volume / `vtkAnariPass`+volume。 +- **数据不变**:逐线密体(含通道插值,spec 前一份)原样喂各后端;多体合成由后端负责(无 K=7 分包)。 +- 硬件探测:GL_VENDOR / 平台 API(DXGI 枚举适配器)判 N/A/Intel + 独显/核显。 + +## 7. POC 计划(先验"CPU+ESS 够不够快"——最通用、风险最低) +1. **POC-1(先做)**:最小程序,用 **OSPRay-CPU** 渲一个 GPR 密体(tmp/lines_all_dense 里一条/几条), + **实测普通 CPU 上对多体/密体的 fps + ESS 提速比**,对照现状 OpenGL。先确认 OSPRay 在本环境 + 可编可跑、CPU+ESS 实际够快——这是整套方案值不值得上的关键闸门。 +2. **POC-2**:若有 N 卡,ANARI+VisRTX 渲同一体,对照 GPU 提速。 +3. POC 通过 → 才动手重编 VTK + 接后端抽象层。 + +## 8. 风险 / 待定 +- OSPRay-GPU(Intel)较新、不如 CPU 路成熟;ANARI/VisRTX 需 NVIDIA 驱动 + VisRTX 库。 +- 各后端传函/外观与 OpenGL 有差异,需重新调一致。 +- 本环境能否编出带光追/ANARI 的 VTK(vcpkg/手动依赖)待 POC-1 验证。 +- CPU+ESS 在低核机上的实际帧率待实测。 + +## 9. 验收 +1. 客户机无论有无显卡/何种显卡,自动选到可跑的后端并出图(OSPRay-CPU 永远兜底)。 +2. 20 条密体总览:逐线分开、不造假、**ESS 后端下不卡**(目标交互 ≥ 可用帧率,质量不降)。 +3. 手动选项只列兼容项;集显默认 CPU。 +4. 数据层(通道插值密体)零改动复用。 diff --git a/src/io/gpr/Gpr3dvVolumeBridge.cpp b/src/io/gpr/Gpr3dvVolumeBridge.cpp index 589fd31..e3b7bcf 100644 --- a/src/io/gpr/Gpr3dvVolumeBridge.cpp +++ b/src/io/gpr/Gpr3dvVolumeBridge.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include @@ -12,6 +13,7 @@ #include "RadarProcessor.h" #include "core/model/ScalarVolumeI16.hpp" +#include "io/gpr/GprGeometry.hpp" // planChannelInterpolation namespace geopro::io::gpr { @@ -68,7 +70,7 @@ double nowMs(std::chrono::steady_clock::time_point t0) { geopro::core::BuiltI16 buildLineVolumeFromGpr3dv(const std::string& lineDir, const std::string& linePrefix, BridgeMetrics* metricsOut, - int coarse) { + int coarse, double targetDy) { const int stride = coarse > 1 ? coarse : 1; // 沿测线下采样步长(≥1) const QString dir = QString::fromLocal8Bit(lineDir.c_str()); const QString base = QString::fromLocal8Bit(linePrefix.c_str()); @@ -107,9 +109,25 @@ geopro::core::BuiltI16 buildLineVolumeFromGpr3dv(const std::string& lineDir, } // 下采样后输出道数(向上取整保留末道附近):nxOut = ceil(traces/stride)。 const int nxOut = (traces + stride - 1) / stride; - const int nx = nxOut; // X=道(沿测线,已按 stride 下采样) - const int ny = channels; // Y=通道(横向) - const int nz = samples; // Z=样本(深度) + const int nx = nxOut; // X=道(沿测线,已按 stride 下采样) + const int nz = samples; // Z=样本(深度) + + // §1 线内通道插值:读各通道真实横向偏移(header.chXOffsets) → 规则网格化 Y 到 targetDy。 + // 绝不跨线;间距/通道数从数据来,不假设。退路(无偏移/未启用)= 逐通道 identity。 + std::vector latOff; + const auto& chx = processed.header.chXOffsets; + if (chx.size() == channels) + for (int c = 0; c < channels; ++c) + latOff.push_back(static_cast(chx[c])); + std::vector rows; + bool interpolated = false; + if (static_cast(latOff.size()) == channels && targetDy > 0.0) { + rows = planChannelInterpolation(latOff, targetDy); + interpolated = (static_cast(rows.size()) != channels); + } + if (rows.empty()) + for (int c = 0; c < channels; ++c) rows.push_back({c, c, 0.0}); + const int ny = static_cast(rows.size()); // Y=通道(横向,可能已插值加密) // 3) 扫处理后值域 → Quant(offset=中点,防溢出)。 const auto tFill = std::chrono::steady_clock::now(); @@ -138,20 +156,26 @@ geopro::core::BuiltI16 buildLineVolumeFromGpr3dv(const std::string& lineDir, quant.scale = (vmax > vmin) ? (vmax - vmin) / 64000.0 : 1.0; quant.offset = 0.5 * (vmin + vmax); // 中点 → 防溢出 - // 4) 逐 (ch,trace,sample) 填体。GPR 立方体为稠密体(每体素有值),无空洞 → 不置 kBlank。 + // 4) 逐 (输出行 j, trace, sample) 填体。每个输出行 = 两侧最近真实通道线性插值 + // (a==b 时即原通道)。GPR 立方体稠密(每体素有值),无空洞 → 不置 kBlank。 // 沿测线按 stride 下采样:输出道 to → 源道 t = to*stride。 geopro::core::BuiltI16 built; built.vol = geopro::core::ScalarVolumeI16(nx, ny, nz); - for (int c = 0; c < channels; ++c) { - const auto& chData = processed.volumeData[c]; + for (int j = 0; j < ny; ++j) { + const auto& chA = processed.volumeData[rows[j].a]; + const auto& chB = processed.volumeData[rows[j].b]; + const double wb = rows[j].wb, wa = 1.0 - wb; for (int to = 0; to < nxOut; ++to) { const int t = to * stride; - const bool hasTrace = t < static_cast(chData.size()); + const bool hasA = t < static_cast(chA.size()); + const bool hasB = t < static_cast(chB.size()); for (int s = 0; s < samples; ++s) { - short v = 0; - if (hasTrace && s < static_cast(chData[t].size())) v = chData[t][s]; - // X=输出道 to、Y=通道 c、Z=样本 s。 - built.vol.at(to, c, s) = quant.toQ(static_cast(v)); + const double va = + (hasA && s < static_cast(chA[t].size())) ? chA[t][s] : 0.0; + const double vb = + (hasB && s < static_cast(chB[t].size())) ? chB[t][s] : 0.0; + // X=输出道 to、Y=输出行 j、Z=样本 s。 + built.vol.at(to, j, s) = quant.toQ(wa * va + wb * vb); } } } @@ -162,7 +186,8 @@ geopro::core::BuiltI16 buildLineVolumeFromGpr3dv(const std::string& lineDir, // 下采样后相邻输出道在世界中跨 stride 个原始道距 → dx ×stride 保持真实尺度。 const double dxBase = h.distanceInc > 1e-9 ? h.distanceInc : 1.0; const double dx = dxBase * stride; - const double dy = channelSpacingY(h, channels); + // 插值后 Y 已规则化到 targetDy 网格;否则用原通道横距。 + const double dy = interpolated ? targetDy : channelSpacingY(h, channels); const double dz = depthSpacingZ(h); built.quant = quant; diff --git a/src/io/gpr/Gpr3dvVolumeBridge.hpp b/src/io/gpr/Gpr3dvVolumeBridge.hpp index b404559..0dd84ef 100644 --- a/src/io/gpr/Gpr3dvVolumeBridge.hpp +++ b/src/io/gpr/Gpr3dvVolumeBridge.hpp @@ -40,11 +40,15 @@ struct BridgeMetrics { // metricsOut 非空时回填维度/量化/spacing/耗时(供 CLI 报告,不编造)。 // coarse(下采样因子,≥1):沿测线(道/X 轴)每 coarse 道取 1,spacing.x ×coarse 保形; // 通道/样本(横向/深度)保留全分辨率。coarse≤1 即全分辨率。磁盘紧张时省空间用。 +// targetDy(米,>0 启用):线内【通道间插值】目标横向间距。读各通道真实横向偏移 +// (header.chXOffsets) 规则网格化 Y 到 targetDy:ny=round(跨度/targetDy)+1,逐行线性 +// 插值(不跨线、不假设道间距)。<=0 或无偏移 → 不插值,Y=原通道数。默认 0.025(2.5cm)。 // 失败(加载失败/立方体为空) → 抛 std::runtime_error。 geopro::core::BuiltI16 buildLineVolumeFromGpr3dv(const std::string& lineDir, const std::string& linePrefix, BridgeMetrics* metricsOut, - int coarse = 1); + int coarse = 1, + double targetDy = 0.025); } // namespace geopro::io::gpr diff --git a/src/io/gpr/GprGeometry.cpp b/src/io/gpr/GprGeometry.cpp index b45b023..8cb6fb9 100644 --- a/src/io/gpr/GprGeometry.cpp +++ b/src/io/gpr/GprGeometry.cpp @@ -1,5 +1,8 @@ #include "io/gpr/GprGeometry.hpp" +#include +#include +#include #include #include #include @@ -22,6 +25,46 @@ std::vector parseChannelXOffsets(const std::string& ordText) { return offsets; } +std::vector planChannelInterpolation( + const std::vector& offsets, double targetDy) { + const int n = static_cast(offsets.size()); + std::vector rows; + // 退化:通道<2 或 targetDy 非法 → 逐通道 identity。 + if (n < 2 || targetDy <= 0.0) { + for (int i = 0; i < n; ++i) rows.push_back({i, i, 0.0}); + return rows; + } + // 按偏移排序的通道索引(端点 / 区间定位用;通道本身可能非有序)。 + std::vector ord(n); + std::iota(ord.begin(), ord.end(), 0); + std::sort(ord.begin(), ord.end(), + [&](int x, int y) { return offsets[x] < offsets[y]; }); + const double mn = offsets[ord.front()]; + const double mx = offsets[ord.back()]; + const double span = mx - mn; + // 跨度已比 targetDy 还密 → 不加密,逐通道 identity(保原通道序)。 + if (span <= targetDy * 0.5) { + for (int i = 0; i < n; ++i) rows.push_back({i, i, 0.0}); + return rows; + } + const int ny = static_cast(std::lround(span / targetDy)) + 1; + int k = 0; // ord 内区间左指针:offsets[ord[k]] <= p + for (int j = 0; j < ny; ++j) { + const double p = mn + static_cast(j) * targetDy; + while (k + 1 < n && offsets[ord[k + 1]] < p) ++k; + if (k + 1 >= n) { // p 在最右通道之外 → 取最右通道 + rows.push_back({ord[n - 1], ord[n - 1], 0.0}); + continue; + } + const int a = ord[k], b = ord[k + 1]; + const double oa = offsets[a], ob = offsets[b]; + double wb = (ob > oa) ? (p - oa) / (ob - oa) : 0.0; + wb = std::clamp(wb, 0.0, 1.0); + rows.push_back({a, b, wb}); + } + return rows; +} + double depthOfSample(int s, const IprHeader& h) { if (h.samples <= 1) return 0.0; // 防除零 const double timeNs = static_cast(s) * h.timeWindowNs / diff --git a/src/io/gpr/GprGeometry.hpp b/src/io/gpr/GprGeometry.hpp index cabcecc..9338833 100644 --- a/src/io/gpr/GprGeometry.hpp +++ b/src/io/gpr/GprGeometry.hpp @@ -11,6 +11,22 @@ namespace geopro::io::gpr { // 解析 .ord 文本,返回末列==1 的有效通道的横向偏移(第 2 列),按文件顺序。 std::vector parseChannelXOffsets(const std::string& ordText); +// 通道间插值方案:一个输出网格行 = (1-wb)*通道[a] + wb*通道[b](线性)。 +// a==b 时即原样取该通道(无插值)。 +struct ChannelInterpRow { + int a = 0; + int b = 0; + double wb = 0.0; +}; + +// 按真实横向偏移 offsets(米,逐通道) + 目标横向间距 targetDy(米) 规则网格化通道维(Y): +// 返回每个输出网格行的线性插值方案。网格在 [min(off), max(off)] 上以 targetDy 等距取 +// ny = round(span/targetDy)+1 行;每行找两侧最近真实通道线性插值(端点外用端点)。 +// 退化(通道<2 / targetDy<=0 / 跨度已比 targetDy 还密) → 逐通道 identity(每通道一行)。 +// 纯函数,便于单测:不依赖任何文件/模型。 +std::vector planChannelInterpolation( + const std::vector& offsets, double targetDy); + // 采样序号 s → 深度(米)。depth = soilVelocity[m/s] * (s * timeWindowNs/(samples-1) * 1e-9) / 2。 // samples<=1 时返回 0 防除零。 double depthOfSample(int s, const IprHeader& h); diff --git a/tests/io/gpr/test_gpr3dv_volume_bridge.cpp b/tests/io/gpr/test_gpr3dv_volume_bridge.cpp index c1bb45c..594c49b 100644 --- a/tests/io/gpr/test_gpr3dv_volume_bridge.cpp +++ b/tests/io/gpr/test_gpr3dv_volume_bridge.cpp @@ -22,7 +22,8 @@ namespace { void writeSyntheticChannel(const fs::path& iprhPath, int samples, int traces, std::int16_t base, double chYOffset, double distanceInterval, double timeWindowNs, - double soilVelocity, int channels) { + double soilVelocity, int channels, + double chXOffset = 1e30) { // 1e30=不写 CH_X_OFFSET(不触发通道插值) std::ofstream h(iprhPath); h << "SAMPLES: " << samples << "\n"; h << "LAST TRACE: " << (traces - 1) << "\n"; @@ -31,6 +32,7 @@ void writeSyntheticChannel(const fs::path& iprhPath, int samples, int traces, h << "SOIL VELOCITY: " << soilVelocity << "\n"; h << "DISTANCE INTERVAL: " << distanceInterval << "\n"; h << "CH_Y_OFFSET: " << chYOffset << "\n"; + if (chXOffset < 1e29) h << "CH_X_OFFSET: " << chXOffset << "\n"; // 横向偏移(插值用) h.close(); fs::path iprbPath = iprhPath; @@ -119,6 +121,39 @@ TEST_F(Gpr3dvBridgeTest, MapsAxesQuantAndSpacing) { geopro::core::ScalarVolumeI16::kBlank); } +TEST_F(Gpr3dvBridgeTest, ChannelInterpDensifiesYThroughBridge) { + // §1 端到端:3 通道带真实横向偏移 CH_X_OFFSET=-0.5/0/+0.5(跨度 1.0)。 + // targetDy=0.25 → ny=round(1.0/0.25)+1=5(从 3 通道插值加密到 5 平面),dy=0.25。 + const int samples = 64, traces = 40, channels = 3; + writeSyntheticChannel(dir_ / "syn_001_A01.iprh", samples, traces, 100, -1.5, + 0.05, 100.0, 0.1, channels, /*chXOffset=*/-0.5); + writeSyntheticChannel(dir_ / "syn_001_A02.iprh", samples, traces, 200, -1.5, + 0.05, 100.0, 0.1, channels, /*chXOffset=*/0.0); + writeSyntheticChannel(dir_ / "syn_001_A03.iprh", samples, traces, 300, -1.5, + 0.05, 100.0, 0.1, channels, /*chXOffset=*/0.5); + + geopro::io::gpr::BridgeMetrics bm; + geopro::core::BuiltI16 built; + ASSERT_NO_THROW({ + built = geopro::io::gpr::buildLineVolumeFromGpr3dv( + dir_.string(), "syn_001", &bm, /*coarse=*/1, /*targetDy=*/0.25); + }); + + EXPECT_EQ(built.vol.ny(), 5); // 3 通道 → 5 网格平面 + EXPECT_EQ(built.vol.ny(), bm.ny); + EXPECT_NEAR(built.spacing[1], 0.25, 1e-9); // dy = targetDy + // 稠密(无 kBlank);插值值落在原通道值域内(线性混合不外溢)。 + EXPECT_NE(built.vol.at(0, 2, 0), geopro::core::ScalarVolumeI16::kBlank); + + // 关闭插值(targetDy=0)→ ny 回到原通道数 3。 + geopro::core::BuiltI16 raw; + ASSERT_NO_THROW({ + raw = geopro::io::gpr::buildLineVolumeFromGpr3dv( + dir_.string(), "syn_001", nullptr, /*coarse=*/1, /*targetDy=*/0.0); + }); + EXPECT_EQ(raw.vol.ny(), channels); +} + TEST_F(Gpr3dvBridgeTest, ThrowsOnMissingLine) { // 目录无任何 _A.iprh → loadImpulseMultiChannel 失败 → 抛异常。 geopro::io::gpr::BridgeMetrics bm; diff --git a/tests/io/gpr/test_gpr_geometry.cpp b/tests/io/gpr/test_gpr_geometry.cpp index 51f9287..8d5decc 100644 --- a/tests/io/gpr/test_gpr_geometry.cpp +++ b/tests/io/gpr/test_gpr_geometry.cpp @@ -1,6 +1,8 @@ #include "io/gpr/GprGeometry.hpp" #include "io/gpr/IprHeader.hpp" #include +#include +#include using namespace geopro::io::gpr; TEST(GprGeometry, ParsesActiveChannelOffsets) { @@ -11,6 +13,69 @@ TEST(GprGeometry, ParsesActiveChannelOffsets) { EXPECT_NEAR(xs[1], -0.581, 1e-6); } +TEST(GprGeometry, ChannelInterpDensifiesToTargetGrid) { + // 14 通道 ~均匀 0.105m,跨度 1.372m;targetDy=0.025 → ny=round(1.372/0.025)+1=56。 + std::vector off; + for (int i = 0; i < 14; ++i) off.push_back(-0.686 + i * 0.1055385); // -0.686..+0.686 + auto rows = planChannelInterpolation(off, 0.025); + EXPECT_EQ(rows.size(), 56u); + // 首行=最左通道(无插值),末行=最右通道。 + EXPECT_EQ(rows.front().a, 0); + EXPECT_NEAR(rows.front().wb, 0.0, 1e-9); + EXPECT_EQ(rows.back().a, 13); + // 中间存在真插值行(wb 落在 (0,1))。 + bool sawInterp = false; + for (const auto& r : rows) + if (r.a != r.b && r.wb > 0.05 && r.wb < 0.95) sawInterp = true; + EXPECT_TRUE(sawInterp); +} + +TEST(GprGeometry, ChannelInterpDynamicCountBySpacing) { + // 不同道间距 → 不同插值条数(动态,不写死)。两通道 0.10m 间距: + // targetDy=0.025 → ny=round(0.10/0.025)+1=5(端点 + 中间 3 条)。 + auto r1 = planChannelInterpolation({0.0, 0.10}, 0.025); + EXPECT_EQ(r1.size(), 5u); + // targetDy=0.05 → ny=round(0.10/0.05)+1=3(端点 + 中间 1 条)。 + auto r2 = planChannelInterpolation({0.0, 0.10}, 0.05); + EXPECT_EQ(r2.size(), 3u); +} + +TEST(GprGeometry, ChannelInterpHandlesUnsortedOffsets) { + // 通道在文件里可能非按偏移有序(任意排布)。偏移乱序但物理跨度相同 → 网格行数、 + // 端点、单调性应不受顺序影响(内部按偏移排序定位)。 + std::vector sorted = {-0.4, -0.2, 0.0, 0.2, 0.4}; + std::vector shuffled = {0.0, 0.4, -0.4, 0.2, -0.2}; // 同偏移集合,乱序 + auto rs = planChannelInterpolation(sorted, 0.1); + auto ru = planChannelInterpolation(shuffled, 0.1); + ASSERT_EQ(rs.size(), ru.size()); // ny 仅取决于跨度,与顺序无关 + EXPECT_EQ(rs.size(), 9u); // round(0.8/0.1)+1 + // 真正的不变量:每行的【有效插值位置】=(1-wb)*off[a]+wb*off[b] 应等于网格位置 + // p=min+j*targetDy(与通道在文件里的顺序无关)。末行恰落在 max 时会以 [次末,末] 夹 + // 且 wb=1(值=末通道),故不能直接断言 a==末通道,要看有效位置。 + auto effPos = [](const std::vector& off, const ChannelInterpRow& r) { + return (1.0 - r.wb) * off[r.a] + r.wb * off[r.b]; + }; + for (std::size_t j = 0; j < ru.size(); ++j) { + const double p = -0.4 + static_cast(j) * 0.1; + EXPECT_NEAR(effPos(sorted, rs[j]), p, 1e-9); // 有序 + EXPECT_NEAR(effPos(shuffled, ru[j]), p, 1e-9); // 乱序:同一结果 + // a/b 偏移夹住网格位置。 + const double oa = shuffled[ru[j].a], ob = shuffled[ru[j].b]; + EXPECT_LE(std::min(oa, ob) - 1e-9, p); + EXPECT_GE(std::max(oa, ob) + 1e-9, p); + } +} + +TEST(GprGeometry, ChannelInterpDegenerateIdentity) { + // 单通道 / 已比 targetDy 密 / targetDy<=0 → 逐通道 identity。 + EXPECT_EQ(planChannelInterpolation({0.5}, 0.025).size(), 1u); + auto dense = planChannelInterpolation({0.0, 0.01}, 0.025); // 跨度 #include #include +#include #include +#include +#include #include #include #include @@ -71,12 +76,18 @@ #include #include #include +#include +#include +#include +#include +#include #include #include #include #include #include #include +#include #include #include #include @@ -842,7 +853,8 @@ struct LineBuildResult { // 异常(加载失败/立方体空/短桩线维度退化)由调用方捕获,不在此中断批量。 LineBuildResult buildOneLine(const std::string& lineDir, const std::string& linePrefix, - const std::string& out, int levels, int coarse) { + const std::string& out, int levels, int coarse, + double targetDy) { LineBuildResult r; r.prefix = linePrefix; @@ -854,7 +866,7 @@ LineBuildResult buildOneLine(const std::string& lineDir, Stopwatch swBridge; geopro::io::gpr::BridgeMetrics bm; geopro::core::BuiltI16 built = geopro::io::gpr::buildLineVolumeFromGpr3dv( - lineDir, linePrefix, &bm, coarse); + lineDir, linePrefix, &bm, coarse, targetDy); const double bridgeMs = swBridge.elapsedMs(); const std::int64_t nx = built.vol.nx(), ny = built.vol.ny(), @@ -941,6 +953,161 @@ LineBuildResult buildOneLine(const std::string& lineDir, return r; } +// ess-stat:实测一条线密体的"空体素跳过(ESS)潜力"——按块(默认 16³)算 min/max,统计有多少块 +// 整段值落在"近零背景带"(在 V 形不透明传函里≈透明)内 → 这些块 ESS 会整块跳过。空块占比≈ESS +// 提速潜力。零依赖、跑在真实数据上,是上不上 OSPRay/ESS 的关键决策数。 +int cmdEssStat(int argc, char** argv) { + const Args a = parseArgs(argc, argv, 2); + if (a.positional.size() < 2) { + std::cerr << "用法: gpr_poc ess-stat [--coarse 8] " + "[--targetDy 0.025] [--block 16]\n"; + return 2; + } + const std::string lineDir = a.positional[0]; + const std::string prefix = a.positional[1]; + const int coarse = std::stoi(a.get("coarse", "8")); + const double targetDy = std::stod(a.get("targetDy", "0.025")); + const int B = std::max(1, std::stoi(a.get("block", "16"))); + + geopro::io::gpr::BridgeMetrics bm; + geopro::core::BuiltI16 built = + geopro::io::gpr::buildLineVolumeFromGpr3dv(lineDir, prefix, &bm, coarse, + targetDy); + const auto& vol = built.vol; + const int nx = vol.nx(), ny = vol.ny(), nz = vol.nz(); + const double center = built.quant.offset; + const double half = + std::max(1e-9, 0.5 * (built.vmaxPhys - built.vminPhys)); + std::cout << "[ess-stat] " << prefix << " 体维度=" << nx << "x" << ny << "x" + << nz << " 值域=[" << built.vminPhys << "," << built.vmaxPhys + << "] 块=" << B << "³\n"; + + // 几个"透明带半宽"阈值(相对半值域):背景带越宽,可跳块越多(但太宽会跳掉弱反射)。 + const double taus[] = {0.05, 0.10, 0.15, 0.20, 0.30}; + constexpr int NT = 5; + long total = 0, skip[NT] = {0, 0, 0, 0, 0}; + // 同时统计"近零体素占比"(单体素 < 0.1 半值域)作直觉参考。 + long voxNear = 0, voxTotal = 0; + + for (int z0 = 0; z0 < nz; z0 += B) + for (int y0 = 0; y0 < ny; y0 += B) + for (int x0 = 0; x0 < nx; x0 += B) { + short mn = std::numeric_limits::max(); + short mx = std::numeric_limits::min(); + const int x1 = std::min(x0 + B, nx), y1 = std::min(y0 + B, ny), + z1 = std::min(z0 + B, nz); + for (int z = z0; z < z1; ++z) + for (int y = y0; y < y1; ++y) + for (int x = x0; x < x1; ++x) { + const short v = vol.at(x, y, z); + if (v < mn) mn = v; + if (v > mx) mx = v; + const double p = built.quant.toPhys(v); + ++voxTotal; + if (std::abs(p - center) < 0.10 * half) ++voxNear; + } + ++total; + const double pmn = built.quant.toPhys(mn); + const double pmx = built.quant.toPhys(mx); + const double dev = std::max(std::abs(pmn - center), std::abs(pmx - center)); + for (int t = 0; t < NT; ++t) + if (dev < taus[t] * half) ++skip[t]; + } + + std::cout << "[ess-stat] 近零体素占比(<0.1 半值域) = " + << (voxTotal ? 100.0 * voxNear / voxTotal : 0.0) << "%\n"; + std::cout << "[ess-stat] 可跳块占比(块内全段落在透明带) / 对应 ESS 理论提速:\n"; + for (int t = 0; t < NT; ++t) { + const double frac = total ? static_cast(skip[t]) / total : 0.0; + const double speedup = frac < 0.999 ? 1.0 / (1.0 - frac) : 999.0; + std::cout << " 透明带半宽 " << static_cast(taus[t] * 100) + << "% 半值域 → 可跳块 " << 100.0 * frac << "% → 理论上限 ~" + << speedup << "×\n"; + } + std::cout << "[ess-stat] 注:理论上限=1/(1-可跳块占比),实际 OSPRay 还有 SIMD/光线相干等增益," + "但非空块(反射层)仍要采。可跳块>80% 即 ESS 大幅值得上。\n"; + return 0; +} + +vtkSmartPointer makeOffscreenWindow(int w, int h); // 前置声明(定义在后) + +// passcost:确诊 P11/P12"各线独立 mapper + LOD 仍 0.5fps"卡在哪。 +// N 个独立 vtkSmartVolumeMapper(GPU),每个渲一个 size³ 体,测离屏稳态 fps vs N。 +// --overlap 0:N 个体【铺开】不重叠(光线各穿 ~1 个)→ 隔离"每遍固定开销"(嫌疑2); +// --overlap 1:N 个体【叠在一起】重叠(光线穿 N 个)→ 隔离"重叠采样"(嫌疑3)。 +// 铺开随 N 线性掉=固定开销(嫌疑2,最坏);铺开不掉、叠加掉=重叠(嫌疑3);都不掉=选区没调小(嫌疑1,最好)。 +int cmdPassCost(int argc, char** argv) { + const Args a = parseArgs(argc, argv, 0); + const int S = std::stoi(a.get("size", "64")); // 每体边长(模拟 LOD 削小后的小区) + const bool overlap = std::stoi(a.get("overlap", "0")) != 0; + const int W = 1400, H = 900; + + // 合成一个 S³ 体(含非平凡图案,避免被早终止/空跳优化掉)。 + auto img = vtkSmartPointer::New(); + img->SetDimensions(S, S, S); + img->SetSpacing(1.0, 1.0, 1.0); + vtkNew arr; + arr->SetName("v"); + arr->SetNumberOfTuples(static_cast(S) * S * S); + for (int z = 0; z < S; ++z) + for (int y = 0; y < S; ++y) + for (int x = 0; x < S; ++x) { + const double v = 200.0 * std::sin(0.3 * x) * std::cos(0.3 * z) + 300.0; + arr->SetValue((static_cast(z) * S + y) * S + x, + static_cast(v)); + } + img->GetPointData()->SetScalars(arr); + + auto prop = vtkSmartPointer::New(); + vtkNew col; + col->AddRGBPoint(0, 0.1, 0.1, 0.2); + col->AddRGBPoint(600, 0.95, 0.95, 0.9); + vtkNew op; + op->AddPoint(0, 0.0); + op->AddPoint(300, 0.05); + op->AddPoint(600, 0.4); + prop->SetColor(col); + prop->SetScalarOpacity(op); + prop->SetInterpolationTypeToLinear(); + + std::cout << "=== passcost:N 个独立 GPU mapper × " << S << "³ 体 (" + << (overlap ? "叠在一起/重叠" : "铺开/不重叠") << ") ===\n"; + std::cout << "N fps(离屏稳态)\n"; + for (int N : {1, 3, 5, 10, 20}) { + auto rw = makeOffscreenWindow(W, H); + vtkNew ren; + ren->SetBackground(0.05, 0.05, 0.08); + rw->AddRenderer(ren); + std::vector> ms; + std::vector> vs; + for (int n = 0; n < N; ++n) { + auto m = vtkSmartPointer::New(); + m->SetRequestedRenderMode(vtkSmartVolumeMapper::GPURenderMode); + m->SetInputData(img); + auto v = vtkSmartPointer::New(); + v->SetMapper(m); + v->SetProperty(prop); + // 铺开:沿 X 排开 S 间距(不重叠);重叠:全堆在原点。 + if (!overlap) v->SetPosition(static_cast(n) * S * 1.05, 0, 0); + ren->AddVolume(v); + ms.push_back(m); + vs.push_back(v); + } + ren->ResetCamera(); + rw->Render(); // 预热(编译着色器/上传纹理) + rw->Render(); + Stopwatch sw; + const int F = 30; + for (int f = 0; f < F; ++f) rw->Render(); + const double ms_per = sw.elapsedMs(); + const double fps = ms_per > 0 ? F * 1000.0 / ms_per : 0.0; + std::cout << N << " " << fps << "\n"; + } + std::cout << "判读:铺开随 N 线性掉=每遍固定开销(嫌疑2,最坏,逼架构取舍);" + "铺开不掉/叠加掉=重叠(嫌疑3);都不怎么掉=0.5fps 是选区没调小(嫌疑1,最好,可修)。\n"; + return 0; +} + int cmdBuildLine(int argc, char** argv) { const Args a = parseArgs(argc, argv, 2); if (a.positional.size() < 2) { @@ -954,12 +1121,13 @@ int cmdBuildLine(int argc, char** argv) { const std::string linePrefix = a.positional[1]; const int levels = std::stoi(a.get("levels", "3")); const int coarse = std::stoi(a.get("coarse", "1")); + const double targetDy = std::stod(a.get("targetDy", "0.025")); const std::string out = a.get("out", (fs::temp_directory_path() / "gpr_store_line").string()); try { const LineBuildResult r = - buildOneLine(lineDir, linePrefix, out, levels, coarse); + buildOneLine(lineDir, linePrefix, out, levels, coarse, targetDy); if (!r.ok) { std::cerr << "[build-line] 跳过: " << r.reason << "\n"; return 1; @@ -988,6 +1156,7 @@ int cmdBuildAll(int argc, char** argv) { const int levels = std::stoi(a.get("levels", "3")); const int coarse = std::stoi(a.get("coarse", "1")); const double minFreeGB = std::stod(a.get("minFreeGB", "3")); + const double targetDy = std::stod(a.get("targetDy", "0.025")); // 1) 发现所有测线前缀:扫 *__A.iprh,取 "<...>_" 部分(去 _A)。 std::set prefixSet; @@ -1033,7 +1202,7 @@ int cmdBuildAll(int argc, char** argv) { LineBuildResult r; r.prefix = prefix; try { - r = buildOneLine(lineDir, prefix, out, levels, coarse); + r = buildOneLine(lineDir, prefix, out, levels, coarse, targetDy); } catch (const std::exception& e) { r.ok = false; r.reason = std::string("异常: ") + e.what(); @@ -1523,17 +1692,26 @@ class CapturingOutputWindow : public vtkOutputWindow { s.find("MAX_3D_TEXTURE_SIZE") != std::string::npos) { textureError_ = true; } + // 纹理【单元数】超限(multi-volume 一个包挂太多体)——与 3D 纹理【尺寸】超限区分, + // 供 view-all 自动减小每包体数(K)退避,直到本机硬件不再报错。 + if (s.find("number of textures") != std::string::npos || + s.find("Hardware does not support the number") != std::string::npos) { + textureCountError_ = true; + } } // 仍透传到 stderr,便于人工查看。 if (txt) std::cerr << txt; } bool textureError() const { return textureError_; } + bool textureCountError() const { return textureCountError_; } + void resetTextureCountError() { textureCountError_ = false; } const std::string& captured() const { return captured_; } private: std::string captured_; bool textureError_ = false; + bool textureCountError_ = false; }; vtkStandardNewMacro(CapturingOutputWindow); @@ -1795,12 +1973,18 @@ geopro::core::ColorScale makeGrayEnhancedColorScale(double vmin, double vmax) { return cs; } +// 背景压制强度(--bgSuppress,0..1):0=原观感(近零背景压低但可见);越大→近零背景越 +// 透明 + 中心透明死区越宽,只留中/强反射层 → "压背景、突出反射"(业界标准传函做法)。 +// 注意:压太狠会连带抹掉弱异常(与 ESS 阈值同取舍);本系统弱异常靠切片抓,三维体压背景风险低。 +double gBgSuppress = 0.0; + // 「实体感」不透明度包络(Task 12d gallery):与 structural 双端斜坡不同,这里让 // 中高值段普遍可见——背景(近零)仍压低但不归零,中高段从 floorOpacity 平滑升到 // maxOpacity,使体读起来像半透明实心块、内部层次(而非只剩两端薄壳)可见。 // floorOpacity:近零背景的最低不透明度(0.05~0.12,压住但不消失) // maxOpacity :强反射端的不透明度峰值(0.85 时近实心) // midOpacity :中值段(半幅处)的不透明度(0.3~0.5,决定「半透明实心」观感) +// gBgSuppress :见上,压低 floor + 加宽中心透明死区。 vtkSmartPointer makeSolidVolumeProperty( const geopro::core::Quant& q, const geopro::core::ColorScale& cs, double vminPhys, double vmaxPhys, double floorOpacity, double midOpacity, @@ -1821,6 +2005,11 @@ vtkSmartPointer makeSolidVolumeProperty( // 不透明度:V 形(中段=零附近背景=floor,正负两端=max),但全程 ≥floor 且中值 // 段≈mid → 整体半透明实心、内部层次可见,而非两端薄壳。 + // --bgSuppress F:F>0 时把近零背景 floor 压到 floor*(1-F),并在中心开 ±(F*0.45*half) 的 + // 全透明死区 → 压背景、突出中/强反射(F=0 即原观感)。 + const double bg = std::clamp(gBgSuppress, 0.0, 1.0); + const double cFloor = floorOpacity * (1.0 - bg); // 背景压低(F→1 趋 0) + const double dead = bg * 0.45; // 中心透明死区半宽(占 half 的比例) vtkNew opacity; opacity->AddPoint( static_cast(geopro::core::ScalarVolumeI16::kBlank), 0.0); @@ -1828,7 +2017,13 @@ vtkSmartPointer makeSolidVolumeProperty( const double half = 0.5 * (qmaxD - qminD); opacity->AddPoint(qminD, maxOpacity); // 强负反射:近实心 opacity->AddPoint(qmid - 0.55 * half, midOpacity); // 中负段:半透明实心 - opacity->AddPoint(qmid, floorOpacity); // 近零背景:压低但可见 + if (dead > 0.0) { + opacity->AddPoint(qmid - dead * half, 0.0); // 死区左沿:透明 + opacity->AddPoint(qmid, 0.0); // 中心背景:透明 + opacity->AddPoint(qmid + dead * half, 0.0); // 死区右沿:透明 + } else { + opacity->AddPoint(qmid, cFloor); // 近零背景:压低但可见 + } opacity->AddPoint(qmid + 0.55 * half, midOpacity); // 中正段:半透明实心 opacity->AddPoint(qmaxD, maxOpacity); // 强正反射:近实心 @@ -3967,6 +4162,244 @@ int cmdViewGallery(const std::string& dir, int frames, return rc; } +// buildLineProperty 定义在 view-all 段(本函数之后)→ 前置声明,供 slice 渲染体用。 +vtkSmartPointer buildLineProperty( + const geopro::data::StoreMeta& m, vtkImageData* basis, double sharedVmin, + double sharedVmax); + +// ============================================================================ +// slice:复用桌面端 SliceTool(同一份 geopro_render 代码,非重写)在 GPR 体上切片 +// ============================================================================ +// 加载一条线的体图 → 开真窗口 → 挂 SliceTool(4 种 axis:updown=深度切片C-scan/ +// frontback=纵向剖面radargram/leftright=横向剖面/oblique=任意斜切)。切面可拖动/旋转/滚轮推进。 +int cmdSlice(int argc, char** argv) { + using geopro::render::interact::SliceAxis; + using geopro::render::interact::SliceTool; + const Args a = parseArgs(argc, argv, 2); // 跳过 exe[0] + 命令名[1] + if (a.positional.empty()) { + std::cerr << "用法: gpr_poc slice [--axis updown|frontback|leftright|oblique] " + "[--exagg 8] [--bgSuppress 0.5]\n" + " 渲染半透明三维体 + 切面(在体内切,移动看不同剖面,同桌面端)。\n" + " updown=深度切片C-scan(水平面), frontback=纵向剖面radargram(沿线竖直面),\n" + " leftright=横向剖面(垂直线竖直面), oblique=任意角度斜切。\n" + " bgSuppress 越大体越透(更易看清体内切面)。\n"; + return 2; + } + const std::string storeDir = a.positional[0]; + const std::string axisStr = a.get("axis", "frontback"); + const double exagg = std::stod(a.get("exagg", "8")); + const std::string shot = a.get("shot", ""); // 非空=离屏出图验证(不开真窗口) + + geopro::render::ViewAdaptiveVolumeSource source(storeDir, /*exagg=*/1.0); + source.setAspect(1400.0 / 900.0); + source.setViewportHeight(900); + vtkImageData* base = source.baseImage(); + if (base == nullptr) { + std::cerr << "[slice] 底图为空,无法切片(store 有效?)\n"; + return 1; + } + const geopro::data::StoreMeta& meta = source.meta(); + + // 色阶与体绘制同口径(2/98 分位 + pickColor),保证切片配色与三维体一致。 + const GalleryVariant& v = kViewDefaultVariant; + double vmin = meta.vminPhys, vmax = meta.vmaxPhys; + const ScalarPercentiles pc = + sampleScalarPercentiles(base, meta.quant, 0.02, 0.98); + if (pc.samples > 0) { + vmin = pc.lo; + vmax = pc.hi; + } + const geopro::core::ColorScale cs = pickColor(v.color, vmin, vmax); + + // 把 exagg 烤进 Z spacing(显示用,与桌面端"VE 烤入 origin/spacing"同口径)。 + // ShallowCopy 不动 source 的底图;SliceTool 持非拥有指针,img 在本函数作用域内保活。 + auto img = vtkSmartPointer::New(); + img->ShallowCopy(base); + double sp[3]; + img->GetSpacing(sp); + img->SetSpacing(sp[0], sp[1], sp[2] * exagg); + + SliceAxis axis = SliceAxis::UpDown; + if (axisStr == "frontback") axis = SliceAxis::FrontBack; + else if (axisStr == "leftright") axis = SliceAxis::LeftRight; + else if (axisStr == "oblique") axis = SliceAxis::Oblique; + + vtkNew ren; + ren->SetBackground(0.06, 0.06, 0.09); + vtkNew rw; + rw->AddRenderer(ren); + rw->SetSize(1400, 900); + rw->SetWindowName("gpr_poc slice —— 三维体 + 切面(复用桌面端 SliceTool)"); + if (!shot.empty()) rw->SetOffScreenRendering(1); // 验证模式:离屏 + + // 【关键】渲染三维体本身——桌面端切片是"在渲染出的三维体上切",切面在体内切、移动看不同剖面。 + // 体设半透明(bgSuppress 默认压背景)→ 能透过体看见体内的切面。 + gBgSuppress = std::clamp(std::stod(a.get("bgSuppress", "0.5")), 0.0, 1.0); + vtkSmartPointer volProp = buildLineProperty(meta, base, vmin, vmax); + vtkNew volMapper; + volMapper->SetInputData(img); + vtkNew vol; + vol->SetMapper(volMapper); + vol->SetProperty(volProp); + ren->AddVolume(vol); + + // 体包围盒轮廓(上下文:看切面在体里的位置)。 + vtkNew outline; + outline->SetInputData(img); + vtkNew omap; + omap->SetInputConnection(outline->GetOutputPort()); + vtkNew oact; + oact->SetMapper(omap); + oact->GetProperty()->SetColor(0.4, 0.4, 0.5); + ren->AddActor(oact); + + vtkNew iren; + iren->SetRenderWindow(rw); + vtkNew style; + iren->SetInteractorStyle(style); + iren->Initialize(); // SliceTool 构造即 On(),须先有活 interactor + + // 复用桌面端 SliceTool(geopro_render 同一份代码)。 + SliceTool slice(img, iren, axis, cs, vmin, vmax); + + int d[3]; + img->GetDimensions(d); + std::cout << "[slice] axis=" << axisStr << " 体维度=" << d[0] << "x" << d[1] + << "x" << d[2] << " 值域=[" << vmin << "," << vmax << "]\n"; + + // 相机正对切面(沿法向看,否则极扁的体侧视成一条线、看不到切片纹理)。 + double bnd[6]; + img->GetBounds(bnd); + const double cx = 0.5 * (bnd[0] + bnd[1]), cy = 0.5 * (bnd[2] + bnd[3]), + cz = 0.5 * (bnd[4] + bnd[5]); + vtkCamera* cam = ren->GetActiveCamera(); + cam->SetFocalPoint(cx, cy, cz); + if (axis == SliceAxis::UpDown) { // X-Y 面,法向 Z → 俯视 + cam->SetPosition(cx, cy, cz + 1.0); + cam->SetViewUp(0, 1, 0); + } else if (axis == SliceAxis::LeftRight) { // Y-Z 面,法向 X → 侧视 + cam->SetPosition(cx + 1.0, cy, cz); + cam->SetViewUp(0, 0, 1); + } else { // FrontBack/Oblique:X-Z 面,法向 Y → 正视(radargram) + cam->SetPosition(cx, cy - 1.0, cz); + cam->SetViewUp(0, 0, 1); + } + ren->ResetCamera(); // 沿该朝向拟合距离框住体 + if (!shot.empty()) { + rw->Render(); + savePng(rw, shot); + std::cout << "[slice] 离屏出图: " << shot << "(axis=" << axisStr << ")\n"; + return 0; + } + std::cout << "[slice] 打开真窗口(半透明三维体 + 切面)。左键旋转 / 滚轮缩放 / " + "拖切面移动看不同剖面 / q 退出。\n"; + rw->Render(); + iren->Start(); + // 干净拆除:先 Off 切面 widget,再 Finalize GL 上下文,避免关窗时 widget 在死上下文上 + // 重建 shader 报错(depth-blit / texture 告警)。 + slice.close(); + rw->Finalize(); + std::cout << "[slice] 窗口关闭,退出。\n"; + return 0; +} + +// ============================================================================ +// C-scan 深度切片(--slice):在 20 条合成体上加"水平深度切面"——每条线贡献它在该深度的 +// 水平幅值图,按各自世界变换摆进场景 → 拼成"整条路某深度的俯视图"(GPR 找管线/空洞的主力视图)。 +// 深度方向(Z)在"绕竖直 Z 旋转 + 平移"的摆放下不变,故一个世界深度面 = 各线同一 z 切片, +// 直接用 vtkImageActor(显示 z=k 切片) + SetUserTransform(线的世界变换) 摆位,无需斜切 reslice。 +// 键盘 Up/Down 改深度 → 扫过不同深度。逐线真实数据、不合并、空隙透明。 +// ============================================================================ +// 全局世界切面:定义【一个】世界轴对齐的薄板(slab),把【每条线】用各自 worldInv 作 ResliceTransform +// reslice 到这【同一个世界面】上采样(覆盖外=透明),再 blend 合成一张 → 真正一整片切面。 +// 整片沿扫描轴移动 = 所有线被同一个面同步扫过(无论朝向)。 +// depth(updown):沿 world Z 扫、面=X-Y;cross(leftright):沿 world Y 扫、面=X-Z(横穿所有线); +// radargram(frontback):沿 world X 扫、面=Y-Z。 +struct GlobalSlice { + // 竖直剖面(横切/顺路):全局面 reslice 各线 → 同一世界面。 + std::vector> reslices; + // 深度(水平):逐线整张水平切面(共面、全覆盖、原生分辨率)。lineActors 非空即此模式。 + std::vector> lineActors; + std::vector> winv; // 各线 world→local(算深度 z 索引) + std::vector> lorg, lspc; + std::vector> ldim; + int sweepAxis = 1; // world 扫描轴 0=X 1=Y 2=Z + int inA = 0, inB = 2; // 面内两个 world 轴 + double sweepWorld = 0, sweepMin = 0, sweepMax = 1; + double footLo[3] = {0, 0, 0}, footHi[3] = {0, 0, 0}; // footprint AABB(取景用) + vtkSmartPointer planeSrc; // 可见的切面矩形(淡),让"面"看得见 + vtkRenderWindow* rw = nullptr; + std::vector volumes; + double volUnit = 20.0; + bool volOn = true; + + void applySlice() { + if (!lineActors.empty()) { // 深度逐线模式:每线设 z=k 那一片(共面、全覆盖) + for (std::size_t i = 0; i < lineActors.size(); ++i) { + double W[3] = {0.5 * (footLo[0] + footHi[0]), 0.5 * (footLo[1] + footHi[1]), + 0.5 * (footLo[2] + footHi[2])}; + W[sweepAxis] = sweepWorld; + double L[3]; + winv[i]->TransformPoint(W, L); + const int k = std::clamp( + static_cast(std::lround((L[2] - lorg[i][2]) / lspc[i][2])), 0, + ldim[i][2] - 1); + lineActors[i]->SetDisplayExtent(0, ldim[i][0] - 1, 0, ldim[i][1] - 1, k, k); + } + } else { + for (auto& rs : reslices) { + double o[3]; + rs->GetOutputOrigin(o); + o[sweepAxis] = sweepWorld; // 整片世界面沿扫描轴移动(所有线同步) + rs->SetOutputOrigin(o); + } + } + if (planeSrc) { // 同步移动可见的切面矩形 + double o[3] = {footLo[0], footLo[1], footLo[2]}; + o[sweepAxis] = sweepWorld; + double p1[3] = {o[0], o[1], o[2]}, p2[3] = {o[0], o[1], o[2]}; + p1[inA] = footHi[inA]; + p2[inB] = footHi[inB]; + planeSrc->SetOrigin(o); + planeSrc->SetPoint1(p1); + planeSrc->SetPoint2(p2); + } + if (rw) rw->Render(); + } + void applyVolume() { + for (auto* v : volumes) { + if (v == nullptr) continue; + v->SetVisibility(volOn ? 1 : 0); + if (v->GetProperty()) + v->GetProperty()->SetScalarOpacityUnitDistance(volUnit); + } + if (rw) rw->Render(); + } +}; + +void cscanOnKey(vtkObject* caller, unsigned long, void* cd, void*) { + auto* st = static_cast(cd); + auto* iren = static_cast(caller); + const std::string key = iren->GetKeySym() ? iren->GetKeySym() : ""; + const double step = (st->sweepMax - st->sweepMin) * 0.02; + if (key == "Up") { + st->sweepWorld = std::min(st->sweepMax, st->sweepWorld + step); + st->applySlice(); + } else if (key == "Down") { + st->sweepWorld = std::max(st->sweepMin, st->sweepWorld - step); + st->applySlice(); + } else if (key == "v" || key == "V") { + st->volOn = !st->volOn; + st->applyVolume(); + } else if (key == "bracketright") { + st->volUnit *= 1.5; + st->applyVolume(); + } else if (key == "bracketleft") { + st->volUnit = std::max(0.5, st->volUnit / 1.5); + st->applyVolume(); + } +} + // ============================================================================ // view-all:全部独立体按精确 CGCS2000 坐标/朝向摆进同一 3D 场景一起渲(测区全貌)(P7→P9) // ============================================================================ @@ -4003,15 +4436,25 @@ struct PlacedSource { vtkSmartPointer world; // T:Scale(1,1,exagg)→RotateZ→Translate vtkSmartPointer worldInv; // T⁻¹(相机逆变换到局部帧) vtkSmartPointer prop; // 逐线 2/98 分位标定的传函 - // P12:每线只渲【一个】体(不再底图层 + 高清层叠双渲,20 线=40 体爆 1fps)。 - // 该体输入由引擎按相机选的 LOD 决定:起步喂 baseImage()(粗 whole,小且不空), - // 引擎备好更合适的 currentImages() 后整图换上 → 任何时刻每线 ≤1 体 → 最多 20 体。 - vtkSmartPointer volume; // 唯一体(套 T) - vtkSmartPointer mapper; + // 单遍合成(方案 A):每条线仍是【独立插值】的体,但 20 条不再各自一个 mapper(=20 遍 + // ray-cast,物理 20× 卡死),而是全部作为【同一个 vtkGPUVolumeRayCastMapper 的不同 + // 端口】注册进一个 vtkMultiVolume → 单遍 ray-cast 一次性合成(重叠也只穿一遍)。 + // ps.volume 只承载该端口的世界变换 T + 逐线传函(不再持自己的 mapper);该端口的 + // vtkImageData 由引擎按相机选的 LOD 决定,经 multiMapper->SetInputDataObject(port,img) 换上。 + vtkSmartPointer volume; // 该线的体(套 T + 逐线 prop) vtkSmartPointer currentImg; // 持当前单图引用(mapper 仅持裸指针) - double worldBounds[6] = {0, 0, 0, 0, 0, 0}; // 该线(含 T+底图盒)的世界 AABB(视锥裁剪用) - bool culled = false; // 本帧是否被视锥裁掉(两层皆隐 → 真跳过) + // LOD 中心架构(确诊后):每条线【独立】自己的 mapper(passcost 证明 N 遍开销温和), + // 配视野 LOD 引擎换区 + 停手才重建。弃 multi-volume 单遍(它为单遍关掉了 LOD→渲整卷大贴图→卡)。 + vtkSmartPointer mapper; // 每线独立 GPU mapper(弃 multi-volume 单遍) + vtkGPUVolumeRayCastMapper* multiMapper = nullptr; // 旧 multi-volume 残留(保编译,不再用) + vtkMultiVolume* multiVol = nullptr; // 旧残留 + int port = 0; // 旧残留 + + vtkSmartPointer fullYImg; // §3:全 Y 密度底图(通道 LOD 抽平面的源) + int yStride = 1; // §3:当前通道维 LOD stride(1=全密度) + + double worldBounds[6] = {0, 0, 0, 0, 0, 0}; // 该线(含 T+底图盒)的世界 AABB(取景用) }; // 由 起点+航向+Z 夸张 → 世界刚体变换 T。 @@ -4032,10 +4475,15 @@ vtkSmartPointer makeLineTransform(double startX, double startY, // 逐线传函:从该线常驻底图(整卷代表)实测 2/98 分位标定色阶/不透明度端点 + 梯度门, // 与单条 view 的传函标定同口径(底图非空恒可标定;退化回退全量化域)。 vtkSmartPointer buildLineProperty( - const geopro::data::StoreMeta& m, vtkImageData* basis) { + const geopro::data::StoreMeta& m, vtkImageData* basis, + double sharedVmin = 0.0, double sharedVmax = 0.0) { const GalleryVariant& v = kViewDefaultVariant; // P4 默认醒目版(var4) double vmin = m.vminPhys, vmax = m.vmaxPhys; - if (basis != nullptr) { + if (sharedVmin < sharedVmax) { + // #6:传入【统一物理幅值范围】→ 同一幅值在所有线上映射到同一颜色(跨线可比)。 + vmin = sharedVmin; + vmax = sharedVmax; + } else if (basis != nullptr) { const ScalarPercentiles pc = sampleScalarPercentiles(basis, m.quant, 0.02, 0.98); if (pc.samples > 0) { @@ -4051,12 +4499,144 @@ vtkSmartPointer buildLineProperty( : nullptr); } +// 根因验证用:把一张体沿 Y(通道维)抽半 → 同范围、同 X/Z/origin,仅 Y 平面数减半、 +// Y spacing 翻倍。这正是"通道 LOD 换密度"的等价改动(同范围、不同 Y 密度),用来确定 +// 就地换端口贴图在 multi-volume 里能否算对。 +vtkSmartPointer downsampleY(vtkImageData* in) { + int d[3]; + in->GetDimensions(d); + double sp[3], org[3]; + in->GetSpacing(sp); + in->GetOrigin(org); + const int ny2 = std::max(1, (d[1] + 1) / 2); + auto out = vtkSmartPointer::New(); + out->SetDimensions(d[0], ny2, d[2]); + out->SetOrigin(org); + out->SetSpacing(sp[0], sp[1] * 2.0, sp[2]); // Y 间距翻倍(平面数减半) + vtkNew arr; + arr->SetName("v"); + arr->SetNumberOfTuples(static_cast(d[0]) * ny2 * d[2]); + auto* inArr = vtkShortArray::SafeDownCast(in->GetPointData()->GetScalars()); + for (int k = 0; k < d[2]; ++k) + for (int j2 = 0; j2 < ny2; ++j2) { + const int j = std::min(j2 * 2, d[1] - 1); + for (int i = 0; i < d[0]; ++i) { + const vtkIdType idIn = + (static_cast(k) * d[1] + j) * d[0] + i; + const vtkIdType idOut = + (static_cast(k) * ny2 + j2) * d[0] + i; + arr->SetValue(idOut, inArr ? inArr->GetValue(idIn) : 0); + } + } + out->GetPointData()->SetScalars(arr); + return out; +} + +// §3 通道维 LOD:沿 Y(通道维)按 stride 抽平面 → 同范围、保包围盒(ny'×spY×stride≈ny×spY), +// stride=1 即原图。stride∈{1,2,4,8} → 显示 全/半/1-4/1-8 通道密度。bbox 不变 → multi-volume +// 就地换不破坏(已 --swapTest 验证)。stride==1 时直接返回原图(零拷贝)。 +vtkSmartPointer subsampleYStride(vtkImageData* in, int stride) { + if (in == nullptr || stride <= 1) return in; + int d[3]; + in->GetDimensions(d); + double sp[3], org[3]; + in->GetSpacing(sp); + in->GetOrigin(org); + const int ny2 = std::max(1, (d[1] + stride - 1) / stride); + auto out = vtkSmartPointer::New(); + out->SetDimensions(d[0], ny2, d[2]); + out->SetOrigin(org); + out->SetSpacing(sp[0], sp[1] * stride, sp[2]); // Y 间距 ×stride → 跨度不变 + vtkNew arr; + arr->SetName("v"); + arr->SetNumberOfTuples(static_cast(d[0]) * ny2 * d[2]); + auto* inArr = vtkShortArray::SafeDownCast(in->GetPointData()->GetScalars()); + for (int k = 0; k < d[2]; ++k) + for (int j2 = 0; j2 < ny2; ++j2) { + const int j = std::min(j2 * stride, d[1] - 1); + for (int i = 0; i < d[0]; ++i) { + const vtkIdType idIn = + (static_cast(k) * d[1] + j) * d[0] + i; + const vtkIdType idOut = + (static_cast(k) * ny2 + j2) * d[0] + i; + arr->SetValue(idOut, inArr ? inArr->GetValue(idIn) : 0); + } + } + out->GetPointData()->SetScalars(arr); + return out; +} + +// 根因验证用:把一张体沿 X 裁掉后半 → 范围/包围盒改变(模拟引擎换"子区域"),用来确认 +// "改包围盒的换图"才是破坏 multi-volume 的真凶。 +vtkSmartPointer cropXhalf(vtkImageData* in) { + int d[3]; + in->GetDimensions(d); + double sp[3], org[3]; + in->GetSpacing(sp); + in->GetOrigin(org); + const int nx2 = std::max(1, d[0] / 2); + auto out = vtkSmartPointer::New(); + out->SetDimensions(nx2, d[1], d[2]); + out->SetOrigin(org); // 保 origin,但 X 范围缩到一半 → 包围盒变 + out->SetSpacing(sp); + vtkNew arr; + arr->SetName("v"); + arr->SetNumberOfTuples(static_cast(nx2) * d[1] * d[2]); + auto* inArr = vtkShortArray::SafeDownCast(in->GetPointData()->GetScalars()); + for (int k = 0; k < d[2]; ++k) + for (int j = 0; j < d[1]; ++j) + for (int i = 0; i < nx2; ++i) { + const vtkIdType idIn = (static_cast(k) * d[1] + j) * d[0] + i; + const vtkIdType idOut = (static_cast(k) * d[1] + j) * nx2 + i; + arr->SetValue(idOut, inArr ? inArr->GetValue(idIn) : 0); + } + out->GetPointData()->SetScalars(arr); + return out; +} + +// #6 色标图例:按【统一物理幅值范围】+ 默认配色建一个屏幕右侧色阶条(含刻度),让用户 +// 知道颜色代表多大的幅值。配色与体属性同源(pickColor),故图例颜色与体内颜色一致。 +vtkSmartPointer buildScalarBar(double vmin, double vmax) { + const GalleryVariant& v = kViewDefaultVariant; + const geopro::core::ColorScale cs = pickColor(v.color, vmin, vmax); + auto lut = vtkSmartPointer::New(); + constexpr int N = 64; + for (int t = 0; t < N; ++t) { + const double phys = vmin + (vmax - vmin) * t / (N - 1); + const auto c = cs.colorAt(phys); + lut->AddRGBPoint(phys, c.r / 255.0, c.g / 255.0, c.b / 255.0); + } + auto bar = vtkSmartPointer::New(); + bar->SetLookupTable(lut); + bar->SetTitle("Amplitude"); // ASCII(默认字体无 CJK,避免乱码) + bar->SetNumberOfLabels(5); + bar->SetMaximumWidthInPixels(80); + bar->GetTitleTextProperty()->SetColor(1, 1, 1); + bar->GetLabelTextProperty()->SetColor(1, 1, 1); + bar->SetPosition(0.91, 0.28); + bar->SetWidth(0.08); + bar->SetHeight(0.46); + return bar; +} + // 把世界相机参数逆变换到某线局部帧(T⁻¹):pos/focal 是点(含平移逆),up 是方向 // (仅旋转逆,TransformVector 不含平移)。再调引擎 updateView 选层选区(视锥外→引擎 // 内部 selectLod 判 empty → 不提交,保留上一就绪/无图)。 +// 诊断开关:置 1 时跳过引擎选层/换图,全程只渲各线 baseImage(粗整卷)。 +// 用于隔离"首帧连续→引擎换图后断开/消失"——若 baseOnly 始终连续,则根因是引擎换图。 +bool gViewAllBaseOnly = false; + +// 根因验证开关:就地换端口贴图后施加何种"失效/重算"策略,看能否让 multi-volume 算对。 +// 0=不做(基线,已知会断开) 1=multiVol.Modified() 2=重 SetVolume+Modified +// 3=2 + mapper.Modified() 4=3 + 强制 multiVol.GetBounds() 重算 +int gViewAllSwapFix = 0; + +// §3 通道维 LOD:按相机距离选 Y stride(远疏近密),保包围盒就地换 Y 平面子集。--chanLod 0 关。 +bool gChanLod = true; + void viewAllSubmitOneLine(PlacedSource& ps, vtkCamera* worldCam, double aspect, int viewportH) { - if (ps.culled || worldCam == nullptr) return; + if (gViewAllBaseOnly || worldCam == nullptr) return; double wp[3], wf[3], wu[3]; worldCam->GetPosition(wp); worldCam->GetFocalPoint(wf); @@ -4083,112 +4663,149 @@ void viewAllSubmitOneLine(PlacedSource& ps, vtkCamera* worldCam, 1.0}); } -// 非阻塞拉取该线后台已就绪的引擎单图,换上唯一体的 mapper(无新结果→沿用上一帧)。 -// 返回 1=换上新图。P12:单体单层,引擎选 LOD(远→粗 whole,近→细局部)。 +// 非阻塞拉取该线后台已就绪的引擎单图(视野 LOD 选区),换上该线【自己的 mapper】 +// (无新结果→沿用上一帧)。返回 1=换上新图。引擎选 LOD(远→粗 whole,近→细局部小区)。 +// 各线独立 mapper → 换"改包围盒的子区域"安全(无 multi-volume 可破坏)。 int viewAllPickOneLine(PlacedSource& ps) { - if (ps.culled) return 0; + if (gViewAllBaseOnly) return 0; // 诊断:不换图,保持 baseImage auto imgs = ps.source->currentImages(); // 内部 takeLatest(非阻塞) if (imgs.empty() || imgs[0] == nullptr) return 0; if (imgs[0] == ps.currentImg) return 0; ps.currentImg = imgs[0]; - ps.mapper->SetInputData(ps.currentImg); - ps.mapper->Update(); - return 1; -} - -// 视锥裁剪(#2):把该线世界 AABB 与相机 6 个视锥面比对,整盒在任一面外侧 → 裁掉 -// (base+hires 两层皆隐 → 该线本帧完全不渲,省下解压/重组/ray-march)。盒 8 角全在 -// 某面负半空间才裁(保守,绝不误裁部分可见的线)。 -bool aabbOutsideFrustum(const double b[6], const double planes[24]) { - for (int p = 0; p < 6; ++p) { - const double a = planes[p * 4 + 0], bb = planes[p * 4 + 1], - cc = planes[p * 4 + 2], dd = planes[p * 4 + 3]; - bool allOut = true; - for (int cx = 0; cx < 2 && allOut; ++cx) - for (int cy = 0; cy < 2 && allOut; ++cy) - for (int cz = 0; cz < 2 && allOut; ++cz) { - const double x = b[cx], y = b[2 + cy], z = b[4 + cz]; - if (a * x + bb * y + cc * z + dd >= 0.0) allOut = false; // 该角在面内 - } - if (allOut) return true; // 全 8 角在该面外 → 整盒在视锥外 + if (ps.mapper) { + ps.mapper->SetInputData(ps.currentImg); + ps.mapper->Update(); } - return false; + return 1; } // view-all 每帧驱动共享状态(挂 interactor 回调)。 struct ViewAllState { std::vector* lines = nullptr; + std::vector>* mappers = nullptr; vtkRenderer* ren = nullptr; vtkCamera* cam = nullptr; vtkRenderWindow* rw = nullptr; vtkTextActor* fpsText = nullptr; double aspect = 1400.0 / 900.0; int viewportH = 900; + double dragImgSample = 4.0; // 交互态屏幕降采样倍率(--maxImgSample) + double sampleDist = 0.3; // 静止态沿光线步长(世界米,--sampleDist;越大越快越糙) + double dragSampleMul = 3.0; // 交互态沿光线步长再放大倍数(--dragSampleMul) + bool lowQ = false; // 当前是否处于低质(交互)态 + int idleTicks = 0; // 连续无交互的定时器 tick 数(>=阈值 → 恢复全质量) bool inCb = false; }; -// 重算各线视锥可见性(裁屏外线)+ 对可见线提交引擎目标(非阻塞)。culled 线两层皆隐。 -void viewAllRefreshFrustum(ViewAllState* st) { - double planes[24]; - st->cam->GetFrustumPlanes(st->aspect, planes); - for (PlacedSource& ps : *st->lines) { - const bool outside = aabbOutsideFrustum(ps.worldBounds, planes); - ps.culled = outside; - // P12:每线唯一体。视锥外→隐(真跳过 ray-cast);可见→显并提交引擎目标(局部帧)。 - ps.volume->SetVisibility(outside ? 0 : 1); - if (!outside) viewAllSubmitOneLine(ps, st->cam, st->aspect, st->viewportH); +// 交互态(拖动/滚轮):屏幕降采样(1/dragImgSample) + 沿光线步长加粗(sampleDist×dragSampleMul)。 +// 关键:AutoAdjust 只降屏幕、不降沿光线步长;20 个体每步采 20 次,沿光线步长才是大头, +// 故交互时必须手动把沿光线步长也加粗(否则拖动仍卡)。 +void viewAllSetWheelCoarse(ViewAllState* st) { + if (!st->mappers) return; + for (auto& mm : *st->mappers) { + mm->SetImageSampleDistance(st->dragImgSample); + mm->SetSampleDistance(static_cast(st->sampleDist * st->dragSampleMul)); } + st->lowQ = true; } -// 交互进行中:只做视锥裁剪(便宜,仅切可见性),绝不提交引擎目标/重建纹理。 -// 拖动中频繁提交=20 条体反复重建+上传 GPU→GPU 100% thrash→卡死。拖动只渲已有 -// 纹理(mapper 降采样保流畅);重建只在松手(EndInteraction)触发一次。 +// 静止态:全屏幕分辨率 + 沿光线步长回到 sampleDist(清晰)。AutoAdjust 全程关(手动控)。 +void viewAllRestoreAdaptive(ViewAllState* st) { + if (!st->mappers) return; + for (auto& mm : *st->mappers) { + mm->SetImageSampleDistance(1.0); + mm->SetSampleDistance(static_cast(st->sampleDist)); + } + st->lowQ = false; +} + +// §3 通道维 LOD:按相机距离/场景对角线选 Y stride(远→疏省、近→全密度)。 +int viewAllChanStride(vtkRenderer* ren) { + double b[6]; + ren->ComputeVisiblePropBounds(b); + const double diag = std::sqrt((b[1] - b[0]) * (b[1] - b[0]) + + (b[3] - b[2]) * (b[3] - b[2]) + + (b[5] - b[4]) * (b[5] - b[4])); + const double dist = ren->GetActiveCamera()->GetDistance(); + const double r = diag > 1e-9 ? dist / diag : 1.0; + if (r > 1.4) return 8; // 远 → 半通道密度(7 平面/56) + if (r > 0.8) return 4; // 中 → 原始通道(14) + if (r > 0.45) return 2; // 近 → 原始+1(28) + return 1; // 最近 → 全插值(56) +} + +// 按当前相机选 stride,对各线就地换 Y 平面子集(保包围盒 → multi-volume 不破坏)。 +// 返回换图线数(>0 需重渲)。 +int viewAllApplyChanLod(ViewAllState* st) { + if (!gChanLod) return 0; + const int stride = viewAllChanStride(st->ren); + int changed = 0; + for (PlacedSource& ps : *st->lines) { + if (ps.fullYImg == nullptr || ps.yStride == stride) continue; + ps.yStride = stride; + ps.currentImg = subsampleYStride(ps.fullYImg, stride); + if (ps.multiMapper) + ps.multiMapper->SetInputDataObject(ps.port, ps.currentImg); + ++changed; // 保包围盒换图,无需失效重算 + } + return changed; +} + +// 对所有线提交引擎目标(非阻塞,按当前相机各自选 LOD)。 +// 方案 A 起:20 条共享【单个】vtkGPUVolumeRayCastMapper 单遍合成,重叠也只穿一遍 → +// 不再需要视锥裁剪削 20× 开销(且各线 AABB 都覆盖全路、互相重叠,裁剪本就几乎无效)。 +void viewAllSubmitTargets(ViewAllState* st) { + for (PlacedSource& ps : *st->lines) + viewAllSubmitOneLine(ps, st->cam, st->aspect, st->viewportH); +} + +// 拖动进行中(InteractionEvent):质量交给 AutoAdjust 自适应(够快就清晰,不够才降)。 +// 拖动中:进入交互态低质(屏幕 + 沿光线步长都加粗)+ 重置裁剪范围(防旋转裁掉)。绝不重建纹理。 void viewAllOnInteracting(vtkObject*, unsigned long, void* clientData, void*) { auto* st = static_cast(clientData); - double planes[24]; - st->cam->GetFrustumPlanes(st->aspect, planes); - for (PlacedSource& ps : *st->lines) { - const bool outside = aabbOutsideFrustum(ps.worldBounds, planes); - ps.culled = outside; - ps.volume->SetVisibility(outside ? 0 : 1); - } + if (!st->lowQ) viewAllSetWheelCoarse(st); // 进入交互态低质(拖动跟手) + st->idleTicks = 0; + if (st->ren) st->ren->ResetCameraClippingRange(); } -// 定时器:非阻塞拉取各可见线后台已就绪的新高清纹理换上 → 有新图才重渲。 +// 滚轮缩放(MouseWheelForward/BackwardEvent):立刻固定低分辨率(AutoAdjust 单帧来不及 +// 自适应,故手动降)。高优先级先于 style 的 Dolly+Render → 滚轮那一帧即低质快渲。 +void viewAllOnWheel(vtkObject*, unsigned long, void* clientData, void*) { + auto* st = static_cast(clientData); + viewAllSetWheelCoarse(st); + st->idleTicks = 0; + if (st->ren) st->ren->ResetCameraClippingRange(); +} + +// 定时器:滚轮停手约 idle 后恢复自适应 + 全质量重渲。 void viewAllOnTimer(vtkObject*, unsigned long, void* clientData, void*) { auto* st = static_cast(clientData); if (st->inCb) return; - int changed = 0; - for (PlacedSource& ps : *st->lines) changed += viewAllPickOneLine(ps); - if (changed > 0) { + if (st->lowQ && ++st->idleTicks >= 3) { // ~3×定时器周期无交互 → 恢复 + viewAllRestoreAdaptive(st); + viewAllApplyChanLod(st); // §3:滚轮缩放停手后按新距离重选通道密度 st->ren->ResetCameraClippingRange(); st->rw->Render(); } } -// 交互结束:重算视锥 + 提交 + 拉取 + 刷新 fps(仅松手触发一次)。 +// 拖动结束(EndInteractionEvent):恢复自适应(静止 AutoAdjust→全质量)+ 刷新 fps。 void viewAllOnInteract(vtkObject*, unsigned long, void* clientData, void*) { auto* st = static_cast(clientData); if (st->inCb) return; st->inCb = true; - viewAllRefreshFrustum(st); - int culledN = 0, visN = 0; - for (PlacedSource& ps : *st->lines) { - viewAllPickOneLine(ps); - if (ps.culled) ++culledN; else ++visN; - } + viewAllRestoreAdaptive(st); + viewAllApplyChanLod(st); // §3:拖动结束后按当前距离重选通道密度 + st->idleTicks = 0; st->ren->ResetCameraClippingRange(); - constexpr int kFpsProbeFrames = 3; Stopwatch swR; - for (int i = 0; i < kFpsProbeFrames; ++i) st->rw->Render(); - const double fps = swR.elapsedMs() > 0 - ? 1000.0 * kFpsProbeFrames / swR.elapsedMs() - : 0.0; + st->rw->Render(); // 一帧全质量;其耗时即静止 fps + const double fps = swR.elapsedMs() > 0 ? 1000.0 / swR.elapsedMs() : 0.0; char buf[256]; std::snprintf(buf, sizeof(buf), - "fps: %.1f | visible lines: %d | culled: %d", fps, visN, - culledN); + "fps(静止): %.1f | lines: %d | 单遍合成(multi-volume)", fps, + static_cast(st->lines->size())); if (st->fpsText) st->fpsText->SetInput(buf); st->rw->Render(); st->inCb = false; @@ -4213,6 +4830,30 @@ int cmdViewAll(int argc, char** argv) { const double spread = std::stod(a.get("spread", "0")); const bool preview = a.kv.count("preview") > 0; const std::string shotDir = a.get("shotDir", storesDir); + // --maxPerPass N:每个 vtkMultiVolume 单遍合成最多挂几条体(受 GPU 每着色器纹理单元 + // 上限约束,每体约吃 4 个单元)。0=自动按 GPU 纹理单元数推断(推荐)。超过的线分成 + // 多个 multi-volume 包,每包一遍 → 总遍数=ceil(N/K)(远少于"每线一遍"的 N 遍)。 + const int maxPerPassArg = std::stoi(a.get("maxPerPass", "0")); + // LOD 中心架构(passcost 确诊后):各线独立 mapper → 引擎换 LOD 子区【安全】(无 multi-volume + // 可破坏)。故视野 LOD【默认开】,单帧渲染量随视野走、与 20 条总量解耦。--baseOnly 仅诊断用 + // (锁定整卷底图、关引擎换图)。 + gViewAllBaseOnly = a.kv.count("baseOnly") != 0; + gViewAllSwapFix = std::stoi(a.get("swapFix", "0")); // 旧 multi-volume 换图诊断(已无意义,保留不碍) + // 引擎金字塔已逐级降 Y,无需单独抽 Y 平面 → 通道维 LOD【默认关】(--chanLod 1 可重开)。 + gChanLod = std::stoi(a.get("chanLod", "0")) != 0; + // --bgSuppress F(0..1):压背景、突出反射层(传函压低近零背景 + 中心透明死区)。 + // 0=原观感(背景可见);0.3~0.6 让总览干净;过大(>0.7)会连带抹掉弱反射(弱异常靠切片抓)。 + gBgSuppress = std::clamp(std::stod(a.get("bgSuppress", "0")), 0.0, 1.0); + // --maxImgSample F:拖动态屏幕采样距离上限(AutoAdjust 拖动时最多降到 1/F 分辨率, + // 越大越快越糊;松手自动恢复全质量)。默认 4。拉近卡顿可调大(如 8)。 + // LOD 已扛住性能(概览/拉近都够快)→ 拖动【默认不降质,保持清晰】。降质纯 opt-in 兜底: + // --maxImgSample F(拖动屏幕降到 1/F,默认 1=不降)、--dragSampleMul M(拖动步长×M,默认 1=不变)。 + // 仅当低端机拖动仍卡时才调大它们。 + const double maxImgSample = std::stod(a.get("maxImgSample", "1")); + // --sampleDist D:静止态沿光线步长(世界米)。通道插值后 Y 很密,自动步长会算得过细→巨卡, + // 故固定一个合理值。越大越快越糙;卡时调大(0.5/1.0),太糊/丢层调小(0.15)。 + const double sampleDist = std::stod(a.get("sampleDist", "0.3")); + const double dragSampleMul = std::stod(a.get("dragSampleMul", "1")); std::cout << "[view-all] storesDir=" << storesDir << " gpsDir=" << gpsDir << " exagg=" << exagg << " level=" << level << " spread=" << spread @@ -4370,16 +5011,18 @@ int cmdViewAll(int argc, char** argv) { // 起步喂粗底图(小且不空),引擎备好更合适 LOD 单图后整图换上。 ps.prop = buildLineProperty(ps.meta, ps.source->baseImage()); - ps.mapper = vtkSmartPointer::New(); - ps.mapper->SetRequestedRenderMode(vtkSmartVolumeMapper::GPURenderMode); - // #1 拖动降采样:交互式采样距离自适应(拖动→大步长降采样跟手,松手→全质量)。 - ps.mapper->SetAutoAdjustSampleDistances(1); - ps.mapper->SetInteractiveAdjustSampleDistances(1); + // LOD 中心:每条线建【自己的】GPU mapper,起步喂 baseImage,引擎选区后换上。 + // 手动控质(与旧 multi-volume 同口径):AutoAdjust 关、静止步长 sampleDist。 + ps.mapper = vtkSmartPointer::New(); + ps.mapper->SetAutoAdjustSampleDistances(0); + ps.mapper->SetImageSampleDistance(1.0); + ps.mapper->SetSampleDistance(static_cast(sampleDist)); ps.volume = vtkSmartPointer::New(); if (ps.source->baseImage() != nullptr) { - ps.mapper->SetInputData(ps.source->baseImage()); - ps.mapper->Update(); ps.currentImg = ps.source->baseImage(); // 起步即有图(不空白) + ps.fullYImg = ps.source->baseImage(); + ps.mapper->SetInputData(ps.currentImg); + ps.mapper->Update(); } ps.volume->SetMapper(ps.mapper); ps.volume->SetProperty(ps.prop); @@ -4421,7 +5064,73 @@ int cmdViewAll(int argc, char** argv) { std::cout << "[view-all] 加载并定位线数=" << lines.size() << "(每线一个 ViewAdaptiveVolumeSource 引擎)\n"; - // 4) 同一 renderer 加全部线的底图层 + 高清层。 + // 诊断(--overlapStat):实测一条俯视光线真实穿几个体——把各线世界 AABB 投到 X-Y 平面, + // 在公共 footprint 上撒细网格,统计每格被几条线的 AABB 覆盖(=该处俯视光线穿的体数)。 + // 纯几何、不渲染。验证"20× 重叠"是真是假(§7.1)。 + if (a.kv.count("overlapStat") > 0) { + double mnx = 1e30, mny = 1e30, mxx = -1e30, mxy = -1e30; + for (const auto& ps : lines) { + mnx = std::min(mnx, ps.worldBounds[0]); mxx = std::max(mxx, ps.worldBounds[1]); + mny = std::min(mny, ps.worldBounds[2]); mxy = std::max(mxy, ps.worldBounds[3]); + } + const int G = 400; // 网格分辨率 + const double sx = (mxx - mnx) / G, sy = (mxy - mny) / G; + long covered = 0, sumCov = 0, maxCov = 0; + std::vector hist(static_cast(lines.size()) + 1, 0); + for (int j = 0; j < G; ++j) + for (int i = 0; i < G; ++i) { + const double cx = mnx + (i + 0.5) * sx, cy = mny + (j + 0.5) * sy; + int cov = 0; + for (const auto& ps : lines) + if (cx >= ps.worldBounds[0] && cx <= ps.worldBounds[1] && + cy >= ps.worldBounds[2] && cy <= ps.worldBounds[3]) + ++cov; + if (cov > 0) { ++covered; sumCov += cov; maxCov = std::max(maxCov, cov); } + ++hist[cov]; + } + std::cout << "\n=== overlapStat(俯视光线穿体数,纯几何)===\n"; + std::cout << "footprint X=[" << mnx << "," << mxx << "] Y=[" << mny << "," << mxy + << "] (米)\n"; + std::cout << "有体覆盖格的【平均重叠层数】= " + << (covered ? double(sumCov) / covered : 0.0) + << " 最大重叠 = " << maxCov << " / 共 " << lines.size() << " 条\n"; + std::cout << "重叠层数分布(覆盖格中占比):\n"; + for (std::size_t k = 1; k < hist.size(); ++k) + if (hist[k] > 0) + std::cout << " 穿 " << k << " 个体: " << (covered ? 100.0 * hist[k] / covered : 0.0) + << "%\n"; + std::cout << "结论:平均/最大若 ~2–3 而非 ~20,则'20×重叠'是误判,瓶颈在别处(密 Y 细采样)。\n"; + return 0; + } + + // #6 统一传函:汇总各线底图 2/98 分位 → 一套【公共物理幅值范围】,重建各线属性使 + // 同一幅值跨线映射同色(可比)。各线仍用自己的 quant(量化差异由各自 quant 吸收)。 + double sumLo = 0.0, sumHi = 0.0; + int nRange = 0; + for (PlacedSource& ps : lines) { + vtkImageData* b = ps.source->baseImage(); + if (b == nullptr) continue; + const ScalarPercentiles pc = + sampleScalarPercentiles(b, ps.meta.quant, 0.02, 0.98); + if (pc.samples > 0) { + sumLo += pc.lo; + sumHi += pc.hi; + ++nRange; + } + } + double shVmin = 0.0, shVmax = 0.0; + if (nRange > 0 && sumLo / nRange < sumHi / nRange) { + shVmin = sumLo / nRange; + shVmax = sumHi / nRange; + for (PlacedSource& ps : lines) { + ps.prop = buildLineProperty(ps.meta, ps.source->baseImage(), shVmin, shVmax); + ps.volume->SetProperty(ps.prop); + } + std::cout << "[view-all] #6 统一幅值范围 [" << shVmin << ", " << shVmax + << "](跨线一致 + 色标图例)\n"; + } + + // 4) 单个共享 vtkGPUVolumeRayCastMapper + vtkMultiVolume:全部线作为端口单遍合成。 auto rw = preview ? makeOffscreenWindow(winW, winH) : vtkSmartPointer::New(); if (!preview) rw->SetSize(winW, winH); @@ -4430,42 +5139,253 @@ int cmdViewAll(int argc, char** argv) { kViewDefaultVariant.bg[2]); rw->AddRenderer(ren); + // #6 色标图例(右侧色阶条 + 幅值刻度)。仅在拿到公共幅值范围时加。 + if (shVmin < shVmax) ren->AddViewProp(buildScalarBar(shVmin, shVmax)); + auto capWin = vtkSmartPointer::New(); vtkOutputWindow::SetInstance(capWin); - for (PlacedSource& ps : lines) { - ren->AddVolume(ps.volume); // P12:每线唯一体(引擎 LOD 单层) + // 方案 A 核心:把多条【各自独立插值】的体作为同一个 vtkGPUVolumeRayCastMapper 的不同 + // 端口注册进一个 vtkMultiVolume → 单遍 ray-cast 一次性合成(含重叠),消除"N 体=N 遍 + // ray-cast"的物理 N× 开销;每条线仍各保留自己的世界变换 T 与逐线传函(满足"分开插值、 + // 合并渲染")。 + // + // 硬约束:单个 multi-volume 同时挂的体数受 GPU 每着色器纹理单元上限制约(每体约吃 4 个 + // 单元:体标量 + 颜色 TF + 不透明 TF + 梯度)。超限会报"Hardware does not support the + // number of textures"并悄悄丢体。故按 K 条/包分组:每包一个 multi-volume 单遍合成, + // 总遍数=ceil(N/K)(远少于每线一遍的 N 遍)。实测本机 K=7(32 单元 GPU)。 + // + // 先空渲一帧建好 GL 上下文,再查 GPU 纹理单元数推断【起始】K(每包体数)。 + rw->Render(); + int K = maxPerPassArg; + const bool autoK = (maxPerPassArg <= 0); + if (autoK) { + int units = 0; + if (auto* oglrw = vtkOpenGLRenderWindow::SafeDownCast(rw)) { + if (auto* tum = oglrw->GetTextureUnitManager()) + units = tum->GetNumberOfTextureUnits(); + } + K = units > 8 ? (units - 4) / 4 : 1; // 起始【估计】:留 4 单元、每体 ~4 单元 + if (K < 1) K = 1; + std::cout << "[view-all] GPU 纹理单元=" << units << " → 起始每包体数 K=" << K + << "(仅估计;下面按真实硬件告警自动退避。--maxPerPass 可锁定)\n"; + } else { + std::cout << "[view-all] 每包体数 K=" << K << "(--maxPerPass 锁定,不自动退避)\n"; } + + // LOD 中心装配:各线独立 GPU mapper(已在上面建好、喂了 baseImage),直接把每条线的 volume + // 加进同一 renderer。无 multi-volume → 无纹理单元上限、无分包、无退避(passcost 证明 N 遍开销 + // 温和:20 条独立 mapper 铺开 78fps)。每帧实际渲染量由视野 LOD(引擎选区)压住、与 20 条总量解耦。 + (void)K; + (void)autoK; + std::vector> mappers; + mappers.reserve(lines.size()); + for (PlacedSource& ps : lines) { + if (ps.mapper) mappers.push_back(ps.mapper); + ren->AddVolume(ps.volume); + } + ren->ResetCamera(); + rw->Render(); std::cout << "[view-all] 已加入场景线数=" << lines.size() - << "(每线唯一体=" << lines.size() - << " 体,引擎 LOD 单层,各 ≤16384 单纹理,绝不撞 GL 纹理墙)\n"; + << "(各线独立 GPU mapper + 视野 LOD,弃 multi-volume 单遍;单帧渲染量随视野走)\n"; + + // --slice:全局世界切面——定义一个世界轴对齐薄板,每条线用 worldInv 作 ResliceTransform + // reslice 到这【同一个面】上采样(覆盖外透明),blend 合成一张 → 真正一整片切面,整体扫过去。 + GlobalSlice gs; + const bool sliceOn = a.kv.count("slice") > 0; + vtkSmartPointer sliceActor; + if (sliceOn) { + gs.rw = rw.Get(); + const std::string sax = a.get("slice", ""); + int inPlane[2]; + if (sax == "frontback") { // 顺路 radargram:沿 world X 扫,面=Y-Z + gs.sweepAxis = 0; inPlane[0] = 1; inPlane[1] = 2; + } else if (sax == "leftright") { // 横切断面:沿 world Y(沿路) 扫,面=X-Z(横穿所有线) + gs.sweepAxis = 1; inPlane[0] = 0; inPlane[1] = 2; + } else { // updown 深度 C-scan:沿 world Z 扫,面=X-Y + gs.sweepAxis = 2; inPlane[0] = 0; inPlane[1] = 1; + } + gs.inA = inPlane[0]; + gs.inB = inPlane[1]; + // footprint:全线世界 AABB 并集。 + double lo[3] = {1e30, 1e30, 1e30}, hi[3] = {-1e30, -1e30, -1e30}; + for (const PlacedSource& ps : lines) + for (int d = 0; d < 3; ++d) { + lo[d] = std::min(lo[d], ps.worldBounds[d * 2]); + hi[d] = std::max(hi[d], ps.worldBounds[d * 2 + 1]); + } + gs.sweepMin = lo[gs.sweepAxis]; + gs.sweepMax = hi[gs.sweepAxis]; + const double atFrac = std::clamp(std::stod(a.get("sliceAt", "0.5")), 0.0, 1.0); + gs.sweepWorld = lo[gs.sweepAxis] + atFrac * (hi[gs.sweepAxis] - lo[gs.sweepAxis]); + for (int d = 0; d < 3; ++d) { gs.footLo[d] = lo[d]; gs.footHi[d] = hi[d]; } + const double SENT = -32768.0; // 覆盖外 sentinel(int16 数据 ±7417 之外) + const geopro::core::ColorScale sliceCs = + pickColor(kViewDefaultVariant.color, shVmin, shVmax); + vtkSmartPointer lut = makeLut(sliceCs, shVmin, shVmax); + + if (gs.sweepAxis == 2) { + // ── 深度 C-scan:逐线整张水平切面(深度共面→拼成完整 C-scan,全覆盖、原生分辨率)── + for (PlacedSource& ps : lines) { + vtkImageData* b = ps.source->baseImage(); + if (b == nullptr) continue; + int dd[3]; + double oo[3], ss[3]; + b->GetDimensions(dd); + b->GetOrigin(oo); + b->GetSpacing(ss); + auto col = vtkSmartPointer::New(); + col->SetLookupTable(lut); + col->SetInputData(b); + col->SetOutputFormatToRGBA(); + col->Update(); + auto act = vtkSmartPointer::New(); + act->GetMapper()->SetInputConnection(col->GetOutputPort()); + act->SetUserTransform(ps.world); // 整张水平片摆进世界(共面) + ren->AddActor(act); + gs.lineActors.push_back(act); + gs.winv.push_back(ps.worldInv); + gs.lorg.push_back({oo[0], oo[1], oo[2]}); + gs.lspc.push_back({ss[0], ss[1], ss[2]}); + gs.ldim.push_back({dd[0], dd[1], dd[2]}); + if (ps.volume) gs.volumes.push_back(ps.volume.Get()); + } + } else { + // ── 横切/顺路(竖直面):全局面 reslice 各线到同一世界面 + blend(共面、可见整片)── + // 输出薄板:面内铺满 footprint(目标 0.1m、每轴上限 2000),扫描轴 1 片@sweepWorld。 + const double targetSp = 0.1; + double osp[3], oorg[3]; + int oext[6]; + for (int d = 0; d < 3; ++d) { + if (d == gs.sweepAxis) { + osp[d] = 1.0; oorg[d] = gs.sweepWorld; oext[d * 2] = 0; oext[d * 2 + 1] = 0; + } else { + const double span = std::max(1e-3, hi[d] - lo[d]); + const int Nd = std::clamp(static_cast(span / targetSp), 64, 2000); + osp[d] = span / Nd; + oorg[d] = lo[d]; + oext[d * 2] = 0; oext[d * 2 + 1] = Nd - 1; + } + } + auto blend = vtkSmartPointer::New(); + blend->SetBlendModeToNormal(); + for (PlacedSource& ps : lines) { + vtkImageData* b = ps.source->baseImage(); + if (b == nullptr) continue; + auto rs = vtkSmartPointer::New(); + rs->SetInputData(b); + rs->SetResliceTransform(ps.worldInv); + rs->SetInterpolationModeToNearestNeighbor(); + rs->SetBackgroundLevel(SENT); + rs->SetOutputDimensionality(3); + rs->SetOutputSpacing(osp); + rs->SetOutputOrigin(oorg); + rs->SetOutputExtent(oext); + auto rgb = vtkSmartPointer::New(); + rgb->SetLookupTable(lut); + rgb->SetInputConnection(rs->GetOutputPort()); + rgb->SetOutputFormatToRGB(); + auto th = vtkSmartPointer::New(); + th->SetInputConnection(rs->GetOutputPort()); + th->ThresholdBetween(SENT - 0.5, SENT + 0.5); + th->SetInValue(0); + th->SetOutValue(255); + th->SetOutputScalarTypeToUnsignedChar(); + auto app = vtkSmartPointer::New(); + app->AddInputConnection(rgb->GetOutputPort()); + app->AddInputConnection(th->GetOutputPort()); + blend->AddInputConnection(app->GetOutputPort()); + gs.reslices.push_back(rs); + if (ps.volume) gs.volumes.push_back(ps.volume.Get()); + } + sliceActor = vtkSmartPointer::New(); + sliceActor->GetMapper()->SetInputConnection(blend->GetOutputPort()); + blend->Update(); + sliceActor->SetDisplayExtent(oext[0], oext[1], oext[2], oext[3], oext[4], oext[5]); + ren->AddActor(sliceActor); + } + gs.applySlice(); + gs.applyVolume(); + const char* axisName = gs.sweepAxis == 2 + ? "深度 C-scan(逐线整片,全覆盖原生分辨率,沿深度扫)" + : gs.sweepAxis == 1 + ? "横切断面(全局面,沿路扫,整片横穿所有线)" + : "顺路 radargram(全局面,沿横向扫)"; + const std::size_t nslice = + gs.lineActors.empty() ? gs.reslices.size() : gs.lineActors.size(); + std::cout << "[view-all] --slice:切片已加(" << nslice << " 线," << axisName + << ")。↑↓整片扫过 / [ ]调体透明度 / v 体显隐。\n"; + } ViewAllState st; st.lines = &lines; + st.mappers = &mappers; st.ren = ren.Get(); st.rw = rw.Get(); st.aspect = aspect; st.viewportH = winH; + st.dragImgSample = maxImgSample; + st.sampleDist = sampleDist; + st.dragSampleMul = dragSampleMul; // 首帧:ResetCamera 框全测区 → 概览(各线选粗 LOD 底图)。提交引擎目标 + 阻塞拉首图。 ren->ResetCamera(); st.cam = ren->GetActiveCamera(); - viewAllRefreshFrustum(&st); + viewAllSubmitTargets(&st); // 概览阻塞拉一次(保证首帧高清就绪,离屏/真窗口都从有图起步)。 for (PlacedSource& ps : lines) { - if (ps.culled) continue; for (int tries = 0; tries < 200; ++tries) { if (viewAllPickOneLine(ps)) break; if (ps.currentImg != nullptr) break; std::this_thread::sleep_for(std::chrono::milliseconds(2)); } } + viewAllApplyChanLod(&st); // §3:首帧按概览距离选通道密度(远→疏) rw->Render(); if (capWin->textureError()) { std::cerr << "[view-all] 警告: 仍检测到 3D 纹理维度错误(不应发生,引擎契约 " "≤16384)。\n"; } + // 根因验证(--swapTest):确定性复现"通道 LOD 换密度"。before=全 Y 渲一帧存图; + // 然后把每条线端口就地换成 Y 抽半的图(同范围、不同 Y 密度)+ 施加 --swapFix 策略; + // after 再渲一帧存图。对比 before/after:after 若仍连续/在位 → 就地换贴图能算对。 + if (a.kv.count("swapTest") > 0) { + const std::string sd = a.get("shotDir", "tmp/swaptest"); + fs::create_directories(sd); + ren->ResetCamera(); + ren->GetActiveCamera()->Elevation(30); + ren->GetActiveCamera()->Azimuth(25); + ren->ResetCameraClippingRange(); + rw->Render(); + savePng(rw.Get(), (fs::path(sd) / "before-fullY.png").string()); + for (PlacedSource& ps : lines) { + if (ps.currentImg == nullptr) continue; + int dIn[3]; + ps.currentImg->GetDimensions(dIn); + // --swapMode x = 改包围盒(X 裁半,模拟引擎子区域);否则 y = 保包围盒(改 Y 密度,通道 LOD)。 + auto dY = (a.get("swapMode", "y") == "x") ? cropXhalf(ps.currentImg) + : downsampleY(ps.currentImg); + int dOut[3]; + dY->GetDimensions(dOut); + ps.currentImg = dY; + ps.multiMapper->SetInputDataObject(ps.port, dY); + if (gViewAllSwapFix >= 2) ps.multiVol->SetVolume(ps.volume, ps.port); + ps.multiVol->Modified(); + if (gViewAllSwapFix >= 3) ps.multiMapper->Modified(); + if (gViewAllSwapFix >= 4) ps.multiVol->GetBounds(); + std::cout << "[swapTest] " << ps.name << " Y " << dIn[1] << "→" << dOut[1] + << "\n"; + } + ren->ResetCameraClippingRange(); + rw->Render(); + savePng(rw.Get(), (fs::path(sd) / "after-halfY.png").string()); + std::cout << "[swapTest] swapFix=" << gViewAllSwapFix + << " 出图: " << (fs::path(sd) / "before-fullY.png").string() + << " / after-halfY.png(after 连续在位=能算对)\n"; + return 0; + } + if (preview) { fs::create_directories(shotDir); @@ -4482,12 +5402,19 @@ int cmdViewAll(int argc, char** argv) { cam->SetViewUp(0, 1, 0); ren->ResetCameraClippingRange(); st.cam = cam; - viewAllRefreshFrustum(&st); + viewAllSubmitTargets(&st); for (PlacedSource& ps : lines) - for (int t = 0; t < 100 && !ps.culled && ps.currentImg == nullptr; ++t) { + for (int t = 0; t < 100 && ps.currentImg == nullptr; ++t) { if (viewAllPickOneLine(ps)) break; std::this_thread::sleep_for(std::chrono::milliseconds(2)); } + viewAllApplyChanLod(&st); + if (!lines.empty()) { + int d[3] = {0, 0, 0}; + if (lines[0].currentImg) lines[0].currentImg->GetDimensions(d); + std::cout << "[chanLod] 俯视(远) stride=" << lines[0].yStride + << " 线0 Y=" << d[1] << "\n"; + } rw->Render(); savePng(rw.Get(), (fs::path(shotDir) / "view-all-top.png").string()); std::cout << "[view-all] 俯视图存: " @@ -4504,13 +5431,13 @@ int cmdViewAll(int argc, char** argv) { cam->Azimuth(30.0); ren->ResetCameraClippingRange(); st.cam = cam; - viewAllRefreshFrustum(&st); + viewAllSubmitTargets(&st); for (PlacedSource& ps : lines) { - for (int t = 0; t < 100 && !ps.culled && ps.currentImg == nullptr; ++t) { + for (int t = 0; t < 100 && ps.currentImg == nullptr; ++t) { if (viewAllPickOneLine(ps)) break; std::this_thread::sleep_for(std::chrono::milliseconds(2)); } - if (ps.culled) ++ovCulled; else ++ovVisible; + ++ovVisible; } rw->Render(); savePng(rw.Get(), @@ -4560,27 +5487,61 @@ int cmdViewAll(int argc, char** argv) { ren->ResetCameraClippingRange(); st.cam = cam; (void)segLen; - viewAllRefreshFrustum(&st); + viewAllSubmitTargets(&st); for (PlacedSource& ps : lines) { - for (int t = 0; t < 120 && !ps.culled && ps.currentImg == nullptr; ++t) { + for (int t = 0; t < 120 && ps.currentImg == nullptr; ++t) { if (viewAllPickOneLine(ps)) break; std::this_thread::sleep_for(std::chrono::milliseconds(2)); } - if (ps.culled) ++nearCulled; else ++nearVisible; + ++nearVisible; + } + viewAllApplyChanLod(&st); + if (!lines.empty()) { + int d[3] = {0, 0, 0}; + if (lines[0].currentImg) lines[0].currentImg->GetDimensions(d); + std::cout << "[chanLod] 拉近(近) stride=" << lines[0].yStride + << " 线0 Y=" << d[1] << "\n"; } rw->Render(); savePng(rw.Get(), (fs::path(shotDir) / "view-all-near.png").string()); std::cout << "[view-all] 拉近图存: " << (fs::path(shotDir) / "view-all-near.png").string() << "\n"; + // --slice 验证图:相机沿扫描轴正对切面(看整片切面 face-on)。 + if (sliceOn) { + const int sa = gs.sweepAxis; + const int a = (sa == 0) ? 1 : 0; // 面内轴 1 + const int b = (sa == 2) ? 1 : 2; // 面内轴 2 + double ctr[3]; + for (int d = 0; d < 3; ++d) ctr[d] = 0.5 * (gs.footLo[d] + gs.footHi[d]); + ctr[sa] = gs.sweepWorld; + const double extA = std::max(1.0, gs.footHi[a] - gs.footLo[a]); + const double extB = std::max(1.0, gs.footHi[b] - gs.footLo[b]); + const double dist = 0.6 * std::max(extA, extB) / tanH * 1.4; + double pos[3] = {ctr[0], ctr[1], ctr[2]}; + pos[sa] += dist; // 沿扫描轴退后,正对切面 + cam->SetFocalPoint(ctr[0], ctr[1], ctr[2]); + cam->SetPosition(pos[0], pos[1], pos[2]); + // 俯视(深度,沿Z看)用 Y 朝上;竖直剖面用 Z 朝上(避免 up 与视向平行退化)。 + if (sa == 2) cam->SetViewUp(0, 1, 0); + else cam->SetViewUp(0, 0, 1); + ren->ResetCameraClippingRange(); + rw->SetDesiredUpdateRate(0.5); + gs.volOn = false; // 验证:隐去体,只看切面本身 + gs.applyVolume(); + rw->Render(); + savePng(rw.Get(), (fs::path(shotDir) / "view-all-slice.png").string()); + std::cout << "[view-all] 切面正视图存: " + << (fs::path(shotDir) / "view-all-slice.png").string() << "\n"; + } + rw->SetDesiredUpdateRate(15.0); // 拖动态:降采样 rw->Render(); Stopwatch sw; const int frames = 60; for (int f = 0; f < frames; ++f) { cam->Azimuth(f % 2 == 0 ? 0.4 : -0.4); // 小幅摆动(不转回全测区) - viewAllRefreshFrustum(&st); // 拖动中持续重算视锥裁剪 - rw->Render(); + rw->Render(); // 单遍合成,无需逐帧重提交(拖动只渲已有纹理) } fpsNear = sw.elapsedMs() > 0 ? frames * 1000.0 / sw.elapsedMs() : 0.0; rw->SetDesiredUpdateRate(0.5); @@ -4627,7 +5588,7 @@ int cmdViewAll(int argc, char** argv) { // 真窗口:可旋转/缩放(每线引擎 LOD + 视锥裁剪 + 拖动降采样)。 vtkOutputWindow::SetInstance(nullptr); - rw->SetWindowName("gpr_poc view-all —— 20 条独立体引擎LOD/视锥裁剪/拖动降采样"); + rw->SetWindowName("gpr_poc view-all —— 多体单遍合成(multi-volume)/逐线LOD/拖动降采样"); // 屏幕左上角 fps + 可见/裁剪线数文本。 vtkNew fpsText; @@ -4660,7 +5621,27 @@ int cmdViewAll(int argc, char** argv) { cbTimer->SetClientData(&st); iren->AddObserver(vtkCommand::TimerEvent, cbTimer); - std::cout << "[view-all] 打开真窗口。左键旋转 / 滚轮缩放 / q 退出。\n"; + // 滚轮缩放:高优先级(1.0)先于 style 的 Dolly+Render → 该帧即按低质渲染。 + vtkNew cbWheelF; + cbWheelF->SetCallback(viewAllOnWheel); + cbWheelF->SetClientData(&st); + iren->AddObserver(vtkCommand::MouseWheelForwardEvent, cbWheelF, 1.0); + vtkNew cbWheelB; + cbWheelB->SetCallback(viewAllOnWheel); + cbWheelB->SetClientData(&st); + iren->AddObserver(vtkCommand::MouseWheelBackwardEvent, cbWheelB, 1.0); + + // --slice:键盘 Up/Down 改 C-scan 深度(高优先级,先于 style 处理)。 + vtkNew cbKey; + if (sliceOn) { + cbKey->SetCallback(cscanOnKey); + cbKey->SetClientData(&gs); + iren->AddObserver(vtkCommand::KeyPressEvent, cbKey, 1.0); + } + + std::cout << "[view-all] 打开真窗口。左键旋转 / 滚轮缩放 / q 退出" + << (sliceOn ? " / ↑↓改切片深度 / [ ]调体透明度 / v 体显隐" : "") + << "。\n"; iren->Initialize(); iren->CreateRepeatingTimer(33); // ~30Hz 非阻塞拉取后台就绪纹理 rw->Render(); @@ -5825,6 +6806,9 @@ int main(int argc, char** argv) { if (cmd == "build-geo") return cmdBuildGeo(argc, argv); if (cmd == "build-line") return cmdBuildLine(argc, argv); if (cmd == "build-all") return cmdBuildAll(argc, argv); + if (cmd == "ess-stat") return cmdEssStat(argc, argv); + if (cmd == "passcost") return cmdPassCost(argc, argv); + if (cmd == "slice") return cmdSlice(argc, argv); if (cmd == "build-survey-line") return cmdBuildSurveyLine(argc, argv); if (cmd == "build-survey-all") return cmdBuildSurveyAll(argc, argv); if (cmd == "load") return cmdLoad(argc, argv);