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.
- package/README.md +561 -449
- package/dist/cli.js +5632 -521
- package/dist/index.js +5647 -526
- 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,220 @@
|
|
|
1
|
+
// Scale normalization — bring a model to real-world metres before it reaches creator-gl.
|
|
2
|
+
//
|
|
3
|
+
// Exporters routinely ship a model at 1/100 (the Blender/FBX "armature scale 0.01" quirk), in cm, or
|
|
4
|
+
// in inches. Nothing in the file records that, and the SDK carries no unit metadata — the asset just
|
|
5
|
+
// turns up 5 mm across in a scene and every consumer needs its own magic multiplier.
|
|
6
|
+
//
|
|
7
|
+
// measure : the RENDERED world size. Skinned primitives do not use their node's matrix (the glTF
|
|
8
|
+
// spec says a skinned mesh node's transform is ignored) — they land at world(joint)×IBM,
|
|
9
|
+
// so that is what we measure them with.
|
|
10
|
+
// plan : an explicit factor, a target largest-dimension, or an auto unit guess.
|
|
11
|
+
// apply : BAKES the factor into the data — positions, node translations, IBM offsets and
|
|
12
|
+
// translation tracks all × s. Local scales are left alone, so no wrapper node appears
|
|
13
|
+
// and every node path stays what it was.
|
|
14
|
+
//
|
|
15
|
+
// Why the bake is those four edits and nothing else: scaling every translation by s and leaving
|
|
16
|
+
// every rotation/scale block alone gives world' = S·world·S⁻¹ for every node (S = uniform s). For a
|
|
17
|
+
// skinned vertex, Σw·world'·IBM'·(s·v) == s·(Σw·world·IBM·v) holds exactly when IBM' = S·IBM·S⁻¹,
|
|
18
|
+
// and S·IBM·S⁻¹ is just IBM with its translation column × s.
|
|
19
|
+
import type { Accessor, Document } from '@gltf-transform/core'
|
|
20
|
+
|
|
21
|
+
const FLOAT = 5126
|
|
22
|
+
|
|
23
|
+
/** A model whose largest dimension lands outside this (metres) is almost certainly mis-scaled. */
|
|
24
|
+
export const PLAUSIBLE_MIN = 0.1
|
|
25
|
+
export const PLAUSIBLE_MAX = 100
|
|
26
|
+
|
|
27
|
+
// Multiply-by factors we are willing to guess, with what they mean. Ordered by |ln f| so the first
|
|
28
|
+
// in-band hit is the SMALLEST correction that explains the model.
|
|
29
|
+
const CANDIDATES: { f: number; label: string }[] = [
|
|
30
|
+
{ f: 100, label: 'cm units / the 0.01 armature-scale export quirk' },
|
|
31
|
+
{ f: 0.01, label: '100× too big (metre values authored as cm)' },
|
|
32
|
+
{ f: 0.3048, label: 'feet' },
|
|
33
|
+
{ f: 0.0254, label: 'inches' },
|
|
34
|
+
{ f: 1000, label: 'mm units' },
|
|
35
|
+
{ f: 0.001, label: '1000× too big' },
|
|
36
|
+
].sort((a, b) => Math.abs(Math.log(a.f)) - Math.abs(Math.log(b.f)))
|
|
37
|
+
|
|
38
|
+
type Vec3 = [number, number, number]
|
|
39
|
+
export interface ScaleInfo {
|
|
40
|
+
dims: Vec3 // rendered world size, metres
|
|
41
|
+
largest: number
|
|
42
|
+
suggested: number // auto factor (1 = looks fine, or no candidate explains it)
|
|
43
|
+
label: string
|
|
44
|
+
suspicious: boolean
|
|
45
|
+
}
|
|
46
|
+
export interface ScalePlan { factor: number; before: Vec3; after: Vec3; source: 'auto' | 'explicit' | 'fit'; label: string }
|
|
47
|
+
|
|
48
|
+
const mul = (a: number[], b: number[]): number[] => {
|
|
49
|
+
const o = new Array(16).fill(0)
|
|
50
|
+
for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) o[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3]
|
|
51
|
+
return o
|
|
52
|
+
}
|
|
53
|
+
const applyMat = (m: number[], v: number[]): Vec3 => [
|
|
54
|
+
m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12],
|
|
55
|
+
m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13],
|
|
56
|
+
m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14],
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
/** Rendered world-space bounds of every mesh in the document, or null when there is no geometry. */
|
|
60
|
+
export function sceneBounds(doc: Document): { min: Vec3; max: Vec3; dims: Vec3 } | null {
|
|
61
|
+
const cache = new Map<object, number[]>()
|
|
62
|
+
const world = (n: any): number[] => {
|
|
63
|
+
const hit = cache.get(n)
|
|
64
|
+
if (hit) return hit
|
|
65
|
+
const parent = n.getParentNode()
|
|
66
|
+
const local = Array.from(n.getMatrix() as ArrayLike<number>)
|
|
67
|
+
const w = parent ? mul(world(parent), local) : local
|
|
68
|
+
cache.set(n, w)
|
|
69
|
+
return w
|
|
70
|
+
}
|
|
71
|
+
const min: Vec3 = [ Infinity, Infinity, Infinity ], max: Vec3 = [ -Infinity, -Infinity, -Infinity ]
|
|
72
|
+
let any = false
|
|
73
|
+
for (const node of doc.getRoot().listNodes()) {
|
|
74
|
+
const mesh = node.getMesh()
|
|
75
|
+
if (!mesh) continue
|
|
76
|
+
// A skinned primitive ignores its node's transform: at rest it lands at world(joint₀)·IBM₀, the
|
|
77
|
+
// skin's bind space (constant across joints for a sane rig — probe reports the deviation).
|
|
78
|
+
let m = world(node)
|
|
79
|
+
const skin = node.getSkin()
|
|
80
|
+
if (skin) {
|
|
81
|
+
const joints = skin.listJoints()
|
|
82
|
+
const ibm = skin.getInverseBindMatrices()
|
|
83
|
+
if (joints.length && ibm) m = mul(world(joints[0]), ibm.getElement(0, []))
|
|
84
|
+
else if (joints.length) m = world(joints[0])
|
|
85
|
+
}
|
|
86
|
+
for (const prim of mesh.listPrimitives()) {
|
|
87
|
+
const pos = prim.getAttribute('POSITION')
|
|
88
|
+
if (!pos) continue
|
|
89
|
+
const lo = pos.getMin([]), hi = pos.getMax([])
|
|
90
|
+
for (let corner = 0; corner < 8; corner++) {
|
|
91
|
+
const p = applyMat(m, [ corner & 1 ? hi[0] : lo[0], corner & 2 ? hi[1] : lo[1], corner & 4 ? hi[2] : lo[2] ])
|
|
92
|
+
for (let i = 0; i < 3; i++) { min[i] = Math.min(min[i], p[i]); max[i] = Math.max(max[i], p[i]) }
|
|
93
|
+
}
|
|
94
|
+
any = true
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!any) return null
|
|
98
|
+
return { min, max, dims: [ max[0] - min[0], max[1] - min[1], max[2] - min[2] ] }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Measure the document and guess whether (and by how much) it is mis-scaled. */
|
|
102
|
+
export function measureScale(doc: Document): ScaleInfo | null {
|
|
103
|
+
const b = sceneBounds(doc)
|
|
104
|
+
if (!b) return null
|
|
105
|
+
const largest = Math.max(...b.dims)
|
|
106
|
+
if (!(largest > 0)) return null
|
|
107
|
+
const inBand = (x: number): boolean => x >= PLAUSIBLE_MIN && x <= PLAUSIBLE_MAX
|
|
108
|
+
if (inBand(largest)) return { dims: b.dims, largest, suggested: 1, label: 'plausible', suspicious: false }
|
|
109
|
+
const hit = CANDIDATES.find((c) => inBand(largest * c.f))
|
|
110
|
+
return {
|
|
111
|
+
dims: b.dims,
|
|
112
|
+
largest,
|
|
113
|
+
suggested: hit?.f ?? 1,
|
|
114
|
+
label: hit?.label ?? 'no standard unit factor explains this size — pass --scale-to',
|
|
115
|
+
suspicious: true,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface ScaleOptions { scale?: number; scaleTo?: number; fixScale?: boolean }
|
|
120
|
+
|
|
121
|
+
/** Turn options + measurement into a concrete plan, or null when there is nothing to do. */
|
|
122
|
+
export function planScale(info: ScaleInfo | null, opts: ScaleOptions): ScalePlan | null {
|
|
123
|
+
let factor = 1
|
|
124
|
+
let source: ScalePlan['source'] = 'auto'
|
|
125
|
+
let label = info?.label ?? ''
|
|
126
|
+
if (opts.scale !== undefined) {
|
|
127
|
+
// An explicit factor needs no measurement — it still applies to a clip-only file with no geometry.
|
|
128
|
+
factor = opts.scale; source = 'explicit'; label = 'explicit factor'
|
|
129
|
+
} else if (opts.scaleTo !== undefined) {
|
|
130
|
+
if (!info) throw new Error('--scale-to needs geometry to measure; this file has no meshes — use --scale <factor>')
|
|
131
|
+
factor = opts.scaleTo / info.largest; source = 'fit'; label = `largest dimension → ${opts.scaleTo} m`
|
|
132
|
+
} else if (opts.fixScale) {
|
|
133
|
+
if (!info) return null
|
|
134
|
+
factor = info.suggested
|
|
135
|
+
}
|
|
136
|
+
if (!(factor > 0) || !Number.isFinite(factor)) throw new Error(`bad scale factor ${factor}`)
|
|
137
|
+
if (Math.abs(factor - 1) < 1e-9) return null
|
|
138
|
+
const before: Vec3 = info?.dims ?? [ 0, 0, 0 ]
|
|
139
|
+
return { factor, before, after: before.map((d) => d * factor) as Vec3, source, label }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const scaleAccessor = (acc: Accessor, s: number, what: string): void => {
|
|
143
|
+
if (acc.getComponentType() !== FLOAT) throw new Error(`cannot scale quantized/normalized ${what} (component type ${acc.getComponentType()}) — dequantize first`)
|
|
144
|
+
const src = acc.getArray()!
|
|
145
|
+
const out = new Float32Array(src.length)
|
|
146
|
+
for (let i = 0; i < src.length; i++) out[i] = src[i] * s
|
|
147
|
+
acc.setArray(out)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface ScaleResult { positions: number; nodes: number; skins: number; tracks: number; cameras: number; lights: number }
|
|
151
|
+
|
|
152
|
+
/** Bake a uniform scale into the document (mutates). Adds no nodes and changes no local scales. */
|
|
153
|
+
export function applyScale(doc: Document, s: number): ScaleResult {
|
|
154
|
+
const root = doc.getRoot()
|
|
155
|
+
const res: ScaleResult = { positions: 0, nodes: 0, skins: 0, tracks: 0, cameras: 0, lights: 0 }
|
|
156
|
+
|
|
157
|
+
const seen = new Set<Accessor>()
|
|
158
|
+
const once = (acc: Accessor | null, what: string): void => {
|
|
159
|
+
if (!acc || seen.has(acc)) return
|
|
160
|
+
seen.add(acc)
|
|
161
|
+
scaleAccessor(acc, s, what)
|
|
162
|
+
res.positions++
|
|
163
|
+
}
|
|
164
|
+
for (const mesh of root.listMeshes()) for (const prim of mesh.listPrimitives()) {
|
|
165
|
+
once(prim.getAttribute('POSITION'), 'POSITION')
|
|
166
|
+
for (const target of prim.listTargets()) once(target.getAttribute('POSITION'), 'morph POSITION')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const node of root.listNodes()) {
|
|
170
|
+
const t = node.getTranslation()
|
|
171
|
+
if (t[0] || t[1] || t[2]) { node.setTranslation([ t[0] * s, t[1] * s, t[2] * s ]); res.nodes++ }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// IBM' = S·IBM·S⁻¹ — the 3×3 block is untouched, only the offset moves.
|
|
175
|
+
for (const skin of root.listSkins()) {
|
|
176
|
+
const ibm = skin.getInverseBindMatrices()
|
|
177
|
+
if (!ibm) continue
|
|
178
|
+
const m: number[] = []
|
|
179
|
+
for (let i = 0; i < ibm.getCount(); i++) {
|
|
180
|
+
ibm.getElement(i, m)
|
|
181
|
+
m[12] *= s; m[13] *= s; m[14] *= s
|
|
182
|
+
ibm.setElement(i, m)
|
|
183
|
+
}
|
|
184
|
+
res.skins++
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Translation tracks only — rotation and scale tracks live in the untouched 3×3 block.
|
|
188
|
+
for (const anim of root.listAnimations()) {
|
|
189
|
+
const moves = new Set<Accessor>(), keeps = new Set<Accessor>()
|
|
190
|
+
for (const ch of anim.listChannels()) {
|
|
191
|
+
const out = ch.getSampler()?.getOutput()
|
|
192
|
+
if (out) (ch.getTargetPath() === 'translation' ? moves : keeps).add(out)
|
|
193
|
+
}
|
|
194
|
+
for (const acc of moves) {
|
|
195
|
+
if (keeps.has(acc)) throw new Error(`animation "${anim.getName()}" shares one output accessor between translation and other channels — cannot scale safely`)
|
|
196
|
+
if (seen.has(acc)) continue
|
|
197
|
+
seen.add(acc)
|
|
198
|
+
scaleAccessor(acc, s, 'translation track')
|
|
199
|
+
res.tracks++
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
for (const cam of root.listCameras()) {
|
|
204
|
+
cam.setZNear(cam.getZNear() * s)
|
|
205
|
+
const zf = cam.getZFar()
|
|
206
|
+
if (zf) cam.setZFar(zf * s)
|
|
207
|
+
if (cam.getType() === 'orthographic') { cam.setXMag(cam.getXMag() * s); cam.setYMag(cam.getYMag() * s) }
|
|
208
|
+
res.cameras++
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// KHR_lights_punctual: range is a distance; intensity is candela/lux and does not scale with size.
|
|
212
|
+
for (const node of root.listNodes()) {
|
|
213
|
+
const light = node.getExtension('KHR_lights_punctual') as { getRange?: () => number | null; setRange?: (r: number) => unknown } | null
|
|
214
|
+
if (!light?.getRange || !light.setRange) continue
|
|
215
|
+
const range = light.getRange()
|
|
216
|
+
if (range) light.setRange(range * s)
|
|
217
|
+
res.lights++
|
|
218
|
+
}
|
|
219
|
+
return res
|
|
220
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// Skin bone-count reduction for filament's CONFIG_MAX_BONE_COUNT (256), without Blender.
|
|
2
|
+
// Split into PLAN (pure analysis, no mutation) and APPLY (mutation) so the same logic powers
|
|
3
|
+
// both the analyzer and the fixer in glbDoctor.ts.
|
|
4
|
+
//
|
|
5
|
+
// Pass 1 — drop joints not bound to any vertex (leftover IK / control bones) -> LOSSLESS
|
|
6
|
+
// Pass 2 — drop the deepest remaining joints, merging their weights into the
|
|
7
|
+
// nearest surviving ancestor joint, until each skin has <= target -> LOSSY
|
|
8
|
+
import { NodeIO, type Document, type Skin, type Node as GltfNode, type Primitive, type Accessor } from '@gltf-transform/core'
|
|
9
|
+
import { ALL_EXTENSIONS } from '@gltf-transform/extensions'
|
|
10
|
+
import { MeshoptDecoder, MeshoptEncoder } from 'meshoptimizer'
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_TARGET = 256
|
|
13
|
+
const WEIGHT_EPS = 1e-5
|
|
14
|
+
|
|
15
|
+
export interface PrimSet { j: Accessor; w: Accessor }
|
|
16
|
+
export interface PrimEntry { prim: Primitive; sets: PrimSet[] }
|
|
17
|
+
|
|
18
|
+
export interface SkinPlan {
|
|
19
|
+
skin: Skin
|
|
20
|
+
skinIdx: number
|
|
21
|
+
name: string
|
|
22
|
+
before: number
|
|
23
|
+
after: number
|
|
24
|
+
removedUnbound: number
|
|
25
|
+
removedDeep: number
|
|
26
|
+
prims: PrimEntry[]
|
|
27
|
+
// internals reused by applySkinPlan:
|
|
28
|
+
joints: GltfNode[]
|
|
29
|
+
removeSet: Set<number>
|
|
30
|
+
kept: number[]
|
|
31
|
+
newPos: Map<number, number>
|
|
32
|
+
mergeTarget: Int32Array
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function makeIO(): NodeIO {
|
|
36
|
+
return new NodeIO()
|
|
37
|
+
.registerExtensions(ALL_EXTENSIONS)
|
|
38
|
+
.registerDependencies({ 'meshopt.decoder': MeshoptDecoder, 'meshopt.encoder': MeshoptEncoder })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Meshopt codecs are WASM — must be ready before any read/write. */
|
|
42
|
+
export async function ensureCodecs(): Promise<void> {
|
|
43
|
+
await MeshoptDecoder.ready
|
|
44
|
+
await MeshoptEncoder.ready
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Group every skinned primitive under the single skin that uses it. */
|
|
48
|
+
export function gatherSkinPrims(doc: Document): { bySkin: Map<Skin, PrimEntry[]>; conflicts: number } {
|
|
49
|
+
const owner = new Map<Primitive, Skin>()
|
|
50
|
+
const bySkin = new Map<Skin, PrimEntry[]>()
|
|
51
|
+
let conflicts = 0
|
|
52
|
+
for (const node of doc.getRoot().listNodes()) {
|
|
53
|
+
const skin = node.getSkin(), mesh = node.getMesh()
|
|
54
|
+
if (!skin || !mesh) continue
|
|
55
|
+
for (const prim of mesh.listPrimitives()) {
|
|
56
|
+
if (!prim.getAttribute('JOINTS_0')) continue
|
|
57
|
+
const prev = owner.get(prim)
|
|
58
|
+
if (prev) { if (prev !== skin) conflicts++; continue }
|
|
59
|
+
owner.set(prim, skin)
|
|
60
|
+
const sets: PrimSet[] = []
|
|
61
|
+
for (let s = 0; ; s++) {
|
|
62
|
+
const j = prim.getAttribute(`JOINTS_${s}`), w = prim.getAttribute(`WEIGHTS_${s}`)
|
|
63
|
+
if (!j || !w) break
|
|
64
|
+
sets.push({ j, w })
|
|
65
|
+
}
|
|
66
|
+
;(bySkin.get(skin) ?? bySkin.set(skin, []).get(skin)!).push({ prim, sets })
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { bySkin, conflicts }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function depthOf(node: GltfNode): number {
|
|
73
|
+
let d = 0, p = node.getParentNode()
|
|
74
|
+
while (p) { d++; p = p.getParentNode() }
|
|
75
|
+
return d
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Pure: decide which joints to drop. No mutation. */
|
|
79
|
+
export function planSkin(skin: Skin, skinIdx: number, prims: PrimEntry[], target: number): SkinPlan {
|
|
80
|
+
const joints = skin.listJoints()
|
|
81
|
+
const N = joints.length
|
|
82
|
+
const name = skin.getName() || `skin[${skinIdx}]`
|
|
83
|
+
const posOf = new Map<GltfNode, number>(joints.map((n, i) => [n, i]))
|
|
84
|
+
const removeSet = new Set<number>()
|
|
85
|
+
|
|
86
|
+
const empty: SkinPlan = {
|
|
87
|
+
skin, skinIdx, name, before: N, after: N, removedUnbound: 0, removedDeep: 0,
|
|
88
|
+
prims, joints, removeSet, kept: [...Array(N).keys()], newPos: new Map(), mergeTarget: new Int32Array(N).fill(-1),
|
|
89
|
+
}
|
|
90
|
+
if (N <= target || prims.length === 0) return empty
|
|
91
|
+
|
|
92
|
+
// total weight per joint position across all primitives of this skin
|
|
93
|
+
const total = new Float64Array(N)
|
|
94
|
+
const je: number[] = [0, 0, 0, 0], we: number[] = [0, 0, 0, 0]
|
|
95
|
+
for (const { sets } of prims) {
|
|
96
|
+
for (const { j, w } of sets) {
|
|
97
|
+
const c = j.getCount()
|
|
98
|
+
for (let v = 0; v < c; v++) {
|
|
99
|
+
j.getElement(v, je); w.getElement(v, we)
|
|
100
|
+
for (let k = 0; k < 4; k++) if (we[k] > 0) total[je[k]] += we[k]
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Pass 1 — unbound
|
|
106
|
+
for (let i = 0; i < N; i++) if (total[i] <= WEIGHT_EPS) removeSet.add(i)
|
|
107
|
+
const removedUnbound = removeSet.size
|
|
108
|
+
|
|
109
|
+
// Pass 2 — deepest-first, only if still over budget
|
|
110
|
+
const depth = joints.map(depthOf)
|
|
111
|
+
const nearestKept = (i: number): number => {
|
|
112
|
+
let p = joints[i].getParentNode()
|
|
113
|
+
while (p) { const pos = posOf.get(p); if (pos !== undefined && !removeSet.has(pos)) return pos; p = p.getParentNode() }
|
|
114
|
+
return -1
|
|
115
|
+
}
|
|
116
|
+
let remaining = N - removeSet.size, removedDeep = 0
|
|
117
|
+
if (remaining > target) {
|
|
118
|
+
const order = [...Array(N).keys()].filter((i) => !removeSet.has(i))
|
|
119
|
+
.sort((a, b) => depth[b] - depth[a] || joints[b].getName().localeCompare(joints[a].getName()))
|
|
120
|
+
for (const i of order) {
|
|
121
|
+
if (remaining <= target) break
|
|
122
|
+
if (nearestKept(i) === -1) continue
|
|
123
|
+
removeSet.add(i); remaining--; removedDeep++
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const kept = [...Array(N).keys()].filter((i) => !removeSet.has(i))
|
|
128
|
+
const newPos = new Map<number, number>(kept.map((old, ni) => [old, ni]))
|
|
129
|
+
const mergeTarget = new Int32Array(N).fill(-1)
|
|
130
|
+
for (const i of removeSet) {
|
|
131
|
+
let p = joints[i].getParentNode(), t = -1
|
|
132
|
+
while (p) { const pos = posOf.get(p); if (pos !== undefined && !removeSet.has(pos)) { t = pos; break } p = p.getParentNode() }
|
|
133
|
+
mergeTarget[i] = t
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { skin, skinIdx, name, before: N, after: kept.length, removedUnbound, removedDeep, prims, joints, removeSet, kept, newPos, mergeTarget }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Mutating: rewrite skinning attributes + skin joints/IBM per the plan. Returns dropped weight. */
|
|
140
|
+
export function applySkinPlan(doc: Document, plan: SkinPlan): number {
|
|
141
|
+
const { skin, joints, removeSet, kept, newPos, mergeTarget, prims } = plan
|
|
142
|
+
if (removeSet.size === 0) return 0
|
|
143
|
+
|
|
144
|
+
const finalPos = (old: number): number => {
|
|
145
|
+
const np = newPos.get(old)
|
|
146
|
+
if (np !== undefined) return np
|
|
147
|
+
const t = mergeTarget[old]
|
|
148
|
+
return t < 0 ? -1 : newPos.get(t)!
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let droppedWeight = 0
|
|
152
|
+
for (const { prim, sets } of prims) {
|
|
153
|
+
const c = sets[0].j.getCount()
|
|
154
|
+
const numSets = sets.length
|
|
155
|
+
const cap = 4 * numSets
|
|
156
|
+
const outJ = sets.map(() => new Array(c * 4).fill(0))
|
|
157
|
+
const outW = sets.map(() => new Array(c * 4).fill(0))
|
|
158
|
+
let maxInfluences = 0
|
|
159
|
+
const jv: number[] = [0, 0, 0, 0], wv: number[] = [0, 0, 0, 0]
|
|
160
|
+
|
|
161
|
+
for (let v = 0; v < c; v++) {
|
|
162
|
+
const acc = new Map<number, number>()
|
|
163
|
+
for (const { j, w } of sets) {
|
|
164
|
+
j.getElement(v, jv); w.getElement(v, wv)
|
|
165
|
+
for (let k = 0; k < 4; k++) {
|
|
166
|
+
const ww = wv[k]
|
|
167
|
+
if (ww <= 0) continue
|
|
168
|
+
const fp = finalPos(jv[k])
|
|
169
|
+
if (fp < 0) { droppedWeight += ww; continue }
|
|
170
|
+
acc.set(fp, (acc.get(fp) || 0) + ww)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
let pairs = [...acc.entries()].sort((a, b) => b[1] - a[1])
|
|
174
|
+
if (pairs.length > cap) pairs = pairs.slice(0, cap)
|
|
175
|
+
maxInfluences = Math.max(maxInfluences, pairs.length)
|
|
176
|
+
let sum = 0; for (const p of pairs) sum += p[1]; if (sum <= 0) sum = 1
|
|
177
|
+
for (let s = 0; s < numSets; s++)
|
|
178
|
+
for (let k = 0; k < 4; k++) {
|
|
179
|
+
const idx = s * 4 + k
|
|
180
|
+
if (idx < pairs.length) { outJ[s][v * 4 + k] = pairs[idx][0]; outW[s][v * 4 + k] = pairs[idx][1] / sum }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (let s = 0; s < numSets; s++) {
|
|
185
|
+
const Ja = sets[s].j, Wa = sets[s].w
|
|
186
|
+
for (let v = 0; v < c; v++) {
|
|
187
|
+
Ja.setElement(v, [outJ[s][v * 4], outJ[s][v * 4 + 1], outJ[s][v * 4 + 2], outJ[s][v * 4 + 3]])
|
|
188
|
+
Wa.setElement(v, [outW[s][v * 4], outW[s][v * 4 + 1], outW[s][v * 4 + 2], outW[s][v * 4 + 3]])
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const neededSets = Math.max(1, Math.ceil(maxInfluences / 4))
|
|
192
|
+
for (let s = neededSets; s < numSets; s++) { prim.setAttribute(`JOINTS_${s}`, null); prim.setAttribute(`WEIGHTS_${s}`, null) }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const oldIbm = skin.getInverseBindMatrices()
|
|
196
|
+
if (oldIbm) {
|
|
197
|
+
const m: number[] = new Array(16).fill(0)
|
|
198
|
+
const arr = new Float32Array(kept.length * 16)
|
|
199
|
+
for (let ni = 0; ni < kept.length; ni++) { oldIbm.getElement(kept[ni], m); arr.set(m, ni * 16) }
|
|
200
|
+
const newIbm = doc.createAccessor(`${plan.name}_ibm`).setType('MAT4').setArray(arr).setBuffer(doc.getRoot().listBuffers()[0])
|
|
201
|
+
skin.setInverseBindMatrices(newIbm)
|
|
202
|
+
if (!doc.getRoot().listSkins().some((s) => s.getInverseBindMatrices() === oldIbm)) oldIbm.dispose()
|
|
203
|
+
}
|
|
204
|
+
for (const i of removeSet) skin.removeJoint(joints[i])
|
|
205
|
+
return droppedWeight
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Convenience one-shot (bones only), kept for standalone use.
|
|
209
|
+
export async function reduceGlbBones(inputPath: string, outputPath: string, target = DEFAULT_TARGET): Promise<SkinPlan[]> {
|
|
210
|
+
await ensureCodecs()
|
|
211
|
+
const io = makeIO()
|
|
212
|
+
const doc = await io.read(inputPath)
|
|
213
|
+
const { bySkin } = gatherSkinPrims(doc)
|
|
214
|
+
const plans = doc.getRoot().listSkins().map((skin, i) => planSkin(skin, i, bySkin.get(skin) ?? [], target))
|
|
215
|
+
for (const p of plans) applySkinPlan(doc, p)
|
|
216
|
+
await io.write(outputPath, doc)
|
|
217
|
+
return plans
|
|
218
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Shading pass — the two material quirks that make an exported model render far brighter than the
|
|
2
|
+
// art it was painted from, plus the flat-colour (unlit) conversion stylised assets usually want.
|
|
3
|
+
//
|
|
4
|
+
// SELF-LIT. A PBR material's emissive is meant for the parts that GLOW. Several exporters (and
|
|
5
|
+
// Sketchfab's own download path) instead wire the BASE COLOUR texture into `emissiveTexture` with
|
|
6
|
+
// `emissiveFactor = [1,1,1]`, so the shader adds a full copy of the albedo on top of the lit result:
|
|
7
|
+
//
|
|
8
|
+
// out = lit(baseColor) + baseColor instead of out = lit(baseColor)
|
|
9
|
+
//
|
|
10
|
+
// Under any decent IBL that is ~2× too bright — the model looks washed out and loses its shading,
|
|
11
|
+
// and the usual first suspects (metallic, the texture itself) are innocent.
|
|
12
|
+
//
|
|
13
|
+
// SPECULAR BOOST. `KHR_materials_specular` with a `specularColorFactor` above 1 is the same story
|
|
14
|
+
// for the highlight. gltfio ALSO refuses to combine it with unlit ("Cannot use specular extension
|
|
15
|
+
// with unlit"), so an unlit conversion must drop it or the shading model silently reverts to lit.
|
|
16
|
+
import { KHRMaterialsUnlit } from '@gltf-transform/extensions'
|
|
17
|
+
import type { Document, Material } from '@gltf-transform/core'
|
|
18
|
+
|
|
19
|
+
const DROP_ON_UNLIT = [
|
|
20
|
+
'KHR_materials_specular', 'KHR_materials_ior', 'KHR_materials_sheen', 'KHR_materials_clearcoat',
|
|
21
|
+
'KHR_materials_transmission', 'KHR_materials_volume', 'KHR_materials_iridescence',
|
|
22
|
+
'KHR_materials_anisotropy', 'KHR_materials_dispersion',
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
export interface ShadingIssue {
|
|
26
|
+
mat: Material
|
|
27
|
+
name: string
|
|
28
|
+
/** emissive is a full copy of the base colour — the model renders ~2× too bright */
|
|
29
|
+
selfLit: boolean
|
|
30
|
+
/** highest component of a `KHR_materials_specular` colour factor above 1, else 0 */
|
|
31
|
+
specularBoost: number
|
|
32
|
+
/** already flat-shaded via KHR_materials_unlit */
|
|
33
|
+
unlit: boolean
|
|
34
|
+
}
|
|
35
|
+
export interface ShadingPlan { issues: ShadingIssue[]; unlit: boolean; fixEmissive: boolean }
|
|
36
|
+
export interface ShadingOptions { unlit?: boolean; fixEmissive?: boolean }
|
|
37
|
+
|
|
38
|
+
const specularOf = (m: Material): number => {
|
|
39
|
+
const spec = m.getExtension('KHR_materials_specular') as unknown as { getSpecularColorFactor?: () => number[] } | null
|
|
40
|
+
const f = spec?.getSpecularColorFactor?.() ?? []
|
|
41
|
+
return Math.max(0, ...f.map((v) => (v > 1 ? v : 0)))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function analyzeShading(doc: Document, opts: ShadingOptions = {}): ShadingPlan {
|
|
45
|
+
const issues: ShadingIssue[] = []
|
|
46
|
+
for (const mat of doc.getRoot().listMaterials()) {
|
|
47
|
+
const emissive = mat.getEmissiveFactor()
|
|
48
|
+
const emTex = mat.getEmissiveTexture()
|
|
49
|
+
// "A full copy" means the emissive channel carries the albedo at (near) full strength: either
|
|
50
|
+
// literally the same Texture object, or — for untextured materials — a white emissive factor.
|
|
51
|
+
const strong = Math.max(...emissive) >= 0.5
|
|
52
|
+
const selfLit = strong && (emTex ? emTex === mat.getBaseColorTexture() : true)
|
|
53
|
+
const specularBoost = specularOf(mat)
|
|
54
|
+
const unlit = mat.listExtensions().some((e) => e.extensionName === 'KHR_materials_unlit')
|
|
55
|
+
if (selfLit || specularBoost > 0 || (opts.unlit && !unlit)) {
|
|
56
|
+
issues.push({ mat, name: mat.getName() || '?', selfLit, specularBoost, unlit })
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { issues, unlit: !!opts.unlit, fixEmissive: !!opts.fixEmissive }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ShadingResult { unlit: number; emissiveCleared: number; extensionsDropped: number }
|
|
63
|
+
|
|
64
|
+
export function applyShading(doc: Document, plan: ShadingPlan): ShadingResult {
|
|
65
|
+
const res: ShadingResult = { unlit: 0, emissiveCleared: 0, extensionsDropped: 0 }
|
|
66
|
+
if (!plan.unlit && !plan.fixEmissive) return res
|
|
67
|
+
const unlitExt = plan.unlit ? doc.createExtension(KHRMaterialsUnlit) : null
|
|
68
|
+
for (const { mat, selfLit, unlit } of plan.issues) {
|
|
69
|
+
// Unlit shades baseColorFactor × baseColorTexture and nothing else, so the emissive copy has to
|
|
70
|
+
// go either way — left in place a lit fallback would still double the albedo.
|
|
71
|
+
if (plan.unlit || (plan.fixEmissive && selfLit)) {
|
|
72
|
+
if (Math.max(...mat.getEmissiveFactor()) > 0 || mat.getEmissiveTexture()) {
|
|
73
|
+
mat.setEmissiveFactor([ 0, 0, 0 ]).setEmissiveTexture(null)
|
|
74
|
+
res.emissiveCleared++
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (!plan.unlit) continue
|
|
78
|
+
if (!unlit) { mat.setExtension('KHR_materials_unlit', unlitExt!.createUnlit()); res.unlit++ }
|
|
79
|
+
for (const name of DROP_ON_UNLIT) {
|
|
80
|
+
if (mat.getExtension(name)) { mat.setExtension(name, null); res.extensionsDropped++ }
|
|
81
|
+
}
|
|
82
|
+
// Inputs an unlit shader never reads: keep the file honest so a lit viewer that ignores the
|
|
83
|
+
// extension still renders the flat colour rather than a stray highlight.
|
|
84
|
+
mat.setMetallicFactor(0).setRoughnessFactor(1)
|
|
85
|
+
.setMetallicRoughnessTexture(null).setNormalTexture(null).setOcclusionTexture(null)
|
|
86
|
+
}
|
|
87
|
+
// Unlinking the property is not enough: the Extension stays registered on the document and keeps
|
|
88
|
+
// writing its name into `extensionsUsed`. Drop it once no material references it at all.
|
|
89
|
+
const materials = doc.getRoot().listMaterials()
|
|
90
|
+
for (const ext of doc.getRoot().listExtensionsUsed()) {
|
|
91
|
+
if (!DROP_ON_UNLIT.includes(ext.extensionName)) continue
|
|
92
|
+
if (!materials.some((m) => m.getExtension(ext.extensionName))) ext.dispose()
|
|
93
|
+
}
|
|
94
|
+
return res
|
|
95
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// KTX2 / Basis-Universal encoding via KTX-Software's `toktx` (filament supports image/ktx2 through
|
|
2
|
+
// createKtx2Provider — GPU-compressed, supports alpha, smaller on disk AND in VRAM).
|
|
3
|
+
//
|
|
4
|
+
// Codec by texture role:
|
|
5
|
+
// - colour (base/emissive) -> ETC1S, sRGB (small, lossy)
|
|
6
|
+
// - data (normal/metalRough/occ.) -> UASTC + zstd, linear (precise)
|
|
7
|
+
// …unless `codecPolicy: 'uastc'` asks for UASTC everywhere. That is for HERO assets — a
|
|
8
|
+
// first-person weapon or gloves fill a third of the screen at 30 cm, where ETC1S's block artifacts
|
|
9
|
+
// on the base colour are plainly visible. World props stay on the default: ETC1S is the right
|
|
10
|
+
// trade there and the size difference is roughly 8x.
|
|
11
|
+
// Mipmaps are generated; alpha is preserved (ETC1S/UASTC both encode it).
|
|
12
|
+
import sharp from 'sharp'
|
|
13
|
+
import { spawnSync } from 'node:child_process'
|
|
14
|
+
import { existsSync, mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
|
|
15
|
+
import { tmpdir } from 'node:os'
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
import type { Root, Texture } from '@gltf-transform/core'
|
|
18
|
+
import { getTextureRoles, DEFAULT_MAX_SIZE } from './transcodeTextures'
|
|
19
|
+
|
|
20
|
+
/** Locate the toktx binary: $KTX_TOKTX → %LOCALAPPDATA%\Programs\KTX-Software → PATH. */
|
|
21
|
+
export function findToktx(): string {
|
|
22
|
+
const env = process.env.KTX_TOKTX
|
|
23
|
+
if (env && existsSync(env)) return env
|
|
24
|
+
const la = process.env.LOCALAPPDATA
|
|
25
|
+
if (la) {
|
|
26
|
+
const guess = join(la, 'Programs', 'KTX-Software', 'bin', 'toktx.exe')
|
|
27
|
+
if (existsSync(guess)) return guess
|
|
28
|
+
}
|
|
29
|
+
return 'toktx' // assume on PATH
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function checkToktx(toktx: string): string | null {
|
|
33
|
+
try {
|
|
34
|
+
const p = spawnSync(toktx, ['--version'], { encoding: 'utf8' })
|
|
35
|
+
if (p.status === 0) return (p.stdout ?? '').toString().trim() || 'toktx'
|
|
36
|
+
} catch { /* not found */ }
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Ktx2Plan {
|
|
41
|
+
tex: Texture
|
|
42
|
+
name: string
|
|
43
|
+
roles: string
|
|
44
|
+
fromFmt: string
|
|
45
|
+
codec: 'etc1s' | 'uastc'
|
|
46
|
+
oetf: 'srgb' | 'linear'
|
|
47
|
+
resize: boolean
|
|
48
|
+
width: number
|
|
49
|
+
height: number
|
|
50
|
+
hasAlpha: boolean
|
|
51
|
+
beforeBytes: number
|
|
52
|
+
afterBytes?: number
|
|
53
|
+
category: 'lossy'
|
|
54
|
+
reason: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type CodecPolicy = 'default' | 'hq' | 'uastc'
|
|
58
|
+
|
|
59
|
+
export async function planKtx2(tex: Texture, root: Root, maxSize = DEFAULT_MAX_SIZE, policy: CodecPolicy = 'hq'): Promise<Ktx2Plan | null> {
|
|
60
|
+
const img = tex.getImage()
|
|
61
|
+
const mime = tex.getMimeType() || ''
|
|
62
|
+
const fromFmt = mime.replace('image/', '') || '?'
|
|
63
|
+
if (!img || fromFmt.includes('ktx')) return null // missing or already ktx2
|
|
64
|
+
|
|
65
|
+
const { roles, isNormal, isData } = getTextureRoles(tex, root)
|
|
66
|
+
let width = 0, height = 0, hasAlpha = false
|
|
67
|
+
try {
|
|
68
|
+
const b = Buffer.from(img)
|
|
69
|
+
const m = await sharp(b).metadata()
|
|
70
|
+
width = m.width || 0; height = m.height || 0
|
|
71
|
+
hasAlpha = !(await sharp(b).stats()).isOpaque
|
|
72
|
+
} catch {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Normally only normal maps justify UASTC (high quality, ~8bpp): colour and metalRough/occlusion
|
|
77
|
+
// compress far smaller with ETC1S at acceptable quality. 'uastc' overrides that for hero assets.
|
|
78
|
+
// oetf: linear for any non-colour data.
|
|
79
|
+
const codec: 'etc1s' | 'uastc' =
|
|
80
|
+
policy === 'uastc' ? 'uastc'
|
|
81
|
+
: isNormal ? (policy === 'hq' ? 'uastc' : 'etc1s')
|
|
82
|
+
: 'etc1s'
|
|
83
|
+
const oetf: 'srgb' | 'linear' = isNormal || isData ? 'linear' : 'srgb'
|
|
84
|
+
const resize = Math.max(width, height) > maxSize
|
|
85
|
+
return {
|
|
86
|
+
tex, name: tex.getName() || '?', roles, fromFmt, codec, oetf, resize, width, height, hasAlpha,
|
|
87
|
+
beforeBytes: img.length, category: 'lossy',
|
|
88
|
+
reason: `${fromFmt} → ktx2/${codec}${hasAlpha ? '+alpha' : ''}${resize ? ` (≤${maxSize}px)` : ''}`,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function applyKtx2(plan: Ktx2Plan, toktx: string, maxSize = DEFAULT_MAX_SIZE): Promise<void> {
|
|
93
|
+
const dir = mkdtempSync(join(tmpdir(), 'glbktx-'))
|
|
94
|
+
const inPng = join(dir, 'in.png')
|
|
95
|
+
const outKtx = join(dir, 'out.ktx2')
|
|
96
|
+
try {
|
|
97
|
+
// GPU block-compressed formats (BC7/BC3/ETC2, 4×4 blocks) require the texture's base dimensions
|
|
98
|
+
// to be MULTIPLES OF 4 — otherwise filament's glTexStorage2D fails ("Invalid compressed image
|
|
99
|
+
// size") and the texture renders broken. Snap dims to ×4 (and apply the maxSize cap) before toktx.
|
|
100
|
+
const snap4 = (n: number) => Math.max(4, Math.round(n / 4) * 4)
|
|
101
|
+
let tw = plan.width, th = plan.height
|
|
102
|
+
const m = Math.max(tw, th)
|
|
103
|
+
if (m > maxSize) { const s = maxSize / m; tw = tw * s; th = th * s }
|
|
104
|
+
tw = snap4(tw); th = snap4(th)
|
|
105
|
+
|
|
106
|
+
let pipe = sharp(Buffer.from(plan.tex.getImage()!))
|
|
107
|
+
if (tw !== plan.width || th !== plan.height) pipe = pipe.resize({ width: tw, height: th, fit: 'fill' })
|
|
108
|
+
if (!plan.hasAlpha) pipe = pipe.removeAlpha() // RGB → no wasted alpha slice
|
|
109
|
+
writeFileSync(inPng, await pipe.png().toBuffer())
|
|
110
|
+
|
|
111
|
+
const args = plan.codec === 'etc1s'
|
|
112
|
+
? ['--encode', 'etc1s', '--clevel', '2', '--qlevel', '128', '--genmipmap', '--assign_oetf', plan.oetf, outKtx, inPng]
|
|
113
|
+
: ['--encode', 'uastc', '--uastc_quality', '2', '--zcmp', '18', '--genmipmap', '--assign_oetf', plan.oetf, outKtx, inPng]
|
|
114
|
+
|
|
115
|
+
const proc = spawnSync(toktx, args, { encoding: 'utf8' })
|
|
116
|
+
if (proc.status !== 0) throw new Error(`toktx failed (exit ${proc.status}) for "${plan.name}": ${(proc.stderr ?? '').toString().slice(0, 300)}`)
|
|
117
|
+
|
|
118
|
+
const ktx = readFileSync(outKtx)
|
|
119
|
+
plan.tex.setImage(new Uint8Array(ktx)).setMimeType('image/ktx2')
|
|
120
|
+
plan.afterBytes = ktx.length
|
|
121
|
+
} finally {
|
|
122
|
+
rmSync(dir, { recursive: true, force: true })
|
|
123
|
+
}
|
|
124
|
+
}
|