lecodes-assets 0.1.2 → 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.
- package/README.md +240 -5
- package/dist/cli.js +7757 -683
- package/dist/index.js +7866 -675
- package/package.json +13 -3
- package/src/bvh/fromDocument.ts +136 -0
- package/src/bvh/toDocument.ts +202 -0
- package/src/cli.ts +1039 -0
- package/src/doctor/doctor.ts +241 -0
- package/src/doctor/lightmapUv.ts +452 -0
- package/src/doctor/lod.ts +177 -0
- package/src/doctor/normalizeScale.ts +220 -0
- package/src/doctor/reduceGlbBones.ts +218 -0
- package/src/doctor/shading.ts +95 -0
- package/src/doctor/transcodeKtx2.ts +124 -0
- package/src/doctor/transcodeTextures.ts +101 -0
- package/src/doctor/validateGlb.ts +264 -0
- package/src/fbx/convert.ts +327 -0
- package/src/fbx/read.ts +109 -0
- package/src/fbx/toDocument.ts +209 -0
- package/src/filmstrip.ts +263 -0
- package/src/index.ts +17 -0
- package/src/mergeSkins.ts +117 -0
- package/src/probe.ts +167 -0
- package/src/props.ts +137 -0
- package/src/retarget/automap.ts +276 -0
- package/src/retarget/ghost.ts +23 -0
- package/src/retarget/heading.ts +171 -0
- package/src/retarget/humanoid.ts +561 -0
- package/src/retarget/inPlace.ts +130 -0
- package/src/retarget/marks.ts +581 -0
- package/src/retarget/mirror.ts +0 -0
- package/src/retarget/retarget.ts +2272 -0
- package/src/retarget/rig.ts +542 -0
- package/src/retarget/unityAnim.ts +171 -0
- package/src/retarget/writeMarks.ts +132 -0
- package/src/sky/analyze.ts +136 -0
- package/src/sky/cmgen.ts +71 -0
- package/src/sky/index.ts +151 -0
- package/src/terrainPack.ts +169 -0
- package/src/textures/resolveImage.ts +202 -0
- package/src/unity/materials.ts +593 -0
- package/src/unity/png.ts +75 -0
- package/src/unity/prefabs.ts +80 -0
- package/src/unity/scene.ts +363 -0
- package/src/unity/unpack.ts +130 -0
- package/src/unreal/anim.ts +284 -0
- package/src/unreal/clipSource.ts +25 -0
- package/src/unreal/convert.ts +67 -0
- package/src/unreal/convertAsset.ts +81 -0
- package/src/unreal/index.ts +114 -0
- package/src/unreal/list.ts +113 -0
- package/src/unreal/materials.ts +291 -0
- package/src/unreal/mesh.ts +205 -0
- package/src/unreal/oodle.ts +153 -0
- package/src/unreal/package.ts +659 -0
- package/src/unreal/probe.ts +144 -0
- package/src/unreal/resolve.ts +51 -0
- package/src/unreal/rig.ts +149 -0
- package/src/unreal/sequencer.ts +193 -0
- package/src/unreal/skeleton.ts +74 -0
- package/src/unreal/texture.ts +202 -0
- package/src/unreal/toDocument.ts +360 -0
- package/src/unreal/versions.ts +100 -0
- package/src/wasm/fbxReader.js +14 -0
- 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
|
+
}
|