@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,132 @@
1
+ // The standard material: the sphere grid every PBR engine's docs open
2
+ // with - metalness down the rows, roughness across the columns - under a
3
+ // baked sky as background and environment (the skybox example's bake) and
4
+ // a warm sun casting shadows. What to look for: the bare-metal row IS the
5
+ // environment, sharp at the left and blurred toward the right, tinted by
6
+ // the base color and dark wherever the sky is; the dielectric row keeps
7
+ // its diffuse color and shows the sky as a faint gloss, a mirror highlight
8
+ // at the left that widens to the right and brightens toward every
9
+ // silhouette; the rows between blend the two. standard takes lit's base,
10
+ // maps, cutout, shadow, emissive and fog options - only the Blinn-Phong
11
+ // knobs are gone, replaced by metalness/roughness and their packed maps.
12
+ // Light intensities read as lit's: 1 lights a white matte surface to 1.
13
+ // Drag to look around.
14
+
15
+ import { pct, render } from "@solidrt/core"
16
+ import { createCubeTexture } from "@solidrt/core/gpu"
17
+ import { DirectionalLight, HemisphereLight, Mesh, OrbitCamera, plane, Scene, sphere, standard } from "@solidrt/3d"
18
+ import { normalize } from "@solidrt/3d/math"
19
+ import type { Vec3 } from "@solidrt/3d/math"
20
+
21
+ // Metalness steps down the rows, roughness across the columns.
22
+ const ROWS = 4
23
+ const COLS = 6
24
+ // Sphere radius and grid pitch, world units.
25
+ const RADIUS = 0.42
26
+ const PITCH = 1.05
27
+ // The spheres' base color, sRGB: a warm red that reads as paint and as metal.
28
+ const COLOR: [number, number, number] = [0.8, 0.25, 0.2]
29
+ // Height of the bottom row's centers above the ground.
30
+ const LIFT = 0.6
31
+ // Face edge of the baked sky in texels.
32
+ const FACE = 128
33
+ // The sun's direction (toward it).
34
+ const SUN: Vec3 = normalize([0.6, 0.45, -0.7])
35
+ // How far out the sun light sits, so its shadow camera covers the grid.
36
+ const SUN_DISTANCE = 12
37
+ // Cosine of the sun disc's angular radius (1.5 degrees).
38
+ const SUN_DISC = Math.cos((1.5 * Math.PI) / 180)
39
+ // Glow falloff exponent around the disc (higher = tighter).
40
+ const SUN_GLOW = 40
41
+ // How quickly the ground band darkens below the horizon (per unit of -y).
42
+ const GROUND_FALL = 4
43
+
44
+ type Rgb = [number, number, number]
45
+ const ZENITH: Rgb = [0.16, 0.34, 0.74]
46
+ const HORIZON: Rgb = [0.8, 0.87, 0.95]
47
+ const GROUND: Rgb = [0.3, 0.27, 0.24]
48
+ const SUN_COLOR: Rgb = [1, 0.95, 0.82]
49
+
50
+ let mix = (a: Rgb, b: Rgb, t: number): Rgb => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]
51
+
52
+ // The sky's color along a world direction.
53
+ function sky(d: Vec3): Rgb {
54
+ let y = d[1]
55
+ let rgb = y >= 0 ? mix(HORIZON, ZENITH, Math.sqrt(y)) : mix(HORIZON, GROUND, Math.min(1, -y * GROUND_FALL))
56
+ let s = d[0] * SUN[0] + d[1] * SUN[1] + d[2] * SUN[2]
57
+ if (s > 0) {
58
+ let g = s > SUN_DISC ? 1 : Math.pow(s, SUN_GLOW)
59
+ rgb = [rgb[0] + SUN_COLOR[0] * g, rgb[1] + SUN_COLOR[1] * g, rgb[2] + SUN_COLOR[2] * g]
60
+ }
61
+ return [Math.min(1, rgb[0]), Math.min(1, rgb[1]), Math.min(1, rgb[2])]
62
+ }
63
+
64
+ // The world direction whose lookup lands on face i's texel (s, t) - the
65
+ // GL cube-map table, no flip (see skybox.tsx).
66
+ function texelDirection(face: number, s: number, t: number): Vec3 {
67
+ let a = 2 * s - 1
68
+ let b = 2 * t - 1
69
+ let d: Vec3 =
70
+ face === 0 ? [1, -b, -a] : face === 1 ? [-1, -b, a] : face === 2 ? [a, 1, b] : face === 3 ? [a, -1, -b] : face === 4 ? [a, -b, 1] : [-a, -b, -1]
71
+ return normalize(d)
72
+ }
73
+
74
+ function bakeSky(): Uint8Array[] {
75
+ let faces: Uint8Array[] = []
76
+ for (let face = 0; face < 6; face++) {
77
+ let px = new Uint8Array(FACE * FACE * 4)
78
+ for (let row = 0; row < FACE; row++) {
79
+ for (let col = 0; col < FACE; col++) {
80
+ let rgb = sky(texelDirection(face, (col + 0.5) / FACE, (row + 0.5) / FACE))
81
+ let i = (row * FACE + col) * 4
82
+ px[i] = Math.round(rgb[0] * 255)
83
+ px[i + 1] = Math.round(rgb[1] * 255)
84
+ px[i + 2] = Math.round(rgb[2] * 255)
85
+ px[i + 3] = 255
86
+ }
87
+ }
88
+ faces.push(px)
89
+ }
90
+ return faces
91
+ }
92
+
93
+ function App() {
94
+ let cube = createCubeTexture(bakeSky(), FACE, { format: "rgba8-srgb", mipmap: true, label: "sky" })
95
+ let ball = sphere({ radius: RADIUS, widthSegments: 48, heightSegments: 32 })
96
+ let ground = standard({ color: [0.32, 0.33, 0.3], roughness: 0.9 })
97
+ // One material per cell: metalness by row (top row bare metal, bottom
98
+ // row dielectric), roughness by column (mirror at the left).
99
+ let cells: { material: ReturnType<typeof standard>; position: Vec3 }[] = []
100
+ for (let r = 0; r < ROWS; r++) {
101
+ for (let c = 0; c < COLS; c++) {
102
+ cells.push({
103
+ material: standard({ color: COLOR, metalness: 1 - r / (ROWS - 1), roughness: c / (COLS - 1) }),
104
+ position: [(c - (COLS - 1) / 2) * PITCH, LIFT + (ROWS - 1 - r) * PITCH, 0],
105
+ })
106
+ }
107
+ }
108
+ let center: Vec3 = [0, LIFT + ((ROWS - 1) * PITCH) / 2, 0]
109
+ return (
110
+ <window>
111
+ <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} />
114
+ <HemisphereLight sky={[0.5, 0.6, 0.8]} ground={[0.3, 0.27, 0.24]} intensity={0.5} />
115
+ <DirectionalLight
116
+ position={[SUN[0] * SUN_DISTANCE, SUN[1] * SUN_DISTANCE, SUN[2] * SUN_DISTANCE]}
117
+ direction={[-SUN[0], -SUN[1], -SUN[2]]}
118
+ color={SUN_COLOR}
119
+ intensity={1}
120
+ castShadow
121
+ />
122
+ <Mesh geometry={plane({ width: 16, height: 16 })} material={ground} rotation={[-Math.PI / 2, 0, 0]} />
123
+ {cells.map(cell => (
124
+ <Mesh geometry={ball} material={cell.material} position={cell.position} castShadow />
125
+ ))}
126
+ </Scene>
127
+ </view>
128
+ </window>
129
+ )
130
+ }
131
+
132
+ render(() => <App />)
@@ -10,8 +10,6 @@ import { createSignal, onFrame, pct, render } from "@solidrt/core"
10
10
  import { box, DirectionalLight, Group, HemisphereLight, lit, Mesh, PerspectiveCamera, plane, roundRect, Scene, sweep, tube, unlit } from "@solidrt/3d"
11
11
  import type { SweepPath } from "@solidrt/3d"
12
12
 
13
- const SIZE = 720
14
-
15
13
  function App() {
16
14
  let [spin, setSpin] = createSignal(0)
17
15
  onFrame(tick => setSpin(tick / 4000))
@@ -39,8 +37,8 @@ function App() {
39
37
 
40
38
  return (
41
39
  <window>
42
- <view width={pct(100)} height={pct(100)} designSize={[SIZE, SIZE]}>
43
- <Scene width={SIZE} height={SIZE} clearColor={[0.07, 0.07, 0.1, 1]} label="sweep-paths">
40
+ <view width={pct(100)} height={pct(100)}>
41
+ <Scene clearColor={[0.07, 0.07, 0.1, 1]} label="sweep-paths">
44
42
  <PerspectiveCamera fov={55} position={[0, 1.9, 3.9]} lookAt={[0, 0.35, 0]} />
45
43
  <HemisphereLight sky={[0.45, 0.45, 0.45]} ground={[0.22, 0.22, 0.22]} />
46
44
  <DirectionalLight direction={[-0.5, -0.8, -0.4]} intensity={0.8} />
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/3d",
3
- "version": "0.0.55",
3
+ "version": "0.0.56",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -18,7 +18,7 @@
18
18
  "AGENTS.md"
19
19
  ],
20
20
  "peerDependencies": {
21
- "@solidjs/signals": "2.0.0-rc.4",
22
- "@solidrt/core": "0.0.55"
21
+ "@solidjs/signals": "2.0.0-rc.6",
22
+ "@solidrt/core": "0.0.56"
23
23
  }
24
24
  }
package/src/camera.ts ADDED
@@ -0,0 +1,148 @@
1
+ // The camera record: one per scene target and one per view (the shadow
2
+ // views included), the same state and the same one-shared-write contract
3
+ // everywhere. Split out of scene.ts so the shadow system can place its
4
+ // map cameras through the exact code the scene uses for its own.
5
+
6
+ import { invert, lookAt as lookAtMatrix, mat4, multiply, orthographic, perspective } from "./math.ts"
7
+ import type { Mat4, Vec3 } from "./math.ts"
8
+ import type { ShaderParams } from "@solidrt/core/gpu"
9
+
10
+ /** An orthographic projection's view-space extents, in world units (the
11
+ * same box at every depth). */
12
+ export type OrthoExtent = { left: number; right: number; top: number; bottom: number }
13
+
14
+ /** A camera snapshot (Scene/View `camera()`): CameraUpdate's fields, all
15
+ * present. Arrays are copies of the internal state. */
16
+ export type CameraState = {
17
+ fov: number
18
+ near: number
19
+ far: number
20
+ position: Vec3
21
+ target: Vec3
22
+ up: Vec3
23
+ ortho: OrthoExtent | null
24
+ }
25
+
26
+ export type CameraUpdate = {
27
+ /** Vertical field of view in DEGREES (default 60). */
28
+ fov?: number
29
+ near?: number
30
+ far?: number
31
+ position?: Vec3
32
+ target?: Vec3
33
+ up?: Vec3
34
+ /** An orthographic projection with these extents (`fov` is then
35
+ * ignored); null returns to perspective. Three's OrthographicCamera as
36
+ * a camera option: a top-down map, an isometric view, a shadow-map
37
+ * light. */
38
+ ortho?: OrthoExtent | null
39
+ }
40
+
41
+ // `dirty` = the matrices need recomputing (a setCamera or a resize),
42
+ // `pending` = the GPU write is owed to the next sync. The recompute is
43
+ // split from the sync so project()/viewProj() see a fresh matrix right
44
+ // after setCamera, before the microtask runs.
45
+ export type Camera = {
46
+ fov: number
47
+ near: number
48
+ far: number
49
+ eye: Vec3
50
+ target: Vec3
51
+ up: Vec3
52
+ ortho: OrthoExtent | null
53
+ /** Mirror the projection's x: the image comes out flipped left to
54
+ * right. A reflection probe's face cameras set it - a GL cube face is
55
+ * seen from outside, the x mirror of what a camera sees - and the cube
56
+ * draw target's pass inverts the winding to match. */
57
+ mirror: boolean
58
+ dirty: boolean
59
+ pending: boolean
60
+ proj: Mat4
61
+ view: Mat4
62
+ viewProj: Mat4
63
+ invViewProj: Mat4
64
+ }
65
+
66
+ export function makeCamera(): Camera {
67
+ return {
68
+ fov: 60,
69
+ near: 0.1,
70
+ far: 100,
71
+ eye: [0, 0, 3],
72
+ target: [0, 0, 0],
73
+ up: [0, 1, 0],
74
+ ortho: null,
75
+ mirror: false,
76
+ dirty: true,
77
+ pending: false,
78
+ proj: mat4(),
79
+ view: mat4(),
80
+ viewProj: mat4(),
81
+ invViewProj: mat4(),
82
+ }
83
+ }
84
+
85
+ export function cameraState(cam: Camera): CameraState {
86
+ return {
87
+ fov: cam.fov,
88
+ near: cam.near,
89
+ far: cam.far,
90
+ position: [cam.eye[0], cam.eye[1], cam.eye[2]],
91
+ target: [cam.target[0], cam.target[1], cam.target[2]],
92
+ up: [cam.up[0], cam.up[1], cam.up[2]],
93
+ ortho: cam.ortho === null ? null : { left: cam.ortho.left, right: cam.ortho.right, top: cam.ortho.top, bottom: cam.ortho.bottom },
94
+ }
95
+ }
96
+
97
+ export function updateCamera(cam: Camera, update: CameraUpdate): void {
98
+ if (update.fov !== undefined) cam.fov = update.fov
99
+ if (update.near !== undefined) cam.near = update.near
100
+ if (update.far !== undefined) cam.far = update.far
101
+ if (update.position) cam.eye = [update.position[0], update.position[1], update.position[2]]
102
+ if (update.target) cam.target = [update.target[0], update.target[1], update.target[2]]
103
+ if (update.up) cam.up = [update.up[0], update.up[1], update.up[2]]
104
+ if (update.ortho !== undefined) {
105
+ let o = update.ortho
106
+ cam.ortho = o === null ? null : { left: o.left, right: o.right, top: o.top, bottom: o.bottom }
107
+ }
108
+ cam.dirty = true
109
+ }
110
+
111
+ export function ensureCamera(cam: Camera, width: number, height: number): void {
112
+ if (!cam.dirty) return
113
+ cam.dirty = false
114
+ cam.pending = true
115
+ let o = cam.ortho
116
+ if (o === null) perspective(cam.proj, (cam.fov * Math.PI) / 180, width / height, cam.near, cam.far)
117
+ else orthographic(cam.proj, o.left, o.right, o.top, o.bottom, cam.near, cam.far)
118
+ if (cam.mirror) {
119
+ // Negate the clip x row (column-major: elements 0, 4, 8, 12).
120
+ cam.proj[0] = -cam.proj[0]!
121
+ cam.proj[4] = -cam.proj[4]!
122
+ cam.proj[8] = -cam.proj[8]!
123
+ cam.proj[12] = -cam.proj[12]!
124
+ }
125
+ lookAtMatrix(cam.view, cam.eye, cam.target, cam.up)
126
+ multiply(cam.viewProj, cam.proj, cam.view)
127
+ invert(cam.invViewProj, cam.viewProj)
128
+ }
129
+
130
+ // The camera is target state: one shared write, whatever the target holds.
131
+ // Entries are untouched - uModel is camera-independent, and uCamPos is
132
+ // stored even when no current material declares it. The camera basis rides
133
+ // along: the view matrix's first two rows are the camera's world-space
134
+ // right and up (no clip flip - that lives in the projection), so a
135
+ // billboard needs no reconstruction from uViewProj. The inverse rides too:
136
+ // uInvViewProj carries a clip position back to world, which is how the
137
+ // background slot (and any shader declaring it) gets a world-space ray
138
+ // per pixel without knowing the projection.
139
+ export function cameraParams(cam: Camera): ShaderParams {
140
+ let v = cam.view
141
+ return {
142
+ uViewProj: cam.viewProj,
143
+ uInvViewProj: cam.invViewProj,
144
+ uCamPos: cam.eye,
145
+ uCamRight: [v[0], v[4], v[8]],
146
+ uCamUp: [v[1], v[5], v[9]],
147
+ }
148
+ }
package/src/clip.ts ADDED
@@ -0,0 +1,96 @@
1
+ // Pure clip sampling - the mixer's core, split out so it runs with no
2
+ // scene or engine (checks under flux, custom drivers, a future bake-side
3
+ // resample). Everything here is plain array math over ModelChannel data.
4
+
5
+ import { quatNormalize, quatSlerp } from "./math.ts"
6
+ import type { Quat } from "./math.ts"
7
+ import type { ModelChannel } from "./gltf.ts"
8
+
9
+ /**
10
+ * Sample one channel at `time` (seconds, clamped to the key range) into
11
+ * `out` - 3 floats for position/scale, 4 for rotation. Pure array math:
12
+ * step holds the earlier key, linear lerps (rotation slerps the shortest
13
+ * path), cubic evaluates the glTF CUBICSPLINE Hermite (rotation
14
+ * renormalized, per spec).
15
+ */
16
+ export function sampleChannel(channel: ModelChannel, time: number, out: number[]): void {
17
+ let times = channel.times
18
+ let values = channel.values
19
+ let keys = times.length
20
+ let elements = channel.path === "rotation" ? 4 : 3
21
+ let stride = channel.interpolation === "cubic" ? elements * 3 : elements
22
+ // The value element offset within a key: cubic keys are [in, value, out].
23
+ let mid = channel.interpolation === "cubic" ? elements : 0
24
+ if (keys === 0) return
25
+ if (time <= times[0]! || keys === 1) {
26
+ for (let e = 0; e < elements; e++) out[e] = values[mid + e]!
27
+ return
28
+ }
29
+ if (time >= times[keys - 1]!) {
30
+ let at = (keys - 1) * stride + mid
31
+ for (let e = 0; e < elements; e++) out[e] = values[at + e]!
32
+ return
33
+ }
34
+ // The key pair around `time`: binary search for the last key at or
35
+ // before it.
36
+ let lo = 0
37
+ let hi = keys - 1
38
+ while (hi - lo > 1) {
39
+ let m = (lo + hi) >> 1
40
+ if (times[m]! <= time) lo = m
41
+ else hi = m
42
+ }
43
+ let t0 = times[lo]!
44
+ let t1 = times[hi]!
45
+ let span = t1 - t0
46
+ let s = span > 0 ? (time - t0) / span : 0
47
+ let a = lo * stride + mid
48
+ let b = hi * stride + mid
49
+ switch (channel.interpolation) {
50
+ case "step":
51
+ for (let e = 0; e < elements; e++) out[e] = values[a + e]!
52
+ return
53
+ case "linear":
54
+ if (channel.path === "rotation") {
55
+ readQuat(SAMPLE_A, values, a)
56
+ readQuat(SAMPLE_B, values, b)
57
+ quatSlerp(SAMPLE_A, SAMPLE_A, SAMPLE_B, s)
58
+ for (let e = 0; e < 4; e++) out[e] = SAMPLE_A[e]!
59
+ } else {
60
+ for (let e = 0; e < elements; e++) out[e] = values[a + e]! + (values[b + e]! - values[a + e]!) * s
61
+ }
62
+ return
63
+ case "cubic": {
64
+ // glTF's Hermite: p(s) = h00 v0 + h10 d b0 + h01 v1 + h11 d a1,
65
+ // where b0 is key lo's OUT-tangent and a1 key hi's IN-tangent
66
+ // (per-second, so they scale by the span d).
67
+ let s2 = s * s
68
+ let s3 = s2 * s
69
+ let h00 = 2 * s3 - 3 * s2 + 1
70
+ let h10 = s3 - 2 * s2 + s
71
+ let h01 = -2 * s3 + 3 * s2
72
+ let h11 = s3 - s2
73
+ let outTan = lo * stride + elements * 2
74
+ let inTan = hi * stride
75
+ for (let e = 0; e < elements; e++) {
76
+ out[e] = h00 * values[a + e]! + h10 * span * values[outTan + e]! + h01 * values[b + e]! + h11 * span * values[inTan + e]!
77
+ }
78
+ if (channel.path === "rotation") {
79
+ readQuat(SAMPLE_A, out, 0)
80
+ quatNormalize(SAMPLE_A, SAMPLE_A)
81
+ for (let e = 0; e < 4; e++) out[e] = SAMPLE_A[e]!
82
+ }
83
+ return
84
+ }
85
+ }
86
+ }
87
+
88
+ const SAMPLE_A: Quat = [0, 0, 0, 1]
89
+ const SAMPLE_B: Quat = [0, 0, 0, 1]
90
+
91
+ function readQuat(out: Quat, values: ArrayLike<number>, at: number): void {
92
+ out[0] = values[at]!
93
+ out[1] = values[at + 1]!
94
+ out[2] = values[at + 2]!
95
+ out[3] = values[at + 3]!
96
+ }
@@ -0,0 +1,199 @@
1
+ // A kinematic character move over the scene's volume queries: Godot's
2
+ // CharacterBody3D.move_and_slide and Unity's CharacterController.Move as
3
+ // one pure function. Sweep the body along its motion, stop a skin short
4
+ // of the first contact, slide the rest along the contact plane, repeat a
5
+ // few times - with the two passes both engines wrap around that loop: a
6
+ // depenetration first (a body that starts inside something is pushed out
7
+ // before it moves) and a floor snap after (a body walking down a ramp is
8
+ // pulled back onto it instead of stepping into the air). Per body per
9
+ // frame that is the input read and this one call; the geometry runs in
10
+ // the spatial core.
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
+
20
+ 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
+
47
+ /** What moveAndSlide needs of a scene. */
48
+ export type MoveScene = Pick<Scene, "sweep" | "overlap">
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
+ }
68
+
69
+ export type MoveResult = {
70
+ /** The displacement the body gets: add it to the body's position. */
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
+ */
102
+ export function moveAndSlide(scene: MoveScene, volume: Volume, motion: Vec3, opts: MoveOptions = {}): MoveResult {
103
+ let up = opts.up ?? UP
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]
199
+ }
package/src/color.ts ADDED
@@ -0,0 +1,32 @@
1
+ // The sRGB transfer functions (IEC 61966-2-1) in JS, the JS half of the
2
+ // package's color contract: every [r, g, b] color option - material
3
+ // color and emissive, light color, hemisphere sky and ground, fog color -
4
+ // is sRGB-encoded, what a color picker shows, and the library decodes it to
5
+ // linear light when it writes the uniform (Three's ColorManagement,
6
+ // Godot's inspector colors, Unity's linear color space all do the same).
7
+ // Shading then runs in linear light and the fragment's output stage
8
+ // (OUTPUT in ./glsl) encodes the result back. Values written straight to
9
+ // a uniform (scene.setParams, setMeshParams) and vertex colors are linear,
10
+ // as in every engine; encode or decode them here when they come from a
11
+ // picker.
12
+
13
+ /** Decode one sRGB-encoded component (0..1) to linear light. Above 1 the
14
+ * curve continues, so an intensity folded into a color survives. */
15
+ export function srgbToLinear(c: number): number {
16
+ if (c <= 0.04045) return c / 12.92
17
+ return Math.pow((c + 0.055) / 1.055, 2.4)
18
+ }
19
+
20
+ /** Encode one linear-light component to sRGB: the inverse of srgbToLinear. */
21
+ export function linearToSrgb(c: number): number {
22
+ if (c <= 0.0031308) return c * 12.92
23
+ return 1.055 * Math.pow(c, 1 / 2.4) - 0.055
24
+ }
25
+
26
+ /** A copy of an [r, g, b] or [r, g, b, a] color with its rgb decoded to
27
+ * linear light; alpha is coverage, never encoded, and passes through. */
28
+ export function linearColor(color: readonly number[]): number[] {
29
+ let out = color.slice()
30
+ for (let i = 0; i < 3 && i < out.length; i++) out[i] = srgbToLinear(out[i]!)
31
+ return out
32
+ }