Lab 042 – Second pass at an Entity Spawner

Using Components and Systems to create an entity spawner system.

This lab builds on the work from Lab 016. I decoupled the logic a bit. In the first version, I made the mistake of attaching the component to the entity that I wanted to clone. In this revised version, I attach the component to a transform entity, then pass in a the name of the entity we want to clone and spawn. I still have a lot of clean up to do. I also want to add some position/formation logic instead of always using random positions.

Video demo of entities spawning at pseudo-random positions within a fixed volume.

Lab Code

This lab loads a scene from Reality Composer Pro. That scene has the entity with the component and the target to clone. We can tap an entity to disable it, causing it to go back into the pool for respawn.

struct Lab042: View {

    init() {
        EntitySpawnerComponent.registerComponent()
        EntitySpawnerSystem.registerSystem()
    }


    var body: some View {
        RealityView { content, attachments in

            if let scene = try? await Entity(named: "SpawnerLab", in: realityKitContentBundle) {
                content.add(scene)

                print("Scene added")


            }

        } update: { content, attachments in

        } attachments: {
            Attachment(id: "AttachmentContent") {
                Text("wow")
            }
        }
        .gesture(tap)
        .modifier(DragGestureImproved())
        .modifier(MagnifyGestureImproved())
        .modifier(RotateGesture3DImproved())


    }

    var tap: some Gesture {
        TapGesture()
            .targetedToAnyEntity()
            .onEnded { value in
                // Skip if this is the original (spawner) entity
                if value.entity.components[EntitySpawnerComponentLab016.self] != nil {
                    return
                }
                value.entity.isEnabled = false
            }
    }

}

Here is the current version of the component and system. I have a lot of work to do on this, but it’s a start. I’ll be using systems like this in Dark Spaces.

import Foundation
@preconcurrency import RealityKit

public struct EntitySpawnerComponent: Component, Codable {
    public enum SpawnShape: String, Codable {
        case domeUpper
        case domeLower
        case sphere
        case box
        case plane
        case circle
    }


    /// The number of entities to manage in the pool
    public var Copies: Int = 12
    /// The shape to spawn entities in
    public var SpawnShape: SpawnShape = .domeUpper
    /// Radius for spherical shapes (dome, sphere, circle)
    public var Radius: Float = 5.0

    /// Dimensions for box spawning (width, height, depth)
    public var BoxDimensions: SIMD3<Float> = [2.0, 2.0, 2.0]

    /// Dimensions for plane spawning (width, depth)
    public var PlaneDimensions: SIMD2<Float> = [2.0, 2.0]

    /// Track if we've already spawned copies
    public var HasSpawned: Bool = false

    /// Track active entities for pool management
    public var ActiveEntities: Int = 0

    /// Whether to continuously check for disabled entities to respawn
    public var EnableRespawning: Bool = true

    /// The name of the entity to clone
    public var TargetEntityName: String = ""

    public init() {

    }
}

public class EntitySpawnerSystem: System {
    // Define a query to return all entities with a EntitySpawnerComponent.
    private static let query = EntityQuery(where: .has(EntitySpawnerComponent.self))

    // init is required even when not used
    required public init(scene: Scene) {
        // Perform required initialization or setup.
    }

    private func positionForShape(_ shape: EntitySpawnerComponent.SpawnShape,
                                  component: EntitySpawnerComponent) -> SIMD3<Float> {
        switch shape {
        case .domeUpper:
            let distance = Float.random(in: 1...component.Radius)
            let theta = Float.random(in: 0...(2 * .pi))
            let phi = Float.random(in: 0...(Float.pi / 2))
            return SIMD3(
                distance * sin(phi) * cos(theta),
                distance * cos(phi),
                distance * sin(phi) * sin(theta)
            )

        case .domeLower:
            let distance = Float.random(in: 1...component.Radius)
            let theta = Float.random(in: 0...(2 * .pi))
            let phi = Float.random(in: (Float.pi / 2)...Float.pi)
            return SIMD3(
                distance * sin(phi) * cos(theta),
                distance * cos(phi),
                distance * sin(phi) * sin(theta)
            )

        case .sphere:
            let distance = Float.random(in: 1...component.Radius)
            let theta = Float.random(in: 0...(2 * .pi))
            let phi = Float.random(in: 0...Float.pi)
            return SIMD3(
                distance * sin(phi) * cos(theta),
                distance * cos(phi),
                distance * sin(phi) * sin(theta)
            )

        case .box:
            let dims = component.BoxDimensions * 0.5 // Convert to +/- dimensions
            return SIMD3(
                Float.random(in: -dims.x...dims.x),
                Float.random(in: -dims.y...dims.y),
                Float.random(in: -dims.z...dims.z)
            )

        case .plane:
            let dims = component.PlaneDimensions * 0.5 // Convert to +/- dimensions
            return SIMD3(
                Float.random(in: -dims.x...dims.x),
                0,
                Float.random(in: -dims.y...dims.y)
            )

        case .circle:
            let angle = Float.random(in: 0...(2 * .pi))
            let randomRadius = Float.random(in: 0...component.Radius)
            return SIMD3(
                randomRadius * cos(angle),
                0,
                randomRadius * sin(angle)
            )
        }
    }

    @MainActor private func findTargetEntity(from entity: Entity, name: String) -> Entity? {
        // First find the root entity by traversing up
        var root = entity
        while let parent = root.parent {
            root = parent
        }
        
        return root.findEntity(named: name)
    }

    public func update(context: SceneUpdateContext) {
        for entity in context.entities(
            matching: Self.query,
            updatingSystemWhen: .rendering
        ) {
            guard var spawnerComponent = entity.components[EntitySpawnerComponent.self] else { continue }
            
            // Skip if we don't have a target name
            guard !spawnerComponent.TargetEntityName.isEmpty else { continue }
            
            // Find target entity using our spawner entity as the starting point
            guard let targetEntity = findTargetEntity(from: entity, name: spawnerComponent.TargetEntityName) else { continue }

            if !spawnerComponent.HasSpawned {
                // Initial spawn
                spawnInitialEntities(spawner: entity, target: targetEntity, component: &spawnerComponent)
            } else if spawnerComponent.EnableRespawning {
                // Check for disabled entities to respawn
                respawnDisabledEntities(spawner: entity, component: &spawnerComponent)
            }

            entity.components[EntitySpawnerComponent.self] = spawnerComponent
        }
    }

    @MainActor private func spawnInitialEntities(
        spawner: Entity,
        target: Entity,
        component: inout EntitySpawnerComponent
    ) {
        for _ in 1...component.Copies {
            spawnEntity(spawner: spawner, target: target, component: component)
        }
        component.HasSpawned = true
        component.ActiveEntities = component.Copies
    }

    @MainActor private func respawnDisabledEntities(
        spawner: Entity,
        component: inout EntitySpawnerComponent
    ) {
        for child in spawner.children {
            if !child.isEnabled {
                // Transform the local position to spawner's space
                let localOffset = positionForShape(component.SpawnShape, component: component)
                child.position = localOffset
                child.isEnabled = true
            }
        }
    }

    @MainActor private func spawnEntity(
        spawner: Entity,
        target: Entity,
        component: EntitySpawnerComponent
    ) {
        let clone = target.clone(recursive: true)
        
        // Transform the local position to spawner's space
        let localOffset = positionForShape(component.SpawnShape, component: component)
        clone.position = localOffset
        
        // Use spawner's orientation
        clone.orientation = .init()  // Reset to identity since we're in spawner's space
        
        spawner.addChild(clone)
    }
}

Support our work so we can continue to bring you new examples and articles.

Questions or feedback?