Fourth - the 'Hello, World!' triangle
(2335 words)Productivity is always highest on day one when there are no problems to solve and no bugs to fix.
Well, I’ve decided to start up a little blog to discuss what I’ve been doing. I started this at about 10pm at night on the 15th February 2025. What is this project? Difficult to know at this point, but I have this little idea in my head that could become a game, or just a tool for others to be able to use, but I’d like it to be a fun thing to play with and therefore could become a game.
Most people, and by most people I don’t mean you the reader, don’t understand how traffic works. Traffic, which is really just about the movement of people, is complicated, chaotic, an hopelessly difficult to optimise. A lot of the city games out there end up being traffic management games, but without the tools available to players to really fix that traffic. Let’s work to fix it.
In other words, I’d like to try and build a game that is a city building game that emphasises traffic management, public transport, walking, cycling, goods movement, commuting, service delivery, over how pretty the buildings work. I’d also like it to have a full economic model, with companies buying goods from other companies, processing those goods into something else and then selling them to other companies or consumers.
Where did I start? Well, with a zig init
in an empty directory. What’s
that directory called? fourth
. Well, I’d already gone through first
,
second
, and third
already this year, so why no. And naming things is
mostly hard, pointless, and it was almost called elephant
. We can also
back-port the name to transportation, and there are only four types of
transportation modes that are relevant:
- walking / cycling (self propelled)
- wheeled vehicles (motorised with wheels)
- tracked vehicles (motorised using rails / canals)
- flying vehicles (very motorised above ground)
There are no other forms of transportation. Yes, somebody did invent vacuum tubes back in the 19th century - before powered flight existed as a way to get people under the atlantic ocean at 1800 km/h; which was then re-invented in the early 20th century - ironically a Russian professor built the first model of such a system in 1909; which was then invented for the first time again in the 21st century as a way for a car company executive to attempt to derail high speed rail in California from being built and therefore keep selling his cars. Of course, I’m skipping the whole Swissmetro concept in this very brief explanation because we are trying to justify why it is called ‘fourth’.
So what have I done so far?
Well, in the four hours on the first ‘day’ of coding, we have:
ng
- let’s just call it ’enn-gee’ - a bit likenginx
is ’engine’. It’s the graphics, user interface, audio, everything else library that we’ll be using, and writing along the way;build.zig
- the build file that is very generic;build.zig.zon
- which I haven’t touched;main.zig
- the main program
The main program draws a triangle, in a window. The code is very simple.
try ng.init(.{
.video = true,
.audio = true,
});
defer ng.deinit();
const window = try ng.create_window(.{
.name = "Fourth",
.width = 1920,
.height = 1080,
.resizable = true,
});
defer window.close();
This initialises ng, defering the deinit. It then creates a window, again defering the closing of that window. Note, that I’m using what I call a bag of options to determine what it does. I don’t have audio yet, but I’ve already enabled it! This means that I can add any other options to this bag later and use them as required.
I then create a shader:
const triangle_shader = @import("triangle_shader.zig");
const TriangleVertex = triangle_shader.Vertex;
const shader = try ng.create_shader(triangle_shader);
defer shader.delete();
This imports the traiangle_shader.zig
file, that contains the shader
definition, including what the Vertex structure looks like, and creates
a shader for this using ng.create_shader
.
I then create a buffer, and initialise it with some data.
const triangle_data = [_]TriangleVertex{
.{ .pos = .{ 0.0, 0.5 }, .col = .red },
.{ .pos = .{ 0.5, -0.5 }, .col = .green },
.{ .pos = .{ -0.5, -0.5 }, .col = .blue },
};
const buffer = try ng.create_buffer(.{
.label = "Triangle Vertex Data",
.data = ng.as_bytes(&triangle_data),
});
defer buffer.delete();
Finally, I create a pipeline and some bindings.
const pipeline = try ng.create_pipeline(.{
.label = "Triangle Pipeline",
.shader = shader,
.primitive = .triangle_list,
});
defer pipeline.delete();
const bindings = try ng.create_bindings(.{
.label = "Triangle Bindings",
.vertex_buffers = &.{buffer},
});
Yes, this very much looks like we could be able to target ‘modern’ graphics APIs, even though I’ve only implemented OpenGL at this point.
I then go to a simple running loop.
while (running) {
}
Inside this, we do some event management stuff.
while (ng.poll_event()) |event| {
switch (event) {
.quit => {
running = false;
},
.key_down => |key_event| {
if (key_event.key == 9) {
running = false;
}
},
.resize => {},
.enter => {},
.leave => {},
.focus => {},
.unfocus => {},
else => {
std.debug.print("{}\n", .{event});
},
}
}
I love event based systems, and this is fundamentally why I can’t get along with APIs like glfw that abstract the events away into a purely state based approach. Yes, this is conceptually easier to understand at the beginning, but I find that once the system becomes more complicated, it also becomes more difficult to think about how things are working.
Finally, we get to draw stuff to the screen.
const command_buffer = try window.acquire_command_buffer();
const swapchain_texture = try command_buffer.acquire_swapchain_texture();
const render_pass = try command_buffer.begin_render_pass(.{
.texture = swapchain_texture,
.clear_color = .black,
.load = .clear,
.store = .store,
});
render_pass.apply_pipeline(pipeline);
render_pass.apply_bindings(bindings);
render_pass.draw(3);
render_pass.end();
try command_buffer.submit();
So, create a command buffer. Use that to acquire the swapchain texture.
Begin the render pass with that command buffer and swapchain texture,
clearing the screen to black. Apply the pipeline, that defined the
shader we were using, and then the bindings, that defined the vertex
buffer we were using, and then draw 3
vertexes. Once that is done, we
end the render pass, and submit the command buffer. Simples.
Some of the more interesting code is in ng
. Let’s quickly pop into
that and have a look.
I created the concept of a platform. This is essentially just a struct of function pointers that get filled in by one of the many possible platforms that could be supported in an implementation. For example, a linux machine could support X11 with OpenGl, or X11 with Vulkan, or Wayland with OpenGL, or Wayland with Vulkan.
pub const Platform = struct {
deinit: *const fn () void,
create_window: *const fn (CreateWindowOptions) VideoError!Window,
close_window: *const fn (Window) void,
There are more entries to this struct, but not too many. This is just the video platform, and it gets filled in by the actual platform init function.
pub fn init() !void {
if (x11.init()) |plat| {
platform = plat;
} else |_| {
return error.NoVideoPlatform;
}
initialized = true;
}
Above, you can see the the video init calls the x11.init that either
returns a plat
or null
. If it is a valid platform, then it gets
assigned to the platform global variable, otherwise we error out. Now,
it would be easy to add in a wayland.init
function call before the
final else error block.
The way this is used is shown in the deinit
function in video.zig
.
pub fn deinit() void {
if (initialized) {
platform.deinit();
}
initialized = false;
}
Now, we could have made platform an optional, but this is more explicit and does effectively the same thing. I sometimes find that code written by others tries to be too cute, when doing something the long way is easier to read. Ok, are things initialized, yes they are, ok, then we can call the platform.deinit function. Regardless, we’ll set initialised to false to stop all other platform calls.
As an example of usage, this is the create window function.
pub fn create_window(options: CreateWindowOptions) !Window {
if (!initialized) return error.VideoNotInitialized;
return platform.create_window(options);
}
Quickly fail if we are not initialised, otherwise call the initialised platform to create the window. We can just pass through the options. If that errored, then the error would be returned, otherwise we’d return Window.
One of the problems with such an approach is that each platform will
have a different way of representing an object. For example, the concept
of a buffer is roughly the in OpenGL, Vulkan, DirectX, and Metal, but
the data that is used is different. We want to capture that difference
but also keep the returned structure being identical. To acheive this, I
created a Pool
.
var buffer_pool: Pool(GL_Buffer, 256) = .{};
A Pool is one of those lovely zig functions that returns a type. In this
case, we pass a type and a length to it. The Pool function then creates
a way to store the types in an array of the given length. The semantics
of this means that we never pass the raw GL_Buffer
around outside the
x11.zig
file, but instead just pass back a handle into this
buffer_pool
. Lets see this in action:
const GL_Buffer = struct {
label: ?[]const u8 = null,
object: u32 = 0,
kind: video.BufferKind,
update: video.BufferUpdate,
};
As you can see, GL_Buffer is not a simple identifier. It could be just
the OpenGL object
name, but we also include a label for debugging, as
well as the kind and expected update enumerations as well.
fn create_buffer(info: video.CreateBufferInfo) video.VideoError!video.Buffer {
const index = buffer_pool.create() orelse return error.TooManyBuffers;
const buffer = buffer_pool.get(index) orelse return error.TooManyBuffers;
var buffer_object: u32 = undefined;
api.glGenBuffers(1, &buffer_object);
buffer.* = .{
.label = info.label,
.object = buffer_object,
.kind = info.kind,
.update = info.update,
};
// copying data into the OpenGL driver omitted
return .{
.handle = index,
};
}
In the first part of the x11.create_buffer
function, we ask the
buffer_pool to create a slot in the pool for a new buffer. This returns
us an index. We can’t use this directly, so we then ask the buffer_pool
again to get the actual memory address, buffer
, for this index. Then
we ask OpenGL to generate a buffer_object, and we store that away in the
buffer
along with the creation information.
Ignoring the messy bits around copying data into the OpenGL driver, we
can then return this index as the handle in the video.Buffer
value
that all platforms use to represent a buffer.
Using it is even simpler. In the apply_bindings
function, you’d find:
if (buffer_pool.get(buf.handle)) |buffer| {
api.glBindBuffer(GL_ARRAY_BUFFER, buffer.object);
}
So, we have a handle in a video.Buffer
and we ask the buffer_pool to
get us the pointer, as buffer
if it exists. We can then use this
GL_Buffer
to access the OpenGL object name that we need to bind that
buffer to the GL_ARRAY_BUFFER
.
One final thought on today. You will have noticed that there is an
api.
in front of calls to OpenGL. Well, to enable the platform
concept, we can’t link directly to the libGL
library. Mainly because
that library may not exist on your machine, but if we dynamically link
to it then it must exist and the program would crash before it even
started. To solve this, the executable is only linked with three
libraries:
$ ldd fourth
linux-vdso.so.1 (0x00007d3e9f6f3000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007d3e9f400000)
/lib64/ld-linux-x86-64.so.2 (0x00007d3e9f6f5000)
That’s vdso
- the way to map kernel space functions into user space;
libc
- because a lot of the libraries require the C standard library;
and ld
- the dynamic linker itself.
So how do we link with OpenGL and X11, et al?
Well, I define a struct called API
in a file. The instance of this is
called api
, and that’s the one that is being used to call OpenGL
functions like glBindBuffer
.
const API = struct {
XOpenDisplay: *const fn ([*c]const u8) callconv(.c) *Display,
glGenBuffers: *const fn (u32, [*c]u32) callconv(.c) void,
glBindBuffer: *const fn (u32, u32) callconv(.c) void,
};
var api: API = undefined;
Then at the start of the platform init
function there is a call to
fill in these symbols.
pub fn init() !video.Platform {
api = try ng.lookup_symbols(API, &.{
"libX11.so",
"libxcb.so",
"libGLX.so",
"libGL.so",
});
display = api.XOpenDisplay(null);
The lookup_symbols
function takes in a type, that defines all the
symbols that we’d like to have, and a list of library files where these
symbols should be able to be found. It will return an error if any of
the required symbols are missing from any of the library files,
otherwise they exist and can be called.
For example, in Xlib, you can open the display by calling
the XOpenDisplay
function.
I’ve got the capability to have optional function pointers, which would not error out if they don’t exist, allowing optional functionality in the future. Being optional function pointers, they would have to be checked for null’ness each time they are used.
So that’s most of the fancy stuff that I’ve written in the last four hours. If this looks like a lot, it’s not.
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
Zig 13 444 406 2452
-------------------------------------------------------------------------------
So basically, about 2400 lines of code. About 600 lines per hour, or
about 10 lines per minute. Except, that’s a bit of a lie. About 1000
lines of this is in the file color.zig
.
pub const Color = enum(u32) {
// stored internally as ABGR
// License: https://creativecommons.org/publicdomain/zero/1.0/
@"acid green" = raw(0x8ffe09),
@"algae green" = raw(0x21c36f),
@"almost black" = raw(0x070d0d),
@"apple green" = raw(0x76cd26),
@"aqua blue" = raw(0x02d8e9),
@"aqua green" = raw(0x12e193),
@"aqua marine" = raw(0x2ee8bb),
@"army green" = raw(0x4b5d16),
Yes, I’ve used the xkcd colors as the basis of all colors in ng. This means that in reality there are only about 1400 lines of actual code that I wrote in 4 hours, about 350 an hour, or about 6 per minute. Things will slow down from here on…