@solidrt/3d 0.0.54 → 0.0.56
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 +1121 -177
- package/README.md +15 -7
- package/examples/README.md +86 -5
- package/examples/aim.tsx +2 -3
- package/examples/cascades.tsx +20 -5
- package/examples/collision.tsx +250 -0
- package/examples/first-person.tsx +119 -0
- package/examples/fleet.tsx +194 -0
- package/examples/fog.tsx +173 -0
- package/examples/instanced.tsx +72 -32
- package/examples/lamps.tsx +92 -0
- package/examples/lit.tsx +3 -5
- package/examples/materials.tsx +150 -0
- package/examples/model-load.tsx +95 -0
- package/examples/model.glb +0 -0
- package/examples/model.tsx +32 -6
- package/examples/pick.tsx +2 -4
- package/examples/probe.tsx +101 -0
- package/examples/scene-background.tsx +2 -4
- package/examples/scene-basic.tsx +2 -4
- package/examples/scene-views.tsx +32 -3
- package/examples/shadows.tsx +2 -4
- package/examples/sky-lit.tsx +86 -0
- package/examples/skybox.tsx +128 -0
- package/examples/sprites.tsx +4 -6
- package/examples/standard.tsx +132 -0
- package/examples/sweep-paths.tsx +2 -4
- package/package.json +3 -3
- package/src/camera.ts +148 -0
- package/src/clip.ts +96 -0
- package/src/collision.ts +199 -0
- package/src/color.ts +32 -0
- package/src/components.tsx +773 -82
- package/src/environment-bake.ts +492 -0
- package/src/environment.ts +325 -0
- package/src/first-person.ts +306 -0
- package/src/geometry.ts +6 -2
- package/src/glsl.ts +1390 -82
- package/src/gltf.ts +432 -112
- package/src/index.ts +57 -9
- package/src/light.ts +383 -0
- package/src/material.ts +682 -216
- package/src/math.ts +110 -0
- package/src/mesh.ts +728 -0
- package/src/mixer.ts +476 -0
- package/src/model-file.ts +80 -15
- package/src/model.ts +211 -34
- package/src/node.ts +506 -0
- package/src/orbit.ts +97 -48
- package/src/scene-pointer.ts +166 -0
- package/src/scene-shadows.ts +471 -0
- package/src/scene.ts +1304 -1532
- package/tools/environment.ts +69 -0
- package/tools/model.ts +4 -3
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// A fleet of a thousand instance NODES: one `<InstancedMesh>` under the
|
|
2
|
+
// stock `lit` material with `instanceColors`, so lighting, shadows, fog
|
|
3
|
+
// and a per-instance tint come with no GLSL at all. Each `<Instance>`
|
|
4
|
+
// declares a spring `transition`; every few seconds a signal picks the
|
|
5
|
+
// next formation (grid, rings, shell, helix), the thousand `position`
|
|
6
|
+
// props update, and the spatial core springs every instance to its new
|
|
7
|
+
// place - the change is one signal write, and the frames after it cost
|
|
8
|
+
// no JS at all (the core writes the matrix records itself). A tap on an
|
|
9
|
+
// instance flips its tint: pointer events name the instance struck, and
|
|
10
|
+
// however many style records change between two frames they land as ONE
|
|
11
|
+
// buffer write. Drag to orbit.
|
|
12
|
+
//
|
|
13
|
+
// Debug commands: `formation` ({ name } sets one and parks the cycle,
|
|
14
|
+
// { cycle: true } resumes; returns the current one), `state` (formation,
|
|
15
|
+
// the last tapped instance, how many are flipped) and `pixel` ({ i }: the
|
|
16
|
+
// scene pixel instance i projects to and what a pick there finds, for a
|
|
17
|
+
// synthetic tap).
|
|
18
|
+
import { createSignal, flush, For, pct, render } from "@solidrt/core"
|
|
19
|
+
import { box, DirectionalLight, HemisphereLight, Instance, InstancedMesh, lit, Mesh, OrbitCamera, plane, Scene, worldPosition } from "@solidrt/3d"
|
|
20
|
+
import type { InstanceNode, NodeTransition, SceneHandle, Vec3 } from "@solidrt/3d"
|
|
21
|
+
import { registerDebug } from "srt:dev"
|
|
22
|
+
|
|
23
|
+
type Tint = [number, number, number, number]
|
|
24
|
+
|
|
25
|
+
const COUNT = 1000
|
|
26
|
+
// Milliseconds between formation changes.
|
|
27
|
+
const CYCLE_MS = 4000
|
|
28
|
+
// The spring every instance moves by: about a second to settle, a small
|
|
29
|
+
// overshoot so the fleet visibly swings into place.
|
|
30
|
+
const SPRING: NodeTransition = { position: { duration: 900, bounce: 0.2 } }
|
|
31
|
+
// Edge of one unit cube, and the half-width of the ground the formations
|
|
32
|
+
// fit in.
|
|
33
|
+
const UNIT = 0.18
|
|
34
|
+
const REACH = 5
|
|
35
|
+
const SKY: [number, number, number] = [0.07, 0.08, 0.1]
|
|
36
|
+
// The tint a tapped instance flips to.
|
|
37
|
+
const HOT: Tint = [1, 0.35, 0.2, 1]
|
|
38
|
+
|
|
39
|
+
// Formations: where instance i of COUNT stands. Each is a pure function
|
|
40
|
+
// of the index, so a formation change is a signal write and nothing else.
|
|
41
|
+
const GRID_COLUMNS = 40
|
|
42
|
+
const RING_SIZE = 100
|
|
43
|
+
const RING_STEP = 0.4
|
|
44
|
+
const SHELL_RADIUS = 3
|
|
45
|
+
const SHELL_HEIGHT = 3.5
|
|
46
|
+
const HELIX_TURNS = 12
|
|
47
|
+
const HELIX_HEIGHT = 6
|
|
48
|
+
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5))
|
|
49
|
+
const FORMATIONS: { name: string; at: (i: number) => Vec3 }[] = [
|
|
50
|
+
{
|
|
51
|
+
name: "grid",
|
|
52
|
+
at: i => {
|
|
53
|
+
let spacing = (2 * REACH) / GRID_COLUMNS
|
|
54
|
+
let rows = COUNT / GRID_COLUMNS
|
|
55
|
+
return [((i % GRID_COLUMNS) - (GRID_COLUMNS - 1) / 2) * spacing, UNIT / 2, (Math.floor(i / GRID_COLUMNS) - (rows - 1) / 2) * spacing]
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "rings",
|
|
60
|
+
at: i => {
|
|
61
|
+
let ring = Math.floor(i / RING_SIZE)
|
|
62
|
+
let a = ((i % RING_SIZE) / RING_SIZE) * Math.PI * 2 + ring * 0.1
|
|
63
|
+
let r = 1.2 + ring * RING_STEP
|
|
64
|
+
return [Math.cos(a) * r, UNIT / 2, Math.sin(a) * r]
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: "shell",
|
|
69
|
+
at: i => {
|
|
70
|
+
// A Fibonacci sphere: evenly spread points on the shell.
|
|
71
|
+
let y = 1 - (2 * (i + 0.5)) / COUNT
|
|
72
|
+
let r = Math.sqrt(1 - y * y)
|
|
73
|
+
let a = i * GOLDEN_ANGLE
|
|
74
|
+
return [Math.cos(a) * r * SHELL_RADIUS, SHELL_HEIGHT + y * SHELL_RADIUS, Math.sin(a) * r * SHELL_RADIUS]
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: "helix",
|
|
79
|
+
at: i => {
|
|
80
|
+
let t = i / COUNT
|
|
81
|
+
let a = t * HELIX_TURNS * Math.PI * 2
|
|
82
|
+
return [Math.cos(a) * SHELL_RADIUS, UNIT / 2 + t * HELIX_HEIGHT, Math.sin(a) * SHELL_RADIUS]
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
// The fleet's resting tint: a gradient along the index, so the formations
|
|
88
|
+
// read as one ribbon folding into the next shape.
|
|
89
|
+
function gradient(i: number): Tint {
|
|
90
|
+
let t = i / COUNT
|
|
91
|
+
return [0.35 + 0.5 * t, 0.55, 0.9 - 0.6 * t, 1]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const INDICES = Array.from({ length: COUNT }, (_, i) => i)
|
|
95
|
+
|
|
96
|
+
let [formation, setFormation] = createSignal(0)
|
|
97
|
+
let tints = INDICES.map(i => createSignal<Tint>(gradient(i)))
|
|
98
|
+
let flipped = new Set<number>()
|
|
99
|
+
let lastTapped: number | null = null
|
|
100
|
+
let parked = false
|
|
101
|
+
// The handles the debug commands read: the scene and each instance node.
|
|
102
|
+
let scene: SceneHandle | undefined
|
|
103
|
+
let nodes: InstanceNode[] = []
|
|
104
|
+
|
|
105
|
+
let flip = (i: number) => {
|
|
106
|
+
let [, setTint] = tints[i]!
|
|
107
|
+
if (flipped.has(i)) {
|
|
108
|
+
flipped.delete(i)
|
|
109
|
+
setTint(gradient(i))
|
|
110
|
+
} else {
|
|
111
|
+
flipped.add(i)
|
|
112
|
+
setTint(HOT)
|
|
113
|
+
}
|
|
114
|
+
lastTapped = i
|
|
115
|
+
console.log(`tap instance ${i}`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
setInterval(() => {
|
|
119
|
+
if (!parked) setFormation(f => (f + 1) % FORMATIONS.length)
|
|
120
|
+
}, CYCLE_MS)
|
|
121
|
+
|
|
122
|
+
registerDebug("formation", (args?: Record<string, unknown>) => {
|
|
123
|
+
if (typeof args?.name === "string") {
|
|
124
|
+
let k = FORMATIONS.findIndex(f => f.name === args.name)
|
|
125
|
+
if (k >= 0) {
|
|
126
|
+
setFormation(k)
|
|
127
|
+
parked = true
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (args?.cycle === true) parked = false
|
|
131
|
+
flush()
|
|
132
|
+
return { formation: FORMATIONS[formation()]!.name, parked }
|
|
133
|
+
})
|
|
134
|
+
registerDebug("state", () => ({ formation: FORMATIONS[formation()]!.name, tapped: lastTapped, flipped: flipped.size }))
|
|
135
|
+
registerDebug("pixel", (args?: Record<string, unknown>) => {
|
|
136
|
+
let node = typeof args?.i === "number" ? nodes[args.i] : undefined
|
|
137
|
+
if (node === undefined || scene === undefined) return null
|
|
138
|
+
let at = scene.project(worldPosition(node))
|
|
139
|
+
if (at === null) return null
|
|
140
|
+
// What a pick at that pixel finds: the instance's slot, or the mesh kind.
|
|
141
|
+
let hit = scene.pick(at.x, at.y)[0]
|
|
142
|
+
return { x: at.x, y: at.y, hit: hit === undefined ? null : (hit.instance?._slot ?? hit.mesh.kind) }
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
function App() {
|
|
146
|
+
let place = (i: number): Vec3 => FORMATIONS[formation()]!.at(i)
|
|
147
|
+
return (
|
|
148
|
+
<window>
|
|
149
|
+
<view width={pct(100)} height={pct(100)}>
|
|
150
|
+
<Scene clearColor={[SKY[0], SKY[1], SKY[2], 1]} fog={{ color: SKY, near: 12, far: 30 }} label="fleet" ref={s => (scene = s)}>
|
|
151
|
+
<OrbitCamera target={[0, 2, 0]} distance={15} elevation={0.45} azimuth={0.6} />
|
|
152
|
+
<HemisphereLight sky={[0.45, 0.47, 0.5]} ground={[0.12, 0.13, 0.11]} />
|
|
153
|
+
<DirectionalLight
|
|
154
|
+
color={[1, 0.95, 0.85]}
|
|
155
|
+
intensity={0.8}
|
|
156
|
+
position={[8, 14, 6]}
|
|
157
|
+
direction={[-8, -14, -6]}
|
|
158
|
+
castShadow
|
|
159
|
+
shadow={{
|
|
160
|
+
normalBias: 0.03,
|
|
161
|
+
camera: { left: -REACH - 1, right: REACH + 1, top: REACH + 3, bottom: -REACH - 3, near: 1, far: 40 },
|
|
162
|
+
}}
|
|
163
|
+
/>
|
|
164
|
+
<Mesh
|
|
165
|
+
geometry={plane({ width: 3 * REACH, height: 3 * REACH, label: "ground" })}
|
|
166
|
+
material={lit({ color: [0.26, 0.28, 0.27] })}
|
|
167
|
+
rotation={[-Math.PI / 2, 0, 0]}
|
|
168
|
+
/>
|
|
169
|
+
<InstancedMesh
|
|
170
|
+
geometry={box({ width: UNIT, height: UNIT, depth: UNIT, label: "unit" })}
|
|
171
|
+
material={lit({ instanceColors: true, specular: 0.3, shininess: 40 })}
|
|
172
|
+
capacity={COUNT}
|
|
173
|
+
castShadow
|
|
174
|
+
label="fleet"
|
|
175
|
+
>
|
|
176
|
+
<For each={INDICES}>
|
|
177
|
+
{i => (
|
|
178
|
+
<Instance
|
|
179
|
+
position={place(i)}
|
|
180
|
+
style={tints[i]![0]()}
|
|
181
|
+
transition={SPRING}
|
|
182
|
+
onPointerDown={() => flip(i)}
|
|
183
|
+
ref={node => (nodes[i] = node)}
|
|
184
|
+
/>
|
|
185
|
+
)}
|
|
186
|
+
</For>
|
|
187
|
+
</InstancedMesh>
|
|
188
|
+
</Scene>
|
|
189
|
+
</view>
|
|
190
|
+
</window>
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
render(() => <App />)
|
package/examples/fog.tsx
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
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 FAR = 400
|
|
26
|
+
// The sky, shared by the clear and the fog so the horizon has no band.
|
|
27
|
+
const SKY: [number, number, number] = [0.72, 0.78, 0.86]
|
|
28
|
+
// The camera: on the near hillside, panning slowly across the valley.
|
|
29
|
+
const EYE: Vec3 = [-40, 42, 140]
|
|
30
|
+
const PAN_PERIOD = 60
|
|
31
|
+
// How far the look-at point swings left and right of the valley axis.
|
|
32
|
+
const PAN_SWING = 70
|
|
33
|
+
// The linear band, the exp2 thickness and the height layer.
|
|
34
|
+
const NEAR = 40
|
|
35
|
+
const LINEAR_FAR = FAR
|
|
36
|
+
const DENSITY = 0.006
|
|
37
|
+
const HEIGHT = 10
|
|
38
|
+
const HEIGHT_FALLOFF = 0.12
|
|
39
|
+
|
|
40
|
+
type Mode = "linear" | "exp2" | "height" | "off"
|
|
41
|
+
const MODES: Mode[] = ["linear", "exp2", "height", "off"]
|
|
42
|
+
|
|
43
|
+
let [mode, setMode] = createSignal<Mode>("linear")
|
|
44
|
+
let [near, setNear] = createSignal(NEAR)
|
|
45
|
+
let [far, setFar] = createSignal(LINEAR_FAR)
|
|
46
|
+
let [density, setDensity] = createSignal(DENSITY)
|
|
47
|
+
let [height, setHeight] = createSignal(HEIGHT)
|
|
48
|
+
let [falloff, setFalloff] = createSignal(HEIGHT_FALLOFF)
|
|
49
|
+
let [time, setTime] = createSignal(0)
|
|
50
|
+
let parked: number | null = null
|
|
51
|
+
|
|
52
|
+
let num = (v: unknown, set: (n: number) => void) => {
|
|
53
|
+
if (typeof v === "number") set(v)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
registerDebug("fog", (args?: Record<string, unknown>) => {
|
|
57
|
+
if (typeof args?.mode === "string" && MODES.includes(args.mode as Mode)) setMode(args.mode as Mode)
|
|
58
|
+
num(args?.near, setNear)
|
|
59
|
+
num(args?.far, setFar)
|
|
60
|
+
num(args?.density, setDensity)
|
|
61
|
+
num(args?.height, setHeight)
|
|
62
|
+
num(args?.falloff, setFalloff)
|
|
63
|
+
flush()
|
|
64
|
+
return { mode: mode(), near: near(), far: far(), density: density(), height: height(), falloff: falloff() }
|
|
65
|
+
})
|
|
66
|
+
registerDebug("pan", (args?: Record<string, unknown>) => {
|
|
67
|
+
if (typeof args?.t === "number") {
|
|
68
|
+
parked = args.t
|
|
69
|
+
setTime(args.t)
|
|
70
|
+
} else if (args?.t === null) {
|
|
71
|
+
parked = null
|
|
72
|
+
}
|
|
73
|
+
flush()
|
|
74
|
+
return { t: time(), parked: parked !== null }
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
function fog(): FogOptions | undefined {
|
|
78
|
+
switch (mode()) {
|
|
79
|
+
case "linear":
|
|
80
|
+
return { color: SKY, near: near(), far: far() }
|
|
81
|
+
case "exp2":
|
|
82
|
+
return { color: SKY, density: density() }
|
|
83
|
+
case "height":
|
|
84
|
+
return { color: SKY, density: density(), height: height(), heightFalloff: falloff() }
|
|
85
|
+
case "off":
|
|
86
|
+
return undefined
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The two ridges: flattened spheres sunk into the ground, and the height
|
|
91
|
+
// they give the ground at (x, z) so the pines stand on them.
|
|
92
|
+
type Hill = { position: Vec3; scale: Vec3 }
|
|
93
|
+
let hills: Hill[] = [
|
|
94
|
+
{ position: [-90, -8, 120], scale: [110, 44, 90] },
|
|
95
|
+
{ position: [60, -6, -140], scale: [150, 40, 90] },
|
|
96
|
+
{ position: [-140, -10, -60], scale: [90, 36, 120] },
|
|
97
|
+
{ position: [150, -4, 40], scale: [80, 26, 70] },
|
|
98
|
+
]
|
|
99
|
+
function groundHeight(x: number, z: number): number {
|
|
100
|
+
let y = 0
|
|
101
|
+
for (let h of hills) {
|
|
102
|
+
let dx = (x - h.position[0]) / h.scale[0]
|
|
103
|
+
let dz = (z - h.position[2]) / h.scale[2]
|
|
104
|
+
let r = 1 - dx * dx - dz * dz
|
|
105
|
+
if (r > 0) y = Math.max(y, h.position[1] + h.scale[1] * Math.sqrt(r))
|
|
106
|
+
}
|
|
107
|
+
return y
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The pines, on a jittered grid over the valley and up the ridges.
|
|
111
|
+
type Pine = { position: Vec3; size: number }
|
|
112
|
+
let pines: Pine[] = []
|
|
113
|
+
let step = 18
|
|
114
|
+
for (let i = -12; i <= 12; i++) {
|
|
115
|
+
for (let j = -12; j <= 12; j++) {
|
|
116
|
+
let x = i * step + ((i * 7 + j * 13) % 9) - 4
|
|
117
|
+
let z = j * step + ((i * 11 + j * 5) % 9) - 4
|
|
118
|
+
let y = groundHeight(x, z)
|
|
119
|
+
// No pines on the near hillside under the camera, none above the tree line.
|
|
120
|
+
if (y > 30 || (x < -20 && z > 90)) continue
|
|
121
|
+
let size = 5 + ((i * 3 + j * 5 + 20) % 5)
|
|
122
|
+
pines.push({ position: [x, y, z], size })
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function App() {
|
|
127
|
+
onFrame(tick => {
|
|
128
|
+
if (parked === null) setTime(tick / 1000)
|
|
129
|
+
})
|
|
130
|
+
let lookAt = () => {
|
|
131
|
+
let a = Math.sin((time() / PAN_PERIOD) * 2 * Math.PI)
|
|
132
|
+
return [a * PAN_SWING, 6, -60] as Vec3
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let ground = lit({ color: [0.36, 0.48, 0.3] })
|
|
136
|
+
let rock = lit({ color: [0.5, 0.47, 0.42] })
|
|
137
|
+
let needles = lit({ color: [0.16, 0.36, 0.22] })
|
|
138
|
+
let trunk = lit({ color: [0.35, 0.25, 0.16] })
|
|
139
|
+
let sunLit = unlit({ color: [1, 0.92, 0.6], fog: false })
|
|
140
|
+
let sunFogged = unlit({ color: [1, 0.92, 0.6] })
|
|
141
|
+
|
|
142
|
+
let floor = plane({ width: 800, height: 800 })
|
|
143
|
+
let hill = sphere({ radius: 1, widthSegments: 48, heightSegments: 24 })
|
|
144
|
+
let crown = cone({ radius: 0.5, height: 1, radialSegments: 10 })
|
|
145
|
+
let stem = cylinder({ radiusTop: 0.08, radiusBottom: 0.1, height: 1, radialSegments: 6 })
|
|
146
|
+
let sun = sphere({ radius: 14, widthSegments: 24, heightSegments: 12 })
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<window>
|
|
150
|
+
<view width={pct(100)} height={pct(100)} onPointerDown={() => setMode(m => MODES[(MODES.indexOf(m) + 1) % MODES.length] ?? "linear")}>
|
|
151
|
+
<Scene clearColor={[SKY[0], SKY[1], SKY[2], 1]} fog={fog()} samples={4} label="fog">
|
|
152
|
+
<PerspectiveCamera fov={55} near={0.5} far={FAR} position={EYE} lookAt={lookAt()} />
|
|
153
|
+
<HemisphereLight sky={[0.55, 0.62, 0.75]} ground={[0.28, 0.24, 0.2]} />
|
|
154
|
+
<DirectionalLight color={[0.9, 0.85, 0.75]} rotation={[-0.9, 0.6, 0]} />
|
|
155
|
+
<Mesh geometry={floor} material={ground} rotation={[-Math.PI / 2, 0, 0]} />
|
|
156
|
+
{hills.map(h => (
|
|
157
|
+
<Mesh geometry={hill} material={rock} position={h.position} scale={h.scale} />
|
|
158
|
+
))}
|
|
159
|
+
{pines.map(p => (
|
|
160
|
+
<>
|
|
161
|
+
<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]} />
|
|
162
|
+
<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]} />
|
|
163
|
+
</>
|
|
164
|
+
))}
|
|
165
|
+
<Mesh geometry={sun} material={sunLit} position={[-30, 100, -220]} />
|
|
166
|
+
<Mesh geometry={sun} material={sunFogged} position={[30, 100, -220]} />
|
|
167
|
+
</Scene>
|
|
168
|
+
</view>
|
|
169
|
+
</window>
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
render(() => <App />)
|
package/examples/instanced.tsx
CHANGED
|
@@ -1,64 +1,89 @@
|
|
|
1
1
|
// Instanced meshes: one draw entry covering a whole population. A material
|
|
2
|
-
// class declares `instanceAttributes`,
|
|
3
|
-
// <
|
|
2
|
+
// class declares `instanceAttributes`, createRecordMesh (here via the
|
|
3
|
+
// <RecordMesh> component) supplies one interleaved record per instance,
|
|
4
4
|
// and the vertex stage reads each record through the matching `in`
|
|
5
5
|
// variables. Two meshes share the one class: 400 scattered "rocks" and a
|
|
6
6
|
// breathing ring of "pines" - each is ONE entry and ONE uModel however
|
|
7
7
|
// many instances it draws, so the spinning group below moves both fleets
|
|
8
8
|
// with two matrix writes per frame.
|
|
9
9
|
//
|
|
10
|
-
//
|
|
10
|
+
// setRecordCount is the population dial (the pines breathe); records are
|
|
11
11
|
// data, not matrices - position/scale/tint here, whatever your shader
|
|
12
12
|
// wants in general. The explicit `bounds` cover the scatter so picking
|
|
13
13
|
// still works (one conservative box around the population; omit bounds and
|
|
14
14
|
// the mesh simply never picks).
|
|
15
|
+
//
|
|
16
|
+
// The fleets CAST: the class declares `shadowVertex` - its vertex stage
|
|
17
|
+
// reduced to the position math, instance placement included - and with it
|
|
18
|
+
// `castShadow` on an RecordMesh works like on any mesh (without it the
|
|
19
|
+
// shadow views skip instanced meshes). The lit ground receives; both
|
|
20
|
+
// populations throw shadows from the one casting sun, and the breathing
|
|
21
|
+
// pines' shadows appear and vanish with them.
|
|
22
|
+
//
|
|
23
|
+
// The fleets are also lit, shadowed and fogged exactly like the ground,
|
|
24
|
+
// with no lighting code of their own: the class pairs its instanced
|
|
25
|
+
// vertex stage with the stock `litFragment` (the fragment `lit` compiles),
|
|
26
|
+
// which is the first tier of custom looks - a vertex stage that writes
|
|
27
|
+
// the lit varyings gets the whole scene for free. The per-instance tint
|
|
28
|
+
// rides the `vertexColors` path as vColor.
|
|
15
29
|
import { createSignal, onFrame, pct, render } from "@solidrt/core"
|
|
16
30
|
import { glsl } from "@solidrt/core/gpu"
|
|
17
31
|
import {
|
|
18
32
|
box,
|
|
19
33
|
cone,
|
|
34
|
+
DirectionalLight,
|
|
20
35
|
Group,
|
|
21
|
-
|
|
36
|
+
HemisphereLight,
|
|
37
|
+
RecordMesh,
|
|
38
|
+
lit,
|
|
22
39
|
Mesh,
|
|
23
40
|
PerspectiveCamera,
|
|
24
41
|
plane,
|
|
25
42
|
Scene,
|
|
26
|
-
|
|
43
|
+
setRecordCount,
|
|
27
44
|
shaderMaterialClass,
|
|
28
|
-
unlit,
|
|
29
45
|
} from "@solidrt/3d"
|
|
30
|
-
import type {
|
|
31
|
-
import {
|
|
32
|
-
|
|
33
|
-
const SIZE = 720
|
|
46
|
+
import type { RecordMeshNode } from "@solidrt/3d"
|
|
47
|
+
import { litFragment } from "@solidrt/3d/glsl"
|
|
34
48
|
|
|
49
|
+
// The lit varyings (vWorldPos, vNormal, vUv, and vColor for the tint), as
|
|
50
|
+
// LIT_VERTEX writes them, from an instanced placement.
|
|
35
51
|
const INSTANCE_VERTEX = glsl`
|
|
36
52
|
in vec3 aPos;
|
|
37
53
|
in vec3 aNormal;
|
|
54
|
+
in vec2 aUV;
|
|
38
55
|
in vec3 iPos;
|
|
39
56
|
in float iScale;
|
|
40
57
|
in vec3 iTint;
|
|
58
|
+
out vec3 vWorldPos;
|
|
41
59
|
out vec3 vNormal;
|
|
42
|
-
out
|
|
60
|
+
out vec2 vUv;
|
|
61
|
+
out vec4 vColor;
|
|
43
62
|
uniform mat4 uModel;
|
|
44
63
|
uniform mat4 uViewProj;
|
|
64
|
+
uniform mat4 uNormal;
|
|
45
65
|
|
|
46
66
|
void main() {
|
|
47
|
-
|
|
48
|
-
gl_Position = uViewProj *
|
|
49
|
-
|
|
50
|
-
|
|
67
|
+
vec4 world = uModel * vec4(aPos * iScale + iPos, 1.0);
|
|
68
|
+
gl_Position = uViewProj * world;
|
|
69
|
+
vWorldPos = world.xyz;
|
|
70
|
+
vNormal = mat3(uNormal) * aNormal;
|
|
71
|
+
vUv = aUV;
|
|
72
|
+
vColor = vec4(iTint, 1.0);
|
|
51
73
|
}
|
|
52
74
|
`
|
|
53
75
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
76
|
+
// The shadow pass's view of the same placement: aPos * iScale + iPos and
|
|
77
|
+
// nothing else - no normal, no tint. The depth fragment is the engine's.
|
|
78
|
+
const INSTANCE_SHADOW_VERTEX = glsl`
|
|
79
|
+
in vec3 aPos;
|
|
80
|
+
in vec3 iPos;
|
|
81
|
+
in float iScale;
|
|
82
|
+
uniform mat4 uModel;
|
|
83
|
+
uniform mat4 uViewProj;
|
|
58
84
|
|
|
59
85
|
void main() {
|
|
60
|
-
|
|
61
|
-
fragColor = vec4(c, 1.0);
|
|
86
|
+
gl_Position = uViewProj * uModel * vec4(aPos * iScale + iPos, 1.0);
|
|
62
87
|
}
|
|
63
88
|
`
|
|
64
89
|
|
|
@@ -106,33 +131,44 @@ const PINE_COUNT = 48
|
|
|
106
131
|
|
|
107
132
|
function App() {
|
|
108
133
|
let [spin, setSpin] = createSignal(0)
|
|
109
|
-
let pinesMesh!:
|
|
134
|
+
let pinesMesh!: RecordMeshNode
|
|
110
135
|
onFrame(tick => {
|
|
111
136
|
setSpin(tick / 6000)
|
|
112
137
|
// The population dial: draw the first N records. The buffer holds the
|
|
113
138
|
// full ring; only the draw range moves, one setDrawRange per change.
|
|
114
139
|
let n = Math.round(PINE_COUNT * (0.5 + 0.5 * Math.sin(tick / 900)))
|
|
115
|
-
if (pinesMesh)
|
|
140
|
+
if (pinesMesh) setRecordCount(pinesMesh, n)
|
|
116
141
|
})
|
|
117
142
|
|
|
118
143
|
return (
|
|
119
144
|
<window>
|
|
120
|
-
<view width={pct(100)} height={pct(100)}
|
|
121
|
-
<Scene
|
|
145
|
+
<view width={pct(100)} height={pct(100)}>
|
|
146
|
+
<Scene clearColor={[0.07, 0.08, 0.1, 1]} fog={{ color: [0.07, 0.08, 0.1], near: 5, far: 13 }} label="instanced">
|
|
122
147
|
<PerspectiveCamera fov={55} position={[0, 3.2, 5.4]} lookAt={[0, 0.2, 0]} />
|
|
123
|
-
<
|
|
148
|
+
<HemisphereLight sky={[0.4, 0.42, 0.45]} ground={[0.12, 0.13, 0.11]} />
|
|
149
|
+
<DirectionalLight
|
|
150
|
+
color={[1, 0.95, 0.85]}
|
|
151
|
+
intensity={0.7}
|
|
152
|
+
position={[4, 3.5, 3]}
|
|
153
|
+
direction={[-4, -3.5, -3]}
|
|
154
|
+
castShadow
|
|
155
|
+
shadow={{ normalBias: 0.02, camera: { near: 1, far: 20 } }}
|
|
156
|
+
/>
|
|
157
|
+
<Mesh geometry={plane({ width: 9, height: 9, label: "meadow" })} material={lit({ color: [0.24, 0.27, 0.24] })} rotation={[-Math.PI / 2, 0, 0]} />
|
|
124
158
|
<Group rotation={[0, spin(), 0]}>
|
|
125
|
-
<
|
|
159
|
+
<RecordMesh
|
|
126
160
|
geometry={box({ label: "rock" })}
|
|
127
|
-
material={instancedLook.instance()}
|
|
161
|
+
material={instancedLook.instance({ params: LOOK })}
|
|
128
162
|
records={rocks(400)}
|
|
129
163
|
bounds={[-3.9, 0, -3.9, 3.9, 0.2, 3.9]}
|
|
164
|
+
castShadow
|
|
130
165
|
/>
|
|
131
|
-
<
|
|
166
|
+
<RecordMesh
|
|
132
167
|
geometry={cone({ radius: 0.3, height: 1, radialSegments: 10, label: "pine" })}
|
|
133
|
-
material={instancedLook.instance()}
|
|
168
|
+
material={instancedLook.instance({ params: LOOK })}
|
|
134
169
|
records={pines(PINE_COUNT)}
|
|
135
170
|
bounds={[-2.8, 0, -2.8, 2.8, 0.8, 2.8]}
|
|
171
|
+
castShadow
|
|
136
172
|
ref={m => (pinesMesh = m)}
|
|
137
173
|
/>
|
|
138
174
|
</Group>
|
|
@@ -143,10 +179,14 @@ function App() {
|
|
|
143
179
|
}
|
|
144
180
|
|
|
145
181
|
// One class, one compiled pipeline; each mesh gets its own instance() so
|
|
146
|
-
// per-mesh uniforms stay independent
|
|
182
|
+
// per-mesh uniforms stay independent. The fragment is the stock lit one,
|
|
183
|
+
// so the instance carries lit's per-entry uniforms: a white base (the
|
|
184
|
+
// tint arrives per instance through vColor) and a modest highlight.
|
|
185
|
+
const LOOK = { uColor: [1, 1, 1, 1], uSpecular: 0.25, uShininess: 30 }
|
|
147
186
|
let instancedLook = shaderMaterialClass({
|
|
148
187
|
vertex: INSTANCE_VERTEX,
|
|
149
|
-
|
|
188
|
+
shadowVertex: INSTANCE_SHADOW_VERTEX,
|
|
189
|
+
fragment: litFragment({ vertexColors: true }),
|
|
150
190
|
instanceAttributes: [
|
|
151
191
|
{ name: "iPos", format: "vec3" },
|
|
152
192
|
{ name: "iScale", format: "f32" },
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Spot and point lights: a dark courtyard under three lamps.
|
|
2
|
+
// - A warm SOFT spot (angle 26, penumbra 0.4) swings from a rig above
|
|
3
|
+
// the crates - ONE setTransform on the parent group per frame, and
|
|
4
|
+
// the pool of light sweeps the floor with a wide fading rim
|
|
5
|
+
// (uLightDir/uLightPos are core-driven, so the moving lamp costs no
|
|
6
|
+
// per-frame JS beyond the rig rotation).
|
|
7
|
+
// - A cool HARD spot (angle 11, penumbra 0.05) stands still, aimed by
|
|
8
|
+
// its `direction` prop at the knot on the pedestal: a crisp disc of
|
|
9
|
+
// light with a sharp rim, for contrast with the soft one.
|
|
10
|
+
// - A blue point bulb orbits between the crates on its own rig: light
|
|
11
|
+
// in every direction, no cone, falling off on the floor around it -
|
|
12
|
+
// and casting: the crates throw blue-edged shadows that wheel
|
|
13
|
+
// around the courtyard as the bulb passes.
|
|
14
|
+
// Everything casts: a perspective map of each spot's cone (one shadow
|
|
15
|
+
// slot each) and the bulb's six face maps (six slots) - together
|
|
16
|
+
// exactly the MAX_SHADOW_MAPS budget of 8, all tiles of the scene's
|
|
17
|
+
// one atlas. Faces outside every cone and
|
|
18
|
+
// range stay near-black - the hemisphere idles at 0.05.
|
|
19
|
+
//
|
|
20
|
+
// Intensity under the default decay 2 behaves like candela: the light
|
|
21
|
+
// that reaches a face is `intensity / d^2`, so a lamp 5 units up needs
|
|
22
|
+
// intensity ~40 for a bright pool, not ~2. `distance` then windows the
|
|
23
|
+
// falloff to zero so the pool ends instead of trailing off forever.
|
|
24
|
+
import { onFrame, pct, render } from "@solidrt/core"
|
|
25
|
+
import { box, cylinder, Group, HemisphereLight, lit, Mesh, PerspectiveCamera, plane, PointLight, Scene, setTransform, SpotLight, torusKnot } from "@solidrt/3d"
|
|
26
|
+
import type { SceneNode } from "@solidrt/3d"
|
|
27
|
+
|
|
28
|
+
function App() {
|
|
29
|
+
let swing!: SceneNode
|
|
30
|
+
let orbit!: SceneNode
|
|
31
|
+
onFrame(tick => {
|
|
32
|
+
// The lamp sways about z so its pool sweeps left-right; the bulb
|
|
33
|
+
// circles the crates on its own axis.
|
|
34
|
+
setTransform(swing, { rotation: [0, 0, Math.sin(tick / 2000) * 0.35] })
|
|
35
|
+
setTransform(orbit, { rotation: [0, tick / 1500, 0] })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
let ground = lit({ color: [0.6, 0.6, 0.62] })
|
|
39
|
+
let crate = lit({ color: [0.7, 0.55, 0.4] })
|
|
40
|
+
let gold = lit({ color: [0.9, 0.75, 0.3], specular: 0.6, shininess: 50 })
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<window>
|
|
44
|
+
<view width={pct(100)} height={pct(100)}>
|
|
45
|
+
<Scene clearColor={[0.02, 0.02, 0.03, 1]} samples={4} label="lamps">
|
|
46
|
+
<PerspectiveCamera fov={50} position={[0, 5, 8]} lookAt={[0, 0.5, 0]} />
|
|
47
|
+
<HemisphereLight sky={[0.05, 0.05, 0.06]} ground={[0.02, 0.02, 0.02]} />
|
|
48
|
+
<Group ref={g => (swing = g)} position={[0, 5, 0]}>
|
|
49
|
+
<SpotLight
|
|
50
|
+
color={[1, 0.85, 0.6]}
|
|
51
|
+
intensity={40}
|
|
52
|
+
angle={26}
|
|
53
|
+
penumbra={0.4}
|
|
54
|
+
distance={14}
|
|
55
|
+
castShadow
|
|
56
|
+
shadow={{ normalBias: 0.03 }}
|
|
57
|
+
/>
|
|
58
|
+
</Group>
|
|
59
|
+
<SpotLight
|
|
60
|
+
position={[-4.5, 5, 1]}
|
|
61
|
+
direction={[1.3, -5, -2.2]}
|
|
62
|
+
color={[0.7, 0.85, 1]}
|
|
63
|
+
intensity={35}
|
|
64
|
+
angle={11}
|
|
65
|
+
penumbra={0.05}
|
|
66
|
+
distance={12}
|
|
67
|
+
castShadow
|
|
68
|
+
shadow={{ mapSize: 512, normalBias: 0.03 }}
|
|
69
|
+
/>
|
|
70
|
+
<Group ref={g => (orbit = g)}>
|
|
71
|
+
<PointLight
|
|
72
|
+
position={[2.6, 1, 0]}
|
|
73
|
+
color={[0.4, 0.6, 1]}
|
|
74
|
+
intensity={3}
|
|
75
|
+
distance={8}
|
|
76
|
+
castShadow
|
|
77
|
+
shadow={{ mapSize: 512, normalBias: 0.03 }}
|
|
78
|
+
/>
|
|
79
|
+
</Group>
|
|
80
|
+
<Mesh geometry={plane({ width: 20, height: 20 })} material={ground} rotation={[-Math.PI / 2, 0, 0]} />
|
|
81
|
+
<Mesh geometry={box()} material={crate} position={[-1.2, 0.5, 0]} castShadow />
|
|
82
|
+
<Mesh geometry={box()} material={crate} position={[1.6, 0.5, 0.8]} rotation={[0, 0.6, 0]} castShadow />
|
|
83
|
+
<Mesh geometry={box({ width: 0.8, height: 1.6, depth: 0.8 })} material={crate} position={[0.3, 0.8, -1.5]} castShadow />
|
|
84
|
+
<Mesh geometry={cylinder({ radiusTop: 0.35, radiusBottom: 0.45, height: 1 })} material={ground} position={[-3.2, 0.5, -1.2]} castShadow />
|
|
85
|
+
<Mesh geometry={torusKnot({ radius: 0.35, tube: 0.12 })} material={gold} position={[-3.2, 1.5, -1.2]} castShadow />
|
|
86
|
+
</Scene>
|
|
87
|
+
</view>
|
|
88
|
+
</window>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
render(() => <App />)
|
package/examples/lit.tsx
CHANGED
|
@@ -14,8 +14,6 @@ import { createSignal, onFrame, pct, render } from "@solidrt/core"
|
|
|
14
14
|
import { createTexture } from "@solidrt/core/gpu"
|
|
15
15
|
import { box, DirectionalLight, Group, HemisphereLight, lit, Mesh, PerspectiveCamera, Scene, sphere, torusKnot, plane } from "@solidrt/3d"
|
|
16
16
|
|
|
17
|
-
const SIZE = 720
|
|
18
|
-
|
|
19
17
|
function checker(): ReturnType<typeof createTexture> {
|
|
20
18
|
let n = 64
|
|
21
19
|
let data = new Uint8Array(n * n * 4)
|
|
@@ -25,7 +23,7 @@ function checker(): ReturnType<typeof createTexture> {
|
|
|
25
23
|
data.set([v, v, v, 255], (y * n + x) * 4)
|
|
26
24
|
}
|
|
27
25
|
}
|
|
28
|
-
return createTexture(data, n, n, { wrap: "repeat", mipmap: true })
|
|
26
|
+
return createTexture(data, n, n, { format: "rgba8-srgb", wrap: "repeat", mipmap: true })
|
|
29
27
|
}
|
|
30
28
|
|
|
31
29
|
function App() {
|
|
@@ -41,8 +39,8 @@ function App() {
|
|
|
41
39
|
|
|
42
40
|
return (
|
|
43
41
|
<window>
|
|
44
|
-
<view width={pct(100)} height={pct(100)}
|
|
45
|
-
<Scene
|
|
42
|
+
<view width={pct(100)} height={pct(100)}>
|
|
43
|
+
<Scene clearColor={[0.07, 0.07, 0.1, 1]} samples={4} label="lit">
|
|
46
44
|
<PerspectiveCamera fov={50} position={[0, 2.6, 5]} lookAt={[0, 0.4, 0]} />
|
|
47
45
|
<HemisphereLight sky={[0.35, 0.38, 0.45]} ground={[0.12, 0.1, 0.08]} />
|
|
48
46
|
<Group rotation={[0, t(), 0]}>
|