Devlog · three.js

Cómo hicimos un bosque 3D para recorrer en three.js: terreno, hierba, viento y clic para caminar

Una función de altura para el suelo, una malla instanciada para 25.000 briznas de hierba, un parche de shader para el viento y un raycast para convertir un clic en un paseo: las piezas que hay detrás de Paseo por el bosque.

Actualizado en Por ShengyuStudio9 min de lectura

Paseo por el bosque es un pequeño bosque en three.js por el que puedes pasear, directamente en el navegador. Hay lomas, un arroyo que serpentea valle abajo, decenas de miles de briznas de hierba y hojas que se mueven con el viento, flores, peces, mariposas y un sol bajo de hora dorada. Una niña de vestido rojo está junto al agua; haz clic en el suelo y caminará hasta allí. Funciona con three.js r160 y WebGL 2 sin modelos ni texturas de imagen descargados: todo se genera por código al cargar.

Este artículo explica cómo está montado, con fragmentos cortos del código real: terreno, hierba, viento, árboles y una escena 3D en three.js por la que se puede caminar sin motor de física ni navmesh.

Un bosque en three.js a la hora dorada: un árbol grande, un arroyo serpenteante y un prado de hierba y flores
La escena de Paseo por el bosque: terreno, arroyo, hierba, árboles y cielo, todo generado en tiempo de ejecución.

Renderizador, tone mapping y un cielo de hora dorada

El renderizador dibuja dentro del contenedor del escenario de la página, configurado para una escena exterior con iluminación 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;

El tone mapping fílmico ACES suaviza las altas luces en lugar de recortarlas, algo importante con el cielo en plano; la exposición queda bastante por debajo de 1 para que el prado no se queme.

El cielo es el addon Sky de three.js, un shader que modela la dispersión atmosférica. Ajustamos la turbidez y la dispersión de Rayleigh para una tarde ligeramente brumosa y colocamos el sol a solo 16° sobre el horizonte, que es lo que le da a toda la escena su aspecto cálido y de sombras alargadas:

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;

La línea del PMREMGenerator es la victoria barata: hornea el cielo en un mapa de entorno prefiltrado, de modo que cada MeshStandardMaterial de la escena recibe gratis luz ambiental y reflejos del color del cielo. Encima de eso hay una DirectionalLight cálida colocada sobre el mismo sunDir (para que las sombras coincidan con el cielo), un mapa de sombras suaves de 2048 × 2048 que cubre un área de 100 × 100 alrededor del claro, una HemisphereLight azul sobre verde como relleno, y niebla exponencial (FogExp2) en un tono salvia pálido que disuelve la distancia.

Terreno procedural a partir de una sola función de altura

Todo lo que toca el suelo en la escena le pregunta a una única función dónde está el suelo. Primero, el arroyo: su línea central es una curva x = cxOf(z) construida con dos ondas senoidales de distinta frecuencia, para que serpentee sin repetirse de forma evidente:

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;
}

Léela por capas. rolling son tres senos superpuestos para las lomas suaves. d es la distancia horizontal a la línea central del arroyo, y ss es un smoothstep. Multiplicar las lomas por flat las desvanece cerca del agua para que las orillas queden niveladas; carve resta hasta 1,15 unidades justo debajo del arroyo para abrir un cauce; y bank es un pequeño bulto gaussiano a unas 5,6 unidades, que se lee como un borde elevado natural.

La malla es un PlaneGeometry(240, 240, 190, 190) girado en horizontal, con la altura de cada vértice tomada de terrainH. El mismo bucle escribe también colores de vértice a partir de d: lecho oscuro dentro de 3 unidades, arena que se funde con la hierba entre 3,6 y 5,4 unidades, y más allá hierba con una pequeña variación senoidal de tono y luminosidad. Una llamada a computeVertexNormals() y el terreno está listo, sin mapa de alturas ni librería de ruido.

Como terrainH es barata y determinista, también coloca 160 guijarros en el lecho del arroyo, 70 rocas en las orillas, cada brizna, flor y árbol, y los pies de la niña. Nada flota y nada se hunde.

Una cinta de agua que sigue el arroyo

El agua no es un gran plano recortado por el terreno. Es una BufferGeometry propia: una tira de 240 filas, cada una con dos vértices colocados a ambos lados de cxOf(z), de modo que la cinta se curva exactamente con el cauce:

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);
}

Su material es un MeshPhysicalMaterial con transmission: 0.92, ior: 1.33 (el índice de refracción del agua), un poco de grosor y un color de atenuación verde azulado. La transmisión hace que three.js renderice la escena opaca en un búfer extra y lo muestree a través de la superficie, así que los guijarros y el lecho se ven a través con una ligera deformación y un tinte. Cuesta más que la transparencia simple, pero da un agua poco profunda convincente con muy poco código.

Las ondas se calculan en la CPU: en cada fotograma, las 480 alturas de vértice de la tira se fijan a partir de la suma de tres senos de posición y tiempo, y se recalculan las normales. Eso no cuesta casi nada. Nuestro artículo sobre el océano en tormenta usa la misma idea a una escala mucho mayor.

25.000 briznas de hierba en una sola llamada de dibujo

La hierba es lo que más nos preguntan, y es casi todo un solo InstancedMesh. Una brizna empieza como un fino PlaneGeometry(0.085, 1.05, 1, 3) desplazado para que su base quede en el origen, y luego se da forma a cada vértice:

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

El degradado de vértices de oscuro a claro es un sustituto barato de la oclusión ambiental en un césped denso. El material es un MeshStandardMaterial de doble cara con vertexColors.

Después se reparten 25.000 instancias por un disco de radio 58. Usar r = Math.sqrt(Math.random()) * 58 mantiene la densidad uniforme en todo el disco (sin la raíz cuadrada, las briznas se amontonan en el centro), y se descarta cualquier punto a menos de 4,6 unidades del arroyo para que el agua quede despejada. Cada instancia recibe un giro aleatorio, una ligera inclinación, una escala entre 0,6 y 1,7 y su propio color mediante setColorAt. Three.js multiplica el color de la instancia por el degradado de vértices, así que cada brizna tiene un verde distinto pero la misma transición de oscuro a claro.

El resultado: 25.000 briznas, una geometría, un material, una llamada de dibujo, con las matrices construidas una sola vez al arrancar.

Viento con onBeforeCompile

No escribimos un shader de hierba propio. En su lugar, un pequeño ayudante parchea el vertex shader del material estándar justo después de que three.js calcule la posición local, de modo que toda la iluminación, las sombras y la niebla integradas siguen 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;`);
};

(Ligeramente simplificado: el ayudante real envuelve la línea de instanceMatrix en #ifdef USE_INSTANCING y recibe la amplitud como parámetro.) Tres detalles hacen que funcione:

  • Fase a partir de la posición. instanceMatrix[3] es la traslación de la instancia, así que wph es una fase que cambia suavemente por todo el campo. Las briznas vecinas se mueven casi a la vez, y el movimiento recorre el prado como ráfagas en lugar de que todas las briznas se agiten sincronizadas.
  • Peso según la altura. Multiplicar por position.y (0 en la raíz) mantiene cada brizna anclada mientras la punta es la que más oscila.
  • Un único uniform compartido. Todos los materiales parcheados reciben el mismo objeto uTime, así que una sola asignación por fotograma, uTime.value = t, anima a la vez la hierba y el follaje de los siete árboles.

Para las hojas se llama al mismo ayudante con una amplitud menor (0.07) y sin la ponderación por altura, de modo que cada tarjeta de hoja se desplaza entera en lugar de doblarse.

Árboles hechos de cilindros y tarjetas de hojas

Cada tronco es una pila de dos o tres segmentos cónicos de CylinderGeometry con una pequeña inclinación aleatoria, cinco cilindros de raíces abiertos alrededor de la base y unas cuantas ramas inclinadas hacia la copa, todo con una textura de corteza dibujada en un canvas al arrancar (vetas bézier verticales sobre marrón).

La copa es otro InstancedMesh: un simple cuadrado de 0,62 unidades con una hoja dibujada en canvas como textura y recortado con alphaTest: 0.45. Usar alpha test en lugar de transparencia real significa no tener que ordenar y obtener una profundidad correcta, algo esencial con miles de tarjetas superpuestas. Las tarjetas se reparten entre un puñado de grupos esféricos por árbol, según el volumen de cada grupo, y se colocan sobre todo hacia el exterior de cada grupo para que la copa parezca llena pero no maciza:

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);

Las hojas de la parte superior de cada grupo se tiñen más claras y cálidas que las de abajo, lo que simula la luz del sol filtrándose. El gran árbol protagonista lleva 9.000 tarjetas de hojas y cada uno de los seis árboles más pequeños 2.200, unas 22.200 en total. Detrás de ellos, un anillo de 90 conos instanciados se sitúa entre 62 y 90 unidades; en la niebla se leen como una línea de árboles lejana y dan profundidad al claro casi sin coste.

Clic para caminar: raycast, límites y paseo

La cámara usa OrbitControls, y tanto el botón izquierdo como el derecho del ratón están asignados a rotar (las pantallas táctiles no tienen botón derecho). Así que lo primero es distinguir un clic de un arrastre. En pointerup ignoramos el evento si el puntero se movió más de 6 píxeles o la pulsación duró más de 700 ms; si no, lanzamos un rayo desde la cámara a través del clic:

raycaster.setFromCamera(ndc, camera);
const hit = raycaster.intersectObject(ground, false)[0];
if (hit) setGirlTarget(hit.point.x, hit.point.z);

Solo se prueba la malla del terreno, así que el raycast sigue siendo rápido y siempre puedes hacer clic «a través» de la hierba hasta el suelo.

Después, setGirlTarget aplica dos reglas sin ningún navmesh. Si el destino está a menos de 4,4 unidades de la línea central del arroyo, se empuja de vuelta a la orilla en la que ella está, así que hacer clic al otro lado del agua la manda al borde más cercano en lugar de meterse. Y si el destino está a más de 26 unidades del centro, se lleva de vuelta a ese círculo para que se quede dentro del bosque. En el punto final se coloca un anillo que se expande y se desvanece en 0,9 segundos.

Caminar son unas pocas líneas en el bucle de animación. Se mueve en línea recta hacia el destino a 2,6 unidades por segundo y gira hacia él por el lado más corto:

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);

Ajustar la diferencia de ángulos al rango −π…π es lo que evita que dé una vuelta completa cuando el destino está justo detrás de su hombro izquierdo. Un valor gait pasa suavemente de 0 (de pie) a 1 (caminando), y todo escala con él: el balanceo de piernas y brazos, un pequeño salto a partir de abs(sin(phase)), una ligera inclinación hacia delante y el rebote de sus coletas. Arranca y se detiene con suavidad, y cuando está quieta toma el relevo un leve vaivén de respiración. Su altura es simplemente terrainH en su posición, así que sigue cada pendiente.

Peces, mariposas y hojas que caen

Las cosas pequeñas que se mueven son todas movimiento paramétrico barato, sin simulación:

  • Peces. Diez peces, cada uno una esfera achatada con aletas de shape geometry, nadan a lo largo de cxOf(z) con un desplazamiento de carril y vuelven a empezar al llegar a los extremos del arroyo. Cada uno mira un poco por delante en la curva para orientarse aguas abajo, y su cola oscila con un seno.
  • Mariposas. Seis pares de alas con textura de pétalo trazan bucles al estilo Lissajous (velocidades senoidales distintas en x y z), miran en su dirección de avance y aletean rápido, sin bajar nunca de 0,8 unidades sobre el terreno.
  • Hojas que caen. Sesenta tarjetas de hojas en un solo InstancedMesh caen del árbol protagonista en un ciclo de 11 unidades, derivando de lado y dando vueltas, con sus matrices reescritas en cada fotograma.
  • Aire. 500 motas de polen aditivas, siete sprites de nubes a la deriva y cuatro planos de haces de luz inclinados según sunDir a través del árbol grande.

Posprocesado: bloom y corrección de color

El fotograma pasa por una cadena de EffectComposer: RenderPass, luego UnrealBloomPass con una intensidad moderada de 0.32 y un umbral alto para que solo brillen el cielo más luminoso y los bordes de hojas iluminados por el sol, después OutputPass para el tone mapping y la conversión a sRGB, y por último un ShaderPass propio. Como la corrección se aplica después del output pass, trabaja sobre colores listos para pantalla, lo que hace que sus números sean fáciles de razonar:

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

También sube la saturación un 10 % y añade una viñeta. Altas luces cálidas sobre sombras ligeramente verdeazuladas hacen que una escena de verde sobre verde parezca última hora de la tarde en lugar de un render por defecto.

Mantenerlo ligero

Es una escena pesada para una página web, así que tiene que ser educada:

  • Instancing en todo lo que se repite: la hierba, las hojas, las hojas que caen, los guijarros, las rocas y la línea de árboles lejana son cada uno un único InstancedMesh.
  • Pixel ratio limitado a 1,75. Si no, los móviles de alta densidad renderizarían el triple de píxeles a través del bloom y la transmisión.
  • Pausa fuera de pantalla. Un IntersectionObserver detiene el bucle de requestAnimationFrame cuando se ve menos del 15 % del escenario, y un listener de visibilitychange lo detiene cuando la pestaña está oculta. La página también tiene un botón de Pausa.
  • Tiempo de fotograma limitado a 50 ms, para que tras una pausa el mundo se reanude con calma en lugar de dar un salto.
  • Un raycast barato solo contra la malla del suelo, nunca contra el follaje instanciado.
  • Respeto por el movimiento reducido. La lenta órbita automática de la cámara se desactiva para quienes piden movimiento reducido en su sistema, y se detiene para siempre la primera vez que haces clic izquierdo, tocas o arrastras con el botón izquierdo.

Conclusiones

  • Haz de una función de altura la única fuente de verdad. Si el terreno, los objetos y el personaje la muestrean, la escena se sostiene.
  • Construye primero el arroyo y luego el terreno a su alrededor. Una curva de línea central te da de una vez el cauce, la malla del agua, las rutas de los peces y la regla de «no meterse en el agua».
  • La hierba es instancing más un parche de vértices. Una brizna con forma, 25.000 matrices y unas pocas líneas en onBeforeCompile dan mucho de sí.
  • El clic para caminar no necesita navmesh en una escena abierta: raycast contra el suelo, limita el destino con reglas sencillas y suaviza el giro y la marcha.
  • La luz y la corrección de color venden el ambiente. Un sol bajo, un entorno PMREM, niebla, un bloom suave y una corrección cálida/fría aportan tanto como cualquier modelo.

La escena está en marcha en Paseo por el bosque: haz clic por el prado, gira la cámara y mira cómo el viento recorre la hierba. Si te ha gustado, lee cómo hicimos un océano en tormenta con three.js o cómo animamos un monigote con IK de dos huesos.