Skip to content

How Does 3D Rendering Work?

groverburger edited this page Mar 2, 2021 · 6 revisions

3D Rendering Basics

Everything that is rendered using a GPU is made up of polygons, and is rendered using a shader.
Even Love's simple 2D sprites and shapes are made up of polygons, and are rendered using simple shaders that Love already has implemented for you under the hood.
g3d simply uses Love's built-in Mesh object and adds Z-coordinates, using a custom shader to render these 3D models onto the screen.



Also see this repo, which is one of the simplest possible 3D programs in LOVE.

Into to Shaders

Each shader is made up of two main components, the Vertex Shader and the Fragment Shader.
The vertex shader code is run for every vertex in the polygon that is being rendered, and then fragment shader code is run for every pixel in the polygon that is being rendered.
Because the GPU computes everything in parallel, the only thing that these shaders know when they are being run is their current position and any custom data that is fed to them by you.
From this, the fragment shader spits out a color and that color is what is rendered on the screen.

For context, Love's default shader looks something like this:

BasicShader = love.graphics.newShader [[
    #ifdef VERTEX
        vec4 position(mat4 transform_projection, vec4 vertex_position)
        {
            return transform_projection * vertex_position;
        }
    #endif

    #ifdef PIXEL
        vec4 effect(vec4 color, Image tex, vec2 texcoord, vec2 pixcoord)
        {
            return vec4(Texel(tex, texcoord));
        }
    #endif
]]

Sugar, spice, and everything matrices

There are three ingredients to making a 3D shader:

  • Model Matrix
  • View Matrix
  • Projection Matrix

The Model Matrix specifies the transformations applied to the current model being rendered. This can include its position, rotation, and scale.
The View Matrix specifies where the camera is and where it's looking.
The Projection Matrix specifies the camera's near clip plane, far clip plane, FOV, and aspect ratio.

For more information on this topic, check out this article. The same concepts that apply to OpenGL apply here, because it's all graphics programming at the end of the day.

Putting It All Together

The trick for turning a 3D point into a 2D point correctly is to multiply these matrices and that 3D point in this order in the vertex shader:

projectionMatrix * viewMatrix * modelMatrix * vertex_position;

Therefore, a 3D shader would look like this:

ThreeDeeShader = love.graphics.newShader [[
    uniform mat4 projectionMatrix;
    uniform mat4 modelMatrix;
    uniform mat4 viewMatrix;

    #ifdef VERTEX
        vec4 position(mat4 transform_projection, vec4 vertex_position)
        {
            return projectionMatrix * viewMatrix * modelMatrix * vertex_position;
        }
    #endif

    #ifdef PIXEL
        vec4 effect(vec4 color, Image tex, vec2 texcoord, vec2 pixcoord)
        {
            return vec4(Texel(tex, texcoord));
        }
    #endif
]]

All that's left is to use the shader to draw some polygon meshes. Love thankfully has a built-in Mesh object, I recommend about it on the wiki here. Because Love's Meshes are not by default set up for 3D, we need to modify their Vertex Format, in other words the type and amount of data stored in each vertex. We basically want to add a dimension to position so instead of being only (x,y) it includes z, giving us (x,y,z).

g3d's Vertex Format looks like this:

model.vertexFormat = {
    {"VertexPosition", "float", 3},
    {"VertexTexCoord", "float", 2},
    {"VertexNormal", "float", 3},
    {"VertexColor", "byte", 4},
}

This vertex format includes 3 vertex positions instead of only 2, which adds the third dimension as stated earlier. This vertex format also specifies places to put texture coordinate data, normal data, and color data in each vertex.

Except for VertexNormal, all of these Vertex Format options have some built-in Love comparability. This is why they do not have to be declared with the attribute declaration in the shader code. This built-in functionality also makes the vec4 vertex_position argument in the vertex shader always populated with the VertexPosition vertex format option.

g3d adds on top of this in its shader to make love.graphics.setColor() work correctly and fully translucent pixels not render. Check out the differences by looking at G3DShader defined in init.lua!


You can see all of this stuff you just read about in this example repo here.