Cleaning up RealityKit collision events with groups and filters

At one point in development of my game I started playing around with changing the spawn point of projectiles so they looked like they were being fired from the player rather than magically appearing somewhere in front of the player. That’s when I ran into an issue. Depending on where the projectile spawned relative to the player's collision shape, it would register a hit on the way out causing the player to take damage and the projectile to despawn immediately. I could have guarded against this in the collision handler, but that felt brittle and like it could lead to performance issues down the road. The physics engine is already doing spatial queries to find potential contacts. Making it generate events for pairs you're going to throw away is wasteful, and one guard turns into many once you realize projectiles can also hit loot, other projectiles, and anything else with a collision shape.

RealityKit has a built-in mechanism for this: collision groups and filters. They let you tell the physics engine which entities are even eligible to collide with each other, so the bad pairs never generate events in the first place.

Groups are bitmasks

A collision group is just a bitmask. You define one for each category of entity in your game. I defined mine as an extension on CollisionGroup so call sites can use dot-shorthand:

extension CollisionGroup {
    static let projectile = CollisionGroup(rawValue: 1 << 1)
    static let enemy = CollisionGroup(rawValue: 1 << 2)
    static let loot = CollisionGroup(rawValue: 1 << 3)
    static let player = CollisionGroup(rawValue: 1 << 4)
    static let environment = CollisionGroup(rawValue: 1 << 5)
}

One gotcha that cost me some debugging time: CollisionGroup.default is bit 0. If you define your custom groups starting at bit 0 too, they'll silently collide with anything using the default group. Start at bit 1.

I also split the ground plane from world geometry. CollisionGroup.default stays reserved for the ground. Walls, pillars, and barriers get their own environment group. That way systems like my camera occlusion raycaster can target world geometry without hitting the floor.

Filters combine a group with a mask

A CollisionFilter has two parts. The group says "this is what I am." The mask says "this is what I'm allowed to collide with." Collisions are bidirectional: two entities only interact when both of their group bits appear in the other's mask. Edit one filter and forget the reciprocal side, and the collision silently no-ops.

I defined these as an extension on CollisionFilter so the same dot-shorthand works at call sites. Here's the filter for a player projectile:

extension CollisionFilter {
    static let projectile = CollisionFilter(
        group: .projectile,
        mask: [.enemy, .environment, .default]
    )
}

Reading it in plain English: "I'm a projectile. I can hit enemies, walls, and the ground." That's the full list of things a player projectile should interact with. Everything else (other projectiles, loot, the player) is excluded by not being in the mask.

I build masks opt-in rather than opt-out. My first version used CollisionGroup.all.subtracting(...) to remove the groups I didn't want. That reads well at first, but it breaks the moment you add a new group. Every subtracting-based filter silently starts hitting the new group because it was never excluded. Listing what you do want is safer and just as readable.

Some filters are even simpler. Loot only needs to rest on the ground:

static let loot = CollisionFilter(
    group: .loot,
    mask: .default
)

One group in the mask. Loot silently ignores everything except the floor. Or at least it will remain that way until inevitably I run into a case where loot is flying through walls, at which point I can add .environment to the mask. Maybe I should go update that now before I forget…

Applying the filter

Assigning a filter to an entity is a one-liner:

collisionComponent.filter = .projectile

That's it. The extension on CollisionFilter makes dot-shorthand work directly on the filter property. No namespacing, no nested enums.

The payoff

All the logic for "what can collide with what" lives in one file. When I add a new entity type, I define its group with the next available bit, build its filter, and update every existing filter that should interact with it. Both directions, both masks. The physics engine enforces this at the query level, so collision events that used to flood in are gone before the handler ever sees them.

Next
Next

How I stopped projectiles from dealing damage 60 times