Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 81 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,42 +1,99 @@
# Zig 0.14 + SDL3 + OpenGL Pyramid
# Zig Voxel Engine

A simple 3D spinning pyramid implemented in [Zig](https://ziglang.org/) (0.14/master), using [SDL3](https://wiki.libsdl.org/SDL3/FrontPage) for windowing and [OpenGL 3.3](https://www.opengl.org/) for rendering.

This project uses **Nix** to provide a reproducible development and build environment, ensuring all dependencies (Zig compiler, SDL3, GLEW, OpenGL drivers) are correctly linked and patched.
A Minecraft-style voxel engine built with [Zig](https://ziglang.org/) (0.14/master), [SDL3](https://wiki.libsdl.org/SDL3/FrontPage), and [OpenGL 3.3](https://www.opengl.org/).

## Features
- **Modern OpenGL (3.3 Core):** Uses Shaders, VAOs, and VBOs.
- **3D Math:** Custom matrix math struct for perspective projection, translation, and rotation.
- **Nix Flake:** Fully hermetic build and development shell.
- **Auto-Patching:** The Nix build process automatically fixes ELF interpreters and RPATHs (including `libstdc++` for audio backends).

### Rendering
- **Modern OpenGL 3.3 Core** - Shaders, VAOs, VBOs
- **Floating Origin** - Camera-relative rendering prevents precision loss at large coordinates
- **Reverse-Z Depth Buffer** - Better depth precision at far distances
- **Greedy Meshing** - Optimized chunk mesh generation
- **Frustum Culling** - Camera-relative chunk culling
- **Texture Atlas** - 16x16 tile atlas for block textures
- **Flat Shading** - Per-face normals for clean voxel look

### World Generation
- **Multi-noise Biome System** - 11 biome types based on temperature/humidity
- **Domain Warping** - Natural-looking terrain variation
- **Layered Noise** - Continental, erosion, and detail noise layers
- **Cave Generation** - 3D noise-based cave systems
- **Water Bodies** - Lakes and oceans at sea level

### Engine
- **Multithreaded Chunk Loading** - 4 generation + 3 meshing worker threads
- **Job Prioritization** - Chunks closest to player load first
- **Dynamic Re-prioritization** - Jobs update when player moves
- **Subchunk Rendering** - 16 vertical subchunks per chunk column
- **Solid/Fluid Render Passes** - Proper water transparency

### Controls
| Key | Action |
|-----|--------|
| WASD | Move |
| Space | Fly up |
| Shift | Fly down |
| Mouse | Look around |
| Tab | Toggle mouse capture |
| F | Toggle wireframe |
| T | Toggle textures |
| V | Toggle VSync |
| Esc | Pause/Menu |

## Prerequisites
- [Nix](https://nixos.org/download.html) with `flakes` enabled.

## Build & Run
- [Nix](https://nixos.org/download.html) with `flakes` enabled

### 1. Build with Nix
This produces a patched binary in `./result/bin/`:
## Build & Run

### Development
```bash
nix build
nix develop
zig build run
```

### 2. Run
### Production Build
```bash
nix build
./result/bin/zig-triangle
```

### Development Shell
To work on the code with `zls` and the `zig` compiler available in your path:
## Project Structure

```bash
nix develop
zig build run
```
*(Note: `zig build run` inside `nix develop` uses the local cache and might require `LD_LIBRARY_PATH` setup if not fully patched, but the flake handles the production build perfectly)*
src/
engine/
core/ # Job system, logging, time
graphics/ # Camera, renderer, shaders, textures
input/ # Input handling
math/ # Vec3, Mat4, AABB, Frustum
ui/ # UI system for menus
world/
worldgen/ # Terrain generator, noise functions
block.zig # Block types and properties
chunk.zig # Chunk data structure
chunk_mesh.zig # Greedy meshing
world.zig # World manager, chunk loading
main.zig # Entry point, game loop
c.zig # C bindings (SDL3, GLEW, OpenGL)
```

## Project Structure
- `src/main.zig`: Application entry point, render loop, and shader logic.
- `build.zig`: Zig build configuration.
- `flake.nix`: Nix dependencies, package definition, and wrapper logic.
## Technical Details

### Render Stability
The engine implements industry-standard techniques to prevent terrain shimmering at high altitude and large render distances:

1. **Floating Origin** - Chunk vertices use local coordinates (0-16), world offset applied via model matrix relative to camera position
2. **Reverse-Z Depth** - Near plane maps to z=1, far plane to z=0, with `glDepthFunc(GL_GEQUAL)`
3. **Near Plane** - Set to 0.5 (not 0.1) for better depth precision
4. **Flat Shading** - `flat` interpolation qualifier on normals prevents lighting shimmer

### Chunk System
- Chunk size: 16x256x16 blocks
- 16 subchunks per column (16x16x16 each)
- Render distance configurable in settings
- Chunks unload when player moves away

## License

MIT
38 changes: 38 additions & 0 deletions src/engine/core/job_system.zig
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ pub const JobQueue = struct {
cond: Condition,
jobs: std.PriorityQueue(Job, void, compareJobs),
stopped: bool,
allocator: std.mem.Allocator,
// Current player chunk for dynamic re-prioritization
player_cx: i32 = 0,
player_cz: i32 = 0,

fn compareJobs(context: void, a: Job, b: Job) std.math.Order {
_ = context;
Expand All @@ -41,6 +45,7 @@ pub const JobQueue = struct {
.cond = Condition{},
.jobs = std.PriorityQueue(Job, void, compareJobs).init(allocator, {}),
.stopped = false,
.allocator = allocator,
};
}

Expand All @@ -67,6 +72,39 @@ pub const JobQueue = struct {
return self.jobs.removeOrNull();
}

/// Update player position and rebuild priority queue with new distances
pub fn updatePlayerPos(self: *JobQueue, cx: i32, cz: i32) !void {
self.mutex.lock();
defer self.mutex.unlock();

// Only rebuild if player moved
if (cx == self.player_cx and cz == self.player_cz) return;
self.player_cx = cx;
self.player_cz = cz;

// Rebuild queue with updated priorities
const count = self.jobs.count();
if (count == 0) return;

var temp = std.ArrayListUnmanaged(Job).empty;
defer temp.deinit(self.allocator);

// Extract all jobs
while (self.jobs.removeOrNull()) |job| {
// Recalculate distance
const dx = job.chunk_x - cx;
const dz = job.chunk_z - cz;
var updated_job = job;
updated_job.dist_sq = dx * dx + dz * dz;
temp.append(self.allocator, updated_job) catch continue;
}

// Re-add with updated priorities
for (temp.items) |job| {
self.jobs.add(job) catch continue;
}
}

pub fn stop(self: *JobQueue) void {
self.mutex.lock();
self.stopped = true;
Expand Down
23 changes: 16 additions & 7 deletions src/engine/graphics/camera.zig
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ pub const Camera = struct {
yaw: f32 = -std.math.pi / 2.0, // Looking toward -Z
pitch: f32 = 0,
fov: f32 = std.math.degreesToRadians(70.0),
near: f32 = 0.1,
far: f32 = 1000.0,
near: f32 = 0.5, // Pushed out for better depth precision with reverse-Z
far: f32 = 10000.0, // Increased for large render distances
move_speed: f32 = 5.0,
sensitivity: f32 = 0.002,
};
Expand Down Expand Up @@ -117,13 +117,22 @@ pub const Camera = struct {
return Mat4.lookAt(self.position, target, Vec3.up);
}

/// Get projection matrix
/// Get projection matrix with reverse-Z for better depth precision
pub fn getProjectionMatrix(self: *const Camera, aspect_ratio: f32) Mat4 {
return Mat4.perspective(self.fov, aspect_ratio, self.near, self.far);
return Mat4.perspectiveReverseZ(self.fov, aspect_ratio, self.near, self.far);
}

/// Get combined view-projection matrix
pub fn getViewProjectionMatrix(self: *const Camera, aspect_ratio: f32) Mat4 {
return self.getProjectionMatrix(aspect_ratio).multiply(self.getViewMatrix());
/// Get view matrix centered at origin (for floating origin rendering)
/// Camera is conceptually at origin looking in the forward direction
pub fn getViewMatrixOriginCentered(self: *const Camera) Mat4 {
// View matrix with camera at origin - just rotation, no translation
const target = self.forward;
return Mat4.lookAt(Vec3.zero, target, Vec3.up);
}

/// Get combined view-projection matrix for floating origin rendering
/// Use this with camera-relative chunk positions
pub fn getViewProjectionMatrixOriginCentered(self: *const Camera, aspect_ratio: f32) Mat4 {
return self.getProjectionMatrix(aspect_ratio).multiply(self.getViewMatrixOriginCentered());
}
};
9 changes: 7 additions & 2 deletions src/engine/graphics/renderer.zig
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,14 @@ pub const Renderer = struct {
log.log.info("OpenGL Version: {s}", .{version});
log.log.info("GLSL Version: {s}", .{glsl_version});

// Enable depth testing
// Enable depth testing with reverse-Z for better precision at distance
c.glEnable(c.GL_DEPTH_TEST);
c.glDepthFunc(c.GL_LESS);
c.glDepthFunc(c.GL_GEQUAL); // Reverse-Z: greater values are closer
c.glClearDepth(0.0); // Clear to 0 (far plane in reverse-Z)
// glClipControl for [0,1] depth range - use function pointer from GLEW
if (c.glClipControl()) |clip_fn| {
clip_fn(c.GL_LOWER_LEFT, c.GL_ZERO_TO_ONE);
}

// Enable backface culling
c.glEnable(c.GL_CULL_FACE);
Expand Down
16 changes: 12 additions & 4 deletions src/engine/math/frustum.zig
Original file line number Diff line number Diff line change
Expand Up @@ -132,17 +132,25 @@ pub const Frustum = struct {

/// Check if a chunk (given by chunk coordinates) intersects the frustum
/// Chunks are 16x256x16 blocks
/// For floating origin rendering, pass camera position to compute relative coordinates
pub fn intersectsChunk(self: Frustum, chunk_x: i32, chunk_z: i32) bool {
return self.intersectsChunkRelative(chunk_x, chunk_z, 0, 0, 0);
}

/// Check if a chunk intersects the frustum using camera-relative coordinates
pub fn intersectsChunkRelative(self: Frustum, chunk_x: i32, chunk_z: i32, cam_x: f32, cam_y: f32, cam_z: f32) bool {
const CHUNK_SIZE_X: f32 = 16.0;
const CHUNK_SIZE_Y: f32 = 256.0;
const CHUNK_SIZE_Z: f32 = 16.0;

const world_x: f32 = @floatFromInt(chunk_x * 16);
const world_z: f32 = @floatFromInt(chunk_z * 16);
// Chunk world position relative to camera
const world_x: f32 = @as(f32, @floatFromInt(chunk_x * 16)) - cam_x;
const world_z: f32 = @as(f32, @floatFromInt(chunk_z * 16)) - cam_z;
const world_y: f32 = -cam_y; // Y=0 in chunk space

const aabb = AABB.init(
Vec3.init(world_x, 0, world_z),
Vec3.init(world_x + CHUNK_SIZE_X, CHUNK_SIZE_Y, world_z + CHUNK_SIZE_Z),
Vec3.init(world_x, world_y, world_z),
Vec3.init(world_x + CHUNK_SIZE_X, world_y + CHUNK_SIZE_Y, world_z + CHUNK_SIZE_Z),
);

return self.intersectsAABB(aabb);
Expand Down
17 changes: 17 additions & 0 deletions src/engine/math/mat4.zig
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,23 @@ pub const Mat4 = struct {
return result;
}

/// Perspective projection with reverse-Z for better depth precision at distance
/// Maps near plane to z=1 and far plane to z=0 (reversed from standard)
/// Use with glDepthFunc(GL_GEQUAL) and glClearDepth(0.0)
pub fn perspectiveReverseZ(fov_radians: f32, aspect: f32, near: f32, far: f32) Mat4 {
const tan_half_fov = std.math.tan(fov_radians / 2.0);
var result = Mat4.zero;

result.data[0][0] = 1.0 / (aspect * tan_half_fov);
result.data[1][1] = 1.0 / tan_half_fov;
// Reverse-Z: swap near and far in depth calculation
result.data[2][2] = near / (far - near);
result.data[2][3] = -1.0;
result.data[3][2] = (far * near) / (far - near);

return result;
}

pub fn orthographic(left: f32, right_val: f32, bottom: f32, top: f32, near: f32, far: f32) Mat4 {
var result = Mat4.zero;

Expand Down
15 changes: 9 additions & 6 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const vertex_shader_src =
\\layout (location = 3) in vec2 aTexCoord;
\\layout (location = 4) in float aTileID;
\\out vec3 vColor;
\\out vec3 vNormal;
\\flat out vec3 vNormal;
\\out vec2 vTexCoord;
\\flat out int vTileID;
\\uniform mat4 transform;
Expand All @@ -48,7 +48,7 @@ const vertex_shader_src =
const fragment_shader_src =
\\#version 330 core
\\in vec3 vColor;
\\in vec3 vNormal;
\\flat in vec3 vNormal;
\\in vec2 vTexCoord;
\\flat in int vTileID;
\\out vec4 FragColor;
Expand Down Expand Up @@ -113,6 +113,8 @@ pub fn main() !void {
_ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MAJOR_VERSION, 3);
_ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MINOR_VERSION, 3);
_ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_PROFILE_MASK, c.SDL_GL_CONTEXT_PROFILE_CORE);
// Request 24-bit depth buffer (32-bit may not be available on all drivers)
_ = c.SDL_GL_SetAttribute(c.SDL_GL_DEPTH_SIZE, 24);

// 3. Create Window
const window = c.SDL_CreateWindow(
Expand Down Expand Up @@ -279,18 +281,19 @@ pub fn main() !void {

if (in_world or in_pause) {
if (world) |active_world| {
// Calculate matrices
// Calculate matrices using origin-centered view for floating origin rendering
const aspect = screen_w / screen_h;
// TODO: Update camera FOV with settings.fov
const view_proj = camera.getViewProjectionMatrix(aspect);
const view_proj = camera.getViewProjectionMatrixOriginCentered(aspect);

// Bind texture atlas and set uniforms
shader.use();
atlas.bind(0);
shader.setInt("uTexture", 0);
shader.setBool("uUseTexture", settings.textures_enabled);

active_world.render(&shader, view_proj);
// Pass camera position for floating origin chunk rendering
active_world.render(&shader, view_proj, camera.position);

// Render UI (FPS counter)
ui.begin();
Expand Down Expand Up @@ -408,7 +411,7 @@ pub fn main() !void {
if (settings.render_distance > 1) settings.render_distance -= 1;
}
if (drawButton(&ui, .{ .x = value_x + 100.0, .y = setting_y - 5.0, .width = 30.0, .height = 30.0 }, "+", 1.5, mouse_x, mouse_y, mouse_clicked)) {
if (settings.render_distance < 32) settings.render_distance += 1;
settings.render_distance += 1; // No upper limit for experiments
}
setting_y += 50.0;

Expand Down
Loading