Fotoansicht fluessig: statische Schatten, weniger Laub, adaptive Aufloesung
Deploy App / deploy (push) Successful in 30s

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 17:16:21 +02:00
co-authored by Claude Opus 5
parent 134fb22937
commit 2ba564d749
9 changed files with 238 additions and 128 deletions
+19 -1
View File
@@ -226,9 +226,27 @@ Was die fotorealistische Variante anders macht:
| Bäume | Rekursiv gewachsene, gekrümmte Äste; Blattwolken an den Zweigspitzen | | Bäume | Rekursiv gewachsene, gekrümmte Äste; Blattwolken an den Zweigspitzen |
| Rasen | Grasnarbe aus Büscheln einzelner Halme, die im Wind wiegen (`gras.ts`) | | 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 | | 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 | | 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 ### Rundgang
Bewusst einfach: **auf den Boden klicken** (am Handy tippen), dann läuft man Bewusst einfach: **auf den Boden klicken** (am Handy tippen), dann läuft man
+90 -12
View File
@@ -21,9 +21,8 @@ import { buildBuilding, buildNeighbourBuildings } from './scene/building';
import { buildEnvironment } from './scene/environment'; import { buildEnvironment } from './scene/environment';
import { Walker, isTouch, obstacleFromSegment, type Obstacle } from './controls/walker'; import { Walker, isTouch, obstacleFromSegment, type Obstacle } from './controls/walker';
import { setupHud } from './ui/hud'; import { setupHud } from './ui/hud';
import { setQuality, type Quality } from './scene/quality'; import { leichtesGeraet, setQuality, type Quality } from './scene/quality';
import { buildPost, type Post } from './scene/post'; import { grasSichtweite, wind } from './scene/gras';
import { wind } from './scene/gras';
import { ladeFotomaterialien } from './scene/fotomaterial'; import { ladeFotomaterialien } from './scene/fotomaterial';
const CENTER: [number, number] = [-9.5, -1.0]; const CENTER: [number, number] = [-9.5, -1.0];
@@ -46,10 +45,37 @@ async function main() {
renderer.toneMappingExposure = 0.94; renderer.toneMappingExposure = 0.94;
renderer.shadowMap.enabled = true; renderer.shadowMap.enabled = true;
renderer.shadowMap.type = PCFSoftShadowMap; 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 scene = new Scene();
const camera = new PerspectiveCamera(62, 1, 0.08, 1400); 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 …'); await progress(8, 'Gartenplan wird geladen …');
const site: SiteData = await loadSite(); const site: SiteData = await loadSite();
const height = makeHeightSampler(site.terrain); const height = makeHeightSampler(site.terrain);
@@ -334,18 +360,50 @@ async function main() {
}, { passive: false }); }, { passive: false });
// -- Umschalten zwischen den Darstellungen -------------------------------- // -- Umschalten zwischen den Darstellungen --------------------------------
let post: Post | null = null;
const loader = document.getElementById('loader')!; 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) { function darstellung(q: Quality) {
const foto = q === 'foto'; const foto = q === 'foto';
env.setQuality(q); env.setQuality(q);
renderer.toneMapping = ACESFilmicToneMapping; renderer.toneMapping = ACESFilmicToneMapping;
renderer.toneMappingExposure = foto ? 0.9 : 0.94; renderer.toneMappingExposure = foto ? 0.9 : 0.94;
renderer.setPixelRatio(Math.min(devicePixelRatio, foto ? 1.5 : 2)); aufloesungFuer(q);
if (foto && !post) post = buildPost(renderer, scene, camera, () => welten.foto?.laub ?? []);
resize(); resize();
welt().lamps.setOn(env.isDark()); welt().lamps.setOn(env.isDark());
renderer.shadowMap.needsUpdate = true;
wecken();
} }
async function setAnsicht(q: Quality) { async function setAnsicht(q: Quality) {
@@ -398,7 +456,6 @@ async function main() {
const w = canvas.clientWidth || window.innerWidth; const w = canvas.clientWidth || window.innerWidth;
const h = canvas.clientHeight || window.innerHeight; const h = canvas.clientHeight || window.innerHeight;
renderer.setSize(w, h, false); renderer.setSize(w, h, false);
post?.setSize(w, h);
camera.aspect = w / h; camera.aspect = w / h;
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
} }
@@ -409,6 +466,7 @@ async function main() {
let last = performance.now(); let last = performance.now();
let lastSun = { hours: -1, day: -1 }; let lastSun = { hours: -1, day: -1 };
function frame(now: number) { function frame(now: number) {
const dt = Math.min(0.05, (now - last) / 1000); const dt = Math.min(0.05, (now - last) / 1000);
last = now; last = now;
@@ -419,6 +477,8 @@ async function main() {
welt().lamps.setOn(env.isDark()); welt().lamps.setOn(env.isDark());
hud.setSunReadout(sun); hud.setSunReadout(sun);
lastSun = t; lastSun = t;
renderer.shadowMap.needsUpdate = true;
wecken();
} }
if (orbit.active) { if (orbit.active) {
@@ -437,14 +497,27 @@ async function main() {
if (d < 0.9) auf = true; if (d < 0.9) auf = true;
const ziel = auf ? tor.offen : tor.zu; const ziel = auf ? tor.offen : tor.zu;
const schritt = 2.4 * dt; const schritt = 2.4 * dt;
const vorher = tor.winkel;
tor.winkel += Math.max(-schritt, Math.min(schritt, ziel - 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; tor.obstacle.disabled = tor.winkel - tor.zu > 0.6;
} }
wind.value = now / 1000; camera.updateMatrixWorld();
if (ansicht === 'foto' && post) post.render(); if (kameraBewegt()) wecken();
else renderer.render(scene, camera); 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); requestAnimationFrame(frame);
} }
@@ -459,7 +532,12 @@ async function main() {
scene, camera, site, env, walker, orbit, hud, renderer, Group, scene, camera, site, env, walker, orbit, hud, renderer, Group,
setOverview(on: boolean) { hud.setMode(on ? 'overview' : 'walk'); }, setOverview(on: boolean) { hud.setMode(on ? 'overview' : 'walk'); },
setAnsicht, 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,
}; };
} }
+17 -4
View File
@@ -11,7 +11,8 @@ import {
type WebGLRenderer, type WebGLRenderTarget, type WebGLRenderer, type WebGLRenderTarget,
} from 'three'; } from 'three';
import { Sky } from 'three/examples/jsm/objects/Sky.js'; 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 { export interface SunState {
/** Hoehenwinkel in Grad, negativ wenn die Sonne unter dem Horizont steht. */ /** 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); envScene.add(envBoden);
const pmrem = new PMREMGenerator(renderer); const pmrem = new PMREMGenerator(renderer);
let envTarget: WebGLRenderTarget | null = null; let envTarget: WebGLRenderTarget | null = null;
let envStand = { alt: -99, az: -999 };
let quality: Quality = 'stil'; let quality: Quality = 'stil';
let last = { day: 172, hours: 14 }; 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 // und hellem Travertin. Zu dunkel gewaehlt, liegen Decken, Vordaecher und
// Unterseiten im Fotomodus fast schwarz und blaustichig im Schatten. // 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); (envBoden.material as ShaderMaterial).uniforms.uTone.value.setRGB(0.30, 0.28, 0.21).multiplyScalar(0.15 + 0.85 * dusk);
envTarget?.dispose(); // Die Umgebungskarte zu rechnen kostet spuerbar. Beim Ziehen am
envTarget = pmrem.fromScene(envScene, 0.02, 0.1, 200); // 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; scene.environment = envTarget.texture;
// Das Himmelslicht kommt jetzt aus der Umgebungskarte - die pauschalen // Das Himmelslicht kommt jetzt aus der Umgebungskarte - die pauschalen
// Fuellichter werden stark zurueckgenommen. // Fuellichter werden stark zurueckgenommen.
// Sonne zu Himmel etwa 5:1, wie an einem klaren Tag - sonst verschwinden // Sonne zu Himmel etwa 5:1, wie an einem klaren Tag - sonst verschwinden
// die Schatten im Himmelslicht. // die Schatten im Himmelslicht.
scene.environmentIntensity = 0.05 + 0.30 * dusk; 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; hemi.intensity = 0.03 + 0.08 * dusk;
ambient.intensity = 0.01; ambient.intensity = 0.01;
sun.intensity = 3.6 * day; sun.intensity = 3.6 * day;
@@ -289,9 +300,11 @@ export function buildEnvironment(scene: Scene, renderer: WebGLRenderer, opts: {
if (q === 'stil') { if (q === 'stil') {
scene.environment = null; scene.environment = null;
scene.environmentIntensity = 1; scene.environmentIntensity = 1;
} else {
envStand = { alt: -99, az: -999 };
} }
// Feinere Schatten im Fotomodus // Feinere Schatten im Fotomodus
const size = q === 'foto' ? 4096 : 2048; const size = q === 'foto' && !leichtesGeraet() ? 4096 : 2048;
if (sun.shadow.mapSize.x !== size) { if (sun.shadow.mapSize.x !== size) {
sun.shadow.mapSize.set(size, size); sun.shadow.mapSize.set(size, size);
sun.shadow.map?.dispose(); sun.shadow.map?.dispose();
+64 -27
View File
@@ -6,10 +6,31 @@
* Vertex-Shader laesst die Spitzen leicht im Wind wiegen. * Vertex-Shader laesst die Spitzen leicht im Wind wiegen.
*/ */
import { import {
BufferAttribute, BufferGeometry, Color, DoubleSide, InstancedMesh, Matrix4, BufferAttribute, BufferGeometry, Color, DoubleSide, Group, InstancedMesh, Matrix4,
MeshStandardMaterial, Quaternion, Vector3, MeshStandardMaterial, Quaternion, Vector3,
} from 'three'; } from 'three';
import type { Vec2 } from '../site'; 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. */ /** Laufzeit in Sekunden, wird pro Bild gesetzt. */
export const wind = { value: 0 }; export const wind = { value: 0 };
@@ -85,27 +106,33 @@ export interface GrasArt {
breite?: number; breite?: number;
} }
export const RASEN: GrasArt = { dichte: 420, hoehe: [0.05, 0.10], farbe: 0x597f35, streuung: 0.22 }; // Dichte bewusst massvoll: jedes Bueschel sind 20 Dreiecke. Die Rasentextur
export const SCHOTTERRASEN: GrasArt = { dichte: 70, hoehe: [0.08, 0.22], farbe: 0x7f9146, streuung: 0.22, breite: 1.6 }; // 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( export function grasnarbe(
flaechen: Vec2[][], height: (x: number, z: number) => number, art: GrasArt, seed: number, name: string, flaechen: Vec2[][], height: (x: number, z: number) => number, art: GrasArt, seed: number, name: string,
): InstancedMesh | null { ): Group | null {
const r = rng(seed); const r = rng(seed);
const spacing = 1 / Math.sqrt(art.dichte); const spacing = 1 / Math.sqrt(art.dichte * dichte());
const items: { x: number; z: number }[] = []; const proFeld = new Map<string, { x: number; z: number }[]>();
for (const poly of flaechen) { for (const poly of flaechen) {
let x0 = Infinity, z0 = Infinity, x1 = -Infinity, z1 = -Infinity; 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 (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 z = z0; z < z1; z += spacing) {
for (let x = x0; x < x1; x += spacing) { for (let x = x0; x < x1; x += spacing) {
const jx = x + (r() - 0.5) * spacing, jz = z + (r() - 0.5) * 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 geo = bueschel(seed);
const mat = new MeshStandardMaterial({ const mat = new MeshStandardMaterial({
color: 0xffffff, vertexColors: true, roughness: 0.9, metalness: 0, side: DoubleSide, 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; transformed.z += cos(uWind * 1.2 + ip.x * 0.5) * 0.05 * h2;
#endif`); #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 m = new Matrix4(), q = new Quaternion(), s = new Vector3(), p = new Vector3();
const up = new Vector3(0, 1, 0); const up = new Vector3(0, 1, 0);
const base = new Color(art.farbe), c = new Color(); const base = new Color(art.farbe), c = new Color();
items.forEach((it, i) => { for (const items of proFeld.values()) {
const h = art.hoehe[0] + (art.hoehe[1] - art.hoehe[0]) * r(); const mesh = new InstancedMesh(geo, mat, items.length);
q.setFromAxisAngle(up, r() * Math.PI * 2); const mitte = new Vector3();
const b = art.breite ?? 1; items.forEach((it, i) => {
s.set(h * b, h, h * b); const h = art.hoehe[0] + (art.hoehe[1] - art.hoehe[0]) * r();
p.set(it.x, height(it.x, it.z) + 0.004, it.z); q.setFromAxisAngle(up, r() * Math.PI * 2);
m.compose(p, q, s); const b = art.breite ?? 1;
mesh.setMatrixAt(i, m); s.set(h * b, h, h * b);
c.copy(base).offsetHSL((r() - 0.5) * 0.04, (r() - 0.5) * art.streuung, (r() - 0.5) * art.streuung * 0.8); p.set(it.x, height(it.x, it.z) + 0.004, it.z);
mesh.setColorAt(i, c); m.compose(p, q, s);
}); mesh.setMatrixAt(i, m);
mesh.instanceMatrix.needsUpdate = true; c.copy(base).offsetHSL((r() - 0.5) * 0.04, (r() - 0.5) * art.streuung, (r() - 0.5) * art.streuung * 0.8);
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; mesh.setColorAt(i, c);
mesh.receiveShadow = true; mitte.add(p);
mesh.name = name; });
mesh.userData.laub = true; mesh.instanceMatrix.needsUpdate = true;
return mesh; 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;
} }
+24 -7
View File
@@ -14,10 +14,11 @@
import { import {
BufferAttribute, BufferGeometry, CanvasTexture, Color, CylinderGeometry, DoubleSide, BufferAttribute, BufferGeometry, CanvasTexture, Color, CylinderGeometry, DoubleSide,
Group, InstancedBufferAttribute, InstancedMesh, LinearMipmapLinearFilter, Matrix4, Mesh, Group, InstancedBufferAttribute, InstancedMesh, LinearMipmapLinearFilter, Matrix4, Mesh,
MeshDepthMaterial, MeshStandardMaterial, PlaneGeometry, Quaternion, RGBADepthPacking, MeshDepthMaterial, MeshLambertMaterial, MeshStandardMaterial, PlaneGeometry, Quaternion, RGBADepthPacking,
RepeatWrapping, SRGBColorSpace, ShaderChunk, Vector3, RepeatWrapping, SRGBColorSpace, ShaderChunk, Vector3,
} from 'three'; } from 'three';
import type { Tree } from '../site'; import type { Tree } from '../site';
import { dichte } from './quality';
export type Blattart = 'baum' | 'apfel' | 'eibe' | 'liguster' | 'strauch' | 'kraut'; export type Blattart = 'baum' | 'apfel' | 'eibe' | 'liguster' | 'strauch' | 'kraut';
@@ -158,15 +159,31 @@ function blattTextur(art: Blattart): CanvasTexture {
// Material mit Volumen-Normalen // Material mit Volumen-Normalen
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const matCache = new Map<Blattart, { mat: MeshStandardMaterial; depth: MeshDepthMaterial }>(); const matCache = new Map<Blattart, { mat: MeshLambertMaterial; depth: MeshDepthMaterial }>();
/**
* 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) { function laubMaterial(art: Blattart) {
const hit = matCache.get(art); const hit = matCache.get(art);
if (hit) return hit; if (hit) return hit;
const map = blattTextur(art); const map = blattTextur(art);
const mat = new MeshStandardMaterial({ const mat = new MeshLambertMaterial({
map, alphaTest: 0.45, side: DoubleSide, roughness: art === 'liguster' ? 0.62 : 0.78, map, alphaTest: 0.45, side: DoubleSide,
metalness: 0, emissive: FUELL, emissiveMap: map, emissiveIntensity: 0.04 + laubFuellung * 0.96,
}); });
mat.onBeforeCompile = (sh) => { mat.onBeforeCompile = (sh) => {
sh.vertexShader = sh.vertexShader 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; } 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 mitte = new Vector3(cx / wolken.length, cy / wolken.length - crownR * 0.25, cz / wolken.length);
const punkte: Blattpunkt[] = []; 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 (const w of wolken) {
for (let i = 0; i < proWolke; i++) { for (let i = 0; i < proWolke; i++) {
const d = new Vector3(r() * 2 - 1, r() * 2 - 1, r() * 2 - 1); 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 rad = w.r * (0.3 + 0.7 * Math.pow(r(), 0.45));
const p = w.c.clone().addScaledVector(d, rad); const p = w.c.clone().addScaledVector(d, rad);
p.y = Math.min(p.y, top); 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`)); g.add(blattkarten(punkte, t.species === 'apfel' ? 'apfel' : 'baum', Math.round(t.x * 13 + t.z * 7), `${t.name} – Laub`));
-67
View File
@@ -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);
},
};
}
+14
View File
@@ -14,3 +14,17 @@ let current: Quality = 'stil';
export function quality(): Quality { return current; } export function quality(): Quality { return current; }
export function setQuality(q: Quality) { current = q; } export function setQuality(q: Quality) { current = q; }
export function isFoto(): boolean { return current === 'foto'; } 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;
}
+2 -2
View File
@@ -10,7 +10,7 @@ import {
CanvasTexture, RepeatWrapping, SRGBColorSpace, LinearMipmapLinearFilter, CanvasTexture, RepeatWrapping, SRGBColorSpace, LinearMipmapLinearFilter,
LinearSRGBColorSpace, Texture, Vector2, LinearSRGBColorSpace, Texture, Vector2,
} from 'three'; } from 'three';
import { isFoto } from './quality'; import { isFoto, leichtesGeraet } from './quality';
import { bild, fotoBereit, FOTO_M, type Satz } from './fotomaterial'; 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 { function fotoPlatten(key: string, d: Platten): Belag {
const color = bild(d.satz, 'color')!; const color = bild(d.satz, 'color')!;
const rough = bild(d.satz, 'rough'); const rough = bild(d.satz, 'rough');
PHYS = d.px; PHYS = leichtesGeraet() ? Math.min(d.px, 1024) : d.px;
const [c, ctx] = canvas(); const [c, ctx] = canvas();
const [rc, rctx] = canvas(); const [rc, rctx] = canvas();
const [hc, hctx] = canvas(); const [hc, hctx] = canvas();
+8 -8
View File
@@ -12,7 +12,7 @@ import {
} from 'three'; } from 'three';
import type { Plant, Tree, Vec2 } from '../site'; import type { Plant, Tree, Vec2 } from '../site';
import * as tex from './textures'; import * as tex from './textures';
import { isFoto } from './quality'; import { dichte, isFoto } from './quality';
import { blattkarten, fotoBaum, oberflaechenPunkte, type Blattart } from './laub'; import { blattkarten, fotoBaum, oberflaechenPunkte, type Blattart } from './laub';
/** Dunkler Kern unter den Blattkarten: das Innere einer dichten Pflanze. */ /** Dunkler Kern unter den Blattkarten: das Innere einer dichten Pflanze. */
@@ -30,11 +30,11 @@ const KERN = () => {
* Oberflaeche), so wird die Pflanze als Volumen schattiert. * Oberflaeche), so wird die Pflanze als Volumen schattiert.
*/ */
function belauben( 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 { ): InstancedMesh {
koerper.updateMatrixWorld(true); koerper.updateMatrixWorld(true);
const geo = koerper.geometry.clone().applyMatrix4(koerper.matrixWorld); 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); for (const b of punkte) b.c = b.p.clone().addScaledVector(b.n, -tiefe);
return blattkarten(punkte, art, seed, `${koerper.name} – Laub`, flach); return blattkarten(punkte, art, seed, `${koerper.name} – Laub`, flach);
} }
@@ -476,7 +476,7 @@ export function buildVegetation(
if (row.length >= 2) { if (row.length >= 2) {
const hecke = hedgeMesh(row, h, foto ? 0.50 : 0.55, foto ? KERN() : eibeMat, height, r, 'Eibenhecke `Hilli`'); const hecke = hedgeMesh(row, h, foto ? 0.50 : 0.55, foto ? KERN() : eibeMat, height, r, 'Eibenhecke `Hilli`');
group.add(hecke); 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 { } else {
// Einzelstand: als schmales, aufrechtes Polster // Einzelstand: als schmales, aufrechtes Polster
const [x, z] = row[0]; const [x, z] = row[0];
@@ -487,7 +487,7 @@ export function buildVegetation(
m.castShadow = true; m.castShadow = true;
m.receiveShadow = true; m.receiveShadow = true;
group.add(m); 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.position.set(p.x!, height(p.x!, p.z!) + s * 0.72, p.z!);
m.castShadow = true; m.castShadow = true;
group.add(m); 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) => { 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.name = 'Kraut';
m.position.set(p.x!, height(p.x!, p.z!) + rr * 0.45, p.z!); m.position.set(p.x!, height(p.x!, p.z!) + rr * 0.45, p.z!);
// Kraeuter sind locker - nur Blattkarten, kein dunkler Kern darunter. // 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) => { 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]]; : [[(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'); 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); 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; return group;