@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,92 @@
1
+ import { createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
2
+ import type { VoidComponent } from "@solidrt/core"
3
+ import { SceneContext } from "./context.tsx"
4
+ import { syncNode } from "./node-props.ts"
5
+ import type { TransformProps, PointerEventProps } from "./node-props.ts"
6
+ import { add, remove } from "../node.ts"
7
+ import { createMesh, setCastShadow, setCulling, setGeometry, setLayers, setMaterial, setMeshParams, setRenderOrder } from "../mesh.ts"
8
+ import type { Mesh as MeshNode } from "../mesh.ts"
9
+ import type { ShaderParams } from "@solidrt/core/gpu"
10
+ import type { Geometry } from "../geometry.ts"
11
+ import type { Material } from "../material.ts"
12
+
13
+ export type MeshProps = TransformProps & PointerEventProps & {
14
+ geometry: Geometry
15
+ material: Material
16
+ /** Per-mesh uniforms for a custom material (setMeshParams as a prop).
17
+ * Keys merge - a key that disappears keeps its old value; there is no
18
+ * unset. Names must be declared by the material's shaders. For values
19
+ * changing every frame prefer `ref` + setMeshParams from onFrame, the
20
+ * same split as setTransform. */
21
+ params?: ShaderParams
22
+ /** Explicit draw-order key (setRenderOrder as a prop); default 0. */
23
+ renderOrder?: number
24
+ /** Draw into the scene's shadow map (setCastShadow as a prop); default
25
+ * false. Needs a `castShadow` light to show. */
26
+ castShadow?: boolean
27
+ /** Layer membership bitmask (setLayers as a prop; default 1): a target
28
+ * draws the mesh when its mask intersects this. Not inherited from
29
+ * ancestor Groups. */
30
+ layers?: number
31
+ /** Frustum culling switch (setCulling as a prop; default true). */
32
+ frustumCulled?: boolean
33
+ /** World units the culled box grows by (setCulling as a prop; default 0). */
34
+ cullMargin?: number
35
+ ref?: (mesh: MeshNode) => void
36
+ }
37
+
38
+ // The mesh-side props every mesh component shares (Sprite has no
39
+ // geometry and casts no shadow, so those stay with the components that
40
+ // take them); the
41
+ // ref and the cleanup are each component's own (a populated mesh frees
42
+ // its buffers, a plain one is removed).
43
+ export function syncMesh(mesh: MeshNode, props: Omit<MeshProps, "geometry" | "castShadow" | "ref">): void {
44
+ createEffect(
45
+ () => props.material,
46
+ m => setMaterial(mesh, m),
47
+ { defer: true },
48
+ )
49
+ createEffect(
50
+ () => props.params,
51
+ p => {
52
+ if (p !== undefined) setMeshParams(mesh, p)
53
+ },
54
+ )
55
+ createEffect(
56
+ () => props.renderOrder,
57
+ o => setRenderOrder(mesh, o ?? 0),
58
+ )
59
+ createEffect(
60
+ () => props.layers,
61
+ l => setLayers(mesh, l ?? 1),
62
+ )
63
+ createEffect(
64
+ () => [props.frustumCulled, props.cullMargin] as const,
65
+ ([culled, margin]) => setCulling(mesh, { frustumCulled: culled !== false, cullMargin: margin ?? 0 }),
66
+ )
67
+ syncNode(mesh, props)
68
+ }
69
+
70
+ /** One draw entry: geometry drawn with a material at a transform. */
71
+ export let Mesh: VoidComponent<MeshProps> = props => {
72
+ let ctx = useContext(SceneContext)
73
+ let mesh = untrack(() => createMesh(props.geometry, props.material))
74
+ add(ctx.parent, mesh)
75
+ createEffect(
76
+ () => props.geometry,
77
+ g => setGeometry(mesh, g),
78
+ { defer: true },
79
+ )
80
+ createEffect(
81
+ () => props.castShadow,
82
+ c => setCastShadow(mesh, c === true),
83
+ )
84
+ syncMesh(mesh, props)
85
+ untrack(() => props.ref)?.(mesh)
86
+ onCleanup(() => remove(mesh))
87
+ return null
88
+ }
89
+
90
+ // The props both populated meshes share with Mesh: everything but the
91
+ // geometry/material pair (documented per component) and the ref.
92
+ export type PopulatedMeshProps = Omit<MeshProps, "geometry" | "material" | "ref">
@@ -0,0 +1,90 @@
1
+ import { createEffect } from "@solidrt/core"
2
+ import { setTransform, setTransition, setVisible } from "../node.ts"
3
+ import type { NodePointerEvent, NodeTapEvent, NodeWheelEvent, SceneNode, TransitionEndEvent } from "../node.ts"
4
+ import type { NodeTransition } from "flux:spatial"
5
+ import type { Quat, Vec3 } from "../math.ts"
6
+
7
+ export type TransformProps = {
8
+ position?: Vec3
9
+ /** Euler radians in XYZ order (x first), Three's `Euler` default. */
10
+ rotation?: Vec3
11
+ /** The rotation as a quaternion - what the node stores. Pass this or
12
+ * `rotation`, not both. */
13
+ quaternion?: Quat
14
+ scale?: Vec3 | number
15
+ visible?: boolean
16
+ /** How transform-prop changes animate (see setTransition); the mount
17
+ * transform always snaps. */
18
+ transition?: NodeTransition | string | null
19
+ /** A declared transition settled on one component. */
20
+ onTransitionEnd?: (event: TransitionEndEvent) => void
21
+ }
22
+
23
+ /**
24
+ * Node pointer events, the element vocabulary one tree deeper: the nearest
25
+ * hit (the struck instance, else the mesh) receives the event,
26
+ * down/move/up/wheel/tap bubble to ancestor Groups and end at the
27
+ * `<Scene>`'s own handlers (stopPropagation stops the walk; a stopped
28
+ * down claims the whole press, so a mesh that drags itself keeps an
29
+ * `<OrbitCamera>` still), enter/leave pair on the struck node alone.
30
+ * Events flow while the element showing the scene carries scene.handlers -
31
+ * the built-in <Scene> leaf does (opt out with events={false}); an `output`
32
+ * leaf spreads them itself.
33
+ */
34
+ export type PointerEventProps = {
35
+ onPointerDown?: (event: NodePointerEvent) => void
36
+ onPointerMove?: (event: NodePointerEvent) => void
37
+ onPointerUp?: (event: NodePointerEvent) => void
38
+ /** Meshes and instances only: a Group never receives enter/leave. */
39
+ onPointerEnter?: (event: NodePointerEvent) => void
40
+ onPointerLeave?: (event: NodePointerEvent) => void
41
+ /** The wheel over the node: `deltaX`/`deltaY`. */
42
+ onWheel?: (event: NodeWheelEvent) => void
43
+ /** A press released on the node within the slop, alone for its whole
44
+ * press: DOM's click, with `tapCount` for repeats. */
45
+ onTap?: (event: NodeTapEvent) => void
46
+ }
47
+
48
+ export function syncNode(node: SceneNode, props: TransformProps & PointerEventProps): void {
49
+ // One effect for the transform and the handlers: re-assigning eight
50
+ // handler fields on a transform write is free, an effect of its own is
51
+ // not (a node component's mount cost is mostly its effects and its
52
+ // context provider, see probes/3d-instance-mount-bench.tsx).
53
+ createEffect(
54
+ () =>
55
+ [
56
+ props.position,
57
+ props.rotation,
58
+ props.quaternion,
59
+ props.scale,
60
+ props.visible,
61
+ props.onPointerDown,
62
+ props.onPointerMove,
63
+ props.onPointerUp,
64
+ props.onPointerEnter,
65
+ props.onPointerLeave,
66
+ props.onWheel,
67
+ props.onTap,
68
+ props.onTransitionEnd,
69
+ ] as const,
70
+ ([position, rotation, quaternion, scale, visible, down, move, up, enter, leave, wheel, tap, end]) => {
71
+ setTransform(node, { position, rotation, quaternion, scale })
72
+ setVisible(node, visible !== false)
73
+ node.onPointerDown = down
74
+ node.onPointerMove = move
75
+ node.onPointerUp = up
76
+ node.onPointerEnter = enter
77
+ node.onPointerLeave = leave
78
+ node.onWheel = wheel
79
+ node.onTap = tap
80
+ node.onTransitionEnd = end
81
+ },
82
+ )
83
+ // After the transform effect, so the mount transform snaps before writes
84
+ // animate. Its own effect: merged above, every transform write would
85
+ // re-send the config to the core.
86
+ createEffect(
87
+ () => props.transition,
88
+ transition => setTransition(node, transition ?? null),
89
+ )
90
+ }
@@ -0,0 +1,75 @@
1
+ import { createEffect, merge, onFrame, untrack, useContext } from "@solidrt/core"
2
+ import type { InputMap, VoidComponent } from "@solidrt/core"
3
+ import { SceneContext } from "./context.tsx"
4
+ import { createOrbitCamera } from "../orbit.ts"
5
+ import type { OrbitAxes, OrbitCamera as OrbitCameraHandle, OrbitCameraOptions } from "../orbit.ts"
6
+
7
+ // Cap on the frame loop's per-frame dt in seconds, so the first tick after
8
+ // a suspended stretch (a resumed app, a reload) cannot leap the pose.
9
+ const MAX_ORBIT_DT = 0.1
10
+
11
+ export type OrbitCameraProps = OrbitCameraOptions & {
12
+ /** The input map driving the control (InputMap.drive): its `rotate`,
13
+ * `zoom` and `pan` actions, or the ones `actions` names. Live: a new
14
+ * map reconnects. Without one the control moves only through `ref`. */
15
+ input?: InputMap<any>
16
+ /** Action names per axis when the map's differ (null skips an axis). */
17
+ actions?: Partial<Record<keyof OrbitAxes, string | null>>
18
+ /** The control's handle (pose()/set()/eye()/orbiting()/active(), the
19
+ * verbs, `axes` - also the debug command shape). */
20
+ ref?: (orbit: OrbitCameraHandle) => void
21
+ }
22
+
23
+ /**
24
+ * createOrbitCamera as a Scene (or View3d) child: drives the enclosing
25
+ * camera - the scene's, or inside a `<View3d>` that view's - through
26
+ * context, and takes its input from the map in `input`, nothing else
27
+ * (ARCHITECTURE.md: no device wiring in a component; the app binds a
28
+ * pointer feed, a pad or anything else to the map). The pose props
29
+ * (target, azimuth, elevation, distance) are initial values - change the
30
+ * pose at runtime through `ref`'s set() or the verbs; every other prop is
31
+ * live, forwarded to the control as a getter and read where it applies,
32
+ * so a clamp, a rate or an anchor callback follows its prop, and a clamp
33
+ * change re-clamps the pose at once. The frame loop runs only while
34
+ * `active()` (auto-orbit on, or an axis rate driving); a camera moved
35
+ * by drags alone leaves the app demand-driven idle.
36
+ */
37
+ export let OrbitCamera: VoidComponent<OrbitCameraProps> = props => {
38
+ let ctx = useContext(SceneContext)
39
+ // The control keeps this object and reads each option where it applies,
40
+ // so the props go through as getters (merge), never a spread: a spread
41
+ // would snapshot every prop once and a change could only remount.
42
+ let options: OrbitCameraOptions = merge(props, {})
43
+ let orbit = untrack(() => createOrbitCamera(ctx.viewport, options))
44
+ createEffect(
45
+ () => props.input,
46
+ input => (input ? input.drive(orbit.axes, untrack(() => props.actions)) : undefined),
47
+ )
48
+ createEffect(
49
+ () => orbit.active(),
50
+ on => {
51
+ if (!on) return
52
+ let last: number | null = null
53
+ return onFrame(tick => {
54
+ let now = tick / 1000
55
+ let dt = last === null ? 0 : Math.min(now - last, MAX_ORBIT_DT)
56
+ last = now
57
+ orbit.update(dt)
58
+ })
59
+ },
60
+ )
61
+ // A clamp prop change re-clamps the pose at once (set({}) applies the
62
+ // clamps and pushes) - the update() call a Three OrbitControls app
63
+ // makes after setting minDistance, done by the prop. Deferred: the
64
+ // creation already clamped and pushed the initial pose. Untracked: the
65
+ // control reads the clamps through the getters while it applies them,
66
+ // and the apply wants the values of that moment (the compute above is
67
+ // what tracks them).
68
+ createEffect(
69
+ () => [options.minDistance, options.maxDistance, options.minElevation, options.maxElevation],
70
+ () => untrack(() => orbit.set({})),
71
+ { defer: true },
72
+ )
73
+ untrack(() => props.ref)?.(orbit)
74
+ return null
75
+ }
@@ -0,0 +1,32 @@
1
+ import { createEffect, useContext } from "@solidrt/core"
2
+ import type { VoidComponent } from "@solidrt/core"
3
+ import { SceneContext } from "./context.tsx"
4
+ import type { Vec3 } from "../math.ts"
5
+
6
+ export type PerspectiveCameraProps = {
7
+ /** Vertical field of view in DEGREES (default 60). */
8
+ fov?: number
9
+ near?: number
10
+ far?: number
11
+ position?: Vec3
12
+ lookAt?: Vec3
13
+ up?: Vec3
14
+ }
15
+
16
+ /**
17
+ * Drives the enclosing camera from props - the scene's, or inside a
18
+ * `<View3d>` that view's (both have a default camera, so this component is
19
+ * optional). The camera is target state, not a tree node: to orbit it,
20
+ * update `position`/`lookAt`. The Scene and View3d `camera` props drive the
21
+ * same state (a partial CameraUpdate, ortho included) - use one form, not
22
+ * both.
23
+ */
24
+ export let PerspectiveCamera: VoidComponent<PerspectiveCameraProps> = props => {
25
+ let ctx = useContext(SceneContext)
26
+ createEffect(
27
+ () => [props.fov, props.near, props.far, props.position, props.lookAt, props.up] as const,
28
+ ([fov, near, far, position, lookAt, up]) =>
29
+ ctx.viewport.setCamera({ fov, near, far, position, target: lookAt, up }),
30
+ )
31
+ return null
32
+ }
@@ -0,0 +1,52 @@
1
+ import { createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
2
+ import type { VoidComponent } from "@solidrt/core"
3
+ import { SceneContext } from "./context.tsx"
4
+ import { syncNode } from "./node-props.ts"
5
+ import type { TransformProps } from "./node-props.ts"
6
+ import { add, remove } from "../node.ts"
7
+ import { createPointLight, setLight } from "../light.ts"
8
+ import type { PointLight as PointLightNode, SpotShadowOptions } from "../light.ts"
9
+ import type { Vec3 } from "../math.ts"
10
+
11
+ export type PointLightProps = TransformProps & {
12
+ color?: Vec3
13
+ intensity?: number
14
+ /** Falloff cutoff in world units (0 = no cutoff). */
15
+ distance?: number
16
+ /** Falloff exponent; default 2 (inverse square). */
17
+ decay?: number
18
+ /** Render shadow maps from this light (six 90-degree face maps, one
19
+ * per cube face; six shadow slots). Far = `distance`, so give a
20
+ * casting bulb one. */
21
+ castShadow?: boolean
22
+ /** Shadow-map options (mapSize, bias, normalBias, near), merged key
23
+ * by key. */
24
+ shadow?: SpotShadowOptions
25
+ ref?: (light: PointLightNode) => void
26
+ }
27
+
28
+ /** A point light node (createPointLight): light in every direction from
29
+ * the node's world position - give it a `position`; rotation does not
30
+ * matter. Counts against MAX_LIGHTS with the other non-ambient lights. */
31
+ export let PointLight: VoidComponent<PointLightProps> = props => {
32
+ let ctx = useContext(SceneContext)
33
+ let light = untrack(() =>
34
+ createPointLight({
35
+ color: props.color,
36
+ intensity: props.intensity,
37
+ distance: props.distance,
38
+ decay: props.decay,
39
+ castShadow: props.castShadow,
40
+ shadow: props.shadow,
41
+ }),
42
+ )
43
+ add(ctx.parent, light)
44
+ syncNode(light, props)
45
+ createEffect(
46
+ () => [props.color, props.intensity, props.distance, props.decay, props.castShadow, props.shadow] as const,
47
+ ([color, intensity, distance, decay, castShadow, shadow]) => setLight(light, { color, intensity, distance, decay, castShadow, shadow }),
48
+ )
49
+ untrack(() => props.ref)?.(light)
50
+ onCleanup(() => remove(light))
51
+ return null
52
+ }
@@ -0,0 +1,68 @@
1
+ import { createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
2
+ import type { VoidComponent } from "@solidrt/core"
3
+ import { SceneContext } from "./context.tsx"
4
+ import { syncMesh } from "./mesh.tsx"
5
+ import type { PopulatedMeshProps } from "./mesh.tsx"
6
+ import { add } from "../node.ts"
7
+ import { createRecordMesh, disposeInstances, setCastShadow, setGeometry, setRecordCount, setRecords } from "../mesh.ts"
8
+ import type { RecordMesh as RecordMeshNode } from "../mesh.ts"
9
+ import type { Geometry } from "../geometry.ts"
10
+ import type { Material } from "../material.ts"
11
+
12
+ export type RecordMeshProps = PopulatedMeshProps & {
13
+ geometry: Geometry
14
+ /** Must declare instanceAttributes (shaderMaterialClass), slot 0 only. */
15
+ material: Material
16
+ /** Interleaved per-instance records (stride = the material's instance
17
+ * attributes summed). Reactive; a later array larger than the buffer
18
+ * grows it (capacity doubles into a replacement buffer). */
19
+ records: Float32Array
20
+ /** How many records draw; default all of the latest `records`. */
21
+ count?: number
22
+ /** LOCAL bounds covering every instance ([minX..maxZ]), fixed at
23
+ * creation. Without them the mesh has no picking leaf, so pointer events
24
+ * never target it. */
25
+ bounds?: ArrayLike<number>
26
+ /** Draw into the scene's shadow map (setCastShadow as a prop); default
27
+ * false. Needs a `castShadow` light AND a material class declaring
28
+ * `shadowVertex` (the depth pass with the instance placement) - the
29
+ * shadow views skip a populated mesh without one. */
30
+ castShadow?: boolean
31
+ ref?: (mesh: RecordMeshNode) => void
32
+ }
33
+
34
+ /** One draw entry covering N records: geometry repeated per record of
35
+ * `records` (createRecordMesh as a component, the JS-written population).
36
+ * The record buffer is component-owned and freed on unmount. */
37
+ export let RecordMesh: VoidComponent<RecordMeshProps> = props => {
38
+ let ctx = useContext(SceneContext)
39
+ let mesh = untrack(() =>
40
+ createRecordMesh(props.geometry, props.material, props.records, props.count, { bounds: props.bounds }),
41
+ )
42
+ add(ctx.parent, mesh)
43
+ createEffect(
44
+ () => props.records,
45
+ r => setRecords(mesh, r, untrack(() => props.count)),
46
+ { defer: true },
47
+ )
48
+ createEffect(
49
+ () => props.count,
50
+ c => {
51
+ if (c !== undefined) setRecordCount(mesh, c)
52
+ },
53
+ { defer: true },
54
+ )
55
+ createEffect(
56
+ () => props.geometry,
57
+ g => setGeometry(mesh, g),
58
+ { defer: true },
59
+ )
60
+ createEffect(
61
+ () => props.castShadow,
62
+ c => setCastShadow(mesh, c === true),
63
+ )
64
+ syncMesh(mesh, props)
65
+ untrack(() => props.ref)?.(mesh)
66
+ onCleanup(() => disposeInstances(mesh))
67
+ return null
68
+ }