fix: filter physics volumes like the client's collision groups

Volumes on the server touched far more than in the client:

- An enemy's body in the physics world was a sphere the size of its aggro
  radius, so trigger and damage volumes caught enemies from far away
  (Cavalry Hill enemies taking damage on spawn). It is now the enemy's
  own radius and collision group from its physics component.
- Trigger volumes ignored their collision group and caught everything.
  dpEntity now filters with the client's collision filter
  (PeCollisionFilter, 1.10.64 0x00fb6940, group table from 0x00fcf9a0):
  POI walls ignore enemies, threat clearing walls ignore players, and
  so on. The aggro sensor keeps seeing only players.
- Rotated boxes were tested as the axis aligned box around them, which
  for a turned wall covers a big square (the AG survival boundary).
  Sphere and point tests now use the box's own axes.

Proximity monitors take an optional collision group like live's
SetProximityRadius; the AM shield generators use live's (10 finds
enemies, 1 finds players).

Fixes #1127
Refs #1971

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-26 23:57:50 -05:00
parent 2d76c81bba
commit 5d3c2e6f9a
20 changed files with 277 additions and 64 deletions

View File

@@ -1,16 +0,0 @@
#pragma once
#include <cstdint>
/*
* Collision Groups
*/
enum eCollisionGroup : uint8_t
{
COLLISION_GROUP_ALL = 0 << 0,
COLLISION_GROUP_NEUTRAL = 1 << 0,
COLLISION_GROUP_FRIENDLY = 1 << 1,
COLLISION_GROUP_ENEMY = 1 << 2,
COLLISION_GROUP_DYNAMIC = 1 << 3,
};

View File

@@ -923,10 +923,10 @@ void Entity::Unsubscribe(LWOOBJID scriptObjId, const std::string& notificationNa
}
}
void Entity::SetProximityRadius(float proxRadius, std::string name) {
void Entity::SetProximityRadius(float proxRadius, std::string name, uint32_t collisionGroup) {
auto* proxMon = GetComponent<ProximityMonitorComponent>();
if (!proxMon) proxMon = AddComponent<ProximityMonitorComponent>(-1);
proxMon->SetProximityRadius(proxRadius, name);
proxMon->SetProximityRadius(proxRadius, name, collisionGroup);
}
void Entity::SetProximityRadius(dpEntity* entity, std::string name) {

View File

@@ -196,7 +196,7 @@ public:
void Subscribe(LWOOBJID scriptObjId, CppScripts::Script* scriptToAdd, const std::string& notificationName);
void Unsubscribe(LWOOBJID scriptObjId, const std::string& notificationName);
void SetProximityRadius(float proxRadius, std::string name);
void SetProximityRadius(float proxRadius, std::string name, uint32_t collisionGroup = 0);
void SetProximityRadius(dpEntity* entity, std::string name);
void AddChild(Entity* child);

View File

@@ -30,6 +30,15 @@
#include "CDPhysicsComponentTable.h"
#include "dNavMesh.h"
#include "Amf3.h"
#include "dpCollisionFilter.h"
namespace {
// The collision groups of players and enemies in PhysicsComponent
constexpr uint32_t PLAYER_COLLISION_GROUP = 10;
constexpr uint32_t ENEMY_COLLISION_GROUP = 12;
// standard_enemy.hkx's radius
constexpr float DEFAULT_BODY_RADIUS = 1.7f;
}
BaseCombatAIComponent::BaseCombatAIComponent(Entity* parent, const int32_t componentID) : Component(parent, componentID) {
RegisterMsg(&BaseCombatAIComponent::MsgGetObjectReportInfo);
@@ -105,7 +114,11 @@ BaseCombatAIComponent::BaseCombatAIComponent(Entity* parent, const int32_t compo
* Add physics
*/
int32_t collisionGroup = (COLLISION_GROUP_DYNAMIC | COLLISION_GROUP_ENEMY);
// The enemy's own body, which other volumes (triggers, damage volumes, proximity monitors) see: its group and
// radius from its physics component, like its character controller in the client. It used to be a sphere of
// the aggro radius, so volumes caught enemies from far away (e.g. the Cavalry Hill damage volumes, #1127).
uint32_t collisionGroup = ENEMY_COLLISION_GROUP;
float bodyRadius = DEFAULT_BODY_RADIUS;
CDComponentsRegistryTable* componentRegistryTable = CDClientManager::GetTable<CDComponentsRegistryTable>();
const auto controllablePhysicsID = componentRegistryTable->GetByIDAndType(parent->GetLOT(), eReplicaComponentType::CONTROLLABLE_PHYSICS);
@@ -115,15 +128,16 @@ BaseCombatAIComponent::BaseCombatAIComponent(Entity* parent, const int32_t compo
if (physicsComponentTable != nullptr) {
auto* info = physicsComponentTable->GetByID(controllablePhysicsID);
if (info != nullptr) {
collisionGroup = info->bStatic ? COLLISION_GROUP_NEUTRAL : info->collisionGroup;
collisionGroup = static_cast<uint32_t>(info->collisionGroup);
if (info->playerRadius > 0.0f) bodyRadius = info->playerRadius;
}
}
//Create a phantom physics volume so we can detect when we're aggro'd.
// The aggro sensor only needs to see players, as before; targets are picked by faction afterwards
m_dpEntity = new dpEntity(m_Parent->GetObjectID(), m_AggroRadius);
m_dpEntityEnemy = new dpEntity(m_Parent->GetObjectID(), m_AggroRadius, false);
m_dpEntityEnemy = new dpEntity(m_Parent->GetObjectID(), bodyRadius, false);
m_dpEntity->SetCollisionGroup(collisionGroup);
m_dpEntity->SetCollisionGroup(dpCollisionFilter::GROUP_MASK | (1u << (PLAYER_COLLISION_GROUP - 1)));
m_dpEntityEnemy->SetCollisionGroup(collisionGroup);
m_dpEntity->SetPosition(m_Parent->GetPosition());

View File

@@ -58,7 +58,8 @@ ControllablePhysicsComponent::ControllablePhysicsComponent(Entity* entity, const
float radius = 1.5f;
m_dpEntity = new dpEntity(m_Parent->GetObjectID(), radius, false);
m_dpEntity->SetCollisionGroup(COLLISION_GROUP_DYNAMIC | COLLISION_GROUP_FRIENDLY);
// The player's group from its physics component (10), which trigger volumes filter on
m_dpEntity->SetCollisionGroup(static_cast<uint32_t>(m_CollisionGroup));
dpWorld::AddEntity(m_dpEntity);
}
}

View File

@@ -103,6 +103,8 @@ dpEntity* PhysicsComponent::CreatePhysicsEntity(eReplicaComponentType type) {
//add fallback cube:
toReturn = new dpEntity(m_Parent->GetObjectID(), 2.0f, 2.0f, 2.0f);
}
// Only touch what the client lets this group touch (e.g. POI walls ignore enemies, threat clearing walls ignore players)
toReturn->SetCollisionGroup(static_cast<uint32_t>(m_CollisionGroup));
return toReturn;
}
@@ -174,7 +176,10 @@ dpEntity* PhysicsComponent::CreatePhysicsLnv(const float scale, const eReplicaCo
}
}
if (toReturn) dpWorld::AddEntity(toReturn);
if (toReturn) {
toReturn->SetCollisionGroup(static_cast<uint32_t>(m_CollisionGroup));
dpWorld::AddEntity(toReturn);
}
return toReturn;
}

View File

@@ -27,8 +27,9 @@ ProximityMonitorComponent::~ProximityMonitorComponent() {
m_ProximitiesData.clear();
}
void ProximityMonitorComponent::SetProximityRadius(float proxRadius, const std::string& name) {
void ProximityMonitorComponent::SetProximityRadius(float proxRadius, const std::string& name, const uint32_t collisionGroup) {
dpEntity* en = new dpEntity(m_Parent->GetObjectID(), proxRadius);
en->SetCollisionGroup(collisionGroup);
en->SetPosition(m_Parent->GetPosition());
dpWorld::AddEntity(en);

View File

@@ -32,7 +32,11 @@ public:
* @param proxRadius the radius to use for the physics entity we use to detect proximity
* @param name the name of this check
*/
void SetProximityRadius(float proxRadius, const std::string& name);
/**
* Adds a sphere that reports what enters and leaves it
* @param collisionGroup the collision group it filters on like the client's (see dpCollisionFilter), 0 for everything
*/
void SetProximityRadius(float proxRadius, const std::string& name, uint32_t collisionGroup = 0);
/**
* Creates an entry to check proximity for, given a name

View File

@@ -1,5 +1,6 @@
set(DPHYSICS_SOURCES "dpCollisionChecks.cpp"
"dpEntity.cpp"
"dpCollisionFilter.cpp"
"dpGrid.cpp"
"dpKnockback.cpp"
"dpShapeBase.cpp"

View File

@@ -100,16 +100,7 @@ bool dpCollisionChecks::CheckSphereBox(dpEntity* a, dpEntity* b) {
spherePos = a->GetPosition();
}
//Get closest point from the box to the sphere center by clamping
float x = std::max(box->m_MinX, std::min(spherePos.x, box->m_MaxX));
float y = std::max(box->m_MinY, std::min(spherePos.y, box->m_MaxY));
float z = std::max(box->m_MinZ, std::min(spherePos.z, box->m_MaxZ));
//Check the distance between that point & our sphere
float dX = x - spherePos.x;
float dY = y - spherePos.y;
float dZ = z - spherePos.z;
float distanceSquared = (dX * dX) + (dY * dY) + (dZ * dZ);
const float distanceSquared = box->SquaredDistanceTo(spherePos);
const float radius = sphere->GetRadius();
return distanceSquared < radius* radius;

View File

@@ -0,0 +1,89 @@
#include "dpCollisionFilter.h"
#include <array>
namespace {
using Table = std::array<uint32_t, dpCollisionFilter::GROUP_COUNT>;
// Group `group` touches every group whose bit is set in `mask`, and each of those touches it back
constexpr void Enable(Table& table, const uint32_t group, const uint32_t mask) {
table[group - 1] |= mask;
for (uint32_t other = 0; other < dpCollisionFilter::GROUP_COUNT; other++) {
if (mask & (1u << other)) table[other] |= 1u << (group - 1);
}
}
// The client's calls, in its order
constexpr Table BuildTable() {
Table table{};
Enable(table, 1, 0x96d9);
Enable(table, 2, 0x9400);
Enable(table, 10, 0x1cc9);
Enable(table, 3, 0x204d8);
Enable(table, 12, 0x24ed8);
Enable(table, 4, 0x1604);
Enable(table, 13, 0x164b);
Enable(table, 14, 0x1000);
Enable(table, 15, 0x246d9);
Enable(table, 24, 0xc000);
Enable(table, 5, 0x7c5);
Enable(table, 16, 0x4c3);
Enable(table, 7, 0x96d5);
Enable(table, 11, 0x9257);
Enable(table, 8, 0x8235);
Enable(table, 18, 0x4);
Enable(table, 19, 0x40449);
Enable(table, 20, 0x967d);
Enable(table, 22, 0x451);
Enable(table, 23, 0x401cd9);
Enable(table, 17, 0x400280);
Enable(table, 21, 0);
Enable(table, 25, 0);
return table;
}
constexpr Table GROUP_TABLE = BuildTable();
// Not the same system: same group and the same system bits (and some set) never touch (0x00fb5720)
bool DifferentSystems(const uint32_t a, const uint32_t b) {
using namespace dpCollisionFilter;
const bool sameSystem = (a & GROUP_BITS) == (b & GROUP_BITS)
&& ((a & SYSTEM_BITS) != 0 || (b & SYSTEM_BITS) != 0)
&& (a & SYSTEM_BITS) == (b & SYSTEM_BITS);
return !sameSystem;
}
// A group mask touches the other's group (0x00fb5780)
bool MaskHasGroup(const uint32_t mask, const uint32_t other) {
using namespace dpCollisionFilter;
const auto group = other & GROUP_BITS;
return other == 0 || (group > 0 && group <= 32 && (mask & (1u << (group - 1))) != 0);
}
}
bool dpCollisionFilter::GroupsCollide(const uint32_t groupA, const uint32_t groupB) {
if (groupA == 0 || groupB == 0) return true;
if (groupA > GROUP_COUNT || groupB > GROUP_COUNT) return false;
return (GROUP_TABLE[groupA - 1] & (1u << (groupB - 1))) != 0;
}
bool dpCollisionFilter::ShouldCollide(uint32_t filterA, uint32_t filterB) {
if ((filterA & PHANTOM_ONLY) || (filterB & PHANTOM_ONLY)) {
if ((filterA & PHANTOM_ONLY) && (filterB & PHANTOM_ONLY)) return false;
filterA &= ~PHANTOM_ONLY;
filterB &= ~PHANTOM_ONLY;
}
if (filterA == 0 || filterB == 0) return true;
const bool maskA = (filterA & GROUP_MASK) != 0;
const bool maskB = (filterB & GROUP_MASK) != 0;
if (!maskA && !maskB) {
const auto groupA = filterA & GROUP_BITS;
const auto groupB = filterB & GROUP_BITS;
return GroupsCollide(groupA, groupB) && DifferentSystems(filterA, filterB);
}
if (maskA && maskB) return false;
if (maskA) return MaskHasGroup(filterA, filterB) && DifferentSystems(filterA, filterB);
return MaskHasGroup(filterB, filterA) && DifferentSystems(filterB, filterA);
}

View File

@@ -0,0 +1,31 @@
#ifndef DPCOLLISIONFILTER_H
#define DPCOLLISIONFILTER_H
#include <cstdint>
/**
* Which physics objects can touch, the way the client's Havok filter decides it (1.10.64: table built in
* PeCollisionFilter::SetupGroups 0x00fcf9a0, tested in PeCollisionFilter::IsCollisionEnabled 0x00fb6940).
*
* A filter value is a collision group (PhysicsComponent.collisionGroup, or the CollisionGroupID config) with
* optional flags in the high bits:
* - 0 touches everything.
* - PHANTOM_ONLY (0x40000000): two objects that both have it never touch; otherwise it is ignored.
* - GROUP_MASK (0x04000000): the low bits are a mask of the groups it touches (bit n = group n + 1) instead of a group.
* - Two objects with the same group and the same non zero high bits never touch (the same system's parts).
* Otherwise two groups touch when the client's group table says so. Players are group 10, enemies 12.
*/
namespace dpCollisionFilter {
constexpr uint32_t PHANTOM_ONLY = 0x40000000;
constexpr uint32_t GROUP_MASK = 0x04000000;
constexpr uint32_t GROUP_BITS = 0x07ffffff;
constexpr uint32_t SYSTEM_BITS = 0xf8000000;
constexpr uint32_t GROUP_COUNT = 26;
[[nodiscard]] bool ShouldCollide(uint32_t filterA, uint32_t filterB);
// Whether group a touches group b in the client's table (groups start at 1)
[[nodiscard]] bool GroupsCollide(uint32_t groupA, uint32_t groupB);
};
#endif //!DPCOLLISIONFILTER_H

View File

@@ -8,7 +8,7 @@ dpEntity::dpEntity(const LWOOBJID& objectID, dpShapeType shapeType, bool isStati
m_IsStatic = isStatic;
m_CollisionShape = nullptr;
m_Scale = 1.0f;
m_CollisionGroup = COLLISION_GROUP_ALL;
m_CollisionGroup = 0;
switch (shapeType) {
case dpShapeType::Sphere:
@@ -29,7 +29,7 @@ dpEntity::dpEntity(const LWOOBJID& objectID, NiPoint3 boxDimensions, bool isStat
m_IsStatic = isStatic;
m_CollisionShape = nullptr;
m_Scale = 1.0f;
m_CollisionGroup = COLLISION_GROUP_ALL;
m_CollisionGroup = 0;
m_CollisionShape = new dpShapeBox(this, boxDimensions.x, boxDimensions.y, boxDimensions.z);
}
@@ -39,7 +39,7 @@ dpEntity::dpEntity(const LWOOBJID& objectID, float width, float height, float de
m_IsStatic = isStatic;
m_CollisionShape = nullptr;
m_Scale = 1.0f;
m_CollisionGroup = COLLISION_GROUP_ALL;
m_CollisionGroup = 0;
m_CollisionShape = new dpShapeBox(this, width, height, depth);
}
@@ -49,7 +49,7 @@ dpEntity::dpEntity(const LWOOBJID& objectID, float radius, bool isStatic) {
m_IsStatic = isStatic;
m_CollisionShape = nullptr;
m_Scale = 1.0f;
m_CollisionGroup = COLLISION_GROUP_ALL;
m_CollisionGroup = 0;
m_CollisionShape = new dpShapeSphere(this, radius);
}
@@ -70,9 +70,7 @@ void dpEntity::Update(float deltaTime) {
void dpEntity::CheckCollision(dpEntity* other) {
if (!m_CollisionShape) return;
if ((m_CollisionGroup & other->m_CollisionGroup) & (~COLLISION_GROUP_DYNAMIC)) {
return;
}
if (!dpCollisionFilter::ShouldCollide(m_CollisionGroup, other->m_CollisionGroup)) return;
const auto objId = other->GetObjectID();
const auto objItr = m_CurrentlyCollidingObjects.find(objId);

View File

@@ -8,7 +8,7 @@
#include "dCommonVars.h"
#include "dpCommon.h"
#include "dpShapeBase.h"
#include "dpCollisionGroups.h"
#include "dpCollisionFilter.h"
#include "dpGrid.h"
class dpEntity {
@@ -44,8 +44,9 @@ public:
bool GetIsStatic() const { return m_IsStatic; }
uint8_t GetCollisionGroup() const { return m_CollisionGroup; }
void SetCollisionGroup(uint8_t value) { m_CollisionGroup = value; }
// The collision filter value (see dpCollisionFilter): the client's collision group, 0 touches everything
uint32_t GetCollisionGroup() const { return m_CollisionGroup; }
void SetCollisionGroup(uint32_t value) { m_CollisionGroup = value; }
bool GetSleeping() const { return m_Sleeping; }
void SetSleeping(bool value) { m_Sleeping = value; }
@@ -76,7 +77,7 @@ private:
dpGrid* m_Grid = nullptr;
uint8_t m_CollisionGroup;
uint32_t m_CollisionGroup;
bool m_Sleeping = false;
bool m_IsGargantuan = false;

View File

@@ -7,6 +7,7 @@
#include "NiPoint3.h"
#include "NiQuaternion.h"
#include <algorithm>
#include <iostream>
dpShapeBox::dpShapeBox(dpEntity* parentEntity, float width, float height, float depth) :
@@ -78,6 +79,7 @@ void dpShapeBox::SetScale(float scale) {
void dpShapeBox::SetRotation(const NiQuaternion& rotation) {
if (m_HasBeenRotated) return; //Boxes cannot be rotated more than once.
m_HasBeenRotated = true;
m_Orientation = rotation;
m_TopMinLeft = m_TopMinLeft.RotateByQuaternion(rotation);
m_TopMaxLeft = m_TopMaxLeft.RotateByQuaternion(rotation);
@@ -93,19 +95,17 @@ void dpShapeBox::SetRotation(const NiQuaternion& rotation) {
}
bool dpShapeBox::IsVertInBox(const NiPoint3& vert) {
//if we are in the correct height
if (vert.y >= m_MinY && vert.y <= m_MaxY) {
//if we're inside the x bounds
if (vert.x >= m_MinX && vert.x <= m_MaxX) {
//if we're inside the z bounds
if (vert.z >= m_MinZ && vert.z <= m_MaxZ)
return true;
}
return SquaredDistanceTo(vert) <= 0.0f;
}
return false;
float dpShapeBox::SquaredDistanceTo(const NiPoint3& point) const {
// Into the box's frame: its origin is the middle of its bottom face
const auto local = (point - m_Origin).RotateByQuaternion(glm::conjugate(m_Orientation));
const float dX = local.x - std::clamp(local.x, -m_Width, m_Width);
const float dY = local.y - std::clamp(local.y, 0.0f, m_Height * 2.0f);
const float dZ = local.z - std::clamp(local.z, -m_Depth, m_Depth);
return dX * dX + dY * dY + dZ * dZ;
}
void dpShapeBox::InitVertices() {
@@ -129,6 +129,7 @@ void dpShapeBox::InitVertices() {
void dpShapeBox::SetPosition(const NiPoint3& position) {
if (isTransformed) return;
isTransformed = true;
m_Origin = position;
for (auto& vert : m_Vertices) {
vert.x += position.x;

View File

@@ -33,6 +33,12 @@ public:
bool IsVertInBox(const NiPoint3& vert);
/**
* The squared distance from a point to the box, measured along the box's own (rotated) axes. The m_Min/m_Max
* bounds are the axis aligned box around the rotated one, which for a rotated wall is far bigger than the wall.
*/
float SquaredDistanceTo(const NiPoint3& point) const;
void InitVertices();
void SetPosition(const NiPoint3& position);
@@ -64,6 +70,10 @@ private:
float m_Scale;
// Where the box was placed and how it was turned, to test against its real shape
NiPoint3 m_Origin{};
NiQuaternion m_Orientation = QuatUtils::IDENTITY;
bool m_HasBeenRotated = false;
bool isScaled = false;
bool isTransformed = false;

View File

@@ -9,8 +9,9 @@
#include "SkillComponent.h"
void AmShieldGenerator::OnStartup(Entity* self) {
self->SetProximityRadius(20, "shield");
self->SetProximityRadius(21, "buffer");
// Enemies (the client group table: 10 sees group 12) and players (1 sees group 10), like live
self->SetProximityRadius(20, "shield", 10);
self->SetProximityRadius(21, "buffer", 1);
StartShield(self);
}

View File

@@ -11,8 +11,9 @@
#include "MissionComponent.h"
void AmShieldGeneratorQuickbuild::OnStartup(Entity* self) {
self->SetProximityRadius(20, "shield");
self->SetProximityRadius(21, "buffer");
// Enemies (the client group table: 10 sees group 12) and players (1 sees group 10), like live
self->SetProximityRadius(20, "shield", 10);
self->SetProximityRadius(21, "buffer", 1);
}
void AmShieldGeneratorQuickbuild::OnProximityUpdate(Entity* self, Entity* entering, std::string name, std::string status) {

View File

@@ -8,6 +8,7 @@ set(DGAMETEST_SOURCES
"StaleSaveGuardTests.cpp"
"ContrabandTests.cpp"
"KnockbackTests.cpp"
"CollisionFilterTests.cpp"
)
add_subdirectory(dComponentsTests)

View File

@@ -0,0 +1,79 @@
#include <gtest/gtest.h>
#include "dpCollisionFilter.h"
#include "dpEntity.h"
#include "dpShapeBox.h"
using namespace dpCollisionFilter;
namespace {
constexpr uint32_t PLAYER = 10;
constexpr uint32_t ENEMY = 12;
}
TEST(CollisionFilterTests, GroupZeroTouchesEverything) {
EXPECT_TRUE(ShouldCollide(0, PLAYER));
EXPECT_TRUE(ShouldCollide(ENEMY, 0));
EXPECT_TRUE(ShouldCollide(0, 0));
}
TEST(CollisionFilterTests, TableIsSymmetric) {
for (uint32_t a = 1; a <= GROUP_COUNT; a++) {
for (uint32_t b = 1; b <= GROUP_COUNT; b++) EXPECT_EQ(GroupsCollide(a, b), GroupsCollide(b, a)) << a << " " << b;
}
}
TEST(CollisionFilterTests, LiveProximityGroups) {
// The AM shield generator: group 10 to find enemies, group 1 to find players
EXPECT_TRUE(ShouldCollide(10, ENEMY));
EXPECT_FALSE(ShouldCollide(10, PLAYER));
EXPECT_TRUE(ShouldCollide(1, PLAYER));
EXPECT_FALSE(ShouldCollide(1, ENEMY));
}
TEST(CollisionFilterTests, TriggerWalls) {
// POI trigger walls (group 1) ignore enemies; "Clear threat list" walls (18) only catch enemies
EXPECT_FALSE(ShouldCollide(1, ENEMY));
EXPECT_TRUE(ShouldCollide(18, ENEMY));
EXPECT_FALSE(ShouldCollide(18, PLAYER));
// Death volumes (4) catch both
EXPECT_TRUE(ShouldCollide(4, PLAYER));
EXPECT_TRUE(ShouldCollide(4, ENEMY));
// Groups 21 and 25 touch nothing
EXPECT_FALSE(ShouldCollide(21, PLAYER));
EXPECT_FALSE(ShouldCollide(25, ENEMY));
}
TEST(CollisionFilterTests, Flags) {
// Two phantom-only objects never touch, one is ignored
EXPECT_FALSE(ShouldCollide(PHANTOM_ONLY | 4, PHANTOM_ONLY | PLAYER));
EXPECT_TRUE(ShouldCollide(PHANTOM_ONLY | 4, PLAYER));
// A group mask touches exactly the groups in it
const auto playersOnly = GROUP_MASK | (1u << (PLAYER - 1));
EXPECT_TRUE(ShouldCollide(playersOnly, PLAYER));
EXPECT_FALSE(ShouldCollide(ENEMY, playersOnly));
EXPECT_FALSE(ShouldCollide(playersOnly, playersOnly));
// Parts of the same system don't touch each other
EXPECT_FALSE(ShouldCollide(0x08000000 | ENEMY, 0x08000000 | ENEMY));
EXPECT_TRUE(ShouldCollide(0x08000000 | ENEMY, 0x10000000 | ENEMY));
}
TEST(CollisionFilterTests, RotatedBoxesKeepTheirShape) {
// A 40 long, 1 thick wall turned 45 degrees: its axis aligned bounds are a ~29x29 square, but only the wall counts
dpEntity wall(1, 40.0f, 10.0f, 1.0f);
wall.SetRotation(QuatUtils::AxisAngle(NiPoint3(0.0f, 1.0f, 0.0f), glm::radians(45.0f)));
wall.SetPosition(NiPoint3(100.0f, 0.0f, 100.0f));
auto* box = static_cast<dpShapeBox*>(wall.GetShape());
// Along the wall
const auto along = QuatUtils::AxisAngle(NiPoint3(0.0f, 1.0f, 0.0f), glm::radians(45.0f));
const auto onWall = NiPoint3(100.0f, 5.0f, 100.0f) + NiPoint3(15.0f, 0.0f, 0.0f).RotateByQuaternion(along);
EXPECT_FLOAT_EQ(box->SquaredDistanceTo(onWall), 0.0f);
// A corner of the old axis aligned box, far from the wall
EXPECT_GT(box->SquaredDistanceTo(NiPoint3(112.0f, 5.0f, 112.0f)), 100.0f);
// Above the top and below the bottom
EXPECT_NEAR(box->SquaredDistanceTo(NiPoint3(100.0f, 12.0f, 100.0f)), 4.0f, 1e-3f);
EXPECT_NEAR(box->SquaredDistanceTo(NiPoint3(100.0f, -3.0f, 100.0f)), 9.0f, 1e-3f);
}