From 361a299afd80ef1cc237ea4072238aa58a3e5daf Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Wed, 13 May 2026 12:32:01 -0700 Subject: [PATCH 1/2] [Patch] Preserve original mesh names add selective merge via NM_ prefix --- docs/API/UsingTheExporter.md | 19 +++++++++++++++++++ scripts/tilestreamingpartition.py | 25 +++++++++++++++++++++++-- scripts/untoldexplorer.py | 6 +++--- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/docs/API/UsingTheExporter.md b/docs/API/UsingTheExporter.md index a637844b..c7a81218 100644 --- a/docs/API/UsingTheExporter.md +++ b/docs/API/UsingTheExporter.md @@ -130,6 +130,25 @@ Expected output layout: The manifest stores relative runtime paths so it remains portable across machines, repos, and app bundles. +## Selective Merging With The NM_ Prefix + +When `MERGE_BY_MATERIAL` is enabled (the default), objects that share the same material within a tile are joined into a single mesh entity before export. This reduces draw calls significantly, but means multiple original objects collapse into one exported entity — losing their individual names. + +If you need certain objects to remain as separate identifiable entities (for example, to support tap-to-select workflows or per-object JSON lookups at runtime), prefix their name in Blender with `NM_`. + +Objects whose name starts with `NM_` are excluded from the merge step and exported individually, preserving their original name in the `.untold` file. All other objects are still merged normally. + +Example naming in Blender: + +- `NM_Pipe_001` — exported as its own entity, name survives into `.untold` +- `NM_LightFixture_A` — exported as its own entity +- `Wall_North` — merged with other same-material walls, one entity for the group +- `Door_Main` — merged with same-material doors + +This lets you keep background geometry (walls, floors, ceilings) optimized while still being able to identify and interact with specific objects at runtime: + +To change the prefix or disable selective merging, edit `NO_MERGE_PREFIX` at the top of `scripts/tilestreamingpartition.py`. Set it to `""` to merge all objects regardless of name. + ## Optimization Workflows After exporting assets, use [Optimizations](Optimizations.md) for optional diff --git a/scripts/tilestreamingpartition.py b/scripts/tilestreamingpartition.py index 7011e7ae..4125c483 100755 --- a/scripts/tilestreamingpartition.py +++ b/scripts/tilestreamingpartition.py @@ -180,6 +180,12 @@ def append_worker_progress(progress_file, event): # tile. No visual effect. Requires BAKE_WORLD_TRANSFORMS. MERGE_BY_MATERIAL = True +# Objects whose original name starts with this prefix are never merged, even +# when MERGE_BY_MATERIAL is True. They are exported as individual entities and +# retain their original name in the .untold file. Set to "" to disable. +# Example: name an object "NM_Pipe_001" in Blender to keep it separate. +NO_MERGE_PREFIX = "NM_" + # Clip tolerance at tile boundaries. # for objects at large world coordinates (e.g. buildings at x=1500). SPLIT_CLIP_EPSILON = 1e-4 @@ -1606,6 +1612,7 @@ def duplicate_objects_to_scene(source_objects, temp_scene, bake_world=True): new_obj = src_obj.copy() if src_obj.data: new_obj.data = src_obj.data.copy() + new_obj["mesh_original_name"] = src_obj.name src_collection.objects.link(new_obj) if bake_world: @@ -2062,6 +2069,7 @@ def split_objects_by_material(objects, temp_scene): p.material_index = 0 new_obj = bpy.data.objects.new(f"{obj.name}_mat{mat_idx}", new_mesh) + new_obj["mesh_original_name"] = obj.get("mesh_original_name", obj.name) new_obj.matrix_world = obj.matrix_world.copy() temp_scene.collection.objects.link(new_obj) result.append(new_obj) @@ -2083,6 +2091,9 @@ def merge_objects_by_material(objects, temp_scene): is performed at the mesh-data level instead of via bpy.ops.object.join() so stale object transform caches cannot double-apply importer axis corrections. + Objects whose original name starts with NO_MERGE_PREFIX are passed through + untouched so they keep their own entity name in the exported file. + Returns the reduced object list. """ if len(objects) <= 1: @@ -2090,14 +2101,20 @@ def merge_objects_by_material(objects, temp_scene): groups = {} non_mesh = [] + protected = [] for obj in objects: if obj.type != 'MESH' or obj.data is None: non_mesh.append(obj) continue + if NO_MERGE_PREFIX: + original_name = obj.get("mesh_original_name", obj.name) + if original_name.startswith(NO_MERGE_PREFIX): + protected.append(obj) + continue key = material_merge_key(obj) groups.setdefault(key, []).append(obj) - result = list(non_mesh) + result = list(non_mesh) + protected total_mesh_input = sum(len(g) for g in groups.values()) total_merged_away = 0 for key, group in groups.items(): @@ -2113,6 +2130,8 @@ def merge_objects_by_material(objects, temp_scene): print(f" Warning: mesh merge failed ({len(group)} objects): {ex}") result.extend(group) + if protected: + print(f" Mesh merge: {len(protected)} protected object(s) skipped (prefix '{NO_MERGE_PREFIX}')") if total_merged_away > 0: print(f" Mesh merge: {total_mesh_input} → {total_mesh_input - total_merged_away} " f"objects ({total_merged_away} eliminated)") @@ -2890,6 +2909,7 @@ def _config_snapshot() -> dict: "SOURCE_ORIENTATION": SOURCE_ORIENTATION, "CLIP_LOCAL_MESHES": CLIP_LOCAL_MESHES, "MERGE_BY_MATERIAL": MERGE_BY_MATERIAL, + "NO_MERGE_PREFIX": NO_MERGE_PREFIX, "BAKE_WORLD_TRANSFORMS": BAKE_WORLD_TRANSFORMS, "SPLIT_CLIP_EPSILON": SPLIT_CLIP_EPSILON, "DEBUG_AABB_ONLY": DEBUG_AABB_ONLY, @@ -2900,13 +2920,14 @@ def _config_snapshot() -> dict: def _apply_bundle_config(cfg: dict) -> None: """Restore config globals in a worker process from a bundle dict.""" global EXPORT_FORMAT, CONVERT_ORIENTATION, SOURCE_ORIENTATION - global CLIP_LOCAL_MESHES, MERGE_BY_MATERIAL, BAKE_WORLD_TRANSFORMS + global CLIP_LOCAL_MESHES, MERGE_BY_MATERIAL, NO_MERGE_PREFIX, BAKE_WORLD_TRANSFORMS global SPLIT_CLIP_EPSILON, DEBUG_AABB_ONLY, COMPRESS_GEOMETRY EXPORT_FORMAT = cfg.get("EXPORT_FORMAT", EXPORT_FORMAT) CONVERT_ORIENTATION = cfg.get("CONVERT_ORIENTATION", CONVERT_ORIENTATION) SOURCE_ORIENTATION = cfg.get("SOURCE_ORIENTATION", SOURCE_ORIENTATION) CLIP_LOCAL_MESHES = cfg.get("CLIP_LOCAL_MESHES", CLIP_LOCAL_MESHES) MERGE_BY_MATERIAL = cfg.get("MERGE_BY_MATERIAL", MERGE_BY_MATERIAL) + NO_MERGE_PREFIX = cfg.get("NO_MERGE_PREFIX", NO_MERGE_PREFIX) BAKE_WORLD_TRANSFORMS = cfg.get("BAKE_WORLD_TRANSFORMS", BAKE_WORLD_TRANSFORMS) SPLIT_CLIP_EPSILON = cfg.get("SPLIT_CLIP_EPSILON", SPLIT_CLIP_EPSILON) DEBUG_AABB_ONLY = cfg.get("DEBUG_AABB_ONLY", DEBUG_AABB_ONLY) diff --git a/scripts/untoldexplorer.py b/scripts/untoldexplorer.py index 887fa53f..711a5145 100644 --- a/scripts/untoldexplorer.py +++ b/scripts/untoldexplorer.py @@ -2201,7 +2201,7 @@ def _extract_mesh_numpy(mesh_object: object, mesh_data: object, asset_path: Path ) return ExportedMesh( - entity_name=mesh_object.name, + entity_name=mesh_object.get("mesh_original_name") or mesh_object.name, parent_entity_name=getattr(getattr(mesh_object, "parent", None), "name", None), mesh_name=mesh_object.data.name or mesh_object.name, local_transform_rows=local_transform_rows, @@ -2384,7 +2384,7 @@ def extract_mesh_object( ) if _validate else None return ExportedMesh( - entity_name=mesh_object.name, + entity_name=mesh_object.get("mesh_original_name") or mesh_object.name, parent_entity_name=getattr(getattr(mesh_object, "parent", None), "name", None), mesh_name=mesh_object.data.name or mesh_object.name, local_transform_rows=local_transform_rows, @@ -2615,7 +2615,7 @@ def aggregate_world_corners(obj: object) -> list[tuple[float, float, float]]: parent_entity_name = parent.name if parent is not None and parent.as_pointer() in export_object_ids else None nodes.append( ExportedNode( - entity_name=obj.name, + entity_name=obj.get("mesh_original_name") or obj.name, parent_entity_name=parent_entity_name, local_transform_rows=local_transform_rows, local_bounds=local_bounds, From 4d22abcd11178f4ee9aff51b0d8234c6620b9d52 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Wed, 13 May 2026 17:40:02 -0700 Subject: [PATCH 2/2] [Release] Preparing release 0.12.11 --- CHANGELOG.md | 43 +++++++++++++++++++ README.md | 2 +- Sources/DemoGame/AppDelegate.swift | 2 +- Sources/DemoGame/DemoHUD.swift | 36 ++++++++-------- Sources/Sandbox/AppDelegate.swift | 2 +- .../UntoldEngine/Renderer/UntoldEngine.swift | 2 +- docs/API/GettingStarted.md | 2 +- 7 files changed, 65 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f76d69ab..9fdcc966 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,47 @@ # Changelog +## v0.12.11 - 2026-05-13 +### 🐞 Fixes +- [Patch] Fix SSAO floating in XR (9a0fba5…) +- [Patch] Fix DoF depth linearization for reverse-Z (Vision Pro) (79512a3…) +- [Patch] Bloom threshold samples full HDR scene, not emissive-only (0652749…) +- [Patch] Fix DoF bokeh aspect ratio — radius now in pixel space (ebd36b0…) +- [Patch] Replace DoF ring sampling with Vogel disc pattern (16 samples) (947fe23…) +- [Patch] Fix chromatic aberration aspect ratio and redundant samples (e9f35bb…) +- [Patch] Fix vignette shape — circular not elliptical on wide screens (1559ede…) +- [Patch] Wider bloom blur — 9-tap Gaussian kernel, 4 passes, radius 6 (6af62db…) +- [Patch] Eliminate redundant texture sample in LookShader (9ca27f0…) +- [Patch] SSAO bilateral blur: linearize depth and thread reverseZ flag (d211b62…) +- [Patch] Rename BloomCompositeShader textures to match their actual slots (8ec208f…) +- [Patch] Fix ColorCorrectionShader parameter mismatch (d72a8db…) +- [Patch] Remove dead commented-out code block from LookShader (39926ac…) +- [Patch] Update RenderGraphBuilderTest for removed geometryPassId param (8e11d5c…) +- [Patch] Fix SSAO intensity and retune preset values (84ddb59…) +- [Patch] Add progress/percentage to exporter cli (e6741a8…) +- [Patch] Fixed demo camera orbit (ea2c571…) +- [Patch] Fix occlusion for transparency meshes in xr (ce811e0…) +- [Patch] Added cache to the native texture loader (f4470d5…) +- [Patch] Implemented cascade shadow mapping (4856e59…) +- [Patch] Updated the build templates (6c49df0…) +- [Patch] Implemented Temporal Anti-Aliasing (dd3abd3…) +- [Patch] Moved loadUntoldScene from build templates (403320f…) +- [Patch] Added a factor to the tile gen script to use area as input (884e051…) +- [Patch] Fix XR mixed-mode HZB culling behind transparent surfaces (8c63977…) +- [Patch] Fix HZB false culling near transparent surfaces (0c0b0df…) +- [Patch] Generate mip map for untold files (3dda8bc…) +- [Patch] Fix z-figthing issues (b3b6535…) +- [Patch] fixed reversed z for depth debug (848800d…) +- [Patch] Fixed grid after doing reversed-z (225b9c1…) +- [Patch] Preserve original mesh names add selective merge via NM_ prefix (361a299…) +### 📚 Docs +- [Docs] Updated static batching docs (4471b07…) +- [Docs] Updated readme (476056e…) +- [Docs] Updated licenses (9dc3f6d…) +- [Docs] Updated documents (136d8a1…) +- [Docs] Updated readme (434274c…) +- [Docs] Moved documentation to use mkdocs (811d032…) +- [Docs] point to stable release (89c56b7…) +- [Docs] Added load scene docs (cd0a8eb…) +- [Docs] Update spatial docs (9885d85…) ## v0.12.10 - 2026-04-29 ### 🐞 Fixes - [Patch] Made fxaa pass public (736ec7b…) diff --git a/README.md b/README.md index 010c6d92..cb6aede8 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Clone the repository and launch the demo: ```bash git clone https://github.com/untoldengine/UntoldEngine.git cd UntoldEngine -git checkout v0.12.10 +git checkout v0.12.11 swift run untolddemo ``` diff --git a/Sources/DemoGame/AppDelegate.swift b/Sources/DemoGame/AppDelegate.swift index 9ef5cfac..9139cce8 100644 --- a/Sources/DemoGame/AppDelegate.swift +++ b/Sources/DemoGame/AppDelegate.swift @@ -11,7 +11,7 @@ @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { private enum Constants { - static let appVersion = "0.12.10" + static let appVersion = "0.12.11" static let defaultWindowSize = NSSize(width: 1920, height: 1080) static let minimumWindowSize = NSSize(width: 640, height: 480) } diff --git a/Sources/DemoGame/DemoHUD.swift b/Sources/DemoGame/DemoHUD.swift index e5a92557..0239b9b9 100644 --- a/Sources/DemoGame/DemoHUD.swift +++ b/Sources/DemoGame/DemoHUD.swift @@ -75,7 +75,6 @@ static let panelCornerRadius: CGFloat = 8 static let panelPadding: CGFloat = 12 static let edgePadding: CGFloat = 16 - static let compactMinimumPanelHeight: CGFloat = 220 static let controlsPanelWidth: CGFloat = 320 static let sidePanelWidth: CGFloat = 260 static let resolutionPresets: [ResolutionPreset] = [ @@ -114,32 +113,17 @@ 260, min(Constants.controlsPanelWidth, proxy.size.width - Constants.edgePadding * 2) ) + let controlsHeight = max(0, proxy.size.height - Constants.edgePadding * 2) ZStack(alignment: .topLeading) { SceneView(renderer: renderer) if isCompact { if areControlsVisible { - ScrollView { - controlsPanel - .padding(Constants.panelPadding) - } - .frame(width: controlsWidth, alignment: .topLeading) - .frame( - maxHeight: max(Constants.compactMinimumPanelHeight, proxy.size.height - Constants.edgePadding * 2), - alignment: .topLeading - ) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: Constants.panelCornerRadius)) - .padding(Constants.edgePadding) - .onHover { state.isMouseOverControlPanel = $0 } + controlsPanelContainer(width: controlsWidth, maxHeight: controlsHeight) } } else { - controlsPanel - .padding(Constants.panelPadding) - .frame(width: Constants.controlsPanelWidth, alignment: .topLeading) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: Constants.panelCornerRadius)) - .padding(Constants.edgePadding) - .onHover { state.isMouseOverControlPanel = $0 } + controlsPanelContainer(width: Constants.controlsPanelWidth, maxHeight: controlsHeight) } if isCompact { @@ -367,6 +351,20 @@ } } + private func controlsPanelContainer(width: CGFloat, maxHeight: CGFloat) -> some View { + ScrollView(.vertical) { + controlsPanel + .padding(Constants.panelPadding) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .scrollIndicators(.visible) + .frame(width: width, alignment: .topLeading) + .frame(maxHeight: maxHeight, alignment: .topLeading) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: Constants.panelCornerRadius)) + .padding(Constants.edgePadding) + .onHover { state.isMouseOverControlPanel = $0 } + } + private var sidePanel: some View { HStack { Spacer() diff --git a/Sources/Sandbox/AppDelegate.swift b/Sources/Sandbox/AppDelegate.swift index 41b5203f..39d944b0 100644 --- a/Sources/Sandbox/AppDelegate.swift +++ b/Sources/Sandbox/AppDelegate.swift @@ -10,7 +10,7 @@ @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { private enum Constants { - static let appVersion = "0.12.10" + static let appVersion = "0.12.11" static let windowSize = NSSize(width: 1600, height: 900) } diff --git a/Sources/UntoldEngine/Renderer/UntoldEngine.swift b/Sources/UntoldEngine/Renderer/UntoldEngine.swift index b8de3cb0..d5c81afa 100644 --- a/Sources/UntoldEngine/Renderer/UntoldEngine.swift +++ b/Sources/UntoldEngine/Renderer/UntoldEngine.swift @@ -175,7 +175,7 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { CameraSystem.shared.activeCamera = gameCamera - Logger.log(message: "Untold Engine Starting. Version 0.12.10") + Logger.log(message: "Untold Engine Starting. Version 0.12.11") } public func initSizeableResources() { diff --git a/docs/API/GettingStarted.md b/docs/API/GettingStarted.md index 2f77121c..76660286 100644 --- a/docs/API/GettingStarted.md +++ b/docs/API/GettingStarted.md @@ -27,7 +27,7 @@ Clone the repository and launch the demo: ```bash git clone https://github.com/untoldengine/UntoldEngine.git cd UntoldEngine -git checkout v0.12.10 +git checkout v0.12.11 swift run untolddemo ```