@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 +38 -16
- package/examples/README.md +8 -0
- package/examples/cascades.tsx +121 -0
- package/package.json +4 -4
- package/src/components.tsx +2 -2
- package/src/glsl.ts +95 -40
- package/src/material.ts +5 -4
- package/src/math.ts +76 -0
- package/src/scene.ts +312 -94
- package/tools/model.ts +52 -0
- package/demos/README.md +0 -15
- package/demos/assets/icon.svg +0 -23
- package/demos/package.json +0 -9
- package/demos/src/the-third-dimension.tsx +0 -866
- package/demos/tsconfig.json +0 -15
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 (
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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: `
|
|
412
|
-
`
|
|
413
|
-
`
|
|
414
|
-
|
|
415
|
-
`
|
|
416
|
-
|
|
417
|
-
|
|
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
|
package/examples/README.md
CHANGED
|
@@ -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.
|
|
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.
|
|
22
|
-
"@solidrt/core": "0.0.
|
|
21
|
+
"@solidjs/signals": "2.0.0-rc.3",
|
|
22
|
+
"@solidrt/core": "0.0.53"
|
|
23
23
|
}
|
|
24
24
|
}
|
package/src/components.tsx
CHANGED
|
@@ -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
|
|
123
|
-
*
|
|
124
|
-
* white texel when
|
|
125
|
-
*
|
|
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
|
-
*
|
|
131
|
-
*
|
|
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
|
-
|
|
135
|
-
uniform
|
|
136
|
-
uniform
|
|
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
|
-
*
|
|
143
|
-
*
|
|
144
|
-
* casting light's clip space by
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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,
|
|
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
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
* the
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
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
|
|
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
|
-
|
|
190
|
-
|
|
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:
|
|
220
|
-
// directional light i's
|
|
221
|
-
//
|
|
222
|
-
//
|
|
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
|
+
}
|