lecodes-sdk 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/global.d.ts +18 -4
- package/dist/inject.js +260 -361
- package/dist/types/animate/tween/Animation.d.ts +69 -0
- package/dist/types/animate/tween/Timeline.d.ts +55 -0
- package/dist/types/animate/tween/animateValue.d.ts +27 -0
- package/dist/types/animate/tween/easing.d.ts +29 -0
- package/dist/types/animate/tween/spec.d.ts +178 -0
- package/dist/types/g2/Node2D.d.ts +16 -0
- package/dist/types/g2/Sprite.d.ts +11 -1
- package/dist/types/gl/Camera.d.ts +15 -1
- package/dist/types/gl/Foliage.d.ts +47 -0
- package/dist/types/gl/Geometry.d.ts +24 -0
- package/dist/types/gl/Light.d.ts +28 -7
- package/dist/types/gl/Lightmap.d.ts +90 -60
- package/dist/types/gl/Material.d.ts +28 -20
- package/dist/types/gl/Model.d.ts +7 -5
- package/dist/types/gl/Node.d.ts +18 -0
- package/dist/types/gl/Particles.d.ts +55 -1
- package/dist/types/gl/Scene.d.ts +20 -0
- package/dist/types/gl/animation/AnimationClip.d.ts +19 -0
- package/dist/types/gl/animation/Animator.d.ts +27 -0
- package/dist/types/gl/animation/DynamicBone.d.ts +19 -8
- package/dist/types/gl/animation/IK.d.ts +86 -30
- package/dist/types/gl/animation/Locomotion.d.ts +52 -3
- package/dist/types/gl/animation/Warp.d.ts +2 -1
- package/dist/types/gl/animation/core.d.ts +35 -4
- package/dist/types/gl/physics/Ragdoll.d.ts +87 -12
- package/dist/types/gl/terrain/Terrain.d.ts +12 -2
- package/dist/types/inject.d.ts +8 -2
- package/dist/types/runtime/input.d.ts +7 -0
- package/dist/types/scene/defineScene.d.ts +44 -32
- package/dist/types/ui/UIButton.d.ts +3 -1
- package/dist/types/ui/UIInput.d.ts +5 -1
- package/dist/types/ui/UINode.d.ts +24 -24
- package/dist/types.json +1 -1
- package/package.json +1 -1
- package/prompts/README.md +142 -142
- package/prompts/core-design.md +27 -4
- package/prompts/core.md +35 -6
- package/prompts/dist/2d-game.md +197 -408
- package/prompts/dist/3d-app.md +166 -491
- package/prompts/dist/ar-app.md +163 -373
- package/prompts/dist/design.md +87 -83
- package/prompts/dist/ui-app.md +136 -325
- package/prompts/select.ts +19 -4
- package/src/animate/tween/Animation.ts +378 -0
- package/src/animate/tween/Timeline.ts +175 -0
- package/src/animate/tween/animateValue.ts +100 -0
- package/src/animate/tween/easing.ts +172 -0
- package/src/animate/tween/spec.ts +479 -0
- package/src/audio/audio.ts +161 -161
- package/src/bridges.d.ts +235 -65
- package/src/compile/__tests__/assetMacro.test.ts +26 -0
- package/src/compile/__tests__/detectEntry.test.ts +19 -0
- package/src/compile/__tests__/serverSplit.test.ts +27 -0
- package/src/compile/bundler.ts +34 -4
- package/src/compile/compileProject.ts +31 -1
- package/src/compile/detectEntry.ts +8 -3
- package/src/compile/index.ts +2 -0
- package/src/compile/serverSplit.ts +9 -3
- package/src/g2/Node2D.ts +38 -0
- package/src/g2/Sprite.ts +20 -1
- package/src/gl/Camera.ts +34 -1
- package/src/gl/CameraPlace.ts +52 -52
- package/src/gl/Foliage.ts +102 -0
- package/src/gl/Geometry.ts +393 -348
- package/src/gl/Light.ts +49 -16
- package/src/gl/Lightmap.ts +439 -275
- package/src/gl/Material.ts +59 -47
- package/src/gl/Mesh.ts +120 -120
- package/src/gl/Model.ts +23 -12
- package/src/gl/Node.ts +39 -0
- package/src/gl/Particles.ts +80 -2
- package/src/gl/Scene.ts +34 -1
- package/src/gl/animation/AnimationClip.ts +52 -0
- package/src/gl/animation/Animator.ts +42 -2
- package/src/gl/animation/DynamicBone.ts +35 -12
- package/src/gl/animation/IK.ts +173 -152
- package/src/gl/animation/Locomotion.ts +72 -8
- package/src/gl/animation/Playback.ts +5 -4
- package/src/gl/animation/Warp.ts +5 -2
- package/src/gl/animation/core.ts +65 -4
- package/src/gl/physics/Ragdoll.ts +451 -272
- package/src/gl/scenarios.ts +291 -291
- package/src/gl/terrain/Terrain.ts +33 -2
- package/src/inject.ts +236 -226
- package/src/runtime/input.ts +11 -0
- package/src/scene/defineScene.ts +72 -62
- package/src/scene/gizmos.ts +148 -148
- package/src/ui/UIButton.ts +2 -2
- package/src/ui/UIInput.ts +3 -3
- package/src/ui/UINode.ts +61 -36
- package/dist/types/animate/animate.d.ts +0 -20
- package/src/animate/animate.ts +0 -238
package/src/gl/Lightmap.ts
CHANGED
|
@@ -1,145 +1,204 @@
|
|
|
1
|
-
// Baked lighting for static 3D geometry (
|
|
2
|
-
// itself: `env: { lightmap: { data,
|
|
1
|
+
// Baked lighting for static 3D geometry (packages/creator-bake). A scene FILE does all of this by
|
|
2
|
+
// itself: `env: { lightmap: { data, light, aux } }` marks the level as baked, every model/mesh node
|
|
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
|
|
7
|
-
//
|
|
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:
|
|
8
8
|
//
|
|
9
9
|
// const crate = await Model.load(asset('./assets/props/crate.glb'), { lightmap: true })
|
|
10
10
|
// Lightmap.add(holder) // a Model, a Mesh, or a node holding them
|
|
11
|
-
// Lightmap.add(ground) // Mesh.plane works too
|
|
12
11
|
// ...
|
|
13
|
-
// await Lightmap.load(scene, { data: asset('./assets/lightmap/
|
|
14
|
-
//
|
|
12
|
+
// await Lightmap.load(scene, { data: asset('./assets/lightmap/level.bake'),
|
|
13
|
+
// light: asset('./assets/lightmap/level-light.ktx2'),
|
|
14
|
+
// aux: asset('./assets/lightmap/level-aux.ktx2'),
|
|
15
|
+
// probes: asset('./assets/lightmap/level-probes.ktx2') })
|
|
15
16
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
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.
|
|
17
|
+
// `probes` (optional) is the bake's REFLECTION PROBES — the level's radiance traced by the same bake from a grid of
|
|
18
|
+
// points, prefiltered per roughness, one octahedral tile column per probe in one atlas; every pixel of a static
|
|
19
|
+
// blends the probes around it (the .bake carries the grid). Without the file a static's specular reflects the
|
|
20
|
+
// sky's SH alone.
|
|
22
21
|
//
|
|
23
|
-
//
|
|
22
|
+
// The files are asset() handles because the compiler stages exactly the files it sees (the data file
|
|
23
|
+
// is JSON with a .bake extension — a .json would be scanned as a code module). `load` is a no-op when
|
|
24
|
+
// the bake is missing or the host predates the feature, so the project stays runnable — and in BAKE
|
|
25
|
+
// mode (`lecodes lightmap bake`) it is where the bake fires: the same call marks "the scene is complete".
|
|
24
26
|
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
// the
|
|
27
|
+
// What a static gets: its whole DIFFUSE light from the atlas — the sun, the sky and the lamps that the
|
|
28
|
+
// bake traced onto it, in lux — and nothing from the real-time sun or lamps (the engine keeps those
|
|
29
|
+
// off baked renderables through light channels). Movers, glass and anything loaded without
|
|
30
|
+
// `lightmap: true` stay real-time. A lamp that must light statics live too (`Light.point({ baked:
|
|
31
|
+
// false })`) is put on the statics' channel and kept out of the bake.
|
|
30
32
|
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
// `
|
|
34
|
-
//
|
|
35
|
-
//
|
|
33
|
+
// Debugging: `Lightmap.debug(mode)` switches every lightmapped surface to a DATA view (irradiance,
|
|
34
|
+
// albedo, normals, visibilities, the atlas checker, the light direction, false-colour lux) and
|
|
35
|
+
// `Lightmap.probe(x, y)` names the texel under a screen point. View 1 ("irradiance") is the lighting alone:
|
|
36
|
+
// a white diffuse material under the camera's exposure. Views "direct" / "indirect" show the bake's two
|
|
37
|
+
// halves: a bake made with `--split` writes them as page sets of their own, the scene lists them as
|
|
38
|
+
// `direct` / `indirect` next to `light`, and the view binds those pages in place of `light` (the shading
|
|
39
|
+
// is untouched — it is the same material reading other pages). The bake's `--debug-dir` keeps the
|
|
40
|
+
// float layers `lecodes lightmap inspect` reads.
|
|
36
41
|
|
|
37
42
|
import { fetch } from "../runtime/fetch"
|
|
38
43
|
import { Texture } from "./Texture"
|
|
39
44
|
import { Material } from "./Material"
|
|
40
45
|
import { Mesh } from "./Mesh"
|
|
41
46
|
import { Model } from "./Model"
|
|
42
|
-
import { Node } from "./Node"
|
|
47
|
+
import { Node, nodeRegistry } from "./Node"
|
|
43
48
|
import { Light } from "./Light"
|
|
44
|
-
import { cx, cy, cz } from "../math/vec"
|
|
45
49
|
import type { Scene } from "./Scene"
|
|
46
50
|
|
|
47
51
|
export type LightmapLoadOptions = {
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
*
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
|
|
65
|
-
* movers out long before it blows out the level. Past ~x100 expect to light the movers
|
|
66
|
-
* separately. */
|
|
67
|
-
lightBoost?: number
|
|
68
|
-
/** Multiplier on the light VOLUME's irradiance alone — the movers' own `lightBoost`. Default =
|
|
69
|
-
* `lightBoost`, and since 2026-08-31 that default is the right one: the volume is an ambient cube
|
|
70
|
-
* (irradiance per axis face, blended by the pixel's normal), so a mover's top takes a lamp above it
|
|
71
|
-
* and its underside does not — the same shading the atlas gives the statics. Before that the volume
|
|
72
|
-
* was one flat 0.5·E on every face and this knob was the workaround. Kept as a trim. */
|
|
73
|
-
volumeBoost?: number
|
|
74
|
-
/** What an OCCLUDER-only static (a prop without lightmap UVs — traced into the receivers' atlas but
|
|
75
|
-
* carrying no rect of its own) does in real time. `"baked"` (default): its shadow is in the bake, so
|
|
76
|
-
* it stops casting and the shadow pass draws only the movers — on a level of thousands of foliage
|
|
77
|
-
* cards that is a third of the GPU frame. `"realtime"`: it keeps casting, which is the only way its
|
|
78
|
-
* shadow reaches OTHER non-receiver props (a rock under a tree); the honest fix for that is lightmap
|
|
79
|
-
* UVs on the props (`lecodes assets doctor --lightmap-uv`), which makes them receivers. */
|
|
80
|
-
occluderShadows?: "baked" | "realtime"
|
|
52
|
+
/** A DEBUG multiplier on the atlas' lux (default 1): the atlas is physical, so this is a knob for looking,
|
|
53
|
+
* not a look. */
|
|
54
|
+
lightScale?: number
|
|
55
|
+
/** The level's own radiance of emission 1.0 for the BAKE (cd / m², `BakeConfig.emissiveNits`): an imported pack's
|
|
56
|
+
* emissive values are tuned for a look, not for light - this is where the level says how much its panels really give.
|
|
57
|
+
* The CLI's `--emissive-nits` wins over it; without either the camera's exposure decides. */
|
|
58
|
+
emissiveNits?: number
|
|
59
|
+
/** `false` = the emissive surfaces light NOTHING in the bake (the level is lit by its lamps, the panels are decoration)
|
|
60
|
+
* while `emissiveNits` still says how bright their glow is drawn. Default true. */
|
|
61
|
+
emissiveBake?: boolean
|
|
62
|
+
/** FOR THE BAKE: materials that let light THROUGH them, by their glTF name (`"name*"` = every name with that start):
|
|
63
|
+
* the share of a shadow ray that passes, tinted by the material's base colour x map - an awning's warm, patterned light
|
|
64
|
+
* on the sand. The surface itself stays opaque: baked, drawn as before, a real-time shadow caster.
|
|
65
|
+
* A number = the share that goes STRAIGHT through (the gaps of a weave: it draws the map's picture on the ground);
|
|
66
|
+
* `{ through, diffuse }` adds the share the fibres SCATTER - the cloth's underside glows like a matte panel and lights
|
|
67
|
+
* what is under it by the solid angle it fills, with no picture. Dense canvas: `{ through: 0.08, diffuse: 0.25 }`. */
|
|
68
|
+
transmit?: Record<string, LightmapTransmit>
|
|
81
69
|
}
|
|
82
70
|
|
|
71
|
+
/** one `transmit` entry (LightmapLoadOptions.transmit): the straight share, or both shares */
|
|
72
|
+
export type LightmapTransmit = number | { through?: number, diffuse?: number }
|
|
73
|
+
|
|
83
74
|
export type LightmapInfo = {
|
|
84
75
|
size: number
|
|
85
76
|
texel: number
|
|
86
|
-
/** Atlas pages the bake took (`
|
|
77
|
+
/** Atlas pages the bake took (`light` / `aux` list them in order). */
|
|
87
78
|
pages: number
|
|
88
|
-
/** The light volume, when one was loaded: grid dims + cell size in metres. */
|
|
89
|
-
volume?: { dims: number[], cell: number }
|
|
90
|
-
/** Point lights the bake carries and this load switched off (statics read them from the atlas,
|
|
91
|
-
* movers from the volume); 0 without a `light` atlas. */
|
|
92
|
-
lights: number
|
|
93
79
|
/** keys applied / keys in the file / registered statics without a rect */
|
|
94
80
|
applied: number
|
|
95
81
|
total: number
|
|
96
82
|
missing: string[]
|
|
97
|
-
/** Occluder-only statics whose real-time shadow was switched off (`occluderShadows: "baked"`). */
|
|
98
|
-
occluders: number
|
|
99
83
|
}
|
|
100
84
|
|
|
101
85
|
export type LightmapFiles = {
|
|
102
86
|
data: string
|
|
103
|
-
/** The atlas — one page, or every page in order for a bake that took several. */
|
|
104
|
-
|
|
105
|
-
/** The
|
|
106
|
-
|
|
87
|
+
/** The irradiance atlas — one page, or every page in order for a bake that took several. */
|
|
88
|
+
light: string | string[]
|
|
89
|
+
/** The aux atlas (sun / sky visibility + light direction), pages like `light`. */
|
|
90
|
+
aux: string | string[]
|
|
91
|
+
/** DEBUG: the direct-light-only pages a `--split` bake wrote (`<stem>-direct[_n].ktx2`), for the "direct" view. */
|
|
92
|
+
direct?: string | string[]
|
|
93
|
+
/** DEBUG: the bounce-only pages of a `--split` bake (`<stem>-indirect[_n].ktx2`), for the "indirect" view. */
|
|
94
|
+
indirect?: string | string[]
|
|
95
|
+
/** The reflection probes (`<stem>-probes.ktx2`), when the bake placed some (`lecodes lightmap bake --probe-spacing`). */
|
|
96
|
+
probes?: string
|
|
97
|
+
/** THE LIGHT GRID for movers (`<stem>.lgrid`, `lecodes lightmap bake --volume`): the baked ambient light of every
|
|
98
|
+
* place a mover can be. Absent = movers keep the scene's IBL. */
|
|
107
99
|
volume?: string
|
|
108
100
|
}
|
|
109
101
|
|
|
110
|
-
|
|
102
|
+
/** The lightmap material's data views (`Lightmap.debug`); "direct" / "indirect" bind a `--split` bake's pages instead. */
|
|
103
|
+
export type LightmapDebugMode = "off" | "irradiance" | "albedo" | "normal" | "shadingNormal" | "share" | "skyVis" | "atlas" | "direction" | "lux" | "relief" | "direct" | "indirect" | "probe" | "probeMap" | "skyPath" | "probePath"
|
|
104
|
+
const DENOISE_LEVELS = { none: 0, indirect: 1, "no-sun": 2, all: 3 } as const
|
|
105
|
+
const DEBUG_MODES: LightmapDebugMode[] = [ "off", "irradiance", "albedo", "normal", "shadingNormal", "share", "skyVis", "atlas", "direction", "lux", "relief", "direct", "indirect", "probe", "probeMap", "skyPath", "probePath" ]
|
|
106
|
+
const PAGE_VIEWS: Record<number, "direct" | "indirect"> = { 11: "direct", 12: "indirect" }
|
|
107
|
+
|
|
108
|
+
export type LightmapDebugParams = {
|
|
109
|
+
/** `lux`: the false-colour ramp's log10 range (default 0..5, i.e. 1 lux .. 100 000). */
|
|
110
|
+
luxRange?: [number, number]
|
|
111
|
+
}
|
|
111
112
|
|
|
112
|
-
|
|
113
|
+
/** What `Lightmap.probe` answers: the baked texel under a screen point. */
|
|
114
|
+
export type LightmapProbe = {
|
|
115
|
+
key: string
|
|
116
|
+
entity: number
|
|
117
|
+
node: string
|
|
118
|
+
material: string
|
|
119
|
+
page: number
|
|
120
|
+
/** The texel's column / row on its page. */
|
|
121
|
+
texel: [number, number]
|
|
122
|
+
uv1: [number, number]
|
|
123
|
+
world: [number, number, number]
|
|
124
|
+
distance: number
|
|
125
|
+
}
|
|
113
126
|
|
|
114
|
-
|
|
127
|
+
/** `ground` = a terrain node (built with `Material.terrainLightmap()`): one rect, bound to its chunks by the host */
|
|
128
|
+
type Entry = { key: string, model?: Model, mesh?: Mesh, ground?: Node }
|
|
115
129
|
|
|
116
130
|
type BakeConfig = {
|
|
117
|
-
outDir: string,
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
*
|
|
132
|
-
|
|
131
|
+
outDir: string, stem: string, size?: number, texel?: number, pages?: number,
|
|
132
|
+
sunRays?: number, skyRays?: number, lightRays?: number, lightRadius?: number, sunAngle?: number, bias?: number, seed?: number,
|
|
133
|
+
bounceRays?: number, bounces?: number, patchSamples?: number, split?: boolean,
|
|
134
|
+
/** 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"`. */
|
|
135
|
+
denoise?: "none" | "indirect" | "no-sun" | "all",
|
|
136
|
+
/** The texel's reconstruction filter radius in texels: 1 (the default) = a ±1 texel tent, 0.5 = within the texel, 0 = the centre alone. */
|
|
137
|
+
filter?: number,
|
|
138
|
+
/** 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). */
|
|
139
|
+
probeSpacing?: number, probeSize?: number, probeRays?: number,
|
|
140
|
+
/** Keep only the probes under a roof (a closed top, fewer than three open sides). Outdoor statics then reflect the sky through
|
|
141
|
+
* their baked sky visibility, like an environment map without the ground; no outdoor probe can reach indoors and the
|
|
142
|
+
* atlas shrinks. Default false. */
|
|
143
|
+
probeIndoor?: boolean,
|
|
144
|
+
/** Where the probes go. "rooms" (the default): every indoor point of the grid slides to the middle of its room and the
|
|
145
|
+
* points of one room merge — one probe per room, far from the walls, its box the room (stations every ~8 m along a
|
|
146
|
+
* room over 12 m); outdoor points stay on the grid. "grid": everything stays on the grid. Either way the bake records, per block of atlas
|
|
147
|
+
* texels, the two probes those texels SEE (rays), so no probe reflects through a wall. */
|
|
148
|
+
probeLayout?: "rooms" | "grid",
|
|
149
|
+
/** Instances smaller than this many ATLAS TEXELS along every axis (x `texel` metres: 12 at 5 cm = 0.6 m) get no place
|
|
150
|
+
* in the atlas: they still cast their baked shadow and bounce - the wall keeps their shadow - and take their own light
|
|
151
|
+
* from an ambient cube baked where they stand. In texels and not metres: what cannot hold a lightmap is a matter of
|
|
152
|
+
* how many texels it gets (a fire extinguisher of 0.55 m: 300 charts, the largest 29 texels - every chart edge a step).
|
|
153
|
+
* 0 = everything bakes. Default 12. */
|
|
154
|
+
minTexels?: number,
|
|
155
|
+
/** THE LIGHT GRID for movers: the cell size in metres (0.75 of it tall); 0 = none. Default 1. */
|
|
156
|
+
volumeSpacing?: number,
|
|
157
|
+
/** A SMALL SURFACE GETS A FINER TEXEL: an instance whose surface is under this many atlas texels (area / texel²) is laid
|
|
158
|
+
* out denser by sqrt(detailTexels / its texels), 3x at most - a pipe of 1 m is ~300 texels at 5 cm, its charts a few
|
|
159
|
+
* texels wide and every chart edge a step. Default 2000; 0 = off. */
|
|
160
|
+
detailTexels?: number,
|
|
161
|
+
/** EMISSIVE SURFACES LIGHT THE LEVEL: the radiance in cd / m² of emission 1.0 (a material's emissive factor x strength x
|
|
162
|
+
* map) - every lit region of an emissive surface becomes a rectangle lamp in the bake (a ceiling panel drawn in the
|
|
163
|
+
* ceiling's texture, a screen, a sign). Default: what the level's CAMERA makes of emission 1.0, `1.2 x 2^ev100` - the
|
|
164
|
+
* engine draws emission screen-referred, so a panel then lights the room as brightly as it looks. 0 = they light nothing. */
|
|
165
|
+
emissiveNits?: number,
|
|
166
|
+
/** Only grid cells whose centre lies inside this world box get a probe (min xyz, max xyz) — keeps probes off a roof top the
|
|
167
|
+
* player never sees. Default: the receivers' bounds. */
|
|
168
|
+
probeBox?: [number, number, number, number, number, number],
|
|
169
|
+
debugDir?: string,
|
|
133
170
|
}
|
|
134
171
|
|
|
135
|
-
|
|
136
|
-
|
|
172
|
+
/** a small static's light where it stands (creator-bake objects.h): per side of its bounds (+x -x +y -y +z -z) the
|
|
173
|
+
* irradiance rgb in lux + the sky visibility; probes = a, b, a's weight (-1 = none, -2 = the sky) */
|
|
174
|
+
type BakeObjectLight = { at: number[], cube: number[][], probes: number[] }
|
|
175
|
+
type BakeInstance = { key: string, receiver: boolean, small?: boolean, light?: BakeObjectLight, page?: number, rect?: number[], st?: number[] }
|
|
176
|
+
type BakeProbes = {
|
|
177
|
+
file: string, size: number, levels: number, count: number, oct: number, tile: number[], perRow: number, atlas: number[],
|
|
178
|
+
spacing: number, layout: "rooms" | "grid",
|
|
179
|
+
/** the block size of the pages' probe maps (atlas texels) and the range their records decode with: lo xyz, size xyz */
|
|
180
|
+
map: number, encode: number[],
|
|
181
|
+
positions: number[][], boxes: number[][],
|
|
182
|
+
/** per probe: its open share of the sphere (0 = a room, over 0.15 = under the sky) */
|
|
183
|
+
sky: number[],
|
|
184
|
+
}
|
|
137
185
|
type BakeData = {
|
|
138
|
-
version
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
186
|
+
version: number, size: number, pages: number, texel: number, lightScale: number,
|
|
187
|
+
light: string[], aux: string[], direct?: string[], indirect?: string[],
|
|
188
|
+
probes?: BakeProbes | null,
|
|
189
|
+
/** the light grid (creator-bake volume.h): its file and lattice */
|
|
190
|
+
volume?: { file: string, n: number[], origin: number[], cell: number[], valid: number } | null,
|
|
191
|
+
sun?: { direction: number[], color: number[], lux: number } | null, sunMap?: boolean, sunMapLevels?: number, mips?: number, emissiveNits?: number,
|
|
192
|
+
instances: BakeInstance[],
|
|
193
|
+
}
|
|
194
|
+
type PageKind = "light" | "direct" | "indirect"
|
|
195
|
+
type Bound = {
|
|
196
|
+
data: BakeData, rects: Map<string, BakeInstance>, byEntity: Map<number, Entry>, files: LightmapFiles, pages: number,
|
|
197
|
+
aux: Texture[], light: Texture[], direct?: Texture[], indirect?: Texture[],
|
|
198
|
+
/** how many reflection probes the host holds (0 = none loaded), and their atlas */
|
|
199
|
+
probes: number, probeAtlas?: Texture,
|
|
200
|
+
/** which page set the surfaces read right now */
|
|
201
|
+
showing: PageKind,
|
|
143
202
|
}
|
|
144
203
|
|
|
145
204
|
const bakeConfig = (): BakeConfig | null => {
|
|
@@ -147,14 +206,6 @@ const bakeConfig = (): BakeConfig | null => {
|
|
|
147
206
|
return g && typeof g.outDir === "string" ? g : null
|
|
148
207
|
}
|
|
149
208
|
|
|
150
|
-
/** A hex colour as the 9-char form the float4 uniform path expects ("#rrggbbaa"). */
|
|
151
|
-
const hex8 = (c: unknown): string => {
|
|
152
|
-
if (typeof c !== "string") return "#ffffffff"
|
|
153
|
-
if (c.length === 7) return c + "ff"
|
|
154
|
-
if (c.length === 4) return "#" + c[1] + c[1] + c[2] + c[2] + c[3] + c[3] + "ff"
|
|
155
|
-
return c
|
|
156
|
-
}
|
|
157
|
-
|
|
158
209
|
const list = (v: string | string[] | undefined): string[] => v === undefined ? [] : Array.isArray(v) ? v : [ v ]
|
|
159
210
|
|
|
160
211
|
const NONE = 0xFFFFFFFF
|
|
@@ -164,21 +215,18 @@ export class Lightmap {
|
|
|
164
215
|
static get baking(): boolean { return bakeConfig() !== null }
|
|
165
216
|
|
|
166
217
|
private static entries: Entry[] = []
|
|
167
|
-
private static dynamics: Mesh[] = []
|
|
168
218
|
private static ordinals = new Map<string, number>()
|
|
169
219
|
private static warned = false
|
|
170
|
-
|
|
171
|
-
|
|
220
|
+
/** The bake bound last (`load`): what `probe` reads rects from. */
|
|
221
|
+
private static bound: Bound | null = null
|
|
172
222
|
|
|
173
223
|
/** Register static geometry — a Model, a Mesh, or any node whose subtree holds them. Statics are
|
|
174
|
-
* both receivers and occluders in the bake. `key` names the entry in
|
|
224
|
+
* both receivers and occluders in the bake. `key` names the entry in the .bake (default:
|
|
175
225
|
* the node's name + a running number, `container#3`); pass one when names are not stable. */
|
|
176
226
|
static add(node: Node, key?: string): Node {
|
|
177
227
|
const base = key ?? `${node.name || "node"}#${Lightmap.next(node.name || "node")}`
|
|
178
228
|
const found: Entry[] = []
|
|
179
229
|
const walk = (n: Node): void => {
|
|
180
|
-
const t = terrainOf(n)
|
|
181
|
-
if (t) { found.push({ key: base, terrain: t }); return } // one receiver, its chunks are internal
|
|
182
230
|
if (n instanceof Model) { found.push({ key: base, model: n }); return }
|
|
183
231
|
if (n instanceof Mesh) { found.push({ key: base, mesh: n }); return }
|
|
184
232
|
for (const c of n.children) walk(c)
|
|
@@ -194,16 +242,9 @@ export class Lightmap {
|
|
|
194
242
|
static _register(node: Model | Mesh, key: string): void {
|
|
195
243
|
Lightmap.entries.push(node instanceof Model ? { key, model: node } : { key, mesh: node })
|
|
196
244
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
Lightmap.entries.push({ key, terrain })
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** @internal Scene-file runtime: a dynamic Mesh — swapped to the lightmap material on the volume
|
|
204
|
-
* when `load` runs (dynamic Models need nothing here: the engine binds the volume itself). */
|
|
205
|
-
static _registerDynamic(mesh: Mesh): void {
|
|
206
|
-
Lightmap.dynamics.push(mesh)
|
|
245
|
+
/** @internal Scene-file runtime: a terrain node - a receiver with one rect (the host keeps its triangles for the bake). */
|
|
246
|
+
static _registerGround(node: Node, key: string): void {
|
|
247
|
+
Lightmap.entries.push({ key, ground: node })
|
|
207
248
|
}
|
|
208
249
|
|
|
209
250
|
private static next(name: string): number {
|
|
@@ -212,23 +253,77 @@ export class Lightmap {
|
|
|
212
253
|
return n
|
|
213
254
|
}
|
|
214
255
|
|
|
215
|
-
|
|
256
|
+
// ---- movers: the light grid ------------------------------------------------------------------------
|
|
257
|
+
private static movers = new Set<Model | Mesh>()
|
|
258
|
+
private static volumeOn = false
|
|
259
|
+
|
|
260
|
+
/** A MOVER takes its ambient light from the level's baked LIGHT GRID (`env.lightmap.volume`) at the place it is at,
|
|
261
|
+
* every frame — whatever its shader: the standard glTF one or a custom lit material. Every Model / Mesh under `node`
|
|
262
|
+
* is marked (a character, a weapon with its parts). A scene file does this itself for every node a Physics aspect or
|
|
263
|
+
* a CharacterController moves; call it for what CODE spawns — an enemy, a pickup, a projectile. `on = false` hands
|
|
264
|
+
* the subtree back to the scene's IBL. Safe before the level's bake has loaded. */
|
|
265
|
+
static track(node: Node, on = true): void {
|
|
266
|
+
// by entity id through the bridge, not `node.children`: the bundler drops that accessor from a build where
|
|
267
|
+
// nothing else reads it (the leash in lightcheck, 2026-09-18: "children" was undefined on a Mesh)
|
|
268
|
+
const walk = (id: number): void => {
|
|
269
|
+
const n = nodeRegistry.get(id)
|
|
270
|
+
if (n instanceof Model || n instanceof Mesh) {
|
|
271
|
+
if (on) Lightmap.movers.add(n); else Lightmap.movers.delete(n)
|
|
272
|
+
if (Lightmap.volumeOn || !on) _creator.lightVolumeTrack?.(id, on)
|
|
273
|
+
}
|
|
274
|
+
for (const c of _creator.getChildren(id)) walk(c)
|
|
275
|
+
}
|
|
276
|
+
walk(node.id)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private static async loadVolume(data: BakeData, files: LightmapFiles): Promise<void> {
|
|
280
|
+
Lightmap.volumeOn = false
|
|
281
|
+
if (!files.volume || !data.volume || !_creator.lightVolumeLoad) {
|
|
282
|
+
if (data.volume && !files.volume) console.warn(`Lightmap: the bake wrote a light grid (${data.volume.file}) but the scene does not list it — add volume: asset('…/${data.volume.file}') to env.lightmap, movers keep the IBL until then`)
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
const resp = await fetch(files.volume, { useOnce: true })
|
|
287
|
+
if (resp.status >= 400) { console.warn(`Lightmap: no light grid at ${files.volume} (HTTP ${resp.status})`); return }
|
|
288
|
+
const ok = _creator.lightVolumeLoad((resp as unknown as { _id: number })._id)
|
|
289
|
+
resp.dispose()
|
|
290
|
+
if (!ok) { console.warn(`Lightmap: ${files.volume} is not a light grid — rebake`); return }
|
|
291
|
+
} catch (e) {
|
|
292
|
+
console.warn(`Lightmap: could not read ${files.volume}: ${String(e)}`)
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
Lightmap.volumeOn = true
|
|
296
|
+
// the baked lamps are in the grid's cubes now: burning on, they would light every mover twice (Light._holdDark)
|
|
297
|
+
for (const l of Light._points) if (l._baked) l._holdDark(true)
|
|
298
|
+
for (const m of Lightmap.movers) _creator.lightVolumeTrack?.(m.id, true)
|
|
299
|
+
// the statics the atlas left out (glass, a material the lightmap shader declines) take the grid's light once; the
|
|
300
|
+
// host ignores a static whose every primitive is baked
|
|
301
|
+
for (const e of Lightmap.entries) if (!e.ground) _creator.lightVolumeTrack?.((e.model ?? e.mesh!).id, true, true)
|
|
302
|
+
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)`)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Forget every registration (a scene rebuild). */
|
|
216
306
|
static clear(): void {
|
|
307
|
+
for (const l of Light._points) l._holdDark(false)
|
|
308
|
+
Lightmap.movers.clear()
|
|
309
|
+
Lightmap.volumeOn = false
|
|
310
|
+
_creator.lightVolumeClear?.()
|
|
217
311
|
Lightmap.entries = []
|
|
218
|
-
Lightmap.dynamics = []
|
|
219
312
|
Lightmap.ordinals.clear()
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
_creator.
|
|
224
|
-
_creator.
|
|
313
|
+
Lightmap.bound = null
|
|
314
|
+
if (Lightmap.balls) { Lightmap.balls.destroy(); Lightmap.balls = null }
|
|
315
|
+
_creator.lightmapSetOptions?.(1, 0)
|
|
316
|
+
_creator.lightmapSunSet?.(0, 1, 0, 0, 0, 0, false)
|
|
317
|
+
_creator.lightmapDebug?.(0)
|
|
225
318
|
}
|
|
226
319
|
|
|
227
320
|
/** Apply a bake — or, under `lecodes lightmap bake`, run it. Resolves to null when nothing was
|
|
228
321
|
* applied (no bake yet, a host without the feature, bake mode). */
|
|
322
|
+
private static scene: Scene | null = null
|
|
229
323
|
static async load(scene: Scene, files: LightmapFiles, options: LightmapLoadOptions = {}): Promise<LightmapInfo | null> {
|
|
324
|
+
Lightmap.scene = scene
|
|
230
325
|
const bake = bakeConfig()
|
|
231
|
-
if (bake) { Lightmap.bake(bake); return null }
|
|
326
|
+
if (bake) { Lightmap.bake(bake, options.emissiveNits, options.emissiveBake ?? true, options.transmit); return null }
|
|
232
327
|
if (!_creator.lightmapApply) {
|
|
233
328
|
if (!Lightmap.warned) { Lightmap.warned = true; console.warn("Lightmap: this host has no lightmap support — rendering without the bake") }
|
|
234
329
|
return null
|
|
@@ -243,188 +338,257 @@ export class Lightmap {
|
|
|
243
338
|
console.warn(`Lightmap: could not read ${files.data}: ${String(e)}`)
|
|
244
339
|
return null
|
|
245
340
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
if (pageFiles.length === 0) { console.warn("Lightmap: no atlas texture given"); return null }
|
|
250
|
-
if (pageFiles.length < pages) {
|
|
251
|
-
console.warn(`Lightmap: the bake took ${pages} atlas pages but the scene lists ${pageFiles.length} — add the other page files (${(data.textures ?? []).slice(pageFiles.length).join(", ")}) to env.lightmap.texture`)
|
|
341
|
+
if (data.version !== 4 || !Array.isArray(data.instances)) {
|
|
342
|
+
console.warn(`Lightmap: ${files.data} is not a creator-bake file (version ${String(data.version)}) — rebake with \`lecodes lightmap bake\``)
|
|
343
|
+
return null
|
|
252
344
|
}
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
let volumeScale = 0
|
|
260
|
-
if (data.lightTextures?.length && (data.lightScale ?? 0) > 0) {
|
|
261
|
-
if (lightFiles.length === 0) {
|
|
262
|
-
console.warn(`Lightmap: the bake carries ${data.lights?.length ?? 0} baked point light(s) but the scene passes no \`light\` atlas (${data.lightTextures[0]}) — the lamps stay real-time`)
|
|
263
|
-
} else {
|
|
264
|
-
if (lightFiles.length < pages) console.warn(`Lightmap: the light atlas has ${pages} pages but the scene lists ${lightFiles.length}`)
|
|
265
|
-
lightTextures = await Promise.all(lightFiles.slice(0, pages).map((f) => Texture.load(f, { fullSize: true })))
|
|
266
|
-
lightScale = (data.lightScale ?? 0) * (options.lightBoost ?? 1)
|
|
267
|
-
volumeScale = (data.lightScale ?? 0) * (options.volumeBoost ?? options.lightBoost ?? 1)
|
|
268
|
-
}
|
|
345
|
+
if (!data.pages || !(data.lightScale > 0)) return null // the CLI's placeholder: nothing baked yet
|
|
346
|
+
const pages = Math.max(1, data.pages)
|
|
347
|
+
const lightFiles = list(files.light), auxFiles = list(files.aux)
|
|
348
|
+
if (lightFiles.length === 0 || auxFiles.length === 0) { console.warn("Lightmap: the scene passes no `light` / `aux` atlas"); return null }
|
|
349
|
+
if (lightFiles.length < pages || auxFiles.length < pages) {
|
|
350
|
+
console.warn(`Lightmap: the bake took ${pages} page(s) but the scene lists ${Math.min(lightFiles.length, auxFiles.length)} — add ${data.light.slice(lightFiles.length).join(", ")} / ${data.aux.slice(auxFiles.length).join(", ")} to env.lightmap`)
|
|
269
351
|
}
|
|
270
|
-
|
|
271
|
-
const
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
352
|
+
// both atlases are DATA and exempt from Texture.maxSize (a capped atlas blurs every shadow edge)
|
|
353
|
+
const n = Math.min(pages, lightFiles.length, auxFiles.length)
|
|
354
|
+
const light = await Promise.all(lightFiles.slice(0, n).map((f) => Texture.load(f, { fullSize: true, srgb: false })))
|
|
355
|
+
const aux = await Promise.all(auxFiles.slice(0, n).map((f) => Texture.load(f, { fullSize: true, srgb: false })))
|
|
356
|
+
const lightScale = data.lightScale * (options.lightScale ?? 1)
|
|
357
|
+
_creator.lightmapSetOptions?.(lightScale, data.emissiveNits ?? 0)
|
|
358
|
+
// a mover's sun shadow on the statics: only a bake that wrote the sun map under its aux pages
|
|
359
|
+
const sun = data.sunMap ? data.sun : null
|
|
360
|
+
if (sun) _creator.lightmapSunSet?.(-sun.direction[0], -sun.direction[1], -sun.direction[2], sun.color[0] * sun.lux, sun.color[1] * sun.lux, sun.color[2] * sun.lux, true, data.sunMapLevels ?? 1)
|
|
361
|
+
else _creator.lightmapSunSet?.(0, 1, 0, 0, 0, 0, false)
|
|
362
|
+
// the reflection probes: the atlas loads like a page, the grid travels as numbers; level-wide on the host
|
|
363
|
+
let probes = 0
|
|
364
|
+
let probeAtlas: Texture | undefined
|
|
365
|
+
const pb = data.probes
|
|
366
|
+
if (pb && pb.count > 0) {
|
|
367
|
+
if (!files.probes) console.warn(`Lightmap: the bake has ${pb.count} reflection probe(s) but the scene lists no \`probes\` file — add probes: asset('<dir>/${pb.file}') to env.lightmap`)
|
|
368
|
+
else if (!_creator.lightmapProbesSet) console.warn("Lightmap: this host has no reflection probes — statics reflect the sky alone")
|
|
369
|
+
else if (!Array.isArray(pb.encode) || pb.encode.length < 6) console.warn("Lightmap: the bake's probes are in an older format — rebake (statics reflect the sky alone until then)")
|
|
370
|
+
else {
|
|
371
|
+
probeAtlas = await Texture.load(files.probes, { fullSize: true, srgb: false })
|
|
372
|
+
// which probes a pixel reflects is in the aux pages (their probe maps); the host needs the atlas and the records' range
|
|
373
|
+
probes = _creator.lightmapProbesSet(probeAtlas._id, pb.oct, pb.levels, pb.perRow, pb.count,
|
|
374
|
+
pb.encode[0], pb.encode[1], pb.encode[2], pb.encode[3], pb.encode[4], pb.encode[5])
|
|
375
|
+
if (probes !== pb.count) console.warn(`Lightmap: the host took ${probes} of ${pb.count} probe(s) — rebake`)
|
|
376
|
+
}
|
|
293
377
|
}
|
|
294
|
-
|
|
295
|
-
|
|
378
|
+
console.log(`[lightmap] bound ${n} page(s) of ${data.size} at ${(data.texel * 100).toFixed(2)} cm (${data.mips ?? 1} level(s), sun map ${data.sunMapLevels ?? (data.sunMap ? 1 : 0)}), lightScale ${lightScale}${(options.lightScale ?? 1) !== 1 ? ` (bake ${data.lightScale} x ${options.lightScale})` : ""}${probes > 0 ? `, ${probes} reflection probe(s)` : ""}`)
|
|
379
|
+
const rects = new Map<string, BakeInstance>()
|
|
380
|
+
for (const inst of data.instances) if (inst.receiver && inst.st && inst.page !== undefined) rects.set(inst.key, inst)
|
|
381
|
+
// the ones the bake left out for their size: occluders there, movers here — not a rect gone missing
|
|
382
|
+
const small = new Map<string, BakeInstance>()
|
|
383
|
+
for (const inst of data.instances) if (inst.small) small.set(inst.key, inst)
|
|
384
|
+
const declined = new Set<string>()
|
|
385
|
+
for (const inst of data.instances) if (!inst.receiver && !inst.small) declined.add(inst.key)
|
|
386
|
+
const probeId = (k: number) => k === -2 ? 65534 : k < 0 ? 65535 : k
|
|
387
|
+
// A GLB UNWRAPPED IN GROUPS (`assets doctor --lightmap-uv --lightmap-groups n`): the bake keys its parts `<key>#k`, a rect
|
|
388
|
+
// (or a small static's cube) each, and the host binds part k to the nodes that carry extras.lightmapGroup = k
|
|
389
|
+
const parts = Lightmap.groupsOf(data.instances)
|
|
390
|
+
let smallLit = 0
|
|
391
|
+
const byEntity = new Map<number, Entry>()
|
|
392
|
+
let applied = 0
|
|
296
393
|
const missing: string[] = []
|
|
297
394
|
for (const e of Lightmap.entries) {
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
395
|
+
const node = e.model ?? e.mesh ?? e.ground!
|
|
396
|
+
byEntity.set(node.id, e)
|
|
397
|
+
const groups = parts.get(e.key)
|
|
398
|
+
if (groups) {
|
|
399
|
+
let took = 0
|
|
400
|
+
for (const [ k, inst ] of groups) {
|
|
401
|
+
if (inst.small && inst.light && _creator.lightmapObjectApply && n > 0) {
|
|
402
|
+
const l = inst.light
|
|
403
|
+
took += _creator.lightmapObjectApply(node.id, light[0]._id, aux[0]._id, Float32Array.from([ ...l.cube.flat(), probeId(l.probes[0]), probeId(l.probes[1]), l.probes[2] ]), k)
|
|
404
|
+
} else if (inst.receiver && inst.st && inst.page !== undefined && inst.page < n) {
|
|
405
|
+
took += _creator.lightmapApply(node.id, light[inst.page]._id, aux[inst.page]._id, inst.st[0], inst.st[1], inst.st[2], inst.st[3], k)
|
|
305
406
|
}
|
|
306
|
-
}
|
|
407
|
+
}
|
|
408
|
+
if (took === 0) console.warn(`Lightmap: ${e.key} (${groups.size} groups) was not loaded lightmapped, or the host binds no groups (rebuild it)`)
|
|
409
|
+
else applied++
|
|
307
410
|
continue
|
|
308
411
|
}
|
|
309
|
-
const
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
const
|
|
314
|
-
if (
|
|
315
|
-
e.
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
} else if (e.terrain) {
|
|
320
|
-
// terrain.mat carries lightmap.mat's atlas block: bind the rect on the terrain's own material
|
|
321
|
-
const m = e.terrain.material.set("lightmap", texture).set("lightmapST", [ st[0], st[1], st[2], st[3] ])
|
|
322
|
-
.set("ambientScale", ambientScale).set("sunStrength", strength).set("bakedAo", aoStrength).set("lightGamma", lightGamma)
|
|
323
|
-
if (light) m.set("lightmapLight", light).set("lightScale", lightScale)
|
|
324
|
-
else m.set("lightScale", 0)
|
|
325
|
-
e.terrain.castShadows = false
|
|
412
|
+
const r = rects.get(e.key)
|
|
413
|
+
const tiny = small.get(e.key)
|
|
414
|
+
if (tiny) {
|
|
415
|
+
// no rect: the light baked where it stands (any page's samplers — the probes' records sit under every aux page)
|
|
416
|
+
const l = tiny.light
|
|
417
|
+
if (!l || !_creator.lightmapObjectApply || n === 0) continue
|
|
418
|
+
if (e.mesh) e.mesh.material = Lightmap.meshMaterial(e.mesh)
|
|
419
|
+
const d = Float32Array.from([ ...l.cube.flat(), probeId(l.probes[0]), probeId(l.probes[1]), l.probes[2] ])
|
|
420
|
+
if (_creator.lightmapObjectApply(node.id, light[0]._id, aux[0]._id, d) > 0) smallLit++
|
|
421
|
+
continue
|
|
326
422
|
}
|
|
423
|
+
if (!r && declined.has(e.key)) continue // glass / a declined material: the bake knows it as an occluder - the light grid lights it (loadVolume)
|
|
424
|
+
if (!r || r.page! >= n) { missing.push(e.key); continue }
|
|
425
|
+
const st = r.st!
|
|
426
|
+
// a Mesh receiver: its lit material becomes a lightmap material carrying the same colour / map / factors
|
|
427
|
+
if (e.mesh) e.mesh.material = Lightmap.meshMaterial(e.mesh)
|
|
428
|
+
const took = _creator.lightmapApply(node.id, light[r.page!]._id, aux[r.page!]._id, st[0], st[1], st[2], st[3])
|
|
429
|
+
if (took === 0) { console.warn(`Lightmap: ${e.key} was not loaded lightmapped (Model.load { lightmap: true } / Material.lightmap())`); continue }
|
|
327
430
|
applied++
|
|
328
431
|
}
|
|
432
|
+
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`)
|
|
329
433
|
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`)
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
if (shadowless > 0) console.log(`[lightmap] ${shadowless} occluder-only static(s) cast no real-time shadow (baked); the shadow pass draws the movers only`)
|
|
334
|
-
const info: LightmapInfo = { size: data.size, texel: data.texel, pages, applied, total: rects.size, missing, lights, occluders: shadowless }
|
|
335
|
-
if (volume) info.volume = { dims: volume.dims, cell: volume.cell }
|
|
336
|
-
return info
|
|
434
|
+
Lightmap.bound = { data, rects, byEntity, files, pages: n, aux, light, probes, probeAtlas, showing: "light" }
|
|
435
|
+
await Lightmap.loadVolume(data, files)
|
|
436
|
+
return { size: data.size, texel: data.texel, pages: n, applied, total: rects.size, missing }
|
|
337
437
|
}
|
|
338
438
|
|
|
339
|
-
/**
|
|
340
|
-
|
|
341
|
-
|
|
439
|
+
/** The bake's `<key>#k` instances by their base key: the parts of a GLB unwrapped in groups (k = extras.lightmapGroup). */
|
|
440
|
+
private static groupsOf(instances: BakeInstance[]): Map<string, Map<number, BakeInstance>> {
|
|
441
|
+
const out = new Map<string, Map<number, BakeInstance>>()
|
|
442
|
+
const registered = new Set(Lightmap.entries.map((e) => e.key))
|
|
443
|
+
for (const inst of instances) {
|
|
444
|
+
const m = /^(.*)#(\d+)$/.exec(inst.key)
|
|
445
|
+
if (!m || !registered.has(m[1])) continue
|
|
446
|
+
let g = out.get(m[1])
|
|
447
|
+
if (!g) { g = new Map(); out.set(m[1], g) }
|
|
448
|
+
g.set(Number(m[2]), inst)
|
|
449
|
+
}
|
|
450
|
+
return out
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Switch every lightmapped surface to a DATA view (or back with `"off"` / 0). Level-wide, instant —
|
|
454
|
+
* except "direct" / "indirect", which load that page set the first time (a `--split` bake listed in the scene). */
|
|
455
|
+
static debug(mode: LightmapDebugMode | number, params: LightmapDebugParams = {}): void {
|
|
456
|
+
const m = typeof mode === "number" ? mode : Math.max(0, DEBUG_MODES.indexOf(mode))
|
|
457
|
+
const [ lo, hi ] = params.luxRange ?? [ 0, 5 ]
|
|
458
|
+
const pages = PAGE_VIEWS[m]
|
|
459
|
+
void Lightmap.show(pages ?? "light")
|
|
460
|
+
_creator.lightmapDebug?.(pages ? 0 : m, 0, lo, hi, 0)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Bind another page set of the bound bake to every surface (the "direct" / "indirect" views). */
|
|
464
|
+
private static async show(kind: PageKind): Promise<void> {
|
|
465
|
+
const b = Lightmap.bound
|
|
466
|
+
if (!b || b.showing === kind || !_creator.lightmapApply) return
|
|
467
|
+
let tex = b[kind]
|
|
468
|
+
if (!tex) {
|
|
469
|
+
const files = list(b.files[kind])
|
|
470
|
+
if (files.length < b.pages) {
|
|
471
|
+
console.warn(`Lightmap: the scene lists ${files.length ? "only " + files.length : "no"} "${kind}" page(s) — bake with \`lecodes lightmap bake --split\` and add its ${kind} pages to env.lightmap`)
|
|
472
|
+
return
|
|
473
|
+
}
|
|
474
|
+
tex = await Promise.all(files.slice(0, b.pages).map((f) => Texture.load(f, { fullSize: true, srgb: false })))
|
|
475
|
+
if (Lightmap.bound !== b) return // rebuilt meanwhile
|
|
476
|
+
b[kind] = tex
|
|
477
|
+
}
|
|
478
|
+
b.showing = kind
|
|
479
|
+
const parts = Lightmap.groupsOf(b.data.instances)
|
|
480
|
+
for (const [ id, e ] of b.byEntity) {
|
|
481
|
+
for (const [ k, inst ] of parts.get(e.key) ?? []) {
|
|
482
|
+
if (inst.receiver && inst.st && inst.page !== undefined && inst.page < b.pages) _creator.lightmapApply(id, tex[inst.page]._id, b.aux[inst.page]._id, inst.st[0], inst.st[1], inst.st[2], inst.st[3], k)
|
|
483
|
+
}
|
|
484
|
+
const r = b.rects.get(e.key)
|
|
485
|
+
if (!r || r.page! >= b.pages) continue
|
|
486
|
+
const st = r.st!
|
|
487
|
+
_creator.lightmapApply(id, tex[r.page!]._id, b.aux[r.page!]._id, st[0], st[1], st[2], st[3])
|
|
488
|
+
}
|
|
489
|
+
console.log(`[lightmap] showing the ${kind} pages`)
|
|
490
|
+
}
|
|
491
|
+
private static balls: Node | null = null
|
|
492
|
+
/** DEBUG: a mirror ball at every reflection probe of the bound bake (`on`), or none (`off`) — a ball reflects the
|
|
493
|
+
* probes around its point unoccluded (at a probe's own position that is the probe itself), so a probe inside a
|
|
494
|
+
* wall, a black one or a leak across a wall shows at a glance. Metallic, roughness 0; view "probe" shows the
|
|
495
|
+
* probes alone. */
|
|
496
|
+
static debugProbes(on: boolean, radius = 0.2): number {
|
|
497
|
+
if (Lightmap.balls) { Lightmap.balls.destroy(); Lightmap.balls = null }
|
|
498
|
+
const b = Lightmap.bound
|
|
499
|
+
if (!on || !b || b.probes === 0 || !b.data.probes || !_creator.lightmapApply) return 0
|
|
500
|
+
const holder = new Node()
|
|
501
|
+
holder.name = "lightmap-probes"
|
|
342
502
|
let n = 0
|
|
343
|
-
for (
|
|
344
|
-
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
503
|
+
for (let k = 0; k < Math.min(b.probes, b.data.probes.positions.length); k++) {
|
|
504
|
+
const [ x, y, z ] = b.data.probes.positions[k]
|
|
505
|
+
const m = Material.lightmap()
|
|
506
|
+
m.set("baseColorFactor", "#ffffffff").set("metallicFactor", 1).set("roughnessFactor", 0).set("hasNormalMap", 0)
|
|
507
|
+
const ball = Mesh.sphere({ radius, material: m })
|
|
508
|
+
ball.name = `probe#${k}`
|
|
509
|
+
ball.position = [ x, y, z ]
|
|
510
|
+
holder.add(ball)
|
|
511
|
+
_creator.lightmapApply(ball.id, b.light[0]._id, b.aux[0]._id, 0, 0, 0, 0)
|
|
512
|
+
m.set("probeBall", k + 1) // probe k alone, unoccluded
|
|
351
513
|
n++
|
|
352
514
|
}
|
|
515
|
+
Lightmap.scene?.add(holder)
|
|
516
|
+
Lightmap.balls = holder
|
|
517
|
+
console.log(`[lightmap] ${n} probe ball(s)`)
|
|
353
518
|
return n
|
|
354
519
|
}
|
|
355
520
|
|
|
356
|
-
/**
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
521
|
+
/** The view names in `lightmapDebug`'s order — for a knob that cycles them. */
|
|
522
|
+
static get debugModes(): readonly LightmapDebugMode[] { return DEBUG_MODES }
|
|
523
|
+
|
|
524
|
+
/** The baked texel under a screen point (logical px): its instance key, page and texel column / row,
|
|
525
|
+
* so the value can be looked up in the bake's debug layers (`lecodes lightmap inspect`). null when
|
|
526
|
+
* nothing baked is under the point or the host has no triangle pick. */
|
|
527
|
+
static probe(screenX: number, screenY: number): LightmapProbe | null {
|
|
528
|
+
const b = Lightmap.bound
|
|
529
|
+
if (!b || !_creator.pickTriangle) return null
|
|
530
|
+
type Hit = { entity: number, nodeName: string, material: string, point: number[], distance: number, uv1?: number[], lightmapGroup?: number }
|
|
531
|
+
// the nearest hit over the registered statics: one pick per entry (the all-instances pick, entity 0, walks
|
|
532
|
+
// every GLB instance including the off-scene templates and is what a scene file's clones confuse)
|
|
533
|
+
let hit: Hit | null = null
|
|
534
|
+
for (const id of b.byEntity.keys()) {
|
|
535
|
+
const j = _creator.pickTriangle(id, screenX, screenY)
|
|
536
|
+
if (!j) continue
|
|
537
|
+
const h = JSON.parse(j) as Hit
|
|
538
|
+
if (!hit || h.distance < hit.distance) hit = h
|
|
371
539
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
const
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
if (
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
// .w = the grid's depth: the shader keeps its fetch inside one face slab of the light texture
|
|
386
|
-
m.set("probeInvSize", [ 1 / size[0], 1 / size[1], 1 / size[2], nz ])
|
|
387
|
-
mesh.setMaterial(m)
|
|
540
|
+
if (!hit) { console.log(`[probe] nothing baked under ${screenX.toFixed(0)},${screenY.toFixed(0)}`); return null }
|
|
541
|
+
const entry = b.byEntity.get(hit.entity)
|
|
542
|
+
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 }
|
|
543
|
+
const hitKey = hit.lightmapGroup !== undefined && b.rects.has(`${entry.key}#${hit.lightmapGroup}`) ? `${entry.key}#${hit.lightmapGroup}` : entry.key
|
|
544
|
+
const inst = b.rects.get(hitKey)
|
|
545
|
+
if (!inst || !inst.st) { console.log(`[probe] ${hitKey}: no rect in the bake`); return null }
|
|
546
|
+
if (!hit.uv1) { console.log(`[probe] ${entry.key}: the hit primitive carries no TEXCOORD_1`); return null }
|
|
547
|
+
const st = inst.st
|
|
548
|
+
const u = hit.uv1[0] * st[0] + st[2], v = hit.uv1[1] * st[1] + st[3]
|
|
549
|
+
return {
|
|
550
|
+
key: hitKey, entity: hit.entity, node: hit.nodeName, material: hit.material, page: inst.page ?? 0,
|
|
551
|
+
texel: [ Math.floor(u * b.data.size), Math.floor(v * b.data.size) ],
|
|
552
|
+
uv1: [ hit.uv1[0], hit.uv1[1] ], world: [ hit.point[0], hit.point[1], hit.point[2] ], distance: hit.distance,
|
|
388
553
|
}
|
|
389
|
-
return header
|
|
390
554
|
}
|
|
391
555
|
|
|
392
|
-
/** A
|
|
393
|
-
private static
|
|
394
|
-
|
|
395
|
-
|
|
556
|
+
/** A hex colour as the 9-char form the float4 uniform path expects ("#rrggbbaa"). */
|
|
557
|
+
private static hex8(c: unknown): string {
|
|
558
|
+
if (typeof c !== "string") return "#ffffffff"
|
|
559
|
+
if (c.length === 7) return c + "ff"
|
|
560
|
+
if (c.length === 4) return "#" + c[1] + c[1] + c[2] + c[2] + c[3] + c[3] + "ff"
|
|
561
|
+
return c
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** The lightmap material for a static Mesh, from whatever lit material it carried (colour, map, roughness, metallic). */
|
|
565
|
+
private static meshMaterial(mesh: Mesh): Material {
|
|
566
|
+
const u = mesh.material?.uniforms ?? {}
|
|
396
567
|
const m = Material.lightmap()
|
|
397
|
-
m.set("baseColorFactor", hex8(u.baseColor))
|
|
568
|
+
m.set("baseColorFactor", Lightmap.hex8(u.baseColor ?? u.color))
|
|
398
569
|
if (u.baseColorMap instanceof Texture) m.set("baseColorMap", u.baseColorMap)
|
|
399
570
|
if (typeof u.roughness === "number") m.set("roughnessFactor", u.roughness)
|
|
400
571
|
if (typeof u.metallic === "number") m.set("metallicFactor", u.metallic)
|
|
401
|
-
m.set("ambientScale", ambientScale)
|
|
402
|
-
m.set("sunStrength", strength)
|
|
403
|
-
m.set("bakedAo", aoStrength)
|
|
404
|
-
m.set("lightGamma", lightGamma)
|
|
405
572
|
return m
|
|
406
573
|
}
|
|
407
574
|
|
|
408
|
-
private static
|
|
409
|
-
|
|
410
|
-
const
|
|
411
|
-
m.set("lightmap", texture)
|
|
412
|
-
m.set("lightmapST", [ st[0], st[1], st[2], st[3] ])
|
|
413
|
-
if (light) m.set("lightmapLight", light).set("lightScale", lightScale)
|
|
414
|
-
mesh.setMaterial(m)
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
private static bake(cfg: BakeConfig): void {
|
|
418
|
-
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 }
|
|
419
|
-
const ids = Uint32Array.from(Lightmap.entries.map((e) => (e.model ?? e.mesh ?? e.terrain!.node).id))
|
|
575
|
+
private static bake(cfg: BakeConfig, levelNits?: number, emissiveBake = true, transmit?: Record<string, LightmapTransmit>): void {
|
|
576
|
+
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 }
|
|
577
|
+
const ids = Uint32Array.from(Lightmap.entries.map((e) => (e.model ?? e.mesh ?? e.ground!).id))
|
|
420
578
|
const keys = Lightmap.entries.map((e) => e.key).join("\n")
|
|
421
579
|
if (ids.length === 0) { console.error("[lightmap] error: nothing registered — call Lightmap.add on the static props before Lightmap.load"); return }
|
|
422
580
|
// a `baked: false` light is held dark for the bake — the engine bakes every lit point light it finds
|
|
581
|
+
for (const l of Light._points) l._holdDark(false) // a bake started over a loaded level
|
|
423
582
|
const live = [ ...Light._points ].filter((l) => !l._baked)
|
|
424
583
|
for (const l of live) _creator.setLightIntensity?.(l.id, 0)
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
584
|
+
const ev100 = Lightmap.scene?.camera.ev100 ?? 15
|
|
585
|
+
const emissiveNits = cfg.emissiveNits ?? levelNits ?? 1.2 * 2 ** ev100
|
|
586
|
+
if (emissiveNits > 0) console.log(`[lightmap] emission 1.0 = ${Math.round(emissiveNits)} nits${emissiveBake ? "" : ", DRAWN only (env.lightmap.emissiveBake: false - the emissive surfaces light nothing)"} (${cfg.emissiveNits !== undefined ? "--emissive-nits" : levelNits !== undefined ? "the level's env.lightmap.emissiveNits" : `the camera's EV100 ${ev100.toFixed(1)}`})`)
|
|
587
|
+
_creator.lightmapBake(cfg.outDir, cfg.stem, ids, keys, cfg.size ?? 4096, cfg.texel ?? 0.05, cfg.pages ?? 4,
|
|
588
|
+
cfg.sunRays ?? 64, cfg.skyRays ?? 2048, cfg.lightRays ?? 64, cfg.lightRadius ?? 0.05, cfg.sunAngle ?? 0.53, cfg.bias ?? 0.01, cfg.seed ?? 1,
|
|
589
|
+
cfg.bounceRays ?? 128, cfg.bounces ?? 3, cfg.patchSamples ?? 64, cfg.split ?? false, DENOISE_LEVELS[cfg.denoise ?? "no-sun"], cfg.filter ?? 1,
|
|
590
|
+
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 ?? "",
|
|
591
|
+
Object.entries(transmit ?? {}).map(([name, share]) => typeof share === "number" ? `${name}=${share}` : `${name}=${share.through ?? 0},${share.diffuse ?? 0}`).join(";"))
|
|
428
592
|
for (const l of live) _creator.setLightIntensity?.(l.id, l.intensity)
|
|
429
593
|
}
|
|
430
594
|
}
|