lecodes-sdk 2.4.5 → 2.4.7

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/package.json CHANGED
@@ -43,7 +43,7 @@
43
43
  "marcidb-embedded": "^0.15.0",
44
44
  "sharp": "^0.35.2"
45
45
  },
46
- "version": "2.4.5",
46
+ "version": "2.4.7",
47
47
  "files": [
48
48
  "src",
49
49
  "dist",
@@ -11,7 +11,7 @@
11
11
  // `?` on a method = an optional slot: a host that leaves it null gets no such JS method, so the SDK's
12
12
  // `typeof` feature-detects stay honest. The `HostApp` interface below adds the slots the runtime
13
13
  // calls that JS never sees (notifications to the host).
14
- import type { bytes, i32 } from "./types"
14
+ import type { bytes, f64, i32 } from "./types"
15
15
 
16
16
  declare global {
17
17
  // ---- World control (run/restart/quit — the platform's `location` analog) ----------------------
@@ -58,6 +58,13 @@ declare global {
58
58
  /** Collect garbage, drain the release queue and report the live owned records per kind
59
59
  * (`{"live":{"tree":n,…},"pending":n}`) — the read behind checks/owned. @custom */
60
60
  ownedStats(): string
61
+ /** Collect garbage and drain the release queue now, logging `[gc] app.gc: <ms>, heap …, next at
62
+ * …, <n> owned resources freed`. `headroomMB` (> 0) puts the next automatic pass at least that
63
+ * many megabytes above the heap the pass left (QuickJS; until that pass). On JavaScriptCore a
64
+ * request (the engine collects on its own schedule, the drain follows: on iOS the line says 0;
65
+ * no headroom); the wasm arm collects only under Bun, in a browser page it is the drain alone.
66
+ * Web Lite has no such member. Backs app.gc. @custom */
67
+ gc?(headroomMB?: f64): void
61
68
  /** Current lifecycle state: "active" | "background". Surfaced as app.state. */
62
69
  state?(): string
63
70
  /** Current connectivity (navigator.onLine semantics — best-effort: false only when the platform
@@ -14,9 +14,11 @@ export type PickBone = { name: string, weight: f32 }
14
14
  * `bones` = its JOINTS_0 / WEIGHTS_0 pairs with weight > 0 (empty = unweighted). */
15
15
  export type PickVertex = { index: u32, bind: [f32, f32, f32], world: [f32, f32, f32], bones: PickBone[] }
16
16
  /** The closest triangle under a screen point (`pickTriangle`). `node` = the mesh node's own entity (0
17
- * when the host has none), `lightmapGroup` / `uv1` only when the node / primitive carry them. */
17
+ * when the host has none), `lightmapOrdinal` = the primitive's index over the file's meshes (the bake's record
18
+ * `<key>@<ordinal>`), `uv1` (the lightmap uv the GPU reads: the bake's packed one when loaded) only when the primitive
19
+ * carries one. */
18
20
  export type TrianglePick = {
19
- entity: u32, node: u32, nodeName: string, lightmapGroup?: i32,
21
+ entity: u32, node: u32, nodeName: string, lightmapOrdinal: u32,
20
22
  mesh: string, primitive: u32, material: string,
21
23
  triangle: u32, backface: boolean, distance: f32,
22
24
  point: [f32, f32, f32], bary: [f32, f32, f32], uv1?: [f32, f32],
@@ -671,23 +673,31 @@ declare global {
671
673
  // JoltPhysics. motionType: 0 static / 1 kinematic / 2 dynamic.
672
674
  /** @c creatorHasPhysics */
673
675
  physicsHasSupport(): boolean
674
- /** Lightmap bake (engines/bake) — a dev-time tool compiled into the desktop host only; `lightmapBake` exists
676
+ /** Lightmap bake (engines/bake) — a dev-time tool compiled into the bake tool lecodes-bake only; `lightmapBake` exists
675
677
  * only where `lightmapHasSupport()` is true. Describes the given static instances (GLB roots / Mesh entities;
676
678
  * `keysJoined` = one key per id, '
677
679
  '-joined), the scene's brightest sun, its point lights (spherical emitters of
678
- * `lightRadius` metres) and its IBL to the bake, which traces them on the NVIDIA card — direct light plus
680
+ * `lightRadius` metres) and its IBL to the bake, which traces them on the GPU (`tracer`: 0 auto = OptiX where an NVIDIA driver
681
+ * with OptiX opens, else Vulkan ray query on any card with hardware ray tracing; 1 OptiX, 2 Vulkan by name) — direct light plus
679
682
  * `bounceRays` Lambertian paths of `bounces` vertices per texel (0 / 0 = direct only) with `patchSamples` next-event
680
683
  * samples over the bright patches of a first direct pass — and writes
681
684
  * `<outDir>/<stem>.bake` + `<stem>-light[_n].ktx2` (irradiance in lux / lightScale, BC6H) + `<stem>-aux[_n].ktx2`
682
685
  * (sun / sky visibility + light direction); `split` adds `<stem>-direct[_n]` / `-indirect[_n]` page sets for the
683
- * SDK's two views; `denoise` runs OptiX's AI denoiser over 0 nothing, 1 the indirect part, 2 + the sky and the lamps, 3 + the sun; `filter` = the texel's reconstruction filter radius in texels (1 = ±1 texel tent, 0.5 = within the texel, 0 = the centre alone);
686
+ * SDK's two views; `denoise` runs the AI denoiser (`denoiser`: 0 auto = OptiX's on an NVIDIA card, else Intel Open Image Denoise; 1 OptiX, 2 OIDN by name —
687
+ * independent of the tracer) over 0 nothing, 1 the indirect part, 2 + the sky and the lamps, 3 + the sun; `filter` = the texel's reconstruction filter radius in texels (1 = ±1 texel tent, 0.5 = within the texel, 0 = the centre alone);
684
688
  * `probeSpacing` > 0 places REFLECTION PROBES on a grid of that many metres and writes `<stem>-probes.ktx2` (a BC6H atlas of octahedral
685
689
  * maps: one column of roughness-level tiles per probe, captured with `probeSize`² cube faces and `probeRays` paths per texel; the scene's
686
690
  * IBL cubemap is what an escaping ray sees; `probeIndoor` keeps only the probes under a roof (an open share of the sphere of at most 15 %) — the
687
691
  * outdoors reflects the sky through the texel's baked sky visibility instead; `volumeSpacing` (metres, 0 = none) bakes THE LIGHT GRID for movers — `<name>.lgrid`, named in the .bake's `volume`: per cell
688
692
  * in free space an ambient cube without the direct sun, links between neighbours a ray connects (what `setAmbientCube`
689
- * is fed from); `minSize` (metres, 0 = off) leaves every instance smaller than that along every axis out of the atlas — an occluder
690
- * only, `"small": true` + its `light` (an ambient cube baked where it stands) in the .bake, applied by lightmapObjectApply; `probeLayout` 0 = ROOMS (indoor points slide to their
693
+ * is fed from); `minSize` (metres, 0 = off) leaves every record (a mesh of an instance) smaller than that along every axis out of the atlas — an occluder
694
+ * only, `"small": true` + its `light` (an ambient cube baked where it stands) in the .bake, applied by lightmapObjectApply; `thinTexels` (0 = off) does the same to every
695
+ * record none of whose lightmap charts is wider than that many texels across its short side — a railing's tubes, a cable, a pipe run: a strip of 1-3 texels around a tube
696
+ * cannot hold the light's change around it and its uv seam shows as a step; `objectSlab` (metres, 0 = one cube an object) gives a long one of
697
+ * those round(length / objectSlab) cubes along the longest axis of its bounds (16 at most), blended per pixel along it; BY HAND, the same way out of
698
+ * the atlas: every record whose key (`<node key>@<ordinal>`) `cubeKeys` matches — an ECMAScript regular expression, searched, case-sensitive ("" = off; one
699
+ * that does not compile fails the bake: "error: bad --cube pattern") — and every record of the entities in `cubeIds` (of `entityIds`: a node's
700
+ * `lightmap: 'cube'`), whatever their size or charts; `probeLayout` 0 = ROOMS (indoor points slide to their
691
701
  * room's middle and merge: one probe per room), 1 = GRID; `probeBox` = 6 floats, min xyz + max xyz, keeps the grid inside that box — null = the
692
702
  * receivers' bounds) and the grid into the .bake; `debugDir` (optional) gets the float layers per page. Synchronous; progress on stdout as
693
703
  * "[lightmap] …" lines, "[lightmap] done" on success. @c creatorHasLightmapBake */
@@ -697,7 +707,7 @@ declare global {
697
707
  sunRays: u32, skyRays: u32, lightRays: u32, lightRadius: f32, sunAngleDeg: f32, bias: f32, seed: u32,
698
708
  bounceRays: u32, bounces: u32, patchSamples: u32, split: boolean, denoise: u32, filter: f32,
699
709
  probeSpacing: f32, probeSize: u32, probeRays: u32, probeIndoor: boolean, probeLayout: u32, probeBox: Float32Array | null, minSize: f32, volumeSpacing: f32, emissiveNits: f32, detailTexels: u32, debugDir?: string,
700
- transmit?: string): boolean
710
+ transmit?: string, tracer?: u32, denoiser?: u32, thinTexels?: f32, objectSlab?: f32, cubeKeys?: string, cubeIds?: Uint32Array): boolean
701
711
  /** Lightmap consumption: the next createGlb takes lightmap.filamat (the material behind that instance id) for its
702
712
  * OPAQUE materials and the masked twin for its MASK materials instead of the ubershader, and keeps TEXCOORD_1;
703
713
  * BLEND (glass) materials always keep the ubershader. 0xFFFFFFFF clears. The same call arms the foliage tier. @custom */
@@ -724,17 +734,22 @@ declare global {
724
734
  * Returns the probe count; an invalid texture id clears. */
725
735
  lightmapProbesSet?(atlasTextureId: u32, oct: u32, levels: u32, perRow: u32, count: u32,
726
736
  lox: f32, loy: f32, loz: f32, sx: f32, sy: f32, sz: f32): u32
727
- /** Bind the light page + the aux page + this instance's rect (uv1 * [sx, sy] + [ox, oy]) on every lightmap-material
728
- * instance of the entity (a GLB instance's renderables, or a Mesh entity's own) and move those renderables to light
729
- * channel 1 alone — a baked static takes nothing from the real-time sun and lamps (channel 0). Returns how many
737
+ /** Bind the light page + the aux page + a RECORD's rect (uv1 * [sx, sy] + [ox, oy]) and move the renderables it went on
738
+ * to light channel 1 alone — a baked static takes nothing from the real-time sun and lamps (channel 0). The bake's
739
+ * unit is the mesh: a GLB instance is one record per primitive (`<key>@<ordinal>` in the .bake); `ordinal` >= 0 binds
740
+ * that primitive (its index over the file's meshes) of every node that draws it, on a material instance of its own;
741
+ * -1 (the default) = every lightmap-material primitive of the entity (a Mesh entity, a terrain). Returns how many
730
742
  * material instances took it (0 = not loaded lightmapped). */
731
- lightmapApply?(entityId: u32, lightTextureId: u32, auxTextureId: u32, sx: f32, sy: f32, ox: f32, oy: f32, group?: i32): u32
743
+ lightmapApply?(entityId: u32, lightTextureId: u32, auxTextureId: u32, sx: f32, sy: f32, ox: f32, oy: f32, ordinal?: i32): u32
732
744
  /** A SMALL STATIC the atlas left out (the bake's `minSize`: `small` + `light` in the .bake): no rect — the light
733
- * baked where it stands goes into its material parameters. `data` = 27 floats: the ambient cube — per side of its
734
- * bounds (+x -x +y -y +z -z) the irradiance rgb in lux and the sky visibility — then probe a, probe b, a's weight
735
- * (65535 = no probe, 65534 = the sky). The textures = any page of the level (the probes' records sit under every
736
- * aux page). Returns the material instances that took it (0 = not loaded lightmapped). @count count=data */
737
- lightmapObjectApply?(entityId: u32, lightTextureId: u32, auxTextureId: u32, data: Float32Array, group?: i32): u32
745
+ * baked where it stands goes into its material parameters. `data` = 7 + 24 n floats: axis (0 x, 1 y, 2 z), from, to
746
+ * (its bounds along the axis), n (slabs, 1..16); then n AMBIENT CUBES, slab by slab from `from` — per side of the slab's
747
+ * box (+x -x +y -y +z -z) the irradiance rgb in lux and the sky visibility; then probe a, probe b, a's weight (65535 =
748
+ * no probe, 65534 = the sky). Any other count is refused. The shader blends the two slabs around a pixel along the
749
+ * axis. The textures = any page of the level (the probes' records sit under every aux page). `ordinal` as
750
+ * `lightmapApply` (a small record of a GLB is one primitive). Returns the material instances that took it (0 = not
751
+ * loaded lightmapped). @count count=data */
752
+ lightmapObjectApply?(entityId: u32, lightTextureId: u32, auxTextureId: u32, data: Float32Array, ordinal?: i32): u32
738
753
  /** BAKED AMBIENT LIGHT for a mover, or anything on a standard or custom LIT shader: every renderable of the GLB
739
754
  * instance (or the Mesh entity) takes its diffuse indirect light from `data`'s ambient cube instead of the scene's
740
755
  * IBL, and the IBL's reflections through the sky visibility. `data` = 25 or 26 floats: six sides (+x -x +y -y +z -z) of
@@ -751,6 +766,12 @@ declare global {
751
766
  * `lightVolumeSample(x, y, z)` = that cube at a point (26 floats, null = no grid or no valid cell near), for tools;
752
767
  * `lightVolumeClear()` drops the grid and every mover's cube. */
753
768
  lightVolumeLoad?(fetchId: bytes): boolean
769
+ /** THE PACKED LIGHTMAP UV1 (`<name>-uv1.bin`, the .bake's `uv1`): the bake packs every file's lightmap charts into a
770
+ * square of their own and its rects map THAT uv1. `lightmapUv1Load(fetchId)` hands the engine the fetched sidecar
771
+ * (false = not one); every GLB a rect is applied to then takes its file's packed uv1 into TEXCOORD_1, once per asset
772
+ * (assets bound before the call are applied again). `lightmapUv1Clear()` drops it: the models get their own uv1 back. */
773
+ lightmapUv1Load?(fetchId: bytes): boolean
774
+ lightmapUv1Clear?(): void
754
775
  lightVolumeTrack?(entityId: u32, on: boolean, fixed?: boolean): void
755
776
  lightVolumeClear?(): void
756
777
  /** @out 26 @out 26 */
@@ -3,8 +3,9 @@
3
3
  // that no Physics aspect moves is a static (keys = node paths, `lightmap: true|false` overrides),
4
4
  // and the runtime applies the bake when the level finishes loading (scene/defineScene.ts).
5
5
  // Hand-built scenes register what never moves with `Lightmap.add`, bake once with
6
- // `lecodes lightmap bake` (the desktop host describes the live scene to creator-bake, which traces it
7
- // on the NVIDIA card and writes the atlas), and `Lightmap.load` applies it at runtime:
6
+ // `lecodes lightmap bake` (the bake tool lecodes-bake — the windowless host with the bake built in —
7
+ // describes the live scene to creator-bake, which traces it on the GPU and writes the atlas), and
8
+ // `Lightmap.load` applies it at runtime:
8
9
  //
9
10
  // const crate = await Model.load(asset('./assets/props/crate.glb'), { lightmap: true })
10
11
  // Lightmap.add(holder) // a Model, a Mesh, or a node holding them
@@ -67,6 +68,11 @@ export type LightmapLoadOptions = {
67
68
  * `{ through, diffuse }` adds the share the fibres SCATTER - the cloth's underside glows like a matte panel and lights
68
69
  * what is under it by the solid angle it fills, with no picture. Dense canvas: `{ through: 0.08, diffuse: 0.25 }`. */
69
70
  transmit?: Record<string, LightmapTransmit>
71
+ /** FOR THE BAKE: CUBES BY NAME — regular expressions (ECMAScript, case-sensitive) searched in every record's key
72
+ * (`<node key>@<ordinal>`: `"SM_pipe"` takes every pipe node, `"@1$"` the second mesh of each): a record that matches
73
+ * leaves the atlas like a small or thin one — an occluder lit by ambient cubes baked where it stands — whatever its size
74
+ * or charts. Added to the CLI's `--cube` patterns (both apply). */
75
+ cube?: string[]
70
76
  }
71
77
 
72
78
  /** one `transmit` entry (LightmapLoadOptions.transmit): the straight share, or both shares */
@@ -98,11 +104,17 @@ export type LightmapFiles = {
98
104
  /** THE LIGHT GRID for movers (`<stem>.lgrid`, `lecodes lightmap bake --volume`): the baked ambient light of every
99
105
  * place a mover can be. Absent = movers keep the scene's IBL. */
100
106
  volume?: string
107
+ /** THE PACKED LIGHTMAP UV1 (`<stem>-uv1.bin`, the .bake's `uv1`): the bake packs every mesh's lightmap charts into a
108
+ * square of their own and its rects map that uv1 — the engine swaps it into the models' TEXCOORD_1 first. Required
109
+ * when the bake names one. */
110
+ uv1?: string
101
111
  }
102
112
 
103
113
  /** The lightmap material's data views (`Lightmap.debug`); "direct" / "indirect" bind a `--split` bake's pages instead. */
104
114
  export type LightmapDebugMode = "off" | "irradiance" | "albedo" | "normal" | "shadingNormal" | "share" | "skyVis" | "atlas" | "direction" | "lux" | "relief" | "direct" | "indirect" | "probe" | "probeMap" | "skyPath" | "probePath"
105
115
  const DENOISE_LEVELS = { none: 0, indirect: 1, "no-sun": 2, all: 3 } as const
116
+ const TRACERS = { auto: 0, optix: 1, vulkan: 2 } as const // CBAKE_TRACER_*
117
+ const DENOISERS = { auto: 0, optix: 1, oidn: 2 } as const // CBAKE_DENOISER_*
106
118
  const DEBUG_MODES: LightmapDebugMode[] = [ "off", "irradiance", "albedo", "normal", "shadingNormal", "share", "skyVis", "atlas", "direction", "lux", "relief", "direct", "indirect", "probe", "probeMap", "skyPath", "probePath" ]
107
119
  const PAGE_VIEWS: Record<number, "direct" | "indirect"> = { 11: "direct", 12: "indirect" }
108
120
 
@@ -125,15 +137,32 @@ export type LightmapProbe = {
125
137
  distance: number
126
138
  }
127
139
 
128
- /** `ground` = a terrain node (built with `Material.terrainLightmap()`): one rect, bound to its chunks by the host */
129
- type Entry = { key: string, model?: Model, mesh?: Mesh, ground?: Node }
140
+ /** `ground` = a terrain node (built with `Material.terrainLightmap()`): one rect, bound to its chunks by the host;
141
+ * `cube` = every record of it bakes as ambient cubes (`LightmapAddOptions.cube`, a scene node's `lightmap: 'cube'`) */
142
+ type Entry = { key: string, model?: Model, mesh?: Mesh, ground?: Node, cube?: boolean }
143
+
144
+ /** `Lightmap.add`'s options */
145
+ export type LightmapAddOptions = {
146
+ /** Bake it as AMBIENT CUBES instead of atlas texels: an occluder (its shadow and bounce stay in the atlas) whose own
147
+ * light is baked where it stands — the way the bake treats small and thin objects (`--min-texels`, `--thin-texels`),
148
+ * here by hand: a pipe run, a fence, a prop whose charts show their seams. A long one takes a slab of cubes per metre
149
+ * (`--object-slab`). Default false. */
150
+ cube?: boolean
151
+ }
130
152
 
131
153
  type BakeConfig = {
132
154
  outDir: string, stem: string, size?: number, texel?: number, pages?: number,
133
155
  sunRays?: number, skyRays?: number, lightRays?: number, lightRadius?: number, sunAngle?: number, bias?: number, seed?: number,
134
156
  bounceRays?: number, bounces?: number, patchSamples?: number, split?: boolean,
135
- /** OptiX's AI denoiser: `"no-sun"` (the default) the bounce, the sky and the lamps, `"indirect"` the bounce alone, `"all"` + the sun (its shadow edges too), `"none"`. */
157
+ /** The AI denoiser (see `denoiser`) over: `"no-sun"` (the default) the bounce, the sky and the lamps, `"indirect"` the bounce alone, `"all"` + the sun (its shadow edges too), `"none"`. */
136
158
  denoise?: "none" | "indirect" | "no-sun" | "all",
159
+ /** Which GPU API traces: `"auto"` (the default) = OptiX where an NVIDIA driver with OptiX opens, else Vulkan ray query — any card
160
+ * with hardware ray tracing (RTX, Radeon RX 6000+, Arc) with its driver alone; `"optix"` / `"vulkan"` by name (the bake fails
161
+ * when it cannot open). */
162
+ tracer?: "auto" | "optix" | "vulkan",
163
+ /** Which AI denoiser `denoise` runs: `"auto"` (the default) = OptiX's on an NVIDIA card, else Intel Open Image Denoise (its CUDA /
164
+ * HIP / CPU device); `"optix"` / `"oidn"` by name. Independent of the tracer. One that cannot open is a warning: the pages stay as traced. */
165
+ denoiser?: "auto" | "optix" | "oidn",
137
166
  /** The texel's reconstruction filter radius in texels: 1 (the default) = a ±1 texel tent, 0.5 = within the texel, 0 = the centre alone. */
138
167
  filter?: number,
139
168
  /** Reflection probes: the grid spacing in metres (4; 0 = none), a probe face's edge in texels (32) and the paths per probe texel (128). */
@@ -153,6 +182,19 @@ type BakeConfig = {
153
182
  * how many texels it gets (a fire extinguisher of 0.55 m: 300 charts, the largest 29 texels - every chart edge a step).
154
183
  * 0 = everything bakes. Default 12. */
155
184
  minTexels?: number,
185
+ /** ROUND AND NARROW objects leave the atlas the same way: an instance none of whose lightmap charts is wider than this
186
+ * many texels across its short side (at `texel`) — a railing's tubes, a cable, a pipe run, blinds. A strip of 1-3 texels
187
+ * around a tube cannot hold the light's change around it and its uv seam shows as a step; the ambient cube baked where
188
+ * it stands is smooth. A flat object has a wide chart somewhere and stays. 0 = off. Default 4 (20 cm at 5 cm texels). */
189
+ thinTexels?: number,
190
+ /** A LONG object the atlas left out takes its light in SLABS of this many metres along the longest axis of its bounds
191
+ * (round(length / objectSlab), 16 at most) — a 6 m pipe run, a railing along a wall: one end under a lamp, the other in
192
+ * the dark, where one cube gave all of it the mean. The shader blends the two slabs around each pixel along that axis.
193
+ * 0 = one cube an object. Default 1. */
194
+ objectSlab?: number,
195
+ /** CUBES BY NAME (the CLI's `--cube`): regular expressions over the records' keys, joined with the level's own
196
+ * (`LightmapLoadOptions.cube`) into one — each wrapped in `(?: )`, `|` between. */
197
+ cube?: string[],
156
198
  /** THE LIGHT GRID for movers: the cell size in metres (0.75 of it tall); 0 = none. Default 1. */
157
199
  volumeSpacing?: number,
158
200
  /** A SMALL SURFACE GETS A FINER TEXEL: an instance whose surface is under this many atlas texels (area / texel²) is laid
@@ -170,9 +212,12 @@ type BakeConfig = {
170
212
  debugDir?: string,
171
213
  }
172
214
 
173
- /** a small static's light where it stands (creator-bake objects.h): per side of its bounds (+x -x +y -y +z -z) the
174
- * irradiance rgb in lux + the sky visibility; probes = a, b, a's weight (-1 = none, -2 = the sky) */
175
- type BakeObjectLight = { at: number[], cube: number[][], probes: number[] }
215
+ /** a small static's light where it stands (creator-bake objects.h): its SLABS along `axis` (0 x, 1 y, 2 z; its bounds run
216
+ * from `from` to `to` along it) — one for a small one, round(length / objectSlab) for a long one — and per slab per side
217
+ * of its box (+x -x +y -y +z -z) the irradiance rgb in lux + the sky visibility; probes = a, b, a's weight (-1 = none,
218
+ * -2 = the sky) */
219
+ type BakeObjectLight = { at: number[], axis: number, from: number, to: number, cubes: number[][][], probes: number[] }
220
+ /** a RECORD of the .bake: a registered node's key, or `<key>@<ordinal>` for one mesh of a model (creator-bake atlas.h) */
176
221
  type BakeInstance = { key: string, receiver: boolean, small?: boolean, light?: BakeObjectLight, page?: number, rect?: number[], st?: number[] }
177
222
  type BakeProbes = {
178
223
  file: string, size: number, levels: number, count: number, oct: number, tile: number[], perRow: number, atlas: number[],
@@ -189,6 +234,8 @@ type BakeData = {
189
234
  probes?: BakeProbes | null,
190
235
  /** the light grid (creator-bake volume.h): its file and lattice */
191
236
  volume?: { file: string, n: number[], origin: number[], cell: number[], valid: number } | null,
237
+ /** the packed lightmap uv1 the rects map (creator-bake atlas.h), null = the models' own */
238
+ uv1?: string | null,
192
239
  sun?: { direction: number[], color: number[], lux: number } | null, sunMap?: boolean, sunMapLevels?: number, mips?: number, emissiveNits?: number,
193
240
  instances: BakeInstance[],
194
241
  }
@@ -225,16 +272,17 @@ export class Lightmap {
225
272
  * both receivers and occluders in the bake (a model without lightmap UVs, or with materials the lightmap shader
226
273
  * declines — alpha cards — is an occluder alone; a terrain is ONE rect, built with `Material.terrainLightmap()`).
227
274
  * `key` names the entry in the .bake (default: the node's name + a running number, `container#3`); pass one when
228
- * names are not stable. */
229
- static add(node: Node, key?: string): Node {
275
+ * names are not stable. `{ cube: true }` bakes it as ambient cubes instead of atlas texels (`LightmapAddOptions`). */
276
+ static add(node: Node, key?: string, options: LightmapAddOptions = {}): Node {
230
277
  const base = key ?? `${node.name || "node"}#${Lightmap.next(node.name || "node")}`
278
+ const cube = options.cube === true
231
279
  const found: Entry[] = []
232
280
  const walk = (n: Node): void => {
233
281
  // the node `Terrain.create` returned carries its terrain (Terrain.of reads the same field): the host keeps the
234
282
  // terrain's triangles under this node's id for the bake and binds the rect to its chunks
235
- if ((n as unknown as { _terrain?: unknown })._terrain) { found.push({ key: base, ground: n }); return }
236
- if (n instanceof Model) { found.push({ key: base, model: n }); return }
237
- if (n instanceof Mesh) { found.push({ key: base, mesh: n }); return }
283
+ if ((n as unknown as { _terrain?: unknown })._terrain) { found.push({ key: base, ground: n, cube }); return }
284
+ if (n instanceof Model) { found.push({ key: base, model: n, cube }); return }
285
+ if (n instanceof Mesh) { found.push({ key: base, mesh: n, cube }); return }
238
286
  for (const c of n.children) walk(c)
239
287
  }
240
288
  walk(node)
@@ -244,13 +292,13 @@ export class Lightmap {
244
292
  return node
245
293
  }
246
294
 
247
- /** @internal Scene-file runtime: one model/mesh node under its path key (no subtree walk). */
248
- static _register(node: Model | Mesh, key: string): void {
249
- Lightmap.entries.push(node instanceof Model ? { key, model: node } : { key, mesh: node })
295
+ /** @internal Scene-file runtime: one model/mesh node under its path key (no subtree walk); `cube` = `lightmap: 'cube'`. */
296
+ static _register(node: Model | Mesh, key: string, cube = false): void {
297
+ Lightmap.entries.push(node instanceof Model ? { key, model: node, cube } : { key, mesh: node, cube })
250
298
  }
251
299
  /** @internal Scene-file runtime: a terrain node - a receiver with one rect (the host keeps its triangles for the bake). */
252
- static _registerGround(node: Node, key: string): void {
253
- Lightmap.entries.push({ key, ground: node })
300
+ static _registerGround(node: Node, key: string, cube = false): void {
301
+ Lightmap.entries.push({ key, ground: node, cube })
254
302
  }
255
303
 
256
304
  private static next(name: string): number {
@@ -308,12 +356,31 @@ export class Lightmap {
308
356
  console.log(`[lightmap] light grid ${data.volume.n.join(" x ")} cells of ${data.volume.cell.map((v) => v.toFixed(2)).join(" x ")} m, ${Lightmap.movers.size} mover(s)`)
309
357
  }
310
358
 
359
+ /** THE PACKED UV1 the bake's rects map (the .bake's `uv1`), into the host BEFORE any rect is applied: the host swaps it
360
+ * into the models' TEXCOORD_1 as their rects go on. A bake without one clears the last (the models' own uv1 back).
361
+ * False = the bake needs it and it cannot be had - its rects would read the wrong texels. */
362
+ private static async loadUv1(data: BakeData, files: LightmapFiles): Promise<boolean> {
363
+ if (!data.uv1) { _creator.lightmapUv1Clear?.(); return true }
364
+ if (!files.uv1) { console.warn(`Lightmap: the bake packed the models' lightmap charts (${data.uv1}) but the scene does not list it — add uv1: asset('…/${data.uv1}') to env.lightmap; the bake is not applied until then`); return false }
365
+ if (!_creator.lightmapUv1Load) { console.warn("Lightmap: this host cannot take the bake's packed lightmap uv1 (rebuild it) — the bake is not applied"); return false }
366
+ try {
367
+ const resp = await fetch(files.uv1, { useOnce: true })
368
+ if (resp.status >= 400) { console.warn(`Lightmap: no packed uv1 at ${files.uv1} (HTTP ${resp.status}) — the bake is not applied`); return false }
369
+ if (!_creator.lightmapUv1Load((resp as unknown as { _h: Handle })._h.id)) { console.warn(`Lightmap: ${files.uv1} is not a packed uv1 — rebake`); return false }
370
+ } catch (e) {
371
+ console.warn(`Lightmap: could not read ${files.uv1}: ${String(e)}`)
372
+ return false
373
+ }
374
+ return true
375
+ }
376
+
311
377
  /** Forget every registration (a scene rebuild). */
312
378
  static clear(): void {
313
379
  for (const l of Light._points) l._holdDark(false)
314
380
  Lightmap.movers.clear()
315
381
  Lightmap.volumeOn = false
316
382
  _creator.lightVolumeClear?.()
383
+ _creator.lightmapUv1Clear?.()
317
384
  Lightmap.entries = []
318
385
  Lightmap.ordinals.clear()
319
386
  Lightmap.bound = null
@@ -350,7 +417,7 @@ export class Lightmap {
350
417
  static async load(scene: Scene, files: LightmapFiles, options: LightmapLoadOptions = {}): Promise<LightmapInfo | null> {
351
418
  Lightmap.scene = scene
352
419
  const bake = bakeConfig()
353
- if (bake) { Lightmap.bake(bake, options.emissiveNits, options.emissiveBake ?? true, options.transmit); return null }
420
+ if (bake) { Lightmap.bake(bake, options.emissiveNits, options.emissiveBake ?? true, options.transmit, options.cube); return null }
354
421
  if (!_creator.lightmapApply) {
355
422
  if (!Lightmap.warned) { Lightmap.warned = true; console.warn("Lightmap: this host has no lightmap support — rendering without the bake") }
356
423
  return null
@@ -369,6 +436,7 @@ export class Lightmap {
369
436
  return null
370
437
  }
371
438
  if (!data.pages || !(data.lightScale > 0)) return null // the CLI's placeholder: nothing baked yet
439
+ if (!(await Lightmap.loadUv1(data, files))) return null // the rects map the packed uv1: it goes in first
372
440
  const pages = Math.max(1, data.pages)
373
441
  const lightFiles = list(files.light), auxFiles = list(files.aux)
374
442
  if (lightFiles.length === 0 || auxFiles.length === 0) { console.warn("Lightmap: the scene passes no `light` / `aux` atlas"); return null }
@@ -410,9 +478,15 @@ export class Lightmap {
410
478
  const declined = new Set<string>()
411
479
  for (const inst of data.instances) if (!inst.receiver && !inst.small) declined.add(inst.key)
412
480
  const probeId = (k: number) => k === -2 ? 65534 : k < 0 ? 65535 : k
413
- // A GLB UNWRAPPED IN GROUPS (`assets doctor --lightmap-uv --lightmap-groups n`): the bake keys its parts `<key>#k`, a rect
414
- // (or a small static's cube) each, and the host binds part k to the nodes that carry extras.lightmapGroup = k
415
- const parts = Lightmap.groupsOf(data.instances)
481
+ // lightmapObjectApply's numbers: axis, from, to, n, then n slabs x six sides x (rgb, sky), then probe a, probe b, a's weight
482
+ const objectData = (l: BakeObjectLight) =>
483
+ Float32Array.from([ l.axis, l.from, l.to, l.cubes.length, ...l.cubes.flat(2), probeId(l.probes[0]), probeId(l.probes[1]), l.probes[2] ])
484
+ // a bake from before the slabs (`light.cube`, one cube): not read — its small statics stay unlit until it is rebaked
485
+ const lit = (l?: BakeObjectLight): l is BakeObjectLight => !!l && Array.isArray(l.cubes)
486
+ if (data.instances.some((i) => i.small && i.light && !lit(i.light))) console.warn(`Lightmap: ${files.data} has small statics without \`light.cubes\` (baked before the slabbed object light) — they stay unlit; rebake with \`lecodes lightmap bake\``)
487
+ // THE BAKE'S UNIT IS THE MESH: a model's primitives are records of their own, `<key>@<ordinal>` (ordinal = the primitive's
488
+ // index over the file's meshes) - a rect, or a small static's light, each; the host binds record k to that primitive
489
+ const parts = Lightmap.recordsOf(data.instances)
416
490
  let smallLit = 0
417
491
  const byEntity = new Map<number, Entry>()
418
492
  let applied = 0
@@ -420,19 +494,20 @@ export class Lightmap {
420
494
  for (const e of Lightmap.entries) {
421
495
  const node = e.model ?? e.mesh ?? e.ground!
422
496
  byEntity.set(node.id, e)
423
- const groups = parts.get(e.key)
424
- if (groups) {
425
- let took = 0
426
- for (const [ k, inst ] of groups) {
427
- if (inst.small && inst.light && _creator.lightmapObjectApply && n > 0) {
428
- const l = inst.light
429
- took += _creator.lightmapObjectApply(node.id, light[0]._h.id, aux[0]._h.id, Float32Array.from([ ...l.cube.flat(), probeId(l.probes[0]), probeId(l.probes[1]), l.probes[2] ]), k)
430
- } else if (inst.receiver && inst.st && inst.page !== undefined && inst.page < n) {
431
- took += _creator.lightmapApply(node.id, light[inst.page]._h.id, aux[inst.page]._h.id, inst.st[0], inst.st[1], inst.st[2], inst.st[3], k)
497
+ const records = parts.get(e.key)
498
+ if (records) {
499
+ let took = 0, receivers = 0
500
+ for (const [ k, inst ] of records) {
501
+ if (inst.small) {
502
+ if (lit(inst.light) && _creator.lightmapObjectApply && n > 0 && _creator.lightmapObjectApply(node.id, light[0]._h.id, aux[0]._h.id, objectData(inst.light), k) > 0) { took++; smallLit++ }
503
+ } else if (inst.receiver) {
504
+ receivers++
505
+ if (!inst.st || inst.page === undefined || inst.page >= n) { missing.push(inst.key); continue }
506
+ if (_creator.lightmapApply(node.id, light[inst.page]._h.id, aux[inst.page]._h.id, inst.st[0], inst.st[1], inst.st[2], inst.st[3], k) > 0) took++
432
507
  }
433
508
  }
434
- if (took === 0) console.warn(`Lightmap: ${e.key} (${groups.size} groups) was not loaded lightmapped, or the host binds no groups (rebuild it)`)
435
- else applied++
509
+ if (took > 0) applied++
510
+ else if (receivers > 0) console.warn(`Lightmap: ${e.key} (${records.size} mesh record(s)) took no rect — not loaded lightmapped (Model.load { lightmap: true }), or the host binds no records (rebuild it)`)
436
511
  continue
437
512
  }
438
513
  const r = rects.get(e.key)
@@ -440,10 +515,9 @@ export class Lightmap {
440
515
  if (tiny) {
441
516
  // no rect: the light baked where it stands (any page's samplers — the probes' records sit under every aux page)
442
517
  const l = tiny.light
443
- if (!l || !_creator.lightmapObjectApply || n === 0) continue
518
+ if (!lit(l) || !_creator.lightmapObjectApply || n === 0) continue
444
519
  if (e.mesh) e.mesh.material = Lightmap.meshMaterial(e.mesh)
445
- const d = Float32Array.from([ ...l.cube.flat(), probeId(l.probes[0]), probeId(l.probes[1]), l.probes[2] ])
446
- if (_creator.lightmapObjectApply(node.id, light[0]._h.id, aux[0]._h.id, d) > 0) smallLit++
520
+ if (_creator.lightmapObjectApply(node.id, light[0]._h.id, aux[0]._h.id, objectData(l)) > 0) smallLit++
447
521
  continue
448
522
  }
449
523
  if (!r && declined.has(e.key)) continue // glass / a declined material: the bake knows it as an occluder - the light grid lights it (loadVolume)
@@ -455,19 +529,19 @@ export class Lightmap {
455
529
  if (took === 0) { console.warn(`Lightmap: ${e.key} was not loaded lightmapped (Model.load { lightmap: true } / Material.lightmap())`); continue }
456
530
  applied++
457
531
  }
458
- if (small.size) console.log(`[lightmap] ${small.size} small static(s) are not in the atlas (the bake's minTexels): ${smallLit} lit by the light baked where they stand`)
459
- 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`)
532
+ if (small.size) console.log(`[lightmap] ${small.size} small, thin or cube-marked record(s) are not in the atlas (the bake's minTexels / thinTexels / cube): ${smallLit} lit by the light baked where they stand`)
533
+ if (missing.length) console.warn(`Lightmap: ${missing.length} static(s) or mesh record(s) have no rect in the bake (${missing.slice(0, 5).join(", ")}${missing.length > 5 ? "…" : ""}) — rebake`)
460
534
  Lightmap.bound = { data, rects, byEntity, files, pages: n, aux, light, probes, probeAtlas, showing: "light" }
461
535
  await Lightmap.loadVolume(data, files)
462
536
  return { size: data.size, texel: data.texel, pages: n, applied, total: rects.size, missing }
463
537
  }
464
538
 
465
- /** The bake's `<key>#k` instances by their base key: the parts of a GLB unwrapped in groups (k = extras.lightmapGroup). */
466
- private static groupsOf(instances: BakeInstance[]): Map<string, Map<number, BakeInstance>> {
539
+ /** The bake's `<key>@k` records by their node key: a model's meshes (k = the primitive's ordinal in its file). */
540
+ private static recordsOf(instances: BakeInstance[]): Map<string, Map<number, BakeInstance>> {
467
541
  const out = new Map<string, Map<number, BakeInstance>>()
468
542
  const registered = new Set(Lightmap.entries.map((e) => e.key))
469
543
  for (const inst of instances) {
470
- const m = /^(.*)#(\d+)$/.exec(inst.key)
544
+ const m = /^(.*)@(\d+)$/.exec(inst.key)
471
545
  if (!m || !registered.has(m[1])) continue
472
546
  let g = out.get(m[1])
473
547
  if (!g) { g = new Map(); out.set(m[1], g) }
@@ -502,7 +576,7 @@ export class Lightmap {
502
576
  b[kind] = tex
503
577
  }
504
578
  b.showing = kind
505
- const parts = Lightmap.groupsOf(b.data.instances)
579
+ const parts = Lightmap.recordsOf(b.data.instances)
506
580
  for (const [ id, e ] of b.byEntity) {
507
581
  for (const [ k, inst ] of parts.get(e.key) ?? []) {
508
582
  if (inst.receiver && inst.st && inst.page !== undefined && inst.page < b.pages) _creator.lightmapApply(id, tex[inst.page]._h.id, b.aux[inst.page]._h.id, inst.st[0], inst.st[1], inst.st[2], inst.st[3], k)
@@ -553,7 +627,7 @@ export class Lightmap {
553
627
  static probe(screenX: number, screenY: number): LightmapProbe | null {
554
628
  const b = Lightmap.bound
555
629
  if (!b || !_creator.pickTriangle) return null
556
- type Hit = { entity: number, nodeName: string, material: string, point: number[], distance: number, uv1?: [number, number], lightmapGroup?: number }
630
+ type Hit = { entity: number, nodeName: string, material: string, point: number[], distance: number, uv1?: [number, number], lightmapOrdinal?: number }
557
631
  // the nearest hit over the registered statics: one pick per entry (the all-instances pick, entity 0, walks
558
632
  // every GLB instance including the off-scene templates and is what a scene file's clones confuse)
559
633
  let hit: Hit | null = null
@@ -564,7 +638,7 @@ export class Lightmap {
564
638
  if (!hit) { console.log(`[probe] nothing baked under ${screenX.toFixed(0)},${screenY.toFixed(0)}`); return null }
565
639
  const entry = b.byEntity.get(hit.entity)
566
640
  if (!entry) { console.log(`[probe] hit entity ${hit.entity} node "${hit.nodeName}" at ${hit.point.map((c) => c.toFixed(2)).join(",")} is not a registered static`); return null }
567
- const hitKey = hit.lightmapGroup !== undefined && b.rects.has(`${entry.key}#${hit.lightmapGroup}`) ? `${entry.key}#${hit.lightmapGroup}` : entry.key
641
+ const hitKey = hit.lightmapOrdinal !== undefined && b.rects.has(`${entry.key}@${hit.lightmapOrdinal}`) ? `${entry.key}@${hit.lightmapOrdinal}` : entry.key
568
642
  const inst = b.rects.get(hitKey)
569
643
  if (!inst || !inst.st) { console.log(`[probe] ${hitKey}: no rect in the bake`); return null }
570
644
  if (!hit.uv1) { console.log(`[probe] ${entry.key}: the hit primitive carries no TEXCOORD_1`); return null }
@@ -593,10 +667,14 @@ export class Lightmap {
593
667
  return m
594
668
  }
595
669
 
596
- private static bake(cfg: BakeConfig, levelNits?: number, emissiveBake = true, transmit?: Record<string, LightmapTransmit>): void {
597
- if (!_creator.lightmapBake) { console.error("[lightmap] error: this host has no bake module (build the desktop host with the CUDA toolkit, or update lecodes desktop)"); return }
598
- const ids = Uint32Array.from(Lightmap.entries.map((e) => (e.model ?? e.mesh ?? e.ground!).id))
670
+ private static bake(cfg: BakeConfig, levelNits?: number, emissiveBake = true, transmit?: Record<string, LightmapTransmit>, levelCube?: string[]): void {
671
+ if (!_creator.lightmapBake) { console.error("[lightmap] error: this host has no bake module (the bake runs in lecodes-bake: LECODES_BAKE_EXE names a host without it)"); return }
672
+ const idOf = (e: Entry): number => (e.model ?? e.mesh ?? e.ground!).id
673
+ const ids = Uint32Array.from(Lightmap.entries.map(idOf))
599
674
  const keys = Lightmap.entries.map((e) => e.key).join("\n")
675
+ // the statics baked as cubes: by hand per node (`cube`), and by key - the CLI's --cube and the level's env.lightmap.cube, one regex
676
+ const cubeIds = Uint32Array.from(Lightmap.entries.filter((e) => e.cube).map(idOf))
677
+ const cubeKeys = [ ...(cfg.cube ?? []), ...(levelCube ?? []) ].filter((p) => p !== "").map((p) => `(?:${p})`).join("|")
600
678
  if (ids.length === 0) { console.error("[lightmap] error: nothing registered — call Lightmap.add on the static props before Lightmap.load"); return }
601
679
  // a `baked: false` light is held dark for the bake — the engine bakes every lit point light it finds
602
680
  for (const l of Light._points) l._holdDark(false) // a bake started over a loaded level
@@ -609,7 +687,8 @@ export class Lightmap {
609
687
  cfg.sunRays ?? 64, cfg.skyRays ?? 2048, cfg.lightRays ?? 64, cfg.lightRadius ?? 0.05, cfg.sunAngle ?? 0.53, cfg.bias ?? 0.01, cfg.seed ?? 1,
610
688
  cfg.bounceRays ?? 128, cfg.bounces ?? 3, cfg.patchSamples ?? 64, cfg.split ?? false, DENOISE_LEVELS[cfg.denoise ?? "no-sun"], cfg.filter ?? 1,
611
689
  cfg.probeSpacing ?? 4, cfg.probeSize ?? 32, cfg.probeRays ?? 128, cfg.probeIndoor ?? false, cfg.probeLayout === "grid" ? 1 : 0, cfg.probeBox ? Float32Array.from(cfg.probeBox) : null, (cfg.minTexels ?? 12) * (cfg.texel ?? 0.05), cfg.volumeSpacing ?? 1, emissiveBake ? emissiveNits : -emissiveNits, cfg.detailTexels ?? 2000, cfg.debugDir ?? "",
612
- Object.entries(transmit ?? {}).map(([name, share]) => typeof share === "number" ? `${name}=${share}` : `${name}=${share.through ?? 0},${share.diffuse ?? 0}`).join(";"))
690
+ Object.entries(transmit ?? {}).map(([name, share]) => typeof share === "number" ? `${name}=${share}` : `${name}=${share.through ?? 0},${share.diffuse ?? 0}`).join(";"),
691
+ TRACERS[cfg.tracer ?? "auto"] ?? 0, DENOISERS[cfg.denoiser ?? "auto"] ?? 0, cfg.thinTexels ?? 4, cfg.objectSlab ?? 1, cubeKeys, cubeIds)
613
692
  for (const l of live) _creator.setLightIntensity?.(l.id, l.intensity)
614
693
  }
615
694
  }
package/src/gl/Model.ts CHANGED
@@ -20,8 +20,8 @@ export interface TrianglePick {
20
20
  mesh: string
21
21
  primitive: number
22
22
  material: string
23
- /** the node's lightmap group (scene-file extras), when it has one */
24
- lightmapGroup?: number
23
+ /** the primitive's index over its file's meshes: the lightmap bake's record `<key>@<ordinal>` */
24
+ lightmapOrdinal: number
25
25
  /** triangle index within the primitive (index-buffer order), and whether the ray came from behind */
26
26
  triangle: number
27
27
  backface: boolean
@@ -130,8 +130,8 @@ export class Model extends Node {
130
130
  culling?: boolean
131
131
  onProgress?: (p: { loaded: number, total?: number }) => void
132
132
  /** Baked lighting (engines/bake). `true` = a STATIC: loads through the lightmap material so
133
- * `Lightmap.load` can bind its atlas rect (the GLB needs TEXCOORD_1 — `lecodes assets doctor
134
- * --lightmap-uv`) and takes nothing from the real-time lights once the bake applies. Omitted / `false` =
133
+ * `Lightmap.load` can bind its atlas rect (the GLB needs TEXCOORD_1 —
134
+ * `lecodes assets unwrap`, which `lightmap bake` runs itself on raw GLBs) and takes nothing from the real-time lights once the bake applies. Omitted / `false` =
135
135
  * the standard shader, lit real-time (movers). Hosts without lightmap support ignore it. */
136
136
  lightmap?: boolean
137
137
  /** Vegetation: load through the FOLIAGE tier (see `Foliage`) — wind, touch bending, distance fade,
package/src/math/vec.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // Fluent 2D / 3D vectors — the SDK's user-facing vector math (no gl-matrix, no out-params, no imports).
2
2
  //
3
- // Design (see PLAN.md): a vector is a *mutable little struct* — `v.x = 3`, `v.set(x, y, z)` mutate in
3
+ // Design: a vector is a *mutable little struct* — `v.x = 3`, `v.set(x, y, z)` mutate in
4
4
  // place — but every *method* is PURE: `a.add(b)` returns a NEW vector and never touches `a`. So chains
5
5
  // (`dir.normalize().scale(s)`) can't clobber their source, and the immutable-method style is the most
6
6
  // amenable to a future escape-analysis / scalar-replacement compiler pass. Backing is plain number
@@ -113,6 +113,20 @@ export const app = {
113
113
  quit(): void {
114
114
  _creatorApp.quit?.()
115
115
  },
116
+ /** Collect garbage now: what the app no longer references — a finished level's nodes, its
117
+ * textures and materials — is freed before this returns, not whenever the collector next runs.
118
+ * Call it where a pause is invisible (a loading screen, between levels), after dropping the
119
+ * references; never during play — a collection walks the whole heap. It also puts the next
120
+ * automatic collection at half again the heap it leaves: the room the next stretch of play has.
121
+ * `headroomMB` asks for more room when the play ahead allocates more than that — a drive that
122
+ * streams in hundreds of megabytes of terrain buffers: `app.gc({ headroomMB: 512 })` keeps the
123
+ * next collection at least 512 MB away (until it comes; then the engine's rule again). The log's
124
+ * `[gc]` line says what it took, what it freed and where the next one is. On iOS a request the
125
+ * engine serves on its own schedule, without the headroom; a no-op in the browser, where a page
126
+ * cannot ask for a collection. */
127
+ gc(options?: { headroomMB?: number }): void {
128
+ _creatorApp.gc?.(options?.headroomMB)
129
+ },
116
130
  /** Global back-press fallback (Android back button/gesture — iOS has no system back). Fires
117
131
  * only when nothing else consumed the press: no open widget/modal claimed it, the current
118
132
  * screen has no `onBack`, every pager is at its tab root and the router is at its