@solidrt/3d 0.0.55 → 0.0.57

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 +1206 -224
  2. package/README.md +16 -7
  3. package/examples/README.md +88 -9
  4. package/examples/aim.tsx +2 -3
  5. package/examples/cascades.tsx +1 -4
  6. package/examples/collision.tsx +263 -0
  7. package/examples/first-person.tsx +129 -0
  8. package/examples/fleet.tsx +200 -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 +35 -9
  17. package/examples/pick.tsx +145 -37
  18. package/examples/probe.tsx +107 -0
  19. package/examples/scene-background.tsx +2 -4
  20. package/examples/scene-basic.tsx +2 -4
  21. package/examples/scene-views.tsx +68 -48
  22. package/examples/shadows.tsx +2 -4
  23. package/examples/sky-lit.tsx +92 -0
  24. package/examples/skybox.tsx +134 -0
  25. package/examples/sprites.tsx +4 -6
  26. package/examples/standard.tsx +138 -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 +20 -0
  32. package/src/color.ts +32 -0
  33. package/src/components/context.tsx +35 -0
  34. package/src/components/directional-light.tsx +48 -0
  35. package/src/components/first-person-camera.tsx +72 -0
  36. package/src/components/group.tsx +18 -0
  37. package/src/components/hemisphere-light.tsx +24 -0
  38. package/src/components/index.ts +46 -0
  39. package/src/components/instanced-mesh.tsx +105 -0
  40. package/src/components/mesh.tsx +92 -0
  41. package/src/components/node-props.ts +90 -0
  42. package/src/components/orbit-camera.tsx +75 -0
  43. package/src/components/perspective-camera.tsx +32 -0
  44. package/src/components/point-light.tsx +52 -0
  45. package/src/components/record-mesh.tsx +68 -0
  46. package/src/components/scene.tsx +281 -0
  47. package/src/components/spot-light.tsx +65 -0
  48. package/src/components/sprite.tsx +38 -0
  49. package/src/components/view3d.tsx +152 -0
  50. package/src/environment-bake.ts +492 -0
  51. package/src/environment.ts +325 -0
  52. package/src/first-person.ts +258 -0
  53. package/src/geometry.ts +6 -2
  54. package/src/glsl.ts +1343 -82
  55. package/src/gltf.ts +432 -112
  56. package/src/index.ts +61 -9
  57. package/src/input.ts +70 -0
  58. package/src/light.ts +383 -0
  59. package/src/material.ts +667 -232
  60. package/src/math.ts +110 -0
  61. package/src/mesh.ts +728 -0
  62. package/src/mixer.ts +476 -0
  63. package/src/model-file.ts +80 -15
  64. package/src/model.ts +204 -35
  65. package/src/node.ts +579 -0
  66. package/src/orbit.ts +292 -212
  67. package/src/scene-pointer.ts +294 -0
  68. package/src/scene-shadows.ts +471 -0
  69. package/src/scene.ts +1435 -1576
  70. package/tools/environment.ts +69 -0
  71. package/tools/model.ts +4 -3
  72. package/src/components.tsx +0 -420
@@ -0,0 +1,95 @@
1
+ // Async model loading: loadModel + <Loading>. The same rover as
2
+ // model.tsx, but read from assets/ at runtime instead of riding in the
3
+ // bundle - the shape every app with real content ends up needing. Bake
4
+ // the asset once and drop it in your app's assets tree:
5
+ //
6
+ // bunx srt tool 3d/model examples/model.glb -o assets/model.srtm
7
+ //
8
+ // (loadGltf("assets/model.glb") has the exact same async shape; the bake
9
+ // just removes the runtime parse.)
10
+ //
11
+ // TWO rules make the async read work, both enforced by the runtime:
12
+ //
13
+ // 1. Read the async value only inside a TRACKING scope whose result JSX
14
+ // reads back - the memo pattern below. Reading it straight in the
15
+ // component body (`let model = loaded()`) throws
16
+ // PENDING_ASYNC_UNTRACKED_READ: outside tracking there is nothing to
17
+ // suspend and retry.
18
+ // 2. The component under <Loading> creates NO elements of its own before
19
+ // the suspending read. An element built ahead of a suspend is orphaned
20
+ // when the boundary discards and retries the subtree, and orphaned
21
+ // subtrees are never freed (the dev leak sentinel reports them). So
22
+ // the window/view shell lives in App ABOVE the boundary, and Rover
23
+ // suspends at `loaded()` before building any JSX.
24
+
25
+ import { createMemo, createSignal, Loading, onFrame, pct, render } from "@solidrt/core"
26
+ import { createTexture } from "@solidrt/core/gpu"
27
+ import type { TextureId } from "@solidrt/core/gpu"
28
+ import { add, DirectionalLight, equirectToCube, Group, loadModel, PerspectiveCamera, Scene } from "@solidrt/3d"
29
+ import type { Model, SceneNode } from "@solidrt/3d"
30
+
31
+ // The same gradient environment as model.tsx: the default `standard`
32
+ // materials want one (a baked HDRI in a real app, see environment.tsx).
33
+ const ENV_FACE = 16
34
+ const ENV_ROWS: [number, number, number][] = [
35
+ [96, 122, 168],
36
+ [176, 190, 210],
37
+ [120, 105, 90],
38
+ [60, 52, 45],
39
+ ]
40
+
41
+ function gradientEnvironment(): TextureId {
42
+ let px = new Uint8Array(2 * ENV_ROWS.length * 4)
43
+ ENV_ROWS.forEach((c, y) => {
44
+ for (let x = 0; x < 2; x++) px.set([c[0], c[1], c[2], 255], (y * 2 + x) * 4)
45
+ })
46
+ let panorama = createTexture(px, 2, ENV_ROWS.length, { format: "rgba8-srgb", filter: "linear" })
47
+ return equirectToCube(panorama, ENV_FACE, { format: "rgba8-srgb", mipmap: true, label: "gradient-env" })
48
+ }
49
+
50
+ function Rover(props: { turn: () => number }) {
51
+ // The async value: a memo returning a Promise. Its read below suspends
52
+ // until the file is read and the model built, then resumes under the
53
+ // <Loading> boundary in App.
54
+ let loaded = createMemo(() => loadModel("assets/model.srtm", { label: "rover" }))
55
+
56
+ // Everything derived from the model - framing, mounting - happens in
57
+ // this memo, AFTER the suspending read, so nothing here runs or builds
58
+ // until the model exists. The memo returns the scene JSX; the component
59
+ // returns the memo read and nothing else.
60
+ let scene = createMemo(() => {
61
+ let model: Model = loaded()
62
+ let cube = gradientEnvironment()
63
+ let b = model.bounds
64
+ let center: [number, number, number] = [(b[0]! + b[3]!) / 2, (b[1]! + b[4]!) / 2, (b[2]! + b[5]!) / 2]
65
+ let radius = Math.hypot(b[3]! - b[0]!, b[4]! - b[1]!, b[5]! - b[2]!) / 2
66
+ return (
67
+ <Scene clearColor={[0.1, 0.11, 0.14, 1]} environment={{ cube }} samples={4} label="model-load">
68
+ <PerspectiveCamera fov={40} position={[center[0] + radius * 1.4, center[1] + radius * 1.1, center[2] + radius * 2.2]} lookAt={center} />
69
+ <DirectionalLight direction={[0.5, -0.8, 0.3]} color={[1, 0.95, 0.85]} intensity={0.9} />
70
+ <Group rotation={[0, props.turn(), 0]} ref={(g: SceneNode) => add(g, model)} />
71
+ </Scene>
72
+ )
73
+ })
74
+
75
+ return <>{scene()}</>
76
+ }
77
+
78
+ function App() {
79
+ let [t, setT] = createSignal(0)
80
+ onFrame(tick => setT(tick / 1000))
81
+
82
+ // The shell above the boundary; the fallback is plain UI and may be
83
+ // anything - it is created and torn down by <Loading> itself.
84
+ return (
85
+ <window>
86
+ <view width={pct(100)} height={pct(100)}>
87
+ <Loading fallback={<text fontSize={24} color="#8899aa">Loading rover...</text>}>
88
+ <Rover turn={() => t() / 3} />
89
+ </Loading>
90
+ </view>
91
+ </window>
92
+ )
93
+ }
94
+
95
+ render(() => <App />)
Binary file
@@ -6,16 +6,43 @@
6
6
  // through loadGltf (same parser, read with flux:fs) or, baked once with
7
7
  // `srt tool 3d/model`, through loadModel (no parsing at all). Every part
8
8
  // keeps its node name and is an ordinary mesh: pointer events, transforms
9
- // and materials work per part.
9
+ // and materials work per part. The materials are createModel's default
10
+ // `standard` from the file's PBR factors, so the scene carries an
11
+ // environment: the steel reflects it and every part takes its diffuse
12
+ // ambient from it (a metal with nothing to reflect renders near black) -
13
+ // here a sky-to-ground gradient built at startup, in an app usually a
14
+ // baked HDRI (see environment.tsx).
10
15
 
11
16
  import { createSignal, onFrame, pct, render } from "@solidrt/core"
12
- import { add, createModel, DirectionalLight, Group, HemisphereLight, parseGltf, PerspectiveCamera, Scene, setVisible } from "@solidrt/3d"
17
+ import { createTexture } from "@solidrt/core/gpu"
18
+ import type { TextureId } from "@solidrt/core/gpu"
19
+ import { add, createModel, DirectionalLight, equirectToCube, Group, parseGltf, PerspectiveCamera, Scene, setVisible } from "@solidrt/3d"
13
20
  import type { SceneNode } from "@solidrt/3d"
14
21
  import modelBytes from "./model.glb" with { type: "binary" }
15
22
 
16
- const SIZE = 720
23
+ // Face edge of the gradient environment cube: a gradient needs no detail.
24
+ const ENV_FACE = 16
25
+ // The gradient, zenith to nadir, sRGB bytes: sky, horizon, ground, below.
26
+ const ENV_ROWS: [number, number, number][] = [
27
+ [96, 122, 168],
28
+ [176, 190, 210],
29
+ [120, 105, 90],
30
+ [60, 52, 45],
31
+ ]
32
+
33
+ // A tiny equirectangular panorama (2 columns, one row per band, linear
34
+ // filtering between) turned into the cube the scene lights by.
35
+ function gradientEnvironment(): TextureId {
36
+ let px = new Uint8Array(2 * ENV_ROWS.length * 4)
37
+ ENV_ROWS.forEach((c, y) => {
38
+ for (let x = 0; x < 2; x++) px.set([c[0], c[1], c[2], 255], (y * 2 + x) * 4)
39
+ })
40
+ let panorama = createTexture(px, 2, ENV_ROWS.length, { format: "rgba8-srgb", filter: "linear" })
41
+ return equirectToCube(panorama, ENV_FACE, { format: "rgba8-srgb", mipmap: true, label: "gradient-env" })
42
+ }
17
43
 
18
44
  function App() {
45
+ let cube = gradientEnvironment()
19
46
  let [t, setT] = createSignal(0)
20
47
  onFrame(tick => setT(tick / 1000))
21
48
 
@@ -25,10 +52,10 @@ function App() {
25
52
  let center: [number, number, number] = [(b[0]! + b[3]!) / 2, (b[1]! + b[4]!) / 2, (b[2]! + b[5]!) / 2]
26
53
  let radius = Math.hypot(b[3]! - b[0]!, b[4]! - b[1]!, b[5]! - b[2]!) / 2
27
54
 
28
- // Parts are named after the glTF nodes: a click hides that part, a
29
- // click on the body brings everything back.
55
+ // Parts are named after the glTF nodes: a tap hides that part, a tap
56
+ // on the body brings everything back.
30
57
  for (let part of model.parts) {
31
- part.mesh.onPointerDown = () => {
58
+ part.mesh.onTap = () => {
32
59
  if (part.name === "body") for (let p of model.parts) setVisible(p.mesh, true)
33
60
  else setVisible(part.mesh, false)
34
61
  }
@@ -36,10 +63,9 @@ function App() {
36
63
 
37
64
  return (
38
65
  <window>
39
- <view width={pct(100)} height={pct(100)} designSize={[SIZE, SIZE]}>
40
- <Scene width={SIZE} height={SIZE} clearColor={[0.1, 0.11, 0.14, 1]} samples={4} label="model">
66
+ <view width={pct(100)} height={pct(100)}>
67
+ <Scene clearColor={[0.1, 0.11, 0.14, 1]} environment={{ cube }} samples={4} label="model">
41
68
  <PerspectiveCamera fov={40} position={[center[0] + radius * 1.4, center[1] + radius * 1.1, center[2] + radius * 2.2]} lookAt={center} />
42
- <HemisphereLight sky={[0.45, 0.5, 0.6]} ground={[0.2, 0.17, 0.14]} />
43
69
  <DirectionalLight direction={[0.5, -0.8, 0.3]} color={[1, 0.95, 0.85]} intensity={0.9} />
44
70
  <Group rotation={[0, t() / 3, 0]} ref={(g: SceneNode) => add(g, model)} />
45
71
  </Scene>
package/examples/pick.tsx CHANGED
@@ -1,62 +1,136 @@
1
- // Mesh pointer events: hover to tint (enter/leave), click to pop (down),
2
- // with a Group seeing its children's clicks through bubbling and one mesh
3
- // stopping the walk. The scene is STATIC - no onFrame - so it renders only
4
- // when an event changes something: picking is demand-driven like
5
- // everything else. Events flow because the built-in <Scene> leaf carries
6
- // scene.handlers (default on); hit testing runs over the scene's BVH, so a
7
- // pointer move costs O(log meshes).
1
+ // Mesh pointer events under an orbit control, the scene as the root of
2
+ // the walk: hover tints (enter/leave), a TAP pops (a down may be the start
3
+ // of an orbit drag, so the pop waits for the release), a drag on the
4
+ // crate slides it over the floor and never orbits - its down claims the
5
+ // press, so the orbit control's feed, listening at the root, sees none
6
+ // of it - while a drag anywhere else orbits and a wheel zooms; the ring
7
+ // claims the wheel to spin itself; a tap on empty space un-pops
8
+ // everything (the scene's own onTap with `mesh` null, the deselect
9
+ // idiom). The Group hears the cone's downs through bubbling (the crate's
10
+ // claim stops the walk at the crate). The scene is STATIC - no onFrame - so it renders only when an event changes
11
+ // something, and the orbit runs frames only while it glides; hit testing
12
+ // runs over the scene's BVH, so a pointer move costs O(log meshes).
8
13
  //
9
14
  // The volume tier is honest about its shape: hits test each mesh's
10
15
  // bounding box, so hovering just outside the ball's silhouette (inside its
11
16
  // box corners) still counts. Triangle-accurate hits are a later tier.
17
+ //
18
+ // Debug commands: `state` (the orbit pose, the crate's position, the
19
+ // ring's spin, the popped and hovered names) and `pixel` ({ name }: the
20
+ // WINDOW pixel a mesh's position projects to, for a synthetic tap).
12
21
 
13
- import { createSignal, pct, render } from "@solidrt/core"
14
- import { box, cone, Group, Mesh, PerspectiveCamera, plane, Scene, setMeshParams, setTransform, sphere, torus, unlit } from "@solidrt/3d"
15
- import type { Geometry, ScenePointerEvent, Vec3 } from "@solidrt/3d"
16
-
17
- const SIZE = 720
22
+ import { createInputMap, createPointerFeed, createSignal, displayScale, pct, render } from "@solidrt/core"
23
+ import { box, cone, getRotation, Group, Mesh, OrbitCamera, orbitActions, orbitBindings, PerspectiveCamera, plane, Scene, setMeshParams, setTransform, sphere, torus, unlit, useScene } from "@solidrt/3d"
24
+ import type { Geometry, MeshNode, NodePointerEvent, NodeTapEvent, NodeWheelEvent, OrbitCameraHandle, SceneHandle, Vec3 } from "@solidrt/3d"
25
+ import { registerDebug } from "srt:dev"
18
26
 
19
27
  type Color = [number, number, number]
20
28
 
29
+ // How much a popped mesh grows.
30
+ const POP_SCALE = 1.25
31
+ // Radians of ring spin per wheel pixel.
32
+ const WHEEL_SPIN = 0.01
33
+ // The orbit's starting pose: the old fixed camera at [0, 2.6, 5] looking
34
+ // at the scene's middle.
35
+ const ORBIT_TARGET: Vec3 = [0, 0.4, 0]
36
+ const ORBIT_DISTANCE = 5.5
37
+ const ORBIT_ELEVATION = 0.42
38
+
21
39
  let [hovered, setHovered] = createSignal("nothing")
22
- let popped = new Set<object>()
40
+ // Popped meshes and the scale they pop back to.
41
+ let popped = new Map<MeshNode, number>()
42
+ let meshes: Record<string, MeshNode> = {}
43
+ let orbit!: OrbitCameraHandle
44
+ let scene!: SceneHandle
45
+
46
+ let unpopAll = () => {
47
+ for (let [mesh, base] of popped) setTransform(mesh, { scale: base })
48
+ popped.clear()
49
+ }
23
50
 
24
51
  /** A pickable mesh: unlit color that brightens on hover, scale-pop on
25
- * click. setMeshParams writes raw uniform values, so the tint premultiplies
26
- * here (alpha 1: the rgb passes through). */
27
- function Pickable(p: { name: string; color: Color; geometry: Geometry; position: Vec3; scale?: number; stop?: boolean }) {
52
+ * tap; `drag` slides it over the floor (claiming the press), `spin`
53
+ * turns it on the wheel (claiming the wheel). setMeshParams writes raw
54
+ * uniform values, so the tint premultiplies here (alpha 1: the rgb passes
55
+ * through). */
56
+ function Pickable(p: { name: string; color: Color; geometry: Geometry; position: Vec3; scale?: number; drag?: boolean; spin?: boolean }) {
57
+ let scene = useScene().scene
28
58
  let lift = (up: number): [number, number, number, number] => [
29
59
  Math.min(p.color[0] + up, 1),
30
60
  Math.min(p.color[1] + up, 1),
31
61
  Math.min(p.color[2] + up, 1),
32
62
  1,
33
63
  ]
64
+ // The floor point under the pointer at the mesh's height: the camera
65
+ // ray through the event's scene pixel meets the plane y = mesh y.
66
+ let floorPoint = (e: NodePointerEvent): [number, number] | null => {
67
+ let ray = scene.screenRay(e.x, e.y)
68
+ if (Math.abs(ray.direction[1]) < 1e-6) return null
69
+ let t = (e.mesh.position[1] - ray.origin[1]) / ray.direction[1]
70
+ if (t < 0) return null
71
+ return [ray.origin[0] + t * ray.direction[0], ray.origin[2] + t * ray.direction[2]]
72
+ }
73
+ // The mesh's offset from the grabbed floor point while a drag is on.
74
+ let grab: [number, number] | null = null
75
+ let spin = 0
34
76
  return (
35
77
  <Mesh
36
78
  geometry={p.geometry}
37
79
  material={unlit({ color: p.color })}
38
80
  position={p.position}
39
81
  scale={p.scale}
40
- onPointerEnter={(e: ScenePointerEvent) => {
82
+ ref={m => (meshes[p.name] = m)}
83
+ onPointerEnter={(e: NodePointerEvent) => {
41
84
  setHovered(p.name)
42
85
  setMeshParams(e.mesh, { uColor: lift(0.25) })
43
- console.log(`enter ${p.name}`)
44
86
  }}
45
- onPointerLeave={(e: ScenePointerEvent) => {
87
+ onPointerLeave={(e: NodePointerEvent) => {
46
88
  setHovered("nothing")
47
89
  setMeshParams(e.mesh, { uColor: lift(0) })
48
- console.log(`leave ${p.name}`)
49
90
  }}
50
- onPointerDown={(e: ScenePointerEvent) => {
51
- let pop = !popped.has(e.mesh)
52
- if (pop) popped.add(e.mesh)
53
- else popped.delete(e.mesh)
54
- setTransform(e.mesh, { scale: pop ? (p.scale ?? 1) * 1.25 : (p.scale ?? 1) })
91
+ onTap={(e: NodeTapEvent) => {
92
+ let base = popped.get(e.mesh)
93
+ if (base === undefined) {
94
+ popped.set(e.mesh, p.scale ?? 1)
95
+ setTransform(e.mesh, { scale: (p.scale ?? 1) * POP_SCALE })
96
+ } else {
97
+ popped.delete(e.mesh)
98
+ setTransform(e.mesh, { scale: base })
99
+ }
55
100
  let pt = e.point!
56
- console.log(`down ${p.name} at ${pt[0].toFixed(2)},${pt[1].toFixed(2)},${pt[2].toFixed(2)} d=${e.distance!.toFixed(2)}`)
57
- // The cone demonstrates stopping the bubble: its group never hears it.
58
- if (p.stop) e.stopPropagation()
101
+ console.log(`tap ${p.name} x${e.tapCount} at ${pt[0].toFixed(2)},${pt[1].toFixed(2)},${pt[2].toFixed(2)} d=${e.distance!.toFixed(2)}`)
59
102
  }}
103
+ onPointerDown={
104
+ p.drag
105
+ ? (e: NodePointerEvent) => {
106
+ let f = floorPoint(e)
107
+ if (f === null) return
108
+ grab = [e.mesh.position[0] - f[0], e.mesh.position[2] - f[1]]
109
+ // The claim: this press is the crate's, the orbit never sees it.
110
+ e.stopPropagation()
111
+ }
112
+ : undefined
113
+ }
114
+ onPointerMove={
115
+ p.drag
116
+ ? (e: NodePointerEvent) => {
117
+ if (grab === null) return
118
+ let f = floorPoint(e)
119
+ if (f === null) return
120
+ setTransform(e.mesh, { position: [f[0] + grab[0], e.mesh.position[1], f[1] + grab[1]] })
121
+ }
122
+ : undefined
123
+ }
124
+ onPointerUp={p.drag ? () => (grab = null) : undefined}
125
+ onWheel={
126
+ p.spin
127
+ ? (e: NodeWheelEvent) => {
128
+ spin += e.deltaY * WHEEL_SPIN
129
+ setTransform(e.mesh, { rotation: [0, spin, 0] })
130
+ e.stopPropagation()
131
+ }
132
+ : undefined
133
+ }
60
134
  />
61
135
  )
62
136
  }
@@ -67,27 +141,61 @@ function App() {
67
141
  let ball = sphere({ radius: 0.45 })
68
142
  let spike = cone({ radius: 0.45, height: 1 })
69
143
  let ring = torus({ radius: 0.4, tube: 0.14 })
144
+ // The orbit control's input: the scene leaf's pointer feed, bound to
145
+ // the standard orbit actions (drag rotates, wheel and pinch zoom).
146
+ let pointer = createPointerFeed()
147
+ let input = createInputMap(orbitActions)
148
+ input.bind(orbitBindings({ pointer }))
149
+
150
+ registerDebug("state", () => ({
151
+ pose: orbit.pose(),
152
+ crate: meshes.crate?.position,
153
+ ringSpin: meshes.ring ? getRotation(meshes.ring)[1] : null,
154
+ popped: Object.keys(meshes).filter(n => popped.has(meshes[n]!)),
155
+ hovered: hovered(),
156
+ }))
157
+ // The scene fills the window at device density, so a scene pixel over
158
+ // the display scale is a window pixel.
159
+ registerDebug("pixel", (args?: { name?: string }) => {
160
+ let mesh = meshes[args?.name ?? ""]
161
+ if (!mesh) return null
162
+ let p = scene.project(mesh.position)
163
+ if (p === null) return null
164
+ return { x: p.x / displayScale(), y: p.y / displayScale() }
165
+ })
70
166
 
71
167
  return (
72
168
  <window>
73
- <view width={pct(100)} height={pct(100)} designSize={[SIZE, SIZE]}>
74
- <Scene width={SIZE} height={SIZE} clearColor={[0.07, 0.07, 0.1, 1]} label="pick">
75
- <PerspectiveCamera fov={55} position={[0, 2.6, 5]} lookAt={[0, 0.4, 0]} />
169
+ <view width={pct(100)} height={pct(100)}>
170
+ <Scene
171
+ clearColor={[0.07, 0.07, 0.1, 1]}
172
+ label="pick"
173
+ pointer={pointer}
174
+ ref={s => (scene = s)}
175
+ onTap={e => {
176
+ if (e.mesh !== null) return
177
+ console.log(`tap on nothing at ${e.x.toFixed(0)},${e.y.toFixed(0)}: un-pop`)
178
+ unpopAll()
179
+ }}
180
+ >
181
+ <PerspectiveCamera fov={55} />
182
+ <OrbitCamera input={input} target={ORBIT_TARGET} distance={ORBIT_DISTANCE} elevation={ORBIT_ELEVATION} ref={o => (orbit = o)} />
76
183
  <Mesh geometry={floor} material={unlit({ color: [0.15, 0.16, 0.2] })} rotation={[-Math.PI / 2, 0, 0]} />
77
- {/* The group hears every child click that is not stopped. */}
78
- <Group onPointerDown={() => console.log("group saw down")}>
79
- <Pickable name="crate" color={[0.85, 0.3, 0.3]} geometry={crate} position={[-1.6, 0.4, 0]} scale={0.8} />
80
- <Pickable name="cone" color={[0.35, 0.65, 0.9]} geometry={spike} position={[1.6, 0.5, 0]} stop />
184
+ {/* The group hears the cone's downs through bubbling; the crate's
185
+ claim stops the walk at the crate, so the group never sees those. */}
186
+ <Group onPointerDown={e => console.log(`group saw down on ${e.mesh === meshes.crate ? "crate" : "cone"}`)}>
187
+ <Pickable name="crate" color={[0.85, 0.3, 0.3]} geometry={crate} position={[-1.6, 0.4, 0]} scale={0.8} drag />
188
+ <Pickable name="cone" color={[0.35, 0.65, 0.9]} geometry={spike} position={[1.6, 0.5, 0]} />
81
189
  </Group>
82
190
  <Pickable name="ball" color={[0.9, 0.8, 0.35]} geometry={ball} position={[0, 0.45, 0]} />
83
- <Pickable name="ring" color={[0.5, 0.85, 0.5]} geometry={ring} position={[0.9, 0.35, -1.6]} />
191
+ <Pickable name="ring" color={[0.5, 0.85, 0.5]} geometry={ring} position={[0.9, 0.35, -1.6]} spin />
84
192
  </Scene>
85
193
  <view position="absolute" x={0} y={0} padding={16} gap={4}>
86
194
  <text color="#eef4ff" fontSize={22} fontWeight={700}>
87
195
  {`hover: ${hovered()}`}
88
196
  </text>
89
197
  <text color="#8fa6c8" fontSize={13}>
90
- hover tints, click pops - a static scene, rendered only on change
198
+ hover tints, tap pops, drag the crate; drag elsewhere orbits, wheel zooms (over the ring: spins it), tap nothing to un-pop
91
199
  </text>
92
200
  </view>
93
201
  </view>
@@ -0,0 +1,107 @@
1
+ // A reflection probe: `scene.createReflectionProbe` renders the scene into
2
+ // a cube map from a point, and that cube is the environment a chrome ball
3
+ // at the same point mirrors - Three's CubeCamera, Unity's and Godot's
4
+ // realtime ReflectionProbe. Six colored walls make a room, four glowing
5
+ // spheres orbit the ball, and the probe re-renders every frame (six scene
6
+ // passes) so the orbit shows in the chrome. Layers keep the ball out of
7
+ // its own probe: the room and the orbiters live on layers 1 and 2, the
8
+ // ball on layer 1 only, and the probe looks at layer 2. Each update also
9
+ // prefilters the faces into the roughness chain (the default), so the
10
+ // ball's satin finish blurs the room the way a baked environment would;
11
+ // `prefilter: false` would keep a sharp mirror at a sixth of the passes
12
+ // (the chain is the frame's largest GPU item; see AGENTS.md). Drag to
13
+ // look around.
14
+
15
+ import { createInputMap, createPointerFeed, createSignal, gamepad, onFrame, pct, render } from "@solidrt/core"
16
+ import { DirectionalLight, HemisphereLight, Mesh, OrbitCamera, orbitActions, orbitBindings, plane, Scene, sphere, standard, unlit } from "@solidrt/3d"
17
+ import type { ReflectionProbe, SceneHandle } from "@solidrt/3d"
18
+ import type { Vec3 } from "@solidrt/3d/math"
19
+
20
+ // Half the room's edge.
21
+ const ROOM = 5
22
+ // The orbiters' radius, orbit radius and speed (radians per second).
23
+ const ORBITER_RADIUS = 0.35
24
+ const ORBIT = 2
25
+ const ORBIT_SPEED = 0.8
26
+ // The chrome ball's radius and its satin roughness (0 is a mirror).
27
+ const BALL = 1
28
+ const BALL_ROUGHNESS = 0.2
29
+ // The probe's face edge.
30
+ const PROBE_SIZE = 128
31
+ // Layer bits: the scene camera sees both, the probe only ROOM_LAYER.
32
+ const BALL_LAYER = 1
33
+ const ROOM_LAYER = 2
34
+
35
+ // Wall colors (sRGB), one per face: +X, -X, +Y, -Y, +Z, -Z.
36
+ const WALL_COLORS: [number, number, number][] = [
37
+ [0.75, 0.3, 0.25],
38
+ [0.25, 0.6, 0.35],
39
+ [0.85, 0.85, 0.9],
40
+ [0.3, 0.28, 0.26],
41
+ [0.25, 0.4, 0.75],
42
+ [0.8, 0.7, 0.3],
43
+ ]
44
+ // Each wall's position and the rotation that turns the plane's front inward.
45
+ const WALLS: { position: Vec3; rotation: Vec3 }[] = [
46
+ { position: [ROOM, 0, 0], rotation: [0, -Math.PI / 2, 0] },
47
+ { position: [-ROOM, 0, 0], rotation: [0, Math.PI / 2, 0] },
48
+ { position: [0, ROOM, 0], rotation: [Math.PI / 2, 0, 0] },
49
+ { position: [0, -ROOM, 0], rotation: [-Math.PI / 2, 0, 0] },
50
+ { position: [0, 0, ROOM], rotation: [0, Math.PI, 0] },
51
+ { position: [0, 0, -ROOM], rotation: [0, 0, 0] },
52
+ ]
53
+ const ORBITER_COLORS: [number, number, number][] = [
54
+ [1, 0.4, 0.2],
55
+ [0.3, 0.9, 1],
56
+ [1, 0.9, 0.3],
57
+ [0.7, 0.4, 1],
58
+ ]
59
+
60
+ function App() {
61
+ // The camera's input: the scene leaf's gestures and any pad, bound to the
62
+ // orbit control's actions - the app wires devices, the component takes
63
+ // the map (ARCHITECTURE.md).
64
+ let pointer = createPointerFeed()
65
+ let input = createInputMap(orbitActions)
66
+ input.bind(orbitBindings({ pointer, gamepad: gamepad() }))
67
+ let [t, setT] = createSignal(0)
68
+ let scene: SceneHandle | undefined
69
+ let probe: ReflectionProbe | undefined
70
+ onFrame(tick => {
71
+ setT(tick / 1000)
72
+ if (scene !== undefined && probe === undefined) {
73
+ probe = scene.createReflectionProbe({ position: [0, 0, 0], size: PROBE_SIZE, layers: ROOM_LAYER, label: "ball-probe" })
74
+ scene.setEnvironment({ cube: probe.cube })
75
+ }
76
+ probe?.update()
77
+ })
78
+ let wall = plane({ width: ROOM * 2, height: ROOM * 2 })
79
+ let walls = WALL_COLORS.map(color => unlit({ color }))
80
+ let orbiter = sphere({ radius: ORBITER_RADIUS, widthSegments: 24, heightSegments: 16 })
81
+ let orbiters = ORBITER_COLORS.map(color => unlit({ color }))
82
+ let ball = standard({ color: [1, 1, 1], metalness: 1, roughness: BALL_ROUGHNESS })
83
+ let orbitPosition = (i: number): Vec3 => {
84
+ let a = t() * ORBIT_SPEED + (i * Math.PI) / 2
85
+ return [Math.cos(a) * ORBIT, Math.sin(a * 1.7) * 0.8, Math.sin(a) * ORBIT]
86
+ }
87
+ return (
88
+ <window>
89
+ <view width={pct(100)} height={pct(100)}>
90
+ <Scene clearColor={[0.05, 0.05, 0.06, 1]} samples={4} label="probe-demo" ref={s => (scene = s)} pointer={pointer}>
91
+ <OrbitCamera input={input} target={[0, 0, 0]} azimuth={0.5} elevation={0.2} distance={4} />
92
+ <HemisphereLight sky={[0.6, 0.65, 0.7]} ground={[0.3, 0.28, 0.26]} intensity={0.6} />
93
+ <DirectionalLight direction={[0.4, -0.8, 0.3]} intensity={0.8} />
94
+ {WALLS.map((w, i) => (
95
+ <Mesh geometry={wall} material={walls[i]!} position={w.position} rotation={w.rotation} layers={BALL_LAYER | ROOM_LAYER} />
96
+ ))}
97
+ {ORBITER_COLORS.map((_, i) => (
98
+ <Mesh geometry={orbiter} material={orbiters[i]!} position={orbitPosition(i)} layers={BALL_LAYER | ROOM_LAYER} />
99
+ ))}
100
+ <Mesh geometry={sphere({ radius: BALL, widthSegments: 64, heightSegments: 48 })} material={ball} layers={BALL_LAYER} />
101
+ </Scene>
102
+ </view>
103
+ </window>
104
+ )
105
+ }
106
+
107
+ render(() => <App />)
@@ -10,8 +10,6 @@ import { pct, render } from "@solidrt/core"
10
10
  import { glsl } from "@solidrt/core/gpu"
11
11
  import { box, Mesh, PerspectiveCamera, Scene, sphere, torusKnot, unlit } from "@solidrt/3d"
12
12
 
13
- const SIZE = 720
14
-
15
13
  // A radial night-sky gradient with hash grain so the ramp does not band.
16
14
  let BACKDROP = glsl`
17
15
  void main() {
@@ -28,8 +26,8 @@ let BACKDROP = glsl`
28
26
  function App() {
29
27
  return (
30
28
  <window>
31
- <view width={pct(100)} height={pct(100)} designSize={[SIZE, SIZE]}>
32
- <Scene width={SIZE} height={SIZE} background={BACKDROP} label="backdrop-demo">
29
+ <view width={pct(100)} height={pct(100)}>
30
+ <Scene background={BACKDROP} label="backdrop-demo">
33
31
  <PerspectiveCamera fov={55} position={[0, 1.8, 4.4]} lookAt={[0, 0.4, 0]} />
34
32
  <Mesh geometry={torusKnot({ radius: 0.7, tube: 0.2, tubularSegments: 128, radialSegments: 16 })} material={unlit({ color: [0.85, 0.55, 0.25] })} position={[0, 0.9, 0]} />
35
33
  <Mesh geometry={box()} material={unlit({ color: [0.3, 0.5, 0.8] })} position={[-1.5, 0.4, -0.5]} scale={0.8} />
@@ -11,8 +11,6 @@
11
11
  import { createSignal, onFrame, pct, render } from "@solidrt/core"
12
12
  import { box, Group, Mesh, PerspectiveCamera, plane, Scene, sphere, unlit } from "@solidrt/3d"
13
13
 
14
- const SIZE = 720
15
-
16
14
  function App() {
17
15
  let [spin, setSpin] = createSignal(0)
18
16
  onFrame(tick => setSpin(tick / 2000))
@@ -26,8 +24,8 @@ function App() {
26
24
 
27
25
  return (
28
26
  <window>
29
- <view width={pct(100)} height={pct(100)} designSize={[SIZE, SIZE]}>
30
- <Scene width={SIZE} height={SIZE} clearColor={[0.07, 0.07, 0.1, 1]} label="scene-basic">
27
+ <view width={pct(100)} height={pct(100)}>
28
+ <Scene clearColor={[0.07, 0.07, 0.1, 1]} label="scene-basic">
31
29
  <PerspectiveCamera fov={55} position={[0, 1.6, 3.6]} lookAt={[0, 0.3, 0]} />
32
30
  <Mesh
33
31
  geometry={floor}