diff --git a/README.md b/README.md index 28d78d9..cc15b5b 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Restart Window Burn after changing any of the three permissions. | Mode | Shortcut | What it does | | --- | --- | --- | | Burn & Close | `⌃⌥⌘B` | Closes the front window and burns its snapshot from a randomized edge. | -| Torch | `⌃⌥⌘F` | Turns the cursor into an animated torch. Click a window, then add up to eight ignition points. | +| Torch | `⌃⌥⌘F` | Turns the cursor into an animated torch. Burn up to four windows at once, with up to eight ignition points per window. | | Soak & Burn | `⌃⌥⌘U` | Hold and drag the 18+ badge over a window to soak and tear it, then ignite the remaining edges with the torch. | | Test Effect | Menu bar | Plays a harmless demo without closing a real window. | diff --git a/Sources/WindowBurn/BurnCoordinator.swift b/Sources/WindowBurn/BurnCoordinator.swift index 20cdf57..c43406b 100644 --- a/Sources/WindowBurn/BurnCoordinator.swift +++ b/Sources/WindowBurn/BurnCoordinator.swift @@ -2,14 +2,28 @@ import AppKit import OSLog import WindowBurnCore +@MainActor +private final class TorchWindowSession { + let accessibleWindow: AccessibleWindow + let overlay = BurnOverlayController() + var captureFrame: CGRect + var pendingClicks: [CGPoint] + var isOverlayReady = false + + init(accessibleWindow: AccessibleWindow, initialClick: CGPoint) { + self.accessibleWindow = accessibleWindow + captureFrame = accessibleWindow.target.frame + pendingClicks = [initialClick] + } +} + @MainActor final class BurnCoordinator { private let logger = Logger(subsystem: "dev.malikov.WindowBurn", category: "burn") private let overlay = BurnOverlayController() private var isBusy = false - private var torchCaptureFrame: CGRect? - private var pendingTorchClicks: [CGPoint] = [] - private var isTorchOverlayActive = false + private var torchSessionRegistry = TorchWindowSessionRegistry() + private var torchSessions: [UUID: TorchWindowSession] = [:] private var soakAndBurnSession = SoakAndBurnSession() private var soakedWindow: AccessibleWindow? private var soakCaptureFrame: CGRect? @@ -23,7 +37,7 @@ final class BurnCoordinator { var onDestructiveCloseFailure: (() -> Void)? func burnFrontWindow() { - guard !isBusy else { return } + guard !isBusy, torchSessions.isEmpty else { return } isBusy = true Task { @MainActor [weak self] in @@ -41,18 +55,26 @@ final class BurnCoordinator { ) let profile = BurnProfile.random() - try await AccessibilityWindowService.closeDiscardingUnsavedChanges(accessibleWindow) try overlay.present( image: capturedWindow.image, + shadowImage: capturedWindow.shadowImage, + shadowSamplingOffset: capturedWindow.shadowSamplingOffset, panelFrame: panelFrame, profile: profile, + startImmediately: false, onFirstFrame: nil, completion: { [weak self] in self?.isBusy = false } ) + try await AccessibilityWindowService.closeDiscardingUnsavedChanges(accessibleWindow) + guard overlay.activateReplacementSurface() else { + throw BurnOverlayError.rendererUnavailable + } + overlay.startBurning() logger.info( "Burning front window owned by pid \(accessibleWindow.target.ownerPID), duration \(profile.duration, format: .fixed(precision: 2))s" ) } catch { + overlay.dismiss() isBusy = false onDestructiveCloseFailure?() logger.error("Burn failed: \(error.localizedDescription, privacy: .public)") @@ -62,7 +84,7 @@ final class BurnCoordinator { } func interceptWindowControl(_ control: AccessibleWindowControl) -> Bool { - guard !isBusy else { return false } + guard !isBusy, torchSessions.isEmpty else { return false } isBusy = true Task { @MainActor [weak self] in @@ -80,19 +102,27 @@ final class BurnCoordinator { ) let profile = BurnProfile.random() - actionPerformed = true - try await AccessibilityWindowService.closeDiscardingUnsavedChanges(control.window) try overlay.present( image: capturedWindow.image, + shadowImage: capturedWindow.shadowImage, + shadowSamplingOffset: capturedWindow.shadowSamplingOffset, panelFrame: panelFrame, profile: profile, + startImmediately: false, onFirstFrame: nil, completion: { [weak self] in self?.isBusy = false } ) + actionPerformed = true + try await AccessibilityWindowService.closeDiscardingUnsavedChanges(control.window) + guard overlay.activateReplacementSurface() else { + throw BurnOverlayError.rendererUnavailable + } + overlay.startBurning() logger.info( "Burning intercepted \(control.kind.logName, privacy: .public) action for pid \(control.window.target.ownerPID), duration \(profile.duration, format: .fixed(precision: 2))s" ) } catch { + overlay.dismiss() if !actionPerformed { do { try AccessibilityWindowService.perform(control) @@ -115,29 +145,37 @@ final class BurnCoordinator { } func interceptTorchClick(at screenPoint: CGPoint) -> Bool { - if let captureFrame = torchCaptureFrame, + if let sessionID = torchSessionRegistry.sessionID(containing: screenPoint), + let session = torchSessions[sessionID], let ignition = TorchBurnGeometry.normalizedIgnition( screenPoint: screenPoint, - captureFrame: captureFrame + captureFrame: session.captureFrame ) { - if isTorchOverlayActive { - let added = overlay.addIgnition(ignition) + if session.isOverlayReady { + let added = session.overlay.addIgnition(ignition) if added { logger.info( - "Added another torch ignition at \(ignition.x, format: .fixed(precision: 2)), \(ignition.y, format: .fixed(precision: 2))" + "Added another torch ignition to session \(sessionID.uuidString, privacy: .public) at \(ignition.x, format: .fixed(precision: 2)), \(ignition.y, format: .fixed(precision: 2))" ) } else { logger.info( - "Ignored torch ignition because the active window already has the maximum number of points" + "Ignored torch ignition because session \(sessionID.uuidString, privacy: .public) already has the maximum number of points" ) } - } else if pendingTorchClicks.count < TorchIgnitionField.maximumCount { - pendingTorchClicks.append(screenPoint) + } else if session.pendingClicks.count < TorchIgnitionField.maximumCount { + session.pendingClicks.append(screenPoint) } return true } + if torchSessionRegistry.isAtCapacity { + logger.info( + "Ignored torch ignition because \(TorchWindowSessionRegistry.maximumConcurrentWindows) window sessions are already active" + ) + return true + } + guard !isBusy, let accessibleWindow = AccessibilityWindowService.window(at: screenPoint), @@ -149,24 +187,35 @@ final class BurnCoordinator { return false } - isBusy = true - torchCaptureFrame = accessibleWindow.target.frame - pendingTorchClicks = [screenPoint] - isTorchOverlayActive = false + let sessionID = UUID() + guard + torchSessionRegistry.register( + id: sessionID, + captureFrame: accessibleWindow.target.frame + ) + else { + return true + } + torchSessions[sessionID] = TorchWindowSession( + accessibleWindow: accessibleWindow, + initialClick: screenPoint + ) Task { @MainActor [weak self] in guard let self else { return } do { + guard let session = torchSessions[sessionID] else { return } let capturedWindow = try await WindowCaptureService.capture( - target: accessibleWindow.target + target: session.accessibleWindow.target ) + guard let session = torchSessions[sessionID] else { return } let panelFrame = ScreenCoordinateConverter.appKitFrame( for: capturedWindow.captureFrame, mainDisplayHeight: NSScreen.screens.first?.frame.maxY ?? 0, padding: BurnOverlayController.padding ) - let ignitionPoints = pendingTorchClicks.compactMap { + let ignitionPoints = session.pendingClicks.compactMap { TorchBurnGeometry.normalizedIgnition( screenPoint: $0, captureFrame: capturedWindow.captureFrame @@ -176,27 +225,51 @@ final class BurnCoordinator { throw WindowCaptureError.noMatchingWindow } - torchCaptureFrame = capturedWindow.captureFrame + session.captureFrame = capturedWindow.captureFrame + guard + torchSessionRegistry.updateCaptureFrame( + id: sessionID, + captureFrame: capturedWindow.captureFrame + ) + else { + throw WindowCaptureError.noMatchingWindow + } let profile = BurnProfile.randomTorch() - try await AccessibilityWindowService.closeDiscardingUnsavedChanges(accessibleWindow) - try overlay.present( + try session.overlay.present( image: capturedWindow.image, + shadowImage: capturedWindow.shadowImage, + shadowSamplingOffset: capturedWindow.shadowSamplingOffset, panelFrame: panelFrame, profile: profile, style: .torch(initialIgnitions: ignitionPoints), + startImmediately: false, onFirstFrame: nil, completion: { [weak self] in - self?.finishTorchBurn() + self?.finishTorchBurn(sessionID) } ) - isTorchOverlayActive = true + session.isOverlayReady = true + session.pendingClicks = [] + try await AccessibilityWindowService.closeDiscardingUnsavedChanges( + session.accessibleWindow + ) + guard + torchSessions[sessionID] != nil, + session.overlay.activateReplacementSurface() + else { + throw BurnOverlayError.rendererUnavailable + } + session.overlay.startBurning() logger.info( - "Started torch burn for pid \(accessibleWindow.target.ownerPID) with \(ignitionPoints.count) ignition point(s), duration \(profile.duration, format: .fixed(precision: 2))s" + "Started torch session \(sessionID.uuidString, privacy: .public) for pid \(session.accessibleWindow.target.ownerPID) with \(ignitionPoints.count) ignition point(s), duration \(profile.duration, format: .fixed(precision: 2))s; \(self.torchSessionRegistry.count) concurrent window(s)" ) } catch { - finishTorchBurn() + torchSessions[sessionID]?.overlay.dismiss() + finishTorchBurn(sessionID) onDestructiveCloseFailure?() - logger.error("Torch burn failed: \(error.localizedDescription, privacy: .public)") + logger.error( + "Torch session \(sessionID.uuidString, privacy: .public) failed: \(error.localizedDescription, privacy: .public)" + ) showError(error) } } @@ -247,7 +320,7 @@ final class BurnCoordinator { } func showDemo() { - guard !isBusy else { return } + guard !isBusy, torchSessions.isEmpty else { return } isBusy = true logger.info("Starting demo burn") @@ -283,7 +356,7 @@ final class BurnCoordinator { } func showSoakDemo() { - guard !isBusy else { return } + guard !isBusy, torchSessions.isEmpty else { return } isBusy = true logger.info("Starting soak-and-burn demo") @@ -352,17 +425,18 @@ final class BurnCoordinator { } } - private func finishTorchBurn() { - isBusy = false - torchCaptureFrame = nil - pendingTorchClicks = [] - isTorchOverlayActive = false - logger.info("Torch burn completed") + private func finishTorchBurn(_ sessionID: UUID) { + guard torchSessions.removeValue(forKey: sessionID) != nil else { return } + torchSessionRegistry.remove(id: sessionID) + logger.info( + "Torch session \(sessionID.uuidString, privacy: .public) completed; \(self.torchSessionRegistry.count) concurrent window(s) remain" + ) } private func beginSoaking(at screenPoint: CGPoint) -> Bool { guard !isBusy, + torchSessions.isEmpty, let accessibleWindow = AccessibilityWindowService.window(at: screenPoint), TorchBurnGeometry.normalizedIgnition( screenPoint: screenPoint, @@ -421,6 +495,8 @@ final class BurnCoordinator { try overlay.present( image: capturedWindow.image, backdropImage: capturedWindow.backdropImage, + shadowImage: capturedWindow.shadowImage, + shadowSamplingOffset: capturedWindow.shadowSamplingOffset, panelFrame: panelFrame, profile: profile, style: .soakAndBurn(initialSoakPoints: initialSoakPoints), @@ -469,10 +545,16 @@ final class BurnCoordinator { soakCloseTask = Task { @MainActor [weak self] in guard let self else { return } do { + guard overlay.prepareForIgnitionHandoff() else { + overlay.dismiss() + resetSoakAndBurn() + return + } try await AccessibilityWindowService.closeDiscardingUnsavedChanges(accessibleWindow) guard generation == soakGeneration, !Task.isCancelled else { return } soakCloseTask = nil guard + overlay.activateReplacementSurface(), soakAndBurnSession.beginBurning(), overlay.igniteSoakedWindow(at: ignition) else { diff --git a/Sources/WindowBurn/BurnOverlayController.swift b/Sources/WindowBurn/BurnOverlayController.swift index 4ad4a7a..3bae34b 100644 --- a/Sources/WindowBurn/BurnOverlayController.swift +++ b/Sources/WindowBurn/BurnOverlayController.swift @@ -4,9 +4,15 @@ import WindowBurnCore enum BurnOverlayError: LocalizedError { case metalUnavailable + case rendererUnavailable var errorDescription: String? { - "Metal is not available on this Mac." + switch self { + case .metalUnavailable: + "Metal is not available on this Mac." + case .rendererUnavailable: + "The burn overlay renderer is no longer available." + } } } @@ -20,15 +26,19 @@ final class BurnOverlayController { static let padding: CGFloat = 84 private var window: NSWindow? + private var metalView: MTKView? private var renderer: BurnRenderer? func present( image: CGImage, backdropImage: CGImage? = nil, + shadowImage: CGImage? = nil, + shadowSamplingOffset: CGPoint = .zero, panelFrame: CGRect, profile: BurnProfile, style: BurnRendererStyle = .sweep, presentation: BurnOverlayPresentation = .effectOverlay, + startImmediately: Bool = true, onFirstFrame: (() throws -> Void)?, completion: (() -> Void)? = nil ) throws { @@ -92,10 +102,15 @@ final class BurnOverlayController { device: device, image: image, backdropImage: backdropImage, + shadowImage: shadowImage, + shadowSamplingOffset: shadowSamplingOffset, profile: profile, style: style, horizontalPadding: presentation == .demoWindow ? 0 : Float(Self.padding / panelFrame.width), verticalPadding: presentation == .demoWindow ? 0 : Float(Self.padding / panelFrame.height), + cornerRadius: presentation == .demoWindow + ? 0 + : Float(10 / max(1, panelFrame.height - Self.padding * 2)), completion: { [weak self] in self?.dismiss() completion?() @@ -116,6 +131,7 @@ final class BurnOverlayController { window.contentView = metalView self.window = window + self.metalView = metalView renderer = burnRenderer switch presentation { case .effectOverlay: @@ -125,6 +141,7 @@ final class BurnOverlayController { window.makeKeyAndOrderFront(nil) } + burnRenderer.synchronizeNextFrame() metalView.draw() do { try onFirstFrame?() @@ -133,9 +150,42 @@ final class BurnOverlayController { throw error } + if startImmediately { + startBurning() + } + } + + func startBurning() { + guard let metalView, let renderer else { return } + renderer.start() metalView.enableSetNeedsDisplay = false metalView.isPaused = false - burnRenderer.start() + } + + @discardableResult + func activateReplacementSurface() -> Bool { + guard + let metalView, + let renderer + else { + return false + } + renderer.activateReplacementSurface() + metalView.draw() + return true + } + + @discardableResult + func prepareForIgnitionHandoff() -> Bool { + guard + let metalView, + let renderer, + renderer.prepareForIgnitionHandoff() + else { + return false + } + metalView.draw() + return true } @discardableResult @@ -161,6 +211,7 @@ final class BurnOverlayController { func dismiss() { window?.orderOut(nil) window = nil + metalView = nil renderer = nil } } diff --git a/Sources/WindowBurn/BurnRenderer.swift b/Sources/WindowBurn/BurnRenderer.swift index 03f9153..8cb3bda 100644 --- a/Sources/WindowBurn/BurnRenderer.swift +++ b/Sources/WindowBurn/BurnRenderer.swift @@ -36,38 +36,64 @@ enum BurnRendererStyle { @MainActor final class BurnRenderer: NSObject, MTKViewDelegate { + private struct PipelineResources { + let render: MTLRenderPipelineState + let clearWet: MTLComputePipelineState + let accumulateWet: MTLComputePipelineState + let initializeCombustion: MTLComputePipelineState + let stepCombustion: MTLComputePipelineState + } + + private static var pipelineResourcesByRegistryID: [UInt64: PipelineResources] = [:] + private let commandQueue: MTLCommandQueue private let pipeline: MTLRenderPipelineState private let clearWetPipeline: MTLComputePipelineState private let accumulateWetPipeline: MTLComputePipelineState + private let initializeCombustionPipeline: MTLComputePipelineState + private let stepCombustionPipeline: MTLComputePipelineState private let texture: MTLTexture private let backdropTexture: MTLTexture + private let shadowTexture: MTLTexture private let wetAccumulationTexture: MTLTexture + private let combustionStateTextures: [MTLTexture] private let sampler: MTLSamplerState private let profile: BurnProfile private let visualProfile: BurnVisualProfile private let wetVisualProfile: WetVisualProfile + private let combustionProfile: CombustionProfile private let style: BurnRendererStyle private let horizontalPadding: Float private let verticalPadding: Float + private let cornerRadius: Float + private let shadowSamplingOffset: SIMD2 + private let hasCapturedWindowShadow: Bool private let completion: () -> Void private var ignitionField: TorchIgnitionField private var wetDepositQueue: WetDepositQueue private var isWetTextureInitialized = false + private var isCombustionStateInitialized = false + private var currentCombustionTextureIndex = 0 private var startTime: CFTimeInterval? private var lastDrawTime: CFTimeInterval? private var soakEndedAt: TimeInterval? private var burnStartedAt: TimeInterval? + private var isHandoffPrepared = false + private var isReplacementSurfaceActive = false + private var shouldSynchronizeNextFrame = false private var hasCompleted = false init( device: MTLDevice, image: CGImage, backdropImage: CGImage?, + shadowImage: CGImage?, + shadowSamplingOffset: CGPoint, profile: BurnProfile, style: BurnRendererStyle, horizontalPadding: Float, verticalPadding: Float, + cornerRadius: Float, completion: @escaping () -> Void ) throws { guard let commandQueue = device.makeCommandQueue() else { @@ -77,9 +103,16 @@ final class BurnRenderer: NSObject, MTKViewDelegate { self.profile = profile self.visualProfile = .cinematic self.wetVisualProfile = .cinematic + self.combustionProfile = .cinematic self.style = style self.horizontalPadding = horizontalPadding self.verticalPadding = verticalPadding + self.cornerRadius = cornerRadius + self.shadowSamplingOffset = SIMD2( + Float(shadowSamplingOffset.x), + Float(shadowSamplingOffset.y) + ) + self.hasCapturedWindowShadow = shadowImage != nil self.completion = completion var ignitionField = TorchIgnitionField() if case .torch(let initialIgnitions) = style { @@ -96,39 +129,12 @@ final class BurnRenderer: NSObject, MTKViewDelegate { } self.wetDepositQueue = wetDepositQueue - let library: MTLLibrary - do { - library = try device.makeLibrary(source: Self.shaderSource, options: nil) - } catch { - throw BurnRendererError.shaderCompilation(error.localizedDescription) - } - guard - let vertexFunction = library.makeFunction(name: "burnVertex"), - let fragmentFunction = library.makeFunction(name: "burnFragment"), - let clearWetFunction = library.makeFunction(name: "clearWetField"), - let accumulateWetFunction = library.makeFunction(name: "accumulateWetField") - else { - throw BurnRendererError.shaderFunction - } - - let descriptor = MTLRenderPipelineDescriptor() - descriptor.vertexFunction = vertexFunction - descriptor.fragmentFunction = fragmentFunction - descriptor.colorAttachments[0].pixelFormat = .bgra8Unorm - descriptor.colorAttachments[0].isBlendingEnabled = true - descriptor.colorAttachments[0].rgbBlendOperation = .add - descriptor.colorAttachments[0].alphaBlendOperation = .add - descriptor.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha - descriptor.colorAttachments[0].sourceAlphaBlendFactor = .one - descriptor.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha - descriptor.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha - do { - pipeline = try device.makeRenderPipelineState(descriptor: descriptor) - clearWetPipeline = try device.makeComputePipelineState(function: clearWetFunction) - accumulateWetPipeline = try device.makeComputePipelineState(function: accumulateWetFunction) - } catch { - throw BurnRendererError.pipeline(error.localizedDescription) - } + let pipelineResources = try Self.pipelineResources(for: device) + pipeline = pipelineResources.render + clearWetPipeline = pipelineResources.clearWet + accumulateWetPipeline = pipelineResources.accumulateWet + initializeCombustionPipeline = pipelineResources.initializeCombustion + stepCombustionPipeline = pipelineResources.stepCombustion let imageAspect = CGFloat(image.width) / CGFloat(max(1, image.height)) let wetTextureWidth: Int @@ -153,6 +159,26 @@ final class BurnRenderer: NSObject, MTKViewDelegate { } self.wetAccumulationTexture = wetAccumulationTexture + let combustionTextureDescriptor = MTLTextureDescriptor.texture2DDescriptor( + pixelFormat: .rgba16Float, + width: wetTextureWidth, + height: wetTextureHeight, + mipmapped: false + ) + combustionTextureDescriptor.storageMode = .private + combustionTextureDescriptor.usage = [.shaderRead, .shaderWrite] + guard + let firstCombustionTexture = device.makeTexture( + descriptor: combustionTextureDescriptor + ), + let secondCombustionTexture = device.makeTexture( + descriptor: combustionTextureDescriptor + ) + else { + throw BurnRendererError.texture("Metal could not create the combustion state textures.") + } + combustionStateTextures = [firstCombustionTexture, secondCombustionTexture] + let textureLoader = MTKTextureLoader(device: device) let textureOptions: [MTKTextureLoader.Option: Any] = [ .origin: MTKTextureLoader.Origin.topLeft, @@ -167,6 +193,10 @@ final class BurnRenderer: NSObject, MTKViewDelegate { cgImage: backdropImage ?? image, options: textureOptions ) + shadowTexture = try textureLoader.newTexture( + cgImage: shadowImage ?? image, + options: textureOptions + ) } catch { throw BurnRendererError.texture(error.localizedDescription) } @@ -185,11 +215,29 @@ final class BurnRenderer: NSObject, MTKViewDelegate { } func start() { + guard startTime == nil else { return } let now = CACurrentMediaTime() startTime = now lastDrawTime = now } + func synchronizeNextFrame() { + shouldSynchronizeNextFrame = true + } + + func activateReplacementSurface() { + isReplacementSurfaceActive = true + shouldSynchronizeNextFrame = true + } + + @discardableResult + func prepareForIgnitionHandoff() -> Bool { + guard case .soakAndBurn = style, burnStartedAt == nil else { return false } + isHandoffPrepared = true + shouldSynchronizeNextFrame = true + return true + } + @discardableResult func addIgnition(_ point: BurnIgnitionPoint) -> Bool { guard case .torch = style else { return false } @@ -219,6 +267,7 @@ final class BurnRenderer: NSObject, MTKViewDelegate { if soakEndedAt == nil { soakEndedAt = elapsed } + isHandoffPrepared = false burnStartedAt = elapsed ignitionField.add(point: point, startedAt: elapsed) return true @@ -257,9 +306,6 @@ final class BurnRenderer: NSObject, MTKViewDelegate { ) } guard encodeWetFieldUpdates(wetDeposits, on: commandBuffer) else { return } - guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPass) else { - return - } let burnElapsed: TimeInterval if case .soakAndBurn = style { @@ -301,7 +347,7 @@ final class BurnRenderer: NSObject, MTKViewDelegate { let hasWetContent = wetDepositQueue.totalPointCount > 0 var wetInfo = SIMD4( hasWetContent ? SoakEffect.wetness(heldFor: soakingDuration) : 0, - burnStartedAt == nil ? (soakEndedAt == nil ? 0 : 1) : 2, + burnStartedAt == nil && !isHandoffPrepared ? (soakEndedAt == nil ? 0 : 1) : 2, hasWetContent ? SoakEffect.amount(heldFor: soakingDuration) : 0, hasWetContent ? 1 : 0 ) @@ -311,9 +357,11 @@ final class BurnRenderer: NSObject, MTKViewDelegate { visualProfile.glowWidth, visualProfile.flameReach ) - var fireMaterial = SIMD2( + var fireMaterial = SIMD4( visualProfile.sparkDensity, - visualProfile.residualCharOpacity + visualProfile.residualCharOpacity, + visualProfile.radialContourWarp, + visualProfile.radialBiteDepth ) var waterOptics = SIMD4( wetVisualProfile.refractionStrength, @@ -339,6 +387,13 @@ final class BurnRenderer: NSObject, MTKViewDelegate { wetVisualProfile.tearStartDensity, wetVisualProfile.tearFullDensity ) + var windowCornerRadius = cornerRadius + var replacementInfo = SIMD4( + isReplacementSurfaceActive ? 1 : 0, + hasCapturedWindowShadow ? 1 : 0, + shadowSamplingOffset.x, + shadowSamplingOffset.y + ) var wetUniforms = activeWetPoint.map { point in [SIMD4(point.x, point.y, profile.seed + 11.73, impactFade)] @@ -358,6 +413,20 @@ final class BurnRenderer: NSObject, MTKViewDelegate { ignitionUniforms.append(.zero) } + guard + encodeCombustionFieldUpdate( + progress: timing.x, + elapsed: Float(elapsed), + frameDuration: Float(frameDuration), + effectMode: effectMode, + ignitionUniforms: ignitionUniforms, + on: commandBuffer + ) + else { return } + guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPass) else { + return + } + encoder.setRenderPipelineState(pipeline) encoder.setViewport( MTLViewport( @@ -372,6 +441,11 @@ final class BurnRenderer: NSObject, MTKViewDelegate { encoder.setFragmentTexture(texture, index: 0) encoder.setFragmentTexture(wetAccumulationTexture, index: 1) encoder.setFragmentTexture(backdropTexture, index: 2) + encoder.setFragmentTexture( + combustionStateTextures[currentCombustionTextureIndex], + index: 3 + ) + encoder.setFragmentTexture(shadowTexture, index: 4) encoder.setFragmentSamplerState(sampler, index: 0) encoder.setFragmentBytes(&timing, length: MemoryLayout>.stride, index: 0) encoder.setFragmentBytes(&padding, length: MemoryLayout>.stride, index: 1) @@ -394,7 +468,7 @@ final class BurnRenderer: NSObject, MTKViewDelegate { ) encoder.setFragmentBytes( &fireMaterial, - length: MemoryLayout>.stride, + length: MemoryLayout>.stride, index: 9 ) encoder.setFragmentBytes( @@ -417,16 +491,33 @@ final class BurnRenderer: NSObject, MTKViewDelegate { length: MemoryLayout>.stride, index: 13 ) + encoder.setFragmentBytes( + &windowCornerRadius, + length: MemoryLayout.stride, + index: 14 + ) + encoder.setFragmentBytes( + &replacementInfo, + length: MemoryLayout>.stride, + index: 15 + ) encoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4) encoder.endEncoding() commandBuffer.present(drawable) commandBuffer.commit() + if shouldSynchronizeNextFrame { + commandBuffer.waitUntilCompleted() + shouldSynchronizeNextFrame = false + } let canComplete: Bool = { if case .soakAndBurn = style { return burnStartedAt != nil } return true }() - if canComplete, timing.x >= 1 { + let completionProgress = CombustionVisualModel.completionProgress( + isRadial: effectMode > 0.5 + ) + if canComplete, timing.x >= completionProgress { hasCompleted = true view.isPaused = true completion() @@ -498,6 +589,104 @@ final class BurnRenderer: NSObject, MTKViewDelegate { return true } + private func encodeCombustionFieldUpdate( + progress: Float, + elapsed: Float, + frameDuration: Float, + effectMode: Float, + ignitionUniforms: [SIMD4], + on commandBuffer: MTLCommandBuffer + ) -> Bool { + if !isCombustionStateInitialized { + guard let initializeEncoder = commandBuffer.makeComputeCommandEncoder() else { + return false + } + initializeEncoder.setComputePipelineState(initializeCombustionPipeline) + initializeEncoder.setTexture(wetAccumulationTexture, index: 0) + initializeEncoder.setTexture(combustionStateTextures[0], index: 1) + initializeEncoder.dispatchThreads( + MTLSize( + width: combustionStateTextures[0].width, + height: combustionStateTextures[0].height, + depth: 1 + ), + threadsPerThreadgroup: computeThreadgroupSize(for: initializeCombustionPipeline) + ) + initializeEncoder.endEncoding() + isCombustionStateInitialized = true + currentCombustionTextureIndex = 0 + } + + let nextCombustionTextureIndex = 1 - currentCombustionTextureIndex + guard let stepEncoder = commandBuffer.makeComputeCommandEncoder() else { + return false + } + var timing = SIMD4( + progress, + elapsed, + Float(profile.duration), + frameDuration + ) + let acceptsNewMoisture = effectMode > 1.5 && ignitionField.ignitions.isEmpty + var mode = SIMD4( + effectMode, + Float(ignitionField.ignitions.count), + acceptsNewMoisture ? 1 : 0, + 0 + ) + var fieldInfo = SIMD4( + Float(texture.width) / Float(max(1, texture.height)), + profile.seed, + profile.tilt, + profile.turbulence + ) + var physics = SIMD4( + combustionProfile.ignitionThreshold, + combustionProfile.moistureResistance, + combustionProfile.evaporationRate, + combustionProfile.fuelBurnRate + ) + var dynamics = SIMD4( + combustionProfile.heatDecay, + combustionProfile.spreadRate, + combustionProfile.heatRelease, + combustionProfile.maximumHeat + ) + var edgeShape = SIMD2( + visualProfile.radialContourWarp, + visualProfile.radialBiteDepth + ) + + stepEncoder.setComputePipelineState(stepCombustionPipeline) + stepEncoder.setTexture( + combustionStateTextures[currentCombustionTextureIndex], + index: 0 + ) + stepEncoder.setTexture(wetAccumulationTexture, index: 1) + stepEncoder.setTexture(combustionStateTextures[nextCombustionTextureIndex], index: 2) + ignitionUniforms.withUnsafeBytes { bytes in + guard let baseAddress = bytes.baseAddress else { return } + stepEncoder.setBytes(baseAddress, length: bytes.count, index: 0) + } + stepEncoder.setBytes(&timing, length: MemoryLayout>.stride, index: 1) + stepEncoder.setBytes(&mode, length: MemoryLayout>.stride, index: 2) + stepEncoder.setBytes(&fieldInfo, length: MemoryLayout>.stride, index: 3) + stepEncoder.setBytes(&physics, length: MemoryLayout>.stride, index: 4) + stepEncoder.setBytes(&dynamics, length: MemoryLayout>.stride, index: 5) + stepEncoder.setBytes(&edgeShape, length: MemoryLayout>.stride, index: 6) + stepEncoder.dispatchThreads( + MTLSize( + width: combustionStateTextures[nextCombustionTextureIndex].width, + height: combustionStateTextures[nextCombustionTextureIndex].height, + depth: 1 + ), + threadsPerThreadgroup: computeThreadgroupSize(for: stepCombustionPipeline) + ) + stepEncoder.endEncoding() + currentCombustionTextureIndex = nextCombustionTextureIndex + return true + } + private func computeThreadgroupSize( for pipeline: MTLComputePipelineState ) -> MTLSize { @@ -506,6 +695,59 @@ final class BurnRenderer: NSObject, MTKViewDelegate { return MTLSize(width: width, height: height, depth: 1) } + private static func pipelineResources(for device: MTLDevice) throws -> PipelineResources { + if let cached = pipelineResourcesByRegistryID[device.registryID] { + return cached + } + + let library: MTLLibrary + do { + library = try device.makeLibrary(source: shaderSource, options: nil) + } catch { + throw BurnRendererError.shaderCompilation(error.localizedDescription) + } + guard + let vertexFunction = library.makeFunction(name: "burnVertex"), + let fragmentFunction = library.makeFunction(name: "burnFragment"), + let clearWetFunction = library.makeFunction(name: "clearWetField"), + let accumulateWetFunction = library.makeFunction(name: "accumulateWetField"), + let initializeCombustionFunction = library.makeFunction(name: "initializeCombustionField"), + let stepCombustionFunction = library.makeFunction(name: "stepCombustionField") + else { + throw BurnRendererError.shaderFunction + } + + let descriptor = MTLRenderPipelineDescriptor() + descriptor.vertexFunction = vertexFunction + descriptor.fragmentFunction = fragmentFunction + descriptor.colorAttachments[0].pixelFormat = .bgra8Unorm + descriptor.colorAttachments[0].isBlendingEnabled = true + descriptor.colorAttachments[0].rgbBlendOperation = .add + descriptor.colorAttachments[0].alphaBlendOperation = .add + descriptor.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha + descriptor.colorAttachments[0].sourceAlphaBlendFactor = .one + descriptor.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha + descriptor.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha + + do { + let resources = PipelineResources( + render: try device.makeRenderPipelineState(descriptor: descriptor), + clearWet: try device.makeComputePipelineState(function: clearWetFunction), + accumulateWet: try device.makeComputePipelineState(function: accumulateWetFunction), + initializeCombustion: try device.makeComputePipelineState( + function: initializeCombustionFunction + ), + stepCombustion: try device.makeComputePipelineState( + function: stepCombustionFunction + ) + ) + pipelineResourcesByRegistryID[device.registryID] = resources + return resources + } catch { + throw BurnRendererError.pipeline(error.localizedDescription) + } + } + private static let shaderSource = #""" #include using namespace metal; @@ -564,6 +806,40 @@ final class BurnRenderer: NSObject, MTKViewDelegate { return value; } + float radialEdgeOffset( + float2 uv, + float2 physicalDelta, + float ignitionProgress, + float ignitionSeed, + float turbulence, + float contourWarpStrength, + float biteDepth + ) { + float radialDistance = length(physicalDelta); + float2 radialDirection = physicalDelta / max(radialDistance, 0.0001); + float radialGrain = fbm( + uv * float2(13.0, 11.0) + + float2(ignitionSeed * 0.011, ignitionSeed * 0.019) + ); + float radialFibers = valueNoise( + uv * float2(47.0, 39.0) + ignitionSeed * 0.007 + ); + float offset = (radialGrain - 0.5) * 0.13 * turbulence + + (radialFibers - 0.5) * 0.035; + float contourNoise = fbm( + radialDirection * 2.15 + + float2(ignitionSeed * 0.023, ignitionSeed * 0.037) + ); + float biteNoise = valueNoise( + radialDirection * 7.7 + + float2(ignitionSeed * 0.041, ignitionSeed * 0.029) + ); + float frontMaturity = smoothstep(0.05, 0.30, ignitionProgress); + float contourWarp = (contourNoise - 0.47) * contourWarpStrength; + float bite = pow(smoothstep(0.68, 0.91, biteNoise), 1.7) * biteDepth; + return offset + (contourWarp + bite) * frontMaturity; + } + kernel void clearWetField( texture2d wetField [[texture(0)]], uint2 position [[thread_position_in_grid]] @@ -628,11 +904,170 @@ final class BurnRenderer: NSObject, MTKViewDelegate { wetField.write(float4(min(density, 64.0), 0.0, 0.0, 1.0), position); } + kernel void initializeCombustionField( + texture2d wetField [[texture(0)]], + texture2d combustionState [[texture(1)]], + uint2 position [[thread_position_in_grid]] + ) { + if (position.x >= combustionState.get_width() + || position.y >= combustionState.get_height()) { + return; + } + + float wetDensity = wetField.read(position).r; + float moisture = clamp(log2(1.0 + max(0.0, wetDensity)) * 0.32, 0.0, 1.0); + combustionState.write(float4(0.0, moisture, 1.0, 0.0), position); + } + + kernel void stepCombustionField( + texture2d currentState [[texture(0)]], + texture2d wetField [[texture(1)]], + texture2d nextState [[texture(2)]], + constant float4 *ignitions [[buffer(0)]], + constant float4 &timing [[buffer(1)]], + constant float4 &mode [[buffer(2)]], + constant float4 &fieldInfo [[buffer(3)]], + constant float4 &physics [[buffer(4)]], + constant float4 &dynamics [[buffer(5)]], + constant float2 &edgeShape [[buffer(6)]], + uint2 position [[thread_position_in_grid]] + ) { + if (position.x >= nextState.get_width() + || position.y >= nextState.get_height()) { + return; + } + + uint2 left = uint2(max(int(position.x) - 1, 0), position.y); + uint2 right = uint2(min(position.x + 1, currentState.get_width() - 1), position.y); + uint2 above = uint2(position.x, max(int(position.y) - 1, 0)); + uint2 below = uint2(position.x, min(position.y + 1, currentState.get_height() - 1)); + float4 state = currentState.read(position); + float neighborHeat = ( + currentState.read(left).r + + currentState.read(right).r + + currentState.read(above).r + + currentState.read(below).r * 1.35 + ) / 4.35; + + float2 fieldSize = float2(nextState.get_width(), nextState.get_height()); + float2 uv = (float2(position) + 0.5) / fieldSize; + float progress = timing.x; + float time = timing.y; + float burnDuration = max(timing.z, 0.001); + float deltaTime = clamp(timing.w, 0.0, 1.0 / 15.0); + float effectMode = mode.x; + uint ignitionCount = uint(mode.y); + float aspect = fieldInfo.x; + float seed = fieldInfo.y; + float tilt = fieldInfo.z; + float turbulence = fieldInfo.w; + float2 seedOffset = float2(seed * 0.0137, seed * 0.0319); + + float sourceHeat = 0.0; + if (effectMode < 0.5) { + float coarse = fbm(float2(uv.x * 6.4 * turbulence, seedOffset.x)); + float detail = valueNoise(float2(uv.x * 41.0, seedOffset.y)); + float fibers = sin(uv.x * (71.0 + seed * 0.003) + seed) * 0.5 + 0.5; + float ragged = (coarse - 0.5) * 0.19 * turbulence + + (detail - 0.5) * 0.052 + + (fibers - 0.5) * 0.016; + float front = progress * 1.24 - 0.12 + + tilt * (uv.x - 0.5) + + sin(uv.x * 3.14159265) * 0.055 + + ragged; + float frontHeat = 1.0 - smoothstep(0.012, 0.060, abs(uv.y - front)); + float passedFront = 1.0 - step(front - 0.025, uv.y); + sourceHeat = max(frontHeat, passedFront * 0.34 * (1.0 - state.a)); + } else if (ignitionCount > 0) { + float maximumRadius = length(float2(aspect, 1.0)) + 0.12; + for (uint index = 0; index < ignitionCount; index++) { + float4 ignition = ignitions[index]; + float age = max(0.0, time - ignition.z); + float ignitionProgress = clamp(age / burnDuration, 0.0, 1.0); + float radius = ignitionProgress * maximumRadius; + float2 physicalDelta = (uv - ignition.xy) * float2(aspect, 1.0); + float frontDistance = length(physicalDelta) - radius + + radialEdgeOffset( + uv, + physicalDelta, + ignitionProgress, + ignition.w, + turbulence, + edgeShape.x, + edgeShape.y + ); + float frontHeat = 1.0 - smoothstep( + 0.012, + 0.060, + abs(frontDistance) + ); + float passedFront = 1.0 - smoothstep(-0.065, 0.0, frontDistance); + sourceHeat = max( + sourceHeat, + max(frontHeat, passedFront * 0.34 * (1.0 - state.a)) + ); + } + } + + float ignitionThreshold = physics.x; + float moistureResistance = physics.y; + float evaporationRate = physics.z; + float fuelBurnRate = physics.w; + float heatDecay = dynamics.x; + float spreadRate = dynamics.y; + float heatRelease = dynamics.z; + float maximumHeat = dynamics.w; + + float heat = clamp(state.r, 0.0, maximumHeat); + float moisture = clamp(state.g, 0.0, 1.0); + float fuel = clamp(state.b, 0.0, 1.0); + float damage = clamp(state.a, 0.0, 1.0); + if (mode.z > 0.5) { + float depositedMoisture = clamp( + log2(1.0 + max(0.0, wetField.read(position).r)) * 0.32, + 0.0, + 1.0 + ); + moisture = max(moisture, depositedMoisture); + } + + float retainedHeat = heat * max(0.0, 1.0 - heatDecay * deltaTime); + float spreadHeat = clamp(neighborHeat, 0.0, maximumHeat) * spreadRate; + float nextHeat = min( + max(max(retainedHeat, spreadHeat), max(sourceHeat, 0.0)), + maximumHeat + ); + float evaporatedMoisture = min( + moisture, + nextHeat * evaporationRate * deltaTime + ); + float nextMoisture = moisture - evaporatedMoisture; + nextHeat = max( + 0.0, + nextHeat - evaporatedMoisture * moistureResistance * 0.85 + ); + float combustibleHeat = max( + 0.0, + nextHeat - ignitionThreshold - nextMoisture * moistureResistance + ); + float burnedFuel = min(fuel, combustibleHeat * fuelBurnRate * deltaTime); + float nextFuel = fuel - burnedFuel; + nextHeat = min(nextHeat + burnedFuel * heatRelease, maximumHeat); + float nextDamage = max(damage, 1.0 - nextFuel); + + nextState.write( + float4(nextHeat, nextMoisture, nextFuel, nextDamage), + position + ); + } + fragment float4 burnFragment( VertexOut input [[stage_in]], texture2d image [[texture(0)]], texture2d wetField [[texture(1)]], texture2d backdrop [[texture(2)]], + texture2d combustionState [[texture(3)]], + texture2d windowShadow [[texture(4)]], sampler imageSampler [[sampler(0)]], constant float2 &timing [[buffer(0)]], constant float2 &padding [[buffer(1)]], @@ -643,11 +1078,13 @@ final class BurnRenderer: NSObject, MTKViewDelegate { constant float4 *wetPoints [[buffer(6)]], constant float4 &wetInfo [[buffer(7)]], constant float4 &flameLayers [[buffer(8)]], - constant float2 &fireMaterial [[buffer(9)]], + constant float4 &fireMaterial [[buffer(9)]], constant float4 &waterOptics [[buffer(10)]], constant float4 &waterDetail [[buffer(11)]], constant float4 &waterGeometry [[buffer(12)]], - constant float4 &wetPaperDamage [[buffer(13)]] + constant float4 &wetPaperDamage [[buffer(13)]], + constant float &windowCornerRadius [[buffer(14)]], + constant float4 &replacementInfo [[buffer(15)]] ) { float progress = timing.x; float time = timing.y; @@ -661,6 +1098,8 @@ final class BurnRenderer: NSObject, MTKViewDelegate { float maximumFlameReach = flameLayers.w; float sparkDensity = fireMaterial.x; float residualCharOpacity = fireMaterial.y; + float radialContourWarp = fireMaterial.z; + float radialBiteDepth = fireMaterial.w; float refractionStrength = waterOptics.x; float dispersionStrength = waterOptics.y; float reflectionStrength = waterOptics.z; @@ -686,7 +1125,31 @@ final class BurnRenderer: NSObject, MTKViewDelegate { float2 imageUV = (input.uv - padding) / contentSize; float horizontalMask = step(0.0, imageUV.x) * step(imageUV.x, 1.0); - float insideMask = horizontalMask * step(0.0, imageUV.y) * step(imageUV.y, 1.0); + float rectangleMask = horizontalMask + * step(0.0, imageUV.y) + * step(imageUV.y, 1.0); + float capturedShape = image.sample(imageSampler, imageUV).a; + float insideMask = rectangleMask + * smoothstep(0.01, 0.99, capturedShape); + float contentAspect = aspect * contentSize.x / max(contentSize.y, 0.001); + float2 shapePosition = (imageUV - 0.5) * float2(contentAspect, 1.0); + float2 shapeHalfSize = float2(contentAspect * 0.5, 0.5); + float shapeRadius = min( + windowCornerRadius, + min(shapeHalfSize.x, shapeHalfSize.y) + ); + float2 roundedDelta = abs(shapePosition) + - (shapeHalfSize - shapeRadius); + float roundedRectangleDistance = length(max(roundedDelta, 0.0)) + + min(max(roundedDelta.x, roundedDelta.y), 0.0) + - shapeRadius; + float4 combustion = combustionState.sample( + imageSampler, + clamp(imageUV, 0.0, 1.0) + ); + float combustionHeat = combustion.r * insideMask; + float combustionMoisture = combustion.g * insideMask; + float combustionDamage = combustion.a * insideMask; float2 seedOffset = float2(seed * 0.0137, seed * 0.0319); float coarse = fbm(float2(imageUV.x * 6.4 * turbulence, seedOffset.x)); @@ -710,18 +1173,18 @@ final class BurnRenderer: NSObject, MTKViewDelegate { float age = max(0.0, time - ignition.z); float ignitionProgress = clamp(age / burnDuration, 0.0, 1.0); float2 delta = (imageUV - ignition.xy) * float2(aspect, 1.0); - float radialGrain = fbm( - imageUV * float2(13.0, 11.0) - + float2(ignition.w * 0.011, ignition.w * 0.019) - ); - float radialFibers = valueNoise( - imageUV * float2(47.0, 39.0) - + ignition.w * 0.007 + float radialDistance = length(delta); + float radialRagged = radialEdgeOffset( + imageUV, + delta, + ignitionProgress, + ignition.w, + turbulence, + radialContourWarp, + radialBiteDepth ); - float radialRagged = (radialGrain - 0.5) * 0.13 * turbulence - + (radialFibers - 0.5) * 0.035; float radius = ignitionProgress * maximumRadius; - nearestFront = min(nearestFront, length(delta) - radius + radialRagged); + nearestFront = min(nearestFront, radialDistance - radius + radialRagged); } signedDistance = nearestFront; } else if (soakMode > 0.5) { @@ -734,6 +1197,9 @@ final class BurnRenderer: NSObject, MTKViewDelegate { * step(0.0, signedDistance) * (1.0 - smoothstep(0.0, charWidth * 1.35, signedDistance)); scorchBand *= 0.58 + grain * 0.42; + float stateScorch = smoothstep(0.06, 0.34, combustionDamage) + * (1.0 - smoothstep(0.72, 0.98, combustionDamage)); + scorchBand *= mix(1.0, stateScorch, soakMode); float pores = scorchBand * smoothstep(0.74, 0.95, pinholeNoise + scorchBand * 0.18); @@ -743,6 +1209,33 @@ final class BurnRenderer: NSObject, MTKViewDelegate { hotCoreWidth * 1.65, signedDistance + edgeBreakup ); + float physicalBorderDistance = -roundedRectangleDistance; + float borderSuppression = mix( + 1.0, + smoothstep(0.0, 0.085, physicalBorderDistance), + radialMode + ); + float radialEffectVisibility = borderSuppression; + float damageContour = fbm( + imageUV * float2(6.8, 5.4) + seedOffset * 4.3 + ); + float damageFibers = valueNoise( + imageUV * float2(37.0, 29.0) + seedOffset * 9.7 + ); + float damageBreakup = (damageContour - 0.47) + * radialContourWarp * 3.0; + damageBreakup += (damageFibers - 0.5) * radialBiteDepth * 2.0; + float activeDamageEdge = smoothstep(0.10, 0.44, combustionDamage) + * (1.0 - smoothstep(0.84, 1.0, combustionDamage)); + float fractureMaturity = radialMode * smoothstep(0.05, 0.30, progress); + float fracturedDamage = clamp( + combustionDamage + + damageBreakup * activeDamageEdge * fractureMaturity, + 0.0, + 1.0 + ); + float stateKeep = 1.0 - smoothstep(0.50, 0.98, fracturedDamage); + keep = mix(keep, min(keep, stateKeep), radialMode); float2 sourceUV = imageUV; float wetMask = 0.0; float waterThickness = 0.0; @@ -764,6 +1257,9 @@ final class BurnRenderer: NSObject, MTKViewDelegate { float tornEdge = 0.0; float tornLip = 0.0; float tornShadow = 0.0; + float depositedMoisture = 0.0; + float steamSource = 0.0; + float steamPlume = 0.0; if (soakMode > 0.5 && wetInfo.w > 0.5) { float wetness = wetInfo.x; float fluidAmount = max(wetInfo.z, wetness); @@ -783,6 +1279,66 @@ final class BurnRenderer: NSObject, MTKViewDelegate { clamp(imageUV, 0.0, 1.0) ).r * insideMask; localFluidDensity = bakedDensity; + depositedMoisture = clamp( + log2(1.0 + max(0.0, bakedDensity)) * 0.32, + 0.0, + 1.0 + ); + float evaporatedMoisture = max( + 0.0, + depositedMoisture - combustionMoisture + ); + float boilingMoisture = smoothstep(0.12, 0.78, combustionHeat) + * max( + smoothstep(0.02, 0.32, evaporatedMoisture), + smoothstep(0.16, 0.76, combustionMoisture) * 0.75 + ); + steamSource = min(0.62, boilingMoisture * 0.62); + + for (uint plumeLayer = 0; plumeLayer < 4; plumeLayer++) { + float layer = float(plumeLayer); + float travel = fmod( + time * (0.040 + layer * 0.012) + layer * 0.051, + 0.22 + ); + float sway = sin( + time * (1.7 + layer * 0.23) + + imageUV.y * 13.0 + + layer * 2.1 + ) * (0.007 + travel * 0.12); + float2 plumeUV = imageUV + float2(sway, 0.018 + travel); + float plumeInside = step(0.0, plumeUV.x) + * step(plumeUV.x, 1.0) + * step(0.0, plumeUV.y) + * step(plumeUV.y, 1.0); + float plumeDensity = wetField.sample( + imageSampler, + clamp(plumeUV, 0.0, 1.0) + ).r * plumeInside; + float plumeDepositedMoisture = clamp( + log2(1.0 + max(0.0, plumeDensity)) * 0.32, + 0.0, + 1.0 + ); + float4 plumeCombustion = combustionState.sample( + imageSampler, + clamp(plumeUV, 0.0, 1.0) + ) * plumeInside; + float plumeEvaporated = max( + 0.0, + plumeDepositedMoisture - plumeCombustion.g + ); + float plumeBoiling = smoothstep(0.12, 0.78, plumeCombustion.r) + * max( + smoothstep(0.02, 0.32, plumeEvaporated), + smoothstep(0.16, 0.76, plumeCombustion.g) * 0.75 + ); + float plumeLife = 1.0 - smoothstep(0.08, 0.22, travel); + steamPlume = max( + steamPlume, + plumeBoiling * plumeLife * (0.48 - layer * 0.055) + ); + } absorptionMask = smoothstep(0.018, 0.28, bakedDensity); liquidMask = smoothstep(0.28, 1.40, bakedDensity) * mix(0.62, 1.0, stillSoaking); @@ -1099,14 +1655,26 @@ final class BurnRenderer: NSObject, MTKViewDelegate { ); } - absorptionMask *= insideMask * wetness; - liquidMask *= insideMask * wetness; + float remainingMoistureRatio = depositedMoisture > 0.0001 + ? clamp(combustionMoisture / depositedMoisture, 0.0, 1.0) + : 0.0; + float wetRetention = mix( + 1.0, + 0.05 + smoothstep(0.02, 0.90, remainingMoistureRatio) * 0.95, + step(1.5, wetInfo.y) + ); + absorptionMask *= insideMask * wetness * wetRetention; + liquidMask *= insideMask * wetness * wetRetention; + dropletMask *= wetRetention; + dropletRim *= wetRetention; + dropletHighlight *= wetRetention; + localFluidDensity *= wetRetention; wetMask = max( max(absorptionMask * 0.62, liquidMask), dropletMask ); - waterThickness = max(waterThickness, dropletMask) * wetness; - wetRim = max(wetRim, dropletRim) * insideMask * wetness; + waterThickness = max(waterThickness, dropletMask) * wetness * wetRetention; + wetRim = max(wetRim, dropletRim) * insideMask * wetness * wetRetention; float ripple = sin( imageUV.y * 95.0 + fbm(imageUV * 21.0 + seedOffset) * 8.0 @@ -1385,9 +1953,15 @@ final class BurnRenderer: NSObject, MTKViewDelegate { source.rgb = mix(source.rgb, edgeSoot, scorchBand * scorchBand * 0.64); float effectMask = mix(horizontalMask, insideMask, radialMode) - * combustibleMask; + * combustibleMask + * radialEffectVisibility; float edgeDistance = abs(signedDistance); float burnedDistance = max(0.0, -signedDistance); + float combustionActivity = smoothstep(0.10, 0.72, combustionHeat) + * (1.0 - smoothstep(0.88, 1.0, combustionDamage)); + float moistureDamping = 1.0 + - smoothstep(0.08, 0.72, combustionMoisture) * 0.96; + effectMask *= max(0.028, combustionActivity) * moistureDamping; float edgeFlicker = 0.58 + 0.42 * valueNoise(float2( imageUV.x * 127.0 + seedOffset.y, time * 17.0 + seedOffset.x @@ -1401,7 +1975,9 @@ final class BurnRenderer: NSObject, MTKViewDelegate { float glow = effectMask * (1.0 - smoothstep(emberWidth * 0.48, glowWidth, edgeDistance)); float tornEdgeArrival = tornEdge - * (1.0 - smoothstep(hotCoreWidth * 0.45, glowWidth * 1.35, edgeDistance)); + * (1.0 - smoothstep(hotCoreWidth * 0.45, glowWidth * 1.35, edgeDistance)) + * moistureDamping + * radialEffectVisibility; hotCore = max(hotCore, tornEdgeArrival * edgeFlicker * 0.82); emberEdge = max(emberEdge, tornEdgeArrival * 0.74); glow = max(glow, tornEdgeArrival * 0.52); @@ -1533,6 +2109,7 @@ final class BurnRenderer: NSObject, MTKViewDelegate { } } } + spark *= radialEffectVisibility * mix(1.0, moistureDamping, soakMode); float smoke = effectMask * step(emberWidth, burnedDistance) @@ -1541,6 +2118,15 @@ final class BurnRenderer: NSObject, MTKViewDelegate { imageUV.x * 5.0 + time * 0.25 + seedOffset.x, imageUV.y * 4.0 - time * 0.34 + seedOffset.y ))); + float steamNoise = fbm(float2( + imageUV.x * 7.0 + time * 0.18 + seedOffset.y, + imageUV.y * 6.0 - time * 0.72 + seedOffset.x + )); + float steam = insideMask + * soakMode + * radialEffectVisibility + * max(steamSource, steamPlume) + * (0.70 + steamNoise * 0.30); float fireAlpha = max( glow * 0.32, @@ -1550,11 +2136,54 @@ final class BurnRenderer: NSObject, MTKViewDelegate { + orange * emberEdge * 1.05 + flameColor * flame * 1.58 + hotWhite * hotCore * 1.95; - float outputAlpha = max(source.a, max(fireAlpha, max(spark, smoke))); + float outsideDistance = max(0.0, roundedRectangleDistance); + float shapeAntialias = max(fwidth(roundedRectangleDistance), 0.0005); + float exterior = 1.0 - insideMask; + float replacementActive = replacementInfo.x; + float hasNativeShadow = replacementInfo.y; + float survivingExterior = smoothstep(0.08, 0.72, keep); + float4 capturedShadow = windowShadow.sample( + imageSampler, + input.uv + replacementInfo.zw + ); + float nativeShadowAlpha = capturedShadow.a + * exterior + * survivingExterior + * replacementActive + * hasNativeShadow; + float fallbackExterior = smoothstep( + -shapeAntialias, + shapeAntialias, + roundedRectangleDistance + ); + float exteriorShadow = exterior + * (1.0 - smoothstep(0.006, 0.065, outsideDistance)) + * survivingExterior + * replacementActive + * (1.0 - hasNativeShadow) + * 0.30; + float exteriorRim = fallbackExterior + * (1.0 - smoothstep(0.0, 0.0045, outsideDistance)) + * survivingExterior + * replacementActive + * (1.0 - hasNativeShadow) + * 0.18; + float exteriorDepth = max( + nativeShadowAlpha, + max(exteriorShadow, exteriorRim) + ); + float outputAlpha = max( + source.a, + max(exteriorDepth, max(fireAlpha, max(spark, max(smoke, steam)))) + ); float3 outputRGB = source.rgb * source.a + fireRGB + float3(1.0, 0.48, 0.045) * spark * 2.20 - + float3(0.10, 0.075, 0.068) * smoke; + + float3(0.10, 0.075, 0.068) * smoke + + float3(0.80, 0.88, 0.92) * steam * 1.18 + + capturedShadow.rgb * nativeShadowAlpha + + float3(0.012, 0.010, 0.009) * exteriorShadow + + float3(0.20, 0.17, 0.13) * exteriorRim; outputRGB = outputAlpha > 0.0 ? outputRGB / outputAlpha : 0.0; return float4(outputRGB, outputAlpha); diff --git a/Sources/WindowBurn/WindowCaptureService.swift b/Sources/WindowBurn/WindowCaptureService.swift index c359900..9bec94d 100644 --- a/Sources/WindowBurn/WindowCaptureService.swift +++ b/Sources/WindowBurn/WindowCaptureService.swift @@ -6,6 +6,8 @@ import WindowBurnCore struct CapturedWindow { let image: CGImage let backdropImage: CGImage + let shadowImage: CGImage? + let shadowSamplingOffset: CGPoint let captureFrame: CGRect } @@ -76,6 +78,13 @@ enum WindowCaptureService { configuration: configuration ) + let shadowCapture = await captureAlignedShadow( + contentFilter: filter, + windowImage: image, + pointPixelScale: CGFloat(filter.pointPixelScale), + padding: BurnOverlayController.padding + ) + let displayFrames = content.displays.map(\.frame) guard let displayIndex = BackdropCaptureGeometry.displayIndex( @@ -108,7 +117,127 @@ enum WindowCaptureService { return CapturedWindow( image: image, backdropImage: backdropImage, + shadowImage: shadowCapture?.image, + shadowSamplingOffset: shadowCapture?.samplingOffset ?? .zero, captureFrame: window.frame ) } + + private static func captureAlignedShadow( + contentFilter: SCContentFilter, + windowImage: CGImage, + pointPixelScale: CGFloat, + padding: CGFloat + ) async -> (image: CGImage, samplingOffset: CGPoint)? { + let paddingPixels = max(1, Int((padding * pointPixelScale).rounded())) + let configuration = SCStreamConfiguration() + configuration.width = windowImage.width + paddingPixels * 2 + configuration.height = windowImage.height + paddingPixels * 2 + configuration.showsCursor = false + configuration.ignoreShadowsSingleWindow = false + configuration.scalesToFit = false + configuration.preservesAspectRatio = true + + do { + let shadowImage = try await SCScreenshotManager.captureImage( + contentFilter: contentFilter, + configuration: configuration + ) + guard + let capturedContentOrigin = opaqueContentOrigin( + in: shadowImage, + expectedContentSize: CGSize(width: windowImage.width, height: windowImage.height) + ) + else { + logger.warning("Could not align the captured native window shadow") + return nil + } + let desiredContentOrigin = CGPoint(x: paddingPixels, y: paddingPixels) + let samplingOffset = OverlayDepthModel.shadowSamplingOffset( + capturedContentOrigin: capturedContentOrigin, + desiredContentOrigin: desiredContentOrigin, + textureSize: CGSize(width: shadowImage.width, height: shadowImage.height) + ) + logger.info( + "Captured native shadow with sampling offset \(samplingOffset.x, format: .fixed(precision: 4)), \(samplingOffset.y, format: .fixed(precision: 4))" + ) + return (shadowImage, samplingOffset) + } catch { + logger.warning( + "Native window shadow capture failed: \(error.localizedDescription, privacy: .public)" + ) + return nil + } + } + + private static func opaqueContentOrigin( + in image: CGImage, + expectedContentSize: CGSize + ) -> CGPoint? { + let width = image.width + let height = image.height + let bytesPerPixel = 4 + let bytesPerRow = width * bytesPerPixel + var pixels = [UInt8](repeating: 0, count: bytesPerRow * height) + let colorSpace = CGColorSpaceCreateDeviceRGB() + let bitmapInfo = + CGImageAlphaInfo.premultipliedLast.rawValue + | CGBitmapInfo.byteOrder32Big.rawValue + let drewImage = pixels.withUnsafeMutableBytes { bytes -> Bool in + guard + let baseAddress = bytes.baseAddress, + let context = CGContext( + data: baseAddress, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: colorSpace, + bitmapInfo: bitmapInfo + ) + else { + return false + } + context.translateBy(x: 0, y: CGFloat(height)) + context.scaleBy(x: 1, y: -1) + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + return true + } + guard drewImage else { return nil } + + var maximumAlpha: UInt8 = 0 + for alphaIndex in stride(from: 3, to: pixels.count, by: bytesPerPixel) { + maximumAlpha = max(maximumAlpha, pixels[alphaIndex]) + } + guard maximumAlpha > 0 else { return nil } + let threshold = UInt8(max(1, Int(Float(maximumAlpha) * 0.98))) + var minimumX = width + var minimumY = height + var maximumX = -1 + var maximumY = -1 + for y in 0..= threshold { + minimumX = min(minimumX, x) + minimumY = min(minimumY, y) + maximumX = max(maximumX, x) + maximumY = max(maximumY, y) + } + } + guard maximumX >= minimumX, maximumY >= minimumY else { return nil } + + let detectedWidth = maximumX - minimumX + 1 + let detectedHeight = maximumY - minimumY + 1 + let expectedWidth = Int(expectedContentSize.width.rounded()) + let expectedHeight = Int(expectedContentSize.height.rounded()) + guard + abs(detectedWidth - expectedWidth) <= 4, + abs(detectedHeight - expectedHeight) <= 4 + else { + return nil + } + let topOriginY = height - maximumY - 1 + return CGPoint(x: minimumX, y: topOriginY) + } } diff --git a/Sources/WindowBurnCore/BurnGeometry.swift b/Sources/WindowBurnCore/BurnGeometry.swift index e501e1c..f054284 100644 --- a/Sources/WindowBurnCore/BurnGeometry.swift +++ b/Sources/WindowBurnCore/BurnGeometry.swift @@ -105,3 +105,69 @@ public enum BurnTiming { return min(1, max(0, elapsed / duration)) } } + +public enum OverlayDepthModel { + public static func shadowSamplingOffset( + capturedContentOrigin: CGPoint, + desiredContentOrigin: CGPoint, + textureSize: CGSize + ) -> CGPoint { + guard textureSize.width > 0, textureSize.height > 0 else { return .zero } + return CGPoint( + x: (capturedContentOrigin.x - desiredContentOrigin.x) / textureSize.width, + y: (capturedContentOrigin.y - desiredContentOrigin.y) / textureSize.height + ) + } + + public static func nativeShadowOpacity( + capturedAlpha: Float, + exteriorCoverage: Float, + materialVisibility: Float, + isReplacementActive: Bool + ) -> Float { + guard isReplacementActive else { return 0 } + return min(1, max(0, capturedAlpha)) + * min(1, max(0, exteriorCoverage)) + * smoothstep(0.08, 0.72, materialVisibility) + } + + public static func roundedRectangleSignedDistance( + x: Float, + y: Float, + aspect: Float, + cornerRadius: Float + ) -> Float { + let validAspect = max(0.001, aspect) + let radius = min(0.5, validAspect * 0.5, max(0, cornerRadius)) + let distanceX = abs(x - 0.5) * validAspect - (validAspect * 0.5 - radius) + let distanceY = abs(y - 0.5) - (0.5 - radius) + let outsideX = max(0, distanceX) + let outsideY = max(0, distanceY) + let outside = sqrt(outsideX * outsideX + outsideY * outsideY) + let inside = min(max(distanceX, distanceY), 0) + return outside + inside - radius + } + + public static func materialCoverage( + sourceAlpha: Float, + rectangleCoverage: Float + ) -> Float { + min(1, max(0, sourceAlpha)) * min(1, max(0, rectangleCoverage)) + } + + public static func exteriorShadowOpacity( + outsideDistance: Float, + materialVisibility: Float, + isNativeWindowVisible: Bool + ) -> Float { + guard !isNativeWindowVisible else { return 0 } + let distanceFade = 1 - smoothstep(0.006, 0.065, max(0, outsideDistance)) + let survivingMaterial = smoothstep(0.08, 0.72, materialVisibility) + return distanceFade * survivingMaterial * 0.30 + } + + private static func smoothstep(_ edge0: Float, _ edge1: Float, _ value: Float) -> Float { + let normalized = min(1, max(0, (value - edge0) / (edge1 - edge0))) + return normalized * normalized * (3 - 2 * normalized) + } +} diff --git a/Sources/WindowBurnCore/BurnVisualProfile.swift b/Sources/WindowBurnCore/BurnVisualProfile.swift index d4a8b51..98a5c6d 100644 --- a/Sources/WindowBurnCore/BurnVisualProfile.swift +++ b/Sources/WindowBurnCore/BurnVisualProfile.swift @@ -5,6 +5,8 @@ public struct BurnVisualProfile: Equatable, Sendable { public let flameReach: Float public let sparkDensity: Float public let residualCharOpacity: Float + public let radialContourWarp: Float + public let radialBiteDepth: Float public init( hotCoreWidth: Float, @@ -12,7 +14,9 @@ public struct BurnVisualProfile: Equatable, Sendable { glowWidth: Float, flameReach: Float, sparkDensity: Float, - residualCharOpacity: Float + residualCharOpacity: Float, + radialContourWarp: Float = 0.11, + radialBiteDepth: Float = 0.05 ) { self.hotCoreWidth = hotCoreWidth self.emberWidth = emberWidth @@ -20,6 +24,8 @@ public struct BurnVisualProfile: Equatable, Sendable { self.flameReach = flameReach self.sparkDensity = sparkDensity self.residualCharOpacity = residualCharOpacity + self.radialContourWarp = radialContourWarp + self.radialBiteDepth = radialBiteDepth } public static let cinematic = BurnVisualProfile( @@ -28,6 +34,8 @@ public struct BurnVisualProfile: Equatable, Sendable { glowWidth: 0.094, flameReach: 0.20, sparkDensity: 0.28, - residualCharOpacity: 0 + residualCharOpacity: 0, + radialContourWarp: 0.195, + radialBiteDepth: 0.076 ) } diff --git a/Sources/WindowBurnCore/CombustionModel.swift b/Sources/WindowBurnCore/CombustionModel.swift new file mode 100644 index 0000000..01af745 --- /dev/null +++ b/Sources/WindowBurnCore/CombustionModel.swift @@ -0,0 +1,191 @@ +public struct CombustionProfile: Equatable, Sendable { + public let ignitionThreshold: Float + public let moistureResistance: Float + public let evaporationRate: Float + public let fuelBurnRate: Float + public let heatDecay: Float + public let spreadRate: Float + public let heatRelease: Float + public let maximumHeat: Float + + public init( + ignitionThreshold: Float, + moistureResistance: Float, + evaporationRate: Float, + fuelBurnRate: Float, + heatDecay: Float, + spreadRate: Float, + heatRelease: Float, + maximumHeat: Float + ) { + self.ignitionThreshold = ignitionThreshold + self.moistureResistance = moistureResistance + self.evaporationRate = evaporationRate + self.fuelBurnRate = fuelBurnRate + self.heatDecay = heatDecay + self.spreadRate = spreadRate + self.heatRelease = heatRelease + self.maximumHeat = maximumHeat + } + + public static let cinematic = CombustionProfile( + ignitionThreshold: 0.22, + moistureResistance: 0.95, + evaporationRate: 1.35, + fuelBurnRate: 3.8, + heatDecay: 0.42, + spreadRate: 0.92, + heatRelease: 0.85, + maximumHeat: 1.5 + ) +} + +public struct CombustionCellState: Equatable, Sendable { + public let heat: Float + public let moisture: Float + public let fuel: Float + public let damage: Float + + public init(heat: Float, moisture: Float, fuel: Float, damage: Float) { + self.heat = heat + self.moisture = moisture + self.fuel = fuel + self.damage = damage + } +} + +public enum CombustionModel { + public static func step( + state: CombustionCellState, + neighboringHeat: Float, + sourceHeat: Float, + deltaTime: Float, + profile: CombustionProfile + ) -> CombustionCellState { + let deltaTime = min(max(deltaTime, 0), 1.0 / 15.0) + let heat = min(max(state.heat, 0), profile.maximumHeat) + let moisture = min(max(state.moisture, 0), 1) + let fuel = min(max(state.fuel, 0), 1) + let damage = min(max(state.damage, 0), 1) + + let retainedHeat = heat * max(0, 1 - profile.heatDecay * deltaTime) + let spreadHeat = min(max(neighboringHeat, 0), profile.maximumHeat) * profile.spreadRate + var nextHeat = min( + max(max(retainedHeat, spreadHeat), max(sourceHeat, 0)), + profile.maximumHeat + ) + + let evaporatedMoisture = min( + moisture, + nextHeat * profile.evaporationRate * deltaTime + ) + let nextMoisture = moisture - evaporatedMoisture + nextHeat = max( + 0, + nextHeat - evaporatedMoisture * profile.moistureResistance * 0.85 + ) + let combustibleHeat = max( + 0, + nextHeat - profile.ignitionThreshold - nextMoisture * profile.moistureResistance + ) + let burnedFuel = min(fuel, combustibleHeat * profile.fuelBurnRate * deltaTime) + let nextFuel = fuel - burnedFuel + nextHeat = min(nextHeat + burnedFuel * profile.heatRelease, profile.maximumHeat) + + return CombustionCellState( + heat: nextHeat, + moisture: nextMoisture, + fuel: nextFuel, + damage: max(damage, 1 - nextFuel) + ) + } +} + +public struct CombustionVisualResponse: Equatable, Sendable { + public let fireVisibility: Float + public let steamOpacity: Float + public let scorchOpacity: Float + public let effectVisibility: Float + public let materialVisibility: Float + + public init( + fireVisibility: Float, + steamOpacity: Float, + scorchOpacity: Float, + effectVisibility: Float, + materialVisibility: Float + ) { + self.fireVisibility = fireVisibility + self.steamOpacity = steamOpacity + self.scorchOpacity = scorchOpacity + self.effectVisibility = effectVisibility + self.materialVisibility = materialVisibility + } +} + +public enum CombustionVisualModel { + public static func completionProgress(isRadial: Bool) -> Float { + 1 + } + + public static func borderVisibility( + distanceToBorder: Float, + fadeWidth: Float, + isRadial: Bool + ) -> Float { + guard isRadial else { return 1 } + return smoothstep(0, max(0.001, fadeWidth), max(0, distanceToBorder)) + } + + public static func response( + depositedMoisture: Float, + remainingMoisture: Float, + heat: Float, + damage: Float, + progress: Float, + isRadial: Bool, + geometricVisibility: Float = 1 + ) -> CombustionVisualResponse { + let depositedMoisture = clamp(depositedMoisture) + let remainingMoisture = min(clamp(remainingMoisture), depositedMoisture) + let heat = max(0, heat) + let damage = clamp(damage) + let geometricVisibility = clamp(geometricVisibility) + + let moistureDamping = 1 - smoothstep(0.08, 0.72, remainingMoisture) * 0.96 + let evaporatedMoisture = max(0, depositedMoisture - remainingMoisture) + let boilingMoisture = + smoothstep(0.12, 0.78, heat) + * max( + smoothstep(0.02, 0.32, evaporatedMoisture), + smoothstep(0.16, 0.76, remainingMoisture) * 0.75 + ) + let steamOpacity = min(0.62, boilingMoisture * 0.62) + let scorchOpacity = + smoothstep(0.06, 0.34, damage) + * (1 - smoothstep(0.72, 0.98, damage)) + + let undamagedMaterial = 1 - smoothstep(0.50, 0.98, damage) + let materialVisibility = + isRadial + ? min(geometricVisibility, undamagedMaterial) + : geometricVisibility + + return CombustionVisualResponse( + fireVisibility: moistureDamping, + steamOpacity: steamOpacity, + scorchOpacity: scorchOpacity, + effectVisibility: 1, + materialVisibility: materialVisibility + ) + } + + private static func clamp(_ value: Float) -> Float { + min(1, max(0, value)) + } + + private static func smoothstep(_ edge0: Float, _ edge1: Float, _ value: Float) -> Float { + let normalized = clamp((value - edge0) / (edge1 - edge0)) + return normalized * normalized * (3 - 2 * normalized) + } +} diff --git a/Sources/WindowBurnCore/SoakAndBurn.swift b/Sources/WindowBurnCore/SoakAndBurn.swift index fd952fa..07be054 100644 --- a/Sources/WindowBurnCore/SoakAndBurn.swift +++ b/Sources/WindowBurnCore/SoakAndBurn.swift @@ -66,9 +66,10 @@ public enum SoakEffect { public enum WetPaperCompositing { public static func sourceCoverage( effectCoverage: Float, - isBurning: Bool + isBurning: Bool, + isHandoffPrepared: Bool = false ) -> Float { - isBurning ? 1 : clamp(effectCoverage) + isBurning || isHandoffPrepared ? 1 : clamp(effectCoverage) } public static func materialCoverage(ruptureCoverage: Float) -> Float { diff --git a/Sources/WindowBurnCore/TorchBurn.swift b/Sources/WindowBurnCore/TorchBurn.swift index 59c0319..475b9bf 100644 --- a/Sources/WindowBurnCore/TorchBurn.swift +++ b/Sources/WindowBurnCore/TorchBurn.swift @@ -42,6 +42,62 @@ public struct TorchIgnitionField: Equatable, Sendable { } } +public struct TorchWindowSessionRegistry: Equatable, Sendable { + public static let maximumConcurrentWindows = 4 + + private struct Entry: Equatable, Sendable { + let id: UUID + var captureFrame: CGRect + } + + private var entries: [Entry] = [] + + public init() {} + + public var count: Int { + entries.count + } + + public var isAtCapacity: Bool { + count >= Self.maximumConcurrentWindows + } + + @discardableResult + public mutating func register(id: UUID, captureFrame: CGRect) -> Bool { + guard + !isAtCapacity, + !entries.contains(where: { $0.id == id }), + captureFrame.width > 1, + captureFrame.height > 1 + else { + return false + } + entries.append(Entry(id: id, captureFrame: captureFrame)) + return true + } + + @discardableResult + public mutating func updateCaptureFrame(id: UUID, captureFrame: CGRect) -> Bool { + guard + captureFrame.width > 1, + captureFrame.height > 1, + let index = entries.firstIndex(where: { $0.id == id }) + else { + return false + } + entries[index].captureFrame = captureFrame + return true + } + + public func sessionID(containing screenPoint: CGPoint) -> UUID? { + entries.last(where: { $0.captureFrame.contains(screenPoint) })?.id + } + + public mutating func remove(id: UUID) { + entries.removeAll(where: { $0.id == id }) + } +} + public enum TorchBurnGeometry { public static func normalizedIgnition( screenPoint: CGPoint, diff --git a/Support/Info.plist b/Support/Info.plist index a7e7f45..7f300ef 100644 --- a/Support/Info.plist +++ b/Support/Info.plist @@ -21,9 +21,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.2.0 + 0.3.0 CFBundleVersion - 2 + 3 LSApplicationCategoryType public.app-category.utilities LSMinimumSystemVersion diff --git a/Tests/WindowBurnCoreTests/BurnVisualProfileTests.swift b/Tests/WindowBurnCoreTests/BurnVisualProfileTests.swift index e019d1d..ac9e2a0 100644 --- a/Tests/WindowBurnCoreTests/BurnVisualProfileTests.swift +++ b/Tests/WindowBurnCoreTests/BurnVisualProfileTests.swift @@ -24,4 +24,14 @@ struct BurnVisualProfileTests { #expect(BurnVisualProfile.cinematic.sparkDensity >= 0.25) #expect(BurnVisualProfile.cinematic.sparkDensity <= 0.6) } + + @Test("A radial burn breaks its circular silhouette at multiple scales") + func radialContourIsStructurallyIrregular() { + let profile = BurnVisualProfile.cinematic + + #expect(profile.radialContourWarp >= 0.18) + #expect(profile.radialContourWarp <= 0.22) + #expect(profile.radialBiteDepth >= 0.07) + #expect(profile.radialBiteDepth < profile.radialContourWarp) + } } diff --git a/Tests/WindowBurnCoreTests/CombustionModelTests.swift b/Tests/WindowBurnCoreTests/CombustionModelTests.swift new file mode 100644 index 0000000..0f0ee2b --- /dev/null +++ b/Tests/WindowBurnCoreTests/CombustionModelTests.swift @@ -0,0 +1,228 @@ +import Testing + +@testable import WindowBurnCore + +@Suite("Stateful combustion") +struct CombustionModelTests { + @Test("Dry material ignites and loses fuel") + func dryMaterialBurns() { + let next = CombustionModel.step( + state: CombustionCellState(heat: 1, moisture: 0, fuel: 1, damage: 0), + neighboringHeat: 0.8, + sourceHeat: 1, + deltaTime: 1.0 / 60.0, + profile: .cinematic + ) + + #expect(next.fuel < 1) + #expect(next.damage > 0) + #expect(next.heat > 0.9) + } + + @Test("Moisture consumes heat before the material burns") + func moistureDelaysCombustion() { + let dry = CombustionModel.step( + state: CombustionCellState(heat: 1, moisture: 0, fuel: 1, damage: 0), + neighboringHeat: 0.8, + sourceHeat: 1, + deltaTime: 1.0 / 60.0, + profile: .cinematic + ) + let wet = CombustionModel.step( + state: CombustionCellState(heat: 1, moisture: 1, fuel: 1, damage: 0), + neighboringHeat: 0.8, + sourceHeat: 1, + deltaTime: 1.0 / 60.0, + profile: .cinematic + ) + + #expect(wet.moisture < 1) + #expect(wet.fuel > dry.fuel) + #expect(wet.damage < dry.damage) + } + + @Test("Evaporation removes heat from wet material") + func evaporationCoolsMaterial() { + let dry = CombustionModel.step( + state: CombustionCellState(heat: 0, moisture: 0, fuel: 0, damage: 1), + neighboringHeat: 0, + sourceHeat: 1, + deltaTime: 1.0 / 15.0, + profile: .cinematic + ) + let wet = CombustionModel.step( + state: CombustionCellState(heat: 0, moisture: 1, fuel: 0, damage: 1), + neighboringHeat: 0, + sourceHeat: 1, + deltaTime: 1.0 / 15.0, + profile: .cinematic + ) + + #expect(wet.heat < dry.heat) + #expect(wet.moisture < 1) + } + + @Test("A sustained flame eventually dries and burns soaked material") + func sustainedHeatOvercomesMoisture() { + var state = CombustionCellState(heat: 0, moisture: 1, fuel: 1, damage: 0) + + for _ in 0..<(60 * 4) { + state = CombustionModel.step( + state: state, + neighboringHeat: 0.9, + sourceHeat: 1, + deltaTime: 1.0 / 60.0, + profile: .cinematic + ) + } + + #expect(state.moisture < 0.05) + #expect(state.fuel < 0.1) + #expect(state.damage > 0.9) + } + + @Test("Damage is monotonic and all channels stay bounded") + func stateRemainsStable() { + let initial = CombustionCellState(heat: 4, moisture: -1, fuel: 0.4, damage: 0.7) + let next = CombustionModel.step( + state: initial, + neighboringHeat: 3, + sourceHeat: 5, + deltaTime: 1, + profile: .cinematic + ) + + #expect(next.heat >= 0 && next.heat <= CombustionProfile.cinematic.maximumHeat) + #expect(next.moisture >= 0 && next.moisture <= 1) + #expect(next.fuel >= 0 && next.fuel <= 1) + #expect(next.damage >= initial.damage && next.damage <= 1) + } + + @Test("Heated soaked material steams visibly before it flames") + func wetMaterialProducesVisibleSteam() { + let response = CombustionVisualModel.response( + depositedMoisture: 1, + remainingMoisture: 0.72, + heat: 1, + damage: 0.05, + progress: 0.45, + isRadial: true + ) + + #expect(response.steamOpacity >= 0.35) + #expect(response.fireVisibility <= 0.10) + } + + @Test("Dry material does not create a steam cloud") + func dryMaterialDoesNotSteam() { + let response = CombustionVisualModel.response( + depositedMoisture: 0, + remainingMoisture: 0, + heat: 1, + damage: 0.2, + progress: 0.45, + isRadial: true + ) + + #expect(response.steamOpacity == 0) + #expect(response.fireVisibility > 0.9) + } + + @Test("Freshly ignited material does not darken before it is damaged") + func undamagedMaterialDoesNotScorch() { + let fresh = CombustionVisualModel.response( + depositedMoisture: 0.8, + remainingMoisture: 0.8, + heat: 1, + damage: 0, + progress: 0.1, + isRadial: true + ) + let damaged = CombustionVisualModel.response( + depositedMoisture: 0.8, + remainingMoisture: 0.2, + heat: 1, + damage: 0.25, + progress: 0.3, + isRadial: true + ) + + #expect(fresh.scorchOpacity == 0) + #expect(damaged.scorchOpacity > fresh.scorchOpacity) + } + + @Test("Radial fire remains attached to its front instead of globally fading") + func radialFireHasNoGlobalTerminalFade() { + let response = CombustionVisualModel.response( + depositedMoisture: 0, + remainingMoisture: 0, + heat: 1, + damage: 0.7, + progress: 0.82, + isRadial: true + ) + + #expect(response.effectVisibility == 1) + } + + @Test("Radial fire fades before rectangular clipping can create a straight seam") + func radialFireFadesAtPhysicalBorder() { + let atBorder = CombustionVisualModel.borderVisibility( + distanceToBorder: 0, + fadeWidth: 0.085, + isRadial: true + ) + let approachingBorder = CombustionVisualModel.borderVisibility( + distanceToBorder: 0.03, + fadeWidth: 0.085, + isRadial: true + ) + let safelyInside = CombustionVisualModel.borderVisibility( + distanceToBorder: 0.10, + fadeWidth: 0.085, + isRadial: true + ) + let sweepAtBorder = CombustionVisualModel.borderVisibility( + distanceToBorder: 0, + fadeWidth: 0.085, + isRadial: false + ) + + #expect(atBorder == 0) + #expect(approachingBorder > atBorder) + #expect(approachingBorder < safelyInside) + #expect(safelyInside == 1) + #expect(sweepAtBorder == 1) + } + + @Test("A passed radial front cannot be held by undamaged combustion state") + func radialGeometryRemovesMaterialWithoutAStaleWindowOutline() { + let behindFront = CombustionVisualModel.response( + depositedMoisture: 0, + remainingMoisture: 0, + heat: 1, + damage: 0, + progress: 0.52, + isRadial: true, + geometricVisibility: 0 + ) + let aheadOfFront = CombustionVisualModel.response( + depositedMoisture: 0, + remainingMoisture: 0, + heat: 0, + damage: 0, + progress: 0.52, + isRadial: true, + geometricVisibility: 1 + ) + + #expect(behindFront.materialVisibility == 0) + #expect(aheadOfFront.materialVisibility == 1) + } + + @Test("A radial overlay remains until its front has traversed the whole window") + func radialOverlayCompletesAtFullProgress() { + #expect(CombustionVisualModel.completionProgress(isRadial: true) == 1) + #expect(CombustionVisualModel.completionProgress(isRadial: false) == 1) + } +} diff --git a/Tests/WindowBurnCoreTests/GeometryTests.swift b/Tests/WindowBurnCoreTests/GeometryTests.swift index 4ff7476..b36325f 100644 --- a/Tests/WindowBurnCoreTests/GeometryTests.swift +++ b/Tests/WindowBurnCoreTests/GeometryTests.swift @@ -40,6 +40,94 @@ struct GeometryTests { #expect(point == CGPoint(x: 320, y: 820)) } + + @Test("The overlay shadow separates only the surviving external silhouette") + func preservesWindowDepthDuringBurn() { + let visibleEdge = OverlayDepthModel.exteriorShadowOpacity( + outsideDistance: 0.012, + materialVisibility: 1, + isNativeWindowVisible: false + ) + let nativeShadowStillVisible = OverlayDepthModel.exteriorShadowOpacity( + outsideDistance: 0.012, + materialVisibility: 1, + isNativeWindowVisible: true + ) + let burnedEdge = OverlayDepthModel.exteriorShadowOpacity( + outsideDistance: 0.012, + materialVisibility: 0, + isNativeWindowVisible: false + ) + let beyondShadow = OverlayDepthModel.exteriorShadowOpacity( + outsideDistance: 0.09, + materialVisibility: 1, + isNativeWindowVisible: false + ) + + #expect(visibleEdge > 0.15) + #expect(nativeShadowStillVisible == 0) + #expect(burnedEdge == 0) + #expect(beyondShadow == 0) + } + + @Test("The captured system shadow aligns with the original window") + func alignsCapturedWindowShadow() { + let offset = OverlayDepthModel.shadowSamplingOffset( + capturedContentOrigin: CGPoint(x: 112, y: 76), + desiredContentOrigin: CGPoint(x: 168, y: 168), + textureSize: CGSize(width: 1_776, height: 1_400) + ) + + #expect(abs(offset.x + 56 / 1_776) < 0.000_001) + #expect(abs(offset.y + 92 / 1_400) < 0.000_001) + } + + @Test("The replacement shadow appears only after the native window closes") + func activatesCapturedWindowShadowAtomically() { + let beforeHandoff = OverlayDepthModel.nativeShadowOpacity( + capturedAlpha: 0.48, + exteriorCoverage: 1, + materialVisibility: 1, + isReplacementActive: false + ) + let afterHandoff = OverlayDepthModel.nativeShadowOpacity( + capturedAlpha: 0.48, + exteriorCoverage: 1, + materialVisibility: 1, + isReplacementActive: true + ) + let burnedAway = OverlayDepthModel.nativeShadowOpacity( + capturedAlpha: 0.48, + exteriorCoverage: 1, + materialVisibility: 0, + isReplacementActive: true + ) + + #expect(beforeHandoff == 0) + #expect(afterHandoff > 0.47) + #expect(burnedAway == 0) + } + + @Test("Rounded window corners are outside the combustible silhouette") + func clipsRoundedWindowCorners() { + let topEdge = OverlayDepthModel.roundedRectangleSignedDistance( + x: 0.5, + y: 0, + aspect: 1.5, + cornerRadius: 0.025 + ) + let squareCorner = OverlayDepthModel.roundedRectangleSignedDistance( + x: 0, + y: 0, + aspect: 1.5, + cornerRadius: 0.025 + ) + + #expect(abs(topEdge) < 0.001) + #expect(squareCorner > 0.005) + #expect(OverlayDepthModel.materialCoverage(sourceAlpha: 0, rectangleCoverage: 1) == 0) + #expect(OverlayDepthModel.materialCoverage(sourceAlpha: 1, rectangleCoverage: 1) == 1) + } } @Suite("Capture pixel sizing") diff --git a/Tests/WindowBurnCoreTests/SoakAndBurnTests.swift b/Tests/WindowBurnCoreTests/SoakAndBurnTests.swift index 9322f33..44ab75c 100644 --- a/Tests/WindowBurnCoreTests/SoakAndBurnTests.swift +++ b/Tests/WindowBurnCoreTests/SoakAndBurnTests.swift @@ -144,6 +144,17 @@ struct SoakAndBurnTests { #expect(WetPaperCompositing.sourceCoverage(effectCoverage: 0, isBurning: true) == 1) } + @Test("The captured window is fully covered before the native window closes") + func preparesAnAtomicBurnHandoff() { + #expect( + WetPaperCompositing.sourceCoverage( + effectCoverage: 0, + isBurning: false, + isHandoffPrepared: true + ) == 1 + ) + } + @Test("A ruptured hole is no longer combustible material") func excludesHolesFromCombustion() { #expect(WetPaperCompositing.materialCoverage(ruptureCoverage: 0) == 1) diff --git a/Tests/WindowBurnCoreTests/TorchBurnTests.swift b/Tests/WindowBurnCoreTests/TorchBurnTests.swift index fd0d77f..31af402 100644 --- a/Tests/WindowBurnCoreTests/TorchBurnTests.swift +++ b/Tests/WindowBurnCoreTests/TorchBurnTests.swift @@ -1,4 +1,5 @@ import CoreGraphics +import Foundation import Testing @testable import WindowBurnCore @@ -52,4 +53,72 @@ struct TorchBurnTests { #expect(field.ignitions.first?.startedAt == 0) #expect(field.ignitions.last?.startedAt == Double(TorchIgnitionField.maximumCount - 1)) } + + @Test("Routes an overlapping click to the most recently ignited window") + func routesClickToMostRecentWindow() throws { + let olderWindow = UUID() + let newerWindow = UUID() + var registry = TorchWindowSessionRegistry() + + let registeredOlderWindow = registry.register( + id: olderWindow, + captureFrame: CGRect(x: 100, y: 100, width: 500, height: 400) + ) + let registeredNewerWindow = registry.register( + id: newerWindow, + captureFrame: CGRect(x: 300, y: 200, width: 500, height: 400) + ) + #expect(registeredOlderWindow) + #expect(registeredNewerWindow) + + let match = try #require( + registry.sessionID(containing: CGPoint(x: 350, y: 250)) + ) + #expect(match == newerWindow) + } + + @Test("Completing one torch window leaves the other sessions active") + func removesOnlyCompletedWindow() { + let firstWindow = UUID() + let secondWindow = UUID() + var registry = TorchWindowSessionRegistry() + let firstFrame = CGRect(x: 100, y: 100, width: 300, height: 250) + let secondFrame = CGRect(x: 500, y: 100, width: 300, height: 250) + + let registeredFirstWindow = registry.register(id: firstWindow, captureFrame: firstFrame) + let registeredSecondWindow = registry.register(id: secondWindow, captureFrame: secondFrame) + #expect(registeredFirstWindow) + #expect(registeredSecondWindow) + + registry.remove(id: firstWindow) + + #expect(registry.count == 1) + #expect(registry.sessionID(containing: firstFrame.center) == nil) + #expect(registry.sessionID(containing: secondFrame.center) == secondWindow) + } + + @Test("Bounds the number of simultaneously burning windows") + func boundsConcurrentWindowCount() { + var registry = TorchWindowSessionRegistry() + + for index in 0..