From 16d9b0ef64708e955cb7b4c9262a9b883b10ca16 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Mon, 17 Aug 2026 10:06:26 -0700 Subject: [PATCH] [Patch] Fixed tile-streamer texture exporter [Patch] Fixed streaming flickering [Test] Added test coverage to tile streaming --- scripts/tests/test_tilestreamingpartition.py | 301 +++++++++++++++++++ scripts/tilestreamingpartition.py | 84 +++++- 2 files changed, 382 insertions(+), 3 deletions(-) diff --git a/scripts/tests/test_tilestreamingpartition.py b/scripts/tests/test_tilestreamingpartition.py index 2e65d5d60..6c76ae943 100644 --- a/scripts/tests/test_tilestreamingpartition.py +++ b/scripts/tests/test_tilestreamingpartition.py @@ -46,6 +46,84 @@ def __init__(self, name: str, **props) -> None: self.name = name +# --------------------------------------------------------------------------- +# Minimal fakes for Blender's mesh.uv_layers collection API, faithful enough +# (new/remove/active/foreach_get/foreach_set) to exercise +# normalize_primary_uv_layer() without a real Blender process. bpy itself is +# a bare MagicMock in this suite, which would silently no-op these calls +# instead of catching regressions. +# --------------------------------------------------------------------------- + +class FakeUVLoopData: + def __init__(self, loop_count: int) -> None: + self._uv = [0.0] * (loop_count * 2) + + def foreach_get(self, attr: str, out_list) -> None: + assert attr == "uv" + for i in range(len(out_list)): + out_list[i] = self._uv[i] + + def foreach_set(self, attr: str, in_list) -> None: + assert attr == "uv" + self._uv = list(in_list) + + +class FakeUVLayer: + def __init__(self, name: str, loop_count: int) -> None: + self.name = name + self.data = FakeUVLoopData(loop_count) + + +class FakeUVLayers: + def __init__(self, loop_count: int) -> None: + self._loop_count = loop_count + self._layers: list[FakeUVLayer] = [] + self._active_name = None + + def new(self, name: str) -> FakeUVLayer: + layer = FakeUVLayer(name, self._loop_count) + self._layers.append(layer) + if self._active_name is None: + self._active_name = name + return layer + + def remove(self, layer: FakeUVLayer) -> None: + self._layers = [l for l in self._layers if l is not layer] + if self._active_name == layer.name: + self._active_name = self._layers[0].name if self._layers else None + + def __len__(self) -> int: + return len(self._layers) + + def __iter__(self): + return iter(list(self._layers)) + + def __getitem__(self, key): + if isinstance(key, int): + return self._layers[key] + for layer in self._layers: + if layer.name == key: + return layer + raise KeyError(key) + + @property + def active(self): + for layer in self._layers: + if layer.name == self._active_name: + return layer + return None + + @active.setter + def active(self, layer) -> None: + self._active_name = layer.name if layer else None + + +class FakeMesh: + def __init__(self, loop_count: int) -> None: + self.loops = [None] * loop_count + self.uv_layers = FakeUVLayers(loop_count) + + class TileStreamingPartitionTests(unittest.TestCase): # ------------------------------------------------------------------ @@ -336,6 +414,94 @@ def test_classify_mesh_result_contains_required_keys(self) -> None: for key in ("policy", "xz_overlap_count", "dimensions", "dim_ratio", "reasons"): self.assertIn(key, result) + # ------------------------------------------------------------------ + # Spanning-object routing (shared bucket vs. per-tile duplication) + # + # Regression coverage for the flickering-ground-plane bug: a spanning + # object (e.g. a ground plane wide enough to be classified shared_bucket) + # must stay in the shared bucket unless CLIP_LOCAL_MESHES is also on. + # Routing it to per-tile local export without clipping means the *entire* + # mesh gets duplicated whole into every overlapping tile — dozens of + # coplanar copies that z-fight and stream in/out independently. + # ------------------------------------------------------------------ + + def _spanning_routing_fixture(self): + # Small object fully inside tile (0,0,0) → local_overlap. + local_obj = FakeObject("Prop") + local_aabb = {"min": (1.0, 0.0, 1.0), "max": (4.0, 3.0, 4.0)} + + # 90x90 object spanning many tiles at tile_size=10 → width_threshold + # (90/10 = 9 > SPANNING_THRESHOLD_TILES=4) → shared_bucket, well under + # SPLIT_MAX_TILES (400) so the routing branch under test is exercised. + ground_obj = FakeObject("Ground") + ground_aabb = {"min": (0.0, 0.0, 0.0), "max": (90.0, 1.0, 90.0)} + + object_bounds = {"Prop": local_aabb, "Ground": ground_aabb} + return local_obj, ground_obj, object_bounds + + def test_build_assignments_keeps_spanning_object_in_shared_bucket_when_unclipped(self) -> None: + local_obj, ground_obj, object_bounds = self._spanning_routing_fixture() + previous = (t.SPLIT_SPANNING_OBJECTS, t.SPLIT_MAX_TILES, t.CLIP_LOCAL_MESHES) + try: + t.SPLIT_SPANNING_OBJECTS = True + t.SPLIT_MAX_TILES = 400 + t.CLIP_LOCAL_MESHES = False # the default + + tile_assignments, shared_objects, classification_map = t.build_assignments( + [local_obj, ground_obj], object_bounds, + 0.0, 0.0, 0.0, + 10.0, 100.0, 10.0, + ) + + self.assertEqual(classification_map["Ground"]["policy"], "shared_bucket") + # FakeObject subclasses dict for _obj_prop's `key in obj` support, so + # membership must be checked by name — plain `in`/`==` would compare + # dict contents and both fixture objects are empty dicts. + self.assertIn("Ground", [obj.name for obj in shared_objects]) + for tile_objs in tile_assignments.values(): + self.assertNotIn("Ground", [obj.name for obj in tile_objs]) + finally: + t.SPLIT_SPANNING_OBJECTS, t.SPLIT_MAX_TILES, t.CLIP_LOCAL_MESHES = previous + + def test_build_assignments_routes_spanning_object_to_tiles_when_clipped(self) -> None: + local_obj, ground_obj, object_bounds = self._spanning_routing_fixture() + previous = (t.SPLIT_SPANNING_OBJECTS, t.SPLIT_MAX_TILES, t.CLIP_LOCAL_MESHES) + try: + t.SPLIT_SPANNING_OBJECTS = True + t.SPLIT_MAX_TILES = 400 + t.CLIP_LOCAL_MESHES = True # local meshes are actually clipped + + tile_assignments, shared_objects, classification_map = t.build_assignments( + [local_obj, ground_obj], object_bounds, + 0.0, 0.0, 0.0, + 10.0, 100.0, 10.0, + ) + + self.assertEqual(classification_map["Ground"]["policy"], "shared_bucket") + self.assertNotIn("Ground", [obj.name for obj in shared_objects]) + routed_names = [obj.name for objs in tile_assignments.values() for obj in objs] + self.assertIn("Ground", routed_names) + finally: + t.SPLIT_SPANNING_OBJECTS, t.SPLIT_MAX_TILES, t.CLIP_LOCAL_MESHES = previous + + def test_build_assignments_keeps_spanning_object_shared_when_split_disabled(self) -> None: + local_obj, ground_obj, object_bounds = self._spanning_routing_fixture() + previous = (t.SPLIT_SPANNING_OBJECTS, t.SPLIT_MAX_TILES, t.CLIP_LOCAL_MESHES) + try: + t.SPLIT_SPANNING_OBJECTS = False + t.SPLIT_MAX_TILES = 400 + t.CLIP_LOCAL_MESHES = True + + _tile_assignments, shared_objects, _classification_map = t.build_assignments( + [local_obj, ground_obj], object_bounds, + 0.0, 0.0, 0.0, + 10.0, 100.0, 10.0, + ) + + self.assertIn("Ground", [obj.name for obj in shared_objects]) + finally: + t.SPLIT_SPANNING_OBJECTS, t.SPLIT_MAX_TILES, t.CLIP_LOCAL_MESHES = previous + # ------------------------------------------------------------------ # Output helpers # ------------------------------------------------------------------ @@ -561,6 +727,141 @@ def test_apply_cli_overrides_disables_bake_cache(self) -> None: finally: t.BAKE_CACHE = previous + # ------------------------------------------------------------------ + # UV layer normalization before cross-object merge + # + # Regression coverage for the missing-texture bug: merge_objects_by_material() + # joins objects sharing a material via repeated bmesh.from_mesh() calls, which + # unify UV layers *by name*, and the exporter always reads uv_layers[0]. Source + # assets that name their primary UV layer differently ("UVMap", "UVChannel_1", + # "UVW", ...) must have that layer renamed to a shared canonical name before + # merging, or every object whose layer lands at a non-zero index gets all-zero + # UVs and renders untextured. + # ------------------------------------------------------------------ + + def test_normalize_primary_uv_layer_renames_active_layer_to_canonical(self) -> None: + mesh = FakeMesh(loop_count=3) + layer = mesh.uv_layers.new(name="UVChannel_1") + layer.data.foreach_set("uv", [0.1, 0.2, 0.3, 0.4, 0.5, 0.6]) + mesh.uv_layers.active = layer + + obj = FakeObject("Palm") + obj.data = mesh + + t.normalize_primary_uv_layer(obj) + + self.assertEqual(len(mesh.uv_layers), 1) + self.assertEqual(mesh.uv_layers[0].name, t.MERGE_CANONICAL_UV_LAYER_NAME) + out = [0.0] * 6 + mesh.uv_layers[0].data.foreach_get("uv", out) + self.assertEqual(out, [0.1, 0.2, 0.3, 0.4, 0.5, 0.6]) + + def test_normalize_primary_uv_layer_is_noop_when_already_canonical(self) -> None: + mesh = FakeMesh(loop_count=2) + layer = mesh.uv_layers.new(name=t.MERGE_CANONICAL_UV_LAYER_NAME) + mesh.uv_layers.active = layer + + obj = FakeObject("AMT") + obj.data = mesh + + t.normalize_primary_uv_layer(obj) + + self.assertEqual(len(mesh.uv_layers), 1) + self.assertIs(mesh.uv_layers[0], layer, "already-canonical layer must be left untouched") + + def test_normalize_primary_uv_layer_preserves_secondary_layer(self) -> None: + mesh = FakeMesh(loop_count=2) + secondary = mesh.uv_layers.new(name="Lightmap") + secondary.data.foreach_set("uv", [0.9, 0.9, 0.8, 0.8]) + primary = mesh.uv_layers.new(name="UVW") + primary.data.foreach_set("uv", [0.1, 0.1, 0.2, 0.2]) + mesh.uv_layers.active = primary + + obj = FakeObject("Building") + obj.data = mesh + + t.normalize_primary_uv_layer(obj) + + self.assertEqual(mesh.uv_layers[0].name, t.MERGE_CANONICAL_UV_LAYER_NAME) + out0 = [0.0] * 4 + mesh.uv_layers[0].data.foreach_get("uv", out0) + self.assertEqual(out0, [0.1, 0.1, 0.2, 0.2], "canonical layer must carry the formerly-active data") + + names = [layer.name for layer in mesh.uv_layers] + self.assertIn("Lightmap", names) + lightmap = mesh.uv_layers["Lightmap"] + out1 = [0.0] * 4 + lightmap.data.foreach_get("uv", out1) + self.assertEqual(out1, [0.9, 0.9, 0.8, 0.8], "secondary layer data must survive untouched") + + def test_normalize_primary_uv_layer_handles_name_collision_with_existing_canonical(self) -> None: + # Active layer is "UVW", but a *secondary* layer already happens to be + # named "UVMap" — renaming the active layer must not silently clobber it. + mesh = FakeMesh(loop_count=2) + existing_canonical = mesh.uv_layers.new(name=t.MERGE_CANONICAL_UV_LAYER_NAME) + existing_canonical.data.foreach_set("uv", [0.3, 0.3, 0.4, 0.4]) + primary = mesh.uv_layers.new(name="UVW") + primary.data.foreach_set("uv", [0.1, 0.1, 0.2, 0.2]) + mesh.uv_layers.active = primary + + obj = FakeObject("Weird") + obj.data = mesh + + t.normalize_primary_uv_layer(obj) + + self.assertEqual(len(mesh.uv_layers), 2) + self.assertEqual(mesh.uv_layers[0].name, t.MERGE_CANONICAL_UV_LAYER_NAME) + out0 = [0.0] * 4 + mesh.uv_layers[0].data.foreach_get("uv", out0) + self.assertEqual(out0, [0.1, 0.1, 0.2, 0.2], "index 0 must carry the formerly-active UVW data") + + names = [layer.name for layer in mesh.uv_layers] + self.assertIn(f"{t.MERGE_CANONICAL_UV_LAYER_NAME}.orig", names, + "the pre-existing secondary 'UVMap' layer must be preserved under a renamed slot") + + def test_normalize_primary_uv_layer_noop_when_mesh_has_no_uv_layers(self) -> None: + mesh = FakeMesh(loop_count=2) + obj = FakeObject("NoUV") + obj.data = mesh + + t.normalize_primary_uv_layer(obj) # must not raise + + self.assertEqual(len(mesh.uv_layers), 0) + + def test_merge_objects_by_material_normalizes_uv_before_merging(self) -> None: + """Wiring guard: merge_objects_by_material() must call + normalize_primary_uv_layer() on every object in a merge group before + handing off to _merge_objects_in_scene(). This is the actual fix — a + future refactor that drops the call would reintroduce the + missing-texture bug with none of the per-unit UV tests noticing.""" + original_normalize = t.normalize_primary_uv_layer + original_merge_key = t.material_merge_key + original_merge_in_scene = t._merge_objects_in_scene + try: + normalized_names = [] + t.normalize_primary_uv_layer = lambda obj: normalized_names.append(obj.name) + t.material_merge_key = lambda obj: "same-material" + merged_placeholder = FakeObject("merged") + t._merge_objects_in_scene = MagicMock(return_value=merged_placeholder) + + obj_a = FakeObject("A") + obj_a.type = "MESH" + obj_a.data = object() + obj_b = FakeObject("B") + obj_b.type = "MESH" + obj_b.data = object() + + result = t.merge_objects_by_material([obj_a, obj_b], temp_scene=MagicMock()) + + self.assertEqual(sorted(normalized_names), ["A", "B"], + "every object in the merge group must be UV-normalized before merging") + t._merge_objects_in_scene.assert_called_once() + self.assertEqual(result, [merged_placeholder]) + finally: + t.normalize_primary_uv_layer = original_normalize + t.material_merge_key = original_merge_key + t._merge_objects_in_scene = original_merge_in_scene + if __name__ == "__main__": unittest.main() diff --git a/scripts/tilestreamingpartition.py b/scripts/tilestreamingpartition.py index 9144539f6..bd7aaeb75 100755 --- a/scripts/tilestreamingpartition.py +++ b/scripts/tilestreamingpartition.py @@ -269,6 +269,15 @@ def append_worker_progress(progress_file, event): # to the shared bucket to avoid thousands of clip+export iterations for truly # scene-spanning meshes (ground planes, terrain slabs). # +# This routing is only honored when CLIP_LOCAL_MESHES is also True (see below). +# Without clipping, "routed to tiles" means the *entire* spanning mesh is +# duplicated whole into every overlapping tile instead of being cut into +# per-tile pieces — for an object that already overlaps dozens of tiles, that +# is dozens of full-size coplanar copies at the same world position, which +# z-fights and pops in/out independently as each tile streams. With +# CLIP_LOCAL_MESHES == False, spanning objects always stay in the shared +# bucket regardless of SPLIT_MAX_TILES. +# # Rule of thumb for SPLIT_MAX_TILES: (max_building_width / TILE_SIZE)² # At TILE_SIZE=25, default 400 allows objects up to 500 m × 500 m to be split. SPLIT_SPANNING_OBJECTS = True @@ -299,6 +308,16 @@ def append_worker_progress(progress_file, event): # Example: name an object "NM_Pipe_001" in Blender to keep it separate. NO_MERGE_PREFIX = "NM_" +# The exporter always writes uv_layers[0] as a merged mesh's texture coordinates +# (see untoldexplorer.py). Source assets combined into one scene often name their +# primary UV layer differently ("UVMap", "UVChannel_1", "UVW", ...). When objects +# with different primary-layer names are joined by merge_objects_by_material(), +# bmesh unifies layers *by name*, so only the objects whose layer name landed at +# index 0 keep real UVs — everyone else's merged-in faces get all-zero UVs and +# render untextured. Renaming every object's active layer to this canonical name +# before merging keeps the primary UV channel unified across the whole tile. +MERGE_CANONICAL_UV_LAYER_NAME = "UVMap" + # Clip tolerance at tile boundaries. # for objects at large world coordinates (e.g. buildings at x=1500). SPLIT_CLIP_EPSILON = 1e-4 @@ -2420,6 +2439,7 @@ def build_assignments(objects, object_bounds, origin_x, origin_y, origin_z, result["policy"] == "local_overlap" or ( SPLIT_SPANNING_OBJECTS + and CLIP_LOCAL_MESHES and result["policy"] in ("shared_bucket", "future_split_candidate") and result["xz_overlap_count"] <= SPLIT_MAX_TILES ) @@ -3034,6 +3054,53 @@ def split_objects_by_material(objects, temp_scene): return result +def normalize_primary_uv_layer(obj): + """Rename obj's active UV layer to MERGE_CANONICAL_UV_LAYER_NAME and move it to index 0. + + See the comment on MERGE_CANONICAL_UV_LAYER_NAME for why this matters: bmesh + merges UV layers by name, and the exporter always reads index 0, so every + object about to be joined with others must agree on the primary layer's name. + Other (secondary) UV layers are preserved under their original names. + No-op if the mesh has no UV layers, or already satisfies both conditions. + """ + mesh = obj.data + uv_layers = getattr(mesh, "uv_layers", None) + if not uv_layers or len(uv_layers) == 0: + return + + # Compare by name, not identity: each attribute access on uv_layers.active / + # uv_layers[i] returns a fresh RNA wrapper, so `is` can be False even when + # both refer to the same underlying layer. + active_name = (uv_layers.active or uv_layers[0]).name + if uv_layers[0].name == active_name and active_name == MERGE_CANONICAL_UV_LAYER_NAME: + return + + loop_count = len(mesh.loops) + saved = [] + for layer in uv_layers: + data = [0.0] * (loop_count * 2) + layer.data.foreach_get("uv", data) + saved.append((layer.name, data, layer.name == active_name)) + + for layer in list(uv_layers): + uv_layers.remove(layer) + + active_entry = next(entry for entry in saved if entry[2]) + new_active = uv_layers.new(name=MERGE_CANONICAL_UV_LAYER_NAME) + new_active.data.foreach_set("uv", active_entry[1]) + + for name, data, was_active in saved: + if was_active: + continue + # Avoid a name collision with the canonical layer we just created + # (e.g. a mesh that already had a secondary layer literally named "UVMap"). + restored_name = name if name != MERGE_CANONICAL_UV_LAYER_NAME else f"{name}.orig" + layer = uv_layers.new(name=restored_name) + layer.data.foreach_set("uv", data) + + uv_layers.active = new_active + + def merge_objects_by_material(objects, temp_scene): """Join objects that share identical material(s) into single meshes. @@ -3073,6 +3140,8 @@ def merge_objects_by_material(objects, temp_scene): continue try: + for obj in group: + normalize_primary_uv_layer(obj) merged = _merge_objects_in_scene(group, temp_scene) total_merged_away += len(group) - 1 result.append(merged) @@ -4863,24 +4932,33 @@ def run(): if PERIMETER_MODE: tile_assignments = filter_tile_assignments_perimeter(tile_assignments, depth=PERIMETER_DEPTH) + # Spanning objects are only ever routed to per-tile local export when + # CLIP_LOCAL_MESHES is also on — see the comment on SPLIT_SPANNING_OBJECTS. + split_spanning_active = SPLIT_SPANNING_OBJECTS and CLIP_LOCAL_MESHES local_count = sum(1 for r in classification_map.values() if r["policy"] == "local_overlap") spanning_routed = sum( 1 for r in classification_map.values() - if SPLIT_SPANNING_OBJECTS + if split_spanning_active and r["policy"] in ("shared_bucket", "future_split_candidate") and r["xz_overlap_count"] <= SPLIT_MAX_TILES ) capped_count = sum( 1 for r in classification_map.values() if r["policy"] in ("shared_bucket", "future_split_candidate") - and (not SPLIT_SPANNING_OBJECTS or r["xz_overlap_count"] > SPLIT_MAX_TILES) + and (not split_spanning_active or r["xz_overlap_count"] > SPLIT_MAX_TILES) ) + if SPLIT_SPANNING_OBJECTS and not CLIP_LOCAL_MESHES and capped_count: + print( + " Note: SPLIT_SPANNING_OBJECTS is on but CLIP_LOCAL_MESHES is off — " + "spanning objects stay in the shared bucket rather than being " + "duplicated whole into every overlapping tile." + ) print( f"Classification: {len(objects)} objects → " f"{local_count} local" + (f", {spanning_routed} spanning→tiles, {capped_count} spanning→shared bucket" f" (SPLIT_MAX_TILES={SPLIT_MAX_TILES})" - if SPLIT_SPANNING_OBJECTS else f", {capped_count} spanning→shared bucket") + if split_spanning_active else f", {capped_count} spanning→shared bucket") ) # ------------------------------------------------------------------