lecodes-cli 0.17.2 → 0.18.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/README.md +1 -1
- package/dist/index.js +2376 -755
- package/package.json +4 -4
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk/compile/aspectMacro.ts +52 -8
- package/runtime/sdk/compile/assetMacro.ts +116 -15
- package/runtime/sdk/compile/bundler.ts +39 -4
- package/runtime/sdk/compile/compileProject.ts +16 -1
- package/runtime/sdk/compile/header.ts +6 -1
- package/runtime/sdk/compile/index.ts +31 -0
- package/runtime/sdk/compile/liteMaterial.ts +247 -0
- package/runtime/sdk/compile/sceneEditor.ts +11 -1
- package/runtime/sdk/compile/shaderSchema.ts +202 -0
- package/runtime/sdk/compile/shaderTargets.ts +81 -0
- package/runtime/sdk/core/Aspect.ts +363 -95
- package/runtime/sdk/core/compWrite.ts +42 -0
- package/runtime/sdk/core/fields.ts +1 -1
- package/runtime/sdk/core/time.ts +81 -0
- package/runtime/sdk/g2/Camera2D.ts +8 -1
- package/runtime/sdk/g2/CharacterController2D.ts +253 -53
- package/runtime/sdk/g2/Node2D.ts +80 -10
- package/runtime/sdk/g2/OneWay2D.ts +66 -0
- package/runtime/sdk/g2/Physics2D.ts +240 -30
- package/runtime/sdk/g2/Scene2D.ts +33 -1
- package/runtime/sdk/g2/Shape2D.ts +218 -22
- package/runtime/sdk/g2/Trigger2D.ts +42 -12
- package/runtime/sdk/g2/groups2d.ts +106 -0
- package/runtime/sdk/g2/loop.ts +15 -4
- package/runtime/sdk/gl/Camera.ts +41 -0
- package/runtime/sdk/gl/CameraPlace.ts +52 -0
- package/runtime/sdk/gl/CharacterController.ts +184 -55
- package/runtime/sdk/gl/Gearbox.ts +212 -0
- package/runtime/sdk/gl/Geometry.ts +70 -9
- package/runtime/sdk/gl/IK.ts +193 -174
- package/runtime/sdk/gl/Light.ts +64 -2
- package/runtime/sdk/gl/Lightmap.ts +179 -0
- package/runtime/sdk/gl/Material.ts +36 -0
- package/runtime/sdk/gl/Mesh.ts +6 -23
- package/runtime/sdk/gl/Model.ts +23 -8
- package/runtime/sdk/gl/Node.ts +350 -285
- package/runtime/sdk/gl/Physics.ts +222 -126
- package/runtime/sdk/gl/Scene.ts +175 -8
- package/runtime/sdk/gl/Shape.ts +255 -12
- package/runtime/sdk/gl/Trigger.ts +1 -6
- package/runtime/sdk/gl/Vehicle.ts +473 -0
- package/runtime/sdk/gl/Wheel.ts +240 -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 +291 -349
- package/runtime/sdk/inject.ts +186 -162
- package/runtime/sdk/runtime/app.ts +13 -0
- package/runtime/sdk/runtime/input.ts +169 -6
- package/runtime/sdk/scene/defineScene.ts +1227 -1016
- package/runtime/sdk/scene/gizmos.ts +148 -0
- package/runtime/sdk/scene/material.ts +188 -0
- package/runtime/sdk-types.json +1 -1
- package/runtime/sdk/gl/Animator.ts +0 -642
- package/runtime/sdk/gl/ModelAnimation.ts +0 -95
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
|
package/runtime/sdk/gl/Light.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// Lights.
|
|
1
|
+
// Lights. A directional sun (the engine's primary light) and punctual point lights. Add one to a
|
|
2
|
+
// scene like any node; `intensity` and `color` stay writable so a light can be animated.
|
|
2
3
|
|
|
3
4
|
import { Color, type ColorInput } from "../core/color"
|
|
4
5
|
import type { Vec3Like } from "../math/vec"
|
|
@@ -21,17 +22,78 @@ export type SunOptions = {
|
|
|
21
22
|
shadowsQuality?: 0 | 1 | 2 | 3
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
export type PointOptions = {
|
|
26
|
+
/**
|
|
27
|
+
* Luminous POWER in lumens — the same physical scale as the sun's lux and `environmentIntensity`,
|
|
28
|
+
* so a light keeps its look when the scene's exposure changes. A candle is ~12 lm, a bare 60 W
|
|
29
|
+
* bulb ~800, a car headlight ~1500, a fireball millions. Default 1000.
|
|
30
|
+
*/
|
|
31
|
+
intensity?: number
|
|
32
|
+
color?: ColorInput
|
|
33
|
+
/**
|
|
34
|
+
* Metres of influence — past it the light contributes nothing. This is the performance knob:
|
|
35
|
+
* overlapping point lights are the expensive case, so keep it as small as the look allows.
|
|
36
|
+
* Default 10.
|
|
37
|
+
*/
|
|
38
|
+
range?: number
|
|
39
|
+
/** Point-light shadows are a cubemap render per light; off by default. */
|
|
40
|
+
castShadows?: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
24
43
|
export class Light extends Node {
|
|
44
|
+
private _intensity = 0
|
|
45
|
+
/** @internal sun direction as created (the engine keeps it; Lightmap.load reads it back). */
|
|
46
|
+
_direction: [number, number, number] = [ 0, -1, 0 ]
|
|
47
|
+
/** The most recently created sun — what `Lightmap.load` uses unless told otherwise. */
|
|
48
|
+
static lastSun: Light | null = null
|
|
49
|
+
|
|
25
50
|
/** A directional sun light. */
|
|
26
51
|
static sun(options: SunOptions = {}): Light {
|
|
27
52
|
const light = new Light()
|
|
28
53
|
const [ dx, dy, dz ] = options.direction ?? [ 0.548267, -0.473983, -0.689016 ]
|
|
54
|
+
light._direction = [ dx, dy, dz ]
|
|
55
|
+
light._intensity = options.intensity ?? 100000
|
|
56
|
+
Light.lastSun = light
|
|
29
57
|
_creator.createSunLight(
|
|
30
58
|
light.id, dx, dy, dz,
|
|
31
|
-
|
|
59
|
+
light._intensity,
|
|
32
60
|
Color.toPackedRgb(options.color ?? 0xffffff),
|
|
33
61
|
options.shadowsQuality ?? 1,
|
|
34
62
|
)
|
|
35
63
|
return light
|
|
36
64
|
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A point light — a lamp, a muzzle flash, a fireball. Position it like any node.
|
|
68
|
+
*
|
|
69
|
+
* Feature-detected: hosts that predate it create the node and light nothing, which keeps a scene
|
|
70
|
+
* that adds atmosphere on top of its sun renderable everywhere. Check `Light.supportsPoint`
|
|
71
|
+
* before making one carry the scene.
|
|
72
|
+
*/
|
|
73
|
+
static point(options: PointOptions = {}): Light {
|
|
74
|
+
const light = new Light()
|
|
75
|
+
light._intensity = options.intensity ?? 1000
|
|
76
|
+
_creator.createPointLight?.(
|
|
77
|
+
light.id,
|
|
78
|
+
light._intensity,
|
|
79
|
+
Color.toPackedRgb(options.color ?? 0xffffff),
|
|
80
|
+
options.range ?? 10,
|
|
81
|
+
options.castShadows ?? false,
|
|
82
|
+
)
|
|
83
|
+
return light
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Whether this host can create point lights at all. */
|
|
87
|
+
static get supportsPoint(): boolean { return _creator.createPointLight !== undefined }
|
|
88
|
+
|
|
89
|
+
/** Live intensity (sun: lux, point: lumens) — animate a flash without rebuilding the light. */
|
|
90
|
+
get intensity(): number { return this._intensity }
|
|
91
|
+
set intensity(value: number) {
|
|
92
|
+
this._intensity = value
|
|
93
|
+
_creator.setLightIntensity?.(this.id, value)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
set color(value: ColorInput) {
|
|
97
|
+
_creator.setLightColor?.(this.id, Color.toPackedRgb(value))
|
|
98
|
+
}
|
|
37
99
|
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Baked shadows for static 3D geometry (docs/lightmap-plan.md). A scene FILE does all of this by
|
|
2
|
+
// itself: `env: { lightmap: { data, texture } }` marks the level as baked, every model/mesh node
|
|
3
|
+
// that no Physics aspect moves is a static (keys = node paths, `lightmap: true|false` overrides),
|
|
4
|
+
// and the runtime applies the bake when the level finishes loading (scene/defineScene.ts).
|
|
5
|
+
// Hand-built scenes register what never moves with `Lightmap.add`, bake once with
|
|
6
|
+
// `lecodes lightmap bake` (the desktop host traces the live scene and writes lightmap.ktx2 +
|
|
7
|
+
// lightmap.bake), and `Lightmap.load` applies the atlas at runtime:
|
|
8
|
+
//
|
|
9
|
+
// const crate = await Model.load(asset('./assets/props/crate.glb'), { lightmap: true })
|
|
10
|
+
// Lightmap.add(holder) // a Model, a Mesh, or a node holding them
|
|
11
|
+
// Lightmap.add(ground) // Mesh.plane works too
|
|
12
|
+
// ...
|
|
13
|
+
// await Lightmap.load(scene, { data: asset('./assets/lightmap/lightmap.bake'),
|
|
14
|
+
// texture: asset('./assets/lightmap/lightmap.ktx2') })
|
|
15
|
+
//
|
|
16
|
+
// Both files are asset() handles because the compiler stages exactly the files it sees (the data
|
|
17
|
+
// file is JSON with a .bake extension — a .json would be scanned as a code module). `load` is a
|
|
18
|
+
// no-op when the bake is missing or the host predates the feature, so the project stays runnable —
|
|
19
|
+
// and in BAKE mode (`lecodes lightmap bake`) it is where the bake fires: the same call marks "the
|
|
20
|
+
// scene is complete". Atlas channels: R = sun visibility, G = ambient occlusion; the shader keeps
|
|
21
|
+
// the real-time sun for dynamic casters and multiplies the baked mask in with an ambient floor.
|
|
22
|
+
|
|
23
|
+
import { fetch } from "../runtime/fetch"
|
|
24
|
+
import { Texture } from "./Texture"
|
|
25
|
+
import { Material } from "./Material"
|
|
26
|
+
import { Mesh } from "./Mesh"
|
|
27
|
+
import { Model } from "./Model"
|
|
28
|
+
import { Node } from "./Node"
|
|
29
|
+
import type { Scene } from "./Scene"
|
|
30
|
+
|
|
31
|
+
export type LightmapLoadOptions = {
|
|
32
|
+
/** 1 = baked sun shadows at full strength, 0 = ambient occlusion only. Default 1. */
|
|
33
|
+
sunStrength?: number
|
|
34
|
+
/** Multiplier on the ambient (IBL) share in the shadow math — how bright a fully shadowed texel
|
|
35
|
+
* stays. 1 reproduces filament's own real-time shadow darkness (the shader reads the scene's sun
|
|
36
|
+
* and IBL from filament's per-frame uniforms); raise it for lighter shadows, lower for deeper. Default 1. */
|
|
37
|
+
ambientScale?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type LightmapInfo = {
|
|
41
|
+
size: number
|
|
42
|
+
texel: number
|
|
43
|
+
/** keys applied / keys in the file / registered statics without a rect */
|
|
44
|
+
applied: number
|
|
45
|
+
total: number
|
|
46
|
+
missing: string[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type Entry = { key: string, model?: Model, mesh?: Mesh }
|
|
50
|
+
|
|
51
|
+
type BakeConfig = {
|
|
52
|
+
outDir: string, size?: number, texel?: number, sunRays?: number, aoRays?: number,
|
|
53
|
+
aoDistance?: number, bias?: number, sunAngle?: number,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const bakeConfig = (): BakeConfig | null => {
|
|
57
|
+
const g = (globalThis as unknown as { __lecodesLightmap?: BakeConfig }).__lecodesLightmap
|
|
58
|
+
return g && typeof g.outDir === "string" ? g : null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A hex colour as the 9-char form the float4 uniform path expects ("#rrggbbaa"). */
|
|
62
|
+
const hex8 = (c: unknown): string => {
|
|
63
|
+
if (typeof c !== "string") return "#ffffffff"
|
|
64
|
+
if (c.length === 7) return c + "ff"
|
|
65
|
+
if (c.length === 4) return "#" + c[1] + c[1] + c[2] + c[2] + c[3] + c[3] + "ff"
|
|
66
|
+
return c
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class Lightmap {
|
|
70
|
+
/** True while `lecodes lightmap bake` runs the app — skip menus and build the scene straight away. */
|
|
71
|
+
static get baking(): boolean { return bakeConfig() !== null }
|
|
72
|
+
|
|
73
|
+
private static entries: Entry[] = []
|
|
74
|
+
private static ordinals = new Map<string, number>()
|
|
75
|
+
private static warned = false
|
|
76
|
+
|
|
77
|
+
/** Register static geometry — a Model, a Mesh, or any node whose subtree holds them. Statics are
|
|
78
|
+
* both receivers and occluders in the bake. `key` names the entry in lightmap.bake (default:
|
|
79
|
+
* the node's name + a running number, `container#3`); pass one when names are not stable. */
|
|
80
|
+
static add(node: Node, key?: string): Node {
|
|
81
|
+
const base = key ?? `${node.name || "node"}#${Lightmap.next(node.name || "node")}`
|
|
82
|
+
const found: Entry[] = []
|
|
83
|
+
const walk = (n: Node): void => {
|
|
84
|
+
if (n instanceof Model) { found.push({ key: base, model: n }); return }
|
|
85
|
+
if (n instanceof Mesh) { found.push({ key: base, mesh: n }); return }
|
|
86
|
+
for (const c of n.children) walk(c)
|
|
87
|
+
}
|
|
88
|
+
walk(node)
|
|
89
|
+
if (found.length === 0) { console.warn(`Lightmap.add: ${base} holds no Model or Mesh`); return node }
|
|
90
|
+
if (found.length > 1) found.forEach((e, i) => { e.key = `${base}/${i}` })
|
|
91
|
+
Lightmap.entries.push(...found)
|
|
92
|
+
return node
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @internal Scene-file runtime: one model/mesh node under its path key (no subtree walk). */
|
|
96
|
+
static _register(node: Model | Mesh, key: string): void {
|
|
97
|
+
Lightmap.entries.push(node instanceof Model ? { key, model: node } : { key, mesh: node })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private static next(name: string): number {
|
|
101
|
+
const n = Lightmap.ordinals.get(name) ?? 0
|
|
102
|
+
Lightmap.ordinals.set(name, n + 1)
|
|
103
|
+
return n
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Forget every registration (a scene rebuild). */
|
|
107
|
+
static clear(): void {
|
|
108
|
+
Lightmap.entries = []
|
|
109
|
+
Lightmap.ordinals.clear()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Apply a bake — or, under `lecodes lightmap bake`, run it. Resolves to null when nothing was
|
|
113
|
+
* applied (no bake yet, a host without the feature, bake mode). */
|
|
114
|
+
static async load(scene: Scene, files: { data: string, texture: string }, options: LightmapLoadOptions = {}): Promise<LightmapInfo | null> {
|
|
115
|
+
const bake = bakeConfig()
|
|
116
|
+
if (bake) { Lightmap.bake(bake); return null }
|
|
117
|
+
if (!_creator.lightmapApply) {
|
|
118
|
+
if (!Lightmap.warned) { Lightmap.warned = true; console.warn("Lightmap: this host has no lightmap support — rendering without the bake") }
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
let data: { size: number, texel: number, sun?: number[] | null, instances: { key: string, receiver: boolean, st?: number[] }[] }
|
|
122
|
+
try {
|
|
123
|
+
const resp = await fetch(files.data, { useOnce: true })
|
|
124
|
+
if (resp.status >= 400) { console.warn(`Lightmap: no bake at ${files.data} (HTTP ${resp.status}) — run \`lecodes lightmap bake\``); return null }
|
|
125
|
+
data = resp.json()
|
|
126
|
+
resp.dispose()
|
|
127
|
+
} catch (e) {
|
|
128
|
+
console.warn(`Lightmap: could not read ${files.data}: ${String(e)}`)
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
const texture = await Texture.load(files.texture)
|
|
132
|
+
const ambientScale = options.ambientScale ?? 1
|
|
133
|
+
const strength = options.sunStrength ?? 1
|
|
134
|
+
const rects = new Map<string, number[]>()
|
|
135
|
+
for (const inst of data.instances) if (inst.receiver && inst.st) rects.set(inst.key, inst.st)
|
|
136
|
+
let applied = 0
|
|
137
|
+
const missing: string[] = []
|
|
138
|
+
for (const e of Lightmap.entries) {
|
|
139
|
+
const st = rects.get(e.key)
|
|
140
|
+
if (!st) { missing.push(e.key); continue }
|
|
141
|
+
if (e.model) {
|
|
142
|
+
const n = _creator.lightmapApply(e.model.id, texture._id, st[0], st[1], st[2], st[3], ambientScale, strength)
|
|
143
|
+
if (n === 0) { console.warn(`Lightmap: ${e.key} was not loaded with { lightmap: true } — skipped`); continue }
|
|
144
|
+
_creator.setGlbShadows?.(e.model.id, false, true)
|
|
145
|
+
} else if (e.mesh) {
|
|
146
|
+
Lightmap.swapMeshMaterial(e.mesh, texture, st, ambientScale, strength)
|
|
147
|
+
e.mesh.castShadows = false
|
|
148
|
+
}
|
|
149
|
+
applied++
|
|
150
|
+
}
|
|
151
|
+
if (missing.length) console.warn(`Lightmap: ${missing.length} static(s) have no rect in the bake (${missing.slice(0, 5).join(", ")}${missing.length > 5 ? "…" : ""}) — rebake`)
|
|
152
|
+
return { size: data.size, texel: data.texel, applied, total: rects.size, missing }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A Mesh keeps its look (colour / map / roughness / metallic) but moves to the lightmap material. */
|
|
156
|
+
private static swapMeshMaterial(mesh: Mesh, texture: Texture, st: number[], ambientScale: number, strength: number): void {
|
|
157
|
+
const src = mesh.material
|
|
158
|
+
const u = src?.uniforms ?? {}
|
|
159
|
+
const m = Material.lightmap()
|
|
160
|
+
m.set("baseColorFactor", hex8(u.baseColor))
|
|
161
|
+
if (u.baseColorMap instanceof Texture) m.set("baseColorMap", u.baseColorMap)
|
|
162
|
+
if (typeof u.roughness === "number") m.set("roughnessFactor", u.roughness)
|
|
163
|
+
if (typeof u.metallic === "number") m.set("metallicFactor", u.metallic)
|
|
164
|
+
m.set("lightmap", texture)
|
|
165
|
+
m.set("lightmapST", [ st[0], st[1], st[2], st[3] ])
|
|
166
|
+
m.set("ambientScale", ambientScale)
|
|
167
|
+
m.set("sunStrength", strength)
|
|
168
|
+
mesh.setMaterial(m)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private static bake(cfg: BakeConfig): void {
|
|
172
|
+
if (!_creator.lightmapBake) { console.error("[lightmap] error: this host has no bake module (build the desktop host with CREATOR_GL_LIGHTMAP, or update lecodes desktop)"); return }
|
|
173
|
+
const ids = Uint32Array.from(Lightmap.entries.map((e) => (e.model ?? e.mesh)!.id))
|
|
174
|
+
const keys = Lightmap.entries.map((e) => e.key).join("\n")
|
|
175
|
+
if (ids.length === 0) { console.error("[lightmap] error: nothing registered — call Lightmap.add on the static props before Lightmap.load"); return }
|
|
176
|
+
_creator.lightmapBake(cfg.outDir, ids, keys, cfg.size ?? 4096, cfg.texel ?? 0.02, cfg.sunRays ?? 32, cfg.aoRays ?? 64,
|
|
177
|
+
cfg.aoDistance ?? 2, cfg.bias ?? 0.02, cfg.sunAngle ?? 0.5)
|
|
178
|
+
}
|
|
179
|
+
}
|