diff --git a/Sources/UntoldEngine/Renderer/RenderInitializer.swift b/Sources/UntoldEngine/Renderer/RenderInitializer.swift index 5a3ba2bd..be28b690 100644 --- a/Sources/UntoldEngine/Renderer/RenderInitializer.swift +++ b/Sources/UntoldEngine/Renderer/RenderInitializer.swift @@ -469,6 +469,20 @@ func initTextureResources() { storageMode: .private ) + // XR mixed mode needs an opaque-only depth snapshot for HZB so transparent + // glass depth does not occlusion-cull virtual meshes behind the glass. + if renderInfo.isXRStereoMode { + textureResources.hzbSourceDepthMap = createTexture( + device: renderInfo.device, + label: "HZB Source Depth Texture", + pixelFormat: renderInfo.depthPixelFormat, + width: viewportWidth, + height: viewportHeight, + usage: [.shaderRead, .renderTarget], + storageMode: .private + ) + } + // Deferred Depth Texture textureResources.deferredDepthMap = createTexture( device: renderInfo.device, diff --git a/Sources/UntoldEngine/Renderer/RenderPasses.swift b/Sources/UntoldEngine/Renderer/RenderPasses.swift index 3ceeef96..e7ff1cc4 100644 --- a/Sources/UntoldEngine/Renderer/RenderPasses.swift +++ b/Sources/UntoldEngine/Renderer/RenderPasses.swift @@ -312,6 +312,31 @@ public enum RenderPasses { return buildFrustum(from: effectiveLightMatrix) } + public static let copyOpaqueDepthForHZBExecution: RenderPassExecution = { commandBuffer in + guard renderInfo.isXRStereoMode, + renderInfo.immersionStyle == .mixed, + let sourceDepth = textureResources.depthMap, + let hzbSourceDepth = textureResources.hzbSourceDepthMap + else { return } + + let width = min(sourceDepth.width, hzbSourceDepth.width) + let height = min(sourceDepth.height, hzbSourceDepth.height) + guard width > 0, height > 0 else { return } + + guard let blitEncoder = commandBuffer.makeBlitCommandEncoder() else { return } + blitEncoder.label = "Copy Opaque Depth for XR HZB" + blitEncoder.copy( + from: sourceDepth, + sourceSlice: 0, sourceLevel: 0, + sourceOrigin: MTLOrigin(x: 0, y: 0, z: 0), + sourceSize: MTLSize(width: width, height: height, depth: 1), + to: hzbSourceDepth, + destinationSlice: 0, destinationLevel: 0, + destinationOrigin: MTLOrigin(x: 0, y: 0, z: 0) + ) + blitEncoder.endEncoding() + } + private static func shadowCasterEntityIds(for cascadeIdx: Int) -> [EntityID] { guard let frustum = shadowFrustum(for: cascadeIdx) else { return [] } diff --git a/Sources/UntoldEngine/Renderer/RenderResources.swift b/Sources/UntoldEngine/Renderer/RenderResources.swift index 6e19f31d..83d43f1e 100644 --- a/Sources/UntoldEngine/Renderer/RenderResources.swift +++ b/Sources/UntoldEngine/Renderer/RenderResources.swift @@ -157,6 +157,7 @@ public struct TextureResources { public var materialMap: MTLTexture? public var emissiveMap: MTLTexture? public var depthMap: MTLTexture? + public var hzbSourceDepthMap: MTLTexture? public var environmentColorMap: MTLTexture? // deferred diff --git a/Sources/UntoldEngine/Shaders/HZBCompute.metal b/Sources/UntoldEngine/Shaders/HZBCompute.metal index 6fb83847..39108ecf 100644 --- a/Sources/UntoldEngine/Shaders/HZBCompute.metal +++ b/Sources/UntoldEngine/Shaders/HZBCompute.metal @@ -164,29 +164,23 @@ kernel void hzbCullVisibleEntities( mipLevel = min((uint)floor(log2(rectMaxDim)), mipCount - 1u); } - // 9-sample 3×3 grid across the projected rect. - // 4-corner sampling misses the interior of porous occluders (e.g. a wall - // frame whose solid members sit at the rect edges while the centre is open - // air). By adding the four mid-edge points and the centre we catch that - // empty space: the conservative min/max across all 9 samples then reflects - // the farthest depth anywhere inside the rect, preventing false occlusion. - const float2 uvMid = (uvMin + uvMax) * 0.5; - const float lod = float(mipLevel); - constexpr sampler pointSampler(coord::normalized, address::clamp_to_edge, filter::nearest); - float d0 = hzbDepthPyramid.sample(pointSampler, float2(uvMin.x, uvMin.y), level(lod)).x; // top-left - float d1 = hzbDepthPyramid.sample(pointSampler, float2(uvMid.x, uvMin.y), level(lod)).x; // top-center - float d2 = hzbDepthPyramid.sample(pointSampler, float2(uvMax.x, uvMin.y), level(lod)).x; // top-right - float d3 = hzbDepthPyramid.sample(pointSampler, float2(uvMin.x, uvMid.y), level(lod)).x; // mid-left - float d4 = hzbDepthPyramid.sample(pointSampler, float2(uvMid.x, uvMid.y), level(lod)).x; // center - float d5 = hzbDepthPyramid.sample(pointSampler, float2(uvMax.x, uvMid.y), level(lod)).x; // mid-right - float d6 = hzbDepthPyramid.sample(pointSampler, float2(uvMin.x, uvMax.y), level(lod)).x; // bottom-left - float d7 = hzbDepthPyramid.sample(pointSampler, float2(uvMid.x, uvMax.y), level(lod)).x; // bottom-center - float d8 = hzbDepthPyramid.sample(pointSampler, float2(uvMax.x, uvMax.y), level(lod)).x; // bottom-right - - float hzbDepth = (reverseZ != 0u) - ? min(min(min(d0, d1), min(d2, d3)), min(min(d4, d5), min(d6, min(d7, d8)))) - : max(max(max(d0, d1), max(d2, d3)), max(max(d4, d5), max(d6, max(d7, d8)))); + const float lod = float(mipLevel); + + // Sample a dense grid across the projected rect. Porous occluders such as + // window frames or glass assemblies can leave only part of a candidate + // visible; sparse samples can land entirely on the solid frame and falsely + // cull the object behind it. + float hzbDepth = (reverseZ != 0u) ? 1.0 : 0.0; + for (uint y = 0u; y < 5u; ++y) { + const float ty = float(y) * 0.25; + for (uint x = 0u; x < 5u; ++x) { + const float tx = float(x) * 0.25; + const float2 uv = mix(uvMin, uvMax, float2(tx, ty)); + const float sampleDepth = hzbDepthPyramid.sample(pointSampler, uv, level(lod)).x; + hzbDepth = (reverseZ != 0u) ? min(hzbDepth, sampleDepth) : max(hzbDepth, sampleDepth); + } + } bool isOccluded = (reverseZ != 0u) ? (nearDepth < hzbDepth - occlusionBias) diff --git a/Sources/UntoldEngine/Systems/CullingSystem.swift b/Sources/UntoldEngine/Systems/CullingSystem.swift index 643d36ad..6033dcc7 100644 --- a/Sources/UntoldEngine/Systems/CullingSystem.swift +++ b/Sources/UntoldEngine/Systems/CullingSystem.swift @@ -342,6 +342,11 @@ func initFrustumCulllingCompute() { public func buildHZBDepthPyramid(_ commandBuffer: MTLCommandBuffer, eyeIndex: Int? = nil) { // Per-eye stereo path for XR + // Current XR stereo rendering builds the shared mono HZB once after both eyes + // are rendered, so mixed-mode opaque-depth snapshotting is handled by the + // mono path below. If this per-eye path is wired up for mixed mode later, it + // needs matching per-eye opaque depth snapshots to avoid glass depth culling + // virtual meshes behind transparent surfaces. if let ei = eyeIndex, renderInfo.isXRStereoMode { guard hzbBuildPyramidPipeline.success, let pipelineState = hzbBuildPyramidPipeline.pipelineState, @@ -387,7 +392,14 @@ public func buildHZBDepthPyramid(_ commandBuffer: MTLCommandBuffer, eyeIndex: In return } - guard let depthTexture = textureResources.depthMap else { + let hzbDepthSource: MTLTexture? + if renderInfo.isXRStereoMode, renderInfo.immersionStyle == .mixed { + hzbDepthSource = textureResources.hzbSourceDepthMap ?? textureResources.depthMap + } else { + hzbDepthSource = textureResources.depthMap + } + + guard let depthTexture = hzbDepthSource else { handleError(.textureMissing, "Depth Texture") renderInfo.hzbIsValid = false textureResources.hzbDebugMipTexture = nil @@ -496,8 +508,9 @@ func executeHZBOcclusionCulling( var mipCount = UInt32(max(0, renderInfo.hzbMipCount)) var reverseZFlag: UInt32 = renderInfo.reverseZEnabled ? 1 : 0 var viewProjectionMatrix = viewProjection - // XR uses a larger bias to tolerate one-frame VP drift from 90 Hz head-tracking. - var occlusionBias: Float = renderInfo.isXRStereoMode ? 0.02 : 1e-4 + // HZB is temporal, so use a conservative bias to tolerate one-frame camera + // and projection drift before declaring an object fully occluded. + var occlusionBias: Float = 0.02 let computeEncoder: MTLComputeCommandEncoder = commandBuffer.makeComputeCommandEncoder()! computeEncoder.label = "HZB Occlusion Culling pass" diff --git a/Sources/UntoldEngine/Systems/RenderingSystem.swift b/Sources/UntoldEngine/Systems/RenderingSystem.swift index f91a1837..0cec39f0 100644 --- a/Sources/UntoldEngine/Systems/RenderingSystem.swift +++ b/Sources/UntoldEngine/Systems/RenderingSystem.swift @@ -356,6 +356,23 @@ func gBufferPass(graph: inout [String: RenderPass], shadowPass: RenderPass) { id: "batchedModel", dependencies: [modelPass.id], execute: RenderPasses.batchedModelExecution ) graph[batchedModelPass.id] = batchedModelPass + + // HZB is temporal: the depth captured during this frame is consumed by + // next-frame culling. In XR mixed mode, capture opaque depth before the + // transparency pass writes glass depth for compositor edges. + let opaqueDepthAnchorId: String + if renderInfo.isXRStereoMode, renderInfo.immersionStyle == .mixed { + let hzbDepthSourcePass = RenderPass( + id: "hzbDepthSource", + dependencies: [batchedModelPass.id], + execute: RenderPasses.copyOpaqueDepthForHZBExecution + ) + graph[hzbDepthSourcePass.id] = hzbDepthSourcePass + opaqueDepthAnchorId = hzbDepthSourcePass.id + } else { + opaqueDepthAnchorId = batchedModelPass.id + } + // Update SSAO to depend on batched pass let ssaoPass = RenderPass( id: "ssao", @@ -368,7 +385,7 @@ func gBufferPass(graph: inout [String: RenderPass], shadowPass: RenderPass) { // Note: ssaoOptimizedExecution handles all blur/upsample internally // No need for separate ssaoBlur pass in the graph - let lightPass = RenderPass(id: "lightPass", dependencies: [batchedModelPass.id, modelPass.id, shadowPass.id, ssaoPass.id], execute: RenderPasses.lightExecution) + let lightPass = RenderPass(id: "lightPass", dependencies: [opaqueDepthAnchorId, modelPass.id, shadowPass.id, ssaoPass.id], execute: RenderPasses.lightExecution) graph[lightPass.id] = lightPass } diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.air b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.air index df07c23a..0b1bdcee 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.air and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.air differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib index e7634169..09022fd9 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.air b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.air index b1a09602..d7aa3513 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.air and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.air differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.metallib index 300cafb2..a0f55022 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.air b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.air index e9ba3b45..d5fe7ca0 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.air and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.air differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.metallib index a114b57d..4d3a8b62 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.air b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.air index 2866d7fa..43df5222 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.air and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.air differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.metallib index a34d7efd..c703845a 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.air b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.air index 26ef7b18..4a2d7c21 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.air and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.air differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib index a8892d1e..6084df93 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib index c55153bc..aad6b539 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib differ diff --git a/docs/API/UsingTheExporter.md b/docs/API/UsingTheExporter.md index 8afeaac9..a637844b 100644 --- a/docs/API/UsingTheExporter.md +++ b/docs/API/UsingTheExporter.md @@ -91,6 +91,7 @@ Common options: - `--all-meshes`: optional include hidden meshes - `--debug-aabb-only`: optional emit debug AABB payloads instead of geometry - `--quadtree`: optional partition tiles using a quad-tree instead of a uniform grid +- `--scene-profile `: optional streaming radius profile, defaults to `auto`. Radii are always proportional to scene size — no fixed distances to hand-tune. Use `outdoor` for cities, terrain, and large exterior scenes if auto-detection misses. - `--floor-count `: optional number of vertical floors to split each tile into - `--blender `: optional wrapper-level Blender override diff --git a/scripts/export-untold-tiles b/scripts/export-untold-tiles index 45c04e44..22de92e9 100755 --- a/scripts/export-untold-tiles +++ b/scripts/export-untold-tiles @@ -32,6 +32,10 @@ Common tile exporter flags: --quadtree Use floor+quadtree partitioning. Works with raw USD/USDZ (no pre-annotation step needed) — the exporter classifies objects inline. Also honours pre-baked __UT_ metadata when present. + --scene-profile P Streaming radius profile: auto (default), indoor, or outdoor. + Radii are always proportional to scene size — no fixed distances + to tweak. 'auto' infers the profile; use 'outdoor' to force + city/open-world bands if auto-detection misses. --floor-count N Pin the number of floors (inline quadtree mode). Use when auto-detection is wrong due to outlier objects inflating the Z range. --floor-band-height N Pin the per-floor height in metres. Floor count is derived diff --git a/scripts/tilestreamingpartition.py b/scripts/tilestreamingpartition.py index c42c20e4..7011e7ae 100755 --- a/scripts/tilestreamingpartition.py +++ b/scripts/tilestreamingpartition.py @@ -225,21 +225,27 @@ def append_worker_progress(progress_file, event): SWITCH_DISTANCE_OUTER_MARGIN = 0.75 # --- Quadtree / semantic-tier streaming radii ----------------- -# Used when a scene has been pre-annotated with the Untold phase-1+2 Blender -# script. Each semantic tier gets its own streaming and unload radius so the -# engine loads only the geometry appropriate for the current camera distance. -# -# Adjust these to taste for your scene scale: -# ExteriorShell — visible from far away; wide band -# StructuralInterior — walls, ceilings, floors; load when approaching -# RoomContents — furniture, fixtures; load when near a room entrance -# FineProps — small details; load only when very close -TIER_STREAMING_RADII = { - "ExteriorShell": {"streaming": 80.0, "unload": 120.0, "priority": 15}, - "StructuralInterior": {"streaming": 15.0, "unload": 25.0, "priority": 10}, - "RoomContents": {"streaming": 5.0, "unload": 8.0, "priority": 8}, - "FineProps": {"streaming": 2.0, "unload": 4.0, "priority": 5}, +# Fractions of scene_half_diag — converted to world-space metres once at +# export time so radii scale automatically with any scene size. +# indoor — tight bands for room/building interiors +# outdoor — wider bands for cities, open-world, and street scenes +# 'auto' (default) infers the profile from scene footprint and tier distribution. +TIER_STREAMING_FRACTIONS = { + "indoor": { + "ExteriorShell": {"streaming": 0.80, "unload": 1.20, "priority": 15}, + "StructuralInterior": {"streaming": 0.30, "unload": 0.50, "priority": 10}, + "RoomContents": {"streaming": 0.10, "unload": 0.18, "priority": 8}, + "FineProps": {"streaming": 0.03, "unload": 0.06, "priority": 5}, + }, + "outdoor": { + "ExteriorShell": {"streaming": 0.35, "unload": 0.55, "priority": 15}, + "StructuralInterior": {"streaming": 0.25, "unload": 0.40, "priority": 12}, + "RoomContents": {"streaming": 0.10, "unload": 0.18, "priority": 8}, + "FineProps": {"streaming": 0.04, "unload": 0.08, "priority": 5}, + }, } +SCENE_STREAMING_PROFILE = "auto" # auto | indoor | outdoor +_ACTIVE_TIER_RADII: dict = {} # Tiers for which HLOD and LOD variants are generated during quadtree export. # RoomContents (stream=5m) and FineProps (stream=2m) have radii too small for @@ -2442,6 +2448,84 @@ def compute_shared_streaming_radii(scene_half_diag): return r, ur +def infer_streaming_profile(use_quadtree, node_tier_groups, scene_half_diag, base_tile_size): + """Return 'indoor' or 'outdoor' for this export. + + Explicit CLI choice wins. Auto falls back to 'indoor' unless the scene + looks like a large outdoor/city layout: broad footprint, few ExteriorShell + objects, and most quadtree groups classified as StructuralInterior. + """ + requested = (SCENE_STREAMING_PROFILE or "auto").lower() + if requested in TIER_STREAMING_FRACTIONS: + return requested + + if not use_quadtree or not node_tier_groups: + return "indoor" + + tier_counts: dict = {} + for (_, tier), objs in node_tier_groups.items(): + tier_counts[tier] = tier_counts.get(tier, 0) + len(objs) + + total = sum(tier_counts.values()) + if total == 0: + return "indoor" + + exterior_fraction = tier_counts.get("ExteriorShell", 0) / total + structural_fraction = tier_counts.get("StructuralInterior", 0) / total + large_footprint = scene_half_diag >= max(150.0, base_tile_size * 8.0) + + if large_footprint and exterior_fraction < 0.10 and structural_fraction >= 0.65: + return "outdoor" + + return "indoor" + + +def compute_tier_radii(scene_half_diag, profile): + """Convert fraction table for *profile* to world-space metres.""" + fractions = TIER_STREAMING_FRACTIONS.get(profile, TIER_STREAMING_FRACTIONS["indoor"]) + return { + tier: { + "streaming": max(1.0, scene_half_diag * v["streaming"]), + "unload": max(2.0, scene_half_diag * v["unload"]), + "priority": v["priority"], + } + for tier, v in fractions.items() + } + + +def init_tier_radii(scene_half_diag, profile): + global _ACTIVE_TIER_RADII + _ACTIVE_TIER_RADII = compute_tier_radii(scene_half_diag, profile) + + +def tier_streaming_radii(tier): + return _ACTIVE_TIER_RADII.get(tier, {}) + + +def log_streaming_profile(scene_bounds, scene_half_diag, resolved_profile): + """Print a human-readable summary of the resolved tier streaming radii.""" + bx = scene_bounds["max"][0] - scene_bounds["min"][0] + by = scene_bounds["max"][1] - scene_bounds["min"][1] + bz = scene_bounds["max"][2] - scene_bounds["min"][2] + profile_label = resolved_profile + if SCENE_STREAMING_PROFILE == "auto": + profile_label = f"auto → {resolved_profile}" + print( + f"Streaming profile : {profile_label}\n" + f" Scene dimensions : {bx:.1f}m (W) × {by:.1f}m (D) × {bz:.1f}m (H)\n" + f" Footprint half-diag : {scene_half_diag:.1f}m ← multiplier base" + ) + fractions = TIER_STREAMING_FRACTIONS.get(resolved_profile, TIER_STREAMING_FRACTIONS["indoor"]) + for tier, v in fractions.items(): + s = max(1.0, scene_half_diag * v["streaming"]) + u = max(2.0, scene_half_diag * v["unload"]) + print( + f" {tier:25s}: " + f"{v['streaming']:.2f} × {scene_half_diag:.1f}m = {s:7.1f}m stream | " + f"{v['unload']:.2f} × {scene_half_diag:.1f}m = {u:7.1f}m unload" + ) + + # ============================================================ # SECTION 11: MEMORY ESTIMATION # ============================================================ @@ -3618,6 +3702,15 @@ def run(): if SPLIT_SPANNING_OBJECTS else f", {capped_count} spanning→shared bucket") ) + # ------------------------------------------------------------------ + # Resolve streaming profile and build per-tier radius table + # ------------------------------------------------------------------ + resolved_profile = infer_streaming_profile( + use_quadtree, node_tier_groups, scene_half_diag, base_tile + ) + init_tier_radii(scene_half_diag, resolved_profile) + log_streaming_profile(scene_bounds, scene_half_diag, resolved_profile) + # ------------------------------------------------------------------ # Scene name and manifest path # ------------------------------------------------------------------ @@ -3650,6 +3743,11 @@ def run(): "tile_size_mode": "auto" if AUTO_TILE_SIZE else "manual", "tile_size": {"x": tile_size_x, "y": tile_size_y, "z": tile_size_z}, "scene_bounds": {"min": list(sb_usd["min"]), "max": list(sb_usd["max"])}, + "streaming_profile": { + "requested": SCENE_STREAMING_PROFILE, + "resolved": resolved_profile, + "scene_half_diag": round(scene_half_diag, 3), + }, "streaming_defaults": { "streaming_radius": streaming_r, "unload_radius": unload_r, @@ -3707,7 +3805,7 @@ def run(): by_tier.setdefault(tier, 0) by_tier[tier] += len(objs) for tier, count in sorted(by_tier.items()): - radii = TIER_STREAMING_RADII.get(tier, {}) + radii = tier_streaming_radii(tier) print(f" {tier:25s}: {count:5d} objects " f"stream={radii.get('streaming','?')}m " f"unload={radii.get('unload','?')}m") @@ -3720,7 +3818,7 @@ def run(): center = aabb_center(aabb_usd) if aabb_usd else [0,0,0] est_mem = sum(estimate_object_memory_bytes(o, mesh_size_cache) for o in tile_objs) - tier_radii = TIER_STREAMING_RADII.get(tier, {}) + tier_radii = tier_streaming_radii(tier) floor_id = 0 for obj in tile_objs: m = metadata_map.get(obj.name) @@ -3953,7 +4051,7 @@ def run(): for o in tile_objs) # Fetch per-tier streaming radii; fall back to scene defaults. - tier_radii = TIER_STREAMING_RADII.get(tier, {}) + tier_radii = tier_streaming_radii(tier) tile_stream = tier_radii.get("streaming", streaming_r) tile_unload = tier_radii.get("unload", unload_r) tile_priority = tier_radii.get("priority", DEFAULT_STREAMING_PRIORITY) @@ -4360,6 +4458,17 @@ def parse_args(argv): "Otherwise the exporter runs the annotation pass inline — no separate Blender step needed." ), ) + parser.add_argument( + "--scene-profile", + choices=("auto", "indoor", "outdoor"), + default="auto", + help=( + "Streaming radius profile for semantic tiers. " + "'auto' infers the profile from scene size and tier distribution. " + "'outdoor' forces city/open-world bands; 'indoor' forces tight room-scale bands. " + "Radii are always proportional to scene_half_diag — no hardcoded distances." + ), + ) parser.add_argument( "--floor-count", type=int, @@ -4409,6 +4518,7 @@ def apply_cli_overrides(args): global PERIMETER_MODE global PERIMETER_DEPTH global FORCE_QUADTREE + global SCENE_STREAMING_PROFILE global INLINE_FLOOR_COUNT_OVERRIDE global INLINE_FLOOR_BAND_HEIGHT_OVERRIDE @@ -4452,6 +4562,8 @@ def apply_cli_overrides(args): PERIMETER_DEPTH = args.perimeter_depth if getattr(args, "quadtree", False): FORCE_QUADTREE = True + if getattr(args, "scene_profile", None): + SCENE_STREAMING_PROFILE = args.scene_profile if getattr(args, "floor_count", None) is not None: INLINE_FLOOR_COUNT_OVERRIDE = args.floor_count if getattr(args, "floor_band_height", None) is not None: