diff --git a/docs/EQUIPE_B_ARCHITECTURE.md b/docs/EQUIPE_B_ARCHITECTURE.md index 49dc1da..88b0c61 100644 --- a/docs/EQUIPE_B_ARCHITECTURE.md +++ b/docs/EQUIPE_B_ARCHITECTURE.md @@ -219,6 +219,12 @@ encontra o maior suporte em cada coluna X/Z dos chunks carregados e cria contorn compartilhados. Em um degrau, os dois contornos ficam em suas respectivas alturas, sem uma diagonal que atravesse blocos. +O caminho normal de streaming representa alturas e bloqueadores em matrizes +NumPy da região contígua e gera somente uma cópia das arestas compartilhadas. +Para snapshots esparsos com ilhas muito distantes, o algoritmo preserva um +fallback baseado em coordenadas, evitando alocação proporcional à distância +entre os grupos de chunks. + `WATER` não cria uma célula tática e também exclui a grade do leito submerso; movimentação aquática continua fora de escopo. Folhas não substituem o suporte abaixo delas. A etapa atual representa somente a superfície externa; @@ -229,8 +235,11 @@ pisos internos de cavernas e múltiplos andares não são enumerados. `WorldManager` expõe uma revisão monotônica barata e `get_loaded_chunks_snapshot()`, uma cópia rasa protegida por lock. O snapshot e `GridOverlayRenderer` só refaz seu VBO quando essa revisão muda e a grade está -visível; o caminho -normal é um único `glDrawArrays(GL_LINES, ...)`. Os shaders `grid.vert` e +visível. Revisões que mudam em frames consecutivos continuam coalescidas, mas o +snapshot só é sincronizado depois que solicitações, geração, integração e fila +de resultados da sequência atual terminam. Isso evita reconstruir a grade para +estados intermediários mesmo quando a worker cede tempo à thread principal. O +caminho normal é um único `glDrawArrays(GL_LINES, ...)`. Os shaders `grid.vert` e `grid.frag` aplicam as mesmas `Projection`, `View` e `Model` animada do terreno, mantendo alinhamento durante pan, zoom, resize e Q/E. diff --git a/src/interaction/README.md b/src/interaction/README.md index 08f1f2c..4fb16a2 100644 --- a/src/interaction/README.md +++ b/src/interaction/README.md @@ -55,8 +55,13 @@ invalida o leito submerso antes de gerar geometria. O `WorldManager` expõe uma revisão monotônica barata e `get_loaded_chunks_snapshot()`, uma cópia rasa protegida por lock. O snapshot e o VBO só são obtidos/refeitos quando a revisão muda, e não são sequer -sincronizados enquanto o grid está desligado. A renderização -usa as mesmas matrizes `Projection`, `View` e `Model` animada do terreno, mantém +sincronizados enquanto o grid está desligado. Em regiões contíguas de +streaming, alturas, bloqueadores e segmentos compartilhados são calculados em +arrays NumPy; snapshots com ilhas muito distantes usam um caminho esparso para +não alocar a área vazia entre elas. Revisões consecutivas de uma mesma sequência +de streaming são coalescidas e o snapshot só é sincronizado quando não há +solicitações, geração ou resultados pendentes no `WorldManager`. A +renderização usa as mesmas matrizes `Projection`, `View` e `Model` animada do terreno, mantém o depth test ativo, desativa escrita de profundidade e aplica apenas esse pequeno offset contra z-fighting. `G` alterna a visibilidade por evento PRESS. Não há suporte a múltiplos andares ou navegação de cavernas nesta etapa. diff --git a/src/interactive/grid_overlay.py b/src/interactive/grid_overlay.py index 5048d9c..1065ca4 100644 --- a/src/interactive/grid_overlay.py +++ b/src/interactive/grid_overlay.py @@ -16,39 +16,34 @@ PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) SURFACE_EPSILON = 0.002 +_LOCAL_Y = np.arange(Chunk3D.SIZE, dtype=np.int32)[None, :, None] +_SURFACE_LUT = np.zeros(256, dtype=bool) +_SURFACE_LUT[[int(block) for block in TACTICAL_SURFACE_BLOCKS]] = True +_BLOCKER_LUT = np.zeros(256, dtype=bool) +_BLOCKER_LUT[[int(block) for block in TACTICAL_SURFACE_BLOCKERS]] = True +_NO_HEIGHT = np.iinfo(np.int32).min -def build_surface_grid_vertices( +def _build_sparse_surface_grid_vertices( chunks: Mapping[tuple[int, int, int], Chunk3D], - *, - epsilon: float = SURFACE_EPSILON, + epsilon: float, ) -> np.ndarray: - """Constrói segmentos ``GL_LINES`` sobre as células táticas carregadas. - - Cada contorno permanece horizontal na altura da própria célula. Em uma - borda entre alturas diferentes os dois contornos são preservados; assim não - há uma linha diagonal atravessando a face lateral de um voxel. Segmentos - idênticos em terreno plano são compartilhados. - """ - - if not chunks: - return np.empty((0, 3), dtype=np.float32) + """Constrói a grade sem alocar a área entre regiões muito distantes.""" - support_values = np.fromiter(TACTICAL_SURFACE_BLOCKS, dtype=np.uint8) - blocker_values = np.fromiter(TACTICAL_SURFACE_BLOCKERS, dtype=np.uint8) heights: dict[tuple[int, int], int] = {} blocker_heights: dict[tuple[int, int], int] = {} for (chunk_x, chunk_y, chunk_z), chunk in chunks.items(): - walkable = np.isin(chunk.blocks, support_values) origin_x = chunk_x * Chunk3D.SIZE origin_y = chunk_y * Chunk3D.SIZE origin_z = chunk_z * Chunk3D.SIZE - local_y = np.arange(Chunk3D.SIZE, dtype=np.int32)[None, :, None] - for mask, target in ((walkable, heights), (np.isin(chunk.blocks, blocker_values), blocker_heights)): + for mask, target in ( + (_SURFACE_LUT[chunk.blocks], heights), + (_BLOCKER_LUT[chunk.blocks], blocker_heights), + ): if not mask.any(): continue - local_heights = np.where(mask, local_y, -1).max(axis=1) + local_heights = np.where(mask, _LOCAL_Y, -1).max(axis=1) for local_x, local_z in np.argwhere(local_heights >= 0): position = (origin_x + int(local_x), origin_z + int(local_z)) height = origin_y + int(local_heights[local_x, local_z]) @@ -71,6 +66,106 @@ def build_surface_grid_vertices( ).reshape(-1, 3) +def _grid_edges( + x: np.ndarray, + y: np.ndarray, + z: np.ndarray, + *, + x_offset: int = 0, + z_offset: int = 0, +) -> np.ndarray: + """Converte vetores de início/fim em pares de vértices ``GL_LINES``.""" + + return np.column_stack( + (x, y, z, x + x_offset, y, z + z_offset) + ).astype(np.float32, copy=False).reshape(-1, 2, 3) + + +def build_surface_grid_vertices( + chunks: Mapping[tuple[int, int, int], Chunk3D], + *, + epsilon: float = SURFACE_EPSILON, +) -> np.ndarray: + """Constrói segmentos ``GL_LINES`` sobre as células táticas carregadas. + + Cada contorno permanece horizontal na altura da própria célula. Em uma + borda entre alturas diferentes os dois contornos são preservados; assim não + há uma linha diagonal atravessando a face lateral de um voxel. Segmentos + idênticos em terreno plano são compartilhados. + """ + + if not chunks: + return np.empty((0, 3), dtype=np.float32) + + chunk_columns = {(chunk_x, chunk_z) for chunk_x, _, chunk_z in chunks} + min_chunk_x = min(chunk_x for chunk_x, _ in chunk_columns) + max_chunk_x = max(chunk_x for chunk_x, _ in chunk_columns) + min_chunk_z = min(chunk_z for _, chunk_z in chunk_columns) + max_chunk_z = max(chunk_z for _, chunk_z in chunk_columns) + width = (max_chunk_x - min_chunk_x + 1) * Chunk3D.SIZE + depth = (max_chunk_z - min_chunk_z + 1) * Chunk3D.SIZE + + # O streaming mantém uma região contígua. Para consumidores que forneçam + # ilhas muito distantes, preservar o caminho esparso evita uma matriz + # proporcional à distância entre elas. + if width * depth > len(chunk_columns) * Chunk3D.SIZE**2 * 4: + return _build_sparse_surface_grid_vertices(chunks, epsilon) + + heights = np.full((width, depth), _NO_HEIGHT, dtype=np.int32) + blocker_heights = np.full((width, depth), _NO_HEIGHT, dtype=np.int32) + for (chunk_x, chunk_y, chunk_z), chunk in chunks.items(): + start_x = (chunk_x - min_chunk_x) * Chunk3D.SIZE + start_z = (chunk_z - min_chunk_z) * Chunk3D.SIZE + area = np.s_[ + start_x:start_x + Chunk3D.SIZE, + start_z:start_z + Chunk3D.SIZE, + ] + origin_y = chunk_y * Chunk3D.SIZE + for lookup, target in ( + (_SURFACE_LUT, heights), + (_BLOCKER_LUT, blocker_heights), + ): + mask = lookup[chunk.blocks] + if mask.any(): + local_heights = np.where(mask, _LOCAL_Y, -1).max(axis=1) + world_heights = np.where( + local_heights >= 0, + np.maximum(local_heights + origin_y, -1), + _NO_HEIGHT, + ) + np.maximum(target[area], world_heights, out=target[area]) + + valid = (heights != _NO_HEIGHT) & (blocker_heights <= heights) + local_x, local_z = np.nonzero(valid) + if len(local_x) == 0: + return np.empty((0, 3), dtype=np.float32) + + x = local_x.astype(np.float32) + np.float32(min_chunk_x * Chunk3D.SIZE) + z = local_z.astype(np.float32) + np.float32(min_chunk_z * Chunk3D.SIZE) + y = ( + heights[local_x, local_z].astype(np.float64) + 1.0 + float(epsilon) + ).astype(np.float32) + + same_height_east = np.zeros_like(valid) + same_height_east[:-1, :] = ( + valid[:-1, :] & valid[1:, :] & (heights[:-1, :] == heights[1:, :]) + ) + same_height_south = np.zeros_like(valid) + same_height_south[:, :-1] = ( + valid[:, :-1] & valid[:, 1:] & (heights[:, :-1] == heights[:, 1:]) + ) + keep_east = ~same_height_east[local_x, local_z] + keep_south = ~same_height_south[local_x, local_z] + + edges = [ + _grid_edges(x, y, z, x_offset=1), + _grid_edges(x, y, z, z_offset=1), + _grid_edges(x[keep_east] + 1, y[keep_east], z[keep_east], z_offset=1), + _grid_edges(x[keep_south], y[keep_south], z[keep_south] + 1, x_offset=1), + ] + return np.concatenate(edges, axis=0).reshape(-1, 3) + + class GridOverlayRenderer: """Mantém um VBO dedicado e desenha a grade em uma única chamada.""" @@ -83,6 +178,7 @@ def __init__(self, *, color: tuple[float, float, float, float] = (0.06, 0.08, 0. self.visible = True self.vertex_count = 0 self._chunk_revision = -1 + self._pending_chunk_revision: int | None = None self.last_rebuild_cpu_ms = 0.0 self.last_render_cpu_ms = 0.0 @@ -106,6 +202,17 @@ def needs_sync(self, revision: int) -> bool: """Indica se é necessário obter o snapshot do WorldManager neste frame.""" return self.visible and revision != self._chunk_revision + def should_sync(self, revision: int) -> bool: + """Coalesce revisões consecutivas até que uma persista por dois frames.""" + + if not self.needs_sync(revision): + self._pending_chunk_revision = None + return False + if revision == self._pending_chunk_revision: + return True + self._pending_chunk_revision = revision + return False + def sync_chunks(self, chunks: Mapping[tuple[int, int, int], Chunk3D], revision: int) -> bool: """Atualiza o VBO somente quando o streaming alterou os chunks lógicos.""" @@ -117,6 +224,7 @@ def sync_chunks(self, chunks: Mapping[tuple[int, int, int], Chunk3D], revision: gl.glBufferData(gl.GL_ARRAY_BUFFER, vertices.nbytes, vertices, gl.GL_DYNAMIC_DRAW) self.vertex_count = len(vertices) self._chunk_revision = revision + self._pending_chunk_revision = None self.last_rebuild_cpu_ms = (perf_counter() - started) * 1000.0 return True diff --git a/src/main.py b/src/main.py index 413ea20..f7026da 100644 --- a/src/main.py +++ b/src/main.py @@ -525,7 +525,10 @@ def handle_cursor_position( # 2. Atualização contínua de streaming de chunks ao redor do jogador (Mapa Infinito) world_manager.update(player_token.position[0], player_token.position[2]) grid_revision = world_manager.get_loaded_chunk_revision() - if grid_renderer.needs_sync(grid_revision): + if ( + not world_manager.has_pending_streaming_work() + and grid_renderer.should_sync(grid_revision) + ): revision, loaded_chunks = world_manager.get_loaded_chunks_snapshot() grid_renderer.sync_chunks(loaded_chunks, revision) diff --git a/src/world/caves.py b/src/world/caves.py index 61897e9..eb27f7b 100644 --- a/src/world/caves.py +++ b/src/world/caves.py @@ -4,12 +4,23 @@ from hashlib import blake2b from math import cos, floor, isfinite, sin from numbers import Real +import threading +import time import numpy as np from src.world.block import BlockType from src.world.chunk import Chunk3D, _coordinate +_BACKGROUND_YIELD_INTERVAL = 1 +_BACKGROUND_YIELD_SECONDS = 0.0005 + + +def _yield_to_main_thread() -> None: + """Cede o GIL entre etapas do carver quando a geração roda em background.""" + if threading.current_thread() is not threading.main_thread(): + time.sleep(_BACKGROUND_YIELD_SECONDS) + @dataclass(frozen=True) class CaveCarver: @@ -141,13 +152,17 @@ def _carve_column( # Passo 1: Construir a casca orgânica ao redor das cavernas que descem para Y < 0 if self.allow_sparse_depth: - for px, py, pz, radius in spheres: + for sphere_index, (px, py, pz, radius) in enumerate(spheres, start=1): if py - radius - self.shell_thickness < 0: self._build_organic_shell(px, py, pz, radius, chunks) + if sphere_index % _BACKGROUND_YIELD_INTERVAL == 0: + _yield_to_main_thread() # Passo 2: Escavar o ar no interior de todas as esferas do túnel - for px, py, pz, radius in spheres: + for sphere_index, (px, py, pz, radius) in enumerate(spheres, start=1): self._carve_tunnel_air(px, py, pz, radius, chunks, terrain_generator) + if sphere_index % _BACKGROUND_YIELD_INTERVAL == 0: + _yield_to_main_thread() def _build_organic_shell( self, diff --git a/src/world/manager.py b/src/world/manager.py index 1d04a41..0632470 100644 --- a/src/world/manager.py +++ b/src/world/manager.py @@ -63,6 +63,7 @@ def __init__( self._request_queue: queue.Queue = queue.Queue() self._result_queue: queue.Queue = queue.Queue() self._stop_event = threading.Event() + self._worker_busy = threading.Event() self._in_progress: Set[Tuple[int, int, int]] = set() self._worker_thread: Optional[threading.Thread] = None @@ -92,6 +93,7 @@ def _worker_loop(self) -> None: if positions is None or self._stop_event.is_set(): break + self._worker_busy.set() try: # Descarta requisições obsoletas se o jogador já se afastou muito if self._last_center is not None: @@ -121,6 +123,7 @@ def _worker_loop(self) -> None: except Exception: pass finally: + self._worker_busy.clear() self._request_queue.task_done() def neighbor_at(self, x: int, y: int, z: int) -> BlockType: @@ -288,6 +291,15 @@ def get_loaded_chunk_revision(self) -> int: with self._chunks_lock: return self._chunk_revision + def has_pending_streaming_work(self) -> bool: + """Indica se a sequência atual ainda pode alterar os chunks carregados.""" + return bool( + self._in_progress + or self._worker_busy.is_set() + or not self._request_queue.empty() + or not self._result_queue.empty() + ) + def render( self, shader, diff --git a/tests/test_caves.py b/tests/test_caves.py index 9197528..c92b7ee 100644 --- a/tests/test_caves.py +++ b/tests/test_caves.py @@ -1,10 +1,12 @@ """Testes unitários e invariantes do gerador de cavernas 3D (CaveCarver).""" from itertools import product +import threading import numpy as np import pytest +import src.world.caves as caves_module from src.world import BlockType, Chunk3D, ChunkMesher, TerrainGenerator from src.world.caves import CaveCarver @@ -132,6 +134,42 @@ def test_caved_chunk_meshing(): assert mesh.indices.shape == (mesh.face_count * 6,) +def test_cave_carver_yields_only_when_running_in_background(monkeypatch): + main = object() + worker = object() + sleeps = [] + + monkeypatch.setattr(caves_module.threading, "main_thread", lambda: main) + monkeypatch.setattr(caves_module.threading, "current_thread", lambda: main) + monkeypatch.setattr(caves_module.time, "sleep", sleeps.append) + + caves_module._yield_to_main_thread() + assert sleeps == [] + + monkeypatch.setattr(caves_module.threading, "current_thread", lambda: worker) + caves_module._yield_to_main_thread() + assert sleeps == [caves_module._BACKGROUND_YIELD_SECONDS] + + +def test_background_generation_preserves_deterministic_cave_geometry(): + positions = [(0, 0, 0)] + generator = TerrainGenerator(seed=1234, enable_caves=True, cave_chance=1.0) + expected = generator.generate_region(positions) + generated = {} + + def generate() -> None: + generated.update(generator.generate_region(positions)) + + worker = threading.Thread(target=generate) + worker.start() + worker.join(timeout=2.0) + + assert not worker.is_alive() + assert set(generated) == set(expected) + for key in expected: + np.testing.assert_array_equal(generated[key].blocks, expected[key].blocks) + + @pytest.mark.parametrize("kwargs,error", [ ({"cave_chance": -0.1}, ValueError), ({"cave_chance": 1.1}, ValueError), diff --git a/tests/test_grid_overlay.py b/tests/test_grid_overlay.py index 728f0a9..c671344 100644 --- a/tests/test_grid_overlay.py +++ b/tests/test_grid_overlay.py @@ -2,6 +2,7 @@ import numpy as np +import src.interactive.grid_overlay as grid_overlay from src.integration import VoxelGridProvider from src.interaction import is_tactical_column, is_tactical_surface from src.interactive.grid_overlay import ( @@ -105,6 +106,39 @@ def test_highest_support_across_vertical_chunks_is_used(): np.testing.assert_allclose(vertices[:, 1], 19.0 + SURFACE_EPSILON) +def test_surface_at_negative_one_preserves_grid_at_world_zero(): + lower = chunk(0, -1, 0) + lower.set_block(3, 15, 4, BlockType.STONE) + + vertices = build_surface_grid_vertices({(0, -1, 0): lower}) + + assert line_count(vertices) == 4 + np.testing.assert_allclose(vertices[:, 1], SURFACE_EPSILON) + + +def test_contiguous_chunks_use_vectorized_geometry_path(monkeypatch): + def unexpected_sparse_fallback(*args, **kwargs): + raise AssertionError("região contígua não deve usar o fallback esparso") + + monkeypatch.setattr( + grid_overlay, + "_build_sparse_surface_grid_vertices", + unexpected_sparse_fallback, + ) + chunks = {} + for chunk_x in range(2): + for chunk_z in range(2): + source = chunk(chunk_x, 0, chunk_z) + source.blocks[:, 2, :] = BlockType.STONE + chunks[(chunk_x, 0, chunk_z)] = source + + vertices = build_surface_grid_vertices(chunks) + + width = depth = 2 * Chunk3D.SIZE + expected_lines = (width + 1) * depth + (depth + 1) * width + assert line_count(vertices) == expected_lines + + def test_added_and_removed_chunks_change_only_loaded_geometry(): first = chunk() first.set_block(0, 1, 0, BlockType.STONE) @@ -117,6 +151,19 @@ def test_added_and_removed_chunks_change_only_loaded_geometry(): assert line_count(after) == 4 +def test_distant_chunk_islands_use_sparse_geometry_without_filling_the_gap(): + first = chunk(-100, 0, 0) + second = chunk(100, 0, 0) + first.set_block(0, 1, 0, BlockType.STONE) + second.set_block(0, 1, 0, BlockType.STONE) + + vertices = build_surface_grid_vertices( + {(-100, 0, 0): first, (100, 0, 0): second} + ) + + assert line_count(vertices) == 8 + + def test_voxel_provider_chunks_are_usable_without_graphics_context(): source = chunk() source.set_block(4, 3, 5, BlockType.GRASS) @@ -156,9 +203,25 @@ def test_visibility_toggle_skips_snapshot_sync_when_hidden_without_gl_context(): renderer = object.__new__(GridOverlayRenderer) renderer.visible = True renderer._chunk_revision = 4 + renderer._pending_chunk_revision = None assert renderer.needs_sync(5) assert renderer.toggle_visibility() is False assert not renderer.needs_sync(5) assert renderer.toggle_visibility() is True assert renderer.needs_sync(5) + + +def test_streaming_revisions_are_coalesced_until_stable(): + renderer = object.__new__(GridOverlayRenderer) + renderer.visible = True + renderer._chunk_revision = 4 + renderer._pending_chunk_revision = None + + assert not renderer.should_sync(5) + assert not renderer.should_sync(6) + assert renderer.should_sync(6) + + renderer._chunk_revision = 6 + assert not renderer.should_sync(6) + assert renderer._pending_chunk_revision is None diff --git a/tests/test_interactive.py b/tests/test_interactive.py index d16a15e..3e9eb9e 100644 --- a/tests/test_interactive.py +++ b/tests/test_interactive.py @@ -200,6 +200,37 @@ def test_world_manager_result_queue_safe_without_gl(): manager.delete() +def test_world_manager_reports_each_pending_streaming_stage(): + manager = WorldManager( + generator=TerrainGenerator(seed=123, enable_caves=False), + render_distance=1, + create_gl_meshes=False, + async_loading=False, + ) + assert not manager.has_pending_streaming_work() + + manager._in_progress.add((0, 0, 0)) + assert manager.has_pending_streaming_work() + manager._in_progress.clear() + + manager._request_queue.put([(0, 0, 0)]) + assert manager.has_pending_streaming_work() + manager._request_queue.get_nowait() + manager._request_queue.task_done() + + manager._worker_busy.set() + assert manager.has_pending_streaming_work() + manager._worker_busy.clear() + + manager._result_queue.put(object()) + assert manager.has_pending_streaming_work() + manager._result_queue.get_nowait() + manager._result_queue.task_done() + + assert not manager.has_pending_streaming_work() + manager.delete() + + def test_world_manager_frustum_culling_rendering(): """Valida que render() com view_projection aplica Frustum Culling e descarta chunks fora da tela.""" gen = TerrainGenerator(seed=123, enable_caves=False) @@ -307,4 +338,3 @@ def test_modular_character_walk_cycle_animation(): char.delete() -