From 2d281fcf398d171bb2c0d934dc362f9febb6c400 Mon Sep 17 00:00:00 2001 From: ewen Date: Thu, 15 Jan 2026 00:48:25 +0100 Subject: [PATCH] V0.1.4.2 FIxed memory leak issue AND an import issue, causing re-importation not working properly --- Sources/iDither/ContentView.swift | 12 +- .../iDither/Renderer/MetalImageRenderer.swift | 188 ++++++++++-------- Sources/iDither/Resources/Shaders.metal | 35 +++- .../iDither/ViewModel/DitherViewModel.swift | 142 +++++++++---- 4 files changed, 246 insertions(+), 131 deletions(-) diff --git a/Sources/iDither/ContentView.swift b/Sources/iDither/ContentView.swift index 63e5056..1e03e3d 100644 --- a/Sources/iDither/ContentView.swift +++ b/Sources/iDither/ContentView.swift @@ -343,7 +343,7 @@ struct SidebarView: View { .foregroundStyle(.secondary.opacity(0.8)) .padding(.top, 12) - SliderControl(label: "Pixel Displace", value: $viewModel.pixelDisplace, range: 0...50, format: .pixels) + SliderControl(label: "Pixel Displace", value: $viewModel.pixelDisplace, range: 0...100, format: .pixels) SliderControl(label: "Turbulence", value: $viewModel.turbulence, range: 0...1, format: .percent) SliderControl(label: "Chroma Aberration", value: $viewModel.chromaAberration, range: 0...20, format: .pixels) @@ -379,6 +379,16 @@ struct SidebarView: View { .cornerRadius(8) .shadow(color: .black.opacity(0.05), radius: 1, x: 0, y: 1) + #if DEBUG + Button("Force Refresh (Debug)") { + viewModel.forceRefresh() + } + .font(.caption) + .foregroundStyle(.red) + .buttonStyle(.plain) + .padding(.leading, 4) + #endif + Spacer() } .padding(20) diff --git a/Sources/iDither/Renderer/MetalImageRenderer.swift b/Sources/iDither/Renderer/MetalImageRenderer.swift index 3e7b1b1..52fb5ca 100644 --- a/Sources/iDither/Renderer/MetalImageRenderer.swift +++ b/Sources/iDither/Renderer/MetalImageRenderer.swift @@ -66,93 +66,120 @@ final class MetalImageRenderer: Sendable { } func render(input: CGImage, params: RenderParameters) -> CGImage? { - let textureLoader = MTKTextureLoader(device: device) - - // Load input texture - guard let inputTexture = try? textureLoader.newTexture(cgImage: input, options: [.origin: MTKTextureLoader.Origin.topLeft]) else { - return nil - } - - // Create output texture - let descriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .rgba8Unorm, - width: inputTexture.width, - height: inputTexture.height, - mipmapped: false) - descriptor.usage = [.shaderWrite, .shaderRead] - - guard let outputTexture = device.makeTexture(descriptor: descriptor) else { - return nil - } - - // Encode command - guard let commandBuffer = commandQueue.makeCommandBuffer(), - let computeEncoder = commandBuffer.makeComputeCommandEncoder() else { - return nil - } - - var params = params - - if params.algorithm == 7, let pipe1 = pipelineStateFS_Pass1, let pipe2 = pipelineStateFS_Pass2 { - // FLOYD-STEINBERG MULTI-PASS + return autoreleasepool { + print("🎨 Metal render started - Image: \(input.width)x\(input.height), Algo: \(params.algorithm)") - // Create Error Texture (Float16 or Float32 for precision) - let errorDesc = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .rgba16Float, - width: inputTexture.width, - height: inputTexture.height, - mipmapped: false) - errorDesc.usage = [.shaderWrite, .shaderRead] - guard let errorTexture = device.makeTexture(descriptor: errorDesc) else { - computeEncoder.endEncoding() - return nil + let textureLoader = MTKTextureLoader(device: device) + + // Load input texture + guard let inputTexture = try? textureLoader.newTexture(cgImage: input, options: [.origin: MTKTextureLoader.Origin.topLeft]) else { + print("❌ Failed to create input texture") + return nil } - // PASS 1: Even Rows - computeEncoder.setComputePipelineState(pipe1) - computeEncoder.setTexture(inputTexture, index: 0) - computeEncoder.setTexture(outputTexture, index: 1) - computeEncoder.setTexture(errorTexture, index: 2) - computeEncoder.setBytes(¶ms, length: MemoryLayout.stride, index: 0) + print("✅ Input texture created: \(inputTexture.width)x\(inputTexture.height)") - // Dispatch (1, H/2, 1) -> Each thread handles one full row - let h = (inputTexture.height + 1) / 2 - let threadsPerGrid = MTLSizeMake(1, h, 1) - let threadsPerThreadgroup = MTLSizeMake(1, min(h, pipe1.maxTotalThreadsPerThreadgroup), 1) + // Create output texture + let descriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .rgba8Unorm, + width: inputTexture.width, + height: inputTexture.height, + mipmapped: false) + descriptor.usage = [.shaderWrite, .shaderRead] - computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup) + guard let outputTexture = device.makeTexture(descriptor: descriptor) else { + print("❌ Failed to create output texture") + return nil + } - // Memory Barrier (Ensure Pass 1 writes are visible to Pass 2) - computeEncoder.memoryBarrier(scope: .textures) + // Encode command + guard let commandBuffer = commandQueue.makeCommandBuffer(), + let computeEncoder = commandBuffer.makeComputeCommandEncoder() else { + print("❌ Failed to create command buffer or encoder") + return nil + } - // PASS 2: Odd Rows - computeEncoder.setComputePipelineState(pipe2) - computeEncoder.setTexture(inputTexture, index: 0) - computeEncoder.setTexture(outputTexture, index: 1) - computeEncoder.setTexture(errorTexture, index: 2) - computeEncoder.setBytes(¶ms, length: MemoryLayout.stride, index: 0) + var params = params - computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup) - - } else { - // STANDARD ALGORITHMS - computeEncoder.setComputePipelineState(pipelineState) - computeEncoder.setTexture(inputTexture, index: 0) - computeEncoder.setTexture(outputTexture, index: 1) - computeEncoder.setBytes(¶ms, length: MemoryLayout.stride, index: 0) - - let w = pipelineState.threadExecutionWidth - let h = pipelineState.maxTotalThreadsPerThreadgroup / w - let threadsPerThreadgroup = MTLSizeMake(w, h, 1) - let threadsPerGrid = MTLSizeMake(inputTexture.width, inputTexture.height, 1) - - computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup) - } + if params.algorithm == 7, let pipe1 = pipelineStateFS_Pass1, let pipe2 = pipelineStateFS_Pass2 { + print("🔄 Using Floyd-Steinberg two-pass rendering") + + // FLOYD-STEINBERG MULTI-PASS + + // Create Error Texture (Float16 or Float32 for precision) + let errorDesc = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .rgba16Float, + width: inputTexture.width, + height: inputTexture.height, + mipmapped: false) + errorDesc.usage = [.shaderWrite, .shaderRead] + + // CRITICAL: Use autoreleasepool check for error texture too + guard let errorTexture = device.makeTexture(descriptor: errorDesc) else { + computeEncoder.endEncoding() + return nil + } + + // PASS 1: Even Rows + computeEncoder.setComputePipelineState(pipe1) + computeEncoder.setTexture(inputTexture, index: 0) + computeEncoder.setTexture(outputTexture, index: 1) + computeEncoder.setTexture(errorTexture, index: 2) + computeEncoder.setBytes(¶ms, length: MemoryLayout.stride, index: 0) + + // Dispatch (1, H/2, 1) -> Each thread handles one full row + let h = (inputTexture.height + 1) / 2 + let threadsPerGrid = MTLSizeMake(1, h, 1) + let threadsPerThreadgroup = MTLSizeMake(1, min(h, pipe1.maxTotalThreadsPerThreadgroup), 1) + + computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup) + + // Memory Barrier (Ensure Pass 1 writes are visible to Pass 2) + computeEncoder.memoryBarrier(scope: .textures) + + // PASS 2: Odd Rows + computeEncoder.setComputePipelineState(pipe2) + computeEncoder.setTexture(inputTexture, index: 0) + computeEncoder.setTexture(outputTexture, index: 1) + computeEncoder.setTexture(errorTexture, index: 2) + computeEncoder.setBytes(¶ms, length: MemoryLayout.stride, index: 0) + + computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup) + + } else { + print("🔄 Using standard dithering algorithm") + + // STANDARD ALGORITHMS + computeEncoder.setComputePipelineState(pipelineState) + computeEncoder.setTexture(inputTexture, index: 0) + computeEncoder.setTexture(outputTexture, index: 1) + computeEncoder.setBytes(¶ms, length: MemoryLayout.stride, index: 0) + + let w = pipelineState.threadExecutionWidth + let h = pipelineState.maxTotalThreadsPerThreadgroup / w + let threadsPerThreadgroup = MTLSizeMake(w, h, 1) + let threadsPerGrid = MTLSizeMake(inputTexture.width, inputTexture.height, 1) + + computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup) + } - computeEncoder.endEncoding() - - commandBuffer.commit() - commandBuffer.waitUntilCompleted() - - return createCGImage(from: outputTexture) + computeEncoder.endEncoding() + + commandBuffer.commit() + commandBuffer.waitUntilCompleted() + + if let error = commandBuffer.error { + print("❌ Metal command buffer error: \(error)") + return nil + } + + print("✅ Metal render completed successfully") + + let result = createCGImage(from: outputTexture) + if result == nil { + print("❌ Failed to create CGImage from output texture") + } + + return result + } } private func createCGImage(from texture: MTLTexture) -> CGImage? { @@ -161,6 +188,7 @@ final class MetalImageRenderer: Sendable { let rowBytes = width * 4 let length = rowBytes * height + // CRITICAL: Create data buffer that will be copied, not retained var bytes = [UInt8](repeating: 0, count: length) let region = MTLRegionMake2D(0, 0, width, height) @@ -169,7 +197,9 @@ final class MetalImageRenderer: Sendable { let colorSpace = CGColorSpaceCreateDeviceRGB() let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue) - guard let provider = CGDataProvider(data: Data(bytes: bytes, count: length) as CFData) else { return nil } + // Create data with .copy behavior to avoid retaining original buffer + guard let data = CFDataCreate(nil, bytes, length) else { return nil } + guard let provider = CGDataProvider(data: data) else { return nil } return CGImage(width: width, height: height, diff --git a/Sources/iDither/Resources/Shaders.metal b/Sources/iDither/Resources/Shaders.metal index 995e46c..eb63ba6 100644 --- a/Sources/iDither/Resources/Shaders.metal +++ b/Sources/iDither/Resources/Shaders.metal @@ -65,14 +65,14 @@ float2 applySpatialChaos(float2 coord, constant RenderParameters ¶ms, uint2 if (params.pixelDisplace > 0.0) { float2 offset = random2(coord * 0.01, params.randomSeed) - 0.5; - chaosCoord += offset * params.pixelDisplace; + chaosCoord += offset * params.pixelDisplace * 2.0; } if (params.turbulence > 0.0) { - float scale = 0.05; + float scale = 0.02; float offsetX = noise(coord * scale, params.randomSeed) * 2.0 - 1.0; float offsetY = noise(coord * scale + float2(100.0), params.randomSeed) * 2.0 - 1.0; - chaosCoord += float2(offsetX, offsetY) * params.turbulence * 20.0; + chaosCoord += float2(offsetX, offsetY) * params.turbulence * 50.0; } return chaosCoord; @@ -123,8 +123,8 @@ float3 applyChromaAberration(texture2d inputTexture, return inputTexture.read(pixelCoord).rgb; } - float2 redOffset = coord + float2(amount, 0); - float2 blueOffset = coord - float2(amount, 0); + float2 redOffset = coord + float2(amount, amount * 0.5); + float2 blueOffset = coord - float2(amount, amount * 0.5); uint2 redCoord = uint2(clamp(redOffset, float2(0), float2(texSize) - 1.0)); uint2 greenCoord = uint2(clamp(coord, float2(0), float2(texSize) - 1.0)); @@ -141,15 +141,15 @@ float applyQuantizationChaos(float value, float2 coord, constant RenderParameter float chaosValue = value; if (params.bitDepthChaos > 0.0) { - float randVal = random(coord * 0.1, params.randomSeed); + float randVal = random(coord * 0.05, params.randomSeed); if (randVal < params.bitDepthChaos) { - float reducedDepth = floor(randVal * 3.0) + 2.0; + float reducedDepth = floor(randVal * 1.5) + 2.0; chaosValue = floor(value * reducedDepth) / reducedDepth; } } if (params.paletteRandomize > 0.0) { - float randShift = (random(coord, params.randomSeed) - 0.5) * params.paletteRandomize; + float randShift = (random(coord, params.randomSeed) - 0.5) * params.paletteRandomize * 0.5; chaosValue = clamp(value + randShift, 0.0, 1.0); } @@ -409,7 +409,13 @@ kernel void ditherShaderFS_Pass1(texture2d inputTexture [[t float weight = 7.0 / 16.0; if (params.errorRandomness > 0.0) { float r = random(float2(coords), params.randomSeed); - weight = mix(weight, r * 0.8, params.errorRandomness); + if (r < params.errorRandomness * 1.5) { + float r1 = random(float2(coords) + float2(1.0), params.randomSeed); + float r2 = random(float2(coords) + float2(2.0), params.randomSeed); + r1 = pow(r1, 0.5); + r2 = pow(r2, 0.5); + weight = mix(weight, r1, params.errorRandomness); + } } currentError = diff * weight; @@ -447,11 +453,18 @@ kernel void ditherShaderFS_Pass2(texture2d inputTexture [[t // Chaos: Error Randomness if (params.errorRandomness > 0.0) { float r = random(float2(coords) + float2(10.0), params.randomSeed); - if (r < params.errorRandomness) { + if (r < params.errorRandomness * 1.5) { float r1 = random(float2(coords) + float2(1.0), params.randomSeed); float r2 = random(float2(coords) + float2(2.0), params.randomSeed); float r3 = random(float2(coords) + float2(3.0), params.randomSeed); - float sum = r1 + r2 + r3 + 0.1; + float r4 = random(float2(coords) + float2(4.0), params.randomSeed); + + r1 = pow(r1, 0.5); + r2 = pow(r2, 0.5); + r3 = pow(r3, 0.5); + r4 = pow(r4, 0.5); + + float sum = r1 + r2 + r3 + r4 + 0.001; w_tr = r1 / sum; w_t = r2 / sum; w_tl = r3 / sum; diff --git a/Sources/iDither/ViewModel/DitherViewModel.swift b/Sources/iDither/ViewModel/DitherViewModel.swift index a540e4c..0e4ba30 100644 --- a/Sources/iDither/ViewModel/DitherViewModel.swift +++ b/Sources/iDither/ViewModel/DitherViewModel.swift @@ -60,6 +60,7 @@ class DitherViewModel { private let renderer = MetalImageRenderer() private var renderTask: Task? + private var renderDebounceTask: Task? init() {} @@ -78,59 +79,120 @@ class DitherViewModel { processImage() } - func load(url: URL) { - guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), - let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else { - print("Failed to load image from \(url)") + func forceRefresh() { + print("🔄 Force refresh triggered") + guard let _ = inputImage else { + print("⚠️ No input image to refresh") return } - self.inputImage = cgImage + // Clear everything + renderDebounceTask?.cancel() + renderDebounceTask = nil + renderTask?.cancel() + renderTask = nil + processedImage = nil + + // Wait a frame + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(100)) + self.processImage() + } + } + + func load(url: URL) { + // Cancel all tasks + renderTask?.cancel() + renderTask = nil + renderDebounceTask?.cancel() + renderDebounceTask = nil + + // CRITICAL: Clear old images to release memory + processedImage = nil + inputImage = nil + + // Force memory cleanup and load new image + autoreleasepool { + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), + let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else { + print("Failed to load image from \(url)") + return + } + + self.inputImage = cgImage + } + self.inputImageId = UUID() // Signal that a new image has been loaded - self.processImage() + + // Small delay to ensure UI updates + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(100)) + self.processImage() + } } func processImage() { guard let input = inputImage, let renderer = renderer else { return } - // Cancel previous task to prevent UI freezing and Metal overload - renderTask?.cancel() + // Cancel previous debounce + renderDebounceTask?.cancel() - // Generate a random seed for consistent chaos per frame/update - let seed = UInt32.random(in: 0...UInt32.max) - - let params = RenderParameters( - brightness: Float(brightness), - contrast: Float(contrast), - pixelScale: Float(pixelScale), - colorDepth: Float(colorDepth), - algorithm: Int32(selectedAlgorithm.rawValue), - isGrayscale: isGrayscale ? 1 : 0, - - // Chaos Params - offsetJitter: Float(offsetJitter), - patternRotation: Float(patternRotation), - errorAmplify: Float(errorAmplify), - errorRandomness: Float(errorRandomness), - thresholdNoise: Float(thresholdNoise), - waveDistortion: Float(waveDistortion), - pixelDisplace: Float(pixelDisplace), - turbulence: Float(turbulence), - chromaAberration: Float(chromaAberration), - bitDepthChaos: Float(bitDepthChaos), - paletteRandomize: Float(paletteRandomize), - randomSeed: seed - ) - - renderTask = Task.detached(priority: .userInitiated) { [input, renderer, params] in - if Task.isCancelled { return } - - let result = renderer.render(input: input, params: params) + // Debounce rapid parameter changes + renderDebounceTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(50)) // 50ms debounce if Task.isCancelled { return } - await MainActor.run { - self.processedImage = result + // Cancel previous render task + self.renderTask?.cancel() + self.renderTask = nil + + // Generate a random seed for consistent chaos per frame/update + let seed = UInt32.random(in: 0...UInt32.max) + + let params = RenderParameters( + brightness: Float(self.brightness), + contrast: Float(self.contrast), + pixelScale: Float(self.pixelScale), + colorDepth: Float(self.colorDepth), + algorithm: Int32(self.selectedAlgorithm.rawValue), + isGrayscale: self.isGrayscale ? 1 : 0, + + // Chaos Params + offsetJitter: Float(self.offsetJitter), + patternRotation: Float(self.patternRotation), + errorAmplify: Float(self.errorAmplify), + errorRandomness: Float(self.errorRandomness), + thresholdNoise: Float(self.thresholdNoise), + waveDistortion: Float(self.waveDistortion), + pixelDisplace: Float(self.pixelDisplace), + turbulence: Float(self.turbulence), + chromaAberration: Float(self.chromaAberration), + bitDepthChaos: Float(self.bitDepthChaos), + paletteRandomize: Float(self.paletteRandomize), + randomSeed: seed + ) + + print("🔄 Processing image with algorithm: \(self.selectedAlgorithm.name)") + + self.renderTask = Task.detached(priority: .userInitiated) { [input, renderer, params] in + if Task.isCancelled { + print("⚠️ Render task cancelled before starting") + return + } + + let result = renderer.render(input: input, params: params) + + if Task.isCancelled { + print("⚠️ Render task cancelled after render") + return + } + + await MainActor.run { + if Task.isCancelled { return } + print("✅ Render complete, updating UI") + self.processedImage = result + } } } }