lecodes-cli 2.4.6 → 2.4.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +450 -138
- package/package.json +8 -8
- package/runtime/materials/lightmap.mat +7 -4
- package/runtime/materials/terrain-lightmap.mat +2 -1
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +3 -3
- package/runtime/web/assets/materials-world-aKwAWYRd.bin +0 -0
- package/runtime/web/player.js +3 -3
- package/runtime/web-lite/lite.js +1 -1
- package/src/commands/app/android.ts +81 -3
- package/src/commands/app/index.ts +3 -12
- package/src/commands/app/play.ts +107 -0
- package/src/commands/assets.ts +1 -1
- package/src/commands/lightmap.ts +130 -20
- package/src/declarations/lecodes-assets.d.ts +31 -4
- package/src/hosts/desktopRenderer.ts +112 -11
- package/runtime/web/assets/materials-world-n3tLX5vO.bin +0 -0
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
type AndroidRuntimePlugin, type AndroidShellIdentity, runtimeManifestTemplate, urlSchemeFilter, appLinksFilter,
|
|
25
25
|
} from "./templates/android"
|
|
26
26
|
import { GRADLEW_BAT, GRADLEW_SH } from "./templates/gradlew"
|
|
27
|
+
import { readServiceAccount, serviceAccountToken, uploadToPlay } from "./play"
|
|
27
28
|
|
|
28
29
|
/* The Android arm of `lecodes app` — the mirror of the iOS flows in index.ts, over Gradle
|
|
29
30
|
* instead of Xcode: init / sync / open / doctor checks as functions the shared command nodes call,
|
|
@@ -637,10 +638,9 @@ const buildAndroid = async (flags: FlagValues<typeof androidBuildFlags>): Promis
|
|
|
637
638
|
}
|
|
638
639
|
if (produced.length === 0) throw new CliError("Gradle finished but produced no .apk/.aab — check android/app/build/outputs.")
|
|
639
640
|
|
|
640
|
-
const slug = flags.slug ?? slugify(config.name ?? basename(root))
|
|
641
641
|
const outDir = resolve(root, flags.out ?? join(root, "dist"))
|
|
642
642
|
mkdirSync(outDir, { recursive: true })
|
|
643
|
-
const stem = `${
|
|
643
|
+
const stem = `${artifactStem(root, config, flags.slug)}${configuration === "debug" ? "-debug" : ""}`
|
|
644
644
|
for (const src of produced) {
|
|
645
645
|
const unsigned = configuration === "release" && !signing
|
|
646
646
|
const dest = join(outDir, `${stem}${unsigned ? "-unsigned" : ""}${src.endsWith(".aab") ? ".aab" : ".apk"}`)
|
|
@@ -651,7 +651,7 @@ const buildAndroid = async (flags: FlagValues<typeof androidBuildFlags>): Promis
|
|
|
651
651
|
success(configuration === "debug"
|
|
652
652
|
? "Debug build done — `adb install` the .apk to test (or `lecodes dev --android --apk <file>`)."
|
|
653
653
|
: signing
|
|
654
|
-
? "Release build done — upload the .aab
|
|
654
|
+
? "Release build done — `lecodes app upload android` sends the .aab to Play (the .apk side-loads for testing)."
|
|
655
655
|
: "Unsigned release build done — sign it before it goes anywhere.")
|
|
656
656
|
}
|
|
657
657
|
|
|
@@ -664,6 +664,84 @@ export const androidBuild = defineCommand({
|
|
|
664
664
|
run: ({ flags }) => buildAndroid(flags),
|
|
665
665
|
})
|
|
666
666
|
|
|
667
|
+
/** dist/'s name for a build of this app.json — `<slug>-<version>-<versionCode>`, so the upload
|
|
668
|
+
* finds the bundle of the versionCode app.json says, never an older one left in dist/. */
|
|
669
|
+
const artifactStem = (root: string, config: AppConfig, slug?: string): string =>
|
|
670
|
+
`${slug ?? slugify(config.name ?? basename(root))}-${config.version ?? "1.0.0"}-${config.android?.versionCode ?? 1}`
|
|
671
|
+
|
|
672
|
+
/* ---------------------------------------- upload ---------------------------------------- */
|
|
673
|
+
|
|
674
|
+
/** The project .env key the service account key is remembered under (CI sets it itself). */
|
|
675
|
+
export const PLAY_KEY_ENV = "LECODES_PLAY_SERVICE_ACCOUNT"
|
|
676
|
+
|
|
677
|
+
const androidUploadFlags = {
|
|
678
|
+
aab: str("the bundle (default: the dist/<name>-<version>-<versionCode>.aab `app build android` wrote)", { value: "<file>" }),
|
|
679
|
+
track: str("internal | alpha | beta | production | a closed track's name", { value: "<t>", default: "internal" }),
|
|
680
|
+
draft: bool("a draft release, rolled out in Play Console (the only kind a never-published app takes)"),
|
|
681
|
+
key: str("the service account's JSON key (remembered in the project .env)", { value: "<json>" }),
|
|
682
|
+
slug: str("file name stem of the default bundle, as given to `app build android --slug`", { value: "<name>" }),
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** `lecodes app upload android` — the bundle onto a Play track through one edit (play.ts). */
|
|
686
|
+
const uploadAndroid = async (flags: FlagValues<typeof androidUploadFlags>): Promise<void> => {
|
|
687
|
+
const root = projectRoot()
|
|
688
|
+
const config = readAppConfigAt(root)
|
|
689
|
+
if (!config?.android?.applicationId || !existsSync(join(root, "android"))) {
|
|
690
|
+
throw new CliError("No Android shell here — run `lecodes app init android` first.")
|
|
691
|
+
}
|
|
692
|
+
const packageName = config.android.applicationId
|
|
693
|
+
|
|
694
|
+
const aabPath = flags.aab ? resolve(flags.aab) : join(root, "dist", `${artifactStem(root, config, flags.slug)}.aab`)
|
|
695
|
+
if (!existsSync(aabPath)) {
|
|
696
|
+
throw new CliError(`No bundle at ${relative(root, aabPath) || aabPath} — run \`lecodes app build android\` first (it names the file by app.json's version + versionCode).`)
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// The key: --key on first use, then the project .env — per project, not ~/.lecodes like the
|
|
700
|
+
// iOS key, because two apps may live in two developer accounts.
|
|
701
|
+
const given = flags.key
|
|
702
|
+
const keyPath = given ? resolve(given.startsWith("~/") ? join(homedir(), given.slice(2)) : given) : process.env[PLAY_KEY_ENV]
|
|
703
|
+
if (!keyPath) {
|
|
704
|
+
throw new CliError([
|
|
705
|
+
`A Google Play service account key is needed: --key <file.json> (remembered in the project .env as ${PLAY_KEY_ENV}).`,
|
|
706
|
+
" Google Cloud: enable the Google Play Android Developer API, then IAM → Service accounts → Keys → Add key → JSON;",
|
|
707
|
+
" Play Console → Users and permissions: invite the account's email with \"Release to testing tracks\" for the app.",
|
|
708
|
+
].join("\n"))
|
|
709
|
+
}
|
|
710
|
+
if (!existsSync(keyPath)) throw new CliError(`Service account key not found: ${keyPath}`)
|
|
711
|
+
const key = readServiceAccount(keyPath)
|
|
712
|
+
if (given) {
|
|
713
|
+
upsertDotEnv(root, { [PLAY_KEY_ENV]: envValue(envPathFor(root, keyPath)) })
|
|
714
|
+
note(`Stored the key reference in .env (${PLAY_KEY_ENV})`)
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const size = (statSync(aabPath).size / (1024 * 1024)).toFixed(1)
|
|
718
|
+
note(`Uploading ${relative(root, aabPath) || aabPath} (${size} MB) to ${packageName}, track ${flags.track}${flags.draft ? " as a draft" : ""} — as ${key.client_email}`)
|
|
719
|
+
const token = await serviceAccountToken(key)
|
|
720
|
+
const { versionCode } = await uploadToPlay(token, {
|
|
721
|
+
packageName, aab: readFileSync(aabPath), track: flags.track, status: flags.draft ? "draft" : "completed",
|
|
722
|
+
})
|
|
723
|
+
|
|
724
|
+
// A versionCode Play has seen is spent: app.json moves past it for the next build (`app upload
|
|
725
|
+
// ios` does the same with buildNumber). Never backwards — --aab may name an older bundle.
|
|
726
|
+
const fresh = readAppConfigAt(root)!
|
|
727
|
+
const next = Math.max(fresh.android!.versionCode ?? 1, versionCode + 1)
|
|
728
|
+
if (next !== fresh.android!.versionCode) {
|
|
729
|
+
fresh.android!.versionCode = next
|
|
730
|
+
writeJson(join(root, "app.json"), fresh)
|
|
731
|
+
}
|
|
732
|
+
success(`Uploaded versionCode ${versionCode} to the ${flags.track} track${flags.draft ? " as a draft — roll it out in Play Console" : ""}.`)
|
|
733
|
+
note(`android.versionCode is ${next} for the next build.`)
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
export const androidUpload = defineCommand({
|
|
737
|
+
name: "android",
|
|
738
|
+
summary: "Upload the .aab to a Google Play track (then bumps android.versionCode)",
|
|
739
|
+
description: `One edit of the Google Play Developer API — the bundle onto the track as one release, then the commit; Play changes only when every step went through. Authenticated with a Cloud service account's JSON key: --key on first use, then the project .env remembers it (${PLAY_KEY_ENV}; in CI set the variable to the key's path, e.g. a GitLab File variable). The app must already exist in Play Console under app.json android.applicationId (the API cannot create it), the account be invited there with "Release to testing tracks". A successful upload spends the versionCode, so app.json android.versionCode moves past it for the next build.`,
|
|
740
|
+
flags: androidUploadFlags,
|
|
741
|
+
examples: ["lecodes app upload android --key ~/keys/play.json", "lecodes app upload android --track internal", "lecodes app upload android --draft"],
|
|
742
|
+
run: ({ flags }) => uploadAndroid(flags),
|
|
743
|
+
})
|
|
744
|
+
|
|
667
745
|
/* --------------------------------------- signing --------------------------------------- */
|
|
668
746
|
|
|
669
747
|
/** The four names the generated app/build.gradle.kts reads with System.getenv — the project
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
} from "./shared"
|
|
21
21
|
import { isLocalPluginSpec, type PluginEntry } from "lecodes-sdk/compile"
|
|
22
22
|
import {
|
|
23
|
-
ANDROID_SDK_MIN_VERSION, ANDROID_SDK_REPO, androidBuild, doctorAndroidChecks, initAndroid,
|
|
23
|
+
ANDROID_SDK_MIN_VERSION, ANDROID_SDK_REPO, androidBuild, androidUpload, doctorAndroidChecks, initAndroid,
|
|
24
24
|
keystore, openAndroid, shellSdkVersionAndroid, syncAndroid, type SyncArtifacts,
|
|
25
25
|
} from "./android"
|
|
26
26
|
import { desktopBuild, initDesktop } from "./desktop"
|
|
@@ -979,17 +979,8 @@ const iosUpload = defineCommand({
|
|
|
979
979
|
|
|
980
980
|
const upload = defineCommand({
|
|
981
981
|
name: "upload",
|
|
982
|
-
summary: "Upload a build to the store: ios (App Store Connect)",
|
|
983
|
-
commands: [
|
|
984
|
-
iosUpload,
|
|
985
|
-
// Not wired yet — kept as a node so `app upload android` says what to do instead of "unknown command".
|
|
986
|
-
defineCommand({
|
|
987
|
-
name: "android",
|
|
988
|
-
summary: "Play upload (not wired yet)",
|
|
989
|
-
hidden: true,
|
|
990
|
-
run: () => { throw new CliError("Play upload isn't wired yet — upload android/app/build/outputs/bundle/release/*.aab in the Play Console (`lecodes app build android` produces it).") },
|
|
991
|
-
}),
|
|
992
|
-
],
|
|
982
|
+
summary: "Upload a build to the store: ios (App Store Connect) | android (Google Play)",
|
|
983
|
+
commands: [iosUpload, androidUpload],
|
|
993
984
|
})
|
|
994
985
|
|
|
995
986
|
/* --------------------------------------- open ---------------------------------------- */
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { sign } from "node:crypto"
|
|
2
|
+
import { readFileSync } from "node:fs"
|
|
3
|
+
import { CliError } from "../../cli"
|
|
4
|
+
|
|
5
|
+
/* Google Play: the Developer API's one upload path, the v3 EDIT — the Android twin of `app upload
|
|
6
|
+
* ios`. An edit is a transaction: insert → upload the bundle → put it on a track → commit. Nothing
|
|
7
|
+
* reaches Play before the commit, so a failure anywhere leaves the app as it was (an uncommitted
|
|
8
|
+
* edit simply expires). The edit calls take a bare access token: a service account's JSON key
|
|
9
|
+
* produces one here (CI, a key nobody signs in with); a user's OAuth refresh token would be the
|
|
10
|
+
* other source (the platform's "Connect Google Play") and changes nothing below. */
|
|
11
|
+
|
|
12
|
+
const SCOPE = "https://www.googleapis.com/auth/androidpublisher"
|
|
13
|
+
|
|
14
|
+
/** `LECODES_PLAY_API_URL` points the edit calls at a fake (the tests). */
|
|
15
|
+
const apiBase = () => process.env.LECODES_PLAY_API_URL ?? "https://androidpublisher.googleapis.com"
|
|
16
|
+
|
|
17
|
+
/** The fields of a Cloud service account's JSON key the token exchange needs. */
|
|
18
|
+
export interface ServiceAccountKey {
|
|
19
|
+
client_email: string
|
|
20
|
+
private_key: string
|
|
21
|
+
token_uri?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const readServiceAccount = (path: string): ServiceAccountKey => {
|
|
25
|
+
let key: Partial<ServiceAccountKey> & { type?: string }
|
|
26
|
+
try {
|
|
27
|
+
key = JSON.parse(readFileSync(path, "utf8"))
|
|
28
|
+
} catch {
|
|
29
|
+
throw new CliError(`${path} is not a JSON key file.`)
|
|
30
|
+
}
|
|
31
|
+
if (key.type !== "service_account" || !key.client_email || !key.private_key) {
|
|
32
|
+
throw new CliError(`${path} is not a service account key (Google Cloud → IAM → Service accounts → Keys → Add key → JSON).`)
|
|
33
|
+
}
|
|
34
|
+
return key as ServiceAccountKey
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A service account's access token: a JWT signed with its key, traded at its token_uri (RFC 7523). */
|
|
38
|
+
export const serviceAccountToken = async (key: ServiceAccountKey, now = Math.floor(Date.now() / 1000)): Promise<string> => {
|
|
39
|
+
const tokenUri = key.token_uri ?? "https://oauth2.googleapis.com/token"
|
|
40
|
+
const part = (v: object) => Buffer.from(JSON.stringify(v)).toString("base64url")
|
|
41
|
+
const unsigned = `${part({ alg: "RS256", typ: "JWT" })}.${part({ iss: key.client_email, scope: SCOPE, aud: tokenUri, iat: now, exp: now + 3600 })}`
|
|
42
|
+
const signature = sign("RSA-SHA256", Buffer.from(unsigned), key.private_key).toString("base64url")
|
|
43
|
+
const res = await fetch(tokenUri, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
46
|
+
body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion: `${unsigned}.${signature}` }),
|
|
47
|
+
})
|
|
48
|
+
const body = await res.json().catch(() => ({})) as { access_token?: string, error?: string, error_description?: string }
|
|
49
|
+
if (!res.ok || !body.access_token) {
|
|
50
|
+
throw new CliError(`Google refused the key of ${key.client_email}: ${body.error_description ?? body.error ?? `HTTP ${res.status}`} (a deleted or rotated key fails here — make a new one).`)
|
|
51
|
+
}
|
|
52
|
+
return body.access_token
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface PlayRelease {
|
|
56
|
+
packageName: string
|
|
57
|
+
aab: Uint8Array
|
|
58
|
+
/** internal | alpha | beta | production | a closed track's own name. */
|
|
59
|
+
track: string
|
|
60
|
+
/** draft = the only status an app that was never published accepts. */
|
|
61
|
+
status: "completed" | "draft"
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** What to do about the refusals a first setup runs into; Google's own message stays in front. */
|
|
65
|
+
const HINTS: [RegExp, (r: PlayRelease) => string][] = [
|
|
66
|
+
[/caller does not have permission|insufficient permissions/i, () => "invite the service account in Play Console → Users and permissions with \"Release to testing tracks\" for this app (a new grant can take hours to reach the API)"],
|
|
67
|
+
[/package not found/i, r => `Play Console has no app ${r.packageName} — create it there first (the API cannot); app.json android.applicationId must match`],
|
|
68
|
+
[/version code .*already been used/i, () => "bump app.json android.versionCode and build again"],
|
|
69
|
+
[/only releases with status draft/i, () => "the app was never published — upload with --draft, then roll the release out in Play Console"],
|
|
70
|
+
[/has not been used in project|is disabled/i, () => "enable the Google Play Android Developer API in the key's Cloud project"],
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
const playError = (step: string, status: number, text: string, release: PlayRelease): CliError => {
|
|
74
|
+
let message = text
|
|
75
|
+
try { message = (JSON.parse(text) as { error?: { message?: string } }).error?.message ?? text } catch { /* not JSON: as is */ }
|
|
76
|
+
const hint = HINTS.find(([re]) => re.test(message))?.[1](release)
|
|
77
|
+
return new CliError(`Google Play refused the ${step} (HTTP ${status}): ${message.trim()}${hint ? `\n → ${hint}` : ""}`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One edit: the bundle onto `track` as one release. Returns the versionCode Play read from it. */
|
|
81
|
+
export const uploadToPlay = async (token: string, release: PlayRelease): Promise<{ versionCode: number }> => {
|
|
82
|
+
const app = `applications/${encodeURIComponent(release.packageName)}`
|
|
83
|
+
const call = async <T>(step: string, method: string, path: string, body?: Uint8Array | object): Promise<T> => {
|
|
84
|
+
const bytes = body instanceof Uint8Array
|
|
85
|
+
const res = await fetch(`${apiBase()}/${path}`, {
|
|
86
|
+
method,
|
|
87
|
+
headers: {
|
|
88
|
+
authorization: `Bearer ${token}`,
|
|
89
|
+
...(body ? { "content-type": bytes ? "application/octet-stream" : "application/json" } : {}),
|
|
90
|
+
},
|
|
91
|
+
body: bytes ? body : body ? JSON.stringify(body) : undefined,
|
|
92
|
+
})
|
|
93
|
+
const text = await res.text()
|
|
94
|
+
if (!res.ok) throw playError(step, res.status, text, release)
|
|
95
|
+
return (text ? JSON.parse(text) : {}) as T
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const edit = await call<{ id: string }>("edit", "POST", `androidpublisher/v3/${app}/edits`, {})
|
|
99
|
+
const editPath = `androidpublisher/v3/${app}/edits/${encodeURIComponent(edit.id)}`
|
|
100
|
+
const bundle = await call<{ versionCode: number }>("bundle", "POST", `upload/${editPath}/bundles?uploadType=media`, release.aab)
|
|
101
|
+
await call("track", "PUT", `${editPath}/tracks/${encodeURIComponent(release.track)}`, {
|
|
102
|
+
track: release.track,
|
|
103
|
+
releases: [{ versionCodes: [String(bundle.versionCode)], status: release.status }],
|
|
104
|
+
})
|
|
105
|
+
await call("commit", "POST", `${editPath}:commit`)
|
|
106
|
+
return { versionCode: bundle.versionCode }
|
|
107
|
+
}
|
package/src/commands/assets.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { loadPeer } from "../hosts/peerInstall"
|
|
|
9
9
|
*/
|
|
10
10
|
export default defineCommand({
|
|
11
11
|
name: "assets",
|
|
12
|
-
summary: "The asset pipeline: convert / probe / doctor / retarget / unpack / scene / sky / terrain-pack …",
|
|
12
|
+
summary: "The asset pipeline: convert / probe / doctor / unwrap / retarget / unpack / scene / sky / terrain-pack …",
|
|
13
13
|
usage: "<command> [args…]",
|
|
14
14
|
passthrough: true,
|
|
15
15
|
examples: ["lecodes assets convert hero.fbx --clips anims/*.fbx -o hero.glb", "lecodes assets doctor hero.glb --fix --ktx2", "lecodes assets --help"],
|
package/src/commands/lightmap.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
1
|
+
import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
2
2
|
import { tmpdir } from "node:os"
|
|
3
3
|
import { basename, dirname, join, relative, resolve } from "node:path"
|
|
4
4
|
import { deflateSync } from "node:zlib"
|
|
5
|
-
import { buildBundle } from "./compile"
|
|
6
|
-
import { readAppConfigAt
|
|
5
|
+
import { buildBundle, type BundleResource } from "./compile"
|
|
6
|
+
import { readAppConfigAt } from "./app/shared"
|
|
7
7
|
import { runDesktopRender } from "../hosts/desktopRenderer"
|
|
8
|
+
import { loadPeer } from "../hosts/peerInstall"
|
|
8
9
|
import { CliError, bool, c, defineCommand, log, logErr, note, num, str, warnErr } from "../cli"
|
|
9
10
|
import { bundleFlags, bundleOptions, localProject } from "./shared"
|
|
10
11
|
|
|
@@ -12,17 +13,22 @@ import { bundleFlags, bundleOptions, localProject } from "./shared"
|
|
|
12
13
|
* `lecodes lightmap` — the baked lighting of a level (engines/bake).
|
|
13
14
|
*
|
|
14
15
|
* bake compile the app with a synthetic entry that puts it in BAKE mode (`globalThis.__lecodesLightmap`),
|
|
15
|
-
* run it in the
|
|
16
|
+
* run it in the bake host (lecodes-bake); when the app calls `Lightmap.load(...)` — the point where its
|
|
16
17
|
* scene is complete — the SDK calls `_creator.lightmapBake` instead of loading: the host describes
|
|
17
18
|
* every registered static to creator-bake, which traces the sun, the sky and the lamps on the
|
|
18
|
-
* NVIDIA
|
|
19
|
+
* GPU (--tracer: OptiX on NVIDIA, else Vulkan ray query on any card with hardware ray tracing) and
|
|
20
|
+
* writes <out>/<name>.bake + <name>-light[_n].ktx2 + <name>-aux[_n].ktx2 itself
|
|
19
21
|
* (no toktx, no PNG intermediates). "[lightmap] done" is the success marker.
|
|
20
22
|
* stats the per-instance numbers a .bake carries (mean lux, sun / sky visibility, texels, rect)
|
|
21
23
|
* diff the noise of a bake per instance: two bakes with different --seed compared texel by texel
|
|
22
24
|
* inspect PNGs from the float layers a `bake --debug-dir` kept: whole pages or one instance's rect
|
|
23
25
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
+
* The bake is not in the shipped desktop / server hosts: it runs in lecodes-bake (hosts/desktop CREATOR_DESKTOP_BAKE),
|
|
27
|
+
* the windowless host with the bake built in — resolved by hosts/desktopRenderer.ts resolveBakeExe (LECODES_BAKE_EXE
|
|
28
|
+
* for a local build, else the published release product "bake").
|
|
29
|
+
* Lightmap UVs: before the host runs, every GLB the app references that has none is unwrapped IN PLACE (lecodes-assets
|
|
30
|
+
* `unwrapGlbFiles`, what `lecodes assets unwrap` runs — the pre-step below); `--no-unwrap` skips it. A level in one GLB
|
|
31
|
+
* that wants groups or a zone is unwrapped by hand first (`lecodes assets unwrap --groups`), and then left alone here.
|
|
26
32
|
*/
|
|
27
33
|
|
|
28
34
|
const range = (name: string, value: number, min: number, max: number): number => {
|
|
@@ -31,7 +37,7 @@ const range = (name: string, value: number, min: number, max: number): number =>
|
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
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[] }
|
|
40
|
+
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, uv1?: string | null, texels?: number, instances: BakeInstance[], sun?: unknown, sky?: unknown, lights?: unknown[] }
|
|
35
41
|
|
|
36
42
|
const readBake = (file: string): BakeData => {
|
|
37
43
|
if (!existsSync(file)) throw new CliError(`No bake at ${file} — run \`lecodes lightmap bake\` first.`)
|
|
@@ -52,6 +58,23 @@ const probeBoxOf = (s: string): [number, number, number, number, number, number]
|
|
|
52
58
|
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]) ]
|
|
53
59
|
}
|
|
54
60
|
|
|
61
|
+
/** Every `--cube` on the line (`--cube a --cube b`, `--cube=a`): the argv grammar keeps a repeated flag's LAST value, so
|
|
62
|
+
* the patterns are read off argv by its own rule — a value is the next token unless it starts with "-" (a negative
|
|
63
|
+
* number aside). Each must compile as a regular expression here too (the bake's std::regex has the last word). */
|
|
64
|
+
const cubePatternsOf = (argv: string[]): string[] => {
|
|
65
|
+
const out: string[] = []
|
|
66
|
+
for (let i = 0; i < argv.length; i++) {
|
|
67
|
+
const a = argv[i]
|
|
68
|
+
if (a === "--") break
|
|
69
|
+
if (a.startsWith("--cube=")) out.push(a.slice("--cube=".length))
|
|
70
|
+
else if (a === "--cube" && i + 1 < argv.length && (!argv[i + 1].startsWith("-") || /^-(\d|\.\d)/.test(argv[i + 1]))) out.push(argv[++i])
|
|
71
|
+
}
|
|
72
|
+
for (const p of out) {
|
|
73
|
+
try { new RegExp(p) } catch (e) { throw new CliError(`bad --cube pattern: ${p} (${e instanceof Error ? e.message : String(e)})`) }
|
|
74
|
+
}
|
|
75
|
+
return out.filter((p) => p !== "")
|
|
76
|
+
}
|
|
77
|
+
|
|
55
78
|
/** A .bake with no pages and no scale: enough for the compiler to stage the files and for Lightmap.load to
|
|
56
79
|
* no-op with a "rebake" warning. The two KTX2 placeholders are 4x4 pages of the right formats. */
|
|
57
80
|
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"
|
|
@@ -82,12 +105,81 @@ const ensurePlaceholders = (outDir: string, stem: string): void => {
|
|
|
82
105
|
if (missing(probes)) writeFileSync(probes, ktx2Placeholder(143)) // a scene may list the probes before the first bake wrote them
|
|
83
106
|
const grid = join(outDir, `${stem}.lgrid`)
|
|
84
107
|
if (missing(grid)) writeFileSync(grid, "NONE") // ... and the light grid: the engine refuses it with a "rebake" line
|
|
108
|
+
const uv1 = join(outDir, `${stem}-uv1.bin`)
|
|
109
|
+
if (missing(uv1)) writeFileSync(uv1, "NONE") // ... and the packed uv1: a placeholder .bake names none, so nothing reads it
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---- the unwrap pre-step --------------------------------------------------------------------------
|
|
113
|
+
// The bake traces TEXCOORD_1, and a raw import has none: before the host runs, the GLBs the bundle names (asset()
|
|
114
|
+
// compiles to `_creatorFetch.local("<name>")` — the reference list `app build desktop` ships by) whose JSON shows a
|
|
115
|
+
// primitive the unwrap would lay out without one are unwrapped IN PLACE by lecodes-assets (`unwrapGlbFiles`, the
|
|
116
|
+
// `lecodes assets unwrap` code). Nothing is rebuilt after it: the bundle names the files and carries none of their bytes,
|
|
117
|
+
// and the run dir links them only when the host starts. A file that has TEXCOORD_1 is never opened past its JSON.
|
|
118
|
+
|
|
119
|
+
type GltfJson = {
|
|
120
|
+
nodes?: { mesh?: number, skin?: number }[]
|
|
121
|
+
meshes?: { primitives?: { mode?: number, indices?: number, attributes?: Record<string, number> }[] }[]
|
|
122
|
+
accessors?: { count?: number }[]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A GLB's JSON chunk — the 20 header bytes and the chunk itself, never the binary one — or null when it is not a GLB. */
|
|
126
|
+
const readGlbJson = (file: string): GltfJson | null => {
|
|
127
|
+
let fd: number | undefined
|
|
128
|
+
try {
|
|
129
|
+
fd = openSync(file, "r")
|
|
130
|
+
const head = Buffer.alloc(20)
|
|
131
|
+
if (readSync(fd, head, 0, 20, 0) !== 20) return null
|
|
132
|
+
if (head.readUInt32LE(0) !== 0x46546c67 || head.readUInt32LE(16) !== 0x4e4f534a) return null // "glTF", then a "JSON" chunk
|
|
133
|
+
const json = Buffer.alloc(head.readUInt32LE(12))
|
|
134
|
+
if (readSync(fd, json, 0, json.length, 20) !== json.length) return null
|
|
135
|
+
return JSON.parse(json.toString("utf8")) as GltfJson
|
|
136
|
+
} catch {
|
|
137
|
+
return null
|
|
138
|
+
} finally {
|
|
139
|
+
if (fd !== undefined) closeSync(fd)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A primitive the unwrap lays out (a triangle list with a triangle in it, on a mesh no skinned node draws) without
|
|
144
|
+
* TEXCOORD_1 — lecodes-assets' planUnwrap verdict, read off the JSON. */
|
|
145
|
+
const lacksLightmapUv = (g: GltfJson): boolean => {
|
|
146
|
+
const skinned = new Set((g.nodes ?? []).filter((n) => n.skin !== undefined && n.mesh !== undefined).map((n) => n.mesh))
|
|
147
|
+
const count = (accessor: number | undefined): number => (accessor === undefined ? 0 : g.accessors?.[accessor]?.count ?? 0)
|
|
148
|
+
return (g.meshes ?? []).some((mesh, i) => !skinned.has(i) && (mesh.primitives ?? []).some((p) => {
|
|
149
|
+
const pos = count(p.attributes?.POSITION)
|
|
150
|
+
return (p.mode ?? 4) === 4 && pos > 0 && (p.indices === undefined ? pos : count(p.indices)) >= 3 && p.attributes?.TEXCOORD_1 === undefined
|
|
151
|
+
}))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The project GLBs the bundle names. */
|
|
155
|
+
const referencedGlbs = (js: string, resources: BundleResource[]): string[] => {
|
|
156
|
+
const wanted = new Set([ ...js.matchAll(/(?:fetchLocal|_creatorFetch\.local)\("([^"]+)"\)/g) ].map((m) => m[1]))
|
|
157
|
+
return [ ...new Set(resources.filter((r) => !r.remote && !r.shader && r.absPath && /\.glb$/i.test(r.name) && wanted.has(r.name)).map((r) => r.absPath!)) ]
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const unwrapReferenced = async (js: string, resources: BundleResource[], root: string, texel: number): Promise<void> => {
|
|
161
|
+
const glbs = referencedGlbs(js, resources)
|
|
162
|
+
const raw = glbs.filter((f) => { const g = readGlbJson(f); return g !== null && lacksLightmapUv(g) })
|
|
163
|
+
if (raw.length === 0) { note(`Lightmap uvs: the ${glbs.length} GLB(s) the app references carry them`); return }
|
|
164
|
+
note(`Unwrapping ${raw.length} of the ${glbs.length} GLB(s) the app references — no lightmap uvs; written in place (--no-unwrap skips)…`)
|
|
165
|
+
const assets = await loadPeer<typeof import("lecodes-assets/cli")>("lecodes-assets", "cli", { for: "lecodes lightmap bake (lightmap uvs)" })
|
|
166
|
+
const t0 = performance.now()
|
|
167
|
+
const rel = (f: string): string => relative(root, f).replace(/\\/g, "/")
|
|
168
|
+
const reports = await assets.unwrapGlbFiles(raw, {
|
|
169
|
+
texel,
|
|
170
|
+
onFile: (r) => {
|
|
171
|
+
if (r.status === "failed") warnErr(`${rel(r.input)}: ${r.summary} — it bakes without lightmap uvs`)
|
|
172
|
+
else logErr(c.dim(` ${r.status === "unwrapped" ? "unwrapped " : ""}${rel(r.input)}: ${r.summary}`))
|
|
173
|
+
},
|
|
174
|
+
})
|
|
175
|
+
const count = (s: string): number => reports.filter((r) => r.status === s).length
|
|
176
|
+
note(`Lightmap uvs: ${count("unwrapped")} file(s) unwrapped${count("skipped") ? `, ${count("skipped")} had nothing to unwrap` : ""}${count("failed") ? `, ${count("failed")} failed` : ""} in ${((performance.now() - t0) / 1000).toFixed(1)} s`)
|
|
85
177
|
}
|
|
86
178
|
|
|
87
179
|
const bake = defineCommand({
|
|
88
180
|
name: "bake",
|
|
89
181
|
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
|
|
182
|
+
description: "Runs the app in bake mode in lecodes-bake — the windowless host with the bake built in (no physics, 2D, audio, net or navmesh), downloaded on first use like the desktop host; LECODES_BAKE_EXE points at a local build (hosts/desktop build.ps1 -Bake). 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 GPU with hardware ray tracing (--tracer).\n\nLightmap UVs: the GLBs the app references that have none (TEXCOORD_1) are unwrapped first, IN PLACE \u2014 the same as lecodes assets unwrap, run on just those files; a file that has them is left alone, --no-unwrap skips the step. A whole level in one GLB that wants --groups / --zone is unwrapped by hand first (lecodes assets unwrap --help).",
|
|
91
183
|
flags: {
|
|
92
184
|
out: str("where the atlas lands", { value: "<dir>", default: "assets/lightmap" }),
|
|
93
185
|
name: str("file stem — one per baked level", { value: "<stem>", default: "lightmap" }),
|
|
@@ -103,7 +195,9 @@ const bake = defineCommand({
|
|
|
103
195
|
bounces: num("path vertices at most (Russian roulette from the third); 0 = direct light only", { default: 3 }),
|
|
104
196
|
"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
197
|
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("
|
|
198
|
+
denoise: str("the AI denoiser (--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" }),
|
|
199
|
+
tracer: str("which GPU API traces: auto = OptiX where an NVIDIA driver with OptiX opens, else Vulkan ray query — any card with hardware ray tracing (RTX, Radeon RX 6000+, Arc) with its driver alone; optix / vulkan by name (the bake fails when it cannot open)", { value: "<auto|optix|vulkan>", default: "auto" }),
|
|
200
|
+
denoiser: str("which AI denoiser --denoise runs: auto = OptiX's on an NVIDIA card, else Intel Open Image Denoise (its CUDA / HIP / CPU device, the DLLs next to the host); optix / oidn by name. Independent of --tracer; one that cannot open is a warning and the pages stay as traced", { value: "<auto|optix|oidn>", default: "auto" }),
|
|
107
201
|
"light-radius": num("lamps: the emitter's radius (m) — soft shadows, no 1/d² singularity", { default: 0.05 }),
|
|
108
202
|
"sun-angle": num("sun angular diameter (deg)", { default: 0.53 }),
|
|
109
203
|
bias: num("ray start offset from the surface (m)", { default: 0.01 }),
|
|
@@ -115,6 +209,9 @@ const bake = defineCommand({
|
|
|
115
209
|
"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
210
|
"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
211
|
"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 }),
|
|
212
|
+
"thin-texels": num("ROUND AND NARROW objects leave the atlas like --min-texels: an instance none of whose lightmap charts is wider than this many texels across its short side (at --texel) — a railing's tubes, a cable, a pipe run, blinds, a gun barrel. A strip of 1-3 texels around a tube cannot hold the light's change around it and its uv seam shows as a step (the range's railing: two texels 5 mm apart on the seam, one lit, one shadowed); the ambient cube baked where it stands is smooth. A flat object has a wide chart somewhere and stays. 0 = off; --dry-run lists what falls under it", { default: 4 }),
|
|
213
|
+
cube: str("BAKE AS CUBES BY NAME: a regular expression (ECMAScript, case-sensitive) searched in every record's key — <node path>@<ordinal>, one record per mesh of a model, so SM_pipe takes every pipe node and @1$ the second mesh of each. A record that matches leaves the atlas like a --min-texels / --thin-texels one, whatever its size or charts: an occluder whose own light is baked where it stands as ambient cubes (in slabs along a long one, --object-slab) — a pipe run, a fence, a prop whose lightmap seams show. Repeat the flag (--cube SM_pipe --cube SM_fence) or join with | in one (--cube \"SM_pipe|SM_fence\", quoted for the shell); a pattern that starts with - goes as --cube=<regex>. The scene's env.lightmap.cube adds its own patterns, a node's lightmap: 'cube' marks one node by hand; --dry-run lists what matches", { value: "<regex>" }),
|
|
214
|
+
"object-slab": num("A LONG object the atlas left out (--min-texels, --thin-texels, --cube) takes its light in SLABS of this many metres along the longest axis of its bounds — round(length / this) ambient cubes, 16 at most — a 6 m pipe run, a railing along a wall: one end under a lamp, the other in the dark, where one cube gave all of it the mean. The shader blends the two slabs around each pixel along that axis. 0 = one cube an object", { value: "<m>", default: 1 }),
|
|
118
215
|
"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
216
|
"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
217
|
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 }),
|
|
@@ -123,22 +220,27 @@ const bake = defineCommand({
|
|
|
123
220
|
"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
221
|
frames: num("host frames to run before giving up — load headroom", { default: 900 }),
|
|
125
222
|
timeout: num("host time cap (ms; default: none)"),
|
|
126
|
-
width: num("host
|
|
127
|
-
height: num("host
|
|
128
|
-
renderer: str("
|
|
223
|
+
width: num("the bake host's virtual viewport width — it has no window; nothing in the bake depends on it (default: app.json desktop.window, else 1280)"),
|
|
224
|
+
height: num("the bake host's virtual viewport height (default: app.json desktop.window, else 720)"),
|
|
225
|
+
renderer: str("ignored: the bake host is one binary (lecodes-bake, GL shaders on Filament's NOOP backend); kept so older scripts still parse", { value: "<gl|vulkan>" }),
|
|
129
226
|
logs: bool("forward the host's output"),
|
|
227
|
+
"no-unwrap": bool("leave the referenced GLBs as they are: no lightmap unwrap of those without TEXCOORD_1 (they bake without lightmap uvs)"),
|
|
130
228
|
},
|
|
131
229
|
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 }) => {
|
|
230
|
+
run: async ({ flags, argv }) => {
|
|
133
231
|
const { root } = localProject()
|
|
134
232
|
const outDir = resolve(root, flags.out)
|
|
135
233
|
const stem = flags.name
|
|
234
|
+
const cube = cubePatternsOf(argv)
|
|
136
235
|
if (!/^[A-Za-z0-9_-]+$/.test(stem)) throw new CliError(`--name wants a plain file stem (letters, digits, - and _), got "${stem}".`)
|
|
137
236
|
const entryFile = flags.entry
|
|
138
237
|
if (!existsSync(resolve(root, entryFile))) {
|
|
139
238
|
throw new CliError(`No ${entryFile} in ${root} — pass --entry <file> (the file that builds the scene and calls Lightmap.load).`)
|
|
140
239
|
}
|
|
141
240
|
const debugDir = flags["debug-dir"] ? resolve(root, flags["debug-dir"]) : ""
|
|
241
|
+
if (!["none", "indirect", "no-sun", "all"].includes(flags.denoise)) throw new CliError(`--denoise wants none, indirect, no-sun or all, got "${flags.denoise}".`)
|
|
242
|
+
if (!["auto", "optix", "vulkan"].includes(flags.tracer)) throw new CliError(`--tracer wants auto, optix or vulkan, got "${flags.tracer}".`)
|
|
243
|
+
if (!["auto", "optix", "oidn"].includes(flags.denoiser)) throw new CliError(`--denoiser wants auto, optix or oidn, got "${flags.denoiser}".`)
|
|
142
244
|
const cfg = {
|
|
143
245
|
outDir: outDir.replace(/\\/g, "/"),
|
|
144
246
|
stem,
|
|
@@ -157,6 +259,8 @@ const bake = defineCommand({
|
|
|
157
259
|
patchSamples: range("patch-samples", flags["patch-samples"], 0, 1024),
|
|
158
260
|
split: !!flags.split,
|
|
159
261
|
denoise: flags.denoise,
|
|
262
|
+
tracer: flags.tracer,
|
|
263
|
+
denoiser: flags.denoiser,
|
|
160
264
|
filter: range("filter", flags.filter, 0, 4),
|
|
161
265
|
probeSpacing: range("probe-spacing", flags["probe-spacing"], 0, 100),
|
|
162
266
|
probeSize: range("probe-size", flags["probe-size"], 16, 128),
|
|
@@ -164,6 +268,9 @@ const bake = defineCommand({
|
|
|
164
268
|
probeIndoor: !!flags["probe-indoor"],
|
|
165
269
|
probeLayout: probeLayoutOf(flags["probe-layout"]),
|
|
166
270
|
minTexels: range("min-texels", flags["min-texels"], 0, 10000),
|
|
271
|
+
thinTexels: range("thin-texels", flags["thin-texels"], 0, 64),
|
|
272
|
+
objectSlab: range("object-slab", flags["object-slab"], 0, 100),
|
|
273
|
+
...(cube.length ? { cube } : {}),
|
|
167
274
|
volumeSpacing: range("volume", flags.volume, 0, 100),
|
|
168
275
|
detailTexels: range("detail-texels", flags["detail-texels"], 0, 1e7),
|
|
169
276
|
...(flags["emissive-nits"] !== undefined ? { emissiveNits: range("emissive-nits", flags["emissive-nits"], 0, 1e7) } : {}),
|
|
@@ -187,12 +294,13 @@ const bake = defineCommand({
|
|
|
187
294
|
ensurePlaceholders(outDir, stem)
|
|
188
295
|
if (debugDir) mkdirSync(debugDir, { recursive: true })
|
|
189
296
|
|
|
190
|
-
const desktopRenderer =
|
|
297
|
+
const desktopRenderer = "gl" as const // lecodes-bake is the GL tree on Filament's NOOP backend: `_creator.backend` "desktop" fetches the GL shaders
|
|
191
298
|
const { js, resources } = await buildBundle(bundleOptions(flags), {
|
|
192
299
|
localAssets: true, desktopRenderer,
|
|
193
300
|
extraEntries: [{ path: entryPath, type: "text", text: entryText }],
|
|
194
301
|
entryOverride: entryPath,
|
|
195
302
|
})
|
|
303
|
+
if (!flags["no-unwrap"]) await unwrapReferenced(js, resources, root, cfg.texel)
|
|
196
304
|
|
|
197
305
|
// The host writes the bake itself; the PNG the render driver insists on goes to a scratch dir.
|
|
198
306
|
const scratch = mkdtempSync(join(tmpdir(), "lecodes-lightmap-"))
|
|
@@ -204,7 +312,7 @@ const bake = defineCommand({
|
|
|
204
312
|
try {
|
|
205
313
|
const timeoutMs = flags.timeout || 24 * 3600 * 1000
|
|
206
314
|
const res = await runDesktopRender(js, resources, {
|
|
207
|
-
renderer: desktopRenderer, width, height, frames, outPng, logs: flags.logs, timeoutMs,
|
|
315
|
+
host: "bake", renderer: desktopRenderer, width, height, frames, outPng, logs: flags.logs, timeoutMs,
|
|
208
316
|
stopOnLine: (chunk) => chunk.includes("[lightmap] done") || chunk.includes("[lightmap] error"),
|
|
209
317
|
})
|
|
210
318
|
output = res.output
|
|
@@ -223,7 +331,7 @@ const bake = defineCommand({
|
|
|
223
331
|
logErr(text.startsWith("error") ? c.red(`✗ ${text}`) : text.startsWith("warning") ? c.yellow(` ${text}`) : c.dim(` ${text}`))
|
|
224
332
|
}
|
|
225
333
|
if (flags["dry-run"] && /\[lightmap\] done/.test(output)) {
|
|
226
|
-
for (const l of lines) if (/atlas \d+ x|
|
|
334
|
+
for (const l of lines) if (/atlas \d+ x|packed per file|largest rect|do not fit|fill:|--cube|lightmap: 'cube'/.test(l)) logErr(l.replace(/^\[lightmap\] /, " "))
|
|
227
335
|
logErr(c.green("✓ layout only — nothing written"))
|
|
228
336
|
return
|
|
229
337
|
}
|
|
@@ -236,13 +344,15 @@ const bake = defineCommand({
|
|
|
236
344
|
const probeFiles = data.probes ? ` + ${data.probes.file} (${data.probes.count} probe(s) of ${data.probes.size}² at ${data.probes.spacing} m)` : ""
|
|
237
345
|
const gridFile = data.volume ? ` + ${data.volume.file} (light grid ${data.volume.n.join(" x ")}, ${data.volume.valid} cell(s) in free space)` : ""
|
|
238
346
|
const probeDef = (data.probes ? `, probes: asset('<rel>/${rel(data.probes.file)}')` : "") + (data.volume ? `, volume: asset('<rel>/${rel(data.volume.file)}')` : "")
|
|
239
|
-
|
|
347
|
+
+ (data.uv1 ? `, uv1: asset('<rel>/${rel(data.uv1)}')` : "")
|
|
348
|
+
const uv1File = data.uv1 ? ` + ${data.uv1} (the packed lightmap uv1 — list it as env.lightmap.uv1, the rects map it)` : ""
|
|
349
|
+
logErr(c.green(`✓ baked → ${relative(root, outDir) || "."}/${stem}.bake + ${data.light.join(", ")} + ${data.aux.join(", ")}${splitFiles}${probeFiles}${gridFile}${uv1File}`))
|
|
240
350
|
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
351
|
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
352
|
return
|
|
243
353
|
}
|
|
244
|
-
if (
|
|
245
|
-
throw new CliError("This
|
|
354
|
+
if (/\[lightmap\] error: this host has no bake module/.test(output)) { // console.error: the host prefixes the line ("[ERROR] [lightmap] …")
|
|
355
|
+
throw new CliError("This host has no bake module — the bake runs in lecodes-bake. Point LECODES_BAKE_EXE at it (hosts/desktop: build.ps1 -Bake → build-bake/lecodes-bake.exe), or unset it to use the published one.")
|
|
246
356
|
}
|
|
247
357
|
if (lines.some((l) => /error/.test(l))) throw new CliError("The bake failed — see the lines above.")
|
|
248
358
|
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.")
|
|
@@ -1,7 +1,34 @@
|
|
|
1
|
-
// Types for the optional `lecodes-assets` package's CLI entry (lazy-loaded by the `assets` command
|
|
2
|
-
// The real package is installed separately; this lets the
|
|
3
|
-
// present. Keep in sync with tools/assets/src/cli.ts.
|
|
1
|
+
// Types for the optional `lecodes-assets` package's CLI entry (lazy-loaded by the `assets` command
|
|
2
|
+
// and `lightmap bake`'s unwrap pre-step). The real package is installed separately; this lets the
|
|
3
|
+
// dynamic import typecheck without it present. Keep in sync with tools/assets/src/cli.ts.
|
|
4
4
|
declare module "lecodes-assets/cli" {
|
|
5
|
-
/** Runs `
|
|
5
|
+
/** Runs one `lecodes assets` command line (sub-command first). Resolves to the exit code. */
|
|
6
6
|
export function runAssetsCli(argv: string[]): Promise<number>
|
|
7
|
+
|
|
8
|
+
/** One file's outcome (tools/assets src/unwrap UnwrapFileReport, the fields the CLI reads). */
|
|
9
|
+
export interface UnwrapFileReport {
|
|
10
|
+
input: string
|
|
11
|
+
/** where the unwrapped file went; null when nothing was written */
|
|
12
|
+
output: string | null
|
|
13
|
+
status: "unwrapped" | "skipped" | "failed"
|
|
14
|
+
reason?: string
|
|
15
|
+
/** one line: what the unwrap made of the file */
|
|
16
|
+
summary: string
|
|
17
|
+
ms: number
|
|
18
|
+
}
|
|
19
|
+
/** The lightmap unwrap (TEXCOORD_1, xatlas) of whole GLB files, IN PLACE unless `out` says where; a file that
|
|
20
|
+
* carries TEXCOORD_1 is skipped unless `force`. Never throws for a file's sake: a failure is its report. */
|
|
21
|
+
export function unwrapGlbFiles(files: string[], opts?: {
|
|
22
|
+
force?: boolean
|
|
23
|
+
out?: string
|
|
24
|
+
/** the bake's texel (m) the fixed chart padding is measured in */
|
|
25
|
+
texel?: number
|
|
26
|
+
iterations?: number
|
|
27
|
+
groups?: number
|
|
28
|
+
zone?: string
|
|
29
|
+
zoneNear?: number
|
|
30
|
+
zoneMax?: number
|
|
31
|
+
zoneUp?: number
|
|
32
|
+
onFile?: (r: UnwrapFileReport) => void
|
|
33
|
+
}): Promise<UnwrapFileReport[]>
|
|
7
34
|
}
|