lecodes-assets 0.2.1 → 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 +203 -26
- package/dist/cli.js +17948 -11911
- package/dist/index.js +17525 -11479
- package/package.json +3 -1
- 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 -1039
- 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 +94 -30
- 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/transcodeKtx2.ts +179 -28
- package/src/doctor/validateGlb.ts +2 -2
- package/src/doctor/vertexAo.ts +321 -0
- package/src/fbx/read.ts +2 -0
- package/src/fbx/toDocument.ts +227 -209
- package/src/geometry.ts +222 -0
- package/src/index.ts +2 -0
- package/src/retarget/heading.ts +238 -171
- package/src/retarget/humanoid.ts +572 -561
- package/src/retarget/retarget.ts +2375 -2272
- package/src/retarget/rig.ts +4 -1
- package/src/retarget/writeMarks.ts +134 -132
- package/src/sky/cmgen.ts +3 -3
- package/src/sky/index.ts +57 -8
- package/src/terrainPack.ts +61 -22
- 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 +34 -429
- package/src/unity/math.ts +82 -0
- package/src/unity/prefabTree.ts +285 -0
- package/src/unity/prefabs.ts +10 -36
- package/src/unity/propertyPath.ts +43 -0
- package/src/unity/scene.ts +325 -304
- 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 +24 -5
- package/src/unity/yaml.ts +131 -0
- package/src/unreal/index.ts +6 -5
- package/src/unreal/materialGraph.ts +224 -0
- package/src/unreal/materials.ts +117 -9
- package/src/unreal/package.ts +30 -12
- package/src/unreal/resolve.ts +6 -4
- package/src/unreal/sound.ts +128 -0
- package/src/unreal/texture.ts +70 -3
- package/src/unreal/toDocument.ts +7 -0
- package/src/unreal/versions.ts +8 -0
- package/src/unwrap/index.ts +167 -0
- package/src/unwrap/lightmapUv.ts +761 -0
- package/src/wasm/fbxReader.js +1 -1
- package/src/wasm/lightmapUv.js +1 -1
- package/src/doctor/lightmapUv.ts +0 -452
package/src/doctor/doctor.ts
CHANGED
|
@@ -6,26 +6,27 @@
|
|
|
6
6
|
// unbound joints / re-encoding webp→png) vs LOSSY fixes (bound-joint removal,
|
|
7
7
|
// webp→jpg, downscaling, heavy-png→jpg).
|
|
8
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
|
|
9
|
+
// KTX2 : textures → KTX2 (ETC1S; --ktx2-hq keeps normals UASTC) via KTX-Software (toktx, or `ktx create` where toktx is gone).
|
|
10
10
|
import { gatherSkinPrims, planSkin, applySkinPlan, DEFAULT_TARGET, type SkinPlan } from './reduceGlbBones'
|
|
11
11
|
import { planTexture, applyTexturePlan, getTextureRoles, DEFAULT_MAX_SIZE, type TexturePlan } from './transcodeTextures'
|
|
12
|
-
import { planKtx2, applyKtx2,
|
|
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 {
|
|
15
|
+
import { planCardTrim, applyCardTrim, type CardTrimPlan, type CardTrimOptions, type CardTrimResult } from './cardTrim'
|
|
16
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'
|
|
17
18
|
import { duplicateNodeNames } from '../mergeSkins'
|
|
18
19
|
import { KHRTextureBasisu } from '@gltf-transform/extensions'
|
|
19
20
|
import type { Document, Material } from '@gltf-transform/core'
|
|
20
21
|
import { mkdirSync, existsSync, statSync, writeFileSync } from 'node:fs'
|
|
21
22
|
import { join } from 'node:path'
|
|
22
23
|
|
|
23
|
-
export { DEFAULT_TARGET, DEFAULT_MAX_SIZE,
|
|
24
|
+
export { DEFAULT_TARGET, DEFAULT_MAX_SIZE, findKtxEncoder, KTX_ENCODER_MISSING }
|
|
24
25
|
export { sceneBounds, measureScale, applyScale } from './normalizeScale'
|
|
25
26
|
|
|
26
27
|
interface MetallicWarn { mat: Material; name: string; metal: number }
|
|
27
|
-
export interface DoctorPlan { skinPlans: SkinPlan[]; texPlans: TexturePlan[]; ktx2Plans: Ktx2Plan[]; metallicWarn: MetallicWarn[];
|
|
28
|
-
export interface DoctorOptions extends ScaleOptions, ShadingOptions,
|
|
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 }
|
|
29
30
|
|
|
30
31
|
export async function analyzeDoc(doc: Document, opts: DoctorOptions = {}): Promise<DoctorPlan> {
|
|
31
32
|
const target = opts.target ?? DEFAULT_TARGET
|
|
@@ -50,9 +51,12 @@ export async function analyzeDoc(doc: Document, opts: DoctorOptions = {}): Promi
|
|
|
50
51
|
const metallicWarn: MetallicWarn[] = root.listMaterials()
|
|
51
52
|
.filter((m) => m.getMetallicFactor() > 0.01 && !m.getMetallicRoughnessTexture())
|
|
52
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++
|
|
53
59
|
const shadingPlan = analyzeShading(doc, opts)
|
|
54
|
-
// Lightmap UVs are only ever ADDED on request (--lightmap-uv); the analysis just says what would be unwrapped.
|
|
55
|
-
const lightmapPlan = analyzeLightmapUv(doc, opts)
|
|
56
60
|
// Scale is always MEASURED (probe/report it even with no flag); it is only APPLIED when asked.
|
|
57
61
|
const scaleInfo = measureScale(doc)
|
|
58
62
|
const scalePlan = planScale(scaleInfo, opts)
|
|
@@ -61,7 +65,11 @@ export async function analyzeDoc(doc: Document, opts: DoctorOptions = {}): Promi
|
|
|
61
65
|
const dupNames = duplicateNodeNames(doc)
|
|
62
66
|
// LOD levels are only ever ADDED on request (--lod); see doctor/lod.ts.
|
|
63
67
|
const lodPlan = planLod(doc, opts)
|
|
64
|
-
|
|
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 }
|
|
65
73
|
}
|
|
66
74
|
|
|
67
75
|
export const kb = (n?: number) => (n === undefined ? '?' : `${Math.round(n / 1024)}KB`)
|
|
@@ -137,15 +145,16 @@ export function reportPlan(plan: DoctorPlan): { text: string; lossless: number;
|
|
|
137
145
|
if (lossless.length) { lines.push(' ── LOSSLESS (no quality loss) ──'); lossless.forEach((l) => lines.push(' • ' + l)) }
|
|
138
146
|
if (lossy.length) { lines.push(' ── LOSSY (some degradation) ──'); lossy.forEach((l) => lines.push(' • ' + l)) }
|
|
139
147
|
if (blocking.length) { lines.push(' ── BLOCKING / manual ──'); blocking.forEach((l) => lines.push(' ✗ ' + l)) }
|
|
148
|
+
if (plan.metallicWarn.length || plan.bareCount) lines.push(` ── MATERIALS ──`)
|
|
140
149
|
if (plan.metallicWarn.length) {
|
|
141
|
-
lines.push(` ── MATERIALS ──`)
|
|
142
150
|
lines.push(` ℹ ${plan.metallicWarn.length} material(s) have metallic without an MR texture → may render BLACK without IBL (--fix-metallic zeroes them):`)
|
|
143
151
|
lines.push(` ${plan.metallicWarn.map((w) => `${w.name}(${w.metal.toFixed(2)})`).join(', ')}`)
|
|
144
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)`)
|
|
145
154
|
const selfLit = plan.shadingPlan.issues.filter((i) => i.selfLit)
|
|
146
155
|
const boosted = plan.shadingPlan.issues.filter((i) => i.specularBoost > 0)
|
|
147
156
|
if (selfLit.length || boosted.length) {
|
|
148
|
-
if (!plan.metallicWarn.length) lines.push(` ── MATERIALS ──`)
|
|
157
|
+
if (!plan.metallicWarn.length && !plan.bareCount) lines.push(` ── MATERIALS ──`)
|
|
149
158
|
if (selfLit.length) {
|
|
150
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):`)
|
|
151
160
|
lines.push(` ${selfLit.map((i) => i.name).join(', ')}`)
|
|
@@ -163,14 +172,24 @@ export function reportPlan(plan: DoctorPlan): { text: string; lossless: number;
|
|
|
163
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`)
|
|
164
173
|
else lines.push(` --scale-to <m> sets the largest dimension explicitly`)
|
|
165
174
|
}
|
|
166
|
-
const
|
|
167
|
-
if (
|
|
168
|
-
lines.push(` ──
|
|
169
|
-
const tris =
|
|
170
|
-
const
|
|
171
|
-
if (
|
|
172
|
-
else lines.push(` ℹ ${
|
|
173
|
-
|
|
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 ? '…' : ''}`)
|
|
174
193
|
}
|
|
175
194
|
const plainDups = plan.dupNames.filter((d) => !d.shadowsJoint)
|
|
176
195
|
if (plainDups.length) {
|
|
@@ -181,10 +200,10 @@ export function reportPlan(plan: DoctorPlan): { text: string; lossless: number;
|
|
|
181
200
|
return { text: lines.join('\n'), lossless: lossless.length, lossy: lossy.length, blocking: blocking.length }
|
|
182
201
|
}
|
|
183
202
|
|
|
184
|
-
export interface ApplyOptions extends DoctorOptions { fixMetallic?: boolean;
|
|
185
|
-
export interface ApplyResult { metallicZeroed: number; ktx2Encoded: number; scaled: number; unlit: number; emissiveCleared: number;
|
|
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 }
|
|
186
205
|
|
|
187
|
-
/** Apply a plan to the document (mutates). Resolves
|
|
206
|
+
/** Apply a plan to the document (mutates). Resolves the KTX encoder itself when `ktx2` and none is given. */
|
|
188
207
|
export async function applyPlan(doc: Document, plan: DoctorPlan, opts: ApplyOptions = {}): Promise<ApplyResult> {
|
|
189
208
|
const maxSize = opts.maxSize ?? DEFAULT_MAX_SIZE
|
|
190
209
|
for (const s of plan.skinPlans) applySkinPlan(doc, s)
|
|
@@ -196,19 +215,48 @@ export async function applyPlan(doc: Document, plan: DoctorPlan, opts: ApplyOpti
|
|
|
196
215
|
for (const w of plan.metallicWarn) w.mat.setMetallicFactor(0)
|
|
197
216
|
metallicZeroed = plan.metallicWarn.length
|
|
198
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
|
+
}
|
|
199
223
|
const shading = applyShading(doc, plan.shadingPlan)
|
|
200
|
-
//
|
|
201
|
-
|
|
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
|
+
}
|
|
202
243
|
// LOD levels after every geometry pass (they copy the final base mesh) and before textures.
|
|
203
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
|
|
204
248
|
let ktx2Encoded = 0
|
|
205
249
|
if (opts.ktx2) {
|
|
206
|
-
const
|
|
207
|
-
if (!
|
|
208
|
-
|
|
209
|
-
|
|
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)
|
|
210
258
|
opts.progress?.(++ktx2Encoded, plan.ktx2Plans.length)
|
|
211
|
-
}
|
|
259
|
+
}))
|
|
212
260
|
// KTX2 textures must be written via KHR_texture_basisu (core texture.source allows only PNG/JPEG);
|
|
213
261
|
// gltf-transform only emits that form when the extension is attached to the document.
|
|
214
262
|
if (plan.ktx2Plans.length && !doc.getRoot().listExtensionsUsed().some((e) => e.extensionName === 'KHR_texture_basisu')) {
|
|
@@ -224,7 +272,23 @@ export async function applyPlan(doc: Document, plan: DoctorPlan, opts: ApplyOpti
|
|
|
224
272
|
if (ext.extensionName === 'EXT_texture_webp') ext.dispose()
|
|
225
273
|
}
|
|
226
274
|
}
|
|
227
|
-
|
|
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 }
|
|
228
292
|
}
|
|
229
293
|
|
|
230
294
|
/** Output path for a doctored file: single file + -o → exact path; -o as dir (or batch) → derived name inside it. */
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Atlas pixels: every tile rendered with its material's FACTORS baked in, blitted with edge
|
|
2
|
+
// replication, one PNG per map kind, and the merged material that reads them.
|
|
3
|
+
|
|
4
|
+
import type { Document, Material, Texture } from '@gltf-transform/core'
|
|
5
|
+
import { KHRMaterialsUnlit } from '@gltf-transform/extensions'
|
|
6
|
+
import sharp from 'sharp'
|
|
7
|
+
import { texels } from './textures'
|
|
8
|
+
import type { MapKind, MergeGroupPlan, Tile } from './types'
|
|
9
|
+
|
|
10
|
+
const SRGB_TO_LINEAR = new Float32Array(256).map((_, i) => { const c = i / 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4) })
|
|
11
|
+
const linearToSrgb = (v: number): number => { const c = v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055; return Math.max(0, Math.min(255, Math.round(c * 255))) }
|
|
12
|
+
const clamp8 = (v: number): number => Math.max(0, Math.min(255, Math.round(v)))
|
|
13
|
+
|
|
14
|
+
/** The tile's pixels for one map kind, factors baked in. */
|
|
15
|
+
async function tilePixels(kind: MapKind, m: Material, t: Tile, ktxTool: string | null, cache: Map<Texture, Buffer | null>): Promise<Uint8Array> {
|
|
16
|
+
const n = t.w * t.h
|
|
17
|
+
const out = new Uint8Array(n * 4)
|
|
18
|
+
const fetch = (tex: Texture | null) => (tex ? texels(tex, t.w, t.h, t.ru, t.rv, ktxTool, cache) : Promise.resolve(null))
|
|
19
|
+
if (kind === 'base') {
|
|
20
|
+
const f = m.getBaseColorFactor()
|
|
21
|
+
const src = await fetch(m.getBaseColorTexture())
|
|
22
|
+
for (let i = 0; i < n; i++) {
|
|
23
|
+
const o = i * 4
|
|
24
|
+
if (src) {
|
|
25
|
+
for (let c = 0; c < 3; c++) out[o + c] = f[c] === 1 ? src[o + c] : linearToSrgb(SRGB_TO_LINEAR[src[o + c]] * f[c])
|
|
26
|
+
out[o + 3] = clamp8(src[o + 3] * f[3])
|
|
27
|
+
} else { out[o] = linearToSrgb(f[0]); out[o + 1] = linearToSrgb(f[1]); out[o + 2] = linearToSrgb(f[2]); out[o + 3] = clamp8(f[3] * 255) }
|
|
28
|
+
}
|
|
29
|
+
} else if (kind === 'normal') {
|
|
30
|
+
const src = await fetch(m.getNormalTexture())
|
|
31
|
+
const s = m.getNormalScale()
|
|
32
|
+
for (let i = 0; i < n; i++) {
|
|
33
|
+
const o = i * 4
|
|
34
|
+
if (src) {
|
|
35
|
+
if (s === 1) { out[o] = src[o]; out[o + 1] = src[o + 1]; out[o + 2] = src[o + 2] }
|
|
36
|
+
else {
|
|
37
|
+
const x = (src[o] / 255 * 2 - 1) * s, y = (src[o + 1] / 255 * 2 - 1) * s, z = src[o + 2] / 255 * 2 - 1
|
|
38
|
+
const l = Math.hypot(x, y, z) || 1
|
|
39
|
+
out[o] = clamp8((x / l * 0.5 + 0.5) * 255); out[o + 1] = clamp8((y / l * 0.5 + 0.5) * 255); out[o + 2] = clamp8((z / l * 0.5 + 0.5) * 255)
|
|
40
|
+
}
|
|
41
|
+
} else { out[o] = 128; out[o + 1] = 128; out[o + 2] = 255 }
|
|
42
|
+
out[o + 3] = 255
|
|
43
|
+
}
|
|
44
|
+
} else if (kind === 'orm') {
|
|
45
|
+
const mr = await fetch(m.getMetallicRoughnessTexture())
|
|
46
|
+
const occTex = m.getOcclusionTexture()
|
|
47
|
+
const occ = occTex === m.getMetallicRoughnessTexture() ? mr : await fetch(occTex)
|
|
48
|
+
const rough = m.getRoughnessFactor(), metal = m.getMetallicFactor(), strength = m.getOcclusionStrength()
|
|
49
|
+
for (let i = 0; i < n; i++) {
|
|
50
|
+
const o = i * 4
|
|
51
|
+
out[o] = occ ? clamp8(255 * (1 + strength * (occ[o] / 255 - 1))) : 255
|
|
52
|
+
out[o + 1] = mr ? clamp8(mr[o + 1] * rough) : clamp8(rough * 255)
|
|
53
|
+
out[o + 2] = mr ? clamp8(mr[o + 2] * metal) : clamp8(metal * 255)
|
|
54
|
+
out[o + 3] = 255
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
const f = m.getEmissiveFactor()
|
|
58
|
+
const src = await fetch(m.getEmissiveTexture())
|
|
59
|
+
for (let i = 0; i < n; i++) {
|
|
60
|
+
const o = i * 4
|
|
61
|
+
if (src) for (let c = 0; c < 3; c++) out[o + c] = f[c] === 1 ? src[o + c] : linearToSrgb(SRGB_TO_LINEAR[src[o + c]] * f[c])
|
|
62
|
+
else { out[o] = linearToSrgb(f[0]); out[o + 1] = linearToSrgb(f[1]); out[o + 2] = linearToSrgb(f[2]) }
|
|
63
|
+
out[o + 3] = 255
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Blit a tile into the atlas with `pad` texels of edge replication around it. */
|
|
70
|
+
function blit(atlas: Uint8Array, S: number, t: Tile, pad: number, px: Uint8Array): void {
|
|
71
|
+
for (let y = t.y - pad; y < t.y + t.h + pad; y++) {
|
|
72
|
+
if (y < 0 || y >= S) continue
|
|
73
|
+
const sy = Math.max(0, Math.min(t.h - 1, y - t.y))
|
|
74
|
+
for (let x = t.x - pad; x < t.x + t.w + pad; x++) {
|
|
75
|
+
if (x < 0 || x >= S) continue
|
|
76
|
+
const sx = Math.max(0, Math.min(t.w - 1, x - t.x))
|
|
77
|
+
const si = (sy * t.w + sx) * 4, di = (y * S + x) * 4
|
|
78
|
+
atlas[di] = px[si]; atlas[di + 1] = px[si + 1]; atlas[di + 2] = px[si + 2]; atlas[di + 3] = px[si + 3]
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function buildAtlas(doc: Document, g: MergeGroupPlan, ktxTool: string | null): Promise<{ material: Material; textures: Texture[]; tileOf: Map<Material, Tile> }> {
|
|
84
|
+
const a = g.atlas!
|
|
85
|
+
const S = a.size
|
|
86
|
+
const cache = new Map<Texture, Buffer | null>()
|
|
87
|
+
const tileOf = new Map<Material, Tile>()
|
|
88
|
+
for (const t of a.tiles) for (const m of t.materials) tileOf.set(m, t)
|
|
89
|
+
const textures: Texture[] = []
|
|
90
|
+
const material = doc.createMaterial(g.label).setAlphaMode(g.alphaMode).setAlphaCutoff(g.alphaCutoff).setDoubleSided(g.doubleSided)
|
|
91
|
+
if (g.unlit) material.setExtension('KHR_materials_unlit', doc.createExtension(KHRMaterialsUnlit).createUnlit())
|
|
92
|
+
for (const kind of a.maps) {
|
|
93
|
+
const atlas = new Uint8Array(S * S * 4)
|
|
94
|
+
// unused space: a neutral value per kind (mips of the border sample it)
|
|
95
|
+
const fill = kind === 'normal' ? [ 128, 128, 255, 255 ] : kind === 'orm' ? [ 255, 255, 0, 255 ] : kind === 'emissive' ? [ 0, 0, 0, 255 ] : [ 0, 0, 0, a.hasAlpha ? 0 : 255 ]
|
|
96
|
+
for (let i = 0; i < S * S; i++) atlas.set(fill, i * 4)
|
|
97
|
+
for (const t of a.tiles) blit(atlas, S, t, a.pad, await tilePixels(kind, t.materials[0], t, ktxTool, cache))
|
|
98
|
+
let pipe = sharp(Buffer.from(atlas.buffer, atlas.byteOffset, atlas.byteLength), { raw: { width: S, height: S, channels: 4 } })
|
|
99
|
+
if (kind !== 'base' || !a.hasAlpha) pipe = pipe.removeAlpha()
|
|
100
|
+
const png = new Uint8Array(await pipe.png({ compressionLevel: 6 }).toBuffer())
|
|
101
|
+
const tex = doc.createTexture(`${g.label}_${kind}`).setImage(png).setMimeType('image/png')
|
|
102
|
+
textures.push(tex)
|
|
103
|
+
if (kind === 'base') material.setBaseColorTexture(tex).setBaseColorFactor([ 1, 1, 1, 1 ])
|
|
104
|
+
else if (kind === 'normal') material.setNormalTexture(tex).setNormalScale(1)
|
|
105
|
+
else if (kind === 'orm') material.setMetallicRoughnessTexture(tex).setOcclusionTexture(tex).setRoughnessFactor(1).setMetallicFactor(1).setOcclusionStrength(1)
|
|
106
|
+
else material.setEmissiveTexture(tex).setEmissiveFactor([ 1, 1, 1 ])
|
|
107
|
+
}
|
|
108
|
+
// maps every material agreed on stay factors
|
|
109
|
+
const m0 = g.materials[0]
|
|
110
|
+
if (!a.maps.includes('base')) material.setBaseColorFactor(m0.getBaseColorFactor())
|
|
111
|
+
if (!a.maps.includes('orm')) material.setRoughnessFactor(m0.getRoughnessFactor()).setMetallicFactor(m0.getMetallicFactor())
|
|
112
|
+
if (!a.maps.includes('emissive')) material.setEmissiveFactor([ 0, 0, 0 ])
|
|
113
|
+
return { material, textures, tileOf }
|
|
114
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// ONE bind space for several skins. Modular parts are often skinned separately, each with its own
|
|
2
|
+
// inverse bind matrices; two skins can be unified when, on every joint they share, their matrices
|
|
3
|
+
// differ by the SAME rigid transform D (IBM_part = IBM_canon · D) — the part was simply bound in
|
|
4
|
+
// another space, and its vertices move into the canonical one by D. Different transforms per joint
|
|
5
|
+
// mean the parts were bound in different POSES, and no single transform reconciles them.
|
|
6
|
+
|
|
7
|
+
import type { Node, Skin } from '@gltf-transform/core'
|
|
8
|
+
import { invert, isIdentity, mul, same } from './matrix'
|
|
9
|
+
import type { Mat4 } from './types'
|
|
10
|
+
|
|
11
|
+
const ibmAt = (skin: Skin, i: number): Mat4 => { const m = new Array<number>(16); skin.getInverseBindMatrices()!.getElement(i, m); return m }
|
|
12
|
+
|
|
13
|
+
/** Skins by joint count, largest first: the one with most joints defines the canonical space. */
|
|
14
|
+
export const bySize = (skins: Iterable<Skin | null>): Skin[] =>
|
|
15
|
+
[ ...new Set([ ...skins ].filter((s): s is Skin => !!s)) ].sort((a, b) => b.listJoints().length - a.listJoints().length)
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The canonical inverse bind matrix per joint over `skins` (already ordered, see `bySize`), and the
|
|
19
|
+
* skins that cannot join: no inverse bind matrices, or joints that disagree by different transforms.
|
|
20
|
+
* `verify: false` is for skins the plan already vetted: D is taken from the first shared joint.
|
|
21
|
+
*/
|
|
22
|
+
export function canonicalBinds(skins: Skin[], verify = true): { ibm: Map<Node, Mat4>; bad: Set<Skin> } {
|
|
23
|
+
const ibm = new Map<Node, Mat4>()
|
|
24
|
+
const bad = new Set<Skin>()
|
|
25
|
+
for (const skin of skins) {
|
|
26
|
+
if (!skin.getInverseBindMatrices()) { bad.add(skin); continue }
|
|
27
|
+
const js = skin.listJoints()
|
|
28
|
+
let D: Mat4 | null = null, ok = true
|
|
29
|
+
const own: [Node, Mat4][] = []
|
|
30
|
+
for (let i = 0; i < js.length && ok; i++) {
|
|
31
|
+
const m = ibmAt(skin, i)
|
|
32
|
+
const c = ibm.get(js[i])
|
|
33
|
+
if (!c) { own.push([ js[i], m ]); continue }
|
|
34
|
+
if (D && !verify) continue
|
|
35
|
+
const inv = invert(c)
|
|
36
|
+
if (!inv) { ok = false; break }
|
|
37
|
+
const d = mul(inv, m)
|
|
38
|
+
if (!D) D = d
|
|
39
|
+
else if (verify && !same(D, d, 1e-3)) ok = false
|
|
40
|
+
}
|
|
41
|
+
if (!ok) { bad.add(skin); continue }
|
|
42
|
+
// this skin's new joints enter the canon in the canonical space: IBM_canon = IBM_skin · D⁻¹
|
|
43
|
+
const Dinv = D ? invert(D) : null
|
|
44
|
+
for (const [ j, m ] of own) ibm.set(j, Dinv && !isIdentity(Dinv) ? mul(m, Dinv) : m)
|
|
45
|
+
}
|
|
46
|
+
return { ibm, bad }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The transform that moves a part skinned on `skin` into the canonical space (identity when it already is). */
|
|
50
|
+
export function intoCanon(skin: Skin, canon: Map<Node, Mat4>): Mat4 | null {
|
|
51
|
+
const js = skin.listJoints()
|
|
52
|
+
for (let i = 0; i < js.length; i++) {
|
|
53
|
+
const c = canon.get(js[i])
|
|
54
|
+
if (!c) continue
|
|
55
|
+
const D = mul(invert(c)!, ibmAt(skin, i))
|
|
56
|
+
return isIdentity(D, 1e-6) ? null : D
|
|
57
|
+
}
|
|
58
|
+
return null
|
|
59
|
+
}
|