Basic Tutorial - jopo86/onyx GitHub Wiki

Onyx - Basic Tutorial

This is a tutorial that will help you easily get started creating projects with Onyx.
Before we get into the code, we need to set up our project.

Setup

Use the Build page for detailed download/build instructions.

Start Coding!

Starting from a blank slate, include Onyx/Core.h if you haven't already. The first steps are to create an error handler (not necessary but recommended) and initialize the library using that handler. (If you don't create an error handler, which you may not for production since it would be slightly faster, then you can just call Init() with no arguments.) Let's terminate the library after. All of the code from this point will come before the terminate call.

Onyx::ErrorHandler errorHandler(true, true);
Onyx::Init(errorHandler);
Onyx::Terminate();

The first argument in the error handler constructor is whether to log warnings, and the second is whether to log errors. You may also add a third argument for the minimum warning severity - use the Onyx::Warning::Severity enum class. If you leave it blank, it will log all warnings, which is ideal for development purposes.

Let's also declare that we will be using Vec2, Vec3, and Vec4, since it is annoying to type their full name. (You will have to include Onyx/Math.h.)

using Onyx::Math::Vec2, Onyx::Math::Vec3, Onyx::Math::Vec4

Note - you cannot use multiple using directives like this if you are compiling older than C++17. To switch to C++17/20 in Visual Studio, open project properties, and under General, change the C++ Language Standard to ISO C++17 or ISO C++20.

The Window

Now, let's create a window. We will use the Window class, so include Onyx/Window.h. When we create a window, we pass it a WindowProperties struct (simply because there are many window properties that we can customize, and it would be annoying to have like 15 constructor arguments). We can look into all the window properties later, but for now we will just set the title, width, and height of the window. We need to initialize the window after creating it (which initializes OpenGL), and dispose it before the program ends.

// after Onyx initialization
Onyx::Window window(
    Onyx::WindowProperties{ // need to have C++17 or higher to initialize like this
        .title = "Onyx Tutorial", 
        .width = 1280, 
        .height = 720
    }
);
window.init();

// at the end of the program, just before Onyx termination
window.dispose();

Running the program now should flash a window on the screen, but it will disappear instantly. We need to create the mainloop, a code block that runs every frame until the window is closed. Every frame, at the minimum, we need to tell the window to start rendering and to end rendering.

// in between window init and dispose
while (window.isOpen())
{
    window.startRender();
    window.endRender();
}

Now the window should stay on the screen until we close it. The background is black, though, let's spice it up - let's add a background color to the window properties. Colors are represented as a Vec3, where the 3 components are RGB values ranging from 0-1.

// replace window creation
Onyx::Window window(
    Onyx::WindowProperties{ 
        .title = "Onyx Tutorial", 
        .width = 1280, 
        .height = 720,
        .backgroundColor = Vec3(0.0f, 0.2f, 0.4f)
    }
);

This should set the background to a nice blue color. Obviously, you can set it to whatever you want.

Quick note - there is doxygen documentation for all functions. If it's unclear what something does, in most IDEs, you can hover over the function and get a description of it.

Input Handling

Before we get to rendering some stuff, let's detect some key presses. For this, we need an input handler. Include Onyx/InputHandler.h. After we create the window, let's create an input handler and 'link' it to the window. Then, in the mainloop, let's test if the escape key is pressed, and if it is, let's close the window. We also need to 'update' the input handler every frame.

// after window initialization
Onyx::InputHandler input;
window.linkInputHandler(input);

// beginning of mainloop
input.update();

if (input.isKeyDown(Onyx::Key::Escape)) window.close();

We use the isKeyDown method to test whether a key is currently pressed down, and to identify the key we use the Onyx::Key enum.

Try running the program and hitting the escape key. It should close.

Rendering

Let's render something! To do this, we create renderables and render them in between the start and end render calls. Onyx has some presets that we can use to start. There are several classes in Onyx with presets, found as static functions of the class. Let's include Onyx/Renderable.h and create a colored triangle renderable with a side length of 1 and a red color. Then, let's render it in the mainloop. We also need to dispose of the renderable before the program exits.

// after window and input handler creation
Onyx::Renderable triangle = Onyx::Renderable::ColoredTriangle(1.0f, Vec3(1.0f, 0.0f, 0.0f));

// in between start and end render calls in mainloop
triangle.render();

// just after window is disposed
triangle.dispose();

Now it should look something like this:
Image 1

3D World

To go 3D, we need a camera. But to use a camera, we need a renderer. A renderer is a class to organize your renderables and render them from the perspective of a camera. Let's include Onyx/Renderer.h and Onyx/Camera.h. Let's create a camera linked to our window, and then a renderer linked to our window that uses that camera. Then, add the triangle to the renderer, and instead of calling render on the triangle, let's call it on the renderer.

// after triangle creation
Onyx::Camera cam(Onyx::Projection::Perspective(60.0f, 1280, 720));
window.linkCamera(cam);

Onyx::Renderer renderer(cam);
window.linkRenderer(renderer);
renderer.add(triangle);

// mainloop, after input handling but before rendering
cam.update();

// between start and end render calls (replace triangle.render())
renderer.render()

// after window disposal (replace triangle.dispose())
renderer.dispose()

The camera needs to know whether we want perspective projection, in which further objects appear smaller, or orthographic projection, in which they don't. Orthographic is used for 2D renders or confusing art, so we want perspective, with a field of view of 60 degrees (the first argument). The second two arguments are just the screen dimensions. We also just dispose of the renderer now, rather than the triangle. Don't worry about memory issues from the renderer copying the renderable, it all makes sense and works behind the scenes. We also need to 'update' the camera each frame.

If you run the program now, you'll see... nothing! Don't worry, this is because the camera is inside of our triangle. Let's translate (change position) it backwards a bit.

// after cam creation
cam.translateFB(-2.0f); // FB = forward/backward

Now, we can see the triangle again: Image 2

You may notice that its proportions have changed a bit. This is because, with our camera, the side lengths of the triangle are now truly 1 unit each - before they were 1 times a percentage of the screen width or height, so it was wider than it was tall.

Now, let's actually add some camera controls so we can move around in our (seemingly not) 3D world. We'll use the input handler to test for the traditional movement controls (WASD), and use some camera functions to move it around. We'll then rotate the camera by the mouse deltas (delta means change, in this context, since the last frame).

// before cam.update()
if (input.isKeyDown(Onyx::Key::W))      cam.translateFB( 1.0f);
if (input.isKeyDown(Onyx::Key::A))      cam.translateLR(-1.0f);
if (input.isKeyDown(Onyx::Key::S))      cam.translateFB(-1.0f);
if (input.isKeyDown(Onyx::Key::D))      cam.translateLR( 1.0f);
if (input.isKeyDown(Onyx::Key::Space))  cam.translateUD( 1.0f);
if (input.isKeyDown(Onyx::Key::C))      cam.translateUD(-1.0f);

cam.rotate(input.getMouseDeltas().getX(), input.getMouseDeltas().getY());

Again, if all this FB/LR/UD stuff is confusing (you can probably figure it out though), you can hover over the functions in most IDEs to get documentation.

Now, we should be able to move around in our 3D world using WASD, space to go up, c to go down (you can change these binds, obviously), and mouse to look around. But there's two problems, one of which you may have heard of if you've worked with graphics programming before. We move a static amount every frame, which makes it so the movements will be much more drastic if the frame rate is higher. We can fix this by multiplying values used every frame by the time since the last frame - the delta time. This calculation is covered by the window class. Multiply all 1.0f by window.getDeltaTime(). We don't need to do this with mouse deltas, though, since they already are changes since the last frame, as long as input.update() is called each frame. The second problem is that the mouse pointer is still there, and if it starts off the screen and transitions onto it, there is jump. So let's lock the cursor to avoid this before the mainloop with input.setCursorLock(true).

Now, movement is pretty slow and sensitivity is pretty fast, so let's make some constants for the camera speed and sensitivity and multiply them as well. To make sensitivity look nice on a scale of 1-100 or something, like it usually is in games, we'll divide it by 200 (just an arbitrary, nice number that works) and then by the sensitivity. I like a speed of 4 and a sensitivity of 50:

// before mainloop
const float CAM_SPEED = 4.0f;
const float CAM_SENS = 50.0f;

// mainloop
if (input.isKeyDown(Onyx::Key::W))      cam.translateFB( CAM_SPEED * window.getDeltaTime());
if (input.isKeyDown(Onyx::Key::A))      cam.translateLR(-CAM_SPEED * window.getDeltaTime());
if (input.isKeyDown(Onyx::Key::S))      cam.translateFB(-CAM_SPEED * window.getDeltaTime());
if (input.isKeyDown(Onyx::Key::D))      cam.translateLR( CAM_SPEED * window.getDeltaTime());
if (input.isKeyDown(Onyx::Key::Space))  cam.translateUD( CAM_SPEED * window.getDeltaTime());
if (input.isKeyDown(Onyx::Key::C))      cam.translateUD(-CAM_SPEED * window.getDeltaTime());

cam.rotate(input.getMouseDeltas().getX() / 200.0f * CAM_SENS, input.getMouseDeltas().getY() / 200.0f * CAM_SENS);

The 1.0f's were deleted because multiplying by 1 doesn't do anything, that's all.

Now our 3D scene should have some smooth movement controls.
Let's change our boring triangle to a cube to complement our newfound third dimension:

// replace triangle creation
Onyx::Renderable cube = Onyx::Renderable::ColoredCube(1.0f, Vec3(1.0f, 0.0f, 0.0f));
// ...
renderer.add(cube);

Nice!
Image 3
It doesn't really look like a cube though, right? That's because there's no shading on the sides, it's just a blob of red. Hang tight, you'll learn about lighting in the advanced tutorial!

Here's all the source code up to this point:

#include <Onyx/Core.h>
#include <Onyx/Math.h>
#include <Onyx/Window.h>
#include <Onyx/InputHandler.h>
#include <Onyx/Renderable.h>
#include <Onyx/Renderer.h>
#include <Onyx/Camera.h>

using Onyx::Math::Vec2, Onyx::Math::Vec3;

int main()
{
    Onyx::ErrorHandler errorHandler(true, true);
    Onyx::Init(errorHandler);

    Onyx::Window window(
        Onyx::WindowProperties{ 
            .title = "Onyx Tutorial", 
            .width = 1280, 
            .height = 720,
            .backgroundColor = Vec3(0.0f, 0.2f, 0.4f)
        }
    );
    window.init();

    Onyx::InputHandler input;
    window.linkInputHandler(input);

    Onyx::Renderable cube = Onyx::Renderable::ColoredCube(1.0f, Vec3(1.0f, 0.0f, 0.0f));

    Onyx::Camera cam(Onyx::Projection::Perspective(60.0f, 1280, 720));
    window.linkCamera(cam);
    cam.translateFB(-2.0f);

    Onyx::Renderer renderer(cam);
    window.linkRenderer(renderer);
    renderer.add(cube);

    input.setCursorLock(true);

    const float CAM_SPEED = 4.0f;
    const float CAM_SENS = 50.0f;

    while (window.isOpen())
    {
        input.update();

        if (input.isKeyDown(Onyx::Key::Escape)) window.close();

        if (input.isKeyDown(Onyx::Key::W))      cam.translateFB( CAM_SPEED * window.getDeltaTime());
        if (input.isKeyDown(Onyx::Key::A))      cam.translateLR(-CAM_SPEED * window.getDeltaTime());
        if (input.isKeyDown(Onyx::Key::S))      cam.translateFB(-CAM_SPEED * window.getDeltaTime());
        if (input.isKeyDown(Onyx::Key::D))      cam.translateLR( CAM_SPEED * window.getDeltaTime());
        if (input.isKeyDown(Onyx::Key::Space))  cam.translateUD( CAM_SPEED * window.getDeltaTime());
        if (input.isKeyDown(Onyx::Key::C))      cam.translateUD(-CAM_SPEED * window.getDeltaTime());

        cam.rotate(input.getMouseDeltas().getX() / 200.0f * CAM_SENS, input.getMouseDeltas().getY() / 200.0f * CAM_SENS);

        cam.update();

        window.startRender();
        renderer.render();
        window.endRender();
    }

    window.dispose();
    renderer.dispose();
    Onyx::Terminate();

    return 0;
}

Transforms

We can translate (position), rotate, and scale renderables, and it's very simple. Let's rotate the cube a little each frame, multiplying by the delta time again so it isn't frame rate dependent. 20 looks nice.

// in mainloop
cube.rotate(Vec3(20.0f * window.getDeltaTime()));

The vector we pass is a vector containing the rotation angles around each axis. The key here is AROUND each axis - if you want something rotate horizontally, meaning on the XZ plane, you rotate around the Y axis. Rotating on the YZ plane = X axis rotation, and you can probably guess how you rotate on the XY plane. Here, we are rotating 20 * deltaTime degrees around all axes (the single argument constructor for a vector sets all values to that argument).

You can also translate and scale renderables. Let's make the cube travel to the right and get smaller as time goes on, just for fun.

// in mainloop
cube.translate(Vec3(0.5f * window.getDeltaTime(), 0.0f, 0.0f));
cube.rotate(Vec3(20.0f * window.getDeltaTime()));
cube.scale(1 - 0.1f * window.getDeltaTime());

Translation, unlike rotation, is easy to think about - it's just the movement on each axis.
Scale is a multiplier based on the current scale. So, if we scale by 0.999 every frame, it will slowly get smaller. To incorporate this with delta time, we take 1 and subtract a small amount multiplied by delta time.
I'm gonna remove the translation and scaling, though, because it will just be annoying in the future.

Textures

Enough of that boring red color on our cube! Let's make it look like a wooden container. In the resources folder from the download, there is an image that we can use.

To create a texture, we use the static Load() function that takes a filepath in the Texture class, and the Onyx::Resources() function to generate a file path relative to the resource path. Let's create a Texture object from textures/container.jpg in the resources folder. We can then attach it to the renderable using the TexturedCube preset.

// replace renderable creation
Onyx::Texture container = Onyx::Texture::Load(Onyx::Resources("textures/container.jpg"));
Onyx::Renderable cube = Onyx::Renderable::TexturedCube(1.0f, container);

Now the cube should look like a container:
Image 4

Text Rendering

The last thing we are going to cover in the basic tutorial is text rendering. To render text, we use a different type of renderable - a TextRenderable. Before we create one, though, we need to create a font. In the resources/fonts folder, the Roboto font is installed. Let's include Onyx/TextRenderable.h and load the font in, passing the filepath and font size.

// before mainloop
Onyx::Font roboto = Onyx::Font::Load(Onyx::Resources("fonts/Roboto/Roboto-Bold.ttf"), 48);

You may initially see it as a problem that each font needs its own size, but this does not at all mean we have to create different fonts for each size. You can, but you can also just scale TextRenderables similarly to normal renderables.

Now, let's create a TextRenderable that says "Hello, World!" in a red color. This time, the color will be a Vec4, with the fourth value representing the alpha (opacity) value, also ranging from 0-1. I presume you get how to make colors now, so we can cheat and use some color presets of vectors now.

// after font creation
Onyx::TextRenderable hello("Hello, World!", roboto, Vec4::Red());
// ...
renderer.add(hello);

Running the program should yield this:
Image 5

Now, let's have it tell us something useful. Let's make an FPS counter, this time on the top left of the screen. However, while the top left would be (0, 720), the coordinate you enter is the bottom left of the text that is rendered, so we need to lower it - specifically by the size of the text. Let's also add a little bit of padding (distance from edge) so it looks nicer, maybe 20 pixels. So the coordinates of our text will be (20, 720 - font size - 20). I'm going to make it a cyan color this time, to go well with the background.

Onyx::TextRenderable fpsCounter("FPS: 0", roboto, Vec4::Cyan());
fpsCounter.setPosition(Vec2(20.0f, window.getBufferHeight() - roboto.getSize() - 20.0f));
//...
renderer.add(fpsCounter);

We have to update the text each frame to display the FPS. We can get the FPS with window.getFPS(), which works as long as we are correctly calling window.startRender() and window.endRender(0) each frame.

// in mainloop
fpsCounter.setText("FPS: " + std::to_string(window.getFPS())); // may need to #include <string> to get std::to_string

Note that setting the text of a text renderable is a costly function performance-wise, because the mesh of each character needs to be regenerated. Only change the text (and/or font) of a text renderable if necessary, like right now.

Now we should have a nice FPS counter on the top of the screen.
Image 6
Your FPS should fluctuate around the default FPS of the monitor you're using.
It is a little further from the top than it is from the left, so feel free to adjust the positioning; lowering it by the size of the text doesn't seem to be a perfect solution.

Here's all the source code for the basic tutorial:

#include <Onyx/Core.h>
#include <Onyx/Math.h>
#include <Onyx/Window.h>
#include <Onyx/InputHandler.h>
#include <Onyx/Renderable.h>
#include <Onyx/Renderer.h>
#include <Onyx/Camera.h>

using Onyx::Math::Vec2, Onyx::Math::Vec3, Onyx::Math::Vec4;

int main()
{
    Onyx::ErrorHandler errorHandler(true, true);
    Onyx::Init(errorHandler);

    Onyx::Window window(
        Onyx::WindowProperties{ 
            .title = "Onyx Tutorial", 
            .width = 1280, 
            .height = 720,
            .backgroundColor = Vec3(0.0f, 0.2f, 0.4f)
        }
    );
    window.init();

    Onyx::InputHandler input;
    window.linkInputHandler(input);

    Onyx::Texture container = Onyx::Texture::Load(Onyx::Resources("textures/container.jpg"));
    Onyx::Renderable cube = Onyx::Renderable::TexturedCube(1.0f, container);

    Onyx::Camera cam(Onyx::Projection::Perspective(60.0f, 1280, 720));
    window.linkCamera(cam);
    cam.translateFB(-2.0f);

    Onyx::Font roboto = Onyx::Font::Load(Onyx::Resources("fonts/Roboto/Roboto-Bold.ttf"), 48);
    Onyx::TextRenderable fpsCounter("FPS: 0", roboto, Vec4::Cyan());
    fpsCounter.setPosition(Vec2(20.0f, window.getBufferHeight() - roboto.getSize() - 20.0f));

    Onyx::Renderer renderer(cam);
    window.linkRenderer(renderer);
    renderer.add(cube);
    renderer.add(fpsCounter);

    input.setCursorLock(true);

    const float CAM_SPEED = 4.0f;
    const float CAM_SENS = 50.0f;

    while (window.isOpen())
    {
        input.update();

        if (input.isKeyDown(Onyx::Key::Escape)) window.close();

        if (input.isKeyDown(Onyx::Key::W))      cam.translateFB( CAM_SPEED * window.getDeltaTime());
        if (input.isKeyDown(Onyx::Key::A))      cam.translateLR(-CAM_SPEED * window.getDeltaTime());
        if (input.isKeyDown(Onyx::Key::S))      cam.translateFB(-CAM_SPEED * window.getDeltaTime());
        if (input.isKeyDown(Onyx::Key::D))      cam.translateLR( CAM_SPEED * window.getDeltaTime());
        if (input.isKeyDown(Onyx::Key::Space))  cam.translateUD( CAM_SPEED * window.getDeltaTime());
        if (input.isKeyDown(Onyx::Key::C))      cam.translateUD(-CAM_SPEED * window.getDeltaTime());

        cam.rotate(input.getMouseDeltas().getX() / 200.0f * CAM_SENS, input.getMouseDeltas().getY() / 200.0f * CAM_SENS);

        cam.update();

        cube.rotate(Vec3(20.0f * window.getDeltaTime()));

        fpsCounter.setText("FPS: " + std::to_string(window.getFPS()));

        window.startRender();
        renderer.render();
        window.endRender();
    }

    window.dispose();
    renderer.dispose();
    Onyx::Terminate();

    return 0;
}

There's more to do!

This was all cool, but the Advanced Tutorial is where the magic really happens. Learn about the good stuff, like lighting, custom renderables, model loading, UI (User Interface), and other miscellaneous but useful features not covered here. The most amazing thing is, none of it is even much harder to do than what we covered here!

⚠️ **GitHub.com Fallback** ⚠️