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/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..74e7357 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,77 @@ +# 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++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 and test + +```bash +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, 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 + +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**, 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), 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. + +**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). + +**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. + + 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`). + +**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. + +**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()`. + +## 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. +- 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/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/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. 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; + +} 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/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/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/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/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/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/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 7f43323..2e5c807 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,15 @@ namespace Pancake { public: Rigidbody(); + ~Rigidbody() override; void start() override; void end() override; json serialise() override; bool load(json j) override; + void imgui() override; + World* getWorld(); + void setWorld(World* world); std::vector getColliders(); std::unordered_set getForceGenerators(); glm::vec2 getVelocity(); @@ -59,6 +66,7 @@ namespace Pancake { float getAngularVelocity(); float getRestitution(); float getFriction(); + float getGravityScale(); float getMass(); float getInverseMass(); float getMomentOfInertia(); @@ -92,6 +100,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(); @@ -100,6 +109,9 @@ namespace Pancake { Rigidbody* setMomentDirty(); void physicsUpdate(float dt); + void integrateVelocity(float dt); + void integratePosition(float dt); + void updateGridRegistration(bool moved); void clearAccumulators(); void addVelocity(glm::vec2 velocity); diff --git a/include/pancake/physics/world.hpp b/include/pancake/physics/world.hpp index 10b87c5..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; @@ -32,8 +42,10 @@ namespace Pancake { SpatialHashGrid* grid; + glm::vec2 gravity; float timeStep; float time; + bool debug; void fixedUpdate(); @@ -44,6 +56,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 +74,7 @@ namespace Pancake { SpatialHashGrid* getGrid(); RaycastResult raycast(Ray ray); + RaycastResult raycast(Ray ray, float maxDistance); }; 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 f18f3f7..5383aa0 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,27 @@ namespace Pancake { // If not active, don't render the terminal. if (!active) {return;} - ImGui::Begin("Console"); + // 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;} // 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 +160,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/entity.cpp b/src/core/entity.cpp index b4f7796..312d7b9 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; } @@ -279,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/core/inspector.cpp b/src/core/inspector.cpp new file mode 100644 index 0000000..0ca2e7b --- /dev/null +++ b/src/core/inspector.cpp @@ -0,0 +1,141 @@ +#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(); + } + + // 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. + 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/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..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" @@ -27,22 +28,24 @@ 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; + } + + this->gridBounds.clear(); + // 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() { @@ -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 701e918..43dc55e 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" @@ -24,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; @@ -89,6 +92,7 @@ namespace Pancake { ImGui_ImplGlfw_NewFrame(); ImGui::NewFrame(); Console::render(); + Inspector::render(); ImGui::Render(); ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); @@ -99,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); } } @@ -130,6 +140,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"; @@ -154,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); @@ -187,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(); @@ -226,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; } @@ -269,8 +282,8 @@ namespace Pancake { render(); } + KeyListener::endFrame(); MouseListener::endFrame(); - Window::readPixel(0, 0); endTime = (float)glfwGetTime(); dt = endTime - beginTime; @@ -278,10 +291,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(); } @@ -317,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; } @@ -346,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/debugdraw.cpp b/src/graphics/debugdraw.cpp index 65189c2..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) { @@ -75,7 +76,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 +132,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,16 +154,33 @@ 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 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/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); } 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 bb0dabc..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" @@ -8,6 +9,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 +18,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 +37,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 +76,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 +115,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 +128,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()) { @@ -129,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; } @@ -186,8 +247,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 +272,6 @@ namespace Pancake { } - glm::vec2 position = this->getEntity()->getPosition(); this->bounds = std::make_pair(position + first, position + second); this->boundsDirty = false; return this->bounds; @@ -224,6 +291,10 @@ namespace Pancake { return this->friction; } + float Rigidbody::getGravityScale() { + return this->gravityScale; + } + float Rigidbody::getMass() { if (!this->massDirty) {return this->mass;} @@ -262,12 +333,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; @@ -367,18 +437,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; } @@ -393,18 +455,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; } @@ -427,7 +480,8 @@ namespace Pancake { } Rigidbody* Rigidbody::clearForceGenerators() { - this->forceGenerators.clear(); + std::vector types(this->forceGenerators.begin(), this->forceGenerators.end()); + this->removeForceGenerators(types); return this; } @@ -477,6 +531,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; @@ -514,23 +573,44 @@ namespace Pancake { } void Rigidbody::physicsUpdate(float dt) { - - // Do not do a physics update if static. - if (this->hasInfiniteMass()) {return;} + this->integrateVelocity(dt); + this->integratePosition(dt); + } - // Required variables - glm::vec2 displacement = glm::vec2(0.0f, 0.0f); - float rotation = 0.0f; + void Rigidbody::integrateVelocity(float dt) { + + // Static bodies don't move. + if (this->hasInfiniteMass()) { + this->clearAccumulators(); + return; + } + + // 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; + 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 - this->velocity += this->force * (dt / this->getMass()); - displacement = this->velocity * dt; + 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->getMass()); rotation = this->angularVelocity * dt; this->getEntity()->addRotationAround(rotation, this->getCentroid()); @@ -548,20 +628,23 @@ 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); + + } + + void Rigidbody::updateGridRegistration(bool moved) { - // Check if there has been a changed in the bounding box. - this->boundsDirty = (this->lastPosition != this->getEntity()->getPosition()) || + // 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. - 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 679c84e..8906d1a 100644 --- a/src/physics/world.cpp +++ b/src/physics/world.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include #include @@ -13,180 +14,181 @@ namespace Pancake { namespace { - class ImpulseResult { - - public: - - glm::vec2 aPosition; - glm::vec2 bPosition; - glm::vec2 aVelocity; - glm::vec2 bVelocity; - float aAngularVelocity; - float bAngularVelocity; - float aAngularVelocityTotal; - float bAngularVelocityTotal; - float aAngularOrbitalSpeed; - 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; - this->bAngularVelocity = 0.0f; - this->aAngularVelocityTotal = 0.0f; - this->bAngularVelocityTotal = 0.0f; - this->aAngularOrbitalSpeed = 0.0f; - this->bAngularOrbitalSpeed = 0.0f; - } + const int VELOCITY_ITERATIONS = 10; + const int MAX_STEPS_PER_UPDATE = 5; + const float RESTITUTION_THRESHOLD = 1.0f; // Contacts slower than this do not bounce, which stops resting bodies jittering. - 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; - 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; - } + inline float cross(glm::vec2 a, glm::vec2 b) { + return a.x * b.y - a.y * b.x; + } + + // 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); + } + + // 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; + + } + + 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)); + } - 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; - this->bAngularVelocity += rhs.bAngularVelocity; - this->aAngularVelocityTotal += rhs.aAngularVelocityTotal; - this->bAngularVelocityTotal += rhs.bAngularVelocityTotal; - this->aAngularOrbitalSpeed += rhs.aAngularOrbitalSpeed; - this->bAngularOrbitalSpeed += rhs.bAngularOrbitalSpeed; - return *this; + // 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; } + } - }; - - const int IMPULSE_ITERATIONS = 6; - - inline glm::vec2 perp(glm::vec2 v) { - return glm::rotate(v, glm::half_pi()); - } + void solve() { - inline float proj(glm::vec2 a, glm::vec2 b) { - return glm::dot(a, b) / glm::length(b); - } + // 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); - ImpulseResult applyImpulse(Rigidbody* a, Rigidbody* b, CollisionManifold m) { + } + + }; - // Check for infinite mass objects. - ImpulseResult result; - if (a->hasInfiniteMass() && b->hasInfiniteMass()) {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) { - 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); + const float slop = 0.005f; + const float percent = 0.8f; 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); - + if (invMassA + invMassB <= 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) { + if (m.depth <= deepest) {continue;} + deepest = m.depth; + correction = m.normal * (std::max(m.depth - slop, 0.0f) * percent / (invMassA + invMassB)); } - // 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; - } - - if (b->hasInfiniteMass()) {result.aPosition -= m.depth * m.normal;} - if (a->hasInfiniteMass()) {result.bPosition += m.depth * m.normal;} + a->getEntity()->addPosition(-correction * invMassA); + b->getEntity()->addPosition(correction * invMassB); - return result; } } 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() { - for (ForceGenerator* force : this->forces) {delete force;} + + // 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() { - // 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; @@ -199,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()); @@ -256,6 +259,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)); @@ -270,52 +281,92 @@ 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; - - 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);} + // 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); + } + } - r1->getEntity()->addPosition(sum.aPosition); - r2->getEntity()->addPosition(sum.bPosition); - r1->addVelocity(sum.aVelocity); - r2->addVelocity(sum.bVelocity); - r1->addAngularVelocity(sum.aAngularVelocity); - r2->addAngularVelocity(sum.bAngularVelocity); + for (int k = 0; k < VELOCITY_ITERATIONS; k++) { + for (Contact& contact : contacts) {contact.solve();} + } - 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));} + // 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}); + } - } - + // 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]); } } 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); @@ -340,8 +391,11 @@ 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. + if (!this->has(rigidbody)) {return;} + rigidbody->setWorld(nullptr); this->rigidbodiesIndex.erase(rigidbody); + this->grid->remove(rigidbody); // Remove the rigidbody from the rigidbody vector. int n = this->rigidbodies.size(); @@ -365,6 +419,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); @@ -374,6 +429,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; @@ -395,6 +492,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) { @@ -404,8 +502,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) { @@ -415,7 +514,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); @@ -423,7 +524,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; } 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()