Mapping 3D coordinates to the screen using Entity's convert(position:from:) and ARView's project(_:) methods.

I'm still trying to understand how to correctly convert 3D coordinates to 2D screen coordinates using convert(position:from:) and project(_:)

Below is the example ContentView.swift from the default Augmented Reality App project, with a few important modifications. Two buttons have been added, one that toggles visibility of red circular markers on the screen, and a second button that adds blue spheres to the scene. Additionally a timer has been added to trigger regular screen updates.

When run, the markers should line up with the spheres on screen and follow them on screen, as the camera is moved around. However, the red circles are all very far from their corresponding spheres on screen.

What am I doing wrong in my conversion that is causing the circles to not line up with the spheres?

//  ContentView.swift
import SwiftUI
import RealityKit

class Coordinator {
    var arView: ARView?
    var anchor: AnchorEntity?
    var objects: [Entity] = []
}

struct ContentView : View {
    let timer = Timer.publish(every: 1.0/30.0, on: .main, in: .common).autoconnect()
    var coord = Coordinator()
    @State var showMarkers = false
    @State var circleColor: Color = .red

    var body: some View {
        ZStack {
            ARViewContainer(coordinator: coord).edgesIgnoringSafeArea(.all)

            if showMarkers {
                // Add circles to the screen
                ForEach(coord.objects) { obj in
                    Circle()
                        .offset(projectedPosition(of: obj))
                        .frame(width: 10.0, height: 10.0)
                        .foregroundColor(circleColor)
                }
            }

            VStack {
                Button(action: { showMarkers = !showMarkers },
                       label: { Text(showMarkers ? "Hide Markers" : "Show Markers") })

                Spacer()

                Button(action: { addSphere() },
                       label: { Text("Add Sphere") })
            }
        }.onReceive(timer, perform: { _ in
            // silly hack to force circles to redraw
            if circleColor == .red {
                circleColor = Color(#colorLiteral(red: 1, green: 0, blue: 0, alpha: 1))
            } else {
                circleColor = .red
            }
        })
    }

    func addSphere() {
        guard let anchor = coord.anchor else { return }

        // pick random point for new sphere
        let pos = SIMD3<Float>.random(in: 0...0.5)
        print("Adding sphere at \(pos)")

        // Create a sphere
        let mesh = MeshResource.generateSphere(radius: 0.01)
        let material = SimpleMaterial(color: .blue, roughness: 0.15, isMetallic: true)
        let model = ModelEntity(mesh: mesh, materials: [material])
        model.setPosition(pos, relativeTo: anchor)
        anchor.addChild(model)

        // record sphere for later use
        coord.objects.append(model)
    }

    func projectedPosition(of object: Entity) -> CGPoint {
        // convert position of object into "world space"
        // (i.e., "the 3D world coordinate system of the scene")
        // https://developer.apple.com/documentation/realitykit/entity/convert(position:to:)
        let worldCoordinate = object.convert(position: object.position, to: nil)

        // project worldCoordinate into "the 2D pixel coordinate system of the view"
        // https://developer.apple.com/documentation/realitykit/arview/project(_:)
        guard let arView = coord.arView else { return CGPoint(x: -1, y: -1) }
        guard let screenPos = arView.project(worldCoordinate) else { return CGPoint(x: -1, y: -1) }

        // At this point, screenPos should be the screen coordinate of the object's positions on the screen.

        print("3D position \(object.position) mapped to \(screenPos) on screen.")

        return screenPos
    }

}

struct ARViewContainer: UIViewRepresentable {
    var coordinator: Coordinator
    
    func makeUIView(context: Context) -> ARView {
        
        let arView = ARView(frame: .zero)
        
        // Create a sphere model
        let mesh = MeshResource.generateSphere(radius: 0.01)
        let material = SimpleMaterial(color: .gray, roughness: 0.15, isMetallic: true)
        let model = ModelEntity(mesh: mesh, materials: [material])
        
        // Create horizontal plane anchor for the content
        let anchor = AnchorEntity(.plane(.horizontal, classification: .any, minimumBounds: SIMD2<Float>(0.2, 0.2)))
        anchor.children.append(model)

        // Record values needed elsewhere
        coordinator.arView = arView
        coordinator.anchor = anchor
        coordinator.objects.append(model)

        // Add the horizontal plane anchor to the scene
        arView.scene.anchors.append(anchor)

        return arView
        
    }

    func updateUIView(_ uiView: ARView, context: Context) {}
    
}

#Preview {
    ContentView()
}
Answered by fraggle in 772413022

As previously mentioned, SwiftUI Circles use the center of the screen as their origin, where project(_:) uses the upper-left corner of the screen as the origin. This offset is confusing, but unrelated to understanding how to use convert(position:from:), convert(position:to:), and project(_:).

While it has previously been noted that to get a world space coordinate for use with project(_:), the referenceEntity passed to convert(position:to:) needs to be nil. Looking closer at the description of convert(position:to:), it also says that the point is converted, "from the local space of the entity on which you called this method". Since the entity on which convert(position:to:) is being called on is the sphere object itself, the sphere's position will be the origin of that local space.

There are two ways to fix this. The first is to continue to call convert(position:to:) on object but pass the coordinate [0,0,0] instead of object.position, since an object's position is the origin of its local coordinate space. The other way is to convert object.position using the object's parent's convert(position:to:) method.

In both cases the line that need to be changed is:

    let worldCoordinate = object.convert(position: object.position, to: nil)            // Incorrect

One way to fix it:

    let worldCoordinate = object.convert(position: [0,0,0], to: nil)                    // Fix 1

An alternate fix:

    let worldCoordinate = object.parent?.convert(position: object.position, to: nil)    // Fix 2

With either of these modifications and the previously mentioned framingOffset fix, the red marker circles do correctly follow all of the blue spheres.

I am still left with my original question though; Is there better documentation somewhere on how to do this conversion?

While playing around with just a single sphere and its marker circle, it seemed to be the case that when the sphere was in the upper-left corner (i.e., near coordinate 0,0, according to the printed output), the marker circle was near the center of the screen. Similarly, when the sphere was near the center of the screen, the marker circle was in the lower-right corner of the screen.

After consulting some sample code, to see if there might be any functions that are used to adjust for this unexpected offset, nothing seemed obvious.

Then I noticed that SwiftUI's Circle is described as, "centered on the frame of the view containing it". So, when it is at offset 0,0, it will be centered, not in the upper-left corner.

To test this, I added a framingOffset(_:) function to my ContentView struct:

    func framingOffset(_ point: CGPoint?) -> CGPoint {
        guard var ret = point else { return CGPoint(x: -1, y: -1) }
        guard let arView = coord.arView else { return CGPoint(x: -1, y: -1) }

        // Adjust for Circle being "centered on the frame of the view containing it".
        // https://developer.apple.com/documentation/swiftui/shape/circle
        ret.x -= arView.frame.width / 2.0
        ret.y -= arView.frame.height / 2.0

        return ret
    }

Then changed the circle creation from:

    Circle().offset(projectedPosition(of: obj))

To:

    Circle().offset(framingOffset(projectedPosition(of: obj)))

With these changes, the marker circle for the the gray sphere placed at the anchor correctly follows its sphere, but when additional spheres are added their marker circles are still a bit off.

So, what else am I missing?

Accepted Answer

As previously mentioned, SwiftUI Circles use the center of the screen as their origin, where project(_:) uses the upper-left corner of the screen as the origin. This offset is confusing, but unrelated to understanding how to use convert(position:from:), convert(position:to:), and project(_:).

While it has previously been noted that to get a world space coordinate for use with project(_:), the referenceEntity passed to convert(position:to:) needs to be nil. Looking closer at the description of convert(position:to:), it also says that the point is converted, "from the local space of the entity on which you called this method". Since the entity on which convert(position:to:) is being called on is the sphere object itself, the sphere's position will be the origin of that local space.

There are two ways to fix this. The first is to continue to call convert(position:to:) on object but pass the coordinate [0,0,0] instead of object.position, since an object's position is the origin of its local coordinate space. The other way is to convert object.position using the object's parent's convert(position:to:) method.

In both cases the line that need to be changed is:

    let worldCoordinate = object.convert(position: object.position, to: nil)            // Incorrect

One way to fix it:

    let worldCoordinate = object.convert(position: [0,0,0], to: nil)                    // Fix 1

An alternate fix:

    let worldCoordinate = object.parent?.convert(position: object.position, to: nil)    // Fix 2

With either of these modifications and the previously mentioned framingOffset fix, the red marker circles do correctly follow all of the blue spheres.

I am still left with my original question though; Is there better documentation somewhere on how to do this conversion?

Mapping 3D coordinates to the screen using Entity's convert(position:from:) and ARView's project(_:) methods.
 
 
Q