red character 'A' without the horizontal bar Achraf Kassioui

RealityKit TextComponent Bug

Blog / Apple Dev,

RealityKit TextComponent renders inverted text on Mac Catalyst. The issue is reported as FB24126863. Below is a workaround.

On Mac Catalyst, RealityKit’s TextComponent renders its text vertically inverted. The issue reproduces 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":

RealityKit-TextComponent-Catalyst-InvertedText.png

Workaround

On Mac Catalyst:

RealityKit-TextComponent-Catalyst-Workaround.png

Code

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
}

Feedback Report

I filed report FB24126863 about this issue, on 3 Aug. 2026.