Eos the goddess of dawn, aka the first light of the day. A wordplay on lighting.
Warning
This project is still in its early stages.
Eos aims to be:
- Bindless Rendering Framework. Mainly targetting Vulkan.
- As GPU-friendly as possible, while providing a "higher" level API for ease of use.
- only targets Windows and Linux.
Note
Dependencies are fetched automatically during CMake configure using FetchContent in cmake/deps.cmake.
Versions are centralized in cmake/deps-lock.cmake.
- GLFW
- Volk
- Vulkan Utility Libraries
- Vulkan Memory Allocator (VMA)
- spdlog
- KTX-Software
- GLM
- rapidyaml
- fastGLTF
- Slang when
EOS_SHADER_TOOLS=ON - stb when
EOS_BUILD_TEXTURE_TOOLS=ON - Dear ImGui when
EOS_USE_IMGUI=ON - Tracy when
EOS_USE_TRACY=ON
- Vulkan SDK (required)
All build toggles are exposed as CMake options and can be configured with -D... flags.
option(EOS_VULKAN "Enable Vulkan backend" ON)
option(EOS_USE_IMGUI "Enable ImGui integration" ON)
option(EOS_USE_TRACY "Enable Tracy profiler" ON)
option(EOS_BUILD_EXAMPLES "Build example applications" ON)
option(EOS_SHADER_TOOLS "Enable shader tools (hot reload, shader compiler, prebuild shader compile step)" ON)
option(EOS_BUILD_TEXTURE_TOOLS "Build the texture compressor tool" ON)EOS_PROJECT_SHADER_PATH:- Empty by default.
EOS_SHADER_OUTPUT_PATH:- Defaults to
binin the repository root. - Used as the output location for compiled shaders.
- Defaults to
Shaders are written in Slang. Every .slang file with [shader("...")] entry points is a
program; the other files are modules it imports.
- Compilation: before an example builds,
EOSShaderCompilerToolcompiles its programs and the engine's intobin/shaders/<Example>/<Debug|Release>/<module>.EOS. Each file holds the SPIR-V of every entry point plus the reflection (push-constant size, thread-group size, specialization constants, vertex inputs, color outputs, bindings). A program is only recompiled when one of its source files (imports included), the compiler options or the Slang version changed.--forcerecompiles everything,--reflect <module>prints a program's reflection and--dump-spirv <dir>writes its SPIR-V. A build where no shader changed does not start Slang. - Precompiled library: before EOS builds, the engine's modules (
src/shaders/eos/) are precompiled to Slang IR inbin/shaders/EOS/<Debug|Release>/modules/. Every compile, at build time and at runtime, loads those instead of parsing and type-checking the library again, which makes recompiling a program that uses the material and BSDF code about three times faster (hot reload, shader graph edits). A module whose sources changed is compiled from source until the next build precompiles it again. Library modules are shared by all programs, so they must not depend on a program'sDefines; vary them with generics, specialization constants or link-time constants. - Runtime:
IContext::CreateShaderProgram({.Module = "shade"})loads a program from the cache and falls back to compiling it when the cache is stale (only withEOS_SHADER_TOOLS=ON). Pipelines pick its entry points by name:.VertexShader = {shade, "vertexMain"}.IContext::GetShaderProgram(handle)returns the reflection, andcmdDispatchThreadsuses the reflected[numthreads]to size a dispatch. - Pipeline layout: every pipeline shares one layout: the bindless descriptor set plus one push-constant range
visible to all stages (
IContext::GetMaxPushConstantSize(), 256 bytes on desktop GPUs). Programs are checked against it when they are created: larger push constants or bindings outside the bindless set are rejected with an error. Pipeline creation also checks that every vertex shader input has a matching attribute. - Hot reload:
IContext::ReloadShaders()recompiles every program in use whose source files, imports included, changed on disk, and rebuilds the pipelines that use it. A program that fails to compile or validate keeps its previous version. - Conventions: matrices are row-major and multiply row vectors (
mul(v, M)), which matches glm's memory layout; buffers use scalar layout. Debug builds compile shaders with debug info and without optimization, so they can be stepped through in RenderDoc or Nsight.
Data that C++ writes and shaders read (buffer contents, push constants) is declared once, in Slang, and marked
[CppExport] (from eos.core). EOSShaderCompilerTool generates the C++ struct into .generated/<module>.h next to
the code that uses it: src/.generated/ for the engine's modules (namespace EOS), <Example>/src/.generated/ for an
example's (global namespace). Include it as #include ".generated/shadowCommon.h".
import eos.core;
[CppExport]
public struct DofBlurPC
{
public DescriptorHandle<Texture2D> inputImage;
public DescriptorHandle<RWTexture2D<float4>> outputImage;
public float maxBlurRadius = 4.0;
}The generated struct has the Slang field names and a static_assert for its size and every offset, so a layout
mismatch fails the C++ build instead of reaching the GPU. Types map to glm (float3 to glm::vec3, float4x4 to
glm::mat4), T[N] to std::array, pointers to uint64_t device addresses and DescriptorHandle<T> to
EOS::DescriptorHandle. Every enum of a module that exports structs is exported too (Slang does not allow attributes
on enums). Fields start zeroed, or at their Slang default when that is a literal number. bool, half, resources and
defaults that are not literal numbers are reported as errors with the reason; see tools/ShaderTools/shaderCodegen.h.
Headers are only rewritten when their content changes, so C++ is not rebuilt for shader edits that do not touch a
shared struct. They are build output and not committed (.generated/ is in .gitignore), so they appear with the
first build; a build with EOS_SHADER_TOOLS=OFF cannot write them and needs an earlier build of the same checkout
with the tools on.
The engine's modules live in src/shaders/eos/ and are imported as eos.<name>:
| Module | Contents |
|---|---|
eos.bindless |
DescriptorHandle<T> routing onto the bindless descriptor set; IsValid(handle) |
eos.core, eos.math, eos.color |
constants, [CppExport], math helpers, sRGB conversion and luminance |
eos.sampling |
PCG random numbers, hemisphere and GGX visible-normal sampling, MIS weights |
eos.brdf |
GGX distribution, height-correlated Smith masking-shadowing, Fresnel |
eos.bsdf |
IBSDF (Eval, Sample, EvalPdf, Albedo), ShadingFrame and the glTF 2.0 StandardBSDF |
eos.material |
IMaterial, SurfaceData, texture samplers for implicit and explicit mip levels, and the glTF 2.0 StandardMaterial |
eos.lighting |
directional and point lights, constant ambient light for any IBSDF |
eos.fullscreen, eos.debugDraw, eos.imgui |
fullscreen triangle, debug shapes, the ImGui program |
Bindless resources: textures, samplers, storage images and acceleration structures are referenced with
DescriptorHandle<T> in shaders and EOS::DescriptorHandle in C++, an 8-byte value built from a TextureHandle,
SamplerHandle or AccelStructHandle. Put it in push constants or buffers and use it like the resource itself:
albedo.Sample(linearSampler, uv). Every texture type shares one index space, so a texture must be read as the type it
was created as (Texture2D, Texture2DArray, TextureCube, ...). Buffers are passed as device addresses and read
through pointers (MyStruct*). eos.bindless is linked into every program, so this works without importing it.
Materials: StandardMaterial evaluates a glTF 2.0 metallic-roughness material (StandardMaterialData, generated
as EOS::StandardMaterialData in C++) into a StandardBSDF, shading frame, emission, opacity and occlusion. Textures are
optional: an empty handle means the factor alone is used. The rasterizer evaluates the BSDF per light (eos.lighting);
a path tracer uses Sample and EvalPdf from the same BSDF.
Alpha-tested (glTF MASK) materials are cut with PassesAlphaTest(EvaluateOpacity(...)), which also has to run in
depth-only passes, otherwise the cut-away parts still write depth and cast shadows. A fragment shader that can discard
turns off early depth testing for the whole draw, so depth-only passes draw the opaque meshes with a fragment shader
that never discards and only the alpha-tested ones with one that does. A scene's instances are sorted by alpha mode,
so a [DrawScene] pass with a fragment shader per alpha mode ([Materials]) makes that two indirect draws (see the
shadow-mapping examples).
Frames are described with EOS::RenderGraph (src/renderGraph.h), rebuilt every frame like Frostbite's FrameGraph
and Unreal's RDG. A pass declares what it renders to, samples, writes and reads; Execute() derives the rest.
EOS::RenderGraph& graph = *App.Graph;
const EOS::GraphTexture backbuffer = graph.ImportSwapchain();
const EOS::GraphTexture albedo = graph.CreateTexture({.TextureFormat = EOS::Format::RGBA_UN8, .DebugName = "Albedo"});
const EOS::GraphTexture depth = graph.CreateTexture({.TextureFormat = EOS::Format::Z_F32, .DebugName = "Depth"});
const EOS::GraphTexture lit = graph.CreateTexture({.TextureFormat = EOS::Format::RGBA_F16, .Scale = 0.5f, .DebugName = "Lit"});
graph.AddRasterPass("GBuffer").Color(EOS::Clear(albedo)).Depth(EOS::ClearDepth(depth)).Execute([&](EOS::PassContext& pass)
{
cmdBindRenderPipeline(pass.Cmd, gbufferPipeline);
cmdDrawIndexedIndirect(pass.Cmd, indirectBuffer, 0, drawCount);
});
graph.AddComputePass("Lighting").Sample(albedo).Write(lit).Execute([&](EOS::PassContext& pass)
{
cmdBindComputePipeline(pass.Cmd, lightingPipeline);
cmdPushConstants(pass.Cmd, LightingPC{.albedo = pass.Descriptor(albedo), .output = pass.Descriptor(lit)});
cmdDispatchThreads(pass.Cmd, pass.Size(lit));
});
graph.Execute(); // barriers, render passes, markers, submit and present- Resources:
CreateTexture/CreateBufferresources belong to the graph. Textures are sized relative to the swapchain (Scale) or fixed (Size), get their usage flags from how passes use them, are pooled across frames and are recreated when the window is resized. Their contents do not survive the frame;CreateHistoryTexturegives a texture that does, together with last frame's version (temporal effects). - Memory aliasing:
CreateTexturetextures live in memory heaps that the graph keeps across frames. A texture is alive from the first pass that uses it to the last one, and textures that are never alive at the same time share memory (the depth of field example needs 73 MiB of textures and fits them in 42 MiB). The first pass that uses a texture discards its old contents and waits for the earlier users of that memory, this frame's or a previous one's. A heap grows when a frame needs more and shrinks when it stays more than twice as large as needed for 120 frames, for example after the window got smaller.GetMemoryStatistics()returns the texture bytes and the heap bytes;SetAliasing(false)gives every texture its own memory, which helps to tell an aliasing bug from another one. Heaps come fromIContext::CreateMemoryHeap, andTextureDescription::Heap/HeapOffsetplace a texture in one. - Imports: resources owned elsewhere are imported (
ImportTexture,ImportBuffer,ImportSwapchain). The graph keeps the state it left them in for the next frame. Anything referenced before the graph executes, such as a texture the UI shows or a handle uploaded in a buffer, has to be such an import: graph resources only exist inside passes, wherepass.Descriptor(),pass.Texture()andpass.Address()resolve them. - Per-frame data: write it with
graph.AddUpload(name, buffer, data), which copies it on the GPU in order with the passes (cmdUpdateBuffer, at most 64 KiB), and let the passes that use it.Read(buffer). Writing it from the CPU (IContext::Uploadinto host-visible memory) changes it while earlier frames may still be reading it: in the cascaded shadow mapping example that made early-Z and shading see different camera matrices, and the scene vanished while the camera moved. - Execution: passes run in the order they were added. Passes whose results nothing uses are skipped (writes to
imports, the swapchain and history textures always count). Every pass gets the barriers it needs in one batch,
raster passes are wrapped in
cmdBeginRenderingfor their targets, and every pass is a named debug marker. - Data: the graph is flat arrays of resources, passes and accesses indexed by the handles, and pass functions live in a reused arena, so building a frame allocates nothing once the graph is warm.
In the examples, App.AddUIPass(target, [&] { ... }) declares the frame's UI and draws it on top of target.
A graph file (YAML, src/renderGraphFile.h) lists what a frame is made of: its data, its passes, their settings and
how their pins connect, in the style of Falcor's render graphs. It is what a node editor would save: data and passes
are nodes, edges are wires.
EOS::GraphFile file{registry, "graphs/depthOfField.yaml"} reads the YAML at runtime, and graphFile.Reload() loads
it again when it changed on disk, so passes can be added, rewired or tuned while the application runs; the examples
call it on the same key as shader reloading (-). Examples read their graph files from their sources
(examples/<name>/src/graphs, EOS_PROJECT_GRAPH_PATH), so edits change the running example.
data:
Camera: { type: flyCamera, origin: [0, 1, 0], speed: 100 }
Sponza: { type: gltfScene, path: sponza/Sponza.gltf }
passes:
Geometry: { shader: gbuffer }
Lighting: { shader: deferredLightCompute, debugView: Normals }
DOF Composite: { shader: dofComposite, enabled: false, output: { format: RGBA_F16 } }
Present: { shader: present }
edges:
- Sponza.scene -> Geometry.scene
- Camera.view -> Geometry.view
- Geometry.albedo -> Lighting.albedo
- Lighting.output -> DOF Composite.color
- DOF Composite.output -> Present.input
- Present.output -> swapchain- Passes are GPU work:
shader: gbufferis the Slang filegbuffer.slang(below), whose pins (the textures and buffers it reads and writes, with how it uses them) and properties come from the shader. A pass type registered in C++ with anExecutefunction that records its own commands is named withtype:instead. - Data is made on the CPU by a type registered in C++ (a
PassRegistry), named withtype:: its pins, its properties (bool, int, float, float2-4, a choice of names, a string) and aSetupfunction, called while the file is added to the frame, each after what it reads from. Data that owns resources hands them out on its outputs there (setup.Output(pin, buffer)), and data computed on the CPU is uploaded (setup.Upload(pin, value)). What a C++ type uploads travels along the pin with a CPU copy, so C++ types downstream read it on the CPU (setup.Data.Host<EOS::View>("view")) while Slang passes read the buffer. Data records nothing on the GPU, so it is not a pass and cannot be disabled. The examples' C++ is data like this: a turntable (model), a light's View (shadow mapping), the CPU cascade fit. - Scene, camera and sun data come with the engine.
gltfScene(src/scene.h) loads a glTF file (path, relative to the data folder in the examples) once and hands out the scene on itsscenepin: every mesh in one vertex and index buffer, the instances sorted opaque, alpha-tested, blended, the materials, indirect draws and a TLAS on devices that build acceleration structures.flyCamera(src/flyCamera.h) is flown with WASD/QE and the right mouse button and uploads itsView(eos.view) every frame; changing itsoriginorrotationmoves it there.sun(src/sun.h) uploads aDirectionalLight(eos.lighting) from a rotation, color and intensity, for every pass that shares it. - Typed buffers: a buffer pin can say what it holds (
Scene,View; Slang passes take it from what their pointer points to), and only pins of the same type connect, soCamera.view -> Geometry.sceneis an error in the file. - An entry has its
shader(passes) ortype(data), sets property values by name, and can override an output'sformatandscale; a pass may setenabled. Data and passes share one set of names. - Edges connect an output to an input:
Name.pin -> Name.pin. A name without a dot is a resource of the application, handed over every frame:graphFile.AddTo(graph, {{"swapchain", backbuffer}, {"perFrame", perFrame}}). - Order: every pass runs after the passes it reads from; otherwise the file's order is kept. Passes added to the
graph before and after
AddTorun before and after the file's passes. Outputs nobody reads are culled as usual. - Disabled passes pass on what they received: an input-output keeps its input, and an output can name an input as
its bypass (
BypassFrom). In the depth of field example, disablingDOF Compositeshows the scene without blur. - Mistakes are reported like compiler errors (
file:line:column: message) and the last version without errors keeps running, so a typo while editing never takes the frame down. - UI:
EOS::UI::GraphFileProperties(graphFile)shows the data and the passes under a heading each, every one a section that opens to its properties; passes that can be turned off without starving the passes after them (every output they create has a bypass) have an enable checkbox. Changes last until the file is reloaded.EOS::UI::GraphFilePaneladds a preview of any texture a pass writes, picked from a list: one layer of it, with its values remapped to a range (depth in grey). - In the examples,
App.Run("depthOfField")runssrc/graphs/depthOfField.yamlas the whole frame, with the panel on top;-reloads it with the shaders.App.Run({"csmCompute", "csmCpu", "csmRayQuery"})offers several files in the panel, one shown at a time (the cascaded shadow mapping example has a file per shadow technique).App.Passesholds the types graph files use, where an example registers its C++ ones.
Every example is built this way: its frame is graph files and their Slang passes, and main.cpp runs them, after
registering the few C++ data types it has. Only the compute example has a loop of its own, since it reads its result
back and checks it.
A pass is a Slang file: a shader whose push constants are a struct marked [Pass] (from eos.pass). Graph files use
it by its module name (shader: dofComposite); the registry loads it the first time a file names it, creates its
pipeline, fills its push constants and records it. One pass per file.
import eos.pass;
[Pass]
struct DofComposite
{
[Input] DescriptorHandle<Texture2D> color; // a pin: what another pass wrote
[Input] DescriptorHandle<Texture2D> blurred;
[Output] [Bypass("color")] DescriptorHandle<RWTexture2D<float4>> output; // created by the graph
DescriptorHandle<SamplerState> linearSampler; // filled with the engine's linear sampler
[Range(0.1, 25.0)] float focusDistance = 6.0; // a property
BlurShape shape; // an enum: a choice by case name
};
[[vk::push_constant]] DofComposite pass;
[shader("compute")] [numthreads(8, 8, 1)]
void computeMain(uint3 id : SV_DispatchThreadID) { ... }- Pins come from the field types.
DescriptorHandle<Texture*>fields are inputs.DescriptorHandle<RWTexture*>fields and pointers (T*, buffers) say[Input],[Output]or[InOut]. An output is created by the graph and shaped with[Format("RGBA_F16")](the swapchain's format by default),[Scale(0.5)],[SizeOf("color")],[Size(4096, 4096)],[Layers(4)]and[Bypass("color")]; a pointer output is a buffer the size of what it points to.[Optional]inputs may stay unconnected. - Depth: a
DepthTargetfield is the depth attachment and takes no push-constant space.[Output]clears and writes it,[InOut]loads and writes it,[Input]only tests against it;[DepthTest(CompareOp.Equal)]sets the comparison. ADescriptorHandle<Texture2D>input marked[DepthTest]is attached read-only and can be sampled by the same pass; a pass that only samples depth (SSAO) takes a plain texture input. - Samplers:
DescriptorHandle<SamplerState>fields get a sampler of[Sampler(Filter.Nearest, Address.ClampToBorder)], linear and repeating without it. - Properties are the
bool,int,uint,float,float2-float4and enum fields. Graph files set them by name (an enum by case name),[Range(min, max)]gives the UI a slider, and a scalar field's default is the value until a file sets one. - Compute or raster: a
computeentry point dispatches one thread per pixel of the pass's first storage texture output. Afragmententry point renders into color targets: the fields of the shader's output struct,[Output](cleared,[Clear(r, g, b, a)]) or[InOut](loaded). It draws a fullscreen triangle witheos.fullscreen's vertex shader, or 3 vertices with avertexentry point of its own.[Cull(CullMode.Back)]and[DepthClamp]on the[Pass]struct set the rasterizer. - Drawing the scene: a
[DrawScene]pass (eos.scene) has aScene*pin and draws every instance of the scene connected to it, with one indirect draw per range of instances. Its vertex shader pulls the vertices itself:pass.scene.vertices[vertexID]andpass.scene.instances[instanceID], withSV_VulkanVertexIDandSV_VulkanInstanceID, so there is no vertex input layout anywhere. A pass with several fragment shaders tags each with the materials it draws,[Materials(AlphaMode.Opaque)]and[Materials(AlphaMode.Mask)], so a depth pass keeps early depth testing for opaque instances and pays for the alpha test only on the others. - Checks: the shader tool reflects every pass when it compiles it and reports a field it cannot use, an unknown
format, a
[SizeOf]/[Bypass]that names no fitting pin or fragment shaders that draw the same materials.--reflect <module>prints a pass's pins and properties. - Hot reload:
-recompiles a changed pass; when its pins or properties changed, the registry registers it again and graph files that use it are resolved again, so a new property can be set in the file on the same reload. A graph file that failed to load is read again on the next-even when unchanged, so fixing a pass it uses brings it back.
This project is built using CMake and Ninja.
- CMake: Ensure CMake is installed and accessible from your command line/terminal. (Download CMake)
- Ninja: Ensure Ninja is installed and accessible. (Download Ninja)
- C++ Compiler:
- Linux: A C++ compiler like GCC or Clang. (e.g.,
sudo apt update && sudo apt install build-essential g++on Debian/Ubuntu). - Windows: Microsoft Visual C++ (MSVC), usually installed with Visual Studio. Make sure the "Desktop development with C++" workload is installed.
- Linux: A C++ compiler like GCC or Clang. (e.g.,
- Vulkan SDK: Install the Vulkan SDK for your platform. (Download Vulkan SDK)
-
Clone the repository:
git clone https://github.com/XanderBert/EOS.git cd EOS -
Run the appropriate build script:
- Linux:
build.shscript in the root directory of the project. - Windows:
build.batscript in the root directory of the project.
- Linux:
This project is heavily inspired by LVK and The Forge. It's created for my personal use to learn about modern Graphics Techniques
