Como Criar uma Forma 3D no RealityKit para o visionOS a partir de um Shape 2D do SwiftUI

Olá, pessoal! 👋 Hoje, quero compartilhar com vocês uma maneira super interessante de transformar um Shape (2D) criado com SwiftUI em um modelo 3D para o RealityKit no visionOS.

Nesse tutorial, iremos fazer uso de um recurso fantástico que a Apple introduziu à API do RealityKit no visionOS 2.0, que permite criar um MeshResource à partir de um Path: o MeshResource(extruding:extrusionOptions:).

Com ele você pode adicionar profundidade e criar um Mesh (malha) do objeto 3D resultante à partir de qualquer Path, incluindo textos em AttributedString.

Vamos ao nosso passo-a-passo:

Passo 1: Requisitos

Como esse recurso foi adicionado ao visionOS 2.0, você precisará:

  • Xcode 16 beta
  • visionOS 2.0

Passo 2: Criando um Shape 2D no SwiftUI

Para criar nosso shape 2D, vamos utilizar Circles do SwiftUI.

Para obter um formato de tubo, temos que criar um formato de círculo com um furo no meio, que será o interior do nosso tubo.

Com SwiftUI é muito fácil fazer isso:

let diameter = CGFloat(radius * 2.0)
let internalDiameter = diameter - CGFloat(thick * 2.0)
let innerShape = Circle().size(width: internalDiameter, height: internalDiameter)
let roundShape = Circle().size(width: diameter, height: diameter)

let hollowCircle = roundShape
    .symmetricDifference(innerShape.offset(x: CGFloat(thick), y: CGFloat(thick)))

Passo 3: Convertendo o Shape 2D em Mesh 3D

Para converter nosso shape 2D em um objeto 3D, vamos usar o `extruding` do MeshResource.

var options = MeshResource.ShapeExtrusionOptions()
options.extrusionMethod = .linear(depth: height)
            
let mesh = try await MeshResource(
    extruding: hollowCircle.path(
        in:CGRect(x: 0.0, y: 0.0, width: diameter, height: diameter)
    ),
    extrusionOptions: options
)

No código acima, nós fazemos duas coisas que são fundamentais para criar o objeto:

  • Criamos um ShapeExtrusionOptions para dizermos qual extrusionMethod nós queremos. Nesse caso, iremos usar o .linear, que receberá o valor de profundidade (depth). No exemplo, a profundidade do tubo pode ser considerada a sua altura, pois é a altura que teremos ao colocá-lo em pé.
  • Criamos um MeshResource, realizando o extruding à partir do path do nosso círculo oco (hollowCircle) e informando as opções que queremos para o procedimento de extruding.

Passo 4: Criando e configurando nosso ModelEntity

Agora que temos nosso shape convertido em uma MeshResource, podemos usá-lo para criar um ModelEntity e configurar seu corpo físico e sua estrutura de colisão.

let entity = ModelEntity(mesh: mesh, materials: [SimpleMaterial(color: .gray, isMetallic: false)])
            
let shape = try await ShapeResource.generateStaticMesh(from: mesh)

let colision = CollisionComponent(shapes: [shape], isStatic: false)

entity.components.set(colision)

entity.components.set(PhysicsBodyComponent(
    massProperties: .default,
    mode: .static
))

Passo 5 (Opcional): Deixando nosso tubo em pé

Se você quiser que seu tubo fique na vertical, e não na horizontal, você pode simplesmente movê-lo para deixá-lo na posição correta:

entity.move(to: simd_float4x4(simd_quatf(.init(angle: Angle2D(degrees: 90), axis: .x))), relativeTo: nil)

Nossa implementação final

Ao final, teremos uma implementação semelhante à essa:

import RealityKit
import SwiftUI

extension ModelEntity {
    @MainActor
    enum Tube {

        enum Position {
            case vertical
            case horizontal
        }
        
        static func generateTube(
            radius: Float,
            thick: Float,
            height: Float,
            position: Tube.Position = .horizontal
        ) async throws -> some Entity {
            let diameter = CGFloat(radius * 2.0)
            let internalDiameter = diameter - CGFloat(thick * 2.0)
            let innerShape = Circle().size(width: internalDiameter, height: internalDiameter)
            let roundShape = Circle().size(width: diameter, height: diameter)
            
            let hollowCircle = roundShape
                .symmetricDifference(innerShape.offset(x: CGFloat(thick), y: CGFloat(thick)))
            
            var options = MeshResource.ShapeExtrusionOptions()
            options.extrusionMethod = .linear(depth: height)
            
            let mesh = try await MeshResource(
                extruding: hollowCircle.path(
                    in:CGRect(x: 0.0, y: 0.0, width: diameter, height: diameter)
                ),
                extrusionOptions: options
            )
            
            let entity = ModelEntity(mesh: mesh, materials: [SimpleMaterial(color: .gray, isMetallic: false)])
            
            let shape = try await ShapeResource.generateStaticMesh(from: mesh)
            
            let collision = CollisionComponent(shapes: [shape], isStatic: false)
            
            entity.components.set(collision)
            
            entity.components.set(PhysicsBodyComponent(
              massProperties: .default,
              mode: .static
            ))
            
            if position == .vertical {
                entity.move(to: simd_float4x4(simd_quatf(.init(angle: Angle2D(degrees: 90), axis: .x))), relativeTo: nil)
            }
            
            return entity
        }
    }
}

Quer testar aí?

Todo o código e a aplicação dele em um projeto demo está disponível no meu GitHub: https://github.com/salla-andre/TubeExample

E é isso, pessoal! 🎉

Espero que tenham gostado desse pequeno tutorial e que ele seja útil no seu próximo projeto no visionOS. Experimente com diferentes shapes e materiais para criar suas próprias experiências imersivas. Até a próxima!