lecodes-assets 0.2.1 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +203 -26
- package/dist/cli.js +18157 -11441
- package/dist/index.js +16603 -9878
- package/package.json +3 -1
- package/src/cli-kit/command.ts +100 -0
- package/src/cli-kit/help.ts +72 -0
- package/src/cli-kit/index.ts +4 -0
- package/src/cli-kit/parse.ts +72 -0
- package/src/cli-kit/run.ts +52 -0
- package/src/cli.ts +24 -1039
- package/src/commands/avatar.ts +77 -0
- package/src/commands/bvh.ts +26 -0
- package/src/commands/convert.ts +156 -0
- package/src/commands/doctor.ts +79 -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 +108 -0
- package/src/commands/shared.ts +140 -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 +288 -0
- package/src/doctor/doctor.ts +94 -30
- package/src/doctor/impostor.ts +614 -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/transcodeKtx2.ts +186 -31
- package/src/doctor/transcodeTextures.ts +6 -1
- package/src/doctor/validateGlb.ts +2 -2
- package/src/doctor/vertexAo.ts +333 -0
- package/src/fbx/read.ts +2 -0
- package/src/fbx/toDocument.ts +227 -209
- package/src/geometry.ts +222 -0
- package/src/index.ts +2 -0
- package/src/retarget/heading.ts +238 -171
- package/src/retarget/humanoid.ts +572 -561
- package/src/retarget/retarget.ts +2375 -2272
- package/src/retarget/rig.ts +4 -1
- package/src/retarget/writeMarks.ts +134 -132
- package/src/sky/cmgen.ts +3 -3
- package/src/sky/index.ts +57 -8
- package/src/terrainPack.ts +61 -22
- package/src/unity/colliderFit.ts +287 -0
- package/src/unity/fbxModel.ts +194 -0
- package/src/unity/foliage.ts +63 -0
- package/src/unity/gameObjects.ts +90 -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 +414 -0
- package/src/unity/levelTerrain.ts +125 -0
- package/src/unity/matFile.ts +179 -0
- package/src/unity/materialSlots.ts +53 -0
- package/src/unity/materialWriter.ts +316 -0
- package/src/unity/materials.ts +34 -429
- package/src/unity/math.ts +82 -0
- package/src/unity/prefabTree.ts +285 -0
- package/src/unity/prefabs.ts +10 -36
- package/src/unity/propertyPath.ts +43 -0
- package/src/unity/scene.ts +339 -304
- package/src/unity/serialized.ts +242 -0
- package/src/unity/terrain.ts +221 -0
- package/src/unity/triBudget.ts +77 -0
- package/src/unity/triplanar.ts +122 -0
- package/src/unity/unityMesh.ts +151 -0
- package/src/unity/unpack.ts +24 -5
- package/src/unity/yaml.ts +131 -0
- package/src/unreal/index.ts +6 -5
- package/src/unreal/materialGraph.ts +224 -0
- package/src/unreal/materials.ts +117 -9
- package/src/unreal/package.ts +30 -12
- package/src/unreal/resolve.ts +6 -4
- package/src/unreal/sound.ts +128 -0
- package/src/unreal/texture.ts +70 -3
- package/src/unreal/toDocument.ts +7 -0
- package/src/unreal/versions.ts +8 -0
- package/src/unwrap/index.ts +167 -0
- package/src/unwrap/lightmapUv.ts +761 -0
- package/src/wasm/fbxReader.js +1 -1
- package/src/wasm/lightmapUv.js +1 -1
- package/src/doctor/lightmapUv.ts +0 -452
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// A Unity `.mat` file read into its slots. Only YAML-serialised materials are fully readable (Unity
|
|
2
|
+
// ≥ 5 with "Force Text"); a BINARY `.mat` (many asset-store packs ship those) falls back to the
|
|
3
|
+
// pack's naming convention — its slots are inferred from the textures next to it.
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
6
|
+
import { basename, dirname, resolve } from "node:path"
|
|
7
|
+
import { walkFiles } from "./guid"
|
|
8
|
+
|
|
9
|
+
export type UnityMaterial = {
|
|
10
|
+
name: string
|
|
11
|
+
file: string
|
|
12
|
+
/** Shader asset path (resolved) or the builtin id. */
|
|
13
|
+
shader: string
|
|
14
|
+
/** Texture slot → GUID (only slots that reference a texture). */
|
|
15
|
+
textures: Record<string, string>
|
|
16
|
+
floats: Record<string, number>
|
|
17
|
+
colors: Record<string, [number, number, number, number]>
|
|
18
|
+
keywords: string[]
|
|
19
|
+
/** Texture slot → `[scaleX, scaleY, offsetX, offsetY]` (Unity `m_Scale` / `m_Offset`) for the slots that are not (1, 1, 0, 0). */
|
|
20
|
+
tiling?: Record<string, [number, number, number, number]>
|
|
21
|
+
/** `m_LightmapFlags`: bit 4 = EmissiveIsBlack - Unity's own "this material does not emit", whatever the colours say;
|
|
22
|
+
* bit 2 = BakedEmissive, bit 1 = RealtimeEmissive - the inspector's "Global Illumination" (neither = None: the glow
|
|
23
|
+
* lights nothing; materialWriter writes it as the glTF extras `lecodes.emissiveBake: false`). */
|
|
24
|
+
lightmapFlags?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---- .mat parsing (YAML) ------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/** Parse a YAML-serialised Unity material. Returns null for binary serialisation / not a material. */
|
|
30
|
+
export const parseUnityMaterial = (text: string, file: string): UnityMaterial | null => {
|
|
31
|
+
if (!text.startsWith("%YAML") || !/\nMaterial:\n/.test(text)) return null
|
|
32
|
+
const block = text.slice(text.indexOf("\nMaterial:\n"))
|
|
33
|
+
const name = /\n\s*m_Name: (.*)/.exec(block)?.[1]?.trim() || basename(file, ".mat")
|
|
34
|
+
const sh = /m_Shader: \{fileID: (-?\d+)(?:, guid: ([0-9a-f]{32}))?/.exec(text)
|
|
35
|
+
// Unity's built-in shaders live in two pseudo-assets whose GUIDs are all zeros but one digit
|
|
36
|
+
// (`…f000…` = builtin_extra: Standard 46, Standard (Specular) 45, Autodesk Interactive 47); the fileID
|
|
37
|
+
// is then the shader's identity, so those read as `builtin:<fileID>` like a GUID-less reference does.
|
|
38
|
+
const builtin = !sh?.[2] || /^0{16}[ef]0{15}$/.test(sh[2])
|
|
39
|
+
const shader = sh ? (builtin ? `builtin:${sh[1]}` : `guid:${sh[2]}`) : ""
|
|
40
|
+
const textures: Record<string, string> = {}
|
|
41
|
+
const texRe = /\n\s+- (\w+):\n\s+m_Texture: \{fileID: -?\d+, guid: ([0-9a-f]{32}), type: \d+\}/g
|
|
42
|
+
for (let m = texRe.exec(text); m; m = texRe.exec(text)) textures[m[1]] = m[2]
|
|
43
|
+
// a slot's tiling — a detail layer's bark repeats 3× along the trunk (NatureManufacture `_DetailAlbedoMap` (1, 3))
|
|
44
|
+
const tiling: Record<string, [number, number, number, number]> = {}
|
|
45
|
+
const stRe = /\n\s+- (\w+):\n\s+m_Texture: \{fileID: -?\d+, guid: [0-9a-f]{32}, type: \d+\}\n\s+m_Scale: \{x: (-?[\d.eE+-]+), y: (-?[\d.eE+-]+)\}\n\s+m_Offset: \{x: (-?[\d.eE+-]+), y: (-?[\d.eE+-]+)\}/g
|
|
46
|
+
for (let m = stRe.exec(text); m; m = stRe.exec(text)) { const st: [number, number, number, number] = [ Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5]) ]; if (st[0] !== 1 || st[1] !== 1 || st[2] !== 0 || st[3] !== 0) tiling[m[1]] = st }
|
|
47
|
+
const floats: Record<string, number> = {}
|
|
48
|
+
const floatSection = /m_Floats:\n([\s\S]*?)\n\s+m_Colors:/.exec(text)?.[1] ?? ""
|
|
49
|
+
const fRe = /- (\w+): (-?[\d.eE+-]+)/g
|
|
50
|
+
for (let m = fRe.exec(floatSection); m; m = fRe.exec(floatSection)) floats[m[1]] = Number(m[2])
|
|
51
|
+
const colors: Record<string, [number, number, number, number]> = {}
|
|
52
|
+
const cRe = /- (\w+): \{r: (-?[\d.eE+-]+), g: (-?[\d.eE+-]+), b: (-?[\d.eE+-]+), a: (-?[\d.eE+-]+)\}/g
|
|
53
|
+
for (let m = cRe.exec(text); m; m = cRe.exec(text)) colors[m[1]] = [ Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5]) ]
|
|
54
|
+
// keywords: one space-separated line before Unity 2021, a `m_ValidKeywords:` list since
|
|
55
|
+
const keywords = (/m_ShaderKeywords: (.*)/.exec(text)?.[1] ?? "").trim().split(/\s+/).filter(Boolean)
|
|
56
|
+
const valid = /\n\s*m_ValidKeywords:\n((?:\s*- .*\n)*)/.exec(text)?.[1]
|
|
57
|
+
if (valid) for (const k of valid.split("\n")) { const m = /^\s*- (\S+)/.exec(k); if (m && !keywords.includes(m[1])) keywords.push(m[1]) }
|
|
58
|
+
const lightmapFlags = Number(/m_LightmapFlags: (\d+)/.exec(text)?.[1] ?? NaN)
|
|
59
|
+
return { name, file, shader, textures, floats, colors, keywords, tiling: Object.keys(tiling).length ? tiling : undefined, lightmapFlags: Number.isFinite(lightmapFlags) ? lightmapFlags : undefined }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const isBinaryUnityAsset = (bytes: Uint8Array): boolean => !(bytes[0] === 0x25 && bytes[1] === 0x59 && bytes[2] === 0x41) // "%YA"
|
|
63
|
+
|
|
64
|
+
// ---- what a material says about itself ------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
/** Unity's alpha-cutout switch across pipelines: URP `_AlphaClip`, HDRP `_AlphaCutoffEnable`, Standard `_Mode` 1,
|
|
67
|
+
* or the keyword — what becomes glTF MASK (and marks a kind as foliage for the level writer). */
|
|
68
|
+
export const isCutoutMaterial = (um: UnityMaterial): boolean => {
|
|
69
|
+
const clip = um.floats._AlphaClip ?? um.floats._AlphaCutoffEnable
|
|
70
|
+
return clip === 1 || um.floats._Mode === 1 || um.keywords.includes("_ALPHATEST_ON")
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The material carries its pipeline's own cutout SWITCH and that switch is OFF — i.e. it has said,
|
|
75
|
+
* in the field its shader actually reads, that it is not a cutout. Only `_AlphaClip` (URP) and
|
|
76
|
+
* `_AlphaCutoffEnable` (HDRP) count: `_Mode` is the Standard shader's field and packs leave a stale
|
|
77
|
+
* `_Mode: 0` on materials whose custom shader never looks at it, which is the whole reason the
|
|
78
|
+
* texture heuristic below exists.
|
|
79
|
+
*/
|
|
80
|
+
export const declaresNoCutout = (um: UnityMaterial): boolean =>
|
|
81
|
+
um.floats._AlphaClip === 0 || um.floats._AlphaCutoffEnable === 0
|
|
82
|
+
|
|
83
|
+
/** The material's WIND PROFILE — Unity HDRP's Wind.hlsl properties, which NatureManufacture's vegetation shaders carry
|
|
84
|
+
* per material: `_Drag` (how much of the wind the plant takes: trees 0.1, grass 1-2), `_Stiffness` (the bend's growth
|
|
85
|
+
* along the plant), `_InitialBend` (a permanent lean downwind), `_ShiverDrag` (the leaves' own motion: needles 0.02,
|
|
86
|
+
* grass 0.1-0.2, bark ~0) and `_ShiverDirectionality` (0 = along the normal, 1 = along the wind). Goes into the glTF
|
|
87
|
+
* material extras as `lecodes.wind`, which creator-gl's foliage tier reads (glb.cpp foliageProfileFromExtras); a
|
|
88
|
+
* material without the two defining floats has no profile and the engine picks one by the plant's size. */
|
|
89
|
+
export const windProfile = (um: UnityMaterial): [number, number, number, number, number] | null => {
|
|
90
|
+
const f = um.floats
|
|
91
|
+
if (f._Drag === undefined || f._Stiffness === undefined) return null
|
|
92
|
+
return [ f._Drag, f._Stiffness, f._InitialBend ?? 0, f._ShiverDrag ?? 0, f._ShiverDirectionality ?? 0.5 ]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Normalise a material name for matching: case, and the M_/MI_/MAT_ prefixes packs sprinkle. */
|
|
96
|
+
export const materialKey = (n: string): string => n.trim().toLowerCase().replace(/^(mi|m|mat|mtl)_/, "").replace(/\.\d+$/, "")
|
|
97
|
+
|
|
98
|
+
// ---- binary materials, collecting ------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/** Texture references an inferred material carries: a pseudo-GUID naming the file directly. */
|
|
101
|
+
export const PATH_GUID = "path:"
|
|
102
|
+
const IMAGE_RE = /\.(png|jpe?g|tga|bmp|tiff?|psd|exr)$/i
|
|
103
|
+
/** Suffix conventions (after the material name, case-insensitive) → Unity slot. The base colour is
|
|
104
|
+
* the bare name or one of the albedo suffixes. */
|
|
105
|
+
const SUFFIX_SLOTS: [RegExp, string][] = [
|
|
106
|
+
[ /^$|^_(d|bc|albedo|diffuse|diff|basecolor|base|col|color)$/i, "_MainTex" ],
|
|
107
|
+
[ /^_(nm|n|nrm|normal|normalmap|norm)$/i, "_BumpMap" ],
|
|
108
|
+
[ /^_(ms|metallicsmoothness|metallicgloss|mg)$/i, "_MetallicGlossMap" ],
|
|
109
|
+
[ /^_(m|metallic|metal|met)$/i, "_Metallic" ],
|
|
110
|
+
[ /^_(r|rough|roughness)$/i, "_Roughness" ],
|
|
111
|
+
[ /^_(s|smoothness|gloss)$/i, "_Smoothness" ],
|
|
112
|
+
[ /^_(ao|occlusion|ambientocclusion)$/i, "_OcclusionMap" ],
|
|
113
|
+
[ /^_(e|em|emission|emissive|emit)$/i, "_EmissionMap" ],
|
|
114
|
+
[ /^_(orm|ormh)$/i, "_ORM" ],
|
|
115
|
+
[ /^_(mask|maskmap)$/i, "_MaskMap" ],
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A binary-serialised `.mat` can't be parsed, but asset-store packs name their textures after the
|
|
120
|
+
* material: `Seamless_asphalt_v1.mat` next to `Source/Seamless_asphalt_v1.tga` + `…_NM.tga`. Scan the
|
|
121
|
+
* .mat's folder (and its subfolders, two deep) for images whose stem is the material name plus a
|
|
122
|
+
* known suffix and build a material whose texture refs point at those files (`path:` pseudo-GUIDs,
|
|
123
|
+
* resolved without an index). Null when no base colour is found — a wrong guess is worse than none.
|
|
124
|
+
*/
|
|
125
|
+
export const inferBinaryMaterial = (file: string): UnityMaterial | null => {
|
|
126
|
+
const name = basename(file, ".mat")
|
|
127
|
+
const dir = dirname(file)
|
|
128
|
+
const images: string[] = []
|
|
129
|
+
walkFiles(dir, 2, images, (n) => IMAGE_RE.test(n))
|
|
130
|
+
const textures: Record<string, string> = {}
|
|
131
|
+
const lname = name.toLowerCase()
|
|
132
|
+
for (const img of images.sort()) {
|
|
133
|
+
const stem = basename(img).replace(/\.[^.]+$/, "")
|
|
134
|
+
if (!stem.toLowerCase().startsWith(lname)) continue
|
|
135
|
+
const suffix = stem.slice(name.length)
|
|
136
|
+
const slot = SUFFIX_SLOTS.find(([ re ]) => re.test(suffix))?.[1]
|
|
137
|
+
if (slot && !textures[slot]) textures[slot] = PATH_GUID + img
|
|
138
|
+
}
|
|
139
|
+
if (!textures._MainTex) return null
|
|
140
|
+
return { name, file, shader: "binary", textures, floats: {}, colors: {}, keywords: [] }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Every readable `.mat` under `dirs` (recursive) plus `extraFiles`, keyed by normalised name.
|
|
144
|
+
* Binary ones are listed in `binary`; those whose textures could be inferred from their neighbours
|
|
145
|
+
* (`inferBinaryMaterial`) are ALSO used and listed in `inferred`. */
|
|
146
|
+
export const collectUnityMaterials = (dirs: string[], depth = 6, extraFiles: string[] = []): { byKey: Map<string, UnityMaterial>, byFile: Map<string, UnityMaterial>, binary: string[], inferred: string[] } => {
|
|
147
|
+
const files: string[] = []
|
|
148
|
+
for (const d of dirs) walkFiles(d, depth, files, (n) => /\.mat$/i.test(n))
|
|
149
|
+
const seen = new Set(files.map((f) => resolve(f)))
|
|
150
|
+
for (const f of extraFiles) { const a = resolve(f); if (!seen.has(a) && existsSync(a)) { seen.add(a); files.push(a) } }
|
|
151
|
+
const byKey = new Map<string, UnityMaterial>()
|
|
152
|
+
// Keyed by ABSOLUTE PATH as well: `materialKey` strips the M_/MI_ prefix, so a pack that ships a
|
|
153
|
+
// real `Materials/M_Wall.mat` beside the FBX's placeholder `Assets/Materials/MI_Wall.mat` collapses
|
|
154
|
+
// both onto one key and whichever was walked first wins. A prefab override names its material by
|
|
155
|
+
// GUID — an exact file — and must be looked up as one.
|
|
156
|
+
const byFile = new Map<string, UnityMaterial>()
|
|
157
|
+
const binary: string[] = []
|
|
158
|
+
const inferred: string[] = []
|
|
159
|
+
const put = (m: UnityMaterial, f: string) => {
|
|
160
|
+
byFile.set(resolve(f), m)
|
|
161
|
+
const k = materialKey(m.name)
|
|
162
|
+
if (!byKey.has(k)) byKey.set(k, m)
|
|
163
|
+
const fk = materialKey(basename(f, ".mat"))
|
|
164
|
+
if (!byKey.has(fk)) byKey.set(fk, m)
|
|
165
|
+
}
|
|
166
|
+
for (const f of files) {
|
|
167
|
+
const bytes = new Uint8Array(readFileSync(f))
|
|
168
|
+
if (isBinaryUnityAsset(bytes)) {
|
|
169
|
+
binary.push(f)
|
|
170
|
+
const m = inferBinaryMaterial(f)
|
|
171
|
+
if (m) { inferred.push(f); put(m, f) }
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
const m = parseUnityMaterial(new TextDecoder().decode(bytes), f)
|
|
175
|
+
if (!m) continue
|
|
176
|
+
put(m, f)
|
|
177
|
+
}
|
|
178
|
+
return { byKey, byFile, binary, inferred }
|
|
179
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// WHICH MATERIAL SLOT BELONGS TO WHICH PRIMITIVE.
|
|
2
|
+
//
|
|
3
|
+
// A MeshRenderer's `m_Materials` is indexed by UNITY's submesh order, and that is NOT always the
|
|
4
|
+
// order ufbx hands us the primitives in: for Chernobyl's `Balcony_13` the two orders agree on LOD0
|
|
5
|
+
// and disagree on LOD1, so assigning the list by POSITION put the trim's material on the wall, the
|
|
6
|
+
// balcony panel's on the trim and the wall's on the glass — on the far level only. On screen that is
|
|
7
|
+
// a building whose textures rearrange themselves as you walk up to it, and it is invisible in any
|
|
8
|
+
// check that compares the SET of materials per level, because the set is identical.
|
|
9
|
+
//
|
|
10
|
+
// The pairing is recoverable because both sides name the same FBX material: a slot holds either
|
|
11
|
+
// `name:<fbx material>` or the GUID that this model's `.meta` remaps that name to — and when the
|
|
12
|
+
// `.meta` remaps nothing (a pack that assigns materials in its PREFABS ships an empty one), the slot
|
|
13
|
+
// still has a name: the `.mat`'s own. Packs name those after the FBX slot they fill.
|
|
14
|
+
|
|
15
|
+
import { materialKey } from "./matFile"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* For each primitive (by its FBX material name) the index of the slot it takes, −1 for none.
|
|
19
|
+
* `slotNames[k]` is what slot k is known as (null when nothing names it).
|
|
20
|
+
*
|
|
21
|
+
* 1. equal names;
|
|
22
|
+
* 2. equal KEYS (M_/MI_ prefixes aside), then one key containing the other — taken only when exactly
|
|
23
|
+
* one slot fits. ORDER IS NOT A PAIRING: what was left over used to be handed out in primitive
|
|
24
|
+
* order, and `SM_Wall_Door_04`'s wall reads as [Wall, Wall_Exterior] here while the prefab
|
|
25
|
+
* assigns [Brick_Wall, Wall], so the brick ended up on the INSIDE face. By name `Wall` ↔
|
|
26
|
+
* `Wall.mat` and `Metal` ↔ `Metal_01.mat` pair, which leaves `Wall_Exterior` ↔ `Brick_Wall.mat`
|
|
27
|
+
* as the only way round for the rest;
|
|
28
|
+
* 3. what is left over to what is left over, in order — which is exactly how an OVERRIDDEN slot
|
|
29
|
+
* finds its primitive, since an override's GUID matches no FBX name.
|
|
30
|
+
*/
|
|
31
|
+
export const pairSlots = (fbxNames: string[], slotNames: (string | null)[]): number[] => {
|
|
32
|
+
const slotFor = new Array<number>(fbxNames.length).fill(-1)
|
|
33
|
+
const used = new Set<number>()
|
|
34
|
+
const take = (i: number, k: number): void => { slotFor[i] = k; used.add(k) }
|
|
35
|
+
for (let i = 0; i < fbxNames.length; i++) {
|
|
36
|
+
const j = slotNames.findIndex((n, k) => n !== null && n === fbxNames[i] && !used.has(k))
|
|
37
|
+
if (j >= 0) take(i, j)
|
|
38
|
+
}
|
|
39
|
+
for (let i = 0; i < fbxNames.length; i++) {
|
|
40
|
+
if (slotFor[i] >= 0 || !fbxNames[i]) continue
|
|
41
|
+
const a = materialKey(fbxNames[i])
|
|
42
|
+
const free = slotNames.map((n, k) => ({ n, k })).filter((x) => x.n !== null && !used.has(x.k))
|
|
43
|
+
let hit = free.find((x) => materialKey(x.n!) === a)
|
|
44
|
+
if (!hit && a.length >= 3) {
|
|
45
|
+
const near = free.filter((x) => { const b = materialKey(x.n!); return b.length >= 3 && (b.includes(a) || a.includes(b)) })
|
|
46
|
+
if (near.length === 1) hit = near[0]
|
|
47
|
+
}
|
|
48
|
+
if (hit) take(i, hit.k)
|
|
49
|
+
}
|
|
50
|
+
const spare = slotNames.map((_, k) => k).filter((k) => !used.has(k))
|
|
51
|
+
for (let i = 0; i < fbxNames.length; i++) if (slotFor[i] < 0 && spare.length) slotFor[i] = spare.shift()!
|
|
52
|
+
return slotFor
|
|
53
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// One Unity material → one glTF material: base colour, normal, emissive, alpha mode, and ONE
|
|
2
|
+
// metallic-roughness(+occlusion) texture packed the glTF way (R = AO, G = roughness, B = metallic),
|
|
3
|
+
// re-encoding Unity's layouts (Standard `_MetallicGlossMap` R=metal A=smooth, HDRP/URP `_MaskMap`
|
|
4
|
+
// R=metal G=AO A=smooth, separate Metallic / Roughness / Smoothness / AO maps, or a ready `_ORM`),
|
|
5
|
+
// plus the shader's own SCALE on those maps (`_RoughnessScale` / `_MetallicScale` → the glTF
|
|
6
|
+
// factors, which multiply the same way).
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from "node:fs"
|
|
9
|
+
import { basename } from "node:path"
|
|
10
|
+
import type { Document, Material, Texture } from "@gltf-transform/core"
|
|
11
|
+
import { KHRMaterialsEmissiveStrength } from "@gltf-transform/extensions"
|
|
12
|
+
import { sniffMime, encodePng, type Rgba } from "../textures/resolveImage"
|
|
13
|
+
import type { GuidResolver } from "./guid"
|
|
14
|
+
import { alphaIsKeyed, decodeImage, packOrm, type PackInput } from "./image"
|
|
15
|
+
import { PATH_GUID, declaresNoCutout, isCutoutMaterial, type UnityMaterial } from "./matFile"
|
|
16
|
+
|
|
17
|
+
export type Warn = (w: { level: "warn" | "info", message: string }) => void
|
|
18
|
+
|
|
19
|
+
// ---- slot vocabulary --------------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
// `_Main*` is the vertex-PAINT convention: a shadergraph that blends a base layer with per-vertex-
|
|
22
|
+
// colour layers (R/G/B channels) names the base one "Main". Nothing here can run the graph, so the
|
|
23
|
+
// main layer IS the material — far better than the white nothing those slot names used to produce.
|
|
24
|
+
// `_Base_Color` / `_Base_Map` / `_Normal_Map` are the UNDERSCORED spellings a hand-written shader
|
|
25
|
+
// graph produces from a property display name ("Base Color" -> `_Base_Color`), and they are common:
|
|
26
|
+
// Chernobyl's vegetation shader names its albedo ONLY `_Base_Color`, which left 1400+ of its trees
|
|
27
|
+
// untextured, and the school pack has three `_Base_Map` materials of its own. Missing a base slot is
|
|
28
|
+
// the quietest failure in this file — the material still carries a colour, so it reads as flat art
|
|
29
|
+
// rather than as a lost texture.
|
|
30
|
+
export const SLOTS = {
|
|
31
|
+
base: [ "_BaseMap", "_Base_Map", "_MainTex", "_BaseColor", "_Base_Color", "_BaseColorMap", "_BC", "_Albedo", "_AlbedoMap", "_Diffuse", "_DiffuseMap", "_Color", "_MainBC", "_MainTex1", "_Diffuse_Tex", "_Albedo_Tex", "_Base_Tex" ],
|
|
32
|
+
normal: [ "_BumpMap", "_NormalMap", "_Normal_Map", "_Normal", "_NormalTex", "_MainNormal", "_Normal_Tex" ],
|
|
33
|
+
// `_ORMH` = Occlusion / Roughness / Metallic / Height — the same RGB as an ORM with the height in
|
|
34
|
+
// alpha (glTF's MR sampler ignores alpha, so it reads as an ORM). MISSING IT IS EXPENSIVE: the
|
|
35
|
+
// Modular Shooting Range's materials carry their whole shading in `_ORMH` and ALSO point
|
|
36
|
+
// `_OcclusionMap` at the same file, so the pass took the AO channel alone and filled the other two
|
|
37
|
+
// from the floats — `_Metallic: 1` — and every sign, locker and pipe in the level came out a bare
|
|
38
|
+
// mirror (2026-09-17: measured B = 1.000 in the import against 0.009 in T_Signs_ORM.png).
|
|
39
|
+
orm: [ "_ORM", "_ORMMap", "_ORMH", "_ORMHMap", "_OcclusionRoughnessMetallic", "_ORMTex", "_MainORM" ],
|
|
40
|
+
// `_MetalicRAOGSmothnessA` = NatureManufacture's own shaders: the HDRP layout under another name
|
|
41
|
+
maskMap: [ "_MaskMap", "_MetalicRAOGSmothnessA" ],
|
|
42
|
+
metallicGloss: [ "_MetallicGlossMap" ],
|
|
43
|
+
metallic: [ "_Metallic", "_MetallicMap", "_MetallicTex", "_MetalMap" ],
|
|
44
|
+
roughness: [ "_Roughness", "_RoughnessMap", "_RoughnessTex", "_Roughness_Tex" ],
|
|
45
|
+
smoothness: [ "_Smoothness", "_SmoothnessMap", "_SmoothnessTex", "_Gloss", "_GlossMap" ],
|
|
46
|
+
occlusion: [ "_OcclusionMap", "_Occlusion", "_AO", "_AOMap", "_AmbientOcclusion" ],
|
|
47
|
+
emission: [ "_EmissionMap", "_Emission", "_Emissive", "_EmissiveMap", "_EmissiveColorMap" ],
|
|
48
|
+
alpha: [ "_Alpha", "_Opacity", "_OpacityMap", "_AlphaMap", "_OpacityMask" ],
|
|
49
|
+
}
|
|
50
|
+
export const pick = (m: UnityMaterial, names: string[]): string | null => { for (const n of names) { const g = m.textures[n]; if (g) return g } return null }
|
|
51
|
+
|
|
52
|
+
/** Writes Unity `.mat` materials into glTF materials of `doc` (textures decoded / packed once per
|
|
53
|
+
* document). `applyTo` rewrites one glTF material from one Unity material and returns a slot summary. */
|
|
54
|
+
export const unityMaterialWriter = (doc: Document, resolver: GuidResolver, warn: Warn = () => {}): { applyTo: (gm: Material, um: UnityMaterial, how: string) => Promise<string[]> } => {
|
|
55
|
+
const texCache = new Map<string, Texture | null>()
|
|
56
|
+
const imgCache = new Map<string, Rgba | null>()
|
|
57
|
+
const loadBytes = (guid: string): { bytes: Uint8Array, file: string } | null => {
|
|
58
|
+
const f = guid.startsWith(PATH_GUID) ? guid.slice(PATH_GUID.length) : resolver.resolve(guid)
|
|
59
|
+
if (!f) { warn({ level: "warn", message: `texture ${guid}: not in the index / not extracted — skipped` }); return null }
|
|
60
|
+
return { bytes: new Uint8Array(readFileSync(f)), file: f }
|
|
61
|
+
}
|
|
62
|
+
const texFromGuid = async (guid: string): Promise<Texture | null> => {
|
|
63
|
+
if (texCache.has(guid)) return texCache.get(guid)!
|
|
64
|
+
const src = loadBytes(guid)
|
|
65
|
+
let tex: Texture | null = null
|
|
66
|
+
if (src) {
|
|
67
|
+
const mime = sniffMime(src.bytes, src.file)
|
|
68
|
+
let data = src.bytes, outMime: "image/png" | "image/jpeg" = "image/png"
|
|
69
|
+
if (mime === "image/png" || mime === "image/jpeg") outMime = mime
|
|
70
|
+
else {
|
|
71
|
+
const img = await decodeImage(src.bytes, src.file)
|
|
72
|
+
if (!img) { warn({ level: "warn", message: `texture ${basename(src.file)}: unsupported format (${mime}) — skipped` }); texCache.set(guid, null); return null }
|
|
73
|
+
data = encodePng(img)
|
|
74
|
+
}
|
|
75
|
+
tex = doc.createTexture(basename(src.file).replace(/\.[^.]+$/, "")).setImage(data).setMimeType(outMime)
|
|
76
|
+
}
|
|
77
|
+
texCache.set(guid, tex)
|
|
78
|
+
return tex
|
|
79
|
+
}
|
|
80
|
+
const imgFromGuid = async (guid: string): Promise<Rgba | null> => {
|
|
81
|
+
if (imgCache.has(guid)) return imgCache.get(guid)!
|
|
82
|
+
const src = loadBytes(guid)
|
|
83
|
+
const img = src ? await decodeImage(src.bytes, src.file) : null
|
|
84
|
+
if (src && !img) warn({ level: "warn", message: `texture ${basename(src.file)}: could not decode for packing — skipped` })
|
|
85
|
+
imgCache.set(guid, img)
|
|
86
|
+
return img
|
|
87
|
+
}
|
|
88
|
+
const nameOf = (guid: string): string => { const f = guid.startsWith(PATH_GUID) ? guid.slice(PATH_GUID.length) : resolver.resolve(guid); return f ? basename(f) : guid }
|
|
89
|
+
|
|
90
|
+
/** Rewrite one glTF material from a Unity material; returns the slot summary. */
|
|
91
|
+
const applyTo = async (gm: Material, um: UnityMaterial, how: string): Promise<string[]> => {
|
|
92
|
+
const slots: string[] = how === "name" ? [] : [ `(matched by ${how})` ]
|
|
93
|
+
if (um.shader === "binary") slots.push("(binary .mat — slots inferred from sibling textures)")
|
|
94
|
+
// base colour (+ the FBX's base when the .mat has none — keep whatever is there)
|
|
95
|
+
// the first base slot whose texture EXISTS: custom shaders keep stale refs in the usual slots (a
|
|
96
|
+
// tree-billboard shader's `_BaseColor` names a deleted file, its atlas sits in `_MainTex1`)
|
|
97
|
+
const baseG = SLOTS.base.map((n) => um.textures[n]).find((g) => g && (g.startsWith(PATH_GUID) || resolver.resolve(g))) ?? null
|
|
98
|
+
if (baseG) { const t = await texFromGuid(baseG); if (t) { gm.setBaseColorTexture(t); slots.push(`base=${nameOf(baseG)}`) } }
|
|
99
|
+
let baseColor = um.colors._BaseColor ?? um.colors._Color
|
|
100
|
+
// An Amplify leaf graph's albedo is `_Color × tex + _Color_Tilling × tex × noise` (Chernobyl's Leaves.shader: a
|
|
101
|
+
// world-noise patchiness, noise 0..1): the mean of that is `_Color + _Color_Tilling / 2` on the texture, and
|
|
102
|
+
// `_Color` alone drew every leaf of the pack at two thirds of its brightness (2026-11-03). The patchiness itself
|
|
103
|
+
// is the vegetation shader's own (foliage.mat's leaf tint noise).
|
|
104
|
+
const tilling = um.colors._Color_Tilling
|
|
105
|
+
if (tilling && um.floats._Tilling_Color !== undefined) {
|
|
106
|
+
const c = baseColor ?? [ 1, 1, 1, 1 ]
|
|
107
|
+
baseColor = [ c[0] + 0.5 * tilling[0], c[1] + 0.5 * tilling[1], c[2] + 0.5 * tilling[2], c[3] ]
|
|
108
|
+
slots.push(`base×(_Color + _Color_Tilling/2)`)
|
|
109
|
+
}
|
|
110
|
+
if (baseColor) gm.setBaseColorFactor([ baseColor[0], baseColor[1], baseColor[2], gm.getBaseColorFactor()[3] ])
|
|
111
|
+
// EVERY slot is picked the way the base is: the first candidate whose texture EXISTS. A `.mat` keeps the
|
|
112
|
+
// property block of every shader it ever wore, so a NatureManufacture bark that moved from HDRP Lit to
|
|
113
|
+
// the pack's own shader still names a deleted `_MaskMap` beside the live `_MetalicRAOGSmothnessA` — and
|
|
114
|
+
// taking the dead one first left the trunk with no pack at all, i.e. `_Smoothness: 1` = a mirror.
|
|
115
|
+
const live = (names: string[]): string | null => names.map((n) => um.textures[n]).find((g) => g && (g.startsWith(PATH_GUID) || resolver.resolve(g))) ?? null
|
|
116
|
+
const nG = live(SLOTS.normal)
|
|
117
|
+
if (nG) { const t = await texFromGuid(nG); if (t) { gm.setNormalTexture(t); gm.setNormalScale(um.floats._BumpScale ?? 1); slots.push(`normal=${nameOf(nG)}`) } }
|
|
118
|
+
|
|
119
|
+
// metallic / roughness / occlusion
|
|
120
|
+
// Unity's built-in "Autodesk Interactive" shader (builtin fileID 47 — what a Maya / 3ds Max PBR
|
|
121
|
+
// material imports as) reuses the Standard shader's slot NAMES with other meanings: `_SpecGlossMap`
|
|
122
|
+
// is a ROUGHNESS map, `_MetallicGlossMap` a plain metallic map (its alpha is NOT smoothness — it
|
|
123
|
+
// ships opaque, and reading it as smoothness 1 turned a cloth glove into a mirror), and
|
|
124
|
+
// `_Glossiness` is the roughness slider.
|
|
125
|
+
const autodesk = um.shader === "builtin:47"
|
|
126
|
+
const ormG = live(SLOTS.orm), maskG = live(SLOTS.maskMap), mgG = autodesk ? null : live(SLOTS.metallicGloss)
|
|
127
|
+
const metG = live(SLOTS.metallic) ?? (autodesk ? live(SLOTS.metallicGloss) : null)
|
|
128
|
+
const roughG = live(SLOTS.roughness) ?? (autodesk ? um.textures._SpecGlossMap ?? null : null)
|
|
129
|
+
const smoothG = live(SLOTS.smoothness), aoG = live(SLOTS.occlusion)
|
|
130
|
+
/** Metallic / roughness as plain NUMBERS off the material.
|
|
131
|
+
*
|
|
132
|
+
* This runs when no map covers them — including when the material NAMES metal-ish slots that
|
|
133
|
+
* then fail to resolve or decode, which used to fall through and set nothing at all. glTF's
|
|
134
|
+
* default metallicFactor is 1 and Unity's `_Metallic` default is 0, so "set nothing" means
|
|
135
|
+
* EVERY SUCH MATERIAL BECOMES A MIRROR: `Maple_Leaves` says `_Metallic: 0` in the .mat, names a
|
|
136
|
+
* `_Mask` and a `_Smoothness` texture that are not in the pack, and arrived at metallic 1.00 —
|
|
137
|
+
* a rough metal has no diffuse, so Chernobyl's maples reflected the sky and read as silver. */
|
|
138
|
+
const scalarMr = (): void => {
|
|
139
|
+
// ALWAYS a number (2026-10-18): a shader with no `_Metallic` at all — NatureManufacture's Cross Model
|
|
140
|
+
// Shader (the trees' LOD3 billboard cross, a specular shader with `_Specular` 0) — left the glTF default
|
|
141
|
+
// of 1 standing, and every far tree in the forest was a black cut-out: a metal has no diffuse. Unity's
|
|
142
|
+
// default is 0, so the absence means 0
|
|
143
|
+
gm.setMetallicFactor(um.floats._Metallic ?? 0)
|
|
144
|
+
if (smoothness !== undefined) gm.setRoughnessFactor(1 - smoothness)
|
|
145
|
+
slots.push(`metallic=${gm.getMetallicFactor().toFixed(2)} roughness=${gm.getRoughnessFactor().toFixed(2)}`)
|
|
146
|
+
}
|
|
147
|
+
const smoothness = autodesk ? (um.floats._Glossiness !== undefined ? 1 - um.floats._Glossiness : undefined) : um.floats._Smoothness ?? um.floats._Glossiness
|
|
148
|
+
// A shader's SCALE on top of the maps. glTF's metallicFactor / roughnessFactor multiply their
|
|
149
|
+
// texture exactly the way these do, so they carry over one-to-one — and dropping them is not a
|
|
150
|
+
// cosmetic loss: KINEMATION's weapon shader ships `_RoughnessScale: 0.65`, so a gun imported
|
|
151
|
+
// without it renders 54 % rougher than the artist set it, which reads as plastic instead of
|
|
152
|
+
// gunmetal. Every weapon in that pack sets 0.6-0.8; only characters leave it at 1.
|
|
153
|
+
// Names differ per shader, so accept the ones seen in the wild.
|
|
154
|
+
const scaleOf = (...keys: string[]): number => {
|
|
155
|
+
for (const k of keys) { const v = um.floats[k]; if (v !== undefined && v >= 0 && v <= 1) return v }
|
|
156
|
+
return 1
|
|
157
|
+
}
|
|
158
|
+
const roughScale = scaleOf("_RoughnessScale", "_RoughnessMultiplier", "_RoughnessStrength")
|
|
159
|
+
const metalScale = scaleOf("_MetallicScale", "_MetallicMultiplier", "_MetallicStrength")
|
|
160
|
+
const setMr = (t: Texture, withAo: boolean, label: string): void => {
|
|
161
|
+
gm.setMetallicRoughnessTexture(t).setMetallicFactor(metalScale).setRoughnessFactor(roughScale)
|
|
162
|
+
if (withAo) gm.setOcclusionTexture(t).setOcclusionStrength(um.floats._OcclusionStrength ?? 1)
|
|
163
|
+
if (roughScale !== 1 || metalScale !== 1) {
|
|
164
|
+
slots.push(`scale metallic=${metalScale} roughness=${roughScale}`)
|
|
165
|
+
}
|
|
166
|
+
slots.push(label)
|
|
167
|
+
}
|
|
168
|
+
if (ormG) {
|
|
169
|
+
const t = await texFromGuid(ormG)
|
|
170
|
+
if (t) setMr(t, true, `orm=${nameOf(ormG)}`)
|
|
171
|
+
} else if (maskG || mgG || metG || roughG || smoothG || aoG) {
|
|
172
|
+
// pack: R = AO, G = roughness, B = metallic
|
|
173
|
+
let ao: PackInput | null = null, rough: PackInput | null = null, metal: PackInput | null = null
|
|
174
|
+
const parts: string[] = []
|
|
175
|
+
if (maskG) { const img = await imgFromGuid(maskG); if (img) { metal = { img, channel: 0 }; ao = { img, channel: 1 }; rough = { img, channel: 3, invert: true }; parts.push(`MaskMap ${nameOf(maskG)}`) } }
|
|
176
|
+
if (mgG) { const img = await imgFromGuid(mgG); if (img) { metal = { img, channel: 0 }; rough = { img, channel: 3, invert: true }; parts.push(`MetallicGloss ${nameOf(mgG)}`) } }
|
|
177
|
+
if (metG) { const img = await imgFromGuid(metG); if (img) { metal = { img, channel: 0 }; parts.push(`metallic ${nameOf(metG)}`) } }
|
|
178
|
+
if (roughG) { const img = await imgFromGuid(roughG); if (img) { rough = { img, channel: 0 }; parts.push(`roughness ${nameOf(roughG)}`) } }
|
|
179
|
+
else if (smoothG) { const img = await imgFromGuid(smoothG); if (img) { rough = { img, channel: 0, invert: true }; parts.push(`smoothness ${nameOf(smoothG)}`) } }
|
|
180
|
+
if (aoG) { const img = await imgFromGuid(aoG); if (img) { ao = { img, channel: 0 }; parts.push(`ao ${nameOf(aoG)}`) } }
|
|
181
|
+
if (metal || rough || ao) {
|
|
182
|
+
// scalar fallbacks for the channels no map covers
|
|
183
|
+
const dflt = { ao: 255, rough: Math.round((1 - (smoothness ?? 0.5)) * 255), metal: Math.round((um.floats._Metallic ?? 0) * 255) }
|
|
184
|
+
const packed = packOrm(ao, rough, metal, dflt)
|
|
185
|
+
const t = doc.createTexture(`${um.name}_ORM`).setImage(encodePng(packed)).setMimeType("image/png")
|
|
186
|
+
setMr(t, !!ao, `packed ORM ← ${parts.join(" + ")}`)
|
|
187
|
+
} else scalarMr()
|
|
188
|
+
} else scalarMr()
|
|
189
|
+
|
|
190
|
+
// emission: only when the material actually emits (keyword or a non-black colour)
|
|
191
|
+
const emG = live(SLOTS.emission)
|
|
192
|
+
// `_EmissiveColor` FIRST: HDRP's Lit shader emits by that one and leaves the Standard shader's
|
|
193
|
+
// `_EmissionColor` sitting in the property block at white, so reading `_EmissionColor` made every
|
|
194
|
+
// surface of an HDRP pack a self-lit white card — a whole imported level rendered as blank
|
|
195
|
+
// cardboard. URP / Standard materials carry no `_EmissiveColor`, so they are unaffected.
|
|
196
|
+
// ... EXCEPT a material whose `_EMISSION` keyword is on: that is URP / Standard speaking, and there `_EmissionColor`
|
|
197
|
+
// is the live one. A URP pack upgraded from HDRP keeps dead `_EmissiveColor` / `_EmissiveIntensity` fields (the
|
|
198
|
+
// school, 2026-09-18: Ceiling_02 emits at `_EmissionColor` 10 while its `_EmissiveColor` says 1200 nits).
|
|
199
|
+
const emC = um.keywords.includes("_EMISSION") ? um.colors._EmissionColor ?? um.colors._EmissiveColor : um.colors._EmissiveColor ?? um.colors._EmissionColor
|
|
200
|
+
// An explicitly BLACK _EmissionColor is a veto, not a missing value: Unity multiplies the
|
|
201
|
+
// emission map by that colour, so black means the surface does not emit however many maps are
|
|
202
|
+
// bound. Without this the "a map and no keywords, so assume it emits" fallback below fires on
|
|
203
|
+
// custom-shader materials that keep a texture in the slot and switch it off with the colour —
|
|
204
|
+
// which lit a whole rifle body through a magazine's emission map.
|
|
205
|
+
// `m_LightmapFlags` bit 4 (EmissiveIsBlack) is Unity's own veto, in every pipeline: the inspector sets it when the
|
|
206
|
+
// emission is switched off. The school's Ceiling_03 has no `_EMISSION`, this flag, and a dead HDRP `_EmissiveColor`
|
|
207
|
+
// of 0.125 - it was imported as a dim emitter.
|
|
208
|
+
// ... but ONLY for the Standard / URP Lit family, where the inspector couples the flag with the `_EMISSION` keyword.
|
|
209
|
+
// HDRP (and the shadergraphs of HDRP packs) leaves the flag at 4 on materials that glow by `_EmissiveColor` - there it
|
|
210
|
+
// only says "no contribution to baked GI" (the range's M_Lamp_long: `_EmissiveColor` 4, flags 4, and it emits).
|
|
211
|
+
const keywordShader = um.shader === "builtin:45" || um.shader === "builtin:46"
|
|
212
|
+
|| um.shader === "guid:933532a4fcc9baf4fa0491de14d08ed7" || um.shader === "guid:8d2bb70cbf9db8d4da26e15b26e74248" // URP Lit, Simple Lit
|
|
213
|
+
const flagVeto = keywordShader && ((um.lightmapFlags ?? 0) & 4) !== 0 && !um.keywords.includes("_EMISSION")
|
|
214
|
+
const emissionVetoed = (emC !== undefined && emC[0] + emC[1] + emC[2] <= 0.01) || flagVeto
|
|
215
|
+
const emits = !emissionVetoed && (um.keywords.includes("_EMISSION")
|
|
216
|
+
|| (emC ? emC[0] + emC[1] + emC[2] > 0.01 : false)
|
|
217
|
+
|| (!!emG && !um.keywords.length))
|
|
218
|
+
// an HDR emission colour (Unity's intensity is in it): glTF's factor stops at 1, the rest is
|
|
219
|
+
// KHR_materials_emissive_strength - the factor keeps the hue, the strength the brightest channel
|
|
220
|
+
const setEmission = (c: readonly number[] | undefined): void => {
|
|
221
|
+
const lit = c && c[0] + c[1] + c[2] > 0.01 ? c : [ 1, 1, 1 ]
|
|
222
|
+
const peak = Math.max(lit[0], lit[1], lit[2], 1)
|
|
223
|
+
gm.setEmissiveFactor([ lit[0] / peak, lit[1] / peak, lit[2] / peak ])
|
|
224
|
+
if (peak > 1.001) {
|
|
225
|
+
gm.setExtension("KHR_materials_emissive_strength", doc.createExtension(KHRMaterialsEmissiveStrength).createEmissiveStrength().setEmissiveStrength(peak))
|
|
226
|
+
slots.push(`emissiveStrength=${peak.toFixed(2)}`)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (emG && emits) {
|
|
230
|
+
const t = await texFromGuid(emG)
|
|
231
|
+
if (t) { gm.setEmissiveTexture(t); setEmission(emC); slots.push(`emissive=${nameOf(emG)}`) }
|
|
232
|
+
} else if (emC && emits && !emG) setEmission(emC)
|
|
233
|
+
else if (!emits) {
|
|
234
|
+
// The Unity material is the authority and it says this surface does NOT emit — so CLEAR what
|
|
235
|
+
// the FBX bound (toDocument.ts binds fbx emissiveTex with factor 1,1,1 whenever the mesh file
|
|
236
|
+
// names one). DCC exports routinely carry an emission slot the Unity material later overrode
|
|
237
|
+
// to none; leaving it produced a rifle body glowing through a magazine's emission map, which
|
|
238
|
+
// only became visible once the textures stopped being mushy.
|
|
239
|
+
if (gm.getEmissiveTexture()) { gm.setEmissiveTexture(null); slots.push("emissive=off") }
|
|
240
|
+
const ef = gm.getEmissiveFactor()
|
|
241
|
+
if (ef[0] + ef[1] + ef[2] > 0) gm.setEmissiveFactor([ 0, 0, 0 ])
|
|
242
|
+
}
|
|
243
|
+
// THE GI SWITCH: does the glow light the level in a lightmap bake? Unity says it per material in `m_LightmapFlags`
|
|
244
|
+
// (the inspector's "Global Illumination"): bit 2 BakedEmissive = Baked, bit 1 RealtimeEmissive = Realtime (which a
|
|
245
|
+
// project without realtime GI bakes as well), neither = None — drawn, lighting nothing; the pack lights the room
|
|
246
|
+
// with lamps instead (the range's M_Lamp_long: `_EmissiveColor` 4, flags 4 = EmissiveIsBlack alone). The engine's
|
|
247
|
+
// bake reads the glTF extras {"lecodes":{"emissiveBake":false}} (creator-gl lightmap_bake.cpp). A material with no
|
|
248
|
+
// `m_LightmapFlags` at all keeps the default: it bakes. MERGED into the extras (the foliage `wind` profile lives there).
|
|
249
|
+
const giNone = emits && um.lightmapFlags !== undefined && (um.lightmapFlags & 3) === 0
|
|
250
|
+
const extras = (gm.getExtras() ?? {}) as Record<string, unknown>
|
|
251
|
+
const lecodes = extras.lecodes as Record<string, unknown> | undefined
|
|
252
|
+
if (giNone) {
|
|
253
|
+
gm.setExtras({ ...extras, lecodes: { ...(lecodes ?? {}), emissiveBake: false } })
|
|
254
|
+
slots.push("emissiveBake=false (Unity GI: None)")
|
|
255
|
+
} else if (lecodes && "emissiveBake" in lecodes) {
|
|
256
|
+
// a re-import of a material whose flags changed: the switch goes
|
|
257
|
+
const { emissiveBake: _off, ...rest } = lecodes
|
|
258
|
+
const { lecodes: _l, ...others } = extras
|
|
259
|
+
gm.setExtras(Object.keys(rest).length ? { ...others, lecodes: rest } : others)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// alpha: URP `_Surface` 1 = transparent, `_AlphaClip` 1 = cutout; Standard `_Mode` 1 cutout, 2/3 blend;
|
|
263
|
+
// HDRP `_SurfaceType` 1 = transparent, `_AlphaCutoffEnable` 1 = cutout at `_AlphaCutoff` (a foliage
|
|
264
|
+
// pack's leaves are all cutout — missed, the texture's clear pixels draw as black cards)
|
|
265
|
+
const mode = um.floats._Mode, surface = um.floats._Surface ?? um.floats._SurfaceType
|
|
266
|
+
const cutout = isCutoutMaterial(um)
|
|
267
|
+
// THE AUTHORED SURFACE TYPE BEATS THE CLIP FLAG. URP writes `_AlphaClip: 1` beside `_Surface: 1`
|
|
268
|
+
// for a pane that is transparent AND clipped (a window's glass is the ordinary case: clip the
|
|
269
|
+
// fully-clear texels, blend the rest), and read as CUTOUT such a pane comes out fully OPAQUE —
|
|
270
|
+
// the test passes everywhere once the base alpha is 1, so the glass turns to stone and nothing
|
|
271
|
+
// in the conversion looks wrong. Keywords stay weaker evidence than the surface float: a stale
|
|
272
|
+
// `_ALPHABLEND_ON` left on an opaque foliage material must not beat its clip.
|
|
273
|
+
const transparentSurface = surface === 1 || mode === 2 || mode === 3
|
|
274
|
+
const blend = transparentSurface || um.keywords.includes("_ALPHABLEND_ON") || um.keywords.includes("_ALPHAPREMULTIPLY_ON") || um.keywords.includes("_SURFACE_TYPE_TRANSPARENT")
|
|
275
|
+
if (transparentSurface || (blend && !cutout)) {
|
|
276
|
+
gm.setAlphaMode("BLEND")
|
|
277
|
+
// ...and on a transparent material Unity's base-colour ALPHA is the opacity — the one number
|
|
278
|
+
// that says how much glass this is (0.18 on this school's window panes). Everywhere else the
|
|
279
|
+
// FBX's own alpha is deliberately left alone; see the base colour written above.
|
|
280
|
+
const a = (um.colors._BaseColor ?? um.colors._Color)?.[3]
|
|
281
|
+
if (a !== undefined && a < 1) { const c = gm.getBaseColorFactor(); gm.setBaseColorFactor([ c[0], c[1], c[2], a ]) }
|
|
282
|
+
slots.push(`blend${a !== undefined && a < 1 ? ` a=${a.toFixed(2)}` : ""}`)
|
|
283
|
+
}
|
|
284
|
+
else if (cutout) { gm.setAlphaMode("MASK").setAlphaCutoff(um.floats._AlphaCutoff ?? um.floats._Cutoff ?? 0.5); slots.push("cutout") }
|
|
285
|
+
// A CUSTOM shader's `_Mode` / `_Surface` MEAN NOTHING. Those floats belong to Unity's own lit
|
|
286
|
+
// shaders; a hand-written one keys its opacity however it likes and routinely leaves them at 0.
|
|
287
|
+
// Chernobyl's vegetation shader clips at `_Cutoff` internally while `Birch_Leaves` and
|
|
288
|
+
// `Small_Tree` say `_Mode: 0` with no keywords at all — imported opaque, which drew every leaf
|
|
289
|
+
// CARD as a solid quad and turned a forest of 9326 trees into white blobs seen from the air.
|
|
290
|
+
// So when the shader is one we cannot read, the TEXTURE is the evidence: a base map whose alpha
|
|
291
|
+
// channel actually carries a shape is a cutout, whatever the material's floats claim.
|
|
292
|
+
// ...but NEVER against a material that declared itself opaque in the field its own pipeline
|
|
293
|
+
// reads. HDRP addresses every shader by GUID, so `guid:` alone says nothing there, and the
|
|
294
|
+
// Modular Shooting Range's linoleum — `_AlphaCutoffEnable: 0`, `_SurfaceType: 0` — ships a base
|
|
295
|
+
// map whose alpha channel this heuristic read as a shape. The whole FLOOR was then clipped away
|
|
296
|
+
// (you saw the placeholder ground box through it) and so was the 12 cm skirting at the foot of
|
|
297
|
+
// every wall, which is the same linoleum material on the wall mesh. `_Mode: 0` still does not
|
|
298
|
+
// count as a declaration — see `declaresNoCutout`.
|
|
299
|
+
else if (um.shader.startsWith("guid:") && baseG && !declaresNoCutout(um) && await (async () => {
|
|
300
|
+
const img = await imgFromGuid(baseG)
|
|
301
|
+
return !!img && alphaIsKeyed(img)
|
|
302
|
+
})()) {
|
|
303
|
+
gm.setAlphaMode("MASK").setAlphaCutoff(um.floats._AlphaCutoff ?? um.floats._Cutoff ?? 0.5)
|
|
304
|
+
slots.push(`cutout (custom shader, keyed base map)`)
|
|
305
|
+
}
|
|
306
|
+
else if (gm.getAlphaMode() !== "OPAQUE" && !pick(um, SLOTS.alpha)) gm.setAlphaMode("OPAQUE")
|
|
307
|
+
// two-sided: Standard/URP `_Cull` 0, HDRP `_DoubleSidedEnable` 1 / `_CullMode` 0
|
|
308
|
+
if (um.floats._Cull === 0 || um.floats._CullMode === 0 || um.floats._DoubleSidedEnable === 1 || um.keywords.includes("_DOUBLESIDED_ON")) {
|
|
309
|
+
gm.setDoubleSided(true)
|
|
310
|
+
slots.push("double-sided")
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return slots
|
|
314
|
+
}
|
|
315
|
+
return { applyTo }
|
|
316
|
+
}
|