lecodes-assets 2.5.1 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +3970 -3400
- package/dist/index.js +9126 -8556
- package/package.json +1 -1
- package/src/commands/doctor.ts +2 -1
- package/src/commands/scene.ts +2 -1
- package/src/commands/shared.ts +3 -2
- package/src/doctor/cardTrim.ts +19 -13
- package/src/doctor/coverageMips.ts +132 -0
- package/src/doctor/doctor.ts +8 -1
- package/src/doctor/imageFacts.ts +44 -0
- package/src/doctor/impostor.ts +199 -54
- package/src/doctor/transcodeKtx2.ts +71 -24
- package/src/doctor/transcodeTextures.ts +5 -9
- package/src/textures/byteLru.ts +38 -0
- package/src/textures/resolveImage.ts +30 -4
- package/src/unity/image.ts +23 -12
- package/src/unity/kindGlb.ts +47 -7
- package/src/unity/lecodesLevel.ts +37 -6
- package/src/unity/materialWriter.ts +182 -96
- package/src/unity/prefabTree.ts +3 -0
- package/src/unity/scene.ts +6 -2
- package/src/unity/triBudget.ts +41 -9
package/package.json
CHANGED
package/src/commands/doctor.ts
CHANGED
|
@@ -17,6 +17,7 @@ export default defineCommand({
|
|
|
17
17
|
"lod-bones": bool("fold fingers / toes / twists onto their parents in the last level's skin"),
|
|
18
18
|
impostor: str("bake the level-0 geometry into an impostor atlas (albedo + hull normal / AO) and add it as a LOD level, one camera-facing quad (<name>_LOD<k>, k = --impostor-level, never past the first missing level; the levels from k up are replaced). PNG / JPEG base colours only — run it with --ktx2, not after it; the atlases skip --max-size. <A>x<E>[@<px>] = A azimuths × E elevation rows (0°, 90°/E, …; 8x2@256), oct<N>[@<px>] = the N×N hemi-octahedron (steep views too); bare = 12x3; px = a frame's pixels (default 256 for a plant ≥ 8 m, 128 under)", { value: "<A>x<E>[@px]|oct<N>[@px]", bare: "12x3" }),
|
|
19
19
|
"impostor-level": int("the LOD slot the quad takes (default 2: shown under 0.12 of the viewport height; 1 = under 0.30)"),
|
|
20
|
+
"impostor-samples": int("the impostor bake's supersamples a texel's side, S: S×S samples (default 8: 5.3 s for a 16 m pine against 2.1 s at 4) - the coverage is covered / S², a porous crown's holes kept as a fraction the engine's mask cuts (alphaToCoverage takes it under MSAA)"),
|
|
20
21
|
},
|
|
21
22
|
examples: [
|
|
22
23
|
"lecodes assets doctor hero.glb",
|
|
@@ -31,7 +32,7 @@ export default defineCommand({
|
|
|
31
32
|
const { validateGlb, formatReport } = await import("../doctor/validateGlb")
|
|
32
33
|
const useKtx2 = wantsKtx2(flags)
|
|
33
34
|
const lod = (flags.lod ?? "").split(",").map(Number).filter((n) => n > 0 && n < 1)
|
|
34
|
-
const impostor = impostorFlag(flags.impostor, flags["impostor-level"])
|
|
35
|
+
const impostor = impostorFlag(flags.impostor, flags["impostor-level"], flags["impostor-samples"])
|
|
35
36
|
const doFix = wantsDoctorFix(flags) || flags["fix-metallic"] || lod.length > 0 || !!impostor
|
|
36
37
|
const opts = { ...doctorOptions(flags), lod, lodBones: flags["lod-bones"] }
|
|
37
38
|
const ktxEncoder = useKtx2 ? findKtxEncoder() : null
|
package/src/commands/scene.ts
CHANGED
|
@@ -50,6 +50,7 @@ export default defineCommand({
|
|
|
50
50
|
"max-tris": int("triangles a kind may have before the import simplifies it (default 50000, 0 = no cap): a photogrammetry scan otherwise arrives as a million-triangle prop AND mesh collider"),
|
|
51
51
|
impostor: str("foliage kinds ≥ 4 m tall get their far LOD level as ONE camera-facing quad over an atlas of views (albedo + hull normal / AO), replacing the pack's billboard; the atlases skip --max-size. <A>x<E>[@<px>] = A azimuths × E elevation rows (0°, 90°/E, …; 8x2@256), oct<N>[@<px>] = the N×N hemi-octahedron (steep views too); bare = 12x3; px = a frame's pixels (default 256 for a plant ≥ 8 m, 128 under)", { value: "<A>x<E>[@px]|oct<N>[@px]", bare: "12x3" }),
|
|
52
52
|
"impostor-level": int("the LOD slot the quad takes (default 2: shown under 0.12 of the viewport height; 1 = under 0.30, the pack's LOD1 goes; never past the first missing level)"),
|
|
53
|
+
"impostor-samples": int("the impostor bake's supersamples a texel's side, S: S×S samples (default 8: 5.3 s for a 16 m pine against 2.1 s at 4) - the coverage is covered / S², a porous crown's holes kept as a fraction the engine's mask cuts (alphaToCoverage takes it under MSAA)"),
|
|
53
54
|
"no-trim-cards": bool("skip the alpha-card trim the import otherwise runs (it can empty a kind whose decal atlas is mostly clear)"),
|
|
54
55
|
filter: str("keep only instances whose kind name / scene group / node name matches", { value: "<re>", group: "Subset" }),
|
|
55
56
|
"drop-at": str(`leave OUT the placements standing at these points ("x,y,z;x,y,z", the lecodes frame the scene file prints, within 10 cm): the one instance of a kind the game moves while its twins stay put — the range's five lane targets, which Unity names like the sixth on a shelf`, { value: "<x,y,z;…>" }),
|
|
@@ -89,7 +90,7 @@ export default defineCommand({
|
|
|
89
90
|
project: flags.project, name: flags.name, force: flags.force, maxSize: flags["max-size"] ?? 1024, ktx2: !flags.png, log,
|
|
90
91
|
foliage: regexFlag(flags.foliage), noFoliage: flags["no-foliage"], noSun: flags["no-sun"],
|
|
91
92
|
noLightmap: regexFlag(flags["no-lightmap"]), noTrimCards: flags["no-trim-cards"], colliderFit: flags["collider-fit"], noColliders: flags["no-colliders"], noShadows: regexFlag(flags["no-shadows"]), maxTris: flags["max-tris"],
|
|
92
|
-
impostor: impostorFlag(flags.impostor, flags["impostor-level"]),
|
|
93
|
+
impostor: impostorFlag(flags.impostor, flags["impostor-level"], flags["impostor-samples"]),
|
|
93
94
|
terrainRes: flags["terrain-res"] ?? 1025,
|
|
94
95
|
// the invocation, for the scene file's header (quote what a shell would need quoted)
|
|
95
96
|
command: "lecodes assets scene " + argv.map((a) => (/[\s"'|<>^&();]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a)).join(" "),
|
package/src/commands/shared.ts
CHANGED
|
@@ -120,9 +120,10 @@ export const resolveInputs = (args: string[], ext = /\.glb$/i, skipGenerated = t
|
|
|
120
120
|
/** `--impostor <A>x<E>[@<px>]` (the grid of A azimuths × E elevation rows: "12x3", "8x2@256") or `oct<N>[@<px>]` (the
|
|
121
121
|
* hemi-octahedron: "oct8", "oct8@128"), bare = 12x3, + `--impostor-level` → the bake's options (rows 0 = the
|
|
122
122
|
* octahedron, cols = N), or undefined when absent. */
|
|
123
|
-
export const impostorFlag = (v: string | undefined, level?: number): { cols: number, rows: number, px?: number, level?: number } | undefined => {
|
|
123
|
+
export const impostorFlag = (v: string | undefined, level?: number, samples?: number): { cols: number, rows: number, px?: number, level?: number, samples?: number } | undefined => {
|
|
124
124
|
if (v === undefined) return undefined
|
|
125
125
|
if (level !== undefined && (!Number.isInteger(level) || level < 1 || level > 3)) throw usageError(`--impostor-level wants 1, 2 or 3 — got "${level}"`)
|
|
126
|
+
if (samples !== undefined && (!Number.isInteger(samples) || samples < 1 || samples > 16)) throw usageError(`--impostor-samples wants 1..16 — got "${samples}"`)
|
|
126
127
|
const s = v.trim() || "12x3"
|
|
127
128
|
const bad = (why: string): Error => usageError(`--impostor wants <A>x<E>[@<px>] (azimuths ≥ 3, rows 1..8) or oct<N>[@<px>] (N 2..64), px 32..1024 a multiple of 4 — e.g. 12x3, 8x2@256, oct8@128 — got "${v}" (${why})`)
|
|
128
129
|
const grid = /^(\d+)x(\d+)(?:@(\d+))?$/i.exec(s), oct = /^oct(\d+)(?:@(\d+))?$/i.exec(s)
|
|
@@ -133,7 +134,7 @@ export const impostorFlag = (v: string | undefined, level?: number): { cols: num
|
|
|
133
134
|
if (grid && (rows < 1 || rows > 8)) throw bad("rows 1..8")
|
|
134
135
|
if (oct && (cols < 2 || cols > 64)) throw bad("N 2..64")
|
|
135
136
|
if (px !== undefined && (px < 32 || px > 1024 || px % 4 !== 0)) throw bad("px 32..1024, a multiple of 4")
|
|
136
|
-
return { cols, rows, px, level }
|
|
137
|
+
return { cols, rows, px, level, samples }
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
/** `--x <regex>` as a case-insensitive RegExp, or undefined. */
|
package/src/doctor/cardTrim.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import type { Accessor, Document, Primitive } from '@gltf-transform/core'
|
|
18
18
|
import { weldPrimitive } from '@gltf-transform/functions'
|
|
19
19
|
import { decodeImage } from '../unity/image'
|
|
20
|
+
import { ByteLru } from '../textures/byteLru'
|
|
20
21
|
|
|
21
22
|
export interface CardTrimOptions {
|
|
22
23
|
/** Trim alpha-MASK quads to their opaque texels (see the header). */
|
|
@@ -82,6 +83,7 @@ class OpaqueTable {
|
|
|
82
83
|
}
|
|
83
84
|
this.sat = sat
|
|
84
85
|
}
|
|
86
|
+
get bytes(): number { return this.sat.byteLength }
|
|
85
87
|
/** Opaque texels in [x0, x1) × [y0, y1) (clamped). */
|
|
86
88
|
count(x0: number, y0: number, x1: number, y1: number): number {
|
|
87
89
|
x0 = Math.max(0, Math.min(this.w, x0)); x1 = Math.max(0, Math.min(this.w, x1))
|
|
@@ -166,10 +168,21 @@ function findQuads(indices: Uint32Array, uv: Float32Array): Quad[] {
|
|
|
166
168
|
return quads
|
|
167
169
|
}
|
|
168
170
|
|
|
171
|
+
/**
|
|
172
|
+
* THE OPAQUE TABLES OUTLIVE THE CALL, keyed by the image OBJECT and the cutoff: a level import runs this pass per
|
|
173
|
+
* kind, and its foliage kinds share a handful of atlases by the same bytes (unity/materialWriter.ts
|
|
174
|
+
* `SharedTextures`) — each kind decoded every MASK base colour again (a 2048² PNG: ~50 ms of inflate + 16 MB).
|
|
175
|
+
* Bounded by the tables' own size (a 2048² table is 16 MB; 512 MB ≈ 30 atlases). The id is per object and never
|
|
176
|
+
* reused, so a table whose image is gone just ages out.
|
|
177
|
+
*/
|
|
178
|
+
const tableCache = new ByteLru<OpaqueTable | null>(512 * 1024 * 1024, (t) => t?.bytes ?? 0)
|
|
179
|
+
const imageIds = new WeakMap<Uint8Array, number>()
|
|
180
|
+
let nextImageId = 0
|
|
181
|
+
const imageId = (b: Uint8Array): number => { let id = imageIds.get(b); if (id === undefined) imageIds.set(b, id = ++nextImageId); return id }
|
|
182
|
+
|
|
169
183
|
/** Apply the pass to every planned primitive. */
|
|
170
184
|
export async function applyCardTrim(doc: Document, plan: CardTrimPlan): Promise<CardTrimResult> {
|
|
171
185
|
const res: CardTrimResult = { primitives: 0, quads: 0, trimmed: 0, dropped: 0, areaBefore: 0, areaAfter: 0, skipped: [] }
|
|
172
|
-
const tables = new Map<string, Promise<OpaqueTable | null>>()
|
|
173
186
|
for (const p of plan.prims) {
|
|
174
187
|
const mat = p.prim.getMaterial()!
|
|
175
188
|
if (p.texture !== 'ok') {
|
|
@@ -178,18 +191,11 @@ export async function applyCardTrim(doc: Document, plan: CardTrimPlan): Promise<
|
|
|
178
191
|
}
|
|
179
192
|
const tex = mat.getBaseColorTexture()!
|
|
180
193
|
const cutoff = Math.max(0, Math.min(1, mat.getAlphaCutoff())) * 255 / Math.max(1e-3, mat.getBaseColorFactor()[3])
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
if (!bytes) return null
|
|
187
|
-
const img = await decodeImage(bytes, tex.getURI() || tex.getMimeType() || 'texture.png')
|
|
188
|
-
return img ? new OpaqueTable(img.data, img.width, img.height, cutoff) : null
|
|
189
|
-
})()
|
|
190
|
-
tables.set(key, table)
|
|
191
|
-
}
|
|
192
|
-
const opaque = await table
|
|
194
|
+
const bytes = tex.getImage()
|
|
195
|
+
const opaque = bytes ? await tableCache.get(`${imageId(bytes)}#${cutoff}`, async () => {
|
|
196
|
+
const img = await decodeImage(bytes, tex.getURI() || tex.getMimeType() || 'texture.png')
|
|
197
|
+
return img ? new OpaqueTable(img.data, img.width, img.height, cutoff) : null
|
|
198
|
+
}) : null
|
|
193
199
|
if (!opaque) { res.skipped.push({ name: `${p.name}/${p.material}`, reason: 'base colour texture could not be decoded' }); continue }
|
|
194
200
|
const r = trimPrimitive(p.prim, opaque, plan.margin)
|
|
195
201
|
if (!r) continue
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// COVERAGE-PRESERVING MIPS for an alpha-MASK texture (Castaño, "Computing alpha mipmaps"; Unity's "Mip Maps Preserve
|
|
2
|
+
// Coverage"). A leaf card's alpha is hard-edged: 13 % of a birch atlas' texels stand over the material's cutoff, and a
|
|
3
|
+
// plain box mip averages that edge into a grey that falls UNDER the cutoff — four levels down (a 1 m card at 20 m) the
|
|
4
|
+
// alpha test passes almost nothing and the crown goes bare. Here every level is box-filtered and then its alpha is
|
|
5
|
+
// scaled by ONE scalar, chosen so that the fraction of texels at or over the cutoff equals the fraction the full-size
|
|
6
|
+
// source has. A `--max-size` downscale is a level like any other: the target is measured once, on the source.
|
|
7
|
+
//
|
|
8
|
+
// colour area-weighted box, averaged in LINEAR light (the sRGB transfer undone and redone), weighted by alpha so a
|
|
9
|
+
// transparent texel's colour (often black) does not bleed into the leaf's; a footprint with no alpha at all
|
|
10
|
+
// keeps the plain average
|
|
11
|
+
// alpha the area-weighted box average, then × s (clamped to 1), s from a 4096-bin histogram: the alpha τ at which
|
|
12
|
+
// the level's own coverage reaches the target, s = cutoff / τ
|
|
13
|
+
//
|
|
14
|
+
// Pure functions over raw RGBA8 buffers: the doctor writes the levels as PNGs and hands them to the encoder as an
|
|
15
|
+
// explicit mip chain (transcodeKtx2.ts), the tests call them directly.
|
|
16
|
+
|
|
17
|
+
export type Level = { data: Uint8Array, width: number, height: number }
|
|
18
|
+
|
|
19
|
+
const SRGB_TO_LINEAR = Float32Array.from({ length: 256 }, (_, i) => {
|
|
20
|
+
const c = i / 255
|
|
21
|
+
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
|
|
22
|
+
})
|
|
23
|
+
const linearToSrgb8 = (x: number): number => {
|
|
24
|
+
const c = x <= 0 ? 0 : x >= 1 ? 1 : x <= 0.0031308 ? x * 12.92 : 1.055 * Math.pow(x, 1 / 2.4) - 0.055
|
|
25
|
+
return Math.round(c * 255)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The fraction of texels whose alpha (8-bit, as the GPU reads it) is at or over `cutoff`. */
|
|
29
|
+
export const alphaCoverage = (rgba: Uint8Array, cutoff: number): number => {
|
|
30
|
+
const n = rgba.length >> 2
|
|
31
|
+
if (!n) return 0
|
|
32
|
+
const min8 = Math.ceil(cutoff * 255 - 1e-6)
|
|
33
|
+
let k = 0
|
|
34
|
+
for (let i = 3; i < rgba.length; i += 4) if (rgba[i] >= min8) k++
|
|
35
|
+
return k / n
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Area-weighted box resample of RGBA8 to dw × dh: colour in linear light (sRGB in and out when `srgb`) weighted by
|
|
39
|
+
* alpha, alpha the plain area average. Returns floats: rgb linear 0..1, alpha 0..1. */
|
|
40
|
+
const boxResample = (src: Uint8Array, sw: number, sh: number, dw: number, dh: number, srgb: boolean): Float32Array => {
|
|
41
|
+
const out = new Float32Array(dw * dh * 4)
|
|
42
|
+
const lin = srgb ? SRGB_TO_LINEAR : Float32Array.from({ length: 256 }, (_, i) => i / 255)
|
|
43
|
+
const fx = sw / dw, fy = sh / dh
|
|
44
|
+
for (let y = 0; y < dh; y++) {
|
|
45
|
+
const y0 = y * fy, y1 = y0 + fy
|
|
46
|
+
for (let x = 0; x < dw; x++) {
|
|
47
|
+
const x0 = x * fx, x1 = x0 + fx
|
|
48
|
+
let wa = 0, ra = 0, ga = 0, ba = 0, w = 0, r = 0, g = 0, b = 0
|
|
49
|
+
for (let sy = Math.floor(y0); sy < Math.min(Math.ceil(y1), sh); sy++) {
|
|
50
|
+
const wy = Math.min(sy + 1, y1) - Math.max(sy, y0)
|
|
51
|
+
if (wy <= 0) continue
|
|
52
|
+
for (let sx = Math.floor(x0); sx < Math.min(Math.ceil(x1), sw); sx++) {
|
|
53
|
+
const wx = Math.min(sx + 1, x1) - Math.max(sx, x0)
|
|
54
|
+
if (wx <= 0) continue
|
|
55
|
+
const k = wx * wy, o = (sy * sw + sx) * 4
|
|
56
|
+
const a = src[o + 3] / 255
|
|
57
|
+
const lr = lin[src[o]], lg = lin[src[o + 1]], lb = lin[src[o + 2]]
|
|
58
|
+
w += k; r += lr * k; g += lg * k; b += lb * k
|
|
59
|
+
wa += a * k; ra += lr * a * k; ga += lg * a * k; ba += lb * a * k
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const o = (y * dw + x) * 4
|
|
63
|
+
if (wa > 1e-6) { out[o] = ra / wa; out[o + 1] = ga / wa; out[o + 2] = ba / wa }
|
|
64
|
+
else if (w > 0) { out[o] = r / w; out[o + 1] = g / w; out[o + 2] = b / w }
|
|
65
|
+
out[o + 3] = w > 0 ? wa / w : 0
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The scale s that brings `alpha`'s coverage at `cutoff` to `target` (the fraction of texels at or over it). */
|
|
72
|
+
const coverageScale = (lvl: Float32Array, cutoff: number, target: number): number => {
|
|
73
|
+
const n = lvl.length >> 2
|
|
74
|
+
const want = Math.round(target * n)
|
|
75
|
+
if (want <= 0 || cutoff <= 0) return 1
|
|
76
|
+
const BINS = 4096
|
|
77
|
+
const hist = new Uint32Array(BINS + 1)
|
|
78
|
+
for (let i = 3; i < lvl.length; i += 4) hist[Math.min(BINS, Math.floor(lvl[i] * BINS))]++
|
|
79
|
+
// walk down from the top: τ = the alpha at which `want` texels are at or over it
|
|
80
|
+
let k = 0, bin = BINS
|
|
81
|
+
for (; bin > 0; bin--) { k += hist[bin]; if (k >= want) break }
|
|
82
|
+
const tau = bin / BINS
|
|
83
|
+
if (tau <= 1e-4) return 1 // the level has (almost) no alpha left to scale
|
|
84
|
+
return cutoff / tau
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Floats (linear rgb, alpha) → RGBA8 with the alpha scaled by `s` (and clamped). */
|
|
88
|
+
const toBytes = (lvl: Float32Array, s: number, srgb: boolean): Uint8Array => {
|
|
89
|
+
const out = new Uint8Array(lvl.length)
|
|
90
|
+
for (let o = 0; o < lvl.length; o += 4) {
|
|
91
|
+
for (let c = 0; c < 3; c++) out[o + c] = srgb ? linearToSrgb8(lvl[o + c]) : Math.round(Math.min(Math.max(lvl[o + c], 0), 1) * 255)
|
|
92
|
+
out[o + 3] = Math.round(Math.min(lvl[o + 3] * s, 1) * 255)
|
|
93
|
+
}
|
|
94
|
+
return out
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type MipChainOptions = {
|
|
98
|
+
/** The alpha test's cutoff (the lowest of the MASK materials sharing the texture). */
|
|
99
|
+
cutoff: number
|
|
100
|
+
/** Level 0's size (the `--max-size` cap, ×4 snapped by the caller); the source's when equal. */
|
|
101
|
+
width: number
|
|
102
|
+
height: number
|
|
103
|
+
/** The colour is sRGB-encoded (base colour): averaged in linear light. */
|
|
104
|
+
srgb: boolean
|
|
105
|
+
/** false = the plain box chain (no alpha scaling) — the tests' contrast. Default true. */
|
|
106
|
+
preserve?: boolean
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The full mip chain, level 0 (`width` × `height`) down to 1 × 1, each level max(1, ⌊w / 2^i⌋) × max(1, ⌊h / 2^i⌋) (the
|
|
111
|
+
* encoder's own level sizes). Every level is box-filtered from the one above (level 0 from the source) and, with
|
|
112
|
+
* `preserve`, its alpha scaled to the SOURCE's coverage at the cutoff. A level 0 at the source's size is the source.
|
|
113
|
+
*/
|
|
114
|
+
export const coverageMipChain = (src: Uint8Array, sw: number, sh: number, o: MipChainOptions): { levels: Level[], target: number } => {
|
|
115
|
+
const preserve = o.preserve !== false
|
|
116
|
+
const target = alphaCoverage(src, o.cutoff)
|
|
117
|
+
const levels: Level[] = []
|
|
118
|
+
let w = o.width, h = o.height
|
|
119
|
+
let cur: Level = { data: src, width: sw, height: sh }
|
|
120
|
+
for (let i = 0; ; i++) {
|
|
121
|
+
if (i === 0 && w === sw && h === sh) levels.push(cur)
|
|
122
|
+
else {
|
|
123
|
+
const f = boxResample(cur.data, cur.width, cur.height, w, h, o.srgb)
|
|
124
|
+
const s = preserve ? coverageScale(f, o.cutoff, target) : 1
|
|
125
|
+
cur = { data: toBytes(f, s, o.srgb), width: w, height: h }
|
|
126
|
+
levels.push(cur)
|
|
127
|
+
}
|
|
128
|
+
if (w === 1 && h === 1) break
|
|
129
|
+
w = Math.max(1, w >> 1); h = Math.max(1, h >> 1)
|
|
130
|
+
}
|
|
131
|
+
return { levels, target }
|
|
132
|
+
}
|
package/src/doctor/doctor.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { planTexture, applyTexturePlan, getTextureRoles, DEFAULT_MAX_SIZE, type
|
|
|
12
12
|
import { planKtx2, applyKtx2, findKtxEncoder, KTX_ENCODER_MISSING, type Ktx2Plan, type KtxEncoder } from './transcodeKtx2'
|
|
13
13
|
import { measureScale, planScale, applyScale, type ScaleInfo, type ScalePlan, type ScaleOptions } from './normalizeScale'
|
|
14
14
|
import { analyzeShading, applyShading, type ShadingPlan, type ShadingOptions } from './shading'
|
|
15
|
+
import { imageInfo } from './imageFacts'
|
|
15
16
|
import { planCardTrim, applyCardTrim, type CardTrimPlan, type CardTrimOptions, type CardTrimResult } from './cardTrim'
|
|
16
17
|
import { planLod, applyLod, type LodPlan, type LodOptions, type LodResult } from './lod'
|
|
17
18
|
import { planMerge, applyMerge, describeScale, type MergePlan, type MergeOptions, type MergeResult } from './merge'
|
|
@@ -39,6 +40,9 @@ export async function analyzeDoc(doc: Document, opts: DoctorOptions = {}): Promi
|
|
|
39
40
|
// KTX2 mode: everything → ETC1S (max compression). --ktx2-hq upgrades NORMAL maps to UASTC
|
|
40
41
|
// (much higher quality, same VRAM, larger file); colour/metalRough stay ETC1S either way.
|
|
41
42
|
const codecPolicy = opts.ktx2Uastc ? 'uastc' : opts.ktx2Hq ? 'hq' : 'default'
|
|
43
|
+
// sharp's look at every image, all at once (its own threads; cached per image object, imageFacts.ts) — one after
|
|
44
|
+
// another was 0.1–0.25 s a map: 3 s of the 30 maps of Chernobyl's Department_Store
|
|
45
|
+
await Promise.all(root.listTextures().map((t) => { const img = t.getImage(); return img && !(t.getMimeType() || '').includes('ktx') ? imageInfo(img) : null }))
|
|
42
46
|
for (const tex of root.listTextures()) {
|
|
43
47
|
if (opts.ktx2) {
|
|
44
48
|
const p = await planKtx2(tex, root, maxSize, codecPolicy); if (p) ktx2Plans.push(p)
|
|
@@ -255,7 +259,10 @@ export async function applyPlan(doc: Document, plan: DoctorPlan, opts: ApplyOpti
|
|
|
255
259
|
// texture set went through one core however many were idle.
|
|
256
260
|
await Promise.all(plan.ktx2Plans.map(async (t) => {
|
|
257
261
|
await applyKtx2(t, enc, maxSize)
|
|
258
|
-
|
|
262
|
+
// counted OUTSIDE the optional call: `progress?.(++n)` skips its arguments without a callback, so the
|
|
263
|
+
// result said 0 encoded for every caller that passed none
|
|
264
|
+
ktx2Encoded++
|
|
265
|
+
opts.progress?.(ktx2Encoded, plan.ktx2Plans.length)
|
|
259
266
|
}))
|
|
260
267
|
// KTX2 textures must be written via KHR_texture_basisu (core texture.source allows only PNG/JPEG);
|
|
261
268
|
// gltf-transform only emits that form when the extension is attached to the document.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// What sharp says about an image, once per image OBJECT: its size, whether its alpha is used, and its sha1 (the
|
|
2
|
+
// key of transcodeKtx2.ts's encode cache).
|
|
3
|
+
//
|
|
4
|
+
// A level import hands every kind the same `Uint8Array` of a shared map (unity/materialWriter.ts
|
|
5
|
+
// `SharedTextures`), and `metadata()` + `stats()` of each was the whole of the `analyze` stage — 12 s of a
|
|
6
|
+
// 14-kind Chernobyl sample, 0.1–0.25 s a 2048–4096² map, paid again in every kind that used it (2026-11-04).
|
|
7
|
+
// Weak: an entry dies with its bytes.
|
|
8
|
+
import { createHash } from 'node:crypto'
|
|
9
|
+
import sharp from 'sharp'
|
|
10
|
+
import type { Rgba } from '../textures/resolveImage'
|
|
11
|
+
|
|
12
|
+
type ImageFacts = { width: number; height: number; hasAlpha: boolean; sha1?: string }
|
|
13
|
+
const facts = new WeakMap<Uint8Array, Promise<ImageFacts | null>>()
|
|
14
|
+
export function imageInfo(img: Uint8Array): Promise<ImageFacts | null> {
|
|
15
|
+
let f = facts.get(img)
|
|
16
|
+
if (!f) {
|
|
17
|
+
f = (async () => {
|
|
18
|
+
try {
|
|
19
|
+
const b = Buffer.from(img.buffer, img.byteOffset, img.byteLength)
|
|
20
|
+
const m = await sharp(b).metadata()
|
|
21
|
+
return { width: m.width || 0, height: m.height || 0, hasAlpha: !(await sharp(b).stats()).isOpaque }
|
|
22
|
+
} catch { return null }
|
|
23
|
+
})()
|
|
24
|
+
facts.set(img, f)
|
|
25
|
+
}
|
|
26
|
+
return f
|
|
27
|
+
}
|
|
28
|
+
/** The image's sha1, hashed once per image object. */
|
|
29
|
+
export const imageSha1 = async (img: Uint8Array): Promise<string> => {
|
|
30
|
+
const f = await imageInfo(img)
|
|
31
|
+
if (f) return (f.sha1 ??= createHash('sha1').update(img).digest('hex'))
|
|
32
|
+
return createHash('sha1').update(img).digest('hex')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The facts of a PNG the caller has just encoded from `rgba` itself — sharp is not asked. Sharp's `isOpaque` is
|
|
36
|
+
* "the minimum alpha is the maximum", which is exactly "no texel below 255" here. Not past sharp's own input
|
|
37
|
+
* limit (16383² pixels): sharp refuses such an image, and the passes must go on seeing that refusal. */
|
|
38
|
+
export function knownImageInfo(png: Uint8Array, rgba: Rgba): void {
|
|
39
|
+
if (facts.has(png) || rgba.width * rgba.height > 0x3fff * 0x3fff) return
|
|
40
|
+
const d = rgba.data
|
|
41
|
+
let hasAlpha = false
|
|
42
|
+
for (let i = 3; i < d.length; i += 4) if (d[i] !== 255) { hasAlpha = true; break }
|
|
43
|
+
facts.set(png, Promise.resolve({ width: rgba.width, height: rgba.height, hasAlpha }))
|
|
44
|
+
}
|