Introduction
Here lives the guide for the Blue Engine, including examples, tips, tricks, philosophy, and more. This guide will go in depth through the engine with examples and opinions on the engine mechanics. This guide is updated to version 0.5.1
of the engine.
The early idea for this project was to use this engine as the first layer for all future projects of myself, but later was decided that it should be made for general use. Now the engine can be used for games, GUIs, or even as a render backend.
Although the engine is quite young in terms of development, the main focus is small footprint, stability, flexibility, ease of use, and portability. The engine should not force your hand into a certain way, rather be usable for both new and veteren devs.
We do have a small community as of now, mainly on discord. So join us if you haven't yet!.
The engine is licensed under Apache-2.0 license and includes all the files in this repository, unless stated otherwise.
Edit this guide on the Blue Engine Doc repository.
Setup
To start, we need to setup the framework first. This guide expects you to know basics of Rust. If not, start here
Fortunately, the installation is very simple; thanks to rust's ecosystem. Because we're using Rust, make sure you have it installed and working properly along cargo
.
Examples
If you'd like to try testing the engine before starting to work with it, you'd need to download/clone the repository.
git clone https://github.com/AryanpurTech/BlueEngine
cd BlueEngine
After that, you can try running examples that are currently provided. For example, let's try running Triangle example that shows a white-ish triangle on your screen:
cargo run --example triangle
Cargo will download dependencies, setup the engine, and run the example. After it's all done, a window will appear with the triangle in middle. If you can't see a triangle, make sure that your drivers are up-to-date. If any errors were shown on console and you think might be source of the problem, feel free to open a new issue and I'll look into it.
The examples folder provides valuable source of examples on how the engine can be used. More examples will be added as the time goes. Although the API is unstable now, the examples keep up with the latest API changes.
New Blue Engine project
To start a new Blue Engine project, open your command line in the desired folder, and create a new project with cargo:
cargo init my_awesome_be_project
Make sure to replace my_awesome_be_project
with your desired name, or, just leave it with that, we know it'll be awesome either way :D
After that, add this as dependency in your Cargo.toml
in the project folder:
# The star tells the package manager to download the latest version
blue_engine = "*"
If you want a specific version, you can specify it too! E.g. the current published version as of this writing:
blue_engine = "0.4.13"
If you'd like to enable an optional feature, you can specify it in the features
list:
# For example enable gui support
blue_engine = { version = "*", features = ["model_loading"] }
That's it! Just open the main.rs
file in the src
folder and make the world a better place!
Turbo build times
This will allow fast build times on debug. This works by building a shared library/dynamic library and linking it with executable. Rust's compiler spends quite some time to pack and link everything into an exectuable on each build, so this will remove the need to repack and link the engine, thus improving the build times significantly. There are a few steps to follow, but they're not hard.
DO NOT USE FOR FINAL RELEASE! OR ELSE WILL NEED TO COPY EXTRA FILES ALONG YOUR EXECUTABLE.
Clone the engine
We first start by cloning the engine from github. Move to parent directory of your project, and clone the engine:
git clone https://github.com/AryanpurTech/BlueEngine
Your project structure should look something like this:
Projects:
- blue_engine
- my_app
With my_app
being your project. This way it ensures you can use it for multiple projects at once too as it's a one-time setup.
Setup the engine
Open the engine's folder, and add this to the Cargo.toml
under [lib]
.
crate-type = ["dylib"]
This essentially tells the compiler to compile the engine as a shared library/dynamic library, after finishing the setup, in the build folder you should be able to see a file with similar name to libblue_engine
.
Setup your project
And on your project's Cargo.toml
under [dependencies]
, add path = "../blue_engine"
to the blue_engine
as such:
blue_engine = { path = "../blue_engine", .. } # replace the .. with the rest. e.g. version, features, e.t.c.
Windows
For windows, you need to go one more step. This essentially doesn't work normally, hence you'll need to add some more configuration and switch to nightly.
Switch to nightly:
rustup override set nightly
This sets current project to nightly only. And next step is in your project's directory, add a folder with the name .cargo
, and add a file in it with the name config.toml
and copy these into it:
[target.x86_64-pc-windows-msvc]
linker = "rust-lld.exe"
rustflags = ["-Zshare-generics=off"]
Enjoy fast builds
And that's it! You should now see faster build times. For final release versions, make sure to just remove path = "../blue_engine"
from dependency, and you should be good to go for release. You can also configure it in Cargo.toml
to use the path for debug profile and non turbo build for release profile as well.
Philosophy, structure, and technologies
Philosophy
The philosophy is always to bring as much ease of use and flexibility as possible. Anything done with the engine, the changes made, the features added, e.t.c. are all made to be as easy-to-use and as flexible as possible for everyone to have a peace of mind when using. Doing graphics shouldn't be hard even at such low level right?
For most people, use cases differ. And because of that, the level of abstraction should differ too; the engine must be flexible enough to accommodate that. This is how Blue Engine can be as high or low level when using as you need. Just need objects to be displayed? Sure thing, they're just one line away! Want to have custom built structure for your own shape and bring your custom engines upon it with custom shaders, textures, and more? We got you covered too! There's something for everyone!
Structure
To achieve this goal, the Blue Engine source code is structured in a flexible way. All definitions, such as structs
, types
, and enums
, are defined in a single header file, header.rs
. This file also specifies whether each part will be public for external use or not, as well as defining global default settings, such as default textures and shaders.
The methods' code is organized under their respective files, with the target classes taken from the definitions file. For example, methods related to rendering live under a render.rs
file, while camera functionality lives in a separate camera.rs
file. This structure allows for clean progress and minimizes interference between different parts of the engine.
Technologies used
The Blue Engine uses a combination of standard Rust library functions and external crates to achieve its goals. While we prioritize using the standard library as much as possible, there are some tasks that require more advanced functionality or are not feasible within its bounds. For these cases, we turn to external crates.
The current list of dependencies for Blue Engine can be found in the cargo.toml
file located in the root directory.
Dependency justification as of 0.5.1
-
futures = "0.3"
: This dependency is used to enable blocking within async functions that are utilized in lower-level APIs. -
winit = { version = "0.29.8", features = ["rwh_05"] }
: This crate handles the creation and control of windows and contexts, ensuring a consistent interface across various platforms. -
image = "0.24"
: The image crate is used for reading and handling textures within the engine. -
bytemuck = { version = "1.14", features = ["derive"] }
: This dependency provides utility functions for casting between plain data types, which is necessary for sending data to the GPU. -
winit_input_helper = "0.15"
: This crate simplifies the process of gathering input event data and provides an easy-to-use list of functions for accessing them. -
anyhow = "1.0"
: This dependency is used for error handling, providing a clear and concise way to handle errors within the engine. -
downcast = "0.11
: This dependency helps with downcasting trait objects of plugins into usable components. -
wgpu = { version = "0.18" }
: The wgpu crate acts as a wrapper over the graphics API and follows the WebGPU standard. It provides an intuitive and performant way to render graphics across multiple platforms. -
nalgebra-glm = "0.18"
: This math library is used extensively throughout the engine for handling various mathematical operations.
Optional dependencies
-
android_logger = { version = "0.13", optional = true }
: This dependency is used for logging on Android. -
log = { version = "0.4", optional = true }
: same use asenv_logger
-
env_logger = { version = "0.10", optional = true }
: This logging library is used internally for event logging, as well as for providing useful debugging information.
Hello World
Setting up
Firstly let's create a new rust project:
cargo init --bin blue_graphics
After that's taken care of, open cargo.toml
and add the crate under dependencies:
[dependencies]
blue_engine = "0.5.1"
This should now install Blue Engine and all it's dependencies. We should be ready to get started.
Engine initialization
Alright, now that we're done with that, let's get the exciting work started! Firstly, we will create a new window so that later we can draw under it!
Firstly, open the main.rs
file under src
folder, and paste below in it:
use blue_engine::header::{Engine, WindowDescriptor}; // 0 fn main(){ let mut engine = Engine::new(WindowDescriptor::default()) // 1 .expect("Couldn't init the Engine"); // 2 engine .update_loop(move |_, _, _, _, _, _| {}) // 3, 4 .expect("Error during update loop"); // 5 }
- We need some structs to initialize the engine.
- We initialize the Engine and start to describe how the window should look like. For now, we'll use the default values.
- The initialization returns a
Result<Engine>
which you can unwrap. This way you can also check if there is any error during the creation of the Engine. - The update loop is responsible for everything that you will do that needs to be updated or checked every frame. This includes checking for events such as inputs, or updating certain things if a change happens.
- The _ are to not use the parameters passed on. The update loop gives you access to 5 things in this order:
- renderer
- window
- objects
- events
- camera
- plugins
- Update loop also returns a
Result<()>
. The errors that will happen through this means it happened during the update loop. So make sure to check for errors during the update loop as well, or not if you're a brave soul :)
After you're done, you can run:
cargo run
You should now see an empty window! Congratulations you just created your first window using Blue Engine!
Engine introduction
Engine
Engine
is the starting point for using the BE (Blue Engine). It acts like a tree, with each branch being a functionality. This is an opinionated approach and not often liked by all of Rust's community as it brings up some issues for the borrow checker. However it appears to be working for our case, so unless highly requested or a major issue appears it will remain in such way. In future the monolithic structure will be changed with modular approach, but as of current versions, it will remain as such.
The Engine
is a struct
type, and contains:
Renderer
Window
Objects
Camera
event_loop
[hidden to create only]
We will discuss each of them in great detail later on. For now, let's look at an example on how to start using the Engine
.
// import the Engine and WindowDescriptor from the header. use blue_engine::header::{Engine, WindowDescriptor}; fn main() { // you can create the engine through the Engine::new() // it returns a Result<Engine> let engine = Engine::new(WindowDescriptor::default()).expect("Couldn't create the engine"); }
WindowDescriptor
is used for window settings as the Engine
initializes it too. The WindowDescriptor
has these default values:
Parameter | value |
---|---|
width | 800 |
height | 600 |
title | Blue Engine |
decorations | true |
resizable | true |
You can also alter only few and leave the rest as default.
After you initialized the engine, you can start the update loop. The update loop runs code per frame, and also provides events and updates.
note that the events are updated BEFORE each frame! So any changes you make in this frame, will be updated on the next frame.
Update loop
The update loop is method of Engine
that has one parameter which is a mutable callback function, that provides these:
&mut Renderer
&mut Window
&mut Vec<Object>
&Input
&mut Camera
&mut HashMap<&'static str, Box<Any>>
The fields that are not mutable are only there to provide information. The mutable fields are the ones where your changes will exist, such as showing things on screen, moving camera, e.t.c.
Once you run the update_loop, the window will start to appear. You can also leave the loop empty for an empty screen! The loop also allows to use the move
keyword before for passing variables from outside the scope to the inside of the loop.
Creating an update loop that's empty is as easy as:
#![allow(unused)] fn main() { // the underscores shows that we do not want to use them now. // The `move` keyword makes it possible to use variables from outside of the loop scope. engine.update_loop(move |_, _, _, _, _, _| {}) .expect("Error during update loop"); }
This function returns a Result<()>
.
Renderer
Let's talk about the renderer. Renderer
handles everything that is about talking with the GPU to render things you specify. This includes
- Shaders
- Textures
- Vertices
- Uniform Buffers
We'll discuss more about each of them later on, for now let's talk about how it looks like and how it works.
There are default data of them except for vertices. The exist on the 0th index. Which includes a default uniform buffer (transformation, camera and color), a default texture (one white pixel), and a default shader. We'll talk about them later on.
Pipeline
The Pipeline is the way that BE handles data to renderer. BE stores data for rendering on a dynamically sized array and gives you their index on time of creation. A Pipeline
struct holds one index for each of those four things listed above. At time of render, the values are then fetched from the array and sent to GPU. We will learn about the order and position of each of them when rendering, later. For now, think of pipeline as the data holder of your object.
Shader
Shaders are programs that run in the GPU. BE uses WGSL which is the main shading language of WebGPU.
As of yet, only vertex and fragment stages are supported. You can also change many other aspects such as cull face, render mode, and more at the time of shader creation!
You can change those settings through the ShaderSetting
, and of course default settings also exist. They are:
Parameter | Value |
---|---|
topology | TriangleList |
strip_index_format | None |
front_face | Ccw |
cull_mode | Back |
polygon_mode | Fill |
clamp_depth | false |
conservative | false |
count | 1 |
mask | !0 |
alpha_to_coverage_enabled | false |
To create a new shader through BE and append it to the storage for render, you can use the build_and_append_shader()
.
The shader source as of yet (0.2.5) only supports WGSL, but later on the support for SPIR-V compiled shaders will also be available.
Textures
Textures are images that can be burned to vertices on the scene. They have all sorts of uses, e.g. adding textures to plane to make it look like a wall or ground, or textures to character shapes for more details.
As of now (0.2.5), BE only supports RGBA based textures. PNG is the recommended file format and the format is Rgba8UnormSrgb.
There's two ways to create BE textures
- Creating an
image::DynamicImage
and appending that - Using bytes of the image and creating one.
image
is a rust crate that BE uses under the hood for processing texture data.
BE also allows for different modes of the texture in case the texture couldn't fit, which are:
- Clamp
- Repeat
- Mirror repeat
The textures are sampled as well, and they can be accessed on group 0 on slot:
- Texture
- Sampler
Vertices
A Vertex
in BE is a point in 3D space that also includes the positions of texture associated with it.
Vertices in BE exist with their indices to create shapes and reuse vertex data. And by default they're rendered in counter clockwise manner and in triangle form.
There are no settings for vertices. You can access vertex position [vector3] at location 0, and the associated texture coordination [vector2] at location 1 in the vertex shader. And you can output the texture coordinates to fragment shader at location 0.
Uniform Buffers
Uniform buffers are small bits of custom data sent to the GPU. BE supports 3 default types of Uniform Buffers:
- Float32
- Vector4 of Float32
- Matrix4x4 of Float32
You can also define a custom Uniform Buffer structure, just make sure to implement Pod
and Zeroable
traits, along C
layout.
Many uniform buffers can be sent at one time, and the order that you send them matters. You can access them on group 2 and the slot is in the order you specify.
By default the starting would be conditional. If you disable camera, it'll start at 1, elsewise will start at 2. as the 0th slot is for transformation matrix.
Windowing, input
Windowing in Blue Engine is handled by winit, and the events are handled by winit input helper. In the future SDL2 is an alternative planned implementation, but as of now winit handles all the windowing needs.
Window settings
The window has a bunch of settings that can be changed and manipulated. These window settings are applied at the time of engine initialization. The settings exist as WindowDescriptor
and the options and their default data are as such:
Parameter | Value |
---|---|
width | 800 |
height | 600 |
title | "Blue Engine" |
decorations | true |
resizable | true |
-
Decorations are the title bar and the borders that windows have. Setting it to false will remove them, which is also called borderless windowing.
-
Resizable is a setting that allows the windows to be resized or stay in a fixed size. Having this set to false will also disable maximize and minimize options.
What are objects?
An object in Blue Engine is a collection of a vertex buffer, shader, texture, and maybe a uniform buffer, and allows various operations on them such as scaling, translation, change in data, and more. Objects are similar to nodes in Godot. Other traits can be applied to them to add extra functionalities that are desired. Objects are the only things are are sent for render and they are the very basic element of Blue Engine. Unlike the many structures that exist so far, objects do not have a parent or a child, and does not work in a tree structure. Instead the objects are stored in a dynamically sized array which then are iterated upon when rendering.
Initialization
A new object can be created by using the engine's new_object
method. Three arguments are required:
- Vertices: a
Vec<Vertex>
which includes the vertices for your object. - Indices: a
Vec<u16>
which includes the indices of your vertices. ObjectSettings
which is a struct defining the structure and settings for the object. These can later on be changed as well. The list of options along their default values are as such:
Parameter | Value |
---|---|
name | Some("Object!") |
size | (100f32, 100f32, 100f32) |
scale | (1f32, 1f32, 1f32) |
position | (0f32, 0f32, 0f32) |
color: | uniform_type::Array [DEFAULT_COLOR] |
camera_effect | true |
shader_settings | ShaderSettings::default() |
- color's uniform type of array is due to the color being passed down to the uniform buffer and applied at fragment stage.
- camera effect defines if the camera transformations have any effect on the object. An object with camera effect as false will not experience any move from camera unless manually moved, and also will not be affected by POV change or anything else related to cameras.
- shader settings are a collection of settings related to the shaders, their options and default values are defined in the renderer page of this guide.
Upon creation, an &mut Object
will be returned wrapped in Result
. Which then you can further change and update.
Camera
Camera in Blue Engine is default initialized upon creation. This may change in the future, but as of yet a default camera is created. This camera is left-hand perspective camera, and some default values are assigned according to the window which are as such:
Parameter | Value |
---|---|
position | (0f32, 0f32, 1f32) |
target | (0f32, 0f32, 0f32) |
up | (0f32, 1f32, 0f32) |
aspect | window_width / window_height |
fov | 70f32 * (PI / 180f32) |
near | 0.1f32 |
far | 100f32 |
view_data | DEFAULT_MATRIX_4 |
- position, target, and up defines how the camera views the scene, changing these parameters allow you to alter the view.
- fov is in radians instead of degrees.
- near and far define how close and how far can the camera sees, changing these values will alter how much further or near are things in your scene visible.
- view data is a matrix containing the matrix that will be applied to the scene during render, you usually don't have to deal with this directly.
Along with these options, you can alter the parameters through methods that are assigned. For example set_position
to change the position of the camera in the scene. Each of these methods start with set_
and then the option name, e.g. set_fov
or set_far
.
More options and features will be added as time goes.
Example projects
Let's make an example project, we'll start from the basics and go deep as time goes.
In the end you'll learn how to:
-
create window and modify it
-
add objects to the scene
-
modify objects and give them movement
-
add many objects and move all of them
-
add gui (use optional featues in the engine)
-
interop gui with the scene
Window creation and Customization
Window creation
For setup, check setup page from first chapter. To create a window in Blue Engine, first initialize the engine:
#![allow(unused)] fn main() { let mut engine = Engine::new(WindowDescriptor::default()).expect("Couldn't init the Engine"); }
This will initalize the engine, and making components ready for use. The engine by itself can't do anything, you'll need to start the update loop for things to happen:
#![allow(unused)] fn main() { engine.update_loop(move |_, _, _, _, _, _| {}, vec![]).expect("Error during update loop"); }
The update_loop
function takes in a closure, that it'll be run every frame. Try running it and you should see a blank window:
cargo run
This means everything is working. We will draw things in it later on, for now let's try changing the window a bit.
The default window has a small size, and a default title. Let's say we want to change it from 800x600
to 1280x720
and change title to My Awesome Render
. There are two ways to accomplish this.
Customize at start with WindowDescriptor
At the start, we declared a default WindowDescriptor
, we can define our desired customization there to reflect it globally. To do this, simple define the width
, height
, and title
fields in the WindowDescriptor
:
#![allow(unused)] fn main() { WindowDescriptor { width: 1280, height: 720, title: "My Awesome Render", ..Default::default() // To keep other details to default values } }
Customize later on the runtime
During runtime, these options are available too! These settings can be accessed through window
property of the Engine
struct. Before update_loop
simple use engine.window.
, or during update_loop
the window is exposed to the loop directly, and then type the setting you want, e.g. set_inner_size
method for size. For our case, we can do this:
#![allow(unused)] fn main() { engine.window.set_inner_size(PhysicalSize::new(1280, 720)); engine.window.set_title("My Awesome Render"); }
Notice that we use set_
before every option method, and we use PhysicalSize
for size. There is also LogicalSize
which you can use.