nnnotes 0.1.0__py3-none-any.whl
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.
- nnnotes/__init__.py +7 -0
- nnnotes/__main__.py +4 -0
- nnnotes/acb.py +190 -0
- nnnotes/addressables.py +413 -0
- nnnotes/adv.py +243 -0
- nnnotes/advcommand.py +29 -0
- nnnotes/advmedia.py +178 -0
- nnnotes/advscene.py +113 -0
- nnnotes/advui.py +1154 -0
- nnnotes/advvideo.py +684 -0
- nnnotes/atoms/__init__.py +151 -0
- nnnotes/atoms/astc.py +52 -0
- nnnotes/atoms/gltf.py +290 -0
- nnnotes/atoms/mesh.py +289 -0
- nnnotes/atoms/png.py +28 -0
- nnnotes/atoms/reader.py +269 -0
- nnnotes/atoms/sniff.py +198 -0
- nnnotes/atoms/sprite.py +236 -0
- nnnotes/atoms/texture.py +80 -0
- nnnotes/cache.py +289 -0
- nnnotes/catalog.py +373 -0
- nnnotes/catalogdb.py +457 -0
- nnnotes/census.py +276 -0
- nnnotes/cli.py +744 -0
- nnnotes/cli_assets.py +1539 -0
- nnnotes/config.py +238 -0
- nnnotes/contract.py +497 -0
- nnnotes/cri.py +371 -0
- nnnotes/crikey.py +71 -0
- nnnotes/crilips.py +265 -0
- nnnotes/cristages.py +558 -0
- nnnotes/deckdata.py +462 -0
- nnnotes/export.py +1462 -0
- nnnotes/gameapi.py +318 -0
- nnnotes/jsonio.py +98 -0
- nnnotes/languages.py +44 -0
- nnnotes/layout.py +596 -0
- nnnotes/link.py +418 -0
- nnnotes/live.py +115 -0
- nnnotes/live2d.py +235 -0
- nnnotes/liveaudio.py +331 -0
- nnnotes/livenotes.py +291 -0
- nnnotes/liveoptions.py +250 -0
- nnnotes/livescene.py +450 -0
- nnnotes/liveui.py +328 -0
- nnnotes/master.py +251 -0
- nnnotes/modelstages.py +424 -0
- nnnotes/motion.py +131 -0
- nnnotes/objexport.py +1564 -0
- nnnotes/orchestrate.py +630 -0
- nnnotes/plan.py +218 -0
- nnnotes/player.py +392 -0
- nnnotes/room.py +391 -0
- nnnotes/score.py +1139 -0
- nnnotes/shader.py +217 -0
- nnnotes/spot.py +201 -0
- nnnotes/stages.py +285 -0
- nnnotes/store.py +518 -0
- nnnotes/story.py +142 -0
- nnnotes/storyfonts.py +1258 -0
- nnnotes/storyhost.py +1190 -0
- nnnotes/storysite.py +662 -0
- nnnotes/storyvoices.py +102 -0
- nnnotes/textstyle.py +355 -0
- nnnotes/tmpfont.py +636 -0
- nnnotes/typetrees/6000.3.12f1.json +19 -0
- nnnotes/unity.py +368 -0
- nnnotes/viewrules.json +441 -0
- nnnotes/views.py +966 -0
- nnnotes/voices.py +1363 -0
- nnnotes/web.py +1624 -0
- nnnotes/webaudio.py +14 -0
- nnnotes/webmodel.py +397 -0
- nnnotes-0.1.0.dist-info/METADATA +200 -0
- nnnotes-0.1.0.dist-info/RECORD +79 -0
- nnnotes-0.1.0.dist-info/WHEEL +5 -0
- nnnotes-0.1.0.dist-info/entry_points.txt +2 -0
- nnnotes-0.1.0.dist-info/licenses/LICENSE +21 -0
- nnnotes-0.1.0.dist-info/top_level.txt +1 -0
nnnotes/room.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""Spot background prefab -> binary glTF (.glb).
|
|
2
|
+
|
|
3
|
+
A spot background is a few hundred textured cards (MeshFilter + MeshRenderer
|
|
4
|
+
under a Transform hierarchy). Each mesh is baked into prefab space by walking
|
|
5
|
+
the Transform TRS chain, converted from Unity's left-handed space to glTF's
|
|
6
|
+
right-handed space (negate Z, reverse winding), and written with its texture(s)
|
|
7
|
+
embedded. Geometry is read from the mesh's vertex/index buffers; each submesh
|
|
8
|
+
becomes one primitive with the renderer's material of the same index.
|
|
9
|
+
|
|
10
|
+
Materials are translated from the Unity material's shader and that shader's own
|
|
11
|
+
pass render state (culling, blend factors and operations, depth write and test,
|
|
12
|
+
colour mask, alpha to mask, queue tags) -- not from names. A render state value
|
|
13
|
+
that names a property (URP/Lit's `Blend [_SrcBlend] [_DstBlend]`, `Cull [_Cull]`,
|
|
14
|
+
`ZWrite [_ZWrite]`) takes the material's value of that property, else the
|
|
15
|
+
shader's default for it, else the value the shader stores (render_state). Each
|
|
16
|
+
glTF material records the resolved state in `extras.unityRenderState`.
|
|
17
|
+
Unsupported shaders and states raise instead of being approximated. A submesh
|
|
18
|
+
whose material slot is empty (null) is not written: nothing says what it would
|
|
19
|
+
be drawn with; room.json lists it (`nullMaterialSubmeshes`). A MeshFilter with
|
|
20
|
+
no mesh, or a mesh with no triangles, draws nothing; room.json lists the object
|
|
21
|
+
(`nullMeshFilters`, `meshesWithoutTriangles`).
|
|
22
|
+
|
|
23
|
+
The situation's backgroundPosition/Rotation/Scale (spot.json) is not baked in: it
|
|
24
|
+
goes on top of this, as the game applies it to the prefab root. Objects that
|
|
25
|
+
are inactive in the prefab are kept, flagged `extras.unityActive = false`.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import io
|
|
30
|
+
import struct
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
import numpy as np
|
|
34
|
+
import UnityPy
|
|
35
|
+
|
|
36
|
+
from .catalog import Catalog
|
|
37
|
+
from .jsonio import dumps, write_json
|
|
38
|
+
from .unity import SceneGraph, mesh_arrays, texture_image
|
|
39
|
+
|
|
40
|
+
# UnityEngine.Rendering.BlendMode
|
|
41
|
+
BLEND_ONE, BLEND_ZERO, BLEND_SRC_ALPHA, BLEND_ONE_MINUS_SRC_ALPHA = 1, 0, 5, 10
|
|
42
|
+
BLEND_OP_ADD = 0 # UnityEngine.Rendering.BlendOp.Add
|
|
43
|
+
# UnityEngine.Rendering.CullMode
|
|
44
|
+
CULL_OFF, CULL_FRONT, CULL_BACK = 0, 1, 2
|
|
45
|
+
# glTF sampler enums
|
|
46
|
+
NEAREST, LINEAR, LINEAR_MIPMAP_LINEAR, NEAREST_MIPMAP_NEAREST = 9728, 9729, 9987, 9984
|
|
47
|
+
REPEAT, CLAMP, MIRROR = 10497, 33071, 33648
|
|
48
|
+
# UnityEngine.TextureWrapMode
|
|
49
|
+
WRAP = {0: REPEAT, 1: CLAMP, 2: MIRROR, 3: MIRROR}
|
|
50
|
+
UNLIT_SHADERS = ("Unlit/", "Universal Render Pipeline/Unlit")
|
|
51
|
+
# render state of a pass (SerializedShaderState) read by room: record key -> path in m_State (render target 0)
|
|
52
|
+
STATE_FIELDS = {
|
|
53
|
+
"srcBlend": ("rtBlend0", "srcBlend"), "dstBlend": ("rtBlend0", "destBlend"),
|
|
54
|
+
"srcBlendAlpha": ("rtBlend0", "srcBlendAlpha"), "dstBlendAlpha": ("rtBlend0", "destBlendAlpha"),
|
|
55
|
+
"blendOp": ("rtBlend0", "blendOp"), "blendOpAlpha": ("rtBlend0", "blendOpAlpha"),
|
|
56
|
+
"colorMask": ("rtBlend0", "colMask"), "cull": ("culling",), "zWrite": ("zWrite",), "zTest": ("zTest",),
|
|
57
|
+
"alphaToMask": ("alphaToMask",),
|
|
58
|
+
}
|
|
59
|
+
NO_PROPERTY = "<noninit>" # SerializedShaderFloatValue.name of a fixed value
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---- material translation -------------------------------------------------
|
|
63
|
+
def shader_defaults(shader_tt: dict) -> dict:
|
|
64
|
+
"""{property name: default value} of a shader's properties (m_ParsedForm.m_PropInfo, first component)."""
|
|
65
|
+
props = shader_tt["m_ParsedForm"].get("m_PropInfo", {}).get("m_Props", [])
|
|
66
|
+
return {p["m_Name"]: p["m_DefValue[0]"] for p in props if "m_DefValue[0]" in p}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def material_values(mat_tt: dict) -> dict:
|
|
70
|
+
"""{property name: value} of a material's int and float properties."""
|
|
71
|
+
sp = mat_tt["m_SavedProperties"]
|
|
72
|
+
return {**dict(sp.get("m_Ints", [])), **dict(sp.get("m_Floats", []))}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def state_value(x, values: dict, defaults: dict):
|
|
76
|
+
"""One pass render state value. A SerializedShaderFloatValue {val, name} whose name is a property (not
|
|
77
|
+
"<noninit>") takes the material's value of it (`values`), else the shader's default (`defaults`), else `val`;
|
|
78
|
+
a fixed value is `val`. Enum values come back as int."""
|
|
79
|
+
if not isinstance(x, dict):
|
|
80
|
+
return x
|
|
81
|
+
name = x.get("name")
|
|
82
|
+
v = x.get("val")
|
|
83
|
+
if name and name != NO_PROPERTY:
|
|
84
|
+
v = values.get(name, defaults.get(name, v))
|
|
85
|
+
return int(v) if isinstance(v, float) and v.is_integer() else v
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def render_state(mat_tt: dict, shader_tt: dict) -> dict:
|
|
89
|
+
"""The render state (STATE_FIELDS) of the shader's first pass for this material."""
|
|
90
|
+
state, _ = _pass_state(shader_tt)
|
|
91
|
+
values, defaults = material_values(mat_tt), shader_defaults(shader_tt)
|
|
92
|
+
out = {}
|
|
93
|
+
for key, path in STATE_FIELDS.items():
|
|
94
|
+
x = state
|
|
95
|
+
for part in path:
|
|
96
|
+
x = x.get(part, {}) if isinstance(x, dict) else {}
|
|
97
|
+
out[key] = state_value(x, values, defaults) if x != {} else None
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _pass_state(shader_tt: dict) -> tuple[dict, dict]:
|
|
102
|
+
"""(render state of the first pass, merged subshader+pass tags)."""
|
|
103
|
+
ss = shader_tt["m_ParsedForm"]["m_SubShaders"][0]
|
|
104
|
+
ps = ss["m_Passes"][0]
|
|
105
|
+
tags = dict(ss.get("m_Tags", {}).get("tags", []))
|
|
106
|
+
tags.update(dict(ps.get("m_Tags", {}).get("tags", [])))
|
|
107
|
+
return ps["m_State"], tags
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def translate_material(mat_tt: dict, shader_tt: dict) -> dict:
|
|
111
|
+
"""Unity material + shader -> glTF material fields (texture filled by caller)."""
|
|
112
|
+
shader_name = shader_tt["m_ParsedForm"]["m_Name"]
|
|
113
|
+
_, tags = _pass_state(shader_tt)
|
|
114
|
+
rs = render_state(mat_tt, shader_tt)
|
|
115
|
+
src, dst, cull = rs["srcBlend"], rs["dstBlend"], rs["cull"]
|
|
116
|
+
floats = dict(mat_tt["m_SavedProperties"]["m_Floats"])
|
|
117
|
+
colors = dict(mat_tt["m_SavedProperties"]["m_Colors"])
|
|
118
|
+
|
|
119
|
+
unlit = shader_name.startswith(UNLIT_SHADERS)
|
|
120
|
+
if not unlit and shader_name != "Universal Render Pipeline/Lit":
|
|
121
|
+
raise NotImplementedError(f"shader not translated: {shader_name}")
|
|
122
|
+
|
|
123
|
+
out: dict = {"name": mat_tt["m_Name"], "extras": {"unityShader": shader_name, "unityRenderState": rs}}
|
|
124
|
+
if (src, dst) == (BLEND_ONE, BLEND_ZERO):
|
|
125
|
+
cutout = tags.get("RenderType") == "TransparentCutout" or tags.get("QUEUE") == "AlphaTest"
|
|
126
|
+
if cutout:
|
|
127
|
+
out["alphaMode"] = "MASK"
|
|
128
|
+
out["alphaCutoff"] = floats["_Cutoff"]
|
|
129
|
+
elif (src, dst) == (BLEND_SRC_ALPHA, BLEND_ONE_MINUS_SRC_ALPHA):
|
|
130
|
+
if rs["blendOp"] not in (None, BLEND_OP_ADD):
|
|
131
|
+
raise NotImplementedError(f"blend op {rs['blendOp']} in {shader_name}")
|
|
132
|
+
out["alphaMode"] = "BLEND"
|
|
133
|
+
else:
|
|
134
|
+
raise NotImplementedError(f"blend {src}/{dst} in {shader_name}")
|
|
135
|
+
if cull == CULL_OFF:
|
|
136
|
+
out["doubleSided"] = True
|
|
137
|
+
elif cull != CULL_BACK:
|
|
138
|
+
raise NotImplementedError(f"cull mode {cull} in {shader_name}")
|
|
139
|
+
|
|
140
|
+
pbr: dict = {}
|
|
141
|
+
if unlit:
|
|
142
|
+
out["extensions"] = {"KHR_materials_unlit": {}}
|
|
143
|
+
pbr.update(metallicFactor=0.0, roughnessFactor=1.0)
|
|
144
|
+
else:
|
|
145
|
+
c = colors.get("_BaseColor")
|
|
146
|
+
if c:
|
|
147
|
+
pbr["baseColorFactor"] = [c["r"], c["g"], c["b"], c["a"]]
|
|
148
|
+
pbr["metallicFactor"] = floats.get("_Metallic", 0.0)
|
|
149
|
+
pbr["roughnessFactor"] = 1.0 - floats.get("_Smoothness", 0.5)
|
|
150
|
+
out["pbrMetallicRoughness"] = pbr
|
|
151
|
+
return out
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _main_tex_slot(shader_name: str) -> str:
|
|
155
|
+
return "_BaseMap" if shader_name.startswith("Universal Render Pipeline/") else "_MainTex"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _sampler(tex_tt: dict) -> dict:
|
|
159
|
+
s = tex_tt.get("m_TextureSettings", {})
|
|
160
|
+
mips = tex_tt.get("m_MipCount", 1) > 1
|
|
161
|
+
fm = s.get("m_FilterMode", 1)
|
|
162
|
+
mag = NEAREST if fm == 0 else LINEAR
|
|
163
|
+
if fm == 0:
|
|
164
|
+
mn = NEAREST_MIPMAP_NEAREST if mips else NEAREST
|
|
165
|
+
else:
|
|
166
|
+
mn = LINEAR_MIPMAP_LINEAR if mips else LINEAR
|
|
167
|
+
return {"magFilter": mag, "minFilter": mn,
|
|
168
|
+
"wrapS": WRAP.get(s.get("m_WrapU", 0), REPEAT),
|
|
169
|
+
"wrapT": WRAP.get(s.get("m_WrapV", 0), REPEAT)}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---- glTF builder -------------------------------------------------------
|
|
173
|
+
class _Glb:
|
|
174
|
+
def __init__(self):
|
|
175
|
+
self.bin = bytearray()
|
|
176
|
+
self.bufferViews, self.accessors, self.meshes, self.nodes = [], [], [], []
|
|
177
|
+
self.materials, self.textures, self.images, self.samplers = [], [], [], []
|
|
178
|
+
self.ext_used: set[str] = set()
|
|
179
|
+
|
|
180
|
+
def _view(self, data: bytes, target=None) -> int:
|
|
181
|
+
while len(self.bin) % 4:
|
|
182
|
+
self.bin.append(0)
|
|
183
|
+
bv = {"buffer": 0, "byteOffset": len(self.bin), "byteLength": len(data)}
|
|
184
|
+
if target:
|
|
185
|
+
bv["target"] = target
|
|
186
|
+
self.bin.extend(data)
|
|
187
|
+
self.bufferViews.append(bv)
|
|
188
|
+
return len(self.bufferViews) - 1
|
|
189
|
+
|
|
190
|
+
def accessor_f32(self, arr: np.ndarray, kind: str, minmax=False) -> int:
|
|
191
|
+
a = np.ascontiguousarray(arr, dtype="<f4")
|
|
192
|
+
acc = {"bufferView": self._view(a.tobytes(), 34962), "componentType": 5126,
|
|
193
|
+
"count": int(arr.shape[0]), "type": kind}
|
|
194
|
+
if minmax:
|
|
195
|
+
acc["min"] = a.min(0).tolist()
|
|
196
|
+
acc["max"] = a.max(0).tolist()
|
|
197
|
+
self.accessors.append(acc)
|
|
198
|
+
return len(self.accessors) - 1
|
|
199
|
+
|
|
200
|
+
def accessor_idx(self, idx: np.ndarray) -> int:
|
|
201
|
+
a = np.ascontiguousarray(idx, dtype="<u4")
|
|
202
|
+
self.accessors.append({"bufferView": self._view(a.tobytes(), 34963), "componentType": 5125,
|
|
203
|
+
"count": int(idx.shape[0]), "type": "SCALAR"})
|
|
204
|
+
return len(self.accessors) - 1
|
|
205
|
+
|
|
206
|
+
def texture(self, png: bytes, name: str, sampler: dict) -> int:
|
|
207
|
+
self.images.append({"name": name, "bufferView": self._view(png), "mimeType": "image/png"})
|
|
208
|
+
if sampler not in self.samplers:
|
|
209
|
+
self.samplers.append(sampler)
|
|
210
|
+
self.textures.append({"source": len(self.images) - 1,
|
|
211
|
+
"sampler": self.samplers.index(sampler)})
|
|
212
|
+
return len(self.textures) - 1
|
|
213
|
+
|
|
214
|
+
def material(self, m: dict) -> int:
|
|
215
|
+
self.ext_used.update(m.get("extensions", {}).keys())
|
|
216
|
+
self.materials.append(m)
|
|
217
|
+
return len(self.materials) - 1
|
|
218
|
+
|
|
219
|
+
def add_mesh(self, name, pos, nrm, uv, prims, active=True) -> None:
|
|
220
|
+
"""prims: [(triangle index array, material index)] -- one per submesh."""
|
|
221
|
+
attrs = {"POSITION": self.accessor_f32(pos, "VEC3", minmax=True),
|
|
222
|
+
"TEXCOORD_0": self.accessor_f32(uv, "VEC2")}
|
|
223
|
+
if len(nrm):
|
|
224
|
+
attrs["NORMAL"] = self.accessor_f32(nrm, "VEC3")
|
|
225
|
+
primitives = []
|
|
226
|
+
for idx, material in prims:
|
|
227
|
+
p = {"attributes": attrs, "indices": self.accessor_idx(idx), "mode": 4}
|
|
228
|
+
if material is not None:
|
|
229
|
+
p["material"] = material
|
|
230
|
+
primitives.append(p)
|
|
231
|
+
self.meshes.append({"name": name, "primitives": primitives})
|
|
232
|
+
self.nodes.append({"name": name, "mesh": len(self.meshes) - 1,
|
|
233
|
+
"extras": {"unityActive": active}})
|
|
234
|
+
|
|
235
|
+
def write(self, path: Path, extras: dict):
|
|
236
|
+
gltf = {
|
|
237
|
+
"asset": {"version": "2.0", "generator": "nnnotes/room", "extras": extras},
|
|
238
|
+
"scene": 0, "scenes": [{"nodes": list(range(len(self.nodes)))}],
|
|
239
|
+
"nodes": self.nodes, "meshes": self.meshes, "accessors": self.accessors,
|
|
240
|
+
"bufferViews": self.bufferViews, "buffers": [{"byteLength": len(self.bin)}],
|
|
241
|
+
"materials": self.materials, "textures": self.textures,
|
|
242
|
+
"images": self.images, "samplers": self.samplers,
|
|
243
|
+
}
|
|
244
|
+
if self.ext_used:
|
|
245
|
+
gltf["extensionsUsed"] = sorted(self.ext_used)
|
|
246
|
+
js = dumps(gltf, separators=(",", ":")).encode("utf-8")
|
|
247
|
+
js += b" " * (-len(js) % 4)
|
|
248
|
+
self.bin.extend(b"\0" * (-len(self.bin) % 4))
|
|
249
|
+
with open(path, "wb") as f:
|
|
250
|
+
f.write(struct.pack("<III", 0x46546C67, 2, 12 + 8 + len(js) + 8 + len(self.bin)))
|
|
251
|
+
f.write(struct.pack("<II", len(js), 0x4E4F534A)); f.write(js)
|
|
252
|
+
f.write(struct.pack("<II", len(self.bin), 0x004E4942)); f.write(self.bin)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ---- extraction ---------------------------------------------------------
|
|
256
|
+
def submesh_primitives(tris: list, mats: list, material_for, on_null) -> list:
|
|
257
|
+
"""[(glTF triangle indices, material index)] of a mesh's submeshes: `tris` (per submesh, Unity winding),
|
|
258
|
+
`mats` the renderer's material PPtrs of the same index. An empty submesh gives nothing; a submesh whose material
|
|
259
|
+
slot is empty (path id 0) gives nothing and is reported to `on_null(submesh index)`."""
|
|
260
|
+
prims = []
|
|
261
|
+
for i, t in enumerate(tris):
|
|
262
|
+
if not len(t):
|
|
263
|
+
continue
|
|
264
|
+
if not mats[i].path_id: # empty slot: no material to draw the submesh with
|
|
265
|
+
on_null(i)
|
|
266
|
+
continue
|
|
267
|
+
prims.append((t[:, [0, 2, 1]].reshape(-1).astype(np.uint32), material_for(mats[i])))
|
|
268
|
+
return prims
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def drawn_meshes(mf_by_go: dict, mr_by_go: dict, graph, on_null_mesh):
|
|
272
|
+
"""(transform path id, Mesh, material PPtrs) of each GameObject with a MeshFilter and an enabled MeshRenderer.
|
|
273
|
+
A MeshFilter whose mesh reference is empty (path id 0) draws nothing: its object path goes to
|
|
274
|
+
`on_null_mesh(path)`."""
|
|
275
|
+
for go_pid, mf in mf_by_go.items():
|
|
276
|
+
tf_pid = graph.tf_of_go.get(go_pid)
|
|
277
|
+
mr = mr_by_go.get(go_pid)
|
|
278
|
+
if tf_pid is None or mr is None:
|
|
279
|
+
continue
|
|
280
|
+
if not mr.read_typetree().get("m_Enabled", 1):
|
|
281
|
+
continue
|
|
282
|
+
mesh_ref = mf.read().m_Mesh
|
|
283
|
+
if not mesh_ref.path_id:
|
|
284
|
+
on_null_mesh(graph.path(tf_pid))
|
|
285
|
+
continue
|
|
286
|
+
yield tf_pid, mesh_ref.read(), mr.read().m_Materials
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def extract_room(cat: Catalog, bg_key: str, out_path: Path) -> dict:
|
|
290
|
+
env = UnityPy.load(*[str(p) for p in cat.fetch_key(bg_key)])
|
|
291
|
+
graph = SceneGraph(env)
|
|
292
|
+
mf_by_go, mr_by_go = {}, {}
|
|
293
|
+
for o in env.objects:
|
|
294
|
+
if o.type.name == "MeshFilter":
|
|
295
|
+
mf_by_go[o.read_typetree()["m_GameObject"]["m_PathID"]] = o
|
|
296
|
+
elif o.type.name == "MeshRenderer":
|
|
297
|
+
mr_by_go[o.read_typetree()["m_GameObject"]["m_PathID"]] = o
|
|
298
|
+
|
|
299
|
+
glb = _Glb()
|
|
300
|
+
tex_cache: dict[int, int] = {}
|
|
301
|
+
mat_cache: dict[int, int] = {}
|
|
302
|
+
|
|
303
|
+
def material_for(pptr) -> int:
|
|
304
|
+
if pptr.path_id in mat_cache:
|
|
305
|
+
return mat_cache[pptr.path_id]
|
|
306
|
+
mat = pptr.read()
|
|
307
|
+
mat_tt = mat.object_reader.read_typetree()
|
|
308
|
+
shader_tt = mat.m_Shader.read().object_reader.read_typetree()
|
|
309
|
+
gm = translate_material(mat_tt, shader_tt)
|
|
310
|
+
slot = _main_tex_slot(shader_tt["m_ParsedForm"]["m_Name"])
|
|
311
|
+
for key, env_ in mat.m_SavedProperties.m_TexEnvs:
|
|
312
|
+
if key != slot or not env_.m_Texture.path_id:
|
|
313
|
+
continue
|
|
314
|
+
tid = env_.m_Texture.path_id
|
|
315
|
+
if tid not in tex_cache:
|
|
316
|
+
tex = env_.m_Texture.read()
|
|
317
|
+
buf = io.BytesIO(); texture_image(tex).save(buf, format="PNG")
|
|
318
|
+
tex_cache[tid] = glb.texture(buf.getvalue(), tex.m_Name,
|
|
319
|
+
_sampler(tex.object_reader.read_typetree()))
|
|
320
|
+
info = {"index": tex_cache[tid]}
|
|
321
|
+
sx, sy = env_.m_Scale.x, env_.m_Scale.y
|
|
322
|
+
ox, oy = env_.m_Offset.x, env_.m_Offset.y
|
|
323
|
+
if (sx, sy, ox, oy) != (1.0, 1.0, 0.0, 0.0):
|
|
324
|
+
# Unity samples (u*sx+ox, v*sy+oy) with a bottom-left origin;
|
|
325
|
+
# in glTF's top-left texture space that is this transform.
|
|
326
|
+
info["extensions"] = {"KHR_texture_transform":
|
|
327
|
+
{"scale": [sx, sy], "offset": [ox, 1.0 - sy - oy]}}
|
|
328
|
+
glb.ext_used.add("KHR_texture_transform")
|
|
329
|
+
gm["pbrMetallicRoughness"]["baseColorTexture"] = info
|
|
330
|
+
mat_cache[pptr.path_id] = glb.material(gm)
|
|
331
|
+
return mat_cache[pptr.path_id]
|
|
332
|
+
|
|
333
|
+
n_mesh = 0
|
|
334
|
+
n_inactive = 0
|
|
335
|
+
skipped = []
|
|
336
|
+
null_slots = [] # submeshes whose material slot is empty
|
|
337
|
+
null_meshes = [] # objects whose MeshFilter has no mesh
|
|
338
|
+
no_triangles = [] # meshes with vertices but no triangles
|
|
339
|
+
for tf_pid, mesh, mats in drawn_meshes(mf_by_go, mr_by_go, graph, null_meshes.append):
|
|
340
|
+
arrays = mesh_arrays(mesh)
|
|
341
|
+
if arrays is None:
|
|
342
|
+
skipped.append(mesh.m_Name) # empty mesh (no vertex data)
|
|
343
|
+
continue
|
|
344
|
+
v, vn, uv0, tris = arrays
|
|
345
|
+
if not any(len(t) for t in tris): # vertices only (e.g. a placeholder mesh without index data)
|
|
346
|
+
no_triangles.append({"mesh": mesh.m_Name, "path": graph.path(tf_pid)})
|
|
347
|
+
continue
|
|
348
|
+
if len(mats) < len(tris):
|
|
349
|
+
raise NotImplementedError(f"{mesh.m_Name}: {len(tris)} submeshes, {len(mats)} materials")
|
|
350
|
+
m = graph.world(tf_pid)
|
|
351
|
+
p = (np.hstack([v, np.ones((len(v), 1))]) @ m.T)[:, :3]
|
|
352
|
+
p[:, 2] *= -1.0 # Unity LH -> glTF RH
|
|
353
|
+
if len(vn):
|
|
354
|
+
nw = vn @ np.linalg.inv(m[:3, :3]).T
|
|
355
|
+
nw /= np.maximum(np.linalg.norm(nw, axis=1, keepdims=True), 1e-12)
|
|
356
|
+
nw[:, 2] *= -1.0
|
|
357
|
+
else:
|
|
358
|
+
nw = np.empty((0, 3))
|
|
359
|
+
uv = uv0.copy()
|
|
360
|
+
uv[:, 1] = 1.0 - uv[:, 1] # Unity UV origin bottom-left -> glTF top-left
|
|
361
|
+
prims = submesh_primitives(tris, mats, material_for, lambda i: null_slots.append(
|
|
362
|
+
{"mesh": mesh.m_Name, "path": graph.path(tf_pid), "submesh": i}))
|
|
363
|
+
if not prims:
|
|
364
|
+
continue
|
|
365
|
+
is_active = graph.active(tf_pid)
|
|
366
|
+
glb.add_mesh(mesh.m_Name, p, nw, uv, prims, active=is_active)
|
|
367
|
+
n_mesh += 1
|
|
368
|
+
n_inactive += 0 if is_active else 1
|
|
369
|
+
|
|
370
|
+
out_path = Path(out_path)
|
|
371
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
372
|
+
glb.write(out_path, {"backgroundKey": bg_key})
|
|
373
|
+
doc = {
|
|
374
|
+
"backgroundKey": bg_key, "glb": out_path.name, "meshCount": n_mesh,
|
|
375
|
+
"inactiveMeshes": n_inactive,
|
|
376
|
+
"skippedEmptyMeshes": skipped,
|
|
377
|
+
"nullMaterialSubmeshes": null_slots,
|
|
378
|
+
"nullMeshFilters": null_meshes,
|
|
379
|
+
"meshesWithoutTriangles": no_triangles,
|
|
380
|
+
"materials": [{"name": m["name"], "shader": m["extras"]["unityShader"],
|
|
381
|
+
"alphaMode": m.get("alphaMode", "OPAQUE"),
|
|
382
|
+
"alphaCutoff": m.get("alphaCutoff"),
|
|
383
|
+
"doubleSided": m.get("doubleSided", False),
|
|
384
|
+
"unlit": "KHR_materials_unlit" in m.get("extensions", {}),
|
|
385
|
+
"renderState": m["extras"]["unityRenderState"]}
|
|
386
|
+
for m in glb.materials],
|
|
387
|
+
"textures": [im["name"] for im in glb.images],
|
|
388
|
+
"samplers": glb.samplers,
|
|
389
|
+
}
|
|
390
|
+
write_json(out_path.with_suffix(".json"), doc)
|
|
391
|
+
return doc
|