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,88 @@
|
|
|
1
|
+
// The plan: classify, group by what one draw cannot mix, check the skins can be unified, lay the atlases out.
|
|
2
|
+
|
|
3
|
+
import type { Document, Material, Node, Texture } from '@gltf-transform/core'
|
|
4
|
+
import { planAtlas } from './atlasLayout'
|
|
5
|
+
import { bySize, canonicalBinds } from './bindSpace'
|
|
6
|
+
import { classify, classSuffix, materialInfo, measureSurface, type MatInfo } from './classify'
|
|
7
|
+
import { readAttr } from './accessors'
|
|
8
|
+
import { findKtxTool, isKtx2 } from './textures'
|
|
9
|
+
import { DEFAULT_ATLAS_PAD, DEFAULT_ATLAS_SIZE, type AtlasLayout, type Kind, type Mat4, type MergeGroupPlan, type MergeOptions, type MergePlan, type Source } from './types'
|
|
10
|
+
|
|
11
|
+
export async function planMerge(doc: Document, opts: MergeOptions): Promise<MergePlan> {
|
|
12
|
+
const atlasSize = Math.max(64, Math.round(opts.atlasSize ?? DEFAULT_ATLAS_SIZE))
|
|
13
|
+
const pad = Math.max(0, Math.round(opts.atlasPad ?? DEFAULT_ATLAS_PAD))
|
|
14
|
+
const exclude = opts.mergeExclude ? new RegExp(opts.mergeExclude) : null
|
|
15
|
+
const root = doc.getRoot()
|
|
16
|
+
const primitivesBefore = root.listMeshes().reduce((n, m) => n + m.listPrimitives().length, 0)
|
|
17
|
+
const materialsBefore = root.listMaterials().length
|
|
18
|
+
const empty: MergePlan = { enabled: false, atlasSize, pad, groups: [], skipped: [], primitivesBefore, materialsBefore, primitivesAfter: primitivesBefore, joints: 0, ktxTool: null }
|
|
19
|
+
if (primitivesBefore < 2) return empty
|
|
20
|
+
const { sources, skipped } = classify(doc, exclude)
|
|
21
|
+
if (!sources.length) return { ...empty, skipped }
|
|
22
|
+
|
|
23
|
+
// material infos (UV ranges over the candidate primitives)
|
|
24
|
+
const uvOf = (m: Material): number[][] => sources.filter((s) => s.material === m).map((s) => { const a = s.prim.getAttribute('TEXCOORD_0'); return a ? Array.from(readAttr(a).data) : [] })
|
|
25
|
+
const texId = (t: Texture): string => String(root.listTextures().indexOf(t))
|
|
26
|
+
const wm = new Map<Node, Mat4>()
|
|
27
|
+
const measure = (m: Material) => measureSurface(sources.filter((x) => x.material === m), wm)
|
|
28
|
+
const infos = new Map<Material, MatInfo>()
|
|
29
|
+
for (const s of sources) if (s.material && !infos.has(s.material)) infos.set(s.material, await materialInfo(s.material, root.listMaterials().indexOf(s.material), uvOf, measure))
|
|
30
|
+
|
|
31
|
+
// KTX2 sources need the ktx tool — checked once, only when some candidate map is KTX2
|
|
32
|
+
const needsKtx = [ ...infos.values() ].some((i) => Object.values(i.maps).some((t) => isKtx2(t)))
|
|
33
|
+
const ktxTool = needsKtx ? findKtxTool() : null
|
|
34
|
+
|
|
35
|
+
// group by family × material class
|
|
36
|
+
const groupsMap = new Map<string, Source[]>()
|
|
37
|
+
for (const s of sources) {
|
|
38
|
+
const family = s.kind === 'static' ? 'static' : 'skinned'
|
|
39
|
+
const cls = s.material ? infos.get(s.material)!.classKey : 'OPAQUE||0|0'
|
|
40
|
+
const key = `${family}|${cls}`
|
|
41
|
+
const list = groupsMap.get(key) ?? []
|
|
42
|
+
list.push(s)
|
|
43
|
+
groupsMap.set(key, list)
|
|
44
|
+
}
|
|
45
|
+
// skinned family: can every skin join one bind space? (against the skin with most joints)
|
|
46
|
+
const badSkins = canonicalBinds(bySize(sources.filter((x) => x.kind !== 'static').map((x) => x.skin))).bad
|
|
47
|
+
const groups: MergeGroupPlan[] = []
|
|
48
|
+
let kept = 0
|
|
49
|
+
const usedLabels = new Set<string>()
|
|
50
|
+
for (const [ key, list ] of groupsMap) {
|
|
51
|
+
const family = key.startsWith('static') ? 'static' : 'skinned'
|
|
52
|
+
const usable = list.filter((s) => { if (s.skin && badSkins.has(s.skin)) { skipped.push({ name: s.name, reason: 'bind spaces differ per joint (skin cannot be unified)' }); return false } return true })
|
|
53
|
+
if (usable.length < 2) { kept += usable.length; continue }
|
|
54
|
+
// skinned parts first (the merged node is placed where the first one sat), then rigid, then static
|
|
55
|
+
const rank: Record<Kind, number> = { skinned: 0, rigid: 1, static: 2 }
|
|
56
|
+
usable.sort((x, y) => rank[x.kind] - rank[y.kind])
|
|
57
|
+
const mats = [ ...new Set(usable.map((s) => s.material).filter((m): m is Material => !!m)) ]
|
|
58
|
+
const first = mats[0]
|
|
59
|
+
let label = family === 'static' && groupsMap.size > 1 && [ ...groupsMap.keys() ].some((k) => k.startsWith('skinned')) ? 'merged.static' : 'merged'
|
|
60
|
+
if (first) {
|
|
61
|
+
const info = infos.get(first)!
|
|
62
|
+
const suffix = info.own ? (first.getName() || 'material').replace(/[^\w.-]+/g, '_') : classSuffix(first)
|
|
63
|
+
if (suffix) label = `${label}.${suffix}`
|
|
64
|
+
}
|
|
65
|
+
while (usedLabels.has(label)) label += '_'
|
|
66
|
+
usedLabels.add(label)
|
|
67
|
+
let atlas: AtlasLayout | null = null
|
|
68
|
+
let note: string | undefined
|
|
69
|
+
if (mats.length > 1) {
|
|
70
|
+
const minfos = mats.map((m) => infos.get(m)!)
|
|
71
|
+
const ktxNeeded = minfos.some((i) => Object.values(i.maps).some((t) => isKtx2(t)))
|
|
72
|
+
if (ktxNeeded && !ktxTool) { skipped.push({ name: label, reason: `${mats.length} materials carry KTX2 textures and the ktx tool was not found (KTX-Software, next to toktx)` }); kept += usable.length; continue }
|
|
73
|
+
const a = planAtlas(minfos, atlasSize, pad, !!opts.atlasUniform, texId)
|
|
74
|
+
if (!a.layout) { skipped.push({ name: label, reason: a.reason! }); kept += usable.length; continue }
|
|
75
|
+
atlas = a.layout
|
|
76
|
+
} else if (first && infos.get(first)!.own) note = infos.get(first)!.own!
|
|
77
|
+
const jointSet = new Set<Node>()
|
|
78
|
+
for (const s of usable) { if (s.skin) for (const j of s.skin.listJoints()) jointSet.add(j); if (s.joint) jointSet.add(s.joint) }
|
|
79
|
+
groups.push({
|
|
80
|
+
label, family, sources: usable, materials: mats, atlas,
|
|
81
|
+
alphaMode: first?.getAlphaMode() ?? 'OPAQUE', alphaCutoff: first?.getAlphaCutoff() ?? 0.5, doubleSided: first?.getDoubleSided() ?? false, unlit: !!first?.getExtension('KHR_materials_unlit'),
|
|
82
|
+
joints: jointSet.size, note,
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
const inGroups = groups.reduce((n, g) => n + g.sources.length, 0)
|
|
86
|
+
const primitivesAfter = primitivesBefore - inGroups + groups.length
|
|
87
|
+
return { enabled: !!opts.merge && groups.length > 0, atlasSize, pad, groups, skipped, primitivesBefore, materialsBefore, primitivesAfter, joints: Math.max(0, ...groups.map((g) => g.joints)), ktxTool }
|
|
88
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Reading the parts' textures: sizes without decoding, pixels at a tile's size — a Basis KTX2 goes
|
|
2
|
+
// back to PNG through KTX-Software's `ktx extract`.
|
|
3
|
+
|
|
4
|
+
import type { Texture } from '@gltf-transform/core'
|
|
5
|
+
import sharp from 'sharp'
|
|
6
|
+
import { spawnSync } from 'node:child_process'
|
|
7
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { tmpdir } from 'node:os'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { checkToktx, findKtxCli } from '../transcodeKtx2'
|
|
11
|
+
|
|
12
|
+
export function findKtxTool(): string | null {
|
|
13
|
+
const ktx = findKtxCli()
|
|
14
|
+
return checkToktx(ktx) ? ktx : null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const isKtx2 = (t: Texture): boolean => (t.getMimeType() || '').includes('ktx2')
|
|
18
|
+
|
|
19
|
+
/** Pixel size of a texture without decoding it: the KTX2 header, or sharp's metadata. */
|
|
20
|
+
export async function imageSize(t: Texture): Promise<[number, number] | null> {
|
|
21
|
+
const img = t.getImage()
|
|
22
|
+
if (!img) return null
|
|
23
|
+
if (isKtx2(t)) {
|
|
24
|
+
if (img.length < 28) return null
|
|
25
|
+
const dv = new DataView(img.buffer, img.byteOffset, img.byteLength)
|
|
26
|
+
return [ dv.getUint32(20, true), dv.getUint32(24, true) ]
|
|
27
|
+
}
|
|
28
|
+
try { const m = await sharp(Buffer.from(img)).metadata(); return m.width && m.height ? [ m.width, m.height ] : null } catch { return null }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Encoded bytes sharp can read for a texture (a KTX2 goes through `ktx extract`). */
|
|
32
|
+
export async function decodable(tex: Texture, ktxTool: string | null, cache: Map<Texture, Buffer | null>): Promise<Buffer | null> {
|
|
33
|
+
if (cache.has(tex)) return cache.get(tex)!
|
|
34
|
+
const img = tex.getImage()
|
|
35
|
+
let out: Buffer | null = null
|
|
36
|
+
if (img && !isKtx2(tex)) out = Buffer.from(img)
|
|
37
|
+
else if (img && ktxTool) {
|
|
38
|
+
const dir = mkdtempSync(join(tmpdir(), 'glbatlas-'))
|
|
39
|
+
try {
|
|
40
|
+
const inK = join(dir, 'in.ktx2'), outP = join(dir, 'out.png')
|
|
41
|
+
writeFileSync(inK, img)
|
|
42
|
+
const p = spawnSync(ktxTool, [ 'extract', '--transcode', 'rgba8', inK, outP ], { encoding: 'utf8' })
|
|
43
|
+
if (p.status === 0 && existsSync(outP)) out = readFileSync(outP)
|
|
44
|
+
} finally { rmSync(dir, { recursive: true, force: true }) }
|
|
45
|
+
}
|
|
46
|
+
cache.set(tex, out)
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A texture as RGBA at exactly w×h (tiled `ru`×`rv` times), or null when it cannot be read. */
|
|
51
|
+
export async function texels(tex: Texture, w: number, h: number, ru: number, rv: number, ktxTool: string | null, cache: Map<Texture, Buffer | null>): Promise<Uint8Array | null> {
|
|
52
|
+
const bytes = await decodable(tex, ktxTool, cache)
|
|
53
|
+
if (!bytes) return null
|
|
54
|
+
const cw = Math.max(1, Math.round(w / ru)), ch = Math.max(1, Math.round(h / rv))
|
|
55
|
+
const cell = new Uint8Array(await sharp(bytes).resize({ width: cw, height: ch, fit: 'fill', kernel: 'lanczos3' }).ensureAlpha().raw().toBuffer())
|
|
56
|
+
if (ru === 1 && rv === 1 && cw === w && ch === h) return cell
|
|
57
|
+
const out = new Uint8Array(w * h * 4)
|
|
58
|
+
for (let y = 0; y < h; y++) { const sy = y % ch; for (let x = 0; x < w; x++) { const sx = x % cw; const si = (sy * cw + sx) * 4, di = (y * w + x) * 4; out[di] = cell[si]; out[di + 1] = cell[si + 1]; out[di + 2] = cell[si + 2]; out[di + 3] = cell[si + 3] } }
|
|
59
|
+
return out
|
|
60
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// The merge pass's vocabulary: options, the plan (what WOULD be merged — the doctor reports it), the result.
|
|
2
|
+
|
|
3
|
+
import type { Material, Mesh, Node, Primitive, Skin, Texture } from '@gltf-transform/core'
|
|
4
|
+
|
|
5
|
+
export interface MergeOptions {
|
|
6
|
+
/** Merge every mergeable primitive into one per material class + atlas the textures (see the header). */
|
|
7
|
+
merge?: boolean
|
|
8
|
+
/** Largest atlas edge in texels (default 2048); tiles scale down uniformly to fit. */
|
|
9
|
+
atlasSize?: number
|
|
10
|
+
/** Edge-replication padding around every tile, in texels (default 8). */
|
|
11
|
+
atlasPad?: number
|
|
12
|
+
/** Node names (regex) to leave out of the merge — a part the game hides or swaps. */
|
|
13
|
+
mergeExclude?: string
|
|
14
|
+
/** Scale every tile by the same factor to fit (default: by surface area — equal texel density). */
|
|
15
|
+
atlasUniform?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_ATLAS_SIZE = 2048
|
|
19
|
+
export const DEFAULT_ATLAS_PAD = 8
|
|
20
|
+
|
|
21
|
+
export type Mat4 = number[]
|
|
22
|
+
export type Kind = 'skinned' | 'rigid' | 'static'
|
|
23
|
+
export type MapKind = 'base' | 'normal' | 'orm' | 'emissive'
|
|
24
|
+
|
|
25
|
+
export interface Source { node: Node; mesh: Mesh; prim: Primitive; kind: Kind; skin: Skin | null; joint: Node | null; name: string; material: Material | null; triangles: number }
|
|
26
|
+
export interface MergeSkipped { name: string; reason: string }
|
|
27
|
+
|
|
28
|
+
export interface Tile {
|
|
29
|
+
key: string
|
|
30
|
+
materials: Material[]
|
|
31
|
+
/** source texel size (the material's largest map, × the UV repeat) */
|
|
32
|
+
srcW: number; srcH: number
|
|
33
|
+
/** UV repeat and origin: the tile covers [u0, u0 + ru) × [v0, v0 + rv) of the material's UV space */
|
|
34
|
+
u0: number; v0: number; ru: number; rv: number
|
|
35
|
+
/** surface on the model (m², bind / world space) and the fraction of the tile's UV square the triangles cover */
|
|
36
|
+
area: number; uvArea: number
|
|
37
|
+
/** placed size and position (inner rect, without the padding) at the chosen scale */
|
|
38
|
+
w: number; h: number; x: number; y: number
|
|
39
|
+
/** tile edge / source edge */
|
|
40
|
+
scale: number
|
|
41
|
+
}
|
|
42
|
+
export interface AtlasLayout { size: number; /** smallest tile scale */ scale: number; /** largest tile scale */ scaleMax: number; byArea: boolean; pad: number; maps: MapKind[]; tiles: Tile[]; hasAlpha: boolean }
|
|
43
|
+
|
|
44
|
+
export interface MergeGroupPlan {
|
|
45
|
+
label: string
|
|
46
|
+
family: 'skinned' | 'static'
|
|
47
|
+
sources: Source[]
|
|
48
|
+
materials: Material[]
|
|
49
|
+
/** null = the group keeps its (single) material — geometry-only merge */
|
|
50
|
+
atlas: AtlasLayout | null
|
|
51
|
+
/** the class the merged material inherits */
|
|
52
|
+
alphaMode: 'OPAQUE' | 'MASK' | 'BLEND'; alphaCutoff: number; doubleSided: boolean; unlit: boolean
|
|
53
|
+
joints: number
|
|
54
|
+
note?: string
|
|
55
|
+
}
|
|
56
|
+
export interface MergePlan {
|
|
57
|
+
enabled: boolean
|
|
58
|
+
atlasSize: number
|
|
59
|
+
pad: number
|
|
60
|
+
groups: MergeGroupPlan[]
|
|
61
|
+
skipped: MergeSkipped[]
|
|
62
|
+
primitivesBefore: number
|
|
63
|
+
materialsBefore: number
|
|
64
|
+
/** primitives that stay (skipped) + one per group */
|
|
65
|
+
primitivesAfter: number
|
|
66
|
+
/** joints of the largest merged skin (the bone cap applies to it) */
|
|
67
|
+
joints: number
|
|
68
|
+
ktxTool: string | null
|
|
69
|
+
}
|
|
70
|
+
export interface MergeResult {
|
|
71
|
+
groups: { label: string; primitives: number; materials: number; vertices: number; triangles: number; atlas: string | null; joints: number }[]
|
|
72
|
+
skipped: MergeSkipped[]
|
|
73
|
+
/** the atlas textures created (for the caller's texture passes) */
|
|
74
|
+
textures: Texture[]
|
|
75
|
+
primitivesBefore: number
|
|
76
|
+
primitivesAfter: number
|
|
77
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Scale normalization — bring a model to real-world metres before it reaches creator-gl.
|
|
2
|
+
//
|
|
3
|
+
// Exporters routinely ship a model at 1/100 (the Blender/FBX "armature scale 0.01" quirk), in cm, or
|
|
4
|
+
// in inches. Nothing in the file records that, and the SDK carries no unit metadata — the asset just
|
|
5
|
+
// turns up 5 mm across in a scene and every consumer needs its own magic multiplier.
|
|
6
|
+
//
|
|
7
|
+
// measure : the RENDERED world size. Skinned primitives do not use their node's matrix (the glTF
|
|
8
|
+
// spec says a skinned mesh node's transform is ignored) — they land at world(joint)×IBM,
|
|
9
|
+
// so that is what we measure them with.
|
|
10
|
+
// plan : an explicit factor, a target largest-dimension, or an auto unit guess.
|
|
11
|
+
// apply : BAKES the factor into the data — positions, node translations, IBM offsets and
|
|
12
|
+
// translation tracks all × s. Local scales are left alone, so no wrapper node appears
|
|
13
|
+
// and every node path stays what it was.
|
|
14
|
+
//
|
|
15
|
+
// Why the bake is those four edits and nothing else: scaling every translation by s and leaving
|
|
16
|
+
// every rotation/scale block alone gives world' = S·world·S⁻¹ for every node (S = uniform s). For a
|
|
17
|
+
// skinned vertex, Σw·world'·IBM'·(s·v) == s·(Σw·world·IBM·v) holds exactly when IBM' = S·IBM·S⁻¹,
|
|
18
|
+
// and S·IBM·S⁻¹ is just IBM with its translation column × s.
|
|
19
|
+
import type { Accessor, Document } from '@gltf-transform/core'
|
|
20
|
+
|
|
21
|
+
const FLOAT = 5126
|
|
22
|
+
|
|
23
|
+
/** A model whose largest dimension lands outside this (metres) is almost certainly mis-scaled. */
|
|
24
|
+
export const PLAUSIBLE_MIN = 0.1
|
|
25
|
+
export const PLAUSIBLE_MAX = 100
|
|
26
|
+
|
|
27
|
+
// Multiply-by factors we are willing to guess, with what they mean. Ordered by |ln f| so the first
|
|
28
|
+
// in-band hit is the SMALLEST correction that explains the model.
|
|
29
|
+
const CANDIDATES: { f: number; label: string }[] = [
|
|
30
|
+
{ f: 100, label: 'cm units / the 0.01 armature-scale export quirk' },
|
|
31
|
+
{ f: 0.01, label: '100× too big (metre values authored as cm)' },
|
|
32
|
+
{ f: 0.3048, label: 'feet' },
|
|
33
|
+
{ f: 0.0254, label: 'inches' },
|
|
34
|
+
{ f: 1000, label: 'mm units' },
|
|
35
|
+
{ f: 0.001, label: '1000× too big' },
|
|
36
|
+
].sort((a, b) => Math.abs(Math.log(a.f)) - Math.abs(Math.log(b.f)))
|
|
37
|
+
|
|
38
|
+
type Vec3 = [number, number, number]
|
|
39
|
+
export interface ScaleInfo {
|
|
40
|
+
dims: Vec3 // rendered world size, metres
|
|
41
|
+
largest: number
|
|
42
|
+
suggested: number // auto factor (1 = looks fine, or no candidate explains it)
|
|
43
|
+
label: string
|
|
44
|
+
suspicious: boolean
|
|
45
|
+
}
|
|
46
|
+
export interface ScalePlan { factor: number; before: Vec3; after: Vec3; source: 'auto' | 'explicit' | 'fit'; label: string }
|
|
47
|
+
|
|
48
|
+
const mul = (a: number[], b: number[]): number[] => {
|
|
49
|
+
const o = new Array(16).fill(0)
|
|
50
|
+
for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) o[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3]
|
|
51
|
+
return o
|
|
52
|
+
}
|
|
53
|
+
const applyMat = (m: number[], v: number[]): Vec3 => [
|
|
54
|
+
m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12],
|
|
55
|
+
m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13],
|
|
56
|
+
m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14],
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
/** Rendered world-space bounds of every mesh in the document, or null when there is no geometry. */
|
|
60
|
+
export function sceneBounds(doc: Document): { min: Vec3; max: Vec3; dims: Vec3 } | null {
|
|
61
|
+
const cache = new Map<object, number[]>()
|
|
62
|
+
const world = (n: any): number[] => {
|
|
63
|
+
const hit = cache.get(n)
|
|
64
|
+
if (hit) return hit
|
|
65
|
+
const parent = n.getParentNode()
|
|
66
|
+
const local = Array.from(n.getMatrix() as ArrayLike<number>)
|
|
67
|
+
const w = parent ? mul(world(parent), local) : local
|
|
68
|
+
cache.set(n, w)
|
|
69
|
+
return w
|
|
70
|
+
}
|
|
71
|
+
const min: Vec3 = [ Infinity, Infinity, Infinity ], max: Vec3 = [ -Infinity, -Infinity, -Infinity ]
|
|
72
|
+
let any = false
|
|
73
|
+
for (const node of doc.getRoot().listNodes()) {
|
|
74
|
+
const mesh = node.getMesh()
|
|
75
|
+
if (!mesh) continue
|
|
76
|
+
// A skinned primitive ignores its node's transform: at rest it lands at world(joint₀)·IBM₀, the
|
|
77
|
+
// skin's bind space (constant across joints for a sane rig — probe reports the deviation).
|
|
78
|
+
let m = world(node)
|
|
79
|
+
const skin = node.getSkin()
|
|
80
|
+
if (skin) {
|
|
81
|
+
const joints = skin.listJoints()
|
|
82
|
+
const ibm = skin.getInverseBindMatrices()
|
|
83
|
+
if (joints.length && ibm) m = mul(world(joints[0]), ibm.getElement(0, []))
|
|
84
|
+
else if (joints.length) m = world(joints[0])
|
|
85
|
+
}
|
|
86
|
+
for (const prim of mesh.listPrimitives()) {
|
|
87
|
+
const pos = prim.getAttribute('POSITION')
|
|
88
|
+
if (!pos) continue
|
|
89
|
+
const lo = pos.getMin([]), hi = pos.getMax([])
|
|
90
|
+
for (let corner = 0; corner < 8; corner++) {
|
|
91
|
+
const p = applyMat(m, [ corner & 1 ? hi[0] : lo[0], corner & 2 ? hi[1] : lo[1], corner & 4 ? hi[2] : lo[2] ])
|
|
92
|
+
for (let i = 0; i < 3; i++) { min[i] = Math.min(min[i], p[i]); max[i] = Math.max(max[i], p[i]) }
|
|
93
|
+
}
|
|
94
|
+
any = true
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!any) return null
|
|
98
|
+
return { min, max, dims: [ max[0] - min[0], max[1] - min[1], max[2] - min[2] ] }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Measure the document and guess whether (and by how much) it is mis-scaled. */
|
|
102
|
+
export function measureScale(doc: Document): ScaleInfo | null {
|
|
103
|
+
const b = sceneBounds(doc)
|
|
104
|
+
if (!b) return null
|
|
105
|
+
const largest = Math.max(...b.dims)
|
|
106
|
+
if (!(largest > 0)) return null
|
|
107
|
+
const inBand = (x: number): boolean => x >= PLAUSIBLE_MIN && x <= PLAUSIBLE_MAX
|
|
108
|
+
if (inBand(largest)) return { dims: b.dims, largest, suggested: 1, label: 'plausible', suspicious: false }
|
|
109
|
+
const hit = CANDIDATES.find((c) => inBand(largest * c.f))
|
|
110
|
+
return {
|
|
111
|
+
dims: b.dims,
|
|
112
|
+
largest,
|
|
113
|
+
suggested: hit?.f ?? 1,
|
|
114
|
+
label: hit?.label ?? 'no standard unit factor explains this size — pass --scale-to',
|
|
115
|
+
suspicious: true,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface ScaleOptions { scale?: number; scaleTo?: number; fixScale?: boolean }
|
|
120
|
+
|
|
121
|
+
/** Turn options + measurement into a concrete plan, or null when there is nothing to do. */
|
|
122
|
+
export function planScale(info: ScaleInfo | null, opts: ScaleOptions): ScalePlan | null {
|
|
123
|
+
let factor = 1
|
|
124
|
+
let source: ScalePlan['source'] = 'auto'
|
|
125
|
+
let label = info?.label ?? ''
|
|
126
|
+
if (opts.scale !== undefined) {
|
|
127
|
+
// An explicit factor needs no measurement — it still applies to a clip-only file with no geometry.
|
|
128
|
+
factor = opts.scale; source = 'explicit'; label = 'explicit factor'
|
|
129
|
+
} else if (opts.scaleTo !== undefined) {
|
|
130
|
+
if (!info) throw new Error('--scale-to needs geometry to measure; this file has no meshes — use --scale <factor>')
|
|
131
|
+
factor = opts.scaleTo / info.largest; source = 'fit'; label = `largest dimension → ${opts.scaleTo} m`
|
|
132
|
+
} else if (opts.fixScale) {
|
|
133
|
+
if (!info) return null
|
|
134
|
+
factor = info.suggested
|
|
135
|
+
}
|
|
136
|
+
if (!(factor > 0) || !Number.isFinite(factor)) throw new Error(`bad scale factor ${factor}`)
|
|
137
|
+
if (Math.abs(factor - 1) < 1e-9) return null
|
|
138
|
+
const before: Vec3 = info?.dims ?? [ 0, 0, 0 ]
|
|
139
|
+
return { factor, before, after: before.map((d) => d * factor) as Vec3, source, label }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const scaleAccessor = (acc: Accessor, s: number, what: string): void => {
|
|
143
|
+
if (acc.getComponentType() !== FLOAT) throw new Error(`cannot scale quantized/normalized ${what} (component type ${acc.getComponentType()}) — dequantize first`)
|
|
144
|
+
const src = acc.getArray()!
|
|
145
|
+
const out = new Float32Array(src.length)
|
|
146
|
+
for (let i = 0; i < src.length; i++) out[i] = src[i] * s
|
|
147
|
+
acc.setArray(out)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface ScaleResult { positions: number; nodes: number; skins: number; tracks: number; cameras: number; lights: number }
|
|
151
|
+
|
|
152
|
+
/** Bake a uniform scale into the document (mutates). Adds no nodes and changes no local scales. */
|
|
153
|
+
export function applyScale(doc: Document, s: number): ScaleResult {
|
|
154
|
+
const root = doc.getRoot()
|
|
155
|
+
const res: ScaleResult = { positions: 0, nodes: 0, skins: 0, tracks: 0, cameras: 0, lights: 0 }
|
|
156
|
+
|
|
157
|
+
const seen = new Set<Accessor>()
|
|
158
|
+
const once = (acc: Accessor | null, what: string): void => {
|
|
159
|
+
if (!acc || seen.has(acc)) return
|
|
160
|
+
seen.add(acc)
|
|
161
|
+
scaleAccessor(acc, s, what)
|
|
162
|
+
res.positions++
|
|
163
|
+
}
|
|
164
|
+
for (const mesh of root.listMeshes()) for (const prim of mesh.listPrimitives()) {
|
|
165
|
+
once(prim.getAttribute('POSITION'), 'POSITION')
|
|
166
|
+
for (const target of prim.listTargets()) once(target.getAttribute('POSITION'), 'morph POSITION')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const node of root.listNodes()) {
|
|
170
|
+
const t = node.getTranslation()
|
|
171
|
+
if (t[0] || t[1] || t[2]) { node.setTranslation([ t[0] * s, t[1] * s, t[2] * s ]); res.nodes++ }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// IBM' = S·IBM·S⁻¹ — the 3×3 block is untouched, only the offset moves.
|
|
175
|
+
for (const skin of root.listSkins()) {
|
|
176
|
+
const ibm = skin.getInverseBindMatrices()
|
|
177
|
+
if (!ibm) continue
|
|
178
|
+
const m: number[] = []
|
|
179
|
+
for (let i = 0; i < ibm.getCount(); i++) {
|
|
180
|
+
ibm.getElement(i, m)
|
|
181
|
+
m[12] *= s; m[13] *= s; m[14] *= s
|
|
182
|
+
ibm.setElement(i, m)
|
|
183
|
+
}
|
|
184
|
+
res.skins++
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Translation tracks only — rotation and scale tracks live in the untouched 3×3 block.
|
|
188
|
+
for (const anim of root.listAnimations()) {
|
|
189
|
+
const moves = new Set<Accessor>(), keeps = new Set<Accessor>()
|
|
190
|
+
for (const ch of anim.listChannels()) {
|
|
191
|
+
const out = ch.getSampler()?.getOutput()
|
|
192
|
+
if (out) (ch.getTargetPath() === 'translation' ? moves : keeps).add(out)
|
|
193
|
+
}
|
|
194
|
+
for (const acc of moves) {
|
|
195
|
+
if (keeps.has(acc)) throw new Error(`animation "${anim.getName()}" shares one output accessor between translation and other channels — cannot scale safely`)
|
|
196
|
+
if (seen.has(acc)) continue
|
|
197
|
+
seen.add(acc)
|
|
198
|
+
scaleAccessor(acc, s, 'translation track')
|
|
199
|
+
res.tracks++
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
for (const cam of root.listCameras()) {
|
|
204
|
+
cam.setZNear(cam.getZNear() * s)
|
|
205
|
+
const zf = cam.getZFar()
|
|
206
|
+
if (zf) cam.setZFar(zf * s)
|
|
207
|
+
if (cam.getType() === 'orthographic') { cam.setXMag(cam.getXMag() * s); cam.setYMag(cam.getYMag() * s) }
|
|
208
|
+
res.cameras++
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// KHR_lights_punctual: range is a distance; intensity is candela/lux and does not scale with size.
|
|
212
|
+
for (const node of root.listNodes()) {
|
|
213
|
+
const light = node.getExtension('KHR_lights_punctual') as { getRange?: () => number | null; setRange?: (r: number) => unknown } | null
|
|
214
|
+
if (!light?.getRange || !light.setRange) continue
|
|
215
|
+
const range = light.getRange()
|
|
216
|
+
if (range) light.setRange(range * s)
|
|
217
|
+
res.lights++
|
|
218
|
+
}
|
|
219
|
+
return res
|
|
220
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// Skin bone-count reduction for filament's CONFIG_MAX_BONE_COUNT (256), without Blender.
|
|
2
|
+
// Split into PLAN (pure analysis, no mutation) and APPLY (mutation) so the same logic powers
|
|
3
|
+
// both the analyzer and the fixer in glbDoctor.ts.
|
|
4
|
+
//
|
|
5
|
+
// Pass 1 — drop joints not bound to any vertex (leftover IK / control bones) -> LOSSLESS
|
|
6
|
+
// Pass 2 — drop the deepest remaining joints, merging their weights into the
|
|
7
|
+
// nearest surviving ancestor joint, until each skin has <= target -> LOSSY
|
|
8
|
+
import { NodeIO, type Document, type Skin, type Node as GltfNode, type Primitive, type Accessor } from '@gltf-transform/core'
|
|
9
|
+
import { ALL_EXTENSIONS } from '@gltf-transform/extensions'
|
|
10
|
+
import { MeshoptDecoder, MeshoptEncoder } from 'meshoptimizer'
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_TARGET = 256
|
|
13
|
+
const WEIGHT_EPS = 1e-5
|
|
14
|
+
|
|
15
|
+
export interface PrimSet { j: Accessor; w: Accessor }
|
|
16
|
+
export interface PrimEntry { prim: Primitive; sets: PrimSet[] }
|
|
17
|
+
|
|
18
|
+
export interface SkinPlan {
|
|
19
|
+
skin: Skin
|
|
20
|
+
skinIdx: number
|
|
21
|
+
name: string
|
|
22
|
+
before: number
|
|
23
|
+
after: number
|
|
24
|
+
removedUnbound: number
|
|
25
|
+
removedDeep: number
|
|
26
|
+
prims: PrimEntry[]
|
|
27
|
+
// internals reused by applySkinPlan:
|
|
28
|
+
joints: GltfNode[]
|
|
29
|
+
removeSet: Set<number>
|
|
30
|
+
kept: number[]
|
|
31
|
+
newPos: Map<number, number>
|
|
32
|
+
mergeTarget: Int32Array
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function makeIO(): NodeIO {
|
|
36
|
+
return new NodeIO()
|
|
37
|
+
.registerExtensions(ALL_EXTENSIONS)
|
|
38
|
+
.registerDependencies({ 'meshopt.decoder': MeshoptDecoder, 'meshopt.encoder': MeshoptEncoder })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Meshopt codecs are WASM — must be ready before any read/write. */
|
|
42
|
+
export async function ensureCodecs(): Promise<void> {
|
|
43
|
+
await MeshoptDecoder.ready
|
|
44
|
+
await MeshoptEncoder.ready
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Group every skinned primitive under the single skin that uses it. */
|
|
48
|
+
export function gatherSkinPrims(doc: Document): { bySkin: Map<Skin, PrimEntry[]>; conflicts: number } {
|
|
49
|
+
const owner = new Map<Primitive, Skin>()
|
|
50
|
+
const bySkin = new Map<Skin, PrimEntry[]>()
|
|
51
|
+
let conflicts = 0
|
|
52
|
+
for (const node of doc.getRoot().listNodes()) {
|
|
53
|
+
const skin = node.getSkin(), mesh = node.getMesh()
|
|
54
|
+
if (!skin || !mesh) continue
|
|
55
|
+
for (const prim of mesh.listPrimitives()) {
|
|
56
|
+
if (!prim.getAttribute('JOINTS_0')) continue
|
|
57
|
+
const prev = owner.get(prim)
|
|
58
|
+
if (prev) { if (prev !== skin) conflicts++; continue }
|
|
59
|
+
owner.set(prim, skin)
|
|
60
|
+
const sets: PrimSet[] = []
|
|
61
|
+
for (let s = 0; ; s++) {
|
|
62
|
+
const j = prim.getAttribute(`JOINTS_${s}`), w = prim.getAttribute(`WEIGHTS_${s}`)
|
|
63
|
+
if (!j || !w) break
|
|
64
|
+
sets.push({ j, w })
|
|
65
|
+
}
|
|
66
|
+
;(bySkin.get(skin) ?? bySkin.set(skin, []).get(skin)!).push({ prim, sets })
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { bySkin, conflicts }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function depthOf(node: GltfNode): number {
|
|
73
|
+
let d = 0, p = node.getParentNode()
|
|
74
|
+
while (p) { d++; p = p.getParentNode() }
|
|
75
|
+
return d
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Pure: decide which joints to drop. No mutation. */
|
|
79
|
+
export function planSkin(skin: Skin, skinIdx: number, prims: PrimEntry[], target: number): SkinPlan {
|
|
80
|
+
const joints = skin.listJoints()
|
|
81
|
+
const N = joints.length
|
|
82
|
+
const name = skin.getName() || `skin[${skinIdx}]`
|
|
83
|
+
const posOf = new Map<GltfNode, number>(joints.map((n, i) => [n, i]))
|
|
84
|
+
const removeSet = new Set<number>()
|
|
85
|
+
|
|
86
|
+
const empty: SkinPlan = {
|
|
87
|
+
skin, skinIdx, name, before: N, after: N, removedUnbound: 0, removedDeep: 0,
|
|
88
|
+
prims, joints, removeSet, kept: [...Array(N).keys()], newPos: new Map(), mergeTarget: new Int32Array(N).fill(-1),
|
|
89
|
+
}
|
|
90
|
+
if (N <= target || prims.length === 0) return empty
|
|
91
|
+
|
|
92
|
+
// total weight per joint position across all primitives of this skin
|
|
93
|
+
const total = new Float64Array(N)
|
|
94
|
+
const je: number[] = [0, 0, 0, 0], we: number[] = [0, 0, 0, 0]
|
|
95
|
+
for (const { sets } of prims) {
|
|
96
|
+
for (const { j, w } of sets) {
|
|
97
|
+
const c = j.getCount()
|
|
98
|
+
for (let v = 0; v < c; v++) {
|
|
99
|
+
j.getElement(v, je); w.getElement(v, we)
|
|
100
|
+
for (let k = 0; k < 4; k++) if (we[k] > 0) total[je[k]] += we[k]
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Pass 1 — unbound
|
|
106
|
+
for (let i = 0; i < N; i++) if (total[i] <= WEIGHT_EPS) removeSet.add(i)
|
|
107
|
+
const removedUnbound = removeSet.size
|
|
108
|
+
|
|
109
|
+
// Pass 2 — deepest-first, only if still over budget
|
|
110
|
+
const depth = joints.map(depthOf)
|
|
111
|
+
const nearestKept = (i: number): number => {
|
|
112
|
+
let p = joints[i].getParentNode()
|
|
113
|
+
while (p) { const pos = posOf.get(p); if (pos !== undefined && !removeSet.has(pos)) return pos; p = p.getParentNode() }
|
|
114
|
+
return -1
|
|
115
|
+
}
|
|
116
|
+
let remaining = N - removeSet.size, removedDeep = 0
|
|
117
|
+
if (remaining > target) {
|
|
118
|
+
const order = [...Array(N).keys()].filter((i) => !removeSet.has(i))
|
|
119
|
+
.sort((a, b) => depth[b] - depth[a] || joints[b].getName().localeCompare(joints[a].getName()))
|
|
120
|
+
for (const i of order) {
|
|
121
|
+
if (remaining <= target) break
|
|
122
|
+
if (nearestKept(i) === -1) continue
|
|
123
|
+
removeSet.add(i); remaining--; removedDeep++
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const kept = [...Array(N).keys()].filter((i) => !removeSet.has(i))
|
|
128
|
+
const newPos = new Map<number, number>(kept.map((old, ni) => [old, ni]))
|
|
129
|
+
const mergeTarget = new Int32Array(N).fill(-1)
|
|
130
|
+
for (const i of removeSet) {
|
|
131
|
+
let p = joints[i].getParentNode(), t = -1
|
|
132
|
+
while (p) { const pos = posOf.get(p); if (pos !== undefined && !removeSet.has(pos)) { t = pos; break } p = p.getParentNode() }
|
|
133
|
+
mergeTarget[i] = t
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { skin, skinIdx, name, before: N, after: kept.length, removedUnbound, removedDeep, prims, joints, removeSet, kept, newPos, mergeTarget }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Mutating: rewrite skinning attributes + skin joints/IBM per the plan. Returns dropped weight. */
|
|
140
|
+
export function applySkinPlan(doc: Document, plan: SkinPlan): number {
|
|
141
|
+
const { skin, joints, removeSet, kept, newPos, mergeTarget, prims } = plan
|
|
142
|
+
if (removeSet.size === 0) return 0
|
|
143
|
+
|
|
144
|
+
const finalPos = (old: number): number => {
|
|
145
|
+
const np = newPos.get(old)
|
|
146
|
+
if (np !== undefined) return np
|
|
147
|
+
const t = mergeTarget[old]
|
|
148
|
+
return t < 0 ? -1 : newPos.get(t)!
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let droppedWeight = 0
|
|
152
|
+
for (const { prim, sets } of prims) {
|
|
153
|
+
const c = sets[0].j.getCount()
|
|
154
|
+
const numSets = sets.length
|
|
155
|
+
const cap = 4 * numSets
|
|
156
|
+
const outJ = sets.map(() => new Array(c * 4).fill(0))
|
|
157
|
+
const outW = sets.map(() => new Array(c * 4).fill(0))
|
|
158
|
+
let maxInfluences = 0
|
|
159
|
+
const jv: number[] = [0, 0, 0, 0], wv: number[] = [0, 0, 0, 0]
|
|
160
|
+
|
|
161
|
+
for (let v = 0; v < c; v++) {
|
|
162
|
+
const acc = new Map<number, number>()
|
|
163
|
+
for (const { j, w } of sets) {
|
|
164
|
+
j.getElement(v, jv); w.getElement(v, wv)
|
|
165
|
+
for (let k = 0; k < 4; k++) {
|
|
166
|
+
const ww = wv[k]
|
|
167
|
+
if (ww <= 0) continue
|
|
168
|
+
const fp = finalPos(jv[k])
|
|
169
|
+
if (fp < 0) { droppedWeight += ww; continue }
|
|
170
|
+
acc.set(fp, (acc.get(fp) || 0) + ww)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
let pairs = [...acc.entries()].sort((a, b) => b[1] - a[1])
|
|
174
|
+
if (pairs.length > cap) pairs = pairs.slice(0, cap)
|
|
175
|
+
maxInfluences = Math.max(maxInfluences, pairs.length)
|
|
176
|
+
let sum = 0; for (const p of pairs) sum += p[1]; if (sum <= 0) sum = 1
|
|
177
|
+
for (let s = 0; s < numSets; s++)
|
|
178
|
+
for (let k = 0; k < 4; k++) {
|
|
179
|
+
const idx = s * 4 + k
|
|
180
|
+
if (idx < pairs.length) { outJ[s][v * 4 + k] = pairs[idx][0]; outW[s][v * 4 + k] = pairs[idx][1] / sum }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (let s = 0; s < numSets; s++) {
|
|
185
|
+
const Ja = sets[s].j, Wa = sets[s].w
|
|
186
|
+
for (let v = 0; v < c; v++) {
|
|
187
|
+
Ja.setElement(v, [outJ[s][v * 4], outJ[s][v * 4 + 1], outJ[s][v * 4 + 2], outJ[s][v * 4 + 3]])
|
|
188
|
+
Wa.setElement(v, [outW[s][v * 4], outW[s][v * 4 + 1], outW[s][v * 4 + 2], outW[s][v * 4 + 3]])
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const neededSets = Math.max(1, Math.ceil(maxInfluences / 4))
|
|
192
|
+
for (let s = neededSets; s < numSets; s++) { prim.setAttribute(`JOINTS_${s}`, null); prim.setAttribute(`WEIGHTS_${s}`, null) }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const oldIbm = skin.getInverseBindMatrices()
|
|
196
|
+
if (oldIbm) {
|
|
197
|
+
const m: number[] = new Array(16).fill(0)
|
|
198
|
+
const arr = new Float32Array(kept.length * 16)
|
|
199
|
+
for (let ni = 0; ni < kept.length; ni++) { oldIbm.getElement(kept[ni], m); arr.set(m, ni * 16) }
|
|
200
|
+
const newIbm = doc.createAccessor(`${plan.name}_ibm`).setType('MAT4').setArray(arr).setBuffer(doc.getRoot().listBuffers()[0])
|
|
201
|
+
skin.setInverseBindMatrices(newIbm)
|
|
202
|
+
if (!doc.getRoot().listSkins().some((s) => s.getInverseBindMatrices() === oldIbm)) oldIbm.dispose()
|
|
203
|
+
}
|
|
204
|
+
for (const i of removeSet) skin.removeJoint(joints[i])
|
|
205
|
+
return droppedWeight
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Convenience one-shot (bones only), kept for standalone use.
|
|
209
|
+
export async function reduceGlbBones(inputPath: string, outputPath: string, target = DEFAULT_TARGET): Promise<SkinPlan[]> {
|
|
210
|
+
await ensureCodecs()
|
|
211
|
+
const io = makeIO()
|
|
212
|
+
const doc = await io.read(inputPath)
|
|
213
|
+
const { bySkin } = gatherSkinPrims(doc)
|
|
214
|
+
const plans = doc.getRoot().listSkins().map((skin, i) => planSkin(skin, i, bySkin.get(skin) ?? [], target))
|
|
215
|
+
for (const p of plans) applySkinPlan(doc, p)
|
|
216
|
+
await io.write(outputPath, doc)
|
|
217
|
+
return plans
|
|
218
|
+
}
|