@solidrt/3d 0.0.63 → 0.0.65

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,27 +545,30 @@ 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
- `tests/model-data.test.ts` imports it under `bun test`, so a runtime
546
- import creeping into any of them fails there; keep them that way.
547
- The rigs under `checks/`
548
- (`geometry-check`, `sweep-check`, `pick-check`, `dispatch-check`,
549
- `gltf-check`) run on
550
- flux from the repo root: `bunx srt bundle -f --stdout
551
- packages/3d/checks/<name>.ts | target/release/flux -`. Run the ones
552
- touching what you changed. `raycast-check.tsx` and
553
- `collision-check.tsx` are the exceptions: they assert the documented
554
- picking contract (triangle accuracy, the box tier, pick/raycast
555
- parity, layer masks, the `{ meshes }` filter) and collision contract
556
- (exact sweep times, the surface rule, the slide filter, layers and
557
- meshes on overlap/sweep, moveAndSlide's landing) against a real scene,
558
- so they run on the playback client instead:
559
- `bunx srt render packages/3d/checks/<name>.tsx --project --duration 3
560
- --size 128x128`. Run them whenever a doc edit touches picking or
561
- collision claims - two copies of this contract have drifted before.
553
+ `tests/model-data.test.ts` imports it under `srt test`, on the bare flux
554
+ binary, so a gui or `srt:` import creeping into any of them fails there
555
+ (a headless `flux:` import would not); keep them that way.
556
+ `src/splat-data.ts` is its own published entry the same way
557
+ (`@solidrt/3d/splat`, guarded by `tests/splat-data.test.ts`).
558
+ The tests under `tests/` run with `bun run srt test packages/3d` from the
559
+ repo root, or one file, or `--filter <text>` for the tests whose name
560
+ contains it: the pure modules on the bare flux binary, and the files
561
+ that need a GPU (`.test.tsx`, importing `@solidrt/test`) on the
562
+ headless dev client. Run them after a change. `raycast.test.tsx` and
563
+ `collision.test.tsx` assert the documented picking contract (triangle
564
+ accuracy, the box tier, pick/raycast parity, layer masks, the
565
+ `{ meshes }` filter) and collision contract (exact sweep times, the
566
+ surface rule, the slide filter, layers and meshes on overlap/sweep,
567
+ moveAndSlide's landing) against a real scene; run them whenever a doc
568
+ edit touches picking or collision claims - two copies of this contract
569
+ have drifted before. `fog.test.tsx` reads the fog example's pixels
570
+ through its debug commands, and `camera-components.test.tsx` drives
571
+ `<OrbitCamera>` and `<FirstPersonCamera>` with a real drag and key.
562
572
 
563
573
  ## Components
564
574
 
@@ -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)` |
@@ -799,7 +810,7 @@ driving - the predicate every camera control shares) are reactive
799
810
  (HUD-safe); the pose is plain state via `pose()`/`set()` (also the
800
811
  debug-command shape). It drives position and target only; fov/near/far
801
812
  stay on scene.setCamera (or the Scene `camera` prop). The auto-orbit
802
- pauses while a gesture is open. checks/orbit-check.ts pins every stage
813
+ pauses while a gesture is open. tests/orbit.test.ts pins every stage
803
814
  headless.
804
815
 
805
816
  In a component tree, skip the wiring: `<OrbitCamera input={input}
@@ -857,7 +868,7 @@ the shot's recording target, so an `<OrbitCamera>` or
857
868
  it, the container runs the blend on frames while one is in flight. A
858
869
  shot's picks and raycasts (`anchor`, `occlusion`) go through the
859
870
  owner's actual camera, which is the shot's own only while it is live
860
- and at rest. Pinned in checks/orbit-check.ts; probes/shots-3d-probe.tsx
871
+ and at rest. Pinned in tests/orbit.test.ts; probes/shots-3d-probe.tsx
861
872
  is the live guard.
862
873
 
863
874
  ### First-person camera
@@ -920,8 +931,8 @@ swap with it), `moveSpeed`/`lookSpeed` follow their props, and a pitch
920
931
  clamp change re-clamps at once. Like the orbit control it drives
921
932
  position and yaw/pitch only: fov/near/far stay on scene.setCamera (or
922
933
  the Scene `camera` prop), so a walker in a scene bigger than the default
923
- `far` of 100 sets one there. checks/orbit-check.ts and
924
- checks/first-person-check.ts pin both controls headless (they import
934
+ `far` of 100 sets one there. tests/orbit.test.ts and
935
+ tests/first-person.test.ts pin both controls headless (they import
925
936
  `@solidrt/core/input` only).
926
937
 
927
938
  ### Overlay projection
@@ -1073,7 +1084,9 @@ repeats within 300 ms and 20 px on the same target (a double tap is
1073
1084
  tap. Wheel walks like a move with `deltaX/deltaY` (a node stopping it
1074
1085
  keeps the zoom out). Every event carries `native`, the leaf's element
1075
1086
  event, for core's recognizers, and the element fields (pointerId,
1076
- pointerType, button, modifiers) plus `x`/`y` in scene pixels
1087
+ pointerType, button, modifiers, `timeStamp`, and `predicted`: a move to
1088
+ draw with, not to measure with - core's AGENTS.md has both) plus `x`/`y`
1089
+ in scene pixels
1077
1090
  (screenRay's input - a drag plane is one intersection away). Root
1078
1091
  listeners all run, in registration order (the root is the last stop,
1079
1092
  nothing is left to claim). Wiring: the built-in `<Scene>` leaf carries
@@ -1087,7 +1100,7 @@ natural size, under any ancestor transforms or design-size fits (the hit
1087
1100
  test undoes them; localX/localY arrive in the leaf's layout frame). A
1088
1101
  leaf laid out at a different size (the supersampling pattern) uses
1089
1102
  `scene.handlersFor(() => ({ width, height }))` with its layout size.
1090
- checks/dispatch-check.ts pins the walk, claiming, capture, hover, wheel
1103
+ tests/dispatch.test.ts pins the walk, claiming, capture, hover, wheel
1091
1104
  and tap rules headless; examples/pick.tsx is the live guard.
1092
1105
 
1093
1106
  ### Geometry generators
@@ -1333,7 +1346,10 @@ with uViewProj - the specular/fresnel view vector is
1333
1346
  uViewProj rows, that carries the clip flip), `uniform mat4
1334
1347
  uInvViewProj` (the camera's inverse view-projection, shared likewise -
1335
1348
  a clip position back to world, the world-space ray through a pixel
1336
- without knowing the projection) and `uniform mat4 uNormal` (the world
1349
+ without knowing the projection), `uniform vec2 uViewport` (the target's
1350
+ pixel size, shared likewise and per target - each view feeds its own -
1351
+ so a pixel-space footprint like the splat material's needs no app
1352
+ write) and `uniform mat4 uNormal` (the world
1337
1353
  inverse-transpose, written beside uModel for this material's meshes;
1338
1354
  take `mat3(uNormal)` - correct under non-uniform scale, where
1339
1355
  mat3(uModel) bends normals off the surface). Attributes come from the
@@ -1491,7 +1507,8 @@ or in five figures, belong to the function face.
1491
1507
  #### createRecordMesh
1492
1508
 
1493
1509
  `createRecordMesh(geometry, material, records, count?, { bounds?,
1494
- label? })` is the raw form: `records` is the per-instance data laid out
1510
+ instanceOrder?, label? })` is the raw form: `records` is the
1511
+ per-instance data laid out
1495
1512
  in the material's first instance layout - a Float32Array over an
1496
1513
  all-float layout, otherwise bytes built with `vertexView(layout, buffer)`
1497
1514
  and `attributeAccess` like a geometry stream (a length that is not whole
@@ -1523,6 +1540,63 @@ unhide, renderOrder/params/geometry/material swaps apply, and
1523
1540
  explicit free, geometry-buffer rule. `examples/fleet.tsx` (instances,
1524
1541
  components) and `examples/instanced.tsx` (records) are the live proofs.
1525
1542
 
1543
+ #### Instance order
1544
+
1545
+ `instanceOrder` on either create (and the components) draws a
1546
+ population's records in key order, decided core-side; slots as you write
1547
+ them never move. `{ position: "iCenter", retain: true, descending: true
1548
+ }` names a float32x3/x4 attribute holding a local-space position: records
1549
+ draw by their depth along the scene camera's view direction, which the
1550
+ SCENE feeds the core whenever the camera or the mesh turns meaningfully
1551
+ (mapped through the mesh's rotation; an epsilon gate meters the
1552
+ re-sorts) - back-to-front transparency over 100k+ records with zero
1553
+ per-frame JS, and with `retain` a camera turn re-sorts and republishes
1554
+ core-side while a parked camera uploads nothing. `{ field: "age" }`
1555
+ names a float32 attribute and draws by its value, re-ordered when the
1556
+ records publish. The costs, by design: an ordered mesh publishes the
1557
+ LIVE record set whole on any write (a byte range has no stable position
1558
+ under a permutation; `count` defines the sorted population), and one
1559
+ buffer holds ONE order - the scene target's entry owns it, views and
1560
+ shadow passes draw the same gathered buffers, and a mesh layered out of
1561
+ the scene target keeps its last order. Where the key sits decides
1562
+ nothing else: raw instancing stays buffer-order everywhere (Three,
1563
+ Unity, Godot), so this knob has no engine counterpart to compare
1564
+ against; the core primitive is okf/done/gpu-instance-order.md.
1565
+
1566
+ `transfer: true` (createRecordMesh only) is the write-once form for a
1567
+ large population: the records go to the ENGINE at creation and the mesh
1568
+ keeps no JS mirror, so the record bytes are resident once CPU-side
1569
+ (engine mirror) plus the GPU buffer, instead of twice more (JS mirror,
1570
+ plus the creation bytes the app would keep). The contract is loud:
1571
+ setRecords, updateRecords, instanceAttribute and growth throw naming
1572
+ the transfer; setRecordCount still works (with an instanceOrder the
1573
+ engine re-sorts and republishes the first n records of ITS copy),
1574
+ and so do setVisible, transforms, re-attach and disposeInstances (which
1575
+ frees the engine copy with the buffer). Takes a single-stream material;
1576
+ with an instanceOrder the engine's retained copy is implied. The engine
1577
+ mirror is BUFFER-lifetime, so a detached-and-re-attached ordered entry
1578
+ (setLayers re-admission included) seeds its order from it with no
1579
+ publish from JS - true for every retained ordered mesh since this
1580
+ landed, transfer or not.
1581
+
1582
+ The INDEXED form, `instanceOrder: { position: "iCenter", records:
1583
+ LAYOUT, descending: true }` (createRecordMesh only), is for a population
1584
+ whose records live in a DATA TEXTURE the vertex stage fetches by record
1585
+ index - a splat cloud, any large write-once set whose per-instance
1586
+ frontend cost matters. `records` is the layout of the record bytes you
1587
+ pass (the key attribute lives there), and the material's ONE instance
1588
+ buffer is the id stream: uint32/uint32x4 attributes the engine fills
1589
+ with the sorted record indices, K ids per instance record, so one drawn
1590
+ instance covers K records - repeat the geometry K times and pick the
1591
+ record with `gl_VertexID` (quad `gl_VertexID / 4`), fetch it by id
1592
+ (`texelFetch` on an `rgba32ui` texture the app built from the same
1593
+ bytes and bound per mesh), and cull the id 0xFFFFFFFF that pads the
1594
+ last group. Transfer is implied (the records are the engine's sort
1595
+ key source), a re-sort uploads 4 bytes per record instead of the
1596
+ record, and `count` stays in records while the entry draws
1597
+ ceil(count / K) instances (`drawCount`). `MeshInstances.textures` holds
1598
+ data textures the population owns, freed by disposeInstances.
1599
+
1526
1600
  ### Background
1527
1601
 
1528
1602
  Background: `scene.setBackground(source | null)`, the `background` option
@@ -1590,7 +1664,7 @@ specular, `envIrradiance` - the fully rough sample along the normal, as
1590
1664
  Three's getIBLIrradiance and Godot read it - added to the hemisphere
1591
1665
  for the diffuse) and every `phong({ reflectivity })` material mirrors,
1592
1666
  typically the skybox's own cube turned with it. The cube to use is a
1593
- BAKED one: `bunx srt tool 3d/environment sky.hdr -o assets/sky.srte`
1667
+ BAKED one: `bun run srt tool 3d/environment sky.hdr -o assets/sky.srte`
1594
1668
  turns an equirectangular Radiance .hdr (Poly Haven's are CC0) into the
1595
1669
  six faces plus the GGX-prefiltered mip chain in linear float, and
1596
1670
  `await loadEnvironment("assets/sky.srte")` uploads it as an explicit
@@ -1614,8 +1688,27 @@ sky-lit reflections: ONE `uEnv` samplerCube bound on every target the
1614
1688
  scene draws into (a 1x1 black placeholder while unset) and one
1615
1689
  shared-params write (`uEnvIntensity`, `uEnvRotation`, `uEnvOn`), however
1616
1690
  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
1691
+ `envMap` sits on the MATERIAL, but materials are shared here) - a
1692
+ custom material composes ENVIRONMENT from `@solidrt/3d/glsl`, and a
1693
+ single object's exception is PER MESH: `Mesh.environment`
1694
+ (`setEnvironment(mesh, env)`, the `environment` prop on every mesh
1695
+ component). `null` removes the environment term from that mesh - the
1696
+ sealed-interior pair to masked lights, since light `layers` cannot mask
1697
+ image lighting; Unity's reflectionProbeUsage Off, Godot's
1698
+ disable_ambient_light in spirit - an `{ cube, intensity?, rotation? }`
1699
+ samples that cube instead of the scene's (an interior probe's cube, a
1700
+ baked room - Unity assigns probes by volume with an anchor override,
1701
+ Godot by probe box and cull_mask; here the app assigns explicitly),
1702
+ and absent follows the scene. The same uEnv* names written per ENTRY,
1703
+ which beat the target's shared values, so it costs nothing per frame;
1704
+ the change rebuilds the mesh's entries, a state switch (a doorway
1705
+ crossing), not a per-frame dial. Applied where the material reflects at
1706
+ all (`standard`, `phong({ reflectivity })`, a custom ENVIRONMENT
1707
+ composer - `Material.env`); on any other material it is a no-op, so an
1708
+ interior can be masked wholesale. A reflection probe drawing such a
1709
+ mesh never samples its own cube through it (the black placeholder
1710
+ stands in, the same rule the scene's own environment follows on probe
1711
+ faces). `reflectivity` 0..1 is the
1619
1712
  face-on weight, rising to 1 at grazing angles (Schlick), mixed in as
1620
1713
  `rgb = mix(rgb, reflection, weight)`: 1 is chrome, ~0.05 a glossy
1621
1714
  dielectric with rim reflections; Three's Phong `reflectivity` under its
@@ -1915,7 +2008,7 @@ resolve - at one of three tiers, top first:
1915
2008
  maps and encodes it). A custom LIGHT MODEL
1916
2009
  loops `sceneLight(i, position, normal)` to `uLightCount` instead of a
1917
2010
  shade function: light i's direction and its color already attenuated,
1918
- cone-faded and shadowed (zero when it cannot reach). The stock
2011
+ cone-faded, layer-masked and shadowed (zero when it cannot reach). The stock
1919
2012
  materials are built from this same set, so the tiers cannot drift.
1920
2013
  The demo `the-third-dimension.tsx` has tier 2 (the ground) and tier 3
1921
2014
  (the knot's rim term).
@@ -1939,7 +2032,7 @@ instance or every caster move warns about the skipped write.
1939
2032
  ### Lights
1940
2033
 
1941
2034
  Lights and `phong`: lights are graph NODES, like Three. `createDirectionalLight({
1942
- direction?, color?, intensity? })` / `<DirectionalLight>` is parallel light
2035
+ direction?, color?, intensity?, layers? })` / `<DirectionalLight>` is parallel light
1943
2036
  travelling along `direction` in the node's LOCAL space (default `[0, -1,
1944
2037
  0]`, a sun overhead; length ignored), so a parent Group's rotation turns it
1945
2038
  and position/scale do not matter - deliberately a direction, not Three's
@@ -1958,7 +2051,16 @@ decay? })` / `<PointLight>` is the omnidirectional version: position
1958
2051
  only, same falloff, no cone. `createHemisphereLight({ sky?, ground?, intensity?
1959
2052
  })` / `<HemisphereLight>` is the ambient term, a gradient by the WORLD
1960
2053
  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
2054
+ scene, the last attached wins. EVERY light (the hemisphere included)
2055
+ takes `layers?: number`, a filter bitmask defaulting to all bits set:
2056
+ the light lights, and a casting light shadows, only the meshes whose
2057
+ `layers` intersect it (Unity's cullingMask, Godot's light_cull_mask;
2058
+ Three has none) - the sealed-interiors pattern's light half (see Views
2059
+ and layers), one integer AND per light per fragment on two uniforms,
2060
+ so the branch is coherent per draw. A mesh a casting light's mask
2061
+ excludes also draws into none of its shadow maps. The environment term
2062
+ is not a light and not masked: its per-mesh knob is `Mesh.environment`
2063
+ (see Environment). Placement goes through setTransform, the
1962
2064
  light's own fields through `setLight(light, { ... })` (frame-rate-safe,
1963
2065
  like setMeshParams). At most `MAX_LIGHTS` (8, exported from the root and `/glsl`)
1964
2066
  lights per scene, directional, spot and point together (the hemisphere
@@ -1971,13 +2073,21 @@ node's world rotation (direction, negated so the shader reads the
1971
2073
  vector TOWARD the light) and world position, so a MOVING light costs no
1972
2074
  JS. The sync rewrites the rest whenever a light attaches, detaches or
1973
2075
  changes a field - `uHemiSky`/`uHemiGround` (vec3, intensity folded in),
1974
- `uLightCount` (int), `uLightType[N]` (LIGHT_DIRECTIONAL | LIGHT_SPOT |
2076
+ `uHemiMask` (int, the hemisphere's layers), `uLightCount` (int),
2077
+ `uLightType[N]` (LIGHT_DIRECTIONAL | LIGHT_SPOT |
1975
2078
  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` +
2079
+ folded into the color), `uLightParams[N]` (cosInner, cosOuter,
2080
+ distance, decay) and `uLightMask[N]` (each light's layers; masks cross
2081
+ as SIGNED 32-bit ints, all bits = -1, the shader only ANDs the bits) -
2082
+ so a custom fragment composing `LIGHT_SLOTS` +
1978
2083
  `LIGHT_LOOKUP` from `/glsl` reads the same list through `lightVector(i,
1979
2084
  worldPos, out l)` (returns the attenuation, 0 = skip the light; `phong`
1980
2085
  is the shape), and a light change costs one write however many meshes.
2086
+ The mesh side of the mask is `uniform int uLayers` (declared by
2087
+ LIGHT_LOOKUP, tested first in lightVector): a per-entry param the scene
2088
+ seeds from `mesh.layers` and follows through setLayers on every
2089
+ material whose source mentions it - never per frame, and never
2090
+ something a fragment declares itself.
1981
2091
  A custom fragment that declares only the old directional subset
1982
2092
  (`uLightCount`/`uLightDir`/`uLightColor`) still works - it just shades
1983
2093
  every light as directional, so keep such materials to directional-only
@@ -2117,7 +2227,9 @@ index into `images`, `doubleSided`, `transparent` = alphaMode BLEND,
2117
2227
  `alphaMode` as written and `alphaCutoff`, spec default 0.5, the
2118
2228
  normal and emissive slots, `metalness`/`roughness` factors and the
2119
2229
  packed `metalnessRoughnessMap` - standard's inputs), `images`
2120
- (the encoded PNG/JPEG bytes, undecoded) and `bounds` (world-space rest
2230
+ (each `{ bytes, name, uri? }`: the encoded PNG, JPEG or KTX2 file,
2231
+ undecoded; its name in the document, else the uri of its file, else
2232
+ "image<i>"; the `uri` when it is a file of its own) and `bounds` (world-space rest
2121
2233
  pose: a part's box through its node, a skinned part's per-joint boxes
2122
2234
  through the joints' rest transforms - where the skin places it, armature
2123
2235
  scale included; conservative under rotation). External
@@ -2165,7 +2277,8 @@ its true roots; a hierarchy reaching a node twice (a cycle) throws.
2165
2277
  #### createModel
2166
2278
 
2167
2279
  `createModel(data, { material?, label?, autoFree? })` - uploads the images (repeat
2168
- wrap, mipmapped, 4x anisotropic), makes one material per glTF material (default `standard`
2280
+ wrap, mipmapped, 4x anisotropic; each texture is labeled `<label>-<image name>`, so the
2281
+ `/gpu` inventory names the image), makes one material per glTF material (default `standard`
2169
2282
  with the file's color, maps, normal scale, metalness/roughness and
2170
2283
  packed map, emissive and transparency - the glTF material model, so a
2171
2284
  scene showing a model wants an `environment` (a glTF metal in a scene
@@ -2213,14 +2326,118 @@ and one loaded in a component with its unmount. A load that settles
2213
2326
  after its scope is gone (unmounted mid-load) is freed on arrival and
2214
2327
  resolves already disposed - nothing owns it any more. `loadModel` reads the baked `.srtm` written by `srt tool
2215
2328
  3d/model <in.gltf|glb> -o assets/<name>.srtm`: the same parse run once
2216
- under bun, stored in the GPU layout, so loading is views onto the file's
2217
- bytes plus the image decodes. Numbers from a 32k-vertex, 6-texture model
2329
+ at build time, stored in the GPU layout, so loading is views onto the
2330
+ file's bytes plus the image decodes. Numbers from a 32k-vertex, 6-texture model
2218
2331
  on a release client: `parseGltf` 124 ms on flux (22 ms under bun) against
2219
2332
  40 ms for the whole baked load - the runtime parse is fine for small
2220
2333
  models and a binary import (`import bytes from "./x.glb" with { type:
2221
2334
  "binary" }` then `createModel(parseGltf(bytes))`, see
2222
2335
  `examples/model.tsx`); bake anything big.
2223
2336
 
2337
+ #### Compressed textures
2338
+
2339
+ `srt tool 3d/model <in> -o assets/<name>.srtm` bakes the images
2340
+ too: each becomes a KTX2 compressed texture with its mip chain. The file is the
2341
+ same for every platform; at load each device turns the images into the
2342
+ block format its GPU samples (BC7 on desktops, ETC2 elsewhere), which
2343
+ holds them in a quarter of the memory of the decoded image. Measured on
2344
+ the Khronos Sponza sample (69 images): 363 MB of textures as PNG/JPEG,
2345
+ 91 MB compressed. The encode is slow (a minute for that
2346
+ model) and runs once, at bake time. Block compression is lossy: judge
2347
+ the result on your own textures. What it saves is GPU memory; the file
2348
+ shrinks for color maps and can grow for data maps.
2349
+
2350
+ How the images are compressed, and whether, is the app's to say, in the
2351
+ `textures` group of the `solidrt` key in its package.json (the project
2352
+ the tool runs in, whose root is where the tool is run). These are the
2353
+ defaults; name only what differs:
2354
+
2355
+ ```json
2356
+ "solidrt": {
2357
+ "textures": {
2358
+ "compress": true,
2359
+ "color": { "codec": "etc1s", "quality": 0.75 },
2360
+ "normal": { "codec": "uastc", "quality": 0.9 },
2361
+ "data": { "codec": "uastc", "quality": 0.9 },
2362
+ "files": []
2363
+ }
2364
+ }
2365
+ ```
2366
+
2367
+ Three levels, the narrower one winning: the app (`compress`), a kind
2368
+ of map (`compress`, `codec`, `quality`), and single files:
2369
+
2370
+ ```json
2371
+ "files": [
2372
+ { "match": "assets/sponza/textures/lion_*.png", "codec": "uastc", "quality": 0.95 },
2373
+ { "match": ["assets/ui/**", "assets/hero.glb#lut*"], "compress": false }
2374
+ ]
2375
+ ```
2376
+
2377
+ - `match` is a glob pattern, or a list of them, against the image's
2378
+ path FROM THE PROJECT ROOT, written with "/": `*` inside a segment,
2379
+ `**` across segments, `?`, `[a-z]`, `[!a-z]` (`matchesGlob` of
2380
+ flux:path; no `{a,b}`, list the patterns). Not the uri the glTF
2381
+ writes: two models with a `textures/base.png` each are two files.
2382
+ - An image embedded in the model (a .glb) has no file of its own: the
2383
+ model's path names every image in it, and `#name` after it picks
2384
+ the ones of that name (the glTF image's `name`, "image<i>" without
2385
+ one).
2386
+ - An entry sets `compress`, `codec`, `quality` or several, over what
2387
+ the image's kind says. Where several entries name one image, a
2388
+ later entry wins, field by field.
2389
+ - `"compress": false` keeps the image as its PNG or JPEG. That is for
2390
+ what must come back exact (a lookup table, a distance field) and
2391
+ for sharp art and text, which block compression damages.
2392
+ - A pattern that names nothing FAILS the bake, before anything is
2393
+ encoded: no file of the project matches it, or the baked model has
2394
+ no embedded image of that name. A misspelled pattern would
2395
+ otherwise do nothing and say nothing.
2396
+
2397
+ `--compress` and `--no-compress` stand in for the app's `compress` for
2398
+ one run (a quick bake while the model is still changing); what a kind
2399
+ or an entry says about itself still holds.
2400
+
2401
+ The kinds are core's and say what the texels are (`@solidrt/core/textures`).
2402
+ This package maps a material's slots onto them: base color and emissive
2403
+ maps are `color`, normal maps are `normal`, metallic-roughness maps
2404
+ (occlusion rides in the same image) are `data`.
2405
+ An image several slots share takes the most demanding kind, normal over
2406
+ color over data, and stays sRGB when one of them is a color slot;
2407
+ `modelImageUses(data)` returns that per image, the rule the loader and
2408
+ the bake both follow, for a bake script of your own.
2409
+ `codec` and `quality` are `encodeTexture`'s: "etc1s" is small (a
2410
+ quarter of a JPEG), "uastc" is accurate and large (often larger than
2411
+ the JPEG it replaces), `quality` is 0..1. A normal map steers the
2412
+ lighting and shows its errors, and a metallic-roughness map packs
2413
+ unrelated values into its channels, which the small codec handles
2414
+ badly, so both take the accurate one (what the Khronos KTX guide
2415
+ recommends). Set `data` to "etc1s" when the download matters more than
2416
+ roughness detail: on the Khronos Sponza sample that is 9 MB less for
2417
+ about 2 dB in the views that glossy surfaces fill. An unknown key, codec or field fails the bake naming the key,
2418
+ before anything is encoded, and the bake prints how many images it
2419
+ compressed with which codec and quality, and how many it kept. The color space (sRGB for `color`), the mip chain and the
2420
+ wrap are not settings: they follow how `createModel` samples. The
2421
+ settings are the app's, not this package's: an app's own bake script,
2422
+ or another extension's, reads the same group with
2423
+ `textureSettings(packageJson)` from `@solidrt/core/textures` and asks
2424
+ `textureSettingFor(settings, kind, { file, name }, matchesGlob)` per
2425
+ texture (core's AGENTS.md has the rest).
2426
+
2427
+ `loadModel` and `loadGltf` handle such a model as they are, and
2428
+ `loadGltf` reads a glTF that carries KTX2 images itself
2429
+ (`KHR_texture_basisu`). Only the primitives show the step: transcoding
2430
+ is asynchronous and `createModel` is not, so bytes obtained another way
2431
+ go through `transcodeModelImages` between the two:
2432
+
2433
+ ```ts
2434
+ let data = await transcodeModelImages(decodeModel(bytes))
2435
+ let model = createModel(data)
2436
+ ```
2437
+
2438
+ `createModel` on data whose KTX2 images were not transcoded throws and
2439
+ names the function. Data without KTX2 images needs no such step.
2440
+
2224
2441
  #### Baking your own geometry
2225
2442
 
2226
2443
  Geometry an app GENERATES (a terrain, a city block, a hundred thousand
@@ -2479,9 +2696,82 @@ mesh (JS-written records, no nodes) cannot morph: rejected at add().
2479
2696
  Not in the subset, dropped: tangents and further UV sets; samplers are
2480
2697
  ignored (every texture repeats); additive blending draws as base color.
2481
2698
  The follow-ups are filed in okf/backlog/3d-model-loader.md. The `.srtm`
2482
- container is VERSION 8 (node table in the header, node-local vertices,
2483
- skins, clips, packed morph targets); older bakes are rejected - re-bake
2484
- with `srt tool 3d/model`.
2699
+ container is VERSION 11 (node table in the header, node-local vertices,
2700
+ skins, clips, packed morph targets, app data, images under their
2701
+ names); older bakes are rejected - re-bake with `srt tool 3d/model`.
2702
+
2703
+ ## Splats
2704
+
2705
+ Captured 3D Gaussian splat scenes (phone scans, photogrammetry
2706
+ successors) as ordinary content, the model-loading split repeated:
2707
+
2708
+ - `@solidrt/3d/splat` (src/splat-data.ts) is the runtime-free data side,
2709
+ the model-data rule: `parseSplat(bytes, { format?, keepOrientation? })`
2710
+ reads a trainer's `.ply`, the de-facto `.splat` interchange or a
2711
+ GUNZIPPED `.spz` (Niantic, version 2 or 3; the entry stays
2712
+ decompression-free, so inflate first) and bakes it - the 3D covariance
2713
+ precomputed per splat (R S St Rt, the vertex-stage wall every
2714
+ bake-less viewer pays at each corner), records importance-sorted (size
2715
+ times opacity, so the first n are the scene at n), y-down captures
2716
+ (.ply, .splat) stood up to y-up (positions and covariances rotated
2717
+ together; `keepOrientation` opts out, `.spz` is y-up already), bounds
2718
+ measured. `encodeSplat`/`decodeSplat` round-trip the `.srts` container
2719
+ (the record and SH blocks, next bullet); decode is a header parse
2720
+ plus byte VIEWS, nothing per-splat. `srt tool 3d/splat <in> [-o
2721
+ out.srts] [--sh 0..3] [--keep-orientation]` is the parse and bake
2722
+ under bun (tools/splat.ts); an app bake script uses the same entry.
2723
+ - The `.srts` record (version 2) is 32 bytes = two rgba32ui texels:
2724
+ center float32x3 + sRGB color and opacity unorm8x4, then the
2725
+ covariance upper triangle as six float16 + a spare word
2726
+ (SPLAT_ATTRIBUTES as a byte layout, SPLAT_RECORD_TEXELS). `--sh 1..3`
2727
+ bakes that many spherical-harmonic bands beyond the base color from a
2728
+ trainer's `.ply` or an `.spz` (`.splat` has none), capped at the
2729
+ capture's: a second texel block of 2/3/6 texels per splat
2730
+ (SPLAT_SH_TEXELS; the halves packed pairwise, coefficient-major with
2731
+ rgb interleaved), 32/48/96 bytes per splat of texture, so the
2732
+ default is 0 and a bake opts in. The y-up flip negates the
2733
+ coefficients whose basis is odd in (y, z).
2734
+ - On the runtime, `loadSplat(path)` reads a `.srts` (fetch + decode,
2735
+ the loadModel shape) and `createSplatMesh(data, { count?, material?,
2736
+ label? })` / `<SplatMesh>` shows it: a RecordMesh in the INDEXED form
2737
+ (see "Instance order") - the records handed to the engine as the
2738
+ depth sort's key source AND uploaded once as the `rgba32ui` record
2739
+ texture the vertex stage fetches by id (`uSplatRecords`, plus
2740
+ `uSplatSh` when baked; bound per mesh, so a forked material keeps
2741
+ them), SPLAT_GROUP (16) splats per drawn instance of a merged-quad
2742
+ geometry, the header bounds as the cull and picking box, and the
2743
+ id stream sorted back to front core-side - the cloud composes as a
2744
+ mesh (depth-tested against opaque geometry, placed by the transparent
2745
+ entry sort, transformed by its node), a camera turn re-sorts and
2746
+ republishes 4 bytes per splat with zero per-frame JS, and a parked
2747
+ camera uploads nothing. Write-once: treat the SplatData as consumed
2748
+ and drop it; at 1M splats the cloud is resident as the engine's 32 MB
2749
+ key copy plus 32 MB of texture plus a 4 MB id buffer, and
2750
+ disposeInstances frees all three. `splatMaterialClass(shDegree)` is
2751
+ the stock class per degree (a shared instance each), and
2752
+ `splatVertexSource(shDegree)` / SPLAT_FRAGMENT / SPLAT_ID_ATTRIBUTES
2753
+ are exported for forks; `count` rounds up to whole groups.
2754
+ - The vertex stage projects the baked covariance through the Jacobian
2755
+ of `uViewProj * uModel` (no focal/view uniforms; any node transform
2756
+ shapes the footprint with the same matrix that moves the center),
2757
+ reads the target size from the standard `uViewport`, and evaluates
2758
+ the SH bands per corner from the view direction (`uCamPos` through
2759
+ the transposed model rotation - exact under rotation and uniform
2760
+ scale). The data textures' row width is the device's (up to 4096),
2761
+ compiled into the source.
2762
+ - Why this shape: on the Pixel 7 at 1M splats the one-quad-per-instance
2763
+ form spent two thirds of its vertex side on the instancing frontend;
2764
+ the indexed form takes the full-res frame from 45 to 31 ms and the
2765
+ vertex side from 24 to 8 ms, the per-vertex fetch and random id order
2766
+ cost under a millisecond, and SH1-3 are near free (the bands cost
2767
+ memory, not time). What remains at 1M is fill (overdraw). The table
2768
+ is in okf/done/gaussian-splats.md.
2769
+ - Two capture caveats worth knowing at the app level: captures are
2770
+ TRAINED against sRGB blending, so show them in a display-space scene
2771
+ (`blendSpace: "display"`) - a linear scene lays a milky veil over
2772
+ stacked translucents - and a capture is only clean near the camera
2773
+ path it was taken from (elsewhere it is floaters), so keep the camera
2774
+ near that path.
2485
2775
 
2486
2776
  ## Traps
2487
2777
 
@@ -1,6 +1,6 @@
1
1
  # @solidrt/3d examples
2
2
 
3
- One concept per file; run with `bunx srt run <file>` from an app that
3
+ One concept per file; run with `bun run srt run <file>` from an app that
4
4
  depends on `@solidrt/3d` (or in-repo from the package directory).
5
5
 
6
6
  - `scene-basic.tsx` - the whole v1 surface: a `<Scene>` composited as a
@@ -58,7 +58,7 @@ depends on `@solidrt/3d` (or in-repo from the package directory).
58
58
  sky as a faint face-on gloss. Drag to look around.
59
59
  - `environment.tsx` - a baked HDR environment lighting the scene ALONE
60
60
  (no lights, no hemisphere): `loadEnvironment` reads the .srte that
61
- `bunx srt tool 3d/environment <panorama>.hdr -o assets/environment.srte`
61
+ `bun run srt tool 3d/environment <panorama>.hdr -o assets/environment.srte`
62
62
  bakes from any equirectangular .hdr (Poly Haven's are CC0; the asset
63
63
  is not committed, bake one first), used as background and environment
64
64
  with ACES tone mapping. A metal row and a red dielectric row, roughness
@@ -200,4 +200,4 @@ depends on `@solidrt/3d` (or in-repo from the package directory).
200
200
  stays above the boundary - the two rules that avoid
201
201
  PENDING_ASYNC_UNTRACKED_READ and the suspend-retry element leak. Needs
202
202
  the asset baked into the running app:
203
- `bunx srt tool 3d/model examples/model.glb -o assets/model.srtm`.
203
+ `bun run srt tool 3d/model examples/model.glb -o assets/model.srtm`.