lecodes-assets 0.2.1 → 2.4.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +203 -26
- package/dist/cli.js +17948 -11911
- package/dist/index.js +17525 -11479
- package/package.json +3 -1
- package/src/cli-kit/command.ts +100 -0
- package/src/cli-kit/help.ts +72 -0
- package/src/cli-kit/index.ts +4 -0
- package/src/cli-kit/parse.ts +72 -0
- package/src/cli-kit/run.ts +52 -0
- package/src/cli.ts +24 -1039
- package/src/commands/avatar.ts +77 -0
- package/src/commands/bvh.ts +26 -0
- package/src/commands/convert.ts +156 -0
- package/src/commands/doctor.ts +67 -0
- package/src/commands/geometry.ts +45 -0
- package/src/commands/index.ts +24 -0
- package/src/commands/marks.ts +38 -0
- package/src/commands/mirror.ts +35 -0
- package/src/commands/probe.ts +43 -0
- package/src/commands/retarget/donor.ts +63 -0
- package/src/commands/retarget/index.ts +169 -0
- package/src/commands/retarget/map.ts +81 -0
- package/src/commands/retarget/report.ts +77 -0
- package/src/commands/scene.ts +103 -0
- package/src/commands/shared.ts +121 -0
- package/src/commands/sky.ts +35 -0
- package/src/commands/terrainPack.ts +40 -0
- package/src/commands/unpack.ts +43 -0
- package/src/commands/unreal.ts +109 -0
- package/src/commands/unwrap.ts +56 -0
- package/src/doctor/cardTrim.ts +286 -0
- package/src/doctor/doctor.ts +94 -30
- package/src/doctor/merge/accessors.ts +30 -0
- package/src/doctor/merge/atlasLayout.ts +94 -0
- package/src/doctor/merge/atlasPixels.ts +114 -0
- package/src/doctor/merge/bindSpace.ts +59 -0
- package/src/doctor/merge/classify.ts +136 -0
- package/src/doctor/merge/geometry.ts +167 -0
- package/src/doctor/merge/index.ts +98 -0
- package/src/doctor/merge/matrix.ts +60 -0
- package/src/doctor/merge/plan.ts +88 -0
- package/src/doctor/merge/textures.ts +60 -0
- package/src/doctor/merge/types.ts +77 -0
- package/src/doctor/transcodeKtx2.ts +179 -28
- package/src/doctor/validateGlb.ts +2 -2
- package/src/doctor/vertexAo.ts +321 -0
- package/src/fbx/read.ts +2 -0
- package/src/fbx/toDocument.ts +227 -209
- package/src/geometry.ts +222 -0
- package/src/index.ts +2 -0
- package/src/retarget/heading.ts +238 -171
- package/src/retarget/humanoid.ts +572 -561
- package/src/retarget/retarget.ts +2375 -2272
- package/src/retarget/rig.ts +4 -1
- package/src/retarget/writeMarks.ts +134 -132
- package/src/sky/cmgen.ts +3 -3
- package/src/sky/index.ts +57 -8
- package/src/terrainPack.ts +61 -22
- package/src/unity/colliderFit.ts +287 -0
- package/src/unity/fbxModel.ts +194 -0
- package/src/unity/foliage.ts +63 -0
- package/src/unity/gameObjects.ts +81 -0
- package/src/unity/guid.ts +104 -0
- package/src/unity/image.ts +58 -0
- package/src/unity/kindGlb.ts +364 -0
- package/src/unity/lecodesLevel.ts +381 -0
- package/src/unity/levelTerrain.ts +116 -0
- package/src/unity/matFile.ts +179 -0
- package/src/unity/materialSlots.ts +53 -0
- package/src/unity/materialWriter.ts +306 -0
- package/src/unity/materials.ts +34 -429
- package/src/unity/math.ts +82 -0
- package/src/unity/prefabTree.ts +285 -0
- package/src/unity/prefabs.ts +10 -36
- package/src/unity/propertyPath.ts +43 -0
- package/src/unity/scene.ts +325 -304
- package/src/unity/serialized.ts +242 -0
- package/src/unity/terrain.ts +221 -0
- package/src/unity/triBudget.ts +77 -0
- package/src/unity/triplanar.ts +122 -0
- package/src/unity/unityMesh.ts +151 -0
- package/src/unity/unpack.ts +24 -5
- package/src/unity/yaml.ts +131 -0
- package/src/unreal/index.ts +6 -5
- package/src/unreal/materialGraph.ts +224 -0
- package/src/unreal/materials.ts +117 -9
- package/src/unreal/package.ts +30 -12
- package/src/unreal/resolve.ts +6 -4
- package/src/unreal/sound.ts +128 -0
- package/src/unreal/texture.ts +70 -3
- package/src/unreal/toDocument.ts +7 -0
- package/src/unreal/versions.ts +8 -0
- package/src/unwrap/index.ts +167 -0
- package/src/unwrap/lightmapUv.ts +761 -0
- package/src/wasm/fbxReader.js +1 -1
- package/src/wasm/lightmapUv.js +1 -1
- package/src/doctor/lightmapUv.ts +0 -452
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
// The lightmap unwrap — `lecodes assets unwrap` (./index.ts), and `lecodes lightmap bake` before it bakes: a second
|
|
2
|
+
// texture-coordinate set (TEXCOORD_1) that lays every triangle of the file out in ONE non-overlapping unit square, made
|
|
3
|
+
// by xatlas (vendor/xatlas, run as wasm — src/wasm/lightmapUv.js). This is what the lightmap bake samples and writes
|
|
4
|
+
// (engines/bake): each placed instance of the prop gets a rectangle of the scene atlas, so the props stay clones and one
|
|
5
|
+
// unwrap per file is enough.
|
|
6
|
+
//
|
|
7
|
+
// WHAT COUNTS IS THE CUTTING. The bake re-packs a file's uv1-connected charts itself (engines/bake README, "THE CHARTS
|
|
8
|
+
// ARE PACKED PER FILE": MaxRects at the instances' density, its own gutter, the `<stem>-uv1.bin` sidecar), so xatlas's
|
|
9
|
+
// packing here only matters where the bake keeps the unwrap's square as it is (a file whose TEXCOORD_1 a texture reads,
|
|
10
|
+
// a mesh under two groups, CBK_PACK_CHARTS=0): the padding is a fixed UNWRAP_PADDING texels of the bake's texel
|
|
11
|
+
// (`--texel`). The decisions the bake cannot take back are the charts, the groups and the zone's texel scales.
|
|
12
|
+
//
|
|
13
|
+
// xatlas RE-INDEXES the mesh: a vertex that sits on a chart seam is split into one copy per chart,
|
|
14
|
+
// and every output vertex carries `xref`, the input vertex it came from. So this pass rebuilds every
|
|
15
|
+
// attribute (positions, normals, UV0, colours, joints/weights, tangents, morph targets) by that map,
|
|
16
|
+
// preserving each accessor's component type and normalization, and widens the index buffer past
|
|
17
|
+
// 65535 when the split pushes it there. Skinned meshes are left alone: a lightmap is baked in ONE
|
|
18
|
+
// pose, and a rigged character never stays in it.
|
|
19
|
+
//
|
|
20
|
+
// GROUPS (`--groups <n>`): ONE unit square = ONE atlas rect in the bake, and a rect cannot span
|
|
21
|
+
// pages — a whole level exported as one GLB (the arena: 133 m, 800k triangles) got one 8192 page at 7 cm
|
|
22
|
+
// and no way to ask for more. With groups the mesh nodes are split into n parts of equal surface by
|
|
23
|
+
// bisecting their world centroids (neighbours stay together), each part is unwrapped into a unit square
|
|
24
|
+
// of ITS OWN, and every node carries `extras.lightmapGroup = k`. The bake describes one instance per
|
|
25
|
+
// group (`<key>#k`, creator-gl lightmap_bake.cpp), the runtime binds each group's page and rect to the
|
|
26
|
+
// group's renderables (glb.cpp lightmapBind) — one file, no texture paid twice.
|
|
27
|
+
//
|
|
28
|
+
// THE PLAYER'S ZONE (`--zone "x0,y0,z0,x1,y1,z1[;…]"`, with groups): a level is not seen from everywhere —
|
|
29
|
+
// an arena's player walks the sand, and 5 cm texels on tiers 40 m away are texels nobody can tell apart. The zone is
|
|
30
|
+
// the boxes the player can be in (world metres; the entrances of a passage count, the dark passage behind need not).
|
|
31
|
+
// A triangle's TEXEL SCALE follows its distance d to the zone: 1 within `--zone-near` (12 m), 2 to twice
|
|
32
|
+
// that, 4 beyond — a texel stays about the same size ON SCREEN. Scales never share a group: a large mesh that spans
|
|
33
|
+
// them (the seating bowl) is cut along them, a small one takes its finest. A group of scale s is unwrapped with its
|
|
34
|
+
// padding measured in ITS texels, carries `extras.lightmapTexelScale = s`, and the bake lays it out s times coarser
|
|
35
|
+
// (CbakeInstanceDesc.texelScale): a 16th of the texels at 4.
|
|
36
|
+
//
|
|
37
|
+
// LOD siblings (`<name>_LOD<n>`, n ≥ 1 — the kit's own or `doctor --lod`'s) are NOT unwrapped: the
|
|
38
|
+
// engine's lightmap pass traces level 0 only and binds ONE atlas rect per instance, which every
|
|
39
|
+
// level reads. So level 0 gets the xatlas layout and the LODs get level 0's UV1 by PROJECTION: each
|
|
40
|
+
// LOD triangle's centroid picks the closest level-0 triangle and with it a chart, and each corner
|
|
41
|
+
// takes the UV of its closest point on that chart (a corner that straddles charts is split, like a
|
|
42
|
+
// seam vertex). A decimated surface then samples the very texels its full-detail twin was traced
|
|
43
|
+
// into — no atlas paid twice, no LOD reading texels nothing lit (the two-features-corrupt-each-other
|
|
44
|
+
// bug of fps-demo FEEDBACK 92).
|
|
45
|
+
import type { Accessor, Document, Mesh, Node as GltfNode, Primitive } from '@gltf-transform/core'
|
|
46
|
+
// @ts-ignore — generated by build-wasm.sh (Emscripten single-file module)
|
|
47
|
+
import createLightmapUv from '../wasm/lightmapUv.js'
|
|
48
|
+
|
|
49
|
+
const TRIANGLES = 4
|
|
50
|
+
|
|
51
|
+
type Module = {
|
|
52
|
+
_malloc(n: number): number
|
|
53
|
+
_free(p: number): void
|
|
54
|
+
_lmuv_begin(hint: number): number
|
|
55
|
+
_lmuv_add_mesh(h: number, pos: number, normals: number, uv: number, vertexCount: number, indices: number, indexCount: number): number
|
|
56
|
+
_lmuv_generate(h: number, padding: number, iterations: number, texelsPerUnit: number): number
|
|
57
|
+
_lmuv_width(h: number): number
|
|
58
|
+
_lmuv_height(h: number): number
|
|
59
|
+
_lmuv_chart_count(h: number): number
|
|
60
|
+
_lmuv_mesh_count(h: number): number
|
|
61
|
+
_lmuv_mesh_vertex_count(h: number, i: number): number
|
|
62
|
+
_lmuv_mesh_index_count(h: number, i: number): number
|
|
63
|
+
_lmuv_mesh_xref(h: number, i: number): number
|
|
64
|
+
_lmuv_mesh_uv(h: number, i: number): number
|
|
65
|
+
_lmuv_mesh_indices(h: number, i: number): number
|
|
66
|
+
_lmuv_error(): number
|
|
67
|
+
_lmuv_end(h: number): void
|
|
68
|
+
HEAPU8: Uint8Array
|
|
69
|
+
HEAPU32: Uint32Array
|
|
70
|
+
HEAPF32: Float32Array
|
|
71
|
+
UTF8ToString(ptr: number): string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let modulePromise: Promise<Module> | undefined
|
|
75
|
+
const getModule = (): Promise<Module> => (modulePromise ??= (createLightmapUv as () => Promise<Module>)())
|
|
76
|
+
|
|
77
|
+
export interface UnwrapOptions {
|
|
78
|
+
/** the bake's texel in metres the (fixed) chart padding is measured in (0.05, `lecodes lightmap bake --texel`); 0 = xatlas's auto density */
|
|
79
|
+
texel?: number
|
|
80
|
+
/** xatlas chart-growing iterations — more = better charts, slower (default 1) */
|
|
81
|
+
iterations?: number
|
|
82
|
+
/** split the file's meshes into this many groups, each with a unit square (an atlas rect) of its own (default 1) */
|
|
83
|
+
groups?: number
|
|
84
|
+
/** the boxes the player can be in, world metres: "x0,y0,z0,x1,y1,z1[;…]" — what is far from them gets a coarser texel (needs groups) */
|
|
85
|
+
zone?: string
|
|
86
|
+
/** the distance to the zone within which the texel is the full one; the scale doubles every such distance past it (default 12) */
|
|
87
|
+
zoneNear?: number
|
|
88
|
+
/** the coarsest texel scale, a power of two: the doubling stops there (default 4 = twenty centimetres at a 5 cm texel) */
|
|
89
|
+
zoneMax?: number
|
|
90
|
+
/** the weight of the VERTICAL distance to the zone (default 1): 2 = a metre of height counts as two metres of reach, so
|
|
91
|
+
* an arena's upper tiers go coarse before the far end of the pitch does */
|
|
92
|
+
zoneUp?: number
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface UnwrapPrim { mesh: Mesh; prim: Primitive; name: string; triangles: number; vertices: number; hasUv1: boolean }
|
|
96
|
+
/** A LOD sibling's primitive: takes its UV1 from `baseNode`'s (level-0) mesh by projection. */
|
|
97
|
+
export interface UnwrapLod { mesh: Mesh; prim: Primitive; node: GltfNode; baseNode: GltfNode; level: number; name: string; triangles: number; hasUv1: boolean }
|
|
98
|
+
export interface UnwrapPlan {
|
|
99
|
+
prims: UnwrapPrim[]
|
|
100
|
+
lods: UnwrapLod[]
|
|
101
|
+
skipped: { name: string; reason: string }[]
|
|
102
|
+
padding: number
|
|
103
|
+
iterations: number
|
|
104
|
+
/** the bake's density the padding is measured in: 1 / its texel (metres); 0 = xatlas's own auto density */
|
|
105
|
+
texelsPerUnit: number
|
|
106
|
+
/** how many unit squares the file's meshes are split into (1 = the classic single layout) */
|
|
107
|
+
groups: number
|
|
108
|
+
/** the player's zone: boxes [x0,y0,z0,x1,y1,z1] in world metres (empty = one texel everywhere) and its near distance */
|
|
109
|
+
zones: number[][]
|
|
110
|
+
zoneNear: number
|
|
111
|
+
zoneMax: number
|
|
112
|
+
zoneUp: number
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** texels between charts in xatlas's own packing — what the bake reads only where it keeps the unwrap's square (see the top) */
|
|
116
|
+
export const UNWRAP_PADDING = 4
|
|
117
|
+
/** `lecodes lightmap bake`'s default texel (metres): the unwrap keeps UNWRAP_PADDING texels of THIS size between charts */
|
|
118
|
+
export const DEFAULT_UNWRAP_TEXEL = 0.05
|
|
119
|
+
|
|
120
|
+
const LOD_SUFFIX = /_LOD(\d+)$/
|
|
121
|
+
/** "<base>_LOD<n>" → n (≥ 1), else 0 — the suffix creator-gl's lod.cpp groups renderables by. */
|
|
122
|
+
export const lodLevelOf = (name: string): number => { const m = LOD_SUFFIX.exec(name); return m ? Number(m[1]) : 0 }
|
|
123
|
+
|
|
124
|
+
const primName = (mesh: Mesh, i: number): string => `${mesh.getName() || 'mesh'}${mesh.listPrimitives().length > 1 ? `[${i}]` : ''}`
|
|
125
|
+
|
|
126
|
+
const parseZones = (text: string | undefined): number[][] => {
|
|
127
|
+
if (!text) return []
|
|
128
|
+
return text.split(';').map((t) => t.trim()).filter(Boolean).map((t) => {
|
|
129
|
+
const v = t.split(',').map(Number)
|
|
130
|
+
if (v.length !== 6 || v.some((x) => !Number.isFinite(x))) throw new Error(`--zone: "${t}" is not x0,y0,z0,x1,y1,z1`)
|
|
131
|
+
return [ Math.min(v[0], v[3]), Math.min(v[1], v[4]), Math.min(v[2], v[5]), Math.max(v[0], v[3]), Math.max(v[1], v[4]), Math.max(v[2], v[5]) ]
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The texel scale of a world point: 1 within `near` of the zone, then doubling every `near` metres (2 to twice near, 4 to three
|
|
136
|
+
* times, 8 …) up to `zoneMax` (1 when there is no zone). The distance is to the zone's boxes, the vertical part weighed by
|
|
137
|
+
* `zoneUp` (the arena, 2026-09-18: with 1 the upper tiers, 15-20 m up and 25 m out, stayed at x4; with 2 they are x8 / x16). */
|
|
138
|
+
const texelScaleAt = (plan: UnwrapPlan, x: number, y: number, z: number): number => {
|
|
139
|
+
if (plan.zones.length === 0) return 1
|
|
140
|
+
let d = Infinity
|
|
141
|
+
for (const b of plan.zones) {
|
|
142
|
+
const dx = Math.max(b[0] - x, 0, x - b[3]), dy = Math.max(b[1] - y, 0, y - b[4]), dz = Math.max(b[2] - z, 0, z - b[5])
|
|
143
|
+
d = Math.min(d, Math.hypot(dx, dy * plan.zoneUp, dz))
|
|
144
|
+
}
|
|
145
|
+
return Math.min(plan.zoneMax, 2 ** Math.floor(d / plan.zoneNear))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** What the unwrap would do to `doc` (pure): the primitives it lays out, the LOD siblings it projects, what it leaves. */
|
|
149
|
+
export function planUnwrap(doc: Document, opts: UnwrapOptions = {}): UnwrapPlan {
|
|
150
|
+
const root = doc.getRoot()
|
|
151
|
+
const skinnedMeshes = new Set<Mesh>()
|
|
152
|
+
const byName = new Map<string, GltfNode>()
|
|
153
|
+
for (const node of root.listNodes()) {
|
|
154
|
+
const mesh = node.getMesh()
|
|
155
|
+
if (mesh && node.getSkin()) skinnedMeshes.add(mesh)
|
|
156
|
+
if (mesh && node.getName() && !byName.has(node.getName())) byName.set(node.getName(), node)
|
|
157
|
+
}
|
|
158
|
+
// LOD siblings: a mesh drawn by a `<base>_LOD<n≥1>` node whose level-0 node (`<base>` or
|
|
159
|
+
// `<base>_LOD0`) has a mesh of its own; a mesh SHARED with level 0 needs nothing.
|
|
160
|
+
const lodMeshes = new Map<Mesh, { node: GltfNode; baseNode: GltfNode; level: number }>()
|
|
161
|
+
const level0Meshes = new Set<Mesh>()
|
|
162
|
+
for (const node of root.listNodes()) {
|
|
163
|
+
const mesh = node.getMesh()
|
|
164
|
+
if (!mesh) continue
|
|
165
|
+
if (lodLevelOf(node.getName()) === 0) level0Meshes.add(mesh)
|
|
166
|
+
}
|
|
167
|
+
for (const node of root.listNodes()) {
|
|
168
|
+
const mesh = node.getMesh()
|
|
169
|
+
if (!mesh || level0Meshes.has(mesh) || lodMeshes.has(mesh)) continue
|
|
170
|
+
const level = lodLevelOf(node.getName())
|
|
171
|
+
if (level === 0) continue
|
|
172
|
+
const base = node.getName().replace(LOD_SUFFIX, '')
|
|
173
|
+
const baseNode = byName.get(base) ?? byName.get(`${base}_LOD0`)
|
|
174
|
+
if (!baseNode || !baseNode.getMesh() || baseNode.getMesh() === mesh || skinnedMeshes.has(baseNode.getMesh()!)) continue
|
|
175
|
+
lodMeshes.set(mesh, { node, baseNode, level })
|
|
176
|
+
}
|
|
177
|
+
const prims: UnwrapPrim[] = []
|
|
178
|
+
const lods: UnwrapLod[] = []
|
|
179
|
+
const skipped: { name: string; reason: string }[] = []
|
|
180
|
+
for (const mesh of root.listMeshes()) {
|
|
181
|
+
const lod = lodMeshes.get(mesh)
|
|
182
|
+
mesh.listPrimitives().forEach((prim, i) => {
|
|
183
|
+
const name = primName(mesh, i)
|
|
184
|
+
const pos = prim.getAttribute('POSITION')
|
|
185
|
+
if (!pos || pos.getCount() === 0) { skipped.push({ name, reason: 'no positions' }); return }
|
|
186
|
+
if (prim.getMode() !== TRIANGLES) { skipped.push({ name, reason: 'not a triangle list' }); return }
|
|
187
|
+
if (skinnedMeshes.has(mesh)) { skipped.push({ name, reason: 'skinned' }); return }
|
|
188
|
+
const idx = prim.getIndices()
|
|
189
|
+
const triangles = Math.floor((idx ? idx.getCount() : pos.getCount()) / 3)
|
|
190
|
+
if (triangles === 0) { skipped.push({ name, reason: 'no triangles' }); return }
|
|
191
|
+
const hasUv1 = !!prim.getAttribute('TEXCOORD_1')
|
|
192
|
+
if (lod) { lods.push({ mesh, prim, node: lod.node, baseNode: lod.baseNode, level: lod.level, name, triangles, hasUv1 }); return }
|
|
193
|
+
prims.push({ mesh, prim, name, triangles, vertices: pos.getCount(), hasUv1 })
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
const texel = opts.texel ?? DEFAULT_UNWRAP_TEXEL
|
|
197
|
+
return {
|
|
198
|
+
prims, lods, skipped,
|
|
199
|
+
padding: UNWRAP_PADDING,
|
|
200
|
+
iterations: Math.max(1, Math.floor(opts.iterations ?? 1)),
|
|
201
|
+
texelsPerUnit: texel > 0 ? 1 / texel : 0,
|
|
202
|
+
groups: Math.max(1, Math.min(256, Math.floor(opts.groups ?? 1))),
|
|
203
|
+
zones: parseZones(opts.zone),
|
|
204
|
+
zoneNear: opts.zoneNear && opts.zoneNear > 0 ? opts.zoneNear : 12,
|
|
205
|
+
zoneMax: opts.zoneMax && opts.zoneMax > 1 ? 2 ** Math.round(Math.log2(opts.zoneMax)) : opts.zoneMax === 1 ? 1 : 4,
|
|
206
|
+
zoneUp: opts.zoneUp && opts.zoneUp > 0 ? opts.zoneUp : 1,
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** True when the unwrap has something to do: a primitive it lays out (or a LOD sibling it projects onto one) without a
|
|
211
|
+
* TEXCOORD_1. A file whose every such primitive carries one is left alone unless forced. */
|
|
212
|
+
export const planNeedsUnwrap = (plan: UnwrapPlan): boolean =>
|
|
213
|
+
plan.prims.length > 0 && (plan.prims.some((p) => !p.hasUv1) || plan.lods.some((l) => !l.hasUv1))
|
|
214
|
+
|
|
215
|
+
export interface UnwrapGroup { primitives: number; charts: number; width: number; height: number; area: number; texelScale: number }
|
|
216
|
+
export interface UnwrapResult {
|
|
217
|
+
primitives: number; charts: number; width: number; height: number; verticesBefore: number; verticesAfter: number
|
|
218
|
+
/** one entry per unit square when the file was split (`--groups`), else empty */
|
|
219
|
+
groups: UnwrapGroup[]
|
|
220
|
+
/** nodes added by cutting meshes larger than a group's share (splitLargeMeshes) */
|
|
221
|
+
splitNodes: number
|
|
222
|
+
/** LOD primitives that took level 0's layout by projection, and the vertices they hold afterwards */
|
|
223
|
+
lodPrimitives: number; lodVertices: number
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Every element of `acc` as floats (dequantized), `size` per element. */
|
|
227
|
+
const floats = (acc: Accessor, size: number): Float32Array => {
|
|
228
|
+
const n = acc.getCount()
|
|
229
|
+
const out = new Float32Array(n * size)
|
|
230
|
+
const el: number[] = new Array(acc.getElementSize()).fill(0)
|
|
231
|
+
for (let i = 0; i < n; i++) { acc.getElement(i, el); for (let k = 0; k < size; k++) out[i * size + k] = el[k] ?? 0 }
|
|
232
|
+
return out
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A copy of `acc` with its elements reordered by `xref` — same type, component type, normalization. */
|
|
236
|
+
const remapAccessor = (doc: Document, acc: Accessor, xref: Uint32Array, buffer: ReturnType<Document['createBuffer']>): Accessor => {
|
|
237
|
+
const src = acc.getArray()!
|
|
238
|
+
const es = acc.getElementSize()
|
|
239
|
+
const Ctor = src.constructor as { new (n: number): typeof src }
|
|
240
|
+
const out = new Ctor(xref.length * es)
|
|
241
|
+
for (let v = 0; v < xref.length; v++) {
|
|
242
|
+
const s = xref[v] * es
|
|
243
|
+
for (let k = 0; k < es; k++) out[v * es + k] = src[s + k]
|
|
244
|
+
}
|
|
245
|
+
return doc.createAccessor(acc.getName()).setType(acc.getType()).setNormalized(acc.getNormalized()).setArray(out).setBuffer(buffer)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Rebuild a primitive's attributes, morph targets and indices by `xref` (re-indexed geometry) and give it `uv1`. */
|
|
249
|
+
const reindexPrimitive = (doc: Document, prim: Primitive, xref: Uint32Array, indices32: Uint32Array<ArrayBuffer>, uv1: Float32Array<ArrayBuffer>,
|
|
250
|
+
buffer: ReturnType<Document['createBuffer']>, retired: Set<Accessor>): void => {
|
|
251
|
+
for (const semantic of prim.listSemantics()) {
|
|
252
|
+
const acc = prim.getAttribute(semantic)!
|
|
253
|
+
if (semantic === 'TEXCOORD_1') { retired.add(acc); continue } // replaced below
|
|
254
|
+
retired.add(acc)
|
|
255
|
+
prim.setAttribute(semantic, remapAccessor(doc, acc, xref, buffer))
|
|
256
|
+
}
|
|
257
|
+
for (const target of prim.listTargets()) {
|
|
258
|
+
for (const semantic of target.listSemantics()) {
|
|
259
|
+
const acc = target.getAttribute(semantic)!
|
|
260
|
+
retired.add(acc)
|
|
261
|
+
target.setAttribute(semantic, remapAccessor(doc, acc, xref, buffer))
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const oldIdx = prim.getIndices()
|
|
265
|
+
if (oldIdx) retired.add(oldIdx)
|
|
266
|
+
const indices = xref.length <= 65535 ? Uint16Array.from(indices32) : indices32
|
|
267
|
+
prim.setIndices(doc.createAccessor().setType('SCALAR').setArray(indices).setBuffer(buffer))
|
|
268
|
+
prim.setAttribute('TEXCOORD_1', doc.createAccessor('lightmap_uv').setType('VEC2').setArray(uv1).setBuffer(buffer))
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---- LOD projection ------------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
const xformPoint = (m: number[], x: number, y: number, z: number, out: Float32Array, o: number): void => {
|
|
274
|
+
out[o] = m[0] * x + m[4] * y + m[8] * z + m[12]
|
|
275
|
+
out[o + 1] = m[1] * x + m[5] * y + m[9] * z + m[13]
|
|
276
|
+
out[o + 2] = m[2] * x + m[6] * y + m[10] * z + m[14]
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Level 0's triangles in world space with their UV1 and chart, on a uniform grid for nearest-point queries. */
|
|
280
|
+
class BaseSurface {
|
|
281
|
+
readonly tris: Float32Array // 9 floats per triangle (world positions)
|
|
282
|
+
readonly uvs: Float32Array // 6 floats per triangle
|
|
283
|
+
readonly chart: Int32Array // chart id per triangle (an xatlas chart = connected output vertices)
|
|
284
|
+
readonly count: number
|
|
285
|
+
private readonly min = [ 0, 0, 0 ]
|
|
286
|
+
private readonly cell: number
|
|
287
|
+
private readonly dims = [ 1, 1, 1 ]
|
|
288
|
+
private readonly cells = new Map<number, number[]>()
|
|
289
|
+
|
|
290
|
+
constructor(node: GltfNode) {
|
|
291
|
+
const world = node.getWorldMatrix()
|
|
292
|
+
const triPos: number[] = [], triUv: number[] = [], triChart: number[] = []
|
|
293
|
+
let chartBase = 0
|
|
294
|
+
for (const prim of node.getMesh()!.listPrimitives()) {
|
|
295
|
+
const pos = prim.getAttribute('POSITION'), uv1 = prim.getAttribute('TEXCOORD_1')
|
|
296
|
+
if (!pos || !uv1 || prim.getMode() !== TRIANGLES) continue
|
|
297
|
+
const idxAcc = prim.getIndices()
|
|
298
|
+
const idx = idxAcc ? idxAcc.getArray()! : Uint32Array.from({ length: pos.getCount() }, (_, i) => i)
|
|
299
|
+
const p = floats(pos, 3), u = floats(uv1, 2)
|
|
300
|
+
// charts = connected components over the (seam-split) index buffer
|
|
301
|
+
const parent = Int32Array.from({ length: pos.getCount() }, (_, i) => i)
|
|
302
|
+
const find = (a: number): number => { while (parent[a] !== a) { parent[a] = parent[parent[a]]; a = parent[a] } return a }
|
|
303
|
+
for (let i = 0; i + 2 < idx.length; i += 3) {
|
|
304
|
+
const a = find(idx[i]), b = find(idx[i + 1]), c = find(idx[i + 2])
|
|
305
|
+
parent[b] = a; parent[find(c)] = find(a)
|
|
306
|
+
}
|
|
307
|
+
const chartOf = new Map<number, number>()
|
|
308
|
+
for (let i = 0; i + 2 < idx.length; i += 3) {
|
|
309
|
+
const r = find(idx[i])
|
|
310
|
+
let ch = chartOf.get(r)
|
|
311
|
+
if (ch === undefined) { ch = chartBase + chartOf.size; chartOf.set(r, ch) }
|
|
312
|
+
const w = new Float32Array(9)
|
|
313
|
+
for (let k = 0; k < 3; k++) { const v = idx[i + k]; xformPoint(world, p[v * 3], p[v * 3 + 1], p[v * 3 + 2], w, k * 3); triUv.push(u[v * 2], u[v * 2 + 1]) }
|
|
314
|
+
for (let k = 0; k < 9; k++) triPos.push(w[k])
|
|
315
|
+
triChart.push(ch)
|
|
316
|
+
}
|
|
317
|
+
chartBase += chartOf.size
|
|
318
|
+
}
|
|
319
|
+
this.tris = Float32Array.from(triPos); this.uvs = Float32Array.from(triUv); this.chart = Int32Array.from(triChart)
|
|
320
|
+
this.count = triChart.length
|
|
321
|
+
if (this.count === 0) { this.cell = 1; return }
|
|
322
|
+
const min = [ Infinity, Infinity, Infinity ], max = [ -Infinity, -Infinity, -Infinity ]
|
|
323
|
+
for (let i = 0; i < this.tris.length; i += 3) for (let k = 0; k < 3; k++) { min[k] = Math.min(min[k], this.tris[i + k]); max[k] = Math.max(max[k], this.tris[i + k]) }
|
|
324
|
+
const ext = [ max[0] - min[0], max[1] - min[1], max[2] - min[2] ]
|
|
325
|
+
const longest = Math.max(ext[0], ext[1], ext[2], 1e-6)
|
|
326
|
+
// ~4 triangles per cell along the longest axis, 48 cells at most: a crate's 10k triangles land in a few hundred cells
|
|
327
|
+
this.cell = Math.max(longest / Math.min(48, Math.max(4, Math.cbrt(this.count) * 2)), 1e-5)
|
|
328
|
+
this.min = [ min[0] - this.cell * 0.5, min[1] - this.cell * 0.5, min[2] - this.cell * 0.5 ]
|
|
329
|
+
this.dims = ext.map((e) => Math.max(1, Math.ceil(e / this.cell) + 1))
|
|
330
|
+
for (let t = 0; t < this.count; t++) {
|
|
331
|
+
const lo = [ Infinity, Infinity, Infinity ], hi = [ -Infinity, -Infinity, -Infinity ]
|
|
332
|
+
for (let k = 0; k < 3; k++) for (let a = 0; a < 3; a++) { const v = this.tris[t * 9 + k * 3 + a]; lo[a] = Math.min(lo[a], v); hi[a] = Math.max(hi[a], v) }
|
|
333
|
+
const c0 = this.cellOf(lo), c1 = this.cellOf(hi)
|
|
334
|
+
for (let z = c0[2]; z <= c1[2]; z++) for (let y = c0[1]; y <= c1[1]; y++) for (let x = c0[0]; x <= c1[0]; x++) {
|
|
335
|
+
const key = this.key(x, y, z)
|
|
336
|
+
let list = this.cells.get(key)
|
|
337
|
+
if (!list) { list = []; this.cells.set(key, list) }
|
|
338
|
+
list.push(t)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private cellOf(p: ArrayLike<number>): number[] {
|
|
344
|
+
return [ 0, 1, 2 ].map((a) => Math.min(this.dims[a] - 1, Math.max(0, Math.floor((p[a] - this.min[a]) / this.cell))))
|
|
345
|
+
}
|
|
346
|
+
private key(x: number, y: number, z: number): number { return (z * this.dims[1] + y) * this.dims[0] + x }
|
|
347
|
+
|
|
348
|
+
/** Closest point on triangle `t` to `p` (Ericson, Real-Time Collision Detection 5.1.5): squared distance + barycentrics. */
|
|
349
|
+
private closest(t: number, p: number[], out: { d2: number; b: number[] }): void {
|
|
350
|
+
const T = this.tris, o = t * 9
|
|
351
|
+
const ax = T[o], ay = T[o + 1], az = T[o + 2], bx = T[o + 3], by = T[o + 4], bz = T[o + 5], cx = T[o + 6], cy = T[o + 7], cz = T[o + 8]
|
|
352
|
+
const abx = bx - ax, aby = by - ay, abz = bz - az, acx = cx - ax, acy = cy - ay, acz = cz - az
|
|
353
|
+
const apx = p[0] - ax, apy = p[1] - ay, apz = p[2] - az
|
|
354
|
+
const d1 = abx * apx + aby * apy + abz * apz, d2 = acx * apx + acy * apy + acz * apz
|
|
355
|
+
let u: number, v: number, w: number
|
|
356
|
+
if (d1 <= 0 && d2 <= 0) { u = 1; v = 0; w = 0 } else {
|
|
357
|
+
const bpx = p[0] - bx, bpy = p[1] - by, bpz = p[2] - bz
|
|
358
|
+
const d3 = abx * bpx + aby * bpy + abz * bpz, d4 = acx * bpx + acy * bpy + acz * bpz
|
|
359
|
+
if (d3 >= 0 && d4 <= d3) { u = 0; v = 1; w = 0 } else {
|
|
360
|
+
const vc = d1 * d4 - d3 * d2
|
|
361
|
+
if (vc <= 0 && d1 >= 0 && d3 <= 0) { v = d1 / (d1 - d3); u = 1 - v; w = 0 } else {
|
|
362
|
+
const cpx = p[0] - cx, cpy = p[1] - cy, cpz = p[2] - cz
|
|
363
|
+
const d5 = abx * cpx + aby * cpy + abz * cpz, d6 = acx * cpx + acy * cpy + acz * cpz
|
|
364
|
+
if (d6 >= 0 && d5 <= d6) { u = 0; v = 0; w = 1 } else {
|
|
365
|
+
const vb = d5 * d2 - d1 * d6
|
|
366
|
+
if (vb <= 0 && d2 >= 0 && d6 <= 0) { w = d2 / (d2 - d6); u = 1 - w; v = 0 } else {
|
|
367
|
+
const va = d3 * d6 - d5 * d4
|
|
368
|
+
if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0) { w = (d4 - d3) / ((d4 - d3) + (d5 - d6)); v = 1 - w; u = 0 } else {
|
|
369
|
+
const denom = 1 / (va + vb + vc)
|
|
370
|
+
v = vb * denom; w = vc * denom; u = 1 - v - w
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const qx = ax * u + bx * v + cx * w, qy = ay * u + by * v + cy * w, qz = az * u + bz * v + cz * w
|
|
378
|
+
out.d2 = (qx - p[0]) ** 2 + (qy - p[1]) ** 2 + (qz - p[2]) ** 2
|
|
379
|
+
out.b[0] = u; out.b[1] = v; out.b[2] = w
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** The closest triangle to `p` (within chart `chart` when ≥ 0) and the barycentrics of the closest point; -1 = none. */
|
|
383
|
+
nearest(p: number[], chart: number, bary: number[]): number {
|
|
384
|
+
if (this.count === 0) return -1
|
|
385
|
+
const c = this.cellOf(p)
|
|
386
|
+
let best = -1, bestD2 = Infinity
|
|
387
|
+
const tmp = { d2: 0, b: [ 0, 0, 0 ] }
|
|
388
|
+
const maxR = Math.max(this.dims[0], this.dims[1], this.dims[2])
|
|
389
|
+
for (let r = 0; r <= maxR; r++) {
|
|
390
|
+
// everything not yet visited lies at least (r − 1) cells away: stop once the best is closer than that
|
|
391
|
+
if (best >= 0 && Math.sqrt(bestD2) <= (r - 1) * this.cell) break
|
|
392
|
+
for (let z = c[2] - r; z <= c[2] + r; z++) {
|
|
393
|
+
if (z < 0 || z >= this.dims[2]) continue
|
|
394
|
+
for (let y = c[1] - r; y <= c[1] + r; y++) {
|
|
395
|
+
if (y < 0 || y >= this.dims[1]) continue
|
|
396
|
+
const edgeYZ = Math.abs(z - c[2]) === r || Math.abs(y - c[1]) === r
|
|
397
|
+
for (let x = c[0] - r; x <= c[0] + r; x++) {
|
|
398
|
+
if (x < 0 || x >= this.dims[0]) continue
|
|
399
|
+
if (!edgeYZ && Math.abs(x - c[0]) !== r) continue // only the ring's shell
|
|
400
|
+
const list = this.cells.get(this.key(x, y, z))
|
|
401
|
+
if (!list) continue
|
|
402
|
+
for (const t of list) {
|
|
403
|
+
if (chart >= 0 && this.chart[t] !== chart) continue
|
|
404
|
+
this.closest(t, p, tmp)
|
|
405
|
+
if (tmp.d2 < bestD2) { bestD2 = tmp.d2; best = t; bary[0] = tmp.b[0]; bary[1] = tmp.b[1]; bary[2] = tmp.b[2] }
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return best
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
uvAt(t: number, bary: number[], out: Float32Array, o: number): void {
|
|
415
|
+
const U = this.uvs, k = t * 6
|
|
416
|
+
out[o] = U[k] * bary[0] + U[k + 2] * bary[1] + U[k + 4] * bary[2]
|
|
417
|
+
out[o + 1] = U[k + 1] * bary[0] + U[k + 3] * bary[1] + U[k + 5] * bary[2]
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Give a LOD primitive level 0's UV1: per triangle the centroid picks a chart, per corner the closest point on it. */
|
|
422
|
+
const projectLodPrimitive = (doc: Document, lod: UnwrapLod, base: BaseSurface, buffer: ReturnType<Document['createBuffer']>,
|
|
423
|
+
retired: Set<Accessor>): number => {
|
|
424
|
+
const prim = lod.prim
|
|
425
|
+
const pos = prim.getAttribute('POSITION')!
|
|
426
|
+
const idxAcc = prim.getIndices()
|
|
427
|
+
const idx = idxAcc ? Uint32Array.from(idxAcc.getArray()!) : Uint32Array.from({ length: pos.getCount() }, (_, i) => i)
|
|
428
|
+
const local = floats(pos, 3)
|
|
429
|
+
const world = lod.node.getWorldMatrix()
|
|
430
|
+
const wp = new Float32Array(local.length)
|
|
431
|
+
for (let v = 0; v < pos.getCount(); v++) xformPoint(world, local[v * 3], local[v * 3 + 1], local[v * 3 + 2], wp, v * 3)
|
|
432
|
+
const xref: number[] = [], uv: number[] = [], out: number[] = []
|
|
433
|
+
const slot = new Map<number, number>() // (vertex, chart) → output vertex
|
|
434
|
+
const bary = [ 0, 0, 0 ]
|
|
435
|
+
const centroid = [ 0, 0, 0 ], corner = [ 0, 0, 0 ]
|
|
436
|
+
const uv2 = new Float32Array(2)
|
|
437
|
+
for (let i = 0; i + 2 < idx.length; i += 3) {
|
|
438
|
+
for (let a = 0; a < 3; a++) centroid[a] = (wp[idx[i] * 3 + a] + wp[idx[i + 1] * 3 + a] + wp[idx[i + 2] * 3 + a]) / 3
|
|
439
|
+
const ct = base.nearest(centroid, -1, bary)
|
|
440
|
+
const chart = ct >= 0 ? base.chart[ct] : -1
|
|
441
|
+
for (let k = 0; k < 3; k++) {
|
|
442
|
+
const v = idx[i + k]
|
|
443
|
+
const key = v * 65536 + (chart + 1)
|
|
444
|
+
let o = slot.get(key)
|
|
445
|
+
if (o === undefined) {
|
|
446
|
+
corner[0] = wp[v * 3]; corner[1] = wp[v * 3 + 1]; corner[2] = wp[v * 3 + 2]
|
|
447
|
+
let t = base.nearest(corner, chart, bary)
|
|
448
|
+
if (t < 0) t = base.nearest(corner, -1, bary)
|
|
449
|
+
if (t >= 0) base.uvAt(t, bary, uv2, 0); else { uv2[0] = 0; uv2[1] = 0 }
|
|
450
|
+
o = xref.length
|
|
451
|
+
slot.set(key, o)
|
|
452
|
+
xref.push(v); uv.push(uv2[0], uv2[1])
|
|
453
|
+
}
|
|
454
|
+
out.push(o)
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
reindexPrimitive(doc, prim, Uint32Array.from(xref), Uint32Array.from(out), Float32Array.from(uv), buffer, retired)
|
|
458
|
+
return xref.length
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** A MESH LARGER THAN A GROUP'S SHARE IS CUT INTO SEVERAL NODES first (the arena's seating bowl: one mesh, 43 % of the
|
|
462
|
+
* level's surface - 6454 texels of layout at 5 cm, which no 4096 page holds and no grouping of whole meshes can help).
|
|
463
|
+
* Its triangles are bisected by their world centroids into k = ceil(area / share) parts; part 0 stays in the mesh, the
|
|
464
|
+
* others become sibling nodes `<name>_lm<i>` with the same transform and material. Left whole: a mesh drawn by several
|
|
465
|
+
* nodes, one with morph targets, one that has LOD siblings. Returns how many nodes were added. */
|
|
466
|
+
const splitLargeMeshes = (doc: Document, plan: UnwrapPlan, buffer: ReturnType<Document['createBuffer']>, retired: Set<Accessor>): number => {
|
|
467
|
+
const root = doc.getRoot()
|
|
468
|
+
const users = new Map<Mesh, GltfNode[]>()
|
|
469
|
+
for (const node of root.listNodes()) { const mesh = node.getMesh(); if (mesh) users.set(mesh, [ ...(users.get(mesh) ?? []), node ]) }
|
|
470
|
+
const lodBases = new Set(plan.lods.map((l) => l.baseNode.getMesh()!))
|
|
471
|
+
type Tri = { prim: Primitive; i: number; c: number[]; area: number; cls: number }
|
|
472
|
+
const perMesh = new Map<Mesh, Tri[]>()
|
|
473
|
+
let total = 0
|
|
474
|
+
const w = new Float32Array(9)
|
|
475
|
+
for (const p of plan.prims) {
|
|
476
|
+
const node = users.get(p.mesh)?.[0]
|
|
477
|
+
const m = node ? node.getWorldMatrix() : [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ]
|
|
478
|
+
const pos = floats(p.prim.getAttribute('POSITION')!, 3)
|
|
479
|
+
const idxAcc = p.prim.getIndices()
|
|
480
|
+
const idx = idxAcc ? idxAcc.getArray()! : Uint32Array.from({ length: pos.length / 3 }, (_, i) => i)
|
|
481
|
+
const list = perMesh.get(p.mesh) ?? []
|
|
482
|
+
for (let i = 0; i + 2 < idx.length; i += 3) {
|
|
483
|
+
for (let k = 0; k < 3; k++) { const v = idx[i + k]; xformPoint(m, pos[v * 3], pos[v * 3 + 1], pos[v * 3 + 2], w, k * 3) }
|
|
484
|
+
const ux = w[3] - w[0], uy = w[4] - w[1], uz = w[5] - w[2], vx = w[6] - w[0], vy = w[7] - w[1], vz = w[8] - w[2]
|
|
485
|
+
const area = 0.5 * Math.hypot(uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx)
|
|
486
|
+
const c = [ (w[0] + w[3] + w[6]) / 3, (w[1] + w[4] + w[7]) / 3, (w[2] + w[5] + w[8]) / 3 ]
|
|
487
|
+
list.push({ prim: p.prim, i, c, area, cls: texelScaleAt(plan, c[0], c[1], c[2]) })
|
|
488
|
+
}
|
|
489
|
+
perMesh.set(p.mesh, list)
|
|
490
|
+
}
|
|
491
|
+
// A mesh spans texel scales (the zone): it is cut along them when what it has in the coarser ones is worth a cut;
|
|
492
|
+
// else it is one piece at its finest. Shares are in EFFECTIVE area (area / scale²) - what the atlas pays.
|
|
493
|
+
const kMinCoarseArea = 50
|
|
494
|
+
const buckets = new Map<Mesh, Map<number, Tri[]>>()
|
|
495
|
+
for (const [ mesh, tris ] of perMesh) {
|
|
496
|
+
const finest = tris.reduce((a, t) => Math.min(a, t.cls), plan.zoneMax)
|
|
497
|
+
const coarseArea = tris.reduce((a, t) => a + (t.cls > finest ? t.area : 0), 0)
|
|
498
|
+
const byCls = new Map<number, Tri[]>()
|
|
499
|
+
for (const t of tris) {
|
|
500
|
+
const cls = coarseArea >= kMinCoarseArea ? t.cls : finest
|
|
501
|
+
byCls.set(cls, [ ...(byCls.get(cls) ?? []), t ])
|
|
502
|
+
}
|
|
503
|
+
buckets.set(mesh, byCls)
|
|
504
|
+
for (const [ cls, list ] of byCls) total += list.reduce((a, t) => a + t.area, 0) / (cls * cls)
|
|
505
|
+
}
|
|
506
|
+
const share = total / plan.groups
|
|
507
|
+
let added = 0
|
|
508
|
+
for (const [ mesh, byCls ] of buckets) {
|
|
509
|
+
const nodes = users.get(mesh) ?? []
|
|
510
|
+
const want = [ ...byCls ].map(([ cls, list ]) => ({ list, parts: Math.min(64, Math.max(1, Math.ceil(list.reduce((a, t) => a + t.area, 0) / (cls * cls) / share))) }))
|
|
511
|
+
if (want.reduce((a, b) => a + b.parts, 0) < 2 || nodes.length !== 1 || lodBases.has(mesh) || mesh.listPrimitives().some((q) => q.listTargets().length > 0)) continue
|
|
512
|
+
const node = nodes[0]
|
|
513
|
+
const out: Tri[][] = []
|
|
514
|
+
const bisect = (list: Tri[], n: number): void => {
|
|
515
|
+
if (n <= 1 || list.length <= 1) { out.push(list); return }
|
|
516
|
+
const lo = [ Infinity, Infinity, Infinity ], hi = [ -Infinity, -Infinity, -Infinity ]
|
|
517
|
+
for (const t of list) for (let k = 0; k < 3; k++) { lo[k] = Math.min(lo[k], t.c[k]); hi[k] = Math.max(hi[k], t.c[k]) }
|
|
518
|
+
let axis = 0
|
|
519
|
+
for (let k = 1; k < 3; k++) if (hi[k] - lo[k] > hi[axis] - lo[axis]) axis = k
|
|
520
|
+
list.sort((p, q) => p.c[axis] - q.c[axis])
|
|
521
|
+
const nl = Math.floor(n / 2)
|
|
522
|
+
const want = list.reduce((a, t) => a + t.area, 0) * nl / n
|
|
523
|
+
let acc = 0, cut = 0
|
|
524
|
+
while (cut < list.length - 1 && acc < want) acc += list[cut++].area
|
|
525
|
+
bisect(list.slice(0, Math.max(1, cut)), nl)
|
|
526
|
+
bisect(list.slice(Math.max(1, cut)), n - nl)
|
|
527
|
+
}
|
|
528
|
+
for (const b of want) bisect(b.list, b.parts)
|
|
529
|
+
// a part's triangles of one primitive -> a compact primitive (attributes remapped like a seam split)
|
|
530
|
+
const build = (prim: Primitive, list: Tri[], into: Primitive): void => {
|
|
531
|
+
const idxAcc = prim.getIndices()
|
|
532
|
+
const idx = idxAcc ? idxAcc.getArray()! : null
|
|
533
|
+
const slot = new Map<number, number>(), xref: number[] = [], indices: number[] = []
|
|
534
|
+
for (const t of list) for (let k = 0; k < 3; k++) {
|
|
535
|
+
const v = idx ? idx[t.i + k] : t.i + k
|
|
536
|
+
let o = slot.get(v)
|
|
537
|
+
if (o === undefined) { o = xref.length; slot.set(v, o); xref.push(v) }
|
|
538
|
+
indices.push(o)
|
|
539
|
+
}
|
|
540
|
+
const x = Uint32Array.from(xref)
|
|
541
|
+
for (const semantic of prim.listSemantics()) into.setAttribute(semantic, remapAccessor(doc, prim.getAttribute(semantic)!, x, buffer))
|
|
542
|
+
into.setIndices(doc.createAccessor().setType('SCALAR').setArray(xref.length <= 65535 ? Uint16Array.from(indices) : Uint32Array.from(indices)).setBuffer(buffer))
|
|
543
|
+
into.setMaterial(prim.getMaterial()).setMode(prim.getMode())
|
|
544
|
+
}
|
|
545
|
+
const prims = mesh.listPrimitives()
|
|
546
|
+
const made: { mesh: Mesh; prim: Primitive }[] = []
|
|
547
|
+
const replacements = new Map<Primitive, Primitive>()
|
|
548
|
+
out.forEach((list, k) => {
|
|
549
|
+
const target = k === 0 ? null : doc.createMesh(`${mesh.getName()}_lm${k}`)
|
|
550
|
+
for (const prim of prims) {
|
|
551
|
+
const mine = list.filter((t) => t.prim === prim)
|
|
552
|
+
if (mine.length === 0) continue
|
|
553
|
+
const q = doc.createPrimitive()
|
|
554
|
+
build(prim, mine, q)
|
|
555
|
+
if (target) { target.addPrimitive(q); made.push({ mesh: target, prim: q }) } else replacements.set(prim, q)
|
|
556
|
+
}
|
|
557
|
+
if (target) {
|
|
558
|
+
const twin = doc.createNode(`${node.getName()}_lm${k}`).setMesh(target).setTranslation(node.getTranslation()).setRotation(node.getRotation()).setScale(node.getScale()).setExtras({ ...node.getExtras() })
|
|
559
|
+
const parent = node.getParentNode()
|
|
560
|
+
if (parent) parent.addChild(twin); else for (const scene of root.listScenes()) if (scene.listChildren().includes(node)) scene.addChild(twin)
|
|
561
|
+
added++
|
|
562
|
+
}
|
|
563
|
+
})
|
|
564
|
+
// part 0 replaces the mesh's own primitives
|
|
565
|
+
for (const prim of prims) {
|
|
566
|
+
for (const semantic of prim.listSemantics()) retired.add(prim.getAttribute(semantic)!)
|
|
567
|
+
const oldIdx = prim.getIndices(); if (oldIdx) retired.add(oldIdx)
|
|
568
|
+
mesh.removePrimitive(prim)
|
|
569
|
+
const q = replacements.get(prim)
|
|
570
|
+
if (q) { mesh.addPrimitive(q); made.push({ mesh, prim: q }) }
|
|
571
|
+
prim.dispose()
|
|
572
|
+
}
|
|
573
|
+
plan.prims = plan.prims.filter((p) => p.mesh !== mesh)
|
|
574
|
+
for (const m of made) {
|
|
575
|
+
const pos = m.prim.getAttribute('POSITION')!
|
|
576
|
+
plan.prims.push({ mesh: m.mesh, prim: m.prim, name: m.mesh.getName(), triangles: m.prim.getIndices()!.getCount() / 3, vertices: pos.getCount(), hasUv1: false })
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
return added
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** Split the plan's meshes into `n` groups of about equal world surface by bisecting their centroids along the longest
|
|
583
|
+
* axis (neighbours share a group — a page holds a part of the level, not a scatter of it). A mesh drawn by several
|
|
584
|
+
* nodes is placed by its first one: UV1 belongs to the mesh, so all of its nodes are in the same group. */
|
|
585
|
+
const partitionMeshes = (doc: Document, plan: UnwrapPlan): { groupOf: Map<Mesh, number>; areas: number[]; scales: number[] } => {
|
|
586
|
+
const firstNode = new Map<Mesh, GltfNode>()
|
|
587
|
+
for (const node of doc.getRoot().listNodes()) {
|
|
588
|
+
const mesh = node.getMesh()
|
|
589
|
+
if (mesh && lodLevelOf(node.getName()) === 0 && !firstNode.has(mesh)) firstNode.set(mesh, node)
|
|
590
|
+
}
|
|
591
|
+
type Item = { mesh: Mesh; c: number[]; area: number; cls: number }
|
|
592
|
+
const items: Item[] = []
|
|
593
|
+
const seen = new Set<Mesh>()
|
|
594
|
+
const w = new Float32Array(9)
|
|
595
|
+
for (const p of plan.prims) {
|
|
596
|
+
if (seen.has(p.mesh)) continue
|
|
597
|
+
seen.add(p.mesh)
|
|
598
|
+
const node = firstNode.get(p.mesh)
|
|
599
|
+
const m = node ? node.getWorldMatrix() : [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ]
|
|
600
|
+
let area = 0, cls = plan.zoneMax
|
|
601
|
+
const c = [ 0, 0, 0 ]
|
|
602
|
+
for (const q of plan.prims) {
|
|
603
|
+
if (q.mesh !== p.mesh) continue
|
|
604
|
+
const pos = floats(q.prim.getAttribute('POSITION')!, 3)
|
|
605
|
+
const idxAcc = q.prim.getIndices()
|
|
606
|
+
const idx = idxAcc ? idxAcc.getArray()! : Uint32Array.from({ length: pos.length / 3 }, (_, i) => i)
|
|
607
|
+
for (let i = 0; i + 2 < idx.length; i += 3) {
|
|
608
|
+
for (let k = 0; k < 3; k++) { const v = idx[i + k]; xformPoint(m, pos[v * 3], pos[v * 3 + 1], pos[v * 3 + 2], w, k * 3) }
|
|
609
|
+
const ux = w[3] - w[0], uy = w[4] - w[1], uz = w[5] - w[2], vx = w[6] - w[0], vy = w[7] - w[1], vz = w[8] - w[2]
|
|
610
|
+
const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx
|
|
611
|
+
const a = 0.5 * Math.sqrt(nx * nx + ny * ny + nz * nz)
|
|
612
|
+
area += a
|
|
613
|
+
for (let k = 0; k < 3; k++) c[k] += a * (w[k] + w[3 + k] + w[6 + k]) / 3
|
|
614
|
+
// a mesh's scale is its FINEST triangle's (splitLargeMeshes has already cut what was worth cutting)
|
|
615
|
+
cls = Math.min(cls, texelScaleAt(plan, (w[0] + w[3] + w[6]) / 3, (w[1] + w[4] + w[7]) / 3, (w[2] + w[5] + w[8]) / 3))
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
if (area > 0) for (let k = 0; k < 3; k++) c[k] /= area
|
|
619
|
+
items.push({ mesh: p.mesh, c, area, cls: area > 0 ? cls : 1 })
|
|
620
|
+
}
|
|
621
|
+
const groupOf = new Map<Mesh, number>()
|
|
622
|
+
const areas: number[] = []
|
|
623
|
+
const scales: number[] = []
|
|
624
|
+
const split = (list: Item[], n: number): void => {
|
|
625
|
+
if (n <= 1 || list.length <= 1) {
|
|
626
|
+
const g = areas.length
|
|
627
|
+
areas.push(list.reduce((a, it) => a + it.area, 0))
|
|
628
|
+
scales.push(list[0]?.cls ?? 1)
|
|
629
|
+
for (const it of list) groupOf.set(it.mesh, g)
|
|
630
|
+
return
|
|
631
|
+
}
|
|
632
|
+
const lo = [ Infinity, Infinity, Infinity ], hi = [ -Infinity, -Infinity, -Infinity ]
|
|
633
|
+
for (const it of list) for (let k = 0; k < 3; k++) { lo[k] = Math.min(lo[k], it.c[k]); hi[k] = Math.max(hi[k], it.c[k]) }
|
|
634
|
+
let axis = 0
|
|
635
|
+
for (let k = 1; k < 3; k++) if (hi[k] - lo[k] > hi[axis] - lo[axis]) axis = k
|
|
636
|
+
const sorted = [ ...list ].sort((p, q) => p.c[axis] - q.c[axis])
|
|
637
|
+
const nl = Math.floor(n / 2)
|
|
638
|
+
const want = sorted.reduce((a, it) => a + it.area, 0) * nl / n
|
|
639
|
+
let acc = 0, cut = 0
|
|
640
|
+
while (cut < sorted.length - 1 && (cut === 0 || acc + sorted[cut].area * 0.5 <= want)) acc += sorted[cut++].area
|
|
641
|
+
split(sorted.slice(0, cut), nl)
|
|
642
|
+
split(sorted.slice(cut), n - nl)
|
|
643
|
+
}
|
|
644
|
+
// every scale gets its share of the groups by what it costs in the atlas (area / scale²), one at least
|
|
645
|
+
const classes = [ ...new Set(items.map((it) => it.cls)) ].sort((a, b) => a - b)
|
|
646
|
+
const eff = classes.map((cls) => items.filter((it) => it.cls === cls).reduce((a, it) => a + it.area, 0) / (cls * cls))
|
|
647
|
+
const effTotal = eff.reduce((a, b) => a + b, 0) || 1
|
|
648
|
+
classes.forEach((cls, i) => split(items.filter((it) => it.cls === cls), Math.max(1, Math.round(plan.groups * eff[i] / effTotal))))
|
|
649
|
+
return { groupOf, areas, scales }
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
export async function applyUnwrap(doc: Document, plan: UnwrapPlan): Promise<UnwrapResult> {
|
|
653
|
+
const res: UnwrapResult = { primitives: 0, charts: 0, width: 0, height: 0, verticesBefore: 0, verticesAfter: 0, groups: [], splitNodes: 0, lodPrimitives: 0, lodVertices: 0 }
|
|
654
|
+
if (plan.prims.length === 0) return res
|
|
655
|
+
const m = await getModule()
|
|
656
|
+
const buffer = doc.getRoot().listBuffers()[0] ?? doc.createBuffer()
|
|
657
|
+
const retired = new Set<Accessor>()
|
|
658
|
+
|
|
659
|
+
// one xatlas layout = one unit square = one atlas rect of the bake
|
|
660
|
+
const unwrap = (prims: UnwrapPrim[], texelScale = 1): UnwrapGroup => {
|
|
661
|
+
const h = m._lmuv_begin(prims.length)
|
|
662
|
+
if (h === 0) throw new Error(`lightmap UV: ${m.UTF8ToString(m._lmuv_error())}`)
|
|
663
|
+
const allocs: number[] = []
|
|
664
|
+
const upload = (arr: Float32Array | Uint32Array): number => {
|
|
665
|
+
const ptr = m._malloc(arr.byteLength)
|
|
666
|
+
allocs.push(ptr)
|
|
667
|
+
if (arr instanceof Float32Array) m.HEAPF32.set(arr, ptr >> 2)
|
|
668
|
+
else m.HEAPU32.set(arr, ptr >> 2)
|
|
669
|
+
return ptr
|
|
670
|
+
}
|
|
671
|
+
try {
|
|
672
|
+
for (const p of prims) {
|
|
673
|
+
const pos = p.prim.getAttribute('POSITION')!
|
|
674
|
+
const nrm = p.prim.getAttribute('NORMAL')
|
|
675
|
+
const uv0 = p.prim.getAttribute('TEXCOORD_0')
|
|
676
|
+
const idxAcc = p.prim.getIndices()
|
|
677
|
+
const indices = idxAcc ? Uint32Array.from(idxAcc.getArray()!) : Uint32Array.from({ length: pos.getCount() }, (_, i) => i)
|
|
678
|
+
const indexCount = indices.length - (indices.length % 3)
|
|
679
|
+
const posPtr = upload(floats(pos, 3))
|
|
680
|
+
const nrmPtr = nrm && nrm.getCount() === pos.getCount() ? upload(floats(nrm, 3)) : 0
|
|
681
|
+
const uvPtr = uv0 && uv0.getCount() === pos.getCount() ? upload(floats(uv0, 2)) : 0
|
|
682
|
+
const idxPtr = upload(indices)
|
|
683
|
+
const err = m._lmuv_add_mesh(h, posPtr, nrmPtr, uvPtr, pos.getCount(), idxPtr, indexCount)
|
|
684
|
+
if (err !== 0) throw new Error(`lightmap UV: ${p.name}: ${m.UTF8ToString(m._lmuv_error())}`)
|
|
685
|
+
res.verticesBefore += pos.getCount()
|
|
686
|
+
}
|
|
687
|
+
// xatlas copies the mesh data in AddMesh — the inputs can go before the (long) generate.
|
|
688
|
+
for (const ptr of allocs) m._free(ptr)
|
|
689
|
+
allocs.length = 0
|
|
690
|
+
const err = m._lmuv_generate(h, plan.padding, plan.iterations, plan.texelsPerUnit / texelScale)
|
|
691
|
+
if (err !== 0) throw new Error(`lightmap UV: ${m.UTF8ToString(m._lmuv_error())}`)
|
|
692
|
+
const group: UnwrapGroup = { texelScale, primitives: prims.length, charts: m._lmuv_chart_count(h), width: m._lmuv_width(h), height: m._lmuv_height(h), area: 0 }
|
|
693
|
+
if (m._lmuv_mesh_count(h) !== prims.length) throw new Error('lightmap UV: xatlas returned a different mesh count')
|
|
694
|
+
prims.forEach((p, i) => {
|
|
695
|
+
const vcount = m._lmuv_mesh_vertex_count(h, i)
|
|
696
|
+
const icount = m._lmuv_mesh_index_count(h, i)
|
|
697
|
+
const xrefPtr = m._lmuv_mesh_xref(h, i) >> 2
|
|
698
|
+
const xref = m.HEAPU32.slice(xrefPtr, xrefPtr + vcount)
|
|
699
|
+
const uvPtr = m._lmuv_mesh_uv(h, i) >> 2
|
|
700
|
+
const uv1 = m.HEAPF32.slice(uvPtr, uvPtr + vcount * 2)
|
|
701
|
+
const idxPtr = m._lmuv_mesh_indices(h, i) >> 2
|
|
702
|
+
const idx32 = m.HEAPU32.slice(idxPtr, idxPtr + icount)
|
|
703
|
+
reindexPrimitive(doc, p.prim, xref, idx32, uv1, buffer, retired)
|
|
704
|
+
res.verticesAfter += vcount
|
|
705
|
+
res.primitives++
|
|
706
|
+
})
|
|
707
|
+
return group
|
|
708
|
+
} finally {
|
|
709
|
+
for (const ptr of allocs) m._free(ptr)
|
|
710
|
+
m._lmuv_end(h)
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// a node's group rides in its extras; a file unwrapped whole carries none (and loses what an earlier split left)
|
|
715
|
+
const setGroup = (node: GltfNode, g: number | undefined, texelScale = 1): void => {
|
|
716
|
+
const extras = { ...(node.getExtras() as Record<string, unknown>) }
|
|
717
|
+
if (g === undefined) delete extras.lightmapGroup; else extras.lightmapGroup = g
|
|
718
|
+
if (g === undefined || texelScale <= 1) delete extras.lightmapTexelScale; else extras.lightmapTexelScale = texelScale
|
|
719
|
+
node.setExtras(extras)
|
|
720
|
+
}
|
|
721
|
+
if (plan.groups <= 1) {
|
|
722
|
+
const g = unwrap(plan.prims)
|
|
723
|
+
res.charts = g.charts; res.width = g.width; res.height = g.height
|
|
724
|
+
for (const node of doc.getRoot().listNodes()) if (node.getMesh() && (node.getExtras() as Record<string, unknown>).lightmapGroup !== undefined) setGroup(node, undefined)
|
|
725
|
+
} else {
|
|
726
|
+
res.splitNodes = splitLargeMeshes(doc, plan, buffer, retired)
|
|
727
|
+
const { groupOf, areas, scales } = partitionMeshes(doc, plan)
|
|
728
|
+
for (let k = 0; k < areas.length; k++) {
|
|
729
|
+
const g = unwrap(plan.prims.filter((p) => groupOf.get(p.mesh) === k), scales[k])
|
|
730
|
+
g.area = areas[k]
|
|
731
|
+
res.groups.push(g)
|
|
732
|
+
res.charts += g.charts; res.width = Math.max(res.width, g.width); res.height = Math.max(res.height, g.height)
|
|
733
|
+
}
|
|
734
|
+
// every node of a grouped mesh, and the LOD siblings with their level 0 (they read its texels)
|
|
735
|
+
const lodBase = new Map<Mesh, Mesh>()
|
|
736
|
+
for (const lod of plan.lods) lodBase.set(lod.mesh, lod.baseNode.getMesh()!)
|
|
737
|
+
for (const node of doc.getRoot().listNodes()) {
|
|
738
|
+
const mesh = node.getMesh()
|
|
739
|
+
if (!mesh) continue
|
|
740
|
+
const g = groupOf.get(lodBase.get(mesh) ?? mesh)
|
|
741
|
+
setGroup(node, g, g !== undefined ? scales[g] : 1)
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// the LOD siblings, from the level-0 meshes that now carry the layout
|
|
746
|
+
const surfaces = new Map<GltfNode, BaseSurface>()
|
|
747
|
+
for (const lod of plan.lods) {
|
|
748
|
+
let base = surfaces.get(lod.baseNode)
|
|
749
|
+
if (!base) { base = new BaseSurface(lod.baseNode); surfaces.set(lod.baseNode, base) }
|
|
750
|
+
if (base.count === 0) continue // level 0 got no layout (skipped above): the LOD keeps what it had
|
|
751
|
+
res.lodVertices += projectLodPrimitive(doc, lod, base, buffer, retired)
|
|
752
|
+
res.lodPrimitives++
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// A retired accessor may still back another primitive (shared POSITION with different indices)
|
|
756
|
+
// or a morph target we did not touch — only drop the ones nothing references any more.
|
|
757
|
+
for (const acc of retired) {
|
|
758
|
+
if (acc.listParents().every((parent) => parent.propertyType === 'Root')) acc.dispose()
|
|
759
|
+
}
|
|
760
|
+
return res
|
|
761
|
+
}
|