@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.
- package/AGENTS.md +1206 -224
- package/README.md +16 -7
- package/examples/README.md +88 -9
- package/examples/aim.tsx +2 -3
- package/examples/cascades.tsx +1 -4
- package/examples/collision.tsx +263 -0
- package/examples/first-person.tsx +129 -0
- package/examples/fleet.tsx +200 -0
- package/examples/fog.tsx +2 -3
- package/examples/instanced.tsx +72 -32
- package/examples/lamps.tsx +92 -0
- package/examples/lit.tsx +3 -5
- package/examples/materials.tsx +150 -0
- package/examples/model-load.tsx +95 -0
- package/examples/model.glb +0 -0
- package/examples/model.tsx +35 -9
- package/examples/pick.tsx +145 -37
- package/examples/probe.tsx +107 -0
- package/examples/scene-background.tsx +2 -4
- package/examples/scene-basic.tsx +2 -4
- package/examples/scene-views.tsx +68 -48
- package/examples/shadows.tsx +2 -4
- package/examples/sky-lit.tsx +92 -0
- package/examples/skybox.tsx +134 -0
- package/examples/sprites.tsx +4 -6
- package/examples/standard.tsx +138 -0
- package/examples/sweep-paths.tsx +2 -4
- package/package.json +3 -3
- package/src/camera.ts +148 -0
- package/src/clip.ts +96 -0
- package/src/collision.ts +20 -0
- package/src/color.ts +32 -0
- 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/environment-bake.ts +492 -0
- package/src/environment.ts +325 -0
- package/src/first-person.ts +258 -0
- package/src/geometry.ts +6 -2
- package/src/glsl.ts +1343 -82
- package/src/gltf.ts +432 -112
- package/src/index.ts +61 -9
- package/src/input.ts +70 -0
- package/src/light.ts +383 -0
- package/src/material.ts +667 -232
- package/src/math.ts +110 -0
- package/src/mesh.ts +728 -0
- package/src/mixer.ts +476 -0
- package/src/model-file.ts +80 -15
- package/src/model.ts +204 -35
- package/src/node.ts +579 -0
- package/src/orbit.ts +292 -212
- package/src/scene-pointer.ts +294 -0
- package/src/scene-shadows.ts +471 -0
- package/src/scene.ts +1435 -1576
- package/tools/environment.ts +69 -0
- package/tools/model.ts +4 -3
- package/src/components.tsx +0 -420
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
|
+
}
|
package/src/collision.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
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"
|
|
11
|
+
import type { Vec3 } from "./math.ts"
|
|
12
|
+
|
|
13
|
+
/** What moveAndSlide needs of a scene. */
|
|
14
|
+
export type MoveScene = Pick<Scene, "moveAndSlide">
|
|
15
|
+
|
|
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). */
|
|
18
|
+
export function moveAndSlide(scene: MoveScene, volume: Volume, motion: Vec3, opts: MoveOptions = {}): MoveResult {
|
|
19
|
+
return scene.moveAndSlide(volume, motion, opts)
|
|
20
|
+
}
|
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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
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 { createDirectionalLight, setLight } from "../light.ts"
|
|
8
|
+
import type { DirectionalLight as DirectionalLightNode, ShadowOptions } from "../light.ts"
|
|
9
|
+
import type { Vec3 } from "../math.ts"
|
|
10
|
+
|
|
11
|
+
export type DirectionalLightProps = TransformProps & {
|
|
12
|
+
/** Travel direction in the node's local space; default [0, -1, 0]. */
|
|
13
|
+
direction?: Vec3
|
|
14
|
+
color?: Vec3
|
|
15
|
+
intensity?: number
|
|
16
|
+
/** Render a shadow map from this light (any directional light may;
|
|
17
|
+
* each is a pass). Its shadow camera sits at the light's WORLD
|
|
18
|
+
* position, so give a casting light a `position` above the scene. */
|
|
19
|
+
castShadow?: boolean
|
|
20
|
+
/** Shadow-map options (mapSize, bias, normalBias, camera frustum,
|
|
21
|
+
* cascades, distance), merged key by key. */
|
|
22
|
+
shadow?: ShadowOptions
|
|
23
|
+
ref?: (light: DirectionalLightNode) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A directional light node (createDirectionalLight): a parent Group's
|
|
27
|
+
* rotation turns it; up to MAX_LIGHTS per scene, in mount order. */
|
|
28
|
+
export let DirectionalLight: VoidComponent<DirectionalLightProps> = props => {
|
|
29
|
+
let ctx = useContext(SceneContext)
|
|
30
|
+
let light = untrack(() =>
|
|
31
|
+
createDirectionalLight({
|
|
32
|
+
direction: props.direction,
|
|
33
|
+
color: props.color,
|
|
34
|
+
intensity: props.intensity,
|
|
35
|
+
castShadow: props.castShadow,
|
|
36
|
+
shadow: props.shadow,
|
|
37
|
+
}),
|
|
38
|
+
)
|
|
39
|
+
add(ctx.parent, light)
|
|
40
|
+
syncNode(light, props)
|
|
41
|
+
createEffect(
|
|
42
|
+
() => [props.direction, props.color, props.intensity, props.castShadow, props.shadow] as const,
|
|
43
|
+
([direction, color, intensity, castShadow, shadow]) => setLight(light, { direction, color, intensity, castShadow, shadow }),
|
|
44
|
+
)
|
|
45
|
+
untrack(() => props.ref)?.(light)
|
|
46
|
+
onCleanup(() => remove(light))
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
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 { createFirstPersonCamera } from "../first-person.ts"
|
|
5
|
+
import type { FirstPersonAxes, FirstPersonCamera as FirstPersonCameraHandle, FirstPersonCameraOptions } from "../first-person.ts"
|
|
6
|
+
|
|
7
|
+
// Cap on the frame loop's per-frame dt in seconds, the same guard as the
|
|
8
|
+
// orbit's: a resumed app must not teleport the walker.
|
|
9
|
+
const MAX_WALK_DT = 0.1
|
|
10
|
+
|
|
11
|
+
export type FirstPersonCameraProps = FirstPersonCameraOptions & {
|
|
12
|
+
/** The input map driving the control (InputMap.drive): its `look`,
|
|
13
|
+
* `move` and `rise` 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 FirstPersonAxes, string | null>>
|
|
18
|
+
/** The control's handle (pose()/set()/eye()/forward()/active(), the
|
|
19
|
+
* verbs, `axes` - also the debug command shape). */
|
|
20
|
+
ref?: (camera: FirstPersonCameraHandle) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* createFirstPersonCamera as a Scene (or View3d) child: drives the
|
|
25
|
+
* enclosing camera - the scene's, or inside a `<View3d>` that view's -
|
|
26
|
+
* through context, and takes its input from the map in `input`, nothing
|
|
27
|
+
* else (ARCHITECTURE.md: the app binds keys, a pointer feed, a pad or
|
|
28
|
+
* anything else to the map, and spreads the map's key handlers where the
|
|
29
|
+
* keys should be caught). Pointer lock stays the app's: call
|
|
30
|
+
* `lockPointer(true)` from a click and `lockPointer(false)` from Escape;
|
|
31
|
+
* the pointer feed's `mouseDelta` source looks around while locked. The
|
|
32
|
+
* pose props (position, yaw, pitch) are initial values - change the pose
|
|
33
|
+
* at runtime through `ref`'s set() or the verbs; every other prop is
|
|
34
|
+
* live, forwarded to the control as a getter and read where it applies:
|
|
35
|
+
* `fly={flying()}` swaps walk and fly on the running control (pose
|
|
36
|
+
* carries over, no remount), `moveSpeed`, `lookSpeed` and
|
|
37
|
+
* `clampPosition` follow their props, and a pitch clamp change re-clamps
|
|
38
|
+
* at once. A frame loop runs only while `active()` - a rate driving - so
|
|
39
|
+
* a still scene stays demand-driven idle.
|
|
40
|
+
*/
|
|
41
|
+
export let FirstPersonCamera: VoidComponent<FirstPersonCameraProps> = props => {
|
|
42
|
+
let ctx = useContext(SceneContext)
|
|
43
|
+
// Getters, not a spread: see OrbitCamera.
|
|
44
|
+
let options: FirstPersonCameraOptions = merge(props, {})
|
|
45
|
+
let camera = untrack(() => createFirstPersonCamera(ctx.viewport, options))
|
|
46
|
+
createEffect(
|
|
47
|
+
() => props.input,
|
|
48
|
+
input => (input ? input.drive(camera.axes, untrack(() => props.actions)) : undefined),
|
|
49
|
+
)
|
|
50
|
+
createEffect(
|
|
51
|
+
() => camera.active(),
|
|
52
|
+
on => {
|
|
53
|
+
if (!on) return
|
|
54
|
+
let last: number | null = null
|
|
55
|
+
return onFrame(tick => {
|
|
56
|
+
let now = tick / 1000
|
|
57
|
+
let dt = last === null ? 0 : Math.min(now - last, MAX_WALK_DT)
|
|
58
|
+
last = now
|
|
59
|
+
camera.update(dt)
|
|
60
|
+
})
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
// A pitch clamp change re-clamps the pose at once; deferred and
|
|
64
|
+
// untracked as OrbitCamera's.
|
|
65
|
+
createEffect(
|
|
66
|
+
() => [options.minPitch, options.maxPitch],
|
|
67
|
+
() => untrack(() => camera.set({})),
|
|
68
|
+
{ defer: true },
|
|
69
|
+
)
|
|
70
|
+
untrack(() => props.ref)?.(camera)
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { onCleanup, untrack, useContext } from "@solidrt/core"
|
|
2
|
+
import type { ParentComponent } from "@solidrt/core"
|
|
3
|
+
import { SceneContext, provide } from "./context.tsx"
|
|
4
|
+
import { syncNode } from "./node-props.ts"
|
|
5
|
+
import type { TransformProps, PointerEventProps } from "./node-props.ts"
|
|
6
|
+
import { add, createGroup, remove } from "../node.ts"
|
|
7
|
+
import type { SceneNode } from "../node.ts"
|
|
8
|
+
|
|
9
|
+
/** A transform node: children inherit its position/rotation/scale. */
|
|
10
|
+
export let Group: ParentComponent<TransformProps & PointerEventProps & { ref?: (node: SceneNode) => void }> = props => {
|
|
11
|
+
let ctx = useContext(SceneContext)
|
|
12
|
+
let node = createGroup()
|
|
13
|
+
add(ctx.parent, node)
|
|
14
|
+
syncNode(node, props)
|
|
15
|
+
untrack(() => props.ref)?.(node)
|
|
16
|
+
onCleanup(() => remove(node))
|
|
17
|
+
return provide(ctx, node, props)
|
|
18
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
|
|
2
|
+
import type { VoidComponent } from "@solidrt/core"
|
|
3
|
+
import { SceneContext } from "./context.tsx"
|
|
4
|
+
import { add, remove } from "../node.ts"
|
|
5
|
+
import { createHemisphereLight, setLight } from "../light.ts"
|
|
6
|
+
import type { HemisphereLight as HemisphereLightNode } from "../light.ts"
|
|
7
|
+
import type { Vec3 } from "../math.ts"
|
|
8
|
+
|
|
9
|
+
export type HemisphereLightProps = { sky?: Vec3; ground?: Vec3; intensity?: number; ref?: (light: HemisphereLightNode) => void }
|
|
10
|
+
|
|
11
|
+
/** The scene's ambient term as a node (createHemisphereLight); one per
|
|
12
|
+
* scene, the last mounted wins. */
|
|
13
|
+
export let HemisphereLight: VoidComponent<HemisphereLightProps> = props => {
|
|
14
|
+
let ctx = useContext(SceneContext)
|
|
15
|
+
let light = untrack(() => createHemisphereLight({ sky: props.sky, ground: props.ground, intensity: props.intensity }))
|
|
16
|
+
add(ctx.parent, light)
|
|
17
|
+
createEffect(
|
|
18
|
+
() => [props.sky, props.ground, props.intensity] as const,
|
|
19
|
+
([sky, ground, intensity]) => setLight(light, { sky, ground, intensity }),
|
|
20
|
+
)
|
|
21
|
+
untrack(() => props.ref)?.(light)
|
|
22
|
+
onCleanup(() => remove(light))
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The Solid face: PascalCase components over context, syncing the retained
|
|
2
|
+
// scene (node/mesh/light/scene.ts) - no new intrinsic elements, no
|
|
3
|
+
// renderer changes. Props
|
|
4
|
+
// follow the Solid 2.0 model (reactive values, no destructuring); effects
|
|
5
|
+
// write into the retained nodes and the runtime's dirty flush renders.
|
|
6
|
+
// Anything moving at frame rate can bypass the declarative layer: grab the
|
|
7
|
+
// node with `ref` and call setTransform from onFrame - signals carry
|
|
8
|
+
// structure and slow state, per-frame motion goes straight to the scene.
|
|
9
|
+
//
|
|
10
|
+
// One file per component (the lights, cameras and meshes each their own),
|
|
11
|
+
// with the shared pieces beside them: context.tsx (the scene context and
|
|
12
|
+
// useScene), node-props.ts (the transform and pointer
|
|
13
|
+
// props every node component syncs) and mesh.tsx (the mesh-side props the
|
|
14
|
+
// populated meshes share). This index is the package's face: index.ts at
|
|
15
|
+
// the root re-exports it, nothing outside imports the files directly.
|
|
16
|
+
|
|
17
|
+
export { useScene } from "./context.tsx"
|
|
18
|
+
export type { CameraTarget } from "./context.tsx"
|
|
19
|
+
export type { PointerEventProps, TransformProps } from "./node-props.ts"
|
|
20
|
+
export { Scene } from "./scene.tsx"
|
|
21
|
+
export type { ScenePointerProps, SceneProps } from "./scene.tsx"
|
|
22
|
+
export { View3d } from "./view3d.tsx"
|
|
23
|
+
export type { View3dProps } from "./view3d.tsx"
|
|
24
|
+
export { Group } from "./group.tsx"
|
|
25
|
+
export { Mesh } from "./mesh.tsx"
|
|
26
|
+
export type { MeshProps } from "./mesh.tsx"
|
|
27
|
+
export { Sprite } from "./sprite.tsx"
|
|
28
|
+
export type { SpriteProps } from "./sprite.tsx"
|
|
29
|
+
export { Instance, InstancedMesh } from "./instanced-mesh.tsx"
|
|
30
|
+
export type { InstancedMeshProps, InstanceProps } from "./instanced-mesh.tsx"
|
|
31
|
+
export { RecordMesh } from "./record-mesh.tsx"
|
|
32
|
+
export type { RecordMeshProps } from "./record-mesh.tsx"
|
|
33
|
+
export { PerspectiveCamera } from "./perspective-camera.tsx"
|
|
34
|
+
export type { PerspectiveCameraProps } from "./perspective-camera.tsx"
|
|
35
|
+
export { OrbitCamera } from "./orbit-camera.tsx"
|
|
36
|
+
export type { OrbitCameraProps } from "./orbit-camera.tsx"
|
|
37
|
+
export { FirstPersonCamera } from "./first-person-camera.tsx"
|
|
38
|
+
export type { FirstPersonCameraProps } from "./first-person-camera.tsx"
|
|
39
|
+
export { HemisphereLight } from "./hemisphere-light.tsx"
|
|
40
|
+
export type { HemisphereLightProps } from "./hemisphere-light.tsx"
|
|
41
|
+
export { DirectionalLight } from "./directional-light.tsx"
|
|
42
|
+
export type { DirectionalLightProps } from "./directional-light.tsx"
|
|
43
|
+
export { SpotLight } from "./spot-light.tsx"
|
|
44
|
+
export type { SpotLightProps } from "./spot-light.tsx"
|
|
45
|
+
export { PointLight } from "./point-light.tsx"
|
|
46
|
+
export type { PointLightProps } from "./point-light.tsx"
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { createContext, createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
|
|
2
|
+
import type { ParentComponent } from "@solidrt/core"
|
|
3
|
+
import { SceneContext, provide } from "./context.tsx"
|
|
4
|
+
import { syncNode } from "./node-props.ts"
|
|
5
|
+
import type { TransformProps, PointerEventProps } from "./node-props.ts"
|
|
6
|
+
import { syncMesh } from "./mesh.tsx"
|
|
7
|
+
import type { PopulatedMeshProps } from "./mesh.tsx"
|
|
8
|
+
import { add } from "../node.ts"
|
|
9
|
+
import { addInstance, createInstancedMesh, disposeInstances, removeInstance, setCastShadow, setGeometry, setInstanceStyle } from "../mesh.ts"
|
|
10
|
+
import type { InstancedMesh as InstancedMeshNode, InstanceNode } from "../mesh.ts"
|
|
11
|
+
import type { Geometry } from "../geometry.ts"
|
|
12
|
+
import type { Material } from "../material.ts"
|
|
13
|
+
|
|
14
|
+
export type InstancedMeshProps = PopulatedMeshProps & {
|
|
15
|
+
geometry: Geometry
|
|
16
|
+
/** An instanced material: a stock one with `instanced` (or
|
|
17
|
+
* `instanceColors`, for a per-instance tint), or a class declaring
|
|
18
|
+
* INSTANCE_MATRIX_ATTRIBUTES in slot 0 and any style layout in slot 1. */
|
|
19
|
+
material: Material
|
|
20
|
+
/** Instance slots reserved up front (default 64; fixed at creation).
|
|
21
|
+
* Past it the buffers double into replacements - amortized, but size it
|
|
22
|
+
* realistically to skip the copies. */
|
|
23
|
+
capacity?: number
|
|
24
|
+
/** LOCAL bounds covering the population ([minX..maxZ]), fixed at
|
|
25
|
+
* creation: the mesh node's own box for the frustum test and the
|
|
26
|
+
* transparent sort. Optional here - instances pick by themselves, and
|
|
27
|
+
* without it the mesh culls by the union of their boxes. */
|
|
28
|
+
bounds?: ArrayLike<number>
|
|
29
|
+
/** Debug label for the record buffers. */
|
|
30
|
+
label?: string
|
|
31
|
+
/** Draw into the scene's shadow map (setCastShadow as a prop); default
|
|
32
|
+
* false. The stock instanced materials cast; a custom class needs a
|
|
33
|
+
* `shadowVertex`. */
|
|
34
|
+
castShadow?: boolean
|
|
35
|
+
ref?: (mesh: InstancedMeshNode) => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let InstancedMeshContext = createContext<InstancedMeshNode | null>(null)
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One draw entry covering N instance NODES (createInstancedMesh as a
|
|
42
|
+
* component): `<Instance>` children populate it - each a scene node the
|
|
43
|
+
* core places, so their `transition` props spring with zero per-frame
|
|
44
|
+
* JS, and picking, pointer events, overlap and sweep name the instance
|
|
45
|
+
* struck. `<Group>` children between the mesh and its instances are
|
|
46
|
+
* squads (the records stay mesh-relative through them). The record
|
|
47
|
+
* buffers are component-owned and freed on unmount.
|
|
48
|
+
*/
|
|
49
|
+
export let InstancedMesh: ParentComponent<InstancedMeshProps> = props => {
|
|
50
|
+
let ctx = useContext(SceneContext)
|
|
51
|
+
let mesh = untrack(() =>
|
|
52
|
+
createInstancedMesh(props.geometry, props.material, { capacity: props.capacity, bounds: props.bounds, label: props.label }),
|
|
53
|
+
)
|
|
54
|
+
add(ctx.parent, mesh)
|
|
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 (
|
|
68
|
+
<SceneContext value={{ scene: ctx.scene, parent: mesh, viewport: ctx.viewport, pointer: ctx.pointer }}>
|
|
69
|
+
<InstancedMeshContext value={mesh}>{props.children}</InstancedMeshContext>
|
|
70
|
+
</SceneContext>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type InstanceProps = TransformProps & PointerEventProps & {
|
|
75
|
+
/** The instance's style record (setInstanceStyle as a prop): the
|
|
76
|
+
* floats of the material's slot-1 instance attributes - `[r, g, b, a]`
|
|
77
|
+
* under a stock material's `instanceColors`. Reactive; absent, the
|
|
78
|
+
* instance keeps the material's instanceStyle (white for a tint). */
|
|
79
|
+
style?: ArrayLike<number>
|
|
80
|
+
ref?: (instance: InstanceNode) => void
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* One instance of the enclosing `<InstancedMesh>` (addInstance as a
|
|
85
|
+
* component): a node placed under the nearest `<Group>` or `<Instance>`
|
|
86
|
+
* inside the mesh, with the transform, transition and pointer props of
|
|
87
|
+
* a `<Group>` plus `style`. Children (a `<Mesh>` headlight under a car
|
|
88
|
+
* instance) mount under it. Throws outside an `<InstancedMesh>`.
|
|
89
|
+
*/
|
|
90
|
+
export let Instance: ParentComponent<InstanceProps> = props => {
|
|
91
|
+
let ctx = useContext(SceneContext)
|
|
92
|
+
let mesh = useContext(InstancedMeshContext)
|
|
93
|
+
if (mesh === null) throw new Error("<Instance> must be inside an <InstancedMesh>")
|
|
94
|
+
let instance = addInstance(mesh, undefined, ctx.parent)
|
|
95
|
+
syncNode(instance, props)
|
|
96
|
+
createEffect(
|
|
97
|
+
() => props.style,
|
|
98
|
+
s => {
|
|
99
|
+
if (s !== undefined) setInstanceStyle(instance, s)
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
untrack(() => props.ref)?.(instance)
|
|
103
|
+
onCleanup(() => removeInstance(instance))
|
|
104
|
+
return provide(ctx, instance, props)
|
|
105
|
+
}
|