Chapter 5: High-Level Graphics
(
mach.gfx) — Mach Engine 0.4
CAUTION: THIS TUTORIAL WAS AI-GENERATED AND MAY CONTAIN ERRORS. IT IS NOT AFFILIATED WITH OR ENDORSED BY HEXOPS/MACH.
In Chapter 4: Mach Modules, we saw how Mach applications are built by combining different “bricks” or modules. We used mach.Core for windowing and input, and our own App module for overall logic.
But how do we actually draw things like characters, backgrounds, or user interfaces? We could use the raw graphics tools directly, but that often involves a lot of complex setup for common tasks. Imagine wanting to draw a simple 2D character (a “sprite”) on the screen. You’d need to: load an image file, create a texture on the graphics card (GPU), define the shape to draw (usually two triangles forming a rectangle), write a program (a “shader”) telling the GPU how to paint the texture onto the rectangle, and send commands to the GPU every frame. That’s a lot of work just for one picture!
This is where mach.gfx comes in. It provides ready-made tools for common graphics tasks, making your life much easier.
Your Graphics Toolkit
Think of mach.gfx as a toolbox filled with specialized graphics tools, built on top of the fundamental, lower-level graphics capabilities that we’ll cover later in Chapter 7: Graphics Abstraction.
- Lower-Level (
mach.sysgpu): Provides the basic building blocks – drawing triangles, managing textures, running shader programs. It’s powerful but detailed. It’s like having individual LEGO bricks. - Higher-Level (
mach.gfx): Provides convenient abstractions for common patterns. It’s like having pre-assembled LEGO components – a car chassis, a window piece. It uses the basic bricks (mach.sysgpu) internally but hides the complexity.
The main tools (modules) currently in the mach.gfx toolbox are:
mach.gfx.Sprite: A tool specifically designed for drawing 2D images (sprites). It handles loading textures, setting up shaders, and drawing sprites efficiently, even thousands of them.mach.gfx.Text: A tool for rendering text. It deals with loading fonts, laying out characters, and drawing them to the screen.
Using mach.gfx lets you focus more on what you want to draw, rather than the nitty-gritty details of how the GPU draws it.
Putting mach.gfx.Sprite to Work: Drawing a Character
Let’s focus on the common task of drawing a 2D character sprite using mach.gfx.Sprite.
1. Including the Sprite Module
First, we need to tell our application that we want to use the mach.gfx.Sprite tool. We add it to our Modules list in App.zig:
// src/App.zig (Modules declaration)
pub const Modules = mach.Modules(.{
mach.Core, // Still need windowing/input
mach.gfx.Sprite, // <-- Add the Sprite module!
App, // Our main application module
});
This makes the Sprite module available for dependency injection in our systems.
2. Setting Up the “Pipeline”
Before we can draw sprites, we need a basic setup. The Sprite module uses the concept of a “pipeline”. A pipeline defines how a group of sprites will be drawn – specifically, which texture they use and which window they appear in.
We typically do this setup once when the window opens. Mach sends a .window_open event which we can handle in our App.tick system.
// src/App.zig (Inside App struct)
// Store the ID of our sprite pipeline
pipeline_id: mach.ObjectID = undefined,
// ... other App fields ...
// src/App.zig (Inside App.tick function)
pub fn tick(
core: *mach.Core,
app: *App,
// Dependency injection: Mach gives us access to the Sprite module
sprite: *gfx.Sprite,
sprite_mod: mach.Mod(gfx.Sprite),
) !void {
while (core.nextEvent()) |event| {
switch (event) {
// ... other event handling (.key_press, .close) ...
.window_open => |ev| {
// --- Pipeline Setup ---
const window = core.windows.getValue(ev.window_id);
// 1. Load the image file into a GPU texture
// (Using a helper function like in the examples)
const character_texture = try loadTexture(
window.device,
window.queue,
app.allocator,
"character.png", // Path to your image
);
// 2. Create the pipeline object
app.pipeline_id = try sprite.pipelines.new(.{
.window = ev.window_id, // Link to the window
.texture = character_texture, // Use our character image
// .render_pass will be set later per frame
});
std.log.info("Sprite pipeline created: ID {any}", .{app.pipeline_id});
// --- End Pipeline Setup ---
},
else => {},
}
}
// ... rest of tick ...
- We add
sprite: *gfx.Spriteandsprite_mod: mach.Mod(gfx.Sprite)as parameters totick. Mach automatically provides these because we includedmach.gfx.SpriteinModules. loadTextureis a simplified helper function (like in thespriteexample) that loads an image (e.g., “character.png”) and prepares it for the GPU, returning a*gpu.Texture.sprite.pipelines.new(...)creates a new pipeline configuration object managed by theSpritemodule. We link it to the window and the texture it should use.- We store the returned
ObjectID(app.pipeline_id) so we can refer to this specific pipeline later.
3. Creating a Sprite Object
Now that we have a pipeline, we can create the actual sprite object. Let’s create one in our App.init function.
// src/App.zig (Inside App struct)
player_id: mach.ObjectID = undefined, // Store the ID of our player sprite
// ... other App fields ...
// src/App.zig (Inside App.init function, AFTER window creation)
pub fn init(
core: *mach.Core,
app: *App,
app_mod: mach.Mod(App),
// We can also get sprite access in init if needed
sprite: *gfx.Sprite,
) !void {
// ... window creation code ...
app.window = window;
// Wait for the pipeline to be created in the first tick
// For simplicity here, we assume the pipeline exists when we need it.
// A better approach involves checking if pipeline_id is valid.
// Create the player sprite object
app.player_id = try sprite.objects.new(.{
// Position (center of screen), no rotation, unit scale
.transform = math.Mat4x4.identity(),
// Size in pixels (e.g., if the character image is 32x32)
.size = math.vec2(32.0, 32.0),
// Use the top-left part of the texture (0,0)
.uv_transform = math.Mat3x3.identity(),
});
// --- IMPORTANT: Link Sprite to Pipeline ---
// We need to tell the sprite which pipeline it belongs to.
// This is done by setting the sprite's parent to the pipeline's ID.
// We assume app.pipeline_id has been set by the time this runs,
// or handle cases where it might not be ready yet.
// A robust way is to create sprites AFTER pipeline setup in tick.
// For this example, we assume pipeline_id is ready (simplification).
// try sprite.pipelines.setParent(app.player_id, app.pipeline_id);
std.log.info("Player sprite created: ID {any}", .{app.player_id});
}
sprite.objects.new(...)creates a new sprite data object, managed by theSpritemodule’s internalmach.Objectslist..transform: A 4x4 matrix (mach.math) defining the sprite’s position, rotation, and scale in the world.Mat4x4.identity()means no transformation (position 0,0,0, no rotation, scale 1).Mat4x4.translate(math.vec3(x, y, z))would set its position..size: A 2D vector (mach.math) specifying the width and height of the sprite in pixels..uv_transform: A 3x3 matrix (mach.math) defining which part of the texture to use.Mat3x3.identity()usually means use the texture starting from the top-left corner (0,0).Mat3x3.translate(math.vec2(u, v))can be used to select different parts of a sprite sheet.- It returns an
ObjectID(app.player_id) for this specific sprite. - Crucially, you need to link the sprite to a pipeline using
sprite.pipelines.setParent(sprite_id, pipeline_id). This tells theSpritemodule: “When you processpipeline_id, please includesprite_id.” Note: The example code places creation ininitfor simplicity, but linking requires thepipeline_idwhich is usually created intick. A more robust pattern creates sprites *after the pipeline is confirmed ready intick.*
4. Rendering (The Easy Part!)
How do we draw the sprite every frame? The Sprite module does most of the heavy lifting internally when its tick system is called. We just need to trigger that system within our main rendering logic in App.tick.
// src/App.zig (Inside App.tick function, near the end)
// --- Drawing ---
// Get the window's current surface to draw on
const window = core.windows.getValue(app.window);
const back_buffer_view = window.swap_chain.getCurrentTextureView().?;
defer back_buffer_view.release();
// Create a command encoder to record drawing commands
const encoder = window.device.createCommandEncoder(null);
defer encoder.release();
// Begin the render pass (clearing the screen to sky blue)
const color_attachments = [_]gpu.RenderPassColorAttachment{.{
.view = back_buffer_view,
.clear_value = gpu.Color{ .r = 0.1, .g = 0.7, .b = 0.9, .a = 1.0 }, // Sky blue
.load_op = .clear,
.store_op = .store,
}};
const render_pass = encoder.beginRenderPass(&gpu.RenderPassDescriptor.init(.{
.color_attachments = &color_attachments,
}));
defer render_pass.release(); // Ensure render_pass is released
// --- Tell the Sprite Module to Draw ---
// 1. Make sure the pipeline knows which render pass to draw into
sprite.pipelines.set(app.pipeline_id, .render_pass, render_pass);
// 2. Run the Sprite module's tick system
sprite_mod.call(.tick);
// --- Sprite Drawing Done! ---
// Finish the render pass and submit commands to the GPU
render_pass.end();
var command = encoder.finish(null);
defer command.release(); // Ensure command buffer is released
window.queue.submit(&[_]*gpu.CommandBuffer{command});
// --- End Drawing ---
}
- We set up a standard “render pass” – a sequence of drawing operations targeting the window’s back buffer. (This uses
mach.sysgpuconcepts briefly). sprite.pipelines.set(app.pipeline_id, .render_pass, render_pass)tells the sprite pipeline which render pass context it should use for drawing this frame.sprite_mod.call(.tick): This is the key! We explicitly run theticksystem of theSpritemodule. Inside this function, theSpritemodule finds all active pipelines (like ours), finds all sprites attached to them (like our player), gathers their data, and issues the necessary draw commands to the GPU viamach.sysgpu.- You don’t manually draw each sprite; you just tell the
Spritemodule to do its job for the frame.
5. Updating the Sprite
Let’s make the player sprite move based on keyboard input. We can modify its transform property.
// src/App.zig (Inside App.tick, after event loop, before drawing)
// --- Game Logic / Updates ---
// Check if arrow keys are pressed (using core.keyPressed)
var move_x: f32 = 0.0;
if (core.keyPressed(.left)) move_x -= 1.0;
if (core.keyPressed(.right)) move_x += 1.0;
// (Similarly for up/down with move_y)
if (move_x != 0.0) { // Only update if moving
const speed = 200.0; // Pixels per second
const delta_time = app.timer.lap(); // Time since last frame
// Get the current player sprite data
var player_data = sprite.objects.getValue(app.player_id);
// Calculate new position
var current_pos = player_data.transform.translation();
current_pos.v[0] += move_x * speed * delta_time;
// Update the transform matrix with the new position
player_data.transform = math.Mat4x4.translate(current_pos);
// Save the updated data back to the Sprite module
sprite.objects.setValue(app.player_id, player_data);
}
// --- End Game Logic ---
- We calculate movement based on input and elapsed time (
delta_time). sprite.objects.getValue(app.player_id)retrieves the current data for our player sprite.- We modify the
transformfield of theplayer_datastruct. We usemach.mathhelpers like.translation()to get the position vector andMat4x4.translate()to create a new transform matrix from the updated position. sprite.objects.setValue(app.player_id, player_data)writes the modified data back into theSpritemodule’s storage. The next timesprite_mod.call(.tick)runs, it will use this updated position.
That’s it! mach.gfx.Sprite handles the complexity of batching sprites together and telling the GPU how to draw them efficiently using the underlying mach.sysgpu tools.
Under the Hood: How mach.gfx.Sprite.tick Works
What magic happens inside sprite_mod.call(.tick)?
High-Level Idea:
The Sprite module acts like a stage manager for sprites. When its tick system runs:
- Gather Performers: It looks at all the
pipelineobjects that have a valid.render_passset for this frame. - Roll Call: For each active pipeline, it finds all the
spriteobjects that are linked to it (using the parent relationship we set up). - Collect Instructions: It iterates through these linked sprites and collects their current
.transform,.size, and.uv_transformdata from thesprite.objectslist. - Optimize & Prepare: It puts all this collected data into large, efficient lists (GPU buffers) that the graphics card can read very quickly. It might also sort sprites (e.g., for transparency).
- Send to GPU: It tells the GPU (using
mach.sysgpucommands within the providedrender_pass) to: “Draw a whole batch of rectangles using the data in these buffers, paint them using the pipeline’s texture, and use the standard sprite shader program.”
This process, called “batching,” is much faster than telling the GPU to draw each sprite individually.
Sequence Diagram (Simplified sprite.tick):
sequenceDiagram
participant AppTick as App.tick Loop
participant SpriteMod as mach.gfx.Sprite Module
participant SpriteObjects as sprite.objects (SoA Data)
participant GPU Buffers
participant RenderPass as GPU Render Pass
participant GPU
AppTick->>SpriteMod: sprite_mod.call(.tick)
SpriteMod->>SpriteMod: Iterate active pipelines (check .render_pass)
Note over SpriteMod: Found active pipeline_id
SpriteMod->>SpriteObjects: Get children of pipeline_id (sprite IDs)
SpriteObjects-->>SpriteMod: Return [player_id, enemy_id, ...]
SpriteMod->>SpriteObjects: For each sprite ID, get transform, size, uv_transform
SpriteObjects-->>SpriteMod: Return sprite data
SpriteMod->>GPU Buffers: Update transforms, sizes, uvs buffers with collected data
Note over SpriteMod, RenderPass: Prepare draw command
SpriteMod->>RenderPass: setPipeline(sprite_shader)
SpriteMod->>RenderPass: setBindGroup(texture, buffers)
SpriteMod->>RenderPass: draw(num_sprites * 6 vertices)
RenderPass->>GPU: Execute draw command
GPU-->>AppTick: (Eventually frame is displayed)
Code Glance (src/gfx/Sprite.zig):
The core logic resides in src/gfx/Sprite.zig.
-
Module Definition: It defines the
Spritestruct, which holds themach.Objectslists for sprites and pipelines.// src/gfx/Sprite.zig (Simplified Snippets) const Sprite = @This(); pub const mach_module = .mach_gfx_sprite; pub const mach_systems = .{.tick}; // Declares the tick system // Manages data for individual sprite instances objects: mach.Objects(..., struct { transform: Mat4x4, uv_transform: Mat3x3, size: Vec2, }), // Manages data for sprite rendering pipelines pipelines: mach.Objects(..., struct { window: ?mach.ObjectID = null, render_pass: ?*gpu.RenderPassEncoder = null, texture: *gpu.Texture, // ... other pipeline settings (shader, blend state, etc.) ... built: ?BuiltPipeline = null, // Internal GPU resources num_sprites: u32 = 0, // How many sprites rendered last frame }),- We see the familiar
mach.Objectsstructure from Chapter 2, tailored to hold sprite and pipeline data.
- We see the familiar
-
The
tickSystem: This function contains the logic described above.// src/gfx/Sprite.zig (Simplified tick system) pub fn tick(sprite: *Sprite, core: *mach.Core) !void { var pipelines_iter = sprite.pipelines.slice(); while (pipelines_iter.next()) |pipeline_id| { var pipeline = sprite.pipelines.getValue(pipeline_id); // Skip if pipeline isn't ready for rendering this frame if (pipeline.window == null or pipeline.render_pass == null) continue; // Rebuild internal GPU resources if pipeline settings changed if (sprite.pipelines.anyUpdated(pipeline_id)) { rebuildPipeline(core, sprite, pipeline_id); // Reload pipeline data after rebuild pipeline = sprite.pipelines.getValue(pipeline_id); } // Get list of sprites attached to this pipeline var pipeline_children = try sprite.pipelines.getChildren(pipeline_id); defer pipeline_children.deinit(); // Check if any attached sprites changed position, size, etc. const any_sprites_updated = /* ... check sprite.objects updates ... */; if (any_sprites_updated) { // Collect data from all children sprites // Put data into intermediate CPU buffers (e.g., cp_transforms) // Sort sprites if needed (e.g., for Z-depth) // Upload data from CPU buffers to GPU buffers updatePipelineBuffers(sprite, core, pipeline_id, pipeline_children.items); // Update sprite count on pipeline pipeline = sprite.pipelines.getValue(pipeline_id); } // If there are sprites, issue the draw command if (pipeline.num_sprites > 0) { renderPipeline(sprite, core, pipeline_id); // Issues GPU commands } } }- The actual
updatePipelineBuffersandrenderPipelinefunctions handle interacting withmach.sysgputo create buffers, upload data (queue.writeBuffer), and issue draw calls (render_pass.draw).
- The actual
What About Text? (mach.gfx.Text)
Just like mach.gfx.Sprite simplifies drawing images, mach.gfx.Text simplifies rendering text. It handles:
- Loading font files (using libraries like FreeType).
- Managing a texture atlas (a large texture containing many character shapes, or “glyphs”).
- Calculating character positions (layout).
- Batching glyphs together and drawing them efficiently, similar to how sprites are batched.
Using mach.gfx.Text involves similar steps: include the module, create text styles, create text objects with strings, link them to a text pipeline, and call text_mod.call(.tick) during rendering. You can see it in action in the examples/text project.
Conclusion
You’ve learned about mach.gfx, Mach’s high-level graphics toolkit designed to make common rendering tasks easier. We saw how modules like mach.gfx.Sprite provide convenient ways to draw things like 2D sprites by managing textures, shaders, and batching internally. This lets you create sprites, set their properties (position, size, texture region), link them to a rendering pipeline, and have the module handle the complex GPU interactions when its tick system is called.
While mach.gfx provides great convenience, it achieves this using shaders – small programs that run on the GPU to determine how things are drawn. Understanding shaders is key to customizing rendering or working directly with the lower-level mach.sysgpu.
Let’s dive into the world of shaders in Chapter 6: Shaders (WGSL).
Generated by AI Codebase Knowledge Builder