Waldspaziergang ist ein kleiner three.js-Wald, durch den du direkt im Browser spazieren kannst. Es gibt sanfte Hügel, einen Bach, der sich durch ein Tal windet, Zehntausende Grashalme und Blätter, die sich im Wind bewegen, Blumen, Fische, Schmetterlinge und eine tiefe Sonne zur goldenen Stunde. Am Wasser steht ein kleines Mädchen im roten Kleid; klick auf den Boden, und sie geht dorthin. Die Szene läuft auf three.js r160 und WebGL 2, ohne heruntergeladene Modelle oder Bildtexturen: Alles wird beim Laden im Code erzeugt.
Dieser Artikel zeigt, wie sie zusammengesetzt ist, mit kurzen Ausschnitten aus dem echten Quellcode: Gelände, Gras, Wind, Bäume und eine begehbare 3D-Szene in three.js ohne Physik-Engine und ohne Navmesh.
Renderer, Tone Mapping und ein Himmel zur goldenen Stunde
Der Renderer zeichnet in den Bühnen-Container der Seite und ist für eine physikalisch beleuchtete Außenszene eingerichtet:
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;
ACES-Filmic-Tone-Mapping lässt helle Lichter weich auslaufen, statt sie abzuschneiden – wichtig, wenn der Himmel im Bild ist; die Belichtung liegt deutlich unter 1, damit die Wiese nicht ausbleicht.
Der Himmel ist das Sky-Addon von three.js, ein Shader, der atmosphärische Streuung modelliert. Wir stellen Trübung und Rayleigh-Streuung auf einen leicht diesigen Nachmittag ein und setzen die Sonne nur 16° über den Horizont – genau das gibt der ganzen Szene ihren warmen Look mit langen Schatten:
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;
Die Zeile mit dem PMREMGenerator ist der günstige Gewinn: Sie backt den Himmel in eine vorgefilterte Environment Map, sodass jedes MeshStandardMaterial in der Szene himmelfarbenes Umgebungslicht und Reflexionen gratis bekommt. Dazu kommen ein warmes DirectionalLight entlang desselben sunDir (damit die Schatten zum Himmel passen), eine weiche Shadow Map mit 2048 × 2048 für einen Bereich von 100 × 100 rund um die Lichtung, ein blau-über-grünes HemisphereLight als Aufhellung und exponentieller Nebel (FogExp2) in einem blassen Salbeiton, der die Ferne auflöst.
Prozedurales Gelände aus einer Höhenfunktion
Alles in der Szene, was den Boden berührt, fragt eine einzige Funktion, wo der Boden ist. Zuerst der Bach: Seine Mittellinie ist eine Kurve x = cxOf(z) aus zwei Sinuswellen unterschiedlicher Frequenz, sodass er mäandert, ohne sich offensichtlich zu wiederholen:
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;
}
Lies sie als Schichten. rolling sind drei überlagerte Sinuswellen für sanfte Hügel. d ist der horizontale Abstand zur Mittellinie des Bachs, und ss ist ein Smoothstep. Die Hügel mit flat zu multiplizieren, blendet sie nahe am Wasser aus, damit die Ufer eben sind; carve zieht direkt unter dem Bach bis zu 1,15 Einheiten ab und gräbt so eine Rinne; und bank ist eine kleine Gauß-Erhebung etwa 5,6 Einheiten weiter außen, die wie eine natürliche Uferkante wirkt.
Das Mesh ist eine flach gedrehte PlaneGeometry(240, 240, 190, 190), deren Eckpunkthöhen aus terrainH gesetzt werden. Dieselbe Schleife schreibt auch Vertex-Farben anhand von d: dunkles Bachbett innerhalb von 3 Einheiten, Sand, der zwischen 3,6 und 5,4 Einheiten in Gras übergeht, und dahinter Gras mit ein wenig sinusbasierter Variation in Farbton und Helligkeit. Ein Aufruf von computeVertexNormals(), und das Gelände ist fertig – ohne Heightmap und ohne Noise-Bibliothek.
Weil terrainH günstig und deterministisch ist, platziert sie auch 160 Kiesel im Bachbett, 70 Ufersteine, jeden Halm, jede Blume, jeden Baum und die Füße des Mädchens. Nichts schwebt und nichts sinkt ein.
Ein Wasserband, das dem Bach folgt
Das Wasser ist keine große, vom Gelände abgeschnittene Ebene. Es ist eine eigene BufferGeometry: ein Streifen aus 240 Reihen mit je zwei Eckpunkten links und rechts von cxOf(z), sodass sich das Band exakt mit der Rinne biegt:
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);
}
Sein Material ist ein MeshPhysicalMaterial mit transmission: 0.92, ior: 1.33 (dem Brechungsindex von Wasser), etwas Dicke und einer petrolfarbenen Attenuation-Farbe. Transmission lässt three.js die undurchsichtige Szene in einen zusätzlichen Puffer rendern und durch die Oberfläche abtasten, sodass Kiesel und Bachbett leicht gebrochen und getönt durchscheinen. Das kostet mehr als einfache Transparenz, ergibt aber mit sehr wenig Code überzeugendes flaches Wasser.
Die Wellen laufen auf der CPU: In jedem Frame werden die 480 Vertex-Höhen des Streifens aus drei summierten Sinuswellen von Position und Zeit gesetzt und die Normalen neu berechnet. Das kostet so gut wie nichts. Unser Artikel über den Sturm-Ozean nutzt dieselbe Idee in viel größerem Maßstab.
25.000 Grashalme in einem Draw Call
Nach dem Gras wird am häufigsten gefragt, und es ist im Wesentlichen ein einziges InstancedMesh. Ein einzelner Halm beginnt als schmale PlaneGeometry(0.085, 1.05, 1, 3), die so verschoben wird, dass ihre Basis im Ursprung liegt; dann wird jeder Eckpunkt geformt:
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
Der Vertex-Verlauf von dunkel nach hell ist ein günstiger Ersatz für Ambient Occlusion in einem dichten Rasen. Das Material ist ein doppelseitiges MeshStandardMaterial mit vertexColors.
Dann werden 25.000 Instanzen über eine Scheibe mit Radius 58 verteilt. Mit r = Math.sqrt(Math.random()) * 58 bleibt die Dichte über die ganze Scheibe gleichmäßig (ohne die Wurzel ballen sich die Halme in der Mitte), und jeder Punkt innerhalb von 4,6 Einheiten um den Bach wird verworfen, damit das Wasser frei bleibt. Jede Instanz bekommt eine zufällige Drehung, eine leichte Neigung, eine Skalierung zwischen 0,6 und 1,7 und über setColorAt ihre eigene Farbe. three.js multipliziert die Instanzfarbe mit dem Vertex-Verlauf, sodass jeder Halm ein anderes Grün hat, aber denselben Verlauf von dunkel nach hell.
Das Ergebnis: 25.000 Halme, eine Geometrie, ein Material, ein Draw Call, mit Matrizen, die einmal beim Start berechnet werden.
Wind mit onBeforeCompile
Einen eigenen Gras-Shader haben wir nicht geschrieben. Stattdessen patcht eine kleine Hilfsfunktion den Vertex-Shader des Standardmaterials direkt nachdem three.js die lokale Position berechnet hat – so funktionieren Beleuchtung, Schatten und Nebel weiterhin wie eingebaut:
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;`);
};
(Leicht vereinfacht: Die echte Hilfsfunktion verpackt die instanceMatrix-Zeile in #ifdef USE_INSTANCING und nimmt die Amplitude als Parameter.) Drei Details sorgen dafür, dass es funktioniert:
- Phase aus der Position.
instanceMatrix[3]ist die Verschiebung der Instanz, also istwpheine Phase, die sich über das Feld hinweg gleichmäßig ändert. Benachbarte Halme bewegen sich fast gemeinsam, und die Bewegung rollt wie Böen über die Wiese, statt dass alle Halme im Gleichtakt zucken. - Gewichtung nach Höhe. Die Multiplikation mit
position.y(0 an der Wurzel) hält jeden Halm am Boden verankert, während seine Spitze am weitesten ausschwingt. - Ein gemeinsames Uniform. Jedes gepatchte Material bekommt dasselbe
uTime-Objekt, sodass eine einzige Zuweisung pro Frame,uTime.value = t, das Gras und das Laub aller sieben Bäume auf einmal animiert.
Für die Blätter wird dieselbe Hilfsfunktion mit kleinerer Amplitude (0.07) und ohne Höhengewichtung aufgerufen, sodass sich jede Blattkarte als Ganzes verschiebt, statt sich zu biegen.
Bäume aus Zylindern und Blattkarten
Jeder Stamm ist ein Stapel aus zwei oder drei sich verjüngenden CylinderGeometry-Segmenten mit leichter zufälliger Neigung, fünf ausgestellten Wurzelzylindern am Fuß und ein paar Ästen, die zur Krone hin abgewinkelt sind – alle mit einer Rindentextur, die beim Start in ein Canvas gezeichnet wird (senkrechte Bézier-Striche auf Braun).
Die Krone ist ein weiteres InstancedMesh: ein einfaches Quadrat mit 0,62 Einheiten Kantenlänge, texturiert mit einem im Canvas gezeichneten Blatt und per alphaTest: 0.45 ausgeschnitten. Alpha-Test statt echter Transparenz bedeutet keine Sortierung und korrekte Tiefe – unverzichtbar bei Tausenden sich überlappenden Karten. Die Karten verteilen sich auf eine Handvoll kugelförmiger Cluster pro Baum, aufgeteilt nach Cluster-Volumen, und liegen überwiegend zur Außenseite jedes Clusters hin, sodass die Krone voll, aber nicht massiv wirkt:
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);
Blätter oben auf einem Cluster werden heller und wärmer getönt als die darunter, was durchfallendes Sonnenlicht vortäuscht. Der große Hauptbaum trägt 9.000 Blattkarten und jeder der sechs kleineren Bäume 2.200, insgesamt etwa 22.200. Dahinter steht in 62 bis 90 Einheiten Entfernung ein Ring aus 90 instanzierten Kegeln; im Nebel wirken sie wie ein ferner Waldrand und geben der Lichtung fast kostenlos Tiefe.
Klick-zum-Gehen: Raycast, begrenzen, losspazieren
Die Kamera nutzt OrbitControls, und sowohl die linke als auch die rechte Maustaste sind aufs Drehen gelegt (Touchscreens haben keine rechte Taste). Die erste Aufgabe ist also, einen Klick von einem Ziehen zu unterscheiden. Bei pointerup ignorieren wir das Ereignis, wenn sich der Zeiger mehr als 6 Pixel bewegt hat oder der Druck länger als 700 ms dauerte; andernfalls schicken wir einen Strahl von der Kamera durch den Klickpunkt:
raycaster.setFromCamera(ndc, camera);
const hit = raycaster.intersectObject(ground, false)[0];
if (hit) setGirlTarget(hit.point.x, hit.point.z);
Getestet wird nur das Gelände-Mesh, sodass der Raycast schnell bleibt und du immer „durch“ das Gras auf den Boden klicken kannst.
setGirlTarget wendet dann zwei Regeln an, ganz ohne Navmesh. Liegt das Ziel innerhalb von 4,4 Einheiten um die Mittellinie des Bachs, wird es auf das Ufer zurückgeschoben, auf dem sie gerade steht – ein Klick über das Wasser schickt sie also an den nahen Rand, statt dass sie hineinwatet. Und liegt das Ziel mehr als 26 Einheiten vom Mittelpunkt entfernt, wird es auf diesen Kreis zurückgezogen, damit sie im Wald bleibt. Am endgültigen Punkt erscheint ein Ring, der sich über 0,9 Sekunden ausdehnt und verblasst.
Das Gehen sind ein paar Zeilen in der Animationsschleife. Sie bewegt sich mit 2,6 Einheiten pro Sekunde geradewegs aufs Ziel zu und dreht sich auf dem kürzesten Weg dorthin:
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);
Die Winkeldifferenz in −π…π einzupacken, verhindert, dass sie sich einmal komplett im Kreis dreht, wenn das Ziel knapp hinter ihrer linken Schulter liegt. Ein gait-Wert gleitet zwischen 0 (Stehen) und 1 (Gehen), und alles skaliert damit: das Schwingen von Beinen und Armen, ein kleiner Hüpfer aus abs(sin(phase)), eine leichte Vorlage und das Wippen ihrer Zöpfe. Sie läuft weich an und hält weich an, und im Stand übernimmt ein winziges Atmen. Ihre Höhe ist einfach terrainH an ihrer Position, sodass sie jedem Hang folgt.
Fische, Schmetterlinge und fallende Blätter
Die kleinen beweglichen Dinge sind alle günstige parametrische Bewegung, ganz ohne Simulation:
- Fische. Zehn Fische, jeder eine gestauchte Kugel mit Flossen aus Shape-Geometrie, schwimmen mit einem Bahnversatz entlang
cxOf(z)und springen an den Enden des Bachs wieder an den Anfang. Jeder schaut ein Stück voraus entlang der Kurve, um stromabwärts zu blicken, und sein Schwanz schwingt auf einer Sinuskurve. - Schmetterlinge. Sechs Paar Flügel mit Blütenblatt-Textur fliegen Schleifen im Lissajous-Stil (unterschiedliche Sinusgeschwindigkeiten auf x und z), schauen in Flugrichtung und schlagen schnell mit den Flügeln, nie tiefer als 0,8 Einheiten über dem Gelände.
- Fallende Blätter. Sechzig Blattkarten in einem
InstancedMeshfallen in einem Zyklus über 11 Einheiten vom Hauptbaum, treiben seitlich und taumeln, wobei ihre Matrizen in jedem Frame neu geschrieben werden. - Luft. 500 additive Pollenteilchen, sieben treibende Wolken-Sprites und vier Lichtstrahl-Ebenen, die entlang
sunDirdurch den großen Baum fallen.
Post-Processing: Bloom und Color Grading
Das Bild läuft durch eine EffectComposer-Kette: RenderPass, dann UnrealBloomPass mit einer moderaten Stärke von 0.32 und hohem Schwellenwert, sodass nur der hellste Himmel und sonnenbeschienene Blattränder leuchten, dann OutputPass für Tone Mapping und sRGB-Umwandlung und zuletzt ein eigener ShaderPass. Weil das Grading nach dem Output-Pass läuft, arbeitet es mit anzeigefertigen Farben, was seine Zahlen leicht nachvollziehbar macht:
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
Außerdem hebt es die Sättigung um 10 % an und fügt eine Vignette hinzu. Warme Lichter über leicht petrolfarbenen Schatten lassen eine Grün-in-Grün-Szene nach spätem Nachmittag wirken statt nach einem Standard-Render.
Leichtgewichtig bleiben
Für eine Webseite ist das eine schwere Szene, also muss sie rücksichtsvoll sein:
- Instancing überall, wo sich etwas wiederholt: Gras, Blätter, fallende Blätter, Kiesel, Steine und der ferne Waldrand sind jeweils ein einziges
InstancedMesh. - Pixel Ratio auf 1,75 begrenzt. Handys mit hoher DPI würden sonst die dreifache Pixelmenge durch Bloom und Transmission schicken.
- Pause außerhalb des Sichtbereichs. Ein
IntersectionObserverstoppt dierequestAnimationFrame-Schleife, wenn weniger als 15 % der Bühne sichtbar sind, und einvisibilitychange-Listener stoppt sie, wenn der Tab verborgen ist. Die Seite hat außerdem einen Pause-Button. - Frame-Zeit auf 50 ms begrenzt, damit die Welt nach einer Pause ruhig weiterläuft, statt nach vorn zu springen.
- Ein günstiger Raycast nur gegen das Boden-Mesh, nie gegen das instanzierte Laub.
- Reduzierte Bewegung wird respektiert. Die langsame Leerlauf-Kamerafahrt ist für Besucher abgeschaltet, die in ihrem System reduzierte Bewegung eingestellt haben, und sie endet endgültig beim ersten Linksklick, Tippen oder Ziehen mit der linken Taste.
Fazit
- Mach eine Höhenfunktion zur einzigen Quelle der Wahrheit. Wenn Gelände, Requisiten und Figur alle sie abfragen, hält die Szene zusammen.
- Erst den Bach bauen, dann das Gelände drumherum. Eine Mittellinien-Kurve liefert dir Rinne, Wasser-Mesh, Fischpfade und die Regel „nicht ins Wasser laufen“ in einem Zug.
- Gras ist Instancing plus ein Vertex-Patch. Ein geformter Halm, 25.000 Matrizen und ein paar Zeilen in
onBeforeCompilebringen einen weit. - Klick-zum-Gehen braucht kein Navmesh für eine offene Szene: Raycast auf den Boden, Ziel mit einfachen Regeln begrenzen, Drehung und Gang weich überblenden.
- Licht und Grading verkaufen die Stimmung. Eine tiefe Sonne, eine PMREM-Umgebung, Nebel, sanfter Bloom und ein warm/kaltes Grading leisten so viel wie jedes Modell.
Die Szene ist live unter Waldspaziergang: Klick dich über die Wiese, dreh die Kamera und sieh zu, wie der Wind durchs Gras geht. Wenn dir das gefallen hat, lies, wie wir einen Sturm-Ozean in three.js gebaut haben oder wie wir ein Strichmännchen mit Two-Bone-IK animieren.