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,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
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Which primitives can merge (and as what), and what each material brings to an atlas.
|
|
2
|
+
|
|
3
|
+
import type { Document, Material, Node, Primitive, Texture, TextureInfo } from '@gltf-transform/core'
|
|
4
|
+
import { readAttr, readIndices } from './accessors'
|
|
5
|
+
import { I4, point, worldMatrix } from './matrix'
|
|
6
|
+
import { imageSize } from './textures'
|
|
7
|
+
import type { Kind, Mat4, MergeSkipped, Source } from './types'
|
|
8
|
+
|
|
9
|
+
export interface MatInfo {
|
|
10
|
+
material: Material
|
|
11
|
+
classKey: string
|
|
12
|
+
/** why the material cannot share an atlas (own group) */
|
|
13
|
+
own: string | null
|
|
14
|
+
maps: Partial<Record<'base' | 'normal' | 'mr' | 'occ' | 'emissive', Texture>>
|
|
15
|
+
sizes: Partial<Record<'base' | 'normal' | 'mr' | 'occ' | 'emissive', [number, number]>>
|
|
16
|
+
/** UV range over every candidate primitive using it */
|
|
17
|
+
uv: { u0: number; v0: number; ru: number; rv: number } | null
|
|
18
|
+
/** surface on the model and UV coverage (fraction of the [u0,u0+ru)×[v0,v0+rv) rectangle) */
|
|
19
|
+
area: number; uvArea: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const UV_EPS = 2e-3
|
|
23
|
+
const MAX_REPEAT = 4
|
|
24
|
+
|
|
25
|
+
export const classSuffix = (m: Material): string => {
|
|
26
|
+
const parts: string[] = []
|
|
27
|
+
if (m.getAlphaMode() === 'BLEND') parts.push('blend')
|
|
28
|
+
if (m.getAlphaMode() === 'MASK') parts.push('mask')
|
|
29
|
+
if (m.getDoubleSided()) parts.push('2side')
|
|
30
|
+
if (m.getExtension('KHR_materials_unlit')) parts.push('unlit')
|
|
31
|
+
return parts.join('.')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function texInfos(m: Material): TextureInfo[] {
|
|
35
|
+
return [ m.getBaseColorTextureInfo(), m.getNormalTextureInfo(), m.getMetallicRoughnessTextureInfo(), m.getOcclusionTextureInfo(), m.getEmissiveTextureInfo() ].filter((t): t is TextureInfo => !!t)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function materialInfo(m: Material, ownId: number, uvOf: (m: Material) => number[][], measure: (m: Material) => { area: number; uvArea: number }): Promise<MatInfo> {
|
|
39
|
+
const maps: MatInfo['maps'] = {}
|
|
40
|
+
if (m.getBaseColorTexture()) maps.base = m.getBaseColorTexture()!
|
|
41
|
+
if (m.getNormalTexture()) maps.normal = m.getNormalTexture()!
|
|
42
|
+
if (m.getMetallicRoughnessTexture()) maps.mr = m.getMetallicRoughnessTexture()!
|
|
43
|
+
if (m.getOcclusionTexture()) maps.occ = m.getOcclusionTexture()!
|
|
44
|
+
if (m.getEmissiveTexture()) maps.emissive = m.getEmissiveTexture()!
|
|
45
|
+
const sizes: MatInfo['sizes'] = {}
|
|
46
|
+
for (const k of Object.keys(maps) as (keyof MatInfo['maps'])[]) { const s = await imageSize(maps[k]!); if (s) sizes[k] = s }
|
|
47
|
+
let own: string | null = null
|
|
48
|
+
const exts = m.listExtensions().map((e) => e.extensionName).filter((n) => n !== 'KHR_materials_unlit')
|
|
49
|
+
if (exts.length) own = `material extension ${exts.join(', ')}`
|
|
50
|
+
else if (texInfos(m).some((i) => i.getExtension('KHR_texture_transform'))) own = 'KHR_texture_transform'
|
|
51
|
+
else if ((Object.keys(maps) as (keyof MatInfo['maps'])[]).some((k) => !sizes[k])) own = 'a texture could not be read'
|
|
52
|
+
// UV range: every primitive of the material, over all its texture-using channels (one UV set: TEXCOORD_0)
|
|
53
|
+
let uv: MatInfo['uv'] = null
|
|
54
|
+
if (Object.keys(maps).length) {
|
|
55
|
+
let umin = Infinity, umax = -Infinity, vmin = Infinity, vmax = -Infinity
|
|
56
|
+
for (const arr of uvOf(m)) for (let i = 0; i < arr.length; i += 2) { umin = Math.min(umin, arr[i]); umax = Math.max(umax, arr[i]); vmin = Math.min(vmin, arr[i + 1]); vmax = Math.max(vmax, arr[i + 1]) }
|
|
57
|
+
if (umin === Infinity) uv = { u0: 0, v0: 0, ru: 1, rv: 1 }
|
|
58
|
+
else {
|
|
59
|
+
const inUnit = umin >= -UV_EPS && umax <= 1 + UV_EPS && vmin >= -UV_EPS && vmax <= 1 + UV_EPS
|
|
60
|
+
if (inUnit) uv = { u0: 0, v0: 0, ru: 1, rv: 1 }
|
|
61
|
+
else {
|
|
62
|
+
const u0 = Math.floor(umin + UV_EPS), v0 = Math.floor(vmin + UV_EPS)
|
|
63
|
+
const ru = Math.ceil(umax - UV_EPS) - u0, rv = Math.ceil(vmax - UV_EPS) - v0
|
|
64
|
+
if (ru <= MAX_REPEAT && rv <= MAX_REPEAT && ru >= 1 && rv >= 1) uv = { u0, v0, ru, rv }
|
|
65
|
+
else if (!own) own = `UVs tile the texture ${Math.max(ru, rv)}×`
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const classKey = own ? `own:${ownId}` : `${m.getAlphaMode()}|${m.getAlphaMode() === 'MASK' ? m.getAlphaCutoff().toFixed(3) : ''}|${m.getDoubleSided() ? 1 : 0}|${m.getExtension('KHR_materials_unlit') ? 1 : 0}`
|
|
70
|
+
const { area, uvArea } = measure(m)
|
|
71
|
+
const span = uv ? uv.ru * uv.rv : 1
|
|
72
|
+
return { material: m, classKey, own, maps, sizes, uv, area, uvArea: uvArea / span }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const primTris = (p: Primitive): number => Math.floor((p.getIndices()?.getCount() ?? p.getAttribute('POSITION')?.getCount() ?? 0) / 3)
|
|
76
|
+
|
|
77
|
+
/** Every mergeable primitive with its kind, and the reasons for the rest. */
|
|
78
|
+
export function classify(doc: Document, exclude: RegExp | null): { sources: Source[]; skipped: MergeSkipped[]; joints: Set<Node>; animated: Set<Node> } {
|
|
79
|
+
const root = doc.getRoot()
|
|
80
|
+
const joints = new Set<Node>(root.listSkins().flatMap((s) => s.listJoints()))
|
|
81
|
+
const animated = new Set<Node>()
|
|
82
|
+
for (const a of root.listAnimations()) for (const c of a.listChannels()) { const n = c.getTargetNode(); if (n) animated.add(n) }
|
|
83
|
+
const sources: Source[] = []
|
|
84
|
+
const skipped: MergeSkipped[] = []
|
|
85
|
+
const seen = new Set<Node>()
|
|
86
|
+
for (const scene of root.listScenes()) scene.traverse((node) => {
|
|
87
|
+
if (seen.has(node)) return
|
|
88
|
+
seen.add(node)
|
|
89
|
+
const mesh = node.getMesh()
|
|
90
|
+
if (!mesh) return
|
|
91
|
+
const name = node.getName() || mesh.getName() || 'mesh'
|
|
92
|
+
if (exclude && (exclude.test(node.getName()) || exclude.test(mesh.getName()))) { skipped.push({ name, reason: 'excluded' }); return }
|
|
93
|
+
// the chain up to the nearest joint (a rigid part) or the root (a static part)
|
|
94
|
+
let kind: Kind = node.getSkin() ? 'skinned' : 'static'
|
|
95
|
+
let joint: Node | null = null
|
|
96
|
+
if (kind !== 'skinned') {
|
|
97
|
+
for (let n: Node | null = node; n; n = n.getParentNode()) {
|
|
98
|
+
if (joints.has(n)) { joint = n; break }
|
|
99
|
+
if (animated.has(n)) { skipped.push({ name, reason: `animated node${n === node ? '' : ` (${n.getName()})`}` }); return }
|
|
100
|
+
}
|
|
101
|
+
if (joint) kind = 'rigid'
|
|
102
|
+
}
|
|
103
|
+
for (const prim of mesh.listPrimitives()) {
|
|
104
|
+
const pname = mesh.listPrimitives().length > 1 ? `${name}[${mesh.listPrimitives().indexOf(prim)}]` : name
|
|
105
|
+
if (prim.getMode() !== 4) { skipped.push({ name: pname, reason: 'not triangles' }); continue }
|
|
106
|
+
if (!prim.getAttribute('POSITION')) { skipped.push({ name: pname, reason: 'no positions' }); continue }
|
|
107
|
+
if (prim.listTargets().length) { skipped.push({ name: pname, reason: 'morph targets' }); continue }
|
|
108
|
+
if (kind === 'skinned' && (!prim.getAttribute('JOINTS_0') || !prim.getAttribute('WEIGHTS_0'))) { skipped.push({ name: pname, reason: 'skinned node without joints/weights' }); continue }
|
|
109
|
+
sources.push({ node, mesh, prim, kind, skin: node.getSkin(), joint, name: pname, material: prim.getMaterial(), triangles: primTris(prim) })
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
return { sources, skipped, joints, animated }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Surface area on the model (bind space for skinned parts, the rest pose for rigid / static ones) and
|
|
116
|
+
* how much of the UV square the triangles cover — the texel-density budget of the atlas packer. */
|
|
117
|
+
export function measureSurface(sources: Source[], worldCache: Map<Node, Mat4>): { area: number; uvArea: number } {
|
|
118
|
+
let area = 0, uvArea = 0
|
|
119
|
+
for (const s of sources) {
|
|
120
|
+
const M = s.kind === 'skinned' ? I4 : worldMatrix(s.node, worldCache)
|
|
121
|
+
const pos = readAttr(s.prim.getAttribute('POSITION')!).data
|
|
122
|
+
const uvAcc = s.prim.getAttribute('TEXCOORD_0')
|
|
123
|
+
const uv = uvAcc ? readAttr(uvAcc).data : null
|
|
124
|
+
const idx = readIndices(s.prim)
|
|
125
|
+
for (let t = 0; t + 2 < idx.length; t += 3) {
|
|
126
|
+
const [ a, b, c ] = [ idx[t], idx[t + 1], idx[t + 2] ].map((i) => point(M, [ pos[i * 3], pos[i * 3 + 1], pos[i * 3 + 2] ]))
|
|
127
|
+
const ux = b[0] - a[0], uy = b[1] - a[1], uz = b[2] - a[2], vx = c[0] - a[0], vy = c[1] - a[1], vz = c[2] - a[2]
|
|
128
|
+
area += 0.5 * Math.hypot(uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx)
|
|
129
|
+
if (uv) {
|
|
130
|
+
const i0 = idx[t] * 2, i1 = idx[t + 1] * 2, i2 = idx[t + 2] * 2
|
|
131
|
+
uvArea += 0.5 * Math.abs((uv[i1] - uv[i0]) * (uv[i2 + 1] - uv[i0 + 1]) - (uv[i2] - uv[i0]) * (uv[i1 + 1] - uv[i0 + 1]))
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { area, uvArea }
|
|
136
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// The merged primitive: every source's vertices moved into one space (world for static parts, the
|
|
2
|
+
// canonical bind space for skinned / rigid ones), UVs remapped into the atlas, one union skin.
|
|
3
|
+
|
|
4
|
+
import type { Accessor, Document, Material, Node, Primitive, Skin } from '@gltf-transform/core'
|
|
5
|
+
import { flatNormals, readAttr, readIndices } from './accessors'
|
|
6
|
+
import { bySize, canonicalBinds, intoCanon } from './bindSpace'
|
|
7
|
+
import { I4, det3, dir, invert, localMatrix, mul, normalMatrix, point, worldMatrix } from './matrix'
|
|
8
|
+
import type { AtlasLayout, Mat4, MergeGroupPlan, Source, Tile } from './types'
|
|
9
|
+
|
|
10
|
+
interface UnionSkin { skin: Skin; index: Map<Node, number>; ibm: Map<Node, Mat4>; bindSpace: Mat4 }
|
|
11
|
+
|
|
12
|
+
/** One skin over every joint the group touches, inverse bind matrices in one canonical space. */
|
|
13
|
+
function unionSkin(doc: Document, g: MergeGroupPlan): UnionSkin {
|
|
14
|
+
const root = doc.getRoot()
|
|
15
|
+
const skins = bySize(g.sources.map((s) => s.skin))
|
|
16
|
+
const { ibm } = canonicalBinds(skins, false) // the plan kept only skins that can be unified
|
|
17
|
+
const cache = new Map<Node, Mat4>()
|
|
18
|
+
const lead = skins[0]?.listJoints()[0]
|
|
19
|
+
const bindSpace: Mat4 = lead ? mul(worldMatrix(lead, cache), ibm.get(lead)!) : I4
|
|
20
|
+
// a rigid part's joint that no skin of the group lists: any skin of the file that has it, else the rest pose
|
|
21
|
+
for (const s of g.sources) {
|
|
22
|
+
if (!s.joint || ibm.has(s.joint)) continue
|
|
23
|
+
let found: Mat4 | null = null
|
|
24
|
+
for (const skin of root.listSkins()) {
|
|
25
|
+
const i = skin.listJoints().indexOf(s.joint)
|
|
26
|
+
const acc = skin.getInverseBindMatrices()
|
|
27
|
+
if (i >= 0 && acc) { const m = new Array<number>(16); acc.getElement(i, m); found = m; break }
|
|
28
|
+
}
|
|
29
|
+
ibm.set(s.joint, found ?? mul(invert(worldMatrix(s.joint, cache)) ?? I4, bindSpace))
|
|
30
|
+
}
|
|
31
|
+
// joints in tree order
|
|
32
|
+
const ordered: Node[] = []
|
|
33
|
+
const seen = new Set<Node>()
|
|
34
|
+
for (const scene of root.listScenes()) scene.traverse((n) => { if (ibm.has(n) && !seen.has(n)) { seen.add(n); ordered.push(n) } })
|
|
35
|
+
for (const j of ibm.keys()) if (!seen.has(j)) { seen.add(j); ordered.push(j) }
|
|
36
|
+
const index = new Map<Node, number>(ordered.map((j, i) => [ j, i ]))
|
|
37
|
+
const mats = new Float32Array(ordered.length * 16)
|
|
38
|
+
ordered.forEach((j, i) => mats.set(ibm.get(j)!, i * 16))
|
|
39
|
+
const buffer = root.listBuffers()[0] ?? doc.createBuffer()
|
|
40
|
+
const skin = doc.createSkin(g.label).setInverseBindMatrices(doc.createAccessor(`${g.label}_ibm`).setType('MAT4').setArray(mats).setBuffer(buffer))
|
|
41
|
+
for (const j of ordered) skin.addJoint(j)
|
|
42
|
+
const skel = skins[0]?.getSkeleton()
|
|
43
|
+
if (skel) skin.setSkeleton(skel)
|
|
44
|
+
return { skin, index, ibm, bindSpace }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Where a source's vertices go: the transform into the merged primitive's space. */
|
|
48
|
+
function sourceTransform(s: Source, u: UnionSkin | null, cache: Map<Node, Mat4>): Mat4 {
|
|
49
|
+
if (s.kind === 'static') return worldMatrix(s.node, cache)
|
|
50
|
+
if (s.kind === 'rigid') {
|
|
51
|
+
// world = J(t) · IBM_j · p' must equal J(t) · L · p (L = the chain from the joint down to the node)
|
|
52
|
+
let L: Mat4 = I4
|
|
53
|
+
const chain: Node[] = []
|
|
54
|
+
for (let n: Node | null = s.node; n && n !== s.joint; n = n.getParentNode()) chain.unshift(n)
|
|
55
|
+
for (const n of chain) L = mul(L, localMatrix(n))
|
|
56
|
+
return mul(invert(u!.ibm.get(s.joint!)!) ?? I4, L)
|
|
57
|
+
}
|
|
58
|
+
// skinned: the part's bind space → the canonical one (D = IBM_canon⁻¹ · IBM_part, same for every shared joint)
|
|
59
|
+
return intoCanon(s.skin!, u!.ibm) ?? I4
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function mergeGroup(doc: Document, g: MergeGroupPlan, tileOf: Map<Material, Tile> | null, atlas: AtlasLayout | null, material: Material | null): { node: Node; vertices: number; triangles: number } {
|
|
63
|
+
const root = doc.getRoot()
|
|
64
|
+
const buffer = root.listBuffers()[0] ?? doc.createBuffer()
|
|
65
|
+
const cache = new Map<Node, Mat4>()
|
|
66
|
+
const u = g.family === 'skinned' ? unionSkin(doc, g) : null
|
|
67
|
+
const has = (sem: string) => g.sources.map((s) => !!s.prim.getAttribute(sem))
|
|
68
|
+
const wantTangent = has('TANGENT').every(Boolean)
|
|
69
|
+
const wantUv1 = has('TEXCOORD_1').every(Boolean)
|
|
70
|
+
const wantColor = has('COLOR_0').some(Boolean)
|
|
71
|
+
const wantJ1 = u ? has('JOINTS_1').some(Boolean) : false
|
|
72
|
+
const total = g.sources.reduce((n, s) => n + s.prim.getAttribute('POSITION')!.getCount(), 0)
|
|
73
|
+
const P = new Float32Array(total * 3), N = new Float32Array(total * 3), UV = new Float32Array(total * 2)
|
|
74
|
+
const T = wantTangent ? new Float32Array(total * 4) : null
|
|
75
|
+
const UV1 = wantUv1 ? new Float32Array(total * 2) : null
|
|
76
|
+
const C = wantColor ? new Float32Array(total * 4).fill(1) : null
|
|
77
|
+
const J = u ? new Uint16Array(total * 4) : null, W = u ? new Float32Array(total * 4) : null
|
|
78
|
+
const J1 = wantJ1 ? new Uint16Array(total * 4) : null, W1 = wantJ1 ? new Float32Array(total * 4) : null
|
|
79
|
+
const indices: number[] = []
|
|
80
|
+
let base = 0
|
|
81
|
+
for (const s of g.sources) {
|
|
82
|
+
const prim = s.prim
|
|
83
|
+
const count = prim.getAttribute('POSITION')!.getCount()
|
|
84
|
+
const M = sourceTransform(s, u, cache)
|
|
85
|
+
const n3 = normalMatrix(M)
|
|
86
|
+
const flip = det3(M) < 0
|
|
87
|
+
const pos = readAttr(prim.getAttribute('POSITION')!).data
|
|
88
|
+
const idx = readIndices(prim)
|
|
89
|
+
const nrmAcc = prim.getAttribute('NORMAL')
|
|
90
|
+
const nrm = nrmAcc ? readAttr(nrmAcc).data : flatNormals(pos, idx)
|
|
91
|
+
const uvAcc = prim.getAttribute('TEXCOORD_0')
|
|
92
|
+
const uv = uvAcc ? readAttr(uvAcc).data : null
|
|
93
|
+
const tile = s.material && tileOf ? tileOf.get(s.material) ?? null : null
|
|
94
|
+
const S = atlas?.size ?? 1
|
|
95
|
+
for (let i = 0; i < count; i++) {
|
|
96
|
+
const p = point(M, [ pos[i * 3], pos[i * 3 + 1], pos[i * 3 + 2] ])
|
|
97
|
+
P[(base + i) * 3] = p[0]; P[(base + i) * 3 + 1] = p[1]; P[(base + i) * 3 + 2] = p[2]
|
|
98
|
+
const nn = dir(n3, [ nrm[i * 3], nrm[i * 3 + 1], nrm[i * 3 + 2] ])
|
|
99
|
+
N[(base + i) * 3] = nn[0]; N[(base + i) * 3 + 1] = nn[1]; N[(base + i) * 3 + 2] = nn[2]
|
|
100
|
+
let uu = uv ? uv[i * 2] : 0.5, vv = uv ? uv[i * 2 + 1] : 0.5
|
|
101
|
+
if (tile) {
|
|
102
|
+
// into the tile: [u0, u0 + ru) → [x, x + w) of an S-wide atlas (clamped to the tile's own texels)
|
|
103
|
+
const fu = Math.max(0, Math.min(1, (uu - tile.u0) / tile.ru)), fv = Math.max(0, Math.min(1, (vv - tile.v0) / tile.rv))
|
|
104
|
+
uu = (tile.x + fu * tile.w) / S; vv = (tile.y + fv * tile.h) / S
|
|
105
|
+
}
|
|
106
|
+
UV[(base + i) * 2] = uu; UV[(base + i) * 2 + 1] = vv
|
|
107
|
+
}
|
|
108
|
+
if (T) {
|
|
109
|
+
const t = readAttr(prim.getAttribute('TANGENT')!)
|
|
110
|
+
for (let i = 0; i < count; i++) {
|
|
111
|
+
const el = t.size === 4 ? [ t.data[i * 4], t.data[i * 4 + 1], t.data[i * 4 + 2], t.data[i * 4 + 3] ] : [ t.data[i * 3], t.data[i * 3 + 1], t.data[i * 3 + 2], 1 ]
|
|
112
|
+
const d = dir(n3, el)
|
|
113
|
+
T[(base + i) * 4] = d[0]; T[(base + i) * 4 + 1] = d[1]; T[(base + i) * 4 + 2] = d[2]; T[(base + i) * 4 + 3] = flip ? -el[3] : el[3]
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (UV1) UV1.set(readAttr(prim.getAttribute('TEXCOORD_1')!).data, base * 2)
|
|
117
|
+
if (C) {
|
|
118
|
+
const cAcc = prim.getAttribute('COLOR_0')
|
|
119
|
+
if (cAcc) { const c = readAttr(cAcc); for (let i = 0; i < count; i++) for (let k = 0; k < 4; k++) C[(base + i) * 4 + k] = k < c.size ? c.data[i * c.size + k] : 1 }
|
|
120
|
+
}
|
|
121
|
+
if (J && W) {
|
|
122
|
+
if (s.kind === 'skinned') {
|
|
123
|
+
const remap = s.skin!.listJoints().map((j) => u!.index.get(j) ?? 0)
|
|
124
|
+
const readSet = (n: number, Jo: Uint16Array, Wo: Float32Array) => {
|
|
125
|
+
const ja = readAttr(prim.getAttribute(`JOINTS_${n}`)!).data, wa = readAttr(prim.getAttribute(`WEIGHTS_${n}`)!).data
|
|
126
|
+
for (let i = 0; i < count * 4; i++) { Jo[base * 4 + i] = remap[ja[i]] ?? 0; Wo[base * 4 + i] = wa[i] }
|
|
127
|
+
}
|
|
128
|
+
readSet(0, J, W)
|
|
129
|
+
if (J1 && W1 && prim.getAttribute('JOINTS_1')) readSet(1, J1, W1)
|
|
130
|
+
} else {
|
|
131
|
+
const ji = u!.index.get(s.joint!) ?? 0
|
|
132
|
+
for (let i = 0; i < count; i++) { J[(base + i) * 4] = ji; W[(base + i) * 4] = 1 }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (let t = 0; t + 2 < idx.length; t += 3) {
|
|
136
|
+
if (flip) indices.push(base + idx[t], base + idx[t + 2], base + idx[t + 1])
|
|
137
|
+
else indices.push(base + idx[t], base + idx[t + 1], base + idx[t + 2])
|
|
138
|
+
}
|
|
139
|
+
base += count
|
|
140
|
+
}
|
|
141
|
+
const acc = (name: string, type: 'SCALAR' | 'VEC2' | 'VEC3' | 'VEC4', arr: Float32Array | Uint16Array | Uint8Array | Uint32Array) => doc.createAccessor(`${g.label}_${name}`).setType(type).setArray(arr as never).setBuffer(buffer)
|
|
142
|
+
const prim = doc.createPrimitive().setMode(4)
|
|
143
|
+
.setAttribute('POSITION', acc('pos', 'VEC3', P))
|
|
144
|
+
.setAttribute('NORMAL', acc('nrm', 'VEC3', N))
|
|
145
|
+
.setAttribute('TEXCOORD_0', acc('uv', 'VEC2', UV))
|
|
146
|
+
if (T) prim.setAttribute('TANGENT', acc('tan', 'VEC4', T))
|
|
147
|
+
if (UV1) prim.setAttribute('TEXCOORD_1', acc('uv1', 'VEC2', UV1))
|
|
148
|
+
if (C) prim.setAttribute('COLOR_0', acc('col', 'VEC4', C))
|
|
149
|
+
if (J && W) {
|
|
150
|
+
prim.setAttribute('JOINTS_0', acc('j0', 'VEC4', u!.index.size <= 256 ? Uint8Array.from(J) : J)).setAttribute('WEIGHTS_0', acc('w0', 'VEC4', W))
|
|
151
|
+
if (J1 && W1) prim.setAttribute('JOINTS_1', acc('j1', 'VEC4', u!.index.size <= 256 ? Uint8Array.from(J1) : J1)).setAttribute('WEIGHTS_1', acc('w1', 'VEC4', W1))
|
|
152
|
+
}
|
|
153
|
+
prim.setIndices(acc('idx', 'SCALAR', total > 65535 ? Uint32Array.from(indices) : Uint16Array.from(indices)))
|
|
154
|
+
prim.setMaterial(material ?? g.materials[0] ?? null)
|
|
155
|
+
const mesh = doc.createMesh(g.label).addPrimitive(prim)
|
|
156
|
+
const node = doc.createNode(g.label).setMesh(mesh)
|
|
157
|
+
if (u) {
|
|
158
|
+
node.setSkin(u.skin)
|
|
159
|
+
// where the canonical part sat (the spec ignores a skinned node's transform; a host that reads it sees the same)
|
|
160
|
+
const first = g.sources.find((s) => s.kind === 'skinned')?.node
|
|
161
|
+
const parent = first?.getParentNode()
|
|
162
|
+
if (first) node.setTranslation(first.getTranslation()).setRotation(first.getRotation()).setScale(first.getScale())
|
|
163
|
+
if (parent) parent.addChild(node)
|
|
164
|
+
else (root.getDefaultScene() ?? root.listScenes()[0]).addChild(node)
|
|
165
|
+
} else (root.getDefaultScene() ?? root.listScenes()[0]).addChild(node)
|
|
166
|
+
return { node, vertices: total, triangles: indices.length / 3 }
|
|
167
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Mesh merging + texture atlasing (`lecodes assets doctor --merge`): fewer draw calls per model.
|
|
2
|
+
//
|
|
3
|
+
// A modular character arrives as a dozen parts — head, hands, shirt, pants, boots, vest, helmet,
|
|
4
|
+
// pouches — each its own mesh node, its own skin (a subset of the joints) and its own material with
|
|
5
|
+
// its own base / normal / ORM maps. The renderer draws one primitive per material, so the character
|
|
6
|
+
// costs 16 draw calls and 45 texture binds; a squad of twelve is 200 draw calls of characters alone.
|
|
7
|
+
// This pass folds all of it into ONE primitive over ONE material per compatibility class:
|
|
8
|
+
//
|
|
9
|
+
// • every primitive is classified — SKINNED (on a skin), RIGID (a static mesh sitting under a joint:
|
|
10
|
+
// a hat, a strap, a holstered weapon — it is skinned to that joint with weight 1), STATIC (a plain
|
|
11
|
+
// node: its world transform is baked into the vertices); anything animated by a node channel, with
|
|
12
|
+
// morph targets, or excluded by name is left alone (reported);
|
|
13
|
+
// • primitives are grouped by what the material pipeline cannot mix in one draw: alpha mode (+ cutoff),
|
|
14
|
+
// double-sidedness, unlit; a material carrying an extension this pass does not understand, or a
|
|
15
|
+
// KHR_texture_transform, or UVs that tile the texture too far, forms a group of its own (its geometry
|
|
16
|
+
// still merges with its siblings, its textures are kept as they are);
|
|
17
|
+
// • one union SKIN per skinned group: joints in tree order, the inverse bind matrices from the skin that
|
|
18
|
+
// has most of them; a part bound in another bind space (each of its inverse bind matrices differs from
|
|
19
|
+
// the canonical one by the same rigid transform) is moved into the canonical space; a part whose joints
|
|
20
|
+
// disagree by different transforms is skipped;
|
|
21
|
+
// • one ATLAS per map kind (base colour, normal, packed occlusion-roughness-metallic, emissive) per group:
|
|
22
|
+
// every material gets a tile sized like its largest map (materials with the same maps and factors share
|
|
23
|
+
// a tile), the tiles are shelf-packed into the smallest power-of-two square that holds them at full
|
|
24
|
+
// resolution, or scaled down to fit `--atlas-size` (default 2048) BY SURFACE AREA: each material's area
|
|
25
|
+
// on the model (its triangles in bind / world space) and its UV coverage give the texel density its tile
|
|
26
|
+
// would have, and the tiles are sized so every part lands at the same texels-per-metre — a torso keeps
|
|
27
|
+
// its map, a strap or a buckle gets the small tile it needs, never past a map's native size
|
|
28
|
+
// (`--atlas-uniform` = the plain rule instead: every tile at the same fraction); each tile is padded by
|
|
29
|
+
// `--atlas-pad` texels of edge replication (bilinear + the first mips stay clean); the material's
|
|
30
|
+
// FACTORS are baked into its tile (base colour factor in linear light, roughness / metallic into the
|
|
31
|
+
// ORM channels, normal scale, emissive factor, occlusion strength), so the merged material is factor-
|
|
32
|
+
// free and reads the same as the parts did; UVs are remapped into the tile; a material without a map
|
|
33
|
+
// gets a flat tile of its factor;
|
|
34
|
+
// • the merged primitive carries the union of the parts' attributes (positions, normals — computed for a
|
|
35
|
+
// part without any —, UVs, colours, joints / weights; tangents only when every part has them, a
|
|
36
|
+
// lightmap UV set only when every part has one), the old meshes, skins, materials, textures and empty
|
|
37
|
+
// part nodes are gone, and the new node is named `merged` (`merged.blend`, `merged.mask`, `merged.2side`,
|
|
38
|
+
// `merged.<material>` for the further classes).
|
|
39
|
+
//
|
|
40
|
+
// The pass runs BEFORE the texture passes: the atlases are written as PNG and picked up by `--ktx2` /
|
|
41
|
+
// the size cap like any other texture — except that an atlas is capped by `--atlas-size`, not
|
|
42
|
+
// `--max-size` (the per-part cap): a kit whose 15 maps were capped at 1024 each is one 4096² map now.
|
|
43
|
+
// KTX2 source textures are decoded through KTX-Software's `ktx extract` (next to toktx); without the
|
|
44
|
+
// tool a group holding KTX2 maps is reported and skipped.
|
|
45
|
+
import type { Document, Material, Mesh, Node } from '@gltf-transform/core'
|
|
46
|
+
import { buildAtlas } from './atlasPixels'
|
|
47
|
+
import { mergeGroup } from './geometry'
|
|
48
|
+
import type { AtlasLayout, MergePlan, MergeResult, Tile } from './types'
|
|
49
|
+
|
|
50
|
+
export { planMerge } from './plan'
|
|
51
|
+
export { findKtxTool } from './textures'
|
|
52
|
+
export { DEFAULT_ATLAS_PAD, DEFAULT_ATLAS_SIZE } from './types'
|
|
53
|
+
export type { AtlasLayout, MergeGroupPlan, MergeOptions, MergePlan, MergeResult, MergeSkipped } from './types'
|
|
54
|
+
|
|
55
|
+
/** Drop what the merge replaced: the parts' primitives, meshes, skins, nodes (when empty), unused materials / textures / accessors. */
|
|
56
|
+
function cleanup(doc: Document, plan: MergePlan, mergedNodes: Set<Node>): void {
|
|
57
|
+
const root = doc.getRoot()
|
|
58
|
+
const animated = new Set<Node>()
|
|
59
|
+
for (const a of root.listAnimations()) for (const c of a.listChannels()) { const n = c.getTargetNode(); if (n) animated.add(n) }
|
|
60
|
+
const joints = new Set<Node>(root.listSkins().flatMap((s) => s.listJoints()))
|
|
61
|
+
const meshes = new Set<Mesh>()
|
|
62
|
+
const nodes = new Set<Node>()
|
|
63
|
+
for (const g of plan.groups) for (const s of g.sources) { s.prim.dispose(); meshes.add(s.mesh); nodes.add(s.node) }
|
|
64
|
+
for (const m of meshes) if (!m.listPrimitives().length) m.dispose()
|
|
65
|
+
for (const n of nodes) {
|
|
66
|
+
if (mergedNodes.has(n) || n.getMesh() || joints.has(n) || animated.has(n)) continue
|
|
67
|
+
if (n.listChildren().length) continue
|
|
68
|
+
n.setSkin(null)
|
|
69
|
+
n.dispose()
|
|
70
|
+
}
|
|
71
|
+
for (const s of root.listSkins()) if (!s.listParents().some((p) => p.propertyType === 'Node')) s.dispose()
|
|
72
|
+
for (const m of root.listMaterials()) if (!m.listParents().some((p) => p.propertyType === 'Primitive')) m.dispose()
|
|
73
|
+
for (const t of root.listTextures()) if (!t.listParents().some((p) => p.propertyType !== 'Root')) t.dispose()
|
|
74
|
+
for (const a of root.listAccessors()) if (a.listParents().every((p) => p === root)) a.dispose()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** "tiles at 40%" / "tiles 12–100% by area" — nothing when every tile is native. */
|
|
78
|
+
export const describeScale = (a: AtlasLayout): string =>
|
|
79
|
+
a.scaleMax >= 0.999 && a.scale >= 0.999 ? '' : a.byArea && a.scaleMax - a.scale > 0.005 ? ` (tiles ${(a.scale * 100).toFixed(0)}–${(a.scaleMax * 100).toFixed(0)}% by area)` : ` (tiles at ${(a.scale * 100).toFixed(0)}%)`
|
|
80
|
+
|
|
81
|
+
/** Apply the plan (mutates). The atlas textures come back as PNG for the caller's texture passes. */
|
|
82
|
+
export async function applyMerge(doc: Document, plan: MergePlan): Promise<MergeResult> {
|
|
83
|
+
const res: MergeResult = { groups: [], skipped: plan.skipped, textures: [], primitivesBefore: plan.primitivesBefore, primitivesAfter: plan.primitivesAfter }
|
|
84
|
+
const mergedNodes = new Set<Node>()
|
|
85
|
+
for (const g of plan.groups) {
|
|
86
|
+
let material: Material | null = null, tileOf: Map<Material, Tile> | null = null
|
|
87
|
+
if (g.atlas) {
|
|
88
|
+
const built = await buildAtlas(doc, g, plan.ktxTool)
|
|
89
|
+
material = built.material; tileOf = built.tileOf
|
|
90
|
+
res.textures.push(...built.textures)
|
|
91
|
+
}
|
|
92
|
+
const r = mergeGroup(doc, g, tileOf, g.atlas, material)
|
|
93
|
+
mergedNodes.add(r.node)
|
|
94
|
+
res.groups.push({ label: g.label, primitives: g.sources.length, materials: g.materials.length, vertices: r.vertices, triangles: r.triangles, joints: g.joints, atlas: g.atlas ? `${g.atlas.size}² ${g.atlas.maps.join('+')}${describeScale(g.atlas)}` : null })
|
|
95
|
+
}
|
|
96
|
+
cleanup(doc, plan, mergedNodes)
|
|
97
|
+
return res
|
|
98
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// A small matrix kit (column-major, like glTF).
|
|
2
|
+
|
|
3
|
+
import type { Node } from '@gltf-transform/core'
|
|
4
|
+
import type { Mat4 } from './types'
|
|
5
|
+
|
|
6
|
+
export const I4: Mat4 = [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ]
|
|
7
|
+
export const mul = (a: Mat4, b: Mat4): Mat4 => {
|
|
8
|
+
const o = new Array<number>(16).fill(0)
|
|
9
|
+
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]
|
|
10
|
+
return o
|
|
11
|
+
}
|
|
12
|
+
export const invert = (m: Mat4): Mat4 | null => {
|
|
13
|
+
const [ a00, a01, a02, a03, a10, a11, a12, a13, a20, a21, a22, a23, a30, a31, a32, a33 ] = m
|
|
14
|
+
const b00 = a00 * a11 - a01 * a10, b01 = a00 * a12 - a02 * a10, b02 = a00 * a13 - a03 * a10, b03 = a01 * a12 - a02 * a11
|
|
15
|
+
const b04 = a01 * a13 - a03 * a11, b05 = a02 * a13 - a03 * a12, b06 = a20 * a31 - a21 * a30, b07 = a20 * a32 - a22 * a30
|
|
16
|
+
const b08 = a20 * a33 - a23 * a30, b09 = a21 * a32 - a22 * a31, b10 = a21 * a33 - a23 * a31, b11 = a22 * a33 - a23 * a32
|
|
17
|
+
let det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06
|
|
18
|
+
if (Math.abs(det) < 1e-30) return null
|
|
19
|
+
det = 1 / det
|
|
20
|
+
return [
|
|
21
|
+
(a11 * b11 - a12 * b10 + a13 * b09) * det, (a02 * b10 - a01 * b11 - a03 * b09) * det, (a31 * b05 - a32 * b04 + a33 * b03) * det, (a22 * b04 - a21 * b05 - a23 * b03) * det,
|
|
22
|
+
(a12 * b08 - a10 * b11 - a13 * b07) * det, (a00 * b11 - a02 * b08 + a03 * b07) * det, (a32 * b02 - a30 * b05 - a33 * b01) * det, (a20 * b05 - a22 * b02 + a23 * b01) * det,
|
|
23
|
+
(a10 * b10 - a11 * b08 + a13 * b06) * det, (a01 * b08 - a00 * b10 - a03 * b06) * det, (a30 * b04 - a31 * b02 + a33 * b00) * det, (a21 * b02 - a20 * b04 - a23 * b00) * det,
|
|
24
|
+
(a11 * b07 - a10 * b09 - a12 * b06) * det, (a00 * b09 - a01 * b07 + a02 * b06) * det, (a31 * b01 - a30 * b03 - a32 * b00) * det, (a20 * b03 - a21 * b01 + a22 * b00) * det,
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
export const isIdentity = (m: Mat4, tol = 1e-5): boolean => m.every((v, i) => Math.abs(v - I4[i]) < tol)
|
|
28
|
+
export const same = (a: Mat4, b: Mat4, tol: number): boolean => a.every((v, i) => Math.abs(v - b[i]) <= tol * Math.max(1, Math.abs(v), Math.abs(b[i])))
|
|
29
|
+
export const det3 = (m: Mat4): number => m[0] * (m[5] * m[10] - m[6] * m[9]) - m[4] * (m[1] * m[10] - m[2] * m[9]) + m[8] * (m[1] * m[6] - m[2] * m[5])
|
|
30
|
+
/** Inverse-transpose of the upper 3×3, as a column-major 3×3 (`dir` applies it). */
|
|
31
|
+
export const normalMatrix = (m: Mat4): number[] => {
|
|
32
|
+
const a = m[0], b = m[4], c = m[8], d = m[1], e = m[5], f = m[9], g = m[2], h = m[6], i = m[10]
|
|
33
|
+
const det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g)
|
|
34
|
+
if (Math.abs(det) < 1e-20) return [ 1, 0, 0, 0, 1, 0, 0, 0, 1 ]
|
|
35
|
+
// inverse (row-major rows), then transposed = columns of the inverse become rows
|
|
36
|
+
const inv = [
|
|
37
|
+
(e * i - f * h) / det, (c * h - b * i) / det, (b * f - c * e) / det,
|
|
38
|
+
(f * g - d * i) / det, (a * i - c * g) / det, (c * d - a * f) / det,
|
|
39
|
+
(d * h - e * g) / det, (b * g - a * h) / det, (a * e - b * d) / det,
|
|
40
|
+
]
|
|
41
|
+
// n' = inv^T · n → n'[r] = Σ_c inv[c*3 + r] · n[c]; stored column-major: col c = inv row c
|
|
42
|
+
return [ inv[0], inv[1], inv[2], inv[3], inv[4], inv[5], inv[6], inv[7], inv[8] ]
|
|
43
|
+
}
|
|
44
|
+
export const point = (m: Mat4, p: number[]): number[] => [
|
|
45
|
+
m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12], m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13], m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14],
|
|
46
|
+
]
|
|
47
|
+
export const dir = (n3: number[], v: number[]): number[] => {
|
|
48
|
+
const x = n3[0] * v[0] + n3[3] * v[1] + n3[6] * v[2], y = n3[1] * v[0] + n3[4] * v[1] + n3[7] * v[2], z = n3[2] * v[0] + n3[5] * v[1] + n3[8] * v[2]
|
|
49
|
+
const l = Math.hypot(x, y, z) || 1
|
|
50
|
+
return [ x / l, y / l, z / l ]
|
|
51
|
+
}
|
|
52
|
+
export const localMatrix = (n: Node): Mat4 => Array.from(n.getMatrix() as unknown as ArrayLike<number>)
|
|
53
|
+
export const worldMatrix = (n: Node, cache = new Map<Node, Mat4>()): Mat4 => {
|
|
54
|
+
const hit = cache.get(n)
|
|
55
|
+
if (hit) return hit
|
|
56
|
+
const parent = n.getParentNode()
|
|
57
|
+
const w = parent ? mul(worldMatrix(parent, cache), localMatrix(n)) : localMatrix(n)
|
|
58
|
+
cache.set(n, w)
|
|
59
|
+
return w
|
|
60
|
+
}
|