// Development-only evidence. Diagnostic reads never force a scene render. import { createMotionIntervalWindow } from '../domain/frameMotionWindow.js' export function attachModelDiagnostics(scene, host, readState, { now = () => performance.now(), isMoving = () => false, pilotView = null } = {}) { const doc = host.ownerDocument const node = doc.createElement('details'), summary = doc.createElement('summary'), output = doc.createElement('output') node.setAttribute('data-model-diagnostics', '') summary.textContent = '模型加载诊断(开发)' node.style.cssText = 'position:absolute;bottom:4px;left:4px;z-index:8;max-width:480px;font:11px monospace;background:#061827ee;color:#bad4e0;padding:4px;pointer-events:auto' output.style.cssText = 'display:block;white-space:pre-wrap;max-height:180px;overflow:auto' output.setAttribute('aria-label', '模型加载诊断读数') node.append(summary, output); host.appendChild(node) const query = new URLSearchParams(doc.defaultView.location.search) const audit = query.get('lodAudit') === '1' let start = null, lastPaint = -Infinity, total = 0, label = 'initial', requested = null, viewResult = null, disposed = false const durations = [], records = [] const motion = createMotionIntervalWindow() const add = (list, n) => { list.push(n); if (list.length > 240) list.shift() } const stats = list => { const sorted = [...list].sort((a, b) => a - b) return { samples: sorted.length, p50Ms: sorted.length ? +sorted[Math.floor(sorted.length * .5)].toFixed(2) : null, p95Ms: sorted.length ? +sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * .95))].toFixed(2) : null, maxMs: sorted.length ? +sorted[sorted.length - 1].toFixed(2) : null, over50Ms: sorted.filter(n => n > 50).length } } function paint() { const payload = { ...readState(), label, requested, viewResult, renderEvents: total, renderSubmissionDuration: stats(durations), movingFrameIntervals: motion.snapshot(), gpuTimeMeasured: false, fpsDuringIdleMeaningful: false, visibleDocument: !doc.hidden } output.textContent = JSON.stringify(payload, null, 2) if (audit) { records.push(payload); if (records.length > 240) records.shift() const regions = (payload.regions || []).map(r => ({ ...r, renderedTiles: undefined, renderedByLevel: (r.renderedTiles || []).reduce((a,t) => { a[t.level] = (a[t.level] || 0) + 1; return a }, {}), renderedExamples: (r.renderedTiles || []).slice(0, 3) })) console.log('[LOD-AUDIT] ' + JSON.stringify({ label, requested, viewResult: viewResult && { applied: viewResult.applied, height: viewResult.height, error: viewResult.error }, renderEvents: total, sub: payload.renderSubmissionDuration, moving: payload.movingFrameIntervals, camera: payload.camera && { pivot: payload.camera.shownPivot, hpr: payload.camera.shownHpr, settled: payload.camera.settled }, regions: regions.map(r => ({ id: r.id, show: r.show, masked: r.coarseMasked, loaded: r.loaded, pending: r.pending, processing: r.processing, sse: r.maximumScreenSpaceError, effective: r.memoryAdjustedScreenSpaceError, memoryMB: r.memoryBytes && +(r.memoryBytes / 1048576).toFixed(1), firstContentMs: r.firstContentMs, rendered: r.renderedByLevel })) })) } return payload } if (audit && pilotView) { node.open = true node.style.left = '32%'; node.style.zIndex = '90' const controls = doc.createElement('div') for (const range of [6000, 3000, 1200, 450, 180, 320]) { const button = doc.createElement('button') button.textContent = '试点 ' + range + 'm'; button.dataset.lodAuditRange = String(range) button.addEventListener('click', async () => { label = 'view-' + range; requested = { range, atMs: now() }; viewResult = null durations.length = 0; motion.reset() button.disabled = true try { viewResult = await pilotView(range) } catch (error) { viewResult = { error: String(error.message || error) } } if (!disposed) { button.disabled = false; paint() } }) controls.appendChild(button) } const exportButton = doc.createElement('button') exportButton.textContent = '导出本次 LOD 观测' exportButton.addEventListener('click', () => { paint() const a = doc.createElement('a'), url = URL.createObjectURL(new Blob([JSON.stringify(records, null, 2)], { type: 'application/json' })) a.href = url; a.download = 'lod-audit.json'; a.click(); URL.revokeObjectURL(url) }) controls.appendChild(exportButton); node.insertBefore(controls, output) } const pre = scene.preRender.addEventListener(() => { start = now() }) const post = scene.postRender.addEventListener(() => { const t = now(); total++ if (!doc.hidden) { if (start !== null) add(durations, t - start) motion.push(t, isMoving()) } else { // Hidden frames do not sample, but they must break "consecutive motion" // so the first moving frame after returning cannot smuggle an idle gap in. motion.markGap(t) } start = null if (t - lastPaint >= 1000) { lastPaint = t; paint() } }) // Audit mode also observes load progress while requestRenderMode is idle. const timer = audit ? setInterval(paint, 2000) : null summary.addEventListener('click', paint); paint() return () => { disposed = true; if (timer) clearInterval(timer); pre(); post(); node.remove() } }