@solidrt/3d 0.0.52 → 0.0.54

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
@@ -54,20 +54,37 @@ blendMode and pointer events like any element.
54
54
  the `castShadow` meshes (`<Mesh castShadow>`, `setCastShadow`) from an
55
55
  orthographic camera at the light's WORLD position along its world
56
56
  direction, `shadow.camera` (+-5, 0.5..500 by default) as the frustum.
57
- Any directional light may cast (each map is a pass, capped by
58
- MAX_LIGHTS = MAX_SHADOWS): shadow slot i is directional light i's -
59
- the map's depth id binds as the target-level `uShadowMap<i>` of the
60
- scene and every non-shadow view (a white texel when light i does not
61
- cast), `uShadowMatrix[i]` is its view's own view-projection (the whole
62
- array is one write per shadow-camera move), `uShadowCast[i]` says
63
- whether it casts, `uShadowBias[i]`/`uShadowNormalBias[i]` its knobs;
64
- `SHADOW_SLOTS` in glsl declares the set. Every `lit` material RECEIVES by default
57
+ Any directional light may cast (capped by MAX_LIGHTS = MAX_SHADOWS).
58
+ `shadow: { cascades: N }` (1..MAX_CASCADES = 4) replaces the box with
59
+ N maps fitted to slices of the SCENE camera's frustum (near ..
60
+ `shadow.distance`, default the camera far; the practical split; each
61
+ slice's bounding sphere as an ortho box along the light, its centre
62
+ snapped to the map's texel grid so edges do not swim; re-fitted
63
+ whenever the scene camera or the light moves) - a receiver samples the
64
+ tightest map that covers the point, fading into the next over the
65
+ map's outer 10% (`SHADOW_BLEND`) so the hand-over is a band, not a
66
+ seam; contact shadows stay sharp near the camera while the horizon
67
+ still has coarse ones, and pulling `distance` in sharpens all of them.
68
+ The box is the honest tier for a bounded scene; cascades are for a
69
+ scene that outgrows it, at N times the shadow fill. Every map is a
70
+ TILE of the scene's one shadow atlas (a `depth: "texture"` draw target,
71
+ a grid of cells the largest `mapSize` wide, scaled down uniformly
72
+ against `limits.maxTextureSize`), so N maps are ONE pass: the atlas
73
+ depth binds as the target-level `uShadowAtlas` of the scene and every
74
+ non-shadow view (a white texel when nothing casts); maps are MAP slots
75
+ dealt in light order, a light's cascades consecutive and tightest
76
+ first - `uShadowRect[j]` slot j's tile in atlas UV, `uShadowMatrix[j]`
77
+ its view's own view-projection (the whole array is one write per
78
+ shadow-camera move) - and per light i `uShadowFirst[i]`/`uShadowCount[i]`
79
+ name its slots (count 0 = it does not cast) with
80
+ `uShadowBias[i]`/`uShadowNormalBias[i]` its knobs; `SHADOW_SLOTS` in
81
+ glsl declares the set. Every `lit` material RECEIVES by default
65
82
  (Godot's and Three's default); `lit({ receiveShadow: false })` opts a
66
83
  material out and drops the map from its program - a material option,
67
84
  as with vertexColors/triplanar, because the material picks the program
68
85
  (Godot's `disable_receive_shadows`). The factor is `SHADOW`'s 3x3 PCF
69
86
  on each casting light's own term. `examples/shadows.tsx` (three
70
- casting lights) is the shape.
87
+ casting lights) is the shape; `examples/cascades.tsx` the cascaded sun.
71
88
  - RETARGETED motion is native: `setTransition(node, { position:
72
89
  { duration: 400 }, ... })` makes setTransform writes TARGETS the core
73
90
  animates toward every frame (position/scale per lane, rotation along
@@ -114,9 +131,12 @@ blendMode and pointer events like any element.
114
131
  the upload), so swapping `<Mesh geometry>` reactively never accumulates
115
132
  old generations. `disposeGeometry` is the immediate explicit free.
116
133
  - Materials dedupe hard: one program + one pipeline per material CLASS
117
- (unlit color, unlit map, each opaque or transparent), `depth: true` +
118
- `cull: "back"`; an instance is
119
- just per-entry uniforms (`uColor`) and bindings (`uMap`).
134
+ (a `shaderMaterialClass` per option combination for unlit, lit and
135
+ sprite alike: map x transparent x cull x alphaTest, lit's extras on
136
+ top), `depth: true` + `cull: "back"` unless the material says otherwise
137
+ (`cull: "none"` for double-sided geometry; lit flips the normal on back
138
+ faces); an instance is just per-entry uniforms (`uColor`) and bindings
139
+ (`uMap`).
120
140
  - The pure pieces (`math.ts`, `order.ts`, `geometry.ts`,
121
141
  `profile.ts`, `sweep.ts`, `gltf.ts`, `model-file.ts`) are Solid-free and
122
142
  GPU-free BY DESIGN so they can be checked headless (and, for the two
@@ -151,6 +171,31 @@ elsewhere. Called once, untracked, inside the scene context. Scene
151
171
  layout, so render and display size separate - render at 2x and display
152
172
  smaller for supersampling.
153
173
 
174
+ Filling the window: `width`/`height` are DEVICE pixels, the leaf's layout
175
+ is LOGICAL. A `designSize` view fits the leaf to the window but never
176
+ changes the target, so on a HiDPI display a 720-pixel scene is stretched
177
+ across ~1100 device pixels and looks soft, and nothing warns (the
178
+ examples' `SIZE = 720` is a verification convenience, not a sizing
179
+ model). Render at the window's device size and lay the leaf out at its
180
+ logical size:
181
+
182
+ ```tsx
183
+ let target = createMemo(() => {
184
+ let { width, height } = windowSize()
185
+ let scale = displayScale()
186
+ return { w: Math.round(width * scale), h: Math.round(height * scale) }
187
+ })
188
+ <Scene width={target().w} height={target().h}
189
+ output={t => <texture src={t} width={windowSize().width}
190
+ height={windowSize().height}
191
+ {...useScene().scene.handlersFor(windowSize)} />}>
192
+ ```
193
+
194
+ `windowSize` and `displayScale` come from `@solidrt/core`. The leaf's
195
+ layout differs from the target, so it takes `handlersFor` (below), not
196
+ `handlers`; `useScene()` works inside `output` because it runs in the
197
+ scene context.
198
+
154
199
  Camera control: `createOrbitCamera(scene, { target?, azimuth?, elevation?,
155
200
  distance?, min/maxDistance?, min/maxElevation?, orbitSpeed?, rotateSpeed?,
156
201
  zoomSpeed?, zoomAnchor?, rotateAnchor?, panSpeed?, viewport?, clampTarget? })`
@@ -304,8 +349,9 @@ uint16/uint32 indices by vertex count automatically.
304
349
 
305
350
  Materials:
306
351
 
307
- - `unlit({ color?, map? })` - straight `[r, g, b, a?]` 0..1, premultiplied
308
- internally.
352
+ - `unlit({ color?, map?, transparent?, cull?, alphaTest? })` - straight
353
+ `[r, g, b, a?]` 0..1, premultiplied internally; `cull` and `alphaTest`
354
+ as on lit (a mapped cutout casts its cutout).
309
355
  - `sprite({ color?, map?, transparent?, billboard? })` - unlit on a quad
310
356
  that turns to face the camera IN THE VERTEX STAGE (off the shared
311
357
  uCamRight/uCamUp, or uCamPos for `billboard: "fixed-y"`, which yaws
@@ -408,13 +454,18 @@ the material need that channel) and the pure functions `HEMISPHERE`
408
454
  (`hemisphere(n, sky, ground)`), `LAMBERT` (`lambert(n, l)`),
409
455
  `BLINN_SPECULAR` (`blinnSpecular(n, v, l, shininess)`), `FRESNEL`
410
456
  (`fresnel(n, v, power)`), and the shadow trio composed IN ORDER:
411
- `SHADOW_SLOTS` (the scene's shadow set: `uShadowMap0..N-1`,
412
- `uShadowMatrix[N]`, `uShadowCast[N]`, `uShadowBias[N]`,
413
- `uShadowNormalBias[N]`, slot i = directional light i), `SHADOW`
414
- (`shadow(map, coord, bias)` - one map's 3x3 PCF factor) and
415
- `SHADOW_LOOKUP` (`lightShadow(i, worldPos, n)` - light i's factor, 1
416
- when it does not cast; it hides the if-chain that picks the map, since
417
- GLSL ES 3.00 forbids dynamic sampler indexing). A receiving fragment
457
+ `SHADOW_SLOTS` (the scene's shadow set: `uShadowAtlas`, per map slot
458
+ `uShadowRect[M]`/`uShadowMatrix[M]`, per directional light
459
+ `uShadowFirst[N]`/`uShadowCount[N]` (its slots; a cascaded light has
460
+ several, tightest first), `uShadowBias[N]`, `uShadowNormalBias[N]`),
461
+ `SHADOW` (`shadowPoint(coord)` - clip to map point, `shadowInside(p)` -
462
+ does the map have it, `shadowSample(map, rect, p, bias)` - one tile's
463
+ 3x3 PCF factor, and `shadow(map, rect, coord, bias)` composing the
464
+ three) and `SHADOW_LOOKUP`
465
+ (`lightShadow(i, worldPos, n)` - light i's factor, 1 when it does not
466
+ cast; it walks the light's slots and samples the first map that covers
467
+ the point, which is the cascade select, blended into the next map over
468
+ the outer `SHADOW_BLEND` of the map). A receiving fragment
418
469
  multiplies light i's term by `lightShadow(i, ...)`, exactly what `lit`
419
470
  composes; a non-receiving one composes none of the three and declares no
420
471
  samplers. Lights, colors and exponents are arguments, so
@@ -450,17 +501,22 @@ the directional list, Lambert diffuse, Blinn-Phong highlight when
450
501
  `specular` (0..1 strength) is set with `shininess` (default 30), the
451
502
  same `color`/`map`/`transparent` as unlit, `vertexColors: true` to
452
503
  multiply by the colored layout's aColor (so the geometry must carry it),
453
- and `triplanar: n` to sample `map` by world position at `n` repeats per
454
- world unit, blended across the three axis planes by the normal. Triplanar
504
+ `triplanar: n` to sample `map` by world position at `n` repeats per
505
+ world unit, blended across the three axis planes by the normal, and
506
+ `alphaTest: t` for a cutout (a fragment whose final alpha is below `t`
507
+ is discarded; Three's alphaTest, glTF MASK): opaque, depth-written, no
508
+ sorting, usually with `cull: "none"` for cards. Triplanar
455
509
  is an OPTION, not the default: generators emit 0..1 UVs per face, so a
456
510
  map on a plane is a decal (UV) while a map on generated scenery wants one
457
511
  density across parts of any size (triplanar); the map must be created
458
512
  with `wrap: "repeat"`. Internally one `shaderMaterialClass` per option
459
- combination (map x vertexColors x triplanar x transparent), cached for
460
- the app's lifetime, one pipeline per vertex layout - a thousand lit
461
- meshes share one program. The view vector comes from the shared uCamPos;
462
- `uTriplanar` is declared only by the triplanar classes so the other
463
- classes do not warn about an inactive uniform.
513
+ combination (map x vertexColors x triplanar x transparent x cull x
514
+ alphaTest), cached for the app's lifetime, one pipeline per vertex layout
515
+ - a thousand lit meshes share one program. The view vector comes from the
516
+ shared uCamPos; `uTriplanar` and `uAlphaTest` are declared only by the
517
+ classes that use them (the cutoff is a per-entry value, so every
518
+ alphaTest material shares one class) so the other classes do not warn
519
+ about an inactive uniform.
464
520
 
465
521
  ## Models
466
522
 
@@ -472,7 +528,8 @@ next to it, or single-file .glb) and become a Group of meshes, Three's
472
528
  bun and on flux): `ModelData` = `parts` (one per mesh node, its NAME
473
529
  kept, vertices in the standard layout with the node's WORLD transform
474
530
  baked in), `materials` (base color factor, `map` = index into `images`,
475
- `doubleSided`, `transparent` = alphaMode BLEND), `images` (the encoded
531
+ `doubleSided`, `transparent` = alphaMode BLEND, `alphaMode` as written
532
+ and `alphaCutoff`, spec default 0.5), `images` (the encoded
476
533
  PNG/JPEG bytes, undecoded) and `bounds`. A .gltf's external files come
477
534
  through `resolve(uri)` (uri as written, still percent-encoded;
478
535
  `gltfExternalUris(bytes)` lists them so an async caller can read them
@@ -504,10 +561,10 @@ next to it, or single-file .glb) and become a Group of meshes, Three's
504
561
  "binary" }` then `createModel(parseGltf(bytes))`, see
505
562
  `examples/model.tsx`); bake anything big.
506
563
 
507
- Not in the subset, reported or dropped: `doubleSided` is reported and NOT
508
- applied (the standard materials cull back faces); vertex colors, tangents
509
- and further UV sets are dropped; samplers are ignored (every texture
510
- repeats); alphaMode MASK draws opaque; emissive/additive parts of a model
564
+ Applied: `doubleSided` (the default material draws it with `cull:
565
+ "none"`) and alphaMode MASK (`alphaTest: alphaCutoff`). Not in the
566
+ subset, dropped: vertex colors, tangents and further UV sets; samplers
567
+ are ignored (every texture repeats); emissive/additive parts of a model
511
568
  draw as their base color (a model's "glow" cards come out as dark wedges).
512
569
  The follow-ups are filed in okf/backlog/3d-model-loader.md.
513
570
 
@@ -546,7 +603,13 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
546
603
  are opaque to the library, so any inferred box would be a guess. Supply
547
604
  `bounds` for anything pickable or transparent.
548
605
  - Transparency is an EXPLICIT material flag, Three's rule: `unlit({ color:
549
- [r, g, b, 0.5] })` still draws opaque; `unlit({ ..., transparent: true })`
606
+ [r, g, b, 0.5] })` still draws opaque, and opaque means it: the standard
607
+ classes write alpha 1 when not `transparent` (the scene target is
608
+ composited premultiplied, so a leaked texel or color alpha would punch
609
+ a see-through hole in an opaque draw - the source of "white cutouts"
610
+ on an alpha-mapped model drawn without alphaTest). A `shaderMaterial`
611
+ writes its own fragColor: give an opaque look alpha 1 too.
612
+ `unlit({ ..., transparent: true })`
550
613
  (or `shaderMaterial({ transparent: true })`) builds the pipeline with
551
614
  `blend: "alpha"` and `depthWrite: false` (depth test stays on, so it hides
552
615
  behind opaques without occluding other translucents). The one inference:
@@ -653,7 +716,14 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
653
716
  `shadow.normalBias` (world units along the receiver normal, the one to
654
717
  reach for first, ~0.02); the depth pass culls FRONT faces (Three's
655
718
  shadowSide default), so closed casters need little bias but a
656
- single-sided plane casts nothing. Opting out of receiving is on the
719
+ back-culling plane casts only from its back. The shadow side follows
720
+ the material's `cull` (Three's shadowSide rule, Godot's shadow pass):
721
+ a `cull: "none"` foliage card or pane casts from both faces, and a
722
+ UV-mapped `alphaTest` material casts its cutout (leaves, not
723
+ rectangles), through the `Material.shadow` variant the standard
724
+ classes carry (a `shaderMaterial` gets the cull side from its `cull`
725
+ and supplies its own cutout variant as the `shadow` instance option).
726
+ Opting out of receiving is on the
657
727
  MATERIAL here (`receiveShadow: false`), not the object (Three's
658
728
  `mesh.receiveShadow`) - Godot's split, and URP's - and instanced
659
729
  meshes never cast (the depth override cannot know their records) - the
@@ -730,12 +800,13 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
730
800
  vertex source - a comment counts - selects the "colored" layout, and
731
801
  the material then rejects standard geometry at add(). Do not mention
732
802
  aColor you do not read.
733
- - Picking is the VOLUME tier: a hit means the ray crossed the mesh's
734
- transformed bounding box, not its surface. Never present `point` as a
735
- surface point (it is the box-entry point), and never add a
736
- triangle-accurate path in JS - per-triangle rays at mesh scale are
737
- interpreter-hostile; that tier is core work (BVH descent per the
738
- differentiators ladder).
803
+ - Picking is triangle-accurate for ordinary meshes (`point` is a surface
804
+ point, hits carry `face`/`uv`/`normal`) but box-only for instanced
805
+ meshes: there `point` is the entry point of the population `bounds`
806
+ box, and `face`/`uv`/`normal` are absent. Never present an instanced
807
+ hit as a surface hit. Both tiers run in the spatial core (Rust); never
808
+ add a per-triangle path in JS - rays at mesh scale are
809
+ interpreter-hostile, and the core already does it.
739
810
  - `scene.handlers` vs `handlersFor`: localX/localY arrive in the leaf's
740
811
  LAYOUT frame (every ancestor transform and design-size fit is already
741
812
  undone by the element hit test). `handlers` therefore assumes leaf
@@ -50,6 +50,14 @@ depends on `@solidrt/3d` (or in-repo from the package directory).
50
50
  ground and the casters receiving through plain `lit` (the default);
51
51
  each shadow camera follows its light's world matrix, each map is
52
52
  rendered by an internal view.
53
+ - `cascades.tsx` - cascaded shadow maps: a sun with `shadow: { cascades:
54
+ 3 }` over a field of pillars to the horizon under a slowly flying
55
+ camera; three maps fitted to slices of the camera frustum, sampled
56
+ tightest-first, so the shadows are sharp at the camera's feet and still
57
+ there at the far edge of the ground. A click cycles 1..4 cascades (1 is
58
+ the plain box widened to cover the field: one map's texels spread over
59
+ it, blocky everywhere) and the `cascades`/`fly` debug commands set the
60
+ count and the shadow distance and park the flight.
53
61
  - `model.tsx` - a model from a file: `model.glb` (a small rover with
54
62
  nested node transforms, a mirrored node, a textured material, a
55
63
  transparent dome and a mesh without normals) parsed with `parseGltf`
@@ -0,0 +1,121 @@
1
+ // Cascaded shadow maps: a field of pillars to the horizon under a slowly
2
+ // flying camera, lit by one casting sun with `shadow: { cascades: 3 }`.
3
+ // A box shadow (`shadow.camera`) is one map over a fixed area: widened to
4
+ // cover this field its 1024 texels spread over 260 world units and every
5
+ // shadow is blocky, kept tight the far field is unshadowed. With cascades
6
+ // the light renders one map per slice of the SCENE camera's frustum
7
+ // (near..far, tightest first), re-fitted every time the camera or the
8
+ // light moves, and a receiver samples the tightest map that has the
9
+ // point: sharp contact shadows at the camera's feet, coarser ones toward
10
+ // the horizon, at the cost of N times the shadow fill. Every map is a
11
+ // tile of the scene's one atlas, so the pass count is unchanged.
12
+ //
13
+ // A click cycles 1..4 cascades (1 = the box, widened over the field).
14
+ // The `cascades` debug command sets the count and the shadow distance
15
+ // (`{ count, distance }`; the range the cascades split, the camera's far
16
+ // by default - pulling it in sharpens every cascade) and `fly` parks the
17
+ // flight (`{ t: seconds }`), so a capture repeats.
18
+ import { createSignal, onFrame, pct, render } from "@solidrt/core"
19
+ import { registerDebug } from "srt:dev"
20
+ import { box, DirectionalLight, HemisphereLight, lit, Mesh, PerspectiveCamera, plane, Scene, sphere } from "@solidrt/3d"
21
+ import type { Geometry, Vec3 } from "@solidrt/3d"
22
+
23
+ const SIZE = 720
24
+ const FIELD = 260
25
+ const FAR = 200
26
+ // The flight: a circle over the field, looking ahead along it.
27
+ const RADIUS = 50
28
+ const HEIGHT = 5
29
+ const PERIOD = 90
30
+
31
+ let [cascades, setCascades] = createSignal(3)
32
+ let [distance, setDistance] = createSignal<number | null>(null)
33
+ // The flight clock, in seconds; `parked` holds it.
34
+ let [time, setTime] = createSignal(0)
35
+ let parked: number | null = null
36
+
37
+ registerDebug("cascades", (args?: Record<string, unknown>) => {
38
+ if (typeof args?.count === "number") setCascades(args.count)
39
+ if (typeof args?.distance === "number" || args?.distance === null) setDistance(args.distance)
40
+ return { cascades: cascades(), distance: distance() }
41
+ })
42
+ registerDebug("fly", (args?: Record<string, unknown>) => {
43
+ if (typeof args?.t === "number") {
44
+ parked = args.t
45
+ setTime(args.t)
46
+ } else if (args?.t === null) {
47
+ parked = null
48
+ }
49
+ return { t: time(), parked: parked !== null }
50
+ })
51
+
52
+ // A grid of pillars with a sphere on every third one, heights varying so
53
+ // the shadows differ in length; one geometry per height, shared.
54
+ let pillars: { position: Vec3; height: number; ball: boolean }[] = []
55
+ let step = 16
56
+ for (let i = -7; i <= 7; i++) {
57
+ for (let j = -7; j <= 7; j++) {
58
+ let h = 2 + ((i * 7 + j * 3 + 100) % 5)
59
+ pillars.push({ position: [i * step + (j % 2) * 4, h / 2, j * step + (i % 2) * 5], height: h, ball: (i + j) % 3 === 0 })
60
+ }
61
+ }
62
+ let boxes = new Map<number, Geometry>()
63
+ let pillar = (height: number): Geometry => {
64
+ let g = boxes.get(height)
65
+ if (g === undefined) boxes.set(height, (g = box({ width: 1.2, height, depth: 1.2 })))
66
+ return g
67
+ }
68
+ let orb = sphere({ radius: 0.9 })
69
+
70
+ function App() {
71
+ onFrame(tick => {
72
+ if (parked === null) setTime(tick / 1000)
73
+ })
74
+ let eye = (): Vec3 => {
75
+ let a = (time() / PERIOD) * Math.PI * 2
76
+ return [Math.sin(a) * RADIUS, HEIGHT, Math.cos(a) * RADIUS]
77
+ }
78
+ let ahead = (): Vec3 => {
79
+ let a = (time() / PERIOD) * Math.PI * 2 + 0.5
80
+ return [Math.sin(a) * RADIUS, 1.5, Math.cos(a) * RADIUS]
81
+ }
82
+
83
+ let ground = lit({ color: [0.5, 0.55, 0.45] })
84
+ let stone = lit({ color: [0.75, 0.7, 0.62] })
85
+ let ball = lit({ color: [0.85, 0.35, 0.3], specular: 0.4, shininess: 30 })
86
+
87
+ return (
88
+ <window>
89
+ <view width={pct(100)} height={pct(100)} designSize={[SIZE, SIZE]} onPointerDown={() => setCascades(c => (c % 4) + 1)}>
90
+ <Scene width={SIZE} height={SIZE} clearColor={[0.6, 0.72, 0.88, 1]} samples={4} label="cascades">
91
+ <PerspectiveCamera fov={50} near={0.5} far={FAR} position={eye()} lookAt={ahead()} />
92
+ <HemisphereLight sky={[0.5, 0.58, 0.7]} ground={[0.25, 0.22, 0.18]} />
93
+ <DirectionalLight
94
+ color={[1, 0.95, 0.85]}
95
+ intensity={0.9}
96
+ position={[40, 90, 30]}
97
+ direction={[-1, -0.55, -0.4]}
98
+ castShadow
99
+ shadow={{
100
+ mapSize: 1024,
101
+ normalBias: 0.08,
102
+ cascades: cascades(),
103
+ distance: distance(),
104
+ // The box tier's frustum, when cascades is 1: the whole field.
105
+ camera: { left: -FIELD / 2, right: FIELD / 2, top: FIELD / 2, bottom: -FIELD / 2, near: 1, far: 400 },
106
+ }}
107
+ />
108
+ <Mesh geometry={plane({ width: FIELD, height: FIELD })} material={ground} rotation={[-Math.PI / 2, 0, 0]} />
109
+ {pillars.map(p => (
110
+ <>
111
+ <Mesh geometry={pillar(p.height)} material={stone} position={p.position} castShadow />
112
+ {p.ball ? <Mesh geometry={orb} material={ball} position={[p.position[0], p.height + 0.9, p.position[2]]} castShadow /> : null}
113
+ </>
114
+ ))}
115
+ </Scene>
116
+ </view>
117
+ </window>
118
+ )
119
+ }
120
+
121
+ render(() => <App />)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/3d",
3
- "version": "0.0.52",
3
+ "version": "0.0.54",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -13,12 +13,12 @@
13
13
  },
14
14
  "files": [
15
15
  "src/",
16
+ "tools/",
16
17
  "examples/",
17
- "demos/",
18
18
  "AGENTS.md"
19
19
  ],
20
20
  "peerDependencies": {
21
- "@solidjs/signals": "2.0.0-rc.1",
22
- "@solidrt/core": "0.0.52"
21
+ "@solidjs/signals": "2.0.0-rc.3",
22
+ "@solidrt/core": "0.0.54"
23
23
  }
24
24
  }
@@ -379,8 +379,8 @@ export type DirectionalLightProps = TransformProps & {
379
379
  * each is a pass). Its shadow camera sits at the light's WORLD
380
380
  * position, so give a casting light a `position` above the scene. */
381
381
  castShadow?: boolean
382
- /** Shadow-map options (mapSize, bias, normalBias, camera frustum),
383
- * merged key by key. */
382
+ /** Shadow-map options (mapSize, bias, normalBias, camera frustum,
383
+ * cascades, distance), merged key by key. */
384
384
  shadow?: ShadowOptions
385
385
  ref?: (light: DirectionalLightNode) => void
386
386
  }
package/src/glsl.ts CHANGED
@@ -23,6 +23,15 @@ import { glsl } from "@solidrt/core/gpu"
23
23
  * the app (see okf/backlog/app-runtime-config.md). */
24
24
  export const MAX_LIGHTS = 4
25
25
 
26
+ /** The most cascades one casting light splits its shadow into
27
+ * (`shadow.cascades`, 1..MAX_CASCADES). */
28
+ export const MAX_CASCADES = 4
29
+
30
+ /** The shadow-map slot count of the scene's shadow set: every casting
31
+ * light owns `shadow.cascades` consecutive slots (one per map), so this
32
+ * bounds `uShadowRect`/`uShadowMatrix`. */
33
+ export const MAX_SHADOW_MAPS = MAX_LIGHTS * MAX_CASCADES
34
+
26
35
  /**
27
36
  * The standard lit vertex stage: clip position via uViewProj * uModel,
28
37
  * with world position, world normal (via `mat3(uNormal)`, correct under
@@ -119,74 +128,120 @@ export const FRESNEL = glsl`
119
128
  `
120
129
 
121
130
  /**
122
- * The scene's shadow set as a receiving program declares it, one slot per
123
- * directional light index: `uShadowMap0..N-1` (each light's depth map, a
124
- * white texel when it does not cast), `uShadowMatrix[N]` (its light-space
125
- * viewProj), `uShadowCast[N]` (1 when light i casts), `uShadowBias[N]` and
131
+ * The scene's shadow set as a receiving program declares it: ONE
132
+ * `uShadowAtlas` (every casting light's depth map is a tile of it, so N
133
+ * maps render as one pass; a white texel when nothing casts), a MAP slot
134
+ * set of `MAX_SHADOW_MAPS` - `uShadowRect[M]` (map slot j's tile as x, y,
135
+ * width, height in atlas 0..1 UV) and `uShadowMatrix[M]` (its light-space
136
+ * viewProj) - and, per directional light index, `uShadowFirst[N]` /
137
+ * `uShadowCount[N]` (light i's maps are slots `first .. first + count - 1`;
138
+ * count 0 = it does not cast; a box light has one map, a cascaded light
139
+ * `shadow.cascades` of them, tightest first), `uShadowBias[N]` and
126
140
  * `uShadowNormalBias[N]`. The scene binds and writes all of it on every
127
141
  * target a receiving material can draw into; a custom material composes
128
142
  * this, then SHADOW, then SHADOW_LOOKUP (in that order) and multiplies
129
- * light i's term by `lightShadow(i, worldPos, n)` - `lit` is the shape.
130
- * A material that does not receive composes none of it, so it declares
131
- * no samplers for nothing.
143
+ * light i's term by `lightShadow(i, worldPos, n)` - `lit` is the shape. A
144
+ * material that does not receive composes none of it, so it declares no
145
+ * sampler for nothing.
132
146
  */
133
147
  export const SHADOW_SLOTS = glsl`
134
- ${Array.from({ length: MAX_LIGHTS }, (_, i) => `uniform sampler2D uShadowMap${i};`).join("\n ")}
135
- uniform mat4 uShadowMatrix[${MAX_LIGHTS}];
136
- uniform int uShadowCast[${MAX_LIGHTS}];
148
+ uniform sampler2D uShadowAtlas;
149
+ uniform vec4 uShadowRect[${MAX_SHADOW_MAPS}];
150
+ uniform mat4 uShadowMatrix[${MAX_SHADOW_MAPS}];
151
+ uniform int uShadowFirst[${MAX_LIGHTS}];
152
+ uniform int uShadowCount[${MAX_LIGHTS}];
137
153
  uniform float uShadowBias[${MAX_LIGHTS}];
138
154
  uniform float uShadowNormalBias[${MAX_LIGHTS}];
139
155
  `
140
156
 
141
157
  /**
142
- * `float shadow(sampler2D map, vec4 coord, float bias)` - the directional
143
- * shadow factor (1 lit, 0 shadowed) for a world point carried into a
144
- * casting light's clip space by its `uShadowMatrix[i]`:
145
- * `shadow(uShadowMap0, uShadowMatrix[0] * vec4(vWorldPos, 1.0), uShadowBias[0])`.
146
- * Perspective divide, 0..1 remap, out-of-frustum returns 1 (lit), then a
147
- * 3x3 PCF over texel neighbours comparing the map's `.r` (a stage-1 depth
148
- * texture samples nearest, so the softness is this loop, not the sampler).
149
- * `bias` is subtracted from the point's depth against acne; SHADOW_LOOKUP
150
- * also offsets the point along its normal by `uShadowNormalBias[i]`
151
- * before the transform.
158
+ * The shadow lookup in three steps plus their composition, one map at a
159
+ * time. `vec3 shadowPoint(vec4 coord)` takes a world point carried into a
160
+ * casting light's clip space by that map's `uShadowMatrix[j]` to its map
161
+ * point: xy in 0..1 across the map, z the depth to compare. `bool
162
+ * shadowInside(vec3 p)` is whether the map has it at all (xy in 0..1, z
163
+ * not past the far plane) - the cascade select. `float shadowSample(
164
+ * sampler2D map, vec4 rect, vec3 p, float bias)` is the factor (1 lit, 0
165
+ * shadowed) of a point the map has: a 3x3 PCF over texel neighbours in
166
+ * the map's tile `rect` (x, y, width, height in `map`'s 0..1 UV;
167
+ * `vec4(0, 0, 1, 1)` is a whole map) comparing the map's `.r` (a stage-1
168
+ * depth texture samples nearest, so the softness is this loop, not the
169
+ * sampler); every tap is clamped to the tile inset by half a texel, so
170
+ * no tap reads a neighbouring map's tile; `bias` is subtracted from the
171
+ * point's depth against acne. `float shadow(sampler2D map, vec4 rect,
172
+ * vec4 coord, float bias)` composes the three: 1 (lit) outside the map,
173
+ * else the sample -
174
+ * `shadow(uShadowAtlas, uShadowRect[0], uShadowMatrix[0] * vec4(vWorldPos, 1.0), uShadowBias[0])`.
175
+ * SHADOW_LOOKUP uses the steps, so it projects each map once.
152
176
  */
153
177
  export const SHADOW = glsl`
154
- float shadow(sampler2D map, vec4 coord, float bias) {
155
- vec3 p = coord.xyz / coord.w * 0.5 + 0.5;
156
- if (p.x < 0.0 || p.x > 1.0 || p.y < 0.0 || p.y > 1.0 || p.z > 1.0) return 1.0;
178
+ vec3 shadowPoint(vec4 coord) {
179
+ return coord.xyz / coord.w * 0.5 + 0.5;
180
+ }
181
+
182
+ bool shadowInside(vec3 p) {
183
+ return all(greaterThanEqual(p.xy, vec2(0.0))) && all(lessThanEqual(p, vec3(1.0)));
184
+ }
185
+
186
+ float shadowSample(sampler2D map, vec4 rect, vec3 p, float bias) {
157
187
  vec2 texel = 1.0 / vec2(textureSize(map, 0));
188
+ vec2 lo = rect.xy + 0.5 * texel;
189
+ vec2 hi = rect.xy + rect.zw - 0.5 * texel;
190
+ vec2 base = rect.xy + p.xy * rect.zw;
158
191
  float lit = 0.0;
159
192
  for (int y = -1; y <= 1; y++) {
160
193
  for (int x = -1; x <= 1; x++) {
161
- float d = texture(map, p.xy + vec2(float(x), float(y)) * texel).r;
194
+ float d = texture(map, clamp(base + vec2(float(x), float(y)) * texel, lo, hi)).r;
162
195
  lit += p.z - bias <= d ? 1.0 : 0.0;
163
196
  }
164
197
  }
165
198
  return lit / 9.0;
166
199
  }
200
+
201
+ float shadow(sampler2D map, vec4 rect, vec4 coord, float bias) {
202
+ vec3 p = shadowPoint(coord);
203
+ return shadowInside(p) ? shadowSample(map, rect, p, bias) : 1.0;
204
+ }
167
205
  `
168
206
 
169
207
  /**
170
208
  * The step from a light index to its shadow factor, over SHADOW_SLOTS and
171
- * SHADOW (compose both first). `float shadowAt(int i, vec4 coord, float
172
- * bias)` picks light i's map - an if-chain over the slots, because GLSL
173
- * ES 3.00 only indexes a sampler array by a constant - and samples it
174
- * with `shadow`. `float lightShadow(int i, vec3 worldPos, vec3 n)` is
175
- * the one to call per light: 1 for a light that does not cast, else the
176
- * factor for `worldPos` pushed along its normal `n` by
177
- * `uShadowNormalBias[i]` (the acne knob to reach for first) and carried
178
- * through `uShadowMatrix[i]` with `uShadowBias[i]`. Position and normal
179
- * are arguments, so no varying name is pinned and a custom vertex stage
180
- * composes freely.
209
+ * SHADOW (compose both first). `float lightShadow(int i, vec3 worldPos,
210
+ * vec3 n)` is the one to call per light: 1 for a light that does not
211
+ * cast, else the factor for `worldPos` pushed along its normal `n` by
212
+ * `uShadowNormalBias[i]` (the acne knob to reach for first), looked up in
213
+ * the FIRST of light i's maps that has the point (a box light has one; a
214
+ * cascaded light's maps come tightest first, so the sharpest cascade
215
+ * that has the point wins and a point past the last is lit) with that
216
+ * map's `uShadowMatrix[j]`, its tile and `uShadowBias[i]`. Inside the
217
+ * outer SHADOW_BLEND of a map (in map 0..1 units, so 0.1 is its outer
218
+ * 10% on each side) the factor fades into the next cascade's, so the
219
+ * hand-over is a band and not a seam; the last map, a box light's only
220
+ * one, and any rim the next cascade does not reach (the near side, at
221
+ * the camera's feet) have no band. Position and normal are arguments, so
222
+ * no varying name is pinned and a custom vertex stage composes freely.
181
223
  */
182
224
  export const SHADOW_LOOKUP = glsl`
183
- float shadowAt(int i, vec4 coord, float bias) {
184
- ${Array.from({ length: MAX_LIGHTS }, (_, i) => `if (i == ${i}) return shadow(uShadowMap${i}, coord, bias);`).join("\n ")}
185
- return 1.0;
186
- }
225
+ const float SHADOW_BLEND = 0.1;
187
226
 
188
227
  float lightShadow(int i, vec3 worldPos, vec3 n) {
189
- if (uShadowCast[i] != 1) return 1.0;
190
- return shadowAt(i, uShadowMatrix[i] * vec4(worldPos + n * uShadowNormalBias[i], 1.0), uShadowBias[i]);
228
+ int count = uShadowCount[i];
229
+ if (count == 0) return 1.0;
230
+ vec4 w = vec4(worldPos + n * uShadowNormalBias[i], 1.0);
231
+ float bias = uShadowBias[i];
232
+ int first = uShadowFirst[i];
233
+ int last = first + count - 1;
234
+ for (int j = first; j <= last; j++) {
235
+ vec3 p = shadowPoint(uShadowMatrix[j] * w);
236
+ if (!shadowInside(p)) continue;
237
+ float s = shadowSample(uShadowAtlas, uShadowRect[j], p, bias);
238
+ if (j == last) return s;
239
+ float edge = min(min(p.x, 1.0 - p.x), min(p.y, 1.0 - p.y));
240
+ if (edge >= SHADOW_BLEND) return s;
241
+ vec3 q = shadowPoint(uShadowMatrix[j + 1] * w);
242
+ if (!shadowInside(q)) return s;
243
+ return mix(shadowSample(uShadowAtlas, uShadowRect[j + 1], q, bias), s, edge / SHADOW_BLEND);
244
+ }
245
+ return 1.0;
191
246
  }
192
247
  `