Game - Now with Graphics!
We last left our game project with a console app that takes input. However, a console app isn’t going to cut it for the game we want to make. It doesn’t give us enough control of the either the display or access to the mouse. So we need graphics.
OpenTk seems like a pretty good choice. It has a maintained nuget package for C#, which will work in our F# project with minimal fuss. Also, the fact that it is basically a wrapper around OpenGL means that we can reference all the OpenGL resources, of which Learn OpenGL seems really good.
So let’s get something on the screen!
So there are three things we need to do to accomplish this:
1) Add shaders to the project, which OpenGL uses to transform data. 2) Add the OpenTk/OpenGL code to the project that will load the shaders and handle the graphics 3) Integrate our “game” logic with the OpenTk/OpenGL code
Shaders
There are two shaders that we need a vertex shader and a fragment shader. The vertex shader creates gl_Position
s from its input and the fragment shaders deals with colors.
Let’s create two files (vertexShader.vert
and fragmentShader.frag
) in a new folder components\shaders
.
The contents of the vertexShader.vert
file are as follows:
#version 440 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aColor;
out vec3 ourColor;
void main()
{
gl_Position = vec4(aPos, 1.0);
ourColor = aColor;
}
The contents of fragmentShader.frag
are:
#version 440 core
in vec3 ourColor;
out vec4 FragColor;
void main()
{
FragColor = vec4(ourColor, 1.0);
}
For more information on what is going on here, take a look at the shader post on Learn OpenGL. Basically, shaders are little programs that run on the GPU written in a C-like language called GLSL.
One last task before we are done with the shaders, we need to add them to the build.
Open build.fsx
file. Than add two new directory variables.
let componentsDirSource = "./components/"
let componentsDirDest = buildDir + "components/"
Next modify the build command to copy the components to the output directly.
Target "Build" (fun _ ->
// compile all projects below src/app/
MSBuildDebug buildDir "Build" appReferences
|> Log "AppBuild-Output: "
CopyDir (directory componentsDirDest) componentsDirSource allFiles
)
Now when we run FAKE:Build
the shader files are copied to the output folder as expected, where the resulting exe can load them.
OpenGL object
Now the shader files exist, but we still aren’t doing anything with them. Let’s fix that.
OpenTk is a C# library so it isn’t really setup for a functional program. For now, I am not going to worry about that to much. But later (another blog post later), I want to figure out a good way to encapsulate the non-function stuff and add a functional/non-mutable api on top of it. For now, we need to create a class that inherits from GameWindow.
type MyGameWindow() as this =
inherit GameWindow(800, 600, GraphicsMode.Default, "Look! I am an application title!")
do this.Resize.Add(fun e -> GL.Viewport(0, 0, this.Width, this.Height))
do this.Load.Add(fun e ->
this.VSync <- VSyncMode.On
)
do this.UpdateFrame.Add(fun e -> //game code goes here
)
do this.RenderFrame.Add(this._RenderFrameEvent)
member this._RenderFrameEvent e =
GL.ClearColor(0.2f, 0.3f, 0.3f, 1.0f)
GL.Clear(ClearBufferMask.ColorBufferBit ||| ClearBufferMask.DepthBufferBit)
this.SwapBuffers()
So what is going on here? We are creating a GameWindow object with a hard coded width of 800 and height of 600. It has a couple of event handlers defined, for Resize
, Load
, UpdateFrame
, and RenderFrame
. Right now nothing much happens in those event handlers, but we will need to add the game update code in UpdateFrame (it basically replaces the game loop we made last time) and the drawing code in RenderFrame. If we put to much stuff in the UpdateFrame
handler we should copy what was done for the RenderFrame
and add a real method that the handler calls, but that is more of a style choice.
Anyway, let’s replace the game loop we had with the OpenTk game loop.
/// <summary>
/// The main entry point for the application.
/// </summary>
[<EntryPoint>]
let main argv =
// let initialState =
// {
// shouldContinue = true;
// position = 0.0f, 0.0f;
// }
let gw = new MyGameWindow()
do gw.Run(60.0)
0
I will come back to the currently unused initialState
, but all this does is create an instance of the MyGameWindow
class and call its inherited Run
method with an update rate limit specified.
Creating the Program
Now that we have the framework in place, let’s load the shaders and use them. To do this we need to read the shaders from the files, compile them, and put them in a program
that we can use when drawing things. For now, we can create a method on the MyGameWindow
class called CompileProgram
that will do all these things for us, and return the id for the program so we can use it later.
member this.CompileProgram () =
let currentDirectory = Environment.CurrentDirectory
let vertexShader = GL.CreateShader(ShaderType.VertexShader)
GL.ShaderSource(vertexShader, File.ReadAllText(Path.Combine(currentDirectory, @"components\shaders\vertexShader.vert")))
GL.CompileShader(vertexShader)
//print shader compilation errors to the console
let shaderResults = GL.GetShaderInfoLog(vertexShader)
printf "%s" shaderResults
let fragmentShader = GL.CreateShader(ShaderType.FragmentShader)
GL.ShaderSource(fragmentShader, File.ReadAllText(Path.Combine(currentDirectory, @"components\shaders\fragmentShader.frag")))
GL.CompileShader(fragmentShader)
//print shader compilation errors to the console
let fragmentResults = GL.GetShaderInfoLog(fragmentShader)
printf "%s" shaderResults
let program = GL.CreateProgram()
GL.AttachShader(program, vertexShader)
GL.AttachShader(program, fragmentShader)
GL.LinkProgram(program)
//print program errors to the console
let programResults = GL.GetProgramInfoLog(program)
printf "%s" programResults
GL.DeleteShader(vertexShader)
GL.DeleteShader(fragmentShader)
{
ProgramId = program;
VertexId = vertexShader;
FragmentId = fragmentShader;
}
Now we can call this method in the Load
event handler.
do this.Load.Add(fun e ->
this.VSync <- VSyncMode.On
this.Ids <- this.CompileProgram()
)
CompileProgram
doesn’t just return the ProgramId
it also returns the two shader Ids. This is two facilitate clean up in the Exit
event handler. We will need to define a type to hold all three ids as well as add the Exit
event handler.
type OpenGlIds = {
ProgramId: int;
VertexId: int;
FragmentId: int;
}
override this.Exit () =
GL.DetachShader(this.Ids.ProgramId, this.Ids.VertexId)
GL.DetachShader(this.Ids.ProgramId, this.Ids.FragmentId)
GL.DeleteProgram(this.Ids.ProgramId)
base.Exit()
member val Ids: OpenGlIds = { ProgramId = 0; VertexId = 0; FragmentId = 0; } with get,set
Loading The Data
Next, we need data to pass OpenGL, in a form that it understands. To do this we need a Buffer
(VBO
) that will hold the data and a VertexArray
(VAO
) that describes the data format to OpenGL. Similar to the shader setup we will create a method to create the objects that returns the ids for later cleanup. However, there is also an additional method to load data into the buffer. This additional method, LoadData
is what will get called in the UpdateFrame
handler.
type BufferData = {
Vbo: uint32;
Vao: uint32;
}
member val Buffers: BufferData = { Vbo = 0u; Vao = 0u; } with get,set
member this.CreateBuffers() =
let mutable vbo = 0u
let mutable vao = 0u
GL.GenVertexArrays(1, &vao)
GL.GenBuffers(1, &vbo)
{
Vbo = vbo;
Vao = vao;
}
member this.LoadData(vertices : Vector3[]) =
let vao = this.Buffers.Vao
let vbo = this.Buffers.Vbo
GL.BindVertexArray(vao)
GL.BindBuffer(BufferTarget.ArrayBuffer, vbo)
GL.BufferData(BufferTarget.ArrayBuffer, IntPtr(vertices.Length * Vector3.SizeInBytes), vertices, BufferUsageHint.StaticDraw)
GL.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, 2 * Vector3.SizeInBytes, 0)
GL.EnableVertexAttribArray(0)
GL.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, 2 * Vector3.SizeInBytes, Vector3.SizeInBytes)
GL.EnableVertexAttribArray(1)
//unbind
GL.BindBuffer(BufferTarget.ArrayBuffer, 0)
GL.BindVertexArray(0)
vao
do this.Load.Add(fun e ->
this.VSync <- VSyncMode.On
this.Ids <- this.CompileProgram()
this.Buffers <- this.CreateBuffers()
)
Now let’s update the UpdateFrame
method to load data into the buffer by calling LoadData
.
First we need to add a property to hold the id of the VAO
to use when drawing.
member val DrawingData: uint32 = 0u with get,set
Then, define the vertices and put the resulting VAO
into that property.
do this.UpdateFrame.Add(fun e ->
let vertices =
[| //Postion | Colors
Vector3(-0.5f, -0.5f, 0.0f); Vector3(1.00f, 1.00f, 0.94f);
Vector3( 0.5f, -0.5f, 0.0f); Vector3(0.09f, 0.09f, 0.44f);
Vector3( 0.0f, 0.5f, 0.0f); Vector3(0.00f, 1.00f, 0.50f);
|]
this.DrawingData <- this.LoadData(vertices)
Drawing The Triangle
One last step, actually drawing the triangle. Add the following three lines to the RenderFrameEvent
method.
GL.UseProgram(this.Ids.ProgramId)
GL.BindVertexArray(this.DrawingData)
GL.DrawArrays(PrimitiveType.Triangles, 0, 3)
This tells OpenGL to use the program with the attached shaders, binds the VertexArray
with the data, and than tells it to use the first three of the data points to draw triangles.
Run It!
Run FAKE: Build
to build the project and than run the exe in the build
folder of the project. The result should look like this:
User Input
So that was a bit of a trek, but now we have something on the screen again. Still, we lost some functionality, specific user input. We can do that pretty easier at this point.
We can start by defining a game state type.
type GameState =
{
shouldContinue: bool;
position: float32*float32;
}
We are going to use the position
parameter, which holds an (x,y) coordinate tuple, to move the triangle around. The shouldContinue
bool behaves like it did in part one. We can define a couple methods to accomplish this.
let updatePosition (dx,dy) (x,y) =
x+dx, y+dy
let move delta state =
{ state with position = updatePosition delta state.position }
let deltaX = 0.1f
let deltaY = 0.1f
let moveRight state =
move (deltaX, 0.0f) state
let moveLeft state =
move (-1.0f * deltaX, 0.0f) state
let moveUp state =
move (0.0f, deltaY) state
let moveDown state =
move (0.0f, -1.0f * deltaY) state
let exitGame gameState =
{gameState with shouldContinue = false}
Now we need to call these based on user input. A function that takes the keyboard state and the current game state and returns the new game state seems like a good starting point.
let updateState (keyState: KeyboardState) gameState =
match keyState with
| ks when ks.IsKeyDown(Key.Escape) -> exitGame gameState
| ks when ks.IsKeyDown(Key.Right) -> moveRight gameState
| ks when ks.IsKeyDown(Key.Left) -> moveLeft gameState
| ks when ks.IsKeyDown(Key.Up) -> moveUp gameState
| ks when ks.IsKeyDown(Key.Down) -> moveDown gameState
| _ -> gameState
Now the MyGameWindow
object will need to be updated to have a game state and call updateState
in updateFrame
. Instead of having it call it directly, we can inject the method in the constructor (along with the initial game state, I told you we would get back to that!).
type MyGameWindow(initialState, onUpdateFrame) as this =
member val GameObject: GameState = initialState with get,set
/// <summary>
/// The main entry point for the application.
/// </summary>
[<EntryPoint>]
let main argv =
let initialState =
{
shouldContinue = true;
position = 0.0f, 0.0f;
}
let gw = new MyGameWindow(initialState, updateState)
do gw.Run(60.0)
0
The final step is to update the game state on UpdateFrame
.
do this.UpdateFrame.Add(fun e ->
let keyState = this.Keyboard.GetState()
this.GameObject <- onUpdateFrame keyState this.GameObject
let x, y = this.GameObject.position
let vertices =
[| //Postion | Colors
Vector3(-0.5f + x, -0.5f + y, 0.0f); Vector3(1.00f, 1.00f, 0.94f);
Vector3( 0.5f + x, -0.5f + y, 0.0f); Vector3(0.09f, 0.09f, 0.44f);
Vector3( 0.0f + x, 0.5f + y, 0.0f); Vector3(0.00f, 1.00f, 0.50f);
|]
this.DrawingData <- this.LoadData(vertices)
match this.GameObject.shouldContinue with
|false -> this.Exit()
|true -> ()
)
I want to take a second and talk a bit about the organization of the code as it stands. All the work associated with drawing, except the drawing itself, is done in the UpdateFrame
method rather than the RenderFrame
. From what I read about OpenGL this is pretty important. Another thing I want to draw attention to is the fact that all the vertices and shaders and all that is basically hardcoded right now. We would need to create some classes or encapsulate it somehow to better allow the drawing of multiple types of objects. Basically, turning the game data into a list of vertices and a list of parameters for the DrawArray
method.
Some stuff to think about when you enjoy this gif.
Comments
Leave a comment