@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.cjs CHANGED
@@ -38,8 +38,7 @@ function isMobileDevice() {
38
38
  return isMobileUA || isIPadAsMac || hasTouch && isSmallScreen;
39
39
  }
40
40
  function getRecommendedDPR() {
41
- const isMobile = isMobileDevice();
42
- const maxDpr = isMobile ? 2 : 2;
41
+ const maxDpr = isMobileDevice() ? 2 : 2;
43
42
  return Math.min(window.devicePixelRatio || 1, maxDpr);
44
43
  }
45
44
  function isWebGPUSupported() {
@@ -144,6 +143,140 @@ function transformBoundingBox(bbox, modelMatrix) {
144
143
  [maxX, maxY, maxZ]
145
144
  );
146
145
  }
146
+ class DebugOverlay {
147
+ constructor(maxEntries = 50) {
148
+ __publicField(this, "container");
149
+ __publicField(this, "logList");
150
+ __publicField(this, "maxEntries");
151
+ this.maxEntries = maxEntries;
152
+ this.container = document.createElement("div");
153
+ Object.assign(this.container.style, {
154
+ position: "fixed",
155
+ top: "0",
156
+ left: "0",
157
+ right: "0",
158
+ maxHeight: "40vh",
159
+ overflowY: "auto",
160
+ background: "rgba(0,0,0,0.85)",
161
+ color: "#0f0",
162
+ fontFamily: "monospace",
163
+ fontSize: "11px",
164
+ lineHeight: "1.4",
165
+ padding: "6px 8px",
166
+ zIndex: "99999",
167
+ pointerEvents: "auto",
168
+ whiteSpace: "pre-wrap",
169
+ wordBreak: "break-all"
170
+ });
171
+ const header = document.createElement("div");
172
+ Object.assign(header.style, {
173
+ display: "flex",
174
+ justifyContent: "space-between",
175
+ marginBottom: "4px",
176
+ borderBottom: "1px solid #333",
177
+ paddingBottom: "4px"
178
+ });
179
+ header.innerHTML = '<span style="color:#ff0;font-weight:bold">GPU Debug</span>';
180
+ const closeBtn = document.createElement("span");
181
+ closeBtn.textContent = "[X]";
182
+ closeBtn.style.color = "#f66";
183
+ closeBtn.style.cursor = "pointer";
184
+ closeBtn.onclick = () => this.hide();
185
+ header.appendChild(closeBtn);
186
+ this.logList = document.createElement("div");
187
+ this.container.appendChild(header);
188
+ this.container.appendChild(this.logList);
189
+ document.body.appendChild(this.container);
190
+ }
191
+ log(msg, level = "info") {
192
+ const colors = { info: "#0f0", warn: "#ff0", error: "#f44" };
193
+ const prefix = { info: "I", warn: "W", error: "E" };
194
+ const ts = (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false });
195
+ const entry = document.createElement("div");
196
+ entry.style.color = colors[level];
197
+ entry.textContent = `[${ts}][${prefix[level]}] ${msg}`;
198
+ this.logList.appendChild(entry);
199
+ while (this.logList.childElementCount > this.maxEntries) {
200
+ this.logList.removeChild(this.logList.firstChild);
201
+ }
202
+ this.container.scrollTop = this.container.scrollHeight;
203
+ }
204
+ info(msg) {
205
+ this.log(msg, "info");
206
+ }
207
+ warn(msg) {
208
+ this.log(msg, "warn");
209
+ }
210
+ error(msg) {
211
+ this.log(msg, "error");
212
+ }
213
+ hide() {
214
+ this.container.style.display = "none";
215
+ }
216
+ show() {
217
+ this.container.style.display = "block";
218
+ }
219
+ /**
220
+ * 绑定 WebGPU device 的全局错误捕获
221
+ */
222
+ attachDevice(device) {
223
+ device.onuncapturederror = (event) => {
224
+ this.error(`GPU Uncaptured: ${event.error.message}`);
225
+ };
226
+ this.info(
227
+ `Device attached. maxBuf=${(device.limits.maxBufferSize / 1048576).toFixed(0)}MB, maxStorage=${(device.limits.maxStorageBufferBindingSize / 1048576).toFixed(0)}MB`
228
+ );
229
+ }
230
+ /**
231
+ * 检查 shader 编译结果
232
+ */
233
+ async checkShader(module2, label) {
234
+ try {
235
+ const info = await module2.getCompilationInfo();
236
+ let hasError = false;
237
+ for (const msg of info.messages) {
238
+ const text = `Shader[${label}] ${msg.type}: L${msg.lineNum}:${msg.linePos} ${msg.message}`;
239
+ if (msg.type === "error") {
240
+ this.error(text);
241
+ hasError = true;
242
+ } else if (msg.type === "warning") {
243
+ this.warn(text);
244
+ }
245
+ }
246
+ if (!hasError) {
247
+ this.info(`Shader[${label}] compiled OK`);
248
+ }
249
+ return !hasError;
250
+ } catch (e) {
251
+ this.error(`Shader[${label}] getCompilationInfo failed: ${e}`);
252
+ return false;
253
+ }
254
+ }
255
+ /**
256
+ * 包裹一个异步操作,捕获 validation/oom 错误
257
+ */
258
+ async wrapGPU(device, label, fn) {
259
+ device.pushErrorScope("validation");
260
+ device.pushErrorScope("out-of-memory");
261
+ const result = fn();
262
+ const oomErr = await device.popErrorScope();
263
+ const valErr = await device.popErrorScope();
264
+ if (oomErr) this.error(`[${label}] OOM: ${oomErr.message}`);
265
+ if (valErr) this.error(`[${label}] Validation: ${valErr.message}`);
266
+ if (!oomErr && !valErr) this.info(`[${label}] OK`);
267
+ return result;
268
+ }
269
+ }
270
+ let _instance = null;
271
+ function getDebugOverlay() {
272
+ return _instance;
273
+ }
274
+ function createDebugOverlay() {
275
+ if (!_instance) {
276
+ _instance = new DebugOverlay();
277
+ }
278
+ return _instance;
279
+ }
147
280
  async function loadTextureFromURL(device, url) {
148
281
  try {
149
282
  const response = await fetch(url);
@@ -363,7 +496,16 @@ class Renderer {
363
496
  }
364
497
  });
365
498
  this._device.lost.then((info) => {
499
+ const overlay2 = getDebugOverlay();
500
+ overlay2 == null ? void 0 : overlay2.error(`GPU device lost: ${info.reason} - ${info.message}`);
366
501
  });
502
+ const overlay = getDebugOverlay();
503
+ if (overlay) {
504
+ overlay.attachDevice(this._device);
505
+ overlay.info(
506
+ `GPU: vendor=${this._gpuVendor || "unknown"}, arch=${this._gpuArchitecture || "unknown"}, apple=${this._isAppleGPU}, format=${navigator.gpu.getPreferredCanvasFormat()}`
507
+ );
508
+ }
367
509
  this._context = this.canvas.getContext("webgpu");
368
510
  if (!this._context) {
369
511
  throw new Error("无法获取 WebGPU 上下文");
@@ -651,6 +793,7 @@ const _OrbitControls = class _OrbitControls {
651
793
  // 键盘移动
652
794
  __publicField(this, "moveSpeed", 0.015);
653
795
  __publicField(this, "pressedKeys", /* @__PURE__ */ new Set());
796
+ __publicField(this, "_wasKeyboardMoving", false);
654
797
  // 触摸手势状态
655
798
  __publicField(this, "touchMode", "none");
656
799
  __publicField(this, "lastTouchDistance", 0);
@@ -687,6 +830,9 @@ const _OrbitControls = class _OrbitControls {
687
830
  this.setupEventListeners();
688
831
  this.applySpherical();
689
832
  }
833
+ get isInteracting() {
834
+ return this.isDragging;
835
+ }
690
836
  setupEventListeners() {
691
837
  this.canvas.addEventListener("mousedown", this.boundOnMouseDown);
692
838
  this.canvas.addEventListener("mousemove", this.boundOnMouseMove);
@@ -824,7 +970,14 @@ const _OrbitControls = class _OrbitControls {
824
970
  this.pressedKeys.delete(e.key.toLowerCase());
825
971
  }
826
972
  applyKeyboardMovement() {
827
- if (this.pressedKeys.size === 0) return;
973
+ if (this.pressedKeys.size === 0) {
974
+ if (this._wasKeyboardMoving) {
975
+ this._wasKeyboardMoving = false;
976
+ this.recenterOrbitTarget();
977
+ }
978
+ return;
979
+ }
980
+ this._wasKeyboardMoving = true;
828
981
  const m = this.camera.viewMatrix;
829
982
  const right = [m[0], m[4], m[8]];
830
983
  const forward = [-m[2], -m[6], -m[10]];
@@ -984,9 +1137,15 @@ const _OrbitControls = class _OrbitControls {
984
1137
  onTouchEnd(e) {
985
1138
  if (e.touches.length === 0) {
986
1139
  this.isDragging = false;
1140
+ if (this.touchMode === "zoom-pan") {
1141
+ this.recenterOrbitTarget();
1142
+ }
987
1143
  this.touchMode = "none";
988
1144
  this.lastTouchDistance = 0;
989
1145
  } else if (e.touches.length === 1) {
1146
+ if (this.touchMode === "zoom-pan") {
1147
+ this.recenterOrbitTarget();
1148
+ }
990
1149
  this.touchMode = "rotate";
991
1150
  this.lastX = e.touches[0].clientX;
992
1151
  this.lastY = e.touches[0].clientY;
@@ -1003,6 +1162,34 @@ const _OrbitControls = class _OrbitControls {
1003
1162
  y: (touches[0].clientY + touches[1].clientY) / 2
1004
1163
  };
1005
1164
  }
1165
+ /**
1166
+ * 将 orbit 目标重新锚定到屏幕中心的模型表面点,
1167
+ * 保持相机世界坐标不变,仅重算 distance/theta/phi。
1168
+ * 用于 WASD 移动或触摸缩放结束后修复旋转中心偏移。
1169
+ */
1170
+ recenterOrbitTarget() {
1171
+ if (!this.pickWorldPosition) return;
1172
+ const rect = this.canvas.getBoundingClientRect();
1173
+ const hit = this.pickWorldPosition(
1174
+ rect.left + rect.width / 2,
1175
+ rect.top + rect.height / 2
1176
+ );
1177
+ if (!hit) return;
1178
+ const dx = this.camera.position[0] - hit[0];
1179
+ const dy = this.camera.position[1] - hit[1];
1180
+ const dz = this.camera.position[2] - hit[2];
1181
+ const newDist = Math.sqrt(dx * dx + dy * dy + dz * dz);
1182
+ if (newDist < this.minDistance) return;
1183
+ this.camera.target[0] = hit[0];
1184
+ this.camera.target[1] = hit[1];
1185
+ this.camera.target[2] = hit[2];
1186
+ this.distance = newDist;
1187
+ this.theta = Math.atan2(dx, dz);
1188
+ this.phi = Math.acos(Math.min(1, Math.max(-1, dy / newDist)));
1189
+ this.deltaPanX = 0;
1190
+ this.deltaPanY = 0;
1191
+ this.deltaPanZ = 0;
1192
+ }
1006
1193
  /**
1007
1194
  * 将球坐标写入相机位置(内部方法,不处理阻尼)
1008
1195
  */
@@ -5231,9 +5418,10 @@ function compactDataToGPUBuffer(data, includeFullSH = false) {
5231
5418
  }
5232
5419
  return buffer;
5233
5420
  } else {
5234
- const buffer = new Float32Array(count * 64);
5421
+ const COMPACT_FLOATS = 16;
5422
+ const buffer = new Float32Array(count * COMPACT_FLOATS);
5235
5423
  for (let i = 0; i < count; i++) {
5236
- const offset = i * 64;
5424
+ const offset = i * COMPACT_FLOATS;
5237
5425
  buffer[offset + 0] = data.positions[i * 3 + 0];
5238
5426
  buffer[offset + 1] = data.positions[i * 3 + 1];
5239
5427
  buffer[offset + 2] = data.positions[i * 3 + 2];
@@ -5254,9 +5442,56 @@ function compactDataToGPUBuffer(data, includeFullSH = false) {
5254
5442
  return buffer;
5255
5443
  }
5256
5444
  }
5445
+ const _f16Scratch = new Float32Array(1);
5446
+ const _f16ScratchU32 = new Uint32Array(_f16Scratch.buffer);
5447
+ function f32ToF16Bits(val) {
5448
+ _f16Scratch[0] = val;
5449
+ const bits2 = _f16ScratchU32[0];
5450
+ const sign = bits2 >>> 31 & 1;
5451
+ let exp = bits2 >>> 23 & 255;
5452
+ let frac = bits2 & 8388607;
5453
+ if (exp === 255) {
5454
+ return sign << 15 | 31744 | (frac ? 512 : 0);
5455
+ }
5456
+ exp = exp - 127 + 15;
5457
+ if (exp >= 31) {
5458
+ return sign << 15 | 31744;
5459
+ }
5460
+ if (exp <= 0) {
5461
+ if (exp < -10) return sign << 15;
5462
+ frac = (frac | 8388608) >> 1 - exp;
5463
+ return sign << 15 | frac >> 13;
5464
+ }
5465
+ return sign << 15 | exp << 10 | frac >> 13;
5466
+ }
5467
+ function compactDataToGPUBufferHalf(data) {
5468
+ const count = data.count;
5469
+ const U32_PER_SPLAT = 8;
5470
+ const buffer = new Uint32Array(count * U32_PER_SPLAT);
5471
+ const pos = data.positions;
5472
+ const scl = data.scales;
5473
+ const rot = data.rotations;
5474
+ const col = data.colors;
5475
+ const opa = data.opacities;
5476
+ for (let i = 0; i < count; i++) {
5477
+ const o = i * U32_PER_SPLAT;
5478
+ const i3 = i * 3;
5479
+ const i4 = i * 4;
5480
+ buffer[o] = f32ToF16Bits(pos[i3 + 1]) << 16 | f32ToF16Bits(pos[i3]);
5481
+ buffer[o + 1] = f32ToF16Bits(pos[i3 + 2]);
5482
+ buffer[o + 2] = f32ToF16Bits(scl[i3 + 1]) << 16 | f32ToF16Bits(scl[i3]);
5483
+ buffer[o + 3] = f32ToF16Bits(opa[i]) << 16 | f32ToF16Bits(scl[i3 + 2]);
5484
+ buffer[o + 4] = f32ToF16Bits(rot[i4 + 1]) << 16 | f32ToF16Bits(rot[i4]);
5485
+ buffer[o + 5] = f32ToF16Bits(rot[i4 + 3]) << 16 | f32ToF16Bits(rot[i4 + 2]);
5486
+ buffer[o + 6] = f32ToF16Bits(col[i3 + 1]) << 16 | f32ToF16Bits(col[i3]);
5487
+ buffer[o + 7] = f32ToF16Bits(col[i3 + 2]);
5488
+ }
5489
+ return buffer;
5490
+ }
5257
5491
  const PLYLoaderMobile = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
5258
5492
  __proto__: null,
5259
5493
  compactDataToGPUBuffer,
5494
+ compactDataToGPUBufferHalf,
5260
5495
  loadPLYMobile,
5261
5496
  parsePLYBuffer,
5262
5497
  transformSHCoeffsYZSwap
@@ -7316,16 +7551,38 @@ const RADIX_BITS = 8;
7316
7551
  const RADIX_SIZE = 256;
7317
7552
  const ELEMENTS_PER_THREAD = 4;
7318
7553
  const BLOCK_SIZE = WORKGROUP_SIZE$1 * ELEMENTS_PER_THREAD;
7319
- function generateCullingShaderCode$1() {
7320
- return (
7321
- /* wgsl */
7322
- `
7323
- /**
7324
- * Project & Cull Shader
7325
- * 基于 rfs-gsplat-render 实现
7326
- */
7327
-
7328
- struct Splat {
7554
+ function generateCullingShaderCode$1(compact = false, half = false, sortBits = 32) {
7555
+ let splatBinding;
7556
+ let splatAccessCode;
7557
+ if (half) {
7558
+ splatBinding = `@group(0) @binding(0) var<storage, read> splatDataU32: array<u32>;`;
7559
+ splatAccessCode = `
7560
+ const HALF_U32S: u32 = 8u;
7561
+ fn getSplatMean(idx: u32) -> vec3<f32> {
7562
+ let b = idx * HALF_U32S;
7563
+ let xy = unpack2x16float(splatDataU32[b]);
7564
+ let zp = unpack2x16float(splatDataU32[b + 1u]);
7565
+ return vec3<f32>(xy.x, xy.y, zp.x);
7566
+ }
7567
+ fn getSplatScale(idx: u32) -> vec3<f32> {
7568
+ let b = idx * HALF_U32S;
7569
+ let xy = unpack2x16float(splatDataU32[b + 2u]);
7570
+ return vec3<f32>(xy.x, xy.y, unpack2x16float(splatDataU32[b + 3u]).x);
7571
+ }
7572
+ fn getSplatOpacity(idx: u32) -> f32 {
7573
+ let b = idx * HALF_U32S;
7574
+ return unpack2x16float(splatDataU32[b + 3u]).y;
7575
+ }`;
7576
+ } else {
7577
+ const splatStruct = compact ? `struct Splat {
7578
+ mean: vec3<f32>,
7579
+ _pad0: f32,
7580
+ scale: vec3<f32>,
7581
+ _pad1: f32,
7582
+ rotation: vec4<f32>,
7583
+ colorDC: vec3<f32>,
7584
+ opacity: f32,
7585
+ }` : `struct Splat {
7329
7586
  mean: vec3<f32>,
7330
7587
  _pad0: f32,
7331
7588
  scale: vec3<f32>,
@@ -7337,7 +7594,24 @@ struct Splat {
7337
7594
  sh2: array<f32, 15>,
7338
7595
  sh3: array<f32, 21>,
7339
7596
  _pad2: array<f32, 3>,
7340
- }
7597
+ }`;
7598
+ splatBinding = `${splatStruct}
7599
+ @group(0) @binding(0) var<storage, read> splats: array<Splat>;`;
7600
+ splatAccessCode = `
7601
+ fn getSplatMean(idx: u32) -> vec3<f32> { return splats[idx].mean; }
7602
+ fn getSplatScale(idx: u32) -> vec3<f32> { return splats[idx].scale; }
7603
+ fn getSplatOpacity(idx: u32) -> f32 { return splats[idx].opacity; }`;
7604
+ }
7605
+ return (
7606
+ /* wgsl */
7607
+ `
7608
+ /**
7609
+ * Project & Cull Shader
7610
+ * 基于 rfs-gsplat-render 实现
7611
+ */
7612
+
7613
+ ${splatBinding}
7614
+ ${splatAccessCode}
7341
7615
 
7342
7616
  struct CameraUniforms {
7343
7617
  view: mat4x4<f32>,
@@ -7357,10 +7631,9 @@ struct CullingParams {
7357
7631
  pixelThreshold: f32,
7358
7632
  maxVisibleCount: u32,
7359
7633
  depthRangeLimit: f32,
7360
- _pad_cull: f32,
7634
+ lodSkipRate: f32,
7361
7635
  }
7362
7636
 
7363
- @group(0) @binding(0) var<storage, read> splats: array<Splat>;
7364
7637
  @group(0) @binding(1) var<uniform> camera: CameraUniforms;
7365
7638
  @group(0) @binding(2) var<uniform> params: CullingParams;
7366
7639
  @group(0) @binding(3) var<storage, read_write> depthKeys: array<u32>;
@@ -7378,16 +7651,12 @@ fn getModelMaxScale(model: mat4x4<f32>) -> f32 {
7378
7651
  return max(max(sx, sy), sz);
7379
7652
  }
7380
7653
 
7381
- // IEEE 754 位操作编码浮点数为可排序的 u32
7382
- // 参考 rfs-gsplat-render 的 encode_min_max_fp32 实现
7383
7654
  fn encodeDepthKey(val: f32) -> u32 {
7384
7655
  var bits = bitcast<u32>(val);
7385
7656
  bits ^= bitcast<u32>(bitcast<i32>(bits) >> 31) | 0x80000000u;
7386
- return bits;
7657
+ return bits >> ${sortBits === 16 ? "16u" : "0u"};
7387
7658
  }
7388
7659
 
7389
- // 视锥剔除检查
7390
- // 基于 rfs-gsplat-render 的 is_in_frustum 实现
7391
7660
  fn isInFrustum(clipPos: vec4<f32>, frustumDilation: f32) -> bool {
7392
7661
  let clip = (1.0 + frustumDilation) * clipPos.w;
7393
7662
 
@@ -7406,43 +7675,47 @@ fn isInFrustum(clipPos: vec4<f32>, frustumDilation: f32) -> bool {
7406
7675
  fn projectAndCull(@builtin(global_invocation_id) gid: vec3<u32>) {
7407
7676
  let i = gid.x;
7408
7677
  if i >= params.splatCount { return; }
7678
+
7679
+ // LOD 抽稀放最前面:跳过的 splat 不做任何数据加载和矩阵运算
7680
+ // 哈希仅依赖 splat index → 被跳过的集合永远不变 → 零闪烁
7681
+ if params.lodSkipRate > 0.0 {
7682
+ let hash = ((i * 2654435761u) >> 16u) & 0xFFFFu;
7683
+ if f32(hash) < params.lodSkipRate * 65535.0 {
7684
+ return;
7685
+ }
7686
+ }
7409
7687
 
7410
- let splat = splats[i];
7688
+ let splatMean = getSplatMean(i);
7689
+ let splatScale = getSplatScale(i);
7690
+ let splatOpacity = getSplatOpacity(i);
7411
7691
 
7412
- // 透明度剔除
7413
- if splat.opacity < 0.004 { return; }
7692
+ if splatOpacity < 0.004 { return; }
7414
7693
 
7415
- // 变换: Local -> World -> View -> Clip
7416
- let worldPos = camera.model * vec4<f32>(splat.mean, 1.0);
7694
+ let worldPos = camera.model * vec4<f32>(splatMean, 1.0);
7417
7695
  let viewPos = camera.view * worldPos;
7418
7696
  let clipPos = camera.proj * viewPos;
7419
7697
 
7420
- // 视锥剔除
7421
7698
  if !isInFrustum(clipPos, params.frustumDilation) { return; }
7422
7699
 
7423
- // 亚像素剔除:Gaussian 可见范围小于阈值的 splat 跳过渲染和排序
7424
- // scale σ(标准差),Gaussian 可见范围约 3σ(覆盖 99.7%)
7425
- if params.pixelThreshold > 0.0 {
7426
- let splatSigma = maxScale(splat.scale) * getModelMaxScale(camera.model);
7427
- let focalY = abs(camera.proj[1][1]) * params.screenHeight * 0.5;
7428
- let projectedExtent = splatSigma * 3.0 * focalY / max(abs(viewPos.z), 0.001);
7429
- if projectedExtent < params.pixelThreshold { return; }
7430
- }
7700
+ let splatSigma = maxScale(splatScale) * getModelMaxScale(camera.model);
7701
+ let focalY = abs(camera.proj[1][1]) * params.screenHeight * 0.5;
7702
+ let projectedExtent = splatSigma * 3.0 * focalY / max(abs(viewPos.z), 0.001);
7703
+
7704
+ // 剔除投影尺寸过大的 splat(远离模型时去除周围遮挡物)
7705
+ if projectedExtent > params.screenHeight * 0.5 { return; }
7706
+
7707
+ // 剔除投影尺寸过小的 splat
7708
+ if params.pixelThreshold > 0.0 && projectedExtent < params.pixelThreshold { return; }
7431
7709
 
7432
- // 深度范围限制:只渲染距相机一定深度范围内的 splat
7433
7710
  if params.depthRangeLimit > 0.0 {
7434
7711
  if abs(viewPos.z) > params.depthRangeLimit { return; }
7435
7712
  }
7436
7713
 
7437
- // 深度编码 (viewPos.z 是负数)
7438
7714
  let depth = viewPos.z;
7439
7715
  let sortableDepth = encodeDepthKey(depth);
7440
7716
 
7441
- // 原子增加可见计数并获取索引
7442
- // indirectBuffer[1] 是 instance_count
7443
7717
  let visibleIdx = atomicAdd(&indirectBuffer[1], 1u);
7444
7718
 
7445
- // 写入可见点列表
7446
7719
  depthKeys[visibleIdx] = sortableDepth;
7447
7720
  visibleIndices[visibleIdx] = i;
7448
7721
  }
@@ -7757,7 +8030,7 @@ fn downsweep(
7757
8030
  );
7758
8031
  }
7759
8032
  class GSSplatSorter {
7760
- constructor(device, splatCount, splatBuffer, cameraBuffer, _options = {}) {
8033
+ constructor(device, splatCount, splatBuffer, cameraBuffer, _options = {}, compact = false, half = false, sortBits = 32) {
7761
8034
  __publicField(this, "device");
7762
8035
  __publicField(this, "splatCount");
7763
8036
  // Culling Buffers
@@ -7787,11 +8060,12 @@ class GSSplatSorter {
7787
8060
  __publicField(this, "upsweepBindGroupLayout");
7788
8061
  __publicField(this, "spineBindGroupLayout");
7789
8062
  __publicField(this, "downsweepBindGroupLayout");
7790
- // Bind groups for each pass (4 passes)
8063
+ // Bind groups for each pass
7791
8064
  __publicField(this, "upsweepBindGroups", []);
7792
8065
  __publicField(this, "spineBindGroups", []);
7793
8066
  __publicField(this, "downsweepBindGroups", []);
7794
8067
  __publicField(this, "numPartitions");
8068
+ __publicField(this, "numSortPasses");
7795
8069
  // 屏幕信息和剔除选项
7796
8070
  __publicField(this, "screenWidth", 1920);
7797
8071
  __publicField(this, "screenHeight", 1080);
@@ -7804,14 +8078,19 @@ class GSSplatSorter {
7804
8078
  this.device = device;
7805
8079
  this.splatCount = splatCount;
7806
8080
  this.numPartitions = Math.ceil(splatCount / BLOCK_SIZE);
8081
+ this.numSortPasses = sortBits / RADIX_BITS;
8082
+ const dbg = getDebugOverlay();
8083
+ dbg == null ? void 0 : dbg.info(`Sorter init: ${splatCount} splats, compact=${compact}, half=${half}, sortBits=${sortBits}`);
7807
8084
  const cullingModule = device.createShaderModule({
7808
- code: generateCullingShaderCode$1(),
8085
+ code: generateCullingShaderCode$1(compact, half, sortBits),
7809
8086
  label: "culling-shader"
7810
8087
  });
8088
+ dbg == null ? void 0 : dbg.checkShader(cullingModule, "culling");
7811
8089
  const radixSortModule = device.createShaderModule({
7812
8090
  code: generateRadixSortShaderCode(),
7813
8091
  label: "radix-sort-shader"
7814
8092
  });
8093
+ dbg == null ? void 0 : dbg.checkShader(radixSortModule, "radix-sort");
7815
8094
  this.cullingParamsBuffer = device.createBuffer({
7816
8095
  size: 48,
7817
8096
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
@@ -7829,12 +8108,11 @@ class GSSplatSorter {
7829
8108
  });
7830
8109
  this.indirectBuffer = device.createBuffer({
7831
8110
  size: 16,
7832
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_DST,
8111
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
7833
8112
  label: "indirect-buffer"
7834
8113
  });
7835
8114
  this.globalHistogramBuffer = device.createBuffer({
7836
- size: RADIX_SIZE * 4 * 4,
7837
- // 4 passes * 256 bins * 4 bytes
8115
+ size: RADIX_SIZE * this.numSortPasses * 4,
7838
8116
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
7839
8117
  label: "global-histogram"
7840
8118
  });
@@ -7853,7 +8131,7 @@ class GSSplatSorter {
7853
8131
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
7854
8132
  label: "values-temp"
7855
8133
  });
7856
- for (let i = 0; i < 4; i++) {
8134
+ for (let i = 0; i < this.numSortPasses; i++) {
7857
8135
  const paramsBuffer = device.createBuffer({
7858
8136
  size: 16,
7859
8137
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
@@ -7965,16 +8243,12 @@ class GSSplatSorter {
7965
8243
  }
7966
8244
  /**
7967
8245
  * 创建 Radix Sort 的 bind groups
7968
- * 4 个 pass,使用 ping-pong buffers
7969
- *
7970
- * Ping-pong 模式:
7971
- * - Pass 0: depthKeys/visibleIndices -> keysTempBuffer/valuesTempBuffer
7972
- * - Pass 1: keysTempBuffer/valuesTempBuffer -> depthKeys/visibleIndices
7973
- * - Pass 2: depthKeys/visibleIndices -> keysTempBuffer/valuesTempBuffer
7974
- * - Pass 3: keysTempBuffer/valuesTempBuffer -> (depthKeys)/sortedIndicesBuffer
8246
+ * numSortPasses 个 pass,使用 ping-pong buffers
8247
+ * 最后一个 pass 的 values 输出到 sortedIndicesBuffer
7975
8248
  */
7976
8249
  createRadixSortBindGroups() {
7977
- for (let passIdx = 0; passIdx < 4; passIdx++) {
8250
+ const lastPassIdx = this.numSortPasses - 1;
8251
+ for (let passIdx = 0; passIdx < this.numSortPasses; passIdx++) {
7978
8252
  const isEvenPass = passIdx % 2 === 0;
7979
8253
  const keysIn = isEvenPass ? this.depthKeysBuffer : this.keysTempBuffer;
7980
8254
  const valuesIn = isEvenPass ? this.visibleIndicesBuffer : this.valuesTempBuffer;
@@ -7982,10 +8256,10 @@ class GSSplatSorter {
7982
8256
  let valuesOut;
7983
8257
  if (isEvenPass) {
7984
8258
  keysOut = this.keysTempBuffer;
7985
- valuesOut = this.valuesTempBuffer;
8259
+ valuesOut = passIdx === lastPassIdx ? this.sortedIndicesBuffer : this.valuesTempBuffer;
7986
8260
  } else {
7987
8261
  keysOut = this.depthKeysBuffer;
7988
- valuesOut = passIdx === 3 ? this.sortedIndicesBuffer : this.visibleIndicesBuffer;
8262
+ valuesOut = passIdx === lastPassIdx ? this.sortedIndicesBuffer : this.visibleIndicesBuffer;
7989
8263
  }
7990
8264
  this.upsweepBindGroups[passIdx] = this.device.createBindGroup({
7991
8265
  layout: this.upsweepBindGroupLayout,
@@ -8053,16 +8327,15 @@ class GSSplatSorter {
8053
8327
  view.setFloat32(24, this.cullingOptions.pixelThreshold, true);
8054
8328
  view.setUint32(28, this.cullingOptions.maxVisibleCount ?? 0, true);
8055
8329
  view.setFloat32(32, this.cullingOptions.depthRangeLimit ?? 0, true);
8330
+ view.setFloat32(36, this.cullingOptions.lodSkipRate ?? 0, true);
8056
8331
  this.device.queue.writeBuffer(this.cullingParamsBuffer, 0, cullingParamsData);
8332
+ this.device.queue.writeBuffer(
8333
+ this.indirectBuffer,
8334
+ 0,
8335
+ new Uint32Array([4, 0, 0, 0])
8336
+ );
8057
8337
  const encoder = this.device.createCommandEncoder({ label: "splat-sort-encoder" });
8058
8338
  encoder.clearBuffer(this.globalHistogramBuffer);
8059
- {
8060
- const pass = encoder.beginComputePass({ label: "init-indirect" });
8061
- pass.setPipeline(this.initIndirectPipeline);
8062
- pass.setBindGroup(0, this.cullingBindGroup);
8063
- pass.dispatchWorkgroups(1);
8064
- pass.end();
8065
- }
8066
8339
  {
8067
8340
  const pass = encoder.beginComputePass({ label: "project-cull" });
8068
8341
  pass.setPipeline(this.projectCullPipeline);
@@ -8070,7 +8343,7 @@ class GSSplatSorter {
8070
8343
  pass.dispatchWorkgroups(Math.ceil(this.splatCount / WORKGROUP_SIZE$1));
8071
8344
  pass.end();
8072
8345
  }
8073
- for (let passIdx = 0; passIdx < 4; passIdx++) {
8346
+ for (let passIdx = 0; passIdx < this.numSortPasses; passIdx++) {
8074
8347
  {
8075
8348
  const pass = encoder.beginComputePass({ label: `upsweep-p${passIdx}` });
8076
8349
  pass.setPipeline(this.upsweepPipeline);
@@ -8114,6 +8387,24 @@ class GSSplatSorter {
8114
8387
  getDrawIndirectBuffer() {
8115
8388
  return this.indirectBuffer;
8116
8389
  }
8390
+ /**
8391
+ * 异步读回 drawIndirect buffer 内容(调试用)
8392
+ * 返回 [vertexCount, instanceCount, firstVertex, firstInstance]
8393
+ */
8394
+ async readbackIndirect() {
8395
+ const staging = this.device.createBuffer({
8396
+ size: 16,
8397
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
8398
+ });
8399
+ const encoder = this.device.createCommandEncoder();
8400
+ encoder.copyBufferToBuffer(this.indirectBuffer, 0, staging, 0, 16);
8401
+ this.device.queue.submit([encoder.finish()]);
8402
+ await staging.mapAsync(GPUMapMode.READ);
8403
+ const result = new Uint32Array(staging.getMappedRange().slice(0));
8404
+ staging.unmap();
8405
+ staging.destroy();
8406
+ return result;
8407
+ }
8117
8408
  /**
8118
8409
  * 获取 splat 总数量
8119
8410
  */
@@ -8361,10 +8652,6 @@ fn computeExtentBasisAA(cov2dIn: vec3<f32>, opacity: f32, viewportSize: vec2<f32
8361
8652
  // 使用基于视口的最大限制 (匹配 PlayCanvas)
8362
8653
  let vmin = min(1024.0, min(viewportSize.x, viewportSize.y));
8363
8654
 
8364
- // 计算轴长度: l = 2 * sqrt(2 * lambda) ≈ 2.83 * sqrt(lambda)
8365
- // 这与 GAUSSIAN_K=4 配套使用:
8366
- // 在 UV=1 边界,对应 2*sqrt(2) 个标准差的位置
8367
- // exp(-4 * 1) = exp(-4) ≈ 0.018,Normalized 后精确为 0
8368
8655
  let l1 = min(2.0 * sqrt(2.0 * lambda1), vmin);
8369
8656
  let l2 = min(2.0 * sqrt(2.0 * lambda2), vmin);
8370
8657
 
@@ -8381,7 +8668,6 @@ fn computeExtentBasisAA(cov2dIn: vec3<f32>, opacity: f32, viewportSize: vec2<f32
8381
8668
  let eigenvector1 = diagVec;
8382
8669
  let eigenvector2 = vec2<f32>(diagVec.y, -diagVec.x);
8383
8670
 
8384
- // 计算基向量 (不应用额外的 splat_scale,因为我们使用默认值 1.0)
8385
8671
  result.basis = vec4<f32>(eigenvector1 * l1, eigenvector2 * l2);
8386
8672
  result.adjustedOpacity = alpha;
8387
8673
  return result;
@@ -8775,7 +9061,79 @@ fn fs_depth_normal(input: VertexOutput) -> FragOutput {
8775
9061
  }
8776
9062
  `
8777
9063
  );
9064
+ const SPLAT_BYTE_SIZE = 256;
8778
9065
  const SPLAT_FLOAT_COUNT = 64;
9066
+ const COMPACT_SPLAT_BYTE_SIZE = 64;
9067
+ const COMPACT_SPLAT_FLOAT_COUNT = 16;
9068
+ const HALF_SPLAT_BYTE_SIZE = 32;
9069
+ const HALF_SPLAT_U32_COUNT = 8;
9070
+ function transformShaderForCompact(code) {
9071
+ code = code.replace(
9072
+ /struct Splat \{[\s\S]*?\n\}/,
9073
+ `struct Splat {
9074
+ mean: vec3<f32>, _pad0: f32,
9075
+ scale: vec3<f32>, _pad1: f32,
9076
+ rotation: vec4<f32>,
9077
+ colorDC: vec3<f32>,
9078
+ opacity: f32,
9079
+ }`
9080
+ );
9081
+ code = code.replace(
9082
+ /fn evalSH\(splat: Splat, dir: vec3<f32>\) -> vec3<f32> \{[\s\S]*?\n\}/,
9083
+ `fn evalSH(splat: Splat, dir: vec3<f32>) -> vec3<f32> {
9084
+ return vec3<f32>(0.0);
9085
+ }`
9086
+ );
9087
+ return code;
9088
+ }
9089
+ function transformShaderForHalf(code) {
9090
+ code = code.replace(
9091
+ /struct Splat \{[\s\S]*?\n\}/,
9092
+ `struct Splat {
9093
+ mean: vec3<f32>, _pad0: f32,
9094
+ scale: vec3<f32>, _pad1: f32,
9095
+ rotation: vec4<f32>,
9096
+ colorDC: vec3<f32>,
9097
+ opacity: f32,
9098
+ }`
9099
+ );
9100
+ code = code.replace(
9101
+ /@group\(0\)\s*@binding\((\d+)\)\s*var<storage,\s*read>\s*splats\s*:\s*array<Splat>/,
9102
+ `@group(0) @binding($1) var<storage, read> splatDataU32: array<u32>`
9103
+ );
9104
+ const unpackFunctions = `
9105
+ const HALF_U32S: u32 = 8u;
9106
+ fn unpackSplatFromHalf(idx: u32) -> Splat {
9107
+ let b = idx * HALF_U32S;
9108
+ let mean_xy = unpack2x16float(splatDataU32[b]);
9109
+ let mean_zp = unpack2x16float(splatDataU32[b + 1u]);
9110
+ let sc_xy = unpack2x16float(splatDataU32[b + 2u]);
9111
+ let sc_z_op = unpack2x16float(splatDataU32[b + 3u]);
9112
+ let r_xy = unpack2x16float(splatDataU32[b + 4u]);
9113
+ let r_zw = unpack2x16float(splatDataU32[b + 5u]);
9114
+ let c_rg = unpack2x16float(splatDataU32[b + 6u]);
9115
+ let c_bp = unpack2x16float(splatDataU32[b + 7u]);
9116
+ var s: Splat;
9117
+ s.mean = vec3<f32>(mean_xy.x, mean_xy.y, mean_zp.x);
9118
+ s._pad0 = 0.0;
9119
+ s.scale = vec3<f32>(sc_xy.x, sc_xy.y, sc_z_op.x);
9120
+ s._pad1 = 0.0;
9121
+ s.rotation = vec4<f32>(r_xy.x, r_xy.y, r_zw.x, r_zw.y);
9122
+ s.colorDC = vec3<f32>(c_rg.x, c_rg.y, c_bp.x);
9123
+ s.opacity = sc_z_op.y;
9124
+ return s;
9125
+ }
9126
+ `;
9127
+ code = code.replace(/(@vertex|@compute)/, unpackFunctions + "$1");
9128
+ code = code.replace(/splats\[([^\]]+)\]/g, "unpackSplatFromHalf($1)");
9129
+ code = code.replace(
9130
+ /fn evalSH\(splat: Splat, dir: vec3<f32>\) -> vec3<f32> \{[\s\S]*?\n\}/,
9131
+ `fn evalSH(splat: Splat, dir: vec3<f32>) -> vec3<f32> {
9132
+ return vec3<f32>(0.0);
9133
+ }`
9134
+ );
9135
+ return code;
9136
+ }
8779
9137
  const _GSSplatRenderer = class _GSSplatRenderer {
8780
9138
  constructor(renderer, camera) {
8781
9139
  __publicField(this, "renderer");
@@ -8789,6 +9147,12 @@ const _GSSplatRenderer = class _GSSplatRenderer {
8789
9147
  __publicField(this, "sorter", null);
8790
9148
  __publicField(this, "shMode", SHMode.L3);
8791
9149
  __publicField(this, "boundingBox", null);
9150
+ /** 紧凑布局模式:64B/splat,无 SH,适合移动端 */
9151
+ __publicField(this, "compactLayout", false);
9152
+ /** 半精度模式:32B/splat,所有数据用 f16 打包 */
9153
+ __publicField(this, "halfPrecision", false);
9154
+ /** 排序位宽:16-bit 排序减少一半 radix sort pass */
9155
+ __publicField(this, "sortBits", 32);
8792
9156
  // 预分配 uniform 上传缓冲区,避免每帧 GC(56 floats = 224 bytes)
8793
9157
  __publicField(this, "uniformData", new Float32Array(56));
8794
9158
  __publicField(this, "cpuPositions", null);
@@ -8802,6 +9166,7 @@ const _GSSplatRenderer = class _GSSplatRenderer {
8802
9166
  __publicField(this, "pixelCullThreshold", 1);
8803
9167
  __publicField(this, "maxVisibleSplats", 0);
8804
9168
  __publicField(this, "depthRangeLimit", 0);
9169
+ __publicField(this, "lodSkipRate", 0);
8805
9170
  // 排序优化:相机变化检测 + 频率控制
8806
9171
  __publicField(this, "lastSortViewMatrix", new Float32Array(16));
8807
9172
  __publicField(this, "lastSortProjMatrix", new Float32Array(16));
@@ -8814,6 +9179,7 @@ const _GSSplatRenderer = class _GSSplatRenderer {
8814
9179
  __publicField(this, "sortStateInitialized", false);
8815
9180
  __publicField(this, "sortFrequency", 1);
8816
9181
  __publicField(this, "frameCounter", 0);
9182
+ __publicField(this, "debugFrameLogged", false);
8817
9183
  // 编辑器状态
8818
9184
  __publicField(this, "editorStateBuffer", null);
8819
9185
  __publicField(this, "editorPipeline", null);
@@ -8838,15 +9204,56 @@ const _GSSplatRenderer = class _GSSplatRenderer {
8838
9204
  this.createUniformBuffer();
8839
9205
  this.updateModelMatrix();
8840
9206
  }
9207
+ /**
9208
+ * 启用/禁用紧凑布局模式(64B/splat,无 SH)。
9209
+ * 必须在 setCompactData / setData 之前调用。
9210
+ */
9211
+ setCompactLayout(compact) {
9212
+ if (this.compactLayout === compact) return;
9213
+ this.compactLayout = compact;
9214
+ if (compact) {
9215
+ this.shMode = SHMode.L0;
9216
+ }
9217
+ this.createPipeline();
9218
+ }
9219
+ /**
9220
+ * 设置排序位宽。16-bit 模式排序 pass 从 4 降到 2,排序速度翻倍。
9221
+ * 必须在 setData / setCompactData 之前调用。
9222
+ */
9223
+ setSortBits(bits2) {
9224
+ this.sortBits = bits2;
9225
+ }
9226
+ /**
9227
+ * 启用/禁用半精度模式(32B/splat,所有数据 f16 打包)。
9228
+ * 自动启用 compactLayout。必须在 setCompactData 之前调用。
9229
+ */
9230
+ setHalfPrecision(half) {
9231
+ if (this.halfPrecision === half) return;
9232
+ this.halfPrecision = half;
9233
+ if (half) {
9234
+ this.compactLayout = true;
9235
+ this.shMode = SHMode.L0;
9236
+ }
9237
+ this.createPipeline();
9238
+ }
8841
9239
  createPipeline() {
8842
9240
  const device = this.renderer.device;
8843
- const shaderCode = gsOptimizedShader.replace(
9241
+ const dbg = getDebugOverlay();
9242
+ let shaderCode = gsOptimizedShader.replace(
8844
9243
  "const SH_LEVEL: u32 = 3u; // @SH_LEVEL_INJECT@",
8845
9244
  `const SH_LEVEL: u32 = ${this.shMode}u;`
8846
9245
  );
9246
+ if (this.halfPrecision) {
9247
+ shaderCode = transformShaderForHalf(shaderCode);
9248
+ } else if (this.compactLayout) {
9249
+ shaderCode = transformShaderForCompact(shaderCode);
9250
+ }
9251
+ dbg == null ? void 0 : dbg.info(`createPipeline: SH=${this.shMode}, compact=${this.compactLayout}, half=${this.halfPrecision}`);
8847
9252
  const shaderModule = device.createShaderModule({
8848
- code: shaderCode
9253
+ code: shaderCode,
9254
+ label: "gs-main-shader"
8849
9255
  });
9256
+ dbg == null ? void 0 : dbg.checkShader(shaderModule, "gs-main");
8850
9257
  this.bindGroupLayout = device.createBindGroupLayout({
8851
9258
  entries: [
8852
9259
  {
@@ -8923,8 +9330,12 @@ const _GSSplatRenderer = class _GSSplatRenderer {
8923
9330
  // A采用"zero add one-minus-src-alpha"的混合模式计算Transmittance
8924
9331
  createDepthNormalPipeline() {
8925
9332
  const device = this.renderer.device;
9333
+ let dnShader = gsDepthNormalShader;
9334
+ if (this.compactLayout) {
9335
+ dnShader = transformShaderForCompact(dnShader);
9336
+ }
8926
9337
  const shaderModule = device.createShaderModule({
8927
- code: gsDepthNormalShader
9338
+ code: dnShader
8928
9339
  });
8929
9340
  const pipelineLayout = device.createPipelineLayout({
8930
9341
  bindGroupLayouts: [this.bindGroupLayout]
@@ -9103,6 +9514,17 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9103
9514
  getDepthRangeLimit() {
9104
9515
  return this.depthRangeLimit;
9105
9516
  }
9517
+ /**
9518
+ * 设置 LOD 抽稀率(0~1)
9519
+ * 远处 splat 按此比例随机跳过(确定性哈希,无闪烁)
9520
+ * 0 = 不抽稀
9521
+ */
9522
+ setLodSkipRate(rate) {
9523
+ this.lodSkipRate = Math.max(0, Math.min(1, rate));
9524
+ }
9525
+ getLodSkipRate() {
9526
+ return this.lodSkipRate;
9527
+ }
9106
9528
  /**
9107
9529
  * 设置排序频率
9108
9530
  * 1 = 每帧排序(默认),2 = 每 2 帧排序一次,以此类推
@@ -9145,6 +9567,7 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9145
9567
  }
9146
9568
  setData(splats) {
9147
9569
  const device = this.renderer.device;
9570
+ const dbg = getDebugOverlay();
9148
9571
  if (this.splatBuffer) {
9149
9572
  this.splatBuffer.destroy();
9150
9573
  }
@@ -9159,12 +9582,24 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9159
9582
  this.boundingBox = null;
9160
9583
  return;
9161
9584
  }
9585
+ const floatsPerSplat = this.compactLayout ? COMPACT_SPLAT_FLOAT_COUNT : SPLAT_FLOAT_COUNT;
9586
+ const bytesPerSplat = floatsPerSplat * 4;
9587
+ const maxStorageSize = device.limits.maxStorageBufferBindingSize;
9588
+ const maxSplatsForGPU = Math.floor(maxStorageSize / bytesPerSplat);
9589
+ if (this.splatCount > maxSplatsForGPU) {
9590
+ dbg == null ? void 0 : dbg.warn(
9591
+ `setData: truncating ${this.splatCount} -> ${maxSplatsForGPU} splats (maxStorageBufferBindingSize=${(maxStorageSize / 1048576).toFixed(0)}MB)`
9592
+ );
9593
+ this.splatCount = maxSplatsForGPU;
9594
+ splats = splats.slice(0, this.splatCount);
9595
+ }
9596
+ dbg == null ? void 0 : dbg.info(`setData: ${this.splatCount} splats, compact=${this.compactLayout}`);
9162
9597
  this.boundingBox = this.computeBoundingBox(splats);
9163
9598
  const positions = new Float32Array(this.splatCount * 3);
9164
- const data = new Float32Array(this.splatCount * SPLAT_FLOAT_COUNT);
9599
+ const data = new Float32Array(this.splatCount * floatsPerSplat);
9165
9600
  for (let i = 0; i < this.splatCount; i++) {
9166
9601
  const splat = splats[i];
9167
- const offset = i * SPLAT_FLOAT_COUNT;
9602
+ const offset = i * floatsPerSplat;
9168
9603
  positions[i * 3 + 0] = splat.mean[0];
9169
9604
  positions[i * 3 + 1] = splat.mean[1];
9170
9605
  positions[i * 3 + 2] = splat.mean[2];
@@ -9184,31 +9619,39 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9184
9619
  data[offset + 13] = splat.colorDC[1];
9185
9620
  data[offset + 14] = splat.colorDC[2];
9186
9621
  data[offset + 15] = splat.opacity;
9187
- const shRest = splat.shRest;
9188
- for (let j = 0; j < 9; j++) {
9189
- data[offset + 16 + j] = shRest ? shRest[j] : 0;
9190
- }
9191
- for (let j = 0; j < 15; j++) {
9192
- data[offset + 25 + j] = shRest ? shRest[9 + j] : 0;
9193
- }
9194
- for (let j = 0; j < 21; j++) {
9195
- data[offset + 40 + j] = shRest ? shRest[24 + j] : 0;
9622
+ if (!this.compactLayout) {
9623
+ const shRest = splat.shRest;
9624
+ for (let j = 0; j < 9; j++) {
9625
+ data[offset + 16 + j] = shRest ? shRest[j] : 0;
9626
+ }
9627
+ for (let j = 0; j < 15; j++) {
9628
+ data[offset + 25 + j] = shRest ? shRest[9 + j] : 0;
9629
+ }
9630
+ for (let j = 0; j < 21; j++) {
9631
+ data[offset + 40 + j] = shRest ? shRest[24 + j] : 0;
9632
+ }
9633
+ data[offset + 61] = 0;
9634
+ data[offset + 62] = 0;
9635
+ data[offset + 63] = 0;
9196
9636
  }
9197
- data[offset + 61] = 0;
9198
- data[offset + 62] = 0;
9199
- data[offset + 63] = 0;
9200
9637
  }
9638
+ dbg == null ? void 0 : dbg.info(`splatBuffer: ${(data.byteLength / 1048576).toFixed(1)}MB (${floatsPerSplat} floats/splat)`);
9201
9639
  this.splatBuffer = device.createBuffer({
9202
9640
  size: data.byteLength,
9203
9641
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
9204
9642
  });
9205
9643
  device.queue.writeBuffer(this.splatBuffer, 0, data);
9206
9644
  this.cpuPositions = positions;
9645
+ dbg == null ? void 0 : dbg.info(`Creating sorter: compact=${this.compactLayout}, sortBits=${this.sortBits}`);
9207
9646
  this.sorter = new GSSplatSorter(
9208
9647
  device,
9209
9648
  this.splatCount,
9210
9649
  this.splatBuffer,
9211
- this.uniformBuffer
9650
+ this.uniformBuffer,
9651
+ {},
9652
+ this.compactLayout,
9653
+ false,
9654
+ this.sortBits
9212
9655
  );
9213
9656
  this.sorter.setScreenSize(this.renderer.width, this.renderer.height);
9214
9657
  this.sorter.setCullingOptions({
@@ -9224,10 +9667,12 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9224
9667
  { binding: 2, resource: { buffer: this.sorter.getIndicesBuffer() } }
9225
9668
  ]
9226
9669
  });
9670
+ dbg == null ? void 0 : dbg.info(`setData complete, bindGroup created`);
9227
9671
  if (this.editorEnabled) this.rebuildEditorBindGroup();
9228
9672
  }
9229
9673
  setCompactData(compactData) {
9230
9674
  const device = this.renderer.device;
9675
+ const dbg = getDebugOverlay();
9231
9676
  if (this.splatBuffer) {
9232
9677
  this.splatBuffer.destroy();
9233
9678
  }
@@ -9242,20 +9687,98 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9242
9687
  this.boundingBox = null;
9243
9688
  return;
9244
9689
  }
9690
+ const bytesPerSplat = this.halfPrecision ? HALF_SPLAT_BYTE_SIZE : this.compactLayout ? COMPACT_SPLAT_BYTE_SIZE : SPLAT_BYTE_SIZE;
9691
+ const maxStorageSize = device.limits.maxStorageBufferBindingSize;
9692
+ const maxSplatsForGPU = Math.floor(maxStorageSize / bytesPerSplat);
9693
+ dbg == null ? void 0 : dbg.info(
9694
+ `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}`
9695
+ );
9696
+ if (this.splatCount > maxSplatsForGPU) {
9697
+ dbg == null ? void 0 : dbg.warn(
9698
+ `Splat count ${this.splatCount} exceeds GPU maxStorageBufferBindingSize (${(maxStorageSize / 1048576).toFixed(0)}MB = ${maxSplatsForGPU} splats). Truncating to ${maxSplatsForGPU}.`
9699
+ );
9700
+ this.splatCount = maxSplatsForGPU;
9701
+ }
9245
9702
  this.boundingBox = this.computeBoundingBoxFromCompact(compactData);
9246
- this.cpuPositions = new Float32Array(compactData.positions);
9247
- const includeSH = compactData.shCoeffs !== void 0;
9248
- const gpuData = compactDataToGPUBuffer(compactData, includeSH);
9703
+ let trimmedData;
9704
+ if (this.halfPrecision && this.lodSkipRate > 0) {
9705
+ const skipRate = this.lodSkipRate;
9706
+ const threshold = skipRate * 65535;
9707
+ const kept = [];
9708
+ for (let i = 0; i < this.splatCount; i++) {
9709
+ const hash = (i * 2654435761 | 0) >>> 16 & 65535;
9710
+ if (hash >= threshold) kept.push(i);
9711
+ }
9712
+ const n = kept.length;
9713
+ const pos = new Float32Array(n * 3);
9714
+ const scl = new Float32Array(n * 3);
9715
+ const rot = new Float32Array(n * 4);
9716
+ const col = new Float32Array(n * 3);
9717
+ const opa = new Float32Array(n);
9718
+ for (let k = 0; k < n; k++) {
9719
+ const i = kept[k];
9720
+ pos[k * 3] = compactData.positions[i * 3];
9721
+ pos[k * 3 + 1] = compactData.positions[i * 3 + 1];
9722
+ pos[k * 3 + 2] = compactData.positions[i * 3 + 2];
9723
+ scl[k * 3] = compactData.scales[i * 3];
9724
+ scl[k * 3 + 1] = compactData.scales[i * 3 + 1];
9725
+ scl[k * 3 + 2] = compactData.scales[i * 3 + 2];
9726
+ rot[k * 4] = compactData.rotations[i * 4];
9727
+ rot[k * 4 + 1] = compactData.rotations[i * 4 + 1];
9728
+ rot[k * 4 + 2] = compactData.rotations[i * 4 + 2];
9729
+ rot[k * 4 + 3] = compactData.rotations[i * 4 + 3];
9730
+ col[k * 3] = compactData.colors[i * 3];
9731
+ col[k * 3 + 1] = compactData.colors[i * 3 + 1];
9732
+ col[k * 3 + 2] = compactData.colors[i * 3 + 2];
9733
+ opa[k] = compactData.opacities[i];
9734
+ }
9735
+ trimmedData = { count: n, positions: pos, scales: scl, rotations: rot, colors: col, opacities: opa };
9736
+ this.splatCount = n;
9737
+ dbg == null ? void 0 : dbg.info(`CPU LOD filter: ${compactData.count} → ${n} splats (skip ${(skipRate * 100).toFixed(0)}%)`);
9738
+ } else {
9739
+ const includeSH = !this.compactLayout && compactData.shCoeffs !== void 0;
9740
+ trimmedData = this.splatCount < compactData.count ? {
9741
+ count: this.splatCount,
9742
+ positions: compactData.positions.slice(0, this.splatCount * 3),
9743
+ scales: compactData.scales.slice(0, this.splatCount * 3),
9744
+ rotations: compactData.rotations.slice(0, this.splatCount * 4),
9745
+ colors: compactData.colors.slice(0, this.splatCount * 3),
9746
+ opacities: compactData.opacities.slice(0, this.splatCount),
9747
+ shCoeffs: includeSH && compactData.shCoeffs ? compactData.shCoeffs.slice(0, this.splatCount * compactData.shCoeffs.length / compactData.count) : void 0
9748
+ } : compactData;
9749
+ }
9750
+ dbg == null ? void 0 : dbg.info(`setCompactData: ${this.splatCount} splats, compact=${this.compactLayout}, half=${this.halfPrecision}`);
9751
+ this.cpuPositions = new Float32Array(trimmedData.positions);
9752
+ let gpuBuf;
9753
+ if (this.halfPrecision) {
9754
+ const halfData = compactDataToGPUBufferHalf(trimmedData);
9755
+ gpuBuf = halfData.buffer;
9756
+ dbg == null ? void 0 : dbg.info(
9757
+ `gpuData(half): ${(halfData.byteLength / 1048576).toFixed(1)}MB, u32s=${halfData.length}, u32/splat=${HALF_SPLAT_U32_COUNT}`
9758
+ );
9759
+ } else {
9760
+ const includeSH = !this.compactLayout && compactData.shCoeffs !== void 0;
9761
+ const f32Data = compactDataToGPUBuffer(trimmedData, includeSH);
9762
+ gpuBuf = f32Data.buffer;
9763
+ dbg == null ? void 0 : dbg.info(
9764
+ `gpuData: ${(f32Data.byteLength / 1048576).toFixed(1)}MB, includeSH=${includeSH}, floats=${f32Data.length}, floats/splat=${f32Data.length / this.splatCount}`
9765
+ );
9766
+ }
9249
9767
  this.splatBuffer = device.createBuffer({
9250
- size: gpuData.byteLength,
9768
+ size: gpuBuf.byteLength,
9251
9769
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
9252
9770
  });
9253
- device.queue.writeBuffer(this.splatBuffer, 0, gpuData.buffer);
9771
+ device.queue.writeBuffer(this.splatBuffer, 0, gpuBuf);
9772
+ dbg == null ? void 0 : dbg.info(`Creating sorter: compact=${this.compactLayout}, half=${this.halfPrecision}, sortBits=${this.sortBits}`);
9254
9773
  this.sorter = new GSSplatSorter(
9255
9774
  device,
9256
9775
  this.splatCount,
9257
9776
  this.splatBuffer,
9258
- this.uniformBuffer
9777
+ this.uniformBuffer,
9778
+ {},
9779
+ this.compactLayout,
9780
+ this.halfPrecision,
9781
+ this.sortBits
9259
9782
  );
9260
9783
  this.sorter.setScreenSize(this.renderer.width, this.renderer.height);
9261
9784
  this.sorter.setCullingOptions({
@@ -9271,6 +9794,7 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9271
9794
  { binding: 2, resource: { buffer: this.sorter.getIndicesBuffer() } }
9272
9795
  ]
9273
9796
  });
9797
+ dbg == null ? void 0 : dbg.info(`setCompactData complete, bindGroup created`);
9274
9798
  if (this.editorEnabled) this.rebuildEditorBindGroup();
9275
9799
  this.sortStateInitialized = false;
9276
9800
  }
@@ -9294,7 +9818,7 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9294
9818
  this.renderer.device.queue.writeBuffer(this.uniformBuffer, 0, ud);
9295
9819
  const changed = this.needsSort();
9296
9820
  this.frameCounter++;
9297
- const shouldSort = changed || this.sortFrequency > 1 && this.frameCounter % this.sortFrequency === 0;
9821
+ const shouldSort = !this.sortStateInitialized || changed;
9298
9822
  if (shouldSort) {
9299
9823
  this.sorter.setScreenSize(this.renderer.width, this.renderer.height);
9300
9824
  this.sorter.setCullingOptions({
@@ -9302,9 +9826,24 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9302
9826
  farPlane: this.camera.far,
9303
9827
  pixelThreshold: this.pixelCullThreshold,
9304
9828
  maxVisibleCount: this.maxVisibleSplats,
9305
- depthRangeLimit: this.depthRangeLimit
9829
+ depthRangeLimit: this.depthRangeLimit,
9830
+ lodSkipRate: this.lodSkipRate
9306
9831
  });
9307
- this.sorter.sort();
9832
+ if (!this.debugFrameLogged) {
9833
+ const dbg = getDebugOverlay();
9834
+ const dev = this.renderer.device;
9835
+ dev.pushErrorScope("validation");
9836
+ dev.pushErrorScope("out-of-memory");
9837
+ this.sorter.sort();
9838
+ dev.popErrorScope().then((err2) => {
9839
+ if (err2) dbg == null ? void 0 : dbg.error(`Sort OOM: ${err2.message}`);
9840
+ });
9841
+ dev.popErrorScope().then((err2) => {
9842
+ if (err2) dbg == null ? void 0 : dbg.error(`Sort Validation: ${err2.message}`);
9843
+ });
9844
+ } else {
9845
+ this.sorter.sort();
9846
+ }
9308
9847
  }
9309
9848
  if (this.editorEnabled && this.editorPipeline && this.editorBindGroup) {
9310
9849
  pass.setPipeline(this.editorPipeline);
@@ -9314,6 +9853,28 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9314
9853
  pass.setBindGroup(0, this.bindGroup);
9315
9854
  }
9316
9855
  pass.drawIndirect(this.sorter.getDrawIndirectBuffer(), 0);
9856
+ if (!this.debugFrameLogged) {
9857
+ this.debugFrameLogged = true;
9858
+ const dbg = getDebugOverlay();
9859
+ dbg == null ? void 0 : dbg.info(
9860
+ `First render: splats=${this.splatCount}, canvas=${this.renderer.width}x${this.renderer.height}, editor=${this.editorEnabled}, depthWrite=${this.depthWriteEnabled}`
9861
+ );
9862
+ if (dbg && this.sorter) {
9863
+ const pos2 = this.camera.position;
9864
+ dbg.info(`Camera pos: [${pos2[0].toFixed(2)}, ${pos2[1].toFixed(2)}, ${pos2[2].toFixed(2)}]`);
9865
+ dbg.info(
9866
+ `Culling cfg: pixelThresh=${this.pixelCullThreshold.toFixed(2)}, maxVisible=${this.maxVisibleSplats}, depthRange=${this.depthRangeLimit.toFixed(2)}, near=${this.camera.near}, far=${this.camera.far}`
9867
+ );
9868
+ this.sorter.readbackIndirect().then((data) => {
9869
+ dbg.info(
9870
+ `DrawIndirect: vtx=${data[0]}, inst=${data[1]}, firstVtx=${data[2]}, firstInst=${data[3]}`
9871
+ );
9872
+ if (data[0] === 0 && data[1] === 0) {
9873
+ dbg.error("ALL splats culled! visible=0");
9874
+ }
9875
+ });
9876
+ }
9877
+ }
9317
9878
  }
9318
9879
  getSplatCount() {
9319
9880
  return this.splatCount;
@@ -9631,10 +10192,13 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9631
10192
  }
9632
10193
  createEditorPipeline() {
9633
10194
  const device = this.renderer.device;
9634
- const editorShaderCode = this.buildEditorShader().replace(
10195
+ let editorShaderCode = this.buildEditorShader().replace(
9635
10196
  "const SH_LEVEL: u32 = 3u; // @SH_LEVEL_INJECT@",
9636
10197
  `const SH_LEVEL: u32 = ${this.shMode}u;`
9637
10198
  );
10199
+ if (this.compactLayout) {
10200
+ editorShaderCode = transformShaderForCompact(editorShaderCode);
10201
+ }
9638
10202
  const shaderModule = device.createShaderModule({ code: editorShaderCode });
9639
10203
  this.editorBindGroupLayout = device.createBindGroupLayout({
9640
10204
  entries: [
@@ -18727,6 +19291,10 @@ class App {
18727
19291
  __publicField(this, "animationId", 0);
18728
19292
  // 是否使用移动端渲染器
18729
19293
  __publicField(this, "useMobileRenderer", false);
19294
+ // 移动端优化开关(默认关闭,由外部显式启用)
19295
+ __publicField(this, "mobileOptimizationsEnabled", false);
19296
+ // 移动端可见 splat 硬上限(保证稳定帧率)
19297
+ __publicField(this, "mobileMaxVisibleCap", 0);
18730
19298
  // 最近加载的 CompactSplatData(用于编辑器导出)
18731
19299
  __publicField(this, "lastCompactData", null);
18732
19300
  // 自适应性能控制
@@ -18736,6 +19304,17 @@ class App {
18736
19304
  });
18737
19305
  __publicField(this, "lastAppliedRenderScale", 1);
18738
19306
  __publicField(this, "baseRenderScale", 1);
19307
+ // 动态分辨率:移动时降分辨率,静止后恢复
19308
+ __publicField(this, "dynamicResolutionEnabled", false);
19309
+ __publicField(this, "dynResLastViewMatrix", new Float32Array(16));
19310
+ __publicField(this, "dynResStillFrames", 0);
19311
+ __publicField(this, "dynResCurrentScale", 1);
19312
+ __publicField(this, "DYNRES_MOVE_SCALE", 0.6);
19313
+ // 移动时 DPR*0.6(2.0*0.6=1.2)
19314
+ __publicField(this, "DYNRES_STILL_SCALE", 1);
19315
+ // 静止时 DPR*1.0
19316
+ __publicField(this, "DYNRES_STILL_THRESHOLD", 2);
19317
+ // 静止 2 帧即恢复
18739
19318
  // 绑定的事件处理函数
18740
19319
  __publicField(this, "boundOnResize");
18741
19320
  /** 额外渲染回调(在 gizmo 之前、场景辅助之后执行) */
@@ -18789,16 +19368,46 @@ class App {
18789
19368
  nearDepthRangeRatio: 2
18790
19369
  };
18791
19370
  console.log(
18792
- `[3DGS] Apple GPU detected (${this.renderer.gpuVendor}/${this.renderer.gpuArchitecture}), applying TBDR optimizations: SH→L1, depthWrite off, aggressive culling`
19371
+ `[3DGS] Apple GPU detected (${this.renderer.gpuVendor}/${this.renderer.gpuArchitecture}), applying TBDR optimizations: depthWrite off, aggressive culling`
18793
19372
  );
18794
19373
  }
18795
19374
  /**
18796
- * 创建桌面端 GSSplatRenderer 并自动应用平台优化
19375
+ * 启用/禁用移动端性能优化(默认关闭)
19376
+ * 开启后,加载模型时将自动应用:f16 半精度、16-bit 排序、像素剔除、动态分辨率等。
19377
+ * 应在 init() 之后、加载模型之前调用。
19378
+ */
19379
+ enableMobileOptimizations(enabled = true) {
19380
+ var _a2;
19381
+ this.mobileOptimizationsEnabled = enabled;
19382
+ if (enabled) {
19383
+ this.dynamicResolutionEnabled = true;
19384
+ createDebugOverlay();
19385
+ (_a2 = getDebugOverlay()) == null ? void 0 : _a2.info(
19386
+ `Mobile optimizations enabled. DPR=${window.devicePixelRatio}, canvas=${this.canvas.width}x${this.canvas.height}`
19387
+ );
19388
+ console.log(`[3DGS] Mobile optimizations: f16, sortBits=16, pixelThreshold=1.5, dynamicRes=ON`);
19389
+ } else {
19390
+ this.dynamicResolutionEnabled = false;
19391
+ }
19392
+ }
19393
+ isMobileOptimized() {
19394
+ return this.mobileOptimizationsEnabled;
19395
+ }
19396
+ /**
19397
+ * 创建 GSSplatRenderer 并自动应用平台优化
19398
+ * @param forMobile 移动端模式:半精度(32B/splat)+ SH L0,内存降为 1/8
18797
19399
  */
18798
- createDesktopGSRenderer() {
19400
+ createGSRendererUnified(forMobile = false) {
18799
19401
  const renderer = new GSSplatRenderer(this.renderer, this.camera);
18800
- if (this.renderer.isAppleGPU) {
19402
+ if (forMobile) {
19403
+ renderer.setHalfPrecision(true);
19404
+ renderer.setSortBits(16);
19405
+ renderer.setPixelCullThreshold(1.5);
19406
+ renderer.setSortFrequency(2);
19407
+ } else if (this.renderer.isAppleGPU) {
18801
19408
  renderer.setSHMode(SHMode.L1);
19409
+ }
19410
+ if (this.renderer.isAppleGPU) {
18802
19411
  renderer.setDepthWriteEnabled(false);
18803
19412
  }
18804
19413
  return renderer;
@@ -18845,7 +19454,7 @@ class App {
18845
19454
  */
18846
19455
  async addPLY(urlOrBuffer, onProgress, isLocalFile = false, coordinateSystem = "blender") {
18847
19456
  try {
18848
- const isMobile = isMobileDevice();
19457
+ const useMobileOpt = this.mobileOptimizationsEnabled;
18849
19458
  let buffer;
18850
19459
  if (typeof urlOrBuffer === "string") {
18851
19460
  buffer = await this.fetchWithProgress(
@@ -18868,40 +19477,26 @@ class App {
18868
19477
  onProgress(50 + parseProgress, "parse");
18869
19478
  }
18870
19479
  };
18871
- let gsRenderer;
18872
- if (isMobile) {
18873
- gsRenderer = new GSSplatRendererMobile(this.renderer, this.camera);
18874
- this.useMobileRenderer = true;
18875
- const compactData = await this.parsePLYBuffer(buffer, {
18876
- maxSplats: Infinity,
18877
- loadSH: false,
18878
- onProgress: parseProgressCallback,
18879
- coordinateSystem
18880
- });
18881
- if (onProgress) onProgress(90, "upload");
18882
- gsRenderer.setCompactData(compactData);
18883
- if (onProgress) onProgress(100, "upload");
18884
- this.lastCompactData = compactData;
18885
- this.sceneManager.setGSRenderer(gsRenderer);
18886
- this.hotspotManager.setGSRenderer(gsRenderer);
18887
- return compactData.count;
18888
- } else {
18889
- gsRenderer = this.createDesktopGSRenderer();
18890
- this.useMobileRenderer = false;
18891
- const compactData = await this.parsePLYBuffer(buffer, {
18892
- maxSplats: Infinity,
18893
- loadSH: true,
18894
- onProgress: parseProgressCallback,
18895
- coordinateSystem
18896
- });
18897
- if (onProgress) onProgress(90, "upload");
18898
- gsRenderer.setCompactData(compactData);
18899
- if (onProgress) onProgress(100, "upload");
18900
- this.lastCompactData = compactData;
18901
- this.sceneManager.setGSRenderer(gsRenderer);
18902
- this.hotspotManager.setGSRenderer(gsRenderer);
18903
- return compactData.count;
19480
+ if (useMobileOpt) {
19481
+ console.log(
19482
+ "[3DGS] Mobile optimizations active, using SH L0"
19483
+ );
18904
19484
  }
19485
+ const gsRenderer = this.createGSRendererUnified(useMobileOpt);
19486
+ this.useMobileRenderer = false;
19487
+ const compactData = await this.parsePLYBuffer(buffer, {
19488
+ maxSplats: Infinity,
19489
+ loadSH: !useMobileOpt,
19490
+ onProgress: parseProgressCallback,
19491
+ coordinateSystem
19492
+ });
19493
+ if (onProgress) onProgress(90, "upload");
19494
+ gsRenderer.setCompactData(compactData);
19495
+ if (onProgress) onProgress(100, "upload");
19496
+ this.lastCompactData = compactData;
19497
+ this.sceneManager.setGSRenderer(gsRenderer);
19498
+ this.hotspotManager.setGSRenderer(gsRenderer);
19499
+ return compactData.count;
18905
19500
  } catch (error) {
18906
19501
  throw error;
18907
19502
  }
@@ -18912,7 +19507,7 @@ class App {
18912
19507
  */
18913
19508
  async addSplat(urlOrBuffer, onProgress, isLocalFile = false, coordinateSystem = "blender") {
18914
19509
  try {
18915
- const isMobile = isMobileDevice();
19510
+ const useMobileOpt = this.mobileOptimizationsEnabled;
18916
19511
  let buffer;
18917
19512
  if (typeof urlOrBuffer === "string") {
18918
19513
  buffer = await this.fetchWithProgress(
@@ -18943,23 +19538,9 @@ class App {
18943
19538
  }
18944
19539
  if (onProgress) onProgress(90, "parse");
18945
19540
  if (onProgress) onProgress(90, "upload");
18946
- let gsRenderer;
18947
- if (isMobile) {
18948
- const mobileRenderer = new GSSplatRendererMobile(
18949
- this.renderer,
18950
- this.camera
18951
- );
18952
- this.useMobileRenderer = true;
18953
- const compactData = App.splatCpuToCompactData(splats);
18954
- mobileRenderer.setCompactData(compactData);
18955
- this.lastCompactData = compactData;
18956
- gsRenderer = mobileRenderer;
18957
- } else {
18958
- const desktopRenderer = this.createDesktopGSRenderer();
18959
- this.useMobileRenderer = false;
18960
- desktopRenderer.setData(splats);
18961
- gsRenderer = desktopRenderer;
18962
- }
19541
+ const gsRenderer = this.createGSRendererUnified(useMobileOpt);
19542
+ this.useMobileRenderer = false;
19543
+ gsRenderer.setData(splats);
18963
19544
  this.sceneManager.setGSRenderer(gsRenderer);
18964
19545
  this.hotspotManager.setGSRenderer(gsRenderer);
18965
19546
  if (onProgress) onProgress(100, "upload");
@@ -19004,7 +19585,7 @@ class App {
19004
19585
  */
19005
19586
  async addSOG(urlOrBuffer, onProgress, isLocalFile = false, coordinateSystem = "blender") {
19006
19587
  try {
19007
- const isMobile = isMobileDevice();
19588
+ const useMobileOpt = this.mobileOptimizationsEnabled;
19008
19589
  let buffer;
19009
19590
  if (typeof urlOrBuffer === "string") {
19010
19591
  buffer = await this.fetchWithProgress(
@@ -19046,14 +19627,8 @@ class App {
19046
19627
  }
19047
19628
  }
19048
19629
  if (onProgress) onProgress(90, "upload");
19049
- let gsRenderer;
19050
- if (isMobile) {
19051
- gsRenderer = new GSSplatRendererMobile(this.renderer, this.camera);
19052
- this.useMobileRenderer = true;
19053
- } else {
19054
- gsRenderer = this.createDesktopGSRenderer();
19055
- this.useMobileRenderer = false;
19056
- }
19630
+ const gsRenderer = this.createGSRendererUnified(useMobileOpt);
19631
+ this.useMobileRenderer = false;
19057
19632
  gsRenderer.setCompactData(compactData);
19058
19633
  this.lastCompactData = compactData;
19059
19634
  this.sceneManager.setGSRenderer(gsRenderer);
@@ -19104,6 +19679,21 @@ class App {
19104
19679
  this.render();
19105
19680
  this.animationId = requestAnimationFrame(this.animate.bind(this));
19106
19681
  }
19682
+ updateDynamicResolution() {
19683
+ if (!this.dynamicResolutionEnabled) return;
19684
+ const interacting = this.controls.isInteracting;
19685
+ if (interacting) {
19686
+ if (this.dynResCurrentScale !== this.DYNRES_MOVE_SCALE) {
19687
+ this.dynResCurrentScale = this.DYNRES_MOVE_SCALE;
19688
+ this.renderer.setRenderScale(this.DYNRES_MOVE_SCALE);
19689
+ }
19690
+ } else {
19691
+ if (this.dynResCurrentScale !== this.DYNRES_STILL_SCALE) {
19692
+ this.dynResCurrentScale = this.DYNRES_STILL_SCALE;
19693
+ this.renderer.setRenderScale(this.DYNRES_STILL_SCALE);
19694
+ }
19695
+ }
19696
+ }
19107
19697
  updateAdaptivePerformance() {
19108
19698
  if (!this.adaptivePerformanceEnabled) return;
19109
19699
  const gsRenderer = this.getGSRenderer();
@@ -19147,7 +19737,11 @@ class App {
19147
19737
  gsRenderer.setMaxVisibleSplats(0);
19148
19738
  } else {
19149
19739
  const ratio = cfg.nearVisibleRatio + (1 - cfg.nearVisibleRatio) * t;
19150
- gsRenderer.setMaxVisibleSplats(Math.round(splatCount * ratio));
19740
+ let maxVisible = Math.round(splatCount * ratio);
19741
+ if (this.mobileMaxVisibleCap > 0) {
19742
+ maxVisible = Math.min(maxVisible, this.mobileMaxVisibleCap);
19743
+ }
19744
+ gsRenderer.setMaxVisibleSplats(maxVisible);
19151
19745
  }
19152
19746
  if (cfg.enableDepthRangeLimit) {
19153
19747
  if (t >= 0.99) {
@@ -19157,16 +19751,13 @@ class App {
19157
19751
  gsRenderer.setDepthRangeLimit(depthRange);
19158
19752
  }
19159
19753
  }
19160
- if (t < 0.3) {
19161
- gsRenderer.setSortFrequency(2);
19162
- } else {
19163
- gsRenderer.setSortFrequency(1);
19164
- }
19754
+ gsRenderer.setSortFrequency(1);
19165
19755
  }
19166
19756
  render() {
19167
19757
  var _a2, _b2;
19168
19758
  this.camera.setAspect(this.renderer.getAspectRatio());
19169
19759
  this.controls.update();
19760
+ this.updateDynamicResolution();
19170
19761
  this.updateAdaptivePerformance();
19171
19762
  this.hotspotManager.updateBillboards();
19172
19763
  if ((_a2 = this.skyboxRenderer) == null ? void 0 : _a2.isActive) {
@@ -19742,10 +20333,6 @@ class App {
19742
20333
  if (gsRenderer) {
19743
20334
  gsRenderer.setSortFrequency(frequency);
19744
20335
  }
19745
- const mobileRenderer = this.getGSRendererMobile();
19746
- if (mobileRenderer) {
19747
- mobileRenderer.setSortFrequency(frequency);
19748
- }
19749
20336
  }
19750
20337
  /**
19751
20338
  * 是否检测到 Apple GPU(M1/M2/M3 等)