From ac0225484f6ec5336e532e3292fecb88bca83d79 Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 12 Oct 2026 21:55:52 +0200 Subject: [PATCH] feat: implement sprite renderer component --- .github/workflows/ci.yml | 2 +- CLAUDE.md | 10 +- README.md | 98 +++++++++++- cmake/Dependencies.cmake | 3 +- include/parser/Parser.hpp | 14 +- include/scene/Scene.hpp | 1 + include/scene/components/BlinkComponent.hpp | 4 +- include/scene/components/Component.hpp | 8 +- .../components/ComponentBuildContext.hpp | 24 +++ .../scene/components/ComponentRegistry.hpp | 7 +- .../components/MarkerEmitterComponent.hpp | 6 +- include/scene/components/SpriteRenderer.hpp | 55 +++++++ protoFiles/neuronide.proto | 4 +- src/CMakeLists.txt | 12 +- src/parser/Parser.cpp | 21 ++- src/renderer/Renderer.cpp | 8 +- src/scene/CMakeLists.txt | 12 +- src/scene/Scene.cpp | 8 + src/scene/components/BlinkComponent.cpp | 5 +- src/scene/components/ComponentRegistry.cpp | 5 +- .../components/MarkerEmitterComponent.cpp | 3 +- src/scene/components/SpriteRenderer.cpp | 90 +++++++++++ tests/CMakeLists.txt | 3 +- tests/component_tests/sprite_scene_test.cpp | 109 +++++++++++++ tests/registration_tests/CMakeLists.txt | 7 + .../registration_tests/registration_test.cpp | 52 ++++++ tests/unit_tests/RendererTest.cpp | 30 ++++ .../component_build_context_test.cpp | 37 +++++ tests/unit_tests/sprite_renderer_test.cpp | 148 ++++++++++++++++++ tests/utils/SdlTestUtils.hpp | 85 ++++++++++ tests/utils/TempDir.hpp | 45 ++++++ 31 files changed, 877 insertions(+), 39 deletions(-) create mode 100644 include/scene/components/ComponentBuildContext.hpp create mode 100644 include/scene/components/SpriteRenderer.hpp create mode 100644 src/scene/components/SpriteRenderer.cpp create mode 100644 tests/component_tests/sprite_scene_test.cpp create mode 100644 tests/registration_tests/CMakeLists.txt create mode 100644 tests/registration_tests/registration_test.cpp create mode 100644 tests/unit_tests/component_build_context_test.cpp create mode 100644 tests/unit_tests/sprite_renderer_test.cpp create mode 100644 tests/utils/SdlTestUtils.hpp create mode 100644 tests/utils/TempDir.hpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e9951c..0f4a484 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - name: Install dependencies run: | sudo apt-get update - sudo apt-get install -y cmake clang-format clang-tidy-20 libsdl2-dev protobuf-compiler gcovr + sudo apt-get install -y cmake clang-format clang-tidy-20 libsdl2-dev libsdl2-image-dev protobuf-compiler gcovr - name: Check Code Formatting run: | diff --git a/CLAUDE.md b/CLAUDE.md index d056b2a..dde7d9c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,8 +10,10 @@ You are in the **runtime** repo. Read the system overview below first. - Layout: `include/` headers, `src/` impl, `src/parser/Parser.cpp` turns a `.neuroz`/`.pb` file into a `Scene`, `scene/components/` component classes, `tests/{unit,component}_tests/`. - New component: subclass `Component`, add a static creator, and - `REGISTER_COMPONENT(NeuronIDE::Component::k, Creator)` in its `.cpp`. The proto oneof - entry must exist first — which means a backend change too. + `REGISTER_COMPONENT(NeuronIDE::Component::k, Creator)` in its `.cpp`, then add it to + `tests/registration_tests` (static self-registration is silently dropped unless executables + link the `runtime_core` target). The proto oneof entry must exist first — which means a + backend change too. - Test scenes: write `.pbtxt`, then `protoc --encode=NeuronIDE.Scene protoFiles/neuronide.proto < x.pbtxt > x.pb`. - Read README §3 (architecture: Renderer / LSLReader / DataWriter threads) and §5 (what goes in @@ -96,7 +98,9 @@ launcher#1. ### Known cross-repo gaps (as of 2026-10-09 — fix them or update this list) -- Proto copies have diverged: backend lacks `MarkerEmitterComponent` (backend#23). +- Proto copies have diverged: backend lacks `MarkerEmitterComponent` (backend#23), and the + runtime reserved `SpriteRenderer.texture_path` (field 1) in favour of `img_path`; the backend + and launcher copies must follow. - Experiment flow graph is not in the proto, so it isn't saved, exported or run (epic frontend#59). - Scene object color and LSL marker are not persisted (frontend#56, backend#24). - Media files (images, audio, scripts) can't be uploaded, so the runtime can't find them (backend#25). diff --git a/README.md b/README.md index efa93db..c6e8e50 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ EEG samples and the stimulus markers must share a **common clock domain** so tha | Experiment file format | Protocol Buffers (proto3) — see `protoFiles/neuronide.proto` | | Device config format | JSON (`config.json`) parsed with [nlohmann/json](https://github.com/nlohmann/json) | | EEG acquisition | [LSL — Lab Streaming Layer](https://github.com/sccn/liblsl) (`liblsl`) | -| Rendering / windowing | SDL2 (+ SDL2_image), with vsync | +| Rendering / windowing | SDL2 + SDL2_image, with vsync (SDL2_ttf planned, see [Components](#components)) | | Inter-thread queues | [moodycamel ConcurrentQueue](https://github.com/cameron314/concurrentqueue) (lock-free) | | Python scripting (planned)| pybind11 (`ScriptComponent`) | | Testing | GoogleTest + CTest | @@ -138,7 +138,28 @@ classDiagram Components are built from protobuf messages by a **`ComponentRegistry`** (a singleton mapping a proto component type id → factory function). New component types self-register via the `REGISTER_COMPONENT(typeId, creatorFunc)` macro, so -the parser does not need to know about concrete component classes. +the parser does not need to know about concrete component classes. Each creator +also receives a `ComponentBuildContext`, which resolves asset paths: a relative +path in the `.neuroz` is taken relative to the folder holding the `.neuroz`, and an +absolute one is used as-is. + +Self-registration runs from static initializers that nothing else references, so a +plain static link silently drops them. Executables therefore link the `runtime_core` +target, which pulls in the whole archive. `tests/registration_tests` guards this, and +every new component type gets a line there. + +After construction, a component goes through two hooks, both before the first frame: + +1. `onSceneReady(Scene&)`, called by `Parser` once every object exists, to resolve + and subscribe to events. +2. `onRenderTargetReady(const std::shared_ptr&)`, called by the + `Renderer` constructor once the window exists, to create GPU resources such as + textures. Doing it there, not lazily on the first `render()`, keeps uploads from + delaying the first stimulus frame and its marker. SDL frees a renderer's textures + along with it, so a component holding textures keeps the target alive. + +Anything that can fail on bad input (a missing image, for instance) fails during +parsing, before the window opens. ### Core data structures @@ -359,7 +380,7 @@ correct when `SDL_RenderPresent` waits for the refresh. | `Scene` / `SceneObject` | Implemented | component containers | | `Component` (base) | Implemented | abstract `update` / `render` | | `ComponentRegistry` | Implemented | proto-type → factory, macro-based self-registration | -| `specifiic components` | **Planned** | defined in `neuronide.proto`, not yet implemented in C++ | +| Scene components | **Partial** | 3 of the 9 MVP components have a C++ class, see [Components](#components) | | `Renderer` | Implemented | SDL + vsync, marker timestamping | | `LSLReader` | Implemented | LSL inlet → `eegQueue`, clock-synced (see §4); driven by `DeviceConfig`, enabled channels only, re-resolves lost streams, reports `AcquisitionState` | | `ConfigParser` | Implemented | `config.json` → `DeviceConfig` (1:1 mapping, major-version checked, see §5), nlohmann/json | @@ -371,6 +392,72 @@ correct when `SDL_RenderPresent` waits for the refresh. The class diagram in older docs is partly aspirational; the table above reflects the actual code. +### Components + +The MVP set of components an experiment is built from, and where each one stands. +Status as of **2026-10-10**, checked against `main`, open PRs and `neuronide.proto`. +Update the row in the same PR that changes it. + +| Component | What it does | Proto (`neuronide.proto`) | C++ (`scene/components/`) | Status | Tracking | +| --- | --- | --- | --- | --- | --- | +| Transform | Position, size, rotation. Exactly one per object. x/y is the top-left corner in window pixels, rotation is degrees clockwise about the centre. | `SceneObject.transform`, a field rather than a `Component` | `SceneObject::Transform` | **Partial**: parsed onto the object and drawn by SpriteRenderer, but not required (an object without one sits at all zeros; a sprite on it is rejected at parse time) | — | +| SpriteRenderer | Draws an image: visual stimuli, photos, icons. | `SpriteRenderer` (`renderer = 1`), `img_path`; field 1 (`texture_path`) is reserved | `SpriteRenderer`, registered | **Implemented**: any SDL2_image format (PNG, JPEG, …), stretched over the Transform and rotated. Decoded at parse time, so a missing or unreadable file stops the run before the window opens; uploaded to the GPU before the first frame | [#17][rt-17], [backend#25][be-25] | +| TextRenderer | Draws text in a chosen font and size: instructions, letters, numbers. | `TextRenderer` (`text = 2`) | — | **Planned**: needs SDL2_ttf, which is not a dependency yet. The message has no text color | [#18][rt-18], [backend#25][be-25] | +| BlinkComponent | Flickers the object at a set frequency in Hz. The basis for SSVEP. | `BlinkComponent` (`blinker = 3`) | `BlinkComponent`, registered and built by the parser | **In review**: `update()` is an empty TODO on `main`, so nothing blinks yet. PR [#34][rt-34] implements it; changes requested | [#16][rt-16] | +| MarkerEmitter | Emits an LSL marker on a scene event, ideally when an object appears or disappears. | `MarkerEmitterComponent` (`marker_emitter = 5`). Missing from the backend's copy | `MarkerEmitterComponent`, registered | **Implemented, nothing to bind to**: bindings resolve and push markers (tested against a test-only event source), but no production component exposes an event and objects have no appear/disappear events, so any binding in a real `.neuroz` fails at parse time | [backend#23][be-23] | +| Lifetime | Keeps the object visible for N ms, then hides it. | — | — | **Not started** | — | +| ResponseListener | While its object is active, records which key was pressed and the reaction time. | — | — | **Not started** | — | +| ScriptComponent | Custom logic in Python, outside the standard components. | `ScriptComponent` (`script = 4`) | — | **Planned**: needs pybind11, which is not a dependency yet | [backend#25][be-25] | +| AudioSource | Plays a sound. Possibly post-MVP. | — | — | **Not started** | — | + +- **Implemented**: registered class, behaviour complete, covered by tests. +- **In review**: the implementation is in an open PR. +- **Partial**: exists, with the gaps listed in its row. +- **Planned**: the proto message exists, the C++ class does not. The parser **drops such + components silently** (`ComponentRegistry::build` returns `nullptr` and `Parser` skips + it), so a scene that uses one loads without error and the component does nothing. +- **Not started**: neither a proto message nor a class. Adding the message comes first and + is a cross-team change (backend proto and dataclasses, launcher copy, frontend editor; see + `CLAUDE.md`). + +#### Open questions before the remaining components + +- **Event sources.** `MarkerEmitter` binds to events that a component exposes through + `getEvent()`, and nothing exposes one yet. The obvious first sources are an object + appearing or disappearing, a Lifetime timeout and a ResponseListener response. +- **Who owns visibility.** Blink, Lifetime and scripts all write `SceneObject::isVisible`. + If appear/disappear events follow that flag, a 15 Hz blinker emits 30 markers a second, + and the 0 Hz branch in PR [#34][rt-34] overrides a script that hides the object. Keeping + "the object is shown in this trial" (Lifetime, scripts; drives the events) separate from + "flicker phase" (Blink; affects drawing only) avoids both. +- **Frame-locked timing.** The screen only changes at vsync, and markers are stamped there. + Flicker phase and Lifetime durations should come from the frame being presented (frame + count × refresh period), not from summing `Context::timestamp`. That value is a loop + delta measured at the top of the loop, so it jitters, and a frame near an on/off edge + can land on either side. Frequencies above half the refresh rate can't be shown at all + and should be rejected. Dropped frames should be detected and logged. +- **Input and reaction time.** The render loop polls SDL events itself and handles only + `SDL_QUIT`, and `Context` carries no input. ResponseListener needs key events passed + in, stamped with when the key was pressed, not with the vsync time the Renderer gives + markers. With one poll per frame, reaction-time resolution is one frame (~16.7 ms at + 60 Hz). One option is to record the response as a marker (e.g. `response:`) with its + own timestamp and compute reaction time offline against the onset marker. +- **Media files.** Sprite, Text (fonts), Script and Audio all reference files by path. The + runtime resolves relative paths against the `.neuroz` folder (see + [Component model](#component-model)), but assets can't be uploaded or exported yet + ([backend#25][be-25]). Until they can, the launcher has to place them next to the + `.neuroz` by hand. +- **Transform units.** `Transform` is in window pixels, so a stimulus's physical size depends + on the lab monitor, which comes from `config.json`. Decide whether to stay with pixels or + move to normalized or visual-angle units before more renderers land. + +[rt-16]: https://github.com/KN-Neuron/Neuron-IDE-runtime/issues/16 +[rt-17]: https://github.com/KN-Neuron/Neuron-IDE-runtime/issues/17 +[rt-18]: https://github.com/KN-Neuron/Neuron-IDE-runtime/issues/18 +[rt-34]: https://github.com/KN-Neuron/Neuron-IDE-runtime/pull/34 +[be-23]: https://github.com/KN-Neuron/Neuron-IDE-backend/issues/23 +[be-25]: https://github.com/KN-Neuron/Neuron-IDE-backend/issues/25 + ## 8. Repository layout ``` @@ -393,7 +480,8 @@ Neuron-IDE-runtime/ # the C++ runtime (git repo) ├── src/ # one CMake subdirectory (static lib) per module └── tests/ ├── unit_tests/ # CTest label: "unit" - └── component_tests/ # CTest label: "component" + ├── component_tests/ # CTest label: "component" + └── registration_tests/ # separate binary: every component is linked in (label "component") ``` Each `src//` builds a static library; `runtime_core` links them together @@ -407,7 +495,7 @@ All commands are run from the `Neuron-IDE-runtime/` directory. ```bash sudo apt update -sudo apt install cmake clang-format clang-tidy libsdl2-dev protobuf-compiler gcovr +sudo apt install cmake clang-format clang-tidy libsdl2-dev libsdl2-image-dev protobuf-compiler gcovr # GTest, LSL and concurrentqueue are fetched automatically by CMake. ``` diff --git a/cmake/Dependencies.cmake b/cmake/Dependencies.cmake index d9f2c02..ca38e3b 100644 --- a/cmake/Dependencies.cmake +++ b/cmake/Dependencies.cmake @@ -52,8 +52,9 @@ FetchContent_MakeAvailable(googletest liblsl concurrentqueue nlohmann_json) set(CMAKE_C_FLAGS "${BACKUP_C_FLAGS}") set(CMAKE_CXX_FLAGS "${BACKUP_CXX_FLAGS}") -# 5. SDL2 (System installed) +# 5. SDL2 + SDL2_image (System installed) find_package(SDL2 REQUIRED) +find_package(SDL2_image REQUIRED) # 6. Protobuf (System installed) find_package(Protobuf REQUIRED) diff --git a/include/parser/Parser.hpp b/include/parser/Parser.hpp index d4e8660..920d06f 100644 --- a/include/parser/Parser.hpp +++ b/include/parser/Parser.hpp @@ -5,6 +5,8 @@ #include #include +#include "scene/components/ComponentBuildContext.hpp" + class Scene; class SceneObject; class Component; @@ -18,13 +20,19 @@ class Parser { public: Parser() = default; + // Relative asset paths in the scene (e.g. sprite images) resolve against the folder holding + // filePath. static std::shared_ptr parse(const std::string& filePath); - static std::shared_ptr parseStream(std::istream& stream); + // Relative asset paths resolve against context.assetRoot: the working directory by default. + static std::shared_ptr parseStream(std::istream& stream, + const ComponentBuildContext& context = {}); private: - static std::shared_ptr buildSceneObject(const NeuronIDE::SceneObject& protoObj); + static std::shared_ptr buildSceneObject(const NeuronIDE::SceneObject& protoObj, + const ComponentBuildContext& context); static std::unique_ptr buildComponent(const NeuronIDE::Component& protoComp, - const std::shared_ptr& owner); + const std::shared_ptr& owner, + const ComponentBuildContext& context); }; #endif // PARSER_HPP \ No newline at end of file diff --git a/include/scene/Scene.hpp b/include/scene/Scene.hpp index 358f526..60c781f 100644 --- a/include/scene/Scene.hpp +++ b/include/scene/Scene.hpp @@ -28,6 +28,7 @@ class Scene { void render(SDL_Renderer* renderer); void onSceneReady(); + void onRenderTargetReady(const std::shared_ptr& target); Delegate& resolveEvents(std::string_view objectName, std::string_view eventName); }; diff --git a/include/scene/components/BlinkComponent.hpp b/include/scene/components/BlinkComponent.hpp index dd68a4a..a72a6d5 100644 --- a/include/scene/components/BlinkComponent.hpp +++ b/include/scene/components/BlinkComponent.hpp @@ -8,6 +8,7 @@ namespace NeuronIDE { class Component; } +struct ComponentBuildContext; class BlinkComponent : public Component { public: @@ -19,7 +20,8 @@ class BlinkComponent : public Component { void render(SDL_Renderer* renderer) override; static std::unique_ptr createBlinker(const NeuronIDE::Component& protoComp, - const std::shared_ptr& owner); + const std::shared_ptr& owner, + const ComponentBuildContext& context); private: double blinkFrequencyHz = 0.0; diff --git a/include/scene/components/Component.hpp b/include/scene/components/Component.hpp index 0f17992..360a9f7 100644 --- a/include/scene/components/Component.hpp +++ b/include/scene/components/Component.hpp @@ -24,7 +24,13 @@ class Component { virtual void update(const Context& context) = 0; virtual void render(SDL_Renderer* renderer) = 0; - virtual void onSceneReady(Scene& scene) { (void)scene; } + virtual void onSceneReady(Scene& scene) { (void)scene; } + + // Called once the render target this scene is drawn to exists, before the first frame: the + // place to create GPU resources such as textures. SDL frees a renderer's textures along with + // it, so a component holding any keeps `target` alive for as long as it does. + virtual void onRenderTargetReady(const std::shared_ptr& target) { (void)target; } + virtual Delegate* getEvent(std::string_view eventName) { (void)eventName; return nullptr; diff --git a/include/scene/components/ComponentBuildContext.hpp b/include/scene/components/ComponentBuildContext.hpp new file mode 100644 index 0000000..4d58358 --- /dev/null +++ b/include/scene/components/ComponentBuildContext.hpp @@ -0,0 +1,24 @@ +#ifndef COMPONENTBUILDCONTEXT_HPP +#define COMPONENTBUILDCONTEXT_HPP + +#include + +// What a component creator may need beyond its own proto message and owner. Parser builds one per +// scene and hands it to every creator. +struct ComponentBuildContext { + // Folder that relative asset paths in the experiment file resolve against: the folder holding + // the .neuroz file. Empty for a scene parsed from a bare stream, so relative paths resolve + // against the working directory. + std::filesystem::path assetRoot; + + // An absolute path is returned as-is and a relative one is taken relative to assetRoot. An + // empty path stays empty, so the caller can still reject it. + [[nodiscard]] std::filesystem::path resolveAsset(const std::filesystem::path& path) const { + if (path.empty() || path.is_absolute()) { + return path; + } + return assetRoot / path; + } +}; + +#endif // COMPONENTBUILDCONTEXT_HPP diff --git a/include/scene/components/ComponentRegistry.hpp b/include/scene/components/ComponentRegistry.hpp index 1ef388e..35b72e8 100644 --- a/include/scene/components/ComponentRegistry.hpp +++ b/include/scene/components/ComponentRegistry.hpp @@ -10,9 +10,11 @@ namespace NeuronIDE { class Component; } class SceneObject; +struct ComponentBuildContext; using ComponentCreatorFunc = std::function( - const NeuronIDE::Component&, const std::shared_ptr&)>; + const NeuronIDE::Component&, const std::shared_ptr&, + const ComponentBuildContext&)>; class ComponentRegistry { public: @@ -31,7 +33,8 @@ class ComponentRegistry { void registerCreator(int typeId, ComponentCreatorFunc creator); std::unique_ptr build(const NeuronIDE::Component& protoComp, - const std::shared_ptr& owner); + const std::shared_ptr& owner, + const ComponentBuildContext& context); private: ComponentRegistry() = default; diff --git a/include/scene/components/MarkerEmitterComponent.hpp b/include/scene/components/MarkerEmitterComponent.hpp index 95c11ea..88707d8 100644 --- a/include/scene/components/MarkerEmitterComponent.hpp +++ b/include/scene/components/MarkerEmitterComponent.hpp @@ -10,6 +10,7 @@ #include "scene/components/Component.hpp" class Scene; +struct ComponentBuildContext; namespace NeuronIDE { class Component; @@ -36,8 +37,9 @@ class MarkerEmitterComponent : public Component { void render(SDL_Renderer* renderer) override; void onSceneReady(Scene& scene) override; - static std::unique_ptr createMarkerEmitter( - const NeuronIDE::Component& protoComp, const std::shared_ptr& owner); + static std::unique_ptr createMarkerEmitter(const NeuronIDE::Component& protoComp, + const std::shared_ptr& owner, + const ComponentBuildContext& context); private: struct ActiveSubscription { diff --git a/include/scene/components/SpriteRenderer.hpp b/include/scene/components/SpriteRenderer.hpp new file mode 100644 index 0000000..2accbab --- /dev/null +++ b/include/scene/components/SpriteRenderer.hpp @@ -0,0 +1,55 @@ +#ifndef SPRITERENDERER_HPP +#define SPRITERENDERER_HPP + +#include +#include + +#include "scene/components/Component.hpp" + +struct ComponentBuildContext; +struct SDL_Surface; +struct SDL_Texture; + +namespace NeuronIDE { +class Component; +} + +// Draws an image file into its owner's Transform: x/y is the top-left corner in window pixels, +// width/height the drawn size, and rotation is in degrees clockwise about the rectangle's centre. +// +// The image is decoded on construction, so a missing or unreadable file fails while the scene is +// parsed, before any window opens. It becomes a texture in onRenderTargetReady(), before the +// first frame, and the decoded pixels are released then. +class SpriteRenderer : public Component { + public: + // Throws std::invalid_argument for a null owner, an empty path, or an owner without a + // positive width and height; std::runtime_error when the image cannot be loaded. + SpriteRenderer(const std::shared_ptr& owner, std::filesystem::path path); + + void update(const Context& context) override; + void render(SDL_Renderer* renderer) override; + + // Uploads the image to `target`. A sprite belongs to one render target: a second call throws + // std::logic_error. + void onRenderTargetReady(const std::shared_ptr& target) override; + + static std::unique_ptr createSprite(const NeuronIDE::Component& protoComp, + const std::shared_ptr& owner, + const ComponentBuildContext& context); + + private: + struct SurfaceDeleter { + void operator()(SDL_Surface* surface) const noexcept; + }; + struct TextureDeleter { + // The renderer the texture belongs to, kept alive until the texture is destroyed. + std::shared_ptr target; + void operator()(SDL_Texture* texture) const noexcept; + }; + + std::filesystem::path imagePath; + std::unique_ptr image; + std::unique_ptr texture; +}; + +#endif // SPRITERENDERER_HPP diff --git a/protoFiles/neuronide.proto b/protoFiles/neuronide.proto index 0623fb6..10d580c 100644 --- a/protoFiles/neuronide.proto +++ b/protoFiles/neuronide.proto @@ -3,7 +3,9 @@ syntax = "proto3"; package NeuronIDE; message SpriteRenderer { - string texture_path = 1; + reserved 1; + reserved "texture_path"; + // Image file. A relative path resolves against the folder holding the .neuroz file. string img_path = 2; } diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index c134744..8ff558c 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -12,11 +12,11 @@ add_subdirectory(renderer) add_subdirectory(lslreader) add_subdirectory(datawriter) -add_library(runtime_core STATIC +add_library(runtime_core_archive STATIC Runtime.cpp ) -target_include_directories(runtime_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../include) -target_link_libraries(runtime_core PUBLIC +target_include_directories(runtime_core_archive PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../include) +target_link_libraries(runtime_core_archive PUBLIC scene parser config @@ -25,5 +25,11 @@ target_link_libraries(runtime_core PUBLIC datawriter ) +# Components register themselves from static initializers (REGISTER_COMPONENT) in object files +# nothing else references. A plain static link drops those objects, and the parser then skips +# every component of that type without a word. Link `runtime_core`, never the archive itself. +add_library(runtime_core INTERFACE) +target_link_libraries(runtime_core INTERFACE "$") + add_executable(NeuronIDE main.cpp) target_link_libraries(NeuronIDE PRIVATE runtime_core) \ No newline at end of file diff --git a/src/parser/Parser.cpp b/src/parser/Parser.cpp index 43413f6..acbeaf5 100644 --- a/src/parser/Parser.cpp +++ b/src/parser/Parser.cpp @@ -1,5 +1,6 @@ #include "parser/Parser.hpp" +#include #include #include #include @@ -16,15 +17,19 @@ std::shared_ptr Parser::parse(const std::string& filePath) { throw std::runtime_error("Parser: cannot open file: " + filePath); } + const ComponentBuildContext context{.assetRoot = + std::filesystem::absolute(filePath).parent_path()}; + try { - return parseStream(file); + return parseStream(file, context); } catch (const std::runtime_error& e) { throw std::runtime_error(std::string("Parser: failed to parse file ") + filePath + " - " + e.what()); } } -std::shared_ptr Parser::parseStream(std::istream& stream) { +std::shared_ptr Parser::parseStream(std::istream& stream, + const ComponentBuildContext& context) { NeuronIDE::Scene protoScene; if (!protoScene.ParseFromIstream(&stream)) { @@ -39,7 +44,7 @@ std::shared_ptr Parser::parseStream(std::istream& stream) { scene->setExperimentName(protoScene.project_name()); for (const auto& protoObj : protoScene.scene_objects()) { - auto obj = buildSceneObject(protoObj); + auto obj = buildSceneObject(protoObj, context); scene->addObject(std::move(obj)); } @@ -48,7 +53,8 @@ std::shared_ptr Parser::parseStream(std::istream& stream) { return scene; } -std::shared_ptr Parser::buildSceneObject(const NeuronIDE::SceneObject& protoObj) { +std::shared_ptr Parser::buildSceneObject(const NeuronIDE::SceneObject& protoObj, + const ComponentBuildContext& context) { if (protoObj.name().empty()) { throw std::invalid_argument("SceneObject name cannot be empty"); } @@ -71,7 +77,7 @@ std::shared_ptr Parser::buildSceneObject(const NeuronIDE::SceneObje } seenComponentTypes.insert(typeId); - auto comp = buildComponent(protoComp, obj); + auto comp = buildComponent(protoComp, obj, context); if (comp) { obj->addComponent(std::move(comp)); } @@ -81,6 +87,7 @@ std::shared_ptr Parser::buildSceneObject(const NeuronIDE::SceneObje } std::unique_ptr Parser::buildComponent(const NeuronIDE::Component& protoComp, - const std::shared_ptr& owner) { - return ComponentRegistry::instance().build(protoComp, owner); + const std::shared_ptr& owner, + const ComponentBuildContext& context) { + return ComponentRegistry::instance().build(protoComp, owner, context); } \ No newline at end of file diff --git a/src/renderer/Renderer.cpp b/src/renderer/Renderer.cpp index 142e969..ed1dc4b 100644 --- a/src/renderer/Renderer.cpp +++ b/src/renderer/Renderer.cpp @@ -16,7 +16,13 @@ Renderer::Renderer(const std::shared_ptr& scene, std::shared_ptrsdlRenderer) { + scene->onRenderTargetReady(this->sdlRenderer); + } +} void Renderer::render(const std::stop_token& stoken) { std::vector currentFrameMarkers; diff --git a/src/scene/CMakeLists.txt b/src/scene/CMakeLists.txt index 212cb07..2fa86e1 100644 --- a/src/scene/CMakeLists.txt +++ b/src/scene/CMakeLists.txt @@ -4,6 +4,14 @@ add_library(scene OBJECT components/ComponentRegistry.cpp components/BlinkComponent.cpp components/MarkerEmitterComponent.cpp + components/SpriteRenderer.cpp ) -target_include_directories(scene PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../../include) -target_link_libraries(scene PUBLIC neuronide_proto) \ No newline at end of file +target_include_directories(scene PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/../../include + ${SDL2_INCLUDE_DIRS} +) +target_link_libraries(scene PUBLIC + neuronide_proto + ${SDL2_LIBRARIES} + SDL2_image::SDL2_image +) \ No newline at end of file diff --git a/src/scene/Scene.cpp b/src/scene/Scene.cpp index b6a67e6..ff0fc19 100644 --- a/src/scene/Scene.cpp +++ b/src/scene/Scene.cpp @@ -28,6 +28,14 @@ void Scene::onSceneReady() { } } +void Scene::onRenderTargetReady(const std::shared_ptr& target) { + for (const auto& obj : objects) { + for (const auto& comp : obj->components) { + comp->onRenderTargetReady(target); + } + } +} + Delegate& Scene::resolveEvents(std::string_view objectName, std::string_view eventName) { for (const auto& obj : objects) { if (obj->name != objectName) { diff --git a/src/scene/components/BlinkComponent.cpp b/src/scene/components/BlinkComponent.cpp index 7911f0f..dd7da80 100644 --- a/src/scene/components/BlinkComponent.cpp +++ b/src/scene/components/BlinkComponent.cpp @@ -5,8 +5,9 @@ void BlinkComponent::setFrequency(double freq) { blinkFrequencyHz = freq; } -std::unique_ptr BlinkComponent::createBlinker( - const NeuronIDE::Component& protoComp, const std::shared_ptr& owner) { +std::unique_ptr BlinkComponent::createBlinker(const NeuronIDE::Component& protoComp, + const std::shared_ptr& owner, + const ComponentBuildContext& /*context*/) { return std::make_unique(owner, protoComp.blinker().blink_frequency_hz()); } diff --git a/src/scene/components/ComponentRegistry.cpp b/src/scene/components/ComponentRegistry.cpp index f8c67c8..77b02ee 100644 --- a/src/scene/components/ComponentRegistry.cpp +++ b/src/scene/components/ComponentRegistry.cpp @@ -14,14 +14,15 @@ void ComponentRegistry::registerCreator(int typeId, ComponentCreatorFunc creator } std::unique_ptr ComponentRegistry::build(const NeuronIDE::Component& protoComp, - const std::shared_ptr& owner) { + const std::shared_ptr& owner, + const ComponentBuildContext& context) { auto activeCase = protoComp.component_type_case(); int typeId = static_cast(activeCase); auto iter = creators.find(typeId); if (iter != creators.end()) { - return iter->second(protoComp, owner); + return iter->second(protoComp, owner, context); } return nullptr; diff --git a/src/scene/components/MarkerEmitterComponent.cpp b/src/scene/components/MarkerEmitterComponent.cpp index 9066439..2bf7776 100644 --- a/src/scene/components/MarkerEmitterComponent.cpp +++ b/src/scene/components/MarkerEmitterComponent.cpp @@ -25,7 +25,8 @@ void MarkerEmitterComponent::onSceneReady(Scene& scene) { } std::unique_ptr MarkerEmitterComponent::createMarkerEmitter( - const NeuronIDE::Component& protoComp, const std::shared_ptr& owner) { + const NeuronIDE::Component& protoComp, const std::shared_ptr& owner, + const ComponentBuildContext& /*context*/) { std::vector bindings; for (const auto& protoBinding : protoComp.marker_emitter().bindings()) { bindings.push_back({protoBinding.target_object(), protoBinding.target_event(), diff --git a/src/scene/components/SpriteRenderer.cpp b/src/scene/components/SpriteRenderer.cpp new file mode 100644 index 0000000..bde58c7 --- /dev/null +++ b/src/scene/components/SpriteRenderer.cpp @@ -0,0 +1,90 @@ +#include "scene/components/SpriteRenderer.hpp" + +#include +#include + +#include +#include +#include + +#include "neuronide.pb.h" +#include "scene/SceneObject.hpp" +#include "scene/components/ComponentBuildContext.hpp" +#include "scene/components/ComponentRegistry.hpp" + +void SpriteRenderer::SurfaceDeleter::operator()(SDL_Surface* surface) const noexcept { + SDL_FreeSurface(surface); +} + +void SpriteRenderer::TextureDeleter::operator()(SDL_Texture* texture) const noexcept { + SDL_DestroyTexture(texture); +} + +SpriteRenderer::SpriteRenderer(const std::shared_ptr& owner, + std::filesystem::path path) + : Component(owner), imagePath(std::move(path)) { + if (!owner) { + throw std::invalid_argument("SpriteRenderer: owner must not be null"); + } + const std::string where = "SpriteRenderer on '" + owner->name + "'"; + + if (imagePath.empty()) { + throw std::invalid_argument(where + ": image path is empty"); + } + // Written so that a NaN size is rejected too. + const bool hasArea = owner->transform.width > 0.0 && owner->transform.height > 0.0; + if (!hasArea) { + throw std::invalid_argument(where + ": the object needs a positive width and height"); + } + + image.reset(IMG_Load(imagePath.string().c_str())); + if (!image) { + throw std::runtime_error(where + ": cannot load image '" + imagePath.string() + + "': " + IMG_GetError()); + } +} + +void SpriteRenderer::update(const Context& context) { (void)context; } + +void SpriteRenderer::render(SDL_Renderer* renderer) { + const auto ownerPtr = owner.lock(); + if (renderer == nullptr || !texture || !ownerPtr) { + return; + } + + const auto& transform = ownerPtr->transform; + const SDL_FRect destination{ + static_cast(transform.posX), static_cast(transform.posY), + static_cast(transform.width), static_cast(transform.height)}; + + // A null centre rotates about the centre of `destination`. + SDL_RenderCopyExF(renderer, texture.get(), nullptr, &destination, transform.rotation, nullptr, + SDL_FLIP_NONE); +} + +void SpriteRenderer::onRenderTargetReady(const std::shared_ptr& target) { + if (!target) { + throw std::invalid_argument("SpriteRenderer: render target must not be null"); + } + if (!image) { + throw std::logic_error("SpriteRenderer: '" + imagePath.string() + + "' is already uploaded to a render target"); + } + + SDL_Texture* created = SDL_CreateTextureFromSurface(target.get(), image.get()); + if (created == nullptr) { + throw std::runtime_error("SpriteRenderer: cannot create a texture from '" + + imagePath.string() + "': " + SDL_GetError()); + } + texture = std::unique_ptr(created, TextureDeleter{target}); + image.reset(); +} + +std::unique_ptr SpriteRenderer::createSprite(const NeuronIDE::Component& protoComp, + const std::shared_ptr& owner, + const ComponentBuildContext& context) { + return std::make_unique(owner, + context.resolveAsset(protoComp.renderer().img_path())); +} + +REGISTER_COMPONENT(NeuronIDE::Component::kRenderer, SpriteRenderer::createSprite) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 840c37d..b666412 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,4 +1,5 @@ include(GoogleTest) add_subdirectory(unit_tests) -add_subdirectory(component_tests) \ No newline at end of file +add_subdirectory(component_tests) +add_subdirectory(registration_tests) \ No newline at end of file diff --git a/tests/component_tests/sprite_scene_test.cpp b/tests/component_tests/sprite_scene_test.cpp new file mode 100644 index 0000000..dbf7682 --- /dev/null +++ b/tests/component_tests/sprite_scene_test.cpp @@ -0,0 +1,109 @@ +#include + +#include +#include +#include +#include + +#include "neuronide.pb.h" +#include "parser/Parser.hpp" +#include "scene/Scene.hpp" +#include "scene/SceneObject.hpp" +#include "scene/components/SpriteRenderer.hpp" +#include "utils/SdlTestUtils.hpp" +#include "utils/TempDir.hpp" + +// Sprites in a .neuroz parsed from disk, the way Runtime loads an experiment. + +namespace { +namespace fs = std::filesystem; + +constexpr double kSpriteSize = 10.0; +constexpr utils::Rgba kLeft{255, 0, 0, 255}; +constexpr utils::Rgba kRight{0, 0, 255, 255}; + +NeuronIDE::Scene sceneWithSprite(const std::string& imagePath, bool withTransform = true) { + NeuronIDE::Scene scene; + scene.set_project_name("Sprites"); + + auto* object = scene.add_scene_objects(); + object->set_name("Stimulus"); + object->set_is_visible(true); + if (withTransform) { + object->mutable_transform()->set_width(kSpriteSize); + object->mutable_transform()->set_height(kSpriteSize); + } + object->add_components()->mutable_renderer()->set_img_path(imagePath); + return scene; +} + +class SpriteSceneTest : public ::testing::Test { + protected: + const fs::path& dir() const { return tempDir.path(); } + fs::path experimentPath() const { return dir() / "experiment.neuroz"; } + + void writeExperiment(const NeuronIDE::Scene& scene) const { + std::ofstream out(experimentPath(), std::ios::binary | std::ios::trunc); + ASSERT_TRUE(out.is_open()); + ASSERT_TRUE(scene.SerializeToOstream(&out)); + } + + std::shared_ptr parseExperiment() const { + return Parser::parse(experimentPath().string()); + } + + private: + utils::TempDir tempDir; +}; + +bool hasSprite(const Scene& scene) { + const auto& components = scene.getObjects().at(0)->components; + return components.size() == 1 && + dynamic_cast(components.front().get()) != nullptr; +} +} // namespace + +TEST_F(SpriteSceneTest, ResolvesRelativeImagePathAgainstTheExperimentFolder) { + // Otherwise the path would resolve even if it were taken relative to the working directory. + ASSERT_NE(fs::current_path(), dir()); + + fs::create_directories(dir() / "stimuli"); + utils::writeTwoPixelPng(dir() / "stimuli" / "face.png", kLeft, kRight); + writeExperiment(sceneWithSprite("stimuli/face.png")); + + EXPECT_TRUE(hasSprite(*parseExperiment())); +} + +TEST_F(SpriteSceneTest, UsesAbsoluteImagePathAsIs) { + const utils::TempDir elsewhere; + const fs::path image = elsewhere.path() / "face.png"; + utils::writeTwoPixelPng(image, kLeft, kRight); + writeExperiment(sceneWithSprite(image.string())); + + EXPECT_TRUE(hasSprite(*parseExperiment())); +} + +TEST_F(SpriteSceneTest, MissingImageFailsTheParseAndNamesTheFile) { + writeExperiment(sceneWithSprite("stimuli/missing.png")); + const std::string expectedPath = (dir() / "stimuli" / "missing.png").string(); + + try { + parseExperiment(); + FAIL() << "expected the parse to fail"; + } catch (const std::runtime_error& error) { + EXPECT_NE(std::string(error.what()).find(expectedPath), std::string::npos) << error.what(); + } +} + +TEST_F(SpriteSceneTest, EmptyImagePathFailsTheParse) { + writeExperiment(sceneWithSprite("")); + + EXPECT_THROW(parseExperiment(), std::invalid_argument); +} + +TEST_F(SpriteSceneTest, SpriteOnObjectWithoutTransformFailsTheParse) { + utils::writeTwoPixelPng(dir() / "face.png", kLeft, kRight); + writeExperiment(sceneWithSprite("face.png", /*withTransform=*/false)); + + EXPECT_THROW(parseExperiment(), std::invalid_argument); +} diff --git a/tests/registration_tests/CMakeLists.txt b/tests/registration_tests/CMakeLists.txt new file mode 100644 index 0000000..966c2f4 --- /dev/null +++ b/tests/registration_tests/CMakeLists.txt @@ -0,0 +1,7 @@ +# A separate executable on purpose: it references no component class, so it sees exactly the +# components that linking runtime_core brings in through REGISTER_COMPONENT, as NeuronIDE does. +add_executable(registration_tests registration_test.cpp) +target_link_libraries(registration_tests PRIVATE gtest_main runtime_core) +target_include_directories(registration_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/..) + +gtest_discover_tests(registration_tests PROPERTIES LABELS "component") diff --git a/tests/registration_tests/registration_test.cpp b/tests/registration_tests/registration_test.cpp new file mode 100644 index 0000000..dccc05d --- /dev/null +++ b/tests/registration_tests/registration_test.cpp @@ -0,0 +1,52 @@ +#include + +#include +#include + +#include "neuronide.pb.h" +#include "parser/Parser.hpp" +#include "scene/Scene.hpp" +#include "scene/SceneObject.hpp" +#include "utils/SdlTestUtils.hpp" +#include "utils/TempDir.hpp" + +// Do not include or name any component class in this file. A component found here was linked in +// only through its REGISTER_COMPONENT static initializer, exactly as in the NeuronIDE executable. + +namespace { +constexpr double kBlinkFrequencyHz = 10.0; +constexpr utils::Rgba kWhite{255, 255, 255, 255}; + +NeuronIDE::Component* addObjectWithComponent(NeuronIDE::Scene& scene, const std::string& name) { + auto* object = scene.add_scene_objects(); + object->set_name(name); + object->set_is_visible(true); + object->mutable_transform()->set_width(1.0); + object->mutable_transform()->set_height(1.0); + return object->add_components(); +} +} // namespace + +// One object per implemented component type. Add yours here when you register a new one. +TEST(ComponentRegistrationTest, EveryImplementedComponentIsLinkedIn) { + const utils::TempDir assets; + utils::writeTwoPixelPng(assets.path() / "sprite.png", kWhite, kWhite); + + NeuronIDE::Scene proto; + proto.set_project_name("Registration"); + addObjectWithComponent(proto, "Blinker") + ->mutable_blinker() + ->set_blink_frequency_hz(kBlinkFrequencyHz); + addObjectWithComponent(proto, "MarkerEmitter")->mutable_marker_emitter(); + addObjectWithComponent(proto, "Sprite")->mutable_renderer()->set_img_path("sprite.png"); + + std::stringstream stream; + ASSERT_TRUE(proto.SerializeToOstream(&stream)); + const auto scene = + Parser::parseStream(stream, ComponentBuildContext{.assetRoot = assets.path()}); + + for (const auto& object : scene->getObjects()) { + EXPECT_EQ(object->components.size(), 1U) + << object->name << ": its component type is not registered in this binary"; + } +} diff --git a/tests/unit_tests/RendererTest.cpp b/tests/unit_tests/RendererTest.cpp index 6f552c3..ebdbac0 100644 --- a/tests/unit_tests/RendererTest.cpp +++ b/tests/unit_tests/RendererTest.cpp @@ -68,6 +68,22 @@ class MarkerComponent : public Component { std::shared_ptr stopSource; }; +class RenderTargetSpy : public Component { + public: + RenderTargetSpy(const std::shared_ptr& owner, + std::weak_ptr* received) + : Component(owner), received(received) {} + + void onRenderTargetReady(const std::shared_ptr& target) override { + *received = target; + } + void update(const Context& context) override { (void)context; } + void render(SDL_Renderer* renderer) override { (void)renderer; } + + private: + std::weak_ptr* received; +}; + class RendererTest : public ::testing::Test { protected: void SetUp() override { ASSERT_EQ(SDL_Init(SDL_INIT_EVENTS), 0); } @@ -110,6 +126,20 @@ TEST_F(RendererTest, RenderLoop_WhenComponentAdded_CallsUpdateExactlyOnceBeforeS EXPECT_EQ(*renders, 1); } +TEST_F(RendererTest, Constructor_HandsRenderTargetToComponentsBeforeFirstFrame) { + auto scene = std::make_shared(); + auto obj = std::make_shared("obj"); + std::weak_ptr received; + + obj->addComponent(std::make_unique(obj, &received)); + scene->addObject(obj); + + const auto target = makeSoftwareRenderer(); + const Renderer renderer(scene, target, std::make_shared>()); + + EXPECT_EQ(received.lock(), target); +} + TEST_F(RendererTest, RenderLoop_QueuesMarkersFromComponents) { auto scene = std::make_shared(); auto obj = std::make_shared("obj"); diff --git a/tests/unit_tests/component_build_context_test.cpp b/tests/unit_tests/component_build_context_test.cpp new file mode 100644 index 0000000..81eec90 --- /dev/null +++ b/tests/unit_tests/component_build_context_test.cpp @@ -0,0 +1,37 @@ +#include + +#include + +#include "scene/components/ComponentBuildContext.hpp" + +namespace { +namespace fs = std::filesystem; + +// temp_directory_path() is absolute on every platform, unlike a literal such as "/data". +fs::path experimentFolder() { return fs::temp_directory_path() / "experiment"; } +} // namespace + +TEST(ComponentBuildContextTest, ResolvesRelativePathAgainstAssetRoot) { + const ComponentBuildContext context{.assetRoot = experimentFolder()}; + + EXPECT_EQ(context.resolveAsset("stimuli/face.png"), experimentFolder() / "stimuli/face.png"); +} + +TEST(ComponentBuildContextTest, KeepsAbsolutePathAsIs) { + const ComponentBuildContext context{.assetRoot = experimentFolder()}; + const fs::path absolute = fs::temp_directory_path() / "elsewhere" / "face.png"; + + EXPECT_EQ(context.resolveAsset(absolute), absolute); +} + +TEST(ComponentBuildContextTest, KeepsEmptyPathEmpty) { + const ComponentBuildContext context{.assetRoot = experimentFolder()}; + + EXPECT_TRUE(context.resolveAsset(fs::path{}).empty()); +} + +TEST(ComponentBuildContextTest, WithoutAssetRootLeavesRelativePathForWorkingDirectory) { + const ComponentBuildContext context; + + EXPECT_EQ(context.resolveAsset("face.png"), fs::path("face.png")); +} diff --git a/tests/unit_tests/sprite_renderer_test.cpp b/tests/unit_tests/sprite_renderer_test.cpp new file mode 100644 index 0000000..4956352 --- /dev/null +++ b/tests/unit_tests/sprite_renderer_test.cpp @@ -0,0 +1,148 @@ +#include + +#include +#include +#include +#include + +#include "scene/SceneObject.hpp" +#include "scene/components/SpriteRenderer.hpp" +#include "utils/SdlTestUtils.hpp" +#include "utils/TempDir.hpp" + +namespace { +constexpr int kCanvasSize = 40; +constexpr double kSpriteX = 10.0; +constexpr double kSpriteY = 20.0; +constexpr double kSpriteWidth = 20.0; +constexpr double kSpriteHeight = 4.0; +constexpr double kQuarterTurn = 90.0; + +constexpr utils::Rgba kBackground{0, 0, 0, 255}; +constexpr utils::Rgba kLeft{255, 0, 0, 255}; +constexpr utils::Rgba kRight{0, 0, 255, 255}; + +// Probe points sit well inside one half of the sprite, away from edges where rounding could go +// either way. Unturned, the sprite covers x 10..30 and y 20..24, its left half x 10..20. +constexpr int kLeftHalfX = 12; +constexpr int kRightHalfX = 27; +constexpr int kRowY = 21; +// Turned a quarter clockwise about its centre (20, 22), it covers x 18..22 and y 12..32, with +// what was its left half now on top. +constexpr int kColumnX = 20; +constexpr int kTopY = 14; +constexpr int kBottomY = 29; +constexpr int kOutside = 5; + +class SpriteRendererTest : public ::testing::Test { + protected: + void SetUp() override { + image = tempDir.path() / "sprite.png"; + utils::writeTwoPixelPng(image, kLeft, kRight); + + object = std::make_shared("Stimulus"); + object->setTransform({kSpriteX, kSpriteY, kSpriteWidth, kSpriteHeight, 0.0}); + + renderTarget = utils::makeSoftwareTarget(kCanvasSize, kCanvasSize); + utils::clear(renderTarget.get(), kBackground); + } + + const std::filesystem::path& dir() const { return tempDir.path(); } + const std::filesystem::path& imagePath() const { return image; } + const std::shared_ptr& owner() const { return object; } + const std::shared_ptr& target() const { return renderTarget; } + void releaseTarget() { renderTarget.reset(); } + + utils::Rgba pixelAt(int column, int row) const { + return utils::readPixel(renderTarget.get(), column, row); + } + + private: + utils::TempDir tempDir; + std::filesystem::path image; + std::shared_ptr object; + std::shared_ptr renderTarget; +}; +} // namespace + +TEST_F(SpriteRendererTest, StretchesImageOverTransform) { + SpriteRenderer sprite(owner(), imagePath()); + sprite.onRenderTargetReady(target()); + sprite.render(target().get()); + + EXPECT_EQ(pixelAt(kLeftHalfX, kRowY), kLeft); + EXPECT_EQ(pixelAt(kRightHalfX, kRowY), kRight); + EXPECT_EQ(pixelAt(kColumnX, kTopY), kBackground); + EXPECT_EQ(pixelAt(kOutside, kOutside), kBackground); +} + +TEST_F(SpriteRendererTest, RotatesClockwiseAboutCentre) { + owner()->transform.rotation = kQuarterTurn; + + SpriteRenderer sprite(owner(), imagePath()); + sprite.onRenderTargetReady(target()); + sprite.render(target().get()); + + EXPECT_EQ(pixelAt(kColumnX, kTopY), kLeft); + EXPECT_EQ(pixelAt(kColumnX, kBottomY), kRight); + EXPECT_EQ(pixelAt(kLeftHalfX, kRowY), kBackground); +} + +TEST_F(SpriteRendererTest, DrawsNothingBeforeRenderTargetIsReady) { + SpriteRenderer sprite(owner(), imagePath()); + sprite.render(target().get()); + + EXPECT_EQ(pixelAt(kLeftHalfX, kRowY), kBackground); +} + +TEST_F(SpriteRendererTest, ThrowsWhenImageIsMissing) { + EXPECT_THROW(SpriteRenderer(owner(), dir() / "missing.png"), std::runtime_error); +} + +TEST_F(SpriteRendererTest, ThrowsWhenFileIsNotAnImage) { + const auto notAnImage = dir() / "notes.png"; + std::ofstream(notAnImage) << "not an image"; + + EXPECT_THROW(SpriteRenderer(owner(), notAnImage), std::runtime_error); +} + +TEST_F(SpriteRendererTest, ThrowsWhenImagePathIsEmpty) { + EXPECT_THROW(SpriteRenderer(owner(), std::filesystem::path{}), std::invalid_argument); +} + +TEST_F(SpriteRendererTest, ThrowsWhenOwnerIsNull) { + EXPECT_THROW(SpriteRenderer(nullptr, imagePath()), std::invalid_argument); +} + +TEST_F(SpriteRendererTest, ThrowsWhenRenderTargetIsNull) { + SpriteRenderer sprite(owner(), imagePath()); + + EXPECT_THROW(sprite.onRenderTargetReady(nullptr), std::invalid_argument); +} + +TEST_F(SpriteRendererTest, ThrowsWhenOwnerHasNoArea) { + owner()->transform.width = 0.0; + + EXPECT_THROW(SpriteRenderer(owner(), imagePath()), std::invalid_argument); +} + +TEST_F(SpriteRendererTest, RejectsASecondRenderTarget) { + SpriteRenderer sprite(owner(), imagePath()); + sprite.onRenderTargetReady(target()); + + EXPECT_THROW(sprite.onRenderTargetReady(target()), std::logic_error); +} + +// SDL frees a renderer's textures along with it, so destroying the texture afterwards would be a +// use-after-free. The texture therefore keeps its renderer alive. +TEST_F(SpriteRendererTest, KeepsRenderTargetAliveWhileTextureExists) { + auto sprite = std::make_unique(owner(), imagePath()); + sprite->onRenderTargetReady(target()); + + const std::weak_ptr weakTarget = target(); + releaseTarget(); + EXPECT_FALSE(weakTarget.expired()); + + sprite.reset(); + EXPECT_TRUE(weakTarget.expired()); +} diff --git a/tests/utils/SdlTestUtils.hpp b/tests/utils/SdlTestUtils.hpp new file mode 100644 index 0000000..d792937 --- /dev/null +++ b/tests/utils/SdlTestUtils.hpp @@ -0,0 +1,85 @@ +#ifndef SDL_TEST_UTILS_HPP +#define SDL_TEST_UTILS_HPP + +#include +#include + +#include +#include +#include +#include +#include + +namespace utils { + +// One pixel as {r, g, b, a}; ints rather than Uint8 so gtest prints them as numbers. +using Rgba = std::array; + +inline constexpr int kSurfaceDepth = 32; + +inline std::runtime_error sdlFailure(const std::string& what) { + return std::runtime_error(what + ": " + SDL_GetError()); +} + +// A software render target that draws into a CPU surface, so tests can render without a window +// and read the pixels back. +inline std::shared_ptr makeSoftwareTarget(int width, int height) { + std::shared_ptr surface( + SDL_CreateRGBSurfaceWithFormat(0, width, height, kSurfaceDepth, SDL_PIXELFORMAT_RGBA32), + &SDL_FreeSurface); + if (!surface) { + throw sdlFailure("SDL_CreateRGBSurfaceWithFormat"); + } + SDL_Renderer* renderer = SDL_CreateSoftwareRenderer(surface.get()); + if (renderer == nullptr) { + throw sdlFailure("SDL_CreateSoftwareRenderer"); + } + return {renderer, [surface](SDL_Renderer* target) { SDL_DestroyRenderer(target); }}; +} + +inline void clear(SDL_Renderer* renderer, const Rgba& color) { + SDL_SetRenderDrawColor(renderer, static_cast(color[0]), static_cast(color[1]), + static_cast(color[2]), static_cast(color[3])); + SDL_RenderClear(renderer); +} + +inline Rgba readPixel(SDL_Renderer* renderer, int column, int row) { + const SDL_Rect pixel{column, row, 1, 1}; + std::array bytes{}; + // RGBA32 is r, g, b, a in memory order on any endianness. + if (SDL_RenderReadPixels(renderer, &pixel, SDL_PIXELFORMAT_RGBA32, bytes.data(), + static_cast(bytes.size())) != 0) { + throw sdlFailure("SDL_RenderReadPixels"); + } + return {bytes[0], bytes[1], bytes[2], bytes[3]}; +} + +// Writes a 2x1 PNG whose left pixel is `left` and right pixel is `right`. Stretched over a +// sprite, the two halves show how the image was placed and which way it was turned. +inline void writeTwoPixelPng(const std::filesystem::path& path, const Rgba& left, + const Rgba& right) { + const std::unique_ptr surface( + SDL_CreateRGBSurfaceWithFormat(0, 2, 1, kSurfaceDepth, SDL_PIXELFORMAT_RGBA32), + &SDL_FreeSurface); + if (!surface) { + throw sdlFailure("SDL_CreateRGBSurfaceWithFormat"); + } + + const auto fill = [&surface](int column, const Rgba& color) { + const SDL_Rect pixel{column, 0, 1, 1}; + SDL_FillRect( + surface.get(), &pixel, + SDL_MapRGBA(surface->format, static_cast(color[0]), static_cast(color[1]), + static_cast(color[2]), static_cast(color[3]))); + }; + fill(0, left); + fill(1, right); + + if (IMG_SavePNG(surface.get(), path.string().c_str()) != 0) { + throw sdlFailure("IMG_SavePNG " + path.string()); + } +} + +} // namespace utils + +#endif // SDL_TEST_UTILS_HPP diff --git a/tests/utils/TempDir.hpp b/tests/utils/TempDir.hpp new file mode 100644 index 0000000..e9317dd --- /dev/null +++ b/tests/utils/TempDir.hpp @@ -0,0 +1,45 @@ +#ifndef TEMP_DIR_HPP +#define TEMP_DIR_HPP + +#include + +#include +#include +#include +#include + +namespace utils { + +// A fresh directory under the system temp folder, removed with everything in it on destruction. +// The name includes the running test, so tests in parallel processes never share one. +class TempDir { + public: + TempDir() { + const auto* info = ::testing::UnitTest::GetInstance()->current_test_info(); + const std::string testName = + info != nullptr ? std::string(info->test_suite_name()) + "_" + info->name() : "none"; + root = std::filesystem::temp_directory_path() / + ("neuronide_" + testName + "_" + + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + std::filesystem::create_directories(root); + } + + ~TempDir() { + std::error_code errorCode; + std::filesystem::remove_all(root, errorCode); + } + + TempDir(const TempDir&) = delete; + TempDir& operator=(const TempDir&) = delete; + TempDir(TempDir&&) = delete; + TempDir& operator=(TempDir&&) = delete; + + [[nodiscard]] const std::filesystem::path& path() const { return root; } + + private: + std::filesystem::path root; +}; + +} // namespace utils + +#endif // TEMP_DIR_HPP