@solidrt/3d 0.0.60 → 0.0.61
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 +436 -139
- package/examples/cascades.tsx +10 -4
- package/examples/pick.tsx +6 -4
- package/examples/sprites.tsx +7 -1
- package/package.json +4 -3
- package/src/components/context.tsx +13 -2
- package/src/components/first-person-camera.tsx +1 -1
- package/src/components/index.ts +2 -0
- package/src/components/instanced-mesh.tsx +25 -6
- package/src/components/lod.tsx +3 -1
- package/src/components/orbit-camera.tsx +105 -20
- package/src/components/shots.tsx +93 -0
- package/src/first-person.ts +48 -16
- package/src/gltf.ts +316 -62
- package/src/index.ts +8 -3
- package/src/input.ts +61 -17
- package/src/light.ts +25 -2
- package/src/material.ts +101 -25
- package/src/math.ts +11 -0
- package/src/mesh.ts +68 -21
- package/src/model-data.ts +27 -0
- package/src/model-file.ts +112 -37
- package/src/model.ts +90 -29
- package/src/node.ts +1 -1
- package/src/orbit.ts +526 -108
- package/src/scene-shadows.ts +4 -3
- package/src/scene.ts +201 -189
- package/src/shots.ts +63 -0
- package/src/skeleton.ts +39 -7
- package/tools/environment.ts +1 -0
- package/tools/model.ts +4 -3
- package/tools/node-env.d.ts +25 -0
- package/src/motion.ts +0 -17
- package/src/order.ts +0 -58
package/src/shots.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Shots and blends over the 3d cameras (okf/design/camera-controls.md):
|
|
2
|
+
// core's blender specialized to the scene's CameraState - an orbit or
|
|
3
|
+
// first-person control drives a shot's recording target instead of the
|
|
4
|
+
// scene, the blender owns the scene's setCamera, and a switch blends
|
|
5
|
+
// position, target, up, fov, near and far linearly; two orthographic
|
|
6
|
+
// extents blend too, a perspective-to-ortho switch cuts at the midpoint
|
|
7
|
+
// (there is no in-between projection).
|
|
8
|
+
|
|
9
|
+
import { createShotBlend } from "@solidrt/core/camera-control"
|
|
10
|
+
import type { ShotBlend, ShotBlendOptions, ShotTarget as RecordingTarget } from "@solidrt/core/camera-control"
|
|
11
|
+
import type { CameraState, CameraUpdate } from "./camera.ts"
|
|
12
|
+
import type { Vec3 } from "./math.ts"
|
|
13
|
+
|
|
14
|
+
let lerp = (a: number, b: number, t: number) => a + (b - a) * t
|
|
15
|
+
let lerp3 = (a: Vec3, b: Vec3, t: number): Vec3 => [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]
|
|
16
|
+
|
|
17
|
+
/** Interpolate two scene cameras (see the header). */
|
|
18
|
+
export let mixCamera = (a: CameraState, b: CameraState, t: number): CameraState => ({
|
|
19
|
+
position: lerp3(a.position, b.position, t),
|
|
20
|
+
target: lerp3(a.target, b.target, t),
|
|
21
|
+
up: lerp3(a.up, b.up, t),
|
|
22
|
+
fov: lerp(a.fov, b.fov, t),
|
|
23
|
+
near: lerp(a.near, b.near, t),
|
|
24
|
+
far: lerp(a.far, b.far, t),
|
|
25
|
+
ortho:
|
|
26
|
+
a.ortho !== null && b.ortho !== null
|
|
27
|
+
? { left: lerp(a.ortho.left, b.ortho.left, t), right: lerp(a.ortho.right, b.ortho.right, t), top: lerp(a.ortho.top, b.ortho.top, t), bottom: lerp(a.ortho.bottom, b.ortho.bottom, t) }
|
|
28
|
+
: t < 0.5
|
|
29
|
+
? a.ortho
|
|
30
|
+
: b.ortho,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
/** What shots drive: a Scene, or one of its Views. */
|
|
34
|
+
export type ShotsOwner = { setCamera(update: CameraUpdate): void; camera(): CameraState; size(): { width: number; height: number } }
|
|
35
|
+
|
|
36
|
+
/** A shot's target: the recording half plus the owner's size, so an
|
|
37
|
+
* orbit or first-person control drives it exactly like the scene. */
|
|
38
|
+
export type ShotTarget = RecordingTarget<CameraState> & { size(): { width: number; height: number } }
|
|
39
|
+
|
|
40
|
+
export type ShotsHandle = Omit<ShotBlend<CameraState>, "shot"> & { shot(name: string, opts?: { priority?: number }): ShotTarget }
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Shots over a scene or a view: `shot(name, { priority? })` returns the
|
|
44
|
+
* target a `createOrbitCamera` or `createFirstPersonCamera` drives (its
|
|
45
|
+
* `camera()` is the shot's own, its `size()` the owner's); `activate(
|
|
46
|
+
* name, { blend? })` makes it live when its priority wins, blending
|
|
47
|
+
* from the current output; `update(dt)` from a frame loop while
|
|
48
|
+
* `active()`. Every shot starts from the owner's camera at creation.
|
|
49
|
+
*/
|
|
50
|
+
export function createShots(owner: ShotsOwner, options: ShotBlendOptions = {}): ShotsHandle {
|
|
51
|
+
if (!owner || typeof owner.setCamera !== "function" || typeof owner.camera !== "function" || typeof owner.size !== "function") {
|
|
52
|
+
throw new Error("createShots: the owner needs setCamera(), camera() and size() (a Scene or a View)")
|
|
53
|
+
}
|
|
54
|
+
let initial = owner.camera()
|
|
55
|
+
let blend = createShotBlend<CameraState>(camera => owner.setCamera(camera), mixCamera, initial, options)
|
|
56
|
+
return {
|
|
57
|
+
...blend,
|
|
58
|
+
shot(name, opts) {
|
|
59
|
+
let rec = blend.shot(name, opts)
|
|
60
|
+
return { setCamera: rec.setCamera, camera: rec.camera, size: () => owner.size() }
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/skeleton.ts
CHANGED
|
@@ -24,8 +24,10 @@
|
|
|
24
24
|
|
|
25
25
|
import * as spatial from "flux:spatial"
|
|
26
26
|
import type { TextureId } from "@solidrt/core/gpu"
|
|
27
|
-
import { add, remove, setTransform } from "./node.ts"
|
|
27
|
+
import { add, destroy, remove, setTransform } from "./node.ts"
|
|
28
28
|
import type { SceneNode } from "./node.ts"
|
|
29
|
+
import { reparentInstance } from "./mesh.ts"
|
|
30
|
+
import type { InstancedMesh, InstanceNode } from "./mesh.ts"
|
|
29
31
|
import type { Model } from "./model.ts"
|
|
30
32
|
|
|
31
33
|
/** Inverse-bind entries of a piece and its body differ by exporter
|
|
@@ -41,9 +43,19 @@ export type WornPiece = {
|
|
|
41
43
|
boxes: { joint: SceneNode; box: Float32Array }[]
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
export type BindSkeletonOptions = {
|
|
47
|
+
/** Map a piece node's name to the body name to look up (still
|
|
48
|
+
* case-insensitive), for a pipeline whose joint names differ by a
|
|
49
|
+
* prefix or suffix: Mixamo's `mixamorig:`, a one-sided `_JNT`. Unity
|
|
50
|
+
* matches exact names and leaves the rest to the app; this is that
|
|
51
|
+
* hook. Default the identity. */
|
|
52
|
+
match?: (pieceName: string) => string
|
|
53
|
+
}
|
|
54
|
+
|
|
44
55
|
/**
|
|
45
56
|
* Drive `piece`'s skins from `body`'s skeleton: a wardrobe piece exported
|
|
46
|
-
* over the body's joints (matched by name, case-insensitive
|
|
57
|
+
* over the body's joints (matched by name, case-insensitive, through
|
|
58
|
+
* `match` when the names differ by a fixed prefix or suffix) with no
|
|
47
59
|
* clips of its own. After this the piece hangs under the body at the
|
|
48
60
|
* body's placement, its skinned parts follow every pose the body takes -
|
|
49
61
|
* mixer-driven or hand-posed - and its joints with no body counterpart
|
|
@@ -54,8 +66,9 @@ export type WornPiece = {
|
|
|
54
66
|
* rest pose). A piece is taken off by disposing it; disposing the body
|
|
55
67
|
* disposes what it wears.
|
|
56
68
|
*/
|
|
57
|
-
export function bindSkeleton(body: Model, piece: Model): void {
|
|
69
|
+
export function bindSkeleton(body: Model, piece: Model, opts: BindSkeletonOptions = {}): void {
|
|
58
70
|
if (piece === body) throw new Error("bindSkeleton: a model cannot wear itself")
|
|
71
|
+
if (opts.match !== undefined && typeof opts.match !== "function") throw new Error("bindSkeleton: match must be a function from a piece name to a body name")
|
|
59
72
|
if (piece._body !== null) throw new Error("bindSkeleton: the piece is already bound to a body")
|
|
60
73
|
if (body._body !== null) throw new Error("bindSkeleton: the body is itself a worn piece")
|
|
61
74
|
if (piece._skins.length === 0) throw new Error("bindSkeleton: the piece has no skin")
|
|
@@ -64,6 +77,13 @@ export function bindSkeleton(body: Model, piece: Model): void {
|
|
|
64
77
|
// re-bind under a different anchor (one anchor per palette texture),
|
|
65
78
|
// and the add() under the body re-enters everything below.
|
|
66
79
|
remove(piece)
|
|
80
|
+
// The piece's shared parts re-anchor on the BODY root: the piece sits at
|
|
81
|
+
// identity under it (below), so the mesh's world still equals the
|
|
82
|
+
// anchor's, and a copy grafted under a body joint stays inside the
|
|
83
|
+
// anchor's subtree. The records rebind at the re-enter.
|
|
84
|
+
for (let part of piece.parts) {
|
|
85
|
+
if (part.instances !== undefined) (part.mesh as InstancedMesh)._instances.anchor = body
|
|
86
|
+
}
|
|
67
87
|
|
|
68
88
|
let bodyByName = new Map<string, SceneNode>()
|
|
69
89
|
for (let n of body.nodes) {
|
|
@@ -74,12 +94,13 @@ export function bindSkeleton(body: Model, piece: Model): void {
|
|
|
74
94
|
for (let s of body._skins) s.joints.forEach((j, k) => bodyBind.set(j, s.inverseBind.subarray(k * 16, k * 16 + 16)))
|
|
75
95
|
let match = new Map<SceneNode, SceneNode>()
|
|
76
96
|
let names = new Map<SceneNode, string>()
|
|
97
|
+
let bodyName = opts.match ?? ((name: string) => name)
|
|
77
98
|
for (let n of piece.nodes) {
|
|
78
99
|
names.set(n.node, n.name)
|
|
79
|
-
let b = bodyByName.get(n.name.toLowerCase())
|
|
100
|
+
let b = bodyByName.get(bodyName(n.name).toLowerCase())
|
|
80
101
|
if (b !== undefined) match.set(n.node, b)
|
|
81
102
|
}
|
|
82
|
-
if (match.size === 0) throw new Error("bindSkeleton: no node of the piece is named like a node of the body")
|
|
103
|
+
if (match.size === 0) throw new Error("bindSkeleton: no node of the piece is named like a node of the body" + (opts.match ? " (through match)" : ""))
|
|
83
104
|
|
|
84
105
|
// Matched rows move onto the body's joints. Identical skins share one
|
|
85
106
|
// texture, so its rows move with the first skin naming it; the check
|
|
@@ -126,7 +147,12 @@ export function bindSkeleton(body: Model, piece: Model): void {
|
|
|
126
147
|
for (let [node, bodyNode] of match) {
|
|
127
148
|
for (let child of node.children.slice()) {
|
|
128
149
|
if (match.has(child)) continue
|
|
129
|
-
|
|
150
|
+
// A shared part's copy under a matched node (its placement node IS
|
|
151
|
+
// a joint) rides the body's joint like any rigid part: slot-bound,
|
|
152
|
+
// so it moves by reparentInstance rather than add, against the
|
|
153
|
+
// body-root anchor set above.
|
|
154
|
+
if (child.kind === "instance") reparentInstance(child as InstanceNode, bodyNode)
|
|
155
|
+
else add(bodyNode, child)
|
|
130
156
|
piece._grafts.push(child)
|
|
131
157
|
}
|
|
132
158
|
}
|
|
@@ -158,7 +184,13 @@ export function unbindSkeleton(piece: Model): void {
|
|
|
158
184
|
}
|
|
159
185
|
n.node._palettes = keep.length > 0 ? keep : null
|
|
160
186
|
}
|
|
161
|
-
|
|
187
|
+
// A grafted group detaches; a grafted instance is slot-bound and goes
|
|
188
|
+
// by destroy (the piece's populations are disposed right after, by the
|
|
189
|
+
// one caller, dispose).
|
|
190
|
+
for (let g of piece._grafts) {
|
|
191
|
+
if (g.kind === "instance") destroy(g)
|
|
192
|
+
else remove(g)
|
|
193
|
+
}
|
|
162
194
|
piece._grafts = []
|
|
163
195
|
body._worn.splice(body._worn.findIndex((w) => w.piece === piece), 1)
|
|
164
196
|
piece._body = null
|
package/tools/environment.ts
CHANGED
package/tools/model.ts
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
/// <reference path="./node-env.d.ts" />
|
|
2
3
|
|
|
3
4
|
// srt tool 3d/model: bake a glTF into a .srtm model file - the parse
|
|
4
5
|
// (src/gltf.ts) run once here under bun, the result written in the exact
|
|
5
6
|
// layout loadModel views without any per-vertex work at runtime. Same
|
|
6
7
|
// parser, same subset, same result as loadGltf; this only moves the cost
|
|
7
8
|
// to build time. Put the output under assets/ so it ships with the app.
|
|
9
|
+
// Everything here comes from the runtime-free `@solidrt/3d/model` entry
|
|
10
|
+
// (src/model-data.ts), the same surface an app's own bake script uses.
|
|
8
11
|
//
|
|
9
12
|
// srt tool 3d/model <in.gltf|in.glb> [-o <out.srtm>]
|
|
10
13
|
|
|
11
14
|
import { readFileSync, writeFileSync } from "node:fs"
|
|
12
15
|
import { basename, dirname, extname, join } from "node:path"
|
|
13
|
-
import { parseGltf } from "../src/
|
|
14
|
-
import { encodeModel } from "../src/model-file.ts"
|
|
15
|
-
import { layoutStride } from "../src/geometry.ts"
|
|
16
|
+
import { encodeModel, layoutStride, parseGltf } from "../src/model-data.ts"
|
|
16
17
|
|
|
17
18
|
function usage(error?: string): never {
|
|
18
19
|
if (error) console.error(error)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The node surface the tools use, declared here so `srt check
|
|
2
|
+
// packages/3d/tools/<name>.ts` typechecks them on the package's flux-typed
|
|
3
|
+
// program (the nearest tsconfig is the repo root's). The tools run under
|
|
4
|
+
// bun, but bun's own types cannot join this program: their globals
|
|
5
|
+
// (Response, fetch, process) collide with the flux standards the rest of
|
|
6
|
+
// the chain is typed against - the same split packages/cli keeps. Each
|
|
7
|
+
// tool references this file with a triple-slash directive; nothing else
|
|
8
|
+
// includes it, and a scaffolded app never roots a program at a tool.
|
|
9
|
+
|
|
10
|
+
declare module "node:fs" {
|
|
11
|
+
export function readFileSync(path: string): Uint8Array
|
|
12
|
+
export function writeFileSync(path: string, data: Uint8Array | string): void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
declare module "node:path" {
|
|
16
|
+
export function basename(path: string, ext?: string): string
|
|
17
|
+
export function dirname(path: string): string
|
|
18
|
+
export function extname(path: string): string
|
|
19
|
+
export function join(...parts: string[]): string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
declare const process: {
|
|
23
|
+
argv: string[]
|
|
24
|
+
exit(code?: number): never
|
|
25
|
+
}
|
package/src/motion.ts
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
// The ease every camera control's self-driven motion runs on: a glide to
|
|
2
|
-
// a commanded pose (glideTo, fit) and the damping of an unbracketed input
|
|
3
|
-
// delta (a wheel notch) both close the gap to a goal pose by the same
|
|
4
|
-
// exponential fraction per frame, so the two controls here and the 2d
|
|
5
|
-
// camera read the same. Frame-rate independent: the step is
|
|
6
|
-
// 1 - e^(-rate * dt), never a fixed fraction.
|
|
7
|
-
|
|
8
|
-
// E-foldings per second toward the goal: high enough that a wheel notch
|
|
9
|
-
// reads as one push, low enough to look smooth (the 2d camera's rate).
|
|
10
|
-
export const GLIDE_EASE = 9
|
|
11
|
-
// A motion lands - snaps to its goal and stops - once this fraction of
|
|
12
|
-
// its initial gap remains: under a pixel on any pose component a glide
|
|
13
|
-
// moves through at ordinary distances.
|
|
14
|
-
export const GLIDE_EPSILON = 0.001
|
|
15
|
-
|
|
16
|
-
/** The fraction of the remaining gap an ease at `rate` closes over dt. */
|
|
17
|
-
export let easeStep = (rate: number, dt: number): number => 1 - Math.exp(-rate * dt)
|
package/src/order.ts
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
// Draw-list ordering for a scene: a pure function of the live meshes and the
|
|
2
|
-
// camera's view matrix, with no GUI import, so the check rig
|
|
3
|
-
// (checks/order-check.ts) runs it headless on flux against a linear oracle.
|
|
4
|
-
// The scene calls it whenever the order is dirty and hands the result to
|
|
5
|
-
// setDrawOrder.
|
|
6
|
-
|
|
7
|
-
import type { Mat4, Vec3 } from "./math.ts"
|
|
8
|
-
|
|
9
|
-
/** The slice of a Mesh the sort reads (field names match Mesh so the
|
|
10
|
-
* scene passes its meshes straight through). */
|
|
11
|
-
export type Orderable<T> = {
|
|
12
|
-
_entry: T | null
|
|
13
|
-
_transparent: boolean
|
|
14
|
-
renderOrder: number
|
|
15
|
-
_center: Vec3
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Draw order: `first` (the background entry, if any), then opaque meshes by
|
|
20
|
-
* renderOrder with add order within a key, then transparent meshes by
|
|
21
|
-
* renderOrder then back-to-front by the view-space depth of the world-bounds
|
|
22
|
-
* center. The center, not the origin (Three's key), so geometry built
|
|
23
|
-
* off-origin sorts by where it is; and not the nearest bounds point, which
|
|
24
|
-
* would draw a large translucent ground plane over the small translucents
|
|
25
|
-
* resting on it. Per-mesh only: no per-triangle sort, no OIT. `entry`
|
|
26
|
-
* picks the id ordered for each mesh (default its own `_entry`; a view
|
|
27
|
-
* passes its per-mesh entries in its target).
|
|
28
|
-
*/
|
|
29
|
-
export function orderEntries<T>(
|
|
30
|
-
meshes: readonly Orderable<T>[],
|
|
31
|
-
view: Mat4,
|
|
32
|
-
first?: T,
|
|
33
|
-
entry: (m: Orderable<T>) => T | null = m => m._entry,
|
|
34
|
-
): T[] {
|
|
35
|
-
let opaque: Orderable<T>[] = []
|
|
36
|
-
let transparent: Orderable<T>[] = []
|
|
37
|
-
for (let m of meshes) {
|
|
38
|
-
if (entry(m) === null) continue
|
|
39
|
-
;(m._transparent ? transparent : opaque).push(m)
|
|
40
|
-
}
|
|
41
|
-
// Array sort is stable, so equal keys keep add order.
|
|
42
|
-
opaque.sort((a, b) => a.renderOrder - b.renderOrder)
|
|
43
|
-
if (transparent.length > 1) {
|
|
44
|
-
// The camera looks down -z in view space, so farther is more negative
|
|
45
|
-
// and ascending depth is back-to-front.
|
|
46
|
-
let depth = new Map<Orderable<T>, number>()
|
|
47
|
-
for (let m of transparent) {
|
|
48
|
-
let c = m._center
|
|
49
|
-
depth.set(m, view[2] * c[0] + view[6] * c[1] + view[10] * c[2] + view[14])
|
|
50
|
-
}
|
|
51
|
-
transparent.sort((a, b) => a.renderOrder - b.renderOrder || depth.get(a)! - depth.get(b)!)
|
|
52
|
-
}
|
|
53
|
-
let order: T[] = []
|
|
54
|
-
if (first !== undefined) order.push(first)
|
|
55
|
-
for (let m of opaque) order.push(entry(m)!)
|
|
56
|
-
for (let m of transparent) order.push(entry(m)!)
|
|
57
|
-
return order
|
|
58
|
-
}
|