diff --git a/Sources/UntoldEngine/Renderer/RenderPasses.swift b/Sources/UntoldEngine/Renderer/RenderPasses.swift index 6206a276..aa17f747 100644 --- a/Sources/UntoldEngine/Renderer/RenderPasses.swift +++ b/Sources/UntoldEngine/Renderer/RenderPasses.swift @@ -559,6 +559,22 @@ public enum RenderPasses { runtimeState.lock.unlock() } + /// Returns the current shadow entity candidate cache, rebuilding first if dirty. + /// Internal — exposed for testing via @testable import. Lets tests pin the exact + /// staleness bug this cache has had before: a non-streaming load that skips + /// invalidateShadowEntityCache() is silently absent from shadow candidates. + static func shadowEntityCandidatesForTesting() -> [EntityID] { + runtimeState.lock.lock() + let dirty = runtimeState.shadowCacheDirty + runtimeState.lock.unlock() + if dirty { + rebuildShadowEntityCache() + } + runtimeState.lock.lock() + defer { runtimeState.lock.unlock() } + return runtimeState.shadowEntityCandidates + } + private static func shadowCasterEntityIds(for cascadeIdx: Int) -> [EntityID] { ensureShadowCacheConfigured() guard let frustum = shadowFrustum(for: cascadeIdx) else { return [] } diff --git a/Tests/UntoldEngineRenderTests/EmissiveLightPassTest.swift b/Tests/UntoldEngineRenderTests/EmissiveLightPassTest.swift new file mode 100644 index 00000000..7d3bc8b2 --- /dev/null +++ b/Tests/UntoldEngineRenderTests/EmissiveLightPassTest.swift @@ -0,0 +1,138 @@ +// +// EmissiveLightPassTest.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import CShaderTypes +import Metal +import simd +@testable import UntoldEngine +import XCTest + +/// Deliberately builds its own minimal scene (camera + one cube, no lights) instead of +/// using BaseRenderSetup's default stadium/player/lights scene, so a material's emissive +/// contribution can be isolated from every other light source in the TBDR light pass. +final class EmissiveLightPassTest: BaseRenderSetup { + override func setUp() async throws { + try await super.setUp() + } + + override func tearDown() async throws { + destroyAllEntities() + try await super.tearDown() + } + + override func initializeAssets() { + // Intentionally empty — see class doc comment. + } + + @discardableResult + private func createTestCamera() -> EntityID { + let cameraEntity = createEntity() + createGameCamera(entityId: cameraEntity) + CameraSystem.shared.activeCamera = cameraEntity + cameraLookAt(entityId: cameraEntity, eye: simd_float3(0, 0, 5), target: simd_float3(0, 0, 0), up: simd_float3(0, 1, 0)) + return cameraEntity + } + + /// A black-albedo, fully rough, non-metallic cube filling most of the frame. With no + /// scene lights, its only possible source of brightness is its own emissive term. + @discardableResult + private func addUnlitCube(emissiveFactor: simd_float3) -> EntityID { + let entity = createEntity() + var meshes = BasicPrimitives.createCube(extent: 3.0) + let material = Material( + runtimeMaterial: RuntimeMaterialSource( + baseColorFactor: simd_float4(0, 0, 0, 1), + emissiveFactor: emissiveFactor, + metallicFactor: 0.0, + roughnessFactor: 1.0 + ), + device: renderInfo.device + ) + for meshIndex in meshes.indices { + for submeshIndex in meshes[meshIndex].submeshes.indices { + meshes[meshIndex].submeshes[submeshIndex].material = material + } + } + if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { + renderComponent.mesh = meshes + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/emissive-cube.untold") + } + if let local = scene.get(component: LocalTransformComponent.self, for: entity) { + local.boundingBox = Mesh.computeMeshBoundingBox(for: meshes) + } + setVisibleEntities() + return entity + } + + private func maxBrightnessAnywhere(in texture: MTLTexture) -> Float { + precondition(texture.pixelFormat == .rgba16Float, "Test assumes the deferred color target is rgba16Float") + let width = texture.width + let height = texture.height + let bytesPerPixel = 8 + let bytesPerRow = width * bytesPerPixel + let dataSize = bytesPerRow * height + let rawData = UnsafeMutableRawPointer.allocate(byteCount: dataSize, alignment: 1) + defer { rawData.deallocate() } + texture.getBytes(rawData, bytesPerRow: bytesPerRow, from: MTLRegionMake2D(0, 0, width, height), mipmapLevel: 0) + let ptr = rawData.bindMemory(to: Float16.self, capacity: width * height * 4) + var best: Float = 0 + for i in 0 ..< (width * height) { + let r = Float(ptr[i * 4 + 0]) + let g = Float(ptr[i * 4 + 1]) + let b = Float(ptr[i * 4 + 2]) + let brightness = r + g + b + if brightness > best { best = brightness } + } + return best + } + + private func renderAndReadMaxBrightness() -> Float { + renderer.draw(in: renderer.metalView) + let expectation = XCTestExpectation(description: "Emissive light pass render") + var result: Float = -1 + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + guard let texture = textureResources.deferredColorMap else { + XCTFail("Expected deferredColorMap to be initialized") + expectation.fulfill() + return + } + result = self.maxBrightnessAnywhere(in: texture) + expectation.fulfill() + } + wait(for: [expectation], timeout: TimeInterval(timeoutFactor)) + return result + } + + /// Regression test for a0b0eab05: `fragmentLightShaderTBDR` used to hardcode + /// `emissive = 0.0` right before compositing the final lit color ("set emissive to + /// zero for now - need to revisit this"), silently discarding every material's + /// emissive contribution in the TBDR light pass regardless of `emissiveFactor`. A + /// black-albedo, unlit (no scene lights) cube's only possible source of brightness in + /// this scene is its own emissive term, so comparing against the same cube with zero + /// emissive isolates the regression precisely. + func testEmissiveMaterial_brightensTBDRLightPassOutput() { + createTestCamera() + addUnlitCube(emissiveFactor: .zero) + let nonEmissiveBrightness = renderAndReadMaxBrightness() + + destroyAllEntities() + createTestCamera() + addUnlitCube(emissiveFactor: simd_float3(4.0, 4.0, 4.0)) + let emissiveBrightness = renderAndReadMaxBrightness() + + XCTAssertGreaterThan( + emissiveBrightness, nonEmissiveBrightness + 0.5, + "❌ A material with a strong emissiveFactor should brighten the final TBDR-lit " + + "pixel well beyond the same (black-albedo, unlit) material with no emissive — " + + "got emissive=\(emissiveBrightness) vs non-emissive=\(nonEmissiveBrightness). " + + "If the TBDR light pass discards emissive again, these converge." + ) + } +} diff --git a/Tests/UntoldEngineRenderTests/GaussianRenderingTest.swift b/Tests/UntoldEngineRenderTests/GaussianRenderingTest.swift index 6681a602..337f1799 100644 --- a/Tests/UntoldEngineRenderTests/GaussianRenderingTest.swift +++ b/Tests/UntoldEngineRenderTests/GaussianRenderingTest.swift @@ -585,6 +585,177 @@ final class GaussianRenderingTest: BaseRenderSetup { ) } + // MARK: - SceneRootTransform (effective camera) correctness + + /// Runs `executeGaussianFrustumCulling` synchronously on a fresh command buffer and + /// reads back the GPU visible-splat counter it writes (`GaussianComponent.gaussianVisibleCount`, + /// an `atomic_uint` in `gaussianFrustumCull`, see BitonicSort.metal). The reset pass inside + /// `executeGaussianFrustumCulling` zeroes this counter before culling runs, so calling this + /// repeatedly with different camera/scene-root state is safe. + private func runGaussianFrustumCullingAndReadVisibleCount() -> UInt32 { + guard let commandBuffer = renderInfo.commandQueue.makeCommandBuffer() else { + XCTFail("Expected to allocate a command buffer") + return .max + } + executeGaussianFrustumCulling(commandBuffer) + commandBuffer.commit() + commandBuffer.waitUntilCompleted() + XCTAssertEqual(commandBuffer.status, .completed) + + let transformId = getComponentId(for: WorldTransformComponent.self) + let gaussianId = getComponentId(for: GaussianComponent.self) + let entities = queryEntitiesWithComponentIds([transformId, gaussianId], in: scene) + guard let entity = entities.first, + let component = scene.get(component: GaussianComponent.self, for: entity) + else { + XCTFail("Expected the Gaussian test asset to be loaded") + return .max + } + + let frameSlot = min(renderInfo.currentInFlightFrameSlot, component.gaussianVisibleCount.count - 1) + guard let visibleCountBuffer = component.gaussianVisibleCount[frameSlot] else { + XCTFail("Expected a visible-count buffer for the active frame slot") + return .max + } + return visibleCountBuffer.contents().load(as: UInt32.self) + } + + /// Regression test for the bugfix in 302e097eb: `executeGaussianFrustumCulling` used to + /// build its model-view matrix from the raw `cameraComponent.viewSpace`, ignoring + /// `SceneRootTransform`. Per SceneRootTransform.swift's "virtual camera" trick, entity + /// transforms are never touched when the scene root moves — only the effective camera — + /// so a splat's world position is `rootMatrix * modelMatrix * localPosition`, projected + /// through the *unmodified* raw camera view when the bug is present. A large scene-root + /// translation therefore has no effect on culling at all under the bug (every splat stays + /// visible, exactly as if the root were still identity), while the fix pushes every splat + /// out of the frustum. This directly reads the GPU visible-splat counter rather than going + /// through a full render + occlusion-cube check, because that path (see the removed + /// `testGaussianOcclusion_respectsSceneRootTransformOffset` attempt) turned out to be + /// insensitive to this bug: a full-frame occluder covers the frustum regardless of exactly + /// where within it the (mis-projected) splat lands. + func testGaussianFrustumCulling_offsetSceneRootCullsAllSplats() { + let camera = createTestCamera() + cameraLookAt(entityId: camera, eye: simd_float3(0, 3, 7), target: simd_float3(0, 0, 0), up: simd_float3(0, 1, 0)) + + let baselineVisible = runGaussianFrustumCullingAndReadVisibleCount() + XCTAssertGreaterThan( + baselineVisible, 0, + "Sanity check: the test splat should have visible splats within the frustum before any scene-root offset" + ) + + SceneRootTransform.shared.position = simd_float3(500, 0, 0) + SceneRootTransform.shared.updateIfNeeded() + defer { + SceneRootTransform.shared.position = .zero + SceneRootTransform.shared.rotation = simd_quatf() + SceneRootTransform.shared.scale = .one + SceneRootTransform.shared.updateIfNeeded() + } + + let offsetVisible = runGaussianFrustumCullingAndReadVisibleCount() + XCTAssertEqual( + offsetVisible, 0, + "❌ Every splat should be culled once the scene root is translated far outside the " + + "camera frustum. A nonzero count here means Gaussian frustum culling is not tracking " + + "SceneRootTransform — it fell back to the raw (unmoved) camera view, got \(offsetVisible) " + + "visible splats (baseline was \(baselineVisible))" + ) + } + + // MARK: - HZB occlusion pre-cull (0763066cb) + + /// A 1x1 depth texture — `clamp_to_edge` sampling means every UV the gaussianFrustumCull + /// kernel samples reads back this single value, exactly like CullingTest's + /// `makeHZBTestTexture` for the equivalent mesh-AABB HZB tests. + private func makeHZBTestTexture(depthValue: Float) -> MTLTexture { + let descriptor = MTLTextureDescriptor.texture2DDescriptor( + pixelFormat: .r32Float, + width: 1, + height: 1, + mipmapped: false + ) + descriptor.usage = [.shaderRead] + descriptor.storageMode = .shared + let texture = renderInfo.device.makeTexture(descriptor: descriptor)! + + var value = depthValue + withUnsafeBytes(of: &value) { bytes in + texture.replace( + region: MTLRegionMake2D(0, 0, 1, 1), + mipmapLevel: 0, + withBytes: bytes.baseAddress!, + bytesPerRow: MemoryLayout.stride + ) + } + return texture + } + + /// Regression coverage for 0763066cb: `gaussianFrustumCull` fuses a coarse per-splat HZB + /// occlusion pre-cull into the same dispatch as frustum culling, gated by `hzbValid` + /// (`renderInfo.hzbIsValid && textureResources.hzbDepthPyramid != nil`). Existing + /// occlusion tests (`testGaussianOcclusion_*`) render a single frame, so `hzbIsValid` is + /// still false and this whole code path never runs — it needs the HZB injected directly, + /// the same way CullingTest does for the equivalent mesh-AABB HZB pass. + func testGaussianFrustumCulling_hzbOccludedSplatIsCulled() { + let originalHZBTexture = textureResources.hzbDepthPyramid + let originalHZBValid = renderInfo.hzbIsValid + defer { + textureResources.hzbDepthPyramid = originalHZBTexture + renderInfo.hzbIsValid = originalHZBValid + } + + let camera = createTestCamera() + cameraLookAt(entityId: camera, eye: simd_float3(0, 3, 7), target: simd_float3(0, 0, 0), up: simd_float3(0, 1, 0)) + + // "Clear" HZB — nothing occluding, camera sees to the far plane. Sanity baseline: + // the splat must actually be visible before an occluder is introduced. + let clearDepth: Float = renderInfo.reverseZEnabled ? 0.0 : 1.0 + textureResources.hzbDepthPyramid = makeHZBTestTexture(depthValue: clearDepth) + renderInfo.hzbIsValid = true + let clearVisible = runGaussianFrustumCullingAndReadVisibleCount() + XCTAssertGreaterThan(clearVisible, 0, "Sanity check: splat should be visible against a clear (far-plane) HZB") + + // "Solid" HZB — an occluder close to the camera sits in front of everything. + // Standard-Z: close = small value. Reverse-Z: close = large value. + let occluderDepth: Float = renderInfo.reverseZEnabled ? 0.95 : 0.05 + textureResources.hzbDepthPyramid = makeHZBTestTexture(depthValue: occluderDepth) + renderInfo.hzbIsValid = true + let occludedVisible = runGaussianFrustumCullingAndReadVisibleCount() + XCTAssertEqual( + occludedVisible, 0, + "❌ Splats behind a full-frame HZB occluder should be pre-culled before preprocess/depth/sort/draw, " + + "got \(occludedVisible) visible splats" + ) + } + + /// Companion regression test: the `hzbValid` flag itself must gate the occlusion branch. + /// If a stale/first-frame HZB were sampled without checking `hzbIsValid`, an occluding + /// depth value left over in the texture would incorrectly cull splats even before the + /// HZB pyramid has ever been built for this camera position. + func testGaussianFrustumCulling_ignoresHZBWhenInvalid() { + let originalHZBTexture = textureResources.hzbDepthPyramid + let originalHZBValid = renderInfo.hzbIsValid + defer { + textureResources.hzbDepthPyramid = originalHZBTexture + renderInfo.hzbIsValid = originalHZBValid + } + + let camera = createTestCamera() + cameraLookAt(entityId: camera, eye: simd_float3(0, 3, 7), target: simd_float3(0, 0, 0), up: simd_float3(0, 1, 0)) + + // Same "occluding" HZB texture as the culled case above, but marked invalid. + let occluderDepth: Float = renderInfo.reverseZEnabled ? 0.95 : 0.05 + textureResources.hzbDepthPyramid = makeHZBTestTexture(depthValue: occluderDepth) + renderInfo.hzbIsValid = false + + let visible = runGaussianFrustumCullingAndReadVisibleCount() + XCTAssertGreaterThan( + visible, 0, + "❌ hzbIsValid=false should disable the occlusion pre-cull entirely, regardless of what's " + + "in the HZB texture — got 0 visible splats" + ) + } + // MARK: - Helper Methods func createTestCamera() -> EntityID { diff --git a/Tests/UntoldEngineRenderTests/XRImmersionContractTest.swift b/Tests/UntoldEngineRenderTests/XRImmersionContractTest.swift new file mode 100644 index 00000000..795d9672 --- /dev/null +++ b/Tests/UntoldEngineRenderTests/XRImmersionContractTest.swift @@ -0,0 +1,241 @@ +// +// XRImmersionContractTest.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import CShaderTypes +import Metal +import simd +@testable import UntoldEngine +import XCTest + +/// Regression coverage for 5cc6571b1's XR full-immersion alpha/depth contract (see +/// docs/API/UsingXRImmersionMode.md and the project's XR alpha immersion notes): in +/// non-passthrough modes the visionOS compositor treats the output layer as fully opaque, +/// so (1) `fragmentPreCompositeShader` must force alpha to 1.0 whenever `isPassthrough` +/// is false, and (2) `fragmentOutputTransformShader` must never emit a depth of exactly +/// 0.0 (the reverse-Z clear/"infinity" value), since the compositor divides by view +/// distance and 0.0 reprojects those pixels to black. +/// +/// Both fragment shaders are ordinary (non-platform-gated) Metal functions reachable on +/// macOS, so this dispatches their existing production `RenderPipeline`s directly with +/// fully controlled input textures/uniforms instead of driving the whole renderer — +/// mirrors CullingTest's `executeHZBOcclusionCulling` pattern. +final class XRImmersionContractTest: BaseRenderSetup { + override func setUp() async throws { + try await super.setUp() + } + + override func tearDown() async throws { + destroyAllEntities() + try await super.tearDown() + } + + override func initializeAssets() { + // No scene needed — both tests dispatch their pipeline directly. + } + + private func makeColorTexture(pixelFormat: MTLPixelFormat, value: SIMD4, usage: MTLTextureUsage) -> MTLTexture { + let descriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: pixelFormat, width: 1, height: 1, mipmapped: false) + descriptor.usage = usage + descriptor.storageMode = .shared + let texture = renderInfo.device.makeTexture(descriptor: descriptor)! + if usage.contains(.shaderRead) { + let bytes: [Float16] = [Float16(value.x), Float16(value.y), Float16(value.z), Float16(value.w)] + bytes.withUnsafeBytes { raw in + texture.replace(region: MTLRegionMake2D(0, 0, 1, 1), mipmapLevel: 0, withBytes: raw.baseAddress!, bytesPerRow: 8) + } + } + return texture + } + + private func makeDepthTexture(pixelFormat: MTLPixelFormat, value: Float, usage: MTLTextureUsage) -> MTLTexture { + let descriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: pixelFormat, width: 1, height: 1, mipmapped: false) + descriptor.usage = usage + descriptor.storageMode = .shared + let texture = renderInfo.device.makeTexture(descriptor: descriptor)! + if usage.contains(.shaderRead) { + var v = value + withUnsafeBytes(of: &v) { raw in + texture.replace(region: MTLRegionMake2D(0, 0, 1, 1), mipmapLevel: 0, withBytes: raw.baseAddress!, bytesPerRow: 4) + } + } + return texture + } + + // MARK: - fragmentPreCompositeShader: forced-opaque contract + + /// Runs the real `.preComposite` pipeline with every alpha-contributing input + /// (model, environment, Gaussian) transparent, and reads back the composited alpha. + private func runPreCompositeAndReadAlpha(isPassthrough: Bool) -> Float { + guard let pipeline = PipelineManager.shared.renderPipelinesByType[.preComposite], pipeline.success else { + XCTFail("Expected the Pre-Composite pipeline to be initialized") + return -1 + } + + let transparentRGBA = makeColorTexture(pixelFormat: .rgba16Float, value: .zero, usage: [.shaderRead]) + let depthDummy = makeDepthTexture(pixelFormat: .depth32Float, value: 0.5, usage: [.shaderRead]) + let outputTexture = makeColorTexture(pixelFormat: .rgba16Float, value: .zero, usage: [.renderTarget, .shaderRead]) + + let renderPassDescriptor = MTLRenderPassDescriptor() + renderPassDescriptor.colorAttachments[0].texture = outputTexture + renderPassDescriptor.colorAttachments[0].loadAction = .clear + renderPassDescriptor.colorAttachments[0].storeAction = .store + renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColorMake(0, 0, 0, 0) + + guard let commandBuffer = renderInfo.commandQueue.makeCommandBuffer(), + let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) + else { + XCTFail("Expected to create a command buffer/encoder") + return -1 + } + + encoder.setRenderPipelineState(pipeline.pipelineState!) + encoder.setVertexBuffer(bufferResources.quadVerticesBuffer, offset: 0, index: 0) + encoder.setVertexBuffer(bufferResources.quadTexCoordsBuffer, offset: 0, index: 1) + encoder.setFragmentTexture(transparentRGBA, index: Int(prePassFinalTextureIndex.rawValue)) + encoder.setFragmentTexture(transparentRGBA, index: Int(prePassEnvTextureIndex.rawValue)) + encoder.setFragmentTexture(depthDummy, index: Int(prePassDepthTextureIndex.rawValue)) + encoder.setFragmentTexture(transparentRGBA, index: Int(prePassGizmoTextureIndex.rawValue)) + encoder.setFragmentTexture(transparentRGBA, index: Int(prePassGaussianTextureIndex.rawValue)) + encoder.setFragmentTexture(transparentRGBA, index: Int(prePassSSAOTextureIndex.rawValue)) + + var isGameMode = true // skip the gizmo-overlay branch + encoder.setFragmentBytes(&isGameMode, length: MemoryLayout.stride, index: Int(prePassGizmoBufferIndex.rawValue)) + var passthrough = isPassthrough + encoder.setFragmentBytes(&passthrough, length: MemoryLayout.stride, index: Int(prePassPassthroughBufferIndex.rawValue)) + var ssaoEnabled = false + encoder.setFragmentBytes(&ssaoEnabled, length: MemoryLayout.stride, index: Int(prePassSSAOEnabledIndex.rawValue)) + + encoder.drawIndexedPrimitives( + type: .triangle, + indexCount: 6, + indexType: .uint16, + indexBuffer: bufferResources.quadIndexBuffer!, + indexBufferOffset: 0 + ) + encoder.endEncoding() + commandBuffer.commit() + commandBuffer.waitUntilCompleted() + XCTAssertEqual(commandBuffer.status, .completed) + + var pixel = [Float16](repeating: 0, count: 4) + pixel.withUnsafeMutableBytes { raw in + outputTexture.getBytes(raw.baseAddress!, bytesPerRow: 8, from: MTLRegionMake2D(0, 0, 1, 1), mipmapLevel: 0) + } + return Float(pixel[3]) + } + + /// Sanity check: passthrough mode must NOT force opacity — with every input already + /// transparent, the composited pixel should stay transparent so the camera feed shows + /// through. + func testPreCompositeAlpha_passthroughLeavesTransparentPixelsTransparent() { + let alpha = runPreCompositeAndReadAlpha(isPassthrough: true) + XCTAssertEqual(alpha, 0.0, accuracy: 0.01, "Passthrough mode should not force alpha to 1.0") + } + + /// Regression test for 5cc6571b1: with `isPassthrough == false` (full immersion, AR, + /// macOS), every fully-transparent input (model, environment, Gaussian all alpha 0) + /// must still composite to alpha 1.0 — the environment layer is opaque and must never + /// let a background pixel punch through. + func testPreCompositeAlpha_nonPassthroughForcesOpaque() { + let alpha = runPreCompositeAndReadAlpha(isPassthrough: false) + XCTAssertEqual( + alpha, 1.0, accuracy: 0.01, + "❌ Non-passthrough compositing should force alpha to 1.0 even when every upstream " + + "layer is transparent — got \(alpha). If this regresses, XR full-immersion/AR " + + "background pixels punch through to black on the compositor." + ) + } + + // MARK: - fragmentOutputTransformShader: zero-depth clamp + + private func runOutputTransformAndReadDepth(sourceDepth: Float) -> Float { + guard let pipeline = PipelineManager.shared.renderPipelinesByType[.outputTransform], pipeline.success else { + XCTFail("Expected the Output Transform pipeline to be initialized") + return -1 + } + + let lookTexture = makeColorTexture(pixelFormat: .rgba16Float, value: SIMD4(0.2, 0.3, 0.4, 1.0), usage: [.shaderRead]) + let sourceDepthTexture = makeDepthTexture(pixelFormat: .depth32Float, value: sourceDepth, usage: [.shaderRead]) + let outputColorTexture = makeColorTexture( + pixelFormat: renderInfo.presentColorPixelFormat, value: .zero, usage: [.renderTarget, .shaderRead] + ) + let outputDepthTexture = makeDepthTexture( + pixelFormat: renderInfo.presentDepthPixelFormat, value: 0, usage: [.renderTarget, .shaderRead] + ) + + let renderPassDescriptor = MTLRenderPassDescriptor() + renderPassDescriptor.colorAttachments[0].texture = outputColorTexture + renderPassDescriptor.colorAttachments[0].loadAction = .clear + renderPassDescriptor.colorAttachments[0].storeAction = .store + renderPassDescriptor.depthAttachment.texture = outputDepthTexture + renderPassDescriptor.depthAttachment.loadAction = .clear + renderPassDescriptor.depthAttachment.clearDepth = 0.0 + renderPassDescriptor.depthAttachment.storeAction = .store + + guard let commandBuffer = renderInfo.commandQueue.makeCommandBuffer(), + let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) + else { + XCTFail("Expected to create a command buffer/encoder") + return -1 + } + + encoder.setRenderPipelineState(pipeline.pipelineState!) + if let depthState = pipeline.depthState { + encoder.setDepthStencilState(depthState) + } + encoder.setVertexBuffer(bufferResources.quadVerticesBuffer, offset: 0, index: 0) + encoder.setVertexBuffer(bufferResources.quadTexCoordsBuffer, offset: 0, index: 1) + encoder.setFragmentTexture(lookTexture, index: 0) + encoder.setFragmentTexture(sourceDepthTexture, index: 1) + var encodingMode: Int32 = 0 + encoder.setFragmentBytes(&encodingMode, length: MemoryLayout.stride, index: Int(outputTransformPassEncodingModeIndex.rawValue)) + + encoder.drawIndexedPrimitives( + type: .triangle, + indexCount: 6, + indexType: .uint16, + indexBuffer: bufferResources.quadIndexBuffer!, + indexBufferOffset: 0 + ) + encoder.endEncoding() + commandBuffer.commit() + commandBuffer.waitUntilCompleted() + XCTAssertEqual(commandBuffer.status, .completed) + + var depthValue: Float = -1 + withUnsafeMutableBytes(of: &depthValue) { raw in + outputDepthTexture.getBytes(raw.baseAddress!, bytesPerRow: 4, from: MTLRegionMake2D(0, 0, 1, 1), mipmapLevel: 0) + } + return depthValue + } + + /// Sanity check: a legitimate (non-zero) depth value must pass through unmodified — + /// the clamp must not corrupt real depth data. + func testOutputTransformDepth_nonZeroDepthPassesThroughUnchanged() { + let depth = runOutputTransformAndReadDepth(sourceDepth: 0.5) + XCTAssertEqual(depth, 0.5, accuracy: 1e-6) + } + + /// Regression test for 5cc6571b1: `fragmentOutputTransformShader` used to write the + /// sampled depth straight through. A sky pixel carrying the reverse-Z clear value of + /// 0.0 ("infinite distance") would reach the visionOS compositor as exactly 0.0, which + /// the compositor's view-distance reprojection divides by — discarding those pixels + /// (rendered black). The fix clamps to a finite far depth (1e-4). + func testOutputTransformDepth_zeroDepthIsClampedToFiniteValue() { + let depth = runOutputTransformAndReadDepth(sourceDepth: 0.0) + XCTAssertGreaterThanOrEqual( + depth, 1e-4 * 0.999, + "❌ A depth of exactly 0.0 (reverse-Z 'infinity') should be clamped to a finite far " + + "value, got \(depth). Left at 0.0, the visionOS compositor's view-distance " + + "reprojection divides by it and discards these pixels as black." + ) + XCTAssertLessThan(depth, 0.01, "Clamped depth should still be visually indistinguishable from infinity, not a large jump") + } +} diff --git a/Tests/UntoldEngineTests/PLYReaderTest.swift b/Tests/UntoldEngineTests/PLYReaderTest.swift index 801efaf7..f4302068 100644 --- a/Tests/UntoldEngineTests/PLYReaderTest.swift +++ b/Tests/UntoldEngineTests/PLYReaderTest.swift @@ -246,6 +246,76 @@ final class PLYReaderTest: XCTestCase { } } + // MARK: - Negligible-Opacity Culling at Load Time + + /// Regression test for 6ca071369: `filterNegligibleOpacitySplats` drops any splat whose + /// (post-sigmoid) opacity falls below `minRetainedOpacity` (1/255) and, when spherical + /// harmonics are present, compacts `shCoefficients` (a flat, splat-major buffer) in lock + /// step so coefficient blocks stay aligned with their splat after removal. The first splat + /// here has opacity logit -10 (sigmoid ≈ 4.5e-5, below threshold); the other two use + /// distinct, easily-identifiable f_dc/f_rest values so an off-by-one in the coefficient + /// compaction (e.g. still slicing by the pre-filter index) would surface as a mismatched + /// or garbled block rather than just a wrong count. + func test_readGaussianAsset_dropsNegligibleOpacitySplatsAndCompactsCoefficients() throws { + let plyContent = """ + ply + format ascii 1.0 + element vertex 3 + property float x + property float y + property float z + property float f_dc_0 + property float f_dc_1 + property float f_dc_2 + property float opacity + property float f_rest_0 + property float f_rest_1 + property float f_rest_2 + property float f_rest_3 + property float f_rest_4 + property float f_rest_5 + property float f_rest_6 + property float f_rest_7 + property float f_rest_8 + end_header + 0 0 0 999 999 999 -10 999 999 999 999 999 999 999 999 999 + 1 0 0 100 101 102 5 110 111 112 120 121 122 130 131 132 + 2 0 0 200 201 202 5 210 211 212 220 221 222 230 231 232 + """ + + let tempURL = createTempFile(content: plyContent) + tempFileURL = tempURL + + let asset = try PLYReader.readGaussianAsset(from: tempURL) + + XCTAssertEqual(asset.splats.count, 2, "The negligible-opacity splat should be dropped, leaving the other two") + XCTAssertEqual(asset.splats[0].center.x, 1.0, accuracy: 0.001, "Surviving splats should keep their original order") + XCTAssertEqual(asset.splats[1].center.x, 2.0, accuracy: 0.001) + + for splat in asset.splats { + XCTAssertGreaterThan(splat.opacity, 1.0 / 255.0, "Every surviving splat should be at or above the retention threshold") + XCTAssertEqual(splat.color.w, splat.opacity, accuracy: 0.0001, "color.w mirrors opacity") + } + + let sh = try XCTUnwrap(asset.sphericalHarmonics) + XCTAssertEqual(sh.degree, 1) + XCTAssertEqual(sh.coefficientsPerSplat, 12) + XCTAssertEqual(sh.coefficients.count, 2 * 12, "Coefficient buffer should shrink in lock step with the splat count") + + let splat1Expected: [Float] = [100, 110, 111, 112, 101, 120, 121, 122, 102, 130, 131, 132] + let splat2Expected: [Float] = [200, 210, 211, 212, 201, 220, 221, 222, 202, 230, 231, 232] + XCTAssertEqual( + Array(sh.coefficients[0 ..< 12]), splat1Expected, + "❌ First surviving splat's coefficient block should be intact and start at index 0 " + + "(a compaction off-by-one would pull in the culled splat's 999s or shift this block)" + ) + XCTAssertEqual( + Array(sh.coefficients[12 ..< 24]), splat2Expected, + "❌ Second surviving splat's coefficient block should immediately follow the first, " + + "not leave a gap for the culled splat or bleed into the first splat's values" + ) + } + func test_externalGaussianDiagnosticAssetPreservesAndPacksDegreeThreeSH() throws { guard let path = ProcessInfo.processInfo.environment["UNTOLD_GAUSSIAN_DIAGNOSTIC_PLY"] else { throw XCTSkip("Set UNTOLD_GAUSSIAN_DIAGNOSTIC_PLY to audit a production Gaussian asset") diff --git a/Tests/UntoldEngineTests/ShadowEntityCacheTests.swift b/Tests/UntoldEngineTests/ShadowEntityCacheTests.swift new file mode 100644 index 00000000..1ea449b9 --- /dev/null +++ b/Tests/UntoldEngineTests/ShadowEntityCacheTests.swift @@ -0,0 +1,67 @@ +// +// ShadowEntityCacheTests.swift +// UntoldEngineTests +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import simd +@testable import UntoldEngine +import XCTest + +/// Covers RenderPasses' shadow entity candidate cache — specifically the +/// invalidation contract described in the shadow-cache-invalidation postmortem: +/// non-streaming entity loads must call RenderPasses.invalidateShadowEntityCache() +/// after ECS registration, or the entity silently never casts a shadow because the +/// cache was already rebuilt (shadowCacheDirty == false) before it existed. +@MainActor +final class ShadowEntityCacheTests: XCTestCase { + override func setUp() async throws { + resetEngineTestState() + // Force a clean rebuild against the empty scene so no candidate state + // leaks in from whatever the previous test class left in this singleton. + RenderPasses.invalidateShadowEntityCache() + _ = RenderPasses.shadowEntityCandidatesForTesting() + } + + @discardableResult + private func makeShadowCastingEntity(castsShadow: Bool = true, position: simd_float3 = .zero) -> EntityID { + let entityId = createEntity() + registerComponent(entityId: entityId, componentType: RenderComponent.self) + scene.get(component: RenderComponent.self, for: entityId)?.castsShadow = castsShadow + scene.get(component: WorldTransformComponent.self, for: entityId)?.space = + matrix4x4Translation(position.x, position.y, position.z) + return entityId + } + + /// Reproduces the exact bug: a renderable entity created via a non-streaming + /// load path is invisible to the shadow candidate cache until something calls + /// invalidateShadowEntityCache() — creating the entity alone is not enough. + func testShadowCacheRequiresInvalidationAfterNonStreamingEntityCreation() { + let entityId = makeShadowCastingEntity() + + // No invalidation yet: the cache is still the clean-but-stale rebuild from + // setUp, so the new entity must be absent. This is the bug reproduced. + XCTAssertFalse( + RenderPasses.shadowEntityCandidatesForTesting().contains(entityId), + "A newly created shadow caster must not appear before the cache is invalidated" + ) + + RenderPasses.invalidateShadowEntityCache() + + XCTAssertTrue( + RenderPasses.shadowEntityCandidatesForTesting().contains(entityId), + "invalidateShadowEntityCache() must force a rebuild that picks up entities created since the last one" + ) + } + + func testShadowEntityCandidatesExcludeNonShadowCastingEntity() { + let entityId = makeShadowCastingEntity(castsShadow: false) + RenderPasses.invalidateShadowEntityCache() + + XCTAssertFalse(RenderPasses.shadowEntityCandidatesForTesting().contains(entityId)) + } +} diff --git a/docs/index.md b/docs/index.md index bfba49de..a0c71c35 100644 --- a/docs/index.md +++ b/docs/index.md @@ -80,7 +80,7 @@ Clone the repository and launch the Starter Demo: ```bash git clone https://github.com/untoldengine/UntoldEngine.git cd UntoldEngine -git checkout v0.14.3 +git checkout v0.16.0 swift run StarterDemo ``` diff --git a/scripts/next-version.sh b/scripts/next-version.sh index c1705cb4..5bb0efeb 100755 --- a/scripts/next-version.sh +++ b/scripts/next-version.sh @@ -132,8 +132,9 @@ if [[ "${DO_CLIFF}" == "true" ]]; then # Update stable release tag in README and GettingStarted doc sed -i '' 's/git checkout v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*/git checkout v'"${NEXT}"'/' \ README.md \ - docs/API/GettingStarted.md - echo "Updated checkout tag to v${NEXT} in README.md and docs/API/GettingStarted.md." + docs/API/GettingStarted.md \ + docs/index.md + echo "Updated checkout tag to v${NEXT} in README.md, docs/API/GettingStarted.md, and docs/index.md." fi # Optionally run Docusaurus docs:version