@solidrt/3d 0.0.54 → 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 +50 -6
- 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/index.ts +1 -1
- package/src/material.ts +44 -13
- package/src/model.ts +10 -2
- package/src/scene.ts +87 -0
package/AGENTS.md
CHANGED
|
@@ -152,7 +152,7 @@ blendMode and pointer events like any element.
|
|
|
152
152
|
|
|
153
153
|
| Component | Props |
|
|
154
154
|
| --- | --- |
|
|
155
|
-
| `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) |
|
|
156
156
|
| `Group` | `position?`, `rotation?` (Euler radians, XYZ order), `quaternion?` (either, not both), `scale?` (number = uniform), `visible?`, pointer events (below), `ref?(node)` |
|
|
157
157
|
| `Mesh` | `geometry`, `material`, transforms as Group, `params?` (per-mesh uniforms, merge semantics - no unset), pointer events (below), `ref?(mesh)` |
|
|
158
158
|
| `Sprite` | as Mesh minus `geometry`: a camera-facing unit quad, `scale` is its world size, rotation is ignored; pair with a `sprite()` material |
|
|
@@ -349,9 +349,10 @@ uint16/uint32 indices by vertex count automatically.
|
|
|
349
349
|
|
|
350
350
|
Materials:
|
|
351
351
|
|
|
352
|
-
- `unlit({ color?, map?, transparent?, cull?, alphaTest? })` -
|
|
353
|
-
`[r, g, b, a?]` 0..1, premultiplied internally; `cull` and
|
|
354
|
-
as on lit (a mapped cutout casts its cutout)
|
|
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).
|
|
355
356
|
- `sprite({ color?, map?, transparent?, billboard? })` - unlit on a quad
|
|
356
357
|
that turns to face the camera IN THE VERTEX STAGE (off the shared
|
|
357
358
|
uCamRight/uCamUp, or uCamPos for `billboard: "fixed-y"`, which yaws
|
|
@@ -445,6 +446,37 @@ a background is static art - anything animated is a mesh's own
|
|
|
445
446
|
shaderMaterial (or, until blend factors land, a separate shader texture
|
|
446
447
|
underneath, which translucent grounds also still need).
|
|
447
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
|
+
|
|
448
480
|
Lighting GLSL (`@solidrt/3d/glsl`): exported string constants composed
|
|
449
481
|
into shaderMaterial sources with plain template literals - `LIT_VERTEX`
|
|
450
482
|
(the standard vertex stage: clip position plus vWorldPos/vNormal/vUv
|
|
@@ -509,7 +541,12 @@ sorting, usually with `cull: "none"` for cards. Triplanar
|
|
|
509
541
|
is an OPTION, not the default: generators emit 0..1 UVs per face, so a
|
|
510
542
|
map on a plane is a decal (UV) while a map on generated scenery wants one
|
|
511
543
|
density across parts of any size (triplanar); the map must be created
|
|
512
|
-
with `wrap: "repeat"`.
|
|
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
|
|
513
550
|
combination (map x vertexColors x triplanar x transparent x cull x
|
|
514
551
|
alphaTest), cached for the app's lifetime, one pipeline per vertex layout
|
|
515
552
|
- a thousand lit meshes share one program. The view vector comes from the
|
|
@@ -542,7 +579,7 @@ next to it, or single-file .glb) and become a Group of meshes, Three's
|
|
|
542
579
|
Blender exports Draco by DEFAULT, so that is the first error a real
|
|
543
580
|
file hits.
|
|
544
581
|
- `createModel(data, { material?, label? })` - uploads the images (repeat
|
|
545
|
-
wrap, mipmapped), makes one material per glTF material (default `lit({
|
|
582
|
+
wrap, mipmapped, 4x anisotropic), makes one material per glTF material (default `lit({
|
|
546
583
|
color, map, transparent })`; pass `material(m, map)` for anything else,
|
|
547
584
|
it is called once per material and shared), one mesh per part, all
|
|
548
585
|
children of the returned `Model` (a Group): `add(scene.root, model)`,
|
|
@@ -815,6 +852,13 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
|
|
|
815
852
|
leaf under a design size. Only a leaf whose layout size deliberately
|
|
816
853
|
differs from the target (supersampling) needs `handlersFor`, fed the
|
|
817
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.
|
|
818
862
|
- Hover (enter/leave) reacts to pointer MOTION only: a mesh animating
|
|
819
863
|
under a still pointer fires nothing until the next move - the same
|
|
820
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/index.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// PerspectiveCamera) on top. See AGENTS.md for the model and the traps.
|
|
7
7
|
|
|
8
8
|
export { add, createDirectionalLight, createGroup, createHemisphereLight, createInstancedMesh, createMesh, createScene, createSprite, setLight, disposeInstances, getRotation, lookAt, remove, setCastShadow, setGeometry, setInstanceCount, setInstances, setMaterial, setMeshParams, setRenderOrder, setTransform, setTransition, setVisible, worldPosition, MAX_SHADOWS } from "./scene.ts"
|
|
9
|
-
export type { CameraUpdate, DirectionalLight as DirectionalLightNode, DirectionalLightOptions, HemisphereLight as HemisphereLightNode, HemisphereLightOptions, Hit, Light, InstancedMesh as InstancedMeshNode, InstancedMeshOptions, Mesh as MeshNode, MeshInstances, OrthoExtent, Scene as SceneHandle, SceneHandlers, SceneNode, SceneOptions, ScenePointerEvent, ShadowCamera, ShadowOptions, TransformUpdate, TransitionEndEvent, View, ViewOptions } from "./scene.ts"
|
|
9
|
+
export type { CameraUpdate, DirectionalLight as DirectionalLightNode, DirectionalLightOptions, FogOptions, HemisphereLight as HemisphereLightNode, HemisphereLightOptions, Hit, Light, InstancedMesh as InstancedMeshNode, InstancedMeshOptions, Mesh as MeshNode, MeshInstances, OrthoExtent, Scene as SceneHandle, SceneHandlers, SceneNode, SceneOptions, ScenePointerEvent, ShadowCamera, ShadowOptions, TransformUpdate, TransitionEndEvent, View, ViewOptions } from "./scene.ts"
|
|
10
10
|
export type { NodeTransition, NodeTransitionSpec } from "flux:spatial"
|
|
11
11
|
export { disposeGeometry } from "./geometry-gpu.ts"
|
|
12
12
|
export { box, circle, cone, cylinder, fillAttribute, fillColors, geometryBounds, layoutAttributes, layoutKey, layoutSlot, layoutStride, mergeGeometries, packGeometry, plane, ring, sphere, torus, torusKnot, transformGeometry, validateGeometry, withAttribute, withColors, STANDARD_FLOATS, VERTEX_LAYOUTS } from "./geometry.ts"
|
package/src/material.ts
CHANGED
|
@@ -42,7 +42,7 @@ import type {
|
|
|
42
42
|
} from "@solidrt/core/gpu"
|
|
43
43
|
import { layoutAttributes, layoutKey, layoutSlot } from "./geometry.ts"
|
|
44
44
|
import type { VertexLayout } from "./geometry.ts"
|
|
45
|
-
import { BLINN_SPECULAR, HEMISPHERE, LAMBERT, LIT_VERTEX, LIT_VERTEX_COLORED, MAX_LIGHTS, SHADOW, SHADOW_LOOKUP, SHADOW_SLOTS } from "./glsl.ts"
|
|
45
|
+
import { BLINN_SPECULAR, FOG, HEMISPHERE, LAMBERT, LIT_VERTEX, LIT_VERTEX_COLORED, MAX_LIGHTS, SHADOW, SHADOW_LOOKUP, SHADOW_SLOTS } from "./glsl.ts"
|
|
46
46
|
|
|
47
47
|
export type Material = {
|
|
48
48
|
/** The pipeline this material draws with for geometry of `layout`
|
|
@@ -91,34 +91,46 @@ export type Material = {
|
|
|
91
91
|
// what keeps camera motion O(1) instead of O(meshes), and the extra
|
|
92
92
|
// per-vertex mat4 multiply is free on the GPU. aNormal from the shared
|
|
93
93
|
// layout is deliberately not declared - inactive attributes are skipped
|
|
94
|
-
// and only the stride accounts for them.
|
|
94
|
+
// and only the stride accounts for them. vWorldPos is the fog distance
|
|
95
|
+
// input; a fragment that does not read it (fog: false) leaves the out
|
|
96
|
+
// unmatched, which links fine.
|
|
95
97
|
const UNLIT_VERTEX = glsl`
|
|
96
98
|
in vec3 aPos;
|
|
97
99
|
in vec2 aUV;
|
|
98
100
|
out vec2 vUv;
|
|
101
|
+
out vec3 vWorldPos;
|
|
99
102
|
uniform mat4 uModel;
|
|
100
103
|
uniform mat4 uViewProj;
|
|
101
104
|
|
|
102
105
|
void main() {
|
|
103
|
-
|
|
106
|
+
vec4 world = uModel * vec4(aPos, 1.0);
|
|
107
|
+
vWorldPos = world.xyz;
|
|
108
|
+
gl_Position = uViewProj * world;
|
|
104
109
|
vUv = aUV;
|
|
105
110
|
}
|
|
106
111
|
`
|
|
107
112
|
|
|
108
113
|
// The unlit fragment (sprites share it): the color, times the map when
|
|
109
|
-
// there is one, with the alphaTest discard when asked for
|
|
114
|
+
// there is one, with the alphaTest discard when asked for, then the
|
|
115
|
+
// scene's fog (FOG from ./glsl, mixed at the alpha about to be written)
|
|
116
|
+
// unless the material opted out. An opaque
|
|
110
117
|
// class writes alpha 1: the scene target is composited premultiplied, so
|
|
111
118
|
// a leaked texel alpha would punch a hole through an opaque draw.
|
|
112
|
-
function unlitFragment(map: boolean, alphaTest: boolean, transparent: boolean): string {
|
|
119
|
+
function unlitFragment(map: boolean, alphaTest: boolean, transparent: boolean, fog: boolean): string {
|
|
120
|
+
let alpha = transparent ? "base.a" : "1.0"
|
|
113
121
|
return glsl`
|
|
114
122
|
${map ? "in vec2 vUv;" : ""}
|
|
123
|
+
${fog ? "in vec3 vWorldPos;" : ""}
|
|
115
124
|
${map ? "uniform sampler2D uMap;" : ""}
|
|
116
125
|
uniform vec4 uColor;
|
|
117
126
|
${alphaTest ? "uniform float uAlphaTest;" : ""}
|
|
127
|
+
${fog ? "uniform vec3 uCamPos;" : ""}
|
|
128
|
+
${fog ? FOG : ""}
|
|
118
129
|
void main() {
|
|
119
130
|
vec4 base = ${map ? "texture(uMap, vUv) * uColor" : "uColor"};
|
|
120
131
|
${alphaTest ? "if (base.a < uAlphaTest) discard;" : ""}
|
|
121
|
-
|
|
132
|
+
${fog ? `base.rgb = fog(base.rgb, ${alpha}, vWorldPos, uCamPos);` : ""}
|
|
133
|
+
fragColor = vec4(base.rgb, ${alpha});
|
|
122
134
|
}
|
|
123
135
|
`
|
|
124
136
|
}
|
|
@@ -148,6 +160,12 @@ export type UnlitOptions = {
|
|
|
148
160
|
* fences want it with `cull: "none"`; a mapped cutout casts its cutout
|
|
149
161
|
* (Material.shadow). */
|
|
150
162
|
alphaTest?: number
|
|
163
|
+
/** Take the scene's fog (default true, Three's `material.fog`): the
|
|
164
|
+
* fragment fades toward the fog color with its distance from the
|
|
165
|
+
* camera once `scene.setFog` is set. `false` drops the fog code from
|
|
166
|
+
* the program - a sky sphere or a far backdrop that must keep its
|
|
167
|
+
* color, an emissive marker. */
|
|
168
|
+
fog?: boolean
|
|
151
169
|
}
|
|
152
170
|
|
|
153
171
|
/**
|
|
@@ -163,12 +181,13 @@ export function unlit(opts: UnlitOptions = {}): Material {
|
|
|
163
181
|
let transparent = opts.transparent === true
|
|
164
182
|
let cull = opts.cull ?? "back"
|
|
165
183
|
let alphaTest = opts.alphaTest !== undefined
|
|
166
|
-
let
|
|
184
|
+
let fog = opts.fog !== false
|
|
185
|
+
let key = [map, transparent, cull, alphaTest, fog].join("|")
|
|
167
186
|
let cls = unlitClasses.get(key)
|
|
168
187
|
if (cls === undefined) {
|
|
169
188
|
cls = shaderMaterialClass({
|
|
170
189
|
vertex: UNLIT_VERTEX,
|
|
171
|
-
fragment: unlitFragment(map, alphaTest, transparent),
|
|
190
|
+
fragment: unlitFragment(map, alphaTest, transparent, fog),
|
|
172
191
|
transparent,
|
|
173
192
|
cull,
|
|
174
193
|
label: "scene-unlit-" + key,
|
|
@@ -217,7 +236,8 @@ export type LitOptions = UnlitOptions & {
|
|
|
217
236
|
// composes by hand, per flag: map x vertexColors x triplanar x shadow x
|
|
218
237
|
// transparent x cull (a class that shows back faces lights them with the
|
|
219
238
|
// normal flipped, else a double-sided leaf's back is black) x alphaTest
|
|
220
|
-
// (the cutoff itself is a per-entry uniform, one class for every value)
|
|
239
|
+
// (the cutoff itself is a per-entry uniform, one class for every value)
|
|
240
|
+
// x fog (the scene's fog composed last, or left out of the program).
|
|
221
241
|
// An opaque class writes alpha 1 (see unlitFragment). Lights arrive
|
|
222
242
|
// through the scene's shared params (light nodes); the base color, map
|
|
223
243
|
// and highlight are per entry. The
|
|
@@ -237,6 +257,7 @@ type LitClass = {
|
|
|
237
257
|
shadow: boolean
|
|
238
258
|
cull: CullMode
|
|
239
259
|
alphaTest: boolean
|
|
260
|
+
fog: boolean
|
|
240
261
|
}
|
|
241
262
|
|
|
242
263
|
function litClassKey(c: LitClass): string {
|
|
@@ -244,8 +265,9 @@ function litClassKey(c: LitClass): string {
|
|
|
244
265
|
}
|
|
245
266
|
|
|
246
267
|
function litFragment(c: LitClass): string {
|
|
247
|
-
let { map, vertexColors, triplanar, shadow, alphaTest } = c
|
|
268
|
+
let { map, vertexColors, triplanar, shadow, alphaTest, fog } = c
|
|
248
269
|
let backFaces = c.cull !== "back"
|
|
270
|
+
let alpha = c.transparent ? "base.a" : "1.0"
|
|
249
271
|
return glsl`
|
|
250
272
|
in vec3 vWorldPos;
|
|
251
273
|
in vec3 vNormal;
|
|
@@ -273,6 +295,7 @@ function litFragment(c: LitClass): string {
|
|
|
273
295
|
${HEMISPHERE}
|
|
274
296
|
${LAMBERT}
|
|
275
297
|
${BLINN_SPECULAR}
|
|
298
|
+
${fog ? FOG : ""}
|
|
276
299
|
|
|
277
300
|
void main() {
|
|
278
301
|
vec3 n = normalize(vNormal);
|
|
@@ -302,7 +325,9 @@ function litFragment(c: LitClass): string {
|
|
|
302
325
|
light += uLightColor[i] * lambert(n, l) * s;
|
|
303
326
|
spec += uLightColor[i] * blinnSpecular(n, v, l, uShininess) * s;
|
|
304
327
|
}
|
|
305
|
-
|
|
328
|
+
vec3 rgb = base.rgb * light + spec * uSpecular * base.a;
|
|
329
|
+
${fog ? `rgb = fog(rgb, ${alpha}, vWorldPos, uCamPos);` : ""}
|
|
330
|
+
fragColor = vec4(rgb, ${alpha});
|
|
306
331
|
}
|
|
307
332
|
`
|
|
308
333
|
}
|
|
@@ -335,6 +360,7 @@ export function lit(opts: LitOptions = {}): Material {
|
|
|
335
360
|
shadow: opts.receiveShadow !== false,
|
|
336
361
|
cull,
|
|
337
362
|
alphaTest,
|
|
363
|
+
fog: opts.fog !== false,
|
|
338
364
|
}
|
|
339
365
|
let key = litClassKey(flags)
|
|
340
366
|
let cls = litClasses.get(key)
|
|
@@ -477,6 +503,7 @@ const SPRITE_VERTEX_SRC = glsl`
|
|
|
477
503
|
in vec3 aPos;
|
|
478
504
|
in vec2 aUV;
|
|
479
505
|
out vec2 vUv;
|
|
506
|
+
out vec3 vWorldPos;
|
|
480
507
|
uniform mat4 uModel;
|
|
481
508
|
uniform mat4 uViewProj;
|
|
482
509
|
uniform vec3 uCamRight;
|
|
@@ -486,6 +513,7 @@ const SPRITE_VERTEX_SRC = glsl`
|
|
|
486
513
|
vec3 center = uModel[3].xyz;
|
|
487
514
|
vec2 size = vec2(length(uModel[0].xyz), length(uModel[1].xyz));
|
|
488
515
|
vec3 world = center + uCamRight * (aPos.x * size.x) + uCamUp * (aPos.y * size.y);
|
|
516
|
+
vWorldPos = world;
|
|
489
517
|
gl_Position = uViewProj * vec4(world, 1.0);
|
|
490
518
|
vUv = aUV;
|
|
491
519
|
}
|
|
@@ -495,6 +523,7 @@ const SPRITE_FIXED_Y_VERTEX_SRC = glsl`
|
|
|
495
523
|
in vec3 aPos;
|
|
496
524
|
in vec2 aUV;
|
|
497
525
|
out vec2 vUv;
|
|
526
|
+
out vec3 vWorldPos;
|
|
498
527
|
uniform mat4 uModel;
|
|
499
528
|
uniform mat4 uViewProj;
|
|
500
529
|
uniform vec3 uCamPos;
|
|
@@ -507,6 +536,7 @@ const SPRITE_FIXED_Y_VERTEX_SRC = glsl`
|
|
|
507
536
|
float len = length(toCam);
|
|
508
537
|
vec3 right = len > 1e-6 ? vec3(toCam.z, 0.0, -toCam.x) / len : vec3(1.0, 0.0, 0.0);
|
|
509
538
|
vec3 world = center + right * (aPos.x * size.x) + vec3(0.0, aPos.y * size.y, 0.0);
|
|
539
|
+
vWorldPos = world;
|
|
510
540
|
gl_Position = uViewProj * vec4(world, 1.0);
|
|
511
541
|
vUv = aUV;
|
|
512
542
|
}
|
|
@@ -531,12 +561,13 @@ export function sprite(opts: SpriteOptions = {}): Material {
|
|
|
531
561
|
let map = opts.map !== undefined
|
|
532
562
|
let transparent = opts.transparent !== false
|
|
533
563
|
let fixedY = opts.billboard === "fixed-y"
|
|
534
|
-
let
|
|
564
|
+
let fog = opts.fog !== false
|
|
565
|
+
let key = [map, transparent, fixedY, fog].join("|")
|
|
535
566
|
let cls = spriteClasses.get(key)
|
|
536
567
|
if (cls === undefined) {
|
|
537
568
|
cls = shaderMaterialClass({
|
|
538
569
|
vertex: fixedY ? SPRITE_FIXED_Y_VERTEX_SRC : SPRITE_VERTEX_SRC,
|
|
539
|
-
fragment: unlitFragment(map, false, transparent),
|
|
570
|
+
fragment: unlitFragment(map, false, transparent, fog),
|
|
540
571
|
transparent,
|
|
541
572
|
cull: "none",
|
|
542
573
|
label: "scene-sprite-" + key,
|
package/src/model.ts
CHANGED
|
@@ -20,6 +20,12 @@ import type { Material } from "./material.ts"
|
|
|
20
20
|
import { add, createGroup, createMesh, remove } from "./scene.ts"
|
|
21
21
|
import type { Mesh, SceneNode } from "./scene.ts"
|
|
22
22
|
|
|
23
|
+
/** Anisotropic filtering level for a model's textures: the engines' usual
|
|
24
|
+
* default (Godot ships 2x, Unity's quality presets 2-8x) - enough to keep a
|
|
25
|
+
* tiled surface legible at a grazing angle, cheap on every GPU. Clamped to
|
|
26
|
+
* the device by the runtime. */
|
|
27
|
+
const MODEL_ANISOTROPY = 4
|
|
28
|
+
|
|
23
29
|
export type ModelOptions = {
|
|
24
30
|
/** The material for each glTF material (default: `lit` with its color,
|
|
25
31
|
* map and transparency). `map` is the uploaded base color texture, or
|
|
@@ -44,8 +50,9 @@ export type Model = SceneNode & {
|
|
|
44
50
|
|
|
45
51
|
/**
|
|
46
52
|
* Build the scene object for parsed model data: upload its images (repeat
|
|
47
|
-
* wrap, mipmapped), make a material per glTF material, a
|
|
48
|
-
* all under one Group. Synchronous - the data is already in
|
|
53
|
+
* wrap, mipmapped, MODEL_ANISOTROPY), make a material per glTF material, a
|
|
54
|
+
* mesh per part, all under one Group. Synchronous - the data is already in
|
|
55
|
+
* memory.
|
|
49
56
|
*/
|
|
50
57
|
export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
|
|
51
58
|
let label = opts.label
|
|
@@ -54,6 +61,7 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
|
|
|
54
61
|
return createTexture(image.data, image.width, image.height, {
|
|
55
62
|
wrap: "repeat",
|
|
56
63
|
mipmap: true,
|
|
64
|
+
anisotropy: MODEL_ANISOTROPY,
|
|
57
65
|
autoFree: false,
|
|
58
66
|
label: label ? label + "-image" + i : undefined,
|
|
59
67
|
})
|
package/src/scene.ts
CHANGED
|
@@ -373,8 +373,50 @@ export type CameraUpdate = {
|
|
|
373
373
|
ortho?: OrthoExtent | null
|
|
374
374
|
}
|
|
375
375
|
|
|
376
|
+
/**
|
|
377
|
+
* Scene fog, by RADIAL distance from the camera, in one of two forms:
|
|
378
|
+
* linear (Three's `Fog`, Unity's linear mode) fades toward `color` from
|
|
379
|
+
* `near` to `far` world units and is fully fogged past `far`; exp2
|
|
380
|
+
* (Three's `FogExp2`, Unity's default) thickens as
|
|
381
|
+
* `1 - exp(-(distance * density)^2)`, no start band, never quite opaque
|
|
382
|
+
* - `density` 0.01 is about 63% fog at 100 units, 98% at 200. Either
|
|
383
|
+
* form takes the height attenuation (Godot's fog height, Unreal's
|
|
384
|
+
* height falloff): the fog is full at and below `height` (world y) and
|
|
385
|
+
* thins above it by `exp(-(y - height) * heightFalloff)` - a valley
|
|
386
|
+
* fills while the hilltops and the sky stay clear; `heightFalloff` 0.1
|
|
387
|
+
* halves the fog every ~7 units of climb. Match
|
|
388
|
+
* `color` to the clearColor or background - the background is not
|
|
389
|
+
* fogged, so a mismatch shows as a band at the horizon - and, for the
|
|
390
|
+
* linear form, set `far` at or inside the camera's far plane to hide
|
|
391
|
+
* the clip.
|
|
392
|
+
*/
|
|
393
|
+
export type FogOptions = (
|
|
394
|
+
| {
|
|
395
|
+
/** Distance where the fade starts, world units; negative puts the
|
|
396
|
+
* camera itself part-way into the fog (Three allows the same). */
|
|
397
|
+
near: number
|
|
398
|
+
/** Distance where the fade completes, world units; must exceed near. */
|
|
399
|
+
far: number
|
|
400
|
+
}
|
|
401
|
+
| {
|
|
402
|
+
/** Exp2 thickness per world unit, > 0 (0.005 haze .. 0.05 pea soup). */
|
|
403
|
+
density: number
|
|
404
|
+
}
|
|
405
|
+
) & {
|
|
406
|
+
/** Straight [r, g, b], 0..1. */
|
|
407
|
+
color: [number, number, number]
|
|
408
|
+
/** World y at and below which the fog is full; default 0. Only acts
|
|
409
|
+
* with a `heightFalloff`. */
|
|
410
|
+
height?: number
|
|
411
|
+
/** How fast the fog thins above `height`, per world unit (e-fold rate,
|
|
412
|
+
* >= 0); default 0, no height attenuation. */
|
|
413
|
+
heightFalloff?: number
|
|
414
|
+
}
|
|
415
|
+
|
|
376
416
|
export type SceneOptions = {
|
|
377
417
|
clearColor?: [number, number, number, number]
|
|
418
|
+
/** Scene-wide fog; see setFog. */
|
|
419
|
+
fog?: FogOptions
|
|
378
420
|
/** Fragment GLSL drawn behind the meshes, inside the scene's own pass -
|
|
379
421
|
* see setBackground. */
|
|
380
422
|
background?: string
|
|
@@ -458,6 +500,19 @@ export type Scene = {
|
|
|
458
500
|
* declare a name simply skips it. Frame-rate-safe like setTransform.
|
|
459
501
|
*/
|
|
460
502
|
setParams(params: ShaderParams): void
|
|
503
|
+
/**
|
|
504
|
+
* Set, replace, or remove (null) the scene's fog, linear
|
|
505
|
+
* (`{ color, near, far }`) or exp2 (`{ color, density }`), either with
|
|
506
|
+
* the optional `height`/`heightFalloff`. One shared-params write
|
|
507
|
+
* (`uFogColor`, `uFogNear`, `uFogInv`, `uFogDensity`, `uFogHeight`,
|
|
508
|
+
* `uFogHeightFalloff` - the set FOG in `@solidrt/3d/glsl` declares;
|
|
509
|
+
* the form not in use is written 0), fanned out to every view like
|
|
510
|
+
* setParams, so however many meshes fog it costs nothing per frame.
|
|
511
|
+
* Every standard material (unlit, lit, sprite) composes it unless
|
|
512
|
+
* created with `fog: false`; a shaderMaterial opts in by composing FOG.
|
|
513
|
+
* The background is not fogged: match colors (see FogOptions).
|
|
514
|
+
*/
|
|
515
|
+
setFog(fog: FogOptions | null): void
|
|
461
516
|
/**
|
|
462
517
|
* Set, replace, or remove (null) the scene's background: fragment GLSL
|
|
463
518
|
* drawn as the FIRST entry of the scene's own pass - one target, no
|
|
@@ -2119,6 +2174,32 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
2119
2174
|
setTargetParams(texture, params)
|
|
2120
2175
|
for (let v of views) setTargetParams(v.texture, params)
|
|
2121
2176
|
},
|
|
2177
|
+
setFog(fog) {
|
|
2178
|
+
if (fog === null) {
|
|
2179
|
+
scene.setParams({ uFogInv: 0, uFogDensity: 0, uFogHeightFalloff: 0 })
|
|
2180
|
+
return
|
|
2181
|
+
}
|
|
2182
|
+
let color = [fog.color[0], fog.color[1], fog.color[2]]
|
|
2183
|
+
let height = fog.height ?? 0
|
|
2184
|
+
let falloff = fog.heightFalloff ?? 0
|
|
2185
|
+
if (!Number.isFinite(height) || !Number.isFinite(falloff) || falloff < 0) {
|
|
2186
|
+
throw new Error(`setFog: height must be finite and heightFalloff finite and >= 0, got ${height} / ${falloff}`)
|
|
2187
|
+
}
|
|
2188
|
+
let params: ShaderParams = { uFogColor: color, uFogHeight: height, uFogHeightFalloff: falloff }
|
|
2189
|
+
if ("density" in fog) {
|
|
2190
|
+
let { density } = fog
|
|
2191
|
+
if (!Number.isFinite(density) || density <= 0) {
|
|
2192
|
+
throw new Error(`setFog: density must be finite and > 0, got ${density}`)
|
|
2193
|
+
}
|
|
2194
|
+
scene.setParams({ ...params, uFogInv: 0, uFogDensity: density })
|
|
2195
|
+
return
|
|
2196
|
+
}
|
|
2197
|
+
let { near, far } = fog
|
|
2198
|
+
if (!(Number.isFinite(near) && Number.isFinite(far)) || far <= near) {
|
|
2199
|
+
throw new Error(`setFog: near/far must be finite with near < far, got ${near}..${far}`)
|
|
2200
|
+
}
|
|
2201
|
+
scene.setParams({ ...params, uFogNear: near, uFogInv: 1 / (far - near), uFogDensity: 0 })
|
|
2202
|
+
},
|
|
2122
2203
|
setBackground(source) {
|
|
2123
2204
|
if (disposed) return
|
|
2124
2205
|
if (background !== null) {
|
|
@@ -2273,6 +2354,12 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
2273
2354
|
}
|
|
2274
2355
|
},
|
|
2275
2356
|
}
|
|
2357
|
+
// The fog set starts at "none" (uFogInv and uFogDensity 0 are factor 0,
|
|
2358
|
+
// uFogHeightFalloff 0 is no attenuation) so every material that declares
|
|
2359
|
+
// it has coverage from the first frame; a target tolerates the names
|
|
2360
|
+
// when nothing declares them.
|
|
2361
|
+
scene.setParams({ uFogColor: [0, 0, 0], uFogNear: 0, uFogInv: 0, uFogDensity: 0, uFogHeight: 0, uFogHeightFalloff: 0 })
|
|
2362
|
+
if (opts?.fog !== undefined) scene.setFog(opts.fog)
|
|
2276
2363
|
if (opts?.background !== undefined) scene.setBackground(opts.background)
|
|
2277
2364
|
if (opts?.autoFree !== false && getOwner()) onCleanup(() => scene.dispose())
|
|
2278
2365
|
return scene
|