@d5techs/3dgs-lib 1.4.83 → 1.4.85

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.
package/dist/3dgs-lib.js CHANGED
@@ -36,8 +36,7 @@ function isMobileDevice() {
36
36
  return isMobileUA || isIPadAsMac || hasTouch && isSmallScreen;
37
37
  }
38
38
  function getRecommendedDPR() {
39
- const isMobile = isMobileDevice();
40
- const maxDpr = isMobile ? 2 : 2;
39
+ const maxDpr = isMobileDevice() ? 2 : 2;
41
40
  return Math.min(window.devicePixelRatio || 1, maxDpr);
42
41
  }
43
42
  function isWebGPUSupported() {
@@ -142,6 +141,140 @@ function transformBoundingBox(bbox, modelMatrix) {
142
141
  [maxX, maxY, maxZ]
143
142
  );
144
143
  }
144
+ class DebugOverlay {
145
+ constructor(maxEntries = 50) {
146
+ __publicField(this, "container");
147
+ __publicField(this, "logList");
148
+ __publicField(this, "maxEntries");
149
+ this.maxEntries = maxEntries;
150
+ this.container = document.createElement("div");
151
+ Object.assign(this.container.style, {
152
+ position: "fixed",
153
+ top: "0",
154
+ left: "0",
155
+ right: "0",
156
+ maxHeight: "40vh",
157
+ overflowY: "auto",
158
+ background: "rgba(0,0,0,0.85)",
159
+ color: "#0f0",
160
+ fontFamily: "monospace",
161
+ fontSize: "11px",
162
+ lineHeight: "1.4",
163
+ padding: "6px 8px",
164
+ zIndex: "99999",
165
+ pointerEvents: "auto",
166
+ whiteSpace: "pre-wrap",
167
+ wordBreak: "break-all"
168
+ });
169
+ const header = document.createElement("div");
170
+ Object.assign(header.style, {
171
+ display: "flex",
172
+ justifyContent: "space-between",
173
+ marginBottom: "4px",
174
+ borderBottom: "1px solid #333",
175
+ paddingBottom: "4px"
176
+ });
177
+ header.innerHTML = '<span style="color:#ff0;font-weight:bold">GPU Debug</span>';
178
+ const closeBtn = document.createElement("span");
179
+ closeBtn.textContent = "[X]";
180
+ closeBtn.style.color = "#f66";
181
+ closeBtn.style.cursor = "pointer";
182
+ closeBtn.onclick = () => this.hide();
183
+ header.appendChild(closeBtn);
184
+ this.logList = document.createElement("div");
185
+ this.container.appendChild(header);
186
+ this.container.appendChild(this.logList);
187
+ document.body.appendChild(this.container);
188
+ }
189
+ log(msg, level = "info") {
190
+ const colors = { info: "#0f0", warn: "#ff0", error: "#f44" };
191
+ const prefix = { info: "I", warn: "W", error: "E" };
192
+ const ts = (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false });
193
+ const entry = document.createElement("div");
194
+ entry.style.color = colors[level];
195
+ entry.textContent = `[${ts}][${prefix[level]}] ${msg}`;
196
+ this.logList.appendChild(entry);
197
+ while (this.logList.childElementCount > this.maxEntries) {
198
+ this.logList.removeChild(this.logList.firstChild);
199
+ }
200
+ this.container.scrollTop = this.container.scrollHeight;
201
+ }
202
+ info(msg) {
203
+ this.log(msg, "info");
204
+ }
205
+ warn(msg) {
206
+ this.log(msg, "warn");
207
+ }
208
+ error(msg) {
209
+ this.log(msg, "error");
210
+ }
211
+ hide() {
212
+ this.container.style.display = "none";
213
+ }
214
+ show() {
215
+ this.container.style.display = "block";
216
+ }
217
+ /**
218
+ * 绑定 WebGPU device 的全局错误捕获
219
+ */
220
+ attachDevice(device) {
221
+ device.onuncapturederror = (event) => {
222
+ this.error(`GPU Uncaptured: ${event.error.message}`);
223
+ };
224
+ this.info(
225
+ `Device attached. maxBuf=${(device.limits.maxBufferSize / 1048576).toFixed(0)}MB, maxStorage=${(device.limits.maxStorageBufferBindingSize / 1048576).toFixed(0)}MB`
226
+ );
227
+ }
228
+ /**
229
+ * 检查 shader 编译结果
230
+ */
231
+ async checkShader(module, label) {
232
+ try {
233
+ const info = await module.getCompilationInfo();
234
+ let hasError = false;
235
+ for (const msg of info.messages) {
236
+ const text = `Shader[${label}] ${msg.type}: L${msg.lineNum}:${msg.linePos} ${msg.message}`;
237
+ if (msg.type === "error") {
238
+ this.error(text);
239
+ hasError = true;
240
+ } else if (msg.type === "warning") {
241
+ this.warn(text);
242
+ }
243
+ }
244
+ if (!hasError) {
245
+ this.info(`Shader[${label}] compiled OK`);
246
+ }
247
+ return !hasError;
248
+ } catch (e) {
249
+ this.error(`Shader[${label}] getCompilationInfo failed: ${e}`);
250
+ return false;
251
+ }
252
+ }
253
+ /**
254
+ * 包裹一个异步操作,捕获 validation/oom 错误
255
+ */
256
+ async wrapGPU(device, label, fn) {
257
+ device.pushErrorScope("validation");
258
+ device.pushErrorScope("out-of-memory");
259
+ const result = fn();
260
+ const oomErr = await device.popErrorScope();
261
+ const valErr = await device.popErrorScope();
262
+ if (oomErr) this.error(`[${label}] OOM: ${oomErr.message}`);
263
+ if (valErr) this.error(`[${label}] Validation: ${valErr.message}`);
264
+ if (!oomErr && !valErr) this.info(`[${label}] OK`);
265
+ return result;
266
+ }
267
+ }
268
+ let _instance = null;
269
+ function getDebugOverlay() {
270
+ return _instance;
271
+ }
272
+ function createDebugOverlay() {
273
+ if (!_instance) {
274
+ _instance = new DebugOverlay();
275
+ }
276
+ return _instance;
277
+ }
145
278
  async function loadTextureFromURL(device, url) {
146
279
  try {
147
280
  const response = await fetch(url);
@@ -361,7 +494,16 @@ class Renderer {
361
494
  }
362
495
  });
363
496
  this._device.lost.then((info) => {
497
+ const overlay2 = getDebugOverlay();
498
+ overlay2 == null ? void 0 : overlay2.error(`GPU device lost: ${info.reason} - ${info.message}`);
364
499
  });
500
+ const overlay = getDebugOverlay();
501
+ if (overlay) {
502
+ overlay.attachDevice(this._device);
503
+ overlay.info(
504
+ `GPU: vendor=${this._gpuVendor || "unknown"}, arch=${this._gpuArchitecture || "unknown"}, apple=${this._isAppleGPU}, format=${navigator.gpu.getPreferredCanvasFormat()}`
505
+ );
506
+ }
365
507
  this._context = this.canvas.getContext("webgpu");
366
508
  if (!this._context) {
367
509
  throw new Error("无法获取 WebGPU 上下文");
@@ -685,6 +827,9 @@ const _OrbitControls = class _OrbitControls {
685
827
  this.setupEventListeners();
686
828
  this.applySpherical();
687
829
  }
830
+ get isInteracting() {
831
+ return this.isDragging;
832
+ }
688
833
  setupEventListeners() {
689
834
  this.canvas.addEventListener("mousedown", this.boundOnMouseDown);
690
835
  this.canvas.addEventListener("mousemove", this.boundOnMouseMove);
@@ -5229,9 +5374,10 @@ function compactDataToGPUBuffer(data, includeFullSH = false) {
5229
5374
  }
5230
5375
  return buffer;
5231
5376
  } else {
5232
- const buffer = new Float32Array(count * 64);
5377
+ const COMPACT_FLOATS = 16;
5378
+ const buffer = new Float32Array(count * COMPACT_FLOATS);
5233
5379
  for (let i = 0; i < count; i++) {
5234
- const offset = i * 64;
5380
+ const offset = i * COMPACT_FLOATS;
5235
5381
  buffer[offset + 0] = data.positions[i * 3 + 0];
5236
5382
  buffer[offset + 1] = data.positions[i * 3 + 1];
5237
5383
  buffer[offset + 2] = data.positions[i * 3 + 2];
@@ -5252,9 +5398,56 @@ function compactDataToGPUBuffer(data, includeFullSH = false) {
5252
5398
  return buffer;
5253
5399
  }
5254
5400
  }
5401
+ const _f16Scratch = new Float32Array(1);
5402
+ const _f16ScratchU32 = new Uint32Array(_f16Scratch.buffer);
5403
+ function f32ToF16Bits(val) {
5404
+ _f16Scratch[0] = val;
5405
+ const bits2 = _f16ScratchU32[0];
5406
+ const sign = bits2 >>> 31 & 1;
5407
+ let exp = bits2 >>> 23 & 255;
5408
+ let frac = bits2 & 8388607;
5409
+ if (exp === 255) {
5410
+ return sign << 15 | 31744 | (frac ? 512 : 0);
5411
+ }
5412
+ exp = exp - 127 + 15;
5413
+ if (exp >= 31) {
5414
+ return sign << 15 | 31744;
5415
+ }
5416
+ if (exp <= 0) {
5417
+ if (exp < -10) return sign << 15;
5418
+ frac = (frac | 8388608) >> 1 - exp;
5419
+ return sign << 15 | frac >> 13;
5420
+ }
5421
+ return sign << 15 | exp << 10 | frac >> 13;
5422
+ }
5423
+ function compactDataToGPUBufferHalf(data) {
5424
+ const count = data.count;
5425
+ const U32_PER_SPLAT = 8;
5426
+ const buffer = new Uint32Array(count * U32_PER_SPLAT);
5427
+ const pos = data.positions;
5428
+ const scl = data.scales;
5429
+ const rot = data.rotations;
5430
+ const col = data.colors;
5431
+ const opa = data.opacities;
5432
+ for (let i = 0; i < count; i++) {
5433
+ const o = i * U32_PER_SPLAT;
5434
+ const i3 = i * 3;
5435
+ const i4 = i * 4;
5436
+ buffer[o] = f32ToF16Bits(pos[i3 + 1]) << 16 | f32ToF16Bits(pos[i3]);
5437
+ buffer[o + 1] = f32ToF16Bits(pos[i3 + 2]);
5438
+ buffer[o + 2] = f32ToF16Bits(scl[i3 + 1]) << 16 | f32ToF16Bits(scl[i3]);
5439
+ buffer[o + 3] = f32ToF16Bits(opa[i]) << 16 | f32ToF16Bits(scl[i3 + 2]);
5440
+ buffer[o + 4] = f32ToF16Bits(rot[i4 + 1]) << 16 | f32ToF16Bits(rot[i4]);
5441
+ buffer[o + 5] = f32ToF16Bits(rot[i4 + 3]) << 16 | f32ToF16Bits(rot[i4 + 2]);
5442
+ buffer[o + 6] = f32ToF16Bits(col[i3 + 1]) << 16 | f32ToF16Bits(col[i3]);
5443
+ buffer[o + 7] = f32ToF16Bits(col[i3 + 2]);
5444
+ }
5445
+ return buffer;
5446
+ }
5255
5447
  const PLYLoaderMobile = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
5256
5448
  __proto__: null,
5257
5449
  compactDataToGPUBuffer,
5450
+ compactDataToGPUBufferHalf,
5258
5451
  loadPLYMobile,
5259
5452
  parsePLYBuffer,
5260
5453
  transformSHCoeffsYZSwap
@@ -7314,16 +7507,38 @@ const RADIX_BITS = 8;
7314
7507
  const RADIX_SIZE = 256;
7315
7508
  const ELEMENTS_PER_THREAD = 4;
7316
7509
  const BLOCK_SIZE = WORKGROUP_SIZE$1 * ELEMENTS_PER_THREAD;
7317
- function generateCullingShaderCode$1() {
7318
- return (
7319
- /* wgsl */
7320
- `
7321
- /**
7322
- * Project & Cull Shader
7323
- * 基于 rfs-gsplat-render 实现
7324
- */
7325
-
7326
- struct Splat {
7510
+ function generateCullingShaderCode$1(compact = false, half = false, sortBits = 32) {
7511
+ let splatBinding;
7512
+ let splatAccessCode;
7513
+ if (half) {
7514
+ splatBinding = `@group(0) @binding(0) var<storage, read> splatDataU32: array<u32>;`;
7515
+ splatAccessCode = `
7516
+ const HALF_U32S: u32 = 8u;
7517
+ fn getSplatMean(idx: u32) -> vec3<f32> {
7518
+ let b = idx * HALF_U32S;
7519
+ let xy = unpack2x16float(splatDataU32[b]);
7520
+ let zp = unpack2x16float(splatDataU32[b + 1u]);
7521
+ return vec3<f32>(xy.x, xy.y, zp.x);
7522
+ }
7523
+ fn getSplatScale(idx: u32) -> vec3<f32> {
7524
+ let b = idx * HALF_U32S;
7525
+ let xy = unpack2x16float(splatDataU32[b + 2u]);
7526
+ return vec3<f32>(xy.x, xy.y, unpack2x16float(splatDataU32[b + 3u]).x);
7527
+ }
7528
+ fn getSplatOpacity(idx: u32) -> f32 {
7529
+ let b = idx * HALF_U32S;
7530
+ return unpack2x16float(splatDataU32[b + 3u]).y;
7531
+ }`;
7532
+ } else {
7533
+ const splatStruct = compact ? `struct Splat {
7534
+ mean: vec3<f32>,
7535
+ _pad0: f32,
7536
+ scale: vec3<f32>,
7537
+ _pad1: f32,
7538
+ rotation: vec4<f32>,
7539
+ colorDC: vec3<f32>,
7540
+ opacity: f32,
7541
+ }` : `struct Splat {
7327
7542
  mean: vec3<f32>,
7328
7543
  _pad0: f32,
7329
7544
  scale: vec3<f32>,
@@ -7335,7 +7550,24 @@ struct Splat {
7335
7550
  sh2: array<f32, 15>,
7336
7551
  sh3: array<f32, 21>,
7337
7552
  _pad2: array<f32, 3>,
7338
- }
7553
+ }`;
7554
+ splatBinding = `${splatStruct}
7555
+ @group(0) @binding(0) var<storage, read> splats: array<Splat>;`;
7556
+ splatAccessCode = `
7557
+ fn getSplatMean(idx: u32) -> vec3<f32> { return splats[idx].mean; }
7558
+ fn getSplatScale(idx: u32) -> vec3<f32> { return splats[idx].scale; }
7559
+ fn getSplatOpacity(idx: u32) -> f32 { return splats[idx].opacity; }`;
7560
+ }
7561
+ return (
7562
+ /* wgsl */
7563
+ `
7564
+ /**
7565
+ * Project & Cull Shader
7566
+ * 基于 rfs-gsplat-render 实现
7567
+ */
7568
+
7569
+ ${splatBinding}
7570
+ ${splatAccessCode}
7339
7571
 
7340
7572
  struct CameraUniforms {
7341
7573
  view: mat4x4<f32>,
@@ -7355,10 +7587,9 @@ struct CullingParams {
7355
7587
  pixelThreshold: f32,
7356
7588
  maxVisibleCount: u32,
7357
7589
  depthRangeLimit: f32,
7358
- _pad_cull: f32,
7590
+ lodSkipRate: f32,
7359
7591
  }
7360
7592
 
7361
- @group(0) @binding(0) var<storage, read> splats: array<Splat>;
7362
7593
  @group(0) @binding(1) var<uniform> camera: CameraUniforms;
7363
7594
  @group(0) @binding(2) var<uniform> params: CullingParams;
7364
7595
  @group(0) @binding(3) var<storage, read_write> depthKeys: array<u32>;
@@ -7376,16 +7607,12 @@ fn getModelMaxScale(model: mat4x4<f32>) -> f32 {
7376
7607
  return max(max(sx, sy), sz);
7377
7608
  }
7378
7609
 
7379
- // IEEE 754 位操作编码浮点数为可排序的 u32
7380
- // 参考 rfs-gsplat-render 的 encode_min_max_fp32 实现
7381
7610
  fn encodeDepthKey(val: f32) -> u32 {
7382
7611
  var bits = bitcast<u32>(val);
7383
7612
  bits ^= bitcast<u32>(bitcast<i32>(bits) >> 31) | 0x80000000u;
7384
- return bits;
7613
+ return bits >> ${sortBits === 16 ? "16u" : "0u"};
7385
7614
  }
7386
7615
 
7387
- // 视锥剔除检查
7388
- // 基于 rfs-gsplat-render 的 is_in_frustum 实现
7389
7616
  fn isInFrustum(clipPos: vec4<f32>, frustumDilation: f32) -> bool {
7390
7617
  let clip = (1.0 + frustumDilation) * clipPos.w;
7391
7618
 
@@ -7404,43 +7631,47 @@ fn isInFrustum(clipPos: vec4<f32>, frustumDilation: f32) -> bool {
7404
7631
  fn projectAndCull(@builtin(global_invocation_id) gid: vec3<u32>) {
7405
7632
  let i = gid.x;
7406
7633
  if i >= params.splatCount { return; }
7634
+
7635
+ // LOD 抽稀放最前面:跳过的 splat 不做任何数据加载和矩阵运算
7636
+ // 哈希仅依赖 splat index → 被跳过的集合永远不变 → 零闪烁
7637
+ if params.lodSkipRate > 0.0 {
7638
+ let hash = ((i * 2654435761u) >> 16u) & 0xFFFFu;
7639
+ if f32(hash) < params.lodSkipRate * 65535.0 {
7640
+ return;
7641
+ }
7642
+ }
7407
7643
 
7408
- let splat = splats[i];
7644
+ let splatMean = getSplatMean(i);
7645
+ let splatScale = getSplatScale(i);
7646
+ let splatOpacity = getSplatOpacity(i);
7409
7647
 
7410
- // 透明度剔除
7411
- if splat.opacity < 0.004 { return; }
7648
+ if splatOpacity < 0.004 { return; }
7412
7649
 
7413
- // 变换: Local -> World -> View -> Clip
7414
- let worldPos = camera.model * vec4<f32>(splat.mean, 1.0);
7650
+ let worldPos = camera.model * vec4<f32>(splatMean, 1.0);
7415
7651
  let viewPos = camera.view * worldPos;
7416
7652
  let clipPos = camera.proj * viewPos;
7417
7653
 
7418
- // 视锥剔除
7419
7654
  if !isInFrustum(clipPos, params.frustumDilation) { return; }
7420
7655
 
7421
- // 亚像素剔除:Gaussian 可见范围小于阈值的 splat 跳过渲染和排序
7422
- // scale σ(标准差),Gaussian 可见范围约 3σ(覆盖 99.7%)
7423
- if params.pixelThreshold > 0.0 {
7424
- let splatSigma = maxScale(splat.scale) * getModelMaxScale(camera.model);
7425
- let focalY = abs(camera.proj[1][1]) * params.screenHeight * 0.5;
7426
- let projectedExtent = splatSigma * 3.0 * focalY / max(abs(viewPos.z), 0.001);
7427
- if projectedExtent < params.pixelThreshold { return; }
7428
- }
7656
+ let splatSigma = maxScale(splatScale) * getModelMaxScale(camera.model);
7657
+ let focalY = abs(camera.proj[1][1]) * params.screenHeight * 0.5;
7658
+ let projectedExtent = splatSigma * 3.0 * focalY / max(abs(viewPos.z), 0.001);
7659
+
7660
+ // 剔除投影尺寸过大的 splat(远离模型时去除周围遮挡物)
7661
+ if projectedExtent > params.screenHeight * 0.5 { return; }
7662
+
7663
+ // 剔除投影尺寸过小的 splat
7664
+ if params.pixelThreshold > 0.0 && projectedExtent < params.pixelThreshold { return; }
7429
7665
 
7430
- // 深度范围限制:只渲染距相机一定深度范围内的 splat
7431
7666
  if params.depthRangeLimit > 0.0 {
7432
7667
  if abs(viewPos.z) > params.depthRangeLimit { return; }
7433
7668
  }
7434
7669
 
7435
- // 深度编码 (viewPos.z 是负数)
7436
7670
  let depth = viewPos.z;
7437
7671
  let sortableDepth = encodeDepthKey(depth);
7438
7672
 
7439
- // 原子增加可见计数并获取索引
7440
- // indirectBuffer[1] 是 instance_count
7441
7673
  let visibleIdx = atomicAdd(&indirectBuffer[1], 1u);
7442
7674
 
7443
- // 写入可见点列表
7444
7675
  depthKeys[visibleIdx] = sortableDepth;
7445
7676
  visibleIndices[visibleIdx] = i;
7446
7677
  }
@@ -7755,7 +7986,7 @@ fn downsweep(
7755
7986
  );
7756
7987
  }
7757
7988
  class GSSplatSorter {
7758
- constructor(device, splatCount, splatBuffer, cameraBuffer, _options = {}) {
7989
+ constructor(device, splatCount, splatBuffer, cameraBuffer, _options = {}, compact = false, half = false, sortBits = 32) {
7759
7990
  __publicField(this, "device");
7760
7991
  __publicField(this, "splatCount");
7761
7992
  // Culling Buffers
@@ -7785,11 +8016,12 @@ class GSSplatSorter {
7785
8016
  __publicField(this, "upsweepBindGroupLayout");
7786
8017
  __publicField(this, "spineBindGroupLayout");
7787
8018
  __publicField(this, "downsweepBindGroupLayout");
7788
- // Bind groups for each pass (4 passes)
8019
+ // Bind groups for each pass
7789
8020
  __publicField(this, "upsweepBindGroups", []);
7790
8021
  __publicField(this, "spineBindGroups", []);
7791
8022
  __publicField(this, "downsweepBindGroups", []);
7792
8023
  __publicField(this, "numPartitions");
8024
+ __publicField(this, "numSortPasses");
7793
8025
  // 屏幕信息和剔除选项
7794
8026
  __publicField(this, "screenWidth", 1920);
7795
8027
  __publicField(this, "screenHeight", 1080);
@@ -7802,14 +8034,19 @@ class GSSplatSorter {
7802
8034
  this.device = device;
7803
8035
  this.splatCount = splatCount;
7804
8036
  this.numPartitions = Math.ceil(splatCount / BLOCK_SIZE);
8037
+ this.numSortPasses = sortBits / RADIX_BITS;
8038
+ const dbg = getDebugOverlay();
8039
+ dbg == null ? void 0 : dbg.info(`Sorter init: ${splatCount} splats, compact=${compact}, half=${half}, sortBits=${sortBits}`);
7805
8040
  const cullingModule = device.createShaderModule({
7806
- code: generateCullingShaderCode$1(),
8041
+ code: generateCullingShaderCode$1(compact, half, sortBits),
7807
8042
  label: "culling-shader"
7808
8043
  });
8044
+ dbg == null ? void 0 : dbg.checkShader(cullingModule, "culling");
7809
8045
  const radixSortModule = device.createShaderModule({
7810
8046
  code: generateRadixSortShaderCode(),
7811
8047
  label: "radix-sort-shader"
7812
8048
  });
8049
+ dbg == null ? void 0 : dbg.checkShader(radixSortModule, "radix-sort");
7813
8050
  this.cullingParamsBuffer = device.createBuffer({
7814
8051
  size: 48,
7815
8052
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
@@ -7827,12 +8064,11 @@ class GSSplatSorter {
7827
8064
  });
7828
8065
  this.indirectBuffer = device.createBuffer({
7829
8066
  size: 16,
7830
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_DST,
8067
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
7831
8068
  label: "indirect-buffer"
7832
8069
  });
7833
8070
  this.globalHistogramBuffer = device.createBuffer({
7834
- size: RADIX_SIZE * 4 * 4,
7835
- // 4 passes * 256 bins * 4 bytes
8071
+ size: RADIX_SIZE * this.numSortPasses * 4,
7836
8072
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
7837
8073
  label: "global-histogram"
7838
8074
  });
@@ -7851,7 +8087,7 @@ class GSSplatSorter {
7851
8087
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
7852
8088
  label: "values-temp"
7853
8089
  });
7854
- for (let i = 0; i < 4; i++) {
8090
+ for (let i = 0; i < this.numSortPasses; i++) {
7855
8091
  const paramsBuffer = device.createBuffer({
7856
8092
  size: 16,
7857
8093
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
@@ -7963,16 +8199,12 @@ class GSSplatSorter {
7963
8199
  }
7964
8200
  /**
7965
8201
  * 创建 Radix Sort 的 bind groups
7966
- * 4 个 pass,使用 ping-pong buffers
7967
- *
7968
- * Ping-pong 模式:
7969
- * - Pass 0: depthKeys/visibleIndices -> keysTempBuffer/valuesTempBuffer
7970
- * - Pass 1: keysTempBuffer/valuesTempBuffer -> depthKeys/visibleIndices
7971
- * - Pass 2: depthKeys/visibleIndices -> keysTempBuffer/valuesTempBuffer
7972
- * - Pass 3: keysTempBuffer/valuesTempBuffer -> (depthKeys)/sortedIndicesBuffer
8202
+ * numSortPasses 个 pass,使用 ping-pong buffers
8203
+ * 最后一个 pass 的 values 输出到 sortedIndicesBuffer
7973
8204
  */
7974
8205
  createRadixSortBindGroups() {
7975
- for (let passIdx = 0; passIdx < 4; passIdx++) {
8206
+ const lastPassIdx = this.numSortPasses - 1;
8207
+ for (let passIdx = 0; passIdx < this.numSortPasses; passIdx++) {
7976
8208
  const isEvenPass = passIdx % 2 === 0;
7977
8209
  const keysIn = isEvenPass ? this.depthKeysBuffer : this.keysTempBuffer;
7978
8210
  const valuesIn = isEvenPass ? this.visibleIndicesBuffer : this.valuesTempBuffer;
@@ -7980,10 +8212,10 @@ class GSSplatSorter {
7980
8212
  let valuesOut;
7981
8213
  if (isEvenPass) {
7982
8214
  keysOut = this.keysTempBuffer;
7983
- valuesOut = this.valuesTempBuffer;
8215
+ valuesOut = passIdx === lastPassIdx ? this.sortedIndicesBuffer : this.valuesTempBuffer;
7984
8216
  } else {
7985
8217
  keysOut = this.depthKeysBuffer;
7986
- valuesOut = passIdx === 3 ? this.sortedIndicesBuffer : this.visibleIndicesBuffer;
8218
+ valuesOut = passIdx === lastPassIdx ? this.sortedIndicesBuffer : this.visibleIndicesBuffer;
7987
8219
  }
7988
8220
  this.upsweepBindGroups[passIdx] = this.device.createBindGroup({
7989
8221
  layout: this.upsweepBindGroupLayout,
@@ -8051,16 +8283,15 @@ class GSSplatSorter {
8051
8283
  view.setFloat32(24, this.cullingOptions.pixelThreshold, true);
8052
8284
  view.setUint32(28, this.cullingOptions.maxVisibleCount ?? 0, true);
8053
8285
  view.setFloat32(32, this.cullingOptions.depthRangeLimit ?? 0, true);
8286
+ view.setFloat32(36, this.cullingOptions.lodSkipRate ?? 0, true);
8054
8287
  this.device.queue.writeBuffer(this.cullingParamsBuffer, 0, cullingParamsData);
8288
+ this.device.queue.writeBuffer(
8289
+ this.indirectBuffer,
8290
+ 0,
8291
+ new Uint32Array([4, 0, 0, 0])
8292
+ );
8055
8293
  const encoder = this.device.createCommandEncoder({ label: "splat-sort-encoder" });
8056
8294
  encoder.clearBuffer(this.globalHistogramBuffer);
8057
- {
8058
- const pass = encoder.beginComputePass({ label: "init-indirect" });
8059
- pass.setPipeline(this.initIndirectPipeline);
8060
- pass.setBindGroup(0, this.cullingBindGroup);
8061
- pass.dispatchWorkgroups(1);
8062
- pass.end();
8063
- }
8064
8295
  {
8065
8296
  const pass = encoder.beginComputePass({ label: "project-cull" });
8066
8297
  pass.setPipeline(this.projectCullPipeline);
@@ -8068,7 +8299,7 @@ class GSSplatSorter {
8068
8299
  pass.dispatchWorkgroups(Math.ceil(this.splatCount / WORKGROUP_SIZE$1));
8069
8300
  pass.end();
8070
8301
  }
8071
- for (let passIdx = 0; passIdx < 4; passIdx++) {
8302
+ for (let passIdx = 0; passIdx < this.numSortPasses; passIdx++) {
8072
8303
  {
8073
8304
  const pass = encoder.beginComputePass({ label: `upsweep-p${passIdx}` });
8074
8305
  pass.setPipeline(this.upsweepPipeline);
@@ -8112,6 +8343,24 @@ class GSSplatSorter {
8112
8343
  getDrawIndirectBuffer() {
8113
8344
  return this.indirectBuffer;
8114
8345
  }
8346
+ /**
8347
+ * 异步读回 drawIndirect buffer 内容(调试用)
8348
+ * 返回 [vertexCount, instanceCount, firstVertex, firstInstance]
8349
+ */
8350
+ async readbackIndirect() {
8351
+ const staging = this.device.createBuffer({
8352
+ size: 16,
8353
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
8354
+ });
8355
+ const encoder = this.device.createCommandEncoder();
8356
+ encoder.copyBufferToBuffer(this.indirectBuffer, 0, staging, 0, 16);
8357
+ this.device.queue.submit([encoder.finish()]);
8358
+ await staging.mapAsync(GPUMapMode.READ);
8359
+ const result = new Uint32Array(staging.getMappedRange().slice(0));
8360
+ staging.unmap();
8361
+ staging.destroy();
8362
+ return result;
8363
+ }
8115
8364
  /**
8116
8365
  * 获取 splat 总数量
8117
8366
  */
@@ -8359,10 +8608,6 @@ fn computeExtentBasisAA(cov2dIn: vec3<f32>, opacity: f32, viewportSize: vec2<f32
8359
8608
  // 使用基于视口的最大限制 (匹配 PlayCanvas)
8360
8609
  let vmin = min(1024.0, min(viewportSize.x, viewportSize.y));
8361
8610
 
8362
- // 计算轴长度: l = 2 * sqrt(2 * lambda) ≈ 2.83 * sqrt(lambda)
8363
- // 这与 GAUSSIAN_K=4 配套使用:
8364
- // 在 UV=1 边界,对应 2*sqrt(2) 个标准差的位置
8365
- // exp(-4 * 1) = exp(-4) ≈ 0.018,Normalized 后精确为 0
8366
8611
  let l1 = min(2.0 * sqrt(2.0 * lambda1), vmin);
8367
8612
  let l2 = min(2.0 * sqrt(2.0 * lambda2), vmin);
8368
8613
 
@@ -8379,7 +8624,6 @@ fn computeExtentBasisAA(cov2dIn: vec3<f32>, opacity: f32, viewportSize: vec2<f32
8379
8624
  let eigenvector1 = diagVec;
8380
8625
  let eigenvector2 = vec2<f32>(diagVec.y, -diagVec.x);
8381
8626
 
8382
- // 计算基向量 (不应用额外的 splat_scale,因为我们使用默认值 1.0)
8383
8627
  result.basis = vec4<f32>(eigenvector1 * l1, eigenvector2 * l2);
8384
8628
  result.adjustedOpacity = alpha;
8385
8629
  return result;
@@ -8773,7 +9017,79 @@ fn fs_depth_normal(input: VertexOutput) -> FragOutput {
8773
9017
  }
8774
9018
  `
8775
9019
  );
9020
+ const SPLAT_BYTE_SIZE = 256;
8776
9021
  const SPLAT_FLOAT_COUNT = 64;
9022
+ const COMPACT_SPLAT_BYTE_SIZE = 64;
9023
+ const COMPACT_SPLAT_FLOAT_COUNT = 16;
9024
+ const HALF_SPLAT_BYTE_SIZE = 32;
9025
+ const HALF_SPLAT_U32_COUNT = 8;
9026
+ function transformShaderForCompact(code) {
9027
+ code = code.replace(
9028
+ /struct Splat \{[\s\S]*?\n\}/,
9029
+ `struct Splat {
9030
+ mean: vec3<f32>, _pad0: f32,
9031
+ scale: vec3<f32>, _pad1: f32,
9032
+ rotation: vec4<f32>,
9033
+ colorDC: vec3<f32>,
9034
+ opacity: f32,
9035
+ }`
9036
+ );
9037
+ code = code.replace(
9038
+ /fn evalSH\(splat: Splat, dir: vec3<f32>\) -> vec3<f32> \{[\s\S]*?\n\}/,
9039
+ `fn evalSH(splat: Splat, dir: vec3<f32>) -> vec3<f32> {
9040
+ return vec3<f32>(0.0);
9041
+ }`
9042
+ );
9043
+ return code;
9044
+ }
9045
+ function transformShaderForHalf(code) {
9046
+ code = code.replace(
9047
+ /struct Splat \{[\s\S]*?\n\}/,
9048
+ `struct Splat {
9049
+ mean: vec3<f32>, _pad0: f32,
9050
+ scale: vec3<f32>, _pad1: f32,
9051
+ rotation: vec4<f32>,
9052
+ colorDC: vec3<f32>,
9053
+ opacity: f32,
9054
+ }`
9055
+ );
9056
+ code = code.replace(
9057
+ /@group\(0\)\s*@binding\((\d+)\)\s*var<storage,\s*read>\s*splats\s*:\s*array<Splat>/,
9058
+ `@group(0) @binding($1) var<storage, read> splatDataU32: array<u32>`
9059
+ );
9060
+ const unpackFunctions = `
9061
+ const HALF_U32S: u32 = 8u;
9062
+ fn unpackSplatFromHalf(idx: u32) -> Splat {
9063
+ let b = idx * HALF_U32S;
9064
+ let mean_xy = unpack2x16float(splatDataU32[b]);
9065
+ let mean_zp = unpack2x16float(splatDataU32[b + 1u]);
9066
+ let sc_xy = unpack2x16float(splatDataU32[b + 2u]);
9067
+ let sc_z_op = unpack2x16float(splatDataU32[b + 3u]);
9068
+ let r_xy = unpack2x16float(splatDataU32[b + 4u]);
9069
+ let r_zw = unpack2x16float(splatDataU32[b + 5u]);
9070
+ let c_rg = unpack2x16float(splatDataU32[b + 6u]);
9071
+ let c_bp = unpack2x16float(splatDataU32[b + 7u]);
9072
+ var s: Splat;
9073
+ s.mean = vec3<f32>(mean_xy.x, mean_xy.y, mean_zp.x);
9074
+ s._pad0 = 0.0;
9075
+ s.scale = vec3<f32>(sc_xy.x, sc_xy.y, sc_z_op.x);
9076
+ s._pad1 = 0.0;
9077
+ s.rotation = vec4<f32>(r_xy.x, r_xy.y, r_zw.x, r_zw.y);
9078
+ s.colorDC = vec3<f32>(c_rg.x, c_rg.y, c_bp.x);
9079
+ s.opacity = sc_z_op.y;
9080
+ return s;
9081
+ }
9082
+ `;
9083
+ code = code.replace(/(@vertex|@compute)/, unpackFunctions + "$1");
9084
+ code = code.replace(/splats\[([^\]]+)\]/g, "unpackSplatFromHalf($1)");
9085
+ code = code.replace(
9086
+ /fn evalSH\(splat: Splat, dir: vec3<f32>\) -> vec3<f32> \{[\s\S]*?\n\}/,
9087
+ `fn evalSH(splat: Splat, dir: vec3<f32>) -> vec3<f32> {
9088
+ return vec3<f32>(0.0);
9089
+ }`
9090
+ );
9091
+ return code;
9092
+ }
8777
9093
  const _GSSplatRenderer = class _GSSplatRenderer {
8778
9094
  constructor(renderer, camera) {
8779
9095
  __publicField(this, "renderer");
@@ -8787,6 +9103,12 @@ const _GSSplatRenderer = class _GSSplatRenderer {
8787
9103
  __publicField(this, "sorter", null);
8788
9104
  __publicField(this, "shMode", SHMode.L3);
8789
9105
  __publicField(this, "boundingBox", null);
9106
+ /** 紧凑布局模式:64B/splat,无 SH,适合移动端 */
9107
+ __publicField(this, "compactLayout", false);
9108
+ /** 半精度模式:32B/splat,所有数据用 f16 打包 */
9109
+ __publicField(this, "halfPrecision", false);
9110
+ /** 排序位宽:16-bit 排序减少一半 radix sort pass */
9111
+ __publicField(this, "sortBits", 32);
8790
9112
  // 预分配 uniform 上传缓冲区,避免每帧 GC(56 floats = 224 bytes)
8791
9113
  __publicField(this, "uniformData", new Float32Array(56));
8792
9114
  __publicField(this, "cpuPositions", null);
@@ -8800,6 +9122,7 @@ const _GSSplatRenderer = class _GSSplatRenderer {
8800
9122
  __publicField(this, "pixelCullThreshold", 1);
8801
9123
  __publicField(this, "maxVisibleSplats", 0);
8802
9124
  __publicField(this, "depthRangeLimit", 0);
9125
+ __publicField(this, "lodSkipRate", 0);
8803
9126
  // 排序优化:相机变化检测 + 频率控制
8804
9127
  __publicField(this, "lastSortViewMatrix", new Float32Array(16));
8805
9128
  __publicField(this, "lastSortProjMatrix", new Float32Array(16));
@@ -8812,6 +9135,7 @@ const _GSSplatRenderer = class _GSSplatRenderer {
8812
9135
  __publicField(this, "sortStateInitialized", false);
8813
9136
  __publicField(this, "sortFrequency", 1);
8814
9137
  __publicField(this, "frameCounter", 0);
9138
+ __publicField(this, "debugFrameLogged", false);
8815
9139
  // 编辑器状态
8816
9140
  __publicField(this, "editorStateBuffer", null);
8817
9141
  __publicField(this, "editorPipeline", null);
@@ -8836,15 +9160,56 @@ const _GSSplatRenderer = class _GSSplatRenderer {
8836
9160
  this.createUniformBuffer();
8837
9161
  this.updateModelMatrix();
8838
9162
  }
9163
+ /**
9164
+ * 启用/禁用紧凑布局模式(64B/splat,无 SH)。
9165
+ * 必须在 setCompactData / setData 之前调用。
9166
+ */
9167
+ setCompactLayout(compact) {
9168
+ if (this.compactLayout === compact) return;
9169
+ this.compactLayout = compact;
9170
+ if (compact) {
9171
+ this.shMode = SHMode.L0;
9172
+ }
9173
+ this.createPipeline();
9174
+ }
9175
+ /**
9176
+ * 设置排序位宽。16-bit 模式排序 pass 从 4 降到 2,排序速度翻倍。
9177
+ * 必须在 setData / setCompactData 之前调用。
9178
+ */
9179
+ setSortBits(bits2) {
9180
+ this.sortBits = bits2;
9181
+ }
9182
+ /**
9183
+ * 启用/禁用半精度模式(32B/splat,所有数据 f16 打包)。
9184
+ * 自动启用 compactLayout。必须在 setCompactData 之前调用。
9185
+ */
9186
+ setHalfPrecision(half) {
9187
+ if (this.halfPrecision === half) return;
9188
+ this.halfPrecision = half;
9189
+ if (half) {
9190
+ this.compactLayout = true;
9191
+ this.shMode = SHMode.L0;
9192
+ }
9193
+ this.createPipeline();
9194
+ }
8839
9195
  createPipeline() {
8840
9196
  const device = this.renderer.device;
8841
- const shaderCode = gsOptimizedShader.replace(
9197
+ const dbg = getDebugOverlay();
9198
+ let shaderCode = gsOptimizedShader.replace(
8842
9199
  "const SH_LEVEL: u32 = 3u; // @SH_LEVEL_INJECT@",
8843
9200
  `const SH_LEVEL: u32 = ${this.shMode}u;`
8844
9201
  );
9202
+ if (this.halfPrecision) {
9203
+ shaderCode = transformShaderForHalf(shaderCode);
9204
+ } else if (this.compactLayout) {
9205
+ shaderCode = transformShaderForCompact(shaderCode);
9206
+ }
9207
+ dbg == null ? void 0 : dbg.info(`createPipeline: SH=${this.shMode}, compact=${this.compactLayout}, half=${this.halfPrecision}`);
8845
9208
  const shaderModule = device.createShaderModule({
8846
- code: shaderCode
9209
+ code: shaderCode,
9210
+ label: "gs-main-shader"
8847
9211
  });
9212
+ dbg == null ? void 0 : dbg.checkShader(shaderModule, "gs-main");
8848
9213
  this.bindGroupLayout = device.createBindGroupLayout({
8849
9214
  entries: [
8850
9215
  {
@@ -8921,8 +9286,12 @@ const _GSSplatRenderer = class _GSSplatRenderer {
8921
9286
  // A采用"zero add one-minus-src-alpha"的混合模式计算Transmittance
8922
9287
  createDepthNormalPipeline() {
8923
9288
  const device = this.renderer.device;
9289
+ let dnShader = gsDepthNormalShader;
9290
+ if (this.compactLayout) {
9291
+ dnShader = transformShaderForCompact(dnShader);
9292
+ }
8924
9293
  const shaderModule = device.createShaderModule({
8925
- code: gsDepthNormalShader
9294
+ code: dnShader
8926
9295
  });
8927
9296
  const pipelineLayout = device.createPipelineLayout({
8928
9297
  bindGroupLayouts: [this.bindGroupLayout]
@@ -9101,6 +9470,17 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9101
9470
  getDepthRangeLimit() {
9102
9471
  return this.depthRangeLimit;
9103
9472
  }
9473
+ /**
9474
+ * 设置 LOD 抽稀率(0~1)
9475
+ * 远处 splat 按此比例随机跳过(确定性哈希,无闪烁)
9476
+ * 0 = 不抽稀
9477
+ */
9478
+ setLodSkipRate(rate) {
9479
+ this.lodSkipRate = Math.max(0, Math.min(1, rate));
9480
+ }
9481
+ getLodSkipRate() {
9482
+ return this.lodSkipRate;
9483
+ }
9104
9484
  /**
9105
9485
  * 设置排序频率
9106
9486
  * 1 = 每帧排序(默认),2 = 每 2 帧排序一次,以此类推
@@ -9143,6 +9523,7 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9143
9523
  }
9144
9524
  setData(splats) {
9145
9525
  const device = this.renderer.device;
9526
+ const dbg = getDebugOverlay();
9146
9527
  if (this.splatBuffer) {
9147
9528
  this.splatBuffer.destroy();
9148
9529
  }
@@ -9157,12 +9538,24 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9157
9538
  this.boundingBox = null;
9158
9539
  return;
9159
9540
  }
9541
+ const floatsPerSplat = this.compactLayout ? COMPACT_SPLAT_FLOAT_COUNT : SPLAT_FLOAT_COUNT;
9542
+ const bytesPerSplat = floatsPerSplat * 4;
9543
+ const maxStorageSize = device.limits.maxStorageBufferBindingSize;
9544
+ const maxSplatsForGPU = Math.floor(maxStorageSize / bytesPerSplat);
9545
+ if (this.splatCount > maxSplatsForGPU) {
9546
+ dbg == null ? void 0 : dbg.warn(
9547
+ `setData: truncating ${this.splatCount} -> ${maxSplatsForGPU} splats (maxStorageBufferBindingSize=${(maxStorageSize / 1048576).toFixed(0)}MB)`
9548
+ );
9549
+ this.splatCount = maxSplatsForGPU;
9550
+ splats = splats.slice(0, this.splatCount);
9551
+ }
9552
+ dbg == null ? void 0 : dbg.info(`setData: ${this.splatCount} splats, compact=${this.compactLayout}`);
9160
9553
  this.boundingBox = this.computeBoundingBox(splats);
9161
9554
  const positions = new Float32Array(this.splatCount * 3);
9162
- const data = new Float32Array(this.splatCount * SPLAT_FLOAT_COUNT);
9555
+ const data = new Float32Array(this.splatCount * floatsPerSplat);
9163
9556
  for (let i = 0; i < this.splatCount; i++) {
9164
9557
  const splat = splats[i];
9165
- const offset = i * SPLAT_FLOAT_COUNT;
9558
+ const offset = i * floatsPerSplat;
9166
9559
  positions[i * 3 + 0] = splat.mean[0];
9167
9560
  positions[i * 3 + 1] = splat.mean[1];
9168
9561
  positions[i * 3 + 2] = splat.mean[2];
@@ -9182,31 +9575,39 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9182
9575
  data[offset + 13] = splat.colorDC[1];
9183
9576
  data[offset + 14] = splat.colorDC[2];
9184
9577
  data[offset + 15] = splat.opacity;
9185
- const shRest = splat.shRest;
9186
- for (let j = 0; j < 9; j++) {
9187
- data[offset + 16 + j] = shRest ? shRest[j] : 0;
9188
- }
9189
- for (let j = 0; j < 15; j++) {
9190
- data[offset + 25 + j] = shRest ? shRest[9 + j] : 0;
9191
- }
9192
- for (let j = 0; j < 21; j++) {
9193
- data[offset + 40 + j] = shRest ? shRest[24 + j] : 0;
9578
+ if (!this.compactLayout) {
9579
+ const shRest = splat.shRest;
9580
+ for (let j = 0; j < 9; j++) {
9581
+ data[offset + 16 + j] = shRest ? shRest[j] : 0;
9582
+ }
9583
+ for (let j = 0; j < 15; j++) {
9584
+ data[offset + 25 + j] = shRest ? shRest[9 + j] : 0;
9585
+ }
9586
+ for (let j = 0; j < 21; j++) {
9587
+ data[offset + 40 + j] = shRest ? shRest[24 + j] : 0;
9588
+ }
9589
+ data[offset + 61] = 0;
9590
+ data[offset + 62] = 0;
9591
+ data[offset + 63] = 0;
9194
9592
  }
9195
- data[offset + 61] = 0;
9196
- data[offset + 62] = 0;
9197
- data[offset + 63] = 0;
9198
9593
  }
9594
+ dbg == null ? void 0 : dbg.info(`splatBuffer: ${(data.byteLength / 1048576).toFixed(1)}MB (${floatsPerSplat} floats/splat)`);
9199
9595
  this.splatBuffer = device.createBuffer({
9200
9596
  size: data.byteLength,
9201
9597
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
9202
9598
  });
9203
9599
  device.queue.writeBuffer(this.splatBuffer, 0, data);
9204
9600
  this.cpuPositions = positions;
9601
+ dbg == null ? void 0 : dbg.info(`Creating sorter: compact=${this.compactLayout}, sortBits=${this.sortBits}`);
9205
9602
  this.sorter = new GSSplatSorter(
9206
9603
  device,
9207
9604
  this.splatCount,
9208
9605
  this.splatBuffer,
9209
- this.uniformBuffer
9606
+ this.uniformBuffer,
9607
+ {},
9608
+ this.compactLayout,
9609
+ false,
9610
+ this.sortBits
9210
9611
  );
9211
9612
  this.sorter.setScreenSize(this.renderer.width, this.renderer.height);
9212
9613
  this.sorter.setCullingOptions({
@@ -9222,10 +9623,12 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9222
9623
  { binding: 2, resource: { buffer: this.sorter.getIndicesBuffer() } }
9223
9624
  ]
9224
9625
  });
9626
+ dbg == null ? void 0 : dbg.info(`setData complete, bindGroup created`);
9225
9627
  if (this.editorEnabled) this.rebuildEditorBindGroup();
9226
9628
  }
9227
9629
  setCompactData(compactData) {
9228
9630
  const device = this.renderer.device;
9631
+ const dbg = getDebugOverlay();
9229
9632
  if (this.splatBuffer) {
9230
9633
  this.splatBuffer.destroy();
9231
9634
  }
@@ -9240,20 +9643,98 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9240
9643
  this.boundingBox = null;
9241
9644
  return;
9242
9645
  }
9646
+ const bytesPerSplat = this.halfPrecision ? HALF_SPLAT_BYTE_SIZE : this.compactLayout ? COMPACT_SPLAT_BYTE_SIZE : SPLAT_BYTE_SIZE;
9647
+ const maxStorageSize = device.limits.maxStorageBufferBindingSize;
9648
+ const maxSplatsForGPU = Math.floor(maxStorageSize / bytesPerSplat);
9649
+ dbg == null ? void 0 : dbg.info(
9650
+ `GPU limits: maxStorageBinding=${(maxStorageSize / 1048576).toFixed(0)}MB, maxBuf=${(device.limits.maxBufferSize / 1048576).toFixed(0)}MB, maxSplats=${maxSplatsForGPU} (at ${bytesPerSplat}B/splat), half=${this.halfPrecision}`
9651
+ );
9652
+ if (this.splatCount > maxSplatsForGPU) {
9653
+ dbg == null ? void 0 : dbg.warn(
9654
+ `Splat count ${this.splatCount} exceeds GPU maxStorageBufferBindingSize (${(maxStorageSize / 1048576).toFixed(0)}MB = ${maxSplatsForGPU} splats). Truncating to ${maxSplatsForGPU}.`
9655
+ );
9656
+ this.splatCount = maxSplatsForGPU;
9657
+ }
9243
9658
  this.boundingBox = this.computeBoundingBoxFromCompact(compactData);
9244
- this.cpuPositions = new Float32Array(compactData.positions);
9245
- const includeSH = compactData.shCoeffs !== void 0;
9246
- const gpuData = compactDataToGPUBuffer(compactData, includeSH);
9659
+ let trimmedData;
9660
+ if (this.halfPrecision && this.lodSkipRate > 0) {
9661
+ const skipRate = this.lodSkipRate;
9662
+ const threshold = skipRate * 65535;
9663
+ const kept = [];
9664
+ for (let i = 0; i < this.splatCount; i++) {
9665
+ const hash = (i * 2654435761 | 0) >>> 16 & 65535;
9666
+ if (hash >= threshold) kept.push(i);
9667
+ }
9668
+ const n = kept.length;
9669
+ const pos = new Float32Array(n * 3);
9670
+ const scl = new Float32Array(n * 3);
9671
+ const rot = new Float32Array(n * 4);
9672
+ const col = new Float32Array(n * 3);
9673
+ const opa = new Float32Array(n);
9674
+ for (let k = 0; k < n; k++) {
9675
+ const i = kept[k];
9676
+ pos[k * 3] = compactData.positions[i * 3];
9677
+ pos[k * 3 + 1] = compactData.positions[i * 3 + 1];
9678
+ pos[k * 3 + 2] = compactData.positions[i * 3 + 2];
9679
+ scl[k * 3] = compactData.scales[i * 3];
9680
+ scl[k * 3 + 1] = compactData.scales[i * 3 + 1];
9681
+ scl[k * 3 + 2] = compactData.scales[i * 3 + 2];
9682
+ rot[k * 4] = compactData.rotations[i * 4];
9683
+ rot[k * 4 + 1] = compactData.rotations[i * 4 + 1];
9684
+ rot[k * 4 + 2] = compactData.rotations[i * 4 + 2];
9685
+ rot[k * 4 + 3] = compactData.rotations[i * 4 + 3];
9686
+ col[k * 3] = compactData.colors[i * 3];
9687
+ col[k * 3 + 1] = compactData.colors[i * 3 + 1];
9688
+ col[k * 3 + 2] = compactData.colors[i * 3 + 2];
9689
+ opa[k] = compactData.opacities[i];
9690
+ }
9691
+ trimmedData = { count: n, positions: pos, scales: scl, rotations: rot, colors: col, opacities: opa };
9692
+ this.splatCount = n;
9693
+ dbg == null ? void 0 : dbg.info(`CPU LOD filter: ${compactData.count} → ${n} splats (skip ${(skipRate * 100).toFixed(0)}%)`);
9694
+ } else {
9695
+ const includeSH = !this.compactLayout && compactData.shCoeffs !== void 0;
9696
+ trimmedData = this.splatCount < compactData.count ? {
9697
+ count: this.splatCount,
9698
+ positions: compactData.positions.slice(0, this.splatCount * 3),
9699
+ scales: compactData.scales.slice(0, this.splatCount * 3),
9700
+ rotations: compactData.rotations.slice(0, this.splatCount * 4),
9701
+ colors: compactData.colors.slice(0, this.splatCount * 3),
9702
+ opacities: compactData.opacities.slice(0, this.splatCount),
9703
+ shCoeffs: includeSH && compactData.shCoeffs ? compactData.shCoeffs.slice(0, this.splatCount * compactData.shCoeffs.length / compactData.count) : void 0
9704
+ } : compactData;
9705
+ }
9706
+ dbg == null ? void 0 : dbg.info(`setCompactData: ${this.splatCount} splats, compact=${this.compactLayout}, half=${this.halfPrecision}`);
9707
+ this.cpuPositions = new Float32Array(trimmedData.positions);
9708
+ let gpuBuf;
9709
+ if (this.halfPrecision) {
9710
+ const halfData = compactDataToGPUBufferHalf(trimmedData);
9711
+ gpuBuf = halfData.buffer;
9712
+ dbg == null ? void 0 : dbg.info(
9713
+ `gpuData(half): ${(halfData.byteLength / 1048576).toFixed(1)}MB, u32s=${halfData.length}, u32/splat=${HALF_SPLAT_U32_COUNT}`
9714
+ );
9715
+ } else {
9716
+ const includeSH = !this.compactLayout && compactData.shCoeffs !== void 0;
9717
+ const f32Data = compactDataToGPUBuffer(trimmedData, includeSH);
9718
+ gpuBuf = f32Data.buffer;
9719
+ dbg == null ? void 0 : dbg.info(
9720
+ `gpuData: ${(f32Data.byteLength / 1048576).toFixed(1)}MB, includeSH=${includeSH}, floats=${f32Data.length}, floats/splat=${f32Data.length / this.splatCount}`
9721
+ );
9722
+ }
9247
9723
  this.splatBuffer = device.createBuffer({
9248
- size: gpuData.byteLength,
9724
+ size: gpuBuf.byteLength,
9249
9725
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
9250
9726
  });
9251
- device.queue.writeBuffer(this.splatBuffer, 0, gpuData.buffer);
9727
+ device.queue.writeBuffer(this.splatBuffer, 0, gpuBuf);
9728
+ dbg == null ? void 0 : dbg.info(`Creating sorter: compact=${this.compactLayout}, half=${this.halfPrecision}, sortBits=${this.sortBits}`);
9252
9729
  this.sorter = new GSSplatSorter(
9253
9730
  device,
9254
9731
  this.splatCount,
9255
9732
  this.splatBuffer,
9256
- this.uniformBuffer
9733
+ this.uniformBuffer,
9734
+ {},
9735
+ this.compactLayout,
9736
+ this.halfPrecision,
9737
+ this.sortBits
9257
9738
  );
9258
9739
  this.sorter.setScreenSize(this.renderer.width, this.renderer.height);
9259
9740
  this.sorter.setCullingOptions({
@@ -9269,6 +9750,7 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9269
9750
  { binding: 2, resource: { buffer: this.sorter.getIndicesBuffer() } }
9270
9751
  ]
9271
9752
  });
9753
+ dbg == null ? void 0 : dbg.info(`setCompactData complete, bindGroup created`);
9272
9754
  if (this.editorEnabled) this.rebuildEditorBindGroup();
9273
9755
  this.sortStateInitialized = false;
9274
9756
  }
@@ -9292,7 +9774,7 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9292
9774
  this.renderer.device.queue.writeBuffer(this.uniformBuffer, 0, ud);
9293
9775
  const changed = this.needsSort();
9294
9776
  this.frameCounter++;
9295
- const shouldSort = changed || this.sortFrequency > 1 && this.frameCounter % this.sortFrequency === 0;
9777
+ const shouldSort = !this.sortStateInitialized || changed;
9296
9778
  if (shouldSort) {
9297
9779
  this.sorter.setScreenSize(this.renderer.width, this.renderer.height);
9298
9780
  this.sorter.setCullingOptions({
@@ -9300,9 +9782,24 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9300
9782
  farPlane: this.camera.far,
9301
9783
  pixelThreshold: this.pixelCullThreshold,
9302
9784
  maxVisibleCount: this.maxVisibleSplats,
9303
- depthRangeLimit: this.depthRangeLimit
9785
+ depthRangeLimit: this.depthRangeLimit,
9786
+ lodSkipRate: this.lodSkipRate
9304
9787
  });
9305
- this.sorter.sort();
9788
+ if (!this.debugFrameLogged) {
9789
+ const dbg = getDebugOverlay();
9790
+ const dev = this.renderer.device;
9791
+ dev.pushErrorScope("validation");
9792
+ dev.pushErrorScope("out-of-memory");
9793
+ this.sorter.sort();
9794
+ dev.popErrorScope().then((err2) => {
9795
+ if (err2) dbg == null ? void 0 : dbg.error(`Sort OOM: ${err2.message}`);
9796
+ });
9797
+ dev.popErrorScope().then((err2) => {
9798
+ if (err2) dbg == null ? void 0 : dbg.error(`Sort Validation: ${err2.message}`);
9799
+ });
9800
+ } else {
9801
+ this.sorter.sort();
9802
+ }
9306
9803
  }
9307
9804
  if (this.editorEnabled && this.editorPipeline && this.editorBindGroup) {
9308
9805
  pass.setPipeline(this.editorPipeline);
@@ -9312,6 +9809,28 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9312
9809
  pass.setBindGroup(0, this.bindGroup);
9313
9810
  }
9314
9811
  pass.drawIndirect(this.sorter.getDrawIndirectBuffer(), 0);
9812
+ if (!this.debugFrameLogged) {
9813
+ this.debugFrameLogged = true;
9814
+ const dbg = getDebugOverlay();
9815
+ dbg == null ? void 0 : dbg.info(
9816
+ `First render: splats=${this.splatCount}, canvas=${this.renderer.width}x${this.renderer.height}, editor=${this.editorEnabled}, depthWrite=${this.depthWriteEnabled}`
9817
+ );
9818
+ if (dbg && this.sorter) {
9819
+ const pos2 = this.camera.position;
9820
+ dbg.info(`Camera pos: [${pos2[0].toFixed(2)}, ${pos2[1].toFixed(2)}, ${pos2[2].toFixed(2)}]`);
9821
+ dbg.info(
9822
+ `Culling cfg: pixelThresh=${this.pixelCullThreshold.toFixed(2)}, maxVisible=${this.maxVisibleSplats}, depthRange=${this.depthRangeLimit.toFixed(2)}, near=${this.camera.near}, far=${this.camera.far}`
9823
+ );
9824
+ this.sorter.readbackIndirect().then((data) => {
9825
+ dbg.info(
9826
+ `DrawIndirect: vtx=${data[0]}, inst=${data[1]}, firstVtx=${data[2]}, firstInst=${data[3]}`
9827
+ );
9828
+ if (data[0] === 0 && data[1] === 0) {
9829
+ dbg.error("ALL splats culled! visible=0");
9830
+ }
9831
+ });
9832
+ }
9833
+ }
9315
9834
  }
9316
9835
  getSplatCount() {
9317
9836
  return this.splatCount;
@@ -9629,10 +10148,13 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9629
10148
  }
9630
10149
  createEditorPipeline() {
9631
10150
  const device = this.renderer.device;
9632
- const editorShaderCode = this.buildEditorShader().replace(
10151
+ let editorShaderCode = this.buildEditorShader().replace(
9633
10152
  "const SH_LEVEL: u32 = 3u; // @SH_LEVEL_INJECT@",
9634
10153
  `const SH_LEVEL: u32 = ${this.shMode}u;`
9635
10154
  );
10155
+ if (this.compactLayout) {
10156
+ editorShaderCode = transformShaderForCompact(editorShaderCode);
10157
+ }
9636
10158
  const shaderModule = device.createShaderModule({ code: editorShaderCode });
9637
10159
  this.editorBindGroupLayout = device.createBindGroupLayout({
9638
10160
  entries: [
@@ -17566,186 +18088,31 @@ class EyedropperSelection {
17566
18088
  }
17567
18089
  }
17568
18090
  class SphereSelection {
17569
- constructor(parent, callbacks) {
17570
- __publicField(this, "parent");
17571
- __publicField(this, "toolbar");
17572
- __publicField(this, "callbacks");
18091
+ constructor(onRadiusChanged) {
17573
18092
  __publicField(this, "_radius", 1);
17574
- __publicField(this, "radiusInput");
17575
- this.parent = parent;
17576
- this.callbacks = callbacks;
17577
- this.toolbar = document.createElement("div");
17578
- this.toolbar.className = "volume-select-toolbar";
17579
- this.toolbar.style.cssText = `
17580
- position:absolute; bottom:90px; left:50%; transform:translateX(-50%);
17581
- display:none; z-index:20; background:rgba(30,30,30,0.92);
17582
- border-radius:8px; padding:6px 10px; gap:6px;
17583
- align-items:center; font-size:13px; color:#ddd;
17584
- backdrop-filter:blur(6px); user-select:none; white-space:nowrap;
17585
- box-shadow:0 2px 12px rgba(0,0,0,0.4);
17586
- `;
17587
- this.toolbar.addEventListener("pointerdown", (e) => e.stopPropagation());
17588
- this.toolbar.addEventListener("wheel", (e) => e.stopPropagation());
17589
- const mkBtn = (label2, op) => {
17590
- const btn = document.createElement("button");
17591
- btn.textContent = label2;
17592
- btn.style.cssText = `
17593
- padding:4px 12px; border:1px solid #555; border-radius:4px;
17594
- background:#333; color:#ddd; cursor:pointer; font-size:13px;
17595
- transition: background 0.15s;
17596
- `;
17597
- btn.addEventListener("mouseenter", () => {
17598
- btn.style.background = "#555";
17599
- });
17600
- btn.addEventListener("mouseleave", () => {
17601
- btn.style.background = "#333";
17602
- });
17603
- btn.addEventListener("pointerdown", (e) => {
17604
- e.stopPropagation();
17605
- this.callbacks.onApply(op);
17606
- });
17607
- return btn;
17608
- };
17609
- const setBtn = mkBtn("Set", "set");
17610
- const addBtn = mkBtn("Add", "add");
17611
- const removeBtn = mkBtn("Remove", "remove");
17612
- const inputWrap = document.createElement("span");
17613
- inputWrap.style.cssText = "display:inline-flex; align-items:center; gap:2px;";
17614
- const input = document.createElement("input");
17615
- input.type = "number";
17616
- input.min = "1";
17617
- input.step = "1";
17618
- input.value = String(this._radius);
17619
- input.style.cssText = `
17620
- width:40px; padding:3px 4px; border:1px solid #555; border-radius:4px;
17621
- background:#222; color:#ddd; font-size:12px; text-align:center;
17622
- `;
17623
- const label = document.createElement("span");
17624
- label.textContent = "Radius";
17625
- label.style.cssText = "color:#888; font-size:11px;";
17626
- input.addEventListener("change", () => {
17627
- const v = Math.max(1, Math.round(parseFloat(input.value) || 1));
17628
- input.value = String(v);
17629
- this._radius = v;
17630
- this.callbacks.onRadiusChanged(v);
17631
- });
17632
- inputWrap.appendChild(input);
17633
- inputWrap.appendChild(label);
17634
- this.radiusInput = input;
17635
- this.toolbar.appendChild(setBtn);
17636
- this.toolbar.appendChild(addBtn);
17637
- this.toolbar.appendChild(removeBtn);
17638
- this.toolbar.appendChild(inputWrap);
17639
- parent.appendChild(this.toolbar);
18093
+ __publicField(this, "_onRadiusChanged");
18094
+ this._onRadiusChanged = onRadiusChanged;
17640
18095
  }
17641
18096
  get radius() {
17642
18097
  return this._radius;
17643
18098
  }
17644
18099
  setRadius(radius) {
17645
- this._radius = Math.round(radius);
17646
- this.radiusInput.value = String(this._radius);
18100
+ var _a2;
18101
+ this._radius = radius;
18102
+ (_a2 = this._onRadiusChanged) == null ? void 0 : _a2.call(this, radius);
17647
18103
  }
17648
18104
  activate() {
17649
- this.toolbar.style.display = "flex";
17650
18105
  }
17651
18106
  deactivate() {
17652
- this.toolbar.style.display = "none";
17653
18107
  }
17654
18108
  }
17655
18109
  class BoxSelection {
17656
- constructor(parent, callbacks) {
17657
- __publicField(this, "parent");
17658
- __publicField(this, "toolbar");
17659
- __publicField(this, "callbacks");
18110
+ constructor(onDimensionsChanged) {
17660
18111
  __publicField(this, "_lenX", 2);
17661
18112
  __publicField(this, "_lenY", 2);
17662
18113
  __publicField(this, "_lenZ", 2);
17663
- __publicField(this, "inputX");
17664
- __publicField(this, "inputY");
17665
- __publicField(this, "inputZ");
17666
- this.parent = parent;
17667
- this.callbacks = callbacks;
17668
- this.toolbar = document.createElement("div");
17669
- this.toolbar.className = "volume-select-toolbar";
17670
- this.toolbar.style.cssText = `
17671
- position:absolute; bottom:90px; left:50%; transform:translateX(-50%);
17672
- display:none; z-index:20; background:rgba(30,30,30,0.92);
17673
- border-radius:8px; padding:6px 10px; gap:6px;
17674
- align-items:center; font-size:13px; color:#ddd;
17675
- backdrop-filter:blur(6px); user-select:none; white-space:nowrap;
17676
- box-shadow:0 2px 12px rgba(0,0,0,0.4);
17677
- `;
17678
- this.toolbar.addEventListener("pointerdown", (e) => e.stopPropagation());
17679
- this.toolbar.addEventListener("wheel", (e) => e.stopPropagation());
17680
- const mkBtn = (label, op) => {
17681
- const btn = document.createElement("button");
17682
- btn.textContent = label;
17683
- btn.style.cssText = `
17684
- padding:4px 12px; border:1px solid #555; border-radius:4px;
17685
- background:#333; color:#ddd; cursor:pointer; font-size:13px;
17686
- transition: background 0.15s;
17687
- `;
17688
- btn.addEventListener("mouseenter", () => {
17689
- btn.style.background = "#555";
17690
- });
17691
- btn.addEventListener("mouseleave", () => {
17692
- btn.style.background = "#333";
17693
- });
17694
- btn.addEventListener("pointerdown", (e) => {
17695
- e.stopPropagation();
17696
- this.callbacks.onApply(op);
17697
- });
17698
- return btn;
17699
- };
17700
- const mkInput = (placeholder, initial, onChange) => {
17701
- const wrap = document.createElement("span");
17702
- wrap.style.cssText = "display:inline-flex; align-items:center; gap:2px;";
17703
- const input = document.createElement("input");
17704
- input.type = "number";
17705
- input.min = "1";
17706
- input.step = "1";
17707
- input.value = String(Math.round(initial));
17708
- input.style.cssText = `
17709
- width:40px; padding:3px 4px; border:1px solid #555; border-radius:4px;
17710
- background:#222; color:#ddd; font-size:12px; text-align:center;
17711
- `;
17712
- const label = document.createElement("span");
17713
- label.textContent = placeholder;
17714
- label.style.cssText = "color:#888; font-size:11px;";
17715
- input.addEventListener("change", () => {
17716
- const v = Math.max(1, Math.round(parseFloat(input.value) || 1));
17717
- input.value = String(v);
17718
- onChange(v);
17719
- });
17720
- wrap.appendChild(input);
17721
- wrap.appendChild(label);
17722
- return { wrap, input };
17723
- };
17724
- const setBtn = mkBtn("Set", "set");
17725
- const addBtn = mkBtn("Add", "add");
17726
- const removeBtn = mkBtn("Remove", "remove");
17727
- const xInput = mkInput("LenX", this._lenX, (v) => {
17728
- this._lenX = v;
17729
- this.emitDims();
17730
- });
17731
- const yInput = mkInput("LenY", this._lenY, (v) => {
17732
- this._lenY = v;
17733
- this.emitDims();
17734
- });
17735
- const zInput = mkInput("LenZ", this._lenZ, (v) => {
17736
- this._lenZ = v;
17737
- this.emitDims();
17738
- });
17739
- this.inputX = xInput.input;
17740
- this.inputY = yInput.input;
17741
- this.inputZ = zInput.input;
17742
- this.toolbar.appendChild(setBtn);
17743
- this.toolbar.appendChild(addBtn);
17744
- this.toolbar.appendChild(removeBtn);
17745
- this.toolbar.appendChild(xInput.wrap);
17746
- this.toolbar.appendChild(yInput.wrap);
17747
- this.toolbar.appendChild(zInput.wrap);
17748
- parent.appendChild(this.toolbar);
18114
+ __publicField(this, "_onDimensionsChanged");
18115
+ this._onDimensionsChanged = onDimensionsChanged;
17749
18116
  }
17750
18117
  get lenX() {
17751
18118
  return this._lenX;
@@ -17757,21 +18124,15 @@ class BoxSelection {
17757
18124
  return this._lenZ;
17758
18125
  }
17759
18126
  setDimensions(lenX, lenY, lenZ) {
17760
- this._lenX = Math.round(lenX);
17761
- this._lenY = Math.round(lenY);
17762
- this._lenZ = Math.round(lenZ);
17763
- this.inputX.value = String(this._lenX);
17764
- this.inputY.value = String(this._lenY);
17765
- this.inputZ.value = String(this._lenZ);
18127
+ var _a2;
18128
+ this._lenX = lenX;
18129
+ this._lenY = lenY;
18130
+ this._lenZ = lenZ;
18131
+ (_a2 = this._onDimensionsChanged) == null ? void 0 : _a2.call(this, lenX, lenY, lenZ);
17766
18132
  }
17767
18133
  activate() {
17768
- this.toolbar.style.display = "flex";
17769
18134
  }
17770
18135
  deactivate() {
17771
- this.toolbar.style.display = "none";
17772
- }
17773
- emitDims() {
17774
- this.callbacks.onDimensionsChanged(this._lenX, this._lenY, this._lenZ);
17775
18136
  }
17776
18137
  }
17777
18138
  function exportEditedPLY(positions, scales, rotations, colors, opacities, shCoeffs, state) {
@@ -18278,20 +18639,14 @@ class SplatEditor {
18278
18639
  if (this.gpuRenderer) {
18279
18640
  this.volumeRenderer = new SelectionVolumeRenderer(this.gpuRenderer, this.camera);
18280
18641
  }
18281
- this.sphereTool = new SphereSelection(this.container, {
18282
- onApply: (op) => this.applyVolumeSelection(op),
18283
- onRadiusChanged: (radius) => {
18284
- var _a2;
18285
- (_a2 = this.volumeRenderer) == null ? void 0 : _a2.setDimensions(radius, 0, 0);
18286
- }
18642
+ this.sphereTool = new SphereSelection((radius) => {
18643
+ var _a2;
18644
+ (_a2 = this.volumeRenderer) == null ? void 0 : _a2.setDimensions(radius, 0, 0);
18287
18645
  });
18288
18646
  this.toolManager.register("sphere", this.sphereTool);
18289
- this.boxTool = new BoxSelection(this.container, {
18290
- onApply: (op) => this.applyVolumeSelection(op),
18291
- onDimensionsChanged: (lx, ly, lz) => {
18292
- var _a2;
18293
- (_a2 = this.volumeRenderer) == null ? void 0 : _a2.setDimensions(lx, ly, lz);
18294
- }
18647
+ this.boxTool = new BoxSelection((lx, ly, lz) => {
18648
+ var _a2;
18649
+ (_a2 = this.volumeRenderer) == null ? void 0 : _a2.setDimensions(lx, ly, lz);
18295
18650
  });
18296
18651
  this.toolManager.register("box", this.boxTool);
18297
18652
  this.gsRenderer.setEditorState(this.splatState.data);
@@ -18649,6 +19004,24 @@ class SplatEditor {
18649
19004
  }
18650
19005
  };
18651
19006
  }
19007
+ // ============================================
19008
+ // 体积工具参数 API(供前端 UI 调用)
19009
+ // ============================================
19010
+ setBoxDimensions(lx, ly, lz) {
19011
+ var _a2;
19012
+ (_a2 = this.boxTool) == null ? void 0 : _a2.setDimensions(lx, ly, lz);
19013
+ }
19014
+ getBoxDimensions() {
19015
+ return this.boxTool ? [this.boxTool.lenX, this.boxTool.lenY, this.boxTool.lenZ] : [2, 2, 2];
19016
+ }
19017
+ setSphereRadius(radius) {
19018
+ var _a2;
19019
+ (_a2 = this.sphereTool) == null ? void 0 : _a2.setRadius(radius);
19020
+ }
19021
+ getSphereRadius() {
19022
+ var _a2;
19023
+ return ((_a2 = this.sphereTool) == null ? void 0 : _a2.radius) ?? 1;
19024
+ }
18652
19025
  /**
18653
19026
  * 获取模型世界空间包围盒(中心 + 尺寸)
18654
19027
  */
@@ -18874,6 +19247,10 @@ class App {
18874
19247
  __publicField(this, "animationId", 0);
18875
19248
  // 是否使用移动端渲染器
18876
19249
  __publicField(this, "useMobileRenderer", false);
19250
+ // 缓存移动端检测结果,避免每帧调用
19251
+ __publicField(this, "isMobile", false);
19252
+ // 移动端可见 splat 硬上限(保证稳定帧率)
19253
+ __publicField(this, "mobileMaxVisibleCap", 0);
18877
19254
  // 最近加载的 CompactSplatData(用于编辑器导出)
18878
19255
  __publicField(this, "lastCompactData", null);
18879
19256
  // 自适应性能控制
@@ -18883,6 +19260,17 @@ class App {
18883
19260
  });
18884
19261
  __publicField(this, "lastAppliedRenderScale", 1);
18885
19262
  __publicField(this, "baseRenderScale", 1);
19263
+ // 动态分辨率:移动时降分辨率,静止后恢复
19264
+ __publicField(this, "dynamicResolutionEnabled", false);
19265
+ __publicField(this, "dynResLastViewMatrix", new Float32Array(16));
19266
+ __publicField(this, "dynResStillFrames", 0);
19267
+ __publicField(this, "dynResCurrentScale", 1);
19268
+ __publicField(this, "DYNRES_MOVE_SCALE", 0.6);
19269
+ // 移动时 DPR*0.6(2.0*0.6=1.2)
19270
+ __publicField(this, "DYNRES_STILL_SCALE", 1);
19271
+ // 静止时 DPR*1.0
19272
+ __publicField(this, "DYNRES_STILL_THRESHOLD", 2);
19273
+ // 静止 2 帧即恢复
18886
19274
  // 绑定的事件处理函数
18887
19275
  __publicField(this, "boundOnResize");
18888
19276
  /** 额外渲染回调(在 gizmo 之前、场景辅助之后执行) */
@@ -18894,6 +19282,7 @@ class App {
18894
19282
  * 初始化应用
18895
19283
  */
18896
19284
  async init() {
19285
+ var _a2;
18897
19286
  this.renderer = new Renderer(this.canvas);
18898
19287
  await this.renderer.init();
18899
19288
  this.camera = new Camera();
@@ -18922,6 +19311,14 @@ class App {
18922
19311
  if (this.renderer.isAppleGPU) {
18923
19312
  this.applyAppleGPUDefaults();
18924
19313
  }
19314
+ this.isMobile = isMobileDevice();
19315
+ if (this.isMobile) {
19316
+ createDebugOverlay();
19317
+ (_a2 = getDebugOverlay()) == null ? void 0 : _a2.info(
19318
+ `Mobile detected. DPR=${window.devicePixelRatio}, canvas=${this.canvas.width}x${this.canvas.height}`
19319
+ );
19320
+ this.applyMobileDefaults();
19321
+ }
18925
19322
  }
18926
19323
  /**
18927
19324
  * Apple GPU (M1/M2/M3 等) 自动优化配置
@@ -18936,16 +19333,32 @@ class App {
18936
19333
  nearDepthRangeRatio: 2
18937
19334
  };
18938
19335
  console.log(
18939
- `[3DGS] Apple GPU detected (${this.renderer.gpuVendor}/${this.renderer.gpuArchitecture}), applying TBDR optimizations: SH→L1, depthWrite off, aggressive culling`
19336
+ `[3DGS] Apple GPU detected (${this.renderer.gpuVendor}/${this.renderer.gpuArchitecture}), applying TBDR optimizations: depthWrite off, aggressive culling`
18940
19337
  );
18941
19338
  }
18942
19339
  /**
18943
- * 创建桌面端 GSSplatRenderer 并自动应用平台优化
19340
+ * 移动端自动性能优化
19341
+ * 核心策略:降 DPR + 降分辨率 + 激进剔除 + 降排序频率
19342
+ */
19343
+ applyMobileDefaults() {
19344
+ this.dynamicResolutionEnabled = true;
19345
+ console.log(`[3DGS] Mobile: f16, sortBits=16, pixelThreshold=2.5, DPR=1.5, dynamicRes=ON`);
19346
+ }
19347
+ /**
19348
+ * 创建 GSSplatRenderer 并自动应用平台优化
19349
+ * @param forMobile 移动端模式:半精度(32B/splat)+ SH L0,内存降为 1/8
18944
19350
  */
18945
- createDesktopGSRenderer() {
19351
+ createGSRendererUnified(forMobile = false) {
18946
19352
  const renderer = new GSSplatRenderer(this.renderer, this.camera);
18947
- if (this.renderer.isAppleGPU) {
19353
+ if (forMobile) {
19354
+ renderer.setHalfPrecision(true);
19355
+ renderer.setSortBits(16);
19356
+ renderer.setPixelCullThreshold(1.5);
19357
+ renderer.setSortFrequency(2);
19358
+ } else if (this.renderer.isAppleGPU) {
18948
19359
  renderer.setSHMode(SHMode.L1);
19360
+ }
19361
+ if (this.renderer.isAppleGPU) {
18949
19362
  renderer.setDepthWriteEnabled(false);
18950
19363
  }
18951
19364
  return renderer;
@@ -19015,40 +19428,26 @@ class App {
19015
19428
  onProgress(50 + parseProgress, "parse");
19016
19429
  }
19017
19430
  };
19018
- let gsRenderer;
19019
19431
  if (isMobile) {
19020
- gsRenderer = new GSSplatRendererMobile(this.renderer, this.camera);
19021
- this.useMobileRenderer = true;
19022
- const compactData = await this.parsePLYBuffer(buffer, {
19023
- maxSplats: Infinity,
19024
- loadSH: false,
19025
- onProgress: parseProgressCallback,
19026
- coordinateSystem
19027
- });
19028
- if (onProgress) onProgress(90, "upload");
19029
- gsRenderer.setCompactData(compactData);
19030
- if (onProgress) onProgress(100, "upload");
19031
- this.lastCompactData = compactData;
19032
- this.sceneManager.setGSRenderer(gsRenderer);
19033
- this.hotspotManager.setGSRenderer(gsRenderer);
19034
- return compactData.count;
19035
- } else {
19036
- gsRenderer = this.createDesktopGSRenderer();
19037
- this.useMobileRenderer = false;
19038
- const compactData = await this.parsePLYBuffer(buffer, {
19039
- maxSplats: Infinity,
19040
- loadSH: true,
19041
- onProgress: parseProgressCallback,
19042
- coordinateSystem
19043
- });
19044
- if (onProgress) onProgress(90, "upload");
19045
- gsRenderer.setCompactData(compactData);
19046
- if (onProgress) onProgress(100, "upload");
19047
- this.lastCompactData = compactData;
19048
- this.sceneManager.setGSRenderer(gsRenderer);
19049
- this.hotspotManager.setGSRenderer(gsRenderer);
19050
- return compactData.count;
19432
+ console.log(
19433
+ "[3DGS] Mobile device detected, using unified desktop renderer with SH L0"
19434
+ );
19051
19435
  }
19436
+ const gsRenderer = this.createGSRendererUnified(isMobile);
19437
+ this.useMobileRenderer = false;
19438
+ const compactData = await this.parsePLYBuffer(buffer, {
19439
+ maxSplats: Infinity,
19440
+ loadSH: !isMobile,
19441
+ onProgress: parseProgressCallback,
19442
+ coordinateSystem
19443
+ });
19444
+ if (onProgress) onProgress(90, "upload");
19445
+ gsRenderer.setCompactData(compactData);
19446
+ if (onProgress) onProgress(100, "upload");
19447
+ this.lastCompactData = compactData;
19448
+ this.sceneManager.setGSRenderer(gsRenderer);
19449
+ this.hotspotManager.setGSRenderer(gsRenderer);
19450
+ return compactData.count;
19052
19451
  } catch (error) {
19053
19452
  throw error;
19054
19453
  }
@@ -19090,23 +19489,9 @@ class App {
19090
19489
  }
19091
19490
  if (onProgress) onProgress(90, "parse");
19092
19491
  if (onProgress) onProgress(90, "upload");
19093
- let gsRenderer;
19094
- if (isMobile) {
19095
- const mobileRenderer = new GSSplatRendererMobile(
19096
- this.renderer,
19097
- this.camera
19098
- );
19099
- this.useMobileRenderer = true;
19100
- const compactData = App.splatCpuToCompactData(splats);
19101
- mobileRenderer.setCompactData(compactData);
19102
- this.lastCompactData = compactData;
19103
- gsRenderer = mobileRenderer;
19104
- } else {
19105
- const desktopRenderer = this.createDesktopGSRenderer();
19106
- this.useMobileRenderer = false;
19107
- desktopRenderer.setData(splats);
19108
- gsRenderer = desktopRenderer;
19109
- }
19492
+ const gsRenderer = this.createGSRendererUnified(isMobile);
19493
+ this.useMobileRenderer = false;
19494
+ gsRenderer.setData(splats);
19110
19495
  this.sceneManager.setGSRenderer(gsRenderer);
19111
19496
  this.hotspotManager.setGSRenderer(gsRenderer);
19112
19497
  if (onProgress) onProgress(100, "upload");
@@ -19193,14 +19578,8 @@ class App {
19193
19578
  }
19194
19579
  }
19195
19580
  if (onProgress) onProgress(90, "upload");
19196
- let gsRenderer;
19197
- if (isMobile) {
19198
- gsRenderer = new GSSplatRendererMobile(this.renderer, this.camera);
19199
- this.useMobileRenderer = true;
19200
- } else {
19201
- gsRenderer = this.createDesktopGSRenderer();
19202
- this.useMobileRenderer = false;
19203
- }
19581
+ const gsRenderer = this.createGSRendererUnified(isMobile);
19582
+ this.useMobileRenderer = false;
19204
19583
  gsRenderer.setCompactData(compactData);
19205
19584
  this.lastCompactData = compactData;
19206
19585
  this.sceneManager.setGSRenderer(gsRenderer);
@@ -19251,6 +19630,21 @@ class App {
19251
19630
  this.render();
19252
19631
  this.animationId = requestAnimationFrame(this.animate.bind(this));
19253
19632
  }
19633
+ updateDynamicResolution() {
19634
+ if (!this.dynamicResolutionEnabled) return;
19635
+ const interacting = this.controls.isInteracting;
19636
+ if (interacting) {
19637
+ if (this.dynResCurrentScale !== this.DYNRES_MOVE_SCALE) {
19638
+ this.dynResCurrentScale = this.DYNRES_MOVE_SCALE;
19639
+ this.renderer.setRenderScale(this.DYNRES_MOVE_SCALE);
19640
+ }
19641
+ } else {
19642
+ if (this.dynResCurrentScale !== this.DYNRES_STILL_SCALE) {
19643
+ this.dynResCurrentScale = this.DYNRES_STILL_SCALE;
19644
+ this.renderer.setRenderScale(this.DYNRES_STILL_SCALE);
19645
+ }
19646
+ }
19647
+ }
19254
19648
  updateAdaptivePerformance() {
19255
19649
  if (!this.adaptivePerformanceEnabled) return;
19256
19650
  const gsRenderer = this.getGSRenderer();
@@ -19294,7 +19688,11 @@ class App {
19294
19688
  gsRenderer.setMaxVisibleSplats(0);
19295
19689
  } else {
19296
19690
  const ratio = cfg.nearVisibleRatio + (1 - cfg.nearVisibleRatio) * t;
19297
- gsRenderer.setMaxVisibleSplats(Math.round(splatCount * ratio));
19691
+ let maxVisible = Math.round(splatCount * ratio);
19692
+ if (this.mobileMaxVisibleCap > 0) {
19693
+ maxVisible = Math.min(maxVisible, this.mobileMaxVisibleCap);
19694
+ }
19695
+ gsRenderer.setMaxVisibleSplats(maxVisible);
19298
19696
  }
19299
19697
  if (cfg.enableDepthRangeLimit) {
19300
19698
  if (t >= 0.99) {
@@ -19304,16 +19702,13 @@ class App {
19304
19702
  gsRenderer.setDepthRangeLimit(depthRange);
19305
19703
  }
19306
19704
  }
19307
- if (t < 0.3) {
19308
- gsRenderer.setSortFrequency(2);
19309
- } else {
19310
- gsRenderer.setSortFrequency(1);
19311
- }
19705
+ gsRenderer.setSortFrequency(1);
19312
19706
  }
19313
19707
  render() {
19314
19708
  var _a2, _b2;
19315
19709
  this.camera.setAspect(this.renderer.getAspectRatio());
19316
19710
  this.controls.update();
19711
+ this.updateDynamicResolution();
19317
19712
  this.updateAdaptivePerformance();
19318
19713
  this.hotspotManager.updateBillboards();
19319
19714
  if ((_a2 = this.skyboxRenderer) == null ? void 0 : _a2.isActive) {
@@ -19889,10 +20284,6 @@ class App {
19889
20284
  if (gsRenderer) {
19890
20285
  gsRenderer.setSortFrequency(frequency);
19891
20286
  }
19892
- const mobileRenderer = this.getGSRendererMobile();
19893
- if (mobileRenderer) {
19894
- mobileRenderer.setSortFrequency(frequency);
19895
- }
19896
20287
  }
19897
20288
  /**
19898
20289
  * 是否检测到 Apple GPU(M1/M2/M3 等)