Hotline
Hotline is a graphics engine and live coding tool that allows you to edit code, shaders, and render configs without restarting the application. It provides a client application which remains running for the duration of a session. Code can be reloaded that is inside the dynamic plugins and render configs can be edited and hot reloaded through pmfx files.
There is a demo video showcasing the features in their early stages and an example workflow demonstration of how the geometry primitives were created. Some development has been live streamed on Twitch and archived on YouTube.
Features
- An easy to use cross platform graphics/compute/os api for rapid development.
- Hot reloadable, live coding environment (shaders, render configuration, code).
- Plugin architexture, flexible and extendible.
- os - Operating system API; window, input, etc.
- gfx - Concise low level graphics API.
- pmfx - High level, data driven, ergonomic graphics API.
- av - Hardware accelerated av decoding.
- imgui - Full featured dear ImGui implementation with viewports and docking.
- ecs - Entity component system using
bevy_ecs
. - Client - Live reloading coding environment client application.
- Standalone Library - Use as a standalone library and integrate into your own applications.
- Examples - A focus on modern rendering examples (gpu-driven, multi-threaded, bindless).
Prerequisites
Currently Windows with Direct3D12 is the only supported platform, there are plans for macOS, Metal, Linux, Vulkan, WASM and WebGPU in future.
Using the Client
For the time being it is recommended to use the repository from GitHub if you want to use the example plugins
or standalone examples
. If you just want to use the library then crates.io
is suitable. There are some difficulties with publishing data and plugins which I hope to iron out in time.
Building / Fetching Data
The hotline-data repository is required to build and serve data for the examples and the example plugins, it is included as a submodule of this repository, you can clone with submodules as so:
git clone https://github.com/polymonster/hotline.git --recursive
You can add the submodule after cloning or update the submodule to keep it in-sync with the main repository as follows:
git submodule update --init --recursive
Running The Client
You can run the binary client
which allows code to be reloaded through plugins
. There are some plugins already provided with the repository:
// build the hotline library and the client, fetch the hotline-data repository
cargo build
// build the data
hotline-data\pmbuild win32-data
// then build plugins
cargo build --manifest-path plugins/Cargo.toml
// run the client
cargo run client
Any code changes made to the plugin libs will cause a rebuild and reload to happen with the client still running. You can also edit the shaders where hlsl
files make up the shader code and pmfx
files allow you to specify pipeline state objects in config files. Any changes detected to pmfx
shaders will be rebuilt and all modified pipelines or views will be rebuilt.
Building One-Liners
To make things more convenient during development and keep the plugins
, client
and lib
all in sync and make switching configurations easily, you can use the bundled pmbuild in the hotline-data
repository and use the following commands which bundle together build steps:
// show aavailable build profiles
hotline-data\pmbuild -help
// build release
hotline-data\pmbuild win32-release
// build debug
hotline-data\pmbuild win32-debug
// run the client
hotline-data\pmbuild win32-debug -run
// build and run the client
hotline-data\pmbuild win32-release -all -run
Building from Visual Studio Code
There are included tasks
and launch
files for vscode including configurations for the client and the examples. Launching the client
from vscode in debug or release will build the core hotline lib
, client
, data
and plugins
.
Adding Plugins
Plugins are loaded by passing a directory to add_plugin_lib which contains a Cargo.toml
and is a dynamic library. They can be opened interactively in the client using the File > Open
from the main menu bar by selecting the Cargo.toml
.
The basic Cargo.toml
setup looks like this:
[package]
name = "ecs_examples"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["rlib", "dylib"]
[dependencies]
hotline-rs = { path = "../.." }
You can provide your own plugin implementations using the Plugin trait. A basic plugin can hook itself by implementing a few functions:
impl Plugin<gfx_platform::Device, os_platform::App> for EmptyPlugin {
fn create() -> Self {
EmptyPlugin {
}
}
fn setup(&mut self, client: Client<gfx_platform::Device, os_platform::App>)
-> Client<gfx_platform::Device, os_platform::App> {
println!("plugin setup");
client
}
fn update(&mut self, client: client::Client<gfx_platform::Device, os_platform::App>)
-> Client<gfx_platform::Device, os_platform::App> {
println!("plugin update");
client
}
fn unload(&mut self, client: Client<gfx_platform::Device, os_platform::App>)
-> Client<gfx_platform::Device, os_platform::App> {
println!("plugin unload");
client
}
fn ui(&mut self, client: Client<gfx_platform::Device, os_platform::App>)
-> Client<gfx_platform::Device, os_platform::App> {
println!("plugin ui");
client
}
}
// the macro instantiates the plugin with a c-abi so it can be loaded dynamically.
hotline_plugin![EmptyPlugin];
ecs
There is a core entity component system plugin which builds on top of bevy_ecs. It allows you to supply your own systems and build schedules dynamically. It is possible to load and find new ecs
systems in different dynamic libraries. You can register and instantiate demos
which are collections of setup
, update
and render
systems.
Registering Demos
Systems can be imported dynamically from different plugins, in order to do so they need to be hooked into a function which can be located dynamically by the ecs
plugin. You can implement a function called get_demos_<lib_name>
, which returns a list of available demos inside a plugin
named <lib_name>
.
/// Register demo names
#[no_mangle]
pub fn get_demos_ecs_examples() -> Vec<String> {
demos![
"primitives",
"draw_indexed",
"draw_indexed_push_constants",
// ..
]
}
You can then provide an initialisation function named after the demo this returns a ScheduleInfo
for which systems to run:
/// Init function for primitives demo
#[no_mangle]
pub fn primitives(client: &mut Client<gfx_platform::Device, os_platform::App>) -> ScheduleInfo {
// load resources we may need
client.pmfx.load(&hotline_rs::get_data_path("shaders/debug").as_str()).unwrap();
// fill out info
ScheduleInfo {
setup: systems![
"setup_primitives"
],
update: systems![
"update_cameras",
"update_main_camera_config"
],
render_graph: "mesh_debug"
}
}
The setup
, update
and render
systems are tagged with the attribute macro #[export_update_fn]
, #[export_render_fn]
or #[export_compute_fn]
.
Setup Systems
You can supply setup
systems to add entities and set up a scene, these will be executed once at startup and then anytime when a dynamic code reload happens the world will be cleared and the setup systems will be re-executed. This allows changes to setup systems to appear in the live client. You can add multiple setup systems and they will be executed concurrently.
#[no_mangle]
#[export_update_fn]
pub fn setup_cube(
mut device: ResMut<DeviceRes>,
mut commands: Commands) {
let pos = Mat4f::from_translation(Vec3f::unit_y() * 10.0);
let scale = Mat4f::from_scale(splat3f(10.0));
let cube_mesh = hotline_rs::primitives::create_cube_mesh(&mut device.0);
commands.spawn((
Position(Vec3f::zero()),
Velocity(Vec3f::one()),
MeshComponent(cube_mesh.clone()),
WorldMatrix(pos * scale)
));
Ok(())
}
Update Systems
You can also supply your own update
systems to animate and move your entities.
#[no_mangle]
#[export_update_fn]
fn update_cameras(
app: Res<AppRes>,
main_window: Res<MainWindowRes>,
mut query: Query<(&mut Position, &mut Rotation, &mut ViewProjectionMatrix), With<Camera>>
) -> Result<(), {
let app = &app.0;
for (mut position, mut rotation, mut view_proj) in &mut query {
// ..
}
}
Render Systems
You can specify render graphs in pmfx
that set up views
, which get dispatched into render
functions. All render systems run concurrently on the CPU, the command buffers they generate are executed in an order determined by the pmfx
render graph and it's dependencies.
#[no_mangle]
#[export_render_fn]
pub fn render_meshes(
pmfx: &Res<PmfxRes>,
view: &pmfx::View<gfx_platform::Device>,
mesh_draw_query: Query<(&WorldMatrix, &MeshComponent)>) -> Result<(), hotline_rs::Error> {
let fmt = view.pass.get_format_hash();
let mesh_debug = pmfx.get_render_pipeline_for_format(&view.view_pipeline, fmt)?;
let camera = pmfx.get_camera_constants(&view.camera)?;
view.cmd_buf.set_render_pipeline(&mesh_debug);
view.cmd_buf.push_render_constants(0, 16 * 3, 0, gfx::as_u8_slice(camera));
// make draw calls
for (world_matrix, mesh) in &mesh_draw_query {
view.cmd_buf.push_render_constants(1, 16, 0, &world_matrix.0);
view.cmd_buf.set_index_buffer(&mesh.0.ib);
view.cmd_buf.set_vertex_buffer(&mesh.0.vb, 0);
view.cmd_buf.draw_indexed_instanced(mesh.0.num_indices, 1, 0, 0, 0);
}
// end / transition / execute
view.cmd_buf.end_render_pass();
Ok(())
}
Compute Systems
Compute systems work similarly to render
systems. They get passed a pmfx::ComputePass
which has information about the compute workload that has been specified in pmfx files. The dispatch_compute
function in the ecs_examples
could be used for many purposes without needing to supply any new code because it allows generic dispatched based data that is configured in the pmfx file.
#[no_mangle]
#[export_compute_fn]
pub fn dispatch_compute(
pmfx: &Res<PmfxRes>,
pass: &pmfx::ComputePass<gfx_platform::Device>
) -> Result<(), hotline_rs::Error> {
let pipeline = pmfx.get_compute_pipeline(&pass.pass_pipline)?;
pass.cmd_buf.set_compute_pipeline(&pipeline);
let using_slot = pipeline.get_pipeline_slot(0, 0, gfx::DescriptorType::PushConstants);
if let Some(slot) = using_slot {
for i in 0..pass.use_indices.len() {
let num_constants = gfx::num_32bit_constants(&pass.use_indices[i]);
pass.cmd_buf.push_compute_constants(
0,
num_constants,
i as u32 * num_constants,
gfx::as_u8_slice(&pass.use_indices[i])
);
}
}
pass.cmd_buf.set_heap(pipeline, &pmfx.shader_heap);
pass.cmd_buf.dispatch(
pass.group_count,
pass.numthreads
);
Ok(())
}
System Execute Order
By default all systems in a particular group will be executed asyncronsly and the groups will be executed in-order, there are some predefined, core system sets:
SystemSets::Update
- Use this to animate and move entities, perform logic adn so forth.SystemSets::Batch
- Use this to batch data such as baking world matrices, culling or update buffers ready for rendering.SystemSets::Render
- Used to render entities and make draw calls.
Any render functions are automatically added to the Render
system set, but you can choose to create your own sets or add things into the pre-defined SystemSets
. There are some core oprations which will happen but you can define your own and order execution as follows:
// custom base set
#[no_mangle]
#[export_update_fn(in_base_set(MyCustsomSet::CustomUpdate))]
fn update_cameras() {
// ..
}
#[no_mangle]
#[export_update_fn(after(MyCustsomSet::CustomUpdate))]
fn update_batch() {
// ..
}
// in base set after render
#[no_mangle]
#[export_update_fn(in_base_set(MyCustsomSet::PostStep).before(SystemSets::Render))]
fn finalise() {
// ..
}
Serialising Plugin Data
You can supply your own serialisable plugin data that will be serialised with the rest of the user_config
and can be grouped with your plugin and reloaded between sessions.
/// Seriablisable user info for maintaining state between reloads and sessions
#[derive(Serialize, Deserialize, Default, Resource, Clone)]
pub struct SessionInfo {
pub active_demo: String,
pub main_camera: Option<CameraInfo>
}
// the client provides functions which can serialise and deserialise this data for you
fn update_user_config(&mut self) {
// find plugin data for the "ecs" plugin
self.session_info = client.deserialise_plugin_data("ecs");
//.. make updates to your data here
// write back session info which will be serialised to disk and reloaded between sessions
client.serialise_plugin_data("ecs", &self.session_info);
}
Using as a library
You can use hotline as a library inside the plugin system or on its own to use the low level abstractions and modules to create windowed applications with a graphics api backend. You can easily add to your projects using cargo and install the package from crates.io.
Basic Application
A quick example of a basic application setup:
// include prelude for convenience
use hotline_rs::prelude::*;
pub fn main() -> Result<(), hotline_rs::Error> {
// Create an Application
let mut app = os_platform::App::create(os::AppInfo {
name: String::from("triangle"),
window: false,
num_buffers: 0,
dpi_aware: true,
});
// Double buffered
let num_buffers = 2;
// Create an a GPU Device
let mut device = gfx_platform::Device::create(&gfx::DeviceInfo {
render_target_heap_size: num_buffers,
..Default::default()
});
// Create main window
let mut window = app.create_window(os::WindowInfo {
title: String::from("triangle!"),
..Default::default()
});
/// Create swap chain
let swap_chain_info = gfx::SwapChainInfo {
num_buffers: num_buffers as u32,
format: gfx::Format::RGBA8n,
..Default::default()
};
let mut swap_chain = device.create_swap_chain::<os_platform::App>(&swap_chain_info, &window)?;
/// Create a command buffer
let mut cmd = device.create_cmd_buf(num_buffers);
// Run main loop
while app.run() {
// update window and swap chain
window.update(&mut app);
swap_chain.update::<os_platform::App>(&mut device, &window, &mut cmd);
// build command buffer and make draw calls
cmd.reset(&swap_chain);
// Render command can go here
// ..
cmd.close()?;
// execute command buffer
device.execute(&cmd);
// swap for the next frame
swap_chain.swap(&device);
}
// must wait for the final frame to be completed so it is safe to drop GPU resources.
swap_chain.wait_for_last_frame();
Ok(());
}
os
The os API provides an abstraction of operating system related functionality. You can use this to create windows, file dialogs and obtain user input.
// create window
let mut app = os_platform::App::create(os::AppInfo {
name: String::from("triangle"),
window: false,
num_buffers: 0,
dpi_aware: true,
});
// get mouse input
let drag = app.get_mouse_pos_delta();
let wheel = app.get_mouse_wheel();
let buttons = app.get_mouse_buttons();
// keyboard input
let keys = app.get_keys_down();
if keys['A' as usize] {
// held 'A'
}
let keys = client.app.get_keys_pressed();
let toggle = if keys['B'] {
// pressed 'B'
}
// open file dialog for 'txt' files
if let Ok(files) = os_platform::App::open_file_dialog(os::OpenFileDialogFlags::FILES, vec![".txt"]) {
if !files.is_empty() {
player.set_source(files[0].to_string())?;
}
}
gfx
The gfx module provides a modern graphics API loosely following Direct3D12 with Vulkan and Metal compatibility in mind. If you are familiar with those API's it should be straight forward, but here is a quick example of how to do some render commands:
// create a buffer
let info = gfx::BufferInfo {
usage: gfx::BufferUsage::Vertex,
cpu_access: gfx::CpuAccessFlags::NONE,
format: gfx::Format::Unknown,
stride: std::mem::size_of::<Vertex>(),
num_elements: 3,
};
let vertex_buffer = device.create_buffer(&info, Some(gfx::as_u8_slice(&vertices)))?;
// create shaders and a pipeline
let vsc_filepath = hotline_rs::get_data_path("shaders/triangle/vs_main.vsc");
let psc_filepath = hotline_rs::get_data_path("shaders/triangle/ps_main.psc");
let vsc_data = fs::read(vsc_filepath)?;
let psc_data = fs::read(psc_filepath)?;
let vsc_info = gfx::ShaderInfo {
shader_type: gfx::ShaderType::Vertex,
compile_info: None
};
let vs = device.create_shader(&vsc_info, &vsc_data)?;
let psc_info = gfx::ShaderInfo {
shader_type: gfx::ShaderType::Vertex,
compile_info: None
};
let fs = device.create_shader(&psc_info, &psc_data)?;
// create the pipeline itself with the vs and fs
let pso = device.create_render_pipeline(&gfx::RenderPipelineInfo {
vs: Some(&vs),
fs: Some(&fs),
input_layout: vec![
gfx::InputElementInfo {
semantic: String::from("POSITION"),
index: 0,
format: gfx::Format::RGB32f,
input_slot: 0,
aligned_byte_offset: 0,
input_slot_class: gfx::InputSlotClass::PerVertex,
step_rate: 0,
},
gfx::InputElementInfo {
semantic: String::from("COLOR"),
index: 0,
format: gfx::Format::RGBA32f,
input_slot: 0,
aligned_byte_offset: 12,
input_slot_class: gfx::InputSlotClass::PerVertex,
step_rate: 0,
},
],
pipeline_layout: gfx::PipelineLayout::default(),
raster_info: gfx::RasterInfo::default(),
depth_stencil_info: gfx::DepthStencilInfo::default(),
blend_info: gfx::BlendInfo {
alpha_to_coverage_enabled: false,
independent_blend_enabled: false,
render_target: vec![gfx::RenderTargetBlendInfo::default()],
},
topology: gfx::Topology::TriangleList,
patch_index: 0,
pass: swap_chain.get_backbuffer_pass(),
})?;
// build command buffer and make draw calls
cmd.reset(&swap_chain);
// manual transition handling
cmd.transition_barrier(&gfx::TransitionBarrier {
texture: Some(swap_chain.get_backbuffer_texture()),
buffer: None,
state_before: gfx::ResourceState::Present,
state_after: gfx::ResourceState::RenderTarget,
});
// render pass approach is used, swap chain automatically creates some for us
cmd.begin_render_pass(swap_chain.get_backbuffer_pass_mut());
cmd.set_viewport(&viewport);
cmd.set_scissor_rect(&scissor);
// set state for the draw
cmd.set_render_pipeline(&pso);
cmd.set_vertex_buffer(&vertex_buffer, 0);
cmd.draw_instanced(3, 1, 0, 0);
cmd.end_render_pass();
// manually transition
cmd.transition_barrier(&gfx::TransitionBarrier {
texture: Some(swap_chain.get_backbuffer_texture()),
buffer: None,
state_before: gfx::ResourceState::RenderTarget,
state_after: gfx::ResourceState::Present,
});
// execute command buffer
cmd.close()?;
device.execute(&cmd);
// swap for the next frame
swap_chain.swap(&device);
pmfx
The pmfx module builds on top of the gfx
module to make render configuration more ergonomic, data driven and quicker to develop with. You can use the API and pmfx
data to configure render pipelines in a data driven way. The pmfx-shader repository has more detailed information and is currently undergoing changes and improvements but it now supports a decent range of features.
You can supply jsn config files to specify render pipelines, textures (render targets), views (render pass with cameras) and render graphs. Useful defaults are supplied for all fields and combined with jsn inheritance it can aid creating many different render strategies with minimal repetition.
textures: {
main_colour: {
ratio: {
window: "main_window",
scale: 1.0
}
format: RGBA8n
usage: [ShaderResource, RenderTarget]
samples: 8
}
main_depth(main_colour): {
format: D24nS8u
usage: [ShaderResource, DepthStencil]
samples: 8
}
}
views: {
main_view: {
render_target: [
"main_colour"
]
clear_colour: [0.45, 0.55, 0.60, 1.0]
depth_stencil: [
"main_depth"
]
clear_depth: 1.0
viewport: [0.0, 0.0, 1.0, 1.0, 0.0, 1.0]
camera: "main_camera"
}
main_view_no_clear(main_view): {
clear_colour: null
clear_depth: null
}
}
pipelines: {
mesh_debug: {
vs: vs_mesh
ps: ps_checkerboard
push_constants: [
"view_push_constants"
"draw_push_constants"
]
depth_stencil_state: depth_test_less
raster_state: cull_back
topology: TriangleList
}
}
render_graphs: {
mesh_debug: {
grid: {
view: "main_view"
pipelines: ["imdraw_3d"]
function: "render_grid"
}
meshes: {
view: "main_view_no_clear"
pipelines: ["mesh_debug"]
function: "render_meshes"
depends_on: ["grid"]
}
wireframe: {
view: "main_view_no_clear"
pipelines: ["wireframe_overlay"]
function: "render_meshes"
depends_on: ["meshes", "grid"]
}
}
}
When pmfx is built, shader source is generated along with an info file that contains useful reflection information to be used at runtime. Based on shader inputs and usage, descriptor layouts and vertex layouts are automatically generated.
av
The av API can be used to decode and playback audio and video streams with hardware accelleration. Video frames are decoded into native GPU texture formats with no CPU copy overhead.
// create a player
let mut player = av_platform::VideoPlayer::create(&dev).unwrap();
// load a video and play
player.set_source("video.mp4")?;
player.play()?;
// ..
let mut player_open = true;
while app.run() {
// poll update
player.update(&mut dev)?;
if player.is_ended() {
// .. handle case where video is ended
}
// get texture
if let Some(video_tex) = &player.get_texture() {
let size = player.get_size();
// .. render
}
}
imgui
Dear ImGui support with docking and viewports (multiple windows) through the imgui module. The widget API is not fully complete in safe Rust. I have been adding these on a case by case basis as I need them.
// create imgui instance
let font_path = asset_path
.join("data/fonts/roboto_medium.ttf")
.to_str()
.unwrap()
.to_string();
let mut imgui_info = imgui::ImGuiInfo {
device: &mut dev,
swap_chain: &mut swap_chain,
main_window: &win,
fonts: vec![imgui::FontInfo {
filepath: font_path,
glyph_ranges: None
}],
};
let mut imgui = imgui::ImGui::create(&mut imgui_info).unwrap();
while app.run() {
// ..
// imgui
imgui.new_frame(&mut app, &mut win, &mut dev);
// button with size
imgui.button_size(font_awesome::strs::EYE, 32.0, 0.0);
// same line checkbox
imgui.same_line();
let mut dd = self.session_info.debug_draw_flags.contains(DebugDrawFlags::GRID);
if imgui.checkbox("Grid", &mut dd) {
if dd {
self.session_info.debug_draw_flags |= DebugDrawFlags::GRID;
}
else {
self.session_info.debug_draw_flags &= !DebugDrawFlags::GRID;
}
}
// combo
let (_, selected) = imgui.combo_list("Camera", &camera_types, &selected);
imgui.render(&mut app, &mut win, &mut dev, &mut cmdbuffer, &Vec::new());
}
Examples
There are a few standalone examples of how to use the lower level components of hotline (gfx, app, av
). You can build and run these as follows:
// build examples
cargo build --examples
// make sure to build data
hotline-data\pmbuild win32-data
// run a single sample
cargo run --example triangle
Triangle
This is a standalone example, outside of the ecs
system. It directly uses the gfx
API to set up and render a triangle. This gives a good overview of the low-level graphics setup and can serve as a first port of call for any porting work to other platforms.
Bindless
The second standalone example is used to test render targets, compute shaders, image loading, and bindless texture sampling. A few render passes are configured and compute shader writes to a read-write texture before everything is composited to screen in 4 quadrants.
Bindful
This example shows how a bindful rendering model is possible in place of or side by side with a bindless rendering model
ImGui Demo
Test for implementing and verifying the imgui backend - this demonstrates the entire feature set of imgui with docking, viewports and mouse cursors.
Play Video
A standalone example of video playback, it allows you to load a video file from disk so it can be used to test compatibility of different video formats. The current implementation uses windows media foundation and Direct3D12 device to perform video decoding. The av
API provides access to decoded video frames as a native gfx::Texture
and performs all decoding on the GPU.
Resource Tests
This provides a testbed for loading different resource types, it loads some compressed texture formats and displays them to the screen to verify the texture build pipeline and the texture loading pipeline. It also tests the copy_texture_region
API by copying a texture file specified in a pmfx
into an empty texture also declared inside the pmfx
config. This sample can be used in future as more resources are added.
Ecs Examples
More advanced examples are implemented using the ecs
and plugin
system. You can build and run them as follows:
hotline-data\pmbuild win32-debug -all -run
The source code can be located in this directory and the shaders / pmfx configs are located in the hotline-data repository.
Draw
The first and most basic ecs
driven example draws a triangle mesh entity using cmd_buf.draw_instanced
call. This is a non-indexed draw call and just there to serve as a test for the draw_instanced
function. Push constants are used to push the camera matrices but the mesh itself is drawn from raw vertex data.
Draw Indexed
Similar to the draw_instanced
call but this time we draw a cube mesh with draw_indexed_instanced
with an index buffer. All of the meshes created as part of the [primitives] API come with index buffers and once I get round to implementing a model loader, they will also have index buffers so draw_indexed_instanced
will likely be used more than draw_instanced
, but it's necessary to support and test both of them.
Draw Push Constants
Building on from the draw indexed example, this one adds extra per-entity draw information of a world matrix to position them.
Draw Indirect
This is a very simple and not very useful example of execute_indirect
; it creates 2 indirect CommandSignatures
(one for Draw
and one for DrawIndexed
) and then the IndirectArguments
are populated on the CPU. The entities are drawn by calling cmd_buf.execute_indirect
. Later this functionality becomes much more powerful as command buffers can be populated on the GPU, but this is here as a very basic unit-test just to make sure everything is hooked up and indirect draws can be made.
Geometry Primitives
A sample showcasing all of the available procedurally generated geometry primitives.
Draw Vertex Buffer Instanced
This example uses 2 vertex streams, one of which contains vertex data and the second containing a per entity world matrix. The entity world matrices are updated batched together into a single vertex buffer each frame on the CPU. Instance striding is driven by the vertex layout.
Draw cbuffer Instanced
This example provides instanced draws by updating entity world matrices on the CPU and batching them into a larger constant buffer. The constant buffer is bound onto the pipeline slot and the vertex shader semantic SV_InstanceID
is used to index into the constant buffer to obtain a per instance world matrix.
Bindless Texture
Bindless texturing example - uses push constants to push a per draw call texture id for each entity. The texture id (shader resource view index) is used to lookup the texture inside an unbounded descriptor array in the fragment shader.
Tangent Space Normal Maps
A test bed to verify the correctness of the shaders, geometry, and normal map textures performing tangent space normal map transformations.
Bindless Material
Bindless material setup example. Instance batches are created for the draw calls, the instance buffer consists of a uint4
which has packed draw id and material id for each entity. The world matrix for each entity is looked up inside an unbounded descriptor array and so is the material. The material data consists of texture ids which are passed to the fragment shader. In the fragment shader we use the texture ids to look up albedo, normal, and roughness textures again stored in unbounded descriptor arrays.
Point Lights
A demo and visualisation created by using point lights, spheres, and a plane. The shader applies cook-torrance specular with lambertian diffuse. It also demonstrates how light entities can be added and manipulated and how data is passed to the GPU in the form of light data, with the lookups into the light array being driven by bindless ID lookups.
Spot Lights
Similar to the point lights demo, this showcases spot lights, which are processed in a separate loop to point lights and have their data stored in a separate structured buffer.
Directional Lights
Another light type example, directional lights are processed and stored in a separate structured buffer to the point and spot lights.
GPU Frustum Culling
This sample utilises execute_indirect
and a compute shader to perform AABB vs frustum culling on the GPU. A structured buffer is populated with all draw call information for the scene and a secondary structured buffer with unordered access is created with a counter and used on the GPU as an AppendStructuredBuffer
. Entity extents are looked up in the compute shader and culling is performed by testing the entity AABB extents against the camera frustum planes. Entities that are inside or intersecting the frustum have their draw call data copied from the full structured buffer and appended into the draw indirect structured buffer. The draw indirect structured buffer is used to drive the execute_indirect
call. The sample draws 64k entities all with unique vertex and index buffers, running at 16ms on the CPU, where making the equivalent number of draw calls via draw_indexed
takes well over 80ms.
Cubemap
A test to verify the correctness of the texture pipeline and cubemap loading. texturec
is used to take 6 input face images stored in a folder and pack them into a .dds
image with convolved mip-map levels. The mip-map levels are looked up individually by the different sphere draw calls to verify the mips and faces have loaded correctly and serves as a starting point of how to use a cubemap convolution for image based lighting.
Texture2DArray
A test to verify the correctness of the data pipeline for loading 2D texture arrays. A simple animation is applied to the texture array to roll through the various array slices and the camera distance will select mip-map levels automatically based on hardware mip-map selection.
Texture3D
The sample loads and renders a 3D texture which contains signed distance field data. The image itself was generated in my C++ engine [pmtech] and this sample serves as a test to ensure 3D texture loading works correctly. It also provides a simple demonstration of how to visualise / ray march a signed distance field volume.
Read Write Texture
An example to showcase how to configure compute passes through the pmfx
and ecs
systems. We setup a basic compute pass which writes some noise into a 3D read-write texture and then use a basic 3D ray march to trace the volume in a rasterization pass.
Multiple Render Targets
This sample demonstrates and tests multiple render target outputs. It renders to multiple textures as you would for a g-buffer deferred setup. The targets are MSAA enabled, and then in a compute shader we sample directly from the MSAA resources, outputting one of the MSAA fragments and splitting the screen into 4 quadrants to show the different outputs from the MRT setup.
Raster States
A basic test which verifies the correctness of rasterizer state data being supplied in .pmfx
config files. A few primitives are rendered with front-face, back-face and no culling, and another draw call with wireframe fill mode.
Blend States
This basic test shows a variety of different blend modes. It covers the common cases: no blending, alpha blending, and additive blending, as well as some more esoteric ones such as reverse subtract and min / max blend ops.
Generate Mip Maps
This demonstrates the functionality in the low level gfx API to generate a mip chain at run time for a render target. The pmfx system is utilised to specify ReadMips
in the uses:
section of a render graph pass. After the render target is rendered into but before it is read in the shader the target is resolved and the mip chain is generated.
Shadow Map
A sample to demonstrate how to configure a single shadow map with a vertex shader only render pipeline that only outputs depth to the depth stencil unit. A SamplerComparsionState
is used in the lighting shader to read shadow samples and a 9-tap PCF filter is also applied.
Dynamic Cubemap
Demonstrates how multiple cubemap faces can be rendered using cubemap cameras and then sampled from to create dynamic environment mapped reflections. A single pmfx
view is created with the cubemap
flag set to true, this will automatically create 6 child views for each face of the cubemap.
Tests
There are standalone tests and client/plugin tests to test graphics API features. This requires a test runner which has a GPU and is not headless, so I am using my home machine as a self-hosted actions runner. You can run the tests yourself but because of the requirement of a GPU device and plugin loading the tests need to be ran single threaded.
cargo test -- --test-threads=1
This is wrapped into pmbuild
so you can also run:
pmbuild test
Contributing
Contributions of all kinds are welcome, you can make a fork and send a PR if you want to submit small fixes or improvements. Anyone interested in being more involved in development I am happy to take on people to help with the project of all experience levels, especially people with more experience in Rust. You can contact me if interested via Twitter or Discord.