Mundo grande, cavernas e persistência
Aula 2 de geração procedural · TAMJ 2026.2 · 1h30
Sumário
- O problema: o mundo não cabe na memória
- Chunks
- A costura
- Precisão de float e floating origin
- Cavernas
- Itens coletáveis com chave
- Inventário
- Salvar e carregar
- Juntando tudo: a ordem de um frame
- Atividade 3
- 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)
| Nome | O que é |
|---|---|
TAMANHO | lado do chunk em unidades de mundo. Entre 64 e 240 em Unity com 1 vértice por metro. |
cx, cz | coordenada do chunk, inteira. O chunk (2, −1) cobre x de 128 a 191 e z de −64 a −1, se TAMANHO é 64. |
floor | arredonda para baixo. Sem isso, x = −0,5 cai no chunk 0 em vez do −1. |
vivos | dicionário chunk → objeto na cena, chave "cx,cz" |
criar | gerar o heightmap com fBm, montar a malha, posicionar em (cx × TAMANHO, 0, cz × TAMANHO) |
destruir | ou 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. |
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:
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)
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 origem | Passo mínimo | O que o jogador vê |
|---|---|---|
| 1 m | 0,0000001 m | nada |
| 10 000 m | 0,001 m | câmera muito próxima treme um pouco |
| 100 000 m | 0,008 m | animações e sombras tremem |
| 1 000 000 m | 0,06 m | personagem anda aos pulos |
| 10 000 000 m | 1 m | injogá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
| Nome | O que é |
|---|---|
LIMITE | 1000 a 5000 m. Longe o bastante para não disparar toda hora. |
origem_do_mundo | soma 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:
- Preencher: cada célula de uma grade vira parede com 45% de chance, usando a semente. Borda sempre parede.
- 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.
- Achar as salas: flood fill numera cada bolsão de chão.
- Limpar: bolsões com menos de N células viram parede.
- 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
| Nome | O que é |
|---|---|
chave | texto único e determinístico. Não use o nome do objeto nem o índice de instanciação, que mudam. |
i | contador 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. |
coletados | HashSet<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. |
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)
| Nome | O que é |
|---|---|
id | tipo do item ("carregador_rifle"), não o objeto. Vinte carregadores iguais, mesmo id. |
MAX_PILHA | quantos cabem num slot: munição 99, rifle 1. |
capacidade | nú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. |
Mudou | event 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
- O jogador se move (input, física).
- Posição verdadeira = posição na cena +
origem_do_mundo. - Se a posição na cena passou do LIMITE: floating origin, desloca tudo.
- Chunk atual =
floor(verdadeira / TAMANHO). Se mudou desde o frame anterior: recalcular a grade 3x3, criar e destruir. - Ao criar um chunk: heightmap com coordenada global → malha → itens com chave, pulando coletados → caverna, se a faixa de altura pedir.
- Coleta: item some da cena, chave entra em coletados, inventário dispara Mudou, HUD redesenha.
- Salvar: semente, coletados, inventário, posição verdadeira.
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:
- terreno com fBm, curva de altura e faixas de bioma, parâmetros expostos;
- chunks em grade 3x3 com malha própria, sem costura, com floating origin;
- pelo menos uma caverna por autômato celular (ou ruído 3D, documentado);
- itens por densidade com chave determinística, que não voltam;
- inventário como dados com evento, ligado ao HUD;
- salvar e carregar em JSON;
- sem objetivo, sem progressão, sem inimigos: isso é o trabalho final.
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
- Sebastian Lague, Procedural Landmass Generation (chunks, threads, LOD) e Procedural Cave Generation (autômato celular, flood fill, marching squares).
- Unity, Floating Origin (script clássico da wiki) e JSON Serialization.
- Godot, Procedural geometry e JSON.
- RogueBasin, Cellular Automata Method for Generating Random Cave-Like Levels.
- Alan Zucconi, Floating point precision.