@solidrt/3d 0.0.56 → 0.0.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1052 -639
- package/README.md +2 -1
- package/examples/README.md +19 -8
- package/examples/collision.tsx +17 -4
- package/examples/first-person.tsx +20 -10
- package/examples/fleet.tsx +11 -5
- package/examples/model.tsx +3 -3
- package/examples/pick.tsx +144 -34
- package/examples/probe.tsx +10 -4
- package/examples/scene-views.tsx +49 -58
- package/examples/sky-lit.tsx +10 -4
- package/examples/skybox.tsx +10 -4
- package/examples/standard.tsx +10 -4
- package/examples/wireframe.tsx +107 -0
- package/package.json +2 -2
- package/src/collision.ts +14 -193
- package/src/components/context.tsx +35 -0
- package/src/components/directional-light.tsx +48 -0
- package/src/components/first-person-camera.tsx +72 -0
- package/src/components/group.tsx +18 -0
- package/src/components/hemisphere-light.tsx +24 -0
- package/src/components/index.ts +46 -0
- package/src/components/instanced-mesh.tsx +105 -0
- package/src/components/mesh.tsx +92 -0
- package/src/components/node-props.ts +90 -0
- package/src/components/orbit-camera.tsx +75 -0
- package/src/components/perspective-camera.tsx +32 -0
- package/src/components/point-light.tsx +52 -0
- package/src/components/record-mesh.tsx +68 -0
- package/src/components/scene.tsx +281 -0
- package/src/components/spot-light.tsx +65 -0
- package/src/components/sprite.tsx +38 -0
- package/src/components/view3d.tsx +152 -0
- package/src/first-person.ts +140 -188
- package/src/geometry-gpu.ts +45 -13
- package/src/geometry.ts +186 -5
- package/src/gltf.ts +97 -16
- package/src/index.ts +15 -10
- package/src/input.ts +79 -0
- package/src/material.ts +26 -8
- package/src/mesh.ts +5 -6
- package/src/model.ts +65 -33
- package/src/node.ts +100 -26
- package/src/orbit.ts +266 -235
- package/src/scene-pointer.ts +229 -101
- package/src/scene.ts +256 -79
- package/src/skeleton.ts +211 -0
- package/src/components.tsx +0 -1101
package/examples/scene-views.tsx
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
// One scene, three renderings: the built-in leaf from a perspective
|
|
2
|
-
// camera, plus two
|
|
3
|
-
//
|
|
2
|
+
// camera, plus two `<View3d>`s - a top-down ORTHOGRAPHIC map (`ortho` in
|
|
3
|
+
// the view's `camera`) and a side silhouette drawn with an
|
|
4
4
|
// `overrideMaterial`. The group spins from ONE signal: each mesh is one
|
|
5
5
|
// core node whose flush writes its world matrix into every target's entry
|
|
6
6
|
// at once, so the extra views cost the app nothing per frame (and the
|
|
7
7
|
// light set fans out to them too - the map is lit like the main view).
|
|
8
|
-
//
|
|
8
|
+
// A view is a Scene child: it renders its own `<texture>` leaf where it
|
|
9
|
+
// sits, so the two stack in the column beside the scene's leaf, and it
|
|
10
|
+
// dies with the scene.
|
|
9
11
|
//
|
|
10
12
|
// Layers and per-view fog: the marker disc is on layer bit 2, so the
|
|
11
13
|
// main render (mask 1) and the silhouette never draw it - only the map,
|
|
12
|
-
// whose mask admits both bits. The scene is fogged; `fog
|
|
14
|
+
// whose mask admits both bits. The scene is fogged; `fog={null}` on the
|
|
13
15
|
// map keeps the top-down view clear (its fog names are view-owned, so
|
|
14
16
|
// scene-wide fog writes never clobber them).
|
|
15
17
|
import { createSignal, onFrame, pct, render } from "@solidrt/core"
|
|
16
|
-
import { box, circle, DirectionalLight, Group, HemisphereLight, lit, Mesh, PerspectiveCamera, plane, Scene, sphere, unlit } from "@solidrt/3d"
|
|
17
|
-
import type { SceneHandle } from "@solidrt/3d"
|
|
18
|
+
import { box, circle, DirectionalLight, Group, HemisphereLight, lit, Mesh, PerspectiveCamera, plane, Scene, sphere, unlit, View3d } from "@solidrt/3d"
|
|
18
19
|
|
|
19
20
|
// Layer bits: the world everything defaults to, and the map-only markers.
|
|
20
21
|
const WORLD_LAYER = 1
|
|
@@ -31,67 +32,16 @@ function App() {
|
|
|
31
32
|
let floor = plane({ width: 6, height: 6, label: "floor" })
|
|
32
33
|
let ball = sphere({ radius: 0.35 })
|
|
33
34
|
let marker = circle({ radius: 0.3, label: "marker" })
|
|
34
|
-
// `ref` runs before `output`, so the handle is set when the views are
|
|
35
|
-
// created there; both callbacks run once, untracked - no signals needed.
|
|
36
|
-
let scene!: SceneHandle
|
|
37
35
|
|
|
38
36
|
return (
|
|
39
37
|
<window>
|
|
40
|
-
<view width={pct(100)} height={pct(100)} designSize={[MAIN + SIDE, MAIN]}>
|
|
38
|
+
<view width={pct(100)} height={pct(100)} designSize={[MAIN + SIDE, MAIN]} flexDirection="row">
|
|
41
39
|
<Scene
|
|
42
40
|
width={MAIN}
|
|
43
41
|
height={MAIN}
|
|
44
42
|
clearColor={[0.07, 0.07, 0.1, 1]}
|
|
45
43
|
fog={{ color: [0.07, 0.07, 0.1], near: 2.5, far: 9 }}
|
|
46
44
|
label="scene-views"
|
|
47
|
-
ref={s => (scene = s)}
|
|
48
|
-
output={tex => {
|
|
49
|
-
// The map: straight down from y = 10, world -z toward the top of
|
|
50
|
-
// the leaf, seven world units across at any depth. Its mask also
|
|
51
|
-
// admits the marker layer, and `fog: null` keeps it clear while
|
|
52
|
-
// the main view fades to the horizon.
|
|
53
|
-
let map = scene.createView({
|
|
54
|
-
width: SIDE,
|
|
55
|
-
height: SIDE,
|
|
56
|
-
clearColor: [0.05, 0.08, 0.06, 1],
|
|
57
|
-
layers: WORLD_LAYER | MARKER_LAYER,
|
|
58
|
-
fog: null,
|
|
59
|
-
label: "map",
|
|
60
|
-
})
|
|
61
|
-
map.setCamera({
|
|
62
|
-
position: [0, 10, 0],
|
|
63
|
-
target: [0, 0, 0],
|
|
64
|
-
up: [0, 0, -1],
|
|
65
|
-
ortho: { left: -3.5, right: 3.5, top: 3.5, bottom: -3.5 },
|
|
66
|
-
near: 0.1,
|
|
67
|
-
far: 20,
|
|
68
|
-
})
|
|
69
|
-
// The silhouette: every mesh drawn flat amber, from the side. The
|
|
70
|
-
// floor is edge-on here, a single line at the bottom.
|
|
71
|
-
let side = scene.createView({
|
|
72
|
-
width: SIDE,
|
|
73
|
-
height: SIDE,
|
|
74
|
-
clearColor: [0.1, 0.06, 0.05, 1],
|
|
75
|
-
overrideMaterial: unlit({ color: [0.95, 0.75, 0.3] }),
|
|
76
|
-
label: "silhouette",
|
|
77
|
-
})
|
|
78
|
-
side.setCamera({
|
|
79
|
-
position: [6, 1, 0],
|
|
80
|
-
target: [0, 0.6, 0],
|
|
81
|
-
ortho: { left: -2.4, right: 2.4, top: 2.4, bottom: -2.4 },
|
|
82
|
-
near: 0.1,
|
|
83
|
-
far: 20,
|
|
84
|
-
})
|
|
85
|
-
return (
|
|
86
|
-
<view flexDirection="row" width={MAIN + SIDE} height={MAIN}>
|
|
87
|
-
<texture src={tex} width={MAIN} height={MAIN} />
|
|
88
|
-
<view flexDirection="column" width={SIDE} height={MAIN}>
|
|
89
|
-
<texture src={map.texture} width={SIDE} height={SIDE} />
|
|
90
|
-
<texture src={side.texture} width={SIDE} height={SIDE} />
|
|
91
|
-
</view>
|
|
92
|
-
</view>
|
|
93
|
-
)
|
|
94
|
-
}}
|
|
95
45
|
>
|
|
96
46
|
<PerspectiveCamera fov={55} position={[0, 1.6, 3.6]} lookAt={[0, 0.3, 0]} />
|
|
97
47
|
<HemisphereLight sky={[0.6, 0.7, 0.9]} ground={[0.25, 0.2, 0.15]} intensity={0.6} />
|
|
@@ -115,6 +65,47 @@ function App() {
|
|
|
115
65
|
rotation={[-Math.PI / 2, 0, 0]}
|
|
116
66
|
/>
|
|
117
67
|
</Group>
|
|
68
|
+
{/* The two views' leaves stack beside the scene's: a laid-out
|
|
69
|
+
element among Scene children renders where it sits, and the
|
|
70
|
+
views inside it stay Scene children through context. */}
|
|
71
|
+
<view flexDirection="column" width={SIDE} height={MAIN}>
|
|
72
|
+
{/* The map: straight down from y = 10, world -z toward the top
|
|
73
|
+
of the leaf, seven world units across at any depth. Its mask
|
|
74
|
+
also admits the marker layer, and `fog={null}` keeps it clear
|
|
75
|
+
while the main view fades to the horizon. */}
|
|
76
|
+
<View3d
|
|
77
|
+
width={SIDE}
|
|
78
|
+
height={SIDE}
|
|
79
|
+
clearColor={[0.05, 0.08, 0.06, 1]}
|
|
80
|
+
layers={WORLD_LAYER | MARKER_LAYER}
|
|
81
|
+
fog={null}
|
|
82
|
+
label="map"
|
|
83
|
+
camera={{
|
|
84
|
+
position: [0, 10, 0],
|
|
85
|
+
target: [0, 0, 0],
|
|
86
|
+
up: [0, 0, -1],
|
|
87
|
+
ortho: { left: -3.5, right: 3.5, top: 3.5, bottom: -3.5 },
|
|
88
|
+
near: 0.1,
|
|
89
|
+
far: 20,
|
|
90
|
+
}}
|
|
91
|
+
/>
|
|
92
|
+
{/* The silhouette: every mesh drawn flat amber, from the side.
|
|
93
|
+
The floor is edge-on here, a single line at the bottom. */}
|
|
94
|
+
<View3d
|
|
95
|
+
width={SIDE}
|
|
96
|
+
height={SIDE}
|
|
97
|
+
clearColor={[0.1, 0.06, 0.05, 1]}
|
|
98
|
+
overrideMaterial={unlit({ color: [0.95, 0.75, 0.3] })}
|
|
99
|
+
label="silhouette"
|
|
100
|
+
camera={{
|
|
101
|
+
position: [6, 1, 0],
|
|
102
|
+
target: [0, 0.6, 0],
|
|
103
|
+
ortho: { left: -2.4, right: 2.4, top: 2.4, bottom: -2.4 },
|
|
104
|
+
near: 0.1,
|
|
105
|
+
far: 20,
|
|
106
|
+
}}
|
|
107
|
+
/>
|
|
108
|
+
</view>
|
|
118
109
|
</Scene>
|
|
119
110
|
</view>
|
|
120
111
|
</window>
|
package/examples/sky-lit.tsx
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
// gradient. The sky is a scene param (uSunDir), so the bake sees the same
|
|
10
10
|
// sun the backdrop draws. Drag to look around.
|
|
11
11
|
|
|
12
|
-
import { pct, render } from "@solidrt/core"
|
|
12
|
+
import { createInputMap, createPointerFeed, gamepad, pct, render } from "@solidrt/core"
|
|
13
13
|
import { glsl } from "@solidrt/core/gpu"
|
|
14
|
-
import { Mesh, OrbitCamera, Scene, sphere, standard } from "@solidrt/3d"
|
|
14
|
+
import { Mesh, OrbitCamera, orbitActions, orbitBindings, Scene, sphere, standard } from "@solidrt/3d"
|
|
15
15
|
import type { SceneHandle } from "@solidrt/3d"
|
|
16
16
|
import { normalize } from "@solidrt/3d/math"
|
|
17
17
|
import type { Vec3 } from "@solidrt/3d/math"
|
|
@@ -55,6 +55,12 @@ const SKY = glsl`
|
|
|
55
55
|
`
|
|
56
56
|
|
|
57
57
|
function App() {
|
|
58
|
+
// The camera's input: the scene leaf's gestures and any pad, bound to the
|
|
59
|
+
// orbit control's actions - the app wires devices, the component takes
|
|
60
|
+
// the map (ARCHITECTURE.md).
|
|
61
|
+
let pointer = createPointerFeed()
|
|
62
|
+
let input = createInputMap(orbitActions)
|
|
63
|
+
input.bind(orbitBindings({ pointer, gamepad: gamepad() }))
|
|
58
64
|
let spheres: { position: Vec3; material: ReturnType<typeof standard> }[] = []
|
|
59
65
|
for (let i = 0; i < COUNT; i++) {
|
|
60
66
|
let roughness = i / (COUNT - 1)
|
|
@@ -72,8 +78,8 @@ function App() {
|
|
|
72
78
|
return (
|
|
73
79
|
<window>
|
|
74
80
|
<view width={pct(100)} height={pct(100)}>
|
|
75
|
-
<Scene background={SKY} toneMapping="aces" samples={4} label="sky-lit" ref={lit}>
|
|
76
|
-
<OrbitCamera target={[0, 0, 0]} azimuth={0.3} elevation={0.15} distance={7} />
|
|
81
|
+
<Scene background={SKY} toneMapping="aces" samples={4} label="sky-lit" ref={lit} pointer={pointer}>
|
|
82
|
+
<OrbitCamera input={input} target={[0, 0, 0]} azimuth={0.3} elevation={0.15} distance={7} />
|
|
77
83
|
{spheres.map(s => (
|
|
78
84
|
<Mesh geometry={geometry} material={s.material} position={s.position} />
|
|
79
85
|
))}
|
package/examples/skybox.tsx
CHANGED
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
// equirectToCube on a panorama. The sky turns slowly and the sun light
|
|
12
12
|
// turns with it: `rotation` is a reactive prop the scene updates in
|
|
13
13
|
// place, no recompile. Drag to look around, wheel to zoom.
|
|
14
|
-
import { createSignal, onFrame, pct, render } from "@solidrt/core"
|
|
14
|
+
import { createInputMap, createPointerFeed, createSignal, gamepad, onFrame, pct, render } from "@solidrt/core"
|
|
15
15
|
import { createCubeTexture } from "@solidrt/core/gpu"
|
|
16
|
-
import { box, DirectionalLight, HemisphereLight, lit, Mesh, OrbitCamera, plane, Scene, sphere, torusKnot } from "@solidrt/3d"
|
|
16
|
+
import { box, DirectionalLight, HemisphereLight, lit, Mesh, OrbitCamera, orbitActions, orbitBindings, plane, Scene, sphere, torusKnot } from "@solidrt/3d"
|
|
17
17
|
import { normalize } from "@solidrt/3d/math"
|
|
18
18
|
import type { Vec3 } from "@solidrt/3d/math"
|
|
19
19
|
|
|
@@ -92,6 +92,12 @@ function turnedSun(angle: number): Vec3 {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
function App() {
|
|
95
|
+
// The camera's input: the scene leaf's gestures and any pad, bound to the
|
|
96
|
+
// orbit control's actions - the app wires devices, the component takes
|
|
97
|
+
// the map (ARCHITECTURE.md).
|
|
98
|
+
let pointer = createPointerFeed()
|
|
99
|
+
let input = createInputMap(orbitActions)
|
|
100
|
+
input.bind(orbitBindings({ pointer, gamepad: gamepad() }))
|
|
95
101
|
let cube = createCubeTexture(bakeSky(), FACE, { format: "rgba8-srgb", mipmap: true, label: "sky" })
|
|
96
102
|
let [turn, setTurn] = createSignal(0)
|
|
97
103
|
let last = 0
|
|
@@ -111,8 +117,8 @@ function App() {
|
|
|
111
117
|
return (
|
|
112
118
|
<window>
|
|
113
119
|
<view width={pct(100)} height={pct(100)}>
|
|
114
|
-
<Scene background={{ cube, rotation: turn() }} environment={{ cube, rotation: turn() }} label="skybox-demo">
|
|
115
|
-
<OrbitCamera azimuth={0.4} elevation={0.12} distance={7} />
|
|
120
|
+
<Scene background={{ cube, rotation: turn() }} environment={{ cube, rotation: turn() }} label="skybox-demo" pointer={pointer}>
|
|
121
|
+
<OrbitCamera input={input} azimuth={0.4} elevation={0.12} distance={7} />
|
|
116
122
|
<HemisphereLight sky={[0.5, 0.6, 0.8]} ground={[0.3, 0.27, 0.24]} />
|
|
117
123
|
<DirectionalLight direction={[-sun()[0], -sun()[1], -sun()[2]]} color={SUN_COLOR} intensity={0.9} />
|
|
118
124
|
<Mesh geometry={plane({ width: 12, height: 12 })} material={ground} rotation={[-Math.PI / 2, 0, 0]} />
|
package/examples/standard.tsx
CHANGED
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
// Light intensities read as lit's: 1 lights a white matte surface to 1.
|
|
13
13
|
// Drag to look around.
|
|
14
14
|
|
|
15
|
-
import { pct, render } from "@solidrt/core"
|
|
15
|
+
import { createInputMap, createPointerFeed, gamepad, pct, render } from "@solidrt/core"
|
|
16
16
|
import { createCubeTexture } from "@solidrt/core/gpu"
|
|
17
|
-
import { DirectionalLight, HemisphereLight, Mesh, OrbitCamera, plane, Scene, sphere, standard } from "@solidrt/3d"
|
|
17
|
+
import { DirectionalLight, HemisphereLight, Mesh, OrbitCamera, orbitActions, orbitBindings, plane, Scene, sphere, standard } from "@solidrt/3d"
|
|
18
18
|
import { normalize } from "@solidrt/3d/math"
|
|
19
19
|
import type { Vec3 } from "@solidrt/3d/math"
|
|
20
20
|
|
|
@@ -91,6 +91,12 @@ function bakeSky(): Uint8Array[] {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
function App() {
|
|
94
|
+
// The camera's input: the scene leaf's gestures and any pad, bound to the
|
|
95
|
+
// orbit control's actions - the app wires devices, the component takes
|
|
96
|
+
// the map (ARCHITECTURE.md).
|
|
97
|
+
let pointer = createPointerFeed()
|
|
98
|
+
let input = createInputMap(orbitActions)
|
|
99
|
+
input.bind(orbitBindings({ pointer, gamepad: gamepad() }))
|
|
94
100
|
let cube = createCubeTexture(bakeSky(), FACE, { format: "rgba8-srgb", mipmap: true, label: "sky" })
|
|
95
101
|
let ball = sphere({ radius: RADIUS, widthSegments: 48, heightSegments: 32 })
|
|
96
102
|
let ground = standard({ color: [0.32, 0.33, 0.3], roughness: 0.9 })
|
|
@@ -109,8 +115,8 @@ function App() {
|
|
|
109
115
|
return (
|
|
110
116
|
<window>
|
|
111
117
|
<view width={pct(100)} height={pct(100)}>
|
|
112
|
-
<Scene background={{ cube }} environment={{ cube }} samples={4} label="standard-demo">
|
|
113
|
-
<OrbitCamera target={center} azimuth={0.25} elevation={0.15} distance={9} />
|
|
118
|
+
<Scene background={{ cube }} environment={{ cube }} samples={4} label="standard-demo" pointer={pointer}>
|
|
119
|
+
<OrbitCamera input={input} target={center} azimuth={0.25} elevation={0.15} distance={9} />
|
|
114
120
|
<HemisphereLight sky={[0.5, 0.6, 0.8]} ground={[0.3, 0.27, 0.24]} intensity={0.5} />
|
|
115
121
|
<DirectionalLight
|
|
116
122
|
position={[SUN[0] * SUN_DISTANCE, SUN[1] * SUN_DISTANCE, SUN[2] * SUN_DISTANCE]}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// The rover drawn solid, as a wireframe or as its feature edges. Both line
|
|
2
|
+
// forms are GEOMETRY: wireframeGeometry lists every edge of a triangle
|
|
3
|
+
// geometry once, edgesGeometry only the edges where two faces meet at the
|
|
4
|
+
// threshold angle or more (plus the open borders), each as a "lines"
|
|
5
|
+
// geometry over the source's OWN vertex array. The topology rides on the
|
|
6
|
+
// geometry, so the plain unlit material draws it as lines, and the vertex
|
|
7
|
+
// upload is shared, so a mode costs an index buffer, not a second model.
|
|
8
|
+
// The swap is setGeometry + setMaterial on the live mesh: the node, its
|
|
9
|
+
// transform and its pointer handlers are untouched. Space, a tap on the
|
|
10
|
+
// rover or the `wire` debug command ({ mode }) cycles the modes.
|
|
11
|
+
|
|
12
|
+
import { createSignal, onFrame, pct, render } from "@solidrt/core"
|
|
13
|
+
import { createTexture } from "@solidrt/core/gpu"
|
|
14
|
+
import type { TextureId } from "@solidrt/core/gpu"
|
|
15
|
+
import { registerDebug } from "srt:dev"
|
|
16
|
+
import { add, createModel, DirectionalLight, edgesGeometry, equirectToCube, Group, parseGltf, PerspectiveCamera, Scene, setGeometry, setMaterial, unlit, wireframeGeometry } from "@solidrt/3d"
|
|
17
|
+
import type { SceneNode } from "@solidrt/3d"
|
|
18
|
+
import modelBytes from "./model.glb" with { type: "binary" }
|
|
19
|
+
|
|
20
|
+
const MODES = ["solid", "wireframe", "edges"] as const
|
|
21
|
+
type Mode = (typeof MODES)[number]
|
|
22
|
+
// Feature-edge threshold in degrees: above the facet angle of the rover's
|
|
23
|
+
// rounded parts, so their seams stay hidden and only rims and creases draw.
|
|
24
|
+
const EDGE_ANGLE = 20
|
|
25
|
+
const LINE_COLOR: [number, number, number] = [0.85, 0.95, 1]
|
|
26
|
+
// Face edge of the gradient environment cube: a gradient needs no detail.
|
|
27
|
+
const ENV_FACE = 16
|
|
28
|
+
// The gradient, zenith to nadir, sRGB bytes: sky, horizon, ground, below.
|
|
29
|
+
const ENV_ROWS: [number, number, number][] = [
|
|
30
|
+
[96, 122, 168],
|
|
31
|
+
[176, 190, 210],
|
|
32
|
+
[120, 105, 90],
|
|
33
|
+
[60, 52, 45],
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
// A tiny equirectangular panorama turned into the cube the solid rover's
|
|
37
|
+
// standard materials light by (see model.tsx).
|
|
38
|
+
function gradientEnvironment(): TextureId {
|
|
39
|
+
let px = new Uint8Array(2 * ENV_ROWS.length * 4)
|
|
40
|
+
ENV_ROWS.forEach((c, y) => {
|
|
41
|
+
for (let x = 0; x < 2; x++) px.set([c[0], c[1], c[2], 255], (y * 2 + x) * 4)
|
|
42
|
+
})
|
|
43
|
+
let panorama = createTexture(px, 2, ENV_ROWS.length, { format: "rgba8-srgb", filter: "linear" })
|
|
44
|
+
return equirectToCube(panorama, ENV_FACE, { format: "rgba8-srgb", mipmap: true, label: "gradient-env" })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function App() {
|
|
48
|
+
let cube = gradientEnvironment()
|
|
49
|
+
let [t, setT] = createSignal(0)
|
|
50
|
+
onFrame(tick => setT(tick / 1000))
|
|
51
|
+
|
|
52
|
+
let model = createModel(parseGltf(modelBytes), { label: "rover" })
|
|
53
|
+
let b = model.bounds
|
|
54
|
+
let center: [number, number, number] = [(b[0]! + b[3]!) / 2, (b[1]! + b[4]!) / 2, (b[2]! + b[5]!) / 2]
|
|
55
|
+
let radius = Math.hypot(b[3]! - b[0]!, b[4]! - b[1]!, b[5]! - b[2]!) / 2
|
|
56
|
+
|
|
57
|
+
// One line material for every part and both line modes. Per part, what
|
|
58
|
+
// each mode swaps in: the line geometries share the solid's vertices,
|
|
59
|
+
// so building both up front costs their index buffers only.
|
|
60
|
+
let lines = unlit({ color: LINE_COLOR })
|
|
61
|
+
let looks = model.parts.map(part => ({
|
|
62
|
+
mesh: part.mesh,
|
|
63
|
+
solid: { geometry: part.mesh.geometry, material: part.mesh.material },
|
|
64
|
+
wireframe: wireframeGeometry(part.mesh.geometry),
|
|
65
|
+
edges: edgesGeometry(part.mesh.geometry, EDGE_ANGLE),
|
|
66
|
+
}))
|
|
67
|
+
let mode: Mode = "solid"
|
|
68
|
+
let apply = (next: Mode): void => {
|
|
69
|
+
mode = next
|
|
70
|
+
for (let look of looks) {
|
|
71
|
+
// Each call rebuilds the draw entry, so the intermediate state is
|
|
72
|
+
// always the line material over the solid's triangles - a pipeline
|
|
73
|
+
// unlit has anyway - never the solid material over lines.
|
|
74
|
+
if (next === "solid") {
|
|
75
|
+
setGeometry(look.mesh, look.solid.geometry)
|
|
76
|
+
setMaterial(look.mesh, look.solid.material)
|
|
77
|
+
} else {
|
|
78
|
+
setMaterial(look.mesh, lines)
|
|
79
|
+
setGeometry(look.mesh, look[next])
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
let cycle = (): void => apply(MODES[(MODES.indexOf(mode) + 1) % MODES.length]!)
|
|
84
|
+
for (let part of model.parts) part.mesh.onTap = cycle
|
|
85
|
+
registerDebug("wire", (args?: Record<string, unknown>) => {
|
|
86
|
+
if (typeof args?.mode === "string" && (MODES as readonly string[]).includes(args.mode)) apply(args.mode as Mode)
|
|
87
|
+
else cycle()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<window
|
|
92
|
+
onKeyDown={e => {
|
|
93
|
+
if (e.key === " ") cycle()
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
<view width={pct(100)} height={pct(100)}>
|
|
97
|
+
<Scene clearColor={[0.1, 0.11, 0.14, 1]} environment={{ cube }} samples={4} label="wireframe">
|
|
98
|
+
<PerspectiveCamera fov={40} position={[center[0] + radius * 1.4, center[1] + radius * 1.1, center[2] + radius * 2.2]} lookAt={center} />
|
|
99
|
+
<DirectionalLight direction={[0.5, -0.8, 0.3]} color={[1, 0.95, 0.85]} intensity={0.9} />
|
|
100
|
+
<Group rotation={[0, t() / 3, 0]} ref={(g: SceneNode) => add(g, model)} />
|
|
101
|
+
</Scene>
|
|
102
|
+
</view>
|
|
103
|
+
</window>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
render(() => <App />)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/3d",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.58",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"funding": "https://github.com/sponsors/wellawaretech",
|
|
6
6
|
"author": "Antoine van Wel",
|
|
@@ -19,6 +19,6 @@
|
|
|
19
19
|
],
|
|
20
20
|
"peerDependencies": {
|
|
21
21
|
"@solidjs/signals": "2.0.0-rc.6",
|
|
22
|
-
"@solidrt/core": "0.0.
|
|
22
|
+
"@solidrt/core": "0.0.58"
|
|
23
23
|
}
|
|
24
24
|
}
|
package/src/collision.ts
CHANGED
|
@@ -1,199 +1,20 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
//
|
|
12
|
-
// Pure on purpose: no node, no velocity state. It takes a volume where the
|
|
13
|
-
// body IS and the motion it WANTS, and returns the motion it gets plus
|
|
14
|
-
// what it touched - the first-person camera (which has no node) composes
|
|
15
|
-
// it in clampPosition, and a node-driven body applies the result with
|
|
16
|
-
// setTransform. Gravity is the caller's: fold it into `motion`; a walkable
|
|
17
|
-
// floor absorbs the vertical part (a body does not creep down a slope it
|
|
18
|
-
// can stand on), a steep one slides it.
|
|
19
|
-
|
|
1
|
+
// The kinematic character move, Godot's CharacterBody3D.move_and_slide
|
|
2
|
+
// and Unity's CharacterController.Move as one pure function: no node, no
|
|
3
|
+
// velocity state. It takes a volume where the body IS and the motion it
|
|
4
|
+
// WANTS, and returns the motion it gets plus what it touched - the
|
|
5
|
+
// first-person camera (which has no node) composes it in clampPosition,
|
|
6
|
+
// a node-driven body applies the result with setTransform. The loop
|
|
7
|
+
// itself (depenetration, sweep, slide, floor snap) runs in the spatial
|
|
8
|
+
// core as scene.moveAndSlide; this is its free-function spelling.
|
|
9
|
+
|
|
10
|
+
import type { MoveOptions, MoveResult, Scene, Volume } from "./scene.ts"
|
|
20
11
|
import type { Vec3 } from "./math.ts"
|
|
21
|
-
import type { Impact, QueryOptions, Scene, Volume } from "./scene.ts"
|
|
22
|
-
|
|
23
|
-
/** Gap the body keeps from every surface, world units (Unity's skinWidth,
|
|
24
|
-
* Godot's safe_margin): what keeps float error from sinking a resting body
|
|
25
|
-
* into its floor. */
|
|
26
|
-
const SKIN = 0.01
|
|
27
|
-
/** Contacts within this angle of `up` are floor: 45 degrees, Godot's
|
|
28
|
-
* floor_max_angle and Unity's slopeLimit. */
|
|
29
|
-
const FLOOR_MAX_ANGLE = Math.PI / 4
|
|
30
|
-
/** Most contacts one call slides along before it stops where it is
|
|
31
|
-
* (Godot's max_slides). */
|
|
32
|
-
const MAX_SLIDES = 6
|
|
33
|
-
/** How far below the body a floor is pulled to while it is not rising
|
|
34
|
-
* (Godot's floor_snap_length). */
|
|
35
|
-
const FLOOR_SNAP = 0.1
|
|
36
|
-
/** Passes of the depenetration loop, one contact set pushed out per pass
|
|
37
|
-
* (Godot's recovery attempts). */
|
|
38
|
-
const MAX_RECOVERIES = 4
|
|
39
|
-
/** Motion below which a slide stops: nothing left worth sweeping. */
|
|
40
|
-
const MIN_MOVE = 1e-6
|
|
41
|
-
/** Floor of the approach cosine the skin is divided by to retreat along
|
|
42
|
-
* the motion: a grazing contact retreats a few skins, not the whole step. */
|
|
43
|
-
const SKIN_MIN_COS = 0.1
|
|
44
|
-
|
|
45
|
-
const UP: Vec3 = [0, 1, 0]
|
|
46
12
|
|
|
47
13
|
/** What moveAndSlide needs of a scene. */
|
|
48
|
-
export type MoveScene = Pick<Scene, "
|
|
49
|
-
|
|
50
|
-
export type MoveOptions = QueryOptions & {
|
|
51
|
-
/** The direction floors face (default [0, 1, 0]). */
|
|
52
|
-
up?: Vec3
|
|
53
|
-
/** Largest angle (radians) between a contact normal and `up` that still
|
|
54
|
-
* counts as floor (default 45 degrees). Steeper contacts are walls, and
|
|
55
|
-
* the body slides down them. */
|
|
56
|
-
floorMaxAngle?: number
|
|
57
|
-
/** Most contacts one call slides along (default 6). */
|
|
58
|
-
maxSlides?: number
|
|
59
|
-
/** Gap kept from every surface, world units (default 0.01). */
|
|
60
|
-
skin?: number
|
|
61
|
-
/** How far below the body a floor is pulled to when the motion does not
|
|
62
|
-
* rise (default 0.1; 0 disables): what keeps a walker on a ramp going
|
|
63
|
-
* down, and what decides `floor` at the end of the move - a body that
|
|
64
|
-
* ends higher than this above its floor is airborne, whatever it
|
|
65
|
-
* touched on the way. A rising motion (a jump) never snaps. */
|
|
66
|
-
floorSnap?: number
|
|
67
|
-
}
|
|
14
|
+
export type MoveScene = Pick<Scene, "moveAndSlide">
|
|
68
15
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
motion: Vec3
|
|
72
|
-
/** The unit normal of the floor the body ends the move on - within
|
|
73
|
-
* `floorSnap` below it, snapped onto - else null (airborne, or on a
|
|
74
|
-
* slope too steep to stand on). With `floorSnap: 0` it is a floor met
|
|
75
|
-
* during the move instead. */
|
|
76
|
-
floor: Vec3 | null
|
|
77
|
-
/** Whether a wall (a contact steeper than a floor and flatter than a
|
|
78
|
-
* ceiling) or a ceiling was met. */
|
|
79
|
-
wall: boolean
|
|
80
|
-
ceiling: boolean
|
|
81
|
-
/** Every contact met, in order, the floor snap's last. */
|
|
82
|
-
hits: Impact[]
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Move a body `motion` through the scene's colliders, sliding along what
|
|
87
|
-
* it hits (Godot's move_and_slide, Unity's CharacterController.Move): a
|
|
88
|
-
* capsule for a character, a sphere for a ball, a box for a crate. The
|
|
89
|
-
* body first pushes out of anything it starts inside, then sweeps and
|
|
90
|
-
* slides up to `maxSlides` times, then, unless the motion rises, snaps
|
|
91
|
-
* down onto a floor within `floorSnap` - the floor it reports is the one
|
|
92
|
-
* it ends on. Colliders are whatever `opts`
|
|
93
|
-
* selects (`layers`/`meshes`, as for sweep): pass the collision layer,
|
|
94
|
-
* or the body's own mesh answers for its own walls.
|
|
95
|
-
*
|
|
96
|
-
* Gravity is the caller's: fold the fall into `motion` each frame, zero
|
|
97
|
-
* it while `floor` is set (the snap keeps reporting the floor while the
|
|
98
|
-
* body stands still), and a walkable floor absorbs the vertical part -
|
|
99
|
-
* `motion` only ever moves the body along a floor it stands on, never
|
|
100
|
-
* down it.
|
|
101
|
-
*/
|
|
16
|
+
/** Move a body `motion` through the scene's colliders, sliding along what
|
|
17
|
+
* it hits: scene.moveAndSlide as a function (see it for the contract). */
|
|
102
18
|
export function moveAndSlide(scene: MoveScene, volume: Volume, motion: Vec3, opts: MoveOptions = {}): MoveResult {
|
|
103
|
-
|
|
104
|
-
let floorCos = Math.cos(opts.floorMaxAngle ?? FLOOR_MAX_ANGLE)
|
|
105
|
-
let maxSlides = opts.maxSlides ?? MAX_SLIDES
|
|
106
|
-
let skin = opts.skin ?? SKIN
|
|
107
|
-
let snap = opts.floorSnap ?? FLOOR_SNAP
|
|
108
|
-
let query: QueryOptions = { layers: opts.layers, meshes: opts.meshes }
|
|
109
|
-
let offset: Vec3 = [0, 0, 0]
|
|
110
|
-
let hits: Impact[] = []
|
|
111
|
-
let floor: Vec3 | null = null
|
|
112
|
-
let wall = false
|
|
113
|
-
let ceiling = false
|
|
114
|
-
|
|
115
|
-
// Depenetration: every contact's push-out, each only as far as the
|
|
116
|
-
// pushes so far have not already covered along its normal, so two
|
|
117
|
-
// walls of a corner do not push twice.
|
|
118
|
-
for (let pass = 0; pass < MAX_RECOVERIES; pass++) {
|
|
119
|
-
let push: Vec3 = [0, 0, 0]
|
|
120
|
-
let any = false
|
|
121
|
-
for (let c of scene.overlap(shifted(volume, offset), query)) {
|
|
122
|
-
if (c.depth <= 0) continue
|
|
123
|
-
any = true
|
|
124
|
-
let extra = c.depth + skin - dot(push, c.normal)
|
|
125
|
-
if (extra > 0) push = add(push, scale(c.normal, extra))
|
|
126
|
-
}
|
|
127
|
-
if (!any) break
|
|
128
|
-
offset = add(offset, push)
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
let remaining: Vec3 = [motion[0], motion[1], motion[2]]
|
|
132
|
-
for (let slide = 0; slide < maxSlides; slide++) {
|
|
133
|
-
let len = Math.hypot(remaining[0], remaining[1], remaining[2])
|
|
134
|
-
if (len < MIN_MOVE) break
|
|
135
|
-
let hit = scene.sweep(shifted(volume, offset), remaining, query)[0]
|
|
136
|
-
if (hit === undefined) {
|
|
137
|
-
offset = add(offset, remaining)
|
|
138
|
-
break
|
|
139
|
-
}
|
|
140
|
-
hits.push(hit)
|
|
141
|
-
let dir = scale(remaining, 1 / len)
|
|
142
|
-
let n = hit.normal
|
|
143
|
-
// Stop where the gap to the surface, measured along its normal, is
|
|
144
|
-
// one skin; the sweep guarantees the approach is positive.
|
|
145
|
-
let retreat = skin / Math.max(-dot(dir, n), SKIN_MIN_COS)
|
|
146
|
-
let advance = Math.max(hit.time * len - retreat, 0)
|
|
147
|
-
offset = add(offset, scale(dir, advance))
|
|
148
|
-
remaining = scale(dir, len - advance)
|
|
149
|
-
let rise = dot(n, up)
|
|
150
|
-
if (rise >= floorCos) {
|
|
151
|
-
// A floor takes the vertical part (the fall, a landing) and passes
|
|
152
|
-
// the horizontal part along its plane.
|
|
153
|
-
floor = n
|
|
154
|
-
let level = sub(remaining, scale(up, dot(remaining, up)))
|
|
155
|
-
remaining = sub(level, scale(n, dot(level, n)))
|
|
156
|
-
} else {
|
|
157
|
-
if (rise <= -floorCos) ceiling = true
|
|
158
|
-
else wall = true
|
|
159
|
-
remaining = sub(remaining, scale(n, dot(remaining, n)))
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Where the body ends decides the floor: a contact met on the way
|
|
164
|
-
// (a ramp's top before a big step past its crest) is not one to stand
|
|
165
|
-
// on unless it is still within reach below.
|
|
166
|
-
if (snap > 0 && dot(motion, up) <= 0) {
|
|
167
|
-
floor = null
|
|
168
|
-
let hit = scene.sweep(shifted(volume, offset), scale(up, -snap), query)[0]
|
|
169
|
-
if (hit !== undefined && dot(hit.normal, up) >= floorCos) {
|
|
170
|
-
hits.push(hit)
|
|
171
|
-
let retreat = skin / Math.max(dot(hit.normal, up), SKIN_MIN_COS)
|
|
172
|
-
offset = add(offset, scale(up, -Math.max(hit.time * snap - retreat, 0)))
|
|
173
|
-
floor = hit.normal
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
return { motion: offset, floor, wall, ceiling, hits }
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** The volume displaced by `d`. */
|
|
180
|
-
function shifted(volume: Volume, d: Vec3): Volume {
|
|
181
|
-
if ("center" in volume) return { ...volume, center: add(volume.center, d) }
|
|
182
|
-
return { ...volume, a: add(volume.a, d), b: add(volume.b, d) }
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function add(a: Vec3, b: Vec3): Vec3 {
|
|
186
|
-
return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function sub(a: Vec3, b: Vec3): Vec3 {
|
|
190
|
-
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function scale(v: Vec3, s: number): Vec3 {
|
|
194
|
-
return [v[0] * s, v[1] * s, v[2] * s]
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function dot(a: Vec3, b: Vec3): number {
|
|
198
|
-
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
|
19
|
+
return scene.moveAndSlide(volume, motion, opts)
|
|
199
20
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createContext, useContext } from "@solidrt/core"
|
|
2
|
+
import type { Element, PointerFeed } from "@solidrt/core"
|
|
3
|
+
import type { SceneNode } from "../node.ts"
|
|
4
|
+
import type { Scene as SceneHandle } from "../scene.ts"
|
|
5
|
+
import type { CameraState, CameraUpdate } from "../camera.ts"
|
|
6
|
+
|
|
7
|
+
/** The camera state a camera control drives: the scene, or a view (both
|
|
8
|
+
* expose setCamera and camera()). */
|
|
9
|
+
export type CameraTarget = { setCamera(update: CameraUpdate): void; camera(): CameraState }
|
|
10
|
+
|
|
11
|
+
export type SceneCtx = { scene: SceneHandle; parent: SceneNode; viewport: CameraTarget; pointer: PointerFeed | null }
|
|
12
|
+
export let SceneContext = createContext<SceneCtx>()
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The enclosing scene, parent node, viewport and pointer feed - the
|
|
16
|
+
* imperative escape hatch inside a component subtree (throws outside a
|
|
17
|
+
* `<Scene>`). `viewport` and `pointer` are the nearest OWNER's: the
|
|
18
|
+
* scene, or inside a `<View3d>` that view - what the camera-control
|
|
19
|
+
* components drive, and the feed the owner's `pointer` prop handed in
|
|
20
|
+
* (null when none was: the owner's leaf then feeds no gestures). The
|
|
21
|
+
* same shape as @solidrt/2d's useSpriteLayer.
|
|
22
|
+
*/
|
|
23
|
+
export function useScene(): SceneCtx {
|
|
24
|
+
return useContext(SceneContext)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The scene context for a node component's children. Solid's provider is
|
|
28
|
+
// a root and two memos over the children, paid even when there are none,
|
|
29
|
+
// so it is built only when the JSX has children: the compiler emits the
|
|
30
|
+
// `children` getter only then, so the test is structural, not a read
|
|
31
|
+
// (probes/3d-instance-mount-bench.tsx has the per-row cost).
|
|
32
|
+
export function provide(ctx: SceneCtx, parent: SceneNode, props: { children?: Element }): Element | undefined {
|
|
33
|
+
if (!("children" in props)) return undefined
|
|
34
|
+
return <SceneContext value={{ scene: ctx.scene, parent, viewport: ctx.viewport, pointer: ctx.pointer }}>{props.children}</SceneContext>
|
|
35
|
+
}
|