lecodes-cli 0.16.2 → 0.17.1
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/dist/index.js +983 -320
- package/package.json +4 -3
- package/runtime/sdk/gl/Animator.ts +10 -2
- package/runtime/sdk/gl/IK.ts +174 -170
- package/runtime/sdk/gl/InstancedMesh.ts +132 -0
- package/runtime/sdk/gl/Node.ts +285 -267
- package/runtime/sdk/gl/Particles.ts +625 -546
- package/runtime/sdk/gl/Scene.ts +291 -289
- package/runtime/sdk/gl/state.ts +6 -6
- package/runtime/sdk/inject.ts +162 -160
- package/runtime/sdk-types.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lecodes-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.1",
|
|
4
4
|
"dependencies": {
|
|
5
5
|
"@letary/chisel": "^0.8.0",
|
|
6
6
|
"jimp": "^1.6.1"
|
|
@@ -11,11 +11,12 @@
|
|
|
11
11
|
"qrcode-terminal": "^0.12.0",
|
|
12
12
|
"lecodes-3d-editor": "workspace:*",
|
|
13
13
|
"lecodes-assets": "workspace:*",
|
|
14
|
-
"sdk": "workspace:*"
|
|
14
|
+
"sdk": "workspace:*",
|
|
15
|
+
"viewer-lite": "workspace:*"
|
|
15
16
|
},
|
|
16
17
|
"peerDependencies": {
|
|
17
18
|
"lecodes-design": "^0.8.1",
|
|
18
|
-
"lecodes-renderer": "^0.8.
|
|
19
|
+
"lecodes-renderer": "^0.8.6",
|
|
19
20
|
"lecodes-3d-editor": "^0.3.0",
|
|
20
21
|
"lecodes-assets": "^0.1.0"
|
|
21
22
|
},
|
|
@@ -394,7 +394,8 @@ export class Animator extends Aspect<"animator", Node> {
|
|
|
394
394
|
/** Default crossfade seconds for play()/stop(). */
|
|
395
395
|
fade = 0.2
|
|
396
396
|
/** Root motion: strip the root bone's horizontal travel from the pose and move the NODE with it
|
|
397
|
-
* instead (or drive its CharacterController
|
|
397
|
+
* instead (or drive its CharacterController — on this node or an ancestor — when it has one; the
|
|
398
|
+
* vertical part stays on the bone, so a jump's height is the clip's) — for clips whose hips actually travel
|
|
398
399
|
* (Mixamo without "In Place", lunges, rolls). `true` = auto-detect the root bone (the shallowest bone
|
|
399
400
|
* a clip translates), or a bone name. Read at attach. */
|
|
400
401
|
rootMotion?: boolean | string
|
|
@@ -528,8 +529,15 @@ export class Animator extends Aspect<"animator", Node> {
|
|
|
528
529
|
if (this.rootMotion) this._applyRootMotion(dt)
|
|
529
530
|
}
|
|
530
531
|
|
|
532
|
+
/** The CharacterController root motion feeds: on this node, or on the nearest ANCESTOR — the usual
|
|
533
|
+
* rig is a capsule node holding Shape + CharacterController with the model parented under it,
|
|
534
|
+
* shifted down so its feet sit at the capsule's bottom (the controller centers the shape on its node). */
|
|
531
535
|
private _controller(): CharacterController | undefined {
|
|
532
|
-
|
|
536
|
+
for (let n: Node | null = this.node; n; n = n.parent) {
|
|
537
|
+
const c = (n as unknown as { controller?: CharacterController }).controller
|
|
538
|
+
if (c) return c
|
|
539
|
+
}
|
|
540
|
+
return undefined
|
|
533
541
|
}
|
|
534
542
|
|
|
535
543
|
/** Root motion (last native frame's delta, node-local, horizontal): move the node, or feed the
|
package/runtime/sdk/gl/IK.ts
CHANGED
|
@@ -1,170 +1,174 @@
|
|
|
1
|
-
// IK — precise character actions as late-phase aspects on BONES (docs/animation-plan.md §2.5).
|
|
2
|
-
// They run after the animation wrote the pose (base anim or Animator) and before the skin is
|
|
3
|
-
// flushed, so their bone writes land in the skin. Attach to the END bone of the chain; the chain is
|
|
4
|
-
// walked up through parents — one instance per end effector, any Node hierarchy works.
|
|
5
|
-
//
|
|
6
|
-
// const foot = hero.bone('LeftFoot')!
|
|
7
|
-
// foot.aspect(IK.TwoBone, { target: footTarget, pole: kneeHint }) // upLeg → leg → foot
|
|
8
|
-
// foot.ik.weight = grounded ? 1 : 0 // blend in/out
|
|
9
|
-
// hero.bone('Head')!.aspect(IK.LookAt, { target: camera, limit: 70 }) // head tracks the camera
|
|
10
|
-
//
|
|
11
|
-
// Pure SDK/JS: a handful of world-matrix reads + two quaternion writes per solve. A native solver can
|
|
12
|
-
// slot in later behind the same API.
|
|
13
|
-
|
|
14
|
-
import { Aspect } from "../core/Aspect"
|
|
15
|
-
import { Vec3, type Vec3Like } from "../math/vec"
|
|
16
|
-
import { Quat } from "../math/quat"
|
|
17
|
-
import type { Node } from "./Node"
|
|
18
|
-
|
|
19
|
-
type Target = Node | Vec3Like
|
|
20
|
-
|
|
21
|
-
const targetPos = (t: Target | undefined): Vec3 | null => {
|
|
22
|
-
if (t === undefined || t === null) return null
|
|
23
|
-
const n = t as Node
|
|
24
|
-
if (typeof (n as { worldPosition?: unknown }).worldPosition === "object" && typeof n.id === "number") return n.worldPosition
|
|
25
|
-
return new Vec3(t as Vec3Like)
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** World rotation of a node (parent chain composed natively). */
|
|
29
|
-
const worldRot = (n: Node): Quat => n.worldMatrix.rotation
|
|
30
|
-
|
|
31
|
-
/** Write a WORLD rotation to a node by converting through the parent's world rotation. */
|
|
32
|
-
const setWorldRot = (n: Node, q: Quat): void => {
|
|
33
|
-
const p = n.parent
|
|
34
|
-
n.quaternion = p ? worldRot(p).invert().mul(q).normalize() : q.normalize()
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const clampAngle = (q: Quat, maxRad: number): Quat => {
|
|
38
|
-
// angle of a unit quaternion = 2·acos(|w|)
|
|
39
|
-
const w = Math.min(1, Math.abs(q.w))
|
|
40
|
-
const ang = 2 * Math.acos(w)
|
|
41
|
-
if (ang <= maxRad || ang < 1e-6) return q
|
|
42
|
-
const s = Math.sqrt(1 - w * w)
|
|
43
|
-
const axis = new Vec3(q.x / s, q.y / s, q.z / s)
|
|
44
|
-
return Quat.fromAxisAngle(axis, q.w < 0 ? -maxRad : maxRad)
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Two-bone analytic IK (limbs). Attach to the END bone: `foot.aspect(IK.TwoBone, { target })`
|
|
48
|
-
* solves upper (grandparent) + mid (parent) so the end reaches `target`; `pole` steers the bend
|
|
49
|
-
* (knee/elbow) — a Node or world point. */
|
|
50
|
-
class TwoBone extends Aspect<"ik", Node> {
|
|
51
|
-
static readonly aspect = "ik"
|
|
52
|
-
|
|
53
|
-
/** Where the end bone should be (Node or world position). */
|
|
54
|
-
target?: Target
|
|
55
|
-
/** Bend hint — the mid joint is pulled toward it (Node or world position). */
|
|
56
|
-
pole?: Target
|
|
57
|
-
/** 0–1 contribution (blend in/out, e.g. foot planting only while grounded). */
|
|
58
|
-
weight = 1
|
|
59
|
-
/** Solve every frame (default). Set false to drive `solve()` yourself. */
|
|
60
|
-
enabled = true
|
|
61
|
-
|
|
62
|
-
update(): void {
|
|
63
|
-
if (this.enabled) this.solve()
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** One solve at the current pose. Safe to call manually (e.g. from a later-ordered aspect). */
|
|
67
|
-
solve(): void {
|
|
68
|
-
const t = targetPos(this.target)
|
|
69
|
-
if (!t || this.weight <= 0) return
|
|
70
|
-
const end = this.node
|
|
71
|
-
const mid = end.parent
|
|
72
|
-
const root = mid?.parent
|
|
73
|
-
if (!mid || !root) return
|
|
74
|
-
|
|
75
|
-
const w = Math.min(1, this.weight)
|
|
76
|
-
const midLocal0 = mid.quaternion
|
|
77
|
-
const rootLocal0 = root.quaternion
|
|
78
|
-
|
|
79
|
-
const a = root.worldPosition, b = mid.worldPosition, c = end.worldPosition
|
|
80
|
-
const l1 = a.distanceTo(b), l2 = b.distanceTo(c)
|
|
81
|
-
if (l1 < 1e-6 || l2 < 1e-6) return
|
|
82
|
-
const eps = 1e-4
|
|
83
|
-
const toT = t.sub(a)
|
|
84
|
-
const d = Math.min(Math.max(toT.length(), eps), l1 + l2 - eps)
|
|
85
|
-
|
|
86
|
-
// 1. bend the mid joint to the angle the target distance demands (law of cosines)
|
|
87
|
-
const ba = a.sub(b), bc = c.sub(b)
|
|
88
|
-
const cur = ba.angle(bc)
|
|
89
|
-
const want = Math.acos(Math.min(1, Math.max(-1, (l1 * l1 + l2 * l2 - d * d) / (2 * l1 * l2))))
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
//
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
1
|
+
// IK — precise character actions as late-phase aspects on BONES (docs/animation-plan.md §2.5).
|
|
2
|
+
// They run after the animation wrote the pose (base anim or Animator) and before the skin is
|
|
3
|
+
// flushed, so their bone writes land in the skin. Attach to the END bone of the chain; the chain is
|
|
4
|
+
// walked up through parents — one instance per end effector, any Node hierarchy works.
|
|
5
|
+
//
|
|
6
|
+
// const foot = hero.bone('LeftFoot')!
|
|
7
|
+
// foot.aspect(IK.TwoBone, { target: footTarget, pole: kneeHint }) // upLeg → leg → foot
|
|
8
|
+
// foot.ik.weight = grounded ? 1 : 0 // blend in/out
|
|
9
|
+
// hero.bone('Head')!.aspect(IK.LookAt, { target: camera, limit: 70 }) // head tracks the camera
|
|
10
|
+
//
|
|
11
|
+
// Pure SDK/JS: a handful of world-matrix reads + two quaternion writes per solve. A native solver can
|
|
12
|
+
// slot in later behind the same API.
|
|
13
|
+
|
|
14
|
+
import { Aspect } from "../core/Aspect"
|
|
15
|
+
import { Vec3, type Vec3Like } from "../math/vec"
|
|
16
|
+
import { Quat } from "../math/quat"
|
|
17
|
+
import type { Node } from "./Node"
|
|
18
|
+
|
|
19
|
+
type Target = Node | Vec3Like
|
|
20
|
+
|
|
21
|
+
const targetPos = (t: Target | undefined): Vec3 | null => {
|
|
22
|
+
if (t === undefined || t === null) return null
|
|
23
|
+
const n = t as Node
|
|
24
|
+
if (typeof (n as { worldPosition?: unknown }).worldPosition === "object" && typeof n.id === "number") return n.worldPosition
|
|
25
|
+
return new Vec3(t as Vec3Like)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** World rotation of a node (parent chain composed natively). */
|
|
29
|
+
const worldRot = (n: Node): Quat => n.worldMatrix.rotation
|
|
30
|
+
|
|
31
|
+
/** Write a WORLD rotation to a node by converting through the parent's world rotation. */
|
|
32
|
+
const setWorldRot = (n: Node, q: Quat): void => {
|
|
33
|
+
const p = n.parent
|
|
34
|
+
n.quaternion = p ? worldRot(p).invert().mul(q).normalize() : q.normalize()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const clampAngle = (q: Quat, maxRad: number): Quat => {
|
|
38
|
+
// angle of a unit quaternion = 2·acos(|w|)
|
|
39
|
+
const w = Math.min(1, Math.abs(q.w))
|
|
40
|
+
const ang = 2 * Math.acos(w)
|
|
41
|
+
if (ang <= maxRad || ang < 1e-6) return q
|
|
42
|
+
const s = Math.sqrt(1 - w * w)
|
|
43
|
+
const axis = new Vec3(q.x / s, q.y / s, q.z / s)
|
|
44
|
+
return Quat.fromAxisAngle(axis, q.w < 0 ? -maxRad : maxRad)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Two-bone analytic IK (limbs). Attach to the END bone: `foot.aspect(IK.TwoBone, { target })`
|
|
48
|
+
* solves upper (grandparent) + mid (parent) so the end reaches `target`; `pole` steers the bend
|
|
49
|
+
* (knee/elbow) — a Node or world point. */
|
|
50
|
+
class TwoBone extends Aspect<"ik", Node> {
|
|
51
|
+
static readonly aspect = "ik"
|
|
52
|
+
|
|
53
|
+
/** Where the end bone should be (Node or world position). */
|
|
54
|
+
target?: Target
|
|
55
|
+
/** Bend hint — the mid joint is pulled toward it (Node or world position). */
|
|
56
|
+
pole?: Target
|
|
57
|
+
/** 0–1 contribution (blend in/out, e.g. foot planting only while grounded). */
|
|
58
|
+
weight = 1
|
|
59
|
+
/** Solve every frame (default). Set false to drive `solve()` yourself. */
|
|
60
|
+
enabled = true
|
|
61
|
+
|
|
62
|
+
update(): void {
|
|
63
|
+
if (this.enabled) this.solve()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One solve at the current pose. Safe to call manually (e.g. from a later-ordered aspect). */
|
|
67
|
+
solve(): void {
|
|
68
|
+
const t = targetPos(this.target)
|
|
69
|
+
if (!t || this.weight <= 0) return
|
|
70
|
+
const end = this.node
|
|
71
|
+
const mid = end.parent
|
|
72
|
+
const root = mid?.parent
|
|
73
|
+
if (!mid || !root) return
|
|
74
|
+
|
|
75
|
+
const w = Math.min(1, this.weight)
|
|
76
|
+
const midLocal0 = mid.quaternion
|
|
77
|
+
const rootLocal0 = root.quaternion
|
|
78
|
+
|
|
79
|
+
const a = root.worldPosition, b = mid.worldPosition, c = end.worldPosition
|
|
80
|
+
const l1 = a.distanceTo(b), l2 = b.distanceTo(c)
|
|
81
|
+
if (l1 < 1e-6 || l2 < 1e-6) return
|
|
82
|
+
const eps = 1e-4
|
|
83
|
+
const toT = t.sub(a)
|
|
84
|
+
const d = Math.min(Math.max(toT.length(), eps), l1 + l2 - eps)
|
|
85
|
+
|
|
86
|
+
// 1. bend the mid joint to the angle the target distance demands (law of cosines)
|
|
87
|
+
const ba = a.sub(b), bc = c.sub(b)
|
|
88
|
+
const cur = ba.angle(bc)
|
|
89
|
+
const want = Math.acos(Math.min(1, Math.max(-1, (l1 * l1 + l2 * l2 - d * d) / (2 * l1 * l2))))
|
|
90
|
+
// a positive rotation about ba × bc moves `bc` AWAY from `ba`, i.e. opens the joint — so rotating the
|
|
91
|
+
// mid bone by (want − cur) about it opens/closes exactly as needed. (bc × ba has the opposite sense:
|
|
92
|
+
// with it every already-bent limb — which is every animated limb — bends the wrong way; the straight
|
|
93
|
+
// case below picks its axis as ba × ref, the same sense as ba × bc for a limb barely bent toward ref.)
|
|
94
|
+
let axis = ba.cross(bc)
|
|
95
|
+
if (axis.lengthSq() < 1e-10) {
|
|
96
|
+
// straight limb: bend toward the pole (or any perpendicular)
|
|
97
|
+
const p = targetPos(this.pole)
|
|
98
|
+
const ref = p ? p.sub(b) : new Vec3(0, 0, 1)
|
|
99
|
+
axis = ba.cross(ref)
|
|
100
|
+
if (axis.lengthSq() < 1e-10) axis = ba.cross(new Vec3(0, 1, 0))
|
|
101
|
+
if (axis.lengthSq() < 1e-10) axis = ba.cross(new Vec3(1, 0, 0))
|
|
102
|
+
}
|
|
103
|
+
axis = axis.normalize()
|
|
104
|
+
// rotating mid by (want - cur) about `axis` opens/closes the angle between ba and bc
|
|
105
|
+
const midWorld = worldRot(mid)
|
|
106
|
+
setWorldRot(mid, Quat.fromAxisAngle(axis, want - cur).mul(midWorld))
|
|
107
|
+
|
|
108
|
+
// 2. swing the root so the end lands on the target direction
|
|
109
|
+
const c2 = end.worldPosition
|
|
110
|
+
const rootWorld = worldRot(root)
|
|
111
|
+
setWorldRot(root, Quat.fromTo(c2.sub(a), toT).mul(rootWorld))
|
|
112
|
+
|
|
113
|
+
// 3. pole: spin the root about the root→target axis so the mid joint lies toward the pole
|
|
114
|
+
const p = targetPos(this.pole)
|
|
115
|
+
if (p) {
|
|
116
|
+
const dir = toT.normalize()
|
|
117
|
+
const b2 = mid.worldPosition
|
|
118
|
+
const midProj = b2.sub(a).sub(dir.scale(b2.sub(a).dot(dir)))
|
|
119
|
+
const poleProj = p.sub(a).sub(dir.scale(p.sub(a).dot(dir)))
|
|
120
|
+
if (midProj.lengthSq() > 1e-10 && poleProj.lengthSq() > 1e-10) {
|
|
121
|
+
setWorldRot(root, Quat.fromTo(midProj, poleProj).mul(worldRot(root)))
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 4. weight: blend from the animated pose to the solved one
|
|
126
|
+
if (w < 1) {
|
|
127
|
+
root.quaternion = rootLocal0.slerp(root.quaternion, w)
|
|
128
|
+
mid.quaternion = midLocal0.slerp(mid.quaternion, w)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Aim a bone at a target (head / eyes / turret). Attach to the bone itself:
|
|
134
|
+
* `head.aspect(IK.LookAt, { target: camera, limit: 70 })`. `axis` is the bone's LOCAL forward
|
|
135
|
+
* (the direction that should point at the target) — rigs differ; default −Z, Mixamo heads look
|
|
136
|
+
* along +Z of the head bone in most exports, so pass `axis: [0, 0, 1]` there if it faces backwards. */
|
|
137
|
+
class LookAt extends Aspect<"lookAt", Node> {
|
|
138
|
+
static readonly aspect = "lookAt"
|
|
139
|
+
|
|
140
|
+
target?: Target
|
|
141
|
+
/** The bone's local axis that should point at the target. */
|
|
142
|
+
axis: Vec3Like = [ 0, 0, -1 ]
|
|
143
|
+
/** Max deflection from the animated direction, in degrees (default 80). */
|
|
144
|
+
limit = 80
|
|
145
|
+
/** 0–1 contribution. */
|
|
146
|
+
weight = 1
|
|
147
|
+
enabled = true
|
|
148
|
+
|
|
149
|
+
update(): void {
|
|
150
|
+
if (this.enabled) this.solve()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
solve(): void {
|
|
154
|
+
const t = targetPos(this.target)
|
|
155
|
+
if (!t || this.weight <= 0) return
|
|
156
|
+
const bone = this.node
|
|
157
|
+
const w = Math.min(1, this.weight)
|
|
158
|
+
const local0 = bone.quaternion
|
|
159
|
+
const from = bone.worldPosition
|
|
160
|
+
const dir = t.sub(from)
|
|
161
|
+
if (dir.lengthSq() < 1e-10) return
|
|
162
|
+
const rot = worldRot(bone)
|
|
163
|
+
const current = new Vec3(this.axis).rotate(rot)
|
|
164
|
+
let delta = Quat.fromTo(current, dir)
|
|
165
|
+
delta = clampAngle(delta, (this.limit * Math.PI) / 180)
|
|
166
|
+
setWorldRot(bone, delta.mul(rot))
|
|
167
|
+
if (w < 1) bone.quaternion = local0.slerp(bone.quaternion, w)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Inverse kinematics aspects — attach to bones (see file header). */
|
|
172
|
+
export const IK = { TwoBone, LookAt }
|
|
173
|
+
export type IKTwoBone = TwoBone
|
|
174
|
+
export type IKLookAt = LookAt
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// One geometry drawn many times — grass tufts, crates, debris, coins, a crowd of identical props —
|
|
2
|
+
// with a transform per instance and a handful of draw calls. Hardware instancing (Filament's
|
|
3
|
+
// InstanceBuffer): the engine applies each instance's local matrix, so any regular material works.
|
|
4
|
+
//
|
|
5
|
+
// Transforms live in a CPU-side Float32Array (16 floats per instance, column-major, local to this
|
|
6
|
+
// node). `setAt` / `hideAt` / `setMatrixAt` edit it and track the dirty range; `commit()` uploads
|
|
7
|
+
// that range in one bridge call — call it once per frame after your edits, not per instance.
|
|
8
|
+
// The native side splits the instances into chunks of the platform's per-draw cap (64 native,
|
|
9
|
+
// 8 on WebGL) on child entities, and keeps a bounding box per chunk so frustum culling stays on.
|
|
10
|
+
|
|
11
|
+
import { Mat4, type Mat4Like } from "../math/mat4"
|
|
12
|
+
import { Quat, type QuatLike } from "../math/quat"
|
|
13
|
+
import { cx, cy, cz, type Vec3Like } from "../math/vec"
|
|
14
|
+
import type { Geometry } from "./Geometry"
|
|
15
|
+
import { Material } from "./Material"
|
|
16
|
+
import type { MeshOptions } from "./Mesh"
|
|
17
|
+
import { Node } from "./Node"
|
|
18
|
+
|
|
19
|
+
/** One instance's placement, local to the InstancedMesh node. Omitted fields mean identity. */
|
|
20
|
+
export type InstanceTransform = {
|
|
21
|
+
position?: Vec3Like
|
|
22
|
+
/** Quaternion; wins over `eulerAngles`. */
|
|
23
|
+
rotation?: QuatLike
|
|
24
|
+
/** Degrees (YXZ), like `Node.eulerAngles`. */
|
|
25
|
+
eulerAngles?: Vec3Like
|
|
26
|
+
scale?: Vec3Like | number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEG = Math.PI / 180
|
|
30
|
+
|
|
31
|
+
export class InstancedMesh extends Node {
|
|
32
|
+
/** Instance capacity, fixed at construction. Unset instances are hidden. */
|
|
33
|
+
readonly count: number
|
|
34
|
+
private _geometry: Geometry
|
|
35
|
+
private _material: Material
|
|
36
|
+
private _data: Float32Array
|
|
37
|
+
private _lo = Infinity
|
|
38
|
+
private _hi = -1
|
|
39
|
+
|
|
40
|
+
constructor(geometry: Geometry, count: number, options: MeshOptions = {}) {
|
|
41
|
+
super()
|
|
42
|
+
if (!(count >= 1)) throw new Error("InstancedMesh: count must be ≥ 1")
|
|
43
|
+
this.count = Math.floor(count)
|
|
44
|
+
this._geometry = geometry
|
|
45
|
+
this._material = options.material ?? Material.unlit()
|
|
46
|
+
this._data = new Float32Array(16 * this.count)
|
|
47
|
+
_creator.createInstancedMesh(this.id, Material.idOf(this._material), geometry.vertices, geometry.normals, geometry.indices, geometry.uv, this.count)
|
|
48
|
+
if (options.position) this.position = options.position
|
|
49
|
+
if (options.eulerAngles) this.eulerAngles = options.eulerAngles
|
|
50
|
+
if (options.scale !== undefined) this.scale = typeof options.scale === "number" ? [ options.scale, options.scale, options.scale ] : options.scale
|
|
51
|
+
if (options.name !== undefined) this.name = options.name
|
|
52
|
+
if (options.castShadows !== undefined || options.receiveShadows !== undefined) {
|
|
53
|
+
_creator.setInstancedMeshShadows(this.id, options.castShadows ?? true, options.receiveShadows ?? true)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
override get geometry(): Geometry { return this._geometry }
|
|
58
|
+
|
|
59
|
+
get material(): Material { return this._material }
|
|
60
|
+
set material(m: Material) {
|
|
61
|
+
this._material = m
|
|
62
|
+
_creator.setInstancedMeshMaterial(this.id, Material.idOf(m))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Shadow flags for all instances (both at once — the engine sets them per draw). */
|
|
66
|
+
setShadows(cast: boolean, receive = cast): this {
|
|
67
|
+
_creator.setInstancedMeshShadows(this.id, cast, receive)
|
|
68
|
+
return this
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private _touch(i: number): void {
|
|
72
|
+
if (i < this._lo) this._lo = i
|
|
73
|
+
if (i > this._hi) this._hi = i
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Place instance `i`. Pending until `commit()`. */
|
|
77
|
+
setAt(i: number, t: InstanceTransform): this {
|
|
78
|
+
if (i < 0 || i >= this.count) throw new RangeError(`InstancedMesh: index ${i} out of 0..${this.count - 1}`)
|
|
79
|
+
const rot = t.rotation ?? (t.eulerAngles
|
|
80
|
+
? Quat.fromEuler(cx(t.eulerAngles) * DEG, cy(t.eulerAngles) * DEG, cz(t.eulerAngles) * DEG)
|
|
81
|
+
: Quat.identity)
|
|
82
|
+
const m = Mat4.compose(t.position ?? [ 0, 0, 0 ], rot, t.scale ?? 1).toArray()
|
|
83
|
+
this._data.set(m, i * 16)
|
|
84
|
+
this._touch(i)
|
|
85
|
+
return this
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Place instance `i` from a 4×4 column-major matrix (local to this node). */
|
|
89
|
+
setMatrixAt(i: number, m: Mat4Like): this {
|
|
90
|
+
if (i < 0 || i >= this.count) throw new RangeError(`InstancedMesh: index ${i} out of 0..${this.count - 1}`)
|
|
91
|
+
const a = m instanceof Mat4 ? m.toArray() : m
|
|
92
|
+
for (let k = 0; k < 16; k++) this._data[i * 16 + k] = a[k]
|
|
93
|
+
this._touch(i)
|
|
94
|
+
return this
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Hide instance `i` (a zero matrix — no geometry is emitted for it). */
|
|
98
|
+
hideAt(i: number): this {
|
|
99
|
+
if (i < 0 || i >= this.count) throw new RangeError(`InstancedMesh: index ${i} out of 0..${this.count - 1}`)
|
|
100
|
+
this._data.fill(0, i * 16, i * 16 + 16)
|
|
101
|
+
this._touch(i)
|
|
102
|
+
return this
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Bulk write: `matrices` holds 16 floats per instance, starting at instance `offset`. */
|
|
106
|
+
setMatrices(matrices: ArrayLike<number>, offset = 0): this {
|
|
107
|
+
const n = Math.min(Math.floor(matrices.length / 16), this.count - offset)
|
|
108
|
+
if (n <= 0) return this
|
|
109
|
+
this._data.set(Array.prototype.slice.call(matrices, 0, n * 16), offset * 16)
|
|
110
|
+
this._touch(offset)
|
|
111
|
+
this._touch(offset + n - 1)
|
|
112
|
+
return this
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The 4×4 of instance `i` as stored (a copy). */
|
|
116
|
+
matrixAt(i: number): Mat4 {
|
|
117
|
+
return Mat4.from(this._data.subarray(i * 16, i * 16 + 16))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Upload every change since the last commit (one bridge call covering the dirty range). */
|
|
121
|
+
commit(): this {
|
|
122
|
+
if (this._hi < this._lo) return this
|
|
123
|
+
const lo = this._lo, hi = this._hi
|
|
124
|
+
this._lo = Infinity
|
|
125
|
+
this._hi = -1
|
|
126
|
+
_creator.setInstanceTransforms(this.id, this._data.subarray(lo * 16, (hi + 1) * 16), lo)
|
|
127
|
+
return this
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Whether edits are waiting for `commit()`. */
|
|
131
|
+
get dirty(): boolean { return this._hi >= this._lo }
|
|
132
|
+
}
|