@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.
Files changed (54) hide show
  1. package/AGENTS.md +1083 -183
  2. package/README.md +15 -7
  3. package/examples/README.md +79 -5
  4. package/examples/aim.tsx +2 -3
  5. package/examples/cascades.tsx +1 -4
  6. package/examples/collision.tsx +250 -0
  7. package/examples/first-person.tsx +119 -0
  8. package/examples/fleet.tsx +194 -0
  9. package/examples/fog.tsx +2 -3
  10. package/examples/instanced.tsx +72 -32
  11. package/examples/lamps.tsx +92 -0
  12. package/examples/lit.tsx +3 -5
  13. package/examples/materials.tsx +150 -0
  14. package/examples/model-load.tsx +95 -0
  15. package/examples/model.glb +0 -0
  16. package/examples/model.tsx +32 -6
  17. package/examples/pick.tsx +2 -4
  18. package/examples/probe.tsx +101 -0
  19. package/examples/scene-background.tsx +2 -4
  20. package/examples/scene-basic.tsx +2 -4
  21. package/examples/scene-views.tsx +32 -3
  22. package/examples/shadows.tsx +2 -4
  23. package/examples/sky-lit.tsx +86 -0
  24. package/examples/skybox.tsx +128 -0
  25. package/examples/sprites.tsx +4 -6
  26. package/examples/standard.tsx +132 -0
  27. package/examples/sweep-paths.tsx +2 -4
  28. package/package.json +3 -3
  29. package/src/camera.ts +148 -0
  30. package/src/clip.ts +96 -0
  31. package/src/collision.ts +199 -0
  32. package/src/color.ts +32 -0
  33. package/src/components.tsx +763 -82
  34. package/src/environment-bake.ts +492 -0
  35. package/src/environment.ts +325 -0
  36. package/src/first-person.ts +306 -0
  37. package/src/geometry.ts +6 -2
  38. package/src/glsl.ts +1343 -82
  39. package/src/gltf.ts +432 -112
  40. package/src/index.ts +57 -9
  41. package/src/light.ts +383 -0
  42. package/src/material.ts +667 -232
  43. package/src/math.ts +110 -0
  44. package/src/mesh.ts +728 -0
  45. package/src/mixer.ts +476 -0
  46. package/src/model-file.ts +80 -15
  47. package/src/model.ts +204 -35
  48. package/src/node.ts +506 -0
  49. package/src/orbit.ts +97 -48
  50. package/src/scene-pointer.ts +166 -0
  51. package/src/scene-shadows.ts +471 -0
  52. package/src/scene.ts +1242 -1557
  53. package/tools/environment.ts +69 -0
  54. 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)} designSize={[SIZE, SIZE]} onPointerDown={() => setMode(m => MODES[(MODES.indexOf(m) + 1) % MODES.length] ?? "linear")}>
152
- <Scene width={SIZE} height={SIZE} clearColor={[SKY[0], SKY[1], SKY[2], 1]} fog={fog()} samples={4} label="fog">
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]} />
@@ -1,64 +1,89 @@
1
1
  // Instanced meshes: one draw entry covering a whole population. A material
2
- // class declares `instanceAttributes`, createInstancedMesh (here via the
3
- // <InstancedMesh> component) supplies one interleaved record per instance,
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
- // setInstanceCount is the population dial (the pines breathe); records are
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
- InstancedMesh,
36
+ HemisphereLight,
37
+ RecordMesh,
38
+ lit,
22
39
  Mesh,
23
40
  PerspectiveCamera,
24
41
  plane,
25
42
  Scene,
26
- setInstanceCount,
43
+ setRecordCount,
27
44
  shaderMaterialClass,
28
- unlit,
29
45
  } from "@solidrt/3d"
30
- import type { InstancedMeshNode } from "@solidrt/3d"
31
- import { HEMISPHERE } from "@solidrt/3d/glsl"
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 vec3 vTint;
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
- vec3 p = aPos * iScale + iPos;
48
- gl_Position = uViewProj * uModel * vec4(p, 1.0);
49
- vNormal = mat3(uModel) * aNormal;
50
- vTint = iTint;
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
- const INSTANCE_FRAGMENT = glsl`
55
- in vec3 vNormal;
56
- in vec3 vTint;
57
- ${HEMISPHERE}
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
- vec3 c = vTint * hemisphere(normalize(vNormal), vec3(1.05, 1.0, 0.95), vec3(0.35, 0.32, 0.3));
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!: InstancedMeshNode
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) setInstanceCount(pinesMesh, n)
140
+ if (pinesMesh) setRecordCount(pinesMesh, n)
116
141
  })
117
142
 
118
143
  return (
119
144
  <window>
120
- <view width={pct(100)} height={pct(100)} designSize={[SIZE, SIZE]}>
121
- <Scene width={SIZE} height={SIZE} clearColor={[0.07, 0.08, 0.1, 1]} label="instanced">
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
- <Mesh geometry={plane({ width: 9, height: 9, label: "meadow" })} material={unlit({ color: [0.16, 0.18, 0.16] })} rotation={[-Math.PI / 2, 0, 0]} />
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
- <InstancedMesh
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
- <InstancedMesh
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 (none are used here).
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
- fragment: INSTANCE_FRAGMENT,
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)} designSize={[SIZE, SIZE]}>
45
- <Scene width={SIZE} height={SIZE} clearColor={[0.07, 0.07, 0.1, 1]} samples={4} label="lit">
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 />)