SceneKit Performance Issues with Large Node Counts on iPad (10th Gen, iPadOS 18.3)

We’re developing an iPad application that visualizes 2D and 3D building floor plans, including a mesh network of nodes that control lighting and climate. The node count ranges from 1,000 to 15,000.

We’re using SceneKit to dynamically render the floor plan and node mesh on an iPad 10th generation running iPadOS 18.3. While the core visualization works, we are experiencing significant performance degradation as the node count increases.

Specifically:

  • At 750–1,000 nodes, UI responsiveness noticeably declines.
  • At 2,000 nodes, navigating the floor plan becomes nearly unusable.

We attempted to optimize performance with a Geometric Pool algorithm, but the impact was minimal. Strangely, the same iPad handles 30,000+ 3D objects effortlessly when using Unity or Unreal Engine, raising the question of whether SceneKit may not be optimized for this scale.

Our questions:

  1. Is SceneKit suitable for visualizing such large node counts, or are we hitting an inherent limitation of the framework?
  2. Are there best practices or optimization techniques for SceneKit that we might be missing?
  3. Should we consider a hybrid approach or fully transition to a different 3D engine for this use case?

We’ve attached a code sample below demonstrating the issue. Any insights, suggestions, or experiences would be greatly appreciated!

import SwiftUI
import SceneKit

// MARK: - Custom Colors
extension UIColor {
    static let placeholderBackground = UIColor(red: 0.2, green: 0.5, blue: 0.8, alpha: 1.0)
    static let placeholderDisabledBackground = UIColor.gray
    static let placeholderFilteredOutBackground = UIColor.lightGray
    static let placeholderIcon = UIColor.white
    static let placeholderDisabledIcon = UIColor.white
}

// MARK: - Main View
struct ContentView: View {
    @State private var gridSize: Double = 5
    @State private var scene: SCNScene?
    
    private var optimizedGridManager: OptimizedGridManager = OptimizedGridManager()
    
    var body: some View {
        ZStack {
            SceneView(
                scene: scene ?? createScene(),
                pointOfView: createCamera(),
                options: [.allowsCameraControl, .autoenablesDefaultLighting]
            )
            .edgesIgnoringSafeArea(.all)
            .onChange(of: gridSize) { _, _ in
                // Debounce scene recreation
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                    scene = createScene()
                }
            }
            
            VStack {
                Spacer()
                
                // Grid size slider
                VStack {
                    Text("Grid Size: \(Int(gridSize)) x \(Int(gridSize))")
                        .foregroundColor(.white)
                        .padding(.bottom, 5)
                    
                    Slider(value: $gridSize, in: 1...150, step: 1)
                        .padding(.horizontal)
                }
                .padding()
                .background(Color.black.opacity(0.7))
                .cornerRadius(10)
                .padding()
            }
        }
    }
    
    private func createScene() -> SCNScene {
        let scene = SCNScene()
        
        // Add ambient light
        let ambientLight = SCNNode()
        ambientLight.light = SCNLight()
        ambientLight.light?.type = .ambient
        ambientLight.light?.intensity = 100
        scene.rootNode.addChildNode(ambientLight)
        
        // Add directional light
        let directionalLight = SCNNode()
        directionalLight.light = SCNLight()
        directionalLight.light?.type = .directional
        directionalLight.light?.intensity = 1000
        directionalLight.position = SCNVector3(x: 0, y: 10, z: 10)
        directionalLight.eulerAngles = SCNVector3(x: -Float.pi/4, y: 0, z: 0)
        scene.rootNode.addChildNode(directionalLight)
        
        // Create grid of nodes
        optimizedGridManager.createGrid(size: Int(gridSize), in: scene)
        
        return scene
    }
    
    private func createCamera() -> SCNNode {
        let cameraNode = SCNNode()
        cameraNode.camera = SCNCamera()
        cameraNode.position = SCNVector3(x: 0, y: 5, z: 10)
        cameraNode.eulerAngles = SCNVector3(x: -Float.pi/6, y: 0, z: 0)
        return cameraNode
    }
}

class OptimizedGridManager {
    private let geometryPool: GeometryPool
    private var gridNode: SCNNode?
    
    init() {
        // Create shared geometry instances
        self.geometryPool = GeometryPool()
    }
    
    private class GeometryPool {
        let cylinderGeometry: SCNCylinder
        let planeGeometry: SCNPlane
        
        init() {
            // Create shared geometries
            cylinderGeometry = SCNCylinder(radius: 0.125, height: 0.02)
            cylinderGeometry.materials.first?.diffuse.contents = UIColor.placeholderBackground
            
            planeGeometry = SCNPlane(width: 0.18, height: 0.18)
            planeGeometry.firstMaterial?.diffuse.contents = UIImage(systemName: "star.fill")
            planeGeometry.firstMaterial?.lightingModel = .constant
        }
    }
    
    func createGrid(size: Int, in scene: SCNScene) {
        // Remove existing grid
        gridNode?.removeFromParentNode()
        
        // Create a single parent node for the entire grid
        let newGridNode = SCNNode()
        
        // Create geometry instances once
        let cylinderGeometry = geometryPool.cylinderGeometry
        let planeGeometry = geometryPool.planeGeometry
        
        // Use SCNTransaction to batch updates
        SCNTransaction.begin()
        SCNTransaction.animationDuration = 0
        
        let spacing = 1.0
        let offset = Double(size-1) * spacing/2
        
        // Pre-calculate transforms for better performance
        var transforms: [SCNMatrix4] = []
        transforms.reserveCapacity(size * size)
        
        for row in 0..
SceneKit Performance Issues with Large Node Counts on iPad (10th Gen, iPadOS 18.3)
 
 
Q