low-level drawing API in Ebitengine
I have been using the game engine for several years now Ebitenginebut previously I was able to get by with only a high-level method of drawing objects – DrawImage.
Function DrawTriangles seemed not very clear to a person who was used to the concept of “there is a sprite – you can draw”.
Now I have started to have tasks for which DrawTriangles is perfect. Today I will tell you when and how to use this function.
Let's figure out what kind of triangles these are and what they are eaten with.
Preface
My new game required a particle system to implement some visual effects.
Since a quick search awesome-ebitengine did not give results, I decided to try to sketch my own. I took it as a reference CPUParticles2DI compare the performance with it.
The library itself is not ready yet, but I will definitely write about it someday. In the meantime, let's find out how DrawTriangles can help in its implementation.
Adding a game to your wishlist will help me continue working on new libraries to develop games in Go, create new training materials (including articles on Habré), and support the community.
DrawImage
Let's start with the most basic drawing to make it easier to understand what the pros and cons of different options are.
Image — is one of the most fundamental and ubiquitous types in Ebitengine. Conceptually, an Image is a rectangular texture (a two-dimensional array of pixels).
Image can be created programmatically, or loaded from some PNG. At the level of image abstraction, we have almost pixel-by-pixel access.
Ebitengine manages automatic atlases for us. Different Images can be part of one large atlas, which will be located on the video card.
Each game on Ebitengint implements an interface Game. This interface has a Draw method:
type Game interface {
Draw(screen *Image)
// ...остальное
}
The game must “draw” all its graphic objects onto the Image passed to the input (screen parameter) during each Draw call.
Let's say we have []*ebiten.Image
which we loaded from PNG files. Rendering these objects will be quite simple:
// Вместо обычного *ebiten.Image, у нас будут обёртки
// с дополнительными полями, типа позиции на экране.
type object struct {
image *ebiten.Image
pos [2]float64 // {X, Y}
}
func (g *myGame) Draw(screen *ebiten.Image) {
for _, o := range g.objects {
var opts ebiten.DrawImageOptions
opts.GeoM.Translate(o.pos[0], o.pos[1])
screen.DrawImage(o.image, &opts)
}
}
If you add several objects to myGame, the game will display them on the screen, taking into account their positions:
In opts you can configure rendering: set color scale, define position on screen, change size (scaling), and so on.
There is a more advanced version of this function – DrawRectShader. Although Image is not in the name, I believe this function is at the same level of abstraction.
The rectangle for DrawRectShaderOptions is most often Bounds images. Multiple input textures can be passed through options for sampling within the shader.
Combined with shaders and the ability to create Images on the fly, as well as rich rendering options, you can get by with just a high-level API for most indie Go games.
DrawImage is so optimized that in some cases it works more efficiently than the much less familiar DrawTriangles! To understand why we need DrawTriangles then, it's worth finding its strengths.
In the repository github.com/quasilyte/ebitengine-draw-example you can find the entire project, with all the examples. You can run them via go run:
$ git clone https://github.com/quasilyte/ebitengine-draw-example.git
$ cd ebitengine-draw-example
$ go run ./cmd/drawimage
$ go run ./cmd/drawshader
# ...
Full code listing: github.com/quasilyte/ebitengine-draw-example/cmd/drawimage
DrawTriangles
Compare the signatures of the two functions:
// Немного сократил имена, чтобы уместилось по ширине.
DrawImage (img *Image, options *DrawImageOpts)
DrawTriangles(v []Vertex, i []uint16, img *Image, o *DrawTrianglesOpts)
For people who are used to working with video cards through low-level drivers, everything is probably clear. But personally, I mastered game development not by drawing triangles through OpenGL, but by creating games on engines like Game Maker.
Let's try to draw the images from the example above without DrawImage:
// Я поменял float64 на float32 чтобы уменьшить количество
// преобразований типов ниже. В реальной игре я бы так
// делать не рекомендовал.
type object struct {
image *ebiten.Image
pos [2]float32
}
func (g *myGame) Draw(screen *ebiten.Image) {
for _, o := range g.objects {
img := o.image
iw, ih := img.Size()
w := float32(iw)
h := float32(ih)
// Здесь было бы много преобразований float64->float32,
// но я отредактировал object так, чтобы уместить
// код по ширине для комфорта хабравчан.
vertices := []ebiten.Vertex{
{
DstX: o.pos[0], DstY: o.pos[1],
SrcX: 0, SrcY: 0,
ColorR: 1, ColorG: 1,
ColorB: 1, ColorA: 1,
},
{
DstX: o.pos[0] + w, DstY: o.pos[1],
SrcX: float32(w), SrcY: 0,
ColorR: 1, ColorG: 1,
ColorB: 1, ColorA: 1,
},
{
DstX: o.pos[0], DstY: o.pos[1] + h,
SrcX: 0, SrcY: h,
ColorR: 1, ColorG: 1,
ColorB: 1, ColorA: 1,
},
{
DstX: o.pos[0] + w, DstY: o.pos[1] + h,
SrcX: w, SrcY: h,
ColorR: 1, ColorG: 1,
ColorB: 1, ColorA: 1,
},
}
// Эти индексы говорят, как построить треугольники из вершинок выше.
// Для прямоугольной формы достаточно двух треугольников.
// Каждый из этих треугольников описывается индексом
// нужной вершинки. Отсюда и название DrawTriangles.
indices := []uint16{
0, 1, 2, // Первый треугольник
1, 2, 3, // Второй треугольник
}
var opts ebiten.DrawTrianglesOptions
screen.DrawTriangles(vertices, indices, img, &opts)
}
}
There is little practical sense in this. We will most likely not get any acceleration. Also, slices for vertices and indexes now need to be managed by ourselves (preferably reused).
However, this experience using DrawTriangles is already enough to move on to the next step. The problem is not exactly how we use DrawTriangles, but what we use it for.
Full code listing: github.com/quasilyte/ebitengine-draw-example/cmd/drawtriangles_single
Benefits and harms of DrawTriangles
As noted above, there is no point in replacing all DrawImages with DrawTriangles. For single images, DrawImage works quite well.
There are at least two cases when DrawTriangles will be appropriate:
- Generation of graphics via shader (DrawTrianglesShader)
- Drawing multiple identical images at once in one call
In theory, the second should already be partially be optimized by the engine. Calling several DrawImages in a row for one image will be efficient. But these calls may have additional overhead that can be avoided by doing some of the work on your end.
Shader graphics
Let's say we want to draw a circle through a shader. If we ignore the functions that require us to use triangles, all that remains is DrawRectShader.
For this function to work, we need a source image, since it is assumed that we want to transform the pixels of the source image inside the shader and draw the result on the destination image.
Here is an example of converting an image with a planet texture to its final appearance:
Another limitation of DrawRectShader is that all additional input images must be the same size as the main source. This makes it difficult to use noise sources, since they have to be scaled to this size.
Therefore, if DrawRectShader is used to generate shader graphics:
- You will have to create an empty image of the required size (source)
- All additional textures must be brought to the same size
A blank image will take up the same amount of space as a regular image, it's just that all the pixels there will have a color
vec4(0, 0, 0, 0)
.
Typically the second step means that the noise texture is of a size that is sufficient for any other image, and before passing them to DrawRectShader, SubImage.
Using DrawTrianglesShader you can solve both of these problems.
Below are two shaders for generating graphics: the first will draw a rectangle, and the second will draw a circle.
//kage:unit pixels
//go:build ignore
package main
func Fragment(_ vec4, pos vec2, _ vec4) vec4 {
return vec4(0.2, 0.2, 0.7, 1)
}
//kage:unit pixels
//go:build ignore
package main
var Radius float
func Fragment(_ vec4, pos vec2, _ vec4) vec4 {
zpos := pos
r := Radius
center := vec2(r, r)
dist := distance(zpos, center)
if dist > r {
return vec4(0)
}
return vec4(0.4, 0.7, 0.9, 1)
}
Since there will be two entities, it is better to move the rendering to a separate function that will work for either of them.
// Эта структура описывает необходимые для отрисовки параметры,
// которые можно заполнить как для окружности, так и для прямоугольника.
type drawShaderOptions struct {
pos [2]float32
shader *ebiten.Shader
width float32
height float32
uniforms map[string]any
}
func (g *myGame) drawShader(dst *ebiten.Image, opts drawShaderOptions) {
pos := opts.pos
w := opts.width
h := opts.height
// Будем рисовать относительно центра.
pos[0] -= w / 2
pos[1] -= h / 2
vertices := []ebiten.Vertex{
// Здесь уже знакомый нам код с заполнением 4 вершинок.
}
indices := []uint16{0, 1, 2, 1, 2, 3}
var drawOptions ebiten.DrawTrianglesShaderOptions
drawOptions.Uniforms = opts.uniforms
dst.DrawTrianglesShader(vertices, indices, opts.shader, &drawOptions)
}
You need to call drawShader from the game Draw:
func (g *myGame) Draw(screen *ebiten.Image) {
for _, r := range g.rects {
g.drawShader(screen, drawShaderOptions{
shader: r.shader,
pos: r.pos,
width: r.width,
height: r.height,
})
}
for _, c := range g.circles {
g.drawShader(screen, drawShaderOptions{
shader: c.shader,
uniforms: c.uniforms,
pos: c.pos,
width: 2 * c.radius,
height: 2 * c.radius,
})
}
}
The result will be squares and circles:
Full code listing: github.com/quasilyte/ebitengine-draw-example/cmd/drawshader
Rendering multiple objects at once
Let's do the same thing in a new way. Instead of several calls to DrawTriangles, let's fit the entire rendering into one.
In a large game, grouping many objects by their textures can be problematic, because they can be on different layers and the drawing order in this case will not be so simple.
I'll take as a basis a situation that fits perfectly into mass rendering: a particle system. Most often, all particles have the same texture, and their emitter is located on a specific graphic layer.
func (g *myGame) Draw(screen *ebiten.Image) {
// Здесь мы пользуемся фактом, что все объекты у нас используют
// одно и то же изображение.
img := g.objects[0].image
iw, ih := img.Size()
w := float32(iw)
h := float32(ih)
// Аллоцируем вершинки и индексы сразу для всех объектов.
vertices := make([]ebiten.Vertex, 0, 4*len(g.objects))
indices := make([]uint16, 0, 6*len(g.objects))
i := uint16(0)
for _, o := range g.objects {
vertices = append(vertices,
// Здесь уже знакомый нам код с заполнением 4 вершинок.
)
indices = append(indices,
i+0, i+1, i+2,
i+1, i+2, i+3,
)
i += 4 // Увеличиваем на количество vertices на объект
}
var opts ebiten.DrawTrianglesOptions
screen.DrawTriangles(vertices, indices, img, &opts)
}
In reality, there are some peculiarities to consider. For example, you shouldn't use more than MaxUint16/4 vertices, because otherwise you won't be able to address them in the indices array. To avoid this, batches should be split into pieces. In v3, Ebitengine plans to change the indices type from uint16 to uint32, so the problem will be less noticeable, although splitting into reasonably sized pieces may still be correct in terms of memory consumption.
Full code listing: github.com/quasilyte/ebitengine-draw-example/cmd/drawtriangles_batch
Comparing performance
I can't promise an exact comparison between DrawImage and DrawTriangles, but I can give preliminary results for my particle system. Absolute values are not so important here, since I have a rather weak machine, but comparing with the result on the same machine with the same Godot is valid.
The effect will be something similar to an explosion, generating 150-200 particles that fly apart in different directions at a speed in a certain range. The particles live for ~2 seconds. Each emitter creates such an explosion once per second.
For the baseline performance, let's take the CPU particles result from Godot: my laptop can handle about 200-220 particle generators on the screen. That's about 60k particles simulated on the screen at the same time.
The upper threshold is considered to be the moment when FPS drops from a stable 60 to ~50.
Below are the results for Ebitengine:
- DrawImage/particle: 110-120 generators
- DrawTriangles/batch: 190-200 generators
Each batch in the DrawTriangles version has a limited number of particles it can process. I've tried different values and settled on MaxUint16/16 for now. Maybe this threshold can be lowered further without losing throughput.
We are fixing it
- DrawImage – for single images, default selection
- DrawRectShader – when you need to add shader processing to an image
- DrawTriangles – if you need to have textures of different sizes
- DrawTriangles – when we draw many homogeneous objects at once
- DrawTrianglesShader — generating graphics with a shader (when a source image is not needed)
My games will likely still be dominated by DrawImage and DrawRectShader, but I now have a better understanding of how to solve certain problems more efficiently.
And if you don’t want to bother with manual draw, but want high-level sprites, then I recommend my library ebitengine-graphics.
PS – I haven’t forgotten about the “Making RPGs in Go” series, but the second part is already outdated due to some changes in my libraries that break backward compatibility. I plan to fix the second part first, and then start the third.
Now it’s easier for me to publish smaller articles that focus on specific aspects of game development in Go.
Interested in gamedev on Go? Visit us at telegram group!