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
42 changes: 30 additions & 12 deletions Sources/CShaderTypes/ShaderTypes.h
Original file line number Diff line number Diff line change
Expand Up @@ -486,38 +486,55 @@ typedef struct{
float opacity;
}GaussianSplat;

// position stays full float precision (fed directly into world/view/clip-space matrix math,
// where half-precision error would visibly drift); covariance and color/opacity are
// half-precision, matching the density another native Metal/simd Gaussian-splat renderer
// (MetalSplatter) uses for the same fields. simd_half3/simd_half4 pack tightly (8 bytes each,
// no padding between them) unlike simd_float3 (16-byte-aligned even for a 3-component vector),
// so this is 48 bytes total per splat vs. the previous 128 — not just "half the bytes" of the
// 3 vector fields, but a ~2.7x reduction overall once the float3 alignment padding that also
// disappears is accounted for. colorAndOpacity folds opacity into color's 4th component
// (again matching MetalSplatter) since a lone scalar field here would otherwise force the
// same kind of alignment padding this change is trying to eliminate.
typedef struct{
simd_float3 position;
float opacity;
simd_float3 color;
float _pad0;
simd_float3 covA;
float _pad1;
simd_float3 covB;
float _pad2;
simd_half3 covA;
simd_half3 covB;
simd_half4 colorAndOpacity; // .xyz = SH0 base color, .w = opacity
}EncodedGaussianSplat;

// Higher-order SH coefficients are stored in a separate packed half buffer.
// Higher-order SH coefficients are stored in a separate packed byte buffer,
// quantized to a fixed [-1, 1] range: byte = round(clamp(x,-1,1)*127)+128,
// dequantized on read as (byte-128)/128 — see loadGaussianSHCoefficient.
// Layout per splat: R[1...n], G[1...n], B[1...n]. The DC coefficient remains
// represented by EncodedGaussianSplat.color.
// represented by EncodedGaussianSplat.colorAndOpacity.xyz.
typedef struct{
uint degree;
uint coefficientsPerChannel;
uint higherOrderCoefficientsPerSplat;
uint _pad0;
}GaussianSHMetadata;

// Per-splat conic/radius/color, computed once per splat per frame by the gaussianPreprocess
// Per-splat conic/axes/color, computed once per splat per frame by the gaussianPreprocess
// compute kernel instead of redundantly 4x per splat (once per instanced quad vertex) in the
// draw vertex shader — see gaussianPreprocess in Gaussians.metal. Indexed by the same
// original splat index as EncodedGaussianSplat.
//
// axis1/axis2 are the projected covariance ellipse's two (orthogonal) kGaussianQuadSigma-sigma
// semi-axis vectors in screen pixels — i.e. eigenvectors of the 2D covariance scaled by
// kGaussianQuadSigma*sqrt(eigenvalue) — used to build a tight, rotated quad instead of an
// axis-aligned bounding box. An axis-aligned box has
// to cover a rotated ellipse's full extent along screen X/Y, which for an anisotropic splat
// (the common case — Gaussians are oriented however the surface they came from sits) can be
// several times larger in area than the ellipse itself, costing that many more rasterized/
// shaded fragments regardless of how cheap the per-fragment TBDR blend itself is.
typedef struct{
simd_float3 conic;
float _pad0;
simd_float3 color;
float _pad1;
simd_float2 radius;
simd_float2 _pad2;
simd_float2 axis1;
simd_float2 axis2;
}GaussianPrecomputedSplat;

typedef enum{
Expand All @@ -540,6 +557,7 @@ typedef enum{
gaussianTBDRRenderViewPortIndex,
gaussianTBDRRenderReverseZIndex,
gaussianTBDRRenderPrecomputedIndex,
gaussianTBDRRenderDebugColorIndex,
}GaussianTBDRRenderBufferIndices;

typedef enum{
Expand Down
13 changes: 1 addition & 12 deletions Sources/Sandbox/GameScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,6 @@
setSceneReady(success)
}
*/
/*
let sceneRoot = createEntity()
setEntityStreamScene(
entityId: sceneRoot,
url: URL(fileURLWithPath: "/path/to/local/json")
) { success in
if success {
loadSceneAuthored(url: URL(fileURLWithPath: "/path/to/local/json"))
}
setSceneReady(success)
}
*/

// Uncomment to render a streamed scene
}
Expand All @@ -57,6 +45,7 @@
InputSystem.shared.registerKeyboardEvents()
InputSystem.shared.registerMouseEvents()
bypassPostProcessing = false
setSpatialDebug(.lodLevels(false))
}

private func setupDefaultSceneObjects() {
Expand Down
135 changes: 120 additions & 15 deletions Sources/UntoldEngine/ECS/Components.swift
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,23 @@ public enum LODResidencyState {
case loading // Mesh is being loaded
}

/// Shared by `LODLevel` and `GaussianLODLevel` so `isLODLevelResident`/`findFallbackLODLevel`
/// (`LODSystem.swift`) can implement residency/fallback selection once for both mesh and
/// Gaussian LOD instead of each component re-declaring the same algorithm.
protocol LODResidencyLevel {
var residencyState: LODResidencyState { get }
/// Whether this level actually has a usable payload — `residencyState` alone isn't
/// trusted, mirroring the double-check both components already made before this was shared.
var isPopulated: Bool { get }
}

/// Shared by `LODLevel` and `GaussianLODLevel` so `selectLODIndex` (`LODSystem.swift`) can pick
/// a distance-based tier once for both mesh and Gaussian LOD instead of each system
/// re-declaring the same threshold/hysteresis algorithm.
protocol LODDistanceLevel {
var maxDistance: Float { get }
}

public struct LODLevel {
public var mesh: [Mesh] // Meshes for this lod
public var maxDistance: Float // Switch to next LOD beyond this
Expand All @@ -431,6 +448,14 @@ public struct LODLevel {
}
}

extension LODLevel: LODResidencyLevel {
var isPopulated: Bool {
!mesh.isEmpty
}
}

extension LODLevel: LODDistanceLevel {}

public class LODComponent: Component {
public var lodLevels: [LODLevel] = [] // Sorted by distance (LOD0 first)
public var currentLOD: Int = 0 // Active LOD index (what's actually rendering)
Expand All @@ -451,26 +476,106 @@ public class LODComponent: Component {

/// Check if the desired LOD level has a resident mesh
public func isLODResident(_ lodIndex: Int) -> Bool {
guard lodIndex >= 0, lodIndex < lodLevels.count else { return false }
let level = lodLevels[lodIndex]
return level.residencyState == .resident && !level.mesh.isEmpty
isLODLevelResident(lodLevels, lodIndex)
}

/// Find the best available fallback LOD (coarser than desired)
public func findFallbackLOD(from desiredIndex: Int) -> Int? {
// Try coarser LODs first (higher index = lower detail)
for i in (desiredIndex + 1) ..< lodLevels.count {
if isLODResident(i) {
return i
}
}
// Then try finer LODs (lower index = higher detail)
for i in (0 ..< desiredIndex).reversed() {
if isLODResident(i) {
return i
}
findFallbackLODLevel(lodLevels, from: desiredIndex)
}
}

// MARK: - Progressive Gaussian LOD Component

/// One pre-baked quality tier for a progressive Gaussian splat asset.
/// LOD0 is expected to be the full-resolution tier; later indices are progressively coarser.
public struct GaussianLODLevel {
public var buffers: GaussianComponent?
public var maxDistance: Float
public var url: URL?
public var residencyState: LODResidencyState = .unknown
var loadTask: Task<Void, Never>?
/// Bake-time mean of this tier's kept splats' squared major-axis extent — see
/// `estimatedGaussianOverdraw`. Baked into the `.untoldgs` file itself
/// (`UntoldGSFormat`/`bakeGaussianSplatProgressiveTiers`) and populated automatically by
/// `loadGaussianLODLevel` once this tier's file is actually read. `nil` only before that —
/// i.e. this tier hasn't loaded yet — in which case `clampGaussianLODForOverdraw` falls
/// back to distance-only LOD selection for it.
public var meanSquaredSplatExtent: Float?

public init(maxDistance: Float, url: URL? = nil) {
self.maxDistance = maxDistance
self.url = url
}
}

extension GaussianLODLevel: LODResidencyLevel {
var isPopulated: Bool {
buffers != nil
}
}

extension GaussianLODLevel: LODDistanceLevel {}

/// Runtime state for a progressively streamed Gaussian splat prop.
/// The renderer still consumes a normal `GaussianComponent`; this component owns the
/// per-tier residency and `GaussianLODSystem` copies the selected tier onto the live
/// `GaussianComponent`.
public class GaussianLODComponent: Component {
public var lodLevels: [GaussianLODLevel] = []
public var currentLOD: Int = -1
public var desiredLOD: Int = 0
public var forcedLOD: Int?
public var isUsingFallback: Bool = false

/// The LOD index pure distance+hysteresis selection last landed on, before the
/// overdraw-aware clamp is applied — see `GaussianLODSystem.selectDesiredLOD`. Kept
/// separate from `desiredLOD` (the final, possibly overdraw-forced-coarser target used for
/// residency/streaming/`applyLOD`) so an overdraw-forced tier doesn't retroactively bias
/// the hysteresis anchor `selectLODIndex` uses next frame — otherwise a frame where overdraw
/// forces LOD 3 would make LOD 3 "the tier we're logically at" for the *next* frame's
/// distance-only hysteresis math too, even though distance alone would have picked LOD 1.
var distanceSelectedLOD: Int = 0

/// Distance to camera the last time this entity's LOD was fully re-evaluated — see
/// `GaussianLODSystem.update`'s per-entity fast path, which forces a refresh when this
/// entity (not just the camera) has moved enough to plausibly cross a LOD threshold,
/// independent of the camera-movement/frame-interval throttle. `nil` forces an evaluation
/// the first time this entity is seen.
var lastEvaluatedDistance: Float?

/// `true` when the caller explicitly supplied a `boundingBoxHalfExtent` (always the case
/// for the streaming path, optional for the non-streaming path). When `false`,
/// `loadGaussianLODLevel` auto-populates `LocalTransformComponent.boundingBox` from the
/// first (coarsest) tier's real splat data once it loads, instead of leaving the entity on
/// its default placeholder box forever.
var hasExplicitBoundingBox: Bool = false

/// Last state GaussianLODSystem's diagnostic log reported for this entity — used only to
/// dedup consecutive identical log lines (log on change, not every evaluation).
var lastLoggedDesiredLOD: Int = -2
var lastLoggedActualLOD: Int = -2

public required init() {}

public func isLODResident(_ lodIndex: Int) -> Bool {
isLODLevelResident(lodLevels, lodIndex)
}

public func findFallbackLOD(from desiredIndex: Int) -> Int? {
findFallbackLODLevel(lodLevels, from: desiredIndex)
}

/// Cancels in-flight loads and drops residency for every tier. Does not touch
/// currentLOD/desiredLOD — callers decide those based on whether this is a
/// stream-out (reset to coarsest) or full teardown (component removed right after).
func releaseAllLevelResources() {
for index in lodLevels.indices {
lodLevels[index].loadTask?.cancel()
lodLevels[index].loadTask = nil
lodLevels[index].buffers = nil
lodLevels[index].residencyState = .notResident
}
return nil
}
}

Expand Down
13 changes: 13 additions & 0 deletions Sources/UntoldEngine/Renderer/RenderPasses.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4540,6 +4540,19 @@ public enum RenderPasses {
index: Int(gaussianTBDRRenderPrecomputedIndex.rawValue)
)

var gaussianLODDebugColor = simd_float4(0, 0, 0, 0)
if SpatialDebugVisualization.shared.colorRenderablesByLOD,
let gaussianLOD = scene.get(component: GaussianLODComponent.self, for: entityId)
{
let color = lodDebugColor(for: gaussianLOD.currentLOD)
gaussianLODDebugColor = simd_float4(color.x, color.y, color.z, 1.0)
}
renderEncoder.setVertexBytes(
&gaussianLODDebugColor,
length: MemoryLayout<simd_float4>.stride,
index: Int(gaussianTBDRRenderDebugColorIndex.rawValue)
)

renderEncoder.drawPrimitivesTracked(type: .triangleStrip,
vertexStart: 0,
vertexCount: 4,
Expand Down
1 change: 1 addition & 0 deletions Sources/UntoldEngine/Renderer/UntoldEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,7 @@ public class UntoldRenderer: NSObject, MTKViewDelegate {

// 3. LOD selection (decides which representation is active, checks residency)
LODSystem.shared.update(deltaTime: fixedStep)
GaussianLODSystem.shared.update(deltaTime: fixedStep)

// 4. Flush events (residency and LOD change events are processed)
SystemEventBus.shared.flushEvents()
Expand Down
Loading
Loading