|
/** |
|
* @brief An ECS example with Magnum, part 2 |
|
* |
|
* # What's changed? |
|
* |
|
* - **Global registry** I think it can make sense to have many, but in this case |
|
* there was only one, and this saves us from passing the registry to each system |
|
* - **No mode Mesh::draw** Meshes are now plain-old-data, actual logic being |
|
* implemented by a system. |
|
* |
|
* # What's novel? |
|
* |
|
* - Components are pure types, e.g. Vector3, as opposed to one wrapped in a struct |
|
* Except when otherwise necessary, such as the `ShaderTemplate` component |
|
* - Two-stage initialisation via "Template" and "Instance", to avoid making calls to GPU |
|
* during instantiation of lots of data. Effectively deferring uploads to a bulk operation |
|
* happening during "spawn" (see SpawnSystem) |
|
* - Shaders contain references to things to draw, as opposed to the (default) other way around |
|
* See `ShaderAssignment` component |
|
* |
|
* # Troubles |
|
* |
|
* - I wanted to `using MeshTemplate = MeshTools::MeshData3D` for a component, |
|
* but wasn't able to. Something about `position` being empty once being |
|
* read from within a system. |
|
* - `MeshTools::transformPointsInPlace` isn't working, unsure why. |
|
* Compilation couldn't find its declaration. |
|
* |
|
* # Questions |
|
* |
|
* - How does a camera fit into this? |
|
* That is, do we need a custom Orientation component, e.g. CameraOrientation |
|
* or would it suffice to have a `Camera` "tag" on the entity representing it? |
|
* And how do we make rendering relative a camera? We need some sense of what |
|
* camera is "current" or related to any particular view; in case there are multiple |
|
* such as top, bottom, front and perspective |
|
* - How does shadows fit into this? |
|
* In the case of shadow maps, we need a (set of) draw call(s) to happen prior |
|
* to drawing what is currently drawn. Is it simply a matter of another system, |
|
* and ordering the calls to each system accordingly? Do we need to establish |
|
* some form of relationship between systems, which is dependent on which (and |
|
* for what aspect of it)? |
|
* - How does picking fit into this? |
|
* Like shadows, an ID pass needs to be rendered in parallel with colors/shadows, |
|
* with its own shader (taking only the ID color into account) and independently |
|
* from the rest; i.e. the output isn't relevant outside of calling glReadPixels |
|
* during a picking session. |
|
* - How does *selection* fit into this? |
|
* That is, once something has been picked, I'd like for a highlight of sorts |
|
* to get drawn around the object. Do I add a transient `Selected` component, |
|
* and filter entities by that, in a separate render system? |
|
* |
|
*/ |
|
|
|
#include <Magnum/GL/DefaultFramebuffer.h> |
|
#include <Magnum/GL/Mesh.h> |
|
#include <Magnum/GL/Renderer.h> |
|
#include <Magnum/MeshTools/Compile.h> |
|
#include <Magnum/Platform/Sdl2Application.h> |
|
#include <Magnum/Primitives/Cube.h> |
|
#include <Magnum/Primitives/Icosphere.h> |
|
#include <Magnum/Shaders/Phong.h> |
|
#include <Magnum/Trade/MeshData3D.h> |
|
#include <Magnum/Math/Quaternion.h> |
|
|
|
#include "externals/entt.hpp" |
|
|
|
namespace Magnum { namespace Examples { |
|
|
|
// "There can be only one" - Highlander, 1986 |
|
static entt::registry gRegistry; |
|
|
|
// -------------------------------------------------------------- |
|
// |
|
// Components |
|
// |
|
// -------------------------------------------------------------- |
|
|
|
using Orientation = Quaternion; |
|
using Color = Color3; |
|
|
|
// NOTE: "using" isn't enough here, EnTT requires unique types per component |
|
struct Position : public Vector3 { using Vector3::Vector3; }; |
|
struct Scale : public Vector3 { using Vector3::Vector3; }; |
|
|
|
/** @brief Name, useful for debugging. E.g. to print an entity's name |
|
*/ |
|
using Identity = std::string; |
|
|
|
/** @brief Template for the creation of a mesh |
|
*/ |
|
struct MeshTemplate { |
|
enum { Cube, Sphere, Capsule, Plane } type; |
|
Vector3 extents; |
|
}; |
|
|
|
/** @brief Compiled and uploaded mesh |
|
* |
|
* Including vertex and index buffer, and vertex layout information. |
|
* |
|
*/ |
|
using MeshInstance = GL::Mesh; |
|
|
|
/** @brief Template for the compile and linking of a new shader program |
|
*/ |
|
struct ShaderTemplate { |
|
std::string type; |
|
}; |
|
|
|
/** @brief Compiled and linked shader program |
|
*/ |
|
using ShaderInstance = Shaders::Phong; |
|
|
|
// Connection between drawable entities and a shader entity |
|
using ShaderAssignment = std::vector<entt::registry::entity_type>; |
|
|
|
|
|
// --------------------------------------------------------- |
|
// |
|
// Systems |
|
// |
|
// --------------------------------------------------------- |
|
|
|
/** @brief Affect *all* orientations with the mouse |
|
* |
|
* NOTE: This should probably be more targeted; e.g. affecting only a "camera" |
|
* |
|
*/ |
|
static void MouseMoveSystem(Vector2 distance) { |
|
gRegistry.view<Orientation>().each([distance](auto& ori) { |
|
ori = ( |
|
Quaternion::rotation(Rad{ distance.y() }, Vector3(1.0f, 0, 0)) * |
|
ori * |
|
Quaternion::rotation(Rad{ distance.x() }, Vector3(0, 1.0f, 0)) |
|
).normalized(); |
|
}); |
|
} |
|
|
|
/** |
|
* @brief Shift all colors on mouse release |
|
* |
|
* NOTE: Like the above, this should probably be more targeted; using ECS "tags"? |
|
* |
|
*/ |
|
static void MouseReleaseSystem() { |
|
gRegistry.view<Color>().each([](auto& color) { |
|
color = Color3::fromHsv({ color.hue() + 50.0_degf, 1.0f, 1.0f }); |
|
}); |
|
} |
|
|
|
static void AnimationSystem() { |
|
Debug() << "Animating.."; |
|
} |
|
|
|
static void PhysicsSystem() { |
|
Debug() << "Simulating.."; |
|
} |
|
|
|
/** |
|
* @brief Upload new data to the GPU |
|
* |
|
* Whenever a new item spawns, it'll carry data pending an upload |
|
* to the GPU. A spawned component may be replaced by assigning |
|
* a new template to an entity. |
|
* |
|
*/ |
|
static void SpawnSystem() { |
|
gRegistry.view<Identity, ShaderTemplate>().each([](auto entity, auto& id, auto& tmpl) { |
|
Debug() << "Instantiating shader from template for:" << id; |
|
|
|
gRegistry.remove<ShaderTemplate>(entity); |
|
gRegistry.assign_or_replace<ShaderInstance>( |
|
entity, |
|
|
|
// Only one option, for now |
|
tmpl.type == "phong" ? Shaders::Phong{} |
|
: Shaders::Phong{} |
|
); |
|
}); |
|
|
|
gRegistry.view<Identity, MeshTemplate>().each([](auto entity, auto& id, auto& tmpl) { |
|
Debug() << "Instantiating mesh from template for:" << id; |
|
|
|
auto data = tmpl.type == MeshTemplate::Cube ? Primitives::cubeSolid() : |
|
MeshTemplate::Sphere ? Primitives::icosphereSolid(3) : |
|
Primitives::icosphereSolid(3); |
|
|
|
// NOTE: The below isn't working |
|
// NOTE: Cannot find `transformPointsInPlace` |
|
|
|
// Matrix4 transformation = Matrix4::scaling(tmpl.extents); |
|
//MeshTools::transformPointsInPlace(transformation, data.positions(0)); |
|
//MeshTools::transformVectorsInPlace(transformation, data.normals(0)); |
|
|
|
gRegistry.remove<MeshTemplate>(entity); |
|
gRegistry.assign_or_replace<MeshInstance>(entity, MeshTools::compile(data)); |
|
}); |
|
} |
|
|
|
/** |
|
* @brief Facilitate new templates being added for either shaders or meshes |
|
* |
|
*/ |
|
static void ChangeSystem() {} |
|
|
|
/** |
|
* @brief Destroy entities with a `Destroyed` component |
|
* |
|
*/ |
|
static void CleanupSystem() {} |
|
|
|
/** |
|
* @brief Produce pixels by calling on the uploaded shader |
|
* |
|
* Meshes are drawn per-shader. That is, a shader is associated to multiple renderables |
|
* |
|
*/ |
|
static void RenderSystem(Vector2i viewport, Matrix4 projection) { |
|
GL::defaultFramebuffer.clear(GL::FramebufferClear::Color | GL::FramebufferClear::Depth); |
|
GL::defaultFramebuffer.setViewport({{}, viewport}); |
|
|
|
GL::Renderer::enable(GL::Renderer::Feature::DepthTest); |
|
|
|
Debug() << "Drawing.."; |
|
|
|
gRegistry.view<Identity, ShaderAssignment, ShaderInstance>().each([projection](auto& id, auto& assignment, auto& shader) { |
|
|
|
Debug() << " ..using shader:" << id; |
|
|
|
// NOTE: Doing double-duty; calls to `shader.set*` also call on `glUseProgram` |
|
// ..except it shouldn't have to. |
|
glUseProgram(shader.id()); |
|
|
|
// Uniforms applicable to *all* assigned meshes |
|
shader |
|
.setLightPosition({7.0f, 7.0f, 2.5f}) |
|
.setLightColor(Color3{1.0f}) |
|
.setProjectionMatrix(projection); |
|
|
|
for (auto& entity : assignment) { |
|
// NOTE: Looping through entities from within a loop of components |
|
// Is this a good idea? What is the alternative? |
|
|
|
const auto& [id, pos, ori, scale, color, mesh] = gRegistry.get< |
|
Identity, Position, Orientation, Scale, Color, MeshInstance |
|
>(entity); |
|
|
|
Debug() << " - " << id; |
|
|
|
auto transform = ( |
|
Matrix4::scaling(scale) * |
|
Matrix4::rotation(ori.angle(), ori.axis().normalized()) * |
|
Matrix4::translation(pos) |
|
); |
|
|
|
shader |
|
.setDiffuseColor(color) |
|
.setAmbientColor(Color3::fromHsv({color.hue(), 1.0f, 0.3f})) |
|
.setTransformationMatrix(transform) |
|
.setNormalMatrix(transform.rotationScaling()); |
|
|
|
// NOTE: Assumes indexed draw, which is fine for this example |
|
glBindVertexArray(mesh.id()); |
|
glDrawElements(GLenum(mesh.primitive()), |
|
mesh.count(), |
|
GLenum(mesh.indexType()), |
|
reinterpret_cast<GLvoid*>(nullptr)); |
|
glBindVertexArray(0); |
|
} |
|
}); |
|
} |
|
|
|
// --------------------------------------------------------- |
|
// |
|
// Application |
|
// |
|
// --------------------------------------------------------- |
|
|
|
using namespace Magnum::Math::Literals; |
|
|
|
class ECSExample : public Platform::Application { |
|
public: |
|
explicit ECSExample(const Arguments& arguments); |
|
~ECSExample(); |
|
|
|
private: |
|
void drawEvent() override; |
|
void mousePressEvent(MouseEvent& event) override; |
|
void mouseReleaseEvent(MouseEvent& event) override; |
|
void mouseMoveEvent(MouseMoveEvent& event) override; |
|
|
|
Matrix4 _projection; |
|
Vector2i _previousMousePosition; |
|
}; |
|
|
|
ECSExample::~ECSExample() { |
|
|
|
// Let go of all references to components |
|
// |
|
// If we don't do this, gRegistry is destroyed *after* the application, |
|
// which means after the OpenGL context has been freed, resulting in shaders |
|
// being unable to clean themselves up. |
|
gRegistry.reset(); |
|
} |
|
|
|
ECSExample::ECSExample(const Arguments& arguments) : |
|
Platform::Application{ arguments, Configuration{} |
|
.setTitle("Magnum ECS Example") } |
|
{ |
|
GL::Renderer::enable(GL::Renderer::Feature::DepthTest); |
|
GL::Renderer::enable(GL::Renderer::Feature::FaceCulling); |
|
|
|
_projection = |
|
Matrix4::perspectiveProjection( |
|
35.0_degf, Vector2{ windowSize() }.aspectRatio(), 0.01f, 100.0f) * |
|
Matrix4::translation(Vector3::zAxis(-10.0f)); |
|
|
|
auto box = gRegistry.create(); |
|
auto sphere = gRegistry.create(); |
|
auto phong = gRegistry.create(); |
|
|
|
// Box |
|
gRegistry.assign<Identity>(box, "box"); |
|
gRegistry.assign<Position>(box, Vector3{}); |
|
gRegistry.assign<Orientation>(box, Quaternion::rotation(5.0_degf, Vector3(0, 1.0f, 0))); |
|
gRegistry.assign<Scale>(box, Vector3{1.0f, 1.0f, 1.0f}); |
|
gRegistry.assign<Color>(box, Color3{ 0.1f, 0.6f, 0.8f }); |
|
gRegistry.assign<MeshTemplate>(box, MeshTemplate::Cube, Vector3(2.0f, 0.5f, 2.0f)); |
|
|
|
// Sphere |
|
gRegistry.assign<Identity>(sphere, "sphere"); |
|
gRegistry.assign<Position>(sphere, Vector3{}); |
|
gRegistry.assign<Orientation>(sphere, Quaternion::rotation(5.0_degf, Vector3(0, 1.0f, 0))); |
|
gRegistry.assign<Scale>(sphere, Vector3{1.2f, 1.2f, 1.2f}); |
|
gRegistry.assign<Color>(sphere, Color3{ 0.9f, 0.6f, 0.2f }); |
|
gRegistry.assign<MeshTemplate>(sphere, MeshTemplate::Sphere, Vector3(1.0f, 1.0f, 1.0f)); |
|
|
|
// Phong |
|
gRegistry.assign<Identity>(phong, "phong"); |
|
gRegistry.assign<ShaderTemplate>(phong); |
|
|
|
// Connect vertex buffers to shader program |
|
// Called on changes to assignment, e.g. a new torus is assigned this shader |
|
gRegistry.assign<ShaderAssignment>(phong, std::vector<entt::registry::entity_type>{box, sphere}); |
|
} |
|
|
|
void ECSExample::drawEvent() { |
|
GL::defaultFramebuffer.clear( |
|
GL::FramebufferClear::Color | GL::FramebufferClear::Depth |
|
); |
|
|
|
auto viewport = GL::defaultFramebuffer.viewport().size(); |
|
|
|
SpawnSystem(); |
|
AnimationSystem(); |
|
PhysicsSystem(); |
|
RenderSystem(viewport, _projection); |
|
CleanupSystem(); |
|
|
|
swapBuffers(); |
|
} |
|
|
|
void ECSExample::mousePressEvent(MouseEvent& event) { |
|
if (event.button() != MouseEvent::Button::Left) return; |
|
_previousMousePosition = event.position(); |
|
event.setAccepted(); |
|
} |
|
|
|
void ECSExample::mouseReleaseEvent(MouseEvent& event) { |
|
if (event.button() != MouseEvent::Button::Left) return; |
|
|
|
// Should the system handle all mouse events, instead of individual ones? |
|
MouseReleaseSystem(); |
|
|
|
event.setAccepted(); |
|
redraw(); |
|
} |
|
|
|
void ECSExample::mouseMoveEvent(MouseMoveEvent& event) { |
|
if (!(event.buttons() & MouseMoveEvent::Button::Left)) return; |
|
|
|
const float sensitivity = 3.0f; |
|
const Vector2 distance = ( |
|
Vector2{ event.position() - _previousMousePosition } / |
|
Vector2{ GL::defaultFramebuffer.viewport().size() } |
|
) * sensitivity; |
|
|
|
// Should the system compute delta? |
|
// If so, where does state go, i.e. _previousMousePosition? |
|
MouseMoveSystem(distance); |
|
|
|
_previousMousePosition = event.position(); |
|
event.setAccepted(); |
|
|
|
redraw(); |
|
} |
|
|
|
}} |
|
|
|
MAGNUM_APPLICATION_MAIN(Magnum::Examples::ECSExample) |
So, finally had a chance to get back to this. Commenting mainly on the Magnum side of things. First the tiny things:
Did you
#include <Magnum/MeshTools/Transform.h>
? :)This data structure is scheduled for a rewrite. Currently I'm recommending people to not use it for their general vertex data storage (only for asset import) because it's not general enough. That'll get solved once mosra/magnum#371 lands.
The external SDL dependency is the main pain point, the rest is fiddling with CMake subprojects. I'm working on improving that (and incorporating all your feedback) right now.
Magnum's GL wrapper
In Part I and here as well I see you're practically on the way to ditch
Magnum::GL
and reimplementing it fully yourself. Well, nothing wrong with that I'd say, but there's a lot of work to be done :) Here's the design rationale of why is it done like this right now:No matter whether Magnum is used in a DOD or the "
goodold" OOP way, expecting the user all theglBindBuffer()
,glUseProgram()
, ... manually before any buffer data upload, sampler setup, shader uniform setting etc., is very error prone. Modern GL and theARB_direct_state_access
extension remove most of these extra calls, allowing "direct access". Magnum takes that approach and expands it also for platforms where this extension isn't (GLES and WebGL) for a consistent experience.Additionally, simply doing
proved to be very slow on particular platforms (especially all the random mobile drivers and WebGL -- each of those is a call into JS with lots of allocations, string comparison and other sad stuff), which is why Magnum has an internal state tracker, calling
glBindBuffer()
,glUseProgram()
, ... only if it thinks given state is not already set.The above is not true, it'll do it just once when first needed ... unless you side-step the state tracker by manually calling
glUseProgram()
(which is what you're doing).I still hold my opinion that extracting those out and calling them manually "only when needed" in your ECS code is basically an attempt to reimplement magnum's state tracker in the app. You don't want to do that, trust me. There's a lot of very
interestingannoying state interactions that the user shouldn't have to care about (e.g., what happens to anGL_ELEMENT_ARRAY
binding when you bind a VAO?), and sooner or later you'll run into "my app is drawing just black on WebGL but not on desktop" and other things exactly because of this state tracker misuse.The way it's currently done it (in my opinion) gives users a reasonable amount of control but also leaves enough headroom for the engine to work around driver insanities (e.g. macOS drivers crash when you set texture state in a particular way) --- or implementing extension-dependent functionality, giving you
buffer.setData()
but doing either theglBindBuffer()
+glBufferData()
dance orglNamedBuffer()
if your driver supports that.GL, global current context and multithreading
It doesn't and it reasonably ever won't, sorry. People tried to contort OpenGL to work on a single context across threads or with multiple contexts being switched in a frame, but once you get around all driver bugs, you'll be lucky to end up in a state where it's not slower than a single-thread single-context version --- the driver will be doing everything serialized anyway. The best you can do is either having two absolutely independent thread-local GL contexts (which is kinda useless except for very specific cases) or having the other threads operate only pure memory, without any GL API calls.
It's freaking terrible actually, you just have to live with that. Too bad all alternatives are either extremely fragmented and thus non-portable (DX12/Vulkan/Metal) or still having two decades before they become viable alternatives (WebGPU).
Magnum tries to undo most of this "global state" mess, giving you an interface that you can reasonably treat as non-global (i.e., not having to worry that calling
buffer.setData()
will break your drawing code that's below), but you need to respect its state tracker -- if you ever call a raw GL function, the state tracker gets confused if you don't notify it.The Mesh class doing too much
Data? I don't think so. It just an ID, one flag, an array of vertex layout bindings (unless your driver supports VAOs, which it usually does), and a bunch of data entries that define which one of the seven thousand various
glDraw*()
functions is used to draw it so you don't need to have this logic in your app. I don't really see a way around theglDraw*()
branch hell.But -- for a proper DOD-like drawing, have a look at a
MeshView
, which is really just "a struct" -- ideally there should be just a small number ofMesh
instances that define the vertex layout and buffer assignment for more than one object and then multiple views that render portions of these. Having a separate vertex/index buffer for each drawn mesh isn't very DOD anyway :PModern GL has the
ARB_vertex_layout
extension that separates buffers and vertex layout --- but since you can't use in in ES or WebGL, I didn't see a point in implementing it. Better to just go full Vulkan instead.See above -- I'm neither an OOP extremist nor a DOD extremist and in order to support both approaches (not everyone wants to go full ECS), I think this is a good compromise (given all the GL limitations we have to suffer through). For Vulkan there's of course a ton of better ways to implement it.
And for the memory locality / layout concern -- compared to what GL drivers do (or Emscripten does) underneath, this is a complete non-issue. Seriously. Don't overthink that. The state tracker is on the engine side only because the API call overhead in the GL driver is so damn huge.
GPU state vs CPU state
I'm a bit unsure about storing vertex-data both CPU- and GPU-side and doing anything beyond raw data upload / state changes in the main loop. Ideally, all shaders should be compiled upfront (otherwise you get stutters ... unless the driver is already implemented to minimize the impact by caching compiled shaders, the stuff your app is supposed to be doing instead), and mesh data uploaded to the GPU and discarded CPU-side (because otherwise you'll be using twice as much memory).
If there's something I forgot to comment on, let me know.