From 2ba564d7498db22e5863ba68e34f73e0fddea14e Mon Sep 17 00:00:00 2001 From: kelle Date: Tue, 15 Sep 2026 17:16:21 +0200 Subject: [PATCH] Fotoansicht fluessig: statische Schatten, weniger Laub, adaptive Aufloesung Die Fotoansicht lief weder auf dem Handy noch auf dem Laptop rund. Pro Bild braucht sie jetzt rund ein Fuenftel der Rechenzeit (gemessen relativ im Software-Renderer), die stilisierte rund ein Viertel. - Schattenkarten nur noch bei Sonnenstand-, Ansichts- oder Torwechsel neu (shadowMap.autoUpdate = false). Gilt fuer beide Ansichten. - GTAO und Bloom entfernt: GTAO hat die Szene ein zweites Mal gezeichnet. - Gras in Felder von 3 x 3 m, nur bis 22 m gezeichnet, Dichte halbiert. - Blattkarten mit Lambert statt physikalischer Beleuchtung, Eigenleuchten aus der Blatttextur als Himmelsfuellung, das dem Tageslicht folgt. Etwa ein Drittel so viele Karten, dafuer etwas groesser. - Anpassungsfaehige Pixeldichte: unter rund 40 Bildern/s sinkt sie schrittweise bis 0.55, bei Reserve steigt sie wieder. - Ohne Bewegung wird nach knapp 2 s nicht mehr gezeichnet. - Umgebungskarte nur bei merklicher Sonnenstandsaenderung neu. - Auf Geraeten ohne Maus: 55 % der Blaetter und Halme, Travertin 1024 px, Schatten 2048. Co-Authored-By: Claude Opus 5 --- README.md | 20 +++++++- src/main.ts | 102 ++++++++++++++++++++++++++++++++++----- src/scene/environment.ts | 21 ++++++-- src/scene/gras.ts | 91 +++++++++++++++++++++++----------- src/scene/laub.ts | 31 +++++++++--- src/scene/post.ts | 67 ------------------------- src/scene/quality.ts | 14 ++++++ src/scene/textures.ts | 4 +- src/scene/vegetation.ts | 16 +++--- 9 files changed, 238 insertions(+), 128 deletions(-) delete mode 100644 src/scene/post.ts diff --git a/README.md b/README.md index f0c769c..9a6594b 100644 --- a/README.md +++ b/README.md @@ -226,9 +226,27 @@ Was die fotorealistische Variante anders macht: | Bäume | Rekursiv gewachsene, gekrümmte Äste; Blattwolken an den Zweigspitzen | | Rasen | Grasnarbe aus Büscheln einzelner Halme, die im Wind wiegen (`gras.ts`) | | Himmel | Physikalisches Streumodell (`Sky`), daraus eine Umgebungskarte für Himmelslicht und Spiegelungen. In der Dämmerung wird der Verlaufshimmel eingeblendet, weil das Streumodell kein Blau der blauen Stunde kennt | -| Nachbearbeitung | Umgebungsverdeckung (GTAO), leichtes Überstrahlen der Lampen, Mehrfachabtastung (`post.ts`). Blattkarten und Halme werden für die Verdeckung ausgeblendet – das Ersatzmaterial kennt keinen Alpha-Test und würde sie als volle Vierecke abdunkeln | | Wiese | Kachel in zwei Massstäben gemischt und grossflächig variiert, damit das Wiederholungsmuster verschwindet | +### Damit es flüssig läuft + +Die erste Fassung der Fotoansicht lief weder auf dem Handy noch auf dem Laptop +rund. Gemessen (Software-Renderer, relativ) war sie pro Bild rund 3,5-mal so +teuer wie die stilisierte – ohne die Nachbearbeitung. Was geändert wurde: + +| Massnahme | Wirkung | +| --- | --- | +| **Statische Schatten**: Schattenkarten werden nur neu gerechnet, wenn die Sonne wandert, die Ansicht wechselt oder das Tor schwingt () | beide Ansichten rund 4-mal schneller | +| **Keine Nachbearbeitung** mehr (GTAO und Bloom): GTAO hat die ganze Szene ein zweites Mal gezeichnet | grösster Einzelposten der alten Fassung | +| **Gras in Felder von 3 × 3 m** aufgeteilt, nur bis 22 m Entfernung gezeichnet, Dichte halbiert | Gras fällt kaum mehr ins Gewicht | +| **Blattkarten mit Lambert** statt physikalischer Beleuchtung, Eigenleuchten als Himmelsfüllung; rund ein Drittel so viele Karten | Laub rund 30 % günstiger | +| **Anpassungsfähige Auflösung**: fällt die Bildrate unter rund 40 Bilder/s, sinkt die Pixeldichte schrittweise (bis 0,55), bei Reserve steigt sie wieder | hält jedes Gerät flüssig | +| **Ruhe, wenn nichts passiert**: ohne Bewegung wird nach knapp 2 s nicht mehr gezeichnet | Handys werden nicht warm und drosseln nicht | +| **Handy** (Zeiger ohne Maus): 55 % der Blätter und Halme, Travertin 1024 statt 2048 px, Schatten 2048 statt 4096 | | + +Insgesamt braucht ein Bild im Fotomodus jetzt rund ein Fünftel der +ursprünglichen Rechenzeit. + ### Rundgang Bewusst einfach: **auf den Boden klicken** (am Handy tippen), dann läuft man diff --git a/src/main.ts b/src/main.ts index 0d9c24f..6c14c88 100644 --- a/src/main.ts +++ b/src/main.ts @@ -21,9 +21,8 @@ import { buildBuilding, buildNeighbourBuildings } from './scene/building'; import { buildEnvironment } from './scene/environment'; import { Walker, isTouch, obstacleFromSegment, type Obstacle } from './controls/walker'; import { setupHud } from './ui/hud'; -import { setQuality, type Quality } from './scene/quality'; -import { buildPost, type Post } from './scene/post'; -import { wind } from './scene/gras'; +import { leichtesGeraet, setQuality, type Quality } from './scene/quality'; +import { grasSichtweite, wind } from './scene/gras'; import { ladeFotomaterialien } from './scene/fotomaterial'; const CENTER: [number, number] = [-9.5, -1.0]; @@ -46,10 +45,37 @@ async function main() { renderer.toneMappingExposure = 0.94; renderer.shadowMap.enabled = true; renderer.shadowMap.type = PCFSoftShadowMap; + // Schatten sind statisch: sie werden nur neu gerechnet, wenn die Sonne + // wandert, die Darstellung wechselt oder das Tor schwingt - nicht in jedem + // Bild. Das halbiert im Fotomodus die Arbeit pro Bild. + renderer.shadowMap.autoUpdate = false; + renderer.shadowMap.needsUpdate = true; const scene = new Scene(); const camera = new PerspectiveCamera(62, 1, 0.08, 1400); + /** + * Gezeichnet wird nur, solange sich etwas tut: Kamera, Tageszeit, Tor, + * Umschalten. Danach laeuft die Schleife noch kurz weiter (Wind, Ausklingen) + * und ruht dann. Ein ruhendes Bild kostet so keine Rechenzeit - Handys + * werden nicht warm und drosseln nicht, wenn man wieder losgeht. + */ + let wachBis = 0; + function wecken(ms = 1800) { wachBis = Math.max(wachBis, performance.now() + ms); } + const letzteKamera = new Float32Array(16); + function kameraBewegt(): boolean { + const e = camera.matrixWorld.elements; + let anders = false; + for (let i = 0; i < 16; i++) { + if (Math.abs(e[i] - letzteKamera[i]) > 1e-5) { anders = true; letzteKamera[i] = e[i]; } + } + return anders; + } + window.addEventListener('resize', () => wecken()); + canvas.addEventListener('pointerdown', () => wecken()); + window.addEventListener('keydown', () => wecken()); + document.getElementById('hud')!.addEventListener('input', () => wecken()); + await progress(8, 'Gartenplan wird geladen …'); const site: SiteData = await loadSite(); const height = makeHeightSampler(site.terrain); @@ -334,18 +360,50 @@ async function main() { }, { passive: false }); // -- Umschalten zwischen den Darstellungen -------------------------------- - let post: Post | null = null; const loader = document.getElementById('loader')!; + /** + * Aufloesung passt sich der Leistung an: laeuft es nicht fluessig, wird + * die Pixeldichte schrittweise gesenkt, bis wieder rund 45 Bilder pro + * Sekunde erreicht sind; bei Reserve wird sie wieder angehoben. + */ + const aufloesung = { min: 0.6, max: 1, wert: 1, summe: 0, bilder: 0 }; + function aufloesungFuer(q: Quality) { + const foto = q === 'foto'; + const dpr = devicePixelRatio || 1; + aufloesung.max = Math.min(dpr, foto ? (leichtesGeraet() ? 1.1 : 1.5) : 2); + aufloesung.min = foto ? 0.55 : 0.75; + aufloesung.wert = Math.min(aufloesung.max, foto ? 1 : dpr); + aufloesung.summe = 0; + aufloesung.bilder = 0; + renderer.setPixelRatio(aufloesung.wert); + } + function aufloesungMessen(dt: number) { + aufloesung.summe += dt; + if (++aufloesung.bilder < 40) return; + const mittel = aufloesung.summe / aufloesung.bilder; + aufloesung.summe = 0; + aufloesung.bilder = 0; + let neu = aufloesung.wert; + if (mittel > 1 / 40) neu = Math.max(aufloesung.min, aufloesung.wert * 0.85); + else if (mittel < 1 / 56) neu = Math.min(aufloesung.max, aufloesung.wert * 1.08); + if (Math.abs(neu - aufloesung.wert) > 0.02) { + aufloesung.wert = neu; + renderer.setPixelRatio(neu); + resize(); + } + } + function darstellung(q: Quality) { const foto = q === 'foto'; env.setQuality(q); renderer.toneMapping = ACESFilmicToneMapping; renderer.toneMappingExposure = foto ? 0.9 : 0.94; - renderer.setPixelRatio(Math.min(devicePixelRatio, foto ? 1.5 : 2)); - if (foto && !post) post = buildPost(renderer, scene, camera, () => welten.foto?.laub ?? []); + aufloesungFuer(q); resize(); welt().lamps.setOn(env.isDark()); + renderer.shadowMap.needsUpdate = true; + wecken(); } async function setAnsicht(q: Quality) { @@ -398,7 +456,6 @@ async function main() { const w = canvas.clientWidth || window.innerWidth; const h = canvas.clientHeight || window.innerHeight; renderer.setSize(w, h, false); - post?.setSize(w, h); camera.aspect = w / h; camera.updateProjectionMatrix(); } @@ -409,6 +466,7 @@ async function main() { let last = performance.now(); let lastSun = { hours: -1, day: -1 }; + function frame(now: number) { const dt = Math.min(0.05, (now - last) / 1000); last = now; @@ -419,6 +477,8 @@ async function main() { welt().lamps.setOn(env.isDark()); hud.setSunReadout(sun); lastSun = t; + renderer.shadowMap.needsUpdate = true; + wecken(); } if (orbit.active) { @@ -437,14 +497,27 @@ async function main() { if (d < 0.9) auf = true; const ziel = auf ? tor.offen : tor.zu; const schritt = 2.4 * dt; + const vorher = tor.winkel; tor.winkel += Math.max(-schritt, Math.min(schritt, ziel - tor.winkel)); - for (const f of tor.fluegel) f.rotation.y = tor.winkel; + if (tor.winkel !== vorher) { + for (const f of tor.fluegel) f.rotation.y = tor.winkel; + renderer.shadowMap.needsUpdate = true; + wecken(); + } tor.obstacle.disabled = tor.winkel - tor.zu > 0.6; } - wind.value = now / 1000; - if (ansicht === 'foto' && post) post.render(); - else renderer.render(scene, camera); + camera.updateMatrixWorld(); + if (kameraBewegt()) wecken(); + if (now < wachBis) { + wind.value = now / 1000; + if (ansicht === 'foto') grasSichtweite(camera.position.x, camera.position.z); + renderer.render(scene, camera); + aufloesungMessen(dt); + } else { + aufloesung.summe = 0; + aufloesung.bilder = 0; + } requestAnimationFrame(frame); } @@ -459,7 +532,12 @@ async function main() { scene, camera, site, env, walker, orbit, hud, renderer, Group, setOverview(on: boolean) { hud.setMode(on ? 'overview' : 'walk'); }, setAnsicht, - get post() { return post; }, + /** ein Bild so zeichnen wie die Schleife - fuer Messungen */ + renderFrame() { + if (ansicht === 'foto') grasSichtweite(camera.position.x, camera.position.z); + renderer.render(scene, camera); + }, + aufloesung, }; } diff --git a/src/scene/environment.ts b/src/scene/environment.ts index 8cbb464..a20758f 100644 --- a/src/scene/environment.ts +++ b/src/scene/environment.ts @@ -11,7 +11,8 @@ import { type WebGLRenderer, type WebGLRenderTarget, } from 'three'; import { Sky } from 'three/examples/jsm/objects/Sky.js'; -import type { Quality } from './quality'; +import { setzeLaubFuellung } from './laub'; +import { leichtesGeraet, type Quality } from './quality'; export interface SunState { /** Hoehenwinkel in Grad, negativ wenn die Sonne unter dem Horizont steht. */ @@ -206,6 +207,7 @@ export function buildEnvironment(scene: Scene, renderer: WebGLRenderer, opts: { envScene.add(envBoden); const pmrem = new PMREMGenerator(renderer); let envTarget: WebGLRenderTarget | null = null; + let envStand = { alt: -99, az: -999 }; let quality: Quality = 'stil'; let last = { day: 172, hours: 14 }; @@ -257,14 +259,23 @@ export function buildEnvironment(scene: Scene, renderer: WebGLRenderer, opts: { // und hellem Travertin. Zu dunkel gewaehlt, liegen Decken, Vordaecher und // Unterseiten im Fotomodus fast schwarz und blaustichig im Schatten. (envBoden.material as ShaderMaterial).uniforms.uTone.value.setRGB(0.30, 0.28, 0.21).multiplyScalar(0.15 + 0.85 * dusk); - envTarget?.dispose(); - envTarget = pmrem.fromScene(envScene, 0.02, 0.1, 200); + // Die Umgebungskarte zu rechnen kostet spuerbar. Beim Ziehen am + // Tageszeitregler wird sie nur neu erstellt, wenn sich der Sonnenstand + // merklich geaendert hat. + if (!envTarget || Math.abs(s.altitude - envStand.alt) > 1.2 || Math.abs(s.azimuth - envStand.az) > 4) { + envTarget?.dispose(); + envTarget = pmrem.fromScene(envScene, 0.02, 0.1, 200); + envStand = { alt: s.altitude, az: s.azimuth }; + } scene.environment = envTarget.texture; // Das Himmelslicht kommt jetzt aus der Umgebungskarte - die pauschalen // Fuellichter werden stark zurueckgenommen. // Sonne zu Himmel etwa 5:1, wie an einem klaren Tag - sonst verschwinden // die Schatten im Himmelslicht. scene.environmentIntensity = 0.05 + 0.30 * dusk; + // Die Fuellung der Blattschattenseiten folgt der Himmelshelligkeit, nicht + // der Daemmerung - sonst leuchten die Kronen nach Sonnenuntergang. + setzeLaubFuellung(Math.min(1, day * 1.6)); hemi.intensity = 0.03 + 0.08 * dusk; ambient.intensity = 0.01; sun.intensity = 3.6 * day; @@ -289,9 +300,11 @@ export function buildEnvironment(scene: Scene, renderer: WebGLRenderer, opts: { if (q === 'stil') { scene.environment = null; scene.environmentIntensity = 1; + } else { + envStand = { alt: -99, az: -999 }; } // Feinere Schatten im Fotomodus - const size = q === 'foto' ? 4096 : 2048; + const size = q === 'foto' && !leichtesGeraet() ? 4096 : 2048; if (sun.shadow.mapSize.x !== size) { sun.shadow.mapSize.set(size, size); sun.shadow.map?.dispose(); diff --git a/src/scene/gras.ts b/src/scene/gras.ts index d9c73f8..390219b 100644 --- a/src/scene/gras.ts +++ b/src/scene/gras.ts @@ -6,10 +6,31 @@ * Vertex-Shader laesst die Spitzen leicht im Wind wiegen. */ import { - BufferAttribute, BufferGeometry, Color, DoubleSide, InstancedMesh, Matrix4, + BufferAttribute, BufferGeometry, Color, DoubleSide, Group, InstancedMesh, Matrix4, MeshStandardMaterial, Quaternion, Vector3, } from 'three'; import type { Vec2 } from '../site'; +import { dichte } from './quality'; + +/** + * Halme werden in Felder von FELD x FELD Metern aufgeteilt. Jedes Feld ist + * eine eigene InstancedMesh: was nicht im Bild ist, faellt aus, und Felder in + * groesserer Entfernung blendet `grasSichtweite` ganz aus - dort sind die + * Halme ohnehin kleiner als ein Pixel, die Rasentextur darunter genuegt. + */ +const FELD = 3; +export const GRAS_SICHTWEITE = 22; +const felder: InstancedMesh[] = []; + +/** Blendet Grasfelder je nach Abstand zur Kamera ein oder aus. */ +export function grasSichtweite(cx: number, cz: number) { + const r2 = GRAS_SICHTWEITE * GRAS_SICHTWEITE; + for (const f of felder) { + const c = f.userData.mitte as Vector3; + const dx = c.x - cx, dz = c.z - cz; + f.visible = dx * dx + dz * dz < r2; + } +} /** Laufzeit in Sekunden, wird pro Bild gesetzt. */ export const wind = { value: 0 }; @@ -85,27 +106,33 @@ export interface GrasArt { breite?: number; } -export const RASEN: GrasArt = { dichte: 420, hoehe: [0.05, 0.10], farbe: 0x597f35, streuung: 0.22 }; -export const SCHOTTERRASEN: GrasArt = { dichte: 70, hoehe: [0.08, 0.22], farbe: 0x7f9146, streuung: 0.22, breite: 1.6 }; +// Dichte bewusst massvoll: jedes Bueschel sind 20 Dreiecke. Die Rasentextur +// darunter fuellt die Luecken, die Halme geben Tiefe und Bewegung. +export const RASEN: GrasArt = { dichte: 240, hoehe: [0.06, 0.11], farbe: 0x597f35, streuung: 0.22, breite: 1.25 }; +export const SCHOTTERRASEN: GrasArt = { dichte: 55, hoehe: [0.08, 0.22], farbe: 0x7f9146, streuung: 0.22, breite: 1.6 }; -/** Streut Grasbueschel auf die Flaechen. */ +/** Streut Grasbueschel auf die Flaechen, aufgeteilt in Felder. */ export function grasnarbe( flaechen: Vec2[][], height: (x: number, z: number) => number, art: GrasArt, seed: number, name: string, -): InstancedMesh | null { +): Group | null { const r = rng(seed); - const spacing = 1 / Math.sqrt(art.dichte); - const items: { x: number; z: number }[] = []; + const spacing = 1 / Math.sqrt(art.dichte * dichte()); + const proFeld = new Map(); for (const poly of flaechen) { let x0 = Infinity, z0 = Infinity, x1 = -Infinity, z1 = -Infinity; for (const [x, z] of poly) { x0 = Math.min(x0, x); x1 = Math.max(x1, x); z0 = Math.min(z0, z); z1 = Math.max(z1, z); } for (let z = z0; z < z1; z += spacing) { for (let x = x0; x < x1; x += spacing) { const jx = x + (r() - 0.5) * spacing, jz = z + (r() - 0.5) * spacing; - if (inPoly(jx, jz, poly)) items.push({ x: jx, z: jz }); + if (!inPoly(jx, jz, poly)) continue; + const key = `${Math.floor(jx / FELD)}:${Math.floor(jz / FELD)}`; + const liste = proFeld.get(key) ?? []; + liste.push({ x: jx, z: jz }); + proFeld.set(key, liste); } } } - if (!items.length) return null; + if (!proFeld.size) return null; const geo = bueschel(seed); const mat = new MeshStandardMaterial({ color: 0xffffff, vertexColors: true, roughness: 0.9, metalness: 0, side: DoubleSide, @@ -123,25 +150,35 @@ export function grasnarbe( transformed.z += cos(uWind * 1.2 + ip.x * 0.5) * 0.05 * h2; #endif`); }; - const mesh = new InstancedMesh(geo, mat, items.length); + const gruppe = new Group(); + gruppe.name = name; const m = new Matrix4(), q = new Quaternion(), s = new Vector3(), p = new Vector3(); const up = new Vector3(0, 1, 0); const base = new Color(art.farbe), c = new Color(); - items.forEach((it, i) => { - const h = art.hoehe[0] + (art.hoehe[1] - art.hoehe[0]) * r(); - q.setFromAxisAngle(up, r() * Math.PI * 2); - const b = art.breite ?? 1; - s.set(h * b, h, h * b); - p.set(it.x, height(it.x, it.z) + 0.004, it.z); - m.compose(p, q, s); - mesh.setMatrixAt(i, m); - c.copy(base).offsetHSL((r() - 0.5) * 0.04, (r() - 0.5) * art.streuung, (r() - 0.5) * art.streuung * 0.8); - mesh.setColorAt(i, c); - }); - mesh.instanceMatrix.needsUpdate = true; - if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; - mesh.receiveShadow = true; - mesh.name = name; - mesh.userData.laub = true; - return mesh; + for (const items of proFeld.values()) { + const mesh = new InstancedMesh(geo, mat, items.length); + const mitte = new Vector3(); + items.forEach((it, i) => { + const h = art.hoehe[0] + (art.hoehe[1] - art.hoehe[0]) * r(); + q.setFromAxisAngle(up, r() * Math.PI * 2); + const b = art.breite ?? 1; + s.set(h * b, h, h * b); + p.set(it.x, height(it.x, it.z) + 0.004, it.z); + m.compose(p, q, s); + mesh.setMatrixAt(i, m); + c.copy(base).offsetHSL((r() - 0.5) * 0.04, (r() - 0.5) * art.streuung, (r() - 0.5) * art.streuung * 0.8); + mesh.setColorAt(i, c); + mitte.add(p); + }); + mesh.instanceMatrix.needsUpdate = true; + if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; + // Grenzkugel des Feldes, damit ausserhalb des Bildes nichts gezeichnet wird + mesh.computeBoundingSphere(); + mesh.receiveShadow = true; + mesh.userData.laub = true; + mesh.userData.mitte = mitte.divideScalar(items.length); + felder.push(mesh); + gruppe.add(mesh); + } + return gruppe; } diff --git a/src/scene/laub.ts b/src/scene/laub.ts index 5cf933b..43bcc85 100644 --- a/src/scene/laub.ts +++ b/src/scene/laub.ts @@ -14,10 +14,11 @@ import { BufferAttribute, BufferGeometry, CanvasTexture, Color, CylinderGeometry, DoubleSide, Group, InstancedBufferAttribute, InstancedMesh, LinearMipmapLinearFilter, Matrix4, Mesh, - MeshDepthMaterial, MeshStandardMaterial, PlaneGeometry, Quaternion, RGBADepthPacking, + MeshDepthMaterial, MeshLambertMaterial, MeshStandardMaterial, PlaneGeometry, Quaternion, RGBADepthPacking, RepeatWrapping, SRGBColorSpace, ShaderChunk, Vector3, } from 'three'; import type { Tree } from '../site'; +import { dichte } from './quality'; export type Blattart = 'baum' | 'apfel' | 'eibe' | 'liguster' | 'strauch' | 'kraut'; @@ -158,15 +159,31 @@ function blattTextur(art: Blattart): CanvasTexture { // Material mit Volumen-Normalen // --------------------------------------------------------------------------- -const matCache = new Map(); +const matCache = new Map(); + +/** + * Blattkarten sind der teuerste Teil der Fotoansicht: zehntausende sich + * ueberlagernde Vierecke. Sie werden deshalb mit dem einfachen Lambert-Modell + * beleuchtet statt physikalisch - Blaetter glaenzen kaum, der Unterschied ist + * gering, die Rechenzeit pro Pixel aber ein Bruchteil. Weil Lambert das + * Himmelslicht der Umgebungskarte nicht kennt, fuellt ein Eigenleuchten aus + * der Blatttextur die Schattenseite auf; es folgt dem Tageslicht + * (`setzeLaubFuellung`). + */ +const FUELL = new Color(0.34, 0.38, 0.42); +export function setzeLaubFuellung(tag: number) { + for (const { mat } of matCache.values()) mat.emissiveIntensity = 0.04 + tag * 0.96; + laubFuellung = tag; +} +let laubFuellung = 1; function laubMaterial(art: Blattart) { const hit = matCache.get(art); if (hit) return hit; const map = blattTextur(art); - const mat = new MeshStandardMaterial({ - map, alphaTest: 0.45, side: DoubleSide, roughness: art === 'liguster' ? 0.62 : 0.78, - metalness: 0, + const mat = new MeshLambertMaterial({ + map, alphaTest: 0.45, side: DoubleSide, + emissive: FUELL, emissiveMap: map, emissiveIntensity: 0.04 + laubFuellung * 0.96, }); mat.onBeforeCompile = (sh) => { sh.vertexShader = sh.vertexShader @@ -427,7 +444,7 @@ export function fotoBaum(t: Tree, y: number): Group { for (const w of wolken) { cx += w.c.x; cy += w.c.y; cz += w.c.z; } const mitte = new Vector3(cx / wolken.length, cy / wolken.length - crownR * 0.25, cz / wolken.length); const punkte: Blattpunkt[] = []; - const proWolke = Math.round(Math.min(220, 5200 / wolken.length) * (crownR / 1.4)); + const proWolke = Math.round(Math.min(120, 2400 / wolken.length) * (crownR / 1.4) * dichte()); for (const w of wolken) { for (let i = 0; i < proWolke; i++) { const d = new Vector3(r() * 2 - 1, r() * 2 - 1, r() * 2 - 1); @@ -437,7 +454,7 @@ export function fotoBaum(t: Tree, y: number): Group { const rad = w.r * (0.3 + 0.7 * Math.pow(r(), 0.45)); const p = w.c.clone().addScaledVector(d, rad); p.y = Math.min(p.y, top); - punkte.push({ p, n: p.clone().sub(mitte).normalize(), c: mitte, s: 0.3 }); + punkte.push({ p, n: p.clone().sub(mitte).normalize(), c: mitte, s: 0.36 }); } } g.add(blattkarten(punkte, t.species === 'apfel' ? 'apfel' : 'baum', Math.round(t.x * 13 + t.z * 7), `${t.name} – Laub`)); diff --git a/src/scene/post.ts b/src/scene/post.ts deleted file mode 100644 index 64ae7df..0000000 --- a/src/scene/post.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Nachbearbeitung fuer die fotorealistische Darstellung: - * - GTAO: Umgebungsverdeckung - Ecken, Fugen und der Boden unter Pflanzen - * und Mauern werden dunkler, wie bei indirektem Licht in der Wirklichkeit. - * - Bloom: Lampen und die Sonnenscheibe strahlen leicht ueber. - * - Mehrfach-Abtastung gegen Treppchenkanten. - */ -import { - HalfFloatType, Vector2, WebGLRenderTarget, - type Camera, type Object3D, type Scene, type WebGLRenderer, -} from 'three'; -import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js'; -import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js'; -import { GTAOPass } from 'three/examples/jsm/postprocessing/GTAOPass.js'; -import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js'; -import { OutputPass } from 'three/examples/jsm/postprocessing/OutputPass.js'; - -export interface Post { - render(): void; - /** fuer Fehlersuche und Bildkontrolle */ - passes: { gtao: GTAOPass; bloom: UnrealBloomPass }; - setSize(w: number, h: number): void; -} - -/** - * @param ohneAO liefert die Objekte, die fuer die Verdeckung ausgeblendet - * werden: Blattkarten und Halme. Die AO-Normalen werden mit einem - * Ersatzmaterial ohne Alpha-Test gerechnet - die Karten wuerden dort als - * volle Vierecke erscheinen und dunkle Kaesten erzeugen. - */ -export function buildPost( - renderer: WebGLRenderer, scene: Scene, camera: Camera, ohneAO: () => Object3D[], -): Post { - const size = renderer.getDrawingBufferSize(new Vector2()); - const target = new WebGLRenderTarget(size.x, size.y, { type: HalfFloatType, samples: 4 }); - const composer = new EffectComposer(renderer, target); - composer.addPass(new RenderPass(scene, camera)); - - const gtao = new GTAOPass(scene, camera, size.x, size.y); - gtao.updateGtaoMaterial({ - radius: 0.55, distanceExponent: 1.6, thickness: 1.0, scale: 1.0, samples: 16, - distanceFallOff: 1.0, screenSpaceRadius: false, - }); - gtao.updatePdMaterial({ lumaPhi: 10, depthPhi: 2, normalPhi: 3, radius: 5, rings: 2, samples: 12 }); - gtao.blendIntensity = 0.85; - const gtaoRender = gtao.render.bind(gtao); - gtao.render = (r, w, rd, dt, mask) => { - const aus = ohneAO().filter((o) => o.visible); - for (const o of aus) o.visible = false; - gtaoRender(r, w, rd, dt, mask); - for (const o of aus) o.visible = true; - }; - composer.addPass(gtao); - - const bloom = new UnrealBloomPass(new Vector2(size.x, size.y), 0.16, 0.55, 0.92); - composer.addPass(bloom); - composer.addPass(new OutputPass()); - - return { - passes: { gtao, bloom }, - render: () => composer.render(), - setSize: (w, h) => { - composer.setPixelRatio(renderer.getPixelRatio()); - composer.setSize(w, h); - }, - }; -} diff --git a/src/scene/quality.ts b/src/scene/quality.ts index 817cd91..e109339 100644 --- a/src/scene/quality.ts +++ b/src/scene/quality.ts @@ -14,3 +14,17 @@ let current: Quality = 'stil'; export function quality(): Quality { return current; } export function setQuality(q: Quality) { current = q; } export function isFoto(): boolean { return current === 'foto'; } + +/** + * Geraete ohne Maus (Handy, Tablet) bekommen im Fotomodus weniger Blaetter und + * Halme und kleinere Texturen - die Grafikchips dort vertragen nur einen + * Bruchteil dessen, was ein Laptop schafft. + */ +export function leichtesGeraet(): boolean { + return typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches; +} + +/** Faktor fuer Blatt- und Halmdichten: 1 am Rechner, weniger auf dem Handy. */ +export function dichte(): number { + return leichtesGeraet() ? 0.55 : 1; +} diff --git a/src/scene/textures.ts b/src/scene/textures.ts index 10852db..1033e48 100644 --- a/src/scene/textures.ts +++ b/src/scene/textures.ts @@ -10,7 +10,7 @@ import { CanvasTexture, RepeatWrapping, SRGBColorSpace, LinearMipmapLinearFilter, LinearSRGBColorSpace, Texture, Vector2, } from 'three'; -import { isFoto } from './quality'; +import { isFoto, leichtesGeraet } from './quality'; import { bild, fotoBereit, FOTO_M, type Satz } from './fotomaterial'; /** @@ -872,7 +872,7 @@ function fotoDirekt(d: Direkt): Belag { function fotoPlatten(key: string, d: Platten): Belag { const color = bild(d.satz, 'color')!; const rough = bild(d.satz, 'rough'); - PHYS = d.px; + PHYS = leichtesGeraet() ? Math.min(d.px, 1024) : d.px; const [c, ctx] = canvas(); const [rc, rctx] = canvas(); const [hc, hctx] = canvas(); diff --git a/src/scene/vegetation.ts b/src/scene/vegetation.ts index 915441c..24e451f 100644 --- a/src/scene/vegetation.ts +++ b/src/scene/vegetation.ts @@ -12,7 +12,7 @@ import { } from 'three'; import type { Plant, Tree, Vec2 } from '../site'; import * as tex from './textures'; -import { isFoto } from './quality'; +import { dichte, isFoto } from './quality'; import { blattkarten, fotoBaum, oberflaechenPunkte, type Blattart } from './laub'; /** Dunkler Kern unter den Blattkarten: das Innere einer dichten Pflanze. */ @@ -30,11 +30,11 @@ const KERN = () => { * Oberflaeche), so wird die Pflanze als Volumen schattiert. */ function belauben( - koerper: Mesh, art: Blattart, dichte: number, groesse: number, seed: number, tiefe = 0.35, flach = false, + koerper: Mesh, art: Blattart, anzahlProM2: number, groesse: number, seed: number, tiefe = 0.35, flach = false, ): InstancedMesh { koerper.updateMatrixWorld(true); const geo = koerper.geometry.clone().applyMatrix4(koerper.matrixWorld); - const punkte = oberflaechenPunkte(geo, dichte, seed, (q) => q, groesse); + const punkte = oberflaechenPunkte(geo, anzahlProM2 * dichte(), seed, (q) => q, groesse); for (const b of punkte) b.c = b.p.clone().addScaledVector(b.n, -tiefe); return blattkarten(punkte, art, seed, `${koerper.name} – Laub`, flach); } @@ -476,7 +476,7 @@ export function buildVegetation( if (row.length >= 2) { const hecke = hedgeMesh(row, h, foto ? 0.50 : 0.55, foto ? KERN() : eibeMat, height, r, 'Eibenhecke `Hilli`'); group.add(hecke); - if (foto) group.add(belauben(hecke, 'eibe', 340, 0.14, Math.round(mx * 31 + mz * 17), 0.25, true)); + if (foto) group.add(belauben(hecke, 'eibe', 95, 0.21, Math.round(mx * 31 + mz * 17), 0.25, true)); } else { // Einzelstand: als schmales, aufrechtes Polster const [x, z] = row[0]; @@ -487,7 +487,7 @@ export function buildVegetation( m.castShadow = true; m.receiveShadow = true; group.add(m); - if (foto) group.add(belauben(m, 'eibe', 340, 0.14, Math.round(x * 31 + z * 17), 0.22, true)); + if (foto) group.add(belauben(m, 'eibe', 95, 0.21, Math.round(x * 31 + z * 17), 0.22, true)); } } @@ -501,7 +501,7 @@ export function buildVegetation( m.position.set(p.x!, height(p.x!, p.z!) + s * 0.72, p.z!); m.castShadow = true; group.add(m); - group.add(belauben(m, 'strauch', 85, 0.24, Math.round(p.x! * 11 + p.z! * 5), s * 0.6)); + group.add(belauben(m, 'strauch', 60, 0.28, Math.round(p.x! * 11 + p.z! * 5), s * 0.6)); } } const strauchMesh = foto ? null : instances(blob(1, 2, 0.06, 7, 0.8), foliage(LAUB.strauch), straeucher.map((p) => { @@ -551,7 +551,7 @@ export function buildVegetation( m.name = 'Kraut'; m.position.set(p.x!, height(p.x!, p.z!) + rr * 0.45, p.z!); // Kraeuter sind locker - nur Blattkarten, kein dunkler Kern darunter. - group.add(belauben(m, 'kraut', 420, 0.12, Math.round(p.x! * 17 + p.z! * 3), rr * 0.6)); + group.add(belauben(m, 'kraut', 160, 0.16, Math.round(p.x! * 17 + p.z! * 3), rr * 0.6)); } } const krautMesh = foto ? null : instances(blob(1, 2, 0.10, 11, 0.7), foliage(LAUB.kraut, 0.98), kraeuter.map((p) => { @@ -578,7 +578,7 @@ export function buildVegetation( : [[(x0 + x1) / 2, z0], [(x0 + x1) / 2, z1]]; const hecke = hedgeMesh(along, p.height ?? 1.7, (p.width ?? 0.75) * (foto ? 0.9 : 1), foto ? KERN() : ligusterMat, height, r, 'Ligusterhecke'); group.add(hecke); - if (foto) group.add(belauben(hecke, 'liguster', 160, 0.18, 911, 0.3, true)); + if (foto) group.add(belauben(hecke, 'liguster', 70, 0.24, 911, 0.3, true)); } return group;