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;