Sfoglia il codice sorgente

fix(cockpit): 标记贴地高度改为按坐标一次采样恒定

ChenJZ 1 giorno fa
parent
commit
668bdfc0e6

+ 0 - 40
src/modules/cockpit/domain/groundAnchor.js

@@ -1,40 +0,0 @@
-/**
- * 标记贴地高度规则(维护者 2026-11-11「坐标点先入地、再上浮到地表」)。
- *
- * Cesium 的 `Globe.getHeight` 取的是**当前已渲染网格**的高度:细瓦片网格没出来时给的是父级
- * 粗网格(粗→细可差几十米),连粗网格都没有才回 `undefined`。所以贴地高度在地形补齐过程中
- * 本来就会变。老实现把它简化成两条兜底,正是缺陷来源:
- *   · 取不到就按椭球面 0 米画 —— 地形区里标记被摆到地面以下;
- *   · 取到就缓存 3 秒 —— 地表抬高后标记仍停在旧高度。
- * 两者叠加就是用户看到的「先入地、再上浮」。
- *
- * 标记层口径(让位/锚定那条路仍按旧口径回椭球面,见 `projectPointMarker`):
- *   有样本 > 最后有效高度 > (加载中:不上图)> (加载已停:无地形覆盖,椭球面即真实地面)。
- */
-
-/**
- * 纯规则:一次采样该用哪个高度。
- * @param {{sample?: number, lastKnown?: number, terrainLoading?: boolean|Function}} input
- *   `terrainLoading` 可以是惰性函数 —— `globe.tilesLoaded` 是 getter,只有真要判断时才读。
- * @returns {number|null} 米;`null` = 没有可信地面,调用方不上图
- */
-export function resolveGroundHeight({ sample, lastKnown, terrainLoading } = {}) {
-    if (Number.isFinite(sample)) return sample
-    if (Number.isFinite(lastKnown)) return lastKnown
-    const loading = typeof terrainLoading === 'function' ? terrainLoading() : terrainLoading
-    return loading === true ? null : 0
-}
-
-/** 按坐标记住「最后一次有效地面高度」(只记有效值:记下"没取到"等于把假值钉住)。 */
-export function createGroundAnchor() {
-    const lastKnown = new Map()
-    return {
-        remember(key, sample) {
-            if (Number.isFinite(sample)) lastKnown.set(key, sample)
-        },
-        lastKnown(key) {
-            const value = lastKnown.get(key)
-            return Number.isFinite(value) ? value : null
-        },
-    }
-}

+ 119 - 85
src/modules/cockpit/services/cesiumEngine.js

@@ -234,10 +234,6 @@ export function createViewer(host, basemapPlan) {
     // 地面视图必须保留地形深度,否则埋在地表以下的 3D Tiles 也会覆盖到地形之上。
     // 回归:tools/city-massing/verify-occlusion.mjs 用实际渲染像素检查遮挡。
     viewer.scene.globe.depthTestAgainstTerrain = true
-    // 贴地高度会随地形补齐而变(`Globe.getHeight` 取的是**当前已渲染网格**的高度):
-    // 采样缓存必须跟着失效,否则 DOM 标记会停在地表以下直到 TTL 到期 ——
-    // 维护者 2026-11-11 报的「坐标点先入地、再上浮到地表」正是这段滞后。
-    attachGroundHeightRefresh(viewer)
     return viewer
 }
 
@@ -313,8 +309,7 @@ export const PERFORMANCE_DEFAULT_OPTIONS = { ...PERFORMANCE_DEFAULTS }
 
 import { isContinuousGap } from '../domain/adaptiveResolution.js'
 import { createRenderResolutionController } from '../domain/renderResolutionController.js'
-import { createHeightCache, heightKey } from '../domain/heightCache.js'
-import { createGroundAnchor, resolveGroundHeight } from '../domain/groundAnchor.js'
+import { heightKey } from '../domain/heightCache.js'
 
 // Per-viewer state avoids inheriting samples/targets from a destroyed or different viewer.
 const resolutionControllers = new WeakMap()
@@ -2350,108 +2345,145 @@ export function pipelineSource(viewer) {
     return viewer.dataSources.getByName(PIPELINE_SOURCE)[0] || null
 }
 
-/** 按坐标的地面高程缓存(票 98 第二轮):标记/锚定两条投影每帧都要取一次高程。 */
-const groundHeightCache = createHeightCache({ ttlMs: 3000, maxEntries: 5000 })
-
-/** 地形瓦片加载状态变化的监听器(卸载时摘掉;否则打向已销毁的 scene)。 */
-const groundRefreshDetachers = new WeakMap()
-
-/** 标记层「最后一次有效地面高度」:每 viewer 一份,跟随 viewer 生命周期。 */
-const groundAnchors = new WeakMap()
-function groundAnchorFor(viewer) {
-    let anchor = groundAnchors.get(viewer)
-    if (!anchor) {
-        anchor = createGroundAnchor()
-        groundAnchors.set(viewer, anchor)
+/**
+ * 地面高程的**一次性采样表**(维护者 2026-11-11「还是会出现位置跳转」)。
+ *
+ * 为什么不能用 `globe.getHeight` 逐帧取:它返回的是**当前已渲染网格**的高度,同一坐标在粗网格与
+ * 细网格之间能差几十米。镜头一动 LOD 就换,标记就跟着跳——这是「靠近后又上浮」的根源;
+ * 让缓存跟着瓦片失效只能减少次数,消除不了。
+ *
+ * 所以标记/锚定一律用 `sampleTerrainMostDetailed` 按坐标采一次**最详细**高程并**永不重采**:
+ * 同一坐标永远得到同一个值,位置就不可能再跳。
+ */
+const detailedGroundHeights = new WeakMap()
+/** 单批采样上限:批太大请求会被拆得很零碎(与 `cesiumPipeModels` 同量级)。 */
+const GROUND_SAMPLE_CHUNK = 256
+
+/** 某 viewer 的高程采样器:`heights` 已定值(永不重采),`pending` 正在采的坐标键。 */
+function groundSamplerFor(viewer) {
+    let sampler = detailedGroundHeights.get(viewer)
+    if (!sampler) {
+        sampler = { heights: new Map(), pending: new Set() }
+        detailedGroundHeights.set(viewer, sampler)
     }
-    return anchor
-}
-
-function nowMs() {
-    return typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()
+    return sampler
+}
+
+/** 已定值就直接用;没采过就先定值(无地形)或返回 null 并排一次异步采样。 */
+function queuedGroundHeight(viewer, lon, lat) {
+    if (!viewer || !Number.isFinite(lon) || !Number.isFinite(lat)) return null
+    const sampler = groundSamplerFor(viewer)
+    const key = heightKey(lon, lat)
+    if (sampler.heights.has(key)) return sampler.heights.get(key)
+    if (!viewer.terrainProvider) {
+        // 无地形部署:椭球面就是地面,不必等一拍异步。
+        sampler.heights.set(key, 0)
+        return 0
+    }
+    queueGroundSample(viewer, sampler, [{ key, lon, lat }])
+    return null
 }
 
-/** 取这一刻的可信地面样本;没有网格(瓦片未落地/无覆盖)回 null,且**不进缓存**。 */
-function sampleGroundHeightAt(globe, carto, key) {
-    const height = groundHeightCache.read(key, nowMs(), () => {
-        const sampled = globe.getHeight(carto)
-        return Number.isFinite(sampled) ? sampled : null
-    })
-    return Number.isFinite(height) ? height : null
+/** 排一批地形采样:无地形 provider 时直接定 0(椭球面就是地面),否则采一次并永久记住。 */
+function queueGroundSample(viewer, sampler, items) {
+    const provider = viewer && viewer.terrainProvider
+    if (!provider) {
+        for (const item of items) sampler.heights.set(item.key, 0)
+        return
+    }
+    const batch = []
+    for (const item of items) {
+        if (sampler.heights.has(item.key) || sampler.pending.has(item.key)) continue
+        sampler.pending.add(item.key)
+        batch.push(item)
+    }
+    if (!batch.length) return
+    Promise.resolve()
+        .then(async () => {
+            for (let start = 0; start < batch.length; start += GROUND_SAMPLE_CHUNK) {
+                const chunk = batch.slice(start, start + GROUND_SAMPLE_CHUNK)
+                try {
+                    const sampled = await sampleTerrainMostDetailed(
+                        provider,
+                        chunk.map((item) => Cartographic.fromDegrees(item.lon, item.lat)),
+                    )
+                    chunk.forEach((item, index) => {
+                        const height = sampled && sampled[index] && sampled[index].height
+                        // 只有真采到才入表;失败的坐标下次还会再试,不会被钉成 0。
+                        if (Number.isFinite(height)) sampler.heights.set(item.key, height)
+                    })
+                } catch {
+                    // 整批失败:不写表,等下一帧再排。
+                } finally {
+                    for (const item of chunk) sampler.pending.delete(item.key)
+                    const scene = viewer && viewer.scene
+                    if (scene && typeof scene.requestRender === 'function') scene.requestRender()
+                }
+            }
+        })
+        .catch(() => {})
 }
 
 /**
- * 地形瓦片一有落地就丢掉高程采样缓存。
+ * 按坐标取**确定性**地面高度:已采过的直接用(永不重采),没采过的返回 `null` 并排队采样。
  *
- * `Globe.getHeight` 给的是**当前已渲染网格**的高度,粗网格 → 细网格可以差几十米;
- * 缓存不失效,标记就会在新地表以下停留到 TTL(3 秒)到期——用户看到的「地底→上浮」。
- * 导出让闸门能拿假 viewer 直接验这条契约(与 `groundHeightAt` 同做法)。
+ * 排队期间拿不到高度=标记暂不上图;采完自然出现。宁可晚一步,也不让标记在粗/细网格间跳。
+ * @returns {number|null} 米
  */
-export function attachGroundHeightRefresh(viewer) {
-    const globe = viewer && viewer.scene && viewer.scene.globe
-    const event = globe && globe.tileLoadProgressEvent
-    if (!event || typeof event.addEventListener !== 'function') return
-    let lastAt = 0
-    const remove = event.addEventListener((remaining) => {
-        const now = nowMs()
-        // 队列见底=本轮瓦片全落地,必丢;加载中节流丢(每来一片就重采几百个坐标是纯浪费)。
-        if (remaining > 0 && now - lastAt < 250) return
-        lastAt = now
-        groundHeightCache.clear()
-    })
-    if (typeof remove === 'function') groundRefreshDetachers.set(viewer, remove)
+export function deterministicGroundHeight(viewer, lon, lat) {
+    return queuedGroundHeight(viewer, lon, lat)
 }
 
-/** 摘掉高程刷新监听(`destroyViewer` 调用;同时丢掉该 viewer 的记忆表)。 */
+/** 批量预热(进入系统时把待上图坐标一次排队,不必每帧只排一个)。 */
+export function warmGroundHeights(viewer, points) {
+    const list = Array.isArray(points) ? points : []
+    if (!viewer || !list.length) return
+    const sampler = groundSamplerFor(viewer)
+    queueGroundSample(viewer, sampler, list
+        .filter((point) => point && Number.isFinite(point.lon) && Number.isFinite(point.lat))
+        .map((point) => ({ key: heightKey(point.lon, point.lat), lon: point.lon, lat: point.lat })))
+}
+
+/** 该 viewer 的高程采样状态(闸门读它验「采一次、不再变」)。 */
+export function groundSamplerState(viewer) {
+    const sampler = viewer ? detailedGroundHeights.get(viewer) : null
+    return sampler ? { size: sampler.heights.size, pending: sampler.pending.size } : { size: 0, pending: 0 }
+}
+
+/** 丢掉该 viewer 的高程采样(`destroyViewer` 调用)。 */
 export function detachGroundHeightRefresh(viewer) {
-    const remove = groundRefreshDetachers.get(viewer)
-    if (typeof remove === 'function') {
-        try {
-            remove()
-        } catch {
-            // 场景已销毁:忽略
-        }
+    const sampler = detailedGroundHeights.get(viewer)
+    if (sampler) {
+        sampler.heights.clear()
+        sampler.pending.clear()
     }
-    groundRefreshDetachers.delete(viewer)
-    groundAnchors.delete(viewer)
+    detailedGroundHeights.delete(viewer)
 }
 
-/** 让位/锚定口径的地面高程:无样本按椭球面 0 米(票 41/47 的既定判据,勿收紧)。 */
+/** 让位/锚定口径的地面高程:没采到时回 null(调用方兜成椭球面 0 米)。 */
 export function groundHeightAt(viewer, lon, lat) {
-    const globe = viewer && viewer.scene && viewer.scene.globe
-    if (!globe || !Number.isFinite(lon) || !Number.isFinite(lat)) return null
-    return sampleGroundHeightAt(globe, Cartographic.fromDegrees(lon, lat), heightKey(lon, lat))
+    return deterministicGroundHeight(viewer, lon, lat)
 }
 
 /**
- * **标记层**(DOM 覆盖层)的贴地高度:没有可信地面时回 `null`=调用方不上图。
+ * **标记层**(DOM 覆盖层)的贴地高度:还没采到时回 `null`=调用方不上图。
  *
- * 口径在 `domain/groundAnchor.js`:有样本 > 最后有效高度 > (加载中:不上图)>
- * (加载已停:无地形覆盖,椭球面即真实地面)。让位/锚定那一路不走这里。
+ * 不用 `globe.getHeight`、也不用「最后有效高度」过渡:两者都依赖会随 LOD 变的网格,
+ * 标记依旧会在粗/细网格切换时跳。这里是**采一次、永不重采**的确定性值。
  * @returns {number|null} 米
  */
 export function markerGroundHeight(viewer, lon, lat) {
-    const globe = viewer && viewer.scene && viewer.scene.globe
-    if (!globe || !Number.isFinite(lon) || !Number.isFinite(lat)) return null
-    return markerGroundHeightAt(viewer, globe, Cartographic.fromDegrees(lon, lat))
-}
-
-function markerGroundHeightAt(viewer, globe, carto) {
-    const key = heightKey(CesiumMath.toDegrees(carto.longitude), CesiumMath.toDegrees(carto.latitude))
-    const sample = sampleGroundHeightAt(globe, carto, key)
-    const anchor = groundAnchorFor(viewer)
-    anchor.remember(key, sample)
-    // `terrainLoading` 传函数:`tilesLoaded` 是 getter,只有真需要判断时才读。
-    return resolveGroundHeight({
-        sample,
-        lastKnown: anchor.lastKnown(key),
-        terrainLoading: () => globe.tilesLoaded !== true,
-    })
+    return deterministicGroundHeight(viewer, lon, lat)
+}
+
+/** 标记层的高度解算器(`projectMarkerEntity` 的参数):还没采到→不上图。 */
+function markerGroundHeightAt(viewer, lon, lat) {
+    return deterministicGroundHeight(viewer, lon, lat)
 }
 
-/** 让位/锚定口径的高度解算器(`projectMarkerEntity` 的参数)。 */
-function anchorGroundHeightAt(viewer, globe, carto) {
-    const sampled = sampleGroundHeightAt(globe, carto, heightKey(CesiumMath.toDegrees(carto.longitude), CesiumMath.toDegrees(carto.latitude)))
+/** 让位/锚定口径的高度解算器:没采到回椭球面 0 米(判据不能比可见性更严)。 */
+function anchorGroundHeightAt(viewer, lon, lat) {
+    const sampled = deterministicGroundHeight(viewer, lon, lat)
     return Number.isFinite(sampled) ? sampled : 0
 }
 
@@ -2469,7 +2501,9 @@ function projectMarkerEntity(viewer, entity, groundOf) {
     const carto = ellipsoid.cartesianToCartographic(original)
     if (!carto) return null
 
-    const height = groundOf(viewer, viewer.scene.globe, carto)
+    const lon = CesiumMath.toDegrees(carto.longitude)
+    const lat = CesiumMath.toDegrees(carto.latitude)
+    const height = groundOf(viewer, lon, lat)
     if (height === null) return null
 
     const world = Cartesian3.fromRadians(carto.longitude, carto.latitude, height)

+ 2 - 2
src/modules/cockpit/services/mapService.js

@@ -1085,8 +1085,8 @@ async function bootEngine(record) {
             const ground = engine.groundHeightAt(viewer, lon, lat)
             return cameraEngine.projectPivot({ lon, lat, height: Number.isFinite(ground) ? ground : 0 }, { visibleOnly: true })
         }
-        // DOM 标记层的投影(维护者 2026-11-11):没有可信地面就回 null=**不上图**,
-        // 绝不按椭球面把标记摆到地面以下(口径见 domain/groundAnchor.js)。
+        // DOM 标记层的投影(维护者 2026-11-11):按坐标采一次**最详细**地形高程并永不重采,
+        // 同一坐标永远同一高度,标记不会随镜头 LOD 跳;还没采到就回 null=先不上图。
         record.projectMarkerPoint = (lon, lat) => {
             const height = engine.markerGroundHeight(viewer, lon, lat)
             if (height === null) return null

+ 2 - 2
src/modules/cockpit/services/markerOverlay.js

@@ -11,8 +11,8 @@
  *     `recolorInner` 去色重染规则,但**不下采样**——256px 原生分辨率 data URL,
  *     DOM 里按帽径向下采样,永远锐利)。
  *   · 标签 = 真 DOM(圆角底板、装饰边、text-overflow、hover title 原生能力)。
- *   · 每帧 `postRender` 投影:贴**当前已渲染的地形网格**高度;没有可信地面时本轮不上图
- *     (不按椭球面 0 米摆到地底),口径见 `domain/groundAnchor.js`。
+ *   · 每帧 `postRender` 投影:贴**按坐标一次定值**的地形高程(不随镜头 LOD 变、
+ *     不会先入地再上浮);还没采到时本轮不上图。
  *
  * 与既有链路的关系(维护者裁定:DOM 完整复刻 billboard 功能后才替换它):
  *   · 合成计划层 `domain/markerPlan.js` **原样复用**(归属/几何/锚点/降饱和不换媒介);