diff --git a/docs/block-roadmap-audit.md b/docs/block-roadmap-audit.md index a0876503..ea53cc3f 100644 --- a/docs/block-roadmap-audit.md +++ b/docs/block-roadmap-audit.md @@ -10,9 +10,10 @@ The current block registry lives in `modules/world-core/src/block.zig` and `modu - `BlockType` is `enum(u8)`. - `MAX_BLOCK_TYPES` is 256 in `modules/world-core/src/chunk_constants.zig`. -- The registry currently defines 47 enum entries including `air`, leaving 209 ID slots before the current cap. -- `RenderShape` currently supports `.cube` and `.cross`. +- The registry currently defines 79 concrete enum entries including `air`, leaving 177 ID slots before the current cap. +- `RenderShape` currently supports `.cube`, `.cross`, `.flat_quad`, `.tall_cross`, `.wall_attached`, and `.custom_mesh`. - Cross-billboard vegetation is already implemented by `modules/world-meshing/src/meshing/cross_mesher.zig`. +- Flat quads, tall crosses, wall-attached quads, and custom slab/stair meshes are implemented under `modules/world-meshing/src/meshing/`. ## Implemented Blocks @@ -32,6 +33,11 @@ These are present in the current `BlockType` enum and registered in `BLOCK_REGIS | Cross vegetation | `tall_grass`, `flower_red`, `flower_yellow`, `dead_bush` | | Temperate/cold trees | `birch_log`, `birch_leaves`, `spruce_log`, `spruce_leaves` | | Attachments/lights | `vine`, `torch` | +| Ice/cold surfaces | `snow_layer`, `ice`, `packed_ice`, `blue_ice` | +| Dirt/stone variants | `coarse_dirt`, `rooted_dirt`, `podzol`, `mossy_cobblestone` | +| Terracotta colors | `white_terracotta`, `orange_terracotta`, `magenta_terracotta`, `light_blue_terracotta`, `yellow_terracotta`, `lime_terracotta`, `pink_terracotta`, `gray_terracotta`, `light_gray_terracotta`, `cyan_terracotta`, `purple_terracotta`, `blue_terracotta`, `brown_terracotta`, `green_terracotta`, `red_terracotta`, `black_terracotta` | +| Aquatic foundation | `seagrass`, `tall_seagrass`, `kelp`, `seaweed`, `coral_block`, `coral_fan` | +| Custom mesh fixtures | `stone_slab`, `stone_stairs` | ## Stale Entries From #138 @@ -45,11 +51,11 @@ These are present in the current `BlockType` enum and registered in `BLOCK_REGIS | `birch_leaves` | Missing | Implemented | Present as ID 41. | | `spruce_log` | Missing | Implemented | Present as ID 42. | | `spruce_leaves` | Missing | Implemented | Present as ID 43. | -| `vine` | Missing wall-attached block | Implemented with interim shape | Present as ID 44 and currently uses `.cube`; #627 should decide wall-attached geometry/state. | +| `vine` | Missing wall-attached block | Implemented with wall-attached shape | Present as ID 44 and uses `.wall_attached`; per-block attachment state remains future work. | | `torch` | Missing special placement | Implemented with interim shape | Present as ID 45, emits light, and currently uses `.cross`; #627 should decide custom/attachment geometry. | | `lava` | Missing | Implemented | Present as ID 46, fluid pass, emits light. | | 322-block roadmap total | Future target | Exceeds current cap if fully adopted | #621 should decide if/when to widen IDs or introduce palette storage. | -| #138 summary counts | 40 implemented / 282 planned | Out of date | Current baseline is 47 registry entries including `air`, or 46 non-air blocks. | +| #138 summary counts | 40 implemented / 282 planned | Out of date | Current baseline is 79 registry entries including `air`, or 78 non-air blocks. | #138 also omits implemented ore blocks from its terrain/building catalogue: `coal_ore`, `iron_ore`, and `gold_ore`. @@ -88,3 +94,14 @@ The unchecked #138 list is still valid as a catalogue only after removing the st ## #616 Impact No scope, ordering, or block-family name changes are required for #616. The epic already accounts for the important corrections: #138 is superseded, current paths use `modules/*`, cross rendering exists, and the 322-block catalogue exceeds the current `u8`/256-ID architecture. + +## #616 Completion Boundary + +The remaining #616 gaps are resolved by the current implementation baseline: + +- #623 is complete: `.flat_quad` and `.tall_cross` exist and are meshed. +- #625 is complete: the core natural pack exists, including snow/ice, dirt variants, mossy cobblestone, and badlands terracotta colors. +- #626 is complete: aquatic vegetation and coral foundation blocks exist, and water-constrained decoration rules can place them. +- #627 is complete for the roadmap foundation: `.wall_attached` exists with a vine fixture, and `.custom_mesh` exists with slab/stair fixture meshers. + +Future work remains outside #616 for per-block orientation/state, accurate partial-block collision volumes, doors, fences, panes, walls, and broad decorative/building parity. diff --git a/docs/roadmap/block-id-capacity.md b/docs/roadmap/block-id-capacity.md index 220df097..e8f3db08 100644 --- a/docs/roadmap/block-id-capacity.md +++ b/docs/roadmap/block-id-capacity.md @@ -6,7 +6,7 @@ Issue: [#621](https://github.com/OpenStaticFish/ZigCraft/issues/621), part of [# `BlockType` is currently stored as `enum(u8)` in `modules/world-core/src/block.zig`, and `MAX_BLOCK_TYPES` is `256` in `modules/world-core/src/chunk_constants.zig`. -The current block catalog defines 47 concrete block IDs, `0` through `46`, leaving 209 IDs before the `u8` ceiling. The enum also has a flexible `_` tag, so new block IDs can continue to be added without changing the representation while the catalog remains under 256 entries. +The current block catalog defines 79 concrete block IDs, `0` through `78`, leaving 177 IDs before the `u8` ceiling. The enum also has a flexible `_` tag, so new block IDs can continue to be added without changing the representation while the catalog remains under 256 entries. ## Policy @@ -14,4 +14,4 @@ Keep block IDs as `u8` in the near term. This keeps chunk storage compact and av Revisit a `u16` block ID representation with a palette/remapping layer when the concrete block catalog approaches roughly 200 entries. At that point, open or update a follow-up issue to define the migration order, save compatibility requirements, chunk storage layout, serialization changes, and rendering/meshing impacts before adding large block families that would risk exhausting the `u8` space. -No block ID widening, palette storage, or remapping layer is needed for the current 47/256 catalog. +No block ID widening, palette storage, or remapping layer is needed for the current 79/256 catalog. diff --git a/modules/world-core/src/block.zig b/modules/world-core/src/block.zig index f3bfd703..87e812d2 100644 --- a/modules/world-core/src/block.zig +++ b/modules/world-core/src/block.zig @@ -183,6 +183,8 @@ pub const BlockType = enum(u8) { coral_block = 74, coral_fan = 75, tall_seagrass = 76, + stone_slab = 77, + stone_stairs = 78, _, }; diff --git a/modules/world-core/src/block_registry.zig b/modules/world-core/src/block_registry.zig index ae84faed..a565c251 100644 --- a/modules/world-core/src/block_registry.zig +++ b/modules/world-core/src/block_registry.zig @@ -39,7 +39,7 @@ pub const RenderShape = enum { tall_cross, /// Face-attached non-cube geometry driven by RenderShapeData.attachment wall_attached, - /// Placeholder for block-specific custom mesh variants (slabs, stairs, doors, fences) + /// Block-specific custom mesh variants (slabs, stairs, doors, fences) custom_mesh, }; @@ -112,12 +112,16 @@ pub const BlockDefinition = struct { // Same transparent types occlude each other (no internal glass faces) if (self.is_transparent and self.id == other_def.id) return true; - // Non-transparent solid blocks occlude everything - if (self.is_solid and !self.is_transparent) return true; + // Only full cubes fully occlude neighboring cube faces. + if (self.isFullCubeOccluder()) return true; return false; } + pub fn isFullCubeOccluder(self: BlockDefinition) bool { + return self.is_solid and !self.is_transparent and self.render_shape == .cube; + } + /// Get face color with shading based on normal direction pub fn getFaceColor(self: BlockDefinition, face: Face) [3]f32 { const shade = face.getShade(); @@ -290,6 +294,11 @@ pub const BLOCK_REGISTRY = blk: { def.texture_bottom = "dirt"; def.texture_side = "podzol_side"; }, + .stone_slab, .stone_stairs => { + def.texture_top = "stone"; + def.texture_bottom = "stone"; + def.texture_side = "stone"; + }, else => {}, } @@ -372,6 +381,7 @@ pub const BLOCK_REGISTRY = blk: { .green_terracotta => .{ 0.30, 0.35, 0.18 }, .red_terracotta => .{ 0.56, 0.24, 0.18 }, .black_terracotta => .{ 0.16, 0.10, 0.08 }, + .stone_slab, .stone_stairs => .{ 0.5, 0.5, 0.5 }, else => .{ 1, 0, 1 }, }; @@ -422,6 +432,7 @@ pub const BLOCK_REGISTRY = blk: { .coral_fan => .flat_quad, .snow_layer => .flat_quad, .vine => .wall_attached, + .stone_slab, .stone_stairs => .custom_mesh, else => .cube, }; @@ -432,6 +443,8 @@ pub const BLOCK_REGISTRY = blk: { .allowed_faces = AttachmentFaces.walls(), }, }, + .stone_slab => .{ .custom_mesh = .slab }, + .stone_stairs => .{ .custom_mesh = .stairs }, else => .{}, }; diff --git a/modules/world-core/src/block_registry_tests.zig b/modules/world-core/src/block_registry_tests.zig index efaab0bc..7106c77e 100644 --- a/modules/world-core/src/block_registry_tests.zig +++ b/modules/world-core/src/block_registry_tests.zig @@ -135,6 +135,32 @@ test "RenderShapeData can represent custom mesh variants" { try testing.expectEqual(null, data.attachment); } +test "custom mesh fixture blocks use slab and stair variants" { + const slab = block_registry.getBlockDefinition(.stone_slab); + try testing.expectEqual(block_registry.RenderShape.custom_mesh, slab.render_shape); + try testing.expectEqual(block_registry.CustomMeshVariant.slab, slab.render_shape_data.custom_mesh); + try testing.expectEqual(block_registry.RenderPass.solid, slab.render_pass); + try testing.expect(slab.is_solid); + try testing.expect(!slab.isFullCubeOccluder()); + + const stairs = block_registry.getBlockDefinition(.stone_stairs); + try testing.expectEqual(block_registry.RenderShape.custom_mesh, stairs.render_shape); + try testing.expectEqual(block_registry.CustomMeshVariant.stairs, stairs.render_shape_data.custom_mesh); + try testing.expectEqual(block_registry.RenderPass.solid, stairs.render_pass); + try testing.expect(stairs.is_solid); + try testing.expect(!stairs.isFullCubeOccluder()); +} + +test "custom mesh blocks do not fully occlude neighboring cube faces" { + const stone = block_registry.getBlockDefinition(.stone); + const slab = block_registry.getBlockDefinition(.stone_slab); + const stairs = block_registry.getBlockDefinition(.stone_stairs); + + try testing.expect(stone.occludes(slab, .top)); + try testing.expect(!slab.occludes(stone, .top)); + try testing.expect(!stairs.occludes(stone, .north)); +} + test "core natural block pack registry properties" { try testing.expectEqual(block_registry.RenderShape.flat_quad, block_registry.getBlockDefinition(.snow_layer).render_shape); try testing.expectEqual(block_registry.RenderPass.cutout, block_registry.getBlockDefinition(.snow_layer).render_pass); diff --git a/modules/world-meshing/src/chunk_mesh.zig b/modules/world-meshing/src/chunk_mesh.zig index ae42a5e5..4f592599 100644 --- a/modules/world-meshing/src/chunk_mesh.zig +++ b/modules/world-meshing/src/chunk_mesh.zig @@ -28,6 +28,7 @@ const cross_mesher = @import("meshing/cross_mesher.zig"); const flat_quad_mesher = @import("meshing/flat_quad_mesher.zig"); const tall_cross_mesher = @import("meshing/tall_cross_mesher.zig"); const wall_attached_mesher = @import("meshing/wall_attached_mesher.zig"); +const custom_mesh_mesher = @import("meshing/custom_mesh_mesher.zig"); const boundary = @import("meshing/boundary.zig"); // Re-export public types for external consumers @@ -152,11 +153,12 @@ pub const ChunkMesh = struct { try greedy_mesher.meshSlice(self.allocator, chunk, neighbors, .south, sz, si, &solid_verts, &cutout_verts, &fluid_verts, atlas); } - // Mesh non-cube cutout shapes (flowers, saplings, flat floor quads, tall plants, attached quads) + // Mesh non-cube shapes (plants, attached quads, and custom solid geometry) try cross_mesher.meshCrossBlocks(self.allocator, chunk, neighbors, si, &cutout_verts, atlas); try flat_quad_mesher.meshFlatQuadBlocks(self.allocator, chunk, neighbors, si, &cutout_verts, atlas); try tall_cross_mesher.meshTallCrossBlocks(self.allocator, chunk, neighbors, si, &cutout_verts, atlas); try wall_attached_mesher.meshWallAttachedBlocks(self.allocator, chunk, neighbors, si, &cutout_verts, atlas); + try custom_mesh_mesher.meshCustomMeshBlocks(self.allocator, chunk, neighbors, si, &solid_verts, atlas); // Store subchunk data temporarily (will be merged later) self.mutex.lock(); diff --git a/modules/world-meshing/src/chunk_mesh_tests.zig b/modules/world-meshing/src/chunk_mesh_tests.zig index a1da3cd5..37f4b479 100644 --- a/modules/world-meshing/src/chunk_mesh_tests.zig +++ b/modules/world-meshing/src/chunk_mesh_tests.zig @@ -79,3 +79,37 @@ test "ChunkMesh emits wall-attached fixture cutout quad" { try testing.expectEqual(null, mesh.pending_solid); try testing.expectEqual(null, mesh.pending_fluid); } + +test "ChunkMesh emits custom slab fixture solid mesh" { + var chunk = world_core.Chunk.init(0, 0); + chunk.setBlock(1, 1, 1, .stone_slab); + + var atlas: TextureAtlas = undefined; + atlas.tile_mappings = [_]TextureAtlas.BlockTiles{TextureAtlas.BlockTiles.uniform(7)} ** 256; + + var mesh = ChunkMesh.init(testing.allocator); + defer mesh.deinitWithoutRHI(); + + try mesh.buildWithNeighbors(&chunk, NeighborChunks.empty, &atlas); + + try testing.expectEqual(@as(usize, 36), mesh.pending_solid.?.len); + try testing.expectEqual(null, mesh.pending_cutout); + try testing.expectEqual(null, mesh.pending_fluid); +} + +test "ChunkMesh emits custom stair fixture solid mesh" { + var chunk = world_core.Chunk.init(0, 0); + chunk.setBlock(1, 1, 1, .stone_stairs); + + var atlas: TextureAtlas = undefined; + atlas.tile_mappings = [_]TextureAtlas.BlockTiles{TextureAtlas.BlockTiles.uniform(7)} ** 256; + + var mesh = ChunkMesh.init(testing.allocator); + defer mesh.deinitWithoutRHI(); + + try mesh.buildWithNeighbors(&chunk, NeighborChunks.empty, &atlas); + + try testing.expectEqual(@as(usize, 72), mesh.pending_solid.?.len); + try testing.expectEqual(null, mesh.pending_cutout); + try testing.expectEqual(null, mesh.pending_fluid); +} diff --git a/modules/world-meshing/src/meshing/custom_mesh_mesher.zig b/modules/world-meshing/src/meshing/custom_mesh_mesher.zig new file mode 100644 index 00000000..bda0cdb6 --- /dev/null +++ b/modules/world-meshing/src/meshing/custom_mesh_mesher.zig @@ -0,0 +1,160 @@ +//! Custom mesh meshing for non-cube fixture blocks. +//! +//! This is the render-shape foundation for custom geometry. Variants are driven +//! only by immutable block registry data, so worker-thread meshing remains +//! deterministic until per-block state/orientation exists. + +const std = @import("std"); + +const world_core = @import("world-core"); +const Chunk = world_core.Chunk; +const PackedLight = world_core.PackedLight; +const CHUNK_SIZE_X = world_core.CHUNK_SIZE_X; +const CHUNK_SIZE_Z = world_core.CHUNK_SIZE_Z; +const block_registry = world_core.block_registry; +const TextureAtlas = @import("engine-assets").TextureAtlas; +const rhi_mod = @import("engine-rhi"); +const Vertex = rhi_mod.Vertex; + +const boundary = @import("boundary.zig"); +const NeighborChunks = boundary.NeighborChunks; +const SUBCHUNK_SIZE = boundary.SUBCHUNK_SIZE; +const lighting_sampler = @import("lighting_sampler.zig"); +const biome_color_sampler = @import("biome_color_sampler.zig"); + +const Box = struct { + min: [3]f32, + max: [3]f32, +}; + +const FaceQuad = struct { + positions: [4][3]f32, + normal: [3]f32, + tile_id: u16, +}; + +pub fn meshCustomMeshBlocks( + allocator: std.mem.Allocator, + chunk: *const Chunk, + neighbors: NeighborChunks, + si: u32, + solid_list: *std.ArrayListUnmanaged(Vertex), + atlas: *const TextureAtlas, +) !void { + const y0: i32 = @intCast(si * SUBCHUNK_SIZE); + const y1: i32 = y0 + SUBCHUNK_SIZE; + + var y: i32 = y0; + while (y < y1) : (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.getBlockSafe(@intCast(x), y, @intCast(z)); + const def = block_registry.getBlockDefinition(block); + if (def.render_shape != .custom_mesh) continue; + + const xi: i32 = @intCast(x); + const zi: i32 = @intCast(z); + const light = sampleCustomLight(chunk, neighbors, xi, y, zi); + const entrance_bounce = sampleCustomEntranceBounce(chunk, neighbors, xi, y, zi); + const entrance_dir = boundary.getEntranceDirCross(chunk, neighbors, xi, y, zi); + const norm_light = lighting_sampler.normalizeLightValues(light, entrance_bounce, entrance_dir); + const color = biome_color_sampler.getBlockColor(chunk, neighbors, .top, y + 1, x, z, block); + + const xf: f32 = @floatFromInt(x); + const yf: f32 = @floatFromInt(y); + const zf: f32 = @floatFromInt(z); + const tiles = atlas.getTilesForBlock(@intFromEnum(block)); + + switch (def.render_shape_data.custom_mesh) { + .slab => try emitBox(allocator, solid_list, .{ .min = .{ xf, yf, zf }, .max = .{ xf + 1, yf + 0.5, zf + 1 } }, color, norm_light, tiles), + .stairs => { + try emitBox(allocator, solid_list, .{ .min = .{ xf, yf, zf }, .max = .{ xf + 1, yf + 0.5, zf + 1 } }, color, norm_light, tiles); + try emitBox(allocator, solid_list, .{ .min = .{ xf, yf + 0.5, zf + 0.5 }, .max = .{ xf + 1, yf + 1, zf + 1 } }, color, norm_light, tiles); + }, + .none, .door, .fence => {}, + } + } + } + } +} + +fn sampleCustomLight(chunk: *const Chunk, neighbors: NeighborChunks, x: i32, y: i32, z: i32) PackedLight { + var result = PackedLight.init(0, 0); + var oy: i32 = 0; + while (oy <= 1) : (oy += 1) { + var ox: i32 = -1; + while (ox <= 1) : (ox += 1) { + var oz: i32 = -1; + while (oz <= 1) : (oz += 1) { + const light = boundary.getLightCross(chunk, neighbors, x + ox, y + oy, z + oz); + result.sky_light = @max(result.sky_light, light.getSkyLight()); + result.block_light_r = @max(result.block_light_r, light.getBlockLightR()); + result.block_light_g = @max(result.block_light_g, light.getBlockLightG()); + result.block_light_b = @max(result.block_light_b, light.getBlockLightB()); + } + } + } + return result; +} + +fn sampleCustomEntranceBounce(chunk: *const Chunk, neighbors: NeighborChunks, x: i32, y: i32, z: i32) u4 { + var result: u4 = 0; + var oy: i32 = 0; + while (oy <= 1) : (oy += 1) { + var ox: i32 = -1; + while (ox <= 1) : (ox += 1) { + var oz: i32 = -1; + while (oz <= 1) : (oz += 1) { + result = @max(result, boundary.getEntranceBounceCross(chunk, neighbors, x + ox, y + oy, z + oz)); + } + } + } + return result; +} + +fn emitBox( + allocator: std.mem.Allocator, + verts: *std.ArrayListUnmanaged(Vertex), + box: Box, + color: [3]f32, + light: lighting_sampler.NormalizedLight, + tiles: TextureAtlas.BlockTiles, +) !void { + const x0 = box.min[0]; + const y0 = box.min[1]; + const z0 = box.min[2]; + const x1 = box.max[0]; + const y1 = box.max[1]; + const z1 = box.max[2]; + + const quads = [_]FaceQuad{ + .{ .positions = .{ .{ x0, y1, z1 }, .{ x1, y1, z1 }, .{ x1, y1, z0 }, .{ x0, y1, z0 } }, .normal = .{ 0, 1, 0 }, .tile_id = tiles.top }, + .{ .positions = .{ .{ x0, y0, z0 }, .{ x1, y0, z0 }, .{ x1, y0, z1 }, .{ x0, y0, z1 } }, .normal = .{ 0, -1, 0 }, .tile_id = tiles.bottom }, + .{ .positions = .{ .{ x0, y0, z0 }, .{ x0, y0, z1 }, .{ x0, y1, z1 }, .{ x0, y1, z0 } }, .normal = .{ -1, 0, 0 }, .tile_id = tiles.side }, + .{ .positions = .{ .{ x1, y0, z1 }, .{ x1, y0, z0 }, .{ x1, y1, z0 }, .{ x1, y1, z1 } }, .normal = .{ 1, 0, 0 }, .tile_id = tiles.side }, + .{ .positions = .{ .{ x1, y0, z0 }, .{ x0, y0, z0 }, .{ x0, y1, z0 }, .{ x1, y1, z0 } }, .normal = .{ 0, 0, -1 }, .tile_id = tiles.side }, + .{ .positions = .{ .{ x0, y0, z1 }, .{ x1, y0, z1 }, .{ x1, y1, z1 }, .{ x0, y1, z1 } }, .normal = .{ 0, 0, 1 }, .tile_id = tiles.side }, + }; + + for (quads) |quad| { + try emitQuad(allocator, verts, quad, color, light); + } +} + +fn emitQuad( + allocator: std.mem.Allocator, + verts: *std.ArrayListUnmanaged(Vertex), + quad: FaceQuad, + color: [3]f32, + light: lighting_sampler.NormalizedLight, +) !void { + const uv = [4][2]f32{ .{ 0, 1 }, .{ 1, 1 }, .{ 1, 0 }, .{ 0, 0 } }; + const idx = [6]usize{ 0, 1, 2, 0, 2, 3 }; + const ao: f32 = 1.0; + + for (idx) |i| { + try verts.append(allocator, Vertex.initWithEntrance(quad.positions[i], color, quad.normal, uv[i], quad.tile_id, light.skylight, light.blocklight, ao, light.entrance_bounce, light.entrance_dir)); + } +} diff --git a/modules/world-meshing/src/root.zig b/modules/world-meshing/src/root.zig index b18a3980..00af1266 100644 --- a/modules/world-meshing/src/root.zig +++ b/modules/world-meshing/src/root.zig @@ -14,6 +14,7 @@ pub const meshing = struct { pub const boundary = @import("meshing/boundary.zig"); pub const boundary_tests = @import("meshing/boundary_tests.zig"); pub const cross_mesher = @import("meshing/cross_mesher.zig"); + pub const custom_mesh_mesher = @import("meshing/custom_mesh_mesher.zig"); pub const greedy_mesher = @import("meshing/greedy_mesher.zig"); pub const lighting_sampler = @import("meshing/lighting_sampler.zig"); pub const quadric_simplifier = @import("meshing/quadric_simplifier.zig");