diff --git a/build.zig b/build.zig index b856c69d..cae6bdf9 100644 --- a/build.zig +++ b/build.zig @@ -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, @@ -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; @@ -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); @@ -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, @@ -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); @@ -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, diff --git a/modules/game-core/src/map_controller.zig b/modules/game-core/src/map_controller.zig index a3f60f08..2293aca3 100644 --- a/modules/game-core/src/map_controller.zig +++ b/modules/game-core/src/map_controller.zig @@ -11,6 +11,7 @@ const Texture = @import("engine-rhi").Texture; const Font = @import("engine-ui").font; const log = @import("engine-core").log; const Vec3 = @import("engine-math").Vec3; +const World = @import("world-runtime").World; const input_mapper_pkg = @import("input_mapper.zig"); const InputMapper = input_mapper_pkg.InputMapper; @@ -20,8 +21,13 @@ const GameAction = input_mapper_pkg.GameAction; pub const MapController = struct { const MIN_ZOOM: f32 = 0.05; const MAX_ZOOM: f32 = 128.0; - const MAP_SCREEN_FRACTION: f32 = 0.82; - const MAP_PADDING: f32 = 28.0; + const MAP_REFERENCE_SIZE: f32 = 256.0; + const MAP_SCREEN_FRACTION: f32 = 0.96; + const MAP_PADDING: f32 = 16.0; + const KEYBOARD_PAN_SPEED: f32 = 320.0; + const DRAG_SENSITIVITY: f32 = 1.0; + const KEY_ZOOM_RATE: f32 = 2.0; + const WHEEL_ZOOM_RATE: f32 = 0.28; pub const MapRect = struct { x: f32, @@ -48,8 +54,16 @@ pub const MapController = struct { vel_x: f32 = 0.0, vel_z: f32 = 0.0, is_dragging: bool = false, - - pub fn update(self: *MapController, input: IRawInputProvider, mapper: IInputMapper, camera: *const Camera, time_delta: f32, window: *c.SDL_Window, screen_w: f32, screen_h: f32, world_map_width: u32) void { + texture_center_x: f32 = 0.0, + texture_center_z: f32 = 0.0, + texture_scale: f32 = 1.0, + has_texture_view: bool = false, + last_surface_revision: u64 = 0, + observed_residency_revision: u64 = 0, + captured_residency_revision: u64 = 0, + residency_stable_frames: u8 = 0, + + pub fn update(self: *MapController, input: IRawInputProvider, mapper: IInputMapper, camera: *const Camera, time_delta: f32, window: *c.SDL_Window, screen_w: f32, screen_h: f32) void { if (mapper.isActionPressed(input, .toggle_map)) { self.toggle(camera.position, input, window); } @@ -59,30 +73,60 @@ pub const MapController = struct { const dt = @min(time_delta, 0.05); const rect = getMapRect(screen_w, screen_h); - self.handleZoom(input, mapper, dt); + self.handleZoom(input, mapper, dt, rect); if (mapper.isActionPressed(input, .map_center)) { self.recenter(camera.position); } - self.handlePan(input, mapper, dt, rect.size, world_map_width); + self.handlePan(input, mapper, dt, rect.size); self.smoothView(dt); } - pub fn draw(self: *MapController, u: *UISystem, screen_w: f32, screen_h: f32, world_map: *WorldMap, world_map_texture: *const Texture, generator: Generator, camera_pos: Vec3) !void { + pub fn draw(self: *MapController, u: *UISystem, screen_w: f32, screen_h: f32, world_map: *WorldMap, world_map_texture: *const Texture, world: *World, generator: Generator, camera_pos: Vec3) !void { if (!self.show_map) return; + const surface_revision = world.getMapSurfaceRevision(); + if (surface_revision != self.last_surface_revision) self.map_needs_update = true; + const residency_revision = world.getMapResidencyRevision(); + if (residency_revision != self.observed_residency_revision) { + self.observed_residency_revision = residency_revision; + self.residency_stable_frames = 0; + } else if (self.residency_stable_frames < 15) { + self.residency_stable_frames += 1; + } + // Debounce streaming churn so refinement is not cancelled for every + // arriving chunk, then refresh once the loaded set has settled. + if (self.residency_stable_frames >= 15 and self.captured_residency_revision != residency_revision) { + self.map_needs_update = true; + } + if (self.map_needs_update) { - world_map.update(generator, self.map_pos_x, self.map_pos_z, self.map_zoom); - try world_map_texture.update(world_map.pixels); + const sample_scale = textureSamplingScale(self.map_zoom, world_map.width); + const overlay = try world_map.createLoadedSurfaceOverlay(); + world.captureLoadedMapSurface(overlay, self.map_pos_x, self.map_pos_z, sample_scale, world_map.width, world_map.height) catch |err| { + overlay.deinit(); + return err; + }; + try world_map.requestUpdate(generator, self.map_pos_x, self.map_pos_z, sample_scale, overlay); self.map_needs_update = false; + self.last_surface_revision = surface_revision; + self.captured_residency_revision = residency_revision; + } + if (world_map.consumeCompleted()) |view| { + try world_map_texture.update(world_map.pixels); + self.texture_center_x = view.center_x; + self.texture_center_z = view.center_z; + self.texture_scale = view.scale; + self.has_texture_view = true; } const rect = getMapRect(screen_w, screen_h); self.drawBackdrop(u, screen_w, screen_h); self.drawFrame(u, rect); - u.drawTexture(@intCast(world_map_texture.handle), .{ .x = rect.x, .y = rect.y, .width = rect.size, .height = rect.size }); + self.drawMapTexture(u, rect, world_map_texture, world_map.width); self.drawGrid(u, rect); + self.drawScaleBar(u, rect); self.drawPlayerMarker(u, rect, world_map.width, world_map.height, camera_pos); self.drawHeader(u, rect); self.drawFooter(u, rect, camera_pos); @@ -122,28 +166,43 @@ pub const MapController = struct { self.map_needs_update = true; } - fn handleZoom(self: *MapController, input: IRawInputProvider, mapper: IInputMapper, dt: f32) void { + fn handleZoom(self: *MapController, input: IRawInputProvider, mapper: IInputMapper, dt: f32, rect: MapRect) void { const before = self.map_target_zoom; - if (mapper.isActionActive(input, .map_zoom_in)) self.map_target_zoom /= @exp(3.0 * dt); - if (mapper.isActionActive(input, .map_zoom_out)) self.map_target_zoom *= @exp(3.0 * dt); + if (mapper.isActionActive(input, .map_zoom_in)) self.map_target_zoom /= @exp(KEY_ZOOM_RATE * dt); + if (mapper.isActionActive(input, .map_zoom_out)) self.map_target_zoom *= @exp(KEY_ZOOM_RATE * dt); const scroll_y = input.getScrollDelta().y; - if (scroll_y != 0) self.map_target_zoom /= @exp(scroll_y * 0.45); + if (scroll_y != 0) self.map_target_zoom /= @exp(scroll_y * WHEEL_ZOOM_RATE); self.map_target_zoom = std.math.clamp(self.map_target_zoom, MIN_ZOOM, MAX_ZOOM); + if (scroll_y != 0 and self.map_target_zoom != before) { + const mouse = input.getMousePosition(); + const cursor_x = @as(f32, @floatFromInt(mouse.x)) - (rect.x + rect.size * 0.5); + const cursor_z = @as(f32, @floatFromInt(mouse.y)) - (rect.y + rect.size * 0.5); + const old_world_per_pixel = screenPixelToWorldScale(self.map_zoom, rect.size); + const new_world_per_pixel = screenPixelToWorldScale(self.map_target_zoom, rect.size); + self.map_pos_x += cursor_x * (old_world_per_pixel - new_world_per_pixel); + self.map_pos_z += cursor_z * (old_world_per_pixel - new_world_per_pixel); + self.map_target_pos_x = self.map_pos_x; + self.map_target_pos_z = self.map_pos_z; + self.map_zoom = self.map_target_zoom; + self.vel_x = 0; + self.vel_z = 0; + } if (self.map_target_zoom != before) self.map_needs_update = true; } - fn handlePan(self: *MapController, input: IRawInputProvider, mapper: IInputMapper, dt: f32, map_ui_size: f32, world_map_width: u32) void { + fn handlePan(self: *MapController, input: IRawInputProvider, mapper: IInputMapper, dt: f32, map_ui_size: f32) void { const mouse_pos = input.getMousePosition(); const mouse_x: f32 = @floatFromInt(mouse_pos.x); const mouse_y: f32 = @floatFromInt(mouse_pos.y); - const safe_dt = @max(dt, 0.001); - const pixel_world_scale = screenPixelToWorldScale(self.map_zoom, map_ui_size, world_map_width); + const pixel_world_scale = screenPixelToWorldScale(self.map_zoom, map_ui_size); if (input.isMouseButtonPressed(.left)) { self.last_mouse_x = mouse_x; self.last_mouse_y = mouse_y; + self.map_pos_x = self.map_target_pos_x; + self.map_pos_z = self.map_target_pos_z; self.is_dragging = true; self.vel_x = 0; self.vel_z = 0; @@ -153,12 +212,14 @@ pub const MapController = struct { const drag_dx = mouse_x - self.last_mouse_x; const drag_dz = mouse_y - self.last_mouse_y; if (@abs(drag_dx) > 0.5 or @abs(drag_dz) > 0.5) { - const pan_dx = drag_dx * pixel_world_scale; - const pan_dz = drag_dz * pixel_world_scale; + const pan_dx = drag_dx * pixel_world_scale * DRAG_SENSITIVITY; + const pan_dz = drag_dz * pixel_world_scale * DRAG_SENSITIVITY; self.map_target_pos_x -= pan_dx; self.map_target_pos_z -= pan_dz; - self.vel_x = -pan_dx / safe_dt; - self.vel_z = -pan_dz / safe_dt; + self.map_pos_x -= pan_dx; + self.map_pos_z -= pan_dz; + self.vel_x = 0; + self.vel_z = 0; self.map_needs_update = true; } self.last_mouse_x = mouse_x; @@ -189,7 +250,7 @@ pub const MapController = struct { } fn applyKeyboardPan(self: *MapController, mapper: IInputMapper, input: IRawInputProvider, dt: f32) void { - const pan_kb_speed = 1600.0 * self.map_zoom; + const pan_kb_speed = KEYBOARD_PAN_SPEED * self.map_zoom; const move_vec = mapper.getMovementVector(input); if (move_vec.x == 0 and move_vec.z == 0) return; @@ -222,14 +283,93 @@ pub const MapController = struct { u.drawRectOutline(.{ .x = rect.x, .y = rect.y, .width = rect.size, .height = rect.size }, Color.white, 2.0); } - fn drawGrid(_: *MapController, u: *UISystem, rect: MapRect) void { - const grid_color = Color.rgba(1.0, 1.0, 1.0, 0.12); - var i: u32 = 1; - while (i < 4) : (i += 1) { - const offset = rect.size * @as(f32, @floatFromInt(i)) * 0.25; - u.drawRect(.{ .x = rect.x + offset, .y = rect.y, .width = 1, .height = rect.size }, grid_color); - u.drawRect(.{ .x = rect.x, .y = rect.y + offset, .width = rect.size, .height = 1 }, grid_color); + fn drawGrid(self: *MapController, u: *UISystem, rect: MapRect) void { + const world_per_pixel = screenPixelToWorldScale(self.map_zoom, rect.size); + const spacing = gridSpacing(world_per_pixel, 112.0); + const half_span = rect.size * world_per_pixel * 0.5; + const min_x = self.map_pos_x - half_span; + const max_x = self.map_pos_x + half_span; + const min_z = self.map_pos_z - half_span; + const max_z = self.map_pos_z + half_span; + const grid_color = Color.rgba(0.92, 0.96, 1.0, 0.14); + const label_color = Color.rgba(0.90, 0.95, 1.0, 0.72); + + var world_x = @floor(min_x / spacing) * spacing; + while (world_x <= max_x) : (world_x += spacing) { + const screen_x = rect.x + (world_x - min_x) / world_per_pixel; + u.drawRect(.{ .x = screen_x, .y = rect.y, .width = 1, .height = rect.size }, grid_color); + if (screen_x < rect.x + rect.size - 68) { + var label_buf: [32]u8 = undefined; + const label = std.fmt.bufPrint(&label_buf, "X {d:.0}", .{world_x}) catch "X ?"; + Font.drawText(u, label, screen_x + 4, rect.y + 5, 1.15, label_color); + } } + + var world_z = @floor(min_z / spacing) * spacing; + while (world_z <= max_z) : (world_z += spacing) { + const screen_y = rect.y + (world_z - min_z) / world_per_pixel; + u.drawRect(.{ .x = rect.x, .y = screen_y, .width = rect.size, .height = 1 }, grid_color); + if (screen_y < rect.y + rect.size - 16) { + var label_buf: [32]u8 = undefined; + const label = std.fmt.bufPrint(&label_buf, "Z {d:.0}", .{world_z}) catch "Z ?"; + Font.drawText(u, label, rect.x + 5, screen_y + 4, 1.15, label_color); + } + } + } + + fn drawScaleBar(self: *MapController, u: *UISystem, rect: MapRect) void { + const world_per_pixel = screenPixelToWorldScale(self.map_zoom, rect.size); + const world_length = gridSpacing(world_per_pixel, 120.0); + const pixel_length = world_length / world_per_pixel; + const x = rect.x + 20; + const y = rect.y + rect.size - 28; + u.drawRect(.{ .x = x - 8, .y = y - 13, .width = pixel_length + 16, .height = 31 }, Color.rgba(0.015, 0.02, 0.025, 0.72)); + u.drawRect(.{ .x = x, .y = y, .width = pixel_length, .height = 3 }, Color.white); + u.drawRect(.{ .x = x, .y = y - 4, .width = 2, .height = 11 }, Color.white); + u.drawRect(.{ .x = x + pixel_length - 2, .y = y - 4, .width = 2, .height = 11 }, Color.white); + + var label_buf: [32]u8 = undefined; + const label = std.fmt.bufPrint(&label_buf, "{d:.0} blocks", .{world_length}) catch "? blocks"; + Font.drawText(u, label, x + 4, y - 13, 1.15, Color.white); + } + + fn gridSpacing(world_per_pixel: f32, target_pixels: f32) f32 { + const target_world = @max(world_per_pixel * target_pixels, 1.0); + var spacing: f32 = 1.0; + while (spacing < target_world) spacing *= 2.0; + return spacing; + } + + fn drawMapTexture(self: *MapController, u: *UISystem, rect: MapRect, texture: *const Texture, texture_width: u32) void { + // Unknown coverage uses a map-like ocean tint rather than flashing to + // black while the tiny progressive preview is being generated. + u.drawRect(.{ .x = rect.x, .y = rect.y, .width = rect.size, .height = rect.size }, Color.rgba(0.025, 0.10, 0.14, 1.0)); + if (!self.has_texture_view) return; + + const current_scale = textureSamplingScale(self.map_zoom, texture_width); + const texture_width_f: f32 = @floatFromInt(texture_width); + const world_per_screen_pixel = current_scale * texture_width_f / rect.size; + const draw_size = rect.size * self.texture_scale / current_scale; + const transformed_x = rect.x + rect.size * 0.5 + (self.texture_center_x - self.map_pos_x) / world_per_screen_pixel - draw_size * 0.5; + const transformed_y = rect.y + rect.size * 0.5 + (self.texture_center_z - self.map_pos_z) / world_per_screen_pixel - draw_size * 0.5; + + const visible_x0 = @max(rect.x, transformed_x); + const visible_y0 = @max(rect.y, transformed_y); + const visible_x1 = @min(rect.x + rect.size, transformed_x + draw_size); + const visible_y1 = @min(rect.y + rect.size, transformed_y + draw_size); + if (visible_x1 <= visible_x0 or visible_y1 <= visible_y0) return; + + u.drawTextureRegion( + @intCast(texture.handle), + .{ .x = visible_x0, .y = visible_y0, .width = visible_x1 - visible_x0, .height = visible_y1 - visible_y0 }, + .{ + .u0 = (visible_x0 - transformed_x) / draw_size, + .v0 = (visible_y0 - transformed_y) / draw_size, + .u1 = (visible_x1 - transformed_x) / draw_size, + .v1 = (visible_y1 - transformed_y) / draw_size, + }, + Color.white, + ); } fn drawPlayerMarker(self: *MapController, u: *UISystem, rect: MapRect, map_width: u32, map_height: u32, camera_pos: Vec3) void { @@ -242,11 +382,12 @@ pub const MapController = struct { } fn drawHeader(self: *MapController, u: *UISystem, rect: MapRect) void { - Font.drawText(u, "WORLD MAP", rect.x, rect.y - 58.0, 3.0, Color.white); + Font.drawText(u, "WORLD MAP", rect.x, rect.y - 48.0, 3.0, Color.white); Font.drawText(u, "Drag to pan | Scroll/+/- to zoom | Space to center | M to close", rect.x + 4.0, rect.y + rect.size + 16.0, 1.5, Color.rgba(0.78, 0.84, 0.9, 1.0)); var buf: [48]u8 = undefined; - const zoom_text = std.fmt.bufPrint(&buf, "scale: {d:.2} blocks/px", .{self.map_zoom}) catch "scale: ?"; + const screen_scale = self.map_zoom * MAP_REFERENCE_SIZE / rect.size; + const zoom_text = std.fmt.bufPrint(&buf, "scale: {d:.2} blocks/screen px", .{screen_scale}) catch "scale: ?"; Font.drawText(u, zoom_text, rect.x + rect.size - 210.0, rect.y - 38.0, 1.5, Color.rgba(0.78, 0.84, 0.9, 1.0)); } @@ -258,22 +399,29 @@ pub const MapController = struct { pub fn getMapRect(screen_w: f32, screen_h: f32) MapRect { const available_w = @max(screen_w - MAP_PADDING * 2.0, 64.0); - const available_h = @max(screen_h - 150.0, 64.0); + // Keep enough room for the title and controls while using nearly the + // full display height. Width remains square so map scale is uniform. + const available_h = @max(screen_h - 100.0, 64.0); const size = @min(@min(available_w, available_h), @min(screen_w, screen_h) * MAP_SCREEN_FRACTION); return .{ .x = (screen_w - size) * 0.5, - .y = (screen_h - size) * 0.5 + 10.0, + .y = (screen_h - size) * 0.5 + 4.0, .size = size, }; } - pub fn screenPixelToWorldScale(zoom: f32, map_ui_size: f32, world_map_width: u32) f32 { - return zoom * @as(f32, @floatFromInt(world_map_width)) / map_ui_size; + pub fn screenPixelToWorldScale(zoom: f32, map_ui_size: f32) f32 { + return zoom * MAP_REFERENCE_SIZE / map_ui_size; + } + + pub fn textureSamplingScale(zoom: f32, texture_width: u32) f32 { + return zoom * MAP_REFERENCE_SIZE / @as(f32, @floatFromInt(texture_width)); } pub fn playerMarker(self: *const MapController, rect: MapRect, map_width: u32, map_height: u32, camera_pos: Vec3) MarkerPosition { - const rx = (camera_pos.x - self.map_pos_x) / (self.map_zoom * @as(f32, @floatFromInt(map_width))); - const rz = (camera_pos.z - self.map_pos_z) / (self.map_zoom * @as(f32, @floatFromInt(map_height))); + const sample_scale = textureSamplingScale(self.map_zoom, map_width); + const rx = (camera_pos.x - self.map_pos_x) / (sample_scale * @as(f32, @floatFromInt(map_width))); + const rz = (camera_pos.z - self.map_pos_z) / (sample_scale * @as(f32, @floatFromInt(map_height))); const px = rect.x + (rx + 0.5) * rect.size; const py = rect.y + (rz + 0.5) * rect.size; @@ -295,7 +443,17 @@ test "MapController getMapRect fits display" { } test "MapController screenPixelToWorldScale includes zoom and texture ratio" { - try std.testing.expectApproxEqAbs(@as(f32, 2.0), MapController.screenPixelToWorldScale(4.0, 512.0, 256), 0.001); + try std.testing.expectApproxEqAbs(@as(f32, 2.0), MapController.screenPixelToWorldScale(4.0, 512.0), 0.001); +} + +test "MapController textureSamplingScale preserves map span at higher resolution" { + try std.testing.expectApproxEqAbs(@as(f32, 2.0), MapController.textureSamplingScale(4.0, 512), 0.001); + try std.testing.expectApproxEqAbs(@as(f32, 1.0), MapController.textureSamplingScale(4.0, 1024), 0.001); +} + +test "MapController grid spacing stays legible across zoom levels" { + try std.testing.expectEqual(@as(f32, 128), MapController.gridSpacing(1.0, 112.0)); + try std.testing.expectEqual(@as(f32, 512), MapController.gridSpacing(4.0, 112.0)); } test "MapController playerMarker centers player at map center" { diff --git a/modules/game-core/src/session.zig b/modules/game-core/src/session.zig index d5eb4b54..25b02944 100644 --- a/modules/game-core/src/session.zig +++ b/modules/game-core/src/session.zig @@ -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, @@ -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); @@ -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); @@ -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 diff --git a/modules/game-core/src/ui/session_hud.zig b/modules/game-core/src/ui/session_hud.zig index 7291fffc..81c7e2cd 100644 --- a/modules/game-core/src/ui/session_hud.zig +++ b/modules/game-core/src/ui/session_hud.zig @@ -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; } diff --git a/modules/world-core/src/chunk.zig b/modules/world-core/src/chunk.zig index 63f84ab0..ecac1916 100644 --- a/modules/world-core/src/chunk.zig +++ b/modules/world-core/src/chunk.zig @@ -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. @@ -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), }; @@ -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 { diff --git a/modules/world-core/src/chunk_extended_tests.zig b/modules/world-core/src/chunk_extended_tests.zig index cbfbcb36..9a0208b1 100644 --- a/modules/world-core/src/chunk_extended_tests.zig +++ b/modules/world-core/src/chunk_extended_tests.zig @@ -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]); +} diff --git a/modules/world-meshing/src/chunk_storage.zig b/modules/world-meshing/src/chunk_storage.zig index 9b7bf4b5..18607812 100644 --- a/modules/world-meshing/src/chunk_storage.zig +++ b/modules/world-meshing/src/chunk_storage.zig @@ -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 .{ @@ -82,6 +83,7 @@ pub const ChunkStorage = struct { .lighting_mutex = .{}, .allocator = allocator, .next_job_token = 1, + .map_surface_revision = .init(0), }; } @@ -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(); @@ -231,6 +241,7 @@ pub const ChunkStorage = struct { } else { self.allocator.destroy(entry.value); } + self.markMapSurfaceChanged(); return true; } return false; diff --git a/modules/world-runtime/src/chunk_queue_coordinator.zig b/modules/world-runtime/src/chunk_queue_coordinator.zig index 3d131f56..e3e2a612 100644 --- a/modules/world-runtime/src/chunk_queue_coordinator.zig +++ b/modules/world-runtime/src/chunk_queue_coordinator.zig @@ -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| @@ -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); } } diff --git a/modules/world-runtime/src/world.zig b/modules/world-runtime/src/world.zig index 2b3b875d..e3840f5d 100644 --- a/modules/world-runtime/src/world.zig +++ b/modules/world-runtime/src/world.zig @@ -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; @@ -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, @@ -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, @@ -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); @@ -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). diff --git a/modules/world-runtime/src/world_facade_tests.zig b/modules/world-runtime/src/world_facade_tests.zig index 3ea1884c..6e9d738f 100644 --- a/modules/world-runtime/src/world_facade_tests.zig +++ b/modules/world-runtime/src/world_facade_tests.zig @@ -588,3 +588,29 @@ test "World saveAllModifiedChunks and loadChunkFromSave round-trip chunk data" { try testing.expectEqual(world_core.BlockType.stone, loaded.getBlock(4, 64, 5)); try testing.expectEqual(world_core.BiomeId.forest, loaded.getBiome(4, 5)); } + +test "World loaded map capture reflects foliage placement and removal" { + var world = makeStorageOnlyWorld(testing.allocator); + defer deinitStorageOnlyWorld(&world); + const data = try world.storage.getOrCreate(0, 0); + data.chunk.generated = true; + data.chunk.setBlock(3, 64, 5, .grass); + data.chunk.setBlock(3, 72, 5, .leaves); + + var map = try worldgen.WorldMap.init(testing.allocator, 16, 16); + defer map.deinit(); + const first = try map.createLoadedSurfaceOverlay(); + defer first.deinit(); + try world.captureLoadedMapSurface(first, 8, 8, 1, 16, 16); + const canopy = first.sample(3, 5).?; + try testing.expectEqual(world_core.BlockType.leaves, canopy.block); + try testing.expectEqual(@as(i16, 72), canopy.height); + + data.chunk.setBlock(3, 72, 5, .air); + const second = try map.createLoadedSurfaceOverlay(); + defer second.deinit(); + try world.captureLoadedMapSurface(second, 8, 8, 1, 16, 16); + const exposed = second.sample(3, 5).?; + try testing.expectEqual(world_core.BlockType.grass, exposed.block); + try testing.expectEqual(@as(i16, 64), exposed.height); +} diff --git a/modules/worldgen-api/src/root.zig b/modules/worldgen-api/src/root.zig index 65032eaf..72c8a100 100644 --- a/modules/worldgen-api/src/root.zig +++ b/modules/worldgen-api/src/root.zig @@ -43,6 +43,37 @@ pub const ColumnInfo = struct { continentalness: f32, }; +/// The water covering a map sample's terrain surface. +/// +/// Rivers are intentionally not a separate water class: generators may expose +/// them through `MapSample.biome` and `MapSample.river_mask` instead. +pub const MapWaterClassification = enum { + /// No ocean or inland water covers the sampled terrain. + none, + /// Terrain below sea level that belongs to the ocean. + ocean, + /// Terrain below sea level that is not ocean water. + inland, +}; + +/// Stable, map-oriented data for one world column. +/// +/// `terrain_height` is the solid terrain surface, rather than a water-surface +/// height. `sea_level` is the block Y coordinate used for water classification. +/// Climate values and masks are normalized to the 0..1 range. A terrain height +/// equal to `sea_level` is not classified as water. +pub const MapSample = struct { + terrain_height: i32, + biome: BiomeId, + water: MapWaterClassification, + sea_level: i32, + temperature: f32, + humidity: f32, + continentalness: f32, + river_mask: f32, + ridge_mask: f32, +}; + pub const GenerationOptions = struct { lod_level: LODLevel = .lod0, enable_caves: bool = true, @@ -86,6 +117,11 @@ pub const Generator = struct { getSeed: *const fn (ptr: *anyopaque) u64, getRegionInfo: *const fn (ptr: *anyopaque, world_x: i32, world_z: i32) RegionInfo, getColumnInfo: *const fn (ptr: *anyopaque, wx: f32, wz: f32) ColumnInfo, + getColumnInfoReduced: ?*const fn (ptr: *anyopaque, wx: f32, wz: f32, reduction: u8) ColumnInfo = null, + getMapSampleReduced: ?*const fn (ptr: *anyopaque, wx: f32, wz: f32, reduction: u8) MapSample = null, + /// Allows concurrent calls to column-info and map-sampling methods on + /// the same generator pointer. + column_info_thread_safe: bool = false, deinit: *const fn (ptr: *anyopaque, allocator: std.mem.Allocator) void, }; @@ -113,6 +149,39 @@ pub const Generator = struct { return self.vtable.getColumnInfo(self.ptr, wx, wz); } + /// Returns map/LOD-oriented column data when the generator supports it. + /// Generators without a reduced sampler retain their existing behavior. + pub fn getColumnInfoReduced(self: Generator, wx: f32, wz: f32, reduction: u8) ColumnInfo { + const reduced = self.vtable.getColumnInfoReduced orelse return self.getColumnInfo(wx, wz); + return reduced(self.ptr, wx, wz, reduction); + } + + /// Returns map-oriented data at the requested sampling reduction. + /// + /// Generators without a dedicated sampler use column information with the + /// legacy sea level of 64. The fallback only classifies terrain below that + /// level, and treats non-ocean water as inland. + pub fn getMapSampleReduced(self: Generator, wx: f32, wz: f32, reduction: u8) MapSample { + const sample_fn = self.vtable.getMapSampleReduced orelse { + const column = self.getColumnInfoReduced(wx, wz, reduction); + return .{ + .terrain_height = column.height, + .biome = column.biome, + .water = if (column.height < 64) + if (column.is_ocean) .ocean else .inland + else + .none, + .sea_level = 64, + .temperature = column.temperature, + .humidity = column.humidity, + .continentalness = column.continentalness, + .river_mask = 0.0, + .ridge_mask = 0.0, + }; + }; + return sample_fn(self.ptr, wx, wz, reduction); + } + pub fn deinit(self: Generator, allocator: std.mem.Allocator) void { self.vtable.deinit(self.ptr, allocator); } diff --git a/modules/worldgen-flat/src/root.zig b/modules/worldgen-flat/src/root.zig index d77986aa..da94c9c3 100644 --- a/modules/worldgen-flat/src/root.zig +++ b/modules/worldgen-flat/src/root.zig @@ -126,6 +126,7 @@ pub const FlatWorldGenerator = struct { .getSeed = getSeedWrapper, .getRegionInfo = getRegionInfoWrapper, .getColumnInfo = getColumnInfoWrapper, + .column_info_thread_safe = true, .deinit = deinitWrapper, }; diff --git a/modules/worldgen-overworld-v2/src/root.zig b/modules/worldgen-overworld-v2/src/root.zig index 87c61c83..639fa727 100644 --- a/modules/worldgen-overworld-v2/src/root.zig +++ b/modules/worldgen-overworld-v2/src/root.zig @@ -355,6 +355,7 @@ pub const OverworldV2Generator = struct { .getSeed = getSeedWrapper, .getRegionInfo = getRegionInfoWrapper, .getColumnInfo = getColumnInfoWrapper, + .column_info_thread_safe = true, .deinit = deinitWrapper, }; diff --git a/modules/worldgen-overworld/src/overworld_generator.zig b/modules/worldgen-overworld/src/overworld_generator.zig index 102f48c0..6e0cf980 100644 --- a/modules/worldgen-overworld/src/overworld_generator.zig +++ b/modules/worldgen-overworld/src/overworld_generator.zig @@ -28,6 +28,7 @@ const Generator = gen_interface.Generator; const GeneratorInfo = gen_interface.GeneratorInfo; const WorldgenError = gen_interface.WorldgenError; const ColumnInfo = gen_interface.ColumnInfo; +const MapSample = gen_interface.MapSample; const log = @import("engine-core").log; const terrain_shape_mod = @import("terrain_shape_generator.zig"); @@ -126,7 +127,39 @@ pub const OverworldGenerator = struct { } pub fn getColumnInfo(self: *const OverworldGenerator, wx: f32, wz: f32) ColumnInfo { - const column = self.terrain_shape.sampleColumnData(wx, wz, 0); + return self.getColumnInfoReduced(wx, wz, 0); + } + + pub fn getColumnInfoReduced(self: *const OverworldGenerator, wx: f32, wz: f32, reduction: u8) ColumnInfo { + const column = self.terrain_shape.sampleColumnData(wx, wz, @min(reduction, 4)); + return .{ + .height = column.terrain_height_i, + .biome = self.selectColumnBiome(column), + .is_ocean = column.continentalness < self.terrain_shape.getOceanThreshold(), + .temperature = column.temperature, + .humidity = column.humidity, + .continentalness = column.continentalness, + }; + } + + /// Samples terrain, biome, climate, and water in one terrain-shape query. + /// Rivers remain encoded by the biome and river mask. + pub fn getMapSampleReduced(self: *const OverworldGenerator, wx: f32, wz: f32, reduction: u8) MapSample { + const column = self.terrain_shape.sampleMapColumnData(wx, wz, @min(reduction, 4)); + return .{ + .terrain_height = column.terrain_height_i, + .biome = self.selectColumnBiome(column), + .water = classifyMapWater(column), + .sea_level = self.terrain_shape.getSeaLevel(), + .temperature = column.temperature, + .humidity = column.humidity, + .continentalness = column.continentalness, + .river_mask = column.river_mask, + .ridge_mask = column.ridge_mask, + }; + } + + fn selectColumnBiome(self: *const OverworldGenerator, column: terrain_shape_mod.ColumnData) BiomeId { const climate = self.terrain_shape.biome_source.computeClimate( column.temperature, column.humidity, @@ -142,16 +175,16 @@ pub const OverworldGenerator = struct { .continentalness = column.continentalness, .ridge_mask = column.ridge_mask, }; + return self.terrain_shape.biome_source.selectBiome(climate, structural, column.river_mask); + } - const biome_id = self.terrain_shape.biome_source.selectBiome(climate, structural, column.river_mask); - return .{ - .height = column.terrain_height_i, - .biome = biome_id, - .is_ocean = column.continentalness < self.terrain_shape.getOceanThreshold(), - .temperature = column.temperature, - .humidity = column.humidity, - .continentalness = column.continentalness, - }; + fn classifyMapWater(column: terrain_shape_mod.ColumnData) gen_interface.MapWaterClassification { + if (!column.is_underwater) return .none; + // sampleColumnData already computed warped, coast-jittered + // continentalness. Re-running the full-resolution warp and continent + // noise here doubled the expensive work for every underwater inland + // sample without adding useful map detail. + return if (column.is_ocean) .ocean else .inland; } pub fn maybeRecenterCache(self: *OverworldGenerator, player_x: i32, player_z: i32) bool { @@ -777,6 +810,9 @@ pub const OverworldGenerator = struct { .getSeed = getSeedWrapper, .getRegionInfo = getRegionInfoWrapper, .getColumnInfo = getColumnInfoWrapper, + .getColumnInfoReduced = getColumnInfoReducedWrapper, + .getMapSampleReduced = getMapSampleReducedWrapper, + .column_info_thread_safe = true, .deinit = deinitWrapper, }; @@ -810,6 +846,16 @@ pub const OverworldGenerator = struct { return self.getColumnInfo(wx, wz); } + fn getColumnInfoReducedWrapper(ptr: *anyopaque, wx: f32, wz: f32, reduction: u8) ColumnInfo { + const self: *OverworldGenerator = @ptrCast(@alignCast(ptr)); + return self.getColumnInfoReduced(wx, wz, reduction); + } + + fn getMapSampleReducedWrapper(ptr: *anyopaque, wx: f32, wz: f32, reduction: u8) MapSample { + const self: *OverworldGenerator = @ptrCast(@alignCast(ptr)); + return self.getMapSampleReduced(wx, wz, reduction); + } + fn deinitWrapper(ptr: *anyopaque, allocator: std.mem.Allocator) void { const self: *OverworldGenerator = @ptrCast(@alignCast(ptr)); self.deinit(); diff --git a/modules/worldgen-overworld/src/terrain_shape_generator.zig b/modules/worldgen-overworld/src/terrain_shape_generator.zig index 08a34d7d..5dd9bbe5 100644 --- a/modules/worldgen-overworld/src/terrain_shape_generator.zig +++ b/modules/worldgen-overworld/src/terrain_shape_generator.zig @@ -190,6 +190,19 @@ pub const TerrainShapeGenerator = struct { return self.sampleColumnDataWithControls(wx, wz, reduction, controls); } + /// High-detail map sampling without the generation-only nine-point biome + /// terrain blend. This preserves the requested noise octave detail while + /// using one representative terrain modifier, reducing near-map sampling + /// from roughly ten column evaluations to two. + pub fn sampleMapColumnData(self: *const TerrainShapeGenerator, wx: f32, wz: f32, reduction: u8) ColumnData { + const region_seed = self.getRegionSeed(); + const wx_i = floatToI32(@floor(wx)); + const wz_i = floatToI32(@floor(wz)); + const controls = region_pkg.getBlendedControls(region_seed, wx_i, wz_i); + const terrain_modifier = self.sampleTerrainModifierAt(wx, wz, reduction); + return self.sampleColumnDataWithControlsAndTerrainModifier(wx, wz, reduction, controls, terrain_modifier); + } + pub fn sampleColumnDataWithControls(self: *const TerrainShapeGenerator, wx: f32, wz: f32, reduction: u8, controls: region_pkg.RegionControls) ColumnData { const terrain_modifier = self.sampleBlendedTerrainModifier(wx, wz, reduction, controls); return self.sampleColumnDataWithControlsAndTerrainModifier(wx, wz, reduction, controls, terrain_modifier); diff --git a/modules/worldgen-overworld/src/tests.zig b/modules/worldgen-overworld/src/tests.zig new file mode 100644 index 00000000..0c094d3a --- /dev/null +++ b/modules/worldgen-overworld/src/tests.zig @@ -0,0 +1,5 @@ +//! Dedicated WorldMap test root. + +test { + _ = @import("world_map.zig"); +} diff --git a/modules/worldgen-overworld/src/world_map.zig b/modules/worldgen-overworld/src/world_map.zig index 52b04425..7b9c3145 100644 --- a/modules/worldgen-overworld/src/world_map.zig +++ b/modules/worldgen-overworld/src/world_map.zig @@ -1,13 +1,150 @@ const std = @import("std"); +const sync = @import("sync"); const gen_interface = @import("worldgen-api"); const Generator = gen_interface.Generator; const ColumnInfo = gen_interface.ColumnInfo; +const MapSample = gen_interface.MapSample; +const MapWaterClassification = gen_interface.MapWaterClassification; +const world_core = @import("world-core"); +const BlockType = world_core.BlockType; +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; pub const WorldMap = struct { + const COARSE_TARGET_RESOLUTION: u32 = 256; + const MAX_FULL_RENDER_WORKERS: u32 = 8; + + pub const View = struct { + center_x: f32, + center_z: f32, + scale: f32, + generation: u64, + }; + + pub const LoadedSurfaceCell = struct { + height: i16, + block: BlockType, + }; + + pub const LoadedSurfaceChunk = struct { + chunk_x: i32, + chunk_z: i32, + heights: [CHUNK_SIZE_X * CHUNK_SIZE_Z]i16, + blocks: [CHUNK_SIZE_X * CHUNK_SIZE_Z]BlockType, + }; + + /// Immutable copied live-world data. No chunk pointers cross into the map + /// worker, so edits and streaming remain synchronized by World capture. + pub const LoadedSurfaceOverlay = struct { + allocator: std.mem.Allocator, + chunks: std.ArrayListUnmanaged(LoadedSurfaceChunk) = .empty, + + pub fn deinit(self: *LoadedSurfaceOverlay) void { + self.chunks.deinit(self.allocator); + self.allocator.destroy(self); + } + + pub fn ensureUnusedCapacity(self: *LoadedSurfaceOverlay, count: usize) !void { + try self.chunks.ensureUnusedCapacity(self.allocator, count); + } + + pub fn appendAssumeCapacity(self: *LoadedSurfaceOverlay, chunk: LoadedSurfaceChunk) void { + self.chunks.appendAssumeCapacity(chunk); + } + + pub fn finish(self: *LoadedSurfaceOverlay) void { + std.mem.sort(LoadedSurfaceChunk, self.chunks.items, {}, lessThanSurfaceChunk); + } + + pub fn sample(self: *const LoadedSurfaceOverlay, world_x: i32, world_z: i32) ?LoadedSurfaceCell { + const chunk_pos = world_core.worldToChunk(world_x, world_z); + const local = world_core.worldToLocal(world_x, world_z); + const chunk = self.findChunk(chunk_pos.chunk_x, chunk_pos.chunk_z) orelse return null; + const index = local.x + local.z * CHUNK_SIZE_X; + const height = chunk.heights[index]; + if (height < 0) return null; + return .{ .height = height, .block = chunk.blocks[index] }; + } + + pub fn sampleRepresentative(self: *const LoadedSurfaceOverlay, wx: f32, wz: f32, scale: f32) ?LoadedSurfaceCell { + const radius = @max(scale * 0.5, 0.5); + const min_x: i32 = @intFromFloat(@floor(wx - radius)); + const max_x: i32 = @intFromFloat(@ceil(wx + radius) - 1.0); + const min_z: i32 = @intFromFloat(@floor(wz - radius)); + const max_z: i32 = @intFromFloat(@ceil(wz + radius) - 1.0); + + var best: ?LoadedSurfaceCell = null; + var z = min_z; + while (z <= max_z) : (z += 1) { + var x = min_x; + while (x <= max_x) : (x += 1) { + const cell = self.sample(x, z) orelse continue; + if (best == null or cell.height > best.?.height) best = cell; + } + } + return best; + } + + fn findChunk(self: *const LoadedSurfaceOverlay, chunk_x: i32, chunk_z: i32) ?*const LoadedSurfaceChunk { + var low: usize = 0; + var high = self.chunks.items.len; + while (low < high) { + const mid = low + (high - low) / 2; + const chunk = &self.chunks.items[mid]; + if (chunk.chunk_z < chunk_z or (chunk.chunk_z == chunk_z and chunk.chunk_x < chunk_x)) { + low = mid + 1; + } else { + high = mid; + } + } + if (low >= self.chunks.items.len) return null; + const chunk = &self.chunks.items[low]; + return if (chunk.chunk_x == chunk_x and chunk.chunk_z == chunk_z) chunk else null; + } + + fn lessThanSurfaceChunk(_: void, a: LoadedSurfaceChunk, b: LoadedSurfaceChunk) bool { + return a.chunk_z < b.chunk_z or (a.chunk_z == b.chunk_z and a.chunk_x < b.chunk_x); + } + }; + + const Request = struct { + generator: Generator, + center_x: f32, + center_z: f32, + scale: f32, + generation: u64, + overlay: ?*LoadedSurfaceOverlay, + }; + + const Pass = enum { coarse, full }; + + const RenderSlice = struct { + map: *WorldMap, + request: Request, + sample_step: u32, + reduction: u8, + start_slice: u32, + slice_stride: u32, + cancelled: *std.atomic.Value(bool), + }; + allocator: std.mem.Allocator, pixels: []u8, + worker_pixels: []u8, + worker_heights: []i32, + worker_water: []MapWaterClassification, width: u32, height: u32, + mutex: sync.Mutex = .{}, + work_ready: sync.Condition = .{}, + thread: ?std.Thread = null, + pending_request: ?Request = null, + refinement_request: ?Request = null, + completed: bool = false, + completed_view: View = .{ .center_x = 0, .center_z = 0, .scale = 1, .generation = 0 }, + latest_generation: u64 = 0, + stopping: bool = false, pub fn init(allocator: std.mem.Allocator, width: u32, height: u32) !WorldMap { // Safety: ensure texture size is within typical hardware limits @@ -15,44 +152,336 @@ pub const WorldMap = struct { const safe_h = @min(height, 4096); const pixel_bytes = @as(usize, safe_w) * @as(usize, safe_h) * 4; const pixels = try allocator.alloc(u8, pixel_bytes); + errdefer allocator.free(pixels); + const worker_pixels = try allocator.alloc(u8, pixel_bytes); + errdefer allocator.free(worker_pixels); + const worker_heights = try allocator.alloc(i32, @as(usize, safe_w) * @as(usize, safe_h)); + errdefer allocator.free(worker_heights); + const worker_water = try allocator.alloc(MapWaterClassification, @as(usize, safe_w) * @as(usize, safe_h)); @memset(pixels, 0); + @memset(worker_pixels, 0); + @memset(worker_heights, 0); + @memset(worker_water, .none); return .{ .allocator = allocator, .pixels = pixels, + .worker_pixels = worker_pixels, + .worker_heights = worker_heights, + .worker_water = worker_water, .width = safe_w, .height = safe_h, }; } + pub fn createLoadedSurfaceOverlay(self: *WorldMap) !*LoadedSurfaceOverlay { + const overlay = try self.allocator.create(LoadedSurfaceOverlay); + overlay.* = .{ .allocator = self.allocator }; + return overlay; + } + pub fn deinit(self: *WorldMap) void { + self.mutex.lock(); + self.stopping = true; + if (self.pending_request) |request| if (request.overlay) |overlay| overlay.deinit(); + if (self.refinement_request) |request| if (request.overlay) |overlay| overlay.deinit(); + self.pending_request = null; + self.refinement_request = null; + self.work_ready.broadcast(); + self.mutex.unlock(); + + if (self.thread) |thread| thread.join(); + + self.allocator.free(self.worker_water); + self.allocator.free(self.worker_heights); + self.allocator.free(self.worker_pixels); self.allocator.free(self.pixels); + self.worker_heights = &.{}; + self.worker_water = &.{}; + self.worker_pixels = &.{}; self.pixels = &.{}; } - pub fn update(self: *WorldMap, generator: Generator, center_x: f32, center_z: f32, scale: f32) void { + /// Coalesces requests so pan/zoom input never queues obsolete map renders. + /// The worker is spawned lazily after WorldMap has reached its stable owner. + pub fn requestUpdate(self: *WorldMap, generator: Generator, center_x: f32, center_z: f32, scale: f32, overlay: ?*LoadedSurfaceOverlay) !void { + self.mutex.lock(); + defer self.mutex.unlock(); + if (self.stopping) { + if (overlay) |owned| owned.deinit(); + return; + } + + if (self.pending_request) |request| if (request.overlay) |owned| owned.deinit(); + if (self.refinement_request) |request| if (request.overlay) |owned| owned.deinit(); + self.latest_generation +%= 1; + self.pending_request = .{ + .generator = generator, + .center_x = center_x, + .center_z = center_z, + .scale = scale, + .generation = self.latest_generation, + .overlay = overlay, + }; + self.refinement_request = null; + // A completion from an older viewport must never block or overwrite a + // newer request. It can be safely dropped while holding the mutex. + self.completed = false; + if (self.thread == null) self.thread = try std.Thread.spawn(.{}, workerMain, .{self}); + self.work_ready.signal(); + } + + /// Copies a completed worker result into the render-thread-owned buffer. + /// The GPU upload must happen after this returns and must remain on the + /// render thread. + pub fn consumeCompleted(self: *WorldMap) ?View { + self.mutex.lock(); + defer self.mutex.unlock(); + if (!self.completed) return null; + + @memcpy(self.pixels, self.worker_pixels); + self.completed = false; + self.work_ready.signal(); + return self.completed_view; + } + + pub fn reductionForScale(scale: f32) u8 { + if (scale <= 1.0) return 0; + if (scale <= 4.0) return 1; + if (scale <= 16.0) return 2; + if (scale > 64.0) return 4; + return 3; + } + + pub fn coarseSampleStep(self: *const WorldMap) u32 { + const longest_axis = @max(self.width, self.height); + return @max((longest_axis + COARSE_TARGET_RESOLUTION - 1) / COARSE_TARGET_RESOLUTION, 1); + } + + fn workerMain(self: *WorldMap) void { + while (true) { + self.mutex.lock(); + while (!self.stopping and (self.completed or (self.pending_request == null and self.refinement_request == null))) { + self.work_ready.wait(&self.mutex); + } + if (self.stopping) { + self.mutex.unlock(); + return; + } + + const pass: Pass = if (self.pending_request != null) .coarse else .full; + const request = if (self.pending_request) |pending| blk: { + self.pending_request = null; + break :blk pending; + } else blk: { + const refinement = self.refinement_request.?; + self.refinement_request = null; + break :blk refinement; + }; + self.mutex.unlock(); + + const sample_step: u32 = if (pass == .coarse) self.coarseSampleStep() else 1; + const reduction: u8 = if (pass == .coarse) 4 else reductionForScale(request.scale); + if (!self.render(request, sample_step, reduction)) { + if (request.overlay) |overlay| overlay.deinit(); + continue; + } + + self.mutex.lock(); + if (self.stopping) { + self.mutex.unlock(); + if (request.overlay) |overlay| overlay.deinit(); + return; + } + // All passes are latest-wins. Publishing an obsolete preview makes + // pan/zoom visibly lag and then blocks the latest work until the + // render thread uploads an image that is already wrong. + if (request.generation != self.latest_generation) { + self.mutex.unlock(); + if (request.overlay) |overlay| overlay.deinit(); + continue; + } + if (pass == .coarse) self.refinement_request = request; + self.completed_view = .{ + .center_x = request.center_x, + .center_z = request.center_z, + .scale = request.scale, + .generation = request.generation, + }; + self.completed = true; + self.mutex.unlock(); + if (pass == .full) if (request.overlay) |overlay| overlay.deinit(); + } + } + + fn render(self: *WorldMap, request: Request, sample_step: u32, reduction: u8) bool { + if (self.height >= 64 and request.generator.vtable.column_info_thread_safe) { + if (!self.renderParallel(request, sample_step, reduction)) return false; + } else if (!self.renderRows(request, sample_step, reduction, 0, 1)) { + return false; + } + if (sample_step == 1 and !self.applyTerrainStyling(request)) return false; + return true; + } + + fn renderParallel(self: *WorldMap, request: Request, sample_step: u32, reduction: u8) bool { + const worker_count: u32 = @intCast(@min(@max(std.Thread.getCpuCount() catch 4, 2), MAX_FULL_RENDER_WORKERS)); + var cancelled = std.atomic.Value(bool).init(false); + var threads: [MAX_FULL_RENDER_WORKERS - 1]?std.Thread = @splat(null); + var slices: [MAX_FULL_RENDER_WORKERS - 1]RenderSlice = undefined; + + for (1..worker_count) |worker_index| { + const slot = worker_index - 1; + slices[slot] = .{ + .map = self, + .request = request, + .sample_step = sample_step, + .reduction = reduction, + .start_slice = @intCast(worker_index), + .slice_stride = worker_count, + .cancelled = &cancelled, + }; + threads[slot] = std.Thread.spawn(.{}, renderSliceMain, .{slices[slot]}) catch null; + } + + var rendered = self.renderRows(request, sample_step, reduction, 0, worker_count); + for (threads[0 .. worker_count - 1]) |thread| if (thread) |running| running.join(); + + // Thread creation failure is rare, but rendering the missing slice on + // this worker preserves a complete map instead of publishing stripes. + for (threads[0 .. worker_count - 1], 1..) |thread, worker_index| { + if (thread == null and rendered) { + rendered = self.renderRows(request, sample_step, reduction, @intCast(worker_index), worker_count); + } + } + return rendered and !cancelled.load(.acquire); + } + + fn renderSliceMain(slice: RenderSlice) void { + if (!slice.map.renderRows(slice.request, slice.sample_step, slice.reduction, slice.start_slice, slice.slice_stride)) { + slice.cancelled.store(true, .release); + } + } + + fn renderRows(self: *WorldMap, request: Request, sample_step: u32, reduction: u8, start_slice: u32, slice_stride: u32) bool { const hw = @as(f32, @floatFromInt(self.width)) * 0.5; const hh = @as(f32, @floatFromInt(self.height)) * 0.5; - const start_x = center_x - (hw * scale); - const start_z = center_z - (hh * scale); + const start_x = request.center_x - (hw * request.scale); + const start_z = request.center_z - (hh * request.scale); - var py: u32 = 0; - while (py < self.height) : (py += 1) { - const wz = start_z + @as(f32, @floatFromInt(py)) * scale; + var py = start_slice * sample_step; + var rows_since_cancel_check: u32 = 16; + while (py < self.height) : (py += sample_step * slice_stride) { + if (rows_since_cancel_check >= 16) { + if (self.shouldCancel(request.generation)) return false; + rows_since_cancel_check = 0; + } + rows_since_cancel_check += 1; + const sample_y = @min(py + sample_step / 2, self.height - 1); + const wz = start_z + @as(f32, @floatFromInt(sample_y)) * request.scale; var px: u32 = 0; - while (px < self.width) : (px += 1) { - const wx = start_x + @as(f32, @floatFromInt(px)) * scale; + while (px < self.width) : (px += sample_step) { + const sample_x = @min(px + sample_step / 2, self.width - 1); + const wx = start_x + @as(f32, @floatFromInt(sample_x)) * request.scale; + + const live_footprint = @min(request.scale * @as(f32, @floatFromInt(sample_step)), 8.0); + const live_surface = if (request.overlay) |overlay| overlay.sampleRepresentative(wx, wz, live_footprint) else null; + const sample = if (live_surface == null) request.generator.getMapSampleReduced(wx, wz, reduction) else null; + const color = if (live_surface) |surface| colorForLiveBlock(surface.block) else colorForSample(sample.?); + const rgba: [4]u8 = .{ + @intFromFloat(color[0] * 255.0), + @intFromFloat(color[1] * 255.0), + @intFromFloat(color[2] * 255.0), + 255, + }; + + const end_y = @min(py + sample_step, self.height); + const end_x = @min(px + sample_step, self.width); + var fill_y = py; + while (fill_y < end_y) : (fill_y += 1) { + var fill_x = px; + while (fill_x < end_x) : (fill_x += 1) { + const idx = (@as(usize, fill_x) + @as(usize, fill_y) * @as(usize, self.width)) * 4; + @memcpy(self.worker_pixels[idx .. idx + 4], &rgba); + self.worker_heights[idx / 4] = if (live_surface) |surface| surface.height else sample.?.terrain_height; + self.worker_water[idx / 4] = if (live_surface) |surface| + if (surface.block == .water) .inland else .none + else + sample.?.water; + } + } + } + } + return true; + } + + /// Applies cartographic relief, contours, and crisp shoreline definition + /// after all height samples are available. Water stays visually flat while + /// land receives a normalized NW-lit hillshade. + fn applyTerrainStyling(self: *WorldMap, request: Request) bool { + if (self.width < 3 or self.height < 3) return true; + const width: usize = self.width; + const height: usize = self.height; + const stride = width; + const gradient_scale = 1.0 / @max(request.scale * 2.0, 0.25); + const contour_interval = contourInterval(request.scale); + const light_x: f32 = -0.557; + const light_y: f32 = 0.743; + const light_z: f32 = -0.371; + + var y: usize = 1; + while (y + 1 < height) : (y += 1) { + if (self.shouldCancel(request.generation)) return false; + var x: usize = 1; + while (x + 1 < width) : (x += 1) { + const pixel = x + y * stride; + if (self.worker_water[pixel] != .none) { + const shoreline = self.worker_water[pixel - 1] == .none or + self.worker_water[pixel + 1] == .none or + self.worker_water[pixel - stride] == .none or + self.worker_water[pixel + stride] == .none; + if (shoreline) multiplyPixel(self.worker_pixels, pixel, 0.76); + continue; + } - const info = generator.getColumnInfo(wx, wz); - const color = shadeColor(getBiomeColor(info), info.height); + const slope_x = @as(f32, @floatFromInt(self.worker_heights[pixel + 1] - self.worker_heights[pixel - 1])) * gradient_scale; + const slope_z = @as(f32, @floatFromInt(self.worker_heights[pixel + stride] - self.worker_heights[pixel - stride])) * gradient_scale; + const normal_length = @sqrt(slope_x * slope_x + slope_z * slope_z + 1.0); + const light = (-slope_x * light_x + light_y - slope_z * light_z) / normal_length; + const relief = std.math.clamp(0.72 + light * 0.42, 0.66, 1.20); + multiplyPixel(self.worker_pixels, pixel, relief); - const idx = (px + py * self.width) * 4; - self.pixels[idx + 0] = @intFromFloat(color[0] * 255.0); - self.pixels[idx + 1] = @intFromFloat(color[1] * 255.0); - self.pixels[idx + 2] = @intFromFloat(color[2] * 255.0); - self.pixels[idx + 3] = 255; + const height_here = self.worker_heights[pixel]; + const contour = @divFloor(height_here, contour_interval); + const crosses_contour = @divFloor(self.worker_heights[pixel - 1], contour_interval) != contour or + @divFloor(self.worker_heights[pixel - stride], contour_interval) != contour; + if (crosses_contour) { + const major = @mod(height_here, contour_interval * 4) == 0; + multiplyPixel(self.worker_pixels, pixel, if (major) 0.68 else 0.82); + } } } + return true; + } + + fn contourInterval(scale: f32) i32 { + if (scale <= 2.0) return 8; + if (scale <= 8.0) return 16; + if (scale <= 32.0) return 32; + return 64; + } + + fn multiplyPixel(pixels: []u8, pixel: usize, factor: f32) void { + const color = pixel * 4; + inline for (0..3) |channel| { + pixels[color + channel] = @intFromFloat(std.math.clamp(@as(f32, @floatFromInt(pixels[color + channel])) * factor, 0.0, 255.0)); + } + } + + fn shouldCancel(self: *WorldMap, generation: u64) bool { + self.mutex.lock(); + defer self.mutex.unlock(); + return self.stopping or generation != self.latest_generation; } fn getBiomeColor(info: ColumnInfo) [3]f32 { @@ -113,9 +542,66 @@ pub const WorldMap = struct { }; } - fn shadeColor(color: [3]f32, height: i32) [3]f32 { - const normalized_height = std.math.clamp((@as(f32, @floatFromInt(height)) - 48.0) / 96.0, 0.0, 1.0); - const shade = std.math.lerp(0.82, 1.18, normalized_height); + fn colorForSample(sample: MapSample) [3]f32 { + if (sample.water != .none) { + const frozen = switch (sample.biome) { + .frozen_ocean, .frozen_river => true, + else => false, + }; + if (frozen) return .{ 0.64, 0.82, 0.90 }; + + const depth = @as(f32, @floatFromInt(@max(sample.sea_level - sample.terrain_height, 0))); + const depth_mix = std.math.clamp(depth / 48.0, 0.0, 1.0); + const shallow: [3]f32 = switch (sample.biome) { + .warm_ocean, .tropical => .{ 0.08, 0.58, 0.78 }, + .cold_ocean => .{ 0.08, 0.38, 0.65 }, + else => if (sample.water == .inland) .{ 0.12, 0.48, 0.72 } else .{ 0.08, 0.43, 0.76 }, + }; + const deep: [3]f32 = switch (sample.biome) { + .warm_ocean, .tropical => .{ 0.03, 0.30, 0.58 }, + else => .{ 0.025, 0.16, 0.43 }, + }; + return .{ + std.math.lerp(shallow[0], deep[0], depth_mix), + std.math.lerp(shallow[1], deep[1], depth_mix), + std.math.lerp(shallow[2], deep[2], depth_mix), + }; + } + + const info = ColumnInfo{ + .height = sample.terrain_height, + .biome = sample.biome, + .is_ocean = false, + .temperature = sample.temperature, + .humidity = sample.humidity, + .continentalness = sample.continentalness, + }; + return shadeColor(getBiomeColor(info), sample.terrain_height, sample.sea_level); + } + + fn colorForLiveBlock(block: BlockType) [3]f32 { + return switch (block) { + .grass, .tall_grass, .vine, .flower_red, .flower_yellow => .{ 0.34, 0.66, 0.23 }, + .leaves, .jungle_leaves, .acacia_leaves, .birch_leaves => .{ 0.11, 0.43, 0.13 }, + .spruce_leaves => .{ 0.15, 0.34, 0.27 }, + .mangrove_leaves => .{ 0.13, 0.34, 0.18 }, + .wood, .mangrove_log, .jungle_log, .acacia_log, .birch_log, .spruce_log, .mushroom_stem => .{ 0.38, 0.27, 0.15 }, + .water, .seagrass, .tall_seagrass, .kelp, .seaweed => .{ 0.08, 0.43, 0.76 }, + .sand => .{ 0.90, 0.78, 0.48 }, + .red_sand => .{ 0.76, 0.34, 0.16 }, + .stone, .cobblestone, .mossy_cobblestone, .gravel => .{ 0.48, 0.49, 0.47 }, + .dirt, .coarse_dirt, .rooted_dirt, .podzol => .{ 0.47, 0.36, 0.22 }, + .mud, .mangrove_roots => .{ 0.25, 0.29, 0.20 }, + .snow_block, .snow_layer => .{ 0.88, 0.93, 0.95 }, + .ice, .packed_ice, .blue_ice => .{ 0.64, 0.82, 0.90 }, + else => block_registry.getBlockDefinition(block).default_color, + }; + } + + fn shadeColor(color: [3]f32, height: i32, sea_level: i32) [3]f32 { + const elevation = @as(f32, @floatFromInt(@max(height - sea_level, 0))); + const normalized_height = std.math.clamp(elevation / 128.0, 0.0, 1.0); + const shade = std.math.lerp(0.92, 1.14, normalized_height); return .{ std.math.clamp(color[0] * shade, 0.0, 1.0), std.math.clamp(color[1] * shade, 0.0, 1.0), @@ -123,3 +609,184 @@ pub const WorldMap = struct { }; } }; + +test "WorldMap chooses map-appropriate sampling reduction" { + try std.testing.expectEqual(@as(u8, 0), WorldMap.reductionForScale(0.05)); + try std.testing.expectEqual(@as(u8, 0), WorldMap.reductionForScale(1.0)); + try std.testing.expectEqual(@as(u8, 1), WorldMap.reductionForScale(2.0)); + try std.testing.expectEqual(@as(u8, 1), WorldMap.reductionForScale(4.0)); + try std.testing.expectEqual(@as(u8, 2), WorldMap.reductionForScale(8.0)); + try std.testing.expectEqual(@as(u8, 3), WorldMap.reductionForScale(32.0)); + try std.testing.expectEqual(@as(u8, 4), WorldMap.reductionForScale(128.0)); +} + +test "WorldMap preview targets a 256 texel grid" { + var map = try WorldMap.init(std.testing.allocator, 1024, 1024); + defer map.deinit(); + try std.testing.expectEqual(@as(u32, 4), map.coarseSampleStep()); +} + +const FakeMapGenerator = struct { + sample_count: usize = 0, + last_reduction: u8 = 0, + block_next_sample: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + sample_started: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + release_sample: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + + fn generate(_: *anyopaque, _: *world_core.Chunk, _: ?*const bool) gen_interface.WorldgenError!void {} + fn generateHeightmapOnly(_: *anyopaque, _: *world_core.LODSimplifiedData, _: i32, _: i32, _: world_core.LODLevel, _: ?*const std.atomic.Value(bool)) void {} + fn maybeRecenterCache(_: *anyopaque, _: i32, _: i32) bool { + return false; + } + fn getSeed(_: *anyopaque) u64 { + return 1; + } + fn getRegionInfo(_: *anyopaque, x: i32, z: i32) gen_interface.RegionInfo { + return .{ .mood = .calm, .role = .transit, .focus = .none, .center_x = x, .center_z = z }; + } + fn getColumnInfo(ptr: *anyopaque, wx: f32, wz: f32) ColumnInfo { + return getColumnInfoReduced(ptr, wx, wz, 0); + } + fn getColumnInfoReduced(ptr: *anyopaque, _: f32, _: f32, reduction: u8) ColumnInfo { + const self: *@This() = @ptrCast(@alignCast(ptr)); + if (self.block_next_sample.swap(false, .acq_rel)) { + self.sample_started.store(true, .release); + while (!self.release_sample.load(.acquire)) std.Options.debug_io.sleep(.fromNanoseconds(100_000), .boot) catch {}; + } + self.sample_count += 1; + self.last_reduction = reduction; + return .{ .height = 64, .biome = .plains, .is_ocean = false, .temperature = 0.5, .humidity = 0.5, .continentalness = 0.5 }; + } + fn deinit(_: *anyopaque, _: std.mem.Allocator) void {} + + const VTABLE = Generator.VTable{ + .generate = generate, + .generateHeightmapOnly = generateHeightmapOnly, + .maybeRecenterCache = maybeRecenterCache, + .getSeed = getSeed, + .getRegionInfo = getRegionInfo, + .getColumnInfo = getColumnInfo, + .getColumnInfoReduced = getColumnInfoReduced, + .deinit = deinit, + }; + + fn generator(self: *@This()) Generator { + return .{ .ptr = self, .vtable = &VTABLE, .info = .{ .name = "fake", .description = "fake", .version = 1 } }; + } +}; + +test "WorldMap coarse pass fills blocks with reduced samples" { + var fake = FakeMapGenerator{}; + var map = try WorldMap.init(std.testing.allocator, 5, 3); + defer map.deinit(); + + const rendered = map.render(.{ .generator = fake.generator(), .center_x = 0, .center_z = 0, .scale = 4, .generation = 0, .overlay = null }, 2, 4); + + try std.testing.expect(rendered); + try std.testing.expectEqual(@as(usize, 6), fake.sample_count); + try std.testing.expectEqual(@as(u8, 4), fake.last_reduction); + var alpha: usize = 3; + while (alpha < map.worker_pixels.len) : (alpha += 4) try std.testing.expectEqual(@as(u8, 255), map.worker_pixels[alpha]); +} + +test "WorldMap full pass samples every output pixel" { + var fake = FakeMapGenerator{}; + var map = try WorldMap.init(std.testing.allocator, 5, 3); + defer map.deinit(); + + const rendered = map.render(.{ .generator = fake.generator(), .center_x = 0, .center_z = 0, .scale = 4, .generation = 0, .overlay = null }, 1, 2); + + try std.testing.expect(rendered); + try std.testing.expectEqual(@as(usize, 15), fake.sample_count); + try std.testing.expectEqual(@as(u8, 2), fake.last_reduction); +} + +test "WorldMap terrain styling adds directional terrain contrast" { + var fake = FakeMapGenerator{}; + var map = try WorldMap.init(std.testing.allocator, 3, 3); + defer map.deinit(); + @memset(map.worker_pixels, 100); + map.worker_heights[3] = 0; + map.worker_heights[5] = 10; + map.worker_heights[1] = 0; + map.worker_heights[7] = 10; + + try std.testing.expect(map.applyTerrainStyling(.{ .generator = fake.generator(), .center_x = 0, .center_z = 0, .scale = 1, .generation = 0, .overlay = null })); + + try std.testing.expect(map.worker_pixels[(1 + 3) * 4] != 100); +} + +test "WorldMap terrain styling outlines shorelines without shading water relief" { + var fake = FakeMapGenerator{}; + var map = try WorldMap.init(std.testing.allocator, 3, 3); + defer map.deinit(); + @memset(map.worker_pixels, 100); + @memset(map.worker_heights, 64); + @memset(map.worker_water, .none); + map.worker_water[4] = .ocean; + + try std.testing.expect(map.applyTerrainStyling(.{ .generator = fake.generator(), .center_x = 0, .center_z = 0, .scale = 1, .generation = 0, .overlay = null })); + + try std.testing.expectEqual(@as(u8, 76), map.worker_pixels[4 * 4]); +} + +test "WorldMap latest request supersedes an in-flight preview" { + var fake = FakeMapGenerator{}; + fake.block_next_sample.store(true, .release); + var map = try WorldMap.init(std.testing.allocator, 16, 16); + defer map.deinit(); + defer fake.release_sample.store(true, .release); + + try map.requestUpdate(fake.generator(), 0, 0, 4, null); + var started = false; + for (0..10_000) |_| { + if (fake.sample_started.load(.acquire)) { + started = true; + break; + } + std.Options.debug_io.sleep(.fromNanoseconds(100_000), .boot) catch {}; + } + try std.testing.expect(started); + + try map.requestUpdate(fake.generator(), 256, -128, 8, null); + fake.release_sample.store(true, .release); + + var latest: ?WorldMap.View = null; + for (0..20_000) |_| { + if (map.consumeCompleted()) |view| { + latest = view; + break; + } + std.Options.debug_io.sleep(.fromNanoseconds(100_000), .boot) catch {}; + } + + try std.testing.expect(latest != null); + try std.testing.expectEqual(@as(u64, 2), latest.?.generation); + try std.testing.expectEqual(@as(f32, 256), latest.?.center_x); + try std.testing.expectEqual(@as(f32, -128), latest.?.center_z); +} + +test "WorldMap loaded surface overrides procedural terrain with foliage" { + var fake = FakeMapGenerator{}; + var map = try WorldMap.init(std.testing.allocator, 3, 3); + defer map.deinit(); + const overlay = try map.createLoadedSurfaceOverlay(); + defer overlay.deinit(); + + var heights = [_]i16{-1} ** (CHUNK_SIZE_X * CHUNK_SIZE_Z); + var blocks = [_]BlockType{.air} ** (CHUNK_SIZE_X * CHUNK_SIZE_Z); + heights[0] = 80; + blocks[0] = .leaves; + try overlay.ensureUnusedCapacity(1); + overlay.appendAssumeCapacity(.{ .chunk_x = 0, .chunk_z = 0, .heights = heights, .blocks = blocks }); + overlay.finish(); + + const request = WorldMap.Request{ .generator = fake.generator(), .center_x = 0, .center_z = 0, .scale = 1, .generation = 0, .overlay = overlay }; + try std.testing.expect(map.renderRows(request, 1, 2, 0, 1)); + + const pixel = 2 + 2 * 3; + const expected = WorldMap.colorForLiveBlock(.leaves); + try std.testing.expectEqual(@as(i32, 80), map.worker_heights[pixel]); + try std.testing.expectEqual(@as(u8, @intFromFloat(expected[0] * 255.0)), map.worker_pixels[pixel * 4]); + try std.testing.expectEqual(@as(usize, 8), fake.sample_count); +} diff --git a/modules/worldgen-test/src/root.zig b/modules/worldgen-test/src/root.zig index 5769fa67..041a617f 100644 --- a/modules/worldgen-test/src/root.zig +++ b/modules/worldgen-test/src/root.zig @@ -247,6 +247,7 @@ pub const ShadowTestWorldGenerator = struct { .getSeed = getSeedWrapper, .getRegionInfo = getRegionInfoWrapper, .getColumnInfo = getColumnInfoWrapper, + .column_info_thread_safe = true, .deinit = deinitWrapper, };