diff --git a/ROADMAP.md b/ROADMAP.md index 6255302..59fc045 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -15,14 +15,14 @@ Property names and file references are included so each item is easy to find. - [x] Full per-option menu — `ui/graphics_panel.tscn` ("Advanced Graphics..." in Settings); sections: Lighting, Shadows, Sky & Atmosphere, Post-Processing, Performance - [x] Per-option overrides on top of a preset with "Custom" indicator + "Reset to Preset" - [x] Graphics settings persist to `user://settings.cfg` (`[graphics] values` dictionary) -- [x] Render distance slider 4-32 chunks — `ui/settings_category_panel.gd` (Display category) raises the cap from 16; full-detail chunks now extend to the render distance itself (`lod_distance = min(render_distance, VoxelWorld.MAX_FULL_DETAIL_DISTANCE)`). An "Extreme" toggle raises the slider to 100 chunks with an inline load-time/memory warning; only distances past the 32-chunk full-detail cap fall back to compact LOD. Collision shapes are built only within `VoxelWorld.COLLISION_DISTANCE` and added on approach, so distant full chunks hold data/mesh but no physics. Measured RD 32 (4225 full chunks, 8 workers) ~155 s CPU-bound; RD 16 ~37 s +- [x] Render distance slider 4-32 chunks — `ui/settings_category_panel.gd` (Display category) raises the cap from 16; full-detail chunks now extend to the render distance itself (`lod_distance = min(render_distance, VoxelWorld.MAX_FULL_DETAIL_DISTANCE)`). An "Extreme" toggle raises the slider to 100 chunks with an inline load-time/memory warning; only distances past the 32-chunk full-detail cap fall back to compact LOD. Collision shapes are built only within `VoxelWorld.COLLISION_DISTANCE` and added on approach, so distant full chunks hold data/mesh but no physics. Authoritative streaming now generates data first and meshes once the neighbor ring is ready, avoiding temporary preview geometry and redundant seam regeneration; RD 10 (441 full chunks, 8 workers) completes in ~11.5 s in `stream_full_verify.gd`. The older pre-pipeline RD 32 ~155 s / RD 16 ~37 s measurements need refreshing ## Interface - [x] Deepslate & Ember UI overhaul — shared `UITheme`, bundled Iosevka typography, reusable motion and generated controls, dusk voxel title screen, sectioned settings/world creation, responsive inventory, texture-derived isometric block icons, compact telemetry HUD, item hotbar, focus restoration, and nested-modal cancel handling. Architecture notes live in `ui/README.md` - [x] Play and Settings information architecture — Play opens a world setup screen (world type, seed, clipboard import, Create Game) whose Advanced button opens the tunables-only world gen screen; Settings opens a category hub (Display, Graphics, Sound, Gameplay) where Advanced Graphics is itself a hub split into Lighting, Shadows, Sky & Atmosphere, Post-Processing, and Performance section panels. Cancel unwinds one layer at a time with focus restoration, and the pause menu reuses the hub. `tools/ui_flow_verify.gd` covers the flows - [x] Compact HUD — hotbar slots 76 -> 48 px with smaller icons, key hints, and counts, coords/stats chips reduced to ~150 px through the shared compact chip style, and status/selection labels moved above the smaller hotbar - [x] E opens the inventory — `inventory` action rebound from I; main-menu footer and docs updated -- [ ] Loading and world-creation progress — show generation/streaming progress between menu and gameplay; high render distances block with no feedback today (RD 32 measured ~155 s CPU-bound) +- [ ] Loading and world-creation progress — show generation/streaming progress between menu and gameplay; high render distances still need feedback while the refreshed RD 16/32 authoritative-stream measurements are collected - [ ] Chat / command console — `/time`, `/weather`, `/tp`, `/give`, `/seed`, and `/help` for testing and moderation; reuses `DayNightCycle.set_time()`, `WeatherSystem.toggle()`, and `GameConfig.world` - [ ] Death and respawn flow — prompts respawn at `Player.spawn_position` or quit to menu; pairs with the survival layer in Gameplay - [x] Photo mode and HUD hiding — F1 hides the HUD, F2 saves a HUD-free PNG to `user://screenshots/` (every CanvasLayer, so modals cannot leak into the frame), and P swaps in a detached free camera (`game/photo_mode.gd`, built on the `game/shadow_capture.gd` precedent: opt-in node added by `Main`, own key handling, `status_requested` feedback, `user://` output). The free camera inherits the player camera pose/FOV, flies with WASD/SPACE/SHIFT/CTRL, zooms with the wheel, and P/Esc return to the player. While active, `Main` follows it with chunk streaming (entry streams around the camera asynchronously; exit sync-commits the player's 3x3 ring before unfreezing so a long flight cannot drop the player), rain/cover, underwater grading, and the coords readout, and `Player.set_photo_mode()` freezes input and hides the targeting highlight and held block; entering photo mode hides the HUD, minimap, and F3 worldgen overlay, and exiting restores the pre-entry HUD state (an F1 press inside photo mode persists). `tools/photo_mode_verify.gd` covers HUD state, camera handoff, look/movement, player artifact and UI-layer hiding, and screenshot naming; `_get_shadow_capture_state()` records HUD/camera mode @@ -56,7 +56,7 @@ Property names and file references are included so each item is easy to find. ## Geometry / materials - [x] `Texture2DArray` instead of the block atlas — `BlockRegistry` now builds one 64px layer per texture (per-image tint, alpha edge fix-up, mipmaps) and the mesher writes each face's layer into `ARRAY_CUSTOM1` (`ARRAY_CUSTOM_R_FLOAT`); `block.gdshader` samples `vec3(UV, layer)`, so there is no atlas inset and no cross-tile UV/mip bleeding - [ ] Chunk merging / MultiMesh — fewer draw calls for distant chunks -- [x] Chunk LOD meshes — `ChunkMesher.build_lod()` distance meshes (top quad per column + exposed side runs, no light volume/AO/collision) for chunks beyond `lod_distance` (the render distance, capped at 32 chunks for Extreme distances); full detail covers the configured render distance and rebuilds LOD<->full on approach/retreat with the same version checks as edits. Distance chunks carry compact per-column top/sub/water arrays (~3 KB) instead of a full 192-block data array (~50 KB), and full chunks expand compact neighbors on worker threads for correct light and culling. Pinned benchmark: full mesh CPU averages ~181 ms vs ~13 ms for LOD, and LOD generation ~12 ms vs ~41 ms full +- [x] Chunk LOD meshes — `ChunkMesher.build_lod()` distance meshes (top quad per column + merged exposed material runs, no light volume/AO/collision) for chunks beyond `lod_distance` (the render distance, capped at 32 chunks for Extreme distances); full detail covers the configured render distance and rebuilds LOD<->full on approach/retreat with the same version checks as edits. Distance chunks carry compact per-column top/sub/water arrays (~3 KB) instead of a full 192-block data array (~50 KB), and full chunks expand compact neighbors on worker threads for correct light and culling. Current pinned 5x5/eight-worker benchmark: 34.1 full chunks/s vs 180 compact LOD chunks/s, with ~93 ms full mesh vs ~3.6 ms merged LOD mesh average per concurrent job - [x] LOD tree canopies — compact columns bake the actual tree crowns using the same stamp functions as full chunks. Distance collection keeps only anchors inside the padded field and skips site-validity probes, which leave the field and re-enter the sampler (full parity cost ~19 ms per forest chunk, nearly full population). The top two tree blocks become the column's solid/sub pair, so distance forests read as real trees. Measured: LOD generation ~12.2 ms vs ~28.4 ms full, with the canopy pass ~1.2 ms; grove sample 142 canopy columns, open-ocean sample 0. `worldgen_lod_verify.gd` checks forest coverage and ocean exclusion - [x] LOD occlusion shading — distance meshes carry no AO or block-light volume, so far terrain read flat and cliffs showed a single soil stripe to the ground. Top faces now darken under taller cardinal neighbors (`LOD_AO_PER_BLOCK` 0.14, floor 0.52), side faces darken with depth, and sides below `LOD_SOIL_DEPTH` switch to stone (leaf columns stay leaf-textured). Pinned verification: flat spread 0.0000, hilly spread 1.5036, 5273 stone side faces; LOD mesh stays ~14 ms vs ~183 ms full - [x] LOD ground cover — compact columns now carry the same floor patches as full chunks (dirt/mud/gravel from the shared patch pass) plus low scrub bumps on grove edges and clearings (grove strength sampled on a stride-8 lattice, 12% of columns in the 0.45-0.85 band). `worldgen_lod_verify` checks patch propagation (2408 patch columns across a 7x7 grove area) alongside canopy coverage. Full generation ~30.8 ms, LOD ~12.5 ms diff --git a/tools/chunk_loading_benchmark.gd b/tools/chunk_loading_benchmark.gd new file mode 100644 index 0000000..459b1da --- /dev/null +++ b/tools/chunk_loading_benchmark.gd @@ -0,0 +1,157 @@ +extends SceneTree + +## Repeatable phase benchmark for chunk workers and detached main-thread mesh +## resources. It reports full-detail and compact-LOD latency, +## throughput, generation phase costs, mesh cost, and ArrayMesh/shape commit +## cost. Keep the seed, positions, and collision policy pinned so before/after +## runs are comparable. + +const SEED := 123456789 +const RADIUS := 2 +const CONFIG := { + "seed": SEED, + "world_type": 0, + "terrain_scale": 1.0, + "tree_density": 1.0, + "macro_scale": 384.0, + "river_density": 1.0, + "erosion_strength": 0.55, + "regional_erosion": 0.5, + "hydraulic_erosion": false, + "cave_density": 1.0, + "decoration_density": 1.0, +} + + +func _initialize() -> void: + var blocks := BlockRegistry.new() + var generator := TerrainGenerator.new() + generator.configure(CONFIG) + var mesher := ChunkMesher.new(blocks) + var positions := _positions() + var concurrency := clampi(OS.get_processor_count() / 2, 4, 8) + _warm_up(generator, mesher, blocks) + print("CHUNK LOAD BENCH seed=", SEED, " chunks=", positions.size(), + " concurrency=", concurrency) + _run_mode("full", false, generator, mesher, blocks, positions, concurrency) + _run_mode("lod", true, generator, mesher, blocks, positions, concurrency) + quit() + + +func _warm_up(generator: TerrainGenerator, mesher: ChunkMesher, blocks: BlockRegistry) -> void: + var generated := generator.generate_data(Vector2i(3, 3), {}, false) + var result := mesher.build(generated.data, generated.max_y, generated.heights, + generated.foliage_tints, generated.water_tints, ChunkMesher.NeighborSet.new(), true) + _warm_resources(result, blocks) + generated = generator.generate_data(Vector2i(4, 3), {}, true) + result = mesher.build_lod(generated.lod_solid_y, generated.lod_solid_id, generated.lod_sub_id, + generated.lod_water_y, generated.lod_water_level, generated.max_y, + generated.foliage_tints, generated.water_tints, ChunkMesher.LodNeighbors.new()) + _warm_resources(result, blocks) + + +func _warm_resources(result: ChunkMesher.MeshResult, blocks: BlockRegistry) -> void: + ChunkMesher.arrays_to_mesh(result.verts, result.normals, result.uvs, result.colors, + result.indices, blocks.material, result.light, result.layers) + ChunkMesher.arrays_to_mesh(result.water_verts, result.water_normals, result.water_uvs, + result.water_colors, result.water_indices, blocks.water_material, result.water_light) + if not result.collision.is_empty(): + var shape := ConcavePolygonShape3D.new() + shape.set_faces(result.collision) + + +func _positions() -> Array[Vector2i]: + var out: Array[Vector2i] = [] + for z in range(-RADIUS, RADIUS + 1): + for x in range(-RADIUS, RADIUS + 1): + out.append(Vector2i(x, z)) + out.sort_custom(func(a: Vector2i, b: Vector2i) -> bool: + return a.length_squared() < b.length_squared() + ) + return out + + +func _run_mode(label: String, lod: bool, generator: TerrainGenerator, mesher: ChunkMesher, + blocks: BlockRegistry, positions: Array[Vector2i], concurrency: int) -> void: + var start := Time.get_ticks_usec() + var next_position := 0 + var tasks: Array[Dictionary] = [] + var completed: Array[Dictionary] = [] + while next_position < positions.size() or not tasks.is_empty(): + while tasks.size() < concurrency and next_position < positions.size(): + var slot: Dictionary = {} + var pos := positions[next_position] + next_position += 1 + var task := WorkerThreadPool.add_task( + _job.bind(generator, mesher, pos, lod, slot), true, "chunk_load_bench") + tasks.append({"task": task, "slot": slot}) + var found := false + for index in range(tasks.size() - 1, -1, -1): + var entry: Dictionary = tasks[index] + if not WorkerThreadPool.is_task_completed(int(entry["task"])): + continue + WorkerThreadPool.wait_for_task_completion(int(entry["task"])) + completed.append(entry["slot"]) + tasks.remove_at(index) + found = true + if not found: + OS.delay_usec(200) + var worker_wall_us := Time.get_ticks_usec() - start + var commit_start := Time.get_ticks_usec() + var triangles := 0 + for slot in completed: + var result: ChunkMesher.MeshResult = slot["result"] + var mesh := ChunkMesher.arrays_to_mesh(result.verts, result.normals, result.uvs, + result.colors, result.indices, blocks.material, result.light, result.layers) + var water := ChunkMesher.arrays_to_mesh(result.water_verts, result.water_normals, + result.water_uvs, result.water_colors, result.water_indices, blocks.water_material, + result.water_light) + var shape: ConcavePolygonShape3D = null + if not result.collision.is_empty(): + shape = ConcavePolygonShape3D.new() + shape.set_faces(result.collision) + triangles += result.indices.size() / 3 + result.water_indices.size() / 3 + # Keep resources alive through the measured call. + slot["mesh"] = mesh + slot["water"] = water + slot["shape"] = shape + var commit_us := Time.get_ticks_usec() - commit_start + var terrain_us := 0 + var populate_us := 0 + var heightmap_us := 0 + var generation_us := 0 + var mesh_us := 0 + for slot in completed: + var timings: Dictionary = slot["timings"] + terrain_us += int(timings["terrain_us"]) + populate_us += int(timings["populate_us"]) + heightmap_us += int(timings["heightmap_us"]) + generation_us += int(timings["generation_us"]) + mesh_us += int(slot["mesh_us"]) + var count := float(completed.size()) + var wall_ms := float(worker_wall_us) / 1000.0 + print(label.to_upper(), " wall_ms=", snappedf(wall_ms, 0.01), + " chunks/s=", snappedf(count * 1000.0 / wall_ms, 0.1), + " avg_ms={terrain:", snappedf(float(terrain_us) / count / 1000.0, 0.01), + ", populate:", snappedf(float(populate_us) / count / 1000.0, 0.01), + ", heightmap:", snappedf(float(heightmap_us) / count / 1000.0, 0.01), + ", generation:", snappedf(float(generation_us) / count / 1000.0, 0.01), + ", mesh:", snappedf(float(mesh_us) / count / 1000.0, 0.01), + ", commit:", snappedf(float(commit_us) / count / 1000.0, 0.01), + "} triangles/chunk=", roundi(float(triangles) / count)) + + +func _job(generator: TerrainGenerator, mesher: ChunkMesher, pos: Vector2i, lod: bool, + slot: Dictionary) -> void: + var generated := generator.generate_data(pos, {}, lod) + var mesh_start := Time.get_ticks_usec() + if lod: + slot["result"] = mesher.build_lod(generated.lod_solid_y, generated.lod_solid_id, + generated.lod_sub_id, generated.lod_water_y, generated.lod_water_level, + generated.max_y, generated.foliage_tints, generated.water_tints, + ChunkMesher.LodNeighbors.new()) + else: + slot["result"] = mesher.build(generated.data, generated.max_y, generated.heights, + generated.foliage_tints, generated.water_tints, ChunkMesher.NeighborSet.new(), true) + slot["timings"] = generated.timings + slot["mesh_us"] = Time.get_ticks_usec() - mesh_start diff --git a/tools/chunk_loading_benchmark.gd.uid b/tools/chunk_loading_benchmark.gd.uid new file mode 100644 index 0000000..4c37c72 --- /dev/null +++ b/tools/chunk_loading_benchmark.gd.uid @@ -0,0 +1 @@ +uid://cgvm04h6uukjb diff --git a/tools/stream_full_verify.gd b/tools/stream_full_verify.gd index 849c60c..c6bab2c 100644 --- a/tools/stream_full_verify.gd +++ b/tools/stream_full_verify.gd @@ -7,7 +7,10 @@ extends SceneTree const RENDER_DISTANCE := 10 const CONFIG := {"seed": 918273, "tree_density": 1.0, "decoration_density": 1.0} -const MAX_WAIT_TICKS := 240 +# Shared CI runners can take just over 60 seconds to finish the 441-chunk pass +# under concurrent shards. Keep the assertion strict, but leave enough wall +# time to distinguish a real stalled queue from runner contention. +const MAX_WAIT_TICKS := 480 const WAIT_TICK := 0.25 var _failures := 0 @@ -28,10 +31,25 @@ func _run() -> void: var expected := (RENDER_DISTANCE * 2 + 1) * (RENDER_DISTANCE * 2 + 1) var waited := 0 - while (world._chunks.size() < expected or not world._pending.is_empty()) and waited < MAX_WAIT_TICKS: + var stream_start := Time.get_ticks_usec() + var visible_ms := -1.0 + while (world._chunks.size() < expected or not world._pending.is_empty() \ + or not world._gen_queue.is_empty() or not world._mesh_queue.is_empty() \ + or not world._generated.is_empty() or not world._commit_queue.is_empty()) \ + and waited < MAX_WAIT_TICKS: await create_timer(WAIT_TICK).timeout waited += 1 - print("STREAM FULL: chunks=%d/%d wait_ticks=%d" % [world._chunks.size(), expected, waited]) + if visible_ms < 0.0 and world._chunks.size() >= expected: + visible_ms = float(Time.get_ticks_usec() - stream_start) / 1000.0 + print("STREAM FULL: chunks=%d/%d visible_ms=%.1f full_ms=%.1f wait_ticks=%d" % [ + world._chunks.size(), expected, visible_ms, + float(Time.get_ticks_usec() - stream_start) / 1000.0, waited]) + if world._chunks.size() != expected: + _fail("stream timed out with %d/%d chunks" % [world._chunks.size(), expected]) + if not world._pending.is_empty() or not world._gen_queue.is_empty() \ + or not world._mesh_queue.is_empty() or not world._generated.is_empty() \ + or not world._commit_queue.is_empty(): + _fail("stream timed out with unfinished generation, mesh, or commit work") for pos in world._chunks.keys(): if (world._chunks[pos] as VoxelWorld.Chunk).lod: @@ -59,7 +77,9 @@ func _run() -> void: await create_timer(WAIT_TICK).timeout waited += 1 if world._stream_center != center or world._pending.size() > 0 \ - or world._gen_queue.size() > 0 or world._commit_queue.size() > 0: + or world._gen_queue.size() > 0 or world._mesh_queue.size() > 0 \ + or world._generated.size() > 0 \ + or world._commit_queue.size() > 0: continue var chunk: VoxelWorld.Chunk = world._chunks.get(center) if chunk != null and chunk.shape.shape != null: diff --git a/tools/worldgen_lod_verify.gd b/tools/worldgen_lod_verify.gd index ce2f543..2eaa660 100644 --- a/tools/worldgen_lod_verify.gd +++ b/tools/worldgen_lod_verify.gd @@ -46,6 +46,7 @@ func _initialize() -> void: _verify_chunk(clean, decorated, cave_free, mesher, pos) _verify_canopy_coverage(decorated) _verify_lod_shading(mesher) + _verify_unloaded_water_edges(mesher) if _failures == 0: print("WORLDGEN LOD VERIFY: PASS") else: @@ -249,6 +250,35 @@ func _verify_compact_neighbors(generator: TerrainGenerator, mesher: ChunkMesher, _fail("compact neighbors did not cull boundary faces at %s" % pos) +## Missing distance neighbors must not turn a flat ocean into four transparent +## chunk-edge curtains. Only the 16x16 water top should be emitted. +func _verify_unloaded_water_edges(mesher: ChunkMesher) -> void: + var solid_y := PackedInt32Array() + var solid_id := PackedByteArray() + var sub_id := PackedByteArray() + var water_y := PackedInt32Array() + var water_level := PackedByteArray() + var tints := PackedColorArray() + solid_y.resize(VoxelDefs.CHUNK_AREA) + solid_y.fill(12) + solid_id.resize(VoxelDefs.CHUNK_AREA) + solid_id.fill(BlockRegistry.BLOCK_SAND) + sub_id.resize(VoxelDefs.CHUNK_AREA) + sub_id.fill(BlockRegistry.BLOCK_SAND) + water_y.resize(VoxelDefs.CHUNK_AREA) + water_y.fill(VoxelDefs.SEA_LEVEL) + water_level.resize(VoxelDefs.CHUNK_AREA) + water_level.fill(8) + tints.resize(VoxelDefs.CHUNK_AREA) + tints.fill(Color.WHITE) + var mesh := mesher.build_lod(solid_y, solid_id, sub_id, water_y, water_level, + VoxelDefs.SEA_LEVEL, tints, tints, ChunkMesher.LodNeighbors.new()) + var expected_top_indices := VoxelDefs.CHUNK_AREA * 6 + if mesh.water_indices.size() != expected_top_indices: + _fail("unloaded ocean edge emitted water curtains: %d indices != %d" % [ + mesh.water_indices.size(), expected_top_indices]) + + func _top_face_heights(mesh: ChunkMesher.MeshResult) -> Dictionary: var tops := {} var index := 0 diff --git a/world/block.gdshader b/world/block.gdshader index 6b34322..c889fdc 100644 --- a/world/block.gdshader +++ b/world/block.gdshader @@ -1,7 +1,7 @@ shader_type spatial; render_mode diffuse_burley, specular_schlick_ggx, alpha_to_coverage; -uniform sampler2DArray albedo_texture : source_color, filter_linear_mipmap_anisotropic, repeat_disable; +uniform sampler2DArray albedo_texture : source_color, filter_linear_mipmap_anisotropic, repeat_enable; uniform float block_emission : hint_range(0.0, 2.0) = 0.45; uniform float min_light : hint_range(0.0, 1.0) = 0.05; // Camera-submerged strength (0 above water), driven by DayNightCycle. Caustics diff --git a/world/chunk_mesher.gd b/world/chunk_mesher.gd index 3209369..e36caf4 100644 --- a/world/chunk_mesher.gd +++ b/world/chunk_mesher.gd @@ -31,6 +31,8 @@ var _emission_b: PackedByteArray = PackedByteArray() class MeshResult: var data := PackedByteArray() var heights := PackedInt32Array() + var foliage_tints := PackedColorArray() + var water_tints := PackedColorArray() var max_y := 0 var mask := 0 var verts := PackedVector3Array() @@ -151,10 +153,14 @@ func build(data: PackedByteArray, data_max_y: int, heights: PackedInt32Array, fo var result := MeshResult.new() result.data = data result.heights = heights + result.foliage_tints = foliage_tints + result.water_tints = water_tints result.mask = neighbors.mask result.build_collision = want_collision + # Persist the authoritative inclusive content height. The extra air row is a + # local meshing bound only; storing it made max_y grow on every remesh. + result.max_y = data_max_y var max_y := clampi(data_max_y + 1, 1, VoxelDefs.WORLD_HEIGHT - 1) - result.max_y = max_y var light_volume := _assemble_light_volume(data, data_max_y, heights, neighbors) _compute_sky_light(light_volume) _compute_block_light(light_volume) @@ -242,6 +248,8 @@ const LOD_MIN_SIDE_SHADE: float = 0.5 func build_lod(solid_y: PackedInt32Array, solid_id: PackedByteArray, sub_id: PackedByteArray, water_y: PackedInt32Array, water_level: PackedByteArray, data_max_y: int, foliage_tints: PackedColorArray, water_tints: PackedColorArray, neighbors: LodNeighbors) -> MeshResult: var result := MeshResult.new() + result.foliage_tints = foliage_tints + result.water_tints = water_tints result.mask = neighbors.mask result.max_y = data_max_y result.lod_solid_y = solid_y @@ -278,21 +286,37 @@ func build_lod(solid_y: PackedInt32Array, solid_id: PackedByteArray, sub_id: Pac var edge_index := z if direction.x != 0 else x neighbor_solid = edge.solid[edge_index] neighbor_water = edge.water[edge_index] + elif top_water >= 0: + # An unknown chunk edge is not a shoreline. Treat its water + # surface as continuous until the neighbor arrives; otherwise + # transparent sea-level curtains expose each 16x16 LOD chunk as + # a large square split into visible diagonal triangles. + neighbor_water = top_water var face: int = LOD_SIDE_FACES[index] var exposed_from := maxi(neighbor_solid, neighbor_water) + 1 - var top := maxi(top_solid, top_water) - var leaves := solid_id[column] >= 0 and _leaves[solid_id[column]] == 1 - for y in range(exposed_from, top + 1): - if y > top_solid: - if level > 0 and y > neighbor_water: - var above_water := y + 1 <= top_water - _append_lod_water_face(face, x, y, z, 1.0 if above_water else _water_top(level), level < 8, _column_tint(column, water_tints), result) + # Distance sides used to emit one quad per vertical block, making a + # compact LOD almost as triangle-heavy as a full chunk. Merge each + # contiguous material run instead. Endpoint colors retain the depth + # gradient, reducing typical LOD geometry by an + # order of magnitude without changing its silhouette. + if top_solid >= exposed_from: + var leaves := _leaves[solid_id[column]] == 1 + if leaves: + _append_lod_block_run(face, x, exposed_from, top_solid, z, + solid_id[column], _tint_for(solid_id[column], column, foliage_tints), + result, top_ambient, top_solid) else: - var depth := top_solid - y - var id := solid_id[column] if depth == 0 else (sub_id[column] if (depth <= LOD_SOIL_DEPTH or leaves) else BlockRegistry.BLOCK_STONE) - var side_ambient: float = clampf(1.0 - float(depth) * LOD_DEPTH_DARKEN, LOD_MIN_SIDE_SHADE, 1.0) - side_ambient *= 1.0 - (1.0 - top_ambient) * 0.6 - _append_lod_block_face(face, x, y, z, id, _tint_for(id, column, foliage_tints), result, side_ambient) + _append_lod_block_run_clipped(face, x, exposed_from, top_solid - LOD_SOIL_DEPTH - 1, + top_solid, z, BlockRegistry.BLOCK_STONE, foliage_tints, column, result, top_ambient) + _append_lod_block_run_clipped(face, x, maxi(exposed_from, top_solid - LOD_SOIL_DEPTH), + top_solid - 1, top_solid, z, sub_id[column], foliage_tints, column, result, top_ambient) + _append_lod_block_run_clipped(face, x, maxi(exposed_from, top_solid), top_solid, + top_solid, z, solid_id[column], foliage_tints, column, result, top_ambient) + var water_from := maxi(exposed_from, top_solid + 1) + if level > 0 and top_water >= water_from and top_water > neighbor_water: + var run_from := maxi(water_from, neighbor_water + 1) + _append_lod_water_run(face, x, run_from, top_water, z, _water_top(level), + level < 8, _column_tint(column, water_tints), result) return result @@ -348,6 +372,78 @@ func _append_lod_water_face(face: int, x: int, y: int, z: int, top: float, flowi result.water_indices.append_array(PackedInt32Array([base, base + 2, base + 1, base, base + 3, base + 2])) +func _append_lod_block_run_clipped(face: int, x: int, from_y: int, to_y: int, + top_solid: int, z: int, block_id: int, foliage_tints: PackedColorArray, + column: int, result: MeshResult, top_ambient: float) -> void: + if from_y > to_y: + return + _append_lod_block_run(face, x, from_y, to_y, z, block_id, + _tint_for(block_id, column, foliage_tints), result, top_ambient, top_solid) + + +func _append_lod_block_run(face: int, x: int, from_y: int, to_y: int, z: int, + block_id: int, tint: Color, result: MeshResult, top_ambient: float, + top_solid: int) -> void: + var normal: Vector3i = VoxelDefs.FACE_NORMALS[face] + var layer := _layer_for(block_id, face) + var base := result.verts.size() + var face_verts: Array = VoxelDefs.FACE_VERTS[face] + var face_uvs: Array = VoxelDefs.FACE_UVS[face] + var run_height := to_y - from_y + 1 + var ecotone_shade := 1.0 - (1.0 - top_ambient) * 0.6 + for corner in 4: + var offset: Vector3i = face_verts[corner] + var vertex_y := to_y + 1 if offset.y == 1 else from_y + result.verts.append(Vector3(x + offset.x, vertex_y, z + offset.z)) + result.normals.append(Vector3(normal)) + var uv: Vector2 = face_uvs[corner] + uv.y *= float(run_height) + result.uvs.append(uv) + var sample_y := to_y if offset.y == 1 else from_y + var depth := top_solid - sample_y + var ambient: float = clampf(1.0 - float(depth) * LOD_DEPTH_DARKEN, + LOD_MIN_SIDE_SHADE, 1.0) * ecotone_shade + var shade: float = VoxelDefs.FACE_SHADE[face] * ambient + result.colors.append(Color(shade * tint.r, shade * tint.g, shade * tint.b, _wind[block_id])) + result.layers.push_back(float(layer)) + result.light.push_back(0.0) + result.light.push_back(0.0) + result.light.push_back(0.0) + result.light.push_back(1.0) + result.indices.push_back(base) + result.indices.push_back(base + 2) + result.indices.push_back(base + 1) + result.indices.push_back(base) + result.indices.push_back(base + 3) + result.indices.push_back(base + 2) + + +func _append_lod_water_run(face: int, x: int, from_y: int, to_y: int, z: int, + top: float, flowing: bool, tint: Color, result: MeshResult) -> void: + var normal: Vector3i = VoxelDefs.FACE_NORMALS[face] + var base := result.water_verts.size() + var face_verts: Array = VoxelDefs.FACE_VERTS[face] + var shade := 0.92 + var flow_alpha := 0.72 if flowing else 1.0 + for corner in 4: + var offset: Vector3i = face_verts[corner] + var vertex_y := float(from_y) if offset.y == 0 else float(to_y) + top + result.water_verts.append(Vector3(x + offset.x, vertex_y, z + offset.z)) + result.water_normals.append(Vector3(normal)) + result.water_uvs.append(VoxelDefs.FACE_UVS[face][corner]) + result.water_colors.append(Color(shade * tint.r, shade * tint.g, shade * tint.b, flow_alpha)) + result.water_light.push_back(0.0) + result.water_light.push_back(0.0) + result.water_light.push_back(0.0) + result.water_light.push_back(1.0) + result.water_indices.push_back(base) + result.water_indices.push_back(base + 2) + result.water_indices.push_back(base + 1) + result.water_indices.push_back(base) + result.water_indices.push_back(base + 3) + result.water_indices.push_back(base + 2) + + func make_block_mesh(block_id: int) -> ArrayMesh: if not _blocks.is_valid_id(block_id): block_id = BlockRegistry.BLOCK_GRASS diff --git a/world/terrain_generator.gd b/world/terrain_generator.gd index 239fcca..d5f27fc 100644 --- a/world/terrain_generator.gd +++ b/world/terrain_generator.gd @@ -12,6 +12,8 @@ class GenResult: var foliage_tints: PackedColorArray var water_tints: PackedColorArray var timings: Dictionary + var lod := false + var config_revision := -1 var lod_solid_y := PackedInt32Array() var lod_solid_id := PackedByteArray() var lod_sub_id := PackedByteArray() @@ -69,6 +71,7 @@ func generate_data(chunk_pos: Vector2i, edits: Dictionary, lod: bool = false) -> result.lod_sub_id = compact["sub_id"] result.lod_water_y = compact["water_y"] result.lod_water_level = compact["water_level"] + result.lod = true return result var populated: Dictionary = _populator.populate(chunk_pos, field, edits, true) var populate_us := Time.get_ticks_usec() - populate_start @@ -77,12 +80,13 @@ func generate_data(chunk_pos: Vector2i, edits: Dictionary, lod: bool = false) -> var heights_start := Time.get_ticks_usec() var heights := _build_heights(data, max_y) var heights_us := Time.get_ticks_usec() - heights_start - return GenResult.new(data, max_y, heights, _build_foliage_tints(field), _build_water_tints(field), { + var result := GenResult.new(data, max_y, heights, _build_foliage_tints(field), _build_water_tints(field), { "terrain_us": terrain_us, "populate_us": populate_us, "heightmap_us": heights_us, "generation_us": Time.get_ticks_usec() - total_start, }) + return result func _build_foliage_tints(field: ChunkTerrainData) -> PackedColorArray: diff --git a/world/voxel_world.gd b/world/voxel_world.gd index 5e7cac1..be32921 100644 --- a/world/voxel_world.gd +++ b/world/voxel_world.gd @@ -15,7 +15,7 @@ const MAX_FULL_DETAIL_DISTANCE := 32 ## distant full chunk rebuilds it to add collision instead of holding a shape ## for every loaded chunk. const COLLISION_DISTANCE := 6 -const COMMIT_BUDGET_MS := 5 +const COMMIT_BUDGET_MS := 2 ## Diagnostic map rasters resolve one mode sample per pixel. The final-height ## family walks the erosion graph five times per sample, so map views request ## fewer pixels for those modes instead of freezing their refresh for seconds. @@ -47,6 +47,9 @@ var _pending: Dictionary = {} var _commit_queue: Array = [] var _gen_queue: Array[Vector2i] = [] var _gen_queued: Dictionary = {} +var _mesh_queue: Array[Vector2i] = [] +var _mesh_queued: Dictionary = {} +var _generated: Dictionary = {} var _dirty: Dictionary = {} var _desired: Dictionary = {} var _edited_blocks: Dictionary = {} @@ -75,6 +78,8 @@ var _debug_cache: Dictionary = {} class Chunk: var data := PackedByteArray() var heights := PackedInt32Array() + var foliage_tints := PackedColorArray() + var water_tints := PackedColorArray() var lod_solid_y := PackedInt32Array() var lod_solid_id := PackedByteArray() var lod_sub_id := PackedByteArray() @@ -91,6 +96,7 @@ class Chunk: class PendingJob: var task := -1 + var kind := "generate" var version := 0 var config_revision := 0 var lod := false @@ -104,7 +110,8 @@ class CommitItem: var config_revision := 0 var lod := false - func _init(p_pos: Vector2i, p_result: ChunkMesher.MeshResult, p_version: int, p_config_revision: int, p_lod: bool) -> void: + func _init(p_pos: Vector2i, p_result: ChunkMesher.MeshResult, p_version: int, + p_config_revision: int, p_lod: bool) -> void: pos = p_pos result = p_result version = p_version @@ -225,6 +232,8 @@ func _generate_spawn_area() -> void: func _rebuild_desired() -> void: _desired.clear() + _mesh_queue.clear() + _mesh_queued.clear() var wanted: Array[Vector2i] = [] for dx in range(-render_distance, render_distance + 1): for dz in range(-render_distance, render_distance + 1): @@ -233,7 +242,11 @@ func _rebuild_desired() -> void: var want_lod := _chunk_uses_lod(pos) if _chunks.has(pos) and (_chunks[pos] as Chunk).lod != want_lod: _dirty[pos] = true - if not _chunks.has(pos) and not _pending.has(pos): + var staged: TerrainGenerator.GenResult = _generated.get(pos) + if staged != null and (staged.lod != want_lod \ + or staged.config_revision != _worldgen_revision): + _generated.erase(pos) + if not _chunks.has(pos) and not _generated.has(pos) and not _pending.has(pos): wanted.append(pos) var center := _stream_center wanted.sort_custom(func(a: Vector2i, b: Vector2i) -> bool: @@ -245,19 +258,31 @@ func _rebuild_desired() -> void: _gen_queued[pos] = true for pos in _dirty.keys(): if _desired.has(pos) and not _pending.has(pos) and not _gen_queued.has(pos): - _gen_queue.push_front(pos) - _gen_queued[pos] = true + _queue_rebuild(pos) + for pos in _generated.keys(): + if not _desired.has(pos): + _generated.erase(pos) + else: + _queue_generated_mesh_if_ready(pos) func _schedule_jobs() -> void: - if _gen_queue.is_empty(): + if _gen_queue.is_empty() and _mesh_queue.is_empty(): return - while _pending.size() < _max_active_jobs and not _gen_queue.is_empty(): - var pos: Vector2i = _gen_queue.pop_front() - _gen_queued.erase(pos) + while _pending.size() < _max_active_jobs and (not _mesh_queue.is_empty() or not _gen_queue.is_empty()): + var mesh_job := not _mesh_queue.is_empty() + var pos: Vector2i + if mesh_job: + pos = _mesh_queue.pop_front() + _mesh_queued.erase(pos) + else: + pos = _gen_queue.pop_front() + _gen_queued.erase(pos) if _pending.has(pos): continue - if _chunks.has(pos) and not _dirty.has(pos): + if mesh_job and not _chunks.has(pos) and not _generated.has(pos): + continue + if not mesh_job and (_chunks.has(pos) or _generated.has(pos)) and not _dirty.has(pos): continue _dirty.erase(pos) var lod := _chunk_uses_lod(pos) @@ -266,11 +291,38 @@ func _schedule_jobs() -> void: job.config_revision = _worldgen_revision job.lod = lod job.slot = {} - job.task = WorkerThreadPool.add_task( - _run_chunk_job.bind(pos, _chunk_edits_for(pos), _gather_job_neighbors(pos, lod), job.slot, lod, - not lod and _within_collision_range(pos)), - true, - "voxel_chunk") + var high_priority := not lod and _within_collision_range(pos) + var chunk: Chunk = _chunks.get(pos) + if mesh_job and chunk != null and chunk.lod == lod: + job.kind = "mesh" + if lod: + job.task = WorkerThreadPool.add_task( + _run_lod_remesh_job.bind(chunk.lod_solid_y.duplicate(), chunk.lod_solid_id.duplicate(), + chunk.lod_sub_id.duplicate(), chunk.lod_water_y.duplicate(), + chunk.lod_water_level.duplicate(), chunk.max_y, + chunk.foliage_tints.duplicate(), chunk.water_tints.duplicate(), + _gather_lod_neighbors(pos), job.slot), + false, "voxel_lod_remesh") + else: + job.task = WorkerThreadPool.add_task( + _run_full_remesh_job.bind(chunk.data.duplicate(), chunk.foliage_tints.duplicate(), + chunk.water_tints.duplicate(), _gather_neighbors(pos), job.slot, high_priority), + high_priority, "voxel_full_remesh") + elif mesh_job: + job.kind = "mesh" + var generated: TerrainGenerator.GenResult = _generated.get(pos) + if generated == null: + continue + job.task = WorkerThreadPool.add_task( + _run_generated_mesh_job.bind(generated, _gather_generated_neighbors(pos, lod), + job.slot, lod, high_priority), + high_priority, "voxel_generated_mesh") + else: + job.kind = "generate" + job.task = WorkerThreadPool.add_task( + _run_generation_job.bind(pos, _chunk_edits_for(pos), job.slot, lod), + high_priority, + "voxel_generate") _pending[pos] = job @@ -280,7 +332,8 @@ func _process_commit_queue() -> void: var item: CommitItem = _commit_queue.pop_front() if not _desired.has(item.pos): continue - if item.version != _chunk_edit_version.get(item.pos, 0) or item.config_revision != _worldgen_revision or item.lod != _chunk_uses_lod(item.pos): + var mode_stale := item.lod != _chunk_uses_lod(item.pos) + if item.version != _chunk_edit_version.get(item.pos, 0) or item.config_revision != _worldgen_revision or mode_stale: _queue_rebuild(item.pos) continue _commit_chunk(item.pos, item.result, item.lod) @@ -298,18 +351,30 @@ func _collect_jobs() -> void: if _dirty.has(pos): # An edit invalidated this job's neighbor snapshot while it was running. # Discard the stale result and immediately schedule a fresh build. - if _desired.has(pos) and not _gen_queued.has(pos): - _gen_queue.push_front(pos) - _gen_queued[pos] = true + if _desired.has(pos): + _queue_rebuild(pos) + continue + if job.kind == "generate": + if job.slot.has("generated") and job.slot["generated"] != null \ + and _desired.has(pos) and job.version == _chunk_edit_version.get(pos, 0) \ + and job.config_revision == _worldgen_revision and job.lod == _chunk_uses_lod(pos): + var generated: TerrainGenerator.GenResult = job.slot["generated"] + generated.config_revision = job.config_revision + _generated[pos] = generated + _queue_generated_meshes_around(pos) + elif _desired.has(pos): + _queue_rebuild(pos) continue if job.slot.has("result") and job.slot["result"] != null: _record_job_metrics(job.slot, job.lod) - _commit_queue.append(CommitItem.new(pos, job.slot["result"], job.version, job.config_revision, job.lod)) + _commit_queue.append(CommitItem.new(pos, job.slot["result"], job.version, + job.config_revision, job.lod)) ## Worker-thread entry point. Only reads state that is immutable while jobs ## are in flight (generator noise set, mesher tables, block registry). -func _run_chunk_job(chunk_pos: Vector2i, edits: Dictionary, neighbors, slot: Dictionary, lod: bool, want_collision: bool = true) -> void: +func _run_chunk_job(chunk_pos: Vector2i, edits: Dictionary, neighbors, slot: Dictionary, + lod: bool, want_collision: bool = true) -> void: var generated := _generator.generate_data(chunk_pos, edits, lod) var mesh_start := Time.get_ticks_usec() if lod: @@ -320,6 +385,69 @@ func _run_chunk_job(chunk_pos: Vector2i, edits: Dictionary, neighbors, slot: Dic slot["mesh_us"] = Time.get_ticks_usec() - mesh_start +func _run_generation_job(chunk_pos: Vector2i, edits: Dictionary, slot: Dictionary, + lod: bool) -> void: + slot["generated"] = _generator.generate_data(chunk_pos, edits, lod) + + +func _run_generated_mesh_job(generated: TerrainGenerator.GenResult, neighbors, + slot: Dictionary, lod: bool, want_collision: bool) -> void: + var mesh_start := Time.get_ticks_usec() + if lod: + slot["result"] = _mesher.build_lod(generated.lod_solid_y, generated.lod_solid_id, + generated.lod_sub_id, generated.lod_water_y, generated.lod_water_level, + generated.max_y, generated.foliage_tints, generated.water_tints, neighbors) + else: + slot["result"] = _mesher.build(generated.data, generated.max_y, generated.heights, + generated.foliage_tints, generated.water_tints, neighbors, want_collision) + slot["timings"] = generated.timings + slot["mesh_us"] = Time.get_ticks_usec() - mesh_start + + +## Rebuilds an authoritative loaded chunk from a thread-safe voxel snapshot. +## Edits already mutate Chunk.data, so rerunning terrain, caves, ores, and +## population here was redundant and dominated seam/collision rebuild cost. +func _run_full_remesh_job(data: PackedByteArray, foliage_tints: PackedColorArray, + water_tints: PackedColorArray, neighbors: ChunkMesher.NeighborSet, + slot: Dictionary, want_collision: bool) -> void: + var scan_start := Time.get_ticks_usec() + var heights := PackedInt32Array() + heights.resize(VoxelDefs.CHUNK_AREA) + var max_y := 0 + for z in VoxelDefs.CHUNK_SIZE: + for x in VoxelDefs.CHUNK_SIZE: + var column := x + z * VoxelDefs.DATA_STRIDE_Z + var top := -1 + for y in range(VoxelDefs.WORLD_HEIGHT - 1, -1, -1): + if data[column + y * VoxelDefs.DATA_STRIDE_Y] != BlockRegistry.BLOCK_AIR: + top = y + break + heights[column] = top + max_y = maxi(max_y, top) + var scan_us := Time.get_ticks_usec() - scan_start + var mesh_start := Time.get_ticks_usec() + slot["result"] = _mesher.build(data, max_y, heights, foliage_tints, water_tints, + neighbors, want_collision) + slot["timings"] = { + "terrain_us": 0, + "populate_us": 0, + "heightmap_us": scan_us, + "generation_us": 0, + } + slot["mesh_us"] = Time.get_ticks_usec() - mesh_start + + +func _run_lod_remesh_job(solid_y: PackedInt32Array, solid_id: PackedByteArray, + sub_id: PackedByteArray, water_y: PackedInt32Array, water_level: PackedByteArray, + max_y: int, foliage_tints: PackedColorArray, water_tints: PackedColorArray, + neighbors: ChunkMesher.LodNeighbors, slot: Dictionary) -> void: + var mesh_start := Time.get_ticks_usec() + slot["result"] = _mesher.build_lod(solid_y, solid_id, sub_id, water_y, water_level, + max_y, foliage_tints, water_tints, neighbors) + slot["timings"] = {"terrain_us": 0, "populate_us": 0, "heightmap_us": 0, "generation_us": 0} + slot["mesh_us"] = Time.get_ticks_usec() - mesh_start + + func _record_job_metrics(slot: Dictionary, lod: bool) -> void: var timings: Dictionary = slot.get("timings", {}) var alpha := 0.12 @@ -347,10 +475,78 @@ func _chunk_uses_lod(pos: Vector2i) -> bool: return maxi(absi(pos.x - _stream_center.x), absi(pos.y - _stream_center.y)) > lod_distance -func _gather_job_neighbors(pos: Vector2i, lod: bool): +func _queue_generated_meshes_around(pos: Vector2i) -> void: + _queue_generated_mesh_if_ready(pos) + for direction in VoxelDefs.DIRS_8: + _queue_generated_mesh_if_ready(pos + direction) + + +func _queue_generated_mesh_if_ready(pos: Vector2i) -> void: + if not _generated.has(pos) or not _desired.has(pos) or _pending.has(pos) \ + or _mesh_queued.has(pos) or not _generated_neighbors_ready(pos): + return + _mesh_queue.append(pos) + _mesh_queued[pos] = true + + +func _generated_neighbors_ready(pos: Vector2i) -> bool: + var lod := _chunk_uses_lod(pos) + var directions := VoxelDefs.DIRS_4 if lod else VoxelDefs.DIRS_8 + for direction in directions: + var neighbor_pos: Vector2i = pos + direction + if not _desired.has(neighbor_pos): + continue + var chunk: Chunk = _chunks.get(neighbor_pos) + if chunk != null and chunk.lod == _chunk_uses_lod(neighbor_pos): + continue + var generated: TerrainGenerator.GenResult = _generated.get(neighbor_pos) + if generated == null or generated.lod != _chunk_uses_lod(neighbor_pos) \ + or generated.config_revision != _worldgen_revision: + return false + return true + + +func _gather_generated_neighbors(pos: Vector2i, lod: bool): if lod: - return _gather_lod_neighbors(pos) - return _gather_neighbors(pos) + var lod_out := ChunkMesher.LodNeighbors.new() + for index in VoxelDefs.DIRS_4.size(): + var direction: Vector2i = VoxelDefs.DIRS_4[index] + var neighbor_pos := pos + direction + var chunk: Chunk = _chunks.get(neighbor_pos) + if chunk != null and chunk.lod == _chunk_uses_lod(neighbor_pos): + lod_out.edges[direction] = _build_lod_edge(chunk, direction) + elif _generated.has(neighbor_pos): + lod_out.edges[direction] = _build_generated_lod_edge(_generated[neighbor_pos], direction) + else: + continue + lod_out.mask |= (1 << index) + return lod_out + var out := ChunkMesher.NeighborSet.new() + for index in VoxelDefs.DIRS_8.size(): + var direction: Vector2i = VoxelDefs.DIRS_8[index] + var neighbor_pos := pos + direction + var chunk: Chunk = _chunks.get(neighbor_pos) + if chunk != null and chunk.lod == _chunk_uses_lod(neighbor_pos): + if chunk.lod: + out.samples[direction] = ChunkMesher.NeighborSample.from_lod( + chunk.lod_solid_y.duplicate(), chunk.lod_solid_id.duplicate(), + chunk.lod_sub_id.duplicate(), chunk.lod_water_y.duplicate()) + else: + out.samples[direction] = ChunkMesher.NeighborSample.new( + chunk.data.duplicate(), chunk.max_y, chunk.heights.duplicate()) + elif _generated.has(neighbor_pos): + var generated: TerrainGenerator.GenResult = _generated[neighbor_pos] + if generated.lod: + out.samples[direction] = ChunkMesher.NeighborSample.from_lod( + generated.lod_solid_y, generated.lod_solid_id, + generated.lod_sub_id, generated.lod_water_y) + else: + out.samples[direction] = ChunkMesher.NeighborSample.new( + generated.data.duplicate(), generated.max_y, generated.heights.duplicate()) + else: + continue + out.mask |= (1 << index) + return out func _gather_lod_neighbors(pos: Vector2i) -> ChunkMesher.LodNeighbors: @@ -396,6 +592,35 @@ func _build_lod_edge(chunk: Chunk, direction: Vector2i) -> ChunkMesher.LodEdge: return edge +func _build_generated_lod_edge(generated: TerrainGenerator.GenResult, + direction: Vector2i) -> ChunkMesher.LodEdge: + var edge := ChunkMesher.LodEdge.new() + edge.solid.resize(VoxelDefs.CHUNK_SIZE) + edge.water.resize(VoxelDefs.CHUNK_SIZE) + for index in VoxelDefs.CHUNK_SIZE: + var local_x := index if direction.y != 0 else (0 if direction.x > 0 else VoxelDefs.CHUNK_SIZE - 1) + var local_z := index if direction.x != 0 else (0 if direction.y > 0 else VoxelDefs.CHUNK_SIZE - 1) + var column := local_x + local_z * VoxelDefs.DATA_STRIDE_Z + if generated.lod: + edge.solid[index] = generated.lod_solid_y[column] + edge.water[index] = generated.lod_water_y[column] + continue + var solid := ChunkMesher.LOD_NONE + var water := ChunkMesher.LOD_NONE + for y in range(generated.max_y, -1, -1): + var id: int = generated.data[column + y * VoxelDefs.DATA_STRIDE_Y] + if water == ChunkMesher.LOD_NONE and _blocks.is_water_id(id): + water = y + if solid == ChunkMesher.LOD_NONE and id != BlockRegistry.BLOCK_AIR \ + and not _blocks.is_water_id(id) and not _blocks.has_flag(id, BlockRegistry.FLAG_CROSS): + solid = y + if solid != ChunkMesher.LOD_NONE and water != ChunkMesher.LOD_NONE: + break + edge.solid[index] = solid + edge.water[index] = water + return edge + + func _gather_neighbors(pos: Vector2i) -> ChunkMesher.NeighborSet: var out := ChunkMesher.NeighborSet.new() for index in VoxelDefs.DIRS_8.size(): @@ -414,13 +639,17 @@ func _gather_neighbors(pos: Vector2i) -> ChunkMesher.NeighborSet: func _commit_chunk(pos: Vector2i, res: ChunkMesher.MeshResult, lod: bool) -> void: + _generated.erase(pos) var chunk: Chunk = _chunks.get(pos) + var mode_changed := chunk != null and chunk.lod != lod if chunk == null: chunk = _create_chunk_nodes(pos) _chunks[pos] = chunk chunk.lod = lod chunk.data = res.data chunk.heights = res.heights + chunk.foliage_tints = res.foliage_tints + chunk.water_tints = res.water_tints chunk.lod_solid_y = res.lod_solid_y chunk.lod_solid_id = res.lod_solid_id chunk.lod_sub_id = res.lod_sub_id @@ -438,6 +667,16 @@ func _commit_chunk(pos: Vector2i, res: ChunkMesher.MeshResult, lod: bool) -> voi else: chunk.shape.shape = null _remesh_on_commit_neighbors(pos) + if mode_changed: + _invalidate_mode_change_neighbors(pos) + + +func _invalidate_mode_change_neighbors(pos: Vector2i) -> void: + for direction in VoxelDefs.DIRS_8: + var neighbor_pos: Vector2i = pos + direction + var neighbor: Chunk = _chunks.get(neighbor_pos) + if neighbor != null and _desired_neighbors_ready(neighbor_pos, neighbor.lod): + _queue_rebuild(neighbor_pos, true, true) func _within_collision_range(pos: Vector2i) -> bool: @@ -471,22 +710,52 @@ func _remesh_on_commit_neighbors(pos: Vector2i) -> void: for index in VoxelDefs.DIRS_8.size(): var direction: Vector2i = VoxelDefs.DIRS_8[index] var neighbor: Chunk = _chunks.get(pos + direction) - if neighbor == null or neighbor.lod: + if neighbor == null or (neighbor.lod and index >= VoxelDefs.DIRS_4.size()): continue - if (neighbor.mask & REBUILD_OPPOSITE_BITS[index]) == 0: - _queue_rebuild(pos + direction, true) + if (neighbor.mask & REBUILD_OPPOSITE_BITS[index]) == 0 \ + and _desired_neighbors_ready(pos + direction, neighbor.lod): + _queue_rebuild(pos + direction, true, true) + + +## Wait until the complete desired neighbor ring exists before doing one seam +## rebuild. Rebuilding after every individual commit caused a burst of up to +## eight redundant terrain+mesh jobs per chunk and noticeable stream stutter. +func _desired_neighbors_ready(pos: Vector2i, lod: bool) -> bool: + var directions := VoxelDefs.DIRS_4 if lod else VoxelDefs.DIRS_8 + for direction in directions: + var neighbor_pos: Vector2i = pos + direction + if not _desired.has(neighbor_pos): + continue + var neighbor: Chunk = _chunks.get(neighbor_pos) + if neighbor == null or neighbor.lod != _chunk_uses_lod(neighbor_pos): + return false + return true -func _queue_rebuild(pos: Vector2i, preserve_if_pending := false) -> void: +func _queue_rebuild(pos: Vector2i, preserve_if_pending := false, + low_priority := false) -> void: if _pending.has(pos): if preserve_if_pending: _dirty[pos] = true return - if _gen_queued.has(pos): + if _gen_queued.has(pos) or _mesh_queued.has(pos): return _dirty[pos] = true - _gen_queue.push_front(pos) - _gen_queued[pos] = true + var chunk: Chunk = _chunks.get(pos) + var can_remesh := chunk != null and chunk.lod == _chunk_uses_lod(pos) + if can_remesh: + if low_priority: + _mesh_queue.append(pos) + else: + _mesh_queue.push_front(pos) + _mesh_queued[pos] = true + else: + _generated.erase(pos) + if low_priority: + _gen_queue.append(pos) + else: + _gen_queue.push_front(pos) + _gen_queued[pos] = true func _unload_far() -> void: @@ -929,14 +1198,16 @@ func get_worldgen_stats() -> Dictionary: var full_chunks := 0 var lod_chunks := 0 for chunk_value in _chunks.values(): - if (chunk_value as Chunk).lod: + var chunk := chunk_value as Chunk + if chunk.lod: lod_chunks += 1 else: full_chunks += 1 return { "chunks": "%d full / %d lod" % [full_chunks, lod_chunks], "pending": _pending.size(), - "queued": _gen_queue.size(), + "queued": _gen_queue.size() + _mesh_queue.size(), + "generated waiting": _generated.size(), "commits": _commit_queue.size(), "gen ms ema": _generation_ema_ms, "terrain ms": _terrain_ema_ms, diff --git a/world/worldgen/README.md b/world/worldgen/README.md index f5e2c23..ff17177 100644 --- a/world/worldgen/README.md +++ b/world/worldgen/README.md @@ -31,12 +31,35 @@ catalogs/samplers used by worker jobs. A chunk then runs these ordered stages: population. `ChunkMesher.build_lod()` consumes those arrays directly, and full chunks expand compact neighbors on the worker thread. Keep the compact and full surface choices in sync: both come from `_surface_rule_values()`. +7. `ChunkMesher.build_lod()` merges vertical material runs so permanent compact + distance meshes stay cheap to upload. Full-detail streaming does not use + temporary preview geometry: every visible near chunk is authoritative. + +`VoxelWorld` schedules cold chunks in two worker stages. It first generates the +authoritative voxel/column data, then waits for the desired neighbor ring before +meshing. This closes seams on the first upload and avoids regenerating/remeshing +each chunk as eight neighbors arrive. Later edits, collision additions, and +neighbor invalidations rebuild from immutable snapshots of loaded chunk data; +only an actual full/LOD mode transition reruns terrain generation. All coordinates are global and all generation state is read-only after configuration. Do not add mutable sampler caches or scene-tree access to worker code. Any cache must be bounded, synchronized, and keyed by the complete config revision. `VoxelWorld.configure()` must still run before chunk jobs are queued. +Measure changes with: + +```sh +redot --headless --path . --script res://tools/chunk_loading_benchmark.gd +``` + +The warmed phase benchmark pins the seed and 5x5 position set and reports worker wall time, +chunks/second, terrain/population/height/mesh averages, main-thread mesh and +shape-resource creation time, and triangle count for both full and compact LOD +chunks. It intentionally excludes `VoxelWorld` scheduling and neighbors; use +`stream_full_verify.gd` to time the end-to-end authoritative stream. +Compare like-for-like runs on the same machine. + ## Main tuning controls Defaults and persisted values live in `autoload/game_config.gd`; validation and diff --git a/world/worldgen/terrain_sampler.gd b/world/worldgen/terrain_sampler.gd index 3d3fb71..59a0360 100644 --- a/world/worldgen/terrain_sampler.gd +++ b/world/worldgen/terrain_sampler.gd @@ -367,7 +367,7 @@ func build_field(chunk_pos: Vector2i) -> ChunkTerrainData: ## Diagnostic-friendly point query. Unlike build_field, this deliberately returns -## a Dictionary and may be used by tools, previews, or spawn inspection. +## a Dictionary and may be used by tools, compact LOD, or spawn inspection. func sample_point(x: int, z: int) -> Dictionary: _ensure_configured() var mainland: float = _mainland_continentalness_at(x, z)