@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 +112 -41
- 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/gltf.ts +22 -4
- package/src/material.ts +219 -100
- package/src/math.ts +76 -0
- package/src/model.ts +7 -1
- package/src/scene.ts +316 -95
- 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
|
|
@@ -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
|
-
(
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
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: `
|
|
412
|
-
`
|
|
413
|
-
`
|
|
414
|
-
|
|
415
|
-
`
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
454
|
-
world unit, blended across the three axis planes by the normal
|
|
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
|
|
460
|
-
the app's lifetime, one pipeline per vertex layout
|
|
461
|
-
meshes share one program. The view vector comes from the
|
|
462
|
-
`uTriplanar`
|
|
463
|
-
classes
|
|
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
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
and further UV sets
|
|
510
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
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.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.
|
|
22
|
-
"@solidrt/core": "0.0.
|
|
21
|
+
"@solidjs/signals": "2.0.0-rc.3",
|
|
22
|
+
"@solidrt/core": "0.0.54"
|
|
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
|
`
|