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.