lecodes-assets 0.2.0 → 2.4.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +738 -449
- package/dist/cli.js +19760 -8612
- package/dist/index.js +19334 -8167
- package/package.json +15 -3
- package/src/bvh/fromDocument.ts +136 -0
- package/src/bvh/toDocument.ts +202 -0
- package/src/cli-kit/command.ts +100 -0
- package/src/cli-kit/help.ts +72 -0
- package/src/cli-kit/index.ts +4 -0
- package/src/cli-kit/parse.ts +72 -0
- package/src/cli-kit/run.ts +52 -0
- package/src/cli.ts +24 -0
- package/src/commands/avatar.ts +77 -0
- package/src/commands/bvh.ts +26 -0
- package/src/commands/convert.ts +156 -0
- package/src/commands/doctor.ts +67 -0
- package/src/commands/geometry.ts +45 -0
- package/src/commands/index.ts +24 -0
- package/src/commands/marks.ts +38 -0
- package/src/commands/mirror.ts +35 -0
- package/src/commands/probe.ts +43 -0
- package/src/commands/retarget/donor.ts +63 -0
- package/src/commands/retarget/index.ts +169 -0
- package/src/commands/retarget/map.ts +81 -0
- package/src/commands/retarget/report.ts +77 -0
- package/src/commands/scene.ts +103 -0
- package/src/commands/shared.ts +121 -0
- package/src/commands/sky.ts +35 -0
- package/src/commands/terrainPack.ts +40 -0
- package/src/commands/unpack.ts +43 -0
- package/src/commands/unreal.ts +109 -0
- package/src/commands/unwrap.ts +56 -0
- package/src/doctor/cardTrim.ts +286 -0
- package/src/doctor/doctor.ts +305 -0
- package/src/doctor/lod.ts +177 -0
- package/src/doctor/merge/accessors.ts +30 -0
- package/src/doctor/merge/atlasLayout.ts +94 -0
- package/src/doctor/merge/atlasPixels.ts +114 -0
- package/src/doctor/merge/bindSpace.ts +59 -0
- package/src/doctor/merge/classify.ts +136 -0
- package/src/doctor/merge/geometry.ts +167 -0
- package/src/doctor/merge/index.ts +98 -0
- package/src/doctor/merge/matrix.ts +60 -0
- package/src/doctor/merge/plan.ts +88 -0
- package/src/doctor/merge/textures.ts +60 -0
- package/src/doctor/merge/types.ts +77 -0
- package/src/doctor/normalizeScale.ts +220 -0
- package/src/doctor/reduceGlbBones.ts +218 -0
- package/src/doctor/shading.ts +95 -0
- package/src/doctor/transcodeKtx2.ts +275 -0
- package/src/doctor/transcodeTextures.ts +101 -0
- package/src/doctor/validateGlb.ts +264 -0
- package/src/doctor/vertexAo.ts +321 -0
- package/src/fbx/convert.ts +327 -0
- package/src/fbx/read.ts +111 -0
- package/src/fbx/toDocument.ts +227 -0
- package/src/filmstrip.ts +263 -0
- package/src/geometry.ts +222 -0
- package/src/index.ts +19 -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 +238 -0
- package/src/retarget/humanoid.ts +572 -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 +2375 -0
- package/src/retarget/rig.ts +545 -0
- package/src/retarget/unityAnim.ts +171 -0
- package/src/retarget/writeMarks.ts +134 -0
- package/src/sky/analyze.ts +136 -0
- package/src/sky/cmgen.ts +71 -0
- package/src/sky/index.ts +200 -0
- package/src/terrainPack.ts +208 -0
- package/src/textures/resolveImage.ts +202 -0
- package/src/unity/colliderFit.ts +287 -0
- package/src/unity/fbxModel.ts +194 -0
- package/src/unity/foliage.ts +63 -0
- package/src/unity/gameObjects.ts +81 -0
- package/src/unity/guid.ts +104 -0
- package/src/unity/image.ts +58 -0
- package/src/unity/kindGlb.ts +364 -0
- package/src/unity/lecodesLevel.ts +381 -0
- package/src/unity/levelTerrain.ts +116 -0
- package/src/unity/matFile.ts +179 -0
- package/src/unity/materialSlots.ts +53 -0
- package/src/unity/materialWriter.ts +306 -0
- package/src/unity/materials.ts +198 -0
- package/src/unity/math.ts +82 -0
- package/src/unity/png.ts +75 -0
- package/src/unity/prefabTree.ts +285 -0
- package/src/unity/prefabs.ts +54 -0
- package/src/unity/propertyPath.ts +43 -0
- package/src/unity/scene.ts +384 -0
- package/src/unity/serialized.ts +242 -0
- package/src/unity/terrain.ts +221 -0
- package/src/unity/triBudget.ts +77 -0
- package/src/unity/triplanar.ts +122 -0
- package/src/unity/unityMesh.ts +151 -0
- package/src/unity/unpack.ts +149 -0
- package/src/unity/yaml.ts +131 -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 +115 -0
- package/src/unreal/list.ts +113 -0
- package/src/unreal/materialGraph.ts +224 -0
- package/src/unreal/materials.ts +399 -0
- package/src/unreal/mesh.ts +205 -0
- package/src/unreal/oodle.ts +153 -0
- package/src/unreal/package.ts +677 -0
- package/src/unreal/probe.ts +144 -0
- package/src/unreal/resolve.ts +53 -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/sound.ts +128 -0
- package/src/unreal/texture.ts +269 -0
- package/src/unreal/toDocument.ts +367 -0
- package/src/unreal/versions.ts +108 -0
- package/src/unwrap/index.ts +167 -0
- package/src/unwrap/lightmapUv.ts +761 -0
- package/src/wasm/fbxReader.js +14 -0
- package/src/wasm/lightmapUv.js +14 -0
|
@@ -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,275 @@
|
|
|
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; ETC1S steps on a smooth channel)
|
|
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 { createHash } from 'node:crypto'
|
|
13
|
+
import sharp from 'sharp'
|
|
14
|
+
import { spawn, spawnSync } from 'node:child_process' // spawnSync only for the one-shot --version probe
|
|
15
|
+
import { existsSync, mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
|
|
16
|
+
import { cpus, tmpdir } from 'node:os'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
import type { Root, Texture } from '@gltf-transform/core'
|
|
19
|
+
import { getTextureRoles, DEFAULT_MAX_SIZE } from './transcodeTextures'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The KTX2 encoder: KTX-Software's legacy `toktx`, or — when there is none — its unified `ktx`
|
|
23
|
+
* tool's `create` subcommand. KTX-Software 5 dropped `toktx` (the macOS installer ships only `ktx`),
|
|
24
|
+
* 4.4 still has both; the two write the same Basis files from the same inputs, only the flags differ
|
|
25
|
+
* (`ktxEncodeArgs`).
|
|
26
|
+
*/
|
|
27
|
+
export interface KtxEncoder { kind: 'toktx' | 'ktx'; path: string; version: string }
|
|
28
|
+
|
|
29
|
+
export const KTX_ENCODER_MISSING =
|
|
30
|
+
'KTX-Software not found — neither `toktx` nor `ktx` (looked at $KTX_TOKTX, $KTX_TOOL, the installer\'s folders, PATH).\n' +
|
|
31
|
+
' Install it from https://github.com/KhronosGroup/KTX-Software/releases, or set KTX_TOKTX / KTX_TOOL=<path to the binary>.'
|
|
32
|
+
|
|
33
|
+
/** Where KTX-Software's installers put their binaries, per platform. */
|
|
34
|
+
function installDirs(): string[] {
|
|
35
|
+
if (process.platform === 'win32') {
|
|
36
|
+
return [ process.env.LOCALAPPDATA && join(process.env.LOCALAPPDATA, 'Programs', 'KTX-Software', 'bin'),
|
|
37
|
+
process.env.ProgramFiles && join(process.env.ProgramFiles, 'KTX-Software', 'bin') ].filter((d): d is string => !!d)
|
|
38
|
+
}
|
|
39
|
+
return [ '/usr/local/bin', '/opt/homebrew/bin' ]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `name` resolved: the env override, then the installer's folders, then PATH (the bare name). */
|
|
43
|
+
function locate(name: 'toktx' | 'ktx', env: string | undefined): string {
|
|
44
|
+
if (env && existsSync(env)) return env
|
|
45
|
+
const exe = process.platform === 'win32' ? `${name}.exe` : name
|
|
46
|
+
return installDirs().map((d) => join(d, exe)).find((p) => existsSync(p)) ?? exe
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Locate the toktx binary: $KTX_TOKTX → the installer's folders → PATH. */
|
|
50
|
+
export const findToktx = (): string => locate('toktx', process.env.KTX_TOKTX)
|
|
51
|
+
|
|
52
|
+
/** Locate KTX-Software's unified `ktx` tool: $KTX_TOOL → next to $KTX_TOKTX → the installer's folders → PATH. */
|
|
53
|
+
export const findKtxCli = (): string =>
|
|
54
|
+
locate('ktx', process.env.KTX_TOOL ?? process.env.KTX_TOKTX?.replace(/toktx(\.exe)?$/i, process.platform === 'win32' ? 'ktx.exe' : 'ktx'))
|
|
55
|
+
|
|
56
|
+
/** The binary's `--version` line, or null when it does not run. */
|
|
57
|
+
export function checkToktx(tool: string): string | null {
|
|
58
|
+
try {
|
|
59
|
+
const p = spawnSync(tool, ['--version'], { encoding: 'utf8' })
|
|
60
|
+
if (p.status === 0) return (p.stdout ?? '').toString().trim() || tool
|
|
61
|
+
} catch { /* not found */ }
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** toktx when it runs, else `ktx` (create), else null. */
|
|
66
|
+
export function findKtxEncoder(): KtxEncoder | null {
|
|
67
|
+
const toktx = findToktx()
|
|
68
|
+
const tv = checkToktx(toktx)
|
|
69
|
+
if (tv) return { kind: 'toktx', path: toktx, version: tv }
|
|
70
|
+
const ktx = findKtxCli()
|
|
71
|
+
const kv = checkToktx(ktx)
|
|
72
|
+
return kv ? { kind: 'ktx', path: ktx, version: kv } : null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface KtxEncodeJob {
|
|
76
|
+
input: string
|
|
77
|
+
output: string
|
|
78
|
+
codec: 'etc1s' | 'uastc'
|
|
79
|
+
oetf: 'srgb' | 'linear'
|
|
80
|
+
/** Channels of the input PNG — `ktx create` wants the format spelled out. */
|
|
81
|
+
channels: 3 | 4
|
|
82
|
+
/** UASTC quality 0–4. */
|
|
83
|
+
uastcQuality: number
|
|
84
|
+
/** Zstandard level for UASTC (ETC1S is Basis-LZ, never supercompressed on top). */
|
|
85
|
+
zstd: number
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The encoder's command line for one PNG → KTX2 (always 2D, mipmapped, the transfer function assigned). */
|
|
89
|
+
export function ktxEncodeArgs(enc: KtxEncoder, j: KtxEncodeJob): string[] {
|
|
90
|
+
if (enc.kind === 'toktx') {
|
|
91
|
+
return [ '--t2', ...(j.codec === 'etc1s'
|
|
92
|
+
? [ '--encode', 'etc1s', '--clevel', '2', '--qlevel', '128' ]
|
|
93
|
+
: [ '--encode', 'uastc', '--uastc_quality', String(j.uastcQuality), '--zcmp', String(j.zstd) ]),
|
|
94
|
+
'--genmipmap', '--assign_oetf', j.oetf, j.output, j.input ]
|
|
95
|
+
}
|
|
96
|
+
const format = `${j.channels === 4 ? 'R8G8B8A8' : 'R8G8B8'}_${j.oetf === 'srgb' ? 'SRGB' : 'UNORM'}`
|
|
97
|
+
return [ 'create', '--format', format, ...(j.codec === 'etc1s'
|
|
98
|
+
? [ '--encode', 'basis-lz', '--clevel', '2', '--qlevel', '128' ]
|
|
99
|
+
: [ '--encode', 'uastc', '--uastc-quality', String(j.uastcQuality), '--zstd', String(j.zstd) ]),
|
|
100
|
+
// --assign-tf: sharp's PNGs carry no colour chunk, which `ktx` reads as sRGB and then refuses to
|
|
101
|
+
// put into a *_UNORM format without a conversion — the data maps ARE linear, so say so.
|
|
102
|
+
'--generate-mipmap', '--assign-tf', j.oetf, j.input, j.output ]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface Ktx2Plan {
|
|
106
|
+
tex: Texture
|
|
107
|
+
name: string
|
|
108
|
+
roles: string
|
|
109
|
+
fromFmt: string
|
|
110
|
+
codec: 'etc1s' | 'uastc'
|
|
111
|
+
oetf: 'srgb' | 'linear'
|
|
112
|
+
resize: boolean
|
|
113
|
+
width: number
|
|
114
|
+
height: number
|
|
115
|
+
hasAlpha: boolean
|
|
116
|
+
beforeBytes: number
|
|
117
|
+
afterBytes?: number
|
|
118
|
+
category: 'lossy'
|
|
119
|
+
reason: string
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type CodecPolicy = 'default' | 'hq' | 'uastc'
|
|
123
|
+
|
|
124
|
+
export async function planKtx2(tex: Texture, root: Root, maxSize = DEFAULT_MAX_SIZE, policy: CodecPolicy = 'hq'): Promise<Ktx2Plan | null> {
|
|
125
|
+
const img = tex.getImage()
|
|
126
|
+
const mime = tex.getMimeType() || ''
|
|
127
|
+
const fromFmt = mime.replace('image/', '') || '?'
|
|
128
|
+
if (!img || fromFmt.includes('ktx')) return null // missing or already ktx2
|
|
129
|
+
|
|
130
|
+
const { roles, isNormal, isData } = getTextureRoles(tex, root)
|
|
131
|
+
let width = 0, height = 0, hasAlpha = false
|
|
132
|
+
try {
|
|
133
|
+
const b = Buffer.from(img)
|
|
134
|
+
const m = await sharp(b).metadata()
|
|
135
|
+
width = m.width || 0; height = m.height || 0
|
|
136
|
+
hasAlpha = !(await sharp(b).stats()).isOpaque
|
|
137
|
+
} catch {
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// DATA maps (normal, metal / rough, occlusion) always take UASTC: ETC1S is a palette codec whose blocks show
|
|
142
|
+
// as steps on a smooth metallic or roughness channel (the range's metal props read pixelated under the
|
|
143
|
+
// reflection probes, 2026-09-17) and it wrecks normals; colour stays on ETC1S (small, its artefacts hide in the
|
|
144
|
+
// detail) unless 'uastc' asks for UASTC everywhere (hero assets). 'hq' is the same as the default now.
|
|
145
|
+
// oetf: linear for any non-colour data.
|
|
146
|
+
const codec: 'etc1s' | 'uastc' =
|
|
147
|
+
policy === 'uastc' ? 'uastc'
|
|
148
|
+
: isNormal || isData ? 'uastc'
|
|
149
|
+
: 'etc1s'
|
|
150
|
+
const oetf: 'srgb' | 'linear' = isNormal || isData ? 'linear' : 'srgb'
|
|
151
|
+
const resize = Math.max(width, height) > maxSize
|
|
152
|
+
return {
|
|
153
|
+
tex, name: tex.getName() || '?', roles, fromFmt, codec, oetf, resize, width, height, hasAlpha,
|
|
154
|
+
beforeBytes: img.length, category: 'lossy',
|
|
155
|
+
reason: `${fromFmt} → ktx2/${codec}${hasAlpha ? '+alpha' : ''}${resize ? ` (≤${maxSize}px)` : ''}`,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* ENCODED KTX2 BY CONTENT, for the life of the process.
|
|
161
|
+
*
|
|
162
|
+
* Encoding is pure: the same pixels at the same cap, codec and transfer function give the same file.
|
|
163
|
+
* And a LEVEL import asks for the same pixels over and over — every kind is its own self-contained
|
|
164
|
+
* GLB, so a wall texture shared by a hundred buildings is embedded, and therefore encoded, a hundred
|
|
165
|
+
* times. Measured on the Chernobyl import: **3554 embedded textures of which 314 are distinct**, i.e.
|
|
166
|
+
* 11.3 encodes of each on average and 144 of the most-shared ones. Each of those is a temp dir, a
|
|
167
|
+
* sharp decode + PNG re-encode, and a SYNCHRONOUS `toktx` process — which is also why the importer
|
|
168
|
+
* sat at 1.4 of this machine's 24 cores while it ran.
|
|
169
|
+
*
|
|
170
|
+
* Keyed on everything that changes the output and nothing else.
|
|
171
|
+
*/
|
|
172
|
+
const encoded = new Map<string, Uint8Array>()
|
|
173
|
+
let hits = 0
|
|
174
|
+
/** Encodes RUNNING right now, by cache key: a second caller for the same image waits on the first
|
|
175
|
+
* instead of starting its own toktx. Without it the concurrency below would turn the cache's whole
|
|
176
|
+
* point inside out — a texture on a hundred props would encode a hundred times, in parallel. */
|
|
177
|
+
const inFlight = new Map<string, Promise<Uint8Array>>()
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* How many toktx processes may run at once. The encode is an external process per texture, so the
|
|
181
|
+
* work is ENTIRELY outside this thread and the only reason to cap it is not to swamp the machine;
|
|
182
|
+
* one per core, minus one for us, is the usual shape. `LECODES_KTX_JOBS` overrides it (1 = the old
|
|
183
|
+
* strictly-sequential behaviour, for a bisect).
|
|
184
|
+
*/
|
|
185
|
+
const JOBS = Math.max(1, Number(process.env.LECODES_KTX_JOBS) || Math.max(1, cpus().length - 1))
|
|
186
|
+
let running = 0
|
|
187
|
+
const waiting: (() => void)[] = []
|
|
188
|
+
const acquire = async (): Promise<void> => {
|
|
189
|
+
if (running < JOBS) { running++; return }
|
|
190
|
+
await new Promise<void>((res) => waiting.push(res))
|
|
191
|
+
running++
|
|
192
|
+
}
|
|
193
|
+
const release = (): void => { running--; waiting.shift()?.() }
|
|
194
|
+
|
|
195
|
+
export const ktx2Jobs = (): number => JOBS
|
|
196
|
+
|
|
197
|
+
/** The encoder as a promise. `spawnSync` blocked the event loop for the whole encode, which made every
|
|
198
|
+
* texture in a project strictly sequential no matter how many cores were idle. */
|
|
199
|
+
const runEncoder = (enc: KtxEncoder, args: string[], name: string): Promise<void> =>
|
|
200
|
+
new Promise((resolve, reject) => {
|
|
201
|
+
const proc = spawn(enc.path, args, { windowsHide: true })
|
|
202
|
+
let err = ''
|
|
203
|
+
proc.stderr?.on('data', (d) => { err += String(d) })
|
|
204
|
+
proc.on('error', reject)
|
|
205
|
+
proc.on('close', (code) => code === 0
|
|
206
|
+
? resolve()
|
|
207
|
+
: reject(new Error(`${enc.kind === 'ktx' ? 'ktx create' : 'toktx'} failed (exit ${code}) for "${name}": ${err.slice(0, 300)}`)))
|
|
208
|
+
})
|
|
209
|
+
/** How many encodes this process served from the cache (for a caller that wants to report it). */
|
|
210
|
+
export const ktx2CacheHits = (): number => hits
|
|
211
|
+
|
|
212
|
+
export async function applyKtx2(plan: Ktx2Plan, enc: KtxEncoder, maxSize = DEFAULT_MAX_SIZE): Promise<void> {
|
|
213
|
+
const src = plan.tex.getImage()
|
|
214
|
+
const key = src
|
|
215
|
+
? `${createHash('sha1').update(src).digest('hex')}:${maxSize}:${plan.codec}:${plan.oetf}:${plan.hasAlpha ? 1 : 0}`
|
|
216
|
+
: null
|
|
217
|
+
if (key) {
|
|
218
|
+
const hit = encoded.get(key)
|
|
219
|
+
if (hit) { hits++; plan.tex.setImage(hit).setMimeType('image/ktx2'); plan.afterBytes = hit.length; return }
|
|
220
|
+
// the same image, already being encoded by another caller: wait on that one
|
|
221
|
+
const busy = inFlight.get(key)
|
|
222
|
+
if (busy) {
|
|
223
|
+
hits++
|
|
224
|
+
const bytes = await busy
|
|
225
|
+
plan.tex.setImage(bytes).setMimeType('image/ktx2')
|
|
226
|
+
plan.afterBytes = bytes.length
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const work = encodeOne(plan, enc, maxSize)
|
|
231
|
+
if (key) {
|
|
232
|
+
inFlight.set(key, work)
|
|
233
|
+
try { encoded.set(key, await work) } finally { inFlight.delete(key) }
|
|
234
|
+
}
|
|
235
|
+
const bytes = await work
|
|
236
|
+
plan.tex.setImage(bytes).setMimeType('image/ktx2')
|
|
237
|
+
plan.afterBytes = bytes.length
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** One texture through sharp + the encoder, returning the encoded bytes. Holds a pool slot only around
|
|
241
|
+
* the external process, so the sharp resize of the next texture overlaps with this one's encode. */
|
|
242
|
+
async function encodeOne(plan: Ktx2Plan, enc: KtxEncoder, maxSize: number): Promise<Uint8Array> {
|
|
243
|
+
const dir = mkdtempSync(join(tmpdir(), 'glbktx-'))
|
|
244
|
+
const inPng = join(dir, 'in.png')
|
|
245
|
+
const outKtx = join(dir, 'out.ktx2')
|
|
246
|
+
try {
|
|
247
|
+
// GPU block-compressed formats (BC7/BC3/ETC2, 4×4 blocks) require the texture's base dimensions
|
|
248
|
+
// to be MULTIPLES OF 4 — otherwise filament's glTexStorage2D fails ("Invalid compressed image
|
|
249
|
+
// size") and the texture renders broken. Snap dims to ×4 (and apply the maxSize cap) before toktx.
|
|
250
|
+
const snap4 = (n: number) => Math.max(4, Math.round(n / 4) * 4)
|
|
251
|
+
let tw = plan.width, th = plan.height
|
|
252
|
+
const m = Math.max(tw, th)
|
|
253
|
+
if (m > maxSize) { const s = maxSize / m; tw = tw * s; th = th * s }
|
|
254
|
+
tw = snap4(tw); th = snap4(th)
|
|
255
|
+
|
|
256
|
+
let pipe = sharp(Buffer.from(plan.tex.getImage()!))
|
|
257
|
+
if (tw !== plan.width || th !== plan.height) pipe = pipe.resize({ width: tw, height: th, fit: 'fill' })
|
|
258
|
+
// Always RGB / RGBA (a grey or palette source too): `ktx create` declares the channel count up front.
|
|
259
|
+
pipe = pipe.toColourspace('srgb')
|
|
260
|
+
pipe = plan.hasAlpha ? pipe.ensureAlpha() : pipe.removeAlpha() // RGB → no wasted alpha slice
|
|
261
|
+
writeFileSync(inPng, await pipe.png().toBuffer())
|
|
262
|
+
|
|
263
|
+
const args = ktxEncodeArgs(enc, {
|
|
264
|
+
input: inPng, output: outKtx, codec: plan.codec, oetf: plan.oetf,
|
|
265
|
+
channels: plan.hasAlpha ? 4 : 3, uastcQuality: 2, zstd: 18,
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
await acquire()
|
|
269
|
+
try { await runEncoder(enc, args, plan.name) } finally { release() }
|
|
270
|
+
|
|
271
|
+
return new Uint8Array(readFileSync(outKtx))
|
|
272
|
+
} finally {
|
|
273
|
+
rmSync(dir, { recursive: true, force: true })
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Texture normalization for creator-gl (filament providers = png/jpeg/ktx2 only) + size budget.
|
|
2
|
+
// Split into PLAN (analysis, no mutation) and APPLY (sharp re-encode).
|
|
3
|
+
//
|
|
4
|
+
// Policy (format chosen purely by alpha — PNG only when alpha is needed, else JPG, to keep size
|
|
5
|
+
// small; webp→png for a 2048 image is ~5–10× larger than webp, so we avoid PNG unless required):
|
|
6
|
+
// - alpha present -> PNG (jpeg has no alpha channel)
|
|
7
|
+
// - no alpha -> JPG (normals/metalRough use quality 92 + 4:4:4 chroma)
|
|
8
|
+
// - webp -> re-encode per the rule above (filament has no webp provider)
|
|
9
|
+
// - max dim > 2048 -> downscale to fit 2048 + re-encode
|
|
10
|
+
// - opaque PNG (>=1024px) -> JPG (heavy opaque PNGs are wasteful)
|
|
11
|
+
// - ktx2 -> left untouched (supported; sharp can't handle it)
|
|
12
|
+
import sharp from 'sharp'
|
|
13
|
+
import type { Root, Texture } from '@gltf-transform/core'
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_MAX_SIZE = 2048
|
|
16
|
+
const HEAVY_PNG_MIN_DIM = 1024
|
|
17
|
+
|
|
18
|
+
export interface TexturePlan {
|
|
19
|
+
tex: Texture
|
|
20
|
+
name: string
|
|
21
|
+
roles: string
|
|
22
|
+
fromFmt: string
|
|
23
|
+
toFmt: 'jpeg' | 'png' | null // null = leave unchanged
|
|
24
|
+
resize: boolean
|
|
25
|
+
width: number
|
|
26
|
+
height: number
|
|
27
|
+
hasAlpha: boolean
|
|
28
|
+
isNormal: boolean
|
|
29
|
+
isData: boolean
|
|
30
|
+
category: 'lossless' | 'lossy' | null
|
|
31
|
+
reason: string
|
|
32
|
+
beforeBytes: number
|
|
33
|
+
afterBytes?: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getTextureRoles(tex: Texture, root: Root) {
|
|
37
|
+
const set = new Set<string>()
|
|
38
|
+
for (const m of root.listMaterials()) {
|
|
39
|
+
if (m.getBaseColorTexture() === tex) set.add('base')
|
|
40
|
+
if (m.getEmissiveTexture() === tex) set.add('emissive')
|
|
41
|
+
if (m.getNormalTexture() === tex) set.add('normal')
|
|
42
|
+
if (m.getMetallicRoughnessTexture() === tex) set.add('metalRough')
|
|
43
|
+
if (m.getOcclusionTexture() === tex) set.add('occlusion')
|
|
44
|
+
}
|
|
45
|
+
return { roles: [...set].join('+') || 'unused', isNormal: set.has('normal'), isData: set.has('metalRough') || set.has('occlusion') }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function planTexture(tex: Texture, root: Root, maxSize = DEFAULT_MAX_SIZE, forceFmt?: 'jpeg' | 'png'): Promise<TexturePlan | null> {
|
|
49
|
+
const img = tex.getImage()
|
|
50
|
+
const mime = tex.getMimeType() || ''
|
|
51
|
+
const fromFmt = mime.replace('image/', '') || '?'
|
|
52
|
+
if (!img) return null
|
|
53
|
+
if (fromFmt.includes('ktx')) return null
|
|
54
|
+
|
|
55
|
+
const { roles, isNormal, isData } = getTextureRoles(tex, root)
|
|
56
|
+
let width = 0, height = 0, hasAlpha = false
|
|
57
|
+
try {
|
|
58
|
+
const buf = Buffer.from(img)
|
|
59
|
+
const meta = await sharp(buf).metadata()
|
|
60
|
+
width = meta.width || 0; height = meta.height || 0
|
|
61
|
+
hasAlpha = !(await sharp(buf).stats()).isOpaque
|
|
62
|
+
} catch {
|
|
63
|
+
return null // undecodable by sharp — leave it
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const resize = Math.max(width, height) > maxSize
|
|
67
|
+
let desired: 'jpeg' | 'png' = forceFmt ?? (hasAlpha ? 'png' : 'jpeg') // PNG only when alpha is needed
|
|
68
|
+
if (desired === 'jpeg' && hasAlpha) desired = 'png' // never drop a real alpha channel
|
|
69
|
+
|
|
70
|
+
let act = false, reason = 'ok'
|
|
71
|
+
if (forceFmt) {
|
|
72
|
+
if ((fromFmt as string) !== desired || resize || (fromFmt as string) === 'webp') { act = true; reason = `${fromFmt} → ${desired}${resize ? ` (≤${maxSize}px)` : ''}` }
|
|
73
|
+
}
|
|
74
|
+
else if (fromFmt === 'webp') { act = true; reason = `webp → ${desired}` }
|
|
75
|
+
else if (resize) { act = true; reason = `${width}×${height} → ≤${maxSize}px, ${desired}` }
|
|
76
|
+
else if (fromFmt === 'png' && desired === 'jpeg' && Math.max(width, height) >= HEAVY_PNG_MIN_DIM) { act = true; reason = `heavy opaque png → jpeg` }
|
|
77
|
+
|
|
78
|
+
const base = { tex, name: tex.getName() || '?', roles, fromFmt, resize, width, height, hasAlpha, isNormal, isData, beforeBytes: img.length }
|
|
79
|
+
if (!act) return { ...base, toFmt: null, category: null, reason }
|
|
80
|
+
|
|
81
|
+
// lossless only when staying/becoming PNG without a resize (no further pixel loss)
|
|
82
|
+
const category: 'lossless' | 'lossy' = desired === 'png' && !resize ? 'lossless' : 'lossy'
|
|
83
|
+
return { ...base, toFmt: desired, category, reason }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function applyTexturePlan(plan: TexturePlan, maxSize = DEFAULT_MAX_SIZE): Promise<void> {
|
|
87
|
+
if (!plan.toFmt) return
|
|
88
|
+
let pipe = sharp(Buffer.from(plan.tex.getImage()!))
|
|
89
|
+
if (plan.resize) pipe = pipe.resize({ width: maxSize, height: maxSize, fit: 'inside', withoutEnlargement: true })
|
|
90
|
+
if (plan.toFmt === 'jpeg') {
|
|
91
|
+
// normals / metal-rough / occlusion carry data, not colour: higher quality, no chroma subsampling
|
|
92
|
+
const dataLike = plan.isNormal || plan.isData
|
|
93
|
+
const out = await pipe.jpeg({ quality: dataLike ? 92 : 85, mozjpeg: true, chromaSubsampling: dataLike ? '4:4:4' : '4:2:0' }).toBuffer()
|
|
94
|
+
plan.tex.setImage(new Uint8Array(out)).setMimeType('image/jpeg')
|
|
95
|
+
plan.afterBytes = out.length
|
|
96
|
+
} else {
|
|
97
|
+
const out = await pipe.png({ compressionLevel: 9 }).toBuffer()
|
|
98
|
+
plan.tex.setImage(new Uint8Array(out)).setMimeType('image/png')
|
|
99
|
+
plan.afterBytes = out.length
|
|
100
|
+
}
|
|
101
|
+
}
|