O Passeio na floresta é uma pequena floresta em three.js que você pode explorar a pé, direto no navegador. Tem colinas, um riacho serpenteando por um vale, dezenas de milhares de folhas de grama e de árvore se movendo ao vento, flores, peixes, borboletas e um sol baixo de fim de tarde dourado. Uma menina de vestido vermelho está parada junto à água; clique no chão e ela caminha até lá. Roda em three.js r160 e WebGL 2, sem modelos nem texturas de imagem baixados: tudo é gerado em código no carregamento.
Este post mostra como tudo se encaixa, com trechos curtos do código-fonte real: terreno, grama, vento, árvores e uma cena 3D explorável em three.js sem motor de física nem navmesh.
Renderer, tone mapping e um céu de fim de tarde
O renderer desenha no contêiner do palco da página, configurado para uma cena externa com iluminação física:
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.75));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.68;
O tone mapping ACES filmic suaviza os realces mais fortes em vez de estourá-los, o que importa com o céu em quadro; a exposição fica bem abaixo de 1 para o prado não ficar lavado.
O céu é o addon Sky do three.js, um shader que modela o espalhamento atmosférico. Ajustamos a turbidez e o espalhamento de Rayleigh para uma tarde levemente enevoada e colocamos o sol a apenas 16° acima do horizonte, o que dá à cena inteira o seu visual quente, de sombras longas:
const phi = THREE.MathUtils.degToRad(90 - 16); // sun 16° up: golden hour
const theta = THREE.MathUtils.degToRad(150);
sunDir.setFromSphericalCoords(1, phi, theta);
skyU.sunPosition.value.copy(sunDir);
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(sky).texture;
A linha do PMREMGenerator é o ganho barato: ela transforma o céu em um mapa de ambiente pré-filtrado, e assim todo MeshStandardMaterial da cena ganha luz ambiente e reflexos com a cor do céu de graça. Além disso há uma DirectionalLight quente posicionada ao longo do mesmo sunDir (para as sombras concordarem com o céu), um mapa de sombras suaves de 2048 × 2048 cobrindo uma área de 100 × 100 ao redor da clareira, uma HemisphereLight azul sobre verde para preenchimento e neblina exponencial (FogExp2) num tom sálvia claro para dissolver a distância.
Terreno procedural a partir de uma função de altura
Tudo na cena que toca o chão pergunta a uma única função onde o chão está. Primeiro, o riacho: sua linha central é uma curva x = cxOf(z) feita de duas ondas senoidais de frequências diferentes, para que ele serpenteie sem se repetir de forma óbvia:
const cxOf = z => 8 + 2.6 * Math.sin(z * 0.045) + 1.3 * Math.sin(z * 0.11 + 1.3);
function terrainH(x, z) {
const d = Math.abs(x - cxOf(z));
const rolling = (Math.sin(x * 0.13 + z * 0.07) + Math.sin(x * 0.05 - z * 0.11 + 1.7)) * 0.35
+ Math.sin(x * 0.021 + z * 0.017 + 0.5) * 0.9;
const flat = ss(3.2, 7.5, d); // flatten near the water
const carve = (1 - ss(0, 4.6, d)) * 1.15; // dig the valley
const bank = Math.exp(-((d - 5.6) ** 2) / 3.0) * 0.28; // slight lip on the bank
return rolling * flat - carve + bank;
}
Leia em camadas. rolling são três senos sobrepostos para colinas suaves. d é a distância horizontal até a linha central do riacho, e ss é um smoothstep. Multiplicar as colinas por flat as atenua perto da água para que as margens fiquem planas; carve subtrai até 1,15 unidade bem embaixo do riacho para formar um canal; e bank é uma pequena elevação gaussiana a cerca de 5,6 unidades, que parece uma borda natural elevada.
A malha é um PlaneGeometry(240, 240, 190, 190) deitado, com a altura de cada vértice definida por terrainH. O mesmo laço também grava cores de vértice a partir de d: leito escuro até 3 unidades, areia se misturando à grama entre 3,6 e 5,4 unidades, e grama com uma pequena variação senoidal de matiz e luminosidade além disso. Uma chamada a computeVertexNormals() e o terreno está pronto, sem mapa de altura nem biblioteca de ruído.
Como terrainH é barata e determinística, ela também posiciona 160 seixos no leito do riacho, 70 pedras na margem, cada folha de grama, flor e árvore, e os pés da menina. Nada flutua e nada afunda.
Uma fita de água que acompanha o riacho
A água não é um plano grande recortado pelo terreno. É uma BufferGeometry personalizada: uma faixa de 240 linhas, cada linha com dois vértices de cada lado de cxOf(z), para que a fita se curve exatamente junto com o canal:
for (let i = 0; i < rows; i++) {
const z = waterZ0 + (waterZ1 - waterZ0) * (i / (rows - 1));
const cx = cxOf(z);
wPos.set([cx - WATER_HALF, WATER_Y, z, cx + WATER_HALF, WATER_Y, z], i * 6);
}
O material é um MeshPhysicalMaterial com transmission: 0.92, ior: 1.33 (o índice de refração da água), um pouco de espessura e uma cor de atenuação verde-azulada. A transmissão faz o three.js renderizar a cena opaca em um buffer extra e amostrá-lo através da superfície, então os seixos e o leito aparecem com uma leve distorção e tonalidade. Custa mais do que transparência simples, mas é uma água rasa convincente com muito pouco código.
As ondulações são feitas na CPU: a cada quadro as alturas dos 480 vértices da faixa são definidas pela soma de três senos de posição e tempo, e as normais são recalculadas. Isso não custa quase nada. Nosso post sobre o oceano em tempestade usa a mesma ideia numa escala muito maior.
25.000 folhas de grama em uma draw call
A grama é a parte sobre a qual mais perguntam, e ela é basicamente um único InstancedMesh. Uma folha começa como um PlaneGeometry(0.085, 1.05, 1, 3) fino, deslocado para que a base fique na origem, e então cada vértice é moldado:
const yy = p.getY(i) / 1.05;
p.setX(i, p.getX(i) * (1 - yy * 0.85)); // taper to a point
p.setZ(i, p.getZ(i) + yy * yy * 0.32); // curve forward
const v = 0.5 + yy * 0.65; // dark root, bright tip
O gradiente de vértice do escuro para o claro é um substituto barato para oclusão ambiente num gramado denso. O material é um MeshStandardMaterial de dupla face com vertexColors.
Depois, 25.000 instâncias são espalhadas por um disco de raio 58. Usar r = Math.sqrt(Math.random()) * 58 mantém a densidade uniforme no disco (sem a raiz quadrada, as folhas se amontoam no meio), e qualquer ponto a menos de 4,6 unidades do riacho é descartado para a água ficar livre. Cada instância recebe uma rotação aleatória, uma leve inclinação, uma escala entre 0,6 e 1,7 e a sua própria cor via setColorAt. O three.js multiplica a cor da instância pelo gradiente de vértice, então cada folha tem um verde diferente, mas a mesma transição do escuro para o claro.
O resultado: 25.000 folhas, uma geometria, um material, uma draw call, com as matrizes montadas uma única vez na inicialização.
Vento com onBeforeCompile
Não escrevemos um shader de grama próprio. Em vez disso, um pequeno helper modifica o vertex shader do material padrão logo depois que o three.js calcula a posição local, e assim toda a iluminação, as sombras e a neblina embutidas continuam funcionando:
material.onBeforeCompile = (shader) => {
shader.uniforms.uTime = uTime;
shader.vertexShader = 'uniform float uTime;\n' + shader.vertexShader.replace(
'#include <begin_vertex>',
`#include <begin_vertex>
float wph = instanceMatrix[3].x * 0.8 + instanceMatrix[3].z * 0.6;
float wh = max(position.y, 0.0);
transformed.x += (sin(uTime * 1.7 + wph) + sin(uTime * 2.9 + wph * 1.7) * 0.4) * 0.17 * wh;
transformed.z += cos(uTime * 1.3 + wph * 1.2) * 0.102 * wh;`);
};
(Um pouco simplificado: o helper real envolve a linha do instanceMatrix em #ifdef USE_INSTANCING e recebe a amplitude como parâmetro.) Três detalhes fazem isso funcionar:
- Fase a partir da posição.
instanceMatrix[3]é a translação da instância, entãowphé uma fase que muda suavemente pelo campo. Folhas vizinhas se movem quase juntas, e o movimento rola pelo prado como rajadas, em vez de todas as folhas tremerem em sincronia. - Peso pela altura. Multiplicar por
position.y(0 na raiz) mantém cada folha plantada enquanto a ponta balança mais. - Um uniform compartilhado. Todos os materiais modificados recebem o mesmo objeto
uTime, então uma única atribuição por quadro,uTime.value = t, anima a grama e a folhagem das sete árvores ao mesmo tempo.
Para as folhas das árvores, o mesmo helper é chamado com uma amplitude menor (0,07) e sem o peso pela altura, então cada cartão de folha se desloca por inteiro em vez de se dobrar.
Árvores de cilindros e cartões de folhas
Cada tronco é uma pilha de dois ou três segmentos afunilados de CylinderGeometry com uma pequena inclinação aleatória, cinco cilindros de raiz abertos ao redor da base e alguns galhos inclinados em direção à copa, todos usando uma textura de casca desenhada num canvas na inicialização (traços verticais em bézier sobre marrom).
A copa é outro InstancedMesh: um quadrado simples de 0,62 unidade com a textura de uma folha desenhada em canvas e recortada com alphaTest: 0.45. Usar alpha test em vez de transparência real significa nada de ordenação e profundidade correta, o que é essencial com milhares de cartões sobrepostos. Os cartões são distribuídos por alguns aglomerados esféricos em cada árvore, repartidos pelo volume de cada aglomerado, e colocados principalmente na parte de fora de cada um, para que a copa pareça cheia, mas não maciça:
const rr = c[3] * (0.55 + 0.45 * Math.cbrt(Math.random()));
dmy.position.set(c[0] + dir.x * rr, c[1] + dir.y * rr * 0.85, c[2] + dir.z * rr);
As folhas no topo de um aglomerado recebem um tom mais claro e mais quente do que as de baixo, o que simula a luz do sol filtrando. A grande árvore principal tem 9.000 cartões de folhas e cada uma das seis árvores menores tem 2.200, cerca de 22.200 no total. Atrás delas, um anel de 90 cones instanciados fica entre 62 e 90 unidades de distância; na neblina eles parecem uma linha de árvores distante e dão profundidade à clareira por quase nenhum custo.
Clique para andar: raycast, limite, passeio
A câmera usa OrbitControls, e tanto o botão esquerdo quanto o direito do mouse estão mapeados para girar (telas de toque não têm botão direito). Então a primeira tarefa é distinguir um clique de um arraste. No pointerup ignoramos o evento se o ponteiro se moveu mais de 6 pixels ou se o toque durou mais de 700 ms; caso contrário, lançamos um raio da câmera através do clique:
raycaster.setFromCamera(ndc, camera);
const hit = raycaster.intersectObject(ground, false)[0];
if (hit) setGirlTarget(hit.point.x, hit.point.z);
Só a malha do terreno é testada, então o raycast continua rápido e você sempre consegue clicar "através" da grama até o chão.
Depois, setGirlTarget aplica duas regras sem navmesh nenhum. Se o alvo está a menos de 4,4 unidades da linha central do riacho, ele é empurrado de volta para a margem em que ela está, então clicar do outro lado da água a manda para a beira mais próxima em vez de entrar na água. E se o alvo está a mais de 26 unidades do centro, ele é puxado de volta para esse círculo para que ela fique dentro da floresta. Um marcador em anel é colocado no ponto final, se expande e some em 0,9 segundo.
Andar são poucas linhas no laço de animação. Ela segue em linha reta até o alvo a 2,6 unidades por segundo e vira na direção dele pelo lado mais curto:
const want = Math.atan2(dx, dz);
let turn = (want - g.heading + Math.PI * 3) % (Math.PI * 2) - Math.PI;
g.heading += turn * Math.min(1, dt * 7);
Encaixar a diferença de ângulo no intervalo −π…π é o que impede que ela dê uma volta inteira quando o alvo está logo atrás do ombro esquerdo. Um valor gait transita suavemente entre 0 (parada) e 1 (andando), e tudo é escalado por ele: o balanço das pernas e dos braços, um pequeno pulo a partir de abs(sin(phase)), uma leve inclinação para a frente e o balanço das marias-chiquinhas. Ela começa e para de forma suave, e uma pequena oscilação de respiração assume quando ela está parada. A altura dela é simplesmente terrainH na sua posição, então ela acompanha cada declive.
Peixes, borboletas e folhas caindo
As pequenas coisas que se movem são todas movimento paramétrico barato, sem simulação:
- Peixes. Dez peixes, cada um uma esfera achatada com nadadeiras de shape geometry, nadam ao longo de
cxOf(z)com um deslocamento de faixa e dão a volta nas extremidades do riacho. Cada um olha um pouco à frente na curva para ficar virado rio abaixo, e a cauda balança num seno. - Borboletas. Seis pares de asas com textura de pétala voam em laços estilo Lissajous (velocidades de seno diferentes em x e z), ficam viradas na direção do voo e batem as asas rápido, nunca descendo abaixo de 0,8 unidade acima do terreno.
- Folhas caindo. Sessenta cartões de folhas em um
InstancedMeshcaem da árvore principal num ciclo de 11 unidades, derivando para o lado e girando, com as matrizes reescritas a cada quadro. - Ar. 500 partículas de pólen aditivas, sete sprites de nuvem à deriva e quatro planos de feixes de luz inclinados ao longo de
sunDiratravessando a árvore grande.
Pós-processamento: bloom e correção de cor
O quadro passa por uma cadeia de EffectComposer: RenderPass, depois UnrealBloomPass com uma intensidade modesta de 0,32 e um limiar alto para que só o céu mais claro e as bordas das folhas iluminadas pelo sol brilhem, depois OutputPass para tone mapping e conversão para sRGB e, por fim, um ShaderPass personalizado. Como a correção roda depois do output pass, ela trabalha com cores já prontas para exibição, o que deixa seus números fáceis de entender:
col = mix(col, col * col * (3.0 - 2.0 * col), 0.22); // gentle S-curve
col += vec3(0.035, 0.018, -0.012) * smoothstep(0.55, 1.0, luma); // warm highlights
col += vec3(-0.018, 0.004, 0.022) * (1.0 - smoothstep(0.0, 0.45, luma)); // cool shadows
Ela também aumenta a saturação em 10% e adiciona uma vinheta. Realces quentes sobre sombras levemente verde-azuladas fazem uma cena de verde sobre verde parecer fim de tarde em vez de um render padrão.
Mantendo tudo leve
É uma cena pesada para uma página web, então ela precisa ser educada:
- Instanciamento em tudo o que se repete: grama, folhas, folhas caindo, seixos, pedras e a linha de árvores distante são, cada um, um único
InstancedMesh. - Pixel ratio limitado a 1,75. Do contrário, celulares de alta densidade renderizariam o triplo de pixels passando por bloom e transmissão.
- Pausa fora da tela. Um
IntersectionObserverinterrompe o laço derequestAnimationFramequando menos de 15% do palco está visível, e um listener devisibilitychangeo interrompe quando a aba fica oculta. A página também tem um botão Pausar. - Tempo de quadro limitado a 50 ms, para que, depois de uma pausa, o mundo retome com calma em vez de dar um salto.
- Um raycast barato apenas contra a malha do chão, nunca contra a folhagem instanciada.
- Respeito ao movimento reduzido. A órbita lenta da câmera parada fica desligada para quem pede movimento reduzido no sistema, e para de vez na primeira vez que você clica com o botão esquerdo, toca ou arrasta com o botão esquerdo.
Lições
- Faça de uma função de altura a única fonte da verdade. Se o terreno, os objetos e a personagem a consultam, a cena se mantém coesa.
- Construa o riacho primeiro e depois o terreno ao redor dele. Uma curva de linha central dá de uma vez só o canal, a malha da água, os caminhos dos peixes e a regra de "não entrar na água".
- Grama é instanciamento mais um patch de vértice. Uma folha moldada, 25.000 matrizes e algumas linhas em
onBeforeCompilerendem muito. - Clique para andar não precisa de navmesh numa cena aberta: faça raycast no chão, limite o alvo com regras simples e suavize a virada e o passo.
- Luz e correção de cor vendem o clima. Um sol baixo, um ambiente PMREM, neblina, bloom suave e uma correção quente/fria fazem tanto quanto qualquer modelo.
A cena está no ar em Passeio na floresta: clique pelo prado, orbite a câmera e veja o vento passar pela grama. Se você gostou, leia como fizemos um oceano em tempestade no three.js ou como animamos um boneco de palito com IK de dois ossos.