diff --git a/README.md b/README.md index 3382c8cd..58cf900c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/engine/core/job_system.zig b/src/engine/core/job_system.zig index 0d0b473c..726c8052 100644 --- a/src/engine/core/job_system.zig +++ b/src/engine/core/job_system.zig @@ -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; @@ -41,6 +45,7 @@ pub const JobQueue = struct { .cond = Condition{}, .jobs = std.PriorityQueue(Job, void, compareJobs).init(allocator, {}), .stopped = false, + .allocator = allocator, }; } @@ -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; diff --git a/src/engine/graphics/camera.zig b/src/engine/graphics/camera.zig index 8d258abc..2f19d21e 100644 --- a/src/engine/graphics/camera.zig +++ b/src/engine/graphics/camera.zig @@ -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, }; @@ -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()); } }; diff --git a/src/engine/graphics/renderer.zig b/src/engine/graphics/renderer.zig index 87e0f01c..bc4881a0 100644 --- a/src/engine/graphics/renderer.zig +++ b/src/engine/graphics/renderer.zig @@ -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); diff --git a/src/engine/math/frustum.zig b/src/engine/math/frustum.zig index 261b766f..9a4616df 100644 --- a/src/engine/math/frustum.zig +++ b/src/engine/math/frustum.zig @@ -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); diff --git a/src/engine/math/mat4.zig b/src/engine/math/mat4.zig index 64a0e746..25284c2a 100644 --- a/src/engine/math/mat4.zig +++ b/src/engine/math/mat4.zig @@ -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; diff --git a/src/main.zig b/src/main.zig index 434e088d..1d769c2d 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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; @@ -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; @@ -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( @@ -279,10 +281,10 @@ 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(); @@ -290,7 +292,8 @@ pub fn main() !void { 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(); @@ -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; diff --git a/src/world/chunk_mesh.zig b/src/world/chunk_mesh.zig index edaeb8fb..600d82c8 100644 --- a/src/world/chunk_mesh.zig +++ b/src/world/chunk_mesh.zig @@ -107,20 +107,20 @@ pub const ChunkMesh = struct { const y0: i32 = @intCast(si * SUBCHUNK_SIZE); const y1: i32 = y0 + SUBCHUNK_SIZE; - const wx: f32 = @floatFromInt(chunk.getWorldX()); - const wz: f32 = @floatFromInt(chunk.getWorldZ()); + // Meshes now use chunk-local coordinates (0-16 range) + // World offset is applied at render time via model matrix for floating origin var sy: i32 = y0; while (sy <= y1) : (sy += 1) { - try self.meshSlice(chunk, neighbors, .top, sy, wx, wz, si, &solid_verts, &fluid_verts); + try self.meshSlice(chunk, neighbors, .top, sy, si, &solid_verts, &fluid_verts); } var sx: i32 = 0; while (sx <= CHUNK_SIZE_X) : (sx += 1) { - try self.meshSlice(chunk, neighbors, .east, sx, wx, wz, si, &solid_verts, &fluid_verts); + try self.meshSlice(chunk, neighbors, .east, sx, si, &solid_verts, &fluid_verts); } var sz: i32 = 0; while (sz <= CHUNK_SIZE_Z) : (sz += 1) { - try self.meshSlice(chunk, neighbors, .south, sz, wx, wz, si, &solid_verts, &fluid_verts); + try self.meshSlice(chunk, neighbors, .south, sz, si, &solid_verts, &fluid_verts); } self.mutex.lock(); @@ -136,7 +136,7 @@ pub const ChunkMesh = struct { side: bool, }; - fn meshSlice(self: *ChunkMesh, chunk: *const Chunk, neighbors: NeighborChunks, axis: Face, s: i32, wx: f32, wz: f32, si: u32, solid_list: *std.ArrayListUnmanaged(f32), fluid_list: *std.ArrayListUnmanaged(f32)) !void { + fn meshSlice(self: *ChunkMesh, chunk: *const Chunk, neighbors: NeighborChunks, axis: Face, s: i32, si: u32, solid_list: *std.ArrayListUnmanaged(f32), fluid_list: *std.ArrayListUnmanaged(f32)) !void { const du: u32 = 16; const dv: u32 = 16; var mask = try self.allocator.alloc(?FaceKey, du * dv); @@ -195,7 +195,7 @@ pub const ChunkMesh = struct { } const target = if (k.block.isTransparent() and k.block != .leaves) fluid_list else solid_list; - try addGreedyFace(self.allocator, target, axis, s, su, sv, width, height, k.block, k.side, wx, wz, si); + try addGreedyFace(self.allocator, target, axis, s, su, sv, width, height, k.block, k.side, si); var dy: u32 = 0; while (dy < height) : (dy += 1) { @@ -278,7 +278,7 @@ fn getBlockCross(chunk: *const Chunk, neighbors: NeighborChunks, x: i32, y: i32, return chunk.getBlockSafe(x, y, z); } -fn addGreedyFace(allocator: std.mem.Allocator, verts: *std.ArrayListUnmanaged(f32), axis: Face, s: i32, u: u32, v: u32, w: u32, h: u32, block: BlockType, forward: bool, wx: f32, wz: f32, si: u32) !void { +fn addGreedyFace(allocator: std.mem.Allocator, verts: *std.ArrayListUnmanaged(f32), axis: Face, s: i32, u: u32, v: u32, w: u32, h: u32, block: BlockType, forward: bool, si: u32) !void { const face = if (forward) axis else switch (axis) { .top => Face.bottom, .east => Face.west, @@ -299,53 +299,54 @@ fn addGreedyFace(allocator: std.mem.Allocator, verts: *std.ArrayListUnmanaged(f3 const sf: f32 = @floatFromInt(s); const uf: f32 = @floatFromInt(u); const vf: f32 = @floatFromInt(v); + // Use chunk-local coordinates (0-16 range) for floating origin rendering var p: [4][3]f32 = undefined; var uv: [4][2]f32 = undefined; if (axis == .top) { const y = sf; if (forward) { - p[0] = .{ wx + uf, y, wz + vf + hf }; - p[1] = .{ wx + uf + wf, y, wz + vf + hf }; - p[2] = .{ wx + uf + wf, y, wz + vf }; - p[3] = .{ wx + uf, y, wz + vf }; + p[0] = .{ uf, y, vf + hf }; + p[1] = .{ uf + wf, y, vf + hf }; + p[2] = .{ uf + wf, y, vf }; + p[3] = .{ uf, y, vf }; uv = [4][2]f32{ .{ 0, hf }, .{ wf, hf }, .{ wf, 0 }, .{ 0, 0 } }; } else { - p[0] = .{ wx + uf, y, wz + vf }; - p[1] = .{ wx + uf + wf, y, wz + vf }; - p[2] = .{ wx + uf + wf, y, wz + vf + hf }; - p[3] = .{ wx + uf, y, wz + vf + hf }; + p[0] = .{ uf, y, vf }; + p[1] = .{ uf + wf, y, vf }; + p[2] = .{ uf + wf, y, vf + hf }; + p[3] = .{ uf, y, vf + hf }; uv = [4][2]f32{ .{ 0, 0 }, .{ wf, 0 }, .{ wf, hf }, .{ 0, hf } }; } } else if (axis == .east) { - const x = wx + sf; + const x = sf; const y0: f32 = @floatFromInt(si * SUBCHUNK_SIZE); if (forward) { - p[0] = .{ x, y0 + uf, wz + vf + hf }; - p[1] = .{ x, y0 + uf, wz + vf }; - p[2] = .{ x, y0 + uf + wf, wz + vf }; - p[3] = .{ x, y0 + uf + wf, wz + vf + hf }; + p[0] = .{ x, y0 + uf, vf + hf }; + p[1] = .{ x, y0 + uf, vf }; + p[2] = .{ x, y0 + uf + wf, vf }; + p[3] = .{ x, y0 + uf + wf, vf + hf }; uv = [4][2]f32{ .{ hf, 0 }, .{ 0, 0 }, .{ 0, wf }, .{ hf, wf } }; } else { - p[0] = .{ x, y0 + uf, wz + vf }; - p[1] = .{ x, y0 + uf, wz + vf + hf }; - p[2] = .{ x, y0 + uf + wf, wz + vf + hf }; - p[3] = .{ x, y0 + uf + wf, wz + vf }; + p[0] = .{ x, y0 + uf, vf }; + p[1] = .{ x, y0 + uf, vf + hf }; + p[2] = .{ x, y0 + uf + wf, vf + hf }; + p[3] = .{ x, y0 + uf + wf, vf }; uv = [4][2]f32{ .{ 0, 0 }, .{ hf, 0 }, .{ hf, wf }, .{ 0, wf } }; } } else { - const z = wz + sf; + const z = sf; const y0: f32 = @floatFromInt(si * SUBCHUNK_SIZE); if (forward) { - p[0] = .{ wx + uf, y0 + vf, z }; - p[1] = .{ wx + uf + wf, y0 + vf, z }; - p[2] = .{ wx + uf + wf, y0 + vf + hf, z }; - p[3] = .{ wx + uf, y0 + vf + hf, z }; + p[0] = .{ uf, y0 + vf, z }; + p[1] = .{ uf + wf, y0 + vf, z }; + p[2] = .{ uf + wf, y0 + vf + hf, z }; + p[3] = .{ uf, y0 + vf + hf, z }; uv = [4][2]f32{ .{ 0, 0 }, .{ wf, 0 }, .{ wf, hf }, .{ 0, hf } }; } else { - p[0] = .{ wx + uf + wf, y0 + vf, z }; - p[1] = .{ wx + uf, y0 + vf, z }; - p[2] = .{ wx + uf, y0 + vf + hf, z }; - p[3] = .{ wx + uf + wf, y0 + vf + hf, z }; + p[0] = .{ uf + wf, y0 + vf, z }; + p[1] = .{ uf, y0 + vf, z }; + p[2] = .{ uf, y0 + vf + hf, z }; + p[3] = .{ uf + wf, y0 + vf + hf, z }; uv = [4][2]f32{ .{ wf, 0 }, .{ 0, 0 }, .{ 0, hf }, .{ wf, hf } }; } } diff --git a/src/world/world.zig b/src/world/world.zig index 7086c8dd..2915c09b 100644 --- a/src/world/world.zig +++ b/src/world/world.zig @@ -103,8 +103,8 @@ pub const World = struct { .last_pc = .{ .x = 9999, .z = 9999 }, }; - world.gen_pool = try WorkerPool.init(allocator, 2, gen_queue, world, processGenJob); - world.mesh_pool = try WorkerPool.init(allocator, 2, mesh_queue, world, processMeshJob); + world.gen_pool = try WorkerPool.init(allocator, 4, gen_queue, world, processGenJob); + world.mesh_pool = try WorkerPool.init(allocator, 3, mesh_queue, world, processMeshJob); return world; } @@ -140,6 +140,20 @@ pub const World = struct { self.chunks_mutex.unlock(); return; }; + + // Skip if chunk is now too far from player (stale job) + const dx = job.chunk_x - self.last_pc.x; + const dz = job.chunk_z - self.last_pc.z; + const max_dist = self.render_distance + 2; + if (dx * dx + dz * dz > max_dist * max_dist) { + // Reset state so it can be re-queued if player returns + if (chunk_data.chunk.state == .generating) { + chunk_data.chunk.state = .missing; + } + self.chunks_mutex.unlock(); + return; + } + chunk_data.chunk.pin(); self.chunks_mutex.unlock(); @@ -161,6 +175,18 @@ pub const World = struct { return; }; + // Skip if chunk is now too far from player (stale job) + const dx = job.chunk_x - self.last_pc.x; + const dz = job.chunk_z - self.last_pc.z; + const max_dist = self.render_distance + 2; + if (dx * dx + dz * dz > max_dist * max_dist) { + if (chunk_data.chunk.state == .meshing) { + chunk_data.chunk.state = .generated; + } + self.chunks_mutex.unlock(); + return; + } + chunk_data.chunk.pin(); const neighbors = NeighborChunks{ .north = if (self.chunks.get(ChunkKey{ .x = job.chunk_x, .z = job.chunk_z - 1 })) |d| d: { @@ -255,6 +281,10 @@ pub const World = struct { if (moved) { self.last_pc = .{ .x = pc.chunk_x, .z = pc.chunk_z }; + // Re-prioritize queued jobs based on new player position + try self.gen_queue.updatePlayerPos(pc.chunk_x, pc.chunk_z); + try self.mesh_queue.updatePlayerPos(pc.chunk_x, pc.chunk_z); + var cz = pc.chunk_z - self.render_distance; while (cz <= pc.chunk_z + self.render_distance) : (cz += 1) { var cx = pc.chunk_x - self.render_distance; @@ -311,14 +341,16 @@ pub const World = struct { } self.chunks_mutex.unlock(); - if (self.upload_queue.items.len > 0) { + // Upload multiple meshes per frame (up to 4) for faster chunk appearance + const max_uploads: usize = 4; + var uploads: usize = 0; + while (self.upload_queue.items.len > 0 and uploads < max_uploads) { const data = self.upload_queue.orderedRemove(0); data.mesh.upload(); - // Only transition to renderable if we were still in the uploading state. - // If we were set back to .generated, we stay there. if (data.chunk.state == .uploading) { data.chunk.state = .renderable; } + uploads += 1; } const unload_dist_sq = (self.render_distance + 2) * (self.render_distance + 2); @@ -354,7 +386,9 @@ pub const World = struct { self.chunks_mutex.unlock(); } - pub fn render(self: *World, shader: *const Shader, view_proj: Mat4) void { + /// Render all visible chunks using camera-relative coordinates (floating origin) + /// This prevents floating-point precision issues at large world coordinates + pub fn render(self: *World, shader: *const Shader, view_proj: Mat4, camera_pos: Vec3) void { const frustum = Frustum.fromViewProj(view_proj); self.last_render_stats = .{}; @@ -367,7 +401,8 @@ pub const World = struct { if (data.chunk.state != .renderable) continue; self.last_render_stats.chunks_total += 1; - if (!frustum.intersectsChunk(key.x, key.z)) { + // Use camera-relative frustum culling + if (!frustum.intersectsChunkRelative(key.x, key.z, camera_pos.x, camera_pos.y, camera_pos.z)) { self.last_render_stats.chunks_culled += 1; continue; } @@ -377,7 +412,18 @@ pub const World = struct { self.last_render_stats.vertices_rendered += s.count_solid; } - shader.setMat4("transform", &view_proj.data); + // Camera-relative chunk position (floating origin) + // Chunk mesh vertices are in local coords (0-16), we translate them + // relative to camera position to keep values small for GPU precision + const chunk_world_x: f32 = @floatFromInt(key.x * CHUNK_SIZE_X); + const chunk_world_z: f32 = @floatFromInt(key.z * CHUNK_SIZE_Z); + const rel_x = chunk_world_x - camera_pos.x; + const rel_z = chunk_world_z - camera_pos.z; + const rel_y = -camera_pos.y; // Y=0 in chunk space maps to -camera_y in view space + + const model = Mat4.translate(Vec3.init(rel_x, rel_y, rel_z)); + const mvp = view_proj.multiply(model); + shader.setMat4("transform", &mvp.data); data.mesh.draw(.solid); } @@ -387,13 +433,21 @@ pub const World = struct { const data = entry.value_ptr.*; if (data.chunk.state != .renderable) continue; const key = entry.key_ptr.*; - if (!frustum.intersectsChunk(key.x, key.z)) continue; + if (!frustum.intersectsChunkRelative(key.x, key.z, camera_pos.x, camera_pos.y, camera_pos.z)) continue; for (data.mesh.subchunks) |s| { self.last_render_stats.vertices_rendered += s.count_fluid; } - shader.setMat4("transform", &view_proj.data); + const chunk_world_x: f32 = @floatFromInt(key.x * CHUNK_SIZE_X); + const chunk_world_z: f32 = @floatFromInt(key.z * CHUNK_SIZE_Z); + const rel_x = chunk_world_x - camera_pos.x; + const rel_z = chunk_world_z - camera_pos.z; + const rel_y = -camera_pos.y; + + const model = Mat4.translate(Vec3.init(rel_x, rel_y, rel_z)); + const mvp = view_proj.multiply(model); + shader.setMat4("transform", &mvp.data); data.mesh.draw(.fluid); }