@viggle/splat-engine-three 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  // src/three/splat-character.ts
2
- import { Color, Matrix4 as Matrix42, Object3D, SRGBColorSpace, Vector3 as Vector35 } from "three";
2
+ import { Color, Matrix3, Matrix4 as Matrix42, Object3D, SRGBColorSpace, Vector3 as Vector35 } from "three";
3
3
 
4
4
  // src/character.ts
5
5
  import { Euler, MathUtils, Vector3 as Vector34 } from "three";
@@ -2341,6 +2341,14 @@ function buildSourceBoneRemap(splat, numBones, skeletonLibrary) {
2341
2341
  const sourceCount = splat.sourceBoneCount;
2342
2342
  if (!sourceCount || sourceCount === numBones) return null;
2343
2343
  if (!skeletonLibrary) return null;
2344
+ if (sourceCount === 119 && numBones === 86) {
2345
+ const head = skeletonLibrary.boneNames?.indexOf("head") ?? -1;
2346
+ if (head < 0) return null;
2347
+ const remap2 = new Int32Array(sourceCount);
2348
+ for (let i = 0; i < 86; i++) remap2[i] = i;
2349
+ remap2.fill(head, 86);
2350
+ return remap2;
2351
+ }
2344
2352
  let targetNames;
2345
2353
  if (skeletonLibrary.boneNames?.length === numBones) targetNames = skeletonLibrary.boneNames;
2346
2354
  else if (skeletonLibrary.boneNames441?.length === numBones) targetNames = skeletonLibrary.boneNames441;
@@ -2404,15 +2412,20 @@ function initializeSplatBoneMapping(splat, numBones, skeletonLibrary) {
2404
2412
  }
2405
2413
  }
2406
2414
  }
2415
+ splat.syncMappedCpuInfluences(firstIdx);
2407
2416
  splat.markBoneTexturesDirty();
2408
2417
  return firstIdx;
2409
2418
  }
2410
- function buildSplatBoneIndexList(splat, numBones) {
2419
+ function buildSplatBoneIndexList(splat, numBones, firstPaletteIdx) {
2411
2420
  const weights = splat.splatWeights;
2412
2421
  if (!weights) return new Int32Array(0);
2413
2422
  const used = new Uint8Array(numBones);
2423
+ const indices = splat.boneIndicesData;
2424
+ const mappedWeights = splat.boneWeightsData;
2425
+ if (!indices || !mappedWeights) return new Int32Array(0);
2414
2426
  for (let i = 0; i < weights.indices.length; i++) {
2415
- const idx2 = weights.indices[i] | 0;
2427
+ if (mappedWeights[i] <= 1e-3) continue;
2428
+ const idx2 = indices[i] - firstPaletteIdx | 0;
2416
2429
  if (idx2 >= 0 && idx2 < numBones) used[idx2] = 1;
2417
2430
  }
2418
2431
  let count = 0;
@@ -2561,7 +2574,7 @@ var SplatSkinning = class {
2561
2574
  }
2562
2575
  let boneIndexList = this._boneIndexLists.get(splat);
2563
2576
  if (!boneIndexList) {
2564
- boneIndexList = buildSplatBoneIndexList(splat, numBones);
2577
+ boneIndexList = buildSplatBoneIndexList(splat, numBones, firstPaletteIdx);
2565
2578
  this._boneIndexLists.set(splat, boneIndexList);
2566
2579
  }
2567
2580
  const palette = splat.transformPalette;
@@ -3741,6 +3754,490 @@ function packSplats(numSplats, s) {
3741
3754
  };
3742
3755
  }
3743
3756
 
3757
+ // src/splat/importance.ts
3758
+ import { DataUtils as DataUtils2 } from "three";
3759
+ function bucketByCell(cellKey, n) {
3760
+ const id = /* @__PURE__ */ new Map();
3761
+ const dense = new Uint32Array(n);
3762
+ let cells = 0;
3763
+ for (let i = 0; i < n; i++) {
3764
+ const key = cellKey[i];
3765
+ let d = id.get(key);
3766
+ if (d === void 0) {
3767
+ d = cells++;
3768
+ id.set(key, d);
3769
+ }
3770
+ dense[i] = d;
3771
+ }
3772
+ const start = new Uint32Array(cells + 1);
3773
+ for (let i = 0; i < n; i++) start[dense[i] + 1]++;
3774
+ for (let c = 0; c < cells; c++) start[c + 1] += start[c];
3775
+ const order = new Uint32Array(n);
3776
+ const cursor = new Uint32Array(cells);
3777
+ for (let i = 0; i < n; i++) {
3778
+ const d = dense[i];
3779
+ order[start[d] + cursor[d]++] = i;
3780
+ }
3781
+ return { order, start, cells };
3782
+ }
3783
+ var CELL_STRIDE = 1024;
3784
+ function cellKeyOf(x, y, z, minX, minY, minZ, invVoxel) {
3785
+ const cx = Math.floor((x - minX) * invVoxel);
3786
+ const cy = Math.floor((y - minY) * invVoxel);
3787
+ const cz = Math.floor((z - minZ) * invVoxel);
3788
+ return (cx * CELL_STRIDE + cy) * CELL_STRIDE + cz;
3789
+ }
3790
+ function cellKeysFor(centers, count, voxelSize) {
3791
+ let minX = Infinity, minY = Infinity, minZ = Infinity;
3792
+ for (let i = 0, c = 0; i < count; i++, c += 3) {
3793
+ if (centers[c] < minX) minX = centers[c];
3794
+ if (centers[c + 1] < minY) minY = centers[c + 1];
3795
+ if (centers[c + 2] < minZ) minZ = centers[c + 2];
3796
+ }
3797
+ const inv = 1 / voxelSize;
3798
+ const keys = new Float64Array(count);
3799
+ for (let i = 0, c = 0; i < count; i++, c += 3) {
3800
+ keys[i] = cellKeyOf(centers[c], centers[c + 1], centers[c + 2], minX, minY, minZ, inv);
3801
+ }
3802
+ return keys;
3803
+ }
3804
+ function buildImportanceOrder(packed, voxelSize) {
3805
+ const n = packed.numSplats;
3806
+ const out = new Uint32Array(n);
3807
+ if (n === 0) return out;
3808
+ const { color, transformB, centers } = packed;
3809
+ const half = DataUtils2.fromHalfFloat;
3810
+ const score = new Float32Array(n);
3811
+ for (let i = 0; i < n; i++) {
3812
+ const o = i * 4;
3813
+ const alpha = half(color[o + 3]);
3814
+ const s0 = half(transformB[o]);
3815
+ const s1 = half(transformB[o + 1]);
3816
+ const s2 = half(transformB[o + 2]);
3817
+ const mx = s0 > s1 ? s0 > s2 ? s0 : s2 : s1 > s2 ? s1 : s2;
3818
+ const mn = s0 < s1 ? s0 < s2 ? s0 : s2 : s1 < s2 ? s1 : s2;
3819
+ score[i] = alpha * mx * (s0 + s1 + s2 - mx - mn);
3820
+ }
3821
+ const { order, start, cells } = bucketByCell(cellKeysFor(centers, n, voxelSize), n);
3822
+ let widest = 0;
3823
+ const scratch = [];
3824
+ for (let c = 0; c < cells; c++) {
3825
+ const lo = start[c];
3826
+ const hi = start[c + 1];
3827
+ const size = hi - lo;
3828
+ if (size > widest) widest = size;
3829
+ if (size < 2) continue;
3830
+ scratch.length = 0;
3831
+ for (let k = lo; k < hi; k++) scratch.push(order[k]);
3832
+ scratch.sort((a, b) => score[b] - score[a]);
3833
+ for (let k = 0; k < size; k++) order[lo + k] = scratch[k];
3834
+ }
3835
+ let w = 0;
3836
+ for (let rank = 0; rank < widest; rank++) {
3837
+ for (let c = 0; c < cells; c++) {
3838
+ const at = start[c] + rank;
3839
+ if (at < start[c + 1]) out[w++] = order[at];
3840
+ }
3841
+ }
3842
+ return out;
3843
+ }
3844
+
3845
+ // src/splat/quality.ts
3846
+ var MAX_MIN_OPACITY = 0.1;
3847
+ var _quadAlphaCutoff = 8 / 255;
3848
+ var _screenSizeCull = 0;
3849
+ var _minOpacity = 0.05;
3850
+ var _pruneRatio = 0;
3851
+ var _pruneVoxelSize = 0.04;
3852
+ var clamp = (v, lo, hi) => v < lo ? lo : v > hi ? hi : v;
3853
+ var warnedAboutCap = false;
3854
+ var SplatQuality = {
3855
+ /**
3856
+ * Alpha at which a splat's quad is allowed to end, so each one is sized to
3857
+ * the radius its own opacity actually reaches instead of to the worst case.
3858
+ * **The first knob to reach for on a weak GPU.**
3859
+ *
3860
+ * The fragment stage multiplies a gaussian falloff by the splat's opacity
3861
+ * and discards anything below 1/255, so a quad drawn out to where that
3862
+ * product is already below the cutoff spends fragments on a ring that
3863
+ * cannot put ink on the screen. Raising the cutoff shrinks every quad. What
3864
+ * it costs is a harder edge on each splat, which shows first on soft things
3865
+ * - hair, fabric, anything semi-transparent.
3866
+ *
3867
+ * Takes effect on the next frame; nothing is reloaded and nothing is lost,
3868
+ * so it is safe to drive from a frame-rate governor.
3869
+ *
3870
+ * | Value | What it is for |
3871
+ * |---|---|
3872
+ * | `1 / 255` | Exactly the behaviour before this knob existed. Changes nothing at all: below it a fragment is discarded anyway, so the quad it trims was drawing nothing. |
3873
+ * | `8 / 255` | What the Unity build ships. The conservative end. |
3874
+ * | `12 / 255` | **The default here.** |
3875
+ * | `16 / 255` | **Low-end phones, and the floor.** Still 43.7 dB overall and 34.8 dB on the face, which the Unity build calls invisible at arm's length. |
3876
+ * | above `16 / 255` | Not on this project. The Unity build measures a further -7% at 24/255, but the edges are visibly harder by then; a device that needs more should spend it on {@link SplatQuality.pruneRatio}, the renderer's pixel ratio, or a distance LOD, none of which harden every silhouette in the frame. |
3877
+ *
3878
+ * Measured on a Mali-G57 with three characters, against 8/255: 12/255 -7%,
3879
+ * 16/255 -12%, 24/255 -19%. Measured here on an Apple GPU where fragments
3880
+ * are the bottleneck, against 1/255: 8/255 -29%, 16/255 -42%, 24/255 -50%.
3881
+ * Where the main thread is the bottleneck instead - the WebGL backend with
3882
+ * animated characters - it is free but buys nothing; see the README's
3883
+ * Tuning section for which case a scene is in.
3884
+ */
3885
+ get quadAlphaCutoff() {
3886
+ return _quadAlphaCutoff;
3887
+ },
3888
+ set quadAlphaCutoff(value) {
3889
+ _quadAlphaCutoff = clamp(value, 0, 1);
3890
+ },
3891
+ /**
3892
+ * Drop splats whose projected size falls below this radius in pixels, for
3893
+ * every splat at once. `0` (the default) draws everything.
3894
+ *
3895
+ * The same test as the per-splat {@link Splat.minPixelSize}; the larger of
3896
+ * the two wins, so a global floor cannot un-cull a splat an application
3897
+ * already asked to cull. Removes distant crowd splats that cannot contribute
3898
+ * more than a fraction of a pixel.
3899
+ */
3900
+ get screenSizeCull() {
3901
+ return _screenSizeCull;
3902
+ },
3903
+ set screenSizeCull(value) {
3904
+ _screenSizeCull = Math.max(0, value);
3905
+ },
3906
+ /**
3907
+ * Peak opacity a splat must reach to be kept when a character or scene is
3908
+ * loaded. `0` keeps everything.
3909
+ *
3910
+ * A splat's alpha at its centre IS its opacity - the gaussian only falls
3911
+ * off from there - so the faintest splats in a bundle are ones that paint
3912
+ * almost nothing from any camera while still costing memory, a skinning
3913
+ * pass, a sort slot and a quad. This removes them outright, which is what
3914
+ * separates it from {@link SplatQuality.quadAlphaCutoff}: the cutoff
3915
+ * shrinks a splat's quad and saves rasterisation alone, this saves
3916
+ * everything, but it cannot half-remove one.
3917
+ *
3918
+ * **Applied during the decode of a CHARACTER bundle**, so it only affects
3919
+ * what is loaded afterwards - set it before the first `load()`, and changing
3920
+ * it later does nothing to characters already in the scene. It does not
3921
+ * touch `.ply` scenes, matching the Unity build, which applies it in the
3922
+ * skinned renderer and nowhere else: a scene's opacity distribution is a
3923
+ * different thing, and 0.05 measured as 8.7% of a character against 41.8%
3924
+ * of a 1M-splat room. {@link pruneSplats} is exported for an application
3925
+ * that wants to make the trade on a scene of its own. It also changes
3926
+ * {@link Splat.numSplats}, so an application that hands back a per-splat
3927
+ * array ({@link Splat.setSplatGroups}) must size that array from
3928
+ * `numSplats` after loading rather than from the file.
3929
+ *
3930
+ * | Value | What it is for |
3931
+ * |---|---|
3932
+ * | `0` | Keep every splat. The behaviour before this knob existed. |
3933
+ * | `1 / 255` | Free in the strict sense: provably pixel-identical, because a splat under the fragment shader's own discard cannot produce a pixel. Worth about 2% of a character. |
3934
+ * | `0.05` | **The default here, and what the Unity build ships.** Removes ~13% of a typical character for a difference of at most 11/255 on 1.3% of it - 58-62 dB, which does not read as a quality loss. |
3935
+ * | above `0.05` | Untested. Prefer {@link SplatQuality.pruneRatio} to go further: it is measured, and it thins every region by the same share instead of taking the faintest splats wherever they happen to be - which is disproportionately the face. |
3936
+ */
3937
+ get minOpacity() {
3938
+ return _minOpacity;
3939
+ },
3940
+ set minOpacity(value) {
3941
+ if (value > MAX_MIN_OPACITY && !warnedAboutCap) {
3942
+ warnedAboutCap = true;
3943
+ console.warn(
3944
+ `[splat-engine-three] SplatQuality.minOpacity is capped at ${MAX_MIN_OPACITY}; ${value} was requested and ${MAX_MIN_OPACITY} applied. Past the cap a character loses the faint splats its soft edges are made of. To go further, use pruneRatio, the renderer's pixel ratio, or a distance LOD.`
3945
+ );
3946
+ }
3947
+ _minOpacity = clamp(value, 0, MAX_MIN_OPACITY);
3948
+ },
3949
+ /**
3950
+ * Fraction of splats removed at load by voxel-local importance, on top of
3951
+ * {@link SplatQuality.minOpacity}. `0` (the default) keeps everything, `0.5`
3952
+ * drops the least-important half of every cell.
3953
+ *
3954
+ * Importance is opacity times a splat's two largest axes - the area it
3955
+ * actually paints - ranked separately inside each spatial cell so a dense
3956
+ * region loses the same share as a sparse one rather than being deleted
3957
+ * wholesale. That per-cell quota is what keeps the face: a global ranking
3958
+ * puts the many faint facial splats at the bottom and smears it. Measured on
3959
+ * the Unity build's reference character at 0.5: 25,826 of 50,267 splats
3960
+ * remain, the frame falls 24.5% on a Mali-G57, and the face holds 43.5 dB
3961
+ * where a global rank gives 37.6.
3962
+ *
3963
+ * Applied during the decode of a character bundle, like
3964
+ * {@link SplatQuality.minOpacity}, with the same scope and the same reason
3965
+ * for being off by default.
3966
+ */
3967
+ get pruneRatio() {
3968
+ return _pruneRatio;
3969
+ },
3970
+ set pruneRatio(value) {
3971
+ _pruneRatio = clamp(value, 0, 1);
3972
+ },
3973
+ /**
3974
+ * Cell size, in metres, for {@link SplatQuality.pruneRatio}'s per-voxel
3975
+ * quota.
3976
+ *
3977
+ * 4 cm gives ~19 splats per occupied cell on the Unity build's reference
3978
+ * character - small enough that the face and the jacket are ranked apart,
3979
+ * large enough that a cell has a population to rank. A tuning constant
3980
+ * rather than a per-title knob, exposed because a character authored at a
3981
+ * different scale needs a different one.
3982
+ */
3983
+ get pruneVoxelSize() {
3984
+ return _pruneVoxelSize;
3985
+ },
3986
+ set pruneVoxelSize(value) {
3987
+ _pruneVoxelSize = Math.max(1e-4, value);
3988
+ },
3989
+ /**
3990
+ * Test each splat against the depth buffer, so opaque geometry hides the
3991
+ * splats behind it. On by default.
3992
+ *
3993
+ * Unlike the Unity build, where turning this off was the single largest
3994
+ * mobile saving, this is a correctness switch here and not a performance
3995
+ * one: that build renders splats into an offscreen target and pays for the
3996
+ * test by sampling a depth texture per fragment, where this package draws
3997
+ * straight into the camera's target and gets the hardware depth test for
3998
+ * free. Turning it off makes characters draw through walls and saves
3999
+ * nothing. It is here because the behaviour is occasionally what an
4000
+ * application wants - a character deliberately drawn over the scene.
4001
+ */
4002
+ depthOcclusion: true,
4003
+ /**
4004
+ * Collect {@link SplatQuality.timings}. Off by default.
4005
+ *
4006
+ * The CPU stages are timed with `performance.now()`, which is cheap but not
4007
+ * free at a few calls per splat per frame, and a shipping application that
4008
+ * never reads the numbers should not pay for them.
4009
+ */
4010
+ passTimings: false,
4011
+ /**
4012
+ * Per-stage cost of the last frame in which {@link SplatQuality.passTimings}
4013
+ * was on, in milliseconds, summed over every splat that ran that stage.
4014
+ *
4015
+ * These are main-thread times, which is the measurement that matters for
4016
+ * this package: under `WebGLRenderer` the per-character CPU cost - skinning
4017
+ * every centre to feed the sort, then the sort - is what limits how many
4018
+ * characters a scene can carry, and neither stage exists on the GPU
4019
+ * timeline. `skin` and `sort` are zero under `WebGPURenderer`, where both
4020
+ * happen in compute; `splatsDrawn` and `splatsTotal` are meaningful on both,
4021
+ * and differ when {@link SplatQuality.screenSizeCull} or a count LOD is
4022
+ * reducing what is submitted.
4023
+ *
4024
+ * These describe the most recent pass in which any splat drew, so read them
4025
+ * after your render call. A pass that drew no splats leaves the previous
4026
+ * numbers in place rather than zeroing them - a zero and "nothing happened"
4027
+ * would otherwise read the same, which is the mistake the Unity build's
4028
+ * equivalent made and spent a long time chasing.
4029
+ */
4030
+ timings: {
4031
+ /** CPU skinning of splat centres, i.e. `Splat.ensureCenters`. */
4032
+ skin: 0,
4033
+ /** The depth sort, where it runs on the main thread. */
4034
+ sort: 0,
4035
+ /** Uniform and buffer updates in `beforeRender`, excluding skin and sort. */
4036
+ prepare: 0,
4037
+ /** Splat instances submitted to the GPU this frame. */
4038
+ splatsDrawn: 0,
4039
+ /** Splat instances those draws could have covered at full detail. */
4040
+ splatsTotal: 0
4041
+ },
4042
+ /** Restore every knob to its default. Call when tearing the engine down. */
4043
+ resetStatics() {
4044
+ SplatQuality.quadAlphaCutoff = 8 / 255;
4045
+ SplatQuality.screenSizeCull = 0;
4046
+ SplatQuality.minOpacity = 0.05;
4047
+ SplatQuality.pruneRatio = 0;
4048
+ SplatQuality.pruneVoxelSize = 0.04;
4049
+ SplatQuality.depthOcclusion = true;
4050
+ SplatQuality.passTimings = false;
4051
+ resetTimings();
4052
+ }
4053
+ };
4054
+ function effectiveMinPixelSize(splatMinPixelSize) {
4055
+ const global = SplatQuality.screenSizeCull;
4056
+ return global > splatMinPixelSize ? global : splatMinPixelSize;
4057
+ }
4058
+ var pendingSkin = 0;
4059
+ var contributors = /* @__PURE__ */ new Set();
4060
+ function addSkinTime(ms) {
4061
+ pendingSkin += ms;
4062
+ }
4063
+ function resetTimings() {
4064
+ const t = SplatQuality.timings;
4065
+ t.skin = pendingSkin;
4066
+ pendingSkin = 0;
4067
+ t.sort = 0;
4068
+ t.prepare = 0;
4069
+ t.splatsDrawn = 0;
4070
+ t.splatsTotal = 0;
4071
+ }
4072
+ function beginFrameTimings(source) {
4073
+ if (contributors.has(source)) {
4074
+ contributors.clear();
4075
+ resetTimings();
4076
+ }
4077
+ contributors.add(source);
4078
+ }
4079
+
4080
+ // src/splat/prune.ts
4081
+ var COLUMNS = [
4082
+ "x",
4083
+ "y",
4084
+ "z",
4085
+ "scale_0",
4086
+ "scale_1",
4087
+ "scale_2",
4088
+ "rot_0",
4089
+ "rot_1",
4090
+ "rot_2",
4091
+ "rot_3",
4092
+ "f_dc_0",
4093
+ "f_dc_1",
4094
+ "f_dc_2",
4095
+ "opacity"
4096
+ ];
4097
+ function pruneSplats(numSplats, attributes, weights, opts) {
4098
+ const minOpacity = opts?.minOpacity ?? SplatQuality.minOpacity;
4099
+ const pruneRatio = opts?.pruneRatio ?? SplatQuality.pruneRatio;
4100
+ const voxelSize = opts?.voxelSize ?? SplatQuality.pruneVoxelSize;
4101
+ const report = {
4102
+ numSplatsIn: numSplats,
4103
+ numSplatsOut: numSplats,
4104
+ droppedByOpacity: 0,
4105
+ droppedByImportance: 0,
4106
+ weights
4107
+ };
4108
+ if (numSplats === 0) return report;
4109
+ const k = influencesPerSplat(numSplats, weights);
4110
+ let count = numSplats;
4111
+ if (minOpacity > 0) {
4112
+ const kept = cullByOpacity(count, attributes, weights, k, minOpacity);
4113
+ report.droppedByOpacity = count - kept;
4114
+ count = kept;
4115
+ }
4116
+ if (pruneRatio > 0 && voxelSize > 0) {
4117
+ const kept = pruneByImportance(count, attributes, weights, k, Math.min(pruneRatio, 1), voxelSize);
4118
+ report.droppedByImportance = count - kept;
4119
+ count = kept;
4120
+ }
4121
+ report.numSplatsOut = count;
4122
+ if (weights && count !== numSplats) {
4123
+ report.weights = {
4124
+ indices: weights.indices.subarray(0, count * k),
4125
+ weights: weights.weights.subarray(0, count * k)
4126
+ };
4127
+ }
4128
+ return report;
4129
+ }
4130
+ function influencesPerSplat(numSplats, weights) {
4131
+ if (!weights || numSplats === 0) return 0;
4132
+ return Math.max(1, Math.floor(weights.indices.length / numSplats));
4133
+ }
4134
+ function cullByOpacity(numSplats, attributes, weights, influences, minOpacity) {
4135
+ if (minOpacity >= 1) return 0;
4136
+ const threshold = Math.log(minOpacity / (1 - minOpacity));
4137
+ const opacity = attributes.opacity;
4138
+ const keep = new Uint8Array(numSplats);
4139
+ let kept = 0;
4140
+ for (let i = 0; i < numSplats; i++) {
4141
+ if (opacity[i] >= threshold) {
4142
+ keep[i] = 1;
4143
+ kept++;
4144
+ }
4145
+ }
4146
+ if (kept === numSplats) return kept;
4147
+ compact(numSplats, keep, attributes, weights, influences);
4148
+ return kept;
4149
+ }
4150
+ function pruneByImportance(numSplats, attributes, weights, influences, ratio, voxelSize) {
4151
+ const { x, y, z, scale_0, scale_1, scale_2, opacity } = attributes;
4152
+ let minX = Infinity, minY = Infinity, minZ = Infinity;
4153
+ for (let i = 0; i < numSplats; i++) {
4154
+ if (x[i] < minX) minX = x[i];
4155
+ if (y[i] < minY) minY = y[i];
4156
+ if (z[i] < minZ) minZ = z[i];
4157
+ }
4158
+ const invVoxel = 1 / voxelSize;
4159
+ const score = new Float32Array(numSplats);
4160
+ const cellKey = new Float64Array(numSplats);
4161
+ for (let i = 0; i < numSplats; i++) {
4162
+ const s0 = Math.exp(scale_0[i]);
4163
+ const s1 = Math.exp(scale_1[i]);
4164
+ const s2 = Math.exp(scale_2[i]);
4165
+ const mx = s0 > s1 ? s0 > s2 ? s0 : s2 : s1 > s2 ? s1 : s2;
4166
+ const mn = s0 < s1 ? s0 < s2 ? s0 : s2 : s1 < s2 ? s1 : s2;
4167
+ const md = s0 + s1 + s2 - mx - mn;
4168
+ const alpha = 1 / (1 + Math.exp(-opacity[i]));
4169
+ score[i] = alpha * mx * md;
4170
+ cellKey[i] = cellKeyOf(x[i], y[i], z[i], minX, minY, minZ, invVoxel);
4171
+ }
4172
+ const { order, start, cells } = bucketByCell(cellKey, numSplats);
4173
+ const keep = new Uint8Array(numSplats).fill(1);
4174
+ let dropped = 0;
4175
+ for (let c = 0; c < cells; c++) {
4176
+ const lo = start[c];
4177
+ const hi = start[c + 1];
4178
+ const size = hi - lo;
4179
+ const dropN = Math.floor(size * ratio);
4180
+ if (dropN <= 0) continue;
4181
+ if (dropN >= size) {
4182
+ for (let k = lo; k < hi; k++) keep[order[k]] = 0;
4183
+ dropped += size;
4184
+ continue;
4185
+ }
4186
+ selectWeakest(order, score, lo, hi, dropN);
4187
+ for (let k = 0; k < dropN; k++) keep[order[lo + k]] = 0;
4188
+ dropped += dropN;
4189
+ }
4190
+ if (dropped === 0) return numSplats;
4191
+ compact(numSplats, keep, attributes, weights, influences);
4192
+ return numSplats - dropped;
4193
+ }
4194
+ function selectWeakest(order, score, lo, hi, k) {
4195
+ while (hi - lo > 1) {
4196
+ const mid = lo + (hi - lo >> 1);
4197
+ const a = score[order[lo]];
4198
+ const b = score[order[mid]];
4199
+ const c = score[order[hi - 1]];
4200
+ const pivot = a < b ? b < c ? b : a < c ? c : a : a < c ? a : b < c ? c : b;
4201
+ let i = lo;
4202
+ let j = hi - 1;
4203
+ while (i <= j) {
4204
+ while (score[order[i]] < pivot) i++;
4205
+ while (score[order[j]] > pivot) j--;
4206
+ if (i <= j) {
4207
+ const t = order[i];
4208
+ order[i] = order[j];
4209
+ order[j] = t;
4210
+ i++;
4211
+ j--;
4212
+ }
4213
+ }
4214
+ if (lo + k <= i && lo + k > j + 1) return;
4215
+ if (lo + k <= j + 1) hi = j + 1;
4216
+ else {
4217
+ k -= i - lo;
4218
+ lo = i;
4219
+ }
4220
+ }
4221
+ }
4222
+ function compact(numSplats, keep, attributes, weights, k) {
4223
+ const indices = weights?.indices;
4224
+ const values = weights?.weights;
4225
+ let w = 0;
4226
+ for (let r = 0; r < numSplats; r++) {
4227
+ if (!keep[r]) continue;
4228
+ if (w !== r) {
4229
+ for (const column of COLUMNS) attributes[column][w] = attributes[column][r];
4230
+ if (indices && values) {
4231
+ for (let j = 0; j < k; j++) {
4232
+ indices[w * k + j] = indices[r * k + j];
4233
+ values[w * k + j] = values[r * k + j];
4234
+ }
4235
+ }
4236
+ }
4237
+ w++;
4238
+ }
4239
+ }
4240
+
3744
4241
  // src/loader/character/types.ts
3745
4242
  var CharacterBundleError = class extends Error {
3746
4243
  category;
@@ -4854,10 +5351,16 @@ async function decodeCharacterBundle(file) {
4854
5351
  };
4855
5352
  }
4856
5353
  const header = result.header;
5354
+ const decodedWeights = result.boneIndices && result.boneWeights ? { indices: result.boneIndices, weights: result.boneWeights } : void 0;
5355
+ const { numSplatsOut, weights: splatWeights } = pruneSplats(
5356
+ result.numSplats,
5357
+ result.splats,
5358
+ decodedWeights
5359
+ );
4857
5360
  return {
4858
5361
  source: {
4859
- packed: packSplats(result.numSplats, result.splats),
4860
- splatWeights: result.boneIndices && result.boneWeights ? { indices: result.boneIndices, weights: result.boneWeights } : void 0,
5362
+ packed: packSplats(numSplatsOut, result.splats),
5363
+ splatWeights,
4861
5364
  sourceBoneCount: header?.numBones ?? armatureData?.numBones
4862
5365
  },
4863
5366
  armatureData,
@@ -5019,7 +5522,7 @@ import {
5019
5522
  } from "three";
5020
5523
 
5021
5524
  // src/splat/depth-sort.ts
5022
- function countingSortByDepth(centers, count, dx, dy, dz, depthMin, depthMax, order, scratch) {
5525
+ function countingSortByDepth(centers, count, dx, dy, dz, depthMin, depthMax, order, scratch, subset) {
5023
5526
  if (count === 0) return;
5024
5527
  const bits = Math.max(10, Math.min(20, Math.round(Math.log2(count / 4))));
5025
5528
  const bucketCount = 1 << bits;
@@ -5031,17 +5534,31 @@ function countingSortByDepth(centers, count, dx, dy, dz, depthMin, depthMax, ord
5031
5534
  const range = depthMax - depthMin;
5032
5535
  const scale = range > 1e-12 ? (bucketCount - 1) / range : 0;
5033
5536
  const last = bucketCount - 1;
5034
- for (let i = 0, c = 0; i < count; i++, c += 3) {
5035
- const depth = centers[c] * dx + centers[c + 1] * dy + centers[c + 2] * dz;
5036
- let key = (depthMax - depth) * scale | 0;
5037
- if (key < 0) key = 0;
5038
- else if (key > last) key = last;
5039
- keys[i] = key;
5040
- counts[key]++;
5537
+ if (subset) {
5538
+ for (let i = 0; i < count; i++) {
5539
+ const c = subset[i] * 3;
5540
+ const depth = centers[c] * dx + centers[c + 1] * dy + centers[c + 2] * dz;
5541
+ let key = (depthMax - depth) * scale | 0;
5542
+ if (key < 0) key = 0;
5543
+ else if (key > last) key = last;
5544
+ keys[i] = key;
5545
+ counts[key]++;
5546
+ }
5547
+ } else {
5548
+ for (let i = 0, c = 0; i < count; i++, c += 3) {
5549
+ const depth = centers[c] * dx + centers[c + 1] * dy + centers[c + 2] * dz;
5550
+ let key = (depthMax - depth) * scale | 0;
5551
+ if (key < 0) key = 0;
5552
+ else if (key > last) key = last;
5553
+ keys[i] = key;
5554
+ counts[key]++;
5555
+ }
5041
5556
  }
5042
5557
  for (let i = 1; i < bucketCount; i++) counts[i] += counts[i - 1];
5043
- for (let i = count - 1; i >= 0; i--) {
5044
- order[--counts[keys[i]]] = i;
5558
+ if (subset) {
5559
+ for (let i = count - 1; i >= 0; i--) order[--counts[keys[i]]] = subset[i];
5560
+ } else {
5561
+ for (let i = count - 1; i >= 0; i--) order[--counts[keys[i]]] = i;
5045
5562
  }
5046
5563
  }
5047
5564
  function projectBoundsOnDirection(min, max, dx, dy, dz) {
@@ -5051,8 +5568,14 @@ function projectBoundsOnDirection(min, max, dx, dy, dz) {
5051
5568
  }
5052
5569
  var DepthSorter = class {
5053
5570
  _scratch = { keys: null, counts: null };
5054
- sort(centers, count, dx, dy, dz, depthMin, depthMax, order) {
5055
- countingSortByDepth(centers, count, dx, dy, dz, depthMin, depthMax, order, this._scratch);
5571
+ /**
5572
+ * @param count - How many splats take part. With `subset`, the length of
5573
+ * the prefix of it to use; without, the splats are `[0, count)`.
5574
+ * @param subset - Splat indices to sort, for a count LOD. See
5575
+ * {@link Splat.setCountLod}.
5576
+ */
5577
+ sort(centers, count, dx, dy, dz, depthMin, depthMax, order, subset) {
5578
+ countingSortByDepth(centers, count, dx, dy, dz, depthMin, depthMax, order, this._scratch, subset);
5056
5579
  }
5057
5580
  };
5058
5581
  var AsyncDepthSorter = class {
@@ -5062,22 +5585,26 @@ var AsyncDepthSorter = class {
5062
5585
  _order;
5063
5586
  /**
5064
5587
  * @param centers - Flat `[x,y,z] * count`. Copied into the worker once.
5065
- * @param onSorted - Called with each finished order. The array is only valid
5066
- * for the duration of the call - it goes back to the worker on the next
5067
- * request - so copy out of it before returning.
5588
+ * @param onSorted - Called with each finished order and how many of its
5589
+ * entries are current. The array is only valid for the duration of the call
5590
+ * - it goes back to the worker on the next request - so copy out of it
5591
+ * before returning.
5068
5592
  */
5069
5593
  constructor(centers, count, onSorted) {
5070
5594
  this._order = new Uint32Array(count);
5071
5595
  const source = `
5072
5596
  const sortByDepth = (${countingSortByDepth.toString()});
5073
5597
  let centers = null;
5598
+ let subset = null;
5074
5599
  const scratch = { keys: null, counts: null };
5075
5600
  self.onmessage = (e) => {
5076
5601
  const m = e.data;
5077
5602
  if (m.centers) { centers = m.centers; return; }
5603
+ if (m.subset) { subset = m.subset; return; }
5078
5604
  const order = m.order;
5079
- sortByDepth(centers, order.length, m.dx, m.dy, m.dz, m.depthMin, m.depthMax, order, scratch);
5080
- self.postMessage({ order }, [order.buffer]);
5605
+ const count = m.count === undefined ? order.length : m.count;
5606
+ sortByDepth(centers, count, m.dx, m.dy, m.dz, m.depthMin, m.depthMax, order, scratch, subset);
5607
+ self.postMessage({ order, count }, [order.buffer]);
5081
5608
  };
5082
5609
  `;
5083
5610
  const url = URL.createObjectURL(new Blob([source], { type: "text/javascript" }));
@@ -5085,7 +5612,7 @@ var AsyncDepthSorter = class {
5085
5612
  URL.revokeObjectURL(url);
5086
5613
  this._worker.onmessage = (e) => {
5087
5614
  this._order = e.data.order;
5088
- onSorted(this._order);
5615
+ onSorted(this._order, e.data.count);
5089
5616
  if (this._pending) {
5090
5617
  const next = this._pending;
5091
5618
  this._pending = null;
@@ -5095,6 +5622,16 @@ var AsyncDepthSorter = class {
5095
5622
  const copy = centers.slice(0, count * 3);
5096
5623
  this._worker.postMessage({ centers: copy }, [copy.buffer]);
5097
5624
  }
5625
+ /**
5626
+ * Give the worker the importance ordering a count LOD selects from. Sent
5627
+ * once and kept there, like the centres; after this a request carries only
5628
+ * how many of its entries to use.
5629
+ */
5630
+ setSubset(subset) {
5631
+ if (!this._worker) return;
5632
+ const copy = subset.slice();
5633
+ this._worker.postMessage({ subset: copy }, [copy.buffer]);
5634
+ }
5098
5635
  /** True while a sort is running in the worker. */
5099
5636
  get busy() {
5100
5637
  return this._order === null;
@@ -5382,6 +5919,11 @@ uniform int uTexWidth;
5382
5919
  // Drawing-buffer size in pixels.
5383
5920
  uniform vec2 uViewport;
5384
5921
  uniform float uMinPixelSize;
5922
+ // 1 / the alpha a splat's quad is allowed to end at. Carried as the reciprocal
5923
+ // so the historic 1/255 cutoff arrives as exactly 255.0 and the two
5924
+ // expressions below reduce, bit for bit, to what they were before the cutoff
5925
+ // was a setting at all. See SplatQuality.quadAlphaCutoff.
5926
+ uniform float uAlphaCutoffScale;
5385
5927
  // World rotation (xyzw) and scale of the splat object.
5386
5928
  uniform vec4 uModelRotation;
5387
5929
  uniform vec3 uModelScale;
@@ -5572,14 +6114,19 @@ void main(void) {
5572
6114
  clr.rgb = clamp(clr.rgb, 0.0, 1.0);
5573
6115
  }
5574
6116
 
5575
- if (255.0 * clr.a <= 1.0) {
6117
+ // A splat whose peak alpha is already under the cutoff cannot put a
6118
+ // fragment on screen: its own quad would be empty.
6119
+ float alphaOverCutoff = clr.a * uAlphaCutoffScale;
6120
+ if (alphaOverCutoff <= 1.0) {
5576
6121
  gl_Position = discardVec;
5577
6122
  return;
5578
6123
  }
5579
6124
 
5580
- // Shrink the quad to where the gaussian is still above 1/255: fewer
5581
- // fragments, identical pixels.
5582
- float clip = min(1.0, sqrt(log(255.0 * clr.a)) * 0.5);
6125
+ // Shrink the quad to where the gaussian is still above the cutoff: fewer
6126
+ // fragments. At a cutoff of 1/255 the pixels are identical, since below
6127
+ // that frag() discards anyway; above it, this is the trade the setting
6128
+ // exists to make.
6129
+ float clip = min(1.0, sqrt(log(alphaOverCutoff)) * 0.5);
5583
6130
  offset *= clip;
5584
6131
 
5585
6132
  gl_Position = centerProj + vec4(offset, 0.0, 0.0);
@@ -5645,6 +6192,8 @@ var WebGLSplatImpl = class {
5645
6192
  _lastDx = NaN;
5646
6193
  _lastDy = NaN;
5647
6194
  _lastDz = NaN;
6195
+ /** The importance ordering the worker already holds, if any. */
6196
+ _sentSubset = null;
5648
6197
  /**
5649
6198
  * @param asyncSort - Sort in a worker and keep drawing the previous order
5650
6199
  * meanwhile. Right for large static splats; wrong for skinned ones, whose
@@ -5674,6 +6223,7 @@ var WebGLSplatImpl = class {
5674
6223
  uTexWidth: { value: splat.packed.width },
5675
6224
  uViewport: { value: new Vector2(1, 1) },
5676
6225
  uMinPixelSize: { value: 0 },
6226
+ uAlphaCutoffScale: { value: 255 },
5677
6227
  uModelRotation: { value: new Vector4(0, 0, 0, 1) },
5678
6228
  uModelScale: { value: new Vector32(1, 1, 1) },
5679
6229
  uTint: { value: splat.tint },
@@ -5712,10 +6262,10 @@ var WebGLSplatImpl = class {
5712
6262
  if (asyncSort) {
5713
6263
  this._syncSorter = null;
5714
6264
  geometry.instanceCount = 0;
5715
- this._asyncSorter = new AsyncDepthSorter(splat.centers, splat.numSplats, (sorted) => {
6265
+ this._asyncSorter = new AsyncDepthSorter(splat.centers, splat.numSplats, (sorted, count) => {
5716
6266
  order.array.set(sorted);
5717
6267
  order.needsUpdate = true;
5718
- geometry.instanceCount = splat.numSplats;
6268
+ geometry.instanceCount = count;
5719
6269
  });
5720
6270
  } else {
5721
6271
  this._asyncSorter = null;
@@ -5759,6 +6309,7 @@ var WebGLSplatImpl = class {
5759
6309
  box.getBoundingSphere(this.geometry.boundingSphere);
5760
6310
  }
5761
6311
  beforeRender(renderer, camera) {
6312
+ const tEntered = SplatQuality.passTimings ? performance.now() : 0;
5762
6313
  const gl = renderer;
5763
6314
  const splat = this._splat;
5764
6315
  const u = this.material.uniforms;
@@ -5770,16 +6321,31 @@ var WebGLSplatImpl = class {
5770
6321
  this._host.matrixWorld.decompose(_position, _quaternion, _scale);
5771
6322
  u.uModelRotation.value.set(_quaternion.x, _quaternion.y, _quaternion.z, _quaternion.w);
5772
6323
  u.uModelScale.value.copy(_scale);
5773
- u.uMinPixelSize.value = splat.minPixelSize;
6324
+ u.uMinPixelSize.value = effectiveMinPixelSize(splat.minPixelSize);
6325
+ u.uAlphaCutoffScale.value = 1 / Math.max(1 / 65535, SplatQuality.quadAlphaCutoff);
5774
6326
  u.transformPalette.value = splat.transformPalette.texture;
5775
6327
  u.splatGroupIds.value = splat.groupIdsTexture ?? null;
5776
6328
  u.splatVariantMask.value = splat.variantMaskTexture ?? null;
6329
+ if (this.material.depthTest !== SplatQuality.depthOcclusion) {
6330
+ this.material.depthTest = SplatQuality.depthOcclusion;
6331
+ }
5777
6332
  this._engine.syncLighting();
6333
+ if (!SplatQuality.passTimings) {
6334
+ this._sortFor(camera);
6335
+ return;
6336
+ }
6337
+ beginFrameTimings(this);
6338
+ const t = SplatQuality.timings;
6339
+ const tPrepared = performance.now();
6340
+ t.prepare += tPrepared - tEntered;
5778
6341
  this._sortFor(camera);
6342
+ t.sort += performance.now() - tPrepared;
6343
+ t.splatsDrawn += this.geometry.instanceCount;
6344
+ t.splatsTotal += splat.numSplats;
5779
6345
  }
5780
6346
  _sortFor(camera) {
5781
6347
  const splat = this._splat;
5782
- const count = splat.numSplats;
6348
+ const count = splat.activeSplatCount;
5783
6349
  if (count === 0) return;
5784
6350
  splat.ensureCenters();
5785
6351
  const ce = camera.matrixWorld.elements;
@@ -5804,11 +6370,27 @@ var WebGLSplatImpl = class {
5804
6370
  dy,
5805
6371
  dz
5806
6372
  );
6373
+ const subset = splat.countLodActive ? splat.importanceOrder : null;
5807
6374
  if (this._asyncSorter) {
5808
- this._asyncSorter.request({ dx, dy, dz, depthMin, depthMax });
6375
+ if (subset && subset !== this._sentSubset) {
6376
+ this._sentSubset = subset;
6377
+ this._asyncSorter.setSubset(subset);
6378
+ }
6379
+ this._asyncSorter.request({ dx, dy, dz, depthMin, depthMax, count });
5809
6380
  } else {
5810
- this._syncSorter.sort(splat.centers, count, dx, dy, dz, depthMin, depthMax, this._order.array);
6381
+ this._syncSorter.sort(
6382
+ splat.centers,
6383
+ count,
6384
+ dx,
6385
+ dy,
6386
+ dz,
6387
+ depthMin,
6388
+ depthMax,
6389
+ this._order.array,
6390
+ subset
6391
+ );
5811
6392
  this._order.needsUpdate = true;
6393
+ this.geometry.instanceCount = count;
5812
6394
  }
5813
6395
  }
5814
6396
  dispose() {
@@ -6026,6 +6608,10 @@ var Splat = class _Splat {
6026
6608
  localBounds = new Box3();
6027
6609
  /** Discard splats smaller than this many pixels on screen. `0` draws everything. */
6028
6610
  minPixelSize = 0;
6611
+ _activeSplatCount;
6612
+ _importanceOrder = null;
6613
+ /** `_importanceOrder` inverted: each splat's place in it. @internal */
6614
+ _importanceRank = null;
6029
6615
  _mesh;
6030
6616
  _cpuInfluences = null;
6031
6617
  _pendingBoneTransforms = null;
@@ -6069,6 +6655,7 @@ var Splat = class _Splat {
6069
6655
  const size = this.localBounds.getSize(_size);
6070
6656
  this.localBounds.expandByScalar(0.5 * Math.max(size.x, size.y, size.z));
6071
6657
  }
6658
+ this._activeSplatCount = this.numSplats;
6072
6659
  const asyncSort = opts?.asyncSort ?? !this.splatWeights;
6073
6660
  this._mesh = new SplatMesh(this, engine, asyncSort);
6074
6661
  this.object3D = this._mesh;
@@ -6103,10 +6690,101 @@ var Splat = class _Splat {
6103
6690
  }
6104
6691
  return { indices, weights };
6105
6692
  }
6693
+ /**
6694
+ * How many splats are skinned, sorted and drawn this frame: the whole set,
6695
+ * or the first `n` of {@link Splat.importanceOrder} after
6696
+ * {@link Splat.setCountLod}.
6697
+ */
6698
+ get activeSplatCount() {
6699
+ return this._activeSplatCount;
6700
+ }
6701
+ /** Whether a count LOD is currently reducing this splat. */
6702
+ get countLodActive() {
6703
+ return this._activeSplatCount < this.numSplats;
6704
+ }
6705
+ /**
6706
+ * Process only the `n` most important splats this frame, or the whole set
6707
+ * with `null`.
6708
+ *
6709
+ * The dynamic counterpart of {@link SplatQuality.pruneRatio}: the prune
6710
+ * decides once, at load, that some splats are not worth carrying at all,
6711
+ * while this decides per frame how many of them are worth the work right
6712
+ * now - which is the trade a character makes as it walks away from the
6713
+ * camera. Nothing is freed, so it reverses the moment `n` goes back up.
6714
+ *
6715
+ * Which splats the first `n` are is {@link Splat.importanceOrder}'s answer:
6716
+ * the strongest splat of every 4 cm cell, then the second of every cell, and
6717
+ * so on, so the character thins evenly rather than losing a region.
6718
+ *
6719
+ * `WebGLRenderer` only, where it is worth the most: that backend skins every
6720
+ * splat centre on the CPU each frame to feed its sort, and this cuts that
6721
+ * pass, the sort and the draw together. Under `WebGPURenderer` all of that
6722
+ * already happens in compute, and the call is accepted and ignored rather
6723
+ * than silently changing which splats a GPU sort can see.
6724
+ *
6725
+ * {@link Character.setSplatCountLod} drives this from camera distance; call
6726
+ * it directly for a policy of your own.
6727
+ */
6728
+ setCountLod(n) {
6729
+ const next = n === null ? this.numSplats : Math.max(1, Math.min(this.numSplats, Math.floor(n)));
6730
+ if (next === this._activeSplatCount) return;
6731
+ if (next < this.numSplats) this.prepareCountLod();
6732
+ this._activeSplatCount = next;
6733
+ this.centersVersion++;
6734
+ this._mesh.requestSort();
6735
+ }
6736
+ /**
6737
+ * Build {@link Splat.importanceOrder} now, if it does not exist yet.
6738
+ *
6739
+ * One pass over the splats plus a small sort inside each 4 cm cell - single
6740
+ * digit milliseconds for a character, but milliseconds that would otherwise
6741
+ * land on the frame where the LOD first engages, which is a frame where the
6742
+ * camera is already moving. {@link Character.setSplatCountLod} calls this
6743
+ * when the LOD is configured, so the cost lands there instead.
6744
+ */
6745
+ prepareCountLod() {
6746
+ if (this._importanceRank) return;
6747
+ const order = this._importanceOrder ??= buildImportanceOrder(this.packed, SplatQuality.pruneVoxelSize);
6748
+ const rank = new Uint32Array(order.length);
6749
+ for (let i = 0; i < order.length; i++) rank[order[i]] = i;
6750
+ this._importanceRank = rank;
6751
+ }
6752
+ /**
6753
+ * Each splat's place in {@link Splat.importanceOrder}, which is how the
6754
+ * per-frame passes test membership of the active prefix. `null` until
6755
+ * {@link Splat.prepareCountLod}.
6756
+ * @internal
6757
+ */
6758
+ get importanceRank() {
6759
+ return this._importanceRank;
6760
+ }
6761
+ /**
6762
+ * Splat indices ordered by importance for {@link Splat.setCountLod}. `null`
6763
+ * until {@link Splat.prepareCountLod} or a count LOD has asked for one.
6764
+ */
6765
+ get importanceOrder() {
6766
+ return this._importanceOrder;
6767
+ }
6106
6768
  /** Re-upload the bone index/weight textures after editing their data arrays. */
6107
6769
  markBoneTexturesDirty() {
6108
6770
  this._mesh.syncBoneData();
6109
6771
  }
6772
+ /** Keep CPU-skinned centres (sorting and ground probes) on the same bone
6773
+ * mapping as the GPU shader. Called once when an armature binds this splat. */
6774
+ syncMappedCpuInfluences(firstPaletteIdx) {
6775
+ const packed = this._cpuInfluences;
6776
+ const indices = this.boneIndicesData;
6777
+ const weights = this.boneWeightsData;
6778
+ if (!packed || !indices || !weights) return;
6779
+ for (let s = 0; s < this.numSplats; s++) {
6780
+ const base = s * 11;
6781
+ const src = s * 4;
6782
+ for (let j = 0; j < 4; j++) {
6783
+ packed[base + 3 + j * 2] = weights[src + j];
6784
+ packed[base + 4 + j * 2] = indices[src + j] - firstPaletteIdx;
6785
+ }
6786
+ }
6787
+ }
6110
6788
  get variantMaskEnabled() {
6111
6789
  return this._variantMaskEnabled;
6112
6790
  }
@@ -6152,11 +6830,16 @@ var Splat = class _Splat {
6152
6830
  /**
6153
6831
  * Assign each splat to a group and enable per-group visibility. All groups
6154
6832
  * start visible; toggle them with {@link Splat.setVariantMask}.
6833
+ *
6834
+ * `groupIds` is indexed by splat, so it has to be built against
6835
+ * {@link Splat.numSplats} as loaded - which {@link SplatQuality.minOpacity}
6836
+ * makes smaller than the count in the file. An array authored offline
6837
+ * against the file cannot simply be shortened: see the error this throws.
6155
6838
  */
6156
6839
  setSplatGroups(groupIds, numGroups) {
6157
6840
  if (groupIds.length !== this.numSplats) {
6158
6841
  throw new Error(
6159
- `Splat.setSplatGroups: groupIds length ${groupIds.length} != numSplats ${this.numSplats}`
6842
+ `Splat.setSplatGroups: groupIds has ${groupIds.length} entries but this splat has ${this.numSplats}. One id per loaded splat is required. If the array was authored against the source file, do not truncate it - its indices no longer line up, because SplatQuality.minOpacity drops splats during the decode. Build it from the loaded splats, or set SplatQuality.minOpacity = 0 before loading this character.`
6160
6843
  );
6161
6844
  }
6162
6845
  if (numGroups < 1) throw new Error("Splat.setSplatGroups: numGroups must be \u22651");
@@ -6217,21 +6900,22 @@ var Splat = class _Splat {
6217
6900
  if (n === 0 || !this.splatWeights) return this.getWorldMinY();
6218
6901
  this.ensureCenters();
6219
6902
  const centers = this.centers;
6903
+ this.object3D.updateWorldMatrix(true, false);
6904
+ const m = this.object3D.matrixWorld.elements;
6905
+ const worldY = (i) => m[1] * centers[i * 3] + m[5] * centers[i * 3 + 1] + m[9] * centers[i * 3 + 2] + m[13];
6220
6906
  let lo = Infinity, hi = -Infinity;
6221
6907
  for (let i = 0; i < n; i++) {
6222
- const y = centers[i * 3 + 1];
6908
+ const y = worldY(i);
6223
6909
  if (y < lo) lo = y;
6224
6910
  if (y > hi) hi = y;
6225
6911
  }
6226
- this.object3D.updateWorldMatrix(true, false);
6227
- const worldOffsetY = this.object3D.matrixWorld.elements[13];
6228
- if (!(hi > lo)) return lo + worldOffsetY;
6912
+ if (!(hi > lo)) return lo;
6229
6913
  const BUCKETS = 256;
6230
6914
  const counts = this._groundHist;
6231
6915
  counts.fill(0);
6232
6916
  const scale = BUCKETS / (hi - lo);
6233
6917
  for (let i = 0; i < n; i++) {
6234
- const y = centers[i * 3 + 1];
6918
+ const y = worldY(i);
6235
6919
  let b = (y - lo) * scale | 0;
6236
6920
  if (b >= BUCKETS) b = BUCKETS - 1;
6237
6921
  if (b < 0) b = 0;
@@ -6247,7 +6931,7 @@ var Splat = class _Splat {
6247
6931
  break;
6248
6932
  }
6249
6933
  }
6250
- return boundary + worldOffsetY;
6934
+ return boundary;
6251
6935
  }
6252
6936
  /**
6253
6937
  * Flatten rest centres and influences into one interleaved buffer for the
@@ -6286,7 +6970,13 @@ var Splat = class _Splat {
6286
6970
  const boneTransforms = this._pendingBoneTransforms;
6287
6971
  if (!boneTransforms) return;
6288
6972
  this._pendingBoneTransforms = null;
6973
+ if (!SplatQuality.passTimings) {
6974
+ this._skinCenters(boneTransforms);
6975
+ return;
6976
+ }
6977
+ const t0 = performance.now();
6289
6978
  this._skinCenters(boneTransforms);
6979
+ addSkinTime(performance.now() - t0);
6290
6980
  }
6291
6981
  /**
6292
6982
  * Skin the splat centres on the CPU with the same matrices the GPU uses.
@@ -6318,9 +7008,12 @@ var Splat = class _Splat {
6318
7008
  }
6319
7009
  const centers = this.centers;
6320
7010
  const count = this.numSplats;
7011
+ const active = this._activeSplatCount;
7012
+ const rank = active < count ? this._importanceRank : null;
6321
7013
  let minX = Infinity, minY = Infinity, minZ = Infinity;
6322
7014
  let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
6323
7015
  for (let i = 0, base = 0, ci = 0; i < count; i++, base += 11, ci += 3) {
7016
+ if (rank !== null && rank[i] >= active) continue;
6324
7017
  const rx = packed[base];
6325
7018
  const ry = packed[base + 1];
6326
7019
  const rz = packed[base + 2];
@@ -6428,6 +7121,12 @@ var Character = class _Character {
6428
7121
  get splat() {
6429
7122
  return this.splats[this._activeLevel];
6430
7123
  }
7124
+ // Splat-count LOD configuration (see setSplatCountLod). Off by default.
7125
+ _countLodEnabled = false;
7126
+ _countLodNearDist = 0;
7127
+ _countLodFarDist = Infinity;
7128
+ _countLodNearFraction = 1;
7129
+ _countLodFarFraction = 0.3;
6431
7130
  // Render LOD configuration.
6432
7131
  _renderLodEnabled = false;
6433
7132
  _renderLodNearDist = Infinity;
@@ -6653,7 +7352,9 @@ var Character = class _Character {
6653
7352
  const boneNames = use441 ? library.boneNames441 : library.boneNames;
6654
7353
  const restSource = opts?.restSource ?? "auto";
6655
7354
  const wantsBundled = restSource === "auto" ? primary.armatureData?.corrected === true : restSource === "bundled";
6656
- const canBundle = wantsBundled && bundledJoints instanceof Float32Array && bundledJoints.length === builtinRest.length;
7355
+ const bodyRestCount = builtinRest.length;
7356
+ const expressionOnBody = binBoneCount === 119 && !use441 && bodyRestCount === 86 * 3;
7357
+ const canBundle = wantsBundled && bundledJoints instanceof Float32Array && (bundledJoints.length === bodyRestCount || expressionOnBody && bundledJoints.length === 119 * 3);
6657
7358
  let restTranslations = builtinRest;
6658
7359
  if (canBundle) {
6659
7360
  const joints = bundledJoints;
@@ -6709,6 +7410,7 @@ var Character = class _Character {
6709
7410
  this._activeLevel = next;
6710
7411
  }
6711
7412
  }
7413
+ if (this._countLodEnabled) this._applyCountLod();
6712
7414
  this.armature.update(dt);
6713
7415
  };
6714
7416
  this._engine.addUpdater(this._updateHandler);
@@ -6774,6 +7476,60 @@ var Character = class _Character {
6774
7476
  this.splats[clamped].setRenderingEnabled(true);
6775
7477
  this._activeLevel = clamped;
6776
7478
  }
7479
+ /**
7480
+ * Thin the character with distance by processing fewer of its splats, rather
7481
+ * than by switching to a different file.
7482
+ *
7483
+ * At `nearDist` and closer, `nearFraction` of the splats are skinned, sorted
7484
+ * and drawn; at `farDist` and beyond, `farFraction` of them; in between, the
7485
+ * fraction is interpolated. Which splats those are is decided by importance
7486
+ * inside each 4 cm cell (see {@link Splat.setCountLod}), so the character
7487
+ * thins evenly instead of losing a region.
7488
+ *
7489
+ * The dynamic counterpart of {@link SplatQuality.pruneRatio}, and the one to
7490
+ * reach for when the splats are still wanted close up. It composes with
7491
+ * everything else: {@link Character.setRenderLod} switches between loaded
7492
+ * files, {@link Armature.setAnimationLod} and {@link Armature.setSortLod}
7493
+ * drop frames of pose and sort work, and this one changes how much of the
7494
+ * character each of those frames covers.
7495
+ *
7496
+ * `WebGLRenderer` only - see {@link Splat.setCountLod} for why.
7497
+ *
7498
+ * ```ts
7499
+ * character.setSplatCountLod({ nearDist: 4, farDist: 16, farFraction: 0.25 });
7500
+ * ```
7501
+ */
7502
+ setSplatCountLod(opts) {
7503
+ this._countLodNearDist = Math.max(0, opts.nearDist);
7504
+ this._countLodFarDist = Math.max(this._countLodNearDist + 1e-6, opts.farDist);
7505
+ this._countLodNearFraction = Math.min(1, Math.max(0.01, opts.nearFraction ?? 1));
7506
+ this._countLodFarFraction = Math.min(1, Math.max(0.01, opts.farFraction ?? 0.3));
7507
+ this._countLodEnabled = true;
7508
+ for (const splat of this.splats) splat.prepareCountLod();
7509
+ }
7510
+ /** Stop thinning: every splat is processed again, on every level. */
7511
+ disableSplatCountLod() {
7512
+ if (!this._countLodEnabled) return;
7513
+ this._countLodEnabled = false;
7514
+ for (const splat of this.splats) splat.setCountLod(null);
7515
+ }
7516
+ /** Pick this frame's splat fraction from camera distance and apply it. */
7517
+ _applyCountLod() {
7518
+ const distSq = this.armature.cameraDistanceSq();
7519
+ if (distSq === null) return;
7520
+ const near = this._countLodNearDist;
7521
+ const far = this._countLodFarDist;
7522
+ const nearF = this._countLodNearFraction;
7523
+ const farF = this._countLodFarFraction;
7524
+ const dist = Math.sqrt(distSq);
7525
+ let fraction;
7526
+ if (dist <= near) fraction = nearF;
7527
+ else if (dist >= far) fraction = farF;
7528
+ else fraction = nearF + (farF - nearF) * (dist - near) / (far - near);
7529
+ const steps = Math.max(1, Math.round(fraction * 64));
7530
+ const splat = this.splat;
7531
+ splat.setCountLod(Math.round(steps / 64 * splat.numSplats));
7532
+ }
6777
7533
  /** Number of LOD levels loaded for this character (>=1). */
6778
7534
  get renderLodLevels() {
6779
7535
  return this.splats.length;
@@ -7172,6 +7928,8 @@ var _euler = new Euler();
7172
7928
  // src/three/splat-character.ts
7173
7929
  var _color = new Color();
7174
7930
  var _rgb = { r: 0, g: 0, b: 0 };
7931
+ var _groundDelta = new Vector35();
7932
+ var _parentInverseLinear = new Matrix3();
7175
7933
  var _clipSerial = 0;
7176
7934
  var SplatCharacter = class _SplatCharacter extends Object3D {
7177
7935
  /** Type flag, in the three.js manner. */
@@ -7189,6 +7947,10 @@ var SplatCharacter = class _SplatCharacter extends Object3D {
7189
7947
  _loop = true;
7190
7948
  _finishedSent = false;
7191
7949
  _rootMotion = "off";
7950
+ /** Optional world-space floor height. When set, the lowest visible splats
7951
+ * are aligned to it after each pose. Useful for dances with no airborne
7952
+ * phase; leave null for clips with intentional jumps or flight. */
7953
+ groundLockY = null;
7192
7954
  _disposed = false;
7193
7955
  /**
7194
7956
  * @param bundles - Decoded character bundles, finest level of detail first.
@@ -7226,6 +7988,18 @@ var SplatCharacter = class _SplatCharacter extends Object3D {
7226
7988
  const armature = this.character.armature;
7227
7989
  const before = armature.animationTime;
7228
7990
  this.character.update(delta);
7991
+ if (this.groundLockY !== null) {
7992
+ const low = this.character.getLowestWorldY();
7993
+ if (low !== null && Number.isFinite(low)) {
7994
+ _groundDelta.set(0, this.groundLockY - low, 0);
7995
+ if (this.parent) {
7996
+ this.parent.updateWorldMatrix(true, false);
7997
+ _parentInverseLinear.setFromMatrix4(this.parent.matrixWorld).invert();
7998
+ _groundDelta.applyMatrix3(_parentInverseLinear);
7999
+ }
8000
+ this.position.add(_groundDelta);
8001
+ }
8002
+ }
7229
8003
  const clip = this._clip;
7230
8004
  if (!clip || !armature.isPlaying) return;
7231
8005
  if (this._loop) {
@@ -7358,6 +8132,32 @@ var SplatCharacter = class _SplatCharacter extends Object3D {
7358
8132
  for (const splat of this.character.splats) splat.setGrade(contrast, saturation);
7359
8133
  return this;
7360
8134
  }
8135
+ // ── Performance ──────────────────────────────────────────────────────────
8136
+ /**
8137
+ * Draw fewer of this character's splats as it gets further away.
8138
+ *
8139
+ * `nearFraction` of them at `nearDist` and closer, `farFraction` at
8140
+ * `farDist` and beyond, interpolated in between. Which splats those are is
8141
+ * decided by importance within each 4 cm cell, so the character thins evenly
8142
+ * rather than losing a region of itself. See {@link Character.setSplatCountLod}.
8143
+ *
8144
+ * Needs a camera on the engine to measure distance from - the same one the
8145
+ * other distance throttles use:
8146
+ *
8147
+ * ```ts
8148
+ * SplatEngine.shared.camera = camera;
8149
+ * character.setSplatCountLod({ nearDist: 4, farDist: 16, farFraction: 0.25 });
8150
+ * ```
8151
+ */
8152
+ setSplatCountLod(opts) {
8153
+ this.character.setSplatCountLod(opts);
8154
+ return this;
8155
+ }
8156
+ /** Stop thinning: every splat is drawn again, at any distance. */
8157
+ disableSplatCountLod() {
8158
+ this.character.disableSplatCountLod();
8159
+ return this;
8160
+ }
7361
8161
  // ── Rig ──────────────────────────────────────────────────────────────────
7362
8162
  /** Bone names, in rig order. */
7363
8163
  get boneNames() {
@@ -7391,6 +8191,7 @@ var SplatCharacter = class _SplatCharacter extends Object3D {
7391
8191
  copy.layers.mask = this.layers.mask;
7392
8192
  copy.userData = { ...this.userData };
7393
8193
  copy.rootMotion = this._rootMotion;
8194
+ copy.groundLockY = this.groundLockY;
7394
8195
  return copy;
7395
8196
  }
7396
8197
  /**
@@ -7778,6 +8579,10 @@ export {
7778
8579
  SplatEngine,
7779
8580
  EngineDisposedError,
7780
8581
  packSplats,
8582
+ SplatQuality,
8583
+ effectiveMinPixelSize,
8584
+ beginFrameTimings,
8585
+ pruneSplats,
7781
8586
  CharacterBundleError,
7782
8587
  decodeCharacterBundle,
7783
8588
  loadCharacterBundle,
@@ -7798,4 +8603,4 @@ export {
7798
8603
  SkyLight,
7799
8604
  PointLight
7800
8605
  };
7801
- //# sourceMappingURL=chunk-MM6DJU6S.js.map
8606
+ //# sourceMappingURL=chunk-WN5WBKM4.js.map