lecodes-cli 0.18.0 → 0.18.2
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 +2013 -564
- package/package.json +4 -4
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk/core/Aspect.ts +512 -255
- 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 +40 -1
- package/runtime/sdk/gl/CameraPlace.ts +52 -51
- package/runtime/sdk/gl/CharacterController.ts +184 -56
- package/runtime/sdk/gl/Gearbox.ts +212 -0
- package/runtime/sdk/gl/Geometry.ts +70 -9
- package/runtime/sdk/gl/Light.ts +64 -2
- package/runtime/sdk/gl/Lightmap.ts +179 -0
- package/runtime/sdk/gl/Material.ts +25 -0
- package/runtime/sdk/gl/Mesh.ts +6 -23
- package/runtime/sdk/gl/Model.ts +16 -2
- package/runtime/sdk/gl/Node.ts +119 -39
- package/runtime/sdk/gl/Physics.ts +75 -24
- package/runtime/sdk/gl/Scene.ts +161 -5
- package/runtime/sdk/gl/Shape.ts +42 -3
- package/runtime/sdk/gl/Trigger.ts +45 -50
- package/runtime/sdk/gl/Vehicle.ts +276 -322
- package/runtime/sdk/gl/Wheel.ts +240 -0
- package/runtime/sdk/gl/scenarios.ts +4 -30
- package/runtime/sdk/gl/state.ts +6 -6
- package/runtime/sdk/inject.ts +186 -171
- package/runtime/sdk/runtime/device.ts +102 -1
- package/runtime/sdk/scene/defineScene.ts +108 -53
- package/runtime/sdk/scene/gizmos.ts +31 -11
- package/runtime/sdk/scene/material.ts +188 -0
- package/runtime/sdk-types.json +1 -1
- package/runtime/sdk/compile/aspectMacro.ts +0 -42
- package/runtime/sdk/compile/assetIconMacro.ts +0 -384
- package/runtime/sdk/compile/assetMacro.ts +0 -45
- package/runtime/sdk/compile/assetName.ts +0 -50
- package/runtime/sdk/compile/bundler.ts +0 -252
- package/runtime/sdk/compile/compileProject.ts +0 -129
- package/runtime/sdk/compile/detectEntry.ts +0 -128
- package/runtime/sdk/compile/fontMacro.ts +0 -459
- package/runtime/sdk/compile/fontRegistry.ts +0 -78
- package/runtime/sdk/compile/header.ts +0 -67
- package/runtime/sdk/compile/index.ts +0 -85
- package/runtime/sdk/compile/libraryImports.ts +0 -52
- package/runtime/sdk/compile/liteMaterial.ts +0 -247
- package/runtime/sdk/compile/sceneEditor.ts +0 -78
- package/runtime/sdk/compile/serverSplit.ts +0 -233
- package/runtime/sdk/compile/serverTypes.ts +0 -227
- package/runtime/sdk/compile/sfnt.ts +0 -98
- package/runtime/sdk/compile/shaderTargets.ts +0 -42
- package/runtime/sdk/compile/sourcemap.ts +0 -25
|
@@ -39,7 +39,6 @@ import {
|
|
|
39
39
|
type AspectEntry, type MakeEntry as SharedMakeEntry,
|
|
40
40
|
} from "./grammar"
|
|
41
41
|
import { InspectorUI, type InspectorEvent, type InspectorWidget } from "../core/InspectorUI"
|
|
42
|
-
import type { ColorInput } from "../core/color"
|
|
43
42
|
import type { Vec3Like } from "../math/vec"
|
|
44
43
|
import { Scene, type SceneOptions } from "../gl/Scene"
|
|
45
44
|
import { CAMERA_DEFAULTS } from "../gl/Camera"
|
|
@@ -47,11 +46,12 @@ import { CameraPlace } from "../gl/CameraPlace"
|
|
|
47
46
|
import { Node } from "../gl/Node"
|
|
48
47
|
import { Mesh } from "../gl/Mesh"
|
|
49
48
|
import { Model } from "../gl/Model"
|
|
49
|
+
import { Physics } from "../gl/Physics"
|
|
50
|
+
import { Lightmap } from "../gl/Lightmap"
|
|
50
51
|
import { Light, type SunOptions } from "../gl/Light"
|
|
51
|
-
import {
|
|
52
|
+
import { assignMaterialDef, MaterialHandle, resolveMaterialDef, type MaterialDef } from "./material"
|
|
52
53
|
import type { CylinderOptions, PlaneOptions, SphereOptions } from "../gl/Geometry"
|
|
53
|
-
import {
|
|
54
|
-
import { GizmoBuffer, withGizmoScope, type GizmoBatch } from "./gizmos"
|
|
54
|
+
import { GizmoBuffer, Gizmos, withGizmoScope, type GizmoBatch } from "./gizmos"
|
|
55
55
|
|
|
56
56
|
// ---- the literal grammar (what the visual editor reads and writes) -----------
|
|
57
57
|
|
|
@@ -63,12 +63,9 @@ export type MeshDef =
|
|
|
63
63
|
|
|
64
64
|
export type LightDef = { kind: "sun" } & SunOptions
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
| { shadow: ColorInput }
|
|
70
|
-
// An imported/shared Material instance — valid at runtime; the editor shows it read-only.
|
|
71
|
-
| Material
|
|
66
|
+
// The material grammar (`{ lit }` / `{ unlit }` / `{ shadow }` / `{ shader, params }` / a
|
|
67
|
+
// material asset handle / a code instance) lives in ./material.ts — re-exported for callers.
|
|
68
|
+
export type { MaterialDef }
|
|
72
69
|
|
|
73
70
|
// The grammar markers (`use`/`ref`/`make`) live in ./grammar.ts, shared with defineScene2d —
|
|
74
71
|
// re-exported here so this module remains the one import site for scene-file machinery.
|
|
@@ -85,6 +82,10 @@ export type ModelOverrideDef = {
|
|
|
85
82
|
eulerAngles?: Vec3Like
|
|
86
83
|
scale?: Vec3Like | number
|
|
87
84
|
visible?: boolean
|
|
85
|
+
/** Materials by primitive SLOT of this part (`0` for a single-material mesh; the editor lists
|
|
86
|
+
* the slots with their glTF material names): a material asset, an inline def, or a custom
|
|
87
|
+
* shader. Slots left out keep the glTF material. */
|
|
88
|
+
materials?: Record<number | string, MaterialDef>
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
/** Camera projection settings, shared by the `camera:` source block and the top-level `camera:`
|
|
@@ -142,6 +143,11 @@ export type SceneNodeDef = {
|
|
|
142
143
|
editor?: { clip?: string, time?: number }
|
|
143
144
|
castShadows?: boolean
|
|
144
145
|
receiveShadows?: boolean
|
|
146
|
+
/** Baked lighting (a scene with `env.lightmap`): is this model/mesh node a lightmap STATIC — a
|
|
147
|
+
* receiver and an occluder in the bake, real-time shadow casting off once the bake applies?
|
|
148
|
+
* Default: static unless a `Physics` aspect moves the node (`dynamic` — Physics' default — or
|
|
149
|
+
* `kinematic`). Set it only to override that rule; prefab subtrees inherit the verdict. */
|
|
150
|
+
lightmap?: boolean
|
|
145
151
|
// -- capabilities / hierarchy --
|
|
146
152
|
aspects?: readonly AspectEntry<any>[]
|
|
147
153
|
children?: Record<string, SceneNodeDef>
|
|
@@ -153,8 +159,21 @@ export type SceneCameraDef = CameraProjectionDef & {
|
|
|
153
159
|
target?: Vec3Like
|
|
154
160
|
}
|
|
155
161
|
|
|
162
|
+
/** `env.lightmap` — the level's baked lighting (see lightmap.md): the two files
|
|
163
|
+
* `lecodes lightmap bake` writes, plus how the bake is applied. Absent = real-time only. */
|
|
164
|
+
export type SceneLightmapDef = {
|
|
165
|
+
/** `asset('./assets/lightmap/lightmap.bake')` */
|
|
166
|
+
data: string
|
|
167
|
+
/** `asset('./assets/lightmap/lightmap.ktx2')` */
|
|
168
|
+
texture: string
|
|
169
|
+
/** 1 = baked sun shadows at full strength, 0 = ambient occlusion only. Default 1. */
|
|
170
|
+
sunStrength?: number
|
|
171
|
+
/** Multiplier on the ambient share in the shadow math (1 = filament's own darkness). Default 1. */
|
|
172
|
+
ambientScale?: number
|
|
173
|
+
}
|
|
174
|
+
|
|
156
175
|
export type SceneDef = {
|
|
157
|
-
env?: SceneOptions
|
|
176
|
+
env?: SceneOptions & { lightmap?: SceneLightmapDef }
|
|
158
177
|
camera?: SceneCameraDef
|
|
159
178
|
nodes?: Record<string, SceneNodeDef>
|
|
160
179
|
}
|
|
@@ -265,6 +284,17 @@ const applyOverridesToRows = (rows: ModelPartRow[], overrides: Record<string, Mo
|
|
|
265
284
|
if (o.eulerAngles) node.eulerAngles = o.eulerAngles
|
|
266
285
|
if (o.scale !== undefined) node.scale = o.scale
|
|
267
286
|
if (o.visible !== undefined) node.visible = o.visible
|
|
287
|
+
// slot materials load in like textures do (a shader package fetch) — the part keeps its glTF
|
|
288
|
+
// material until then
|
|
289
|
+
if (o.materials) {
|
|
290
|
+
for (const [ slot, def ] of Object.entries(o.materials)) {
|
|
291
|
+
const index = Number(slot)
|
|
292
|
+
if (!Number.isInteger(index) || index < 0) continue
|
|
293
|
+
void assignMaterialDef(node, index, def).catch((e) => {
|
|
294
|
+
console.warn(`[scene] material for "${path}" slot ${index}: ${e instanceof Error ? e.message : String(e)}`)
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
}
|
|
268
298
|
}
|
|
269
299
|
}
|
|
270
300
|
|
|
@@ -291,27 +321,42 @@ const attachHost = (parent: Node, def: SceneNodeDef, path: string): Node =>
|
|
|
291
321
|
|
|
292
322
|
// ---- runtime -------------------------------------------------------------------
|
|
293
323
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const createMesh = (def: MeshDef, material?: MaterialDef): Mesh => {
|
|
302
|
-
const mat = material !== undefined ? resolveMaterial(material) : undefined
|
|
324
|
+
/** A mesh node resolves its material BEFORE it appears (a custom shader fetches its compiled
|
|
325
|
+
* package, textures decode) — the same contract a model has with its GLB. Handle users are
|
|
326
|
+
* tracked so the editor can re-assign them when the asset's shader changes. */
|
|
327
|
+
const createMesh = async (def: MeshDef, material?: MaterialDef): Promise<Mesh> => {
|
|
328
|
+
const mat = material !== undefined ? await resolveMaterialDef(material) : undefined
|
|
329
|
+
let mesh: Mesh
|
|
303
330
|
switch (def.kind) {
|
|
304
|
-
case "box":
|
|
305
|
-
case "sphere": { const { kind: _k, ...opts } = def;
|
|
306
|
-
case "cylinder": { const { kind: _k, ...opts } = def;
|
|
307
|
-
case "plane": { const { kind: _k, ...opts } = def;
|
|
331
|
+
case "box": mesh = Mesh.box({ size: def.size, material: mat }); break
|
|
332
|
+
case "sphere": { const { kind: _k, ...opts } = def; mesh = Mesh.sphere({ ...opts, material: mat }); break }
|
|
333
|
+
case "cylinder": { const { kind: _k, ...opts } = def; mesh = Mesh.cylinder({ ...opts, material: mat }); break }
|
|
334
|
+
case "plane": { const { kind: _k, ...opts } = def; mesh = Mesh.plane({ ...opts, material: mat }); break }
|
|
308
335
|
}
|
|
336
|
+
if (material instanceof MaterialHandle) material._users.add({ node: mesh, slot: 0 })
|
|
337
|
+
return mesh
|
|
309
338
|
}
|
|
310
339
|
|
|
311
|
-
|
|
340
|
+
// ---- baked lighting (env.lightmap) ---------------------------------------------------------------
|
|
341
|
+
// Statics are derived, not declared: a model/mesh node bakes unless a Physics aspect moves it
|
|
342
|
+
// (`dynamic` — Physics' default — or `kinematic`); `lightmap: true | false` on the node overrides.
|
|
343
|
+
// Keys are node paths (prefab internals: `wrapper/inner`), so lightmap.bake stays readable and a
|
|
344
|
+
// rename honestly invalidates the rect. make() subtrees are code — they register themselves with
|
|
345
|
+
// Lightmap.add. Edit mode never bakes or applies (the editor shows real-time shadows).
|
|
346
|
+
|
|
347
|
+
type LightmapCtx = { prefix: string }
|
|
348
|
+
|
|
349
|
+
const movesByPhysics = (def: SceneNodeDef): boolean =>
|
|
350
|
+
(def.aspects ?? []).some((e) =>
|
|
351
|
+
(e.ctor as unknown) === Physics && ((e.props as { motion?: string } | undefined)?.motion ?? "dynamic") !== "static")
|
|
352
|
+
|
|
353
|
+
/** The static verdict for one node def (the editor's "Baked lighting" switch shows the same rule). */
|
|
354
|
+
export const isLightmapStatic = (def: SceneNodeDef): boolean => def.lightmap ?? !movesByPhysics(def)
|
|
355
|
+
|
|
356
|
+
const createSource = (path: string, def: SceneNodeDef, lightmap = false): Node | Promise<Node> => {
|
|
312
357
|
const sources = [ def.mesh, def.model, def.light, def.make, def.prefab, def.camera ].filter((s) => s !== undefined).length
|
|
313
358
|
if (sources > 1) throw new Error(`Scene node "${path}" declares more than one source (mesh/model/light/make/prefab/camera)`)
|
|
314
|
-
if (def.model !== undefined) return Model.load(def.model)
|
|
359
|
+
if (def.model !== undefined) return Model.load(def.model, { lightmap })
|
|
315
360
|
if (def.mesh !== undefined) return createMesh(def.mesh, def.material)
|
|
316
361
|
if (def.light !== undefined) { const { kind: _k, ...opts } = def.light; return Light.sun(opts) }
|
|
317
362
|
// make/prefab/camera/group nodes are plain wrappers — make/prefab subtrees mount under them
|
|
@@ -369,30 +414,23 @@ const isGroupDef = (def: SceneNodeDef): boolean =>
|
|
|
369
414
|
def.mesh === undefined && def.model === undefined && def.light === undefined
|
|
370
415
|
&& def.make === undefined && def.prefab === undefined && def.camera === undefined
|
|
371
416
|
|
|
372
|
-
/** Edit mode:
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
|
|
376
|
-
|
|
417
|
+
/** Edit mode: otherwise-invisible nodes (empties, `camera:` nodes) get an ANCHORED gizmo marker —
|
|
418
|
+
* an axis cross / a frustum in the node's local frame (scene/gizmos.ts) — so they show and pick
|
|
419
|
+
* in the viewport. Never scene content: nothing renders in Filament, nothing outlines, and the
|
|
420
|
+
* engine follows the node live, so the buffer is filled once and lives on the node
|
|
421
|
+
* (`_editorMarker`) — `_editorGizmos()` reads it off the handle's own def nodes, so a removed
|
|
422
|
+
* node's marker goes with its record and prefab / instance internals (not selectable) draw none. */
|
|
423
|
+
const addEditorMarker = (node: Node, def: SceneNodeDef): void => {
|
|
424
|
+
const buffer = new GizmoBuffer()
|
|
377
425
|
if (def.camera !== undefined) {
|
|
378
|
-
|
|
379
|
-
const z = -0.55, w = 0.3, h = 0.21
|
|
380
|
-
const segs: number[] = []
|
|
381
|
-
for (const [ cx, cy ] of [ [ -w, -h ], [ w, -h ], [ w, h ], [ -w, h ] ] as const) segs.push(0, 0, 0, cx, cy, z)
|
|
382
|
-
segs.push(-w, -h, z, w, -h, z, w, -h, z, w, h, z, w, h, z, -w, h, z, -w, h, z, -w, -h, z)
|
|
383
|
-
segs.push(-0.12, h, z, 0, h + 0.14, z, 0, h + 0.14, z, 0.12, h, z)
|
|
384
|
-
marker = edgesMesh(segs, "#cfd4dd")
|
|
385
|
-
;(node as { _sceneMarker?: string })._sceneMarker = "camera"
|
|
426
|
+
withGizmoScope(buffer, () => Gizmos.frustum(def.camera?.fov ?? CAMERA_DEFAULTS.fov, { node, color: "#cfd4dd" }))
|
|
386
427
|
} else if (isGroupDef(def)) {
|
|
387
|
-
|
|
388
|
-
marker = edgesMesh([ -s, 0, 0, s, 0, 0, 0, -s, 0, 0, s, 0, 0, 0, -s, 0, 0, s ], "#8f96a3")
|
|
389
|
-
;(node as { _sceneMarker?: string })._sceneMarker = "empty"
|
|
428
|
+
withGizmoScope(buffer, () => Gizmos.cross([ 0, 0, 0 ], 0.3, { node, color: "#8f96a3" }))
|
|
390
429
|
} else {
|
|
391
430
|
return
|
|
392
431
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
scene.add(marker)
|
|
432
|
+
;(node as { _editorMarker?: GizmoBuffer })._editorMarker = buffer
|
|
433
|
+
gizmoVersion++
|
|
396
434
|
}
|
|
397
435
|
|
|
398
436
|
/** The ACTIVE `CameraPlace` entry in THIS file's own defs (depth-first; prefab / instance internals
|
|
@@ -677,7 +715,7 @@ const prefabPartRows = (defs: Record<string, SceneNodeDef>, local: Record<string
|
|
|
677
715
|
return out
|
|
678
716
|
}
|
|
679
717
|
|
|
680
|
-
const attachPrefab = async (wrapper: Node, path: string, def: SceneNodeDef, scene: Scene, stack: Set<SceneDef
|
|
718
|
+
const attachPrefab = async (wrapper: Node, path: string, def: SceneNodeDef, scene: Scene, stack: Set<SceneDef>, lm: LightmapCtx | null): Promise<void> => {
|
|
681
719
|
const pdef = (def.prefab as { def?: SceneDef } | undefined)?.def
|
|
682
720
|
if (!pdef || typeof pdef !== "object") {
|
|
683
721
|
console.error(`[scene] "${path}".prefab is not a scene handle (import the .scene file's default export)`)
|
|
@@ -691,7 +729,7 @@ const attachPrefab = async (wrapper: Node, path: string, def: SceneNodeDef, scen
|
|
|
691
729
|
}
|
|
692
730
|
const local: Record<string, Node> = {}
|
|
693
731
|
const localRuns: EditorRun[] = [] // discarded: instance internals render, but aren't editor-tracked
|
|
694
|
-
await buildNodes(pdef.nodes ?? {}, wrapper, scene, local, localRuns, new Set(stack).add(pdef))
|
|
732
|
+
await buildNodes(pdef.nodes ?? {}, wrapper, scene, local, localRuns, new Set(stack).add(pdef), lm)
|
|
695
733
|
const rows = prefabPartRows(pdef.nodes ?? {}, local, "", 0)
|
|
696
734
|
;(wrapper as { _prefabParts?: ModelPartRow[] })._prefabParts = rows
|
|
697
735
|
if (def.overrides) applyOverridesToRows(rows, def.overrides)
|
|
@@ -703,6 +741,7 @@ const attachPrefab = async (wrapper: Node, path: string, def: SceneNodeDef, scen
|
|
|
703
741
|
const buildNodes = async (
|
|
704
742
|
defs: Record<string, SceneNodeDef>, parent: Node | null, scene: Scene,
|
|
705
743
|
nodes: Record<string, Node>, editorRuns: EditorRun[], stack: Set<SceneDef>,
|
|
744
|
+
lm: LightmapCtx | null = null,
|
|
706
745
|
): Promise<void> => {
|
|
707
746
|
const pending: { path: string, node: Node, def: SceneNodeDef }[] = []
|
|
708
747
|
|
|
@@ -714,15 +753,17 @@ const buildNodes = async (
|
|
|
714
753
|
// the path is derived from def keys BEFORE any await, so it is deterministic even though
|
|
715
754
|
// Promise.all makes build completion (and record insertion) order nondeterministic
|
|
716
755
|
const path = parentPath === "" ? name : `${parentPath}/${name}`
|
|
717
|
-
const
|
|
756
|
+
const lmStatic = lm !== null && isLightmapStatic(nd)
|
|
757
|
+
const node = await createSource(path, nd, lmStatic)
|
|
718
758
|
// a `mount` def parents to an internal part of the parent asset — safe here: the parent's
|
|
719
759
|
// model was awaited and attachPrefab completed before its children build
|
|
720
760
|
if (parentNode) attachHost(parentNode, nd, path).add(node)
|
|
721
761
|
// Draw-set membership is separate from parenting (see docs/3d/node.md) — every def node joins.
|
|
722
762
|
scene.add(node)
|
|
723
763
|
applyNode(node, name, nd) // engine-side name stays the bare sibling segment
|
|
724
|
-
if (
|
|
725
|
-
if (
|
|
764
|
+
if (lmStatic && (node instanceof Model || node instanceof Mesh)) Lightmap._register(node, lm!.prefix + path)
|
|
765
|
+
if (isEditMode()) addEditorMarker(node, nd)
|
|
766
|
+
if (nd.prefab !== undefined) await attachPrefab(node, path, nd, scene, stack, lmStatic ? { prefix: `${lm!.prefix}${path}/` } : null)
|
|
726
767
|
pending.push({ path, node, def: nd })
|
|
727
768
|
nodes[path] = node
|
|
728
769
|
await Promise.all(Object.entries(nd.children ?? {}).map(([ childName, child ]) => build(childName, child, node, path)))
|
|
@@ -740,9 +781,13 @@ const buildNodes = async (
|
|
|
740
781
|
}
|
|
741
782
|
|
|
742
783
|
const instantiate = async (def: SceneDef, editorRuns: EditorRun[]): Promise<{ scene: Scene, nodes: Record<string, Node> }> => {
|
|
743
|
-
const
|
|
784
|
+
const { lightmap, ...env } = def.env ?? {}
|
|
785
|
+
const scene = new Scene(env)
|
|
744
786
|
const nodes: Record<string, Node> = {}
|
|
745
|
-
|
|
787
|
+
// baked lighting is a play-mode concern; the level owns the registry (a rebuild starts clean)
|
|
788
|
+
const lm: LightmapCtx | null = lightmap && !isEditMode() ? { prefix: "" } : null
|
|
789
|
+
if (lm) Lightmap.clear()
|
|
790
|
+
await buildNodes(def.nodes ?? {}, null, scene, nodes, editorRuns, new Set([ def ]), lm)
|
|
746
791
|
|
|
747
792
|
// a camera NODE wins over the top-level `camera:` block; in play mode it keeps driving the view
|
|
748
793
|
// (CameraRig), in edit mode it only seeds the editor's starting viewpoint. The PROJECTION applies
|
|
@@ -771,6 +816,12 @@ const instantiate = async (def: SceneDef, editorRuns: EditorRun[]): Promise<{ sc
|
|
|
771
816
|
applyCameraProjection(scene, def.camera)
|
|
772
817
|
}
|
|
773
818
|
|
|
819
|
+
// the level is complete: apply the bake — or, under `lecodes lightmap bake`, run it
|
|
820
|
+
if (lm && lightmap) {
|
|
821
|
+
await Lightmap.load(scene, { data: lightmap.data, texture: lightmap.texture },
|
|
822
|
+
{ sunStrength: lightmap.sunStrength, ambientScale: lightmap.ambientScale })
|
|
823
|
+
}
|
|
824
|
+
|
|
774
825
|
return { scene, nodes }
|
|
775
826
|
}
|
|
776
827
|
|
|
@@ -896,7 +947,7 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
|
|
|
896
947
|
if (parent) attachHost(parent, d, p).add(node)
|
|
897
948
|
scene.add(node)
|
|
898
949
|
applyNode(node, p.slice(p.lastIndexOf("/") + 1), d)
|
|
899
|
-
if (isEditMode()) addEditorMarker(node, d
|
|
950
|
+
if (isEditMode()) addEditorMarker(node, d)
|
|
900
951
|
attachAspects(node, p, d, nodes, scene, this._editorRuns)
|
|
901
952
|
nodes[p] = node
|
|
902
953
|
await Promise.all(Object.entries(d.children ?? {}).map(([ cn, cd ]) => build(`${p}/${cn}`, cd, node)))
|
|
@@ -928,6 +979,10 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
|
|
|
928
979
|
const batches: GizmoBatch[] = []
|
|
929
980
|
for (const run of this._editorRuns) for (const b of run.gizmos.batches) if (b.segments.length > 0) batches.push(b)
|
|
930
981
|
for (const runs of foreignRuns.values()) for (const run of runs) for (const b of run.gizmos.batches) if (b.segments.length > 0) batches.push(b)
|
|
982
|
+
for (const node of Object.values(this._live?.nodes ?? {})) {
|
|
983
|
+
const marker = (node as { _editorMarker?: GizmoBuffer })._editorMarker
|
|
984
|
+
if (marker) for (const b of marker.batches) batches.push(b)
|
|
985
|
+
}
|
|
931
986
|
return { version: gizmoVersion, batches }
|
|
932
987
|
}
|
|
933
988
|
|
|
@@ -13,20 +13,33 @@
|
|
|
13
13
|
// Every run starts with an EMPTY buffer — what a call draws is the whole picture (like
|
|
14
14
|
// `generated.clear()`, only implicit). Outside any scope (play mode, hand-attached aspects, a call
|
|
15
15
|
// after an `await` inside rebuild) the calls are no-ops: rebuild is synchronous by contract.
|
|
16
|
+
//
|
|
17
|
+
// ANCHORED gizmos (`{ node }` in the style): points are in that node's LOCAL frame and the engine
|
|
18
|
+
// follows the node live — through a gizmo drag, not only after it — with the node's scale
|
|
19
|
+
// stripped (an empty scaled ×10 for its children keeps a normal-sized cross). An anchored picture
|
|
20
|
+
// is the node's own: clicking it in the viewport selects the node, and it draws in the selection
|
|
21
|
+
// accent while selected. World-space calls (path lines) are never pickable.
|
|
16
22
|
|
|
17
23
|
import { Mat4, type Mat4Like } from "../math/mat4"
|
|
18
24
|
import type { Vec3Like } from "../math/vec"
|
|
19
25
|
|
|
26
|
+
/** The anchor of a gizmo call: any object with an engine entity id (a `Node`). */
|
|
27
|
+
export type GizmoAnchor = { readonly id: number }
|
|
28
|
+
|
|
20
29
|
export type GizmoStyle = {
|
|
21
30
|
/** CSS hex color (`#rgb` / `#rrggbb`); default a neutral light gray. */
|
|
22
31
|
color?: string
|
|
23
32
|
/** 0..1, default 1. */
|
|
24
33
|
alpha?: number
|
|
34
|
+
/** Anchor: points are in this node's LOCAL frame (scale ignored); the lines follow the node
|
|
35
|
+
* live, are pickable (a click selects the node) and turn the selection accent when it is
|
|
36
|
+
* selected. Omit for world-space lines (not pickable). */
|
|
37
|
+
node?: GizmoAnchor | null
|
|
25
38
|
}
|
|
26
39
|
|
|
27
|
-
/** One color batch of
|
|
28
|
-
* the
|
|
29
|
-
export type GizmoBatch = { color: string, alpha: number, segments: number[] }
|
|
40
|
+
/** One color batch of LINES (flat `[x,y,z, x,y,z]` per segment) — what a host hands the engine.
|
|
41
|
+
* With `entityId` the segments are in that entity's local frame (see GizmoStyle.node). */
|
|
42
|
+
export type GizmoBatch = { color: string, alpha: number, segments: number[], entityId?: number }
|
|
30
43
|
|
|
31
44
|
const DEFAULT_COLOR = "#cfd4dd"
|
|
32
45
|
|
|
@@ -44,10 +57,11 @@ export class GizmoBuffer {
|
|
|
44
57
|
segments(style: GizmoStyle | undefined): number[] {
|
|
45
58
|
const color = style?.color ?? DEFAULT_COLOR
|
|
46
59
|
const alpha = Math.max(0, Math.min(1, style?.alpha ?? 1))
|
|
47
|
-
const
|
|
60
|
+
const entityId = style?.node?.id
|
|
61
|
+
const key = `${color}@${alpha}@${entityId ?? ""}`
|
|
48
62
|
let b = this._byStyle.get(key)
|
|
49
63
|
if (!b) {
|
|
50
|
-
b = { color, alpha, segments: [] }
|
|
64
|
+
b = entityId ? { color, alpha, segments: [], entityId } : { color, alpha, segments: [] }
|
|
51
65
|
this._byStyle.set(key, b)
|
|
52
66
|
this.batches.push(b)
|
|
53
67
|
}
|
|
@@ -102,15 +116,21 @@ export const Gizmos = {
|
|
|
102
116
|
for (let i = 0; i < last; i++) out.push(...xyz(points[i]!), ...xyz(points[(i + 1) % n]!))
|
|
103
117
|
},
|
|
104
118
|
|
|
105
|
-
/** A wireframe camera frustum looking down
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
|
|
119
|
+
/** A wireframe camera frustum looking down −Z: the four edges from the origin to a rect
|
|
120
|
+
* `length` metres ahead sized by `fov` (vertical, degrees) × `aspect` (default 16:9), plus an
|
|
121
|
+
* "up" fin above the rect. Anchor it (`{ node }`) for a camera node's marker — the frustum
|
|
122
|
+
* then follows the node's pose; `matrix` places an unanchored one in world space. */
|
|
123
|
+
frustum(fov: number, style?: GizmoStyle & { aspect?: number, length?: number, matrix?: Mat4Like }): void {
|
|
109
124
|
const out = target(style)
|
|
110
125
|
if (!out) return
|
|
111
|
-
const
|
|
126
|
+
const aspect = style?.aspect ?? 16 / 9, length = style?.length ?? 0.6
|
|
127
|
+
const m = style?.matrix ? new Mat4(style.matrix) : null
|
|
112
128
|
const h = Math.tan((fov * Math.PI) / 360) * length, w = h * aspect, z = -length
|
|
113
|
-
const P = (x: number, y: number, zz: number): [number, number, number] => {
|
|
129
|
+
const P = (x: number, y: number, zz: number): [number, number, number] => {
|
|
130
|
+
if (!m) return [ x, y, zz ]
|
|
131
|
+
const v = m.transformPoint([ x, y, zz ])
|
|
132
|
+
return [ v.x, v.y, v.z ]
|
|
133
|
+
}
|
|
114
134
|
const o = P(0, 0, 0)
|
|
115
135
|
const c = [ P(-w, -h, z), P(w, -h, z), P(w, h, z), P(-w, h, z) ]
|
|
116
136
|
for (const q of c) out.push(...o, ...q)
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Materials as data — the `material:` grammar of scene files and the `defineMaterial` document
|
|
2
|
+
// behind `*.material.ts` assets (docs/scene-materials-plan.md).
|
|
3
|
+
//
|
|
4
|
+
// // materials/holo-glass.material.ts
|
|
5
|
+
// export default defineMaterial({
|
|
6
|
+
// shader: asset('../assets/shaders/lens.mat'),
|
|
7
|
+
// params: { tint: '#1d2b26', opacity: 0.22, mask: asset('../assets/weapons/reticle.png') },
|
|
8
|
+
// })
|
|
9
|
+
//
|
|
10
|
+
// // range.scene.ts
|
|
11
|
+
// import holoGlass from './materials/holo-glass.material'
|
|
12
|
+
// lens: { mesh: { kind: 'plane' }, material: holoGlass },
|
|
13
|
+
//
|
|
14
|
+
// One grammar serves the inline form (`material: { lit: {…} }` / `{ shader, params }` on a node),
|
|
15
|
+
// the asset form (the handle) and GLB-part overrides. A handle is ONE shared GPU material: every
|
|
16
|
+
// node using the asset renders the same instance, and editing the asset changes all of them
|
|
17
|
+
// (Unity's rule — two looks are two assets).
|
|
18
|
+
//
|
|
19
|
+
// Parameter values need no shader metadata at runtime: a string is a colour when it starts with
|
|
20
|
+
// `#`, otherwise it is a texture URL (`asset('./x.png')` compiles to one) — the same lowering
|
|
21
|
+
// `Material.set` already applies to colours.
|
|
22
|
+
|
|
23
|
+
import type { ColorInput } from "../core/color"
|
|
24
|
+
import { Material, type LitMaterialOptions, type MaterialColorOptions } from "../gl/Material"
|
|
25
|
+
import { Texture } from "../gl/Texture"
|
|
26
|
+
import type { Node } from "../gl/Node"
|
|
27
|
+
import { EDIT_FLAG } from "./grammar"
|
|
28
|
+
|
|
29
|
+
/** A shader parameter value as scene files write it: number, boolean, `#colour`, texture URL
|
|
30
|
+
* (`asset('./x.png')`), or a numeric vector. */
|
|
31
|
+
export type MaterialParamValue = number | boolean | string | number[]
|
|
32
|
+
|
|
33
|
+
type WithMapUrl<T> = Omit<T, "map"> & {
|
|
34
|
+
/** Base-colour texture — a `Texture`, a `Canvas`, or `asset('./x.png')` (a URL string). */
|
|
35
|
+
map?: LitMaterialOptions["map"] | string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type LitMaterialDef = WithMapUrl<LitMaterialOptions>
|
|
39
|
+
export type UnlitMaterialDef = WithMapUrl<MaterialColorOptions>
|
|
40
|
+
|
|
41
|
+
/** A custom Filament shader (`asset('./x.mat')` — compiled by the platform / `lecodes shaders`)
|
|
42
|
+
* plus its parameter values. */
|
|
43
|
+
export type ShaderMaterialDef = {
|
|
44
|
+
shader: string
|
|
45
|
+
params?: Record<string, MaterialParamValue>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type MaterialDef =
|
|
49
|
+
| { lit: LitMaterialDef }
|
|
50
|
+
| { unlit: UnlitMaterialDef }
|
|
51
|
+
| { shadow: ColorInput }
|
|
52
|
+
| ShaderMaterialDef
|
|
53
|
+
/** A material asset (`import m from './x.material'`) — one shared instance. */
|
|
54
|
+
| MaterialHandle
|
|
55
|
+
/** A code-level Material instance — valid at runtime; the editor shows it read-only. */
|
|
56
|
+
| Material
|
|
57
|
+
|
|
58
|
+
/** The data forms of MaterialDef (everything but a live instance / handle). */
|
|
59
|
+
export type MaterialData = Exclude<MaterialDef, MaterialHandle | Material>
|
|
60
|
+
|
|
61
|
+
const isTextureUrl = (v: unknown): v is string => typeof v === "string" && !v.startsWith("#")
|
|
62
|
+
|
|
63
|
+
/** Push one parameter into a live material. Textures load asynchronously — the returned promise
|
|
64
|
+
* settles once the value is applied (immediately for scalars). */
|
|
65
|
+
export const applyMaterialParam = (material: Material, name: string, value: MaterialParamValue | null): Promise<void> => {
|
|
66
|
+
if (isTextureUrl(value)) {
|
|
67
|
+
return Texture.load(value).then((tex) => { material.set(name, tex) }, (e) => {
|
|
68
|
+
console.warn(`[material] "${name}": ${e instanceof Error ? e.message : String(e)}`)
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
material.set(name, value as never)
|
|
72
|
+
return Promise.resolve()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Build a live Material from a data def. Resolves once every texture it names has loaded. */
|
|
76
|
+
export const createMaterialFromData = async (def: MaterialData): Promise<Material> => {
|
|
77
|
+
if ("shader" in def) {
|
|
78
|
+
const m = await Material.load(def.shader)
|
|
79
|
+
await Promise.all(Object.entries(def.params ?? {}).map(([ k, v ]) => applyMaterialParam(m, k, v)))
|
|
80
|
+
return m
|
|
81
|
+
}
|
|
82
|
+
if ("shadow" in def) return Material.shadow(def.shadow)
|
|
83
|
+
const lit = "lit" in def
|
|
84
|
+
const opts = lit ? def.lit : def.unlit
|
|
85
|
+
const { map, ...rest } = opts
|
|
86
|
+
const m = lit ? Material.lit(rest as LitMaterialOptions) : Material.unlit(rest as MaterialColorOptions)
|
|
87
|
+
if (isTextureUrl(map)) m.map = await Texture.load(map)
|
|
88
|
+
else if (map !== undefined) m.map = map as LitMaterialOptions["map"] as Texture | null
|
|
89
|
+
return m
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A material asset: the default export of a `*.material.ts` file. `load()` yields ONE shared
|
|
94
|
+
* `Material` (cached); scene files hand the handle to `material:` and the loader resolves it.
|
|
95
|
+
* The editor edits the asset live through `setParam` / `reset` — every node the handle was
|
|
96
|
+
* applied to follows (`_users`).
|
|
97
|
+
*/
|
|
98
|
+
export class MaterialHandle {
|
|
99
|
+
def: MaterialData
|
|
100
|
+
private _instance: Material | null = null
|
|
101
|
+
private _loading: Promise<Material> | null = null
|
|
102
|
+
/** Every (node, slot) this handle was applied to through a scene — the editor re-assigns them
|
|
103
|
+
* when the asset's shader changes. */
|
|
104
|
+
readonly _users = new Set<{ node: Node, slot: number }>()
|
|
105
|
+
|
|
106
|
+
constructor(def: MaterialData) {
|
|
107
|
+
this.def = def
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The shared material instance (built on first use). */
|
|
111
|
+
load(): Promise<Material> {
|
|
112
|
+
if (this._instance) return Promise.resolve(this._instance)
|
|
113
|
+
this._loading ??= createMaterialFromData(this.def).then((m) => { this._instance = m; return m })
|
|
114
|
+
return this._loading
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The live instance, if built yet. */
|
|
118
|
+
get material(): Material | null { return this._instance }
|
|
119
|
+
|
|
120
|
+
/** Editor: assign this material to `node`'s `slot`, remembering the user. */
|
|
121
|
+
async _applyTo(node: Node, slot: number): Promise<Material> {
|
|
122
|
+
const m = await this.load()
|
|
123
|
+
node.setMaterial(m, slot)
|
|
124
|
+
this._users.add({ node, slot })
|
|
125
|
+
return m
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Editor: one parameter value, applied live to the shared instance (a texture loads in). */
|
|
129
|
+
setParam(name: string, value: MaterialParamValue | null): void {
|
|
130
|
+
const def = this.def
|
|
131
|
+
if ("shader" in def) {
|
|
132
|
+
def.params ??= {}
|
|
133
|
+
if (value === null) delete def.params[name]
|
|
134
|
+
else def.params[name] = value
|
|
135
|
+
} else if ("lit" in def || "unlit" in def) {
|
|
136
|
+
const opts = ("lit" in def ? def.lit : def.unlit) as Record<string, unknown>
|
|
137
|
+
if (value === null) delete opts[name]
|
|
138
|
+
else opts[name] = value
|
|
139
|
+
}
|
|
140
|
+
const m = this._instance
|
|
141
|
+
if (!m) return
|
|
142
|
+
if (!("shader" in def) && name === "color") { m.color = value as string; return }
|
|
143
|
+
if (!("shader" in def) && name === "map") {
|
|
144
|
+
if (isTextureUrl(value)) void Texture.load(value).then((t) => { m.map = t })
|
|
145
|
+
else m.map = null
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
void applyMaterialParam(m, name, value)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Editor: replace the whole definition (a shader/kind switch) — rebuilds the instance and
|
|
152
|
+
* re-assigns every user. */
|
|
153
|
+
async reset(def: MaterialData): Promise<Material> {
|
|
154
|
+
this.def = def
|
|
155
|
+
this._instance = null
|
|
156
|
+
this._loading = null
|
|
157
|
+
const m = await this.load()
|
|
158
|
+
for (const u of this._users) u.node.setMaterial(m, u.slot)
|
|
159
|
+
return m
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Resolve any `material:` value to a live Material (a handle's shared instance, a data def's
|
|
164
|
+
* fresh one, a code instance as is). */
|
|
165
|
+
export const resolveMaterialDef = (def: MaterialDef): Promise<Material> => {
|
|
166
|
+
if (def instanceof Material) return Promise.resolve(def)
|
|
167
|
+
if (def instanceof MaterialHandle) return def.load()
|
|
168
|
+
return createMaterialFromData(def)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Resolve and assign a `material:` value to one slot of a node (tracking handle users). */
|
|
172
|
+
export const assignMaterialDef = async (node: Node, slot: number, def: MaterialDef): Promise<Material> => {
|
|
173
|
+
if (def instanceof MaterialHandle) return def._applyTo(node, slot)
|
|
174
|
+
const m = await resolveMaterialDef(def)
|
|
175
|
+
node.setMaterial(m, slot)
|
|
176
|
+
return m
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Define a material as data — the default export of a `*.material.ts` file. Import it into scene
|
|
181
|
+
* files (`material: handle`) or code (`const m = await handle.load()`).
|
|
182
|
+
*/
|
|
183
|
+
export const defineMaterial = (def: MaterialData): MaterialHandle => {
|
|
184
|
+
const handle = new MaterialHandle(def)
|
|
185
|
+
const g = globalThis as unknown as { [EDIT_FLAG]?: boolean, __lecodesMaterialHandles?: MaterialHandle[] }
|
|
186
|
+
if (g[EDIT_FLAG]) (g.__lecodesMaterialHandles ??= []).push(handle)
|
|
187
|
+
return handle
|
|
188
|
+
}
|