Swift add custom geometry (view) to scnnode in SceneKit

95 Views Asked by At

I have an interactive globe built with SceneKit. The globe is a SCNNode and the dots that represent land are also SCNNodes and are children of the globe. I want to add a custom view as the geometry for one of the children of the globe.

I know how to add a node with a spherical geometry and change its color and so on. I'm not sure how to use a custom view (Swift UI) for the geometry of a node.

 let lowerCircle = SCNSphere(radius: dotRadius * 5)
 lowerCircle.firstMaterial?.diffuse.contents = GenericColor(cgColor: UIColor.white.cgColor)
 lowerCircle.firstMaterial?.lightingModel = SCNMaterial.LightingModel.constant

 let dotNode = SCNNode(geometry: lowerCircle)
                    
 dotNode.name = "NewYorkDot"
 dotNode.position = textureMap[i].position
 positions.append(dotNode.position)
 dotNodes.append(dotNode)
1

There are 1 best solutions below

5
VonC On BEST ANSWER

To use a custom SwiftUI view as the geometry for a SCNNode in SceneKit, you can start with rendering your SwiftUI view into a UIImage. This can be done by creating a snapshot of the view.

Once you have the UIImage, you can apply it as a texture to the SCNMaterial of your SCNNode.
See for instance "Updating SCNMaterial texture in SceneKit" from Benoit Layer.

Finally, create a geometry (e.g., SCNPlane) for your SCNNode, and apply the material with the custom texture to this geometry.

The process would be:

[ SwiftUI View ]
    │
    ├─ Render to UIImage
    │
[ UIImage ]
    │
    ├─ Apply as Texture to Material
    │
[ SCNMaterial ]
    │
    ├─ Attach to SCNPlane or other geometry
    │
[ SCNNode ]

Your SwiftUI View to UIImage would be:

import SwiftUI

extension View {
    func snapshot() -> UIImage {
        let controller = UIHostingController(rootView: self)
        let view = controller.view

        let targetSize = controller.view.intrinsicContentSize
        view?.bounds = CGRect(x: 0, y: 0, width: targetSize.width, height: targetSize.height)
        view?.backgroundColor = .clear

        let renderer = UIGraphicsImageRenderer(size: targetSize)
        return renderer.image { _ in
            view?.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true)
        }
    }
}

Then apply the custom geometry:

import SceneKit
import SwiftUI

// Example SwiftUI View
struct CustomView: View {
    var body: some View {
        Text("Hello World")
            .frame(width: 100, height: 100)
            .background(Color.red)
    }
}

// Convert SwiftUI view to UIImage
let image = CustomView().snapshot()

// Create material with this image
let material = SCNMaterial()
material.diffuse.contents = image

// Create a geometry, e.g., SCNPlane
let plane = SCNPlane(width: 1.0, height: 1.0)
plane.materials = [material]

// Create a SCNNode with this geometry
let customNode = SCNNode(geometry: plane)

// Position and add the custom node as needed
customNode.position = SCNVector3(x: 0, y: 0, z: 0)
dotNode.addChildNode(customNode)

But: SwiftUI views are rendered into a static image, so they won't be interactive in the SceneKit context.


The warning === AttributeGraph: cycle detected through attribute 1517200 === suggests there is a cyclic dependency or an issue related to the view's layout in SwiftUI. That can happen when the view's state changes in a way that causes SwiftUI to continually recalculate the view's layout, leading to an infinite loop.

So try and simplify the SwiftUI view: temporarily replace your SwiftUI view with a very simple view (like a single Text element) to confirm if the complexity of your view is causing the issue.

And make sure you are not using any view modifiers that might cause cyclic dependencies, such as those that depend on the view's own size or layout. Set an explicit frame size for your SwiftUI view before rendering it to a UIImage. That can help prevent issues related to size calculations that might lead to a cycle.

extension View {
    func snapshot() -> UIImage {
        let controller = UIHostingController(rootView: self.frame(width: 100, height: 100))
        let view = controller.view

        let targetSize = CGSize(width: 100, height: 100)
        view?.bounds = CGRect(origin: .zero, size: targetSize)
        view?.backgroundColor = .clear

        let renderer = UIGraphicsImageRenderer(size: targetSize)
        return renderer.image { _ in
            view?.drawHierarchy(in: CGRect(origin: .zero, size: targetSize), afterScreenUpdates: true)
        }
    }
}

Make sure all UI-related tasks, including rendering the SwiftUI view to a UIImage, are performed on the main thread.

DispatchQueue.main.async {
    let image = CustomView().snapshot()

    // Create material with this image
    let material = SCNMaterial()
    material.diffuse.contents = image

    // Rest of the code to create and add SCNNode
    // 
}