lecodes-assets 0.2.0 → 2.4.10
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/README.md +738 -449
- package/dist/cli.js +19760 -8612
- package/dist/index.js +19334 -8167
- package/package.json +15 -3
- package/src/bvh/fromDocument.ts +136 -0
- package/src/bvh/toDocument.ts +202 -0
- package/src/cli-kit/command.ts +100 -0
- package/src/cli-kit/help.ts +72 -0
- package/src/cli-kit/index.ts +4 -0
- package/src/cli-kit/parse.ts +72 -0
- package/src/cli-kit/run.ts +52 -0
- package/src/cli.ts +24 -0
- package/src/commands/avatar.ts +77 -0
- package/src/commands/bvh.ts +26 -0
- package/src/commands/convert.ts +156 -0
- package/src/commands/doctor.ts +67 -0
- package/src/commands/geometry.ts +45 -0
- package/src/commands/index.ts +24 -0
- package/src/commands/marks.ts +38 -0
- package/src/commands/mirror.ts +35 -0
- package/src/commands/probe.ts +43 -0
- package/src/commands/retarget/donor.ts +63 -0
- package/src/commands/retarget/index.ts +169 -0
- package/src/commands/retarget/map.ts +81 -0
- package/src/commands/retarget/report.ts +77 -0
- package/src/commands/scene.ts +103 -0
- package/src/commands/shared.ts +121 -0
- package/src/commands/sky.ts +35 -0
- package/src/commands/terrainPack.ts +40 -0
- package/src/commands/unpack.ts +43 -0
- package/src/commands/unreal.ts +109 -0
- package/src/commands/unwrap.ts +56 -0
- package/src/doctor/cardTrim.ts +286 -0
- package/src/doctor/doctor.ts +305 -0
- package/src/doctor/lod.ts +177 -0
- package/src/doctor/merge/accessors.ts +30 -0
- package/src/doctor/merge/atlasLayout.ts +94 -0
- package/src/doctor/merge/atlasPixels.ts +114 -0
- package/src/doctor/merge/bindSpace.ts +59 -0
- package/src/doctor/merge/classify.ts +136 -0
- package/src/doctor/merge/geometry.ts +167 -0
- package/src/doctor/merge/index.ts +98 -0
- package/src/doctor/merge/matrix.ts +60 -0
- package/src/doctor/merge/plan.ts +88 -0
- package/src/doctor/merge/textures.ts +60 -0
- package/src/doctor/merge/types.ts +77 -0
- package/src/doctor/normalizeScale.ts +220 -0
- package/src/doctor/reduceGlbBones.ts +218 -0
- package/src/doctor/shading.ts +95 -0
- package/src/doctor/transcodeKtx2.ts +275 -0
- package/src/doctor/transcodeTextures.ts +101 -0
- package/src/doctor/validateGlb.ts +264 -0
- package/src/doctor/vertexAo.ts +321 -0
- package/src/fbx/convert.ts +327 -0
- package/src/fbx/read.ts +111 -0
- package/src/fbx/toDocument.ts +227 -0
- package/src/filmstrip.ts +263 -0
- package/src/geometry.ts +222 -0
- package/src/index.ts +19 -0
- package/src/mergeSkins.ts +117 -0
- package/src/probe.ts +167 -0
- package/src/props.ts +137 -0
- package/src/retarget/automap.ts +276 -0
- package/src/retarget/ghost.ts +23 -0
- package/src/retarget/heading.ts +238 -0
- package/src/retarget/humanoid.ts +572 -0
- package/src/retarget/inPlace.ts +130 -0
- package/src/retarget/marks.ts +581 -0
- package/src/retarget/mirror.ts +0 -0
- package/src/retarget/retarget.ts +2375 -0
- package/src/retarget/rig.ts +545 -0
- package/src/retarget/unityAnim.ts +171 -0
- package/src/retarget/writeMarks.ts +134 -0
- package/src/sky/analyze.ts +136 -0
- package/src/sky/cmgen.ts +71 -0
- package/src/sky/index.ts +200 -0
- package/src/terrainPack.ts +208 -0
- package/src/textures/resolveImage.ts +202 -0
- package/src/unity/colliderFit.ts +287 -0
- package/src/unity/fbxModel.ts +194 -0
- package/src/unity/foliage.ts +63 -0
- package/src/unity/gameObjects.ts +81 -0
- package/src/unity/guid.ts +104 -0
- package/src/unity/image.ts +58 -0
- package/src/unity/kindGlb.ts +364 -0
- package/src/unity/lecodesLevel.ts +381 -0
- package/src/unity/levelTerrain.ts +116 -0
- package/src/unity/matFile.ts +179 -0
- package/src/unity/materialSlots.ts +53 -0
- package/src/unity/materialWriter.ts +306 -0
- package/src/unity/materials.ts +198 -0
- package/src/unity/math.ts +82 -0
- package/src/unity/png.ts +75 -0
- package/src/unity/prefabTree.ts +285 -0
- package/src/unity/prefabs.ts +54 -0
- package/src/unity/propertyPath.ts +43 -0
- package/src/unity/scene.ts +384 -0
- package/src/unity/serialized.ts +242 -0
- package/src/unity/terrain.ts +221 -0
- package/src/unity/triBudget.ts +77 -0
- package/src/unity/triplanar.ts +122 -0
- package/src/unity/unityMesh.ts +151 -0
- package/src/unity/unpack.ts +149 -0
- package/src/unity/yaml.ts +131 -0
- package/src/unreal/anim.ts +284 -0
- package/src/unreal/clipSource.ts +25 -0
- package/src/unreal/convert.ts +67 -0
- package/src/unreal/convertAsset.ts +81 -0
- package/src/unreal/index.ts +115 -0
- package/src/unreal/list.ts +113 -0
- package/src/unreal/materialGraph.ts +224 -0
- package/src/unreal/materials.ts +399 -0
- package/src/unreal/mesh.ts +205 -0
- package/src/unreal/oodle.ts +153 -0
- package/src/unreal/package.ts +677 -0
- package/src/unreal/probe.ts +144 -0
- package/src/unreal/resolve.ts +53 -0
- package/src/unreal/rig.ts +149 -0
- package/src/unreal/sequencer.ts +193 -0
- package/src/unreal/skeleton.ts +74 -0
- package/src/unreal/sound.ts +128 -0
- package/src/unreal/texture.ts +269 -0
- package/src/unreal/toDocument.ts +367 -0
- package/src/unreal/versions.ts +108 -0
- package/src/unwrap/index.ts +167 -0
- package/src/unwrap/lightmapUv.ts +761 -0
- package/src/wasm/fbxReader.js +14 -0
- package/src/wasm/lightmapUv.js +14 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
// glTF doctor — analyze a Document against creator-gl/filament constraints and (optionally) fix it.
|
|
2
|
+
// The former tools/glb-reduce/glbDoctor.ts, as functions so `convert` can run the same passes on a
|
|
3
|
+
// freshly converted model (`convert … --ktx2`) and the CLI can run them on any GLB (`doctor`).
|
|
4
|
+
//
|
|
5
|
+
// Analyze : reports issues grouped by impact — LOSSLESS fixes (no degradation, e.g. dropping
|
|
6
|
+
// unbound joints / re-encoding webp→png) vs LOSSY fixes (bound-joint removal,
|
|
7
|
+
// webp→jpg, downscaling, heavy-png→jpg).
|
|
8
|
+
// Fix : applies everything; the caller writes <name>.fixed.glb and re-validates.
|
|
9
|
+
// KTX2 : textures → KTX2 (ETC1S; --ktx2-hq keeps normals UASTC) via KTX-Software (toktx, or `ktx create` where toktx is gone).
|
|
10
|
+
import { gatherSkinPrims, planSkin, applySkinPlan, DEFAULT_TARGET, type SkinPlan } from './reduceGlbBones'
|
|
11
|
+
import { planTexture, applyTexturePlan, getTextureRoles, DEFAULT_MAX_SIZE, type TexturePlan } from './transcodeTextures'
|
|
12
|
+
import { planKtx2, applyKtx2, findKtxEncoder, KTX_ENCODER_MISSING, type Ktx2Plan, type KtxEncoder } from './transcodeKtx2'
|
|
13
|
+
import { measureScale, planScale, applyScale, type ScaleInfo, type ScalePlan, type ScaleOptions } from './normalizeScale'
|
|
14
|
+
import { analyzeShading, applyShading, type ShadingPlan, type ShadingOptions } from './shading'
|
|
15
|
+
import { planCardTrim, applyCardTrim, type CardTrimPlan, type CardTrimOptions, type CardTrimResult } from './cardTrim'
|
|
16
|
+
import { planLod, applyLod, type LodPlan, type LodOptions, type LodResult } from './lod'
|
|
17
|
+
import { planMerge, applyMerge, describeScale, type MergePlan, type MergeOptions, type MergeResult } from './merge'
|
|
18
|
+
import { duplicateNodeNames } from '../mergeSkins'
|
|
19
|
+
import { KHRTextureBasisu } from '@gltf-transform/extensions'
|
|
20
|
+
import type { Document, Material } from '@gltf-transform/core'
|
|
21
|
+
import { mkdirSync, existsSync, statSync, writeFileSync } from 'node:fs'
|
|
22
|
+
import { join } from 'node:path'
|
|
23
|
+
|
|
24
|
+
export { DEFAULT_TARGET, DEFAULT_MAX_SIZE, findKtxEncoder, KTX_ENCODER_MISSING }
|
|
25
|
+
export { sceneBounds, measureScale, applyScale } from './normalizeScale'
|
|
26
|
+
|
|
27
|
+
interface MetallicWarn { mat: Material; name: string; metal: number }
|
|
28
|
+
export interface DoctorPlan { skinPlans: SkinPlan[]; texPlans: TexturePlan[]; ktx2Plans: Ktx2Plan[]; metallicWarn: MetallicWarn[]; bareCount: number; shadingPlan: ShadingPlan; scaleInfo: ScaleInfo | null; scalePlan: ScalePlan | null; lodPlan: LodPlan | null; cardTrimPlan: CardTrimPlan; mergePlan: MergePlan; conflicts: number; target: number; dupNames: { name: string; count: number; shadowsJoint: boolean }[] }
|
|
29
|
+
export interface DoctorOptions extends ScaleOptions, ShadingOptions, LodOptions, CardTrimOptions, MergeOptions { target?: number; maxSize?: number; ktx2?: boolean; ktx2Hq?: boolean; ktx2Uastc?: boolean }
|
|
30
|
+
|
|
31
|
+
export async function analyzeDoc(doc: Document, opts: DoctorOptions = {}): Promise<DoctorPlan> {
|
|
32
|
+
const target = opts.target ?? DEFAULT_TARGET
|
|
33
|
+
const maxSize = opts.maxSize ?? DEFAULT_MAX_SIZE
|
|
34
|
+
const root = doc.getRoot()
|
|
35
|
+
const { bySkin, conflicts } = gatherSkinPrims(doc)
|
|
36
|
+
const skinPlans = root.listSkins().map((s, i) => planSkin(s, i, bySkin.get(s) ?? [], target))
|
|
37
|
+
const texPlans: TexturePlan[] = []
|
|
38
|
+
const ktx2Plans: Ktx2Plan[] = []
|
|
39
|
+
// KTX2 mode: everything → ETC1S (max compression). --ktx2-hq upgrades NORMAL maps to UASTC
|
|
40
|
+
// (much higher quality, same VRAM, larger file); colour/metalRough stay ETC1S either way.
|
|
41
|
+
const codecPolicy = opts.ktx2Uastc ? 'uastc' : opts.ktx2Hq ? 'hq' : 'default'
|
|
42
|
+
for (const tex of root.listTextures()) {
|
|
43
|
+
if (opts.ktx2) {
|
|
44
|
+
const p = await planKtx2(tex, root, maxSize, codecPolicy); if (p) ktx2Plans.push(p)
|
|
45
|
+
} else {
|
|
46
|
+
const p = await planTexture(tex, root, maxSize); if (p) texPlans.push(p)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// Materials with a flat metallic factor but NO metallicRoughness texture render BLACK without an
|
|
50
|
+
// IBL/environment (metal reflects nothing, has no diffuse). Common export quirk on non-metal assets.
|
|
51
|
+
const metallicWarn: MetallicWarn[] = root.listMaterials()
|
|
52
|
+
.filter((m) => m.getMetallicFactor() > 0.01 && !m.getMetallicRoughnessTexture())
|
|
53
|
+
.map((m) => ({ mat: m, name: m.getName() || '?', metal: m.getMetallicFactor() }))
|
|
54
|
+
// Primitives with NO material at all: glTF's default material is metallic 1 / roughness 1 — a white ROUGH METAL,
|
|
55
|
+
// which under an environment (or a lightmap's reflection probe) is a dark featureless blob (the school's
|
|
56
|
+
// cabinets and sinks, 2026-09-17). --fix-metallic gives them a plain white dielectric.
|
|
57
|
+
let bareCount = 0
|
|
58
|
+
for (const mesh of root.listMeshes()) for (const prim of mesh.listPrimitives()) if (!prim.getMaterial()) bareCount++
|
|
59
|
+
const shadingPlan = analyzeShading(doc, opts)
|
|
60
|
+
// Scale is always MEASURED (probe/report it even with no flag); it is only APPLIED when asked.
|
|
61
|
+
const scaleInfo = measureScale(doc)
|
|
62
|
+
const scalePlan = planScale(scaleInfo, opts)
|
|
63
|
+
// Duplicate node names: the Animator binds clips BY NAME and takes the first match. A leftover
|
|
64
|
+
// donor skeleton (two skinned files merged by hand) shadows the real joints → the mesh freezes.
|
|
65
|
+
const dupNames = duplicateNodeNames(doc)
|
|
66
|
+
// LOD levels are only ever ADDED on request (--lod); see doctor/lod.ts.
|
|
67
|
+
const lodPlan = planLod(doc, opts)
|
|
68
|
+
// Card trimming only on request (--trim-cards); the analysis lists the alpha-MASK primitives it would cut.
|
|
69
|
+
const cardTrimPlan = planCardTrim(doc, opts)
|
|
70
|
+
// Mesh merge + atlas only on request (--merge); the analysis says how many draw calls it would fold.
|
|
71
|
+
const mergePlan = await planMerge(doc, opts)
|
|
72
|
+
return { skinPlans, texPlans, ktx2Plans, metallicWarn, bareCount, shadingPlan, scaleInfo, scalePlan, lodPlan, cardTrimPlan, mergePlan, conflicts, target, dupNames }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const kb = (n?: number) => (n === undefined ? '?' : `${Math.round(n / 1024)}KB`)
|
|
76
|
+
|
|
77
|
+
/** A length in metres, in whatever unit reads best — 0.0055 is "5.5mm", not "0.01m". */
|
|
78
|
+
export const metres = (m: number): string =>
|
|
79
|
+
m >= 1 ? `${m.toFixed(m >= 10 ? 0 : 2)}m` : m >= 0.01 ? `${(m * 100).toFixed(1)}cm` : `${(m * 1000).toFixed(m >= 0.001 ? 1 : 3)}mm`
|
|
80
|
+
const dims3 = (d: readonly number[]): string => d.map((x) => metres(x)).join(' × ')
|
|
81
|
+
const fmtFactor = (f: number): string => (f >= 0.001 && f < 1e6 ? +f.toPrecision(6) : f.toExponential(2)).toString()
|
|
82
|
+
|
|
83
|
+
const EXT_BY_MIME: Record<string, string> = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/ktx2': 'ktx2' }
|
|
84
|
+
const safeName = (n: string) => (n || 'tex').replace(/[\\/:*?"<>|\s]+/g, '_').slice(0, 60)
|
|
85
|
+
|
|
86
|
+
/** Write every texture's raw bytes to `dir` (+ a _manifest.txt) for manual inspection. */
|
|
87
|
+
export async function dumpTextures(doc: Document, dir: string, log: (s: string) => void = console.log) {
|
|
88
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
89
|
+
const root = doc.getRoot()
|
|
90
|
+
const rows: string[] = []
|
|
91
|
+
let sharp: any = null
|
|
92
|
+
try { sharp = (await import('sharp')).default } catch { /* dims/alpha columns stay '-' */ }
|
|
93
|
+
let i = 0
|
|
94
|
+
for (const tex of root.listTextures()) {
|
|
95
|
+
const img = tex.getImage()
|
|
96
|
+
const mime = tex.getMimeType() || ''
|
|
97
|
+
const ext = EXT_BY_MIME[mime] ?? 'bin'
|
|
98
|
+
const file = `${String(i).padStart(2, '0')}_${safeName(tex.getName())}.${ext}`
|
|
99
|
+
if (img) writeFileSync(`${dir}/${file}`, img)
|
|
100
|
+
const { roles } = getTextureRoles(tex, root)
|
|
101
|
+
let dims = '-', alpha = '-'
|
|
102
|
+
if (img && ext !== 'ktx2' && sharp) {
|
|
103
|
+
try {
|
|
104
|
+
const m = await sharp(Buffer.from(img)).metadata()
|
|
105
|
+
dims = `${m.width}x${m.height}`
|
|
106
|
+
alpha = (await sharp(Buffer.from(img)).stats()).isOpaque ? 'opaque' : 'ALPHA'
|
|
107
|
+
} catch { /* undecodable by sharp */ }
|
|
108
|
+
}
|
|
109
|
+
rows.push(`${file.padEnd(48)} ${ext.padEnd(5)} ${dims.padEnd(11)} ${alpha.padEnd(7)} ${roles.padEnd(12)} ${kb(img?.length)}`)
|
|
110
|
+
i++
|
|
111
|
+
}
|
|
112
|
+
const header = `# ${i} textures from this GLB\n# file format dimensions alpha role size\n`
|
|
113
|
+
writeFileSync(`${dir}/_manifest.txt`, header + rows.join('\n') + '\n')
|
|
114
|
+
log(`\n🖼 dumped ${i} textures → ${dir}`)
|
|
115
|
+
log(rows.join('\n'))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function reportPlan(plan: DoctorPlan): { text: string; lossless: number; lossy: number; blocking: number } {
|
|
119
|
+
const lossless: string[] = [], lossy: string[] = [], blocking: string[] = []
|
|
120
|
+
|
|
121
|
+
for (const s of plan.skinPlans) {
|
|
122
|
+
if (s.before <= plan.target) continue
|
|
123
|
+
if (s.removedDeep === 0) {
|
|
124
|
+
lossless.push(`bones ${s.name}: drop ${s.removedUnbound} unbound joints (${s.before} → ${s.after})`)
|
|
125
|
+
} else {
|
|
126
|
+
if (s.removedUnbound > 0) lossless.push(`bones ${s.name}: drop ${s.removedUnbound} unbound joints`)
|
|
127
|
+
lossy.push(`bones ${s.name}: drop ${s.removedDeep} deepest BOUND joints, merge weights → parent (${s.before} → ${s.after})`)
|
|
128
|
+
}
|
|
129
|
+
if (s.after > plan.target) blocking.push(`bones ${s.name}: still ${s.after} > ${plan.target} after reduction (too many root joints)`)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const t of plan.texPlans) {
|
|
133
|
+
if (!t.toFmt) continue
|
|
134
|
+
const line = `tex "${t.name}" [${t.roles}] ${t.fromFmt}→${t.toFmt} (${t.reason}, ${kb(t.beforeBytes)})`
|
|
135
|
+
;(t.category === 'lossless' ? lossless : lossy).push(line)
|
|
136
|
+
}
|
|
137
|
+
for (const t of plan.ktx2Plans) {
|
|
138
|
+
lossy.push(`tex "${t.name}" [${t.roles}] ${t.reason} (${kb(t.beforeBytes)})`)
|
|
139
|
+
}
|
|
140
|
+
if (plan.conflicts > 0) blocking.push(`${plan.conflicts} primitive(s) shared across skins — skipped`)
|
|
141
|
+
const shadowing = plan.dupNames.filter((d) => d.shadowsJoint)
|
|
142
|
+
if (shadowing.length) blocking.push(`names ${shadowing.length} skinned joint(s) shadowed by a plain node of the same name (${shadowing.slice(0, 5).map((d) => d.name).join(', ')}${shadowing.length > 5 ? '…' : ''}) — a name-bound Animator drives the copy and the mesh freezes; rebuild with convert --merge-skins, or delete the stray skeleton`)
|
|
143
|
+
|
|
144
|
+
const lines: string[] = []
|
|
145
|
+
if (lossless.length) { lines.push(' ── LOSSLESS (no quality loss) ──'); lossless.forEach((l) => lines.push(' • ' + l)) }
|
|
146
|
+
if (lossy.length) { lines.push(' ── LOSSY (some degradation) ──'); lossy.forEach((l) => lines.push(' • ' + l)) }
|
|
147
|
+
if (blocking.length) { lines.push(' ── BLOCKING / manual ──'); blocking.forEach((l) => lines.push(' ✗ ' + l)) }
|
|
148
|
+
if (plan.metallicWarn.length || plan.bareCount) lines.push(` ── MATERIALS ──`)
|
|
149
|
+
if (plan.metallicWarn.length) {
|
|
150
|
+
lines.push(` ℹ ${plan.metallicWarn.length} material(s) have metallic without an MR texture → may render BLACK without IBL (--fix-metallic zeroes them):`)
|
|
151
|
+
lines.push(` ${plan.metallicWarn.map((w) => `${w.name}(${w.metal.toFixed(2)})`).join(', ')}`)
|
|
152
|
+
}
|
|
153
|
+
if (plan.bareCount) lines.push(` ℹ ${plan.bareCount} primitive(s) have NO material → glTF's default is a white ROUGH METAL (a dark blob under any environment; --fix-metallic gives them a white dielectric)`)
|
|
154
|
+
const selfLit = plan.shadingPlan.issues.filter((i) => i.selfLit)
|
|
155
|
+
const boosted = plan.shadingPlan.issues.filter((i) => i.specularBoost > 0)
|
|
156
|
+
if (selfLit.length || boosted.length) {
|
|
157
|
+
if (!plan.metallicWarn.length && !plan.bareCount) lines.push(` ── MATERIALS ──`)
|
|
158
|
+
if (selfLit.length) {
|
|
159
|
+
lines.push(` ⚠ ${selfLit.length} material(s) emit their own base colour (emissive = albedo) → render ~2× too bright, washed out (--fix-emissive drops it, --unlit goes flat):`)
|
|
160
|
+
lines.push(` ${selfLit.map((i) => i.name).join(', ')}`)
|
|
161
|
+
}
|
|
162
|
+
if (boosted.length) {
|
|
163
|
+
lines.push(` ℹ ${boosted.length} material(s) boost specular above 1 (${boosted.map((i) => `${i.name}(×${i.specularBoost.toFixed(1)})`).join(', ')}) — gltfio also refuses specular + unlit, so --unlit drops it`)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const sc = plan.scaleInfo
|
|
167
|
+
if (sc?.suspicious || plan.scalePlan) {
|
|
168
|
+
lines.push(` ── SCALE ──`)
|
|
169
|
+
if (sc) lines.push(` ${sc.suspicious ? '⚠' : 'ℹ'} model measures ${dims3(sc.dims)}${sc.suspicious ? ` — implausible for a real-world asset (${sc.label})` : ''}`)
|
|
170
|
+
else lines.push(` ℹ no geometry to measure (clip-only file)`)
|
|
171
|
+
if (plan.scalePlan) lines.push(` → ×${fmtFactor(plan.scalePlan.factor)}${plan.scalePlan.source === 'explicit' ? '' : ` (${plan.scalePlan.label})`}${sc ? ` → ${dims3(plan.scalePlan.after)}` : ''}`)
|
|
172
|
+
else if (sc && sc.suggested !== 1) lines.push(` --fix-scale applies ×${fmtFactor(sc.suggested)} → ${dims3(sc.dims.map((d) => d * sc.suggested))}; --scale-to <m> / --scale <n> to decide yourself`)
|
|
173
|
+
else lines.push(` --scale-to <m> sets the largest dimension explicitly`)
|
|
174
|
+
}
|
|
175
|
+
const ct = plan.cardTrimPlan
|
|
176
|
+
if (ct.prims.length) {
|
|
177
|
+
lines.push(` ── CARDS ──`)
|
|
178
|
+
const tris = ct.prims.reduce((n, p) => n + p.triangles, 0)
|
|
179
|
+
const ready = ct.prims.filter((p) => p.texture === 'ok').length
|
|
180
|
+
if (ct.enabled) lines.push(` • trim ${ready} alpha-MASK primitive(s) (${tris} triangles) to their opaque texels, margin ${ct.margin} texel(s)${ready < ct.prims.length ? `; ${ct.prims.length - ready} skipped (${ct.prims.filter((p) => p.texture !== 'ok').map((p) => `${p.name}: ${p.texture}`).slice(0, 4).join(', ')})` : ''}`)
|
|
181
|
+
else lines.push(` ℹ ${ct.prims.length} alpha-MASK primitive(s), ${tris} triangles — foliage cards? --trim-cards cuts each quad to its opaque texels (less discarded fill)${ready < ct.prims.length ? `; ${ct.prims.length - ready} carry KTX2 / transformed / no textures and would be skipped` : ''}`)
|
|
182
|
+
}
|
|
183
|
+
const mg = plan.mergePlan
|
|
184
|
+
if (mg.groups.length || mg.skipped.length) {
|
|
185
|
+
lines.push(` ── MERGE ──`)
|
|
186
|
+
const fold = `${mg.primitivesBefore} primitive(s) over ${mg.materialsBefore} material(s) → ${mg.primitivesAfter} draw call(s)`
|
|
187
|
+
if (mg.enabled) {
|
|
188
|
+
lines.push(` • merge ${fold}`)
|
|
189
|
+
for (const g of mg.groups) lines.push(` ${g.label}: ${g.sources.length} primitives, ${g.materials.length} material(s)${g.atlas ? ` → 1 (atlas ${g.atlas.size}² ${g.atlas.maps.join('+')}, ${g.atlas.tiles.length} tiles${describeScale(g.atlas)}, pad ${g.atlas.pad})` : g.materials.length === 1 ? ` (kept: ${g.materials[0].getName() || 'material'}${g.note ? ` — ${g.note}` : ''})` : ''}${g.family === 'skinned' ? `, ${g.joints} joints in one skin` : ', transforms baked'}`)
|
|
190
|
+
if (mg.joints > plan.target) blocking.push(`bones merged skin has ${mg.joints} joints > ${plan.target} — exclude parts (--merge-exclude) or raise --target`)
|
|
191
|
+
} else if (mg.groups.length) lines.push(` ℹ ${fold} with --merge (one atlas material per alpha mode / sidedness)`)
|
|
192
|
+
if (mg.skipped.length) lines.push(` ${mg.enabled ? 'skipped' : 'would skip'}: ${mg.skipped.slice(0, 6).map((s) => `${s.name} (${s.reason})`).join(', ')}${mg.skipped.length > 6 ? '…' : ''}`)
|
|
193
|
+
}
|
|
194
|
+
const plainDups = plan.dupNames.filter((d) => !d.shadowsJoint)
|
|
195
|
+
if (plainDups.length) {
|
|
196
|
+
lines.push(` ── NAMES ──`)
|
|
197
|
+
lines.push(` ℹ ${plainDups.length} node name(s) used more than once (${plainDups.slice(0, 6).map((d) => `${d.name} ×${d.count}`).join(', ')}${plainDups.length > 6 ? '…' : ''}) — clips and node paths address the FIRST one`)
|
|
198
|
+
}
|
|
199
|
+
if (!lines.length) lines.push(' ✅ no issues — model is within all limits')
|
|
200
|
+
return { text: lines.join('\n'), lossless: lossless.length, lossy: lossy.length, blocking: blocking.length }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface ApplyOptions extends DoctorOptions { fixMetallic?: boolean; ktxEncoder?: KtxEncoder; progress?: (done: number, total: number) => void }
|
|
204
|
+
export interface ApplyResult { metallicZeroed: number; bareFixed: number; ktx2Encoded: number; scaled: number; unlit: number; emissiveCleared: number; lod: LodResult | null; cards: CardTrimResult | null; merge: MergeResult | null; emptied: number }
|
|
205
|
+
|
|
206
|
+
/** Apply a plan to the document (mutates). Resolves the KTX encoder itself when `ktx2` and none is given. */
|
|
207
|
+
export async function applyPlan(doc: Document, plan: DoctorPlan, opts: ApplyOptions = {}): Promise<ApplyResult> {
|
|
208
|
+
const maxSize = opts.maxSize ?? DEFAULT_MAX_SIZE
|
|
209
|
+
for (const s of plan.skinPlans) applySkinPlan(doc, s)
|
|
210
|
+
// After the skin plan: bone reduction rewrites joints/IBMs, and scaling the reduced set is both
|
|
211
|
+
// cheaper and keeps the IBM offsets it just wrote consistent with the geometry.
|
|
212
|
+
if (plan.scalePlan) applyScale(doc, plan.scalePlan.factor)
|
|
213
|
+
let metallicZeroed = 0
|
|
214
|
+
if (opts.fixMetallic && plan.metallicWarn.length) {
|
|
215
|
+
for (const w of plan.metallicWarn) w.mat.setMetallicFactor(0)
|
|
216
|
+
metallicZeroed = plan.metallicWarn.length
|
|
217
|
+
}
|
|
218
|
+
let bareFixed = 0
|
|
219
|
+
if (opts.fixMetallic && plan.bareCount) {
|
|
220
|
+
const plain = doc.createMaterial('Default').setBaseColorFactor([1, 1, 1, 1]).setMetallicFactor(0).setRoughnessFactor(1)
|
|
221
|
+
for (const mesh of doc.getRoot().listMeshes()) for (const prim of mesh.listPrimitives()) if (!prim.getMaterial()) { prim.setMaterial(plain); bareFixed++ }
|
|
222
|
+
}
|
|
223
|
+
const shading = applyShading(doc, plan.shadingPlan)
|
|
224
|
+
// Merge + atlas before every other geometry pass (they see the merged primitive) and before the texture
|
|
225
|
+
// passes: the atlases are new PNG textures, planned below like any other — capped by --atlas-size, not
|
|
226
|
+
// --max-size — while the plans of the textures the atlases replaced are dropped with them.
|
|
227
|
+
const merge = plan.mergePlan.enabled ? await applyMerge(doc, plan.mergePlan) : null
|
|
228
|
+
if (merge) {
|
|
229
|
+
const root = doc.getRoot()
|
|
230
|
+
const alive = new Set(root.listTextures())
|
|
231
|
+
plan.ktx2Plans = plan.ktx2Plans.filter((t) => alive.has(t.tex))
|
|
232
|
+
plan.texPlans = plan.texPlans.filter((t) => alive.has(t.tex))
|
|
233
|
+
const codecPolicy = opts.ktx2Uastc ? 'uastc' : opts.ktx2Hq ? 'hq' : 'default'
|
|
234
|
+
for (const tex of merge.textures) {
|
|
235
|
+
if (opts.ktx2) { const p = await planKtx2(tex, root, plan.mergePlan.atlasSize, codecPolicy); if (p) plan.ktx2Plans.push(p) }
|
|
236
|
+
else { const p = await planTexture(tex, root, plan.mergePlan.atlasSize); if (p) plan.texPlans.push(p) }
|
|
237
|
+
}
|
|
238
|
+
// …and the geometry passes are planned AGAIN: their plans were made in `analyzeDoc`, against primitives
|
|
239
|
+
// the merge has just disposed (a plan that kept one died on it: `pos.getCount` of null).
|
|
240
|
+
plan.lodPlan = planLod(doc, opts)
|
|
241
|
+
plan.cardTrimPlan = planCardTrim(doc, opts)
|
|
242
|
+
}
|
|
243
|
+
// LOD levels after every geometry pass (they copy the final base mesh) and before textures.
|
|
244
|
+
const lod = plan.lodPlan ? await applyLod(doc, plan.lodPlan) : null
|
|
245
|
+
// Card trimming before the texture passes: it decodes the base colour (PNG / JPEG), which a KTX2
|
|
246
|
+
// encode below would take away.
|
|
247
|
+
const cards = plan.cardTrimPlan.enabled ? await applyCardTrim(doc, plan.cardTrimPlan) : null
|
|
248
|
+
let ktx2Encoded = 0
|
|
249
|
+
if (opts.ktx2) {
|
|
250
|
+
const enc = opts.ktxEncoder ?? findKtxEncoder()
|
|
251
|
+
if (!enc) throw new Error(KTX_ENCODER_MISSING)
|
|
252
|
+
// Launched together, not one after another: `applyKtx2` bounds the real concurrency with its own
|
|
253
|
+
// pool of encoder processes (LECODES_KTX_JOBS, default one per core less one). Encoding used to be
|
|
254
|
+
// strictly sequential — a `spawnSync` per texture blocked the event loop — so a project's whole
|
|
255
|
+
// texture set went through one core however many were idle.
|
|
256
|
+
await Promise.all(plan.ktx2Plans.map(async (t) => {
|
|
257
|
+
await applyKtx2(t, enc, maxSize)
|
|
258
|
+
opts.progress?.(++ktx2Encoded, plan.ktx2Plans.length)
|
|
259
|
+
}))
|
|
260
|
+
// KTX2 textures must be written via KHR_texture_basisu (core texture.source allows only PNG/JPEG);
|
|
261
|
+
// gltf-transform only emits that form when the extension is attached to the document.
|
|
262
|
+
if (plan.ktx2Plans.length && !doc.getRoot().listExtensionsUsed().some((e) => e.extensionName === 'KHR_texture_basisu')) {
|
|
263
|
+
doc.createExtension(KHRTextureBasisu).setRequired(true)
|
|
264
|
+
}
|
|
265
|
+
} else {
|
|
266
|
+
for (const t of plan.texPlans) if (t.toFmt) await applyTexturePlan(t, maxSize)
|
|
267
|
+
}
|
|
268
|
+
// Once no texture is webp anymore, drop EXT_texture_webp — else it lingers in extensionsRequired
|
|
269
|
+
// and a strict loader (gltfio) can reject the file for a now-unused requirement.
|
|
270
|
+
if (!doc.getRoot().listTextures().some((t) => (t.getMimeType() || '').includes('webp'))) {
|
|
271
|
+
for (const ext of doc.getRoot().listExtensionsUsed()) {
|
|
272
|
+
if (ext.extensionName === 'EXT_texture_webp') ext.dispose()
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// LAST, after every pass that can eat geometry: a primitive with no vertices left is not a
|
|
276
|
+
// primitive. Card trimming produces them — it cuts an alpha-MASK quad down to its opaque texels,
|
|
277
|
+
// and the Modular Shooting Range's floor stripes have LOD levels whose texels are all clear, so
|
|
278
|
+
// the level came out carrying five empty meshes. That is not a cosmetic problem: filament's
|
|
279
|
+
// gltfio walks the primitive in `ResourceLoader::loadResources` and takes the host down with an
|
|
280
|
+
// access violation before the first frame.
|
|
281
|
+
let emptied = 0
|
|
282
|
+
for (const mesh of doc.getRoot().listMeshes()) {
|
|
283
|
+
for (const prim of mesh.listPrimitives()) {
|
|
284
|
+
if ((prim.getAttribute("POSITION")?.getCount() ?? 0) > 0) continue
|
|
285
|
+
mesh.removePrimitive(prim)
|
|
286
|
+
prim.dispose()
|
|
287
|
+
emptied++
|
|
288
|
+
}
|
|
289
|
+
if (!mesh.listPrimitives().length) mesh.dispose()
|
|
290
|
+
}
|
|
291
|
+
return { metallicZeroed, bareFixed, ktx2Encoded, scaled: plan.scalePlan?.factor ?? 1, unlit: shading.unlit, emissiveCleared: shading.emissiveCleared, lod, cards, merge, emptied }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Output path for a doctored file: single file + -o → exact path; -o as dir (or batch) → derived name inside it. */
|
|
295
|
+
export function outputFor(input: string, oVal: string | null, batch: boolean, ktx2: boolean): string {
|
|
296
|
+
const derived = input.replace(/\.glb$/i, ktx2 ? '.ktx2.glb' : '.fixed.glb')
|
|
297
|
+
if (!oVal) return derived
|
|
298
|
+
const oNorm = oVal.replace(/\\/g, '/')
|
|
299
|
+
const asDir = batch || /\/$/.test(oNorm) || (existsSync(oNorm) && statSync(oNorm).isDirectory())
|
|
300
|
+
if (asDir) {
|
|
301
|
+
if (!existsSync(oNorm)) mkdirSync(oNorm, { recursive: true })
|
|
302
|
+
return join(oNorm, derived.split(/[\\/]/).pop()!)
|
|
303
|
+
}
|
|
304
|
+
return oVal
|
|
305
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Mesh + skeleton LOD levels for creator-gl's LOD pass (docs/lod-plan.md §5).
|
|
2
|
+
//
|
|
3
|
+
// --lod 0.5,0.2 every mesh node with ≥ MIN_TRIS triangles gets sibling nodes `<name>_LOD1`,
|
|
4
|
+
// `<name>_LOD2` … (same parent / transform / skin / materials) whose primitives are
|
|
5
|
+
// meshoptimizer-simplified copies at the given ratios of the ORIGINAL triangle count.
|
|
6
|
+
// Skinned primitives keep JOINTS/WEIGHTS (the simplifier only collapses vertices), so
|
|
7
|
+
// every level rides the same skin. Morph targets are not carried to the levels.
|
|
8
|
+
// --lod-bones the LAST level of a skinned node also gets a reduced SKIN: vertices weighted to
|
|
9
|
+
// finger / toe / twist / leaf joints are re-weighted onto their nearest kept ancestor
|
|
10
|
+
// and the level references a new Skin over the kept joints only — the engine
|
|
11
|
+
// uploads a third of the bones for a far character.
|
|
12
|
+
//
|
|
13
|
+
// The engine groups levels by the `_LOD<n>` name (creator-gl src/lod.cpp) and shows one at a time; a
|
|
14
|
+
// viewer without LOD support draws them all — harmless. Extras carry the bookkeeping for probes.
|
|
15
|
+
|
|
16
|
+
import type { Accessor, Document, Mesh, Node as GltfNode, Primitive, Skin } from '@gltf-transform/core'
|
|
17
|
+
import { simplifyPrimitive, weldPrimitive } from '@gltf-transform/functions'
|
|
18
|
+
import { MeshoptSimplifier } from 'meshoptimizer'
|
|
19
|
+
|
|
20
|
+
export interface LodOptions { lod?: number[]; lodBones?: boolean }
|
|
21
|
+
export interface LodNodePlan { node: GltfNode; name: string; tris: number; skinned: boolean }
|
|
22
|
+
export interface LodPlan { ratios: number[]; bones: boolean; nodes: LodNodePlan[]; existing: number }
|
|
23
|
+
export interface LodResult { levels: number; nodes: number; tris: { name: string; levels: number[] }[]; bones: { name: string; before: number; after: number }[]; skipped: { name: string; level: number; tris: number }[] }
|
|
24
|
+
|
|
25
|
+
/** Meshes under this go without levels — a crate does not need one. */
|
|
26
|
+
export const LOD_MIN_TRIS = 2000
|
|
27
|
+
/** Joints a far character stops animating / weighting — the same vocabulary as creator-gl's joint mask
|
|
28
|
+
* (animation/animator.cpp lodMaskedName) and the retargeter's helper classes. Keep them in step. */
|
|
29
|
+
export const LOD_JOINT_MASK = /finger|thumb|index|middle|ring|pinky|toe|twist|roll|leaf|_end$/i
|
|
30
|
+
const LOD_NAME = /_LOD\d+$/
|
|
31
|
+
|
|
32
|
+
const shortName = (s: string): string => { const p = Math.max(s.lastIndexOf(':'), s.lastIndexOf('|')); return p < 0 ? s : s.slice(p + 1) }
|
|
33
|
+
|
|
34
|
+
const primTris = (p: Primitive): number => {
|
|
35
|
+
if (p.getMode() !== 4) return 0 // TRIANGLES only
|
|
36
|
+
const idx = p.getIndices()
|
|
37
|
+
const n = idx ? idx.getCount() : (p.getAttribute('POSITION')?.getCount() ?? 0)
|
|
38
|
+
return Math.floor(n / 3)
|
|
39
|
+
}
|
|
40
|
+
const meshTris = (m: Mesh): number => m.listPrimitives().reduce((s, p) => s + primTris(p), 0)
|
|
41
|
+
|
|
42
|
+
export function planLod(doc: Document, opts: LodOptions): LodPlan | null {
|
|
43
|
+
const ratios = (opts.lod ?? []).filter((r) => r > 0 && r < 1).sort((a, b) => b - a)
|
|
44
|
+
if (!ratios.length) return null
|
|
45
|
+
const nodes: LodNodePlan[] = []
|
|
46
|
+
let existing = 0
|
|
47
|
+
for (const node of doc.getRoot().listNodes()) {
|
|
48
|
+
const mesh = node.getMesh()
|
|
49
|
+
if (!mesh) continue
|
|
50
|
+
if (LOD_NAME.test(node.getName())) { existing++; continue }
|
|
51
|
+
const tris = meshTris(mesh)
|
|
52
|
+
if (tris < LOD_MIN_TRIS) continue
|
|
53
|
+
nodes.push({ node, name: node.getName() || mesh.getName() || `mesh${nodes.length}`, tris, skinned: !!node.getSkin() })
|
|
54
|
+
}
|
|
55
|
+
return { ratios, bones: !!opts.lodBones, nodes, existing }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A deep copy of a primitive's geometry (own accessors — simplify rewrites them in place), sharing the material. */
|
|
59
|
+
const clonePrimitive = (doc: Document, prim: Primitive): Primitive => {
|
|
60
|
+
const buffer = doc.getRoot().listBuffers()[0] ?? doc.createBuffer()
|
|
61
|
+
const cloneAcc = (a: Accessor): Accessor => doc.createAccessor(a.getName(), buffer).setType(a.getType()).setNormalized(a.getNormalized()).setArray(a.getArray()!.slice())
|
|
62
|
+
const out = doc.createPrimitive().setMode(prim.getMode()).setMaterial(prim.getMaterial())
|
|
63
|
+
for (const sem of prim.listSemantics()) out.setAttribute(sem, cloneAcc(prim.getAttribute(sem)!))
|
|
64
|
+
const idx = prim.getIndices()
|
|
65
|
+
if (idx) out.setIndices(cloneAcc(idx))
|
|
66
|
+
return out
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The last level of a skinned node: masked joints fold onto their nearest kept ancestor, on a new Skin over the kept joints. */
|
|
70
|
+
const reduceSkinForLod = (doc: Document, lodNode: GltfNode): { before: number; after: number } | null => {
|
|
71
|
+
const skin = lodNode.getSkin()
|
|
72
|
+
const mesh = lodNode.getMesh()
|
|
73
|
+
if (!skin || !mesh) return null
|
|
74
|
+
const joints = skin.listJoints()
|
|
75
|
+
const removed = new Set<number>()
|
|
76
|
+
joints.forEach((j, i) => { if (LOD_JOINT_MASK.test(shortName(j.getName()))) removed.add(i) })
|
|
77
|
+
if (!removed.size) return null
|
|
78
|
+
const idxOf = new Map<GltfNode, number>(joints.map((j, i) => [j, i]))
|
|
79
|
+
const remap = joints.map((j, i) => {
|
|
80
|
+
if (!removed.has(i)) return i
|
|
81
|
+
for (let p = j.getParentNode(); p; p = p.getParentNode()) { const k = idxOf.get(p); if (k !== undefined && !removed.has(k)) return k }
|
|
82
|
+
return i // no kept ancestor: keep the joint
|
|
83
|
+
})
|
|
84
|
+
const kept = [...new Set(remap)].sort((a, b) => a - b)
|
|
85
|
+
const newIndex = new Map<number, number>(kept.map((old, k) => [old, k]))
|
|
86
|
+
const buffer = doc.getRoot().listBuffers()[0] ?? doc.createBuffer()
|
|
87
|
+
const newSkin: Skin = doc.createSkin(`${skin.getName() || 'skin'}_LOD`)
|
|
88
|
+
if (skin.getSkeleton()) newSkin.setSkeleton(skin.getSkeleton())
|
|
89
|
+
for (const i of kept) newSkin.addJoint(joints[i]!)
|
|
90
|
+
const ibm = skin.getInverseBindMatrices()
|
|
91
|
+
if (ibm) {
|
|
92
|
+
const src = ibm.getArray()!
|
|
93
|
+
const out = new Float32Array(kept.length * 16)
|
|
94
|
+
kept.forEach((i, k) => out.set(src.subarray(i * 16, i * 16 + 16), k * 16))
|
|
95
|
+
newSkin.setInverseBindMatrices(doc.createAccessor(`${ibm.getName() || 'ibm'}_LOD`, buffer).setType('MAT4').setArray(out))
|
|
96
|
+
}
|
|
97
|
+
const jv: number[] = [], wv: number[] = []
|
|
98
|
+
for (const prim of mesh.listPrimitives()) {
|
|
99
|
+
// every set is gathered per vertex, folded by target joint, then written back set by set (largest first)
|
|
100
|
+
const sets: { j: Accessor; w: Accessor }[] = []
|
|
101
|
+
for (let s = 0; ; s++) { const j = prim.getAttribute(`JOINTS_${s}`), w = prim.getAttribute(`WEIGHTS_${s}`); if (!j || !w) break; sets.push({ j, w }) }
|
|
102
|
+
if (!sets.length) continue
|
|
103
|
+
const count = sets[0]!.j.getCount()
|
|
104
|
+
const folded = new Map<number, number>()
|
|
105
|
+
for (let v = 0; v < count; v++) {
|
|
106
|
+
folded.clear()
|
|
107
|
+
for (const { j, w } of sets) {
|
|
108
|
+
j.getElement(v, jv); w.getElement(v, wv)
|
|
109
|
+
for (let k = 0; k < 4; k++) {
|
|
110
|
+
const weight = wv[k] ?? 0
|
|
111
|
+
if (weight <= 0) continue
|
|
112
|
+
const target = newIndex.get(remap[jv[k]!] ?? jv[k]!) ?? 0
|
|
113
|
+
folded.set(target, (folded.get(target) ?? 0) + weight)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const pairs = [...folded].sort((a, b) => b[1] - a[1])
|
|
117
|
+
let n = 0
|
|
118
|
+
for (const { j, w } of sets) {
|
|
119
|
+
for (let k = 0; k < 4; k++) { const p = pairs[n++]; jv[k] = p ? p[0] : 0; wv[k] = p ? p[1] : 0 }
|
|
120
|
+
j.setElement(v, jv); w.setElement(v, wv)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
lodNode.setSkin(newSkin)
|
|
125
|
+
return { before: joints.length, after: kept.length }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function applyLod(doc: Document, plan: LodPlan): Promise<LodResult> {
|
|
129
|
+
await MeshoptSimplifier.ready
|
|
130
|
+
const result: LodResult = { levels: plan.ratios.length, nodes: 0, tris: [], bones: [], skipped: [] }
|
|
131
|
+
for (const entry of plan.nodes) {
|
|
132
|
+
const { node } = entry
|
|
133
|
+
const baseMesh = node.getMesh()!
|
|
134
|
+
const parent = node.getParentNode()
|
|
135
|
+
const scenes = parent ? [] : doc.getRoot().listScenes().filter((s) => s.listChildren().includes(node))
|
|
136
|
+
const baseExtras = (node.getExtras() ?? {}) as Record<string, unknown>
|
|
137
|
+
const baseLecodes = (baseExtras.lecodes ?? {}) as Record<string, unknown>
|
|
138
|
+
node.setExtras({ ...baseExtras, lecodes: { ...baseLecodes, lod: { level: 0, levels: plan.ratios.length + 1, tris: entry.tris } } })
|
|
139
|
+
const levels: number[] = [entry.tris]
|
|
140
|
+
for (let i = 0; i < plan.ratios.length; i++) {
|
|
141
|
+
const level = i + 1
|
|
142
|
+
const ratio = plan.ratios[i]!
|
|
143
|
+
const mesh = doc.createMesh(`${baseMesh.getName() || entry.name}_LOD${level}`)
|
|
144
|
+
let tris = 0
|
|
145
|
+
for (const prim of baseMesh.listPrimitives()) {
|
|
146
|
+
if (prim.getMode() !== 4) continue
|
|
147
|
+
let copy = clonePrimitive(doc, prim)
|
|
148
|
+
weldPrimitive(copy)
|
|
149
|
+
copy = simplifyPrimitive(copy, { simplifier: MeshoptSimplifier, ratio, error: 0.02, lockBorder: false })
|
|
150
|
+
tris += primTris(copy)
|
|
151
|
+
mesh.addPrimitive(copy)
|
|
152
|
+
}
|
|
153
|
+
// A level the simplifier could not shrink (hard-surface meshes with many seams stop early) is not
|
|
154
|
+
// worth its bytes or its draw: drop it rather than ship a near-duplicate.
|
|
155
|
+
if (tris > levels[levels.length - 1]! * 0.85) {
|
|
156
|
+
for (const p of mesh.listPrimitives()) { for (const sem of p.listSemantics()) p.getAttribute(sem)?.dispose(); p.getIndices()?.dispose(); p.dispose() }
|
|
157
|
+
mesh.dispose()
|
|
158
|
+
result.skipped.push({ name: entry.name, level, tris })
|
|
159
|
+
break
|
|
160
|
+
}
|
|
161
|
+
const lodNode = doc.createNode(`${node.getName() || entry.name}_LOD${level}`).setMesh(mesh)
|
|
162
|
+
.setTranslation(node.getTranslation()).setRotation(node.getRotation()).setScale(node.getScale())
|
|
163
|
+
if (node.getSkin()) lodNode.setSkin(node.getSkin())
|
|
164
|
+
lodNode.setExtras({ lecodes: { lod: { level, ratio, tris } } })
|
|
165
|
+
if (parent) parent.addChild(lodNode)
|
|
166
|
+
else for (const s of scenes) s.addChild(lodNode)
|
|
167
|
+
levels.push(tris)
|
|
168
|
+
if (plan.bones && i === plan.ratios.length - 1 && node.getSkin()) {
|
|
169
|
+
const r = reduceSkinForLod(doc, lodNode)
|
|
170
|
+
if (r) result.bones.push({ name: entry.name, ...r })
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
result.nodes++
|
|
174
|
+
result.tris.push({ name: entry.name, levels })
|
|
175
|
+
}
|
|
176
|
+
return result
|
|
177
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Accessor reads as flat typed arrays.
|
|
2
|
+
|
|
3
|
+
import type { Accessor, Primitive } from '@gltf-transform/core'
|
|
4
|
+
|
|
5
|
+
export const readAttr = (acc: Accessor): { size: number; data: Float32Array } => {
|
|
6
|
+
const size = acc.getElementSize(), n = acc.getCount()
|
|
7
|
+
const data = new Float32Array(n * size)
|
|
8
|
+
const el = new Array<number>(size)
|
|
9
|
+
for (let i = 0; i < n; i++) { acc.getElement(i, el); for (let k = 0; k < size; k++) data[i * size + k] = el[k] }
|
|
10
|
+
return { size, data }
|
|
11
|
+
}
|
|
12
|
+
export const readIndices = (p: Primitive): Uint32Array => {
|
|
13
|
+
const idx = p.getIndices()
|
|
14
|
+
if (idx) return Uint32Array.from(idx.getArray() as ArrayLike<number>)
|
|
15
|
+
const n = p.getAttribute('POSITION')!.getCount()
|
|
16
|
+
return Uint32Array.from({ length: n }, (_, i) => i)
|
|
17
|
+
}
|
|
18
|
+
/** Area-weighted vertex normals for a primitive that has none. */
|
|
19
|
+
export function flatNormals(pos: Float32Array, idx: Uint32Array): Float32Array {
|
|
20
|
+
const n = new Float32Array(pos.length)
|
|
21
|
+
for (let t = 0; t + 2 < idx.length; t += 3) {
|
|
22
|
+
const a = idx[t] * 3, b = idx[t + 1] * 3, c = idx[t + 2] * 3
|
|
23
|
+
const ux = pos[b] - pos[a], uy = pos[b + 1] - pos[a + 1], uz = pos[b + 2] - pos[a + 2]
|
|
24
|
+
const vx = pos[c] - pos[a], vy = pos[c + 1] - pos[a + 1], vz = pos[c + 2] - pos[a + 2]
|
|
25
|
+
const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx
|
|
26
|
+
for (const i of [ a, b, c ]) { n[i] += nx; n[i + 1] += ny; n[i + 2] += nz }
|
|
27
|
+
}
|
|
28
|
+
for (let i = 0; i < n.length; i += 3) { const l = Math.hypot(n[i], n[i + 1], n[i + 2]) || 1; n[i] /= l; n[i + 1] /= l; n[i + 2] /= l }
|
|
29
|
+
return n
|
|
30
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Atlas layout: one tile per distinct material look, shelf-packed into a power-of-two square, scaled
|
|
2
|
+
// down BY SURFACE AREA (equal texel density) when the cap is hit.
|
|
3
|
+
|
|
4
|
+
import type { Material, Texture } from '@gltf-transform/core'
|
|
5
|
+
import type { MatInfo } from './classify'
|
|
6
|
+
import type { AtlasLayout, MapKind, Tile } from './types'
|
|
7
|
+
|
|
8
|
+
function shelfPack(items: { w: number; h: number }[], S: number): { x: number; y: number }[] | null {
|
|
9
|
+
const order = items.map((_, i) => i).sort((a, b) => items[b].h - items[a].h || items[b].w - items[a].w)
|
|
10
|
+
const pos: { x: number; y: number }[] = new Array(items.length)
|
|
11
|
+
let x = 0, y = 0, rowH = 0
|
|
12
|
+
for (const i of order) {
|
|
13
|
+
const { w, h } = items[i]
|
|
14
|
+
if (w > S || h > S) return null
|
|
15
|
+
if (x + w > S) { y += rowH; x = 0; rowH = 0 }
|
|
16
|
+
if (y + h > S) return null
|
|
17
|
+
pos[i] = { x, y }; x += w; rowH = Math.max(rowH, h)
|
|
18
|
+
}
|
|
19
|
+
return pos
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Tile scales for a texel-density budget `D` (texels² per m² of surface): the tile's used texels
|
|
23
|
+
* (edge² × UV coverage) per square metre of the material's surface reach D, capped at the native map. */
|
|
24
|
+
const scalesForDensity = (tiles: Tile[], D: number): number[] => tiles.map((t) => {
|
|
25
|
+
const uv = Math.max(0.02, t.uvArea), area = Math.max(1e-6, t.area)
|
|
26
|
+
return Math.min(1, Math.sqrt(D * area / uv / (t.srcW * t.srcH)))
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
function layoutTiles(tiles: Tile[], atlasSize: number, pad: number, uniform: boolean): { size: number; scale: number; scaleMax: number } | null {
|
|
30
|
+
const place = (S: number, scales: number[]): boolean => {
|
|
31
|
+
const items = tiles.map((t, i) => ({ w: Math.max(4, Math.round(t.srcW * scales[i])) + 2 * pad, h: Math.max(4, Math.round(t.srcH * scales[i])) + 2 * pad }))
|
|
32
|
+
const pos = shelfPack(items, S)
|
|
33
|
+
if (!pos) return false
|
|
34
|
+
tiles.forEach((t, i) => { t.w = items[i].w - 2 * pad; t.h = items[i].h - 2 * pad; t.x = pos[i].x + pad; t.y = pos[i].y + pad; t.scale = t.w / t.srcW })
|
|
35
|
+
return true
|
|
36
|
+
}
|
|
37
|
+
const ones = tiles.map(() => 1)
|
|
38
|
+
const done = (S: number) => ({ size: S, scale: Math.min(...tiles.map((t) => t.scale)), scaleMax: Math.max(...tiles.map((t) => t.scale)) })
|
|
39
|
+
// the smallest power-of-two square that holds everything at full resolution …
|
|
40
|
+
for (let S = 64; S <= atlasSize; S *= 2) if (place(S, ones)) return done(S)
|
|
41
|
+
if (place(atlasSize, ones)) return done(atlasSize)
|
|
42
|
+
// … else the cap, with the tiles scaled down as little as possible: one factor for all, or a texel-density
|
|
43
|
+
// budget that gives every part the same texels per metre (the density at which the densest part is native
|
|
44
|
+
// is the upper bound — above it nothing changes)
|
|
45
|
+
const scalesAt = uniform ? (x: number) => tiles.map(() => x) : (x: number) => scalesForDensity(tiles, x)
|
|
46
|
+
let lo = 0, hi = uniform ? 1 : Math.max(...tiles.map((t) => t.srcW * t.srcH * Math.max(0.02, t.uvArea) / Math.max(1e-6, t.area)))
|
|
47
|
+
for (let i = 0; i < 40; i++) { const mid = (lo + hi) / 2; if (place(atlasSize, scalesAt(mid))) lo = mid; else hi = mid }
|
|
48
|
+
if (lo === 0 || !place(atlasSize, scalesAt(lo))) return null
|
|
49
|
+
return done(atlasSize)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function tileKey(info: MatInfo, texId: (t: Texture) => string): string {
|
|
53
|
+
const m = info.material
|
|
54
|
+
const id = (t?: Texture) => (t ? texId(t) : '-')
|
|
55
|
+
return [
|
|
56
|
+
id(info.maps.base), m.getBaseColorFactor().map((v) => v.toFixed(4)).join(','),
|
|
57
|
+
id(info.maps.normal), m.getNormalScale().toFixed(3),
|
|
58
|
+
id(info.maps.mr), id(info.maps.occ), m.getRoughnessFactor().toFixed(3), m.getMetallicFactor().toFixed(3), m.getOcclusionStrength().toFixed(3),
|
|
59
|
+
id(info.maps.emissive), m.getEmissiveFactor().map((v) => v.toFixed(4)).join(','),
|
|
60
|
+
info.uv ? `${info.uv.u0},${info.uv.v0},${info.uv.ru},${info.uv.rv}` : '-',
|
|
61
|
+
].join('|')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function planAtlas(infos: MatInfo[], atlasSize: number, pad: number, uniform: boolean, texId: (t: Texture) => string): { layout: AtlasLayout | null; reason?: string } {
|
|
65
|
+
const anyTex = (k: keyof MatInfo['maps']) => infos.some((i) => i.maps[k])
|
|
66
|
+
const differ = (f: (m: Material) => number[]) => { const first = f(infos[0].material); return infos.some((i) => f(i.material).some((v, k) => Math.abs(v - first[k]) > 1e-4)) }
|
|
67
|
+
const maps: MapKind[] = []
|
|
68
|
+
if (anyTex('base') || differ((m) => m.getBaseColorFactor())) maps.push('base')
|
|
69
|
+
if (anyTex('normal')) maps.push('normal')
|
|
70
|
+
if (anyTex('mr') || anyTex('occ') || differ((m) => [ m.getRoughnessFactor(), m.getMetallicFactor() ])) maps.push('orm')
|
|
71
|
+
if (anyTex('emissive') || infos.some((i) => i.material.getEmissiveFactor().some((v) => v > 0))) maps.push('emissive')
|
|
72
|
+
const byKey = new Map<string, Tile>()
|
|
73
|
+
for (const info of infos) {
|
|
74
|
+
const key = tileKey(info, texId)
|
|
75
|
+
let t = byKey.get(key)
|
|
76
|
+
if (!t) {
|
|
77
|
+
const dims = Object.values(info.sizes)
|
|
78
|
+
const uv = info.uv ?? { u0: 0, v0: 0, ru: 1, rv: 1 }
|
|
79
|
+
const maxW = dims.length ? Math.max(...dims.map((d) => d[0])) : 8
|
|
80
|
+
const maxH = dims.length ? Math.max(...dims.map((d) => d[1])) : 8
|
|
81
|
+
const cap = atlasSize - 2 * pad
|
|
82
|
+
t = { key, materials: [], srcW: Math.min(cap, maxW * uv.ru), srcH: Math.min(cap, maxH * uv.rv), ...uv, area: 0, uvArea: 0, w: 0, h: 0, x: 0, y: 0, scale: 1 }
|
|
83
|
+
byKey.set(key, t)
|
|
84
|
+
}
|
|
85
|
+
t.materials.push(info.material)
|
|
86
|
+
t.area += info.area
|
|
87
|
+
t.uvArea = Math.min(1, t.uvArea + info.uvArea)
|
|
88
|
+
}
|
|
89
|
+
const tiles = [ ...byKey.values() ]
|
|
90
|
+
const fit = layoutTiles(tiles, atlasSize, pad, uniform)
|
|
91
|
+
if (!fit) return { layout: null, reason: `${tiles.length} tiles do not fit a ${atlasSize}² atlas (raise --atlas-size or lower --atlas-pad)` }
|
|
92
|
+
const hasAlpha = infos[0].material.getAlphaMode() !== 'OPAQUE'
|
|
93
|
+
return { layout: { size: fit.size, scale: fit.scale, scaleMax: fit.scaleMax, byArea: !uniform, pad, maps, tiles, hasAlpha } }
|
|
94
|
+
}
|