lecodes-assets 0.2.1 → 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 +203 -26
- package/dist/cli.js +17948 -11911
- package/dist/index.js +17525 -11479
- 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 +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 +94 -30
- 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 +179 -28
- package/src/doctor/validateGlb.ts +2 -2
- package/src/doctor/vertexAo.ts +321 -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 +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 +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 +325 -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,242 @@
|
|
|
1
|
+
// Unity's BINARY serialised files (`SerializedFile`: a `.asset` / `.prefab` / `.unity` written with
|
|
2
|
+
// "Mixed" or "Force Binary" serialisation, TerrainData always) → plain objects, driven by the TYPE TREE
|
|
3
|
+
// the editor stores next to the data. No per-class knowledge: every field comes back under its
|
|
4
|
+
// serialised name (`m_Heightmap.m_Heights`, `m_SplatDatabase.m_AlphaTextures` …), vectors of
|
|
5
|
+
// primitives as typed arrays (a 1025² heightmap is one Int16Array, not a million numbers), strings
|
|
6
|
+
// decoded, `TypelessData` (texture pixels) as bytes.
|
|
7
|
+
//
|
|
8
|
+
// Files without a type tree (player builds) are refused — the editor always writes one. Format
|
|
9
|
+
// versions 9 … 22 (Unity 5 … 2022+); layout per UnityPy's SerializedFile reader.
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from "node:fs"
|
|
12
|
+
|
|
13
|
+
export type UValue = number | bigint | boolean | string | UValue[] | Uint8Array | Int8Array | Int16Array | Uint16Array | Int32Array | Uint32Array | Float32Array | Float64Array | { [k: string]: UValue }
|
|
14
|
+
export type UObject = { [k: string]: UValue }
|
|
15
|
+
|
|
16
|
+
type TypeNode = { type: string, name: string, byteSize: number, level: number, isArray: boolean, align: boolean, children: TypeNode[] }
|
|
17
|
+
type SType = { classID: number, root: TypeNode | null }
|
|
18
|
+
export type SObjectInfo = { pathID: bigint, byteStart: number, byteSize: number, classID: number, type: SType }
|
|
19
|
+
export type SExternal = { guid: string, type: number, path: string }
|
|
20
|
+
|
|
21
|
+
// Unity's built-in string table: a type-tree string offset with the high bit set indexes into this.
|
|
22
|
+
const COMMON = "AABB\0AnimationClip\0AnimationCurve\0AnimationState\0Array\0Base\0BitField\0bitset\0bool\0char\0ColorRGBA\0Component\0data\0deque\0double\0dynamic_array\0FastPropertyName\0first\0float\0Font\0GameObject\0Generic Mono\0GradientNEW\0GUID\0GUIStyle\0int\0list\0long long\0map\0Matrix4x4f\0MdFour\0MonoBehaviour\0MonoScript\0m_ByteSize\0m_Curve\0m_EditorClassIdentifier\0m_EditorHideFlags\0m_Enabled\0m_ExtensionPtr\0m_GameObject\0m_Index\0m_IsArray\0m_IsStatic\0m_MetaFlag\0m_Name\0m_ObjectHideFlags\0m_PrefabInternal\0m_PrefabParentObject\0m_Script\0m_StaticEditorFlags\0m_Type\0m_Version\0Object\0pair\0PPtr<Component>\0PPtr<GameObject>\0PPtr<Material>\0PPtr<MonoBehaviour>\0PPtr<MonoScript>\0PPtr<Object>\0PPtr<Prefab>\0PPtr<Sprite>\0PPtr<TextAsset>\0PPtr<Texture>\0PPtr<Texture2D>\0PPtr<Transform>\0Prefab\0Quaternionf\0Rectf\0RectInt\0RectOffset\0second\0set\0short\0size\0SInt16\0SInt32\0SInt64\0SInt8\0staticvector\0string\0TextAsset\0TextMesh\0Texture\0Texture2D\0Transform\0TypelessData\0UInt16\0UInt32\0UInt64\0UInt8\0unsigned int\0unsigned long long\0unsigned short\0vector\0Vector2f\0Vector3f\0Vector4f\0m_ScriptingClassIdentifier\0Gradient\0Type*\0int2_storage\0int3_storage\0BoundsInt\0m_CorrespondingSourceObject\0m_PrefabInstance\0m_PrefabAsset\0FileSize\0Hash128\0"
|
|
23
|
+
const cstrAt = (s: string, off: number): string => { const e = s.indexOf("\0", off); return s.slice(off, e < 0 ? undefined : e) }
|
|
24
|
+
|
|
25
|
+
class Reader {
|
|
26
|
+
pos = 0
|
|
27
|
+
readonly dv: DataView
|
|
28
|
+
constructor(readonly bytes: Uint8Array, public little = false) { this.dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) }
|
|
29
|
+
u8(): number { return this.dv.getUint8(this.pos++) }
|
|
30
|
+
i16(): number { const v = this.dv.getInt16(this.pos, this.little); this.pos += 2; return v }
|
|
31
|
+
u16(): number { const v = this.dv.getUint16(this.pos, this.little); this.pos += 2; return v }
|
|
32
|
+
i32(): number { const v = this.dv.getInt32(this.pos, this.little); this.pos += 4; return v }
|
|
33
|
+
u32(): number { const v = this.dv.getUint32(this.pos, this.little); this.pos += 4; return v }
|
|
34
|
+
i64(): bigint { const v = this.dv.getBigInt64(this.pos, this.little); this.pos += 8; return v }
|
|
35
|
+
u64(): bigint { const v = this.dv.getBigUint64(this.pos, this.little); this.pos += 8; return v }
|
|
36
|
+
f32(): number { const v = this.dv.getFloat32(this.pos, this.little); this.pos += 4; return v }
|
|
37
|
+
f64(): number { const v = this.dv.getFloat64(this.pos, this.little); this.pos += 8; return v }
|
|
38
|
+
align(n = 4): void { this.pos = (this.pos + n - 1) & ~(n - 1) }
|
|
39
|
+
cstr(): string { let e = this.pos; while (e < this.bytes.length && this.bytes[e] !== 0) e++; const s = new TextDecoder().decode(this.bytes.subarray(this.pos, e)); this.pos = e + 1; return s }
|
|
40
|
+
take(n: number): Uint8Array { const b = this.bytes.subarray(this.pos, this.pos + n); this.pos += n; return b }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---- the file -------------------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
export type SerializedFile = {
|
|
46
|
+
version: number
|
|
47
|
+
unityVersion: string
|
|
48
|
+
objects: SObjectInfo[]
|
|
49
|
+
externals: SExternal[]
|
|
50
|
+
/** Deserialize one object through its type tree. */
|
|
51
|
+
read: (o: SObjectInfo) => UObject
|
|
52
|
+
byPathID: (id: bigint | number | string) => SObjectInfo | undefined
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const isSerializedFile = (bytes: Uint8Array): boolean => {
|
|
56
|
+
if (bytes.length < 20 || (bytes[0] === 0x25 && bytes[1] === 0x59)) return false // "%YAML"
|
|
57
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
58
|
+
const version = dv.getUint32(8)
|
|
59
|
+
return version >= 9 && version <= 30
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const openSerializedFile = (pathOrBytes: string | Uint8Array): SerializedFile => {
|
|
63
|
+
const bytes = typeof pathOrBytes === "string" ? new Uint8Array(readFileSync(pathOrBytes)) : pathOrBytes
|
|
64
|
+
const r = new Reader(bytes, false)
|
|
65
|
+
let metadataSize = r.u32(); let fileSize: number = r.u32()
|
|
66
|
+
const version = r.u32()
|
|
67
|
+
let dataOffset: number = r.u32()
|
|
68
|
+
if (version < 9) throw new Error(`SerializedFile version ${version} is too old (Unity < 5)`)
|
|
69
|
+
const endian = r.u8(); r.take(3)
|
|
70
|
+
if (version >= 22) { metadataSize = r.u32(); fileSize = Number(r.i64()); dataOffset = Number(r.i64()); r.i64() }
|
|
71
|
+
void metadataSize; void fileSize
|
|
72
|
+
r.little = endian === 0
|
|
73
|
+
|
|
74
|
+
const unityVersion = r.cstr()
|
|
75
|
+
r.i32() // target platform
|
|
76
|
+
const typeTree = r.u8() !== 0
|
|
77
|
+
if (!typeTree) throw new Error("SerializedFile has no type tree (a player build?) — only editor-written assets are readable")
|
|
78
|
+
|
|
79
|
+
const readType = (isRef: boolean): SType => {
|
|
80
|
+
const classID = r.i32()
|
|
81
|
+
if (version >= 16) r.u8() // stripped
|
|
82
|
+
const scriptIndex = version >= 17 ? r.i16() : -1
|
|
83
|
+
if (version >= 13) {
|
|
84
|
+
if ((isRef && scriptIndex >= 0) || (version < 16 && classID < 0) || (version >= 16 && classID === 114)) r.take(16)
|
|
85
|
+
r.take(16)
|
|
86
|
+
}
|
|
87
|
+
let root: TypeNode | null = null
|
|
88
|
+
if (version >= 12 || version === 10) root = readTreeBlob()
|
|
89
|
+
else throw new Error(`SerializedFile version ${version}: legacy type tree not supported`)
|
|
90
|
+
if (version >= 21) {
|
|
91
|
+
if (isRef) { r.cstr(); r.cstr(); r.cstr() } else { const n = r.i32(); r.take(n * 4) }
|
|
92
|
+
}
|
|
93
|
+
return { classID, root }
|
|
94
|
+
}
|
|
95
|
+
const readTreeBlob = (): TypeNode => {
|
|
96
|
+
const count = r.i32(), strLen = r.i32()
|
|
97
|
+
const raw: { ver: number, level: number, flags: number, typeOff: number, nameOff: number, byteSize: number, meta: number }[] = []
|
|
98
|
+
for (let i = 0; i < count; i++) {
|
|
99
|
+
const ver = r.u16(), level = r.u8(), flags = r.u8(), typeOff = r.u32(), nameOff = r.u32(), byteSize = r.i32()
|
|
100
|
+
r.i32() // index
|
|
101
|
+
const meta = r.i32()
|
|
102
|
+
if (version >= 19) r.u64() // ref type hash
|
|
103
|
+
raw.push({ ver, level, flags, typeOff, nameOff, byteSize, meta })
|
|
104
|
+
}
|
|
105
|
+
const strs = new TextDecoder().decode(r.take(strLen))
|
|
106
|
+
const str = (off: number): string => (off & 0x80000000 ? cstrAt(COMMON, off & 0x7fffffff) : cstrAt(strs, off))
|
|
107
|
+
const nodes: TypeNode[] = raw.map((n) => ({ type: str(n.typeOff), name: str(n.nameOff), byteSize: n.byteSize, level: n.level, isArray: (n.flags & 1) !== 0, align: (n.meta & 0x4000) !== 0, children: [] }))
|
|
108
|
+
const stack: TypeNode[] = []
|
|
109
|
+
for (const n of nodes) {
|
|
110
|
+
while (stack.length && stack[stack.length - 1].level >= n.level) stack.pop()
|
|
111
|
+
if (stack.length) stack[stack.length - 1].children.push(n)
|
|
112
|
+
stack.push(n)
|
|
113
|
+
}
|
|
114
|
+
return nodes[0]
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const types: SType[] = []
|
|
118
|
+
const typeCount = r.i32()
|
|
119
|
+
for (let i = 0; i < typeCount; i++) types.push(readType(false))
|
|
120
|
+
|
|
121
|
+
if (version >= 7 && version < 14) r.i32() // big ID enabled
|
|
122
|
+
const objects: SObjectInfo[] = []
|
|
123
|
+
const objCount = r.i32()
|
|
124
|
+
for (let i = 0; i < objCount; i++) {
|
|
125
|
+
if (version >= 14) r.align(4)
|
|
126
|
+
const pathID = version >= 14 ? r.i64() : BigInt(r.i32())
|
|
127
|
+
const byteStart = version >= 22 ? Number(r.i64()) : r.u32()
|
|
128
|
+
const byteSize = r.u32()
|
|
129
|
+
const typeID = r.i32()
|
|
130
|
+
let classID: number
|
|
131
|
+
let type: SType
|
|
132
|
+
if (version < 16) { classID = r.u16(); type = types.find((t) => t.classID === typeID) ?? { classID, root: null } }
|
|
133
|
+
else { type = types[typeID]; classID = type.classID }
|
|
134
|
+
if (version < 11) r.u16()
|
|
135
|
+
if (version >= 11 && version < 17) r.i16()
|
|
136
|
+
if (version === 15 || version === 16) r.u8()
|
|
137
|
+
objects.push({ pathID, byteStart: dataOffset + byteStart, byteSize, classID, type })
|
|
138
|
+
}
|
|
139
|
+
if (version >= 11) {
|
|
140
|
+
const n = r.i32()
|
|
141
|
+
for (let i = 0; i < n; i++) { r.i32(); if (version >= 14) { r.align(4); r.i64() } else r.i32() }
|
|
142
|
+
}
|
|
143
|
+
const externals: SExternal[] = []
|
|
144
|
+
const extCount = r.i32()
|
|
145
|
+
for (let i = 0; i < extCount; i++) {
|
|
146
|
+
if (version >= 6) r.cstr()
|
|
147
|
+
let guid = ""
|
|
148
|
+
let type = 0
|
|
149
|
+
if (version >= 5) {
|
|
150
|
+
// GUID bytes → Unity's text form: each byte's nibbles swapped (low nibble first)
|
|
151
|
+
const g = r.take(16)
|
|
152
|
+
for (let k = 0; k < 16; k++) guid += (g[k] & 15).toString(16) + (g[k] >> 4).toString(16)
|
|
153
|
+
type = r.i32()
|
|
154
|
+
}
|
|
155
|
+
externals.push({ guid, type, path: r.cstr() })
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const byId = new Map<string, SObjectInfo>()
|
|
159
|
+
for (const o of objects) byId.set(o.pathID.toString(), o)
|
|
160
|
+
|
|
161
|
+
const read = (o: SObjectInfo): UObject => {
|
|
162
|
+
if (!o.type.root) throw new Error(`object ${o.pathID}: no type tree for class ${o.classID}`)
|
|
163
|
+
const dr = new Reader(bytes, r.little)
|
|
164
|
+
dr.pos = o.byteStart
|
|
165
|
+
const v = readNode(dr, o.type.root)
|
|
166
|
+
return v as UObject
|
|
167
|
+
}
|
|
168
|
+
return { version, unityVersion, objects, externals, read, byPathID: (id) => byId.get(String(id)) }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---- values ---------------------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
const PRIM: Record<string, (r: Reader) => UValue> = {
|
|
174
|
+
bool: (r) => r.u8() !== 0, SInt8: (r) => (r.u8() << 24) >> 24, UInt8: (r) => r.u8(), char: (r) => r.u8(),
|
|
175
|
+
short: (r) => r.i16(), SInt16: (r) => r.i16(), UInt16: (r) => r.u16(), "unsigned short": (r) => r.u16(),
|
|
176
|
+
int: (r) => r.i32(), SInt32: (r) => r.i32(), UInt32: (r) => r.u32(), "unsigned int": (r) => r.u32(), "Type*": (r) => r.u32(),
|
|
177
|
+
"long long": (r) => r.i64(), SInt64: (r) => r.i64(), UInt64: (r) => r.u64(), "unsigned long long": (r) => r.u64(), FileSize: (r) => r.u64(),
|
|
178
|
+
float: (r) => r.f32(), double: (r) => r.f64(),
|
|
179
|
+
}
|
|
180
|
+
// Typed-array fast path for vectors of primitives (heightmaps, index buffers …). Copies, so the
|
|
181
|
+
// result stays valid and aligned whatever the source offset.
|
|
182
|
+
const TYPED: Record<string, (b: Uint8Array, n: number, little: boolean) => UValue> = {
|
|
183
|
+
UInt8: (b) => b.slice(), char: (b) => b.slice(), bool: (b) => b.slice(), SInt8: (b) => new Int8Array(b.slice().buffer),
|
|
184
|
+
SInt16: (b, n, l) => fill(new Int16Array(n), b, 2, (dv, o) => dv.getInt16(o, l)), short: (b, n, l) => fill(new Int16Array(n), b, 2, (dv, o) => dv.getInt16(o, l)),
|
|
185
|
+
UInt16: (b, n, l) => fill(new Uint16Array(n), b, 2, (dv, o) => dv.getUint16(o, l)), "unsigned short": (b, n, l) => fill(new Uint16Array(n), b, 2, (dv, o) => dv.getUint16(o, l)),
|
|
186
|
+
SInt32: (b, n, l) => fill(new Int32Array(n), b, 4, (dv, o) => dv.getInt32(o, l)), int: (b, n, l) => fill(new Int32Array(n), b, 4, (dv, o) => dv.getInt32(o, l)),
|
|
187
|
+
UInt32: (b, n, l) => fill(new Uint32Array(n), b, 4, (dv, o) => dv.getUint32(o, l)), "unsigned int": (b, n, l) => fill(new Uint32Array(n), b, 4, (dv, o) => dv.getUint32(o, l)),
|
|
188
|
+
float: (b, n, l) => fill(new Float32Array(n), b, 4, (dv, o) => dv.getFloat32(o, l)),
|
|
189
|
+
double: (b, n, l) => fill(new Float64Array(n), b, 8, (dv, o) => dv.getFloat64(o, l)),
|
|
190
|
+
}
|
|
191
|
+
const fill = <T extends { length: number, [i: number]: number }>(out: T, b: Uint8Array, size: number, get: (dv: DataView, o: number) => number): T => {
|
|
192
|
+
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength)
|
|
193
|
+
for (let i = 0; i < out.length; i++) out[i] = get(dv, i * size)
|
|
194
|
+
return out
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const readArray = (r: Reader, node: TypeNode): UValue => {
|
|
198
|
+
// node = the "Array" node: children [size, data]
|
|
199
|
+
const n = r.i32()
|
|
200
|
+
const elem = node.children[1]
|
|
201
|
+
let out: UValue
|
|
202
|
+
if (elem.children.length === 0 && TYPED[elem.type] && elem.byteSize > 0) out = TYPED[elem.type](r.take(n * elem.byteSize), n, r.little)
|
|
203
|
+
else { const a: UValue[] = []; for (let i = 0; i < n; i++) a.push(readNode(r, elem)); out = a }
|
|
204
|
+
if (node.align) r.align(4)
|
|
205
|
+
return out
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const readNode = (r: Reader, node: TypeNode): UValue => {
|
|
209
|
+
let v: UValue
|
|
210
|
+
if (node.isArray) return readArray(r, node)
|
|
211
|
+
if (node.type === "string") {
|
|
212
|
+
const n = r.i32()
|
|
213
|
+
v = new TextDecoder().decode(r.take(n))
|
|
214
|
+
if (node.align || node.children[0]?.align) r.align(4)
|
|
215
|
+
return v
|
|
216
|
+
}
|
|
217
|
+
if (node.type === "TypelessData") {
|
|
218
|
+
const n = r.i32()
|
|
219
|
+
v = r.take(n).slice()
|
|
220
|
+
if (node.align) r.align(4)
|
|
221
|
+
return v
|
|
222
|
+
}
|
|
223
|
+
if (node.children.length === 0) {
|
|
224
|
+
const p = PRIM[node.type]
|
|
225
|
+
if (!p) throw new Error(`unknown primitive '${node.type}' (${node.name})`)
|
|
226
|
+
v = p(r)
|
|
227
|
+
} else if (node.children.length === 1 && node.children[0].isArray) {
|
|
228
|
+
v = readArray(r, node.children[0])
|
|
229
|
+
} else {
|
|
230
|
+
const o: UObject = {}
|
|
231
|
+
for (const c of node.children) o[c.name] = readNode(r, c)
|
|
232
|
+
v = o
|
|
233
|
+
}
|
|
234
|
+
if (node.align) r.align(4)
|
|
235
|
+
return v
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** A PPtr field → { fileID, pathID } (fileID 0 = this file, n = externals[n − 1]). */
|
|
239
|
+
export const pptr = (v: UValue | undefined): { fileID: number, pathID: bigint } => {
|
|
240
|
+
const o = (v && typeof v === "object" && !ArrayBuffer.isView(v) && !Array.isArray(v) ? v : {}) as UObject
|
|
241
|
+
return { fileID: Number(o.m_FileID ?? 0), pathID: BigInt((o.m_PathID as number | bigint | undefined) ?? 0) }
|
|
242
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Unity TerrainData (`.asset`, binary-serialised — the editor always writes it binary) → what a
|
|
2
|
+
// LeCodes terrain and the level around it need: the heightmap in metres, the splat weights per sample,
|
|
3
|
+
// the terrain layers (textures + tiling from the `.terrainlayer` files), and the TREES and DETAIL
|
|
4
|
+
// objects painted on it as ordinary placements of their prefabs.
|
|
5
|
+
//
|
|
6
|
+
// Frames: Unity's terrain grows +X across columns and +Z across rows from its transform. Everything
|
|
7
|
+
// here stays in UNITY's frame (the heights row-major, row = z); the caller mirrors — see
|
|
8
|
+
// `toLeCodesGrid`.
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from "node:fs"
|
|
11
|
+
import { isSerializedFile, openSerializedFile, pptr, type SerializedFile, type UObject, type UValue } from "./serialized"
|
|
12
|
+
import { num, readUnityObjects, ref, vec3, isMap, type Vec3 } from "./yaml"
|
|
13
|
+
import type { GuidResolver } from "./guid"
|
|
14
|
+
|
|
15
|
+
export type UnityTerrainLayer = {
|
|
16
|
+
name: string
|
|
17
|
+
/** Absolute paths (null = not found). */
|
|
18
|
+
diffuse: string | null
|
|
19
|
+
normal: string | null
|
|
20
|
+
mask: string | null
|
|
21
|
+
/** Metres per texture repeat (Unity m_TileSize). */
|
|
22
|
+
tileSize: [number, number]
|
|
23
|
+
normalScale: number
|
|
24
|
+
metallic: number
|
|
25
|
+
smoothness: number
|
|
26
|
+
/** Unity `m_DiffuseRemapMax` RGB — a per-channel scale on the sampled albedo (a tint the artist dialled on the layer;
|
|
27
|
+
* remap-min is taken as 0). 1 = untouched. */
|
|
28
|
+
albedoScale: [number, number, number]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type UnityTreePlacement = {
|
|
32
|
+
prefab: string // prefab GUID
|
|
33
|
+
/** Unity world position (terrain origin applied). */
|
|
34
|
+
position: Vec3
|
|
35
|
+
/** Yaw in radians (Unity: around +Y). */
|
|
36
|
+
rotation: number
|
|
37
|
+
widthScale: number
|
|
38
|
+
heightScale: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type UnityTerrain = {
|
|
42
|
+
name: string
|
|
43
|
+
/** Samples per side. */
|
|
44
|
+
resolution: number
|
|
45
|
+
/** Terrain size in metres (x, height, z). */
|
|
46
|
+
size: Vec3
|
|
47
|
+
/** Transform position of the terrain object (Unity world). */
|
|
48
|
+
origin: Vec3
|
|
49
|
+
/** Heights in metres above `origin.y`, row-major (row = z), resolution². */
|
|
50
|
+
heights: Float32Array
|
|
51
|
+
/** Per-layer weight maps at `alphaResolution`², 0–255, row-major (row = z); one array per layer. */
|
|
52
|
+
weights: Uint8Array[]
|
|
53
|
+
alphaResolution: number
|
|
54
|
+
layers: UnityTerrainLayer[]
|
|
55
|
+
trees: UnityTreePlacement[]
|
|
56
|
+
details: UnityTreePlacement[]
|
|
57
|
+
/** Prototypes the importer could not use (texture-billboard grass, missing prefabs). */
|
|
58
|
+
skipped: string[]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const obj = (v: UValue | undefined): UObject => (v && typeof v === "object" && !Array.isArray(v) && !ArrayBuffer.isView(v) ? v as UObject : {})
|
|
62
|
+
const arr = (v: UValue | undefined): UValue[] => (Array.isArray(v) ? v : [])
|
|
63
|
+
|
|
64
|
+
/** The GUID of an external PPtr (fileID ≥ 1), null for a local / empty one. */
|
|
65
|
+
const extGuid = (f: SerializedFile, v: UValue | undefined): string | null => {
|
|
66
|
+
const p = pptr(v)
|
|
67
|
+
if (p.fileID <= 0) return null
|
|
68
|
+
return f.externals[p.fileID - 1]?.guid ?? null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const readLayer = (resolver: GuidResolver, guid: string): UnityTerrainLayer | null => {
|
|
72
|
+
const path = resolver.resolve(guid)
|
|
73
|
+
if (!path) return null
|
|
74
|
+
const bytes = new Uint8Array(readFileSync(path))
|
|
75
|
+
let body: Record<string, unknown> = {}
|
|
76
|
+
let name = path.replace(/^.*[\\/]/, "").replace(/\.terrainlayer$/i, "")
|
|
77
|
+
if (isSerializedFile(bytes)) {
|
|
78
|
+
const f = openSerializedFile(bytes)
|
|
79
|
+
const o = f.objects[0]
|
|
80
|
+
if (!o) return null
|
|
81
|
+
const v = f.read(o)
|
|
82
|
+
const g = (k: string): string | null => extGuid(f, v[k])
|
|
83
|
+
const ts = obj(v.m_TileSize)
|
|
84
|
+
return {
|
|
85
|
+
name: String(v.m_Name ?? name), diffuse: resolvePath(resolver, g("m_DiffuseTexture")), normal: resolvePath(resolver, g("m_NormalMapTexture")),
|
|
86
|
+
mask: resolvePath(resolver, g("m_MaskMapTexture")), tileSize: [ Number(ts.x ?? 15), Number(ts.y ?? 15) ],
|
|
87
|
+
normalScale: Number(v.m_NormalScale ?? 1), metallic: Number(v.m_Metallic ?? 0), smoothness: Number(v.m_Smoothness ?? 0),
|
|
88
|
+
albedoScale: [ Number(obj(v.m_DiffuseRemapMax).x ?? 1), Number(obj(v.m_DiffuseRemapMax).y ?? 1), Number(obj(v.m_DiffuseRemapMax).z ?? 1) ],
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const objs = readUnityObjects(new TextDecoder().decode(bytes))
|
|
92
|
+
body = objs.find((o) => o.cls === "TerrainLayer")?.body ?? {}
|
|
93
|
+
const b = body as Record<string, never>
|
|
94
|
+
name = String(b.m_Name || name)
|
|
95
|
+
const g = (k: string): string | null => ref(b[k]).guid ?? null
|
|
96
|
+
const ts = vec3(b.m_TileSize, [ 15, 15, 0 ])
|
|
97
|
+
return {
|
|
98
|
+
name, diffuse: resolvePath(resolver, g("m_DiffuseTexture")), normal: resolvePath(resolver, g("m_NormalMapTexture")),
|
|
99
|
+
mask: resolvePath(resolver, g("m_MaskMapTexture")), tileSize: [ ts[0], ts[1] ],
|
|
100
|
+
normalScale: num(b.m_NormalScale, 1), metallic: num(b.m_Metallic, 0), smoothness: num(b.m_Smoothness, 0),
|
|
101
|
+
albedoScale: (() => { const r = vec3(b.m_DiffuseRemapMax, [ 1, 1, 1 ]); return [ r[0], r[1], r[2] ] as [number, number, number] })(),
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const resolvePath = (r: GuidResolver, g: string | null): string | null => (g ? r.resolve(g) : null)
|
|
105
|
+
|
|
106
|
+
/** Unity's heightmap integers are 0 … 32766 for 0 … size.y. */
|
|
107
|
+
const HEIGHT_MAX = 32766
|
|
108
|
+
|
|
109
|
+
// Unity scatters detail objects inside each cell with its own RNG; a seeded jitter is the honest
|
|
110
|
+
// stand-in (same counts per cell, positions deterministic across runs).
|
|
111
|
+
const mulberry = (seed: number): (() => number) => () => {
|
|
112
|
+
seed |= 0; seed = (seed + 0x6d2b79f5) | 0
|
|
113
|
+
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
|
|
114
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
|
115
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const readUnityTerrain = (terrainDataPath: string, origin: Vec3, resolver: GuidResolver): UnityTerrain => {
|
|
119
|
+
const bytes = new Uint8Array(readFileSync(terrainDataPath))
|
|
120
|
+
if (!isSerializedFile(bytes)) throw new Error(`${terrainDataPath}: a text-serialised TerrainData is not supported (Unity writes them binary)`)
|
|
121
|
+
const f = openSerializedFile(bytes)
|
|
122
|
+
const tdInfo = f.objects.find((o) => o.classID === 156)
|
|
123
|
+
if (!tdInfo) throw new Error(`${terrainDataPath}: no TerrainData inside`)
|
|
124
|
+
const td = f.read(tdInfo)
|
|
125
|
+
const hm = obj(td.m_Heightmap)
|
|
126
|
+
const res = Number(hm.m_Resolution)
|
|
127
|
+
const scale = obj(hm.m_Scale)
|
|
128
|
+
const size: Vec3 = [ Number(scale.x) * (res - 1), Number(scale.y), Number(scale.z) * (res - 1) ]
|
|
129
|
+
const raw = hm.m_Heights as Int16Array
|
|
130
|
+
const heights = new Float32Array(res * res)
|
|
131
|
+
for (let i = 0; i < heights.length; i++) heights[i] = (raw[i] / HEIGHT_MAX) * size[1]
|
|
132
|
+
|
|
133
|
+
// splat weights: every alpha texture carries four layers in R, G, B, A
|
|
134
|
+
const splat = obj(td.m_SplatDatabase)
|
|
135
|
+
const layerGuids = arr(splat.m_TerrainLayers).map((v) => extGuid(f, v))
|
|
136
|
+
const layers: UnityTerrainLayer[] = []
|
|
137
|
+
const skipped: string[] = []
|
|
138
|
+
for (const g of layerGuids) {
|
|
139
|
+
const l = g ? readLayer(resolver, g) : null
|
|
140
|
+
if (l) layers.push(l)
|
|
141
|
+
else { skipped.push(`terrain layer ${g ?? "?"}`); layers.push({ name: "missing", diffuse: null, normal: null, mask: null, tileSize: [ 15, 15 ], normalScale: 1, metallic: 0, smoothness: 0, albedoScale: [ 1, 1, 1 ] }) }
|
|
142
|
+
}
|
|
143
|
+
const weights: Uint8Array[] = []
|
|
144
|
+
let alphaRes = Number(splat.m_AlphamapResolution ?? 0)
|
|
145
|
+
arr(splat.m_AlphaTextures).forEach((v) => {
|
|
146
|
+
const info = f.byPathID(pptr(v).pathID)
|
|
147
|
+
const tex = info ? f.read(info) : null
|
|
148
|
+
if (!tex) return
|
|
149
|
+
const w = Number(tex.m_Width), h = Number(tex.m_Height), fmt = Number(tex.m_TextureFormat)
|
|
150
|
+
const img = tex["image data"] as Uint8Array
|
|
151
|
+
alphaRes = w
|
|
152
|
+
// TextureFormat: 4 = RGBA32 (bytes R G B A), 5 = ARGB32 (bytes A R G B)
|
|
153
|
+
const chan = fmt === 5 ? [ 1, 2, 3, 0 ] : fmt === 4 ? [ 0, 1, 2, 3 ] : null
|
|
154
|
+
if (!chan || !img || img.length < w * h * 4) { skipped.push(`splat ${String(tex.m_Name)} (format ${fmt})`); for (let c = 0; c < 4; c++) weights.push(new Uint8Array(w * h)); return }
|
|
155
|
+
for (let c = 0; c < 4; c++) {
|
|
156
|
+
const out = new Uint8Array(w * h)
|
|
157
|
+
for (let i = 0; i < w * h; i++) out[i] = img[i * 4 + chan[c]]
|
|
158
|
+
weights.push(out)
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
weights.length = layers.length
|
|
162
|
+
|
|
163
|
+
// trees: normalised positions on the terrain, one prefab per prototype
|
|
164
|
+
const dd = obj(td.m_DetailDatabase)
|
|
165
|
+
const protos = arr(dd.m_TreePrototypes).map((p) => extGuid(f, obj(p).prefab))
|
|
166
|
+
const trees: UnityTreePlacement[] = []
|
|
167
|
+
for (const t of arr(dd.m_TreeInstances)) {
|
|
168
|
+
const o = obj(t)
|
|
169
|
+
const g = protos[Number(o.index)]
|
|
170
|
+
if (!g) continue
|
|
171
|
+
const p = obj(o.position)
|
|
172
|
+
trees.push({
|
|
173
|
+
prefab: g,
|
|
174
|
+
position: [ origin[0] + Number(p.x) * size[0], origin[1] + Number(p.y) * size[1], origin[2] + Number(p.z) * size[2] ],
|
|
175
|
+
rotation: Number(o.rotation ?? 0), widthScale: Number(o.widthScale ?? 1), heightScale: Number(o.heightScale ?? 1),
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// details: per patch, per layer, a count per cell — mesh prototypes only (texture grass has no prefab)
|
|
180
|
+
const dprotos = arr(dd.m_DetailPrototypes).map((p) => {
|
|
181
|
+
const o = obj(p)
|
|
182
|
+
const g = Number(o.usePrototypeMesh) ? extGuid(f, o.prototype) : null
|
|
183
|
+
return { guid: g, minW: Number(o.minWidth ?? 1), maxW: Number(o.maxWidth ?? 1), minH: Number(o.minHeight ?? 1), maxH: Number(o.maxHeight ?? 1) }
|
|
184
|
+
})
|
|
185
|
+
const patchCount = Number(dd.m_PatchCount ?? 0), patchSamples = Number(dd.m_PatchSamples ?? 0)
|
|
186
|
+
const details: UnityTreePlacement[] = []
|
|
187
|
+
const rnd = mulberry(0x7e11a1)
|
|
188
|
+
const cellX = size[0] / (patchCount * patchSamples), cellZ = size[2] / (patchCount * patchSamples)
|
|
189
|
+
const heightAt = (x: number, z: number): number => {
|
|
190
|
+
const fx = Math.min(res - 1.0001, Math.max(0, x / size[0] * (res - 1))), fz = Math.min(res - 1.0001, Math.max(0, z / size[2] * (res - 1)))
|
|
191
|
+
const x0 = Math.floor(fx), z0 = Math.floor(fz), tx = fx - x0, tz = fz - z0
|
|
192
|
+
const h = (i: number, j: number): number => heights[j * res + i]
|
|
193
|
+
return (h(x0, z0) * (1 - tx) + h(x0 + 1, z0) * tx) * (1 - tz) + (h(x0, z0 + 1) * (1 - tx) + h(x0 + 1, z0 + 1) * tx) * tz
|
|
194
|
+
}
|
|
195
|
+
arr(dd.m_Patches).forEach((patch, pi) => {
|
|
196
|
+
const o = obj(patch)
|
|
197
|
+
const li = o.layerIndices as Uint8Array
|
|
198
|
+
const counts = o.numberOfObjects as Uint8Array
|
|
199
|
+
if (!li?.length) return
|
|
200
|
+
const px = pi % patchCount, pz = Math.floor(pi / patchCount)
|
|
201
|
+
for (let k = 0; k < li.length; k++) {
|
|
202
|
+
const proto = dprotos[li[k]]
|
|
203
|
+
if (!proto?.guid) { skipped.push(`detail layer ${li[k]} (texture grass)`); continue }
|
|
204
|
+
for (let cz = 0; cz < patchSamples; cz++) for (let cx = 0; cx < patchSamples; cx++) {
|
|
205
|
+
const n = counts[k * patchSamples * patchSamples + cz * patchSamples + cx] ?? 0
|
|
206
|
+
for (let i = 0; i < n; i++) {
|
|
207
|
+
const x = (px * patchSamples + cx + rnd()) * cellX, z = (pz * patchSamples + cz + rnd()) * cellZ
|
|
208
|
+
const w = proto.minW + rnd() * (proto.maxW - proto.minW)
|
|
209
|
+
const hsc = proto.minH + rnd() * (proto.maxH - proto.minH)
|
|
210
|
+
details.push({ prefab: proto.guid, position: [ origin[0] + x, origin[1] + heightAt(x, z), origin[2] + z ], rotation: rnd() * Math.PI * 2, widthScale: w, heightScale: hsc })
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
return { name: String(td.m_Name ?? "terrain"), resolution: res, size, origin, heights, weights, alphaResolution: alphaRes, layers, trees, details, skipped: [ ...new Set(skipped) ] }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The terrain component of a scene: its TerrainData GUID and transform position. */
|
|
220
|
+
export const terrainDataRef = (body: Record<string, unknown>): string | null => ref((body as { m_TerrainData?: never }).m_TerrainData).guid ?? null
|
|
221
|
+
export { isMap }
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// A TRIANGLE BUDGET PER KIND. Packs and hand-assembled projects carry photogrammetry scans as props:
|
|
2
|
+
// Peremena's open herbarium is 1 057 824 triangles (81 MB of GLB) and a single book 170 000 — on a
|
|
3
|
+
// school desk, next to 500-triangle furniture. Imported as they are, they are not merely heavy: their
|
|
4
|
+
// MESH COLLIDER (the scene file's `Shape { mesh: true }` is built from the GLB) took the headless
|
|
5
|
+
// renderer's physics down with an out-of-bounds abort before the first frame.
|
|
6
|
+
//
|
|
7
|
+
// So a kind over the budget is simplified at import (meshoptimizer, the same weld + simplify the doctor's
|
|
8
|
+
// LOD pass runs), all of its primitives by one ratio. A scan's look lives in its normal map, which the
|
|
9
|
+
// simplified surface keeps (the UVs are preserved), and the collider shrinks with it. Skinned meshes are
|
|
10
|
+
// left alone: collapsing vertices under a skin moves weights the budget knows nothing about.
|
|
11
|
+
|
|
12
|
+
import { PropertyType, type Document, type Primitive } from "@gltf-transform/core"
|
|
13
|
+
import { prune, simplifyPrimitive, weldPrimitive } from "@gltf-transform/functions"
|
|
14
|
+
import { MeshoptSimplifier } from "meshoptimizer"
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_MAX_TRIS = 50_000
|
|
17
|
+
|
|
18
|
+
const trisOf = (p: Primitive): number => p.getMode() !== 4 ? 0 : Math.floor((p.getIndices()?.getCount() ?? p.getAttribute("POSITION")?.getCount() ?? 0) / 3)
|
|
19
|
+
|
|
20
|
+
/** Simplify the document's meshes down to about `maxTris` triangles in total. Null when it already fits. */
|
|
21
|
+
export const fitTriangleBudget = async (doc: Document, maxTris: number): Promise<{ before: number, after: number } | null> => {
|
|
22
|
+
// a Set: a kind's parts that share a mesh share its primitives (kindGlb clones meshes shallowly)
|
|
23
|
+
const prims = [ ...new Set(doc.getRoot().listMeshes().flatMap((m) => m.listPrimitives())) ].filter((p) => p.getMode() === 4 && !p.getAttribute("JOINTS_0"))
|
|
24
|
+
const before = prims.reduce((n, p) => n + trisOf(p), 0)
|
|
25
|
+
if (maxTris <= 0 || before <= maxTris) return null
|
|
26
|
+
await MeshoptSimplifier.ready
|
|
27
|
+
const ratio = maxTris / before
|
|
28
|
+
for (const p of prims) {
|
|
29
|
+
const want = trisOf(p) * ratio
|
|
30
|
+
weldPrimitive(p)
|
|
31
|
+
// `error` is the ceiling on how far the surface may move, relative to the mesh's size: a scan has
|
|
32
|
+
// to go down 20× here, which a tight 1 % stops short of
|
|
33
|
+
simplifyPrimitive(p, { simplifier: MeshoptSimplifier, ratio, error: 0.05, lockBorder: false })
|
|
34
|
+
// A FACETED SCAN DOES NOT WELD: every triangle carries its own normals, so no two faces share a
|
|
35
|
+
// vertex and every edge is a seam the simplifier must keep (the herbarium: 2.1 M vertices on 0.53 M
|
|
36
|
+
// positions, 1 057 824 triangles in, 1 057 824 out). Its shading is in the normal map anyway, so the
|
|
37
|
+
// normals go, the mesh welds on position + UV and simplifies, and smooth ones are rebuilt. Tried
|
|
38
|
+
// SECOND, so hard-surface props that simplify with their normals keep their hard edges.
|
|
39
|
+
if (trisOf(p) > want * 1.5 && p.getAttribute("NORMAL")) {
|
|
40
|
+
p.setAttribute("NORMAL", null).setAttribute("TANGENT", null)
|
|
41
|
+
weldPrimitive(p)
|
|
42
|
+
simplifyPrimitive(p, { simplifier: MeshoptSimplifier, ratio: Math.min(1, want / Math.max(1, trisOf(p))), error: 0.05, lockBorder: false })
|
|
43
|
+
smoothNormals(doc, p)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// weld and simplify write NEW accessors: the million-triangle originals stay in the file until pruned
|
|
47
|
+
// (the herbarium was 27 MB at 50 000 triangles without this)
|
|
48
|
+
await doc.transform(prune({ propertyTypes: [ PropertyType.ACCESSOR ] }))
|
|
49
|
+
return { before, after: prims.reduce((n, p) => n + trisOf(p), 0) }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Area-weighted vertex normals, summed per POSITION so a UV seam (two vertices, one point) shades as one. */
|
|
53
|
+
const smoothNormals = (doc: Document, p: Primitive): void => {
|
|
54
|
+
const pos = p.getAttribute("POSITION")!, idx = p.getIndices()
|
|
55
|
+
const n = pos.getCount()
|
|
56
|
+
const P = pos.getArray()!, I = idx ? idx.getArray()! : Uint32Array.from({ length: n }, (_, i) => i)
|
|
57
|
+
const keyOf = new Int32Array(n), sums: number[] = [], byPos = new Map<string, number>()
|
|
58
|
+
for (let i = 0; i < n; i++) {
|
|
59
|
+
const k = `${P[i * 3]},${P[i * 3 + 1]},${P[i * 3 + 2]}`
|
|
60
|
+
let s = byPos.get(k)
|
|
61
|
+
if (s === undefined) { s = byPos.size; byPos.set(k, s); sums.push(0, 0, 0) }
|
|
62
|
+
keyOf[i] = s
|
|
63
|
+
}
|
|
64
|
+
for (let t = 0; t + 2 < I.length; t += 3) {
|
|
65
|
+
const a = I[t] * 3, b = I[t + 1] * 3, c = I[t + 2] * 3
|
|
66
|
+
const ux = P[b] - P[a], uy = P[b + 1] - P[a + 1], uz = P[b + 2] - P[a + 2]
|
|
67
|
+
const vx = P[c] - P[a], vy = P[c + 1] - P[a + 1], vz = P[c + 2] - P[a + 2]
|
|
68
|
+
const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx // |n| = 2 × area
|
|
69
|
+
for (const v of [ I[t], I[t + 1], I[t + 2] ]) { const s = keyOf[v] * 3; sums[s] += nx; sums[s + 1] += ny; sums[s + 2] += nz }
|
|
70
|
+
}
|
|
71
|
+
const out = new Float32Array(n * 3)
|
|
72
|
+
for (let i = 0; i < n; i++) {
|
|
73
|
+
const s = keyOf[i] * 3, l = Math.hypot(sums[s], sums[s + 1], sums[s + 2]) || 1
|
|
74
|
+
out[i * 3] = sums[s] / l; out[i * 3 + 1] = sums[s + 1] / l; out[i * 3 + 2] = sums[s + 2] / l
|
|
75
|
+
}
|
|
76
|
+
p.setAttribute("NORMAL", doc.createAccessor("NORMAL", pos.getBuffer()).setType("VEC3").setArray(out))
|
|
77
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// TRIPLANAR SHADER GRAPHS → REAL UVs.
|
|
2
|
+
//
|
|
3
|
+
// A Unity Shader Graph whose textures go through a Triplanar node samples them by POSITION, not by the
|
|
4
|
+
// mesh's UVs: `uv = position × Tile`, the X face reading `.zy`, the Y face `.xz`, the Z face `.xy`,
|
|
5
|
+
// blended by the normal. The mesh's own UVs are then whatever the modeller left — Peremena's school
|
|
6
|
+
// shell (walls, ceiling) is one such mesh, and imported with its UVs it came out as garbage or, with
|
|
7
|
+
// the graph's `_Diffuse_Tex` / `_Normal_Tex` / `_Roughness_Tex` names unknown, untextured.
|
|
8
|
+
//
|
|
9
|
+
// There is no triplanar material for models in the engine, and a custom one would take the surface out
|
|
10
|
+
// of the lightmap (baked materials are the stock lit PBR). So the projection is BAKED into TEXCOORD_0
|
|
11
|
+
// at import: per vertex, the face the vertex normal points along picks the plane, exactly the node's
|
|
12
|
+
// planes. For the flat, axis-aligned surfaces triplanar is used on (walls, floors, ceilings) that IS
|
|
13
|
+
// the node's picture; the blend zone on a slanted face and a smooth-shaded corner are where it
|
|
14
|
+
// differs. Positions are the KIND's frame: a kind placed without rotation or scale (the school's shell)
|
|
15
|
+
// matches Unity up to where the texture's tiling starts, a rotated or scaled one tiles in its own axes.
|
|
16
|
+
|
|
17
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
18
|
+
import type { Accessor, Document, Primitive } from "@gltf-transform/core"
|
|
19
|
+
import type { GuidResolver } from "./guid"
|
|
20
|
+
import type { UnityMaterial } from "./matFile"
|
|
21
|
+
import type { Mat4 } from "./math"
|
|
22
|
+
|
|
23
|
+
export type Triplanar = { tile: number, space: "world" | "object" }
|
|
24
|
+
|
|
25
|
+
type GraphObject = Record<string, any>
|
|
26
|
+
|
|
27
|
+
/** A `.shadergraph` file: a run of JSON objects back to back (Unity ≥ 2021's multi-JSON format). */
|
|
28
|
+
const graphObjects = (text: string): GraphObject[] => {
|
|
29
|
+
const out: GraphObject[] = []
|
|
30
|
+
let depth = 0, start = -1, inStr = false, esc = false
|
|
31
|
+
for (let i = 0; i < text.length; i++) {
|
|
32
|
+
const ch = text[i]
|
|
33
|
+
if (inStr) { if (esc) esc = false; else if (ch === "\\") esc = true; else if (ch === "\"") inStr = false; continue }
|
|
34
|
+
if (ch === "\"") inStr = true
|
|
35
|
+
else if (ch === "{") { if (depth++ === 0) start = i }
|
|
36
|
+
else if (ch === "}" && --depth === 0) { try { out.push(JSON.parse(text.slice(start, i + 1))) } catch { /* not a graph object */ } }
|
|
37
|
+
}
|
|
38
|
+
return out
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const isType = (o: GraphObject, t: string): boolean => typeof o.m_Type === "string" && o.m_Type.endsWith(t)
|
|
42
|
+
|
|
43
|
+
/** What a graph's Triplanar node samples with: its Tile (a number, or the reference name of the
|
|
44
|
+
* material property wired into it) and its space. Null = no Triplanar node. */
|
|
45
|
+
type GraphTriplanar = { tileProperty: string | null, tileDefault: number, space: "world" | "object" }
|
|
46
|
+
|
|
47
|
+
const readGraph = (file: string): GraphTriplanar | null => {
|
|
48
|
+
const objs = graphObjects(readFileSync(file, "utf8"))
|
|
49
|
+
const byId = new Map<string, GraphObject>()
|
|
50
|
+
for (const o of objs) if (typeof o.m_ObjectId === "string") byId.set(o.m_ObjectId, o)
|
|
51
|
+
const node = objs.find((o) => isType(o, ".TriplanarNode"))
|
|
52
|
+
if (!node) return null
|
|
53
|
+
const slots = (node.m_Slots ?? []).map((s: GraphObject) => byId.get(s.m_Id)).filter(Boolean) as GraphObject[]
|
|
54
|
+
const tileSlot = slots.find((s) => s.m_DisplayName === "Tile")
|
|
55
|
+
const tileDefault = typeof tileSlot?.m_Value === "number" ? tileSlot.m_Value : 1
|
|
56
|
+
let tileProperty: string | null = null
|
|
57
|
+
const graph = objs.find((o) => isType(o, ".GraphData"))
|
|
58
|
+
for (const e of graph?.m_Edges ?? []) {
|
|
59
|
+
if (e.m_InputSlot?.m_Node?.m_Id !== node.m_ObjectId || e.m_InputSlot?.m_SlotId !== tileSlot?.m_Id) continue
|
|
60
|
+
const from = byId.get(e.m_OutputSlot?.m_Node?.m_Id)
|
|
61
|
+
const prop = from && isType(from, ".PropertyNode") ? byId.get(from.m_Property?.m_Id) : undefined
|
|
62
|
+
if (prop) tileProperty = prop.m_OverrideReferenceName || prop.m_DefaultReferenceName || null
|
|
63
|
+
}
|
|
64
|
+
// CoordinateSpace: 0 Object, 1 View, 2 World, 3 Tangent, 4 AbsoluteWorld
|
|
65
|
+
const space = node.m_InputSpace === 0 ? "object" : "world"
|
|
66
|
+
return { tileProperty, tileDefault, space }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Per resolver: shader guid → its triplanar read (null = none / not a graph). */
|
|
70
|
+
const graphs = new WeakMap<GuidResolver, Map<string, GraphTriplanar | null>>()
|
|
71
|
+
|
|
72
|
+
/** The triplanar projection a material's shader samples its textures with, or null. */
|
|
73
|
+
export const triplanarOf = (um: UnityMaterial, resolver: GuidResolver): Triplanar | null => {
|
|
74
|
+
if (!um.shader.startsWith("guid:")) return null
|
|
75
|
+
const guid = um.shader.slice(5)
|
|
76
|
+
let cache = graphs.get(resolver)
|
|
77
|
+
if (!cache) graphs.set(resolver, cache = new Map())
|
|
78
|
+
if (!cache.has(guid)) {
|
|
79
|
+
const file = resolver.resolve(guid)
|
|
80
|
+
cache.set(guid, file && /\.shadergraph$/i.test(file) && existsSync(file) ? readGraph(file) : null)
|
|
81
|
+
}
|
|
82
|
+
const g = cache.get(guid)
|
|
83
|
+
if (!g) return null
|
|
84
|
+
const tile = g.tileProperty !== null ? um.floats[g.tileProperty] ?? g.tileDefault : g.tileDefault
|
|
85
|
+
return { tile: tile > 0 ? tile : 1, space: g.space }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const apply = (m: Mat4, p: number[], w: number): number[] => [
|
|
89
|
+
m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12] * w,
|
|
90
|
+
m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13] * w,
|
|
91
|
+
m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14] * w,
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The projection's UVs for one primitive, as a new TEXCOORD_0 — `matrix` takes the primitive's
|
|
96
|
+
* positions into the frame the projection runs in (the part's node matrix: kind space). Our frame is
|
|
97
|
+
* Unity's mirrored on X, so Unity's `x` is our `−x`; glTF's V runs down where Unity's runs up.
|
|
98
|
+
* TANGENT is dropped: it belonged to the old UVs, and the engine derives one from the new ones.
|
|
99
|
+
*/
|
|
100
|
+
export const projectTriplanar = (doc: Document, prim: Primitive, matrix: Mat4, tile: number): boolean => {
|
|
101
|
+
const pos = prim.getAttribute("POSITION"), nrm = prim.getAttribute("NORMAL")
|
|
102
|
+
if (!pos || !nrm) return false
|
|
103
|
+
const n = pos.getCount()
|
|
104
|
+
const uv = new Float32Array(n * 2)
|
|
105
|
+
const p: number[] = [], q: number[] = []
|
|
106
|
+
// normals go through the inverse transpose; the kind matrices here are rotation × uniform-ish
|
|
107
|
+
// scale, where the matrix itself points the same way — the length is normalised away
|
|
108
|
+
for (let i = 0; i < n; i++) {
|
|
109
|
+
const [ x, y, z ] = apply(matrix, pos.getElement(i, p), 1)
|
|
110
|
+
const [ nx, ny, nz ] = apply(matrix, nrm.getElement(i, q), 0)
|
|
111
|
+
const ax = Math.abs(nx), ay = Math.abs(ny), az = Math.abs(nz)
|
|
112
|
+
const ux = -x // Unity's x
|
|
113
|
+
const [ a, b ] = ax >= ay && ax >= az ? [ z, y ] : ay >= az ? [ ux, z ] : [ ux, y ]
|
|
114
|
+
uv[i * 2] = a * tile
|
|
115
|
+
uv[i * 2 + 1] = 1 - b * tile
|
|
116
|
+
}
|
|
117
|
+
const acc: Accessor = doc.createAccessor(prim.getName() ? `${prim.getName()}_triplanar` : "triplanar")
|
|
118
|
+
.setType("VEC2").setArray(uv).setBuffer(pos.getBuffer())
|
|
119
|
+
prim.setAttribute("TEXCOORD_0", acc)
|
|
120
|
+
prim.setAttribute("TANGENT", null)
|
|
121
|
+
return true
|
|
122
|
+
}
|