Fix: Remove @MainActor isolation for Metal renderer
Some checks failed
Auto Tag on Push / auto-tag (push) Has been cancelled

This commit is contained in:
ewen 2026-01-16 00:35:35 +01:00
parent b2ffd9edaf
commit 1a5aa1fdef
2 changed files with 102 additions and 108 deletions

View file

@ -30,8 +30,9 @@ struct RenderParameters {
var randomSeed: UInt32 var randomSeed: UInt32
} }
@MainActor // SUPPRESSION DE @MainActor - Metal est thread-safe en pratique
final class MetalImageRenderer { final class MetalImageRenderer: Sendable {
// nonisolated(unsafe) car Metal est thread-safe malgré l'absence de Sendable
private let device: MTLDevice private let device: MTLDevice
private let commandQueue: MTLCommandQueue private let commandQueue: MTLCommandQueue
private let pipelineState: MTLComputePipelineState private let pipelineState: MTLComputePipelineState
@ -67,105 +68,99 @@ final class MetalImageRenderer {
} }
func render(input: CGImage, params: RenderParameters) async -> CGImage? { func render(input: CGImage, params: RenderParameters) async -> CGImage? {
return await withCheckedContinuation { continuation in return await withCheckedContinuation { continuation in
autoreleasepool { autoreleasepool {
print("🎨 Metal render started - Image: \(input.width)x\(input.height), Algo: \(params.algorithm)") print("🎨 Metal render started - Image: \(input.width)x\(input.height), Algo: \(params.algorithm)")
let textureLoader = MTKTextureLoader(device: device) let textureLoader = MTKTextureLoader(device: device)
// Load input texture guard let inputTexture = try? textureLoader.newTexture(cgImage: input, options: [.origin: MTKTextureLoader.Origin.topLeft]) else {
guard let inputTexture = try? textureLoader.newTexture(cgImage: input, options: [.origin: MTKTextureLoader.Origin.topLeft]) else { print("❌ Failed to create input texture")
print("❌ Failed to create input texture") continuation.resume(returning: nil)
continuation.resume(returning: nil) return
return
}
print("✅ Input texture created: \(inputTexture.width)x\(inputTexture.height)")
// 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 {
print("❌ Failed to create output texture")
continuation.resume(returning: nil)
return
}
// Encode command
guard let commandBuffer = commandQueue.makeCommandBuffer(),
let computeEncoder = commandBuffer.makeComputeCommandEncoder() else {
print("❌ Failed to create command buffer or encoder")
continuation.resume(returning: nil)
return
}
var params = params
if params.algorithm == 7, let pipe1 = pipelineStateFS_Pass1, let pipe2 = pipelineStateFS_Pass2 {
print("🔄 Using Floyd-Steinberg two-pass rendering")
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()
continuation.resume(returning: nil)
return
} }
// PASS 1: Even Rows print("✅ Input texture created: \(inputTexture.width)x\(inputTexture.height)")
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)
let h = (inputTexture.height + 1) / 2 let descriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .rgba8Unorm,
let threadsPerGrid = MTLSizeMake(1, h, 1) width: inputTexture.width,
let threadsPerThreadgroup = MTLSizeMake(1, min(h, pipe1.maxTotalThreadsPerThreadgroup), 1) height: inputTexture.height,
mipmapped: false)
descriptor.usage = [.shaderWrite, .shaderRead]
computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup) guard let outputTexture = device.makeTexture(descriptor: descriptor) else {
computeEncoder.memoryBarrier(scope: .textures) print("❌ Failed to create output texture")
continuation.resume(returning: nil)
return
}
// PASS 2: Odd Rows guard let commandBuffer = commandQueue.makeCommandBuffer(),
computeEncoder.setComputePipelineState(pipe2) let computeEncoder = commandBuffer.makeComputeCommandEncoder() else {
computeEncoder.setTexture(inputTexture, index: 0) print("❌ Failed to create command buffer or encoder")
computeEncoder.setTexture(outputTexture, index: 1) continuation.resume(returning: nil)
computeEncoder.setTexture(errorTexture, index: 2) return
computeEncoder.setBytes(&params, length: MemoryLayout<RenderParameters>.stride, index: 0) }
computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup) var params = params
} else { if params.algorithm == 7, let pipe1 = pipelineStateFS_Pass1, let pipe2 = pipelineStateFS_Pass2 {
print("🔄 Using standard dithering algorithm") print("🔄 Using Floyd-Steinberg two-pass rendering")
computeEncoder.setComputePipelineState(pipelineState) let errorDesc = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .rgba16Float,
computeEncoder.setTexture(inputTexture, index: 0) width: inputTexture.width,
computeEncoder.setTexture(outputTexture, index: 1) height: inputTexture.height,
computeEncoder.setBytes(&params, length: MemoryLayout<RenderParameters>.stride, index: 0) mipmapped: false)
errorDesc.usage = [.shaderWrite, .shaderRead]
let w = pipelineState.threadExecutionWidth guard let errorTexture = device.makeTexture(descriptor: errorDesc) else {
let h = pipelineState.maxTotalThreadsPerThreadgroup / w computeEncoder.endEncoding()
let threadsPerThreadgroup = MTLSizeMake(w, h, 1) continuation.resume(returning: nil)
let threadsPerGrid = MTLSizeMake(inputTexture.width, inputTexture.height, 1) return
}
computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup) // PASS 1
} 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)
computeEncoder.endEncoding() let h = (inputTexture.height + 1) / 2
let threadsPerGrid = MTLSizeMake(1, h, 1)
let threadsPerThreadgroup = MTLSizeMake(1, min(h, pipe1.maxTotalThreadsPerThreadgroup), 1)
// CRITICAL FIX: Capture outputTexture dans les deux closures computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup)
commandBuffer.addCompletedHandler { [outputTexture] buffer in computeEncoder.memoryBarrier(scope: .textures)
// Metal completion s'exécute sur com.Metal.CompletionQueueDispatch
// Dispatch vers MainActor car self et createCGImage() sont @MainActor // PASS 2
Task { @MainActor [outputTexture] in 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")
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()
// Pas de Task, pas de @MainActor, juste le completion handler direct
commandBuffer.addCompletedHandler { [outputTexture] buffer in
if let error = buffer.error { if let error = buffer.error {
print("❌ Metal command buffer error: \(error)") print("❌ Metal command buffer error: \(error)")
continuation.resume(returning: nil) continuation.resume(returning: nil)
@ -174,7 +169,6 @@ final class MetalImageRenderer {
print("✅ Metal render completed successfully") print("✅ Metal render completed successfully")
// Maintenant on est sur MainActor ET outputTexture est capturée
let result = self.createCGImage(from: outputTexture) let result = self.createCGImage(from: outputTexture)
if result == nil { if result == nil {
print("❌ Failed to create CGImage from output texture") print("❌ Failed to create CGImage from output texture")
@ -182,12 +176,11 @@ final class MetalImageRenderer {
continuation.resume(returning: result) continuation.resume(returning: result)
} }
}
commandBuffer.commit() commandBuffer.commit()
}
} }
} }
}
private func createCGImage(from texture: MTLTexture) -> CGImage? { private func createCGImage(from texture: MTLTexture) -> CGImage? {
let width = texture.width let width = texture.width
@ -195,7 +188,6 @@ final class MetalImageRenderer {
let rowBytes = width * 4 let rowBytes = width * 4
let length = rowBytes * height let length = rowBytes * height
// CRITICAL: Create data buffer that will be copied, not retained
var bytes = [UInt8](repeating: 0, count: length) var bytes = [UInt8](repeating: 0, count: length)
let region = MTLRegionMake2D(0, 0, width, height) let region = MTLRegionMake2D(0, 0, width, height)
@ -204,7 +196,6 @@ final class MetalImageRenderer {
let colorSpace = CGColorSpaceCreateDeviceRGB() let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue) let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
// Create data with .copy behavior to avoid retaining original buffer
guard let data = CFDataCreate(nil, bytes, length) else { return nil } guard let data = CFDataCreate(nil, bytes, length) else { return nil }
guard let provider = CGDataProvider(data: data) else { return nil } guard let provider = CGDataProvider(data: data) else { return nil }

View file

@ -181,25 +181,28 @@ class DitherViewModel {
print("🔄 Processing image with algorithm: \(self.selectedAlgorithm.name)") print("🔄 Processing image with algorithm: \(self.selectedAlgorithm.name)")
// Wrap CGImage in a Sendable wrapper to satisfy strict concurrency // Wrap CGImage in a Sendable wrapper to satisfy strict concurrency
let inputWrapper = SendableCGImage(image: input) let sendableInput = SendableCGImage(image: input)
self.renderTask = Task { @MainActor [renderer, params, inputWrapper] in // CHANGÉ : Enlève @MainActor de la Task
self.renderTask = Task { [sendableInput, renderer, params] in
if Task.isCancelled { if Task.isCancelled {
print("⚠️ Render task cancelled before starting") print("⚠️ Render task cancelled before starting")
return return
} }
// Call async render method // Le rendu s'exécute sur un thread d'arrière-plan (performant)
let result = await renderer.render(input: inputWrapper.image, params: params) let result = await renderer.render(input: sendableInput.image, params: params)
if Task.isCancelled { if Task.isCancelled {
print("⚠️ Render task cancelled after render") print("⚠️ Render task cancelled after render")
return return
} }
if Task.isCancelled { return } // Dispatch vers MainActor UNIQUEMENT pour la mise à jour UI
print("✅ Render complete, updating UI") await MainActor.run {
self.processedImage = result print("✅ Render complete, updating UI")
self.processedImage = result
}
} }
} }
} }