lecodes-assets 0.2.1 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +203 -26
- package/dist/cli.js +18157 -11441
- package/dist/index.js +16603 -9878
- 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 +79 -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 +108 -0
- package/src/commands/shared.ts +140 -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 +288 -0
- package/src/doctor/doctor.ts +94 -30
- package/src/doctor/impostor.ts +614 -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/transcodeKtx2.ts +186 -31
- package/src/doctor/transcodeTextures.ts +6 -1
- package/src/doctor/validateGlb.ts +2 -2
- package/src/doctor/vertexAo.ts +333 -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 +90 -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 +414 -0
- package/src/unity/levelTerrain.ts +125 -0
- package/src/unity/matFile.ts +179 -0
- package/src/unity/materialSlots.ts +53 -0
- package/src/unity/materialWriter.ts +316 -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 +339 -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
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|