SwiftUI光线追踪器渲染时UI无法逐像素更新问题排查
问题描述
基于Peter Shirley的教程,我用Swift编写了一款小型光线追踪器,它能正常运行并输出正确的PPM文件。现在我想把它嵌入UI,实现渲染过程中逐像素更新实时刷新画面的效果,因此搭建了一个采用基础MVVM架构的MacOS应用。
目前渲染功能可正常运行,图像能正常显示,但并未实现预期的逐像素更新效果,而是一次性显示完整图像。
Model代码(RGBViewModel.swift)
// // RGBViewModel.swift // rtweekend_gui // // import SwiftUI // RGB structure to hold color values struct RGB { var r: UInt8 var g: UInt8 var b: UInt8 } // ViewModel to handle the RGB array and updates class RGBViewModel: ObservableObject { // Define the dimensions of your 2D array let width = 1200 let height = 675 // Published property to trigger UI updates @Published var rgbArray: [[RGB]] init() { // Initialize with black pixels rgbArray = Array(repeating: Array(repeating: RGB(r: 0, g: 0, b: 0), count: width), count: height) } func render_scene() { for j in 0..<height { for i in 0..<width { // Generate a random color let r = UInt8.random(in: 0...255) let g = UInt8.random(in: 0...255) let b = UInt8.random(in: 0...255) // Update on the main thread since this affects the UI DispatchQueue.main.async { // Update the array self.rgbArray[j][i] = RGB(r: r, g: g, b: b) } } } } }
View代码(RGBArrayView.swift)
// // RGBArrayView.swift // rtweekend_gui // // import SwiftUI struct RGBArrayView: View { // The 2D array of RGB values @StateObject private var viewModel = RGBViewModel() // Control the size of each pixel private let pixelSize: CGFloat = 1 var body: some View { VStack { // Display the RGB array Canvas { context, size in for y in 0..<viewModel.rgbArray.count { for x in 0..<viewModel.rgbArray[y].count { let rgb = viewModel.rgbArray[y][x] let rect = CGRect( x: CGFloat(x) * pixelSize, y: CGFloat(y) * pixelSize, width: pixelSize, height: pixelSize ) context.fill( Path(rect), with: .color(Color( red: Double(rgb.r) / 255.0, green: Double(rgb.g) / 255.0, blue: Double(rgb.b) / 255.0 )) ) } } } .border(Color.gray) // Button to start filling the array Button("Render") { viewModel.render_scene() } .padding() } .padding() .frame(width: CGFloat(viewModel.width) * pixelSize + 40, height: CGFloat(viewModel.height) * pixelSize + 80) } } // Preview for SwiftUI struct RGBArrayView_Previews: PreviewProvider { static var previews: some View { RGBArrayView() } }
解决方案
问题根源
- 主线程阻塞与任务批量处理:按钮点击触发
render_scene时,该方法运行在主线程。循环中所有DispatchQueue.main.async任务会被一次性添加到主队列,等到整个循环执行完毕后,主队列才会批量处理这些UI更新。SwiftUI会合并这些频繁的@Published属性更新,只执行一次Canvas重绘,因此看起来是一次性显示完整图像。 - 缺乏UI刷新间隙:即使渲染任务在后台,若没有给UI线程留出处理更新的时间,SwiftUI仍会合并多次像素更新,无法逐帧显示变化。
修改步骤
1. 将渲染任务移至后台线程,并逐像素触发UI更新
修改RGBViewModel的render_scene方法,使用Task(Swift 5.5+)在后台执行渲染逻辑,每次更新像素后切换到主线程,并给UI留出刷新间隙:
func render_scene() { Task { for j in 0..<height { for i in 0..<width { // 生成颜色(实际光线追踪逻辑替换此处) let r = UInt8.random(in: 0...255) let g = UInt8.random(in: 0...255) let b = UInt8.random(in: 0...255) // 切换到主线程更新UI await MainActor.run { self.rgbArray[j][i] = RGB(r: r, g: g, b: b) } // 给UI线程留出刷新时间,可根据需求调整延迟时长 try? await Task.sleep(nanoseconds: 1) } } } }
2. 优化Canvas绘制效率(可选)
当前Canvas每次重绘都会遍历所有像素,当分辨率较高时会影响性能。可以改用CGImage直接绘制像素数据,只更新变化区域,但如果是演示级需求,原Canvas逻辑也能工作。
关键说明
- 使用
Task和MainActor.run确保渲染逻辑不阻塞主线程,同时安全更新UI属性。 Task.sleep的作用是让UI线程有机会处理当前的更新请求,避免SwiftUI合并多次更新。如果光线追踪本身计算耗时较长,可能不需要额外的sleep,因为计算过程已经给UI留出了间隙。
内容的提问来源于stack exchange,提问作者easythrees
相关产品推荐
相关产品推荐

