![]() |
vkmEngine 1.0.0
A C++ game engine · vkmengine.com
|
The mechanical rules: how vkmEngine code is laid out, named, formatted and documented. Every rule here is observed in the tree; where an example and the code disagree, the code is right and the example is a defect to report.
The goal is predictability - someone opening a random file should be able to guess the shape of the next one. The judgment above the mechanics is in the sibling guides: engine.md, design.md, implementation.md, review.md.
Mechanics admit no judgment. Every one of these is a rule.
What a machine can check is checked: tests/docs/docs_tests.cpp fails the build on a function a .cpp exposes undeclared or makes static, a Windows macro name, a stray @param, a log category out of step with the logging, a line past 110 columns or a tab (shaders included), a parameter, argument or braced list broken any way but whole, several items to a line of a list that is not a table, a list broken when it fits, a comment inside an argument list, a class missing any of its five special members, or a struct with a private section - in the engine, the examples and the template alike - and on a reference page that is not ASCII, cites a line number, or names a file, link or member that does not exist.
Code lives under six include roots:
| Root | Include style | Example |
|---|---|---|
| src/engine/ | module-qualified | #include "system/render/render_view.h" |
| src/backend/opengl/ | module-qualified; its own root files are flat | #include "asset/gl_mesh.h", #include "gl_backend.h" |
| src/tools/ | module-qualified | #include "import/texture_loaders.h" |
| src/editor/ | module-qualified, from two roots | #include "panels/inspector_panel.h" and #include "ecs/scene.h" |
| app/ | repo-root-qualified | #include "app/engine_app.h" |
| tests/ | area-qualified; the harness is bare | #include "physics/physics_support.h", #include "support.h" |
The backend's own root files are flat because vkmGL exports only flat names, which is also why a backend header never shares a name with a vkmGL one: a quoted include resolves beside the including file first, so which of the two you got would depend on where you wrote it.
The editor has a root of its own and the engine's, so an editor .cpp includes panels/, command/ and ui/ from the first and core/, ecs/, system/ from the second; its own root files are included bare (#include "editor_state.h"). The hosts add the repo root, which is why app/ is spelled into the path.
Always include the module path, never the bare filename:
Engine code never reaches into backend/. It sees the GPU only through RenderBackend and EditorRenderHooks in system/render/ (engine.md). A host constructing GLBackend is not an exception but the point: the host is the composition root, the one place that picks which backend the engine gets, or none.
Within a file, includes come in groups separated by one blank line:
A local header that drags in a standard header can hide a missing include when the order is reversed; that is why local comes last.
Groups are separated by one blank line.
Forward-declare a type you use only by pointer or reference in a signature; include its header when it appears by value, as a base class, or where a template needs the full definition. Forward declarations go immediately inside the namespace:
A type from another namespace takes a block of its own, closed with a bare } (backend/opengl/gl_frame_context.h shows both kinds).
VKM_REFLECT_BEGIN opens namespace Vkm::Engine::Reflect itself, so it goes at global scope, after the namespace close, naming the type fully qualified. From ecs/component/core/transform.h:
What else a component owes the scene format is design.md.
The own header comes first so it compiles as if it were first in any translation unit, which catches a missing include inside it.
The one thing allowed above it is #define VKM_LOG_CATEGORY "...": the own header pulls in logger.h, which defaults the category when nothing set it, and defining it afterwards redefines the macro. From core/engine.cpp:
Never static free functions. A file may open several anonymous namespaces, each directly above the definitions that use it - which is also how a long .cpp marks where a section begins, instead of a banner comment that can go stale:
Helpers inside take no extra prefix (detail_, _internal): the namespace already restricts them.
| What | Convention | Example |
|---|---|---|
| Class / struct | PascalCase | RenderView, ObjectDraw |
| Method | camelCase | addSystem(), getScene() |
| Class member | m_ + camelCase | m_scene, m_systemsByStage |
| Struct member | bare camelCase | position, viewportWidth |
| Local variable | camelCase | deltaTime, worldMin |
| File-scope state | g_ + camelCase | g_interrupted |
| thread_local | t_ + camelCase | t_isWorker |
| static local | s_ + camelCase | s_iniPath |
| Constant | UPPER_SNAKE_CASE | ALIVE_BIT, DEFAULT_TICK_RATE |
| Enum class value | PascalCase | SystemStage::Render |
| Type alias | PascalCase | EntityId, MeshHandle |
| Template param | single letter or PascalCase | T, ResourceType |
| Namespace | PascalCase, under Vkm:: | Vkm::Engine, Vkm::GL, Vkm::Log |
| File name | snake_case | render_view.h, gl_forward_pass.cpp |
The three lifetime prefixes mark state that outlives a call, which makes each a threading question: g_interrupted (core/engine.cpp) is written from a signal handler, t_isWorker (platform/threading/thread_pool.cpp) is how a worker recognises itself. A file-scope g_ lives in the anonymous namespace.
Namespaces nest under one umbrella - Vkm::Engine for engine code, Vkm::GL for vkmGL, Vkm::Log for vkmLog - with helper namespaces further in (Vkm::Engine::Math). A file whose content lives in one opens it in the one-line form, namespace Vkm::Engine::Math {, closed by one }.
The OpenGL backend is Vkm::Engine, not Vkm::GL. Vkm::GL is vkmGL, the platform layer; every Vkm::GL block in the backend is a forward declaration of a vkmGL type. A backend file that opens namespace Vkm::GL compiles and puts its type in the wrong library's namespace. Where a type lives and which namespace it opens are the same question.
glm::epsilon<float>(), glm::pi<float>(), glm::half_pi<float>() and the rest of glm/gtc/constants.hpp are the engine's vocabulary. A local PI or EPSILON is a second name for a value that already has one, and the two drift. That holds for tolerances too: guarding a division or asking whether a vector can be normalized is a question about floats, and glm::epsilon<float>() answers it.
Declare a constant of your own when the value is the engine's: a slope limit, a sleep threshold, a cascade count. If you cannot say what the number means in the engine's terms, it is an epsilon in disguise.
windef.h defines near, far and pascal as empty macros, so on Windows a local of any of those names vanishes and the next line is a syntax error that mentions neither.
| Kind | Members | Rule of 5 | Examples |
|---|---|---|---|
| Data-only struct | bare name | none | Transform, ObjectDraw, FrameContext |
| Class with behavior | m_name | explicit | Engine, RenderSystem, SparseSet<T>, Resource |
If you want m_ on a struct member, the struct is probably a class. If you are skipping the Rule of 5 on a class, it is probably a struct.
| Rule | Setting |
|---|---|
| Indent | 4 spaces, never tabs |
| Braces | K&R - opening brace on the same line |
| Access specifier | indented 4 spaces from class |
| Member body | indented 8 spaces from class (4 inside the access specifier) |
| Keyword spacing | if (, for (, while (, switch ( |
| Call spacing | fn(), obj.method() - no space before ( |
| Pointer / ref | T& name, T* name - the &/* binds to the type |
| Rvalue ref | T && name - one space each side |
| Callable param | Fn&& fn - unspaced (5.2) |
| Line length | 110 columns; a list that would pass it breaks whole (5.3) |
| Statements | one to a line; a body of more than one takes its own lines |
| Namespace close | } // namespace Name; a forward-declaration block, bare } |
Note the double indent on members:
Align related initializers, defaults and trailing comments when the columns read better. Never align across a blank line - alignment says "these belong together", and a blank line has said they do not. Alignment is for columns of declarations; a continuation line is never aligned to an open paren or to an operand above it - it is indented (5.3):
A forwarded callable or pack is unspaced - Fn&& fn, Args&&... args, auto&&... f - because it reads as one token: the thing you hand a lambda to. Every other && is spaced, forwarding references included: Scene::add(EntityId, T && component).
A parameter list, an argument list or a braced initializer list sits on one line when the line fits in 110 columns. When it does not, it breaks whole, in the one form the tree uses: the opener ends its line, each item takes a line of its own one indent past the statement, and the closer starts a line at the statement's indent, carrying whatever follows it ();, ) const override {). From app/editor/main.cpp and system/render/render_view.h:
Never the forms between: items aligned under the open paren, several items to a continuation line, or the closer tucked after the last item. Each is a layout a reader has to decode, and each is redone by hand the day a name changes length. tools/reflow_lists <file>... rewrites a file's parenthesised lists to these two forms - joining one that fits, breaking one that does not - changing only whitespace; a braced list, a table and a control statement's condition it leaves to you, and the docs suite names what remains.
Five shapes keep a layout of their own:
A call whose last argument is a lambda opens the lambda on the call's line and closes with });, because the lambda is the call's body:
A constructor's initializer list that does not fit on the signature's line starts on the next, one indent in, one member to a line with the comma leading, and the body's brace takes a line of its own so it does not read as part of the last member:
A long expression is better split into named steps than wrapped. When one must wrap - a condition, a return, a sum - it breaks before an operator and the continuation is indented one level past the statement, never aligned under an operand:
Organize with access sections, blank lines, anonymous namespaces and @brief blocks. There is no banner anywhere in src/, app/, examples/ or templates/, so one arriving in a diff is new. A separator inside a runtime log string - the boot banner, the build dump - is output, not structure, and is exempt.
No comment goes inside an argument list:
A comment there is one more thing to keep in step with the parameter it names, and nothing checks that it is. Where the call has to show what a value means - two bare bools side by side always do - name the value (a field set by name, a local, a constant, an enum in place of a bool) rather than label it.
The default is no comment. Write one only when a reader would otherwise have to ask: why does this exist, what invariant does it hold that the types cannot, why is the obvious alternative wrong (a measured cost, a platform quirk, an upstream bug).
Before writing one, try to make the code say it: a name for the value, a function for the step, a type for the state. A comment that restates the line under it goes; a declaration whose name and signature say everything a caller needs takes no doc block at all. Code a reader cannot follow without a paragraph beside it is usually code to restructure, not to annotate.
| Style | Use for |
|---|---|
| /** @brief ... */ | Public API a caller needs more than the signature for |
| /// | A note on a member or function, above it |
| ///< trailing | A short note on a data member, beside it when the line fits |
| // | Inside a function body - the why |
Two kinds of comment, bounded two ways.
Inside a function, a comment is bounded by lines, because every line of it is a line of code the reader is not reading: // runs 1-3 lines, and /// on a plain member is one. Past three lines the comment is usually a diagnosis - implementation.md names the three things it can mean. The exception is a correctness argument the code cannot state, at the one place a reader would otherwise reconstruct it: why friction is clamped as a vector, why last tick's impulse is applied before the first pass. Strike one of those and a competent reader asks the question again. A second paragraph in a body comment is the tell that it may have become a document, which belongs under docs/reference/ with a pointer left behind.
On a declaration, a comment is bounded by relevance to a caller. It is what a caller reads instead of the implementation: one sentence of @brief, then whatever a caller cannot work out - what it is, why it has this shape, what it must not be asked to do. A seam or a format earns twenty lines when none of them is inferable (RenderBackend, EditorRenderHooks, AudioDevice); a block is too long when a line of it is something the caller already knew, never because it passed a count.
At any length, a comment may not:
The test that settles most cases: strike the comment and reread. If a competent reader who knows this engine would now ask a question, keep the answer to that question and nothing else.
Doxygen rules:
6.1 turns on what a comment does, which reads the same in a CMakeLists.txt, a shader and the Python under tools/. A # run inside a foreach() is a body comment; a block above a target, a function or a uniform is a declaration block.
Build files earn more explanation per line than C++: an install() destination, PUBLIC against PRIVATE, why a target is INTERFACE - none of it is inferable from the line. What concerns the build as a whole belongs in ../reference/building.md.
Comments are held the way the manual is: by checks that fail the build, so the mechanical half of a review is never a person's job.
What no check can see - a sentence that names real code and says something untrue about it - is what a review reads for (review.md).
The pages under docs/reference/ are bounded the way a declaration's comment is, and for the same reason: they are read instead of the code, and believed. A page says what the engine is and does.
Every class spells out its five special members, in this order - a resource owner deletes copy and move, a value type defaults them:
The parameter is always other, even when deleted; a blank line separates the copy pair from the move pair; = default for trivial members, = delete to forbid. A gameplay Behavior is the exception: clone() copies it, and the authored fields are its whole interface (10.2).
A System subclass writes the block too, though core/system.h already deletes copy and move: the block is how a reader recognises a system at a glance. Its class @brief also says which stage it runs at and why that one, against a named sibling (system/sky/sky_system.h).
A class is laid out in this order, each block present only when it has something, a blank line between blocks:
A reader opening any class finds what it is for at the top and what it holds at the bottom.
In a class, data members are always last, in a private: section of their own, even when that means two private: blocks:
The state of an object is what a reader most often looks for; it is at the bottom of every class, not in the middle of some.
Anything owning a GPU handle, file handle, thread or unique scene state is non-copyable and non-movable; ownership moves with std::unique_ptr<T>, not a hand-written move constructor, and never an = default move on a class that is meant not to move. A value class (Clock) defaults its five; a plain struct (StorageIndex) has none to write.
Every override carries override, the destructor included (~VisibilitySystem() override = default;), and never also virtual.
A template anyone else can instantiate lives entirely in a header. A .cpp may define one in three shapes, all of which keep that rule:
If a second translation unit could want an instantiation you have not named, it belongs in the header.
Logging is categorized: #define VKM_LOG_CATEGORY "RENDER" above the own header (section 3), then LOG_TRACE / LOG_INFO / LOG_WARNING / LOG_ERROR with printf-style formatting.
Which channel a failure takes - the log, reportError or a toast - is judgment, not mechanics: implementation.md.
Profile zones go through a facade and compile to nothing without the profiler. Engine code never includes Tracy; only debug/profiler.h and backend/opengl/gl_profiler.h do.
| Family | Header | Macros |
|---|---|---|
| CPU | debug/profiler.h | PROFILE_SCOPE / _NAMED, PROFILE_PLOT |
| GPU | backend/opengl/gl_profiler.h | PROFILE_GPU_CONTEXT, PROFILE_GPU_COLLECT, PROFILE_GPU_SCOPE / _NAMED |
Deliberate deviations. Do not add one without the owner.
It holds std::vector<std::unique_ptr<Behavior>>, which makes it the one component that cannot be copied. SparseSet<T> has a move path for such types; deep copy goes through Behavior::clone(). Do not generalize from it.
Authored fields on a Behavior subclass are bare public members on a class, breaking 4.1 on purpose: the field name is the serialized identity, and the inspector's label is made from it, so m_ would leak into both. Runtime state on the same class takes m_. The model is Spinner::degreesPerSecond (templates/default/src/game.h).