TextComponent Renders Inverted Text on Mac Catalyst

On Mac Catalyst, RealityKit TextComponent renders vertically inverted text.

The issue occurs with both ARView and RealityView. The same text renders correctly on iOS and in a native macOS RealityView application (not Catalyst). Tested on macOS 26 and macOS 27 beta, with Xcode 26 and Xcode 27 Beta.

Here is a screenshot of a text entity with a text component rendering the string "Text Component":

Workaround

On Mac Catalyst:

  • Detect when the model generated by TextComponent becomes available on the text entity.
  • Find the generated UnlitMaterial in the ModelComponent of the text entity.
  • Disable face culling on the material.
  • Invert the Y scale of the text entity.

Here is the result:

Reproduce

Below is the full code that reproduces the problem, with both ARView and RealityView, as well as the workaround implementation.

import SwiftUI
import RealityKit
import Combine

// MARK: Text

func createTextEntity() -> Entity {
    let textEntity = Entity()
    
    let attributes: [NSAttributedString.Key: Any] = [
        .font: MeshResource.Font.systemFont(ofSize: 48, weight: .bold),
        .foregroundColor: Material.Color.white
    ]
    
    let attributedText = AttributedString(
        NSAttributedString(
            string: "Text Component",
            attributes: attributes
        )
    )
    
    var textComponent = TextComponent()
    textComponent.text = attributedText
    textComponent.size = CGSize(width: 400, height: 200)
    textComponent.backgroundColor = Material.Color.darkGray.cgColor
    textEntity.components.set(textComponent)
    
    return textEntity
}

// MARK: Camera

func createCameraEntity() -> Entity {
    let cameraEntity = Entity()
    
    cameraEntity.components.set(PerspectiveCameraComponent())
    cameraEntity.look(at: .zero, from: [0, 0, 0.3], relativeTo: nil)
    
    return cameraEntity
}

// MARK: ARView

struct CatalystTextBugARView: UIViewRepresentable {
    let applyWorkaround: Bool = true
    
    func makeUIView(context: Context) -> ARView {
        let arView = ARView(frame: .zero)
        arView.cameraMode = .nonAR
        arView.automaticallyConfigureSession = false
        arView.environment.background = .color(.black)
        
        let rootEntity = AnchorEntity()
        let textEntity = createTextEntity()
        
#if targetEnvironment(macCatalyst)
        if applyWorkaround {
            /// React when the text entity's ModelComponent becomes available or changes
            context.coordinator.modelDidAddSubscription = arView.scene.subscribe(
                to: ComponentEvents.DidAdd.self,
                on: textEntity,
                componentType: ModelComponent.self
            ) { _ in
                applyTextBugWorkaround(to: textEntity)
            }
            
            context.coordinator.modelDidChangeSubscription = arView.scene.subscribe(
                to: ComponentEvents.DidChange.self,
                on: textEntity,
                componentType: ModelComponent.self
            ) { _ in
                applyTextBugWorkaround(to: textEntity)
            }
        }
#endif
        
        rootEntity.addChild(textEntity)
        rootEntity.addChild(createCameraEntity())
        arView.scene.addAnchor(rootEntity)
        
        return arView
    }
    
    func updateUIView(_ uiView: ARView, context: Context) {}
    
    func makeCoordinator() -> Coordinator {
        Coordinator()
    }
    
    class Coordinator {
        /// Retain the scene subscriptions for the lifetime of the ARView.
        var modelDidAddSubscription: (any Cancellable)?
        var modelDidChangeSubscription: (any Cancellable)?
    }
}

// MARK: RealityView

struct CatalystTextBugRealityView: View {
    let applyWorkaround: Bool = true
    
    /// Retain the RealityView subscriptions for the lifetime of the view.
    @State private var modelDidAddSubscription: EventSubscription?
    @State private var modelDidChangeSubscription: EventSubscription?
    
    var body: some View {
        RealityView { content in
            let textEntity = createTextEntity()
            
#if targetEnvironment(macCatalyst)
            if applyWorkaround {
                /// React when the text entity's ModelComponent becomes available or changes.
                modelDidAddSubscription = content.subscribe(
                    to: ComponentEvents.DidAdd.self,
                    on: textEntity,
                    componentType: ModelComponent.self
                ) { _ in
                    applyTextBugWorkaround(to: textEntity)
                }
                
                modelDidChangeSubscription = content.subscribe(
                    to: ComponentEvents.DidChange.self,
                    on: textEntity,
                    componentType: ModelComponent.self
                ) { _ in
                    applyTextBugWorkaround(to: textEntity)
                }
            }
#endif
            
            content.add(textEntity)
            content.add(createCameraEntity())
        }
        .background(.black)
    }
}

// MARK: Workaround

func applyTextBugWorkaround(to textEntity: Entity) {
#if targetEnvironment(macCatalyst)
    guard var modelComponent = textEntity.components[ModelComponent.self] else {
        return
    }
    
    var foundTextMaterial = false
    var changedMaterial = false
    
    for materialIndex in modelComponent.materials.indices {
        guard var textMaterial = modelComponent.materials[materialIndex] as? UnlitMaterial else {
            continue
        }
        
        foundTextMaterial = true
        
        /// If face culling is already disabled, skip
        if case .none = textMaterial.faceCulling {
            continue
        }
        
        textMaterial.faceCulling = .none
        modelComponent.materials[materialIndex] = textMaterial
        changedMaterial = true
    }
    
    guard foundTextMaterial else {
        return
    }
    
    if changedMaterial {
        textEntity.components.set(modelComponent)
    }
    
    /// Counteract vertically inverted text.
    textEntity.scale.y = -abs(textEntity.scale.y)
#endif
}

Usage

  • Run either CatalystTextBugRealityView() or CatalystTextBugARView() on a Mac Catalyst target.
  • Toggle applyWorkaround on either view to try with and without the workaround.

I filed report FB24126863 about this issue.

TextComponent Renders Inverted Text on Mac Catalyst
 
 
Q