@solidrt/3d 0.0.62 → 0.0.64

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/AGENTS.md CHANGED
@@ -161,8 +161,11 @@ Groups), and each target carries a mask (default 1) - `layers` on
161
161
  createScene/createView, live via `setLayers` on the scene handle and
162
162
  each view. A mesh draws where mask & layers is non-zero, so a minimap's
163
163
  marker meshes live on bit 2: invisible in the main render, drawn by
164
- the map view whose mask admits them. Shadow views follow the SCENE's
165
- mask (what the scene cannot see must not darken it), and
164
+ the map view whose mask admits them. LIGHTS carry a `layers` mask too
165
+ (all bits by default, see Lights below): a light lights and shadows
166
+ only the meshes its mask intersects. Shadow views follow the SCENE's
167
+ mask (what the scene cannot see must not darken it) narrowed by their
168
+ light's own `layers`, and
166
169
  pick()/raycast()/overlap()/sweep() skip scene-masked-out meshes like
167
170
  invisible ones - unless the query passes its own `{ layers }`, which is
168
171
  how a low-poly collision mesh lives undrawn in the scene yet answers
@@ -172,8 +175,12 @@ hides its whole interior, but frustum culling cannot know that and
172
175
  there is no occlusion or portal culling. Put the interior, the shell
173
176
  and an opening "plug" on their own layers and switch the scene mask by
174
177
  the camera's zone (inside, outside, in the doorway) - the reactive
175
- `<Scene layers>` makes it one signal, and a light's reach is masked
176
- the same way once lights carry layers.
178
+ `<Scene layers>` makes it one signal - and mask each light (and the
179
+ hemisphere) to its side the same way, so the sun does not light the
180
+ room through its shell nor the room's bulb the street. Light `layers`
181
+ cannot mask the ENVIRONMENT term (image lighting is per target, not
182
+ per light): the interior meshes take `environment={null}` - or an
183
+ interior probe's cube - instead (Mesh.environment, see Environment).
177
184
  Per-view fog: `fog: FogOptions | null` on createView overrides the
178
185
  scene's fog for that view (null = unfogged - the clear minimap over a
179
186
  fogged scene); absent follows the scene. `overrideMaterial` (Three's
@@ -538,12 +545,15 @@ faces); an instance is just per-entry uniforms (`uColor`) and bindings
538
545
  ### Pure pieces and checks
539
546
 
540
547
  The pure pieces (`math.ts`, `color.ts`, `geometry.ts`,
541
- `profile.ts`, `sweep.ts`, `gltf.ts`, `model-file.ts`) are Solid-free and
548
+ `profile.ts`, `sweep.ts`, `gltf.ts`, `model-file.ts`, `splat-data.ts`)
549
+ are Solid-free and
542
550
  GPU-free BY DESIGN so they can be checked headless and run under bun:
543
551
  `src/model-data.ts` re-exports exactly this set as the published
544
552
  `@solidrt/3d/model` entry (the bake tool and app bake scripts), and
545
553
  `tests/model-data.test.ts` imports it under `bun test`, so a runtime
546
554
  import creeping into any of them fails there; keep them that way.
555
+ `src/splat-data.ts` is its own published entry the same way
556
+ (`@solidrt/3d/splat`, guarded by `tests/splat-data.test.ts`).
547
557
  The rigs under `checks/`
548
558
  (`geometry-check`, `sweep-check`, `pick-check`, `dispatch-check`,
549
559
  `gltf-check`) run on
@@ -573,9 +583,10 @@ collision claims - two copies of this contract have drifted before.
573
583
  | `InstancedLod` | as InstancedMesh minus `material`, plus `levels` (`[{ geometry, material, size }]` nearest first, fixed at creation; instanced materials as InstancedMesh's), `castShadow?` (every level); `<Instance>` children populate it as under `InstancedMesh`, each drawing the level its own projected size picks |
574
584
  | `Mesh` | `geometry`, `material`, transforms as Group, `params?` (per-mesh uniforms, merge semantics - no unset), `renderOrder?`, `castShadow?`, `layers?` (membership bitmask, default 1), `frustumCulled?` (default true; false for geometry a vertex stage moves beyond the node's box - a billboard, a fullscreen quad; see Culling), `cullMargin?` (world units of slack around the box for bounded displacement), pointer events (below), `ref?(mesh)` |
575
585
  | `Sprite` | as Mesh minus `geometry`: a camera-facing unit quad, `scale` is its world size, rotation is ignored; pair with a `sprite()` material |
576
- | `InstancedMesh` | as Mesh (an instanced `material`: a stock one with `instanced`/`instanceColors`, or a class declaring INSTANCE_MATRIX_ATTRIBUTES), plus `capacity?` (instance slots, default 64; the buffers double past it), `bounds?` (an optional cull box, never a hit: instances pick by themselves, and without it the mesh culls by their union), `label?`; a PARENT: `<Instance>` children populate it, `<Group>` children are squads; the record buffers are component-owned and freed on unmount, and `anchor?` (a SceneNode, fixed at creation: the ancestor the records are relative to, so `<Instance mesh>` children may sit anywhere under it - see createInstancedMesh) |
586
+ | `InstancedMesh` | as Mesh (an instanced `material`: a stock one with `instanced`/`instanceColors`, or a class declaring INSTANCE_MATRIX_ATTRIBUTES), plus `capacity?` (instance slots, default 64; the buffers double past it), `bounds?` (an optional cull box, never a hit: instances pick by themselves, and without it the mesh culls by their union), `instanceOrder?` (draw the records in key order, fixed at creation - see "Instance order"), `label?`; a PARENT: `<Instance>` children populate it, `<Group>` children are squads; the record buffers are component-owned and freed on unmount, and `anchor?` (a SceneNode, fixed at creation: the ancestor the records are relative to, so `<Instance mesh>` children may sit anywhere under it - see createInstancedMesh) |
577
587
  | `Instance` | one instance of the enclosing `InstancedMesh`: transforms, `transition`, pointer events as Group, plus `style?` (the material's style record, one value per component - `[r, g, b, a]` under `instanceColors`), `ref?(instance)`; a parent too (a `<Mesh>` under an instance rides with it), and `mesh?` (the population, fixed at creation, when the instance is placed outside its `<InstancedMesh>` - inside that mesh's `anchor` subtree) |
578
- | `RecordMesh` | as Mesh, plus `records` (the per-instance records in the material's first instance layout, an ArrayBufferView; buffer capacity starts at the first value and grows on larger rewrites), `count?` (records drawn, default all), `bounds?` (local [minX..maxZ] over the population - without it the mesh never picks); the record buffers are component-owned and freed on unmount |
588
+ | `RecordMesh` | as Mesh, plus `records` (the per-instance records in the material's first instance layout, an ArrayBufferView; buffer capacity starts at the first value and grows on larger rewrites), `count?` (records drawn, default all), `bounds?` (local [minX..maxZ] over the population - without it the mesh never picks), `instanceOrder?` (draw the records in key order, fixed at creation - see "Instance order"), `transfer?` (hand the records to the engine at creation, the write-once form - a later `records` value then throws; see "Instance order"); the record buffers are component-owned and freed on unmount |
589
+ | `SplatMesh` | as Mesh, plus `data` (a baked SplatData from loadSplat; write-once under the default transfer - swap clouds by remounting), `count?` (splats drawn, default all; records are importance-sorted at bake, so a prefix is the scene at that size), `material?` (a fork of the stock splat class) and `transfer?` (default true: records go to the engine, halving residency; see "Splats"); bounds, the back-to-front instance order and the stock material come from the bake |
579
590
  | `PerspectiveCamera` | `fov?` (vertical DEGREES, default 60), `near?`, `far?`, `position?`, `lookAt?`, `up?` - or the Scene `camera` prop, the same state (last write wins) |
580
591
  | `SpotLight` | transforms as Group, `direction?` (local aim, default [0, -1, 0]), `color?`, `intensity?`, `distance?` (falloff cutoff, 0 = none), `angle?` (cone half-angle DEGREES, default 60), `penumbra?` (0..1 rim fade, default 0), `decay?` (falloff exponent, default 2), `castShadow?`, `shadow?` (mapSize, bias, normalBias, near), `ref?(light)` |
581
592
  | `PointLight` | transforms as Group (position is what matters), `color?`, `intensity?`, `distance?`, `decay?`, `castShadow?` (six face maps, six shadow slots), `shadow?` (mapSize, bias, normalBias, near), `ref?(light)` |
@@ -1333,7 +1344,10 @@ with uViewProj - the specular/fresnel view vector is
1333
1344
  uViewProj rows, that carries the clip flip), `uniform mat4
1334
1345
  uInvViewProj` (the camera's inverse view-projection, shared likewise -
1335
1346
  a clip position back to world, the world-space ray through a pixel
1336
- without knowing the projection) and `uniform mat4 uNormal` (the world
1347
+ without knowing the projection), `uniform vec2 uViewport` (the target's
1348
+ pixel size, shared likewise and per target - each view feeds its own -
1349
+ so a pixel-space footprint like the splat material's needs no app
1350
+ write) and `uniform mat4 uNormal` (the world
1337
1351
  inverse-transpose, written beside uModel for this material's meshes;
1338
1352
  take `mat3(uNormal)` - correct under non-uniform scale, where
1339
1353
  mat3(uModel) bends normals off the surface). Attributes come from the
@@ -1491,7 +1505,8 @@ or in five figures, belong to the function face.
1491
1505
  #### createRecordMesh
1492
1506
 
1493
1507
  `createRecordMesh(geometry, material, records, count?, { bounds?,
1494
- label? })` is the raw form: `records` is the per-instance data laid out
1508
+ instanceOrder?, label? })` is the raw form: `records` is the
1509
+ per-instance data laid out
1495
1510
  in the material's first instance layout - a Float32Array over an
1496
1511
  all-float layout, otherwise bytes built with `vertexView(layout, buffer)`
1497
1512
  and `attributeAccess` like a geometry stream (a length that is not whole
@@ -1523,6 +1538,63 @@ unhide, renderOrder/params/geometry/material swaps apply, and
1523
1538
  explicit free, geometry-buffer rule. `examples/fleet.tsx` (instances,
1524
1539
  components) and `examples/instanced.tsx` (records) are the live proofs.
1525
1540
 
1541
+ #### Instance order
1542
+
1543
+ `instanceOrder` on either create (and the components) draws a
1544
+ population's records in key order, decided core-side; slots as you write
1545
+ them never move. `{ position: "iCenter", retain: true, descending: true
1546
+ }` names a float32x3/x4 attribute holding a local-space position: records
1547
+ draw by their depth along the scene camera's view direction, which the
1548
+ SCENE feeds the core whenever the camera or the mesh turns meaningfully
1549
+ (mapped through the mesh's rotation; an epsilon gate meters the
1550
+ re-sorts) - back-to-front transparency over 100k+ records with zero
1551
+ per-frame JS, and with `retain` a camera turn re-sorts and republishes
1552
+ core-side while a parked camera uploads nothing. `{ field: "age" }`
1553
+ names a float32 attribute and draws by its value, re-ordered when the
1554
+ records publish. The costs, by design: an ordered mesh publishes the
1555
+ LIVE record set whole on any write (a byte range has no stable position
1556
+ under a permutation; `count` defines the sorted population), and one
1557
+ buffer holds ONE order - the scene target's entry owns it, views and
1558
+ shadow passes draw the same gathered buffers, and a mesh layered out of
1559
+ the scene target keeps its last order. Where the key sits decides
1560
+ nothing else: raw instancing stays buffer-order everywhere (Three,
1561
+ Unity, Godot), so this knob has no engine counterpart to compare
1562
+ against; the core primitive is okf/done/gpu-instance-order.md.
1563
+
1564
+ `transfer: true` (createRecordMesh only) is the write-once form for a
1565
+ large population: the records go to the ENGINE at creation and the mesh
1566
+ keeps no JS mirror, so the record bytes are resident once CPU-side
1567
+ (engine mirror) plus the GPU buffer, instead of twice more (JS mirror,
1568
+ plus the creation bytes the app would keep). The contract is loud:
1569
+ setRecords, updateRecords, instanceAttribute and growth throw naming
1570
+ the transfer; setRecordCount still works (with an instanceOrder the
1571
+ engine re-sorts and republishes the first n records of ITS copy),
1572
+ and so do setVisible, transforms, re-attach and disposeInstances (which
1573
+ frees the engine copy with the buffer). Takes a single-stream material;
1574
+ with an instanceOrder the engine's retained copy is implied. The engine
1575
+ mirror is BUFFER-lifetime, so a detached-and-re-attached ordered entry
1576
+ (setLayers re-admission included) seeds its order from it with no
1577
+ publish from JS - true for every retained ordered mesh since this
1578
+ landed, transfer or not.
1579
+
1580
+ The INDEXED form, `instanceOrder: { position: "iCenter", records:
1581
+ LAYOUT, descending: true }` (createRecordMesh only), is for a population
1582
+ whose records live in a DATA TEXTURE the vertex stage fetches by record
1583
+ index - a splat cloud, any large write-once set whose per-instance
1584
+ frontend cost matters. `records` is the layout of the record bytes you
1585
+ pass (the key attribute lives there), and the material's ONE instance
1586
+ buffer is the id stream: uint32/uint32x4 attributes the engine fills
1587
+ with the sorted record indices, K ids per instance record, so one drawn
1588
+ instance covers K records - repeat the geometry K times and pick the
1589
+ record with `gl_VertexID` (quad `gl_VertexID / 4`), fetch it by id
1590
+ (`texelFetch` on an `rgba32ui` texture the app built from the same
1591
+ bytes and bound per mesh), and cull the id 0xFFFFFFFF that pads the
1592
+ last group. Transfer is implied (the records are the engine's sort
1593
+ key source), a re-sort uploads 4 bytes per record instead of the
1594
+ record, and `count` stays in records while the entry draws
1595
+ ceil(count / K) instances (`drawCount`). `MeshInstances.textures` holds
1596
+ data textures the population owns, freed by disposeInstances.
1597
+
1526
1598
  ### Background
1527
1599
 
1528
1600
  Background: `scene.setBackground(source | null)`, the `background` option
@@ -1614,8 +1686,27 @@ sky-lit reflections: ONE `uEnv` samplerCube bound on every target the
1614
1686
  scene draws into (a 1x1 black placeholder while unset) and one
1615
1687
  shared-params write (`uEnvIntensity`, `uEnvRotation`, `uEnvOn`), however
1616
1688
  many meshes reflect; no per-material envMap (Three's Basic/Phong
1617
- `envMap`) - a custom material composes ENVIRONMENT from
1618
- `@solidrt/3d/glsl`. `reflectivity` 0..1 is the
1689
+ `envMap` sits on the MATERIAL, but materials are shared here) - a
1690
+ custom material composes ENVIRONMENT from `@solidrt/3d/glsl`, and a
1691
+ single object's exception is PER MESH: `Mesh.environment`
1692
+ (`setEnvironment(mesh, env)`, the `environment` prop on every mesh
1693
+ component). `null` removes the environment term from that mesh - the
1694
+ sealed-interior pair to masked lights, since light `layers` cannot mask
1695
+ image lighting; Unity's reflectionProbeUsage Off, Godot's
1696
+ disable_ambient_light in spirit - an `{ cube, intensity?, rotation? }`
1697
+ samples that cube instead of the scene's (an interior probe's cube, a
1698
+ baked room - Unity assigns probes by volume with an anchor override,
1699
+ Godot by probe box and cull_mask; here the app assigns explicitly),
1700
+ and absent follows the scene. The same uEnv* names written per ENTRY,
1701
+ which beat the target's shared values, so it costs nothing per frame;
1702
+ the change rebuilds the mesh's entries, a state switch (a doorway
1703
+ crossing), not a per-frame dial. Applied where the material reflects at
1704
+ all (`standard`, `phong({ reflectivity })`, a custom ENVIRONMENT
1705
+ composer - `Material.env`); on any other material it is a no-op, so an
1706
+ interior can be masked wholesale. A reflection probe drawing such a
1707
+ mesh never samples its own cube through it (the black placeholder
1708
+ stands in, the same rule the scene's own environment follows on probe
1709
+ faces). `reflectivity` 0..1 is the
1619
1710
  face-on weight, rising to 1 at grazing angles (Schlick), mixed in as
1620
1711
  `rgb = mix(rgb, reflection, weight)`: 1 is chrome, ~0.05 a glossy
1621
1712
  dielectric with rim reflections; Three's Phong `reflectivity` under its
@@ -1915,7 +2006,7 @@ resolve - at one of three tiers, top first:
1915
2006
  maps and encodes it). A custom LIGHT MODEL
1916
2007
  loops `sceneLight(i, position, normal)` to `uLightCount` instead of a
1917
2008
  shade function: light i's direction and its color already attenuated,
1918
- cone-faded and shadowed (zero when it cannot reach). The stock
2009
+ cone-faded, layer-masked and shadowed (zero when it cannot reach). The stock
1919
2010
  materials are built from this same set, so the tiers cannot drift.
1920
2011
  The demo `the-third-dimension.tsx` has tier 2 (the ground) and tier 3
1921
2012
  (the knot's rim term).
@@ -1939,7 +2030,7 @@ instance or every caster move warns about the skipped write.
1939
2030
  ### Lights
1940
2031
 
1941
2032
  Lights and `phong`: lights are graph NODES, like Three. `createDirectionalLight({
1942
- direction?, color?, intensity? })` / `<DirectionalLight>` is parallel light
2033
+ direction?, color?, intensity?, layers? })` / `<DirectionalLight>` is parallel light
1943
2034
  travelling along `direction` in the node's LOCAL space (default `[0, -1,
1944
2035
  0]`, a sun overhead; length ignored), so a parent Group's rotation turns it
1945
2036
  and position/scale do not matter - deliberately a direction, not Three's
@@ -1958,7 +2049,16 @@ decay? })` / `<PointLight>` is the omnidirectional version: position
1958
2049
  only, same falloff, no cone. `createHemisphereLight({ sky?, ground?, intensity?
1959
2050
  })` / `<HemisphereLight>` is the ambient term, a gradient by the WORLD
1960
2051
  normal's tilt (fixed to world up, the node's transform is ignored); one per
1961
- scene, the last attached wins. Placement goes through setTransform, the
2052
+ scene, the last attached wins. EVERY light (the hemisphere included)
2053
+ takes `layers?: number`, a filter bitmask defaulting to all bits set:
2054
+ the light lights, and a casting light shadows, only the meshes whose
2055
+ `layers` intersect it (Unity's cullingMask, Godot's light_cull_mask;
2056
+ Three has none) - the sealed-interiors pattern's light half (see Views
2057
+ and layers), one integer AND per light per fragment on two uniforms,
2058
+ so the branch is coherent per draw. A mesh a casting light's mask
2059
+ excludes also draws into none of its shadow maps. The environment term
2060
+ is not a light and not masked: its per-mesh knob is `Mesh.environment`
2061
+ (see Environment). Placement goes through setTransform, the
1962
2062
  light's own fields through `setLight(light, { ... })` (frame-rate-safe,
1963
2063
  like setMeshParams). At most `MAX_LIGHTS` (8, exported from the root and `/glsl`)
1964
2064
  lights per scene, directional, spot and point together (the hemisphere
@@ -1971,13 +2071,21 @@ node's world rotation (direction, negated so the shader reads the
1971
2071
  vector TOWARD the light) and world position, so a MOVING light costs no
1972
2072
  JS. The sync rewrites the rest whenever a light attaches, detaches or
1973
2073
  changes a field - `uHemiSky`/`uHemiGround` (vec3, intensity folded in),
1974
- `uLightCount` (int), `uLightType[N]` (LIGHT_DIRECTIONAL | LIGHT_SPOT |
2074
+ `uHemiMask` (int, the hemisphere's layers), `uLightCount` (int),
2075
+ `uLightType[N]` (LIGHT_DIRECTIONAL | LIGHT_SPOT |
1975
2076
  LIGHT_POINT), `uLightDir[N]`/`uLightPos[N]`/`uLightColor[N]` (intensity
1976
- folded into the color) and `uLightParams[N]` (cosInner, cosOuter,
1977
- distance, decay) - so a custom fragment composing `LIGHT_SLOTS` +
2077
+ folded into the color), `uLightParams[N]` (cosInner, cosOuter,
2078
+ distance, decay) and `uLightMask[N]` (each light's layers; masks cross
2079
+ as SIGNED 32-bit ints, all bits = -1, the shader only ANDs the bits) -
2080
+ so a custom fragment composing `LIGHT_SLOTS` +
1978
2081
  `LIGHT_LOOKUP` from `/glsl` reads the same list through `lightVector(i,
1979
2082
  worldPos, out l)` (returns the attenuation, 0 = skip the light; `phong`
1980
2083
  is the shape), and a light change costs one write however many meshes.
2084
+ The mesh side of the mask is `uniform int uLayers` (declared by
2085
+ LIGHT_LOOKUP, tested first in lightVector): a per-entry param the scene
2086
+ seeds from `mesh.layers` and follows through setLayers on every
2087
+ material whose source mentions it - never per frame, and never
2088
+ something a fragment declares itself.
1981
2089
  A custom fragment that declares only the old directional subset
1982
2090
  (`uLightCount`/`uLightDir`/`uLightColor`) still works - it just shades
1983
2091
  every light as directional, so keep such materials to directional-only
@@ -2483,6 +2591,79 @@ container is VERSION 8 (node table in the header, node-local vertices,
2483
2591
  skins, clips, packed morph targets); older bakes are rejected - re-bake
2484
2592
  with `srt tool 3d/model`.
2485
2593
 
2594
+ ## Splats
2595
+
2596
+ Captured 3D Gaussian splat scenes (phone scans, photogrammetry
2597
+ successors) as ordinary content, the model-loading split repeated:
2598
+
2599
+ - `@solidrt/3d/splat` (src/splat-data.ts) is the runtime-free data side,
2600
+ the model-data rule: `parseSplat(bytes, { format?, keepOrientation? })`
2601
+ reads a trainer's `.ply`, the de-facto `.splat` interchange or a
2602
+ GUNZIPPED `.spz` (Niantic, version 2 or 3; the entry stays
2603
+ decompression-free, so inflate first) and bakes it - the 3D covariance
2604
+ precomputed per splat (R S St Rt, the vertex-stage wall every
2605
+ bake-less viewer pays at each corner), records importance-sorted (size
2606
+ times opacity, so the first n are the scene at n), y-down captures
2607
+ (.ply, .splat) stood up to y-up (positions and covariances rotated
2608
+ together; `keepOrientation` opts out, `.spz` is y-up already), bounds
2609
+ measured. `encodeSplat`/`decodeSplat` round-trip the `.srts` container
2610
+ (the record and SH blocks, next bullet); decode is a header parse
2611
+ plus byte VIEWS, nothing per-splat. `srt tool 3d/splat <in> [-o
2612
+ out.srts] [--sh 0..3] [--keep-orientation]` is the parse and bake
2613
+ under bun (tools/splat.ts); an app bake script uses the same entry.
2614
+ - The `.srts` record (version 2) is 32 bytes = two rgba32ui texels:
2615
+ center float32x3 + sRGB color and opacity unorm8x4, then the
2616
+ covariance upper triangle as six float16 + a spare word
2617
+ (SPLAT_ATTRIBUTES as a byte layout, SPLAT_RECORD_TEXELS). `--sh 1..3`
2618
+ bakes that many spherical-harmonic bands beyond the base color from a
2619
+ trainer's `.ply` or an `.spz` (`.splat` has none), capped at the
2620
+ capture's: a second texel block of 2/3/6 texels per splat
2621
+ (SPLAT_SH_TEXELS; the halves packed pairwise, coefficient-major with
2622
+ rgb interleaved), 32/48/96 bytes per splat of texture, so the
2623
+ default is 0 and a bake opts in. The y-up flip negates the
2624
+ coefficients whose basis is odd in (y, z).
2625
+ - On the runtime, `loadSplat(path)` reads a `.srts` (fetch + decode,
2626
+ the loadModel shape) and `createSplatMesh(data, { count?, material?,
2627
+ label? })` / `<SplatMesh>` shows it: a RecordMesh in the INDEXED form
2628
+ (see "Instance order") - the records handed to the engine as the
2629
+ depth sort's key source AND uploaded once as the `rgba32ui` record
2630
+ texture the vertex stage fetches by id (`uSplatRecords`, plus
2631
+ `uSplatSh` when baked; bound per mesh, so a forked material keeps
2632
+ them), SPLAT_GROUP (16) splats per drawn instance of a merged-quad
2633
+ geometry, the header bounds as the cull and picking box, and the
2634
+ id stream sorted back to front core-side - the cloud composes as a
2635
+ mesh (depth-tested against opaque geometry, placed by the transparent
2636
+ entry sort, transformed by its node), a camera turn re-sorts and
2637
+ republishes 4 bytes per splat with zero per-frame JS, and a parked
2638
+ camera uploads nothing. Write-once: treat the SplatData as consumed
2639
+ and drop it; at 1M splats the cloud is resident as the engine's 32 MB
2640
+ key copy plus 32 MB of texture plus a 4 MB id buffer, and
2641
+ disposeInstances frees all three. `splatMaterialClass(shDegree)` is
2642
+ the stock class per degree (a shared instance each), and
2643
+ `splatVertexSource(shDegree)` / SPLAT_FRAGMENT / SPLAT_ID_ATTRIBUTES
2644
+ are exported for forks; `count` rounds up to whole groups.
2645
+ - The vertex stage projects the baked covariance through the Jacobian
2646
+ of `uViewProj * uModel` (no focal/view uniforms; any node transform
2647
+ shapes the footprint with the same matrix that moves the center),
2648
+ reads the target size from the standard `uViewport`, and evaluates
2649
+ the SH bands per corner from the view direction (`uCamPos` through
2650
+ the transposed model rotation - exact under rotation and uniform
2651
+ scale). The data textures' row width is the device's (up to 4096),
2652
+ compiled into the source.
2653
+ - Why this shape: on the Pixel 7 at 1M splats the one-quad-per-instance
2654
+ form spent two thirds of its vertex side on the instancing frontend;
2655
+ the indexed form takes the full-res frame from 45 to 31 ms and the
2656
+ vertex side from 24 to 8 ms, the per-vertex fetch and random id order
2657
+ cost under a millisecond, and SH1-3 are near free (the bands cost
2658
+ memory, not time). What remains at 1M is fill (overdraw). The table
2659
+ is in okf/done/gaussian-splats.md.
2660
+ - Two capture caveats worth knowing at the app level: captures are
2661
+ TRAINED against sRGB blending, so show them in a display-space scene
2662
+ (`blendSpace: "display"`) - a linear scene lays a milky veil over
2663
+ stacked translucents - and a capture is only clean near the camera
2664
+ path it was taken from (elsewhere it is floaters), so keep the camera
2665
+ near that path.
2666
+
2486
2667
  ## Traps
2487
2668
 
2488
2669
  ### Models and skinning
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/3d",
3
- "version": "0.0.62",
3
+ "version": "0.0.64",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -9,6 +9,7 @@
9
9
  "exports": {
10
10
  ".": "./src/index.ts",
11
11
  "./model": "./src/model-data.ts",
12
+ "./splat": "./src/splat-data.ts",
12
13
  "./math": "./src/math.ts",
13
14
  "./glsl": "./src/glsl.ts"
14
15
  },
@@ -20,6 +21,6 @@
20
21
  ],
21
22
  "peerDependencies": {
22
23
  "@solidjs/signals": "2.0.0-rc.9",
23
- "@solidrt/core": "0.0.62"
24
+ "@solidrt/core": "0.0.64"
24
25
  }
25
26
  }
package/src/camera.ts CHANGED
@@ -135,8 +135,11 @@ export function ensureCamera(cam: Camera, width: number, height: number): void {
135
135
  // billboard needs no reconstruction from uViewProj. The inverse rides too:
136
136
  // uInvViewProj carries a clip position back to world, which is how the
137
137
  // background slot (and any shader declaring it) gets a world-space ray
138
- // per pixel without knowing the projection.
139
- export function cameraParams(cam: Camera): ShaderParams {
138
+ // per pixel without knowing the projection. The target's pixel size rides
139
+ // as uViewport (the size the camera's aspect came from), so a pixel-space
140
+ // footprint (the splat material) needs no app write and is per target -
141
+ // each view feeds its own.
142
+ export function cameraParams(cam: Camera, width: number, height: number): ShaderParams {
140
143
  let v = cam.view
141
144
  return {
142
145
  uViewProj: cam.viewProj,
@@ -144,5 +147,6 @@ export function cameraParams(cam: Camera): ShaderParams {
144
147
  uCamPos: cam.eye,
145
148
  uCamRight: [v[0], v[4], v[8]],
146
149
  uCamUp: [v[1], v[5], v[9]],
150
+ uViewport: [width, height],
147
151
  }
148
152
  }
@@ -13,6 +13,9 @@ export type DirectionalLightProps = TransformProps & {
13
13
  direction?: Vec3
14
14
  color?: Vec3
15
15
  intensity?: number
16
+ /** Layer filter bitmask, default all bits set: the light lights and
17
+ * shadows only the meshes whose `layers` intersect it. */
18
+ layers?: number
16
19
  /** Render a shadow map from this light (any directional light may;
17
20
  * each is a pass). Its shadow camera sits at the light's WORLD
18
21
  * position, so give a casting light a `position` above the scene. */
@@ -32,6 +35,7 @@ export let DirectionalLight: VoidComponent<DirectionalLightProps> = props => {
32
35
  direction: props.direction,
33
36
  color: props.color,
34
37
  intensity: props.intensity,
38
+ layers: props.layers,
35
39
  castShadow: props.castShadow,
36
40
  shadow: props.shadow,
37
41
  }),
@@ -39,8 +43,8 @@ export let DirectionalLight: VoidComponent<DirectionalLightProps> = props => {
39
43
  add(ctx.parent, light)
40
44
  syncNode(light, props)
41
45
  createEffect(
42
- () => [props.direction, props.color, props.intensity, props.castShadow, props.shadow] as const,
43
- ([direction, color, intensity, castShadow, shadow]) => setLight(light, { direction, color, intensity, castShadow, shadow }),
46
+ () => [props.direction, props.color, props.intensity, props.layers, props.castShadow, props.shadow] as const,
47
+ ([direction, color, intensity, layers, castShadow, shadow]) => setLight(light, { direction, color, intensity, layers, castShadow, shadow }),
44
48
  )
45
49
  untrack(() => props.ref)?.(light)
46
50
  onCleanup(() => destroy(light))
@@ -6,17 +6,25 @@ import { createHemisphereLight, setLight } from "../light.ts"
6
6
  import type { HemisphereLight as HemisphereLightNode } from "../light.ts"
7
7
  import type { Vec3 } from "../math.ts"
8
8
 
9
- export type HemisphereLightProps = { sky?: Vec3; ground?: Vec3; intensity?: number; ref?: (light: HemisphereLightNode) => void }
9
+ export type HemisphereLightProps = {
10
+ sky?: Vec3
11
+ ground?: Vec3
12
+ intensity?: number
13
+ /** Layer filter bitmask, default all bits set: a mesh outside it gets
14
+ * no hemisphere term. */
15
+ layers?: number
16
+ ref?: (light: HemisphereLightNode) => void
17
+ }
10
18
 
11
19
  /** The scene's ambient term as a node (createHemisphereLight); one per
12
20
  * scene, the last mounted wins. */
13
21
  export let HemisphereLight: VoidComponent<HemisphereLightProps> = props => {
14
22
  let ctx = useContext(SceneContext)
15
- let light = untrack(() => createHemisphereLight({ sky: props.sky, ground: props.ground, intensity: props.intensity }))
23
+ let light = untrack(() => createHemisphereLight({ sky: props.sky, ground: props.ground, intensity: props.intensity, layers: props.layers }))
16
24
  add(ctx.parent, light)
17
25
  createEffect(
18
- () => [props.sky, props.ground, props.intensity] as const,
19
- ([sky, ground, intensity]) => setLight(light, { sky, ground, intensity }),
26
+ () => [props.sky, props.ground, props.intensity, props.layers] as const,
27
+ ([sky, ground, intensity, layers]) => setLight(light, { sky, ground, intensity, layers }),
20
28
  )
21
29
  untrack(() => props.ref)?.(light)
22
30
  onCleanup(() => destroy(light))
@@ -33,6 +33,8 @@ export { InstancedLod, Lod } from "./lod.tsx"
33
33
  export type { InstancedLodProps, LodProps } from "./lod.tsx"
34
34
  export { RecordMesh } from "./record-mesh.tsx"
35
35
  export type { RecordMeshProps } from "./record-mesh.tsx"
36
+ export { SplatMesh } from "./splat-mesh.tsx"
37
+ export type { SplatMeshProps } from "./splat-mesh.tsx"
36
38
  export { PerspectiveCamera } from "./perspective-camera.tsx"
37
39
  export type { PerspectiveCameraProps } from "./perspective-camera.tsx"
38
40
  export { OrbitCamera } from "./orbit-camera.tsx"
@@ -8,7 +8,7 @@ import type { PopulatedMeshProps } from "./mesh.tsx"
8
8
  import { add, destroy, setMorphWeights } from "../node.ts"
9
9
  import type { MorphWeights, SceneNode } from "../node.ts"
10
10
  import { addInstance, createInstancedMesh, disposeInstances, setCastShadow, setGeometry, setInstanceStyle } from "../mesh.ts"
11
- import type { InstancedMesh as InstancedMeshNode, InstanceNode } from "../mesh.ts"
11
+ import type { InstancedMesh as InstancedMeshNode, InstanceNode, InstanceOrderOptions } from "../mesh.ts"
12
12
  import type { Geometry } from "../geometry.ts"
13
13
  import type { Material } from "../material.ts"
14
14
 
@@ -30,6 +30,10 @@ export type InstancedMeshProps = Omit<PopulatedMeshProps, "onPointerEnter" | "on
30
30
  * transparent sort. Optional here - instances pick by themselves, and
31
31
  * without it the mesh culls by the union of their boxes. */
32
32
  bounds?: ArrayLike<number>
33
+ /** Draw the records in key order, decided core-side (fixed at
34
+ * creation; see InstanceOrderOptions): back-to-front for a transparent
35
+ * population, an explicit sort field for a stepped one. */
36
+ instanceOrder?: InstanceOrderOptions
33
37
  /** Debug label for the record buffers. */
34
38
  label?: string
35
39
  /** The node the instance records are relative to (default the mesh;
@@ -63,7 +67,7 @@ export let InstancedMeshContext = createContext<InstancedMeshNode | null>(null)
63
67
  export let InstancedMesh: ParentComponent<InstancedMeshProps> = props => {
64
68
  let ctx = useContext(SceneContext)
65
69
  let mesh = untrack(() =>
66
- createInstancedMesh(props.geometry, props.material, { capacity: props.capacity, bounds: props.bounds, anchor: props.anchor, label: props.label }),
70
+ createInstancedMesh(props.geometry, props.material, { capacity: props.capacity, bounds: props.bounds, anchor: props.anchor, instanceOrder: props.instanceOrder, label: props.label }),
67
71
  )
68
72
  add(ctx.parent, mesh)
69
73
  createEffect(
@@ -5,8 +5,9 @@ import { syncNode } from "./node-props.ts"
5
5
  import type { TransformProps, PointerEventProps } from "./node-props.ts"
6
6
  import { add, destroy, setMorphWeights } from "../node.ts"
7
7
  import type { MorphWeights } from "../node.ts"
8
- import { createMesh, setCastShadow, setCulling, setGeometry, setLayers, setMaterial, setMeshParams, setRenderOrder } from "../mesh.ts"
8
+ import { createMesh, setCastShadow, setCulling, setEnvironment, setGeometry, setLayers, setMaterial, setMeshParams, setRenderOrder } from "../mesh.ts"
9
9
  import type { Mesh as MeshNode } from "../mesh.ts"
10
+ import type { EnvironmentOptions } from "../environment.ts"
10
11
  import type { ShaderParams } from "@solidrt/core/gpu"
11
12
  import type { Geometry } from "../geometry.ts"
12
13
  import type { Material } from "../material.ts"
@@ -29,6 +30,12 @@ export type MeshProps = TransformProps & PointerEventProps & {
29
30
  * draws the mesh when its mask intersects this. Not inherited from
30
31
  * ancestor Groups. */
31
32
  layers?: number
33
+ /** This mesh's environment instead of the scene's (setEnvironment as a
34
+ * prop): `null` removes the environment term - the sealed-interior
35
+ * pair to masked lights - a `{ cube, intensity?, rotation? }` samples
36
+ * that cube, absent follows the scene. Rebuilds the mesh's entries on
37
+ * change: a state switch, not a per-frame dial. */
38
+ environment?: EnvironmentOptions | null
32
39
  /** Frustum culling switch (setCulling as a prop; default true). */
33
40
  frustumCulled?: boolean
34
41
  /** World units the culled box grows by (setCulling as a prop; default 0). */
@@ -71,6 +78,10 @@ export function syncMesh(mesh: MeshNode, props: Omit<MeshProps, "geometry" | "ma
71
78
  () => props.layers,
72
79
  l => setLayers(mesh, l ?? 1),
73
80
  )
81
+ createEffect(
82
+ () => props.environment,
83
+ e => setEnvironment(mesh, e),
84
+ )
74
85
  createEffect(
75
86
  () => [props.frustumCulled, props.cullMargin] as const,
76
87
  ([culled, margin]) => setCulling(mesh, { frustumCulled: culled !== false, cullMargin: margin ?? 0 }),
@@ -11,6 +11,9 @@ import type { Vec3 } from "../math.ts"
11
11
  export type PointLightProps = TransformProps & {
12
12
  color?: Vec3
13
13
  intensity?: number
14
+ /** Layer filter bitmask, default all bits set: the light lights and
15
+ * shadows only the meshes whose `layers` intersect it. */
16
+ layers?: number
14
17
  /** Falloff cutoff in world units (0 = no cutoff). */
15
18
  distance?: number
16
19
  /** Falloff exponent; default 2 (inverse square). */
@@ -34,6 +37,7 @@ export let PointLight: VoidComponent<PointLightProps> = props => {
34
37
  createPointLight({
35
38
  color: props.color,
36
39
  intensity: props.intensity,
40
+ layers: props.layers,
37
41
  distance: props.distance,
38
42
  decay: props.decay,
39
43
  castShadow: props.castShadow,
@@ -43,8 +47,8 @@ export let PointLight: VoidComponent<PointLightProps> = props => {
43
47
  add(ctx.parent, light)
44
48
  syncNode(light, props)
45
49
  createEffect(
46
- () => [props.color, props.intensity, props.distance, props.decay, props.castShadow, props.shadow] as const,
47
- ([color, intensity, distance, decay, castShadow, shadow]) => setLight(light, { color, intensity, distance, decay, castShadow, shadow }),
50
+ () => [props.color, props.intensity, props.layers, props.distance, props.decay, props.castShadow, props.shadow] as const,
51
+ ([color, intensity, layers, distance, decay, castShadow, shadow]) => setLight(light, { color, intensity, layers, distance, decay, castShadow, shadow }),
48
52
  )
49
53
  untrack(() => props.ref)?.(light)
50
54
  onCleanup(() => destroy(light))
@@ -5,7 +5,7 @@ import { syncMesh } from "./mesh.tsx"
5
5
  import type { PopulatedMeshProps } from "./mesh.tsx"
6
6
  import { add, destroy } from "../node.ts"
7
7
  import { createRecordMesh, disposeInstances, setCastShadow, setGeometry, setRecordCount, setRecords } from "../mesh.ts"
8
- import type { RecordMesh as RecordMeshNode } from "../mesh.ts"
8
+ import type { InstanceOrderOptions, RecordMesh as RecordMeshNode } from "../mesh.ts"
9
9
  import type { Geometry } from "../geometry.ts"
10
10
  import type { Material } from "../material.ts"
11
11
 
@@ -25,6 +25,14 @@ export type RecordMeshProps = PopulatedMeshProps & {
25
25
  * creation. Without them the mesh has no picking leaf, so pointer events
26
26
  * never target it. */
27
27
  bounds?: ArrayLike<number>
28
+ /** Draw the records in key order, decided core-side (fixed at
29
+ * creation; see InstanceOrderOptions): back-to-front for a transparent
30
+ * population, an explicit sort field for a JS-stepped one. */
31
+ instanceOrder?: InstanceOrderOptions
32
+ /** Transfer the records to the engine at creation (fixed; see
33
+ * RecordMeshOptions.transfer): the write-once form - no JS mirror, so
34
+ * a later `records` value throws instead of rewriting. */
35
+ transfer?: boolean
28
36
  /** Draw into the scene's shadow map (setCastShadow as a prop); default
29
37
  * false. Needs a `castShadow` light AND a material class declaring
30
38
  * `shadowVertex` (the depth pass with the instance placement) - the
@@ -39,7 +47,11 @@ export type RecordMeshProps = PopulatedMeshProps & {
39
47
  export let RecordMesh: VoidComponent<RecordMeshProps> = props => {
40
48
  let ctx = useContext(SceneContext)
41
49
  let mesh = untrack(() =>
42
- createRecordMesh(props.geometry, props.material, props.records, props.count, { bounds: props.bounds }),
50
+ createRecordMesh(props.geometry, props.material, props.records, props.count, {
51
+ bounds: props.bounds,
52
+ instanceOrder: props.instanceOrder,
53
+ transfer: props.transfer,
54
+ }),
43
55
  )
44
56
  add(ctx.parent, mesh)
45
57
  createEffect(
@@ -3,7 +3,7 @@ import type { Element, ParentComponent, PointerFeed, TextureId } from "@solidrt/
3
3
  import { SceneContext } from "./context.tsx"
4
4
  import { createScene } from "../scene.ts"
5
5
  import { feedPointer } from "../scene-pointer.ts"
6
- import type { BloomOptions, EnvironmentOptions, FogOptions, ResolveInput, Scene as SceneHandle, SkyboxOptions, ToneMapping } from "../scene.ts"
6
+ import type { BlendSpace, BloomOptions, EnvironmentOptions, FogOptions, ResolveInput, Scene as SceneHandle, SkyboxOptions, ToneMapping } from "../scene.ts"
7
7
  import type { ScenePointerEvent, SceneTapEvent, SceneWheelEvent } from "../node.ts"
8
8
  import type { CameraUpdate } from "../camera.ts"
9
9
 
@@ -75,6 +75,12 @@ export type SceneProps = ScenePointerProps & {
75
75
  toneMapping?: ToneMapping
76
76
  /** Output exposure (scene.setExposure), default 1. Reactive. */
77
77
  exposure?: number
78
+ /** The buffer's blend space (SceneOptions.blendSpace), default
79
+ * "linear"; fixed at creation. "display" blends sRGB-encoded values
80
+ * in an rgba8 buffer that is itself the displayed image - the mode a
81
+ * splat capture is faithful in - and rejects resolve, bloom,
82
+ * toneMapping and exposure. */
83
+ blendSpace?: BlendSpace
78
84
  /** The scene target's layer mask (scene.setLayers as a prop; default 1):
79
85
  * the scene draws the meshes whose `layers` intersect it. Reactive. */
80
86
  layers?: number
@@ -175,6 +181,7 @@ export let Scene: ParentComponent<SceneProps> = props => {
175
181
  fog: props.fog,
176
182
  toneMapping: props.toneMapping,
177
183
  exposure: props.exposure,
184
+ blendSpace: props.blendSpace,
178
185
  resolve: props.resolve,
179
186
  bloom: props.bloom,
180
187
  layers: props.layers,