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,151 @@
|
|
|
1
|
+
// A Unity MESH ASSET (`!u!43` in a text-serialised `.asset`) as a glTF mesh — the geometry a pack
|
|
2
|
+
// authored INSIDE Unity rather than in a modelling tool: NatureManufacture's tree LOD3 is a hand-made
|
|
3
|
+
// cross of quads saved as `<Tree>_Mesh_s.asset` and referenced by the prefab's last LODGroup level,
|
|
4
|
+
// so a kind built from FBX sources alone silently loses its far level and the forest pops out at
|
|
5
|
+
// 0.026 screen heights. Only what such a mesh carries is read: positions, normals, tangents, colours,
|
|
6
|
+
// up to four UV sets, 16/32-bit indices, triangles. Skins, blend shapes and compressed meshes are not
|
|
7
|
+
// (a `.asset` mesh with bones is not something a pack ships).
|
|
8
|
+
//
|
|
9
|
+
// FRAME. The importer's FBX sources hold Unity's mesh MIRRORED BACK on X (ufbx reads the file in its
|
|
10
|
+
// own right-handed frame; Unity's import negated X), and every part of a kind goes through the same
|
|
11
|
+
// mirror later. A mesh saved by Unity is already in Unity's frame, so it is negated on X here —
|
|
12
|
+
// positions, normals, tangent xyz — with the tangent sign and the triangle winding flipped, which is
|
|
13
|
+
// what a reflection does to a handedness. After that it is indistinguishable from an FBX mesh.
|
|
14
|
+
|
|
15
|
+
import { Document, type Mesh } from "@gltf-transform/core"
|
|
16
|
+
import { readFileSync } from "node:fs"
|
|
17
|
+
import { isMap, num, readUnityObjects, str, type Y, type YMap } from "./yaml"
|
|
18
|
+
|
|
19
|
+
/** Unity's VertexFormat: bytes per component and how to read one. */
|
|
20
|
+
const FORMATS: Record<number, { size: number, read: (v: DataView, o: number) => number }> = {
|
|
21
|
+
0: { size: 4, read: (v, o) => v.getFloat32(o, true) },
|
|
22
|
+
1: { size: 2, read: (v, o) => half(v.getUint16(o, true)) },
|
|
23
|
+
2: { size: 1, read: (v, o) => v.getUint8(o) / 255 },
|
|
24
|
+
3: { size: 1, read: (v, o) => Math.max(-1, v.getInt8(o) / 127) },
|
|
25
|
+
4: { size: 2, read: (v, o) => v.getUint16(o, true) / 65535 },
|
|
26
|
+
5: { size: 2, read: (v, o) => Math.max(-1, v.getInt16(o, true) / 32767) },
|
|
27
|
+
6: { size: 1, read: (v, o) => v.getUint8(o) },
|
|
28
|
+
7: { size: 1, read: (v, o) => v.getInt8(o) },
|
|
29
|
+
8: { size: 2, read: (v, o) => v.getUint16(o, true) },
|
|
30
|
+
9: { size: 2, read: (v, o) => v.getInt16(o, true) },
|
|
31
|
+
10: { size: 4, read: (v, o) => v.getUint32(o, true) },
|
|
32
|
+
11: { size: 4, read: (v, o) => v.getInt32(o, true) },
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const half = (h: number): number => {
|
|
36
|
+
const s = h >> 15 ? -1 : 1, e = (h >> 10) & 0x1f, m = h & 0x3ff
|
|
37
|
+
if (e === 0) return s * m * 2 ** -24
|
|
38
|
+
if (e === 31) return m ? NaN : s * Infinity
|
|
39
|
+
return s * (1 + m / 1024) * 2 ** (e - 15)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Unity's vertex channel order → the glTF attribute it is (blend weights / indices are not read). */
|
|
43
|
+
const CHANNELS: (string | null)[] = [ "POSITION", "NORMAL", "TANGENT", "COLOR_0", "TEXCOORD_0", "TEXCOORD_1", "TEXCOORD_2", "TEXCOORD_3", null, null, null, null, null, null ]
|
|
44
|
+
|
|
45
|
+
const hex = (s: string): Uint8Array => {
|
|
46
|
+
const out = new Uint8Array(s.length >> 1)
|
|
47
|
+
for (let i = 0; i < out.length; i++) out[i] = parseInt(s.substr(i * 2, 2), 16)
|
|
48
|
+
return out
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type UnityMeshAsset = { name: string, mesh: Mesh, doc: Document }
|
|
52
|
+
|
|
53
|
+
/** Is this text a Unity mesh asset at all? Cheap: the class tag, before any parsing. */
|
|
54
|
+
export const isUnityMeshAsset = (text: string): boolean => /^--- !u!43 /m.test(text)
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read the mesh object of a text-serialised `.asset`. Null when the file holds no readable mesh
|
|
58
|
+
* (binary, compressed, external stream data, no vertices, no triangles).
|
|
59
|
+
*/
|
|
60
|
+
export const readUnityMeshAsset = (path: string): UnityMeshAsset | null => {
|
|
61
|
+
const text = readFileSync(path, "utf8")
|
|
62
|
+
if (!isUnityMeshAsset(text)) return null
|
|
63
|
+
const obj = readUnityObjects(text).find((o) => o.classID === 43)
|
|
64
|
+
if (!obj) return null
|
|
65
|
+
const b = obj.body
|
|
66
|
+
const vd = isMap(b.m_VertexData) ? b.m_VertexData : null
|
|
67
|
+
if (!vd) return null
|
|
68
|
+
const vcount = num(vd.m_VertexCount)
|
|
69
|
+
const data = hex(str(vd._typelessdata))
|
|
70
|
+
if (!vcount || data.length === 0) return null
|
|
71
|
+
if (isMap(b.m_StreamData) && num(b.m_StreamData.size) > 0) return null
|
|
72
|
+
if (num(b.m_MeshCompression) !== 0) return null
|
|
73
|
+
|
|
74
|
+
// channels → streams: a stream's stride is the end of its last channel rounded up to 4, streams
|
|
75
|
+
// follow one another 16-aligned
|
|
76
|
+
type Ch = { stream: number, offset: number, format: number, dim: number, attr: string | null }
|
|
77
|
+
const channels: Ch[] = (Array.isArray(vd.m_Channels) ? vd.m_Channels : []).map((c: Y, i: number): Ch => {
|
|
78
|
+
const m = isMap(c) ? c : ({} as YMap)
|
|
79
|
+
return { stream: num(m.stream), offset: num(m.offset), format: num(m.format), dim: num(m.dimension) & 0xf, attr: CHANNELS[i] ?? null }
|
|
80
|
+
})
|
|
81
|
+
const streams = Math.max(0, ...channels.map((c) => c.stream)) + 1
|
|
82
|
+
const stride: number[] = [], base: number[] = []
|
|
83
|
+
let cursor = 0
|
|
84
|
+
for (let s = 0; s < streams; s++) {
|
|
85
|
+
let end = 0
|
|
86
|
+
for (const c of channels) if (c.stream === s && c.dim > 0) end = Math.max(end, c.offset + c.dim * (FORMATS[c.format]?.size ?? 4))
|
|
87
|
+
stride[s] = (end + 3) & ~3
|
|
88
|
+
base[s] = cursor
|
|
89
|
+
cursor += stride[s] * vcount
|
|
90
|
+
cursor = (cursor + 15) & ~15
|
|
91
|
+
}
|
|
92
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
|
|
93
|
+
|
|
94
|
+
const doc = new Document()
|
|
95
|
+
const buffer = doc.createBuffer()
|
|
96
|
+
const mesh = doc.createMesh(str(b.m_Name))
|
|
97
|
+
const attrs = new Map<string, { data: Float32Array<ArrayBuffer>, dim: number }>()
|
|
98
|
+
for (const c of channels) {
|
|
99
|
+
if (!c.attr || c.dim === 0) continue
|
|
100
|
+
const fmt = FORMATS[c.format]
|
|
101
|
+
if (!fmt) continue
|
|
102
|
+
// glTF wants TANGENT as vec4 (w = sign), UVs as vec2; colours keep their width (3 or 4)
|
|
103
|
+
const dim = c.attr === "TANGENT" ? 4 : c.attr.startsWith("TEXCOORD") ? 2 : c.dim
|
|
104
|
+
const out = new Float32Array(new ArrayBuffer(vcount * dim * 4))
|
|
105
|
+
for (let i = 0; i < vcount; i++) {
|
|
106
|
+
const at = base[c.stream] + i * stride[c.stream] + c.offset
|
|
107
|
+
for (let k = 0; k < dim; k++) out[i * dim + k] = k < c.dim ? fmt.read(view, at + k * fmt.size) : (k === 3 ? 1 : 0)
|
|
108
|
+
}
|
|
109
|
+
attrs.set(c.attr, { data: out, dim })
|
|
110
|
+
}
|
|
111
|
+
const pos = attrs.get("POSITION")
|
|
112
|
+
if (!pos || pos.dim !== 3) return null
|
|
113
|
+
|
|
114
|
+
// THE MIRROR (see the header): x negated on positions / normals / tangents, the tangent sign
|
|
115
|
+
// flipped; Unity's UV origin is bottom-left where glTF's is top-left
|
|
116
|
+
for (const key of [ "POSITION", "NORMAL", "TANGENT" ]) {
|
|
117
|
+
const a = attrs.get(key)
|
|
118
|
+
if (!a) continue
|
|
119
|
+
for (let i = 0; i < vcount; i++) {
|
|
120
|
+
a.data[i * a.dim] = -a.data[i * a.dim]
|
|
121
|
+
if (key === "TANGENT") a.data[i * 4 + 3] = -a.data[i * 4 + 3]
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const [ key, a ] of attrs) if (key.startsWith("TEXCOORD")) for (let i = 0; i < vcount; i++) a.data[i * 2 + 1] = 1 - a.data[i * 2 + 1]
|
|
125
|
+
|
|
126
|
+
const idxHex = hex(str(b.m_IndexBuffer))
|
|
127
|
+
const wide = num(b.m_IndexFormat) === 1
|
|
128
|
+
const iv = new DataView(idxHex.buffer, idxHex.byteOffset, idxHex.byteLength)
|
|
129
|
+
const subs = Array.isArray(b.m_SubMeshes) ? b.m_SubMeshes : []
|
|
130
|
+
for (const s of subs) {
|
|
131
|
+
if (!isMap(s)) continue
|
|
132
|
+
if (num(s.topology) !== 0) continue // triangles only
|
|
133
|
+
const first = num(s.firstByte), count = num(s.indexCount), baseVertex = num(s.baseVertex)
|
|
134
|
+
if (count < 3 || first + count * (wide ? 4 : 2) > idxHex.length) continue
|
|
135
|
+
const idx = new Uint32Array(new ArrayBuffer(count * 4))
|
|
136
|
+
for (let i = 0; i < count; i++) idx[i] = (wide ? iv.getUint32(first + i * 4, true) : iv.getUint16(first + i * 2, true)) + baseVertex
|
|
137
|
+
// winding: a reflection turns every triangle inside out
|
|
138
|
+
for (let i = 0; i + 2 < count; i += 3) { const t = idx[i + 1]; idx[i + 1] = idx[i + 2]; idx[i + 2] = t }
|
|
139
|
+
const prim = doc.createPrimitive().setMode(4)
|
|
140
|
+
for (const [ key, a ] of attrs) {
|
|
141
|
+
const type = a.dim === 2 ? "VEC2" : a.dim === 3 ? "VEC3" : "VEC4"
|
|
142
|
+
prim.setAttribute(key, doc.createAccessor().setType(type).setArray(a.data).setBuffer(buffer))
|
|
143
|
+
}
|
|
144
|
+
prim.setIndices(doc.createAccessor().setType("SCALAR").setArray(idx).setBuffer(buffer))
|
|
145
|
+
mesh.addPrimitive(prim)
|
|
146
|
+
}
|
|
147
|
+
if (!mesh.listPrimitives().length) return null
|
|
148
|
+
const node = doc.createNode(str(b.m_Name)).setMesh(mesh)
|
|
149
|
+
doc.createScene(str(b.m_Name)).addChild(node)
|
|
150
|
+
return { name: str(b.m_Name), mesh, doc }
|
|
151
|
+
}
|
package/src/unity/unpack.ts
CHANGED
|
@@ -1,19 +1,26 @@
|
|
|
1
1
|
// `.unitypackage` reader. The file is a gzip'd tar of one folder per asset GUID holding `asset`
|
|
2
|
-
// (the bytes), `asset.meta` and `pathname` (its path under Assets/)
|
|
2
|
+
// (the bytes), `asset.meta` and `pathname` (its path under Assets/) — gzip'd or, from some publishers,
|
|
3
|
+
// a plain tar under the same extension (sniffed by the magic). Two streaming passes keep
|
|
3
4
|
// memory flat on multi-GB packs: pass 1 indexes GUID → path (written as `unity-index.txt` at the
|
|
4
5
|
// output root — `convert` resolves Unity material textures through it), pass 2 writes the files
|
|
5
6
|
// that match the requested patterns (everything by default), `.meta` sidecars included so the
|
|
6
7
|
// result is also a valid Unity project fragment.
|
|
7
8
|
|
|
8
|
-
import { createReadStream, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
|
|
9
|
+
import { closeSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync, writeSync } from "node:fs"
|
|
9
10
|
import { dirname, join } from "node:path"
|
|
10
11
|
import { createGunzip } from "node:zlib"
|
|
11
12
|
|
|
12
13
|
type Entry = { name: string, size: number }
|
|
13
14
|
|
|
15
|
+
/** gzip's magic — some publishers ship the tar UNCOMPRESSED under the same extension. */
|
|
16
|
+
const isGzip = (file: string): boolean => {
|
|
17
|
+
const fd = openSync(file, "r")
|
|
18
|
+
try { const h = Buffer.alloc(2); return readSync(fd, h, 0, 2, 0) === 2 && h[0] === 0x1f && h[1] === 0x8b } finally { closeSync(fd) }
|
|
19
|
+
}
|
|
20
|
+
|
|
14
21
|
const tarStream = (file: string, onEntry: (e: Entry, data: Uint8Array) => void): Promise<void> => new Promise((resolveP, rejectP) => {
|
|
15
|
-
const
|
|
16
|
-
|
|
22
|
+
const raw = createReadStream(file, { highWaterMark: 1 << 22 }).on("error", rejectP)
|
|
23
|
+
const gunzip: NodeJS.ReadableStream = isGzip(file) ? raw.pipe(createGunzip().on("error", rejectP)) : raw
|
|
17
24
|
let buf: Buffer = Buffer.alloc(0)
|
|
18
25
|
let state: "header" | "body" | "pad" = "header"
|
|
19
26
|
let entry: Entry | null = null
|
|
@@ -94,6 +101,18 @@ export const readUnityIndex = (file: string): Map<string, string> => {
|
|
|
94
101
|
return map
|
|
95
102
|
}
|
|
96
103
|
|
|
104
|
+
/** Node's fs cannot write more than 2 GiB - 1 in one call, and a .unitypackage can carry a single
|
|
105
|
+
* asset over that (Chernobyl v1.0 ships a 2.46 GB one). `writeFileSync` throws ERR_OUT_OF_RANGE
|
|
106
|
+
* from deep inside the gunzip callback, which reads as a corrupt archive rather than as a limit —
|
|
107
|
+
* the extraction simply stops a few hundred files in, with the index already written and no clue
|
|
108
|
+
* which asset did it. Slice the write instead. */
|
|
109
|
+
const WRITE_CAP = 0x7fffffff
|
|
110
|
+
const writeWhole = (dest: string, data: Uint8Array): void => {
|
|
111
|
+
if (data.length <= WRITE_CAP) { writeFileSync(dest, data); return }
|
|
112
|
+
const fd = openSync(dest, "w")
|
|
113
|
+
try { for (let o = 0; o < data.length; o += WRITE_CAP) writeSync(fd, data, o, Math.min(WRITE_CAP, data.length - o)) } finally { closeSync(fd) }
|
|
114
|
+
}
|
|
115
|
+
|
|
97
116
|
export type ExtractOptions = {
|
|
98
117
|
/** Regexes (case-insensitive) a path must match; none = everything. */
|
|
99
118
|
only?: RegExp[]
|
|
@@ -122,7 +141,7 @@ export const extractUnityPackage = async (pkg: string, index: Map<string, string
|
|
|
122
141
|
try { if (statSync(dest).size === data.length) return } catch { /* fall through */ }
|
|
123
142
|
}
|
|
124
143
|
mkdirSync(dirname(dest), { recursive: true })
|
|
125
|
-
|
|
144
|
+
writeWhole(dest, data)
|
|
126
145
|
if (isAsset) { files++; bytes += data.length }
|
|
127
146
|
if (files % 50 === 0) opts.progress?.(files, bytes)
|
|
128
147
|
})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Unity's text serialisation: a YAML subset parser (what Unity writes — block maps/sequences, flow
|
|
2
|
+
// maps, scalars) and the split of a `.unity` / `.prefab` / `.mat` file into its objects
|
|
3
|
+
// (`--- !u!<classID> &<fileID>[ stripped]`), plus the small readers every consumer of those bodies
|
|
4
|
+
// needs (refs, numbers, vectors).
|
|
5
|
+
|
|
6
|
+
export type Y = string | Y[] | { [k: string]: Y }
|
|
7
|
+
export type YMap = { [k: string]: Y }
|
|
8
|
+
type Line = { indent: number, text: string }
|
|
9
|
+
|
|
10
|
+
const splitLines = (text: string): Line[] => {
|
|
11
|
+
const raw = text.split(/\r?\n/)
|
|
12
|
+
const out: Line[] = []
|
|
13
|
+
for (let i = 0; i < raw.length; i++) {
|
|
14
|
+
let l = raw[i]
|
|
15
|
+
if (!l.trim() || l.startsWith("%")) continue
|
|
16
|
+
// wrapped flow maps: `{fileID: 1, guid: abc,` ⏎ ` type: 3}`
|
|
17
|
+
let open = (l.match(/\{/g) ?? []).length - (l.match(/\}/g) ?? []).length
|
|
18
|
+
while (open > 0 && i + 1 < raw.length) {
|
|
19
|
+
const nx = raw[++i]
|
|
20
|
+
l += " " + nx.trim()
|
|
21
|
+
open += (nx.match(/\{/g) ?? []).length - (nx.match(/\}/g) ?? []).length
|
|
22
|
+
}
|
|
23
|
+
// wrapped plain / quoted scalars (long `propertyPath` or `value` strings): a deeper line that is
|
|
24
|
+
// neither a key nor a list item continues the previous one
|
|
25
|
+
const indent = l.length - l.trimStart().length
|
|
26
|
+
const prev = out[out.length - 1]
|
|
27
|
+
if (prev && indent > prev.indent && !/^- |^-$/.test(l.trim()) && l.trim().search(/:( |$)/) < 0 && !l.trim().startsWith("{") && /: \S/.test(prev.text)) {
|
|
28
|
+
prev.text += " " + l.trim()
|
|
29
|
+
continue
|
|
30
|
+
}
|
|
31
|
+
out.push({ indent, text: l.trim() })
|
|
32
|
+
}
|
|
33
|
+
return out
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const scalar = (s: string): Y => {
|
|
37
|
+
s = s.trim()
|
|
38
|
+
if (s === "" || s === "~" || s === "null") return ""
|
|
39
|
+
if (s === "[]") return []
|
|
40
|
+
if (s === "{}") return {}
|
|
41
|
+
if (s.startsWith("{") && s.endsWith("}")) {
|
|
42
|
+
const obj: YMap = {}
|
|
43
|
+
for (const part of s.slice(1, -1).split(",")) {
|
|
44
|
+
const c = part.indexOf(":")
|
|
45
|
+
if (c < 0) continue
|
|
46
|
+
obj[part.slice(0, c).trim()] = scalar(part.slice(c + 1))
|
|
47
|
+
}
|
|
48
|
+
return obj
|
|
49
|
+
}
|
|
50
|
+
if (s.startsWith("[") && s.endsWith("]")) return s.slice(1, -1).split(",").map((x) => scalar(x))
|
|
51
|
+
if ((s.startsWith("'") && s.endsWith("'")) || (s.startsWith("\"") && s.endsWith("\""))) return s.slice(1, -1).replace(/''/g, "'")
|
|
52
|
+
return s
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const parseUnityYaml = (text: string): YMap => {
|
|
56
|
+
const lines = splitLines(text)
|
|
57
|
+
let pos = 0
|
|
58
|
+
const parseValue = (minIndent: number): Y => {
|
|
59
|
+
if (pos >= lines.length || lines[pos].indent < minIndent) return ""
|
|
60
|
+
return lines[pos].text.startsWith("- ") || lines[pos].text === "-" ? parseSeq(lines[pos].indent) : parseMap(lines[pos].indent)
|
|
61
|
+
}
|
|
62
|
+
const parseMap = (indent: number): YMap => {
|
|
63
|
+
const obj: YMap = {}
|
|
64
|
+
while (pos < lines.length && lines[pos].indent === indent && !lines[pos].text.startsWith("- ")) {
|
|
65
|
+
const t = lines[pos].text
|
|
66
|
+
const c = t.search(/:( |$)/)
|
|
67
|
+
if (c < 0) { pos++; continue }
|
|
68
|
+
const key = t.slice(0, c).trim()
|
|
69
|
+
const rest = t.slice(c + 1).trim()
|
|
70
|
+
pos++
|
|
71
|
+
if (rest === "") obj[key] = pos < lines.length && lines[pos].indent > indent ? parseValue(indent + 1) : (pos < lines.length && lines[pos].indent === indent && lines[pos].text.startsWith("- ") ? parseSeq(indent) : "")
|
|
72
|
+
else obj[key] = scalar(rest)
|
|
73
|
+
}
|
|
74
|
+
return obj
|
|
75
|
+
}
|
|
76
|
+
const parseSeq = (indent: number): Y[] => {
|
|
77
|
+
const arr: Y[] = []
|
|
78
|
+
while (pos < lines.length && lines[pos].indent === indent && (lines[pos].text.startsWith("- ") || lines[pos].text === "-")) {
|
|
79
|
+
const rest = lines[pos].text.slice(1).trim()
|
|
80
|
+
if (rest === "") { pos++; arr.push(parseValue(indent + 1)); continue }
|
|
81
|
+
// a flow map / flow list / plain scalar item (`- {fileID: 0, guid: …}`) is one value, not a key
|
|
82
|
+
if (rest.startsWith("{") || rest.startsWith("[") || rest.search(/:( |$)/) < 0) { pos++; arr.push(scalar(rest)); continue }
|
|
83
|
+
// the item's first key shares the dash line; the rest of its keys sit two deeper
|
|
84
|
+
lines[pos] = { indent: indent + 2, text: rest }
|
|
85
|
+
arr.push(parseValue(indent + 2))
|
|
86
|
+
}
|
|
87
|
+
return arr
|
|
88
|
+
}
|
|
89
|
+
return parseMap(lines[0]?.indent ?? 0)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---- objects --------------------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
export type UnityObject = { classID: number, id: string, stripped: boolean, cls: string, body: YMap }
|
|
95
|
+
|
|
96
|
+
/** Split a text-serialised Unity file into its objects, each body parsed. */
|
|
97
|
+
export const readUnityObjects = (text: string): UnityObject[] => {
|
|
98
|
+
const re = /^--- !u!(\d+) &(-?\d+)( stripped)?\s*$/gm
|
|
99
|
+
const starts: { idx: number, classID: number, id: string, stripped: boolean, end: number }[] = []
|
|
100
|
+
for (let m = re.exec(text); m; m = re.exec(text)) starts.push({ idx: m.index, classID: +m[1], id: m[2], stripped: !!m[3], end: m.index + m[0].length })
|
|
101
|
+
const out: UnityObject[] = []
|
|
102
|
+
for (let i = 0; i < starts.length; i++) {
|
|
103
|
+
const s = starts[i]
|
|
104
|
+
const body = parseUnityYaml(text.slice(s.end, i + 1 < starts.length ? starts[i + 1].idx : text.length))
|
|
105
|
+
const cls = Object.keys(body)[0] ?? ""
|
|
106
|
+
out.push({ classID: s.classID, id: s.id, stripped: s.stripped, cls, body: (body[cls] as YMap) ?? {} })
|
|
107
|
+
}
|
|
108
|
+
return out
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---- value readers --------------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
export type Ref = { fileID: string, guid?: string }
|
|
114
|
+
export const isMap = (v: Y | undefined): v is YMap => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
115
|
+
export const ref = (v: Y | undefined): Ref => (isMap(v) ? { fileID: String(v.fileID ?? "0"), guid: typeof v.guid === "string" && v.guid ? v.guid : undefined } : { fileID: "0" })
|
|
116
|
+
export const num = (v: Y | undefined, d = 0): number => { const n = typeof v === "string" ? Number(v) : NaN; return Number.isFinite(n) ? n : d }
|
|
117
|
+
export const str = (v: Y | undefined): string => (typeof v === "string" ? v : "")
|
|
118
|
+
export type Vec3 = [number, number, number]
|
|
119
|
+
export type Quat = [number, number, number, number]
|
|
120
|
+
export const vec3 = (v: Y | undefined, d: Vec3 = [ 0, 0, 0 ]): Vec3 => {
|
|
121
|
+
const o = isMap(v) ? v : {}
|
|
122
|
+
return [ num(o.x, d[0]), num(o.y, d[1]), num(o.z, d[2]) ]
|
|
123
|
+
}
|
|
124
|
+
export const quat = (v: Y | undefined): Quat => {
|
|
125
|
+
const o = isMap(v) ? v : {}
|
|
126
|
+
return [ num(o.x, 0), num(o.y, 0), num(o.z, 0), num(o.w, 1) ]
|
|
127
|
+
}
|
|
128
|
+
export const color = (v: Y | undefined): [number, number, number, number] => {
|
|
129
|
+
const o = isMap(v) ? v : {}
|
|
130
|
+
return [ num(o.r, 1), num(o.g, 1), num(o.b, 1), num(o.a, 1) ]
|
|
131
|
+
}
|
package/src/unreal/index.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { readFileSync } from "node:fs"
|
|
6
6
|
import { basename, dirname } from "node:path"
|
|
7
|
-
import { readPackage, engineLabel, classOf, importPackagePath, type UePackage } from "./package"
|
|
7
|
+
import { readPackage, engineLabel, classOf, importPackagePath, type UePackage, type ReadPackageOptions } from "./package"
|
|
8
8
|
import { readSkeleton, type UeSkeleton } from "./skeleton"
|
|
9
9
|
import { readAnimSequence, dataModelKind, packageKind, type UeAnimSequence, type DataModelKind } from "./anim"
|
|
10
10
|
import { readSkeletalMesh, type UeSkeletalMesh } from "./mesh"
|
|
@@ -38,11 +38,12 @@ export type LoadedUnreal = {
|
|
|
38
38
|
const packageCache = new Map<string, UePackage>()
|
|
39
39
|
const skeletonCache = new Map<string, UeSkeleton>()
|
|
40
40
|
|
|
41
|
-
export const readPackageFile = (path: string): UePackage => {
|
|
42
|
-
const
|
|
41
|
+
export const readPackageFile = (path: string, opts: ReadPackageOptions = {}): UePackage => {
|
|
42
|
+
const key = opts.ue4 ? `${path}\0ue4` : path
|
|
43
|
+
const hit = packageCache.get(key)
|
|
43
44
|
if (hit) return hit
|
|
44
|
-
const pkg = readPackage(new Uint8Array(readFileSync(path)), basename(path))
|
|
45
|
-
packageCache.set(
|
|
45
|
+
const pkg = readPackage(new Uint8Array(readFileSync(path)), basename(path), opts)
|
|
46
|
+
packageCache.set(key, pkg)
|
|
46
47
|
return pkg
|
|
47
48
|
}
|
|
48
49
|
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// A Material's EXPRESSION GRAPH — the nodes the artist wired in the material editor, as data.
|
|
2
|
+
//
|
|
3
|
+
// `materials.ts` places textures by what their names suggest, because what a parameter DOES is a
|
|
4
|
+
// property of the graph. This reads the graph itself: every `MaterialExpression*` export with its
|
|
5
|
+
// scalar settings (a Constant's `R`, a ScalarParameter's name and default, a TextureSample's asset)
|
|
6
|
+
// and its inputs as edges (`{ node, output, mask }`), plus the material's own outputs (BaseColor,
|
|
7
|
+
// Opacity, Normal, …) as edges into it. A tool can then EVALUATE the graph — e.g. bake what a decal
|
|
8
|
+
// material computes into a texture a renderer without that shader can use.
|
|
9
|
+
//
|
|
10
|
+
// An input (`FExpressionInput` and its Color/Scalar/Vector subclasses) is a StructProperty whose
|
|
11
|
+
// payload is native, not a tag list: the expression (package index), the output index, the input's
|
|
12
|
+
// name, the mask flag and the four channel masks, then the subclass's own constant. Only the common
|
|
13
|
+
// head is read. Output indices follow each class's own output list — a TextureSample's are RGB, R,
|
|
14
|
+
// G, B, A, RGBA — and `mask` spells the channels the edge takes ("rgb", "a", "" = all).
|
|
15
|
+
//
|
|
16
|
+
// Verified on UE 4.25 packages (the "Bullet Impact VFX" pack's decal materials, read with
|
|
17
|
+
// `{ ue4: true }`). UE5 writes the same input head, but its editor-only data moved to
|
|
18
|
+
// `MaterialEditorOnlyData` — a UE5 Material's outputs are read from that export when present.
|
|
19
|
+
|
|
20
|
+
import { classOf, readFName, readObjectProperties, readProperties, propInt, resolveObject, type PropertyTag, type UePackage, type BinaryReader } from "./package"
|
|
21
|
+
|
|
22
|
+
export type MaterialEdge = {
|
|
23
|
+
/** The expression the edge comes from (its object name, e.g. `MaterialExpressionMultiply_21`). */
|
|
24
|
+
node: string
|
|
25
|
+
/** Which of that expression's outputs. */
|
|
26
|
+
output: number
|
|
27
|
+
/** The channels taken: `"rgb"`, `"a"`, `"r"` … or `""` for the output as it is. */
|
|
28
|
+
mask: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type MaterialExpression = {
|
|
32
|
+
/** `Multiply`, `LinearInterpolate`, `TextureSample`, … (the class without `MaterialExpression`). */
|
|
33
|
+
kind: string
|
|
34
|
+
/** Connected inputs by their property name (`A`, `B`, `Alpha`, `Input`, `Coordinates`, …). */
|
|
35
|
+
inputs: Record<string, MaterialEdge>
|
|
36
|
+
/** Everything scalar the expression carries: numbers, names, enums, booleans, colours, and
|
|
37
|
+
* object references as the referenced asset's path (`Texture` → `/Game/…/T_x`). */
|
|
38
|
+
values: Record<string, number | string | boolean | number[]>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type MaterialGraph = {
|
|
42
|
+
name: string
|
|
43
|
+
/** `MD_Surface`, `MD_DeferredDecal`, …; `BLEND_Opaque`, `BLEND_Translucent`, …; `MSM_DefaultLit`, …. */
|
|
44
|
+
domain: string
|
|
45
|
+
blendMode: string
|
|
46
|
+
shadingModel: string
|
|
47
|
+
/** The material's own pins (`BaseColor`, `Opacity`, `Normal`, `Metallic`, `Roughness`, `EmissiveColor`, …). */
|
|
48
|
+
outputs: Record<string, MaterialEdge>
|
|
49
|
+
/** Every other scalar setting on the material (two-sided, tangent-space normal, …). */
|
|
50
|
+
settings: Record<string, number | string | boolean | number[]>
|
|
51
|
+
expressions: Record<string, MaterialExpression>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Struct types that are an expression input (`FExpressionInput` and its subclasses). */
|
|
55
|
+
const isInputStruct = (s: string | undefined): boolean => !!s && /(^ExpressionInput$|MaterialInput$)/.test(s)
|
|
56
|
+
|
|
57
|
+
const MASK = [ "r", "g", "b", "a" ]
|
|
58
|
+
|
|
59
|
+
/** The native head of an `FExpressionInput`: expression, output index, input name, mask, r/g/b/a. */
|
|
60
|
+
const readEdge = (pkg: UePackage, r: BinaryReader, tag: PropertyTag): MaterialEdge | null => {
|
|
61
|
+
if (isTaggedEdge(pkg, r, tag)) return readTaggedEdge(pkg, r, tag)
|
|
62
|
+
if (tag.size < 36) return null
|
|
63
|
+
r.offset = tag.data
|
|
64
|
+
const expr = r.i32(), output = r.i32()
|
|
65
|
+
readFName(r, pkg.names) // the input's display name
|
|
66
|
+
const masked = r.i32() !== 0
|
|
67
|
+
const channels = [ r.i32(), r.i32(), r.i32(), r.i32() ]
|
|
68
|
+
return edgeTo(pkg, expr, output, masked, channels)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const EDGE_FIELDS = new Set([ "Expression", "OutputIndex", "InputName", "Mask", "MaskR", "MaskG", "MaskB", "MaskA", "ExpressionName", "None" ])
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Old UE4 (seen on 4.10, file version 482) writes an input as an ordinary TAG LIST — `Expression`
|
|
75
|
+
* (ObjectProperty), `OutputIndex`, `Mask`, `MaskR`… each only when it is not the default — instead of
|
|
76
|
+
* the native head. Told apart by its first two names: a field of the struct, then a property type.
|
|
77
|
+
*/
|
|
78
|
+
const isTaggedEdge = (pkg: UePackage, r: BinaryReader, tag: PropertyTag): boolean => {
|
|
79
|
+
if (tag.size < 8 || pkg.summary.ue5 !== 0) return false
|
|
80
|
+
r.offset = tag.data
|
|
81
|
+
const n0 = r.i32()
|
|
82
|
+
if (n0 < 0 || n0 >= pkg.names.length || !EDGE_FIELDS.has(pkg.names[n0])) return false
|
|
83
|
+
if (pkg.names[n0] === "None") return tag.size === 8
|
|
84
|
+
r.i32()
|
|
85
|
+
const n2 = r.i32()
|
|
86
|
+
return n2 >= 0 && n2 < pkg.names.length && pkg.names[n2].endsWith("Property")
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const readTaggedEdge = (pkg: UePackage, r: BinaryReader, tag: PropertyTag): MaterialEdge | null => {
|
|
90
|
+
r.offset = tag.data
|
|
91
|
+
const p = readProperties(r, pkg)
|
|
92
|
+
const int = (n: string): number => propInt(r, p.byName.get(n)) ?? 0
|
|
93
|
+
return edgeTo(pkg, int("Expression"), int("OutputIndex"), int("Mask") !== 0, [ int("MaskR"), int("MaskG"), int("MaskB"), int("MaskA") ])
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const edgeTo = (pkg: UePackage, expr: number, output: number, masked: boolean, channels: number[]): MaterialEdge | null => {
|
|
97
|
+
if (expr === 0) return null
|
|
98
|
+
const target = resolveObject(pkg, expr)
|
|
99
|
+
if (!target) return null
|
|
100
|
+
return { node: target.name, output, mask: masked ? channels.map((c, i) => (c ? MASK[i] : "")).join("") : "" }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A tag's value when it is a plain setting; undefined for what this reader does not model. */
|
|
104
|
+
const readValue = (pkg: UePackage, r: BinaryReader, t: PropertyTag): number | string | boolean | number[] | undefined => {
|
|
105
|
+
r.offset = t.data
|
|
106
|
+
switch (t.type) {
|
|
107
|
+
case "FloatProperty": return r.f32()
|
|
108
|
+
case "DoubleProperty": return r.f64()
|
|
109
|
+
case "IntProperty": return r.i32()
|
|
110
|
+
case "BoolProperty": return t.bool ?? false
|
|
111
|
+
case "NameProperty": return readFName(r, pkg.names)
|
|
112
|
+
case "StrProperty": return r.fstring()
|
|
113
|
+
case "EnumProperty": return readFName(r, pkg.names)
|
|
114
|
+
case "ByteProperty": return t.enumName && t.enumName !== "None" ? readFName(r, pkg.names) : r.u8()
|
|
115
|
+
case "ObjectProperty": {
|
|
116
|
+
const o = resolveObject(pkg, r.i32())
|
|
117
|
+
if (!o) return undefined
|
|
118
|
+
return o.kind === "import" && o.packagePath ? o.packagePath : o.name
|
|
119
|
+
}
|
|
120
|
+
case "StructProperty":
|
|
121
|
+
if (t.struct === "LinearColor") return [ r.f32(), r.f32(), r.f32(), r.f32() ]
|
|
122
|
+
if (t.struct === "Color") { const b = r.raw(4); return [ b[2] / 255, b[1] / 255, b[0] / 255, b[3] / 255 ] } // BGRA in memory
|
|
123
|
+
return undefined
|
|
124
|
+
default: return undefined
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Bookkeeping every expression carries that says nothing about what it computes.
|
|
129
|
+
const NOISE = /^(MaterialExpressionEditor[XY]|MaterialExpressionGuid|ExpressionGUID|Material|Function|Desc|bCollapsed|bRealtimePreview|bHidePreviewWindow|bShowOutputNameOnPin|bNeedToUpdatePreview|Outputs|SortPriority|Group)$/
|
|
130
|
+
|
|
131
|
+
/** What an edge evaluates to when everything upstream of it is a number — `null` as soon as anything
|
|
132
|
+
* is not (a texture, a vertex colour, UVs, an expression this does not model). Output 0 is the whole
|
|
133
|
+
* value, output k the k-th channel (a VectorParameter's RGB / R / G / B / A); the edge's mask then
|
|
134
|
+
* takes its channels. Properties left at their class default are not serialised, so the defaults
|
|
135
|
+
* are UE's: a Constant's R 0, a Multiply's ConstB 1, a Lerp's ConstAlpha 0.5. */
|
|
136
|
+
export const evaluateConstant = (graph: MaterialGraph, edge: MaterialEdge | undefined, depth = 0): number[] | null => {
|
|
137
|
+
if (!edge || depth > 64) return null
|
|
138
|
+
const ex = graph.expressions[edge.node]
|
|
139
|
+
if (!ex) return null
|
|
140
|
+
const num = (k: string, d = 0): number => (typeof ex.values[k] === "number" ? ex.values[k] as number : d)
|
|
141
|
+
const vec = (k: string, d: number[]): number[] => (Array.isArray(ex.values[k]) ? [ ...(ex.values[k] as number[]) ] : d)
|
|
142
|
+
const arg = (pin: string, constKey: string, d: number): number[] | null =>
|
|
143
|
+
ex.inputs[pin] ? evaluateConstant(graph, ex.inputs[pin], depth + 1) : [ num(constKey, d) ]
|
|
144
|
+
const zip = (a: number[], b: number[], f: (x: number, y: number) => number): number[] => {
|
|
145
|
+
const n = Math.max(a.length, b.length)
|
|
146
|
+
return Array.from({ length: n }, (_, i) => f(a.length === 1 ? a[0] : a[i] ?? 0, b.length === 1 ? b[0] : b[i] ?? 0))
|
|
147
|
+
}
|
|
148
|
+
let v: number[] | null
|
|
149
|
+
switch (ex.kind) {
|
|
150
|
+
case "Constant": v = [ num("R") ]; break
|
|
151
|
+
case "Constant2Vector": v = [ num("R"), num("G") ]; break
|
|
152
|
+
case "Constant3Vector": v = vec("Constant", [ 0, 0, 0, 0 ]).slice(0, 3); break
|
|
153
|
+
case "Constant4Vector": v = vec("Constant", [ 0, 0, 0, 0 ]); break
|
|
154
|
+
case "ScalarParameter": case "StaticBoolParameter": v = [ num("DefaultValue") ]; break
|
|
155
|
+
case "VectorParameter": v = vec("DefaultValue", [ 0, 0, 0, 1 ]); break
|
|
156
|
+
case "Multiply": case "Add": case "Subtract": case "Divide": case "Max": case "Min": {
|
|
157
|
+
const a = arg("A", "ConstA", ex.kind === "Multiply" || ex.kind === "Divide" ? 1 : 0)
|
|
158
|
+
const b = arg("B", "ConstB", ex.kind === "Multiply" || ex.kind === "Add" || ex.kind === "Divide" ? 1 : 0)
|
|
159
|
+
if (!a || !b) return null
|
|
160
|
+
const op = { Multiply: (x: number, y: number) => x * y, Add: (x: number, y: number) => x + y, Subtract: (x: number, y: number) => x - y,
|
|
161
|
+
Divide: (x: number, y: number) => (y ? x / y : 0), Max: Math.max, Min: Math.min }[ex.kind]
|
|
162
|
+
v = zip(a, b, op)
|
|
163
|
+
break
|
|
164
|
+
}
|
|
165
|
+
case "OneMinus": { const a = arg("Input", "", 0); v = a && a.map((x) => 1 - x); break }
|
|
166
|
+
case "LinearInterpolate": {
|
|
167
|
+
const a = arg("A", "ConstA", 0), b = arg("B", "ConstB", 1), t = arg("Alpha", "ConstAlpha", 0.5)
|
|
168
|
+
if (!a || !b || !t) return null
|
|
169
|
+
v = zip(zip(a, b, (x, y) => y - x), t, (d, s) => d * s).map((d, i) => (a.length === 1 ? a[0] : a[i] ?? 0) + d)
|
|
170
|
+
break
|
|
171
|
+
}
|
|
172
|
+
default: return null
|
|
173
|
+
}
|
|
174
|
+
if (!v) return null
|
|
175
|
+
if (edge.output > 0) v = [ v[edge.output - 1] ?? 0 ]
|
|
176
|
+
if (edge.mask) v = [ ...edge.mask ].map((c) => v![MASK.indexOf(c)] ?? v![0] ?? 0)
|
|
177
|
+
return v
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The expression graph of the package's Material (throws when it holds none). */
|
|
181
|
+
export const readMaterialGraph = (pkg: UePackage): MaterialGraph => {
|
|
182
|
+
const exports = pkg.exports.map((e, i) => ({ e, i, cls: classOf(pkg, e) }))
|
|
183
|
+
const mat = exports.find((x) => x.cls === "Material")
|
|
184
|
+
if (!mat) throw new Error(`${pkg.label}: no Material export (${[ ...new Set(exports.map((x) => x.cls)) ].join(", ")})`)
|
|
185
|
+
const graph: MaterialGraph = { name: mat.e.objectName, domain: "MD_Surface", blendMode: "BLEND_Opaque", shadingModel: "MSM_DefaultLit", outputs: {}, settings: {}, expressions: {} }
|
|
186
|
+
|
|
187
|
+
// the material's own pins: on the Material in UE4, on its MaterialEditorOnlyData in UE5
|
|
188
|
+
const pinsFrom = [ mat, ...exports.filter((x) => x.cls === "MaterialEditorOnlyData") ]
|
|
189
|
+
for (const src of pinsFrom) {
|
|
190
|
+
const { reader: r, props } = readObjectProperties(pkg, src.e)
|
|
191
|
+
for (const t of props.list) {
|
|
192
|
+
if (t.type === "StructProperty" && isInputStruct(t.struct)) {
|
|
193
|
+
const edge = readEdge(pkg, r, t)
|
|
194
|
+
if (edge) graph.outputs[t.name] = edge
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
if (src !== mat || NOISE.test(t.name)) continue
|
|
198
|
+
const v = readValue(pkg, r, t)
|
|
199
|
+
if (v === undefined) continue
|
|
200
|
+
if (t.name === "MaterialDomain") graph.domain = String(v)
|
|
201
|
+
else if (t.name === "BlendMode") graph.blendMode = String(v)
|
|
202
|
+
else if (t.name === "ShadingModel") graph.shadingModel = String(v)
|
|
203
|
+
else graph.settings[t.name] = v
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
for (const x of exports) {
|
|
208
|
+
if (!x.cls.startsWith("MaterialExpression") || x.cls === "MaterialExpressionComment") continue
|
|
209
|
+
const { reader: r, props } = readObjectProperties(pkg, x.e)
|
|
210
|
+
const ex: MaterialExpression = { kind: x.cls.slice("MaterialExpression".length), inputs: {}, values: {} }
|
|
211
|
+
for (const t of props.list) {
|
|
212
|
+
if (NOISE.test(t.name)) continue
|
|
213
|
+
if (t.type === "StructProperty" && isInputStruct(t.struct)) {
|
|
214
|
+
const edge = readEdge(pkg, r, t)
|
|
215
|
+
if (edge) ex.inputs[t.name] = edge
|
|
216
|
+
continue
|
|
217
|
+
}
|
|
218
|
+
const v = readValue(pkg, r, t)
|
|
219
|
+
if (v !== undefined) ex.values[t.name] = v
|
|
220
|
+
}
|
|
221
|
+
graph.expressions[x.e.objectName] = ex
|
|
222
|
+
}
|
|
223
|
+
return graph
|
|
224
|
+
}
|