lecodes-cli 0.18.1 → 0.19.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 +2096 -506
- package/package.json +3 -2
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk/core/Aspect.ts +65 -9
- package/runtime/sdk/core/StateMachine.ts +308 -0
- package/runtime/sdk/core/time.ts +3 -0
- package/runtime/sdk/gl/Camera.ts +39 -0
- package/runtime/sdk/gl/Light.ts +12 -4
- package/runtime/sdk/gl/Lightmap.ts +87 -10
- package/runtime/sdk/gl/Material.ts +15 -0
- package/runtime/sdk/gl/Model.ts +68 -8
- package/runtime/sdk/gl/NavAgent.ts +337 -0
- package/runtime/sdk/gl/NavMesh.ts +397 -0
- package/runtime/sdk/gl/Ragdoll.ts +270 -0
- package/runtime/sdk/gl/Scene.ts +20 -0
- package/runtime/sdk/gl/Shape.ts +37 -5
- package/runtime/sdk/gl/Terrain.ts +1092 -0
- package/runtime/sdk/gl/Texture.ts +17 -0
- package/runtime/sdk/gl/animation/Animator.ts +35 -2
- package/runtime/sdk/gl/animation/Layer.ts +3 -1
- package/runtime/sdk/gl/animation/Loop.ts +5 -0
- package/runtime/sdk/gl/animation/Playback.ts +4 -3
- package/runtime/sdk/gl/animation/core.ts +52 -10
- package/runtime/sdk/gl/terrainMesh.ts +219 -0
- package/runtime/sdk/inject.ts +12 -1
- package/runtime/sdk/net/codec.ts +119 -0
- package/runtime/sdk/net/core.ts +384 -0
- package/runtime/sdk/net/index.ts +181 -0
- package/runtime/sdk/net/replication.ts +622 -0
- package/runtime/sdk/runtime/device.ts +102 -1
- package/runtime/sdk/scene/defineScene.ts +1353 -1227
- package/runtime/sdk/scene/editorPlugins.ts +18 -0
- package/runtime/sdk/ui/UINode.ts +4 -0
- package/runtime/sdk-types.json +1 -1
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// A hierarchical state machine over a plain context object — the one place a game keeps "what am I
|
|
2
|
+
// doing right now": an enemy brain, a weapon (ready → fire → cooldown → reload), a game mode
|
|
3
|
+
// (warmup → round → over), a door. Declarative: the whole machine is ONE table you can read top
|
|
4
|
+
// to bottom, log, and later show in an inspector; the machine knows nothing about aspects, nodes
|
|
5
|
+
// or the network, so it runs wherever its owner ticks it (an `updateFixed` for anything that must
|
|
6
|
+
// match another machine's, `update` for presentation).
|
|
7
|
+
//
|
|
8
|
+
// const Guard = defineStates<Bot>({
|
|
9
|
+
// initial: 'patrol',
|
|
10
|
+
// on: { hit: 'flinch' }, // from ANY state
|
|
11
|
+
// to: { dead: (b) => b.hp <= 0 }, // checked in every state, first
|
|
12
|
+
// states: {
|
|
13
|
+
// patrol: { update: (b, dt) => b.walk(dt), to: { combat: (b) => b.target !== null } },
|
|
14
|
+
// combat: {
|
|
15
|
+
// initial: 'engage',
|
|
16
|
+
// to: { search: (b) => b.lostFor > 4 }, // leaves whichever child is active
|
|
17
|
+
// states: {
|
|
18
|
+
// engage: { update: (b) => b.approach(), to: { shoot: (b) => b.visible && b.dist < 12 } },
|
|
19
|
+
// shoot: { enter: (b) => b.fire = true, exit: (b) => b.fire = false, to: { engage: (b) => !b.visible } },
|
|
20
|
+
// },
|
|
21
|
+
// },
|
|
22
|
+
// search: { after: [6, 'patrol'] }, // a timed transition
|
|
23
|
+
// flinch: { after: [0.2, '$back'] }, // '$back' = the state we came from
|
|
24
|
+
// dead: { enter: (b) => b.die() },
|
|
25
|
+
// },
|
|
26
|
+
// })
|
|
27
|
+
// const fsm = new StateMachine(Guard, bot)
|
|
28
|
+
// fsm.step(dt) // once per tick: guards (outer states first), then updates
|
|
29
|
+
// fsm.send('hit') // an event: the innermost state that handles it wins
|
|
30
|
+
// fsm.state // 'combat.shoot' fsm.is('combat') → true
|
|
31
|
+
// fsm.on('change', (to, from) => console.log(`[fsm] ${from} → ${to}`))
|
|
32
|
+
//
|
|
33
|
+
// Semantics, in order, per `step(dt)`:
|
|
34
|
+
// 1. every active state's clock advances (`t` = seconds in that state);
|
|
35
|
+
// 2. transitions are tested OUTERMOST first — the root's `to` (any-state guards), then each
|
|
36
|
+
// active state's `to` guards (first true wins) and its `after` timer; a taken transition is
|
|
37
|
+
// re-tested from the new state (a pass-through state chains), at most 8 hops per step;
|
|
38
|
+
// 3. `update` runs outermost → innermost; an update that RETURNS a state name transitions
|
|
39
|
+
// there and ends the pass (the states it entered update next step).
|
|
40
|
+
// A transition exits states innermost-first up to the common ancestor and enters the new branch
|
|
41
|
+
// outermost-first, descending into `initial` children; the target state's own name (or an
|
|
42
|
+
// ancestor's) re-enters it. Names resolve relative to the declaring state (siblings, then each
|
|
43
|
+
// ancestor's siblings), or as a dotted path from the root; unknown names throw when the machine is
|
|
44
|
+
// built, so a typo is a startup error, not a silent no-op. `send` looks innermost → outermost
|
|
45
|
+
// (the root's `on` = any state) and returns whether someone handled it.
|
|
46
|
+
|
|
47
|
+
export type StateGuard<C> = (ctx: C, t: number) => boolean
|
|
48
|
+
export type StateHandler<C> = (ctx: C, ...args: any[]) => string | void
|
|
49
|
+
|
|
50
|
+
export interface StateDef<C> {
|
|
51
|
+
/** The child entered by default when this (compound) state is entered. */
|
|
52
|
+
initial?: string
|
|
53
|
+
/** Child states — this becomes a compound state; only a leaf is ever "the" state. */
|
|
54
|
+
states?: Record<string, StateDef<C>>
|
|
55
|
+
/** Entered (from = the previous leaf's path, '' at start). */
|
|
56
|
+
enter?: (ctx: C, from: string) => void
|
|
57
|
+
/** Left (to = the next leaf's path). */
|
|
58
|
+
exit?: (ctx: C, to: string) => void
|
|
59
|
+
/** Every step while active, outer states first; `t` = seconds in this state. Return a state name
|
|
60
|
+
* to transition. */
|
|
61
|
+
update?: (ctx: C, dt: number, t: number) => string | void
|
|
62
|
+
/** Guarded transitions, tested every step before `update` — key = target, first true wins. */
|
|
63
|
+
to?: Record<string, StateGuard<C>>
|
|
64
|
+
/** Leave for `target` after `seconds` in this state. */
|
|
65
|
+
after?: [seconds: number, target: string]
|
|
66
|
+
/** Event handlers for `send(name, …args)`: a target name, or a function returning one (or nothing). */
|
|
67
|
+
on?: Record<string, string | StateHandler<C>>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface StatesDef<C> {
|
|
71
|
+
initial: string
|
|
72
|
+
states: Record<string, StateDef<C>>
|
|
73
|
+
/** Any-state guards — tested first, in every state. */
|
|
74
|
+
to?: Record<string, StateGuard<C>>
|
|
75
|
+
/** Any-state event handlers — the fallback when no active state handles the event. */
|
|
76
|
+
on?: Record<string, string | StateHandler<C>>
|
|
77
|
+
/** Any-state update, runs before the active states' own. */
|
|
78
|
+
update?: (ctx: C, dt: number, t: number) => string | void
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type StateMachineEvents = {
|
|
82
|
+
/** After a transition completed (both are leaf paths; `from` = '' for the initial entry). */
|
|
83
|
+
change: (to: string, from: string) => void
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Identity helper — names the context type once so every callback is typed. */
|
|
87
|
+
export const defineStates = <C>(def: StatesDef<C>): StatesDef<C> => def
|
|
88
|
+
|
|
89
|
+
const BACK = "$back"
|
|
90
|
+
const MAX_HOPS = 8
|
|
91
|
+
|
|
92
|
+
interface SNode<C> {
|
|
93
|
+
name: string
|
|
94
|
+
path: string
|
|
95
|
+
def: StateDef<C>
|
|
96
|
+
parent: SNode<C> | null
|
|
97
|
+
children: Map<string, SNode<C>>
|
|
98
|
+
initial: SNode<C> | null
|
|
99
|
+
depth: number
|
|
100
|
+
/** seconds in this state (valid while active) */
|
|
101
|
+
t: number
|
|
102
|
+
/** resolved `to` targets in declaration order */
|
|
103
|
+
guards: Array<[SNode<C>, StateGuard<C>]>
|
|
104
|
+
after: [number, SNode<C> | typeof BACK] | null
|
|
105
|
+
/** resolution cache for names returned at runtime */
|
|
106
|
+
resolved: Map<string, SNode<C> | typeof BACK>
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export class StateMachine<C> {
|
|
110
|
+
readonly ctx: C
|
|
111
|
+
private readonly root: SNode<C>
|
|
112
|
+
private readonly byPath = new Map<string, SNode<C>>()
|
|
113
|
+
/** the active branch, root's child (index 0) → leaf */
|
|
114
|
+
private chain: SNode<C>[] = []
|
|
115
|
+
private prev: SNode<C> | null = null
|
|
116
|
+
private listeners: Array<(to: string, from: string) => void> = []
|
|
117
|
+
private changed = 0
|
|
118
|
+
|
|
119
|
+
constructor(def: StatesDef<C>, ctx: C) {
|
|
120
|
+
this.ctx = ctx
|
|
121
|
+
this.root = this.build("", "", def as StateDef<C>, null, 0)
|
|
122
|
+
// second pass: resolve names (every state exists now)
|
|
123
|
+
for (const n of this.byPath.values()) this.link(n)
|
|
124
|
+
this.link(this.root)
|
|
125
|
+
if (!this.root.initial) throw new Error("StateMachine: `initial` is required")
|
|
126
|
+
this.enterBranch([], this.leafOf(this.root.initial), "")
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The active leaf's path ('combat.shoot'). */
|
|
130
|
+
get state(): string { return this.chain[this.chain.length - 1]?.path ?? "" }
|
|
131
|
+
/** The leaf we came from ('' before the first transition). */
|
|
132
|
+
get previous(): string { return this.prev?.path ?? "" }
|
|
133
|
+
/** Seconds in the active leaf. */
|
|
134
|
+
get time(): number { return this.chain[this.chain.length - 1]?.t ?? 0 }
|
|
135
|
+
/** Every active state's path, outermost first. */
|
|
136
|
+
get branch(): string[] { return this.chain.map((n) => n.path) }
|
|
137
|
+
|
|
138
|
+
/** Is `name` (a state name or dotted path) active — as the leaf OR as one of its ancestors? */
|
|
139
|
+
is(name: string): boolean {
|
|
140
|
+
for (const n of this.chain) if (n.name === name || n.path === name) return true
|
|
141
|
+
return false
|
|
142
|
+
}
|
|
143
|
+
/** Seconds in the named active state (0 when it is not active). */
|
|
144
|
+
timeIn(name: string): number {
|
|
145
|
+
for (const n of this.chain) if (n.name === name || n.path === name) return n.t
|
|
146
|
+
return 0
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Force a transition (a spawn, a reset from outside the table). */
|
|
150
|
+
go(name: string): void {
|
|
151
|
+
const leaf = this.chain[this.chain.length - 1] ?? this.root
|
|
152
|
+
this.transition(this.resolve(leaf, name))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Deliver an event. Innermost active state first, the root's `on` last. True if handled. */
|
|
156
|
+
send(event: string, ...args: unknown[]): boolean {
|
|
157
|
+
for (let i = this.chain.length - 1; i >= 0; i--) {
|
|
158
|
+
const n = this.chain[i]!
|
|
159
|
+
const h = n.def.on?.[event]
|
|
160
|
+
if (h === undefined) continue
|
|
161
|
+
const target = typeof h === "string" ? h : h(this.ctx, ...args)
|
|
162
|
+
if (typeof target === "string") this.transition(this.resolve(n, target))
|
|
163
|
+
return true
|
|
164
|
+
}
|
|
165
|
+
const h = this.root.def.on?.[event]
|
|
166
|
+
if (h === undefined) return false
|
|
167
|
+
const target = typeof h === "string" ? h : h(this.ctx, ...args)
|
|
168
|
+
if (typeof target === "string") this.transition(this.resolve(this.chain[this.chain.length - 1] ?? this.root, target))
|
|
169
|
+
return true
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** One tick: clocks, guards (outer first, chained), then updates (outer first). */
|
|
173
|
+
step(dt: number): void {
|
|
174
|
+
for (const n of this.chain) n.t += dt
|
|
175
|
+
this.root.t += dt
|
|
176
|
+
for (let hop = 0; hop < MAX_HOPS; hop++) {
|
|
177
|
+
const target = this.findTransition()
|
|
178
|
+
if (target === null) break
|
|
179
|
+
this.transition(target)
|
|
180
|
+
}
|
|
181
|
+
const mark = this.changed
|
|
182
|
+
const r0 = this.root.def.update?.(this.ctx, dt, this.root.t)
|
|
183
|
+
if (typeof r0 === "string") { this.transition(this.resolve(this.chain[this.chain.length - 1]!, r0)); return }
|
|
184
|
+
if (this.changed !== mark) return
|
|
185
|
+
const chain = this.chain.slice()
|
|
186
|
+
for (const n of chain) {
|
|
187
|
+
const r = n.def.update?.(this.ctx, dt, n.t)
|
|
188
|
+
if (typeof r === "string") { this.transition(this.resolve(n, r)); return }
|
|
189
|
+
if (this.changed !== mark) return // an update transitioned through send()/go()
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Listen for transitions. */
|
|
194
|
+
on(event: "change", fn: StateMachineEvents["change"]): this { this.listeners.push(fn); return this }
|
|
195
|
+
off(event: "change", fn: StateMachineEvents["change"]): this {
|
|
196
|
+
const i = this.listeners.indexOf(fn)
|
|
197
|
+
if (i >= 0) this.listeners.splice(i, 1)
|
|
198
|
+
return this
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---- building ----
|
|
202
|
+
private build(name: string, path: string, def: StateDef<C>, parent: SNode<C> | null, depth: number): SNode<C> {
|
|
203
|
+
const node: SNode<C> = { name, path, def, parent, children: new Map(), initial: null, depth, t: 0, guards: [], after: null, resolved: new Map() }
|
|
204
|
+
if (path) this.byPath.set(path, node)
|
|
205
|
+
if (def.states) {
|
|
206
|
+
for (const [childName, childDef] of Object.entries(def.states)) {
|
|
207
|
+
if (childName.includes(".") || childName.startsWith("$")) throw new Error(`StateMachine: bad state name '${childName}'`)
|
|
208
|
+
node.children.set(childName, this.build(childName, path ? `${path}.${childName}` : childName, childDef, node, depth + 1))
|
|
209
|
+
}
|
|
210
|
+
const initial = def.initial ?? Object.keys(def.states)[0]!
|
|
211
|
+
const child = node.children.get(initial)
|
|
212
|
+
if (!child) throw new Error(`StateMachine: '${path || "root"}' has no child '${initial}' for initial`)
|
|
213
|
+
node.initial = child
|
|
214
|
+
} else if (def.initial) {
|
|
215
|
+
throw new Error(`StateMachine: '${path}' names an initial child but has no states`)
|
|
216
|
+
}
|
|
217
|
+
return node
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private link(n: SNode<C>): void {
|
|
221
|
+
if (n.def.to) for (const [target, guard] of Object.entries(n.def.to)) n.guards.push([this.resolveStrict(n, target), guard])
|
|
222
|
+
if (n.def.after) n.after = [n.def.after[0], this.resolve(n, n.def.after[1])]
|
|
223
|
+
if (n.def.on) for (const h of Object.values(n.def.on)) if (typeof h === "string") this.resolve(n, h)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private resolveStrict(from: SNode<C>, name: string): SNode<C> {
|
|
227
|
+
const r = this.resolve(from, name)
|
|
228
|
+
if (r === BACK) throw new Error(`StateMachine: '${BACK}' is not allowed as a guard target of '${from.path}'`)
|
|
229
|
+
return r
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Relative first (siblings of `from`, then of each ancestor), then a dotted path from the root. */
|
|
233
|
+
private resolve(from: SNode<C>, name: string): SNode<C> | typeof BACK {
|
|
234
|
+
const cached = from.resolved.get(name)
|
|
235
|
+
if (cached) return cached
|
|
236
|
+
let found: SNode<C> | typeof BACK | undefined
|
|
237
|
+
if (name === BACK) found = BACK
|
|
238
|
+
else {
|
|
239
|
+
for (let n: SNode<C> | null = from; n && !found; n = n.parent) {
|
|
240
|
+
const scope = n.parent ?? this.root
|
|
241
|
+
found = scope.children.get(name)
|
|
242
|
+
if (!found && n === from) found = n.children.get(name) // a compound state naming its own child
|
|
243
|
+
}
|
|
244
|
+
if (!found) found = this.byPath.get(name)
|
|
245
|
+
}
|
|
246
|
+
if (!found) throw new Error(`StateMachine: unknown state '${name}' (from '${from.path || "root"}')`)
|
|
247
|
+
from.resolved.set(name, found)
|
|
248
|
+
return found
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---- running ----
|
|
252
|
+
/** Guards never re-enter a state that is already active (a `dead` guard staying true does not
|
|
253
|
+
* re-enter `dead` every step) — only an explicit `go()` / event does that. */
|
|
254
|
+
private findTransition(): SNode<C> | typeof BACK | null {
|
|
255
|
+
const ctx = this.ctx
|
|
256
|
+
for (const [target, guard] of this.root.guards) if (!this.active(target) && guard(ctx, this.root.t)) return target
|
|
257
|
+
for (const n of this.chain) {
|
|
258
|
+
for (const [target, guard] of n.guards) if (!this.active(target) && guard(ctx, n.t)) return target
|
|
259
|
+
if (n.after && n.t >= n.after[0] && (n.after[1] === BACK || !this.active(n.after[1]))) return n.after[1]
|
|
260
|
+
}
|
|
261
|
+
return null
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private active(n: SNode<C>): boolean { return this.chain[n.depth - 1] === n }
|
|
265
|
+
|
|
266
|
+
private leafOf(n: SNode<C>): SNode<C> {
|
|
267
|
+
while (n.initial) n = n.initial
|
|
268
|
+
return n
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private transition(target: SNode<C> | typeof BACK): void {
|
|
272
|
+
const current = this.chain[this.chain.length - 1] ?? null
|
|
273
|
+
let node: SNode<C>
|
|
274
|
+
if (target === BACK) {
|
|
275
|
+
if (!this.prev) return
|
|
276
|
+
node = this.prev
|
|
277
|
+
} else node = target
|
|
278
|
+
const leaf = this.leafOf(node)
|
|
279
|
+
// the new branch (root excluded)
|
|
280
|
+
const next: SNode<C>[] = []
|
|
281
|
+
for (let n: SNode<C> | null = leaf; n && n !== this.root; n = n.parent) next.unshift(n)
|
|
282
|
+
// common prefix; a target that is the current leaf or one of its ancestors re-enters itself
|
|
283
|
+
let common = 0
|
|
284
|
+
while (common < this.chain.length && common < next.length && this.chain[common] === next[common]) common++
|
|
285
|
+
if (node !== this.root && common >= node.depth && this.chain[node.depth - 1] === node) common = node.depth - 1
|
|
286
|
+
const from = current?.path ?? ""
|
|
287
|
+
const to = leaf.path
|
|
288
|
+
for (let i = this.chain.length - 1; i >= common; i--) this.chain[i]!.def.exit?.(this.ctx, to)
|
|
289
|
+
this.prev = current
|
|
290
|
+
this.enterBranch(this.chain.slice(0, common), leaf, from, next)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private enterBranch(kept: SNode<C>[], leaf: SNode<C>, from: string, next?: SNode<C>[]): void {
|
|
294
|
+
if (!next) {
|
|
295
|
+
next = []
|
|
296
|
+
for (let n: SNode<C> | null = leaf; n && n !== this.root; n = n.parent) next.unshift(n)
|
|
297
|
+
}
|
|
298
|
+
this.chain = kept.slice()
|
|
299
|
+
this.changed++
|
|
300
|
+
for (let i = kept.length; i < next.length; i++) {
|
|
301
|
+
const n = next[i]!
|
|
302
|
+
n.t = 0
|
|
303
|
+
this.chain.push(n)
|
|
304
|
+
n.def.enter?.(this.ctx, from)
|
|
305
|
+
}
|
|
306
|
+
for (const fn of this.listeners.slice()) fn(leaf.path, from)
|
|
307
|
+
}
|
|
308
|
+
}
|
package/runtime/sdk/core/time.ts
CHANGED
|
@@ -15,6 +15,9 @@ const sinks: ScaleSink[] = []
|
|
|
15
15
|
class TimeClock {
|
|
16
16
|
private _scale = 1
|
|
17
17
|
private _paused = false
|
|
18
|
+
/** The fixed step: what `updateFixed(dt)` receives, every time, on every machine (1/60). Under
|
|
19
|
+
* `scale` the NUMBER of fixed steps per frame changes, never this value. */
|
|
20
|
+
readonly fixedDt = 1 / 60
|
|
18
21
|
/** Seconds of GAME time since the app started (scaled; stops while paused). */
|
|
19
22
|
now = 0
|
|
20
23
|
/** Seconds of wall-clock time since the app started (never stops). */
|
package/runtime/sdk/gl/Camera.ts
CHANGED
|
@@ -36,6 +36,12 @@ export class Camera extends Node {
|
|
|
36
36
|
* defaults (the headless core takes a `fov` option) and a blind push would overwrite them. */
|
|
37
37
|
private _projSet = false
|
|
38
38
|
|
|
39
|
+
// Exposure the app asked for (filament's physical defaults until it does): f/16, 1/125 s, ISO 100 =
|
|
40
|
+
// EV100 15, bright sunlight — what every light intensity in the docs is tuned against.
|
|
41
|
+
private _exposure = { aperture: 16, shutterSpeed: 1 / 125, iso: 100 }
|
|
42
|
+
private _ev = 0
|
|
43
|
+
private _exposureSet = false
|
|
44
|
+
|
|
39
45
|
/** @internal — Scene wires the camera to its native scene id + projection callback. */
|
|
40
46
|
_attach(sceneId: number): void {
|
|
41
47
|
this._sceneId = sceneId
|
|
@@ -43,6 +49,39 @@ export class Camera extends Node {
|
|
|
43
49
|
for (let i = 0; i < 16; i++) this.projectionMatrix[i] = mat[i]
|
|
44
50
|
})
|
|
45
51
|
if (this._projSet) this._applyProjection()
|
|
52
|
+
if (this._exposureSet) this._applyExposure()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Exposure compensation in stops (default 0): +1 doubles how bright the scene renders, −2
|
|
56
|
+
* quarters it. The one exposure knob most games need — a dark interior, a flash of white, or eye
|
|
57
|
+
* adaptation when the sun swings into view (drop it, and the sun stays at the display's peak
|
|
58
|
+
* while everything else darkens). Scene light follows it; particle `emissive` is post-exposure
|
|
59
|
+
* and does not, so a fireball keeps its on-screen brightness. Cheap to animate every frame. */
|
|
60
|
+
get exposureCompensation(): number { return this._ev }
|
|
61
|
+
set exposureCompensation(stops: number) {
|
|
62
|
+
this._ev = Number.isFinite(stops) ? stops : 0
|
|
63
|
+
this._exposureSet = true
|
|
64
|
+
this._applyExposure()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The physical camera: `aperture` in f-stops, `shutterSpeed` in seconds, `iso`, plus the same
|
|
68
|
+
* `compensation` in stops — any subset, one call. `camera.setExposure({ iso: 400 })` is two stops
|
|
69
|
+
* brighter than the default f/16 · 1/125 · ISO 100. */
|
|
70
|
+
setExposure(exposure: { aperture?: number, shutterSpeed?: number, iso?: number, compensation?: number }): this {
|
|
71
|
+
if (exposure.aperture !== undefined) this._exposure.aperture = exposure.aperture
|
|
72
|
+
if (exposure.shutterSpeed !== undefined) this._exposure.shutterSpeed = exposure.shutterSpeed
|
|
73
|
+
if (exposure.iso !== undefined) this._exposure.iso = exposure.iso
|
|
74
|
+
if (exposure.compensation !== undefined) this._ev = exposure.compensation
|
|
75
|
+
this._exposureSet = true
|
|
76
|
+
this._applyExposure()
|
|
77
|
+
return this
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Hosts that predate the call keep the fixed default (older wasm / native builds). */
|
|
81
|
+
private _applyExposure(): void {
|
|
82
|
+
if (this._sceneId < 0) return
|
|
83
|
+
const e = this._exposure
|
|
84
|
+
_creator.setCameraExposure?.(this._sceneId, e.aperture, e.shutterSpeed, e.iso * 2 ** this._ev)
|
|
46
85
|
}
|
|
47
86
|
|
|
48
87
|
/** Vertical field of view in degrees (default 60) — a smaller angle is a longer lens. */
|
package/runtime/sdk/gl/Light.ts
CHANGED
|
@@ -14,12 +14,19 @@ export type SunOptions = {
|
|
|
14
14
|
/**
|
|
15
15
|
* Shadow quality (default 1):
|
|
16
16
|
* 0 - no shadows
|
|
17
|
-
* 1 -
|
|
18
|
-
* 2 -
|
|
19
|
-
* 3 -
|
|
20
|
-
*
|
|
17
|
+
* 1 - 1024 map, hard edges (one filtered tap; the cheap level, ~3 % of a frame on an iGPU)
|
|
18
|
+
* 2 - 1024 map, soft edges (variance shadows + blur; ~+15 % of a frame on an iGPU)
|
|
19
|
+
* 3 - 2048 map, contact-hardening soft edges (PCSS: sharp where the caster touches, softer
|
|
20
|
+
* away; discrete-GPU territory, ~+60 % of a frame on an iGPU)
|
|
21
|
+
* A lightmapped level already carries every static shadow, so 0 is a legitimate choice there.
|
|
21
22
|
*/
|
|
22
23
|
shadowsQuality?: 0 | 1 | 2 | 3
|
|
24
|
+
/**
|
|
25
|
+
* Metres from the camera within which the sun casts shadows (default 100). Shadows fade out by
|
|
26
|
+
* this distance and the shadow map covers only this range, so a shorter distance is crisper for
|
|
27
|
+
* the same quality and puts fewer casters in the shadow pass. 30-40 is plenty in first person.
|
|
28
|
+
*/
|
|
29
|
+
shadowDistance?: number
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
export type PointOptions = {
|
|
@@ -59,6 +66,7 @@ export class Light extends Node {
|
|
|
59
66
|
light._intensity,
|
|
60
67
|
Color.toPackedRgb(options.color ?? 0xffffff),
|
|
61
68
|
options.shadowsQuality ?? 1,
|
|
69
|
+
options.shadowDistance ?? 100,
|
|
62
70
|
)
|
|
63
71
|
return light
|
|
64
72
|
}
|
|
@@ -19,6 +19,12 @@
|
|
|
19
19
|
// and in BAKE mode (`lecodes lightmap bake`) it is where the bake fires: the same call marks "the
|
|
20
20
|
// scene is complete". Atlas channels: R = sun visibility, G = ambient occlusion; the shader keeps
|
|
21
21
|
// the real-time sun for dynamic casters and multiplies the baked mask in with an ambient floor.
|
|
22
|
+
//
|
|
23
|
+
// DYNAMIC objects (docs/lightmap-plan.md §9): the bake also writes lightmap.volume — the same two
|
|
24
|
+
// channels on a 3D grid over the level. Pass it as `volume` and every model loaded with
|
|
25
|
+
// `{ lightmap: 'dynamic' }` (the default while a scene file with `env.lightmap.volume` runs) plus
|
|
26
|
+
// every dynamic Mesh a scene file registers samples it per pixel: a crate rolled under the roof
|
|
27
|
+
// loses the sun like the floor does, a character near a wall picks up its ambient occlusion.
|
|
22
28
|
|
|
23
29
|
import { fetch } from "../runtime/fetch"
|
|
24
30
|
import { Texture } from "./Texture"
|
|
@@ -40,19 +46,27 @@ export type LightmapLoadOptions = {
|
|
|
40
46
|
export type LightmapInfo = {
|
|
41
47
|
size: number
|
|
42
48
|
texel: number
|
|
49
|
+
/** The light volume, when one was loaded: grid dims + cell size in metres. */
|
|
50
|
+
volume?: { dims: number[], cell: number }
|
|
43
51
|
/** keys applied / keys in the file / registered statics without a rect */
|
|
44
52
|
applied: number
|
|
45
53
|
total: number
|
|
46
54
|
missing: string[]
|
|
47
55
|
}
|
|
48
56
|
|
|
49
|
-
type
|
|
57
|
+
import type { Terrain } from "./Terrain"
|
|
58
|
+
|
|
59
|
+
type Entry = { key: string, model?: Model, mesh?: Mesh, terrain?: Terrain }
|
|
60
|
+
|
|
61
|
+
const terrainOf = (n: Node): Terrain | null => (n as unknown as { _terrain?: Terrain })._terrain ?? null
|
|
50
62
|
|
|
51
63
|
type BakeConfig = {
|
|
52
64
|
outDir: string, size?: number, texel?: number, sunRays?: number, aoRays?: number,
|
|
53
|
-
aoDistance?: number, bias?: number, sunAngle?: number,
|
|
65
|
+
aoDistance?: number, bias?: number, sunAngle?: number, volumeCell?: number,
|
|
54
66
|
}
|
|
55
67
|
|
|
68
|
+
type VolumeHeader = { texture: number, dims: number[], min: number[], cell: number }
|
|
69
|
+
|
|
56
70
|
const bakeConfig = (): BakeConfig | null => {
|
|
57
71
|
const g = (globalThis as unknown as { __lecodesLightmap?: BakeConfig }).__lecodesLightmap
|
|
58
72
|
return g && typeof g.outDir === "string" ? g : null
|
|
@@ -71,9 +85,11 @@ export class Lightmap {
|
|
|
71
85
|
static get baking(): boolean { return bakeConfig() !== null }
|
|
72
86
|
|
|
73
87
|
private static entries: Entry[] = []
|
|
88
|
+
private static dynamics: Mesh[] = []
|
|
74
89
|
private static ordinals = new Map<string, number>()
|
|
75
90
|
private static warned = false
|
|
76
91
|
|
|
92
|
+
|
|
77
93
|
/** Register static geometry — a Model, a Mesh, or any node whose subtree holds them. Statics are
|
|
78
94
|
* both receivers and occluders in the bake. `key` names the entry in lightmap.bake (default:
|
|
79
95
|
* the node's name + a running number, `container#3`); pass one when names are not stable. */
|
|
@@ -81,6 +97,8 @@ export class Lightmap {
|
|
|
81
97
|
const base = key ?? `${node.name || "node"}#${Lightmap.next(node.name || "node")}`
|
|
82
98
|
const found: Entry[] = []
|
|
83
99
|
const walk = (n: Node): void => {
|
|
100
|
+
const t = terrainOf(n)
|
|
101
|
+
if (t) { found.push({ key: base, terrain: t }); return } // one receiver, its chunks are internal
|
|
84
102
|
if (n instanceof Model) { found.push({ key: base, model: n }); return }
|
|
85
103
|
if (n instanceof Mesh) { found.push({ key: base, mesh: n }); return }
|
|
86
104
|
for (const c of n.children) walk(c)
|
|
@@ -97,6 +115,17 @@ export class Lightmap {
|
|
|
97
115
|
Lightmap.entries.push(node instanceof Model ? { key, model: node } : { key, mesh: node })
|
|
98
116
|
}
|
|
99
117
|
|
|
118
|
+
/** @internal Scene-file runtime: a terrain node (its own material carries the atlas, no swap). */
|
|
119
|
+
static _registerTerrain(terrain: Terrain, key: string): void {
|
|
120
|
+
Lightmap.entries.push({ key, terrain })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** @internal Scene-file runtime: a dynamic Mesh — swapped to the lightmap material on the volume
|
|
124
|
+
* when `load` runs (dynamic Models need nothing here: the engine binds the volume itself). */
|
|
125
|
+
static _registerDynamic(mesh: Mesh): void {
|
|
126
|
+
Lightmap.dynamics.push(mesh)
|
|
127
|
+
}
|
|
128
|
+
|
|
100
129
|
private static next(name: string): number {
|
|
101
130
|
const n = Lightmap.ordinals.get(name) ?? 0
|
|
102
131
|
Lightmap.ordinals.set(name, n + 1)
|
|
@@ -106,12 +135,15 @@ export class Lightmap {
|
|
|
106
135
|
/** Forget every registration (a scene rebuild). */
|
|
107
136
|
static clear(): void {
|
|
108
137
|
Lightmap.entries = []
|
|
138
|
+
Lightmap.dynamics = []
|
|
109
139
|
Lightmap.ordinals.clear()
|
|
140
|
+
Model._lightmapDefault = false
|
|
141
|
+
_creator.lightmapVolumeSet?.(0xFFFFFFFF, 0, 0, 0, 0, 0, 0, 0, 1, 1)
|
|
110
142
|
}
|
|
111
143
|
|
|
112
144
|
/** Apply a bake — or, under `lecodes lightmap bake`, run it. Resolves to null when nothing was
|
|
113
145
|
* 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> {
|
|
146
|
+
static async load(scene: Scene, files: { data: string, texture: string, volume?: string }, options: LightmapLoadOptions = {}): Promise<LightmapInfo | null> {
|
|
115
147
|
const bake = bakeConfig()
|
|
116
148
|
if (bake) { Lightmap.bake(bake); return null }
|
|
117
149
|
if (!_creator.lightmapApply) {
|
|
@@ -131,6 +163,8 @@ export class Lightmap {
|
|
|
131
163
|
const texture = await Texture.load(files.texture)
|
|
132
164
|
const ambientScale = options.ambientScale ?? 1
|
|
133
165
|
const strength = options.sunStrength ?? 1
|
|
166
|
+
// the light volume first: it claims every lightmap-material instance, the atlas rects below win back the statics
|
|
167
|
+
const volume = files.volume ? await Lightmap.loadVolume(files.volume, strength, ambientScale) : null
|
|
134
168
|
const rects = new Map<string, number[]>()
|
|
135
169
|
for (const inst of data.instances) if (inst.receiver && inst.st) rects.set(inst.key, inst.st)
|
|
136
170
|
let applied = 0
|
|
@@ -141,19 +175,57 @@ export class Lightmap {
|
|
|
141
175
|
if (e.model) {
|
|
142
176
|
const n = _creator.lightmapApply(e.model.id, texture._id, st[0], st[1], st[2], st[3], ambientScale, strength)
|
|
143
177
|
if (n === 0) { console.warn(`Lightmap: ${e.key} was not loaded with { lightmap: true } — skipped`); continue }
|
|
144
|
-
|
|
178
|
+
e.model.castShadows = false
|
|
145
179
|
} else if (e.mesh) {
|
|
146
180
|
Lightmap.swapMeshMaterial(e.mesh, texture, st, ambientScale, strength)
|
|
147
181
|
e.mesh.castShadows = false
|
|
182
|
+
} else if (e.terrain) {
|
|
183
|
+
// terrain.mat carries lightmap.mat's atlas block: bind the rect on the terrain's own material
|
|
184
|
+
e.terrain.material.set("lightmap", texture).set("lightmapST", [ st[0], st[1], st[2], st[3] ])
|
|
185
|
+
.set("ambientScale", ambientScale).set("sunStrength", strength)
|
|
186
|
+
e.terrain.castShadows = false
|
|
148
187
|
}
|
|
149
188
|
applied++
|
|
150
189
|
}
|
|
151
190
|
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
|
-
|
|
191
|
+
const info: LightmapInfo = { size: data.size, texel: data.texel, applied, total: rects.size, missing }
|
|
192
|
+
if (volume) info.volume = { dims: volume.dims, cell: volume.cell }
|
|
193
|
+
return info
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** lightmap.volume → the engine's 3D texture → THE volume for every dynamic lightmap-material instance,
|
|
197
|
+
* plus the registered dynamic Meshes. Null when the file is a placeholder or the host lacks the feature. */
|
|
198
|
+
private static async loadVolume(url: string, strength: number, ambientScale: number): Promise<VolumeHeader | null> {
|
|
199
|
+
if (!_creator.lightmapVolumeLoad || !_creator.lightmapVolumeSet) return null
|
|
200
|
+
let header: VolumeHeader
|
|
201
|
+
try {
|
|
202
|
+
const resp = await fetch(url, { useOnce: true })
|
|
203
|
+
if (resp.status >= 400) { console.warn(`Lightmap: no volume at ${url} (HTTP ${resp.status}) — run \`lecodes lightmap bake\``); return null }
|
|
204
|
+
const json = _creator.lightmapVolumeLoad((resp as unknown as { _id: number })._id)
|
|
205
|
+
resp.dispose()
|
|
206
|
+
if (!json) { console.warn(`Lightmap: ${url} is not a light volume — rebake`); return null }
|
|
207
|
+
header = JSON.parse(json) as VolumeHeader
|
|
208
|
+
} catch (e) {
|
|
209
|
+
console.warn(`Lightmap: could not read ${url}: ${String(e)}`)
|
|
210
|
+
return null
|
|
211
|
+
}
|
|
212
|
+
const [ nx, ny, nz ] = header.dims
|
|
213
|
+
if (nx * ny * nz <= 1) return null // the CLI's placeholder: nothing baked yet
|
|
214
|
+
const size = [ nx * header.cell, ny * header.cell, nz * header.cell ]
|
|
215
|
+
const texture = new Texture(nx, ny, header.texture)
|
|
216
|
+
_creator.lightmapVolumeSet(header.texture, header.min[0], header.min[1], header.min[2], size[0], size[1], size[2], header.cell, strength, ambientScale)
|
|
217
|
+
for (const mesh of Lightmap.dynamics) {
|
|
218
|
+
const m = Lightmap.meshMaterial(mesh, ambientScale, strength)
|
|
219
|
+
m.set("probeVolume", texture)
|
|
220
|
+
m.set("probeMin", [ header.min[0], header.min[1], header.min[2], header.cell * 0.5 ])
|
|
221
|
+
m.set("probeInvSize", [ 1 / size[0], 1 / size[1], 1 / size[2], 1 ])
|
|
222
|
+
mesh.setMaterial(m)
|
|
223
|
+
}
|
|
224
|
+
return header
|
|
153
225
|
}
|
|
154
226
|
|
|
155
227
|
/** A Mesh keeps its look (colour / map / roughness / metallic) but moves to the lightmap material. */
|
|
156
|
-
private static
|
|
228
|
+
private static meshMaterial(mesh: Mesh, ambientScale: number, strength: number): Material {
|
|
157
229
|
const src = mesh.material
|
|
158
230
|
const u = src?.uniforms ?? {}
|
|
159
231
|
const m = Material.lightmap()
|
|
@@ -161,19 +233,24 @@ export class Lightmap {
|
|
|
161
233
|
if (u.baseColorMap instanceof Texture) m.set("baseColorMap", u.baseColorMap)
|
|
162
234
|
if (typeof u.roughness === "number") m.set("roughnessFactor", u.roughness)
|
|
163
235
|
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
236
|
m.set("ambientScale", ambientScale)
|
|
167
237
|
m.set("sunStrength", strength)
|
|
238
|
+
return m
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private static swapMeshMaterial(mesh: Mesh, texture: Texture, st: number[], ambientScale: number, strength: number): void {
|
|
242
|
+
const m = Lightmap.meshMaterial(mesh, ambientScale, strength)
|
|
243
|
+
m.set("lightmap", texture)
|
|
244
|
+
m.set("lightmapST", [ st[0], st[1], st[2], st[3] ])
|
|
168
245
|
mesh.setMaterial(m)
|
|
169
246
|
}
|
|
170
247
|
|
|
171
248
|
private static bake(cfg: BakeConfig): void {
|
|
172
249
|
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)
|
|
250
|
+
const ids = Uint32Array.from(Lightmap.entries.map((e) => (e.model ?? e.mesh ?? e.terrain!.node).id))
|
|
174
251
|
const keys = Lightmap.entries.map((e) => e.key).join("\n")
|
|
175
252
|
if (ids.length === 0) { console.error("[lightmap] error: nothing registered — call Lightmap.add on the static props before Lightmap.load"); return }
|
|
176
253
|
_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)
|
|
254
|
+
cfg.aoDistance ?? 2, cfg.bias ?? 0.02, cfg.sunAngle ?? 0.5, cfg.volumeCell ?? 0.5)
|
|
178
255
|
}
|
|
179
256
|
}
|
|
@@ -205,6 +205,21 @@ export class Material {
|
|
|
205
205
|
const m = new Material({ _id: _creatorUtils.fetchLocal("lightmap.filamat") } as any)
|
|
206
206
|
m.set("baseColorFactor", "#ffffffff").set("roughnessFactor", 1).set("metallicFactor", 0)
|
|
207
207
|
m.set("lightmapST", [ 1, 1, 0, 0 ]).set("ambientScale", 1).set("sunStrength", 1).set("hasNormalMap", 0)
|
|
208
|
+
m.set("probeMin", [ 0, 0, 0, 0 ]).set("probeInvSize", [ 0, 0, 0, 0 ])
|
|
209
|
+
return m
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The terrain splat material (docs/terrain-plan.md §1.5): four albedo (+ normal-map) layers blended by
|
|
213
|
+
* a control map on the terrain's own grid, per-layer `tiling` (metres per repeat) / `roughness` /
|
|
214
|
+
* `normalScale` / `triplanar`, lightmap-aware (the same `lightmap` / `lightmapST` block as
|
|
215
|
+
* `Material.lightmap`). `Terrain` builds and owns one per terrain; the uniforms are primed here so an
|
|
216
|
+
* unset layer is white and an unbaked terrain is fully lit. */
|
|
217
|
+
static terrain(): Material {
|
|
218
|
+
// Inline literal: the compiler's preload header captures the shader name from it.
|
|
219
|
+
const m = new Material({ _id: _creatorUtils.fetchLocal("terrain.filamat") } as any)
|
|
220
|
+
m.set("tiling", [ 8, 8, 8, 8 ]).set("roughness", [ 1, 1, 1, 1 ]).set("normalScale", [ 0, 0, 0, 0 ]).set("triplanar", [ 0, 0, 0, 0 ])
|
|
221
|
+
m.set("gridSize", [ 2, 2 ]).set("cellSize", 1).set("tint", "#ffffff")
|
|
222
|
+
m.set("lightmapST", [ 1, 1, 0, 0 ]).set("ambientScale", 1).set("sunStrength", 1)
|
|
208
223
|
return m
|
|
209
224
|
}
|
|
210
225
|
|