@solidrt/3d 0.0.53 → 0.0.55
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 +121 -28
- package/examples/README.md +7 -0
- package/examples/cascades.tsx +21 -3
- package/examples/fog.tsx +174 -0
- package/package.json +3 -3
- package/src/components.tsx +11 -1
- package/src/glsl.ts +47 -0
- package/src/gltf.ts +22 -4
- package/src/index.ts +1 -1
- package/src/material.ts +248 -99
- package/src/model.ts +17 -3
- package/src/scene.ts +91 -1
package/AGENTS.md
CHANGED
|
@@ -131,9 +131,12 @@ blendMode and pointer events like any element.
|
|
|
131
131
|
the upload), so swapping `<Mesh geometry>` reactively never accumulates
|
|
132
132
|
old generations. `disposeGeometry` is the immediate explicit free.
|
|
133
133
|
- Materials dedupe hard: one program + one pipeline per material CLASS
|
|
134
|
-
(
|
|
135
|
-
|
|
136
|
-
|
|
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`).
|
|
137
140
|
- The pure pieces (`math.ts`, `order.ts`, `geometry.ts`,
|
|
138
141
|
`profile.ts`, `sweep.ts`, `gltf.ts`, `model-file.ts`) are Solid-free and
|
|
139
142
|
GPU-free BY DESIGN so they can be checked headless (and, for the two
|
|
@@ -149,7 +152,7 @@ blendMode and pointer events like any element.
|
|
|
149
152
|
|
|
150
153
|
| Component | Props |
|
|
151
154
|
| --- | --- |
|
|
152
|
-
| `Scene` | `width`, `height` (target pixels), `clearColor?`, `background?` (fragment GLSL), `samples?` (1/2/4/8 MSAA), `label?`, `ref?(scene)`, `output?(texture)`, `events?` (mesh pointer events, default on) |
|
|
155
|
+
| `Scene` | `width`, `height` (target pixels), `clearColor?`, `background?` (fragment GLSL), `fog?` (`{ color, near, far }`, linear by camera distance), `samples?` (1/2/4/8 MSAA), `label?`, `ref?(scene)`, `output?(texture)`, `events?` (mesh pointer events, default on) |
|
|
153
156
|
| `Group` | `position?`, `rotation?` (Euler radians, XYZ order), `quaternion?` (either, not both), `scale?` (number = uniform), `visible?`, pointer events (below), `ref?(node)` |
|
|
154
157
|
| `Mesh` | `geometry`, `material`, transforms as Group, `params?` (per-mesh uniforms, merge semantics - no unset), pointer events (below), `ref?(mesh)` |
|
|
155
158
|
| `Sprite` | as Mesh minus `geometry`: a camera-facing unit quad, `scale` is its world size, rotation is ignored; pair with a `sprite()` material |
|
|
@@ -168,6 +171,31 @@ elsewhere. Called once, untracked, inside the scene context. Scene
|
|
|
168
171
|
layout, so render and display size separate - render at 2x and display
|
|
169
172
|
smaller for supersampling.
|
|
170
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
|
+
|
|
171
199
|
Camera control: `createOrbitCamera(scene, { target?, azimuth?, elevation?,
|
|
172
200
|
distance?, min/maxDistance?, min/maxElevation?, orbitSpeed?, rotateSpeed?,
|
|
173
201
|
zoomSpeed?, zoomAnchor?, rotateAnchor?, panSpeed?, viewport?, clampTarget? })`
|
|
@@ -321,8 +349,10 @@ uint16/uint32 indices by vertex count automatically.
|
|
|
321
349
|
|
|
322
350
|
Materials:
|
|
323
351
|
|
|
324
|
-
- `unlit({ color?, map? })` -
|
|
325
|
-
internally
|
|
352
|
+
- `unlit({ color?, map?, transparent?, cull?, alphaTest?, fog? })` -
|
|
353
|
+
straight `[r, g, b, a?]` 0..1, premultiplied internally; `cull` and
|
|
354
|
+
`alphaTest` as on lit (a mapped cutout casts its cutout); `fog: false`
|
|
355
|
+
opts out of the scene's fog (all three standard materials take it).
|
|
326
356
|
- `sprite({ color?, map?, transparent?, billboard? })` - unlit on a quad
|
|
327
357
|
that turns to face the camera IN THE VERTEX STAGE (off the shared
|
|
328
358
|
uCamRight/uCamUp, or uCamPos for `billboard: "fixed-y"`, which yaws
|
|
@@ -416,6 +446,37 @@ a background is static art - anything animated is a mesh's own
|
|
|
416
446
|
shaderMaterial (or, until blend factors land, a separate shader texture
|
|
417
447
|
underneath, which translucent grounds also still need).
|
|
418
448
|
|
|
449
|
+
Fog: `scene.setFog(fog | null)`, the `fog` option on createScene and
|
|
450
|
+
the reactive `Scene` prop, in Three's two shapes: linear `{ color, near,
|
|
451
|
+
far }` (`Fog`; fades from near to far, fully fogged past far) or exp2
|
|
452
|
+
`{ color, density }` (`FogExp2`, Unity's default; `1 - exp(-(d *
|
|
453
|
+
density)^2)`, no start band, never quite opaque - 0.01 is ~63% at 100
|
|
454
|
+
units). Either form takes `height` + `heightFalloff` (Godot's fog
|
|
455
|
+
height, Unreal's height falloff): full fog at and below `height` (world
|
|
456
|
+
y, default 0), thinning by `exp(-(y - height) * heightFalloff)` above -
|
|
457
|
+
a valley fills, the hilltops and the sky stay clear; per fragment
|
|
458
|
+
height, not integrated along the ray, the cheap tier every engine ships
|
|
459
|
+
first. A fragment fades toward `color` by its RADIAL distance from
|
|
460
|
+
`uCamPos` (not view depth). It is ONE shared-params write (`uFogColor`,
|
|
461
|
+
`uFogNear`, `uFogInv` = 1/(far-near), `uFogDensity`, `uFogHeight`,
|
|
462
|
+
`uFogHeightFalloff`; the form not in use is 0, "no fog" is every rate
|
|
463
|
+
0, which the scene seeds at creation so there is no enable flag and no
|
|
464
|
+
branch - the shader takes the larger of the two distance factors times
|
|
465
|
+
the height term), fanned out to every view, so fogging costs nothing
|
|
466
|
+
per frame however many meshes. Every standard material (unlit,
|
|
467
|
+
lit, sprite) composes it after its alphaTest discard, mixed at the alpha
|
|
468
|
+
it writes (premultiplied stays premultiplied); `fog: false` on the
|
|
469
|
+
material drops the code from the program (Three's `material.fog`) - a
|
|
470
|
+
sky sphere, a far backdrop. A shaderMaterial opts in by composing `FOG`
|
|
471
|
+
from `/glsl` (declares the set; `fog(rgb, alpha, worldPos, camPos)`, or
|
|
472
|
+
`fogAdditive(rgb, worldPos, camPos)` for a `blend: "add"` look, which
|
|
473
|
+
fades toward black instead of the fog color).
|
|
474
|
+
The BACKGROUND is not fogged: it is entry zero with no depth or
|
|
475
|
+
distance, so match the fog color to `clearColor` or the background's
|
|
476
|
+
horizon, and put `far` at or inside the camera's far plane to hide the
|
|
477
|
+
clip. `examples/fog.tsx` cycles the forms over a valley;
|
|
478
|
+
`examples/cascades.tsx` fogs its field to the sky.
|
|
479
|
+
|
|
419
480
|
Lighting GLSL (`@solidrt/3d/glsl`): exported string constants composed
|
|
420
481
|
into shaderMaterial sources with plain template literals - `LIT_VERTEX`
|
|
421
482
|
(the standard vertex stage: clip position plus vWorldPos/vNormal/vUv
|
|
@@ -472,17 +533,27 @@ the directional list, Lambert diffuse, Blinn-Phong highlight when
|
|
|
472
533
|
`specular` (0..1 strength) is set with `shininess` (default 30), the
|
|
473
534
|
same `color`/`map`/`transparent` as unlit, `vertexColors: true` to
|
|
474
535
|
multiply by the colored layout's aColor (so the geometry must carry it),
|
|
475
|
-
|
|
476
|
-
world unit, blended across the three axis planes by the normal
|
|
536
|
+
`triplanar: n` to sample `map` by world position at `n` repeats per
|
|
537
|
+
world unit, blended across the three axis planes by the normal, and
|
|
538
|
+
`alphaTest: t` for a cutout (a fragment whose final alpha is below `t`
|
|
539
|
+
is discarded; Three's alphaTest, glTF MASK): opaque, depth-written, no
|
|
540
|
+
sorting, usually with `cull: "none"` for cards. Triplanar
|
|
477
541
|
is an OPTION, not the default: generators emit 0..1 UVs per face, so a
|
|
478
542
|
map on a plane is a decal (UV) while a map on generated scenery wants one
|
|
479
543
|
density across parts of any size (triplanar); the map must be created
|
|
480
|
-
with `wrap: "repeat"`.
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
`
|
|
485
|
-
|
|
544
|
+
with `wrap: "repeat"`. Any `map` on a surface seen at distance also wants
|
|
545
|
+
`mipmap: true` at creation, or it aliases as it recedes, and a tiled
|
|
546
|
+
surface seen at a grazing angle (a floor, a road) wants `anisotropy: 4`
|
|
547
|
+
or more beside it, or trilinear smears the far half into the mip its long
|
|
548
|
+
axis picked (`createModel` uploads its images with both; the device clamps
|
|
549
|
+
the level, `limits.maxAnisotropy` reports it). Internally one `shaderMaterialClass` per option
|
|
550
|
+
combination (map x vertexColors x triplanar x transparent x cull x
|
|
551
|
+
alphaTest), cached for the app's lifetime, one pipeline per vertex layout
|
|
552
|
+
- a thousand lit meshes share one program. The view vector comes from the
|
|
553
|
+
shared uCamPos; `uTriplanar` and `uAlphaTest` are declared only by the
|
|
554
|
+
classes that use them (the cutoff is a per-entry value, so every
|
|
555
|
+
alphaTest material shares one class) so the other classes do not warn
|
|
556
|
+
about an inactive uniform.
|
|
486
557
|
|
|
487
558
|
## Models
|
|
488
559
|
|
|
@@ -494,7 +565,8 @@ next to it, or single-file .glb) and become a Group of meshes, Three's
|
|
|
494
565
|
bun and on flux): `ModelData` = `parts` (one per mesh node, its NAME
|
|
495
566
|
kept, vertices in the standard layout with the node's WORLD transform
|
|
496
567
|
baked in), `materials` (base color factor, `map` = index into `images`,
|
|
497
|
-
`doubleSided`, `transparent` = alphaMode BLEND
|
|
568
|
+
`doubleSided`, `transparent` = alphaMode BLEND, `alphaMode` as written
|
|
569
|
+
and `alphaCutoff`, spec default 0.5), `images` (the encoded
|
|
498
570
|
PNG/JPEG bytes, undecoded) and `bounds`. A .gltf's external files come
|
|
499
571
|
through `resolve(uri)` (uri as written, still percent-encoded;
|
|
500
572
|
`gltfExternalUris(bytes)` lists them so an async caller can read them
|
|
@@ -507,7 +579,7 @@ next to it, or single-file .glb) and become a Group of meshes, Three's
|
|
|
507
579
|
Blender exports Draco by DEFAULT, so that is the first error a real
|
|
508
580
|
file hits.
|
|
509
581
|
- `createModel(data, { material?, label? })` - uploads the images (repeat
|
|
510
|
-
wrap, mipmapped), makes one material per glTF material (default `lit({
|
|
582
|
+
wrap, mipmapped, 4x anisotropic), makes one material per glTF material (default `lit({
|
|
511
583
|
color, map, transparent })`; pass `material(m, map)` for anything else,
|
|
512
584
|
it is called once per material and shared), one mesh per part, all
|
|
513
585
|
children of the returned `Model` (a Group): `add(scene.root, model)`,
|
|
@@ -526,10 +598,10 @@ next to it, or single-file .glb) and become a Group of meshes, Three's
|
|
|
526
598
|
"binary" }` then `createModel(parseGltf(bytes))`, see
|
|
527
599
|
`examples/model.tsx`); bake anything big.
|
|
528
600
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
and further UV sets
|
|
532
|
-
|
|
601
|
+
Applied: `doubleSided` (the default material draws it with `cull:
|
|
602
|
+
"none"`) and alphaMode MASK (`alphaTest: alphaCutoff`). Not in the
|
|
603
|
+
subset, dropped: vertex colors, tangents and further UV sets; samplers
|
|
604
|
+
are ignored (every texture repeats); emissive/additive parts of a model
|
|
533
605
|
draw as their base color (a model's "glow" cards come out as dark wedges).
|
|
534
606
|
The follow-ups are filed in okf/backlog/3d-model-loader.md.
|
|
535
607
|
|
|
@@ -568,7 +640,13 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
|
|
|
568
640
|
are opaque to the library, so any inferred box would be a guess. Supply
|
|
569
641
|
`bounds` for anything pickable or transparent.
|
|
570
642
|
- Transparency is an EXPLICIT material flag, Three's rule: `unlit({ color:
|
|
571
|
-
[r, g, b, 0.5] })` still draws opaque
|
|
643
|
+
[r, g, b, 0.5] })` still draws opaque, and opaque means it: the standard
|
|
644
|
+
classes write alpha 1 when not `transparent` (the scene target is
|
|
645
|
+
composited premultiplied, so a leaked texel or color alpha would punch
|
|
646
|
+
a see-through hole in an opaque draw - the source of "white cutouts"
|
|
647
|
+
on an alpha-mapped model drawn without alphaTest). A `shaderMaterial`
|
|
648
|
+
writes its own fragColor: give an opaque look alpha 1 too.
|
|
649
|
+
`unlit({ ..., transparent: true })`
|
|
572
650
|
(or `shaderMaterial({ transparent: true })`) builds the pipeline with
|
|
573
651
|
`blend: "alpha"` and `depthWrite: false` (depth test stays on, so it hides
|
|
574
652
|
behind opaques without occluding other translucents). The one inference:
|
|
@@ -675,7 +753,14 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
|
|
|
675
753
|
`shadow.normalBias` (world units along the receiver normal, the one to
|
|
676
754
|
reach for first, ~0.02); the depth pass culls FRONT faces (Three's
|
|
677
755
|
shadowSide default), so closed casters need little bias but a
|
|
678
|
-
|
|
756
|
+
back-culling plane casts only from its back. The shadow side follows
|
|
757
|
+
the material's `cull` (Three's shadowSide rule, Godot's shadow pass):
|
|
758
|
+
a `cull: "none"` foliage card or pane casts from both faces, and a
|
|
759
|
+
UV-mapped `alphaTest` material casts its cutout (leaves, not
|
|
760
|
+
rectangles), through the `Material.shadow` variant the standard
|
|
761
|
+
classes carry (a `shaderMaterial` gets the cull side from its `cull`
|
|
762
|
+
and supplies its own cutout variant as the `shadow` instance option).
|
|
763
|
+
Opting out of receiving is on the
|
|
679
764
|
MATERIAL here (`receiveShadow: false`), not the object (Three's
|
|
680
765
|
`mesh.receiveShadow`) - Godot's split, and URP's - and instanced
|
|
681
766
|
meshes never cast (the depth override cannot know their records) - the
|
|
@@ -752,12 +837,13 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
|
|
|
752
837
|
vertex source - a comment counts - selects the "colored" layout, and
|
|
753
838
|
the material then rejects standard geometry at add(). Do not mention
|
|
754
839
|
aColor you do not read.
|
|
755
|
-
- Picking is
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
840
|
+
- Picking is triangle-accurate for ordinary meshes (`point` is a surface
|
|
841
|
+
point, hits carry `face`/`uv`/`normal`) but box-only for instanced
|
|
842
|
+
meshes: there `point` is the entry point of the population `bounds`
|
|
843
|
+
box, and `face`/`uv`/`normal` are absent. Never present an instanced
|
|
844
|
+
hit as a surface hit. Both tiers run in the spatial core (Rust); never
|
|
845
|
+
add a per-triangle path in JS - rays at mesh scale are
|
|
846
|
+
interpreter-hostile, and the core already does it.
|
|
761
847
|
- `scene.handlers` vs `handlersFor`: localX/localY arrive in the leaf's
|
|
762
848
|
LAYOUT frame (every ancestor transform and design-size fit is already
|
|
763
849
|
undone by the element hit test). `handlers` therefore assumes leaf
|
|
@@ -766,6 +852,13 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
|
|
|
766
852
|
leaf under a design size. Only a leaf whose layout size deliberately
|
|
767
853
|
differs from the target (supersampling) needs `handlersFor`, fed the
|
|
768
854
|
layout size the app itself set.
|
|
855
|
+
- Scene-wide effects reach a custom material ONLY by composition: a
|
|
856
|
+
`shaderMaterial` that does not compose `FOG` is unfogged, one that does
|
|
857
|
+
not compose the `SHADOW_*` trio is unshadowed, and since every
|
|
858
|
+
instanced mesh has a custom material, an instanced forest stays crisp
|
|
859
|
+
in a fogged scene until its fragment calls `fog()`. The engine cannot
|
|
860
|
+
inject it (what you declare is what runs); check both when a custom
|
|
861
|
+
look sits beside standard ones and reads wrong at distance.
|
|
769
862
|
- Hover (enter/leave) reacts to pointer MOTION only: a mesh animating
|
|
770
863
|
under a still pointer fires nothing until the next move - the same
|
|
771
864
|
limit the element hit test has (hit-test-per-frame is an open platform
|
package/examples/README.md
CHANGED
|
@@ -58,6 +58,13 @@ depends on `@solidrt/3d` (or in-repo from the package directory).
|
|
|
58
58
|
the plain box widened to cover the field: one map's texels spread over
|
|
59
59
|
it, blocky everywhere) and the `cascades`/`fly` debug commands set the
|
|
60
60
|
count and the shadow distance and park the flight.
|
|
61
|
+
- `fog.tsx` - scene fog over a valley of pines between two ridges: a
|
|
62
|
+
click cycles LINEAR (`{ near, far }`, a clear band then a fade to the
|
|
63
|
+
far plane), EXP2 (`{ density }`, thickening from the first metre) and
|
|
64
|
+
HEIGHT (`heightFalloff`: the valley floor fills, the hilltops and sky
|
|
65
|
+
stay clear), then off; two suns show the material opt-out (`unlit({
|
|
66
|
+
fog: false })` stays bright, its twin fogs). The `fog` debug command
|
|
67
|
+
sets the mode and its knobs and `pan` parks the camera.
|
|
61
68
|
- `model.tsx` - a model from a file: `model.glb` (a small rover with
|
|
62
69
|
nested node transforms, a mirrored node, a textured material, a
|
|
63
70
|
transparent dome and a mesh without normals) parsed with `parseGltf`
|
package/examples/cascades.tsx
CHANGED
|
@@ -14,8 +14,11 @@
|
|
|
14
14
|
// The `cascades` debug command sets the count and the shadow distance
|
|
15
15
|
// (`{ count, distance }`; the range the cascades split, the camera's far
|
|
16
16
|
// by default - pulling it in sharpens every cascade) and `fly` parks the
|
|
17
|
-
// flight (`{ t: seconds }`), so a capture repeats.
|
|
18
|
-
|
|
17
|
+
// flight (`{ t: seconds }`), so a capture repeats. The field is fogged
|
|
18
|
+
// toward the sky color from FOG_NEAR to FOG_FAR, inside the camera's far
|
|
19
|
+
// plane, so the far pillars sink into the horizon instead of clipping
|
|
20
|
+
// (`examples/fog.tsx` is the fog tour).
|
|
21
|
+
import { createSignal, flush, onFrame, pct, render } from "@solidrt/core"
|
|
19
22
|
import { registerDebug } from "srt:dev"
|
|
20
23
|
import { box, DirectionalLight, HemisphereLight, lit, Mesh, PerspectiveCamera, plane, Scene, sphere } from "@solidrt/3d"
|
|
21
24
|
import type { Geometry, Vec3 } from "@solidrt/3d"
|
|
@@ -27,6 +30,12 @@ const FAR = 200
|
|
|
27
30
|
const RADIUS = 50
|
|
28
31
|
const HEIGHT = 5
|
|
29
32
|
const PERIOD = 90
|
|
33
|
+
// The sky, shared by the clear and the fog so the horizon has no band.
|
|
34
|
+
const SKY: [number, number, number] = [0.6, 0.72, 0.88]
|
|
35
|
+
// The fog band: clear up to FOG_NEAR, fully sky at FOG_FAR (the far edge
|
|
36
|
+
// of the field is about 150 units out at the flight's radius).
|
|
37
|
+
const FOG_NEAR = 30
|
|
38
|
+
const FOG_FAR = 150
|
|
30
39
|
|
|
31
40
|
let [cascades, setCascades] = createSignal(3)
|
|
32
41
|
let [distance, setDistance] = createSignal<number | null>(null)
|
|
@@ -37,6 +46,7 @@ let parked: number | null = null
|
|
|
37
46
|
registerDebug("cascades", (args?: Record<string, unknown>) => {
|
|
38
47
|
if (typeof args?.count === "number") setCascades(args.count)
|
|
39
48
|
if (typeof args?.distance === "number" || args?.distance === null) setDistance(args.distance)
|
|
49
|
+
flush()
|
|
40
50
|
return { cascades: cascades(), distance: distance() }
|
|
41
51
|
})
|
|
42
52
|
registerDebug("fly", (args?: Record<string, unknown>) => {
|
|
@@ -46,6 +56,7 @@ registerDebug("fly", (args?: Record<string, unknown>) => {
|
|
|
46
56
|
} else if (args?.t === null) {
|
|
47
57
|
parked = null
|
|
48
58
|
}
|
|
59
|
+
flush()
|
|
49
60
|
return { t: time(), parked: parked !== null }
|
|
50
61
|
})
|
|
51
62
|
|
|
@@ -87,7 +98,14 @@ function App() {
|
|
|
87
98
|
return (
|
|
88
99
|
<window>
|
|
89
100
|
<view width={pct(100)} height={pct(100)} designSize={[SIZE, SIZE]} onPointerDown={() => setCascades(c => (c % 4) + 1)}>
|
|
90
|
-
<Scene
|
|
101
|
+
<Scene
|
|
102
|
+
width={SIZE}
|
|
103
|
+
height={SIZE}
|
|
104
|
+
clearColor={[SKY[0], SKY[1], SKY[2], 1]}
|
|
105
|
+
fog={{ color: SKY, near: FOG_NEAR, far: FOG_FAR }}
|
|
106
|
+
samples={4}
|
|
107
|
+
label="cascades"
|
|
108
|
+
>
|
|
91
109
|
<PerspectiveCamera fov={50} near={0.5} far={FAR} position={eye()} lookAt={ahead()} />
|
|
92
110
|
<HemisphereLight sky={[0.5, 0.58, 0.7]} ground={[0.25, 0.22, 0.18]} />
|
|
93
111
|
<DirectionalLight
|
package/examples/fog.tsx
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Scene fog, all three forms over one valley: a camera on a hillside
|
|
2
|
+
// panning slowly across a floor of pines between two ridges, the far
|
|
3
|
+
// ridge near the camera's far plane. LINEAR fog (`{ near, far }`,
|
|
4
|
+
// Three's Fog) is a clear band then a fade, and hides the clip when
|
|
5
|
+
// `far` sits at the camera's; EXP2 (`{ density }`, Three's FogExp2 /
|
|
6
|
+
// Unity's default) thickens from the first metre with no band and
|
|
7
|
+
// never quite closes; HEIGHT (`heightFalloff` on either) fills the
|
|
8
|
+
// valley floor and thins on the way up, so the hilltops and the sky
|
|
9
|
+
// stay clear while the pines below drown - per fragment height, the
|
|
10
|
+
// cheap tier. Fog is one shared-params write per change, whatever the
|
|
11
|
+
// mesh count, and every standard material takes it; the two suns show
|
|
12
|
+
// the opt-out: the left one is `unlit({ fog: false })` and stays
|
|
13
|
+
// bright in every mode, the right one fogs like everything else.
|
|
14
|
+
//
|
|
15
|
+
// A click cycles linear -> exp2 -> height -> off. The `fog` debug
|
|
16
|
+
// command sets the mode and its knobs (`{ mode: "linear" | "exp2" |
|
|
17
|
+
// "height" | "off", near, far, density, height, falloff }`) and returns
|
|
18
|
+
// the state; `pan` parks the camera (`{ t: seconds }`), so a capture
|
|
19
|
+
// repeats.
|
|
20
|
+
import { createSignal, flush, onFrame, pct, render } from "@solidrt/core"
|
|
21
|
+
import { registerDebug } from "srt:dev"
|
|
22
|
+
import { cone, cylinder, DirectionalLight, HemisphereLight, lit, Mesh, PerspectiveCamera, plane, Scene, sphere, unlit } from "@solidrt/3d"
|
|
23
|
+
import type { FogOptions, Vec3 } from "@solidrt/3d"
|
|
24
|
+
|
|
25
|
+
const SIZE = 720
|
|
26
|
+
const FAR = 400
|
|
27
|
+
// The sky, shared by the clear and the fog so the horizon has no band.
|
|
28
|
+
const SKY: [number, number, number] = [0.72, 0.78, 0.86]
|
|
29
|
+
// The camera: on the near hillside, panning slowly across the valley.
|
|
30
|
+
const EYE: Vec3 = [-40, 42, 140]
|
|
31
|
+
const PAN_PERIOD = 60
|
|
32
|
+
// How far the look-at point swings left and right of the valley axis.
|
|
33
|
+
const PAN_SWING = 70
|
|
34
|
+
// The linear band, the exp2 thickness and the height layer.
|
|
35
|
+
const NEAR = 40
|
|
36
|
+
const LINEAR_FAR = FAR
|
|
37
|
+
const DENSITY = 0.006
|
|
38
|
+
const HEIGHT = 10
|
|
39
|
+
const HEIGHT_FALLOFF = 0.12
|
|
40
|
+
|
|
41
|
+
type Mode = "linear" | "exp2" | "height" | "off"
|
|
42
|
+
const MODES: Mode[] = ["linear", "exp2", "height", "off"]
|
|
43
|
+
|
|
44
|
+
let [mode, setMode] = createSignal<Mode>("linear")
|
|
45
|
+
let [near, setNear] = createSignal(NEAR)
|
|
46
|
+
let [far, setFar] = createSignal(LINEAR_FAR)
|
|
47
|
+
let [density, setDensity] = createSignal(DENSITY)
|
|
48
|
+
let [height, setHeight] = createSignal(HEIGHT)
|
|
49
|
+
let [falloff, setFalloff] = createSignal(HEIGHT_FALLOFF)
|
|
50
|
+
let [time, setTime] = createSignal(0)
|
|
51
|
+
let parked: number | null = null
|
|
52
|
+
|
|
53
|
+
let num = (v: unknown, set: (n: number) => void) => {
|
|
54
|
+
if (typeof v === "number") set(v)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
registerDebug("fog", (args?: Record<string, unknown>) => {
|
|
58
|
+
if (typeof args?.mode === "string" && MODES.includes(args.mode as Mode)) setMode(args.mode as Mode)
|
|
59
|
+
num(args?.near, setNear)
|
|
60
|
+
num(args?.far, setFar)
|
|
61
|
+
num(args?.density, setDensity)
|
|
62
|
+
num(args?.height, setHeight)
|
|
63
|
+
num(args?.falloff, setFalloff)
|
|
64
|
+
flush()
|
|
65
|
+
return { mode: mode(), near: near(), far: far(), density: density(), height: height(), falloff: falloff() }
|
|
66
|
+
})
|
|
67
|
+
registerDebug("pan", (args?: Record<string, unknown>) => {
|
|
68
|
+
if (typeof args?.t === "number") {
|
|
69
|
+
parked = args.t
|
|
70
|
+
setTime(args.t)
|
|
71
|
+
} else if (args?.t === null) {
|
|
72
|
+
parked = null
|
|
73
|
+
}
|
|
74
|
+
flush()
|
|
75
|
+
return { t: time(), parked: parked !== null }
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
function fog(): FogOptions | undefined {
|
|
79
|
+
switch (mode()) {
|
|
80
|
+
case "linear":
|
|
81
|
+
return { color: SKY, near: near(), far: far() }
|
|
82
|
+
case "exp2":
|
|
83
|
+
return { color: SKY, density: density() }
|
|
84
|
+
case "height":
|
|
85
|
+
return { color: SKY, density: density(), height: height(), heightFalloff: falloff() }
|
|
86
|
+
case "off":
|
|
87
|
+
return undefined
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The two ridges: flattened spheres sunk into the ground, and the height
|
|
92
|
+
// they give the ground at (x, z) so the pines stand on them.
|
|
93
|
+
type Hill = { position: Vec3; scale: Vec3 }
|
|
94
|
+
let hills: Hill[] = [
|
|
95
|
+
{ position: [-90, -8, 120], scale: [110, 44, 90] },
|
|
96
|
+
{ position: [60, -6, -140], scale: [150, 40, 90] },
|
|
97
|
+
{ position: [-140, -10, -60], scale: [90, 36, 120] },
|
|
98
|
+
{ position: [150, -4, 40], scale: [80, 26, 70] },
|
|
99
|
+
]
|
|
100
|
+
function groundHeight(x: number, z: number): number {
|
|
101
|
+
let y = 0
|
|
102
|
+
for (let h of hills) {
|
|
103
|
+
let dx = (x - h.position[0]) / h.scale[0]
|
|
104
|
+
let dz = (z - h.position[2]) / h.scale[2]
|
|
105
|
+
let r = 1 - dx * dx - dz * dz
|
|
106
|
+
if (r > 0) y = Math.max(y, h.position[1] + h.scale[1] * Math.sqrt(r))
|
|
107
|
+
}
|
|
108
|
+
return y
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The pines, on a jittered grid over the valley and up the ridges.
|
|
112
|
+
type Pine = { position: Vec3; size: number }
|
|
113
|
+
let pines: Pine[] = []
|
|
114
|
+
let step = 18
|
|
115
|
+
for (let i = -12; i <= 12; i++) {
|
|
116
|
+
for (let j = -12; j <= 12; j++) {
|
|
117
|
+
let x = i * step + ((i * 7 + j * 13) % 9) - 4
|
|
118
|
+
let z = j * step + ((i * 11 + j * 5) % 9) - 4
|
|
119
|
+
let y = groundHeight(x, z)
|
|
120
|
+
// No pines on the near hillside under the camera, none above the tree line.
|
|
121
|
+
if (y > 30 || (x < -20 && z > 90)) continue
|
|
122
|
+
let size = 5 + ((i * 3 + j * 5 + 20) % 5)
|
|
123
|
+
pines.push({ position: [x, y, z], size })
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function App() {
|
|
128
|
+
onFrame(tick => {
|
|
129
|
+
if (parked === null) setTime(tick / 1000)
|
|
130
|
+
})
|
|
131
|
+
let lookAt = () => {
|
|
132
|
+
let a = Math.sin((time() / PAN_PERIOD) * 2 * Math.PI)
|
|
133
|
+
return [a * PAN_SWING, 6, -60] as Vec3
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let ground = lit({ color: [0.36, 0.48, 0.3] })
|
|
137
|
+
let rock = lit({ color: [0.5, 0.47, 0.42] })
|
|
138
|
+
let needles = lit({ color: [0.16, 0.36, 0.22] })
|
|
139
|
+
let trunk = lit({ color: [0.35, 0.25, 0.16] })
|
|
140
|
+
let sunLit = unlit({ color: [1, 0.92, 0.6], fog: false })
|
|
141
|
+
let sunFogged = unlit({ color: [1, 0.92, 0.6] })
|
|
142
|
+
|
|
143
|
+
let floor = plane({ width: 800, height: 800 })
|
|
144
|
+
let hill = sphere({ radius: 1, widthSegments: 48, heightSegments: 24 })
|
|
145
|
+
let crown = cone({ radius: 0.5, height: 1, radialSegments: 10 })
|
|
146
|
+
let stem = cylinder({ radiusTop: 0.08, radiusBottom: 0.1, height: 1, radialSegments: 6 })
|
|
147
|
+
let sun = sphere({ radius: 14, widthSegments: 24, heightSegments: 12 })
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<window>
|
|
151
|
+
<view width={pct(100)} height={pct(100)} designSize={[SIZE, SIZE]} onPointerDown={() => setMode(m => MODES[(MODES.indexOf(m) + 1) % MODES.length] ?? "linear")}>
|
|
152
|
+
<Scene width={SIZE} height={SIZE} clearColor={[SKY[0], SKY[1], SKY[2], 1]} fog={fog()} samples={4} label="fog">
|
|
153
|
+
<PerspectiveCamera fov={55} near={0.5} far={FAR} position={EYE} lookAt={lookAt()} />
|
|
154
|
+
<HemisphereLight sky={[0.55, 0.62, 0.75]} ground={[0.28, 0.24, 0.2]} />
|
|
155
|
+
<DirectionalLight color={[0.9, 0.85, 0.75]} rotation={[-0.9, 0.6, 0]} />
|
|
156
|
+
<Mesh geometry={floor} material={ground} rotation={[-Math.PI / 2, 0, 0]} />
|
|
157
|
+
{hills.map(h => (
|
|
158
|
+
<Mesh geometry={hill} material={rock} position={h.position} scale={h.scale} />
|
|
159
|
+
))}
|
|
160
|
+
{pines.map(p => (
|
|
161
|
+
<>
|
|
162
|
+
<Mesh geometry={stem} material={trunk} position={[p.position[0], p.position[1] + p.size * 0.15, p.position[2]]} scale={[p.size, p.size * 0.3, p.size]} />
|
|
163
|
+
<Mesh geometry={crown} material={needles} position={[p.position[0], p.position[1] + p.size * 0.3 + p.size * 0.5, p.position[2]]} scale={[p.size * 0.9, p.size, p.size * 0.9]} />
|
|
164
|
+
</>
|
|
165
|
+
))}
|
|
166
|
+
<Mesh geometry={sun} material={sunLit} position={[-30, 100, -220]} />
|
|
167
|
+
<Mesh geometry={sun} material={sunFogged} position={[30, 100, -220]} />
|
|
168
|
+
</Scene>
|
|
169
|
+
</view>
|
|
170
|
+
</window>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
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.55",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"funding": "https://github.com/sponsors/wellawaretech",
|
|
6
6
|
"author": "Antoine van Wel",
|
|
@@ -18,7 +18,7 @@
|
|
|
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.4",
|
|
22
|
+
"@solidrt/core": "0.0.55"
|
|
23
23
|
}
|
|
24
24
|
}
|
package/src/components.tsx
CHANGED
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
setVisible,
|
|
32
32
|
} from "./scene.ts"
|
|
33
33
|
import type { ShaderParams } from "@solidrt/core/gpu"
|
|
34
|
-
import type { DirectionalLight as DirectionalLightNode, HemisphereLight as HemisphereLightNode, InstancedMesh as InstancedMeshNode, Mesh as MeshNode, Scene as SceneHandle, SceneNode, ScenePointerEvent, ShadowOptions } from "./scene.ts"
|
|
34
|
+
import type { DirectionalLight as DirectionalLightNode, FogOptions, HemisphereLight as HemisphereLightNode, InstancedMesh as InstancedMeshNode, Mesh as MeshNode, Scene as SceneHandle, SceneNode, ScenePointerEvent, ShadowOptions } from "./scene.ts"
|
|
35
35
|
import type { Geometry } from "./geometry.ts"
|
|
36
36
|
import type { Material } from "./material.ts"
|
|
37
37
|
import type { Quat, Vec3 } from "./math.ts"
|
|
@@ -107,6 +107,12 @@ export type SceneProps = {
|
|
|
107
107
|
* source replaces the background; undefined removes it. Three's
|
|
108
108
|
* `scene.background = color` is `clearColor` here. */
|
|
109
109
|
background?: string
|
|
110
|
+
/** Scene-wide fog (scene.setFog): linear `{ color, near, far }` or exp2
|
|
111
|
+
* `{ color, density }`, optionally thinning above `height` by
|
|
112
|
+
* `heightFalloff`; every standard material fades toward `color` by
|
|
113
|
+
* distance from the camera. Reactive; undefined removes it. Match
|
|
114
|
+
* `color` to clearColor or the background, which is not fogged. */
|
|
115
|
+
fog?: FogOptions
|
|
110
116
|
label?: string
|
|
111
117
|
/** Multisample count (1, 2, 4 or 8; default 1): anti-aliased mesh edges.
|
|
112
118
|
* Fixed at creation. */
|
|
@@ -149,6 +155,10 @@ export let Scene: ParentComponent<SceneProps> = props => {
|
|
|
149
155
|
() => props.background,
|
|
150
156
|
b => scene.setBackground(b ?? null),
|
|
151
157
|
)
|
|
158
|
+
createEffect(
|
|
159
|
+
() => props.fog,
|
|
160
|
+
f => scene.setFog(f ?? null),
|
|
161
|
+
)
|
|
152
162
|
untrack(() => props.ref)?.(scene)
|
|
153
163
|
let output = untrack(() => props.output)
|
|
154
164
|
let events = untrack(() => props.events) !== false
|
package/src/glsl.ts
CHANGED
|
@@ -127,6 +127,53 @@ export const FRESNEL = glsl`
|
|
|
127
127
|
}
|
|
128
128
|
`
|
|
129
129
|
|
|
130
|
+
/**
|
|
131
|
+
* The scene's fog (`scene.setFog`): the uniform set it writes - `uFogColor`,
|
|
132
|
+
* the linear band `uFogNear` / `uFogInv` (1 / (far - near)), the exp2
|
|
133
|
+
* `uFogDensity`, and the height attenuation `uFogHeight` /
|
|
134
|
+
* `uFogHeightFalloff`; the form not in use is 0, a fogless scene writes
|
|
135
|
+
* every rate 0, so the factor is 0 with no branch and no enable flag -
|
|
136
|
+
* plus `vec3 fog(vec3 rgb, float alpha, vec3 worldPos, vec3 camPos)`: the
|
|
137
|
+
* factor by the RADIAL distance from the camera (the larger of the two
|
|
138
|
+
* forms), thinned by `exp(-(y - height) * falloff)` above the fog height,
|
|
139
|
+
* mixing toward the fog color at the fragment's written alpha
|
|
140
|
+
* (premultiplied output stays premultiplied). Compose it last, after the
|
|
141
|
+
* alphaTest discard, with the alpha you are about to write:
|
|
142
|
+
*
|
|
143
|
+
* fragColor = vec4(fog(rgb, a, vWorldPos, uCamPos), a);
|
|
144
|
+
*
|
|
145
|
+
* The standard materials compose exactly this; `fog: false` on one drops
|
|
146
|
+
* it (a sky sphere, a far backdrop). The background is not fogged.
|
|
147
|
+
*
|
|
148
|
+
* An ADDITIVE blend (`blend: "add"`) must not fade toward the fog color
|
|
149
|
+
* - a distant glow would brighten into a sky-colored halo - so it uses
|
|
150
|
+
* `vec3 fogAdditive(vec3 rgb, vec3 worldPos, vec3 camPos)`, the same
|
|
151
|
+
* factor fading toward black.
|
|
152
|
+
*
|
|
153
|
+
* Only what composes one of these is fogged: a shaderMaterial that does
|
|
154
|
+
* not - and so every instanced mesh, whose material is always custom -
|
|
155
|
+
* stays crisp in a fogged scene. The engine cannot inject it for you.
|
|
156
|
+
*/
|
|
157
|
+
export const FOG = glsl`
|
|
158
|
+
uniform vec3 uFogColor;
|
|
159
|
+
uniform float uFogNear;
|
|
160
|
+
uniform float uFogInv;
|
|
161
|
+
uniform float uFogDensity;
|
|
162
|
+
uniform float uFogHeight;
|
|
163
|
+
uniform float uFogHeightFalloff;
|
|
164
|
+
vec3 fog(vec3 rgb, float alpha, vec3 worldPos, vec3 camPos) {
|
|
165
|
+
float d = distance(worldPos, camPos);
|
|
166
|
+
float linear = clamp((d - uFogNear) * uFogInv, 0.0, 1.0);
|
|
167
|
+
float dd = d * uFogDensity;
|
|
168
|
+
float exp2 = 1.0 - exp(-dd * dd);
|
|
169
|
+
float h = exp(-max(worldPos.y - uFogHeight, 0.0) * uFogHeightFalloff);
|
|
170
|
+
return mix(rgb, uFogColor * alpha, max(linear, exp2) * h);
|
|
171
|
+
}
|
|
172
|
+
vec3 fogAdditive(vec3 rgb, vec3 worldPos, vec3 camPos) {
|
|
173
|
+
return fog(rgb, 0.0, worldPos, camPos);
|
|
174
|
+
}
|
|
175
|
+
`
|
|
176
|
+
|
|
130
177
|
/**
|
|
131
178
|
* The scene's shadow set as a receiving program declares it: ONE
|
|
132
179
|
* `uShadowAtlas` (every casting light's depth map is a tile of it, so N
|
package/src/gltf.ts
CHANGED
|
@@ -30,11 +30,16 @@ export type ModelMaterial = {
|
|
|
30
30
|
color: [number, number, number, number]
|
|
31
31
|
/** Index into ModelData.images (the base color texture), or null. */
|
|
32
32
|
map: number | null
|
|
33
|
-
/** glTF doubleSided
|
|
34
|
-
*
|
|
33
|
+
/** glTF doubleSided; createModel's default material draws it with
|
|
34
|
+
* `cull: "none"`. */
|
|
35
35
|
doubleSided: boolean
|
|
36
|
-
/** alphaMode BLEND
|
|
36
|
+
/** alphaMode BLEND; createModel's default material blends it. */
|
|
37
37
|
transparent: boolean
|
|
38
|
+
/** glTF alphaMode as written (default OPAQUE). MASK is a cutout:
|
|
39
|
+
* createModel's default material draws it with `alphaTest: alphaCutoff`. */
|
|
40
|
+
alphaMode: "OPAQUE" | "MASK" | "BLEND"
|
|
41
|
+
/** glTF alphaCutoff (default 0.5); meaningful for MASK only. */
|
|
42
|
+
alphaCutoff: number
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
/** One drawable: a mesh node's primitive, vertices in WORLD space. */
|
|
@@ -67,10 +72,21 @@ const CHUNK_JSON = 0x4e4f534a
|
|
|
67
72
|
const CHUNK_BIN = 0x004e4942
|
|
68
73
|
const MODE_TRIANGLES = 4
|
|
69
74
|
|
|
75
|
+
// The spec's alphaCutoff when a MASK material leaves it out.
|
|
76
|
+
const GLTF_ALPHA_CUTOFF = 0.5
|
|
77
|
+
|
|
70
78
|
const COMPONENT_BYTES: Record<number, number> = { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 }
|
|
71
79
|
const TYPE_ELEMENTS: Record<string, number> = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 }
|
|
72
80
|
|
|
73
|
-
const DEFAULT_MATERIAL: ModelMaterial = {
|
|
81
|
+
const DEFAULT_MATERIAL: ModelMaterial = {
|
|
82
|
+
name: "default",
|
|
83
|
+
color: [1, 1, 1, 1],
|
|
84
|
+
map: null,
|
|
85
|
+
doubleSided: false,
|
|
86
|
+
transparent: false,
|
|
87
|
+
alphaMode: "OPAQUE",
|
|
88
|
+
alphaCutoff: GLTF_ALPHA_CUTOFF,
|
|
89
|
+
}
|
|
74
90
|
|
|
75
91
|
/** True when the bytes are a .glb container (the "glTF" magic). */
|
|
76
92
|
export function isGlb(bytes: Uint8Array): boolean {
|
|
@@ -171,6 +187,8 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
|
|
|
171
187
|
map,
|
|
172
188
|
doubleSided: m.doubleSided === true,
|
|
173
189
|
transparent: m.alphaMode === "BLEND",
|
|
190
|
+
alphaMode: m.alphaMode === "MASK" || m.alphaMode === "BLEND" ? m.alphaMode : "OPAQUE",
|
|
191
|
+
alphaCutoff: typeof m.alphaCutoff === "number" ? m.alphaCutoff : GLTF_ALPHA_CUTOFF,
|
|
174
192
|
}
|
|
175
193
|
})
|
|
176
194
|
// Primitives without a material draw the spec's default; it is appended
|