Progress update #1


So, last time I said I wanted to keep a blog updated. That didn't go too well, and I haven't posted anything for a couple of weeks. But I thought my progress so far would be worth showing. So here we go!

As you can see, my engine is capable of running simple gameplay with simple graphics. The above was written in 131 lines of Lua code, including mesh generation and physics. (Those are not part of the engine.) There's a bit of code that isn't really needed, so it could probably be reduced to about 110 lines. The engine runs at 60 fps. (The above gif was reduced in quality for the sake of compression.)

I could just describe the features one at a time, but that's not really that interesting. Instead, I'll show the code that generates the above and point out the engine-specific stuff. 

The first line is just a print statement for debugging purposes. There are a couple of them, and I'll just skip them from now on, since they aren't really important. The second line says the following:

actors = require("Service/Actors");

It sets the global variable actors to a module received from require. In Uranus, require is used the same way as in stock Lua, except that the scripts come from a compressed build file generated by the cooker. Currently, the Actor API is accessed through here as well, although that may likely change in the future. I'll explain the Actor system shortly.

Next, we have this:

box = makemesh({pos = "float3"});
box:set("pos", 0, math3D.float3(-1, -1, 0));
box:set("pos", 1, math3D.float3(1, -1, 0));
box:set("pos", 2, math3D.float3(-1, 1, 0));
box:set("pos", 3, math3D.float3(1, 1, 0));
box:set("pos", 4, math3D.float3(-1, 1, 0));
box:set("pos", 5, math3D.float3(1, -1, 0));

This is where we describe the famous shape known as a box. Shapes like the box will likely be made available as predefined meshes via the API in the future, but for now, we have this. The first line is where we declare the mesh itself. The makemesh function takes one argument, that being a table of attribute names and types. I won't go into too much detail, but in short, a mesh is like an array of structs each known as a vertex, and the members of the vertexes are called attributes. Those are what we describe in the attribute table. Attributes can describe things like colors or UV mappings, but here, we only need to give them their positions in model-space. 

The next 6 lines assign values to these attributes, which we do via the mesh:set method. The first argument is the name of the attribute. Since we only have one attribute, this argument always says "pos" in our case. The second argument is the index of the vertex whose attribute we want to set, and the third argument is the value we want to set it to. Currently, the triangles are always defined by three vertices occurring one after another, so {0, 1, 2} is one triangle and {3, 4, 5} is the second triangle. This may be changed to allow for better performance.

Next we have this:

viewSize = math3D.float4x4(
    math3D.float4(1/24, 0, 0, 0),
    math3D.float4(0, 1/18, 0, 0),
    math3D.float4(0, 0, 1, 0),
    math3D.float4(0, 0, 0, 1)
);
view = viewSize;

Here, we declare a transformation matrix that will later be used for viewport transformation. This topic is a bit weird and very math-heavy, so I won't go into details. But try to imagine that we want to film the world using a camera so that we can show it to the player. However, the camera won't move, so we move the whole world around the camera instead. This matrix describes how to do that.

You may notice that we actually have two variables, viewSize and view. Currently, the two are equal, but later when we want to move the camera, viewSize will only define how the world is stretched to fit the screen, while view combines that with the camera's movement.

Next, we define our shaders.

shaders = {
    makeshader("vert", 
    [[#version 330 core
    in vec3 pos;
    uniform mat4 transform;
    uniform vec3 col;
    out vec3 fragCol;
    void main() {
        gl_Position = transform * vec4(pos, 1);
        fragCol = col;
    }
    ]]),
    makeshader("frag", 
    [[#version 330 core
    in vec3 fragCol;
    out vec3 fragColor;
    void main(){
        fragColor = fragCol;
    }
    ]])
}

We make a table containing two shaders. The shaders are not named; All we need is for them to be in a table together. We make the shaders though the makeshader function, which takes two arguments. The first is the role of the shader we want to make. Currently, only vertex and fragment shaders are supported, both of which we need to draw stuff. The vertex shader reads the vertex data from the mesh and tells the GPU where to put them relative to the camera. The fragment shader gives the color to each pixel within the triangles that the GPU generates. The shader source code is provided though the second argument. All shaders are written in GLSL.

Next, we generate the terrain. We start this by making a table of our platforms:

platforms = {
    {x = -8, y = 0, size = 6},
    {x = 2, y = 3, size = 6},
    {x = -5, y = -2, size = 10},
    {x = 15, y = 1, size = 10},
}

Nothing fancy here. Next, we generate our mesh.

terrain = makemesh({pos = "float3"});
for i, v in pairs(platforms) do
    terrain:set("pos", i * 3, math3D.float3(v.x, v.y, 0));
    terrain:set("pos", i * 3 + 1, math3D.float3(v.x + v.size / 2, v.y - 1, 0));
    terrain:set("pos", i * 3 + 2, math3D.float3(v.x + v.size, v.y, 0));
end

This is similar to what we did when we made our box, but this time, we do it from a for-loop. And lastly, we tell the engine to draw it every frame.

actors.make({
    Update = function(self) 
        terrain:draw(shaders, {
            transform = view, 
            col = math3D.float3(0, 1, 0)
        }) 
    end
});

We now introduce two new functions, the first of which being actors.make. As you may remember, actors was accessed through the require function. Actors in the Uranus engine are like game objects or entities in other engines, but more minimalistic. Currently, they are made by using a single table providing the methods used to keep them updated and active, although I have plans to change that in the future. There's only one method available at the moment, that being Update, which is called once every frame.

Second, we have mesh:draw. When called, a draw instruction is generated. These draw instructions will be executed at the end of the frame. No modifications are made to the draw instructions currently, but I plan to implement a system that reorders them for improved performance. It takes two arguments, a table containing the shaders used for the draw instruction and a table containing the uniforms. Uniforms are like attributes, but with three differences: Firstly, all shaders has access to uniforms, whereas attributes can only be received by the vertex shader, which then might pass the attributes over to the fragment shader. Second, uniforms are global, rather than being bound to a specific vertex. And third, uniforms aren't bound to a specific mesh, so they can be easely changed between different draw instructions. Here, we use two uniforms, being color and transformation. Since the terrain is stationary, we only need to provide the view matrix. Otherwise, we would provide the product of the terrain's position matrix and the view matrix.

The remainder of the code is for the player character itself:

function makeCreature(x, y, species)
    local memory = {};
    memory.x = x;
    memory.y = y;
    
    actors.make({
        Update = function(self)
            species.behavior(memory);
            box:draw(shaders, {
                transform = math3D.transform(memory.x, memory.y + 1) * view, 
                col = species.color
            });
        end
    })
end
makeCreature(0, 0, {
    behavior = function(self)
        local velX = 0;
        if input.getkey("right") then velX = .4 end
        if input.getkey("left") then velX = -.4 end
        self.x = self.x + velX;
        
        self.fall = (self.fall or 0) + ((input.getkey("space") or (self.fall or 0) > 0) and 0.04 or 0.12);
        local hasLanded = false;
        
        for i, platform in pairs(platforms) do 
            if self.y >= platform.y and self.y - self.fall <= platform.y
                and self.x > platform.x and self.x < platform.x + platform.size then
                
                self.y = platform.y;
                self.fall = input.getkey("space") and -1 or 0;
                hasLanded = true;
            end
        end
        
        if not hasLanded then self.y = self.y - self.fall; end
        
        if self.y < -20 then self.x = 0; self.y = 0; end
        
        view = math3D.transform(-self.x, -self.y) * viewSize;
    end,
    color = math3D.float3(0, 1, 1)
})

While the majority of this is just game logic, there are a few things to take away from this. Firstly, we have a wrapper around actors.make. This is recommended for the Uranus engine, since actors.make is rather minimalistic. By making a wrapper, we get to add our own functionality to it. For the final version of this engine, I'd say that having a few of these for different cases such as enemies and collectibles wouldn't be uncommon. The reason that the terrain is made directly is because that it's different in that it's stationary.

Second, we have math3D.transform. This generates a transformation matrix that simply moves objects by the specified amount in the x and y axis.

Third, we have matrix multiplication. In short, moving an object by using the product of two matrixes is like first moving the object by the first matrix and then the second.

And lastly, we have input.getkey. It takes a single argument, that being the name of a key. It then simply returns true if the key is pressed and false otherwise.

And that's it! Run all of the above code, and you have a (rather simple) 2D platformer! I'll try to make another post shortly, but if you haven't heard from me in a month or so, it's because I'm going on vacation. But thank you for reading!

Leave a comment

Log in with itch.io to leave a comment.