@threenative/core 0.3.2 → 0.3.4
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/capabilities.json +1891 -101
- package/dist/{assets-kyoF7JlJ.d.ts → assets-CqvE429w.d.ts} +52 -3
- package/dist/{audio-BFiGneTL.d.ts → audio-7i3Xl0l3.d.ts} +32 -1
- package/dist/{canvas-layer-BLVijiUJ.d.ts → canvas-layer-DDmC_VVF.d.ts} +1 -1
- package/dist/{game-XGrTzapq.d.ts → game-CljaDv4D.d.ts} +503 -11
- package/dist/{gpu-readback-D2iRvoe9.d.ts → gpu-readback-CqJEfWNQ.d.ts} +16 -6
- package/dist/hot.d.ts +5 -5
- package/dist/hot.js +20 -3
- package/dist/index.d.ts +1496 -43
- package/dist/index.js +8345 -753
- package/dist/playtest.d.ts +11 -5
- package/dist/playtest.js +96 -11
- package/dist/react.d.ts +2 -2
- package/dist/{renderer-C6hqZpoG.d.ts → renderer-CfsS2hxi.d.ts} +436 -14
- package/dist/ui-layer.d.ts +15 -7
- package/dist/ui-layer.js +2 -1
- package/dist/world.d.ts +900 -14
- package/dist/world.js +10876 -126
- package/gpl/fixtures/make_world_fixture.py +184 -0
- package/gpl/recipes/_common.py +11 -0
- package/gpl/recipes/decimate.py +11 -5
- package/gpl/recipes/export_world.py +682 -0
- package/mcp/blender-server.mjs +55 -1
- package/mcp/engine-server.mjs +84 -22
- package/mcp/servers.mjs +3 -3
- package/package.json +6 -6
- package/patches/three@0.185.1.patch +2228 -105
- package/scripts/apply-three-patch.mjs +61 -37
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
2
|
+
#
|
|
3
|
+
# Copyright (C) 2026 ThreeNative contributors
|
|
4
|
+
#
|
|
5
|
+
# This program is free software; you can redistribute it and/or modify it under the terms of the
|
|
6
|
+
# GNU General Public License as published by the Free Software Foundation; either version 2 of the
|
|
7
|
+
# License, or (at your option) any later version. See ../LICENSE.GPL.
|
|
8
|
+
#
|
|
9
|
+
# Export a Blender-authored world as a ThreeNative world package v1: world.json, a raw uint16
|
|
10
|
+
# heightmap, one GLB per referenced asset (plus a decimated LOD1), cell-split chunk GLBs and a
|
|
11
|
+
# placements.bin of instance transforms. The package format is binding and defined in
|
|
12
|
+
# packages/core/src/world-package.ts; this recipe only writes it.
|
|
13
|
+
#
|
|
14
|
+
# It never picks a look: materials, colours, lights and curves come from the .blend untouched.
|
|
15
|
+
#
|
|
16
|
+
# blender -b world.blend --python export_world.py -- --out /tmp/world --cell 64
|
|
17
|
+
# blender --background --factory-startup --python export_world.py -- \
|
|
18
|
+
# '{"source":"/abs/world.blend","out":"/abs/world","cell":64,"spacing":2}'
|
|
19
|
+
#
|
|
20
|
+
# Conventions, all custom properties: `tn_world_terrain` marks the heightmap mesh, `tn_asset_id`
|
|
21
|
+
# names a scatter source (the object name is the warned fallback), `tn_max_distance` is an asset's
|
|
22
|
+
# cull distance, `tn_lod_distance` overrides the default 60 m LOD1 range, and a collection marked
|
|
23
|
+
# `tn_world_chunk` exports as per-cell chunk GLBs. Hidden, excluded and `_`-prefixed collections are
|
|
24
|
+
# skipped, and so is anything nested under one. Adapt it: the cell size, the LOD ratio and the asset
|
|
25
|
+
# conventions are the parts a game usually wants to change.
|
|
26
|
+
|
|
27
|
+
import array
|
|
28
|
+
import json
|
|
29
|
+
import math
|
|
30
|
+
import os
|
|
31
|
+
import shutil
|
|
32
|
+
import struct
|
|
33
|
+
import sys
|
|
34
|
+
|
|
35
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
36
|
+
|
|
37
|
+
import bpy # noqa: E402
|
|
38
|
+
from mathutils import Matrix # noqa: E402
|
|
39
|
+
from mathutils.bvhtree import BVHTree # noqa: E402
|
|
40
|
+
|
|
41
|
+
from _common import collapse_decimate, emit, fail, load, meshes # noqa: E402
|
|
42
|
+
|
|
43
|
+
RESULT_RECORD_FLOATS = 8
|
|
44
|
+
HEIGHTMAP_MAX = 65535
|
|
45
|
+
LOD_RATIO_DEFAULT = 0.25
|
|
46
|
+
LOD_DISTANCE_DEFAULT = 60.0
|
|
47
|
+
CAPTURE_ENGINE = "TN_WORLD_EXPORT_CAPTURE"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_request():
|
|
51
|
+
"""Accept the JSON request the recipe runner sends, or plain `--key value` flags.
|
|
52
|
+
|
|
53
|
+
`blender -b world.blend --python export_world.py -- --out <dir> --cell <m>` opens the .blend
|
|
54
|
+
first; the same script through `blender_run_python` gets one JSON argument instead.
|
|
55
|
+
"""
|
|
56
|
+
tail = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else []
|
|
57
|
+
if len(tail) == 1:
|
|
58
|
+
try:
|
|
59
|
+
value = json.loads(tail[0])
|
|
60
|
+
except ValueError:
|
|
61
|
+
value = None
|
|
62
|
+
if isinstance(value, dict):
|
|
63
|
+
return value
|
|
64
|
+
payload = {}
|
|
65
|
+
index = 0
|
|
66
|
+
while index < len(tail):
|
|
67
|
+
token = tail[index]
|
|
68
|
+
if token.startswith("--"):
|
|
69
|
+
key = token[2:]
|
|
70
|
+
if "=" in key:
|
|
71
|
+
key, value = key.split("=", 1)
|
|
72
|
+
else:
|
|
73
|
+
index += 1
|
|
74
|
+
if index >= len(tail):
|
|
75
|
+
fail("'--%s' needs a value" % key)
|
|
76
|
+
value = tail[index]
|
|
77
|
+
payload[key] = value
|
|
78
|
+
index += 1
|
|
79
|
+
return payload
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def number(payload, key, default=None):
|
|
83
|
+
raw = payload.get(key, default)
|
|
84
|
+
if raw is None:
|
|
85
|
+
fail("export_world requires a '%s' argument" % key)
|
|
86
|
+
try:
|
|
87
|
+
value = float(raw)
|
|
88
|
+
except (TypeError, ValueError):
|
|
89
|
+
fail("'%s' must be a number, got %r" % (key, raw))
|
|
90
|
+
if not math.isfinite(value) or value <= 0:
|
|
91
|
+
fail("'%s' must be greater than zero, got %r" % (key, raw))
|
|
92
|
+
return value
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def package_path(out, relative):
|
|
96
|
+
"""A package-relative path, refused when it resolves outside `out`.
|
|
97
|
+
|
|
98
|
+
Asset ids and collection names are authored in a .blend and become file names here, so
|
|
99
|
+
`../../escape` is a name like any other until it is joined. Resolve symlinks as well as `..`,
|
|
100
|
+
or a planted link under `out` writes through it and absolute containment sees nothing wrong.
|
|
101
|
+
Refuse it and name the culprit.
|
|
102
|
+
"""
|
|
103
|
+
root = os.path.realpath(out)
|
|
104
|
+
resolved = os.path.realpath(os.path.join(out, relative))
|
|
105
|
+
if os.path.commonpath((root, resolved)) != root:
|
|
106
|
+
fail("'%s' would write outside the output directory" % relative)
|
|
107
|
+
return resolved
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def object_bounds_gltf(obj):
|
|
111
|
+
vertices = obj.data.vertices
|
|
112
|
+
if len(vertices) == 0:
|
|
113
|
+
fail("asset '%s' has no vertices to export" % obj.name)
|
|
114
|
+
xs = [vertex.co.x for vertex in vertices]
|
|
115
|
+
ys = [vertex.co.z for vertex in vertices]
|
|
116
|
+
zs = [-vertex.co.y for vertex in vertices]
|
|
117
|
+
return {"max": [max(xs), max(ys), max(zs)], "min": [min(xs), min(ys), min(zs)]}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def extract(depsgraph, data):
|
|
121
|
+
"""Read everything that needs the render-mode depsgraph into plain Python values.
|
|
122
|
+
|
|
123
|
+
The depsgraph handed to `render()` is only valid for the duration of that call, so nothing
|
|
124
|
+
that outlives it (objects, meshes) is kept — only tuples and numbers.
|
|
125
|
+
"""
|
|
126
|
+
terrain = next(
|
|
127
|
+
(
|
|
128
|
+
obj
|
|
129
|
+
for obj in bpy.data.objects
|
|
130
|
+
if obj.type == "MESH" and obj.get("tn_world_terrain") is not None
|
|
131
|
+
),
|
|
132
|
+
None,
|
|
133
|
+
)
|
|
134
|
+
if terrain is None:
|
|
135
|
+
fail("no mesh carries the 'tn_world_terrain' custom property")
|
|
136
|
+
|
|
137
|
+
evaluated = terrain.evaluated_get(depsgraph)
|
|
138
|
+
mesh = evaluated.to_mesh()
|
|
139
|
+
world = evaluated.matrix_world
|
|
140
|
+
blender_vertices = [world @ vertex.co for vertex in mesh.vertices]
|
|
141
|
+
polygons = [tuple(polygon.vertices) for polygon in mesh.polygons]
|
|
142
|
+
bvh = BVHTree.FromPolygons(
|
|
143
|
+
[tuple(vertex) for vertex in blender_vertices], polygons, all_triangles=False
|
|
144
|
+
)
|
|
145
|
+
evaluated.to_mesh_clear()
|
|
146
|
+
|
|
147
|
+
min_x = min(vertex.x for vertex in blender_vertices)
|
|
148
|
+
max_x = max(vertex.x for vertex in blender_vertices)
|
|
149
|
+
max_z = max(-vertex.y for vertex in blender_vertices)
|
|
150
|
+
min_z = min(-vertex.y for vertex in blender_vertices)
|
|
151
|
+
max_blender_z = max(vertex.z for vertex in blender_vertices)
|
|
152
|
+
min_blender_z = min(vertex.z for vertex in blender_vertices)
|
|
153
|
+
|
|
154
|
+
spacing = data["spacing"]
|
|
155
|
+
span_x = max_x - min_x
|
|
156
|
+
span_z = max_z - min_z
|
|
157
|
+
columns = max(2, int(math.ceil(span_x / spacing)) + 1)
|
|
158
|
+
rows = max(2, int(math.ceil(span_z / spacing)) + 1)
|
|
159
|
+
size_x = (columns - 1) * spacing
|
|
160
|
+
size_z = (rows - 1) * spacing
|
|
161
|
+
|
|
162
|
+
origin_z = max_blender_z + max(1.0, (max_blender_z - min_blender_z) * 0.01)
|
|
163
|
+
reach = origin_z - min_blender_z + spacing * 2.0 + 1.0
|
|
164
|
+
# A ray exactly on the boundary can miss a mesh that ends there; nudge the outermost samples
|
|
165
|
+
# inward by a thousandth of a cell so edge vertices read the edge height instead of the floor.
|
|
166
|
+
epsilon = spacing * 1e-3
|
|
167
|
+
heights = []
|
|
168
|
+
misses = 0
|
|
169
|
+
for row in range(rows):
|
|
170
|
+
gltf_z = min(max(min_z + row * spacing, min_z + epsilon), max_z - epsilon)
|
|
171
|
+
blender_y = -gltf_z
|
|
172
|
+
for column in range(columns):
|
|
173
|
+
gltf_x = min(max(min_x + column * spacing, min_x + epsilon), max_x - epsilon)
|
|
174
|
+
location = bvh.ray_cast((gltf_x, blender_y, origin_z), (0.0, 0.0, -1.0), reach)[0]
|
|
175
|
+
if location is None:
|
|
176
|
+
misses += 1
|
|
177
|
+
heights.append(min_blender_z)
|
|
178
|
+
else:
|
|
179
|
+
heights.append(location.z)
|
|
180
|
+
if misses:
|
|
181
|
+
sys.stderr.write(
|
|
182
|
+
"TN_BLENDER_WARNING: %d terrain grid samples missed the mesh; clamped to its floor\n"
|
|
183
|
+
% misses
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
height_min = min(heights)
|
|
187
|
+
height_max = max(heights)
|
|
188
|
+
if height_max - height_min < 1e-6:
|
|
189
|
+
height_max = height_min + 1.0
|
|
190
|
+
|
|
191
|
+
cell_size = data["cell"]
|
|
192
|
+
max_cell_x = max(1, int(math.ceil(size_x / cell_size)))
|
|
193
|
+
max_cell_z = max(1, int(math.ceil(size_z / cell_size)))
|
|
194
|
+
|
|
195
|
+
asset_objects = {}
|
|
196
|
+
by_asset = {}
|
|
197
|
+
cell_records = {}
|
|
198
|
+
warned_missing = set()
|
|
199
|
+
warned_scale = set()
|
|
200
|
+
for instance in depsgraph.object_instances:
|
|
201
|
+
if not instance.is_instance:
|
|
202
|
+
continue
|
|
203
|
+
source = instance.instance_object
|
|
204
|
+
if source is None:
|
|
205
|
+
continue
|
|
206
|
+
original = getattr(source, "original", None) or source
|
|
207
|
+
asset_id = original.get("tn_asset_id")
|
|
208
|
+
if asset_id is None:
|
|
209
|
+
asset_id = source.name
|
|
210
|
+
if asset_id not in warned_missing:
|
|
211
|
+
warned_missing.add(asset_id)
|
|
212
|
+
sys.stderr.write(
|
|
213
|
+
"TN_BLENDER_WARNING: instanced object '%s' has no tn_asset_id; using its name\n"
|
|
214
|
+
% asset_id
|
|
215
|
+
)
|
|
216
|
+
if not isinstance(asset_id, str) or not asset_id:
|
|
217
|
+
fail("an instance resolved to an empty asset id")
|
|
218
|
+
asset_objects.setdefault(asset_id, source.name)
|
|
219
|
+
|
|
220
|
+
matrix = instance.matrix_world
|
|
221
|
+
translation = matrix.translation
|
|
222
|
+
gltf_x, gltf_y, gltf_z = translation.x, translation.z, -translation.y
|
|
223
|
+
rotation = matrix.to_quaternion()
|
|
224
|
+
quaternion = (rotation.x, rotation.z, -rotation.y, rotation.w)
|
|
225
|
+
axis_scale = matrix.to_scale()
|
|
226
|
+
uniform = (axis_scale.x + axis_scale.y + axis_scale.z) / 3.0
|
|
227
|
+
if uniform > 0:
|
|
228
|
+
deviation = max(
|
|
229
|
+
abs(axis_scale.x - uniform),
|
|
230
|
+
abs(axis_scale.y - uniform),
|
|
231
|
+
abs(axis_scale.z - uniform),
|
|
232
|
+
)
|
|
233
|
+
if deviation / uniform > 0.01 and asset_id not in warned_scale:
|
|
234
|
+
warned_scale.add(asset_id)
|
|
235
|
+
sys.stderr.write(
|
|
236
|
+
"TN_BLENDER_WARNING: asset '%s' has a non-uniform instance scale; using the mean\n"
|
|
237
|
+
% asset_id
|
|
238
|
+
)
|
|
239
|
+
cell_x = min(max_cell_x - 1, max(0, int(math.floor((gltf_x - min_x) / cell_size))))
|
|
240
|
+
cell_z = min(max_cell_z - 1, max(0, int(math.floor((gltf_z - min_z) / cell_size))))
|
|
241
|
+
record = (
|
|
242
|
+
gltf_x,
|
|
243
|
+
gltf_y,
|
|
244
|
+
gltf_z,
|
|
245
|
+
quaternion[0],
|
|
246
|
+
quaternion[1],
|
|
247
|
+
quaternion[2],
|
|
248
|
+
quaternion[3],
|
|
249
|
+
uniform,
|
|
250
|
+
)
|
|
251
|
+
cell_records.setdefault((cell_x, cell_z), {}).setdefault(asset_id, []).append(record)
|
|
252
|
+
by_asset[asset_id] = by_asset.get(asset_id, 0) + 1
|
|
253
|
+
|
|
254
|
+
data.update(
|
|
255
|
+
{
|
|
256
|
+
"asset_objects": asset_objects,
|
|
257
|
+
"by_asset": by_asset,
|
|
258
|
+
"cell_records": cell_records,
|
|
259
|
+
"columns": columns,
|
|
260
|
+
"extent": {"minX": min_x, "minZ": min_z, "sizeX": size_x, "sizeZ": size_z},
|
|
261
|
+
"height_max": height_max,
|
|
262
|
+
"height_min": height_min,
|
|
263
|
+
"heights": heights,
|
|
264
|
+
"max_cell_x": max_cell_x,
|
|
265
|
+
"max_cell_z": max_cell_z,
|
|
266
|
+
"rows": rows,
|
|
267
|
+
}
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def capture_render_depsgraph(data):
|
|
272
|
+
"""Run one render through a throwaway engine, whose `render(self, depsgraph)` receives the
|
|
273
|
+
depsgraph built at render settings. `evaluated_depsgraph_get()` is viewport mode, and viewport
|
|
274
|
+
"share" tricks would otherwise leak into an export that must use render density."""
|
|
275
|
+
|
|
276
|
+
class RenderCapture(bpy.types.RenderEngine):
|
|
277
|
+
bl_idname = CAPTURE_ENGINE
|
|
278
|
+
bl_label = "TN World Export Capture"
|
|
279
|
+
|
|
280
|
+
def render(self, depsgraph):
|
|
281
|
+
extract(depsgraph, data)
|
|
282
|
+
|
|
283
|
+
scene = bpy.context.scene
|
|
284
|
+
temporary_camera = None
|
|
285
|
+
if scene.camera is None:
|
|
286
|
+
camera_data = bpy.data.cameras.new("TNWorldExportCamera")
|
|
287
|
+
temporary_camera = bpy.data.objects.new("TNWorldExportCamera", camera_data)
|
|
288
|
+
scene.collection.objects.link(temporary_camera)
|
|
289
|
+
scene.camera = temporary_camera
|
|
290
|
+
previous_engine = scene.render.engine
|
|
291
|
+
bpy.utils.register_class(RenderCapture)
|
|
292
|
+
try:
|
|
293
|
+
scene.render.engine = CAPTURE_ENGINE
|
|
294
|
+
try:
|
|
295
|
+
bpy.ops.render.render(write_still=False)
|
|
296
|
+
finally:
|
|
297
|
+
scene.render.engine = previous_engine
|
|
298
|
+
finally:
|
|
299
|
+
bpy.utils.unregister_class(RenderCapture)
|
|
300
|
+
if temporary_camera is not None:
|
|
301
|
+
bpy.data.objects.remove(temporary_camera, do_unlink=True)
|
|
302
|
+
if "extent" not in data:
|
|
303
|
+
fail("could not obtain a render-mode depsgraph")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def glb_mesh_count(path):
|
|
307
|
+
"""Meshes in a written GLB, read from its JSON chunk. Zero means no geometry was exported."""
|
|
308
|
+
with open(path, "rb") as handle:
|
|
309
|
+
data = handle.read()
|
|
310
|
+
offset = 12
|
|
311
|
+
while offset + 8 <= len(data):
|
|
312
|
+
length, chunk_type = struct.unpack_from("<II", data, offset)
|
|
313
|
+
offset += 8
|
|
314
|
+
if chunk_type == 0x4E4F534A:
|
|
315
|
+
chunk = data[offset : offset + length].rstrip(b"\x00 ")
|
|
316
|
+
return len(json.loads(chunk.decode("utf-8")).get("meshes", []))
|
|
317
|
+
offset += length
|
|
318
|
+
return 0
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def write_glb(path, objects):
|
|
322
|
+
directory = os.path.dirname(path)
|
|
323
|
+
if directory and not os.path.isdir(directory):
|
|
324
|
+
os.makedirs(directory, exist_ok=True)
|
|
325
|
+
for candidate in bpy.context.view_layer.objects:
|
|
326
|
+
candidate.select_set(False)
|
|
327
|
+
# A scatter source can sit in an excluded collection, or in a visible collection nested under
|
|
328
|
+
# a hidden one. The glTF exporter skips an object that is in neither a visible collection nor
|
|
329
|
+
# an excluded-but-selected one, and Blender refuses to select an object outside the view
|
|
330
|
+
# layer, so relink every object into a temporary visible collection for the export only and
|
|
331
|
+
# put it back where it came from afterwards.
|
|
332
|
+
staging = bpy.data.collections.new("_tn_export_staging")
|
|
333
|
+
bpy.context.scene.collection.children.link(staging)
|
|
334
|
+
previous = []
|
|
335
|
+
for item in objects:
|
|
336
|
+
previous.append((item, list(item.users_collection)))
|
|
337
|
+
for collection in list(item.users_collection):
|
|
338
|
+
collection.objects.unlink(item)
|
|
339
|
+
staging.objects.link(item)
|
|
340
|
+
bpy.context.view_layer.update()
|
|
341
|
+
try:
|
|
342
|
+
for item in objects:
|
|
343
|
+
item.hide_set(False)
|
|
344
|
+
item.hide_viewport = False
|
|
345
|
+
item.hide_render = False
|
|
346
|
+
item.select_set(True)
|
|
347
|
+
bpy.context.view_layer.objects.active = objects[0]
|
|
348
|
+
bpy.ops.export_scene.gltf(
|
|
349
|
+
filepath=path,
|
|
350
|
+
export_apply=True,
|
|
351
|
+
export_format="GLB",
|
|
352
|
+
export_yup=True,
|
|
353
|
+
use_selection=True,
|
|
354
|
+
)
|
|
355
|
+
finally:
|
|
356
|
+
for item, collections in previous:
|
|
357
|
+
for collection in collections:
|
|
358
|
+
collection.objects.link(item)
|
|
359
|
+
bpy.data.collections.remove(staging)
|
|
360
|
+
if not os.path.isfile(path):
|
|
361
|
+
fail("export wrote no file at '%s'" % path)
|
|
362
|
+
if glb_mesh_count(path) < 1:
|
|
363
|
+
fail("export of '%s' wrote no geometry; its source has no exportable mesh" % path)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def skipped_collections(layer, under_hidden=False):
|
|
367
|
+
"""Collection names the recipe must skip, ancestors included.
|
|
368
|
+
|
|
369
|
+
A collection nested under a hidden or excluded one is hidden too, so the chain is walked from
|
|
370
|
+
the view layer down: the rule is about what a player can see, not about one datablock's flags.
|
|
371
|
+
"""
|
|
372
|
+
skipped = set()
|
|
373
|
+
for child in layer.children:
|
|
374
|
+
collection = child.collection
|
|
375
|
+
hidden = (
|
|
376
|
+
under_hidden
|
|
377
|
+
or child.exclude
|
|
378
|
+
or child.hide_viewport
|
|
379
|
+
or collection.name.startswith("_")
|
|
380
|
+
or collection.hide_viewport
|
|
381
|
+
or collection.hide_render
|
|
382
|
+
)
|
|
383
|
+
if hidden:
|
|
384
|
+
skipped.add(collection.name)
|
|
385
|
+
skipped |= skipped_collections(child, hidden)
|
|
386
|
+
return skipped
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def terrain_layer_texture(table_dir, textures, layer_id, kind):
|
|
390
|
+
"""The source file of one texture set's map, searched in the table's `textures.search` dirs."""
|
|
391
|
+
name = textures[kind].replace("{id}", layer_id)
|
|
392
|
+
for relative in textures.get("search", ["."]):
|
|
393
|
+
base = os.path.normpath(os.path.join(table_dir, relative))
|
|
394
|
+
for root, _subdirs, files in os.walk(base):
|
|
395
|
+
if name in files:
|
|
396
|
+
return os.path.join(root, name)
|
|
397
|
+
fail("terrain layers: texture '%s' not found under %s" % (name, textures.get("search", ["."])))
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def terrain_layer_mask(path, size):
|
|
401
|
+
"""A mask image as raw bytes, read through Blender (Non-Color, so values are not converted).
|
|
402
|
+
|
|
403
|
+
Blender stores pixels bottom-up, which is the package's splat row order.
|
|
404
|
+
"""
|
|
405
|
+
import numpy as np
|
|
406
|
+
|
|
407
|
+
image = bpy.data.images.load(path, check_existing=False)
|
|
408
|
+
try:
|
|
409
|
+
image.colorspace_settings.name = "Non-Color"
|
|
410
|
+
if tuple(image.size) != (size, size):
|
|
411
|
+
image.scale(size, size)
|
|
412
|
+
pixels = np.empty(size * size * 4, np.float32)
|
|
413
|
+
image.pixels.foreach_get(pixels)
|
|
414
|
+
finally:
|
|
415
|
+
bpy.data.images.remove(image)
|
|
416
|
+
return np.clip(pixels.reshape(size, size, 4) * 255.0 + 0.5, 0, 255).astype(np.uint8)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def export_terrain_layers(out, table_path, root=None):
|
|
420
|
+
"""Ship a game's terrain material table into the package, for `loadTerrainSplat`.
|
|
421
|
+
|
|
422
|
+
The table (the game's; its DCC shader can read the same file) names its masks, texture sets and
|
|
423
|
+
blend values. This writes `terrain/layers.json` (the table plus the splat layout),
|
|
424
|
+
`terrain/splat.rgba8` (masks packed into RGBA8 planes, raw so no cook converts them) and
|
|
425
|
+
`terrain/tex/<id>_diff.jpg` / `<id>_nrm.jpg`, and returns the `terrain.layers` paths.
|
|
426
|
+
Relative paths in the table resolve against `root` when given, else the table's folder.
|
|
427
|
+
"""
|
|
428
|
+
import numpy as np
|
|
429
|
+
|
|
430
|
+
with open(table_path) as handle:
|
|
431
|
+
table = json.load(handle)
|
|
432
|
+
table_dir = os.path.abspath(root) if root else os.path.dirname(os.path.abspath(table_path))
|
|
433
|
+
size = int(table.get("splatSize", 1024))
|
|
434
|
+
masks = table.get("masks", {})
|
|
435
|
+
if not masks:
|
|
436
|
+
fail("terrain layers: the table names no masks")
|
|
437
|
+
|
|
438
|
+
planes, placed = [], {}
|
|
439
|
+
for name, spec in masks.items():
|
|
440
|
+
if spec.get("channels", "rgb") == "rgb":
|
|
441
|
+
planes.append(np.full((size, size, 4), 255, np.uint8))
|
|
442
|
+
rgba = terrain_layer_mask(os.path.join(table_dir, spec["image"]), size)
|
|
443
|
+
planes[-1][:, :, :3] = rgba[:, :, :3]
|
|
444
|
+
placed[name] = [len(planes) - 1, "rgb"]
|
|
445
|
+
free_alpha = list(range(len(planes)))
|
|
446
|
+
for name, spec in masks.items():
|
|
447
|
+
if spec.get("channels", "rgb") == "rgb":
|
|
448
|
+
continue
|
|
449
|
+
value = terrain_layer_mask(os.path.join(table_dir, spec["image"]), size)[:, :, 0]
|
|
450
|
+
if free_alpha:
|
|
451
|
+
plane = free_alpha.pop(0)
|
|
452
|
+
planes[plane][:, :, 3] = value
|
|
453
|
+
placed[name] = [plane, "a"]
|
|
454
|
+
else:
|
|
455
|
+
planes.append(np.full((size, size, 4), 255, np.uint8))
|
|
456
|
+
planes[-1][:, :, 0] = value
|
|
457
|
+
placed[name] = [len(planes) - 1, "r"]
|
|
458
|
+
|
|
459
|
+
terrain = os.path.join(out, "terrain")
|
|
460
|
+
tex = os.path.join(terrain, "tex")
|
|
461
|
+
shutil.rmtree(tex, ignore_errors=True)
|
|
462
|
+
os.makedirs(tex, exist_ok=True)
|
|
463
|
+
with open(os.path.join(terrain, "splat.rgba8"), "wb") as handle:
|
|
464
|
+
for plane in planes:
|
|
465
|
+
handle.write(np.ascontiguousarray(plane).tobytes())
|
|
466
|
+
|
|
467
|
+
textures = table.get("textures", {})
|
|
468
|
+
for layer in [table["base"], *table["layers"]]:
|
|
469
|
+
shutil.copyfile(
|
|
470
|
+
terrain_layer_texture(table_dir, textures, layer["id"], "diff"),
|
|
471
|
+
os.path.join(tex, "%s_diff.jpg" % layer["id"]),
|
|
472
|
+
)
|
|
473
|
+
if layer.get("normal"):
|
|
474
|
+
# `_nrm` is the asset cook's normal-map convention: linear, lossless UASTC.
|
|
475
|
+
shutil.copyfile(
|
|
476
|
+
terrain_layer_texture(table_dir, textures, layer["id"], "nrm"),
|
|
477
|
+
os.path.join(tex, "%s_nrm.jpg" % layer["id"]),
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
shipped = {key: table[key] for key in ("base", "breakup", "macro", "layers")}
|
|
481
|
+
shipped["splat"] = {"masks": placed, "planes": len(planes), "size": size}
|
|
482
|
+
shipped["textures"] = "terrain/tex"
|
|
483
|
+
with open(os.path.join(terrain, "layers.json"), "w") as handle:
|
|
484
|
+
json.dump(shipped, handle, indent=2, sort_keys=True)
|
|
485
|
+
handle.write("\n")
|
|
486
|
+
return {"splat": "terrain/splat.rgba8", "table": "terrain/layers.json"}
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def main():
|
|
490
|
+
payload = parse_request()
|
|
491
|
+
out = payload.get("out")
|
|
492
|
+
if not isinstance(out, str) or not out:
|
|
493
|
+
fail("export_world requires an 'out' directory")
|
|
494
|
+
out = os.path.abspath(out)
|
|
495
|
+
terrain_table = payload.get("terrain-layers")
|
|
496
|
+
terrain_root = payload.get("terrain-root") or None
|
|
497
|
+
if payload.get("terrain-only"):
|
|
498
|
+
# Re-ship only the terrain layers into an existing package: no scene, no re-export.
|
|
499
|
+
if not isinstance(terrain_table, str) or not terrain_table:
|
|
500
|
+
fail("export_world terrain-only needs 'terrain-layers'")
|
|
501
|
+
manifest_path = os.path.join(out, "world.json")
|
|
502
|
+
with open(manifest_path) as handle:
|
|
503
|
+
manifest = json.load(handle)
|
|
504
|
+
manifest["terrain"]["layers"] = export_terrain_layers(out, terrain_table, terrain_root)
|
|
505
|
+
with open(manifest_path, "w") as handle:
|
|
506
|
+
json.dump(manifest, handle, indent=2, sort_keys=True)
|
|
507
|
+
handle.write("\n")
|
|
508
|
+
emit({"out": out, "recipe": "export_world", "terrainLayers": manifest["terrain"]["layers"]})
|
|
509
|
+
return
|
|
510
|
+
source = payload.get("source")
|
|
511
|
+
if isinstance(source, str) and source:
|
|
512
|
+
load(source)
|
|
513
|
+
elif not bpy.data.filepath:
|
|
514
|
+
fail("export_world needs a 'source' .blend or an already-open file")
|
|
515
|
+
|
|
516
|
+
for directory in ("terrain", "assets", "chunks"):
|
|
517
|
+
os.makedirs(os.path.join(out, directory), exist_ok=True)
|
|
518
|
+
|
|
519
|
+
data = {"cell": number(payload, "cell"), "spacing": number(payload, "spacing", 2.0)}
|
|
520
|
+
capture_render_depsgraph(data)
|
|
521
|
+
|
|
522
|
+
spacing = data["spacing"]
|
|
523
|
+
columns, rows = data["columns"], data["rows"]
|
|
524
|
+
height_min, height_max = data["height_min"], data["height_max"]
|
|
525
|
+
|
|
526
|
+
heightmap = array.array("H")
|
|
527
|
+
for value in data["heights"]:
|
|
528
|
+
heightmap.append(int(round((value - height_min) / (height_max - height_min) * HEIGHTMAP_MAX)))
|
|
529
|
+
if sys.byteorder != "little":
|
|
530
|
+
heightmap.byteswap()
|
|
531
|
+
with open(os.path.join(out, "terrain", "heightmap.u16"), "wb") as handle:
|
|
532
|
+
handle.write(heightmap.tobytes())
|
|
533
|
+
|
|
534
|
+
asset_objects = data["asset_objects"]
|
|
535
|
+
assets = {}
|
|
536
|
+
for asset_id in sorted(asset_objects):
|
|
537
|
+
obj = bpy.data.objects.get(asset_objects[asset_id])
|
|
538
|
+
if obj is None or obj.type != "MESH":
|
|
539
|
+
fail("asset '%s' resolves no mesh object" % asset_id)
|
|
540
|
+
asset_bounds = object_bounds_gltf(obj)
|
|
541
|
+
|
|
542
|
+
relative = "assets/%s.glb" % asset_id
|
|
543
|
+
saved = obj.matrix_world.copy()
|
|
544
|
+
obj.matrix_world = Matrix.Identity(4)
|
|
545
|
+
bpy.context.view_layer.update()
|
|
546
|
+
write_glb(package_path(out, relative), [obj])
|
|
547
|
+
obj.matrix_world = saved
|
|
548
|
+
bpy.context.view_layer.update()
|
|
549
|
+
|
|
550
|
+
lod_object = obj.copy()
|
|
551
|
+
lod_object.data = obj.data.copy()
|
|
552
|
+
lod_object.name = "%s_lod1" % asset_id
|
|
553
|
+
lod_object.data.name = "%s_lod1" % asset_id
|
|
554
|
+
lod_object.matrix_world = Matrix.Identity(4)
|
|
555
|
+
bpy.context.scene.collection.objects.link(lod_object)
|
|
556
|
+
collapse_decimate(lod_object, obj.get("tn_lod_ratio", LOD_RATIO_DEFAULT))
|
|
557
|
+
lod_relative = "assets/%s_lod1.glb" % asset_id
|
|
558
|
+
write_glb(package_path(out, lod_relative), [lod_object])
|
|
559
|
+
lod_mesh = lod_object.data
|
|
560
|
+
bpy.data.objects.remove(lod_object, do_unlink=True)
|
|
561
|
+
bpy.data.meshes.remove(lod_mesh)
|
|
562
|
+
|
|
563
|
+
asset = {
|
|
564
|
+
"bounds": asset_bounds,
|
|
565
|
+
"glb": relative,
|
|
566
|
+
"lods": [
|
|
567
|
+
{
|
|
568
|
+
"distance": float(obj.get("tn_lod_distance", LOD_DISTANCE_DEFAULT)),
|
|
569
|
+
"glb": lod_relative,
|
|
570
|
+
}
|
|
571
|
+
],
|
|
572
|
+
}
|
|
573
|
+
max_distance = obj.get("tn_max_distance")
|
|
574
|
+
if max_distance is not None:
|
|
575
|
+
asset["maxDistance"] = float(max_distance)
|
|
576
|
+
assets[asset_id] = asset
|
|
577
|
+
|
|
578
|
+
extent = data["extent"]
|
|
579
|
+
cell_size = data["cell"]
|
|
580
|
+
chunk_cells = {}
|
|
581
|
+
skipped = skipped_collections(bpy.context.view_layer.layer_collection)
|
|
582
|
+
for collection in bpy.data.collections:
|
|
583
|
+
if collection.get("tn_world_chunk") is None or (
|
|
584
|
+
collection.name in skipped
|
|
585
|
+
or collection.name.startswith("_")
|
|
586
|
+
or collection.hide_viewport
|
|
587
|
+
or collection.hide_render
|
|
588
|
+
):
|
|
589
|
+
continue
|
|
590
|
+
grouped = {}
|
|
591
|
+
for obj in collection.objects:
|
|
592
|
+
if obj.type != "MESH":
|
|
593
|
+
continue
|
|
594
|
+
translation = obj.matrix_world.translation
|
|
595
|
+
gltf_x, gltf_z = translation.x, -translation.y
|
|
596
|
+
cell_x = min(
|
|
597
|
+
data["max_cell_x"] - 1,
|
|
598
|
+
max(0, int(math.floor((gltf_x - extent["minX"]) / cell_size))),
|
|
599
|
+
)
|
|
600
|
+
cell_z = min(
|
|
601
|
+
data["max_cell_z"] - 1,
|
|
602
|
+
max(0, int(math.floor((gltf_z - extent["minZ"]) / cell_size))),
|
|
603
|
+
)
|
|
604
|
+
grouped.setdefault((cell_x, cell_z), []).append(obj)
|
|
605
|
+
for (cell_x, cell_z), objects in grouped.items():
|
|
606
|
+
# ponytail: a chunk is assigned whole to the cell holding its origin; no mesh is clipped
|
|
607
|
+
# across a boundary. Add per-cell clipping when a game's chunk geometry straddles cells
|
|
608
|
+
# at a scale where the overlap costs a visible draw.
|
|
609
|
+
relative = "chunks/%s_%d_%d.glb" % (collection.name, cell_x, cell_z)
|
|
610
|
+
write_glb(package_path(out, relative), objects)
|
|
611
|
+
chunk_cells.setdefault((cell_x, cell_z), []).append(relative)
|
|
612
|
+
|
|
613
|
+
placements = array.array("f")
|
|
614
|
+
placement_records = []
|
|
615
|
+
cells = []
|
|
616
|
+
for cell in sorted(set(data["cell_records"]) | set(chunk_cells)):
|
|
617
|
+
runs = []
|
|
618
|
+
for asset_id in sorted(data["cell_records"].get(cell, {})):
|
|
619
|
+
records = data["cell_records"][cell][asset_id]
|
|
620
|
+
runs.append(
|
|
621
|
+
{"asset": asset_id, "count": len(records), "offset": len(placement_records)}
|
|
622
|
+
)
|
|
623
|
+
placement_records.extend(records)
|
|
624
|
+
entry = {"runs": runs, "x": cell[0], "z": cell[1]}
|
|
625
|
+
if cell in chunk_cells:
|
|
626
|
+
entry["chunks"] = sorted(chunk_cells[cell])
|
|
627
|
+
cells.append(entry)
|
|
628
|
+
for record in placement_records:
|
|
629
|
+
placements.extend(record)
|
|
630
|
+
if sys.byteorder != "little":
|
|
631
|
+
placements.byteswap()
|
|
632
|
+
with open(os.path.join(out, "placements.bin"), "wb") as handle:
|
|
633
|
+
handle.write(placements.tobytes())
|
|
634
|
+
|
|
635
|
+
manifest = {
|
|
636
|
+
"assets": assets,
|
|
637
|
+
"cellSize": cell_size,
|
|
638
|
+
"cells": cells,
|
|
639
|
+
"extent": extent,
|
|
640
|
+
"placements": "placements.bin",
|
|
641
|
+
"terrain": {
|
|
642
|
+
"columns": columns,
|
|
643
|
+
"heightMax": height_max,
|
|
644
|
+
"heightMin": height_min,
|
|
645
|
+
"heightmap": "terrain/heightmap.u16",
|
|
646
|
+
**(
|
|
647
|
+
{"layers": export_terrain_layers(out, terrain_table, terrain_root)}
|
|
648
|
+
if isinstance(terrain_table, str) and terrain_table
|
|
649
|
+
else {}
|
|
650
|
+
),
|
|
651
|
+
"rows": rows,
|
|
652
|
+
"spacing": spacing,
|
|
653
|
+
},
|
|
654
|
+
"version": 1,
|
|
655
|
+
}
|
|
656
|
+
with open(os.path.join(out, "world.json"), "w") as handle:
|
|
657
|
+
json.dump(manifest, handle, indent=2, sort_keys=True)
|
|
658
|
+
handle.write("\n")
|
|
659
|
+
|
|
660
|
+
viewport_instances = 0
|
|
661
|
+
for instance in bpy.context.evaluated_depsgraph_get().object_instances:
|
|
662
|
+
if instance.is_instance and instance.instance_object is not None:
|
|
663
|
+
viewport_instances += 1
|
|
664
|
+
|
|
665
|
+
emit(
|
|
666
|
+
{
|
|
667
|
+
"assetsWritten": len(assets),
|
|
668
|
+
"cells": len(cells),
|
|
669
|
+
"instances": sum(data["by_asset"].values()),
|
|
670
|
+
"instancesByAsset": data["by_asset"],
|
|
671
|
+
"meshes": len(meshes()),
|
|
672
|
+
"out": out,
|
|
673
|
+
"placementsBytes": len(placements) * 4,
|
|
674
|
+
"recipe": "export_world",
|
|
675
|
+
"source": bpy.data.filepath or (source if isinstance(source, str) else ""),
|
|
676
|
+
"viewportInstances": viewport_instances,
|
|
677
|
+
}
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
if __name__ == "__main__":
|
|
682
|
+
main()
|