lecodes-assets 0.2.0 → 0.2.1

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.
Files changed (65) hide show
  1. package/README.md +561 -449
  2. package/dist/cli.js +5632 -521
  3. package/dist/index.js +5647 -526
  4. package/package.json +13 -3
  5. package/src/bvh/fromDocument.ts +136 -0
  6. package/src/bvh/toDocument.ts +202 -0
  7. package/src/cli.ts +1039 -0
  8. package/src/doctor/doctor.ts +241 -0
  9. package/src/doctor/lightmapUv.ts +452 -0
  10. package/src/doctor/lod.ts +177 -0
  11. package/src/doctor/normalizeScale.ts +220 -0
  12. package/src/doctor/reduceGlbBones.ts +218 -0
  13. package/src/doctor/shading.ts +95 -0
  14. package/src/doctor/transcodeKtx2.ts +124 -0
  15. package/src/doctor/transcodeTextures.ts +101 -0
  16. package/src/doctor/validateGlb.ts +264 -0
  17. package/src/fbx/convert.ts +327 -0
  18. package/src/fbx/read.ts +109 -0
  19. package/src/fbx/toDocument.ts +209 -0
  20. package/src/filmstrip.ts +263 -0
  21. package/src/index.ts +17 -0
  22. package/src/mergeSkins.ts +117 -0
  23. package/src/probe.ts +167 -0
  24. package/src/props.ts +137 -0
  25. package/src/retarget/automap.ts +276 -0
  26. package/src/retarget/ghost.ts +23 -0
  27. package/src/retarget/heading.ts +171 -0
  28. package/src/retarget/humanoid.ts +561 -0
  29. package/src/retarget/inPlace.ts +130 -0
  30. package/src/retarget/marks.ts +581 -0
  31. package/src/retarget/mirror.ts +0 -0
  32. package/src/retarget/retarget.ts +2272 -0
  33. package/src/retarget/rig.ts +542 -0
  34. package/src/retarget/unityAnim.ts +171 -0
  35. package/src/retarget/writeMarks.ts +132 -0
  36. package/src/sky/analyze.ts +136 -0
  37. package/src/sky/cmgen.ts +71 -0
  38. package/src/sky/index.ts +151 -0
  39. package/src/terrainPack.ts +169 -0
  40. package/src/textures/resolveImage.ts +202 -0
  41. package/src/unity/materials.ts +593 -0
  42. package/src/unity/png.ts +75 -0
  43. package/src/unity/prefabs.ts +80 -0
  44. package/src/unity/scene.ts +363 -0
  45. package/src/unity/unpack.ts +130 -0
  46. package/src/unreal/anim.ts +284 -0
  47. package/src/unreal/clipSource.ts +25 -0
  48. package/src/unreal/convert.ts +67 -0
  49. package/src/unreal/convertAsset.ts +81 -0
  50. package/src/unreal/index.ts +114 -0
  51. package/src/unreal/list.ts +113 -0
  52. package/src/unreal/materials.ts +291 -0
  53. package/src/unreal/mesh.ts +205 -0
  54. package/src/unreal/oodle.ts +153 -0
  55. package/src/unreal/package.ts +659 -0
  56. package/src/unreal/probe.ts +144 -0
  57. package/src/unreal/resolve.ts +51 -0
  58. package/src/unreal/rig.ts +149 -0
  59. package/src/unreal/sequencer.ts +193 -0
  60. package/src/unreal/skeleton.ts +74 -0
  61. package/src/unreal/texture.ts +202 -0
  62. package/src/unreal/toDocument.ts +360 -0
  63. package/src/unreal/versions.ts +100 -0
  64. package/src/wasm/fbxReader.js +14 -0
  65. package/src/wasm/lightmapUv.js +14 -0
@@ -0,0 +1,452 @@
1
+ // Lightmap UV pass — `--lightmap-uv`: a second texture-coordinate set (TEXCOORD_1) that lays every
2
+ // triangle of the file out in ONE non-overlapping unit square, made by xatlas (vendor/xatlas, run
3
+ // as wasm — src/wasm/lightmapUv.js). This is what creator-gl's lightmap bake samples and writes
4
+ // (docs/lightmap-plan.md): each placed instance of the prop gets a rectangle of the scene atlas and
5
+ // its UV1 is mapped into it, so the props stay clones and one unwrap per file is enough.
6
+ //
7
+ // xatlas RE-INDEXES the mesh: a vertex that sits on a chart seam is split into one copy per chart,
8
+ // and every output vertex carries `xref`, the input vertex it came from. So this pass rebuilds every
9
+ // attribute (positions, normals, UV0, colours, joints/weights, tangents, morph targets) by that map,
10
+ // preserving each accessor's component type and normalization, and widens the index buffer past
11
+ // 65535 when the split pushes it there. Skinned meshes are left alone: a lightmap is baked in ONE
12
+ // pose, and a rigged character never stays in it.
13
+ //
14
+ // LOD siblings (`<name>_LOD<n>`, n ≥ 1 — the kit's own or `doctor --lod`'s) are NOT unwrapped: the
15
+ // engine's lightmap pass traces level 0 only and binds ONE atlas rect per instance, which every
16
+ // level reads. So level 0 gets the xatlas layout and the LODs get level 0's UV1 by PROJECTION: each
17
+ // LOD triangle's centroid picks the closest level-0 triangle and with it a chart, and each corner
18
+ // takes the UV of its closest point on that chart (a corner that straddles charts is split, like a
19
+ // seam vertex). A decimated surface then samples the very texels its full-detail twin was traced
20
+ // into — no atlas paid twice, no LOD reading texels nothing lit (the two-features-corrupt-each-other
21
+ // bug of fps-demo FEEDBACK 92).
22
+ import type { Accessor, Document, Mesh, Node as GltfNode, Primitive } from '@gltf-transform/core'
23
+ // @ts-ignore — generated by build-wasm.sh (Emscripten single-file module)
24
+ import createLightmapUv from '../wasm/lightmapUv.js'
25
+
26
+ const TRIANGLES = 4
27
+
28
+ type Module = {
29
+ _malloc(n: number): number
30
+ _free(p: number): void
31
+ _lmuv_begin(hint: number): number
32
+ _lmuv_add_mesh(h: number, pos: number, normals: number, uv: number, vertexCount: number, indices: number, indexCount: number): number
33
+ _lmuv_generate(h: number, padding: number, iterations: number): number
34
+ _lmuv_width(h: number): number
35
+ _lmuv_height(h: number): number
36
+ _lmuv_chart_count(h: number): number
37
+ _lmuv_mesh_count(h: number): number
38
+ _lmuv_mesh_vertex_count(h: number, i: number): number
39
+ _lmuv_mesh_index_count(h: number, i: number): number
40
+ _lmuv_mesh_xref(h: number, i: number): number
41
+ _lmuv_mesh_uv(h: number, i: number): number
42
+ _lmuv_mesh_indices(h: number, i: number): number
43
+ _lmuv_error(): number
44
+ _lmuv_end(h: number): void
45
+ HEAPU8: Uint8Array
46
+ HEAPU32: Uint32Array
47
+ HEAPF32: Float32Array
48
+ UTF8ToString(ptr: number): string
49
+ }
50
+
51
+ let modulePromise: Promise<Module> | undefined
52
+ const getModule = (): Promise<Module> => (modulePromise ??= (createLightmapUv as () => Promise<Module>)())
53
+
54
+ export interface LightmapUvOptions {
55
+ lightmapUv?: boolean
56
+ /** gutter between charts, in texels of xatlas's ~1024² layout (default 4) */
57
+ lightmapPadding?: number
58
+ /** xatlas chart-growing iterations — more = better charts, slower (default 1) */
59
+ lightmapIterations?: number
60
+ }
61
+
62
+ export interface LightmapUvPrim { mesh: Mesh; prim: Primitive; name: string; triangles: number; vertices: number; hasUv1: boolean }
63
+ /** A LOD sibling's primitive: takes its UV1 from `baseNode`'s (level-0) mesh by projection. */
64
+ export interface LightmapUvLod { mesh: Mesh; prim: Primitive; node: GltfNode; baseNode: GltfNode; level: number; name: string; triangles: number }
65
+ export interface LightmapUvPlan {
66
+ prims: LightmapUvPrim[]
67
+ lods: LightmapUvLod[]
68
+ skipped: { name: string; reason: string }[]
69
+ enabled: boolean
70
+ padding: number
71
+ iterations: number
72
+ }
73
+
74
+ export const DEFAULT_LIGHTMAP_PADDING = 4
75
+
76
+ const LOD_SUFFIX = /_LOD(\d+)$/
77
+ /** "<base>_LOD<n>" → n (≥ 1), else 0 — the suffix creator-gl's lod.cpp groups renderables by. */
78
+ export const lodLevelOf = (name: string): number => { const m = LOD_SUFFIX.exec(name); return m ? Number(m[1]) : 0 }
79
+
80
+ const primName = (mesh: Mesh, i: number): string => `${mesh.getName() || 'mesh'}${mesh.listPrimitives().length > 1 ? `[${i}]` : ''}`
81
+
82
+ export function analyzeLightmapUv(doc: Document, opts: LightmapUvOptions = {}): LightmapUvPlan {
83
+ const root = doc.getRoot()
84
+ const skinnedMeshes = new Set<Mesh>()
85
+ const byName = new Map<string, GltfNode>()
86
+ for (const node of root.listNodes()) {
87
+ const mesh = node.getMesh()
88
+ if (mesh && node.getSkin()) skinnedMeshes.add(mesh)
89
+ if (mesh && node.getName() && !byName.has(node.getName())) byName.set(node.getName(), node)
90
+ }
91
+ // LOD siblings: a mesh drawn by a `<base>_LOD<n≥1>` node whose level-0 node (`<base>` or
92
+ // `<base>_LOD0`) has a mesh of its own; a mesh SHARED with level 0 needs nothing.
93
+ const lodMeshes = new Map<Mesh, { node: GltfNode; baseNode: GltfNode; level: number }>()
94
+ const level0Meshes = new Set<Mesh>()
95
+ for (const node of root.listNodes()) {
96
+ const mesh = node.getMesh()
97
+ if (!mesh) continue
98
+ if (lodLevelOf(node.getName()) === 0) level0Meshes.add(mesh)
99
+ }
100
+ for (const node of root.listNodes()) {
101
+ const mesh = node.getMesh()
102
+ if (!mesh || level0Meshes.has(mesh) || lodMeshes.has(mesh)) continue
103
+ const level = lodLevelOf(node.getName())
104
+ if (level === 0) continue
105
+ const base = node.getName().replace(LOD_SUFFIX, '')
106
+ const baseNode = byName.get(base) ?? byName.get(`${base}_LOD0`)
107
+ if (!baseNode || !baseNode.getMesh() || baseNode.getMesh() === mesh || skinnedMeshes.has(baseNode.getMesh()!)) continue
108
+ lodMeshes.set(mesh, { node, baseNode, level })
109
+ }
110
+ const prims: LightmapUvPrim[] = []
111
+ const lods: LightmapUvLod[] = []
112
+ const skipped: { name: string; reason: string }[] = []
113
+ for (const mesh of root.listMeshes()) {
114
+ const lod = lodMeshes.get(mesh)
115
+ mesh.listPrimitives().forEach((prim, i) => {
116
+ const name = primName(mesh, i)
117
+ const pos = prim.getAttribute('POSITION')
118
+ if (!pos || pos.getCount() === 0) { skipped.push({ name, reason: 'no positions' }); return }
119
+ if (prim.getMode() !== TRIANGLES) { skipped.push({ name, reason: 'not a triangle list' }); return }
120
+ if (skinnedMeshes.has(mesh)) { skipped.push({ name, reason: 'skinned' }); return }
121
+ const idx = prim.getIndices()
122
+ const triangles = Math.floor((idx ? idx.getCount() : pos.getCount()) / 3)
123
+ if (triangles === 0) { skipped.push({ name, reason: 'no triangles' }); return }
124
+ if (lod) { lods.push({ mesh, prim, node: lod.node, baseNode: lod.baseNode, level: lod.level, name, triangles }); return }
125
+ prims.push({ mesh, prim, name, triangles, vertices: pos.getCount(), hasUv1: !!prim.getAttribute('TEXCOORD_1') })
126
+ })
127
+ }
128
+ return {
129
+ prims, lods, skipped, enabled: !!opts.lightmapUv,
130
+ padding: opts.lightmapPadding ?? DEFAULT_LIGHTMAP_PADDING,
131
+ iterations: opts.lightmapIterations ?? 1,
132
+ }
133
+ }
134
+
135
+ export interface LightmapUvResult {
136
+ primitives: number; charts: number; width: number; height: number; verticesBefore: number; verticesAfter: number
137
+ /** LOD primitives that took level 0's layout by projection, and the vertices they hold afterwards */
138
+ lodPrimitives: number; lodVertices: number
139
+ }
140
+
141
+ /** Every element of `acc` as floats (dequantized), `size` per element. */
142
+ const floats = (acc: Accessor, size: number): Float32Array => {
143
+ const n = acc.getCount()
144
+ const out = new Float32Array(n * size)
145
+ const el: number[] = new Array(acc.getElementSize()).fill(0)
146
+ 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 }
147
+ return out
148
+ }
149
+
150
+ /** A copy of `acc` with its elements reordered by `xref` — same type, component type, normalization. */
151
+ const remapAccessor = (doc: Document, acc: Accessor, xref: Uint32Array, buffer: ReturnType<Document['createBuffer']>): Accessor => {
152
+ const src = acc.getArray()!
153
+ const es = acc.getElementSize()
154
+ const Ctor = src.constructor as { new (n: number): typeof src }
155
+ const out = new Ctor(xref.length * es)
156
+ for (let v = 0; v < xref.length; v++) {
157
+ const s = xref[v] * es
158
+ for (let k = 0; k < es; k++) out[v * es + k] = src[s + k]
159
+ }
160
+ return doc.createAccessor(acc.getName()).setType(acc.getType()).setNormalized(acc.getNormalized()).setArray(out).setBuffer(buffer)
161
+ }
162
+
163
+ /** Rebuild a primitive's attributes, morph targets and indices by `xref` (re-indexed geometry) and give it `uv1`. */
164
+ const reindexPrimitive = (doc: Document, prim: Primitive, xref: Uint32Array, indices32: Uint32Array<ArrayBuffer>, uv1: Float32Array<ArrayBuffer>,
165
+ buffer: ReturnType<Document['createBuffer']>, retired: Set<Accessor>): void => {
166
+ for (const semantic of prim.listSemantics()) {
167
+ const acc = prim.getAttribute(semantic)!
168
+ if (semantic === 'TEXCOORD_1') { retired.add(acc); continue } // replaced below
169
+ retired.add(acc)
170
+ prim.setAttribute(semantic, remapAccessor(doc, acc, xref, buffer))
171
+ }
172
+ for (const target of prim.listTargets()) {
173
+ for (const semantic of target.listSemantics()) {
174
+ const acc = target.getAttribute(semantic)!
175
+ retired.add(acc)
176
+ target.setAttribute(semantic, remapAccessor(doc, acc, xref, buffer))
177
+ }
178
+ }
179
+ const oldIdx = prim.getIndices()
180
+ if (oldIdx) retired.add(oldIdx)
181
+ const indices = xref.length <= 65535 ? Uint16Array.from(indices32) : indices32
182
+ prim.setIndices(doc.createAccessor().setType('SCALAR').setArray(indices).setBuffer(buffer))
183
+ prim.setAttribute('TEXCOORD_1', doc.createAccessor('lightmap_uv').setType('VEC2').setArray(uv1).setBuffer(buffer))
184
+ }
185
+
186
+ // ---- LOD projection ------------------------------------------------------------------------------
187
+
188
+ const xformPoint = (m: number[], x: number, y: number, z: number, out: Float32Array, o: number): void => {
189
+ out[o] = m[0] * x + m[4] * y + m[8] * z + m[12]
190
+ out[o + 1] = m[1] * x + m[5] * y + m[9] * z + m[13]
191
+ out[o + 2] = m[2] * x + m[6] * y + m[10] * z + m[14]
192
+ }
193
+
194
+ /** Level 0's triangles in world space with their UV1 and chart, on a uniform grid for nearest-point queries. */
195
+ class BaseSurface {
196
+ readonly tris: Float32Array // 9 floats per triangle (world positions)
197
+ readonly uvs: Float32Array // 6 floats per triangle
198
+ readonly chart: Int32Array // chart id per triangle (an xatlas chart = connected output vertices)
199
+ readonly count: number
200
+ private readonly min = [ 0, 0, 0 ]
201
+ private readonly cell: number
202
+ private readonly dims = [ 1, 1, 1 ]
203
+ private readonly cells = new Map<number, number[]>()
204
+
205
+ constructor(node: GltfNode) {
206
+ const world = node.getWorldMatrix()
207
+ const triPos: number[] = [], triUv: number[] = [], triChart: number[] = []
208
+ let chartBase = 0
209
+ for (const prim of node.getMesh()!.listPrimitives()) {
210
+ const pos = prim.getAttribute('POSITION'), uv1 = prim.getAttribute('TEXCOORD_1')
211
+ if (!pos || !uv1 || prim.getMode() !== TRIANGLES) continue
212
+ const idxAcc = prim.getIndices()
213
+ const idx = idxAcc ? idxAcc.getArray()! : Uint32Array.from({ length: pos.getCount() }, (_, i) => i)
214
+ const p = floats(pos, 3), u = floats(uv1, 2)
215
+ // charts = connected components over the (seam-split) index buffer
216
+ const parent = Int32Array.from({ length: pos.getCount() }, (_, i) => i)
217
+ const find = (a: number): number => { while (parent[a] !== a) { parent[a] = parent[parent[a]]; a = parent[a] } return a }
218
+ for (let i = 0; i + 2 < idx.length; i += 3) {
219
+ const a = find(idx[i]), b = find(idx[i + 1]), c = find(idx[i + 2])
220
+ parent[b] = a; parent[find(c)] = find(a)
221
+ }
222
+ const chartOf = new Map<number, number>()
223
+ for (let i = 0; i + 2 < idx.length; i += 3) {
224
+ const r = find(idx[i])
225
+ let ch = chartOf.get(r)
226
+ if (ch === undefined) { ch = chartBase + chartOf.size; chartOf.set(r, ch) }
227
+ const w = new Float32Array(9)
228
+ 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]) }
229
+ for (let k = 0; k < 9; k++) triPos.push(w[k])
230
+ triChart.push(ch)
231
+ }
232
+ chartBase += chartOf.size
233
+ }
234
+ this.tris = Float32Array.from(triPos); this.uvs = Float32Array.from(triUv); this.chart = Int32Array.from(triChart)
235
+ this.count = triChart.length
236
+ if (this.count === 0) { this.cell = 1; return }
237
+ const min = [ Infinity, Infinity, Infinity ], max = [ -Infinity, -Infinity, -Infinity ]
238
+ 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]) }
239
+ const ext = [ max[0] - min[0], max[1] - min[1], max[2] - min[2] ]
240
+ const longest = Math.max(ext[0], ext[1], ext[2], 1e-6)
241
+ // ~4 triangles per cell along the longest axis, 48 cells at most: a crate's 10k triangles land in a few hundred cells
242
+ this.cell = Math.max(longest / Math.min(48, Math.max(4, Math.cbrt(this.count) * 2)), 1e-5)
243
+ this.min = [ min[0] - this.cell * 0.5, min[1] - this.cell * 0.5, min[2] - this.cell * 0.5 ]
244
+ this.dims = ext.map((e) => Math.max(1, Math.ceil(e / this.cell) + 1))
245
+ for (let t = 0; t < this.count; t++) {
246
+ const lo = [ Infinity, Infinity, Infinity ], hi = [ -Infinity, -Infinity, -Infinity ]
247
+ 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) }
248
+ const c0 = this.cellOf(lo), c1 = this.cellOf(hi)
249
+ 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++) {
250
+ const key = this.key(x, y, z)
251
+ let list = this.cells.get(key)
252
+ if (!list) { list = []; this.cells.set(key, list) }
253
+ list.push(t)
254
+ }
255
+ }
256
+ }
257
+
258
+ private cellOf(p: ArrayLike<number>): number[] {
259
+ return [ 0, 1, 2 ].map((a) => Math.min(this.dims[a] - 1, Math.max(0, Math.floor((p[a] - this.min[a]) / this.cell))))
260
+ }
261
+ private key(x: number, y: number, z: number): number { return (z * this.dims[1] + y) * this.dims[0] + x }
262
+
263
+ /** Closest point on triangle `t` to `p` (Ericson, Real-Time Collision Detection 5.1.5): squared distance + barycentrics. */
264
+ private closest(t: number, p: number[], out: { d2: number; b: number[] }): void {
265
+ const T = this.tris, o = t * 9
266
+ 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]
267
+ const abx = bx - ax, aby = by - ay, abz = bz - az, acx = cx - ax, acy = cy - ay, acz = cz - az
268
+ const apx = p[0] - ax, apy = p[1] - ay, apz = p[2] - az
269
+ const d1 = abx * apx + aby * apy + abz * apz, d2 = acx * apx + acy * apy + acz * apz
270
+ let u: number, v: number, w: number
271
+ if (d1 <= 0 && d2 <= 0) { u = 1; v = 0; w = 0 } else {
272
+ const bpx = p[0] - bx, bpy = p[1] - by, bpz = p[2] - bz
273
+ const d3 = abx * bpx + aby * bpy + abz * bpz, d4 = acx * bpx + acy * bpy + acz * bpz
274
+ if (d3 >= 0 && d4 <= d3) { u = 0; v = 1; w = 0 } else {
275
+ const vc = d1 * d4 - d3 * d2
276
+ if (vc <= 0 && d1 >= 0 && d3 <= 0) { v = d1 / (d1 - d3); u = 1 - v; w = 0 } else {
277
+ const cpx = p[0] - cx, cpy = p[1] - cy, cpz = p[2] - cz
278
+ const d5 = abx * cpx + aby * cpy + abz * cpz, d6 = acx * cpx + acy * cpy + acz * cpz
279
+ if (d6 >= 0 && d5 <= d6) { u = 0; v = 0; w = 1 } else {
280
+ const vb = d5 * d2 - d1 * d6
281
+ if (vb <= 0 && d2 >= 0 && d6 <= 0) { w = d2 / (d2 - d6); u = 1 - w; v = 0 } else {
282
+ const va = d3 * d6 - d5 * d4
283
+ if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0) { w = (d4 - d3) / ((d4 - d3) + (d5 - d6)); v = 1 - w; u = 0 } else {
284
+ const denom = 1 / (va + vb + vc)
285
+ v = vb * denom; w = vc * denom; u = 1 - v - w
286
+ }
287
+ }
288
+ }
289
+ }
290
+ }
291
+ }
292
+ const qx = ax * u + bx * v + cx * w, qy = ay * u + by * v + cy * w, qz = az * u + bz * v + cz * w
293
+ out.d2 = (qx - p[0]) ** 2 + (qy - p[1]) ** 2 + (qz - p[2]) ** 2
294
+ out.b[0] = u; out.b[1] = v; out.b[2] = w
295
+ }
296
+
297
+ /** The closest triangle to `p` (within chart `chart` when ≥ 0) and the barycentrics of the closest point; -1 = none. */
298
+ nearest(p: number[], chart: number, bary: number[]): number {
299
+ if (this.count === 0) return -1
300
+ const c = this.cellOf(p)
301
+ let best = -1, bestD2 = Infinity
302
+ const tmp = { d2: 0, b: [ 0, 0, 0 ] }
303
+ const maxR = Math.max(this.dims[0], this.dims[1], this.dims[2])
304
+ for (let r = 0; r <= maxR; r++) {
305
+ // everything not yet visited lies at least (r − 1) cells away: stop once the best is closer than that
306
+ if (best >= 0 && Math.sqrt(bestD2) <= (r - 1) * this.cell) break
307
+ for (let z = c[2] - r; z <= c[2] + r; z++) {
308
+ if (z < 0 || z >= this.dims[2]) continue
309
+ for (let y = c[1] - r; y <= c[1] + r; y++) {
310
+ if (y < 0 || y >= this.dims[1]) continue
311
+ const edgeYZ = Math.abs(z - c[2]) === r || Math.abs(y - c[1]) === r
312
+ for (let x = c[0] - r; x <= c[0] + r; x++) {
313
+ if (x < 0 || x >= this.dims[0]) continue
314
+ if (!edgeYZ && Math.abs(x - c[0]) !== r) continue // only the ring's shell
315
+ const list = this.cells.get(this.key(x, y, z))
316
+ if (!list) continue
317
+ for (const t of list) {
318
+ if (chart >= 0 && this.chart[t] !== chart) continue
319
+ this.closest(t, p, tmp)
320
+ if (tmp.d2 < bestD2) { bestD2 = tmp.d2; best = t; bary[0] = tmp.b[0]; bary[1] = tmp.b[1]; bary[2] = tmp.b[2] }
321
+ }
322
+ }
323
+ }
324
+ }
325
+ }
326
+ return best
327
+ }
328
+
329
+ uvAt(t: number, bary: number[], out: Float32Array, o: number): void {
330
+ const U = this.uvs, k = t * 6
331
+ out[o] = U[k] * bary[0] + U[k + 2] * bary[1] + U[k + 4] * bary[2]
332
+ out[o + 1] = U[k + 1] * bary[0] + U[k + 3] * bary[1] + U[k + 5] * bary[2]
333
+ }
334
+ }
335
+
336
+ /** Give a LOD primitive level 0's UV1: per triangle the centroid picks a chart, per corner the closest point on it. */
337
+ const projectLodPrimitive = (doc: Document, lod: LightmapUvLod, base: BaseSurface, buffer: ReturnType<Document['createBuffer']>,
338
+ retired: Set<Accessor>): number => {
339
+ const prim = lod.prim
340
+ const pos = prim.getAttribute('POSITION')!
341
+ const idxAcc = prim.getIndices()
342
+ const idx = idxAcc ? Uint32Array.from(idxAcc.getArray()!) : Uint32Array.from({ length: pos.getCount() }, (_, i) => i)
343
+ const local = floats(pos, 3)
344
+ const world = lod.node.getWorldMatrix()
345
+ const wp = new Float32Array(local.length)
346
+ for (let v = 0; v < pos.getCount(); v++) xformPoint(world, local[v * 3], local[v * 3 + 1], local[v * 3 + 2], wp, v * 3)
347
+ const xref: number[] = [], uv: number[] = [], out: number[] = []
348
+ const slot = new Map<number, number>() // (vertex, chart) → output vertex
349
+ const bary = [ 0, 0, 0 ]
350
+ const centroid = [ 0, 0, 0 ], corner = [ 0, 0, 0 ]
351
+ const uv2 = new Float32Array(2)
352
+ for (let i = 0; i + 2 < idx.length; i += 3) {
353
+ 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
354
+ const ct = base.nearest(centroid, -1, bary)
355
+ const chart = ct >= 0 ? base.chart[ct] : -1
356
+ for (let k = 0; k < 3; k++) {
357
+ const v = idx[i + k]
358
+ const key = v * 65536 + (chart + 1)
359
+ let o = slot.get(key)
360
+ if (o === undefined) {
361
+ corner[0] = wp[v * 3]; corner[1] = wp[v * 3 + 1]; corner[2] = wp[v * 3 + 2]
362
+ let t = base.nearest(corner, chart, bary)
363
+ if (t < 0) t = base.nearest(corner, -1, bary)
364
+ if (t >= 0) base.uvAt(t, bary, uv2, 0); else { uv2[0] = 0; uv2[1] = 0 }
365
+ o = xref.length
366
+ slot.set(key, o)
367
+ xref.push(v); uv.push(uv2[0], uv2[1])
368
+ }
369
+ out.push(o)
370
+ }
371
+ }
372
+ reindexPrimitive(doc, prim, Uint32Array.from(xref), Uint32Array.from(out), Float32Array.from(uv), buffer, retired)
373
+ return xref.length
374
+ }
375
+
376
+ export async function applyLightmapUv(doc: Document, plan: LightmapUvPlan): Promise<LightmapUvResult> {
377
+ const res: LightmapUvResult = { primitives: 0, charts: 0, width: 0, height: 0, verticesBefore: 0, verticesAfter: 0, lodPrimitives: 0, lodVertices: 0 }
378
+ if (!plan.enabled || plan.prims.length === 0) return res
379
+ const m = await getModule()
380
+ const h = m._lmuv_begin(plan.prims.length)
381
+ if (h === 0) throw new Error(`lightmap UV: ${m.UTF8ToString(m._lmuv_error())}`)
382
+ const allocs: number[] = []
383
+ const upload = (arr: Float32Array | Uint32Array): number => {
384
+ const ptr = m._malloc(arr.byteLength)
385
+ allocs.push(ptr)
386
+ if (arr instanceof Float32Array) m.HEAPF32.set(arr, ptr >> 2)
387
+ else m.HEAPU32.set(arr, ptr >> 2)
388
+ return ptr
389
+ }
390
+ const buffer = doc.getRoot().listBuffers()[0] ?? doc.createBuffer()
391
+ const retired = new Set<Accessor>()
392
+ try {
393
+ for (const p of plan.prims) {
394
+ const pos = p.prim.getAttribute('POSITION')!
395
+ const nrm = p.prim.getAttribute('NORMAL')
396
+ const uv0 = p.prim.getAttribute('TEXCOORD_0')
397
+ const idxAcc = p.prim.getIndices()
398
+ const indices = idxAcc ? Uint32Array.from(idxAcc.getArray()!) : Uint32Array.from({ length: pos.getCount() }, (_, i) => i)
399
+ const indexCount = indices.length - (indices.length % 3)
400
+ const posPtr = upload(floats(pos, 3))
401
+ const nrmPtr = nrm && nrm.getCount() === pos.getCount() ? upload(floats(nrm, 3)) : 0
402
+ const uvPtr = uv0 && uv0.getCount() === pos.getCount() ? upload(floats(uv0, 2)) : 0
403
+ const idxPtr = upload(indices)
404
+ const err = m._lmuv_add_mesh(h, posPtr, nrmPtr, uvPtr, pos.getCount(), idxPtr, indexCount)
405
+ if (err !== 0) throw new Error(`lightmap UV: ${p.name}: ${m.UTF8ToString(m._lmuv_error())}`)
406
+ res.verticesBefore += pos.getCount()
407
+ }
408
+ // xatlas copies the mesh data in AddMesh — the inputs can go before the (long) generate.
409
+ for (const ptr of allocs) m._free(ptr)
410
+ allocs.length = 0
411
+ const err = m._lmuv_generate(h, plan.padding, plan.iterations)
412
+ if (err !== 0) throw new Error(`lightmap UV: ${m.UTF8ToString(m._lmuv_error())}`)
413
+ res.width = m._lmuv_width(h)
414
+ res.height = m._lmuv_height(h)
415
+ res.charts = m._lmuv_chart_count(h)
416
+ if (m._lmuv_mesh_count(h) !== plan.prims.length) throw new Error('lightmap UV: xatlas returned a different mesh count')
417
+
418
+ plan.prims.forEach((p, i) => {
419
+ const vcount = m._lmuv_mesh_vertex_count(h, i)
420
+ const icount = m._lmuv_mesh_index_count(h, i)
421
+ const xrefPtr = m._lmuv_mesh_xref(h, i) >> 2
422
+ const xref = m.HEAPU32.slice(xrefPtr, xrefPtr + vcount)
423
+ const uvPtr = m._lmuv_mesh_uv(h, i) >> 2
424
+ const uv1 = m.HEAPF32.slice(uvPtr, uvPtr + vcount * 2)
425
+ const idxPtr = m._lmuv_mesh_indices(h, i) >> 2
426
+ const idx32 = m.HEAPU32.slice(idxPtr, idxPtr + icount)
427
+ reindexPrimitive(doc, p.prim, xref, idx32, uv1, buffer, retired)
428
+ res.verticesAfter += vcount
429
+ res.primitives++
430
+ })
431
+ } finally {
432
+ for (const ptr of allocs) m._free(ptr)
433
+ m._lmuv_end(h)
434
+ }
435
+
436
+ // the LOD siblings, from the level-0 meshes that now carry the layout
437
+ const surfaces = new Map<GltfNode, BaseSurface>()
438
+ for (const lod of plan.lods) {
439
+ let base = surfaces.get(lod.baseNode)
440
+ if (!base) { base = new BaseSurface(lod.baseNode); surfaces.set(lod.baseNode, base) }
441
+ if (base.count === 0) continue // level 0 got no layout (skipped above): the LOD keeps what it had
442
+ res.lodVertices += projectLodPrimitive(doc, lod, base, buffer, retired)
443
+ res.lodPrimitives++
444
+ }
445
+
446
+ // A retired accessor may still back another primitive (shared POSITION with different indices)
447
+ // or a morph target we did not touch — only drop the ones nothing references any more.
448
+ for (const acc of retired) {
449
+ if (acc.listParents().every((parent) => parent.propertyType === 'Root')) acc.dispose()
450
+ }
451
+ return res
452
+ }
@@ -0,0 +1,177 @@
1
+ // Mesh + skeleton LOD levels for creator-gl's LOD pass (docs/lod-plan.md §5).
2
+ //
3
+ // --lod 0.5,0.2 every mesh node with ≥ MIN_TRIS triangles gets sibling nodes `<name>_LOD1`,
4
+ // `<name>_LOD2` … (same parent / transform / skin / materials) whose primitives are
5
+ // meshoptimizer-simplified copies at the given ratios of the ORIGINAL triangle count.
6
+ // Skinned primitives keep JOINTS/WEIGHTS (the simplifier only collapses vertices), so
7
+ // every level rides the same skin. Morph targets are not carried to the levels.
8
+ // --lod-bones the LAST level of a skinned node also gets a reduced SKIN: vertices weighted to
9
+ // finger / toe / twist / leaf joints are re-weighted onto their nearest kept ancestor
10
+ // and the level references a new Skin over the kept joints only — the engine
11
+ // uploads a third of the bones for a far character.
12
+ //
13
+ // The engine groups levels by the `_LOD<n>` name (creator-gl src/lod.cpp) and shows one at a time; a
14
+ // viewer without LOD support draws them all — harmless. Extras carry the bookkeeping for probes.
15
+
16
+ import type { Accessor, Document, Mesh, Node as GltfNode, Primitive, Skin } from '@gltf-transform/core'
17
+ import { simplifyPrimitive, weldPrimitive } from '@gltf-transform/functions'
18
+ import { MeshoptSimplifier } from 'meshoptimizer'
19
+
20
+ export interface LodOptions { lod?: number[]; lodBones?: boolean }
21
+ export interface LodNodePlan { node: GltfNode; name: string; tris: number; skinned: boolean }
22
+ export interface LodPlan { ratios: number[]; bones: boolean; nodes: LodNodePlan[]; existing: number }
23
+ export interface LodResult { levels: number; nodes: number; tris: { name: string; levels: number[] }[]; bones: { name: string; before: number; after: number }[]; skipped: { name: string; level: number; tris: number }[] }
24
+
25
+ /** Meshes under this go without levels — a crate does not need one. */
26
+ export const LOD_MIN_TRIS = 2000
27
+ /** Joints a far character stops animating / weighting — the same vocabulary as creator-gl's joint mask
28
+ * (animation/animator.cpp lodMaskedName) and the retargeter's helper classes. Keep them in step. */
29
+ export const LOD_JOINT_MASK = /finger|thumb|index|middle|ring|pinky|toe|twist|roll|leaf|_end$/i
30
+ const LOD_NAME = /_LOD\d+$/
31
+
32
+ const shortName = (s: string): string => { const p = Math.max(s.lastIndexOf(':'), s.lastIndexOf('|')); return p < 0 ? s : s.slice(p + 1) }
33
+
34
+ const primTris = (p: Primitive): number => {
35
+ if (p.getMode() !== 4) return 0 // TRIANGLES only
36
+ const idx = p.getIndices()
37
+ const n = idx ? idx.getCount() : (p.getAttribute('POSITION')?.getCount() ?? 0)
38
+ return Math.floor(n / 3)
39
+ }
40
+ const meshTris = (m: Mesh): number => m.listPrimitives().reduce((s, p) => s + primTris(p), 0)
41
+
42
+ export function planLod(doc: Document, opts: LodOptions): LodPlan | null {
43
+ const ratios = (opts.lod ?? []).filter((r) => r > 0 && r < 1).sort((a, b) => b - a)
44
+ if (!ratios.length) return null
45
+ const nodes: LodNodePlan[] = []
46
+ let existing = 0
47
+ for (const node of doc.getRoot().listNodes()) {
48
+ const mesh = node.getMesh()
49
+ if (!mesh) continue
50
+ if (LOD_NAME.test(node.getName())) { existing++; continue }
51
+ const tris = meshTris(mesh)
52
+ if (tris < LOD_MIN_TRIS) continue
53
+ nodes.push({ node, name: node.getName() || mesh.getName() || `mesh${nodes.length}`, tris, skinned: !!node.getSkin() })
54
+ }
55
+ return { ratios, bones: !!opts.lodBones, nodes, existing }
56
+ }
57
+
58
+ /** A deep copy of a primitive's geometry (own accessors — simplify rewrites them in place), sharing the material. */
59
+ const clonePrimitive = (doc: Document, prim: Primitive): Primitive => {
60
+ const buffer = doc.getRoot().listBuffers()[0] ?? doc.createBuffer()
61
+ const cloneAcc = (a: Accessor): Accessor => doc.createAccessor(a.getName(), buffer).setType(a.getType()).setNormalized(a.getNormalized()).setArray(a.getArray()!.slice())
62
+ const out = doc.createPrimitive().setMode(prim.getMode()).setMaterial(prim.getMaterial())
63
+ for (const sem of prim.listSemantics()) out.setAttribute(sem, cloneAcc(prim.getAttribute(sem)!))
64
+ const idx = prim.getIndices()
65
+ if (idx) out.setIndices(cloneAcc(idx))
66
+ return out
67
+ }
68
+
69
+ /** The last level of a skinned node: masked joints fold onto their nearest kept ancestor, on a new Skin over the kept joints. */
70
+ const reduceSkinForLod = (doc: Document, lodNode: GltfNode): { before: number; after: number } | null => {
71
+ const skin = lodNode.getSkin()
72
+ const mesh = lodNode.getMesh()
73
+ if (!skin || !mesh) return null
74
+ const joints = skin.listJoints()
75
+ const removed = new Set<number>()
76
+ joints.forEach((j, i) => { if (LOD_JOINT_MASK.test(shortName(j.getName()))) removed.add(i) })
77
+ if (!removed.size) return null
78
+ const idxOf = new Map<GltfNode, number>(joints.map((j, i) => [j, i]))
79
+ const remap = joints.map((j, i) => {
80
+ if (!removed.has(i)) return i
81
+ for (let p = j.getParentNode(); p; p = p.getParentNode()) { const k = idxOf.get(p); if (k !== undefined && !removed.has(k)) return k }
82
+ return i // no kept ancestor: keep the joint
83
+ })
84
+ const kept = [...new Set(remap)].sort((a, b) => a - b)
85
+ const newIndex = new Map<number, number>(kept.map((old, k) => [old, k]))
86
+ const buffer = doc.getRoot().listBuffers()[0] ?? doc.createBuffer()
87
+ const newSkin: Skin = doc.createSkin(`${skin.getName() || 'skin'}_LOD`)
88
+ if (skin.getSkeleton()) newSkin.setSkeleton(skin.getSkeleton())
89
+ for (const i of kept) newSkin.addJoint(joints[i]!)
90
+ const ibm = skin.getInverseBindMatrices()
91
+ if (ibm) {
92
+ const src = ibm.getArray()!
93
+ const out = new Float32Array(kept.length * 16)
94
+ kept.forEach((i, k) => out.set(src.subarray(i * 16, i * 16 + 16), k * 16))
95
+ newSkin.setInverseBindMatrices(doc.createAccessor(`${ibm.getName() || 'ibm'}_LOD`, buffer).setType('MAT4').setArray(out))
96
+ }
97
+ const jv: number[] = [], wv: number[] = []
98
+ for (const prim of mesh.listPrimitives()) {
99
+ // every set is gathered per vertex, folded by target joint, then written back set by set (largest first)
100
+ const sets: { j: Accessor; w: Accessor }[] = []
101
+ for (let s = 0; ; s++) { const j = prim.getAttribute(`JOINTS_${s}`), w = prim.getAttribute(`WEIGHTS_${s}`); if (!j || !w) break; sets.push({ j, w }) }
102
+ if (!sets.length) continue
103
+ const count = sets[0]!.j.getCount()
104
+ const folded = new Map<number, number>()
105
+ for (let v = 0; v < count; v++) {
106
+ folded.clear()
107
+ for (const { j, w } of sets) {
108
+ j.getElement(v, jv); w.getElement(v, wv)
109
+ for (let k = 0; k < 4; k++) {
110
+ const weight = wv[k] ?? 0
111
+ if (weight <= 0) continue
112
+ const target = newIndex.get(remap[jv[k]!] ?? jv[k]!) ?? 0
113
+ folded.set(target, (folded.get(target) ?? 0) + weight)
114
+ }
115
+ }
116
+ const pairs = [...folded].sort((a, b) => b[1] - a[1])
117
+ let n = 0
118
+ for (const { j, w } of sets) {
119
+ for (let k = 0; k < 4; k++) { const p = pairs[n++]; jv[k] = p ? p[0] : 0; wv[k] = p ? p[1] : 0 }
120
+ j.setElement(v, jv); w.setElement(v, wv)
121
+ }
122
+ }
123
+ }
124
+ lodNode.setSkin(newSkin)
125
+ return { before: joints.length, after: kept.length }
126
+ }
127
+
128
+ export async function applyLod(doc: Document, plan: LodPlan): Promise<LodResult> {
129
+ await MeshoptSimplifier.ready
130
+ const result: LodResult = { levels: plan.ratios.length, nodes: 0, tris: [], bones: [], skipped: [] }
131
+ for (const entry of plan.nodes) {
132
+ const { node } = entry
133
+ const baseMesh = node.getMesh()!
134
+ const parent = node.getParentNode()
135
+ const scenes = parent ? [] : doc.getRoot().listScenes().filter((s) => s.listChildren().includes(node))
136
+ const baseExtras = (node.getExtras() ?? {}) as Record<string, unknown>
137
+ const baseLecodes = (baseExtras.lecodes ?? {}) as Record<string, unknown>
138
+ node.setExtras({ ...baseExtras, lecodes: { ...baseLecodes, lod: { level: 0, levels: plan.ratios.length + 1, tris: entry.tris } } })
139
+ const levels: number[] = [entry.tris]
140
+ for (let i = 0; i < plan.ratios.length; i++) {
141
+ const level = i + 1
142
+ const ratio = plan.ratios[i]!
143
+ const mesh = doc.createMesh(`${baseMesh.getName() || entry.name}_LOD${level}`)
144
+ let tris = 0
145
+ for (const prim of baseMesh.listPrimitives()) {
146
+ if (prim.getMode() !== 4) continue
147
+ let copy = clonePrimitive(doc, prim)
148
+ weldPrimitive(copy)
149
+ copy = simplifyPrimitive(copy, { simplifier: MeshoptSimplifier, ratio, error: 0.02, lockBorder: false })
150
+ tris += primTris(copy)
151
+ mesh.addPrimitive(copy)
152
+ }
153
+ // A level the simplifier could not shrink (hard-surface meshes with many seams stop early) is not
154
+ // worth its bytes or its draw: drop it rather than ship a near-duplicate.
155
+ if (tris > levels[levels.length - 1]! * 0.85) {
156
+ for (const p of mesh.listPrimitives()) { for (const sem of p.listSemantics()) p.getAttribute(sem)?.dispose(); p.getIndices()?.dispose(); p.dispose() }
157
+ mesh.dispose()
158
+ result.skipped.push({ name: entry.name, level, tris })
159
+ break
160
+ }
161
+ const lodNode = doc.createNode(`${node.getName() || entry.name}_LOD${level}`).setMesh(mesh)
162
+ .setTranslation(node.getTranslation()).setRotation(node.getRotation()).setScale(node.getScale())
163
+ if (node.getSkin()) lodNode.setSkin(node.getSkin())
164
+ lodNode.setExtras({ lecodes: { lod: { level, ratio, tris } } })
165
+ if (parent) parent.addChild(lodNode)
166
+ else for (const s of scenes) s.addChild(lodNode)
167
+ levels.push(tris)
168
+ if (plan.bones && i === plan.ratios.length - 1 && node.getSkin()) {
169
+ const r = reduceSkinForLod(doc, lodNode)
170
+ if (r) result.bones.push({ name: entry.name, ...r })
171
+ }
172
+ }
173
+ result.nodes++
174
+ result.tris.push({ name: entry.name, levels })
175
+ }
176
+ return result
177
+ }