diff --git a/Sources/UntoldEngine/Shaders/Gaussians.metal b/Sources/UntoldEngine/Shaders/Gaussians.metal index d3500f195..0b505a996 100644 --- a/Sources/UntoldEngine/Shaders/Gaussians.metal +++ b/Sources/UntoldEngine/Shaders/Gaussians.metal @@ -33,6 +33,14 @@ constant float GAUSSIAN_SH_C3[7] = { -0.5900435899266435f }; +// Hard ceiling on a splat's screen-space half-extent, in pixels. Without this, radius +// grows roughly as 1/distance as the camera approaches a splat (see the Jacobian in +// computeCov2D), so a single splat can balloon to cover a huge fraction of the screen at +// close range — every one of those extra pixels pays full fragment-shading cost. Trades a +// little softness/tail accuracy at extreme close range for a bounded worst-case overdraw +// cost per splat. +constant float kGaussianMaxScreenRadius = 128.0f; + inline uint unpackIndex(uint64_t packed) { return (uint)(packed & 0xffffffffu); } inline uint unpackDepthKey(uint64_t packed) { return (uint)(packed >> 32); } @@ -190,9 +198,9 @@ float3 computeCov2D(float4 splatCenter, return float3(cov[0][0], cov[0][1], cov[1][1]); } -// Compute inverse covariance and a scalar radius (~3σ) in pixels +// Compute inverse covariance and a per-axis screen-space half-extent (~3σ) in pixels float3 computeInverseCovarianceConic(float3 cov2D, - thread float &radius, + thread float2 &radius, thread bool &valid) { float a = cov2D.x; @@ -202,7 +210,7 @@ float3 computeInverseCovarianceConic(float3 cov2D, float det = a * c - b * b; if (det == 0.0f) { valid = false; - radius = 0.0f; + radius = float2(0.0f); return float3(0.0f); } @@ -214,14 +222,17 @@ float3 computeInverseCovarianceConic(float3 cov2D, a * detInv ); - // Eigenvalues of the 2×2 covariance - float mid = 0.5f * (a + c); - float disc = max(0.1f, mid * mid - det); - float lambda1 = mid + sqrt(disc); - float lambda2 = mid - sqrt(disc); - - // Radius in pixels covering ~3σ of the larger axis - radius = ceil(3.0f * sqrt(max(lambda1, lambda2))); + // Tight axis-aligned half-extent covering ~3σ along each screen axis independently. + // This is the *exact* per-axis extent of the ellipse {d : dᵀ·conic·d <= 9} — it depends + // only on cov2D's diagonal (a, c), not its off-diagonal correlation term (b), regardless + // of how the ellipse is rotated. That makes it strictly tighter than (or equal to) the + // previous square sized by the larger eigenvalue: an anisotropic splat (e.g. a thin, + // flat-surface splat) no longer forces both screen axes out to the size of its longest + // axis, so fewer wasted fragments get rasterized, shaded, and discarded. + radius = float2( + min(ceil(3.0f * sqrt(a)), kGaussianMaxScreenRadius), + min(ceil(3.0f * sqrt(c)), kGaussianMaxScreenRadius) + ); valid = true; return conic; @@ -326,11 +337,11 @@ vertex GaussianOutData vertexGaussianTBDRShader( uniforms.projectionMatrix, viewport); - float extent = 0.0f; + float2 extent = float2(0.0f); bool valid = true; out.conic = computeInverseCovarianceConic(cov2D, extent, valid); - if (!valid || extent <= 0.0f) { + if (!valid || extent.x <= 0.0f || extent.y <= 0.0f) { return out; } @@ -362,6 +373,32 @@ fragment GaussianTBDRFragmentStore fragmentGaussianTBDRShader( discard_fragment(); } + // Early-terminate once this pixel is effectively opaque: every splat still to come in + // sorted order would contribute (1 - accumulatedAlpha) ~ 0 regardless of its own color + // or occlusion, so there's no need to pay for the opaque-depth read or the power/exp + // blend math below. This is the same "early stop" reference Gaussian-splat rasterizers + // use per-pixel, and is what keeps heavily overlapping splats (close-range viewing) + // from each paying full shading cost for zero visible contribution. + if (previousValues.color.a >= half(0.999h)) { + out.values = previousValues; + return out; + } + + // Evaluate the Gaussian falloff before touching the opaque-depth texture: this is pure + // ALU (no memory fetch), and most of a splat's rasterized area — out near the 3σ quad + // edge — has negligible alpha. Rejecting those tail fragments here means they never pay + // for the depth-texture read at all, on top of never reaching the blend math below. + const float projYSign = 1.0f; + float2 d = calcScreenSpaceDelta(in.position.xy, in.coordxy, projYSign); + float power = calcPowerFromConic(in.conic, d); + + half alpha = half(saturate(in.alpha * exp(power))); + if (alpha < half(1.0f / 255.0f)) { + // Contribution rounds to nothing — skip the opaque-depth read and blend math below. + out.values = previousValues; + return out; + } + // Occlude against opaque geometry already in the depth buffer (a snapshot taken // before this pass — see gaussianExecution). in.position.z is already normalized // device depth from the same projection the opaque pass used, so it's directly @@ -376,24 +413,11 @@ fragment GaussianTBDRFragmentStore fragmentGaussianTBDRShader( ? (splatDepth + depthBias < storedOpaqueDepth) : (splatDepth > storedOpaqueDepth + depthBias); if (occludedByOpaque) { - // discard_fragment() only suppresses standard color/depth attachment writes — - // it does NOT stop this function from computing and returning a value for the - // custom imageblock struct below, so relying on it alone here would let an - // occluded splat's contribution reach the accumulator anyway. Return the - // accumulator unchanged instead, so an occluded splat contributes nothing. + // Return the accumulator unchanged so an occluded splat contributes nothing. out.values = previousValues; return out; } - const float projYSign = 1.0f; - float2 d = calcScreenSpaceDelta(in.position.xy, in.coordxy, projYSign); - float power = calcPowerFromConic(in.conic, d); - - half alpha = half(saturate(in.alpha * exp(power))); - if (alpha < half(1.0f / 255.0f)) { - discard_fragment(); - } - half oneMinusAccumulatedAlpha = half(1.0h - previousValues.color.a); half4 colorWithPremultipliedAlpha = half4(half3(in.color) * alpha, alpha); diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.air b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.air index c0ab4b40f..f754046ed 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 8e3e6ff4c..08e6f7379 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-iossim.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-iossim.metallib index 359da21ea..114d2449b 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-iossim.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-iossim.metallib differ diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.air b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.air index efed062af..8c4ed9b01 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 b1e402ead..717bb8c08 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 a49a9a7f9..f2ce1e437 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 cd99f5a94..c5a9d2e1b 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 a4b68449c..319a8ed90 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 02e17e677..8003cbe32 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 6ecccd2a2..52be51659 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 fabbf86e3..7d886b5f5 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 379ad6841..460c44fe8 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib differ diff --git a/Sources/UntoldEngine/Utils/PLYReader.swift b/Sources/UntoldEngine/Utils/PLYReader.swift index 4c98f7540..7022f8877 100644 --- a/Sources/UntoldEngine/Utils/PLYReader.swift +++ b/Sources/UntoldEngine/Utils/PLYReader.swift @@ -99,19 +99,71 @@ public class PLYReader { parsed = try parseBinaryGaussians(data: data, bodyOffset: bodyOffset, element: vertexElement, bigEndian: true, shSchema: shSchema) } + if let shSchema, + parsed.1.count != vertexElement.count * shSchema.coefficientsPerSplat + { + throw PLYError.invalidData("Spherical-harmonic coefficient data is incomplete") + } + + let (filteredSplats, filteredCoefficients) = filterNegligibleOpacitySplats( + splats: parsed.0, + shCoefficients: parsed.1, + coefficientsPerSplat: shSchema?.coefficientsPerSplat ?? 0 + ) + let sphericalHarmonics = shSchema.map { GaussianSphericalHarmonics( degree: $0.degree, coefficientsPerChannel: $0.coefficientsPerChannel, - coefficients: parsed.1 + coefficients: filteredCoefficients ) } - if let shSchema, - parsed.1.count != vertexElement.count * shSchema.coefficientsPerSplat - { - throw PLYError.invalidData("Spherical-harmonic coefficient data is incomplete") + return GaussianSplatAsset(splats: filteredSplats, sphericalHarmonics: sphericalHarmonics) + } + + /// A splat's peak alpha (at its own center, where the Gaussian falloff is 1) equals its + /// opacity — see fragmentGaussianTBDRShader's `alpha = opacity * exp(power)`, power <= 0. + /// The shader itself discards any fragment below this same threshold, so a splat whose + /// opacity never reaches it can never contribute a visible pixel anywhere in its extent. + /// Dropping it here removes it from vertex shading, rasterization, and per-fragment ALU + /// entirely instead of paying that cost every frame only to discard the result — this is + /// a lossless cull (identical rendered image), not a quality/perf tradeoff. + private static let minRetainedOpacity: Float = 1.0 / 255.0 + + private static func filterNegligibleOpacitySplats( + splats: [GaussianSplat], + shCoefficients: [Float], + coefficientsPerSplat: Int + ) -> ([GaussianSplat], [Float]) { + guard splats.contains(where: { $0.opacity < minRetainedOpacity }) else { + return (splats, shCoefficients) } - return GaussianSplatAsset(splats: parsed.0, sphericalHarmonics: sphericalHarmonics) + + var keptSplats: [GaussianSplat] = [] + keptSplats.reserveCapacity(splats.count) + var keptCoefficients: [Float] = [] + if coefficientsPerSplat > 0 { + keptCoefficients.reserveCapacity(shCoefficients.count) + } + + for (index, splat) in splats.enumerated() { + guard splat.opacity >= minRetainedOpacity else { continue } + keptSplats.append(splat) + if coefficientsPerSplat > 0 { + let start = index * coefficientsPerSplat + keptCoefficients.append(contentsOf: shCoefficients[start ..< start + coefficientsPerSplat]) + } + } + + Logger.log( + message: String( + format: "[Gaussian][PLY] Culled %d/%d splats below visibility threshold (opacity < %.4f)", + splats.count - keptSplats.count, splats.count, minRetainedOpacity + ), + category: LogCategory.gaussian.rawValue + ) + + return (keptSplats, keptCoefficients) } // MARK: - Header Parsing