@solidrt/3d 0.0.58 → 0.0.60
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 +734 -264
- package/README.md +4 -2
- package/examples/README.md +50 -4
- package/examples/bloom.tsx +113 -0
- package/examples/cascades.tsx +4 -4
- package/examples/collision.tsx +6 -6
- package/examples/exits.tsx +96 -0
- package/examples/first-person.tsx +5 -5
- package/examples/fleet.tsx +4 -4
- package/examples/fog.tsx +5 -5
- package/examples/instanced.tsx +70 -50
- package/examples/lamps.tsx +4 -4
- package/examples/lod.tsx +125 -0
- package/examples/materials.tsx +9 -9
- package/examples/morph.tsx +174 -0
- package/examples/normals.tsx +118 -0
- package/examples/{lit.tsx → phong.tsx} +9 -9
- package/examples/polyhedra.tsx +61 -0
- package/examples/scene-atlas.tsx +70 -0
- package/examples/scene-views.tsx +5 -5
- package/examples/shadows.tsx +9 -9
- package/examples/sky-lit.tsx +3 -3
- package/examples/skybox.tsx +5 -5
- package/examples/standard.tsx +2 -2
- package/examples/streams.tsx +122 -0
- package/examples/sweep-paths.tsx +5 -5
- package/examples/wireframe.tsx +24 -2
- package/package.json +3 -3
- package/src/clip.ts +18 -5
- package/src/color.ts +11 -2
- package/src/components/context.tsx +11 -2
- package/src/components/directional-light.tsx +2 -2
- package/src/components/first-person-camera.tsx +4 -4
- package/src/components/group.tsx +9 -5
- package/src/components/hemisphere-light.tsx +2 -2
- package/src/components/index.ts +4 -1
- package/src/components/instanced-mesh.tsx +28 -10
- package/src/components/lod.tsx +102 -0
- package/src/components/mesh.tsx +24 -6
- package/src/components/node-props.ts +35 -5
- package/src/components/orbit-camera.tsx +15 -12
- package/src/components/point-light.tsx +2 -2
- package/src/components/record-mesh.tsx +12 -7
- package/src/components/scene.tsx +55 -2
- package/src/components/spot-light.tsx +2 -2
- package/src/components/sprite.tsx +2 -2
- package/src/components/view3d.tsx +18 -1
- package/src/environment.ts +9 -8
- package/src/first-person.ts +125 -38
- package/src/geometry-gpu.ts +172 -39
- package/src/geometry.ts +1573 -239
- package/src/glsl.ts +398 -113
- package/src/gltf.ts +363 -141
- package/src/index.ts +31 -21
- package/src/light.ts +7 -8
- package/src/lod.ts +148 -0
- package/src/material.ts +316 -202
- package/src/mesh.ts +490 -256
- package/src/mixer.ts +27 -14
- package/src/model-file.ts +54 -15
- package/src/model.ts +86 -21
- package/src/motion.ts +17 -0
- package/src/node.ts +421 -22
- package/src/orbit.ts +378 -116
- package/src/profile.ts +4 -25
- package/src/resolve.ts +181 -0
- package/src/scene-shadows.ts +94 -93
- package/src/scene.ts +605 -185
- package/src/sweep.ts +27 -8
- package/tools/model.ts +3 -1
package/examples/instanced.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Instanced meshes: one draw entry covering a whole population. A material
|
|
2
|
-
// class declares `
|
|
2
|
+
// class declares `instanceBuffers`, createRecordMesh (here via the
|
|
3
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
|
|
@@ -9,9 +9,12 @@
|
|
|
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
|
-
// wants in general. The
|
|
13
|
-
//
|
|
14
|
-
// the
|
|
12
|
+
// wants in general. The record layout is any vertex layout: the tint is
|
|
13
|
+
// a unorm8x4, four bytes where four floats would be sixteen, written as
|
|
14
|
+
// 0..1 values through the same attribute accessor a geometry channel
|
|
15
|
+
// uses. The explicit `bounds` cover the scatter so picking still works
|
|
16
|
+
// (one conservative box around the population; omit bounds and the mesh
|
|
17
|
+
// simply never picks).
|
|
15
18
|
//
|
|
16
19
|
// The fleets CAST: the class declares `shadowVertex` - its vertex stage
|
|
17
20
|
// reduced to the position math, instance placement included - and with it
|
|
@@ -22,29 +25,33 @@
|
|
|
22
25
|
//
|
|
23
26
|
// The fleets are also lit, shadowed and fogged exactly like the ground,
|
|
24
27
|
// with no lighting code of their own: the class pairs its instanced
|
|
25
|
-
// vertex stage with the stock `
|
|
28
|
+
// vertex stage with the stock `phongFragment` (the fragment `phong` compiles),
|
|
26
29
|
// which is the first tier of custom looks - a vertex stage that writes
|
|
27
30
|
// the lit varyings gets the whole scene for free. The per-instance tint
|
|
28
31
|
// rides the `vertexColors` path as vColor.
|
|
29
32
|
import { createSignal, onFrame, pct, render } from "@solidrt/core"
|
|
30
33
|
import { glsl } from "@solidrt/core/gpu"
|
|
31
34
|
import {
|
|
35
|
+
attributeAccess,
|
|
32
36
|
box,
|
|
33
37
|
cone,
|
|
34
38
|
DirectionalLight,
|
|
35
39
|
Group,
|
|
36
40
|
HemisphereLight,
|
|
41
|
+
layoutStride,
|
|
37
42
|
RecordMesh,
|
|
38
|
-
|
|
43
|
+
phong,
|
|
39
44
|
Mesh,
|
|
40
45
|
PerspectiveCamera,
|
|
41
46
|
plane,
|
|
42
47
|
Scene,
|
|
43
48
|
setRecordCount,
|
|
44
49
|
shaderMaterialClass,
|
|
50
|
+
vertexView,
|
|
45
51
|
} from "@solidrt/3d"
|
|
46
52
|
import type { RecordMeshNode } from "@solidrt/3d"
|
|
47
|
-
import {
|
|
53
|
+
import type { VertexAttribute } from "@solidrt/core/gpu"
|
|
54
|
+
import { phongFragment } from "@solidrt/3d/glsl"
|
|
48
55
|
|
|
49
56
|
// The lit varyings (vWorldPos, vNormal, vUv, and vColor for the tint), as
|
|
50
57
|
// LIT_VERTEX writes them, from an instanced placement.
|
|
@@ -54,7 +61,7 @@ const INSTANCE_VERTEX = glsl`
|
|
|
54
61
|
in vec2 aUV;
|
|
55
62
|
in vec3 iPos;
|
|
56
63
|
in float iScale;
|
|
57
|
-
in
|
|
64
|
+
in vec4 iTint;
|
|
58
65
|
out vec3 vWorldPos;
|
|
59
66
|
out vec3 vNormal;
|
|
60
67
|
out vec2 vUv;
|
|
@@ -69,7 +76,7 @@ const INSTANCE_VERTEX = glsl`
|
|
|
69
76
|
vWorldPos = world.xyz;
|
|
70
77
|
vNormal = mat3(uNormal) * aNormal;
|
|
71
78
|
vUv = aUV;
|
|
72
|
-
vColor =
|
|
79
|
+
vColor = iTint;
|
|
73
80
|
}
|
|
74
81
|
`
|
|
75
82
|
|
|
@@ -87,44 +94,61 @@ const INSTANCE_SHADOW_VERTEX = glsl`
|
|
|
87
94
|
}
|
|
88
95
|
`
|
|
89
96
|
|
|
90
|
-
// One record per instance,
|
|
91
|
-
const
|
|
97
|
+
// One record per instance, tightly packed in attribute order: 20 bytes.
|
|
98
|
+
const RECORD: VertexAttribute[] = [
|
|
99
|
+
{ name: "iPos", format: "float32x3" },
|
|
100
|
+
{ name: "iScale", format: "float32" },
|
|
101
|
+
{ name: "iTint", format: "unorm8x4" },
|
|
102
|
+
]
|
|
92
103
|
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
104
|
+
// `count` records of RECORD, filled through the attribute accessors:
|
|
105
|
+
// `place` gives a record's position and scale, `tint` its color, and the
|
|
106
|
+
// codecs pack the bytes (the tint's 0..1 floats become unorm bytes).
|
|
107
|
+
function records(count: number, place: (i: number) => [number, number, number, number], tint: (i: number) => [number, number, number]): ArrayBufferView {
|
|
108
|
+
let out = vertexView(RECORD, new ArrayBuffer(count * layoutStride(RECORD)))
|
|
109
|
+
let pos = attributeAccess(out, RECORD, "iPos")!
|
|
110
|
+
let scale = attributeAccess(out, RECORD, "iScale")!
|
|
111
|
+
let color = attributeAccess(out, RECORD, "iTint")!
|
|
97
112
|
for (let i = 0; i < count; i++) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
records[o + 6] = 0.45
|
|
113
|
+
let [x, y, z, s] = place(i)
|
|
114
|
+
pos.set(i, 0, x)
|
|
115
|
+
pos.set(i, 1, y)
|
|
116
|
+
pos.set(i, 2, z)
|
|
117
|
+
scale.set(i, 0, s)
|
|
118
|
+
let [r, g, b] = tint(i)
|
|
119
|
+
color.set(i, 0, r)
|
|
120
|
+
color.set(i, 1, g)
|
|
121
|
+
color.set(i, 2, b)
|
|
122
|
+
color.set(i, 3, 1)
|
|
109
123
|
}
|
|
110
|
-
return
|
|
124
|
+
return out
|
|
111
125
|
}
|
|
112
126
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
127
|
+
// A deterministic scatter (no per-run surprises when eyeballing).
|
|
128
|
+
function rocks(count: number): ArrayBufferView {
|
|
129
|
+
let a = 0
|
|
130
|
+
return records(
|
|
131
|
+
count,
|
|
132
|
+
i => {
|
|
133
|
+
a += 2.399963 // golden angle: an even spiral scatter
|
|
134
|
+
let r = 0.35 + 3.4 * Math.sqrt((i + 0.5) / count)
|
|
135
|
+
let s = 0.05 + 0.11 * ((i * 7) % 10) / 10
|
|
136
|
+
return [Math.cos(a) * r, s / 2, Math.sin(a) * r, s]
|
|
137
|
+
},
|
|
138
|
+
i => [0.55 + 0.3 * ((i * 3) % 5) / 5, 0.5 + 0.2 * ((i * 11) % 7) / 7, 0.45],
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function pines(count: number): ArrayBufferView {
|
|
143
|
+
return records(
|
|
144
|
+
count,
|
|
145
|
+
i => {
|
|
146
|
+
let a = (i / count) * Math.PI * 2
|
|
147
|
+
let s = 0.5 + 0.25 * ((i * 5) % 8) / 8
|
|
148
|
+
return [Math.cos(a) * 2.4, s / 2, Math.sin(a) * 2.4, s]
|
|
149
|
+
},
|
|
150
|
+
i => [0.15, 0.4 + 0.25 * ((i * 3) % 6) / 6, 0.2],
|
|
151
|
+
)
|
|
128
152
|
}
|
|
129
153
|
|
|
130
154
|
const PINE_COUNT = 48
|
|
@@ -154,7 +178,7 @@ function App() {
|
|
|
154
178
|
castShadow
|
|
155
179
|
shadow={{ normalBias: 0.02, camera: { near: 1, far: 20 } }}
|
|
156
180
|
/>
|
|
157
|
-
<Mesh geometry={plane({ width: 9, height: 9, label: "meadow" })} material={
|
|
181
|
+
<Mesh geometry={plane({ width: 9, height: 9, label: "meadow" })} material={phong({ color: [0.24, 0.27, 0.24] })} rotation={[-Math.PI / 2, 0, 0]} />
|
|
158
182
|
<Group rotation={[0, spin(), 0]}>
|
|
159
183
|
<RecordMesh
|
|
160
184
|
geometry={box({ label: "rock" })}
|
|
@@ -180,18 +204,14 @@ function App() {
|
|
|
180
204
|
|
|
181
205
|
// One class, one compiled pipeline; each mesh gets its own instance() so
|
|
182
206
|
// per-mesh uniforms stay independent. The fragment is the stock lit one,
|
|
183
|
-
// so the instance carries
|
|
207
|
+
// so the instance carries phong's per-entry uniforms: a white base (the
|
|
184
208
|
// tint arrives per instance through vColor) and a modest highlight.
|
|
185
209
|
const LOOK = { uColor: [1, 1, 1, 1], uSpecular: 0.25, uShininess: 30 }
|
|
186
210
|
let instancedLook = shaderMaterialClass({
|
|
187
211
|
vertex: INSTANCE_VERTEX,
|
|
188
212
|
shadowVertex: INSTANCE_SHADOW_VERTEX,
|
|
189
|
-
fragment:
|
|
190
|
-
|
|
191
|
-
{ name: "iPos", format: "vec3" },
|
|
192
|
-
{ name: "iScale", format: "f32" },
|
|
193
|
-
{ name: "iTint", format: "vec3" },
|
|
194
|
-
],
|
|
213
|
+
fragment: phongFragment({ vertexColors: true }),
|
|
214
|
+
instanceBuffers: [{ attributes: RECORD }],
|
|
195
215
|
label: "instanced-look",
|
|
196
216
|
})
|
|
197
217
|
|
package/examples/lamps.tsx
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// intensity ~40 for a bright pool, not ~2. `distance` then windows the
|
|
23
23
|
// falloff to zero so the pool ends instead of trailing off forever.
|
|
24
24
|
import { onFrame, pct, render } from "@solidrt/core"
|
|
25
|
-
import { box, cylinder, Group, HemisphereLight,
|
|
25
|
+
import { box, cylinder, Group, HemisphereLight, phong, Mesh, PerspectiveCamera, plane, PointLight, Scene, setTransform, SpotLight, torusKnot } from "@solidrt/3d"
|
|
26
26
|
import type { SceneNode } from "@solidrt/3d"
|
|
27
27
|
|
|
28
28
|
function App() {
|
|
@@ -35,9 +35,9 @@ function App() {
|
|
|
35
35
|
setTransform(orbit, { rotation: [0, tick / 1500, 0] })
|
|
36
36
|
})
|
|
37
37
|
|
|
38
|
-
let ground =
|
|
39
|
-
let crate =
|
|
40
|
-
let gold =
|
|
38
|
+
let ground = phong({ color: [0.6, 0.6, 0.62] })
|
|
39
|
+
let crate = phong({ color: [0.7, 0.55, 0.4] })
|
|
40
|
+
let gold = phong({ color: [0.9, 0.75, 0.3], specular: 0.6, shininess: 50 })
|
|
41
41
|
|
|
42
42
|
return (
|
|
43
43
|
<window>
|
package/examples/lod.tsx
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Level of detail: a thousand trees as ONE instanced LOD - three draw
|
|
2
|
+
// entries whatever the spread, each instance drawing the level its own
|
|
3
|
+
// projected size picks - and one hero solid as a `<Lod>` group of three
|
|
4
|
+
// meshes cross-fading between levels. The choice is the spatial core's,
|
|
5
|
+
// per target, after each flush: the camera flies over the field and no
|
|
6
|
+
// per-frame JS touches a single tree.
|
|
7
|
+
//
|
|
8
|
+
// The levels are tinted on purpose (green, olive, blue) so the hand-overs
|
|
9
|
+
// show; a real forest would share one material. Sizes are projected
|
|
10
|
+
// heights as a fraction of the viewport, measured on the sphere around
|
|
11
|
+
// the object's box (half its diagonal, ~2.8 for a tree): under this 60
|
|
12
|
+
// degree camera a tree measures 1.73 * 2.8 / distance, so the thresholds
|
|
13
|
+
// below hand over at roughly 55 and 160 world units. The hero's `fade`
|
|
14
|
+
// widens each of its thresholds into a band where both levels draw with
|
|
15
|
+
// complementary dithers; the forest switches hard, with the core's
|
|
16
|
+
// hysteresis, since instanced levels never fade.
|
|
17
|
+
//
|
|
18
|
+
// Debug: `fly { t }` parks the camera at flight time t (seconds) so a
|
|
19
|
+
// snapshot is deterministic; `state` reports the camera and the tree
|
|
20
|
+
// count.
|
|
21
|
+
import { onFrame, pct, render } from "@solidrt/core"
|
|
22
|
+
import { addInstance, cone, cylinder, DirectionalLight, HemisphereLight, icosahedron, InstancedLod, phong, Lod, Mesh, mergeGeometries, plane, Scene, transformGeometry, useScene } from "@solidrt/3d"
|
|
23
|
+
import type { InstancedMeshNode } from "@solidrt/3d"
|
|
24
|
+
import { registerDebug } from "srt:dev"
|
|
25
|
+
|
|
26
|
+
const TREES = 1000
|
|
27
|
+
// Half the side of the square field the trees scatter over.
|
|
28
|
+
const FIELD = 110
|
|
29
|
+
// The hand-over sizes of the forest, nearest first (the last is the cull
|
|
30
|
+
// threshold: 0 keeps the far level drawn at any distance).
|
|
31
|
+
const FOREST_SIZES = [0.09, 0.03, 0]
|
|
32
|
+
// The hero's hand-over sizes and its cross-fade band fraction.
|
|
33
|
+
const HERO_SIZES = [0.45, 0.18, 0]
|
|
34
|
+
const HERO_FADE = 0.35
|
|
35
|
+
|
|
36
|
+
// A tree at three costs: foliage cone over a trunk, 24 / 7 / 3 segments.
|
|
37
|
+
function tree(segments: number): ReturnType<typeof mergeGeometries> {
|
|
38
|
+
let foliage = transformGeometry(cone({ radius: 1.4, height: 3.2, radialSegments: segments }), { position: [0, 2.4, 0] })
|
|
39
|
+
let trunk = transformGeometry(cylinder({ radiusTop: 0.22, radiusBottom: 0.3, height: 1.6, radialSegments: Math.max(3, segments >> 1) }), { position: [0, 0.8, 0] })
|
|
40
|
+
return mergeGeometries([foliage, trunk], "tree-" + segments)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A deterministic scatter (a small LCG), so every run is the same field.
|
|
44
|
+
function scatter(mesh: InstancedMeshNode): void {
|
|
45
|
+
let seed = 12345
|
|
46
|
+
let rand = () => {
|
|
47
|
+
seed = (seed * 1103515245 + 12345) & 0x7fffffff
|
|
48
|
+
return seed / 0x7fffffff
|
|
49
|
+
}
|
|
50
|
+
for (let i = 0; i < TREES; i++) {
|
|
51
|
+
let x = (rand() * 2 - 1) * FIELD
|
|
52
|
+
let z = (rand() * 2 - 1) * FIELD
|
|
53
|
+
// Keep the hero's clearing empty.
|
|
54
|
+
if (x * x + z * z < 12 * 12) continue
|
|
55
|
+
let s = 0.7 + rand() * 0.6
|
|
56
|
+
addInstance(mesh, { position: [x, 0, z], rotation: [0, rand() * Math.PI * 2, 0], scale: [s, s, s] })
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let parked: number | null = null
|
|
61
|
+
let flightTime = 0
|
|
62
|
+
let eye: [number, number, number] = [0, 0, 0]
|
|
63
|
+
|
|
64
|
+
// The flight: a slow orbit whose radius breathes between the clearing and
|
|
65
|
+
// the field's edge, height following the radius, always looking a little
|
|
66
|
+
// ahead of the hero.
|
|
67
|
+
function flyTo(setCamera: (u: { position: [number, number, number]; target: [number, number, number] }) => void, t: number): void {
|
|
68
|
+
let radius = 14 + 80 * (0.5 - 0.5 * Math.cos(t * 0.22))
|
|
69
|
+
let theta = t * 0.18
|
|
70
|
+
eye = [radius * Math.cos(theta), 5 + radius * 0.22, radius * Math.sin(theta)]
|
|
71
|
+
setCamera({ position: eye, target: [-8 * Math.sin(theta), 2, 8 * Math.cos(theta)] })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function Flight() {
|
|
75
|
+
let { viewport } = useScene()
|
|
76
|
+
flyTo(viewport.setCamera, 0)
|
|
77
|
+
onFrame(tick => {
|
|
78
|
+
flightTime = parked ?? tick / 1000
|
|
79
|
+
flyTo(viewport.setCamera, flightTime)
|
|
80
|
+
})
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
registerDebug("fly", (args?: Record<string, unknown>) => {
|
|
85
|
+
parked = typeof args?.t === "number" ? args.t : null
|
|
86
|
+
return { parked }
|
|
87
|
+
})
|
|
88
|
+
registerDebug("state", () => ({ t: flightTime, eye, trees: TREES }))
|
|
89
|
+
|
|
90
|
+
function App() {
|
|
91
|
+
let hi = tree(24)
|
|
92
|
+
let mid = tree(7)
|
|
93
|
+
let lo = tree(3)
|
|
94
|
+
let leafHi = phong({ color: [0.2, 0.55, 0.2], instanced: true })
|
|
95
|
+
let leafMid = phong({ color: [0.5, 0.55, 0.15], instanced: true })
|
|
96
|
+
let leafLo = phong({ color: [0.2, 0.35, 0.6], instanced: true })
|
|
97
|
+
let ground = plane({ width: FIELD * 2.4, height: FIELD * 2.4 })
|
|
98
|
+
return (
|
|
99
|
+
<window>
|
|
100
|
+
<Scene camera={{ fov: 60, near: 0.5, far: 400 }} clearColor={[0.62, 0.74, 0.9, 1]} fog={{ color: [0.62, 0.74, 0.9], near: 120, far: 320 }} label="lod">
|
|
101
|
+
<Flight />
|
|
102
|
+
<HemisphereLight sky={[0.7, 0.8, 1]} ground={[0.3, 0.28, 0.22]} intensity={0.9} />
|
|
103
|
+
<DirectionalLight direction={[-0.5, -1, -0.3]} intensity={1.1} />
|
|
104
|
+
<Mesh geometry={ground} material={phong({ color: [0.36, 0.42, 0.24] })} rotation={[-Math.PI / 2, 0, 0]} />
|
|
105
|
+
<InstancedLod
|
|
106
|
+
capacity={TREES}
|
|
107
|
+
levels={[
|
|
108
|
+
{ geometry: hi, material: leafHi, size: FOREST_SIZES[0]! },
|
|
109
|
+
{ geometry: mid, material: leafMid, size: FOREST_SIZES[1]! },
|
|
110
|
+
{ geometry: lo, material: leafLo, size: FOREST_SIZES[2]! },
|
|
111
|
+
]}
|
|
112
|
+
label="forest"
|
|
113
|
+
ref={scatter}
|
|
114
|
+
/>
|
|
115
|
+
<Lod fade={HERO_FADE} position={[0, 3.5, 0]}>
|
|
116
|
+
<Mesh lodSize={HERO_SIZES[0]!} geometry={icosahedron({ radius: 3, detail: 3 })} material={phong({ color: [0.9, 0.35, 0.25], specular: 0.6 })} />
|
|
117
|
+
<Mesh lodSize={HERO_SIZES[1]!} geometry={icosahedron({ radius: 3, detail: 1 })} material={phong({ color: [0.9, 0.6, 0.2], specular: 0.6 })} />
|
|
118
|
+
<Mesh lodSize={HERO_SIZES[2]!} geometry={icosahedron({ radius: 3, detail: 0 })} material={phong({ color: [0.85, 0.8, 0.3] })} />
|
|
119
|
+
</Lod>
|
|
120
|
+
</Scene>
|
|
121
|
+
</window>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
render(() => <App />)
|
package/examples/materials.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The surface maps on
|
|
1
|
+
// The surface maps on phong(), all procedural so the example is
|
|
2
2
|
// self-contained. normalMap bends the lighting per texel with NO tangent
|
|
3
3
|
// channel (the frame comes from screen-space derivatives) - the flat
|
|
4
4
|
// plane and the sphere both show relief from one bump texture.
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { createSignal, onFrame, pct, render } from "@solidrt/core"
|
|
14
14
|
import { createTexture } from "@solidrt/core/gpu"
|
|
15
|
-
import { box, DirectionalLight, Group, HemisphereLight,
|
|
15
|
+
import { box, DirectionalLight, Group, HemisphereLight, phong, Mesh, PerspectiveCamera, plane, Scene, setMeshParams, sphere, withAttribute } from "@solidrt/3d"
|
|
16
16
|
import type { MeshNode } from "@solidrt/3d"
|
|
17
17
|
|
|
18
18
|
// A tangent-space normal map from a procedural height field (a grid of
|
|
@@ -108,18 +108,18 @@ function App() {
|
|
|
108
108
|
})
|
|
109
109
|
|
|
110
110
|
let bumps = bumpNormalMap()
|
|
111
|
-
let bumpy =
|
|
112
|
-
let flat =
|
|
113
|
-
let city =
|
|
114
|
-
let striped =
|
|
115
|
-
let belt =
|
|
111
|
+
let bumpy = phong({ color: [0.75, 0.5, 0.3], normalMap: bumps, specular: 0.5, shininess: 40 })
|
|
112
|
+
let flat = phong({ color: [0.75, 0.5, 0.3], specular: 0.5, shininess: 40 })
|
|
113
|
+
let city = phong({ color: [0.25, 0.28, 0.34], emissiveMap: windowMap() })
|
|
114
|
+
let striped = phong({ color: [0.2, 0.22, 0.26], specularMap: stripeMap(), shininess: 90 })
|
|
115
|
+
let belt = phong({ map: chevronMap(), mapTransform: { repeat: [3, 1] } })
|
|
116
116
|
// The ground carries its own uv2 island (here just the 0..1 plane UVs
|
|
117
117
|
// recomputed from position) and adds the "baked" glow with no light.
|
|
118
|
-
let groundGeometry = withAttribute(plane({ width: 8, height: 8 }), { name: "aUV2", format: "
|
|
118
|
+
let groundGeometry = withAttribute(plane({ width: 8, height: 8 }), { name: "aUV2", format: "float32x2" }, (_i, pos) => [
|
|
119
119
|
pos[0] / 8 + 0.5,
|
|
120
120
|
pos[1] / 8 + 0.5,
|
|
121
121
|
])
|
|
122
|
-
let ground =
|
|
122
|
+
let ground = phong({ color: [0.45, 0.45, 0.5], lightMap: bakedGlowMap(), lightMapIntensity: 1.5 })
|
|
123
123
|
|
|
124
124
|
return (
|
|
125
125
|
<window>
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Morph targets (blend shapes): a geometry carries named per-vertex
|
|
2
|
+
// deltas (`withMorphTargets`; a glTF's targets arrive the same way
|
|
3
|
+
// through the loader) and a `morph: true` material displaces its
|
|
4
|
+
// vertices by the weights the mesh owns - `setMorphWeights(mesh, {
|
|
5
|
+
// bulge: 0.7 })` by name, or every weight in target order, or the
|
|
6
|
+
// `morphWeights` prop. The weights live in the spatial core (one write
|
|
7
|
+
// lands at the next flush, a clip's weights track writes the same
|
|
8
|
+
// register), the deltas in one texture per geometry packed sparse by
|
|
9
|
+
// vertex, so a target that moves the top of the blob costs nothing at
|
|
10
|
+
// the bottom. The middle blob morphs, its shadow morphs with it (the
|
|
11
|
+
// depth pass walks the same targets); the left one draws the same
|
|
12
|
+
// geometry under a plain lit material - the base shape, targets
|
|
13
|
+
// ignored; the right one flips between two weight sets through the
|
|
14
|
+
// `morphWeights` prop, and its `transition` declares a `weights` lane,
|
|
15
|
+
// so each flip springs there natively - a write is a target, like a
|
|
16
|
+
// transform write under a transition. The back row is ONE instanced
|
|
17
|
+
// mesh: every `<Instance>` owns its own weights (a row of the
|
|
18
|
+
// population's weights texture, read by the instance index in the
|
|
19
|
+
// shader), so six copies at six blends are one draw entry - and their
|
|
20
|
+
// shadows morph too.
|
|
21
|
+
|
|
22
|
+
import { createSignal, onCleanup, onFrame, pct, render } from "@solidrt/core"
|
|
23
|
+
import { registerDebug } from "srt:dev"
|
|
24
|
+
import { DirectionalLight, geometryAttribute, geometryVertexCount, getMorphNames, getMorphWeights, HemisphereLight, Instance, InstancedMesh, phong, Mesh, PerspectiveCamera, plane, Scene, setMorphWeights, sphere, withMorphTargets } from "@solidrt/3d"
|
|
25
|
+
import type { InstanceNode, MeshNode } from "@solidrt/3d"
|
|
26
|
+
|
|
27
|
+
// The blob's authored shape: a sphere with two targets - "bulge", a
|
|
28
|
+
// spike growing out of the top half only (the sparse case), and
|
|
29
|
+
// "squash", the whole sphere flattened to half its height. Each target
|
|
30
|
+
// carries NORMAL deltas too, the way a modeller exports them: the
|
|
31
|
+
// normals of the displaced shape (smooth normals recomputed from its
|
|
32
|
+
// faces) minus the base normals. Without them the lighting and the
|
|
33
|
+
// shadow bias would read the base surface on a moved one. Normals
|
|
34
|
+
// blend LINEARLY between targets (every engine's morph rule), which is
|
|
35
|
+
// exact at the targets and an approximation between them: keep deltas
|
|
36
|
+
// modest, as a modeller's blend shapes are, or a half-blended extreme
|
|
37
|
+
// target shows a ragged terminator against its own shadow.
|
|
38
|
+
const RADIUS = 0.6
|
|
39
|
+
const BULGE_REACH = 0.45
|
|
40
|
+
const SQUASH_TO = 0.6
|
|
41
|
+
// The right blob's two poses and how often it flips between them; its
|
|
42
|
+
// spring takes about a second to settle, with a little bounce.
|
|
43
|
+
const POSE_A = { squash: 0.8, bulge: 0.1 }
|
|
44
|
+
const POSE_B = { squash: 0.1, bulge: 0.8 }
|
|
45
|
+
const FLIP_MS = 1800
|
|
46
|
+
const FLIP_SPRING = { duration: 900, bounce: 0.25 }
|
|
47
|
+
// The back row: six instances of one mesh, each at its own blend - the
|
|
48
|
+
// bulge rising left to right, the squash falling.
|
|
49
|
+
const ROW_COUNT = 6
|
|
50
|
+
const ROW_SPACING = 1.1
|
|
51
|
+
const ROW_Z = -2.2
|
|
52
|
+
const ROW_SCALE = 0.7
|
|
53
|
+
|
|
54
|
+
// Smooth vertex normals of `positions` over the geometry's triangles.
|
|
55
|
+
function smoothNormals(indices: ArrayLike<number>, positions: Float32Array): Float32Array {
|
|
56
|
+
let normals = new Float32Array(positions.length)
|
|
57
|
+
for (let t = 0; t + 2 < indices.length; t += 3) {
|
|
58
|
+
let a = indices[t]! * 3, b = indices[t + 1]! * 3, c = indices[t + 2]! * 3
|
|
59
|
+
let abx = positions[b]! - positions[a]!, aby = positions[b + 1]! - positions[a + 1]!, abz = positions[b + 2]! - positions[a + 2]!
|
|
60
|
+
let acx = positions[c]! - positions[a]!, acy = positions[c + 1]! - positions[a + 1]!, acz = positions[c + 2]! - positions[a + 2]!
|
|
61
|
+
let nx = aby * acz - abz * acy, ny = abz * acx - abx * acz, nz = abx * acy - aby * acx
|
|
62
|
+
for (let v of [a, b, c]) {
|
|
63
|
+
normals[v] = normals[v]! + nx
|
|
64
|
+
normals[v + 1] = normals[v + 1]! + ny
|
|
65
|
+
normals[v + 2] = normals[v + 2]! + nz
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
for (let v = 0; v < normals.length; v += 3) {
|
|
69
|
+
let len = Math.hypot(normals[v]!, normals[v + 1]!, normals[v + 2]!) || 1
|
|
70
|
+
normals[v] = normals[v]! / len
|
|
71
|
+
normals[v + 1] = normals[v + 1]! / len
|
|
72
|
+
normals[v + 2] = normals[v + 2]! / len
|
|
73
|
+
}
|
|
74
|
+
return normals
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function blob() {
|
|
78
|
+
let base = sphere({ radius: RADIUS, label: "blob" })
|
|
79
|
+
let count = geometryVertexCount(base, "blob")
|
|
80
|
+
let pos = geometryAttribute(base, "aPos")!
|
|
81
|
+
let nrm = geometryAttribute(base, "aNormal")!
|
|
82
|
+
let basePos = new Float32Array(count * 3)
|
|
83
|
+
let baseNormal = new Float32Array(count * 3)
|
|
84
|
+
let bulge = new Float32Array(count * 3)
|
|
85
|
+
let squash = new Float32Array(count * 3)
|
|
86
|
+
for (let i = 0; i < count; i++) {
|
|
87
|
+
let y = pos.get(i, 1)
|
|
88
|
+
for (let k = 0; k < 3; k++) {
|
|
89
|
+
basePos[i * 3 + k] = pos.get(i, k)
|
|
90
|
+
baseNormal[i * 3 + k] = nrm.get(i, k)
|
|
91
|
+
}
|
|
92
|
+
let up = Math.max(0, y / RADIUS)
|
|
93
|
+
let reach = BULGE_REACH * up * up
|
|
94
|
+
for (let k = 0; k < 3; k++) bulge[i * 3 + k] = nrm.get(i, k) * reach
|
|
95
|
+
squash[i * 3 + 1] = -(1 - SQUASH_TO) * y
|
|
96
|
+
}
|
|
97
|
+
// A target's normal deltas: the displaced shape's smooth normals minus
|
|
98
|
+
// the base's (the sphere's seam vertices are duplicated, so their
|
|
99
|
+
// recomputed normals differ a little across the seam - fine here).
|
|
100
|
+
let normalDeltas = (deltas: Float32Array): Float32Array => {
|
|
101
|
+
let moved = basePos.map((v, i) => v + deltas[i]!)
|
|
102
|
+
let normals = smoothNormals(base.indices, moved)
|
|
103
|
+
return normals.map((n, i) => n - baseNormal[i]!)
|
|
104
|
+
}
|
|
105
|
+
return withMorphTargets(base, [
|
|
106
|
+
{ name: "bulge", position: bulge, normal: normalDeltas(bulge) },
|
|
107
|
+
{ name: "squash", position: squash, normal: normalDeltas(squash) },
|
|
108
|
+
])
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function App() {
|
|
112
|
+
let geometry = blob()
|
|
113
|
+
let morphing = phong({ color: [0.9, 0.55, 0.3], morph: true, specular: 0.4, shininess: 40 })
|
|
114
|
+
let plain = phong({ color: [0.35, 0.6, 0.85] })
|
|
115
|
+
let ground = phong({ color: [0.75, 0.75, 0.72] })
|
|
116
|
+
let live: MeshNode | undefined
|
|
117
|
+
let right: MeshNode | undefined
|
|
118
|
+
let row: InstanceNode[] = []
|
|
119
|
+
let instanced = phong({ color: [0.45, 0.7, 0.5], morph: true, instanced: true, specular: 0.4, shininess: 40 })
|
|
120
|
+
let rowWeights = (i: number) => ({ bulge: i / (ROW_COUNT - 1), squash: 1 - i / (ROW_COUNT - 1) })
|
|
121
|
+
// The per-frame driver, until a debug command pins the weights:
|
|
122
|
+
// `POST /__control__/debug?name=morph` with `{ "bulge": 1, "squash": 0 }`
|
|
123
|
+
// (or the MCP call_debug tool) holds the middle blob at a weight set
|
|
124
|
+
// and reads the weights back (from the core: mid-flight under a
|
|
125
|
+
// transition); with `"duration": 500` the pin animates there on a
|
|
126
|
+
// one-off spring; `{ "drive": true }` resumes the driver; `{}` only
|
|
127
|
+
// reads.
|
|
128
|
+
let driving = true
|
|
129
|
+
onFrame(tick => {
|
|
130
|
+
let t = tick / 1000
|
|
131
|
+
if (live && driving) setMorphWeights(live, { bulge: (Math.sin(t * 1.7) + 1) / 2, squash: (Math.sin(t * 0.9 + 1) + 1) / 2 })
|
|
132
|
+
})
|
|
133
|
+
registerDebug("morph", (args?: { bulge?: number; squash?: number; duration?: number; drive?: boolean }) => {
|
|
134
|
+
if (!live) return null
|
|
135
|
+
if (args?.drive === true) driving = true
|
|
136
|
+
else {
|
|
137
|
+
let weights: Record<string, number> = {}
|
|
138
|
+
if (typeof args?.bulge === "number") weights.bulge = args.bulge
|
|
139
|
+
if (typeof args?.squash === "number") weights.squash = args.squash
|
|
140
|
+
if (Object.keys(weights).length > 0) {
|
|
141
|
+
driving = false
|
|
142
|
+
setMorphWeights(live, weights, typeof args?.duration === "number" ? { duration: args.duration } : undefined)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { names: getMorphNames(live), weights: Array.from(getMorphWeights(live)), right: right ? Array.from(getMorphWeights(right)) : null, row: row.map(n => Array.from(getMorphWeights(n))) }
|
|
146
|
+
})
|
|
147
|
+
// The right blob's pose flips on a timer; the spring is the node's.
|
|
148
|
+
let [pose, setPose] = createSignal(POSE_A)
|
|
149
|
+
let flip = setInterval(() => setPose(p => (p === POSE_A ? POSE_B : POSE_A)), FLIP_MS)
|
|
150
|
+
onCleanup(() => clearInterval(flip))
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<window>
|
|
154
|
+
<view width={pct(100)} height={pct(100)}>
|
|
155
|
+
<Scene clearColor={[0.08, 0.08, 0.11, 1]} samples={4} label="morph">
|
|
156
|
+
<PerspectiveCamera fov={45} position={[0, 2.4, 5.5]} lookAt={[0, 0.5, 0]} />
|
|
157
|
+
<HemisphereLight sky={[0.35, 0.38, 0.45]} ground={[0.12, 0.1, 0.08]} />
|
|
158
|
+
<DirectionalLight position={[-2, 5, -3]} direction={[2, -5, 3]} color={[1, 0.95, 0.85]} intensity={1} castShadow shadow={{ normalBias: 0.02, camera: { near: 1, far: 20 } }} />
|
|
159
|
+
<Mesh geometry={plane({ width: 8, height: 8 })} material={ground} rotation={[-Math.PI / 2, 0, 0]} />
|
|
160
|
+
<Mesh geometry={geometry} material={plain} position={[-1.8, RADIUS, 0]} castShadow />
|
|
161
|
+
<Mesh geometry={geometry} material={morphing} position={[0, RADIUS, 0]} castShadow ref={m => (live = m)} />
|
|
162
|
+
<Mesh geometry={geometry} material={morphing} position={[1.8, RADIUS, 0]} castShadow morphWeights={pose()} transition={{ weights: FLIP_SPRING }} ref={m => (right = m)} />
|
|
163
|
+
<InstancedMesh geometry={geometry} material={instanced} capacity={ROW_COUNT} castShadow label="morph-row">
|
|
164
|
+
{Array.from({ length: ROW_COUNT }, (_, i) => (
|
|
165
|
+
<Instance position={[(i - (ROW_COUNT - 1) / 2) * ROW_SPACING, RADIUS * ROW_SCALE, ROW_Z]} scale={ROW_SCALE} morphWeights={rowWeights(i)} ref={n => (row[i] = n)} />
|
|
166
|
+
))}
|
|
167
|
+
</InstancedMesh>
|
|
168
|
+
</Scene>
|
|
169
|
+
</view>
|
|
170
|
+
</window>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
render(() => <App />)
|