Pulchritude is a game & rendering engine that ties together a lot of libraries and exposes them to the user through a unified C ABI. The engine is written in C++ with an exposing C ABI to allow as many libraries to be imported as possible. The application software for the game can be written in any desired language; the bulk of an application exists as a collection of dynamic libraries that can each expose more functions and rely on each other, allowing for multiple languages to be used in a simple and sane manner. The engine can be extended easily by adding new dynamic libraries that expose a C ABI.
What this might end up looking like is:
┌────────────┐
┌───────│Engine (C++)│◄────────────────────┐
│ │Application │ │
│ └────────────┘ │
│ │ │
│ │ │
│ │ ┌───────────────────────┐ │
│ │ │Libraries (C, C++, etc)│ │
│ ▼ └─────────────┬─────────┘ │
│ ┌──────────┐ ▼ │
│ │Core Game │ ┌──────────────┐ │
│ │Foundation│◄───────│Pulchritude's │───┘
│ │(Zig, C) │◄┐ │Exposing C ABI│
│ └──────────┘ │ └──────────────┘
│ │ └──┘ │
│ ▼ │
│ ┌────────────────┐ │
│ │Gameplay Logic │◄────────┘
└► │ (lua, D, Haxe)│◄┐
└────────────────┘ │
└──┘
The engine would be the application entry point, and every other bit will exist as either a static library, for math, networking, physics, gui, parsing, logging, etc, or as a dynamic library, which would implement the application. For clarity on wording, the static libraries will be known as library, and the dynamic libraries as components. To clarify, most static libraries will be called indirectly through dynamic libraries that expose a C ABI.
Every plugin should be replaceable with others that expose the same C ABI, it's even a simple manner to replace the engine plugins; each plugin will have corresponding CMake variable that will disable the build+install of that plugin. This allows for even the most central part of the engine, such as the logger or default memory allocator, to be replaced. In other words, the only irreplacable part of Pulchritude is the cohesive interfaces between plugins.
Some plugins in some cases are harder to replace than others and might require changing underlying assets, but the idea will be to minimalize that when possible. For example, the engine uses SPIR-V rather than glsl/hlsl, uses PDS as a common data-asset interface, and so forth.
linux
mkdir pulchritude ;
cd pulchritude ;
mkdir build install ;
git clone https://github.com/aodq/pulchritude-engine src ;
pushd src/ ;
git submodule update --init --recursive ;
pushd third-party/SPIRV-Tools/external ;
git clone https://github.com/khronosgroup/spirv-headers spirv-headers ;
popd ;
popd ;
cd build ;
cmake -G "ninja" -DCMAKE_INSTALL_PREFIX=../install ../src ;
ninja install ;
make sure to add ../install/bin/
to your PATH
The directory layout for the engine is:
When compiled, the application will install to bin/, and each each plugin will install to bin/plugins
with puledit
installed, run
puledit init-project --name "helloworld" --support-gpu false
.
this will generate project.pds
as well as a base src under src/main.cpp
You can run this with puledit run
, which will generate the project files
under build-husk
(be sure to add this directory to your .gitignore), compile
your project, then runs your project.
other examples are at repo https://github.com/aodq/pulchritude-examples . a full application is at https://github.com/aodq/pulde
For the most part, the best way to learn is to look through the example tutorials.
Error handling from C-ABIs is a bit of a mess. Preferrably we'd want to have the error returned along with the value, and some sort of pattern matching to address the error. Given the C-ABI, instead the error will be apart of the parameters. The idea is that the bindings to your language will help here as well.
typedef enum {
PulcInitializeError_noPlayers = 1,
} PulcInitializeError;
PulcGameState pulcInitialize(PuleError * err) {
// silly example
state->players = pulcAcquirePlayers();
if (state->players == 0) {
puleErrorPropagate(
err,
PuleError {
.description = puleStringFormatDefault("failed to acquire players"),
.id = PulcInitializeError_noPlayers,
.sourceLocationNullable = __FILE__,
.lineNumber = __LINE__,
.child = nullptr,
}
);
return PulcGameState {};
}
return state;
}
if you're using C or C++, the PULE_error
macro helps simplify this;
PULE_error(PulcInitializerError_noPlayers, "failed to acquire players");
to consume an error is simple
PuleError error;
PulcGameState gameState = pulcInitialize(&error);
switch (puleErrorConsume(&error)) { // checks & logs error
default: break;
case PulcInitializeError_noPlayers:
exit(-1);
}
Outside of the header/exported C-ABI symbols, style isn't relevant.
Firstly, functions are camelCased and must start with either 'puli' if they are from a library, 'pule' if from the Engine, and 'pulc' if from a Component.
No non-exported/local functions/variables should start with pul
prefix.