@babylonjs/loaders 9.19.1 → 9.20.1

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 (52) hide show
  1. package/SPLAT/gaussianSplattingStream.d.ts +170 -1
  2. package/SPLAT/gaussianSplattingStream.js +516 -40
  3. package/SPLAT/gaussianSplattingStream.js.map +1 -1
  4. package/SPLAT/gaussianSplattingWorkBuffer.d.ts +163 -4
  5. package/SPLAT/gaussianSplattingWorkBuffer.js +683 -35
  6. package/SPLAT/gaussianSplattingWorkBuffer.js.map +1 -1
  7. package/SPLAT/gaussianSplattingWorkBufferShaders.d.ts +70 -2
  8. package/SPLAT/gaussianSplattingWorkBufferShaders.js +465 -4
  9. package/SPLAT/gaussianSplattingWorkBufferShaders.js.map +1 -1
  10. package/glTF/2.0/Extensions/KHR_interactivity/declarationMapper.d.ts +16 -1
  11. package/glTF/2.0/Extensions/KHR_interactivity/declarationMapper.js +232 -25
  12. package/glTF/2.0/Extensions/KHR_interactivity/declarationMapper.js.map +1 -1
  13. package/glTF/2.0/Extensions/KHR_interactivity/interactivityGraphParser.d.ts +52 -2
  14. package/glTF/2.0/Extensions/KHR_interactivity/interactivityGraphParser.js +108 -7
  15. package/glTF/2.0/Extensions/KHR_interactivity/interactivityGraphParser.js.map +1 -1
  16. package/glTF/2.0/Extensions/KHR_interactivity/interactivityHostResolver.d.ts +37 -0
  17. package/glTF/2.0/Extensions/KHR_interactivity/interactivityHostResolver.js +69 -0
  18. package/glTF/2.0/Extensions/KHR_interactivity/interactivityHostResolver.js.map +1 -0
  19. package/glTF/2.0/Extensions/KHR_interactivity/interactivityReferences.d.ts +44 -0
  20. package/glTF/2.0/Extensions/KHR_interactivity/interactivityReferences.js +51 -0
  21. package/glTF/2.0/Extensions/KHR_interactivity/interactivityReferences.js.map +1 -0
  22. package/glTF/2.0/Extensions/KHR_interactivity.pure.d.ts +1 -0
  23. package/glTF/2.0/Extensions/KHR_interactivity.pure.js +143 -12
  24. package/glTF/2.0/Extensions/KHR_interactivity.pure.js.map +1 -1
  25. package/glTF/2.0/Extensions/KHR_node_hoverability.pure.js +6 -0
  26. package/glTF/2.0/Extensions/KHR_node_hoverability.pure.js.map +1 -1
  27. package/glTF/2.0/Extensions/KHR_node_selectability.pure.js +4 -0
  28. package/glTF/2.0/Extensions/KHR_node_selectability.pure.js.map +1 -1
  29. package/glTF/2.0/Extensions/KHR_node_visibility.pure.js +11 -0
  30. package/glTF/2.0/Extensions/KHR_node_visibility.pure.js.map +1 -1
  31. package/glTF/2.0/Extensions/babylonScenePathToObjectConverter.d.ts +126 -0
  32. package/glTF/2.0/Extensions/babylonScenePathToObjectConverter.js +251 -0
  33. package/glTF/2.0/Extensions/babylonScenePathToObjectConverter.js.map +1 -0
  34. package/glTF/2.0/Extensions/compositePathToObjectConverter.d.ts +51 -0
  35. package/glTF/2.0/Extensions/compositePathToObjectConverter.js +51 -0
  36. package/glTF/2.0/Extensions/compositePathToObjectConverter.js.map +1 -0
  37. package/glTF/2.0/Extensions/gltfPathToObjectConverter.js +69 -7
  38. package/glTF/2.0/Extensions/gltfPathToObjectConverter.js.map +1 -1
  39. package/glTF/2.0/Extensions/interactivityAssetPathToObjectConverter.d.ts +41 -0
  40. package/glTF/2.0/Extensions/interactivityAssetPathToObjectConverter.js +108 -0
  41. package/glTF/2.0/Extensions/interactivityAssetPathToObjectConverter.js.map +1 -0
  42. package/glTF/2.0/Extensions/interactivityRefPathToObjectConverter.d.ts +28 -0
  43. package/glTF/2.0/Extensions/interactivityRefPathToObjectConverter.js +71 -0
  44. package/glTF/2.0/Extensions/interactivityRefPathToObjectConverter.js.map +1 -0
  45. package/glTF/2.0/Extensions/objectModelMapping.d.ts +118 -30
  46. package/glTF/2.0/Extensions/objectModelMapping.js +472 -70
  47. package/glTF/2.0/Extensions/objectModelMapping.js.map +1 -1
  48. package/glTF/2.0/Extensions/transmissionHelper.js +4 -0
  49. package/glTF/2.0/Extensions/transmissionHelper.js.map +1 -1
  50. package/glTF/2.0/glTFLoader.pure.js +2 -1
  51. package/glTF/2.0/glTFLoader.pure.js.map +1 -1
  52. package/package.json +3 -3
@@ -1,3 +1,4 @@
1
+ import { Logger } from "@babylonjs/core/Misc/logger.js";
1
2
  import { MultiRenderTarget } from "@babylonjs/core/Materials/Textures/multiRenderTarget.js";
2
3
  import { ShaderMaterial } from "@babylonjs/core/Materials/shaderMaterial.js";
3
4
  import { Constants } from "@babylonjs/core/Engines/constants.js";
@@ -5,13 +6,13 @@ import { Mesh } from "@babylonjs/core/Meshes/mesh.js";
5
6
  import { VertexData } from "@babylonjs/core/Meshes/mesh.vertexData.js";
6
7
  import { Color4 } from "@babylonjs/core/Maths/math.color.js";
7
8
  import { Vector3, Vector4 } from "@babylonjs/core/Maths/math.vector.js";
8
- import { GaussianSplattingWorkBufferVertexShaderGLSL, GaussianSplattingWorkBufferFragmentShaderGLSL, GaussianSplattingWorkBufferVertexShaderWGSL, GaussianSplattingWorkBufferFragmentShaderWGSL, GaussianSplattingWorkBufferRelayoutFragmentShaderGLSL, GaussianSplattingWorkBufferRelayoutFragmentShaderWGSL, GaussianSplattingWorkBufferRelayoutShaderName, } from "./gaussianSplattingWorkBufferShaders.js";
9
+ import { GaussianSplattingWorkBufferVertexShaderGLSL, GaussianSplattingWorkBufferFragmentShaderGLSL, GaussianSplattingWorkBufferVertexShaderWGSL, GaussianSplattingWorkBufferFragmentShaderWGSL, GaussianSplattingWorkBufferRelayoutFragmentShaderGLSL, GaussianSplattingWorkBufferRelayoutFragmentShaderWGSL, GaussianSplattingWorkBufferRelayoutShaderName, GaussianSplattingWorkBufferShDecodeShaderName, GaussianSplattingWorkBufferShDecodeFragmentShaderGLSL, GaussianSplattingWorkBufferShDecodeFragmentShaderWGSL, GaussianSplattingWorkBufferShCopyShaderName, GaussianSplattingWorkBufferShCopyFragmentShaderGLSL, GaussianSplattingWorkBufferShCopyFragmentShaderWGSL, GaussianSplattingWorkBufferRotationDecodeShaderName, GaussianSplattingWorkBufferRotationDecodeFragmentShaderGLSL, GaussianSplattingWorkBufferRotationDecodeFragmentShaderWGSL, GaussianSplattingWorkBufferRotCopyShaderName, GaussianSplattingWorkBufferRotCopyFragmentShaderGLSL, GaussianSplattingWorkBufferRotCopyFragmentShaderWGSL, } from "./gaussianSplattingWorkBufferShaders.js";
9
10
  import { RawTexture } from "@babylonjs/core/Materials/Textures/rawTexture.js";
10
11
  /**
11
12
  * A unified, GPU-decoded Gaussian Splatting work buffer.
12
13
  *
13
14
  * Holds a square MRT texture set (centers / covA / covB / colors) sized to a fixed splat capacity
14
- * (PlayCanvas-style: `ceil(sqrt(capacity))`). Each streamed SOG file is decoded directly on the GPU
15
+ * (`ceil(sqrt(capacity))`). Each streamed SOG file is decoded directly on the GPU
15
16
  * (no CPU readback) into its allocated pixel range. The decoded textures are consumed unchanged by the
16
17
  * standard (non-SOG) Gaussian Splatting draw path.
17
18
  *
@@ -43,30 +44,271 @@ export class GaussianSplattingWorkBuffer {
43
44
  get textures() {
44
45
  return this._mrt.textures;
45
46
  }
47
+ /**
48
+ * The baked higher-order SH textures (packed-u32, one per `ceil(coeffs*3/16)`), consumed by the draw path's
49
+ * `computeSHWeighted`/`decompose` as `shTexture0..N`. Empty when SH decoding is not enabled.
50
+ */
51
+ get shTextures() {
52
+ return this._shMrts.map((m) => m.textures[0]);
53
+ }
54
+ /**
55
+ * The decoded rotation/scale textures ([rotationsA, rotationsB, rotationScale], half-float), consumed by the
56
+ * voxel-IBL path as `rotationsATexture`/`rotationsBTexture`/`rotationScaleTexture`. Empty when rotation decode
57
+ * is not enabled.
58
+ */
59
+ get rotationTextures() {
60
+ return this._rotMrt ? this._rotMrt.textures : [];
61
+ }
46
62
  /**
47
63
  * Creates a work buffer sized to hold `capacity` splats.
64
+ *
65
+ * Standalone (default): the work buffer creates and owns a square MRT sized `ceil(sqrt(capacity))`, with
66
+ * decodes addressed from splat 0.
67
+ *
68
+ * Hosted (`externalAtlas` provided): the work buffer decodes/reads back into an externally-owned MRT (a
69
+ * compound mesh's shared atlas) instead of creating its own. Decodes are placed at `externalAtlas.baseOffset`
70
+ * (the reserved region's first splat) and addressed over `externalAtlas.width` (the wide atlas width), so the
71
+ * streamed splats land in the compound's atlas and sort/draw together with the static parts.
48
72
  * @param scene hosting scene
49
73
  * @param capacity total number of splats the work buffer must address
74
+ * @param externalAtlas optional external atlas to decode into instead of creating an owned square MRT
75
+ * @param sh optional higher-order SH decode configuration. `textureCount = ceil(coeffs*3/16)` for the max SH
76
+ * degree across the streamed files. Standalone: the work buffer creates that many owned single-attachment
77
+ * integer render targets. Hosted: `externalMrts` are the compound's shared SH atlas targets (borrowed).
78
+ * @param rotationScale optional rotation/scale decode configuration (for voxel-IBL shadows). When present the
79
+ * work buffer decodes each splat's rotation matrix + scale into a 3-attachment half-float target. Standalone:
80
+ * the work buffer creates and owns that target. Hosted: `externalMrt` is the compound's shared rotation atlas.
50
81
  */
51
- constructor(scene, capacity) {
82
+ constructor(scene, capacity, externalAtlas, sh, rotationScale) {
52
83
  // Relayout (defrag) copy material, created lazily on first relayout.
53
84
  this._copyMaterial = null;
54
85
  // Reusable destination->source index map for the relayout pass (created lazily, sized to the work buffer).
55
86
  this._relayoutMapData = null;
56
87
  this._relayoutMapTexture = null;
88
+ // Transient backup of a hosted region's four textures, held between backupRegion() and restoreRegion() so the
89
+ // GPU-decoded data survives the compound recreating its atlas on a grow (adding a part / a second stream).
90
+ this._backupMrt = null;
57
91
  this._disposed = false;
58
92
  // Reused WebGL framebuffer for the async centers readback (created lazily, freed in dispose).
59
93
  this._readFbo = null;
94
+ // Higher-order spherical-harmonics. Each SH texture is its own single-attachment integer render target
95
+ // (RGBA_INTEGER/UNSIGNED_INTEGER, 16 SH scalar-bytes per texel) so a decode/relayout pass writes exactly one
96
+ // attachment, keeping within WebGPU's per-sample color-attachment byte budget. Empty when SH is off.
97
+ this._shMrts = [];
98
+ // True when this work buffer owns (and disposes) its SH render targets (standalone). False when they are the
99
+ // hosting compound's shared SH atlas (hosted) — borrowed, not disposed. Rebound alongside the core atlas.
100
+ this._ownsShMrts = false;
101
+ // The SH decode material (packed-u32 dequant), created only when SH is requested. Reused across files/passes.
102
+ this._shMaterial = null;
103
+ // Integer SH relayout/backup copy material (usampler2D -> uvec4), created lazily alongside the float copy.
104
+ this._shCopyMaterial = null;
105
+ // Transient region-sized integer backups of the SH textures, held between backupRegion() and restoreRegion()
106
+ // so the baked SH survives the compound recreating its SH atlas on a grow/compaction (parallel to _backupMrt).
107
+ this._backupShMrts = null;
108
+ // Rotation/scale. One 3-attachment half-float render target (rotA / rotB / rotScale) holding the per-splat
109
+ // rotation matrix + scale that voxel-based IBL shadowing consumes. Null when rotation decode is off.
110
+ this._rotMrt = null;
111
+ // True when this work buffer owns (and disposes) its rotation target (standalone). False when it is the hosting
112
+ // compound's shared rotation atlas (hosted) — borrowed, rebound alongside the core atlas, never disposed here.
113
+ this._ownsRotMrt = false;
114
+ // The rotation/scale decode material, created only when rotation is requested. Reused across files.
115
+ this._rotMaterial = null;
116
+ // Rotation/scale relayout/backup copy material (3-out float copy), created lazily alongside the float copy.
117
+ this._rotCopyMaterial = null;
118
+ // Transient region-sized backup of the rotation textures, held between backupRegion() and restoreRegion() so the
119
+ // decoded rotation/scale survives the compound recreating its rotation atlas on a grow/compaction.
120
+ this._backupRotMrt = null;
60
121
  this._scene = scene;
61
122
  this._shaderLanguage = scene.getEngine().isWebGPU ? 1 /* ShaderLanguage.WGSL */ : 0 /* ShaderLanguage.GLSL */;
62
- this._textureSize = Math.max(1, Math.ceil(Math.sqrt(Math.max(1, capacity))));
63
- // The decode buffer accumulates (clear disabled) so each decode preserves previously-decoded files.
64
- this._mrt = this._createMrt("gsWorkBuffer", true);
123
+ this._capacity = Math.max(1, capacity);
124
+ if (externalAtlas) {
125
+ this._mrt = externalAtlas.mrt;
126
+ this._textureSize = externalAtlas.width;
127
+ this._baseOffset = externalAtlas.baseOffset;
128
+ this._ownsMrt = false;
129
+ }
130
+ else {
131
+ this._textureSize = Math.max(1, Math.ceil(Math.sqrt(Math.max(1, capacity))));
132
+ this._baseOffset = 0;
133
+ this._ownsMrt = true;
134
+ // The decode buffer accumulates (clear disabled) so each decode preserves previously-decoded files.
135
+ this._mrt = this._createMrt("gsWorkBuffer", true);
136
+ }
137
+ // Higher-order SH: one single-attachment integer target per packed-u32 SH texture. Standalone owns square
138
+ // targets sized like the core atlas; hosted borrows the compound's wide shared SH atlas targets.
139
+ if (sh && sh.textureCount > 0) {
140
+ if (sh.externalMrts) {
141
+ this._shMrts = sh.externalMrts.slice(0, sh.textureCount);
142
+ this._ownsShMrts = false;
143
+ }
144
+ else {
145
+ for (let k = 0; k < sh.textureCount; k++) {
146
+ this._shMrts.push(this._createShMrt(`gsWorkBufferSh${k}`, true));
147
+ }
148
+ this._ownsShMrts = true;
149
+ }
150
+ this._shMaterial = this._createShMaterial();
151
+ }
152
+ // Rotation/scale: one 3-attachment half-float target. Standalone owns a square target sized like the core
153
+ // atlas; hosted borrows the compound's wide shared rotation atlas.
154
+ if (rotationScale) {
155
+ if (rotationScale.externalMrt) {
156
+ this._rotMrt = rotationScale.externalMrt;
157
+ this._ownsRotMrt = false;
158
+ }
159
+ else {
160
+ this._rotMrt = this._createRotMrt("gsWorkBufferRot", true);
161
+ this._ownsRotMrt = true;
162
+ }
163
+ this._rotMaterial = this._createRotMaterial();
164
+ }
65
165
  // One persistent decode material + fullscreen-triangle quad, reused (with per-file uniforms)
66
166
  // for every decode so the shader is compiled only once.
67
167
  this._material = this._createMaterial();
68
168
  this._quad = this._createQuad();
69
169
  this._quad.material = this._material;
170
+ // Hosted: start compiling the copy shader now so backupRegion()/restoreRegion() are ready by the time
171
+ // the compound grows its atlas (a synchronous, non-frame-driven event we can't wait on).
172
+ if (!this._ownsMrt) {
173
+ this.isRelayoutReady();
174
+ }
175
+ }
176
+ /**
177
+ * Rebinds a hosted work buffer to a new atlas MRT (after the compound recreated it on a grow). No-op for a
178
+ * standalone work buffer, which owns its MRT.
179
+ * @param mrt the compound's new shared atlas
180
+ */
181
+ rebindAtlas(mrt) {
182
+ if (!this._ownsMrt) {
183
+ this._mrt = mrt;
184
+ }
185
+ }
186
+ /**
187
+ * Rebinds a hosted work buffer to the compound's NEW shared SH atlas (after the compound recreated it on a
188
+ * grow/compaction). No-op when SH isn't in use or the work buffer owns its SH targets (standalone).
189
+ * @param shMrts the compound's new shared SH render targets (one per packed-u32 SH texture)
190
+ */
191
+ rebindShAtlas(shMrts) {
192
+ if (!this._ownsShMrts && shMrts && this._shMrts.length) {
193
+ this._shMrts = shMrts.slice(0, this._shMrts.length);
194
+ }
195
+ }
196
+ /**
197
+ * Rebinds a hosted work buffer to the compound's NEW shared rotation atlas (after the compound recreated it on a
198
+ * grow/compaction). No-op when rotation isn't in use or the work buffer owns its rotation target (standalone).
199
+ * @param rotMrt the compound's new shared rotation atlas
200
+ */
201
+ rebindRotAtlas(rotMrt) {
202
+ if (!this._ownsRotMrt && rotMrt && this._rotMrt) {
203
+ this._rotMrt = rotMrt;
204
+ }
205
+ }
206
+ /**
207
+ * Relocates this hosted region to a new base splat offset in the shared atlas (used when the compound
208
+ * compacts its atlas and this region's rows move). Subsequent decode/render/readback and
209
+ * {@link restoreRegion} all address from the new base. Call between {@link backupRegion} (which read from
210
+ * the old base) and {@link restoreRegion} (which will write to the new base). No-op for a standalone
211
+ * work buffer, which owns its square MRT at base 0.
212
+ * @param baseOffset the region's new first splat index in the atlas
213
+ */
214
+ setBaseOffset(baseOffset) {
215
+ if (!this._ownsMrt) {
216
+ this._baseOffset = baseOffset;
217
+ }
218
+ }
219
+ /**
220
+ * True once the backup/restore/relayout copy shaders are compiled, so {@link backupRegion} can preserve the
221
+ * region across an atlas rebuild. Callers that decode into a hosted region should await this before writing
222
+ * data, so a later grow/compaction can never race shader compilation and drop the region.
223
+ */
224
+ get canBackup() {
225
+ return this._disposed || this._ownsMrt ? false : this.isRelayoutReady();
226
+ }
227
+ /**
228
+ * Copies this hosted region's four decoded textures out of the shared atlas into an internal backup MRT so
229
+ * they survive the compound recreating the atlas on a grow. Call immediately before the atlas is recreated,
230
+ * then {@link rebindAtlas} + {@link restoreRegion} after.
231
+ */
232
+ backupRegion() {
233
+ if (this._disposed || this._ownsMrt) {
234
+ return;
235
+ }
236
+ if (!this.isRelayoutReady()) {
237
+ // The copy shaders should be compiled well before any grow (callers warm them before decoding), so this
238
+ // path means a grow raced compilation; warn rather than silently drop the region's GPU data on the restore.
239
+ Logger.Warn("GaussianSplattingWorkBuffer: backup skipped because the copy shaders are not ready; streamed region data may be lost on the atlas rebuild.");
240
+ return;
241
+ }
242
+ const width = this._textureSize;
243
+ const regionRows = Math.max(1, Math.floor(this._capacity / width));
244
+ const baseRow = Math.floor(this._baseOffset / width);
245
+ if (!this._backupMrt) {
246
+ this._backupMrt = this._createMrt("gsAtlasBackup", false, width, regionRows);
247
+ }
248
+ // Identity copy of the region out of the atlas: with useMap=0 and dstBaseRow=-baseRow the shader reads
249
+ // atlas texel ((p.y + baseRow) * width + p.x) — the region's global texel — into backup texel p.
250
+ this._renderRelayoutPass(this._backupMrt, this._mrt.textures, this._mrt.textures[0], 0, width, width, 0, -baseRow);
251
+ // Same for each baked SH texture (one integer copy pass per texture).
252
+ if (this._shMrts.length && this._shCopyMaterial) {
253
+ if (!this._backupShMrts) {
254
+ this._backupShMrts = this._shMrts.map((_, k) => this._createShMrt(`gsShAtlasBackup${k}`, false, width, regionRows));
255
+ }
256
+ for (let k = 0; k < this._shMrts.length; k++) {
257
+ this._renderShCopyPass(this._backupShMrts[k], this._shMrts[k].textures[0], this._mrt.textures[0], 0, width, width, 0, -baseRow);
258
+ }
259
+ }
260
+ // Same for the rotation/scale textures (one 3-out float copy pass).
261
+ if (this._rotMrt && this._rotCopyMaterial) {
262
+ if (!this._backupRotMrt) {
263
+ this._backupRotMrt = this._createRotMrt("gsRotAtlasBackup", false, width, regionRows);
264
+ }
265
+ this._renderRotCopyPass(this._backupRotMrt, this._rotMrt.textures, this._mrt.textures[0], 0, width, width, 0, -baseRow);
266
+ }
267
+ this._quad.material = this._material;
268
+ }
269
+ /**
270
+ * Restores the backed-up region into the (new) atlas, confined by a scissor to the region's rows so no other
271
+ * part is touched. Call {@link rebindAtlas} to the new atlas first. Frees the backup afterwards.
272
+ */
273
+ restoreRegion() {
274
+ if (this._disposed || this._ownsMrt || !this._backupMrt || !this._copyMaterial) {
275
+ return;
276
+ }
277
+ const width = this._textureSize;
278
+ const regionRows = Math.max(1, Math.floor(this._capacity / width));
279
+ const baseRow = Math.floor(this._baseOffset / width);
280
+ const engine = this._scene.getEngine();
281
+ engine.enableScissor(0, baseRow, width, regionRows);
282
+ try {
283
+ // useMap=0 identity with dstBaseRow=baseRow: atlas texel p reads backup texel ((p.y - baseRow)*width + p.x).
284
+ this._renderRelayoutPass(this._mrt, this._backupMrt.textures, this._backupMrt.textures[0], 0, width, width, 0, baseRow);
285
+ // Restore each baked SH texture the same way (scissored to the region's rows). The map placeholder must
286
+ // be a FLOAT texture (uMapTex is texture_2d<f32>) even though useMap=0 doesn't sample it — WebGPU still
287
+ // validates the bound texture's sample type against the layout. Use the atlas centers (F32).
288
+ if (this._backupShMrts && this._shMrts.length && this._shCopyMaterial) {
289
+ for (let k = 0; k < this._shMrts.length && k < this._backupShMrts.length; k++) {
290
+ this._renderShCopyPass(this._shMrts[k], this._backupShMrts[k].textures[0], this._mrt.textures[0], 0, width, width, 0, baseRow);
291
+ }
292
+ }
293
+ // Restore the rotation/scale textures the same way (3-out float copy, scissored to the region's rows).
294
+ if (this._backupRotMrt && this._rotMrt && this._rotCopyMaterial) {
295
+ this._renderRotCopyPass(this._rotMrt, this._backupRotMrt.textures, this._mrt.textures[0], 0, width, width, 0, baseRow);
296
+ }
297
+ }
298
+ finally {
299
+ engine.disableScissor();
300
+ this._quad.material = this._material;
301
+ }
302
+ this._backupMrt.dispose();
303
+ this._backupMrt = null;
304
+ if (this._backupShMrts) {
305
+ for (const mrt of this._backupShMrts) {
306
+ mrt.dispose();
307
+ }
308
+ this._backupShMrts = null;
309
+ }
310
+ this._backupRotMrt?.dispose();
311
+ this._backupRotMrt = null;
70
312
  }
71
313
  /**
72
314
  * Creates a 4-attachment MRT (centers F32 / covA / covB / colors U8) sized to the work buffer. covA/covB
@@ -77,11 +319,13 @@ export class GaussianSplattingWorkBuffer {
77
319
  * @param name MRT and attachment base name
78
320
  * @param disableClear when true, clearing is suppressed so renders accumulate (the decode buffer); when
79
321
  * false the MRT clears to zero on each render (the temporary relayout buffer, so gaps stay zeroed)
322
+ * @param width texture width (defaults to the work-buffer size; the region row width for a scoped relayout)
323
+ * @param height texture height (defaults to the work-buffer size; the region row count for a scoped relayout)
80
324
  * @returns the created MRT
81
325
  */
82
- _createMrt(name, disableClear) {
326
+ _createMrt(name, disableClear, width = this._textureSize, height = this._textureSize) {
83
327
  const covType = this._scene.getEngine()._caps.textureHalfFloatRender ? Constants.TEXTURETYPE_HALF_FLOAT : Constants.TEXTURETYPE_FLOAT;
84
- const mrt = new MultiRenderTarget(name, { width: this._textureSize, height: this._textureSize }, 4, this._scene, {
328
+ const mrt = new MultiRenderTarget(name, { width, height }, 4, this._scene, {
85
329
  types: [Constants.TEXTURETYPE_FLOAT, covType, covType, Constants.TEXTURETYPE_UNSIGNED_BYTE],
86
330
  samplingModes: [
87
331
  Constants.TEXTURE_NEAREST_SAMPLINGMODE,
@@ -101,6 +345,59 @@ export class GaussianSplattingWorkBuffer {
101
345
  }
102
346
  return mrt;
103
347
  }
348
+ /**
349
+ * Creates a single-attachment integer render target (RGBA_INTEGER / UNSIGNED_INTEGER) that holds one packed-u32
350
+ * baked-SH texture. One attachment per pass keeps within WebGPU's per-sample color-attachment byte budget and
351
+ * matches the format/type of the draw path's `shTexture0..N` samplers (`_GaussianSplattingBytesPerShTexel`).
352
+ * @param name attachment name
353
+ * @param disableClear when true, clearing is suppressed so SH decodes accumulate across files
354
+ * @param width texture width (defaults to the work-buffer size)
355
+ * @param height texture height (defaults to the work-buffer size)
356
+ * @returns the created single-attachment integer MRT
357
+ */
358
+ _createShMrt(name, disableClear, width = this._textureSize, height = this._textureSize) {
359
+ const mrt = new MultiRenderTarget(name, { width, height }, 1, this._scene, {
360
+ types: [Constants.TEXTURETYPE_UNSIGNED_INTEGER],
361
+ formats: [Constants.TEXTUREFORMAT_RGBA_INTEGER],
362
+ samplingModes: [Constants.TEXTURE_NEAREST_SAMPLINGMODE],
363
+ generateDepthBuffer: false,
364
+ generateDepthTexture: false,
365
+ generateMipMaps: false,
366
+ }, [name]);
367
+ mrt.clearColor = new Color4(0, 0, 0, 0);
368
+ mrt.renderList = [];
369
+ if (disableClear) {
370
+ mrt.onClearObservable.add(() => { });
371
+ }
372
+ return mrt;
373
+ }
374
+ /**
375
+ * Creates a 3-attachment half-float MRT ([rotA, rotB, rotScale]) holding the per-splat rotation matrix + scale
376
+ * consumed by voxel-IBL shadowing. RGBA half-float when the engine can render to it (matching the covariance
377
+ * precision), else full float. 3 attachments = 24 B/sample, within WebGPU's per-sample budget (its own pass).
378
+ * @param name MRT and attachment base name
379
+ * @param disableClear when true, clearing is suppressed so decodes accumulate (the decode buffer)
380
+ * @param width texture width (defaults to the work-buffer size; the region row width for a scoped relayout)
381
+ * @param height texture height (defaults to the work-buffer size; the region row count for a scoped relayout)
382
+ * @returns the created MRT
383
+ */
384
+ _createRotMrt(name, disableClear, width = this._textureSize, height = this._textureSize) {
385
+ const rotType = this._scene.getEngine()._caps.textureHalfFloatRender ? Constants.TEXTURETYPE_HALF_FLOAT : Constants.TEXTURETYPE_FLOAT;
386
+ const mrt = new MultiRenderTarget(name, { width, height }, 3, this._scene, {
387
+ types: [rotType, rotType, rotType],
388
+ formats: [Constants.TEXTUREFORMAT_RGBA, Constants.TEXTUREFORMAT_RGBA, Constants.TEXTUREFORMAT_RGBA],
389
+ samplingModes: [Constants.TEXTURE_NEAREST_SAMPLINGMODE, Constants.TEXTURE_NEAREST_SAMPLINGMODE, Constants.TEXTURE_NEAREST_SAMPLINGMODE],
390
+ generateDepthBuffer: false,
391
+ generateDepthTexture: false,
392
+ generateMipMaps: false,
393
+ }, [`${name}A`, `${name}B`, `${name}Scale`]);
394
+ mrt.clearColor = new Color4(0, 0, 0, 0);
395
+ mrt.renderList = [];
396
+ if (disableClear) {
397
+ mrt.onClearObservable.add(() => { });
398
+ }
399
+ return mrt;
400
+ }
104
401
  /**
105
402
  * Decodes one SOG file into the work buffer at the given splat offset (accumulating; previously
106
403
  * decoded files are preserved). Resolves once the GPU decode has been issued. The caller may
@@ -112,7 +409,17 @@ export class GaussianSplattingWorkBuffer {
112
409
  if (this._disposed) {
113
410
  return;
114
411
  }
115
- this._applyPack(pack, offset);
412
+ this._applyPack(pack);
413
+ // When SH is enabled, bake EVERY file's region — even a file with no higher-order SH (a coarse LOD may drop
414
+ // it): those get uCoeffs=0 so the whole region neutral-fills (128), keeping mixed-degree files consistent.
415
+ const decodeSh = this._shMaterial !== null && this._shMrts.length > 0;
416
+ if (decodeSh) {
417
+ this._applyShPack(pack);
418
+ }
419
+ const decodeRot = this._rotMaterial !== null && this._rotMrt !== null;
420
+ if (decodeRot) {
421
+ this._applyRotPack(pack);
422
+ }
116
423
  // Render the decode pass at the start of a frame (the safe point for custom render targets),
117
424
  // once the shader is compiled — never re-entrantly from a promise/observable continuation.
118
425
  await new Promise((resolve) => {
@@ -121,12 +428,54 @@ export class GaussianSplattingWorkBuffer {
121
428
  resolve();
122
429
  return;
123
430
  }
124
- if (!this._material.isReady(this._quad)) {
431
+ if (!this._material.isReady(this._quad) || (decodeSh && !this._shMaterial.isReady(this._quad)) || (decodeRot && !this._rotMaterial.isReady(this._quad))) {
125
432
  this._scene.onBeforeRenderObservable.addOnce(attempt);
126
433
  return;
127
434
  }
128
- this._mrt.renderList = [this._quad];
129
- this._mrt.render();
435
+ // Scissor every pass to just this file's atlas rows so the fullscreen triangle only shades the region
436
+ // it writes, not the whole (wide) atlas. Correct because the shaders already discard outside the exact
437
+ // splat range, and the row band is a superset of it.
438
+ const width = this._textureSize;
439
+ // Bind uOffset at render time from the live _baseOffset so it shares one value with the scissor below:
440
+ // compactAtlas() can relocate this region (via setBaseOffset) during the defer window.
441
+ const globalOffset = this._baseOffset + offset;
442
+ this._material.setInt("uOffset", globalOffset);
443
+ if (decodeSh) {
444
+ this._shMaterial.setInt("uOffset", globalOffset);
445
+ }
446
+ if (decodeRot) {
447
+ this._rotMaterial.setInt("uOffset", globalOffset);
448
+ }
449
+ const firstRow = Math.floor(globalOffset / width);
450
+ const rowCount = Math.max(1, Math.ceil((globalOffset + pack.splatCount) / width) - firstRow);
451
+ const engine = this._scene.getEngine();
452
+ engine.enableScissor(0, firstRow, width, rowCount);
453
+ try {
454
+ this._quad.material = this._material;
455
+ this._mrt.renderList = [this._quad];
456
+ this._mrt.render();
457
+ // Bake this file's higher-order SH: one pass per packed-u32 SH texture (uShTextureIndex selects the
458
+ // 16 SH scalars written this pass). Files with fewer coefficients neutral-fill (128) higher bands.
459
+ if (decodeSh) {
460
+ for (let k = 0; k < this._shMrts.length; k++) {
461
+ this._shMaterial.setInt("uShTextureIndex", k);
462
+ this._quad.material = this._shMaterial;
463
+ this._shMrts[k].renderList = [this._quad];
464
+ this._shMrts[k].render();
465
+ }
466
+ this._quad.material = this._material;
467
+ }
468
+ // Bake this file's rotation/scale into the 3-attachment half-float rotation target (one pass).
469
+ if (decodeRot) {
470
+ this._quad.material = this._rotMaterial;
471
+ this._rotMrt.renderList = [this._quad];
472
+ this._rotMrt.render();
473
+ this._quad.material = this._material;
474
+ }
475
+ }
476
+ finally {
477
+ engine.disableScissor();
478
+ }
130
479
  resolve();
131
480
  };
132
481
  this._scene.onBeforeRenderObservable.addOnce(attempt);
@@ -144,7 +493,46 @@ export class GaussianSplattingWorkBuffer {
144
493
  if (!this._copyMaterial) {
145
494
  this._copyMaterial = this._createCopyMaterial();
146
495
  }
147
- return this._copyMaterial.isReady(this._quad);
496
+ // When SH is in use, the integer copy shader must also be compiled before a backup/restore/relayout can run.
497
+ if (this._shMrts.length && !this._shCopyMaterial) {
498
+ this._shCopyMaterial = this._createShCopyMaterial();
499
+ }
500
+ // Same for the rotation/scale 3-out float copy shader when rotation is in use.
501
+ if (this._rotMrt && !this._rotCopyMaterial) {
502
+ this._rotCopyMaterial = this._createRotCopyMaterial();
503
+ }
504
+ // `isReady` reports "not ready" when a bound sampler points at a disposed texture (a previous backup/relayout
505
+ // binds its temp MRTs as sources, then frees them). The copy passes bind fresh textures every call, so re-point
506
+ // the materials at the live atlas textures first; the check then reflects only whether the shader compiled.
507
+ this._bindCopyMaterialsToAtlas();
508
+ const shReady = this._shMrts.length === 0 || (this._shCopyMaterial !== null && this._shCopyMaterial.isReady(this._quad));
509
+ const rotReady = !this._rotMrt || (this._rotCopyMaterial !== null && this._rotCopyMaterial.isReady(this._quad));
510
+ return this._copyMaterial.isReady(this._quad) && shReady && rotReady;
511
+ }
512
+ /**
513
+ * Re-points the relayout/backup copy materials' samplers at the live atlas textures (valid, never freed), so a
514
+ * later {@link isRelayoutReady} check isn't tripped by a stale binding to a disposed temp/backup MRT.
515
+ */
516
+ _bindCopyMaterialsToAtlas() {
517
+ const t = this._mrt.textures;
518
+ if (this._copyMaterial) {
519
+ this._copyMaterial.setTexture("uMapTex", t[0]);
520
+ this._copyMaterial.setTexture("uSrc0", t[0]);
521
+ this._copyMaterial.setTexture("uSrc1", t[1]);
522
+ this._copyMaterial.setTexture("uSrc2", t[2]);
523
+ this._copyMaterial.setTexture("uSrc3", t[3]);
524
+ }
525
+ if (this._shCopyMaterial && this._shMrts.length) {
526
+ this._shCopyMaterial.setTexture("uMapTex", t[0]);
527
+ this._shCopyMaterial.setTexture("uSrcSh", this._shMrts[0].textures[0]);
528
+ }
529
+ if (this._rotCopyMaterial && this._rotMrt) {
530
+ const r = this._rotMrt.textures;
531
+ this._rotCopyMaterial.setTexture("uMapTex", t[0]);
532
+ this._rotCopyMaterial.setTexture("uSrc0", r[0]);
533
+ this._rotCopyMaterial.setTexture("uSrc1", r[1]);
534
+ this._rotCopyMaterial.setTexture("uSrc2", r[2]);
535
+ }
148
536
  }
149
537
  /**
150
538
  * Relayouts the decoded work-buffer textures to a new (defragmented) splat layout, keeping the same
@@ -159,32 +547,104 @@ export class GaussianSplattingWorkBuffer {
159
547
  if (this._disposed || !this._copyMaterial) {
160
548
  return;
161
549
  }
162
- const size = this._textureSize;
163
- // Reuse the map buffer and its GPU texture across relayouts (the work-buffer size is fixed).
550
+ // Map dimensions: standalone maps the whole square buffer; hosted maps just the region's rows.
551
+ const width = this._textureSize;
552
+ const mapW = width;
553
+ const mapH = this._ownsMrt ? width : Math.max(1, Math.floor(this._capacity / width));
554
+ // Reuse the map buffer + its GPU texture across relayouts (dimensions are fixed for a work buffer).
164
555
  if (!this._relayoutMapData) {
165
- this._relayoutMapData = new Float32Array(size * size);
556
+ this._relayoutMapData = new Float32Array(mapW * mapH);
166
557
  }
167
558
  const mapData = this._relayoutMapData;
168
559
  mapData.fill(-1);
169
560
  mapData.set(srcIndexByDst.subarray(0, Math.min(srcIndexByDst.length, mapData.length)));
170
561
  if (!this._relayoutMapTexture) {
171
- this._relayoutMapTexture = new RawTexture(mapData, size, size, Constants.TEXTUREFORMAT_R, this._scene, false, false, Constants.TEXTURE_NEAREST_SAMPLINGMODE, Constants.TEXTURETYPE_FLOAT);
562
+ this._relayoutMapTexture = new RawTexture(mapData, mapW, mapH, Constants.TEXTUREFORMAT_R, this._scene, false, false, Constants.TEXTURE_NEAREST_SAMPLINGMODE, Constants.TEXTURETYPE_FLOAT);
172
563
  }
173
564
  else {
174
565
  this._relayoutMapTexture.update(mapData);
175
566
  }
176
567
  const mapTexture = this._relayoutMapTexture;
177
- // The temporary ping-pong MRT is (re)created per relayout rather than kept resident: it is the same size
178
- // as the work buffer, so a persistent copy would double GPU memory and defeat the streaming memory budget.
179
- const temp = this._createMrt("gsRelayoutTemp", false);
568
+ if (this._ownsMrt) {
569
+ // Standalone: the work buffer owns the whole square texture, so a full ping-pong is safe.
570
+ const temp = this._createMrt("gsRelayoutTemp", false);
571
+ try {
572
+ this._renderRelayoutPass(temp, this._mrt.textures, mapTexture, 1); // old -> temp via map (gaps cleared)
573
+ this._renderRelayoutPass(this._mrt, temp.textures, mapTexture, 0); // temp -> old, identity full overwrite
574
+ }
575
+ finally {
576
+ temp.dispose();
577
+ }
578
+ // Same ping-pong for each baked SH texture (integer copy, one temp per texture).
579
+ if (this._shMrts.length && this._shCopyMaterial) {
580
+ for (let k = 0; k < this._shMrts.length; k++) {
581
+ const shTemp = this._createShMrt("gsShRelayoutTemp", false);
582
+ try {
583
+ this._renderShCopyPass(shTemp, this._shMrts[k].textures[0], mapTexture, 1);
584
+ this._renderShCopyPass(this._shMrts[k], shTemp.textures[0], mapTexture, 0);
585
+ }
586
+ finally {
587
+ shTemp.dispose();
588
+ }
589
+ }
590
+ }
591
+ // Same ping-pong for the rotation/scale textures (3-out float copy).
592
+ if (this._rotMrt && this._rotCopyMaterial) {
593
+ const rotTemp = this._createRotMrt("gsRotRelayoutTemp", false);
594
+ try {
595
+ this._renderRotCopyPass(rotTemp, this._rotMrt.textures, mapTexture, 1);
596
+ this._renderRotCopyPass(this._rotMrt, rotTemp.textures, mapTexture, 0);
597
+ }
598
+ finally {
599
+ rotTemp.dispose();
600
+ }
601
+ }
602
+ this._quad.material = this._material;
603
+ return;
604
+ }
605
+ // Hosted (shared compound atlas): scope the ping-pong to THIS region's row band so other parts are never
606
+ // touched. `_baseOffset` and `_capacity` are row-aligned (reserveStreamingPart), so the band is exact.
607
+ const baseRow = Math.floor(this._baseOffset / width);
608
+ const regionRows = mapH;
609
+ const engine = this._scene.getEngine();
610
+ // Region-sized temp (width x regionRows) — memory stays proportional to the region, not the whole atlas.
611
+ const temp = this._createMrt("gsRelayoutTemp", false, width, regionRows);
612
+ // One region-sized integer temp per baked SH texture (same ping-pong, integer format).
613
+ const shTemps = this._shMrts.length && this._shCopyMaterial ? this._shMrts.map((_, k) => this._createShMrt(`gsShRelayoutTemp${k}`, false, width, regionRows)) : [];
614
+ // One region-sized rotation temp (3-attachment float) when rotation is in use.
615
+ const rotTemp = this._rotMrt && this._rotCopyMaterial ? this._createRotMrt("gsRotRelayoutTemp", false, width, regionRows) : null;
180
616
  try {
181
- // Pass 1: old -> temp using the map (temp gets the new layout; gaps stay zeroed by the clear).
182
- this._renderRelayoutPass(temp, this._mrt.textures, mapTexture, 1);
183
- // Pass 2: temp -> old, identity (old textures get the new layout, full overwrite).
184
- this._renderRelayoutPass(this._mrt, temp.textures, mapTexture, 0);
617
+ // Pass 1: atlas region -> temp via map. The map is region-local; uSrcBaseOffset shifts each source
618
+ // index to its GLOBAL atlas texel (uSrcWidth = atlas width). Temp is exactly the band, so no scissor.
619
+ this._renderRelayoutPass(temp, this._mrt.textures, mapTexture, 1, /*dstWidth*/ width, /*srcWidth*/ width, /*srcBaseOffset*/ this._baseOffset, /*dstBaseRow*/ 0);
620
+ for (let k = 0; k < shTemps.length; k++) {
621
+ this._renderShCopyPass(shTemps[k], this._shMrts[k].textures[0], mapTexture, 1, width, width, this._baseOffset, 0);
622
+ }
623
+ if (rotTemp) {
624
+ this._renderRotCopyPass(rotTemp, this._rotMrt.textures, mapTexture, 1, width, width, this._baseOffset, 0);
625
+ }
626
+ // Pass 2: temp -> atlas region, identity within the band. uDstBaseRow maps the atlas destination row
627
+ // back into the region-local temp; the scissor confines writes to the band so static parts are safe.
628
+ engine.enableScissor(0, baseRow, width, regionRows);
629
+ try {
630
+ this._renderRelayoutPass(this._mrt, temp.textures, mapTexture, 0, /*dstWidth*/ width, /*srcWidth*/ width, /*srcBaseOffset*/ 0, /*dstBaseRow*/ baseRow);
631
+ for (let k = 0; k < shTemps.length; k++) {
632
+ this._renderShCopyPass(this._shMrts[k], shTemps[k].textures[0], mapTexture, 0, width, width, 0, baseRow);
633
+ }
634
+ if (rotTemp) {
635
+ this._renderRotCopyPass(this._rotMrt, rotTemp.textures, mapTexture, 0, width, width, 0, baseRow);
636
+ }
637
+ }
638
+ finally {
639
+ engine.disableScissor();
640
+ }
185
641
  }
186
642
  finally {
187
643
  temp.dispose();
644
+ for (const t of shTemps) {
645
+ t.dispose();
646
+ }
647
+ rotTemp?.dispose();
188
648
  this._quad.material = this._material;
189
649
  }
190
650
  }
@@ -192,19 +652,25 @@ export class GaussianSplattingWorkBuffer {
192
652
  * Renders one relayout copy pass into the target MRT, sampling the given source textures.
193
653
  * @param target destination MRT
194
654
  * @param sources the four source work-buffer textures
195
- * @param mapTexture the R32F destination-to-source index map
655
+ * @param mapTexture the R32F destination-to-source index map (only sampled when `useMap` is 1; any bound texture otherwise)
196
656
  * @param useMap 1 to read source indices from the map (gaps discarded), 0 for an identity copy
657
+ * @param dstWidth destination width used to linearize the destination texel (defaults to the work-buffer size)
658
+ * @param srcWidth source width used to convert a linear source index to a texel (defaults to the work-buffer size)
659
+ * @param srcBaseOffset added to each mapped source index so a region-local map reads the correct global atlas texel (hosted relayout)
660
+ * @param dstBaseRow subtracted from the destination row so an identity copy reads the region-local temp (hosted relayout)
197
661
  */
198
- _renderRelayoutPass(target, sources, mapTexture, useMap) {
662
+ _renderRelayoutPass(target, sources, mapTexture, useMap, dstWidth = this._textureSize, srcWidth = this._textureSize, srcBaseOffset = 0, dstBaseRow = 0) {
199
663
  const material = this._copyMaterial;
200
664
  material.setTexture("uMapTex", mapTexture);
201
665
  material.setTexture("uSrc0", sources[0]);
202
666
  material.setTexture("uSrc1", sources[1]);
203
667
  material.setTexture("uSrc2", sources[2]);
204
668
  material.setTexture("uSrc3", sources[3]);
205
- material.setInt("uDstWidth", this._textureSize);
206
- material.setInt("uSrcWidth", this._textureSize);
669
+ material.setInt("uDstWidth", dstWidth);
670
+ material.setInt("uSrcWidth", srcWidth);
207
671
  material.setInt("uUseMap", useMap);
672
+ material.setInt("uSrcBaseOffset", srcBaseOffset);
673
+ material.setInt("uDstBaseRow", dstBaseRow);
208
674
  this._quad.material = material;
209
675
  target.renderList = [this._quad];
210
676
  target.render();
@@ -216,7 +682,7 @@ export class GaussianSplattingWorkBuffer {
216
682
  fragmentSource: isWGSL ? GaussianSplattingWorkBufferRelayoutFragmentShaderWGSL : GaussianSplattingWorkBufferRelayoutFragmentShaderGLSL,
217
683
  }, {
218
684
  attributes: ["position"],
219
- uniforms: ["uDstWidth", "uSrcWidth", "uUseMap"],
685
+ uniforms: ["uDstWidth", "uSrcWidth", "uUseMap", "uSrcBaseOffset", "uDstBaseRow"],
220
686
  samplers: ["uMapTex", "uSrc0", "uSrc1", "uSrc2", "uSrc3"],
221
687
  shaderLanguage: this._shaderLanguage,
222
688
  });
@@ -224,6 +690,88 @@ export class GaussianSplattingWorkBuffer {
224
690
  material.disableDepthWrite = true;
225
691
  return material;
226
692
  }
693
+ /**
694
+ * Renders one INTEGER SH copy pass (one packed-u32 SH texture) into the target, sampling one integer source.
695
+ * Same index/map/base math as {@link _renderRelayoutPass} but for the integer SH format.
696
+ * @param target destination single-attachment integer MRT
697
+ * @param srcSh the integer SH source texture
698
+ * @param mapTexture the R32F destination-to-source index map (sampled only when `useMap` is 1)
699
+ * @param useMap 1 to read source indices from the map (gaps discarded), 0 for an identity copy
700
+ * @param dstWidth destination width used to linearize the destination texel
701
+ * @param srcWidth source width used to convert a linear source index to a texel
702
+ * @param srcBaseOffset added to each mapped source index (region-local map -> global atlas texel)
703
+ * @param dstBaseRow subtracted from the destination row for an identity copy of a region-local temp
704
+ */
705
+ _renderShCopyPass(target, srcSh, mapTexture, useMap, dstWidth = this._textureSize, srcWidth = this._textureSize, srcBaseOffset = 0, dstBaseRow = 0) {
706
+ const material = this._shCopyMaterial;
707
+ material.setTexture("uMapTex", mapTexture);
708
+ material.setTexture("uSrcSh", srcSh);
709
+ material.setInt("uDstWidth", dstWidth);
710
+ material.setInt("uSrcWidth", srcWidth);
711
+ material.setInt("uUseMap", useMap);
712
+ material.setInt("uSrcBaseOffset", srcBaseOffset);
713
+ material.setInt("uDstBaseRow", dstBaseRow);
714
+ this._quad.material = material;
715
+ target.renderList = [this._quad];
716
+ target.render();
717
+ }
718
+ _createShCopyMaterial() {
719
+ const isWGSL = this._shaderLanguage === 1 /* ShaderLanguage.WGSL */;
720
+ const material = new ShaderMaterial(GaussianSplattingWorkBufferShCopyShaderName, this._scene, {
721
+ vertexSource: isWGSL ? GaussianSplattingWorkBufferVertexShaderWGSL : GaussianSplattingWorkBufferVertexShaderGLSL,
722
+ fragmentSource: isWGSL ? GaussianSplattingWorkBufferShCopyFragmentShaderWGSL : GaussianSplattingWorkBufferShCopyFragmentShaderGLSL,
723
+ }, {
724
+ attributes: ["position"],
725
+ uniforms: ["uDstWidth", "uSrcWidth", "uUseMap", "uSrcBaseOffset", "uDstBaseRow"],
726
+ samplers: ["uMapTex", "uSrcSh"],
727
+ shaderLanguage: this._shaderLanguage,
728
+ });
729
+ material.backFaceCulling = false;
730
+ material.disableDepthWrite = true;
731
+ return material;
732
+ }
733
+ /**
734
+ * Renders one rotation/scale copy pass (the three half-float rotation textures) into the target. Same
735
+ * index/map/base math as {@link _renderRelayoutPass} but with three attachments.
736
+ * @param target destination 3-attachment MRT
737
+ * @param sources the three source rotation textures ([rotA, rotB, rotScale])
738
+ * @param mapTexture the R32F destination-to-source index map (sampled only when `useMap` is 1)
739
+ * @param useMap 1 to read source indices from the map (gaps discarded), 0 for an identity copy
740
+ * @param dstWidth destination width used to linearize the destination texel
741
+ * @param srcWidth source width used to convert a linear source index to a texel
742
+ * @param srcBaseOffset added to each mapped source index (region-local map -> global atlas texel)
743
+ * @param dstBaseRow subtracted from the destination row for an identity copy of a region-local temp
744
+ */
745
+ _renderRotCopyPass(target, sources, mapTexture, useMap, dstWidth = this._textureSize, srcWidth = this._textureSize, srcBaseOffset = 0, dstBaseRow = 0) {
746
+ const material = this._rotCopyMaterial;
747
+ material.setTexture("uMapTex", mapTexture);
748
+ material.setTexture("uSrc0", sources[0]);
749
+ material.setTexture("uSrc1", sources[1]);
750
+ material.setTexture("uSrc2", sources[2]);
751
+ material.setInt("uDstWidth", dstWidth);
752
+ material.setInt("uSrcWidth", srcWidth);
753
+ material.setInt("uUseMap", useMap);
754
+ material.setInt("uSrcBaseOffset", srcBaseOffset);
755
+ material.setInt("uDstBaseRow", dstBaseRow);
756
+ this._quad.material = material;
757
+ target.renderList = [this._quad];
758
+ target.render();
759
+ }
760
+ _createRotCopyMaterial() {
761
+ const isWGSL = this._shaderLanguage === 1 /* ShaderLanguage.WGSL */;
762
+ const material = new ShaderMaterial(GaussianSplattingWorkBufferRotCopyShaderName, this._scene, {
763
+ vertexSource: isWGSL ? GaussianSplattingWorkBufferVertexShaderWGSL : GaussianSplattingWorkBufferVertexShaderGLSL,
764
+ fragmentSource: isWGSL ? GaussianSplattingWorkBufferRotCopyFragmentShaderWGSL : GaussianSplattingWorkBufferRotCopyFragmentShaderGLSL,
765
+ }, {
766
+ attributes: ["position"],
767
+ uniforms: ["uDstWidth", "uSrcWidth", "uUseMap", "uSrcBaseOffset", "uDstBaseRow"],
768
+ samplers: ["uMapTex", "uSrc0", "uSrc1", "uSrc2"],
769
+ shaderLanguage: this._shaderLanguage,
770
+ });
771
+ material.backFaceCulling = false;
772
+ material.disableDepthWrite = true;
773
+ return material;
774
+ }
227
775
  /**
228
776
  * Asynchronously reads back the decoded splat centers (stride-4 xyzw, w=1) for a contiguous splat range
229
777
  * from the work buffer's centers texture, using a non-blocking GPU readback (WebGL2 PBO + fence, or WebGPU
@@ -240,14 +788,17 @@ export class GaussianSplattingWorkBuffer {
240
788
  return null;
241
789
  }
242
790
  const width = this._textureSize;
791
+ // Shift the region-local offset by the atlas base so the readback targets the same global texels the
792
+ // decode wrote (0 base for a standalone work buffer).
793
+ const globalOffset = this._baseOffset + splatOffset;
243
794
  // The range maps to whole texel rows [rowStart, rowEnd); read that rectangle and slice the exact range.
244
795
  // Splat i lives at texel (i % width, floor(i / width)) in both decode and draw, so the readback (which
245
796
  // indexes the same texture storage directly, with no UV/flip) yields splat i at buffer position
246
797
  // i - rowStart * width on every backend.
247
- const rowStart = Math.floor(splatOffset / width);
248
- const rowEnd = Math.ceil((splatOffset + splatCount) / width);
798
+ const rowStart = Math.floor(globalOffset / width);
799
+ const rowEnd = Math.ceil((globalOffset + splatCount) / width);
249
800
  const rowCount = rowEnd - rowStart;
250
- const startInBuffer = (splatOffset - rowStart * width) * 4;
801
+ const startInBuffer = (globalOffset - rowStart * width) * 4;
251
802
  const sliceEnd = startInBuffer + splatCount * 4;
252
803
  const centers = this._mrt.textures[0];
253
804
  const engine = this._scene.getEngine();
@@ -306,9 +857,38 @@ export class GaussianSplattingWorkBuffer {
306
857
  }
307
858
  this._quad.dispose();
308
859
  this._material.dispose(true, false);
860
+ this._shMaterial?.dispose(true, false);
861
+ this._rotMaterial?.dispose(true, false);
309
862
  this._copyMaterial?.dispose(true, false);
863
+ this._shCopyMaterial?.dispose(true, false);
864
+ this._rotCopyMaterial?.dispose(true, false);
310
865
  this._relayoutMapTexture?.dispose();
311
- this._mrt.dispose();
866
+ this._backupMrt?.dispose();
867
+ this._backupMrt = null;
868
+ if (this._backupShMrts) {
869
+ for (const mrt of this._backupShMrts) {
870
+ mrt.dispose();
871
+ }
872
+ this._backupShMrts = null;
873
+ }
874
+ this._backupRotMrt?.dispose();
875
+ this._backupRotMrt = null;
876
+ // Only dispose the MRT when we own it; an external atlas belongs to the hosting compound mesh.
877
+ if (this._ownsMrt) {
878
+ this._mrt.dispose();
879
+ }
880
+ // Same ownership rule for the SH targets (standalone owns; hosted borrows the compound's shared SH atlas).
881
+ if (this._ownsShMrts) {
882
+ for (const mrt of this._shMrts) {
883
+ mrt.dispose();
884
+ }
885
+ }
886
+ this._shMrts = [];
887
+ // Same ownership rule for the rotation target.
888
+ if (this._ownsRotMrt) {
889
+ this._rotMrt?.dispose();
890
+ }
891
+ this._rotMrt = null;
312
892
  }
313
893
  _createQuad() {
314
894
  const quad = new Mesh("gsWorkBufferQuad", this._scene);
@@ -336,7 +916,7 @@ export class GaussianSplattingWorkBuffer {
336
916
  material.disableDepthWrite = true;
337
917
  return material;
338
918
  }
339
- _applyPack(pack, offset) {
919
+ _applyPack(pack) {
340
920
  const material = this._material;
341
921
  const srcWidth = pack.meansTextureL.getSize().width;
342
922
  material.setTexture("sogMeansLTex", pack.meansTextureL);
@@ -357,7 +937,75 @@ export class GaussianSplattingWorkBuffer {
357
937
  material.setVector4("sogSh0Min", new Vector4(c0Min[0], c0Min[1], c0Min[2], c0Min[3]));
358
938
  material.setVector4("sogSh0Max", new Vector4(c0Max[0], c0Max[1], c0Max[2], c0Max[3]));
359
939
  material.setInt("uVersion", pack.version);
360
- material.setInt("uOffset", offset);
940
+ // uOffset is bound at render time in decodeAsync (from the live _baseOffset).
941
+ material.setInt("uCount", pack.splatCount);
942
+ material.setInt("uDestWidth", this._textureSize);
943
+ material.setInt("uSrcWidth", srcWidth);
944
+ }
945
+ _createShMaterial() {
946
+ const isWGSL = this._shaderLanguage === 1 /* ShaderLanguage.WGSL */;
947
+ const material = new ShaderMaterial(GaussianSplattingWorkBufferShDecodeShaderName, this._scene, {
948
+ vertexSource: isWGSL ? GaussianSplattingWorkBufferVertexShaderWGSL : GaussianSplattingWorkBufferVertexShaderGLSL,
949
+ fragmentSource: isWGSL ? GaussianSplattingWorkBufferShDecodeFragmentShaderWGSL : GaussianSplattingWorkBufferShDecodeFragmentShaderGLSL,
950
+ }, {
951
+ attributes: ["position"],
952
+ uniforms: ["sogShnMin", "sogShnMax", "uVersion", "uOffset", "uCount", "uDestWidth", "uSrcWidth", "uCoeffs", "uShTextureIndex"],
953
+ samplers: ["sogShLabelsTex", "sogShCentroidsTex", "sogCodebookTex"],
954
+ shaderLanguage: this._shaderLanguage,
955
+ });
956
+ material.backFaceCulling = false;
957
+ material.disableDepthWrite = true;
958
+ return material;
959
+ }
960
+ _applyShPack(pack) {
961
+ const material = this._shMaterial;
962
+ // A file may carry no higher-order SH (coarse LOD): fall back to sh0Texture as a harmless placeholder for the
963
+ // label/centroid samplers and set uCoeffs=0 so the shader neutral-fills every coefficient (128 -> 0 lighting).
964
+ const hasSh = !!pack.shLabelsTexture && !!pack.shCentroidsTexture;
965
+ const labels = pack.shLabelsTexture ?? pack.sh0Texture;
966
+ const centroids = pack.shCentroidsTexture ?? pack.sh0Texture;
967
+ material.setTexture("sogShLabelsTex", labels);
968
+ material.setTexture("sogShCentroidsTex", centroids);
969
+ // Codebook only used for v2; bind a harmless placeholder otherwise so the sampler is always set.
970
+ material.setTexture("sogCodebookTex", pack.codebookTexture ?? labels);
971
+ material.setFloat("sogShnMin", pack.shnMin ?? 0);
972
+ material.setFloat("sogShnMax", pack.shnMax ?? 0);
973
+ material.setInt("uVersion", pack.version);
974
+ // uOffset is bound at render time in decodeAsync (from the live _baseOffset).
975
+ material.setInt("uCount", pack.splatCount);
976
+ material.setInt("uDestWidth", this._textureSize);
977
+ material.setInt("uSrcWidth", labels.getSize().width);
978
+ // Higher-order coefficient count for THIS file (bands=3 -> 15). 0 when the file has no SH -> full neutral fill.
979
+ material.setInt("uCoeffs", hasSh ? pack.shCoeffCount : 0);
980
+ }
981
+ _createRotMaterial() {
982
+ const isWGSL = this._shaderLanguage === 1 /* ShaderLanguage.WGSL */;
983
+ const material = new ShaderMaterial(GaussianSplattingWorkBufferRotationDecodeShaderName, this._scene, {
984
+ vertexSource: isWGSL ? GaussianSplattingWorkBufferVertexShaderWGSL : GaussianSplattingWorkBufferVertexShaderGLSL,
985
+ fragmentSource: isWGSL ? GaussianSplattingWorkBufferRotationDecodeFragmentShaderWGSL : GaussianSplattingWorkBufferRotationDecodeFragmentShaderGLSL,
986
+ }, {
987
+ attributes: ["position"],
988
+ uniforms: ["sogScalesMin", "sogScalesMax", "uVersion", "uOffset", "uCount", "uDestWidth", "uSrcWidth"],
989
+ samplers: ["sogScalesTex", "sogQuatsTex", "sogCodebookTex"],
990
+ shaderLanguage: this._shaderLanguage,
991
+ });
992
+ material.backFaceCulling = false;
993
+ material.disableDepthWrite = true;
994
+ return material;
995
+ }
996
+ _applyRotPack(pack) {
997
+ const material = this._rotMaterial;
998
+ const srcWidth = pack.scalesTexture.getSize().width;
999
+ material.setTexture("sogScalesTex", pack.scalesTexture);
1000
+ material.setTexture("sogQuatsTex", pack.quatsTexture);
1001
+ // Codebook only used for v2; bind a harmless placeholder otherwise so the sampler is always set.
1002
+ material.setTexture("sogCodebookTex", pack.codebookTexture ?? pack.scalesTexture);
1003
+ const sMin = pack.scalesMin ?? [0, 0, 0];
1004
+ const sMax = pack.scalesMax ?? [0, 0, 0];
1005
+ material.setVector3("sogScalesMin", new Vector3(sMin[0], sMin[1], sMin[2]));
1006
+ material.setVector3("sogScalesMax", new Vector3(sMax[0], sMax[1], sMax[2]));
1007
+ material.setInt("uVersion", pack.version);
1008
+ // uOffset is bound at render time in decodeAsync (from the live _baseOffset).
361
1009
  material.setInt("uCount", pack.splatCount);
362
1010
  material.setInt("uDestWidth", this._textureSize);
363
1011
  material.setInt("uSrcWidth", srcWidth);