lecodes-cli 0.18.0 → 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 +1222 -293
- 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 +38 -3
- package/runtime/sdk/compile/index.ts +15 -0
- package/runtime/sdk/compile/sceneEditor.ts +11 -1
- package/runtime/sdk/compile/shaderSchema.ts +202 -0
- package/runtime/sdk/compile/shaderTargets.ts +42 -3
- 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 +1 -1
- package/runtime/sdk/gl/CameraPlace.ts +4 -3
- 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 +148 -5
- package/runtime/sdk/gl/Shape.ts +42 -3
- package/runtime/sdk/gl/Trigger.ts +1 -6
- package/runtime/sdk/gl/Vehicle.ts +276 -322
- package/runtime/sdk/gl/Wheel.ts +240 -0
- package/runtime/sdk/gl/scenarios.ts +291 -317
- package/runtime/sdk/inject.ts +186 -171
- package/runtime/sdk/scene/defineScene.ts +1227 -1172
- package/runtime/sdk/scene/gizmos.ts +148 -128
- package/runtime/sdk/scene/material.ts +188 -0
- package/runtime/sdk-types.json +1 -1
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
|
+
}
|
|
@@ -58,6 +58,12 @@ export type ParticlesMaterialOptions = {
|
|
|
58
58
|
blend?: "alpha" | "add"
|
|
59
59
|
/** Soft radial falloff toward the sprite edge. Defaults to true without a map, false with one. */
|
|
60
60
|
soft?: boolean
|
|
61
|
+
/** Soft particles: fade each sprite out over this many metres as it cuts into the scene geometry
|
|
62
|
+
* behind it (Unity's "Soft Particles Factor"), so smoke and dust no longer slice through the
|
|
63
|
+
* ground and walls with a hard edge. 0 = off (default). Reads the scene's depth buffer, which the
|
|
64
|
+
* engine keeps bound while any soft system is on screen — the extra depth pass is only paid then.
|
|
65
|
+
* Filament hosts (desktop / web / Android / Apple); web-lite draws such sprites unfaded. */
|
|
66
|
+
depthFade?: number
|
|
61
67
|
/** How each particle is drawn: `'point'` (default — GPU point sprites, cheapest), `'quad'`
|
|
62
68
|
* (real camera-facing quads — no driver size cap, geometric rotation), or `'stretch'`
|
|
63
69
|
* (quads stretched along velocity — sparks, rain, speed streaks). `'ribbon'` is what `Trail`
|
|
@@ -169,6 +175,7 @@ export class Material {
|
|
|
169
175
|
m.uniforms.emissive = options.emissive ?? 0
|
|
170
176
|
m.uniforms.additive = options.blend === "add" ? 1 : 0
|
|
171
177
|
m.uniforms.softness = (options.soft ?? !options.map) ? 1 : 0
|
|
178
|
+
m.uniforms.depthFade = options.depthFade ?? 0
|
|
172
179
|
if (quad) {
|
|
173
180
|
m.uniforms.mode = options.render === "ribbon" ? 2 : options.render === "stretch" ? 1 : 0
|
|
174
181
|
m.uniforms.stretch = options.stretch ?? (options.render === "stretch" ? 0.05 : 0)
|
|
@@ -188,6 +195,24 @@ export class Material {
|
|
|
188
195
|
return m
|
|
189
196
|
}
|
|
190
197
|
|
|
198
|
+
/** The lightmap material (docs/lightmap-plan.md): PBR base colour × a baked shadow/AO atlas on UV1.
|
|
199
|
+
* Models take it through `Model.load(…, { lightmap: true })`; `Lightmap.load` builds one per static
|
|
200
|
+
* Mesh. Parameters use gltfio's names (`baseColorFactor`, `baseColorMap`, `roughnessFactor`,
|
|
201
|
+
* `metallicFactor`) plus `lightmap`, `lightmapST`, `ambientScale`, `sunStrength` (the sun / IBL terms come from
|
|
202
|
+
* filament's per-frame uniforms inside the shader). */
|
|
203
|
+
static lightmap(): Material {
|
|
204
|
+
// Inline literal: the compiler's preload header captures the shader name from it.
|
|
205
|
+
const m = new Material({ _id: _creatorUtils.fetchLocal("lightmap.filamat") } as any)
|
|
206
|
+
m.set("baseColorFactor", "#ffffffff").set("roughnessFactor", 1).set("metallicFactor", 0)
|
|
207
|
+
m.set("lightmapST", [ 1, 1, 0, 0 ]).set("ambientScale", 1).set("sunStrength", 1).set("hasNormalMap", 0)
|
|
208
|
+
return m
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** @internal the shared template instance `Model.load({ lightmap })` hands the engine (its MATERIAL is what
|
|
212
|
+
* the GLB provider instantiates; the template itself never draws). */
|
|
213
|
+
static _lightmapTemplate(): Material { return (Material._lmTemplate ??= Material.lightmap()) }
|
|
214
|
+
private static _lmTemplate: Material | undefined
|
|
215
|
+
|
|
191
216
|
/** Shadow-catcher material (transparent except where shadows fall). */
|
|
192
217
|
static shadow(color: ColorInput = "#000000aa"): Material {
|
|
193
218
|
const m = new Material({ _id: _creatorUtils.fetchLocal("shadow.filamat") } as any)
|
package/runtime/sdk/gl/Mesh.ts
CHANGED
|
@@ -24,7 +24,6 @@ export type MeshOptions = {
|
|
|
24
24
|
|
|
25
25
|
export class Mesh extends Node {
|
|
26
26
|
private _geometry?: Geometry
|
|
27
|
-
private _materials: Material[] = []
|
|
28
27
|
|
|
29
28
|
constructor(geometry?: Geometry, material?: Material) {
|
|
30
29
|
super()
|
|
@@ -32,29 +31,15 @@ export class Mesh extends Node {
|
|
|
32
31
|
const mat = material ?? Material.unlit()
|
|
33
32
|
this._geometry = geometry
|
|
34
33
|
this._materials = [ mat ]
|
|
35
|
-
_creator.setMesh(this.id, Material.idOf(mat), geometry.vertices, geometry.normals, geometry.indices, geometry.uv, geometry._meshKind)
|
|
34
|
+
_creator.setMesh(this.id, Material.idOf(mat), geometry.vertices, geometry.normals, geometry.indices, geometry.uv, geometry._meshKind, geometry.uv1)
|
|
36
35
|
}
|
|
37
36
|
}
|
|
38
37
|
|
|
39
38
|
override get geometry(): Geometry | undefined { return this._geometry }
|
|
40
39
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
setMaterial(material: Material, index = 0): this {
|
|
45
|
-
this._materials[index] = material
|
|
46
|
-
_creator.setMaterial(this.id, Material.idOf(material), index)
|
|
47
|
-
return this
|
|
48
|
-
}
|
|
49
|
-
getMaterial(index = 0): Material | null {
|
|
50
|
-
const existing = this._materials[index]
|
|
51
|
-
if (existing) return existing
|
|
52
|
-
const id = _creator.getMaterial(this.id, index)
|
|
53
|
-
if (id === 0) return null
|
|
54
|
-
const mat = new Material(id)
|
|
55
|
-
this._materials[index] = mat
|
|
56
|
-
return mat
|
|
57
|
-
}
|
|
40
|
+
/** A Mesh always carries a material (slot 0) — see Node.setMaterial for the slot API. */
|
|
41
|
+
override get material(): Material { return this._materials![0]! }
|
|
42
|
+
override set material(m: Material) { this.setMaterial(m, 0) }
|
|
58
43
|
|
|
59
44
|
set culling(v: boolean) { _creator.setCulling(this.id, v) }
|
|
60
45
|
set castShadows(v: boolean) { _creator.setCastShadows(this.id, v) }
|
|
@@ -63,10 +48,8 @@ export class Mesh extends Node {
|
|
|
63
48
|
// --- factories ---
|
|
64
49
|
|
|
65
50
|
static box(options: MeshOptions & { size?: Vec3Like | number } = {}): Mesh {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (sx !== 1 || sy !== 1 || sz !== 1) geo.scale(sx, sy, sz)
|
|
69
|
-
return apply(new Mesh(geo, options.material), options)
|
|
51
|
+
// the size goes into the geometry (not a scale after): the lightmap chart is laid out per face area
|
|
52
|
+
return apply(new Mesh(Geometry.box(options.size ?? 1), options.material), options)
|
|
70
53
|
}
|
|
71
54
|
|
|
72
55
|
static sphere(options: MeshOptions & SphereOptions = {}): Mesh {
|
package/runtime/sdk/gl/Model.ts
CHANGED
|
@@ -7,11 +7,14 @@
|
|
|
7
7
|
import { fetch, type FetchResponse } from "../runtime/fetch"
|
|
8
8
|
import { Node } from "./Node"
|
|
9
9
|
import { Animator } from "./animation/Animator"
|
|
10
|
+
import { Material } from "./Material"
|
|
10
11
|
|
|
11
12
|
export class Model extends Node {
|
|
12
13
|
/** The model's Animator — always present, its clip table = the GLB's embedded clips. Configure
|
|
13
14
|
* more (external clips, blend spaces, layers) with `model.aspect(Animator, {...})`. */
|
|
14
15
|
declare readonly anim: Animator
|
|
16
|
+
/** @internal loaded through the lightmap material (docs/lightmap-plan.md); clones inherit it. */
|
|
17
|
+
_lightmapped = false
|
|
15
18
|
|
|
16
19
|
constructor(internalId: number) {
|
|
17
20
|
super(internalId)
|
|
@@ -24,13 +27,21 @@ export class Model extends Node {
|
|
|
24
27
|
clone(): Model {
|
|
25
28
|
const id = _creator.cloneEntity(this.id)
|
|
26
29
|
_creator.setGlbCulling(id, false)
|
|
27
|
-
|
|
30
|
+
const m = new Model(id)
|
|
31
|
+
m._lightmapped = this._lightmapped
|
|
32
|
+
return m
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
/** Load a GLB model. Returns its root as a Model; play its baked clips via model.anim. */
|
|
31
36
|
static load(
|
|
32
37
|
source: string | FetchResponse,
|
|
33
|
-
options: {
|
|
38
|
+
options: {
|
|
39
|
+
culling?: boolean
|
|
40
|
+
onProgress?: (p: { loaded: number, total?: number }) => void
|
|
41
|
+
/** Load through the lightmap material so `Lightmap.load` can bind a baked atlas (the GLB needs
|
|
42
|
+
* TEXCOORD_1 — `lecodes assets doctor --lightmap-uv`). Hosts without lightmap support ignore it. */
|
|
43
|
+
lightmap?: boolean
|
|
44
|
+
} = {},
|
|
34
45
|
): Promise<Model> {
|
|
35
46
|
if (typeof source === "string") {
|
|
36
47
|
return fetch(source, { useOnce: true, onProgress: options.onProgress }).then((resp) => {
|
|
@@ -39,9 +50,12 @@ export class Model extends Node {
|
|
|
39
50
|
})
|
|
40
51
|
}
|
|
41
52
|
return new Promise<Model>((resolve, reject) => {
|
|
53
|
+
const lightmapped = !!options.lightmap && !!_creator.setNextGlbLightmapped
|
|
54
|
+
if (lightmapped) _creator.setNextGlbLightmapped!(Material.idOf(Material._lightmapTemplate()))
|
|
42
55
|
_creator.createGlb((source as unknown as { _id: number })._id, (entityId: number) => {
|
|
43
56
|
if (entityId === 0) { reject(new Error("Failed to load GLB")); return }
|
|
44
57
|
const model = new Model(entityId)
|
|
58
|
+
model._lightmapped = lightmapped
|
|
45
59
|
if (options.culling !== true) _creator.setGlbCulling(entityId, false)
|
|
46
60
|
resolve(model)
|
|
47
61
|
}, reject)
|
package/runtime/sdk/gl/Node.ts
CHANGED
|
@@ -13,7 +13,8 @@ import { Mat4, type Mat4Like } from "../math/mat4"
|
|
|
13
13
|
import type { Geometry } from "./Geometry"
|
|
14
14
|
import { registerTouchEndEvent, registerTouchStartEvent } from "./touch"
|
|
15
15
|
import { ensurePhysicsEvents } from "./physicsEvents"
|
|
16
|
-
import {
|
|
16
|
+
import { Material } from "./Material"
|
|
17
|
+
import type { CompAxis, CompWriter } from "../core/compWrite"
|
|
17
18
|
import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
|
|
18
19
|
|
|
19
20
|
/** id → Node, so host callbacks (touch hits, animation events) route back to the owning object. */
|
|
@@ -49,7 +50,10 @@ const findNodeByWalk = (rootId: number, name: string): number => {
|
|
|
49
50
|
return exact || loose
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
// Scratch for the world-position read-back in _setOwnedPosition (one per module, never handed out).
|
|
54
|
+
const worldScratch = new Float32Array(3)
|
|
55
|
+
|
|
56
|
+
export class Node extends AspectHost<NodeEvents> implements CompWriter {
|
|
53
57
|
/** Native entity handle. */
|
|
54
58
|
readonly id: number
|
|
55
59
|
|
|
@@ -58,11 +62,10 @@ export class Node extends AspectHost<NodeEvents> {
|
|
|
58
62
|
isTracked = false
|
|
59
63
|
|
|
60
64
|
private _matrix?: Float32Array
|
|
61
|
-
private _lastSync = 0
|
|
62
|
-
private _syncFrame = -1
|
|
63
65
|
private _worldMatrix?: Float32Array
|
|
64
|
-
private _scaleCache?: [number, number, number]
|
|
65
66
|
private _boneCache?: Map<string, Node | null>
|
|
67
|
+
/** Materials assigned through setMaterial, by primitive slot (a Mesh fills slot 0 itself). */
|
|
68
|
+
protected _materials?: Material[]
|
|
66
69
|
|
|
67
70
|
constructor(internalId?: number) {
|
|
68
71
|
super()
|
|
@@ -80,29 +83,31 @@ export class Node extends AspectHost<NodeEvents> {
|
|
|
80
83
|
set visible(value: boolean) { _creator.setVisible(this.id, value) }
|
|
81
84
|
|
|
82
85
|
// --- local transform ---
|
|
83
|
-
// Native owns the authoritative matrix
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
86
|
+
// Native owns the authoritative matrix. `_sync()` reads it into a per-node scratch buffer on EVERY
|
|
87
|
+
// call: a bridge read is ~40 ns on QuickJS — what the Date.now() of a staleness check cost — so a
|
|
88
|
+
// cache bought nothing and needed invalidation hooks (physics / controllers / animation rewrite
|
|
89
|
+
// transforms natively between frames). The PUBLIC `matrix` getter hands back a fresh Mat4 *copy*
|
|
90
|
+
// (value semantics — mutating it never touches the node until you assign it back); `_sync()` is
|
|
91
|
+
// the internal hot path for component reads.
|
|
87
92
|
private _sync(): Float32Array {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (this._lastSync === 0 || this._syncFrame !== glState.frame || Date.now() - this._lastSync > 10) {
|
|
92
|
-
_creator.getMatrix(this.id, this._matrix)
|
|
93
|
-
this._scaleCache = undefined
|
|
94
|
-
this._lastSync = Date.now()
|
|
95
|
-
this._syncFrame = glState.frame
|
|
96
|
-
}
|
|
97
|
-
return this._matrix
|
|
93
|
+
const m = this._matrix ?? (this._matrix = new Float32Array(16))
|
|
94
|
+
_creator.getMatrix(this.id, m)
|
|
95
|
+
return m
|
|
98
96
|
}
|
|
99
97
|
|
|
100
98
|
get matrix(): Mat4 { return new Mat4(this._sync()) }
|
|
101
99
|
set matrix(m: Mat4Like) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
this.
|
|
105
|
-
|
|
100
|
+
const buf = this._matrix ?? (this._matrix = new Float32Array(16))
|
|
101
|
+
buf.set(matArr(m))
|
|
102
|
+
_creator.setMatrix(this.id, buf)
|
|
103
|
+
// A matrix write on a physics-owned node must move the engine object too, or it is silently
|
|
104
|
+
// overwritten by the next sync. Position always; rotation only for a BODY (see _setOwnedRotation).
|
|
105
|
+
// Scale is never routed — a Jolt shape is built at a fixed size, that is `Shape`'s job.
|
|
106
|
+
// `worldMatrix` composes into this setter, so it is covered here too.
|
|
107
|
+
if (this._xf) {
|
|
108
|
+
this._setOwnedPosition(buf[12], buf[13], buf[14])
|
|
109
|
+
this._setOwnedRotation()
|
|
110
|
+
}
|
|
106
111
|
}
|
|
107
112
|
|
|
108
113
|
get worldMatrix(): Mat4 {
|
|
@@ -120,39 +125,86 @@ export class Node extends AspectHost<NodeEvents> {
|
|
|
120
125
|
this.matrix = p.worldMatrix.invert().mul(m)
|
|
121
126
|
}
|
|
122
127
|
|
|
128
|
+
/** @internal The engine object that mirrors this node's transform, if any — a physics body or a
|
|
129
|
+
* character (0 = none). `position` writes are routed to it as well, because the engine is the
|
|
130
|
+
* authority: on a dynamic body a plain setPosition is overwritten by the next sync, and on a
|
|
131
|
+
* static / pick / trigger body it would leave the COLLIDER behind while the node moved. Set by
|
|
132
|
+
* Shape (bodies) and CharacterController. */
|
|
133
|
+
_xf = 0
|
|
134
|
+
/** @internal What `_xf` is: 1 = character, 2 = physics body. */
|
|
135
|
+
_xfKind = 0
|
|
136
|
+
|
|
137
|
+
// Route a position write to the engine object that owns this node. Kept out of the setters' hot path
|
|
138
|
+
// (they only pay one truthiness check). `position` stays a LOCAL coordinate like on every node, while
|
|
139
|
+
// physics works in world space — so the node is written first and the world point read back natively
|
|
140
|
+
// (one bridge call for any parent depth, no allocation; the native transform manager updates the
|
|
141
|
+
// world matrix synchronously).
|
|
142
|
+
private _setOwnedPosition(x: number, y: number, z: number): void {
|
|
143
|
+
_creator.setPosition(this.id, x, y, z) // keep the node itself right, so a read-back is immediate
|
|
144
|
+
_creator.getWorldPosition(this.id, worldScratch)
|
|
145
|
+
if (this._xfKind === 1) _creator.characterSetPosition(this._xf, worldScratch[0], worldScratch[1], worldScratch[2])
|
|
146
|
+
else _creator.physicsSetBodyPosition(this._xf, worldScratch[0], worldScratch[1], worldScratch[2])
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// The rotation half of the same routing. BODIES ONLY: a character's rotation is node-owned by design
|
|
150
|
+
// (the engine writes its position and nothing else — a capsule is symmetric about its up axis, so its
|
|
151
|
+
// yaw is physically meaningless), and pushing one into Jolt would invent an authority that isn't there.
|
|
152
|
+
// The node has already been written by the caller, so the world rotation is read back from it — that
|
|
153
|
+
// way the engine's own euler convention decides, instead of this file re-deriving it.
|
|
154
|
+
private _setOwnedRotation(): void {
|
|
155
|
+
if (this._xfKind !== 2 || !_creator.physicsSetBodyRotation) return
|
|
156
|
+
const q = this.worldMatrix.rotation
|
|
157
|
+
_creator.physicsSetBodyRotation(this._xf, q.x, q.y, q.z, q.w)
|
|
158
|
+
}
|
|
159
|
+
|
|
123
160
|
get position(): Vec3 { const m = this._sync(); return new Vec3(m[12], m[13], m[14]) }
|
|
124
|
-
set position(v: Vec3Like) {
|
|
161
|
+
set position(v: Vec3Like) {
|
|
162
|
+
if (this._xf) this._setOwnedPosition(cx(v), cy(v), cz(v))
|
|
163
|
+
else _creator.setPosition(this.id, cx(v), cy(v), cz(v))
|
|
164
|
+
}
|
|
125
165
|
|
|
126
|
-
// Scalar position accessors —
|
|
127
|
-
//
|
|
166
|
+
// Scalar position accessors — one axis without touching the others (`node.x = 3`). The direct spelling
|
|
167
|
+
// `node.position.x = 3` compiles to the same thing (chisel's comp_write pass routes it through
|
|
168
|
+
// `_writeComp` below); only a STORED copy of `position` is still a copy.
|
|
128
169
|
get x(): number { return this._sync()[12] }
|
|
129
|
-
set x(v: number) { const m = this._sync(); this.
|
|
170
|
+
set x(v: number) { const m = this._sync(); if (this._xf) this._setOwnedPosition(v, m[13], m[14]); else _creator.setPosition(this.id, v, m[13], m[14]) }
|
|
130
171
|
get y(): number { return this._sync()[13] }
|
|
131
|
-
set y(v: number) { const m = this._sync(); this.
|
|
172
|
+
set y(v: number) { const m = this._sync(); if (this._xf) this._setOwnedPosition(m[12], v, m[14]); else _creator.setPosition(this.id, m[12], v, m[14]) }
|
|
132
173
|
get z(): number { return this._sync()[14] }
|
|
133
|
-
set z(v: number) { const m = this._sync(); this.
|
|
174
|
+
set z(v: number) { const m = this._sync(); if (this._xf) this._setOwnedPosition(m[12], m[13], v); else _creator.setPosition(this.id, m[12], m[13], v) }
|
|
175
|
+
|
|
176
|
+
/** @internal chisel `comp_write` (see core/compWrite.ts): `node.position.<axis> = v` is compiled to a
|
|
177
|
+
* call here; the scalar setters already carry the physics routing. The list is compile-time data. */
|
|
178
|
+
static _comps = [ "position" ]
|
|
179
|
+
_writeComp(_prop: string, axis: CompAxis, v: number): void {
|
|
180
|
+
if (axis === "x") this.x = v
|
|
181
|
+
else if (axis === "y") this.y = v
|
|
182
|
+
else if (axis === "z") this.z = v
|
|
183
|
+
}
|
|
134
184
|
|
|
135
185
|
get scale(): Vec3 {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
this._scaleCache = [ Math.hypot(m[0], m[1], m[2]), Math.hypot(m[4], m[5], m[6]), Math.hypot(m[8], m[9], m[10]) ]
|
|
139
|
-
}
|
|
140
|
-
return new Vec3(this._scaleCache)
|
|
186
|
+
const m = this._sync()
|
|
187
|
+
return new Vec3(Math.hypot(m[0], m[1], m[2]), Math.hypot(m[4], m[5], m[6]), Math.hypot(m[8], m[9], m[10]))
|
|
141
188
|
}
|
|
142
189
|
set scale(v: Vec3Like | number) {
|
|
143
|
-
this._lastSync = 0
|
|
144
190
|
if (typeof v === "number") _creator.setScale(this.id, v, v, v)
|
|
145
191
|
else _creator.setScale(this.id, cx(v), cy(v), cz(v))
|
|
146
192
|
}
|
|
147
193
|
|
|
148
194
|
get quaternion(): Quat { return new Mat4(this._sync()).rotation }
|
|
149
|
-
set quaternion(v: QuatLike) {
|
|
195
|
+
set quaternion(v: QuatLike) {
|
|
196
|
+
_creator.setQuaternion(this.id, cx(v), cy(v), cz(v), cw(v))
|
|
197
|
+
if (this._xf) this._setOwnedRotation()
|
|
198
|
+
}
|
|
150
199
|
|
|
151
200
|
get eulerAngles(): Vec3 { return new Mat4(this._sync()).eulerAngles }
|
|
152
201
|
// order 1 = YXZ — the SDK's euler convention (math/quat.ts); the getter also extracts YXZ, so
|
|
153
202
|
// the pair round-trips. (Historically this passed 0/XYZ AND the engine stored the euler matrix
|
|
154
203
|
// transposed — the setter applied the INVERSE rotation. Both fixed 2026-07-10.)
|
|
155
|
-
set eulerAngles(v: Vec3Like) {
|
|
204
|
+
set eulerAngles(v: Vec3Like) {
|
|
205
|
+
_creator.setEulerAngles(this.id, cx(v), cy(v), cz(v), 1)
|
|
206
|
+
if (this._xf) this._setOwnedRotation()
|
|
207
|
+
}
|
|
156
208
|
|
|
157
209
|
// --- world-space reads ---
|
|
158
210
|
get forward(): Vec3 {
|
|
@@ -190,13 +242,29 @@ export class Node extends AspectHost<NodeEvents> {
|
|
|
190
242
|
setParent(parent: Node | null, worldPositionStays = false): this {
|
|
191
243
|
if (parent === null) _creator.setParentNull(this.id, worldPositionStays)
|
|
192
244
|
else _creator.setParent(this.id, parent.id, worldPositionStays)
|
|
193
|
-
if (worldPositionStays) this._lastSync = 0
|
|
194
245
|
return this
|
|
195
246
|
}
|
|
196
247
|
traverse(callback: (node: Node) => void): void {
|
|
197
248
|
callback(this)
|
|
198
249
|
_creator.traverse(this.id, (id) => callback(nodeRegistry.get(id) ?? new Node(id)))
|
|
199
250
|
}
|
|
251
|
+
// --- materials (any renderable: a Mesh, or an internal node of a loaded Model) ---
|
|
252
|
+
/** The material assigned to slot 0 through this API (null before one is set — a GLB part's own
|
|
253
|
+
* glTF material stays native-side). */
|
|
254
|
+
get material(): Material | null { return this.getMaterial(0) }
|
|
255
|
+
set material(m: Material) { this.setMaterial(m, 0) }
|
|
256
|
+
/** Replace the material of one primitive slot (`index` = the primitive's order in the glTF
|
|
257
|
+
* mesh; a primitive-shape Mesh has one slot). */
|
|
258
|
+
setMaterial(material: Material, index = 0): this {
|
|
259
|
+
(this._materials ??= [])[index] = material
|
|
260
|
+
_creator.setMaterial(this.id, Material.idOf(material), index)
|
|
261
|
+
return this
|
|
262
|
+
}
|
|
263
|
+
/** The material previously assigned to `index` (null when none was — see Mesh for the probe). */
|
|
264
|
+
getMaterial(index = 0): Material | null {
|
|
265
|
+
return this._materials?.[index] ?? null
|
|
266
|
+
}
|
|
267
|
+
|
|
200
268
|
/** Find a descendant by name — bones of a loaded Model included (`hero.bone('RightHand').add(sword)`).
|
|
201
269
|
* Same rule the Animator binds clips with: exact name first, then the part after the last `:` / `|`
|
|
202
270
|
* (Mixamo `mixamorig:Hips` matches `Hips`); a skinned joint beats a plain node of the same name.
|
|
@@ -260,8 +328,20 @@ export class Node extends AspectHost<NodeEvents> {
|
|
|
260
328
|
_emitCollision(channel: "enter" | "exit", other: Node): void { this.dispatch(channel, other) }
|
|
261
329
|
|
|
262
330
|
|
|
331
|
+
/** Destroy this node and its whole subtree. Every aspect in the subtree is detached first (its
|
|
332
|
+
* `onDetach` runs: physics bodies released, updaters unregistered, listeners dropped) — native
|
|
333
|
+
* `destroyEntity` frees the entity tree but knows nothing about JS-side aspects, and a destroyed
|
|
334
|
+
* node's `update()` must not keep ticking. Deepest nodes go first, then this one. */
|
|
263
335
|
destroy(): void {
|
|
264
|
-
|
|
336
|
+
const subtree: Node[] = [this]
|
|
337
|
+
_creator.traverse(this.id, (id) => { const n = nodeRegistry.get(id); if (n) subtree.push(n) })
|
|
338
|
+
for (let i = subtree.length - 1; i >= 0; i--) {
|
|
339
|
+
const n = subtree[i]
|
|
340
|
+
n._detachAll()
|
|
341
|
+
n._xf = 0
|
|
342
|
+
n._xfKind = 0
|
|
343
|
+
nodeRegistry.delete(n.id)
|
|
344
|
+
}
|
|
265
345
|
_creator.destroyEntity(this.id)
|
|
266
346
|
}
|
|
267
347
|
}
|