Let’s create a mobile game with Xamarin and OpenGL – Part 3 (Textures)

In this part we will continue with a more advanced shader which will allow us to draw rectangles with textures also known as sprites. Sprites are a main feature for simple 2D games and we can already achieve a lot by using them. You can use them for simple images, animations and also for rendering text.

Create shaders

Create a new file for the fragment shader “Resources/Shaders/TextureShader.fsh” in “Engine.Shared”:

#version 300 es in highp vec2 textureCoordinate; out lowp vec4 fragColor; uniform lowp sampler2D text; uniform lowp vec4 color; void main() { fragColor = texture(text, textureCoordinate) * color; }

Compared to the “ColorShader we defined a new parameter “text” which represents the texture and parameter “textureCoordinate” which tells us which position of the texture should be used. You can see that the “fragColor” which is the pixel, is set from the current texture now and is also multiplied with an additional color. If the additional color is white the texture will be rendered as it is. But we can use this color to dye the texture as well.

For the vertex shader a new file “Resources/Shaders/TextureShader.vsh”

#version 300 es in vec4 vertex; in vec2 texcoord; out vec2 textureCoordinate; uniform mat4 modelViewProjection; void main() { gl_Position = modelViewProjection * vertex; textureCoordinate = texcoord; }

Compared to the “ColorShader” we also add the texture coordinates as a 2 dimensional vector here. It will tell the shader how to position the texture on the vertex.

Loading textures

To load textures we first need to make “FileManager.cs” in “Engine.Shared” project abstract and add some methods:

namespace Engine.Shared.Managers { public abstract class FileManager { ... public byte[] GetResourceBytes(Assembly assembly, string fileName) { Stream stream = GetResourceStream(assembly, fileName); byte[] bytes; using (MemoryStream ms = new MemoryStream()) { stream.CopyTo(ms); bytes = ms.ToArray(); } return bytes; } public abstract (byte[] Data, int Width, int Height) LoadTexture(Assembly assembly, string path); } }

We also added a method “GetResourceBytes” which will return the a byte array of a resource. Also an abstract method “LoadTexture” was added which has to be implemented for each operating system.

Now we create an implementation of the FileManager for Android. Therefor create a new class “AndroidFileManager.cs” in project “Engine.Android”:

using Android.Graphics; using Engine.Shared.Managers; using System.Reflection; using System.Runtime.InteropServices; namespace Engine.Android { public class AndroidFileManager : FileManager { public override (byte[] Data, int Width, int Height) LoadTexture(Assembly assembly, string path) { byte[] imageData = GetResourceBytes(assembly, path); Bitmap bitmap = BitmapFactory.DecodeByteArray(imageData, 0, imageData.Length); int width = bitmap.Width; int height = bitmap.Height; byte[] data = GetPixelArray(bitmap); bitmap.Recycle(); return (data, width, height); } private static byte[] GetPixelArray(Bitmap bitmap) { int bytesPerPixel = 4; int width = bitmap.Width; int height = bitmap.Height; int size = width * height * bytesPerPixel; byte[] pixelData = new byte[size]; var byteBuffer = Java.Nio.ByteBuffer.AllocateDirect(size); bitmap.CopyPixelsToBuffer(byteBuffer); Marshal.Copy(byteBuffer.GetDirectBufferAddress(), pixelData, 0, size); byteBuffer.Dispose(); return pixelData; } } }

Since we need specific classes from Android to load a Bitmap, this requires a platform specific implementation. This implementation will basically just load any image into a pixel array.

Now we need to send this pixel array to the graphics card as well. For this we create a class “Texture.cs” in project “Engine.Shared” first, which will hold the dimensions of the texture and the id of the texture on the graphics card.

namespace Engine.Shared { public class Texture { public int Id { get; private set; } public float Width { get; private set; } public float Height { get; private set; } public Texture(int id, float width, float height) { Id = id; Width = width; Height = height; } } }

Now create a class “Managers/TextureManager.cs” in the project “Engine.Shared”:

using OpenTK.Graphics.ES30; using System; using System.Collections.Generic; using System.Reflection; namespace Engine.Shared.Managers { public class TextureManager : IDisposable { private readonly List<int> textureIds = new List<int>(); private readonly FileManager fileManager; public TextureManager(FileManager fileManager) { this.fileManager = fileManager; } public Texture Load(Assembly assembly, string path) { int textureId = GL.GenTexture(); textureIds.Add(textureId); GL.BindTexture(TextureTarget.Texture2D, textureId); // Setup texture parameters GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMagFilter.Nearest); GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Nearest); GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat); GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat); var texture = fileManager.LoadTexture(assembly, path); // Required to enable alpha transparency on textures PremultiplyAlpha(texture.Data); GL.TexImage2D(TextureTarget.Texture2D, 0, PixelInternalFormat.Rgba, texture.Width, texture.Height, 0, PixelFormat.Rgba, PixelType.UnsignedByte, texture.Data); GL.GenerateMipmap(TextureTarget.Texture2D); return new Texture(textureId, texture.Width, texture.Height); } private static void PremultiplyAlpha(byte[] data) { for (int i = 0; i < data.Length; i += 4) { int alpha = data[i + 3]; // A float factor = alpha > 0 ? 255f / alpha : 1f; data[i] = (byte)(data[i] * factor); // R data[i + 1] = (byte)(data[i + 1] * factor); // G data[i + 2] = (byte)(data[i + 2] * factor); // B } } public void Dispose() { foreach (int textureId in textureIds) { GL.DeleteTexture(textureId); } textureIds.Clear(); } public void Dispose(int textureId) { GL.DeleteTexture(textureId); textureIds.Remove(textureId); } } }

This class will use the “FileManager” to load the texture and send it to the graphics card. It will also set the color format and apply some options to let us use 32bit textures with alpha transparency. It will result in an id, which we can use to later assign the texture to an object.

Create the game

Create a new “Shared project” named “MyGame.Shared” project. This project will contain files that are related to our game. We will start this project by adding an image that is related to the game we want to create. Add the folders “Resources > Images” and add an image file “Cross.png” there:

Make sure in the properties of this file the “Build Action” is “Embedded resource”.

Create renderer

Now we have everything ready to create a renderer for textured sprites. Create “Renderers/SpriteRenderer.cs” in project “Engine.Shared”:

using OpenTK; using System; using Engine.Shared.Drawables; using OpenTK.Graphics.ES30; using Engine.Shared.Managers; namespace Engine.Shared.Renderers { public class SpriteRenderer { private int vbi = 0; // Is the same for every sprite internal static readonly ushort[] indices = { 0, 1, 2, 1, 3, 2 }; private readonly BufferManager bufferManager; private readonly TextureManager textureManager; private readonly TextureShader shader; public SpriteRenderer(BufferManager bufferManager, TextureManager textureManager, TextureShader shader) { this.bufferManager = bufferManager; this.textureManager = textureManager; this.shader = shader; } public DrawableSprite Create(Texture texture) { return Create(texture, texture.Width, texture.Height, true); } public DrawableSprite Create(Texture texture, float width, float height, bool fit = true) { float tx = 1f; float ty = 1f; if (!fit) { tx = width / texture.Width; ty = height / texture.Height; } return Create(width, height, texture.Id, tx, ty); } public DrawableSprite Create(float width, float height, int textureId, float tx = 1, float ty = 1) { // In OpenGL textures are rendered upside down so we need to flip coordinates // x, y, z, tx, ty float[] vertices = { 0f, 0f, 0f, 0f, ty, width, 0f, 0f, tx, ty, 0f, height, 0f, 0f, 0f, width, height, 0f, tx, 0f }; int vbo = bufferManager.Load(BufferTarget.ArrayBuffer, vertices); if (vbi == 0) { vbi = bufferManager.Load(BufferTarget.ElementArrayBuffer, indices); } DrawableSprite sprite = new DrawableSprite(vbo, width, height, textureId); return sprite; } public void Dispose(DrawableSprite sprite) { textureManager.Dispose(sprite.TextureId); bufferManager.Dispose(sprite.VboId); } public void Render(DrawableSprite sprite, Vector4 color, Matrix4 modelViewProjection) { if (sprite.VboId == 0) { return; } // Set model view projection GL.UniformMatrix4(shader.UniformModelViewProjection, false, ref modelViewProjection); // Set color GL.Uniform4(shader.UniformColor, color); // Set texture GL.BindTexture(TextureTarget.Texture2D, sprite.TextureId); // Update attribute value Position GL.BindBuffer(BufferTarget.ArrayBuffer, sprite.VboId); GL.VertexAttribPointer(shader.AttribVertex, 3, VertexAttribPointerType.Float, false, sizeof(float) * 5, IntPtr.Zero); // 3 + 2 = 5 GL.EnableVertexAttribArray(shader.AttribVertex); // Update attribute value TexCoord GL.VertexAttribPointer(shader.AttribTexCoord, 2, VertexAttribPointerType.Float, false, sizeof(float) * 5, new IntPtr(sizeof(float) * 3)); GL.EnableVertexAttribArray(shader.AttribTexCoord); // Draw one instance indexed GL.DrawElements(BeginMode.Triangles, indices.Length, DrawElementsType.UnsignedShort, IntPtr.Zero); } public void BeforeRender() { // Use shader program. GL.UseProgram(shader.Program); // Enable transparency GL.Enable(EnableCap.Blend); GL.BlendFunc(BlendingFactorSrc.SrcAlpha, BlendingFactorDest.OneMinusSrcAlpha); // Use texture GL.ActiveTexture(TextureUnit.Texture0); GL.Uniform1(shader.UniformTexture, 0); GL.BindBuffer(BufferTarget.ElementArrayBuffer, vbi); } public void AfterRender() { // Unbind / Disable GL.BindBuffer(BufferTarget.ArrayBuffer, 0); GL.BindBuffer(BufferTarget.ElementArrayBuffer, 0); GL.Disable(EnableCap.Blend); } } }

This class looks similar to the “RectangleRenderer” from part 2. It has some notable differences though. The vertices now contain texture coordinates as well. So for each vertex there is also a definition for the x and y position of the texture. Also in the rendering methods the texture is bound and activated.

Bringing it together

We have to make some changes to “GameController.cs”:

using Engine.Shared.Drawables; using Engine.Shared.Managers; using Engine.Shared.Renderers; using OpenTK; using OpenTK.Graphics.ES30; using System; using System.Reflection; namespace Engine.Shared { public class GameController : IDisposable { ... private readonly TextureManager textureManager; private readonly SpriteRenderer spriteRenderer; private readonly Assembly gameAssembly; ... private TextureShader textureShader = new TextureShader(); ... private DrawableSprite sprite1; public GameController(FileManager fileManager, Assembly gameAssembly) { this.fileManager = fileManager; ... this.textureManager = new TextureManager(fileManager); this.spriteRenderer = new SpriteRenderer(bufferManager, textureManager, textureShader); this.gameAssembly = gameAssembly; } public void Load() { ... ShaderHelper.LoadShader(textureShader, fileManager.LoadText(engineAssembly, "Resources.Shaders.TextureShader.vsh"), fileManager.LoadText(engineAssembly, "Resources.Shaders.TextureShader.fsh"), (shader) => { GL.BindAttribLocation(shader.Program, shader.AttribVertex, "vertex"); GL.BindAttribLocation(shader.Program, shader.AttribTexCoord, "texcoord"); }, (shader) => { shader.UniformModelViewProjection = GL.GetUniformLocation(shader.Program, "modelViewProjection"); shader.UniformTexture = GL.GetUniformLocation(shader.Program, "text"); shader.UniformColor = GL.GetUniformLocation(shader.Program, "color"); }); ... sprite1 = spriteRenderer.Create(textureManager.Load(gameAssembly, "Resources.Images.Cross.png")); } public void Render() { ... spriteRenderer.BeforeRender(); spriteRenderer.Render(sprite1, new Vector4(0.5f, 0.5f, 1f, 1f), viewProjection); spriteRenderer.AfterRender(); } public void Dispose() { ... if (textureManager != null) { textureManager.Dispose(); } } } }

First we need to make the “FileManager” and “Assembly” injectable. Then we create instances of “TextureManager” and “SpriteRenderer”.

In the “Load” method we can now load the “TextureShader” which will allow us to render textures. Then we create an instance of a “DrawableSprite” by using the path to the image we added in the new shared project in the previous step.

In the “Render” method we draw the sprite with a slightly blue color.

In the “Dispose” method we also release the textures.

We also need to inject the two classes into the “GameController” in “MainActivity.cs” file in “MyGame.Android” project:

... using System.Reflection; namespace MyGame.Android { [Activity(Label = "@string/app_name", Theme = "@style/AppTheme", MainLauncher = true)] public class MainActivity : AppCompatActivity { ... protected override void OnCreate(Bundle savedInstanceState) { ... view.Initialize(new GameController(new AndroidFileManager(), Assembly.GetExecutingAssembly())); ... } } }

If you run the application on your phone now you should see the following screen:

The cross is now rendered in front of the the colored rectangles because we draw it last. You can also see that alpha transparency is working and the green rectangle behind is still visible.

You can download the source code of this article here: