Mundo grande, cavernas e persistência

Aula 2 de geração procedural · TAMJ 2026.2 · 1h30

O que você leva desta aula. Na aula 1 o terreno era um quadrado. Hoje ele vira um mundo que continua enquanto o jogador anda, sem tremer longe da origem, com cavernas embaixo e itens que, uma vez pegos, não voltam. No fim, o enunciado da Atividade 3.

Sumário

  1. O problema: o mundo não cabe na memória
  2. Chunks
  3. A costura
  4. Precisão de float e floating origin
  5. Cavernas
  6. Itens coletáveis com chave
  7. Inventário
  8. Salvar e carregar
  9. Juntando tudo: a ordem de um frame
  10. Atividade 3
  11. Referências

1. O problema: o mundo não cabe na memória

Um terreno de 241 vértices por lado tem 58 mil vértices e ocupa uns 2 MB. É um quadrado de 240 metros. Um mundo de 10 km por 10 km precisaria de 1700 desses: 3,5 GB só de malha, e a maior parte a quilômetros do jogador, invisível. Ninguém faz assim.

A saída vem da primeira propriedade do ruído coerente: mesma entrada, mesma saída. Se qualquer pedaço do mundo pode ser recalculado a partir da semente e da coordenada, não é preciso guardar nada. Basta gerar o que está perto do jogador, jogar fora o que ficou longe, e gerar de novo quando ele voltar. O pedaço volta idêntico.

2. Chunks

O mundo é dividido em quadrados de tamanho fixo, os chunks. O jogador está sempre dentro de um; o jogo mantém vivos só os 9 ao redor (uma grade 3x3). Quando ele cruza a borda para outro chunk, os que ficaram longe são destruídos e os que faltam, criados. Na figura, o jogador anda do chunk (0, 0) para o (1, 0): a coluna da esquerda sai, a da direita entra, e os seis do meio continuam como estavam.

cx = floor(jogador.x / TAMANHO)      // em que chunk o jogador está
cz = floor(jogador.z / TAMANHO)

desejados = { (cx+dx, cz+dz)  para dx, dz em −1..1 }      // os 9

para cada c em desejados que não está vivo:   criar(c)
para cada c vivo que não está em desejados:   destruir(c)
NomeO que é
TAMANHOlado do chunk em unidades de mundo. Entre 64 e 240 em Unity com 1 vértice por metro.
cx, czcoordenada do chunk, inteira. O chunk (2, −1) cobre x de 128 a 191 e z de −64 a −1, se TAMANHO é 64.
floorarredonda para baixo. Sem isso, x = −0,5 cai no chunk 0 em vez do −1.
vivosdicionário chunk → objeto na cena, chave "cx,cz"
criargerar o heightmap com fBm, montar a malha, posicionar em (cx × TAMANHO, 0, cz × TAMANHO)
destruirou melhor, devolver a um pool: esconder o objeto e guardar; na próxima criação, pegar do pool e só repintar. Evita alocar malha nova toda hora.
Demo 8: a grade 3x3 seguindo o jogador, com log de criação e destruição →

3. A costura

Dois chunks vizinhos precisam continuar um o outro. Na figura, a fileira de cima amostrou o ruído com a coordenada local de cada chunk (0 a 31): os quatro chunks saem idênticos e as bordas não batem. A de baixo usou a coordenada global e o terreno atravessa as bordas sem emenda.

Dois erros comuns estragam a costura:

Coordenada local no ruído. Ao gerar o heightmap, usar x de 0 a TAMANHO−1 em vez de cx × TAMANHO + x. Todos os chunks saem iguais e as bordas não batem. A demo 8 tem uma caixa que reproduz o erro.
para z em 0..TAMANHO, x em 0..TAMANHO:          // note: até TAMANHO inclusive
  gx = cx * TAMANHO + x                        // coordenada GLOBAL
  gz = cz * TAMANHO + z
  altura[x, z] = fbm(gx * escala, gz * escala)
Vértice da borda não compartilhado. Um chunk de 64 quadrados tem 65 vértices por lado. O vértice 64 deste chunk está no mesmo lugar que o vértice 0 do vizinho. Se você gera 64 vértices, fica uma fresta de 1 metro entre eles.

4. Precisão de float e floating origin

Os motores guardam posição em float de 32 bits. Um float tem 24 bits para os dígitos e o resto diz onde está a vírgula. Consequência: a menor diferença que ele representa cresce com o tamanho do número. Perto de zero ele distingue milionésimos de metro. A 100 mil metros, só quase um centímetro. A 10 milhões, um metro inteiro.

Distância da origemPasso mínimoO que o jogador vê
1 m0,0000001 mnada
10 000 m0,001 mcâmera muito próxima treme um pouco
100 000 m0,008 manimações e sombras tremem
1 000 000 m0,06 mpersonagem anda aos pulos
10 000 000 m1 minjogável

Isso não é bug do motor, é a matemática do float. Kerbal Space Program, Star Citizen e todo jogo de mundo aberto grande passa por isso.

A solução: não deixar o jogador se afastar. Quando ele passa de um limite (1000 m, por exemplo), o jogo subtrai a posição dele de tudo na cena, inclusive dele mesmo, que volta para a origem. O mundo escorrega por baixo; ninguém percebe porque tudo se moveu junto. Chama-se floating origin.

se |jogador.posição| > LIMITE:
  desloc = jogador.posição
  para cada objeto raiz na cena:   objeto.posição -= desloc     // chunks, itens, jogador, partículas
  origem_do_mundo += desloc                                      // acumulado, para saber a posição verdadeira
NomeO que é
LIMITE1000 a 5000 m. Longe o bastante para não disparar toda hora.
origem_do_mundosoma de todos os deslocamentos. Posição verdadeira = posição na cena + origem. É ela que entra no ruído, na conta do chunk e na chave dos itens.

O que quebra se você esquecer de deslocar: partículas em espaço de mundo, corpos físicos em movimento (faça no passo de física), câmera com suavização (zere o histórico), trilhas. A demo 9 força float32 no navegador para você ver o tremor e ver ele sumir.

Demo 9: o tremor a 1 milhão de metros, e o floating origin corrigindo →

5. Cavernas

Ruído dá colinas. Caverna é outra forma: salas irregulares ligadas por túneis, paredes grossas. O jeito clássico é o autômato celular, e cabe em cinco passos:

  1. Preencher: cada célula de uma grade vira parede com 45% de chance, usando a semente. Borda sempre parede.
  2. Suavizar: para cada célula, conte as 8 vizinhas. Mais de 4 paredes: vira parede. Menos de 4: vira chão. Repita 4 ou 5 vezes. O chuvisco vira manchas.
  3. Achar as salas: flood fill numera cada bolsão de chão.
  4. Limpar: bolsões com menos de N células viram parede.
  5. Conectar: cave um túnel entre cada sala e a mais próxima, até sobrar uma região só. Sem isso o jogador nasce trancado.

Para levar ao 3D: cada célula de parede vira um cubo, ou a fronteira vira malha por marching squares. Para encaixar no terreno: a caverna fica abaixo da superfície, com entrada onde a altura cai na faixa de rocha; a semente da caverna deriva da semente do mundo e da coordenada do chunk. A alternativa é ruído 3D com limiar, como o Minecraft: menos controle sobre salas, mas já sai em 3D e sem flood fill.

Demo 10: os cinco passos, um botão cada →

6. Itens coletáveis com chave

Os itens nascem pelo ruído como densidade (aula 1, seção 7). O problema novo aparece com os chunks: o jogador coleta um carregador, anda três chunks, volta, e o chunk é gerado do zero, com o carregador no lugar.

A solução tem duas partes. Cada item recebe uma chave que depende só de coisas determinísticas: semente, coordenada do chunk e a ordem em que ele foi encontrado no chunk. E o jogo guarda um conjunto de chaves coletadas. Ao gerar o chunk, um item cuja chave está no conjunto simplesmente não é instanciado.

i = 0
para cada candidato (x, z) do chunk, de PASSO em PASSO:
  d = fbm(gx * 0.05 + 1000, gz * 0.05 + 1000)     // densidade, outro offset
  se d > LIMIAR e bioma é caminhável:
    chave = semente + ":" + cx + ":" + cz + ":" + i    // "7:2:-1:14"
    se chave não está em coletados:  instanciar(tipo(d), gx, gz, chave)
    i += 1                                             // avança MESMO se não instanciou
NomeO que é
chavetexto único e determinístico. Não use o nome do objeto nem o índice de instanciação, que mudam.
icontador de candidatos. Precisa avançar mesmo quando o item já foi coletado, senão as chaves dos seguintes mudam e o jogo "esquece" o que coletou.
coletadosHashSet<string> global. É o que vai para o save.
tipo(d)o próprio valor da densidade escolhe o tipo: faixa alta é raro, média é comum.
Demo 11: colete, afaste-se, volte. Com e sem chave →

7. Inventário

Inventário é dado, não interface. Um objeto com slots que responde "cabe?" e "quantos tenho?", e avisa quando muda. A tela desenha o que ele diz e só quando ele avisa.

classe Inventario:
  slots: lista de (id, quantidade)          // tamanho = capacidade
  evento Mudou
  adicionar(id, qtd): completa pilhas do mesmo id, abre pilha nova se houver slot, devolve o que não coube; dispara Mudou
  remover(id, qtd): tira das pilhas, apaga slot zerado; dispara Mudou
  contar(id)
NomeO que é
idtipo do item ("carregador_rifle"), não o objeto. Vinte carregadores iguais, mesmo id.
MAX_PILHAquantos cabem num slot: munição 99, rifle 1.
capacidadenúmero de slots. É o que faz "inventário cheio" existir; o item que não coube fica no chão e não entra em coletados.
Mudouevent em C#, signal no Godot. A UI se inscreve uma vez. Nada de Find no Update.

Se o projeto é a continuação do airsoft, o carregador equipado da Atividade 2 sai do inventário ao equipar e volta ao trocar. O HUD que já existe passa a escutar o evento do inventário além do da arma.

8. Salvar e carregar

O save é pequeno porque o mundo não está nele. Só o que não dá para recalcular:

{ "semente": 7,
  "coletados": ["7:0:0:3", "7:1:0:11"],
  "inventario": [ {"id": "carregador", "qtd": 2}, {"id": "municao", "qtd": 45} ],
  "jogador": {"x": 812.5, "z": -40.2} }        // posição VERDADEIRA: cena + origem_do_mundo

Ao carregar: ler a semente, colocar o jogador na posição, e deixar os chunks se gerarem sozinhos, já pulando as chaves coletadas. No Unity, JsonUtility não serializa HashSet: converta para lista. No Godot, JSON.stringify aceita Dictionary e Array. Grave em Application.persistentDataPath ou user://.

9. Juntando tudo: a ordem de um frame

  1. O jogador se move (input, física).
  2. Posição verdadeira = posição na cena + origem_do_mundo.
  3. Se a posição na cena passou do LIMITE: floating origin, desloca tudo.
  4. Chunk atual = floor(verdadeira / TAMANHO). Se mudou desde o frame anterior: recalcular a grade 3x3, criar e destruir.
  5. Ao criar um chunk: heightmap com coordenada global → malha → itens com chave, pulando coletados → caverna, se a faixa de altura pedir.
  6. Coleta: item some da cena, chave entra em coletados, inventário dispara Mudou, HUD redesenha.
  7. Salvar: semente, coletados, inventário, posição verdadeira.
Onde as pessoas travam. Na costura (coordenada local) e no contador i das chaves. Os dois aparecem só quando o jogador anda longe e volta. Teste isso cedo: é o checkpoint da primeira semana.

10. Atividade 3

Duas semanas, equipes de 1 a 3, sem encontro presencial, dúvidas no Discord. O enunciado completo está em atividade-03-mundo-procedural.pdf. Em resumo:

Checkpoint no fim da primeira semana, no Discord: um vídeo curto ou GIF com os 9 chunks aparecendo e sumindo enquanto o jogador anda, sem costura. É onde todo mundo trava; melhor travar cedo.

11. Referências

← Aula 1Demo 8: chunks →