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.
@@ -9,8 +9,12 @@
9
9
  // first-person weapon or gloves fill a third of the screen at 30 cm, where ETC1S's block artifacts
10
10
  // on the base colour are plainly visible. World props stay on the default: ETC1S is the right
11
11
  // trade there and the size difference is roughly 8x.
12
- // Mipmaps are generated; alpha is preserved (ETC1S/UASTC both encode it).
13
- import { createHash } from 'node:crypto'
12
+ // Mipmaps are generated; alpha is preserved (ETC1S/UASTC both encode it). The base colour of an alpha-MASK material
13
+ // takes COVERAGE-PRESERVING mips instead of the encoder's (doctor/coverageMips.ts): the chain is built here, every
14
+ // level's alpha scaled to the source's coverage at the material's cutoff, and handed over as explicit levels — a box
15
+ // mip of a hard-edged leaf atlas falls under the cutoff a few levels down and the crown goes bare (Chernobyl's birches
16
+ // at 20 m; the pack's Unity meta asks for `mipMapsPreserveCoverage`). The `--max-size` downscale of such a texture is
17
+ // the chain's level 0, scaled the same way.
14
18
  import sharp from 'sharp'
15
19
  import { spawn, spawnSync } from 'node:child_process' // spawnSync only for the one-shot --version probe
16
20
  import { existsSync, mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
@@ -18,6 +22,9 @@ import { cpus, tmpdir } from 'node:os'
18
22
  import { join } from 'node:path'
19
23
  import type { Root, Texture } from '@gltf-transform/core'
20
24
  import { getTextureRoles, isImpostorAtlas, DEFAULT_MAX_SIZE } from './transcodeTextures'
25
+ import { coverageMipChain } from './coverageMips'
26
+ import { impostorDataMipChain } from './impostor'
27
+ import { imageInfo, imageSha1 } from './imageFacts'
21
28
 
22
29
  /**
23
30
  * The KTX2 encoder: KTX-Software's legacy `toktx`, or — when there is none — its unified `ktx`
@@ -84,15 +91,19 @@ export interface KtxEncodeJob {
84
91
  uastcQuality: number
85
92
  /** Zstandard level for UASTC (ETC1S is Basis-LZ, never supercompressed on top). */
86
93
  zstd: number
94
+ /** An explicit mip chain (level 0 first, down to 1 × 1) in place of the encoder's own mips; `input` is then unused. */
95
+ levels?: string[]
87
96
  }
88
97
 
89
- /** The encoder's command line for one PNG → KTX2 (always 2D, mipmapped, the transfer function assigned). */
98
+ /** The encoder's command line for one PNG → KTX2 (always 2D, mipmapped, the transfer function assigned): the encoder's
99
+ * own mips, or the explicit `levels` (toktx `--mipmap` takes one input per level; `ktx create` the same with `--levels`). */
90
100
  export function ktxEncodeArgs(enc: KtxEncoder, j: KtxEncodeJob): string[] {
101
+ const inputs = j.levels?.length ? j.levels : [ j.input ]
91
102
  if (enc.kind === 'toktx') {
92
103
  return [ '--t2', ...(j.codec === 'etc1s'
93
104
  ? [ '--encode', 'etc1s', '--clevel', '2', '--qlevel', '128' ]
94
105
  : [ '--encode', 'uastc', '--uastc_quality', String(j.uastcQuality), '--zcmp', String(j.zstd) ]),
95
- '--genmipmap', '--assign_oetf', j.oetf, j.output, j.input ]
106
+ ...(j.levels?.length ? [ '--mipmap' ] : [ '--genmipmap' ]), '--assign_oetf', j.oetf, j.output, ...inputs ]
96
107
  }
97
108
  const format = `${j.channels === 4 ? 'R8G8B8A8' : 'R8G8B8'}_${j.oetf === 'srgb' ? 'SRGB' : 'UNORM'}`
98
109
  return [ 'create', '--format', format, ...(j.codec === 'etc1s'
@@ -100,7 +111,7 @@ export function ktxEncodeArgs(enc: KtxEncoder, j: KtxEncodeJob): string[] {
100
111
  : [ '--encode', 'uastc', '--uastc-quality', String(j.uastcQuality), '--zstd', String(j.zstd) ]),
101
112
  // --assign-tf: sharp's PNGs carry no colour chunk, which `ktx` reads as sRGB and then refuses to
102
113
  // put into a *_UNORM format without a conversion — the data maps ARE linear, so say so.
103
- '--generate-mipmap', '--assign-tf', j.oetf, j.input, j.output ]
114
+ ...(j.levels?.length ? [ '--levels', String(j.levels.length) ] : [ '--generate-mipmap' ]), '--assign-tf', j.oetf, ...inputs, j.output ]
104
115
  }
105
116
 
106
117
  export interface Ktx2Plan {
@@ -114,6 +125,12 @@ export interface Ktx2Plan {
114
125
  width: number
115
126
  height: number
116
127
  hasAlpha: boolean
128
+ /** The base colour of an alpha-MASK material: its cutoff (the lowest of the MASK materials sharing it) — the mips and
129
+ * the downscale keep the coverage at it. null = plain mips. */
130
+ maskCutoff: number | null
131
+ /** An impostor atlas' DATA map (the metallicRoughness slot of a `lecodes.impostor` material): its mips are built here
132
+ * by averaging the decoded normals (impostor.ts impostorDataMipChain), never by a box over the octahedral map. */
133
+ impostorData: boolean
117
134
  beforeBytes: number
118
135
  afterBytes?: number
119
136
  category: 'lossy'
@@ -129,15 +146,9 @@ export async function planKtx2(tex: Texture, root: Root, maxSize = DEFAULT_MAX_S
129
146
  if (!img || fromFmt.includes('ktx')) return null // missing or already ktx2
130
147
 
131
148
  const { roles, isNormal, isData } = getTextureRoles(tex, root)
132
- let width = 0, height = 0, hasAlpha = false
133
- try {
134
- const b = Buffer.from(img)
135
- const m = await sharp(b).metadata()
136
- width = m.width || 0; height = m.height || 0
137
- hasAlpha = !(await sharp(b).stats()).isOpaque
138
- } catch {
139
- return null
140
- }
149
+ const info = await imageInfo(img)
150
+ if (!info) return null
151
+ const { width, height, hasAlpha } = info
141
152
 
142
153
  // DATA maps (normal, metal / rough, occlusion) always take UASTC: ETC1S is a palette codec whose blocks show
143
154
  // as steps on a smooth metallic or roughness channel (the range's metal props read pixelated under the
@@ -153,10 +164,22 @@ export async function planKtx2(tex: Texture, root: Root, maxSize = DEFAULT_MAX_S
153
164
  const oetf: 'srgb' | 'linear' = isNormal || isData ? 'linear' : 'srgb'
154
165
  // an impostor atlas keeps its size whatever the cap (its frames are fixed pixel rects); still ×4 and mipmapped
155
166
  const resize = !isImpostorAtlas(tex) && Math.max(width, height) > maxSize
167
+ // COVERAGE-PRESERVING mips for the base colour of an alpha-MASK material, at the lowest cutoff of the MASK materials
168
+ // that share it. NOT for an impostor atlas (its material is MASK too): its alpha is the FRACTIONAL area coverage of a
169
+ // crown by design, not a hard-edged mask, and the engine's mip boost (foliage-impostor.mat) assumes plain mips of it
170
+ let maskCutoff: number | null = null
171
+ if (hasAlpha && !isImpostorAtlas(tex)) {
172
+ for (const m of root.listMaterials()) {
173
+ if (m.getBaseColorTexture() !== tex || m.getAlphaMode() !== 'MASK') continue
174
+ const c = m.getAlphaCutoff()
175
+ if (c > 0 && (maskCutoff === null || c < maskCutoff)) maskCutoff = c
176
+ }
177
+ }
178
+ const impostorData = isImpostorAtlas(tex) && root.listMaterials().some((m) => m.getMetallicRoughnessTexture() === tex)
156
179
  return {
157
- tex, name: tex.getName() || '?', roles, fromFmt, codec, oetf, resize, width, height, hasAlpha,
180
+ tex, name: tex.getName() || '?', roles, fromFmt, codec, oetf, resize, width, height, hasAlpha, maskCutoff, impostorData,
158
181
  beforeBytes: img.length, category: 'lossy',
159
- reason: `${fromFmt} → ktx2/${codec}${hasAlpha ? '+alpha' : ''}${resize ? ` (≤${maxSize}px)` : ''}`,
182
+ reason: `${fromFmt} → ktx2/${codec}${hasAlpha ? '+alpha' : ''}${resize ? ` (≤${maxSize}px)` : ''}${maskCutoff !== null ? ` · coverage mips @${maskCutoff}` : ''}${impostorData ? ' · normal mips' : ''}`,
160
183
  }
161
184
  }
162
185
 
@@ -216,7 +239,7 @@ export const ktx2CacheHits = (): number => hits
216
239
  export async function applyKtx2(plan: Ktx2Plan, enc: KtxEncoder, maxSize = DEFAULT_MAX_SIZE): Promise<void> {
217
240
  const src = plan.tex.getImage()
218
241
  const key = src
219
- ? `${createHash('sha1').update(src).digest('hex')}:${maxSize}:${plan.codec}:${plan.oetf}:${plan.hasAlpha ? 1 : 0}`
242
+ ? `${await imageSha1(src)}:${maxSize}:${plan.codec}:${plan.oetf}:${plan.hasAlpha ? 1 : 0}:${plan.maskCutoff ?? '-'}:cov1:${plan.impostorData ? 'nmip1' : '-'}`
220
243
  : null
221
244
  if (key) {
222
245
  const hit = encoded.get(key)
@@ -257,16 +280,40 @@ async function encodeOne(plan: Ktx2Plan, enc: KtxEncoder, maxSize: number): Prom
257
280
  if (plan.resize && m > maxSize) { const s = maxSize / m; tw = tw * s; th = th * s }
258
281
  tw = snap4(tw); th = snap4(th)
259
282
 
260
- let pipe = sharp(Buffer.from(plan.tex.getImage()!))
261
- if (tw !== plan.width || th !== plan.height) pipe = pipe.resize({ width: tw, height: th, fit: 'fill' })
262
- // Always RGB / RGBA (a grey or palette source too): `ktx create` declares the channel count up front.
263
- pipe = pipe.toColourspace('srgb')
264
- pipe = plan.hasAlpha ? pipe.ensureAlpha() : pipe.removeAlpha() // RGB → no wasted alpha slice
265
- writeFileSync(inPng, await pipe.png().toBuffer())
283
+ let levels: string[] | undefined
284
+ if (plan.maskCutoff !== null && plan.hasAlpha) {
285
+ // the coverage-preserving chain (the header): decoded at the source's size, level 0 = the capped size
286
+ const raw = await sharp(Buffer.from(plan.tex.getImage()!)).toColourspace('srgb').ensureAlpha().raw().toBuffer({ resolveWithObject: true })
287
+ const chain = coverageMipChain(new Uint8Array(raw.data.buffer, raw.data.byteOffset, raw.data.byteLength), raw.info.width, raw.info.height,
288
+ { cutoff: plan.maskCutoff, width: tw, height: th, srgb: plan.oetf === 'srgb' })
289
+ levels = []
290
+ for (const [ i, l ] of chain.levels.entries()) {
291
+ const f = join(dir, `level${i}.png`)
292
+ writeFileSync(f, await sharp(Buffer.from(l.data.buffer, l.data.byteOffset, l.data.byteLength), { raw: { width: l.width, height: l.height, channels: 4 } }).png().toBuffer())
293
+ levels.push(f)
294
+ }
295
+ } else if (plan.impostorData) {
296
+ // the data map of an impostor: its normals' mips by vector (impostor.ts impostorDataMipChain); never resized
297
+ const raw = await sharp(Buffer.from(plan.tex.getImage()!)).ensureAlpha().raw().toBuffer({ resolveWithObject: true })
298
+ const chain = impostorDataMipChain(new Uint8Array(raw.data.buffer, raw.data.byteOffset, raw.data.byteLength), raw.info.width, raw.info.height)
299
+ levels = []
300
+ for (const [ i, l ] of chain.entries()) {
301
+ const f = join(dir, `level${i}.png`)
302
+ writeFileSync(f, await sharp(Buffer.from(l.data.buffer, l.data.byteOffset, l.data.byteLength), { raw: { width: l.width, height: l.height, channels: 4 } }).png().toBuffer())
303
+ levels.push(f)
304
+ }
305
+ } else {
306
+ let pipe = sharp(Buffer.from(plan.tex.getImage()!))
307
+ if (tw !== plan.width || th !== plan.height) pipe = pipe.resize({ width: tw, height: th, fit: 'fill' })
308
+ // Always RGB / RGBA (a grey or palette source too): `ktx create` declares the channel count up front.
309
+ pipe = pipe.toColourspace('srgb')
310
+ pipe = plan.hasAlpha ? pipe.ensureAlpha() : pipe.removeAlpha() // RGB → no wasted alpha slice
311
+ writeFileSync(inPng, await pipe.png().toBuffer())
312
+ }
266
313
 
267
314
  const args = ktxEncodeArgs(enc, {
268
315
  input: inPng, output: outKtx, codec: plan.codec, oetf: plan.oetf,
269
- channels: plan.hasAlpha ? 4 : 3, uastcQuality: 2, zstd: 18,
316
+ channels: plan.hasAlpha ? 4 : 3, uastcQuality: 2, zstd: 18, levels,
270
317
  })
271
318
 
272
319
  await acquire()
@@ -11,6 +11,7 @@
11
11
  // - ktx2 -> left untouched (supported; sharp can't handle it)
12
12
  import sharp from 'sharp'
13
13
  import type { Root, Texture } from '@gltf-transform/core'
14
+ import { imageInfo } from './imageFacts'
14
15
 
15
16
  export const DEFAULT_MAX_SIZE = 2048
16
17
  const HEAVY_PNG_MIN_DIM = 1024
@@ -58,15 +59,10 @@ export async function planTexture(tex: Texture, root: Root, maxSize = DEFAULT_MA
58
59
  if (fromFmt.includes('ktx')) return null
59
60
 
60
61
  const { roles, isNormal, isData } = getTextureRoles(tex, root)
61
- let width = 0, height = 0, hasAlpha = false
62
- try {
63
- const buf = Buffer.from(img)
64
- const meta = await sharp(buf).metadata()
65
- width = meta.width || 0; height = meta.height || 0
66
- hasAlpha = !(await sharp(buf).stats()).isOpaque
67
- } catch {
68
- return null // undecodable by sharp — leave it
69
- }
62
+ // once per image object (transcodeKtx2.ts imageInfo): a level import shares one map's bytes between its kinds
63
+ const info = await imageInfo(img)
64
+ if (!info) return null // undecodable by sharp — leave it
65
+ const { width, height, hasAlpha } = info
70
66
 
71
67
  const resize = !isImpostorAtlas(tex) && Math.max(width, height) > maxSize
72
68
  let desired: 'jpeg' | 'png' = forceFmt ?? (hasAlpha ? 'png' : 'jpeg') // PNG only when alpha is needed
@@ -0,0 +1,38 @@
1
+ // A promise cache bounded by the BYTES its values hold, least-recently-used out first.
2
+ //
3
+ // The values are promises so two callers asking for the same key while it is being made share the
4
+ // one piece of work; a value's size is counted once it resolves (a rejection or a null weighs 0 and
5
+ // stays cached — the same input fails the same way). An entry larger than the whole budget is still
6
+ // handed to the caller that made it, it just does not stay.
7
+
8
+ export class ByteLru<V> {
9
+ private entries = new Map<string, { value: Promise<V>, bytes: number }>()
10
+ private total = 0
11
+ constructor(readonly budget: number, private readonly sizeOf: (v: V) => number) {}
12
+
13
+ get bytes(): number { return this.total }
14
+ get size(): number { return this.entries.size }
15
+
16
+ /** The cached value of `key`, or `make()`'s, cached. */
17
+ get(key: string, make: () => Promise<V>): Promise<V> {
18
+ const hit = this.entries.get(key)
19
+ if (hit) { this.entries.delete(key); this.entries.set(key, hit); return hit.value }
20
+ const entry = { value: make(), bytes: 0 }
21
+ this.entries.set(key, entry)
22
+ entry.value.then((v) => {
23
+ if (this.entries.get(key) !== entry) return
24
+ entry.bytes = v ? this.sizeOf(v) : 0
25
+ this.total += entry.bytes
26
+ this.evict()
27
+ }, () => {})
28
+ return entry.value
29
+ }
30
+
31
+ private evict(): void {
32
+ for (const [ k, e ] of this.entries) {
33
+ if (this.total <= this.budget) break
34
+ this.entries.delete(k)
35
+ this.total -= e.bytes
36
+ }
37
+ }
38
+ }
@@ -4,7 +4,8 @@
4
4
 
5
5
  import { existsSync, readFileSync } from "node:fs"
6
6
  import { basename, dirname, join, isAbsolute } from "node:path"
7
- import { deflateSync } from "node:zlib"
7
+ import * as zlib from "node:zlib"
8
+ import { deflate, deflateSync } from "node:zlib"
8
9
  import { createHash } from "node:crypto"
9
10
  import type { FbxTexture } from "../fbx/read"
10
11
 
@@ -99,6 +100,16 @@ export const decodeTga = (b: Uint8Array): Rgba | null => {
99
100
  if (cmapType === 1) { cmap = b.subarray(p, p + cmapLen * cmapBytes); p += cmapLen * cmapBytes }
100
101
  const bytesPP = bpp >> 3
101
102
  const out = new Uint8Array(width * height * 4)
103
+ // uncompressed 24 / 32-bit truecolour — 435 of a pack's 445 maps (Chernobyl) — row by row, no per-texel closure
104
+ // (a 4096² map: 67 ms the general way)
105
+ if (imgType === 2 && (bytesPP === 3 || bytesPP === 4) && p + width * height * bytesPP <= b.length) {
106
+ for (let y = 0; y < height; y++) {
107
+ let s = p + y * width * bytesPP, o = (topDown ? y : height - 1 - y) * width * 4
108
+ if (bytesPP === 4) for (let x = 0; x < width; x++, s += 4, o += 4) { out[o] = b[s + 2]; out[o + 1] = b[s + 1]; out[o + 2] = b[s]; out[o + 3] = b[s + 3] }
109
+ else for (let x = 0; x < width; x++, s += 3, o += 4) { out[o] = b[s + 2]; out[o + 1] = b[s + 1]; out[o + 2] = b[s]; out[o + 3] = 255 }
110
+ }
111
+ return { width, height, data: out }
112
+ }
102
113
  const px = new Uint8Array(4)
103
114
  const readPixel = (at: number): void => {
104
115
  if (kind === 3) { const g = b[at]; px[0] = g; px[1] = g; px[2] = g; px[3] = bytesPP > 1 ? b[at + 1] : 255; return }
@@ -169,7 +180,10 @@ const CRC_TABLE = (() => {
169
180
  for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0 }
170
181
  return t
171
182
  })()
183
+ // zlib's own where the runtime has it (Node ≥ 22.2, Bun): the JS loop is ~25 ms over a 15 MB IDAT
184
+ const zlibCrc32 = (zlib as { crc32?: (data: Uint8Array, value?: number) => number }).crc32
172
185
  const crc32 = (parts: Uint8Array[]): number => {
186
+ if (zlibCrc32) { let c = 0; for (const p of parts) c = zlibCrc32(p, c); return c >>> 0 }
173
187
  let c = 0xffffffff
174
188
  for (const p of parts) for (let i = 0; i < p.length; i++) c = CRC_TABLE[(c ^ p[i]) & 0xff] ^ (c >>> 8)
175
189
  return (c ^ 0xffffffff) >>> 0
@@ -184,15 +198,17 @@ const chunk = (type: string, data: Uint8Array): Uint8Array => {
184
198
  return out
185
199
  }
186
200
 
187
- export const encodePng = (img: Rgba): Uint8Array => {
201
+ const pngFilterRows = (img: Rgba): Uint8Array => {
188
202
  const { width, height, data } = img
189
203
  const raw = new Uint8Array((width * 4 + 1) * height)
190
204
  for (let y = 0; y < height; y++) { raw[y * (width * 4 + 1)] = 0; raw.set(data.subarray(y * width * 4, (y + 1) * width * 4), y * (width * 4 + 1) + 1) }
205
+ return raw
206
+ }
207
+ const pngFile = (img: Rgba, idat: Uint8Array): Uint8Array => {
191
208
  const ihdr = new Uint8Array(13)
192
209
  const dv = new DataView(ihdr.buffer)
193
- dv.setUint32(0, width); dv.setUint32(4, height)
210
+ dv.setUint32(0, img.width); dv.setUint32(4, img.height)
194
211
  ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0
195
- const idat = new Uint8Array(deflateSync(raw, { level: 6 }))
196
212
  const sig = new Uint8Array([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a ])
197
213
  const parts = [ sig, chunk("IHDR", ihdr), chunk("IDAT", idat), chunk("IEND", new Uint8Array(0)) ]
198
214
  const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0))
@@ -200,3 +216,13 @@ export const encodePng = (img: Rgba): Uint8Array => {
200
216
  for (const p of parts) { out.set(p, o); o += p.length }
201
217
  return out
202
218
  }
219
+
220
+ export const encodePng = (img: Rgba): Uint8Array => pngFile(img, new Uint8Array(deflateSync(pngFilterRows(img), { level: 6 })))
221
+
222
+ /** `encodePng`, byte for byte, with the deflate on libuv's thread pool: it is ~90 % of the encode (a 4096² RGBA
223
+ * map: 1.05 s of deflate on the main thread, 2026-11-04), so the textures of one kind can encode side by side. */
224
+ export const encodePngAsync = async (img: Rgba): Promise<Uint8Array> => {
225
+ const raw = pngFilterRows(img)
226
+ const idat = await new Promise<Uint8Array>((res, rej) => deflate(raw, { level: 6 }, (e, b) => (e ? rej(e) : res(new Uint8Array(b.buffer, b.byteOffset, b.byteLength)))))
227
+ return pngFile(img, idat)
228
+ }
@@ -15,24 +15,35 @@ export const decodeImage = async (bytes: Uint8Array, hint: string): Promise<Rgba
15
15
  } catch { return null }
16
16
  }
17
17
 
18
- /** Nearest-neighbour sample of one channel at (u, v) in 0..1. */
19
- const sampleCh = (img: Rgba, ch: number, x: number, y: number, w: number, h: number): number => {
20
- const sx = Math.min(img.width - 1, Math.floor((x + 0.5) * img.width / w))
21
- const sy = Math.min(img.height - 1, Math.floor((y + 0.5) * img.height / h))
22
- return img.data[(sy * img.width + sx) * 4 + ch]
23
- }
24
-
25
18
  export type PackInput = { img: Rgba, channel: number, invert?: boolean }
26
- /** Pack occlusion / roughness / metallic sources into one RGB image (glTF layout). Missing = constant. */
19
+ /** Pack occlusion / roughness / metallic sources into one RGB image (glTF layout). Missing = constant.
20
+ * Nearest-neighbour onto the largest source's size; each source's column / row lookup is tabulated once (a
21
+ * closure + two divisions per channel per texel was 1.6 s of the 30 maps of Chernobyl's Department_Store). */
27
22
  export const packOrm = (ao: PackInput | null, rough: PackInput | null, metal: PackInput | null, defaults = { ao: 255, rough: 255, metal: 0 }): Rgba => {
28
23
  const srcs = [ ao, rough, metal ].filter((s): s is PackInput => !!s)
29
24
  const w = Math.max(1, ...srcs.map((s) => s.img.width)), h = Math.max(1, ...srcs.map((s) => s.img.height))
30
25
  const out = new Uint8Array(w * h * 4)
31
- for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
32
- const d = (y * w + x) * 4
33
- const get = (s: PackInput | null, dflt: number): number => { if (!s) return dflt; const v = sampleCh(s.img, s.channel, x, y, w, h); return s.invert ? 255 - v : v }
34
- out[d] = get(ao, defaults.ao); out[d + 1] = get(rough, defaults.rough); out[d + 2] = get(metal, defaults.metal); out[d + 3] = 255
26
+ // sample index of (x, y) in a source = row[y] + col[x] (+ channel): floor((x + 0.5) · W / w), clamped
27
+ const lookup = (n: number, size: number, scale: number): Int32Array => {
28
+ const t = new Int32Array(n)
29
+ for (let i = 0; i < n; i++) t[i] = Math.min(size - 1, Math.floor((i + 0.5) * size / n)) * scale
30
+ return t
31
+ }
32
+ const tables = new Map<Rgba, { col: Int32Array, row: Int32Array }>()
33
+ const fill = (s: PackInput | null, dflt: number, o: number): void => {
34
+ if (!s) { for (let d = o; d < out.length; d += 4) out[d] = dflt; return }
35
+ let t = tables.get(s.img)
36
+ if (!t) { t = { col: lookup(w, s.img.width, 4), row: lookup(h, s.img.height, s.img.width * 4) }; tables.set(s.img, t) }
37
+ const { col, row } = t, src = s.img.data, ch = s.channel
38
+ let d = o
39
+ for (let y = 0; y < h; y++) {
40
+ const r = row[y] + ch
41
+ if (s.invert) for (let x = 0; x < w; x++, d += 4) out[d] = 255 - src[r + col[x]]
42
+ else for (let x = 0; x < w; x++, d += 4) out[d] = src[r + col[x]]
43
+ }
35
44
  }
45
+ fill(ao, defaults.ao, 0); fill(rough, defaults.rough, 1); fill(metal, defaults.metal, 2)
46
+ for (let d = 3; d < out.length; d += 4) out[d] = 255
36
47
  return { width: w, height: h, data: out }
37
48
  }
38
49
 
@@ -10,19 +10,19 @@
10
10
  // expects; `globalScale` is the only factor left.
11
11
 
12
12
  import { basename, dirname } from "node:path"
13
- import { readFileSync } from "node:fs"
13
+ import { existsSync, readFileSync } from "node:fs"
14
14
  import { Document, type Material, type Mesh, type Node } from "@gltf-transform/core"
15
15
  import { mergeDocuments, prune } from "@gltf-transform/functions"
16
16
  import { readFbx } from "../fbx/read"
17
17
  import { toDocument } from "../fbx/toDocument"
18
18
  import { quiet } from "../props"
19
- import { readFbxMeta, type FbxMeta } from "./fbxModel"
19
+ import { MODEL_EXT, readFbxMeta, type FbxMeta } from "./fbxModel"
20
20
  import { readUnityMeshAsset } from "./unityMesh"
21
21
  import type { GuidResolver } from "./guid"
22
22
  import { I4, mmul, scaleMatrix, type Mat4 } from "./math"
23
23
  import { collectUnityMaterials, materialKey, parseUnityMaterial, windProfile, type UnityMaterial } from "./matFile"
24
24
  import { pairSlots } from "./materialSlots"
25
- import { unityMaterialWriter } from "./materialWriter"
25
+ import { SharedTextures, unityMaterialWriter } from "./materialWriter"
26
26
  import { projectTriplanar, triplanarOf } from "./triplanar"
27
27
  import type { KindPart, SceneKind } from "./scene"
28
28
 
@@ -103,6 +103,8 @@ export class KindGlbBuilder {
103
103
  private fbx = new Map<string, Promise<FbxSource | null>>()
104
104
  private mats = new Map<string, UnityMaterial | null>()
105
105
  private byKey: Map<string, UnityMaterial> | null = null
106
+ /** the texture bytes of the whole import: every kind's document takes its maps from here (materialWriter.ts) */
107
+ readonly textures = new SharedTextures()
106
108
  constructor(readonly ctx: KindGlbContext) {}
107
109
 
108
110
  private source(guid: string): Promise<FbxSource | null> {
@@ -137,8 +139,27 @@ export class KindGlbBuilder {
137
139
  return p
138
140
  }
139
141
 
142
+ /** A slot holding a material EMBEDDED in the model file (`{fileID: <id>, guid: <the FBX>, type: 3}`): the FBX's own
143
+ * material, which the `.meta` neither remaps nor names (`internalIDToNameTable: []`). Chernobyl's Bus_Stop does it on
144
+ * LOD1 alone — LOD0 names `Bus_Stop.mat`, LOD1 leaves that slot on the FBX's `Bus_Stop` — and read as a `.mat` the
145
+ * ref is nothing, so the far level kept the FBX's untextured grey and its UVs were pruned away (2026-11-04). */
146
+ private embedded(ref: string): boolean {
147
+ return !ref.startsWith("name:") && MODEL_EXT.test(this.ctx.resolver.resolve(ref) ?? "")
148
+ }
149
+
150
+ /** `name:<fbx material>` as the `.mat`'s GUID when the pack has one — the ref its other slots use, so the kind
151
+ * writes ONE material (and one set of maps) where LOD0 names it by GUID and LOD1 by the embedded slot. */
152
+ private byNameRef(fbxName: string): string {
153
+ const um = this.material(`name:${fbxName}`)
154
+ const meta = um ? `${um.file}.meta` : ""
155
+ const guid = meta && existsSync(meta) ? /^guid:\s*([0-9a-f]{32})/m.exec(readFileSync(meta, "utf8"))?.[1] : undefined
156
+ return guid && this.ctx.resolver.resolve(guid) ? guid : `name:${fbxName}`
157
+ }
158
+
140
159
  private material(ref: string): UnityMaterial | null {
141
160
  if (this.mats.has(ref)) return this.mats.get(ref)!
161
+ // resolved per primitive, by its FBX material's name (assignMaterials)
162
+ if (this.embedded(ref)) return null
142
163
  let um: UnityMaterial | null = null
143
164
  if (ref.startsWith("name:")) {
144
165
  this.byKey ??= collectUnityMaterials(this.ctx.materialDirs, 8).byKey
@@ -201,10 +222,20 @@ export class KindGlbBuilder {
201
222
  const prims = mesh.listPrimitives()
202
223
  const byGuid = new Map<string, string>()
203
224
  for (const [ n, g ] of src.meta.materials) if (!byGuid.has(g)) byGuid.set(g, n)
204
- const slotName = part.materials.map((r) => (r.startsWith("name:") ? r.slice(5) : byGuid.get(r) ?? this.material(r)?.name ?? null))
205
- const slotFor = pairSlots(prims.map((pr) => pr.getMaterial()?.getName() ?? ""), slotName)
225
+ // an embedded slot has no name of its own: it pairs with what is left over, then takes the pack's `.mat` named like
226
+ // that primitive's FBX material (what Unity's `materialSearch` would find; the FBX's own stays when there is none)
227
+ const nameOf = (r: string): string | null => (r.startsWith("name:") ? r.slice(5) : this.embedded(r) ? null : byGuid.get(r) ?? this.material(r)?.name ?? null)
228
+ // AN OVERRIDDEN SLOT IS KNOWN BY WHAT IT HELD IN THE SOURCE PREFAB. Its new material names nothing in the FBX, and
229
+ // pairing it by what was left over, in order, swapped the faces: Chernobyl's Pharmacy turns every `Wallpaper_01` slot
230
+ // into `Wall_Paint_01` and every `Wall_Tiiling_01` into `Wall_Tiiling_04`, and Front_Door_01's wall came out with the
231
+ // paint on the tiled face at LOD0/LOD1 while LOD2 (its `Wallpaper_01` slot left alone, so paired by name) was right —
232
+ // the material changed with the level (2026-11-04). The source slot is a name the FBX knows; the override is the material.
233
+ const slotName = part.materials.map((r, k) => nameOf(part.sourceMaterials?.[k] || r) ?? (part.sourceMaterials?.[k] ? nameOf(r) : null))
234
+ const fbxNames = prims.map((pr) => pr.getMaterial()?.getName() ?? "")
235
+ const slotFor = pairSlots(fbxNames, slotName)
206
236
  for (let i = 0; i < prims.length; i++) {
207
- const ref = part.materials[slotFor[i] >= 0 ? slotFor[i] : Math.min(i, part.materials.length - 1)]
237
+ let ref = part.materials[slotFor[i] >= 0 ? slotFor[i] : Math.min(i, part.materials.length - 1)]
238
+ if (ref && this.embedded(ref)) ref = fbxNames[i] ? this.byNameRef(fbxNames[i]) : ""
208
239
  if (!ref) continue
209
240
  const um = this.material(ref)
210
241
  // THE DETAIL LAYER ON ANOTHER UV SET (NatureManufacture's bark shader): albedo / normal / ORM are
@@ -317,7 +348,16 @@ export class KindGlbBuilder {
317
348
  const scene = doc.createScene(kind.name)
318
349
  const root = doc.createNode(kind.name)
319
350
  scene.addChild(root)
320
- const out: KindDoc = { doc, writer: unityMaterialWriter(doc, this.ctx.resolver), merged: new Map(), imported: new Set(), materials: new Map(), meshes: new Map() }
351
+ const out: KindDoc = { doc, writer: unityMaterialWriter(doc, this.ctx.resolver, undefined, this.textures), merged: new Map(), imported: new Set(), materials: new Map(), meshes: new Map() }
352
+ // EVERY MAP OF THE KIND STARTS PREPARING AT ONCE (materialWriter.ts `prefetch`): the parts below take them in
353
+ // their order, but the decodes / deflates overlap — a kind's first-seen maps one after another were its build time
354
+ // (Department_Store, 27 new maps: 8.2 s of PNG deflate on the main thread, 2026-11-04)
355
+ for (const part of kind.parts) {
356
+ for (const ref of part.materials) {
357
+ const um = ref && !this.embedded(ref) ? this.material(ref) : null
358
+ if (um) void this.detailLayer(um).then((d) => out.writer.prefetch(d ? d.um : um)).catch(() => {}) // a hint: a failure shows where the part reads it
359
+ }
360
+ }
321
361
  const missing: string[] = []
322
362
  let parts = 0
323
363
  for (const part of kind.parts) {
@@ -70,7 +70,7 @@ export type WriteLevelOptions = FoliageOptions & {
70
70
  * camera-facing quad over an atlas of views — `cols` azimuths × `rows` elevation rows, or the N×N hemi-octahedron
71
71
  * of N = `cols` when `rows` is 0 — `px` pixels a frame (default 256 for a plant ≥ 8 m, 128 under), at LOD slot
72
72
  * `level` (default 2: the engine shows it under 0.12 of the viewport height; 1 = under 0.30, the pack's own LOD1 goes). */
73
- impostor?: { cols: number, rows: number, px?: number, level?: number }
73
+ impostor?: { cols: number, rows: number, px?: number, level?: number, samples?: number }
74
74
  /** The command that produced this import, written into the scene file's header. A re-import that
75
75
  * forgets a flag silently undoes a decision — the dropped kinds come back, the lamps go into the
76
76
  * atlas again — and nothing in the project records it, so the line IS the record. */
@@ -105,7 +105,15 @@ export type WriteLevelOptions = FoliageOptions & {
105
105
  // 18 (2026-11-03): `--impostor <A>x<E>[@px]`: azimuths × elevation rows (default 12x3), `oct<N>` keeps the octahedron; the atlases skip --max-size
106
106
  // 19 (2026-11-03): the impostor's albedo atlas is UASTC like its data map (ETC1S shifted the far crown's hue)
107
107
  // 20 (2026-11-03): the impostor's data map = octahedral hull normal (rg, object space) + depth (b) + AO (a): the quad self-shadows from its own atlas
108
- const BUILDER_VERSION = 20
108
+ // 21 (2026-11-04): the impostor bakes S×S samples a texel (`--impostor-samples`, default 8: fractional coverage the engine's
109
+ // mask cuts) and a third map, the BARK fraction (occlusionTexture r), with the bark's own mesh normal in the data map
110
+ // 22 (2026-11-04): under `--impostor` the pack's `Billboard_*` level goes for every vegetation kind, the ones under 4 m too
111
+ // (the quad is the billboard; drawn by the cards' shader the pack's cross was a flat pale far tree)
112
+ // 23 (2026-11-04): an alpha-MASK base colour takes COVERAGE-PRESERVING mips and downscale (doctor/coverageMips.ts)
113
+ // 24 (2026-11-04): the triangle budget is PER LOD LEVEL (a chain's levels under it stay whole), and a slot holding the FBX's
114
+ // EMBEDDED material takes the pack's `.mat` of that name (Bus_Stop LOD1, Ladder_03 were untextured grey)
115
+ // and an OVERRIDDEN material slot pairs with its primitive by what it held in the source prefab (Pharmacy's walls had swapped faces)
116
+ const BUILDER_VERSION = 26
109
117
  const v3 = (v: number[]): string => `[${v.map(r3).join(", ")}]`
110
118
  const ident = (s: string): string => { const x = s.replace(/[^\w$]/g, "_"); return /^\d/.test(x) ? `_${x}` : x }
111
119
 
@@ -228,8 +236,17 @@ const buildKindGlbs = async (kinds: SceneKind[], builder: KindGlbBuilder, assetD
228
236
  // cards trimmed to their opaque texels BEFORE the KTX2 encode (doctor/cardTrim.ts): a foliage pack's
229
237
  // quads are mostly transparent, and that discarded fill was the frame on Twig & Twine's map
230
238
  const doctor = { ktx2: opts.ktx2 !== false, maxSize: opts.maxSize ?? 1024, fixEmissive: true, trimCards: !opts.noTrimCards }
239
+ // `LECODES_IMPORT_PROFILE=1`: a stage stopwatch per kind and a total at the end — where a 40-minute import spends it
240
+ const profile = !!process.env.LECODES_IMPORT_PROFILE
241
+ const { ktx2CacheHits } = await import("../doctor/transcodeKtx2")
242
+ const total: Record<string, number> = {}
243
+ let stageT = 0
244
+ const laps: string[] = []
245
+ const lap = (stage: string): void => { const n = performance.now(); total[stage] = (total[stage] ?? 0) + n - stageT; laps.push(`${stage} ${Math.round(n - stageT)}`); stageT = n }
231
246
  let converted = 0, reused = 0
232
247
  for (const [ i, k ] of kinds.entries()) {
248
+ stageT = performance.now(); laps.length = 0
249
+ const hits0 = ktx2CacheHits()
233
250
  const glb = join(assetDir, `${k.name}.glb`)
234
251
  const maxTris = opts.maxTris ?? DEFAULT_MAX_TRIS
235
252
  // VERTEX COLOURS ARE NOT COLOURS in a Unity pack: the Standard / HDRP Lit shaders never read them, and
@@ -238,9 +255,10 @@ const buildKindGlbs = async (kinds: SceneKind[], builder: KindGlbBuilder, assetD
238
255
  // painted every broken branch pure red (2026-09-23). Non-foliage kinds lose COLOR_0; the vegetation
239
256
  // tier keeps it — its shader ignores the tint, and the alpha IS the wind weight it will read.
240
257
  const foliage = decideFoliage(k, builder.materialsOf(k), opts)
241
- const hash = createHash("sha1").update(JSON.stringify([ BUILDER_VERSION, k.key, opts.maxSize, opts.ktx2, maxTris, ...(foliage ? [] : [ "nocolor" ]), ...(foliage && opts.impostor ? [ "impostor", opts.impostor.cols, opts.impostor.rows, opts.impostor.px ?? 0, opts.impostor.level ?? 2 ] : []) ])).digest("hex").slice(0, 16)
258
+ const hash = createHash("sha1").update(JSON.stringify([ BUILDER_VERSION, k.key, opts.maxSize, opts.ktx2, maxTris, ...(foliage ? [] : [ "nocolor" ]), ...(foliage && opts.impostor ? [ "impostor", opts.impostor.cols, opts.impostor.rows, opts.impostor.px ?? 0, opts.impostor.level ?? 2, opts.impostor.samples ?? 0 ] : []) ])).digest("hex").slice(0, 16)
242
259
  if (!opts.force && existsSync(glb) && manifest[k.name] === hash) { reused++; continue }
243
260
  const { doc, parts, missing } = await builder.build(k)
261
+ lap("build")
244
262
  if (!foliage) for (const m of doc.getRoot().listMeshes()) for (const p of m.listPrimitives()) { const c = p.getAttribute("COLOR_0"); if (c) { p.setAttribute("COLOR_0", null); if (c.listParents().length <= 1) c.dispose() } }
245
263
  // and the vegetation tier gets its VERTEX AO: the sky's visibility from every vertex baked into COLOR_0.r
246
264
  // (doctor/vertexAo.ts), the wind weight kept in the alpha — the one thing that makes a crown read as a volume
@@ -254,12 +272,18 @@ const buildKindGlbs = async (kinds: SceneKind[], builder: KindGlbBuilder, assetD
254
272
  }
255
273
  for (const m of missing) log(` ⚠ ${k.name}: ${m}`)
256
274
  if (!parts) { log(` ⚠ ${k.name}: no mesh could be built — skipped`); continue }
275
+ lap("ao")
257
276
  const cut = await fitTriangleBudget(doc, maxTris)
258
- if (cut) log(` ✂ ${k.name}: ${cut.before.toLocaleString("en")} → ${cut.after.toLocaleString("en")} triangles (--max-tris ${maxTris})`)
277
+ if (cut) log(` ✂ ${k.name}: ${cut.before.toLocaleString("en")} → ${cut.after.toLocaleString("en")} triangles on its heaviest level (--max-tris ${maxTris})`)
278
+ lap("budget")
259
279
  // a TREE's far level as one quad over an atlas of views (doctor/impostor.ts) — before the doctor: the bake reads the
260
280
  // PNG / JPEG base colours the KTX2 encode takes away, and the atlases go through that encode like any map
261
281
  if (foliage && opts.impostor) {
262
- const { bakeImpostor, impostorBounds, impostorLabel } = await import("../doctor/impostor")
282
+ const { bakeImpostor, dropBillboards, impostorBounds, impostorLabel } = await import("../doctor/impostor")
283
+ // the pack's own billboard cross goes for EVERY vegetation kind (the quad is the billboard; a small kind ends at
284
+ // its last mesh level) — drawn by the cards' shader it was a flat pale cross on every far Chernobyl tree
285
+ const billboards = await dropBillboards(doc)
286
+ if (billboards) log(` ✂ ${k.name}: the pack's billboard level dropped (${billboards} node${billboards > 1 ? "s" : ""})`)
263
287
  const box = impostorBounds(doc)
264
288
  const height = box ? box.max[1] - box.min[1] : 0
265
289
  if (height >= 4) {
@@ -268,13 +292,20 @@ const buildKindGlbs = async (kinds: SceneKind[], builder: KindGlbBuilder, assetD
268
292
  if (imp) log(` 🌲 ${k.name}: impostor ${impostorLabel(imp)} · ${imp.node} · r ${imp.radius.toFixed(2)} m · ${imp.triangles} triangles in ${imp.ms} ms`)
269
293
  }
270
294
  }
271
- await applyPlan(doc, await analyzeDoc(doc, doctor), doctor)
295
+ lap("impostor")
296
+ const plan = await analyzeDoc(doc, doctor)
297
+ lap("analyze")
298
+ const applied = await applyPlan(doc, plan, doctor)
299
+ lap("doctor")
272
300
  writeFileSync(glb, await makeIO().writeBinary(doc))
301
+ lap("write")
273
302
  manifest[k.name] = hash
274
303
  converted++
275
304
  log(` [${i + 1}/${kinds.length}] ${k.name}.glb · ${parts} part(s) · ${Math.round(readFileSync(glb).length / 1024)} KB`)
305
+ if (profile) log(` ⏱ ${k.name}: ${laps.join(" · ")} ms · ktx2 ${applied.ktx2Encoded} tex (${ktx2CacheHits() - hits0} cached) · ${doc.getRoot().listTextures().length} textures`)
276
306
  writeFileSync(manifestPath, JSON.stringify(manifest, null, 1) + "\n")
277
307
  }
308
+ if (profile && converted) log(` ⏱ total over ${converted} kinds: ${Object.entries(total).map(([ s, ms ]) => `${s} ${(ms / 1000).toFixed(1)} s`).join(" · ")} · texture bytes cached ${builder.textures.images.size} images, ${Math.round(builder.textures.images.bytes / 1048576)} MB`)
278
309
  return { converted, reused }
279
310
  }
280
311