@solidrt/3d 0.0.57 → 0.0.59

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.
Files changed (72) hide show
  1. package/AGENTS.md +1479 -678
  2. package/README.md +4 -2
  3. package/examples/README.md +56 -4
  4. package/examples/bloom.tsx +113 -0
  5. package/examples/cascades.tsx +4 -4
  6. package/examples/collision.tsx +6 -6
  7. package/examples/exits.tsx +96 -0
  8. package/examples/first-person.tsx +5 -5
  9. package/examples/fleet.tsx +4 -4
  10. package/examples/fog.tsx +5 -5
  11. package/examples/instanced.tsx +70 -50
  12. package/examples/lamps.tsx +4 -4
  13. package/examples/lod.tsx +125 -0
  14. package/examples/materials.tsx +9 -9
  15. package/examples/morph.tsx +174 -0
  16. package/examples/normals.tsx +118 -0
  17. package/examples/{lit.tsx → phong.tsx} +9 -9
  18. package/examples/polyhedra.tsx +61 -0
  19. package/examples/scene-atlas.tsx +70 -0
  20. package/examples/scene-views.tsx +5 -5
  21. package/examples/shadows.tsx +9 -9
  22. package/examples/sky-lit.tsx +3 -3
  23. package/examples/skybox.tsx +5 -5
  24. package/examples/standard.tsx +2 -2
  25. package/examples/streams.tsx +122 -0
  26. package/examples/sweep-paths.tsx +5 -5
  27. package/examples/wireframe.tsx +129 -0
  28. package/package.json +3 -3
  29. package/src/clip.ts +18 -5
  30. package/src/color.ts +11 -2
  31. package/src/components/context.tsx +11 -2
  32. package/src/components/directional-light.tsx +2 -2
  33. package/src/components/first-person-camera.tsx +4 -4
  34. package/src/components/group.tsx +9 -5
  35. package/src/components/hemisphere-light.tsx +2 -2
  36. package/src/components/index.ts +4 -1
  37. package/src/components/instanced-mesh.tsx +28 -10
  38. package/src/components/lod.tsx +102 -0
  39. package/src/components/mesh.tsx +24 -6
  40. package/src/components/node-props.ts +35 -5
  41. package/src/components/orbit-camera.tsx +15 -12
  42. package/src/components/point-light.tsx +2 -2
  43. package/src/components/record-mesh.tsx +12 -7
  44. package/src/components/scene.tsx +55 -2
  45. package/src/components/spot-light.tsx +2 -2
  46. package/src/components/sprite.tsx +2 -2
  47. package/src/components/view3d.tsx +18 -1
  48. package/src/environment.ts +9 -8
  49. package/src/first-person.ts +125 -38
  50. package/src/geometry-gpu.ts +187 -22
  51. package/src/geometry.ts +1718 -203
  52. package/src/glsl.ts +398 -113
  53. package/src/gltf.ts +457 -154
  54. package/src/index.ts +32 -21
  55. package/src/input.ts +11 -2
  56. package/src/light.ts +7 -8
  57. package/src/lod.ts +148 -0
  58. package/src/material.ts +322 -190
  59. package/src/mesh.ts +492 -259
  60. package/src/mixer.ts +27 -14
  61. package/src/model-file.ts +54 -15
  62. package/src/model.ts +151 -54
  63. package/src/motion.ts +17 -0
  64. package/src/node.ts +423 -23
  65. package/src/orbit.ts +378 -116
  66. package/src/profile.ts +4 -25
  67. package/src/resolve.ts +181 -0
  68. package/src/scene-shadows.ts +94 -93
  69. package/src/scene.ts +608 -185
  70. package/src/skeleton.ts +211 -0
  71. package/src/sweep.ts +27 -8
  72. package/tools/model.ts +3 -1
@@ -1,5 +1,5 @@
1
1
  // Instanced meshes: one draw entry covering a whole population. A material
2
- // class declares `instanceAttributes`, createRecordMesh (here via the
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 explicit `bounds` cover the scatter so picking
13
- // still works (one conservative box around the population; omit bounds and
14
- // the mesh simply never picks).
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 `litFragment` (the fragment `lit` compiles),
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
- lit,
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 { litFragment } from "@solidrt/3d/glsl"
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 vec3 iTint;
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 = vec4(iTint, 1.0);
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, interleaved in attribute order: 7 floats.
91
- const STRIDE = 7
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
- // A deterministic scatter (no per-run surprises when eyeballing).
94
- function rocks(count: number): Float32Array {
95
- let records = new Float32Array(count * STRIDE)
96
- let a = 0
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
- a += 2.399963 // golden angle: an even spiral scatter
99
- let r = 0.35 + 3.4 * Math.sqrt((i + 0.5) / count)
100
- let s = 0.05 + 0.11 * ((i * 7) % 10) / 10
101
- let o = i * STRIDE
102
- records[o] = Math.cos(a) * r
103
- records[o + 1] = s / 2
104
- records[o + 2] = Math.sin(a) * r
105
- records[o + 3] = s
106
- records[o + 4] = 0.55 + 0.3 * ((i * 3) % 5) / 5
107
- records[o + 5] = 0.5 + 0.2 * ((i * 11) % 7) / 7
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 records
124
+ return out
111
125
  }
112
126
 
113
- function pines(count: number): Float32Array {
114
- let records = new Float32Array(count * STRIDE)
115
- for (let i = 0; i < count; i++) {
116
- let a = (i / count) * Math.PI * 2
117
- let s = 0.5 + 0.25 * ((i * 5) % 8) / 8
118
- let o = i * STRIDE
119
- records[o] = Math.cos(a) * 2.4
120
- records[o + 1] = s / 2
121
- records[o + 2] = Math.sin(a) * 2.4
122
- records[o + 3] = s
123
- records[o + 4] = 0.15
124
- records[o + 5] = 0.4 + 0.25 * ((i * 3) % 6) / 6
125
- records[o + 6] = 0.2
126
- }
127
- return records
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={lit({ color: [0.24, 0.27, 0.24] })} rotation={[-Math.PI / 2, 0, 0]} />
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 lit's per-entry uniforms: a white base (the
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: litFragment({ vertexColors: true }),
190
- instanceAttributes: [
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
 
@@ -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, lit, Mesh, PerspectiveCamera, plane, PointLight, Scene, setTransform, SpotLight, torusKnot } from "@solidrt/3d"
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 = 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 })
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>
@@ -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 />)
@@ -1,4 +1,4 @@
1
- // The surface maps on lit(), all procedural so the example is
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, lit, Mesh, PerspectiveCamera, plane, Scene, setMeshParams, sphere, withAttribute } from "@solidrt/3d"
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 = 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] } })
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: "vec2" }, (_i, pos) => [
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 = lit({ color: [0.45, 0.45, 0.5], lightMap: bakedGlowMap(), lightMapIntensity: 1.5 })
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 />)