@solidrt/3d 0.0.55 → 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 +1083 -183
- package/README.md +15 -7
- package/examples/README.md +79 -5
- package/examples/aim.tsx +2 -3
- package/examples/cascades.tsx +1 -4
- package/examples/collision.tsx +250 -0
- package/examples/first-person.tsx +119 -0
- package/examples/fleet.tsx +194 -0
- package/examples/fog.tsx +2 -3
- 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 +763 -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 +1343 -82
- package/src/gltf.ts +432 -112
- package/src/index.ts +57 -9
- package/src/light.ts +383 -0
- package/src/material.ts +667 -232
- 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 +204 -35
- 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 +1242 -1557
- 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
CHANGED
|
@@ -22,7 +22,6 @@ import { registerDebug } from "srt:dev"
|
|
|
22
22
|
import { cone, cylinder, DirectionalLight, HemisphereLight, lit, Mesh, PerspectiveCamera, plane, Scene, sphere, unlit } from "@solidrt/3d"
|
|
23
23
|
import type { FogOptions, Vec3 } from "@solidrt/3d"
|
|
24
24
|
|
|
25
|
-
const SIZE = 720
|
|
26
25
|
const FAR = 400
|
|
27
26
|
// The sky, shared by the clear and the fog so the horizon has no band.
|
|
28
27
|
const SKY: [number, number, number] = [0.72, 0.78, 0.86]
|
|
@@ -148,8 +147,8 @@ function App() {
|
|
|
148
147
|
|
|
149
148
|
return (
|
|
150
149
|
<window>
|
|
151
|
-
<view width={pct(100)} height={pct(100)}
|
|
152
|
-
<Scene
|
|
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">
|
|
153
152
|
<PerspectiveCamera fov={55} near={0.5} far={FAR} position={EYE} lookAt={lookAt()} />
|
|
154
153
|
<HemisphereLight sky={[0.55, 0.62, 0.75]} ground={[0.28, 0.24, 0.2]} />
|
|
155
154
|
<DirectionalLight color={[0.9, 0.85, 0.75]} rotation={[-0.9, 0.6, 0]} />
|
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]}>
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// The surface maps on lit(), all procedural so the example is
|
|
2
|
+
// self-contained. normalMap bends the lighting per texel with NO tangent
|
|
3
|
+
// channel (the frame comes from screen-space derivatives) - the flat
|
|
4
|
+
// plane and the sphere both show relief from one bump texture.
|
|
5
|
+
// emissive/emissiveMap add light the lights do not provide (the window
|
|
6
|
+
// grid stays lit on the dark side). specularMap masks the highlight
|
|
7
|
+
// (the striped sphere is chrome on the bright bands, rubber between).
|
|
8
|
+
// mapTransform scrolls the belt's uv per frame with one setMeshParams
|
|
9
|
+
// write - per MATERIAL, Godot's uv1_offset/scale, not Three's
|
|
10
|
+
// per-texture transform. lightMap adds a baked glow by the aUV2 channel
|
|
11
|
+
// (withAttribute) at zero light cost.
|
|
12
|
+
|
|
13
|
+
import { createSignal, onFrame, pct, render } from "@solidrt/core"
|
|
14
|
+
import { createTexture } from "@solidrt/core/gpu"
|
|
15
|
+
import { box, DirectionalLight, Group, HemisphereLight, lit, Mesh, PerspectiveCamera, plane, Scene, setMeshParams, sphere, withAttribute } from "@solidrt/3d"
|
|
16
|
+
import type { MeshNode } from "@solidrt/3d"
|
|
17
|
+
|
|
18
|
+
// A tangent-space normal map from a procedural height field (a grid of
|
|
19
|
+
// round bumps): finite-difference the height, pack the normal as
|
|
20
|
+
// OpenGL-style rgb. What every baked normal map contains, minus the
|
|
21
|
+
// authoring tool.
|
|
22
|
+
function bumpNormalMap() {
|
|
23
|
+
let n = 128
|
|
24
|
+
const BUMPS = 4 // bump grid cells per texture edge
|
|
25
|
+
let height = (x: number, y: number): number => {
|
|
26
|
+
let u = ((x * BUMPS) / n) % 1
|
|
27
|
+
let v = ((y * BUMPS) / n) % 1
|
|
28
|
+
let dx = u - 0.5
|
|
29
|
+
let dy = v - 0.5
|
|
30
|
+
let d = Math.sqrt(dx * dx + dy * dy)
|
|
31
|
+
return d < 0.35 ? Math.cos((d / 0.35) * Math.PI * 0.5) : 0
|
|
32
|
+
}
|
|
33
|
+
const STRENGTH = 3 // height units per texel step: how steep the bumps read
|
|
34
|
+
let data = new Uint8Array(n * n * 4)
|
|
35
|
+
for (let y = 0; y < n; y++) {
|
|
36
|
+
for (let x = 0; x < n; x++) {
|
|
37
|
+
let sx = (height(x + 1, y) - height(x - 1, y)) * STRENGTH
|
|
38
|
+
let sy = (height(x, y + 1) - height(x, y - 1)) * STRENGTH
|
|
39
|
+
let inv = 1 / Math.sqrt(sx * sx + sy * sy + 1)
|
|
40
|
+
data.set([((-sx * inv) * 0.5 + 0.5) * 255, ((-sy * inv) * 0.5 + 0.5) * 255, (inv * 0.5 + 0.5) * 255, 255], (y * n + x) * 4)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return createTexture(data, n, n, { wrap: "repeat", mipmap: true })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A window grid: dark wall, warm lit windows - the emissive map.
|
|
47
|
+
function windowMap() {
|
|
48
|
+
let n = 64
|
|
49
|
+
let data = new Uint8Array(n * n * 4)
|
|
50
|
+
for (let y = 0; y < n; y++) {
|
|
51
|
+
for (let x = 0; x < n; x++) {
|
|
52
|
+
let on = (x >> 3) % 2 === 1 && (y >> 3) % 2 === 1 && (x & 7) > 1 && (x & 7) < 6 && (y & 7) > 1 && (y & 7) < 6
|
|
53
|
+
data.set(on ? [255, 190, 90, 255] : [0, 0, 0, 255], (y * n + x) * 4)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return createTexture(data, n, n, { format: "rgba8-srgb", mipmap: true })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Vertical stripes in the red channel: the specular mask.
|
|
60
|
+
function stripeMap() {
|
|
61
|
+
let n = 64
|
|
62
|
+
let data = new Uint8Array(n * n * 4)
|
|
63
|
+
for (let y = 0; y < n; y++) {
|
|
64
|
+
for (let x = 0; x < n; x++) {
|
|
65
|
+
let v = (x >> 3) & 1 ? 255 : 0
|
|
66
|
+
data.set([v, v, v, 255], (y * n + x) * 4)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return createTexture(data, n, n, { wrap: "repeat", mipmap: true })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Chevrons for the scrolling belt.
|
|
73
|
+
function chevronMap() {
|
|
74
|
+
let n = 64
|
|
75
|
+
let data = new Uint8Array(n * n * 4)
|
|
76
|
+
for (let y = 0; y < n; y++) {
|
|
77
|
+
for (let x = 0; x < n; x++) {
|
|
78
|
+
let on = ((x + Math.abs(y - n / 2)) >> 3) & 1
|
|
79
|
+
data.set(on ? [235, 220, 60, 255] : [40, 40, 45, 255], (y * n + x) * 4)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return createTexture(data, n, n, { format: "rgba8-srgb", wrap: "repeat", mipmap: true })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A radial warm glow, the stand-in for an offline lightmap bake.
|
|
86
|
+
function bakedGlowMap() {
|
|
87
|
+
let n = 64
|
|
88
|
+
let data = new Uint8Array(n * n * 4)
|
|
89
|
+
for (let y = 0; y < n; y++) {
|
|
90
|
+
for (let x = 0; x < n; x++) {
|
|
91
|
+
let dx = x / n - 0.5
|
|
92
|
+
let dy = y / n - 0.5
|
|
93
|
+
let g = Math.max(0, 1 - Math.sqrt(dx * dx + dy * dy) * 2.2)
|
|
94
|
+
data.set([255 * g, 180 * g, 90 * g, 255], (y * n + x) * 4)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return createTexture(data, n, n, { mipmap: true })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function App() {
|
|
101
|
+
let [t, setT] = createSignal(0)
|
|
102
|
+
let beltMesh: MeshNode | undefined
|
|
103
|
+
onFrame(tick => {
|
|
104
|
+
setT(tick / 1000)
|
|
105
|
+
// The scrolling belt: one per-frame uniform write, the setTransform
|
|
106
|
+
// split - structure through signals, frame-rate values through refs.
|
|
107
|
+
if (beltMesh !== undefined) setMeshParams(beltMesh, { uMapTransform: [3, 1, tick / 1500, 0] })
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
let bumps = bumpNormalMap()
|
|
111
|
+
let bumpy = lit({ color: [0.75, 0.5, 0.3], normalMap: bumps, specular: 0.5, shininess: 40 })
|
|
112
|
+
let flat = lit({ color: [0.75, 0.5, 0.3], specular: 0.5, shininess: 40 })
|
|
113
|
+
let city = lit({ color: [0.25, 0.28, 0.34], emissiveMap: windowMap() })
|
|
114
|
+
let striped = lit({ color: [0.2, 0.22, 0.26], specularMap: stripeMap(), shininess: 90 })
|
|
115
|
+
let belt = lit({ map: chevronMap(), mapTransform: { repeat: [3, 1] } })
|
|
116
|
+
// The ground carries its own uv2 island (here just the 0..1 plane UVs
|
|
117
|
+
// recomputed from position) and adds the "baked" glow with no light.
|
|
118
|
+
let groundGeometry = withAttribute(plane({ width: 8, height: 8 }), { name: "aUV2", format: "vec2" }, (_i, pos) => [
|
|
119
|
+
pos[0] / 8 + 0.5,
|
|
120
|
+
pos[1] / 8 + 0.5,
|
|
121
|
+
])
|
|
122
|
+
let ground = lit({ color: [0.45, 0.45, 0.5], lightMap: bakedGlowMap(), lightMapIntensity: 1.5 })
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<window>
|
|
126
|
+
<view width={pct(100)} height={pct(100)}>
|
|
127
|
+
<Scene clearColor={[0.06, 0.06, 0.09, 1]} samples={4} label="materials">
|
|
128
|
+
<PerspectiveCamera fov={50} position={[0, 2.8, 5.2]} lookAt={[0, 0.5, 0]} />
|
|
129
|
+
<HemisphereLight sky={[0.3, 0.33, 0.4]} ground={[0.1, 0.09, 0.08]} />
|
|
130
|
+
<Group rotation={[0, t() / 2, 0]}>
|
|
131
|
+
<DirectionalLight direction={[0.7, -0.8, 0.2]} color={[1, 0.95, 0.85]} intensity={1} />
|
|
132
|
+
</Group>
|
|
133
|
+
<Mesh geometry={groundGeometry} material={ground} rotation={[-Math.PI / 2, 0, 0]} />
|
|
134
|
+
<Mesh geometry={sphere({ radius: 0.55 })} material={bumpy} position={[-1.7, 0.55, 0.6]} />
|
|
135
|
+
<Mesh geometry={sphere({ radius: 0.55 })} material={flat} position={[-1.7, 0.55, -0.9]} />
|
|
136
|
+
<Mesh geometry={box({ width: 1, height: 1.6, depth: 1 })} material={city} position={[0, 0.8, -0.6]} rotation={[0, t() / 3, 0]} />
|
|
137
|
+
<Mesh geometry={sphere({ radius: 0.55 })} material={striped} position={[1.7, 0.55, -0.6]} rotation={[0, t() / 2, 0]} />
|
|
138
|
+
<Mesh
|
|
139
|
+
geometry={box({ width: 2.2, height: 0.12, depth: 0.7 })}
|
|
140
|
+
material={belt}
|
|
141
|
+
position={[0.6, 0.06, 1.6]}
|
|
142
|
+
ref={mesh => (beltMesh = mesh)}
|
|
143
|
+
/>
|
|
144
|
+
</Scene>
|
|
145
|
+
</view>
|
|
146
|
+
</window>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
render(() => <App />)
|