modelDiagnostics.js 5.5 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
  1. // Development-only evidence. Diagnostic reads never force a scene render.
  2. import { createMotionIntervalWindow } from '../domain/frameMotionWindow.js'
  3. export function attachModelDiagnostics(scene, host, readState, { now = () => performance.now(), isMoving = () => false, pilotView = null } = {}) {
  4. const doc = host.ownerDocument
  5. const node = doc.createElement('details'), summary = doc.createElement('summary'), output = doc.createElement('output')
  6. node.setAttribute('data-model-diagnostics', '')
  7. summary.textContent = '模型加载诊断(开发)'
  8. 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'
  9. output.style.cssText = 'display:block;white-space:pre-wrap;max-height:180px;overflow:auto'
  10. output.setAttribute('aria-label', '模型加载诊断读数')
  11. node.append(summary, output); host.appendChild(node)
  12. const query = new URLSearchParams(doc.defaultView.location.search)
  13. const audit = query.get('lodAudit') === '1'
  14. let start = null, lastPaint = -Infinity, total = 0, label = 'initial', requested = null, viewResult = null, disposed = false
  15. const durations = [], records = []
  16. const motion = createMotionIntervalWindow()
  17. const add = (list, n) => { list.push(n); if (list.length > 240) list.shift() }
  18. const stats = list => {
  19. const sorted = [...list].sort((a, b) => a - b)
  20. 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 }
  21. }
  22. function paint() {
  23. const payload = { ...readState(), label, requested, viewResult, renderEvents: total, renderSubmissionDuration: stats(durations), movingFrameIntervals: motion.snapshot(), gpuTimeMeasured: false, fpsDuringIdleMeaningful: false, visibleDocument: !doc.hidden }
  24. output.textContent = JSON.stringify(payload, null, 2)
  25. if (audit) {
  26. records.push(payload); if (records.length > 240) records.shift()
  27. 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) }))
  28. 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 })) }))
  29. }
  30. return payload
  31. }
  32. if (audit && pilotView) {
  33. node.open = true
  34. node.style.left = '32%'; node.style.zIndex = '90'
  35. const controls = doc.createElement('div')
  36. for (const range of [6000, 3000, 1200, 450, 180, 320]) {
  37. const button = doc.createElement('button')
  38. button.textContent = '试点 ' + range + 'm'; button.dataset.lodAuditRange = String(range)
  39. button.addEventListener('click', async () => {
  40. label = 'view-' + range; requested = { range, atMs: now() }; viewResult = null
  41. durations.length = 0; motion.reset()
  42. button.disabled = true
  43. try { viewResult = await pilotView(range) } catch (error) { viewResult = { error: String(error.message || error) } }
  44. if (!disposed) { button.disabled = false; paint() }
  45. })
  46. controls.appendChild(button)
  47. }
  48. const exportButton = doc.createElement('button')
  49. exportButton.textContent = '导出本次 LOD 观测'
  50. exportButton.addEventListener('click', () => {
  51. paint()
  52. const a = doc.createElement('a'), url = URL.createObjectURL(new Blob([JSON.stringify(records, null, 2)], { type: 'application/json' }))
  53. a.href = url; a.download = 'lod-audit.json'; a.click(); URL.revokeObjectURL(url)
  54. })
  55. controls.appendChild(exportButton); node.insertBefore(controls, output)
  56. }
  57. const pre = scene.preRender.addEventListener(() => { start = now() })
  58. const post = scene.postRender.addEventListener(() => {
  59. const t = now(); total++
  60. if (!doc.hidden) {
  61. if (start !== null) add(durations, t - start)
  62. motion.push(t, isMoving())
  63. } else {
  64. // Hidden frames do not sample, but they must break "consecutive motion"
  65. // so the first moving frame after returning cannot smuggle an idle gap in.
  66. motion.markGap(t)
  67. }
  68. start = null
  69. if (t - lastPaint >= 1000) { lastPaint = t; paint() }
  70. })
  71. // Audit mode also observes load progress while requestRenderMode is idle.
  72. const timer = audit ? setInterval(paint, 2000) : null
  73. summary.addEventListener('click', paint); paint()
  74. return () => { disposed = true; if (timer) clearInterval(timer); pre(); post(); node.remove() }
  75. }