Skip to content

Latest commit

 

History

History
301 lines (231 loc) · 10.2 KB

File metadata and controls

301 lines (231 loc) · 10.2 KB

A performant, cross-platform game engine

What is Aether?

Aether is a game engine written in Zig. It is platform-agnostic -- there should be no difference running between Windows, Linux, macOS, and consoles like the PSP or Switch.

User code is structured as hooks into the engine via a State interface. You implement the game logic; the engine handles the platform details.

Features

  • Cross-platform: Windows, Linux, macOS (PSP/Switch planned)
  • Multiple graphics backends: OpenGL 4.5, Vulkan -- selected at compile time, overridable with -Dgfx=opengl
  • Fixed-step game loop: 144 Hz updates, 20 Hz ticks, uncapped rendering
  • Action-based input system: keyboard, mouse, and gamepad with callback bindings
  • Generic mesh & pipeline API: define vertex layouts from structs using comptime reflection
  • Budgeted memory pools: render, audio, game, user, and scratch -- no hidden heap allocations

Requirements

  • Zig 0.16.0-dev or later (see build.zig.zon for exact minimum)
  • Vulkan SDK (for Vulkan headers; on macOS, MoltenVK is used)

Desktop windowing, input, and audio use SDL3, which the build compiles from vendored source and statically links -- no system windowing library is needed.

Getting Started

Add Aether as a dependency in your build.zig.zon:

.dependencies = .{
    .engine = .{
        .url = "https://github.com/user/aether/archive/<commit>.tar.gz",
        .hash = "...",
    },
},

Then set up your build.zig:

const std = @import("std");
const Aether = @import("engine");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    // Optional: allow overriding the graphics backend with -Dgfx=opengl
    const overrides: Aether.config.Config.Overrides = .{
        .gfx = b.option(Aether.config.Gfx, "gfx", "Graphics backend override (default: auto-detect from target)"),
    };

    const config = Aether.config.Config.resolve(target, overrides);

    const ae_dep = b.dependency("engine", .{
        .target = target,
        .optimize = optimize,
    });

    // Create a game executable -- this wires up the engine module
    // and all platform-specific dependencies (SDL3/Vulkan/OpenGL/pspsdk)
    const exe = Aether.modules.addGame(ae_dep.builder, b, .{
        .name = "my_game",
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
        .overrides = overrides,
    });

    // Export the artifact (produces EBOOT.PBP for PSP, install artifact otherwise)
    Aether.packaging.exportArtifact(ae_dep.builder, b, exe, config, .{
        .title = "My Game",
    });

    const run_step = b.step("run", "Run the game");
    const run_cmd = b.addRunArtifact(exe);
    run_step.dependOn(&run_cmd.step);
}

The first argument to modules.addGame and packaging.exportArtifact is the dependency's builder (ae_dep.builder), and the second is your project's builder (b). This lets Aether resolve its own internal dependencies (SDL3, Vulkan, Slang, pspsdk) from its build.zig.zon while building artifacts that belong to your project.

The returned executable root is Aether's platform entry shim. Add imports for your game root through userRootModule:

Aether.modules.userRootModule(exe).addImport("my_module", my_module);

Then write your game code:

const std = @import("std");
const ae = @import("aether");

pub const aether_options: ae.Options = .{
    .title = "My Game",
    .app_name = "my_game",
    .psp = .{
        .module_name = "MyGame",
        .stack_size = 512 * 1024,
    },
};

const MyState = struct {
    fn init(ctx: *anyopaque, engine: *ae.Engine) anyerror!void { _ = ctx; _ = engine; }
    fn deinit(ctx: *anyopaque, engine: *ae.Engine) void { _ = ctx; _ = engine; }
    fn tick(ctx: *anyopaque, engine: *ae.Engine) anyerror!void { _ = ctx; _ = engine; }
    fn update(ctx: *anyopaque, engine: *ae.Engine, dt: f32, _: *const ae.Util.BudgetContext) anyerror!void { _ = ctx; _ = engine; _ = dt; }
    fn draw(ctx: *anyopaque, engine: *ae.Engine, dt: f32, _: *const ae.Util.BudgetContext) anyerror!void { _ = ctx; _ = engine; _ = dt; }

    pub fn state(self: *MyState) ae.Core.State {
        return .{ .ptr = self, .tab = &.{
            .init = init, .deinit = deinit,
            .tick = tick, .update = update, .draw = draw,
        }};
    }
};

pub fn main(init: std.process.Init) !void {
    const memory = try init.gpa.alignedAlloc(u8, .fromByteUnits(16), 32 * 1024 * 1024);
    defer init.gpa.free(memory);

    var my_state: MyState = undefined;
    var engine: ae.Engine = undefined;
    try engine.init(init.io, init.environ_map, memory, &.{
        .memory = .{
            .render = 8 * 1024 * 1024,
            .audio = 2 * 1024 * 1024,
            .game = 2 * 1024 * 1024,
            .frame = 4 * 1024 * 1024,
            .user = 16 * 1024 * 1024,
        },
        .title = "My Game",
        .app_name = ae.AppOptions.resolveAppName(aether_options),
    }, &my_state.state());
    defer engine.deinit();
    engine.run() catch |err| switch (err) {
        error.StateTransitionFailed => {
            if (engine.last_transition_failure()) |state_err| {
                ae.Util.game_logger.err("state transition failed: {s}", .{@errorName(state_err)});
            }
            return error.EngineStateTransitionFailed;
        },
        else => return err,
    };
}

Platform and graphics backend are available as comptime constants for per-platform configuration:

const memory_config: ae.Util.MemoryConfig = switch (ae.platform) {
    .psp => .{ .render = 512 * 1024, .audio = 256 * 1024, .frame = 128 * 1024, ... },
    else => .{ .render = 8 * 1024 * 1024, .audio = 2 * 1024 * 1024, .frame = 4 * 1024 * 1024, ... },
};

Set .frame = 0 if you want to disable the per-frame scratch allocator.

Nintendo 3DS streaming audio

The 3DS backend reserves a bounded PCM prefetch cache from the engine's .audio pool so filesystem latency never runs on the real-time mixer thread. It defaults to 512 KiB, split across the 16 software-mix slots; this is staging memory, not complete-file loading. Account for it in your audio budget and adjust it through application options when needed. The value must be at least 64 KiB and divisible by 16:

pub const aether_options: ae.Options = .{
    .nintendo_3ds = .{
        .audio_stream_cache_bytes = 512 * 1024,
    },
};

Use resident sound buffers for short, latency-sensitive effects. Long music and ambient assets can use StreamingSoundDesc; cold streams still wait for their first filesystem read, but cannot stall other audio playback.

Building

# Build and run
zig build run

# Run tests
zig build test

# Override graphics backend
zig build run -Dgfx=opengl

# Build for PSP
zig build -Dtarget=mipsel-psp

# Build in release mode
zig build -Doptimize=ReleaseFast

Input System

Actions are registered by name and bound to one or more input sources:

const actions = try ae.Core.input.register_action_set("gameplay");
const jump = try ae.Core.input.add_action(actions, "jump", .button);
try ae.Core.input.bind_action(jump, &.{ .source = .{ .key = .Space } });
try ae.Core.input.install_action_set(actions);

if (ae.Core.input.button(jump).pressed()) {
    // jump this frame
}

Supported sources: keyboard keys, mouse buttons, mouse scroll, mouse relative movement, and gamepad buttons/axes.

On desktop, keyboard keys are physical-position based (SDL scancodes): Key.W is always the key above Key.A on a QWERTY layout, regardless of the OS keyboard layout.

Rendering

Build editable CPU geometry in MeshData, upload or bind it through Mesh, and draw with an explicit render state:

const Vertex = ae.Rendering.Vertex;
const MeshData = ae.Rendering.MeshData(Vertex);
const Mesh = ae.Rendering.Mesh(Vertex);

var data = try MeshData.init(render_alloc);
defer data.deinit(render_alloc);

var mesh = try Mesh.init(&.{});
defer mesh.deinit();

try data.add_tri(render_alloc, a, b, c);
mesh.update(&data);

ae.Rendering.set_state(&.{
    .texture = texture.handle,
    .proj = projection,
    .view = view,
    .depth_write = false,
});
mesh.draw(&model);

On PSP and 3DS, mesh data is borrowed directly by the backend, so keep the MeshData alive and call mesh.update(&data) after edits that may reallocate. Static textures can use the default .cpu_access = .none; request .read_write when you need set_pixel or direct CPU buffer access.

Build API Reference

Aether.modules.addGame(owner, b, opts) -> *Compile

Creates a game executable with the engine module and platform dependencies wired up.

Option Type Description
name []const u8 Executable name
root_source_file LazyPath Path to your main source file
target ResolvedTarget Build target
optimize OptimizeMode Optimization level (default: .Debug)
overrides config.Config.Overrides Graphics/display mode overrides (default: .{})

Aether.packaging.exportArtifact(owner, b, exe, config, opts)

Exports the build artifact. For PSP targets, produces an EBOOT.PBP. For desktop, installs the artifact normally.

Option Type Description
title []const u8 Application title (used in PSP EBOOT.PBP)
output_dir ?[]const u8 Output directory name (optional)
icon0 ?LazyPath PSP icon (optional)
pic0, pic1 ?LazyPath PSP background images (optional)
snd0 ?LazyPath PSP startup sound (optional)
windows_icon ?LazyPath Windows ICO embedded in the executable (optional)

Aether.config.Config.resolve(target, overrides) -> Config

Resolves the full engine configuration (platform, graphics backend, audio, input) from the build target and any user overrides. Pass the result to packaging.exportArtifact.

Aether.config.Config.Overrides

Field Type Description
gfx ?config.Gfx Graphics backend override (null = auto-detect)
psp_display_mode ?config.PspDisplayMode PSP display mode (null = rgba8888)

License

See LICENSE.