In this post we will look at how to get basic text on screen so that we can display the score of the game to the player.
This is part 14 of my series on OpenGL4 with OpenTK.
For other posts in this series:
OpenGL 4 with OpenTK in C# Part 1: Initialize the GameWindow
OpenGL 4 with OpenTK in C# Part 2: Compiling shaders and linking them
OpenGL 4 with OpenTK in C# Part 3: Passing data to shaders
OpenGL 4 with OpenTK in C# Part 4: Refactoring and adding error handling
OpenGL 4 with OpenTK in C# Part 5: Buffers and Triangle
OpenGL 4 with OpenTK in C# Part 6: Rotations and Movement of objects
OpenGL 4 with OpenTK in C# Part 7: Vectors and Matrices
OpenGL 4 with OpenTK in C# Part 8: Drawing multiple objects
OpenGL 4 with OpenTK in C# Part 9: Texturing
OpenGL 4 with OpenTK in C# Part 10: Asteroid Invaders
Basic bullet movement patterns in Asteroid Invaders
OpenGL 4 with OpenTK in C# Part 11: Mipmap
OpenGL 4 with OpenTK in C# Part 12: Basic Moveable Camera
OpenGL 4 with OpenTK in C# Part 13: IcoSphere
OpenGL 4 with OpenTK in C# Part 14: Basic Text
OpenGL 4 with OpenTK in C# Part 15: Object picking by mouse
As stated in the previous post, I am in no way an expert in OpenGL. I write these posts as a way to learn and if someone else finds these posts useful then all the better :)
If you think that the progress is slow, then know that I am a slow learner :P
This part will build upon the game window and shaders from part 13.
Generate font texture
First off, we need to generate a texture for the font that we want to use. For simplicity, all letters that we will need are put on a single line and each letter will get a fixed size rectangle in the texture.private const string Characters = @"qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM0123456789µ§½!""#¤%&/()=?^*@£€${[]}\~¨'-_.:,;<>|°©®±¥"; public Bitmap GenerateCharacters(int fontSize, string fontName, out Size charSize) { var characters = new List<Bitmap>(); using (var font = new Font(fontName, fontSize)) { for (int i = 0; i < Characters.Length; i++) { var charBmp = GenerateCharacter(font, Characters[i]); characters.Add(charBmp); } charSize = new Size(characters.Max(x => x.Width), characters.Max(x => x.Height)); var charMap = new Bitmap(charSize.Width * characters.Count, charSize.Height); using (var gfx = Graphics.FromImage(charMap)) { gfx.FillRectangle(Brushes.Black, 0, 0, charMap.Width, charMap.Height); for (int i = 0; i < characters.Count; i++) { var c = characters[i]; gfx.DrawImageUnscaled(c, i * charSize.Width, 0); c.Dispose(); } } return charMap; } } private Bitmap GenerateCharacter(Font font, char c) { var size = GetSize(font, c); var bmp = new Bitmap((int)size.Width, (int)size.Height); using (var gfx = Graphics.FromImage(bmp)) { gfx.FillRectangle(Brushes.Black, 0, 0, bmp.Width, bmp.Height); gfx.DrawString(c.ToString(), font, Brushes.White, 0, 0); } return bmp; } private SizeF GetSize(Font font, char c) { using (var bmp = new Bitmap(512, 512)) { using (var gfx = Graphics.FromImage(bmp)) { return gfx.MeasureString(c.ToString(), font); } } }
The GenerateCharacters method takes the name of the font that we want to use, the size of the font and outputs the size of a single character in the returned texture. So white on black.
Example output from above algorithm. Original texture was 3432x48 pixels |
Render Objects
Basically we want to render each character in a quad, so we need to generate a model with 2 triangles that can be reused. So into our RenderObjectFactory we add the following:public static TexturedVertex[] CreateTexturedCharacter() { float h = 1; float w = RenderText.CharacterWidthNormalized; float side = 1f / 2f; // half side - and other half TexturedVertex[] vertices = { new TexturedVertex(new Vector4(-side, -side, side, 1.0f), new Vector2(0, h)), new TexturedVertex(new Vector4(side, -side, side, 1.0f), new Vector2(w, h)), new TexturedVertex(new Vector4(-side, side, side, 1.0f), new Vector2(0, 0)), new TexturedVertex(new Vector4(-side, side, side, 1.0f), new Vector2(0, 0)), new TexturedVertex(new Vector4(side, -side, side, 1.0f), new Vector2(w, h)), new TexturedVertex(new Vector4(side, side, side, 1.0f), new Vector2(w, 0)), }; return vertices; }It is the front facing quad from the generate cube method already there.
The RenderText.CharacterWidthNormalized is returning the 1/number of characters to get the correct alignment of the x-axis.
We will be needing 2 new render objects to accomplish putting text on the screen. RenderText that handles the whole string to be rendered, and RenderCharacter that handles each individual character in the string.
public class RenderText : AGameObject { private readonly Vector4 _color; public const string Characters = @"qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM0123456789µ§½!""#¤%&/()=?^*@£€${[]}\~¨'-_.:,;<>|°©®±¥"; private static readonly Dictionary<char, int> Lookup; public static readonly float CharacterWidthNormalized; // 21x48 per char, public readonly List<RenderCharacter> Text; static RenderText() { Lookup = new Dictionary<char, int>(); for (int i = 0; i < Characters.Length; i++) { if (!Lookup.ContainsKey(Characters[i])) Lookup.Add(Characters[i], i); } CharacterWidthNormalized = 1f / Characters.Length; } public RenderText(ARenderable model, Vector4 position, Color4 color, string value) : base(model, position, Vector4.Zero, Vector4.Zero, 0) { _color = new Vector4(color.R, color.G, color.B, color.A); Text = new List<RenderCharacter>(value.Length); _scale = new Vector3(0.02f); SetText(value); } public void SetText(string value) { Text.Clear(); for (int i = 0; i < value.Length; i++) { int offset; if (Lookup.TryGetValue(value[i], out offset)) { var c = new RenderCharacter(Model, new Vector4(_position.X + (i * 0.015f), _position.Y, _position.Z, _position.W), (offset*CharacterWidthNormalized)); c.SetScale(_scale); Text.Add(c); } } } public override void Render(ICamera camera) { _model.Bind(); GL.VertexAttrib4(3, _color); for (int i = 0; i < Text.Count; i++) { var c = Text[i]; c.Render(camera); } } }From the top.
Characters string containing all the characters in our font texture in the correct order.
Static constructor that initializes the lookup table, mapping each character to its index. Dictionary for faster lookup then doing the index of operation during for each string we want to show.
Instance constructor, just decomposes the Color struct to a Vector4, I still don't know why the GL.VertexAttrib4 doesn't support the Color struct out of the box.
SetText, allows for changing the contents of this RenderText object. This is a naive implementation that empties all content and then adds new. Optimization could be to try re-use whatever objects that already are in the collection. But for now, this works for me.
Render, just sets the color attribute and calls render for all RenderCharacters.
Next, all characters in the string
public class RenderCharacter : AGameObject { private float _offset; public RenderCharacter(ARenderable model, Vector4 position, float charOffset) : base(model, position, Vector4.Zero, Vector4.Zero, 0) { _offset = charOffset; _scale = new Vector3(0.2f); } public void SetChar(float charOffset) { _offset = charOffset; } public override void Render(ICamera camera) { GL.VertexAttrib2(2, new Vector2(_offset, 0)); var t2 = Matrix4.CreateTranslation( _position.X, _position.Y, _position.Z); var s = Matrix4.CreateScale(_scale); _modelView = s * t2 * camera.LookAtMatrix; GL.UniformMatrix4(21, false, ref _modelView); _model.Render(); } }The key component for a character is the x-axis offset in the texture. The Render method just binds the offset attribute to the shader and renders the quad holding the character. At the moment the character is transformed with a Model-View matrix.
Shaders
Vertex Shader#version 450 core layout(location = 0) in vec4 position; layout(location = 1) in vec2 textureCoordinate; layout(location = 2) in vec2 textureOffset; layout(location = 3) in vec4 color; out vec2 vs_textureOffset; out vec4 vs_color; layout(location = 20) uniform mat4 projection; layout (location = 21) uniform mat4 modelView; void main(void) { vs_textureOffset = textureCoordinate + textureOffset; gl_Position = projection * modelView * position; vs_color = color; }The first 2 inputs to our vertex shader are bound from buffers, and we introduce 2 new inputs that we set from the render methods. The texture offset and color.
We also need to send the color forward to the fragment shader so we need an out parameter for that.
The vs_textureOffset is the original texture coordinate plus the new offset to find a character. The original texture coordinate the X-axis was of the width of 1 character and that's why this works.
Fragment Shader
#version 450 core in vec2 vs_textureOffset; in vec4 vs_color; uniform sampler2D textureObject; out vec4 color; void main(void) { vec4 alpha = texture(textureObject, vs_textureOffset); color = vs_color; color.a = alpha.r; }The vertex shader reads the texture, as it is black and white the red, green and blue channels should have the same values, hence we can look at just one of them and set the alpha channel of our color to get transparency. i.e. the color just sticks to the characters and we cut out everything else.
Game Window
OnLoadWe need to setup some stuff in the OnLoad method of our game window. First out is to setup the new shaders needed to render our text objects.
_textProgram = new ShaderProgram(); _textProgram.AddShader(ShaderType.VertexShader, @"Components\Shaders\1Vert\textVert.c"); _textProgram.AddShader(ShaderType.FragmentShader, @"Components\Shaders\5Frag\textFrag.c"); _textProgram.Link();
Nothing fancy there, next to load our texture and add it to the list of models (for correct disposal)
var textModel = new TexturedRenderObject(RenderObjectFactory.CreateTexturedCharacter(), _textProgram.Id, @"Components\Textures\font singleline.bmp"); models.Add("Quad", textModel);
As we do this in our Asteroid Invaders game, we will be displaying the score. So we need a variable to store this in.
_text = new RenderText(models["Quad"], new Vector4(-0.2f, 0.1f, -0.4f, 1), Color4.Red, "Score");
And lastly, we need to enable transparency. Otherwise the alpha blending wouldn't bite and we would have a black background instead.
GL.Enable(EnableCap.Blend); GL.BlendFunc(BlendingFactorSrc.SrcAlpha, BlendingFactorDest.OneMinusSrcAlpha);
OnRenderFrame
The major changes to our OnRenderFrame method consists of updating our score and adding a second render step after all normal game objects for rendering of our transparent objects. This because we need to have whatever we want to show in the transparent areas to be drawn before our text. Otherwise we would get the dark blue background color as a box for all characters.
protected override void OnRenderFrame(FrameEventArgs e) { Title = $"{_title}: FPS:{1f / e.Time:0000.0}, obj:{_gameObjects.Count}, score:{_score}"; _text.SetText($"Score: {_score}"); GL.ClearColor(_backColor); GL.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); int lastProgram = -1; foreach (var obj in _gameObjects) { lastProgram = RenderOneElement(obj, lastProgram); } // render after all opaque objects to get transparency right RenderOneElement(_text, lastProgram); SwapBuffers(); } private int RenderOneElement(AGameObject obj, int lastProgram) { var program = obj.Model.Program; if (lastProgram != program) GL.UniformMatrix4(20, false, ref _projectionMatrix); lastProgram = obj.Model.Program; obj.Render(_camera); return lastProgram; }
Further work
This is not a fully featured way to render text, but enough to get a score on the screen. There's a lot of things that could be done to improve it and here comes a few:As the solution is based on a bitmap, it scales kind of bad. There exists a lot of material on how to make that smooth and something that I will look at some time in the future. Distance fields is an area that seems promising and that takes advantage of the GPU. More information in the following links if you want to give it a try:
- Alpha Tested Magnification, by Chris Green at Valve
- Drawing Text with Signed Distance Fields in Mapbox GL, by Konstantin Käfer at Mapbox
Positioning on the screen. Today the solution uses world coordinates, works for this static camera scene but not so well with a moving camera if you still want to have the text visible to the user.
Text wrapping, multi row text. Better scaling.
The list goes on and I'll probably end up coding some of the stuff on the list whenever I feel a need for it.
End results
So there, thank you for reading. Hope this helps someone out there : )