@voluma/vlam 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +0 -2
  2. package/dist/chunk-loader-DlNwggfG.js.map +1 -1
  3. package/dist/{index.d.ts → core/index.d.ts} +1 -1
  4. package/dist/{splat-budget.d.ts → core/splat-budget.d.ts} +1 -1
  5. package/dist/{splat-data.d.ts → core/splat-data.d.ts} +1 -1
  6. package/dist/{splat-mesh-types.d.ts → core/splat-mesh-types.d.ts} +5 -2
  7. package/dist/{effects.d.ts → effects/index.d.ts} +3 -3
  8. package/dist/effects.js.map +1 -1
  9. package/dist/formats/ksplat/parse-ksplat.d.ts +1 -1
  10. package/dist/formats/ksplat.js.map +1 -1
  11. package/dist/formats/lcc/collision-mesh.d.ts +2 -2
  12. package/dist/formats/lcc/collision-partition.d.ts +1 -1
  13. package/dist/formats/lcc/lcc.d.ts +3 -3
  14. package/dist/formats/lcc/lcc2.d.ts +2 -2
  15. package/dist/formats/lcc/parse-lcc.d.ts +1 -1
  16. package/dist/formats/lcc.js.map +1 -1
  17. package/dist/formats/ply/parse-compressed-ply.d.ts +2 -2
  18. package/dist/formats/ply/parse-splat-ply.d.ts +2 -2
  19. package/dist/formats/ply.js.map +1 -1
  20. package/dist/formats/rad/frontier-worker-protocol.d.ts +8 -1
  21. package/dist/formats/rad/index.d.ts +1 -1
  22. package/dist/formats/rad/parse-rad.d.ts +1 -1
  23. package/dist/formats/rad/rad-foveated-source.d.ts +3 -3
  24. package/dist/formats/rad/rad.d.ts +5 -5
  25. package/dist/formats/rad.js.map +1 -1
  26. package/dist/formats/sog/parse-sog.d.ts +2 -2
  27. package/dist/formats/sog.js.map +1 -1
  28. package/dist/formats/splat/parse-splat.d.ts +1 -1
  29. package/dist/formats/splat.js.map +1 -1
  30. package/dist/formats/spz/parse-spz.d.ts +1 -1
  31. package/dist/formats/spz.js.map +1 -1
  32. package/dist/half-float-Cs4FDq8Q.js.map +1 -1
  33. package/dist/index.js +3 -3
  34. package/dist/index.js.map +1 -1
  35. package/dist/{chunk-loader.d.ts → loaders/chunk-loader.d.ts} +2 -2
  36. package/dist/{load-splat-data.d.ts → loaders/load-splat-data.d.ts} +1 -1
  37. package/dist/{load-worker-protocol.d.ts → loaders/load-worker-protocol.d.ts} +2 -2
  38. package/dist/loaders.js.map +1 -1
  39. package/dist/loading-wLo8vRbA.js.map +1 -1
  40. package/dist/lod-scheduler-B0a_uBlv.js.map +1 -1
  41. package/dist/logging-BfPdd7NJ.js.map +1 -1
  42. package/dist/ply-header-B9WllbPg.js.map +1 -1
  43. package/dist/{radix-sorter-R0FCSqKu.js → radix-sorter-BrbUg_CV.js} +2 -2
  44. package/dist/radix-sorter-BrbUg_CV.js.map +1 -0
  45. package/dist/{relighting-CSQF3UcL.js → relighting-Tiwep8yd.js} +302 -294
  46. package/dist/relighting-Tiwep8yd.js.map +1 -0
  47. package/dist/{selection-volume.d.ts → selection/selection-volume.d.ts} +1 -1
  48. package/dist/{splat-partition.d.ts → selection/splat-partition.d.ts} +1 -1
  49. package/dist/selection.js.map +1 -1
  50. package/dist/sh-pack-D5wAe5gg.js.map +1 -1
  51. package/dist/splat-budget-DuVZspPQ.js.map +1 -1
  52. package/dist/splat-data-BZM5dhhx.js.map +1 -1
  53. package/dist/{splat-mesh-aQdcWA6v.js → splat-mesh-CsLOQb08.js} +3 -3
  54. package/dist/splat-mesh-CsLOQb08.js.map +1 -0
  55. package/dist/splat-mesh-types-BZIko-_9.js.map +1 -1
  56. package/dist/{static-lod-splat-mesh.d.ts → static-lod/static-lod-splat-mesh.d.ts} +2 -2
  57. package/dist/{static-lod-worker-protocol.d.ts → static-lod/static-lod-worker-protocol.d.ts} +1 -1
  58. package/dist/{static-lod.d.ts → static-lod/static-lod.d.ts} +1 -1
  59. package/dist/static-lod.js +1 -1
  60. package/dist/static-lod.js.map +1 -1
  61. package/dist/{dataset-source.d.ts → streaming/dataset-source.d.ts} +1 -1
  62. package/dist/{streaming.d.ts → streaming/index.d.ts} +3 -3
  63. package/dist/{lod-source.d.ts → streaming/lod-source.d.ts} +4 -4
  64. package/dist/{streamed-splat-mesh-utils.d.ts → streaming/streamed-splat-mesh-utils.d.ts} +2 -2
  65. package/dist/{streamed-splat-mesh.d.ts → streaming/streamed-splat-mesh.d.ts} +45 -5
  66. package/dist/streaming.js +844 -810
  67. package/dist/streaming.js.map +1 -1
  68. package/dist/{unified-splat-mesh.d.ts → unified/unified-splat-mesh.d.ts} +13 -6
  69. package/dist/{work-buffer-gather.d.ts → unified/work-buffer-gather.d.ts} +5 -5
  70. package/dist/{work-buffer-material.d.ts → unified/work-buffer-material.d.ts} +1 -1
  71. package/dist/unified.js +280 -272
  72. package/dist/unified.js.map +1 -1
  73. package/dist/webgpu-limits-Y3CI_6_6.js.map +1 -1
  74. package/package.json +9 -9
  75. package/dist/radix-sorter-R0FCSqKu.js.map +0 -1
  76. package/dist/relighting-CSQF3UcL.js.map +0 -1
  77. package/dist/splat-mesh-aQdcWA6v.js.map +0 -1
  78. /package/dist/{compute-sorter.d.ts → core/compute-sorter.d.ts} +0 -0
  79. /package/dist/{create-splat-renderer.d.ts → core/create-splat-renderer.d.ts} +0 -0
  80. /package/dist/{depth-of-field.d.ts → core/depth-of-field.d.ts} +0 -0
  81. /package/dist/{half-float.d.ts → core/half-float.d.ts} +0 -0
  82. /package/dist/{logging.d.ts → core/logging.d.ts} +0 -0
  83. /package/dist/{merged-splat-mesh.d.ts → core/merged-splat-mesh.d.ts} +0 -0
  84. /package/dist/{orientation.d.ts → core/orientation.d.ts} +0 -0
  85. /package/dist/{radix-sort.d.ts → core/radix-sort.d.ts} +0 -0
  86. /package/dist/{radix-sorter.d.ts → core/radix-sorter.d.ts} +0 -0
  87. /package/dist/{relighting-pass.d.ts → core/relighting-pass.d.ts} +0 -0
  88. /package/dist/{relighting.d.ts → core/relighting.d.ts} +0 -0
  89. /package/dist/{sh-pack.d.ts → core/sh-pack.d.ts} +0 -0
  90. /package/dist/{sort-scheduler.d.ts → core/sort-scheduler.d.ts} +0 -0
  91. /package/dist/{sort-worker-protocol.d.ts → core/sort-worker-protocol.d.ts} +0 -0
  92. /package/dist/{sorter.d.ts → core/sorter.d.ts} +0 -0
  93. /package/dist/{source-transform.d.ts → core/source-transform.d.ts} +0 -0
  94. /package/dist/{splat-depth-pack.d.ts → core/splat-depth-pack.d.ts} +0 -0
  95. /package/dist/{splat-mesh-material.d.ts → core/splat-mesh-material.d.ts} +0 -0
  96. /package/dist/{splat-mesh-picking.d.ts → core/splat-mesh-picking.d.ts} +0 -0
  97. /package/dist/{splat-mesh-pool.d.ts → core/splat-mesh-pool.d.ts} +0 -0
  98. /package/dist/{splat-mesh.d.ts → core/splat-mesh.d.ts} +0 -0
  99. /package/dist/{splat-modifier-stack.d.ts → core/splat-modifier-stack.d.ts} +0 -0
  100. /package/dist/{splat-modifier.d.ts → core/splat-modifier.d.ts} +0 -0
  101. /package/dist/{splat-query.d.ts → core/splat-query.d.ts} +0 -0
  102. /package/dist/{splat-sort-bounds.d.ts → core/splat-sort-bounds.d.ts} +0 -0
  103. /package/dist/{storage-attribute-mirror.d.ts → core/storage-attribute-mirror.d.ts} +0 -0
  104. /package/dist/{webgpu-limits.d.ts → core/webgpu-limits.d.ts} +0 -0
  105. /package/dist/{worker-sorter.d.ts → core/worker-sorter.d.ts} +0 -0
  106. /package/dist/{xr-view.d.ts → core/xr-view.d.ts} +0 -0
  107. /package/dist/{loaders.d.ts → loaders/index.d.ts} +0 -0
  108. /package/dist/{loading.d.ts → loaders/loading.d.ts} +0 -0
  109. /package/dist/{ply-header.d.ts → loaders/ply-header.d.ts} +0 -0
  110. /package/dist/{selection.d.ts → selection/index.d.ts} +0 -0
  111. /package/dist/{static-lod-entry.d.ts → static-lod/index.d.ts} +0 -0
  112. /package/dist/{budget-governor.d.ts → streaming/budget-governor.d.ts} +0 -0
  113. /package/dist/{camera-budget-governor.d.ts → streaming/camera-budget-governor.d.ts} +0 -0
  114. /package/dist/{chunk-cache-budget.d.ts → streaming/chunk-cache-budget.d.ts} +0 -0
  115. /package/dist/{chunk-fetch-scheduler.d.ts → streaming/chunk-fetch-scheduler.d.ts} +0 -0
  116. /package/dist/{lod-manifest.d.ts → streaming/lod-manifest.d.ts} +0 -0
  117. /package/dist/{lod-scheduler.d.ts → streaming/lod-scheduler.d.ts} +0 -0
  118. /package/dist/{unified.d.ts → unified/index.d.ts} +0 -0
  119. /package/dist/{unified-work-buffer.d.ts → unified/unified-work-buffer.d.ts} +0 -0
@@ -1 +1 @@
1
- {"version":3,"file":"sh-pack-D5wAe5gg.js","sources":["../src/lib/sh-pack.ts"],"sourcesContent":["/**\n * Packing and requantization for per-splat higher-order SH.\n *\n * Every streamed format that keeps view-dependent color stores it in one\n * layout: {@link SplatPackedShData}, a `DecodePacked_11_10_11` word per\n * coefficient (R: bits 0-10 /2047, G: 11-20 /1023, B: 21-31 /2047) dequantized\n * across a per-channel range. LCC `Quality` captures and `.rad` deliver it directly; a\n * streamed SOG (or `.lcc2`) palette is *converted* into it at decode\n * ({@link packPaletteSh}) so it survives the shared pool - see\n * `docs/formats/streamed-shn-notes.md` (M11). The pool decodes the whole scene through\n * one range uniform, so a chunk whose range differs is requantized into the\n * scene's range at append ({@link requantizeShWord}).\n *\n * Pure array math, no THREE - safe to import in the decode worker.\n */\nimport type { SplatPackedShData, SplatShData } from './splat-data';\n\n/** A per-channel `[min, max]` dequantization range for packed SH. */\nexport type ShRange = SplatPackedShData['range'];\n\n/** The 11/10/11 field maxima of a packed SH word, per channel. */\nconst SH_FIELD_MAX = [2047, 1023, 2047] as const;\n\n/** Coefficients per channel for a band count (0 → none, 3 → 3rd order). */\nexport function shCoefficientCount(bands: number): number {\n return [0, 3, 8, 15][bands] ?? 0;\n}\n\n/**\n * Quantizes splat-major coefficient triples into packed 11/10/11 words:\n * `count * shCoefficientCount(bands)` words, each holding one coefficient's\n * (R, G, B). `knownExtent` pins the symmetric range to a scene-wide value\n * (values outside clamp); without it the extent is measured from\n * `coefficients` - only safe when they are the whole scene, since every chunk\n * must share one range.\n */\nexport function packShCoefficients(\n coefficients: Float32Array,\n count: number,\n bands: 1 | 2 | 3,\n knownExtent?: number,\n): SplatPackedShData {\n const words = shCoefficientCount(bands);\n let extent = knownExtent ?? 0;\n if (knownExtent === undefined) {\n for (const value of coefficients) extent = Math.max(extent, Math.abs(value));\n }\n // The shader has one range for every band. A symmetric common range\n // preserves the signed SH convention; exact 0 is not representable (it falls\n // between the two middle codes, a half-LSB positive bias - see the tests).\n const range = {\n min: [-extent, -extent, -extent] as const,\n max: [extent, extent, extent] as const,\n };\n const packed = new Uint32Array(count * words);\n const divisor = extent || 1;\n for (let i = 0; i < packed.length; i++) {\n const base = i * 3;\n const encode = (value: number, max: number): number =>\n Math.min(max, Math.max(0, Math.round((value / divisor + 1) * 0.5 * max)));\n const r = encode(coefficients[base] as number, 2047);\n const g = encode(coefficients[base + 1] as number, 1023);\n const b = encode(coefficients[base + 2] as number, 2047);\n packed[i] = (r | (g << 11) | (b << 21)) >>> 0;\n }\n return { bands, packed, range };\n}\n\n/**\n * Converts a palette-compressed SH source ({@link SplatShData}, as SOG/`.lcc2`\n * store it) into the per-splat packed form, keeping the lowest `bands` bands.\n * Each splat's palette label indexes the codebook image; the coefficients are\n * read out into splat-major triples and quantized against this chunk's own\n * measured extent. Later chunks may measure a different extent - the pool\n * requantizes any mismatch into the scene's range at append.\n */\nexport function packPaletteSh(sh: SplatShData, count: number, bands: 1 | 2 | 3): SplatPackedShData {\n const want = shCoefficientCount(bands);\n // The palette stores its own (possibly higher) band count; a lower request is\n // a prefix of each entry's coefficients, since bands run low-order first.\n const have = shCoefficientCount(sh.bands);\n const width = sh.paletteWidth;\n const coefficients = new Float32Array(count * want * 3);\n for (let i = 0; i < count; i++) {\n const label = sh.labels[i] as number;\n // SOG centroids layout: entry n, coefficient c at column (n % 64) · have + c,\n // row ⌊n / 64⌋; R/G/B channels hold that coefficient for the R/G/B SH.\n const column0 = (label % 64) * have;\n const row = Math.floor(label / 64);\n // Labels are 16-bit and the parser validates only the labels image, not\n // the label *values*. An out-of-bounds palette read yields NaN\n // coefficients, NaN-poisoning this chunk's measured range - and, were it\n // the scene's first SH chunk, locking a NaN pool range that destroys SH\n // scene-wide. Throw instead, like the `.rad` label path does.\n // Check the row extent as well as the linear index: a label whose entry\n // would wrap past the row's end must fail rather than silently reading\n // the start of another entry on the next row.\n if (column0 + have > width || (row * width + column0 + have) * 4 > sh.palette.length) {\n throw new Error(`packPaletteSh: splat ${i} has SH label ${label} outside the palette.`);\n }\n // Read the min(want, have) low-order coefficients; if the caller asked for\n // more bands than the palette carries, the surplus stays 0 (neutral) so the\n // packed band count still matches the pool's, rather than a band mismatch\n // that would drop the SH entirely.\n const readable = Math.min(want, have);\n for (let c = 0; c < readable; c++) {\n const texel = (row * width + column0 + c) * 4;\n const base = (i * want + c) * 3;\n coefficients[base] = sh.palette[texel] as number;\n coefficients[base + 1] = sh.palette[texel + 1] as number;\n coefficients[base + 2] = sh.palette[texel + 2] as number;\n }\n }\n return packShCoefficients(coefficients, count, bands);\n}\n\n/** The packed word that decodes to 0.0 in every channel under `range`. */\nexport function neutralShWord(range: ShRange): number {\n const code = (lo: number, hi: number, maxCode: number): number => {\n if (hi === lo) return 0;\n const t = (0 - lo) / (hi - lo);\n return Math.max(0, Math.min(maxCode, Math.round(t * maxCode)));\n };\n return (\n (code(range.min[0], range.max[0], 2047) |\n (code(range.min[1], range.max[1], 1023) << 11) |\n (code(range.min[2], range.max[2], 2047) << 21)) >>>\n 0\n );\n}\n\n/** Whether two packed-SH ranges are bit-identical (no requantization needed). */\nexport function packedRangesEqual(a: ShRange, b: ShRange): boolean {\n return (\n a.min[0] === b.min[0] &&\n a.min[1] === b.min[1] &&\n a.min[2] === b.min[2] &&\n a.max[0] === b.max[0] &&\n a.max[1] === b.max[1] &&\n a.max[2] === b.max[2]\n );\n}\n\nfunction lerp(a: number, b: number, t: number): number {\n return a + (b - a) * t;\n}\n\n/**\n * Re-encodes one packed word so it decodes to the same three channel values\n * under `to` as it did under `from`. Values outside `to` clip to its endpoints;\n * a zero-width `to` channel collapses to code 0. This is what lets chunks\n * quantized against different ranges share one pool range.\n */\nexport function requantizeShWord(word: number, from: ShRange, to: ShRange): number {\n const codes = [word & 0x7ff, (word >>> 11) & 0x3ff, (word >>> 21) & 0x7ff];\n let out = 0;\n let shift = 0;\n for (let ch = 0; ch < 3; ch++) {\n const maxCode = SH_FIELD_MAX[ch] as number;\n const value = lerp(\n from.min[ch] as number,\n from.max[ch] as number,\n (codes[ch] as number) / maxCode,\n );\n const lo = to.min[ch] as number;\n const hi = to.max[ch] as number;\n const t = hi === lo ? 0 : (value - lo) / (hi - lo);\n const code = Math.max(0, Math.min(maxCode, Math.round(t * maxCode)));\n out |= code << shift;\n shift += ch === 0 ? 11 : 10;\n }\n return out >>> 0;\n}\n"],"names":["SH_FIELD_MAX","shCoefficientCount","bands","packShCoefficients","coefficients","count","knownExtent","words","extent","value","range","packed","divisor","i","base","encode","max","r","g","b","neutralShWord","code","lo","hi","maxCode","t","packedRangesEqual","a","lerp","requantizeShWord","word","from","to","codes","out","shift","ch"],"mappings":"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"}
1
+ {"version":3,"file":"sh-pack-D5wAe5gg.js","sources":["../src/lib/core/sh-pack.ts"],"sourcesContent":["/**\n * Packing and requantization for per-splat higher-order SH.\n *\n * Every streamed format that keeps view-dependent color stores it in one\n * layout: {@link SplatPackedShData}, a `DecodePacked_11_10_11` word per\n * coefficient (R: bits 0-10 /2047, G: 11-20 /1023, B: 21-31 /2047) dequantized\n * across a per-channel range. LCC `Quality` captures and `.rad` deliver it directly; a\n * streamed SOG (or `.lcc2`) palette is *converted* into it at decode\n * ({@link packPaletteSh}) so it survives the shared pool - see\n * `docs/formats/streamed-shn-notes.md` (M11). The pool decodes the whole scene through\n * one range uniform, so a chunk whose range differs is requantized into the\n * scene's range at append ({@link requantizeShWord}).\n *\n * Pure array math, no THREE - safe to import in the decode worker.\n */\nimport type { SplatPackedShData, SplatShData } from './splat-data';\n\n/** A per-channel `[min, max]` dequantization range for packed SH. */\nexport type ShRange = SplatPackedShData['range'];\n\n/** The 11/10/11 field maxima of a packed SH word, per channel. */\nconst SH_FIELD_MAX = [2047, 1023, 2047] as const;\n\n/** Coefficients per channel for a band count (0 → none, 3 → 3rd order). */\nexport function shCoefficientCount(bands: number): number {\n return [0, 3, 8, 15][bands] ?? 0;\n}\n\n/**\n * Quantizes splat-major coefficient triples into packed 11/10/11 words:\n * `count * shCoefficientCount(bands)` words, each holding one coefficient's\n * (R, G, B). `knownExtent` pins the symmetric range to a scene-wide value\n * (values outside clamp); without it the extent is measured from\n * `coefficients` - only safe when they are the whole scene, since every chunk\n * must share one range.\n */\nexport function packShCoefficients(\n coefficients: Float32Array,\n count: number,\n bands: 1 | 2 | 3,\n knownExtent?: number,\n): SplatPackedShData {\n const words = shCoefficientCount(bands);\n let extent = knownExtent ?? 0;\n if (knownExtent === undefined) {\n for (const value of coefficients) extent = Math.max(extent, Math.abs(value));\n }\n // The shader has one range for every band. A symmetric common range\n // preserves the signed SH convention; exact 0 is not representable (it falls\n // between the two middle codes, a half-LSB positive bias - see the tests).\n const range = {\n min: [-extent, -extent, -extent] as const,\n max: [extent, extent, extent] as const,\n };\n const packed = new Uint32Array(count * words);\n const divisor = extent || 1;\n for (let i = 0; i < packed.length; i++) {\n const base = i * 3;\n const encode = (value: number, max: number): number =>\n Math.min(max, Math.max(0, Math.round((value / divisor + 1) * 0.5 * max)));\n const r = encode(coefficients[base] as number, 2047);\n const g = encode(coefficients[base + 1] as number, 1023);\n const b = encode(coefficients[base + 2] as number, 2047);\n packed[i] = (r | (g << 11) | (b << 21)) >>> 0;\n }\n return { bands, packed, range };\n}\n\n/**\n * Converts a palette-compressed SH source ({@link SplatShData}, as SOG/`.lcc2`\n * store it) into the per-splat packed form, keeping the lowest `bands` bands.\n * Each splat's palette label indexes the codebook image; the coefficients are\n * read out into splat-major triples and quantized against this chunk's own\n * measured extent. Later chunks may measure a different extent - the pool\n * requantizes any mismatch into the scene's range at append.\n */\nexport function packPaletteSh(sh: SplatShData, count: number, bands: 1 | 2 | 3): SplatPackedShData {\n const want = shCoefficientCount(bands);\n // The palette stores its own (possibly higher) band count; a lower request is\n // a prefix of each entry's coefficients, since bands run low-order first.\n const have = shCoefficientCount(sh.bands);\n const width = sh.paletteWidth;\n const coefficients = new Float32Array(count * want * 3);\n for (let i = 0; i < count; i++) {\n const label = sh.labels[i] as number;\n // SOG centroids layout: entry n, coefficient c at column (n % 64) · have + c,\n // row ⌊n / 64⌋; R/G/B channels hold that coefficient for the R/G/B SH.\n const column0 = (label % 64) * have;\n const row = Math.floor(label / 64);\n // Labels are 16-bit and the parser validates only the labels image, not\n // the label *values*. An out-of-bounds palette read yields NaN\n // coefficients, NaN-poisoning this chunk's measured range - and, were it\n // the scene's first SH chunk, locking a NaN pool range that destroys SH\n // scene-wide. Throw instead, like the `.rad` label path does.\n // Check the row extent as well as the linear index: a label whose entry\n // would wrap past the row's end must fail rather than silently reading\n // the start of another entry on the next row.\n if (column0 + have > width || (row * width + column0 + have) * 4 > sh.palette.length) {\n throw new Error(`packPaletteSh: splat ${i} has SH label ${label} outside the palette.`);\n }\n // Read the min(want, have) low-order coefficients; if the caller asked for\n // more bands than the palette carries, the surplus stays 0 (neutral) so the\n // packed band count still matches the pool's, rather than a band mismatch\n // that would drop the SH entirely.\n const readable = Math.min(want, have);\n for (let c = 0; c < readable; c++) {\n const texel = (row * width + column0 + c) * 4;\n const base = (i * want + c) * 3;\n coefficients[base] = sh.palette[texel] as number;\n coefficients[base + 1] = sh.palette[texel + 1] as number;\n coefficients[base + 2] = sh.palette[texel + 2] as number;\n }\n }\n return packShCoefficients(coefficients, count, bands);\n}\n\n/** The packed word that decodes to 0.0 in every channel under `range`. */\nexport function neutralShWord(range: ShRange): number {\n const code = (lo: number, hi: number, maxCode: number): number => {\n if (hi === lo) return 0;\n const t = (0 - lo) / (hi - lo);\n return Math.max(0, Math.min(maxCode, Math.round(t * maxCode)));\n };\n return (\n (code(range.min[0], range.max[0], 2047) |\n (code(range.min[1], range.max[1], 1023) << 11) |\n (code(range.min[2], range.max[2], 2047) << 21)) >>>\n 0\n );\n}\n\n/** Whether two packed-SH ranges are bit-identical (no requantization needed). */\nexport function packedRangesEqual(a: ShRange, b: ShRange): boolean {\n return (\n a.min[0] === b.min[0] &&\n a.min[1] === b.min[1] &&\n a.min[2] === b.min[2] &&\n a.max[0] === b.max[0] &&\n a.max[1] === b.max[1] &&\n a.max[2] === b.max[2]\n );\n}\n\nfunction lerp(a: number, b: number, t: number): number {\n return a + (b - a) * t;\n}\n\n/**\n * Re-encodes one packed word so it decodes to the same three channel values\n * under `to` as it did under `from`. Values outside `to` clip to its endpoints;\n * a zero-width `to` channel collapses to code 0. This is what lets chunks\n * quantized against different ranges share one pool range.\n */\nexport function requantizeShWord(word: number, from: ShRange, to: ShRange): number {\n const codes = [word & 0x7ff, (word >>> 11) & 0x3ff, (word >>> 21) & 0x7ff];\n let out = 0;\n let shift = 0;\n for (let ch = 0; ch < 3; ch++) {\n const maxCode = SH_FIELD_MAX[ch] as number;\n const value = lerp(\n from.min[ch] as number,\n from.max[ch] as number,\n (codes[ch] as number) / maxCode,\n );\n const lo = to.min[ch] as number;\n const hi = to.max[ch] as number;\n const t = hi === lo ? 0 : (value - lo) / (hi - lo);\n const code = Math.max(0, Math.min(maxCode, Math.round(t * maxCode)));\n out |= code << shift;\n shift += ch === 0 ? 11 : 10;\n }\n return out >>> 0;\n}\n"],"names":["SH_FIELD_MAX","shCoefficientCount","bands","packShCoefficients","coefficients","count","knownExtent","words","extent","value","range","packed","divisor","i","base","encode","max","r","g","b","neutralShWord","code","lo","hi","maxCode","t","packedRangesEqual","a","lerp","requantizeShWord","word","from","to","codes","out","shift","ch"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"splat-budget-DuVZspPQ.js","sources":["../src/lib/splat-budget.ts"],"sourcesContent":["/**\n * Device profiling and the default active-splat budget.\n *\n * Streaming is splat-count budgeted rather than byte budgeted: every cost in\n * the renderer (pool textures, sort buffers) scales with the number of\n * resident splats - roughly 64 bytes of GPU memory per splat (three RGBA32F\n * data textures + one RGBA8, plus the sort index/order/bucket buffers), or\n * about 48 bytes when `poolFloatTextures: 'float16'` halves centers and\n * covarianceA; plus about 52 more bytes of CPU-side backing per splat of\n * pool capacity. Capping the active splat count therefore caps memory\n * directly, which is what matters on mobile GPUs.\n */\n\nimport type { StreamedSplatFormat } from './loading';\nimport { shCoefficientCount } from './sh-pack';\n\n/**\n * Desktop GPU capability class for budget / quality defaults.\n *\n * Mobile still keys off {@link SplatDeviceProfile.isMobile}; this splits\n * non-mobile machines that would otherwise share the ~8M workstation path.\n * Unset means \"unknown\" - keep the pre-existing desktop defaults (fail open).\n */\nexport type SplatGpuClass = 'discrete' | 'integrated' | 'fallback';\n\n/** Browser/device signals used for deterministic budget selection. */\nexport interface SplatDeviceProfile {\n /** Coarse memory estimate in GiB, when exposed by the browser. */\n deviceMemoryGb?: number;\n /** Whether the runtime is an iOS/iPadOS-class device. */\n isIOS?: boolean;\n /** Whether the runtime is a phone/tablet-class device (includes iOS). */\n isMobile?: boolean;\n /**\n * Whether the runtime is a standalone XR headset browser (Quest, Pico, …).\n * Headsets are mobile-class GPUs asked to fill a stereo framebuffer larger\n * than a 4K desktop, so they get their own, tighter defaults.\n */\n isHeadset?: boolean;\n /**\n * Whether this is a *budget* phone rather than a flagship one.\n *\n * `isMobile` cannot tell a Galaxy A51 from an iPhone 15 Pro, and the memory\n * signal cannot either - Chrome privacy-caps `deviceMemory` at 8 GiB, so the\n * flagship and the mid-ranger both land on the same ceiling despite an order\n * of magnitude between their GPUs. A coarse signal, and a *downward* one: a\n * reading of ≤4 is trustworthy because the cap only ever lowers it, while a\n * reading of 8 says nothing. {@link hasWebGpu} is the sharper signal where it\n * applies, since it names a difference in how the sort runs rather than\n * guessing capability from RAM.\n */\n isLowPower?: boolean;\n /**\n * Whether the runtime exposes WebGPU at all.\n *\n * `false` means the renderer will take the WebGL2 fallback, where the depth\n * sort runs on the CPU rather than as a GPU compute pass - a different cost\n * curve, not just a slower one. See {@link resolveSplatBudget}.\n *\n * Detected from the presence of `navigator.gpu`, which is a *browser support*\n * signal rather than an adapter one: a runtime that exposes the API but whose\n * `requestAdapter` fails (driver blocklist) still reports `true` here and will\n * fall back to WebGL2 anyway. A host that knows its renderer's real backend\n * can correct this by passing its own profile.\n */\n hasWebGpu?: boolean;\n /**\n * Desktop GPU class from {@link probeSplatGpuClass} / {@link classifySplatGpuClass}.\n * Omit when unknown so workstations without adapter info keep the 8M path.\n */\n gpuClass?: SplatGpuClass;\n}\n\n/** Adapter identifying fields used by {@link classifySplatGpuClass}. */\nexport interface SplatGpuAdapterInfo {\n vendor?: string;\n architecture?: string;\n device?: string;\n description?: string;\n isFallbackAdapter?: boolean;\n}\n\n/**\n * How expensive a format's splats are *per splat*, which is not the same across\n * formats and is the reason one mobile budget cannot serve them all.\n *\n * - `'sampled'` - coarser LOD levels are subsamples of the same surface, so a\n * splat costs about the same however deep the cut is. Streamed SOG.\n * - `'lcc'` - XGRIDS' levels are decimated *alternatives* whose splats merge\n * into wide flat discs (see {@link liftBudgetToFinestLevel}). Cost per splat\n * therefore *rises* as the budget falls, because a tighter budget serves\n * coarser levels and each of their splats covers more pixels. `.rad`, `.lcc`,\n * `.lcc2`.\n *\n * Measured on one iPhone 15 Pro, which is what the numbers below are worth:\n * `oldtimers-route` (`.rad`) ran 31-39 fps at 750k and 45-50 at 600k, while\n * `sandwijck` (streamed SOG) held 52-57 fps at 675k resident. Two streamed\n * scenes, near-identical splat counts, ~20 fps apart - splat count is a poor\n * proxy for cost, and the format is the only better signal available for free\n * before a single byte is fetched.\n */\nexport type SplatCostClass = 'lcc' | 'sampled';\n\n/** The mobile ceilings, per {@link SplatCostClass}. */\ninterface MobileClassBudget {\n /**\n * The most a mobile GPU is asked to keep resident, whatever its RAM says.\n *\n * `deviceMemory` is a *memory* signal, but a mobile GPU runs out of fill rate\n * and bandwidth long before memory: every splat is an alpha-blended quad, and\n * a busy view stacks hundreds of them per pixel. Chrome also privacy-caps\n * `deviceMemory` at 8 GiB, so every Android flagship reports 8 and would\n * otherwise land on the 8M desktop ceiling - measured at 5-24 fps on an\n * Adreno 750 (S24 Ultra), against 45 fps for the same scene on an iPhone at\n * 1M. Peer viewers land in the same band: SuperSplat budgets mobile at 1-2M,\n * Spark targets 1M on Android / 1.5M on iOS.\n */\n ceiling: number;\n /**\n * The budget for a mobile device that exposes no `deviceMemory` at all -\n * which is every iPhone, since mobile Safari does not implement it. This is\n * the branch the measurements above were taken on, so it is a separate number\n * rather than a fraction of {@link ceiling}.\n */\n withoutMemorySignal: number;\n}\n\nconst MOBILE_BUDGETS: Readonly<Record<SplatCostClass, MobileClassBudget>> = {\n // Both measured: 600k held 45-50 fps where 750k managed 31-39. The ceiling\n // matches the default because the evidence is about the class, not the RAM -\n // an Android flagship reporting 8 GiB has no more fill rate for wide discs\n // than an iPhone that reports nothing.\n lcc: { ceiling: 600_000, withoutMemorySignal: 600_000 },\n // Today's numbers, unchanged. `sandwijck` on iPhone had visible headroom\n // (52-57 fps while losing centre detail). Galaxy S24 Ultra SD at ~827k was still\n // streaming (164k holes); do not raise this ceiling from that capture.\n sampled: { ceiling: 1_000_000, withoutMemorySignal: 750_000 },\n};\n\n/**\n * Desktop *integrated* / unified GPU ceilings (laptop iGPU, Apple Silicon).\n *\n * These machines report as desktop in the UA and often claim 8 GiB\n * `deviceMemory`, so without a GPU class they inherit the workstation 8M path.\n * LCC stays at 1M to match the demo performance-mode ceiling; streamed SOG can\n * hold a bit more because sampled levels do not inflate per-splat fill the way\n * XGRIDS coarse discs do.\n */\nconst INTEGRATED_BUDGETS: Readonly<Record<SplatCostClass, number>> = {\n lcc: 1_000_000,\n sampled: 2_000_000,\n};\n\n/**\n * Classifies a WebGPU adapter from its identifying strings.\n *\n * Heuristics only - browsers may blank fields for privacy. Prefer\n * {@link probeSplatGpuClass} when both power-preference adapters are available.\n */\nexport function classifySplatGpuClass(info: SplatGpuAdapterInfo | undefined): SplatGpuClass {\n if (info?.isFallbackAdapter === true) return 'fallback';\n const vendor = (info?.vendor ?? '').toLowerCase();\n const architecture = (info?.architecture ?? '').toLowerCase();\n const device = (info?.device ?? '').toLowerCase();\n const description = (info?.description ?? '').toLowerCase();\n const blob = `${vendor} ${architecture} ${device} ${description}`;\n\n // Apple Silicon is always unified memory / shared GPU.\n if (vendor === 'apple' || blob.includes('apple')) return 'integrated';\n\n // Intel integrated families commonly exposed by Chrome's adapter.info.\n if (\n vendor === 'intel' ||\n architecture.startsWith('gen-') ||\n /iris|uhd|xe-lp|xe-hpg|alderlake|tigerlake|meteorlake|arrowlake/.test(blob)\n ) {\n // Discrete Intel Arc often carries \"arc\" or dg2-class strings; treat those\n // as discrete when clearly named, otherwise Intel desktop WebGPU is usually iGPU.\n if (/\\barc\\b|dg2|battlemage|xe2-hpg/.test(blob) && !/iris|uhd|xe-lp/.test(blob)) {\n return 'discrete';\n }\n return 'integrated';\n }\n\n // AMD APUs / explicit iGPU architecture tokens.\n if (vendor === 'amd' || vendor.includes('amd')) {\n if (/igpu|vega-igpu|gfx1\\d-igpu|radeon\\s*graphics(?!\\s*pro)/.test(blob)) {\n return 'integrated';\n }\n // Bare \"amd\" without a discrete cue still often means an APU on laptops;\n // Radeon RX / Radeon Pro name the discrete cards.\n if (/radeon\\s*rx|radeon\\s*pro|navi|rdna/.test(blob)) return 'discrete';\n }\n\n return 'discrete';\n}\n\n/** Minimal `navigator.gpu` surface for {@link probeSplatGpuClass}. */\nexport interface SplatGpuProbeEntry {\n requestAdapter(options?: {\n powerPreference?: 'low-power' | 'high-performance';\n }): Promise<SplatGpuProbeAdapter | null>;\n}\n\n/** Adapter handle with optional `.info` (WebGPU GPUAdapterInfo). */\nexport interface SplatGpuProbeAdapter {\n readonly info?: SplatGpuAdapterInfo;\n}\n\nfunction ambientProbeGpu(): SplatGpuProbeEntry | null {\n const nav = typeof navigator !== 'undefined' ? (navigator as { gpu?: unknown }) : undefined;\n const gpu = nav?.gpu;\n return gpu ? (gpu as SplatGpuProbeEntry) : null;\n}\n\nfunction adapterIdentityKey(info: SplatGpuAdapterInfo | undefined): string {\n return [\n info?.vendor ?? '',\n info?.architecture ?? '',\n info?.device ?? '',\n info?.description ?? '',\n ].join('\\0');\n}\n\n/**\n * Probes WebGPU adapters to choose a {@link SplatGpuClass}.\n *\n * Requests both `low-power` and `high-performance` adapters when possible. A\n * hybrid laptop that exposes two different devices is `discrete`; a single\n * shared / unified GPU (Apple Silicon, many iGPU-only machines) is classified\n * from {@link classifySplatGpuClass}. Returns `undefined` when WebGPU is absent\n * or the probe fails, so budget resolution keeps the legacy desktop path.\n */\nexport async function probeSplatGpuClass(\n gpu: SplatGpuProbeEntry | null | undefined = typeof navigator !== 'undefined'\n ? ambientProbeGpu()\n : null,\n): Promise<SplatGpuClass | undefined> {\n if (!gpu) return undefined;\n let low: SplatGpuProbeAdapter | null;\n let high: SplatGpuProbeAdapter | null;\n try {\n const adapters = await Promise.all([\n gpu.requestAdapter({ powerPreference: 'low-power' }),\n gpu.requestAdapter({ powerPreference: 'high-performance' }),\n ]);\n low = adapters[0] ?? null;\n high = adapters[1] ?? null;\n } catch {\n return undefined;\n }\n if (!low && !high) return undefined;\n\n const lowInfo = low?.info;\n const highInfo = high?.info;\n if (lowInfo?.isFallbackAdapter || highInfo?.isFallbackAdapter) return 'fallback';\n\n const lowKey = adapterIdentityKey(lowInfo);\n const highKey = adapterIdentityKey(highInfo);\n const both = low != null && high != null;\n // Distinct non-empty identities ⇒ hybrid dGPU available.\n if (both && lowKey.length > 3 && highKey.length > 3 && lowKey !== highKey) {\n return 'discrete';\n }\n\n const info = highInfo ?? lowInfo;\n return classifySplatGpuClass(info);\n}\n\n/**\n * Whether fill-rate / laptop-class defaults apply (mobile, or desktop\n * integrated / software fallback). Exported so hosts and the demo share one\n * predicate for performance-mode defaults and adaptive DPR.\n */\nexport function isFillConstrainedSplatDevice(\n profile: SplatDeviceProfile | undefined = detectSplatDeviceProfile(),\n): boolean {\n return (\n profile?.isMobile === true ||\n profile?.gpuClass === 'integrated' ||\n profile?.gpuClass === 'fallback'\n );\n}\n\n/**\n * The cost class of a streamed format. Unknown and absent formats read as\n * `'sampled'`, which is the pre-existing behaviour for every caller that does\n * not name one.\n */\nfunction splatCostClass(format?: StreamedSplatFormat): SplatCostClass {\n return format === 'rad' || format === 'lcc' || format === 'lcc2' ? 'lcc' : 'sampled';\n}\n\n/**\n * The headset ceiling, below the phone one.\n *\n * A headset is a phone-class GPU asked to fill a stereo framebuffer larger\n * than a 4K desktop (~1680×1760 *per eye* on a Quest 3), sharing the SoC with\n * a compositor that must hit 72 Hz. Every splat is drawn twice, so the\n * fill-rate wall that sets {@link MOBILE_BUDGET_MAX} arrives proportionally\n * sooner. This is a *cap*, not a floor: a genuinely small device still scales\n * below it on its memory signal.\n */\nconst HEADSET_BUDGET_MAX = 600_000;\n\n/**\n * The ceiling for a budget phone, below the flagship one.\n *\n * A mid-range mobile GPU is not a slightly slower flagship: a Mali-G72 MP3\n * (Galaxy A51) has a small fraction of an Adreno 750's fill rate, while both\n * report the same privacy-capped 8 GiB `deviceMemory` and the same `isMobile`.\n * Starting such a device at the flagship ceiling means it thrashes from the\n * first frame. This is a *cap*: a device whose memory signal is lower still\n * scales below it.\n */\nconst LOW_POWER_BUDGET_MAX = 500_000;\n\n/**\n * The ceiling for a mobile device with no WebGPU at all.\n *\n * This is the tightest tier, and the signal behind it is the most direct: with\n * no WebGPU there is no compute path, so the depth sort runs on the CPU in a\n * worker, and that cost scales with the splat count far worse than a GPU radix\n * sort does. A phone still on the WebGL2 fallback is also, in practice, old -\n * WebGPU has shipped on Android 12+ and iOS 18 - so the two costs arrive\n * together.\n *\n * Measured on a Galaxy S7 (2016, Mali-T880, WebGL2) at **750k**, which was the\n * low-power tier at the time: **5 fps, 194 ms frames**. That device is out of\n * scope as a target, but it is evidence that the memory-derived tier alone lands\n * far too high once the sort is on the CPU. The tiers have since been lowered\n * across the board, so the figure is the measurement's, not this constant's.\n *\n * Desktop is deliberately exempt: a desktop that falls back to WebGL2 is\n * sorting on a far stronger CPU, and capping it here would punish a\n * driver-blocklist fallback on capable hardware.\n */\nconst NO_WEBGPU_BUDGET_MAX = 400_000;\n\n/** Reads the available browser device signals without requiring a DOM runtime. */\nexport function detectSplatDeviceProfile(): SplatDeviceProfile | undefined {\n if (typeof navigator === 'undefined') return undefined;\n const nav = navigator as Navigator & { deviceMemory?: number };\n const isIOS =\n /iPhone|iPad|iPod/.test(nav.userAgent) ||\n (nav.platform === 'MacIntel' && nav.maxTouchPoints > 1);\n // UA first (it names the platform outright); the coarse-pointer probe then\n // catches mobile browsers that hide or reword their UA.\n const isMobile =\n isIOS ||\n /Android/i.test(nav.userAgent) ||\n (nav.maxTouchPoints > 0 &&\n typeof matchMedia === 'function' &&\n matchMedia('(pointer: coarse)').matches);\n // Best-effort *hint* only - see `resolveXrSplatBudget`, which is what\n // actually sizes an XR session. Most standalone headsets are Android under\n // the hood (Quest, Pico, Vive, Android XR), so `isMobile` already catches\n // them even when this misses; the named flag just lets first paint start at\n // a headset-appropriate size instead of resizing after the session opens.\n //\n // It cannot be relied on. visionOS Safari presents as *desktop* Safari, so\n // Apple Vision Pro matches neither this nor `isMobile` and would take the\n // desktop budget; and every new headset misses until its string is added.\n // That is the whole reason budgets key off presentation state instead.\n const isHeadset = /OculusBrowser|Quest|Pico(?: Neo)?[ /]|Wolvic|VRBrowser|Vive|XRBrowser/i.test(\n nav.userAgent,\n );\n // A budget phone, best-effort. `deviceMemory` is privacy-capped at 8 GiB but\n // *not* raised, so a device reporting ≤4 really does have ≤4 - that makes a\n // low reading trustworthy even though a high one says nothing. Deliberately\n // not `hardwareConcurrency`: it would get this exactly backwards, since the\n // Galaxy A51 this exists for is octa-core while an iPhone 15 Pro reports 6.\n const isLowPower =\n (isMobile || isHeadset) && nav.deviceMemory !== undefined && nav.deviceMemory <= 4;\n return {\n ...(nav.deviceMemory === undefined ? {} : { deviceMemoryGb: nav.deviceMemory }),\n isIOS,\n isMobile: isMobile || isHeadset,\n isHeadset,\n isLowPower,\n hasWebGpu: 'gpu' in nav,\n };\n}\n\n/** Scene-side signals for {@link resolveSplatBudget}. */\nexport interface SplatBudgetOptions {\n /**\n * The format about to be loaded, which selects the {@link SplatCostClass}\n * whose mobile tier applies. Omit when it is not yet known - the `'sampled'`\n * numbers are what every caller resolved before cost classes existed.\n */\n format?: StreamedSplatFormat;\n /**\n * A ceiling on the resolved default, for callers that want to tighten without\n * overriding what this function knows.\n *\n * `override` is absolute: it wins over the device tier, the cost class and\n * everything else, because a caller who names a number has said they know\n * better. That is the right contract and the wrong tool for \"the same as\n * usual, but no more than N\" - which is what a performance toggle or a host\n * default actually means. Pinning a number there has twice shipped as a bug:\n * a demo performance mode that *raised* the load on the weakest device tested,\n * and a host default that bypassed every device tier.\n *\n * Applied only when `override` is omitted, and only downward.\n */\n cap?: number;\n}\n\n/**\n * Chooses a default active-splat budget for the current device.\n *\n * The default is derived from the coarsest device-memory signal the platform\n * exposes; callers who know better (or want a hard cap for a mobile test on\n * desktop) should pass an explicit override.\n *\n * @param override - Explicit budget in splats; wins over device detection when\n * positive. Throws `RangeError` if it is not a positive finite number.\n * @param profile - Device signals to decide from; defaults to detecting them.\n * @param options - Scene signals. `format` selects the {@link SplatCostClass}\n * whose mobile tier applies; omitting it keeps the `'sampled'` numbers, which\n * are what every caller resolved before cost classes existed. `cap` is a\n * ceiling on the *resolved default*, for callers who want to tighten without\n * overriding - see {@link SplatBudgetOptions.cap}.\n * @returns A splat-count budget, clamped to a sensible range.\n */\nexport function resolveSplatBudget(\n override?: number,\n profile: SplatDeviceProfile | undefined = detectSplatDeviceProfile(),\n options: SplatBudgetOptions = {},\n): number {\n const cap = options.cap;\n if (cap !== undefined && (!Number.isFinite(cap) || cap <= 0)) {\n throw new RangeError('Splat budget cap must be a positive finite number.');\n }\n if (override !== undefined) {\n if (!Number.isFinite(override) || override <= 0) {\n throw new RangeError('Splat budget must be a positive finite number.');\n }\n // Deliberately *not* capped: `override` is the caller saying they know\n // better than every default, and `cap` is a ceiling on a default. Applying\n // it here would make it a second, quieter override.\n return Math.floor(override);\n }\n const capped = (budget: number): number =>\n cap === undefined ? budget : Math.min(budget, Math.floor(cap));\n\n // The device ceiling, tightest signal first. Expressed as a cap rather than\n // a flat headset default so a low-memory headset still scales *below* it\n // instead of being pinned up to it - and so the memory validation below\n // still runs for headsets.\n // Every applicable cap, then the tightest of them. A ternary chain would make\n // this order-dependent, and the order is not obvious - a device can be mobile\n // *and* low-power *and* on the WebGL2 fallback at once, and the answer must be\n // the smallest cap regardless of which test happens to run first.\n //\n // Only the mobile cap is per-format. The headset, low-power and no-WebGPU\n // tiers were each measured on their own hardware against a limit the format\n // cannot move - stereo fill, a mid-range GPU, a CPU-side sort - so they stay\n // absolute, and a raised `'sampled'` tier never reaches those devices.\n const mobile = MOBILE_BUDGETS[splatCostClass(options.format)];\n const costClass = splatCostClass(options.format);\n const caps: number[] = [];\n if (profile?.isHeadset) caps.push(HEADSET_BUDGET_MAX);\n if (profile?.isLowPower) caps.push(LOW_POWER_BUDGET_MAX);\n if (profile?.isMobile) caps.push(mobile.ceiling);\n // Mobile only: a desktop on the WebGL2 fallback sorts on a far stronger CPU.\n if (profile?.isMobile && profile.hasWebGpu === false) caps.push(NO_WEBGPU_BUDGET_MAX);\n // Desktop GPU class: laptop / unified / software must not inherit the 8M path.\n if (!profile?.isMobile && profile?.gpuClass === 'integrated') {\n caps.push(INTEGRATED_BUDGETS[costClass]);\n }\n if (!profile?.isMobile && profile?.gpuClass === 'fallback') {\n caps.push(NO_WEBGPU_BUDGET_MAX);\n }\n const ceiling = caps.length === 0 ? Infinity : Math.min(...caps);\n\n // `deviceMemory` (Chrome/Edge/Android) reports GiB, spec-capped at 8.\n const deviceMemoryGb = profile?.deviceMemoryGb;\n if (deviceMemoryGb !== undefined) {\n if (!Number.isFinite(deviceMemoryGb) || deviceMemoryGb <= 0) {\n throw new RangeError('Device memory must be a positive finite number.');\n }\n // 1M splats per reported GiB → Chrome's 8 GiB privacy cap yields the 8M\n // desktop default. Mobile/headset/integrated caps above still win when tighter.\n const scaled = clamp(Math.round(deviceMemoryGb * 1_000_000), 500_000, 8_000_000);\n return capped(Math.min(scaled, ceiling));\n }\n\n // No `deviceMemory`: mobile Safari (iOS) and privacy-restricted mobile\n // browsers land here, and must not fall through to the desktop default.\n if (profile?.isMobile) return capped(Math.min(mobile.withoutMemorySignal, ceiling));\n\n // Integrated / fallback desktop without a memory signal still needs the class\n // ceiling (Apple Silicon Safari often omits deviceMemory entirely).\n if (profile?.gpuClass === 'integrated') {\n return capped(Math.min(INTEGRATED_BUDGETS[costClass], ceiling));\n }\n if (profile?.gpuClass === 'fallback') {\n return capped(Math.min(NO_WEBGPU_BUDGET_MAX, ceiling));\n }\n\n // An absent profile can mean SSR, Node tooling, or a privacy-restricted\n // browser. Known non-iOS desktop without a memory signal still gets the\n // 8M default; everything else stays on the conservative portable floor.\n return capped(profile?.isIOS === false ? 8_000_000 : 1_000_000);\n}\n\n/**\n * The budget to run at **while an immersive session presents**, given whatever\n * budget the page is using outside one.\n *\n * Stereo is the cost, and stereo is a property of the *session*, not of the\n * device: every splat is drawn twice, into two eye viewports that together\n * exceed a 4K desktop, on a GPU that must hold 72–90 Hz or the compositor\n * reprojects. That is true of a standalone headset and equally true of a\n * desktop machine driving a tethered one - which is exactly the case device\n * sniffing cannot see, since such a machine is a desktop right up until the\n * moment it is not.\n *\n * Keying off presentation instead of identity is also the only approach that\n * survives new hardware. `detectSplatDeviceProfile`'s `isHeadset` is a\n * user-agent guess: it misses Apple Vision Pro outright (visionOS Safari\n * presents as desktop Safari, so a headset would otherwise take the multi-\n * million desktop budget) and misses every headset released after it is\n * written. This function needs to know none of that.\n *\n * Apply it on `sessionstart` and restore the original on `sessionend` - via\n * `BudgetGovernor.setBudget` when several meshes share a pool, or a streamed\n * mesh's own budget setter otherwise.\n *\n * @param pageBudget - The budget in use outside the session, typically from\n * {@link resolveSplatBudget}. Throws `RangeError` if not a positive finite\n * number.\n * @returns `pageBudget` lowered to the stereo ceiling; never raised - a device\n * already rendering below the ceiling stays where it is.\n */\nexport function resolveXrSplatBudget(pageBudget: number): number {\n if (!Number.isFinite(pageBudget) || pageBudget <= 0) {\n throw new RangeError('Splat budget must be a positive finite number.');\n }\n return Math.min(Math.floor(pageBudget), HEADSET_BUDGET_MAX);\n}\n\n/**\n * The largest finest-level scene taken whole rather than streamed. At ~64 B\n * of GPU pool per splat this is ~380 MB - affordable on a desktop, and far\n * cheaper than it looks next to the alternative, since a scene held whole\n * never swaps, never compacts and never re-fetches.\n */\nconst FINEST_LEVEL_BUDGET_MAX = 8_000_000;\n\n/**\n * Raises `budget` far enough to hold a scene's finest level in full, when\n * that level is small enough to be worth taking whole.\n *\n * The LOD budget assumes coarser levels are cheap *approximations* of the\n * same surface, so trading detail for memory is nearly free. XGRIDS' LCC\n * LCC breaks that assumption: its five levels are decimated alternatives\n * whose splats merge into wide flat discs (correct at distance, streaked up\n * close), so any budget shortfall costs visible quality - sub-chunking gives\n * the scheduler ~128k-splat granularity to thin cells with, but a thinned\n * fine level is still sparser than the capture intends. A capture whose\n * finest level fits under the desktop ceiling is better shown whole: Casino's\n * level 0 is 4.74M splats and sits under the 8M default.\n *\n * Mobile and fill-constrained desktops (integrated / fallback GPU class) are\n * exempt: their cap is a fill-rate limit, not a sizing accident.\n *\n * @param budget - The resolved device budget.\n * @param finestLevelSplats - Splats in the scene's finest level.\n * @returns `budget`, or the finest level's size when that is the better fit.\n */\nexport function liftBudgetToFinestLevel(\n budget: number,\n finestLevelSplats: number,\n profile: SplatDeviceProfile | undefined = detectSplatDeviceProfile(),\n): number {\n if (isFillConstrainedSplatDevice(profile)) return budget;\n if (!Number.isFinite(finestLevelSplats) || finestLevelSplats <= budget) return budget;\n if (finestLevelSplats > FINEST_LEVEL_BUDGET_MAX) return budget;\n return Math.floor(finestLevelSplats);\n}\n\n/** Options for {@link estimateSplatPoolBytes}. */\nexport interface SplatPoolBytesOptions {\n /**\n * Pool texture precision, matching `SplatMeshOptions.poolFloatTextures`.\n * `'float16'` halves centers and covarianceA. Default `'float32'`.\n */\n floatTextures?: 'float32' | 'float16';\n /** Per-splat SH bands the pool allocates for. Default `0`. */\n shBands?: 0 | 1 | 2 | 3;\n /**\n * Pool capacity as a multiple of `splats`. `StreamedSplatMesh` allocates\n * 1.5× (the default here) so per-run row alignment and the\n * append-before-remove window during LOD swaps have somewhere to go; pass\n * `1.4` to model `experimentalStagedSwaps: false`, or `1` for a static mesh.\n */\n capacityFactor?: number;\n /**\n * Whether to include the CPU-side backing arrays. Default `true` - they are\n * real host memory a device has to find, so the honest answer to \"can I\n * afford this ceiling\" includes them.\n */\n includeCpuBacking?: boolean;\n}\n\n/**\n * Estimates the memory a splat pool of `splats` costs.\n *\n * This exists to make a streamed mesh's `maxBudget` a computation rather than a\n * guess. A pool is allocated **once, from the ceiling** and never\n * grows, so several governed meshes cost the sum of their ceilings whatever the\n * shared budget is set to - a `BudgetGovernor` redistributes *sharpness* within\n * that envelope, it does not shrink it. Price the ceilings before choosing them:\n *\n * ```js\n * // 1 main + 4 additional meshes, each able to reach 1.5M splats\n * const bytes = estimateSplatPoolBytes(4_000_000) + 4 * estimateSplatPoolBytes(1_500_000);\n * ```\n *\n * Per splat of *capacity* (`splats × capacityFactor`), counted from what the\n * constructor actually allocates:\n *\n * - **Pool textures** 52 B - centers RGBA32F 16, colors RGBA8 4,\n * covarianceA RGBA32F 16, covarianceB RGBA32F 16. `'float16'` drops centers\n * and covarianceA to 8 each (36 B); covarianceB stays float32 because it\n * packs integer IDs.\n * - **Packed SH** 16 B per `RGBA32UI` texture, `ceil(coefficients / 4)` of them\n * - so 16 / 32 / 64 B at 1 / 2 / 3 bands.\n * - **Sort storage** 16 B - the radix sorter's ping-pong key/value buffers.\n * - **CPU backing** 68 B - the float32/uint8 arrays kept for partial uploads\n * (always full precision, even under `'float16'`): centers 16, colors 4,\n * covarianceA 16, covarianceB 16, plus four u32-per-splat arrays - draw-order\n * indices, the active-list source index, the pool-slot map, and the picker's\n * pool-index template (allocated lazily, counted because a picked scene pays\n * it). Under `'float16'` add a further 16 B: the half-encoded texture images\n * are held alongside the float32 backing, not instead of it, so that\n * precision saves GPU bytes only. Included unless `includeCpuBacking` is\n * `false`.\n *\n * The module header's \"roughly 64 bytes per splat\" rounds the GPU side of this.\n *\n * @param splats - The ceiling in splats (e.g. a mesh's `maxBudget`).\n * @returns Estimated bytes. Indicative, not a device-memory guarantee: driver\n * texture padding, staging allocations and the decoded-chunk CPU cache\n * ({@link resolveCpuCacheBytes}) sit outside it.\n * @throws {RangeError} if `splats` is not a positive finite number, or\n * `capacityFactor` is not a finite number `>= 1`.\n */\nexport function estimateSplatPoolBytes(\n splats: number,\n options: SplatPoolBytesOptions = {},\n): number {\n if (!Number.isFinite(splats) || splats <= 0) {\n throw new RangeError('Splat count must be a positive finite number.');\n }\n const capacityFactor = options.capacityFactor ?? 1.5;\n if (!Number.isFinite(capacityFactor) || capacityFactor < 1) {\n throw new RangeError('Pool capacityFactor must be a finite number >= 1.');\n }\n const float16 = options.floatTextures === 'float16';\n const shTextures = Math.ceil(shCoefficientCount(options.shBands ?? 0) / 4);\n const shBytes = shTextures * 16;\n\n const centers = float16 ? 8 : 16;\n const covarianceA = float16 ? 8 : 16;\n const poolTextures = centers + 4 /* colors */ + covarianceA + 16 /* covarianceB */ + shBytes;\n const sortBuffers = 16; // radix ping-pong: keys A/B + values A/B, u32 each\n // Backing arrays are float32/uint32 regardless of the texture precision, and\n // under 'float16' the half-encoded texture images are held *in addition* to\n // them (the constructor keeps both), so that precision saves GPU bytes only.\n const halfImages = float16 ? 8 + 8 : 0; /* centersImage + covarianceAImage */\n const cpuBacking =\n options.includeCpuBacking === false\n ? 0\n : 16 /* centers */ +\n 4 /* colors */ +\n 16 /* covarianceA */ +\n 16 /* covarianceB */ +\n shBytes +\n 4 /* draw order (splatIndexes) */ +\n 4 /* active-list source index */ +\n 4 /* activeSlotByPoolIndex */ +\n 4 /* picker pool-index template */ +\n halfImages;\n\n const capacity = Math.ceil(splats * capacityFactor);\n return capacity * (poolTextures + sortBuffers + cpuBacking);\n}\n\n/**\n * Suggested ceiling for the renderer's pixel ratio on this device.\n *\n * Splat rendering is fragment-bound, so render resolution is one of the\n * largest costs on a high-DPI phone: a 2.6x display renders ~7x the pixels of\n * a 1x one, and every one of them blends the full depth-sorted splat stack.\n * The library cannot apply this itself (it draws into a renderer the app\n * owns), so it exports the policy - pass it to `renderer.setPixelRatio`:\n *\n * ```js\n * renderer.setPixelRatio(Math.min(window.devicePixelRatio, recommendedMaxPixelRatio()));\n * ```\n *\n * This is the *quality* ceiling. Viewers that offer a performance mode\n * (SuperSplat halves mobile resolution in its own) should go lower still when\n * it is on.\n */\nexport function recommendedMaxPixelRatio(\n profile: SplatDeviceProfile | undefined = detectSplatDeviceProfile(),\n): number {\n return isFillConstrainedSplatDevice(profile) ? 1.5 : 2;\n}\n\n/**\n * The Gaussian cutoff a `.rad` renders at, in standard deviations, or\n * `undefined` to accept the device-wide default.\n *\n * Spark hard-codes `sqrt(8)` (≈2.83σ) for every device, and matching it is what\n * makes a `.rad` look like Spark's render on a desktop. On a phone it is the\n * wrong trade: rendering is fill-bound on mobile, so a format override bypasses\n * the device policy that every other format accepts.\n *\n * Returning `undefined` on mobile lets `SplatMesh` apply its own 3σ cutoff and\n * undersized-splat floor; `.rad` must not escape that mobile policy.\n *\n * @returns √8 on discrete desktop; `undefined` on fill-constrained devices,\n * meaning \"no format override\".\n */\nexport function recommendedRadMaxStdDev(\n profile: SplatDeviceProfile | undefined = detectSplatDeviceProfile(),\n): number | undefined {\n return isFillConstrainedSplatDevice(profile) ? undefined : Math.SQRT2 * 2;\n}\n\n/**\n * Suggested WebXR framebuffer scale for this device. On three 0.185.x this is\n * a **WebGL XR** policy: apply it once before the session starts with\n * `renderer.xr.setFramebufferScaleFactor`. three's WebGPU XR path creates its\n * `XRGPUBinding` projection layer at native scale and does not consume that\n * setting, so WebGPU hosts should leave the native scale and use the presenting\n * splat budget plus fixed foveation for runtime headroom instead.\n *\n * Splat rendering is fill-bound, and a headset's default framebuffer is\n * already supersampled past its panels (~1680×1760 per eye on Quest 3):\n * 0.8 cuts fragment work ~36% for a barely visible softening. Non-headset\n * XR (desktop-tethered) keeps the native 1.0.\n */\nexport function recommendedXrFramebufferScale(\n profile: SplatDeviceProfile | undefined = detectSplatDeviceProfile(),\n): number {\n return profile?.isHeadset ? 0.8 : 1;\n}\n\n/**\n * Host-threaded samples to ignore before the EMA starts.\n *\n * First `renderer.compute` and first `copyTextureToTexture` each compile a\n * WebGPU pipeline (~200 ms). Seeding the EMA from those drops the ratio to\n * the floor, and a 60 Hz display's 16.7 ms vsync never beats the raise bar\n * (`targetFrameMs * 0.85` ≈ 15.3 ms), so the drop is permanent. Pass the\n * returned {@link AdaptivePixelRatioResult.warmupRemaining} back each frame.\n */\nexport const ADAPTIVE_PIXEL_RATIO_WARMUP_FRAMES = 5;\n\n/** Inputs for {@link suggestAdaptivePixelRatio}. */\nexport interface AdaptivePixelRatioInput {\n /** Latest wall-frame time in milliseconds. */\n frameMs: number;\n /** Currently applied pixel ratio. */\n current: number;\n /** Quality ceiling (typically {@link recommendedMaxPixelRatio}). */\n max: number;\n /** Floor; defaults to `1`. */\n min?: number;\n /** EMA of frame time from the previous call; omit on the first sample. */\n emaMs?: number;\n /**\n * Samples still ignored before the EMA starts. Omit or `0` to start\n * immediately. Thread the result's `warmupRemaining` when using\n * {@link ADAPTIVE_PIXEL_RATIO_WARMUP_FRAMES}.\n */\n warmupRemaining?: number;\n /**\n * Comfortable frame time (ms). Below this (with headroom) the helper may\n * step the ratio up. Default `18` (~55 fps).\n */\n targetFrameMs?: number;\n /**\n * Sustained frame time (ms) that triggers a step down. Default `22`\n * (~45 fps) so brief spikes do not thrash the canvas size.\n */\n pressureFrameMs?: number;\n}\n\n/** Result of {@link suggestAdaptivePixelRatio}. */\nexport interface AdaptivePixelRatioResult {\n /** Suggested pixel ratio after hysteresis (quarter steps). */\n pixelRatio: number;\n /** Updated EMA to pass back on the next call. Unset while warming up. */\n emaMs: number | undefined;\n /** Remaining warmup samples; pass back as `warmupRemaining`. */\n warmupRemaining: number;\n}\n\n/**\n * Suggests a pixel ratio under frame-time pressure.\n *\n * The library cannot call `renderer.setPixelRatio` (the host owns the\n * renderer); this is a pure policy helper. Pass the previous result's\n * `emaMs` and `warmupRemaining` each frame, and only re-size the canvas when\n * `pixelRatio` changes.\n *\n * Steps are quarter-units with asymmetric thresholds (pressure to lower,\n * comfortable headroom to raise) so the ratio does not oscillate. One-off\n * hitches (pipeline compiles, tab resume) that are several times the EMA do\n * not count as pressure.\n */\nexport function suggestAdaptivePixelRatio(\n input: AdaptivePixelRatioInput,\n): AdaptivePixelRatioResult {\n const min = input.min ?? 1;\n const max = Math.max(min, input.max);\n const targetFrameMs = input.targetFrameMs ?? 18;\n const pressureFrameMs = input.pressureFrameMs ?? 22;\n const frameMs = Number.isFinite(input.frameMs) ? Math.max(0, input.frameMs) : targetFrameMs;\n const pixelRatio = clamp(input.current, min, max);\n const warmupRaw = input.warmupRemaining;\n const warmupRemaining =\n warmupRaw !== undefined && Number.isFinite(warmupRaw) && warmupRaw > 0\n ? Math.floor(warmupRaw)\n : 0;\n if (warmupRemaining > 0) {\n return { pixelRatio, emaMs: input.emaMs, warmupRemaining: warmupRemaining - 1 };\n }\n if (\n input.emaMs !== undefined &&\n Number.isFinite(input.emaMs) &&\n frameMs > Math.max(input.emaMs * 4, pressureFrameMs * 4)\n ) {\n return { pixelRatio, emaMs: input.emaMs, warmupRemaining: 0 };\n }\n const alpha = 0.15;\n const emaMs = input.emaMs === undefined ? frameMs : input.emaMs * (1 - alpha) + frameMs * alpha;\n\n let next = pixelRatio;\n if (emaMs > pressureFrameMs && next > min) {\n next = Math.max(min, roundPixelRatio(next - 0.25));\n } else if (emaMs < targetFrameMs * 0.85 && next < max) {\n next = Math.min(max, roundPixelRatio(next + 0.25));\n }\n return { pixelRatio: clamp(next, min, max), emaMs, warmupRemaining: 0 };\n}\n\n/** Quarter-step pixel ratios (1, 1.25, 1.5, …) keep canvas resizes coarse. */\nfunction roundPixelRatio(value: number): number {\n return Math.round(value * 4) / 4;\n}\n\n/**\n * Chooses a decoded-chunk CPU cache cap from the same safe device profile.\n *\n * **The unknown-memory fallback must not read as \"tiny\".** iOS Safari does not\n * implement `navigator.deviceMemory` at all, so every iPhone lands on this\n * branch - and an earlier version resolved it to 1 GiB, i.e. the 32 MiB floor,\n * on hardware with 8 GiB. That is not a conservative guess, it is a wrong one,\n * and it is invisible on `.rad`, whose page-table cache has its own floor.\n *\n * On streamed SOG there is no such floor and the cost is immediate: measured on\n * an iPhone 15 Pro against `sandwijck`, the scheduler asked for 539,734 splats\n * and the mesh could only hold 466,499 of them across 3 chunk files (desktop:\n * 5), evicting continuously. The finest level is wanted *nearest the camera* and\n * lives in the largest files, so those are what fail to stay resident - the\n * middle of the view drops out and the scene renders as a donut.\n *\n * A device that declines to report its memory is far likelier to be a modern\n * phone withholding a fingerprinting signal than an actual 1 GiB device, so the\n * fallback assumes 4 GiB (128 MiB of cache). The floor stays for the genuinely\n * profile-less case - SSR and Node tooling, which never stream a scene anyway.\n */\nexport function resolveCpuCacheBytes(\n profile: SplatDeviceProfile | undefined = detectSplatDeviceProfile(),\n): number {\n const gb = profile === undefined ? 1 : (profile.deviceMemoryGb ?? 4);\n const mib = 1024 * 1024;\n return Math.min(256 * mib, Math.max(32 * mib, gb * 32 * mib));\n}\n\nfunction clamp(value: number, min: number, max: number): number {\n return Math.min(max, Math.max(min, value));\n}\n"],"names":["MOBILE_BUDGETS","INTEGRATED_BUDGETS","classifySplatGpuClass","info","vendor","architecture","device","description","blob","ambientProbeGpu","nav","gpu","adapterIdentityKey","probeSplatGpuClass","low","high","adapters","lowInfo","highInfo","lowKey","highKey","isFillConstrainedSplatDevice","profile","detectSplatDeviceProfile","splatCostClass","format","HEADSET_BUDGET_MAX","LOW_POWER_BUDGET_MAX","NO_WEBGPU_BUDGET_MAX","isIOS","isMobile","isHeadset","isLowPower","resolveSplatBudget","override","options","cap","capped","budget","mobile","costClass","caps","ceiling","deviceMemoryGb","scaled","clamp","resolveXrSplatBudget","pageBudget","FINEST_LEVEL_BUDGET_MAX","liftBudgetToFinestLevel","finestLevelSplats","estimateSplatPoolBytes","splats","capacityFactor","float16","shBytes","shCoefficientCount","centers","covarianceA","poolTextures","sortBuffers","halfImages","cpuBacking","recommendedMaxPixelRatio","recommendedRadMaxStdDev","recommendedXrFramebufferScale","ADAPTIVE_PIXEL_RATIO_WARMUP_FRAMES","suggestAdaptivePixelRatio","input","min","max","targetFrameMs","pressureFrameMs","frameMs","pixelRatio","warmupRaw","warmupRemaining","alpha","emaMs","next","roundPixelRatio","value","resolveCpuCacheBytes","gb","mib"],"mappings":"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"}
1
+ {"version":3,"file":"splat-budget-DuVZspPQ.js","sources":["../src/lib/core/splat-budget.ts"],"sourcesContent":["/**\n * Device profiling and the default active-splat budget.\n *\n * Streaming is splat-count budgeted rather than byte budgeted: every cost in\n * the renderer (pool textures, sort buffers) scales with the number of\n * resident splats - roughly 64 bytes of GPU memory per splat (three RGBA32F\n * data textures + one RGBA8, plus the sort index/order/bucket buffers), or\n * about 48 bytes when `poolFloatTextures: 'float16'` halves centers and\n * covarianceA; plus about 52 more bytes of CPU-side backing per splat of\n * pool capacity. Capping the active splat count therefore caps memory\n * directly, which is what matters on mobile GPUs.\n */\n\nimport type { StreamedSplatFormat } from '../loaders/loading';\nimport { shCoefficientCount } from './sh-pack';\n\n/**\n * Desktop GPU capability class for budget / quality defaults.\n *\n * Mobile still keys off {@link SplatDeviceProfile.isMobile}; this splits\n * non-mobile machines that would otherwise share the ~8M workstation path.\n * Unset means \"unknown\" - keep the pre-existing desktop defaults (fail open).\n */\nexport type SplatGpuClass = 'discrete' | 'integrated' | 'fallback';\n\n/** Browser/device signals used for deterministic budget selection. */\nexport interface SplatDeviceProfile {\n /** Coarse memory estimate in GiB, when exposed by the browser. */\n deviceMemoryGb?: number;\n /** Whether the runtime is an iOS/iPadOS-class device. */\n isIOS?: boolean;\n /** Whether the runtime is a phone/tablet-class device (includes iOS). */\n isMobile?: boolean;\n /**\n * Whether the runtime is a standalone XR headset browser (Quest, Pico, …).\n * Headsets are mobile-class GPUs asked to fill a stereo framebuffer larger\n * than a 4K desktop, so they get their own, tighter defaults.\n */\n isHeadset?: boolean;\n /**\n * Whether this is a *budget* phone rather than a flagship one.\n *\n * `isMobile` cannot tell a Galaxy A51 from an iPhone 15 Pro, and the memory\n * signal cannot either - Chrome privacy-caps `deviceMemory` at 8 GiB, so the\n * flagship and the mid-ranger both land on the same ceiling despite an order\n * of magnitude between their GPUs. A coarse signal, and a *downward* one: a\n * reading of ≤4 is trustworthy because the cap only ever lowers it, while a\n * reading of 8 says nothing. {@link hasWebGpu} is the sharper signal where it\n * applies, since it names a difference in how the sort runs rather than\n * guessing capability from RAM.\n */\n isLowPower?: boolean;\n /**\n * Whether the runtime exposes WebGPU at all.\n *\n * `false` means the renderer will take the WebGL2 fallback, where the depth\n * sort runs on the CPU rather than as a GPU compute pass - a different cost\n * curve, not just a slower one. See {@link resolveSplatBudget}.\n *\n * Detected from the presence of `navigator.gpu`, which is a *browser support*\n * signal rather than an adapter one: a runtime that exposes the API but whose\n * `requestAdapter` fails (driver blocklist) still reports `true` here and will\n * fall back to WebGL2 anyway. A host that knows its renderer's real backend\n * can correct this by passing its own profile.\n */\n hasWebGpu?: boolean;\n /**\n * Desktop GPU class from {@link probeSplatGpuClass} / {@link classifySplatGpuClass}.\n * Omit when unknown so workstations without adapter info keep the 8M path.\n */\n gpuClass?: SplatGpuClass;\n}\n\n/** Adapter identifying fields used by {@link classifySplatGpuClass}. */\nexport interface SplatGpuAdapterInfo {\n vendor?: string;\n architecture?: string;\n device?: string;\n description?: string;\n isFallbackAdapter?: boolean;\n}\n\n/**\n * How expensive a format's splats are *per splat*, which is not the same across\n * formats and is the reason one mobile budget cannot serve them all.\n *\n * - `'sampled'` - coarser LOD levels are subsamples of the same surface, so a\n * splat costs about the same however deep the cut is. Streamed SOG.\n * - `'lcc'` - XGRIDS' levels are decimated *alternatives* whose splats merge\n * into wide flat discs (see {@link liftBudgetToFinestLevel}). Cost per splat\n * therefore *rises* as the budget falls, because a tighter budget serves\n * coarser levels and each of their splats covers more pixels. `.rad`, `.lcc`,\n * `.lcc2`.\n *\n * Measured on one iPhone 15 Pro, which is what the numbers below are worth:\n * `oldtimers-route` (`.rad`) ran 31-39 fps at 750k and 45-50 at 600k, while\n * `sandwijck` (streamed SOG) held 52-57 fps at 675k resident. Two streamed\n * scenes, near-identical splat counts, ~20 fps apart - splat count is a poor\n * proxy for cost, and the format is the only better signal available for free\n * before a single byte is fetched.\n */\nexport type SplatCostClass = 'lcc' | 'sampled';\n\n/** The mobile ceilings, per {@link SplatCostClass}. */\ninterface MobileClassBudget {\n /**\n * The most a mobile GPU is asked to keep resident, whatever its RAM says.\n *\n * `deviceMemory` is a *memory* signal, but a mobile GPU runs out of fill rate\n * and bandwidth long before memory: every splat is an alpha-blended quad, and\n * a busy view stacks hundreds of them per pixel. Chrome also privacy-caps\n * `deviceMemory` at 8 GiB, so every Android flagship reports 8 and would\n * otherwise land on the 8M desktop ceiling - measured at 5-24 fps on an\n * Adreno 750 (S24 Ultra), against 45 fps for the same scene on an iPhone at\n * 1M. Peer viewers land in the same band: SuperSplat budgets mobile at 1-2M,\n * Spark targets 1M on Android / 1.5M on iOS.\n */\n ceiling: number;\n /**\n * The budget for a mobile device that exposes no `deviceMemory` at all -\n * which is every iPhone, since mobile Safari does not implement it. This is\n * the branch the measurements above were taken on, so it is a separate number\n * rather than a fraction of {@link ceiling}.\n */\n withoutMemorySignal: number;\n}\n\nconst MOBILE_BUDGETS: Readonly<Record<SplatCostClass, MobileClassBudget>> = {\n // Both measured: 600k held 45-50 fps where 750k managed 31-39. The ceiling\n // matches the default because the evidence is about the class, not the RAM -\n // an Android flagship reporting 8 GiB has no more fill rate for wide discs\n // than an iPhone that reports nothing.\n lcc: { ceiling: 600_000, withoutMemorySignal: 600_000 },\n // Today's numbers, unchanged. `sandwijck` on iPhone had visible headroom\n // (52-57 fps while losing centre detail). Galaxy S24 Ultra SD at ~827k was still\n // streaming (164k holes); do not raise this ceiling from that capture.\n sampled: { ceiling: 1_000_000, withoutMemorySignal: 750_000 },\n};\n\n/**\n * Desktop *integrated* / unified GPU ceilings (laptop iGPU, Apple Silicon).\n *\n * These machines report as desktop in the UA and often claim 8 GiB\n * `deviceMemory`, so without a GPU class they inherit the workstation 8M path.\n * LCC stays at 1M to match the demo performance-mode ceiling; streamed SOG can\n * hold a bit more because sampled levels do not inflate per-splat fill the way\n * XGRIDS coarse discs do.\n */\nconst INTEGRATED_BUDGETS: Readonly<Record<SplatCostClass, number>> = {\n lcc: 1_000_000,\n sampled: 2_000_000,\n};\n\n/**\n * Classifies a WebGPU adapter from its identifying strings.\n *\n * Heuristics only - browsers may blank fields for privacy. Prefer\n * {@link probeSplatGpuClass} when both power-preference adapters are available.\n */\nexport function classifySplatGpuClass(info: SplatGpuAdapterInfo | undefined): SplatGpuClass {\n if (info?.isFallbackAdapter === true) return 'fallback';\n const vendor = (info?.vendor ?? '').toLowerCase();\n const architecture = (info?.architecture ?? '').toLowerCase();\n const device = (info?.device ?? '').toLowerCase();\n const description = (info?.description ?? '').toLowerCase();\n const blob = `${vendor} ${architecture} ${device} ${description}`;\n\n // Apple Silicon is always unified memory / shared GPU.\n if (vendor === 'apple' || blob.includes('apple')) return 'integrated';\n\n // Intel integrated families commonly exposed by Chrome's adapter.info.\n if (\n vendor === 'intel' ||\n architecture.startsWith('gen-') ||\n /iris|uhd|xe-lp|xe-hpg|alderlake|tigerlake|meteorlake|arrowlake/.test(blob)\n ) {\n // Discrete Intel Arc often carries \"arc\" or dg2-class strings; treat those\n // as discrete when clearly named, otherwise Intel desktop WebGPU is usually iGPU.\n if (/\\barc\\b|dg2|battlemage|xe2-hpg/.test(blob) && !/iris|uhd|xe-lp/.test(blob)) {\n return 'discrete';\n }\n return 'integrated';\n }\n\n // AMD APUs / explicit iGPU architecture tokens.\n if (vendor === 'amd' || vendor.includes('amd')) {\n if (/igpu|vega-igpu|gfx1\\d-igpu|radeon\\s*graphics(?!\\s*pro)/.test(blob)) {\n return 'integrated';\n }\n // Bare \"amd\" without a discrete cue still often means an APU on laptops;\n // Radeon RX / Radeon Pro name the discrete cards.\n if (/radeon\\s*rx|radeon\\s*pro|navi|rdna/.test(blob)) return 'discrete';\n }\n\n return 'discrete';\n}\n\n/** Minimal `navigator.gpu` surface for {@link probeSplatGpuClass}. */\nexport interface SplatGpuProbeEntry {\n requestAdapter(options?: {\n powerPreference?: 'low-power' | 'high-performance';\n }): Promise<SplatGpuProbeAdapter | null>;\n}\n\n/** Adapter handle with optional `.info` (WebGPU GPUAdapterInfo). */\nexport interface SplatGpuProbeAdapter {\n readonly info?: SplatGpuAdapterInfo;\n}\n\nfunction ambientProbeGpu(): SplatGpuProbeEntry | null {\n const nav = typeof navigator !== 'undefined' ? (navigator as { gpu?: unknown }) : undefined;\n const gpu = nav?.gpu;\n return gpu ? (gpu as SplatGpuProbeEntry) : null;\n}\n\nfunction adapterIdentityKey(info: SplatGpuAdapterInfo | undefined): string {\n return [\n info?.vendor ?? '',\n info?.architecture ?? '',\n info?.device ?? '',\n info?.description ?? '',\n ].join('\\0');\n}\n\n/**\n * Probes WebGPU adapters to choose a {@link SplatGpuClass}.\n *\n * Requests both `low-power` and `high-performance` adapters when possible. A\n * hybrid laptop that exposes two different devices is `discrete`; a single\n * shared / unified GPU (Apple Silicon, many iGPU-only machines) is classified\n * from {@link classifySplatGpuClass}. Returns `undefined` when WebGPU is absent\n * or the probe fails, so budget resolution keeps the legacy desktop path.\n */\nexport async function probeSplatGpuClass(\n gpu: SplatGpuProbeEntry | null | undefined = typeof navigator !== 'undefined'\n ? ambientProbeGpu()\n : null,\n): Promise<SplatGpuClass | undefined> {\n if (!gpu) return undefined;\n let low: SplatGpuProbeAdapter | null;\n let high: SplatGpuProbeAdapter | null;\n try {\n const adapters = await Promise.all([\n gpu.requestAdapter({ powerPreference: 'low-power' }),\n gpu.requestAdapter({ powerPreference: 'high-performance' }),\n ]);\n low = adapters[0] ?? null;\n high = adapters[1] ?? null;\n } catch {\n return undefined;\n }\n if (!low && !high) return undefined;\n\n const lowInfo = low?.info;\n const highInfo = high?.info;\n if (lowInfo?.isFallbackAdapter || highInfo?.isFallbackAdapter) return 'fallback';\n\n const lowKey = adapterIdentityKey(lowInfo);\n const highKey = adapterIdentityKey(highInfo);\n const both = low != null && high != null;\n // Distinct non-empty identities ⇒ hybrid dGPU available.\n if (both && lowKey.length > 3 && highKey.length > 3 && lowKey !== highKey) {\n return 'discrete';\n }\n\n const info = highInfo ?? lowInfo;\n return classifySplatGpuClass(info);\n}\n\n/**\n * Whether fill-rate / laptop-class defaults apply (mobile, or desktop\n * integrated / software fallback). Exported so hosts and the demo share one\n * predicate for performance-mode defaults and adaptive DPR.\n */\nexport function isFillConstrainedSplatDevice(\n profile: SplatDeviceProfile | undefined = detectSplatDeviceProfile(),\n): boolean {\n return (\n profile?.isMobile === true ||\n profile?.gpuClass === 'integrated' ||\n profile?.gpuClass === 'fallback'\n );\n}\n\n/**\n * The cost class of a streamed format. Unknown and absent formats read as\n * `'sampled'`, which is the pre-existing behaviour for every caller that does\n * not name one.\n */\nfunction splatCostClass(format?: StreamedSplatFormat): SplatCostClass {\n return format === 'rad' || format === 'lcc' || format === 'lcc2' ? 'lcc' : 'sampled';\n}\n\n/**\n * The headset ceiling, below the phone one.\n *\n * A headset is a phone-class GPU asked to fill a stereo framebuffer larger\n * than a 4K desktop (~1680×1760 *per eye* on a Quest 3), sharing the SoC with\n * a compositor that must hit 72 Hz. Every splat is drawn twice, so the\n * fill-rate wall that sets {@link MOBILE_BUDGET_MAX} arrives proportionally\n * sooner. This is a *cap*, not a floor: a genuinely small device still scales\n * below it on its memory signal.\n */\nconst HEADSET_BUDGET_MAX = 600_000;\n\n/**\n * The ceiling for a budget phone, below the flagship one.\n *\n * A mid-range mobile GPU is not a slightly slower flagship: a Mali-G72 MP3\n * (Galaxy A51) has a small fraction of an Adreno 750's fill rate, while both\n * report the same privacy-capped 8 GiB `deviceMemory` and the same `isMobile`.\n * Starting such a device at the flagship ceiling means it thrashes from the\n * first frame. This is a *cap*: a device whose memory signal is lower still\n * scales below it.\n */\nconst LOW_POWER_BUDGET_MAX = 500_000;\n\n/**\n * The ceiling for a mobile device with no WebGPU at all.\n *\n * This is the tightest tier, and the signal behind it is the most direct: with\n * no WebGPU there is no compute path, so the depth sort runs on the CPU in a\n * worker, and that cost scales with the splat count far worse than a GPU radix\n * sort does. A phone still on the WebGL2 fallback is also, in practice, old -\n * WebGPU has shipped on Android 12+ and iOS 18 - so the two costs arrive\n * together.\n *\n * Measured on a Galaxy S7 (2016, Mali-T880, WebGL2) at **750k**, which was the\n * low-power tier at the time: **5 fps, 194 ms frames**. That device is out of\n * scope as a target, but it is evidence that the memory-derived tier alone lands\n * far too high once the sort is on the CPU. The tiers have since been lowered\n * across the board, so the figure is the measurement's, not this constant's.\n *\n * Desktop is deliberately exempt: a desktop that falls back to WebGL2 is\n * sorting on a far stronger CPU, and capping it here would punish a\n * driver-blocklist fallback on capable hardware.\n */\nconst NO_WEBGPU_BUDGET_MAX = 400_000;\n\n/** Reads the available browser device signals without requiring a DOM runtime. */\nexport function detectSplatDeviceProfile(): SplatDeviceProfile | undefined {\n if (typeof navigator === 'undefined') return undefined;\n const nav = navigator as Navigator & { deviceMemory?: number };\n const isIOS =\n /iPhone|iPad|iPod/.test(nav.userAgent) ||\n (nav.platform === 'MacIntel' && nav.maxTouchPoints > 1);\n // UA first (it names the platform outright); the coarse-pointer probe then\n // catches mobile browsers that hide or reword their UA.\n const isMobile =\n isIOS ||\n /Android/i.test(nav.userAgent) ||\n (nav.maxTouchPoints > 0 &&\n typeof matchMedia === 'function' &&\n matchMedia('(pointer: coarse)').matches);\n // Best-effort *hint* only - see `resolveXrSplatBudget`, which is what\n // actually sizes an XR session. Most standalone headsets are Android under\n // the hood (Quest, Pico, Vive, Android XR), so `isMobile` already catches\n // them even when this misses; the named flag just lets first paint start at\n // a headset-appropriate size instead of resizing after the session opens.\n //\n // It cannot be relied on. visionOS Safari presents as *desktop* Safari, so\n // Apple Vision Pro matches neither this nor `isMobile` and would take the\n // desktop budget; and every new headset misses until its string is added.\n // That is the whole reason budgets key off presentation state instead.\n const isHeadset = /OculusBrowser|Quest|Pico(?: Neo)?[ /]|Wolvic|VRBrowser|Vive|XRBrowser/i.test(\n nav.userAgent,\n );\n // A budget phone, best-effort. `deviceMemory` is privacy-capped at 8 GiB but\n // *not* raised, so a device reporting ≤4 really does have ≤4 - that makes a\n // low reading trustworthy even though a high one says nothing. Deliberately\n // not `hardwareConcurrency`: it would get this exactly backwards, since the\n // Galaxy A51 this exists for is octa-core while an iPhone 15 Pro reports 6.\n const isLowPower =\n (isMobile || isHeadset) && nav.deviceMemory !== undefined && nav.deviceMemory <= 4;\n return {\n ...(nav.deviceMemory === undefined ? {} : { deviceMemoryGb: nav.deviceMemory }),\n isIOS,\n isMobile: isMobile || isHeadset,\n isHeadset,\n isLowPower,\n hasWebGpu: 'gpu' in nav,\n };\n}\n\n/** Scene-side signals for {@link resolveSplatBudget}. */\nexport interface SplatBudgetOptions {\n /**\n * The format about to be loaded, which selects the {@link SplatCostClass}\n * whose mobile tier applies. Omit when it is not yet known - the `'sampled'`\n * numbers are what every caller resolved before cost classes existed.\n */\n format?: StreamedSplatFormat;\n /**\n * A ceiling on the resolved default, for callers that want to tighten without\n * overriding what this function knows.\n *\n * `override` is absolute: it wins over the device tier, the cost class and\n * everything else, because a caller who names a number has said they know\n * better. That is the right contract and the wrong tool for \"the same as\n * usual, but no more than N\" - which is what a performance toggle or a host\n * default actually means. Pinning a number there has twice shipped as a bug:\n * a demo performance mode that *raised* the load on the weakest device tested,\n * and a host default that bypassed every device tier.\n *\n * Applied only when `override` is omitted, and only downward.\n */\n cap?: number;\n}\n\n/**\n * Chooses a default active-splat budget for the current device.\n *\n * The default is derived from the coarsest device-memory signal the platform\n * exposes; callers who know better (or want a hard cap for a mobile test on\n * desktop) should pass an explicit override.\n *\n * @param override - Explicit budget in splats; wins over device detection when\n * positive. Throws `RangeError` if it is not a positive finite number.\n * @param profile - Device signals to decide from; defaults to detecting them.\n * @param options - Scene signals. `format` selects the {@link SplatCostClass}\n * whose mobile tier applies; omitting it keeps the `'sampled'` numbers, which\n * are what every caller resolved before cost classes existed. `cap` is a\n * ceiling on the *resolved default*, for callers who want to tighten without\n * overriding - see {@link SplatBudgetOptions.cap}.\n * @returns A splat-count budget, clamped to a sensible range.\n */\nexport function resolveSplatBudget(\n override?: number,\n profile: SplatDeviceProfile | undefined = detectSplatDeviceProfile(),\n options: SplatBudgetOptions = {},\n): number {\n const cap = options.cap;\n if (cap !== undefined && (!Number.isFinite(cap) || cap <= 0)) {\n throw new RangeError('Splat budget cap must be a positive finite number.');\n }\n if (override !== undefined) {\n if (!Number.isFinite(override) || override <= 0) {\n throw new RangeError('Splat budget must be a positive finite number.');\n }\n // Deliberately *not* capped: `override` is the caller saying they know\n // better than every default, and `cap` is a ceiling on a default. Applying\n // it here would make it a second, quieter override.\n return Math.floor(override);\n }\n const capped = (budget: number): number =>\n cap === undefined ? budget : Math.min(budget, Math.floor(cap));\n\n // The device ceiling, tightest signal first. Expressed as a cap rather than\n // a flat headset default so a low-memory headset still scales *below* it\n // instead of being pinned up to it - and so the memory validation below\n // still runs for headsets.\n // Every applicable cap, then the tightest of them. A ternary chain would make\n // this order-dependent, and the order is not obvious - a device can be mobile\n // *and* low-power *and* on the WebGL2 fallback at once, and the answer must be\n // the smallest cap regardless of which test happens to run first.\n //\n // Only the mobile cap is per-format. The headset, low-power and no-WebGPU\n // tiers were each measured on their own hardware against a limit the format\n // cannot move - stereo fill, a mid-range GPU, a CPU-side sort - so they stay\n // absolute, and a raised `'sampled'` tier never reaches those devices.\n const mobile = MOBILE_BUDGETS[splatCostClass(options.format)];\n const costClass = splatCostClass(options.format);\n const caps: number[] = [];\n if (profile?.isHeadset) caps.push(HEADSET_BUDGET_MAX);\n if (profile?.isLowPower) caps.push(LOW_POWER_BUDGET_MAX);\n if (profile?.isMobile) caps.push(mobile.ceiling);\n // Mobile only: a desktop on the WebGL2 fallback sorts on a far stronger CPU.\n if (profile?.isMobile && profile.hasWebGpu === false) caps.push(NO_WEBGPU_BUDGET_MAX);\n // Desktop GPU class: laptop / unified / software must not inherit the 8M path.\n if (!profile?.isMobile && profile?.gpuClass === 'integrated') {\n caps.push(INTEGRATED_BUDGETS[costClass]);\n }\n if (!profile?.isMobile && profile?.gpuClass === 'fallback') {\n caps.push(NO_WEBGPU_BUDGET_MAX);\n }\n const ceiling = caps.length === 0 ? Infinity : Math.min(...caps);\n\n // `deviceMemory` (Chrome/Edge/Android) reports GiB, spec-capped at 8.\n const deviceMemoryGb = profile?.deviceMemoryGb;\n if (deviceMemoryGb !== undefined) {\n if (!Number.isFinite(deviceMemoryGb) || deviceMemoryGb <= 0) {\n throw new RangeError('Device memory must be a positive finite number.');\n }\n // 1M splats per reported GiB → Chrome's 8 GiB privacy cap yields the 8M\n // desktop default. Mobile/headset/integrated caps above still win when tighter.\n const scaled = clamp(Math.round(deviceMemoryGb * 1_000_000), 500_000, 8_000_000);\n return capped(Math.min(scaled, ceiling));\n }\n\n // No `deviceMemory`: mobile Safari (iOS) and privacy-restricted mobile\n // browsers land here, and must not fall through to the desktop default.\n if (profile?.isMobile) return capped(Math.min(mobile.withoutMemorySignal, ceiling));\n\n // Integrated / fallback desktop without a memory signal still needs the class\n // ceiling (Apple Silicon Safari often omits deviceMemory entirely).\n if (profile?.gpuClass === 'integrated') {\n return capped(Math.min(INTEGRATED_BUDGETS[costClass], ceiling));\n }\n if (profile?.gpuClass === 'fallback') {\n return capped(Math.min(NO_WEBGPU_BUDGET_MAX, ceiling));\n }\n\n // An absent profile can mean SSR, Node tooling, or a privacy-restricted\n // browser. Known non-iOS desktop without a memory signal still gets the\n // 8M default; everything else stays on the conservative portable floor.\n return capped(profile?.isIOS === false ? 8_000_000 : 1_000_000);\n}\n\n/**\n * The budget to run at **while an immersive session presents**, given whatever\n * budget the page is using outside one.\n *\n * Stereo is the cost, and stereo is a property of the *session*, not of the\n * device: every splat is drawn twice, into two eye viewports that together\n * exceed a 4K desktop, on a GPU that must hold 72–90 Hz or the compositor\n * reprojects. That is true of a standalone headset and equally true of a\n * desktop machine driving a tethered one - which is exactly the case device\n * sniffing cannot see, since such a machine is a desktop right up until the\n * moment it is not.\n *\n * Keying off presentation instead of identity is also the only approach that\n * survives new hardware. `detectSplatDeviceProfile`'s `isHeadset` is a\n * user-agent guess: it misses Apple Vision Pro outright (visionOS Safari\n * presents as desktop Safari, so a headset would otherwise take the multi-\n * million desktop budget) and misses every headset released after it is\n * written. This function needs to know none of that.\n *\n * Apply it on `sessionstart` and restore the original on `sessionend` - via\n * `BudgetGovernor.setBudget` when several meshes share a pool, or a streamed\n * mesh's own budget setter otherwise.\n *\n * @param pageBudget - The budget in use outside the session, typically from\n * {@link resolveSplatBudget}. Throws `RangeError` if not a positive finite\n * number.\n * @returns `pageBudget` lowered to the stereo ceiling; never raised - a device\n * already rendering below the ceiling stays where it is.\n */\nexport function resolveXrSplatBudget(pageBudget: number): number {\n if (!Number.isFinite(pageBudget) || pageBudget <= 0) {\n throw new RangeError('Splat budget must be a positive finite number.');\n }\n return Math.min(Math.floor(pageBudget), HEADSET_BUDGET_MAX);\n}\n\n/**\n * The largest finest-level scene taken whole rather than streamed. At ~64 B\n * of GPU pool per splat this is ~380 MB - affordable on a desktop, and far\n * cheaper than it looks next to the alternative, since a scene held whole\n * never swaps, never compacts and never re-fetches.\n */\nconst FINEST_LEVEL_BUDGET_MAX = 8_000_000;\n\n/**\n * Raises `budget` far enough to hold a scene's finest level in full, when\n * that level is small enough to be worth taking whole.\n *\n * The LOD budget assumes coarser levels are cheap *approximations* of the\n * same surface, so trading detail for memory is nearly free. XGRIDS' LCC\n * LCC breaks that assumption: its five levels are decimated alternatives\n * whose splats merge into wide flat discs (correct at distance, streaked up\n * close), so any budget shortfall costs visible quality - sub-chunking gives\n * the scheduler ~128k-splat granularity to thin cells with, but a thinned\n * fine level is still sparser than the capture intends. A capture whose\n * finest level fits under the desktop ceiling is better shown whole: Casino's\n * level 0 is 4.74M splats and sits under the 8M default.\n *\n * Mobile and fill-constrained desktops (integrated / fallback GPU class) are\n * exempt: their cap is a fill-rate limit, not a sizing accident.\n *\n * @param budget - The resolved device budget.\n * @param finestLevelSplats - Splats in the scene's finest level.\n * @returns `budget`, or the finest level's size when that is the better fit.\n */\nexport function liftBudgetToFinestLevel(\n budget: number,\n finestLevelSplats: number,\n profile: SplatDeviceProfile | undefined = detectSplatDeviceProfile(),\n): number {\n if (isFillConstrainedSplatDevice(profile)) return budget;\n if (!Number.isFinite(finestLevelSplats) || finestLevelSplats <= budget) return budget;\n if (finestLevelSplats > FINEST_LEVEL_BUDGET_MAX) return budget;\n return Math.floor(finestLevelSplats);\n}\n\n/** Options for {@link estimateSplatPoolBytes}. */\nexport interface SplatPoolBytesOptions {\n /**\n * Pool texture precision, matching `SplatMeshOptions.poolFloatTextures`.\n * `'float16'` halves centers and covarianceA. Default `'float32'`.\n */\n floatTextures?: 'float32' | 'float16';\n /** Per-splat SH bands the pool allocates for. Default `0`. */\n shBands?: 0 | 1 | 2 | 3;\n /**\n * Pool capacity as a multiple of `splats`. `StreamedSplatMesh` allocates\n * 1.5× (the default here) so per-run row alignment and the\n * append-before-remove window during LOD swaps have somewhere to go; pass\n * `1.4` to model `experimentalStagedSwaps: false`, or `1` for a static mesh.\n */\n capacityFactor?: number;\n /**\n * Whether to include the CPU-side backing arrays. Default `true` - they are\n * real host memory a device has to find, so the honest answer to \"can I\n * afford this ceiling\" includes them.\n */\n includeCpuBacking?: boolean;\n}\n\n/**\n * Estimates the memory a splat pool of `splats` costs.\n *\n * This exists to make a streamed mesh's `maxBudget` a computation rather than a\n * guess. A pool is allocated **once, from the ceiling** and never\n * grows, so several governed meshes cost the sum of their ceilings whatever the\n * shared budget is set to - a `BudgetGovernor` redistributes *sharpness* within\n * that envelope, it does not shrink it. Price the ceilings before choosing them:\n *\n * ```js\n * // 1 main + 4 additional meshes, each able to reach 1.5M splats\n * const bytes = estimateSplatPoolBytes(4_000_000) + 4 * estimateSplatPoolBytes(1_500_000);\n * ```\n *\n * Per splat of *capacity* (`splats × capacityFactor`), counted from what the\n * constructor actually allocates:\n *\n * - **Pool textures** 52 B - centers RGBA32F 16, colors RGBA8 4,\n * covarianceA RGBA32F 16, covarianceB RGBA32F 16. `'float16'` drops centers\n * and covarianceA to 8 each (36 B); covarianceB stays float32 because it\n * packs integer IDs.\n * - **Packed SH** 16 B per `RGBA32UI` texture, `ceil(coefficients / 4)` of them\n * - so 16 / 32 / 64 B at 1 / 2 / 3 bands.\n * - **Sort storage** 16 B - the radix sorter's ping-pong key/value buffers.\n * - **CPU backing** 68 B - the float32/uint8 arrays kept for partial uploads\n * (always full precision, even under `'float16'`): centers 16, colors 4,\n * covarianceA 16, covarianceB 16, plus four u32-per-splat arrays - draw-order\n * indices, the active-list source index, the pool-slot map, and the picker's\n * pool-index template (allocated lazily, counted because a picked scene pays\n * it). Under `'float16'` add a further 16 B: the half-encoded texture images\n * are held alongside the float32 backing, not instead of it, so that\n * precision saves GPU bytes only. Included unless `includeCpuBacking` is\n * `false`.\n *\n * The module header's \"roughly 64 bytes per splat\" rounds the GPU side of this.\n *\n * @param splats - The ceiling in splats (e.g. a mesh's `maxBudget`).\n * @returns Estimated bytes. Indicative, not a device-memory guarantee: driver\n * texture padding, staging allocations and the decoded-chunk CPU cache\n * ({@link resolveCpuCacheBytes}) sit outside it.\n * @throws {RangeError} if `splats` is not a positive finite number, or\n * `capacityFactor` is not a finite number `>= 1`.\n */\nexport function estimateSplatPoolBytes(\n splats: number,\n options: SplatPoolBytesOptions = {},\n): number {\n if (!Number.isFinite(splats) || splats <= 0) {\n throw new RangeError('Splat count must be a positive finite number.');\n }\n const capacityFactor = options.capacityFactor ?? 1.5;\n if (!Number.isFinite(capacityFactor) || capacityFactor < 1) {\n throw new RangeError('Pool capacityFactor must be a finite number >= 1.');\n }\n const float16 = options.floatTextures === 'float16';\n const shTextures = Math.ceil(shCoefficientCount(options.shBands ?? 0) / 4);\n const shBytes = shTextures * 16;\n\n const centers = float16 ? 8 : 16;\n const covarianceA = float16 ? 8 : 16;\n const poolTextures = centers + 4 /* colors */ + covarianceA + 16 /* covarianceB */ + shBytes;\n const sortBuffers = 16; // radix ping-pong: keys A/B + values A/B, u32 each\n // Backing arrays are float32/uint32 regardless of the texture precision, and\n // under 'float16' the half-encoded texture images are held *in addition* to\n // them (the constructor keeps both), so that precision saves GPU bytes only.\n const halfImages = float16 ? 8 + 8 : 0; /* centersImage + covarianceAImage */\n const cpuBacking =\n options.includeCpuBacking === false\n ? 0\n : 16 /* centers */ +\n 4 /* colors */ +\n 16 /* covarianceA */ +\n 16 /* covarianceB */ +\n shBytes +\n 4 /* draw order (splatIndexes) */ +\n 4 /* active-list source index */ +\n 4 /* activeSlotByPoolIndex */ +\n 4 /* picker pool-index template */ +\n halfImages;\n\n const capacity = Math.ceil(splats * capacityFactor);\n return capacity * (poolTextures + sortBuffers + cpuBacking);\n}\n\n/**\n * Suggested ceiling for the renderer's pixel ratio on this device.\n *\n * Splat rendering is fragment-bound, so render resolution is one of the\n * largest costs on a high-DPI phone: a 2.6x display renders ~7x the pixels of\n * a 1x one, and every one of them blends the full depth-sorted splat stack.\n * The library cannot apply this itself (it draws into a renderer the app\n * owns), so it exports the policy - pass it to `renderer.setPixelRatio`:\n *\n * ```js\n * renderer.setPixelRatio(Math.min(window.devicePixelRatio, recommendedMaxPixelRatio()));\n * ```\n *\n * This is the *quality* ceiling. Viewers that offer a performance mode\n * (SuperSplat halves mobile resolution in its own) should go lower still when\n * it is on.\n */\nexport function recommendedMaxPixelRatio(\n profile: SplatDeviceProfile | undefined = detectSplatDeviceProfile(),\n): number {\n return isFillConstrainedSplatDevice(profile) ? 1.5 : 2;\n}\n\n/**\n * The Gaussian cutoff a `.rad` renders at, in standard deviations, or\n * `undefined` to accept the device-wide default.\n *\n * Spark hard-codes `sqrt(8)` (≈2.83σ) for every device, and matching it is what\n * makes a `.rad` look like Spark's render on a desktop. On a phone it is the\n * wrong trade: rendering is fill-bound on mobile, so a format override bypasses\n * the device policy that every other format accepts.\n *\n * Returning `undefined` on mobile lets `SplatMesh` apply its own 3σ cutoff and\n * undersized-splat floor; `.rad` must not escape that mobile policy.\n *\n * @returns √8 on discrete desktop; `undefined` on fill-constrained devices,\n * meaning \"no format override\".\n */\nexport function recommendedRadMaxStdDev(\n profile: SplatDeviceProfile | undefined = detectSplatDeviceProfile(),\n): number | undefined {\n return isFillConstrainedSplatDevice(profile) ? undefined : Math.SQRT2 * 2;\n}\n\n/**\n * Suggested WebXR framebuffer scale for this device. On three 0.185.x this is\n * a **WebGL XR** policy: apply it once before the session starts with\n * `renderer.xr.setFramebufferScaleFactor`. three's WebGPU XR path creates its\n * `XRGPUBinding` projection layer at native scale and does not consume that\n * setting, so WebGPU hosts should leave the native scale and use the presenting\n * splat budget plus fixed foveation for runtime headroom instead.\n *\n * Splat rendering is fill-bound, and a headset's default framebuffer is\n * already supersampled past its panels (~1680×1760 per eye on Quest 3):\n * 0.8 cuts fragment work ~36% for a barely visible softening. Non-headset\n * XR (desktop-tethered) keeps the native 1.0.\n */\nexport function recommendedXrFramebufferScale(\n profile: SplatDeviceProfile | undefined = detectSplatDeviceProfile(),\n): number {\n return profile?.isHeadset ? 0.8 : 1;\n}\n\n/**\n * Host-threaded samples to ignore before the EMA starts.\n *\n * First `renderer.compute` and first `copyTextureToTexture` each compile a\n * WebGPU pipeline (~200 ms). Seeding the EMA from those drops the ratio to\n * the floor, and a 60 Hz display's 16.7 ms vsync never beats the raise bar\n * (`targetFrameMs * 0.85` ≈ 15.3 ms), so the drop is permanent. Pass the\n * returned {@link AdaptivePixelRatioResult.warmupRemaining} back each frame.\n */\nexport const ADAPTIVE_PIXEL_RATIO_WARMUP_FRAMES = 5;\n\n/** Inputs for {@link suggestAdaptivePixelRatio}. */\nexport interface AdaptivePixelRatioInput {\n /** Latest wall-frame time in milliseconds. */\n frameMs: number;\n /** Currently applied pixel ratio. */\n current: number;\n /** Quality ceiling (typically {@link recommendedMaxPixelRatio}). */\n max: number;\n /** Floor; defaults to `1`. */\n min?: number;\n /** EMA of frame time from the previous call; omit on the first sample. */\n emaMs?: number;\n /**\n * Samples still ignored before the EMA starts. Omit or `0` to start\n * immediately. Thread the result's `warmupRemaining` when using\n * {@link ADAPTIVE_PIXEL_RATIO_WARMUP_FRAMES}.\n */\n warmupRemaining?: number;\n /**\n * Comfortable frame time (ms). Below this (with headroom) the helper may\n * step the ratio up. Default `18` (~55 fps).\n */\n targetFrameMs?: number;\n /**\n * Sustained frame time (ms) that triggers a step down. Default `22`\n * (~45 fps) so brief spikes do not thrash the canvas size.\n */\n pressureFrameMs?: number;\n}\n\n/** Result of {@link suggestAdaptivePixelRatio}. */\nexport interface AdaptivePixelRatioResult {\n /** Suggested pixel ratio after hysteresis (quarter steps). */\n pixelRatio: number;\n /** Updated EMA to pass back on the next call. Unset while warming up. */\n emaMs: number | undefined;\n /** Remaining warmup samples; pass back as `warmupRemaining`. */\n warmupRemaining: number;\n}\n\n/**\n * Suggests a pixel ratio under frame-time pressure.\n *\n * The library cannot call `renderer.setPixelRatio` (the host owns the\n * renderer); this is a pure policy helper. Pass the previous result's\n * `emaMs` and `warmupRemaining` each frame, and only re-size the canvas when\n * `pixelRatio` changes.\n *\n * Steps are quarter-units with asymmetric thresholds (pressure to lower,\n * comfortable headroom to raise) so the ratio does not oscillate. One-off\n * hitches (pipeline compiles, tab resume) that are several times the EMA do\n * not count as pressure.\n */\nexport function suggestAdaptivePixelRatio(\n input: AdaptivePixelRatioInput,\n): AdaptivePixelRatioResult {\n const min = input.min ?? 1;\n const max = Math.max(min, input.max);\n const targetFrameMs = input.targetFrameMs ?? 18;\n const pressureFrameMs = input.pressureFrameMs ?? 22;\n const frameMs = Number.isFinite(input.frameMs) ? Math.max(0, input.frameMs) : targetFrameMs;\n const pixelRatio = clamp(input.current, min, max);\n const warmupRaw = input.warmupRemaining;\n const warmupRemaining =\n warmupRaw !== undefined && Number.isFinite(warmupRaw) && warmupRaw > 0\n ? Math.floor(warmupRaw)\n : 0;\n if (warmupRemaining > 0) {\n return { pixelRatio, emaMs: input.emaMs, warmupRemaining: warmupRemaining - 1 };\n }\n if (\n input.emaMs !== undefined &&\n Number.isFinite(input.emaMs) &&\n frameMs > Math.max(input.emaMs * 4, pressureFrameMs * 4)\n ) {\n return { pixelRatio, emaMs: input.emaMs, warmupRemaining: 0 };\n }\n const alpha = 0.15;\n const emaMs = input.emaMs === undefined ? frameMs : input.emaMs * (1 - alpha) + frameMs * alpha;\n\n let next = pixelRatio;\n if (emaMs > pressureFrameMs && next > min) {\n next = Math.max(min, roundPixelRatio(next - 0.25));\n } else if (emaMs < targetFrameMs * 0.85 && next < max) {\n next = Math.min(max, roundPixelRatio(next + 0.25));\n }\n return { pixelRatio: clamp(next, min, max), emaMs, warmupRemaining: 0 };\n}\n\n/** Quarter-step pixel ratios (1, 1.25, 1.5, …) keep canvas resizes coarse. */\nfunction roundPixelRatio(value: number): number {\n return Math.round(value * 4) / 4;\n}\n\n/**\n * Chooses a decoded-chunk CPU cache cap from the same safe device profile.\n *\n * **The unknown-memory fallback must not read as \"tiny\".** iOS Safari does not\n * implement `navigator.deviceMemory` at all, so every iPhone lands on this\n * branch - and an earlier version resolved it to 1 GiB, i.e. the 32 MiB floor,\n * on hardware with 8 GiB. That is not a conservative guess, it is a wrong one,\n * and it is invisible on `.rad`, whose page-table cache has its own floor.\n *\n * On streamed SOG there is no such floor and the cost is immediate: measured on\n * an iPhone 15 Pro against `sandwijck`, the scheduler asked for 539,734 splats\n * and the mesh could only hold 466,499 of them across 3 chunk files (desktop:\n * 5), evicting continuously. The finest level is wanted *nearest the camera* and\n * lives in the largest files, so those are what fail to stay resident - the\n * middle of the view drops out and the scene renders as a donut.\n *\n * A device that declines to report its memory is far likelier to be a modern\n * phone withholding a fingerprinting signal than an actual 1 GiB device, so the\n * fallback assumes 4 GiB (128 MiB of cache). The floor stays for the genuinely\n * profile-less case - SSR and Node tooling, which never stream a scene anyway.\n */\nexport function resolveCpuCacheBytes(\n profile: SplatDeviceProfile | undefined = detectSplatDeviceProfile(),\n): number {\n const gb = profile === undefined ? 1 : (profile.deviceMemoryGb ?? 4);\n const mib = 1024 * 1024;\n return Math.min(256 * mib, Math.max(32 * mib, gb * 32 * mib));\n}\n\nfunction clamp(value: number, min: number, max: number): number {\n return Math.min(max, Math.max(min, value));\n}\n"],"names":["MOBILE_BUDGETS","INTEGRATED_BUDGETS","classifySplatGpuClass","info","vendor","architecture","device","description","blob","ambientProbeGpu","nav","gpu","adapterIdentityKey","probeSplatGpuClass","low","high","adapters","lowInfo","highInfo","lowKey","highKey","isFillConstrainedSplatDevice","profile","detectSplatDeviceProfile","splatCostClass","format","HEADSET_BUDGET_MAX","LOW_POWER_BUDGET_MAX","NO_WEBGPU_BUDGET_MAX","isIOS","isMobile","isHeadset","isLowPower","resolveSplatBudget","override","options","cap","capped","budget","mobile","costClass","caps","ceiling","deviceMemoryGb","scaled","clamp","resolveXrSplatBudget","pageBudget","FINEST_LEVEL_BUDGET_MAX","liftBudgetToFinestLevel","finestLevelSplats","estimateSplatPoolBytes","splats","capacityFactor","float16","shBytes","shCoefficientCount","centers","covarianceA","poolTextures","sortBuffers","halfImages","cpuBacking","recommendedMaxPixelRatio","recommendedRadMaxStdDev","recommendedXrFramebufferScale","ADAPTIVE_PIXEL_RATIO_WARMUP_FRAMES","suggestAdaptivePixelRatio","input","min","max","targetFrameMs","pressureFrameMs","frameMs","pixelRatio","warmupRaw","warmupRemaining","alpha","emaMs","next","roundPixelRatio","value","resolveCpuCacheBytes","gb","mib"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"splat-data-BZM5dhhx.js","sources":["../src/lib/splat-data.ts"],"sourcesContent":["import type { SplatDataFormat } from './loading';\n\n/**\n * CPU-side representation of a set of 3D Gaussians, ready for GPU upload.\n *\n * Everything is stored in flat typed arrays (struct-of-arrays) so the data\n * can be copied straight into textures and transferred to workers.\n */\nexport interface SplatData {\n /** Number of Gaussians. */\n readonly count: number;\n /** Gaussian centers, 3 floats (x, y, z) per splat. */\n readonly positions: Float32Array;\n /** Base color + opacity, 4 bytes (r, g, b, a) per splat. */\n readonly colors: Uint8Array;\n /**\n * 3D covariance matrices, 6 floats per splat: the upper triangle\n * (m00, m01, m02, m11, m12, m22) of the symmetric 3×3 matrix\n * Σ = R·S·Sᵀ·Rᵀ, precomputed from the per-splat scale and rotation.\n */\n readonly covariances: Float32Array;\n /** Higher-order spherical harmonics (view-dependent color), if present. */\n readonly sh?: SplatShData;\n /**\n * Per-splat (rather than palette-compressed) higher-order SH, as LCC `Quality`\n * delivers it. Unlike {@link sh}, this survives being appended into a\n * shared pool, because there is no per-file palette to merge.\n */\n readonly shPacked?: SplatPackedShData;\n /**\n * Scene was trained/exported with antialiasing (e.g. the SOG `antialias`\n * meta flag). When true, {@link SplatMesh} applies the Mip-Splatting 2D\n * filter (dilation + opacity compensation). Absent/false = classic 3DGS.\n */\n readonly antialias?: boolean;\n /**\n * Per-splat LOD-tree links for a streamed `.rad` chunk (the merged coarse\n * nodes and leaves are all present in {@link positions} etc.). The streamed\n * reader uses this to pick the rendered cut across chunks; other formats and\n * the whole-file path leave it undefined. See `parse-rad.ts` and `rad.ts`.\n */\n readonly radTree?: RadTreeData;\n /**\n * Per-splat parent LOD-node size for the frontier cut (`foveationMode:\n * 'frontier'`), sign-encoding leaf-ness: `> 0` internal node, `< 0` leaf,\n * `|value|` is the parent's world size (a large sentinel ≈ ∞ for a root or a\n * splat whose parent is not yet decoded). Attached by `RadFoveatedSource` as\n * chunks stream in; uploaded into `covarianceB.w`. See `docs/formats/rad-notes.md`.\n */\n readonly frontierParent?: Float32Array;\n /** Internal RAD SH codebook retained while a streamed `.rad` scene is set\n * up. It is passed back to later chunk decodes; consumers can ignore it. */\n readonly radShCodebook?: RadShCodebook;\n /**\n * The self-contained format this scene was decoded from, stamped by the\n * loaders ({@link loadSplatData}/{@link loadSplatDataFile}). {@link SplatMesh} reads\n * it to pick the `orientation: 'y-up'` correction. Absent on hand-built data\n * and streamed chunks, in which case no orientation is ever inferred.\n */\n readonly format?: SplatDataFormat;\n}\n\n/** Palette emitted once in chunk zero by Spark's clustered RAD SH encoding. */\nexport interface RadShCodebook {\n readonly bands: 1 | 2 | 3;\n /** RGB triples, coefficient-major and codebook-entry-major. */\n readonly coefficients: Float32Array;\n readonly count: number;\n}\n\n/** Per-splat LOD-tree links carried on a streamed `.rad` chunk's `SplatData`. */\nexport interface RadTreeData {\n /** Number of children of splat `i`; 0 marks a leaf. */\n readonly childCount: Uint16Array;\n /** Global index of splat `i`'s first child; its children are the contiguous\n * range `[childStart, childStart + childCount)`. */\n readonly childStart: Uint32Array;\n /**\n * LOD node size (world units) of splat `i` - Spark's `2·expansion·avg(scale)`,\n * the radius the runtime cut projects to a screen size. Drives foveation\n * (see `docs/formats/rad-notes.md` M14.6): `pixel_scale = size / distance`.\n */\n readonly size: Float32Array;\n}\n\n/**\n * Palette-compressed higher-order SH coefficients, as in the SOG format:\n * each splat references one of up to 65,536 palette entries (its \"label\"),\n * and each entry holds the coefficients for all three color channels.\n */\nexport interface SplatShData {\n /** SH bands beyond the DC term: 1, 2 or 3 → 3, 8 or 15 coefficients. */\n readonly bands: number;\n /** Per-splat palette index, `count` entries. */\n readonly labels: Uint32Array;\n /**\n * Dequantized palette coefficients as an RGBA float image in the SOG\n * centroids layout: palette entry n, coefficient c lives at column\n * (n % 64) · coefficientCount + c, row ⌊n / 64⌋; the R, G, B channels\n * hold that coefficient for the red, green and blue SH respectively.\n * Uploadable directly as an RGBA32F texture.\n */\n readonly palette: Float32Array;\n readonly paletteWidth: number;\n readonly paletteHeight: number;\n}\n\n/**\n * Per-splat higher-order SH coefficients, still in their source packing:\n * one `uint32` per coefficient holding all three channels (R: bits 0-10,\n * G: bits 11-20, B: bits 21-31), dequantized across {@link range}.\n *\n * The words are kept packed all the way to the GPU - the shader unpacks them.\n * Expanding to floats would cost 45 floats per splat (~1.15 GB over a 6M-splat\n * pool) where the packed form costs 64 bytes (~384 MB) and is lossless.\n */\nexport interface SplatPackedShData {\n /** SH bands beyond the DC term: 1, 2 or 3 → 3, 8 or 15 coefficients. */\n readonly bands: 1 | 2 | 3;\n /** `count * coefficients` words, splat-major. */\n readonly packed: Uint32Array;\n /** Per-channel dequantization range shared by every coefficient. */\n readonly range: {\n readonly min: readonly [number, number, number];\n readonly max: readonly [number, number, number];\n };\n}\n\n/** First-order spherical harmonics constant: Y₀₀ = 1 / (2√π). */\nexport const SH_C0 = 0.28209479177387814;\n\n/**\n * Folds a splat's linear scale and rotation quaternion into the upper\n * triangle of its 3D covariance matrix Σ = R·S·Sᵀ·Rᵀ (the 3DGS paper's\n * parameterization), written to `out` at `index * 6`.\n *\n * The quaternion (w, x, y, z) does not need to be normalized.\n */\nexport function writeCovariance(\n out: Float32Array,\n index: number,\n scaleX: number,\n scaleY: number,\n scaleZ: number,\n qw: number,\n qx: number,\n qy: number,\n qz: number,\n): void {\n const qLength = Math.hypot(qw, qx, qy, qz) || 1;\n const w = qw / qLength;\n const x = qx / qLength;\n const y = qy / qLength;\n const z = qz / qLength;\n\n // M = R·S (rotation matrix from the quaternion, columns scaled).\n const m00 = (1 - 2 * (y * y + z * z)) * scaleX;\n const m01 = 2 * (x * y - w * z) * scaleY;\n const m02 = 2 * (x * z + w * y) * scaleZ;\n const m10 = 2 * (x * y + w * z) * scaleX;\n const m11 = (1 - 2 * (x * x + z * z)) * scaleY;\n const m12 = 2 * (y * z - w * x) * scaleZ;\n const m20 = 2 * (x * z - w * y) * scaleX;\n const m21 = 2 * (y * z + w * x) * scaleY;\n const m22 = (1 - 2 * (x * x + y * y)) * scaleZ;\n\n // Σ = M·Mᵀ is symmetric; store its upper triangle.\n out[index * 6 + 0] = m00 * m00 + m01 * m01 + m02 * m02;\n out[index * 6 + 1] = m00 * m10 + m01 * m11 + m02 * m12;\n out[index * 6 + 2] = m00 * m20 + m01 * m21 + m02 * m22;\n out[index * 6 + 3] = m10 * m10 + m11 * m11 + m12 * m12;\n out[index * 6 + 4] = m10 * m20 + m11 * m21 + m12 * m22;\n out[index * 6 + 5] = m20 * m20 + m21 * m21 + m22 * m22;\n}\n\n/**\n * Transforms an existing covariance Σ (upper triangle `m00,m01,m02,m11,m12,m22`)\n * by the 3×3 linear part `A` of a world matrix, writing Σ' = A·Σ·Aᵀ back as an\n * upper triangle. This is how a Gaussian's shape maps into a parent frame when a\n * splat cloud is placed with a world transform - the rotation/scale portion of\n * that transform (translation does not affect covariance) applied to the shape.\n *\n * Unlike {@link writeCovariance}, which *builds* Σ from a splat's scale and\n * rotation, this operates on an already-folded Σ. Non-uniform scale and shear in\n * `A` are handled exactly (the result is still a valid symmetric covariance).\n *\n * `a` is the linear part in **column-major** order - the first 9 elements of a\n * {@link https://threejs.org/docs/#api/en/math/Matrix4 | THREE.Matrix4}'s\n * `elements` layout for a matrix with no projective row, i.e.\n * `[a00, a10, a20, a01, a11, a21, a02, a12, a22]`. Reading `Matrix4.elements`\n * directly (ignoring the translation column and bottom row) yields exactly this.\n */\nexport function transformCovariance(\n out: Float32Array,\n outIndex: number,\n cov: Float32Array,\n covIndex: number,\n a: ArrayLike<number>,\n): void {\n // A, column-major: a[col*4 ... ] would be Matrix4; here we take the linear 3×3\n // as elements[0..2] = column 0, [3..5] would skip - but Matrix4 stores a 4×4,\n // so callers pass the compacted 9-element linear part described above.\n const a00 = a[0] as number;\n const a10 = a[1] as number;\n const a20 = a[2] as number;\n const a01 = a[3] as number;\n const a11 = a[4] as number;\n const a21 = a[5] as number;\n const a02 = a[6] as number;\n const a12 = a[7] as number;\n const a22 = a[8] as number;\n\n // Σ (symmetric) from the upper triangle.\n const s00 = cov[covIndex * 6 + 0] as number;\n const s01 = cov[covIndex * 6 + 1] as number;\n const s02 = cov[covIndex * 6 + 2] as number;\n const s11 = cov[covIndex * 6 + 3] as number;\n const s12 = cov[covIndex * 6 + 4] as number;\n const s22 = cov[covIndex * 6 + 5] as number;\n\n // T = A·Σ (3×3). Rows of A dotted with columns of the symmetric Σ.\n const t00 = a00 * s00 + a01 * s01 + a02 * s02;\n const t01 = a00 * s01 + a01 * s11 + a02 * s12;\n const t02 = a00 * s02 + a01 * s12 + a02 * s22;\n const t10 = a10 * s00 + a11 * s01 + a12 * s02;\n const t11 = a10 * s01 + a11 * s11 + a12 * s12;\n const t12 = a10 * s02 + a11 * s12 + a12 * s22;\n const t20 = a20 * s00 + a21 * s01 + a22 * s02;\n const t21 = a20 * s01 + a21 * s11 + a22 * s12;\n const t22 = a20 * s02 + a21 * s12 + a22 * s22;\n\n // Σ' = T·Aᵀ; store the upper triangle (Σ' is symmetric).\n out[outIndex * 6 + 0] = t00 * a00 + t01 * a01 + t02 * a02;\n out[outIndex * 6 + 1] = t00 * a10 + t01 * a11 + t02 * a12;\n out[outIndex * 6 + 2] = t00 * a20 + t01 * a21 + t02 * a22;\n out[outIndex * 6 + 3] = t10 * a10 + t11 * a11 + t12 * a12;\n out[outIndex * 6 + 4] = t10 * a20 + t11 * a21 + t12 * a22;\n out[outIndex * 6 + 5] = t20 * a20 + t21 * a21 + t22 * a22;\n}\n"],"names":["SH_C0","writeCovariance","out","index","scaleX","scaleY","scaleZ","qw","qx","qy","qz","qLength","w","x","y","z","m00","m01","m02","m10","m11","m12","m20","m21","m22"],"mappings":"AAiIO,MAAMA,IAAQ;AASd,SAASC,EACdC,GACAC,GACAC,GACAC,GACAC,GACAC,GACAC,GACAC,GACAC,GACM;AACN,QAAMC,IAAU,KAAK,MAAMJ,GAAIC,GAAIC,GAAIC,CAAE,KAAK,GACxCE,IAAIL,IAAKI,GACTE,IAAIL,IAAKG,GACTG,IAAIL,IAAKE,GACTI,IAAIL,IAAKC,GAGTK,KAAO,IAAI,KAAKF,IAAIA,IAAIC,IAAIA,MAAMX,GAClCa,IAAM,KAAKJ,IAAIC,IAAIF,IAAIG,KAAKV,GAC5Ba,IAAM,KAAKL,IAAIE,IAAIH,IAAIE,KAAKR,GAC5Ba,IAAM,KAAKN,IAAIC,IAAIF,IAAIG,KAAKX,GAC5BgB,KAAO,IAAI,KAAKP,IAAIA,IAAIE,IAAIA,MAAMV,GAClCgB,IAAM,KAAKP,IAAIC,IAAIH,IAAIC,KAAKP,GAC5BgB,IAAM,KAAKT,IAAIE,IAAIH,IAAIE,KAAKV,GAC5BmB,IAAM,KAAKT,IAAIC,IAAIH,IAAIC,KAAKR,GAC5BmB,KAAO,IAAI,KAAKX,IAAIA,IAAIC,IAAIA,MAAMR;AAGxC,EAAAJ,EAAIC,IAAQ,IAAI,CAAC,IAAIa,IAAMA,IAAMC,IAAMA,IAAMC,IAAMA,GACnDhB,EAAIC,IAAQ,IAAI,CAAC,IAAIa,IAAMG,IAAMF,IAAMG,IAAMF,IAAMG,GACnDnB,EAAIC,IAAQ,IAAI,CAAC,IAAIa,IAAMM,IAAML,IAAMM,IAAML,IAAMM,GACnDtB,EAAIC,IAAQ,IAAI,CAAC,IAAIgB,IAAMA,IAAMC,IAAMA,IAAMC,IAAMA,GACnDnB,EAAIC,IAAQ,IAAI,CAAC,IAAIgB,IAAMG,IAAMF,IAAMG,IAAMF,IAAMG,GACnDtB,EAAIC,IAAQ,IAAI,CAAC,IAAImB,IAAMA,IAAMC,IAAMA,IAAMC,IAAMA;AACrD;"}
1
+ {"version":3,"file":"splat-data-BZM5dhhx.js","sources":["../src/lib/core/splat-data.ts"],"sourcesContent":["import type { SplatDataFormat } from '../loaders/loading';\n\n/**\n * CPU-side representation of a set of 3D Gaussians, ready for GPU upload.\n *\n * Everything is stored in flat typed arrays (struct-of-arrays) so the data\n * can be copied straight into textures and transferred to workers.\n */\nexport interface SplatData {\n /** Number of Gaussians. */\n readonly count: number;\n /** Gaussian centers, 3 floats (x, y, z) per splat. */\n readonly positions: Float32Array;\n /** Base color + opacity, 4 bytes (r, g, b, a) per splat. */\n readonly colors: Uint8Array;\n /**\n * 3D covariance matrices, 6 floats per splat: the upper triangle\n * (m00, m01, m02, m11, m12, m22) of the symmetric 3×3 matrix\n * Σ = R·S·Sᵀ·Rᵀ, precomputed from the per-splat scale and rotation.\n */\n readonly covariances: Float32Array;\n /** Higher-order spherical harmonics (view-dependent color), if present. */\n readonly sh?: SplatShData;\n /**\n * Per-splat (rather than palette-compressed) higher-order SH, as LCC `Quality`\n * delivers it. Unlike {@link sh}, this survives being appended into a\n * shared pool, because there is no per-file palette to merge.\n */\n readonly shPacked?: SplatPackedShData;\n /**\n * Scene was trained/exported with antialiasing (e.g. the SOG `antialias`\n * meta flag). When true, {@link SplatMesh} applies the Mip-Splatting 2D\n * filter (dilation + opacity compensation). Absent/false = classic 3DGS.\n */\n readonly antialias?: boolean;\n /**\n * Per-splat LOD-tree links for a streamed `.rad` chunk (the merged coarse\n * nodes and leaves are all present in {@link positions} etc.). The streamed\n * reader uses this to pick the rendered cut across chunks; other formats and\n * the whole-file path leave it undefined. See `parse-rad.ts` and `rad.ts`.\n */\n readonly radTree?: RadTreeData;\n /**\n * Per-splat parent LOD-node size for the frontier cut (`foveationMode:\n * 'frontier'`), sign-encoding leaf-ness: `> 0` internal node, `< 0` leaf,\n * `|value|` is the parent's world size (a large sentinel ≈ ∞ for a root or a\n * splat whose parent is not yet decoded). Attached by `RadFoveatedSource` as\n * chunks stream in; uploaded into `covarianceB.w`. See `docs/formats/rad-notes.md`.\n */\n readonly frontierParent?: Float32Array;\n /** Internal RAD SH codebook retained while a streamed `.rad` scene is set\n * up. It is passed back to later chunk decodes; consumers can ignore it. */\n readonly radShCodebook?: RadShCodebook;\n /**\n * The self-contained format this scene was decoded from, stamped by the\n * loaders ({@link loadSplatData}/{@link loadSplatDataFile}). {@link SplatMesh} reads\n * it to pick the `orientation: 'y-up'` correction. Absent on hand-built data\n * and streamed chunks, in which case no orientation is ever inferred.\n */\n readonly format?: SplatDataFormat;\n}\n\n/** Palette emitted once in chunk zero by Spark's clustered RAD SH encoding. */\nexport interface RadShCodebook {\n readonly bands: 1 | 2 | 3;\n /** RGB triples, coefficient-major and codebook-entry-major. */\n readonly coefficients: Float32Array;\n readonly count: number;\n}\n\n/** Per-splat LOD-tree links carried on a streamed `.rad` chunk's `SplatData`. */\nexport interface RadTreeData {\n /** Number of children of splat `i`; 0 marks a leaf. */\n readonly childCount: Uint16Array;\n /** Global index of splat `i`'s first child; its children are the contiguous\n * range `[childStart, childStart + childCount)`. */\n readonly childStart: Uint32Array;\n /**\n * LOD node size (world units) of splat `i` - Spark's `2·expansion·avg(scale)`,\n * the radius the runtime cut projects to a screen size. Drives foveation\n * (see `docs/formats/rad-notes.md` M14.6): `pixel_scale = size / distance`.\n */\n readonly size: Float32Array;\n}\n\n/**\n * Palette-compressed higher-order SH coefficients, as in the SOG format:\n * each splat references one of up to 65,536 palette entries (its \"label\"),\n * and each entry holds the coefficients for all three color channels.\n */\nexport interface SplatShData {\n /** SH bands beyond the DC term: 1, 2 or 3 → 3, 8 or 15 coefficients. */\n readonly bands: number;\n /** Per-splat palette index, `count` entries. */\n readonly labels: Uint32Array;\n /**\n * Dequantized palette coefficients as an RGBA float image in the SOG\n * centroids layout: palette entry n, coefficient c lives at column\n * (n % 64) · coefficientCount + c, row ⌊n / 64⌋; the R, G, B channels\n * hold that coefficient for the red, green and blue SH respectively.\n * Uploadable directly as an RGBA32F texture.\n */\n readonly palette: Float32Array;\n readonly paletteWidth: number;\n readonly paletteHeight: number;\n}\n\n/**\n * Per-splat higher-order SH coefficients, still in their source packing:\n * one `uint32` per coefficient holding all three channels (R: bits 0-10,\n * G: bits 11-20, B: bits 21-31), dequantized across {@link range}.\n *\n * The words are kept packed all the way to the GPU - the shader unpacks them.\n * Expanding to floats would cost 45 floats per splat (~1.15 GB over a 6M-splat\n * pool) where the packed form costs 64 bytes (~384 MB) and is lossless.\n */\nexport interface SplatPackedShData {\n /** SH bands beyond the DC term: 1, 2 or 3 → 3, 8 or 15 coefficients. */\n readonly bands: 1 | 2 | 3;\n /** `count * coefficients` words, splat-major. */\n readonly packed: Uint32Array;\n /** Per-channel dequantization range shared by every coefficient. */\n readonly range: {\n readonly min: readonly [number, number, number];\n readonly max: readonly [number, number, number];\n };\n}\n\n/** First-order spherical harmonics constant: Y₀₀ = 1 / (2√π). */\nexport const SH_C0 = 0.28209479177387814;\n\n/**\n * Folds a splat's linear scale and rotation quaternion into the upper\n * triangle of its 3D covariance matrix Σ = R·S·Sᵀ·Rᵀ (the 3DGS paper's\n * parameterization), written to `out` at `index * 6`.\n *\n * The quaternion (w, x, y, z) does not need to be normalized.\n */\nexport function writeCovariance(\n out: Float32Array,\n index: number,\n scaleX: number,\n scaleY: number,\n scaleZ: number,\n qw: number,\n qx: number,\n qy: number,\n qz: number,\n): void {\n const qLength = Math.hypot(qw, qx, qy, qz) || 1;\n const w = qw / qLength;\n const x = qx / qLength;\n const y = qy / qLength;\n const z = qz / qLength;\n\n // M = R·S (rotation matrix from the quaternion, columns scaled).\n const m00 = (1 - 2 * (y * y + z * z)) * scaleX;\n const m01 = 2 * (x * y - w * z) * scaleY;\n const m02 = 2 * (x * z + w * y) * scaleZ;\n const m10 = 2 * (x * y + w * z) * scaleX;\n const m11 = (1 - 2 * (x * x + z * z)) * scaleY;\n const m12 = 2 * (y * z - w * x) * scaleZ;\n const m20 = 2 * (x * z - w * y) * scaleX;\n const m21 = 2 * (y * z + w * x) * scaleY;\n const m22 = (1 - 2 * (x * x + y * y)) * scaleZ;\n\n // Σ = M·Mᵀ is symmetric; store its upper triangle.\n out[index * 6 + 0] = m00 * m00 + m01 * m01 + m02 * m02;\n out[index * 6 + 1] = m00 * m10 + m01 * m11 + m02 * m12;\n out[index * 6 + 2] = m00 * m20 + m01 * m21 + m02 * m22;\n out[index * 6 + 3] = m10 * m10 + m11 * m11 + m12 * m12;\n out[index * 6 + 4] = m10 * m20 + m11 * m21 + m12 * m22;\n out[index * 6 + 5] = m20 * m20 + m21 * m21 + m22 * m22;\n}\n\n/**\n * Transforms an existing covariance Σ (upper triangle `m00,m01,m02,m11,m12,m22`)\n * by the 3×3 linear part `A` of a world matrix, writing Σ' = A·Σ·Aᵀ back as an\n * upper triangle. This is how a Gaussian's shape maps into a parent frame when a\n * splat cloud is placed with a world transform - the rotation/scale portion of\n * that transform (translation does not affect covariance) applied to the shape.\n *\n * Unlike {@link writeCovariance}, which *builds* Σ from a splat's scale and\n * rotation, this operates on an already-folded Σ. Non-uniform scale and shear in\n * `A` are handled exactly (the result is still a valid symmetric covariance).\n *\n * `a` is the linear part in **column-major** order - the first 9 elements of a\n * {@link https://threejs.org/docs/#api/en/math/Matrix4 | THREE.Matrix4}'s\n * `elements` layout for a matrix with no projective row, i.e.\n * `[a00, a10, a20, a01, a11, a21, a02, a12, a22]`. Reading `Matrix4.elements`\n * directly (ignoring the translation column and bottom row) yields exactly this.\n */\nexport function transformCovariance(\n out: Float32Array,\n outIndex: number,\n cov: Float32Array,\n covIndex: number,\n a: ArrayLike<number>,\n): void {\n // A, column-major: a[col*4 ... ] would be Matrix4; here we take the linear 3×3\n // as elements[0..2] = column 0, [3..5] would skip - but Matrix4 stores a 4×4,\n // so callers pass the compacted 9-element linear part described above.\n const a00 = a[0] as number;\n const a10 = a[1] as number;\n const a20 = a[2] as number;\n const a01 = a[3] as number;\n const a11 = a[4] as number;\n const a21 = a[5] as number;\n const a02 = a[6] as number;\n const a12 = a[7] as number;\n const a22 = a[8] as number;\n\n // Σ (symmetric) from the upper triangle.\n const s00 = cov[covIndex * 6 + 0] as number;\n const s01 = cov[covIndex * 6 + 1] as number;\n const s02 = cov[covIndex * 6 + 2] as number;\n const s11 = cov[covIndex * 6 + 3] as number;\n const s12 = cov[covIndex * 6 + 4] as number;\n const s22 = cov[covIndex * 6 + 5] as number;\n\n // T = A·Σ (3×3). Rows of A dotted with columns of the symmetric Σ.\n const t00 = a00 * s00 + a01 * s01 + a02 * s02;\n const t01 = a00 * s01 + a01 * s11 + a02 * s12;\n const t02 = a00 * s02 + a01 * s12 + a02 * s22;\n const t10 = a10 * s00 + a11 * s01 + a12 * s02;\n const t11 = a10 * s01 + a11 * s11 + a12 * s12;\n const t12 = a10 * s02 + a11 * s12 + a12 * s22;\n const t20 = a20 * s00 + a21 * s01 + a22 * s02;\n const t21 = a20 * s01 + a21 * s11 + a22 * s12;\n const t22 = a20 * s02 + a21 * s12 + a22 * s22;\n\n // Σ' = T·Aᵀ; store the upper triangle (Σ' is symmetric).\n out[outIndex * 6 + 0] = t00 * a00 + t01 * a01 + t02 * a02;\n out[outIndex * 6 + 1] = t00 * a10 + t01 * a11 + t02 * a12;\n out[outIndex * 6 + 2] = t00 * a20 + t01 * a21 + t02 * a22;\n out[outIndex * 6 + 3] = t10 * a10 + t11 * a11 + t12 * a12;\n out[outIndex * 6 + 4] = t10 * a20 + t11 * a21 + t12 * a22;\n out[outIndex * 6 + 5] = t20 * a20 + t21 * a21 + t22 * a22;\n}\n"],"names":["SH_C0","writeCovariance","out","index","scaleX","scaleY","scaleZ","qw","qx","qy","qz","qLength","w","x","y","z","m00","m01","m02","m10","m11","m12","m20","m21","m22"],"mappings":"AAiIO,MAAMA,IAAQ;AASd,SAASC,EACdC,GACAC,GACAC,GACAC,GACAC,GACAC,GACAC,GACAC,GACAC,GACM;AACN,QAAMC,IAAU,KAAK,MAAMJ,GAAIC,GAAIC,GAAIC,CAAE,KAAK,GACxCE,IAAIL,IAAKI,GACTE,IAAIL,IAAKG,GACTG,IAAIL,IAAKE,GACTI,IAAIL,IAAKC,GAGTK,KAAO,IAAI,KAAKF,IAAIA,IAAIC,IAAIA,MAAMX,GAClCa,IAAM,KAAKJ,IAAIC,IAAIF,IAAIG,KAAKV,GAC5Ba,IAAM,KAAKL,IAAIE,IAAIH,IAAIE,KAAKR,GAC5Ba,IAAM,KAAKN,IAAIC,IAAIF,IAAIG,KAAKX,GAC5BgB,KAAO,IAAI,KAAKP,IAAIA,IAAIE,IAAIA,MAAMV,GAClCgB,IAAM,KAAKP,IAAIC,IAAIH,IAAIC,KAAKP,GAC5BgB,IAAM,KAAKT,IAAIE,IAAIH,IAAIE,KAAKV,GAC5BmB,IAAM,KAAKT,IAAIC,IAAIH,IAAIC,KAAKR,GAC5BmB,KAAO,IAAI,KAAKX,IAAIA,IAAIC,IAAIA,MAAMR;AAGxC,EAAAJ,EAAIC,IAAQ,IAAI,CAAC,IAAIa,IAAMA,IAAMC,IAAMA,IAAMC,IAAMA,GACnDhB,EAAIC,IAAQ,IAAI,CAAC,IAAIa,IAAMG,IAAMF,IAAMG,IAAMF,IAAMG,GACnDnB,EAAIC,IAAQ,IAAI,CAAC,IAAIa,IAAMM,IAAML,IAAMM,IAAML,IAAMM,GACnDtB,EAAIC,IAAQ,IAAI,CAAC,IAAIgB,IAAMA,IAAMC,IAAMA,IAAMC,IAAMA,GACnDnB,EAAIC,IAAQ,IAAI,CAAC,IAAIgB,IAAMG,IAAMF,IAAMG,IAAMF,IAAMG,GACnDtB,EAAIC,IAAQ,IAAI,CAAC,IAAImB,IAAMA,IAAMC,IAAMA,IAAMC,IAAMA;AACrD;"}
@@ -4,7 +4,7 @@ var o = (g, u, e) => ft(g, typeof u != "symbol" ? u + "" : u, e);
4
4
  import { r as Y, M as gt, a as mt } from "./splat-mesh-types-BZIko-_9.js";
5
5
  import * as d from "three/webgpu";
6
6
  import { uniform as b } from "three/tsl";
7
- import { z as wt, e as xt, m as St, b as vt, D as yt, c as Rt, A as Z, B as bt, E as Tt, W as At, F as K, G as Q, H as J, I as Mt, i as It, j as kt, y as Pt, n as Ct, r as Ut, J as z, C as tt, f as Dt } from "./relighting-CSQF3UcL.js";
7
+ import { z as wt, e as xt, m as St, b as vt, D as yt, c as Rt, A as Z, B as bt, E as Tt, W as At, F as K, G as Q, H as J, I as Mt, i as It, j as kt, y as Pt, n as Ct, r as Ut, J as z, C as tt, f as Dt } from "./relighting-Tiwep8yd.js";
8
8
  import { l as et, w as O } from "./logging-BfPdd7NJ.js";
9
9
  import { d as Bt } from "./splat-budget-DuVZspPQ.js";
10
10
  import { e as G } from "./half-float-Cs4FDq8Q.js";
@@ -1892,7 +1892,7 @@ const Yt = 4, v = class v extends d.Mesh {
1892
1892
  }
1893
1893
  /** Prefetches the experimental radix sorter; safe to call repeatedly. */
1894
1894
  ensureRadixSorter() {
1895
- this.RadixSorterCtor || this.radixSorterLoad || (this.radixSorterLoad = import("./radix-sorter-R0FCSqKu.js").then((e) => {
1895
+ this.RadixSorterCtor || this.radixSorterLoad || (this.radixSorterLoad = import("./radix-sorter-BrbUg_CV.js").then((e) => {
1896
1896
  this.RadixSorterCtor = e.RadixSorter;
1897
1897
  }).finally(() => {
1898
1898
  this.radixSorterLoad = null;
@@ -1939,4 +1939,4 @@ export {
1939
1939
  Ft as c,
1940
1940
  Vt as y
1941
1941
  };
1942
- //# sourceMappingURL=splat-mesh-aQdcWA6v.js.map
1942
+ //# sourceMappingURL=splat-mesh-CsLOQb08.js.map