@d5techs/3dgs-lib 1.4.84 → 1.4.86

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 上下文");
@@ -649,6 +791,7 @@ const _OrbitControls = class _OrbitControls {
649
791
  // 键盘移动
650
792
  __publicField(this, "moveSpeed", 0.015);
651
793
  __publicField(this, "pressedKeys", /* @__PURE__ */ new Set());
794
+ __publicField(this, "_wasKeyboardMoving", false);
652
795
  // 触摸手势状态
653
796
  __publicField(this, "touchMode", "none");
654
797
  __publicField(this, "lastTouchDistance", 0);
@@ -685,6 +828,9 @@ const _OrbitControls = class _OrbitControls {
685
828
  this.setupEventListeners();
686
829
  this.applySpherical();
687
830
  }
831
+ get isInteracting() {
832
+ return this.isDragging;
833
+ }
688
834
  setupEventListeners() {
689
835
  this.canvas.addEventListener("mousedown", this.boundOnMouseDown);
690
836
  this.canvas.addEventListener("mousemove", this.boundOnMouseMove);
@@ -822,7 +968,14 @@ const _OrbitControls = class _OrbitControls {
822
968
  this.pressedKeys.delete(e.key.toLowerCase());
823
969
  }
824
970
  applyKeyboardMovement() {
825
- if (this.pressedKeys.size === 0) return;
971
+ if (this.pressedKeys.size === 0) {
972
+ if (this._wasKeyboardMoving) {
973
+ this._wasKeyboardMoving = false;
974
+ this.recenterOrbitTarget();
975
+ }
976
+ return;
977
+ }
978
+ this._wasKeyboardMoving = true;
826
979
  const m = this.camera.viewMatrix;
827
980
  const right = [m[0], m[4], m[8]];
828
981
  const forward = [-m[2], -m[6], -m[10]];
@@ -982,9 +1135,15 @@ const _OrbitControls = class _OrbitControls {
982
1135
  onTouchEnd(e) {
983
1136
  if (e.touches.length === 0) {
984
1137
  this.isDragging = false;
1138
+ if (this.touchMode === "zoom-pan") {
1139
+ this.recenterOrbitTarget();
1140
+ }
985
1141
  this.touchMode = "none";
986
1142
  this.lastTouchDistance = 0;
987
1143
  } else if (e.touches.length === 1) {
1144
+ if (this.touchMode === "zoom-pan") {
1145
+ this.recenterOrbitTarget();
1146
+ }
988
1147
  this.touchMode = "rotate";
989
1148
  this.lastX = e.touches[0].clientX;
990
1149
  this.lastY = e.touches[0].clientY;
@@ -1001,6 +1160,34 @@ const _OrbitControls = class _OrbitControls {
1001
1160
  y: (touches[0].clientY + touches[1].clientY) / 2
1002
1161
  };
1003
1162
  }
1163
+ /**
1164
+ * 将 orbit 目标重新锚定到屏幕中心的模型表面点,
1165
+ * 保持相机世界坐标不变,仅重算 distance/theta/phi。
1166
+ * 用于 WASD 移动或触摸缩放结束后修复旋转中心偏移。
1167
+ */
1168
+ recenterOrbitTarget() {
1169
+ if (!this.pickWorldPosition) return;
1170
+ const rect = this.canvas.getBoundingClientRect();
1171
+ const hit = this.pickWorldPosition(
1172
+ rect.left + rect.width / 2,
1173
+ rect.top + rect.height / 2
1174
+ );
1175
+ if (!hit) return;
1176
+ const dx = this.camera.position[0] - hit[0];
1177
+ const dy = this.camera.position[1] - hit[1];
1178
+ const dz = this.camera.position[2] - hit[2];
1179
+ const newDist = Math.sqrt(dx * dx + dy * dy + dz * dz);
1180
+ if (newDist < this.minDistance) return;
1181
+ this.camera.target[0] = hit[0];
1182
+ this.camera.target[1] = hit[1];
1183
+ this.camera.target[2] = hit[2];
1184
+ this.distance = newDist;
1185
+ this.theta = Math.atan2(dx, dz);
1186
+ this.phi = Math.acos(Math.min(1, Math.max(-1, dy / newDist)));
1187
+ this.deltaPanX = 0;
1188
+ this.deltaPanY = 0;
1189
+ this.deltaPanZ = 0;
1190
+ }
1004
1191
  /**
1005
1192
  * 将球坐标写入相机位置(内部方法,不处理阻尼)
1006
1193
  */
@@ -5229,9 +5416,10 @@ function compactDataToGPUBuffer(data, includeFullSH = false) {
5229
5416
  }
5230
5417
  return buffer;
5231
5418
  } else {
5232
- const buffer = new Float32Array(count * 64);
5419
+ const COMPACT_FLOATS = 16;
5420
+ const buffer = new Float32Array(count * COMPACT_FLOATS);
5233
5421
  for (let i = 0; i < count; i++) {
5234
- const offset = i * 64;
5422
+ const offset = i * COMPACT_FLOATS;
5235
5423
  buffer[offset + 0] = data.positions[i * 3 + 0];
5236
5424
  buffer[offset + 1] = data.positions[i * 3 + 1];
5237
5425
  buffer[offset + 2] = data.positions[i * 3 + 2];
@@ -5252,9 +5440,56 @@ function compactDataToGPUBuffer(data, includeFullSH = false) {
5252
5440
  return buffer;
5253
5441
  }
5254
5442
  }
5443
+ const _f16Scratch = new Float32Array(1);
5444
+ const _f16ScratchU32 = new Uint32Array(_f16Scratch.buffer);
5445
+ function f32ToF16Bits(val) {
5446
+ _f16Scratch[0] = val;
5447
+ const bits2 = _f16ScratchU32[0];
5448
+ const sign = bits2 >>> 31 & 1;
5449
+ let exp = bits2 >>> 23 & 255;
5450
+ let frac = bits2 & 8388607;
5451
+ if (exp === 255) {
5452
+ return sign << 15 | 31744 | (frac ? 512 : 0);
5453
+ }
5454
+ exp = exp - 127 + 15;
5455
+ if (exp >= 31) {
5456
+ return sign << 15 | 31744;
5457
+ }
5458
+ if (exp <= 0) {
5459
+ if (exp < -10) return sign << 15;
5460
+ frac = (frac | 8388608) >> 1 - exp;
5461
+ return sign << 15 | frac >> 13;
5462
+ }
5463
+ return sign << 15 | exp << 10 | frac >> 13;
5464
+ }
5465
+ function compactDataToGPUBufferHalf(data) {
5466
+ const count = data.count;
5467
+ const U32_PER_SPLAT = 8;
5468
+ const buffer = new Uint32Array(count * U32_PER_SPLAT);
5469
+ const pos = data.positions;
5470
+ const scl = data.scales;
5471
+ const rot = data.rotations;
5472
+ const col = data.colors;
5473
+ const opa = data.opacities;
5474
+ for (let i = 0; i < count; i++) {
5475
+ const o = i * U32_PER_SPLAT;
5476
+ const i3 = i * 3;
5477
+ const i4 = i * 4;
5478
+ buffer[o] = f32ToF16Bits(pos[i3 + 1]) << 16 | f32ToF16Bits(pos[i3]);
5479
+ buffer[o + 1] = f32ToF16Bits(pos[i3 + 2]);
5480
+ buffer[o + 2] = f32ToF16Bits(scl[i3 + 1]) << 16 | f32ToF16Bits(scl[i3]);
5481
+ buffer[o + 3] = f32ToF16Bits(opa[i]) << 16 | f32ToF16Bits(scl[i3 + 2]);
5482
+ buffer[o + 4] = f32ToF16Bits(rot[i4 + 1]) << 16 | f32ToF16Bits(rot[i4]);
5483
+ buffer[o + 5] = f32ToF16Bits(rot[i4 + 3]) << 16 | f32ToF16Bits(rot[i4 + 2]);
5484
+ buffer[o + 6] = f32ToF16Bits(col[i3 + 1]) << 16 | f32ToF16Bits(col[i3]);
5485
+ buffer[o + 7] = f32ToF16Bits(col[i3 + 2]);
5486
+ }
5487
+ return buffer;
5488
+ }
5255
5489
  const PLYLoaderMobile = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
5256
5490
  __proto__: null,
5257
5491
  compactDataToGPUBuffer,
5492
+ compactDataToGPUBufferHalf,
5258
5493
  loadPLYMobile,
5259
5494
  parsePLYBuffer,
5260
5495
  transformSHCoeffsYZSwap
@@ -7314,16 +7549,38 @@ const RADIX_BITS = 8;
7314
7549
  const RADIX_SIZE = 256;
7315
7550
  const ELEMENTS_PER_THREAD = 4;
7316
7551
  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 {
7552
+ function generateCullingShaderCode$1(compact = false, half = false, sortBits = 32) {
7553
+ let splatBinding;
7554
+ let splatAccessCode;
7555
+ if (half) {
7556
+ splatBinding = `@group(0) @binding(0) var<storage, read> splatDataU32: array<u32>;`;
7557
+ splatAccessCode = `
7558
+ const HALF_U32S: u32 = 8u;
7559
+ fn getSplatMean(idx: u32) -> vec3<f32> {
7560
+ let b = idx * HALF_U32S;
7561
+ let xy = unpack2x16float(splatDataU32[b]);
7562
+ let zp = unpack2x16float(splatDataU32[b + 1u]);
7563
+ return vec3<f32>(xy.x, xy.y, zp.x);
7564
+ }
7565
+ fn getSplatScale(idx: u32) -> vec3<f32> {
7566
+ let b = idx * HALF_U32S;
7567
+ let xy = unpack2x16float(splatDataU32[b + 2u]);
7568
+ return vec3<f32>(xy.x, xy.y, unpack2x16float(splatDataU32[b + 3u]).x);
7569
+ }
7570
+ fn getSplatOpacity(idx: u32) -> f32 {
7571
+ let b = idx * HALF_U32S;
7572
+ return unpack2x16float(splatDataU32[b + 3u]).y;
7573
+ }`;
7574
+ } else {
7575
+ const splatStruct = compact ? `struct Splat {
7576
+ mean: vec3<f32>,
7577
+ _pad0: f32,
7578
+ scale: vec3<f32>,
7579
+ _pad1: f32,
7580
+ rotation: vec4<f32>,
7581
+ colorDC: vec3<f32>,
7582
+ opacity: f32,
7583
+ }` : `struct Splat {
7327
7584
  mean: vec3<f32>,
7328
7585
  _pad0: f32,
7329
7586
  scale: vec3<f32>,
@@ -7335,7 +7592,24 @@ struct Splat {
7335
7592
  sh2: array<f32, 15>,
7336
7593
  sh3: array<f32, 21>,
7337
7594
  _pad2: array<f32, 3>,
7338
- }
7595
+ }`;
7596
+ splatBinding = `${splatStruct}
7597
+ @group(0) @binding(0) var<storage, read> splats: array<Splat>;`;
7598
+ splatAccessCode = `
7599
+ fn getSplatMean(idx: u32) -> vec3<f32> { return splats[idx].mean; }
7600
+ fn getSplatScale(idx: u32) -> vec3<f32> { return splats[idx].scale; }
7601
+ fn getSplatOpacity(idx: u32) -> f32 { return splats[idx].opacity; }`;
7602
+ }
7603
+ return (
7604
+ /* wgsl */
7605
+ `
7606
+ /**
7607
+ * Project & Cull Shader
7608
+ * 基于 rfs-gsplat-render 实现
7609
+ */
7610
+
7611
+ ${splatBinding}
7612
+ ${splatAccessCode}
7339
7613
 
7340
7614
  struct CameraUniforms {
7341
7615
  view: mat4x4<f32>,
@@ -7355,10 +7629,9 @@ struct CullingParams {
7355
7629
  pixelThreshold: f32,
7356
7630
  maxVisibleCount: u32,
7357
7631
  depthRangeLimit: f32,
7358
- _pad_cull: f32,
7632
+ lodSkipRate: f32,
7359
7633
  }
7360
7634
 
7361
- @group(0) @binding(0) var<storage, read> splats: array<Splat>;
7362
7635
  @group(0) @binding(1) var<uniform> camera: CameraUniforms;
7363
7636
  @group(0) @binding(2) var<uniform> params: CullingParams;
7364
7637
  @group(0) @binding(3) var<storage, read_write> depthKeys: array<u32>;
@@ -7376,16 +7649,12 @@ fn getModelMaxScale(model: mat4x4<f32>) -> f32 {
7376
7649
  return max(max(sx, sy), sz);
7377
7650
  }
7378
7651
 
7379
- // IEEE 754 位操作编码浮点数为可排序的 u32
7380
- // 参考 rfs-gsplat-render 的 encode_min_max_fp32 实现
7381
7652
  fn encodeDepthKey(val: f32) -> u32 {
7382
7653
  var bits = bitcast<u32>(val);
7383
7654
  bits ^= bitcast<u32>(bitcast<i32>(bits) >> 31) | 0x80000000u;
7384
- return bits;
7655
+ return bits >> ${sortBits === 16 ? "16u" : "0u"};
7385
7656
  }
7386
7657
 
7387
- // 视锥剔除检查
7388
- // 基于 rfs-gsplat-render 的 is_in_frustum 实现
7389
7658
  fn isInFrustum(clipPos: vec4<f32>, frustumDilation: f32) -> bool {
7390
7659
  let clip = (1.0 + frustumDilation) * clipPos.w;
7391
7660
 
@@ -7404,43 +7673,47 @@ fn isInFrustum(clipPos: vec4<f32>, frustumDilation: f32) -> bool {
7404
7673
  fn projectAndCull(@builtin(global_invocation_id) gid: vec3<u32>) {
7405
7674
  let i = gid.x;
7406
7675
  if i >= params.splatCount { return; }
7676
+
7677
+ // LOD 抽稀放最前面:跳过的 splat 不做任何数据加载和矩阵运算
7678
+ // 哈希仅依赖 splat index → 被跳过的集合永远不变 → 零闪烁
7679
+ if params.lodSkipRate > 0.0 {
7680
+ let hash = ((i * 2654435761u) >> 16u) & 0xFFFFu;
7681
+ if f32(hash) < params.lodSkipRate * 65535.0 {
7682
+ return;
7683
+ }
7684
+ }
7407
7685
 
7408
- let splat = splats[i];
7686
+ let splatMean = getSplatMean(i);
7687
+ let splatScale = getSplatScale(i);
7688
+ let splatOpacity = getSplatOpacity(i);
7409
7689
 
7410
- // 透明度剔除
7411
- if splat.opacity < 0.004 { return; }
7690
+ if splatOpacity < 0.004 { return; }
7412
7691
 
7413
- // 变换: Local -> World -> View -> Clip
7414
- let worldPos = camera.model * vec4<f32>(splat.mean, 1.0);
7692
+ let worldPos = camera.model * vec4<f32>(splatMean, 1.0);
7415
7693
  let viewPos = camera.view * worldPos;
7416
7694
  let clipPos = camera.proj * viewPos;
7417
7695
 
7418
- // 视锥剔除
7419
7696
  if !isInFrustum(clipPos, params.frustumDilation) { return; }
7420
7697
 
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
- }
7698
+ let splatSigma = maxScale(splatScale) * getModelMaxScale(camera.model);
7699
+ let focalY = abs(camera.proj[1][1]) * params.screenHeight * 0.5;
7700
+ let projectedExtent = splatSigma * 3.0 * focalY / max(abs(viewPos.z), 0.001);
7701
+
7702
+ // 剔除投影尺寸过大的 splat(远离模型时去除周围遮挡物)
7703
+ if projectedExtent > params.screenHeight * 0.5 { return; }
7704
+
7705
+ // 剔除投影尺寸过小的 splat
7706
+ if params.pixelThreshold > 0.0 && projectedExtent < params.pixelThreshold { return; }
7429
7707
 
7430
- // 深度范围限制:只渲染距相机一定深度范围内的 splat
7431
7708
  if params.depthRangeLimit > 0.0 {
7432
7709
  if abs(viewPos.z) > params.depthRangeLimit { return; }
7433
7710
  }
7434
7711
 
7435
- // 深度编码 (viewPos.z 是负数)
7436
7712
  let depth = viewPos.z;
7437
7713
  let sortableDepth = encodeDepthKey(depth);
7438
7714
 
7439
- // 原子增加可见计数并获取索引
7440
- // indirectBuffer[1] 是 instance_count
7441
7715
  let visibleIdx = atomicAdd(&indirectBuffer[1], 1u);
7442
7716
 
7443
- // 写入可见点列表
7444
7717
  depthKeys[visibleIdx] = sortableDepth;
7445
7718
  visibleIndices[visibleIdx] = i;
7446
7719
  }
@@ -7755,7 +8028,7 @@ fn downsweep(
7755
8028
  );
7756
8029
  }
7757
8030
  class GSSplatSorter {
7758
- constructor(device, splatCount, splatBuffer, cameraBuffer, _options = {}) {
8031
+ constructor(device, splatCount, splatBuffer, cameraBuffer, _options = {}, compact = false, half = false, sortBits = 32) {
7759
8032
  __publicField(this, "device");
7760
8033
  __publicField(this, "splatCount");
7761
8034
  // Culling Buffers
@@ -7785,11 +8058,12 @@ class GSSplatSorter {
7785
8058
  __publicField(this, "upsweepBindGroupLayout");
7786
8059
  __publicField(this, "spineBindGroupLayout");
7787
8060
  __publicField(this, "downsweepBindGroupLayout");
7788
- // Bind groups for each pass (4 passes)
8061
+ // Bind groups for each pass
7789
8062
  __publicField(this, "upsweepBindGroups", []);
7790
8063
  __publicField(this, "spineBindGroups", []);
7791
8064
  __publicField(this, "downsweepBindGroups", []);
7792
8065
  __publicField(this, "numPartitions");
8066
+ __publicField(this, "numSortPasses");
7793
8067
  // 屏幕信息和剔除选项
7794
8068
  __publicField(this, "screenWidth", 1920);
7795
8069
  __publicField(this, "screenHeight", 1080);
@@ -7802,14 +8076,19 @@ class GSSplatSorter {
7802
8076
  this.device = device;
7803
8077
  this.splatCount = splatCount;
7804
8078
  this.numPartitions = Math.ceil(splatCount / BLOCK_SIZE);
8079
+ this.numSortPasses = sortBits / RADIX_BITS;
8080
+ const dbg = getDebugOverlay();
8081
+ dbg == null ? void 0 : dbg.info(`Sorter init: ${splatCount} splats, compact=${compact}, half=${half}, sortBits=${sortBits}`);
7805
8082
  const cullingModule = device.createShaderModule({
7806
- code: generateCullingShaderCode$1(),
8083
+ code: generateCullingShaderCode$1(compact, half, sortBits),
7807
8084
  label: "culling-shader"
7808
8085
  });
8086
+ dbg == null ? void 0 : dbg.checkShader(cullingModule, "culling");
7809
8087
  const radixSortModule = device.createShaderModule({
7810
8088
  code: generateRadixSortShaderCode(),
7811
8089
  label: "radix-sort-shader"
7812
8090
  });
8091
+ dbg == null ? void 0 : dbg.checkShader(radixSortModule, "radix-sort");
7813
8092
  this.cullingParamsBuffer = device.createBuffer({
7814
8093
  size: 48,
7815
8094
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
@@ -7827,12 +8106,11 @@ class GSSplatSorter {
7827
8106
  });
7828
8107
  this.indirectBuffer = device.createBuffer({
7829
8108
  size: 16,
7830
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_DST,
8109
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
7831
8110
  label: "indirect-buffer"
7832
8111
  });
7833
8112
  this.globalHistogramBuffer = device.createBuffer({
7834
- size: RADIX_SIZE * 4 * 4,
7835
- // 4 passes * 256 bins * 4 bytes
8113
+ size: RADIX_SIZE * this.numSortPasses * 4,
7836
8114
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
7837
8115
  label: "global-histogram"
7838
8116
  });
@@ -7851,7 +8129,7 @@ class GSSplatSorter {
7851
8129
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
7852
8130
  label: "values-temp"
7853
8131
  });
7854
- for (let i = 0; i < 4; i++) {
8132
+ for (let i = 0; i < this.numSortPasses; i++) {
7855
8133
  const paramsBuffer = device.createBuffer({
7856
8134
  size: 16,
7857
8135
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
@@ -7963,16 +8241,12 @@ class GSSplatSorter {
7963
8241
  }
7964
8242
  /**
7965
8243
  * 创建 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
8244
+ * numSortPasses 个 pass,使用 ping-pong buffers
8245
+ * 最后一个 pass 的 values 输出到 sortedIndicesBuffer
7973
8246
  */
7974
8247
  createRadixSortBindGroups() {
7975
- for (let passIdx = 0; passIdx < 4; passIdx++) {
8248
+ const lastPassIdx = this.numSortPasses - 1;
8249
+ for (let passIdx = 0; passIdx < this.numSortPasses; passIdx++) {
7976
8250
  const isEvenPass = passIdx % 2 === 0;
7977
8251
  const keysIn = isEvenPass ? this.depthKeysBuffer : this.keysTempBuffer;
7978
8252
  const valuesIn = isEvenPass ? this.visibleIndicesBuffer : this.valuesTempBuffer;
@@ -7980,10 +8254,10 @@ class GSSplatSorter {
7980
8254
  let valuesOut;
7981
8255
  if (isEvenPass) {
7982
8256
  keysOut = this.keysTempBuffer;
7983
- valuesOut = this.valuesTempBuffer;
8257
+ valuesOut = passIdx === lastPassIdx ? this.sortedIndicesBuffer : this.valuesTempBuffer;
7984
8258
  } else {
7985
8259
  keysOut = this.depthKeysBuffer;
7986
- valuesOut = passIdx === 3 ? this.sortedIndicesBuffer : this.visibleIndicesBuffer;
8260
+ valuesOut = passIdx === lastPassIdx ? this.sortedIndicesBuffer : this.visibleIndicesBuffer;
7987
8261
  }
7988
8262
  this.upsweepBindGroups[passIdx] = this.device.createBindGroup({
7989
8263
  layout: this.upsweepBindGroupLayout,
@@ -8051,16 +8325,15 @@ class GSSplatSorter {
8051
8325
  view.setFloat32(24, this.cullingOptions.pixelThreshold, true);
8052
8326
  view.setUint32(28, this.cullingOptions.maxVisibleCount ?? 0, true);
8053
8327
  view.setFloat32(32, this.cullingOptions.depthRangeLimit ?? 0, true);
8328
+ view.setFloat32(36, this.cullingOptions.lodSkipRate ?? 0, true);
8054
8329
  this.device.queue.writeBuffer(this.cullingParamsBuffer, 0, cullingParamsData);
8330
+ this.device.queue.writeBuffer(
8331
+ this.indirectBuffer,
8332
+ 0,
8333
+ new Uint32Array([4, 0, 0, 0])
8334
+ );
8055
8335
  const encoder = this.device.createCommandEncoder({ label: "splat-sort-encoder" });
8056
8336
  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
8337
  {
8065
8338
  const pass = encoder.beginComputePass({ label: "project-cull" });
8066
8339
  pass.setPipeline(this.projectCullPipeline);
@@ -8068,7 +8341,7 @@ class GSSplatSorter {
8068
8341
  pass.dispatchWorkgroups(Math.ceil(this.splatCount / WORKGROUP_SIZE$1));
8069
8342
  pass.end();
8070
8343
  }
8071
- for (let passIdx = 0; passIdx < 4; passIdx++) {
8344
+ for (let passIdx = 0; passIdx < this.numSortPasses; passIdx++) {
8072
8345
  {
8073
8346
  const pass = encoder.beginComputePass({ label: `upsweep-p${passIdx}` });
8074
8347
  pass.setPipeline(this.upsweepPipeline);
@@ -8112,6 +8385,24 @@ class GSSplatSorter {
8112
8385
  getDrawIndirectBuffer() {
8113
8386
  return this.indirectBuffer;
8114
8387
  }
8388
+ /**
8389
+ * 异步读回 drawIndirect buffer 内容(调试用)
8390
+ * 返回 [vertexCount, instanceCount, firstVertex, firstInstance]
8391
+ */
8392
+ async readbackIndirect() {
8393
+ const staging = this.device.createBuffer({
8394
+ size: 16,
8395
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
8396
+ });
8397
+ const encoder = this.device.createCommandEncoder();
8398
+ encoder.copyBufferToBuffer(this.indirectBuffer, 0, staging, 0, 16);
8399
+ this.device.queue.submit([encoder.finish()]);
8400
+ await staging.mapAsync(GPUMapMode.READ);
8401
+ const result = new Uint32Array(staging.getMappedRange().slice(0));
8402
+ staging.unmap();
8403
+ staging.destroy();
8404
+ return result;
8405
+ }
8115
8406
  /**
8116
8407
  * 获取 splat 总数量
8117
8408
  */
@@ -8359,10 +8650,6 @@ fn computeExtentBasisAA(cov2dIn: vec3<f32>, opacity: f32, viewportSize: vec2<f32
8359
8650
  // 使用基于视口的最大限制 (匹配 PlayCanvas)
8360
8651
  let vmin = min(1024.0, min(viewportSize.x, viewportSize.y));
8361
8652
 
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
8653
  let l1 = min(2.0 * sqrt(2.0 * lambda1), vmin);
8367
8654
  let l2 = min(2.0 * sqrt(2.0 * lambda2), vmin);
8368
8655
 
@@ -8379,7 +8666,6 @@ fn computeExtentBasisAA(cov2dIn: vec3<f32>, opacity: f32, viewportSize: vec2<f32
8379
8666
  let eigenvector1 = diagVec;
8380
8667
  let eigenvector2 = vec2<f32>(diagVec.y, -diagVec.x);
8381
8668
 
8382
- // 计算基向量 (不应用额外的 splat_scale,因为我们使用默认值 1.0)
8383
8669
  result.basis = vec4<f32>(eigenvector1 * l1, eigenvector2 * l2);
8384
8670
  result.adjustedOpacity = alpha;
8385
8671
  return result;
@@ -8773,7 +9059,79 @@ fn fs_depth_normal(input: VertexOutput) -> FragOutput {
8773
9059
  }
8774
9060
  `
8775
9061
  );
9062
+ const SPLAT_BYTE_SIZE = 256;
8776
9063
  const SPLAT_FLOAT_COUNT = 64;
9064
+ const COMPACT_SPLAT_BYTE_SIZE = 64;
9065
+ const COMPACT_SPLAT_FLOAT_COUNT = 16;
9066
+ const HALF_SPLAT_BYTE_SIZE = 32;
9067
+ const HALF_SPLAT_U32_COUNT = 8;
9068
+ function transformShaderForCompact(code) {
9069
+ code = code.replace(
9070
+ /struct Splat \{[\s\S]*?\n\}/,
9071
+ `struct Splat {
9072
+ mean: vec3<f32>, _pad0: f32,
9073
+ scale: vec3<f32>, _pad1: f32,
9074
+ rotation: vec4<f32>,
9075
+ colorDC: vec3<f32>,
9076
+ opacity: f32,
9077
+ }`
9078
+ );
9079
+ code = code.replace(
9080
+ /fn evalSH\(splat: Splat, dir: vec3<f32>\) -> vec3<f32> \{[\s\S]*?\n\}/,
9081
+ `fn evalSH(splat: Splat, dir: vec3<f32>) -> vec3<f32> {
9082
+ return vec3<f32>(0.0);
9083
+ }`
9084
+ );
9085
+ return code;
9086
+ }
9087
+ function transformShaderForHalf(code) {
9088
+ code = code.replace(
9089
+ /struct Splat \{[\s\S]*?\n\}/,
9090
+ `struct Splat {
9091
+ mean: vec3<f32>, _pad0: f32,
9092
+ scale: vec3<f32>, _pad1: f32,
9093
+ rotation: vec4<f32>,
9094
+ colorDC: vec3<f32>,
9095
+ opacity: f32,
9096
+ }`
9097
+ );
9098
+ code = code.replace(
9099
+ /@group\(0\)\s*@binding\((\d+)\)\s*var<storage,\s*read>\s*splats\s*:\s*array<Splat>/,
9100
+ `@group(0) @binding($1) var<storage, read> splatDataU32: array<u32>`
9101
+ );
9102
+ const unpackFunctions = `
9103
+ const HALF_U32S: u32 = 8u;
9104
+ fn unpackSplatFromHalf(idx: u32) -> Splat {
9105
+ let b = idx * HALF_U32S;
9106
+ let mean_xy = unpack2x16float(splatDataU32[b]);
9107
+ let mean_zp = unpack2x16float(splatDataU32[b + 1u]);
9108
+ let sc_xy = unpack2x16float(splatDataU32[b + 2u]);
9109
+ let sc_z_op = unpack2x16float(splatDataU32[b + 3u]);
9110
+ let r_xy = unpack2x16float(splatDataU32[b + 4u]);
9111
+ let r_zw = unpack2x16float(splatDataU32[b + 5u]);
9112
+ let c_rg = unpack2x16float(splatDataU32[b + 6u]);
9113
+ let c_bp = unpack2x16float(splatDataU32[b + 7u]);
9114
+ var s: Splat;
9115
+ s.mean = vec3<f32>(mean_xy.x, mean_xy.y, mean_zp.x);
9116
+ s._pad0 = 0.0;
9117
+ s.scale = vec3<f32>(sc_xy.x, sc_xy.y, sc_z_op.x);
9118
+ s._pad1 = 0.0;
9119
+ s.rotation = vec4<f32>(r_xy.x, r_xy.y, r_zw.x, r_zw.y);
9120
+ s.colorDC = vec3<f32>(c_rg.x, c_rg.y, c_bp.x);
9121
+ s.opacity = sc_z_op.y;
9122
+ return s;
9123
+ }
9124
+ `;
9125
+ code = code.replace(/(@vertex|@compute)/, unpackFunctions + "$1");
9126
+ code = code.replace(/splats\[([^\]]+)\]/g, "unpackSplatFromHalf($1)");
9127
+ code = code.replace(
9128
+ /fn evalSH\(splat: Splat, dir: vec3<f32>\) -> vec3<f32> \{[\s\S]*?\n\}/,
9129
+ `fn evalSH(splat: Splat, dir: vec3<f32>) -> vec3<f32> {
9130
+ return vec3<f32>(0.0);
9131
+ }`
9132
+ );
9133
+ return code;
9134
+ }
8777
9135
  const _GSSplatRenderer = class _GSSplatRenderer {
8778
9136
  constructor(renderer, camera) {
8779
9137
  __publicField(this, "renderer");
@@ -8787,6 +9145,12 @@ const _GSSplatRenderer = class _GSSplatRenderer {
8787
9145
  __publicField(this, "sorter", null);
8788
9146
  __publicField(this, "shMode", SHMode.L3);
8789
9147
  __publicField(this, "boundingBox", null);
9148
+ /** 紧凑布局模式:64B/splat,无 SH,适合移动端 */
9149
+ __publicField(this, "compactLayout", false);
9150
+ /** 半精度模式:32B/splat,所有数据用 f16 打包 */
9151
+ __publicField(this, "halfPrecision", false);
9152
+ /** 排序位宽:16-bit 排序减少一半 radix sort pass */
9153
+ __publicField(this, "sortBits", 32);
8790
9154
  // 预分配 uniform 上传缓冲区,避免每帧 GC(56 floats = 224 bytes)
8791
9155
  __publicField(this, "uniformData", new Float32Array(56));
8792
9156
  __publicField(this, "cpuPositions", null);
@@ -8800,6 +9164,7 @@ const _GSSplatRenderer = class _GSSplatRenderer {
8800
9164
  __publicField(this, "pixelCullThreshold", 1);
8801
9165
  __publicField(this, "maxVisibleSplats", 0);
8802
9166
  __publicField(this, "depthRangeLimit", 0);
9167
+ __publicField(this, "lodSkipRate", 0);
8803
9168
  // 排序优化:相机变化检测 + 频率控制
8804
9169
  __publicField(this, "lastSortViewMatrix", new Float32Array(16));
8805
9170
  __publicField(this, "lastSortProjMatrix", new Float32Array(16));
@@ -8812,6 +9177,7 @@ const _GSSplatRenderer = class _GSSplatRenderer {
8812
9177
  __publicField(this, "sortStateInitialized", false);
8813
9178
  __publicField(this, "sortFrequency", 1);
8814
9179
  __publicField(this, "frameCounter", 0);
9180
+ __publicField(this, "debugFrameLogged", false);
8815
9181
  // 编辑器状态
8816
9182
  __publicField(this, "editorStateBuffer", null);
8817
9183
  __publicField(this, "editorPipeline", null);
@@ -8836,15 +9202,56 @@ const _GSSplatRenderer = class _GSSplatRenderer {
8836
9202
  this.createUniformBuffer();
8837
9203
  this.updateModelMatrix();
8838
9204
  }
9205
+ /**
9206
+ * 启用/禁用紧凑布局模式(64B/splat,无 SH)。
9207
+ * 必须在 setCompactData / setData 之前调用。
9208
+ */
9209
+ setCompactLayout(compact) {
9210
+ if (this.compactLayout === compact) return;
9211
+ this.compactLayout = compact;
9212
+ if (compact) {
9213
+ this.shMode = SHMode.L0;
9214
+ }
9215
+ this.createPipeline();
9216
+ }
9217
+ /**
9218
+ * 设置排序位宽。16-bit 模式排序 pass 从 4 降到 2,排序速度翻倍。
9219
+ * 必须在 setData / setCompactData 之前调用。
9220
+ */
9221
+ setSortBits(bits2) {
9222
+ this.sortBits = bits2;
9223
+ }
9224
+ /**
9225
+ * 启用/禁用半精度模式(32B/splat,所有数据 f16 打包)。
9226
+ * 自动启用 compactLayout。必须在 setCompactData 之前调用。
9227
+ */
9228
+ setHalfPrecision(half) {
9229
+ if (this.halfPrecision === half) return;
9230
+ this.halfPrecision = half;
9231
+ if (half) {
9232
+ this.compactLayout = true;
9233
+ this.shMode = SHMode.L0;
9234
+ }
9235
+ this.createPipeline();
9236
+ }
8839
9237
  createPipeline() {
8840
9238
  const device = this.renderer.device;
8841
- const shaderCode = gsOptimizedShader.replace(
9239
+ const dbg = getDebugOverlay();
9240
+ let shaderCode = gsOptimizedShader.replace(
8842
9241
  "const SH_LEVEL: u32 = 3u; // @SH_LEVEL_INJECT@",
8843
9242
  `const SH_LEVEL: u32 = ${this.shMode}u;`
8844
9243
  );
9244
+ if (this.halfPrecision) {
9245
+ shaderCode = transformShaderForHalf(shaderCode);
9246
+ } else if (this.compactLayout) {
9247
+ shaderCode = transformShaderForCompact(shaderCode);
9248
+ }
9249
+ dbg == null ? void 0 : dbg.info(`createPipeline: SH=${this.shMode}, compact=${this.compactLayout}, half=${this.halfPrecision}`);
8845
9250
  const shaderModule = device.createShaderModule({
8846
- code: shaderCode
9251
+ code: shaderCode,
9252
+ label: "gs-main-shader"
8847
9253
  });
9254
+ dbg == null ? void 0 : dbg.checkShader(shaderModule, "gs-main");
8848
9255
  this.bindGroupLayout = device.createBindGroupLayout({
8849
9256
  entries: [
8850
9257
  {
@@ -8921,8 +9328,12 @@ const _GSSplatRenderer = class _GSSplatRenderer {
8921
9328
  // A采用"zero add one-minus-src-alpha"的混合模式计算Transmittance
8922
9329
  createDepthNormalPipeline() {
8923
9330
  const device = this.renderer.device;
9331
+ let dnShader = gsDepthNormalShader;
9332
+ if (this.compactLayout) {
9333
+ dnShader = transformShaderForCompact(dnShader);
9334
+ }
8924
9335
  const shaderModule = device.createShaderModule({
8925
- code: gsDepthNormalShader
9336
+ code: dnShader
8926
9337
  });
8927
9338
  const pipelineLayout = device.createPipelineLayout({
8928
9339
  bindGroupLayouts: [this.bindGroupLayout]
@@ -9101,6 +9512,17 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9101
9512
  getDepthRangeLimit() {
9102
9513
  return this.depthRangeLimit;
9103
9514
  }
9515
+ /**
9516
+ * 设置 LOD 抽稀率(0~1)
9517
+ * 远处 splat 按此比例随机跳过(确定性哈希,无闪烁)
9518
+ * 0 = 不抽稀
9519
+ */
9520
+ setLodSkipRate(rate) {
9521
+ this.lodSkipRate = Math.max(0, Math.min(1, rate));
9522
+ }
9523
+ getLodSkipRate() {
9524
+ return this.lodSkipRate;
9525
+ }
9104
9526
  /**
9105
9527
  * 设置排序频率
9106
9528
  * 1 = 每帧排序(默认),2 = 每 2 帧排序一次,以此类推
@@ -9143,6 +9565,7 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9143
9565
  }
9144
9566
  setData(splats) {
9145
9567
  const device = this.renderer.device;
9568
+ const dbg = getDebugOverlay();
9146
9569
  if (this.splatBuffer) {
9147
9570
  this.splatBuffer.destroy();
9148
9571
  }
@@ -9157,12 +9580,24 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9157
9580
  this.boundingBox = null;
9158
9581
  return;
9159
9582
  }
9583
+ const floatsPerSplat = this.compactLayout ? COMPACT_SPLAT_FLOAT_COUNT : SPLAT_FLOAT_COUNT;
9584
+ const bytesPerSplat = floatsPerSplat * 4;
9585
+ const maxStorageSize = device.limits.maxStorageBufferBindingSize;
9586
+ const maxSplatsForGPU = Math.floor(maxStorageSize / bytesPerSplat);
9587
+ if (this.splatCount > maxSplatsForGPU) {
9588
+ dbg == null ? void 0 : dbg.warn(
9589
+ `setData: truncating ${this.splatCount} -> ${maxSplatsForGPU} splats (maxStorageBufferBindingSize=${(maxStorageSize / 1048576).toFixed(0)}MB)`
9590
+ );
9591
+ this.splatCount = maxSplatsForGPU;
9592
+ splats = splats.slice(0, this.splatCount);
9593
+ }
9594
+ dbg == null ? void 0 : dbg.info(`setData: ${this.splatCount} splats, compact=${this.compactLayout}`);
9160
9595
  this.boundingBox = this.computeBoundingBox(splats);
9161
9596
  const positions = new Float32Array(this.splatCount * 3);
9162
- const data = new Float32Array(this.splatCount * SPLAT_FLOAT_COUNT);
9597
+ const data = new Float32Array(this.splatCount * floatsPerSplat);
9163
9598
  for (let i = 0; i < this.splatCount; i++) {
9164
9599
  const splat = splats[i];
9165
- const offset = i * SPLAT_FLOAT_COUNT;
9600
+ const offset = i * floatsPerSplat;
9166
9601
  positions[i * 3 + 0] = splat.mean[0];
9167
9602
  positions[i * 3 + 1] = splat.mean[1];
9168
9603
  positions[i * 3 + 2] = splat.mean[2];
@@ -9182,31 +9617,39 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9182
9617
  data[offset + 13] = splat.colorDC[1];
9183
9618
  data[offset + 14] = splat.colorDC[2];
9184
9619
  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;
9620
+ if (!this.compactLayout) {
9621
+ const shRest = splat.shRest;
9622
+ for (let j = 0; j < 9; j++) {
9623
+ data[offset + 16 + j] = shRest ? shRest[j] : 0;
9624
+ }
9625
+ for (let j = 0; j < 15; j++) {
9626
+ data[offset + 25 + j] = shRest ? shRest[9 + j] : 0;
9627
+ }
9628
+ for (let j = 0; j < 21; j++) {
9629
+ data[offset + 40 + j] = shRest ? shRest[24 + j] : 0;
9630
+ }
9631
+ data[offset + 61] = 0;
9632
+ data[offset + 62] = 0;
9633
+ data[offset + 63] = 0;
9194
9634
  }
9195
- data[offset + 61] = 0;
9196
- data[offset + 62] = 0;
9197
- data[offset + 63] = 0;
9198
9635
  }
9636
+ dbg == null ? void 0 : dbg.info(`splatBuffer: ${(data.byteLength / 1048576).toFixed(1)}MB (${floatsPerSplat} floats/splat)`);
9199
9637
  this.splatBuffer = device.createBuffer({
9200
9638
  size: data.byteLength,
9201
9639
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
9202
9640
  });
9203
9641
  device.queue.writeBuffer(this.splatBuffer, 0, data);
9204
9642
  this.cpuPositions = positions;
9643
+ dbg == null ? void 0 : dbg.info(`Creating sorter: compact=${this.compactLayout}, sortBits=${this.sortBits}`);
9205
9644
  this.sorter = new GSSplatSorter(
9206
9645
  device,
9207
9646
  this.splatCount,
9208
9647
  this.splatBuffer,
9209
- this.uniformBuffer
9648
+ this.uniformBuffer,
9649
+ {},
9650
+ this.compactLayout,
9651
+ false,
9652
+ this.sortBits
9210
9653
  );
9211
9654
  this.sorter.setScreenSize(this.renderer.width, this.renderer.height);
9212
9655
  this.sorter.setCullingOptions({
@@ -9222,10 +9665,12 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9222
9665
  { binding: 2, resource: { buffer: this.sorter.getIndicesBuffer() } }
9223
9666
  ]
9224
9667
  });
9668
+ dbg == null ? void 0 : dbg.info(`setData complete, bindGroup created`);
9225
9669
  if (this.editorEnabled) this.rebuildEditorBindGroup();
9226
9670
  }
9227
9671
  setCompactData(compactData) {
9228
9672
  const device = this.renderer.device;
9673
+ const dbg = getDebugOverlay();
9229
9674
  if (this.splatBuffer) {
9230
9675
  this.splatBuffer.destroy();
9231
9676
  }
@@ -9240,20 +9685,98 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9240
9685
  this.boundingBox = null;
9241
9686
  return;
9242
9687
  }
9688
+ const bytesPerSplat = this.halfPrecision ? HALF_SPLAT_BYTE_SIZE : this.compactLayout ? COMPACT_SPLAT_BYTE_SIZE : SPLAT_BYTE_SIZE;
9689
+ const maxStorageSize = device.limits.maxStorageBufferBindingSize;
9690
+ const maxSplatsForGPU = Math.floor(maxStorageSize / bytesPerSplat);
9691
+ dbg == null ? void 0 : dbg.info(
9692
+ `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}`
9693
+ );
9694
+ if (this.splatCount > maxSplatsForGPU) {
9695
+ dbg == null ? void 0 : dbg.warn(
9696
+ `Splat count ${this.splatCount} exceeds GPU maxStorageBufferBindingSize (${(maxStorageSize / 1048576).toFixed(0)}MB = ${maxSplatsForGPU} splats). Truncating to ${maxSplatsForGPU}.`
9697
+ );
9698
+ this.splatCount = maxSplatsForGPU;
9699
+ }
9243
9700
  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);
9701
+ let trimmedData;
9702
+ if (this.halfPrecision && this.lodSkipRate > 0) {
9703
+ const skipRate = this.lodSkipRate;
9704
+ const threshold = skipRate * 65535;
9705
+ const kept = [];
9706
+ for (let i = 0; i < this.splatCount; i++) {
9707
+ const hash = (i * 2654435761 | 0) >>> 16 & 65535;
9708
+ if (hash >= threshold) kept.push(i);
9709
+ }
9710
+ const n = kept.length;
9711
+ const pos = new Float32Array(n * 3);
9712
+ const scl = new Float32Array(n * 3);
9713
+ const rot = new Float32Array(n * 4);
9714
+ const col = new Float32Array(n * 3);
9715
+ const opa = new Float32Array(n);
9716
+ for (let k = 0; k < n; k++) {
9717
+ const i = kept[k];
9718
+ pos[k * 3] = compactData.positions[i * 3];
9719
+ pos[k * 3 + 1] = compactData.positions[i * 3 + 1];
9720
+ pos[k * 3 + 2] = compactData.positions[i * 3 + 2];
9721
+ scl[k * 3] = compactData.scales[i * 3];
9722
+ scl[k * 3 + 1] = compactData.scales[i * 3 + 1];
9723
+ scl[k * 3 + 2] = compactData.scales[i * 3 + 2];
9724
+ rot[k * 4] = compactData.rotations[i * 4];
9725
+ rot[k * 4 + 1] = compactData.rotations[i * 4 + 1];
9726
+ rot[k * 4 + 2] = compactData.rotations[i * 4 + 2];
9727
+ rot[k * 4 + 3] = compactData.rotations[i * 4 + 3];
9728
+ col[k * 3] = compactData.colors[i * 3];
9729
+ col[k * 3 + 1] = compactData.colors[i * 3 + 1];
9730
+ col[k * 3 + 2] = compactData.colors[i * 3 + 2];
9731
+ opa[k] = compactData.opacities[i];
9732
+ }
9733
+ trimmedData = { count: n, positions: pos, scales: scl, rotations: rot, colors: col, opacities: opa };
9734
+ this.splatCount = n;
9735
+ dbg == null ? void 0 : dbg.info(`CPU LOD filter: ${compactData.count} → ${n} splats (skip ${(skipRate * 100).toFixed(0)}%)`);
9736
+ } else {
9737
+ const includeSH = !this.compactLayout && compactData.shCoeffs !== void 0;
9738
+ trimmedData = this.splatCount < compactData.count ? {
9739
+ count: this.splatCount,
9740
+ positions: compactData.positions.slice(0, this.splatCount * 3),
9741
+ scales: compactData.scales.slice(0, this.splatCount * 3),
9742
+ rotations: compactData.rotations.slice(0, this.splatCount * 4),
9743
+ colors: compactData.colors.slice(0, this.splatCount * 3),
9744
+ opacities: compactData.opacities.slice(0, this.splatCount),
9745
+ shCoeffs: includeSH && compactData.shCoeffs ? compactData.shCoeffs.slice(0, this.splatCount * compactData.shCoeffs.length / compactData.count) : void 0
9746
+ } : compactData;
9747
+ }
9748
+ dbg == null ? void 0 : dbg.info(`setCompactData: ${this.splatCount} splats, compact=${this.compactLayout}, half=${this.halfPrecision}`);
9749
+ this.cpuPositions = new Float32Array(trimmedData.positions);
9750
+ let gpuBuf;
9751
+ if (this.halfPrecision) {
9752
+ const halfData = compactDataToGPUBufferHalf(trimmedData);
9753
+ gpuBuf = halfData.buffer;
9754
+ dbg == null ? void 0 : dbg.info(
9755
+ `gpuData(half): ${(halfData.byteLength / 1048576).toFixed(1)}MB, u32s=${halfData.length}, u32/splat=${HALF_SPLAT_U32_COUNT}`
9756
+ );
9757
+ } else {
9758
+ const includeSH = !this.compactLayout && compactData.shCoeffs !== void 0;
9759
+ const f32Data = compactDataToGPUBuffer(trimmedData, includeSH);
9760
+ gpuBuf = f32Data.buffer;
9761
+ dbg == null ? void 0 : dbg.info(
9762
+ `gpuData: ${(f32Data.byteLength / 1048576).toFixed(1)}MB, includeSH=${includeSH}, floats=${f32Data.length}, floats/splat=${f32Data.length / this.splatCount}`
9763
+ );
9764
+ }
9247
9765
  this.splatBuffer = device.createBuffer({
9248
- size: gpuData.byteLength,
9766
+ size: gpuBuf.byteLength,
9249
9767
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
9250
9768
  });
9251
- device.queue.writeBuffer(this.splatBuffer, 0, gpuData.buffer);
9769
+ device.queue.writeBuffer(this.splatBuffer, 0, gpuBuf);
9770
+ dbg == null ? void 0 : dbg.info(`Creating sorter: compact=${this.compactLayout}, half=${this.halfPrecision}, sortBits=${this.sortBits}`);
9252
9771
  this.sorter = new GSSplatSorter(
9253
9772
  device,
9254
9773
  this.splatCount,
9255
9774
  this.splatBuffer,
9256
- this.uniformBuffer
9775
+ this.uniformBuffer,
9776
+ {},
9777
+ this.compactLayout,
9778
+ this.halfPrecision,
9779
+ this.sortBits
9257
9780
  );
9258
9781
  this.sorter.setScreenSize(this.renderer.width, this.renderer.height);
9259
9782
  this.sorter.setCullingOptions({
@@ -9269,6 +9792,7 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9269
9792
  { binding: 2, resource: { buffer: this.sorter.getIndicesBuffer() } }
9270
9793
  ]
9271
9794
  });
9795
+ dbg == null ? void 0 : dbg.info(`setCompactData complete, bindGroup created`);
9272
9796
  if (this.editorEnabled) this.rebuildEditorBindGroup();
9273
9797
  this.sortStateInitialized = false;
9274
9798
  }
@@ -9292,7 +9816,7 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9292
9816
  this.renderer.device.queue.writeBuffer(this.uniformBuffer, 0, ud);
9293
9817
  const changed = this.needsSort();
9294
9818
  this.frameCounter++;
9295
- const shouldSort = changed || this.sortFrequency > 1 && this.frameCounter % this.sortFrequency === 0;
9819
+ const shouldSort = !this.sortStateInitialized || changed;
9296
9820
  if (shouldSort) {
9297
9821
  this.sorter.setScreenSize(this.renderer.width, this.renderer.height);
9298
9822
  this.sorter.setCullingOptions({
@@ -9300,9 +9824,24 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9300
9824
  farPlane: this.camera.far,
9301
9825
  pixelThreshold: this.pixelCullThreshold,
9302
9826
  maxVisibleCount: this.maxVisibleSplats,
9303
- depthRangeLimit: this.depthRangeLimit
9827
+ depthRangeLimit: this.depthRangeLimit,
9828
+ lodSkipRate: this.lodSkipRate
9304
9829
  });
9305
- this.sorter.sort();
9830
+ if (!this.debugFrameLogged) {
9831
+ const dbg = getDebugOverlay();
9832
+ const dev = this.renderer.device;
9833
+ dev.pushErrorScope("validation");
9834
+ dev.pushErrorScope("out-of-memory");
9835
+ this.sorter.sort();
9836
+ dev.popErrorScope().then((err2) => {
9837
+ if (err2) dbg == null ? void 0 : dbg.error(`Sort OOM: ${err2.message}`);
9838
+ });
9839
+ dev.popErrorScope().then((err2) => {
9840
+ if (err2) dbg == null ? void 0 : dbg.error(`Sort Validation: ${err2.message}`);
9841
+ });
9842
+ } else {
9843
+ this.sorter.sort();
9844
+ }
9306
9845
  }
9307
9846
  if (this.editorEnabled && this.editorPipeline && this.editorBindGroup) {
9308
9847
  pass.setPipeline(this.editorPipeline);
@@ -9312,6 +9851,28 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9312
9851
  pass.setBindGroup(0, this.bindGroup);
9313
9852
  }
9314
9853
  pass.drawIndirect(this.sorter.getDrawIndirectBuffer(), 0);
9854
+ if (!this.debugFrameLogged) {
9855
+ this.debugFrameLogged = true;
9856
+ const dbg = getDebugOverlay();
9857
+ dbg == null ? void 0 : dbg.info(
9858
+ `First render: splats=${this.splatCount}, canvas=${this.renderer.width}x${this.renderer.height}, editor=${this.editorEnabled}, depthWrite=${this.depthWriteEnabled}`
9859
+ );
9860
+ if (dbg && this.sorter) {
9861
+ const pos2 = this.camera.position;
9862
+ dbg.info(`Camera pos: [${pos2[0].toFixed(2)}, ${pos2[1].toFixed(2)}, ${pos2[2].toFixed(2)}]`);
9863
+ dbg.info(
9864
+ `Culling cfg: pixelThresh=${this.pixelCullThreshold.toFixed(2)}, maxVisible=${this.maxVisibleSplats}, depthRange=${this.depthRangeLimit.toFixed(2)}, near=${this.camera.near}, far=${this.camera.far}`
9865
+ );
9866
+ this.sorter.readbackIndirect().then((data) => {
9867
+ dbg.info(
9868
+ `DrawIndirect: vtx=${data[0]}, inst=${data[1]}, firstVtx=${data[2]}, firstInst=${data[3]}`
9869
+ );
9870
+ if (data[0] === 0 && data[1] === 0) {
9871
+ dbg.error("ALL splats culled! visible=0");
9872
+ }
9873
+ });
9874
+ }
9875
+ }
9315
9876
  }
9316
9877
  getSplatCount() {
9317
9878
  return this.splatCount;
@@ -9629,10 +10190,13 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9629
10190
  }
9630
10191
  createEditorPipeline() {
9631
10192
  const device = this.renderer.device;
9632
- const editorShaderCode = this.buildEditorShader().replace(
10193
+ let editorShaderCode = this.buildEditorShader().replace(
9633
10194
  "const SH_LEVEL: u32 = 3u; // @SH_LEVEL_INJECT@",
9634
10195
  `const SH_LEVEL: u32 = ${this.shMode}u;`
9635
10196
  );
10197
+ if (this.compactLayout) {
10198
+ editorShaderCode = transformShaderForCompact(editorShaderCode);
10199
+ }
9636
10200
  const shaderModule = device.createShaderModule({ code: editorShaderCode });
9637
10201
  this.editorBindGroupLayout = device.createBindGroupLayout({
9638
10202
  entries: [
@@ -18725,6 +19289,10 @@ class App {
18725
19289
  __publicField(this, "animationId", 0);
18726
19290
  // 是否使用移动端渲染器
18727
19291
  __publicField(this, "useMobileRenderer", false);
19292
+ // 移动端优化开关(默认关闭,由外部显式启用)
19293
+ __publicField(this, "mobileOptimizationsEnabled", false);
19294
+ // 移动端可见 splat 硬上限(保证稳定帧率)
19295
+ __publicField(this, "mobileMaxVisibleCap", 0);
18728
19296
  // 最近加载的 CompactSplatData(用于编辑器导出)
18729
19297
  __publicField(this, "lastCompactData", null);
18730
19298
  // 自适应性能控制
@@ -18734,6 +19302,17 @@ class App {
18734
19302
  });
18735
19303
  __publicField(this, "lastAppliedRenderScale", 1);
18736
19304
  __publicField(this, "baseRenderScale", 1);
19305
+ // 动态分辨率:移动时降分辨率,静止后恢复
19306
+ __publicField(this, "dynamicResolutionEnabled", false);
19307
+ __publicField(this, "dynResLastViewMatrix", new Float32Array(16));
19308
+ __publicField(this, "dynResStillFrames", 0);
19309
+ __publicField(this, "dynResCurrentScale", 1);
19310
+ __publicField(this, "DYNRES_MOVE_SCALE", 0.6);
19311
+ // 移动时 DPR*0.6(2.0*0.6=1.2)
19312
+ __publicField(this, "DYNRES_STILL_SCALE", 1);
19313
+ // 静止时 DPR*1.0
19314
+ __publicField(this, "DYNRES_STILL_THRESHOLD", 2);
19315
+ // 静止 2 帧即恢复
18737
19316
  // 绑定的事件处理函数
18738
19317
  __publicField(this, "boundOnResize");
18739
19318
  /** 额外渲染回调(在 gizmo 之前、场景辅助之后执行) */
@@ -18787,16 +19366,46 @@ class App {
18787
19366
  nearDepthRangeRatio: 2
18788
19367
  };
18789
19368
  console.log(
18790
- `[3DGS] Apple GPU detected (${this.renderer.gpuVendor}/${this.renderer.gpuArchitecture}), applying TBDR optimizations: SH→L1, depthWrite off, aggressive culling`
19369
+ `[3DGS] Apple GPU detected (${this.renderer.gpuVendor}/${this.renderer.gpuArchitecture}), applying TBDR optimizations: depthWrite off, aggressive culling`
18791
19370
  );
18792
19371
  }
18793
19372
  /**
18794
- * 创建桌面端 GSSplatRenderer 并自动应用平台优化
19373
+ * 启用/禁用移动端性能优化(默认关闭)
19374
+ * 开启后,加载模型时将自动应用:f16 半精度、16-bit 排序、像素剔除、动态分辨率等。
19375
+ * 应在 init() 之后、加载模型之前调用。
19376
+ */
19377
+ enableMobileOptimizations(enabled = true) {
19378
+ var _a2;
19379
+ this.mobileOptimizationsEnabled = enabled;
19380
+ if (enabled) {
19381
+ this.dynamicResolutionEnabled = true;
19382
+ createDebugOverlay();
19383
+ (_a2 = getDebugOverlay()) == null ? void 0 : _a2.info(
19384
+ `Mobile optimizations enabled. DPR=${window.devicePixelRatio}, canvas=${this.canvas.width}x${this.canvas.height}`
19385
+ );
19386
+ console.log(`[3DGS] Mobile optimizations: f16, sortBits=16, pixelThreshold=1.5, dynamicRes=ON`);
19387
+ } else {
19388
+ this.dynamicResolutionEnabled = false;
19389
+ }
19390
+ }
19391
+ isMobileOptimized() {
19392
+ return this.mobileOptimizationsEnabled;
19393
+ }
19394
+ /**
19395
+ * 创建 GSSplatRenderer 并自动应用平台优化
19396
+ * @param forMobile 移动端模式:半精度(32B/splat)+ SH L0,内存降为 1/8
18795
19397
  */
18796
- createDesktopGSRenderer() {
19398
+ createGSRendererUnified(forMobile = false) {
18797
19399
  const renderer = new GSSplatRenderer(this.renderer, this.camera);
18798
- if (this.renderer.isAppleGPU) {
19400
+ if (forMobile) {
19401
+ renderer.setHalfPrecision(true);
19402
+ renderer.setSortBits(16);
19403
+ renderer.setPixelCullThreshold(1.5);
19404
+ renderer.setSortFrequency(2);
19405
+ } else if (this.renderer.isAppleGPU) {
18799
19406
  renderer.setSHMode(SHMode.L1);
19407
+ }
19408
+ if (this.renderer.isAppleGPU) {
18800
19409
  renderer.setDepthWriteEnabled(false);
18801
19410
  }
18802
19411
  return renderer;
@@ -18843,7 +19452,7 @@ class App {
18843
19452
  */
18844
19453
  async addPLY(urlOrBuffer, onProgress, isLocalFile = false, coordinateSystem = "blender") {
18845
19454
  try {
18846
- const isMobile = isMobileDevice();
19455
+ const useMobileOpt = this.mobileOptimizationsEnabled;
18847
19456
  let buffer;
18848
19457
  if (typeof urlOrBuffer === "string") {
18849
19458
  buffer = await this.fetchWithProgress(
@@ -18866,40 +19475,26 @@ class App {
18866
19475
  onProgress(50 + parseProgress, "parse");
18867
19476
  }
18868
19477
  };
18869
- let gsRenderer;
18870
- if (isMobile) {
18871
- gsRenderer = new GSSplatRendererMobile(this.renderer, this.camera);
18872
- this.useMobileRenderer = true;
18873
- const compactData = await this.parsePLYBuffer(buffer, {
18874
- maxSplats: Infinity,
18875
- loadSH: false,
18876
- onProgress: parseProgressCallback,
18877
- coordinateSystem
18878
- });
18879
- if (onProgress) onProgress(90, "upload");
18880
- gsRenderer.setCompactData(compactData);
18881
- if (onProgress) onProgress(100, "upload");
18882
- this.lastCompactData = compactData;
18883
- this.sceneManager.setGSRenderer(gsRenderer);
18884
- this.hotspotManager.setGSRenderer(gsRenderer);
18885
- return compactData.count;
18886
- } else {
18887
- gsRenderer = this.createDesktopGSRenderer();
18888
- this.useMobileRenderer = false;
18889
- const compactData = await this.parsePLYBuffer(buffer, {
18890
- maxSplats: Infinity,
18891
- loadSH: true,
18892
- onProgress: parseProgressCallback,
18893
- coordinateSystem
18894
- });
18895
- if (onProgress) onProgress(90, "upload");
18896
- gsRenderer.setCompactData(compactData);
18897
- if (onProgress) onProgress(100, "upload");
18898
- this.lastCompactData = compactData;
18899
- this.sceneManager.setGSRenderer(gsRenderer);
18900
- this.hotspotManager.setGSRenderer(gsRenderer);
18901
- return compactData.count;
19478
+ if (useMobileOpt) {
19479
+ console.log(
19480
+ "[3DGS] Mobile optimizations active, using SH L0"
19481
+ );
18902
19482
  }
19483
+ const gsRenderer = this.createGSRendererUnified(useMobileOpt);
19484
+ this.useMobileRenderer = false;
19485
+ const compactData = await this.parsePLYBuffer(buffer, {
19486
+ maxSplats: Infinity,
19487
+ loadSH: !useMobileOpt,
19488
+ onProgress: parseProgressCallback,
19489
+ coordinateSystem
19490
+ });
19491
+ if (onProgress) onProgress(90, "upload");
19492
+ gsRenderer.setCompactData(compactData);
19493
+ if (onProgress) onProgress(100, "upload");
19494
+ this.lastCompactData = compactData;
19495
+ this.sceneManager.setGSRenderer(gsRenderer);
19496
+ this.hotspotManager.setGSRenderer(gsRenderer);
19497
+ return compactData.count;
18903
19498
  } catch (error) {
18904
19499
  throw error;
18905
19500
  }
@@ -18910,7 +19505,7 @@ class App {
18910
19505
  */
18911
19506
  async addSplat(urlOrBuffer, onProgress, isLocalFile = false, coordinateSystem = "blender") {
18912
19507
  try {
18913
- const isMobile = isMobileDevice();
19508
+ const useMobileOpt = this.mobileOptimizationsEnabled;
18914
19509
  let buffer;
18915
19510
  if (typeof urlOrBuffer === "string") {
18916
19511
  buffer = await this.fetchWithProgress(
@@ -18941,23 +19536,9 @@ class App {
18941
19536
  }
18942
19537
  if (onProgress) onProgress(90, "parse");
18943
19538
  if (onProgress) onProgress(90, "upload");
18944
- let gsRenderer;
18945
- if (isMobile) {
18946
- const mobileRenderer = new GSSplatRendererMobile(
18947
- this.renderer,
18948
- this.camera
18949
- );
18950
- this.useMobileRenderer = true;
18951
- const compactData = App.splatCpuToCompactData(splats);
18952
- mobileRenderer.setCompactData(compactData);
18953
- this.lastCompactData = compactData;
18954
- gsRenderer = mobileRenderer;
18955
- } else {
18956
- const desktopRenderer = this.createDesktopGSRenderer();
18957
- this.useMobileRenderer = false;
18958
- desktopRenderer.setData(splats);
18959
- gsRenderer = desktopRenderer;
18960
- }
19539
+ const gsRenderer = this.createGSRendererUnified(useMobileOpt);
19540
+ this.useMobileRenderer = false;
19541
+ gsRenderer.setData(splats);
18961
19542
  this.sceneManager.setGSRenderer(gsRenderer);
18962
19543
  this.hotspotManager.setGSRenderer(gsRenderer);
18963
19544
  if (onProgress) onProgress(100, "upload");
@@ -19002,7 +19583,7 @@ class App {
19002
19583
  */
19003
19584
  async addSOG(urlOrBuffer, onProgress, isLocalFile = false, coordinateSystem = "blender") {
19004
19585
  try {
19005
- const isMobile = isMobileDevice();
19586
+ const useMobileOpt = this.mobileOptimizationsEnabled;
19006
19587
  let buffer;
19007
19588
  if (typeof urlOrBuffer === "string") {
19008
19589
  buffer = await this.fetchWithProgress(
@@ -19044,14 +19625,8 @@ class App {
19044
19625
  }
19045
19626
  }
19046
19627
  if (onProgress) onProgress(90, "upload");
19047
- let gsRenderer;
19048
- if (isMobile) {
19049
- gsRenderer = new GSSplatRendererMobile(this.renderer, this.camera);
19050
- this.useMobileRenderer = true;
19051
- } else {
19052
- gsRenderer = this.createDesktopGSRenderer();
19053
- this.useMobileRenderer = false;
19054
- }
19628
+ const gsRenderer = this.createGSRendererUnified(useMobileOpt);
19629
+ this.useMobileRenderer = false;
19055
19630
  gsRenderer.setCompactData(compactData);
19056
19631
  this.lastCompactData = compactData;
19057
19632
  this.sceneManager.setGSRenderer(gsRenderer);
@@ -19102,6 +19677,21 @@ class App {
19102
19677
  this.render();
19103
19678
  this.animationId = requestAnimationFrame(this.animate.bind(this));
19104
19679
  }
19680
+ updateDynamicResolution() {
19681
+ if (!this.dynamicResolutionEnabled) return;
19682
+ const interacting = this.controls.isInteracting;
19683
+ if (interacting) {
19684
+ if (this.dynResCurrentScale !== this.DYNRES_MOVE_SCALE) {
19685
+ this.dynResCurrentScale = this.DYNRES_MOVE_SCALE;
19686
+ this.renderer.setRenderScale(this.DYNRES_MOVE_SCALE);
19687
+ }
19688
+ } else {
19689
+ if (this.dynResCurrentScale !== this.DYNRES_STILL_SCALE) {
19690
+ this.dynResCurrentScale = this.DYNRES_STILL_SCALE;
19691
+ this.renderer.setRenderScale(this.DYNRES_STILL_SCALE);
19692
+ }
19693
+ }
19694
+ }
19105
19695
  updateAdaptivePerformance() {
19106
19696
  if (!this.adaptivePerformanceEnabled) return;
19107
19697
  const gsRenderer = this.getGSRenderer();
@@ -19145,7 +19735,11 @@ class App {
19145
19735
  gsRenderer.setMaxVisibleSplats(0);
19146
19736
  } else {
19147
19737
  const ratio = cfg.nearVisibleRatio + (1 - cfg.nearVisibleRatio) * t;
19148
- gsRenderer.setMaxVisibleSplats(Math.round(splatCount * ratio));
19738
+ let maxVisible = Math.round(splatCount * ratio);
19739
+ if (this.mobileMaxVisibleCap > 0) {
19740
+ maxVisible = Math.min(maxVisible, this.mobileMaxVisibleCap);
19741
+ }
19742
+ gsRenderer.setMaxVisibleSplats(maxVisible);
19149
19743
  }
19150
19744
  if (cfg.enableDepthRangeLimit) {
19151
19745
  if (t >= 0.99) {
@@ -19155,16 +19749,13 @@ class App {
19155
19749
  gsRenderer.setDepthRangeLimit(depthRange);
19156
19750
  }
19157
19751
  }
19158
- if (t < 0.3) {
19159
- gsRenderer.setSortFrequency(2);
19160
- } else {
19161
- gsRenderer.setSortFrequency(1);
19162
- }
19752
+ gsRenderer.setSortFrequency(1);
19163
19753
  }
19164
19754
  render() {
19165
19755
  var _a2, _b2;
19166
19756
  this.camera.setAspect(this.renderer.getAspectRatio());
19167
19757
  this.controls.update();
19758
+ this.updateDynamicResolution();
19168
19759
  this.updateAdaptivePerformance();
19169
19760
  this.hotspotManager.updateBillboards();
19170
19761
  if ((_a2 = this.skyboxRenderer) == null ? void 0 : _a2.isActive) {
@@ -19740,10 +20331,6 @@ class App {
19740
20331
  if (gsRenderer) {
19741
20332
  gsRenderer.setSortFrequency(frequency);
19742
20333
  }
19743
- const mobileRenderer = this.getGSRendererMobile();
19744
- if (mobileRenderer) {
19745
- mobileRenderer.setSortFrequency(frequency);
19746
- }
19747
20334
  }
19748
20335
  /**
19749
20336
  * 是否检测到 Apple GPU(M1/M2/M3 等)