@d5techs/3dgs-lib 1.4.83 → 1.4.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/3dgs-lib.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 上下文");
@@ -687,6 +829,9 @@ const _OrbitControls = class _OrbitControls {
687
829
  this.setupEventListeners();
688
830
  this.applySpherical();
689
831
  }
832
+ get isInteracting() {
833
+ return this.isDragging;
834
+ }
690
835
  setupEventListeners() {
691
836
  this.canvas.addEventListener("mousedown", this.boundOnMouseDown);
692
837
  this.canvas.addEventListener("mousemove", this.boundOnMouseMove);
@@ -5231,9 +5376,10 @@ function compactDataToGPUBuffer(data, includeFullSH = false) {
5231
5376
  }
5232
5377
  return buffer;
5233
5378
  } else {
5234
- const buffer = new Float32Array(count * 64);
5379
+ const COMPACT_FLOATS = 16;
5380
+ const buffer = new Float32Array(count * COMPACT_FLOATS);
5235
5381
  for (let i = 0; i < count; i++) {
5236
- const offset = i * 64;
5382
+ const offset = i * COMPACT_FLOATS;
5237
5383
  buffer[offset + 0] = data.positions[i * 3 + 0];
5238
5384
  buffer[offset + 1] = data.positions[i * 3 + 1];
5239
5385
  buffer[offset + 2] = data.positions[i * 3 + 2];
@@ -5254,9 +5400,56 @@ function compactDataToGPUBuffer(data, includeFullSH = false) {
5254
5400
  return buffer;
5255
5401
  }
5256
5402
  }
5403
+ const _f16Scratch = new Float32Array(1);
5404
+ const _f16ScratchU32 = new Uint32Array(_f16Scratch.buffer);
5405
+ function f32ToF16Bits(val) {
5406
+ _f16Scratch[0] = val;
5407
+ const bits2 = _f16ScratchU32[0];
5408
+ const sign = bits2 >>> 31 & 1;
5409
+ let exp = bits2 >>> 23 & 255;
5410
+ let frac = bits2 & 8388607;
5411
+ if (exp === 255) {
5412
+ return sign << 15 | 31744 | (frac ? 512 : 0);
5413
+ }
5414
+ exp = exp - 127 + 15;
5415
+ if (exp >= 31) {
5416
+ return sign << 15 | 31744;
5417
+ }
5418
+ if (exp <= 0) {
5419
+ if (exp < -10) return sign << 15;
5420
+ frac = (frac | 8388608) >> 1 - exp;
5421
+ return sign << 15 | frac >> 13;
5422
+ }
5423
+ return sign << 15 | exp << 10 | frac >> 13;
5424
+ }
5425
+ function compactDataToGPUBufferHalf(data) {
5426
+ const count = data.count;
5427
+ const U32_PER_SPLAT = 8;
5428
+ const buffer = new Uint32Array(count * U32_PER_SPLAT);
5429
+ const pos = data.positions;
5430
+ const scl = data.scales;
5431
+ const rot = data.rotations;
5432
+ const col = data.colors;
5433
+ const opa = data.opacities;
5434
+ for (let i = 0; i < count; i++) {
5435
+ const o = i * U32_PER_SPLAT;
5436
+ const i3 = i * 3;
5437
+ const i4 = i * 4;
5438
+ buffer[o] = f32ToF16Bits(pos[i3 + 1]) << 16 | f32ToF16Bits(pos[i3]);
5439
+ buffer[o + 1] = f32ToF16Bits(pos[i3 + 2]);
5440
+ buffer[o + 2] = f32ToF16Bits(scl[i3 + 1]) << 16 | f32ToF16Bits(scl[i3]);
5441
+ buffer[o + 3] = f32ToF16Bits(opa[i]) << 16 | f32ToF16Bits(scl[i3 + 2]);
5442
+ buffer[o + 4] = f32ToF16Bits(rot[i4 + 1]) << 16 | f32ToF16Bits(rot[i4]);
5443
+ buffer[o + 5] = f32ToF16Bits(rot[i4 + 3]) << 16 | f32ToF16Bits(rot[i4 + 2]);
5444
+ buffer[o + 6] = f32ToF16Bits(col[i3 + 1]) << 16 | f32ToF16Bits(col[i3]);
5445
+ buffer[o + 7] = f32ToF16Bits(col[i3 + 2]);
5446
+ }
5447
+ return buffer;
5448
+ }
5257
5449
  const PLYLoaderMobile = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
5258
5450
  __proto__: null,
5259
5451
  compactDataToGPUBuffer,
5452
+ compactDataToGPUBufferHalf,
5260
5453
  loadPLYMobile,
5261
5454
  parsePLYBuffer,
5262
5455
  transformSHCoeffsYZSwap
@@ -7316,16 +7509,38 @@ const RADIX_BITS = 8;
7316
7509
  const RADIX_SIZE = 256;
7317
7510
  const ELEMENTS_PER_THREAD = 4;
7318
7511
  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 {
7512
+ function generateCullingShaderCode$1(compact = false, half = false, sortBits = 32) {
7513
+ let splatBinding;
7514
+ let splatAccessCode;
7515
+ if (half) {
7516
+ splatBinding = `@group(0) @binding(0) var<storage, read> splatDataU32: array<u32>;`;
7517
+ splatAccessCode = `
7518
+ const HALF_U32S: u32 = 8u;
7519
+ fn getSplatMean(idx: u32) -> vec3<f32> {
7520
+ let b = idx * HALF_U32S;
7521
+ let xy = unpack2x16float(splatDataU32[b]);
7522
+ let zp = unpack2x16float(splatDataU32[b + 1u]);
7523
+ return vec3<f32>(xy.x, xy.y, zp.x);
7524
+ }
7525
+ fn getSplatScale(idx: u32) -> vec3<f32> {
7526
+ let b = idx * HALF_U32S;
7527
+ let xy = unpack2x16float(splatDataU32[b + 2u]);
7528
+ return vec3<f32>(xy.x, xy.y, unpack2x16float(splatDataU32[b + 3u]).x);
7529
+ }
7530
+ fn getSplatOpacity(idx: u32) -> f32 {
7531
+ let b = idx * HALF_U32S;
7532
+ return unpack2x16float(splatDataU32[b + 3u]).y;
7533
+ }`;
7534
+ } else {
7535
+ const splatStruct = compact ? `struct Splat {
7536
+ mean: vec3<f32>,
7537
+ _pad0: f32,
7538
+ scale: vec3<f32>,
7539
+ _pad1: f32,
7540
+ rotation: vec4<f32>,
7541
+ colorDC: vec3<f32>,
7542
+ opacity: f32,
7543
+ }` : `struct Splat {
7329
7544
  mean: vec3<f32>,
7330
7545
  _pad0: f32,
7331
7546
  scale: vec3<f32>,
@@ -7337,7 +7552,24 @@ struct Splat {
7337
7552
  sh2: array<f32, 15>,
7338
7553
  sh3: array<f32, 21>,
7339
7554
  _pad2: array<f32, 3>,
7340
- }
7555
+ }`;
7556
+ splatBinding = `${splatStruct}
7557
+ @group(0) @binding(0) var<storage, read> splats: array<Splat>;`;
7558
+ splatAccessCode = `
7559
+ fn getSplatMean(idx: u32) -> vec3<f32> { return splats[idx].mean; }
7560
+ fn getSplatScale(idx: u32) -> vec3<f32> { return splats[idx].scale; }
7561
+ fn getSplatOpacity(idx: u32) -> f32 { return splats[idx].opacity; }`;
7562
+ }
7563
+ return (
7564
+ /* wgsl */
7565
+ `
7566
+ /**
7567
+ * Project & Cull Shader
7568
+ * 基于 rfs-gsplat-render 实现
7569
+ */
7570
+
7571
+ ${splatBinding}
7572
+ ${splatAccessCode}
7341
7573
 
7342
7574
  struct CameraUniforms {
7343
7575
  view: mat4x4<f32>,
@@ -7357,10 +7589,9 @@ struct CullingParams {
7357
7589
  pixelThreshold: f32,
7358
7590
  maxVisibleCount: u32,
7359
7591
  depthRangeLimit: f32,
7360
- _pad_cull: f32,
7592
+ lodSkipRate: f32,
7361
7593
  }
7362
7594
 
7363
- @group(0) @binding(0) var<storage, read> splats: array<Splat>;
7364
7595
  @group(0) @binding(1) var<uniform> camera: CameraUniforms;
7365
7596
  @group(0) @binding(2) var<uniform> params: CullingParams;
7366
7597
  @group(0) @binding(3) var<storage, read_write> depthKeys: array<u32>;
@@ -7378,16 +7609,12 @@ fn getModelMaxScale(model: mat4x4<f32>) -> f32 {
7378
7609
  return max(max(sx, sy), sz);
7379
7610
  }
7380
7611
 
7381
- // IEEE 754 位操作编码浮点数为可排序的 u32
7382
- // 参考 rfs-gsplat-render 的 encode_min_max_fp32 实现
7383
7612
  fn encodeDepthKey(val: f32) -> u32 {
7384
7613
  var bits = bitcast<u32>(val);
7385
7614
  bits ^= bitcast<u32>(bitcast<i32>(bits) >> 31) | 0x80000000u;
7386
- return bits;
7615
+ return bits >> ${sortBits === 16 ? "16u" : "0u"};
7387
7616
  }
7388
7617
 
7389
- // 视锥剔除检查
7390
- // 基于 rfs-gsplat-render 的 is_in_frustum 实现
7391
7618
  fn isInFrustum(clipPos: vec4<f32>, frustumDilation: f32) -> bool {
7392
7619
  let clip = (1.0 + frustumDilation) * clipPos.w;
7393
7620
 
@@ -7406,43 +7633,47 @@ fn isInFrustum(clipPos: vec4<f32>, frustumDilation: f32) -> bool {
7406
7633
  fn projectAndCull(@builtin(global_invocation_id) gid: vec3<u32>) {
7407
7634
  let i = gid.x;
7408
7635
  if i >= params.splatCount { return; }
7636
+
7637
+ // LOD 抽稀放最前面:跳过的 splat 不做任何数据加载和矩阵运算
7638
+ // 哈希仅依赖 splat index → 被跳过的集合永远不变 → 零闪烁
7639
+ if params.lodSkipRate > 0.0 {
7640
+ let hash = ((i * 2654435761u) >> 16u) & 0xFFFFu;
7641
+ if f32(hash) < params.lodSkipRate * 65535.0 {
7642
+ return;
7643
+ }
7644
+ }
7409
7645
 
7410
- let splat = splats[i];
7646
+ let splatMean = getSplatMean(i);
7647
+ let splatScale = getSplatScale(i);
7648
+ let splatOpacity = getSplatOpacity(i);
7411
7649
 
7412
- // 透明度剔除
7413
- if splat.opacity < 0.004 { return; }
7650
+ if splatOpacity < 0.004 { return; }
7414
7651
 
7415
- // 变换: Local -> World -> View -> Clip
7416
- let worldPos = camera.model * vec4<f32>(splat.mean, 1.0);
7652
+ let worldPos = camera.model * vec4<f32>(splatMean, 1.0);
7417
7653
  let viewPos = camera.view * worldPos;
7418
7654
  let clipPos = camera.proj * viewPos;
7419
7655
 
7420
- // 视锥剔除
7421
7656
  if !isInFrustum(clipPos, params.frustumDilation) { return; }
7422
7657
 
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
- }
7658
+ let splatSigma = maxScale(splatScale) * getModelMaxScale(camera.model);
7659
+ let focalY = abs(camera.proj[1][1]) * params.screenHeight * 0.5;
7660
+ let projectedExtent = splatSigma * 3.0 * focalY / max(abs(viewPos.z), 0.001);
7661
+
7662
+ // 剔除投影尺寸过大的 splat(远离模型时去除周围遮挡物)
7663
+ if projectedExtent > params.screenHeight * 0.5 { return; }
7664
+
7665
+ // 剔除投影尺寸过小的 splat
7666
+ if params.pixelThreshold > 0.0 && projectedExtent < params.pixelThreshold { return; }
7431
7667
 
7432
- // 深度范围限制:只渲染距相机一定深度范围内的 splat
7433
7668
  if params.depthRangeLimit > 0.0 {
7434
7669
  if abs(viewPos.z) > params.depthRangeLimit { return; }
7435
7670
  }
7436
7671
 
7437
- // 深度编码 (viewPos.z 是负数)
7438
7672
  let depth = viewPos.z;
7439
7673
  let sortableDepth = encodeDepthKey(depth);
7440
7674
 
7441
- // 原子增加可见计数并获取索引
7442
- // indirectBuffer[1] 是 instance_count
7443
7675
  let visibleIdx = atomicAdd(&indirectBuffer[1], 1u);
7444
7676
 
7445
- // 写入可见点列表
7446
7677
  depthKeys[visibleIdx] = sortableDepth;
7447
7678
  visibleIndices[visibleIdx] = i;
7448
7679
  }
@@ -7757,7 +7988,7 @@ fn downsweep(
7757
7988
  );
7758
7989
  }
7759
7990
  class GSSplatSorter {
7760
- constructor(device, splatCount, splatBuffer, cameraBuffer, _options = {}) {
7991
+ constructor(device, splatCount, splatBuffer, cameraBuffer, _options = {}, compact = false, half = false, sortBits = 32) {
7761
7992
  __publicField(this, "device");
7762
7993
  __publicField(this, "splatCount");
7763
7994
  // Culling Buffers
@@ -7787,11 +8018,12 @@ class GSSplatSorter {
7787
8018
  __publicField(this, "upsweepBindGroupLayout");
7788
8019
  __publicField(this, "spineBindGroupLayout");
7789
8020
  __publicField(this, "downsweepBindGroupLayout");
7790
- // Bind groups for each pass (4 passes)
8021
+ // Bind groups for each pass
7791
8022
  __publicField(this, "upsweepBindGroups", []);
7792
8023
  __publicField(this, "spineBindGroups", []);
7793
8024
  __publicField(this, "downsweepBindGroups", []);
7794
8025
  __publicField(this, "numPartitions");
8026
+ __publicField(this, "numSortPasses");
7795
8027
  // 屏幕信息和剔除选项
7796
8028
  __publicField(this, "screenWidth", 1920);
7797
8029
  __publicField(this, "screenHeight", 1080);
@@ -7804,14 +8036,19 @@ class GSSplatSorter {
7804
8036
  this.device = device;
7805
8037
  this.splatCount = splatCount;
7806
8038
  this.numPartitions = Math.ceil(splatCount / BLOCK_SIZE);
8039
+ this.numSortPasses = sortBits / RADIX_BITS;
8040
+ const dbg = getDebugOverlay();
8041
+ dbg == null ? void 0 : dbg.info(`Sorter init: ${splatCount} splats, compact=${compact}, half=${half}, sortBits=${sortBits}`);
7807
8042
  const cullingModule = device.createShaderModule({
7808
- code: generateCullingShaderCode$1(),
8043
+ code: generateCullingShaderCode$1(compact, half, sortBits),
7809
8044
  label: "culling-shader"
7810
8045
  });
8046
+ dbg == null ? void 0 : dbg.checkShader(cullingModule, "culling");
7811
8047
  const radixSortModule = device.createShaderModule({
7812
8048
  code: generateRadixSortShaderCode(),
7813
8049
  label: "radix-sort-shader"
7814
8050
  });
8051
+ dbg == null ? void 0 : dbg.checkShader(radixSortModule, "radix-sort");
7815
8052
  this.cullingParamsBuffer = device.createBuffer({
7816
8053
  size: 48,
7817
8054
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
@@ -7829,12 +8066,11 @@ class GSSplatSorter {
7829
8066
  });
7830
8067
  this.indirectBuffer = device.createBuffer({
7831
8068
  size: 16,
7832
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_DST,
8069
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
7833
8070
  label: "indirect-buffer"
7834
8071
  });
7835
8072
  this.globalHistogramBuffer = device.createBuffer({
7836
- size: RADIX_SIZE * 4 * 4,
7837
- // 4 passes * 256 bins * 4 bytes
8073
+ size: RADIX_SIZE * this.numSortPasses * 4,
7838
8074
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
7839
8075
  label: "global-histogram"
7840
8076
  });
@@ -7853,7 +8089,7 @@ class GSSplatSorter {
7853
8089
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
7854
8090
  label: "values-temp"
7855
8091
  });
7856
- for (let i = 0; i < 4; i++) {
8092
+ for (let i = 0; i < this.numSortPasses; i++) {
7857
8093
  const paramsBuffer = device.createBuffer({
7858
8094
  size: 16,
7859
8095
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
@@ -7965,16 +8201,12 @@ class GSSplatSorter {
7965
8201
  }
7966
8202
  /**
7967
8203
  * 创建 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
8204
+ * numSortPasses 个 pass,使用 ping-pong buffers
8205
+ * 最后一个 pass 的 values 输出到 sortedIndicesBuffer
7975
8206
  */
7976
8207
  createRadixSortBindGroups() {
7977
- for (let passIdx = 0; passIdx < 4; passIdx++) {
8208
+ const lastPassIdx = this.numSortPasses - 1;
8209
+ for (let passIdx = 0; passIdx < this.numSortPasses; passIdx++) {
7978
8210
  const isEvenPass = passIdx % 2 === 0;
7979
8211
  const keysIn = isEvenPass ? this.depthKeysBuffer : this.keysTempBuffer;
7980
8212
  const valuesIn = isEvenPass ? this.visibleIndicesBuffer : this.valuesTempBuffer;
@@ -7982,10 +8214,10 @@ class GSSplatSorter {
7982
8214
  let valuesOut;
7983
8215
  if (isEvenPass) {
7984
8216
  keysOut = this.keysTempBuffer;
7985
- valuesOut = this.valuesTempBuffer;
8217
+ valuesOut = passIdx === lastPassIdx ? this.sortedIndicesBuffer : this.valuesTempBuffer;
7986
8218
  } else {
7987
8219
  keysOut = this.depthKeysBuffer;
7988
- valuesOut = passIdx === 3 ? this.sortedIndicesBuffer : this.visibleIndicesBuffer;
8220
+ valuesOut = passIdx === lastPassIdx ? this.sortedIndicesBuffer : this.visibleIndicesBuffer;
7989
8221
  }
7990
8222
  this.upsweepBindGroups[passIdx] = this.device.createBindGroup({
7991
8223
  layout: this.upsweepBindGroupLayout,
@@ -8053,16 +8285,15 @@ class GSSplatSorter {
8053
8285
  view.setFloat32(24, this.cullingOptions.pixelThreshold, true);
8054
8286
  view.setUint32(28, this.cullingOptions.maxVisibleCount ?? 0, true);
8055
8287
  view.setFloat32(32, this.cullingOptions.depthRangeLimit ?? 0, true);
8288
+ view.setFloat32(36, this.cullingOptions.lodSkipRate ?? 0, true);
8056
8289
  this.device.queue.writeBuffer(this.cullingParamsBuffer, 0, cullingParamsData);
8290
+ this.device.queue.writeBuffer(
8291
+ this.indirectBuffer,
8292
+ 0,
8293
+ new Uint32Array([4, 0, 0, 0])
8294
+ );
8057
8295
  const encoder = this.device.createCommandEncoder({ label: "splat-sort-encoder" });
8058
8296
  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
8297
  {
8067
8298
  const pass = encoder.beginComputePass({ label: "project-cull" });
8068
8299
  pass.setPipeline(this.projectCullPipeline);
@@ -8070,7 +8301,7 @@ class GSSplatSorter {
8070
8301
  pass.dispatchWorkgroups(Math.ceil(this.splatCount / WORKGROUP_SIZE$1));
8071
8302
  pass.end();
8072
8303
  }
8073
- for (let passIdx = 0; passIdx < 4; passIdx++) {
8304
+ for (let passIdx = 0; passIdx < this.numSortPasses; passIdx++) {
8074
8305
  {
8075
8306
  const pass = encoder.beginComputePass({ label: `upsweep-p${passIdx}` });
8076
8307
  pass.setPipeline(this.upsweepPipeline);
@@ -8114,6 +8345,24 @@ class GSSplatSorter {
8114
8345
  getDrawIndirectBuffer() {
8115
8346
  return this.indirectBuffer;
8116
8347
  }
8348
+ /**
8349
+ * 异步读回 drawIndirect buffer 内容(调试用)
8350
+ * 返回 [vertexCount, instanceCount, firstVertex, firstInstance]
8351
+ */
8352
+ async readbackIndirect() {
8353
+ const staging = this.device.createBuffer({
8354
+ size: 16,
8355
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
8356
+ });
8357
+ const encoder = this.device.createCommandEncoder();
8358
+ encoder.copyBufferToBuffer(this.indirectBuffer, 0, staging, 0, 16);
8359
+ this.device.queue.submit([encoder.finish()]);
8360
+ await staging.mapAsync(GPUMapMode.READ);
8361
+ const result = new Uint32Array(staging.getMappedRange().slice(0));
8362
+ staging.unmap();
8363
+ staging.destroy();
8364
+ return result;
8365
+ }
8117
8366
  /**
8118
8367
  * 获取 splat 总数量
8119
8368
  */
@@ -8361,10 +8610,6 @@ fn computeExtentBasisAA(cov2dIn: vec3<f32>, opacity: f32, viewportSize: vec2<f32
8361
8610
  // 使用基于视口的最大限制 (匹配 PlayCanvas)
8362
8611
  let vmin = min(1024.0, min(viewportSize.x, viewportSize.y));
8363
8612
 
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
8613
  let l1 = min(2.0 * sqrt(2.0 * lambda1), vmin);
8369
8614
  let l2 = min(2.0 * sqrt(2.0 * lambda2), vmin);
8370
8615
 
@@ -8381,7 +8626,6 @@ fn computeExtentBasisAA(cov2dIn: vec3<f32>, opacity: f32, viewportSize: vec2<f32
8381
8626
  let eigenvector1 = diagVec;
8382
8627
  let eigenvector2 = vec2<f32>(diagVec.y, -diagVec.x);
8383
8628
 
8384
- // 计算基向量 (不应用额外的 splat_scale,因为我们使用默认值 1.0)
8385
8629
  result.basis = vec4<f32>(eigenvector1 * l1, eigenvector2 * l2);
8386
8630
  result.adjustedOpacity = alpha;
8387
8631
  return result;
@@ -8775,7 +9019,79 @@ fn fs_depth_normal(input: VertexOutput) -> FragOutput {
8775
9019
  }
8776
9020
  `
8777
9021
  );
9022
+ const SPLAT_BYTE_SIZE = 256;
8778
9023
  const SPLAT_FLOAT_COUNT = 64;
9024
+ const COMPACT_SPLAT_BYTE_SIZE = 64;
9025
+ const COMPACT_SPLAT_FLOAT_COUNT = 16;
9026
+ const HALF_SPLAT_BYTE_SIZE = 32;
9027
+ const HALF_SPLAT_U32_COUNT = 8;
9028
+ function transformShaderForCompact(code) {
9029
+ code = code.replace(
9030
+ /struct Splat \{[\s\S]*?\n\}/,
9031
+ `struct Splat {
9032
+ mean: vec3<f32>, _pad0: f32,
9033
+ scale: vec3<f32>, _pad1: f32,
9034
+ rotation: vec4<f32>,
9035
+ colorDC: vec3<f32>,
9036
+ opacity: f32,
9037
+ }`
9038
+ );
9039
+ code = code.replace(
9040
+ /fn evalSH\(splat: Splat, dir: vec3<f32>\) -> vec3<f32> \{[\s\S]*?\n\}/,
9041
+ `fn evalSH(splat: Splat, dir: vec3<f32>) -> vec3<f32> {
9042
+ return vec3<f32>(0.0);
9043
+ }`
9044
+ );
9045
+ return code;
9046
+ }
9047
+ function transformShaderForHalf(code) {
9048
+ code = code.replace(
9049
+ /struct Splat \{[\s\S]*?\n\}/,
9050
+ `struct Splat {
9051
+ mean: vec3<f32>, _pad0: f32,
9052
+ scale: vec3<f32>, _pad1: f32,
9053
+ rotation: vec4<f32>,
9054
+ colorDC: vec3<f32>,
9055
+ opacity: f32,
9056
+ }`
9057
+ );
9058
+ code = code.replace(
9059
+ /@group\(0\)\s*@binding\((\d+)\)\s*var<storage,\s*read>\s*splats\s*:\s*array<Splat>/,
9060
+ `@group(0) @binding($1) var<storage, read> splatDataU32: array<u32>`
9061
+ );
9062
+ const unpackFunctions = `
9063
+ const HALF_U32S: u32 = 8u;
9064
+ fn unpackSplatFromHalf(idx: u32) -> Splat {
9065
+ let b = idx * HALF_U32S;
9066
+ let mean_xy = unpack2x16float(splatDataU32[b]);
9067
+ let mean_zp = unpack2x16float(splatDataU32[b + 1u]);
9068
+ let sc_xy = unpack2x16float(splatDataU32[b + 2u]);
9069
+ let sc_z_op = unpack2x16float(splatDataU32[b + 3u]);
9070
+ let r_xy = unpack2x16float(splatDataU32[b + 4u]);
9071
+ let r_zw = unpack2x16float(splatDataU32[b + 5u]);
9072
+ let c_rg = unpack2x16float(splatDataU32[b + 6u]);
9073
+ let c_bp = unpack2x16float(splatDataU32[b + 7u]);
9074
+ var s: Splat;
9075
+ s.mean = vec3<f32>(mean_xy.x, mean_xy.y, mean_zp.x);
9076
+ s._pad0 = 0.0;
9077
+ s.scale = vec3<f32>(sc_xy.x, sc_xy.y, sc_z_op.x);
9078
+ s._pad1 = 0.0;
9079
+ s.rotation = vec4<f32>(r_xy.x, r_xy.y, r_zw.x, r_zw.y);
9080
+ s.colorDC = vec3<f32>(c_rg.x, c_rg.y, c_bp.x);
9081
+ s.opacity = sc_z_op.y;
9082
+ return s;
9083
+ }
9084
+ `;
9085
+ code = code.replace(/(@vertex|@compute)/, unpackFunctions + "$1");
9086
+ code = code.replace(/splats\[([^\]]+)\]/g, "unpackSplatFromHalf($1)");
9087
+ code = code.replace(
9088
+ /fn evalSH\(splat: Splat, dir: vec3<f32>\) -> vec3<f32> \{[\s\S]*?\n\}/,
9089
+ `fn evalSH(splat: Splat, dir: vec3<f32>) -> vec3<f32> {
9090
+ return vec3<f32>(0.0);
9091
+ }`
9092
+ );
9093
+ return code;
9094
+ }
8779
9095
  const _GSSplatRenderer = class _GSSplatRenderer {
8780
9096
  constructor(renderer, camera) {
8781
9097
  __publicField(this, "renderer");
@@ -8789,6 +9105,12 @@ const _GSSplatRenderer = class _GSSplatRenderer {
8789
9105
  __publicField(this, "sorter", null);
8790
9106
  __publicField(this, "shMode", SHMode.L3);
8791
9107
  __publicField(this, "boundingBox", null);
9108
+ /** 紧凑布局模式:64B/splat,无 SH,适合移动端 */
9109
+ __publicField(this, "compactLayout", false);
9110
+ /** 半精度模式:32B/splat,所有数据用 f16 打包 */
9111
+ __publicField(this, "halfPrecision", false);
9112
+ /** 排序位宽:16-bit 排序减少一半 radix sort pass */
9113
+ __publicField(this, "sortBits", 32);
8792
9114
  // 预分配 uniform 上传缓冲区,避免每帧 GC(56 floats = 224 bytes)
8793
9115
  __publicField(this, "uniformData", new Float32Array(56));
8794
9116
  __publicField(this, "cpuPositions", null);
@@ -8802,6 +9124,7 @@ const _GSSplatRenderer = class _GSSplatRenderer {
8802
9124
  __publicField(this, "pixelCullThreshold", 1);
8803
9125
  __publicField(this, "maxVisibleSplats", 0);
8804
9126
  __publicField(this, "depthRangeLimit", 0);
9127
+ __publicField(this, "lodSkipRate", 0);
8805
9128
  // 排序优化:相机变化检测 + 频率控制
8806
9129
  __publicField(this, "lastSortViewMatrix", new Float32Array(16));
8807
9130
  __publicField(this, "lastSortProjMatrix", new Float32Array(16));
@@ -8814,6 +9137,7 @@ const _GSSplatRenderer = class _GSSplatRenderer {
8814
9137
  __publicField(this, "sortStateInitialized", false);
8815
9138
  __publicField(this, "sortFrequency", 1);
8816
9139
  __publicField(this, "frameCounter", 0);
9140
+ __publicField(this, "debugFrameLogged", false);
8817
9141
  // 编辑器状态
8818
9142
  __publicField(this, "editorStateBuffer", null);
8819
9143
  __publicField(this, "editorPipeline", null);
@@ -8838,15 +9162,56 @@ const _GSSplatRenderer = class _GSSplatRenderer {
8838
9162
  this.createUniformBuffer();
8839
9163
  this.updateModelMatrix();
8840
9164
  }
9165
+ /**
9166
+ * 启用/禁用紧凑布局模式(64B/splat,无 SH)。
9167
+ * 必须在 setCompactData / setData 之前调用。
9168
+ */
9169
+ setCompactLayout(compact) {
9170
+ if (this.compactLayout === compact) return;
9171
+ this.compactLayout = compact;
9172
+ if (compact) {
9173
+ this.shMode = SHMode.L0;
9174
+ }
9175
+ this.createPipeline();
9176
+ }
9177
+ /**
9178
+ * 设置排序位宽。16-bit 模式排序 pass 从 4 降到 2,排序速度翻倍。
9179
+ * 必须在 setData / setCompactData 之前调用。
9180
+ */
9181
+ setSortBits(bits2) {
9182
+ this.sortBits = bits2;
9183
+ }
9184
+ /**
9185
+ * 启用/禁用半精度模式(32B/splat,所有数据 f16 打包)。
9186
+ * 自动启用 compactLayout。必须在 setCompactData 之前调用。
9187
+ */
9188
+ setHalfPrecision(half) {
9189
+ if (this.halfPrecision === half) return;
9190
+ this.halfPrecision = half;
9191
+ if (half) {
9192
+ this.compactLayout = true;
9193
+ this.shMode = SHMode.L0;
9194
+ }
9195
+ this.createPipeline();
9196
+ }
8841
9197
  createPipeline() {
8842
9198
  const device = this.renderer.device;
8843
- const shaderCode = gsOptimizedShader.replace(
9199
+ const dbg = getDebugOverlay();
9200
+ let shaderCode = gsOptimizedShader.replace(
8844
9201
  "const SH_LEVEL: u32 = 3u; // @SH_LEVEL_INJECT@",
8845
9202
  `const SH_LEVEL: u32 = ${this.shMode}u;`
8846
9203
  );
9204
+ if (this.halfPrecision) {
9205
+ shaderCode = transformShaderForHalf(shaderCode);
9206
+ } else if (this.compactLayout) {
9207
+ shaderCode = transformShaderForCompact(shaderCode);
9208
+ }
9209
+ dbg == null ? void 0 : dbg.info(`createPipeline: SH=${this.shMode}, compact=${this.compactLayout}, half=${this.halfPrecision}`);
8847
9210
  const shaderModule = device.createShaderModule({
8848
- code: shaderCode
9211
+ code: shaderCode,
9212
+ label: "gs-main-shader"
8849
9213
  });
9214
+ dbg == null ? void 0 : dbg.checkShader(shaderModule, "gs-main");
8850
9215
  this.bindGroupLayout = device.createBindGroupLayout({
8851
9216
  entries: [
8852
9217
  {
@@ -8923,8 +9288,12 @@ const _GSSplatRenderer = class _GSSplatRenderer {
8923
9288
  // A采用"zero add one-minus-src-alpha"的混合模式计算Transmittance
8924
9289
  createDepthNormalPipeline() {
8925
9290
  const device = this.renderer.device;
9291
+ let dnShader = gsDepthNormalShader;
9292
+ if (this.compactLayout) {
9293
+ dnShader = transformShaderForCompact(dnShader);
9294
+ }
8926
9295
  const shaderModule = device.createShaderModule({
8927
- code: gsDepthNormalShader
9296
+ code: dnShader
8928
9297
  });
8929
9298
  const pipelineLayout = device.createPipelineLayout({
8930
9299
  bindGroupLayouts: [this.bindGroupLayout]
@@ -9103,6 +9472,17 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9103
9472
  getDepthRangeLimit() {
9104
9473
  return this.depthRangeLimit;
9105
9474
  }
9475
+ /**
9476
+ * 设置 LOD 抽稀率(0~1)
9477
+ * 远处 splat 按此比例随机跳过(确定性哈希,无闪烁)
9478
+ * 0 = 不抽稀
9479
+ */
9480
+ setLodSkipRate(rate) {
9481
+ this.lodSkipRate = Math.max(0, Math.min(1, rate));
9482
+ }
9483
+ getLodSkipRate() {
9484
+ return this.lodSkipRate;
9485
+ }
9106
9486
  /**
9107
9487
  * 设置排序频率
9108
9488
  * 1 = 每帧排序(默认),2 = 每 2 帧排序一次,以此类推
@@ -9145,6 +9525,7 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9145
9525
  }
9146
9526
  setData(splats) {
9147
9527
  const device = this.renderer.device;
9528
+ const dbg = getDebugOverlay();
9148
9529
  if (this.splatBuffer) {
9149
9530
  this.splatBuffer.destroy();
9150
9531
  }
@@ -9159,12 +9540,24 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9159
9540
  this.boundingBox = null;
9160
9541
  return;
9161
9542
  }
9543
+ const floatsPerSplat = this.compactLayout ? COMPACT_SPLAT_FLOAT_COUNT : SPLAT_FLOAT_COUNT;
9544
+ const bytesPerSplat = floatsPerSplat * 4;
9545
+ const maxStorageSize = device.limits.maxStorageBufferBindingSize;
9546
+ const maxSplatsForGPU = Math.floor(maxStorageSize / bytesPerSplat);
9547
+ if (this.splatCount > maxSplatsForGPU) {
9548
+ dbg == null ? void 0 : dbg.warn(
9549
+ `setData: truncating ${this.splatCount} -> ${maxSplatsForGPU} splats (maxStorageBufferBindingSize=${(maxStorageSize / 1048576).toFixed(0)}MB)`
9550
+ );
9551
+ this.splatCount = maxSplatsForGPU;
9552
+ splats = splats.slice(0, this.splatCount);
9553
+ }
9554
+ dbg == null ? void 0 : dbg.info(`setData: ${this.splatCount} splats, compact=${this.compactLayout}`);
9162
9555
  this.boundingBox = this.computeBoundingBox(splats);
9163
9556
  const positions = new Float32Array(this.splatCount * 3);
9164
- const data = new Float32Array(this.splatCount * SPLAT_FLOAT_COUNT);
9557
+ const data = new Float32Array(this.splatCount * floatsPerSplat);
9165
9558
  for (let i = 0; i < this.splatCount; i++) {
9166
9559
  const splat = splats[i];
9167
- const offset = i * SPLAT_FLOAT_COUNT;
9560
+ const offset = i * floatsPerSplat;
9168
9561
  positions[i * 3 + 0] = splat.mean[0];
9169
9562
  positions[i * 3 + 1] = splat.mean[1];
9170
9563
  positions[i * 3 + 2] = splat.mean[2];
@@ -9184,31 +9577,39 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9184
9577
  data[offset + 13] = splat.colorDC[1];
9185
9578
  data[offset + 14] = splat.colorDC[2];
9186
9579
  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;
9580
+ if (!this.compactLayout) {
9581
+ const shRest = splat.shRest;
9582
+ for (let j = 0; j < 9; j++) {
9583
+ data[offset + 16 + j] = shRest ? shRest[j] : 0;
9584
+ }
9585
+ for (let j = 0; j < 15; j++) {
9586
+ data[offset + 25 + j] = shRest ? shRest[9 + j] : 0;
9587
+ }
9588
+ for (let j = 0; j < 21; j++) {
9589
+ data[offset + 40 + j] = shRest ? shRest[24 + j] : 0;
9590
+ }
9591
+ data[offset + 61] = 0;
9592
+ data[offset + 62] = 0;
9593
+ data[offset + 63] = 0;
9196
9594
  }
9197
- data[offset + 61] = 0;
9198
- data[offset + 62] = 0;
9199
- data[offset + 63] = 0;
9200
9595
  }
9596
+ dbg == null ? void 0 : dbg.info(`splatBuffer: ${(data.byteLength / 1048576).toFixed(1)}MB (${floatsPerSplat} floats/splat)`);
9201
9597
  this.splatBuffer = device.createBuffer({
9202
9598
  size: data.byteLength,
9203
9599
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
9204
9600
  });
9205
9601
  device.queue.writeBuffer(this.splatBuffer, 0, data);
9206
9602
  this.cpuPositions = positions;
9603
+ dbg == null ? void 0 : dbg.info(`Creating sorter: compact=${this.compactLayout}, sortBits=${this.sortBits}`);
9207
9604
  this.sorter = new GSSplatSorter(
9208
9605
  device,
9209
9606
  this.splatCount,
9210
9607
  this.splatBuffer,
9211
- this.uniformBuffer
9608
+ this.uniformBuffer,
9609
+ {},
9610
+ this.compactLayout,
9611
+ false,
9612
+ this.sortBits
9212
9613
  );
9213
9614
  this.sorter.setScreenSize(this.renderer.width, this.renderer.height);
9214
9615
  this.sorter.setCullingOptions({
@@ -9224,10 +9625,12 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9224
9625
  { binding: 2, resource: { buffer: this.sorter.getIndicesBuffer() } }
9225
9626
  ]
9226
9627
  });
9628
+ dbg == null ? void 0 : dbg.info(`setData complete, bindGroup created`);
9227
9629
  if (this.editorEnabled) this.rebuildEditorBindGroup();
9228
9630
  }
9229
9631
  setCompactData(compactData) {
9230
9632
  const device = this.renderer.device;
9633
+ const dbg = getDebugOverlay();
9231
9634
  if (this.splatBuffer) {
9232
9635
  this.splatBuffer.destroy();
9233
9636
  }
@@ -9242,20 +9645,98 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9242
9645
  this.boundingBox = null;
9243
9646
  return;
9244
9647
  }
9648
+ const bytesPerSplat = this.halfPrecision ? HALF_SPLAT_BYTE_SIZE : this.compactLayout ? COMPACT_SPLAT_BYTE_SIZE : SPLAT_BYTE_SIZE;
9649
+ const maxStorageSize = device.limits.maxStorageBufferBindingSize;
9650
+ const maxSplatsForGPU = Math.floor(maxStorageSize / bytesPerSplat);
9651
+ dbg == null ? void 0 : dbg.info(
9652
+ `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}`
9653
+ );
9654
+ if (this.splatCount > maxSplatsForGPU) {
9655
+ dbg == null ? void 0 : dbg.warn(
9656
+ `Splat count ${this.splatCount} exceeds GPU maxStorageBufferBindingSize (${(maxStorageSize / 1048576).toFixed(0)}MB = ${maxSplatsForGPU} splats). Truncating to ${maxSplatsForGPU}.`
9657
+ );
9658
+ this.splatCount = maxSplatsForGPU;
9659
+ }
9245
9660
  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);
9661
+ let trimmedData;
9662
+ if (this.halfPrecision && this.lodSkipRate > 0) {
9663
+ const skipRate = this.lodSkipRate;
9664
+ const threshold = skipRate * 65535;
9665
+ const kept = [];
9666
+ for (let i = 0; i < this.splatCount; i++) {
9667
+ const hash = (i * 2654435761 | 0) >>> 16 & 65535;
9668
+ if (hash >= threshold) kept.push(i);
9669
+ }
9670
+ const n = kept.length;
9671
+ const pos = new Float32Array(n * 3);
9672
+ const scl = new Float32Array(n * 3);
9673
+ const rot = new Float32Array(n * 4);
9674
+ const col = new Float32Array(n * 3);
9675
+ const opa = new Float32Array(n);
9676
+ for (let k = 0; k < n; k++) {
9677
+ const i = kept[k];
9678
+ pos[k * 3] = compactData.positions[i * 3];
9679
+ pos[k * 3 + 1] = compactData.positions[i * 3 + 1];
9680
+ pos[k * 3 + 2] = compactData.positions[i * 3 + 2];
9681
+ scl[k * 3] = compactData.scales[i * 3];
9682
+ scl[k * 3 + 1] = compactData.scales[i * 3 + 1];
9683
+ scl[k * 3 + 2] = compactData.scales[i * 3 + 2];
9684
+ rot[k * 4] = compactData.rotations[i * 4];
9685
+ rot[k * 4 + 1] = compactData.rotations[i * 4 + 1];
9686
+ rot[k * 4 + 2] = compactData.rotations[i * 4 + 2];
9687
+ rot[k * 4 + 3] = compactData.rotations[i * 4 + 3];
9688
+ col[k * 3] = compactData.colors[i * 3];
9689
+ col[k * 3 + 1] = compactData.colors[i * 3 + 1];
9690
+ col[k * 3 + 2] = compactData.colors[i * 3 + 2];
9691
+ opa[k] = compactData.opacities[i];
9692
+ }
9693
+ trimmedData = { count: n, positions: pos, scales: scl, rotations: rot, colors: col, opacities: opa };
9694
+ this.splatCount = n;
9695
+ dbg == null ? void 0 : dbg.info(`CPU LOD filter: ${compactData.count} → ${n} splats (skip ${(skipRate * 100).toFixed(0)}%)`);
9696
+ } else {
9697
+ const includeSH = !this.compactLayout && compactData.shCoeffs !== void 0;
9698
+ trimmedData = this.splatCount < compactData.count ? {
9699
+ count: this.splatCount,
9700
+ positions: compactData.positions.slice(0, this.splatCount * 3),
9701
+ scales: compactData.scales.slice(0, this.splatCount * 3),
9702
+ rotations: compactData.rotations.slice(0, this.splatCount * 4),
9703
+ colors: compactData.colors.slice(0, this.splatCount * 3),
9704
+ opacities: compactData.opacities.slice(0, this.splatCount),
9705
+ shCoeffs: includeSH && compactData.shCoeffs ? compactData.shCoeffs.slice(0, this.splatCount * compactData.shCoeffs.length / compactData.count) : void 0
9706
+ } : compactData;
9707
+ }
9708
+ dbg == null ? void 0 : dbg.info(`setCompactData: ${this.splatCount} splats, compact=${this.compactLayout}, half=${this.halfPrecision}`);
9709
+ this.cpuPositions = new Float32Array(trimmedData.positions);
9710
+ let gpuBuf;
9711
+ if (this.halfPrecision) {
9712
+ const halfData = compactDataToGPUBufferHalf(trimmedData);
9713
+ gpuBuf = halfData.buffer;
9714
+ dbg == null ? void 0 : dbg.info(
9715
+ `gpuData(half): ${(halfData.byteLength / 1048576).toFixed(1)}MB, u32s=${halfData.length}, u32/splat=${HALF_SPLAT_U32_COUNT}`
9716
+ );
9717
+ } else {
9718
+ const includeSH = !this.compactLayout && compactData.shCoeffs !== void 0;
9719
+ const f32Data = compactDataToGPUBuffer(trimmedData, includeSH);
9720
+ gpuBuf = f32Data.buffer;
9721
+ dbg == null ? void 0 : dbg.info(
9722
+ `gpuData: ${(f32Data.byteLength / 1048576).toFixed(1)}MB, includeSH=${includeSH}, floats=${f32Data.length}, floats/splat=${f32Data.length / this.splatCount}`
9723
+ );
9724
+ }
9249
9725
  this.splatBuffer = device.createBuffer({
9250
- size: gpuData.byteLength,
9726
+ size: gpuBuf.byteLength,
9251
9727
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
9252
9728
  });
9253
- device.queue.writeBuffer(this.splatBuffer, 0, gpuData.buffer);
9729
+ device.queue.writeBuffer(this.splatBuffer, 0, gpuBuf);
9730
+ dbg == null ? void 0 : dbg.info(`Creating sorter: compact=${this.compactLayout}, half=${this.halfPrecision}, sortBits=${this.sortBits}`);
9254
9731
  this.sorter = new GSSplatSorter(
9255
9732
  device,
9256
9733
  this.splatCount,
9257
9734
  this.splatBuffer,
9258
- this.uniformBuffer
9735
+ this.uniformBuffer,
9736
+ {},
9737
+ this.compactLayout,
9738
+ this.halfPrecision,
9739
+ this.sortBits
9259
9740
  );
9260
9741
  this.sorter.setScreenSize(this.renderer.width, this.renderer.height);
9261
9742
  this.sorter.setCullingOptions({
@@ -9271,6 +9752,7 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9271
9752
  { binding: 2, resource: { buffer: this.sorter.getIndicesBuffer() } }
9272
9753
  ]
9273
9754
  });
9755
+ dbg == null ? void 0 : dbg.info(`setCompactData complete, bindGroup created`);
9274
9756
  if (this.editorEnabled) this.rebuildEditorBindGroup();
9275
9757
  this.sortStateInitialized = false;
9276
9758
  }
@@ -9294,7 +9776,7 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9294
9776
  this.renderer.device.queue.writeBuffer(this.uniformBuffer, 0, ud);
9295
9777
  const changed = this.needsSort();
9296
9778
  this.frameCounter++;
9297
- const shouldSort = changed || this.sortFrequency > 1 && this.frameCounter % this.sortFrequency === 0;
9779
+ const shouldSort = !this.sortStateInitialized || changed;
9298
9780
  if (shouldSort) {
9299
9781
  this.sorter.setScreenSize(this.renderer.width, this.renderer.height);
9300
9782
  this.sorter.setCullingOptions({
@@ -9302,9 +9784,24 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9302
9784
  farPlane: this.camera.far,
9303
9785
  pixelThreshold: this.pixelCullThreshold,
9304
9786
  maxVisibleCount: this.maxVisibleSplats,
9305
- depthRangeLimit: this.depthRangeLimit
9787
+ depthRangeLimit: this.depthRangeLimit,
9788
+ lodSkipRate: this.lodSkipRate
9306
9789
  });
9307
- this.sorter.sort();
9790
+ if (!this.debugFrameLogged) {
9791
+ const dbg = getDebugOverlay();
9792
+ const dev = this.renderer.device;
9793
+ dev.pushErrorScope("validation");
9794
+ dev.pushErrorScope("out-of-memory");
9795
+ this.sorter.sort();
9796
+ dev.popErrorScope().then((err2) => {
9797
+ if (err2) dbg == null ? void 0 : dbg.error(`Sort OOM: ${err2.message}`);
9798
+ });
9799
+ dev.popErrorScope().then((err2) => {
9800
+ if (err2) dbg == null ? void 0 : dbg.error(`Sort Validation: ${err2.message}`);
9801
+ });
9802
+ } else {
9803
+ this.sorter.sort();
9804
+ }
9308
9805
  }
9309
9806
  if (this.editorEnabled && this.editorPipeline && this.editorBindGroup) {
9310
9807
  pass.setPipeline(this.editorPipeline);
@@ -9314,6 +9811,28 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9314
9811
  pass.setBindGroup(0, this.bindGroup);
9315
9812
  }
9316
9813
  pass.drawIndirect(this.sorter.getDrawIndirectBuffer(), 0);
9814
+ if (!this.debugFrameLogged) {
9815
+ this.debugFrameLogged = true;
9816
+ const dbg = getDebugOverlay();
9817
+ dbg == null ? void 0 : dbg.info(
9818
+ `First render: splats=${this.splatCount}, canvas=${this.renderer.width}x${this.renderer.height}, editor=${this.editorEnabled}, depthWrite=${this.depthWriteEnabled}`
9819
+ );
9820
+ if (dbg && this.sorter) {
9821
+ const pos2 = this.camera.position;
9822
+ dbg.info(`Camera pos: [${pos2[0].toFixed(2)}, ${pos2[1].toFixed(2)}, ${pos2[2].toFixed(2)}]`);
9823
+ dbg.info(
9824
+ `Culling cfg: pixelThresh=${this.pixelCullThreshold.toFixed(2)}, maxVisible=${this.maxVisibleSplats}, depthRange=${this.depthRangeLimit.toFixed(2)}, near=${this.camera.near}, far=${this.camera.far}`
9825
+ );
9826
+ this.sorter.readbackIndirect().then((data) => {
9827
+ dbg.info(
9828
+ `DrawIndirect: vtx=${data[0]}, inst=${data[1]}, firstVtx=${data[2]}, firstInst=${data[3]}`
9829
+ );
9830
+ if (data[0] === 0 && data[1] === 0) {
9831
+ dbg.error("ALL splats culled! visible=0");
9832
+ }
9833
+ });
9834
+ }
9835
+ }
9317
9836
  }
9318
9837
  getSplatCount() {
9319
9838
  return this.splatCount;
@@ -9631,10 +10150,13 @@ const _GSSplatRenderer = class _GSSplatRenderer {
9631
10150
  }
9632
10151
  createEditorPipeline() {
9633
10152
  const device = this.renderer.device;
9634
- const editorShaderCode = this.buildEditorShader().replace(
10153
+ let editorShaderCode = this.buildEditorShader().replace(
9635
10154
  "const SH_LEVEL: u32 = 3u; // @SH_LEVEL_INJECT@",
9636
10155
  `const SH_LEVEL: u32 = ${this.shMode}u;`
9637
10156
  );
10157
+ if (this.compactLayout) {
10158
+ editorShaderCode = transformShaderForCompact(editorShaderCode);
10159
+ }
9638
10160
  const shaderModule = device.createShaderModule({ code: editorShaderCode });
9639
10161
  this.editorBindGroupLayout = device.createBindGroupLayout({
9640
10162
  entries: [
@@ -17568,186 +18090,31 @@ class EyedropperSelection {
17568
18090
  }
17569
18091
  }
17570
18092
  class SphereSelection {
17571
- constructor(parent, callbacks) {
17572
- __publicField(this, "parent");
17573
- __publicField(this, "toolbar");
17574
- __publicField(this, "callbacks");
18093
+ constructor(onRadiusChanged) {
17575
18094
  __publicField(this, "_radius", 1);
17576
- __publicField(this, "radiusInput");
17577
- this.parent = parent;
17578
- this.callbacks = callbacks;
17579
- this.toolbar = document.createElement("div");
17580
- this.toolbar.className = "volume-select-toolbar";
17581
- this.toolbar.style.cssText = `
17582
- position:absolute; bottom:90px; left:50%; transform:translateX(-50%);
17583
- display:none; z-index:20; background:rgba(30,30,30,0.92);
17584
- border-radius:8px; padding:6px 10px; gap:6px;
17585
- align-items:center; font-size:13px; color:#ddd;
17586
- backdrop-filter:blur(6px); user-select:none; white-space:nowrap;
17587
- box-shadow:0 2px 12px rgba(0,0,0,0.4);
17588
- `;
17589
- this.toolbar.addEventListener("pointerdown", (e) => e.stopPropagation());
17590
- this.toolbar.addEventListener("wheel", (e) => e.stopPropagation());
17591
- const mkBtn = (label2, op) => {
17592
- const btn = document.createElement("button");
17593
- btn.textContent = label2;
17594
- btn.style.cssText = `
17595
- padding:4px 12px; border:1px solid #555; border-radius:4px;
17596
- background:#333; color:#ddd; cursor:pointer; font-size:13px;
17597
- transition: background 0.15s;
17598
- `;
17599
- btn.addEventListener("mouseenter", () => {
17600
- btn.style.background = "#555";
17601
- });
17602
- btn.addEventListener("mouseleave", () => {
17603
- btn.style.background = "#333";
17604
- });
17605
- btn.addEventListener("pointerdown", (e) => {
17606
- e.stopPropagation();
17607
- this.callbacks.onApply(op);
17608
- });
17609
- return btn;
17610
- };
17611
- const setBtn = mkBtn("Set", "set");
17612
- const addBtn = mkBtn("Add", "add");
17613
- const removeBtn = mkBtn("Remove", "remove");
17614
- const inputWrap = document.createElement("span");
17615
- inputWrap.style.cssText = "display:inline-flex; align-items:center; gap:2px;";
17616
- const input = document.createElement("input");
17617
- input.type = "number";
17618
- input.min = "1";
17619
- input.step = "1";
17620
- input.value = String(this._radius);
17621
- input.style.cssText = `
17622
- width:40px; padding:3px 4px; border:1px solid #555; border-radius:4px;
17623
- background:#222; color:#ddd; font-size:12px; text-align:center;
17624
- `;
17625
- const label = document.createElement("span");
17626
- label.textContent = "Radius";
17627
- label.style.cssText = "color:#888; font-size:11px;";
17628
- input.addEventListener("change", () => {
17629
- const v = Math.max(1, Math.round(parseFloat(input.value) || 1));
17630
- input.value = String(v);
17631
- this._radius = v;
17632
- this.callbacks.onRadiusChanged(v);
17633
- });
17634
- inputWrap.appendChild(input);
17635
- inputWrap.appendChild(label);
17636
- this.radiusInput = input;
17637
- this.toolbar.appendChild(setBtn);
17638
- this.toolbar.appendChild(addBtn);
17639
- this.toolbar.appendChild(removeBtn);
17640
- this.toolbar.appendChild(inputWrap);
17641
- parent.appendChild(this.toolbar);
18095
+ __publicField(this, "_onRadiusChanged");
18096
+ this._onRadiusChanged = onRadiusChanged;
17642
18097
  }
17643
18098
  get radius() {
17644
18099
  return this._radius;
17645
18100
  }
17646
18101
  setRadius(radius) {
17647
- this._radius = Math.round(radius);
17648
- this.radiusInput.value = String(this._radius);
18102
+ var _a2;
18103
+ this._radius = radius;
18104
+ (_a2 = this._onRadiusChanged) == null ? void 0 : _a2.call(this, radius);
17649
18105
  }
17650
18106
  activate() {
17651
- this.toolbar.style.display = "flex";
17652
18107
  }
17653
18108
  deactivate() {
17654
- this.toolbar.style.display = "none";
17655
18109
  }
17656
18110
  }
17657
18111
  class BoxSelection {
17658
- constructor(parent, callbacks) {
17659
- __publicField(this, "parent");
17660
- __publicField(this, "toolbar");
17661
- __publicField(this, "callbacks");
18112
+ constructor(onDimensionsChanged) {
17662
18113
  __publicField(this, "_lenX", 2);
17663
18114
  __publicField(this, "_lenY", 2);
17664
18115
  __publicField(this, "_lenZ", 2);
17665
- __publicField(this, "inputX");
17666
- __publicField(this, "inputY");
17667
- __publicField(this, "inputZ");
17668
- this.parent = parent;
17669
- this.callbacks = callbacks;
17670
- this.toolbar = document.createElement("div");
17671
- this.toolbar.className = "volume-select-toolbar";
17672
- this.toolbar.style.cssText = `
17673
- position:absolute; bottom:90px; left:50%; transform:translateX(-50%);
17674
- display:none; z-index:20; background:rgba(30,30,30,0.92);
17675
- border-radius:8px; padding:6px 10px; gap:6px;
17676
- align-items:center; font-size:13px; color:#ddd;
17677
- backdrop-filter:blur(6px); user-select:none; white-space:nowrap;
17678
- box-shadow:0 2px 12px rgba(0,0,0,0.4);
17679
- `;
17680
- this.toolbar.addEventListener("pointerdown", (e) => e.stopPropagation());
17681
- this.toolbar.addEventListener("wheel", (e) => e.stopPropagation());
17682
- const mkBtn = (label, op) => {
17683
- const btn = document.createElement("button");
17684
- btn.textContent = label;
17685
- btn.style.cssText = `
17686
- padding:4px 12px; border:1px solid #555; border-radius:4px;
17687
- background:#333; color:#ddd; cursor:pointer; font-size:13px;
17688
- transition: background 0.15s;
17689
- `;
17690
- btn.addEventListener("mouseenter", () => {
17691
- btn.style.background = "#555";
17692
- });
17693
- btn.addEventListener("mouseleave", () => {
17694
- btn.style.background = "#333";
17695
- });
17696
- btn.addEventListener("pointerdown", (e) => {
17697
- e.stopPropagation();
17698
- this.callbacks.onApply(op);
17699
- });
17700
- return btn;
17701
- };
17702
- const mkInput = (placeholder, initial, onChange) => {
17703
- const wrap = document.createElement("span");
17704
- wrap.style.cssText = "display:inline-flex; align-items:center; gap:2px;";
17705
- const input = document.createElement("input");
17706
- input.type = "number";
17707
- input.min = "1";
17708
- input.step = "1";
17709
- input.value = String(Math.round(initial));
17710
- input.style.cssText = `
17711
- width:40px; padding:3px 4px; border:1px solid #555; border-radius:4px;
17712
- background:#222; color:#ddd; font-size:12px; text-align:center;
17713
- `;
17714
- const label = document.createElement("span");
17715
- label.textContent = placeholder;
17716
- label.style.cssText = "color:#888; font-size:11px;";
17717
- input.addEventListener("change", () => {
17718
- const v = Math.max(1, Math.round(parseFloat(input.value) || 1));
17719
- input.value = String(v);
17720
- onChange(v);
17721
- });
17722
- wrap.appendChild(input);
17723
- wrap.appendChild(label);
17724
- return { wrap, input };
17725
- };
17726
- const setBtn = mkBtn("Set", "set");
17727
- const addBtn = mkBtn("Add", "add");
17728
- const removeBtn = mkBtn("Remove", "remove");
17729
- const xInput = mkInput("LenX", this._lenX, (v) => {
17730
- this._lenX = v;
17731
- this.emitDims();
17732
- });
17733
- const yInput = mkInput("LenY", this._lenY, (v) => {
17734
- this._lenY = v;
17735
- this.emitDims();
17736
- });
17737
- const zInput = mkInput("LenZ", this._lenZ, (v) => {
17738
- this._lenZ = v;
17739
- this.emitDims();
17740
- });
17741
- this.inputX = xInput.input;
17742
- this.inputY = yInput.input;
17743
- this.inputZ = zInput.input;
17744
- this.toolbar.appendChild(setBtn);
17745
- this.toolbar.appendChild(addBtn);
17746
- this.toolbar.appendChild(removeBtn);
17747
- this.toolbar.appendChild(xInput.wrap);
17748
- this.toolbar.appendChild(yInput.wrap);
17749
- this.toolbar.appendChild(zInput.wrap);
17750
- parent.appendChild(this.toolbar);
18116
+ __publicField(this, "_onDimensionsChanged");
18117
+ this._onDimensionsChanged = onDimensionsChanged;
17751
18118
  }
17752
18119
  get lenX() {
17753
18120
  return this._lenX;
@@ -17759,21 +18126,15 @@ class BoxSelection {
17759
18126
  return this._lenZ;
17760
18127
  }
17761
18128
  setDimensions(lenX, lenY, lenZ) {
17762
- this._lenX = Math.round(lenX);
17763
- this._lenY = Math.round(lenY);
17764
- this._lenZ = Math.round(lenZ);
17765
- this.inputX.value = String(this._lenX);
17766
- this.inputY.value = String(this._lenY);
17767
- this.inputZ.value = String(this._lenZ);
18129
+ var _a2;
18130
+ this._lenX = lenX;
18131
+ this._lenY = lenY;
18132
+ this._lenZ = lenZ;
18133
+ (_a2 = this._onDimensionsChanged) == null ? void 0 : _a2.call(this, lenX, lenY, lenZ);
17768
18134
  }
17769
18135
  activate() {
17770
- this.toolbar.style.display = "flex";
17771
18136
  }
17772
18137
  deactivate() {
17773
- this.toolbar.style.display = "none";
17774
- }
17775
- emitDims() {
17776
- this.callbacks.onDimensionsChanged(this._lenX, this._lenY, this._lenZ);
17777
18138
  }
17778
18139
  }
17779
18140
  function exportEditedPLY(positions, scales, rotations, colors, opacities, shCoeffs, state) {
@@ -18280,20 +18641,14 @@ class SplatEditor {
18280
18641
  if (this.gpuRenderer) {
18281
18642
  this.volumeRenderer = new SelectionVolumeRenderer(this.gpuRenderer, this.camera);
18282
18643
  }
18283
- this.sphereTool = new SphereSelection(this.container, {
18284
- onApply: (op) => this.applyVolumeSelection(op),
18285
- onRadiusChanged: (radius) => {
18286
- var _a2;
18287
- (_a2 = this.volumeRenderer) == null ? void 0 : _a2.setDimensions(radius, 0, 0);
18288
- }
18644
+ this.sphereTool = new SphereSelection((radius) => {
18645
+ var _a2;
18646
+ (_a2 = this.volumeRenderer) == null ? void 0 : _a2.setDimensions(radius, 0, 0);
18289
18647
  });
18290
18648
  this.toolManager.register("sphere", this.sphereTool);
18291
- this.boxTool = new BoxSelection(this.container, {
18292
- onApply: (op) => this.applyVolumeSelection(op),
18293
- onDimensionsChanged: (lx, ly, lz) => {
18294
- var _a2;
18295
- (_a2 = this.volumeRenderer) == null ? void 0 : _a2.setDimensions(lx, ly, lz);
18296
- }
18649
+ this.boxTool = new BoxSelection((lx, ly, lz) => {
18650
+ var _a2;
18651
+ (_a2 = this.volumeRenderer) == null ? void 0 : _a2.setDimensions(lx, ly, lz);
18297
18652
  });
18298
18653
  this.toolManager.register("box", this.boxTool);
18299
18654
  this.gsRenderer.setEditorState(this.splatState.data);
@@ -18651,6 +19006,24 @@ class SplatEditor {
18651
19006
  }
18652
19007
  };
18653
19008
  }
19009
+ // ============================================
19010
+ // 体积工具参数 API(供前端 UI 调用)
19011
+ // ============================================
19012
+ setBoxDimensions(lx, ly, lz) {
19013
+ var _a2;
19014
+ (_a2 = this.boxTool) == null ? void 0 : _a2.setDimensions(lx, ly, lz);
19015
+ }
19016
+ getBoxDimensions() {
19017
+ return this.boxTool ? [this.boxTool.lenX, this.boxTool.lenY, this.boxTool.lenZ] : [2, 2, 2];
19018
+ }
19019
+ setSphereRadius(radius) {
19020
+ var _a2;
19021
+ (_a2 = this.sphereTool) == null ? void 0 : _a2.setRadius(radius);
19022
+ }
19023
+ getSphereRadius() {
19024
+ var _a2;
19025
+ return ((_a2 = this.sphereTool) == null ? void 0 : _a2.radius) ?? 1;
19026
+ }
18654
19027
  /**
18655
19028
  * 获取模型世界空间包围盒(中心 + 尺寸)
18656
19029
  */
@@ -18876,6 +19249,10 @@ class App {
18876
19249
  __publicField(this, "animationId", 0);
18877
19250
  // 是否使用移动端渲染器
18878
19251
  __publicField(this, "useMobileRenderer", false);
19252
+ // 缓存移动端检测结果,避免每帧调用
19253
+ __publicField(this, "isMobile", false);
19254
+ // 移动端可见 splat 硬上限(保证稳定帧率)
19255
+ __publicField(this, "mobileMaxVisibleCap", 0);
18879
19256
  // 最近加载的 CompactSplatData(用于编辑器导出)
18880
19257
  __publicField(this, "lastCompactData", null);
18881
19258
  // 自适应性能控制
@@ -18885,6 +19262,17 @@ class App {
18885
19262
  });
18886
19263
  __publicField(this, "lastAppliedRenderScale", 1);
18887
19264
  __publicField(this, "baseRenderScale", 1);
19265
+ // 动态分辨率:移动时降分辨率,静止后恢复
19266
+ __publicField(this, "dynamicResolutionEnabled", false);
19267
+ __publicField(this, "dynResLastViewMatrix", new Float32Array(16));
19268
+ __publicField(this, "dynResStillFrames", 0);
19269
+ __publicField(this, "dynResCurrentScale", 1);
19270
+ __publicField(this, "DYNRES_MOVE_SCALE", 0.6);
19271
+ // 移动时 DPR*0.6(2.0*0.6=1.2)
19272
+ __publicField(this, "DYNRES_STILL_SCALE", 1);
19273
+ // 静止时 DPR*1.0
19274
+ __publicField(this, "DYNRES_STILL_THRESHOLD", 2);
19275
+ // 静止 2 帧即恢复
18888
19276
  // 绑定的事件处理函数
18889
19277
  __publicField(this, "boundOnResize");
18890
19278
  /** 额外渲染回调(在 gizmo 之前、场景辅助之后执行) */
@@ -18896,6 +19284,7 @@ class App {
18896
19284
  * 初始化应用
18897
19285
  */
18898
19286
  async init() {
19287
+ var _a2;
18899
19288
  this.renderer = new Renderer(this.canvas);
18900
19289
  await this.renderer.init();
18901
19290
  this.camera = new Camera();
@@ -18924,6 +19313,14 @@ class App {
18924
19313
  if (this.renderer.isAppleGPU) {
18925
19314
  this.applyAppleGPUDefaults();
18926
19315
  }
19316
+ this.isMobile = isMobileDevice();
19317
+ if (this.isMobile) {
19318
+ createDebugOverlay();
19319
+ (_a2 = getDebugOverlay()) == null ? void 0 : _a2.info(
19320
+ `Mobile detected. DPR=${window.devicePixelRatio}, canvas=${this.canvas.width}x${this.canvas.height}`
19321
+ );
19322
+ this.applyMobileDefaults();
19323
+ }
18927
19324
  }
18928
19325
  /**
18929
19326
  * Apple GPU (M1/M2/M3 等) 自动优化配置
@@ -18938,16 +19335,32 @@ class App {
18938
19335
  nearDepthRangeRatio: 2
18939
19336
  };
18940
19337
  console.log(
18941
- `[3DGS] Apple GPU detected (${this.renderer.gpuVendor}/${this.renderer.gpuArchitecture}), applying TBDR optimizations: SH→L1, depthWrite off, aggressive culling`
19338
+ `[3DGS] Apple GPU detected (${this.renderer.gpuVendor}/${this.renderer.gpuArchitecture}), applying TBDR optimizations: depthWrite off, aggressive culling`
18942
19339
  );
18943
19340
  }
18944
19341
  /**
18945
- * 创建桌面端 GSSplatRenderer 并自动应用平台优化
19342
+ * 移动端自动性能优化
19343
+ * 核心策略:降 DPR + 降分辨率 + 激进剔除 + 降排序频率
19344
+ */
19345
+ applyMobileDefaults() {
19346
+ this.dynamicResolutionEnabled = true;
19347
+ console.log(`[3DGS] Mobile: f16, sortBits=16, pixelThreshold=2.5, DPR=1.5, dynamicRes=ON`);
19348
+ }
19349
+ /**
19350
+ * 创建 GSSplatRenderer 并自动应用平台优化
19351
+ * @param forMobile 移动端模式:半精度(32B/splat)+ SH L0,内存降为 1/8
18946
19352
  */
18947
- createDesktopGSRenderer() {
19353
+ createGSRendererUnified(forMobile = false) {
18948
19354
  const renderer = new GSSplatRenderer(this.renderer, this.camera);
18949
- if (this.renderer.isAppleGPU) {
19355
+ if (forMobile) {
19356
+ renderer.setHalfPrecision(true);
19357
+ renderer.setSortBits(16);
19358
+ renderer.setPixelCullThreshold(1.5);
19359
+ renderer.setSortFrequency(2);
19360
+ } else if (this.renderer.isAppleGPU) {
18950
19361
  renderer.setSHMode(SHMode.L1);
19362
+ }
19363
+ if (this.renderer.isAppleGPU) {
18951
19364
  renderer.setDepthWriteEnabled(false);
18952
19365
  }
18953
19366
  return renderer;
@@ -19017,40 +19430,26 @@ class App {
19017
19430
  onProgress(50 + parseProgress, "parse");
19018
19431
  }
19019
19432
  };
19020
- let gsRenderer;
19021
19433
  if (isMobile) {
19022
- gsRenderer = new GSSplatRendererMobile(this.renderer, this.camera);
19023
- this.useMobileRenderer = true;
19024
- const compactData = await this.parsePLYBuffer(buffer, {
19025
- maxSplats: Infinity,
19026
- loadSH: false,
19027
- onProgress: parseProgressCallback,
19028
- coordinateSystem
19029
- });
19030
- if (onProgress) onProgress(90, "upload");
19031
- gsRenderer.setCompactData(compactData);
19032
- if (onProgress) onProgress(100, "upload");
19033
- this.lastCompactData = compactData;
19034
- this.sceneManager.setGSRenderer(gsRenderer);
19035
- this.hotspotManager.setGSRenderer(gsRenderer);
19036
- return compactData.count;
19037
- } else {
19038
- gsRenderer = this.createDesktopGSRenderer();
19039
- this.useMobileRenderer = false;
19040
- const compactData = await this.parsePLYBuffer(buffer, {
19041
- maxSplats: Infinity,
19042
- loadSH: true,
19043
- onProgress: parseProgressCallback,
19044
- coordinateSystem
19045
- });
19046
- if (onProgress) onProgress(90, "upload");
19047
- gsRenderer.setCompactData(compactData);
19048
- if (onProgress) onProgress(100, "upload");
19049
- this.lastCompactData = compactData;
19050
- this.sceneManager.setGSRenderer(gsRenderer);
19051
- this.hotspotManager.setGSRenderer(gsRenderer);
19052
- return compactData.count;
19434
+ console.log(
19435
+ "[3DGS] Mobile device detected, using unified desktop renderer with SH L0"
19436
+ );
19053
19437
  }
19438
+ const gsRenderer = this.createGSRendererUnified(isMobile);
19439
+ this.useMobileRenderer = false;
19440
+ const compactData = await this.parsePLYBuffer(buffer, {
19441
+ maxSplats: Infinity,
19442
+ loadSH: !isMobile,
19443
+ onProgress: parseProgressCallback,
19444
+ coordinateSystem
19445
+ });
19446
+ if (onProgress) onProgress(90, "upload");
19447
+ gsRenderer.setCompactData(compactData);
19448
+ if (onProgress) onProgress(100, "upload");
19449
+ this.lastCompactData = compactData;
19450
+ this.sceneManager.setGSRenderer(gsRenderer);
19451
+ this.hotspotManager.setGSRenderer(gsRenderer);
19452
+ return compactData.count;
19054
19453
  } catch (error) {
19055
19454
  throw error;
19056
19455
  }
@@ -19092,23 +19491,9 @@ class App {
19092
19491
  }
19093
19492
  if (onProgress) onProgress(90, "parse");
19094
19493
  if (onProgress) onProgress(90, "upload");
19095
- let gsRenderer;
19096
- if (isMobile) {
19097
- const mobileRenderer = new GSSplatRendererMobile(
19098
- this.renderer,
19099
- this.camera
19100
- );
19101
- this.useMobileRenderer = true;
19102
- const compactData = App.splatCpuToCompactData(splats);
19103
- mobileRenderer.setCompactData(compactData);
19104
- this.lastCompactData = compactData;
19105
- gsRenderer = mobileRenderer;
19106
- } else {
19107
- const desktopRenderer = this.createDesktopGSRenderer();
19108
- this.useMobileRenderer = false;
19109
- desktopRenderer.setData(splats);
19110
- gsRenderer = desktopRenderer;
19111
- }
19494
+ const gsRenderer = this.createGSRendererUnified(isMobile);
19495
+ this.useMobileRenderer = false;
19496
+ gsRenderer.setData(splats);
19112
19497
  this.sceneManager.setGSRenderer(gsRenderer);
19113
19498
  this.hotspotManager.setGSRenderer(gsRenderer);
19114
19499
  if (onProgress) onProgress(100, "upload");
@@ -19195,14 +19580,8 @@ class App {
19195
19580
  }
19196
19581
  }
19197
19582
  if (onProgress) onProgress(90, "upload");
19198
- let gsRenderer;
19199
- if (isMobile) {
19200
- gsRenderer = new GSSplatRendererMobile(this.renderer, this.camera);
19201
- this.useMobileRenderer = true;
19202
- } else {
19203
- gsRenderer = this.createDesktopGSRenderer();
19204
- this.useMobileRenderer = false;
19205
- }
19583
+ const gsRenderer = this.createGSRendererUnified(isMobile);
19584
+ this.useMobileRenderer = false;
19206
19585
  gsRenderer.setCompactData(compactData);
19207
19586
  this.lastCompactData = compactData;
19208
19587
  this.sceneManager.setGSRenderer(gsRenderer);
@@ -19253,6 +19632,21 @@ class App {
19253
19632
  this.render();
19254
19633
  this.animationId = requestAnimationFrame(this.animate.bind(this));
19255
19634
  }
19635
+ updateDynamicResolution() {
19636
+ if (!this.dynamicResolutionEnabled) return;
19637
+ const interacting = this.controls.isInteracting;
19638
+ if (interacting) {
19639
+ if (this.dynResCurrentScale !== this.DYNRES_MOVE_SCALE) {
19640
+ this.dynResCurrentScale = this.DYNRES_MOVE_SCALE;
19641
+ this.renderer.setRenderScale(this.DYNRES_MOVE_SCALE);
19642
+ }
19643
+ } else {
19644
+ if (this.dynResCurrentScale !== this.DYNRES_STILL_SCALE) {
19645
+ this.dynResCurrentScale = this.DYNRES_STILL_SCALE;
19646
+ this.renderer.setRenderScale(this.DYNRES_STILL_SCALE);
19647
+ }
19648
+ }
19649
+ }
19256
19650
  updateAdaptivePerformance() {
19257
19651
  if (!this.adaptivePerformanceEnabled) return;
19258
19652
  const gsRenderer = this.getGSRenderer();
@@ -19296,7 +19690,11 @@ class App {
19296
19690
  gsRenderer.setMaxVisibleSplats(0);
19297
19691
  } else {
19298
19692
  const ratio = cfg.nearVisibleRatio + (1 - cfg.nearVisibleRatio) * t;
19299
- gsRenderer.setMaxVisibleSplats(Math.round(splatCount * ratio));
19693
+ let maxVisible = Math.round(splatCount * ratio);
19694
+ if (this.mobileMaxVisibleCap > 0) {
19695
+ maxVisible = Math.min(maxVisible, this.mobileMaxVisibleCap);
19696
+ }
19697
+ gsRenderer.setMaxVisibleSplats(maxVisible);
19300
19698
  }
19301
19699
  if (cfg.enableDepthRangeLimit) {
19302
19700
  if (t >= 0.99) {
@@ -19306,16 +19704,13 @@ class App {
19306
19704
  gsRenderer.setDepthRangeLimit(depthRange);
19307
19705
  }
19308
19706
  }
19309
- if (t < 0.3) {
19310
- gsRenderer.setSortFrequency(2);
19311
- } else {
19312
- gsRenderer.setSortFrequency(1);
19313
- }
19707
+ gsRenderer.setSortFrequency(1);
19314
19708
  }
19315
19709
  render() {
19316
19710
  var _a2, _b2;
19317
19711
  this.camera.setAspect(this.renderer.getAspectRatio());
19318
19712
  this.controls.update();
19713
+ this.updateDynamicResolution();
19319
19714
  this.updateAdaptivePerformance();
19320
19715
  this.hotspotManager.updateBillboards();
19321
19716
  if ((_a2 = this.skyboxRenderer) == null ? void 0 : _a2.isActive) {
@@ -19891,10 +20286,6 @@ class App {
19891
20286
  if (gsRenderer) {
19892
20287
  gsRenderer.setSortFrequency(frequency);
19893
20288
  }
19894
- const mobileRenderer = this.getGSRendererMobile();
19895
- if (mobileRenderer) {
19896
- mobileRenderer.setSortFrequency(frequency);
19897
- }
19898
20289
  }
19899
20290
  /**
19900
20291
  * 是否检测到 Apple GPU(M1/M2/M3 等)