A custom Vulkan game engine written in Rust, built around a render-graph architecture with automatic barrier tracking, a deferred rendering pipeline, and a threaded game/render split.
engine-core/ the engine itself: ECS, asset pipeline, Vulkan abstraction, render graph
engine-pipelines/ a batteries-included deferred renderer + built-in shaders, built on engine-core
engine-demo/ minimal example application showing how to use the engine
engine-core has no opinion about how you render things - it gives you the plumbing (device/swapchain setup, render
graph, resource pool, asset loading, ECS). engine-pipelines is one opinionated pipeline built on top of that plumbing.
You could write your own pipeline crate instead and skip engine-pipelines entirely.
- Render graph (
engine-core/src/render/graph.rs) - passes declare read/write access to resources; the graph topologically sorts them and inserts image layout barriers automatically. See Render graph below. - Deferred pipeline (
engine-pipelines) - shadow pass -> depth prepass -> GBuffer (albedo/normal) -> lighting -> tonemap/post-process -> FSR1 (EASU + RCAS) upscale -> UI overlay. - Bindless textures - a single descriptor set with
update_after_bind+PARTIALLY_BOUND, indexed vianonuniformEXTin shaders. No per-draw descriptor churn. - Threaded renderer - game logic and rendering run on separate threads, synchronized via a lock-free triple buffer (
render/triple_buffer.rs). The render thread never blocks on game logic. - Async asset loading - a background thread loads
.obj/.gltf/.glboff the hot path; results flow back through a channel and get GPU-uploaded when ready. - Text rendering -
cosmic-textfor shaping + a custom glyph atlas (etagere-backed packer) for rasterized text. - GPU profiling - per-pass timestamp queries (
vulkan/timestamps.rs) feeding intopuffinfor frame-time breakdowns.
Passes are built declaratively and don't know about each other directly - dependencies are inferred from resource access:
pass("lighting")
.read(h_gbuffer_albedo, ImageLayout::ShaderReadOnly)
.read(h_gbuffer_normal, ImageLayout::ShaderReadOnly)
.read(h_depth, ImageLayout::ShaderReadOnly)
.read(h_shadow_map, ImageLayout::ShaderReadOnly)
.write(h_hdr, ImageLayout::ColorAttachment)
.bind_sampled(h_gbuffer_albedo, lighting_pass.descriptor_set, 0, lighting_pass.sampler)
.bind_sampled(h_gbuffer_normal, lighting_pass.descriptor_set, 1, lighting_pass.sampler)
.bind_sampled(h_depth, lighting_pass.descriptor_set, 2, lighting_pass.sampler)
.bind_sampled(h_shadow_map, lighting_pass.descriptor_set, 4, lighting_pass.shadow_sampler)
.record( move | enc, rw, gpu| lighting_pass.record(enc, rw, gpu, h_hdr))
.build(graph, & gpu_assets);RenderGraph::compile() builds a dependency graph from these read/write accesses, topologically sorts the passes, and
precomputes the minimal set of VkImageMemoryBarrier2s needed between them. Resources are either:
- Transient - sized relative to internal or output resolution, allocated/resized by the graph (
ResourcePool) - External - swapchain images, whose layout is managed at frame boundaries
Current pipeline order in engine-pipelines:
shadow ---------+
|
+--> geometry --> lighting --> post_process
| |
depth_prepass --+ v
fsr_easu --> fsr_rcas --> blit_to_swapchain --> ui
(depth_prepass and shadow have no dependency on each other and could run in parallel on hardware/APIs that support
it - the graph doesn't currently exploit that, it just orders them consistently.)
Requires the Vulkan SDK installed with glslc on your PATH - shaders are compiled to SPIR-V at build time via
build.rs (engine-pipelines/build.rs), there's no runtime shader compilation.
cargo build --release
cargo run -p engine-demoIf glslc isn't found, the build fails immediately with a clear panic rather than silently skipping shader compilation.
See engine-demo/src/main.rs for a full example. Minimal shape:
struct MyApp;
impl App for MyApp {
fn initial_pipeline() -> PipelineFactory { PipelineFactory::of::<LoadingPipeline>() }
fn on_start(&mut self, ctx: &mut EngineContext) {
ctx.world.spawn().insert(CameraComponent::default()).insert(ActiveCamera).build();
ctx.world.spawn().insert(DirectionalLightComponent::default()).build();
}
fn on_update(&mut self, ctx: &mut EngineContext, dt: f32) { /* game logic */ }
fn on_render(&mut self, _ctx: &mut EngineContext) {}
fn on_stop(&mut self, _ctx: &mut EngineContext) {}
}
fn main() -> anyhow::Result<()> {
Engine::run(MyApp)
}Entities are plain hecs ECS entities (GameWorld wraps hecs::World). Each fixed tick, an ExtractSchedule copies
relevant ECS state into a RenderWorld snapshot, which is published to the render thread via the triple buffer.
- Single discrete-GPU assumption in device selection (falls back to first suitable device if none found).
- No parallelism exploited in the render graph yet - passes execute sequentially even when independent.
- Some internal log/error strings are in Russian; not yet standardized to one language throughout.
Engine architecture (engine-core):
- Multithreaded command recording - record passes in parallel into secondary command buffers via a job system
- Draw call batching / instancing - GPU instancing for identical meshes instead of one draw call per instance
- Resource aliasing in the render graph - reuse memory across transient resources with non-overlapping lifetimes
- Runtime debug UI - GPU timings, G-buffer view, live-tweaking of render parameters
-
vertex_format!proc-macro - deriveVertexFormat(layout/offsets/stride) for custom vertex structs instead of a hand-writtenimpl
Rendering backlog (engine-pipelines):
- PBR lighting (specular/GGX, metallic)
- Point light shadows
- Cascaded shadow maps
- IBL / ambient
- Wire up frustum culling (already implemented in
math/frustum.rs, currently unused) - Alpha blending pass
This project is licensed under the Mozilla Public License 2.0 (MPL-2.0). See the LICENSE file for details.