V0.1.4.2 FIxed memory leak issue AND an import issue, causing re-importation not working properly

This commit is contained in:
ewen 2026-01-15 00:48:25 +01:00
parent a470a232e8
commit 2d281fcf39
4 changed files with 246 additions and 131 deletions

View file

@ -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)

View file

@ -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(&params, length: MemoryLayout<RenderParameters>.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(&params, length: MemoryLayout<RenderParameters>.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(&params, length: MemoryLayout<RenderParameters>.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(&params, length: MemoryLayout<RenderParameters>.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(&params, length: MemoryLayout<RenderParameters>.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(&params, length: MemoryLayout<RenderParameters>.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,

View file

@ -65,14 +65,14 @@ float2 applySpatialChaos(float2 coord, constant RenderParameters &params, 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<float, access::read> 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<float, access::read> 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<float, access::read> 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;

View file

@ -60,6 +60,7 @@ class DitherViewModel {
private let renderer = MetalImageRenderer()
private var renderTask: Task<Void, Never>?
private var renderDebounceTask: Task<Void, Never>?
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
}
}
}
}