lecodes-assets 2.5.1 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +3970 -3400
- package/dist/index.js +9126 -8556
- package/package.json +1 -1
- package/src/commands/doctor.ts +2 -1
- package/src/commands/scene.ts +2 -1
- package/src/commands/shared.ts +3 -2
- package/src/doctor/cardTrim.ts +19 -13
- package/src/doctor/coverageMips.ts +132 -0
- package/src/doctor/doctor.ts +8 -1
- package/src/doctor/imageFacts.ts +44 -0
- package/src/doctor/impostor.ts +199 -54
- package/src/doctor/transcodeKtx2.ts +71 -24
- package/src/doctor/transcodeTextures.ts +5 -9
- package/src/textures/byteLru.ts +38 -0
- package/src/textures/resolveImage.ts +30 -4
- package/src/unity/image.ts +23 -12
- package/src/unity/kindGlb.ts +47 -7
- package/src/unity/lecodesLevel.ts +37 -6
- package/src/unity/materialWriter.ts +182 -96
- package/src/unity/prefabTree.ts +3 -0
- package/src/unity/scene.ts +6 -2
- package/src/unity/triBudget.ts +41 -9
|
@@ -6,10 +6,13 @@
|
|
|
6
6
|
// factors, which multiply the same way).
|
|
7
7
|
|
|
8
8
|
import { readFileSync } from "node:fs"
|
|
9
|
+
import { cpus } from "node:os"
|
|
9
10
|
import { basename } from "node:path"
|
|
10
11
|
import type { Document, Material, Texture } from "@gltf-transform/core"
|
|
11
12
|
import { KHRMaterialsEmissiveStrength } from "@gltf-transform/extensions"
|
|
12
|
-
import { sniffMime,
|
|
13
|
+
import { sniffMime, encodePngAsync, type Rgba } from "../textures/resolveImage"
|
|
14
|
+
import { ByteLru } from "../textures/byteLru"
|
|
15
|
+
import { knownImageInfo } from "../doctor/imageFacts"
|
|
13
16
|
import type { GuidResolver } from "./guid"
|
|
14
17
|
import { alphaIsKeyed, decodeImage, packOrm, type PackInput } from "./image"
|
|
15
18
|
import { PATH_GUID, declaresNoCutout, isCutoutMaterial, type UnityMaterial } from "./matFile"
|
|
@@ -49,52 +52,190 @@ export const SLOTS = {
|
|
|
49
52
|
}
|
|
50
53
|
export const pick = (m: UnityMaterial, names: string[]): string | null => { for (const n of names) { const g = m.textures[n]; if (g) return g } return null }
|
|
51
54
|
|
|
52
|
-
/**
|
|
53
|
-
*
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
/** A texture as the glTF image it becomes: a source map (PNG / JPEG kept, anything else re-encoded as PNG) or a
|
|
56
|
+
* packed ORM (`parts` = what went into it, `ao` = whether an occlusion source did). */
|
|
57
|
+
type ImageBytes = { name: string, data: Uint8Array, mime: "image/png" | "image/jpeg", parts?: string[], ao?: boolean }
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* THE TEXTURE BYTES OF ONE IMPORT, shared by every document a writer fills.
|
|
61
|
+
*
|
|
62
|
+
* A level import writes every kind as its own GLB, so a wall map shared by a hundred buildings went through
|
|
63
|
+
* read + TGA decode + PNG encode (+ for a mask map: a second decode and the ORM pack) a hundred times — on
|
|
64
|
+
* Chernobyl 3554 embedded textures of which 314 are distinct, and a kind's build time scaled with its texture
|
|
65
|
+
* count, not its parts (Apartment_Door_01_v3, all 9 maps seen before: build 3.2 s, 2026-11-04).
|
|
66
|
+
*
|
|
67
|
+
* Only the BYTES are shared — the same `Uint8Array` object to every document (`Texture.setImage` keeps the
|
|
68
|
+
* reference, and the doctor keys its per-image caches on it: transcodeKtx2.ts `imageInfo`, cardTrim.ts); each
|
|
69
|
+
* document still makes its own Texture per guid. Decoded RGBA is never kept across kinds (16–64 MB a map).
|
|
70
|
+
* Bounded by bytes, least recently used out: `LECODES_IMPORT_TEX_CACHE_MB`, default 3072 — the 14-kind sample
|
|
71
|
+
* held 83 images in 575 MB (~7 MB a map), so the pack's ~314 distinct maps are ~2.2 GB.
|
|
72
|
+
*/
|
|
73
|
+
export class SharedTextures {
|
|
74
|
+
readonly images: ByteLru<ImageBytes | null>
|
|
75
|
+
/** guid → does its alpha carry a shape (the custom-shader cutout test): a full decode for one boolean */
|
|
76
|
+
readonly keyed = new Map<string, Promise<boolean>>()
|
|
77
|
+
constructor(budget = (Number(process.env.LECODES_IMPORT_TEX_CACHE_MB) || 3072) * 1024 * 1024) {
|
|
78
|
+
this.images = new ByteLru(budget, (v) => v?.data.byteLength ?? 0)
|
|
79
|
+
}
|
|
80
|
+
/** At most this many decodes / encodes at once (a prefetched kind starts all of its maps): what bounds is the
|
|
81
|
+
* memory — a 4096² map is 64 MB decoded and as much again as PNG scanlines while it deflates. */
|
|
82
|
+
private running = 0
|
|
83
|
+
private waiting: (() => void)[] = []
|
|
84
|
+
private readonly jobs = Math.max(2, Math.min(8, Math.floor(cpus().length / 2)))
|
|
85
|
+
async slot<T>(work: () => Promise<T>): Promise<T> {
|
|
86
|
+
if (this.running >= this.jobs) await new Promise<void>((res) => this.waiting.push(res))
|
|
87
|
+
else this.running++
|
|
88
|
+
try { return await work() } finally { const next = this.waiting.shift(); if (next) next(); else this.running-- }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A map as PNG, with what the doctor's analysis would ask sharp about it already known: its size and whether its
|
|
93
|
+
* alpha is used are right here in the pixels (imageFacts.ts — sharp's metadata + stats were 0.1–0.25 s a map). */
|
|
94
|
+
const pngOf = async (img: Rgba): Promise<Uint8Array> => {
|
|
95
|
+
const png = await encodePngAsync(img)
|
|
96
|
+
knownImageInfo(png, img)
|
|
97
|
+
return png
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Writes Unity `.mat` materials into glTF materials of `doc` (one Texture per guid per document; the bytes come
|
|
101
|
+
* from `shared`, which a level import hands every kind). `applyTo` rewrites one glTF material from one Unity
|
|
102
|
+
* material and returns a slot summary. */
|
|
103
|
+
export const unityMaterialWriter = (doc: Document, resolver: GuidResolver, warn: Warn = () => {}, shared: SharedTextures = new SharedTextures(Infinity)): { applyTo: (gm: Material, um: UnityMaterial, how: string) => Promise<string[]>, prefetch: (um: UnityMaterial) => void } => {
|
|
104
|
+
const texCache = new Map<string, Promise<Texture | null>>()
|
|
105
|
+
const imgCache = new Map<string, Promise<Rgba | null>>()
|
|
57
106
|
const loadBytes = (guid: string): { bytes: Uint8Array, file: string } | null => {
|
|
58
107
|
const f = guid.startsWith(PATH_GUID) ? guid.slice(PATH_GUID.length) : resolver.resolve(guid)
|
|
59
108
|
if (!f) { warn({ level: "warn", message: `texture ${guid}: not in the index / not extracted — skipped` }); return null }
|
|
60
109
|
return { bytes: new Uint8Array(readFileSync(f)), file: f }
|
|
61
110
|
}
|
|
62
|
-
const
|
|
63
|
-
if (texCache.has(guid)) return texCache.get(guid)!
|
|
111
|
+
const bytesFromGuid = (guid: string): Promise<ImageBytes | null> => shared.images.get(`tex:${guid}`, async () => {
|
|
64
112
|
const src = loadBytes(guid)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
113
|
+
if (!src) return null
|
|
114
|
+
const name = basename(src.file).replace(/\.[^.]+$/, "")
|
|
115
|
+
const mime = sniffMime(src.bytes, src.file)
|
|
116
|
+
if (mime === "image/png" || mime === "image/jpeg") return { name, data: src.bytes, mime }
|
|
117
|
+
const data = await shared.slot(async () => { const img = await decodeImage(src.bytes, src.file); return img ? pngOf(img) : null })
|
|
118
|
+
if (!data) { warn({ level: "warn", message: `texture ${basename(src.file)}: unsupported format (${mime}) — skipped` }); return null }
|
|
119
|
+
return { name, data, mime: "image/png" }
|
|
120
|
+
})
|
|
121
|
+
const texFromGuid = (guid: string): Promise<Texture | null> => {
|
|
122
|
+
let t = texCache.get(guid)
|
|
123
|
+
if (!t) {
|
|
124
|
+
t = bytesFromGuid(guid).then((b) => (b ? doc.createTexture(b.name).setImage(b.data).setMimeType(b.mime) : null))
|
|
125
|
+
texCache.set(guid, t)
|
|
76
126
|
}
|
|
77
|
-
|
|
78
|
-
return tex
|
|
127
|
+
return t
|
|
79
128
|
}
|
|
80
|
-
const imgFromGuid =
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
129
|
+
const imgFromGuid = (guid: string): Promise<Rgba | null> => {
|
|
130
|
+
let p = imgCache.get(guid)
|
|
131
|
+
if (!p) {
|
|
132
|
+
p = (async () => {
|
|
133
|
+
const src = loadBytes(guid)
|
|
134
|
+
const img = src ? await shared.slot(() => decodeImage(src.bytes, src.file)) : null
|
|
135
|
+
if (src && !img) warn({ level: "warn", message: `texture ${basename(src.file)}: could not decode for packing — skipped` })
|
|
136
|
+
return img
|
|
137
|
+
})()
|
|
138
|
+
imgCache.set(guid, p)
|
|
139
|
+
}
|
|
140
|
+
return p
|
|
87
141
|
}
|
|
88
142
|
const nameOf = (guid: string): string => { const f = guid.startsWith(PATH_GUID) ? guid.slice(PATH_GUID.length) : resolver.resolve(guid); return f ? basename(f) : guid }
|
|
89
143
|
|
|
144
|
+
/** The texture guids `applyTo` reads off a Unity material, by role, and the numbers that decide them — picked
|
|
145
|
+
* once, for `applyTo` and for `prefetch` alike. */
|
|
146
|
+
const sourcesOf = (um: UnityMaterial) => {
|
|
147
|
+
// EVERY slot is picked the same way: the first candidate whose texture EXISTS. A `.mat` keeps the
|
|
148
|
+
// property block of every shader it ever wore, so a NatureManufacture bark that moved from HDRP Lit to
|
|
149
|
+
// the pack's own shader still names a deleted `_MaskMap` beside the live `_MetalicRAOGSmothnessA` — and
|
|
150
|
+
// taking the dead one first left the trunk with no pack at all, i.e. `_Smoothness: 1` = a mirror.
|
|
151
|
+
const live = (names: string[]): string | null => names.map((n) => um.textures[n]).find((g) => g && (g.startsWith(PATH_GUID) || resolver.resolve(g))) ?? null
|
|
152
|
+
// the first base slot whose texture EXISTS: custom shaders keep stale refs in the usual slots (a
|
|
153
|
+
// tree-billboard shader's `_BaseColor` names a deleted file, its atlas sits in `_MainTex1`)
|
|
154
|
+
const baseG = live(SLOTS.base)
|
|
155
|
+
const nG = live(SLOTS.normal)
|
|
156
|
+
// metallic / roughness / occlusion
|
|
157
|
+
// Unity's built-in "Autodesk Interactive" shader (builtin fileID 47 — what a Maya / 3ds Max PBR
|
|
158
|
+
// material imports as) reuses the Standard shader's slot NAMES with other meanings: `_SpecGlossMap`
|
|
159
|
+
// is a ROUGHNESS map, `_MetallicGlossMap` a plain metallic map (its alpha is NOT smoothness — it
|
|
160
|
+
// ships opaque, and reading it as smoothness 1 turned a cloth glove into a mirror), and
|
|
161
|
+
// `_Glossiness` is the roughness slider.
|
|
162
|
+
const autodesk = um.shader === "builtin:47"
|
|
163
|
+
const ormG = live(SLOTS.orm), maskG = live(SLOTS.maskMap), mgG = autodesk ? null : live(SLOTS.metallicGloss)
|
|
164
|
+
const metG = live(SLOTS.metallic) ?? (autodesk ? live(SLOTS.metallicGloss) : null)
|
|
165
|
+
const roughG = live(SLOTS.roughness) ?? (autodesk ? um.textures._SpecGlossMap ?? null : null)
|
|
166
|
+
const smoothG = live(SLOTS.smoothness), aoG = live(SLOTS.occlusion)
|
|
167
|
+
const smoothness = autodesk ? (um.floats._Glossiness !== undefined ? 1 - um.floats._Glossiness : undefined) : um.floats._Smoothness ?? um.floats._Glossiness
|
|
168
|
+
// emission: only when the material actually emits (keyword or a non-black colour)
|
|
169
|
+
const emG = live(SLOTS.emission)
|
|
170
|
+
// `_EmissiveColor` FIRST: HDRP's Lit shader emits by that one and leaves the Standard shader's
|
|
171
|
+
// `_EmissionColor` sitting in the property block at white, so reading `_EmissionColor` made every
|
|
172
|
+
// surface of an HDRP pack a self-lit white card — a whole imported level rendered as blank
|
|
173
|
+
// cardboard. URP / Standard materials carry no `_EmissiveColor`, so they are unaffected.
|
|
174
|
+
// ... EXCEPT a material whose `_EMISSION` keyword is on: that is URP / Standard speaking, and there `_EmissionColor`
|
|
175
|
+
// is the live one. A URP pack upgraded from HDRP keeps dead `_EmissiveColor` / `_EmissiveIntensity` fields (the
|
|
176
|
+
// school, 2026-09-18: Ceiling_02 emits at `_EmissionColor` 10 while its `_EmissiveColor` says 1200 nits).
|
|
177
|
+
const emC = um.keywords.includes("_EMISSION") ? um.colors._EmissionColor ?? um.colors._EmissiveColor : um.colors._EmissiveColor ?? um.colors._EmissionColor
|
|
178
|
+
// An explicitly BLACK _EmissionColor is a veto, not a missing value: Unity multiplies the
|
|
179
|
+
// emission map by that colour, so black means the surface does not emit however many maps are
|
|
180
|
+
// bound. Without this the "a map and no keywords, so assume it emits" fallback below fires on
|
|
181
|
+
// custom-shader materials that keep a texture in the slot and switch it off with the colour —
|
|
182
|
+
// which lit a whole rifle body through a magazine's emission map.
|
|
183
|
+
// `m_LightmapFlags` bit 4 (EmissiveIsBlack) is Unity's own veto, in every pipeline: the inspector sets it when the
|
|
184
|
+
// emission is switched off. The school's Ceiling_03 has no `_EMISSION`, this flag, and a dead HDRP `_EmissiveColor`
|
|
185
|
+
// of 0.125 - it was imported as a dim emitter.
|
|
186
|
+
// ... but ONLY for the Standard / URP Lit family, where the inspector couples the flag with the `_EMISSION` keyword.
|
|
187
|
+
// HDRP (and the shadergraphs of HDRP packs) leaves the flag at 4 on materials that glow by `_EmissiveColor` - there it
|
|
188
|
+
// only says "no contribution to baked GI" (the range's M_Lamp_long: `_EmissiveColor` 4, flags 4, and it emits).
|
|
189
|
+
const keywordShader = um.shader === "builtin:45" || um.shader === "builtin:46"
|
|
190
|
+
|| um.shader === "guid:933532a4fcc9baf4fa0491de14d08ed7" || um.shader === "guid:8d2bb70cbf9db8d4da26e15b26e74248" // URP Lit, Simple Lit
|
|
191
|
+
const flagVeto = keywordShader && ((um.lightmapFlags ?? 0) & 4) !== 0 && !um.keywords.includes("_EMISSION")
|
|
192
|
+
const emissionVetoed = (emC !== undefined && emC[0] + emC[1] + emC[2] <= 0.01) || flagVeto
|
|
193
|
+
const emits = !emissionVetoed && (um.keywords.includes("_EMISSION")
|
|
194
|
+
|| (emC ? emC[0] + emC[1] + emC[2] > 0.01 : false)
|
|
195
|
+
|| (!!emG && !um.keywords.length))
|
|
196
|
+
return { baseG, nG, ormG, maskG, mgG, metG, roughG, smoothG, aoG, smoothness, emG, emC, emits }
|
|
197
|
+
}
|
|
198
|
+
type Sources = ReturnType<typeof sourcesOf>
|
|
199
|
+
|
|
200
|
+
/** The packed ORM of a material (R = AO, G = roughness, B = metallic; scalar fallbacks for the channels no map
|
|
201
|
+
* covers), null when none of its sources decodes. The recipe is the key: the same maps and fallbacks pack to
|
|
202
|
+
* the same pixels in every kind. */
|
|
203
|
+
const packedOrm = (um: UnityMaterial, { maskG, mgG, metG, roughG, smoothG, aoG, smoothness }: Sources): Promise<ImageBytes | null> => {
|
|
204
|
+
const dflt = { ao: 255, rough: Math.round((1 - (smoothness ?? 0.5)) * 255), metal: Math.round((um.floats._Metallic ?? 0) * 255) }
|
|
205
|
+
return shared.images.get(`orm:${JSON.stringify([ maskG, mgG, metG, roughG, roughG ? null : smoothG, aoG, dflt ])}`, async () => {
|
|
206
|
+
let ao: PackInput | null = null, rough: PackInput | null = null, metal: PackInput | null = null
|
|
207
|
+
const parts: string[] = []
|
|
208
|
+
if (maskG) { const img = await imgFromGuid(maskG); if (img) { metal = { img, channel: 0 }; ao = { img, channel: 1 }; rough = { img, channel: 3, invert: true }; parts.push(`MaskMap ${nameOf(maskG)}`) } }
|
|
209
|
+
if (mgG) { const img = await imgFromGuid(mgG); if (img) { metal = { img, channel: 0 }; rough = { img, channel: 3, invert: true }; parts.push(`MetallicGloss ${nameOf(mgG)}`) } }
|
|
210
|
+
if (metG) { const img = await imgFromGuid(metG); if (img) { metal = { img, channel: 0 }; parts.push(`metallic ${nameOf(metG)}`) } }
|
|
211
|
+
if (roughG) { const img = await imgFromGuid(roughG); if (img) { rough = { img, channel: 0 }; parts.push(`roughness ${nameOf(roughG)}`) } }
|
|
212
|
+
else if (smoothG) { const img = await imgFromGuid(smoothG); if (img) { rough = { img, channel: 0, invert: true }; parts.push(`smoothness ${nameOf(smoothG)}`) } }
|
|
213
|
+
if (aoG) { const img = await imgFromGuid(aoG); if (img) { ao = { img, channel: 0 }; parts.push(`ao ${nameOf(aoG)}`) } }
|
|
214
|
+
if (!metal && !rough && !ao) return null
|
|
215
|
+
const data = await shared.slot(() => pngOf(packOrm(ao, rough, metal, dflt)))
|
|
216
|
+
return { name: "", data, mime: "image/png", parts, ao: !!ao }
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
const packs = (s: Sources): boolean => !s.ormG && !!(s.maskG || s.mgG || s.metG || s.roughG || s.smoothG || s.aoG)
|
|
220
|
+
|
|
221
|
+
/** Start making every image `applyTo(um)` will read, without touching the document: the bytes land in `shared`
|
|
222
|
+
* (one prepare per guid, deduplicated in flight) and `applyTo` then waits on them in its own order — the
|
|
223
|
+
* Textures are still created in that order, so the file does not change. Run for a kind's materials up front,
|
|
224
|
+
* the deflates of its maps overlap on the thread pool instead of queueing behind one another. */
|
|
225
|
+
const prefetch = (um: UnityMaterial): void => {
|
|
226
|
+
const s = sourcesOf(um)
|
|
227
|
+
const guids = [ s.baseG, s.nG, s.ormG, s.emits ? s.emG : null ]
|
|
228
|
+
for (const g of guids) if (g) void bytesFromGuid(g)
|
|
229
|
+
if (packs(s)) void packedOrm(um, s)
|
|
230
|
+
}
|
|
231
|
+
|
|
90
232
|
/** Rewrite one glTF material from a Unity material; returns the slot summary. */
|
|
91
233
|
const applyTo = async (gm: Material, um: UnityMaterial, how: string): Promise<string[]> => {
|
|
92
234
|
const slots: string[] = how === "name" ? [] : [ `(matched by ${how})` ]
|
|
93
235
|
if (um.shader === "binary") slots.push("(binary .mat — slots inferred from sibling textures)")
|
|
236
|
+
const src = sourcesOf(um)
|
|
237
|
+
const { baseG, nG, ormG, smoothness, emG, emC, emits } = src
|
|
94
238
|
// base colour (+ the FBX's base when the .mat has none — keep whatever is there)
|
|
95
|
-
// the first base slot whose texture EXISTS: custom shaders keep stale refs in the usual slots (a
|
|
96
|
-
// tree-billboard shader's `_BaseColor` names a deleted file, its atlas sits in `_MainTex1`)
|
|
97
|
-
const baseG = SLOTS.base.map((n) => um.textures[n]).find((g) => g && (g.startsWith(PATH_GUID) || resolver.resolve(g))) ?? null
|
|
98
239
|
if (baseG) { const t = await texFromGuid(baseG); if (t) { gm.setBaseColorTexture(t); slots.push(`base=${nameOf(baseG)}`) } }
|
|
99
240
|
let baseColor = um.colors._BaseColor ?? um.colors._Color
|
|
100
241
|
// An Amplify leaf graph's albedo is `_Color × tex + _Color_Tilling × tex × noise` (Chernobyl's Leaves.shader: a
|
|
@@ -108,25 +249,9 @@ export const unityMaterialWriter = (doc: Document, resolver: GuidResolver, warn:
|
|
|
108
249
|
slots.push(`base×(_Color + _Color_Tilling/2)`)
|
|
109
250
|
}
|
|
110
251
|
if (baseColor) gm.setBaseColorFactor([ baseColor[0], baseColor[1], baseColor[2], gm.getBaseColorFactor()[3] ])
|
|
111
|
-
// EVERY slot is picked the way the base is: the first candidate whose texture EXISTS. A `.mat` keeps the
|
|
112
|
-
// property block of every shader it ever wore, so a NatureManufacture bark that moved from HDRP Lit to
|
|
113
|
-
// the pack's own shader still names a deleted `_MaskMap` beside the live `_MetalicRAOGSmothnessA` — and
|
|
114
|
-
// taking the dead one first left the trunk with no pack at all, i.e. `_Smoothness: 1` = a mirror.
|
|
115
|
-
const live = (names: string[]): string | null => names.map((n) => um.textures[n]).find((g) => g && (g.startsWith(PATH_GUID) || resolver.resolve(g))) ?? null
|
|
116
|
-
const nG = live(SLOTS.normal)
|
|
117
252
|
if (nG) { const t = await texFromGuid(nG); if (t) { gm.setNormalTexture(t); gm.setNormalScale(um.floats._BumpScale ?? 1); slots.push(`normal=${nameOf(nG)}`) } }
|
|
118
253
|
|
|
119
254
|
// metallic / roughness / occlusion
|
|
120
|
-
// Unity's built-in "Autodesk Interactive" shader (builtin fileID 47 — what a Maya / 3ds Max PBR
|
|
121
|
-
// material imports as) reuses the Standard shader's slot NAMES with other meanings: `_SpecGlossMap`
|
|
122
|
-
// is a ROUGHNESS map, `_MetallicGlossMap` a plain metallic map (its alpha is NOT smoothness — it
|
|
123
|
-
// ships opaque, and reading it as smoothness 1 turned a cloth glove into a mirror), and
|
|
124
|
-
// `_Glossiness` is the roughness slider.
|
|
125
|
-
const autodesk = um.shader === "builtin:47"
|
|
126
|
-
const ormG = live(SLOTS.orm), maskG = live(SLOTS.maskMap), mgG = autodesk ? null : live(SLOTS.metallicGloss)
|
|
127
|
-
const metG = live(SLOTS.metallic) ?? (autodesk ? live(SLOTS.metallicGloss) : null)
|
|
128
|
-
const roughG = live(SLOTS.roughness) ?? (autodesk ? um.textures._SpecGlossMap ?? null : null)
|
|
129
|
-
const smoothG = live(SLOTS.smoothness), aoG = live(SLOTS.occlusion)
|
|
130
255
|
/** Metallic / roughness as plain NUMBERS off the material.
|
|
131
256
|
*
|
|
132
257
|
* This runs when no map covers them — including when the material NAMES metal-ish slots that
|
|
@@ -144,7 +269,6 @@ export const unityMaterialWriter = (doc: Document, resolver: GuidResolver, warn:
|
|
|
144
269
|
if (smoothness !== undefined) gm.setRoughnessFactor(1 - smoothness)
|
|
145
270
|
slots.push(`metallic=${gm.getMetallicFactor().toFixed(2)} roughness=${gm.getRoughnessFactor().toFixed(2)}`)
|
|
146
271
|
}
|
|
147
|
-
const smoothness = autodesk ? (um.floats._Glossiness !== undefined ? 1 - um.floats._Glossiness : undefined) : um.floats._Smoothness ?? um.floats._Glossiness
|
|
148
272
|
// A shader's SCALE on top of the maps. glTF's metallicFactor / roughnessFactor multiply their
|
|
149
273
|
// texture exactly the way these do, so they carry over one-to-one — and dropping them is not a
|
|
150
274
|
// cosmetic loss: KINEMATION's weapon shader ships `_RoughnessScale: 0.65`, so a gun imported
|
|
@@ -168,53 +292,14 @@ export const unityMaterialWriter = (doc: Document, resolver: GuidResolver, warn:
|
|
|
168
292
|
if (ormG) {
|
|
169
293
|
const t = await texFromGuid(ormG)
|
|
170
294
|
if (t) setMr(t, true, `orm=${nameOf(ormG)}`)
|
|
171
|
-
} else if (
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if (mgG) { const img = await imgFromGuid(mgG); if (img) { metal = { img, channel: 0 }; rough = { img, channel: 3, invert: true }; parts.push(`MetallicGloss ${nameOf(mgG)}`) } }
|
|
177
|
-
if (metG) { const img = await imgFromGuid(metG); if (img) { metal = { img, channel: 0 }; parts.push(`metallic ${nameOf(metG)}`) } }
|
|
178
|
-
if (roughG) { const img = await imgFromGuid(roughG); if (img) { rough = { img, channel: 0 }; parts.push(`roughness ${nameOf(roughG)}`) } }
|
|
179
|
-
else if (smoothG) { const img = await imgFromGuid(smoothG); if (img) { rough = { img, channel: 0, invert: true }; parts.push(`smoothness ${nameOf(smoothG)}`) } }
|
|
180
|
-
if (aoG) { const img = await imgFromGuid(aoG); if (img) { ao = { img, channel: 0 }; parts.push(`ao ${nameOf(aoG)}`) } }
|
|
181
|
-
if (metal || rough || ao) {
|
|
182
|
-
// scalar fallbacks for the channels no map covers
|
|
183
|
-
const dflt = { ao: 255, rough: Math.round((1 - (smoothness ?? 0.5)) * 255), metal: Math.round((um.floats._Metallic ?? 0) * 255) }
|
|
184
|
-
const packed = packOrm(ao, rough, metal, dflt)
|
|
185
|
-
const t = doc.createTexture(`${um.name}_ORM`).setImage(encodePng(packed)).setMimeType("image/png")
|
|
186
|
-
setMr(t, !!ao, `packed ORM ← ${parts.join(" + ")}`)
|
|
295
|
+
} else if (packs(src)) {
|
|
296
|
+
const packed = await packedOrm(um, src)
|
|
297
|
+
if (packed) {
|
|
298
|
+
const t = doc.createTexture(`${um.name}_ORM`).setImage(packed.data).setMimeType("image/png")
|
|
299
|
+
setMr(t, !!packed.ao, `packed ORM ← ${packed.parts!.join(" + ")}`)
|
|
187
300
|
} else scalarMr()
|
|
188
301
|
} else scalarMr()
|
|
189
302
|
|
|
190
|
-
// emission: only when the material actually emits (keyword or a non-black colour)
|
|
191
|
-
const emG = live(SLOTS.emission)
|
|
192
|
-
// `_EmissiveColor` FIRST: HDRP's Lit shader emits by that one and leaves the Standard shader's
|
|
193
|
-
// `_EmissionColor` sitting in the property block at white, so reading `_EmissionColor` made every
|
|
194
|
-
// surface of an HDRP pack a self-lit white card — a whole imported level rendered as blank
|
|
195
|
-
// cardboard. URP / Standard materials carry no `_EmissiveColor`, so they are unaffected.
|
|
196
|
-
// ... EXCEPT a material whose `_EMISSION` keyword is on: that is URP / Standard speaking, and there `_EmissionColor`
|
|
197
|
-
// is the live one. A URP pack upgraded from HDRP keeps dead `_EmissiveColor` / `_EmissiveIntensity` fields (the
|
|
198
|
-
// school, 2026-09-18: Ceiling_02 emits at `_EmissionColor` 10 while its `_EmissiveColor` says 1200 nits).
|
|
199
|
-
const emC = um.keywords.includes("_EMISSION") ? um.colors._EmissionColor ?? um.colors._EmissiveColor : um.colors._EmissiveColor ?? um.colors._EmissionColor
|
|
200
|
-
// An explicitly BLACK _EmissionColor is a veto, not a missing value: Unity multiplies the
|
|
201
|
-
// emission map by that colour, so black means the surface does not emit however many maps are
|
|
202
|
-
// bound. Without this the "a map and no keywords, so assume it emits" fallback below fires on
|
|
203
|
-
// custom-shader materials that keep a texture in the slot and switch it off with the colour —
|
|
204
|
-
// which lit a whole rifle body through a magazine's emission map.
|
|
205
|
-
// `m_LightmapFlags` bit 4 (EmissiveIsBlack) is Unity's own veto, in every pipeline: the inspector sets it when the
|
|
206
|
-
// emission is switched off. The school's Ceiling_03 has no `_EMISSION`, this flag, and a dead HDRP `_EmissiveColor`
|
|
207
|
-
// of 0.125 - it was imported as a dim emitter.
|
|
208
|
-
// ... but ONLY for the Standard / URP Lit family, where the inspector couples the flag with the `_EMISSION` keyword.
|
|
209
|
-
// HDRP (and the shadergraphs of HDRP packs) leaves the flag at 4 on materials that glow by `_EmissiveColor` - there it
|
|
210
|
-
// only says "no contribution to baked GI" (the range's M_Lamp_long: `_EmissiveColor` 4, flags 4, and it emits).
|
|
211
|
-
const keywordShader = um.shader === "builtin:45" || um.shader === "builtin:46"
|
|
212
|
-
|| um.shader === "guid:933532a4fcc9baf4fa0491de14d08ed7" || um.shader === "guid:8d2bb70cbf9db8d4da26e15b26e74248" // URP Lit, Simple Lit
|
|
213
|
-
const flagVeto = keywordShader && ((um.lightmapFlags ?? 0) & 4) !== 0 && !um.keywords.includes("_EMISSION")
|
|
214
|
-
const emissionVetoed = (emC !== undefined && emC[0] + emC[1] + emC[2] <= 0.01) || flagVeto
|
|
215
|
-
const emits = !emissionVetoed && (um.keywords.includes("_EMISSION")
|
|
216
|
-
|| (emC ? emC[0] + emC[1] + emC[2] > 0.01 : false)
|
|
217
|
-
|| (!!emG && !um.keywords.length))
|
|
218
303
|
// an HDR emission colour (Unity's intensity is in it): glTF's factor stops at 1, the rest is
|
|
219
304
|
// KHR_materials_emissive_strength - the factor keeps the hue, the strength the brightest channel
|
|
220
305
|
const setEmission = (c: readonly number[] | undefined): void => {
|
|
@@ -296,9 +381,10 @@ export const unityMaterialWriter = (doc: Document, resolver: GuidResolver, warn:
|
|
|
296
381
|
// (you saw the placeholder ground box through it) and so was the 12 cm skirting at the foot of
|
|
297
382
|
// every wall, which is the same linoleum material on the wall mesh. `_Mode: 0` still does not
|
|
298
383
|
// count as a declaration — see `declaresNoCutout`.
|
|
299
|
-
else if (um.shader.startsWith("guid:") && baseG && !declaresNoCutout(um) && await (
|
|
300
|
-
|
|
301
|
-
|
|
384
|
+
else if (um.shader.startsWith("guid:") && baseG && !declaresNoCutout(um) && await (() => {
|
|
385
|
+
let k = shared.keyed.get(baseG)
|
|
386
|
+
if (!k) { k = imgFromGuid(baseG).then((img) => !!img && alphaIsKeyed(img)); shared.keyed.set(baseG, k) }
|
|
387
|
+
return k
|
|
302
388
|
})()) {
|
|
303
389
|
gm.setAlphaMode("MASK").setAlphaCutoff(um.floats._AlphaCutoff ?? um.floats._Cutoff ?? 0.5)
|
|
304
390
|
slots.push(`cutout (custom shader, keyed base map)`)
|
|
@@ -312,5 +398,5 @@ export const unityMaterialWriter = (doc: Document, resolver: GuidResolver, warn:
|
|
|
312
398
|
|
|
313
399
|
return slots
|
|
314
400
|
}
|
|
315
|
-
return { applyTo }
|
|
401
|
+
return { applyTo, prefetch }
|
|
316
402
|
}
|
package/src/unity/prefabTree.ts
CHANGED
|
@@ -197,6 +197,9 @@ export class PrefabLoader {
|
|
|
197
197
|
// it is kept and the loss is counted rather than the wall disappearing.
|
|
198
198
|
const objRef = m.objectReference && ref(m.objectReference).fileID !== "0" ? m.objectReference : undefined
|
|
199
199
|
if (objRef && !ref(objRef).guid && ref(readPath(target.body, path)).guid) { this.stats.sceneLocalRefs++; return }
|
|
200
|
+
// the slot list BEFORE the first override (`m_MaterialsSource`, ours): an overridden slot's material names nothing in
|
|
201
|
+
// the FBX, and which submesh the slot stands for is told only by what it held in the source prefab (kindGlb pairs by it)
|
|
202
|
+
if (materialSlot(path) !== null && Array.isArray(target.body.m_Materials) && target.body.m_MaterialsSource === undefined) target.body.m_MaterialsSource = structuredClone(target.body.m_Materials)
|
|
200
203
|
setPath(target.body, path, objRef ?? m.value)
|
|
201
204
|
}
|
|
202
205
|
|
package/src/unity/scene.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { resolverFor } from "./guid"
|
|
|
18
18
|
import { I4, decompose, mirrorX, mmul, r6, trsMatrix, type Mat4 } from "./math"
|
|
19
19
|
import { PrefabLoader, newStats } from "./prefabTree"
|
|
20
20
|
import { readUnityTerrain, type UnityTerrain } from "./terrain"
|
|
21
|
-
import { color, isMap, num, ref, vec3, type Quat, type Vec3 } from "./yaml"
|
|
21
|
+
import { color, isMap, num, ref, vec3, type Quat, type Vec3, type Y } from "./yaml"
|
|
22
22
|
|
|
23
23
|
export type Handedness = "x" | "none"
|
|
24
24
|
|
|
@@ -32,6 +32,9 @@ export type KindPart = {
|
|
|
32
32
|
mesh: { guid: string, fileID: string }
|
|
33
33
|
/** `.mat` GUIDs, one per submesh (Unity's order). */
|
|
34
34
|
materials: string[]
|
|
35
|
+
/** What each slot held in the SOURCE prefab, before an instance overrode it (`""` = nothing there); absent when no
|
|
36
|
+
* slot was overridden. Only for pairing slots with primitives — never in the kind key, so no kind splits on it. */
|
|
37
|
+
sourceMaterials?: string[]
|
|
35
38
|
/** The level this part is drawn at. THE ENGINE'S LOD GROUP IS THE WHOLE GLB (creator-gl lod.cpp): at level n it draws
|
|
36
39
|
* the `_LOD<n>` nodes and nothing else, so a renderer Unity draws at several levels (listed under each, or in a
|
|
37
40
|
* cull-only LODGroup, or in none while the kind has levels) is written once per level — the same mesh under a
|
|
@@ -240,7 +243,8 @@ export const importUnityScene = async (scenePath: string, opts: ImportSceneOptio
|
|
|
240
243
|
// in no LODGroup: a loose renderer named like its node (`…_LOD1`) keeps that level; a plain one is drawn at every level
|
|
241
244
|
const suffix = lodSuffix(goName(g))
|
|
242
245
|
const levels = lods.get(mr) ?? (suffix > 0 ? [ suffix ] : [])
|
|
243
|
-
|
|
246
|
+
const source = Array.isArray(mr.body.m_MaterialsSource) ? mats.map((_, k) => ref((mr.body.m_MaterialsSource as Y[])[k]).guid ?? "") : undefined
|
|
247
|
+
drawn.push({ part: { path: p, matrix: M, mesh: { guid: mesh.guid, fileID: mesh.fileID }, materials: mats, ...(source ? { sourceMaterials: source } : {}), castShadows: num(mr.body.m_CastShadows, 1) !== 0 }, levels })
|
|
244
248
|
}
|
|
245
249
|
}
|
|
246
250
|
for (const c of g.comps) {
|
package/src/unity/triBudget.ts
CHANGED
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
// renderer's physics down with an out-of-bounds abort before the first frame.
|
|
6
6
|
//
|
|
7
7
|
// So a kind over the budget is simplified at import (meshoptimizer, the same weld + simplify the doctor's
|
|
8
|
-
// LOD pass runs),
|
|
8
|
+
// LOD pass runs), each LOD level over the budget by its own ratio. A scan's look lives in its normal map, which the
|
|
9
9
|
// simplified surface keeps (the UVs are preserved), and the collider shrinks with it. Skinned meshes are
|
|
10
10
|
// left alone: collapsing vertices under a skin moves weights the budget knows nothing about.
|
|
11
11
|
|
|
12
|
-
import { PropertyType, type Document, type Primitive } from "@gltf-transform/core"
|
|
12
|
+
import { PropertyType, type Document, type Mesh, type Primitive } from "@gltf-transform/core"
|
|
13
13
|
import { prune, simplifyPrimitive, weldPrimitive } from "@gltf-transform/functions"
|
|
14
14
|
import { MeshoptSimplifier } from "meshoptimizer"
|
|
15
15
|
|
|
@@ -17,15 +17,47 @@ export const DEFAULT_MAX_TRIS = 50_000
|
|
|
17
17
|
|
|
18
18
|
const trisOf = (p: Primitive): number => p.getMode() !== 4 ? 0 : Math.floor((p.getIndices()?.getCount() ?? p.getAttribute("POSITION")?.getCount() ?? 0) / 3)
|
|
19
19
|
|
|
20
|
-
/**
|
|
20
|
+
/** The primitives drawn at each LOD level (`_LOD<n>` node names, none = level 0): kindGlb writes a node per level a
|
|
21
|
+
* part is drawn at, so a primitive shared by twins counts at every one of their levels. A mesh on no node is level 0. */
|
|
22
|
+
const primsByLevel = (doc: Document): Map<number, Set<Primitive>> => {
|
|
23
|
+
const out = new Map<number, Set<Primitive>>()
|
|
24
|
+
const add = (level: number, mesh: Mesh): void => {
|
|
25
|
+
let set = out.get(level)
|
|
26
|
+
if (!set) out.set(level, set = new Set())
|
|
27
|
+
for (const p of mesh.listPrimitives()) if (p.getMode() === 4 && !p.getAttribute("JOINTS_0")) set.add(p)
|
|
28
|
+
}
|
|
29
|
+
const placed = new Set<Mesh>()
|
|
30
|
+
for (const n of doc.getRoot().listNodes()) {
|
|
31
|
+
const mesh = n.getMesh()
|
|
32
|
+
if (!mesh) continue
|
|
33
|
+
placed.add(mesh)
|
|
34
|
+
add(Number(/_LOD(\d+)$/i.exec(n.getName())?.[1] ?? 0), mesh)
|
|
35
|
+
}
|
|
36
|
+
for (const m of doc.getRoot().listMeshes()) if (!placed.has(m)) add(0, m)
|
|
37
|
+
return out
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Simplify the document's meshes until no LOD level draws more than about `maxTris` triangles. Null when it already fits.
|
|
41
|
+
*
|
|
42
|
+
* THE BUDGET IS PER LEVEL, never the kind's sum: one level is drawn at a time and the collider is level 0. Summed,
|
|
43
|
+
* Chernobyl's Ferris_Wheel (110 k + 44 k + 20 k + 10 k over four levels) went down to 0.27 of itself on EVERY level,
|
|
44
|
+
* the coarsest included, and at that ratio the 5 % error ceiling (a metre on a 22 m wheel) collapsed the rim and the
|
|
45
|
+
* outer half of the spokes: the cabins hung in the air around a stump of a wheel (2026-11-04). Now only a level over
|
|
46
|
+
* the budget is simplified, by its own ratio. */
|
|
21
47
|
export const fitTriangleBudget = async (doc: Document, maxTris: number): Promise<{ before: number, after: number } | null> => {
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
const
|
|
48
|
+
const levels = primsByLevel(doc)
|
|
49
|
+
const levelTris = (set: Set<Primitive>): number => [ ...set ].reduce((n, p) => n + trisOf(p), 0)
|
|
50
|
+
const heaviest = (): number => Math.max(0, ...[ ...levels.values() ].map(levelTris))
|
|
51
|
+
const before = heaviest()
|
|
25
52
|
if (maxTris <= 0 || before <= maxTris) return null
|
|
53
|
+
// a primitive drawn at several levels takes the strictest of their ratios
|
|
54
|
+
const ratioOf = new Map<Primitive, number>()
|
|
55
|
+
for (const set of levels.values()) {
|
|
56
|
+
const r = maxTris / Math.max(1, levelTris(set))
|
|
57
|
+
if (r < 1) for (const p of set) ratioOf.set(p, Math.min(r, ratioOf.get(p) ?? 1))
|
|
58
|
+
}
|
|
26
59
|
await MeshoptSimplifier.ready
|
|
27
|
-
const ratio
|
|
28
|
-
for (const p of prims) {
|
|
60
|
+
for (const [ p, ratio ] of ratioOf) {
|
|
29
61
|
const want = trisOf(p) * ratio
|
|
30
62
|
weldPrimitive(p)
|
|
31
63
|
// `error` is the ceiling on how far the surface may move, relative to the mesh's size: a scan has
|
|
@@ -46,7 +78,7 @@ export const fitTriangleBudget = async (doc: Document, maxTris: number): Promise
|
|
|
46
78
|
// weld and simplify write NEW accessors: the million-triangle originals stay in the file until pruned
|
|
47
79
|
// (the herbarium was 27 MB at 50 000 triangles without this)
|
|
48
80
|
await doc.transform(prune({ propertyTypes: [ PropertyType.ACCESSOR ] }))
|
|
49
|
-
return { before, after:
|
|
81
|
+
return { before, after: heaviest() }
|
|
50
82
|
}
|
|
51
83
|
|
|
52
84
|
/** Area-weighted vertex normals, summed per POSITION so a UV seam (two vertices, one point) shades as one. */
|