@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.
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  SplatEngine,
14
14
  SplatMesh,
15
15
  SplatPLYLoader,
16
+ SplatQuality,
16
17
  SunLight,
17
18
  VSplatLoader,
18
19
  WebGLSplatBackend,
@@ -21,8 +22,9 @@ import {
21
22
  loadCharacterBundleFromBytes,
22
23
  makeRegionMask,
23
24
  packSplats,
24
- parseGaussianPly
25
- } from "./chunks/chunk-MM6DJU6S.js";
25
+ parseGaussianPly,
26
+ pruneSplats
27
+ } from "./chunks/chunk-WN5WBKM4.js";
26
28
  export {
27
29
  Animation,
28
30
  Armature,
@@ -39,6 +41,7 @@ export {
39
41
  SplatEngine,
40
42
  SplatMesh,
41
43
  SplatPLYLoader,
44
+ SplatQuality,
42
45
  SunLight,
43
46
  VSplatLoader,
44
47
  WebGLSplatBackend,
@@ -47,6 +50,7 @@ export {
47
50
  loadCharacterBundleFromBytes,
48
51
  makeRegionMask,
49
52
  packSplats,
50
- parseGaussianPly
53
+ parseGaussianPly,
54
+ pruneSplats
51
55
  };
52
56
  //# sourceMappingURL=index.js.map
@@ -65,7 +65,9 @@ export interface LoadCharacterOptions {
65
65
  * Bundles carry translations only, never rotations, so `'bundled'` yields
66
66
  * per-character bone LENGTHS with canonical joint ORIENTATIONS - a hybrid.
67
67
  * Falls back to `'builtin'` when the bundle has no joints (older files) or
68
- * the array length disagrees with the bone count.
68
+ * the array length disagrees with the bone count. A 119-bone expression
69
+ * bundle keeps its first 86 body rest translations when posed on Body86;
70
+ * its 33 facial influences are attached to the head separately.
69
71
  */
70
72
  restSource?: 'auto' | 'builtin' | 'bundled';
71
73
  }
@@ -87,6 +89,11 @@ export declare class Character {
87
89
  * vanishes when the active level swaps.
88
90
  */
89
91
  get splat(): Splat;
92
+ private _countLodEnabled;
93
+ private _countLodNearDist;
94
+ private _countLodFarDist;
95
+ private _countLodNearFraction;
96
+ private _countLodFarFraction;
90
97
  private _renderLodEnabled;
91
98
  private _renderLodNearDist;
92
99
  private _renderLodFarDist;
@@ -250,6 +257,41 @@ export declare class Character {
250
257
  /** Force a specific render-LOD level, bypassing distance bands. Useful
251
258
  * for debugging / A-B comparisons in the profiling scene. */
252
259
  setRenderLevel(level: number): void;
260
+ /**
261
+ * Thin the character with distance by processing fewer of its splats, rather
262
+ * than by switching to a different file.
263
+ *
264
+ * At `nearDist` and closer, `nearFraction` of the splats are skinned, sorted
265
+ * and drawn; at `farDist` and beyond, `farFraction` of them; in between, the
266
+ * fraction is interpolated. Which splats those are is decided by importance
267
+ * inside each 4 cm cell (see {@link Splat.setCountLod}), so the character
268
+ * thins evenly instead of losing a region.
269
+ *
270
+ * The dynamic counterpart of {@link SplatQuality.pruneRatio}, and the one to
271
+ * reach for when the splats are still wanted close up. It composes with
272
+ * everything else: {@link Character.setRenderLod} switches between loaded
273
+ * files, {@link Armature.setAnimationLod} and {@link Armature.setSortLod}
274
+ * drop frames of pose and sort work, and this one changes how much of the
275
+ * character each of those frames covers.
276
+ *
277
+ * `WebGLRenderer` only - see {@link Splat.setCountLod} for why.
278
+ *
279
+ * ```ts
280
+ * character.setSplatCountLod({ nearDist: 4, farDist: 16, farFraction: 0.25 });
281
+ * ```
282
+ */
283
+ setSplatCountLod(opts: {
284
+ nearDist: number;
285
+ farDist: number;
286
+ /** Fraction kept at `nearDist` and closer. Default 1. */
287
+ nearFraction?: number;
288
+ /** Fraction kept at `farDist` and beyond. Default 0.3. */
289
+ farFraction?: number;
290
+ }): void;
291
+ /** Stop thinning: every splat is processed again, on every level. */
292
+ disableSplatCountLod(): void;
293
+ /** Pick this frame's splat fraction from camera distance and apply it. */
294
+ private _applyCountLod;
253
295
  /** Number of LOD levels loaded for this character (>=1). */
254
296
  get renderLodLevels(): number;
255
297
  /** Active render-LOD level (0 = highest fidelity). */
@@ -19,6 +19,9 @@ export type { SplatBackend } from './splat/backend';
19
19
  export type { SplatOptions, SplatSource } from './splat/splat';
20
20
  export { packSplats } from './splat/packed-splats';
21
21
  export type { PackedSplats } from './splat/packed-splats';
22
+ export { SplatQuality } from './splat/quality';
23
+ export { pruneSplats } from './splat/prune';
24
+ export type { PruneOptions, PruneReport } from './splat/prune';
22
25
  export { SunLight } from './lighting/sun-light';
23
26
  export type { CreateSunLightOptions } from './lighting/sun-light';
24
27
  export { SkyLight } from './lighting/sky-light';
@@ -35,4 +35,4 @@ export declare function initializeSplatBoneMapping(splat: Splat, numBones: numbe
35
35
  * Returns an empty `Int32Array` when the splat has no weights data - callers
36
36
  * treat that as "fall back to walking every bone."
37
37
  */
38
- export declare function buildSplatBoneIndexList(splat: Splat, numBones: number): Int32Array;
38
+ export declare function buildSplatBoneIndexList(splat: Splat, numBones: number, firstPaletteIdx: number): Int32Array;
@@ -19,7 +19,13 @@ export declare function projectBoundsOnDirection(min: ArrayLike<number>, max: Ar
19
19
  /** Synchronous sorter with its own scratch buffers. One per splat. */
20
20
  export declare class DepthSorter {
21
21
  private readonly _scratch;
22
- sort(centers: Float32Array, count: number, dx: number, dy: number, dz: number, depthMin: number, depthMax: number, order: Uint32Array): void;
22
+ /**
23
+ * @param count - How many splats take part. With `subset`, the length of
24
+ * the prefix of it to use; without, the splats are `[0, count)`.
25
+ * @param subset - Splat indices to sort, for a count LOD. See
26
+ * {@link Splat.setCountLod}.
27
+ */
28
+ sort(centers: Float32Array, count: number, dx: number, dy: number, dz: number, depthMin: number, depthMax: number, order: Uint32Array, subset?: Uint32Array | null): void;
23
29
  }
24
30
  export interface SortRequest {
25
31
  dx: number;
@@ -27,6 +33,11 @@ export interface SortRequest {
27
33
  dz: number;
28
34
  depthMin: number;
29
35
  depthMax: number;
36
+ /**
37
+ * How many splats to order, for a count LOD. Defaults to all of them; with
38
+ * a subset set, the length of the prefix of it to use.
39
+ */
40
+ count?: number;
30
41
  }
31
42
  /**
32
43
  * Worker-backed sorter for static splats.
@@ -46,11 +57,18 @@ export declare class AsyncDepthSorter {
46
57
  private _order;
47
58
  /**
48
59
  * @param centers - Flat `[x,y,z] * count`. Copied into the worker once.
49
- * @param onSorted - Called with each finished order. The array is only valid
50
- * for the duration of the call - it goes back to the worker on the next
51
- * request - so copy out of it before returning.
60
+ * @param onSorted - Called with each finished order and how many of its
61
+ * entries are current. The array is only valid for the duration of the call
62
+ * - it goes back to the worker on the next request - so copy out of it
63
+ * before returning.
64
+ */
65
+ constructor(centers: Float32Array, count: number, onSorted: (order: Uint32Array, count: number) => void);
66
+ /**
67
+ * Give the worker the importance ordering a count LOD selects from. Sent
68
+ * once and kept there, like the centres; after this a request carries only
69
+ * how many of its entries to use.
52
70
  */
53
- constructor(centers: Float32Array, count: number, onSorted: (order: Uint32Array) => void);
71
+ setSubset(subset: Uint32Array): void;
54
72
  /** True while a sort is running in the worker. */
55
73
  get busy(): boolean;
56
74
  request(req: SortRequest): void;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Ranking splats by how much they contribute, and the spatial bucketing both
3
+ * users of that ranking share.
4
+ *
5
+ * Two features are built on it. {@link pruneSplats} throws the weakest splats
6
+ * away at load; the count LOD ({@link Splat.setCountLod}) keeps them all and
7
+ * processes a prefix of the ranking that shrinks with distance. The ranking
8
+ * has to be the same one in both cases, which is why it lives here rather than
9
+ * in either of them.
10
+ */
11
+ import type { PackedSplats } from './packed-splats';
12
+ /**
13
+ * Group indices by spatial cell without a comparison sort: hash the cells to
14
+ * dense ids, count, prefix-sum, scatter.
15
+ *
16
+ * O(n) with one map probe per splat, against the O(n log n) a sort over a
17
+ * composite key would cost - which measured 11-16 ms of a ~40 ms character
18
+ * load in the Unity build this is ported from.
19
+ */
20
+ export declare function bucketByCell(cellKey: Float64Array, n: number): {
21
+ order: Uint32Array;
22
+ start: Uint32Array;
23
+ cells: number;
24
+ };
25
+ /**
26
+ * One splat's cell key on a `1 / voxelSize` grid whose origin is `min`.
27
+ *
28
+ * The one definition of the grid. Both users of it have to agree - a splat
29
+ * pruned by one rule and ranked by another would be thinned twice in the same
30
+ * place - and they walk different layouts, so what they share is this rather
31
+ * than a loop.
32
+ */
33
+ export declare function cellKeyOf(x: number, y: number, z: number, minX: number, minY: number, minZ: number, invVoxel: number): number;
34
+ /**
35
+ * Cell keys for `centers`, with the grid origin at the actual minimum of the
36
+ * positions rather than at any declared bounds, which can carry a margin the
37
+ * grid should not inherit.
38
+ */
39
+ export declare function cellKeysFor(centers: Float32Array, count: number, voxelSize: number): Float64Array;
40
+ /**
41
+ * Splat indices ordered so that any prefix of the result is a good-looking
42
+ * subset of the whole: the most important splat of every cell first, then the
43
+ * second of every cell, and so on.
44
+ *
45
+ * Importance within a cell is opacity times the two largest gaussian axes -
46
+ * the area the splat actually paints, which the Unity build measured as
47
+ * ranking 0.80 with the ink each splat deposits, at no cost to compute.
48
+ *
49
+ * The round-robin across cells is what makes this usable as a prefix, and it
50
+ * is the same decision the load-time prune makes for the same reason: ranked
51
+ * globally, a prefix would be every strong splat on the jacket and none of the
52
+ * many individually-faint ones that make up a face, so the face would be the
53
+ * first thing to go. Taken a rank at a time across cells, every region thins
54
+ * by the same proportion at every prefix length, so a character at 30% looks
55
+ * like a sparser version of itself rather than a damaged one.
56
+ *
57
+ * Costs one pass over the splats plus a small sort inside each cell, and is
58
+ * built once, the first time a count LOD asks for it.
59
+ */
60
+ export declare function buildImportanceOrder(packed: PackedSplats, voxelSize: number): Uint32Array;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Load-time splat removal: the two passes that make a character cheaper by
3
+ * containing fewer splats, rather than by drawing each one faster.
4
+ *
5
+ * Both run on the decoded attribute columns, before {@link packSplats} turns
6
+ * them into texels, so a removed splat costs nothing anywhere afterwards -
7
+ * no texel, no CPU skinning slot, no sort slot, no quad. That is what
8
+ * separates these from {@link SplatQuality.quadAlphaCutoff}, which shrinks
9
+ * quads and saves rasterisation alone.
10
+ *
11
+ * Ported from the Unity build's `SplatCharacterBinData.CullByOpacity` and
12
+ * `PruneByImportance`, including the two decisions in them that are easy to
13
+ * get wrong: ranking inside spatial cells rather than globally, and selecting
14
+ * rather than sorting within a cell.
15
+ */
16
+ import type { SplatAttributes } from '../loader/character/types';
17
+ import type { SplatWeights } from '../types';
18
+ /** What {@link pruneSplats} did, for a caller that wants to log or report it. */
19
+ export interface PruneReport {
20
+ /** Splats the decode handed in. */
21
+ numSplatsIn: number;
22
+ /** Splats left after both passes. This is what every consumer should read. */
23
+ numSplatsOut: number;
24
+ /** Removed by the opacity floor. */
25
+ droppedByOpacity: number;
26
+ /** Removed by the per-voxel importance quota, on top of the opacity floor. */
27
+ droppedByImportance: number;
28
+ /**
29
+ * The skinning influences, re-viewed onto the compacted prefix when anything
30
+ * was removed, or the caller's own object when nothing was. `undefined` for
31
+ * a splat with no skinning.
32
+ *
33
+ * Handed back rather than mutated in place because the arrays' *length* is
34
+ * what says how many influences each splat has: `Splat` divides it by the
35
+ * splat count to recover `K`, and leaving a pruned set at its original
36
+ * length would make that division report a `K` several times too large.
37
+ */
38
+ weights?: SplatWeights;
39
+ }
40
+ export interface PruneOptions {
41
+ /** Defaults to {@link SplatQuality.minOpacity}. */
42
+ minOpacity?: number;
43
+ /** Defaults to {@link SplatQuality.pruneRatio}. */
44
+ pruneRatio?: number;
45
+ /** Defaults to {@link SplatQuality.pruneVoxelSize}. */
46
+ voxelSize?: number;
47
+ }
48
+ /**
49
+ * Remove splats the current quality settings say are not worth carrying,
50
+ * compacting every column in place.
51
+ *
52
+ * `attributes` and `weights` are mutated: their arrays keep their allocated
53
+ * length and the returned count is what every consumer should read, which is
54
+ * exactly how {@link packSplats} already uses `numSplats`. Order is preserved,
55
+ * so whatever spatial coherence the file had survives.
56
+ *
57
+ * Returns the input count unchanged, having touched nothing, when both knobs
58
+ * are off - the default - so this sits on the load path for free.
59
+ */
60
+ export declare function pruneSplats(numSplats: number, attributes: SplatAttributes, weights?: SplatWeights, opts?: PruneOptions): PruneReport;
@@ -0,0 +1,175 @@
1
+ export declare const SplatQuality: {
2
+ /**
3
+ * Alpha at which a splat's quad is allowed to end, so each one is sized to
4
+ * the radius its own opacity actually reaches instead of to the worst case.
5
+ * **The first knob to reach for on a weak GPU.**
6
+ *
7
+ * The fragment stage multiplies a gaussian falloff by the splat's opacity
8
+ * and discards anything below 1/255, so a quad drawn out to where that
9
+ * product is already below the cutoff spends fragments on a ring that
10
+ * cannot put ink on the screen. Raising the cutoff shrinks every quad. What
11
+ * it costs is a harder edge on each splat, which shows first on soft things
12
+ * - hair, fabric, anything semi-transparent.
13
+ *
14
+ * Takes effect on the next frame; nothing is reloaded and nothing is lost,
15
+ * so it is safe to drive from a frame-rate governor.
16
+ *
17
+ * | Value | What it is for |
18
+ * |---|---|
19
+ * | `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. |
20
+ * | `8 / 255` | What the Unity build ships. The conservative end. |
21
+ * | `12 / 255` | **The default here.** |
22
+ * | `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. |
23
+ * | 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. |
24
+ *
25
+ * Measured on a Mali-G57 with three characters, against 8/255: 12/255 -7%,
26
+ * 16/255 -12%, 24/255 -19%. Measured here on an Apple GPU where fragments
27
+ * are the bottleneck, against 1/255: 8/255 -29%, 16/255 -42%, 24/255 -50%.
28
+ * Where the main thread is the bottleneck instead - the WebGL backend with
29
+ * animated characters - it is free but buys nothing; see the README's
30
+ * Tuning section for which case a scene is in.
31
+ */
32
+ quadAlphaCutoff: number;
33
+ /**
34
+ * Drop splats whose projected size falls below this radius in pixels, for
35
+ * every splat at once. `0` (the default) draws everything.
36
+ *
37
+ * The same test as the per-splat {@link Splat.minPixelSize}; the larger of
38
+ * the two wins, so a global floor cannot un-cull a splat an application
39
+ * already asked to cull. Removes distant crowd splats that cannot contribute
40
+ * more than a fraction of a pixel.
41
+ */
42
+ screenSizeCull: number;
43
+ /**
44
+ * Peak opacity a splat must reach to be kept when a character or scene is
45
+ * loaded. `0` keeps everything.
46
+ *
47
+ * A splat's alpha at its centre IS its opacity - the gaussian only falls
48
+ * off from there - so the faintest splats in a bundle are ones that paint
49
+ * almost nothing from any camera while still costing memory, a skinning
50
+ * pass, a sort slot and a quad. This removes them outright, which is what
51
+ * separates it from {@link SplatQuality.quadAlphaCutoff}: the cutoff
52
+ * shrinks a splat's quad and saves rasterisation alone, this saves
53
+ * everything, but it cannot half-remove one.
54
+ *
55
+ * **Applied during the decode of a CHARACTER bundle**, so it only affects
56
+ * what is loaded afterwards - set it before the first `load()`, and changing
57
+ * it later does nothing to characters already in the scene. It does not
58
+ * touch `.ply` scenes, matching the Unity build, which applies it in the
59
+ * skinned renderer and nowhere else: a scene's opacity distribution is a
60
+ * different thing, and 0.05 measured as 8.7% of a character against 41.8%
61
+ * of a 1M-splat room. {@link pruneSplats} is exported for an application
62
+ * that wants to make the trade on a scene of its own. It also changes
63
+ * {@link Splat.numSplats}, so an application that hands back a per-splat
64
+ * array ({@link Splat.setSplatGroups}) must size that array from
65
+ * `numSplats` after loading rather than from the file.
66
+ *
67
+ * | Value | What it is for |
68
+ * |---|---|
69
+ * | `0` | Keep every splat. The behaviour before this knob existed. |
70
+ * | `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. |
71
+ * | `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. |
72
+ * | 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. |
73
+ */
74
+ minOpacity: number;
75
+ /**
76
+ * Fraction of splats removed at load by voxel-local importance, on top of
77
+ * {@link SplatQuality.minOpacity}. `0` (the default) keeps everything, `0.5`
78
+ * drops the least-important half of every cell.
79
+ *
80
+ * Importance is opacity times a splat's two largest axes - the area it
81
+ * actually paints - ranked separately inside each spatial cell so a dense
82
+ * region loses the same share as a sparse one rather than being deleted
83
+ * wholesale. That per-cell quota is what keeps the face: a global ranking
84
+ * puts the many faint facial splats at the bottom and smears it. Measured on
85
+ * the Unity build's reference character at 0.5: 25,826 of 50,267 splats
86
+ * remain, the frame falls 24.5% on a Mali-G57, and the face holds 43.5 dB
87
+ * where a global rank gives 37.6.
88
+ *
89
+ * Applied during the decode of a character bundle, like
90
+ * {@link SplatQuality.minOpacity}, with the same scope and the same reason
91
+ * for being off by default.
92
+ */
93
+ pruneRatio: number;
94
+ /**
95
+ * Cell size, in metres, for {@link SplatQuality.pruneRatio}'s per-voxel
96
+ * quota.
97
+ *
98
+ * 4 cm gives ~19 splats per occupied cell on the Unity build's reference
99
+ * character - small enough that the face and the jacket are ranked apart,
100
+ * large enough that a cell has a population to rank. A tuning constant
101
+ * rather than a per-title knob, exposed because a character authored at a
102
+ * different scale needs a different one.
103
+ */
104
+ pruneVoxelSize: number;
105
+ /**
106
+ * Test each splat against the depth buffer, so opaque geometry hides the
107
+ * splats behind it. On by default.
108
+ *
109
+ * Unlike the Unity build, where turning this off was the single largest
110
+ * mobile saving, this is a correctness switch here and not a performance
111
+ * one: that build renders splats into an offscreen target and pays for the
112
+ * test by sampling a depth texture per fragment, where this package draws
113
+ * straight into the camera's target and gets the hardware depth test for
114
+ * free. Turning it off makes characters draw through walls and saves
115
+ * nothing. It is here because the behaviour is occasionally what an
116
+ * application wants - a character deliberately drawn over the scene.
117
+ */
118
+ depthOcclusion: boolean;
119
+ /**
120
+ * Collect {@link SplatQuality.timings}. Off by default.
121
+ *
122
+ * The CPU stages are timed with `performance.now()`, which is cheap but not
123
+ * free at a few calls per splat per frame, and a shipping application that
124
+ * never reads the numbers should not pay for them.
125
+ */
126
+ passTimings: boolean;
127
+ /**
128
+ * Per-stage cost of the last frame in which {@link SplatQuality.passTimings}
129
+ * was on, in milliseconds, summed over every splat that ran that stage.
130
+ *
131
+ * These are main-thread times, which is the measurement that matters for
132
+ * this package: under `WebGLRenderer` the per-character CPU cost - skinning
133
+ * every centre to feed the sort, then the sort - is what limits how many
134
+ * characters a scene can carry, and neither stage exists on the GPU
135
+ * timeline. `skin` and `sort` are zero under `WebGPURenderer`, where both
136
+ * happen in compute; `splatsDrawn` and `splatsTotal` are meaningful on both,
137
+ * and differ when {@link SplatQuality.screenSizeCull} or a count LOD is
138
+ * reducing what is submitted.
139
+ *
140
+ * These describe the most recent pass in which any splat drew, so read them
141
+ * after your render call. A pass that drew no splats leaves the previous
142
+ * numbers in place rather than zeroing them - a zero and "nothing happened"
143
+ * would otherwise read the same, which is the mistake the Unity build's
144
+ * equivalent made and spent a long time chasing.
145
+ */
146
+ timings: {
147
+ /** CPU skinning of splat centres, i.e. `Splat.ensureCenters`. */
148
+ skin: number;
149
+ /** The depth sort, where it runs on the main thread. */
150
+ sort: number;
151
+ /** Uniform and buffer updates in `beforeRender`, excluding skin and sort. */
152
+ prepare: number;
153
+ /** Splat instances submitted to the GPU this frame. */
154
+ splatsDrawn: number;
155
+ /** Splat instances those draws could have covered at full detail. */
156
+ splatsTotal: number;
157
+ };
158
+ /** Restore every knob to its default. Call when tearing the engine down. */
159
+ resetStatics(): void;
160
+ };
161
+ /** The effective sub-pixel cull for one splat: its own floor or the global one. */
162
+ export declare function effectiveMinPixelSize(splatMinPixelSize: number): number;
163
+ /**
164
+ * Add a CPU skinning measurement for the frame now being prepared.
165
+ * @internal
166
+ */
167
+ export declare function addSkinTime(ms: number): void;
168
+ /**
169
+ * Open this pass's timing accumulation, clearing it when `source` - the splat
170
+ * reporting - has already contributed, which means a new pass has begun.
171
+ * Called by every backend before it adds to {@link SplatQuality.timings}.
172
+ *
173
+ * @internal
174
+ */
175
+ export declare function beginFrameTimings(source: object): void;
@@ -83,6 +83,10 @@ export declare class Splat {
83
83
  readonly localBounds: Box3;
84
84
  /** Discard splats smaller than this many pixels on screen. `0` draws everything. */
85
85
  minPixelSize: number;
86
+ private _activeSplatCount;
87
+ private _importanceOrder;
88
+ /** `_importanceOrder` inverted: each splat's place in it. @internal */
89
+ private _importanceRank;
86
90
  private readonly _mesh;
87
91
  private _cpuInfluences;
88
92
  private _pendingBoneTransforms;
@@ -103,8 +107,65 @@ export declare class Splat {
103
107
  cloneStatic(engine: SplatEngine, asyncSort: boolean): Splat;
104
108
  /** Top-4 influences per splat, zero-padded. The shader and the CPU path both assume four. */
105
109
  private _normalizeWeights;
110
+ /**
111
+ * How many splats are skinned, sorted and drawn this frame: the whole set,
112
+ * or the first `n` of {@link Splat.importanceOrder} after
113
+ * {@link Splat.setCountLod}.
114
+ */
115
+ get activeSplatCount(): number;
116
+ /** Whether a count LOD is currently reducing this splat. */
117
+ get countLodActive(): boolean;
118
+ /**
119
+ * Process only the `n` most important splats this frame, or the whole set
120
+ * with `null`.
121
+ *
122
+ * The dynamic counterpart of {@link SplatQuality.pruneRatio}: the prune
123
+ * decides once, at load, that some splats are not worth carrying at all,
124
+ * while this decides per frame how many of them are worth the work right
125
+ * now - which is the trade a character makes as it walks away from the
126
+ * camera. Nothing is freed, so it reverses the moment `n` goes back up.
127
+ *
128
+ * Which splats the first `n` are is {@link Splat.importanceOrder}'s answer:
129
+ * the strongest splat of every 4 cm cell, then the second of every cell, and
130
+ * so on, so the character thins evenly rather than losing a region.
131
+ *
132
+ * `WebGLRenderer` only, where it is worth the most: that backend skins every
133
+ * splat centre on the CPU each frame to feed its sort, and this cuts that
134
+ * pass, the sort and the draw together. Under `WebGPURenderer` all of that
135
+ * already happens in compute, and the call is accepted and ignored rather
136
+ * than silently changing which splats a GPU sort can see.
137
+ *
138
+ * {@link Character.setSplatCountLod} drives this from camera distance; call
139
+ * it directly for a policy of your own.
140
+ */
141
+ setCountLod(n: number | null): void;
142
+ /**
143
+ * Build {@link Splat.importanceOrder} now, if it does not exist yet.
144
+ *
145
+ * One pass over the splats plus a small sort inside each 4 cm cell - single
146
+ * digit milliseconds for a character, but milliseconds that would otherwise
147
+ * land on the frame where the LOD first engages, which is a frame where the
148
+ * camera is already moving. {@link Character.setSplatCountLod} calls this
149
+ * when the LOD is configured, so the cost lands there instead.
150
+ */
151
+ prepareCountLod(): void;
152
+ /**
153
+ * Each splat's place in {@link Splat.importanceOrder}, which is how the
154
+ * per-frame passes test membership of the active prefix. `null` until
155
+ * {@link Splat.prepareCountLod}.
156
+ * @internal
157
+ */
158
+ get importanceRank(): Uint32Array | null;
159
+ /**
160
+ * Splat indices ordered by importance for {@link Splat.setCountLod}. `null`
161
+ * until {@link Splat.prepareCountLod} or a count LOD has asked for one.
162
+ */
163
+ get importanceOrder(): Uint32Array | null;
106
164
  /** Re-upload the bone index/weight textures after editing their data arrays. */
107
165
  markBoneTexturesDirty(): void;
166
+ /** Keep CPU-skinned centres (sorting and ground probes) on the same bone
167
+ * mapping as the GPU shader. Called once when an armature binds this splat. */
168
+ syncMappedCpuInfluences(firstPaletteIdx: number): void;
108
169
  get variantMaskEnabled(): boolean;
109
170
  get nonRigidCovariance(): boolean;
110
171
  /**
@@ -136,6 +197,11 @@ export declare class Splat {
136
197
  /**
137
198
  * Assign each splat to a group and enable per-group visibility. All groups
138
199
  * start visible; toggle them with {@link Splat.setVariantMask}.
200
+ *
201
+ * `groupIds` is indexed by splat, so it has to be built against
202
+ * {@link Splat.numSplats} as loaded - which {@link SplatQuality.minOpacity}
203
+ * makes smaller than the count in the file. An array authored offline
204
+ * against the file cannot simply be shortened: see the error this throws.
139
205
  */
140
206
  setSplatGroups(groupIds: Uint8Array, numGroups: number): void;
141
207
  /** Per-group visibility, one entry per group: `>= 0.5` shows the group. */
@@ -1,2 +1,2 @@
1
- export declare const SPLAT_VERTEX_GLSL = "\nprecision highp float;\nprecision highp int;\nprecision highp usampler2D;\n\n// Quad corner in [-1, 1].\nin vec3 position;\n// Index of the splat this instance draws; instances arrive back-to-front.\nin uint splatOrder;\n\nuniform mat4 modelMatrix;\nuniform mat4 viewMatrix;\nuniform mat4 projectionMatrix;\n\nuniform highp usampler2D tTransformA;\nuniform highp sampler2D tTransformB;\nuniform highp sampler2D tColor;\nuniform int uTexWidth;\n\n// Drawing-buffer size in pixels.\nuniform vec2 uViewport;\nuniform float uMinPixelSize;\n// World rotation (xyzw) and scale of the splat object.\nuniform vec4 uModelRotation;\nuniform vec3 uModelScale;\n// rgb tint colour, a tint amount.\nuniform vec4 uTint;\n// x contrast, y saturation.\nuniform vec2 uGrade;\n// 1 when the render target expects linear colour, 0 when it is display-referred.\nuniform float uOutputLinear;\n\nout mediump vec2 gaussianUV;\nout mediump vec4 gaussianColor;\n\n\nuniform vec3 uSunColor;\nuniform float uSunIntensity;\nuniform vec3 uAmbientColor;\nuniform int uPointLightCount;\nuniform highp sampler2D uPointLightTex;\n\n// Baked splat colour is multiplied by this. With no lights it is exactly 1.\nvec3 computeLitFactor(vec3 worldCenter) {\n vec3 lit = uAmbientColor + uSunColor * uSunIntensity;\n for (int i = 0; i < uPointLightCount; i++) {\n vec4 posRange = texelFetch(uPointLightTex, ivec2(i, 0), 0);\n vec4 colorInt = texelFetch(uPointLightTex, ivec2(i, 1), 0);\n vec3 toLight = posRange.xyz - worldCenter;\n float dist = length(toLight);\n float range = posRange.w;\n if (dist >= range) continue;\n float t = dist / range;\n float atten = 1.0 - t * t;\n atten = atten * atten;\n lit += colorInt.rgb * colorInt.w * atten;\n }\n return lit;\n}\n\n\nuniform highp sampler2D transformPalette;\n\n#ifdef USE_BONE_BLENDING\nuniform highp sampler2D boneIndices;\nuniform highp sampler2D boneWeights;\n#endif\n\n#ifdef USE_VARIANT_MASK\nuniform highp sampler2D splatGroupIds;\nuniform highp sampler2D splatVariantMask;\n\nbool isSplatHiddenByVariantMask(ivec2 splatUV) {\n float gidF = texelFetch(splatGroupIds, splatUV, 0).r;\n int gid = int(gidF + 0.5);\n float bit = texelFetch(splatVariantMask, ivec2(gid, 0), 0).r;\n return bit < 0.5;\n}\n#endif\n\nbool isReasonablePaletteTransform(mat4 t) {\n mat3 lin = mat3(t);\n vec3 s = vec3(length(lin[0]), length(lin[1]), length(lin[2]));\n // Reject only when EVERY column is near-zero (a truly empty palette slot).\n // Linear-blend skinning of two very different bone rotations produces a\n // candy-wrapper matrix with one short column; that is still a valid\n // mid-rotation transform. Requiring all columns to pass would collapse\n // joint-spanning splats (notably the neck) back to the bind pose.\n return any(greaterThan(s, vec3(1e-4)));\n}\n\nmat4 readTransformFromPalette(uint paletteIndex) {\n if (paletteIndex == 0u) {\n return mat4(1.0);\n }\n\n int u = int(paletteIndex % 512u) * 3;\n int v = int(paletteIndex / 512u);\n\n mat4 t;\n t[0] = texelFetch(transformPalette, ivec2(u, v), 0);\n t[1] = texelFetch(transformPalette, ivec2(u + 1, v), 0);\n t[2] = texelFetch(transformPalette, ivec2(u + 2, v), 0);\n t[3] = vec4(0.0, 0.0, 0.0, 1.0);\n\n mat4 result = transpose(t);\n if (!isReasonablePaletteTransform(result)) {\n return mat4(1.0);\n }\n return result;\n}\n\nmat4 getSplatPaletteTransform(ivec2 splatUV) {\n #ifdef USE_BONE_BLENDING\n vec4 boneIdxVec = texelFetch(boneIndices, splatUV, 0);\n vec4 boneWeightVec = texelFetch(boneWeights, splatUV, 0);\n float totalWeight = dot(boneWeightVec, vec4(1.0));\n if (totalWeight > 0.001) {\n mat4 blended = mat4(0.0);\n for (int i = 0; i < 4; i++) {\n float w = boneWeightVec[i];\n if (w > 0.001) {\n uint idx = uint(boneIdxVec[i] + 0.5);\n blended += readTransformFromPalette(idx) * w;\n }\n }\n mat4 result = blended / totalWeight;\n if (isReasonablePaletteTransform(result)) {\n return result;\n }\n }\n #endif\n\n return mat4(1.0);\n}\n\nvec4 mat3ToQuatXYZW(mat3 R) {\n vec4 q;\n float trace = R[0][0] + R[1][1] + R[2][2];\n if (trace > 0.0) {\n float s = sqrt(trace + 1.0) * 2.0;\n q.w = 0.25 * s;\n q.x = (R[1][2] - R[2][1]) / s;\n q.y = (R[2][0] - R[0][2]) / s;\n q.z = (R[0][1] - R[1][0]) / s;\n } else if (R[0][0] > R[1][1] && R[0][0] > R[2][2]) {\n float s = sqrt(1.0 + R[0][0] - R[1][1] - R[2][2]) * 2.0;\n q.w = (R[1][2] - R[2][1]) / s;\n q.x = 0.25 * s;\n q.y = (R[1][0] + R[0][1]) / s;\n q.z = (R[2][0] + R[0][2]) / s;\n } else if (R[1][1] > R[2][2]) {\n float s = sqrt(1.0 + R[1][1] - R[0][0] - R[2][2]) * 2.0;\n q.w = (R[2][0] - R[0][2]) / s;\n q.x = (R[1][0] + R[0][1]) / s;\n q.y = 0.25 * s;\n q.z = (R[2][1] + R[1][2]) / s;\n } else {\n float s = sqrt(1.0 + R[2][2] - R[0][0] - R[1][1]) * 2.0;\n q.w = (R[0][1] - R[1][0]) / s;\n q.x = (R[2][0] + R[0][2]) / s;\n q.y = (R[2][1] + R[1][2]) / s;\n q.z = 0.25 * s;\n }\n return q;\n}\n\n\nconst vec4 discardVec = vec4(0.0, 0.0, 2.0, 1.0);\n\nmat3 quatToMat3(vec4 R) {\n vec4 R2 = R + R;\n float X = R2.x * R.w;\n vec4 Y = R2.y * R;\n vec4 Z = R2.z * R;\n float W = R2.w * R.w;\n return mat3(\n 1.0 - Z.z - W, Y.z + X, Y.w - Z.x,\n Y.z - X, 1.0 - Y.y - W, Z.w + Y.x,\n Y.w + Z.x, Z.w - Y.x, 1.0 - Y.y - Z.z\n );\n}\n\nvec4 quatMul(vec4 a, vec4 b) {\n return vec4(\n a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y,\n a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,\n a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w,\n a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z\n );\n}\n\nvoid computeCovariance(vec4 rotation, vec3 scale, out vec3 covA, out vec3 covB) {\n mat3 rot = quatToMat3(rotation);\n mat3 M = transpose(mat3(\n scale.x * rot[0],\n scale.y * rot[1],\n scale.z * rot[2]\n ));\n covA = vec3(dot(M[0], M[0]), dot(M[0], M[1]), dot(M[0], M[2]));\n covB = vec3(dot(M[1], M[1]), dot(M[1], M[2]), dot(M[2], M[2]));\n}\n\nvec3 sRGBToLinear(vec3 c) {\n return mix(\n pow(c * 0.9478672986 + vec3(0.0521327014), vec3(2.4)),\n c * 0.0773993808,\n vec3(lessThanEqual(c, vec3(0.04045)))\n );\n}\n\nvoid main(void) {\n ivec2 splatUV = ivec2(int(splatOrder % uint(uTexWidth)), int(splatOrder / uint(uTexWidth)));\n uvec4 tA = texelFetch(tTransformA, splatUV, 0);\n vec4 tB = texelFetch(tTransformB, splatUV, 0);\n\n vec3 modelCenter = uintBitsToFloat(tA.xyz);\n vec3 packedRot = vec3(unpackHalf2x16(tA.w), tB.w);\n // xyzw, with w recovered: quaternions are stored on the w >= 0 hemisphere.\n vec4 srcRotation = vec4(packedRot, sqrt(max(0.0, 1.0 - dot(packedRot, packedRot))));\n vec3 srcScale = tB.xyz;\n\n // ---- Skin, then lift to world space --------------------------------\n mat4 palette = getSplatPaletteTransform(splatUV);\n vec3 modelDeformed = (palette * vec4(modelCenter, 1.0)).xyz;\n vec3 worldCenter = (modelMatrix * vec4(modelDeformed, 1.0)).xyz;\n\n vec4 worldRotation = quatMul(uModelRotation, srcRotation);\n if (worldRotation.w < 0.0) worldRotation = -worldRotation;\n vec3 worldScale = uModelScale * srcScale;\n\n #ifdef USE_BONE_BLENDING\n {\n // The full world-space skinning rotation comes from modelMatrix * palette;\n // the palette's own scale is deliberately dropped (rigid skinning).\n mat3 lin = mat3(modelMatrix * palette);\n vec3 s = vec3(length(lin[0]), length(lin[1]), length(lin[2]));\n if (s.x > 1e-5 && s.y > 1e-5 && s.z > 1e-5) {\n mat3 rot = mat3(lin[0] / s.x, lin[1] / s.y, lin[2] / s.z);\n vec4 composed = normalize(quatMul(mat3ToQuatXYZW(rot), srcRotation));\n if (composed.w < 0.0) composed = -composed;\n worldRotation = composed;\n }\n #ifdef USE_NONRIGID_COV\n // Bone-length scaling: the palette keeps its per-axis scale and the\n // gaussian stretches with the bone.\n worldScale *= s;\n #endif\n }\n #endif\n\n #ifdef USE_VARIANT_MASK\n if (isSplatHiddenByVariantMask(splatUV)) {\n gl_Position = discardVec;\n return;\n }\n #endif\n\n // ---- Project the centre --------------------------------------------\n bool ortho = projectionMatrix[3][3] == 1.0;\n vec4 centerView = viewMatrix * vec4(worldCenter, 1.0);\n if (!ortho && centerView.z > 0.0) {\n gl_Position = discardVec;\n return;\n }\n vec4 centerProj = projectionMatrix * centerView;\n centerProj.z = clamp(centerProj.z, -abs(centerProj.w), abs(centerProj.w));\n\n // ---- Project the covariance to a screen-space ellipse ----------------\n vec3 covA, covB;\n computeCovariance(worldRotation.wxyz, worldScale, covA, covB);\n mat3 Vrk = mat3(\n covA.x, covA.y, covA.z,\n covA.y, covB.x, covB.y,\n covA.z, covB.y, covB.z\n );\n\n float focal = uViewport.x * projectionMatrix[0][0];\n vec3 v = centerView.xyz;\n vec3 vp = ortho ? vec3(0.0, 0.0, 1.0) : v;\n float J1 = focal / vp.z;\n vec2 J2 = -J1 / vp.z * vp.xy;\n mat3 J = mat3(\n J1, 0.0, J2.x,\n 0.0, J1, J2.y,\n 0.0, 0.0, 0.0\n );\n mat3 W = transpose(mat3(viewMatrix));\n mat3 T = W * J;\n mat3 cov = transpose(T) * Vrk * T;\n\n #ifdef USE_ANTIALIAS\n float detOrig = cov[0][0] * cov[1][1] - cov[0][1] * cov[0][1];\n float detBlur = (cov[0][0] + 0.3) * (cov[1][1] + 0.3) - cov[0][1] * cov[0][1];\n float aaFactor = sqrt(max(detOrig / detBlur, 0.0));\n #endif\n\n float diagonal1 = cov[0][0] + 0.3;\n float offDiagonal = cov[0][1];\n float diagonal2 = cov[1][1] + 0.3;\n float mid = 0.5 * (diagonal1 + diagonal2);\n float radius = length(vec2((diagonal1 - diagonal2) / 2.0, offDiagonal));\n float lambda1 = mid + radius;\n float lambda2 = max(mid - radius, 0.1);\n\n float vmin = min(1024.0, min(uViewport.x, uViewport.y));\n float l1 = 2.0 * min(sqrt(2.0 * lambda1), vmin);\n float l2 = 2.0 * min(sqrt(2.0 * lambda2), vmin);\n if (max(l1, l2) < uMinPixelSize) {\n gl_Position = discardVec;\n return;\n }\n\n // Cull splats whose ellipse lies wholly off-screen.\n vec2 c = centerProj.ww / uViewport;\n if (any(greaterThan(abs(centerProj.xy) - vec2(max(l1, l2)) * c, centerProj.ww))) {\n gl_Position = discardVec;\n return;\n }\n\n vec2 diagonalVector = normalize(vec2(offDiagonal, lambda1 - diagonal1));\n vec2 v1 = l1 * diagonalVector;\n vec2 v2 = l2 * vec2(diagonalVector.y, -diagonalVector.x);\n vec2 cornerUV = position.xy;\n vec2 offset = (cornerUV.x * v1 + cornerUV.y * v2) * c;\n\n // ---- Colour -----------------------------------------------------------\n vec4 clr = texelFetch(tColor, splatUV, 0);\n #ifdef USE_ANTIALIAS\n clr.a *= aaFactor;\n #endif\n\n clr.rgb *= computeLitFactor(worldCenter);\n clr.rgb = mix(clr.rgb, uTint.rgb, clamp(uTint.a, 0.0, 1.0));\n // Identity grade is a true no-op: skipping it keeps HDR splat colours unclamped.\n if (uGrade.x != 1.0 || uGrade.y != 1.0) {\n clr.rgb = (clr.rgb - 0.5) * uGrade.x + 0.5;\n float luma = dot(clr.rgb, vec3(0.2126, 0.7152, 0.0722));\n clr.rgb = mix(vec3(luma), clr.rgb, uGrade.y);\n clr.rgb = clamp(clr.rgb, 0.0, 1.0);\n }\n\n if (255.0 * clr.a <= 1.0) {\n gl_Position = discardVec;\n return;\n }\n\n // Shrink the quad to where the gaussian is still above 1/255: fewer\n // fragments, identical pixels.\n float clip = min(1.0, sqrt(log(255.0 * clr.a)) * 0.5);\n offset *= clip;\n\n gl_Position = centerProj + vec4(offset, 0.0, 0.0);\n gaussianUV = cornerUV * clip;\n\n vec3 rgb = max(clr.rgb, 0.0);\n if (uOutputLinear > 0.5) rgb = sRGBToLinear(rgb);\n gaussianColor = vec4(rgb, clr.a);\n}\n";
1
+ export declare const SPLAT_VERTEX_GLSL = "\nprecision highp float;\nprecision highp int;\nprecision highp usampler2D;\n\n// Quad corner in [-1, 1].\nin vec3 position;\n// Index of the splat this instance draws; instances arrive back-to-front.\nin uint splatOrder;\n\nuniform mat4 modelMatrix;\nuniform mat4 viewMatrix;\nuniform mat4 projectionMatrix;\n\nuniform highp usampler2D tTransformA;\nuniform highp sampler2D tTransformB;\nuniform highp sampler2D tColor;\nuniform int uTexWidth;\n\n// Drawing-buffer size in pixels.\nuniform vec2 uViewport;\nuniform float uMinPixelSize;\n// 1 / the alpha a splat's quad is allowed to end at. Carried as the reciprocal\n// so the historic 1/255 cutoff arrives as exactly 255.0 and the two\n// expressions below reduce, bit for bit, to what they were before the cutoff\n// was a setting at all. See SplatQuality.quadAlphaCutoff.\nuniform float uAlphaCutoffScale;\n// World rotation (xyzw) and scale of the splat object.\nuniform vec4 uModelRotation;\nuniform vec3 uModelScale;\n// rgb tint colour, a tint amount.\nuniform vec4 uTint;\n// x contrast, y saturation.\nuniform vec2 uGrade;\n// 1 when the render target expects linear colour, 0 when it is display-referred.\nuniform float uOutputLinear;\n\nout mediump vec2 gaussianUV;\nout mediump vec4 gaussianColor;\n\n\nuniform vec3 uSunColor;\nuniform float uSunIntensity;\nuniform vec3 uAmbientColor;\nuniform int uPointLightCount;\nuniform highp sampler2D uPointLightTex;\n\n// Baked splat colour is multiplied by this. With no lights it is exactly 1.\nvec3 computeLitFactor(vec3 worldCenter) {\n vec3 lit = uAmbientColor + uSunColor * uSunIntensity;\n for (int i = 0; i < uPointLightCount; i++) {\n vec4 posRange = texelFetch(uPointLightTex, ivec2(i, 0), 0);\n vec4 colorInt = texelFetch(uPointLightTex, ivec2(i, 1), 0);\n vec3 toLight = posRange.xyz - worldCenter;\n float dist = length(toLight);\n float range = posRange.w;\n if (dist >= range) continue;\n float t = dist / range;\n float atten = 1.0 - t * t;\n atten = atten * atten;\n lit += colorInt.rgb * colorInt.w * atten;\n }\n return lit;\n}\n\n\nuniform highp sampler2D transformPalette;\n\n#ifdef USE_BONE_BLENDING\nuniform highp sampler2D boneIndices;\nuniform highp sampler2D boneWeights;\n#endif\n\n#ifdef USE_VARIANT_MASK\nuniform highp sampler2D splatGroupIds;\nuniform highp sampler2D splatVariantMask;\n\nbool isSplatHiddenByVariantMask(ivec2 splatUV) {\n float gidF = texelFetch(splatGroupIds, splatUV, 0).r;\n int gid = int(gidF + 0.5);\n float bit = texelFetch(splatVariantMask, ivec2(gid, 0), 0).r;\n return bit < 0.5;\n}\n#endif\n\nbool isReasonablePaletteTransform(mat4 t) {\n mat3 lin = mat3(t);\n vec3 s = vec3(length(lin[0]), length(lin[1]), length(lin[2]));\n // Reject only when EVERY column is near-zero (a truly empty palette slot).\n // Linear-blend skinning of two very different bone rotations produces a\n // candy-wrapper matrix with one short column; that is still a valid\n // mid-rotation transform. Requiring all columns to pass would collapse\n // joint-spanning splats (notably the neck) back to the bind pose.\n return any(greaterThan(s, vec3(1e-4)));\n}\n\nmat4 readTransformFromPalette(uint paletteIndex) {\n if (paletteIndex == 0u) {\n return mat4(1.0);\n }\n\n int u = int(paletteIndex % 512u) * 3;\n int v = int(paletteIndex / 512u);\n\n mat4 t;\n t[0] = texelFetch(transformPalette, ivec2(u, v), 0);\n t[1] = texelFetch(transformPalette, ivec2(u + 1, v), 0);\n t[2] = texelFetch(transformPalette, ivec2(u + 2, v), 0);\n t[3] = vec4(0.0, 0.0, 0.0, 1.0);\n\n mat4 result = transpose(t);\n if (!isReasonablePaletteTransform(result)) {\n return mat4(1.0);\n }\n return result;\n}\n\nmat4 getSplatPaletteTransform(ivec2 splatUV) {\n #ifdef USE_BONE_BLENDING\n vec4 boneIdxVec = texelFetch(boneIndices, splatUV, 0);\n vec4 boneWeightVec = texelFetch(boneWeights, splatUV, 0);\n float totalWeight = dot(boneWeightVec, vec4(1.0));\n if (totalWeight > 0.001) {\n mat4 blended = mat4(0.0);\n for (int i = 0; i < 4; i++) {\n float w = boneWeightVec[i];\n if (w > 0.001) {\n uint idx = uint(boneIdxVec[i] + 0.5);\n blended += readTransformFromPalette(idx) * w;\n }\n }\n mat4 result = blended / totalWeight;\n if (isReasonablePaletteTransform(result)) {\n return result;\n }\n }\n #endif\n\n return mat4(1.0);\n}\n\nvec4 mat3ToQuatXYZW(mat3 R) {\n vec4 q;\n float trace = R[0][0] + R[1][1] + R[2][2];\n if (trace > 0.0) {\n float s = sqrt(trace + 1.0) * 2.0;\n q.w = 0.25 * s;\n q.x = (R[1][2] - R[2][1]) / s;\n q.y = (R[2][0] - R[0][2]) / s;\n q.z = (R[0][1] - R[1][0]) / s;\n } else if (R[0][0] > R[1][1] && R[0][0] > R[2][2]) {\n float s = sqrt(1.0 + R[0][0] - R[1][1] - R[2][2]) * 2.0;\n q.w = (R[1][2] - R[2][1]) / s;\n q.x = 0.25 * s;\n q.y = (R[1][0] + R[0][1]) / s;\n q.z = (R[2][0] + R[0][2]) / s;\n } else if (R[1][1] > R[2][2]) {\n float s = sqrt(1.0 + R[1][1] - R[0][0] - R[2][2]) * 2.0;\n q.w = (R[2][0] - R[0][2]) / s;\n q.x = (R[1][0] + R[0][1]) / s;\n q.y = 0.25 * s;\n q.z = (R[2][1] + R[1][2]) / s;\n } else {\n float s = sqrt(1.0 + R[2][2] - R[0][0] - R[1][1]) * 2.0;\n q.w = (R[0][1] - R[1][0]) / s;\n q.x = (R[2][0] + R[0][2]) / s;\n q.y = (R[2][1] + R[1][2]) / s;\n q.z = 0.25 * s;\n }\n return q;\n}\n\n\nconst vec4 discardVec = vec4(0.0, 0.0, 2.0, 1.0);\n\nmat3 quatToMat3(vec4 R) {\n vec4 R2 = R + R;\n float X = R2.x * R.w;\n vec4 Y = R2.y * R;\n vec4 Z = R2.z * R;\n float W = R2.w * R.w;\n return mat3(\n 1.0 - Z.z - W, Y.z + X, Y.w - Z.x,\n Y.z - X, 1.0 - Y.y - W, Z.w + Y.x,\n Y.w + Z.x, Z.w - Y.x, 1.0 - Y.y - Z.z\n );\n}\n\nvec4 quatMul(vec4 a, vec4 b) {\n return vec4(\n a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y,\n a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,\n a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w,\n a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z\n );\n}\n\nvoid computeCovariance(vec4 rotation, vec3 scale, out vec3 covA, out vec3 covB) {\n mat3 rot = quatToMat3(rotation);\n mat3 M = transpose(mat3(\n scale.x * rot[0],\n scale.y * rot[1],\n scale.z * rot[2]\n ));\n covA = vec3(dot(M[0], M[0]), dot(M[0], M[1]), dot(M[0], M[2]));\n covB = vec3(dot(M[1], M[1]), dot(M[1], M[2]), dot(M[2], M[2]));\n}\n\nvec3 sRGBToLinear(vec3 c) {\n return mix(\n pow(c * 0.9478672986 + vec3(0.0521327014), vec3(2.4)),\n c * 0.0773993808,\n vec3(lessThanEqual(c, vec3(0.04045)))\n );\n}\n\nvoid main(void) {\n ivec2 splatUV = ivec2(int(splatOrder % uint(uTexWidth)), int(splatOrder / uint(uTexWidth)));\n uvec4 tA = texelFetch(tTransformA, splatUV, 0);\n vec4 tB = texelFetch(tTransformB, splatUV, 0);\n\n vec3 modelCenter = uintBitsToFloat(tA.xyz);\n vec3 packedRot = vec3(unpackHalf2x16(tA.w), tB.w);\n // xyzw, with w recovered: quaternions are stored on the w >= 0 hemisphere.\n vec4 srcRotation = vec4(packedRot, sqrt(max(0.0, 1.0 - dot(packedRot, packedRot))));\n vec3 srcScale = tB.xyz;\n\n // ---- Skin, then lift to world space --------------------------------\n mat4 palette = getSplatPaletteTransform(splatUV);\n vec3 modelDeformed = (palette * vec4(modelCenter, 1.0)).xyz;\n vec3 worldCenter = (modelMatrix * vec4(modelDeformed, 1.0)).xyz;\n\n vec4 worldRotation = quatMul(uModelRotation, srcRotation);\n if (worldRotation.w < 0.0) worldRotation = -worldRotation;\n vec3 worldScale = uModelScale * srcScale;\n\n #ifdef USE_BONE_BLENDING\n {\n // The full world-space skinning rotation comes from modelMatrix * palette;\n // the palette's own scale is deliberately dropped (rigid skinning).\n mat3 lin = mat3(modelMatrix * palette);\n vec3 s = vec3(length(lin[0]), length(lin[1]), length(lin[2]));\n if (s.x > 1e-5 && s.y > 1e-5 && s.z > 1e-5) {\n mat3 rot = mat3(lin[0] / s.x, lin[1] / s.y, lin[2] / s.z);\n vec4 composed = normalize(quatMul(mat3ToQuatXYZW(rot), srcRotation));\n if (composed.w < 0.0) composed = -composed;\n worldRotation = composed;\n }\n #ifdef USE_NONRIGID_COV\n // Bone-length scaling: the palette keeps its per-axis scale and the\n // gaussian stretches with the bone.\n worldScale *= s;\n #endif\n }\n #endif\n\n #ifdef USE_VARIANT_MASK\n if (isSplatHiddenByVariantMask(splatUV)) {\n gl_Position = discardVec;\n return;\n }\n #endif\n\n // ---- Project the centre --------------------------------------------\n bool ortho = projectionMatrix[3][3] == 1.0;\n vec4 centerView = viewMatrix * vec4(worldCenter, 1.0);\n if (!ortho && centerView.z > 0.0) {\n gl_Position = discardVec;\n return;\n }\n vec4 centerProj = projectionMatrix * centerView;\n centerProj.z = clamp(centerProj.z, -abs(centerProj.w), abs(centerProj.w));\n\n // ---- Project the covariance to a screen-space ellipse ----------------\n vec3 covA, covB;\n computeCovariance(worldRotation.wxyz, worldScale, covA, covB);\n mat3 Vrk = mat3(\n covA.x, covA.y, covA.z,\n covA.y, covB.x, covB.y,\n covA.z, covB.y, covB.z\n );\n\n float focal = uViewport.x * projectionMatrix[0][0];\n vec3 v = centerView.xyz;\n vec3 vp = ortho ? vec3(0.0, 0.0, 1.0) : v;\n float J1 = focal / vp.z;\n vec2 J2 = -J1 / vp.z * vp.xy;\n mat3 J = mat3(\n J1, 0.0, J2.x,\n 0.0, J1, J2.y,\n 0.0, 0.0, 0.0\n );\n mat3 W = transpose(mat3(viewMatrix));\n mat3 T = W * J;\n mat3 cov = transpose(T) * Vrk * T;\n\n #ifdef USE_ANTIALIAS\n float detOrig = cov[0][0] * cov[1][1] - cov[0][1] * cov[0][1];\n float detBlur = (cov[0][0] + 0.3) * (cov[1][1] + 0.3) - cov[0][1] * cov[0][1];\n float aaFactor = sqrt(max(detOrig / detBlur, 0.0));\n #endif\n\n float diagonal1 = cov[0][0] + 0.3;\n float offDiagonal = cov[0][1];\n float diagonal2 = cov[1][1] + 0.3;\n float mid = 0.5 * (diagonal1 + diagonal2);\n float radius = length(vec2((diagonal1 - diagonal2) / 2.0, offDiagonal));\n float lambda1 = mid + radius;\n float lambda2 = max(mid - radius, 0.1);\n\n float vmin = min(1024.0, min(uViewport.x, uViewport.y));\n float l1 = 2.0 * min(sqrt(2.0 * lambda1), vmin);\n float l2 = 2.0 * min(sqrt(2.0 * lambda2), vmin);\n if (max(l1, l2) < uMinPixelSize) {\n gl_Position = discardVec;\n return;\n }\n\n // Cull splats whose ellipse lies wholly off-screen.\n vec2 c = centerProj.ww / uViewport;\n if (any(greaterThan(abs(centerProj.xy) - vec2(max(l1, l2)) * c, centerProj.ww))) {\n gl_Position = discardVec;\n return;\n }\n\n vec2 diagonalVector = normalize(vec2(offDiagonal, lambda1 - diagonal1));\n vec2 v1 = l1 * diagonalVector;\n vec2 v2 = l2 * vec2(diagonalVector.y, -diagonalVector.x);\n vec2 cornerUV = position.xy;\n vec2 offset = (cornerUV.x * v1 + cornerUV.y * v2) * c;\n\n // ---- Colour -----------------------------------------------------------\n vec4 clr = texelFetch(tColor, splatUV, 0);\n #ifdef USE_ANTIALIAS\n clr.a *= aaFactor;\n #endif\n\n clr.rgb *= computeLitFactor(worldCenter);\n clr.rgb = mix(clr.rgb, uTint.rgb, clamp(uTint.a, 0.0, 1.0));\n // Identity grade is a true no-op: skipping it keeps HDR splat colours unclamped.\n if (uGrade.x != 1.0 || uGrade.y != 1.0) {\n clr.rgb = (clr.rgb - 0.5) * uGrade.x + 0.5;\n float luma = dot(clr.rgb, vec3(0.2126, 0.7152, 0.0722));\n clr.rgb = mix(vec3(luma), clr.rgb, uGrade.y);\n clr.rgb = clamp(clr.rgb, 0.0, 1.0);\n }\n\n // A splat whose peak alpha is already under the cutoff cannot put a\n // fragment on screen: its own quad would be empty.\n float alphaOverCutoff = clr.a * uAlphaCutoffScale;\n if (alphaOverCutoff <= 1.0) {\n gl_Position = discardVec;\n return;\n }\n\n // Shrink the quad to where the gaussian is still above the cutoff: fewer\n // fragments. At a cutoff of 1/255 the pixels are identical, since below\n // that frag() discards anyway; above it, this is the trade the setting\n // exists to make.\n float clip = min(1.0, sqrt(log(alphaOverCutoff)) * 0.5);\n offset *= clip;\n\n gl_Position = centerProj + vec4(offset, 0.0, 0.0);\n gaussianUV = cornerUV * clip;\n\n vec3 rgb = max(clr.rgb, 0.0);\n if (uOutputLinear > 0.5) rgb = sRGBToLinear(rgb);\n gaussianColor = vec4(rgb, clr.a);\n}\n";
2
2
  export declare const SPLAT_FRAGMENT_GLSL = "\nprecision highp float;\n\nin mediump vec2 gaussianUV;\nin mediump vec4 gaussianColor;\n\nlayout(location = 0) out highp vec4 fragColor;\n\nconst float EXP4 = exp(-4.0);\nconst float INV_EXP4 = 1.0 / (1.0 - EXP4);\n\n// exp(-4x) rescaled to hit exactly 0 at the quad edge (x = 1).\nfloat normExp(float x) {\n return (exp(x * -4.0) - EXP4) * INV_EXP4;\n}\n\nvoid main(void) {\n mediump float A = dot(gaussianUV, gaussianUV);\n if (A > 1.0) {\n discard;\n }\n mediump float alpha = normExp(A) * gaussianColor.a;\n if (alpha < 1.0 / 255.0) {\n discard;\n }\n // Premultiplied.\n fragColor = vec4(gaussianColor.rgb * alpha, alpha);\n}\n";
@@ -28,6 +28,8 @@ export declare class WebGLSplatImpl implements SplatRenderImpl {
28
28
  private _lastDx;
29
29
  private _lastDy;
30
30
  private _lastDz;
31
+ /** The importance ordering the worker already holds, if any. */
32
+ private _sentSubset;
31
33
  /**
32
34
  * @param asyncSort - Sort in a worker and keep drawing the previous order
33
35
  * meanwhile. Right for large static splats; wrong for skinned ones, whose
@@ -67,5 +67,7 @@ export declare class WebGPUSplatImpl implements SplatRenderImpl {
67
67
  */
68
68
  private _build;
69
69
  beforeRender(renderer: any, camera: Camera): void;
70
+ /** Refresh this splat's uniforms and run whatever compute this frame needs. */
71
+ private _prepare;
70
72
  dispose(): void;
71
73
  }