Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions Sources/UntoldEngine/Renderer/RenderInitializer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions Sources/UntoldEngine/Renderer/RenderPasses.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 [] }

Expand Down
1 change: 1 addition & 0 deletions Sources/UntoldEngine/Renderer/RenderResources.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 16 additions & 22 deletions Sources/UntoldEngine/Shaders/HZBCompute.metal
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 16 additions & 3 deletions Sources/UntoldEngine/Systems/CullingSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
19 changes: 18 additions & 1 deletion Sources/UntoldEngine/Systems/RenderingSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
}

Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
1 change: 1 addition & 0 deletions docs/API/UsingTheExporter.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <auto|indoor|outdoor>`: 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 <number>`: optional number of vertical floors to split each tile into
- `--blender <path>`: optional wrapper-level Blender override

Expand Down
4 changes: 4 additions & 0 deletions scripts/export-untold-tiles
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading