lecodes-cli 0.17.1 → 0.18.0
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 +1340 -648
- package/package.json +3 -3
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk/compile/bundler.ts +1 -1
- package/runtime/sdk/compile/compileProject.ts +16 -1
- package/runtime/sdk/compile/header.ts +6 -1
- package/runtime/sdk/compile/index.ts +16 -0
- package/runtime/sdk/compile/liteMaterial.ts +247 -0
- package/runtime/sdk/compile/shaderTargets.ts +42 -0
- package/runtime/sdk/core/Aspect.ts +255 -244
- package/runtime/sdk/g2/CharacterController2D.ts +2 -2
- package/runtime/sdk/gl/Camera.ts +41 -0
- package/runtime/sdk/gl/CameraPlace.ts +51 -0
- package/runtime/sdk/gl/CharacterController.ts +6 -2
- package/runtime/sdk/gl/IK.ts +193 -174
- package/runtime/sdk/gl/Material.ts +23 -0
- package/runtime/sdk/gl/Model.ts +7 -6
- package/runtime/sdk/gl/Node.ts +270 -285
- package/runtime/sdk/gl/Particles.ts +676 -625
- package/runtime/sdk/gl/Physics.ts +53 -1
- package/runtime/sdk/gl/Scene.ts +323 -291
- package/runtime/sdk/gl/Shape.ts +248 -8
- package/runtime/sdk/gl/Trigger.ts +50 -46
- package/runtime/sdk/gl/Vehicle.ts +519 -0
- package/runtime/sdk/gl/{AnimationClip.ts → animation/AnimationClip.ts} +37 -7
- package/runtime/sdk/gl/animation/Animator.ts +87 -0
- package/runtime/sdk/gl/animation/Layer.ts +29 -0
- package/runtime/sdk/gl/animation/Loop.ts +25 -0
- package/runtime/sdk/gl/animation/Playback.ts +43 -0
- package/runtime/sdk/gl/animation/core.ts +294 -0
- package/runtime/sdk/gl/scenarios.ts +26 -58
- package/runtime/sdk/inject.ts +18 -9
- package/runtime/sdk/runtime/app.ts +13 -0
- package/runtime/sdk/runtime/input.ts +169 -6
- package/runtime/sdk/scene/defineScene.ts +182 -26
- package/runtime/sdk/scene/gizmos.ts +128 -0
- package/runtime/sdk-types.json +1 -1
- package/runtime/sdk/gl/Animator.ts +0 -642
- package/runtime/sdk/gl/ModelAnimation.ts +0 -95
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// CameraPlace: "the camera as a place". A scene has ONE camera (`scene.camera`); a CameraPlace marks
|
|
2
|
+
// a node as somewhere that camera can be — with the lens it uses there. Nothing is created engine-
|
|
3
|
+
// side (no second camera, no ABI): it is a pose + a projection record on any node: an empty, an
|
|
4
|
+
// empty mounted on a bone (`mount:`), a node a path aspect moves (a dolly shot), a model node.
|
|
5
|
+
//
|
|
6
|
+
// • In a scene FILE that runs (`open()` / `load()`), the `active: true` place drives
|
|
7
|
+
// `scene.camera` (exactly `scene.camera.follow(node)`); exactly one per file.
|
|
8
|
+
// • Inside a prefab or an `instantiate()`d subtree a place is inert DATA — the hosting scene
|
|
9
|
+
// owns its camera. The host reads it (`inst.nodes.eye.get(CameraPlace)` → world pose + fov)
|
|
10
|
+
// or anchors on it (`inst.alignTo(inst.nodes.eye)`).
|
|
11
|
+
// • From code: `scene.camera.follow(dolly.get(CameraPlace))` — a cutscene shot; `follow(null)`
|
|
12
|
+
// releases.
|
|
13
|
+
// • Edit mode: draws a frustum gizmo (the editor's camera preview reads the same place).
|
|
14
|
+
// See docs/scene-camera-place-plan.md.
|
|
15
|
+
|
|
16
|
+
import { Aspect } from "../core/Aspect"
|
|
17
|
+
import type { FieldMeta } from "../core/fields"
|
|
18
|
+
import { Gizmos } from "../scene/gizmos"
|
|
19
|
+
import type { Camera } from "./Camera"
|
|
20
|
+
import type { Node } from "./Node"
|
|
21
|
+
|
|
22
|
+
export class CameraPlace extends Aspect<"cameraPlace", Node> {
|
|
23
|
+
static readonly aspect = "cameraPlace"
|
|
24
|
+
/** Vertical field of view in degrees (default 60) — smaller is a longer lens. */
|
|
25
|
+
fov = 60
|
|
26
|
+
/** Near clip distance. */
|
|
27
|
+
near = 0.01
|
|
28
|
+
/** Far clip distance = view range. */
|
|
29
|
+
far = 1000
|
|
30
|
+
/** The place that drives `scene.camera` when the file declaring it RUNS. Exactly one per file;
|
|
31
|
+
* ignored inside prefabs / instantiated subtrees (the host scene owns its camera). */
|
|
32
|
+
active = false
|
|
33
|
+
/** Aspect ratio of the frustum gizmo only (the real aspect is the viewport's). */
|
|
34
|
+
static fields: FieldMeta<CameraPlace> = {
|
|
35
|
+
fov: { label: "FOV", min: 1, max: 179, step: 1 },
|
|
36
|
+
near: { min: 0.001, max: 10, step: 0.01 },
|
|
37
|
+
far: { min: 1, max: 100000, step: 1 },
|
|
38
|
+
active: { label: "Active camera" },
|
|
39
|
+
}
|
|
40
|
+
static editor = { rebuild: true }
|
|
41
|
+
|
|
42
|
+
/** Copy this place's projection onto a camera (`camera.follow(place)` does it for you). */
|
|
43
|
+
applyTo(camera: Camera): void {
|
|
44
|
+
camera.setProjection({ fov: this.fov, near: this.near, far: this.far })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Edit mode: the frustum marker (−Z = view). Play mode never calls this. */
|
|
48
|
+
rebuild(): void {
|
|
49
|
+
Gizmos.frustum(this.node.worldMatrix, this.fov, 16 / 9, 0.6, { color: this.active ? "#ffffff" : "#9aa3b2" })
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
// const hero = new Mesh(capsuleGeometry(), material)
|
|
6
6
|
// .aspect(Shape, { capsule: { halfHeight: 0.6, radius: 0.3 } })
|
|
7
7
|
// .aspect(CharacterController, { speed: 6, jumpSpeed: 8 })
|
|
8
|
-
//
|
|
9
|
-
//
|
|
8
|
+
// setLoop(() => hero.controller.move((Input.key('KeyD') ? 1 : 0) - (Input.key('KeyA') ? 1 : 0),
|
|
9
|
+
// (Input.key('KeyS') ? 1 : 0) - (Input.key('KeyW') ? 1 : 0)))
|
|
10
|
+
// Input.on('keydown', e => { if (e.code === 'Space') hero.controller.jump() })
|
|
10
11
|
//
|
|
11
12
|
// You drive horizontal intent + jump; the controller integrates gravity and Jolt handles collision
|
|
12
13
|
// against the world. `grounded` reports whether it's on walkable ground. It runs in the EARLY update
|
|
@@ -49,6 +50,9 @@ export class CharacterController extends Aspect<"controller", Node> {
|
|
|
49
50
|
if (!shape) {
|
|
50
51
|
throw new Error("CharacterController requires a Shape aspect (a capsule is recommended) — add it first: node.aspect(Shape, { capsule: {…} }).aspect(CharacterController)")
|
|
51
52
|
}
|
|
53
|
+
if (shape._isTriangleMesh) {
|
|
54
|
+
throw new Error("CharacterController needs a convex Shape (capsule recommended) — { mesh: true } is a triangle mesh; use { mesh: 'convex' } or a capsule")
|
|
55
|
+
}
|
|
52
56
|
const shapeId = shape._claim() // the character owns the shape; it is not a rigid body
|
|
53
57
|
this._charId = _creator.characterCreate(this.node.id, shapeId, this.maxSlope)
|
|
54
58
|
}
|
package/runtime/sdk/gl/IK.ts
CHANGED
|
@@ -1,174 +1,193 @@
|
|
|
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
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
+
/** World rotation the END bone takes after the solve — a Node (its world rotation) or a Quat.
|
|
58
|
+
* Unset = the end bone keeps its animated local rotation (a hand on a grip, a foot on a slope
|
|
59
|
+
* want it set). Blended by `weight · rotationWeight`. */
|
|
60
|
+
rotation?: Node | Quat
|
|
61
|
+
/** 0–1 contribution of `rotation` (on top of `weight`). */
|
|
62
|
+
rotationWeight = 1
|
|
63
|
+
/** 0–1 contribution (blend in/out, e.g. foot planting only while grounded). */
|
|
64
|
+
weight = 1
|
|
65
|
+
/** Solve every frame (default). Set false to drive `solve()` yourself. */
|
|
66
|
+
enabled = true
|
|
67
|
+
|
|
68
|
+
update(): void {
|
|
69
|
+
if (this.enabled) this.solve()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One solve at the current pose. Safe to call manually (e.g. from a later-ordered aspect). */
|
|
73
|
+
solve(): void {
|
|
74
|
+
const t = targetPos(this.target)
|
|
75
|
+
if (!t || this.weight <= 0) return
|
|
76
|
+
const end = this.node
|
|
77
|
+
const mid = end.parent
|
|
78
|
+
const root = mid?.parent
|
|
79
|
+
if (!mid || !root) return
|
|
80
|
+
|
|
81
|
+
const w = Math.min(1, this.weight)
|
|
82
|
+
const midLocal0 = mid.quaternion
|
|
83
|
+
const rootLocal0 = root.quaternion
|
|
84
|
+
|
|
85
|
+
const a = root.worldPosition, b = mid.worldPosition, c = end.worldPosition
|
|
86
|
+
const l1 = a.distanceTo(b), l2 = b.distanceTo(c)
|
|
87
|
+
if (l1 < 1e-6 || l2 < 1e-6) return
|
|
88
|
+
const eps = 1e-4
|
|
89
|
+
const toT = t.sub(a)
|
|
90
|
+
const d = Math.min(Math.max(toT.length(), eps), l1 + l2 - eps)
|
|
91
|
+
|
|
92
|
+
// 1. bend the mid joint to the angle the target distance demands (law of cosines)
|
|
93
|
+
const ba = a.sub(b), bc = c.sub(b)
|
|
94
|
+
const cur = ba.angle(bc)
|
|
95
|
+
const want = Math.acos(Math.min(1, Math.max(-1, (l1 * l1 + l2 * l2 - d * d) / (2 * l1 * l2))))
|
|
96
|
+
// a positive rotation about ba × bc moves `bc` AWAY from `ba`, i.e. opens the joint — so rotating the
|
|
97
|
+
// mid bone by (want − cur) about it opens/closes exactly as needed. (bc × ba has the opposite sense:
|
|
98
|
+
// with it every already-bent limb — which is every animated limb — bends the wrong way; the straight
|
|
99
|
+
// case below picks its axis as ba × ref, the same sense as ba × bc for a limb barely bent toward ref.)
|
|
100
|
+
let axis = ba.cross(bc)
|
|
101
|
+
if (axis.lengthSq() < 1e-10) {
|
|
102
|
+
// straight limb: bend toward the pole (or any perpendicular)
|
|
103
|
+
const p = targetPos(this.pole)
|
|
104
|
+
const ref = p ? p.sub(b) : new Vec3(0, 0, 1)
|
|
105
|
+
axis = ba.cross(ref)
|
|
106
|
+
if (axis.lengthSq() < 1e-10) axis = ba.cross(new Vec3(0, 1, 0))
|
|
107
|
+
if (axis.lengthSq() < 1e-10) axis = ba.cross(new Vec3(1, 0, 0))
|
|
108
|
+
}
|
|
109
|
+
axis = axis.normalize()
|
|
110
|
+
// rotating mid by (want - cur) about `axis` opens/closes the angle between ba and bc
|
|
111
|
+
const midWorld = worldRot(mid)
|
|
112
|
+
setWorldRot(mid, Quat.fromAxisAngle(axis, want - cur).mul(midWorld))
|
|
113
|
+
|
|
114
|
+
// 2. swing the root so the end lands on the target direction
|
|
115
|
+
const c2 = end.worldPosition
|
|
116
|
+
const rootWorld = worldRot(root)
|
|
117
|
+
setWorldRot(root, Quat.fromTo(c2.sub(a), toT).mul(rootWorld))
|
|
118
|
+
|
|
119
|
+
// 3. pole: spin the root about the root→target axis so the mid joint lies toward the pole
|
|
120
|
+
const p = targetPos(this.pole)
|
|
121
|
+
if (p) {
|
|
122
|
+
const dir = toT.normalize()
|
|
123
|
+
const b2 = mid.worldPosition
|
|
124
|
+
const midProj = b2.sub(a).sub(dir.scale(b2.sub(a).dot(dir)))
|
|
125
|
+
const poleProj = p.sub(a).sub(dir.scale(p.sub(a).dot(dir)))
|
|
126
|
+
if (midProj.lengthSq() > 1e-10 && poleProj.lengthSq() > 1e-10) {
|
|
127
|
+
setWorldRot(root, Quat.fromTo(midProj, poleProj).mul(worldRot(root)))
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 4. weight: blend from the animated pose to the solved one
|
|
132
|
+
if (w < 1) {
|
|
133
|
+
root.quaternion = rootLocal0.slerp(root.quaternion, w)
|
|
134
|
+
mid.quaternion = midLocal0.slerp(mid.quaternion, w)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 5. end-bone rotation: take the target's world rotation (a hand stays on its grip, a foot
|
|
138
|
+
// flat on the slope) — the solve above only placed the end's ORIGIN
|
|
139
|
+
const r = this.rotation
|
|
140
|
+
if (r !== undefined && r !== null) {
|
|
141
|
+
const rw = w * Math.min(1, Math.max(0, this.rotationWeight))
|
|
142
|
+
if (rw > 0) {
|
|
143
|
+
const want = (r as Node).id !== undefined && typeof (r as Node).worldMatrix === "object" ? worldRot(r as Node) : (r as Quat)
|
|
144
|
+
const endLocal0 = end.quaternion
|
|
145
|
+
setWorldRot(end, want)
|
|
146
|
+
if (rw < 1) end.quaternion = endLocal0.slerp(end.quaternion, rw)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Aim a bone at a target (head / eyes / turret). Attach to the bone itself:
|
|
153
|
+
* `head.aspect(IK.LookAt, { target: camera, limit: 70 })`. `axis` is the bone's LOCAL forward
|
|
154
|
+
* (the direction that should point at the target) — rigs differ; default −Z, Mixamo heads look
|
|
155
|
+
* along +Z of the head bone in most exports, so pass `axis: [0, 0, 1]` there if it faces backwards. */
|
|
156
|
+
class LookAt extends Aspect<"lookAt", Node> {
|
|
157
|
+
static readonly aspect = "lookAt"
|
|
158
|
+
|
|
159
|
+
target?: Target
|
|
160
|
+
/** The bone's local axis that should point at the target. */
|
|
161
|
+
axis: Vec3Like = [ 0, 0, -1 ]
|
|
162
|
+
/** Max deflection from the animated direction, in degrees (default 80). */
|
|
163
|
+
limit = 80
|
|
164
|
+
/** 0–1 contribution. */
|
|
165
|
+
weight = 1
|
|
166
|
+
enabled = true
|
|
167
|
+
|
|
168
|
+
update(): void {
|
|
169
|
+
if (this.enabled) this.solve()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
solve(): void {
|
|
173
|
+
const t = targetPos(this.target)
|
|
174
|
+
if (!t || this.weight <= 0) return
|
|
175
|
+
const bone = this.node
|
|
176
|
+
const w = Math.min(1, this.weight)
|
|
177
|
+
const local0 = bone.quaternion
|
|
178
|
+
const from = bone.worldPosition
|
|
179
|
+
const dir = t.sub(from)
|
|
180
|
+
if (dir.lengthSq() < 1e-10) return
|
|
181
|
+
const rot = worldRot(bone)
|
|
182
|
+
const current = new Vec3(this.axis).rotate(rot)
|
|
183
|
+
let delta = Quat.fromTo(current, dir)
|
|
184
|
+
delta = clampAngle(delta, (this.limit * Math.PI) / 180)
|
|
185
|
+
setWorldRot(bone, delta.mul(rot))
|
|
186
|
+
if (w < 1) bone.quaternion = local0.slerp(bone.quaternion, w)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Inverse kinematics aspects — attach to bones (see file header). */
|
|
191
|
+
export const IK = { TwoBone, LookAt }
|
|
192
|
+
export type IKTwoBone = TwoBone
|
|
193
|
+
export type IKLookAt = LookAt
|
|
@@ -45,6 +45,15 @@ export type ParticlesMaterialOptions = {
|
|
|
45
45
|
sheet?: number | [number, number]
|
|
46
46
|
/** Extra brightness multiplier for a glow look (0 = off). */
|
|
47
47
|
emissive?: number
|
|
48
|
+
/** (`render` quad / stretch) a second sheet on the same grid, ADDED as `emissiveMap × emissiveColor`
|
|
49
|
+
* — keyed by `map`'s alpha, not tinted by the particle colour. A fireball = a smoke `map` whose
|
|
50
|
+
* colour curve goes dark + a fire `emissiveMap` that keeps glowing while alpha lasts. */
|
|
51
|
+
emissiveMap?: Texture | null
|
|
52
|
+
/** Multiplier for `emissiveMap`; an `[r, g, b]` array may exceed 1 (HDR glow). Default 0 (off). */
|
|
53
|
+
emissiveColor?: ColorInput
|
|
54
|
+
/** (`render` quad / stretch) crossfade each flipbook frame into the next by the fractional frame
|
|
55
|
+
* index instead of snapping — smooth sheet playback at any frame rate. Default false. */
|
|
56
|
+
frameBlend?: boolean
|
|
48
57
|
/** Compositing: `"alpha"` (default — smoke, dust) or `"add"` (fire, sparks, magic). */
|
|
49
58
|
blend?: "alpha" | "add"
|
|
50
59
|
/** Soft radial falloff toward the sprite edge. Defaults to true without a map, false with one. */
|
|
@@ -163,6 +172,9 @@ export class Material {
|
|
|
163
172
|
if (quad) {
|
|
164
173
|
m.uniforms.mode = options.render === "ribbon" ? 2 : options.render === "stretch" ? 1 : 0
|
|
165
174
|
m.uniforms.stretch = options.stretch ?? (options.render === "stretch" ? 0.05 : 0)
|
|
175
|
+
if (options.emissiveMap) m.uniforms.emissiveMap = options.emissiveMap
|
|
176
|
+
if (options.emissiveColor !== undefined) m.uniforms.emissiveColor = Color.toRgb01(options.emissiveColor)
|
|
177
|
+
if (options.frameBlend) m.uniforms.frameBlend = 1
|
|
166
178
|
}
|
|
167
179
|
if (options.map) m.map = options.map
|
|
168
180
|
return m
|
|
@@ -186,6 +198,17 @@ export class Material {
|
|
|
186
198
|
|
|
187
199
|
/** Load a custom compiled shader (.mat URL) as a material. */
|
|
188
200
|
static load(url: string): Promise<Material> {
|
|
201
|
+
// "id:<n>" — the artifact is a LOCAL file the host already resolved through fetchLocal (a
|
|
202
|
+
// compile with no asset server: the desktop host reads it from next to the bundle). There is
|
|
203
|
+
// nothing to fetch, so the material is ready synchronously; -1 means the host found no such
|
|
204
|
+
// file, which would otherwise reach createMaterial as a bad handle.
|
|
205
|
+
if (url.startsWith("id:")) {
|
|
206
|
+
const id = Number(url.slice(3))
|
|
207
|
+
if (!(id >= 0)) {
|
|
208
|
+
return Promise.reject(new Error(`Shader not found next to the app (${url}). Compile it with \`lecodes shaders compile\`.`))
|
|
209
|
+
}
|
|
210
|
+
return Promise.resolve(new Material({ _id: id } as any))
|
|
211
|
+
}
|
|
189
212
|
return fetch(url, { useOnce: true }).then((resp) => {
|
|
190
213
|
if (resp.status >= 400) return Promise.reject(new Error(`Failed to fetch shader from ${url}. HTTP ${resp.status}`))
|
|
191
214
|
return new Material(resp)
|
package/runtime/sdk/gl/Model.ts
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
// A loaded GLB model — its own node kind (a GLB is a node hierarchy with baked animation clips),
|
|
2
2
|
// distinct from Mesh (raw primitives, no animation). Animation is always present, reached as
|
|
3
|
-
// model.anim:
|
|
3
|
+
// model.anim (an Animator over the GLB's clips — crossfades, blend spaces, layers when you need them):
|
|
4
4
|
// const hero = await Model.load(asset('./hero.glb'))
|
|
5
|
-
// hero.anim.play('Run')
|
|
5
|
+
// hero.anim.play('Run', { loop: true })
|
|
6
6
|
|
|
7
7
|
import { fetch, type FetchResponse } from "../runtime/fetch"
|
|
8
8
|
import { Node } from "./Node"
|
|
9
|
-
import {
|
|
9
|
+
import { Animator } from "./animation/Animator"
|
|
10
10
|
|
|
11
11
|
export class Model extends Node {
|
|
12
|
-
/**
|
|
13
|
-
|
|
12
|
+
/** The model's Animator — always present, its clip table = the GLB's embedded clips. Configure
|
|
13
|
+
* more (external clips, blend spaces, layers) with `model.aspect(Animator, {...})`. */
|
|
14
|
+
declare readonly anim: Animator
|
|
14
15
|
|
|
15
16
|
constructor(internalId: number) {
|
|
16
17
|
super(internalId)
|
|
17
|
-
this.aspect(
|
|
18
|
+
this.aspect(Animator)
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
/** Duplicate this model — a deep copy of the GLB (meshes, skeleton, animation clips), attached to
|