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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions docs/block-roadmap-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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`.

Expand Down Expand Up @@ -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.
4 changes: 2 additions & 2 deletions docs/roadmap/block-id-capacity.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ 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

Keep block IDs as `u8` in the near term. This keeps chunk storage compact and avoids adding palette/remapping complexity before the project needs it.

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.
2 changes: 2 additions & 0 deletions modules/world-core/src/block.zig
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ pub const BlockType = enum(u8) {
coral_block = 74,
coral_fan = 75,
tall_seagrass = 76,
stone_slab = 77,
stone_stairs = 78,

_,
};
Expand Down
19 changes: 16 additions & 3 deletions modules/world-core/src/block_registry.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 => {},
}

Expand Down Expand Up @@ -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 },
};

Expand Down Expand Up @@ -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,
};

Expand All @@ -432,6 +443,8 @@ pub const BLOCK_REGISTRY = blk: {
.allowed_faces = AttachmentFaces.walls(),
},
},
.stone_slab => .{ .custom_mesh = .slab },
.stone_stairs => .{ .custom_mesh = .stairs },
else => .{},
};

Expand Down
26 changes: 26 additions & 0 deletions modules/world-core/src/block_registry_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion modules/world-meshing/src/chunk_mesh.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
34 changes: 34 additions & 0 deletions modules/world-meshing/src/chunk_mesh_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
160 changes: 160 additions & 0 deletions modules/world-meshing/src/meshing/custom_mesh_mesher.zig
Original file line number Diff line number Diff line change
@@ -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));
}
}
Loading
Loading