Fix: Remove @MainActor isolation for Metal renderer
Some checks failed
Auto Tag on Push / auto-tag (push) Has been cancelled
Some checks failed
Auto Tag on Push / auto-tag (push) Has been cancelled
This commit is contained in:
parent
b2ffd9edaf
commit
1a5aa1fdef
|
|
@ -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)
|
|
||||||
|
|
||||||
// Load input texture
|
|
||||||
guard let inputTexture = try? textureLoader.newTexture(cgImage: input, options: [.origin: MTKTextureLoader.Origin.topLeft]) else {
|
|
||||||
print("❌ Failed to create input texture")
|
|
||||||
continuation.resume(returning: nil)
|
|
||||||
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,
|
let textureLoader = MTKTextureLoader(device: device)
|
||||||
width: inputTexture.width,
|
|
||||||
height: inputTexture.height,
|
|
||||||
mipmapped: false)
|
|
||||||
errorDesc.usage = [.shaderWrite, .shaderRead]
|
|
||||||
|
|
||||||
guard let errorTexture = device.makeTexture(descriptor: errorDesc) else {
|
guard let inputTexture = try? textureLoader.newTexture(cgImage: input, options: [.origin: MTKTextureLoader.Origin.topLeft]) else {
|
||||||
computeEncoder.endEncoding()
|
print("❌ Failed to create input texture")
|
||||||
continuation.resume(returning: nil)
|
continuation.resume(returning: nil)
|
||||||
return
|
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(¶ms, 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(¶ms, 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(¶ms, length: MemoryLayout<RenderParameters>.stride, index: 0)
|
mipmapped: false)
|
||||||
|
errorDesc.usage = [.shaderWrite, .shaderRead]
|
||||||
let w = pipelineState.threadExecutionWidth
|
|
||||||
let h = pipelineState.maxTotalThreadsPerThreadgroup / w
|
guard let errorTexture = device.makeTexture(descriptor: errorDesc) else {
|
||||||
let threadsPerThreadgroup = MTLSizeMake(w, h, 1)
|
computeEncoder.endEncoding()
|
||||||
let threadsPerGrid = MTLSizeMake(inputTexture.width, inputTexture.height, 1)
|
continuation.resume(returning: nil)
|
||||||
|
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(¶ms, length: MemoryLayout<RenderParameters>.stride, index: 0)
|
||||||
|
|
||||||
|
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)
|
||||||
|
computeEncoder.memoryBarrier(scope: .textures)
|
||||||
|
|
||||||
|
// PASS 2
|
||||||
|
computeEncoder.setComputePipelineState(pipe2)
|
||||||
|
computeEncoder.setTexture(inputTexture, index: 0)
|
||||||
|
computeEncoder.setTexture(outputTexture, index: 1)
|
||||||
|
computeEncoder.setTexture(errorTexture, index: 2)
|
||||||
|
computeEncoder.setBytes(¶ms, 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(¶ms, 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()
|
computeEncoder.endEncoding()
|
||||||
|
|
||||||
// ✅ CRITICAL FIX: Capture outputTexture dans les deux closures
|
// ✅ Pas de Task, pas de @MainActor, juste le completion handler direct
|
||||||
commandBuffer.addCompletedHandler { [outputTexture] buffer in
|
commandBuffer.addCompletedHandler { [outputTexture] buffer in
|
||||||
// Metal completion s'exécute sur com.Metal.CompletionQueueDispatch
|
|
||||||
// Dispatch vers MainActor car self et createCGImage() sont @MainActor
|
|
||||||
Task { @MainActor [outputTexture] 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 }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
if Task.isCancelled {
|
self.renderTask = Task { [sendableInput, renderer, params] in
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue