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
34 changes: 29 additions & 5 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const BuildOptions = struct {
options: *std.Build.Step.Options,
engine_ui_options: *std.Build.Step.Options,
world_lod_options: *std.Build.Step.Options,
worldgen_overworld_options: *std.Build.Step.Options,
world_runtime_options: *std.Build.Step.Options,
engine_graphics_options: *std.Build.Step.Options,
enable_debug_shadows: bool,
Expand Down Expand Up @@ -91,8 +92,6 @@ fn defineModules(
const world_lod_options = opts.world_lod_options;
const world_runtime_options = opts.world_runtime_options;
const engine_graphics_options = opts.engine_graphics_options;
const chunk_debug_mode = opts.chunk_debug_mode;
const chunk_debug_enable = opts.chunk_debug_enable;
const shadow_test_variant = opts.shadow_test_variant;
const sanitize_c = opts.sanitize_c;

Expand Down Expand Up @@ -336,9 +335,7 @@ fn defineModules(
worldgen_api.addImport("world-core", world_core);
addSharedImports(worldgen_common, zig_math, zig_noise, fs_module, sync_module, c_module, options);
worldgen_common.addImport("world-core", world_core);
const worldgen_overworld_options = b.addOptions();
worldgen_overworld_options.addOption(bool, "chunk_debug_mode", chunk_debug_mode);
worldgen_overworld_options.addOption([]const u8, "chunk_debug_enable", chunk_debug_enable);
const worldgen_overworld_options = opts.worldgen_overworld_options;
addSharedImports(worldgen_overworld, zig_math, zig_noise, fs_module, sync_module, c_module, options);
worldgen_overworld.addImport("engine-core", engine_core);
worldgen_overworld.addImport("engine-rhi", engine_rhi);
Expand Down Expand Up @@ -737,6 +734,28 @@ fn defineBuildSteps(
run_world_lod_tests.setEnvironmentVariable("ZIGCRAFT_LOG_LEVEL", "fatal");
test_step.dependOn(&run_world_lod_tests.step);

const worldgen_overworld_test_root = b.createModule(.{
.root_source_file = b.path("modules/worldgen-overworld/src/tests.zig"),
.target = target,
.optimize = optimize,
.sanitize_c = sanitize_c,
});
addSharedImports(worldgen_overworld_test_root, modules.zig_math, modules.zig_noise, modules.fs_module, modules.sync_module, modules.c_module, options);
worldgen_overworld_test_root.addImport("engine-core", modules.engine_core);
worldgen_overworld_test_root.addImport("engine-rhi", modules.engine_rhi);
worldgen_overworld_test_root.addImport("world-core", modules.world_core);
worldgen_overworld_test_root.addImport("worldgen-api", modules.worldgen_api);
worldgen_overworld_test_root.addImport("worldgen-common", modules.worldgen_common);
worldgen_overworld_test_root.addOptions("worldgen_overworld_options", opts.worldgen_overworld_options);

const worldgen_overworld_tests = b.addTest(.{
.root_module = worldgen_overworld_test_root,
.filters = test_filters,
});
const run_worldgen_overworld_tests = addRunArtifact(b, worldgen_overworld_tests);
run_worldgen_overworld_tests.setEnvironmentVariable("ZIGCRAFT_LOG_LEVEL", "fatal");
test_step.dependOn(&run_worldgen_overworld_tests.step);

const benchmark_phase5_gate_root = b.createModule(.{
.root_source_file = b.path("phase5_benchmark_gate_tests.zig"),
.target = target,
Expand Down Expand Up @@ -1016,6 +1035,10 @@ fn defineBuildOptions(b: *std.Build, optimize: std.builtin.OptimizeMode) BuildOp
world_runtime_options.addOption(u32, "startup_diagnostic_seconds", startup_diagnostic_seconds);
world_runtime_options.addOption(bool, "world_runtime_module", true);

const worldgen_overworld_options = b.addOptions();
worldgen_overworld_options.addOption(bool, "chunk_debug_mode", chunk_debug_mode);
worldgen_overworld_options.addOption([]const u8, "chunk_debug_enable", chunk_debug_enable);

const skip_present = b.option(bool, "skip-present", "Skip presentation (headless mode) to avoid driver crashes") orelse false;
options.addOption(bool, "skip_present", skip_present);

Expand Down Expand Up @@ -1117,6 +1140,7 @@ fn defineBuildOptions(b: *std.Build, optimize: std.builtin.OptimizeMode) BuildOp
.options = options,
.engine_ui_options = engine_ui_options,
.world_lod_options = world_lod_options,
.worldgen_overworld_options = worldgen_overworld_options,
.world_runtime_options = world_runtime_options,
.engine_graphics_options = engine_graphics_options,
.enable_debug_shadows = enable_debug_shadows,
Expand Down
234 changes: 196 additions & 38 deletions modules/game-core/src/map_controller.zig

Large diffs are not rendered by default.

16 changes: 10 additions & 6 deletions modules/game-core/src/session.zig
Original file line number Diff line number Diff line change
Expand Up @@ -218,12 +218,15 @@ pub const GameSession = struct {
});
errdefer world.deinit();

var world_map = try WorldMap.init(allocator, 256, 256);
// Keep source texels near 1:1 with the fullscreen map on common
// displays. At the default zoom this also samples one world block per
// texel, so individual builds and canopy shapes remain visible.
var world_map = try WorldMap.init(allocator, 1024, 1024);
errdefer world_map.deinit();

var world_map_texture = try Texture.initEmpty(rhi.resourceManager(), world_map.width, world_map.height, .rgba, .{
.min_filter = .nearest,
.mag_filter = .nearest,
.min_filter = .linear,
.mag_filter = .linear,
.generate_mipmaps = false,
.wrap_s = .clamp_to_edge,
.wrap_t = .clamp_to_edge,
Expand Down Expand Up @@ -300,9 +303,10 @@ pub const GameSession = struct {
pub fn deinit(self: *GameSession) void {
self.ecs_render_system.deinit();
self.ecs_registry.deinit();
// The map worker reads the generator, so it must join before World.
self.world_map.deinit();
self.world.interface().deinit();
self.world_map_texture.deinit();
self.world_map.deinit();
self.block_outline.deinit();
self.hand_renderer.deinit();
self.allocator.destroy(self);
Expand Down Expand Up @@ -339,7 +343,7 @@ pub const GameSession = struct {
input.setMouseCapture(@ptrCast(@alignCast(window)), !self.inventory_ui_state.visible);
}

if (!self.inventory_ui_state.visible) {
if (!self.inventory_ui_state.visible and !self.map_controller.show_map) {
if (mapper.isActionPressed(input, .slot_1)) self.inventory.selectSlot(0);
if (mapper.isActionPressed(input, .slot_2)) self.inventory.selectSlot(1);
if (mapper.isActionPressed(input, .slot_3)) self.inventory.selectSlot(2);
Expand All @@ -355,7 +359,7 @@ pub const GameSession = struct {
}
}

self.map_controller.update(input, mapper, &self.camera, dt, window, screen_w, screen_h, self.world_map.width);
self.map_controller.update(input, mapper, &self.camera, dt, window, screen_w, screen_h);

if (self.map_controller.show_map) {
// map open – skip player/world update
Expand Down
2 changes: 1 addition & 1 deletion modules/game-core/src/ui/session_hud.zig
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ pub fn draw(session: anytype, ui: *UISystem, atlas: *const TextureAtlas, active_
const telemetry = world.telemetry();

if (session.map_controller.show_map) {
try session.map_controller.draw(ui, screen_w, screen_h, &session.world_map, &session.world_map_texture, telemetry.getGenerator(), session.camera.position);
try session.map_controller.draw(ui, screen_w, screen_h, &session.world_map, &session.world_map_texture, session.world, telemetry.getGenerator(), session.camera.position);
return;
}

Expand Down
40 changes: 40 additions & 0 deletions modules/world-core/src/chunk.zig
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ pub const Chunk = struct {
light: [CHUNK_VOLUME]PackedLight,
biomes: [CHUNK_SIZE_X * CHUNK_SIZE_Z]BiomeId,
heightmap: [CHUNK_SIZE_X * CHUNK_SIZE_Z]i16,
/// Highest live non-air block per column for maps and other surface views.
/// Unlike `heightmap`, this includes water, leaves, logs, and foliage.
map_surface_blocks: [CHUNK_SIZE_X * CHUNK_SIZE_Z]BlockType,
map_surface_heights: [CHUNK_SIZE_X * CHUNK_SIZE_Z]i16,
map_surface_revision: u64 = 0,
state: State = .missing,
job_token: u32 = 0,
/// Monotonic mesh-input revisions. They are updated while storage owns the chunk.
Expand All @@ -59,6 +64,8 @@ pub const Chunk = struct {
.light = [_]PackedLight{PackedLight.init(0, 0)} ** CHUNK_VOLUME,
.biomes = [_]BiomeId{.plains} ** (CHUNK_SIZE_X * CHUNK_SIZE_Z),
.heightmap = [_]i16{0} ** (CHUNK_SIZE_X * CHUNK_SIZE_Z),
.map_surface_blocks = [_]BlockType{.air} ** (CHUNK_SIZE_X * CHUNK_SIZE_Z),
.map_surface_heights = [_]i16{-1} ** (CHUNK_SIZE_X * CHUNK_SIZE_Z),
.state = .missing,
.pin_count = std.atomic.Value(u32).init(0),
};
Expand Down Expand Up @@ -216,6 +223,39 @@ pub const Chunk = struct {
return 0;
}

/// Rebuilds the live top-surface cache in one pass over block storage.
/// Returns the total number of non-air blocks, allowing generation
/// publication to reuse this scan for empty-chunk validation.
pub fn rebuildMapSurface(self: *Chunk) u32 {
@memset(&self.map_surface_blocks, .air);
@memset(&self.map_surface_heights, -1);

var non_air_count: u32 = 0;
var y: i32 = CHUNK_SIZE_Y - 1;
while (y >= 0) : (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 = self.getBlock(x, @intCast(y), z);
if (block == .air) continue;
non_air_count += 1;
const column = x + z * CHUNK_SIZE_X;
if (self.map_surface_heights[column] < 0) {
self.map_surface_blocks[column] = block;
self.map_surface_heights[column] = @intCast(y);
}
}
}
}
self.map_surface_revision = self.content_revision.load(.acquire);
return non_air_count;
}

pub fn mapSurfaceIsCurrent(self: *const Chunk) bool {
return self.map_surface_revision == self.content_revision.load(.acquire);
}

/// Increments the async-work pin count for this chunk.
/// Streamers must not unload pinned chunks while worker jobs may still read them.
pub fn pin(self: *Chunk) void {
Expand Down
28 changes: 28 additions & 0 deletions modules/world-core/src/chunk_extended_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,31 @@ test "Chunk.setBlockLight and getBlockLight" {
chunk.setBlockLight(5, 64, 5, 7);
try testing.expectEqual(@as(u4, 7), chunk.getBlockLight(5, 64, 5));
}

test "Chunk live map surface includes foliage and reacts to removal" {
var chunk = Chunk.init(0, 0);
chunk.setBlock(3, 64, 5, .grass);
chunk.setBlock(3, 70, 5, .leaves);

try testing.expectEqual(@as(u32, 2), chunk.rebuildMapSurface());
const column = 3 + 5 * CHUNK_SIZE_X;
try testing.expectEqual(BlockType.leaves, chunk.map_surface_blocks[column]);
try testing.expectEqual(@as(i16, 70), chunk.map_surface_heights[column]);
try testing.expect(chunk.mapSurfaceIsCurrent());

chunk.setBlock(3, 70, 5, .air);
try testing.expect(!chunk.mapSurfaceIsCurrent());
_ = chunk.rebuildMapSurface();
try testing.expectEqual(BlockType.grass, chunk.map_surface_blocks[column]);
try testing.expectEqual(@as(i16, 64), chunk.map_surface_heights[column]);
}

test "Chunk live map surface includes water" {
var chunk = Chunk.init(0, 0);
chunk.setBlock(8, 60, 8, .sand);
chunk.setBlock(8, 64, 8, .water);
_ = chunk.rebuildMapSurface();
const column = 8 + 8 * CHUNK_SIZE_X;
try testing.expectEqual(BlockType.water, chunk.map_surface_blocks[column]);
try testing.expectEqual(@as(i16, 64), chunk.map_surface_heights[column]);
}
11 changes: 11 additions & 0 deletions modules/world-meshing/src/chunk_storage.zig
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ pub const ChunkStorage = struct {
lighting_mutex: sync.Mutex,
allocator: std.mem.Allocator,
next_job_token: u32,
map_surface_revision: std.atomic.Value(u64),

pub fn init(allocator: std.mem.Allocator) ChunkStorage {
return .{
Expand All @@ -82,6 +83,7 @@ pub const ChunkStorage = struct {
.lighting_mutex = .{},
.allocator = allocator,
.next_job_token = 1,
.map_surface_revision = .init(0),
};
}

Expand Down Expand Up @@ -147,6 +149,14 @@ pub const ChunkStorage = struct {
return self.chunks.count();
}

pub fn markMapSurfaceChanged(self: *ChunkStorage) void {
_ = self.map_surface_revision.fetchAdd(1, .release);
}

pub fn getMapSurfaceRevision(self: *const ChunkStorage) u64 {
return self.map_surface_revision.load(.acquire);
}

/// Sum total vertex count across all chunk meshes
pub fn totalVertexCount(self: *ChunkStorage) u64 {
self.chunks_mutex.lockShared();
Expand Down Expand Up @@ -231,6 +241,7 @@ pub const ChunkStorage = struct {
} else {
self.allocator.destroy(entry.value);
}
self.markMapSurfaceChanged();
return true;
}
return false;
Expand Down
11 changes: 5 additions & 6 deletions modules/world-runtime/src/chunk_queue_coordinator.zig
Original file line number Diff line number Diff line change
Expand Up @@ -587,12 +587,10 @@ pub const ChunkQueueCoordinator = struct {
// Validate worker-owned block data before taking the global storage
// writer lock. Scanning all 65,536 blocks under that lock serialized
// otherwise independent generation completions and render access.
var non_air_count: u32 = 0;
if (chunk_data.chunk.generated) {
for (chunk_data.chunk.blocks) |block| {
if (block != .air) non_air_count += 1;
}
}
const non_air_count = if (chunk_data.chunk.generated)
chunk_data.chunk.rebuildMapSurface()
else
0;

self.storage.chunks_mutex.lock();
const publishable = if (self.storage.chunks.get(ChunkKey{ .x = cx, .z = cz })) |data|
Expand All @@ -613,6 +611,7 @@ pub const ChunkQueueCoordinator = struct {
chunk_data.chunk.state = .missing;
} else {
chunk_data.chunk.state = .generated;
self.storage.markMapSurfaceChanged();
_ = self.chunks_generated_total.fetchAdd(1, .monotonic);
}
}
Expand Down
62 changes: 62 additions & 0 deletions modules/world-runtime/src/world.zig
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const ChunkStorage = world_meshing.ChunkStorage;
const ChunkData = world_meshing.ChunkData;
const gen_interface = @import("world-worldgen");
const Generator = gen_interface.Generator;
const WorldMap = gen_interface.WorldMap;
const registry = @import("world-worldgen").registry;
const rhi_mod = @import("engine-rhi").rhi;
const RHI = rhi_mod.RHI;
Expand Down Expand Up @@ -624,6 +625,7 @@ pub const World = struct {
paused: bool = false,
safe_mode: bool,
safe_render_distance: i32,
map_mutation_revision: std.atomic.Value(u64) = .init(0),

// LOD System (Issue #114, #293)
lod: ?*WorldLOD,
Expand Down Expand Up @@ -679,6 +681,7 @@ pub const World = struct {
.paused = false,
.safe_mode = safe_mode,
.safe_render_distance = safe_render_distance,
.map_mutation_revision = .init(0),
.lod = null,
.lod_enabled = false,
.save_manager = null,
Expand Down Expand Up @@ -979,6 +982,8 @@ pub const World = struct {
/// Updates chunk data and schedules affected mesh/render state refreshes. Propagates errors from streaming, persistence, meshing, or mutation subsystems.
pub fn setBlock(self: *World, world_x: i32, world_y: i32, world_z: i32, block: BlockType) !void {
const result = (try self.mutation.applyBlockMutation(world_x, world_y, world_z, block)) orelse return;
self.storage.markMapSurfaceChanged();
_ = self.map_mutation_revision.fetchAdd(1, .release);
self.streamer.enqueueMutationLighting(&self.mutation, result) catch |err| {
log.log.warn("Failed to enqueue block lighting update, applying synchronously: {}", .{err});
try self.mutation.updateLighting(result);
Expand All @@ -992,6 +997,63 @@ pub const World = struct {
}
}

pub fn getMapSurfaceRevision(self: *const World) u64 {
return self.map_mutation_revision.load(.acquire);
}

pub fn getMapResidencyRevision(self: *const World) u64 {
return self.storage.getMapSurfaceRevision();
}

/// Copies actual loaded top surfaces for a map viewport. The map worker
/// receives only immutable values, never live chunk pointers.
pub fn captureLoadedMapSurface(
self: *World,
overlay: *WorldMap.LoadedSurfaceOverlay,
center_x: f32,
center_z: f32,
scale: f32,
width: u32,
height: u32,
) !void {
const half_width = @as(f32, @floatFromInt(width)) * scale * 0.5;
const half_height = @as(f32, @floatFromInt(height)) * scale * 0.5;
const min_world_x: i32 = @intFromFloat(@floor(center_x - half_width));
const max_world_x: i32 = @intFromFloat(@ceil(center_x + half_width));
const min_world_z: i32 = @intFromFloat(@floor(center_z - half_height));
const max_world_z: i32 = @intFromFloat(@ceil(center_z + half_height));
const min_chunk = worldToChunk(min_world_x, min_world_z);
const max_chunk = worldToChunk(max_world_x, max_world_z);

try overlay.ensureUnusedCapacity(self.storage.count());

// Match mutation/lighting lock order. Generated chunks are immutable
// for this short copy except for mutations, which lighting_mutex blocks.
self.storage.lighting_mutex.lock();
self.storage.chunks_mutex.lockShared();

var iterator = self.storage.iteratorUnsafe();
while (iterator.next()) |entry| {
const key = entry.key_ptr.*;
if (key.x < min_chunk.chunk_x or key.x > max_chunk.chunk_x or key.z < min_chunk.chunk_z or key.z > max_chunk.chunk_z) continue;
const chunk = &entry.value_ptr.*.chunk;
// Generation rebuilds the cached surface before publishing the
// chunk as generated. Skip chunks still owned by a worker so this
// shared-lock copy never races that unlocked rebuild.
if (!chunk.generated or chunk.state != .generated) continue;
if (!chunk.mapSurfaceIsCurrent()) _ = chunk.rebuildMapSurface();
overlay.appendAssumeCapacity(.{
.chunk_x = key.x,
.chunk_z = key.z,
.heights = chunk.map_surface_heights,
.blocks = chunk.map_surface_blocks,
});
}
self.storage.chunks_mutex.unlockShared();
self.storage.lighting_mutex.unlock();
overlay.finish();
}

/// Get chunk data at chunk coordinates.
/// WARNING: Returned pointer is only guaranteed valid if called from the main thread
/// and used before the next call to World.update (which may unload chunks).
Expand Down
Loading
Loading