@solidrt/3d 0.0.52 → 0.0.53

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
@@ -408,13 +425,18 @@ the material need that channel) and the pure functions `HEMISPHERE`
408
425
  (`hemisphere(n, sky, ground)`), `LAMBERT` (`lambert(n, l)`),
409
426
  `BLINN_SPECULAR` (`blinnSpecular(n, v, l, shininess)`), `FRESNEL`
410
427
  (`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
428
+ `SHADOW_SLOTS` (the scene's shadow set: `uShadowAtlas`, per map slot
429
+ `uShadowRect[M]`/`uShadowMatrix[M]`, per directional light
430
+ `uShadowFirst[N]`/`uShadowCount[N]` (its slots; a cascaded light has
431
+ several, tightest first), `uShadowBias[N]`, `uShadowNormalBias[N]`),
432
+ `SHADOW` (`shadowPoint(coord)` - clip to map point, `shadowInside(p)` -
433
+ does the map have it, `shadowSample(map, rect, p, bias)` - one tile's
434
+ 3x3 PCF factor, and `shadow(map, rect, coord, bias)` composing the
435
+ three) and `SHADOW_LOOKUP`
436
+ (`lightShadow(i, worldPos, n)` - light i's factor, 1 when it does not
437
+ cast; it walks the light's slots and samples the first map that covers
438
+ the point, which is the cascade select, blended into the next map over
439
+ the outer `SHADOW_BLEND` of the map). A receiving fragment
418
440
  multiplies light i's term by `lightShadow(i, ...)`, exactly what `lit`
419
441
  composes; a non-receiving one composes none of the three and declares no
420
442
  samplers. Lights, colors and exponents are arguments, so
@@ -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.53",
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.53"
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
  `
package/src/material.ts CHANGED
@@ -216,10 +216,11 @@ export type LitOptions = UnlitOptions & {
216
216
  // composes by hand, per flag: map x vertexColors x triplanar x shadow x
217
217
  // transparent. Lights arrive through the scene's shared params
218
218
  // (light nodes); the base color, map and highlight are per entry. The
219
- // shadow set is shared too and indexed like the lights: slot i is
220
- // directional light i's map, matrix and biases (target-level, bound by
221
- // the scene), uShadowCast[i] says whether it casts; SHADOW_LOOKUP turns
222
- // the index into the factor.
219
+ // shadow set is shared too and indexed like the lights: one atlas sampler,
220
+ // directional light i's maps (one, or its cascades) as map slots
221
+ // uShadowFirst[i] .. + uShadowCount[i] with a tile rect and a matrix
222
+ // each, and its biases (target-level, bound by the scene); uShadowCount
223
+ // 0 means it does not cast; SHADOW_LOOKUP turns the index into the factor.
223
224
  function litFragment(map: boolean, vertexColors: boolean, triplanar: boolean, shadow: boolean): string {
224
225
  return glsl`
225
226
  in vec3 vWorldPos;
package/src/math.ts CHANGED
@@ -648,3 +648,79 @@ export function rayBoxDistance(
648
648
  }
649
649
  return tFar >= tNear ? tNear : -1
650
650
  }
651
+
652
+ /**
653
+ * The far bound of slice `index` of `count` when a range near..far is
654
+ * split for shadow cascades: `lambda` 0 slices it uniformly, 1
655
+ * logarithmically (equal texel density per unit of view depth, which
656
+ * starves the far slices), between the two in between. The last slice
657
+ * ends at `far`; a near of 0 has no logarithm and slices uniformly.
658
+ */
659
+ export function cascadeSplit(near: number, far: number, index: number, count: number, lambda: number): number {
660
+ if (index >= count - 1) return far
661
+ let t = (index + 1) / count
662
+ let uniform = near + (far - near) * t
663
+ if (!(near > 0)) return uniform
664
+ let log = near * Math.pow(far / near, t)
665
+ return uniform + (log - uniform) * lambda
666
+ }
667
+
668
+ /** The camera facts a frustum slice depends on: its view matrix (rows are
669
+ * its right, up and back axes), eye, vertical fov in degrees and, for an
670
+ * orthographic camera, the extents (fov ignored then). */
671
+ export type FrustumSpec = { view: Mat4; eye: Vec3; fov: number; ortho: { left: number; right: number; top: number; bottom: number } | null }
672
+
673
+ /**
674
+ * The bounding sphere of the slice zn..zf of a camera's view frustum
675
+ * (`aspect` = width / height): writes the centre to `out`, returns the
676
+ * radius. Perspective: the centre sits on the view axis where the near
677
+ * and far corner rings are equidistant, clamped into the slice, so it
678
+ * is the tightest sphere on the axis; orthographic: the slice box's
679
+ * centre and half-diagonal. A sphere rather than the slice's own corners
680
+ * so a shadow box fitted to it keeps its size while the camera turns.
681
+ */
682
+ export function frustumSliceSphere(out: Vec3, cam: FrustumSpec, aspect: number, zn: number, zf: number): number {
683
+ let v = cam.view
684
+ let fx = -v[2]
685
+ let fy = -v[6]
686
+ let fz = -v[10]
687
+ let o = cam.ortho
688
+ if (o === null) {
689
+ // Corner distance from the axis per unit of depth.
690
+ let k = Math.tan((cam.fov * Math.PI) / 360) * Math.hypot(1, aspect)
691
+ let rn = zn * k
692
+ let rf = zf * k
693
+ let zc = zf > zn ? Math.min(zf, Math.max(zn, (zf * zf + rf * rf - zn * zn - rn * rn) / (2 * (zf - zn)))) : zn
694
+ out[0] = cam.eye[0] + fx * zc
695
+ out[1] = cam.eye[1] + fy * zc
696
+ out[2] = cam.eye[2] + fz * zc
697
+ return Math.hypot(zf - zc, rf)
698
+ }
699
+ let zc = 0.5 * (zn + zf)
700
+ let cx = 0.5 * (o.left + o.right)
701
+ let cy = 0.5 * (o.top + o.bottom)
702
+ out[0] = cam.eye[0] + fx * zc + v[0] * cx + v[1] * cy
703
+ out[1] = cam.eye[1] + fy * zc + v[4] * cx + v[5] * cy
704
+ out[2] = cam.eye[2] + fz * zc + v[8] * cx + v[9] * cy
705
+ return Math.hypot(0.5 * (o.right - o.left), 0.5 * (o.top - o.bottom), 0.5 * (zf - zn))
706
+ }
707
+
708
+ /**
709
+ * Snap `p`'s coordinates along the first two axes of `basis` (a rotation
710
+ * matrix whose rows are the frame's axes - `lookAt([0, 0, 0], dir, up)`
711
+ * for a light) to multiples of `step`, leaving the third as it is;
712
+ * writes to `out`, which may be `p`. A shadow box centred on the result
713
+ * moves by whole texels only, so its shadows do not swim as the camera
714
+ * creeps.
715
+ */
716
+ export function snapToGrid(out: Vec3, p: Vec3, basis: Mat4, step: number): Vec3 {
717
+ let x = basis[0] * p[0] + basis[4] * p[1] + basis[8] * p[2]
718
+ let y = basis[1] * p[0] + basis[5] * p[1] + basis[9] * p[2]
719
+ let z = basis[2] * p[0] + basis[6] * p[1] + basis[10] * p[2]
720
+ x = Math.round(x / step) * step
721
+ y = Math.round(y / step) * step
722
+ out[0] = basis[0] * x + basis[1] * y + basis[2] * z
723
+ out[1] = basis[4] * x + basis[5] * y + basis[6] * z
724
+ out[2] = basis[8] * x + basis[9] * y + basis[10] * z
725
+ return out
726
+ }