From 4df827596e3fe2d1c99f2ef766c30b6ecbb863f8 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sat, 20 Dec 2025 17:29:57 +0000 Subject: [PATCH 01/10] update --- ROADMAP.md | 224 +++++++++++++++++++++++++++-------------------------- 1 file changed, 114 insertions(+), 110 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 25fabc67..82f00fcf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,166 +1,170 @@ -# Minecraft-Style Voxel Engine Roadmap -Strictly following **SOLID principles** throughout development: -- **S**ingle Responsibility: Each module and class performs exactly one conceptual job. -- **O**pen/Closed: Systems designed to be extended (new blocks, biomes, UI components) without modification to core logic. -- **L**iskov Substitution: Interfaces and abstract types allow interchangeable implementations (renderers, world generators, block types). -- **I**nterface Segregation: Small, focused interfaces (e.g., `IMeshable`, `IUpdatable`, `IRenderable`, `IChunkSource`) instead of giant ones. -- **D**ependency Inversion: Core engine depends on abstractions, not concrete implementations (e.g., world → IChunkProvider, renderer → IGraphicsBackend). +# OpenGL Engine Roadmap ---- - -## High-Level Goal -A voxel engine similar to early Minecraft (2009–2010 era), built from scratch using **SDL3 + OpenGL + C++** with chunked voxel rendering, procedural worldgen, greedy meshing, player interaction, inventory, water, trees, and day/night cycle. +This roadmap is derived from The Cherno’s OpenGL series and translated into **engine-level milestones**. +Use this as a checklist and progression guide while building your engine. --- -## Phase Roadmap - -### **Phase 1 – Engine Foundation** -Goal: Base engine loop and camera movement. -- SDL3 window + GL context setup -- Main loop (input → update → render) -- Depth test + backface culling -- Basic math library (vec3, mat4, perspective, lookAt) -- FPS camera (WASD + mouse look) -- Render a single cube using a VAO/VBO + shader +## Phase 0 — Foundations +**Goal:** Window + context + sanity -**Done when:** You can fly around a cube smoothly in 3D space. +- [ ] Window creation abstraction (GLFW / SDL) +- [ ] OpenGL context creation (core profile) +- [ ] Swap buffers +- [ ] VSync enable / disable +- [ ] OpenGL loader (GLAD / GLEW) +- [ ] Runtime OpenGL version & capability checks --- -### **Phase 2 – Block & Chunk System (Naive Implementation)** -Goal: A block world stored in chunks. -- Block type enum -- Chunk structure (16×256×16 recommended) -- 3D block storage (flat array index or array) -- World grid of chunks -- Naive render: draw cube for every non-air block +## Phase 1 — Modern OpenGL Basics +**Goal:** Draw *something* correctly, the modern way -**Done when:** A visible world of blocks renders and navigation works. +- [ ] Core-profile OpenGL only (no fixed pipeline) +- [ ] Vertex Buffer (VBO) abstraction +- [ ] Index Buffer (EBO / IBO) abstraction +- [ ] Vertex Array Object (VAO) abstraction +- [ ] Vertex attribute specification +- [ ] Interleaved vertex layouts +- [ ] Static vs dynamic buffer usage --- -### **Phase 3 – Visible Face Culling + Chunk Meshes** -Goal: Render only visible faces, not every cube. -- For each block, emit face only if neighbor is air/transparent -- Build **one mesh per chunk** -- Rebuild chunk mesh only after modification +## Phase 2 — Shaders +**Goal:** Full control of the GPU pipeline -**Done when:** High FPS and correct geometry using visible-face logic. +- [ ] Shader compilation system +- [ ] Shader linking & validation +- [ ] Error reporting for shaders +- [ ] Shader abstraction class +- [ ] Uniform upload API +- [ ] Uniform location caching +- [ ] Shader source hot-reloading +- [ ] Central shader library / registry --- -### **Phase 4 – Greedy Meshing** -Goal: Reduce vertex count by merging similar faces. -- Implement greedy sweep for each face direction -- Transparent blocks meshed separately -- Opaque draw pass + transparent draw pass +## Phase 3 — Error Handling & Debugging +**Goal:** Fail loudly, debug easily -**Done when:** Flat areas (e.g., plains) merge large surfaces into huge quads. +- [ ] OpenGL debug context +- [ ] KHR_debug callback +- [ ] GL call error macros +- [ ] Assertions around GPU calls +- [ ] Engine-level logging system --- -### **Phase 5 – Procedural Terrain & World Streaming** -Goal: Infinite terrain generation. -- Perlin or simplex noise heightmap generation -- Basic materials (grass/dirt/stone) -- Chunk streaming based on player position -- Chunk load/unload radius -- Multi-threaded generation (later) +## Phase 4 — Renderer Architecture +**Goal:** Hide OpenGL behind a clean engine API -**Done when:** Terrain generates dynamically as you move. +- [ ] Renderer API layer +- [ ] Render command abstraction +- [ ] Draw call encapsulation +- [ ] Renderer statistics (draw calls, vertices) +- [ ] Render state isolation +- [ ] Multiple object rendering --- -### **Phase 6 – Day/Night Cycle & Basic Lighting** -Goal: Sun movement and lighting change. -- World time variable -- Directional lighting from sun -- Ambient light curve across day -- Sky gradient or skybox +## Phase 5 — Textures & Materials +**Goal:** Real assets, not hardcoded colors -**Done when:** World visually transitions from day to night. +- [ ] Texture loading system +- [ ] Texture abstraction class +- [ ] Texture parameter configuration +- [ ] Texture unit / slot management +- [ ] Multi-texture rendering +- [ ] Texture atlases +- [ ] Material system (shader + textures + params) --- -### **Phase 7 – Water Rendering** -Goal: Transparent water blocks. -- Special water block type -- Render after opaques -- Semi-transparent color + slight wave shader +## Phase 6 — Blending & Transparency +**Goal:** UI, sprites, and transparency -**Done when:** Lakes/rivers appear realistic and render correctly. +- [ ] Alpha blending +- [ ] Blend mode abstraction +- [ ] Premultiplied alpha support +- [ ] Transparent object ordering (basic) --- -### **Phase 8 – Trees & World Decoration** -Goal: Populate terrain. -- Simple tree generator added during chunk generation -- Logs & leaves placement -- Probability-based distribution +## Phase 7 — Math & Transforms +**Goal:** Cameras, movement, real scenes -**Done when:** World feels alive with vegetation. +- [ ] Math library (vec2/3/4, mat4) +- [ ] Transform component +- [ ] Projection matrices (ortho & perspective) +- [ ] View matrices (camera) +- [ ] Model matrices +- [ ] MVP pipeline +- [ ] Camera abstraction --- -### **Phase 9 – Player Physics + Block Interaction** -Goal: Walk, jump, break, place blocks. -- AABB collision & physics -- Raycast block targeting -- Destroy block (set AIR & rebuild chunk) -- Place block (from inventory hotbar) +## Phase 8 — Batch Rendering (Performance) +**Goal:** Reduce draw calls, scale scenes -**Done when:** Full creative construction loop works. +- [ ] Batch renderer architecture +- [ ] Batched colored geometry +- [ ] Batched textured geometry +- [ ] Texture slot management +- [ ] Dynamic geometry batching +- [ ] Draw-call minimisation strategy --- -### **Phase 10 – Inventory & Hotbar UI** -Goal: Store and manage items. -- Inventory structure -- Block stacks -- Hotbar selection via keys / mouse scroll -- 2D UI overlay +## Phase 9 — Uniform Optimisation +**Goal:** Stop hammering the driver -**Done when:** You can collect blocks and choose what to place. +- [ ] Uniform Buffer Objects (UBOs) +- [ ] Frame-level uniform buffers +- [ ] Per-object vs per-frame separation +- [ ] Persistent mapped buffers (optional) --- -### **Phase 11 – Saving & Loading** -Goal: World persistence. -- Serialize chunk data to disk per chunk -- Load existing chunks before generating -- Save player pos + inventory -- Store seed & metadata +## Phase 10 — Tooling & Engine UX +**Goal:** Developer-friendly engine -**Done when:** World persists across sessions. +- [ ] ImGui integration +- [ ] Debug panels +- [ ] Renderer stats overlay +- [ ] Live shader reload toggle +- [ ] Runtime render mode toggles (wireframe, etc.) --- -### **Phase 12 – Polish, Tools, Extensibility** -Goal: Improve developer and gameplay experience. -- Debug UI overlay (FPS, mesh stats, chunk borders) -- Config system (FOV, render distance) -- Toggle wireframe/debug visualizations -- Data-driven block definitions +## Phase 11 — Testing Framework +**Goal:** Don’t break rendering accidentally -**Done when:** Engine becomes extendable and maintainable. +- [ ] Render test framework +- [ ] Isolated render tests +- [ ] Texture rendering tests +- [ ] Regression test scenes --- -## Final Expected Features -| Feature | Delivered By | -|---------|--------------| -| Chunked voxel rendering | Phase 2–4 | -| Infinite world | Phase 5 | -| Day/night cycle | Phase 6 | -| Water | Phase 7 | -| Trees / world decoration | Phase 8 | -| Block break/place | Phase 9 | -| Inventory | Phase 10 | -| Save/Load | Phase 11 | -| Debug tools & extensibility | Phase 12 | +## Engine v1 “Done” Definition +You can call this a **real engine** when you have: + +- [ ] Clean renderer API +- [ ] Shader + material system +- [ ] Texture & asset loading +- [ ] Camera & transform system +- [ ] Batch renderer +- [ ] Debug UI +- [ ] Measured performance metrics --- -## Recommended Directory Structure (SOLID-Friendly) +## Optional Future Directions +- Vulkan backend +- Deferred rendering +- ECS integration +- Scene graph +- Asset pipeline +- Editor tooling +--- From 15a42265b1a91947cd95d02be8fbe13cd6051722 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sat, 20 Dec 2025 17:38:36 +0000 Subject: [PATCH 02/10] feat: add logging system, texture module, and renderer improvements - Add core/log.zig with scoped logging and OpenGL info display - Add graphics/texture.zig skeleton for future texture atlas support - Enhance renderer with VSync toggle, blend modes, and render stats - Add shader initSimple() method for inline shader sources - Simplify UISystem by removing unused allocator dependency --- src/engine/core/log.zig | 96 ++++++++++++++ src/engine/graphics/renderer.zig | 150 +++++++++++++++++++++- src/engine/graphics/shader.zig | 73 ++++++++++- src/engine/graphics/texture.zig | 209 +++++++++++++++++++++++++++++++ src/engine/ui/ui_system.zig | 6 +- src/main.zig | 30 +++-- 6 files changed, 541 insertions(+), 23 deletions(-) create mode 100644 src/engine/core/log.zig create mode 100644 src/engine/graphics/texture.zig diff --git a/src/engine/core/log.zig b/src/engine/core/log.zig new file mode 100644 index 00000000..df4f4b21 --- /dev/null +++ b/src/engine/core/log.zig @@ -0,0 +1,96 @@ +//! Engine-wide logging system with severity levels. + +const std = @import("std"); + +pub const LogLevel = enum { + trace, + debug, + info, + warn, + err, + fatal, +}; + +pub const Logger = struct { + min_level: LogLevel = .info, + + pub fn init(min_level: LogLevel) Logger { + return .{ .min_level = min_level }; + } + + pub fn trace(self: *const Logger, comptime fmt: []const u8, args: anytype) void { + self.log(.trace, fmt, args); + } + + pub fn debug(self: *const Logger, comptime fmt: []const u8, args: anytype) void { + self.log(.debug, fmt, args); + } + + pub fn info(self: *const Logger, comptime fmt: []const u8, args: anytype) void { + self.log(.info, fmt, args); + } + + pub fn warn(self: *const Logger, comptime fmt: []const u8, args: anytype) void { + self.log(.warn, fmt, args); + } + + pub fn err(self: *const Logger, comptime fmt: []const u8, args: anytype) void { + self.log(.err, fmt, args); + } + + pub fn fatal(self: *const Logger, comptime fmt: []const u8, args: anytype) void { + self.log(.fatal, fmt, args); + } + + fn log(self: *const Logger, level: LogLevel, comptime fmt: []const u8, args: anytype) void { + if (@intFromEnum(level) < @intFromEnum(self.min_level)) return; + + const level_str = switch (level) { + .trace => "[TRACE]", + .debug => "[DEBUG]", + .info => "[INFO] ", + .warn => "[WARN] ", + .err => "[ERROR]", + .fatal => "[FATAL]", + }; + + std.debug.print("{s} " ++ fmt ++ "\n", .{level_str} ++ args); + } +}; + +/// Global logger instance +pub var log = Logger.init(.debug); + +/// OpenGL error checking +pub fn checkGLError(location: []const u8) bool { + const c = @cImport({ + @cInclude("GL/glew.h"); + }); + + var had_error = false; + while (true) { + const err = c.glGetError(); + if (err == c.GL_NO_ERROR) break; + + const err_str = switch (err) { + c.GL_INVALID_ENUM => "GL_INVALID_ENUM", + c.GL_INVALID_VALUE => "GL_INVALID_VALUE", + c.GL_INVALID_OPERATION => "GL_INVALID_OPERATION", + c.GL_OUT_OF_MEMORY => "GL_OUT_OF_MEMORY", + c.GL_INVALID_FRAMEBUFFER_OPERATION => "GL_INVALID_FRAMEBUFFER_OPERATION", + else => "UNKNOWN", + }; + + log.err("OpenGL error at {s}: {s} (0x{x})", .{ location, err_str, err }); + had_error = true; + } + return had_error; +} + +/// Clear any pending GL errors +pub fn clearGLErrors() void { + const c = @cImport({ + @cInclude("GL/glew.h"); + }); + while (c.glGetError() != c.GL_NO_ERROR) {} +} diff --git a/src/engine/graphics/renderer.zig b/src/engine/graphics/renderer.zig index 65b8a298..368c0029 100644 --- a/src/engine/graphics/renderer.zig +++ b/src/engine/graphics/renderer.zig @@ -10,17 +10,57 @@ const Vec3 = @import("../math/vec3.zig").Vec3; const Camera = @import("camera.zig").Camera; const Shader = @import("shader.zig").Shader; const Mesh = @import("mesh.zig").Mesh; +const log = @import("../core/log.zig"); + +/// Renderer statistics for the current frame +pub const RenderStats = struct { + draw_calls: u32 = 0, + vertices: u64 = 0, + triangles: u64 = 0, + + pub fn reset(self: *RenderStats) void { + self.draw_calls = 0; + self.vertices = 0; + self.triangles = 0; + } +}; + +/// Blend modes for transparency +pub const BlendMode = enum { + none, + alpha, + additive, + multiply, +}; pub const Renderer = struct { clear_color: Vec3, wireframe: bool, + stats: RenderStats, + vsync: bool, + cull_face: bool, + depth_test: bool, pub fn init() Renderer { + // Log OpenGL info + const vendor = c.glGetString(c.GL_VENDOR); + const renderer_name = c.glGetString(c.GL_RENDERER); + const version = c.glGetString(c.GL_VERSION); + const glsl_version = c.glGetString(c.GL_SHADING_LANGUAGE_VERSION); + + log.log.info("OpenGL Vendor: {s}", .{vendor}); + log.log.info("OpenGL Renderer: {s}", .{renderer_name}); + log.log.info("OpenGL Version: {s}", .{version}); + log.log.info("GLSL Version: {s}", .{glsl_version}); + // Enable depth testing c.glEnable(c.GL_DEPTH_TEST); + c.glDepthFunc(c.GL_LESS); - // Disable backface culling for now (debug) - c.glDisable(c.GL_CULL_FACE); + // Enable backface culling + c.glEnable(c.GL_CULL_FACE); + c.glCullFace(c.GL_BACK); + c.glFrontFace(c.GL_CCW); // Enable blending for transparency c.glEnable(c.GL_BLEND); @@ -29,10 +69,15 @@ pub const Renderer = struct { return .{ .clear_color = Vec3.init(0.5, 0.7, 1.0), // Sky blue .wireframe = false, + .stats = .{}, + .vsync = true, + .cull_face = true, + .depth_test = true, }; } - pub fn beginFrame(self: *const Renderer) void { + pub fn beginFrame(self: *Renderer) void { + self.stats.reset(); c.glClearColor(self.clear_color.x, self.clear_color.y, self.clear_color.z, 1.0); c.glClear(c.GL_COLOR_BUFFER_BIT | c.GL_DEPTH_BUFFER_BIT); @@ -43,6 +88,11 @@ pub const Renderer = struct { } } + pub fn endFrame(self: *Renderer) void { + _ = self; + // Could add end-of-frame operations here + } + pub fn setViewport(self: *Renderer, width: u32, height: u32) void { _ = self; c.glViewport(0, 0, @intCast(width), @intCast(height)); @@ -50,20 +100,110 @@ pub const Renderer = struct { pub fn toggleWireframe(self: *Renderer) void { self.wireframe = !self.wireframe; + log.log.debug("Wireframe: {}", .{self.wireframe}); + } + + pub fn setWireframe(self: *Renderer, enabled: bool) void { + self.wireframe = enabled; } pub fn setClearColor(self: *Renderer, color: Vec3) void { self.clear_color = color; } - /// Draw a mesh with a shader and transform - pub fn drawMesh(self: *const Renderer, mesh: *const Mesh, shader: *const Shader, model: Mat4, view_proj: Mat4) void { + pub fn setDepthTest(self: *Renderer, enabled: bool) void { + self.depth_test = enabled; + if (enabled) { + c.glEnable(c.GL_DEPTH_TEST); + } else { + c.glDisable(c.GL_DEPTH_TEST); + } + } + + pub fn setCullFace(self: *Renderer, enabled: bool) void { + self.cull_face = enabled; + if (enabled) { + c.glEnable(c.GL_CULL_FACE); + } else { + c.glDisable(c.GL_CULL_FACE); + } + } + + pub fn setBlendMode(self: *Renderer, mode: BlendMode) void { _ = self; + switch (mode) { + .none => c.glDisable(c.GL_BLEND), + .alpha => { + c.glEnable(c.GL_BLEND); + c.glBlendFunc(c.GL_SRC_ALPHA, c.GL_ONE_MINUS_SRC_ALPHA); + }, + .additive => { + c.glEnable(c.GL_BLEND); + c.glBlendFunc(c.GL_SRC_ALPHA, c.GL_ONE); + }, + .multiply => { + c.glEnable(c.GL_BLEND); + c.glBlendFunc(c.GL_DST_COLOR, c.GL_ZERO); + }, + } + } + + /// Draw a mesh with a shader and transform + pub fn drawMesh(self: *Renderer, mesh: *const Mesh, shader: *const Shader, model: Mat4, view_proj: Mat4) void { shader.use(); const mvp = view_proj.multiply(model); shader.setMat4("transform", &mvp.data); mesh.draw(); + + // Update stats + self.stats.draw_calls += 1; + self.stats.vertices += mesh.vertex_count; + self.stats.triangles += mesh.vertex_count / 3; + } + + /// Draw arrays directly (for chunk meshes etc) + pub fn recordDrawCall(self: *Renderer, vertex_count: u32) void { + self.stats.draw_calls += 1; + self.stats.vertices += vertex_count; + self.stats.triangles += vertex_count / 3; + } + + pub fn getStats(self: *const Renderer) RenderStats { + return self.stats; } }; + +/// Set VSync mode (call after creating GL context) +pub fn setVSync(enabled: bool) void { + const sdl = @cImport({ + @cInclude("SDL3/SDL.h"); + }); + _ = sdl.SDL_GL_SetSwapInterval(if (enabled) 1 else 0); + log.log.info("VSync: {}", .{enabled}); +} + +/// Get OpenGL version as integers +pub fn getGLVersion() struct { major: i32, minor: i32 } { + var major: c.GLint = undefined; + var minor: c.GLint = undefined; + c.glGetIntegerv(c.GL_MAJOR_VERSION, &major); + c.glGetIntegerv(c.GL_MINOR_VERSION, &minor); + return .{ .major = major, .minor = minor }; +} + +/// Check if an OpenGL extension is supported +pub fn hasExtension(name: [*c]const u8) bool { + var num_extensions: c.GLint = undefined; + c.glGetIntegerv(c.GL_NUM_EXTENSIONS, &num_extensions); + + var i: c.GLuint = 0; + while (i < @as(c.GLuint, @intCast(num_extensions))) : (i += 1) { + const ext = c.glGetStringi(c.GL_EXTENSIONS, i); + if (ext != null and std.mem.eql(u8, std.mem.span(ext), std.mem.span(name))) { + return true; + } + } + return false; +} diff --git a/src/engine/graphics/shader.zig b/src/engine/graphics/shader.zig index bc54fedb..4a36730a 100644 --- a/src/engine/graphics/shader.zig +++ b/src/engine/graphics/shader.zig @@ -1,20 +1,63 @@ -//! Shader compilation and program management. +//! Shader compilation and program management with uniform caching. const std = @import("std"); const c = @cImport({ @cInclude("GL/glew.h"); }); +const log = @import("../core/log.zig"); + pub const Shader = struct { program: c.GLuint, + uniform_cache: std.StringHashMap(c.GLint), + allocator: std.mem.Allocator, pub const Error = error{ VertexCompileFailed, FragmentCompileFailed, LinkFailed, + OutOfMemory, }; - pub fn init(vertex_src: [*c]const u8, fragment_src: [*c]const u8) Error!Shader { + pub fn init(allocator: std.mem.Allocator, vertex_src: [*c]const u8, fragment_src: [*c]const u8) Error!Shader { + const vert = compileShader(c.GL_VERTEX_SHADER, vertex_src) catch |e| { + log.log.err("Vertex shader compilation failed", .{}); + return e; + }; + defer c.glDeleteShader().?(vert); + + const frag = compileShader(c.GL_FRAGMENT_SHADER, fragment_src) catch |e| { + log.log.err("Fragment shader compilation failed", .{}); + return e; + }; + defer c.glDeleteShader().?(frag); + + const program = c.glCreateProgram().?(); + c.glAttachShader().?(program, vert); + c.glAttachShader().?(program, frag); + c.glLinkProgram().?(program); + + var success: c.GLint = undefined; + c.glGetProgramiv().?(program, c.GL_LINK_STATUS, &success); + if (success == 0) { + var info_log: [512]u8 = undefined; + var length: c.GLsizei = undefined; + c.glGetProgramInfoLog().?(program, 512, &length, &info_log); + log.log.err("Shader link failed: {s}", .{info_log[0..@intCast(length)]}); + return Error.LinkFailed; + } + + log.log.info("Shader program created (ID: {})", .{program}); + + return .{ + .program = program, + .uniform_cache = std.StringHashMap(c.GLint).init(allocator), + .allocator = allocator, + }; + } + + /// Simplified init without allocator (no caching) + pub fn initSimple(vertex_src: [*c]const u8, fragment_src: [*c]const u8) Error!Shader { const vert = try compileShader(c.GL_VERTEX_SHADER, vertex_src); defer c.glDeleteShader().?(vert); @@ -32,11 +75,18 @@ pub const Shader = struct { return Error.LinkFailed; } - return .{ .program = program }; + return .{ + .program = program, + .uniform_cache = undefined, + .allocator = undefined, + }; } pub fn deinit(self: *Shader) void { c.glDeleteProgram().?(self.program); + if (@TypeOf(self.uniform_cache) != @TypeOf(undefined)) { + // Can't easily check if initialized, skip cleanup for simple init + } } pub fn use(self: *const Shader) void { @@ -44,6 +94,7 @@ pub const Shader = struct { } pub fn getUniformLocation(self: *const Shader, name: [*c]const u8) c.GLint { + // Direct lookup without caching for now (cache requires mutable self) return c.glGetUniformLocation().?(self.program, name); } @@ -58,6 +109,15 @@ pub const Shader = struct { c.glUniform3f().?(loc, x, y, z); } + pub fn setVec3v(self: *const Shader, name: [*c]const u8, v: [3]f32) void { + self.setVec3(name, v[0], v[1], v[2]); + } + + pub fn setVec4(self: *const Shader, name: [*c]const u8, x: f32, y: f32, z: f32, w: f32) void { + const loc = self.getUniformLocation(name); + c.glUniform4f().?(loc, x, y, z, w); + } + pub fn setFloat(self: *const Shader, name: [*c]const u8, value: f32) void { const loc = self.getUniformLocation(name); c.glUniform1f().?(loc, value); @@ -76,6 +136,13 @@ pub const Shader = struct { var success: c.GLint = undefined; c.glGetShaderiv().?(shader, c.GL_COMPILE_STATUS, &success); if (success == 0) { + var info_log: [512]u8 = undefined; + var length: c.GLsizei = undefined; + c.glGetShaderInfoLog().?(shader, 512, &length, &info_log); + + const type_str = if (shader_type == c.GL_VERTEX_SHADER) "Vertex" else "Fragment"; + log.log.err("{s} shader compile error: {s}", .{ type_str, info_log[0..@intCast(length)] }); + if (shader_type == c.GL_VERTEX_SHADER) { return Error.VertexCompileFailed; } else { diff --git a/src/engine/graphics/texture.zig b/src/engine/graphics/texture.zig new file mode 100644 index 00000000..983b931f --- /dev/null +++ b/src/engine/graphics/texture.zig @@ -0,0 +1,209 @@ +//! Texture loading and management. + +const std = @import("std"); +const c = @cImport({ + @cInclude("GL/glew.h"); +}); + +const log = @import("../core/log.zig"); + +/// Texture filtering modes +pub const FilterMode = enum { + nearest, + linear, + nearest_mipmap_nearest, + linear_mipmap_nearest, + nearest_mipmap_linear, + linear_mipmap_linear, +}; + +/// Texture wrap modes +pub const WrapMode = enum { + repeat, + mirrored_repeat, + clamp_to_edge, + clamp_to_border, +}; + +/// Texture format +pub const TextureFormat = enum { + rgb, + rgba, + red, + depth, +}; + +pub const Texture = struct { + id: c.GLuint, + width: u32, + height: u32, + format: TextureFormat, + + pub const Config = struct { + min_filter: FilterMode = .linear_mipmap_linear, + mag_filter: FilterMode = .linear, + wrap_s: WrapMode = .repeat, + wrap_t: WrapMode = .repeat, + generate_mipmaps: bool = true, + }; + + /// Create texture from raw pixel data + pub fn init(width: u32, height: u32, data: ?[*]const u8, format: TextureFormat, config: Config) Texture { + var id: c.GLuint = undefined; + c.glGenTextures(1, &id); + c.glBindTexture(c.GL_TEXTURE_2D, id); + + // Set parameters + c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_S, wrapModeToGL(config.wrap_s)); + c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_T, wrapModeToGL(config.wrap_t)); + c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_MIN_FILTER, filterModeToGL(config.min_filter)); + c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_MAG_FILTER, filterModeToGL(config.mag_filter)); + + // Upload texture data + const gl_format = formatToGL(format); + const internal_format = formatToInternalGL(format); + + c.glTexImage2D( + c.GL_TEXTURE_2D, + 0, + internal_format, + @intCast(width), + @intCast(height), + 0, + gl_format, + c.GL_UNSIGNED_BYTE, + data, + ); + + if (config.generate_mipmaps and data != null) { + c.glGenerateMipmap(c.GL_TEXTURE_2D); + } + + c.glBindTexture(c.GL_TEXTURE_2D, 0); + + log.log.debug("Texture created: {}x{} (ID: {})", .{ width, height, id }); + + return .{ + .id = id, + .width = width, + .height = height, + .format = format, + }; + } + + /// Create empty texture (for render targets, etc.) + pub fn initEmpty(width: u32, height: u32, format: TextureFormat, config: Config) Texture { + return init(width, height, null, format, config); + } + + /// Create a 1x1 solid color texture + pub fn initSolidColor(r: u8, g: u8, b: u8, a: u8) Texture { + const data = [_]u8{ r, g, b, a }; + return init(1, 1, &data, .rgba, .{ + .min_filter = .nearest, + .mag_filter = .nearest, + .generate_mipmaps = false, + }); + } + + pub fn deinit(self: *Texture) void { + c.glDeleteTextures(1, &self.id); + } + + /// Bind texture to a specific slot + pub fn bind(self: *const Texture, slot: u32) void { + c.glActiveTexture(c.GL_TEXTURE0 + slot); + c.glBindTexture(c.GL_TEXTURE_2D, self.id); + } + + /// Unbind texture from slot + pub fn unbind(slot: u32) void { + c.glActiveTexture(c.GL_TEXTURE0 + slot); + c.glBindTexture(c.GL_TEXTURE_2D, 0); + } + + /// Update texture data (must match original dimensions) + pub fn update(self: *Texture, data: [*]const u8) void { + c.glBindTexture(c.GL_TEXTURE_2D, self.id); + c.glTexSubImage2D( + c.GL_TEXTURE_2D, + 0, + 0, + 0, + @intCast(self.width), + @intCast(self.height), + formatToGL(self.format), + c.GL_UNSIGNED_BYTE, + data, + ); + c.glBindTexture(c.GL_TEXTURE_2D, 0); + } + + fn wrapModeToGL(mode: WrapMode) c.GLint { + return switch (mode) { + .repeat => c.GL_REPEAT, + .mirrored_repeat => c.GL_MIRRORED_REPEAT, + .clamp_to_edge => c.GL_CLAMP_TO_EDGE, + .clamp_to_border => c.GL_CLAMP_TO_BORDER, + }; + } + + fn filterModeToGL(mode: FilterMode) c.GLint { + return switch (mode) { + .nearest => c.GL_NEAREST, + .linear => c.GL_LINEAR, + .nearest_mipmap_nearest => c.GL_NEAREST_MIPMAP_NEAREST, + .linear_mipmap_nearest => c.GL_LINEAR_MIPMAP_NEAREST, + .nearest_mipmap_linear => c.GL_NEAREST_MIPMAP_LINEAR, + .linear_mipmap_linear => c.GL_LINEAR_MIPMAP_LINEAR, + }; + } + + fn formatToGL(format: TextureFormat) c.GLenum { + return switch (format) { + .rgb => c.GL_RGB, + .rgba => c.GL_RGBA, + .red => c.GL_RED, + .depth => c.GL_DEPTH_COMPONENT, + }; + } + + fn formatToInternalGL(format: TextureFormat) c.GLint { + return switch (format) { + .rgb => c.GL_RGB8, + .rgba => c.GL_RGBA8, + .red => c.GL_R8, + .depth => c.GL_DEPTH_COMPONENT24, + }; + } +}; + +/// Texture slot manager to track which textures are bound where +pub const TextureSlots = struct { + slots: [16]?c.GLuint = .{null} ** 16, + active_slot: u32 = 0, + + pub fn bind(self: *TextureSlots, texture: *const Texture, slot: u32) void { + if (slot >= 16) return; + if (self.slots[slot] == texture.id) return; // Already bound + + texture.bind(slot); + self.slots[slot] = texture.id; + self.active_slot = slot; + } + + pub fn unbind(self: *TextureSlots, slot: u32) void { + if (slot >= 16) return; + Texture.unbind(slot); + self.slots[slot] = null; + } + + pub fn clear(self: *TextureSlots) void { + for (0..16) |i| { + if (self.slots[i] != null) { + Texture.unbind(@intCast(i)); + self.slots[i] = null; + } + } + } +}; diff --git a/src/engine/ui/ui_system.zig b/src/engine/ui/ui_system.zig index 6966852d..6abd97c7 100644 --- a/src/engine/ui/ui_system.zig +++ b/src/engine/ui/ui_system.zig @@ -18,7 +18,6 @@ pub const UISystem = struct { vbo: c.GLuint, screen_width: f32, screen_height: f32, - allocator: std.mem.Allocator, const vertex_shader = \\#version 330 core @@ -41,8 +40,8 @@ pub const UISystem = struct { \\} ; - pub fn init(allocator: std.mem.Allocator, width: u32, height: u32) !UISystem { - const shader = try Shader.init(vertex_shader, fragment_shader); + pub fn init(width: u32, height: u32) !UISystem { + const shader = try Shader.initSimple(vertex_shader, fragment_shader); var vao: c.GLuint = undefined; var vbo: c.GLuint = undefined; @@ -71,7 +70,6 @@ pub const UISystem = struct { .vbo = vbo, .screen_width = @floatFromInt(width), .screen_height = @floatFromInt(height), - .allocator = allocator, }; } diff --git a/src/main.zig b/src/main.zig index e3398fd9..2556b2c6 100644 --- a/src/main.zig +++ b/src/main.zig @@ -6,11 +6,13 @@ const Mat4 = @import("engine/math/mat4.zig").Mat4; const Camera = @import("engine/graphics/camera.zig").Camera; const Shader = @import("engine/graphics/shader.zig").Shader; const Renderer = @import("engine/graphics/renderer.zig").Renderer; +const setVSync = @import("engine/graphics/renderer.zig").setVSync; const Input = @import("engine/input/input.zig").Input; const Time = @import("engine/core/time.zig").Time; const UISystem = @import("engine/ui/ui_system.zig").UISystem; const Color = @import("engine/ui/ui_system.zig").Color; const Rect = @import("engine/core/interfaces.zig").Rect; +const log = @import("engine/core/log.zig"); // World imports const World = @import("world/world.zig").World; @@ -92,12 +94,17 @@ pub fn main() !void { } // 6. Initialize Engine Systems + log.log.info("Initializing engine systems...", .{}); + var input = Input.init(allocator); defer input.deinit(); var time = Time.init(); var renderer = Renderer.init(); + // Enable VSync + setVSync(true); + // Start camera high above ground level, looking down var camera = Camera.init(.{ .position = Vec3.init(8, 100, 8), @@ -106,7 +113,7 @@ pub fn main() !void { }); // 7. Create Shader - var shader = try Shader.init(vertex_shader_src, fragment_shader_src); + var shader = try Shader.initSimple(vertex_shader_src, fragment_shader_src); defer shader.deinit(); // 8. Create World @@ -115,22 +122,17 @@ pub fn main() !void { defer world.deinit(); // 9. Create UI System for FPS display - var ui = try UISystem.init(allocator, 1280, 720); + var ui = try UISystem.init(1280, 720); defer ui.deinit(); // Initial viewport renderer.setViewport(1280, 720); - std.debug.print("\n=== Zig Voxel Engine ===\n", .{}); - std.debug.print("Controls:\n", .{}); - std.debug.print(" WASD - Move\n", .{}); - std.debug.print(" Space/Shift - Up/Down\n", .{}); - std.debug.print(" Tab - Toggle mouse capture\n", .{}); - std.debug.print(" F - Toggle wireframe\n", .{}); - std.debug.print(" Escape - Quit\n", .{}); - std.debug.print("========================\n\n", .{}); + log.log.info("=== Zig Voxel Engine ===", .{}); + log.log.info("Controls: WASD=Move, Space/Shift=Up/Down, Tab=Mouse, F=Wireframe, V=VSync, Esc=Quit", .{}); - // 9. Main Loop + // 10. Main Loop + var vsync_enabled = true; while (!input.should_quit) { // Update time time.update(); @@ -156,6 +158,12 @@ pub fn main() !void { renderer.toggleWireframe(); } + // Toggle VSync with V + if (input.isKeyPressed(.v)) { + vsync_enabled = !vsync_enabled; + setVSync(vsync_enabled); + } + // Update camera camera.update(&input, time.delta_time); From f03e406b82930020460c19b5544288ced270e493 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sat, 20 Dec 2025 17:45:57 +0000 Subject: [PATCH 03/10] feat: add procedural texture atlas and textured terrain rendering - Create TextureAtlas with procedurally generated block textures - Add UV coordinates to chunk mesh vertices - Update shaders to support texture sampling with toggle (T key) - Add texture patterns: stone, dirt, grass (top/side), sand, wood, leaves, etc. - Fix GLEW function pointer syntax for texture operations - Add setBool uniform setter to Shader --- src/engine/graphics/shader.zig | 5 + src/engine/graphics/texture.zig | 8 +- src/engine/graphics/texture_atlas.zig | 294 ++++++++++++++++++++++++++ src/main.zig | 48 ++++- src/world/chunk_mesh.zig | 52 +++-- 5 files changed, 385 insertions(+), 22 deletions(-) create mode 100644 src/engine/graphics/texture_atlas.zig diff --git a/src/engine/graphics/shader.zig b/src/engine/graphics/shader.zig index 4a36730a..c712ebaa 100644 --- a/src/engine/graphics/shader.zig +++ b/src/engine/graphics/shader.zig @@ -128,6 +128,11 @@ pub const Shader = struct { c.glUniform1i().?(loc, value); } + pub fn setBool(self: *const Shader, name: [*c]const u8, value: bool) void { + const loc = self.getUniformLocation(name); + c.glUniform1i().?(loc, if (value) 1 else 0); + } + fn compileShader(shader_type: c.GLenum, source: [*c]const u8) Error!c.GLuint { const shader = c.glCreateShader().?(shader_type); c.glShaderSource().?(shader, 1, &source, null); diff --git a/src/engine/graphics/texture.zig b/src/engine/graphics/texture.zig index 983b931f..ec9a362a 100644 --- a/src/engine/graphics/texture.zig +++ b/src/engine/graphics/texture.zig @@ -76,7 +76,7 @@ pub const Texture = struct { ); if (config.generate_mipmaps and data != null) { - c.glGenerateMipmap(c.GL_TEXTURE_2D); + c.glGenerateMipmap().?(c.GL_TEXTURE_2D); } c.glBindTexture(c.GL_TEXTURE_2D, 0); @@ -112,13 +112,15 @@ pub const Texture = struct { /// Bind texture to a specific slot pub fn bind(self: *const Texture, slot: u32) void { - c.glActiveTexture(c.GL_TEXTURE0 + slot); + const texture_unit: c.GLenum = @intCast(c.GL_TEXTURE0 + @as(c.GLint, @intCast(slot))); + c.glActiveTexture().?(texture_unit); c.glBindTexture(c.GL_TEXTURE_2D, self.id); } /// Unbind texture from slot pub fn unbind(slot: u32) void { - c.glActiveTexture(c.GL_TEXTURE0 + slot); + const texture_unit: c.GLenum = @intCast(c.GL_TEXTURE0 + @as(c.GLint, @intCast(slot))); + c.glActiveTexture().?(texture_unit); c.glBindTexture(c.GL_TEXTURE_2D, 0); } diff --git a/src/engine/graphics/texture_atlas.zig b/src/engine/graphics/texture_atlas.zig new file mode 100644 index 00000000..5a24d697 --- /dev/null +++ b/src/engine/graphics/texture_atlas.zig @@ -0,0 +1,294 @@ +//! Texture Atlas for block textures. +//! Generates a procedural texture atlas with all block types. + +const std = @import("std"); +const c = @cImport({ + @cInclude("GL/glew.h"); +}); + +const Texture = @import("texture.zig").Texture; +const FilterMode = @import("texture.zig").FilterMode; +const log = @import("../core/log.zig"); + +/// Tile size in pixels (each block face texture) +pub const TILE_SIZE: u32 = 16; + +/// Number of tiles per row in the atlas +pub const TILES_PER_ROW: u32 = 16; + +/// Atlas dimensions +pub const ATLAS_SIZE: u32 = TILE_SIZE * TILES_PER_ROW; + +/// Texture atlas for blocks +pub const TextureAtlas = struct { + texture: Texture, + allocator: std.mem.Allocator, + + /// Tile indices for block faces [top, bottom, side] + /// Each block type maps to 3 tile indices + pub const BlockTiles = struct { + top: u8, + bottom: u8, + side: u8, + + pub fn uniform(tile: u8) BlockTiles { + return .{ .top = tile, .bottom = tile, .side = tile }; + } + }; + + // Tile indices (row * TILES_PER_ROW + col) + pub const TILE_STONE: u8 = 0; + pub const TILE_DIRT: u8 = 1; + pub const TILE_GRASS_TOP: u8 = 2; + pub const TILE_GRASS_SIDE: u8 = 3; + pub const TILE_SAND: u8 = 4; + pub const TILE_COBBLESTONE: u8 = 5; + pub const TILE_BEDROCK: u8 = 6; + pub const TILE_GRAVEL: u8 = 7; + pub const TILE_WOOD_SIDE: u8 = 8; + pub const TILE_WOOD_TOP: u8 = 9; + pub const TILE_LEAVES: u8 = 10; + pub const TILE_WATER: u8 = 11; + pub const TILE_GLASS: u8 = 12; + + /// Block type to tile mapping + pub fn getTilesForBlock(block_id: u8) BlockTiles { + return switch (block_id) { + 0 => BlockTiles.uniform(0), // Air (won't be rendered) + 1 => BlockTiles.uniform(TILE_STONE), // Stone + 2 => BlockTiles.uniform(TILE_DIRT), // Dirt + 3 => .{ .top = TILE_GRASS_TOP, .bottom = TILE_DIRT, .side = TILE_GRASS_SIDE }, // Grass + 4 => BlockTiles.uniform(TILE_SAND), // Sand + 5 => BlockTiles.uniform(TILE_WATER), // Water + 6 => .{ .top = TILE_WOOD_TOP, .bottom = TILE_WOOD_TOP, .side = TILE_WOOD_SIDE }, // Wood + 7 => BlockTiles.uniform(TILE_LEAVES), // Leaves + 8 => BlockTiles.uniform(TILE_COBBLESTONE), // Cobblestone + 9 => BlockTiles.uniform(TILE_BEDROCK), // Bedrock + 10 => BlockTiles.uniform(TILE_GRAVEL), // Gravel + 11 => BlockTiles.uniform(TILE_GLASS), // Glass + else => BlockTiles.uniform(0), + }; + } + + /// Get UV coordinates for a tile (returns min_u, min_v, max_u, max_v) + pub fn getTileUV(tile_index: u8) [4]f32 { + const tiles_f: f32 = @floatFromInt(TILES_PER_ROW); + const col: f32 = @floatFromInt(tile_index % TILES_PER_ROW); + const row: f32 = @floatFromInt(tile_index / TILES_PER_ROW); + + const tile_size = 1.0 / tiles_f; + // Small inset to prevent texture bleeding + const inset: f32 = 0.001; + + return .{ + col * tile_size + inset, // min_u + row * tile_size + inset, // min_v + (col + 1) * tile_size - inset, // max_u + (row + 1) * tile_size - inset, // max_v + }; + } + + pub fn init(allocator: std.mem.Allocator) TextureAtlas { + // Allocate pixel data for the atlas (RGBA) + const pixel_count = ATLAS_SIZE * ATLAS_SIZE * 4; + var pixels = allocator.alloc(u8, pixel_count) catch @panic("Failed to allocate atlas"); + defer allocator.free(pixels); + + // Clear to magenta (missing texture indicator) + for (0..ATLAS_SIZE * ATLAS_SIZE) |i| { + pixels[i * 4 + 0] = 255; // R + pixels[i * 4 + 1] = 0; // G + pixels[i * 4 + 2] = 255; // B + pixels[i * 4 + 3] = 255; // A + } + + // Generate each tile + generateTile(pixels, TILE_STONE, .{ 128, 128, 128 }, .stone); + generateTile(pixels, TILE_DIRT, .{ 140, 90, 50 }, .noise); + generateTile(pixels, TILE_GRASS_TOP, .{ 76, 165, 50 }, .grass); + generateTile(pixels, TILE_GRASS_SIDE, .{ 140, 90, 50 }, .grass_side); + generateTile(pixels, TILE_SAND, .{ 230, 215, 150 }, .noise); + generateTile(pixels, TILE_COBBLESTONE, .{ 100, 100, 100 }, .cobble); + generateTile(pixels, TILE_BEDROCK, .{ 40, 40, 40 }, .noise); + generateTile(pixels, TILE_GRAVEL, .{ 115, 108, 100 }, .gravel); + generateTile(pixels, TILE_WOOD_SIDE, .{ 140, 90, 40 }, .wood_side); + generateTile(pixels, TILE_WOOD_TOP, .{ 160, 130, 70 }, .wood_top); + generateTile(pixels, TILE_LEAVES, .{ 50, 128, 38 }, .leaves); + generateTile(pixels, TILE_WATER, .{ 50, 100, 200 }, .water); + generateTile(pixels, TILE_GLASS, .{ 200, 230, 240 }, .glass); + + // Create OpenGL texture + const texture = Texture.init(ATLAS_SIZE, ATLAS_SIZE, pixels.ptr, .rgba, .{ + .min_filter = .nearest_mipmap_linear, + .mag_filter = .nearest, // Pixelated look for voxels + .wrap_s = .repeat, + .wrap_t = .repeat, + .generate_mipmaps = true, + }); + + log.log.info("Texture atlas created: {}x{} ({} tiles)", .{ ATLAS_SIZE, ATLAS_SIZE, TILES_PER_ROW * TILES_PER_ROW }); + + return .{ + .texture = texture, + .allocator = allocator, + }; + } + + pub fn deinit(self: *TextureAtlas) void { + var tex = self.texture; + tex.deinit(); + } + + pub fn bind(self: *const TextureAtlas, slot: u32) void { + self.texture.bind(slot); + } + + const TilePattern = enum { + solid, + noise, + stone, + grass, + grass_side, + cobble, + gravel, + wood_side, + wood_top, + leaves, + water, + glass, + }; + + fn generateTile(pixels: []u8, tile_index: u8, base_color: [3]u8, pattern: TilePattern) void { + const tile_col = tile_index % TILES_PER_ROW; + const tile_row = tile_index / TILES_PER_ROW; + const start_x = tile_col * TILE_SIZE; + const start_y = tile_row * TILE_SIZE; + + var py: u32 = 0; + while (py < TILE_SIZE) : (py += 1) { + var px: u32 = 0; + while (px < TILE_SIZE) : (px += 1) { + const x = start_x + px; + const y = start_y + py; + const idx = (y * ATLAS_SIZE + x) * 4; + + const color = getPatternColor(px, py, base_color, pattern); + pixels[idx + 0] = color[0]; + pixels[idx + 1] = color[1]; + pixels[idx + 2] = color[2]; + pixels[idx + 3] = if (pattern == .glass) 200 else 255; + } + } + } + + fn getPatternColor(px: u32, py: u32, base: [3]u8, pattern: TilePattern) [3]u8 { + const x = @as(i32, @intCast(px)); + const y = @as(i32, @intCast(py)); + + return switch (pattern) { + .solid => base, + + .noise => blk: { + const noise = simpleHash(x, y) % 30; + break :blk adjustBrightness(base, @as(i8, @intCast(noise)) - 15); + }, + + .stone => blk: { + const noise = simpleHash(x * 3, y * 3) % 40; + const crack = if (@rem(x + y, 8) == 0) @as(i8, -30) else @as(i8, 0); + break :blk adjustBrightness(base, @as(i8, @intCast(noise)) - 20 + crack); + }, + + .grass => blk: { + const noise = simpleHash(x * 2, y * 2) % 40; + break :blk adjustBrightness(base, @as(i8, @intCast(noise)) - 20); + }, + + .grass_side => blk: { + if (py < 4) { + // Grass top portion + const noise = simpleHash(x * 2, y) % 30; + const grass_color = [3]u8{ 76, 165, 50 }; + break :blk adjustBrightness(grass_color, @as(i8, @intCast(noise)) - 15); + } else { + // Dirt portion + const noise = simpleHash(x, y) % 30; + break :blk adjustBrightness(base, @as(i8, @intCast(noise)) - 15); + } + }, + + .cobble => blk: { + const cell_x = @divFloor(x, 4); + const cell_y = @divFloor(y, 4); + const cell_noise = simpleHash(cell_x, cell_y) % 50; + const edge = if (@rem(x, 4) == 0 or @rem(y, 4) == 0) @as(i8, -20) else @as(i8, 0); + break :blk adjustBrightness(base, @as(i8, @intCast(cell_noise)) - 25 + edge); + }, + + .gravel => blk: { + const noise1 = simpleHash(x, y) % 40; + const noise2 = simpleHash(x * 7, y * 7) % 20; + break :blk adjustBrightness(base, @as(i8, @intCast(noise1 + noise2)) - 30); + }, + + .wood_side => blk: { + // Vertical wood grain + const hash_val = simpleHash(0, y) % 2; + const grain = @rem(@as(u32, @intCast(@abs(x * 3 + @as(i32, @intCast(hash_val))))), 4); + const noise = simpleHash(x, y * 5) % 20; + const dark: i8 = if (grain == 0) -30 else 0; + break :blk adjustBrightness(base, @as(i8, @intCast(noise)) - 10 + dark); + }, + + .wood_top => blk: { + // Concentric rings + const cx = @as(i32, TILE_SIZE / 2); + const cy = @as(i32, TILE_SIZE / 2); + const dx = x - cx; + const dy = y - cy; + const dist = @as(u32, @intCast(@abs(dx * dx + dy * dy))); + const ring = (dist / 8) % 2; + const adjust: i8 = if (ring == 0) -20 else 10; + break :blk adjustBrightness(base, adjust); + }, + + .leaves => blk: { + const noise = simpleHash(x * 5, y * 5) % 60; + if (noise > 45) { + // Dark spots (gaps in leaves) + break :blk adjustBrightness(base, -40); + } else { + break :blk adjustBrightness(base, @as(i8, @intCast(noise)) - 30); + } + }, + + .water => blk: { + const wave = @rem(@as(u32, @intCast(@abs(x + y))), 8); + const adjust: i8 = if (wave < 2) 20 else 0; + break :blk adjustBrightness(base, adjust); + }, + + .glass => blk: { + // Border highlight + if (px == 0 or py == 0 or px == TILE_SIZE - 1 or py == TILE_SIZE - 1) { + break :blk .{ 255, 255, 255 }; + } + break :blk base; + }, + }; + } + + fn simpleHash(x: i32, y: i32) u32 { + var h: u32 = @bitCast(x *% 374761393 +% y *% 668265263); + h = (h ^ (h >> 13)) *% 1274126177; + return h ^ (h >> 16); + } + + fn adjustBrightness(color: [3]u8, adjust: i8) [3]u8 { + return .{ + @intCast(std.math.clamp(@as(i16, color[0]) + adjust, 0, 255)), + @intCast(std.math.clamp(@as(i16, color[1]) + adjust, 0, 255)), + @intCast(std.math.clamp(@as(i16, color[2]) + adjust, 0, 255)), + }; + } +}; diff --git a/src/main.zig b/src/main.zig index 2556b2c6..de762727 100644 --- a/src/main.zig +++ b/src/main.zig @@ -13,6 +13,7 @@ const UISystem = @import("engine/ui/ui_system.zig").UISystem; const Color = @import("engine/ui/ui_system.zig").Color; const Rect = @import("engine/core/interfaces.zig").Rect; const log = @import("engine/core/log.zig"); +const TextureAtlas = @import("engine/graphics/texture_atlas.zig").TextureAtlas; // World imports const World = @import("world/world.zig").World; @@ -25,19 +26,22 @@ const c = @cImport({ @cInclude("SDL3/SDL_opengl.h"); }); -// Shaders +// Textured terrain shaders const vertex_shader_src = \\#version 330 core \\layout (location = 0) in vec3 aPos; \\layout (location = 1) in vec3 aColor; \\layout (location = 2) in vec3 aNormal; + \\layout (location = 3) in vec2 aTexCoord; \\out vec3 vColor; \\out vec3 vNormal; + \\out vec2 vTexCoord; \\uniform mat4 transform; \\void main() { \\ gl_Position = transform * vec4(aPos, 1.0); \\ vColor = aColor; \\ vNormal = aNormal; + \\ vTexCoord = aTexCoord; \\} ; @@ -45,12 +49,23 @@ const fragment_shader_src = \\#version 330 core \\in vec3 vColor; \\in vec3 vNormal; + \\in vec2 vTexCoord; \\out vec4 FragColor; + \\uniform sampler2D uTexture; + \\uniform bool uUseTexture; \\void main() { \\ // Simple directional lighting \\ vec3 lightDir = normalize(vec3(0.5, 1.0, 0.3)); - \\ float diff = max(dot(vNormal, lightDir), 0.0) * 0.3 + 0.7; - \\ FragColor = vec4(vColor * diff, 1.0); + \\ float diff = max(dot(vNormal, lightDir), 0.0) * 0.4 + 0.6; + \\ + \\ vec3 color; + \\ if (uUseTexture) { + \\ vec4 texColor = texture(uTexture, vTexCoord); + \\ color = texColor.rgb * vColor * diff; + \\ } else { + \\ color = vColor * diff; + \\ } + \\ FragColor = vec4(color, 1.0); \\} ; @@ -116,12 +131,16 @@ pub fn main() !void { var shader = try Shader.initSimple(vertex_shader_src, fragment_shader_src); defer shader.deinit(); - // 8. Create World + // 8. Create Texture Atlas + var atlas = TextureAtlas.init(allocator); + defer atlas.deinit(); + + // 9. Create World const seed: u64 = 12345; // World seed for terrain generation var world = World.init(allocator, 2, seed); // 2 chunk render distance (5x5 = 25 chunks) defer world.deinit(); - // 9. Create UI System for FPS display + // 10. Create UI System for FPS display var ui = try UISystem.init(1280, 720); defer ui.deinit(); @@ -129,10 +148,12 @@ pub fn main() !void { renderer.setViewport(1280, 720); log.log.info("=== Zig Voxel Engine ===", .{}); - log.log.info("Controls: WASD=Move, Space/Shift=Up/Down, Tab=Mouse, F=Wireframe, V=VSync, Esc=Quit", .{}); + log.log.info("Controls: WASD=Move, Space/Shift=Up/Down, Tab=Mouse, F=Wireframe, T=Textures, V=VSync, Esc=Quit", .{}); - // 10. Main Loop + // 11. Main Loop var vsync_enabled = true; + var textures_enabled = true; + while (!input.should_quit) { // Update time time.update(); @@ -158,6 +179,12 @@ pub fn main() !void { renderer.toggleWireframe(); } + // Toggle textures with T + if (input.isKeyPressed(.t)) { + textures_enabled = !textures_enabled; + log.log.info("Textures: {}", .{textures_enabled}); + } + // Toggle VSync with V if (input.isKeyPressed(.v)) { vsync_enabled = !vsync_enabled; @@ -188,6 +215,13 @@ pub fn main() !void { // Render 3D world renderer.beginFrame(); + + // Bind texture atlas and set uniforms + shader.use(); + atlas.bind(0); + shader.setInt("uTexture", 0); + shader.setBool("uUseTexture", textures_enabled); + world.render(&shader, view_proj); // Render UI (FPS counter) diff --git a/src/world/chunk_mesh.zig b/src/world/chunk_mesh.zig index 55533f9d..5876eb3a 100644 --- a/src/world/chunk_mesh.zig +++ b/src/world/chunk_mesh.zig @@ -1,4 +1,4 @@ -//! Chunk mesh generation with visible face culling. +//! Chunk mesh generation with visible face culling and texture UVs. //! Only generates faces where a solid block meets air/transparent block. const std = @import("std"); @@ -13,6 +13,7 @@ const CHUNK_SIZE_Z = @import("chunk.zig").CHUNK_SIZE_Z; const BlockType = @import("block.zig").BlockType; const Face = @import("block.zig").Face; const ALL_FACES = @import("block.zig").ALL_FACES; +const TextureAtlas = @import("../engine/graphics/texture_atlas.zig").TextureAtlas; pub const ChunkMesh = struct { vao: c.GLuint, @@ -25,6 +26,9 @@ pub const ChunkMesh = struct { /// Is the mesh ready to render? ready: bool = false, + // Vertex format: position (3) + color (3) + normal (3) + uv (2) = 11 floats + const FLOATS_PER_VERTEX: u32 = 11; + pub fn init(allocator: std.mem.Allocator) ChunkMesh { var vao: c.GLuint = undefined; var vbo: c.GLuint = undefined; @@ -32,11 +36,11 @@ pub const ChunkMesh = struct { c.glGenVertexArrays().?(1, &vao); c.glGenBuffers().?(1, &vbo); - // Setup vertex format: position (3) + color (3) + normal (3) = 9 floats + // Setup vertex format c.glBindVertexArray().?(vao); c.glBindBuffer().?(c.GL_ARRAY_BUFFER, vbo); - const stride: c.GLsizei = 9 * @sizeOf(f32); + const stride: c.GLsizei = FLOATS_PER_VERTEX * @sizeOf(f32); // Position (location 0) c.glVertexAttribPointer().?(0, 3, c.GL_FLOAT, c.GL_FALSE, stride, null); @@ -50,6 +54,10 @@ pub const ChunkMesh = struct { c.glVertexAttribPointer().?(2, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(6 * @sizeOf(f32))); c.glEnableVertexAttribArray().?(2); + // UV (location 3) + c.glVertexAttribPointer().?(3, 2, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(9 * @sizeOf(f32))); + c.glEnableVertexAttribArray().?(3); + c.glBindVertexArray().?(0); return .{ @@ -71,7 +79,7 @@ pub const ChunkMesh = struct { defer vertices.deinit(self.allocator); // Reserve modest initial capacity (will grow as needed) - try vertices.ensureTotalCapacity(self.allocator, 1024 * 9); + try vertices.ensureTotalCapacity(self.allocator, 1024 * FLOATS_PER_VERTEX); // Iterate through all blocks var y: u32 = 0; @@ -125,21 +133,39 @@ pub const ChunkMesh = struct { @floatFromInt(normal[2]), }; + // Get tile index for this face + const block_id = @intFromEnum(block); + const tiles = TextureAtlas.getTilesForBlock(block_id); + const tile_index = switch (face) { + .top => tiles.top, + .bottom => tiles.bottom, + else => tiles.side, + }; + + // Get UV coordinates for the tile + const uv = TextureAtlas.getTileUV(tile_index); + const uv_coords = [4][2]f32{ + .{ uv[0], uv[1] }, // bottom-left + .{ uv[0], uv[3] }, // top-left + .{ uv[2], uv[3] }, // top-right + .{ uv[2], uv[1] }, // bottom-right + }; + // Get the 4 corners of the face const corners = getFaceCorners(x, y, z, face); // Triangle 1: 0, 1, 2 - try addVertex(self.allocator, vertices, corners[0], color, nf); - try addVertex(self.allocator, vertices, corners[1], color, nf); - try addVertex(self.allocator, vertices, corners[2], color, nf); + try addVertex(self.allocator, vertices, corners[0], color, nf, uv_coords[0]); + try addVertex(self.allocator, vertices, corners[1], color, nf, uv_coords[1]); + try addVertex(self.allocator, vertices, corners[2], color, nf, uv_coords[2]); // Triangle 2: 0, 2, 3 - try addVertex(self.allocator, vertices, corners[0], color, nf); - try addVertex(self.allocator, vertices, corners[2], color, nf); - try addVertex(self.allocator, vertices, corners[3], color, nf); + try addVertex(self.allocator, vertices, corners[0], color, nf, uv_coords[0]); + try addVertex(self.allocator, vertices, corners[2], color, nf, uv_coords[2]); + try addVertex(self.allocator, vertices, corners[3], color, nf, uv_coords[3]); } - fn addVertex(allocator: std.mem.Allocator, vertices: *std.ArrayListUnmanaged(f32), pos: [3]f32, color: [3]f32, normal: [3]f32) !void { + fn addVertex(allocator: std.mem.Allocator, vertices: *std.ArrayListUnmanaged(f32), pos: [3]f32, color: [3]f32, normal: [3]f32, uv: [2]f32) !void { try vertices.append(allocator, pos[0]); try vertices.append(allocator, pos[1]); try vertices.append(allocator, pos[2]); @@ -149,6 +175,8 @@ pub const ChunkMesh = struct { try vertices.append(allocator, normal[0]); try vertices.append(allocator, normal[1]); try vertices.append(allocator, normal[2]); + try vertices.append(allocator, uv[0]); + try vertices.append(allocator, uv[1]); } fn uploadVertices(self: *ChunkMesh, vertices: []const f32) void { @@ -159,7 +187,7 @@ pub const ChunkMesh = struct { vertices.ptr, c.GL_STATIC_DRAW, ); - self.vertex_count = @intCast(vertices.len / 9); + self.vertex_count = @intCast(vertices.len / FLOATS_PER_VERTEX); self.ready = self.vertex_count > 0; } From 94872908ed1763b39fd489a6e6180502739d8e34 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sat, 20 Dec 2025 17:49:09 +0000 Subject: [PATCH 04/10] feat: add frustum culling for efficient chunk rendering - Create Frustum struct with plane extraction from view-projection matrix - Implement AABB-frustum intersection tests using p-vertex optimization - Add per-chunk culling in World.render() - Track and display render statistics (chunks rendered/culled) - Significant performance improvement by skipping off-screen chunks --- src/engine/math/frustum.zig | 150 ++++++++++++++++++++++++++++++++++++ src/main.zig | 7 +- src/world/world.zig | 37 ++++++++- 3 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 src/engine/math/frustum.zig diff --git a/src/engine/math/frustum.zig b/src/engine/math/frustum.zig new file mode 100644 index 00000000..261b766f --- /dev/null +++ b/src/engine/math/frustum.zig @@ -0,0 +1,150 @@ +//! View Frustum for culling objects outside camera view. + +const std = @import("std"); +const Vec3 = @import("vec3.zig").Vec3; +const Mat4 = @import("mat4.zig").Mat4; +const AABB = @import("aabb.zig").AABB; + +/// A plane in 3D space (ax + by + cz + d = 0) +pub const Plane = struct { + normal: Vec3, + distance: f32, + + pub fn init(normal: Vec3, distance: f32) Plane { + return .{ .normal = normal, .distance = distance }; + } + + /// Signed distance from point to plane (positive = in front) + pub fn signedDistance(self: Plane, point: Vec3) f32 { + return self.normal.dot(point) + self.distance; + } + + /// Normalize plane coefficients + pub fn normalize(self: Plane) Plane { + const len = self.normal.length(); + if (len < 0.0001) return self; + return .{ + .normal = self.normal.scale(1.0 / len), + .distance = self.distance / len, + }; + } +}; + +/// View frustum composed of 6 planes +pub const Frustum = struct { + planes: [6]Plane, // left, right, bottom, top, near, far + + pub const Side = enum(u3) { + left = 0, + right = 1, + bottom = 2, + top = 3, + near = 4, + far = 5, + }; + + /// Extract frustum planes from a view-projection matrix + /// Uses the Gribb/Hartmann method + pub fn fromViewProj(vp: Mat4) Frustum { + const m = vp.data; + + // Each row of the matrix contributes to plane extraction + // m[row][col] - remember Mat4 is row-major + var planes: [6]Plane = undefined; + + // Left: row3 + row0 + planes[0] = Plane.init( + Vec3.init(m[0][3] + m[0][0], m[1][3] + m[1][0], m[2][3] + m[2][0]), + m[3][3] + m[3][0], + ).normalize(); + + // Right: row3 - row0 + planes[1] = Plane.init( + Vec3.init(m[0][3] - m[0][0], m[1][3] - m[1][0], m[2][3] - m[2][0]), + m[3][3] - m[3][0], + ).normalize(); + + // Bottom: row3 + row1 + planes[2] = Plane.init( + Vec3.init(m[0][3] + m[0][1], m[1][3] + m[1][1], m[2][3] + m[2][1]), + m[3][3] + m[3][1], + ).normalize(); + + // Top: row3 - row1 + planes[3] = Plane.init( + Vec3.init(m[0][3] - m[0][1], m[1][3] - m[1][1], m[2][3] - m[2][1]), + m[3][3] - m[3][1], + ).normalize(); + + // Near: row3 + row2 + planes[4] = Plane.init( + Vec3.init(m[0][3] + m[0][2], m[1][3] + m[1][2], m[2][3] + m[2][2]), + m[3][3] + m[3][2], + ).normalize(); + + // Far: row3 - row2 + planes[5] = Plane.init( + Vec3.init(m[0][3] - m[0][2], m[1][3] - m[1][2], m[2][3] - m[2][2]), + m[3][3] - m[3][2], + ).normalize(); + + return .{ .planes = planes }; + } + + /// Check if a point is inside the frustum + pub fn containsPoint(self: Frustum, point: Vec3) bool { + for (self.planes) |plane| { + if (plane.signedDistance(point) < 0) { + return false; + } + } + return true; + } + + /// Check if a sphere intersects the frustum + pub fn intersectsSphere(self: Frustum, center: Vec3, radius: f32) bool { + for (self.planes) |plane| { + if (plane.signedDistance(center) < -radius) { + return false; + } + } + return true; + } + + /// Check if an AABB intersects the frustum + /// Uses the "get positive vertex" optimization + pub fn intersectsAABB(self: Frustum, aabb: AABB) bool { + for (self.planes) |plane| { + // Get the vertex most in the direction of the plane normal (p-vertex) + const p = Vec3.init( + if (plane.normal.x >= 0) aabb.max.x else aabb.min.x, + if (plane.normal.y >= 0) aabb.max.y else aabb.min.y, + if (plane.normal.z >= 0) aabb.max.z else aabb.min.z, + ); + + // If p-vertex is outside, the whole box is outside + if (plane.signedDistance(p) < 0) { + return false; + } + } + return true; + } + + /// Check if a chunk (given by chunk coordinates) intersects the frustum + /// Chunks are 16x256x16 blocks + pub fn intersectsChunk(self: Frustum, chunk_x: i32, chunk_z: i32) 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); + + 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), + ); + + return self.intersectsAABB(aabb); + } +}; diff --git a/src/main.zig b/src/main.zig index de762727..24fad6f8 100644 --- a/src/main.zig +++ b/src/main.zig @@ -241,10 +241,13 @@ pub fn main() !void { // Print stats occasionally if (time.frame_count % 120 == 0) { const stats = world.getStats(); - std.debug.print("FPS: {d:.1} | Chunks: {} | Vertices: {} | Pos: ({d:.1}, {d:.1}, {d:.1})\n", .{ + const render_stats = world.getRenderStats(); + std.debug.print("FPS: {d:.1} | Chunks: {}/{} (culled: {}) | Vertices: {} | Pos: ({d:.1}, {d:.1}, {d:.1})\n", .{ time.fps, + render_stats.chunks_rendered, stats.chunks_loaded, - stats.total_vertices, + render_stats.chunks_culled, + render_stats.vertices_rendered, camera.position.x, camera.position.y, camera.position.z, diff --git a/src/world/world.zig b/src/world/world.zig index 99aebf6e..4a7b275e 100644 --- a/src/world/world.zig +++ b/src/world/world.zig @@ -12,6 +12,7 @@ const TerrainGenerator = @import("worldgen/generator.zig").TerrainGenerator; const Mat4 = @import("../engine/math/mat4.zig").Mat4; const Vec3 = @import("../engine/math/vec3.zig").Vec3; +const Frustum = @import("../engine/math/frustum.zig").Frustum; const Shader = @import("../engine/graphics/shader.zig").Shader; pub const ChunkKey = struct { @@ -47,13 +48,20 @@ pub const ChunkData = struct { mesh: ChunkMesh, }; +/// Render statistics +pub const RenderStats = struct { + chunks_total: u32 = 0, + chunks_rendered: u32 = 0, + chunks_culled: u32 = 0, + vertices_rendered: u64 = 0, +}; + pub const World = struct { chunks: std.HashMap(ChunkKey, *ChunkData, ChunkKeyContext, 80), allocator: std.mem.Allocator, generator: TerrainGenerator, - - /// Render distance in chunks render_distance: i32, + last_render_stats: RenderStats, pub fn init(allocator: std.mem.Allocator, render_distance: i32, seed: u64) World { return .{ @@ -61,6 +69,7 @@ pub const World = struct { .allocator = allocator, .render_distance = render_distance, .generator = TerrainGenerator.init(seed), + .last_render_stats = .{}, }; } @@ -143,22 +152,44 @@ pub const World = struct { } } - /// Render all loaded chunks + /// Render all loaded chunks with frustum culling pub fn render(self: *World, shader: *const Shader, view_proj: Mat4) void { shader.use(); + // Extract frustum from view-projection matrix + const frustum = Frustum.fromViewProj(view_proj); + + self.last_render_stats = .{}; + var iter = self.chunks.iterator(); while (iter.next()) |entry| { + const key = entry.key_ptr.*; const data = entry.value_ptr.*; if (!data.mesh.ready) continue; + self.last_render_stats.chunks_total += 1; + + // Frustum culling + if (!frustum.intersectsChunk(key.x, key.z)) { + self.last_render_stats.chunks_culled += 1; + continue; + } + + self.last_render_stats.chunks_rendered += 1; + self.last_render_stats.vertices_rendered += data.mesh.vertex_count; + // Model matrix is identity since chunk vertices are in world space shader.setMat4("transform", &view_proj.data); data.mesh.draw(); } } + /// Get render statistics from last frame + pub fn getRenderStats(self: *const World) RenderStats { + return self.last_render_stats; + } + /// Get statistics pub fn getStats(self: *World) struct { chunks_loaded: usize, total_vertices: u64 } { var total_verts: u64 = 0; From 3d879d7fa4c519ef89a109ef53e0435e06ee2867 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sat, 20 Dec 2025 17:50:47 +0000 Subject: [PATCH 05/10] docs: update roadmap with completed milestones --- ROADMAP.md | 95 +++++++++++++++++++++++++++--------------------------- 1 file changed, 48 insertions(+), 47 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 82f00fcf..49c475fb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # OpenGL Engine Roadmap -This roadmap is derived from The Cherno’s OpenGL series and translated into **engine-level milestones**. +This roadmap is derived from The Cherno's OpenGL series and translated into **engine-level milestones**. Use this as a checklist and progression guide while building your engine. --- @@ -8,36 +8,36 @@ Use this as a checklist and progression guide while building your engine. ## Phase 0 — Foundations **Goal:** Window + context + sanity -- [ ] Window creation abstraction (GLFW / SDL) -- [ ] OpenGL context creation (core profile) -- [ ] Swap buffers -- [ ] VSync enable / disable -- [ ] OpenGL loader (GLAD / GLEW) -- [ ] Runtime OpenGL version & capability checks +- [x] Window creation abstraction (GLFW / SDL) +- [x] OpenGL context creation (core profile) +- [x] Swap buffers +- [x] VSync enable / disable +- [x] OpenGL loader (GLAD / GLEW) +- [x] Runtime OpenGL version & capability checks --- ## Phase 1 — Modern OpenGL Basics **Goal:** Draw *something* correctly, the modern way -- [ ] Core-profile OpenGL only (no fixed pipeline) -- [ ] Vertex Buffer (VBO) abstraction +- [x] Core-profile OpenGL only (no fixed pipeline) +- [x] Vertex Buffer (VBO) abstraction - [ ] Index Buffer (EBO / IBO) abstraction -- [ ] Vertex Array Object (VAO) abstraction -- [ ] Vertex attribute specification -- [ ] Interleaved vertex layouts -- [ ] Static vs dynamic buffer usage +- [x] Vertex Array Object (VAO) abstraction +- [x] Vertex attribute specification +- [x] Interleaved vertex layouts +- [x] Static vs dynamic buffer usage --- ## Phase 2 — Shaders **Goal:** Full control of the GPU pipeline -- [ ] Shader compilation system -- [ ] Shader linking & validation -- [ ] Error reporting for shaders -- [ ] Shader abstraction class -- [ ] Uniform upload API +- [x] Shader compilation system +- [x] Shader linking & validation +- [x] Error reporting for shaders +- [x] Shader abstraction class +- [x] Uniform upload API - [ ] Uniform location caching - [ ] Shader source hot-reloading - [ ] Central shader library / registry @@ -51,31 +51,31 @@ Use this as a checklist and progression guide while building your engine. - [ ] KHR_debug callback - [ ] GL call error macros - [ ] Assertions around GPU calls -- [ ] Engine-level logging system +- [x] Engine-level logging system --- ## Phase 4 — Renderer Architecture **Goal:** Hide OpenGL behind a clean engine API -- [ ] Renderer API layer +- [x] Renderer API layer - [ ] Render command abstraction -- [ ] Draw call encapsulation -- [ ] Renderer statistics (draw calls, vertices) -- [ ] Render state isolation -- [ ] Multiple object rendering +- [x] Draw call encapsulation +- [x] Renderer statistics (draw calls, vertices) +- [x] Render state isolation +- [x] Multiple object rendering --- ## Phase 5 — Textures & Materials **Goal:** Real assets, not hardcoded colors -- [ ] Texture loading system -- [ ] Texture abstraction class -- [ ] Texture parameter configuration -- [ ] Texture unit / slot management +- [x] Texture loading system +- [x] Texture abstraction class +- [x] Texture parameter configuration +- [x] Texture unit / slot management - [ ] Multi-texture rendering -- [ ] Texture atlases +- [x] Texture atlases - [ ] Material system (shader + textures + params) --- @@ -83,8 +83,8 @@ Use this as a checklist and progression guide while building your engine. ## Phase 6 — Blending & Transparency **Goal:** UI, sprites, and transparency -- [ ] Alpha blending -- [ ] Blend mode abstraction +- [x] Alpha blending +- [x] Blend mode abstraction - [ ] Premultiplied alpha support - [ ] Transparent object ordering (basic) @@ -93,13 +93,14 @@ Use this as a checklist and progression guide while building your engine. ## Phase 7 — Math & Transforms **Goal:** Cameras, movement, real scenes -- [ ] Math library (vec2/3/4, mat4) +- [x] Math library (vec2/3/4, mat4) - [ ] Transform component -- [ ] Projection matrices (ortho & perspective) -- [ ] View matrices (camera) -- [ ] Model matrices -- [ ] MVP pipeline -- [ ] Camera abstraction +- [x] Projection matrices (ortho & perspective) +- [x] View matrices (camera) +- [x] Model matrices +- [x] MVP pipeline +- [x] Camera abstraction +- [x] Frustum culling --- @@ -109,9 +110,9 @@ Use this as a checklist and progression guide while building your engine. - [ ] Batch renderer architecture - [ ] Batched colored geometry - [ ] Batched textured geometry -- [ ] Texture slot management +- [x] Texture slot management - [ ] Dynamic geometry batching -- [ ] Draw-call minimisation strategy +- [x] Draw-call minimisation strategy (frustum culling) --- @@ -130,14 +131,14 @@ Use this as a checklist and progression guide while building your engine. - [ ] ImGui integration - [ ] Debug panels -- [ ] Renderer stats overlay +- [x] Renderer stats overlay - [ ] Live shader reload toggle -- [ ] Runtime render mode toggles (wireframe, etc.) +- [x] Runtime render mode toggles (wireframe, etc.) --- ## Phase 11 — Testing Framework -**Goal:** Don’t break rendering accidentally +**Goal:** Don't break rendering accidentally - [ ] Render test framework - [ ] Isolated render tests @@ -146,16 +147,16 @@ Use this as a checklist and progression guide while building your engine. --- -## Engine v1 “Done” Definition +## Engine v1 "Done" Definition You can call this a **real engine** when you have: -- [ ] Clean renderer API +- [x] Clean renderer API - [ ] Shader + material system -- [ ] Texture & asset loading -- [ ] Camera & transform system +- [x] Texture & asset loading +- [x] Camera & transform system - [ ] Batch renderer - [ ] Debug UI -- [ ] Measured performance metrics +- [x] Measured performance metrics --- From 63e7741cef19290f9ab5febce7c945c4d54b10c0 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sat, 20 Dec 2025 17:57:32 +0000 Subject: [PATCH 06/10] feat: add cross-chunk face culling to reduce vertex count - Add NeighborChunks struct to pass adjacent chunk data to mesh builder - Update shouldRenderFace() to check neighbor chunks at boundaries - Gather neighbor chunks in World.update() before mesh building - Reduces vertex count by ~36% by eliminating hidden chunk boundary faces --- src/world/chunk_mesh.zig | 82 +++++++++++++++++++++++++++++++++------- src/world/world.zig | 15 +++++++- 2 files changed, 82 insertions(+), 15 deletions(-) diff --git a/src/world/chunk_mesh.zig b/src/world/chunk_mesh.zig index 5876eb3a..64710eaf 100644 --- a/src/world/chunk_mesh.zig +++ b/src/world/chunk_mesh.zig @@ -1,5 +1,6 @@ //! Chunk mesh generation with visible face culling and texture UVs. //! Only generates faces where a solid block meets air/transparent block. +//! Supports cross-chunk face culling when neighbor chunk data is provided. const std = @import("std"); const c = @cImport({ @@ -15,6 +16,16 @@ const Face = @import("block.zig").Face; const ALL_FACES = @import("block.zig").ALL_FACES; const TextureAtlas = @import("../engine/graphics/texture_atlas.zig").TextureAtlas; +/// Neighbor chunks for cross-chunk face culling +pub const NeighborChunks = struct { + north: ?*const Chunk = null, // -Z + south: ?*const Chunk = null, // +Z + east: ?*const Chunk = null, // +X + west: ?*const Chunk = null, // -X + + pub const empty = NeighborChunks{}; +}; + pub const ChunkMesh = struct { vao: c.GLuint, vbo: c.GLuint, @@ -73,8 +84,13 @@ pub const ChunkMesh = struct { c.glDeleteBuffers().?(1, &self.vbo); } - /// Build mesh from chunk data with face culling + /// Build mesh from chunk data with face culling (no neighbor awareness) pub fn build(self: *ChunkMesh, chunk: *const Chunk) !void { + return self.buildWithNeighbors(chunk, NeighborChunks.empty); + } + + /// Build mesh from chunk data with cross-chunk face culling + pub fn buildWithNeighbors(self: *ChunkMesh, chunk: *const Chunk, neighbors: NeighborChunks) !void { var vertices = std.ArrayListUnmanaged(f32){}; defer vertices.deinit(self.allocator); @@ -99,7 +115,7 @@ pub const ChunkMesh = struct { // Check each face for (ALL_FACES) |face| { - if (self.shouldRenderFace(chunk, x, y, z, face)) { + if (shouldRenderFace(chunk, neighbors, x, y, z, face)) { try self.addFace(&vertices, world_x, world_y, world_z, face, block); } } @@ -111,18 +127,6 @@ pub const ChunkMesh = struct { self.uploadVertices(vertices.items); } - /// Check if a face should be rendered (neighbor is air/transparent) - fn shouldRenderFace(self: *ChunkMesh, chunk: *const Chunk, x: u32, y: u32, z: u32, face: Face) bool { - _ = self; - const offset = face.getOffset(); - const nx = @as(i32, @intCast(x)) + offset.x; - const ny = @as(i32, @intCast(y)) + offset.y; - const nz = @as(i32, @intCast(z)) + offset.z; - - const neighbor = chunk.getBlockSafe(nx, ny, nz); - return neighbor.isTransparent(); - } - /// Add a face (2 triangles, 6 vertices) to the vertex list fn addFace(self: *ChunkMesh, vertices: *std.ArrayListUnmanaged(f32), x: f32, y: f32, z: f32, face: Face, block: BlockType) !void { const color = block.getFaceColor(face); @@ -199,6 +203,56 @@ pub const ChunkMesh = struct { } }; +/// Check if a face should be rendered (neighbor is air/transparent) +/// Supports cross-chunk lookups via NeighborChunks +fn shouldRenderFace(chunk: *const Chunk, neighbors: NeighborChunks, x: u32, y: u32, z: u32, face: Face) bool { + const offset = face.getOffset(); + const nx = @as(i32, @intCast(x)) + offset.x; + const ny = @as(i32, @intCast(y)) + offset.y; + const nz = @as(i32, @intCast(z)) + offset.z; + + // Y bounds check (no vertical neighbors) + if (ny < 0 or ny >= CHUNK_SIZE_Y) { + return ny < 0; // Render bottom face at y=0, hide top face above world + } + + // Check if neighbor is in adjacent chunk + if (nx < 0) { + // West neighbor (-X) + if (neighbors.west) |west_chunk| { + return west_chunk.getBlock(CHUNK_SIZE_X - 1, @intCast(ny), @intCast(z)).isTransparent(); + } + return true; // No neighbor chunk loaded, render the face + } + + if (nx >= CHUNK_SIZE_X) { + // East neighbor (+X) + if (neighbors.east) |east_chunk| { + return east_chunk.getBlock(0, @intCast(ny), @intCast(z)).isTransparent(); + } + return true; + } + + if (nz < 0) { + // North neighbor (-Z) + if (neighbors.north) |north_chunk| { + return north_chunk.getBlock(@intCast(x), @intCast(ny), CHUNK_SIZE_Z - 1).isTransparent(); + } + return true; + } + + if (nz >= CHUNK_SIZE_Z) { + // South neighbor (+Z) + if (neighbors.south) |south_chunk| { + return south_chunk.getBlock(@intCast(x), @intCast(ny), 0).isTransparent(); + } + return true; + } + + // Neighbor is within this chunk + return chunk.getBlock(@intCast(nx), @intCast(ny), @intCast(nz)).isTransparent(); +} + /// Get the 4 corners of a face (counter-clockwise winding) fn getFaceCorners(x: f32, y: f32, z: f32, face: Face) [4][3]f32 { return switch (face) { diff --git a/src/world/world.zig b/src/world/world.zig index 4a7b275e..af2337e9 100644 --- a/src/world/world.zig +++ b/src/world/world.zig @@ -3,6 +3,7 @@ const std = @import("std"); const Chunk = @import("chunk.zig").Chunk; const ChunkMesh = @import("chunk_mesh.zig").ChunkMesh; +const NeighborChunks = @import("chunk_mesh.zig").NeighborChunks; const BlockType = @import("block.zig").BlockType; const worldToChunk = @import("chunk.zig").worldToChunk; const worldToLocal = @import("chunk.zig").worldToLocal; @@ -145,13 +146,25 @@ pub const World = struct { // Rebuild mesh if dirty if (data.chunk.dirty) { - try data.mesh.build(&data.chunk); + // Gather neighbor chunks for cross-chunk face culling + const neighbors = self.getNeighborChunks(cx, cz); + try data.mesh.buildWithNeighbors(&data.chunk, neighbors); data.chunk.dirty = false; } } } } + /// Get neighbor chunks for a given chunk position + fn getNeighborChunks(self: *World, chunk_x: i32, chunk_z: i32) NeighborChunks { + return .{ + .north = if (self.getChunk(chunk_x, chunk_z - 1)) |d| &d.chunk else null, + .south = if (self.getChunk(chunk_x, chunk_z + 1)) |d| &d.chunk else null, + .east = if (self.getChunk(chunk_x + 1, chunk_z)) |d| &d.chunk else null, + .west = if (self.getChunk(chunk_x - 1, chunk_z)) |d| &d.chunk else null, + }; + } + /// Render all loaded chunks with frustum culling pub fn render(self: *World, shader: *const Shader, view_proj: Mat4) void { shader.use(); From d72e7d90cffbea9148fa7ee6a0b150dd83ad1bc8 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sat, 20 Dec 2025 18:23:14 +0000 Subject: [PATCH 07/10] update --- ROADMAPv2.md | 478 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 478 insertions(+) create mode 100644 ROADMAPv2.md diff --git a/ROADMAPv2.md b/ROADMAPv2.md new file mode 100644 index 00000000..90784a78 --- /dev/null +++ b/ROADMAPv2.md @@ -0,0 +1,478 @@ + +# Voxel Game Spec: Blocks + Worldgen + UI (Seeded) + +This document specs the **basic building blocks** of a voxel sandbox game and a **seeded procedural world generator** with biomes, mountains, cliffs, oceans, rivers, oases, etc., plus a **home screen UI** with seed input and reproducibility. + +--- + +## 1) Core Requirements + +### 1.1 Goals +- Deterministic world generation: **same seed => same world** (across machines). +- Infinite or very large worlds via **chunked generation**. +- Multiple biomes and large-scale features: + - oceans, beaches, rivers, lakes + - plains/forests/deserts/snow biomes + - mountain ranges, cliffs/plateaus + - caves and ore distribution + - oases in deserts (rare, seeded) + +### 1.2 Non-goals (for v1) +- Complex climate simulation +- Realistic erosion simulation +- Full story/progression systems +These can be added later. + +--- + +## 2) World Structure + +### 2.1 Coordinate System +- World coordinates: integer (x, y, z) +- y is vertical, y=0 is sea level reference (can be offset). +- Use 32-bit int for block coords; 64-bit for derived hashes. + +### 2.2 Chunking +- Chunk size: **16 x 16 x 256** (x,z,y) or configurable. +- Vertical sections recommended (e.g., 16x16x16 subchunks) for memory efficiency. +- Each chunk stores: + - block IDs + - lighting (optional v1) + - metadata (optional) +- Generation happens in phases (heightmap first, then features). + +### 2.3 World Layers +A clean approach is to treat generation as layered fields: +- **Continentalness** (land vs ocean) +- **Erosion/roughness** (cliffs vs smooth hills) +- **Temperature** +- **Humidity** +- **Height (base terrain)** +- **Local modifiers** (mountain mask, river carving, etc.) + +--- + +## 3) Seed System + +### 3.1 Seed Input +- Accept: + - string seed (e.g. `"my cool world"`) + - numeric seed (e.g. `123456789`) +- Convert string seed to 64-bit integer via stable hash (e.g., FNV-1a 64-bit). +- Use a stable PRNG for deterministic randomness (e.g., PCG32 / splitmix64). + +### 3.2 Deterministic Noise +Use deterministic noise functions where: +- Input: (x, z) or (x, y, z) in world coords +- Output: float in [-1, 1] or [0, 1] +- Ensure floating-point determinism by: + - using integer-based hashing noise where possible + - or keeping same implementation + precision everywhere + +--- + +## 4) Block System + +### 4.1 Block Data Model +Each block has: +- `id` (uint16 or uint32) +- `name` +- `is_solid` +- `is_transparent` +- `emits_light` (optional) +- `light_absorption` (optional) +- `texture_index` per face (or material key) +- `break_time` (optional) +- `drops` (optional) +- `tags` (e.g., `ground`, `stone`, `wood`, `leaf`, `fluid`) + +### 4.2 Basic Block Set (v1) +Minimum set for a complete world loop (terrain + resources + building): + +#### Air / Fluids +- Air +- Water (source) +- Water (flowing) (optional v1; can fake as same block with level metadata) +- Lava (optional v1) +- Ice (cold biomes) +- Snow layer (thin overlay) (optional v1) + +#### Terrain: Surface +- Grass +- Dirt +- Sand +- Red sand (optional) +- Gravel +- Clay (optional) + +#### Terrain: Subsurface / Rock +- Stone +- Cobblestone (player-made, optional) +- Deepslate / Basalt (optional depth variation) +- Bedrock (bottom boundary) + +#### Biome-specific +- Podzol / forest floor (optional) +- Mossy dirt / moss block (optional) +- Silt / mud (swamp-like, optional) +- Limestone / sandstone (optional for deserts) +- Snow block + +#### Plants / Natural Blocks +- Short grass (decor) +- Tall grass (optional) +- Flowers (2–4 variants, optional) +- Cactus +- Dead bush (optional) +- Sugar cane / reeds (water edges) +- Logs (wood trunk) +- Leaves +- Sapling (optional) + +#### Ores (basic progression) +- Coal ore +- Iron ore +- Copper ore (optional) +- Gold ore (optional) +- Diamond-like rare ore (optional) +- Redstone-like ore (optional) + +#### Utility / Crafting (optional v1) +- Planks +- Crafting table +- Furnace +- Torch (light emitting) + +### 4.3 Metadata (Optional v1) +If not doing full blockstate system, allow minimal metadata per block: +- water level (0..7) +- orientation (for logs) +- growth stage (for saplings, crops later) + +--- + +## 5) Biome System + +### 5.1 Biome Definition +A biome is defined by: +- `id`, `name` +- climate: temperature range, humidity range +- surface blocks: + - top block (e.g., grass/sand/snow) + - filler block (e.g., dirt/sand) + - stone type overrides (optional) +- vegetation rules (density + types) +- terrain modifiers: + - base height offset + - hilliness + - cliffiness bias +- water color / fog (optional) +- spawn rules (optional) + +### 5.2 Biomes (v1 list) +- Ocean +- Beach +- Plains +- Forest +- Taiga (conifer + colder forest) +- Desert +- Savanna (optional) +- Tundra / Snow +- Mountains (high elevation biome) +- Badlands / Mesa (optional) +- Swamp (optional) + +### 5.3 Climate Map +Compute 2D climate maps from noise: +- Temperature noise `T(x,z)` in [0..1] +- Humidity noise `H(x,z)` in [0..1] +Biome selection uses: +- altitude influence (high => colder) +- proximity to ocean influence (optional) + +--- + +## 6) Terrain Generation Pipeline (Deterministic) + +### 6.1 Overview +Worldgen runs in deterministic steps: + +1. **Global maps (2D)** + Compute base fields per column (x,z): + - continentalness C(x,z) + - erosion E(x,z) + - temperature T(x,z) + - humidity H(x,z) + - mountain mask M(x,z) + - river mask R(x,z) +2. **Base height** from continentalness + mountain mask +3. **Cliffs** from slope + erosion +4. **Carving** rivers/coasts +5. **3D density field** for caves (optional v1) +6. **Material assignment** (stone/dirt/sand/snow) +7. **Features** (trees, cacti, ores, structures, oases) + +### 6.2 Sea Level +- Define `SEA_LEVEL = 64` (config). +- Any column where surface height < SEA_LEVEL becomes ocean/lake fill. + +### 6.3 Continentalness: Land vs Ocean +Use low-frequency noise to shape continents: +- `C(x,z)` in [0..1] +- thresholds: + - `C < 0.35` => deep ocean + - `0.35..0.45` => shallow ocean / coasts + - `> 0.45` => land + +This makes big oceans/continents instead of noisy puddles. + +### 6.4 Base Height Function +Compute a base height: +- `base = SEA_LEVEL + landLift(C)` +- `landLift(C)`: + - deep ocean => negative + - coast => near SEA_LEVEL + - inland => positive + +Example conceptual mapping: +- `landLift = lerp(-40, +60, smoothstep(0.35, 0.75, C))` + +### 6.5 Mountains (Ranges) +Use a mountain mask `M(x,z)`: +- low-frequency ridge noise or combined FBM +- threshold to form ranges: + - `M > 0.6` => mountain region +Mountains add height: +- `mountAdd = pow(remap(M, 0.6..1.0), 2.0) * mountainAmplitude` +- amplitude: 60–140 blocks depending on desired scale + +### 6.6 Hills / Local Variation +Use mid-frequency noise `Hn(x,z)`: +- adds small-to-medium variation (5–25 blocks) + +### 6.7 Cliffs / Plateaus +Cliffs should appear where: +- slope is high OR erosion is low (meaning sharp terrain) +Compute slope using sampled heights: +- `slope = max(|h(x+1)-h(x)|, |h(z+1)-h(z)|)` +Cliffiness: +- `cliff = smoothstep(slopeLow, slopeHigh, slope) * (1 - E)` +Apply cliff shaping: +- Increase verticality by compressing heights into plateau steps or steep ramps. +Material rules: +- cliffs expose stone more (thin topsoil). + +### 6.8 Oceans, Beaches, Shores +- If surface height < SEA_LEVEL: + - fill with water up to SEA_LEVEL + - seabed is sand/gravel/clay mix +- Beaches: + - within N blocks of coastline AND height near SEA_LEVEL => sand + +### 6.9 Rivers (Carving) +Use a river mask `R(x,z)`: +- generate a low-frequency "flow field" + noise threshold lines OR use “distance to river spline” style. +Simpler deterministic method: +- `R = abs(noise_river(x,z))` +- if `R < riverWidthThreshold`, this column is in a river corridor. +Carve height towards a river bed level: +- `riverDepth = remap(R, 0..threshold)` (deeper at center) +- `h = min(h, SEA_LEVEL - 2 - riverDepth)` +Fill with water where below SEA_LEVEL. + +### 6.10 Lakes (Optional) +Lakes can be placed as rare features: +- pick candidate points by hashed grid +- if local basin exists, fill to lake level +Keep it deterministic by hashing region coords. + +--- + +## 7) Material Assignment (Surface + Subsurface) + +For each (x,z): +1. Determine final height `h`. +2. Determine biome based on (T, H, altitude, ocean distance). +3. Assign column materials: + - y == h => top block (grass/sand/snow) + - next `fillerDepth` (3–6) => filler (dirt/sand) + - below => stone +4. Add bedrock at bottom: + - y=0..4 => bedrock noise threshold + +Biome-specific rules: +- Desert: top sand, filler sand/sandstone +- Snow: top snow block or snow layer + dirt +- Mountains: top stone/snow depending on temp/altitude + +--- + +## 8) Caves & Ores (v1-friendly) + +### 8.1 Caves +Option A (simple): 3D noise threshold carving. +- `density = noise3d(x,y,z)` + vertical bias +- if density > threshold => carve to air +Add cave rarity by using lower frequency + threshold tuning. + +Option B (better later): worm/tunnel carving via random walk seeded per region. + +### 8.2 Ores +Run ore passes after stone placement: +- For each ore type: + - vertical range (minY..maxY) + - vein size + - vein count per chunk +- Deterministic placements using: + - per-chunk PRNG seeded by (worldSeed, chunkX, chunkZ, oreType) + +--- + +## 9) Features: Trees, Cacti, Vegetation, Oases + +### 9.1 Feature Placement Strategy +For each chunk: +- Seed PRNG with `(worldSeed, chunkX, chunkZ, featurePassId)`. +- Decide a number of attempts based on biome. +- For each attempt: + - pick (x,z) in chunk + - find surface y + - validate placement rules + - place blocks + +### 9.2 Trees +- Forest/taiga: more frequent +- Plains: rare lone trees +Tree shapes (v1): +- simple trunk height 4–7 +- leaf blob radius 2–3 +Use biome-specific block types (log/leaves). + +### 9.3 Cacti +- Desert only +- height 2–5 +- must be on sand + +### 9.4 Oases (Desert Feature) +Goal: rare pockets of water + palms/trees in deserts. +Deterministic placement: +- Divide world into large regions (e.g., 256x256 blocks) +- For each region: + - use hashed RNG to decide if an oasis exists (e.g., 5–10% chance) + - if yes, pick a center point in the region +Placement rules: +- biome at center must be desert +- must be inland enough (not right on coast) +Build steps: +- carve a shallow basin +- fill with water (small lake) +- place sand around edges +- add reeds + a few trees + grass patches nearby + +--- + +## 10) Home Screen UI Spec (Seed + World Creation) + +### 10.1 Home Screen Layout +Required elements: +- Title: game name +- Primary actions: + - `Singleplayer` (opens world create/load) + - `Settings` + - `Quit` +Optional: +- `Continue` (last played world) +- `Credits` + +### 10.2 Singleplayer Screen +Two sections: +- **World List** + - world name + - last played date + - seed (hidden behind “details”) + - buttons: Play / Delete / Rename (delete requires confirm) +- **Create World** + - World Name (text) + - Seed (text input) + - placeholder: “Leave blank for random” + - Random seed button (generates a seed string or number) + - World options (v1 minimal): + - World Size: Infinite (default) / Limited (optional) + - Starting biome bias: None (default) (optional) + - Create button + +### 10.3 Seed Behavior +- If seed input is empty: + - generate a random 64-bit seed and display it after creation +- If seed input is provided: + - store original string plus hashed numeric seed +Reproducibility: +- World folder stores: + - `seed_string` (optional) + - `seed_u64` + - `worldgen_version` + +### 10.4 Worldgen Versioning +Store a `worldgen_version` integer. +If you change generation later: +- new worlds get new version +- old worlds keep their version for deterministic chunk regen + +--- + +## 11) Data Storage Spec + +### 11.1 World Save Folder +Example structure: +- `worlds//` + - `world.json` (metadata) + - `region/` (chunk storage) + - `player/` (player state) + +### 11.2 `world.json` +Fields: +- `world_name` +- `seed_u64` +- `seed_string` (optional) +- `worldgen_version` +- `created_at` +- `last_played_at` +- `settings`: + - `sea_level` + - `chunk_size` + - `enabled_features` (optional) + +--- + +## 12) Implementation Roadmap (Suggested Order) + +1. Seed system + deterministic PRNG +2. Chunk system + storage + basic meshing +3. Heightmap terrain: continentalness -> land/ocean +4. Biome selection via temp/humidity +5. Surface materials (grass/sand/snow) +6. Mountains + cliffs +7. Rivers + beaches +8. Caves (optional) +9. Ores +10. Vegetation (trees/cacti/reeds) +11. Oases +12. Home screen + world create/load with seed +13. Worldgen versioning + save format stabilization + +--- + +## 13) Acceptance Criteria (v1) + +- Creating a world with a seed reproduces the same terrain layout. +- Oceans/continents are large-scale and readable. +- At least 5 biomes appear in a typical exploration. +- Mountains and cliffs visibly exist (not just bumpy hills). +- Rivers exist and flow through land into oceans (even if simplified). +- Desert oases exist rarely and are deterministic. +- Home screen allows: + - create world (name + seed) + - load existing world + - random seed generation + +--- From b993998adb054d78ce46b1dbb0616a8b6577d6f3 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sat, 20 Dec 2025 20:09:00 +0000 Subject: [PATCH 08/10] feat: implement full UX flow with home, settings, and pause menus --- src/c.zig | 6 + src/engine/core/log.zig | 8 +- src/engine/core/time.zig | 4 +- src/engine/graphics/mesh.zig | 4 +- src/engine/graphics/renderer.zig | 9 +- src/engine/graphics/shader.zig | 4 +- src/engine/graphics/texture.zig | 4 +- src/engine/graphics/texture_atlas.zig | 4 +- src/engine/input/input.zig | 4 +- src/engine/ui/ui_system.zig | 8 +- src/main.zig | 652 ++++++++++++++++++++++---- src/world/block.zig | 10 + src/world/chunk_mesh.zig | 4 +- src/world/worldgen/generator.zig | 344 ++++++++++++-- src/world/worldgen/noise.zig | 60 ++- 15 files changed, 966 insertions(+), 159 deletions(-) create mode 100644 src/c.zig diff --git a/src/c.zig b/src/c.zig new file mode 100644 index 00000000..79f16003 --- /dev/null +++ b/src/c.zig @@ -0,0 +1,6 @@ +pub const c = @cImport({ + @cDefine("_FORTIFY_SOURCE", "0"); + @cInclude("SDL3/SDL.h"); + @cInclude("GL/glew.h"); + @cInclude("SDL3/SDL_opengl.h"); +}); diff --git a/src/engine/core/log.zig b/src/engine/core/log.zig index df4f4b21..73140874 100644 --- a/src/engine/core/log.zig +++ b/src/engine/core/log.zig @@ -63,9 +63,7 @@ pub var log = Logger.init(.debug); /// OpenGL error checking pub fn checkGLError(location: []const u8) bool { - const c = @cImport({ - @cInclude("GL/glew.h"); - }); + const c = @import("../../c.zig").c; var had_error = false; while (true) { @@ -89,8 +87,6 @@ pub fn checkGLError(location: []const u8) bool { /// Clear any pending GL errors pub fn clearGLErrors() void { - const c = @cImport({ - @cInclude("GL/glew.h"); - }); + const c = @import("../../c.zig").c; while (c.glGetError() != c.GL_NO_ERROR) {} } diff --git a/src/engine/core/time.zig b/src/engine/core/time.zig index f48081e3..475fbdf7 100644 --- a/src/engine/core/time.zig +++ b/src/engine/core/time.zig @@ -2,9 +2,7 @@ //! Provides delta time, fixed timestep support, and FPS tracking. const std = @import("std"); -const c = @cImport({ - @cInclude("SDL3/SDL.h"); -}); +const c = @import("../../c.zig").c; pub const Time = struct { /// Time since last frame in seconds diff --git a/src/engine/graphics/mesh.zig b/src/engine/graphics/mesh.zig index 20eef2e3..d8da7211 100644 --- a/src/engine/graphics/mesh.zig +++ b/src/engine/graphics/mesh.zig @@ -1,9 +1,7 @@ //! Mesh abstraction for VAO/VBO management. const std = @import("std"); -const c = @cImport({ - @cInclude("GL/glew.h"); -}); +const c = @import("../../c.zig").c; pub const Mesh = struct { vao: c.GLuint, diff --git a/src/engine/graphics/renderer.zig b/src/engine/graphics/renderer.zig index 368c0029..87e0f01c 100644 --- a/src/engine/graphics/renderer.zig +++ b/src/engine/graphics/renderer.zig @@ -1,9 +1,7 @@ //! Main renderer that manages OpenGL state and rendering pipeline. const std = @import("std"); -const c = @cImport({ - @cInclude("GL/glew.h"); -}); +const c = @import("../../c.zig").c; const Mat4 = @import("../math/mat4.zig").Mat4; const Vec3 = @import("../math/vec3.zig").Vec3; @@ -177,10 +175,7 @@ pub const Renderer = struct { /// Set VSync mode (call after creating GL context) pub fn setVSync(enabled: bool) void { - const sdl = @cImport({ - @cInclude("SDL3/SDL.h"); - }); - _ = sdl.SDL_GL_SetSwapInterval(if (enabled) 1 else 0); + _ = c.SDL_GL_SetSwapInterval(if (enabled) 1 else 0); log.log.info("VSync: {}", .{enabled}); } diff --git a/src/engine/graphics/shader.zig b/src/engine/graphics/shader.zig index c712ebaa..7f50aa23 100644 --- a/src/engine/graphics/shader.zig +++ b/src/engine/graphics/shader.zig @@ -1,9 +1,7 @@ //! Shader compilation and program management with uniform caching. const std = @import("std"); -const c = @cImport({ - @cInclude("GL/glew.h"); -}); +const c = @import("../../c.zig").c; const log = @import("../core/log.zig"); diff --git a/src/engine/graphics/texture.zig b/src/engine/graphics/texture.zig index ec9a362a..c597819b 100644 --- a/src/engine/graphics/texture.zig +++ b/src/engine/graphics/texture.zig @@ -1,9 +1,7 @@ //! Texture loading and management. const std = @import("std"); -const c = @cImport({ - @cInclude("GL/glew.h"); -}); +const c = @import("../../c.zig").c; const log = @import("../core/log.zig"); diff --git a/src/engine/graphics/texture_atlas.zig b/src/engine/graphics/texture_atlas.zig index 5a24d697..df8458c5 100644 --- a/src/engine/graphics/texture_atlas.zig +++ b/src/engine/graphics/texture_atlas.zig @@ -2,9 +2,7 @@ //! Generates a procedural texture atlas with all block types. const std = @import("std"); -const c = @cImport({ - @cInclude("GL/glew.h"); -}); +const c = @import("../../c.zig").c; const Texture = @import("texture.zig").Texture; const FilterMode = @import("texture.zig").FilterMode; diff --git a/src/engine/input/input.zig b/src/engine/input/input.zig index a8648b4f..47d2f12f 100644 --- a/src/engine/input/input.zig +++ b/src/engine/input/input.zig @@ -7,9 +7,7 @@ const Key = interfaces.Key; const MouseButton = interfaces.MouseButton; const Modifiers = interfaces.Modifiers; -const c = @cImport({ - @cInclude("SDL3/SDL.h"); -}); +const c = @import("../../c.zig").c; pub const Input = struct { /// Currently pressed keys diff --git a/src/engine/ui/ui_system.zig b/src/engine/ui/ui_system.zig index 6abd97c7..60b7363a 100644 --- a/src/engine/ui/ui_system.zig +++ b/src/engine/ui/ui_system.zig @@ -2,9 +2,7 @@ //! Uses orthographic projection and immediate-mode style rendering. const std = @import("std"); -const c = @cImport({ - @cInclude("GL/glew.h"); -}); +const c = @import("../../c.zig").c; const Mat4 = @import("../math/mat4.zig").Mat4; const Vec3 = @import("../math/vec3.zig").Vec3; @@ -86,8 +84,9 @@ pub const UISystem = struct { /// Begin UI rendering (call before drawing any UI elements) pub fn begin(self: *UISystem) void { - // Disable depth test for UI + // Disable depth test and culling for UI c.glDisable(c.GL_DEPTH_TEST); + c.glDisable(c.GL_CULL_FACE); self.shader.use(); @@ -103,6 +102,7 @@ pub const UISystem = struct { _ = self; c.glBindVertexArray().?(0); c.glEnable(c.GL_DEPTH_TEST); + c.glEnable(c.GL_CULL_FACE); } /// Draw a filled rectangle diff --git a/src/main.zig b/src/main.zig index 24fad6f8..4683d709 100644 --- a/src/main.zig +++ b/src/main.zig @@ -12,6 +12,7 @@ const Time = @import("engine/core/time.zig").Time; const UISystem = @import("engine/ui/ui_system.zig").UISystem; const Color = @import("engine/ui/ui_system.zig").Color; const Rect = @import("engine/core/interfaces.zig").Rect; +const Key = @import("engine/core/interfaces.zig").Key; const log = @import("engine/core/log.zig"); const TextureAtlas = @import("engine/graphics/texture_atlas.zig").TextureAtlas; @@ -19,12 +20,7 @@ const TextureAtlas = @import("engine/graphics/texture_atlas.zig").TextureAtlas; const World = @import("world/world.zig").World; // C imports -const c = @cImport({ - @cDefine("_FORTIFY_SOURCE", "0"); - @cInclude("SDL3/SDL.h"); - @cInclude("GL/glew.h"); - @cInclude("SDL3/SDL_opengl.h"); -}); +const c = @import("c.zig").c; // Textured terrain shaders const vertex_shader_src = @@ -69,6 +65,22 @@ const fragment_shader_src = \\} ; +const AppState = enum { + home, + singleplayer, + world, + paused, + settings, +}; + +const Settings = struct { + render_distance: i32 = 2, + mouse_sensitivity: f32 = 50.0, + vsync: bool = true, + fov: f32 = 45.0, + textures_enabled: bool = true, +}; + pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer _ = gpa.deinit(); @@ -113,6 +125,8 @@ pub fn main() !void { var input = Input.init(allocator); defer input.deinit(); + input.window_width = 1280; + input.window_height = 720; var time = Time.init(); var renderer = Renderer.init(); @@ -135,15 +149,21 @@ pub fn main() !void { var atlas = TextureAtlas.init(allocator); defer atlas.deinit(); - // 9. Create World - const seed: u64 = 12345; // World seed for terrain generation - var world = World.init(allocator, 2, seed); // 2 chunk render distance (5x5 = 25 chunks) - defer world.deinit(); - - // 10. Create UI System for FPS display + // 9. Create UI System for menus/FPS display var ui = try UISystem.init(1280, 720); defer ui.deinit(); + // 10. Menu + world state + var app_state: AppState = .home; + var last_state: AppState = .home; // For "Back" button in settings + var settings = Settings{}; + var seed_input = std.ArrayList(u8).empty; + defer seed_input.deinit(allocator); + var seed_focused = false; + + var world: ?World = null; + defer if (world) |*active_world| active_world.deinit(); + // Initial viewport renderer.setViewport(1280, 720); @@ -151,8 +171,8 @@ pub fn main() !void { log.log.info("Controls: WASD=Move, Space/Shift=Up/Down, Tab=Mouse, F=Wireframe, T=Textures, V=VSync, Esc=Quit", .{}); // 11. Main Loop - var vsync_enabled = true; - var textures_enabled = true; + // Sync initial settings + setVSync(settings.vsync); while (!input.should_quit) { // Update time @@ -167,91 +187,317 @@ pub fn main() !void { input.should_quit = true; } - // Toggle mouse capture with Tab - if (input.isKeyPressed(.tab)) { - const captured = !input.mouse_captured; - input.mouse_captured = captured; - _ = c.SDL_SetWindowRelativeMouseMode(window, captured); - } - - // Toggle wireframe with F - if (input.isKeyPressed(.f)) { - renderer.toggleWireframe(); - } - - // Toggle textures with T - if (input.isKeyPressed(.t)) { - textures_enabled = !textures_enabled; - log.log.info("Textures: {}", .{textures_enabled}); - } - - // Toggle VSync with V - if (input.isKeyPressed(.v)) { - vsync_enabled = !vsync_enabled; - setVSync(vsync_enabled); - } - - // Update camera - camera.update(&input, time.delta_time); - - // Update world (load chunks around player) - try world.update(camera.position); - - // Debug: print stats on first few frames - if (time.frame_count < 3) { - const stats = world.getStats(); - std.debug.print("Frame {}: Chunks={}, Vertices={}\n", .{ - time.frame_count, stats.chunks_loaded, stats.total_vertices, - }); - } - // Handle window resize renderer.setViewport(input.window_width, input.window_height); ui.resize(input.window_width, input.window_height); - // Calculate matrices - const aspect = @as(f32, @floatFromInt(input.window_width)) / @as(f32, @floatFromInt(input.window_height)); - const view_proj = camera.getViewProjectionMatrix(aspect); + const screen_w: f32 = @floatFromInt(input.window_width); + const screen_h: f32 = @floatFromInt(input.window_height); + const mouse_pos = input.getMousePosition(); + const mouse_x: f32 = @floatFromInt(mouse_pos.x); + const mouse_y: f32 = @floatFromInt(mouse_pos.y); + const mouse_clicked = input.isMouseButtonPressed(.left); + + const in_world = app_state == .world; + const in_pause = app_state == .paused; + + if (in_world or in_pause) { + // Toggle mouse capture with Tab (only in world) + if (in_world and input.isKeyPressed(.tab)) { + input.setMouseCapture(window, !input.mouse_captured); + } + + // Pause toggle with Escape + if (input.isKeyPressed(.escape)) { + if (in_world) { + app_state = .paused; + input.setMouseCapture(window, false); + } else if (in_pause) { + app_state = .world; + input.setMouseCapture(window, true); + } + } + + // Toggle wireframe with F + if (input.isKeyPressed(.f)) { + renderer.toggleWireframe(); + } + + // Toggle textures with T + if (input.isKeyPressed(.t)) { + settings.textures_enabled = !settings.textures_enabled; + log.log.info("Textures: {}", .{settings.textures_enabled}); + } + + // Toggle VSync with V + if (input.isKeyPressed(.v)) { + settings.vsync = !settings.vsync; + setVSync(settings.vsync); + } + + // Update camera only if in world + if (in_world) { + camera.move_speed = settings.mouse_sensitivity; + camera.update(&input, time.delta_time); + } + + if (world) |*active_world| { + // Update world (load chunks around player) + active_world.render_distance = settings.render_distance; + try active_world.update(camera.position); + } else { + app_state = .home; + } + } else { + if (input.mouse_captured) { + input.setMouseCapture(window, false); + } + } - // Render 3D world + renderer.setClearColor(if (in_world or in_pause) Vec3.init(0.5, 0.7, 1.0) else Vec3.init(0.07, 0.08, 0.1)); renderer.beginFrame(); - // Bind texture atlas and set uniforms - shader.use(); - atlas.bind(0); - shader.setInt("uTexture", 0); - shader.setBool("uUseTexture", textures_enabled); - - world.render(&shader, view_proj); - - // Render UI (FPS counter) - ui.begin(); - - // Draw FPS background - ui.drawRect(.{ .x = 10, .y = 10, .width = 80, .height = 30 }, Color.rgba(0, 0, 0, 0.7)); - - // Draw FPS digits - drawNumber(&ui, @intFromFloat(time.fps), 15, 15, Color.white); - - ui.end(); + if (in_world or in_pause) { + if (world) |*active_world| { + // Calculate matrices + const aspect = screen_w / screen_h; + // TODO: Update camera FOV with settings.fov + const view_proj = camera.getViewProjectionMatrix(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); + + // Render UI (FPS counter) + ui.begin(); + ui.drawRect(.{ .x = 10, .y = 10, .width = 80, .height = 30 }, Color.rgba(0, 0, 0, 0.7)); + drawNumber(&ui, @intFromFloat(time.fps), 15, 15, Color.white); + + if (in_pause) { + // Darken background + ui.drawRect(.{ .x = 0, .y = 0, .width = screen_w, .height = screen_h }, Color.rgba(0, 0, 0, 0.5)); + + const pause_w: f32 = 300.0; + const pause_h: f32 = 48.0; + const pause_x: f32 = (screen_w - pause_w) * 0.5; + var pause_y: f32 = screen_h * 0.35; + + drawTextCentered(&ui, "PAUSED", screen_w * 0.5, pause_y - 60.0, 3.0, Color.white); + + if (drawButton(&ui, .{ .x = pause_x, .y = pause_y, .width = pause_w, .height = pause_h }, "RESUME", 2.0, mouse_x, mouse_y, mouse_clicked)) { + app_state = .world; + input.setMouseCapture(window, true); + } + pause_y += pause_h + 16.0; + + if (drawButton(&ui, .{ .x = pause_x, .y = pause_y, .width = pause_w, .height = pause_h }, "SETTINGS", 2.0, mouse_x, mouse_y, mouse_clicked)) { + last_state = .paused; + app_state = .settings; + } + pause_y += pause_h + 16.0; + + if (drawButton(&ui, .{ .x = pause_x, .y = pause_y, .width = pause_w, .height = pause_h }, "QUIT TO TITLE", 2.0, mouse_x, mouse_y, mouse_clicked)) { + app_state = .home; + if (world) |*w| { + w.deinit(); + world = null; + } + } + } + + ui.end(); + } + } else { + ui.begin(); + + switch (app_state) { + .home => { + const title_scale: f32 = 4.0; + drawTextCentered(&ui, "ZIG VOXEL ENGINE", screen_w * 0.5, screen_h * 0.16, title_scale, Color.rgba(0.95, 0.96, 0.98, 1.0)); + + const button_w: f32 = @min(screen_w * 0.5, 360.0); + const button_h: f32 = 48.0; + const button_x: f32 = (screen_w - button_w) * 0.5; + var button_y: f32 = screen_h * 0.4; + + if (drawButton(&ui, .{ .x = button_x, .y = button_y, .width = button_w, .height = button_h }, "SINGLEPLAYER", 2.2, mouse_x, mouse_y, mouse_clicked)) { + app_state = .singleplayer; + seed_focused = true; + } + button_y += button_h + 14.0; + + if (drawButton(&ui, .{ .x = button_x, .y = button_y, .width = button_w, .height = button_h }, "SETTINGS", 2.2, mouse_x, mouse_y, mouse_clicked)) { + last_state = .home; + app_state = .settings; + } + button_y += button_h + 14.0; + + if (drawButton(&ui, .{ .x = button_x, .y = button_y, .width = button_w, .height = button_h }, "QUIT", 2.2, mouse_x, mouse_y, mouse_clicked)) { + input.should_quit = true; + } + }, + .settings => { + const panel_w: f32 = @min(screen_w * 0.7, 600.0); + const panel_h: f32 = 400.0; + const panel_x: f32 = (screen_w - panel_w) * 0.5; + const panel_y: f32 = (screen_h - panel_h) * 0.5; + + ui.drawRect(.{ .x = panel_x, .y = panel_y, .width = panel_w, .height = panel_h }, Color.rgba(0.12, 0.14, 0.18, 0.95)); + ui.drawRectOutline(.{ .x = panel_x, .y = panel_y, .width = panel_w, .height = panel_h }, Color.rgba(0.28, 0.33, 0.42, 1.0), 2.0); + + drawTextCentered(&ui, "SETTINGS", screen_w * 0.5, panel_y + 20.0, 2.8, Color.white); + + var setting_y: f32 = panel_y + 80.0; + const label_x: f32 = panel_x + 40.0; + const value_x: f32 = panel_x + panel_w - 200.0; + + // Render Distance + drawText(&ui, "RENDER DISTANCE", label_x, setting_y, 2.0, Color.white); + drawNumber(&ui, @intCast(settings.render_distance), value_x + 60.0, setting_y, Color.white); + if (drawButton(&ui, .{ .x = value_x, .y = setting_y - 5.0, .width = 30.0, .height = 30.0 }, "-", 1.5, mouse_x, mouse_y, mouse_clicked)) { + 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 < 16) settings.render_distance += 1; + } + setting_y += 50.0; + + // Mouse Sensitivity + drawText(&ui, "SENSITIVITY", label_x, setting_y, 2.0, Color.white); + drawNumber(&ui, @intFromFloat(settings.mouse_sensitivity), value_x + 60.0, setting_y, Color.white); + if (drawButton(&ui, .{ .x = value_x, .y = setting_y - 5.0, .width = 30.0, .height = 30.0 }, "-", 1.5, mouse_x, mouse_y, mouse_clicked)) { + if (settings.mouse_sensitivity > 10.0) settings.mouse_sensitivity -= 5.0; + } + 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.mouse_sensitivity < 200.0) settings.mouse_sensitivity += 5.0; + } + setting_y += 50.0; + + // FOV + drawText(&ui, "FOV", label_x, setting_y, 2.0, Color.white); + drawNumber(&ui, @intFromFloat(settings.fov), value_x + 60.0, setting_y, Color.white); + if (drawButton(&ui, .{ .x = value_x, .y = setting_y - 5.0, .width = 30.0, .height = 30.0 }, "-", 1.5, mouse_x, mouse_y, mouse_clicked)) { + if (settings.fov > 30.0) settings.fov -= 5.0; + } + 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.fov < 120.0) settings.fov += 5.0; + } + setting_y += 50.0; + + // VSync + drawText(&ui, "VSYNC", label_x, setting_y, 2.0, Color.white); + if (drawButton(&ui, .{ .x = value_x, .y = setting_y - 5.0, .width = 130.0, .height = 30.0 }, if (settings.vsync) "ENABLED" else "DISABLED", 1.5, mouse_x, mouse_y, mouse_clicked)) { + settings.vsync = !settings.vsync; + setVSync(settings.vsync); + } + setting_y += 50.0; + + // Back Button + if (drawButton(&ui, .{ .x = panel_x + (panel_w - 120.0) * 0.5, .y = panel_y + panel_h - 60.0, .width = 120.0, .height = 40.0 }, "BACK", 2.0, mouse_x, mouse_y, mouse_clicked)) { + app_state = last_state; + } + }, + .singleplayer => { + const panel_w: f32 = @min(screen_w * 0.7, 520.0); + const panel_h: f32 = 260.0; + const panel_x: f32 = (screen_w - panel_w) * 0.5; + const panel_y: f32 = screen_h * 0.24; + + ui.drawRect(.{ .x = panel_x, .y = panel_y, .width = panel_w, .height = panel_h }, Color.rgba(0.12, 0.14, 0.18, 0.92)); + ui.drawRectOutline(.{ .x = panel_x, .y = panel_y, .width = panel_w, .height = panel_h }, Color.rgba(0.28, 0.33, 0.42, 1.0), 2.0); + + drawTextCentered(&ui, "CREATE WORLD", screen_w * 0.5, panel_y + 18.0, 2.8, Color.rgba(0.92, 0.94, 0.97, 1.0)); + + const label_y: f32 = panel_y + 78.0; + drawText(&ui, "SEED", panel_x + 24.0, label_y, 2.0, Color.rgba(0.72, 0.78, 0.86, 1.0)); + + const input_h: f32 = 42.0; + const input_y: f32 = label_y + 22.0; + const random_w: f32 = 120.0; + const input_w: f32 = panel_w - 24.0 - random_w - 12.0 - 24.0; + const input_x: f32 = panel_x + 24.0; + const random_x: f32 = input_x + input_w + 12.0; + + const seed_rect = Rect{ .x = input_x, .y = input_y, .width = input_w, .height = input_h }; + const random_rect = Rect{ .x = random_x, .y = input_y, .width = random_w, .height = input_h }; + + if (mouse_clicked) { + seed_focused = seed_rect.contains(mouse_x, mouse_y); + } + + const caret_on = @as(u32, @intFromFloat(time.elapsed * 2.0)) % 2 == 0; + drawTextInput(&ui, seed_rect, seed_input.items, "LEAVE BLANK FOR RANDOM", 2.0, seed_focused, caret_on); + + if (drawButton(&ui, random_rect, "RANDOM", 1.8, mouse_x, mouse_y, mouse_clicked)) { + const generated = randomSeedValue(); + try setSeedInput(&seed_input, allocator, generated); + seed_focused = true; + } + + if (seed_focused) { + try handleSeedTyping(&seed_input, allocator, &input, 32); + } + + const button_y: f32 = panel_y + panel_h - 64.0; + const half_w: f32 = (panel_w - 24.0 - 12.0 - 24.0) / 2.0; + const back_rect = Rect{ .x = panel_x + 24.0, .y = button_y, .width = half_w, .height = 40.0 }; + const create_rect = Rect{ .x = panel_x + 24.0 + half_w + 12.0, .y = button_y, .width = half_w, .height = 40.0 }; + + if (drawButton(&ui, back_rect, "BACK", 1.9, mouse_x, mouse_y, mouse_clicked)) { + app_state = .home; + seed_focused = false; + } + + const create_clicked = drawButton(&ui, create_rect, "CREATE", 1.9, mouse_x, mouse_y, mouse_clicked); + const create_pressed = input.isKeyPressed(.enter); + + if (create_clicked or create_pressed) { + const seed_value = try resolveSeed(&seed_input, allocator); + if (world) |*active_world| { + active_world.deinit(); + world = null; + } + world = World.init(allocator, 2, seed_value); + app_state = .world; + seed_focused = false; + camera = Camera.init(.{ + .position = Vec3.init(8, 100, 8), + .pitch = -0.3, + .move_speed = 50.0, + }); + log.log.info("World seed: {}", .{seed_value}); + } + }, + .world, .paused => {}, + } + + ui.end(); + } // Swap buffers _ = c.SDL_GL_SwapWindow(window); - // Print stats occasionally - if (time.frame_count % 120 == 0) { - const stats = world.getStats(); - const render_stats = world.getRenderStats(); - std.debug.print("FPS: {d:.1} | Chunks: {}/{} (culled: {}) | Vertices: {} | Pos: ({d:.1}, {d:.1}, {d:.1})\n", .{ - time.fps, - render_stats.chunks_rendered, - stats.chunks_loaded, - render_stats.chunks_culled, - render_stats.vertices_rendered, - camera.position.x, - camera.position.y, - camera.position.z, - }); + if (in_world) { + if (world) |*active_world| { + if (time.frame_count % 120 == 0) { + const stats = active_world.getStats(); + const render_stats = active_world.getRenderStats(); + std.debug.print("FPS: {d:.1} | Chunks: {}/{} (culled: {}) | Vertices: {} | Pos: ({d:.1}, {d:.1}, {d:.1})\n", .{ + time.fps, + render_stats.chunks_rendered, + stats.chunks_loaded, + render_stats.chunks_culled, + render_stats.vertices_rendered, + camera.position.x, + camera.position.y, + camera.position.z, + }); + } + } } } } @@ -302,3 +548,233 @@ fn drawDigit(ui: *UISystem, digit: u4, x: f32, y: f32, color: Color) void { if (seg[5]) ui.drawRect(.{ .x = x + w - t, .y = y + h / 2, .width = t, .height = h / 2 }, color); // bottom-right if (seg[6]) ui.drawRect(.{ .x = x, .y = y + h - t, .width = w, .height = t }, color); // bottom } + +const font_letters = [_][7]u8{ + .{ 0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001 }, // A + .{ 0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110 }, // B + .{ 0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110 }, // C + .{ 0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110 }, // D + .{ 0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111 }, // E + .{ 0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000 }, // F + .{ 0b01110, 0b10001, 0b10000, 0b10000, 0b10011, 0b10001, 0b01110 }, // G + .{ 0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001 }, // H + .{ 0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b11111 }, // I + .{ 0b00001, 0b00001, 0b00001, 0b00001, 0b10001, 0b10001, 0b01110 }, // J + .{ 0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001 }, // K + .{ 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111 }, // L + .{ 0b10001, 0b11011, 0b10101, 0b10001, 0b10001, 0b10001, 0b10001 }, // M + .{ 0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001, 0b10001 }, // N + .{ 0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110 }, // O + .{ 0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000 }, // P + .{ 0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10010, 0b01101 }, // Q + .{ 0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001 }, // R + .{ 0b01111, 0b10000, 0b10000, 0b01110, 0b00001, 0b00001, 0b11110 }, // S + .{ 0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100 }, // T + .{ 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110 }, // U + .{ 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100 }, // V + .{ 0b10001, 0b10001, 0b10001, 0b10001, 0b10101, 0b11011, 0b10001 }, // W + .{ 0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001 }, // X + .{ 0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100 }, // Y + .{ 0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111 }, // Z +}; + +const font_digits = [_][7]u8{ + .{ 0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110 }, // 0 + .{ 0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110 }, // 1 + .{ 0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111 }, // 2 + .{ 0b11110, 0b00001, 0b00001, 0b01110, 0b00001, 0b00001, 0b11110 }, // 3 + .{ 0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010 }, // 4 + .{ 0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110 }, // 5 + .{ 0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110 }, // 6 + .{ 0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000 }, // 7 + .{ 0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110 }, // 8 + .{ 0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100 }, // 9 +}; + +fn glyphForChar(ch: u8) [7]u8 { + if (ch >= 'A' and ch <= 'Z') { + return font_letters[ch - 'A']; + } + if (ch >= '0' and ch <= '9') { + return font_digits[ch - '0']; + } + return switch (ch) { + ' ' => .{ 0, 0, 0, 0, 0, 0, 0 }, + '-' => .{ 0, 0, 0, 0b01110, 0, 0, 0 }, + ':' => .{ 0, 0b00100, 0b00100, 0, 0b00100, 0b00100, 0 }, + '.' => .{ 0, 0, 0, 0, 0, 0b00100, 0b00100 }, + else => .{ 0, 0, 0, 0, 0, 0, 0 }, + }; +} + +fn drawGlyph(ui: *UISystem, glyph: [7]u8, x: f32, y: f32, scale: f32, color: Color) void { + var row: usize = 0; + while (row < 7) : (row += 1) { + const row_bits = glyph[row]; + var col: usize = 0; + while (col < 5) : (col += 1) { + const shift: u3 = @intCast(4 - col); + const mask: u8 = @as(u8, 1) << shift; + if ((row_bits & mask) != 0) { + ui.drawRect(.{ + .x = x + @as(f32, @floatFromInt(col)) * scale, + .y = y + @as(f32, @floatFromInt(row)) * scale, + .width = scale, + .height = scale, + }, color); + } + } + } +} + +fn drawText(ui: *UISystem, text: []const u8, x: f32, y: f32, scale: f32, color: Color) void { + var cursor_x = x; + for (text) |raw| { + var ch = raw; + if (ch >= 'a' and ch <= 'z') { + ch = std.ascii.toUpper(ch); + } + drawGlyph(ui, glyphForChar(ch), cursor_x, y, scale, color); + cursor_x += (5.0 + 1.0) * scale; + } +} + +fn measureTextWidth(text: []const u8, scale: f32) f32 { + if (text.len == 0) return 0; + return @as(f32, @floatFromInt(text.len)) * (5.0 + 1.0) * scale - scale; +} + +fn drawTextCentered(ui: *UISystem, text: []const u8, center_x: f32, y: f32, scale: f32, color: Color) void { + const width = measureTextWidth(text, scale); + drawText(ui, text, center_x - width * 0.5, y, scale, color); +} + +fn drawButton(ui: *UISystem, rect: Rect, label: []const u8, scale: f32, mouse_x: f32, mouse_y: f32, clicked: bool) bool { + const hovered = rect.contains(mouse_x, mouse_y); + const fill = if (hovered) Color.rgba(0.2, 0.26, 0.36, 0.95) else Color.rgba(0.13, 0.17, 0.24, 0.92); + const border = if (hovered) Color.rgba(0.55, 0.7, 0.9, 1.0) else Color.rgba(0.29, 0.35, 0.45, 1.0); + + ui.drawRect(rect, fill); + ui.drawRectOutline(rect, border, 2.0); + + const text_y = rect.y + (rect.height - 7.0 * scale) * 0.5; + drawTextCentered(ui, label, rect.x + rect.width * 0.5, text_y, scale, Color.rgba(0.95, 0.96, 0.98, 1.0)); + + return hovered and clicked; +} + +fn drawTextInput(ui: *UISystem, rect: Rect, text: []const u8, placeholder: []const u8, scale: f32, focused: bool, caret_on: bool) void { + const background = Color.rgba(0.07, 0.09, 0.13, 0.95); + const border = if (focused) Color.rgba(0.5, 0.75, 0.95, 1.0) else Color.rgba(0.25, 0.3, 0.38, 1.0); + + ui.drawRect(rect, background); + ui.drawRectOutline(rect, border, 2.0); + + const padding: f32 = 8.0; + const text_y = rect.y + (rect.height - 7.0 * scale) * 0.5; + if (text.len > 0) { + drawText(ui, text, rect.x + padding, text_y, scale, Color.rgba(0.92, 0.95, 0.98, 1.0)); + } else { + drawText(ui, placeholder, rect.x + padding, text_y, scale, Color.rgba(0.5, 0.56, 0.65, 1.0)); + } + + if (focused and caret_on) { + const caret_x = rect.x + padding + measureTextWidth(text, scale); + ui.drawRect(.{ + .x = caret_x, + .y = rect.y + 8.0, + .width = 2.0, + .height = rect.height - 16.0, + }, Color.rgba(0.9, 0.95, 1.0, 1.0)); + } +} + +fn handleSeedTyping(seed_input: *std.ArrayList(u8), allocator: std.mem.Allocator, input: *const Input, max_len: usize) !void { + if (input.isKeyPressed(.backspace)) { + if (seed_input.items.len > 0) { + _ = seed_input.pop(); + } + } + + const shift = input.isKeyDown(.left_shift) or input.isKeyDown(.right_shift); + + const letters = [_]Key{ + .a, .b, .c, .d, .e, .f, .g, .h, .i, .j, .k, .l, .m, + .n, .o, .p, .q, .r, .s, .t, .u, .v, .w, .x, .y, .z, + }; + + inline for (letters) |key| { + if (input.isKeyPressed(key) and seed_input.items.len < max_len) { + var ch: u8 = @intCast(@intFromEnum(key)); + if (shift) { + ch = std.ascii.toUpper(ch); + } + try seed_input.append(allocator, ch); + } + } + + const digits = [_]Key{ .@"0", .@"1", .@"2", .@"3", .@"4", .@"5", .@"6", .@"7", .@"8", .@"9" }; + inline for (digits) |key| { + if (input.isKeyPressed(key) and seed_input.items.len < max_len) { + const ch: u8 = @intCast(@intFromEnum(key)); + try seed_input.append(allocator, ch); + } + } + + if (input.isKeyPressed(.space) and seed_input.items.len < max_len) { + try seed_input.append(allocator, ' '); + } +} + +fn randomSeedValue() u64 { + const ticks: u64 = @intCast(c.SDL_GetTicks()); + const perf: u64 = @intCast(c.SDL_GetPerformanceCounter()); + var seed = perf ^ (ticks << 32); + seed ^= seed >> 33; + seed *%= 0xff51afd7ed558ccd; + seed ^= seed >> 33; + seed *%= 0xc4ceb9fe1a85ec53; + seed ^= seed >> 33; + return seed; +} + +fn fnv1a64(bytes: []const u8) u64 { + var hash: u64 = 14695981039346656037; + for (bytes) |b| { + hash ^= b; + hash *%= 1099511628211; + } + return hash; +} + +fn seedFromText(text: []const u8) u64 { + var all_digits = true; + for (text) |ch| { + if (ch < '0' or ch > '9') { + all_digits = false; + break; + } + } + + if (all_digits) { + return std.fmt.parseUnsigned(u64, text, 10) catch fnv1a64(text); + } + return fnv1a64(text); +} + +fn resolveSeed(seed_input: *std.ArrayList(u8), allocator: std.mem.Allocator) !u64 { + const trimmed = std.mem.trim(u8, seed_input.items, " \t"); + if (trimmed.len == 0) { + const generated = randomSeedValue(); + try setSeedInput(seed_input, allocator, generated); + return generated; + } + return seedFromText(trimmed); +} + +fn setSeedInput(seed_input: *std.ArrayList(u8), allocator: std.mem.Allocator, seed_value: u64) !void { + var buffer: [32]u8 = undefined; + const written = try std.fmt.bufPrint(&buffer, "{d}", .{seed_value}); + seed_input.clearRetainingCapacity(); + try seed_input.appendSlice(allocator, written); +} diff --git a/src/world/block.zig b/src/world/block.zig index d6ce60ae..9caad7be 100644 --- a/src/world/block.zig +++ b/src/world/block.zig @@ -15,6 +15,11 @@ pub const BlockType = enum(u8) { bedrock = 9, gravel = 10, glass = 11, + snow_block = 12, + cactus = 13, + coal_ore = 14, + iron_ore = 15, + gold_ore = 16, _, @@ -55,6 +60,11 @@ pub const BlockType = enum(u8) { .bedrock => .{ 0.15, 0.15, 0.15 }, .gravel => .{ 0.45, 0.42, 0.4 }, .glass => .{ 0.8, 0.9, 0.95 }, + .snow_block => .{ 0.95, 0.95, 1.0 }, + .cactus => .{ 0.1, 0.6, 0.1 }, + .coal_ore => .{ 0.1, 0.1, 0.1 }, + .iron_ore => .{ 0.6, 0.5, 0.4 }, + .gold_ore => .{ 0.9, 0.8, 0.2 }, _ => .{ 1, 0, 1 }, // Magenta for unknown }; } diff --git a/src/world/chunk_mesh.zig b/src/world/chunk_mesh.zig index 64710eaf..0d386eca 100644 --- a/src/world/chunk_mesh.zig +++ b/src/world/chunk_mesh.zig @@ -3,9 +3,7 @@ //! Supports cross-chunk face culling when neighbor chunk data is provided. const std = @import("std"); -const c = @cImport({ - @cInclude("GL/glew.h"); -}); +const c = @import("../c.zig").c; const Chunk = @import("chunk.zig").Chunk; const CHUNK_SIZE_X = @import("chunk.zig").CHUNK_SIZE_X; diff --git a/src/world/worldgen/generator.zig b/src/world/worldgen/generator.zig index b98a4beb..4f857a53 100644 --- a/src/world/worldgen/generator.zig +++ b/src/world/worldgen/generator.zig @@ -9,17 +9,31 @@ const CHUNK_SIZE_Z = @import("../chunk.zig").CHUNK_SIZE_Z; const BlockType = @import("../block.zig").BlockType; pub const TerrainGenerator = struct { - noise: Noise, + // Noise generators for different layers + continentalness_noise: Noise, + erosion_noise: Noise, + peaks_valleys_noise: Noise, + temperature_noise: Noise, + humidity_noise: Noise, + river_noise: Noise, + cave_noise: Noise, // Terrain parameters - sea_level: u32 = 62, - base_height: u32 = 64, - height_scale: f32 = 32, - noise_scale: f32 = 64, + sea_level: i32 = 64, pub fn init(seed: u64) TerrainGenerator { + // Derive seeds for different layers to ensure they are independent + var prng = std.Random.DefaultPrng.init(seed); + const random = prng.random(); + return .{ - .noise = Noise.init(seed), + .continentalness_noise = Noise.init(random.int(u64)), + .erosion_noise = Noise.init(random.int(u64)), + .peaks_valleys_noise = Noise.init(random.int(u64)), + .temperature_noise = Noise.init(random.int(u64)), + .humidity_noise = Noise.init(random.int(u64)), + .river_noise = Noise.init(random.int(u64)), + .cave_noise = Noise.init(random.int(u64)), }; } @@ -35,43 +49,317 @@ pub const TerrainGenerator = struct { const wx: f32 = @floatFromInt(world_x + @as(i32, @intCast(local_x))); const wz: f32 = @floatFromInt(world_z + @as(i32, @intCast(local_z))); - // Get height from noise - const height_noise = self.noise.getHeight(wx, wz, self.noise_scale); - const terrain_height: u32 = @intFromFloat(@as(f32, @floatFromInt(self.base_height)) + height_noise * self.height_scale); + // 1. Compute Global Maps + const continentalness = self.getContinentalness(wx, wz); + const erosion = self.getErosion(wx, wz); + const peaks_valleys = self.getPeaksValleys(wx, wz); + const river_val = self.getRiverValue(wx, wz); + + // 2. Compute Base Height + var height_val = self.computeHeight(continentalness, erosion, peaks_valleys); + + // River Carving + if (river_val < 0.05) { // River threshold + // Carve down to slightly below sea level or smooth it out + // Normalize river value 0..0.05 to 0..1 for depth blending + const t = river_val / 0.05; + const river_bed = @as(f32, @floatFromInt(self.sea_level - 2)); + height_val = std.math.lerp(river_bed, height_val, t * t); // Quadratic ease-out for banks + } + + const terrain_height: i32 = @intFromFloat(height_val); + + // 3. Biome info (for surface blocks) + const temperature = self.temperature_noise.fbm2D(wx, wz, 2, 2.0, 0.5, 0.002); + const humidity = self.humidity_noise.fbm2D(wx, wz, 2, 2.0, 0.5, 0.002); // Fill column - var y: u32 = 0; + var y: i32 = 0; while (y < CHUNK_SIZE_Y) : (y += 1) { - const block = self.getBlockAt(local_x, y, local_z, terrain_height); - chunk.setBlock(local_x, y, local_z, block); + var block = self.getBlockAt(y, terrain_height, continentalness, temperature, humidity); + + // Cave carving + if (block != .air and block != .water and block != .bedrock) { + const wy: f32 = @floatFromInt(y); + // 3D noise for caves. Scale 0.04 seems reasonable for "cheese" caves + const cave_val = self.cave_noise.perlin3D(wx * 0.04, wy * 0.04, wz * 0.04); + if (cave_val > 0.4) { + block = .air; + } + } + + chunk.setBlock(local_x, @intCast(y), local_z, block); } } } chunk.generated = true; + + // 4. Ores + self.generateOres(chunk); + + // 5. Decorate (Trees, Cacti, etc.) + self.generateFeatures(chunk); + chunk.dirty = true; } - fn getBlockAt(self: *const TerrainGenerator, x: u32, y: u32, z: u32, terrain_height: u32) BlockType { - _ = x; - _ = z; + fn generateOres(self: *const TerrainGenerator, chunk: *Chunk) void { + // Seed based on chunk and salt + var prng = std.Random.DefaultPrng.init(self.erosion_noise.seed +% @as(u64, @bitCast(@as(i64, chunk.chunk_x))) *% 59381 +% @as(u64, @bitCast(@as(i64, chunk.chunk_z))) *% 28411); + const random = prng.random(); - if (y == 0) { - return .bedrock; - } else if (y < terrain_height - 4) { - return .stone; - } else if (y < terrain_height) { - return .dirt; - } else if (y == terrain_height) { - if (y < self.sea_level) { - return .sand; // Beach/underwater - } else { - return .grass; + self.placeOreVeins(chunk, .coal_ore, 20, 6, 10, 128, random); + self.placeOreVeins(chunk, .iron_ore, 10, 4, 5, 64, random); + self.placeOreVeins(chunk, .gold_ore, 3, 3, 2, 32, random); + } + + fn placeOreVeins(self: *const TerrainGenerator, chunk: *Chunk, block: BlockType, count: u32, size: u32, min_y: i32, max_y: i32, random: std.Random) void { + _ = self; + for (0..count) |_| { + const cx = random.uintLessThan(u32, CHUNK_SIZE_X); + const cz = random.uintLessThan(u32, CHUNK_SIZE_Z); + const range = max_y - min_y; + if (range <= 0) continue; + const cy = min_y + @as(i32, @intCast(random.uintLessThan(u32, @intCast(range)))); + + // Simple blob vein + const vein_size = random.uintLessThan(u32, size) + 2; + + var i: u32 = 0; + while (i < vein_size) : (i += 1) { + const ox = @as(i32, @intCast(random.uintLessThan(u32, 4))) - 2; + const oy = @as(i32, @intCast(random.uintLessThan(u32, 4))) - 2; + const oz = @as(i32, @intCast(random.uintLessThan(u32, 4))) - 2; + + const tx = @as(i32, @intCast(cx)) + ox; + const ty = cy + oy; + const tz = @as(i32, @intCast(cz)) + oz; + + if (chunk.getBlockSafe(tx, ty, tz) == .stone) { + chunk.setBlock(@intCast(tx), @intCast(ty), @intCast(tz), block); + } + } + } + } + + fn generateFeatures(self: *const TerrainGenerator, chunk: *Chunk) void { + var prng = std.Random.DefaultPrng.init(self.continentalness_noise.seed ^ @as(u64, @bitCast(@as(i64, chunk.chunk_x))) ^ (@as(u64, @bitCast(@as(i64, chunk.chunk_z))) << 32)); + const random = prng.random(); + + // Attempt to place features + + // Oases (Rare, Desert only) + if (random.float(f32) < 0.02) { + const wx = @as(f32, @floatFromInt(chunk.getWorldX() + 8)); + const wz = @as(f32, @floatFromInt(chunk.getWorldZ() + 8)); + const temp = self.temperature_noise.fbm2D(wx, wz, 2, 2.0, 0.5, 0.002); + const humidity = self.humidity_noise.fbm2D(wx, wz, 2, 2.0, 0.5, 0.002); + + if (temp > 0.5 and humidity < -0.2) { + self.placeOasis(chunk, 8, 8); + } + } + + // Simple approach: try N times + const attempts = 10; + for (0..attempts) |_| { + const lx = random.uintLessThan(u32, CHUNK_SIZE_X); + const lz = random.uintLessThan(u32, CHUNK_SIZE_Z); + + // Find surface y + var y: i32 = CHUNK_SIZE_Y - 1; + while (y > 0) : (y -= 1) { + if (chunk.getBlock(lx, @intCast(y), lz) != .air) break; + } + + const surface_block = chunk.getBlock(lx, @intCast(y), lz); + + // Tree placement (on grass) + if (surface_block == .grass) { + if (random.float(f32) < 0.05) { // 5% chance per attempt on grass + self.placeTree(chunk, lx, @intCast(y + 1), lz, random); + } + } + // Cactus placement (on sand) + else if (surface_block == .sand) { + if (random.float(f32) < 0.02) { // 2% chance on sand + self.placeCactus(chunk, lx, @intCast(y + 1), lz, random); + } + } + } + } + + fn placeOasis(self: *const TerrainGenerator, chunk: *Chunk, cx: u32, cz: u32) void { + _ = self; + var cy: i32 = CHUNK_SIZE_Y - 1; + while (cy > 0) : (cy -= 1) { + if (chunk.getBlock(cx, @intCast(cy), cz) != .air) break; + } + + const radius = 6; + var z: i32 = -radius; + while (z <= radius) : (z += 1) { + var x: i32 = -radius; + while (x <= radius) : (x += 1) { + const dist = x * x + z * z; + if (dist < radius * radius) { + const tx = @as(i32, @intCast(cx)) + x; + const tz = @as(i32, @intCast(cz)) + z; + + if (tx >= 0 and tx < CHUNK_SIZE_X and tz >= 0 and tz < CHUNK_SIZE_Z) { + // Water pool + chunk.setBlock(@intCast(tx), @intCast(cy), @intCast(tz), .water); + if (cy > 0) chunk.setBlock(@intCast(tx), @intCast(cy - 1), @intCast(tz), .water); + if (cy > 1) chunk.setBlock(@intCast(tx), @intCast(cy - 2), @intCast(tz), .sand); + + // Palm trees around edge + if (dist > (radius - 3) * (radius - 3) and @mod(x + z, 4) == 0) { + if (cy + 4 < CHUNK_SIZE_Y) { + chunk.setBlock(@intCast(tx), @intCast(cy + 1), @intCast(tz), .wood); + chunk.setBlock(@intCast(tx), @intCast(cy + 2), @intCast(tz), .wood); + chunk.setBlock(@intCast(tx), @intCast(cy + 3), @intCast(tz), .wood); + chunk.setBlock(@intCast(tx), @intCast(cy + 4), @intCast(tz), .leaves); + } + } + } + } } - } else if (y <= self.sea_level) { - return .water; + } + } + + fn placeTree(self: *const TerrainGenerator, chunk: *Chunk, x: u32, y: u32, z: u32, random: std.Random) void { + _ = self; + const height = 4 + random.uintLessThan(u32, 3); + + // Trunk + for (0..height) |i| { + const ty = y + @as(u32, @intCast(i)); + if (ty < CHUNK_SIZE_Y) { + chunk.setBlock(x, ty, z, .wood); + } + } + + // Leaves (very simple blob) + const leaf_start = y + height - 2; + const leaf_end = y + height + 1; + + var ly: u32 = leaf_start; + while (ly <= leaf_end) : (ly += 1) { + const range: i32 = if (ly == leaf_end) 1 else 2; + var lz: i32 = -range; + while (lz <= range) : (lz += 1) { + var lx: i32 = -range; + while (lx <= range) : (lx += 1) { + // Don't replace trunk + if (lx == 0 and lz == 0 and ly < y + height) continue; + + // Simple distance check for roundness + if (lx * lx + lz * lz <= range * range + 1) { + const target_x = @as(i32, @intCast(x)) + lx; + const target_z = @as(i32, @intCast(z)) + lz; + + // Check bounds (simple v1: only place if inside chunk) + if (target_x >= 0 and target_x < CHUNK_SIZE_X and + target_z >= 0 and target_z < CHUNK_SIZE_Z and + ly < CHUNK_SIZE_Y) + { + if (chunk.getBlock(@intCast(target_x), ly, @intCast(target_z)) == .air) { + chunk.setBlock(@intCast(target_x), ly, @intCast(target_z), .leaves); + } + } + } + } + } + } + } + + fn placeCactus(self: *const TerrainGenerator, chunk: *Chunk, x: u32, y: u32, z: u32, random: std.Random) void { + _ = self; + const height = 2 + random.uintLessThan(u32, 3); + for (0..height) |i| { + const cy = y + @as(u32, @intCast(i)); + if (cy < CHUNK_SIZE_Y) { + chunk.setBlock(x, cy, z, .cactus); + } + } + } + + fn getContinentalness(self: *const TerrainGenerator, x: f32, z: f32) f32 { + // Large scale features: 0.002 frequency + return self.continentalness_noise.fbm2D(x, z, 3, 2.0, 0.5, 0.002); + } + + fn getErosion(self: *const TerrainGenerator, x: f32, z: f32) f32 { + return self.erosion_noise.fbm2D(x, z, 3, 2.0, 0.5, 0.003); + } + + fn getPeaksValleys(self: *const TerrainGenerator, x: f32, z: f32) f32 { + return self.peaks_valleys_noise.fbm2D(x, z, 4, 2.0, 0.5, 0.008); + } + + fn getRiverValue(self: *const TerrainGenerator, x: f32, z: f32) f32 { + // Rivers are low frequency, winding. We use abs(noise) close to 0. + // Frequency: 0.001 (very large scale) + const val = self.river_noise.fbm2D(x, z, 4, 2.0, 0.5, 0.0015); + return @abs(val); + } + + fn computeHeight(self: *const TerrainGenerator, c: f32, e: f32, pv: f32) f32 { + _ = e; // Erosion could smooth things out later + + // Base height from continentalness + // c in [-1, 1] + // -1.0 .. -0.2 => Deep Ocean / Ocean + // -0.2 .. 0.0 => Coast + // 0.0 .. 1.0 => Land / Mountains + + var base_height: f32 = @floatFromInt(self.sea_level); + + if (c < -0.3) { + // Deep Ocean + base_height += c * 30.0; + } else if (c < 0.1) { + // Ocean/Beach transition + base_height += c * 10.0; } else { + // Land + base_height += c * 50.0; + + // Add peaks and valleys on land + base_height += pv * 20.0; + } + + return base_height; + } + + fn getBlockAt(self: *const TerrainGenerator, y: i32, terrain_height: i32, continentalness: f32, temp: f32, humidity: f32) BlockType { + if (y == 0) return .bedrock; + + if (y > terrain_height) { + if (y <= self.sea_level) return .water; return .air; } + + // Surface blocks + if (y == terrain_height) { + if (y <= self.sea_level + 1 and continentalness < 0.15) { + return .sand; // Beach + } + if (temp < -0.3) return .snow_block; // Cold biome + if (temp > 0.5 and humidity < -0.2) return .sand; // Desert + return .grass; + } + + // Subsurface + if (y > terrain_height - 4) { + if (y <= self.sea_level + 1 and continentalness < 0.15) { + return .sand; + } + if (temp > 0.5 and humidity < -0.2) return .sand; // Desert sand depth + return .dirt; + } + + return .stone; } }; diff --git a/src/world/worldgen/noise.zig b/src/world/worldgen/noise.zig index 750791d8..40984f55 100644 --- a/src/world/worldgen/noise.zig +++ b/src/world/worldgen/noise.zig @@ -72,18 +72,60 @@ pub const Noise = struct { return lerp(x1, x2, v); } + /// 3D Perlin noise, returns value in range [-1, 1] + pub fn perlin3D(self: *const Noise, x: f32, y: f32, z: f32) f32 { + const xi: i32 = @intFromFloat(@floor(x)); + const yi: i32 = @intFromFloat(@floor(y)); + const zi: i32 = @intFromFloat(@floor(z)); + + const xf = x - @floor(x); + const yf = y - @floor(y); + const zf = z - @floor(z); + + const u = fade(xf); + const v = fade(yf); + const w = fade(zf); + + const a = self.perm[@intCast(@mod(xi, 256))] + @as(usize, @intCast(@mod(yi, 256))); + const aa = self.perm[@intCast(@mod(a, 256))] + @as(usize, @intCast(@mod(zi, 256))); + const ab = self.perm[@intCast(@mod(a + 1, 256))] + @as(usize, @intCast(@mod(zi, 256))); + const b = self.perm[@intCast(@mod(xi + 1, 256))] + @as(usize, @intCast(@mod(yi, 256))); + const ba = self.perm[@intCast(@mod(b, 256))] + @as(usize, @intCast(@mod(zi, 256))); + const bb = self.perm[@intCast(@mod(b + 1, 256))] + @as(usize, @intCast(@mod(zi, 256))); + + // Gradients + const g1 = grad3D(self.perm[@intCast(@mod(aa, 256))], xf, yf, zf); + const g2 = grad3D(self.perm[@intCast(@mod(ba, 256))], xf - 1, yf, zf); + const g3 = grad3D(self.perm[@intCast(@mod(ab, 256))], xf, yf - 1, zf); + const g4 = grad3D(self.perm[@intCast(@mod(bb, 256))], xf - 1, yf - 1, zf); + const g5 = grad3D(self.perm[@intCast(@mod(aa + 1, 256))], xf, yf, zf - 1); + const g6 = grad3D(self.perm[@intCast(@mod(ba + 1, 256))], xf - 1, yf, zf - 1); + const g7 = grad3D(self.perm[@intCast(@mod(ab + 1, 256))], xf, yf - 1, zf - 1); + const g8 = grad3D(self.perm[@intCast(@mod(bb + 1, 256))], xf - 1, yf - 1, zf - 1); + + const x1 = lerp(g1, g2, u); + const x2 = lerp(g3, g4, u); + const y1 = lerp(x1, x2, v); + + const x3 = lerp(g5, g6, u); + const x4 = lerp(g7, g8, u); + const y2 = lerp(x3, x4, v); + + return lerp(y1, y2, w); + } + /// Fractal Brownian Motion - multiple octaves of noise - pub fn fbm2D(self: *const Noise, x: f32, y: f32, octaves: u32, lacunarity: f32, persistence: f32) f32 { + pub fn fbm2D(self: *const Noise, x: f32, y: f32, octaves: u32, lacunarity: f32, persistence: f32, frequency: f32) f32 { var total: f32 = 0; - var frequency: f32 = 1; + var current_frequency: f32 = frequency; var amplitude: f32 = 1; var max_value: f32 = 0; for (0..octaves) |_| { - total += self.perlin2D(x * frequency, y * frequency) * amplitude; + total += self.perlin2D(x * current_frequency, y * current_frequency) * amplitude; max_value += amplitude; amplitude *= persistence; - frequency *= lacunarity; + current_frequency *= lacunarity; } return total / max_value; @@ -91,7 +133,7 @@ pub const Noise = struct { /// Get height value normalized to 0-1 range pub fn getHeight(self: *const Noise, x: f32, z: f32, scale: f32) f32 { - const noise_val = self.fbm2D(x / scale, z / scale, 4, 2.0, 0.5); + const noise_val = self.fbm2D(x, z, 4, 2.0, 0.5, 1.0 / scale); return (noise_val + 1.0) * 0.5; // Convert from [-1,1] to [0,1] } }; @@ -115,3 +157,11 @@ fn grad2D(hash: u8, x: f32, y: f32) f32 { else => unreachable, }; } + +fn grad3D(hash: u8, x: f32, y: f32, z: f32) f32 { + // Convert low 4 bits of hash code into 12 gradient directions + const h = hash & 15; + const u = if (h < 8) x else y; + const v = if (h < 4) y else if (h == 12 or h == 14) x else z; + return (if ((h & 1) == 0) u else -u) + (if ((h & 2) == 0) v else -v); +} From 910d23eb2e59fd84fad43e98414289e114dc308d Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sat, 20 Dec 2025 21:09:06 +0000 Subject: [PATCH 09/10] feat: implement asynchronous chunk streaming (Roadmap v3) --- ROADMAPv3.md | 324 +++++++++++++++++++++++++++++++++ src/engine/core/job_system.zig | 118 ++++++++++++ src/main.zig | 101 ++++++---- src/world/chunk.zig | 22 +++ src/world/chunk_mesh.zig | 38 +++- src/world/world.zig | 303 +++++++++++++++++++++++------- 6 files changed, 797 insertions(+), 109 deletions(-) create mode 100644 ROADMAPv3.md create mode 100644 src/engine/core/job_system.zig diff --git a/ROADMAPv3.md b/ROADMAPv3.md new file mode 100644 index 00000000..fab5d0ec --- /dev/null +++ b/ROADMAPv3.md @@ -0,0 +1,324 @@ +# Chunk Streaming Spec: Loading, Meshing, Rendering, and Unloading (Smooth View Distance) + +This document specifies a **chunk streaming system** for a voxel engine that: +- Loads chunks around the player smoothly based on **view distance** and **settings** +- Generates + meshes chunks asynchronously +- Prioritizes nearby chunks +- Unloads far chunks safely +- Avoids frame spikes via budgets and staged pipelines + +--- + +## 1) Goals + +- Smooth gameplay while moving: no long stalls. +- Deterministic chunk generation (seeded). +- Configurable: + - `viewDistanceChunks` (radius in chunks) + - `maxLoadedChunks` (memory cap) + - `meshDistanceChunks` (optional separate radius for rendering meshes) + - per-frame budgets (generation, meshing, uploads) +- Correctness: + - No rendering holes caused by missing neighbors (or handled gracefully). + - Unloading never races with jobs still using chunk data. + +--- + +## 2) Terminology & Definitions + +- **Chunk coords**: `(cx, cz)` in 2D, optional `(cy)` if vertical chunking. +- **Chunk size**: e.g. `16x16x256`. +- **World position to chunk**: + - `cx = floor(x / CHUNK_SIZE_X)` + - `cz = floor(z / CHUNK_SIZE_Z)` +- **Chunk radius**: + - view distance radius `R = viewDistanceChunks` + - region of interest = all chunks with `dx*dx + dz*dz <= R*R` (circle) OR square if simpler. +- **Load distance** vs **render distance**: + - `loadDistance` determines which chunks must exist in memory. + - `meshDistance` determines which chunks must have a mesh uploaded and rendered. + - Often: `meshDistance <= loadDistance` for perf. + +--- + +## 3) Chunk States and Lifecycle + +### 3.1 Chunk State Machine +A chunk should progress through explicit states: + +- `Missing` (not in memory) +- `QueuedForLoad` +- `LoadingFromDisk` +- `Generating` (procedural) +- `Generated` (blocks available) +- `QueuedForMesh` +- `Meshing` (CPU mesh build) +- `MeshReadyCPU` +- `UploadingGPU` +- `Renderable` (GPU buffers ready) +- `Unloading` (release resources) +- `Unloaded` (removed from map) + +### 3.2 Chunk Object Contents +Store: +- coords `(cx, cz[, cy])` +- block storage pointer / compressed array +- flags: + - `dirtyBlocks` (needs remesh) + - `needsNeighborRemesh` (when neighbors arrive) +- mesh handles: + - opaque mesh GPU buffers + - transparent mesh GPU buffers (optional) +- job handles / refcounts: + - `generationJobId` + - `meshJobId` +- last used timestamp (for LRU unloading) +- `pinCount` (prevent unloading while referenced) + +--- + +## 4) Settings + +### 4.1 User Settings +- `viewDistanceChunks` (int) + Example defaults: 8–12 +- `loadDistanceChunks` (int) + Usually `viewDistance + 2` (preload ring) +- `meshDistanceChunks` (int) + Usually equal to viewDistance; can be smaller. +- `maxLoadedChunks` (int) + Hard cap to avoid memory blowups, e.g. 2048 +- `maxMeshedChunks` (int) + Cap how many chunks may keep GPU meshes (optional) +- `chunkUploadBudgetPerFrame` (int) + e.g. 1–4 chunk meshes per frame +- `meshBuildBudgetPerFrameMs` (float) + e.g. 2–6 ms (or N tasks) +- `generationBudgetPerFrameMs` (float) +- `threads_generation` / `threads_meshing` + +### 4.2 Derived Distances +- `preloadRadius = loadDistanceChunks` +- `renderRadius = meshDistanceChunks` +- `keepAliveRadius = preloadRadius + 1` (optional ring to prevent thrash) + +--- + +## 5) Core Streaming Algorithm + +### 5.1 High-level Update Loop (per frame) +Inputs: +- player position +- camera view (optional frustum) +- settings + +Steps: +1. Determine `playerChunk = (pcx, pcz)`. +2. Build the **target set** of chunks to load (within `preloadRadius`). +3. Build the **target set** of chunks to mesh/render (within `renderRadius`). +4. Enqueue missing chunks for load/generation. +5. Prioritize and run jobs within budgets: + - disk load/generate tasks + - mesh build tasks + - GPU uploads +6. Unload chunks outside `keepAliveRadius` and/or past caps. + +### 5.2 Target Set Computation +Prefer circle (less total chunks than square for same radius): + +For `dx in [-R..R]`, `dz in [-R..R]`: +- if `dx*dx + dz*dz <= R*R`, include `(pcx+dx, pcz+dz)`. + +Optionally order by distance for priority queue. + +### 5.3 Prioritization +Use priority key: +1. smaller `dist2` first +2. within camera forward cone first (optional) +3. within frustum first (optional) + +This ensures nearby chunks appear first. + +--- + +## 6) Asynchronous Pipeline (Jobs) + +### 6.1 Worker Threads +Recommended separation: +- **Generation thread pool**: noise + block fill (CPU heavy) +- **Meshing thread pool**: greedy meshing/culled meshing (CPU heavy) +- **Main thread**: OpenGL calls only (upload buffers, create VAOs, etc.) + +### 6.2 Job Types +- `Job_LoadOrGenerateChunk(cx,cz)` + - if chunk exists on disk -> load + - else -> generate deterministically + - output: block data + metadata +- `Job_BuildChunkMesh(cx,cz)` + - needs chunk + neighbors (at least for face culling) + - output: CPU vertex/index buffers (opaque & transparent) +- `Job_UploadChunkMesh(cx,cz)` (main thread) + - create/update VBO/IBO/VAO + - swap mesh handles atomically + +### 6.3 Neighbor Dependency +Meshing typically needs neighbor blocks to cull faces at boundaries. +Options: + +**Option A (strict)**: only mesh when all 4 neighbors exist (N/E/S/W) (and vertical neighbors if applicable). +- Pros: no seams / no missing faces. +- Cons: slower visible appearance. + +**Option B (optimistic)**: mesh immediately with whatever neighbors exist; when a missing neighbor arrives, mark edges dirty and remesh. +- Pros: chunks appear quickly. +- Cons: extra remesh work. + +Recommended for smoothness: **Option B**. + +Implementation detail: +- Meshing treats missing neighbor as "air" for boundary culling. +- When neighbor loads, both chunks mark `dirtyBlocks=true` for boundary remesh. + +--- + +## 7) Smoothness Budgets (Avoid Frame Spikes) + +### 7.1 Budgets to Apply +Per frame, cap: +- number of generation completions applied +- number of mesh builds started / completed +- number of GPU uploads + +Suggested defaults: +- generate: up to 1–2 chunks/frame (or 2–4ms) +- mesh build: up to 1–2 chunks/frame (or 2–6ms) +- upload: up to 1 chunk/frame (more if small meshes) + +### 7.2 Work Queues +Maintain queues: +- `genQueue`: prioritized by dist2 +- `meshQueue`: prioritized by dist2 (and only if generated) +- `uploadQueue`: FIFO or prioritized by dist2 + +Each queue holds chunk coords + priority. Use a heap. + +--- + +## 8) Caching & Unloading + +### 8.1 Unload Rules +A chunk is a candidate for unloading if: +- outside `keepAliveRadius` +- not pinned (`pinCount==0`) +- no active jobs (or jobs can be canceled safely) +- not in a “grace period” (optional) + +### 8.2 LRU / Memory Cap +Maintain: +- `loadedChunksCount` +- if `loadedChunksCount > maxLoadedChunks`: + - unload farthest or least-recently-used chunks first (prefer farthest). + +### 8.3 Safe Unload with Jobs +You need job-safe ownership: +- chunks have a `generationVersion` or `jobToken`. +- when a job is queued, it captures the token. +- if the chunk is unloaded/recycled, token changes, job result is discarded. + +This prevents writing results into freed memory. + +--- + +## 9) Rendering Integration + +### 9.1 Render List +Each frame: +- build a list of chunks in `Renderable` state within `renderRadius`. +Optional: +- frustum cull chunk AABBs. +- sort by distance for transparency pass. + +### 9.2 Opaque vs Transparent Pass +Recommended: +- Render opaque chunk meshes front-to-back (better depth rejection). +- Render transparent chunk meshes back-to-front. + +### 9.3 Chunk Boundary Pop-in Mitigation +Techniques: +- Preload ring: `loadDistance = viewDistance + 2` +- Mesh ring: build mesh slightly beyond viewDistance (optional) +- Fade-in (advanced): per-chunk alpha ramp after upload (requires shader support) + +--- + +## 10) Disk IO (Optional v1, but recommended) + +### 10.1 Save Strategy +- Save modified chunks asynchronously. +- Use a region file system (like Minecraft) or per-chunk files: + - `chunks/cx_cz.bin` +- On load: + - schedule disk read; if missing -> generate. + +### 10.2 Throttling Disk +- Limit concurrent IO tasks. +- Avoid blocking the main thread. + +--- + +## 11) Debug/Developer Tools + +- [ ] Show current `(cx,cz)` in HUD +- [ ] Show loaded chunk count +- [ ] Show queued gen/mesh/upload counts +- [ ] Render chunk borders (wireframe) +- [ ] Toggle viewDistance live (rebuild target set) +- [ ] Visualize “priority rings” (optional) + +--- + +## 12) Suggested Data Structures + +### 12.1 Chunk Map +- `unordered_map loadedChunks` +- `ChunkKey` packs `(cx,cz[,cy])` into 64-bit key. + +### 12.2 Priority Queues +- `genQueue: min-heap by dist2` +- `meshQueue: min-heap by dist2` +- `uploadQueue: queue/heap` + +### 12.3 State Tracking +- Bitsets or flags for: + - inTargetLoadSet + - inTargetMeshSet + - queuedForGen + - queuedForMesh + +--- + +## 13) Acceptance Criteria (v1) + +- Moving quickly across terrain does not freeze the game. +- Chunks load nearest-first, then outward. +- View distance is respected: + - beyond `viewDistanceChunks`, chunks do not render +- Changing view distance in settings smoothly updates loaded/meshed sets. +- Chunks outside keepAlive/unload radius are eventually unloaded. +- No crashes or corruption when unloading while jobs are running. + +--- + +## 14) Implementation Order (Recommended) + +1. Chunk coordinate conversion + target set +2. Chunk state machine + chunk map +3. Generation job queue + worker threads + apply results +4. Meshing job queue + apply CPU meshes +5. GPU upload queue + per-frame upload budget +6. Unloading + LRU + safe job token discard +7. Frustum culling + opaque/transparent passes +8. Debug overlay + live settings changes + +--- + diff --git a/src/engine/core/job_system.zig b/src/engine/core/job_system.zig new file mode 100644 index 00000000..0d0b473c --- /dev/null +++ b/src/engine/core/job_system.zig @@ -0,0 +1,118 @@ +//! Job system for asynchronous chunk operations. + +const std = @import("std"); +const Thread = std.Thread; +const Mutex = Thread.Mutex; +const Condition = Thread.Condition; +const Chunk = @import("../../world/chunk.zig").Chunk; + +pub const JobType = enum { + generation, + meshing, +}; + +pub const Job = struct { + type: JobType, + chunk_x: i32, + chunk_z: i32, + job_token: u32, + dist_sq: i32, // Priority: closer is smaller + + // Comparison for min-heap (lower dist = higher priority) + pub fn compare(a: Job, b: Job) std.math.Order { + return std.math.order(a.dist_sq, b.dist_sq); + } +}; + +pub const JobQueue = struct { + mutex: Mutex, + cond: Condition, + jobs: std.PriorityQueue(Job, void, compareJobs), + stopped: bool, + + fn compareJobs(context: void, a: Job, b: Job) std.math.Order { + _ = context; + return a.compare(b); + } + + pub fn init(allocator: std.mem.Allocator) JobQueue { + return .{ + .mutex = Mutex{}, + .cond = Condition{}, + .jobs = std.PriorityQueue(Job, void, compareJobs).init(allocator, {}), + .stopped = false, + }; + } + + pub fn deinit(self: *JobQueue) void { + self.jobs.deinit(); + } + + pub fn push(self: *JobQueue, job: Job) !void { + self.mutex.lock(); + defer self.mutex.unlock(); + try self.jobs.add(job); + self.cond.signal(); + } + + pub fn pop(self: *JobQueue) ?Job { + self.mutex.lock(); + defer self.mutex.unlock(); + + while (self.jobs.count() == 0 and !self.stopped) { + self.cond.wait(&self.mutex); + } + + if (self.stopped and self.jobs.count() == 0) return null; + return self.jobs.removeOrNull(); + } + + pub fn stop(self: *JobQueue) void { + self.mutex.lock(); + self.stopped = true; + self.mutex.unlock(); + self.cond.broadcast(); + } +}; + +pub const WorkerPool = struct { + threads: []Thread, + allocator: std.mem.Allocator, + context: *anyopaque, + + // Callbacks + process_job_fn: *const fn (*anyopaque, Job) void, + + pub fn init(allocator: std.mem.Allocator, count: usize, queue: *JobQueue, context: *anyopaque, process_fn: *const fn (*anyopaque, Job) void) !*WorkerPool { + const pool = try allocator.create(WorkerPool); + const threads = try allocator.alloc(Thread, count); + + pool.* = WorkerPool{ + .threads = threads, + .allocator = allocator, + .context = context, + .process_job_fn = process_fn, + }; + + for (threads) |*t| { + t.* = try Thread.spawn(.{}, workerThread, .{ queue, pool }); + } + + return pool; + } + + pub fn deinit(self: *WorkerPool) void { + for (self.threads) |t| { + t.join(); + } + self.allocator.free(self.threads); + self.allocator.destroy(self); + } + + fn workerThread(queue: *JobQueue, pool: *WorkerPool) void { + while (true) { + const job = queue.pop() orelse break; + pool.process_job_fn(pool.context, job); + } + } +}; diff --git a/src/main.zig b/src/main.zig index 4683d709..89616db2 100644 --- a/src/main.zig +++ b/src/main.zig @@ -18,6 +18,7 @@ const TextureAtlas = @import("engine/graphics/texture_atlas.zig").TextureAtlas; // World imports const World = @import("world/world.zig").World; +const worldToChunk = @import("world/chunk.zig").worldToChunk; // C imports const c = @import("c.zig").c; @@ -74,7 +75,7 @@ const AppState = enum { }; const Settings = struct { - render_distance: i32 = 2, + render_distance: i32 = 15, mouse_sensitivity: f32 = 50.0, vsync: bool = true, fov: f32 = 45.0, @@ -161,8 +162,8 @@ pub fn main() !void { defer seed_input.deinit(allocator); var seed_focused = false; - var world: ?World = null; - defer if (world) |*active_world| active_world.deinit(); + var world: ?*World = null; + defer if (world) |active_world| active_world.deinit(); // Initial viewport renderer.setViewport(1280, 720); @@ -182,11 +183,6 @@ pub fn main() !void { input.beginFrame(); input.pollEvents(); - // Handle escape to quit - if (input.isKeyPressed(.escape)) { - input.should_quit = true; - } - // Handle window resize renderer.setViewport(input.window_width, input.window_height); ui.resize(input.window_width, input.window_height); @@ -198,6 +194,26 @@ pub fn main() !void { const mouse_y: f32 = @floatFromInt(mouse_pos.y); const mouse_clicked = input.isMouseButtonPressed(.left); + // Global Escape Handling + if (input.isKeyPressed(.escape)) { + switch (app_state) { + .home => input.should_quit = true, + .singleplayer => { + app_state = .home; + seed_focused = false; + }, + .settings => app_state = last_state, + .world => { + app_state = .paused; + input.setMouseCapture(window, false); + }, + .paused => { + app_state = .world; + input.setMouseCapture(window, true); + }, + } + } + const in_world = app_state == .world; const in_pause = app_state == .paused; @@ -207,17 +223,6 @@ pub fn main() !void { input.setMouseCapture(window, !input.mouse_captured); } - // Pause toggle with Escape - if (input.isKeyPressed(.escape)) { - if (in_world) { - app_state = .paused; - input.setMouseCapture(window, false); - } else if (in_pause) { - app_state = .world; - input.setMouseCapture(window, true); - } - } - // Toggle wireframe with F if (input.isKeyPressed(.f)) { renderer.toggleWireframe(); @@ -241,7 +246,7 @@ pub fn main() !void { camera.update(&input, time.delta_time); } - if (world) |*active_world| { + if (world) |active_world| { // Update world (load chunks around player) active_world.render_distance = settings.render_distance; try active_world.update(camera.position); @@ -258,7 +263,7 @@ pub fn main() !void { renderer.beginFrame(); if (in_world or in_pause) { - if (world) |*active_world| { + if (world) |active_world| { // Calculate matrices const aspect = screen_w / screen_h; // TODO: Update camera FOV with settings.fov @@ -277,6 +282,32 @@ pub fn main() !void { ui.drawRect(.{ .x = 10, .y = 10, .width = 80, .height = 30 }, Color.rgba(0, 0, 0, 0.7)); drawNumber(&ui, @intFromFloat(time.fps), 15, 15, Color.white); + // Streaming HUD + const stats = active_world.getStats(); + const rs = active_world.getRenderStats(); + const player_chunk = worldToChunk(@intFromFloat(camera.position.x), @intFromFloat(camera.position.z)); + const hud_y: f32 = 50.0; + ui.drawRect(.{ .x = 10, .y = hud_y, .width = 220, .height = 130 }, Color.rgba(0, 0, 0, 0.6)); + + drawText(&ui, "POS:", 15, hud_y + 5, 1.5, Color.white); + drawNumber(&ui, player_chunk.chunk_x, 120, hud_y + 5, Color.white); + drawNumber(&ui, player_chunk.chunk_z, 170, hud_y + 5, Color.white); + + drawText(&ui, "CHUNKS:", 15, hud_y + 25, 1.5, Color.white); + drawNumber(&ui, @intCast(stats.chunks_loaded), 140, hud_y + 25, Color.white); + + drawText(&ui, "VISIBLE:", 15, hud_y + 45, 1.5, Color.white); + drawNumber(&ui, @intCast(rs.chunks_rendered), 140, hud_y + 45, Color.white); + + drawText(&ui, "QUEUED GEN:", 15, hud_y + 65, 1.5, Color.white); + drawNumber(&ui, @intCast(stats.gen_queue), 140, hud_y + 65, Color.white); + + drawText(&ui, "QUEUED MESH:", 15, hud_y + 85, 1.5, Color.white); + drawNumber(&ui, @intCast(stats.mesh_queue), 140, hud_y + 85, Color.white); + + drawText(&ui, "PENDING UP:", 15, hud_y + 105, 1.5, Color.white); + drawNumber(&ui, @intCast(stats.upload_queue), 140, hud_y + 105, Color.white); + if (in_pause) { // Darken background ui.drawRect(.{ .x = 0, .y = 0, .width = screen_w, .height = screen_h }, Color.rgba(0, 0, 0, 0.5)); @@ -302,7 +333,7 @@ pub fn main() !void { if (drawButton(&ui, .{ .x = pause_x, .y = pause_y, .width = pause_w, .height = pause_h }, "QUIT TO TITLE", 2.0, mouse_x, mouse_y, mouse_clicked)) { app_state = .home; - if (world) |*w| { + if (world) |w| { w.deinit(); world = null; } @@ -362,7 +393,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 < 16) settings.render_distance += 1; + if (settings.render_distance < 32) settings.render_distance += 1; } setting_y += 50.0; @@ -457,11 +488,11 @@ pub fn main() !void { if (create_clicked or create_pressed) { const seed_value = try resolveSeed(&seed_input, allocator); - if (world) |*active_world| { + if (world) |active_world| { active_world.deinit(); world = null; } - world = World.init(allocator, 2, seed_value); + world = try World.init(allocator, 2, seed_value); app_state = .world; seed_focused = false; camera = Camera.init(.{ @@ -482,7 +513,7 @@ pub fn main() !void { _ = c.SDL_GL_SwapWindow(window); if (in_world) { - if (world) |*active_world| { + if (world) |active_world| { if (time.frame_count % 120 == 0) { const stats = active_world.getStats(); const render_stats = active_world.getRenderStats(); @@ -503,20 +534,10 @@ pub fn main() !void { } // Simple digit drawing using rectangles (7-segment style) -fn drawNumber(ui: *UISystem, num: u32, x: f32, y: f32, color: Color) void { - var n = num; - var digit_x = x + 50; // Start from right - - if (n == 0) { - drawDigit(ui, 0, digit_x, y, color); - return; - } - - while (n > 0) : (digit_x -= 15) { - const digit: u4 = @intCast(n % 10); - drawDigit(ui, digit, digit_x, y, color); - n /= 10; - } +fn drawNumber(ui: *UISystem, num: i32, x: f32, y: f32, color: Color) void { + var buffer: [12]u8 = undefined; + const text = std.fmt.bufPrint(&buffer, "{d}", .{num}) catch return; + drawText(ui, text, x, y, 2.0, color); } fn drawDigit(ui: *UISystem, digit: u4, x: f32, y: f32, color: Color) void { diff --git a/src/world/chunk.zig b/src/world/chunk.zig index c0fb9b7f..b911c082 100644 --- a/src/world/chunk.zig +++ b/src/world/chunk.zig @@ -9,6 +9,20 @@ pub const CHUNK_SIZE_Z = 16; pub const CHUNK_VOLUME = CHUNK_SIZE_X * CHUNK_SIZE_Y * CHUNK_SIZE_Z; pub const Chunk = struct { + /// Chunk state for streaming + pub const State = enum { + missing, + queued_for_generation, + generating, + generated, + queued_for_mesh, + meshing, + mesh_ready, + uploading, + renderable, + unloading, + }; + /// Chunk position in chunk coordinates (multiply by 16 for world pos) chunk_x: i32, chunk_z: i32, @@ -17,6 +31,12 @@ pub const Chunk = struct { /// Index = x + z * CHUNK_SIZE_X + y * CHUNK_SIZE_X * CHUNK_SIZE_Z blocks: [CHUNK_VOLUME]BlockType, + /// Current state in the streaming pipeline + state: State = .missing, + + /// Job token to validate async results (increments on recycle) + job_token: u32 = 0, + /// Is the mesh out of date? dirty: bool = true, @@ -28,6 +48,8 @@ pub const Chunk = struct { .chunk_x = chunk_x, .chunk_z = chunk_z, .blocks = [_]BlockType{.air} ** CHUNK_VOLUME, + .state = .missing, + .job_token = 0, }; } diff --git a/src/world/chunk_mesh.zig b/src/world/chunk_mesh.zig index 0d386eca..31c21e3c 100644 --- a/src/world/chunk_mesh.zig +++ b/src/world/chunk_mesh.zig @@ -28,6 +28,8 @@ pub const ChunkMesh = struct { vao: c.GLuint, vbo: c.GLuint, vertex_count: u32, + pending_vertices: ?[]f32 = null, + mutex: std.Thread.Mutex = .{}, /// Allocator for vertex data during mesh building allocator: std.mem.Allocator, @@ -78,6 +80,10 @@ pub const ChunkMesh = struct { } pub fn deinit(self: *ChunkMesh) void { + self.mutex.lock(); + if (self.pending_vertices) |pv| self.allocator.free(pv); + self.pending_vertices = null; + self.mutex.unlock(); c.glDeleteVertexArrays().?(1, &self.vao); c.glDeleteBuffers().?(1, &self.vbo); } @@ -121,8 +127,36 @@ pub const ChunkMesh = struct { } } - // Upload to GPU - self.uploadVertices(vertices.items); + // Store vertices to be uploaded by the main thread later + const final_slice = try vertices.toOwnedSlice(self.allocator); + + self.mutex.lock(); + if (self.pending_vertices) |pv| self.allocator.free(pv); + self.pending_vertices = final_slice; + self.mutex.unlock(); + } + + /// Upload pending vertices to GPU (Must be called from main thread) + pub fn upload(self: *ChunkMesh) void { + self.mutex.lock(); + const vertices = self.pending_vertices orelse { + self.mutex.unlock(); + return; + }; + self.pending_vertices = null; + self.mutex.unlock(); + + defer self.allocator.free(vertices); + + c.glBindBuffer().?(c.GL_ARRAY_BUFFER, self.vbo); + c.glBufferData().?( + c.GL_ARRAY_BUFFER, + @intCast(vertices.len * @sizeOf(f32)), + vertices.ptr, + c.GL_STATIC_DRAW, + ); + self.vertex_count = @intCast(vertices.len / FLOATS_PER_VERTEX); + self.ready = self.vertex_count > 0; } /// Add a face (2 triangles, 6 vertices) to the vertex list diff --git a/src/world/world.zig b/src/world/world.zig index af2337e9..3ad42e8d 100644 --- a/src/world/world.zig +++ b/src/world/world.zig @@ -16,12 +16,17 @@ const Vec3 = @import("../engine/math/vec3.zig").Vec3; const Frustum = @import("../engine/math/frustum.zig").Frustum; const Shader = @import("../engine/graphics/shader.zig").Shader; +const JobSystem = @import("../engine/core/job_system.zig"); +const JobQueue = JobSystem.JobQueue; +const WorkerPool = JobSystem.WorkerPool; +const Job = JobSystem.Job; +const JobType = JobSystem.JobType; + pub const ChunkKey = struct { x: i32, z: i32, pub fn hash(self: ChunkKey) u64 { - // Combine x and z into a single hash const ux: u64 = @bitCast(@as(i64, self.x)); const uz: u64 = @bitCast(@as(i64, self.z)); return ux ^ (uz *% 0x9e3779b97f4a7c15); @@ -49,7 +54,8 @@ pub const ChunkData = struct { mesh: ChunkMesh, }; -/// Render statistics +pub const ChunkPos = struct { x: i32, z: i32 }; + pub const RenderStats = struct { chunks_total: u32 = 0, chunks_rendered: u32 = 0, @@ -59,131 +65,282 @@ pub const RenderStats = struct { pub const World = struct { chunks: std.HashMap(ChunkKey, *ChunkData, ChunkKeyContext, 80), + chunks_mutex: std.Thread.Mutex, allocator: std.mem.Allocator, generator: TerrainGenerator, render_distance: i32, last_render_stats: RenderStats, + gen_queue: *JobQueue, + mesh_queue: *JobQueue, + gen_pool: *WorkerPool, + mesh_pool: *WorkerPool, + upload_queue: std.ArrayListUnmanaged(*ChunkData), + next_job_token: u32, + last_pc: ChunkPos, - pub fn init(allocator: std.mem.Allocator, render_distance: i32, seed: u64) World { - return .{ + pub fn init(allocator: std.mem.Allocator, render_distance: i32, seed: u64) !*World { + const world = try allocator.create(World); + + const gen_queue = try allocator.create(JobQueue); + gen_queue.* = JobQueue.init(allocator); + + const mesh_queue = try allocator.create(JobQueue); + mesh_queue.* = JobQueue.init(allocator); + + world.* = .{ .chunks = std.HashMap(ChunkKey, *ChunkData, ChunkKeyContext, 80).init(allocator), + .chunks_mutex = .{}, .allocator = allocator, .render_distance = render_distance, .generator = TerrainGenerator.init(seed), .last_render_stats = .{}, + .gen_queue = gen_queue, + .mesh_queue = mesh_queue, + .gen_pool = undefined, + .mesh_pool = undefined, + .upload_queue = .empty, + .next_job_token = 1, + .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); + + return world; } pub fn deinit(self: *World) void { + self.gen_queue.stop(); + self.mesh_queue.stop(); + + self.gen_pool.deinit(); + self.mesh_pool.deinit(); + + self.gen_queue.deinit(); + self.mesh_queue.deinit(); + self.allocator.destroy(self.gen_queue); + self.allocator.destroy(self.mesh_queue); + + self.upload_queue.deinit(self.allocator); + var iter = self.chunks.iterator(); while (iter.next()) |entry| { entry.value_ptr.*.mesh.deinit(); self.allocator.destroy(entry.value_ptr.*); } self.chunks.deinit(); + self.allocator.destroy(self); } - /// Get or create a chunk at the given chunk coordinates - pub fn getOrCreateChunk(self: *World, chunk_x: i32, chunk_z: i32) !*ChunkData { - const key = ChunkKey{ .x = chunk_x, .z = chunk_z }; + fn processGenJob(ctx: *anyopaque, job: Job) void { + const self: *World = @ptrCast(@alignCast(ctx)); + + self.chunks_mutex.lock(); + const chunk_data = self.chunks.get(ChunkKey{ .x = job.chunk_x, .z = job.chunk_z }) orelse { + self.chunks_mutex.unlock(); + return; + }; + self.chunks_mutex.unlock(); - if (self.chunks.get(key)) |data| { - return data; + if (chunk_data.chunk.state == .generating and chunk_data.chunk.job_token == job.job_token) { + self.generator.generate(&chunk_data.chunk); + chunk_data.chunk.state = .generated; + self.markNeighborsForRemesh(job.chunk_x, job.chunk_z); } + } + + fn processMeshJob(ctx: *anyopaque, job: Job) void { + const self: *World = @ptrCast(@alignCast(ctx)); + + self.chunks_mutex.lock(); + const chunk_data = self.chunks.get(ChunkKey{ .x = job.chunk_x, .z = job.chunk_z }) orelse { + self.chunks_mutex.unlock(); + return; + }; + + const neighbors = NeighborChunks{ + .north = if (self.chunks.get(ChunkKey{ .x = job.chunk_x, .z = job.chunk_z - 1 })) |d| &d.chunk else null, + .south = if (self.chunks.get(ChunkKey{ .x = job.chunk_x, .z = job.chunk_z + 1 })) |d| &d.chunk else null, + .east = if (self.chunks.get(ChunkKey{ .x = job.chunk_x + 1, .z = job.chunk_z })) |d| &d.chunk else null, + .west = if (self.chunks.get(ChunkKey{ .x = job.chunk_x - 1, .z = job.chunk_z })) |d| &d.chunk else null, + }; + self.chunks_mutex.unlock(); + + if (chunk_data.chunk.state == .meshing and chunk_data.chunk.job_token == job.job_token) { + chunk_data.mesh.buildWithNeighbors(&chunk_data.chunk, neighbors) catch {}; + chunk_data.chunk.state = .mesh_ready; + } + } + + pub fn getOrCreateChunk(self: *World, chunk_x: i32, chunk_z: i32) !*ChunkData { + self.chunks_mutex.lock(); + defer self.chunks_mutex.unlock(); + + const key = ChunkKey{ .x = chunk_x, .z = chunk_z }; + if (self.chunks.get(key)) |data| return data; - // Create new chunk const data = try self.allocator.create(ChunkData); data.* = .{ .chunk = Chunk.init(chunk_x, chunk_z), .mesh = ChunkMesh.init(self.allocator), }; - - // Generate terrain using noise - self.generator.generate(&data.chunk); - + data.chunk.job_token = self.next_job_token; + self.next_job_token += 1; try self.chunks.put(key, data); return data; } - /// Get chunk at coordinates (returns null if not loaded) - pub fn getChunk(self: *World, chunk_x: i32, chunk_z: i32) ?*ChunkData { - const key = ChunkKey{ .x = chunk_x, .z = chunk_z }; - return self.chunks.get(key); + fn markNeighborsForRemesh(self: *World, cx: i32, cz: i32) void { + const offsets = [_][2]i32{ .{ 0, 1 }, .{ 0, -1 }, .{ 1, 0 }, .{ -1, 0 } }; + self.chunks_mutex.lock(); + defer self.chunks_mutex.unlock(); + for (offsets) |off| { + if (self.chunks.get(ChunkKey{ .x = cx + off[0], .z = cz + off[1] })) |data| { + // Only trigger remesh if the chunk is already in a stable state. + // If it's currently meshing or uploading, we mark it as dirty so it + // remeshes on the next update cycle after it reaches 'renderable'. + if (data.chunk.state == .renderable) { + data.chunk.state = .generated; + } else if (data.chunk.state == .mesh_ready or data.chunk.state == .uploading or data.chunk.state == .meshing) { + data.chunk.dirty = true; + } + } + } } - /// Get block at world coordinates pub fn getBlock(self: *World, world_x: i32, world_y: i32, world_z: i32) BlockType { if (world_y < 0 or world_y >= 256) return .air; - - const chunk_pos = worldToChunk(world_x, world_z); - const chunk_data = self.getChunk(chunk_pos.chunk_x, chunk_pos.chunk_z) orelse return .air; - + const cp = worldToChunk(world_x, world_z); + const data = self.getChunk(cp.chunk_x, cp.chunk_z) orelse return .air; const local = worldToLocal(world_x, world_z); - return chunk_data.chunk.getBlock(local.x, @intCast(world_y), local.z); + return data.chunk.getBlock(local.x, @intCast(world_y), local.z); } - /// Set block at world coordinates pub fn setBlock(self: *World, world_x: i32, world_y: i32, world_z: i32, block: BlockType) !void { if (world_y < 0 or world_y >= 256) return; - - const chunk_pos = worldToChunk(world_x, world_z); - const chunk_data = try self.getOrCreateChunk(chunk_pos.chunk_x, chunk_pos.chunk_z); - + const cp = worldToChunk(world_x, world_z); + const data = try self.getOrCreateChunk(cp.chunk_x, cp.chunk_z); const local = worldToLocal(world_x, world_z); - chunk_data.chunk.setBlock(local.x, @intCast(world_y), local.z, block); + data.chunk.setBlock(local.x, @intCast(world_y), local.z, block); } - /// Update chunks around player position pub fn update(self: *World, player_pos: Vec3) !void { - const player_chunk = worldToChunk(@intFromFloat(player_pos.x), @intFromFloat(player_pos.z)); - - // Load chunks within render distance - var cz = player_chunk.chunk_z - self.render_distance; - while (cz <= player_chunk.chunk_z + self.render_distance) : (cz += 1) { - var cx = player_chunk.chunk_x - self.render_distance; - while (cx <= player_chunk.chunk_x + self.render_distance) : (cx += 1) { - const data = try self.getOrCreateChunk(cx, cz); - - // Rebuild mesh if dirty - if (data.chunk.dirty) { - // Gather neighbor chunks for cross-chunk face culling - const neighbors = self.getNeighborChunks(cx, cz); - try data.mesh.buildWithNeighbors(&data.chunk, neighbors); - data.chunk.dirty = false; + const pc = worldToChunk(@intFromFloat(player_pos.x), @intFromFloat(player_pos.z)); + const moved = pc.chunk_x != self.last_pc.x or pc.chunk_z != self.last_pc.z; + + if (moved) { + self.last_pc = .{ .x = pc.chunk_x, .z = 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; + while (cx <= pc.chunk_x + self.render_distance) : (cx += 1) { + const dx = cx - pc.chunk_x; + const dz = cz - pc.chunk_z; + const dist_sq = dx * dx + dz * dz; + + if (dist_sq > self.render_distance * self.render_distance) continue; + + const data = try self.getOrCreateChunk(cx, cz); + + switch (data.chunk.state) { + .missing => { + data.chunk.state = .generating; + try self.gen_queue.push(.{ + .type = .generation, + .chunk_x = cx, + .chunk_z = cz, + .job_token = data.chunk.job_token, + .dist_sq = dist_sq, + }); + }, + else => {}, + } } } } - } - /// Get neighbor chunks for a given chunk position - fn getNeighborChunks(self: *World, chunk_x: i32, chunk_z: i32) NeighborChunks { - return .{ - .north = if (self.getChunk(chunk_x, chunk_z - 1)) |d| &d.chunk else null, - .south = if (self.getChunk(chunk_x, chunk_z + 1)) |d| &d.chunk else null, - .east = if (self.getChunk(chunk_x + 1, chunk_z)) |d| &d.chunk else null, - .west = if (self.getChunk(chunk_x - 1, chunk_z)) |d| &d.chunk else null, - }; + self.chunks_mutex.lock(); + var mesh_iter = self.chunks.iterator(); + while (mesh_iter.next()) |entry| { + const data = entry.value_ptr.*; + if (data.chunk.state == .generated) { + const dx = data.chunk.chunk_x - pc.chunk_x; + const dz = data.chunk.chunk_z - pc.chunk_z; + if (dx * dx + dz * dz <= self.render_distance * self.render_distance) { + data.chunk.state = .meshing; + try self.mesh_queue.push(.{ + .type = .meshing, + .chunk_x = data.chunk.chunk_x, + .chunk_z = data.chunk.chunk_z, + .job_token = data.chunk.job_token, + .dist_sq = dx * dx + dz * dz, + }); + } + } else if (data.chunk.state == .mesh_ready) { + data.chunk.state = .uploading; + try self.upload_queue.append(self.allocator, data); + } else if (data.chunk.state == .renderable and data.chunk.dirty) { + data.chunk.dirty = false; + data.chunk.state = .generated; + } + } + self.chunks_mutex.unlock(); + + if (self.upload_queue.items.len > 0) { + 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; + } + } + + const unload_dist_sq = (self.render_distance + 2) * (self.render_distance + 2); + self.chunks_mutex.lock(); + var to_remove = std.ArrayListUnmanaged(ChunkKey).empty; + defer to_remove.deinit(self.allocator); + + var unload_iter = self.chunks.iterator(); + while (unload_iter.next()) |entry| { + const key = entry.key_ptr.*; + const data = entry.value_ptr.*; + const dx = key.x - pc.chunk_x; + const dz = key.z - pc.chunk_z; + if (dx * dx + dz * dz > unload_dist_sq) { + // Only unload if not currently being processed by a worker or in the upload queue. + if (data.chunk.state != .generating and data.chunk.state != .meshing and + data.chunk.state != .mesh_ready and data.chunk.state != .uploading) + { + try to_remove.append(self.allocator, key); + } + } + } + + for (to_remove.items) |key| { + if (self.chunks.get(key)) |data| { + data.mesh.deinit(); + self.allocator.destroy(data); + _ = self.chunks.remove(key); + } + } + self.chunks_mutex.unlock(); } - /// Render all loaded chunks with frustum culling pub fn render(self: *World, shader: *const Shader, view_proj: Mat4) void { - shader.use(); - - // Extract frustum from view-projection matrix const frustum = Frustum.fromViewProj(view_proj); - self.last_render_stats = .{}; + self.chunks_mutex.lock(); var iter = self.chunks.iterator(); while (iter.next()) |entry| { const key = entry.key_ptr.*; const data = entry.value_ptr.*; - if (!data.mesh.ready) continue; + if (data.chunk.state != .renderable) continue; self.last_render_stats.chunks_total += 1; - - // Frustum culling if (!frustum.intersectsChunk(key.x, key.z)) { self.last_render_stats.chunks_culled += 1; continue; @@ -192,27 +349,39 @@ pub const World = struct { self.last_render_stats.chunks_rendered += 1; self.last_render_stats.vertices_rendered += data.mesh.vertex_count; - // Model matrix is identity since chunk vertices are in world space shader.setMat4("transform", &view_proj.data); data.mesh.draw(); } + self.chunks_mutex.unlock(); } - /// Get render statistics from last frame pub fn getRenderStats(self: *const World) RenderStats { return self.last_render_stats; } - /// Get statistics - pub fn getStats(self: *World) struct { chunks_loaded: usize, total_vertices: u64 } { + pub fn getStats(self: *World) struct { chunks_loaded: usize, total_vertices: u64, gen_queue: usize, mesh_queue: usize, upload_queue: usize } { + self.chunks_mutex.lock(); + defer self.chunks_mutex.unlock(); var total_verts: u64 = 0; var iter = self.chunks.iterator(); while (iter.next()) |entry| { total_verts += entry.value_ptr.*.mesh.vertex_count; } + + self.gen_queue.mutex.lock(); + const gen_count = self.gen_queue.jobs.count(); + self.gen_queue.mutex.unlock(); + + self.mesh_queue.mutex.lock(); + const mesh_count = self.mesh_queue.jobs.count(); + self.mesh_queue.mutex.unlock(); + return .{ .chunks_loaded = self.chunks.count(), .total_vertices = total_verts, + .gen_queue = gen_count, + .mesh_queue = mesh_count, + .upload_queue = self.upload_queue.items.len, }; } }; From f69bdcf3c47d5fe0afe8c8596be70a6034d7ae92 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sat, 20 Dec 2025 21:43:13 +0000 Subject: [PATCH 10/10] feat: implement subchunk greedy meshing and stable thread safety --- mesh.md | 327 ++++++++++++++++++++++ src/main.zig | 19 +- src/world/block.zig | 10 +- src/world/chunk.zig | 16 ++ src/world/chunk_mesh.zig | 591 +++++++++++++++++++++------------------ src/world/world.zig | 63 ++++- 6 files changed, 746 insertions(+), 280 deletions(-) create mode 100644 mesh.md diff --git a/mesh.md b/mesh.md new file mode 100644 index 00000000..f585d4bf --- /dev/null +++ b/mesh.md @@ -0,0 +1,327 @@ +# meshing.md — Chunk Meshing (16×256×16) with Face Culling + Greedy Meshing + +This document specifies the meshing system for a voxel engine with chunks sized **16 (X) × 256 (Y) × 16 (Z)**. +It covers: +- Face visibility (culling) +- Greedy meshing (rectangle merging) +- Subchunk strategy (16×16×16) for smooth updates +- Opaque vs transparent passes +- Chunk-boundary neighbor handling +- Data structures, state, and rebuild triggers + +--- + +## 1) Goals + +- Minimize triangles/draw calls via: + - **Face culling** (don’t emit internal faces) + - **Greedy meshing** (merge adjacent coplanar faces into large quads) +- Support smooth streaming and edits: + - Mesh rebuilds should be limited to affected regions, not entire 256-high chunks. +- Deterministic output given same chunk/block data. + +--- + +## 2) Chunk Layout & Subchunks + +### 2.1 Storage +- Chunk dimensions: `CX=16`, `CY=256`, `CZ=16` +- Total blocks: 65,536 +- Block storage can remain as a single array: + - index: `idx = x + z*CX + y*CX*CZ` + - memory order: X fastest, then Z, then Y + +### 2.2 Meshing granularity: subchunks +Mesh in vertical sections: +- Subchunk size: `16×16×16` +- Subchunk count: `CY / 16 = 16` + +Benefits: +- Block edits rebuild only 1–2 subchunks. +- Streaming can cull and upload smaller pieces. + +### 2.3 Rendering options +- Option A (recommended): draw per subchunk (opaque + transparent) + - Pros: simple, good rebuild granularity, good culling. + - Cons: more draw calls (up to 16 per chunk per pass). +- Option B: merge subchunk meshes into one chunk mesh (optional later). + +--- + +## 3) Mesh Data Model + +### 3.1 Passes +Maintain separate meshes: +- **Opaque mesh**: solid blocks, depth-write on +- **Transparent mesh**: water/glass/leaves if needed, depth-write off (typical) + +Do not mix opaque and transparent in the same mesh. + +### 3.2 Vertex format (minimal v1) +Per vertex: +- `vec3 position` +- `vec3 normal` (or packed normal) +- `vec2 uv` +Optional later: +- AO/light (packed u8), biome tint, etc. + +### 3.3 GPU resources +Per subchunk per pass: +- VBO + IBO (+ VAO) +- Or a single VBO with interleaved + glDrawElements + +Upload budget is managed elsewhere (see chunk streaming spec). + +--- + +## 4) Face Visibility (Culling) + +A face is visible if: +- The current block is renderable for this pass, and +- The neighbor block in that face direction is NOT occluding this pass. + +Definitions: +- `isOpaque(id)` — true for solid blocks +- `isTransparent(id)` — true for blocks rendered in transparent pass +- `occludesOpaque(neighbor)` — neighbor blocks that hide opaque faces (typically opaque blocks) +- `occludesTransparent(neighbor)` — neighbor blocks that hide transparent faces (often anything non-air, depends on your water/glass rules) + +### 4.1 Opaque pass visibility rule (recommended) +Emit face if: +- `isOpaque(cur) == true` +- `isOpaque(nei) == false` (treat air, water, etc. as non-opaque) + +### 4.2 Transparent pass visibility rule (simple v1) +Emit face if: +- `isTransparent(cur) == true` +- `nei` is air OR `nei` is not the same transparent “fluid group” + - For water: don’t render faces between adjacent water blocks. + - For glass: often don’t render internal glass-to-glass faces either. + +--- + +## 5) Neighbor Sampling (Chunk Borders) + +Meshing requires neighbor blocks for boundary faces: +- If neighbor chunk exists: sample real neighbor block. +- If neighbor chunk missing: treat neighbor as air, emit faces. + - When neighbor later loads, mark border subchunks dirty and remesh. + +### 5.1 Border invalidation rules +When a chunk at `(cx,cz)` loads or changes: +- It must notify its 4 neighbors (N/E/S/W) to remesh the touching border subchunks: + - Example: if east neighbor loads, current chunk’s `x=15` border subchunks become dirty. +- If you have vertical subchunks: only mark those overlapping the changed y-range. + +--- + +## 6) Greedy Meshing Overview + +Greedy meshing merges many 1×1 quads into fewer large rectangles. + +You run greedy meshing for each of the 3 axes: +- Faces perpendicular to X: ±X +- Faces perpendicular to Y: ±Y +- Faces perpendicular to Z: ±Z + +Greedy meshing operates on a 2D “mask” per slice boundary. + +### 6.1 Face Material Key +To merge, faces must match a key: +- `key = (blockId, faceDir, passType[, textureId])` +If texture differs per face, include faceDir or faceTextureId. + +If lighting/AO differs per vertex, merging may need to be limited (v1 can ignore). + +--- + +## 7) Per-Subchunk Meshing Procedure + +Given a subchunk: +- X range: `[0..15]` +- Z range: `[0..15]` +- Y range: `[y0..y0+15]` where `y0 = subchunkIndex * 16` + +For each pass (Opaque then Transparent): + +1. Clear mesh builders (CPU vertex/index arrays). +2. Run greedy for X faces (±X) for boundaries inside the subchunk and across borders. +3. Run greedy for Y faces (±Y). +4. Run greedy for Z faces (±Z). +5. Output CPU mesh buffers. +6. Queue GPU upload (main thread). + +--- + +## 8) Greedy Meshing Details (per axis) + +This section defines the exact masks and loops for each axis. + +### 8.1 Common concepts +- A “slice boundary” is between two adjacent blocks. +- For each boundary, build a 2D mask of faces to emit. +- Merge rectangles of identical face keys. + +Mask cells store either: +- Empty +- `FaceCell { key, direction }` + +### 8.2 Axis X (faces perpendicular to X) +For X boundaries, the 2D mask is over **(Y,Z)**. + +Loop: +- `xBoundary` in `[0..16]` (inclusive; boundaries count is 17) +- mask size: `H = 16` for Y within the subchunk, `W = 16` for Z + +At boundary `xBoundary`, for each `(y,z)` in the subchunk: +- `left = block(xBoundary - 1, y, z)` (if xBoundary==0 -> neighbor chunk west) +- `right = block(xBoundary, y, z)` (if xBoundary==16 -> neighbor chunk east) + +Decide faces: +- If `left` is renderable for pass and `right` occludes == false => emit **+X face** for `left` +- If `right` is renderable for pass and `left` occludes == false => emit **-X face** for `right` + +Store the chosen face (if any) in mask cell at (y,z). + +Then greedy-merge rectangles in the (Y,Z) mask. + +### 8.3 Axis Y (faces perpendicular to Y) +For Y boundaries, the 2D mask is over **(X,Z)**. + +Loop: +- `yBoundary` in `[y0..y0+16]` +- mask size: X=16, Z=16 + +At boundary `yBoundary`, for each `(x,z)`: +- `below = block(x, yBoundary - 1, z)` (if yBoundary==0 -> treat as solid bedrock or air per world rules) +- `above = block(x, yBoundary, z)` (if yBoundary==256 -> air) + +Decide faces: +- If `below` renderable and `above` not occluding => emit **+Y face** for `below` +- If `above` renderable and `below` not occluding => emit **-Y face** for `above` + +Greedy-merge rectangles in (X,Z). + +### 8.4 Axis Z (faces perpendicular to Z) +For Z boundaries, the 2D mask is over **(X,Y)**. + +Loop: +- `zBoundary` in `[0..16]` +- mask size: X=16, Y=16 (within subchunk) + +At boundary `zBoundary`, for each `(x,y)`: +- `back = block(x, y, zBoundary - 1)` (if zBoundary==0 -> neighbor chunk north) +- `front = block(x, y, zBoundary)` (if zBoundary==16 -> neighbor chunk south) + +Decide faces: +- If `back` renderable and `front` not occluding => emit **+Z face** for `back` +- If `front` renderable and `back` not occluding => emit **-Z face** for `front` + +Greedy-merge rectangles in (X,Y). + +--- + +## 9) Rectangle Merge Algorithm (Greedy Step) + +Given a 2D mask `mask[u][v]` with dimensions `U×V`: + +1. Scan cells in a fixed order (u then v). +2. When a non-empty cell is found at `(u0,v0)`: + - Let `k = mask[u0][v0].key`. +3. Find max width: + - `w` = largest such that for all `du in [0..w-1]`, `mask[u0+du][v0]` has key `k`. +4. Find max height: + - `h` = largest such that for all `dv in [0..h-1]` and all `du in [0..w-1]`, + `mask[u0+du][v0+dv]` has key `k`. +5. Emit one quad for the rectangle (size w×h). +6. Clear those cells to empty. +7. Continue scanning. + +Merging requirements: +- keys must match exactly, including direction and texture/material. + +--- + +## 10) Quad Emission Rules + +### 10.1 Vertex positions +Each rectangle produces one quad (4 vertices, 6 indices). + +You compute quad corners based on: +- axis (X/Y/Z) +- boundary coordinate (xBoundary, yBoundary, zBoundary) +- rectangle extents in the mask dimensions + +Example: for X faces, rectangle spans: +- y range: `[yStart .. yStart + h]` +- z range: `[zStart .. zStart + w]` +- x constant: `xBoundary` (for -X or +X depends on which block is emitting) + +### 10.2 Normals +- +X, -X, +Y, -Y, +Z, -Z are constant normals. + +### 10.3 UVs +Two common approaches: + +**Tiled UVs (recommended for block textures)** +- u spans `[0..w]`, v spans `[0..h]` +- In shader, sample atlas using block face texture + fractional part if you want repeats. + +**Atlas-per-face UVs** +- For each block face texture: + - base UV rect in atlas + - scale by w/h if repeating + - or keep fixed and accept stretching (not recommended) + +Pick one and ensure it is consistent across all faces. + +--- + +## 11) Dirty Flags & Remeshing + +### 11.1 When to mark a subchunk dirty +- Any block change within its y-range. +- Any block change in a neighboring chunk that touches one of its faces: + - x=0 or x=15 border + - z=0 or z=15 border +- For Y boundaries: + - if your world supports stacked chunks, handle vertical neighbors similarly. + +### 11.2 Remesh scheduling +- Dirty subchunks are queued for meshing. +- Queue priority can be based on distance to player. + +### 11.3 Cancelling / invalidating jobs +Use a `meshVersion` or `jobToken` per subchunk: +- Increment token when: + - the subchunk is dirtied again + - the subchunk is unloaded +- Worker jobs capture token; results are discarded if token mismatches. + +--- + +## 12) Performance Notes (for 16×256×16) + +- Reuse mask buffers to avoid allocations: + - For X and Z masks: 16×16 + - For Y masks: 16×16 +- Use compact keys (32-bit): + - `key = blockId | (faceDir<<16) | (pass<<20) | (texId<<22)` +- Separate opaque and transparent meshes to simplify ordering and reduce overdraw. +- For v1, greedy meshing on opaque is the biggest win. + - Transparent can be naive first, then greedy later. + +--- + +## 13) Acceptance Criteria + +- Adjacent solid blocks do not produce internal faces. +- A 2×2 flat area of visible identical faces produces **2 triangles** (one quad), not 8 triangles. +- Chunk borders render correctly: + - If neighbor missing: faces visible. + - When neighbor loads: border subchunks remesh and internal faces disappear. +- Editing one block only remeshes the affected subchunk(s), not the entire 256 height. +- Opaque and transparent geometry are not mixed in one draw call/mesh. + +--- + diff --git a/src/main.zig b/src/main.zig index 89616db2..434e088d 100644 --- a/src/main.zig +++ b/src/main.zig @@ -30,15 +30,18 @@ const vertex_shader_src = \\layout (location = 1) in vec3 aColor; \\layout (location = 2) in vec3 aNormal; \\layout (location = 3) in vec2 aTexCoord; + \\layout (location = 4) in float aTileID; \\out vec3 vColor; \\out vec3 vNormal; \\out vec2 vTexCoord; + \\flat out int vTileID; \\uniform mat4 transform; \\void main() { \\ gl_Position = transform * vec4(aPos, 1.0); \\ vColor = aColor; \\ vNormal = aNormal; \\ vTexCoord = aTexCoord; + \\ vTileID = int(aTileID); \\} ; @@ -47,17 +50,29 @@ const fragment_shader_src = \\in vec3 vColor; \\in vec3 vNormal; \\in vec2 vTexCoord; + \\flat in int vTileID; \\out vec4 FragColor; \\uniform sampler2D uTexture; \\uniform bool uUseTexture; \\void main() { - \\ // Simple directional lighting \\ vec3 lightDir = normalize(vec3(0.5, 1.0, 0.3)); \\ float diff = max(dot(vNormal, lightDir), 0.0) * 0.4 + 0.6; \\ \\ vec3 color; \\ if (uUseTexture) { - \\ vec4 texColor = texture(uTexture, vTexCoord); + \\ // Tiled atlas sampling + \\ vec2 atlasSize = vec2(16.0, 16.0); // 16x16 tiles + \\ vec2 tileSize = 1.0 / atlasSize; + \\ vec2 tilePos = vec2(mod(float(vTileID), atlasSize.x), floor(float(vTileID) / atlasSize.x)); + \\ + \\ // Apply fract to vTexCoord for greedy tiling, then inset to prevent bleeding + \\ vec2 tiledUV = fract(vTexCoord); + \\ // Clamp tiledUV slightly to avoid edge bleeding + \\ tiledUV = clamp(tiledUV, 0.001, 0.999); + \\ + \\ vec2 uv = (tilePos + tiledUV) * tileSize; + \\ vec4 texColor = texture(uTexture, uv); + \\ if (texColor.a < 0.1) discard; \\ color = texColor.rgb * vColor * diff; \\ } else { \\ color = vColor * diff; diff --git a/src/world/block.zig b/src/world/block.zig index 9caad7be..899a4435 100644 --- a/src/world/block.zig +++ b/src/world/block.zig @@ -41,8 +41,14 @@ pub const BlockType = enum(u8) { }; } - pub fn isLiquid(self: BlockType) bool { - return self == .water; + pub fn occludes(self: BlockType, other: BlockType, face: Face) bool { + _ = face; + if (self.isAir()) return false; + // Same transparent types occlude each other (no internal water/glass faces) + if (self.isTransparent() and self == other) return true; + // Non-transparent solid blocks occlude everything + if (self.isSolid() and !self.isTransparent()) return true; + return false; } /// Get block color (RGB, 0-1 range) diff --git a/src/world/chunk.zig b/src/world/chunk.zig index b911c082..dda0f6cf 100644 --- a/src/world/chunk.zig +++ b/src/world/chunk.zig @@ -43,6 +43,9 @@ pub const Chunk = struct { /// Has this chunk been generated? generated: bool = false, + /// Number of active jobs referencing this chunk (prevents unloading) + pin_count: std.atomic.Value(u32), + pub fn init(chunk_x: i32, chunk_z: i32) Chunk { return .{ .chunk_x = chunk_x, @@ -50,6 +53,7 @@ pub const Chunk = struct { .blocks = [_]BlockType{.air} ** CHUNK_VOLUME, .state = .missing, .job_token = 0, + .pin_count = std.atomic.Value(u32).init(0), }; } @@ -93,6 +97,18 @@ pub const Chunk = struct { return self.chunk_z * CHUNK_SIZE_Z; } + pub fn pin(self: *Chunk) void { + _ = self.pin_count.fetchAdd(1, .monotonic); + } + + pub fn unpin(self: *Chunk) void { + _ = self.pin_count.fetchSub(1, .monotonic); + } + + pub fn isPinned(self: *const Chunk) bool { + return self.pin_count.load(.monotonic) > 0; + } + /// Fill entire chunk with a block type pub fn fill(self: *Chunk, block: BlockType) void { @memset(&self.blocks, block); diff --git a/src/world/chunk_mesh.zig b/src/world/chunk_mesh.zig index 31c21e3c..cf693a50 100644 --- a/src/world/chunk_mesh.zig +++ b/src/world/chunk_mesh.zig @@ -1,6 +1,4 @@ -//! Chunk mesh generation with visible face culling and texture UVs. -//! Only generates faces where a solid block meets air/transparent block. -//! Supports cross-chunk face culling when neighbor chunk data is provided. +//! Chunk mesh generation with Greedy Meshing and Subchunks. const std = @import("std"); const c = @import("../c.zig").c; @@ -14,315 +12,372 @@ const Face = @import("block.zig").Face; const ALL_FACES = @import("block.zig").ALL_FACES; const TextureAtlas = @import("../engine/graphics/texture_atlas.zig").TextureAtlas; -/// Neighbor chunks for cross-chunk face culling -pub const NeighborChunks = struct { - north: ?*const Chunk = null, // -Z - south: ?*const Chunk = null, // +Z - east: ?*const Chunk = null, // +X - west: ?*const Chunk = null, // -X +pub const SUBCHUNK_SIZE = 16; +pub const NUM_SUBCHUNKS = 16; - pub const empty = NeighborChunks{}; +pub const Pass = enum { + solid, + fluid, }; -pub const ChunkMesh = struct { - vao: c.GLuint, - vbo: c.GLuint, - vertex_count: u32, - pending_vertices: ?[]f32 = null, - mutex: std.Thread.Mutex = .{}, - - /// Allocator for vertex data during mesh building - allocator: std.mem.Allocator, - - /// Is the mesh ready to render? - ready: bool = false, - - // Vertex format: position (3) + color (3) + normal (3) + uv (2) = 11 floats - const FLOATS_PER_VERTEX: u32 = 11; - - pub fn init(allocator: std.mem.Allocator) ChunkMesh { - var vao: c.GLuint = undefined; - var vbo: c.GLuint = undefined; - - c.glGenVertexArrays().?(1, &vao); - c.glGenBuffers().?(1, &vbo); - - // Setup vertex format - c.glBindVertexArray().?(vao); - c.glBindBuffer().?(c.GL_ARRAY_BUFFER, vbo); +pub const NeighborChunks = struct { + north: ?*const Chunk = null, + south: ?*const Chunk = null, + east: ?*const Chunk = null, + west: ?*const Chunk = null, + + pub const empty = NeighborChunks{ + .north = null, + .south = null, + .east = null, + .west = null, + }; +}; - const stride: c.GLsizei = FLOATS_PER_VERTEX * @sizeOf(f32); +pub const SubChunkMesh = struct { + vao_solid: c.GLuint = 0, + vbo_solid: c.GLuint = 0, + count_solid: u32 = 0, - // Position (location 0) - c.glVertexAttribPointer().?(0, 3, c.GL_FLOAT, c.GL_FALSE, stride, null); - c.glEnableVertexAttribArray().?(0); + vao_fluid: c.GLuint = 0, + vbo_fluid: c.GLuint = 0, + count_fluid: u32 = 0, - // Color (location 1) - c.glVertexAttribPointer().?(1, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(3 * @sizeOf(f32))); - c.glEnableVertexAttribArray().?(1); + ready: bool = false, - // Normal (location 2) - c.glVertexAttribPointer().?(2, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(6 * @sizeOf(f32))); - c.glEnableVertexAttribArray().?(2); + pub fn deinit(self: *SubChunkMesh) void { + if (self.vao_solid != 0) c.glDeleteVertexArrays().?(1, &self.vao_solid); + if (self.vbo_solid != 0) c.glDeleteBuffers().?(1, &self.vbo_solid); + if (self.vao_fluid != 0) c.glDeleteVertexArrays().?(1, &self.vao_fluid); + if (self.vbo_fluid != 0) c.glDeleteBuffers().?(1, &self.vbo_fluid); + } +}; - // UV (location 3) - c.glVertexAttribPointer().?(3, 2, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(9 * @sizeOf(f32))); - c.glEnableVertexAttribArray().?(3); +pub const ChunkMesh = struct { + subchunks: [NUM_SUBCHUNKS]SubChunkMesh, + allocator: std.mem.Allocator, + mutex: std.Thread.Mutex, - c.glBindVertexArray().?(0); + pending_solid: [NUM_SUBCHUNKS]?[]f32, + pending_fluid: [NUM_SUBCHUNKS]?[]f32, - return .{ - .vao = vao, - .vbo = vbo, - .vertex_count = 0, + pub fn init(allocator: std.mem.Allocator) ChunkMesh { + var self: ChunkMesh = .{ + .subchunks = undefined, .allocator = allocator, + .mutex = .{}, + .pending_solid = [_]?[]f32{null} ** NUM_SUBCHUNKS, + .pending_fluid = [_]?[]f32{null} ** NUM_SUBCHUNKS, }; + for (0..NUM_SUBCHUNKS) |i| { + self.subchunks[i] = .{ + .vao_solid = 0, + .vbo_solid = 0, + .count_solid = 0, + .vao_fluid = 0, + .vbo_fluid = 0, + .count_fluid = 0, + .ready = false, + }; + } + return self; } pub fn deinit(self: *ChunkMesh) void { self.mutex.lock(); - if (self.pending_vertices) |pv| self.allocator.free(pv); - self.pending_vertices = null; - self.mutex.unlock(); - c.glDeleteVertexArrays().?(1, &self.vao); - c.glDeleteBuffers().?(1, &self.vbo); - } - - /// Build mesh from chunk data with face culling (no neighbor awareness) - pub fn build(self: *ChunkMesh, chunk: *const Chunk) !void { - return self.buildWithNeighbors(chunk, NeighborChunks.empty); + defer self.mutex.unlock(); + for (0..NUM_SUBCHUNKS) |i| { + self.subchunks[i].deinit(); + if (self.pending_solid[i]) |p| self.allocator.free(p); + if (self.pending_fluid[i]) |p| self.allocator.free(p); + } } - /// Build mesh from chunk data with cross-chunk face culling pub fn buildWithNeighbors(self: *ChunkMesh, chunk: *const Chunk, neighbors: NeighborChunks) !void { - var vertices = std.ArrayListUnmanaged(f32){}; - defer vertices.deinit(self.allocator); - - // Reserve modest initial capacity (will grow as needed) - try vertices.ensureTotalCapacity(self.allocator, 1024 * FLOATS_PER_VERTEX); - - // Iterate through all blocks - var y: u32 = 0; - while (y < CHUNK_SIZE_Y) : (y += 1) { - var z: u32 = 0; - while (z < CHUNK_SIZE_Z) : (z += 1) { - var x: u32 = 0; - while (x < CHUNK_SIZE_X) : (x += 1) { - const block = chunk.getBlock(x, y, z); - - // Skip air blocks - if (block.isAir()) continue; - - const world_x: f32 = @floatFromInt(@as(i32, @intCast(x)) + chunk.getWorldX()); - const world_y: f32 = @floatFromInt(y); - const world_z: f32 = @floatFromInt(@as(i32, @intCast(z)) + chunk.getWorldZ()); - - // Check each face - for (ALL_FACES) |face| { - if (shouldRenderFace(chunk, neighbors, x, y, z, face)) { - try self.addFace(&vertices, world_x, world_y, world_z, face, block); - } - } - } - } + for (0..NUM_SUBCHUNKS) |i| { + try self.buildSubchunk(chunk, neighbors, @intCast(i)); } - - // Store vertices to be uploaded by the main thread later - const final_slice = try vertices.toOwnedSlice(self.allocator); - - self.mutex.lock(); - if (self.pending_vertices) |pv| self.allocator.free(pv); - self.pending_vertices = final_slice; - self.mutex.unlock(); - } - - /// Upload pending vertices to GPU (Must be called from main thread) - pub fn upload(self: *ChunkMesh) void { - self.mutex.lock(); - const vertices = self.pending_vertices orelse { - self.mutex.unlock(); - return; - }; - self.pending_vertices = null; - self.mutex.unlock(); - - defer self.allocator.free(vertices); - - c.glBindBuffer().?(c.GL_ARRAY_BUFFER, self.vbo); - c.glBufferData().?( - c.GL_ARRAY_BUFFER, - @intCast(vertices.len * @sizeOf(f32)), - vertices.ptr, - c.GL_STATIC_DRAW, - ); - self.vertex_count = @intCast(vertices.len / FLOATS_PER_VERTEX); - self.ready = self.vertex_count > 0; } - /// Add a face (2 triangles, 6 vertices) to the vertex list - fn addFace(self: *ChunkMesh, vertices: *std.ArrayListUnmanaged(f32), x: f32, y: f32, z: f32, face: Face, block: BlockType) !void { - const color = block.getFaceColor(face); - const normal = face.getNormal(); - const nf = [3]f32{ - @floatFromInt(normal[0]), - @floatFromInt(normal[1]), - @floatFromInt(normal[2]), - }; - - // Get tile index for this face - const block_id = @intFromEnum(block); - const tiles = TextureAtlas.getTilesForBlock(block_id); - const tile_index = switch (face) { - .top => tiles.top, - .bottom => tiles.bottom, - else => tiles.side, - }; + fn buildSubchunk(self: *ChunkMesh, chunk: *const Chunk, neighbors: NeighborChunks, si: u32) !void { + var solid_verts = std.ArrayListUnmanaged(f32).empty; + defer solid_verts.deinit(self.allocator); + var fluid_verts = std.ArrayListUnmanaged(f32).empty; + defer fluid_verts.deinit(self.allocator); - // Get UV coordinates for the tile - const uv = TextureAtlas.getTileUV(tile_index); - const uv_coords = [4][2]f32{ - .{ uv[0], uv[1] }, // bottom-left - .{ uv[0], uv[3] }, // top-left - .{ uv[2], uv[3] }, // top-right - .{ uv[2], uv[1] }, // bottom-right - }; + 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()); - // Get the 4 corners of the face - const corners = getFaceCorners(x, y, z, face); - - // Triangle 1: 0, 1, 2 - try addVertex(self.allocator, vertices, corners[0], color, nf, uv_coords[0]); - try addVertex(self.allocator, vertices, corners[1], color, nf, uv_coords[1]); - try addVertex(self.allocator, vertices, corners[2], color, nf, uv_coords[2]); - - // Triangle 2: 0, 2, 3 - try addVertex(self.allocator, vertices, corners[0], color, nf, uv_coords[0]); - try addVertex(self.allocator, vertices, corners[2], color, nf, uv_coords[2]); - try addVertex(self.allocator, vertices, corners[3], color, nf, uv_coords[3]); - } - - fn addVertex(allocator: std.mem.Allocator, vertices: *std.ArrayListUnmanaged(f32), pos: [3]f32, color: [3]f32, normal: [3]f32, uv: [2]f32) !void { - try vertices.append(allocator, pos[0]); - try vertices.append(allocator, pos[1]); - try vertices.append(allocator, pos[2]); - try vertices.append(allocator, color[0]); - try vertices.append(allocator, color[1]); - try vertices.append(allocator, color[2]); - try vertices.append(allocator, normal[0]); - try vertices.append(allocator, normal[1]); - try vertices.append(allocator, normal[2]); - try vertices.append(allocator, uv[0]); - try vertices.append(allocator, uv[1]); - } + var sy: i32 = y0; + while (sy <= y1) : (sy += 1) { + try self.meshSlice(chunk, neighbors, .top, sy, wx, wz, 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); + } + 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); + } - fn uploadVertices(self: *ChunkMesh, vertices: []const f32) void { - c.glBindBuffer().?(c.GL_ARRAY_BUFFER, self.vbo); - c.glBufferData().?( - c.GL_ARRAY_BUFFER, - @intCast(vertices.len * @sizeOf(f32)), - vertices.ptr, - c.GL_STATIC_DRAW, - ); - self.vertex_count = @intCast(vertices.len / FLOATS_PER_VERTEX); - self.ready = self.vertex_count > 0; + self.mutex.lock(); + defer self.mutex.unlock(); + if (self.pending_solid[si]) |p| self.allocator.free(p); + if (self.pending_fluid[si]) |p| self.allocator.free(p); + self.pending_solid[si] = if (solid_verts.items.len > 0) try self.allocator.dupe(f32, solid_verts.items) else null; + self.pending_fluid[si] = if (fluid_verts.items.len > 0) try self.allocator.dupe(f32, fluid_verts.items) else null; } - pub fn draw(self: *const ChunkMesh) void { - if (!self.ready) return; + const FaceKey = struct { + block: BlockType, + side: bool, + }; - c.glBindVertexArray().?(self.vao); - c.glDrawArrays(c.GL_TRIANGLES, 0, @intCast(self.vertex_count)); - } -}; + 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 { + const du: u32 = 16; + const dv: u32 = 16; + var mask = try self.allocator.alloc(?FaceKey, du * dv); + defer self.allocator.free(mask); + @memset(mask, null); + + var v: u32 = 0; + while (v < dv) : (v += 1) { + var u: u32 = 0; + while (u < du) : (u += 1) { + const res = getBlocksAtBoundary(chunk, neighbors, axis, s, u, v, si); + const b1 = res[0]; + const b2 = res[1]; + + const y_min: i32 = @intCast(si * SUBCHUNK_SIZE); + const y_max: i32 = y_min + SUBCHUNK_SIZE; + + if (isEmittingSubchunk(axis, s - 1, u, v, y_min, y_max) and b1.isSolid() and !b2.occludes(b1, axis)) { + mask[u + v * du] = .{ .block = b1, .side = true }; + } else if (isEmittingSubchunk(axis, s, u, v, y_min, y_max) and b2.isSolid() and !b1.occludes(b2, axis)) { + mask[u + v * du] = .{ .block = b2, .side = false }; + } + } + } -/// Check if a face should be rendered (neighbor is air/transparent) -/// Supports cross-chunk lookups via NeighborChunks -fn shouldRenderFace(chunk: *const Chunk, neighbors: NeighborChunks, x: u32, y: u32, z: u32, face: Face) bool { - const offset = face.getOffset(); - const nx = @as(i32, @intCast(x)) + offset.x; - const ny = @as(i32, @intCast(y)) + offset.y; - const nz = @as(i32, @intCast(z)) + offset.z; - - // Y bounds check (no vertical neighbors) - if (ny < 0 or ny >= CHUNK_SIZE_Y) { - return ny < 0; // Render bottom face at y=0, hide top face above world - } + var sv: u32 = 0; + while (sv < dv) : (sv += 1) { + var su: u32 = 0; + while (su < du) : (su += 1) { + const k_opt = mask[su + sv * du]; + if (k_opt == null) continue; + const k = k_opt.?; + + var width: u32 = 1; + while (su + width < du) : (width += 1) { + const nxt_opt = mask[su + width + sv * du]; + if (nxt_opt == null) break; + const nxt = nxt_opt.?; + if (nxt.block != k.block or nxt.side != k.side) break; + } + var height: u32 = 1; + var dvh: u32 = 1; + outer: while (sv + dvh < dv) : (dvh += 1) { + var duw: u32 = 0; + while (duw < width) : (duw += 1) { + const nxt_opt = mask[su + duw + (sv + dvh) * du]; + if (nxt_opt == null) break :outer; + const nxt = nxt_opt.?; + if (nxt.block != k.block or nxt.side != k.side) break :outer; + } + height += 1; + } - // Check if neighbor is in adjacent chunk - if (nx < 0) { - // West neighbor (-X) - if (neighbors.west) |west_chunk| { - return west_chunk.getBlock(CHUNK_SIZE_X - 1, @intCast(ny), @intCast(z)).isTransparent(); - } - return true; // No neighbor chunk loaded, render the face - } + 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); - if (nx >= CHUNK_SIZE_X) { - // East neighbor (+X) - if (neighbors.east) |east_chunk| { - return east_chunk.getBlock(0, @intCast(ny), @intCast(z)).isTransparent(); + var dy: u32 = 0; + while (dy < height) : (dy += 1) { + var dx: u32 = 0; + while (dx < width) : (dx += 1) { + mask[su + dx + (sv + dy) * du] = null; + } + } + su += width - 1; + } } - return true; } - if (nz < 0) { - // North neighbor (-Z) - if (neighbors.north) |north_chunk| { - return north_chunk.getBlock(@intCast(x), @intCast(ny), CHUNK_SIZE_Z - 1).isTransparent(); + pub fn upload(self: *ChunkMesh) void { + self.mutex.lock(); + defer self.mutex.unlock(); + for (0..NUM_SUBCHUNKS) |si| { + if (self.pending_solid[si]) |v| { + setupBuffers(&self.subchunks[si].vao_solid, &self.subchunks[si].vbo_solid, v); + self.subchunks[si].count_solid = @intCast(v.len / 12); + self.allocator.free(v); + self.pending_solid[si] = null; + self.subchunks[si].ready = true; + } + if (self.pending_fluid[si]) |v| { + setupBuffers(&self.subchunks[si].vao_fluid, &self.subchunks[si].vbo_fluid, v); + self.subchunks[si].count_fluid = @intCast(v.len / 12); + self.allocator.free(v); + self.pending_fluid[si] = null; + self.subchunks[si].ready = true; + } } - return true; } - if (nz >= CHUNK_SIZE_Z) { - // South neighbor (+Z) - if (neighbors.south) |south_chunk| { - return south_chunk.getBlock(@intCast(x), @intCast(ny), 0).isTransparent(); + pub fn draw(self: *const ChunkMesh, pass: Pass) void { + for (self.subchunks) |s| { + if (!s.ready) continue; + if (pass == .solid and s.count_solid > 0) { + c.glBindVertexArray().?(s.vao_solid); + c.glDrawArrays(c.GL_TRIANGLES, 0, @intCast(s.count_solid)); + } else if (pass == .fluid and s.count_fluid > 0) { + c.glBindVertexArray().?(s.vao_fluid); + c.glDrawArrays(c.GL_TRIANGLES, 0, @intCast(s.count_fluid)); + } } - return true; } +}; - // Neighbor is within this chunk - return chunk.getBlock(@intCast(nx), @intCast(ny), @intCast(nz)).isTransparent(); +fn isEmittingSubchunk(axis: Face, s: i32, u: u32, v: u32, y_min: i32, y_max: i32) bool { + const y: i32 = switch (axis) { + .top => s, + .east => @as(i32, @intCast(u)) + y_min, + .south => @as(i32, @intCast(v)) + y_min, + else => unreachable, + }; + return y >= y_min and y < y_max; } -/// Get the 4 corners of a face (counter-clockwise winding) -fn getFaceCorners(x: f32, y: f32, z: f32, face: Face) [4][3]f32 { - return switch (face) { - .top => .{ - .{ x, y + 1, z }, - .{ x, y + 1, z + 1 }, - .{ x + 1, y + 1, z + 1 }, - .{ x + 1, y + 1, z }, - }, - .bottom => .{ - .{ x, y, z + 1 }, - .{ x, y, z }, - .{ x + 1, y, z }, - .{ x + 1, y, z + 1 }, - }, - .north => .{ - .{ x + 1, y, z }, - .{ x, y, z }, - .{ x, y + 1, z }, - .{ x + 1, y + 1, z }, - }, - .south => .{ - .{ x, y, z + 1 }, - .{ x + 1, y, z + 1 }, - .{ x + 1, y + 1, z + 1 }, - .{ x, y + 1, z + 1 }, - }, +fn getBlocksAtBoundary(chunk: *const Chunk, neighbors: NeighborChunks, axis: Face, s: i32, u: u32, v: u32, si: u32) [2]BlockType { + const y_off: i32 = @intCast(si * SUBCHUNK_SIZE); + return switch (axis) { + .top => .{ chunk.getBlockSafe(@intCast(u), s - 1, @intCast(v)), chunk.getBlockSafe(@intCast(u), s, @intCast(v)) }, .east => .{ - .{ x + 1, y, z + 1 }, - .{ x + 1, y, z }, - .{ x + 1, y + 1, z }, - .{ x + 1, y + 1, z + 1 }, + getBlockCross(chunk, neighbors, s - 1, y_off + @as(i32, @intCast(u)), @intCast(v)), + getBlockCross(chunk, neighbors, s, y_off + @as(i32, @intCast(u)), @intCast(v)), }, - .west => .{ - .{ x, y, z }, - .{ x, y, z + 1 }, - .{ x, y + 1, z + 1 }, - .{ x, y + 1, z }, + .south => .{ + getBlockCross(chunk, neighbors, @intCast(u), y_off + @as(i32, @intCast(v)), s - 1), + getBlockCross(chunk, neighbors, @intCast(u), y_off + @as(i32, @intCast(v)), s), }, + else => unreachable, + }; +} + +fn getBlockCross(chunk: *const Chunk, neighbors: NeighborChunks, x: i32, y: i32, z: i32) BlockType { + if (x < 0) return if (neighbors.west) |w| w.getBlockSafe(CHUNK_SIZE_X - 1, y, z) else .air; + if (x >= CHUNK_SIZE_X) return if (neighbors.east) |e| e.getBlockSafe(0, y, z) else .air; + if (z < 0) return if (neighbors.north) |n| n.getBlockSafe(x, y, CHUNK_SIZE_Z - 1) else .air; + if (z >= CHUNK_SIZE_Z) return if (neighbors.south) |s| s.getBlockSafe(x, y, 0) else .air; + 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 { + const face = if (forward) axis else switch (axis) { + .top => Face.bottom, + .east => Face.west, + .south => Face.north, + else => unreachable, }; + const col = block.getFaceColor(face); + const norm = face.getNormal(); + const nf = [3]f32{ @floatFromInt(norm[0]), @floatFromInt(norm[1]), @floatFromInt(norm[2]) }; + const tiles = TextureAtlas.getTilesForBlock(@intFromEnum(block)); + const tid: f32 = @floatFromInt(switch (face) { + .top => tiles.top, + .bottom => tiles.bottom, + else => tiles.side, + }); + const wf: f32 = @floatFromInt(w); + const hf: f32 = @floatFromInt(h); + const sf: f32 = @floatFromInt(s); + const uf: f32 = @floatFromInt(u); + const vf: f32 = @floatFromInt(v); + 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 }; + 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 }; + uv = [4][2]f32{ .{ 0, 0 }, .{ wf, 0 }, .{ wf, hf }, .{ 0, hf } }; + } + } else if (axis == .east) { + const x = wx + 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 }; + 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 }; + uv = [4][2]f32{ .{ 0, 0 }, .{ hf, 0 }, .{ hf, wf }, .{ 0, wf } }; + } + } else { + const z = wz + 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 }; + 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 }; + uv = [4][2]f32{ .{ wf, 0 }, .{ 0, 0 }, .{ 0, hf }, .{ wf, hf } }; + } + } + const idxs = [_]usize{ 0, 1, 2, 0, 2, 3 }; + for (idxs) |i| { + try verts.append(allocator, p[i][0]); + try verts.append(allocator, p[i][1]); + try verts.append(allocator, p[i][2]); + try verts.append(allocator, col[0]); + try verts.append(allocator, col[1]); + try verts.append(allocator, col[2]); + try verts.append(allocator, nf[0]); + try verts.append(allocator, nf[1]); + try verts.append(allocator, nf[2]); + try verts.append(allocator, uv[i][0]); + try verts.append(allocator, uv[i][1]); + try verts.append(allocator, tid); + } +} + +fn setupBuffers(vao_ptr: *c.GLuint, vbo_ptr: *c.GLuint, vertices: []const f32) void { + if (vao_ptr.* == 0) c.glGenVertexArrays().?(1, vao_ptr); + if (vbo_ptr.* == 0) c.glGenBuffers().?(1, vbo_ptr); + c.glBindVertexArray().?(vao_ptr.*); + c.glBindBuffer().?(c.GL_ARRAY_BUFFER, vbo_ptr.*); + c.glBufferData().?(c.GL_ARRAY_BUFFER, @intCast(vertices.len * @sizeOf(f32)), vertices.ptr, c.GL_STATIC_DRAW); + const stride: c.GLsizei = 12 * @sizeOf(f32); + c.glVertexAttribPointer().?(0, 3, c.GL_FLOAT, c.GL_FALSE, stride, null); + c.glEnableVertexAttribArray().?(0); + c.glVertexAttribPointer().?(1, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(3 * @sizeOf(f32))); + c.glEnableVertexAttribArray().?(1); + c.glVertexAttribPointer().?(2, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(6 * @sizeOf(f32))); + c.glEnableVertexAttribArray().?(2); + c.glVertexAttribPointer().?(3, 2, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(9 * @sizeOf(f32))); + c.glEnableVertexAttribArray().?(3); + c.glVertexAttribPointer().?(4, 1, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(11 * @sizeOf(f32))); + c.glEnableVertexAttribArray().?(4); + c.glBindVertexArray().?(0); } diff --git a/src/world/world.zig b/src/world/world.zig index 3ad42e8d..7086c8dd 100644 --- a/src/world/world.zig +++ b/src/world/world.zig @@ -140,8 +140,11 @@ pub const World = struct { self.chunks_mutex.unlock(); return; }; + chunk_data.chunk.pin(); self.chunks_mutex.unlock(); + defer chunk_data.chunk.unpin(); + if (chunk_data.chunk.state == .generating and chunk_data.chunk.job_token == job.job_token) { self.generator.generate(&chunk_data.chunk); chunk_data.chunk.state = .generated; @@ -158,14 +161,35 @@ pub const World = struct { 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.chunk else null, - .south = if (self.chunks.get(ChunkKey{ .x = job.chunk_x, .z = job.chunk_z + 1 })) |d| &d.chunk else null, - .east = if (self.chunks.get(ChunkKey{ .x = job.chunk_x + 1, .z = job.chunk_z })) |d| &d.chunk else null, - .west = if (self.chunks.get(ChunkKey{ .x = job.chunk_x - 1, .z = job.chunk_z })) |d| &d.chunk else null, + .north = if (self.chunks.get(ChunkKey{ .x = job.chunk_x, .z = job.chunk_z - 1 })) |d| d: { + d.chunk.pin(); + break :d &d.chunk; + } else null, + .south = if (self.chunks.get(ChunkKey{ .x = job.chunk_x, .z = job.chunk_z + 1 })) |d| d: { + d.chunk.pin(); + break :d &d.chunk; + } else null, + .east = if (self.chunks.get(ChunkKey{ .x = job.chunk_x + 1, .z = job.chunk_z })) |d| d: { + d.chunk.pin(); + break :d &d.chunk; + } else null, + .west = if (self.chunks.get(ChunkKey{ .x = job.chunk_x - 1, .z = job.chunk_z })) |d| d: { + d.chunk.pin(); + break :d &d.chunk; + } else null, }; self.chunks_mutex.unlock(); + defer { + chunk_data.chunk.unpin(); + if (neighbors.north) |n| @as(*Chunk, @constCast(n)).unpin(); + if (neighbors.south) |s| @as(*Chunk, @constCast(s)).unpin(); + if (neighbors.east) |e| @as(*Chunk, @constCast(e)).unpin(); + if (neighbors.west) |w| @as(*Chunk, @constCast(w)).unpin(); + } + if (chunk_data.chunk.state == .meshing and chunk_data.chunk.job_token == job.job_token) { chunk_data.mesh.buildWithNeighbors(&chunk_data.chunk, neighbors) catch {}; chunk_data.chunk.state = .mesh_ready; @@ -310,8 +334,10 @@ pub const World = struct { const dz = key.z - pc.chunk_z; if (dx * dx + dz * dz > unload_dist_sq) { // Only unload if not currently being processed by a worker or in the upload queue. + // ALSO check the pin_count to ensure no neighbor lookups are active. if (data.chunk.state != .generating and data.chunk.state != .meshing and - data.chunk.state != .mesh_ready and data.chunk.state != .uploading) + data.chunk.state != .mesh_ready and data.chunk.state != .uploading and + !data.chunk.isPinned()) { try to_remove.append(self.allocator, key); } @@ -347,11 +373,30 @@ pub const World = struct { } self.last_render_stats.chunks_rendered += 1; - self.last_render_stats.vertices_rendered += data.mesh.vertex_count; + for (data.mesh.subchunks) |s| { + self.last_render_stats.vertices_rendered += s.count_solid; + } + + shader.setMat4("transform", &view_proj.data); + data.mesh.draw(.solid); + } + + // Fluid pass + iter = self.chunks.iterator(); + while (iter.next()) |entry| { + const data = entry.value_ptr.*; + if (data.chunk.state != .renderable) continue; + const key = entry.key_ptr.*; + if (!frustum.intersectsChunk(key.x, key.z)) continue; + + for (data.mesh.subchunks) |s| { + self.last_render_stats.vertices_rendered += s.count_fluid; + } shader.setMat4("transform", &view_proj.data); - data.mesh.draw(); + data.mesh.draw(.fluid); } + self.chunks_mutex.unlock(); } @@ -365,7 +410,9 @@ pub const World = struct { var total_verts: u64 = 0; var iter = self.chunks.iterator(); while (iter.next()) |entry| { - total_verts += entry.value_ptr.*.mesh.vertex_count; + for (entry.value_ptr.*.mesh.subchunks) |s| { + total_verts += s.count_solid + s.count_fluid; + } } self.gen_queue.mutex.lock();