~ 3D Games - First steps ~

* Intro

* Initial Setup

* Creating 3D Cube

* Textures, UVs and Sprites

* Writing Shaders

* Objects, Instances & Rooms

* Play it!

* Conclusion


Intro

In previous article, we had learned about basic concepts of creating 3D games in GameMaker. In this article we will create a new game project and write a simple app with 3D cube on the scene.

Initial Setup

First, you'll need to install GameMaker Engine. Please note that it's free for personal use. The easiest way is to download it from official Steam Page.

After installation, launch the app. You may need to log in. Then simply create an empty (blank game) project and proceed to the next step.

Make a point!
I didn't make a detailed description of the installation and project creation process, as this could all change over time. I'm confident you'll be able to handle this trivial task without any problems~

Open your newly created blank project. You'll see the Asset Browser on the right. If it's not visible by any reason, click Windows -> Asset Browser in the top left menu bar.

Let's create our first script, where we'll write the code. To do this, Mouse right-click -> Create -> Script.

asset browser

You'll be prompted to select your preferred scripting style. Select GML Code and click OK.

asset browser

A Script Editor window titled like "Script1" will open, along with an empty function with the same name. You can rename script by pressing F2. Let's call it script_3d and remove all the auto-generated code from the script (e.g. empty function).

Important!
Scripts are essentially a collection of one or more user-defined functions or variables that you write yourself as snippets of code. They are parsed on a global level and will be compiled at the very start of the game. Any variables declared outside of a function in the script will be considered Global Variables. Global scope functions and variables can be accessed by all.

You can read more about scripts at the manual.

Creating 3D Cube

Any 3D object consists of vertices, and each vertex contains specific information (for example, its position) that must conform to a specific format. Let's create the simplest format that will contain the 3D position and texture coordinates (UVs) of the object.

vertex_format_begin();
vertex_format_add_position_3d();
vertex_format_add_texcoord();
global.format3D = vertex_format_end();

Next, we need to be able to create the cube model itself. This will be a simple 1x1x1 dimension cube at (0,0,0) coordinate with the same texture on all faces. Essentially, the cube has 6 square (quad) faces. But since the engine can't render quads yet, we'll use triangles instead. So, it's 6 x 2 = 12 triangles.
In the same script in which we created the format, below we will write a function that will generate a buffer with vertices for our cube model:

/// @return {Id.VertexBuffer}
function create_cube() {
  var _vbuff = vertex_create_buffer();

  vertex_begin(_vbuff, global.format3D);
    // ================== TOP (Z = -0.5) ==================
    vertex_position_3d(_vbuff, -0.5,  0.5, -0.5);   vertex_texcoord(_vbuff, 0, 1); 
    vertex_position_3d(_vbuff,  0.5, -0.5, -0.5);   vertex_texcoord(_vbuff, 1, 0);
    vertex_position_3d(_vbuff, -0.5, -0.5, -0.5);   vertex_texcoord(_vbuff, 0, 0); 

    vertex_position_3d(_vbuff, -0.5,  0.5, -0.5);   vertex_texcoord(_vbuff, 0, 1); 
    vertex_position_3d(_vbuff,  0.5,  0.5, -0.5);   vertex_texcoord(_vbuff, 1, 1); 
    vertex_position_3d(_vbuff,  0.5, -0.5, -0.5);   vertex_texcoord(_vbuff, 1, 0); 

    // ================== BOTTOM (Z = 0.5) ==================
    vertex_position_3d(_vbuff,  0.5, -0.5,  0.5);   vertex_texcoord(_vbuff, 0, 0);
    vertex_position_3d(_vbuff, -0.5,  0.5,  0.5);   vertex_texcoord(_vbuff, 1, 1);
    vertex_position_3d(_vbuff, -0.5, -0.5,  0.5);   vertex_texcoord(_vbuff, 1, 0);

    vertex_position_3d(_vbuff,  0.5, -0.5,  0.5);   vertex_texcoord(_vbuff, 0, 0);
    vertex_position_3d(_vbuff,  0.5,  0.5,  0.5);   vertex_texcoord(_vbuff, 0, 1);
    vertex_position_3d(_vbuff, -0.5,  0.5,  0.5);   vertex_texcoord(_vbuff, 1, 1);

    // ================== LEFT (X = -0.5) ==================
    vertex_position_3d(_vbuff, -0.5, -0.5, -0.5);   vertex_texcoord(_vbuff, 0, 0);
    vertex_position_3d(_vbuff, -0.5, -0.5,  0.5);   vertex_texcoord(_vbuff, 0, 1);
    vertex_position_3d(_vbuff, -0.5,  0.5,  0.5);   vertex_texcoord(_vbuff, 1, 1);

    vertex_position_3d(_vbuff, -0.5, -0.5, -0.5);   vertex_texcoord(_vbuff, 0, 0);
    vertex_position_3d(_vbuff, -0.5,  0.5,  0.5);   vertex_texcoord(_vbuff, 1, 1);
    vertex_position_3d(_vbuff, -0.5,  0.5, -0.5);   vertex_texcoord(_vbuff, 1, 0);

    // ================== RIGHT (X = 0.5) ==================
    vertex_position_3d(_vbuff,  0.5,  0.5, -0.5);   vertex_texcoord(_vbuff, 0, 0);
    vertex_position_3d(_vbuff,  0.5,  0.5,  0.5);   vertex_texcoord(_vbuff, 0, 1);
    vertex_position_3d(_vbuff,  0.5, -0.5,  0.5);   vertex_texcoord(_vbuff, 1, 1);

    vertex_position_3d(_vbuff,  0.5,  0.5, -0.5);   vertex_texcoord(_vbuff, 0, 0);
    vertex_position_3d(_vbuff,  0.5, -0.5,  0.5);   vertex_texcoord(_vbuff, 1, 1);
    vertex_position_3d(_vbuff,  0.5, -0.5, -0.5);   vertex_texcoord(_vbuff, 1, 0);

    // ================== BACK (Y = -0.5) ==================
    vertex_position_3d(_vbuff,  0.5, -0.5, -0.5);   vertex_texcoord(_vbuff, 0, 0);
    vertex_position_3d(_vbuff,  0.5, -0.5,  0.5);   vertex_texcoord(_vbuff, 0, 1);
    vertex_position_3d(_vbuff, -0.5, -0.5,  0.5);   vertex_texcoord(_vbuff, 1, 1);

    vertex_position_3d(_vbuff,  0.5, -0.5, -0.5);   vertex_texcoord(_vbuff, 0, 0);
    vertex_position_3d(_vbuff, -0.5, -0.5,  0.5);   vertex_texcoord(_vbuff, 1, 1);
    vertex_position_3d(_vbuff, -0.5, -0.5, -0.5);   vertex_texcoord(_vbuff, 1, 0);

    // ================== FRONT (Y = 0.5) ==================
    vertex_position_3d(_vbuff, -0.5,  0.5, -0.5);   vertex_texcoord(_vbuff, 0, 0);
    vertex_position_3d(_vbuff, -0.5,  0.5,  0.5);   vertex_texcoord(_vbuff, 0, 1);
    vertex_position_3d(_vbuff,  0.5,  0.5,  0.5);   vertex_texcoord(_vbuff, 1, 1);

    vertex_position_3d(_vbuff, -0.5,  0.5, -0.5);   vertex_texcoord(_vbuff, 0, 0);
    vertex_position_3d(_vbuff,  0.5,  0.5,  0.5);   vertex_texcoord(_vbuff, 1, 1);
    vertex_position_3d(_vbuff,  0.5,  0.5, -0.5);   vertex_texcoord(_vbuff, 1, 0);
  vertex_end(_vbuff);

  return _vbuff;
}

Make a point!
This amount of code can be daunting. After all, it's just a simple cube - how much code would it take to create a high-poly model? That's why people invented 3D modeling software like Blender, Maya3D, and others to simplify model creation. In future tutorials, we'll cover the system for loading 3D model files. But for now, just copy the script and move on to the next step.

Textures, UVs and Sprites

This section will discuss how our cube will look. Specifically, how the Image is overlaid onto the Cube Model.

Important!
Texture is an image. Texel is a relative unit of texture size equal to u = 1/width and v = 1/height of the texture. Therefore, texture coordinates (UV) are in the range from 0 to 1.
Sprite is a structure that contains information about visual representation of the object - used Texture, UVs, animation speed ect.
UVs example

In this example we'll use the approach of placing only one sprite per texture. In 2D games, it's common to place multiple sprites on the same texture. This is called a Texture Atlas.

You can create a sprite in the same way as a script: right-click in the asset browser window, and then select Sprite. Create one sprite for our cube, you can call it spr_cube.

Next, download the image below. We'll use it to check that the texture is correctly applied to the cube.

UVs check texture

Import the image into the sprite. You'll be informed that this action is undoable; just click OK. Important: check the box next to "Separate Texture Page". This is necessary so that all sprites are located on separate textures, not on a single one.

Sprite Import

Writing shaders

We already know what a Shader is. But shader execution is a pipeline process, consisting of several sequential programs. In GameMaker, you have two main types of shaders available:

Important!
Vertex shader is a program that operates on vertices. It is executed for each vertex of the model. The data from Vertex Buffer is passed and stored in Attribute Variables, and can be accessed directly only in this shader. The main purpose of this shader is to transform the vertex position from the 3D world to screen space.

Fragment shader is a program that operates on rasterized pixels. It is executed for each pixel. This shader is responsible for the final rendering of an object - texture, color, transparency, etc.

Great, now let's write the simplest shaders - it will just apply the texture on the model. Create a new shader in the asset browser and name it shd_3d. A window will open with two tabs containing the vertex and fragment shader code. Remove all the auto-generated code and write the following:

// Vertex shader (shd_3d.vsh)
attribute vec3 in_Position;      // (x,y,z)
attribute vec2 in_TextureCoord;  // (u,v)

varying vec2 v_vTexcoord;

void main()
{
    vec4 object_space_pos = vec4( in_Position, 1.0 );
    gl_Position = gm_Matrices[MATRIX_WORLD_VIEW_PROJECTION] * object_space_pos;
    
    v_vTexcoord = in_TextureCoord;
}

in_Position and in_TextureCoord are standart built-in names for shader attributes. Because our defined 3D format has only 3D position and texture coordinates, we use only them.

Important!
Varying - is variable modifier that allow to pass this variable to the fragment shader. Important thing - the value in the fragment shader will be interpolated between other vertices of rendering element (e.g. triangle). For every single pixel, the GPU calculates its exact distance from each vertex. It then blends the vertex values proportionally based on this distance.

gm_Matrices - is a built-in array that contain various matrices. MATRIX_WORLD_VIEW_PROJECTION is self-explanatory name. This matrix stores the result of World (aka Model), View and Projection matrices multiplication, and is needed to translate 3D vertex positions to 2D screen.

gl_Position is a built-in output variable in the vertex shader that defines the final, processed 4D vector position (x, y, z, w) of a vertex in Normalized Device Coordinates (NDC) space. Once assigned, GameMaker translates gl_Position behind the scenes into a range between -1.0 and 1.0 for the X and Y axes. Anything outputting outside this range is clipped and will not render on screen.

// Fragment shader (shd_3d.fsh)
varying vec2 v_vTexcoord;

void main()
{
    gl_FragColor = texture2D( gm_BaseTexture, v_vTexcoord );
}

gm_BaseTexture - is a built-in 2D sampler (a variable that stores texture reference). When you call any draw_*() function - for example, draw_sprite() - or vertex_submit() function, it binds passed sprite/texture argument value to this sampler, so you can access it in the shader.

texture2D - this function calculates a color value of pixel on the texture using provided UV coordinates.

gl_FragColor - is a built-in output variable in the fragment shader that defines the final color (r,g,b,a) of the pixel. Note that the color components are stored in range from 0 to 1.

Objects, Instances & Rooms

Before we continue, we need to become familiar with vital game concepts: Objects, Instances and Rooms.

Important!
Object - is a template of resource that has Variables and Events, can run code and control some aspects of the game. For example: player, camera, UI menu - all of these can be game objects.
Instances are in-game copy of the object, that has unique id and own variables, but shares all the code. For example: there may be 2 or more players in the game, or several 3D cubes on the scene.
Room is a game scene - it contains many layers with instances.

Let's create three Objects (also in Asset Browser): obj_cube, obj_camera and obj_renderer. . Second object will be a camera, so we can see the game world. And the last one is a system object which will control the render pipeline.

obj_cube - is our 3D cube model representation. It will:

  • Create and maintain vertex buffer of the 3D model (cube);
  • Store the Position, Rotation and Scale of the model;
  • Render the cube - translate, rotate and scale it, and then send the vertex data to GPU.

Before continue, we need to set the Sprite - spr_cube. You can select it in Object Editor, or assign the value to sprite_index in the code.
Okay, for this object we need to create these events:

/// @desc obj_cube - Create Event
// This code will be executed once when instance created
x = 0;
y = 0;
z = 0;
xrot = 0;
yrot = 0;
zrot = 0;
xscale = 8;
yscale = 8;
zscale = 8;

vbuff = create_cube();

Render = function() {
  // Transform the cube - translate, rotate and scale
  var _matWorld = matrix_build(x, y, z, xrot, yrot, zrot, xscale, yscale, zscale);
  matrix_set(matrix_world, _matWorld);

  var _texture = sprite_get_texture(sprite_index, image_index);
  vertex_submit(vbuff, pr_trianglelist, _texture);

  // Restore the matrix
  matrix_set(matrix_world, matrix_build_identity());
}
/// @desc obj_cube - CleanUp Event
// This code will be executed once when instance destroyed

// Garbage Collector (GC) won't clear vertex buffers 
// We need to do it manually
if (vertex_buffer_exists(vbuff)) {
  vertex_delete_buffer(vbuff); 
}

Next object is obj_camera - 3D Camera. It will proceed our view and projection matrices. Also it will implement basic rotation and movement so we can fly in the game world.

/// @desc obj_camera - Create Event
// This code will be executed once when instance created
x = 10;
y = 10;
z = -10;
lookX = 0;
lookY = 0;
lookZ = 0;

face = 45;
pitch = 45;
sensitivity = 10;
movespeed = 1;

// Projection Matrix
fov = 60;
aspect = 16/9;
clipNear = 1;
clipFar = 3200;
matProj = matrix_build_projection_perspective_fov(fov, aspect, clipNear, clipFar);

// View Matrix
matView = [];

// Camera
camera = camera_create();

Apply = function(pm = matProj, vm = matView) {
  var _cam = camera;
  camera_set_proj_mat(_cam, pm);
  camera_set_view_mat(_cam, vm);	
  camera_apply(_cam);
}
/// @desc obj_camera - Step Event
// This code will execute every frame before any Draw (Render) event

// Rotate camera
var _w = display_get_width();
var _h = display_get_height();
var _mx = display_mouse_get_x();
var _my = display_mouse_get_y();

face  -= (_mx - _w / 2) / sensitivity;
pitch -= (_my - _h / 2) / sensitivity;

// Center the mouse cursor
display_mouse_set(_w / 2, _h / 2);
	
// Move camera
var _forward    = keyboard_check(ord("W"));
var _backward   = keyboard_check(ord("S"));
var _left	    = keyboard_check(ord("A"));
var _right      = keyboard_check(ord("D"));
var _up	        = keyboard_check(vk_space);
var _down	    = keyboard_check(vk_lshift);

var _moveFB = _forward - _backward;
var _moveLR = _left - _right;
var _moveUD = _up - _down;

if (_moveFB != 0) {
  x += lengthdir_x(movespeed * _moveFB, face);
  y += lengthdir_y(movespeed * _moveFB, face);
}

if (_moveLR != 0) {
  x += lengthdir_x(movespeed, face + 90 * _moveLR);
  y += lengthdir_y(movespeed, face + 90 * _moveLR);
}

z -= movespeed * _moveUD;

// Update View Matrix
lookX = x + dcos(face);
lookY = y - dsin(face);
lookZ = z - dtan(pitch);

matView = matrix_build_lookat(x, y, z, lookX, lookY, lookZ, 0, 0, 1);

// End game when press `Escape`
if (keyboard_check_pressed(vk_escape)) {
  game_end();	
}

And the last - obj_renderer. This object will control the entire render pipeline: GPU setting, shaders, render order, post-processing ect.
Render is a complex process, and I'll cover important topics like depth, culling, mipmapping and other techniques in the next article.

/// @desc obj_renderer - Create Event
// This code will be executed once when instance created

// Turn off automatic default render - we will do it manually
application_surface_draw_enable(false);
/// @desc obj_renderer - Draw Event
// This code will be executed every frame when game renders instances

// Everything will be drawn on the `application_surface`
// This is a default system render buffer

// First, let's setup the GPU for 3D
// Save current state, and then
// turn on ZWrite and ZTest
gpu_push_state();
gpu_set_ztestenable(true);
gpu_set_zwriteenable(true);

// Clear game screen - fill it with black color
draw_clear(c_black);

// Set projection and view matrices from the camera
with (obj_camera) {
  Apply();
}

// Set shader and Render
shader_set(shd_3d);

with (obj_cube) {
  Render();
}

shader_reset();
gpu_pop_state();
/// @desc obj_renderer - Post-Draw Event
// This code will be executed every frame when game renders views on game window

// Displays the contents of the application surface 
// stretched to fit the game window
var _ww = window_get_width(), _wh = window_get_height();
draw_surface_stretched(application_surface, 0, 0, _ww, _wh);

The final step before we run our game! Thank goodness it's the easiest one~
You need to find (or create) any Room in the Asset Browser. By default it's called Room1. You can rename it, for example - rm_one.
Double click on it, and the Room Editor window will be opened.

All that you need to do is just dran-n-drop our three objects - obj_cube, obj_camera, obj_renderer - from the Asset Browser somewhere in the Room. There are default "Instances" layer for it. Done!

Play it!

Just press F5 (or using menu bar, Build->Run) - it will build and lauch the game.

Probably the first thing you see is a pitch-black window. Just rotate the camera by mouse or move around the world by WASD keys. Then you will see our cube!

the cube!

Conclusion

Me

In this article, we managed to create a simple scene with a simple 3D object - cube, and also be able to examine it from all sides!

Of course, This is still far from a real 3D game, but the first brick has been laid. For the sake of simplicity, I had to omit some details that will be discussed in the following articles.

In Progress

Thank you for reading!

If you liked the article, you can share it and/or write your impressions and wishes to me personally in the guest book.

@Nick_Nishort

Go to home page