lecodes-cli 0.20.2 → 2.0.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/README.md +56 -57
- package/dist/index.js +11052 -7592
- package/package.json +14 -8
- package/runtime/materials/decal-relief.mat +12 -1
- package/runtime/materials/decal.mat +17 -3
- package/runtime/materials/lightmap.mat +117 -138
- package/runtime/materials/particles-quad.mat +6 -2
- package/runtime/materials/terrain-lightmap.mat +123 -0
- package/runtime/materials/terrain.mat +36 -55
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +5 -0
- package/runtime/web/assets/camera-5XOM8eHl.js +1 -0
- package/runtime/web/assets/creator-2d-D9WKYM6h.js +1 -0
- package/runtime/web/assets/creator-2d-ukvEmNEp.wasm +0 -0
- package/runtime/web/assets/creator-full-BBXzQlhq.js +1 -0
- package/runtime/web/assets/creator-full-D2dSDdDj.wasm +0 -0
- package/runtime/web/assets/creator-full.js-BYswGsqR.symbols +8124 -0
- package/runtime/web/assets/creator-ui-DIc92ion.wasm +0 -0
- package/runtime/web/assets/creator-ui-_1Z_AX2M.js +1 -0
- package/runtime/web/assets/geolocation-BjTKN9_P.js +1 -0
- package/runtime/web/assets/inspect-C7x78PNs.js +2 -0
- package/runtime/web/assets/map-CyBxOSV0.js +1 -0
- package/runtime/web/assets/map-XzoOFt0E.js +2 -0
- package/runtime/web/assets/mapImpl-bxvQ2h1Z.js +823 -0
- package/runtime/web/assets/maplibre-gl-worker-B7LXXUkR.js +8 -0
- package/runtime/web/assets/materials-uri4FvPE.bin +0 -0
- package/runtime/web/assets/materials-world-CQWt8TAm.bin +0 -0
- package/runtime/web/assets/qr-scanner-CK_cYoJR.js +1 -0
- package/runtime/web/assets/uberarchive-DLH-kBhM.bin +0 -0
- package/runtime/web/index.html +36 -0
- package/runtime/web/player.js +21 -0
- package/runtime/web-page/index.html +41 -0
- package/runtime/web-page/page.css +1 -0
- package/runtime/web-page/page.js +31 -0
- package/src/api.ts +1 -302
- package/src/cli/args.ts +37 -0
- package/src/cli/command.ts +113 -0
- package/src/cli/errors.ts +13 -0
- package/src/cli/help.ts +158 -0
- package/src/cli/index.ts +7 -0
- package/src/cli/output.ts +102 -0
- package/src/cli/run.ts +106 -0
- package/src/commands/{appAndroid.ts → app/android.ts} +454 -43
- package/src/commands/{appDesktop.ts → app/desktop.ts} +64 -33
- package/src/commands/{appDesktopMac.ts → app/desktopMac.ts} +34 -27
- package/src/commands/{appIcon.ts → app/icon.ts} +11 -8
- package/src/commands/{app.ts → app/index.ts} +400 -290
- package/src/commands/{appShared.ts → app/shared.ts} +43 -44
- package/src/commands/{appTemplatesAndroid.ts → app/templates/android.ts} +72 -39
- package/src/commands/{appTemplatesGradlew.ts → app/templates/gradlew.ts} +1 -1
- package/src/commands/{appTemplates.ts → app/templates/ios.ts} +2 -2
- package/src/commands/assets.ts +23 -21
- package/src/commands/clone.ts +48 -51
- package/src/commands/compile.ts +76 -89
- package/src/commands/create.ts +46 -47
- package/src/commands/design/comments.ts +361 -0
- package/src/commands/design/context.ts +101 -0
- package/src/commands/design/index.ts +391 -0
- package/src/commands/design/snapshot.ts +134 -0
- package/src/commands/{designTemplates.ts → design/templates.ts} +4 -3
- package/src/commands/desktop.ts +94 -82
- package/src/commands/dev.ts +260 -182
- package/src/commands/diff.ts +39 -51
- package/src/commands/index.ts +64 -0
- package/src/commands/{init.ts → init/index.ts} +195 -191
- package/src/commands/{projectTemplates.ts → init/templates.ts} +64 -21
- package/src/commands/install.ts +80 -67
- package/src/commands/lightmap.ts +543 -252
- package/src/commands/link.ts +87 -94
- package/src/commands/login.ts +25 -23
- package/src/commands/navmesh.ts +169 -139
- package/src/commands/plugin.ts +46 -0
- package/src/commands/pn.ts +201 -244
- package/src/commands/pull.ts +49 -56
- package/src/commands/push.ts +48 -54
- package/src/commands/render.ts +176 -111
- package/src/commands/scene.ts +63 -55
- package/src/commands/serve.ts +58 -0
- package/src/commands/{shaders.ts → shaders/index.ts} +61 -42
- package/src/commands/{shadersNew.ts → shaders/new.ts} +3 -3
- package/src/commands/shared.ts +79 -0
- package/src/commands/status.ts +22 -26
- package/src/commands/test.ts +94 -63
- package/src/commands/thumbs.ts +185 -175
- package/src/commands/update/index.ts +226 -0
- package/src/commands/{types.ts → update/types.ts} +31 -22
- package/src/compile/collect.ts +5 -5
- package/src/compile/collectLocal.ts +3 -3
- package/src/compile/designCompile.ts +4 -4
- package/src/compile/headlessBundle.ts +16 -12
- package/src/compile/nativeStack.ts +1 -1
- package/src/compile/projectCompile.ts +12 -4
- package/src/compile/sceneCompile.ts +14 -26
- package/src/compile/screenEntry.ts +123 -127
- package/src/compile/shaders.ts +3 -3
- package/src/{lecodes-3d-editor.d.ts → declarations/lecodes-3d-editor.d.ts} +4 -0
- package/src/{lecodes-assets.d.ts → declarations/lecodes-assets.d.ts} +1 -1
- package/src/{lecodes-renderer.d.ts → declarations/lecodes-headless.d.ts} +3 -3
- package/src/dev/androidDev.ts +1 -1
- package/src/dev/clientTemplates.ts +18 -14
- package/src/dev/devServer.ts +78 -41
- package/src/dev/iosDev.ts +180 -0
- package/src/dev/webRunner.ts +83 -15
- package/src/{cmgenTool.ts → hosts/cmgenTool.ts} +2 -2
- package/src/{desktopRenderer.ts → hosts/desktopRenderer.ts} +104 -32
- package/src/{desktopScript.ts → hosts/desktopScript.ts} +151 -36
- package/src/{distRoot.ts → hosts/distRoot.ts} +9 -5
- package/src/{matcTool.ts → hosts/matcTool.ts} +2 -2
- package/src/{peerInstall.ts → hosts/peerInstall.ts} +7 -7
- package/src/{peers.ts → hosts/peers.ts} +2 -2
- package/src/{releases.ts → hosts/releases.ts} +1 -1
- package/src/index.ts +35 -480
- package/src/platform/api.ts +302 -0
- package/src/{browserAuth.ts → platform/browserAuth.ts} +1 -1
- package/src/{config.ts → platform/config.ts} +1 -1
- package/src/{serverDiff.ts → platform/serverDiff.ts} +3 -3
- package/src/plugin/contract.ts +438 -0
- package/src/plugin/emitKotlin.ts +357 -0
- package/src/plugin/emitSdk.ts +259 -0
- package/src/plugin/emitSwift.ts +336 -0
- package/src/plugin/emitWeb.ts +383 -0
- package/src/plugin/generate.ts +157 -0
- package/src/plugin/lines.ts +28 -0
- package/src/plugin/lower.ts +231 -0
- package/src/plugin/manifest.ts +192 -0
- package/src/{projectEnv.ts → project/env.ts} +33 -2
- package/src/{ignore.ts → project/ignore.ts} +164 -163
- package/src/{localFiles.ts → project/localFiles.ts} +1 -1
- package/src/{manifest.ts → project/manifest.ts} +1 -1
- package/src/{project.ts → project/materialize.ts} +2 -2
- package/src/project/paths.ts +20 -0
- package/src/{textDiff.ts → project/textDiff.ts} +1 -1
- package/src/{types.ts → project/types.ts} +0 -0
- package/runtime/materials/lightmap-baked-lite.mat +0 -175
- package/runtime/materials/lightmap-baked.mat +0 -176
- package/runtime/scene-harness.json +0 -1
- package/runtime/web/assets/__vite-browser-external-BIHI7g3E.js +0 -1
- package/runtime/web/assets/basis-C64VHDVD.js +0 -1
- package/runtime/web/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
- package/runtime/web/assets/createViewerLite-Ct_PZdof.js +0 -852
- package/runtime/web/assets/draco-BiISTFcR.js +0 -118
- package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
- package/runtime/web/assets/index-BMt7AnC5.js +0 -2
- package/runtime/web/assets/mapViewImpl-B2JcES8l.js +0 -810
- package/runtime/web/assets/maplibre-gl-worker-CJfwIrte.js +0 -8
- package/runtime/web/assets/worker-Caf-yYEI.js +0 -2
- package/runtime/web/embed.html +0 -32
- package/runtime/web/embed.js +0 -171
- package/src/commands/design.ts +0 -846
- package/src/commands/update.ts +0 -211
- package/src/util.ts +0 -146
- /package/src/{lecodes-design.d.ts → declarations/lecodes-design.d.ts} +0 -0
- /package/src/{qrcode-terminal.d.ts → declarations/qrcode-terminal.d.ts} +0 -0
- /package/src/{designMeta.ts → project/designMeta.ts} +0 -0
package/src/commands/lightmap.ts
CHANGED
|
@@ -1,290 +1,581 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync,
|
|
2
|
-
import { spawn, spawnSync } from "node:child_process"
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
3
2
|
import { tmpdir } from "node:os"
|
|
4
|
-
import { join, relative, resolve } from "node:path"
|
|
5
|
-
import {
|
|
3
|
+
import { basename, dirname, join, relative, resolve } from "node:path"
|
|
4
|
+
import { deflateSync } from "node:zlib"
|
|
6
5
|
import { buildBundle } from "./compile"
|
|
7
|
-
import { readAppConfigAt, resolveDesktopRenderer } from "./
|
|
8
|
-
import { runDesktopRender } from "../desktopRenderer"
|
|
9
|
-
import { CliError, c,
|
|
6
|
+
import { readAppConfigAt, resolveDesktopRenderer } from "./app/shared"
|
|
7
|
+
import { runDesktopRender } from "../hosts/desktopRenderer"
|
|
8
|
+
import { CliError, bool, c, defineCommand, log, logErr, note, num, str, warnErr } from "../cli"
|
|
9
|
+
import { bundleFlags, bundleOptions, localProject } from "./shared"
|
|
10
10
|
|
|
11
11
|
/*
|
|
12
|
-
* `lecodes lightmap
|
|
13
|
-
* the local project (docs/lightmap-plan.md). The app is compiled with a synthetic entry that puts it
|
|
14
|
-
* in BAKE mode (`globalThis.__lecodesLightmap = { outDir, … }`, then `import "./main"`) and run in the
|
|
15
|
-
* native desktop host; when the app calls `Lightmap.load(...)` — the point where its scene is
|
|
16
|
-
* complete — the SDK sees the flag and calls `_creator.lightmapBake` instead of loading: creator-gl
|
|
17
|
-
* traces every `Lightmap.add`-registered static against the scene's sun and point lights and writes
|
|
18
|
-
* its rasters (PNGs, INTERMEDIATES) + lightmap.bake into --out. The host's stdout carries
|
|
19
|
-
* "[lightmap] …" progress lines; "[lightmap] done" is the success marker. The atlases then ship as
|
|
20
|
-
* **KTX2**: the CLI runs KTX-Software's toktx (UASTC + zstd + mips, sRGB transfer function — the bake
|
|
21
|
-
* encodes sRGB on purpose, precision in the darks) and deletes the PNGs. toktx is REQUIRED — the
|
|
22
|
-
* command refuses to start without it, so a bake never leaves a half-finished output behind.
|
|
12
|
+
* `lecodes lightmap` — the baked lighting of a level (engines/bake).
|
|
23
13
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
14
|
+
* bake compile the app with a synthetic entry that puts it in BAKE mode (`globalThis.__lecodesLightmap`),
|
|
15
|
+
* run it in the native desktop host; when the app calls `Lightmap.load(...)` — the point where its
|
|
16
|
+
* scene is complete — the SDK calls `_creator.lightmapBake` instead of loading: the host describes
|
|
17
|
+
* every registered static to creator-bake, which traces the sun, the sky and the lamps on the
|
|
18
|
+
* NVIDIA card and writes <out>/<name>.bake + <name>-light[_n].ktx2 + <name>-aux[_n].ktx2 itself
|
|
19
|
+
* (no toktx, no PNG intermediates). "[lightmap] done" is the success marker.
|
|
20
|
+
* stats the per-instance numbers a .bake carries (mean lux, sun / sky visibility, texels, rect)
|
|
21
|
+
* diff the noise of a bake per instance: two bakes with different --seed compared texel by texel
|
|
22
|
+
* inspect PNGs from the float layers a `bake --debug-dir` kept: whole pages or one instance's rect
|
|
30
23
|
*
|
|
31
|
-
* Only
|
|
32
|
-
* this needs a host with it: a local build via LECODES_DESKTOP_EXE until the release ships it.
|
|
24
|
+
* Only a desktop host built with the CUDA toolkit has the bake — a local build via LECODES_DESKTOP_EXE.
|
|
33
25
|
* The props need lightmap UVs first: `lecodes assets doctor assets/props/*.glb --lightmap-uv`.
|
|
34
26
|
*/
|
|
35
27
|
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const n = Number(raw)
|
|
40
|
-
if (!Number.isFinite(n) || n < min || n > max) throw new CliError(`--${key} wants a number in ${min}..${max}, got "${raw}".`)
|
|
41
|
-
return n
|
|
28
|
+
const range = (name: string, value: number, min: number, max: number): number => {
|
|
29
|
+
if (value < min || value > max) throw new CliError(`--${name} wants a number in ${min}..${max}, got "${value}".`)
|
|
30
|
+
return value
|
|
42
31
|
}
|
|
43
32
|
|
|
44
|
-
|
|
33
|
+
type BakeInstance = { key: string, receiver: boolean, page?: number, rect?: number[], st?: number[], texels?: number, area?: number, charts?: number, mean?: number[], sunVis?: number, skyVis?: number, share?: number }
|
|
34
|
+
type BakeData = { version: number, size: number, pages: number, texel: number, lightScale: number, light: string[], aux: string[], direct?: string[], indirect?: string[], probes?: { file: string, size: number, levels: number, count: number, spacing: number } | null, volume?: { file: string, n: number[], cell: number[], valid: number } | null, texels?: number, instances: BakeInstance[], sun?: unknown, sky?: unknown, lights?: unknown[] }
|
|
45
35
|
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const guess = join(base, "KTX-Software", "bin", "toktx.exe")
|
|
52
|
-
if (existsSync(guess)) return guess
|
|
53
|
-
}
|
|
54
|
-
return "toktx" // on PATH
|
|
36
|
+
const readBake = (file: string): BakeData => {
|
|
37
|
+
if (!existsSync(file)) throw new CliError(`No bake at ${file} — run \`lecodes lightmap bake\` first.`)
|
|
38
|
+
const data = JSON.parse(readFileSync(file, "utf8")) as BakeData
|
|
39
|
+
if (data.version !== 4) throw new CliError(`${file} is not a creator-bake file (version ${String(data.version)}) — rebake.`)
|
|
40
|
+
return data
|
|
55
41
|
}
|
|
56
42
|
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (p.status === 0) return (p.stdout ?? "").toString().trim() || "toktx"
|
|
61
|
-
} catch { /* not found */ }
|
|
62
|
-
return null
|
|
43
|
+
const probeLayoutOf = (s: string): "rooms" | "grid" => {
|
|
44
|
+
if (s !== "rooms" && s !== "grid") throw new CliError(`--probe-layout is rooms or grid, got "${s}".`)
|
|
45
|
+
return s
|
|
63
46
|
}
|
|
64
47
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return toktx
|
|
48
|
+
/** `--probe-box x0,y0,z0,x1,y1,z1` → six numbers, min then max per axis */
|
|
49
|
+
const probeBoxOf = (s: string): [number, number, number, number, number, number] => {
|
|
50
|
+
const v = s.split(",").map((t) => Number(t.trim()))
|
|
51
|
+
if (v.length !== 6 || v.some((x) => !Number.isFinite(x))) throw new CliError(`--probe-box wants six numbers x0,y0,z0,x1,y1,z1, got "${s}".`)
|
|
52
|
+
return [ Math.min(v[0], v[3]), Math.min(v[1], v[4]), Math.min(v[2], v[5]), Math.max(v[0], v[3]), Math.max(v[1], v[4]), Math.max(v[2], v[5]) ]
|
|
71
53
|
}
|
|
72
54
|
|
|
73
|
-
/**
|
|
74
|
-
*
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
//
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
55
|
+
/** A .bake with no pages and no scale: enough for the compiler to stage the files and for Lightmap.load to
|
|
56
|
+
* no-op with a "rebake" warning. The two KTX2 placeholders are 4x4 pages of the right formats. */
|
|
57
|
+
const placeholderBake = (stem: string): string => JSON.stringify({ version: 4, placeholder: true, size: 4, pages: 0, texel: 0, lightScale: 0, light: [ `${stem}-light.ktx2` ], aux: [ `${stem}-aux.ktx2` ], instances: [] }) + "\n"
|
|
58
|
+
const ktx2Placeholder = (vkFormat: number): Buffer => {
|
|
59
|
+
// one 4x4 level, no supercompression; BC6H (143) = one 16-byte block of zeros, RGBA8 (37) = 64 bytes
|
|
60
|
+
const raw = Buffer.alloc(vkFormat === 143 ? 16 : 64)
|
|
61
|
+
const dfdSize = 44
|
|
62
|
+
const dfdOffset = 80 + 24
|
|
63
|
+
const b = Buffer.alloc(dfdOffset + dfdSize + raw.length)
|
|
64
|
+
Buffer.from([ 0xAB, 0x4B, 0x54, 0x58, 0x20, 0x32, 0x30, 0xBB, 0x0D, 0x0A, 0x1A, 0x0A ]).copy(b, 0)
|
|
65
|
+
let o = 12
|
|
66
|
+
for (const v of [ vkFormat, 1, 4, 4, 0, 0, 1, 1, 0 ]) { b.writeUInt32LE(v, o); o += 4 }
|
|
67
|
+
b.writeUInt32LE(dfdOffset, o); b.writeUInt32LE(dfdSize, o + 4); o += 8
|
|
68
|
+
b.writeUInt32LE(0, o); b.writeUInt32LE(0, o + 4); o += 8
|
|
69
|
+
b.writeBigUInt64LE(0n, o); b.writeBigUInt64LE(0n, o + 8); o += 16
|
|
70
|
+
b.writeBigUInt64LE(BigInt(dfdOffset + dfdSize), o); b.writeBigUInt64LE(BigInt(raw.length), o + 8); b.writeBigUInt64LE(BigInt(raw.length), o + 16); o += 24
|
|
71
|
+
b.writeUInt32LE(dfdSize, o)
|
|
72
|
+
raw.copy(b, dfdOffset + dfdSize)
|
|
73
|
+
return b
|
|
74
|
+
}
|
|
75
|
+
const ensurePlaceholders = (outDir: string, stem: string): void => {
|
|
76
|
+
const missing = (f: string): boolean => !existsSync(f) || statSync(f).size === 0
|
|
77
|
+
const bake = join(outDir, `${stem}.bake`)
|
|
78
|
+
if (missing(bake)) writeFileSync(bake, placeholderBake(stem))
|
|
79
|
+
const light = join(outDir, `${stem}-light.ktx2`), aux = join(outDir, `${stem}-aux.ktx2`), probes = join(outDir, `${stem}-probes.ktx2`)
|
|
80
|
+
if (missing(light)) writeFileSync(light, ktx2Placeholder(143))
|
|
81
|
+
if (missing(aux)) writeFileSync(aux, ktx2Placeholder(37))
|
|
82
|
+
if (missing(probes)) writeFileSync(probes, ktx2Placeholder(143)) // a scene may list the probes before the first bake wrote them
|
|
83
|
+
const grid = join(outDir, `${stem}.lgrid`)
|
|
84
|
+
if (missing(grid)) writeFileSync(grid, "NONE") // ... and the light grid: the engine refuses it with a "rebake" line
|
|
85
|
+
}
|
|
90
86
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
87
|
+
const bake = defineCommand({
|
|
88
|
+
name: "bake",
|
|
89
|
+
summary: "Bake the sun, the sky and the lamps into a lux atlas for the level's statics",
|
|
90
|
+
description: "Runs the app in the desktop host in bake mode: the statics are what the app registers with Lightmap.add (a scene file does it for every node no Physics aspect moves), the bake fires at its Lightmap.load call. Writes <out>/<name>.bake + <name>-light[_n].ktx2 (irradiance, BC6H) + <name>-aux[_n].ktx2 (sky visibility, the directional share and direction of the light) + <name>-probes.ktx2 (the reflection probes: a cubemap of the level's radiance per probe, prefiltered per roughness; --probe-spacing 0 = none). Needs a desktop host built with the CUDA toolkit and an NVIDIA card (LECODES_DESKTOP_EXE for a local build).\n\nProps need lightmap UVs first: lecodes assets doctor assets/props/*.glb --lightmap-uv",
|
|
91
|
+
flags: {
|
|
92
|
+
out: str("where the atlas lands", { value: "<dir>", default: "assets/lightmap" }),
|
|
93
|
+
name: str("file stem — one per baked level", { value: "<stem>", default: "lightmap" }),
|
|
94
|
+
...bundleFlags,
|
|
95
|
+
entry: str("the file that builds the scene and calls Lightmap.load", { value: "<file>", default: "main.ts" }),
|
|
96
|
+
size: num("atlas page edge (texels)", { default: 4096 }),
|
|
97
|
+
texel: num("texel size (m); the bake coarsens only when the pages are full", { default: 0.05 }),
|
|
98
|
+
pages: num("atlas pages to fill before the bake coarsens the texel", { default: 4 }),
|
|
99
|
+
"sun-rays": num("shadow rays into the sun's disc per texel", { default: 64 }),
|
|
100
|
+
"sky-rays": num("hemisphere rays per texel (the sky's irradiance and visibility). 2048 (2026-09-16): shadow rays are cheap (the school's direct pass 2 s a page) and a texel deep in a corridor sees the sky through a far window with 1 ray in 256 — every hit was 410 lux", { default: 2048 }),
|
|
101
|
+
"light-rays": num("shadow rays per lamp per texel (64: at 32 the school's lamp layer read 43 % noise between two seeds, the ceilings and doors next to a lamp 48 %)", { default: 64 }),
|
|
102
|
+
"bounce-rays": num("bounce paths per texel; 0 = direct light only. 128 (2026-09-16): a dark room's floor in the school reads 240 % noise at 64, 160 % at 128, 130 % at 256 — the last doubling costs 2x the bake time for little", { default: 128 }),
|
|
103
|
+
bounces: num("path vertices at most (Russian roulette from the third); 0 = direct light only", { default: 3 }),
|
|
104
|
+
"patch-samples": num("next-event samples per texel over the BRIGHT PATCHES of a first direct pass (sunlit floors, the ceiling around a lamp) — what keeps the bounce in dark rooms from arriving as impulses (every path vertex takes one more); 0 = the cosine paths alone", { default: 64 }),
|
|
105
|
+
split: bool("also write <name>-direct[_n].ktx2 / <name>-indirect[_n].ktx2 — the pages the \"direct\" / \"indirect\" views bind (list them as env.lightmap.direct / .indirect)"),
|
|
106
|
+
denoise: str("OptiX's AI denoiser over: indirect = the bounce alone (the range 2026-09-16: bounce noise 57 % -> 23 % of the mean, per-instance means unchanged), no-sun = + the sky and the lamps (the sky through a small opening: 0..3 hits of 256 rays, a texel is one of four values), all = + the sun (its shadow edges too: a sunlit pillar's mean moved 6 %), none. School, two seeds: no-sun took the sky 1.8 % -> 0.5 %, the lamps 43 % -> 21 %", { value: "<none|indirect|no-sun|all>", default: "no-sun" }),
|
|
107
|
+
"light-radius": num("lamps: the emitter's radius (m) — soft shadows, no 1/d² singularity", { default: 0.05 }),
|
|
108
|
+
"sun-angle": num("sun angular diameter (deg)", { default: 0.53 }),
|
|
109
|
+
bias: num("ray start offset from the surface (m)", { default: 0.01 }),
|
|
110
|
+
filter: num("the texel's reconstruction filter radius, in texels: every ray starts at a tent-distributed point of the texel's footprint, so a shadow's edge is a ramp of this width instead of a step at the texel grid (1 = ±1 texel, the classic; 0.5 = within the texel; 0 = the centre alone)", { default: 1 }),
|
|
111
|
+
seed: num("the RNG seed: two bakes that differ only here differ by their noise (`lightmap diff`)", { default: 1 }),
|
|
112
|
+
"probe-spacing": num("reflection probes: the grid spacing in metres over the receivers (one probe per cell a static touches, minus the points inside geometry; every static reflects the nearest it can see); 0 = no probes", { default: 4 }),
|
|
113
|
+
"probe-size": num("a probe cubemap face's edge in texels (a power of two, 16..128)", { default: 32 }),
|
|
114
|
+
"probe-rays": num("paths per probe texel; the first hit of each takes four times the direct-light samples (the picture), so noise on a mirror is the one thing this buys", { default: 128 }),
|
|
115
|
+
"probe-indoor": bool("keep only the probes under a roof — those whose open share of the sphere (rays from the point that meet nothing) is at most 15 %, holes in the geometry or not: outdoor statics reflect the sky through their baked sky visibility instead — an environment map without the ground, but no outdoor probe can reach indoors and the atlas shrinks"),
|
|
116
|
+
"probe-layout": str("rooms = every indoor point of the grid slides to the middle of its room and the points of one room merge: one probe per room, far from the walls, its box the room (stations every ~8 m along a room over 12 m), outdoor points stay on the grid; grid = everything stays on the grid. Either way the pages carry which probes each block of texels sees (rays at bake time)", { value: "<rooms|grid>", default: "rooms" }),
|
|
117
|
+
"min-texels": num("instances smaller than this many ATLAS TEXELS along every axis (x --texel: 12 at 5 cm = 0.6 m) get no place in the atlas: they still cast their baked shadow and bounce, their own light is baked where they stand (six sides, no atlas texels) — cartridges, boxes, pipe elbows, a fire extinguisher: a baked shadow does not read on them and their tight charts are where the seams are. 0 = everything bakes; --dry-run lists what falls under it", { default: 12 }),
|
|
118
|
+
"emissive-nits": num("EMISSIVE SURFACES LIGHT THE LEVEL: cd/m² of emission 1.0 (emissive factor x strength x map) — every lit region of an emissive surface (a panel drawn in a ceiling's texture, a screen, a sign) bakes as a rectangle lamp. Default: what the level's camera makes of 1.0, 1.2 x 2^EV100, so a panel lights as brightly as it looks. 0 = emissive surfaces light nothing"),
|
|
119
|
+
"detail-texels": num("A SMALL SURFACE GETS A FINER TEXEL: an instance whose surface is under this many atlas texels (area / texel²) is laid out denser by sqrt(this / its texels), 3x at most — a pipe of 1 m is ~300 texels at 5 cm, its charts a few texels wide and every chart edge a step; at 2000 it bakes at ~2 cm. 0 = off", { default: 2000 }),
|
|
120
|
+
volume: num("THE LIGHT GRID for movers: the cell size in metres (0.75 of it tall) — per cell in free space the baked ambient light (sky, lamps, every bounce; not the direct sun, which stays real-time on movers), links between cells a ray connects so nothing blends through a wall; written as <name>.lgrid, list it as env.lightmap.volume. 0 = none", { default: 1 }),
|
|
121
|
+
"probe-box": str("only grid cells whose centre lies inside this world box get a probe: x0,y0,z0,x1,y1,z1 in metres — keeps probes off a roof top the player never sees (the range: 79 probes above a ceiling with holes where the fixtures were bled the sky onto the walls). Default: the receivers' bounds", { value: "<x0,y0,z0,x1,y1,z1>" }),
|
|
122
|
+
"debug-dir": str("keep the float layers per page (PFM) here for `lightmap inspect` — gigabytes per level; a `.lightmap/` dir inside the project is skipped by the compile scan, anywhere else must be outside the project", { value: "<dir>" }),
|
|
123
|
+
"dry-run": bool("only the atlas LAYOUT: pages, texel size, the largest rects — no tracing, nothing written (seconds, not minutes; the host still loads the level)"),
|
|
124
|
+
frames: num("host frames to run before giving up — load headroom", { default: 900 }),
|
|
125
|
+
timeout: num("host time cap (ms; default: none)"),
|
|
126
|
+
width: num("host window width (default: app.json desktop.window, else 1280)"),
|
|
127
|
+
height: num("host window height (default: app.json desktop.window, else 720)"),
|
|
128
|
+
renderer: str("desktop host + shader variant (default: app.json desktop.renderer, else gl)", { value: "<gl|vulkan>" }),
|
|
129
|
+
logs: bool("forward the host's output"),
|
|
130
|
+
},
|
|
131
|
+
examples: [ "lecodes lightmap bake", "lecodes lightmap bake --entry school.ts --name school --texel 0.04 --pages 4 --debug-dir .lightmap/school --logs" ],
|
|
132
|
+
run: async ({ flags }) => {
|
|
133
|
+
const { root } = localProject()
|
|
134
|
+
const outDir = resolve(root, flags.out)
|
|
135
|
+
const stem = flags.name
|
|
136
|
+
if (!/^[A-Za-z0-9_-]+$/.test(stem)) throw new CliError(`--name wants a plain file stem (letters, digits, - and _), got "${stem}".`)
|
|
137
|
+
const entryFile = flags.entry
|
|
138
|
+
if (!existsSync(resolve(root, entryFile))) {
|
|
139
|
+
throw new CliError(`No ${entryFile} in ${root} — pass --entry <file> (the file that builds the scene and calls Lightmap.load).`)
|
|
140
|
+
}
|
|
141
|
+
const debugDir = flags["debug-dir"] ? resolve(root, flags["debug-dir"]) : ""
|
|
142
|
+
const cfg = {
|
|
143
|
+
outDir: outDir.replace(/\\/g, "/"),
|
|
144
|
+
stem,
|
|
145
|
+
size: range("size", flags.size, 256, 16384),
|
|
146
|
+
texel: range("texel", flags.texel, 0.001, 2),
|
|
147
|
+
pages: range("pages", flags.pages, 1, 16),
|
|
148
|
+
sunRays: range("sun-rays", flags["sun-rays"], 1, 4096),
|
|
149
|
+
skyRays: range("sky-rays", flags["sky-rays"], 1, 16384),
|
|
150
|
+
lightRays: range("light-rays", flags["light-rays"], 1, 1024),
|
|
151
|
+
lightRadius: range("light-radius", flags["light-radius"], 0.001, 5),
|
|
152
|
+
sunAngle: range("sun-angle", flags["sun-angle"], 0.01, 30),
|
|
153
|
+
bias: range("bias", flags.bias, 0.0001, 1),
|
|
154
|
+
seed: Math.max(0, Math.floor(flags.seed)),
|
|
155
|
+
bounceRays: range("bounce-rays", flags["bounce-rays"], 0, 16384),
|
|
156
|
+
bounces: range("bounces", flags.bounces, 0, 64),
|
|
157
|
+
patchSamples: range("patch-samples", flags["patch-samples"], 0, 1024),
|
|
158
|
+
split: !!flags.split,
|
|
159
|
+
denoise: flags.denoise,
|
|
160
|
+
filter: range("filter", flags.filter, 0, 4),
|
|
161
|
+
probeSpacing: range("probe-spacing", flags["probe-spacing"], 0, 100),
|
|
162
|
+
probeSize: range("probe-size", flags["probe-size"], 16, 128),
|
|
163
|
+
probeRays: range("probe-rays", flags["probe-rays"], 1, 4096),
|
|
164
|
+
probeIndoor: !!flags["probe-indoor"],
|
|
165
|
+
probeLayout: probeLayoutOf(flags["probe-layout"]),
|
|
166
|
+
minTexels: range("min-texels", flags["min-texels"], 0, 10000),
|
|
167
|
+
volumeSpacing: range("volume", flags.volume, 0, 100),
|
|
168
|
+
detailTexels: range("detail-texels", flags["detail-texels"], 0, 1e7),
|
|
169
|
+
...(flags["emissive-nits"] !== undefined ? { emissiveNits: range("emissive-nits", flags["emissive-nits"], 0, 1e7) } : {}),
|
|
170
|
+
probeBox: flags["probe-box"] ? probeBoxOf(flags["probe-box"]) : undefined,
|
|
171
|
+
debugDir: debugDir.replace(/\\/g, "/"),
|
|
172
|
+
}
|
|
173
|
+
const frames = range("frames", flags.frames, 30, 100000)
|
|
174
|
+
const win = readAppConfigAt(root)?.desktop?.window
|
|
175
|
+
const width = flags.width || win?.width || 1280
|
|
176
|
+
const height = flags.height || win?.height || 720
|
|
94
177
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const stem = flagStr(args, "name") ?? "lightmap"
|
|
101
|
-
if (!/^[A-Za-z0-9_-]+$/.test(stem)) throw new CliError(`--name wants a plain file stem (letters, digits, - and _), got "${stem}".`)
|
|
102
|
-
const entryFlag = flagStr(args, "entry")
|
|
103
|
-
const entryFile = entryFlag ?? "main.ts"
|
|
104
|
-
if (!existsSync(resolve(root, entryFile))) {
|
|
105
|
-
throw new CliError(`No ${entryFile} in ${root} — pass --entry <file> (the file that builds the scene and calls Lightmap.load).`)
|
|
106
|
-
}
|
|
107
|
-
const cfg = {
|
|
108
|
-
outDir: outDir.replace(/\\/g, "/"),
|
|
109
|
-
size: num(args, "size", 4096, 256, 16384),
|
|
110
|
-
texel: num(args, "texel", 0.02, 0.001, 2),
|
|
111
|
-
// atlas PAGES: the bake fills up to this many atlases of --size at the requested texel before it
|
|
112
|
-
// coarsens; every page is one more texture the scene lists under env.lightmap.texture
|
|
113
|
-
pages: num(args, "pages", 4, 1, 16),
|
|
114
|
-
sunRays: num(args, "sun-rays", 32, 1, 1024),
|
|
115
|
-
aoRays: num(args, "ao-rays", 64, 1, 4096),
|
|
116
|
-
aoDistance: num(args, "ao-distance", 2, 0.01, 1000),
|
|
117
|
-
bias: num(args, "bias", 0.02, 0.0001, 1),
|
|
118
|
-
sunAngle: num(args, "sun-angle", 0.5, 0.01, 30),
|
|
119
|
-
// point lights: shadow rays per light per texel, toward a disc of this radius at the light
|
|
120
|
-
lightRays: num(args, "light-rays", 16, 1, 256),
|
|
121
|
-
lightRadius: num(args, "light-radius", 0.1, 0.001, 5),
|
|
122
|
-
// One bounce of indirect from the lamps, gathered on the AO rays (the albedo it assumes for
|
|
123
|
-
// every surface it hits). Off by default: it is the difference between "a room lit by its
|
|
124
|
-
// lamps" and "lit fixtures in a black room", but it costs a full Intersect per AO ray.
|
|
125
|
-
bounce: num(args, "bounce", 0, 0, 1),
|
|
126
|
-
bounceDistance: num(args, "bounce-distance", 8, 0.5, 100),
|
|
127
|
-
bounceRays: num(args, "bounce-rays", 1, 1, 32),
|
|
128
|
-
coplanarSkip: num(args, "coplanar-skip", 0.05, 0, 1),
|
|
129
|
-
// geometry-aware denoise of the traced texels (radius in px; 0 = off)
|
|
130
|
-
denoise: num(args, "denoise", 2, 0, 4),
|
|
131
|
-
// the light volume for dynamic objects (docs/lightmap-plan.md §9); 0 = skip it
|
|
132
|
-
volumeCell: flagBool(args, "no-volume") ? 0 : num(args, "cell", 0.5, 0.05, 20),
|
|
133
|
-
}
|
|
134
|
-
const frames = num(args, "frames", 900, 30, 100000)
|
|
135
|
-
const win = readAppConfigAt(root)?.desktop?.window
|
|
136
|
-
const width = Number(flagStr(args, "width")) || win?.width || 1280
|
|
137
|
-
const height = Number(flagStr(args, "height")) || win?.height || 720
|
|
138
|
-
const logs = flagBool(args, "logs")
|
|
178
|
+
// The synthetic entry: bake mode flag first, then the real entry. It lives at the project root so
|
|
179
|
+
// the real entry's own relative imports resolve unchanged.
|
|
180
|
+
const importPath = "./" + entryFile.replace(/\\/g, "/").replace(/\.tsx?$/, "")
|
|
181
|
+
const entryPath = "/.lightmap-entry.ts"
|
|
182
|
+
const entryText = `globalThis.__lecodesLightmap = ${JSON.stringify(cfg)}\nimport ${JSON.stringify(importPath)}\n`
|
|
139
183
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
184
|
+
// The bake files must exist BEFORE the compile: the scene references them through asset(), and a
|
|
185
|
+
// missing asset is a broken bundle. The first bake starts from placeholders the real files overwrite.
|
|
186
|
+
mkdirSync(outDir, { recursive: true })
|
|
187
|
+
ensurePlaceholders(outDir, stem)
|
|
188
|
+
if (debugDir) mkdirSync(debugDir, { recursive: true })
|
|
145
189
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
190
|
+
const desktopRenderer = resolveDesktopRenderer(root, flags.renderer)
|
|
191
|
+
const { js, resources } = await buildBundle(bundleOptions(flags), {
|
|
192
|
+
localAssets: true, desktopRenderer,
|
|
193
|
+
extraEntries: [{ path: entryPath, type: "text", text: entryText }],
|
|
194
|
+
entryOverride: entryPath,
|
|
195
|
+
})
|
|
152
196
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
197
|
+
// The host writes the bake itself; the PNG the render driver insists on goes to a scratch dir.
|
|
198
|
+
const scratch = mkdtempSync(join(tmpdir(), "lecodes-lightmap-"))
|
|
199
|
+
const outPng = join(scratch, "frame.png")
|
|
200
|
+
const bounce = cfg.bounceRays > 0 && cfg.bounces > 0 ? `, ${cfg.bounceRays} bounce paths of ${cfg.bounces}${cfg.patchSamples > 0 ? ` + ${cfg.patchSamples} patch samples` : ""}${cfg.denoise !== "none" ? `, denoised (${cfg.denoise})` : ", raw"}` : ", no bounce"
|
|
201
|
+
note(`Baking up to ${cfg.pages} × ${cfg.size}² at ${(cfg.texel * 100).toFixed(1)} cm texels (${cfg.sunRays} sun + ${cfg.skyRays} sky + ${cfg.lightRays} lamp rays${bounce}) → ${relative(root, outDir) || "."}/${stem}.*…`)
|
|
202
|
+
let output = ""
|
|
203
|
+
if (flags["dry-run"]) process.env.CBK_LAYOUT_ONLY = "1" // the host inherits it: creator-bake stops after the layout
|
|
204
|
+
try {
|
|
205
|
+
const timeoutMs = flags.timeout || 24 * 3600 * 1000
|
|
206
|
+
const res = await runDesktopRender(js, resources, {
|
|
207
|
+
renderer: desktopRenderer, width, height, frames, outPng, logs: flags.logs, timeoutMs,
|
|
208
|
+
stopOnLine: (chunk) => chunk.includes("[lightmap] done") || chunk.includes("[lightmap] error"),
|
|
209
|
+
})
|
|
210
|
+
output = res.output
|
|
211
|
+
} catch (e) {
|
|
212
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
213
|
+
if (!/\[lightmap\] done/.test(msg)) throw e
|
|
214
|
+
output = msg
|
|
215
|
+
} finally {
|
|
216
|
+
try { rmSync(scratch, { recursive: true, force: true }) } catch { /* a killed host may hold the dir a moment longer */ }
|
|
217
|
+
}
|
|
159
218
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
|
|
219
|
+
const lines = output.split(/\r?\n/).filter((l) => l.startsWith("[lightmap]"))
|
|
220
|
+
for (const l of lines) {
|
|
221
|
+
const text = l.slice("[lightmap]".length).trim()
|
|
222
|
+
if (text === "done") continue
|
|
223
|
+
logErr(text.startsWith("error") ? c.red(`✗ ${text}`) : text.startsWith("warning") ? c.yellow(` ${text}`) : c.dim(` ${text}`))
|
|
224
|
+
}
|
|
225
|
+
if (flags["dry-run"] && /\[lightmap\] done/.test(output)) {
|
|
226
|
+
for (const l of lines) if (/atlas \d+ x|larger rect|largest rect|do not fit|charts touch/.test(l)) logErr(l.replace(/^\[lightmap\] /, " "))
|
|
227
|
+
logErr(c.green("✓ layout only — nothing written"))
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
if (/\[lightmap\] done/.test(output)) {
|
|
231
|
+
const data = readBake(join(outDir, `${stem}.bake`))
|
|
232
|
+
const rel = (f: string): string => relative(root, join(outDir, f)).replace(/\\/g, "/")
|
|
233
|
+
const assets = (files: string[]): string => files.length === 1 ? `asset('<rel>/${rel(files[0])}')` : `[${files.map((f) => `asset('<rel>/${rel(f)}')`).join(", ")}]`
|
|
234
|
+
const splitFiles = data.direct && data.indirect ? ` + ${data.direct.join(", ")} + ${data.indirect.join(", ")}` : ""
|
|
235
|
+
const splitDef = data.direct && data.indirect ? `, direct: ${assets(data.direct)}, indirect: ${assets(data.indirect)}` : ""
|
|
236
|
+
const probeFiles = data.probes ? ` + ${data.probes.file} (${data.probes.count} probe(s) of ${data.probes.size}² at ${data.probes.spacing} m)` : ""
|
|
237
|
+
const gridFile = data.volume ? ` + ${data.volume.file} (light grid ${data.volume.n.join(" x ")}, ${data.volume.valid} cell(s) in free space)` : ""
|
|
238
|
+
const probeDef = (data.probes ? `, probes: asset('<rel>/${rel(data.probes.file)}')` : "") + (data.volume ? `, volume: asset('<rel>/${rel(data.volume.file)}')` : "")
|
|
239
|
+
logErr(c.green(`✓ baked → ${relative(root, outDir) || "."}/${stem}.bake + ${data.light.join(", ")} + ${data.aux.join(", ")}${splitFiles}${probeFiles}${gridFile}`))
|
|
240
|
+
logErr(c.dim(` scene file: env.lightmap: { data: asset('<rel>/${rel(`${stem}.bake`)}'), light: ${assets(data.light)}, aux: ${assets(data.aux)}${splitDef}${probeDef} } — asset() paths are relative to the scene file`))
|
|
241
|
+
if (debugDir) logErr(c.dim(` debug layers: ${relative(root, debugDir)} (lecodes lightmap inspect --bake ${relative(root, join(outDir, `${stem}.bake`)).replace(/\\/g, "/")} --debug-dir ${relative(root, debugDir).replace(/\\/g, "/")})`))
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
if (lines.some((l) => /no bake module/.test(l))) {
|
|
245
|
+
throw new CliError("This desktop host has no bake module. Build hosts/desktop with the CUDA toolkit installed and point LECODES_DESKTOP_EXE at build/lecodes-desktop.exe.")
|
|
246
|
+
}
|
|
247
|
+
if (lines.some((l) => /error/.test(l))) throw new CliError("The bake failed — see the lines above.")
|
|
248
|
+
warnErr("The host ran to its frame cap without baking. Make sure the app calls Lightmap.load(...) after the static scene is built, and skips menus when Lightmap.baking is true. More load headroom: --frames <n>. Host output: --logs.")
|
|
249
|
+
throw new CliError("Nothing was baked.")
|
|
250
|
+
},
|
|
251
|
+
})
|
|
184
252
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
253
|
+
// ---- stats ----------------------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
const lum = (rgb: number[] | undefined): number => rgb ? 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2] : 0
|
|
256
|
+
|
|
257
|
+
const stats = defineCommand({
|
|
258
|
+
name: "stats",
|
|
259
|
+
summary: "The per-instance numbers a .bake carries: mean lux, sun / sky visibility, texels, rect",
|
|
260
|
+
description: "Reads the .bake alone (no debug layers needed). One row per receiver, sorted by mean lux unless --sort says otherwise; --key filters by a regular expression over the keys.",
|
|
261
|
+
flags: {
|
|
262
|
+
bake: str("the .bake file", { value: "<file>", default: "assets/lightmap/lightmap.bake" }),
|
|
263
|
+
key: str("only instances whose key matches this regular expression", { value: "<regex>" }),
|
|
264
|
+
sort: str("row order", { value: "<lux|key|texels|sun|sky>", default: "lux" }),
|
|
265
|
+
limit: num("rows to print (0 = all)", { default: 60 }),
|
|
266
|
+
},
|
|
267
|
+
examples: [ "lecodes lightmap stats --bake assets/school/lightmap/school.bake --key Wall", "lecodes lightmap stats --sort sky --limit 0" ],
|
|
268
|
+
run: ({ flags }) => {
|
|
269
|
+
const { root } = localProject()
|
|
270
|
+
const data = readBake(resolve(root, flags.bake))
|
|
271
|
+
const re = flags.key ? new RegExp(flags.key) : null
|
|
272
|
+
let rows = data.instances.filter((i) => i.receiver && (!re || re.test(i.key)))
|
|
273
|
+
const sorters: Record<string, (a: BakeInstance, b: BakeInstance) => number> = {
|
|
274
|
+
lux: (a, b) => lum(b.mean) - lum(a.mean),
|
|
275
|
+
key: (a, b) => a.key.localeCompare(b.key),
|
|
276
|
+
texels: (a, b) => (b.texels ?? 0) - (a.texels ?? 0),
|
|
277
|
+
sun: (a, b) => (b.sunVis ?? 0) - (a.sunVis ?? 0),
|
|
278
|
+
sky: (a, b) => (b.skyVis ?? 0) - (a.skyVis ?? 0),
|
|
207
279
|
}
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
280
|
+
const sorter = sorters[flags.sort]
|
|
281
|
+
if (!sorter) throw new CliError(`--sort: expected lux, key, texels, sun or sky, got ${JSON.stringify(flags.sort)}`)
|
|
282
|
+
rows = rows.sort(sorter)
|
|
283
|
+
const total = rows.length
|
|
284
|
+
if (flags.limit > 0) rows = rows.slice(0, flags.limit)
|
|
285
|
+
log(`${basename(flags.bake)}: ${data.pages} page(s) of ${data.size}, ${(data.texel * 100).toFixed(2)} cm texels, ${data.texels ?? "?"} texels, lightScale ${data.lightScale}`)
|
|
286
|
+
log(`${"key".padEnd(48)} ${"page".padStart(4)} ${"rect".padStart(20)} ${"texels".padStart(8)} ${"lux".padStart(9)} ${"sun".padStart(6)} ${"sky".padStart(6)} ${"dir".padStart(6)}`)
|
|
287
|
+
for (const r of rows) {
|
|
288
|
+
const rect = r.rect ? `${r.rect[0]},${r.rect[1]} ${r.rect[2]}x${r.rect[3]}` : ""
|
|
289
|
+
log(`${r.key.slice(0, 48).padEnd(48)} ${String(r.page ?? "").padStart(4)} ${rect.padStart(20)} ${String(r.texels ?? "").padStart(8)} ${lum(r.mean).toFixed(1).padStart(9)} ${(r.sunVis ?? 0).toFixed(3).padStart(6)} ${(r.skyVis ?? 0).toFixed(3).padStart(6)} ${(r.share ?? 0).toFixed(3).padStart(6)}`)
|
|
212
290
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
291
|
+
if (total > rows.length) log(`… ${total - rows.length} more (--limit 0 for all)`)
|
|
292
|
+
},
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
// ---- diff -----------------------------------------------------------------------------------------
|
|
296
|
+
// Two bakes that differ only by --seed are two independent samples of the same estimate: per texel the
|
|
297
|
+
// difference of the two, over root two, is the noise of ONE bake. Summed per instance and put over the mean
|
|
298
|
+
// it is the number "is this speckle or a feature" — instead of an eye on a screenshot.
|
|
299
|
+
|
|
300
|
+
const diff = defineCommand({
|
|
301
|
+
name: "diff",
|
|
302
|
+
summary: "The noise of a bake, per instance: two bakes that differ only by --seed, compared texel by texel",
|
|
303
|
+
description: "Bake the level twice with different --seed (and --out / --debug-dir, so the second does not overwrite the first — a `.lightmap/` dir inside the project is skipped by the compile scan). Both must share the layout (same instances, rects and pages — a seed changes nothing else). Per instance: the mean of the layer, the noise as the RMS of (a − b) / √2 over the mean, in %, and the share of texels that differ by more than half their mean. Sorted by noise unless --sort says otherwise.",
|
|
304
|
+
flags: {
|
|
305
|
+
bake: str("the first .bake", { value: "<file>", default: "assets/lightmap/lightmap.bake" }),
|
|
306
|
+
"debug-dir": str("where the first bake kept its PFM layers", { value: "<dir>", default: ".lightmap" }),
|
|
307
|
+
bake2: str("the second .bake (another --seed)", { value: "<file>" }),
|
|
308
|
+
"debug-dir2": str("where the second bake kept its PFM layers", { value: "<dir>" }),
|
|
309
|
+
layer: str("which layer to compare", { value: "<irradiance|bounce|sun|sky|lamps>", default: "irradiance" }),
|
|
310
|
+
key: str("only instances whose key matches this regular expression", { value: "<regex>" }),
|
|
311
|
+
sort: str("row order", { value: "<noise|lux|key|texels>", default: "noise" }),
|
|
312
|
+
"min-lux": num("instances whose mean is under this are 'dark': no noise figure, sorted last (a relative noise over nothing is not a number)", { default: 1 }),
|
|
313
|
+
limit: num("rows to print (0 = all)", { default: 40 }),
|
|
314
|
+
},
|
|
315
|
+
examples: [
|
|
316
|
+
"lecodes lightmap bake --entry range.ts --name range --seed 2 --out .lightmap/range-seed2/bake --debug-dir .lightmap/range-seed2 --logs",
|
|
317
|
+
"lecodes lightmap diff --bake assets/range/lightmap/range.bake --debug-dir .lightmap/range --bake2 .lightmap/range-seed2/bake/range.bake --debug-dir2 .lightmap/range-seed2 --layer bounce",
|
|
318
|
+
],
|
|
319
|
+
run: ({ flags }) => {
|
|
320
|
+
const { root } = localProject()
|
|
321
|
+
if (!flags.bake2 || !flags["debug-dir2"]) throw new CliError("--bake2 and --debug-dir2 name the second bake (another --seed).")
|
|
322
|
+
const a = readBake(resolve(root, flags.bake)), b = readBake(resolve(root, flags.bake2))
|
|
323
|
+
if (a.size !== b.size || a.pages !== b.pages) throw new CliError(`The bakes differ in layout (${a.pages} × ${a.size} vs ${b.pages} × ${b.size}) — a diff needs the same scene baked twice with another --seed.`)
|
|
324
|
+
const ra = a.instances.filter((i) => i.receiver), rb = b.instances.filter((i) => i.receiver)
|
|
325
|
+
if (ra.length !== rb.length || ra.some((i, k) => i.key !== rb[k].key || i.page !== rb[k].page || String(i.rect) !== String(rb[k].rect))) {
|
|
326
|
+
throw new CliError("The bakes list different instances or rects — a diff needs the same scene baked twice with another --seed.")
|
|
327
|
+
}
|
|
328
|
+
const stemA = basename(flags.bake, ".bake"), stemB = basename(flags.bake2, ".bake")
|
|
329
|
+
const dirA = resolve(root, flags["debug-dir"]), dirB = resolve(root, flags["debug-dir2"])
|
|
330
|
+
const layer = flags.layer
|
|
331
|
+
if (![ "irradiance", "bounce", "sun", "sky", "lamps" ].includes(layer)) throw new CliError(`--layer: expected irradiance, bounce, sun, sky or lamps, got ${JSON.stringify(layer)}`)
|
|
332
|
+
// per instance index: texels, sum of the mean, sum of the squared half-difference, texels off by > 50 %
|
|
333
|
+
const n = a.instances.length
|
|
334
|
+
const texels = new Float64Array(n), sumMean = new Float64Array(n), sumVar = new Float64Array(n), off = new Float64Array(n)
|
|
335
|
+
const pageFile = (dir: string, stem: string, kind: string, page: number): string => join(dir, `${stem}-${kind}${page > 0 ? `_${page}` : ""}.pfm`)
|
|
336
|
+
for (let pg = 0; pg < a.pages; pg++) {
|
|
337
|
+
const fa = pageFile(dirA, stemA, layer, pg), fb = pageFile(dirB, stemB, layer, pg), fi = pageFile(dirA, stemA, "ids", pg), fn = pageFile(dirA, stemA, "normal", pg)
|
|
338
|
+
for (const f of [ fa, fb, fi, fn ]) if (!existsSync(f)) throw new CliError(`No ${relative(root, f)} — bake with --debug-dir to keep the layers.`)
|
|
339
|
+
const A = readPfm(fa), B = readPfm(fb), I = readPfm(fi), N = readPfm(fn)
|
|
340
|
+
if (A.w !== B.w || A.h !== B.h) throw new CliError(`Page ${pg}: the layers differ in size.`)
|
|
341
|
+
const count = A.w * A.h
|
|
342
|
+
for (let t = 0; t < count; t++) {
|
|
343
|
+
const nx = N.data[t * 3], ny = N.data[t * 3 + 1], nz = N.data[t * 3 + 2]
|
|
344
|
+
if (nx * nx + ny * ny + nz * nz < 0.25) continue // not a texel of any rect
|
|
345
|
+
const inst = Math.round(I.data[t * 3])
|
|
346
|
+
if (inst < 0 || inst >= n) continue
|
|
347
|
+
const la = 0.2126 * A.data[t * 3] + 0.7152 * A.data[t * 3 + 1] + 0.0722 * A.data[t * 3 + 2]
|
|
348
|
+
const lb = 0.2126 * B.data[t * 3] + 0.7152 * B.data[t * 3 + 1] + 0.0722 * B.data[t * 3 + 2]
|
|
349
|
+
const m = 0.5 * (la + lb), d = la - lb
|
|
350
|
+
texels[inst]++
|
|
351
|
+
sumMean[inst] += m
|
|
352
|
+
sumVar[inst] += 0.5 * d * d
|
|
353
|
+
if (Math.abs(d) > 0.5 * m && m > 0) off[inst]++
|
|
219
354
|
}
|
|
220
355
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
356
|
+
type Row = { key: string, texels: number, mean: number, noise: number, off: number }
|
|
357
|
+
const re = flags.key ? new RegExp(flags.key) : null
|
|
358
|
+
let rows: Row[] = []
|
|
359
|
+
let allTexels = 0, allMean = 0, allVar = 0
|
|
360
|
+
for (let i = 0; i < n; i++) {
|
|
361
|
+
if (texels[i] === 0) continue
|
|
362
|
+
allTexels += texels[i]; allMean += sumMean[i]; allVar += sumVar[i]
|
|
363
|
+
if (re && !re.test(a.instances[i].key)) continue
|
|
364
|
+
const mean = sumMean[i] / texels[i]
|
|
365
|
+
const dark = mean < flags["min-lux"]
|
|
366
|
+
rows.push({ key: a.instances[i].key, texels: texels[i], mean, noise: dark ? -1 : Math.sqrt(sumVar[i] / texels[i]) / mean, off: off[i] / texels[i] })
|
|
367
|
+
}
|
|
368
|
+
const sorters: Record<string, (x: Row, y: Row) => number> = {
|
|
369
|
+
noise: (x, y) => y.noise - x.noise,
|
|
370
|
+
lux: (x, y) => y.mean - x.mean,
|
|
371
|
+
key: (x, y) => x.key.localeCompare(y.key),
|
|
372
|
+
texels: (x, y) => y.texels - x.texels,
|
|
373
|
+
}
|
|
374
|
+
const sorter = sorters[flags.sort]
|
|
375
|
+
if (!sorter) throw new CliError(`--sort: expected noise, lux, key or texels, got ${JSON.stringify(flags.sort)}`)
|
|
376
|
+
rows = rows.sort(sorter)
|
|
377
|
+
const total = rows.length
|
|
378
|
+
if (flags.limit > 0) rows = rows.slice(0, flags.limit)
|
|
379
|
+
const overallMean = allTexels > 0 ? allMean / allTexels : 0
|
|
380
|
+
log(`${layer}: ${a.pages} page(s) of ${a.size}, ${allTexels} texels compared; mean ${overallMean.toFixed(1)} lux, noise ${overallMean > 0 ? (100 * Math.sqrt(allVar / allTexels) / overallMean).toFixed(1) : "0"} % of the mean over the whole atlas`)
|
|
381
|
+
log(`${"key".padEnd(48)} ${"texels".padStart(8)} ${"mean lux".padStart(9)} ${"noise".padStart(7)} ${"off>50%".padStart(8)}`)
|
|
382
|
+
for (const r of rows) {
|
|
383
|
+
log(`${r.key.slice(0, 48).padEnd(48)} ${String(r.texels).padStart(8)} ${r.mean.toFixed(1).padStart(9)} ${r.noise < 0 ? "dark".padStart(7) : (100 * r.noise).toFixed(0).padStart(6) + "%"} ${(100 * r.off).toFixed(0).padStart(7)}%`)
|
|
225
384
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
return
|
|
385
|
+
if (total > rows.length) log(`… ${total - rows.length} more (--limit 0 for all)`)
|
|
386
|
+
},
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
// ---- inspect --------------------------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
/** A Portable Float Map (what creator-bake writes): rows come bottom-up in the file, returned top-down here. */
|
|
392
|
+
const readPfm = (file: string): { w: number, h: number, ch: number, data: Float32Array } => {
|
|
393
|
+
const buf = readFileSync(file)
|
|
394
|
+
let o = 0
|
|
395
|
+
const token = (): string => {
|
|
396
|
+
while (o < buf.length && /\s/.test(String.fromCharCode(buf[o]))) o++
|
|
397
|
+
const s = o
|
|
398
|
+
while (o < buf.length && !/\s/.test(String.fromCharCode(buf[o]))) o++
|
|
399
|
+
return buf.toString("latin1", s, o)
|
|
242
400
|
}
|
|
243
|
-
|
|
244
|
-
|
|
401
|
+
const magic = token()
|
|
402
|
+
const ch = magic === "PF" ? 3 : magic === "Pf" ? 1 : 0
|
|
403
|
+
if (!ch) throw new CliError(`${file} is not a PFM file`)
|
|
404
|
+
const w = Number(token()), h = Number(token()), scale = Number(token())
|
|
405
|
+
o++ // the single whitespace after the scale
|
|
406
|
+
const little = scale < 0
|
|
407
|
+
const data = new Float32Array(w * h * ch)
|
|
408
|
+
for (let y = 0; y < h; y++) {
|
|
409
|
+
const rowAt = o + (h - 1 - y) * w * ch * 4
|
|
410
|
+
for (let i = 0; i < w * ch; i++) data[y * w * ch + i] = little ? buf.readFloatLE(rowAt + i * 4) : buf.readFloatBE(rowAt + i * 4)
|
|
245
411
|
}
|
|
246
|
-
|
|
247
|
-
warnErr("The host ran to its frame cap without baking. Make sure the app calls Lightmap.load(...) after the static scene is built, and skips menus when Lightmap.baking is true (a title screen never reaches the scene). More load headroom: --frames <n>. Host output: --logs.")
|
|
248
|
-
throw new CliError("Nothing was baked.")
|
|
412
|
+
return { w, h, ch, data }
|
|
249
413
|
}
|
|
250
414
|
|
|
251
|
-
/** A
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
return b
|
|
415
|
+
/** A minimal PNG encoder (RGB8, one IDAT) — enough for the inspection images without a native canvas. */
|
|
416
|
+
const writePng = (file: string, w: number, h: number, rgb: Uint8Array): void => {
|
|
417
|
+
const crcTable = new Uint32Array(256)
|
|
418
|
+
for (let n = 0; n < 256; n++) { let cc = n; for (let k = 0; k < 8; k++) cc = cc & 1 ? 0xEDB88320 ^ (cc >>> 1) : cc >>> 1; crcTable[n] = cc >>> 0 }
|
|
419
|
+
const crc = (b: Buffer): number => { let cc = 0xFFFFFFFF; for (const x of b) cc = crcTable[(cc ^ x) & 0xFF] ^ (cc >>> 8); return (cc ^ 0xFFFFFFFF) >>> 0 }
|
|
420
|
+
const chunk = (type: string, body: Buffer): Buffer => {
|
|
421
|
+
const len = Buffer.alloc(4); len.writeUInt32BE(body.length)
|
|
422
|
+
const tb = Buffer.concat([ Buffer.from(type, "latin1"), body ])
|
|
423
|
+
const cb = Buffer.alloc(4); cb.writeUInt32BE(crc(tb))
|
|
424
|
+
return Buffer.concat([ len, tb, cb ])
|
|
425
|
+
}
|
|
426
|
+
const raw = Buffer.alloc((w * 3 + 1) * h)
|
|
427
|
+
for (let y = 0; y < h; y++) { raw[y * (w * 3 + 1)] = 0; Buffer.from(rgb.buffer, rgb.byteOffset + y * w * 3, w * 3).copy(raw, y * (w * 3 + 1) + 1) }
|
|
428
|
+
const ihdr = Buffer.alloc(13)
|
|
429
|
+
ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0
|
|
430
|
+
writeFileSync(file, Buffer.concat([ Buffer.from([ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A ]), chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw)), chunk("IEND", Buffer.alloc(0)) ]))
|
|
268
431
|
}
|
|
269
432
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
* a zero-byte KTX2 is what hand-made placeholders looked like, and the host cannot build a texture
|
|
273
|
-
* from it. */
|
|
274
|
-
const ensurePlaceholders = (outDir: string, stem: string): void => {
|
|
275
|
-
const missing = (f: string): boolean => !existsSync(f) || statSync(f).size === 0
|
|
276
|
-
const bake = join(outDir, `${stem}.bake`)
|
|
277
|
-
const ktx2 = join(outDir, pageFile(stem, 0, false))
|
|
278
|
-
const light = join(outDir, pageFile(stem, 0, true))
|
|
279
|
-
const volume = join(outDir, `${stem}.volume`)
|
|
280
|
-
if (missing(bake)) writeFileSync(bake, JSON.stringify({ version: 2, placeholder: true, size: 1, texel: 0, pages: 1, instances: [] }) + "\n")
|
|
281
|
-
if (missing(ktx2)) writeFileSync(ktx2, PLACEHOLDER_KTX2)
|
|
282
|
-
if (missing(light)) writeFileSync(light, PLACEHOLDER_KTX2)
|
|
283
|
-
if (missing(volume)) writeFileSync(volume, placeholderVolume())
|
|
284
|
-
}
|
|
433
|
+
type Tone = "log" | "linear" | "raw" | "normal"
|
|
434
|
+
const LAYERS = [ "irradiance", "sun", "sky", "lamps", "bounce", "bounce-raw", "visibility", "direction", "share", "normal", "position", "ids", "probes" ]
|
|
285
435
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
436
|
+
/** float layer -> RGB8 display: `log` maps log10 lux over [lo, hi], `linear` divides by `white`,
|
|
437
|
+
* `normal` shows a unit vector as x0.5+0.5, `raw` clamps 0..1 */
|
|
438
|
+
const toneMap = (v: number, tone: Tone, lo: number, hi: number, white: number): number => {
|
|
439
|
+
let t: number
|
|
440
|
+
if (tone === "log") t = (Math.log10(Math.max(v, 1e-6)) - lo) / Math.max(hi - lo, 1e-6)
|
|
441
|
+
else if (tone === "linear") t = v / white
|
|
442
|
+
else if (tone === "normal") t = v * 0.5 + 0.5
|
|
443
|
+
else t = v
|
|
444
|
+
return Math.round(Math.min(1, Math.max(0, t)) * 255)
|
|
290
445
|
}
|
|
446
|
+
|
|
447
|
+
const inspect = defineCommand({
|
|
448
|
+
name: "inspect",
|
|
449
|
+
summary: "PNGs from a bake's debug layers: whole pages, or one instance's rect enlarged",
|
|
450
|
+
description: "Reads the PFM float layers `bake --debug-dir` kept next to the .bake's rects. Without --key: one PNG per page and layer with every rect outlined. With --key: the matching instances' rects (plus a margin) enlarged by --scale, one PNG per instance and layer. Lux layers are shown as log10 over --lux-range (or linear over --white); vectors as x0.5+0.5.",
|
|
451
|
+
flags: {
|
|
452
|
+
bake: str("the .bake file", { value: "<file>", default: "assets/lightmap/lightmap.bake" }),
|
|
453
|
+
"debug-dir": str("where the bake kept its PFM layers", { value: "<dir>", default: ".lightmap" }),
|
|
454
|
+
out: str("where the PNGs go", { value: "<dir>", default: ".lightmap/png" }),
|
|
455
|
+
key: str("instances whose key matches this regular expression (default: whole pages)", { value: "<regex>" }),
|
|
456
|
+
layer: str(`which layers: a comma list of ${LAYERS.join(", ")} or all`, { value: "<list>", default: "irradiance,visibility,direction" }),
|
|
457
|
+
scale: num("enlargement of a rect crop (integer)", { default: 8 }),
|
|
458
|
+
margin: num("texels of gutter around a rect crop", { default: 4 }),
|
|
459
|
+
"lux-range": str("log10 lux mapped to black..white", { value: "<lo,hi>", default: "0,5" }),
|
|
460
|
+
white: num("linear mode: the lux shown as white (0 = log mode)", { default: 0 }),
|
|
461
|
+
limit: num("instances to render with --key (0 = all)", { default: 24 }),
|
|
462
|
+
},
|
|
463
|
+
examples: [ "lecodes lightmap inspect --bake assets/school/lightmap/school.bake --debug-dir .lightmap/school", "lecodes lightmap inspect --key 'Wall_05' --layer irradiance,sun,sky,lamps --scale 6" ],
|
|
464
|
+
run: ({ flags }) => {
|
|
465
|
+
const { root } = localProject()
|
|
466
|
+
const bakeFile = resolve(root, flags.bake)
|
|
467
|
+
const data = readBake(bakeFile)
|
|
468
|
+
const stem = basename(bakeFile).replace(/\.bake$/, "")
|
|
469
|
+
const dbg = resolve(root, flags["debug-dir"])
|
|
470
|
+
const outDir = resolve(root, flags.out)
|
|
471
|
+
mkdirSync(outDir, { recursive: true })
|
|
472
|
+
const layers = flags.layer === "all" ? LAYERS : flags.layer.split(",").map((s) => s.trim()).filter(Boolean)
|
|
473
|
+
for (const l of layers) if (!LAYERS.includes(l)) throw new CliError(`--layer: unknown layer "${l}" (${LAYERS.join(", ")})`)
|
|
474
|
+
const [ lo, hi ] = flags["lux-range"].split(",").map(Number)
|
|
475
|
+
const white = flags.white
|
|
476
|
+
const toneOf = (layer: string): Tone => layer === "irradiance" || layer === "sun" || layer === "sky" || layer === "lamps" || layer === "bounce" || layer === "bounce-raw" || layer === "probes" ? (white > 0 ? "linear" : "log") : layer === "direction" || layer === "normal" ? "normal" : layer === "position" ? "raw" : layer === "ids" ? "raw" : "raw"
|
|
477
|
+
const pageFile = (layer: string, page: number): string => join(dbg, `${stem}-${layer}${page > 0 ? `_${page}` : ""}.pfm`)
|
|
478
|
+
const receivers = data.instances.filter((i) => i.receiver && i.rect)
|
|
479
|
+
const re = flags.key ? new RegExp(flags.key) : null
|
|
480
|
+
const size = data.size
|
|
481
|
+
let written = 0
|
|
482
|
+
const layerCache = new Map<string, ReturnType<typeof readPfm>>()
|
|
483
|
+
const load = (layer: string, page: number) => {
|
|
484
|
+
const k = `${layer}:${page}`
|
|
485
|
+
let v = layerCache.get(k)
|
|
486
|
+
if (!v) {
|
|
487
|
+
const f = pageFile(layer, page)
|
|
488
|
+
if (!existsSync(f)) throw new CliError(`No ${relative(root, f)} — bake with --debug-dir ${relative(root, dbg) || "."} first.`)
|
|
489
|
+
v = readPfm(f)
|
|
490
|
+
layerCache.set(k, v)
|
|
491
|
+
}
|
|
492
|
+
return v
|
|
493
|
+
}
|
|
494
|
+
const pixel = (L: ReturnType<typeof readPfm>, x: number, y: number, tone: Tone, layer: string): [number, number, number] => {
|
|
495
|
+
const i = (y * L.w + x) * L.ch
|
|
496
|
+
if (layer === "ids") {
|
|
497
|
+
// an instance colour from its index, chart darkens it
|
|
498
|
+
const inst = L.data[i], chart = L.data[i + 1]
|
|
499
|
+
if (inst === 0 && chart === 0 && L.data[i + 2] === 0) return [ 0, 0, 0 ]
|
|
500
|
+
const h = ((inst + 1) * 0.618033) % 1, s = 0.75, v = 0.55 + 0.4 * (((chart + 1) * 0.381966) % 1)
|
|
501
|
+
const f = (n: number): number => { const k = (n + h * 6) % 6; return Math.round(255 * v * (1 - s * Math.max(0, Math.min(k, 4 - k, 1)))) }
|
|
502
|
+
return [ f(5), f(3), f(1) ]
|
|
503
|
+
}
|
|
504
|
+
if (L.ch === 1) { const g = toneMap(L.data[i], tone, lo, hi, white); return [ g, g, g ] }
|
|
505
|
+
return [ toneMap(L.data[i], tone, lo, hi, white), toneMap(L.data[i + 1], tone, lo, hi, white), toneMap(L.data[i + 2], tone, lo, hi, white) ]
|
|
506
|
+
}
|
|
507
|
+
// the reflection probes' strip (one file, not per page): level 0 of every probe, its six faces +X -X +Y -Y +Z -Z side
|
|
508
|
+
// by side, one row of faces per probe — radiance in the lux scale of the light layers
|
|
509
|
+
if (!re && layers.includes("probes")) {
|
|
510
|
+
const f = join(dbg, `${stem}-probes.pfm`)
|
|
511
|
+
if (!existsSync(f)) throw new CliError(`No ${relative(root, f)} — bake with --probe-spacing > 0 and --debug-dir ${relative(root, dbg) || "."} first.`)
|
|
512
|
+
const L = readPfm(f)
|
|
513
|
+
const rgb = new Uint8Array(L.w * L.h * 3)
|
|
514
|
+
for (let y = 0; y < L.h; y++) for (let x = 0; x < L.w; x++) { const p = pixel(L, x, y, toneOf("probes"), "probes"); const o = (y * L.w + x) * 3; rgb[o] = p[0]; rgb[o + 1] = p[1]; rgb[o + 2] = p[2] }
|
|
515
|
+
const file = join(outDir, `${stem}-probes.png`)
|
|
516
|
+
writePng(file, L.w, L.h, rgb)
|
|
517
|
+
written++
|
|
518
|
+
logErr(c.dim(` ${relative(root, file)} (${data.probes?.count ?? L.h / (L.w / 6)} probe(s), faces +X -X +Y -Y +Z -Z per row)`))
|
|
519
|
+
}
|
|
520
|
+
if (!re) {
|
|
521
|
+
for (let page = 0; page < data.pages; page++) {
|
|
522
|
+
for (const layer of layers.filter((l) => l !== "probes")) {
|
|
523
|
+
const L = load(layer, page)
|
|
524
|
+
const tone = toneOf(layer)
|
|
525
|
+
const rgb = new Uint8Array(size * size * 3)
|
|
526
|
+
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) { const p = pixel(L, x, y, tone, layer); const o = (y * size + x) * 3; rgb[o] = p[0]; rgb[o + 1] = p[1]; rgb[o + 2] = p[2] }
|
|
527
|
+
// rect outlines in magenta
|
|
528
|
+
for (const r of receivers) {
|
|
529
|
+
if (r.page !== page) continue
|
|
530
|
+
const [ rx, ry, rw, rh ] = r.rect!
|
|
531
|
+
for (let x = rx - 1; x <= rx + rw; x++) for (const y of [ ry - 1, ry + rh ]) if (x >= 0 && x < size && y >= 0 && y < size) { const o = (y * size + x) * 3; rgb[o] = 255; rgb[o + 1] = 0; rgb[o + 2] = 255 }
|
|
532
|
+
for (let y = ry - 1; y <= ry + rh; y++) for (const x of [ rx - 1, rx + rw ]) if (x >= 0 && x < size && y >= 0 && y < size) { const o = (y * size + x) * 3; rgb[o] = 255; rgb[o + 1] = 0; rgb[o + 2] = 255 }
|
|
533
|
+
}
|
|
534
|
+
const file = join(outDir, `${stem}-${layer}${page > 0 ? `_${page}` : ""}.png`)
|
|
535
|
+
writePng(file, size, size, rgb)
|
|
536
|
+
written++
|
|
537
|
+
logErr(c.dim(` ${relative(root, file)}`))
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
} else {
|
|
541
|
+
let rows = receivers.filter((r) => re.test(r.key))
|
|
542
|
+
if (rows.length === 0) throw new CliError(`No receiver key matches /${flags.key}/ in ${relative(root, bakeFile)}`)
|
|
543
|
+
const total = rows.length
|
|
544
|
+
if (flags.limit > 0) rows = rows.slice(0, flags.limit)
|
|
545
|
+
const scale = Math.max(1, Math.floor(flags.scale)), margin = Math.max(0, Math.floor(flags.margin))
|
|
546
|
+
for (const r of rows) {
|
|
547
|
+
const [ rx, ry, rw, rh ] = r.rect!
|
|
548
|
+
const x0 = Math.max(0, rx - margin), y0 = Math.max(0, ry - margin), x1 = Math.min(size, rx + rw + margin), y1 = Math.min(size, ry + rh + margin)
|
|
549
|
+
const cw = (x1 - x0) * scale, chh = (y1 - y0) * scale
|
|
550
|
+
for (const layer of layers.filter((l) => l !== "probes")) {
|
|
551
|
+
const L = load(layer, r.page ?? 0)
|
|
552
|
+
const tone = toneOf(layer)
|
|
553
|
+
const rgb = new Uint8Array(cw * chh * 3)
|
|
554
|
+
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
|
|
555
|
+
const p = pixel(L, x, y, tone, layer)
|
|
556
|
+
const inRect = x >= rx && x < rx + rw && y >= ry && y < ry + rh
|
|
557
|
+
// the gutter dimmed, so the rect's own texels stand out
|
|
558
|
+
const px = inRect ? p : [ p[0] >> 1, p[1] >> 1, p[2] >> 1 ]
|
|
559
|
+
for (let sy = 0; sy < scale; sy++) for (let sx = 0; sx < scale; sx++) {
|
|
560
|
+
const o = (((y - y0) * scale + sy) * cw + (x - x0) * scale + sx) * 3
|
|
561
|
+
rgb[o] = px[0]; rgb[o + 1] = px[1]; rgb[o + 2] = px[2]
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
const safe = r.key.replace(/[^A-Za-z0-9_.-]+/g, "_")
|
|
565
|
+
const file = join(outDir, `${safe}-${layer}.png`)
|
|
566
|
+
writePng(file, cw, chh, rgb)
|
|
567
|
+
written++
|
|
568
|
+
}
|
|
569
|
+
logErr(c.dim(` ${r.key}: page ${r.page} rect ${rx},${ry} ${rw}x${rh}, mean ${lum(r.mean).toFixed(1)} lux, sun ${(r.sunVis ?? 0).toFixed(3)} sky ${(r.skyVis ?? 0).toFixed(3)} → ${relative(root, outDir)}/${r.key.replace(/[^A-Za-z0-9_.-]+/g, "_")}-<layer>.png`))
|
|
570
|
+
}
|
|
571
|
+
if (total > rows.length) logErr(c.dim(` … ${total - rows.length} more match (--limit 0 for all)`))
|
|
572
|
+
}
|
|
573
|
+
logErr(c.green(`✓ ${written} PNG(s) in ${relative(root, outDir) || "."}`))
|
|
574
|
+
},
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
export default defineCommand({
|
|
578
|
+
name: "lightmap",
|
|
579
|
+
summary: "Baked lighting: bake the level's statics, read a bake's numbers, inspect its layers",
|
|
580
|
+
commands: [ bake, stats, diff, inspect ],
|
|
581
|
+
})
|