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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions docs/EQUIPE_B_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.

Expand Down
9 changes: 7 additions & 2 deletions src/interaction/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
146 changes: 127 additions & 19 deletions src/interactive/grid_overlay.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand All @@ -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."""

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

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

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

Expand Down
5 changes: 4 additions & 1 deletion src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
19 changes: 17 additions & 2 deletions src/world/caves.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions src/world/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions tests/test_caves.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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),
Expand Down
Loading