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.
- 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
|
@@ -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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
? `${
|
|
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
|
|
261
|
-
if (
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/unity/image.ts
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
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
|
|
package/src/unity/kindGlb.ts
CHANGED
|
@@ -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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|