Installiert: three r186 · alle Beispiele laufen live

🧊Three.js ausführlich erklärt

three.js nimmt einem die rohen WebGL-Aufrufe ab (siehe WebGL-Labor) und bietet stattdessen Objekte: Szene, Kamera, Mesh, Licht. Jede Lektion zeigt kommentierten Minimal-Code – und daneben läuft genau dieser Code. Die Regler ändern das Objekt params, die Codeansicht aktualisiert sich mit.

1.🎬Scene · Camera · Renderer

Jede three.js-Anwendung braucht drei Dinge: eine Scene (der Behälter für alles Sichtbare), eine Camera (von wo und wie wird geschaut) und einen Renderer (zeichnet die Szene aus Sicht der Kamera auf ein <canvas>).

Ein Mesh ist das sichtbare Objekt: Geometry (Form: Vertices, Normalen, UVs) + Material (Aussehen: wie reagiert die Oberfläche auf Licht).

Farbe
  • PerspectiveCamera(fov, aspect, near, far) – fov ist der vertikale Bildwinkel in Grad.
  • renderer.render(scene, camera) zeichnet genau ein Bild.
  • Ohne Licht sind MeshStandardMaterial-Objekte schwarz – daher das DirectionalLight.
🏖️ Im Apartment-Viewer: Der Apartment-Viewer erzeugt genau dieses Trio – mit ACES-Tonemapping und einem dunklen Hintergrund.
Genau dieser Code läuft links (Regler ändern die params-Zeile live)
1import * as THREE from "three";
2
3const params = { fov: 50, z: 4, color: "#14b8a6" }; // ← die Regler ändern genau dieses Objekt
4
5// 1. Renderer: zeichnet auf das vorhandene <canvas>
6const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
7renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
8
9// 2. Szene: Behälter für Objekte und Lichter
10const scene = new THREE.Scene();
11scene.background = new THREE.Color("#0f172a");
12
13// 3. Kamera: Bildwinkel, Seitenverhältnis, near/far
14const camera = new THREE.PerspectiveCamera(params.fov, canvas.clientWidth / canvas.clientHeight, 0.1, 100);
15
16// 4. Mesh = Geometry + Material
17const geometry = new THREE.BoxGeometry(1, 1, 1);
18const material = new THREE.MeshStandardMaterial({ color: params.color });
19const cube = new THREE.Mesh(geometry, material);
20cube.rotation.set(0.5, 0.7, 0);
21scene.add(cube);
22
23// 5. Licht, sonst bleibt ein Standard-Material schwarz
24const light = new THREE.DirectionalLight(0xffffff, 3);
25light.position.set(2, 3, 4);
26scene.add(light, new THREE.AmbientLight(0xffffff, 0.3));
27
28function update() {
29 camera.fov = params.fov;
30 camera.position.z = params.z;
31 camera.updateProjectionMatrix(); // nach Änderung von fov/aspect/near/far Pflicht!
32 material.color.set(params.color);
33 renderer.render(scene, camera); // ein einziges Bild zeichnen
34}
35update();
36
37return { update, dispose: () => { geometry.dispose(); material.dispose(); renderer.dispose(); } };

2.🎨Materialtypen

Das Material entscheidet, welcher Shader läuft. three.js bringt eine Reihe fertiger Materialien mit – von „ignoriert Licht“ bis „physikalisch korrekt mit Klarlack“.

Je realistischer, desto teurer pro Pixel: Basic < Lambert < Phong < Standard < Physical.

Material
  • MeshBasicMaterial: unbeleuchtet, nur Farbe/Textur.
  • MeshLambertMaterial: nur diffus (Helligkeit pro Pixel nach Lambert).
  • MeshPhongMaterial: diffus + Glanzlicht (Blinn-Phong), shininess.
  • MeshStandardMaterial: PBR mit roughness/metalness – das Material, das der GLTFLoader erzeugt.
  • MeshPhysicalMaterial: Standard + clearcoat, transmission, sheen, iridescence …
🏖️ Im Apartment-Viewer: Alle 27 Materialien der Ferienwohnung kommen als MeshStandardMaterial aus dem GLTFLoader (glTF = PBR metallic/roughness).
Genau dieser Code läuft links (Regler ändern die params-Zeile live)
1import * as THREE from "three";
2
3const params = { type: "Standard", roughness: 0.35, metalness: 0.2, clearcoat: 0, wireframe: false }; // ← die Regler ändern genau dieses Objekt
4
5const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
6renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
7const scene = new THREE.Scene();
8scene.background = new THREE.Color("#0f172a");
9const camera = new THREE.PerspectiveCamera(40, canvas.clientWidth / canvas.clientHeight, 0.1, 100);
10camera.position.set(0, 0.6, 4.2);
11camera.lookAt(0, 0, 0);
12
13const light = new THREE.DirectionalLight(0xffffff, 3);
14light.position.set(3, 4, 5);
15scene.add(light, new THREE.AmbientLight(0x8899bb, 0.4));
16
17const geometry = new THREE.TorusKnotGeometry(0.8, 0.28, 160, 24);
18const mesh = new THREE.Mesh(geometry);
19scene.add(mesh);
20
21function makeMaterial() {
22 const color = 0x14b8a6;
23 switch (params.type) {
24 case "Basic": return new THREE.MeshBasicMaterial({ color });
25 case "Lambert": return new THREE.MeshLambertMaterial({ color });
26 case "Phong": return new THREE.MeshPhongMaterial({ color, shininess: 80 });
27 case "Standard": return new THREE.MeshStandardMaterial({ color, roughness: params.roughness, metalness: params.metalness });
28 default: return new THREE.MeshPhysicalMaterial({ color, roughness: params.roughness, metalness: params.metalness, clearcoat: params.clearcoat });
29 }
30}
31
32function update() {
33 mesh.material.dispose?.(); // altes Material freigeben (Shader-Programm bleibt im Cache)
34 mesh.material = makeMaterial();
35 mesh.material.wireframe = params.wireframe;
36}
37update();
38
39renderer.setAnimationLoop((time) => {
40 mesh.rotation.y = time / 2000;
41 renderer.render(scene, camera);
42 // jedes neue Materialtyp-Paar kompiliert ein eigenes Shader-Programm
43 report({ Material: mesh.material.type, "Shader-Programme": renderer.info.programs.length });
44});
45
46return { update, dispose: () => { renderer.setAnimationLoop(null); geometry.dispose(); mesh.material.dispose(); renderer.dispose(); } };

3.💡Lichter und Schatten

Lichtquellen sind Objekte in der Szene. AmbientLight hellt alles gleichmäßig auf, HemisphereLight mischt Himmel- und Bodenfarbe, DirectionalLight ist wie die Sonne (parallele Strahlen), PointLight strahlt in alle Richtungen, SpotLight als Kegel.

Schatten kosten einen zusätzlichen Render-Durchlauf pro Licht: Die Szene wird aus Sicht des Lichts in eine Shadow-Map (Tiefentextur) gezeichnet.

Lichtart
shadow.mapSize
  • Drei Schalter nötig: renderer.shadowMap.enabled, light.castShadow, mesh.castShadow/receiveShadow.
  • shadow.mapSize bestimmt die Schärfe (und den Speicher).
  • Schau auf die Draw Calls: Mit Schatten verdoppeln sie sich fast.
🏖️ Im Apartment-Viewer: Der Apartment-Viewer nutzt HemisphereLight + DirectionalLight („Sonne aus Nordosten“) mit einer 2048²-Shadow-Map und ein RoomEnvironment für weiche Reflexionen.
Genau dieser Code läuft links (Regler ändern die params-Zeile live)
1import * as THREE from "three";
2
3const params = { light: "Directional", intensity: 3, angle: 40, shadows: true, mapSize: "512" }; // ← die Regler ändern genau dieses Objekt
4
5const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
6renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
7renderer.shadowMap.enabled = true; // Schalter 1
8const scene = new THREE.Scene();
9scene.background = new THREE.Color("#0f172a");
10const camera = new THREE.PerspectiveCamera(45, canvas.clientWidth / canvas.clientHeight, 0.1, 100);
11camera.position.set(0, 3.2, 5.5);
12camera.lookAt(0, 0, 0);
13
14const floor = new THREE.Mesh(new THREE.PlaneGeometry(8, 8), new THREE.MeshStandardMaterial({ color: 0xe2e8f0 }));
15floor.rotation.x = -Math.PI / 2;
16floor.receiveShadow = true; // Schalter 3a
17const ball = new THREE.Mesh(new THREE.SphereGeometry(0.6, 48, 24), new THREE.MeshStandardMaterial({ color: 0x8b5cf6, roughness: 0.4 }));
18ball.position.set(-0.7, 0.6, 0);
19const box = new THREE.Mesh(new THREE.BoxGeometry(0.9, 1.4, 0.9), new THREE.MeshStandardMaterial({ color: 0x14b8a6 }));
20box.position.set(0.9, 0.7, -0.3);
21for (const m of [ball, box]) { m.castShadow = true; m.receiveShadow = true; } // Schalter 3b
22scene.add(floor, ball, box, new THREE.AmbientLight(0xffffff, 0.15));
23
24let light;
25function update() {
26 if (light) { scene.remove(light); light.dispose(); }
27 if (params.light === "Directional") light = new THREE.DirectionalLight(0xffffff, params.intensity);
28 if (params.light === "Point") light = new THREE.PointLight(0xffffff, params.intensity * 4, 0, 2);
29 if (params.light === "Spot") light = new THREE.SpotLight(0xffffff, params.intensity * 6, 0, Math.PI / 6, 0.3, 2);
30 if (params.light === "Hemisphere") light = new THREE.HemisphereLight(0xbcd3e8, 0x3b3a35, params.intensity);
31 const a = params.angle * Math.PI / 180;
32 light.position.set(Math.cos(a) * 3, 3.5, Math.sin(a) * 3);
33 if (light.shadow) {
34 light.castShadow = params.shadows; // Schalter 2 (HemisphereLight kann keine Schatten)
35 light.shadow.mapSize.set(Number(params.mapSize), Number(params.mapSize));
36 }
37 scene.add(light);
38}
39update();
40
41renderer.setAnimationLoop(() => {
42 renderer.render(scene, camera);
43 report({ "Draw Calls": renderer.info.render.calls, Licht: light.type, "wirft Schatten": light.castShadow === true });
44});
45
46return { update, dispose: () => { renderer.setAnimationLoop(null); scene.traverse((o) => { o.geometry?.dispose(); o.material?.dispose(); }); light.dispose(); renderer.dispose(); } };

4.🌳Object3D und der Szenengraph

Alles in einer Szene ist ein Object3D: Meshes, Lichter, Kameras, Gruppen. Jedes hat position, rotation (Euler-Winkel in Radiant), scale und Kinder (add).

Die lokale Matrix beschreibt die Lage relativ zum Elternobjekt; die Weltmatrix (matrixWorld) ergibt sich als Produkt aller Eltern. Dreht sich die Erde um die Sonne, nimmt sie ihren Mond automatisch mit.

  • Group ist ein leeres Object3D – ideal als Drehpunkt.
  • getWorldPosition() liefert die Position in Weltkoordinaten.
  • three.js aktualisiert matrixWorld vor jedem render() automatisch.
🏖️ Im Apartment-Viewer: Das Apartment besteht aus 721 Knoten: 9 Gruppen (EG_Waende, OG_Moebel …) mit 712 Meshes darunter. Die Explosionsdarstellung verschiebt nur die 4 OG-Gruppen – alle Kinder folgen.
Genau dieser Code läuft links (Regler ändern die params-Zeile live)
1import * as THREE from "three";
2
3const params = { orbitSpeed: 0.6, moonSpeed: 2.5, earthScale: 1 }; // ← die Regler ändern genau dieses Objekt
4
5const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
6renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
7const scene = new THREE.Scene();
8scene.background = new THREE.Color("#0f172a");
9const camera = new THREE.PerspectiveCamera(45, canvas.clientWidth / canvas.clientHeight, 0.1, 100);
10camera.position.set(0, 6, 7);
11camera.lookAt(0, 0, 0);
12scene.add(new THREE.PointLight(0xfff2cc, 60, 0, 2), new THREE.AmbientLight(0xffffff, 0.2));
13
14const sphere = new THREE.SphereGeometry(1, 32, 16);
15const sun = new THREE.Mesh(sphere, new THREE.MeshBasicMaterial({ color: 0xfbbf24 }));
16const earthOrbit = new THREE.Group(); // Drehpunkt in der Sonne
17const earth = new THREE.Mesh(sphere, new THREE.MeshStandardMaterial({ color: 0x0ea5e9 }));
18earth.position.x = 3.2; // lokal: 3,2 von der Sonne entfernt
19earth.scale.setScalar(0.45);
20const moonOrbit = new THREE.Group();
21earth.add(moonOrbit); // Mond-Drehpunkt hängt an der Erde
22const moon = new THREE.Mesh(sphere, new THREE.MeshStandardMaterial({ color: 0xcbd5e1 }));
23moon.position.x = 2; // lokal – in Erd-Einheiten!
24moon.scale.setScalar(0.3);
25moonOrbit.add(moon);
26earthOrbit.add(earth);
27scene.add(sun, earthOrbit);
28
29const timer = new THREE.Timer();
30const p = new THREE.Vector3();
31renderer.setAnimationLoop((time) => {
32 timer.update(time);
33 const dt = timer.getDelta();
34 earthOrbit.rotation.y += params.orbitSpeed * dt;
35 moonOrbit.rotation.y += params.moonSpeed * dt;
36 earth.scale.setScalar(0.45 * params.earthScale); // skaliert auch die Mondbahn mit!
37 renderer.render(scene, camera);
38 moon.getWorldPosition(p);
39 report({ "moon.position (lokal)": moon.position.toArray().map((v) => v.toFixed(2)).join("; "), "Mond in Weltkoordinaten": p.toArray().map((v) => v.toFixed(2)).join("; ") });
40});
41
42return { dispose: () => { renderer.setAnimationLoop(null); timer.dispose(); sphere.dispose(); scene.traverse((o) => o.material?.dispose()); renderer.dispose(); } };

5.🔁Render-Loop mit Timer und delta

Für Bewegung wird die Szene immer wieder neu gezeichnet – synchron zur Bildwiederholrate des Monitors. renderer.setAnimationLoop(cb) ist der three.js-Weg für requestAnimationFrame (und nötig für WebXR).

Bildraten schwanken (60, 120, 144 Hz …). Wer pro Bild einen festen Winkel dreht, dreht auf schnellen Monitoren schneller. Richtig ist: Geschwindigkeit × delta (Sekunden seit dem letzten Bild).

  • THREE.Timer ersetzt seit r183 die veraltete THREE.Clock.
  • timer.connect(document) verhindert riesige delta-Werte nach einem Tabwechsel.
  • Links: pro Bild +0,02 rad (bildratenabhängig). Rechts: 1,2 rad/s × delta.
🏖️ Im Apartment-Viewer: Der Apartment-Viewer nutzt denselben Timer: Kamerafahrten zwischen den gespeicherten Ansichten dauern 1,1 s – unabhängig von der Bildrate.
Genau dieser Code läuft links (Regler ändern die params-Zeile live)
1import * as THREE from "three";
2
3const params = { speed: 1.2, fpsLimit: 60 }; // ← die Regler ändern genau dieses Objekt
4
5const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
6renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
7const scene = new THREE.Scene();
8scene.background = new THREE.Color("#0f172a");
9const camera = new THREE.PerspectiveCamera(40, canvas.clientWidth / canvas.clientHeight, 0.1, 100);
10camera.position.z = 6;
11scene.add(new THREE.HemisphereLight(0xffffff, 0x334155, 3));
12const geo = new THREE.BoxGeometry(1.3, 1.3, 1.3);
13const perFrame = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ color: 0xf43f5e }));
14const perSecond = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ color: 0x14b8a6 }));
15perFrame.position.x = -1.4;
16perSecond.position.x = 1.4;
17scene.add(perFrame, perSecond);
18
19const timer = new THREE.Timer();
20timer.connect(document); // Page Visibility API
21let last = 0;
22renderer.setAnimationLoop((time) => {
23 if (time - last < 1000 / params.fpsLimit) return; // Bildrate künstlich drosseln
24 last = time;
25 timer.update(time);
26 const delta = timer.getDelta(); // Sekunden seit dem letzten Bild
27 perFrame.rotation.y += 0.02; // ❌ hängt von der Bildrate ab
28 perSecond.rotation.y += params.speed * delta; // ✅ rad/s × s = rad
29 renderer.render(scene, camera);
30 report({ "delta (s)": delta.toFixed(4), "Bilder/s": (1 / Math.max(delta, 1e-6)).toFixed(0), "elapsed (s)": timer.getElapsed().toFixed(1) });
31});
32
33return { dispose: () => { renderer.setAnimationLoop(null); timer.dispose(); geo.dispose(); perFrame.material.dispose(); perSecond.material.dispose(); renderer.dispose(); } };

6.📥Modelle laden: GLTFLoader

Modelle baut man nicht im Code, man lädt sie. Der GLTFLoader liest .gltf/.glb, erzeugt daraus Meshes, Materialien, Kameras, Animationen und liefert gltf.scene – eine Group, die man in die eigene Szene hängt.

Für große Dateien gibt es Kompression: DRACOLoader (Geometrie, KHR_draco_mesh_compression) und KTX2Loader (GPU-komprimierte Texturen, KHR_texture_basisu). Beide werden dem GLTFLoader nur „eingesteckt“; das Apartment braucht keins von beiden.

  • loadAsync(url, onProgress) liefert ein Promise – ideal mit await.
  • gltf.scene.traverse() besucht jeden Knoten – zum Zählen, Schatten einschalten, Materialien anpassen.
  • Box3().setFromObject() misst das Modell, um die Kamera passend zu setzen.
🏖️ Im Apartment-Viewer: Genau so lädt der Apartment-Viewer /modelle/apartment.glb – mit Fortschrittsbalken aus dem onProgress-Callback.
Genau dieser Code läuft links (Regler ändern die params-Zeile live)
1import * as THREE from "three";
2import { OrbitControls } from "three/addons/controls/OrbitControls.js";
3import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
4
5const params = { wireframe: false }; // ← die Regler ändern genau dieses Objekt
6
7const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
8renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
9const scene = new THREE.Scene();
10scene.background = new THREE.Color("#0f172a");
11scene.add(new THREE.HemisphereLight(0xbcd3e8, 0x3b3a35, 2.5));
12const camera = new THREE.PerspectiveCamera(35, canvas.clientWidth / canvas.clientHeight, 0.1, 2000);
13const controls = new OrbitControls(camera, canvas);
14
15const loader = new GLTFLoader();
16// Nur falls das Modell Draco-komprimiert wäre:
17// const draco = new DRACOLoader(); draco.setDecoderPath("/draco/"); loader.setDRACOLoader(draco);
18const gltf = await loader.loadAsync("/modelle/apartment.glb", (e) => {
19 report({ geladen: Math.round(e.loaded / 1024) + " KiB" });
20});
21const model = gltf.scene;
22scene.add(model);
23
24let meshes = 0, triangles = 0;
25const materials = new Set();
26model.traverse((o) => {
27 if (!o.isMesh) return;
28 meshes++;
29 triangles += o.geometry.index.count / 3;
30 materials.add(o.material);
31});
32
33// Kamera an die Größe des Gebäudes (ohne Gelände) anpassen
34const box = new THREE.Box3();
35for (const name of ["EG_Waende", "OG_Waende"]) box.expandByObject(model.getObjectByName(name));
36const center = box.getCenter(new THREE.Vector3());
37const size = box.getSize(new THREE.Vector3()).length();
38camera.position.copy(center).add(new THREE.Vector3(0.6, 0.7, 0.8).multiplyScalar(size));
39controls.target.copy(center);
40
41function update() {
42 for (const m of materials) m.wireframe = params.wireframe;
43}
44update();
45report({ Meshes: meshes, Dreiecke: triangles, Materialien: materials.size, Animationen: gltf.animations.length });
46
47renderer.setAnimationLoop(() => { controls.update(); renderer.render(scene, camera); });
48
49return { update, dispose: () => {
50 renderer.setAnimationLoop(null);
51 controls.dispose();
52 model.traverse((o) => { o.geometry?.dispose(); o.material?.dispose(); });
53 renderer.dispose();
54} };

7.🕹️OrbitControls

Kamera-Steuerungen sind Add-ons, die Maus- und Touch-Ereignisse in Kamerabewegungen übersetzen. OrbitControls kreist um einen Zielpunkt (controls.target): ziehen = drehen, Mausrad = zoomen, rechte Taste = verschieben.

Mit Damping gleitet die Kamera weich aus – dann muss controls.update() in jedem Bild aufgerufen werden.

  • maxPolarAngle verhindert den Blick unter den Boden.
  • minDistance/maxDistance begrenzen den Zoom.
  • Andere Steuerungen: TrackballControls, FlyControls, PointerLockControls, MapControls.
🏖️ Im Apartment-Viewer: Im Apartment-Viewer: enableDamping, dampingFactor 0,06, maxPolarAngle 0,495·π (knapp über dem Horizont), minDistance 2, maxDistance 220.
Genau dieser Code läuft links (Regler ändern die params-Zeile live)
1import * as THREE from "three";
2import { OrbitControls } from "three/addons/controls/OrbitControls.js";
3
4const params = { damping: true, autoRotate: false, maxPolar: 89, minDistance: 3 }; // ← die Regler ändern genau dieses Objekt
5
6const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
7renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
8const scene = new THREE.Scene();
9scene.background = new THREE.Color("#0f172a");
10const camera = new THREE.PerspectiveCamera(45, canvas.clientWidth / canvas.clientHeight, 0.1, 100);
11camera.position.set(4, 3, 6);
12scene.add(new THREE.GridHelper(10, 10, 0x14b8a6, 0x334155));
13scene.add(new THREE.HemisphereLight(0xffffff, 0x334155, 3));
14const house = new THREE.Mesh(new THREE.BoxGeometry(2, 1.5, 1.5), new THREE.MeshStandardMaterial({ color: 0xf1f5f9 }));
15house.position.y = 0.75;
16const roof = new THREE.Mesh(new THREE.ConeGeometry(1.6, 1, 4), new THREE.MeshStandardMaterial({ color: 0xf43f5e }));
17roof.position.y = 2;
18roof.rotation.y = Math.PI / 4;
19scene.add(house, roof);
20
21const controls = new OrbitControls(camera, canvas);
22controls.target.set(0, 1, 0);
23
24function update() {
25 controls.enableDamping = params.damping;
26 controls.autoRotate = params.autoRotate;
27 controls.maxPolarAngle = params.maxPolar * Math.PI / 180;
28 controls.minDistance = params.minDistance;
29}
30update();
31
32renderer.setAnimationLoop(() => {
33 controls.update(); // Pflicht bei Damping und autoRotate
34 renderer.render(scene, camera);
35 report({ Abstand: camera.position.distanceTo(controls.target).toFixed(2), "Polarwinkel": (controls.getPolarAngle() * 180 / Math.PI).toFixed(1) + "°" });
36});
37
38return { update, dispose: () => { renderer.setAnimationLoop(null); controls.dispose(); scene.traverse((o) => { o.geometry?.dispose(); o.material?.dispose(); }); renderer.dispose(); } };

8.🎯Raycaster: Objekte anklicken

Welches Objekt liegt unter dem Mauszeiger? Der Raycaster schießt einen Strahl von der Kamera durch den Pixel in die Szene und liefert alle getroffenen Objekte, sortiert nach Entfernung.

Dafür wird die Mausposition in NDC umgerechnet (−1 … +1) – dieselben Koordinaten wie in der Rendering-Pipeline, nur rückwärts.

  • setFromCamera(ndc, camera) baut den Strahl.
  • intersectObjects(liste) testet zuerst Bounding-Spheres, dann Dreiecke.
  • Ergebnis: object, point (Weltkoordinaten), distance, face, faceIndex.
🏖️ Im Apartment-Viewer: Klick im Apartment-Viewer auf ein Bauteil: Der Raycaster liefert das Mesh, eine BoxHelper-Box markiert es, Name, Gruppe und Dreiecke erscheinen rechts.
Genau dieser Code läuft links (Regler ändern die params-Zeile live)
1import * as THREE from "three";
2
3const params = { hover: true }; // ← die Regler ändern genau dieses Objekt
4
5const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
6renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
7const scene = new THREE.Scene();
8scene.background = new THREE.Color("#0f172a");
9const camera = new THREE.PerspectiveCamera(45, canvas.clientWidth / canvas.clientHeight, 0.1, 100);
10camera.position.set(0, 4, 7);
11camera.lookAt(0, 0, 0);
12scene.add(new THREE.HemisphereLight(0xffffff, 0x334155, 3));
13
14const geo = new THREE.BoxGeometry(0.8, 0.8, 0.8);
15const boxes = [];
16for (let i = 0; i < 16; i++) {
17 const box = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ color: 0x475569 }));
18 box.position.set((i % 4) * 1.3 - 1.95, 0, Math.floor(i / 4) * 1.3 - 1.95);
19 box.name = "Kiste " + (i + 1);
20 boxes.push(box);
21 scene.add(box);
22}
23
24const raycaster = new THREE.Raycaster();
25const ndc = new THREE.Vector2();
26let selected = null;
27function pick(event, click) {
28 if (!click && !params.hover) return;
29 const r = canvas.getBoundingClientRect();
30 ndc.x = ((event.clientX - r.left) / r.width) * 2 - 1; // Pixel → NDC
31 ndc.y = -((event.clientY - r.top) / r.height) * 2 + 1;
32 raycaster.setFromCamera(ndc, camera);
33 const hit = raycaster.intersectObjects(boxes)[0];
34 if (selected) selected.material.color.set(0x475569);
35 selected = hit ? hit.object : null;
36 if (selected) selected.material.color.set(0x14b8a6);
37 report({ NDC: ndc.x.toFixed(2) + "; " + ndc.y.toFixed(2), Treffer: hit ? hit.object.name : "–", Abstand: hit ? hit.distance.toFixed(2) : "–" });
38}
39const onMove = (e) => pick(e, false);
40const onClick = (e) => pick(e, true);
41canvas.addEventListener("pointermove", onMove);
42canvas.addEventListener("click", onClick);
43
44renderer.setAnimationLoop(() => renderer.render(scene, camera));
45
46return { dispose: () => {
47 renderer.setAnimationLoop(null);
48 canvas.removeEventListener("pointermove", onMove);
49 canvas.removeEventListener("click", onClick);
50 geo.dispose();
51 boxes.forEach((b) => b.material.dispose());
52 renderer.dispose();
53} };

9.📐Resize und devicePixelRatio

Ein Canvas hat zwei Größen: die CSS-Größe (wie groß er auf der Seite erscheint) und die Zeichenpuffer-Größe (wie viele Pixel gerendert werden). Auf Retina-Displays ist devicePixelRatio 2 oder 3 – ohne Anpassung wirkt alles unscharf.

Bei jeder Größenänderung müssen Renderer-Größe und camera.aspect nachgezogen werden – sonst wird das Bild verzerrt.

  • renderer.setPixelRatio(Math.min(devicePixelRatio, 2)) – mehr als 2 kostet viel und bringt wenig.
  • ResizeObserver reagiert auch auf Layoutänderungen, nicht nur auf das Fenster.
  • Stell den Regler auf 0,25: Pixel werden sichtbar, die GPU hat 1/16 der Arbeit.
🏖️ Im Apartment-Viewer: Der Apartment-Viewer begrenzt das Pixelverhältnis auf 2 und hält Renderer und Kamera per ResizeObserver synchron. Schmale Fenster rücken die Kamera zurück, statt den Bildwinkel aufzureißen.
Genau dieser Code läuft links (Regler ändern die params-Zeile live)
1import * as THREE from "three";
2
3const params = { pixelRatio: 1, fixAspect: true }; // ← die Regler ändern genau dieses Objekt
4
5const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
6const scene = new THREE.Scene();
7scene.background = new THREE.Color("#0f172a");
8const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100); // aspect 1 – wird gleich korrigiert
9camera.position.z = 4;
10const knot = new THREE.Mesh(new THREE.TorusKnotGeometry(0.9, 0.3, 200, 32), new THREE.MeshNormalMaterial());
11scene.add(knot);
12
13function resize() {
14 const w = canvas.clientWidth, h = canvas.clientHeight; // CSS-Pixel
15 renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2) * params.pixelRatio);
16 renderer.setSize(w, h, false); // false: CSS-Größe nicht anfassen
17 camera.aspect = params.fixAspect ? w / h : 1;
18 camera.updateProjectionMatrix();
19 report({ "CSS-Größe": w + " × " + h, Zeichenpuffer: canvas.width + " × " + canvas.height, devicePixelRatio: window.devicePixelRatio });
20}
21const observer = new ResizeObserver(resize);
22observer.observe(canvas);
23resize();
24
25renderer.setAnimationLoop((time) => {
26 knot.rotation.set(time / 3000, time / 2000, 0);
27 renderer.render(scene, camera);
28});
29
30return { update: resize, dispose: () => { renderer.setAnimationLoop(null); observer.disconnect(); knot.geometry.dispose(); knot.material.dispose(); renderer.dispose(); } };

10.🧹Speicher freigeben: dispose()

JavaScript räumt Objekte selbst auf – GPU-Ressourcen aber nicht. Geometrien (Vertex-Puffer), Materialien (Shader-Programme) und Texturen belegen Grafikspeicher, bis man dispose() aufruft. scene.remove(mesh) allein genügt nicht.

renderer.info.memory zählt, wie viele Geometrien und Texturen gerade auf der GPU liegen – ein einfacher Leck-Detektor.

  • geometry.dispose(), material.dispose(), texture.dispose(), renderer.dispose().
  • Bei React/Next.js: im Cleanup von useEffect aufräumen (sonst Lecks bei jedem Seitenwechsel).
  • Probier: dispose aus, dann mehrfach „neu erzeugen“ → der Zähler wächst.
🏖️ Im Apartment-Viewer: Beim Verlassen der Ferienwohnungs-Seite gibt der Viewer im useEffect-Cleanup alle 712 Geometrien, die Materialien, die Umgebungstextur und den Renderer frei.
Genau dieser Code läuft links (Regler ändern die params-Zeile live)
1import * as THREE from "three";
2
3const params = { count: 100, callDispose: true, generation: 0 }; // ← die Regler ändern genau dieses Objekt
4
5const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
6renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
7const scene = new THREE.Scene();
8scene.background = new THREE.Color("#0f172a");
9const camera = new THREE.PerspectiveCamera(50, canvas.clientWidth / canvas.clientHeight, 0.1, 100);
10camera.position.set(0, 0, 9);
11scene.add(new THREE.HemisphereLight(0xffffff, 0x334155, 3));
12const group = new THREE.Group();
13scene.add(group);
14
15function update() {
16 for (const m of [...group.children]) {
17 group.remove(m); // nur aus der Szene entfernt …
18 if (params.callDispose) {
19 m.geometry.dispose(); // … erst das gibt den GPU-Puffer frei
20 m.material.dispose();
21 }
22 }
23 for (let i = 0; i < params.count; i++) {
24 // absichtlich je Kugel eine eigene Geometrie (sonst würde three.js sie teilen)
25 const m = new THREE.Mesh(new THREE.SphereGeometry(0.18, 16, 8), new THREE.MeshStandardMaterial({ color: new THREE.Color().setHSL(i / params.count, 0.6, 0.55) }));
26 m.position.set(Math.random() * 8 - 4, Math.random() * 5 - 2.5, Math.random() * 4 - 2);
27 group.add(m);
28 }
29}
30update();
31
32renderer.setAnimationLoop((time) => {
33 group.rotation.y = time / 4000;
34 renderer.render(scene, camera);
35 report({ Durchgang: params.generation, "Geometrien auf der GPU": renderer.info.memory.geometries, "Objekte in der Szene": group.children.length, "Draw Calls": renderer.info.render.calls });
36});
37
38return { update, dispose: () => {
39 renderer.setAnimationLoop(null);
40 group.traverse((o) => { o.geometry?.dispose(); o.material?.dispose(); });
41 renderer.dispose();
42} };

11.🚀Performance: InstancedMesh und Draw Calls

Tausend gleiche Objekte als tausend Meshes bedeuten tausend Draw Calls – die CPU wird zum Flaschenhals, lange bevor die GPU ausgelastet ist.

InstancedMesh zeichnet dieselbe Geometrie mit demselben Material beliebig oft in einem Draw Call; jede Instanz bekommt nur ihre eigene Matrix (und optional Farbe).

  • setMatrixAt(i, matrix) + instanceMatrix.needsUpdate = true.
  • Weitere Hebel: BatchedMesh, mergeGeometries, LOD, Frustum-Culling.
  • Achte auf die FPS bei 5000 Objekten – einzeln vs. instanziert.
🏖️ Im Apartment-Viewer: Das Apartment zeichnet 712 einzelne Meshes – für eine Szene dieser Größe unkritisch. Bei vielen gleichen Objekten (z. B. Terrassendielen oder Bäumen) wäre InstancedMesh der nächste Schritt.
Genau dieser Code läuft links (Regler ändern die params-Zeile live)
1import * as THREE from "three";
2
3const params = { count: 2000, instanced: true }; // ← die Regler ändern genau dieses Objekt
4
5const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
6renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
7const scene = new THREE.Scene();
8scene.background = new THREE.Color("#0f172a");
9const camera = new THREE.PerspectiveCamera(50, canvas.clientWidth / canvas.clientHeight, 0.1, 200);
10camera.position.set(0, 0, 38);
11scene.add(new THREE.HemisphereLight(0xffffff, 0x334155, 3));
12const geometry = new THREE.BoxGeometry(0.5, 0.5, 0.5);
13const material = new THREE.MeshStandardMaterial({ color: 0xffffff });
14const root = new THREE.Group();
15scene.add(root);
16const matrix = new THREE.Matrix4(), color = new THREE.Color();
17
18function position(i) { // Punkte auf einer Kugelschale (Fibonacci)
19 const y = 1 - (i / (params.count - 1)) * 2, r = Math.sqrt(1 - y * y), phi = i * 2.39996;
20 return new THREE.Vector3(Math.cos(phi) * r, y, Math.sin(phi) * r).multiplyScalar(14);
21}
22
23function update() {
24 for (const o of root.children) { // Vorgänger freigeben (siehe Lektion dispose)
25 if (o.isInstancedMesh) o.dispose();
26 else o.material.dispose();
27 }
28 root.clear();
29 if (params.instanced) {
30 const mesh = new THREE.InstancedMesh(geometry, material, params.count);
31 for (let i = 0; i < params.count; i++) {
32 matrix.setPosition(position(i));
33 mesh.setMatrixAt(i, matrix); // nur eine Matrix pro Instanz
34 mesh.setColorAt(i, color.setHSL(i / params.count, 0.7, 0.55));
35 }
36 mesh.instanceMatrix.needsUpdate = true;
37 root.add(mesh);
38 } else {
39 for (let i = 0; i < params.count; i++) {
40 const m = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial({ color: color.setHSL(i / params.count, 0.7, 0.55) }));
41 m.position.copy(position(i));
42 root.add(m); // jedes Mesh = eigener Draw Call
43 }
44 }
45}
46update();
47
48const timer = new THREE.Timer();
49let frames = 0, acc = 0, fps = 0;
50renderer.setAnimationLoop((time) => {
51 timer.update(time);
52 const dt = timer.getDelta();
53 root.rotation.y += dt * 0.3;
54 renderer.render(scene, camera);
55 frames++; acc += dt;
56 if (acc > 0.5) { fps = frames / acc; frames = 0; acc = 0; }
57 report({ "Draw Calls": renderer.info.render.calls, Dreiecke: renderer.info.render.triangles, FPS: fps.toFixed(0) });
58});
59
60return { update, dispose: () => {
61 renderer.setAnimationLoop(null);
62 timer.dispose();
63 root.traverse((o) => { if (o.material && o.material !== material) o.material.dispose(); });
64 geometry.dispose(); material.dispose(); renderer.dispose();
65} };

🏖️ So ist der Apartment-Viewer aufgebaut

Der Viewer auf der Seite Ferienwohnung ist nichts anderes als die Summe der Lektionen oben – angelehnt an den Viewer der Original-3D-Ansicht. Quelltext: src/components/ApartmentViewer.tsx, geladen pernext/dynamic mit ssr: false.

BausteinVerwendung im ViewerLektion
WebGLRendererantialias, setPixelRatio(min(dpr, 2)), ACESFilmicToneMapping, shadowMap (PCF)🎬 Scene · Camera · Renderer
Scene + PerspectiveCameraHintergrund, fov aus der Blender-Brennweite, near 0,1 / far 2000🎬 Scene · Camera · Renderer
RoomEnvironment + PMREMGeneratorvorgefilterte Umgebung für PBR-Reflexionen (scene.environment)🎨 Materialtypen
HemisphereLight + DirectionalLightHimmel/Boden + Sonne aus Nordosten, Shadow-Map 2048²💡 Lichter und Schatten
GLTFLoaderlädt /modelle/apartment.glb, Fortschritt aus onProgress📥 Modelle laden: GLTFLoader
traverse()Schatten an, Materialien einseitig, Meshes und Originalmaterialien merken📥 Modelle laden: GLTFLoader
Gruppenknoten9 Knoten aus apartment.json: ein/aus, Explosion = position der OG-Knoten🌳 Object3D und der Szenengraph
OrbitControlsDamping, maxPolarAngle 0,495·π, Zoom 2 … 220 m🕹️ OrbitControls
Timer + requestAnimationFrameKamerafahrten mit delta, Easing über 1,1 s🔁 Render-Loop mit Timer und delta
RaycasterKlick → Mesh, BoxHelper, Infos zum Bauteil🎯 Raycaster: Objekte anklicken
ResizeObserverRenderer-Größe und camera.aspect nachführen📐 Resize und devicePixelRatio
useEffect-Cleanupdispose() für Geometrien, Materialien, Umgebung, Renderer🧹 Speicher freigeben: dispose()
renderer.infoDraw Calls, Dreiecke, Geometrien, Programme live🚀 Performance: InstancedMesh und Draw Calls
Seite /ferienwohnung (Server)            ApartmentViewer (nur Browser)
+-----------------------------+          +----------------------------------------------+
| Kennzahlen aus apartment.glb|          | WebGLRenderer -> Scene -+- HemisphereLight   |
| (beim Build geparst)        |  dynamic |                         +- DirectionalLight  |
| ApartmentViewerLoader ------┼--------->| PerspectiveCamera       +- gltf.scene        |
|   ssr: false                |  import  |   ^ OrbitControls       |   +- EG_Boeden …    |
+-----------------------------+          |   | Timer (Kamerafahrt) |   +- Aussenanlagen  |
                                         | Raycaster -> BoxHelper  +- Helfer (Boxen,    |
                                         | renderer.info -> React-State   Normalen)     |
                                         +----------------------------------------------+
🏖️ Zum Apartment-Viewer