lecodes-assets 2.5.1 → 2.8.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.
@@ -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, encodePng, type Rgba } from "../textures/resolveImage"
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
- /** Writes Unity `.mat` materials into glTF materials of `doc` (textures decoded / packed once per
53
- * document). `applyTo` rewrites one glTF material from one Unity material and returns a slot summary. */
54
- export const unityMaterialWriter = (doc: Document, resolver: GuidResolver, warn: Warn = () => {}): { applyTo: (gm: Material, um: UnityMaterial, how: string) => Promise<string[]> } => {
55
- const texCache = new Map<string, Texture | null>()
56
- const imgCache = new Map<string, Rgba | null>()
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 texFromGuid = async (guid: string): Promise<Texture | null> => {
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
- let tex: Texture | null = null
66
- if (src) {
67
- const mime = sniffMime(src.bytes, src.file)
68
- let data = src.bytes, outMime: "image/png" | "image/jpeg" = "image/png"
69
- if (mime === "image/png" || mime === "image/jpeg") outMime = mime
70
- else {
71
- const img = await decodeImage(src.bytes, src.file)
72
- if (!img) { warn({ level: "warn", message: `texture ${basename(src.file)}: unsupported format (${mime}) — skipped` }); texCache.set(guid, null); return null }
73
- data = encodePng(img)
74
- }
75
- tex = doc.createTexture(basename(src.file).replace(/\.[^.]+$/, "")).setImage(data).setMimeType(outMime)
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
- texCache.set(guid, tex)
78
- return tex
127
+ return t
79
128
  }
80
- const imgFromGuid = async (guid: string): Promise<Rgba | null> => {
81
- if (imgCache.has(guid)) return imgCache.get(guid)!
82
- const src = loadBytes(guid)
83
- const img = src ? await decodeImage(src.bytes, src.file) : null
84
- if (src && !img) warn({ level: "warn", message: `texture ${basename(src.file)}: could not decode for packing — skipped` })
85
- imgCache.set(guid, img)
86
- return img
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 (maskG || mgG || metG || roughG || smoothG || aoG) {
172
- // pack: R = AO, G = roughness, B = metallic
173
- let ao: PackInput | null = null, rough: PackInput | null = null, metal: PackInput | null = null
174
- const parts: string[] = []
175
- 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)}`) } }
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 (async () => {
300
- const img = await imgFromGuid(baseG)
301
- return !!img && alphaIsKeyed(img)
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
  }
@@ -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
 
@@ -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
- drawn.push({ part: { path: p, matrix: M, mesh: { guid: mesh.guid, fileID: mesh.fileID }, materials: mats, castShadows: num(mr.body.m_CastShadows, 1) !== 0 }, levels })
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) {
@@ -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), all of its primitives by one ratio. A scan's look lives in its normal map, which the
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
- /** Simplify the document's meshes down to about `maxTris` triangles in total. Null when it already fits. */
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
- // a Set: a kind's parts that share a mesh share its primitives (kindGlb clones meshes shallowly)
23
- const prims = [ ...new Set(doc.getRoot().listMeshes().flatMap((m) => m.listPrimitives())) ].filter((p) => p.getMode() === 4 && !p.getAttribute("JOINTS_0"))
24
- const before = prims.reduce((n, p) => n + trisOf(p), 0)
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 = maxTris / before
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: prims.reduce((n, p) => n + trisOf(p), 0) }
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. */