Skip to content

Commit 71fbd3a

Browse files
committed
feat: add collision system
1 parent e814f2a commit 71fbd3a

4 files changed

Lines changed: 409 additions & 2 deletions

File tree

src/game/components/collider.hpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ namespace gameplay {
1111
ColliderShape shape = ColliderShape::Sphere;
1212
std::string layer = "default";
1313
float radius = 0.5f;
14-
float height = 1.0f;
14+
float height = 1.0f; // must be the total height
1515
bool isTrigger = false;
1616

1717
static std::string getID() {
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
#include "collision-system.hpp"
2+
3+
#include <btBulletCollisionCommon.h>
4+
5+
#include <components/collider.hpp>
6+
#include <ecs/entity.hpp>
7+
#include <glm/glm.hpp>
8+
#include <glm/gtc/quaternion.hpp>
9+
10+
// Helper functions for GLM-Bullet conversions
11+
inline btVector3 glmToBtVec3(const glm::vec3& v) {
12+
return btVector3(v.x, v.y, v.z);
13+
}
14+
inline glm::vec3 btToGlmVec3(const btVector3& v) {
15+
return glm::vec3(v.getX(), v.getY(), v.getZ());
16+
}
17+
static btTransform entityToBtTransform(our::Entity* entity) {
18+
glm::mat4 m = entity->getLocalToWorldMatrix();
19+
20+
// Extract position from column 3
21+
glm::vec3 pos = glm::vec3(m[3]);
22+
23+
// Extract rotation by stripping scale from the 3x3 submatrix.
24+
// Each column of the 3x3 has length = scale along that axis.
25+
// Normalizing each column removes the scale, leaving pure rotation.
26+
glm::mat3 rotMat;
27+
rotMat[0] = glm::normalize(glm::vec3(m[0]));
28+
rotMat[1] = glm::normalize(glm::vec3(m[1]));
29+
rotMat[2] = glm::normalize(glm::vec3(m[2]));
30+
glm::quat rot = glm::quat_cast(rotMat);
31+
32+
btTransform t;
33+
t.setOrigin(btVector3(pos.x, pos.y, pos.z));
34+
t.setRotation(btQuaternion(rot.x, rot.y, rot.z, rot.w));
35+
return t;
36+
}
37+
38+
namespace gameplay {
39+
40+
inline short layerStringToGroup(const std::string& layer) {
41+
if (layer == "player") return CollisionLayer::LAYER_PLAYER;
42+
if (layer == "enemy") return CollisionLayer::LAYER_ENEMY;
43+
if (layer == "environment") return CollisionLayer::LAYER_ENVIRONMENT;
44+
if (layer == "projectile") return CollisionLayer::LAYER_PROJECTILE;
45+
if (layer == "trigger") return CollisionLayer::LAYER_TRIGGER;
46+
return 0; // default to no layer
47+
}
48+
49+
inline short getMaskForLayer(short group) {
50+
switch (group) {
51+
case LAYER_PLAYER:
52+
return LAYER_ENEMY | LAYER_ENVIRONMENT | LAYER_TRIGGER;
53+
case LAYER_ENEMY:
54+
return LAYER_PLAYER | LAYER_PROJECTILE | LAYER_ENVIRONMENT;
55+
case LAYER_ENVIRONMENT:
56+
return LAYER_PLAYER | LAYER_ENEMY | LAYER_PROJECTILE;
57+
case LAYER_PROJECTILE:
58+
return LAYER_ENEMY | LAYER_ENVIRONMENT;
59+
case LAYER_TRIGGER:
60+
return LAYER_PLAYER;
61+
default:
62+
return 0;
63+
}
64+
}
65+
66+
void CollisionSystem::initialize() {
67+
// collision configuration contains default setup for memory, collision setup.
68+
collisionConfiguration = new btDefaultCollisionConfiguration();
69+
70+
// use the default collision dispatcher
71+
dispatcher = new btCollisionDispatcher(collisionConfiguration);
72+
73+
broadphase = new btDbvtBroadphase();
74+
75+
// the default constraint solver
76+
collisionWorld = new btCollisionWorld(dispatcher, broadphase, collisionConfiguration);
77+
}
78+
79+
void CollisionSystem::destroy() {
80+
// remove collision objects
81+
for (auto& [entity, obj] : entityToBullet) {
82+
collisionWorld->removeCollisionObject(obj);
83+
delete obj;
84+
}
85+
entityToBullet.clear();
86+
87+
// remove shapes
88+
for (auto shape : ownedShapes) {
89+
delete shape;
90+
}
91+
ownedShapes.clear();
92+
93+
// delete bullet internals
94+
delete collisionWorld;
95+
delete broadphase;
96+
delete dispatcher;
97+
delete collisionConfiguration;
98+
collisionWorld = nullptr;
99+
broadphase = nullptr;
100+
dispatcher = nullptr;
101+
collisionConfiguration = nullptr;
102+
103+
frameCollisions.clear();
104+
}
105+
106+
void CollisionSystem::update(our::World* world) {
107+
// clear previous frame's collisions
108+
frameCollisions.clear();
109+
110+
// Sync entities with colliders to Bullet
111+
for (auto entity : world->getEntities()) {
112+
ColliderComponent* collider = entity->getComponent<ColliderComponent>();
113+
114+
if (!collider) continue;
115+
116+
if (entityToBullet.find(entity) == entityToBullet.end()) {
117+
addEntity(entity);
118+
} else {
119+
syncTransform(entity);
120+
}
121+
}
122+
123+
// Remove bullet objects for entities that no longer exists
124+
std::vector<our::Entity*> toRemove;
125+
for (const auto& [entity, obj] : entityToBullet) {
126+
if (world->getEntities().find(entity) == world->getEntities().end()) {
127+
removeEntity(entity);
128+
}
129+
}
130+
131+
// Run collision detection
132+
collisionWorld->performDiscreteCollisionDetection();
133+
134+
// Read collision events
135+
int numManifolds = dispatcher->getNumManifolds();
136+
for (int i = 0; i < numManifolds; i++) {
137+
// any potential collision will have a manifold, even if it has no contact points
138+
btPersistentManifold* manifold = dispatcher->getManifoldByIndexInternal(i);
139+
const btCollisionObject* objA = manifold->getBody0();
140+
const btCollisionObject* objB = manifold->getBody1();
141+
our::Entity* entityA = static_cast<our::Entity*>(objA->getUserPointer());
142+
our::Entity* entityB = static_cast<our::Entity*>(objB->getUserPointer());
143+
144+
// check if they collide (if they have contact points)
145+
// find deepest contact point (the one with the largest penetration depth)
146+
int numContacts = manifold->getNumContacts();
147+
if (numContacts == 0) continue;
148+
float deepest = 0.0f;
149+
int deepestIndex = -1;
150+
for (int j = 0; j < numContacts; j++) {
151+
// negative distance means penetration, and positive distance means separation. So we want the most
152+
// negative distance.
153+
float d = manifold->getContactPoint(j).getDistance();
154+
if (d < deepest) {
155+
deepest = d;
156+
deepestIndex = j;
157+
}
158+
}
159+
160+
if (deepestIndex != -1) {
161+
// we have a collision!
162+
btManifoldPoint& contactPoint = manifold->getContactPoint(deepestIndex);
163+
CollisionEvent event;
164+
event.entityA = entityA;
165+
event.entityB = entityB;
166+
event.point = btToGlmVec3(contactPoint.getPositionWorldOnB());
167+
event.normal = btToGlmVec3(contactPoint.m_normalWorldOnB);
168+
event.penetrationDepth = -contactPoint.getDistance(); // convert back to positive penetration depth
169+
frameCollisions.push_back(event);
170+
}
171+
}
172+
// apply pushback for non-trigger collisions
173+
for (const CollisionEvent& event : frameCollisions) {
174+
// discard the trigger collisions since they don't need pushback
175+
auto* colliderA = event.entityA->getComponent<ColliderComponent>();
176+
auto* colliderB = event.entityB->getComponent<ColliderComponent>();
177+
178+
if (!colliderA || !colliderB) continue;
179+
if (colliderA->isTrigger || colliderB->isTrigger) continue;
180+
181+
// push back logic (don't push environments)
182+
if (colliderA->layer == "environment") {
183+
event.entityB->localTransform.position -= event.normal * event.penetrationDepth;
184+
} else if (colliderB->layer == "environment") {
185+
event.entityA->localTransform.position += event.normal * event.penetrationDepth;
186+
} else { // this may be edited or removed later
187+
event.entityA->localTransform.position -= event.normal * (event.penetrationDepth / 2.0f);
188+
event.entityB->localTransform.position += event.normal * (event.penetrationDepth / 2.0f);
189+
}
190+
}
191+
}
192+
193+
// On-demand function
194+
HitInfo CollisionSystem::raycast(const Ray& ray, float maxDistance, const std::string targetLayer) const {
195+
HitInfo hitInfo;
196+
197+
if (!collisionWorld) return hitInfo;
198+
btVector3 from = glmToBtVec3(ray.origin);
199+
btVector3 to = glmToBtVec3(ray.origin + ray.direction * maxDistance);
200+
201+
btCollisionWorld::ClosestRayResultCallback callback(from, to);
202+
if (!targetLayer.empty()) {
203+
callback.m_collisionFilterGroup = btBroadphaseProxy::AllFilter; // check against all layers
204+
callback.m_collisionFilterMask = layerStringToGroup(targetLayer); // only collide with the target layer
205+
}
206+
207+
collisionWorld->rayTest(from, to, callback);
208+
209+
if (callback.hasHit()) {
210+
hitInfo.hit = true;
211+
hitInfo.entity = static_cast<our::Entity*>(callback.m_collisionObject->getUserPointer());
212+
hitInfo.point = btToGlmVec3(callback.m_hitPointWorld);
213+
hitInfo.normal = btToGlmVec3(callback.m_hitNormalWorld);
214+
hitInfo.distance = callback.m_closestHitFraction * maxDistance;
215+
}
216+
217+
return hitInfo;
218+
}
219+
220+
// On-demand function
221+
std::vector<our::Entity*> CollisionSystem::overlapSphere(const glm::vec3& center, float radius,
222+
std::string targetLayer) {
223+
std::vector<our::Entity*> results;
224+
if (!collisionWorld) return results;
225+
226+
short targetMask = 0;
227+
if (!targetLayer.empty()) {
228+
targetMask = layerStringToGroup(targetLayer);
229+
}
230+
231+
for (const auto& [entity, obj] : entityToBullet) {
232+
if (targetMask != 0) {
233+
short objGroup = obj->getBroadphaseHandle()->m_collisionFilterGroup;
234+
if ((objGroup & targetMask) == 0) continue;
235+
}
236+
237+
glm::vec3 entityPos = btToGlmVec3(obj->getWorldTransform().getOrigin());
238+
239+
ColliderComponent* collider = entity->getComponent<ColliderComponent>();
240+
float entityRadius = collider ? collider->radius : 0.0f;
241+
242+
// sphere vs sphere hit check
243+
float dist = glm::length(entityPos - center);
244+
if (dist <= radius + entityRadius) {
245+
results.push_back(entity);
246+
}
247+
}
248+
249+
return results;
250+
}
251+
252+
void CollisionSystem::addEntity(our::Entity* entity) {
253+
auto* collider = entity->getComponent<ColliderComponent>();
254+
if (!collider) return;
255+
256+
// Create the collision shape based on component data
257+
btCollisionShape* shape = nullptr;
258+
switch (collider->shape) {
259+
case ColliderShape::Sphere:
260+
shape = new btSphereShape(collider->radius);
261+
break;
262+
case ColliderShape::Capsule: {
263+
// convert from total height to spine
264+
float spine = collider->height - 2.0f * collider->radius;
265+
if (spine < 0.0f) spine = 0.0f;
266+
shape = new btCapsuleShape(collider->radius, spine);
267+
break;
268+
}
269+
}
270+
ownedShapes.push_back(shape);
271+
272+
// Create the collision object
273+
btCollisionObject* obj = new btCollisionObject();
274+
obj->setCollisionShape(shape);
275+
obj->setWorldTransform(entityToBtTransform(entity));
276+
obj->setUserPointer(entity); // so we can go back to the entity
277+
278+
// Mark non-environment objects as KINEMATIC.
279+
if (collider->layer != "environment") {
280+
obj->setCollisionFlags(obj->getCollisionFlags() | btCollisionObject::CF_KINEMATIC_OBJECT);
281+
}
282+
283+
// Add to Bullet world with layer filtering
284+
short group = layerStringToGroup(collider->layer);
285+
short mask = getMaskForLayer(group);
286+
collisionWorld->addCollisionObject(obj, group, mask);
287+
entityToBullet[entity] = obj;
288+
}
289+
290+
void CollisionSystem::removeEntity(our::Entity* entity) {
291+
if (entityToBullet.find(entity) == entityToBullet.end()) return;
292+
293+
btCollisionObject* obj = entityToBullet[entity];
294+
collisionWorld->removeCollisionObject(obj);
295+
delete obj;
296+
entityToBullet.erase(entity);
297+
}
298+
299+
void CollisionSystem::syncTransform(our::Entity* entity) {
300+
btCollisionObject* obj = entityToBullet[entity];
301+
302+
if (!obj) return;
303+
304+
obj->setWorldTransform(entityToBtTransform(entity));
305+
306+
// Update the broadphase AABB so Bullet uses the new position for collision detection
307+
collisionWorld->updateSingleAabb(obj);
308+
}
309+
const std::vector<CollisionEvent>& CollisionSystem::getCollisions() const {
310+
return frameCollisions;
311+
}
312+
313+
} // namespace gameplay

0 commit comments

Comments
 (0)