From 8c81b4ca6623e22123cbde66f437eaa5f2fe2b24 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 23:43:04 +0000 Subject: [PATCH 1/9] Add CLAUDE.md describing build and engine architecture. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSbmWwU5zh3ujWmjioWg1a --- CLAUDE.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..cc252a6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,56 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +Pancake is a small 2D C++ game engine built as a **static library** (`pancake`). There is no executable target in this repo: games are built from a separate template project (https://github.com/RohanFredriksson/PancakeTemplate) that adds this repo as a CMake subdirectory and links `pancake`. Dependencies: GLFW, GLM, glad (vendored), stb (vendored), nlohmann/json, Dear ImGui, SoLoud (author's fork). + +## Build + +```bash +git submodule update --init --recursive # glfw, glm, imgui, json, soloud are submodules +cmake -S . -B build -DGLFW_BUILD_WAYLAND=OFF +make -C build -j8 pancake +``` + +- On Linux, GLFW needs X11 dev headers (`libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libgl1-mesa-dev`) and SoLoud needs `libasound2-dev`. +- No C++ standard is set; this relies on the compiler default (C++17 on GCC 13). `spatial.hpp` uses `SpatialHashGrid(float)` as a constructor name, which is invalid in C++20. +- `CMAKE_EXE_LINKER_FLAGS` adds `-fsanitize=address` on non-Windows, so anything linking the library gets ASan at link time. +- `src/*.cpp` is picked up by `GLOB_RECURSE`; re-run cmake after adding files. + +## Tests / running + +There is no test suite, linter, or CI. To exercise the engine, write a small executable that links `pancake` (`add_subdirectory( pancake)` + `target_link_libraries(x pancake)`), call `Pancake::load(initFn); Pancake::start();`, and run it headless with `xvfb-run -a ./x` (Mesa llvmpipe provides GL 3.3 core). Drive it from a `Component::update` that calls `Pancake::stop()` after N frames. ALSA/JACK warnings without a sound card are harmless. + +## Architecture + +**Engine/Window loop** (`core/engine.cpp`, `core/window.cpp`). `Pancake::*` free functions are a thin facade over the `Window` namespace, which holds all global state (GLFW window, current `Scene*`, shaders, entity-picking framebuffer). Calls such as `load`, `save`, `reset`, `title`, and `size` only **set flags**; the main loop applies them at the top of the next frame. `load(fn)` runs the init function against the current scene, while `reset()` deletes the scene and creates a new empty one. Each frame does `scene->update(dt)`, then renders twice: once into an RGB32F framebuffer with the entity shader (entity IDs for mouse picking via `Window::readPixel`) and once to the screen. After that come `DebugDraw::render()` and the ImGui console. + +**Scene → Entity → Component** (`core/`). `Scene` owns `Entity*`s plus a `Camera`, a `Renderer` (sprite batches) and a physics `World`. Entities own `Component*`s. IDs come from static counters, and loading from JSON bumps the counter past loaded IDs. Lifecycle: +- `start()` runs when the entity is added to a started scene (or on `Scene::start`). Components **self-register** with scene subsystems in `start()` through `Window::getScene()` (e.g. `SpriteRenderer` → `getRenderer()->add`, `Rigidbody` → `getPhysics()->add`, `ForceGenerator` → `getPhysics()->addForceGenerator`) and unregister in `end()`. +- `kill()` marks the component dead and calls `end()`. The owning `update` loop deletes dead entities and components later. `Entity::~Entity` deletes its components. +- `TransformableComponent` adds a position/size/rotation offset relative to the entity. `Entity::addRotation` rotates the offsets of all transformable components. + +Because of the reliance on the global `Window::getScene()`, components assume they belong to the *current* scene, including during destruction. + +**Serialisation / reflection** (`core/factory.hpp`). `REGISTER(Base, Derived)` creates a static `Creator` that registers a default constructor under the class name. `FACTORY(Component).create(type)` is used by `Entity::load`, and `FACTORY(Collider)` by `Rigidbody::load`. Every serialisable component needs a default constructor, a `serialise()`/`load(json)` pair that chains to its base class, and a `REGISTER` line in its header. `Scene::serialise` also writes the asset pools (fonts, spritesheets, sprites, audio) so a save file is self-contained. + +**Physics** (`physics/`). `Rigidbody` is a component that holds non-component `Collider`s (`BoxCollider`, `CircleCollider`, owned by the rigidbody). Mass, centroid, moment and bounds are lazily recomputed via dirty flags. `World::update` steps at a fixed timestep (1/60) and does the following: +- integrates bodies, then culls pairs with `SpatialHashGrid` (cell size 4); bodies update their grid cells when their bounds change in `physicsUpdate` +- runs narrow phase (`Collision::findCollisionFeatures`, SAT-style box/box, circle/circle, circle/box) and notifies any component on either entity that also inherits `CollisionListener` +- applies forces through `ForceRegistry` +- resolves impulses iteratively (6 iterations) + +Forces are opt-in per body: a rigidbody lists force-generator type names (`addForceGenerator("Gravity")`), and a `ForceGenerator` component somewhere in the scene with that type name provides the force. There is **no gravity by default**; the `World` constructor's gravity argument is unused. Bodies with zero total collider mass are treated as infinite-mass statics. `sensor` bodies report collisions but get no impulse. + +**Rendering** (`graphics/`). `Renderer` groups `SpriteRenderer`s into `RenderBatch`es by z-index (max 1000 quads and 7 textures per batch). `TextRenderer` and `Animation` produce child `SpriteRenderer`s. Shader sources are string constants in `asset/shaders.hpp`, and the default font (Pixellari) is embedded as a byte array in `asset/fonts.hpp`. Assets are cached by name in the namespaces in `asset/assetpool.hpp` (`TexturePool`, `SpritePool`, `FontPool`, `AudioPool`). `DebugDraw` is immediate-mode lines and boxes with a frame lifetime. + +**Audio** (`audio/`). A SoLoud wrapper. `AudioEngine::init/destroy` wraps `Window::start()` in `Pancake::start()`. `AudioPlayer` is the component and `AudioWave` is the pooled asset. + +## Conventions + +- Namespace `Pancake`. Internal helpers live in anonymous namespaces inside `.cpp` files. Headers include their dependents at the *bottom* (e.g. `component.hpp` includes `entity.hpp` after the class) to break include cycles. +- The recent commits ("The war against using has begun") have been removing `using` declarations in favour of fully qualified `std::`/`glm::` names; follow that in touched code. Some headers still export `using std::string; using glm::vec2;` etc., and other code relies on that. +- Single-line `if (...) {...}` braces; British spelling (`serialise`, `colour`, `initialiser`). +- Errors are printed as `ERROR::AREA::DETAIL` to stdout; load functions return `false`/`nullptr` on malformed JSON instead of throwing. From 559e1e03272a1bd198b614e694b697435ffbc228 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 23:53:05 +0000 Subject: [PATCH 2/9] Fix memory-safety bugs left by the force generator refactor. - World no longer deletes ForceGenerator components; entities own them. Removing a generator only unregisters it if it is the active one. - World::remove now removes the rigidbody from the spatial hash grid, fixing a use-after-free when a body is killed near another body. - Static bodies refresh their grid registration when moved by game code. - Scene teardown deletes entities before the systems they unregister from. - Window resets its stop flag on start, deletes the scene, shaders, picking framebuffer and ImGui context on shutdown, and no longer reads a pixel back from the GPU every frame. - DebugDraw allocated MAX_LINES times too much GPU memory. - Rigidbody: integrate angular velocity with the moment of inertia, use each collider's mass in the parallel axis term, compute bounds without always including the origin, and drop force registrations in clearForceGenerators. - Begin-down key/mouse queries now last for one frame instead of being consumed by the first reader. - Entity tolerates having no active scene; define Entity::setId. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSbmWwU5zh3ujWmjioWg1a --- include/pancake/core/listener.hpp | 1 + include/pancake/physics/rigidbody.hpp | 1 + src/core/entity.cpp | 9 ++++-- src/core/listener.cpp | 31 ++++++++------------ src/core/scene.cpp | 12 ++++---- src/core/window.cpp | 21 +++++++++++++- src/graphics/debugdraw.cpp | 14 +++++---- src/physics/rigidbody.cpp | 42 ++++++++++++++++++--------- src/physics/world.cpp | 11 +++---- 9 files changed, 90 insertions(+), 52 deletions(-) diff --git a/include/pancake/core/listener.hpp b/include/pancake/core/listener.hpp index 89a15e5..c97b726 100644 --- a/include/pancake/core/listener.hpp +++ b/include/pancake/core/listener.hpp @@ -9,6 +9,7 @@ namespace Pancake { bool isKeyDown(int key); bool isKeyDown(); bool isKeyBeginDown(int key); + void endFrame(); } namespace MouseListener { diff --git a/include/pancake/physics/rigidbody.hpp b/include/pancake/physics/rigidbody.hpp index 7f43323..614f506 100644 --- a/include/pancake/physics/rigidbody.hpp +++ b/include/pancake/physics/rigidbody.hpp @@ -100,6 +100,7 @@ namespace Pancake { Rigidbody* setMomentDirty(); void physicsUpdate(float dt); + void updateGridRegistration(bool moved); void clearAccumulators(); void addVelocity(glm::vec2 velocity); diff --git a/src/core/entity.cpp b/src/core/entity.cpp index b4f7796..b613328 100644 --- a/src/core/entity.cpp +++ b/src/core/entity.cpp @@ -59,9 +59,10 @@ namespace Pancake { } Entity::~Entity() { + Scene* scene = Window::getScene(); for (Component* c: this->components) {c->kill();} // Delete all the components. for (Component* c: this->components) { - Window::getScene()->getComponents()->erase(c->getId()); + if (scene != nullptr) {scene->getComponents()->erase(c->getId());} delete c; } } @@ -88,7 +89,7 @@ namespace Pancake { for (int i : dead) { Component* c = this->components[i]; this->components.erase(this->components.begin() + i); - Window::getScene()->getComponents()->erase(c->getId()); + if (Window::getScene() != nullptr) {Window::getScene()->getComponents()->erase(c->getId());} delete c; } @@ -195,6 +196,10 @@ namespace Pancake { this->dead = true; } + void Entity::setId(int id) { + this->id = id; + } + int Entity::getId() { return this->id; } diff --git a/src/core/listener.cpp b/src/core/listener.cpp index 343fd66..8a302cc 100644 --- a/src/core/listener.cpp +++ b/src/core/listener.cpp @@ -33,13 +33,13 @@ namespace Pancake { if (key < 0 || key > 349) {return;} if (action == GLFW_PRESS) { - keyPressCount++; + if (!keyDown[key]) {keyPressCount++;} keyDown[key] = true; keyBeginDown[key] = true; } else if (action == GLFW_RELEASE) { - keyPressCount--; + if (keyDown[key]) {keyPressCount--;} keyDown[key] = false; keyBeginDown[key] = false; } @@ -56,15 +56,13 @@ namespace Pancake { } bool KeyListener::isKeyBeginDown(int key) { - if (key < 0 || key > 349) {return false;} + return keyBeginDown[key]; + } - if (keyBeginDown[key]) { - keyBeginDown[key] = false; - return true; - } - - return false; + void KeyListener::endFrame() { + // A key is only "beginning down" for the frame it was pressed in. + for (int i = 0; i < 350; i++) {keyBeginDown[i] = false;} } void MouseListener::calcOrthoX() { @@ -105,13 +103,13 @@ namespace Pancake { if (button > 8 || button < 0) {return;} if (action == GLFW_PRESS) { - mouseDownCount++; + if (!mouseDown[button]) {mouseDownCount++;} mouseDown[button] = true; mouseBeginDown[button] = true; } else if (action == GLFW_RELEASE) { - mouseDownCount--; + if (mouseDown[button]) {mouseDownCount--;} mouseDown[button] = false; mouseBeginDown[button] = false; mouseDragging = false; @@ -125,7 +123,7 @@ namespace Pancake { } bool MouseListener::isMouseDown(int button) { - if (button < 0 || button > 9) {return false;} + if (button < 0 || button > 8) {return false;} return mouseDown[button]; } @@ -136,13 +134,7 @@ namespace Pancake { bool MouseListener::isMouseBeginDown(int button) { if (button < 0 || button > 8) {return false;} - - if (mouseBeginDown[button]) { - mouseBeginDown[button] = false; - return true; - } - - return false; + return mouseBeginDown[button]; } bool MouseListener::hasMouseMoved() { @@ -195,6 +187,7 @@ namespace Pancake { void MouseListener::endFrame() { + for (int i = 0; i < 9; i++) {mouseBeginDown[i] = false;} scrollX = 0.0; scrollY = 0.0; lastX = x; diff --git a/src/core/scene.cpp b/src/core/scene.cpp index 3b5db3a..d8f99c6 100644 --- a/src/core/scene.cpp +++ b/src/core/scene.cpp @@ -27,22 +27,22 @@ namespace Pancake { Scene::~Scene() { - // Kill all entities. + // Kill all entities, so components unregister while the scene systems still exist. for (Entity* e : this->entities) { e->kill(); } + // Delete all entities and their components. + for (Entity* e : this->entities) { + delete e; + } + // Delete all scene elements. delete this->camera; delete this->renderer; delete this->physics; delete this->grid; - // Delete all entities and their components. - for (Entity* e : this->entities) { - delete e; - } - } void Scene::start() { diff --git a/src/core/window.cpp b/src/core/window.cpp index 701e918..d57ffdc 100644 --- a/src/core/window.cpp +++ b/src/core/window.cpp @@ -130,6 +130,9 @@ namespace Pancake { void start() { + // Allow the engine to be started again after a previous stop. + stopFlag = false; + // Initialise GLFW if (!glfwInit()) { std::cout << "ERROR::WINDOW::GLFW_INITIALIZATION_FAILED\n"; @@ -269,8 +272,8 @@ namespace Pancake { render(); } + KeyListener::endFrame(); MouseListener::endFrame(); - Window::readPixel(0, 0); endTime = (float)glfwGetTime(); dt = endTime - beginTime; @@ -278,10 +281,26 @@ namespace Pancake { } + // Destroy the scene first, as its components release resources held by other systems. + delete scene; + scene = nullptr; + // Destroy + delete entityTexture; + delete entityShader; + delete defaultShader; + entityTexture = nullptr; + entityShader = nullptr; + defaultShader = nullptr; DebugDraw::destroy(); AssetPool::destroy(); + + ImGui_ImplOpenGL3_Shutdown(); + ImGui_ImplGlfw_Shutdown(); + ImGui::DestroyContext(); + glfwDestroyWindow(window); + window = nullptr; glfwTerminate(); } diff --git a/src/graphics/debugdraw.cpp b/src/graphics/debugdraw.cpp index 65189c2..3ac9e6a 100644 --- a/src/graphics/debugdraw.cpp +++ b/src/graphics/debugdraw.cpp @@ -75,7 +75,7 @@ namespace Pancake { // Allocate space for vertices glGenBuffers(1, &vbo); glBindBuffer(GL_ARRAY_BUFFER, vbo); - glBufferData(GL_ARRAY_BUFFER, MAX_LINES * VERTEX_ARRAY_LENGTH * sizeof(float), nullptr, GL_DYNAMIC_DRAW); + glBufferData(GL_ARRAY_BUFFER, VERTEX_ARRAY_LENGTH * sizeof(float), nullptr, GL_DYNAMIC_DRAW); // Enable the vertex array attributes glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*) 0); @@ -131,7 +131,7 @@ namespace Pancake { } glBindBuffer(GL_ARRAY_BUFFER, vbo); - glBufferData(GL_ARRAY_BUFFER, VERTEX_ARRAY_LENGTH * sizeof(float), vertices, GL_DYNAMIC_DRAW); + glBufferSubData(GL_ARRAY_BUFFER, 0, index * sizeof(float), vertices); shader->bind(); shader->uploadMat4("uProjection", Window::getScene()->getCamera()->getProjection()); @@ -153,11 +153,13 @@ namespace Pancake { void destroy() { delete shader; + shader = nullptr; free(vertices); - int n = lines.size(); - for (int i = 0; i < n; i++) { - delete lines[i]; - } + vertices = nullptr; + for (Line* line : lines) {delete line;} + lines.clear(); + glDeleteBuffers(1, &vbo); + glDeleteVertexArrays(1, &vao); } void drawLine(vec2 from, vec2 to, vec3 colour, int lifetime) { diff --git a/src/physics/rigidbody.cpp b/src/physics/rigidbody.cpp index bb0dabc..1167e1f 100644 --- a/src/physics/rigidbody.cpp +++ b/src/physics/rigidbody.cpp @@ -186,8 +186,15 @@ namespace Pancake { if (!this->boundsDirty) {return this->bounds;} - glm::vec2 first = glm::vec2(0.0f, 0.0f); - glm::vec2 second = glm::vec2(0.0f, 0.0f); + glm::vec2 position = this->getEntity()->getPosition(); + if (this->colliders.empty()) { + this->bounds = std::make_pair(position, position); + this->boundsDirty = false; + return this->bounds; + } + + glm::vec2 first = glm::vec2(FLT_MAX, FLT_MAX); + glm::vec2 second = glm::vec2(-FLT_MAX, -FLT_MAX); for (Collider* c : this->colliders) { @@ -204,7 +211,6 @@ namespace Pancake { } - glm::vec2 position = this->getEntity()->getPosition(); this->bounds = std::make_pair(position + first, position + second); this->boundsDirty = false; return this->bounds; @@ -262,12 +268,11 @@ namespace Pancake { } float moment = 0.0f; - float mass = this->getMass(); glm::vec2 centroid = this->getCentroid(); for (Collider* c : this->colliders) { glm::vec2 r = c->getPosition() - centroid; float rr = r.x * r.x + r.y * r.y; - moment += c->getMomentOfInertia() + rr * mass; + moment += c->getMomentOfInertia() + rr * c->getMass(); } this->moment = moment; @@ -427,7 +432,8 @@ namespace Pancake { } Rigidbody* Rigidbody::clearForceGenerators() { - this->forceGenerators.clear(); + std::vector types(this->forceGenerators.begin(), this->forceGenerators.end()); + this->removeForceGenerators(types); return this; } @@ -515,8 +521,12 @@ namespace Pancake { void Rigidbody::physicsUpdate(float dt) { - // Do not do a physics update if static. - if (this->hasInfiniteMass()) {return;} + // Static bodies are not integrated, but may still be moved by game code. + if (this->hasInfiniteMass()) { + this->clearAccumulators(); + this->updateGridRegistration(false); + return; + } // Required variables glm::vec2 displacement = glm::vec2(0.0f, 0.0f); @@ -530,7 +540,7 @@ namespace Pancake { // Update the rotation if allowed to. if (!this->fixedOrientation) { - this->angularVelocity += this->torque * (dt / this->getMass()); + this->angularVelocity += this->torque * dt * this->getInverseMomentOfInertia(); rotation = this->angularVelocity * dt; this->getEntity()->addRotationAround(rotation, this->getCentroid()); @@ -551,11 +561,17 @@ namespace Pancake { // Clear the force and torque accumulators. this->clearAccumulators(); - // Check if there has been a changed in the bounding box. - this->boundsDirty = (this->lastPosition != this->getEntity()->getPosition()) || + // Update the spatial hash grid if the bounding box has changed. + this->updateGridRegistration(displacement != glm::vec2(0.0f, 0.0f) || rotation != 0.0f); + + } + + void Rigidbody::updateGridRegistration(bool moved) { + + // Check if there has been a change in the bounding box. + this->boundsDirty = moved || + (this->lastPosition != this->getEntity()->getPosition()) || (this->lastRotation != this->getEntity()->getRotation()) || - (displacement != glm::vec2(0.0f, 0.0f)) || - (rotation != 0.0f) || (this->boundsDirty); // If the bounding box has changed we need to update the spatial hash grid. diff --git a/src/physics/world.cpp b/src/physics/world.cpp index 679c84e..493baa1 100644 --- a/src/physics/world.cpp +++ b/src/physics/world.cpp @@ -168,7 +168,7 @@ namespace Pancake { } World::~World() { - for (ForceGenerator* force : this->forces) {delete force;} + // Force generators are components, so they are owned and deleted by their entities. delete this->grid; } @@ -340,8 +340,9 @@ namespace Pancake { void World::remove(Rigidbody* rigidbody) { - // Remove the rigidbody from the index if it is in it. + // Remove the rigidbody from the index and the spatial hash grid. this->rigidbodiesIndex.erase(rigidbody); + this->grid->remove(rigidbody); // Remove the rigidbody from the rigidbody vector. int n = this->rigidbodies.size(); @@ -404,8 +405,9 @@ namespace Pancake { } void World::removeForceGenerator(ForceGenerator* force) { - std::string type = force->getType(); - this->removeForceGenerator(type); + // Only remove the generator if it is the one currently registered under its type. + if (this->getForceGenerator(force->getType()) != force) {return;} + this->removeForceGenerator(force->getType()); } void World::removeForceGenerator(std::string type) { @@ -423,7 +425,6 @@ namespace Pancake { // Remove the force from the vector. for (int i = 0; i < this->forces.size(); i++) { if (this->forces[i]->getType() == type) { - delete this->forces[i]; this->forces.erase(this->forces.begin() + i); return; } From d6cdb48a351d18f5f54f9c5287aa5ebace37c38a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 00:04:18 +0000 Subject: [PATCH 3/9] Rework collision detection, raycasts and the physics solver. Collision detection - Box/box now uses a separating axis test with reference face clipping. The old corner-containment test missed edge overlaps, so identical boxes stacked on each other fell straight through. - Circle/box finds the closest point on the box and handles circles whose centre has entered the box. - All features report the full penetration depth with the normal from the first collider to the second. Solver - Correct positions once per step after the impulse iterations, rather than re-applying the correction in every iteration. - Guard against NaN when contacts are touching with zero depth. - Cap the number of fixed steps per frame and drop excess time. - World gravity (previously an unused constructor argument) is applied to every dynamic body, scaled by a new per-body gravityScale. Raycasts - Box raycasts use a slab test in the box's local space (the old code ignored the ray entirely). Circles behind the ray are no longer hit, and normals point out of the surface. - World::raycast(ray, maxDistance) walks the spatial hash grid cells along the ray instead of testing every body. Ownership - Rigidbodies and force generators remember the World they were added to, so they unregister from the right world and work outside of a running scene. - Rigidbody::load reads colliders before velocity and force, which were previously dropped because a body without colliders has no mass. Debug drawing - Physics debug drawing (bounds, colliders, contacts) is off by default and toggled with World::setDebugDraw. DebugDraw can be disabled entirely with DebugDraw::setEnabled. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSbmWwU5zh3ujWmjioWg1a --- include/pancake/graphics/debugdraw.hpp | 5 + include/pancake/physics/force.hpp | 9 + include/pancake/physics/rigidbody.hpp | 8 + include/pancake/physics/world.hpp | 7 + src/graphics/debugdraw.cpp | 18 +- src/physics/collision.cpp | 559 +++++++------------------ src/physics/force.cpp | 17 +- src/physics/raycast.cpp | 66 ++- src/physics/rigidbody.cpp | 85 ++-- src/physics/world.cpp | 175 ++++++-- 10 files changed, 454 insertions(+), 495 deletions(-) diff --git a/include/pancake/graphics/debugdraw.hpp b/include/pancake/graphics/debugdraw.hpp index 7968561..9882bf7 100644 --- a/include/pancake/graphics/debugdraw.hpp +++ b/include/pancake/graphics/debugdraw.hpp @@ -12,6 +12,11 @@ namespace Pancake { void init(); void render(); void destroy(); + void clear(); + + // Disabling debug drawing discards all draw calls until it is enabled again. + void setEnabled(bool enabled); + bool isEnabled(); void drawLine(vec2 from, vec2 to, vec3 colour, int lifetime); void drawLine(vec2 from, vec2 to, vec3 colour); diff --git a/include/pancake/physics/force.hpp b/include/pancake/physics/force.hpp index 85ab07b..0dee86e 100644 --- a/include/pancake/physics/force.hpp +++ b/include/pancake/physics/force.hpp @@ -11,11 +11,20 @@ namespace Pancake { + class World; + class ForceGenerator : public Component { + private: + + World* world; // The physics world this generator has been added to. + public: ForceGenerator(std::string type); + ~ForceGenerator() override; + World* getWorld(); + void setWorld(World* world); void start() override; void end() override; diff --git a/include/pancake/physics/rigidbody.hpp b/include/pancake/physics/rigidbody.hpp index 614f506..c84defe 100644 --- a/include/pancake/physics/rigidbody.hpp +++ b/include/pancake/physics/rigidbody.hpp @@ -11,11 +11,13 @@ namespace Pancake { class Collider; + class World; class Rigidbody : public Component { private: + World* world; // The physics world this rigidbody has been added to. std::vector colliders; // Gives a rigidbody shape. std::unordered_set forceGenerators; // Allows specific force generators to act on the rigidbody. @@ -26,6 +28,7 @@ namespace Pancake { float restitution; float friction; + float gravityScale; glm::vec2 lastPosition; float lastRotation; @@ -46,11 +49,14 @@ namespace Pancake { public: Rigidbody(); + ~Rigidbody() override; void start() override; void end() override; json serialise() override; bool load(json j) override; + World* getWorld(); + void setWorld(World* world); std::vector getColliders(); std::unordered_set getForceGenerators(); glm::vec2 getVelocity(); @@ -59,6 +65,7 @@ namespace Pancake { float getAngularVelocity(); float getRestitution(); float getFriction(); + float getGravityScale(); float getMass(); float getInverseMass(); float getMomentOfInertia(); @@ -92,6 +99,7 @@ namespace Pancake { Rigidbody* setAngularVelocity(float angularVelocity); Rigidbody* setRestitution(float cor); Rigidbody* setFriction(float cof); + Rigidbody* setGravityScale(float scale); Rigidbody* setSensor(bool sensor); Rigidbody* setFixedOrientation(bool orientation); Rigidbody* setCentroidDirty(); diff --git a/include/pancake/physics/world.hpp b/include/pancake/physics/world.hpp index 10b87c5..ed93420 100644 --- a/include/pancake/physics/world.hpp +++ b/include/pancake/physics/world.hpp @@ -32,8 +32,10 @@ namespace Pancake { SpatialHashGrid* grid; + glm::vec2 gravity; float timeStep; float time; + bool debug; void fixedUpdate(); @@ -44,6 +46,10 @@ namespace Pancake { void update(float dt); void render(); + glm::vec2 getGravity(); + void setGravity(glm::vec2 gravity); + bool hasDebugDraw(); + void setDebugDraw(bool debug); void add(Rigidbody* rigidbody); bool has(Rigidbody* rigidbody); void remove(Rigidbody* rigidbody); @@ -58,6 +64,7 @@ namespace Pancake { SpatialHashGrid* getGrid(); RaycastResult raycast(Ray ray); + RaycastResult raycast(Ray ray, float maxDistance); }; diff --git a/src/graphics/debugdraw.cpp b/src/graphics/debugdraw.cpp index 3ac9e6a..907e331 100644 --- a/src/graphics/debugdraw.cpp +++ b/src/graphics/debugdraw.cpp @@ -39,6 +39,7 @@ namespace Pancake { Shader* shader; unsigned int vao; unsigned int vbo; + bool enabled = true; vec2 rotate(vec2 vector, float degrees, vec2 origin) { @@ -162,9 +163,24 @@ namespace Pancake { glDeleteVertexArrays(1, &vao); } + void clear() { + for (Line* line : lines) {delete line;} + lines.clear(); + } + + void setEnabled(bool state) { + enabled = state; + if (!enabled) {clear();} + } + + bool isEnabled() { + return enabled; + } + void drawLine(vec2 from, vec2 to, vec3 colour, int lifetime) { - if (lines.size() >= MAX_LINES) {return;} + if (!enabled || lines.size() >= MAX_LINES) {return;} + if (Window::getScene() == nullptr) {return;} Camera* camera = Window::getScene()->getCamera(); diff --git a/src/physics/collision.cpp b/src/physics/collision.cpp index ce01bdd..7a5830a 100644 --- a/src/physics/collision.cpp +++ b/src/physics/collision.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -30,289 +31,139 @@ namespace Pancake { namespace { - void rotate(vec2& vec, vec2 origin, float rCos, float rSin) { - float x = vec.x - origin.x; - float y = vec.y - origin.y; - vec.x = origin.x + ((x * rCos) - (y * rSin)); - vec.y = origin.y + ((x * rSin) + (y * rCos)); - } + // All features use the same conventions: the normal points from the first collider towards the second, + // the depth is the full penetration depth, and the point lies midway between the two surfaces. - void rotate(vector& vertices, vec2 origin, float rCos, float rSin) { - int n = vertices.size(); - for (int i = 0; i < n; i++) { - float x = vertices[i].x - origin.x; - float y = vertices[i].y - origin.y; - vertices[i].x = origin.x + ((x * rCos) - (y * rSin)); - vertices[i].y = origin.y + ((x * rSin) + (y * rCos)); - } + vec2 rotate(vec2 v, float rCos, float rSin) { + return vec2(v.x * rCos - v.y * rSin, v.x * rSin + v.y * rCos); } - vec2 project(const vec2& v, const vec2& target) { - vec2 targetUnit = glm::normalize(target); - float projection = glm::dot(v, targetUnit); - return projection * targetUnit; + struct OrientedBox { + vec2 centre; + vec2 axes[2]; // Local x and y axes in world space. + vec2 half; // Half extents along each axis. + vec2 vertices[4]; // Counter clockwise, starting bottom left. + vec2 normals[4]; // Outward face normals, normals[i] belongs to the edge vertices[i] -> vertices[i + 1]. + }; + + OrientedBox orient(BoxCollider* box) { + OrientedBox o; + float r = box->getRotation(); + float c = cosf(r); + float s = sinf(r); + o.centre = box->getPosition(); + o.axes[0] = vec2(c, s); + o.axes[1] = vec2(-s, c); + o.half = glm::abs(box->getSize()) * 0.5f; + vec2 x = o.axes[0] * o.half.x; + vec2 y = o.axes[1] * o.half.y; + o.vertices[0] = o.centre - x - y; + o.vertices[1] = o.centre + x - y; + o.vertices[2] = o.centre + x + y; + o.vertices[3] = o.centre - x + y; + o.normals[0] = -o.axes[1]; + o.normals[1] = o.axes[0]; + o.normals[2] = o.axes[1]; + o.normals[3] = -o.axes[0]; + return o; } - bool intersects(vec2 aStart, vec2 aEnd, vec2 bStart, vec2 bEnd) { - float dx0 = aEnd.x - aStart.x; - float dx1 = bEnd.x - bStart.x; - float dy0 = aEnd.y - aStart.y; - float dy1 = bEnd.y - bStart.y; - float p0 = dy1 * (bEnd.x - aStart.x) - dx1 *(bEnd.y - aStart.y); - float p1 = dy1 * (bEnd.x - aEnd.x) - dx1 *(bEnd.y - aEnd.y); - float p2 = dy0 * (aEnd.x - bStart.x) - dx0 *(aEnd.y - bStart.y); - float p3 = dy0 * (aEnd.x - bEnd.x) - dx0 *(aEnd.y - bEnd.y); - return (p0 * p1 <= 0) && (p2 * p3 <= 0); - } - - float distance(vec2 aStart, vec2 aEnd, vec2 bStart, vec2 bEnd) { - - // If either line is vertical, rotate the problem 45 degrees. - // Since we are solving for distance, direction does not need to be preserved. - if (aStart.x == aEnd.x || bStart.x == bEnd.x) { - rotate(aStart, vec2(0.0f, 0.0f), 0.70710678118f, 0.70710678118f); - rotate(aEnd, vec2(0.0f, 0.0f), 0.70710678118f, 0.70710678118f); - rotate(bStart, vec2(0.0f, 0.0f), 0.70710678118f, 0.70710678118f); - rotate(bEnd, vec2(0.0f, 0.0f), 0.70710678118f, 0.70710678118f); + // The largest separation of b from any face of a, and which face it is. + float findMaxSeparation(const OrientedBox& a, const OrientedBox& b, int& face) { + float best = -FLT_MAX; + for (int i = 0; i < 4; i++) { + float separation = FLT_MAX; + for (int j = 0; j < 4; j++) {separation = std::min(separation, glm::dot(a.normals[i], b.vertices[j] - a.vertices[i]));} + if (separation > best) {best = separation; face = i;} } - - // y = ax + b - float a = (aEnd.y - aStart.y) / (aEnd.x - aStart.x); - float b = aStart.y - a * aStart.x; - - // y = cx + d - float c = (bEnd.y - bStart.y) / (bEnd.x - bStart.x); - float d = bStart.y - c * bStart.x; - - // ax + b = cx + d: solve for x - float x = (d - b) / (a - c); - - // plug x into ax + b - float y = a * x + b; - - // Now that we have the point, compute the distance between points. - vec2 point = vec2(x, y); - return glm::distance(aStart, point); - + return best; } - float distance(vec2 start, vec2 end, vec2 point) { - - // If the line is vertical, rotate the problem 45 degrees. - if (start.x == end.x) { - rotate(start, vec2(0.0f, 0.0f), 0.70710678118f, 0.70710678118f); - rotate(end, vec2(0.0f, 0.0f), 0.70710678118f, 0.70710678118f); - rotate(point, vec2(0.0f, 0.0f), 0.70710678118f, 0.70710678118f); - } - - // y = ax + b - float a = (end.y - start.y) / (end.x - start.x); - float b = start.y - a * start.x; - - // Perpendicular distance formula - float distance = fabsf(a * point.x + -1.0f * point.y + b) / glm::distance(start, end); - return distance; - + // Clip the segment to the side of the plane dot(normal, x) <= offset. + int clipSegment(vec2 in[2], vec2 out[2], vec2 normal, float offset) { + int count = 0; + float d0 = glm::dot(normal, in[0]) - offset; + float d1 = glm::dot(normal, in[1]) - offset; + if (d0 <= 0.0f) {out[count++] = in[0];} + if (d1 <= 0.0f) {out[count++] = in[1];} + if (d0 * d1 < 0.0f) {out[count++] = in[0] + (d0 / (d0 - d1)) * (in[1] - in[0]);} + return count; } std::vector findCollisionFeaturesCircleColliderAndCircleCollider(CircleCollider* a, CircleCollider* b) { std::vector result; - vec2 aPosition = a->getPosition(); - vec2 bPosition = b->getPosition(); - - // Determine if the two circles are colliding. + vec2 difference = b->getPosition() - a->getPosition(); float sumRadii = a->getRadius() + b->getRadius(); - vec2 distance = bPosition - aPosition; - if (glm::dot(distance, distance) - (sumRadii * sumRadii) > 0) {return result;} + float distanceSquared = glm::dot(difference, difference); + if (distanceSquared >= sumRadii * sumRadii) {return result;} - // Find the depth and normal of the collision - float depth = fabsf(glm::length(distance) - sumRadii) * 0.5f; - vec2 normal = glm::normalize(distance); - - // Find the contact point of the collision - float distanceToPoint = a->getRadius() - depth; - vec2 point = distanceToPoint * normal + aPosition; + float distance = sqrtf(distanceSquared); + vec2 normal = distance > 1e-6f ? difference / distance : vec2(0.0f, 1.0f); + float depth = sumRadii - distance; + vec2 point = a->getPosition() + normal * (a->getRadius() - 0.5f * depth); result.push_back(CollisionManifold(normal, point, depth)); return result; } - std::vector findCollisionFeaturesBoxColliderAndBoxCollider(BoxCollider* a, BoxCollider* b) { + std::vector findCollisionFeaturesBoxColliderAndBoxCollider(BoxCollider* boxA, BoxCollider* boxB) { std::vector result; - - // Store all the information that is required. - float aRotation = a->getRotation(); - vec2 aSize = a->getSize(); - vec2 aPos = a->getPosition(); - vec2 aMin = aPos - aSize * 0.5f; - vec2 aMax = aPos + aSize * 0.5f; - - float bRotation = b->getRotation(); - vec2 bSize = b->getSize(); - vec2 bPos = b->getPosition(); - vec2 bMin = bPos - bSize * 0.5f; - vec2 bMax = bPos + bSize * 0.5f; - - // Cache the trig ratios for rotational use. - float aCos = cosf(aRotation); - float aSin = sinf(aRotation); - float bCos = cosf(bRotation); - float bSin = sinf(bRotation); - - // Get the vertices of a. - vector aVertices; - aVertices.push_back(vec2(aMin.x, aMin.y)); // Bottom Left - aVertices.push_back(vec2(aMin.x, aMax.y)); // Top Left - aVertices.push_back(vec2(aMax.x, aMax.y)); // Top Right - aVertices.push_back(vec2(aMax.x, aMin.y)); // Bottom Right - rotate(aVertices, aPos, aCos, aSin); - rotate(aVertices, bPos, bCos, -bSin); - - // Get the vertices of b. - vector bVertices; - bVertices.push_back(vec2(bMin.x, bMin.y)); // Bottom Left - bVertices.push_back(vec2(bMin.x, bMax.y)); // Top Left - bVertices.push_back(vec2(bMax.x, bMax.y)); // Top Right - bVertices.push_back(vec2(bMax.x, bMin.y)); // Bottom Right - rotate(bVertices, bPos, bCos, bSin); // B Space -> Global - rotate(bVertices, aPos, aCos, -aSin);// Global -> A Space - - // Get the positions of each box in their local spaces. - vec2 aPosInB = aPos; - vec2 bPosInA = bPos; - rotate(aPosInB, bPos, bCos, -bSin); - rotate(bPosInA, aPos, aCos, -aSin); - - // Determine which vertices of each box is in each others box. - vector bInsideA; for (vec2 vertex : bVertices) {if (vertex.x > aMin.x && vertex.x < aMax.x && vertex.y > aMin.y && vertex.y < aMax.y) {bInsideA.push_back(vertex);}} - vector aInsideB; for (vec2 vertex : aVertices) {if (vertex.x > bMin.x && vertex.x < bMax.x && vertex.y > bMin.y && vertex.y < bMax.y) {aInsideB.push_back(vertex);}} - - // Collision manifold variables. - vec2 normal; - vec2 point; - float depth; - - // Special case for 1 and 1. - if (aInsideB.size() == 1 && bInsideA.size() == 1) { - - // Find which side of a is b. - vec2 other = aInsideB[0]; - rotate(other, bPos, bCos, bSin); - rotate(other, aPos, aCos, -aSin); - vec2 difference = 0.5f * (bInsideA[0] + other) - aPos; - difference.x /= aSize.x; - difference.y /= aSize.y; - - // Determine the normal direction. - if (fabsf(difference.x) > fabsf(difference.y)) {if (difference.x >= 0.0f) {normal = vec2(1.0f, 0.0f);} else {normal = vec2(-1.0f, 0.0f);}} - else {if (difference.y >= 0.0f) {normal = vec2(0.0f, 1.0f);} else {normal = vec2( 0.0f,-1.0f);}} - - // Determine the depth. - vec2 end = bInsideA[0] + std::max(std::max(aSize.x, aSize.y), std::max(bSize.x, bSize.y)) * normal; - if (intersects(bInsideA[0], end, vec2(aMin.x, aMin.y), vec2(aMin.x, aMax.y))) {depth = 0.5f * distance(bInsideA[0], end, vec2(aMin.x, aMin.y), vec2(aMin.x, aMax.y));} - else if (intersects(bInsideA[0], end, vec2(aMin.x, aMin.y), vec2(aMax.x, aMin.y))) {depth = 0.5f * distance(bInsideA[0], end, vec2(aMin.x, aMin.y), vec2(aMax.x, aMin.y));} - else if (intersects(bInsideA[0], end, vec2(aMin.x, aMax.y), vec2(aMax.x, aMax.y))) {depth = 0.5f * distance(bInsideA[0], end, vec2(aMin.x, aMax.y), vec2(aMax.x, aMax.y));} - else {depth = 0.5f * distance(bInsideA[0], end, vec2(aMax.x, aMin.y), vec2(aMax.x, aMax.y));} - - // Determine the contact point. - point = bInsideA[0] + depth * normal; - - // Rotate into global space. - rotate(normal, vec2(0.0f, 0.0f), aCos, aSin); - rotate(point, aPos, aCos, aSin); - - // Add the point to the manifold, if the depth is positive and non-zero. - depth *= 0.5f; if (depth > 0.0f) {result.push_back(CollisionManifold(normal, point, depth));} - - // Find which side of b is a. - other = bInsideA[0]; - rotate(other, aPos, aCos, aSin); - rotate(other, bPos, bCos, -bSin); - difference = 0.5f * (aInsideB[0] + other) - bPos; - difference.x /= bSize.x; - difference.y /= bSize.y; - - // Determine the normal direction. - if (fabsf(difference.x) > fabsf(difference.y)) {if (difference.x >= 0.0f) {normal = vec2(1.0f, 0.0f);} else {normal = vec2(-1.0f, 0.0f);}} - else {if (difference.y >= 0.0f) {normal = vec2(0.0f, 1.0f);} else {normal = vec2( 0.0f,-1.0f);}} - - // Determine the depth. - end = aInsideB[0] + std::max(std::max(aSize.x, aSize.y), std::max(bSize.x, bSize.y)) * normal; - if (intersects(aInsideB[0], end, vec2(bMin.x, bMin.y), vec2(bMin.x, bMax.y))) {depth = 0.5f * distance(aInsideB[0], end, vec2(bMin.x, bMin.y), vec2(bMin.x, bMax.y));} - else if (intersects(aInsideB[0], end, vec2(bMin.x, bMin.y), vec2(bMax.x, bMin.y))) {depth = 0.5f * distance(aInsideB[0], end, vec2(bMin.x, bMin.y), vec2(bMax.x, bMin.y));} - else if (intersects(aInsideB[0], end, vec2(bMin.x, bMax.y), vec2(bMax.x, bMax.y))) {depth = 0.5f * distance(aInsideB[0], end, vec2(bMin.x, bMax.y), vec2(bMax.x, bMax.y));} - else {depth = 0.5f * distance(aInsideB[0], end, vec2(bMax.x, bMin.y), vec2(bMax.x, bMax.y));} - - // Determine the contact point. - point = aInsideB[0] + depth * normal; - - // Rotate into global space. - rotate(normal, vec2(0.0f, 0.0f), bCos, bSin); - rotate(point, bPos, bCos, bSin); - - // Add the point to the manifold, if the depth is positive and non-zero. - depth *= 0.5f; if (depth > 0.0f) {result.push_back(CollisionManifold(-1.0f * normal, point, depth));} - return result; - - } - - // Flip the problem to reduce number of cases. - bool flip = false; - if (aInsideB.size() > bInsideA.size()) { - flip = true; - vector tmp = bInsideA; - bInsideA = aInsideB; aInsideB = tmp; bVertices = aVertices; - aMax = bMax; aMin = bMin; aPos = bPos; aCos = bCos; aSin = bSin; - } - - if (aInsideB.size() == 0 && bInsideA.size() == 1) { - - // Find the side that minimises the distance - float best = FLT_MAX; - float current; - current = aMax.y - bInsideA[0].y; if (current < best) {normal = vec2( 0.0f, 1.0f); depth = current * 0.5f; point = bInsideA[0] + normal * depth; best = current;} // Top - current = bInsideA[0].y - aMin.y; if (current < best) {normal = vec2( 0.0f, -1.0f); depth = current * 0.5f; point = bInsideA[0] + normal * depth; best = current;} // Bottom - current = aMax.x - bInsideA[0].x; if (current < best) {normal = vec2( 1.0f, 0.0f); depth = current * 0.5f; point = bInsideA[0] + normal * depth; best = current;} // Right - current = bInsideA[0].x - aMin.x; if (current < best) {normal = vec2(-1.0f, 0.0f); depth = current * 0.5f; point = bInsideA[0] + normal * depth; best = current;} // Left - - // Rotate back into global coordinates. - rotate(normal, vec2(0.0f, 0.0f), aCos, aSin); - rotate(point, aPos, aCos, aSin); - if (flip) {normal = -normal;} - - result.push_back(CollisionManifold(normal, point, depth)); - return result; - - } - - if (aInsideB.size() == 0 && bInsideA.size() == 2) { - - // Collision manifold variables. - vec2 points[2]; - float depths[2]; - - // Find the side that minimises the distance. - float best = FLT_MAX; - float current; - current = 0.0f; for (vec2 v : bInsideA) {current += aMax.y - v.y;} if (current < best) {normal = vec2( 0.0f, 1.0f); for (int i = 0; i < 2; i++) {depths[i] = (aMax.y - bInsideA[i].y) * 0.5f; points[i] = bInsideA[i] + normal * depths[i]; best = current;}} // Top - current = 0.0f; for (vec2 v : bInsideA) {current += v.y - aMin.y;} if (current < best) {normal = vec2( 0.0f, -1.0f); for (int i = 0; i < 2; i++) {depths[i] = (bInsideA[i].y - aMin.y) * 0.5f; points[i] = bInsideA[i] + normal * depths[i]; best = current;}} // Bottom - current = 0.0f; for (vec2 v : bInsideA) {current += aMax.x - v.x;} if (current < best) {normal = vec2( 1.0f, 0.0f); for (int i = 0; i < 2; i++) {depths[i] = (aMax.x - bInsideA[i].x) * 0.5f; points[i] = bInsideA[i] + normal * depths[i]; best = current;}} // Right - current = 0.0f; for (vec2 v : bInsideA) {current += v.x - aMin.x;} if (current < best) {normal = vec2(-1.0f, 0.0f); for (int i = 0; i < 2; i++) {depths[i] = (bInsideA[i].x - aMin.x) * 0.5f; points[i] = bInsideA[i] + normal * depths[i]; best = current;}} // Left - - // Rotate back into global coordinates. - rotate(normal, vec2(0.0f, 0.0f), aCos, aSin); - rotate(points[0], aPos, aCos, aSin); - rotate(points[1], aPos, aCos, aSin); - if (flip) {normal = -normal;} - - result.push_back(CollisionManifold(normal, points[0], depths[0] * 0.5f)); - result.push_back(CollisionManifold(normal, points[1], depths[1] * 0.5f)); - return result; - + OrientedBox a = orient(boxA); + OrientedBox b = orient(boxB); + + // Separating axis test on the face normals of both boxes. + int faceA = 0; + int faceB = 0; + float separationA = findMaxSeparation(a, b, faceA); + if (separationA > 0.0f) {return result;} + float separationB = findMaxSeparation(b, a, faceB); + if (separationB > 0.0f) {return result;} + + // Use the face with the least penetration as the reference face, preferring a for stability. + const OrientedBox* reference = &a; + const OrientedBox* incident = &b; + int referenceFace = faceA; + bool flipped = false; + if (separationB > 0.98f * separationA + 0.001f) { + reference = &b; + incident = &a; + referenceFace = faceB; + flipped = true; + } + + // The incident face is the one most anti-parallel to the reference face. + vec2 referenceNormal = reference->normals[referenceFace]; + int incidentFace = 0; + float minimum = FLT_MAX; + for (int i = 0; i < 4; i++) { + float d = glm::dot(referenceNormal, incident->normals[i]); + if (d < minimum) {minimum = d; incidentFace = i;} + } + + vec2 incidentEdge[2] = {incident->vertices[incidentFace], incident->vertices[(incidentFace + 1) % 4]}; + vec2 v1 = reference->vertices[referenceFace]; + vec2 v2 = reference->vertices[(referenceFace + 1) % 4]; + vec2 tangent = glm::normalize(v2 - v1); + + // Clip the incident edge to the sides of the reference face. + vec2 clipped1[2]; + vec2 clipped2[2]; + if (clipSegment(incidentEdge, clipped1, -tangent, -glm::dot(tangent, v1)) < 2) {return result;} + if (clipSegment(clipped1, clipped2, tangent, glm::dot(tangent, v2)) < 2) {return result;} + + // Keep the points below the reference face. + vec2 normal = flipped ? -referenceNormal : referenceNormal; + float offset = glm::dot(referenceNormal, v1); + for (int i = 0; i < 2; i++) { + float separation = glm::dot(referenceNormal, clipped2[i]) - offset; + if (separation <= 0.0f) { + float depth = -separation; + result.push_back(CollisionManifold(normal, clipped2[i] + referenceNormal * (0.5f * depth), depth)); + } } return result; @@ -322,153 +173,55 @@ namespace Pancake { std::vector result; - // Get the circle's properties. - vec2 cPos = c->getPosition(); - float cRadius = c->getRadius(); - - // Get the box's properties. - float bRot = b->getRotation(); + // Move the circle into the box's local space. vec2 bPos = b->getPosition(); - vec2 bHalf = b->getSize() * 0.5f; - vec2 bMin = bPos - bHalf; - vec2 bMax = bPos + bHalf; - - // If we need to rotate, we need to store these variables. - vec2 offset; - float rCos; - float rSin; - - // Rotate the circle into the box's space. - if (bRot != 0.0f) { - rCos = cosf(-bRot); - rSin = sinf(-bRot); - rotate(cPos, bPos, rCos, rSin); - } - + vec2 half = glm::abs(b->getSize()) * 0.5f; + float rCos = cosf(b->getRotation()); + float rSin = sinf(b->getRotation()); + vec2 local = rotate(c->getPosition() - bPos, rCos, -rSin); + float radius = c->getRadius(); + + vec2 normal; // From the box towards the circle, in local space. + vec2 surface; float depth; - vec2 normal; - vec2 point; - - // Colliding with the top face of the box. - if (cPos.x >= bMin.x && cPos.x <= bMax.x && cPos.y >= bMax.y && cPos.y < bMax.y + cRadius) { - - depth = (bMax.y - (cPos.y - cRadius)) * 0.5f; - normal = vec2(0.0f, -1.0f); - point = vec2(cPos.x, bMax.y - depth); - if (bRot != 0.0f) {rotate(normal, vec2(0.0f, 0.0f), rCos, -rSin); rotate(point, bPos, rCos, -rSin);} - if (flip) {normal = -normal;} - - result.push_back(CollisionManifold(normal, point, depth)); - return result; - - } - - // Colliding with the bottom face of the box. - if (cPos.x >= bMin.x && cPos.x <= bMax.x && cPos.y > bMin.y - cRadius && cPos.y <= bMin.y) { - - depth = ((cPos.y + cRadius) - bMin.y) * 0.5f; - normal = vec2(0.0f, 1.0f); - point = vec2(cPos.x, bMin.y + depth); - if (bRot != 0.0f) {rotate(normal, vec2(0.0f, 0.0f), rCos, -rSin); rotate(point, bPos, rCos, -rSin);} - if (flip) {normal = -normal;} - - result.push_back(CollisionManifold(normal, point, depth)); - return result; - - } - - // Colliding with the right face of the box - if (cPos.y >= bMin.y && cPos.y <= bMax.y && cPos.x >= bMax.x && cPos.x < bMax.x + cRadius) { - - depth = (bMax.x - (cPos.x - cRadius)) * 0.5f; - normal = vec2(-1.0f, 0.0f); - point = vec2(bMax.x - depth, cPos.y); - if (bRot != 0.0f) {rotate(normal, vec2(0.0f, 0.0f), rCos, -rSin); rotate(point, bPos, rCos, -rSin);} - if (flip) {normal = -normal;} - - result.push_back(CollisionManifold(normal, point, depth)); - return result; - - } - - // Colliding with the left face of the box. - if (cPos.y >= bMin.y && cPos.y <= bMax.y && cPos.x > bMin.x - cRadius && cPos.x <= bMin.x) { - - depth = ((cPos.x + cRadius) - bMin.x) * 0.5f; - normal = vec2(1.0f, 0.0f); - point = vec2(bMin.x + depth, cPos.y); - if (bRot != 0.0f) {rotate(normal, vec2(0.0f, 0.0f), rCos, -rSin); rotate(point, bPos, rCos, -rSin);} - if (flip) {normal = -normal;} - - result.push_back(CollisionManifold(normal, point, depth)); - return result; - - } - // Colliding with the top left corner of the box. - if (glm::dot(vec2(cPos.x - bMin.x, cPos.y - bMax.y), vec2(cPos.x - bMin.x, cPos.y - bMax.y)) < cRadius * cRadius) { - - vec2 difference = vec2(bMin.x, bMax.y) - cPos; - normal = glm::normalize(difference); - vec2 depthVector = ((normal * cRadius) - difference) * 0.5f; - depth = glm::length(depthVector); - point = vec2(bMin.x, bMax.y) + depthVector; - if (bRot != 0.0f) {rotate(normal, vec2(0.0f, 0.0f), rCos, -rSin); rotate(point, bPos, rCos, -rSin);} - if (flip) {normal = -normal;} - - result.push_back(CollisionManifold(normal, point, depth)); - return result; - - } - - // Colliding with the top right corner of the box. - if (glm::dot(vec2(cPos.x - bMax.x, cPos.y - bMax.y), vec2(cPos.x - bMax.x, cPos.y - bMax.y)) < cRadius * cRadius) { - - vec2 difference = vec2(bMax.x, bMax.y) - cPos; - normal = glm::normalize(difference); - vec2 depthVector = ((normal * cRadius) - difference) * 0.5f; - depth = glm::length(depthVector); - point = vec2(bMax.x, bMax.y) + depthVector; - if (bRot != 0.0f) {rotate(normal, vec2(0.0f, 0.0f), rCos, -rSin); rotate(point, bPos, rCos, -rSin);} - if (flip) {normal = -normal;} - - result.push_back(CollisionManifold(normal, point, depth)); - return result; - - } - - // Colliding with the bottom left corner of the box. - if (glm::dot(vec2(cPos.x - bMin.x, cPos.y - bMin.y), vec2(cPos.x - bMin.x, cPos.y - bMin.y)) < cRadius * cRadius) { - - vec2 difference = vec2(bMin.x, bMin.y) - cPos; - normal = glm::normalize(difference); - vec2 depthVector = ((normal * cRadius) - difference) * 0.5f; - depth = glm::length(depthVector); - point = vec2(bMin.x, bMin.y) + depthVector; - if (bRot != 0.0f) {rotate(normal, vec2(0.0f, 0.0f), rCos, -rSin); rotate(point, bPos, rCos, -rSin);} - if (flip) {normal = -normal;} - - result.push_back(CollisionManifold(normal, point, depth)); - return result; - - } - - // Colliding with the bottom right corner of the box. - if (glm::dot(vec2(cPos.x - bMax.x, cPos.y - bMin.y), vec2(cPos.x - bMax.x, cPos.y - bMin.y)) < cRadius * cRadius) { - - vec2 difference = vec2(bMax.x, bMin.y) - cPos; - normal = glm::normalize(difference); - vec2 depthVector = ((normal * cRadius) - difference) * 0.5f; - depth = glm::length(depthVector); - point = vec2(bMax.x, bMin.y) + depthVector; - if (bRot != 0.0f) {rotate(normal, vec2(0.0f, 0.0f), rCos, -rSin); rotate(point, bPos, rCos, -rSin);} - if (flip) {normal = -normal;} - - result.push_back(CollisionManifold(normal, point, depth)); - return result; - - } - + vec2 closest = glm::clamp(local, -half, half); + if (closest == local) { + + // The centre is inside the box, so push it out through the nearest face. + float dx = half.x - std::abs(local.x); + float dy = half.y - std::abs(local.y); + if (dx < dy) { + float sign = local.x < 0.0f ? -1.0f : 1.0f; + normal = vec2(sign, 0.0f); + surface = vec2(sign * half.x, local.y); + depth = radius + dx; + } else { + float sign = local.y < 0.0f ? -1.0f : 1.0f; + normal = vec2(0.0f, sign); + surface = vec2(local.x, sign * half.y); + depth = radius + dy; + } + + } else { + + vec2 difference = local - closest; + float distanceSquared = glm::dot(difference, difference); + if (distanceSquared >= radius * radius) {return result;} + float distance = sqrtf(distanceSquared); + normal = difference / distance; + surface = closest; + depth = radius - distance; + + } + + // Back into world space. The midpoint lies half the depth outward from the box surface. + vec2 point = bPos + rotate(surface + normal * (0.5f * depth), rCos, rSin); + normal = rotate(normal, rCos, rSin); + + // Unflipped, the circle is the first collider, so the normal must point from the circle to the box. + if (!flip) {normal = -normal;} + result.push_back(CollisionManifold(normal, point, depth)); return result; } diff --git a/src/physics/force.cpp b/src/physics/force.cpp index e907c1f..376560f 100644 --- a/src/physics/force.cpp +++ b/src/physics/force.cpp @@ -5,7 +5,19 @@ namespace Pancake { ForceGenerator::ForceGenerator(std::string type) : Component(type) { + this->world = nullptr; + } + + ForceGenerator::~ForceGenerator() { + if (this->world != nullptr) {this->world->removeForceGenerator(this);} + } + + World* ForceGenerator::getWorld() { + return this->world; + } + void ForceGenerator::setWorld(World* world) { + this->world = world; } void ForceGenerator::updateForce(Rigidbody* rigidbody, float dt) { @@ -13,11 +25,12 @@ namespace Pancake { } void ForceGenerator::start() { - Window::getScene()->getPhysics()->addForceGenerator(this); + Scene* scene = Window::getScene(); + if (scene != nullptr) {scene->getPhysics()->addForceGenerator(this);} } void ForceGenerator::end() { - Window::getScene()->getPhysics()->removeForceGenerator(this); + if (this->world != nullptr) {this->world->removeForceGenerator(this);} } ForceRegistration::ForceRegistration(ForceGenerator* generator, Rigidbody* rigidbody) { diff --git a/src/physics/raycast.cpp b/src/physics/raycast.cpp index d96872b..3522a95 100644 --- a/src/physics/raycast.cpp +++ b/src/physics/raycast.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include "pancake/physics/raycast.hpp" #include "pancake/physics/collider.hpp" @@ -45,35 +46,52 @@ namespace Pancake { RaycastResult raycastBoxCollider(BoxCollider* box, Ray ray) { - // Rotate the ray into the box's local space. + // Transform the ray into the box's local space, where the box is axis aligned and centred at the origin. vec2 centre = box->getPosition(); float radians = box->getRotation(); float rCos = cosf(radians); float rSin = sinf(radians); - ray.rotate(centre, rCos, rSin); + vec2 origin = ray.origin; + vec2 direction = ray.direction; + rotateVector(origin, centre, rCos, -rSin); + rotateVector(direction, vec2(0.0f, 0.0f), rCos, -rSin); + origin -= centre; + vec2 half = box->getSize() * 0.5f; + + // Slab test against both axes. + float tEnter = -FLT_MAX; + float tExit = FLT_MAX; + vec2 enterNormal = vec2(0.0f, 0.0f); + vec2 exitNormal = vec2(0.0f, 0.0f); + + for (int axis = 0; axis < 2; axis++) { + + if (std::abs(direction[axis]) < 1e-8f) { + if (std::abs(origin[axis]) > half[axis]) {return RaycastResult();} + continue; + } + + float t1 = (-half[axis] - origin[axis]) / direction[axis]; + float t2 = ( half[axis] - origin[axis]) / direction[axis]; + float sign = direction[axis] > 0.0f ? 1.0f : -1.0f; + + // t1 is always the face the ray enters through on this axis. + if (t1 > t2) {std::swap(t1, t2);} + if (t1 > tEnter) {tEnter = t1; enterNormal = vec2(0.0f, 0.0f); enterNormal[axis] = -sign;} + if (t2 < tExit) {tExit = t2; exitNormal = vec2(0.0f, 0.0f); exitNormal[axis] = sign;} - // Perform the ray cast in local space. - vec2 unitVector = glm::normalize(ray.direction); - vec2 min = box->getPosition() - box->getSize() * 0.5f; - vec2 max = box->getPosition() + box->getSize() * 0.5f; - - float tMin = std::max(std::min(min.x, max.x), std::min(min.y, max.y)); - float tMax = std::min(std::max(min.x, max.x), std::max(min.y, max.y)); - - if (tMax < 0 || tMin > tMax) {return RaycastResult();} - - float t = (tMin < 0.0f) ? tMax : tMin; - bool hit = t > 0.0f; - if (!hit) {return RaycastResult();} + } - // Get the point and normal in the box's local space. - vec2 point = ray.direction * t + ray.origin; - vec2 normal = glm::normalize(ray.origin - point); + if (tEnter > tExit || tExit < 0.0f) {return RaycastResult();} - // Rotate the result back into global space. - rotateVector(point, centre, rCos, -rSin); - rotateVector(normal, vec2(0.0f, 0.0f), rCos, -rSin); + // If the ray starts inside the box, report where it leaves. + float t = tEnter; + vec2 normal = enterNormal; + if (tEnter < 0.0f) {t = tExit; normal = exitNormal;} + // Rotate the normal back into global space. + rotateVector(normal, vec2(0.0f, 0.0f), rCos, rSin); + vec2 point = ray.origin + ray.direction * t; return RaycastResult(point, normal, t, box->getRigidbody()->getEntity()); } @@ -90,13 +108,17 @@ namespace Pancake { float b2 = originToCircleColliderLengthSquared - (a * a); if (radiusSquared - b2 < 0.0f) {return RaycastResult();} + // If the ray starts inside the circle, report where it leaves. float f = sqrtf(radiusSquared - b2); float t = 0; if (originToCircleColliderLengthSquared < radiusSquared) {t = a + f;} else {t = a - f;} + // The circle is behind the ray. + if (t < 0.0f) {return RaycastResult();} + vec2 point = ray.direction * t + ray.origin; - vec2 normal = glm::normalize(ray.origin - point); + vec2 normal = glm::normalize(point - position); return RaycastResult(point, normal, t, circle->getRigidbody()->getEntity()); } diff --git a/src/physics/rigidbody.cpp b/src/physics/rigidbody.cpp index 1167e1f..6121b10 100644 --- a/src/physics/rigidbody.cpp +++ b/src/physics/rigidbody.cpp @@ -8,6 +8,8 @@ namespace Pancake { Rigidbody::Rigidbody() : Component("Rigidbody") { + this->world = nullptr; + this->force = glm::vec2(0.0f, 0.0f); this->velocity = glm::vec2(0.0f, 0.0f); this->torque = 0.0f; @@ -15,6 +17,7 @@ namespace Pancake { this->restitution = 1.0f; this->friction = 0.0f; + this->gravityScale = 1.0f; this->lastPosition = glm::vec2(0.0f, 0.0f); this->lastRotation = 0.0f; @@ -33,15 +36,29 @@ namespace Pancake { } + Rigidbody::~Rigidbody() { + if (this->world != nullptr) {this->world->remove(this);} + for (Collider* c : this->colliders) {delete c;} + } + void Rigidbody::start() { - Window::getScene()->getPhysics()->add(this); + Scene* scene = Window::getScene(); + if (scene != nullptr) {scene->getPhysics()->add(this);} } void Rigidbody::end() { - Window::getScene()->getPhysics()->remove(this); + if (this->world != nullptr) {this->world->remove(this);} this->clearColliders(); } + World* Rigidbody::getWorld() { + return this->world; + } + + void Rigidbody::setWorld(World* world) { + this->world = world; + } + json Rigidbody::serialise() { json j = this->Component::serialise(); @@ -58,6 +75,7 @@ namespace Pancake { j.emplace("angularVelocity", this->angularVelocity); j.emplace("restitution", this->restitution); j.emplace("friction", this->friction); + j.emplace("gravityScale", this->gravityScale); j.emplace("sensor", this->sensor); j.emplace("fixedOrientation", this->fixedOrientation); @@ -96,15 +114,7 @@ namespace Pancake { if (!j.contains("sensor") || !j["sensor"].is_boolean()) {return false;} if (!j.contains("fixedOrientation") || !j["fixedOrientation"].is_boolean()) {return false;} - this->setForce(glm::vec2(j["force"][0], j["force"][1])); - this->setVelocity(glm::vec2(j["velocity"][0], j["velocity"][1])); - this->setTorque(j["torque"]); - this->setAngularVelocity(j["angularVelocity"]); - this->setRestitution(j["restitution"]); - this->setFriction(j["friction"]); - this->setSensor(j["sensor"]); - this->setFixedOrientation(j["fixedOrientation"]); - + // Load the colliders first, as setting velocities and forces is ignored for bodies without mass. if (j.contains("colliders") && j["colliders"].is_array()) { for (auto element : j["colliders"]) { if (element.is_object()) { @@ -117,6 +127,18 @@ namespace Pancake { } } + this->setForce(glm::vec2(j["force"][0], j["force"][1])); + this->setVelocity(glm::vec2(j["velocity"][0], j["velocity"][1])); + this->setTorque(j["torque"]); + this->setAngularVelocity(j["angularVelocity"]); + this->setRestitution(j["restitution"]); + this->setFriction(j["friction"]); + this->setSensor(j["sensor"]); + this->setFixedOrientation(j["fixedOrientation"]); + + // Optional, as older save files predate world gravity. + if (j.contains("gravityScale") && j["gravityScale"].is_number()) {this->setGravityScale(j["gravityScale"]);} + if (j.contains("forceGenerators") && j["forceGenerators"].is_array()) { for (auto element : j["forceGenerators"]) { if (element.is_string()) { @@ -230,6 +252,10 @@ namespace Pancake { return this->friction; } + float Rigidbody::getGravityScale() { + return this->gravityScale; + } + float Rigidbody::getMass() { if (!this->massDirty) {return this->mass;} @@ -372,18 +398,10 @@ namespace Pancake { // Add the force generator to the local collection. this->forceGenerators.insert(type); - // If the scene exists. - Scene* scene = Window::getScene(); - if (scene == nullptr) {return this;} - - // If the physics engine exists. - World* world = scene->getPhysics(); - if (world == nullptr) {return this;} - // If the rigidbody has already been added, we need to update the registrations. // If it hasn't been added, it will be updated when the rigidbody is added to the engine. - if (!world->has(this)) {return this;} - world->addForceRegistration(type, this); + if (this->world == nullptr) {return this;} + this->world->addForceRegistration(type, this); return this; } @@ -398,18 +416,9 @@ namespace Pancake { // Remove the force generator name from the local collection. this->forceGenerators.erase(type); - // If the scene exists. - Scene* scene = Window::getScene(); - if (scene == nullptr) {return this;} - - // If the physics engine exists. - World* world = scene->getPhysics(); - if (world == nullptr) {return this;} - // If the rigidbody has already been added, we need to update the registrations. - // If it hasn't been added, it will be updated when the rigidbody is added to the engine. - if (!world->has(this)) {return this;} - world->removeForceRegistration(type, this); + if (this->world == nullptr) {return this;} + this->world->removeForceRegistration(type, this); return this; } @@ -483,6 +492,11 @@ namespace Pancake { return this; } + Rigidbody* Rigidbody::setGravityScale(float scale) { + this->gravityScale = scale; + return this; + } + Rigidbody* Rigidbody::setSensor(bool sensor) { this->sensor = sensor; return this; @@ -533,7 +547,8 @@ namespace Pancake { float rotation = 0.0f; // Update linear - this->velocity += this->force * (dt / this->getMass()); + glm::vec2 gravity = this->world != nullptr ? this->world->getGravity() * this->gravityScale : glm::vec2(0.0f, 0.0f); + this->velocity += (this->force * this->getInverseMass() + gravity) * dt; displacement = this->velocity * dt; this->getEntity()->addPosition(displacement); @@ -575,9 +590,9 @@ namespace Pancake { (this->boundsDirty); // If the bounding box has changed we need to update the spatial hash grid. - if (this->boundsDirty) { + if (this->boundsDirty && this->world != nullptr) { std::pair bounds = this->getBounds(); - Window::getScene()->getPhysics()->getGrid()->update(this, bounds.first, bounds.second); + this->world->getGrid()->update(this, bounds.first, bounds.second); } // Update last position and rotation diff --git a/src/physics/world.cpp b/src/physics/world.cpp index 493baa1..eaeedbc 100644 --- a/src/physics/world.cpp +++ b/src/physics/world.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include #include @@ -17,8 +18,6 @@ namespace Pancake { public: - glm::vec2 aPosition; - glm::vec2 bPosition; glm::vec2 aVelocity; glm::vec2 bVelocity; float aAngularVelocity; @@ -29,8 +28,6 @@ namespace Pancake { float bAngularOrbitalSpeed; ImpulseResult() { - this->aPosition = glm::vec2(0.0f, 0.0f); - this->bPosition = glm::vec2(0.0f, 0.0f); this->aVelocity = glm::vec2(0.0f, 0.0f); this->bVelocity = glm::vec2(0.0f, 0.0f); this->aAngularVelocity = 0.0f; @@ -43,8 +40,6 @@ namespace Pancake { ImpulseResult operator*(float scalar) const { ImpulseResult result; - result.aPosition = this->aPosition * scalar; - result.bPosition = this->bPosition * scalar; result.aVelocity = this->aVelocity * scalar; result.bVelocity = this->bVelocity * scalar; result.aAngularVelocity = this->aAngularVelocity * scalar; @@ -57,8 +52,6 @@ namespace Pancake { } ImpulseResult& operator+=(const ImpulseResult& rhs) { - this->aPosition += rhs.aPosition; - this->bPosition += rhs.bPosition; this->aVelocity += rhs.aVelocity; this->bVelocity += rhs.bVelocity; this->aAngularVelocity += rhs.aAngularVelocity; @@ -73,6 +66,7 @@ namespace Pancake { }; const int IMPULSE_ITERATIONS = 6; + const int MAX_STEPS_PER_UPDATE = 5; inline glm::vec2 perp(glm::vec2 v) { return glm::rotate(v, glm::half_pi()); @@ -88,9 +82,6 @@ namespace Pancake { ImpulseResult result; if (a->hasInfiniteMass() && b->hasInfiniteMass()) {return result;} - DebugDraw::drawBox(m.point, glm::vec2(0.1f, 0.1f), 0.0f, vec3(1.0f, 0.0f, 0.0f), 10); - DebugDraw::drawLine(m.point, m.point + m.normal * 0.3f, vec3(0.0f, 0.0f, 1.0f), 10); - float invMassA = a->getInverseMass(); float invMassB = b->getInverseMass(); float invMoiA = a->getInverseMomentOfInertia(); @@ -144,40 +135,67 @@ namespace Pancake { } - // Position correction. - if (!a->hasInfiniteMass() && !b->hasInfiniteMass()) { - const float slop = 0.01f; - const float percent = 0.4f; - glm::vec2 correction = std::max(m.depth - slop, 0.0f) / (invMassA + invMassB) * percent * m.normal; - result.aPosition -= correction * invMassA; - result.bPosition += correction * invMassB; + return result; + } + + // Push overlapping bodies apart, in proportion to their inverse masses, to stop them sinking into each other. + void correctPositions(Rigidbody* a, Rigidbody* b, const std::vector& manifolds) { + + const float slop = 0.005f; + const float percent = 0.8f; + + float invMassA = a->getInverseMass(); + float invMassB = b->getInverseMass(); + if (invMassA + invMassB <= 0.0f) {return;} + + // Weight each contact by its depth, as with the impulses. + float depthTotal = 0.0f; + for (const CollisionManifold& m : manifolds) {depthTotal += m.depth;} + if (depthTotal <= 0.0f) {return;} + + glm::vec2 correction = glm::vec2(0.0f, 0.0f); + for (const CollisionManifold& m : manifolds) { + float magnitude = std::max(m.depth - slop, 0.0f) * percent / (invMassA + invMassB); + correction += m.normal * magnitude * (m.depth / depthTotal); } - - if (b->hasInfiniteMass()) {result.aPosition -= m.depth * m.normal;} - if (a->hasInfiniteMass()) {result.bPosition += m.depth * m.normal;} - return result; + a->getEntity()->addPosition(-correction * invMassA); + b->getEntity()->addPosition(correction * invMassB); + } } World::World(float timeStep, glm::vec2 gravity) { this->grid = new SpatialHashGrid(4); + this->gravity = gravity; this->timeStep = timeStep; this->time = 0.0f; + this->debug = false; } World::~World() { - // Force generators are components, so they are owned and deleted by their entities. + + // Rigidbodies and force generators are components, so they are owned and deleted by their entities. + // Detach any that are still registered so they don't reference this world after it is gone. + for (Rigidbody* rigidbody : this->rigidbodies) {rigidbody->setWorld(nullptr);} + for (ForceGenerator* force : this->forces) {force->setWorld(nullptr);} delete this->grid; + } void World::update(float dt) { + + // Step at a fixed rate. If we fall too far behind, drop the excess time rather than spiralling. this->time += dt; - while (this->time >= 0.0f) { + int steps = 0; + while (this->time >= this->timeStep && steps < MAX_STEPS_PER_UPDATE) { this->fixedUpdate(); this->time -= this->timeStep; + steps++; } + if (steps == MAX_STEPS_PER_UPDATE) {this->time = 0.0f;} + } void World::fixedUpdate() { @@ -256,6 +274,14 @@ namespace Pancake { } + // Visualise the contacts. + if (this->debug) { + for (CollisionManifold manifold : results) { + DebugDraw::drawBox(manifold.point, glm::vec2(0.1f, 0.1f), 0.0f, vec3(1.0f, 0.0f, 0.0f), 10); + DebugDraw::drawLine(manifold.point, manifold.point + manifold.normal * 0.3f, vec3(0.0f, 0.0f, 1.0f), 10); + } + } + // Add the bodies to the impulse list if they are not sensors. if (!rigidbody1->isSensor() && !rigidbody2->isSensor()) { collisions.push_back(std::make_pair(rigidbody1, rigidbody2)); @@ -282,12 +308,15 @@ namespace Pancake { Rigidbody* r2 = collisions[i].second; ImpulseResult sum; + // Weight each contact by its depth, or equally if they are only touching. + int count = manifolds[i].size(); float depthTotal = 0.0f; - for (int j = 0; j < manifolds[i].size(); j++) {depthTotal += manifolds[i][j].depth;} - for (int j = 0; j < manifolds[i].size(); j++) {sum += applyImpulse(r1, r2, manifolds[i][j]) * (manifolds[i][j].depth / depthTotal);} + for (int j = 0; j < count; j++) {depthTotal += manifolds[i][j].depth;} + for (int j = 0; j < count; j++) { + float weight = depthTotal > 0.0f ? manifolds[i][j].depth / depthTotal : 1.0f / count; + sum += applyImpulse(r1, r2, manifolds[i][j]) * weight; + } - r1->getEntity()->addPosition(sum.aPosition); - r2->getEntity()->addPosition(sum.bPosition); r1->addVelocity(sum.aVelocity); r2->addVelocity(sum.bVelocity); r1->addAngularVelocity(sum.aAngularVelocity); @@ -300,22 +329,56 @@ namespace Pancake { } + // Correct positions once, after the velocities have been resolved. + for (int i = 0; i < collisions.size(); i++) { + correctPositions(collisions[i].first, collisions[i].second, manifolds[i]); + } + } void World::render() { - int n = this->rigidbodies.size(); - for (int i = 0; i < n; i++) { + if (!this->debug) {return;} + + // Draw the bounding box and colliders of every rigidbody. + for (Rigidbody* rigidbody : this->rigidbodies) { - Rigidbody* rigidbody = this->rigidbodies[i]; std::pair bounds = rigidbody->getBounds(); DebugDraw::drawAABB(bounds.first, bounds.second, vec3(0.5f, 0.0f, 1.0f), 1); + for (Collider* collider : rigidbody->getColliders()) { + BoxCollider* box = dynamic_cast(collider); + CircleCollider* circle = dynamic_cast(collider); + if (box != nullptr) {DebugDraw::drawBox(box->getPosition(), box->getSize(), box->getRotation(), vec3(0.0f, 1.0f, 0.0f), 1);} + if (circle != nullptr) {DebugDraw::drawCircleCollider(circle->getPosition(), circle->getRadius(), vec3(0.0f, 1.0f, 0.0f), 1);} + } + } } + glm::vec2 World::getGravity() { + return this->gravity; + } + + void World::setGravity(glm::vec2 gravity) { + this->gravity = gravity; + } + + bool World::hasDebugDraw() { + return this->debug; + } + + void World::setDebugDraw(bool debug) { + this->debug = debug; + } + void World::add(Rigidbody* rigidbody) { + + // A rigidbody can only belong to one world at a time. + if (this->has(rigidbody)) {return;} + if (rigidbody->getWorld() != nullptr) {rigidbody->getWorld()->remove(rigidbody);} + rigidbody->setWorld(this); // Insert the rigidbody in the index and the vector. this->rigidbodies.push_back(rigidbody); @@ -341,6 +404,8 @@ namespace Pancake { void World::remove(Rigidbody* rigidbody) { // Remove the rigidbody from the index and the spatial hash grid. + if (!this->has(rigidbody)) {return;} + rigidbody->setWorld(nullptr); this->rigidbodiesIndex.erase(rigidbody); this->grid->remove(rigidbody); @@ -366,6 +431,7 @@ namespace Pancake { RaycastResult World::raycast(Ray ray) { + // An unbounded ray could cross any number of cells, so test every body. RaycastResult best; for (Rigidbody* rigidbody : this->rigidbodies) { RaycastResult current = Raycast::raycast(rigidbody, ray); @@ -375,6 +441,48 @@ namespace Pancake { return best; } + RaycastResult World::raycast(Ray ray, float maxDistance) { + + if (!std::isfinite(maxDistance)) {return this->raycast(ray);} + + // Walk the spatial hash grid cells along the ray (Amanatides & Woo), testing the bodies in each cell. + float size = (float) this->grid->getGridSize(); + int x = (int) std::floor(ray.origin.x / size); + int y = (int) std::floor(ray.origin.y / size); + int stepX = ray.direction.x > 0.0f ? 1 : -1; + int stepY = ray.direction.y > 0.0f ? 1 : -1; + + float nextX = (x + (stepX > 0 ? 1 : 0)) * size; + float nextY = (y + (stepY > 0 ? 1 : 0)) * size; + float tMaxX = ray.direction.x != 0.0f ? (nextX - ray.origin.x) / ray.direction.x : FLT_MAX; + float tMaxY = ray.direction.y != 0.0f ? (nextY - ray.origin.y) / ray.direction.y : FLT_MAX; + float tDeltaX = ray.direction.x != 0.0f ? size / std::abs(ray.direction.x) : FLT_MAX; + float tDeltaY = ray.direction.y != 0.0f ? size / std::abs(ray.direction.y) : FLT_MAX; + + RaycastResult best; + std::unordered_set tested; + + while (true) { + + for (Rigidbody* rigidbody : this->grid->get(x, y)) { + if (!tested.insert(rigidbody).second) {continue;} + RaycastResult current = Raycast::raycast(rigidbody, ray); + if (current.hit != nullptr && current.distance <= maxDistance && current.distance < best.distance) {best = current;} + } + + // A hit closer than the far edge of this cell cannot be beaten by bodies in later cells. + float tCellExit = std::min(tMaxX, tMaxY); + if (best.hit != nullptr && best.distance <= tCellExit) {break;} + if (tCellExit > maxDistance) {break;} + + if (tMaxX < tMaxY) {x += stepX; tMaxX += tDeltaX;} + else {y += stepY; tMaxY += tDeltaY;} + + } + + return best; + } + SpatialHashGrid* World::getGrid() { return this->grid; @@ -396,6 +504,7 @@ namespace Pancake { // Add the force to the index. this->forcesIndex.insert({type, force}); this->forces.push_back(force); + force->setWorld(this); // See if any rigidbodies require this force generator. for (Rigidbody* rigidbody : this->rigidbodies) { @@ -417,7 +526,9 @@ namespace Pancake { if (it == this->forcesIndex.end()) {return;} // Remove all registrations that use this force. - this->registry.remove(this->getForceGenerator(type)); + ForceGenerator* force = it->second; + this->registry.remove(force); + if (force->getWorld() == this) {force->setWorld(nullptr);} // Remove the force from the index. this->forcesIndex.erase(type); From 6cb1806efefacd96ac4d927527dafbb0b1c448e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 00:04:19 +0000 Subject: [PATCH 4/9] Add scene spatial queries, console commands and an entity inspector. - Scene keeps its (previously unused) entity spatial hash grid in sync and offers getEntitiesIn, getEntitiesNear and getEntitiesAt. - Scene files store world gravity and restore the scene name, read audio from the "audio" key instead of "fonts", and report missing files correctly. - The console parses arguments and supports help, quit, clear, reset, save, load, debug, gravity, entities and inspector. Games can add their own with Console::registerCommand, and run commands with Console::execute. - New Inspector window: click an entity in the game to select it and edit its components, or pick it from the entity list. It also edits scene gravity and physics debug drawing. - Pancake::gravity, Pancake::debug and Pancake::inspector facade calls. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSbmWwU5zh3ujWmjioWg1a --- include/pancake/core/console.hpp | 21 +++- include/pancake/core/engine.hpp | 6 ++ include/pancake/core/inspector.hpp | 19 ++++ include/pancake/core/scene.hpp | 11 ++ include/pancake/pancake.hpp | 2 + src/core/console.cpp | 162 +++++++++++++++++++++++++---- src/core/engine.cpp | 17 +++ src/core/inspector.cpp | 138 ++++++++++++++++++++++++ src/core/scene.cpp | 117 +++++++++++++++++++-- src/core/window.cpp | 2 + 10 files changed, 461 insertions(+), 34 deletions(-) create mode 100644 include/pancake/core/inspector.hpp create mode 100644 src/core/inspector.cpp diff --git a/include/pancake/core/console.hpp b/include/pancake/core/console.hpp index aae603e..9d67784 100644 --- a/include/pancake/core/console.hpp +++ b/include/pancake/core/console.hpp @@ -1,9 +1,16 @@ #pragma once +#include +#include +#include + namespace Pancake { namespace Console { - + + // A command receives its arguments, excluding the command name itself. + typedef std::function args)> Command; + void open(); void close(); void set(bool state); @@ -11,6 +18,16 @@ namespace Pancake { void render(); bool opened(); + // Run a command as if it was typed into the console. + void execute(std::string line); + + // Add a command, replacing any existing command with the same name. + void registerCommand(std::string name, std::string description, Command command); + + // Write a line to the console output. + void print(std::string message); + void clear(); + } -} \ No newline at end of file +} diff --git a/include/pancake/core/engine.hpp b/include/pancake/core/engine.hpp index a5a7b5f..4812121 100644 --- a/include/pancake/core/engine.hpp +++ b/include/pancake/core/engine.hpp @@ -24,8 +24,14 @@ namespace Pancake { void projection(vec2 size); void projection(float height); + void gravity(vec2 gravity); + void debug(bool state); + void console(bool state); void console(); + + void inspector(bool state); + void inspector(); Scene* getScene(); Entity* getEntity(int id); diff --git a/include/pancake/core/inspector.hpp b/include/pancake/core/inspector.hpp new file mode 100644 index 0000000..a10aefa --- /dev/null +++ b/include/pancake/core/inspector.hpp @@ -0,0 +1,19 @@ +#pragma once + +namespace Pancake { + + // An ImGui window for viewing and editing the scene. While open, left clicking in the + // game selects the entity under the mouse and shows the editor for each of its components. + namespace Inspector { + void open(); + void close(); + void set(bool state); + void toggle(); + bool opened(); + void render(); + + int getSelected(); + void select(int entityId); + } + +} diff --git a/include/pancake/core/scene.hpp b/include/pancake/core/scene.hpp index c0cf8f6..5b7cb86 100644 --- a/include/pancake/core/scene.hpp +++ b/include/pancake/core/scene.hpp @@ -29,6 +29,10 @@ namespace Pancake { Renderer* renderer; World* physics; SpatialHashGrid* grid; + std::unordered_map> gridBounds; + + std::pair computeBounds(Entity* entity); + void updateGrid(Entity* entity); public: @@ -55,6 +59,13 @@ namespace Pancake { Entity* getEntity(int id); Component* getComponent(int id); std::unordered_map* getComponents(); + std::vector getEntities(); + + // Spatial queries. An entity's bounds cover its transform and its rigidbody, if it has one. + std::pair getBounds(Entity* entity); + std::vector getEntitiesIn(glm::vec2 min, glm::vec2 max); + std::vector getEntitiesNear(glm::vec2 position, float radius); + std::vector getEntitiesAt(glm::vec2 position); }; diff --git a/include/pancake/pancake.hpp b/include/pancake/pancake.hpp index e966b0a..afc13fd 100644 --- a/include/pancake/pancake.hpp +++ b/include/pancake/pancake.hpp @@ -18,6 +18,8 @@ #include "pancake/core/console.hpp" #include "pancake/core/engine.hpp" #include "pancake/core/entity.hpp" +#include "pancake/core/factory.hpp" +#include "pancake/core/inspector.hpp" #include "pancake/core/listener.hpp" #include "pancake/core/scene.hpp" #include "pancake/core/spatial.hpp" diff --git a/src/core/console.cpp b/src/core/console.cpp index f18f3f7..3470510 100644 --- a/src/core/console.cpp +++ b/src/core/console.cpp @@ -1,25 +1,107 @@ -#include "pancake/core/console.hpp" -#include "pancake/core/window.hpp" +#include +#include +#include #include -#include -#include -using std::vector; -using std::string; +#include "pancake/core/console.hpp" +#include "pancake/core/inspector.hpp" +#include "pancake/core/window.hpp" +#include "pancake/graphics/debugdraw.hpp" namespace Pancake { namespace { + struct Entry { + std::string description; + Console::Command command; + }; + bool active = false; - vector output; + bool scrollToBottom = false; + std::vector output; + std::map commands; char input[256]; - void parse(const char* command) { + std::vector tokenise(std::string line) { + std::vector tokens; + std::istringstream stream(line); + std::string token; + while (stream >> token) {tokens.push_back(token);} + return tokens; + } + + // Parses "on", "off", or toggles the current value when no argument is given. + bool parseState(std::vector& args, bool current) { + if (args.empty()) {return !current;} + return args[0] == "on" || args[0] == "true" || args[0] == "1"; + } - if (strcmp(command, "help") == 0) {output.push_back("Available commands: help, quit");} - else if (strcmp(command, "quit") == 0) {Window::stop();} - else {output.push_back("Unknown command. Type 'help' for a list of available commands.");} + void registerDefaults() { + + static bool registered = false; + if (registered) {return;} + registered = true; + + Console::registerCommand("help", "List all commands.", [](std::vector args) { + for (auto const& c : commands) {Console::print(c.first + " - " + c.second.description);} + }); + + Console::registerCommand("quit", "Stop the engine.", [](std::vector args) { + Window::stop(); + }); + + Console::registerCommand("clear", "Clear the console output.", [](std::vector args) { + Console::clear(); + }); + + Console::registerCommand("reset", "Replace the scene with an empty one.", [](std::vector args) { + Window::reset(); + }); + + Console::registerCommand("save", "save : Save the scene to a file.", [](std::vector args) { + if (args.size() != 1) {Console::print("Usage: save "); return;} + Window::save(args[0]); + Console::print("Saving scene to " + args[0]); + }); + + Console::registerCommand("load", "load : Load a scene file into the current scene.", [](std::vector args) { + if (args.size() != 1) {Console::print("Usage: load "); return;} + Window::load(args[0]); + }); + + Console::registerCommand("debug", "debug [on|off]: Draw physics colliders and contacts.", [](std::vector args) { + Scene* scene = Window::getScene(); + if (scene == nullptr) {return;} + bool state = parseState(args, scene->getPhysics()->hasDebugDraw()); + scene->getPhysics()->setDebugDraw(state); + Console::print(std::string("Physics debug drawing ") + (state ? "on" : "off")); + }); + + Console::registerCommand("gravity", "gravity [x y]: Show or set the world gravity.", [](std::vector args) { + Scene* scene = Window::getScene(); + if (scene == nullptr) {return;} + if (args.size() == 2) { + try {scene->getPhysics()->setGravity(glm::vec2(std::stof(args[0]), std::stof(args[1])));} + catch (const std::exception& e) {Console::print("Usage: gravity "); return;} + } + glm::vec2 g = scene->getPhysics()->getGravity(); + Console::print("Gravity: " + std::to_string(g.x) + ", " + std::to_string(g.y)); + }); + + Console::registerCommand("entities", "List the entities in the scene.", [](std::vector args) { + Scene* scene = Window::getScene(); + if (scene == nullptr) {return;} + for (Entity* e : scene->getEntities()) { + std::string line = std::to_string(e->getId()) + ":"; + for (Component* c : e->getComponents()) {line += " " + c->getType();} + Console::print(line); + } + }); + + Console::registerCommand("inspector", "inspector [on|off]: Show the entity inspector.", [](std::vector args) { + Inspector::set(parseState(args, Inspector::opened())); + }); } @@ -47,21 +129,25 @@ namespace Pancake { // If not active, don't render the terminal. if (!active) {return;} - ImGui::Begin("Console"); + ImGui::SetNextWindowSize(ImVec2(480, 300), ImGuiCond_FirstUseEver); + if (!ImGui::Begin("Console", &active)) {ImGui::End(); return;} // Create a scrollable text area for the terminal output - ImGui::BeginChild("Scrolling"); - for (const auto& message : output) {ImGui::Text(message.c_str());} + float footer = ImGui::GetStyle().ItemSpacing.y + ImGui::GetFrameHeightWithSpacing(); + ImGui::BeginChild("Scrolling", ImVec2(0, -footer)); + for (const auto& message : output) {ImGui::TextUnformatted(message.c_str());} + if (scrollToBottom) {ImGui::SetScrollHereY(1.0f); scrollToBottom = false;} ImGui::EndChild(); - // Create a text input box for entering commands - ImGui::InputText("Command", input, sizeof(input)); - - // If the enter key is pressed, parse the command and add it to the terminal buffer - if (ImGui::IsKeyPressed(ImGui::GetKeyIndex(ImGuiKey_Enter)) && strlen(input) > 0) { - output.push_back("> " + std::string(input)); - parse(input); - memset(input, 0, sizeof(input)); + // Create a text input box for entering commands, executed when enter is pressed. + if (ImGui::InputText("Command", input, sizeof(input), ImGuiInputTextFlags_EnterReturnsTrue)) { + if (strlen(input) > 0) { + output.push_back("> " + std::string(input)); + execute(input); + memset(input, 0, sizeof(input)); + scrollToBottom = true; + } + ImGui::SetKeyboardFocusHere(-1); } ImGui::End(); @@ -72,7 +158,37 @@ namespace Pancake { return active; } + void execute(std::string line) { + + registerDefaults(); + std::vector tokens = tokenise(line); + if (tokens.empty()) {return;} + + auto it = commands.find(tokens[0]); + if (it == commands.end()) { + print("Unknown command. Type 'help' for a list of available commands."); + return; + } + + std::vector args(tokens.begin() + 1, tokens.end()); + it->second.command(args); + + } + + void registerCommand(std::string name, std::string description, Command command) { + registerDefaults(); + commands[name] = {description, command}; + } + + void print(std::string message) { + output.push_back(message); + scrollToBottom = true; + } + + void clear() { + output.clear(); + } + } } - diff --git a/src/core/engine.cpp b/src/core/engine.cpp index 1cb633c..0cc0102 100644 --- a/src/core/engine.cpp +++ b/src/core/engine.cpp @@ -2,6 +2,7 @@ #include "pancake/core/engine.hpp" #include "pancake/audio/audioengine.hpp" #include "pancake/core/window.hpp" +#include "pancake/core/inspector.hpp" #include "pancake/core/factory.hpp" #include "pancake/core/component.hpp" @@ -64,6 +65,14 @@ namespace Pancake { Window::setProjectionHeight(height); } + void gravity(vec2 gravity) { + if (Window::getScene() != nullptr) {Window::getScene()->getPhysics()->setGravity(gravity);} + } + + void debug(bool state) { + if (Window::getScene() != nullptr) {Window::getScene()->getPhysics()->setDebugDraw(state);} + } + void console(bool state) { Console::set(state); } @@ -72,6 +81,14 @@ namespace Pancake { Console::toggle(); } + void inspector(bool state) { + Inspector::set(state); + } + + void inspector() { + Inspector::toggle(); + } + Scene* getScene() { return Window::getScene(); } diff --git a/src/core/inspector.cpp b/src/core/inspector.cpp new file mode 100644 index 0000000..b77834d --- /dev/null +++ b/src/core/inspector.cpp @@ -0,0 +1,138 @@ +#include +#include +#include +#include + +#include "pancake/core/inspector.hpp" +#include "pancake/core/listener.hpp" +#include "pancake/core/window.hpp" +#include "pancake/graphics/debugdraw.hpp" + +namespace Pancake { + + namespace { + + bool active = false; + int selected = -1; // Stored as an id so a deleted entity is never dereferenced. + + // Find the entity under the mouse, first by the picking texture, then by entity bounds. + int pick() { + + Scene* scene = Window::getScene(); + if (scene == nullptr) {return -1;} + + int x = (int) MouseListener::getX(); + int y = (int) MouseListener::getY(); + if (x >= 0 && y >= 0 && x < Window::getWidth() && y < Window::getHeight()) { + int id = Window::readPixel(x, y); + if (scene->getEntity(id) != nullptr) {return id;} + } + + glm::vec2 world = glm::vec2(MouseListener::getWorldX(), MouseListener::getWorldY()); + std::vector entities = scene->getEntitiesAt(world); + if (entities.empty()) {return -1;} + + // Prefer the smallest entity under the mouse, which is usually the one meant. + Entity* best = entities[0]; + float bestArea = -1.0f; + for (Entity* e : entities) { + std::pair b = scene->getBounds(e); + float area = (b.second.x - b.first.x) * (b.second.y - b.first.y); + if (bestArea < 0.0f || area < bestArea) {best = e; bestArea = area;} + } + return best->getId(); + + } + + } + + namespace Inspector { + + void open() { + active = true; + } + + void close() { + active = false; + } + + void set(bool state) { + active = state; + } + + void toggle() { + active = !active; + } + + bool opened() { + return active; + } + + int getSelected() { + return selected; + } + + void select(int entityId) { + selected = entityId; + } + + void render() { + + if (!active) {return;} + Scene* scene = Window::getScene(); + if (scene == nullptr) {return;} + + // Select the entity under the mouse when clicking on the game, not on a window. + if (MouseListener::isMouseBeginDown(GLFW_MOUSE_BUTTON_LEFT) && !ImGui::GetIO().WantCaptureMouse) { + selected = pick(); + } + + ImGui::SetNextWindowSize(ImVec2(360, 480), ImGuiCond_FirstUseEver); + if (!ImGui::Begin("Inspector", &active)) {ImGui::End(); return;} + + // Scene settings. + if (ImGui::CollapsingHeader("Scene", ImGuiTreeNodeFlags_DefaultOpen)) { + + ImGui::Text("Name: %s", scene->getName().c_str()); + ImGui::Text("Entities: %d", (int) scene->getEntities().size()); + + glm::vec2 gravity = scene->getPhysics()->getGravity(); + if (ImGui::DragFloat2("Gravity", &gravity[0], 0.1f)) {scene->getPhysics()->setGravity(gravity);} + + bool debug = scene->getPhysics()->hasDebugDraw(); + if (ImGui::Checkbox("Physics debug", &debug)) {scene->getPhysics()->setDebugDraw(debug);} + + } + + // Entity list, so entities without a visible shape can still be selected. + if (ImGui::CollapsingHeader("Entities")) { + for (Entity* e : scene->getEntities()) { + std::string label = std::to_string(e->getId()); + for (Component* c : e->getComponents()) {label += " " + c->getType();} + if (ImGui::Selectable(label.c_str(), e->getId() == selected)) {selected = e->getId();} + } + } + + // The selected entity's editor. + Entity* entity = scene->getEntity(selected); + if (entity == nullptr || entity->isDead()) { + ImGui::Spacing(); + ImGui::TextDisabled("Click an entity to inspect it."); + } else { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Text("Entity %d", entity->getId()); + ImGui::PushID(entity->getId()); + entity->imgui(); + ImGui::PopID(); + std::pair b = scene->getBounds(entity); + DebugDraw::drawAABB(b.first, b.second, vec3(1.0f, 1.0f, 0.0f), 1); + } + + ImGui::End(); + + } + + } + +} diff --git a/src/core/scene.cpp b/src/core/scene.cpp index d8f99c6..f847c39 100644 --- a/src/core/scene.cpp +++ b/src/core/scene.cpp @@ -7,6 +7,7 @@ #include "pancake/core/scene.hpp" #include "pancake/core/listener.hpp" +#include "pancake/physics/rigidbody.hpp" #include "pancake/asset/assetpool.hpp" #include "pancake/asset/spritesheet.hpp" @@ -37,6 +38,8 @@ namespace Pancake { delete e; } + this->gridBounds.clear(); + // Delete all scene elements. delete this->camera; delete this->renderer; @@ -59,6 +62,7 @@ namespace Pancake { this->camera->adjustProjection(); this->camera->update(dt); this->physics->update(dt); // This will update colliding components + this->physics->render(); // Queue physics debug lines, if enabled. // Update all the entities. std::deque dead; @@ -72,14 +76,20 @@ namespace Pancake { Entity* e = this->entities[i]; this->entityIndex.erase(e->getId()); this->entities.erase(this->entities.begin() + i); + this->grid->remove(e); + this->gridBounds.erase(e); delete e; } + // Keep the spatial hash grid in sync with entities that have moved. + for (Entity* e : this->entities) { + if (!e->isDead()) {this->updateGrid(e);} + } + } void Scene::render() { this->renderer->render(); - this->physics->render(); } nlohmann::json Scene::serialise() { @@ -87,6 +97,7 @@ namespace Pancake { nlohmann::json j; j.emplace("name", this->name); j.emplace("camera", this->camera->serialise()); + j.emplace("gravity", nlohmann::json::array({this->physics->getGravity().x, this->physics->getGravity().y})); j.emplace("fonts", FontPool::serialise()); j.emplace("spritesheets", Spritesheet::serialise()); j.emplace("sprites", SpritePool::serialise()); @@ -113,23 +124,31 @@ namespace Pancake { void Scene::load(std::string filename) { std::ifstream f(filename); - nlohmann::json j; + if (!f.is_open()) { + std::cout << "ERROR::SCENE::LOAD::FILE_NOT_FOUND\n"; + return; + } + nlohmann::json j; try { - std::ifstream f(filename); j = nlohmann::json::parse(f); } - - catch (const std::ifstream::failure& e) { - std::cout << "ERROR::SCENE::LOAD::FILE_NOT_FOUND\n"; - return; - } catch (const nlohmann::json::exception& e) { std::cout << "ERROR::SCENE::LOAD::JSON_PARSE_ERROR\n"; return; } + // Load the scene name. + if (j.contains("name") && j["name"].is_string()) { + this->name = j["name"]; + } + + // Load the world gravity. + if (j.contains("gravity") && j["gravity"].is_array() && j["gravity"].size() == 2 && j["gravity"][0].is_number() && j["gravity"][1].is_number()) { + this->physics->setGravity(glm::vec2(j["gravity"][0], j["gravity"][1])); + } + // Load camera settings into the camera. if (j.contains("camera") && j["camera"].is_object()) { this->camera->load(j["camera"]); @@ -146,7 +165,7 @@ namespace Pancake { // Load audio into the audio pool if (j.contains("audio") && j["audio"].is_array()) { - for (auto element : j["fonts"]) { + for (auto element : j["audio"]) { if (element.is_object()) { AudioWave::load(element); } @@ -212,6 +231,86 @@ namespace Pancake { std::pair p(entity->getId(), entity); this->entityIndex.insert(p); if (this->started) {entity->start();} + this->updateGrid(entity); + } + + std::vector Scene::getEntities() { + return this->entities; + } + + std::pair Scene::computeBounds(Entity* entity) { + + // The entity's transform, as an axis aligned box around its rotated size. + glm::vec2 size = glm::abs(entity->getSize()); + float c = std::abs(cosf(entity->getRotation())); + float s = std::abs(sinf(entity->getRotation())); + glm::vec2 half = 0.5f * glm::vec2(size.x * c + size.y * s, size.x * s + size.y * c); + glm::vec2 min = entity->getPosition() - half; + glm::vec2 max = entity->getPosition() + half; + + // Grow the box to cover the rigidbody's colliders. + Rigidbody* rigidbody = dynamic_cast(entity->getComponent("Rigidbody")); + if (rigidbody != nullptr && !rigidbody->getColliders().empty()) { + std::pair bounds = rigidbody->getBounds(); + min = glm::min(min, bounds.first); + max = glm::max(max, bounds.second); + } + + return std::make_pair(min, max); + + } + + void Scene::updateGrid(Entity* entity) { + + std::pair bounds = this->computeBounds(entity); + auto it = this->gridBounds.find(entity); + if (it != this->gridBounds.end() && it->second == bounds) {return;} + + this->gridBounds[entity] = bounds; + this->grid->update(entity, bounds.first, bounds.second); + + } + + std::pair Scene::getBounds(Entity* entity) { + auto it = this->gridBounds.find(entity); + if (it != this->gridBounds.end()) {return it->second;} + return this->computeBounds(entity); + } + + std::vector Scene::getEntitiesIn(glm::vec2 min, glm::vec2 max) { + + std::vector result; + glm::vec2 centre = 0.5f * (min + max); + glm::vec2 size = max - min; + + for (Entity* e : this->grid->get(centre.x, centre.y, size.x, size.y)) { + if (e->isDead()) {continue;} + std::pair b = this->getBounds(e); + bool overlaps = b.first.x <= max.x && b.second.x >= min.x && b.first.y <= max.y && b.second.y >= min.y; + if (overlaps) {result.push_back(e);} + } + + return result; + + } + + std::vector Scene::getEntitiesNear(glm::vec2 position, float radius) { + + std::vector result; + glm::vec2 extent = glm::vec2(radius, radius); + + for (Entity* e : this->getEntitiesIn(position - extent, position + extent)) { + std::pair b = this->getBounds(e); + glm::vec2 closest = glm::clamp(position, b.first, b.second); + if (glm::distance(closest, position) <= radius) {result.push_back(e);} + } + + return result; + + } + + std::vector Scene::getEntitiesAt(glm::vec2 position) { + return this->getEntitiesIn(position, position); } Entity* Scene::getEntity(int id) { diff --git a/src/core/window.cpp b/src/core/window.cpp index d57ffdc..6aec621 100644 --- a/src/core/window.cpp +++ b/src/core/window.cpp @@ -13,6 +13,7 @@ #include #include "pancake/core/window.hpp" +#include "pancake/core/inspector.hpp" #include "pancake/core/listener.hpp" #include "pancake/graphics/shader.hpp" #include "pancake/graphics/framebuffer.hpp" @@ -89,6 +90,7 @@ namespace Pancake { ImGui_ImplGlfw_NewFrame(); ImGui::NewFrame(); Console::render(); + Inspector::render(); ImGui::Render(); ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); From ff9716d9471b1cefec479e656afd92d39de4b6ab Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 00:20:46 +0000 Subject: [PATCH 5/9] Make stacking stable with a warm started sequential impulse solver. The previous solver averaged impulses across contacts and added a heuristic linear velocity, and the step integrated positions before solving contacts, so resting bodies sank every step and relied on position correction. A five row pyramid collapsed within seconds. - Step order is now: forces, integrate velocities, detect, solve, integrate positions, correct positions. - Contacts accumulate and clamp normal and friction impulses, and are warm started from a cache of last step's impulses. - Pairs are ordered by body id so results don't depend on memory layout. - Bounces below 1 unit per second are ignored to stop resting jitter. - Fixed orientation bodies get no rotational response. - Rigidbody gets an inspector panel. - Entity::addRotationAround compared the pivot to the world origin rather than the entity's own position. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSbmWwU5zh3ujWmjioWg1a --- include/pancake/physics/rigidbody.hpp | 3 + include/pancake/physics/world.hpp | 10 + src/core/entity.cpp | 3 +- src/physics/rigidbody.cpp | 78 +++++-- src/physics/world.cpp | 302 +++++++++++++------------- 5 files changed, 225 insertions(+), 171 deletions(-) diff --git a/include/pancake/physics/rigidbody.hpp b/include/pancake/physics/rigidbody.hpp index c84defe..2e5c807 100644 --- a/include/pancake/physics/rigidbody.hpp +++ b/include/pancake/physics/rigidbody.hpp @@ -54,6 +54,7 @@ namespace Pancake { void end() override; json serialise() override; bool load(json j) override; + void imgui() override; World* getWorld(); void setWorld(World* world); @@ -108,6 +109,8 @@ namespace Pancake { Rigidbody* setMomentDirty(); void physicsUpdate(float dt); + void integrateVelocity(float dt); + void integratePosition(float dt); void updateGridRegistration(bool moved); void clearAccumulators(); diff --git a/include/pancake/physics/world.hpp b/include/pancake/physics/world.hpp index ed93420..bc86371 100644 --- a/include/pancake/physics/world.hpp +++ b/include/pancake/physics/world.hpp @@ -18,10 +18,20 @@ namespace Pancake { public: virtual void collision(Entity* with, CollisionManifold manifold) {} }; + // The impulses applied at a contact point, kept between steps to warm start the solver. + struct CachedContact { + glm::vec2 point; + glm::vec2 normal; + float normalImpulse; + float tangentImpulse; + }; + class World { private: + std::unordered_map, std::vector, IntPairHash, IntPairEqual> contactCache; + ForceRegistry registry; std::vector forces; diff --git a/src/core/entity.cpp b/src/core/entity.cpp index b613328..312d7b9 100644 --- a/src/core/entity.cpp +++ b/src/core/entity.cpp @@ -284,7 +284,8 @@ namespace Pancake { // If we don't need to perform expensive computations, don't. if (radians == 0.0f) {return;} - if (around.x == 0.0f && around.y == 0.0f) { + // Rotating around the entity's own position doesn't move it. + if (around == this->position) { this->addRotation(radians); return; } diff --git a/src/physics/rigidbody.cpp b/src/physics/rigidbody.cpp index 6121b10..6863425 100644 --- a/src/physics/rigidbody.cpp +++ b/src/physics/rigidbody.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include "pancake/physics/rigidbody.hpp" #include "pancake/core/window.hpp" @@ -151,6 +152,44 @@ namespace Pancake { } + void Rigidbody::imgui() { + + ImGui::Text("Mass: %s", this->hasInfiniteMass() ? "infinite" : std::to_string(this->getMass()).c_str()); + ImGui::Text("Colliders: %d", (int) this->colliders.size()); + + glm::vec2 velocity = this->getVelocity(); + ImGui::Text("Velocity: "); + ImGui::SameLine(); + if (ImGui::DragFloat2("##Velocity", &velocity[0], 0.1f)) {this->setVelocity(velocity);} + + float angularVelocity = this->getAngularVelocity(); + ImGui::Text("Spin: "); + ImGui::SameLine(); + if (ImGui::DragFloat("##AngularVelocity", &angularVelocity, 0.1f)) {this->setAngularVelocity(angularVelocity);} + + float friction = this->friction; + ImGui::Text("Friction: "); + ImGui::SameLine(); + if (ImGui::DragFloat("##Friction", &friction, 0.01f, 0.0f, 10.0f)) {this->setFriction(friction);} + + float restitution = this->restitution; + ImGui::Text("Restitution: "); + ImGui::SameLine(); + if (ImGui::DragFloat("##Restitution", &restitution, 0.01f, 0.0f, 1.0f)) {this->setRestitution(restitution);} + + float gravityScale = this->gravityScale; + ImGui::Text("Gravity Scale: "); + ImGui::SameLine(); + if (ImGui::DragFloat("##GravityScale", &gravityScale, 0.01f)) {this->setGravityScale(gravityScale);} + + bool sensor = this->sensor; + if (ImGui::Checkbox("Sensor", &sensor)) {this->setSensor(sensor);} + ImGui::SameLine(); + bool fixedOrientation = this->fixedOrientation; + if (ImGui::Checkbox("Fixed Orientation", &fixedOrientation)) {this->setFixedOrientation(fixedOrientation);} + + } + std::vector Rigidbody::getColliders() { return this->colliders; } @@ -534,28 +573,44 @@ namespace Pancake { } void Rigidbody::physicsUpdate(float dt) { - - // Static bodies are not integrated, but may still be moved by game code. + this->integrateVelocity(dt); + this->integratePosition(dt); + } + + void Rigidbody::integrateVelocity(float dt) { + + // Static bodies don't move. if (this->hasInfiniteMass()) { this->clearAccumulators(); - this->updateGridRegistration(false); return; } - // Required variables - glm::vec2 displacement = glm::vec2(0.0f, 0.0f); - float rotation = 0.0f; - - // Update linear + // Apply forces, torque and gravity. glm::vec2 gravity = this->world != nullptr ? this->world->getGravity() * this->gravityScale : glm::vec2(0.0f, 0.0f); this->velocity += (this->force * this->getInverseMass() + gravity) * dt; - displacement = this->velocity * dt; + if (!this->fixedOrientation) {this->angularVelocity += this->torque * dt * this->getInverseMomentOfInertia();} + + // Clear the force and torque accumulators. + this->clearAccumulators(); + + } + + void Rigidbody::integratePosition(float dt) { + + // Static bodies are not integrated, but may still be moved by game code. + if (this->hasInfiniteMass()) { + this->updateGridRegistration(false); + return; + } + + // Update linear + glm::vec2 displacement = this->velocity * dt; this->getEntity()->addPosition(displacement); // Update the rotation if allowed to. + float rotation = 0.0f; if (!this->fixedOrientation) { - this->angularVelocity += this->torque * dt * this->getInverseMomentOfInertia(); rotation = this->angularVelocity * dt; this->getEntity()->addRotationAround(rotation, this->getCentroid()); @@ -573,9 +628,6 @@ namespace Pancake { // Note: There is no need to update collider rotation offsets since they are updated in the entity. } - // Clear the force and torque accumulators. - this->clearAccumulators(); - // Update the spatial hash grid if the bounding box has changed. this->updateGridRegistration(displacement != glm::vec2(0.0f, 0.0f) || rotation != 0.0f); diff --git a/src/physics/world.cpp b/src/physics/world.cpp index eaeedbc..8906d1a 100644 --- a/src/physics/world.cpp +++ b/src/physics/world.cpp @@ -14,129 +14,117 @@ namespace Pancake { namespace { - class ImpulseResult { - - public: - - glm::vec2 aVelocity; - glm::vec2 bVelocity; - float aAngularVelocity; - float bAngularVelocity; - float aAngularVelocityTotal; - float bAngularVelocityTotal; - float aAngularOrbitalSpeed; - float bAngularOrbitalSpeed; - - ImpulseResult() { - this->aVelocity = glm::vec2(0.0f, 0.0f); - this->bVelocity = glm::vec2(0.0f, 0.0f); - this->aAngularVelocity = 0.0f; - this->bAngularVelocity = 0.0f; - this->aAngularVelocityTotal = 0.0f; - this->bAngularVelocityTotal = 0.0f; - this->aAngularOrbitalSpeed = 0.0f; - this->bAngularOrbitalSpeed = 0.0f; - } - - ImpulseResult operator*(float scalar) const { - ImpulseResult result; - result.aVelocity = this->aVelocity * scalar; - result.bVelocity = this->bVelocity * scalar; - result.aAngularVelocity = this->aAngularVelocity * scalar; - result.bAngularVelocity = this->bAngularVelocity * scalar; - result.aAngularVelocityTotal = this->aAngularVelocityTotal * scalar; - result.bAngularVelocityTotal = this->bAngularVelocityTotal * scalar; - result.aAngularOrbitalSpeed = this->aAngularOrbitalSpeed * scalar; - result.bAngularOrbitalSpeed = this->bAngularOrbitalSpeed * scalar; - return result; - } - - ImpulseResult& operator+=(const ImpulseResult& rhs) { - this->aVelocity += rhs.aVelocity; - this->bVelocity += rhs.bVelocity; - this->aAngularVelocity += rhs.aAngularVelocity; - this->bAngularVelocity += rhs.bAngularVelocity; - this->aAngularVelocityTotal += rhs.aAngularVelocityTotal; - this->bAngularVelocityTotal += rhs.bAngularVelocityTotal; - this->aAngularOrbitalSpeed += rhs.aAngularOrbitalSpeed; - this->bAngularOrbitalSpeed += rhs.bAngularOrbitalSpeed; - return *this; - } - - }; - - const int IMPULSE_ITERATIONS = 6; + const int VELOCITY_ITERATIONS = 10; const int MAX_STEPS_PER_UPDATE = 5; - - inline glm::vec2 perp(glm::vec2 v) { - return glm::rotate(v, glm::half_pi()); + const float RESTITUTION_THRESHOLD = 1.0f; // Contacts slower than this do not bounce, which stops resting bodies jittering. + + inline float cross(glm::vec2 a, glm::vec2 b) { + return a.x * b.y - a.y * b.x; } - inline float proj(glm::vec2 a, glm::vec2 b) { - return glm::dot(a, b) / glm::length(b); + // The velocity of a point on a body rotating with angular velocity w, offset r from its centroid. + inline glm::vec2 cross(float w, glm::vec2 r) { + return glm::vec2(-w * r.y, w * r.x); } - ImpulseResult applyImpulse(Rigidbody* a, Rigidbody* b, CollisionManifold m) { + // A single contact point between two bodies, with the impulses accumulated over the solver iterations. + struct Contact { + + Rigidbody* a; + Rigidbody* b; + glm::vec2 normal; + glm::vec2 tangent; + glm::vec2 rA; + glm::vec2 rB; + float invMassA; + float invMassB; + float invMomentA; + float invMomentB; + float normalMass; + float tangentMass; + float friction; + float bounce; + float normalImpulse; + float tangentImpulse; + + Contact(Rigidbody* a, Rigidbody* b, const CollisionManifold& m) { + + this->a = a; + this->b = b; + this->normal = m.normal; + this->tangent = glm::vec2(-m.normal.y, m.normal.x); + this->rA = m.point - a->getCentroid(); + this->rB = m.point - b->getCentroid(); + this->invMassA = a->getInverseMass(); + this->invMassB = b->getInverseMass(); + this->invMomentA = a->hasFixedOrientation() ? 0.0f : a->getInverseMomentOfInertia(); + this->invMomentB = b->hasFixedOrientation() ? 0.0f : b->getInverseMomentOfInertia(); + + float rnA = cross(this->rA, this->normal); + float rnB = cross(this->rB, this->normal); + float kNormal = this->invMassA + this->invMassB + this->invMomentA * rnA * rnA + this->invMomentB * rnB * rnB; + this->normalMass = kNormal > 0.0f ? 1.0f / kNormal : 0.0f; + + float rtA = cross(this->rA, this->tangent); + float rtB = cross(this->rB, this->tangent); + float kTangent = this->invMassA + this->invMassB + this->invMomentA * rtA * rtA + this->invMomentB * rtB * rtB; + this->tangentMass = kTangent > 0.0f ? 1.0f / kTangent : 0.0f; + + this->friction = std::max(a->getFriction(), b->getFriction()); + float restitution = a->getRestitution() * b->getRestitution(); + float approach = glm::dot(this->relativeVelocity(), this->normal); + this->bounce = approach < -RESTITUTION_THRESHOLD ? -restitution * approach : 0.0f; + + this->normalImpulse = 0.0f; + this->tangentImpulse = 0.0f; - // Check for infinite mass objects. - ImpulseResult result; - if (a->hasInfiniteMass() && b->hasInfiniteMass()) {return result;} + } - float invMassA = a->getInverseMass(); - float invMassB = b->getInverseMass(); - float invMoiA = a->getInverseMomentOfInertia(); - float invMoiB = b->getInverseMomentOfInertia(); - float angVelA = a->getAngularVelocity(); - float angVelB = b->getAngularVelocity(); - float restitution = a->getRestitution() * b->getRestitution(); - float friction = std::max(a->getFriction(), b->getFriction()); - glm::vec2 tangent = perp(m.normal); - glm::vec2 rA = m.point - a->getCentroid(); - glm::vec2 rB = m.point - b->getCentroid(); - glm::vec2 vAB = b->getVelocity() + perp(rB) * angVelB - a->getVelocity() - perp(rA) * angVelA; - float rAproj = proj(rA, m.normal); - float rAreg = proj(rA, tangent); - float rBproj = proj(rB, m.normal); - float rBreg = proj(rB, tangent); - float vproj = proj(vAB, m.normal); - float vreg = proj(vAB, tangent); - - // If both bodies are moving away from each other. - if (vproj >= 0.0f) {return result;} - - // Calculate the impulse of the collision. - float impulse = -((1 + restitution) * vproj) / (invMassA + invMassB + (invMoiA * rAreg * rAreg) + (invMoiB * rBreg * rBreg)); - - // Resolve the collision. - result.aVelocity -= m.normal * impulse * invMassA; - result.bVelocity += m.normal * impulse * invMassB; - result.aAngularVelocity += impulse * invMoiA * rAreg; - result.bAngularVelocity -= impulse * invMoiB * rBreg; - result.aAngularVelocityTotal += fabsf(impulse * invMoiA * rAreg); - result.bAngularVelocityTotal += fabsf(impulse * invMoiB * rBreg); - result.aAngularOrbitalSpeed += fabsf(impulse * invMoiA * rAreg * rAreg); - result.bAngularOrbitalSpeed += fabsf(impulse * invMoiB * rBreg * rBreg); - - // Calculate friction. - if (friction > 0.0f) { - - float max = fabsf(vreg) / (invMassA + invMassB + (invMoiA * rAproj * rAproj) + (invMoiB * rBproj * rBproj)); - float sign = vreg < 0.0f ? -1.0f : 1.0f; - impulse = friction * fabsf(impulse); - impulse = std::min(impulse, max); - impulse *= sign; - - result.aVelocity += tangent * impulse * invMassA; - result.bVelocity -= tangent * impulse * invMassB; - result.aAngularVelocity += impulse * invMoiA * rAproj; - result.bAngularVelocity -= impulse * invMoiB * rBproj; - result.aAngularVelocityTotal += fabsf(impulse * invMoiA * rAproj); - result.bAngularVelocityTotal += fabsf(impulse * invMoiB * rBproj); + glm::vec2 relativeVelocity() { + glm::vec2 vA = this->a->getVelocity() + cross(this->a->getAngularVelocity(), this->rA); + glm::vec2 vB = this->b->getVelocity() + cross(this->b->getAngularVelocity(), this->rB); + return vB - vA; + } + void apply(glm::vec2 impulse) { + this->a->addVelocity(-impulse * this->invMassA); + this->a->addAngularVelocity(-this->invMomentA * cross(this->rA, impulse)); + this->b->addVelocity(impulse * this->invMassB); + this->b->addAngularVelocity(this->invMomentB * cross(this->rB, impulse)); } - return result; - } + // Start from the impulses applied at this contact last step, if it persisted. + void warmStart(const std::vector& cache, glm::vec2 point) { + for (const CachedContact& c : cache) { + if (glm::dot(c.normal, this->normal) < 0.95f) {continue;} + if (glm::distance(c.point, point) > 0.05f) {continue;} + this->normalImpulse = c.normalImpulse; + this->tangentImpulse = std::clamp(c.tangentImpulse, -this->friction * this->normalImpulse, this->friction * this->normalImpulse); + this->apply(this->normalImpulse * this->normal + this->tangentImpulse * this->tangent); + return; + } + } + + void solve() { + + // Friction, limited by the normal impulse applied so far. + float vt = glm::dot(this->relativeVelocity(), this->tangent); + float limit = this->friction * this->normalImpulse; + float tangentImpulse = std::clamp(this->tangentImpulse - vt * this->tangentMass, -limit, limit); + float dt = tangentImpulse - this->tangentImpulse; + this->tangentImpulse = tangentImpulse; + this->apply(dt * this->tangent); + + // Normal impulse. The accumulated impulse may only push the bodies apart. + float vn = glm::dot(this->relativeVelocity(), this->normal); + float normalImpulse = std::max(this->normalImpulse + (this->bounce - vn) * this->normalMass, 0.0f); + float dn = normalImpulse - this->normalImpulse; + this->normalImpulse = normalImpulse; + this->apply(dn * this->normal); + + } + + }; // Push overlapping bodies apart, in proportion to their inverse masses, to stop them sinking into each other. void correctPositions(Rigidbody* a, Rigidbody* b, const std::vector& manifolds) { @@ -148,15 +136,13 @@ namespace Pancake { float invMassB = b->getInverseMass(); if (invMassA + invMassB <= 0.0f) {return;} - // Weight each contact by its depth, as with the impulses. - float depthTotal = 0.0f; - for (const CollisionManifold& m : manifolds) {depthTotal += m.depth;} - if (depthTotal <= 0.0f) {return;} - + // Use the deepest contact, so two contacts along the same face don't double the correction. glm::vec2 correction = glm::vec2(0.0f, 0.0f); + float deepest = 0.0f; for (const CollisionManifold& m : manifolds) { - float magnitude = std::max(m.depth - slop, 0.0f) * percent / (invMassA + invMassB); - correction += m.normal * magnitude * (m.depth / depthTotal); + if (m.depth <= deepest) {continue;} + deepest = m.depth; + correction = m.normal * (std::max(m.depth - slop, 0.0f) * percent / (invMassA + invMassB)); } a->getEntity()->addPosition(-correction * invMassA); @@ -200,11 +186,9 @@ namespace Pancake { void World::fixedUpdate() { - // Update velocities of all rigidbodies - int n = this->rigidbodies.size(); - for (int i = 0; i < n; i++) { - this->rigidbodies[i]->physicsUpdate(this->timeStep); - } + // Apply force generators, then integrate forces and gravity into velocities. + this->registry.updateForces(this->timeStep); + for (Rigidbody* rigidbody : this->rigidbodies) {rigidbody->integrateVelocity(this->timeStep);} // Required data structures to store collision data. std::unordered_set, IntPairHash> colliding; @@ -217,9 +201,10 @@ namespace Pancake { for (int j = i + 1; j < bodies.size(); j++) { - // Get the next pairing + // Get the next pairing, ordered by id so results don't depend on the grid's iteration order. Rigidbody* rigidbody1 = bodies[i]; Rigidbody* rigidbody2 = bodies[j]; + if (rigidbody2->getId() < rigidbody1->getId()) {std::swap(rigidbody1, rigidbody2);} // Check if this pairing has already been collided. int smaller = std::min(rigidbody1->getId(), rigidbody2->getId()); @@ -296,41 +281,44 @@ namespace Pancake { } - // Update the forces - this->registry.updateForces(this->timeStep); - - // Resolve collision via iterative impulse resolution. - for (int k = 0; k < IMPULSE_ITERATIONS; k++) { - int m = collisions.size(); - for (int i = 0; i < m; i++) { - - Rigidbody* r1 = collisions[i].first; - Rigidbody* r2 = collisions[i].second; - ImpulseResult sum; - - // Weight each contact by its depth, or equally if they are only touching. - int count = manifolds[i].size(); - float depthTotal = 0.0f; - for (int j = 0; j < count; j++) {depthTotal += manifolds[i][j].depth;} - for (int j = 0; j < count; j++) { - float weight = depthTotal > 0.0f ? manifolds[i][j].depth / depthTotal : 1.0f / count; - sum += applyImpulse(r1, r2, manifolds[i][j]) * weight; - } - - r1->addVelocity(sum.aVelocity); - r2->addVelocity(sum.bVelocity); - r1->addAngularVelocity(sum.aAngularVelocity); - r2->addAngularVelocity(sum.bAngularVelocity); + // Resolve collisions with sequential impulses, accumulating and clamping the impulse at each contact. + // Solve pairs in id order, so the result does not depend on memory layout. + std::vector order(collisions.size()); + for (int i = 0; i < order.size(); i++) {order[i] = i;} + std::sort(order.begin(), order.end(), [&collisions](int x, int y) { + std::pair a = std::make_pair(collisions[x].first->getId(), collisions[x].second->getId()); + std::pair b = std::make_pair(collisions[y].first->getId(), collisions[y].second->getId()); + return a < b; + }); + + // Contacts that persist from the last step start with the impulses they ended with. + std::vector contacts; + std::vector> keys; + std::vector points; + for (int i : order) { + std::pair key = std::make_pair(collisions[i].first->getId(), collisions[i].second->getId()); + auto cached = this->contactCache.find(key); + for (const CollisionManifold& m : manifolds[i]) { + contacts.push_back(Contact(collisions[i].first, collisions[i].second, m)); + if (cached != this->contactCache.end()) {contacts.back().warmStart(cached->second, m.point);} + keys.push_back(key); + points.push_back(m.point); + } + } - if (sum.aAngularVelocityTotal > 0.0f && glm::dot(sum.aVelocity, sum.aVelocity) > 0.0f) {r1->addVelocity(((sum.aAngularVelocityTotal - fabsf(sum.aAngularVelocity)) / sum.aAngularVelocityTotal) * sum.aAngularOrbitalSpeed * glm::normalize(sum.aVelocity));} - if (sum.bAngularVelocityTotal > 0.0f && glm::dot(sum.bVelocity, sum.bVelocity) > 0.0f) {r2->addVelocity(((sum.bAngularVelocityTotal - fabsf(sum.bAngularVelocity)) / sum.bAngularVelocityTotal) * sum.bAngularOrbitalSpeed * glm::normalize(sum.bVelocity));} + for (int k = 0; k < VELOCITY_ITERATIONS; k++) { + for (Contact& contact : contacts) {contact.solve();} + } - } - + // Remember the impulses for the next step. + this->contactCache.clear(); + for (int i = 0; i < contacts.size(); i++) { + this->contactCache[keys[i]].push_back({points[i], contacts[i].normal, contacts[i].normalImpulse, contacts[i].tangentImpulse}); } - // Correct positions once, after the velocities have been resolved. - for (int i = 0; i < collisions.size(); i++) { + // Move the bodies with their resolved velocities, then push apart any that still overlap. + for (Rigidbody* rigidbody : this->rigidbodies) {rigidbody->integratePosition(this->timeStep);} + for (int i : order) { correctPositions(collisions[i].first, collisions[i].second, manifolds[i]); } From ce940ee62366273766d698fd12c5e8f52fada97b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 00:20:46 +0000 Subject: [PATCH 6/9] Fix window sizing and high DPI rendering, and tidy warnings. - Pancake::size set width and height with two resizes, the second using a stale width. Before start the size is now used at creation, and at runtime both dimensions are applied together. - The viewport and picking framebuffer follow the framebuffer size, which differs from the window size on high DPI displays. readPixel converts window coordinates to pixels. - The console and inspector open in opposite corners. - Fall back to the default font if a font file cannot be read. - Remove unused variables and make shader sources constexpr. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSbmWwU5zh3ujWmjioWg1a --- include/pancake/asset/shaders.hpp | 10 ++++---- src/audio/audiowave.cpp | 2 +- src/core/console.cpp | 2 ++ src/core/inspector.cpp | 5 +++- src/core/window.cpp | 39 +++++++++++++++++++++---------- src/graphics/font.cpp | 10 +++++++- src/graphics/renderer.cpp | 1 - 7 files changed, 48 insertions(+), 21 deletions(-) diff --git a/include/pancake/asset/shaders.hpp b/include/pancake/asset/shaders.hpp index 9696f3f..b3937ca 100644 --- a/include/pancake/asset/shaders.hpp +++ b/include/pancake/asset/shaders.hpp @@ -2,7 +2,7 @@ namespace { -const char* DEBUG_FRAGMENT = +constexpr const char* DEBUG_FRAGMENT = "#version 330 core \n" " \n" "in vec3 fColour; \n" @@ -13,7 +13,7 @@ const char* DEBUG_FRAGMENT = " colour = vec4(fColour, 1); \n" "} \n"; -const char* DEBUG_VERTEX = +constexpr const char* DEBUG_VERTEX = "#version 330 core \n" " \n" "layout (location = 0) in vec3 aPos; \n" @@ -30,7 +30,7 @@ const char* DEBUG_VERTEX = " gl_Position = uProjection * uView * vec4(aPos, 1.0); \n" "} \n"; -const char* DEFAULT_FRAGMENT = +constexpr const char* DEFAULT_FRAGMENT = "#version 330 core \n" " \n" "in vec4 fColour; \n" @@ -72,7 +72,7 @@ const char* DEFAULT_FRAGMENT = " } \n" "} \n"; -const char* DEFAULT_VERTEX = +constexpr const char* DEFAULT_VERTEX = "#version 330 core \n" "layout (location=0) in vec3 aPos; \n" "layout (location=1) in vec4 aColour; \n" @@ -97,7 +97,7 @@ const char* DEFAULT_VERTEX = " gl_Position = uProjection * uView * vec4(aPos, 1.0); \n" "} \n"; -const char* ENTITY_FRAGMENT = +constexpr const char* ENTITY_FRAGMENT = "#version 330 core \n" " \n" "in vec4 fColour; \n" diff --git a/src/audio/audiowave.cpp b/src/audio/audiowave.cpp index 42877cf..cafa75b 100644 --- a/src/audio/audiowave.cpp +++ b/src/audio/audiowave.cpp @@ -29,7 +29,7 @@ namespace Pancake { void AudioWave::load(json j) { if (!j.contains("filename") || !j["filename"].is_string()) {return;} - AudioWave* a = AudioPool::get(j["filename"]); + AudioPool::get(j["filename"]); // Loads the audio into the pool. } Wav& AudioWave::getSource() { diff --git a/src/core/console.cpp b/src/core/console.cpp index 3470510..5383aa0 100644 --- a/src/core/console.cpp +++ b/src/core/console.cpp @@ -129,6 +129,8 @@ namespace Pancake { // If not active, don't render the terminal. if (!active) {return;} + // Open in the bottom left corner, clear of the inspector. + ImGui::SetNextWindowPos(ImVec2(10.0f, ImGui::GetIO().DisplaySize.y - 310.0f), ImGuiCond_FirstUseEver); ImGui::SetNextWindowSize(ImVec2(480, 300), ImGuiCond_FirstUseEver); if (!ImGui::Begin("Console", &active)) {ImGui::End(); return;} diff --git a/src/core/inspector.cpp b/src/core/inspector.cpp index b77834d..0ca2e7b 100644 --- a/src/core/inspector.cpp +++ b/src/core/inspector.cpp @@ -87,7 +87,10 @@ namespace Pancake { selected = pick(); } - ImGui::SetNextWindowSize(ImVec2(360, 480), ImGuiCond_FirstUseEver); + // Open on the right hand side, clear of the console. + ImGuiIO& io = ImGui::GetIO(); + ImGui::SetNextWindowPos(ImVec2(io.DisplaySize.x - 370.0f, 10.0f), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(360, io.DisplaySize.y - 20.0f), ImGuiCond_FirstUseEver); if (!ImGui::Begin("Inspector", &active)) {ImGui::End(); return;} // Scene settings. diff --git a/src/core/window.cpp b/src/core/window.cpp index 6aec621..43dc55e 100644 --- a/src/core/window.cpp +++ b/src/core/window.cpp @@ -25,9 +25,11 @@ namespace Pancake { namespace { - GLFWwindow* window; + GLFWwindow* window = nullptr; int width = 800; int height = 800; + int framebufferWidth = 800; // In pixels, which differs from the window size on high DPI displays. + int framebufferHeight = 800; Scene* scene = nullptr; bool loadFlag = false; @@ -101,10 +103,16 @@ namespace Pancake { void resizeCallback(GLFWwindow* window, int screenWidth, int screenHeight) { width = screenWidth; height = screenHeight; + if (scene != nullptr) {scene->getCamera()->adjustProjection();} + } + + void framebufferResizeCallback(GLFWwindow* window, int pixelWidth, int pixelHeight) { + if (pixelWidth <= 0 || pixelHeight <= 0) {return;} // Minimised. + framebufferWidth = pixelWidth; + framebufferHeight = pixelHeight; delete entityTexture; - entityTexture = new Framebuffer(GL_RGB32F, width, height, GL_RGB, GL_FLOAT); - scene->getCamera()->adjustProjection(); - glViewport(0, 0, screenWidth, screenHeight); + entityTexture = new Framebuffer(GL_RGB32F, framebufferWidth, framebufferHeight, GL_RGB, GL_FLOAT); + glViewport(0, 0, framebufferWidth, framebufferHeight); } } @@ -159,6 +167,7 @@ namespace Pancake { // Manage callbacks glfwSetKeyCallback(window, KeyListener::keyCallback); glfwSetWindowSizeCallback(window, resizeCallback); + glfwSetFramebufferSizeCallback(window, framebufferResizeCallback); glfwSetCursorPosCallback(window, MouseListener::mousePosCallback); glfwSetMouseButtonCallback(window, MouseListener::mouseButtonCallback); glfwSetScrollCallback(window, MouseListener::mouseScrollCallback); @@ -192,7 +201,9 @@ namespace Pancake { AssetPool::init(); defaultShader = new Shader("default", "default", DEFAULT_VERTEX, DEFAULT_FRAGMENT); entityShader = new Shader("default", "entity", DEFAULT_VERTEX, ENTITY_FRAGMENT); - entityTexture = new Framebuffer(GL_RGB32F, width, height, GL_RGB, GL_FLOAT); + glfwGetFramebufferSize(window, &framebufferWidth, &framebufferHeight); + glViewport(0, 0, framebufferWidth, framebufferHeight); + entityTexture = new Framebuffer(GL_RGB32F, framebufferWidth, framebufferHeight, GL_RGB, GL_FLOAT); DebugDraw::init(); scene = new Scene(); scene->start(); @@ -231,13 +242,10 @@ namespace Pancake { } - if (widthFlag) { - glfwSetWindowSize(window, widthValue, height); + // Apply both dimensions at once, as the resize callback that updates them arrives later. + if (widthFlag || heightFlag) { + glfwSetWindowSize(window, widthFlag ? widthValue : width, heightFlag ? heightValue : height); widthFlag = false; - } - - if (heightFlag) { - glfwSetWindowSize(window, width, heightValue); heightFlag = false; } @@ -338,11 +346,13 @@ namespace Pancake { } void setWidth(int w) { + if (window == nullptr) {width = w; return;} // Not created yet, so create it at this size. widthValue = w; widthFlag = true; } void setHeight(int h) { + if (window == nullptr) {height = h; return;} heightValue = h; heightFlag = true; } @@ -367,9 +377,14 @@ namespace Pancake { // Create a buffer to store pixel data. float pixel; + // Convert from window coordinates to framebuffer pixels. + int px = (int) (x * (float) framebufferWidth / width); + int py = (int) (y * (float) framebufferHeight / height); + if (px < 0 || py < 0 || px >= framebufferWidth || py >= framebufferHeight) {return -1;} + // Bind the framebuffer and read the corresponding pixel. entityTexture->bind(); - glReadPixels(x, height-y-1, 1, 1, GL_RED, GL_FLOAT, &pixel); + glReadPixels(px, framebufferHeight - py - 1, 1, 1, GL_RED, GL_FLOAT, &pixel); entityTexture->unbind(); // Return the value of this pixel. diff --git a/src/graphics/font.cpp b/src/graphics/font.cpp index fa981f9..a334446 100644 --- a/src/graphics/font.cpp +++ b/src/graphics/font.cpp @@ -170,9 +170,17 @@ namespace Pancake { fileSize = ftell(fontFile); fseek(fontFile, 0, SEEK_SET); fontBuffer = (unsigned char*) malloc(fileSize); - fread(fontBuffer, fileSize, 1, fontFile); + bool read = fileSize > 0 && fread(fontBuffer, fileSize, 1, fontFile) == 1; fclose(fontFile); + // If the file could not be read, load the default font in it's place. + if (!read) { + std::cout << "ERROR::FONT::INIT::FILE_NOT_READ: '" << filename << "'\n"; + free(fontBuffer); + this->load(reinterpret_cast(PIXELLARI), this->size); + return; + } + // Load the font. this->load((const unsigned char*) fontBuffer, this->size); diff --git a/src/graphics/renderer.cpp b/src/graphics/renderer.cpp index abd50cc..c3f0fb7 100644 --- a/src/graphics/renderer.cpp +++ b/src/graphics/renderer.cpp @@ -362,7 +362,6 @@ namespace Pancake { // Rebuffer the data if we removed it. for (int i = 0; i < this->sprites.size(); i++) { - SpriteRenderer* current = this->sprites[i]; this->loadVertexProperties(i); } From ba9f284f76b87ca81262f606baa23b0ccaafa708 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 00:20:46 +0000 Subject: [PATCH 7/9] Add tests, CI and build options. - tests/: a minimal test harness and unit tests for the spatial grid, collision detection, the solver (resting, stacking, pyramids, angled drops), force generators, raycasts and JSON round trips, plus an engine test that runs the real loop to check saving, reloading, killing bodies, resetting scenes with force generators and console commands. - CMake: C++17 is required explicitly, AddressSanitizer moved behind PANCAKE_ASAN (it was always linked into games on Linux), and PANCAKE_BUILD_TESTS / PANCAKE_BUILD_EXAMPLES default on only when Pancake is the top level project. - GitHub Actions builds with ASan and in Release, and runs all tests under Xvfb. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSbmWwU5zh3ujWmjioWg1a --- .github/workflows/ci.yml | 40 ++++++ .gitignore | 2 +- CMakeLists.txt | 46 +++++-- tests/CMakeLists.txt | 35 +++++ tests/helpers.hpp | 44 +++++++ tests/lsan.supp | 16 +++ tests/test.hpp | 72 +++++++++++ tests/test_engine.cpp | 168 ++++++++++++++++++++++++ tests/test_physics.cpp | 269 +++++++++++++++++++++++++++++++++++++++ tests/test_raycast.cpp | 75 +++++++++++ tests/test_serialise.cpp | 70 ++++++++++ tests/test_spatial.cpp | 47 +++++++ 12 files changed, 871 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/CMakeLists.txt create mode 100644 tests/helpers.hpp create mode 100644 tests/lsan.supp create mode 100644 tests/test.hpp create mode 100644 tests/test_engine.cpp create mode 100644 tests/test_physics.cpp create mode 100644 tests/test_raycast.cpp create mode 100644 tests/test_serialise.cpp create mode 100644 tests/test_spatial.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8a8267c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + pull_request: + +jobs: + linux: + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + include: + - name: asan + flags: -DPANCAKE_ASAN=ON + - name: release + flags: -DCMAKE_BUILD_TYPE=Release + name: linux (${{ matrix.name }}) + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev \ + libgl1-mesa-dev libgl1-mesa-dri libasound2-dev xvfb + + - name: Configure + # The bundled dependencies declare old CMake minimums, which newer CMake rejects without this. + run: cmake -S . -B build -DGLFW_BUILD_WAYLAND=OFF -DCMAKE_POLICY_VERSION_MINIMUM=3.5 ${{ matrix.flags }} + + - name: Build + run: cmake --build build -j + + - name: Test + env: + LIBGL_ALWAYS_SOFTWARE: "1" + run: ctest --test-dir build --output-on-failure diff --git a/.gitignore b/.gitignore index 39b1748..36bc025 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ build Makefile CMakeFiles CMakefiles -dependencies/glad/CMakeFiles \ No newline at end of file +dependencies/glad/CMakeFilesimgui.ini diff --git a/CMakeLists.txt b/CMakeLists.txt index c60c2ba..4ee46d5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,17 @@ cmake_minimum_required(VERSION 3.14) -project(pancake) +project(pancake CXX C) + +# Only build the tests and examples by default when this is the top level project. +if (CMAKE_SOURCE_DIR STREQUAL PROJECT_SOURCE_DIR) + set(PANCAKE_TOP_LEVEL ON) +else() + set(PANCAKE_TOP_LEVEL OFF) +endif() + +option(PANCAKE_BUILD_TESTS "Build the Pancake tests" ${PANCAKE_TOP_LEVEL}) +option(PANCAKE_BUILD_EXAMPLES "Build the Pancake examples" ${PANCAKE_TOP_LEVEL}) +option(PANCAKE_ASAN "Build Pancake and everything linking it with AddressSanitizer" OFF) add_subdirectory(dependencies/glfw/) add_subdirectory(dependencies/glad/) @@ -24,25 +35,27 @@ target_include_directories(imgui PUBLIC dependencies/imgui/backends) if (WIN32) set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -std=c11 -Wall -g") set(CMAKE_EXE_LINKER_FLAGS "-static-libgcc -static-libstdc++") - set(CMAKE_VERBOSE_MAKEFILE ON) else() set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -std=c11 -Wall -g") - set(CMAKE_EXE_LINKER_FLAGS "-static-libgcc -static-libstdc++ -fsanitize=address") - set(CMAKE_VERBOSE_MAKEFILE ON) + if (NOT PANCAKE_ASAN) + set(CMAKE_EXE_LINKER_FLAGS "-static-libgcc -static-libstdc++") + endif() endif() include_directories(include) include_directories(dependencies) -file(GLOB_RECURSE source_files src/*.cpp) +file(GLOB_RECURSE source_files CONFIGURE_DEPENDS src/*.cpp) add_library(${PROJECT_NAME} ${source_files}) -target_include_directories(${PROJECT_NAME} PUBLIC glad) -target_include_directories(${PROJECT_NAME} PUBLIC glfw) -target_include_directories(${PROJECT_NAME} PUBLIC glm) -target_include_directories(${PROJECT_NAME} PUBLIC nlohmann_json) -target_include_directories(${PROJECT_NAME} PUBLIC imgui) -target_include_directories(${PROJECT_NAME} PUBLIC soloud) +target_compile_features(${PROJECT_NAME} PUBLIC cxx_std_17) +set_target_properties(${PROJECT_NAME} PROPERTIES CXX_EXTENSIONS OFF) + +if (PANCAKE_ASAN) + target_compile_options(${PROJECT_NAME} PUBLIC -fsanitize=address -fno-omit-frame-pointer -g) + target_link_options(${PROJECT_NAME} PUBLIC -fsanitize=address) +endif() + target_include_directories(${PROJECT_NAME} PUBLIC include/) target_include_directories(${PROJECT_NAME} PUBLIC dependencies/) @@ -51,4 +64,13 @@ target_link_libraries(${PROJECT_NAME} PUBLIC glfw) target_link_libraries(${PROJECT_NAME} PUBLIC glm) target_link_libraries(${PROJECT_NAME} PUBLIC nlohmann_json) target_link_libraries(${PROJECT_NAME} PUBLIC imgui) -target_link_libraries(${PROJECT_NAME} PUBLIC soloud) \ No newline at end of file +target_link_libraries(${PROJECT_NAME} PUBLIC soloud) + +if (PANCAKE_BUILD_TESTS) + enable_testing() + add_subdirectory(tests) +endif() + +if (PANCAKE_BUILD_EXAMPLES) + add_subdirectory(examples) +endif() diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..c128867 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,35 @@ +# Unit tests run without a window. Engine tests open a real window, so they need a display; +# when xvfb-run is available they are wrapped in a virtual one. + +find_program(XVFB_RUN xvfb-run) + +set(PANCAKE_TEST_ENVIRONMENT + "ASAN_OPTIONS=detect_leaks=1" + "LSAN_OPTIONS=suppressions=${CMAKE_CURRENT_SOURCE_DIR}/lsan.supp:print_suppressions=0" +) + +function(pancake_test name) + add_executable(${name} ${name}.cpp) + target_link_libraries(${name} PRIVATE pancake) + add_test(NAME ${name} COMMAND ${name}) + set_tests_properties(${name} PROPERTIES ENVIRONMENT "${PANCAKE_TEST_ENVIRONMENT}" LABELS unit) +endfunction() + +function(pancake_engine_test name) + add_executable(${name} ${name}.cpp) + target_link_libraries(${name} PRIVATE pancake) + if (XVFB_RUN) + add_test(NAME ${name} COMMAND ${XVFB_RUN} -a -s "-screen 0 1024x768x24" $) + else() + add_test(NAME ${name} COMMAND ${name}) + endif() + # Leak checking is off here: GL drivers leak from libraries they unload, which cannot be suppressed by name. + set_tests_properties(${name} PROPERTIES ENVIRONMENT "ASAN_OPTIONS=detect_leaks=0" LABELS engine TIMEOUT 120 + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}) +endfunction() + +pancake_test(test_spatial) +pancake_test(test_physics) +pancake_test(test_raycast) +pancake_test(test_serialise) +pancake_engine_test(test_engine) diff --git a/tests/helpers.hpp b/tests/helpers.hpp new file mode 100644 index 0000000..526eb2f --- /dev/null +++ b/tests/helpers.hpp @@ -0,0 +1,44 @@ +#pragma once + +// Helpers for building physics bodies outside of a running engine. + +#include "pancake/pancake.hpp" + +namespace PancakeTest { + + // Owns entities created for a test and deletes them at the end of scope. + struct Bodies { + + std::vector entities; + + ~Bodies() { + for (Pancake::Entity* e : entities) {delete e;} + } + + Pancake::Rigidbody* box(glm::vec2 position, glm::vec2 size, float mass, float rotation = 0.0f) { + Pancake::Entity* e = new Pancake::Entity(position, glm::vec2(1.0f, 1.0f), rotation); + Pancake::Rigidbody* r = new Pancake::Rigidbody(); + Pancake::BoxCollider* b = new Pancake::BoxCollider(); + b->setSize(size); + b->setMass(mass); + r->addCollider(b); + e->addComponent(r); + entities.push_back(e); + return r; + } + + Pancake::Rigidbody* circle(glm::vec2 position, float radius, float mass) { + Pancake::Entity* e = new Pancake::Entity(position); + Pancake::Rigidbody* r = new Pancake::Rigidbody(); + Pancake::CircleCollider* c = new Pancake::CircleCollider(); + c->setRadius(radius); + c->setMass(mass); + r->addCollider(c); + e->addComponent(r); + entities.push_back(e); + return r; + } + + }; + +} diff --git a/tests/lsan.supp b/tests/lsan.supp new file mode 100644 index 0000000..7e03e5b --- /dev/null +++ b/tests/lsan.supp @@ -0,0 +1,16 @@ +# Leaks inside third party audio and graphics drivers that the engine cannot free. +leak:ma_context_init +leak:ma_device_init +leak:libpulse +leak:libasound +leak:libjack +leak:libdbus +leak:libLLVM +leak:libgallium +leak:swrast_dri +leak:libGLX +leak:libEGL +leak:libX11 +leak:libxcb +leak:_dl_open +leak:_dl_init diff --git a/tests/test.hpp b/tests/test.hpp new file mode 100644 index 0000000..8bf5e2b --- /dev/null +++ b/tests/test.hpp @@ -0,0 +1,72 @@ +#pragma once + +// A minimal test harness: each TEST registers a function, and RUN_TESTS runs them all. + +#include +#include +#include +#include +#include + +namespace PancakeTest { + + struct Case { + const char* name; + std::function body; + }; + + inline std::vector& cases() { + static std::vector instance; + return instance; + } + + inline int& failures() { + static int count = 0; + return count; + } + + struct Registrar { + Registrar(const char* name, std::function body) { + cases().push_back({name, body}); + } + }; + + inline int run() { + for (const Case& c : cases()) { + int before = failures(); + c.body(); + std::printf("[%s] %s\n", failures() == before ? " OK " : "FAIL", c.name); + } + std::printf("%zu tests, %d failed checks\n", cases().size(), failures()); + std::fflush(stdout); + return failures() == 0 ? 0 : 1; + } + +} + +#define PANCAKE_TEST_CONCAT_IMPL(a, b) a##b +#define PANCAKE_TEST_CONCAT(a, b) PANCAKE_TEST_CONCAT_IMPL(a, b) + +#define TEST(name) \ + static void PANCAKE_TEST_CONCAT(test_, __LINE__)(); \ + static PancakeTest::Registrar PANCAKE_TEST_CONCAT(registrar_, __LINE__)(name, PANCAKE_TEST_CONCAT(test_, __LINE__)); \ + static void PANCAKE_TEST_CONCAT(test_, __LINE__)() + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + std::printf(" %s:%d: CHECK(%s) failed\n", __FILE__, __LINE__, #condition); \ + PancakeTest::failures()++; \ + } \ + } while (0) + +#define CHECK_NEAR(a, b, eps) \ + do { \ + double pancake_a = (a), pancake_b = (b); \ + if (!(std::fabs(pancake_a - pancake_b) <= (eps))) { \ + std::printf(" %s:%d: CHECK_NEAR(%s, %s) failed: %f vs %f\n", __FILE__, __LINE__, #a, #b, pancake_a, pancake_b); \ + PancakeTest::failures()++; \ + } \ + } while (0) + +#define RUN_TESTS() int main() { return PancakeTest::run(); } diff --git a/tests/test_engine.cpp b/tests/test_engine.cpp new file mode 100644 index 0000000..3b3da6e --- /dev/null +++ b/tests/test_engine.cpp @@ -0,0 +1,168 @@ +// Runs the real engine loop in a window and drives it through a script of frames: +// physics settling, saving and reloading a scene, killing bodies, resetting, and shutdown. + +#include +#include +#include "test.hpp" +#include "pancake/pancake.hpp" + +using namespace Pancake; + +namespace { + + const char* SAVE_FILE = "test_engine_scene.json"; + + int frame = 0; + int settled = -1; // The frame the box came to rest on, which later steps are timed from. + int boxId = -1; + int groundId = -1; + + void log(const char* message) { + std::printf("frame %d: %s\n", frame, message); + std::fflush(stdout); + } + + Rigidbody* rigidbodyOf(int entityId) { + Entity* e = Pancake::getEntity(entityId); + if (e == nullptr) {return nullptr;} + return dynamic_cast(e->getComponent("Rigidbody")); + } + + void addBody(Scene* scene, vec2 position, vec2 size, float mass, int* id) { + Entity* e = new Entity(position); + Rigidbody* r = new Rigidbody(); + BoxCollider* b = new BoxCollider(); + b->setSize(size); + b->setMass(mass); + r->addCollider(b); + r->setRestitution(0.0f); + e->addComponent(r); + scene->addEntity(e); + if (id != nullptr) {*id = e->getId();} + } + +} + +// Drives the test. It is serialisable so it survives a save and reload of the scene. +class Driver : public Component { + + public: + + Driver() : Component("Driver") {} + + void update(float dt) override { + + frame++; + + // Physics runs in real time, so wait for the box to land rather than counting frames. + if (settled < 0) { + Rigidbody* box = rigidbodyOf(boxId); + bool resting = box != nullptr && box->getEntity()->getPosition().y < -3.9f && std::fabs(box->getVelocity().y) < 0.05f; + if (resting) {settled = frame;} + else if (frame > 5000) {log("box never came to rest"); CHECK(false); Pancake::stop();} + return; + } + + int t = frame - settled; + + if (t == 30) { + log("box should be resting on the ground"); + Rigidbody* box = rigidbodyOf(boxId); + CHECK(box != nullptr); + if (box != nullptr) {CHECK_NEAR(box->getEntity()->getPosition().y, -4.0, 0.1);} + + RaycastResult hit = Pancake::getScene()->getPhysics()->raycast(Ray(vec2(0.0f, 10.0f), vec2(0.0f, -1.0f)), 50.0f); + CHECK(hit.hit != nullptr && hit.hit->getId() == boxId); + + std::vector near = Pancake::getScene()->getEntitiesNear(vec2(0.0f, -4.0f), 0.25f); + bool found = false; + for (Entity* e : near) {found = found || e->getId() == boxId;} + CHECK(found); + + Pancake::save(SAVE_FILE); + } + + if (t == 32) { + log("reset and reload the saved scene"); + Pancake::reset(); + Pancake::load(SAVE_FILE); + } + + if (t == 40) { + log("the reloaded scene matches the saved one"); + CHECK(Pancake::getScene()->getName() == "Engine Test"); + CHECK_NEAR(Pancake::getScene()->getPhysics()->getGravity().y, -9.0, 1e-6); + Rigidbody* box = rigidbodyOf(boxId); + CHECK(box != nullptr); + if (box != nullptr) {CHECK_NEAR(box->getEntity()->getPosition().y, -4.0, 0.1);} + CHECK(rigidbodyOf(groundId) != nullptr); + } + + if (t == 50) { + log("kill the resting box while it touches the ground"); + Entity* box = Pancake::getEntity(boxId); + CHECK(box != nullptr); + if (box != nullptr) {box->kill();} + } + + if (t == 60) { + log("killed entities are removed from the scene"); + CHECK(Pancake::getEntity(boxId) == nullptr); + CHECK(Pancake::getScene()->getEntitiesNear(vec2(0.0f, -4.0f), 0.25f).empty()); + Console::execute("debug on"); + CHECK(Pancake::getScene()->getPhysics()->hasDebugDraw()); + Console::execute("gravity 0 -3"); + CHECK_NEAR(Pancake::getScene()->getPhysics()->getGravity().y, -3.0, 1e-6); + } + + if (t == 70) { + log("reset a scene that has a force generator component"); + Pancake::reset(); + Pancake::load([](Scene* scene) { + Entity* e = new Entity(); + Gravity* wind = new Gravity(); + wind->setAcceleration(1.0f, 0.0f); + e->addComponent(wind); + scene->addEntity(e); + Entity* d = new Entity(); + d->addComponent(new Driver()); + scene->addEntity(d); + }); + } + + if (t == 80) { + log("reset again, then stop"); + Pancake::reset(); + Pancake::load([](Scene* scene) { + Entity* d = new Entity(); + d->addComponent(new Driver()); + scene->addEntity(d); + }); + } + + if (t == 85) {Pancake::stop();} + + } + +}; + +REGISTER(Component, Driver); + +void init(Scene* scene) { + scene->setName("Engine Test"); + scene->getPhysics()->setGravity(vec2(0.0f, -9.0f)); + addBody(scene, vec2(0.0f, -5.0f), vec2(10.0f, 1.0f), 0.0f, &groundId); + addBody(scene, vec2(0.0f, 0.0f), vec2(1.0f, 1.0f), 1.0f, &boxId); + Entity* d = new Entity(); + d->addComponent(new Driver()); + scene->addEntity(d); +} + +int main() { + Pancake::load(init); + Pancake::start(); + std::remove(SAVE_FILE); + CHECK(settled > 0 && frame == settled + 85); + std::printf("engine ran %d frames, %d failed checks\n", frame, PancakeTest::failures()); + return PancakeTest::failures() == 0 ? 0 : 1; +} diff --git a/tests/test_physics.cpp b/tests/test_physics.cpp new file mode 100644 index 0000000..e6472cf --- /dev/null +++ b/tests/test_physics.cpp @@ -0,0 +1,269 @@ +#include "test.hpp" +#include "helpers.hpp" + +using namespace Pancake; +using PancakeTest::Bodies; + +namespace { + + void step(World& world, int frames) { + for (int i = 0; i < frames; i++) {world.update(1.0f / 60.0f);} + } + +} + +TEST("box/box overlap produces a contact along the separating axis") { + Bodies bodies; + Rigidbody* a = bodies.box(glm::vec2(0.0f, 0.0f), glm::vec2(2.0f, 2.0f), 1.0f); + Rigidbody* b = bodies.box(glm::vec2(0.0f, 1.9f), glm::vec2(2.0f, 2.0f), 1.0f); + std::vector m = Collision::findCollisionFeatures(a->getColliders()[0], b->getColliders()[0]); + CHECK(!m.empty()); + for (CollisionManifold& c : m) { + CHECK_NEAR(std::fabs(c.normal.y), 1.0, 1e-3); + CHECK_NEAR(c.depth, 0.1, 1e-3); + } +} + +TEST("separated shapes do not collide") { + Bodies bodies; + Rigidbody* a = bodies.box(glm::vec2(0.0f, 0.0f), glm::vec2(1.0f, 1.0f), 1.0f); + Rigidbody* b = bodies.box(glm::vec2(3.0f, 0.0f), glm::vec2(1.0f, 1.0f), 1.0f); + Rigidbody* c = bodies.circle(glm::vec2(0.0f, 3.0f), 0.5f, 1.0f); + CHECK(Collision::findCollisionFeatures(a->getColliders()[0], b->getColliders()[0]).empty()); + CHECK(Collision::findCollisionFeatures(a->getColliders()[0], c->getColliders()[0]).empty()); +} + +TEST("circle/circle and circle/box overlaps are detected") { + Bodies bodies; + Rigidbody* a = bodies.circle(glm::vec2(0.0f, 0.0f), 1.0f, 1.0f); + Rigidbody* b = bodies.circle(glm::vec2(1.5f, 0.0f), 1.0f, 1.0f); + Rigidbody* c = bodies.box(glm::vec2(0.0f, -1.4f), glm::vec2(4.0f, 1.0f), 1.0f); + std::vector ab = Collision::findCollisionFeatures(a->getColliders()[0], b->getColliders()[0]); + CHECK(ab.size() == 1); + if (!ab.empty()) {CHECK_NEAR(ab[0].depth, 0.5, 1e-3);} + CHECK(!Collision::findCollisionFeatures(a->getColliders()[0], c->getColliders()[0]).empty()); + CHECK(!Collision::findCollisionFeatures(c->getColliders()[0], a->getColliders()[0]).empty()); +} + +TEST("mass, centroid and moment of inertia combine colliders") { + Entity e; + Rigidbody* r = new Rigidbody(); + BoxCollider* left = new BoxCollider(); + BoxCollider* right = new BoxCollider(); + left->setSize(1.0f, 1.0f); left->setMass(1.0f); left->setPositionOffset(-1.0f, 0.0f); + right->setSize(1.0f, 1.0f); right->setMass(3.0f); right->setPositionOffset(1.0f, 0.0f); + r->addCollider(left)->addCollider(right); + e.addComponent(r); + CHECK_NEAR(r->getMass(), 4.0, 1e-5); + CHECK_NEAR(r->getCentroid().x, 0.5, 1e-5); + // Each box: m/6 about its centre, plus m * d^2 about the centroid. + double expected = (1.0 / 6.0 + 1.0 * 1.5 * 1.5) + (3.0 / 6.0 + 3.0 * 0.5 * 0.5); + CHECK_NEAR(r->getMomentOfInertia(), expected, 1e-4); +} + +TEST("bounds wrap the colliders rather than the entity origin") { + Bodies bodies; + Rigidbody* r = bodies.box(glm::vec2(10.0f, 10.0f), glm::vec2(2.0f, 2.0f), 1.0f); + r->getColliders()[0]->setPositionOffset(5.0f, 0.0f); + std::pair bounds = r->getBounds(); + CHECK_NEAR(bounds.first.x, 14.0, 1e-5); + CHECK_NEAR(bounds.second.x, 16.0, 1e-5); +} + +TEST("world gravity accelerates dynamic bodies and ignores static ones") { + World world(1.0f / 60.0f, glm::vec2(0.0f, -10.0f)); + Bodies bodies; + Rigidbody* dynamic = bodies.box(glm::vec2(0.0f, 0.0f), glm::vec2(1.0f, 1.0f), 1.0f); + Rigidbody* fixed = bodies.box(glm::vec2(10.0f, 0.0f), glm::vec2(1.0f, 1.0f), 0.0f); + world.add(dynamic); + world.add(fixed); + step(world, 60); + CHECK_NEAR(dynamic->getVelocity().y, -10.0, 0.2); + CHECK_NEAR(dynamic->getEntity()->getPosition().y, -5.0, 0.3); + CHECK_NEAR(fixed->getEntity()->getPosition().y, 0.0, 1e-6); +} + +TEST("gravity scale can disable gravity per body") { + World world(1.0f / 60.0f, glm::vec2(0.0f, -10.0f)); + Bodies bodies; + Rigidbody* floating = bodies.box(glm::vec2(0.0f, 0.0f), glm::vec2(1.0f, 1.0f), 1.0f); + floating->setGravityScale(0.0f); + world.add(floating); + step(world, 30); + CHECK_NEAR(floating->getEntity()->getPosition().y, 0.0, 1e-6); +} + +TEST("a falling box comes to rest on static ground") { + World world(1.0f / 60.0f, glm::vec2(0.0f, -10.0f)); + Bodies bodies; + Rigidbody* ground = bodies.box(glm::vec2(0.0f, -5.0f), glm::vec2(10.0f, 1.0f), 0.0f); + Rigidbody* box = bodies.box(glm::vec2(0.0f, 0.0f), glm::vec2(1.0f, 1.0f), 1.0f); + box->setRestitution(0.0f); + world.add(ground); + world.add(box); + step(world, 240); + CHECK_NEAR(box->getEntity()->getPosition().y, -4.0, 0.05); + CHECK_NEAR(box->getVelocity().y, 0.0, 0.5); +} + +TEST("identical boxes stack without falling through each other") { + World world(1.0f / 60.0f, glm::vec2(0.0f, -10.0f)); + Bodies bodies; + world.add(bodies.box(glm::vec2(0.0f, -0.5f), glm::vec2(1.0f, 1.0f), 0.0f)); + std::vector stack; + for (int i = 0; i < 3; i++) { + Rigidbody* box = bodies.box(glm::vec2(0.0f, 0.5f + i * 1.0f), glm::vec2(1.0f, 1.0f), 1.0f); + box->setRestitution(0.0f); + world.add(box); + stack.push_back(box); + } + step(world, 300); + for (int i = 0; i < 3; i++) { + CHECK_NEAR(stack[i]->getEntity()->getPosition().y, 0.5 + i, 0.05); + CHECK_NEAR(stack[i]->getEntity()->getPosition().x, 0.0, 0.05); + } +} + +TEST("a pyramid of boxes stays standing") { + World world(1.0f / 60.0f, glm::vec2(0.0f, -10.0f)); + Bodies bodies; + world.add(bodies.box(glm::vec2(0.0f, -4.5f), glm::vec2(20.0f, 1.0f), 0.0f)); + std::vector boxes; + std::vector expected; + for (int row = 0; row < 5; row++) { + for (int i = 0; i <= 4 - row; i++) { + glm::vec2 position = glm::vec2(-2.0f + i + row * 0.5f, -3.5f + row); + Rigidbody* box = bodies.box(position, glm::vec2(1.0f, 1.0f), 1.0f); + box->setFriction(0.4f); + box->setRestitution(0.2f); + world.add(box); + boxes.push_back(box); + expected.push_back(position); + } + } + step(world, 600); + for (int i = 0; i < boxes.size(); i++) { + CHECK(glm::distance(boxes[i]->getEntity()->getPosition(), expected[i]) < 0.05f); + CHECK(std::fabs(boxes[i]->getEntity()->getRotation()) < 0.02f); + } +} + +TEST("a ball comes to rest on a static box without sinking") { + World world(1.0f / 60.0f, glm::vec2(0.0f, -10.0f)); + Bodies bodies; + world.add(bodies.box(glm::vec2(0.0f, 0.0f), glm::vec2(20.0f, 1.0f), 0.0f, 0.0f)); + Rigidbody* ball = bodies.circle(glm::vec2(0.0f, 3.0f), 0.5f, 1.0f); + ball->setRestitution(0.0f); + world.add(ball); + step(world, 240); + CHECK_NEAR(ball->getEntity()->getPosition().y, 1.0, 0.05); +} + +TEST("a box dropped at an angle settles flat on the ground") { + World world(1.0f / 60.0f, glm::vec2(0.0f, -10.0f)); + Bodies bodies; + world.add(bodies.box(glm::vec2(0.0f, -5.0f), glm::vec2(20.0f, 1.0f), 0.0f)); + Rigidbody* box = bodies.box(glm::vec2(0.0f, 0.0f), glm::vec2(1.0f, 1.0f), 1.0f, 0.4f); + box->setRestitution(0.0f); + box->setFriction(0.5f); + world.add(box); + step(world, 600); + float rotation = box->getEntity()->getRotation(); + float quarter = 1.5707963f; + float offFlat = std::fabs(rotation - quarter * std::round(rotation / quarter)); + CHECK(offFlat < 0.05f); + CHECK_NEAR(box->getEntity()->getPosition().y, -4.0, 0.05); + CHECK(std::fabs(box->getAngularVelocity()) < 0.1f); +} + +TEST("a circle whose centre is inside a box is pushed out of the nearest face") { + Bodies bodies; + Rigidbody* box = bodies.box(glm::vec2(0.0f, 0.0f), glm::vec2(4.0f, 2.0f), 0.0f); + Rigidbody* circle = bodies.circle(glm::vec2(0.5f, 0.8f), 0.5f, 1.0f); + std::vector m = Collision::findCollisionFeatures(box->getColliders()[0], circle->getColliders()[0]); + CHECK(m.size() == 1); + if (!m.empty()) { + CHECK_NEAR(m[0].normal.y, 1.0, 1e-4); // From the box to the circle. + CHECK_NEAR(m[0].depth, 0.7, 1e-4); + } +} + +TEST("force generators only act on bodies registered with them") { + World world(1.0f / 60.0f, glm::vec2(0.0f, 0.0f)); + Bodies bodies; + Gravity* wind = new Gravity(); + wind->setAcceleration(5.0f, 0.0f); + Entity windEntity; + windEntity.addComponent(wind); + world.addForceGenerator(wind); + Rigidbody* pushed = bodies.box(glm::vec2(0.0f, 0.0f), glm::vec2(1.0f, 1.0f), 1.0f); + Rigidbody* ignored = bodies.box(glm::vec2(0.0f, 10.0f), glm::vec2(1.0f, 1.0f), 1.0f); + pushed->addForceGenerator("Gravity"); + world.add(pushed); + world.add(ignored); + step(world, 60); + CHECK(pushed->getVelocity().x > 4.0f); + CHECK_NEAR(ignored->getVelocity().x, 0.0, 1e-6); + pushed->clearForceGenerators(); + glm::vec2 velocity = pushed->getVelocity(); + step(world, 10); + CHECK_NEAR(pushed->getVelocity().x, velocity.x, 0.1); + world.removeForceGenerator(wind); +} + +TEST("removing a rigidbody also removes it from the spatial grid") { + World world(1.0f / 60.0f, glm::vec2(0.0f, 0.0f)); + Bodies bodies; + Rigidbody* a = bodies.box(glm::vec2(0.0f, 0.0f), glm::vec2(1.0f, 1.0f), 1.0f); + Rigidbody* b = bodies.box(glm::vec2(0.5f, 0.0f), glm::vec2(1.0f, 1.0f), 1.0f); + world.add(a); + world.add(b); + world.remove(a); + CHECK(a->getWorld() == nullptr); + for (std::vector& cell : *world.getGrid()) { + for (Rigidbody* r : cell) {CHECK(r != a);} + } + step(world, 5); +} + +TEST("static bodies moved by game code update their grid cells") { + World world(1.0f / 60.0f, glm::vec2(0.0f, 0.0f)); + Bodies bodies; + Rigidbody* wall = bodies.box(glm::vec2(0.0f, 0.0f), glm::vec2(1.0f, 1.0f), 0.0f); + world.add(wall); + wall->getEntity()->setPosition(glm::vec2(100.0f, 100.0f)); + step(world, 1); + CHECK(world.getGrid()->get(0, 0).empty()); + CHECK(world.getGrid()->get(25, 25).count(wall) == 1); +} + +TEST("physics drops time instead of spiralling after a long frame") { + World world(1.0f / 60.0f, glm::vec2(0.0f, -10.0f)); + Bodies bodies; + Rigidbody* box = bodies.box(glm::vec2(0.0f, 0.0f), glm::vec2(1.0f, 1.0f), 1.0f); + world.add(box); + world.update(10.0f); // At most 5 steps. + CHECK(box->getVelocity().y > -1.0f); +} + +TEST("collision listeners are notified") { + struct Listener : public Component, public CollisionListener { + int hits = 0; + Listener() : Component("Listener") {} + void collision(Entity* with, CollisionManifold manifold) override {hits++;} + }; + World world(1.0f / 60.0f, glm::vec2(0.0f, 0.0f)); + Bodies bodies; + Rigidbody* a = bodies.box(glm::vec2(0.0f, 0.0f), glm::vec2(1.0f, 1.0f), 1.0f); + Rigidbody* b = bodies.box(glm::vec2(0.0f, 0.9f), glm::vec2(1.0f, 1.0f), 1.0f); + Listener* listener = new Listener(); + a->getEntity()->addComponent(listener); + b->setSensor(true); + world.add(a); + world.add(b); + step(world, 1); + CHECK(listener->hits > 0); + CHECK_NEAR(b->getVelocity().y, 0.0, 1e-6); // Sensors receive no impulse. +} + +RUN_TESTS() diff --git a/tests/test_raycast.cpp b/tests/test_raycast.cpp new file mode 100644 index 0000000..b07ba57 --- /dev/null +++ b/tests/test_raycast.cpp @@ -0,0 +1,75 @@ +#include "test.hpp" +#include "helpers.hpp" + +using namespace Pancake; +using PancakeTest::Bodies; + +TEST("ray hits the near face of an axis aligned box") { + Bodies bodies; + Rigidbody* box = bodies.box(glm::vec2(5.0f, 0.0f), glm::vec2(2.0f, 2.0f), 1.0f); + RaycastResult r = Raycast::raycast(box, Ray(glm::vec2(0.0f, 0.0f), glm::vec2(1.0f, 0.0f))); + CHECK(r.hit == box->getEntity()); + CHECK_NEAR(r.distance, 4.0, 1e-4); + CHECK_NEAR(r.point.x, 4.0, 1e-4); + CHECK_NEAR(r.normal.x, -1.0, 1e-4); +} + +TEST("ray misses a box beside it and boxes behind it") { + Bodies bodies; + Rigidbody* box = bodies.box(glm::vec2(5.0f, 3.0f), glm::vec2(2.0f, 2.0f), 1.0f); + CHECK(Raycast::raycast(box, Ray(glm::vec2(0.0f, 0.0f), glm::vec2(1.0f, 0.0f))).hit == nullptr); + CHECK(Raycast::raycast(box, Ray(glm::vec2(0.0f, 3.0f), glm::vec2(-1.0f, 0.0f))).hit == nullptr); +} + +TEST("ray hits the corner region of a rotated box") { + Bodies bodies; + // A unit-half-extent box rotated 45 degrees has its corner at x = 5 - sqrt(2). + Rigidbody* box = bodies.box(glm::vec2(5.0f, 0.0f), glm::vec2(2.0f, 2.0f), 1.0f, 0.78539816f); + RaycastResult r = Raycast::raycast(box, Ray(glm::vec2(0.0f, 0.0f), glm::vec2(1.0f, 0.0f))); + CHECK(r.hit != nullptr); + CHECK_NEAR(r.point.x, 5.0 - std::sqrt(2.0), 1e-3); + CHECK(r.normal.x < 0.0f); +} + +TEST("ray starting inside a box reports the exit point") { + Bodies bodies; + Rigidbody* box = bodies.box(glm::vec2(0.0f, 0.0f), glm::vec2(2.0f, 2.0f), 1.0f); + RaycastResult r = Raycast::raycast(box, Ray(glm::vec2(0.0f, 0.0f), glm::vec2(0.0f, 1.0f))); + CHECK_NEAR(r.distance, 1.0, 1e-4); + CHECK_NEAR(r.normal.y, 1.0, 1e-4); +} + +TEST("ray hits a circle and ignores circles behind it") { + Bodies bodies; + Rigidbody* circle = bodies.circle(glm::vec2(0.0f, 5.0f), 1.0f, 1.0f); + RaycastResult r = Raycast::raycast(circle, Ray(glm::vec2(0.0f, 0.0f), glm::vec2(0.0f, 1.0f))); + CHECK_NEAR(r.distance, 4.0, 1e-4); + CHECK_NEAR(r.normal.y, -1.0, 1e-4); + CHECK(Raycast::raycast(circle, Ray(glm::vec2(0.0f, 0.0f), glm::vec2(0.0f, -1.0f))).hit == nullptr); +} + +TEST("world raycast returns the closest hit, with and without a max distance") { + World world(1.0f / 60.0f, glm::vec2(0.0f, 0.0f)); + Bodies bodies; + Rigidbody* nearBox = bodies.box(glm::vec2(10.0f, 0.0f), glm::vec2(1.0f, 1.0f), 0.0f); + Rigidbody* farBox = bodies.box(glm::vec2(30.0f, 0.0f), glm::vec2(1.0f, 1.0f), 0.0f); + Rigidbody* offAxis = bodies.circle(glm::vec2(5.0f, 10.0f), 1.0f, 0.0f); + world.add(nearBox); + world.add(farBox); + world.add(offAxis); + + Ray ray(glm::vec2(0.0f, 0.0f), glm::vec2(1.0f, 0.0f)); + CHECK(world.raycast(ray).hit == nearBox->getEntity()); + CHECK(world.raycast(ray, 100.0f).hit == nearBox->getEntity()); + CHECK(world.raycast(ray, 5.0f).hit == nullptr); + + Ray backwards(glm::vec2(35.0f, 0.0f), glm::vec2(-1.0f, 0.0f)); + CHECK(world.raycast(backwards, 100.0f).hit == farBox->getEntity()); + + Ray diagonal(glm::vec2(0.0f, 5.0f), glm::vec2(1.0f, 1.0f)); + RaycastResult d = world.raycast(diagonal, 50.0f); + CHECK(d.hit == offAxis->getEntity()); + CHECK_NEAR(d.distance, world.raycast(diagonal).distance, 1e-5); +} + +RUN_TESTS() diff --git a/tests/test_serialise.cpp b/tests/test_serialise.cpp new file mode 100644 index 0000000..cabe661 --- /dev/null +++ b/tests/test_serialise.cpp @@ -0,0 +1,70 @@ +#include "test.hpp" +#include "helpers.hpp" + +using namespace Pancake; + +TEST("entities with rigidbodies round trip through json") { + Entity original(glm::vec2(1.0f, 2.0f), glm::vec2(3.0f, 4.0f), 0.5f); + Rigidbody* r = new Rigidbody(); + BoxCollider* box = new BoxCollider(); + box->setSize(2.0f, 1.0f); + box->setMass(2.0f); + box->setPositionOffset(0.5f, 0.0f); + CircleCollider* circle = new CircleCollider(); + circle->setRadius(0.75f); + circle->setMass(1.0f); + r->addCollider(box)->addCollider(circle); + r->setFriction(0.3f)->setRestitution(0.2f)->setVelocity(1.0f, -1.0f); + r->setGravityScale(0.5f); + r->addForceGenerator("Wind"); + original.addComponent(r); + + nlohmann::json j = original.serialise(); + Entity* loaded = Entity::load(j); + CHECK(loaded != nullptr); + if (loaded == nullptr) {return;} + + CHECK(loaded->getId() == original.getId()); + CHECK_NEAR(loaded->getPosition().y, 2.0, 1e-6); + CHECK_NEAR(loaded->getRotation(), 0.5, 1e-6); + + Rigidbody* lr = dynamic_cast(loaded->getComponent("Rigidbody")); + CHECK(lr != nullptr); + if (lr != nullptr) { + CHECK(lr->getColliders().size() == 2); + CHECK_NEAR(lr->getMass(), 3.0, 1e-6); + CHECK_NEAR(lr->getFriction(), 0.3, 1e-6); + CHECK_NEAR(lr->getVelocity().x, 1.0, 1e-6); + CHECK_NEAR(lr->getGravityScale(), 0.5, 1e-6); + CHECK(lr->hasForceGenerator("Wind")); + CHECK(lr->getColliders()[0]->getType() == "BoxCollider"); + CHECK(lr->getColliders()[1]->getType() == "CircleCollider"); + } + + // Serialising the loaded entity gives the same json. + CHECK(loaded->serialise() == j); + delete loaded; +} + +TEST("force generator components round trip through json") { + Gravity g; + g.setAcceleration(1.0f, -3.0f); + Component* c = FACTORY(Component).create("Gravity"); + CHECK(c != nullptr); + if (c == nullptr) {return;} + CHECK(c->load(g.serialise())); + Gravity* loaded = dynamic_cast(c); + CHECK(loaded != nullptr); + if (loaded != nullptr) {CHECK_NEAR(loaded->getAcceleration().y, -3.0, 1e-6);} + delete c; +} + +TEST("malformed json is rejected without crashing") { + CHECK(Entity::load(nlohmann::json::object()) == nullptr); + nlohmann::json j = {{"id", 1}, {"position", {0, 0}}, {"size", {1, 1}}, {"rotation", 0}, {"components", {{{"type", "NotAComponent"}}, {{"type", "Rigidbody"}}}}}; + Entity* e = Entity::load(j); + CHECK(e != nullptr); + if (e != nullptr) {CHECK(e->getComponents().empty()); delete e;} +} + +RUN_TESTS() diff --git a/tests/test_spatial.cpp b/tests/test_spatial.cpp new file mode 100644 index 0000000..72e9f16 --- /dev/null +++ b/tests/test_spatial.cpp @@ -0,0 +1,47 @@ +#include "test.hpp" +#include "pancake/core/spatial.hpp" + +using Pancake::SpatialHashGrid; + +TEST("elements are found in every cell their bounds overlap") { + SpatialHashGrid grid(4); + grid.add(1, glm::vec2(-1.0f, -1.0f), glm::vec2(5.0f, 1.0f)); // Cells x -1..1, y -1..0 + CHECK(grid.get(-1, -1).count(1) == 1); + CHECK(grid.get(1, 0).count(1) == 1); + CHECK(grid.get(2, 0).count(1) == 0); +} + +TEST("updating an element moves it between cells") { + SpatialHashGrid grid(4); + grid.add(1, 1.0f, 1.0f, 1.0f, 1.0f); + grid.update(1, 41.0f, 41.0f, 1.0f, 1.0f); + CHECK(grid.get(0, 0).empty()); + CHECK(grid.get(10, 10).count(1) == 1); +} + +TEST("removing an element clears it from all cells") { + SpatialHashGrid grid(4); + grid.add(1, 0.0f, 0.0f, 10.0f, 10.0f); + grid.add(2, 0.0f, 0.0f, 1.0f, 1.0f); + grid.remove(1); + CHECK(grid.get(0.0f, 0.0f, 20.0f, 20.0f).size() == 1); + CHECK(grid.get(0.0f, 0.0f, 20.0f, 20.0f).count(2) == 1); + grid.remove(1); // Removing twice is harmless. +} + +TEST("region queries return each element once") { + SpatialHashGrid grid(1); + grid.add(7, 0.0f, 0.0f, 10.0f, 10.0f); + CHECK(grid.get(0.0f, 0.0f, 10.0f, 10.0f).size() == 1); +} + +TEST("iteration visits every occupied cell") { + SpatialHashGrid grid(4); + grid.add(1, 2.0f, 2.0f, 1.0f, 1.0f); // Entirely within cell (0, 0). + grid.add(2, 102.0f, 102.0f, 1.0f, 1.0f); // Entirely within cell (25, 25). + int cells = 0; + for (std::vector& elements : grid) {cells++; CHECK(elements.size() == 1);} + CHECK(cells == 2); +} + +RUN_TESTS() From a95c060c6bad9e0eeaa532fe53108211e0cbce0a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 00:20:47 +0000 Subject: [PATCH 8/9] Add a physics sandbox example. Spawns boxes and balls, pans and zooms the camera, and binds keys to the console, inspector and physics debug drawing. --frames N quits after N frames, which CTest uses as a smoke test of the whole engine. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSbmWwU5zh3ujWmjioWg1a --- examples/CMakeLists.txt | 14 +++ examples/demo.cpp | 232 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 examples/CMakeLists.txt create mode 100644 examples/demo.cpp diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt new file mode 100644 index 0000000..7557b05 --- /dev/null +++ b/examples/CMakeLists.txt @@ -0,0 +1,14 @@ +add_executable(pancake_demo demo.cpp) +target_link_libraries(pancake_demo PRIVATE pancake) + +# Run the demo for a few hundred frames as a smoke test of the whole engine. +if (PANCAKE_BUILD_TESTS) + find_program(XVFB_RUN xvfb-run) + if (XVFB_RUN) + add_test(NAME demo_smoke COMMAND ${XVFB_RUN} -a -s "-screen 0 1024x768x24" $ --frames 300) + else() + add_test(NAME demo_smoke COMMAND pancake_demo --frames 300) + endif() + set_tests_properties(demo_smoke PROPERTIES ENVIRONMENT "ASAN_OPTIONS=detect_leaks=0" LABELS engine TIMEOUT 120 + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}) +endif() diff --git a/examples/demo.cpp b/examples/demo.cpp new file mode 100644 index 0000000..70a5024 --- /dev/null +++ b/examples/demo.cpp @@ -0,0 +1,232 @@ +// A physics sandbox showing the main parts of the engine. +// +// Left click spawn a box (or select an entity while the inspector is open) +// Shift left click spawn a ball +// Right drag pan the camera +// Scroll zoom +// D toggle physics debug drawing +// I toggle the inspector +// ` toggle the console (try "help") +// R reset the sandbox +// Escape quit +// +// Run with --frames N to quit automatically after N frames. + +#include +#include +#include +#include +#include "pancake/pancake.hpp" + +using namespace Pancake; + +namespace { + + int frameLimit = -1; + int frames = 0; + + vec4 randomColour() { + float r = 0.3f + 0.7f * (std::rand() / (float) RAND_MAX); + float g = 0.3f + 0.7f * (std::rand() / (float) RAND_MAX); + float b = 0.3f + 0.7f * (std::rand() / (float) RAND_MAX); + return vec4(r, g, b, 1.0f); + } + +} + +// Flashes the entity's sprite white whenever it hits something hard. +class ImpactFlash : public Component, public CollisionListener { + + private: + + float flash; + + public: + + ImpactFlash() : Component("ImpactFlash") { + this->flash = 0.0f; + this->setImguiable(false); + } + + void collision(Entity* with, CollisionManifold manifold) override { + Rigidbody* r = dynamic_cast(this->getEntity()->getComponent("Rigidbody")); + if (r != nullptr && glm::length(r->getVelocity()) > 6.0f) {this->flash = 0.15f;} + } + + void update(float dt) override { + SpriteRenderer* sprite = dynamic_cast(this->getEntity()->getComponent("SpriteRenderer")); + if (sprite == nullptr) {return;} + if (this->flash > 0.0f) { + this->flash -= dt; + sprite->setColour(vec4(1.0f, 1.0f, 1.0f, 1.0f)); + if (this->flash <= 0.0f) {sprite->setColour(this->colour);} + } + } + + vec4 colour = vec4(1.0f); + +}; + +REGISTER(Component, ImpactFlash); + +// Sprites are rectangles, so balls draw their outline with debug lines. +class BallOutline : public Component { + + public: + + BallOutline() : Component("BallOutline") { + this->setImguiable(false); + } + + void update(float dt) override { + Rigidbody* r = dynamic_cast(this->getEntity()->getComponent("Rigidbody")); + if (r == nullptr) {return;} + for (Collider* c : r->getColliders()) { + CircleCollider* circle = dynamic_cast(c); + if (circle == nullptr) {continue;} + vec2 centre = circle->getPosition(); + float rotation = this->getEntity()->getRotation(); + DebugDraw::drawCircleCollider(centre, circle->getRadius(), vec3(1.0f, 0.8f, 0.3f), 1); + DebugDraw::drawLine(centre, centre + circle->getRadius() * vec2(cosf(rotation), sinf(rotation)), vec3(1.0f, 0.8f, 0.3f), 1); + } + } + +}; + +REGISTER(Component, BallOutline); + +void spawnBox(Scene* scene, vec2 position, vec2 size, float mass, vec4 colour) { + Entity* e = new Entity(position, size, 0.0f); + + SpriteRenderer* sprite = new SpriteRenderer(); + sprite->setColour(colour); + e->addComponent(sprite); + + Rigidbody* rigidbody = new Rigidbody(); + BoxCollider* box = new BoxCollider(); + box->setSize(size); + box->setMass(mass); + rigidbody->addCollider(box); + rigidbody->setFriction(0.4f); + rigidbody->setRestitution(0.2f); + e->addComponent(rigidbody); + + if (mass > 0.0f) { + ImpactFlash* flash = new ImpactFlash(); + flash->colour = colour; + e->addComponent(flash); + } + + scene->addEntity(e); +} + +void spawnBall(Scene* scene, vec2 position, float radius) { + Entity* e = new Entity(position, vec2(2.0f * radius), 0.0f); + Rigidbody* rigidbody = new Rigidbody(); + CircleCollider* circle = new CircleCollider(); + circle->setRadius(radius); + circle->setMass(1.0f); + rigidbody->addCollider(circle); + rigidbody->setFriction(0.3f); + rigidbody->setRestitution(0.5f); + e->addComponent(rigidbody); + e->addComponent(new BallOutline()); + scene->addEntity(e); +} + +// Handles input for the sandbox. It is not serialised, so saved scenes only contain the bodies. +class Sandbox : public Component { + + public: + + Sandbox() : Component("Sandbox") { + this->setSerialisable(false); + this->setImguiable(false); + } + + void update(float dt) override { + + frames++; + if (frameLimit > 0 && frames >= frameLimit) {Pancake::stop();} + + // Keyboard shortcuts are ignored while typing into an ImGui window, such as the console. + if (!ImGui::GetIO().WantCaptureKeyboard) { + if (KeyListener::isKeyBeginDown(GLFW_KEY_ESCAPE)) {Pancake::stop();} + if (KeyListener::isKeyBeginDown(GLFW_KEY_D)) {Console::execute("debug");} + if (KeyListener::isKeyBeginDown(GLFW_KEY_I)) {Pancake::inspector();} + if (KeyListener::isKeyBeginDown(GLFW_KEY_R)) {Pancake::reset(); Pancake::load(build);} + } + if (KeyListener::isKeyBeginDown(GLFW_KEY_GRAVE_ACCENT)) {Pancake::console();} + + if (ImGui::GetIO().WantCaptureMouse) {return;} + Camera* camera = Pancake::getScene()->getCamera(); + vec2 mouse = vec2(MouseListener::getWorldX(), MouseListener::getWorldY()); + + // Spawn bodies, unless the click is selecting an entity in the inspector. + if (MouseListener::isMouseBeginDown(GLFW_MOUSE_BUTTON_LEFT) && !Inspector::opened()) { + bool shift = KeyListener::isKeyDown(GLFW_KEY_LEFT_SHIFT) || KeyListener::isKeyDown(GLFW_KEY_RIGHT_SHIFT); + if (shift) {spawnBall(Pancake::getScene(), mouse, 0.4f);} + else {spawnBox(Pancake::getScene(), mouse, vec2(0.8f, 0.8f), 1.0f, randomColour());} + } + + // Pan with the right mouse button and zoom with the scroll wheel. + if (MouseListener::isMouseDown(GLFW_MOUSE_BUTTON_RIGHT) && MouseListener::isMouseDragging()) { + camera->addPosition(vec2(-MouseListener::getWorldDx(), -MouseListener::getWorldDy())); + } + if (MouseListener::getScrollY() != 0.0) { + camera->scaleZoom(MouseListener::getScrollY() > 0.0 ? 1.1f : 1.0f / 1.1f); + } + + } + + static void build(Scene* scene) { + + scene->setName("Sandbox"); + scene->getPhysics()->setGravity(vec2(0.0f, -10.0f)); + scene->getCamera()->setPosition(vec2(0.0f, 2.0f)); + scene->getCamera()->setProjectionHeight(17.0f); + + Entity* input = new Entity(); + input->addComponent(new Sandbox()); + scene->addEntity(input); + + // Instructions. + Entity* text = new Entity(vec2(0.0f, 9.6f), vec2(0.45f, 0.45f), 0.0f); + TextRenderer* label = new TextRenderer(); + label->setText("Click: box Shift+Click: ball D: debug I: inspector `: console R: reset"); + label->setAlignment(TextRenderer::CENTER); + text->addComponent(label); + scene->addEntity(text); + + // Ground and walls. + vec4 grey = vec4(0.35f, 0.35f, 0.4f, 1.0f); + spawnBox(scene, vec2(0.0f, -4.5f), vec2(20.0f, 1.0f), 0.0f, grey); + spawnBox(scene, vec2(-10.5f, 1.0f), vec2(1.0f, 12.0f), 0.0f, grey); + spawnBox(scene, vec2(10.5f, 1.0f), vec2(1.0f, 12.0f), 0.0f, grey); + + // A pyramid of crates and a few balls. + for (int row = 0; row < 5; row++) { + for (int i = 0; i <= 4 - row; i++) { + float x = -2.0f + i * 1.0f + row * 0.5f; + spawnBox(scene, vec2(x, -3.5f + row * 1.0f), vec2(1.0f, 1.0f), 1.0f, randomColour()); + } + } + for (int i = 0; i < 3; i++) {spawnBall(scene, vec2(-6.0f + i * 1.2f, 3.0f + i), 0.5f);} + + } + +}; + +int main(int argc, char* argv[]) { + + for (int i = 1; i < argc - 1; i++) { + if (std::strcmp(argv[i], "--frames") == 0) {frameLimit = std::atoi(argv[i + 1]);} + } + + Pancake::title("Pancake Sandbox"); + Pancake::size(1024, 768); + Pancake::load(Sandbox::build); + Pancake::start(); + return 0; + +} From 36cc0a3f531f0e1f87bfddacc5a4b7cd14ffd22b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 00:20:47 +0000 Subject: [PATCH 9/9] Update README and CLAUDE.md for the finished engine. Document building the engine on its own, the tests and options, the debugging tools, and upgrade notes for world gravity, the new solver and per-frame input queries. Fix the README example, which never fell because nothing applied gravity, and drop the gamepad claim as there is no gamepad support. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSbmWwU5zh3ujWmjioWg1a --- CLAUDE.md | 79 +++++++++++++++++++++++++++++++++++-------------------- README.md | 57 +++++++++++++++++++++++++++++++++++---- 2 files changed, 102 insertions(+), 34 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cc252a6..74e7357 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,53 +4,74 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this is -Pancake is a small 2D C++ game engine built as a **static library** (`pancake`). There is no executable target in this repo: games are built from a separate template project (https://github.com/RohanFredriksson/PancakeTemplate) that adds this repo as a CMake subdirectory and links `pancake`. Dependencies: GLFW, GLM, glad (vendored), stb (vendored), nlohmann/json, Dear ImGui, SoLoud (author's fork). +Pancake is a small 2D C++17 game engine built as a **static library** (`pancake`). Games are normally built from a separate template project (https://github.com/RohanFredriksson/PancakeTemplate) that adds this repo as a CMake subdirectory and links `pancake`. This repo has its own tests and a demo sandbox in `examples/demo.cpp`. Dependencies: GLFW, GLM, glad (vendored), stb (vendored), nlohmann/json, Dear ImGui, SoLoud (author's fork). -## Build +## Build and test ```bash -git submodule update --init --recursive # glfw, glm, imgui, json, soloud are submodules -cmake -S . -B build -DGLFW_BUILD_WAYLAND=OFF -make -C build -j8 pancake +git submodule update --init --recursive +cmake -S . -B build -DGLFW_BUILD_WAYLAND=OFF # add -DPANCAKE_ASAN=ON for AddressSanitizer +cmake --build build -j +ctest --test-dir build --output-on-failure # all tests +ctest --test-dir build -L unit # only the tests that need no window +ctest --test-dir build -R test_physics # one test executable +./build/tests/test_physics # run directly to see each case +./build/examples/pancake_demo [--frames N] ``` -- On Linux, GLFW needs X11 dev headers (`libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libgl1-mesa-dev`) and SoLoud needs `libasound2-dev`. -- No C++ standard is set; this relies on the compiler default (C++17 on GCC 13). `spatial.hpp` uses `SpatialHashGrid(float)` as a constructor name, which is invalid in C++20. -- `CMAKE_EXE_LINKER_FLAGS` adds `-fsanitize=address` on non-Windows, so anything linking the library gets ASan at link time. -- `src/*.cpp` is picked up by `GLOB_RECURSE`; re-run cmake after adding files. +- On Linux, install `libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libgl1-mesa-dev libasound2-dev xvfb`. With CMake 4+, add `-DCMAKE_POLICY_VERSION_MINIMUM=3.5`, because the bundled glad/glm/json declare old minimums. +- Options `PANCAKE_BUILD_TESTS` and `PANCAKE_BUILD_EXAMPLES` default to on only when this is the top level project. `PANCAKE_ASAN` applies to `pancake` publicly, so test and demo binaries get it too. +- CI (`.github/workflows/ci.yml`) runs an ASan build and a Release build, both on ubuntu-24.04 under Xvfb. -## Tests / running +### Tests -There is no test suite, linter, or CI. To exercise the engine, write a small executable that links `pancake` (`add_subdirectory( pancake)` + `target_link_libraries(x pancake)`), call `Pancake::load(initFn); Pancake::start();`, and run it headless with `xvfb-run -a ./x` (Mesa llvmpipe provides GL 3.3 core). Drive it from a `Component::update` that calls `Pancake::stop()` after N frames. ALSA/JACK warnings without a sound card are harmless. +There is no external test framework. `tests/test.hpp` provides `TEST("name") {...}`, `CHECK`, `CHECK_NEAR` and `RUN_TESTS()`. Each `tests/test_*.cpp` is its own executable, registered in `tests/CMakeLists.txt`: +- **Unit tests** (`pancake_test`) build physics bodies and a `World` directly, with no window and no scene. Use `tests/helpers.hpp` (`PancakeTest::Bodies`), which owns the entities. LeakSanitizer runs with `tests/lsan.supp`, which suppresses leaks inside audio and GL drivers. +- **Engine tests** (`pancake_engine_test`, and `demo_smoke` in `examples/`) run the real loop in a window, wrapped in `xvfb-run` when it is available. Leak detection is off for these, because Mesa leaks from libraries it unloads, which can't be suppressed by name. `tests/test_engine.cpp` scripts the loop from a `Driver` component. Physics runs in real time, so it waits for a condition (the box settling) and then times later steps relative to that, never to absolute frame numbers. ## Architecture -**Engine/Window loop** (`core/engine.cpp`, `core/window.cpp`). `Pancake::*` free functions are a thin facade over the `Window` namespace, which holds all global state (GLFW window, current `Scene*`, shaders, entity-picking framebuffer). Calls such as `load`, `save`, `reset`, `title`, and `size` only **set flags**; the main loop applies them at the top of the next frame. `load(fn)` runs the init function against the current scene, while `reset()` deletes the scene and creates a new empty one. Each frame does `scene->update(dt)`, then renders twice: once into an RGB32F framebuffer with the entity shader (entity IDs for mouse picking via `Window::readPixel`) and once to the screen. After that come `DebugDraw::render()` and the ImGui console. +**Engine/Window loop** (`core/engine.cpp`, `core/window.cpp`). `Pancake::*` free functions are a thin facade over the `Window` namespace, which holds all global state (GLFW window, current `Scene*`, shaders, entity-picking framebuffer). +- Calls such as `load`, `save`, `reset`, `title`, and `size` only **set flags**, which the main loop applies at the top of the next frame. `load(fn)` runs the init function against the current scene, and `reset()` replaces the scene with an empty one. +- A component that resets the scene deletes itself, so tests and demos must `load` a new driver in the same frame. +- Each frame does `scene->update(dt)`, then renders twice: once into an RGB32F framebuffer with the entity shader (sprite entity IDs for `Window::readPixel`) and once to the screen. Then `DebugDraw::render()` runs, followed by ImGui (`Console::render`, `Inspector::render`). +- Shutdown deletes the scene, then the GL resources, then ImGui. -**Scene → Entity → Component** (`core/`). `Scene` owns `Entity*`s plus a `Camera`, a `Renderer` (sprite batches) and a physics `World`. Entities own `Component*`s. IDs come from static counters, and loading from JSON bumps the counter past loaded IDs. Lifecycle: -- `start()` runs when the entity is added to a started scene (or on `Scene::start`). Components **self-register** with scene subsystems in `start()` through `Window::getScene()` (e.g. `SpriteRenderer` → `getRenderer()->add`, `Rigidbody` → `getPhysics()->add`, `ForceGenerator` → `getPhysics()->addForceGenerator`) and unregister in `end()`. -- `kill()` marks the component dead and calls `end()`. The owning `update` loop deletes dead entities and components later. `Entity::~Entity` deletes its components. -- `TransformableComponent` adds a position/size/rotation offset relative to the entity. `Entity::addRotation` rotates the offsets of all transformable components. +**Scene → Entity → Component** (`core/`). `Scene` owns `Entity*`s plus a `Camera`, a `Renderer` (sprite batches), a physics `World`, and a `SpatialHashGrid`. That grid is refreshed every frame for entities whose bounds (transform plus rigidbody) changed, and it backs `getEntitiesIn/Near/At`. Entities own `Component*`s. IDs come from static counters, and loading from JSON bumps the counter past loaded IDs. Lifecycle: +- `start()` runs when the entity is added to a started scene. Most components self-register with scene systems in `start()` through the global `Window::getScene()` (e.g. `SpriteRenderer` → `getRenderer()->add`, `Rigidbody` → `getPhysics()->add`) and unregister in `end()`. +- `kill()` marks a component dead and calls `end()`. The owning update loop deletes dead entities and components afterwards. +- `Scene::~Scene` kills every entity before deleting any systems, so components can unregister while the systems still exist. +- `TransformableComponent` adds a position/size/rotation offset relative to the entity. -Because of the reliance on the global `Window::getScene()`, components assume they belong to the *current* scene, including during destruction. +**Serialisation / reflection** (`core/factory.hpp`). `REGISTER(Base, Derived)` registers a default constructor under the class name. `FACTORY(Component).create(type)` is used by `Entity::load`, and `FACTORY(Collider)` by `Rigidbody::load`. Every serialisable component needs a default constructor, a `serialise()`/`load(json)` pair that chains to its base class, and a `REGISTER` line. New fields added to existing components should be optional on load, so older save files keep loading (see `gravityScale`). `Scene::serialise` also writes gravity and the asset pools (fonts, spritesheets, sprites, audio). -**Serialisation / reflection** (`core/factory.hpp`). `REGISTER(Base, Derived)` creates a static `Creator` that registers a default constructor under the class name. `FACTORY(Component).create(type)` is used by `Entity::load`, and `FACTORY(Collider)` by `Rigidbody::load`. Every serialisable component needs a default constructor, a `serialise()`/`load(json)` pair that chains to its base class, and a `REGISTER` line in its header. `Scene::serialise` also writes the asset pools (fonts, spritesheets, sprites, audio) so a save file is self-contained. +**Physics** (`physics/`). `Rigidbody` is a component holding non-component `Collider`s (`BoxCollider`, `CircleCollider`), which it owns. Mass, centroid, moment and bounds are lazily recomputed via dirty flags. A body with zero total collider mass is static. +- Rigidbodies and `ForceGenerator`s keep a `World*` (set by `World::add`/`addForceGenerator`) and unregister through it, not through the global scene. The `World` never deletes them, because their entities own them. +- `World::fixedUpdate` runs at 1/60 s, at most 5 steps per frame: + 1. Force generators add forces. + 2. `integrateVelocity` adds forces and world gravity × `gravityScale`. + 3. Pairs are found through the `SpatialHashGrid` and ordered by id. + 4. Narrow phase runs (`collision.cpp`) and `CollisionListener` components are notified. + 5. Velocities are solved with warm-started sequential impulses, using a contact cache keyed by body id pair. + 6. `integratePosition` moves the bodies. + 7. Positions are corrected once. -**Physics** (`physics/`). `Rigidbody` is a component that holds non-component `Collider`s (`BoxCollider`, `CircleCollider`, owned by the rigidbody). Mass, centroid, moment and bounds are lazily recomputed via dirty flags. `World::update` steps at a fixed timestep (1/60) and does the following: -- integrates bodies, then culls pairs with `SpatialHashGrid` (cell size 4); bodies update their grid cells when their bounds change in `physicsUpdate` -- runs narrow phase (`Collision::findCollisionFeatures`, SAT-style box/box, circle/circle, circle/box) and notifies any component on either entity that also inherits `CollisionListener` -- applies forces through `ForceRegistry` -- resolves impulses iteratively (6 iterations) + This order matters: integrating positions before solving made stacks jitter and collapse. +- Collision features use one convention: the normal points from the first collider to the second, depth is the full penetration, and the point lies midway between the surfaces. +- Forces are opt-in per body: `rigidbody->addForceGenerator("Gravity")` subscribes the body to the generator component of that type name. +- `World::raycast(ray, maxDistance)` walks grid cells along the ray. +- Physics debug drawing is off by default (`World::setDebugDraw`, console `debug`). -Forces are opt-in per body: a rigidbody lists force-generator type names (`addForceGenerator("Gravity")`), and a `ForceGenerator` component somewhere in the scene with that type name provides the force. There is **no gravity by default**; the `World` constructor's gravity argument is unused. Bodies with zero total collider mass are treated as infinite-mass statics. `sensor` bodies report collisions but get no impulse. +**Rendering** (`graphics/`). `Renderer` groups `SpriteRenderer`s into `RenderBatch`es by z-index (max 1000 quads and 7 textures per batch). `TextRenderer` and `Animation` create child `SpriteRenderer`s. Shader sources are constants in `asset/shaders.hpp`, and the default font (Pixellari) is embedded in `asset/fonts.hpp`. Assets are cached by name in the namespaces in `asset/assetpool.hpp`. `DebugDraw` draws immediate-mode lines with a lifetime in frames. -**Rendering** (`graphics/`). `Renderer` groups `SpriteRenderer`s into `RenderBatch`es by z-index (max 1000 quads and 7 textures per batch). `TextRenderer` and `Animation` produce child `SpriteRenderer`s. Shader sources are string constants in `asset/shaders.hpp`, and the default font (Pixellari) is embedded as a byte array in `asset/fonts.hpp`. Assets are cached by name in the namespaces in `asset/assetpool.hpp` (`TexturePool`, `SpritePool`, `FontPool`, `AudioPool`). `DebugDraw` is immediate-mode lines and boxes with a frame lifetime. +**Tools** (`core/console.cpp`, `core/inspector.cpp`). The console has a command registry (`Console::registerCommand`, `Console::execute`). The inspector picks the clicked entity with the picking texture, falling back to scene bounds for entities without sprites. It stores the selection as an id so it never dereferences a deleted entity, and it draws each component's `imgui()`. -**Audio** (`audio/`). A SoLoud wrapper. `AudioEngine::init/destroy` wraps `Window::start()` in `Pancake::start()`. `AudioPlayer` is the component and `AudioWave` is the pooled asset. +**Audio** (`audio/`). A SoLoud wrapper. `AudioEngine::init/destroy` wraps `Window::start()` in `Pancake::start()`. ## Conventions - Namespace `Pancake`. Internal helpers live in anonymous namespaces inside `.cpp` files. Headers include their dependents at the *bottom* (e.g. `component.hpp` includes `entity.hpp` after the class) to break include cycles. -- The recent commits ("The war against using has begun") have been removing `using` declarations in favour of fully qualified `std::`/`glm::` names; follow that in touched code. Some headers still export `using std::string; using glm::vec2;` etc., and other code relies on that. -- Single-line `if (...) {...}` braces; British spelling (`serialise`, `colour`, `initialiser`). -- Errors are printed as `ERROR::AREA::DETAIL` to stdout; load functions return `false`/`nullptr` on malformed JSON instead of throwing. +- Include `` before anything that pulls in GLFW (including `pancake/core/listener.hpp`), or glad's header guard errors. +- Prefer fully qualified `std::`/`glm::` names in new code. Some headers still export `using std::string; using glm::vec2;` etc., and other code relies on that. +- Single-line `if (...) {...}` braces; British spelling (`serialise`, `colour`). +- Errors are printed as `ERROR::AREA::DETAIL` to stdout. Load functions return `false`/`nullptr` on malformed JSON instead of throwing. diff --git a/README.md b/README.md index fe15314..7d18537 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,29 @@ cmake .. make ``` +On Linux, GLFW and SoLoud need the X11, OpenGL and ALSA development packages: +``` +sudo apt-get install libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libgl1-mesa-dev libasound2-dev +``` + +## Building the Engine on its Own +The engine can also be built directly, which builds its tests and a demo sandbox. +``` +git submodule update --init --recursive +cmake -S . -B build +cmake --build build -j +ctest --test-dir build --output-on-failure +./build/examples/pancake_demo +``` + +| Option | Default | Description | +| --- | --- | --- | +| `PANCAKE_BUILD_TESTS` | On when top level | Build the unit and engine tests. | +| `PANCAKE_BUILD_EXAMPLES` | On when top level | Build the demo sandbox in `examples/`. | +| `PANCAKE_ASAN` | Off | Build the engine, and everything linking it, with AddressSanitizer. | + +The engine tests open a real window. On a machine without a display, they run inside `xvfb-run` when it is installed. + ## Example ```c++ #include "pancake/pancake.hpp" @@ -65,7 +88,6 @@ void TestInit(Scene* scene) { Entity* entity; MousePanHandler* mousepanhandler; Rigidbody* rigidbody; - CircleCollider* circle; BoxCollider* box; // Set the scene name @@ -77,7 +99,10 @@ void TestInit(Scene* scene) { entity->addComponent(mousepanhandler); scene->addEntity(entity); - // Ground + // Gravity applies to every body by default. It can be changed per scene, or per body with setGravityScale. + scene->getPhysics()->setGravity(vec2(0.0f, -10.0f)); + + // Ground. A body with no mass is static. entity = new Entity(vec2(0.0f, -5.0f), vec2(1.0f, 1.0f), 0.0f); rigidbody = new Rigidbody(); box = new BoxCollider(); @@ -110,13 +135,35 @@ int main(int argc, char* argv[]) { ## Features The engine provides the following features: - 2D graphics rendering using OpenGL. -- 2D rigidbody physics engine supporting arbitrary collider shapes. +- 2D rigidbody physics with box and circle colliders, combinable into compound bodies, solved with warm started sequential impulses so stacks stay stable. +- World gravity, and force generator components for custom forces that act only on the bodies that opt in. +- Collision listeners, sensors and raycasts. +- Spatial queries to find entities in an area, near a point or under the mouse. - Audio playback and management using SoLoud. -- Input management for keyboard, mouse and gamepad. +- Input management for keyboard and mouse. - Entity-component system for managing game objects. - Resource loading from file system. -- JSON serialization and deserialization for game data. +- JSON serialization and deserialization for game data, including custom components. +- An in-game console with built-in commands (`help`, `save`, `load`, `reset`, `debug`, `gravity`, `entities`, `inspector`) and support for your own. +- An entity inspector: click an entity to view and edit its components. - Debug drawing for collision shapes and other debugging purposes. +## Debugging Tools +```c++ +Pancake::console(true); // Open the console. Pancake::console() toggles it. +Pancake::inspector(true); // Open the inspector. Pancake::inspector() toggles it. +Pancake::debug(true); // Draw physics colliders, bounds and contacts. + +// Add a console command. +Console::registerCommand("spawn", "spawn : Spawn a crate.", [](std::vector args) { + // ... +}); +``` + +## Upgrading +- Physics scenes now have world gravity, defaulting to `(0, -10)`. If you previously added a `Gravity` force generator to every body to get gravity, remove it or set the world gravity to zero with `scene->getPhysics()->setGravity(vec2(0.0f, 0.0f))`. +- The collision solver was rewritten, so restitution and friction may feel slightly different. Bounces below 1 unit per second are no longer applied, so resting bodies don't jitter. +- `KeyListener::isKeyBeginDown` and `MouseListener::isMouseBeginDown` are now true for the whole frame a key or button was pressed in, rather than only for the first caller. + ## Contributions Contributions are welcome! If you find any bugs or have suggestions for improvements, feel free to open an issue or submit a pull request on GitHub.