diff --git a/README.md b/README.md
index 246beaf..cc48ebf 100644
--- a/README.md
+++ b/README.md
@@ -207,6 +207,28 @@ offen** („Aussensitzplatz“ in der Südwestfassade) – gegen den Garten steh
eine geschlossene Wand. Ohne sie schaut man von aussen durch die Fassade in den
Sitzplatz hinein.
+### Zwei Darstellungen
+
+Oben in der Mitte schaltet ein Knopf zwischen **Stilisiert** und
+**Fotorealistisch**. Beide Varianten entstehen aus derselben Geometrie – Plan,
+Masse, Höhen und Rundgang sind identisch. Die Bauteile fragen beim Aufbau
+`isFoto()` ab (`src/scene/quality.ts`). Die zweite Variante wird beim ersten
+Umschalten aufgebaut und bleibt danach im Speicher, so springt man ohne
+Wartezeit hin und her. Die Wahl merkt sich der Browser; `?ansicht=foto` oder
+`?ansicht=stil` erzwingt eine Variante.
+
+Was die fotorealistische Variante anders macht:
+
+| Bereich | Umsetzung |
+| --- | --- |
+| Materialien | Texturen doppelt so fein (1024 px), dazu Rauheits- und Verdeckungskarten aus dem Höhenbild (`textures.ts`) |
+| Laub | Blattkarten statt Laubkörpern: Blätter, Eibennadeln, Ligusterblätter als Textur mit Alpha-Test, als InstancedMesh. Die Lichtnormalen zeigen vom Kronenmittelpunkt weg – so wird eine Krone als Volumen beleuchtet (`laub.ts`) |
+| 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 |
+
### Rundgang
Bewusst einfach: **auf den Boden klicken** (am Handy tippen), dann läuft man
diff --git a/index.html b/index.html
index 0d84644..a6b615c 100644
--- a/index.html
+++ b/index.html
@@ -25,6 +25,10 @@
Garten Egglihaldenstrasse 9aAbtwil SG · Gartenplan 1, Garten Design AG
+
+
+
+
diff --git a/src/main.ts b/src/main.ts
index 65040a7..b7a025c 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -6,21 +6,24 @@
* Projektplaenen von EHLE Architekten. Siehe tools/ fuer die Datenpipeline.
*/
import {
- ACESFilmicToneMapping, Group, Mesh, MeshBasicMaterial, PCFSoftShadowMap, PerspectiveCamera,
- Raycaster, RingGeometry, Scene, SRGBColorSpace, Vector2, Vector3, WebGLRenderer,
+ ACESFilmicToneMapping, Group, Mesh, MeshBasicMaterial, PCFSoftShadowMap,
+ PerspectiveCamera, Raycaster, RingGeometry, Scene, SRGBColorSpace, Vector2, Vector3, WebGLRenderer,
type Object3D,
} from 'three';
import './style.css';
import { loadSite, makeHeightSampler, type SiteData } from './site';
import { buildTerrain } from './scene/terrain';
import { buildSurfaces } from './scene/surfaces';
-import { buildStelen, buildProps, buildLamps, buildLinear, treppeTritte } from './scene/structures';
+import { buildStelen, buildProps, buildLamps, buildLinear, treppeTritte, type GardenLights } from './scene/structures';
import { buildWalls } from './scene/walls';
import { buildVegetation } from './scene/vegetation';
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';
const CENTER: [number, number] = [-9.5, -1.0];
const BOUNDS = { x0: -24.0, z0: -12.5, x1: 7.5, z1: 8.5 };
@@ -50,34 +53,74 @@ async function main() {
const site: SiteData = await loadSite();
const height = makeHeightSampler(site.terrain);
- await progress(22, 'Gelände wird modelliert …');
- const terrainGroup = buildTerrain(site.terrain);
- scene.add(terrainGroup);
+ // -- Zwei Darstellungen aus derselben Geometrie --------------------------
+ // Stilisiert und fotorealistisch teilen Plan, Masse und Rundgang; nur
+ // Materialien, Laub, Licht und Nachbearbeitung unterscheiden sich. Die
+ // zweite Variante wird erst beim ersten Umschalten aufgebaut und bleibt
+ // danach bestehen - so springt man ohne Wartezeit hin und her.
+ interface Welt {
+ group: Group;
+ lamps: GardenLights;
+ propGroup: Group;
+ pickTargets: Object3D[];
+ /** Blattkarten und Halme - fuer die Umgebungsverdeckung ausgeblendet */
+ laub: Object3D[];
+ }
+ const welten: Partial> = {};
+ type Melde = (pct: number, text: string) => Promise;
- await progress(38, 'Beläge werden verlegt …');
- const surfaceGroup = buildSurfaces(site.surfaces, site.slabs, height);
- scene.add(surfaceGroup);
+ async function baueWelt(q: Quality, melde: Melde): Promise {
+ setQuality(q);
+ const foto = q === 'foto';
+ const group = new Group();
+ group.name = foto ? 'Fotorealistisch' : 'Stilisiert';
- await progress(52, 'Mauern und Bauteile …');
- const wallGroup = buildWalls(site.walls, height);
- scene.add(wallGroup);
- scene.add(buildStelen(site.stelen, height));
- const propGroup = buildProps(site.props, height);
- scene.add(propGroup);
- const linearGroup = buildLinear(site.linear, height);
- scene.add(linearGroup);
+ await melde(22, foto ? 'Fotorealistisches Gelände …' : 'Gelände wird modelliert …');
+ const terrainGroup = buildTerrain(site.terrain);
+ group.add(terrainGroup);
- await progress(66, 'Haus und Nachbarschaft …');
- scene.add(buildBuilding(site.building));
- scene.add(buildNeighbourBuildings(site.neighbourBuildings));
+ await melde(38, foto ? 'Beläge und Rasen in hoher Auflösung …' : 'Beläge werden verlegt …');
+ const surfaceGroup = buildSurfaces(site.surfaces, site.slabs, height);
+ group.add(surfaceGroup);
- await progress(80, 'Bepflanzung wird gesetzt …');
- scene.add(buildVegetation(site.plants, site.trees, height));
+ await melde(52, 'Mauern und Bauteile …');
+ const wallGroup = buildWalls(site.walls, height);
+ group.add(wallGroup);
+ group.add(buildStelen(site.stelen, height));
+ const propGroup = buildProps(site.props, height);
+ group.add(propGroup);
+ const linearGroup = buildLinear(site.linear, height);
+ group.add(linearGroup);
- await progress(90, 'Licht und Himmel …');
- const lamps = buildLamps(site.lamps, height);
- scene.add(lamps.group);
- const env = buildEnvironment(scene, {
+ await melde(66, 'Haus und Nachbarschaft …');
+ group.add(buildBuilding(site.building));
+ group.add(buildNeighbourBuildings(site.neighbourBuildings));
+
+ await melde(80, foto ? 'Blätter und Halme werden gesetzt …' : 'Bepflanzung wird gesetzt …');
+ group.add(buildVegetation(site.plants, site.trees, height));
+
+ await melde(90, 'Licht und Himmel …');
+ const lamps = buildLamps(site.lamps, height);
+ group.add(lamps.group);
+
+ const laub: Object3D[] = [];
+ group.traverse((o) => { if (o.userData.laub) laub.push(o); });
+ return {
+ group, lamps, propGroup, laub,
+ pickTargets: [terrainGroup, surfaceGroup, wallGroup, linearGroup, propGroup],
+ };
+ }
+
+ const gewuenscht = new URLSearchParams(location.search).get('ansicht');
+ let gespeichert: string | null = null;
+ try { gespeichert = localStorage.getItem('garten-ansicht'); } catch { /* privat */ }
+ let ansicht: Quality = gewuenscht === 'foto' || gewuenscht === 'stil'
+ ? gewuenscht : gespeichert === 'foto' ? 'foto' : 'stil';
+ welten[ansicht] = await baueWelt(ansicht, progress);
+ scene.add(welten[ansicht]!.group);
+ const welt = () => welten[ansicht]!;
+
+ const env = buildEnvironment(scene, renderer, {
lat: site.meta.location.lat,
lon: site.meta.location.lon,
northBearingOfSiteX: site.meta.location.northBearingOfSiteX,
@@ -161,14 +204,22 @@ async function main() {
}
}
// Tore merken wir uns: ihr Hindernis faellt weg, solange sie offen stehen.
- const tore: { x: number; z: number; obstacle: Obstacle; obj: Object3D; winkel: number; hand: boolean | null }[] = [];
+ const tore: {
+ id: string; x: number; z: number; obstacle: Obstacle;
+ zu: number; offen: number; winkel: number; hand: boolean | null; fluegel: Object3D[];
+ }[] = [];
for (const p of site.props) {
// Stufen sind begehbar, kein Hindernis - sie stehen in `tritte`.
if (['schacht', 'wasserhahn', 'stufe', 'torbogen'].includes(p.type)) continue;
const o: Obstacle = { cx: p.x, cz: p.z, hw: p.w / 2, hd: p.d / 2, rot: (p.rot * Math.PI) / 180 };
obstacles.push(o);
- const obj = p.type === 'tor' ? propGroup.getObjectByName(p.id) : undefined;
- if (obj?.userData.fluegel) tore.push({ x: p.x, z: p.z, obstacle: o, obj, winkel: obj.userData.zu, hand: null });
+ const obj = p.type === 'tor' ? welt().propGroup.getObjectByName(p.id) : undefined;
+ if (obj?.userData.fluegel) {
+ tore.push({
+ id: p.id, x: p.x, z: p.z, obstacle: o, zu: obj.userData.zu, offen: obj.userData.offen,
+ winkel: obj.userData.zu, hand: null, fluegel: [obj.userData.fluegel],
+ });
+ }
}
for (const pl of site.plants) {
if (pl.type !== 'hecke' || !pl.polygon) continue;
@@ -196,12 +247,11 @@ async function main() {
const ray = new Raycaster();
ray.far = 70;
const ndc = new Vector2();
- const pickTargets = [terrainGroup, surfaceGroup, wallGroup, linearGroup, propGroup];
const pick = (clientX: number, clientY: number) => {
const r = canvas.getBoundingClientRect();
ndc.set(((clientX - r.left) / r.width) * 2 - 1, -((clientY - r.top) / r.height) * 2 + 1);
ray.setFromCamera(ndc, camera);
- const hit = ray.intersectObjects(pickTargets, true)[0];
+ const hit = ray.intersectObjects(welt().pickTargets, true)[0];
return hit ? { x: hit.point.x, z: hit.point.z } : null;
};
@@ -228,7 +278,7 @@ async function main() {
for (const tor of tore) {
const d = Math.hypot(walker.position.x - tor.x, walker.position.z - tor.z);
if (d > 3) continue;
- const offen = tor.winkel - tor.obj.userData.zu > 0.8;
+ const offen = tor.winkel - tor.zu > 0.8;
tor.hand = !offen;
}
});
@@ -276,8 +326,45 @@ async function main() {
orbit.apply();
}, { passive: false });
+ // -- Umschalten zwischen den Darstellungen --------------------------------
+ let post: Post | null = null;
+ const loader = document.getElementById('loader')!;
+
+ 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 ?? []);
+ resize();
+ welt().lamps.setOn(env.isDark());
+ }
+
+ async function setAnsicht(q: Quality) {
+ if (q === ansicht) return;
+ if (!welten[q]) {
+ loader.classList.remove('done');
+ const neu = await baueWelt(q, progress);
+ welten[q] = neu;
+ for (const tor of tore) {
+ const obj = neu.propGroup.getObjectByName(tor.id);
+ if (obj?.userData.fluegel) tor.fluegel.push(obj.userData.fluegel);
+ }
+ await progress(100, 'Bereit.');
+ loader.classList.add('done');
+ }
+ scene.remove(welt().group);
+ ansicht = q;
+ scene.add(welt().group);
+ darstellung(q);
+ hud.setAnsicht(q);
+ try { localStorage.setItem('garten-ansicht', q); } catch { /* privat */ }
+ }
+
// -- Bedienoberflaeche ---------------------------------------------------
const hud = setupHud({
+ onAnsicht: (q) => { void setAnsicht(q); },
site,
onView: (v) => {
if (orbit.active) hud.setMode('walk');
@@ -304,11 +391,13 @@ 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();
}
window.addEventListener('resize', resize);
- resize();
+ darstellung(ansicht);
+ hud.setAnsicht(ansicht);
let last = performance.now();
let lastSun = { hours: -1, day: -1 };
@@ -320,7 +409,7 @@ async function main() {
const t = hud.time();
if (t.hours !== lastSun.hours || t.day !== lastSun.day) {
const sun = env.update(t.day, t.hours);
- lamps.setOn(env.isDark());
+ welt().lamps.setOn(env.isDark());
hud.setSunReadout(sun);
lastSun = t;
}
@@ -339,14 +428,16 @@ async function main() {
if (tor.hand !== null && d > 4) tor.hand = null;
let auf = tor.hand ?? (!orbit.active && d < 2.2);
if (d < 0.9) auf = true;
- const ziel = auf ? tor.obj.userData.offen : tor.obj.userData.zu;
+ const ziel = auf ? tor.offen : tor.zu;
const schritt = 2.4 * dt;
tor.winkel += Math.max(-schritt, Math.min(schritt, ziel - tor.winkel));
- tor.obj.userData.fluegel.rotation.y = tor.winkel;
- tor.obstacle.disabled = tor.winkel - tor.obj.userData.zu > 0.6;
+ for (const f of tor.fluegel) f.rotation.y = tor.winkel;
+ tor.obstacle.disabled = tor.winkel - tor.zu > 0.6;
}
- renderer.render(scene, camera);
+ wind.value = now / 1000;
+ if (ansicht === 'foto' && post) post.render();
+ else renderer.render(scene, camera);
requestAnimationFrame(frame);
}
@@ -360,6 +451,8 @@ async function main() {
(window as unknown as { garten: unknown }).garten = {
scene, camera, site, env, walker, orbit, hud, renderer, Group,
setOverview(on: boolean) { hud.setMode(on ? 'overview' : 'walk'); },
+ setAnsicht,
+ get post() { return post; },
};
}
diff --git a/src/scene/building.ts b/src/scene/building.ts
index f0c8e56..2ff93aa 100644
--- a/src/scene/building.ts
+++ b/src/scene/building.ts
@@ -14,6 +14,7 @@ import {
import type { Building, NeighbourBuilding, Opening, Vec2 } from '../site';
import { boxGeometry, mergeGeometries } from './geometry';
import * as tex from './textures';
+import { isFoto } from './quality';
/** Rechteck in Fassadenkoordinaten (u entlang der Wand, y in der Hoehe). */
interface Rect { u0: number; u1: number; y0: number; y1: number }
@@ -79,6 +80,15 @@ function faceGeometry(
}
function glassMaterial() {
+ if (isFoto()) {
+ // Isolierverglasung: dunkel, fast spiegelnd. Mit dem Himmelslicht zeigt sie
+ // Wolken und Garten - das macht Glas im Bild erst glaubhaft.
+ return new MeshPhysicalMaterial({
+ color: 0x0d1417, roughness: 0.02, metalness: 0.0,
+ opacity: 0.86, transparent: true, reflectivity: 1.0,
+ clearcoat: 1, clearcoatRoughness: 0.02, envMapIntensity: 1.6, side: 2,
+ });
+ }
return new MeshPhysicalMaterial({
color: 0x1d2a2e, roughness: 0.06, metalness: 0.1,
transmission: 0, opacity: 0.72, transparent: true,
@@ -94,6 +104,14 @@ export function buildBuilding(b: Building): Group {
const holz = tex.fassadeHolz();
const holzMat = new MeshStandardMaterial({ map: holz.map, roughness: holz.roughness, metalness: 0 });
+ if (isFoto()) {
+ // Vertikale Holzschalung mit Relief der Bretter und matter, rauer Oberflaeche
+ holzMat.normalMap = holz.normalMap ?? null;
+ if (holz.normalScale) holzMat.normalScale = holz.normalScale.clone().multiplyScalar(1.2);
+ holzMat.roughnessMap = holz.roughnessMap ?? null;
+ holzMat.aoMap = holz.aoMap ?? null;
+ holzMat.aoMapIntensity = 0.7;
+ }
const sockelTex = tex.sockel();
const sockelMat = new MeshStandardMaterial({ map: sockelTex.map, roughness: sockelTex.roughness });
const glasMat = glassMaterial();
diff --git a/src/scene/environment.ts b/src/scene/environment.ts
index 9bf3ba6..d485151 100644
--- a/src/scene/environment.ts
+++ b/src/scene/environment.ts
@@ -7,8 +7,11 @@
*/
import {
AmbientLight, BackSide, Color, DirectionalLight, Fog, Group, HemisphereLight,
- Mesh, ShaderMaterial, SphereGeometry, Scene, Vector3,
+ Mesh, PMREMGenerator, ShaderMaterial, SphereGeometry, Scene, Vector3,
+ type WebGLRenderer, type WebGLRenderTarget,
} from 'three';
+import { Sky } from 'three/examples/jsm/objects/Sky.js';
+import type { Quality } from './quality';
export interface SunState {
/** Hoehenwinkel in Grad, negativ wenn die Sonne unter dem Horizont steht. */
@@ -80,6 +83,7 @@ const SKY_FRAG = /* glsl */ `
uniform vec3 uGround;
uniform vec3 uSun;
uniform float uSunIntensity;
+ uniform float uAlpha;
varying vec3 vWorld;
void main() {
@@ -89,7 +93,7 @@ const SKY_FRAG = /* glsl */ `
sky = mix(uGround, sky, smoothstep(-0.10, 0.02, h));
float d = max(dot(dir, normalize(uSun)), 0.0);
sky += uSunIntensity * (pow(d, 900.0) * 6.0 + pow(d, 12.0) * 0.30 + pow(d, 3.0) * 0.06);
- gl_FragColor = vec4(sky, 1.0);
+ gl_FragColor = vec4(sky, uAlpha);
}
`;
@@ -100,9 +104,11 @@ export interface Environment {
update(dayOfYear: number, hours: number): SunState;
/** true, sobald die Gartenbeleuchtung eingeschaltet werden soll. */
isDark(): boolean;
+ /** Stilisierter Verlaufshimmel oder physikalischer Himmel mit Umgebungslicht. */
+ setQuality(q: Quality): void;
}
-export function buildEnvironment(scene: Scene, opts: {
+export function buildEnvironment(scene: Scene, renderer: WebGLRenderer, opts: {
lat: number; lon: number; northBearingOfSiteX: number; center: [number, number]; radius: number;
}): Environment {
const group = new Group();
@@ -115,7 +121,9 @@ export function buildEnvironment(scene: Scene, opts: {
uGround: { value: new Color(0x6f7a63) },
uSun: { value: new Vector3(0, 1, 0) },
uSunIntensity: { value: 1 },
+ uAlpha: { value: 1 },
},
+ transparent: true,
vertexShader: SKY_VERT,
fragmentShader: SKY_FRAG,
side: BackSide,
@@ -123,6 +131,10 @@ export function buildEnvironment(scene: Scene, opts: {
});
const sky = new Mesh(new SphereGeometry(900, 32, 20), skyMat);
sky.name = 'Himmel';
+ // Im Fotomodus liegt der Verlaufshimmel ueber dem physikalischen und wird
+ // in der Daemmerung eingeblendet: das Streumodell kennt kein Blau der
+ // blauen Stunde und wird nach Sonnenuntergang flach grau.
+ sky.renderOrder = -1;
group.add(sky);
const sun = new DirectionalLight(0xfff3e0, 2.6);
@@ -144,9 +156,63 @@ export function buildEnvironment(scene: Scene, opts: {
scene.fog = new Fog(0xc4d6e6, 90, 620);
+ // --- Fotorealistisch: physikalischer Himmel (Rayleigh- und Mie-Streuung) --
+ // Derselbe Himmel wird in eine Umgebungskarte gerechnet. Sie liefert das
+ // weiche Himmelslicht von allen Seiten und die Spiegelungen in Glas,
+ // Metall und poliertem Stein.
+ /**
+ * Der Sky-Shader liefert Leuchtdichten fuer eine Belichtung um 0.5. Der
+ * Garten ist auf eine hellere Belichtung abgestimmt - der Himmel wird
+ * deshalb im Shader selbst gedaempft, statt die ganze Szene abzudunkeln.
+ */
+ const daempfen = (s: Sky, hell: number) => {
+ s.material.uniforms.uHell = { value: hell };
+ s.material.fragmentShader = 'uniform float uHell;\n' + s.material.fragmentShader.replace(
+ 'gl_FragColor = vec4( retColor, 1.0 );', 'gl_FragColor = vec4( retColor * uHell, 1.0 );',
+ );
+ };
+ const skyFoto = new Sky();
+ daempfen(skyFoto, 0.62);
+ skyFoto.scale.setScalar(1000);
+ skyFoto.visible = false;
+ const skyU = skyFoto.material.uniforms;
+ skyU.turbidity.value = 4.2;
+ skyU.rayleigh.value = 1.25;
+ skyU.mieCoefficient.value = 0.0042;
+ skyU.mieDirectionalG.value = 0.8;
+ group.add(skyFoto);
+
+ const envScene = new Scene();
+ const envSky = new Sky();
+ daempfen(envSky, 1.0);
+ envSky.scale.setScalar(80);
+ const envU = envSky.material.uniforms;
+ envU.turbidity.value = 4.2;
+ envU.rayleigh.value = 1.25;
+ envU.mieCoefficient.value = 0.0042;
+ envU.mieDirectionalG.value = 0.8;
+ envScene.add(envSky);
+ // Ein dunkler, gruener Boden unter dem Horizont, sonst spiegelt Glas nach
+ // unten den Himmel.
+ const envBoden = new Mesh(
+ new SphereGeometry(60, 24, 12, 0, Math.PI * 2, Math.PI / 2 + 0.02, Math.PI / 2),
+ new ShaderMaterial({
+ uniforms: { uTone: { value: new Color(0x2c3322) } },
+ vertexShader: 'void main(){ gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }',
+ fragmentShader: 'uniform vec3 uTone; void main(){ gl_FragColor = vec4(uTone, 1.0); }',
+ side: BackSide,
+ }),
+ );
+ envScene.add(envBoden);
+ const pmrem = new PMREMGenerator(renderer);
+ let envTarget: WebGLRenderTarget | null = null;
+
+ let quality: Quality = 'stil';
+ let last = { day: 172, hours: 14 };
let dark = false;
function update(dayOfYear: number, hours: number): SunState {
+ last = { day: dayOfYear, hours };
// Sommerzeit grob: Ende Maerz bis Ende Oktober
const utcOffset = dayOfYear > 86 && dayOfYear < 303 ? 2 : 1;
const s = sunPosition(dayOfYear, hours, opts.lat, opts.lon, utcOffset, opts.northBearingOfSiteX);
@@ -181,8 +247,56 @@ export function buildEnvironment(scene: Scene, opts: {
(scene.fog as Fog).color.copy(hor);
+ if (quality === 'foto') {
+ const daemmerung = Math.max(0, Math.min(1, (4 - s.altitude) / 9));
+ skyMat.uniforms.uAlpha.value = daemmerung;
+ sky.visible = daemmerung > 0.001;
+ skyU.sunPosition.value.copy(s.direction);
+ envU.sunPosition.value.copy(s.direction);
+ (envBoden.material as ShaderMaterial).uniforms.uTone.value.setRGB(0.10, 0.12, 0.08).multiplyScalar(0.2 + dusk);
+ envTarget?.dispose();
+ envTarget = pmrem.fromScene(envScene, 0.02, 0.1, 200);
+ 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;
+ hemi.intensity = 0.03 + 0.08 * dusk;
+ ambient.intensity = 0.01;
+ sun.intensity = 3.6 * day;
+ const fog = scene.fog as Fog;
+ fog.near = 140;
+ fog.far = 900;
+ fog.color.setRGB(0.72, 0.80, 0.88).multiplyScalar(0.12 + 0.88 * dusk);
+ } else {
+ skyMat.uniforms.uAlpha.value = 1;
+ sky.visible = true;
+ const fog = scene.fog as Fog;
+ fog.near = 90;
+ fog.far = 620;
+ }
+
return s;
}
- return { group, sun, update, isDark: () => dark };
+ function setQuality(q: Quality) {
+ quality = q;
+ skyFoto.visible = q === 'foto';
+ if (q === 'stil') {
+ scene.environment = null;
+ scene.environmentIntensity = 1;
+ }
+ // Feinere Schatten im Fotomodus
+ const size = q === 'foto' ? 4096 : 2048;
+ if (sun.shadow.mapSize.x !== size) {
+ sun.shadow.mapSize.set(size, size);
+ sun.shadow.map?.dispose();
+ (sun.shadow as unknown as { map: null }).map = null;
+ }
+ sun.shadow.radius = q === 'foto' ? 2.5 : 1;
+ update(last.day, last.hours);
+ }
+
+ return { group, sun, update, isDark: () => dark, setQuality };
}
diff --git a/src/scene/gras.ts b/src/scene/gras.ts
new file mode 100644
index 0000000..d9c73f8
--- /dev/null
+++ b/src/scene/gras.ts
@@ -0,0 +1,147 @@
+/**
+ * Rasen aus Halmen fuer die fotorealistische Darstellung.
+ *
+ * Jede Instanz ist ein Buschel aus fuenf schmalen, sich verjuengenden Halmen.
+ * Tausende Buschel ergeben eine geschlossene Grasnarbe; ein Zeitwert im
+ * Vertex-Shader laesst die Spitzen leicht im Wind wiegen.
+ */
+import {
+ BufferAttribute, BufferGeometry, Color, DoubleSide, InstancedMesh, Matrix4,
+ MeshStandardMaterial, Quaternion, Vector3,
+} from 'three';
+import type { Vec2 } from '../site';
+
+/** Laufzeit in Sekunden, wird pro Bild gesetzt. */
+export const wind = { value: 0 };
+
+function rng(seed: number) {
+ let a = seed >>> 0;
+ return () => {
+ a = (a + 0x6d2b79f5) >>> 0;
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+function inPoly(x: number, z: number, poly: Vec2[]): boolean {
+ let inside = false;
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
+ const [xi, zi] = poly[i], [xj, zj] = poly[j];
+ if ((zi > z) !== (zj > z) && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi) inside = !inside;
+ }
+ return inside;
+}
+
+/** Ein Buschel: fuenf Halme, Hoehe 1, Fusspunkt im Ursprung. */
+function bueschel(seed: number): BufferGeometry {
+ const r = rng(seed);
+ const pos: number[] = [], nor: number[] = [], col: number[] = [];
+ for (let k = 0; k < 5; k++) {
+ const a = r() * Math.PI * 2;
+ const ox = Math.cos(a) * 0.012 * r(), oz = Math.sin(a) * 0.012 * r();
+ const lean = 0.15 + r() * 0.25, h = 0.7 + r() * 0.3, w = 0.0045 + r() * 0.002;
+ const dx = Math.cos(a), dz = Math.sin(a);
+ // Querrichtung des Halms
+ const px = -dz, pz = dx;
+ const pt = (f: number) => ({
+ x: ox + dx * lean * f * f * h, y: f * h, z: oz + dz * lean * f * f * h, w: w * (1 - f * 0.95),
+ });
+ const segs = [pt(0), pt(0.55), pt(1)];
+ for (let s = 0; s < 2; s++) {
+ const A = segs[s], B = segs[s + 1];
+ const quad = [
+ [A.x - px * A.w, A.y, A.z - pz * A.w, s / 2],
+ [A.x + px * A.w, A.y, A.z + pz * A.w, s / 2],
+ [B.x + px * B.w, B.y, B.z + pz * B.w, (s + 1) / 2],
+ [A.x - px * A.w, A.y, A.z - pz * A.w, s / 2],
+ [B.x + px * B.w, B.y, B.z + pz * B.w, (s + 1) / 2],
+ [B.x - px * B.w, B.y, B.z - pz * B.w, (s + 1) / 2],
+ ];
+ for (const [x, y, z, f] of quad) {
+ pos.push(x, y, z);
+ // Nach oben gerichtete Normalen: Gras wird wie eine Flaeche beleuchtet,
+ // sonst flimmern die Halme je nach Blickrichtung hell und dunkel.
+ nor.push(dx * 0.25, 1, dz * 0.25);
+ const v = 0.45 + f * 0.6;
+ col.push(v, v, v);
+ }
+ }
+ }
+ const g = new BufferGeometry();
+ g.setAttribute('position', new BufferAttribute(new Float32Array(pos), 3));
+ g.setAttribute('normal', new BufferAttribute(new Float32Array(nor), 3));
+ g.setAttribute('color', new BufferAttribute(new Float32Array(col), 3));
+ g.normalizeNormals();
+ return g;
+}
+
+export interface GrasArt {
+ /** Buschel pro Quadratmeter */
+ dichte: number;
+ hoehe: [number, number];
+ farbe: number;
+ streuung: number;
+ 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 };
+
+/** Streut Grasbueschel auf die Flaechen. */
+export function grasnarbe(
+ flaechen: Vec2[][], height: (x: number, z: number) => number, art: GrasArt, seed: number, name: string,
+): InstancedMesh | null {
+ const r = rng(seed);
+ const spacing = 1 / Math.sqrt(art.dichte);
+ const items: { x: number; z: number }[] = [];
+ 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 (!items.length) return null;
+ const geo = bueschel(seed);
+ const mat = new MeshStandardMaterial({
+ color: 0xffffff, vertexColors: true, roughness: 0.9, metalness: 0, side: DoubleSide,
+ });
+ mat.onBeforeCompile = (sh) => {
+ sh.uniforms.uWind = wind;
+ sh.vertexShader = sh.vertexShader
+ .replace('#include ', '#include \nuniform float uWind;')
+ .replace('#include ', `#include
+ #ifdef USE_INSTANCING
+ vec3 ip = instanceMatrix[3].xyz;
+ float h2 = position.y * position.y;
+ float boe = sin(uWind * 1.6 + ip.x * 0.7 + ip.z * 0.45) + 0.4 * sin(uWind * 3.1 + ip.z * 1.3);
+ transformed.x += boe * 0.09 * h2;
+ transformed.z += cos(uWind * 1.2 + ip.x * 0.5) * 0.05 * h2;
+ #endif`);
+ };
+ const mesh = new InstancedMesh(geo, mat, items.length);
+ 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;
+}
diff --git a/src/scene/laub.ts b/src/scene/laub.ts
new file mode 100644
index 0000000..5cf933b
--- /dev/null
+++ b/src/scene/laub.ts
@@ -0,0 +1,445 @@
+/**
+ * Laub fuer die fotorealistische Darstellung.
+ *
+ * Statt geschlossener Laubkoerper bestehen Kronen, Hecken und Straeucher hier
+ * aus vielen kleinen Blattkarten: Vierecke mit einer Textur aus einzelnen
+ * Blaettern und durchsichtigem Grund. Sie werden als InstancedMesh gezeichnet,
+ * damit auch zehntausende Karten fluessig bleiben.
+ *
+ * Damit eine Krone als Volumen und nicht als Haufen flacher Zettel wirkt,
+ * zeigt die Beleuchtungsnormale jeder Karte ueberwiegend vom Kronenmittelpunkt
+ * weg ("Volumen-Normalen"): die Sonnenseite leuchtet, die Schattenseite und
+ * das Innere werden dunkel - wie bei einem echten Baum.
+ */
+import {
+ BufferAttribute, BufferGeometry, CanvasTexture, Color, CylinderGeometry, DoubleSide,
+ Group, InstancedBufferAttribute, InstancedMesh, LinearMipmapLinearFilter, Matrix4, Mesh,
+ MeshDepthMaterial, MeshStandardMaterial, PlaneGeometry, Quaternion, RGBADepthPacking,
+ RepeatWrapping, SRGBColorSpace, ShaderChunk, Vector3,
+} from 'three';
+import type { Tree } from '../site';
+
+export type Blattart = 'baum' | 'apfel' | 'eibe' | 'liguster' | 'strauch' | 'kraut';
+
+function rng(seed: number) {
+ let a = seed >>> 0;
+ return () => {
+ a = (a + 0x9e3779b9) >>> 0;
+ let t = Math.imul(a ^ (a >>> 16), 0x21f0aaad);
+ t = Math.imul(t ^ (t >>> 15), 0x735a2d97);
+ return ((t ^ (t >>> 15)) >>> 0) / 4294967296;
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Blatttexturen
+// ---------------------------------------------------------------------------
+
+interface Art {
+ /** Grundfarbe und Streuung in HSL */
+ hue: number; sat: number; light: number; spread: number;
+ /** Blattlaenge und -breite in Pixeln (Textur 512) */
+ len: number; wid: number;
+ count: number;
+ /** Nadeln: schmal, zweizeilig an einem Zweig */
+ nadel?: boolean;
+ twig: string;
+}
+
+const ARTEN: Record = {
+ baum: { hue: 0.25, sat: 0.42, light: 0.30, spread: 0.07, len: 92, wid: 50, count: 26, twig: '#6d6a45' },
+ apfel: { hue: 0.24, sat: 0.40, light: 0.32, spread: 0.06, len: 84, wid: 42, count: 30, twig: '#6b6644' },
+ eibe: { hue: 0.27, sat: 0.42, light: 0.19, spread: 0.04, len: 46, wid: 7, count: 0, nadel: true, twig: '#4d5a30' },
+ liguster: { hue: 0.26, sat: 0.45, light: 0.22, spread: 0.05, len: 52, wid: 24, count: 60, twig: '#5a6236' },
+ strauch: { hue: 0.25, sat: 0.40, light: 0.27, spread: 0.07, len: 64, wid: 36, count: 34, twig: '#63633f' },
+ kraut: { hue: 0.23, sat: 0.30, light: 0.33, spread: 0.06, len: 40, wid: 18, count: 44, twig: '#5d5a3a' },
+};
+
+const texCache = new Map();
+
+/** Zeichnet ein einzelnes Blatt mit Mittelrippe und leichtem Glanzverlauf. */
+function blatt(ctx: CanvasRenderingContext2D, len: number, wid: number, col: Color) {
+ const g = ctx.createLinearGradient(-wid / 2, 0, wid / 2, 0);
+ const hell = col.clone().offsetHSL(0, -0.04, 0.07);
+ const dunkel = col.clone().offsetHSL(0, 0.02, -0.05);
+ g.addColorStop(0, `#${dunkel.getHexString()}`);
+ g.addColorStop(0.5, `#${hell.getHexString()}`);
+ g.addColorStop(1, `#${dunkel.getHexString()}`);
+ ctx.fillStyle = g;
+ ctx.beginPath();
+ ctx.moveTo(0, 0);
+ ctx.bezierCurveTo(wid * 0.62, len * 0.18, wid * 0.55, len * 0.78, 0, len);
+ ctx.bezierCurveTo(-wid * 0.55, len * 0.78, -wid * 0.62, len * 0.18, 0, 0);
+ ctx.fill();
+ ctx.strokeStyle = `rgba(230,240,190,0.28)`;
+ ctx.lineWidth = Math.max(1, wid * 0.05);
+ ctx.beginPath();
+ ctx.moveTo(0, len * 0.04);
+ ctx.lineTo(0, len * 0.92);
+ ctx.stroke();
+}
+
+function blattTextur(art: Blattart): CanvasTexture {
+ const hit = texCache.get(art);
+ if (hit) return hit;
+ const S = 512;
+ const c = document.createElement('canvas');
+ c.width = c.height = S;
+ const ctx = c.getContext('2d')!;
+ ctx.clearRect(0, 0, S, S);
+ const a = ARTEN[art];
+ const r = rng(art.length * 977 + 13);
+ const farbe = () => new Color().setHSL(
+ a.hue + (r() - 0.5) * a.spread,
+ a.sat + (r() - 0.5) * 0.12,
+ a.light + (r() - 0.5) * 0.10,
+ );
+
+ if (a.nadel) {
+ // Eibe: mehrere Zweiglein, zweizeilig mit kurzen, flachen Nadeln besetzt
+ for (let z = 0; z < 7; z++) {
+ const x0 = S * (0.18 + r() * 0.64), y0 = S * (0.1 + r() * 0.2);
+ const ang = (r() - 0.5) * 0.9;
+ const L = S * (0.55 + r() * 0.3);
+ ctx.save();
+ ctx.translate(x0, y0);
+ ctx.rotate(ang);
+ ctx.strokeStyle = a.twig;
+ ctx.lineWidth = 1.8;
+ ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, L); ctx.stroke();
+ for (let s = 8; s < L; s += 7) {
+ for (const side of [-1, 1]) {
+ ctx.save();
+ ctx.translate(0, s);
+ ctx.rotate(side * (1.1 + (r() - 0.5) * 0.3));
+ blatt(ctx, a.len * (0.75 + r() * 0.4) * (1 - s / L * 0.35), a.wid, farbe());
+ ctx.restore();
+ }
+ }
+ ctx.restore();
+ }
+ } else {
+ // Laubblaetter: Zweige, von denen die Blaetter wechselstaendig abgehen
+ const zweige = 5;
+ for (let z = 0; z < zweige; z++) {
+ const x0 = S * (0.5 + (r() - 0.5) * 0.3), y0 = S * 0.98;
+ const ang = -Math.PI / 2 + (z / (zweige - 1) - 0.5) * 1.8 + (r() - 0.5) * 0.3;
+ const L = S * (0.55 + r() * 0.3);
+ ctx.strokeStyle = a.twig;
+ ctx.lineWidth = 1.6;
+ ctx.beginPath();
+ ctx.moveTo(x0, y0);
+ ctx.lineTo(x0 + Math.cos(ang) * L, y0 + Math.sin(ang) * L);
+ ctx.stroke();
+ const n = Math.round(a.count / zweige);
+ for (let k = 0; k < n; k++) {
+ const t = 0.25 + (k / n) * 0.78;
+ const px = x0 + Math.cos(ang) * L * t, py = y0 + Math.sin(ang) * L * t;
+ ctx.save();
+ ctx.translate(px, py);
+ ctx.rotate(ang - Math.PI / 2 + (k % 2 ? 1 : -1) * (0.7 + r() * 0.5));
+ const sc = 0.75 + r() * 0.45;
+ blatt(ctx, a.len * sc, a.wid * sc, farbe());
+ ctx.restore();
+ }
+ }
+ }
+
+ const t = new CanvasTexture(c);
+ t.colorSpace = SRGBColorSpace;
+ t.anisotropy = 8;
+ t.minFilter = LinearMipmapLinearFilter;
+ t.generateMipmaps = true;
+ texCache.set(art, t);
+ return t;
+}
+
+// ---------------------------------------------------------------------------
+// Material mit Volumen-Normalen
+// ---------------------------------------------------------------------------
+
+const matCache = new Map();
+
+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,
+ });
+ mat.onBeforeCompile = (sh) => {
+ sh.vertexShader = sh.vertexShader
+ .replace('#include ', '#include \nattribute vec3 aCenter;')
+ .replace('#include ', `#include
+ #ifdef USE_INSTANCING
+ vec3 ip = (instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0)).xyz;
+ vec3 vn = normalize(ip - aCenter + vec3(0.0, 0.0001, 0.0));
+ vec3 cn = normalize(mat3(instanceMatrix) * objectNormal);
+ transformedNormal = normalize(normalMatrix * normalize(mix(cn, vn, 0.78)));
+ #endif`);
+ // Die Volumen-Normale gilt fuer beide Kartenseiten - nicht umdrehen.
+ sh.fragmentShader = sh.fragmentShader.replace(
+ '#include ',
+ ShaderChunk.normal_fragment_begin.replace('normal *= faceDirection;', ''),
+ );
+ };
+ const depth = new MeshDepthMaterial({ depthPacking: RGBADepthPacking, map, alphaTest: 0.45 });
+ const entry = { mat, depth };
+ matCache.set(art, entry);
+ return entry;
+}
+
+// ---------------------------------------------------------------------------
+// Karten setzen
+// ---------------------------------------------------------------------------
+
+export interface Blattpunkt {
+ p: Vector3;
+ /** grobe Aussenrichtung an dieser Stelle */
+ n: Vector3;
+ /** Mittelpunkt des Volumens, zu dem die Karte gehoert */
+ c: Vector3;
+ s: number;
+}
+
+const kartenGeo = (() => {
+ const g = new PlaneGeometry(1, 1);
+ g.translate(0, 0.5, 0);
+ return g;
+})();
+/** Mittig sitzende Karte - fuer Laub, das flach an einer Oberflaeche anliegt. */
+const kartenGeoMitte = new PlaneGeometry(1, 1);
+
+/** Baut aus Blattpunkten eine InstancedMesh aus Blattkarten. */
+export function blattkarten(
+ punkte: Blattpunkt[], art: Blattart, seed: number, name: string, flach = false,
+): InstancedMesh {
+ const { mat, depth } = laubMaterial(art);
+ const geo = (flach ? kartenGeoMitte : kartenGeo).clone();
+ const achse = flach ? new Vector3(0, 0, 1) : new Vector3(0, 1, 0);
+ const centers = new Float32Array(punkte.length * 3);
+ const mesh = new InstancedMesh(geo, mat, punkte.length);
+ const r = rng(seed);
+ const m = new Matrix4();
+ const q = new Quaternion();
+ const q2 = new Quaternion();
+ const sc = new Vector3();
+ const tint = new Color();
+ punkte.forEach((b, i) => {
+ // Kronenlaub: Karte grob nach aussen gerichtet, mit kraeftiger Neigung.
+ // Heckenlaub: Karte liegt flach an, nur leicht verkippt - eine
+ // geschnittene Hecke hat eine geschlossene Oberflaeche ohne Fransen.
+ const streu = flach ? 0.55 : 1.4;
+ const dir = b.n.clone().add(new Vector3(r() - 0.5, r() - 0.3, r() - 0.5).multiplyScalar(streu)).normalize();
+ q.setFromUnitVectors(achse, dir);
+ q2.setFromAxisAngle(dir, r() * Math.PI * 2);
+ q.premultiply(q2);
+ sc.setScalar(b.s * (0.8 + r() * 0.4));
+ m.compose(b.p, q, sc);
+ mesh.setMatrixAt(i, m);
+ centers[i * 3] = b.c.x; centers[i * 3 + 1] = b.c.y; centers[i * 3 + 2] = b.c.z;
+ // leichte Farbstreuung je Karte
+ const v = 0.86 + r() * 0.26;
+ tint.setRGB(v, v * (0.97 + r() * 0.06), v * (0.9 + r() * 0.1));
+ mesh.setColorAt(i, tint);
+ });
+ geo.setAttribute('aCenter', new InstancedBufferAttribute(centers, 3));
+ mesh.instanceMatrix.needsUpdate = true;
+ if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
+ mesh.customDepthMaterial = depth;
+ mesh.castShadow = true;
+ mesh.receiveShadow = true;
+ mesh.name = name;
+ mesh.userData.laub = true;
+ return mesh;
+}
+
+/**
+ * Tastet die Oberflaeche einer Geometrie flaechengewichtet ab - so bekommt
+ * eine Hecke gleichmaessig dicht Blaetter, egal wie ihr Netz aufgebaut ist.
+ */
+export function oberflaechenPunkte(
+ geo: BufferGeometry, dichte: number, seed: number, center: (p: Vector3) => Vector3,
+ groesse: number, abstand = 0.05,
+): Blattpunkt[] {
+ const g = geo.index ? geo.toNonIndexed() : geo;
+ const pos = g.getAttribute('position') as BufferAttribute;
+ const r = rng(seed);
+ const out: Blattpunkt[] = [];
+ const a = new Vector3(), b = new Vector3(), c = new Vector3();
+ const e1 = new Vector3(), e2 = new Vector3(), n = new Vector3();
+ let rest = 0;
+ for (let i = 0; i < pos.count; i += 3) {
+ a.fromBufferAttribute(pos, i);
+ b.fromBufferAttribute(pos, i + 1);
+ c.fromBufferAttribute(pos, i + 2);
+ e1.subVectors(b, a); e2.subVectors(c, a);
+ n.crossVectors(e1, e2);
+ const area = n.length() / 2;
+ if (area < 1e-6) continue;
+ n.normalize();
+ rest += area * dichte;
+ while (rest >= 1) {
+ rest -= 1;
+ let u = r(), v = r();
+ if (u + v > 1) { u = 1 - u; v = 1 - v; }
+ const p = a.clone().addScaledVector(e1, u).addScaledVector(e2, v);
+ p.addScaledVector(n, (r() - 0.35) * abstand);
+ out.push({ p, n: n.clone(), c: center(p), s: groesse });
+ }
+ }
+ return out;
+}
+
+// ---------------------------------------------------------------------------
+// Baum mit Astgeruest
+// ---------------------------------------------------------------------------
+
+let rindeMat: MeshStandardMaterial | null = null;
+function rinde(): MeshStandardMaterial {
+ if (rindeMat) return rindeMat;
+ const S = 256;
+ const c = document.createElement('canvas');
+ c.width = c.height = S;
+ const ctx = c.getContext('2d')!;
+ ctx.fillStyle = '#8d8474';
+ ctx.fillRect(0, 0, S, S);
+ const r = rng(77);
+ // Parrotia: glatte, graubraune Rinde, die in Flecken abblaettert
+ for (let i = 0; i < 260; i++) {
+ const v = 90 + r() * 70;
+ ctx.fillStyle = `rgba(${v + 10 | 0},${v | 0},${v - 18 | 0},0.5)`;
+ ctx.beginPath();
+ ctx.ellipse(r() * S, r() * S, 4 + r() * 14, 8 + r() * 26, 0, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ for (let i = 0; i < 90; i++) {
+ ctx.strokeStyle = `rgba(40,34,28,${0.15 + r() * 0.2})`;
+ ctx.lineWidth = 1 + r() * 1.5;
+ const x = r() * S;
+ ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x + (r() - 0.5) * 10, S); ctx.stroke();
+ }
+ const t = new CanvasTexture(c);
+ t.colorSpace = SRGBColorSpace;
+ t.wrapS = t.wrapT = RepeatWrapping;
+ rindeMat = new MeshStandardMaterial({ map: t, roughness: 0.88 });
+ return rindeMat;
+}
+
+/** Ast als verjuengter Zylinder von a nach b. */
+function ast(a: Vector3, b: Vector3, r0: number, r1: number): BufferGeometry {
+ const len = a.distanceTo(b);
+ const g = new CylinderGeometry(r1, r0, len, 7, 1, true);
+ g.translate(0, len / 2, 0);
+ const dir = b.clone().sub(a).normalize();
+ g.applyQuaternion(new Quaternion().setFromUnitVectors(new Vector3(0, 1, 0), dir));
+ g.translate(a.x, a.y, a.z);
+ return g.toNonIndexed();
+}
+
+function merge(parts: BufferGeometry[]): BufferGeometry {
+ let n = 0;
+ for (const p of parts) n += p.getAttribute('position').count;
+ const pos = new Float32Array(n * 3), nor = new Float32Array(n * 3), uv = new Float32Array(n * 2);
+ let o = 0;
+ for (const p of parts) {
+ pos.set(p.getAttribute('position').array as Float32Array, o * 3);
+ nor.set(p.getAttribute('normal').array as Float32Array, o * 3);
+ uv.set(p.getAttribute('uv').array as Float32Array, o * 2);
+ o += p.getAttribute('position').count;
+ }
+ const g = new BufferGeometry();
+ g.setAttribute('position', new BufferAttribute(pos, 3));
+ g.setAttribute('normal', new BufferAttribute(nor, 3));
+ g.setAttribute('uv', new BufferAttribute(uv, 2));
+ return g;
+}
+
+/**
+ * Baum aus Staemmen, Aesten und Zweigen, rekursiv gewachsen: jeder Ast ist
+ * leicht gekruemmt, verjuengt sich und teilt sich in zwei bis drei Aeste.
+ * An den Zweigspitzen sitzen die Blattwolken. Hoehe und Kronenbreite
+ * entsprechen der stilisierten Variante, damit Krone und Schatten gleich liegen.
+ *
+ * Parrotia persica waechst mehrstaemmig mit breiter, tief ansetzender Krone;
+ * der Apfelbaum hat einen Stamm und eine rundere Krone.
+ */
+export function fotoBaum(t: Tree, y: number): Group {
+ const g = new Group();
+ g.name = t.name;
+ const r = rng(Math.round((t.x + 100) * 37 + (t.z + 100) * 91));
+ const crownR = Math.max(0.9, t.crown);
+ const parrotia = t.species === 'parrotia';
+ const holz: BufferGeometry[] = [];
+ const wolken: { c: Vector3; r: number }[] = [];
+ const fuss = new Vector3(t.x, y - 0.05, t.z);
+ const top = y + t.height;
+
+ /** Waechst einen Ast in drei gekruemmten Abschnitten und verzweigt ihn. */
+ const wachse = (start: Vector3, dir: Vector3, len: number, r0: number, tiefe: number) => {
+ let p = start.clone();
+ const d = dir.clone().normalize();
+ const seg = 3;
+ for (let s = 0; s < seg; s++) {
+ // leichte Kruemmung, mit einer Neigung zum Licht
+ d.add(new Vector3((r() - 0.5) * 0.35, 0.10 + (r() - 0.5) * 0.2, (r() - 0.5) * 0.35)).normalize();
+ const q = p.clone().addScaledVector(d, len / seg);
+ if (q.y > top - 0.15) q.y = top - 0.15 - r() * 0.2;
+ const ra = r0 * (1 - (s / seg) * 0.45), rb = r0 * (1 - ((s + 1) / seg) * 0.45);
+ holz.push(ast(p, q, ra, rb));
+ p = q;
+ // Seitenzweig aus der Mitte eines Asts
+ if (tiefe > 0 && s === 1 && r() < 0.7) {
+ const seite = new Vector3(r() - 0.5, 0.2 + r() * 0.3, r() - 0.5).normalize();
+ wachse(p, d.clone().lerp(seite, 0.65), len * 0.5, rb * 0.6, tiefe - 1);
+ }
+ }
+ if (tiefe <= 0) {
+ wolken.push({ c: p, r: crownR * (0.22 + r() * 0.1) });
+ return;
+ }
+ const n = 2 + (r() < 0.45 ? 1 : 0);
+ for (let k = 0; k < n; k++) {
+ const a = r() * Math.PI * 2;
+ const aussen = new Vector3(Math.cos(a), 0.35 + r() * 0.5, Math.sin(a)).normalize();
+ wachse(p, d.clone().lerp(aussen, 0.55), len * (0.62 + r() * 0.12), r0 * 0.55, tiefe - 1);
+ }
+ };
+
+ const stems = parrotia ? 3 : 1;
+ const hoch = t.height - crownR * 0.2;
+ for (let s = 0; s < stems; s++) {
+ const a = (s / stems) * Math.PI * 2 + r() * 0.8;
+ const neig = parrotia ? 0.35 + r() * 0.15 : 0.05;
+ const dir = new Vector3(Math.cos(a) * neig, 1, Math.sin(a) * neig);
+ const stamm = parrotia ? hoch * 0.38 : hoch * 0.45;
+ wachse(fuss, dir, stamm, parrotia ? 0.07 : 0.11, 3);
+ }
+ const stammMesh = new Mesh(merge(holz), rinde());
+ stammMesh.castShadow = true;
+ stammMesh.receiveShadow = true;
+ g.add(stammMesh);
+
+ // Blattwolken um die Zweigspitzen. Dichter aussen, lockerer innen - so
+ // bleibt die Silhouette unruhig und es faellt Licht durch die Krone.
+ let cx = 0, cy = 0, cz = 0;
+ 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));
+ 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);
+ const l2 = d.lengthSq();
+ if (l2 > 1 || l2 < 1e-4) { i--; continue; }
+ d.normalize();
+ 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 });
+ }
+ }
+ g.add(blattkarten(punkte, t.species === 'apfel' ? 'apfel' : 'baum', Math.round(t.x * 13 + t.z * 7), `${t.name} – Laub`));
+ return g;
+}
diff --git a/src/scene/post.ts b/src/scene/post.ts
new file mode 100644
index 0000000..64ae7df
--- /dev/null
+++ b/src/scene/post.ts
@@ -0,0 +1,67 @@
+/**
+ * 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
new file mode 100644
index 0000000..817cd91
--- /dev/null
+++ b/src/scene/quality.ts
@@ -0,0 +1,16 @@
+/**
+ * Darstellungsqualitaet. Die Szene gibt es zweimal aus derselben Geometrie:
+ * stilisiert (leicht, auch fuers Handy) und fotorealistisch (hoch aufgeloeste
+ * Materialien, echtes Laub und Gras, Himmelslicht und Nachbearbeitung).
+ *
+ * Die Bauteile fragen beim Aufbau `isFoto()` ab. Der Schalter wird vor dem
+ * Aufbau einer Variante gesetzt - beide Varianten bleiben danach bestehen,
+ * damit man ohne Wartezeit hin und her wechseln kann.
+ */
+export type Quality = 'stil' | 'foto';
+
+let current: Quality = 'stil';
+
+export function quality(): Quality { return current; }
+export function setQuality(q: Quality) { current = q; }
+export function isFoto(): boolean { return current === 'foto'; }
diff --git a/src/scene/structures.ts b/src/scene/structures.ts
index 467882a..cdab042 100644
--- a/src/scene/structures.ts
+++ b/src/scene/structures.ts
@@ -17,6 +17,9 @@ function material(belag: tex.Belag, extra: MeshStandardMaterialParameters = {})
map: belag.map,
normalMap: belag.normalMap ?? null,
normalScale: belag.normalScale,
+ roughnessMap: belag.roughnessMap ?? null,
+ aoMap: belag.aoMap ?? null,
+ aoMapIntensity: 0.8,
roughness: belag.roughness,
metalness: 0,
...extra,
diff --git a/src/scene/surfaces.ts b/src/scene/surfaces.ts
index 3c41ba7..83b0696 100644
--- a/src/scene/surfaces.ts
+++ b/src/scene/surfaces.ts
@@ -20,6 +20,8 @@ import {
surfaceEdgeGeometry, surfaceGeometryMulti,
} from './geometry';
import * as tex from './textures';
+import { isFoto } from './quality';
+import { grasnarbe, RASEN, SCHOTTERRASEN } from './gras';
interface BelagDef {
belag: () => tex.Belag;
@@ -60,6 +62,9 @@ function material(belag: tex.Belag, extra: Record = {}) {
map: belag.map,
normalMap: belag.normalMap ?? null,
normalScale: belag.normalScale,
+ roughnessMap: belag.roughnessMap ?? null,
+ aoMap: belag.aoMap ?? null,
+ aoMapIntensity: 0.8,
roughness: belag.roughness,
metalness: 0,
...extra,
@@ -146,6 +151,14 @@ export function buildSurfaces(
group.add(band);
}
+ // --- Fotorealistisch: Grasnarbe aus Halmen ------------------------------
+ if (isFoto()) {
+ const rasen = grasnarbe(surfacePolygons(surfaces, 'rasen'), height, RASEN, 31, 'Rasen – Halme');
+ if (rasen) group.add(rasen);
+ const schotter = grasnarbe(surfacePolygons(surfaces, 'schotterwiese'), height, SCHOTTERRASEN, 57, 'Schotterwiese – Halme');
+ if (schotter) group.add(schotter);
+ }
+
// --- Einzeln verlegte Travertinplatten 40x60x3 cm ------------------------
if (slabs.length) {
const belag = tex.travertinSlab();
diff --git a/src/scene/terrain.ts b/src/scene/terrain.ts
index c4e8b96..211c313 100644
--- a/src/scene/terrain.ts
+++ b/src/scene/terrain.ts
@@ -7,6 +7,52 @@ import {
} from 'three';
import type { Terrain } from '../site';
import { rohboden } from './textures';
+import { isFoto } from './quality';
+
+/** Wertrauschen im Shader, fuer grossflaechige Farbwechsel der Wiese. */
+const NOISE_GLSL = /* glsl */ `
+ float hash21(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
+ float vnoise(vec2 p) {
+ vec2 i = floor(p), f = fract(p);
+ vec2 u = f * f * (3.0 - 2.0 * f);
+ return mix(mix(hash21(i), hash21(i + vec2(1, 0)), u.x), mix(hash21(i + vec2(0, 1)), hash21(i + vec2(1, 1)), u.x), u.y);
+ }
+`;
+
+/**
+ * Wiese fuer die fotorealistische Darstellung. Die Kachel wird in zwei
+ * Massstaeben gemischt und grossflaechig in Farbe und Helligkeit variiert -
+ * sonst erkennt man das Wiederholungsmuster sofort, sobald man von oben
+ * auf das Umland schaut.
+ */
+function wieseFoto(): MeshStandardMaterial {
+ const belag = rohboden();
+ const mat = new MeshStandardMaterial({
+ map: belag.map, roughness: 1, metalness: 0, roughnessMap: belag.roughnessMap ?? null,
+ color: 0xc4c9ae, side: DoubleSide,
+ });
+ mat.onBeforeCompile = (sh) => {
+ sh.vertexShader = sh.vertexShader
+ .replace('#include ', '#include \nvarying vec3 vWelt;')
+ .replace('#include ', '#include \nvWelt = (modelMatrix * vec4(transformed, 1.0)).xyz;');
+ sh.fragmentShader = sh.fragmentShader
+ .replace('#include ', '#include \nvarying vec3 vWelt;' + NOISE_GLSL)
+ .replace('#include ', `
+ #ifdef USE_MAP
+ vec4 kA = texture2D(map, vMapUv);
+ vec4 kB = texture2D(map, vMapUv * 0.29 + vec2(0.37, 0.11));
+ float mischung = vnoise(vWelt.xz * 0.09);
+ diffuseColor *= mix(kA, kB, smoothstep(0.25, 0.75, mischung));
+ #endif
+ float gross = vnoise(vWelt.xz * 0.035) * 0.6 + vnoise(vWelt.xz * 0.16) * 0.4;
+ diffuseColor.rgb *= mix(0.80, 1.10, gross);
+ // trockenere, gelblichere Stellen
+ float trocken = smoothstep(0.62, 0.85, vnoise(vWelt.xz * 0.021 + 7.3));
+ diffuseColor.rgb = mix(diffuseColor.rgb, diffuseColor.rgb * vec3(1.12, 1.05, 0.78), trocken * 0.7);
+ `);
+ };
+ return mat;
+}
export function buildTerrain(terrain: Terrain): Group {
const group = new Group();
@@ -42,30 +88,35 @@ export function buildTerrain(terrain: Terrain): Group {
geo.setIndex(idx);
geo.computeVertexNormals();
- const ground = new Mesh(geo, new MeshStandardMaterial({
+ const foto = isFoto();
+ const wiese = foto ? wieseFoto() : null;
+ const ground = new Mesh(geo, wiese ?? new MeshStandardMaterial({
map: belag.map, roughness: belag.roughness, metalness: 0,
}));
ground.name = 'Terrain';
ground.receiveShadow = true;
group.add(ground);
+
// --- Umland ---------------------------------------------------------------
// Das Grundstueck liegt an einem Hang ueber dem Tal. Das Umland wird als
// Kranz direkt an die Randpunkte des Rasters gehaengt - so entsteht keine
// sichtbare Kante - und faellt gegen aussen ab, vor allem nach Suedwesten.
- const wiese = rohboden();
+ const umland = rohboden();
const cx = x0 + ((nx - 1) * cell) / 2;
const cz = z0 + ((nz - 1) * cell) / 2;
/**
- * Randpunkte des Rasters im Umlauf, bewusst grob abgetastet: bei jedem
- * Rasterpunkt entstuenden extrem schlanke Dreiecke, deren Normalen
- * unbrauchbar werden und sich als dunkle Streifen abzeichnen.
+ * Randpunkte des Rasters im Umlauf - jeder einzelne. Nur so schliesst der
+ * Kranz luecklos an; mit groeber Abtastung blieb zwischen Rand und Kranz ein
+ * Spalt, durch den man von oben als helle Linie den Himmel sah. Die dabei
+ * entstehenden schlanken Dreiecke bekommen weiter unten ruhige Normalen,
+ * sonst zeichnen sie sich als dunkle Streifen ab.
*/
- const STEP = 10;
+ const STEP = 1;
const border: { x: number; z: number; y: number }[] = [];
const at = (i: number, j: number) => ({
- x: x0 + i * cell, z: z0 + j * cell, y: h[j * nx + i] - 0.05,
+ x: x0 + i * cell, z: z0 + j * cell, y: h[j * nx + i],
});
for (let i = 0; i < nx - 1; i += STEP) border.push(at(i, 0));
for (let j = 0; j < nz - 1; j += STEP) border.push(at(nx - 1, j));
@@ -91,7 +142,7 @@ export function buildTerrain(terrain: Terrain): Group {
// Nach Suedwesten faellt das Gelaende deutlich staerker ab (Blick ins Tal).
const sw = (1 - Math.cos(Math.atan2(dz, dx) - 2.4)) / 2;
ringPos.push(x, p.y - t * (6 + 26 * sw * sw), z);
- ringUv.push(x / wiese.tile, -z / wiese.tile);
+ ringUv.push(x / umland.tile, -z / umland.tile);
}
}
for (let k = 0; k < RINGS; k++) {
@@ -106,8 +157,21 @@ export function buildTerrain(terrain: Terrain): Group {
far.setAttribute('uv', new BufferAttribute(new Float32Array(ringUv), 2));
far.setIndex(ringIdx);
far.computeVertexNormals();
- const surroundings = new Mesh(far, new MeshStandardMaterial({
- map: wiese.map, roughness: 1, metalness: 0, color: 0xa9b096, side: DoubleSide,
+ // Ruhige Normalen: das Umland ist ein weicher Hang, die Beleuchtung soll
+ // nicht die Dreiecksstruktur nachzeichnen.
+ const fn = far.getAttribute('normal') as BufferAttribute;
+ // Der Kranz ist so gewickelt, dass man von oben seine Rueckseite sieht
+ // (DoubleSide dreht die Normale dort um) - also zur selben Seite hin glaetten.
+ let sum = 0;
+ for (let i = 0; i < fn.count; i++) sum += fn.getY(i);
+ const oben = sum >= 0 ? 1 : -1;
+ for (let i = 0; i < fn.count; i++) {
+ const nx2 = fn.getX(i) * 0.35, ny2 = fn.getY(i) * 0.35 + 0.65 * oben, nz2 = fn.getZ(i) * 0.35;
+ const l = Math.hypot(nx2, ny2, nz2) || 1;
+ fn.setXYZ(i, nx2 / l, ny2 / l, nz2 / l);
+ }
+ const surroundings = new Mesh(far, wiese ?? new MeshStandardMaterial({
+ map: umland.map, roughness: 1, metalness: 0, color: 0xa9b096, side: DoubleSide,
}));
surroundings.name = 'Umland';
group.add(surroundings);
diff --git a/src/scene/textures.ts b/src/scene/textures.ts
index d4df267..b345591 100644
--- a/src/scene/textures.ts
+++ b/src/scene/textures.ts
@@ -10,8 +10,19 @@ import {
CanvasTexture, RepeatWrapping, SRGBColorSpace, LinearMipmapLinearFilter,
LinearSRGBColorSpace, Vector2,
} from 'three';
+import { isFoto } from './quality';
+/**
+ * Die Zeichenfunktionen rechnen mit einer logischen Kantenlaenge von SIZE.
+ * Fuer die fotorealistische Variante wird dasselbe Motiv auf eine doppelt so
+ * grosse Leinwand gezeichnet (PHYS), der Kontext skaliert mit. So bleiben
+ * Holzscheite, Kiesel und Poren gleich gross, werden aber schaerfer - und die
+ * Pixeloperationen (Rauschen, Normalen) arbeiten mit der vollen Aufloesung.
+ */
const SIZE = 512;
+let PHYS = 512;
+const sizeFor = () => (isFoto() ? 1024 : 512);
+const keyFor = (key: string) => (isFoto() ? key + ':foto' : key);
/** Deterministischer Zufall, damit jeder Aufbau gleich aussieht. */
function mulberry32(seed: number) {
@@ -26,9 +37,10 @@ function mulberry32(seed: number) {
function canvas(): [HTMLCanvasElement, CanvasRenderingContext2D] {
const c = document.createElement('canvas');
- c.width = SIZE;
- c.height = SIZE;
+ c.width = PHYS;
+ c.height = PHYS;
const ctx = c.getContext('2d', { willReadFrequently: true })!;
+ ctx.setTransform(PHYS / SIZE, 0, 0, PHYS / SIZE, 0, 0);
return [c, ctx];
}
@@ -38,17 +50,18 @@ function noiseTile(ctx: CanvasRenderingContext2D, seed: number, cells: number, a
const g: number[] = [];
for (let i = 0; i < cells * cells; i++) g.push(rnd());
const at = (i: number, j: number) => g[((j + cells) % cells) * cells + ((i + cells) % cells)];
- const img = ctx.getImageData(0, 0, SIZE, SIZE);
- const step = SIZE / cells;
- for (let y = 0; y < SIZE; y++) {
+ const N = ctx.canvas.width;
+ const img = ctx.getImageData(0, 0, N, N);
+ const step = N / cells;
+ for (let y = 0; y < N; y++) {
const fy = y / step, j = Math.floor(fy), ty = fy - j;
- for (let x = 0; x < SIZE; x++) {
+ for (let x = 0; x < N; x++) {
const fx = x / step, i = Math.floor(fx), tx = fx - i;
const sx = tx * tx * (3 - 2 * tx), sy = ty * ty * (3 - 2 * ty);
const v =
at(i, j) * (1 - sx) * (1 - sy) + at(i + 1, j) * sx * (1 - sy) +
at(i, j + 1) * (1 - sx) * sy + at(i + 1, j + 1) * sx * sy;
- const k = (y * SIZE + x) * 4;
+ const k = (y * N + x) * 4;
const d = (dark ? -1 : 1) * (v - 0.5) * 2 * alpha * 255;
img.data[k] = Math.max(0, Math.min(255, img.data[k] + d));
img.data[k + 1] = Math.max(0, Math.min(255, img.data[k + 1] + d));
@@ -103,6 +116,9 @@ function slabs(
export interface Belag {
map: CanvasTexture;
+ /** Nur fotorealistisch: Rauheit und Fugenschatten aus dem Hoehenbild. */
+ roughnessMap?: CanvasTexture;
+ aoMap?: CanvasTexture;
/** Aus dem Fugenbild abgeleitete Normalmap - gibt den Belaegen Tiefe. */
normalMap?: CanvasTexture;
normalScale?: Vector2;
@@ -114,37 +130,71 @@ export interface Belag {
const cache = new Map();
function make(key: string, tile: number, roughness: number, draw: (ctx: CanvasRenderingContext2D) => void): Belag {
- const hit = cache.get(key);
+ const k = keyFor(key);
+ const hit = cache.get(k);
if (hit) return hit;
+ PHYS = sizeFor();
const [c, ctx] = canvas();
draw(ctx);
const belag: Belag = { map: texture(c), tile, roughness };
- cache.set(key, belag);
+ if (isFoto()) {
+ // Ohne eigenes Hoehenbild: Rauheit aus der Helligkeit ableiten - dunkle
+ // Stellen (Erde, Fugen, Schatten im Kies) sind matter.
+ belag.roughnessMap = derivedMap(c, (v) => 0.82 + (1 - v) * 0.3);
+ }
+ cache.set(k, belag);
return belag;
}
+/**
+ * Leitet aus einem Bild eine lineare Graustufenkarte ab (fuer Rauheit oder
+ * Umgebungsverdeckung). `f` bekommt die Helligkeit 0..1 und liefert 0..1.
+ */
+function derivedMap(src: HTMLCanvasElement, f: (v: number) => number): CanvasTexture {
+ const sctx = src.getContext('2d', { willReadFrequently: true })!;
+ const d = sctx.getImageData(0, 0, PHYS, PHYS).data;
+ const [c, ctx] = canvas();
+ const out = ctx.createImageData(PHYS, PHYS);
+ for (let i = 0; i < d.length; i += 4) {
+ const v = (d[i] * 0.3 + d[i + 1] * 0.59 + d[i + 2] * 0.11) / 255;
+ const g = Math.max(0, Math.min(255, f(v) * 255));
+ out.data[i] = out.data[i + 1] = out.data[i + 2] = g;
+ out.data[i + 3] = 255;
+ }
+ ctx.putImageData(out, 0, 0);
+ const t = new CanvasTexture(c);
+ t.wrapS = t.wrapT = RepeatWrapping;
+ t.colorSpace = LinearSRGBColorSpace;
+ t.anisotropy = 8;
+ t.minFilter = LinearMipmapLinearFilter;
+ return t;
+}
+
/**
* Rechnet ein Hoehenbild in eine Normalmap um (Sobel, kachelnd).
* Damit bekommen Fugen und Steinkanten echte Tiefe im Streiflicht.
*/
function normalFromHeight(src: HTMLCanvasElement, strength: number): CanvasTexture {
const sctx = src.getContext('2d', { willReadFrequently: true })!;
- const h = sctx.getImageData(0, 0, SIZE, SIZE).data;
+ const N = PHYS;
+ const h = sctx.getImageData(0, 0, N, N).data;
const [c, ctx] = canvas();
- const out = ctx.createImageData(SIZE, SIZE);
+ const out = ctx.createImageData(N, N);
const at = (x: number, y: number) =>
- h[(((y + SIZE) % SIZE) * SIZE + ((x + SIZE) % SIZE)) * 4] / 255;
- for (let y = 0; y < SIZE; y++) {
- for (let x = 0; x < SIZE; x++) {
+ h[(((y + N) % N) * N + ((x + N) % N)) * 4] / 255;
+ for (let y = 0; y < N; y++) {
+ for (let x = 0; x < N; x++) {
const dx =
at(x - 1, y - 1) + 2 * at(x - 1, y) + at(x - 1, y + 1) -
at(x + 1, y - 1) - 2 * at(x + 1, y) - at(x + 1, y + 1);
const dy =
at(x - 1, y - 1) + 2 * at(x, y - 1) + at(x + 1, y - 1) -
at(x - 1, y + 1) - 2 * at(x, y + 1) - at(x + 1, y + 1);
- const nx = dx * strength, ny = dy * strength, nz = 1;
+ // Leinwand-y laeuft nach unten, Textur-v nach oben: die y-Steigung muss
+ // umgedreht werden, sonst stehen Poren und Fugen als Buckel hervor.
+ const nx = dx * strength, ny = -dy * strength, nz = 1;
const len = Math.hypot(nx, ny, nz);
- const k = (y * SIZE + x) * 4;
+ const k = (y * N + x) * 4;
out.data[k] = ((nx / len) * 0.5 + 0.5) * 255;
out.data[k + 1] = ((ny / len) * 0.5 + 0.5) * 255;
out.data[k + 2] = ((nz / len) * 0.5 + 0.5) * 255;
@@ -169,20 +219,29 @@ function makeRelief(
draw: (ctx: CanvasRenderingContext2D) => void,
drawHeight: (ctx: CanvasRenderingContext2D) => void,
): Belag {
- const hit = cache.get(key);
+ const k = keyFor(key);
+ const hit = cache.get(k);
if (hit) return hit;
+ PHYS = sizeFor();
const [c, ctx] = canvas();
draw(ctx);
const [hc, hctx] = canvas();
drawHeight(hctx);
const belag: Belag = {
map: texture(c),
- normalMap: normalFromHeight(hc, normalStrength),
+ // Bei doppelter Aufloesung ist ein Pixel halb so gross - die Steigung im
+ // Hoehenbild halbiert sich, die Staerke wird entsprechend angehoben.
+ normalMap: normalFromHeight(hc, normalStrength * (PHYS / SIZE)),
normalScale: new Vector2(1, 1),
tile,
roughness,
};
- cache.set(key, belag);
+ if (isFoto()) {
+ // Fugen und Vertiefungen sind rauer und liegen im Schatten.
+ belag.roughnessMap = derivedMap(hc, (h) => 0.78 + (1 - h) * 0.4);
+ belag.aoMap = derivedMap(hc, (h) => 0.35 + Math.min(1, h * 1.25) * 0.65);
+ }
+ cache.set(k, belag);
return belag;
}
diff --git a/src/scene/vegetation.ts b/src/scene/vegetation.ts
index 1875d35..915441c 100644
--- a/src/scene/vegetation.ts
+++ b/src/scene/vegetation.ts
@@ -12,6 +12,32 @@ import {
} from 'three';
import type { Plant, Tree, Vec2 } from '../site';
import * as tex from './textures';
+import { isFoto } from './quality';
+import { blattkarten, fotoBaum, oberflaechenPunkte, type Blattart } from './laub';
+
+/** Dunkler Kern unter den Blattkarten: das Innere einer dichten Pflanze. */
+const KERN = () => {
+ const l = tex.laubEibe();
+ return new MeshStandardMaterial({
+ map: l.map, normalMap: l.normalMap ?? null, normalScale: l.normalScale,
+ color: 0x6f7f64, roughness: 1, metalness: 0,
+ });
+};
+
+/**
+ * Fotorealistisch: Blattkarten auf die Oberflaeche eines Laubkoerpers setzen.
+ * Die Lichtnormale zeigt dabei von der Koerperachse weg (`tiefe` hinter der
+ * 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,
+): InstancedMesh {
+ koerper.updateMatrixWorld(true);
+ const geo = koerper.geometry.clone().applyMatrix4(koerper.matrixWorld);
+ const punkte = oberflaechenPunkte(geo, 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);
+}
function rng(seed: number) {
let a = seed >>> 0;
@@ -424,7 +450,8 @@ export function buildVegetation(
const group = new Group();
group.name = 'Bepflanzung';
- for (const t of trees) group.add(buildTree(t, height(t.x, t.z)));
+ const foto = isFoto();
+ for (const t of trees) group.add(foto ? fotoBaum(t, height(t.x, t.z)) : buildTree(t, height(t.x, t.z)));
const r = rng(4242);
@@ -447,22 +474,37 @@ export function buildVegetation(
const [mx, mz] = row.reduce((a, p) => [a[0] + p[0] / row.length, a[1] + p[1] / row.length], [0, 0]);
const h = hedgeHeight(mx, mz);
if (row.length >= 2) {
- group.add(hedgeMesh(row, h, 0.55, 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);
+ if (foto) group.add(belauben(hecke, 'eibe', 340, 0.14, Math.round(mx * 31 + mz * 17), 0.25, true));
} else {
// Einzelstand: als schmales, aufrechtes Polster
const [x, z] = row[0];
- const geo = blob(0.28, 2, 0.05, 3, h / 0.56);
- const m = new Mesh(geo, eibeMat);
+ const geo = blob(foto ? 0.25 : 0.28, 2, 0.05, 3, h / 0.56);
+ const m = new Mesh(geo, foto ? KERN() : eibeMat);
+ m.name = 'Eibe';
m.position.set(x, height(x, z) + h / 2, z);
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));
}
}
// --- Beerenstraeucher und Heidelbeeren ----------------------------------
const straeucher = plants.filter((p) => p.type === 'strauch' && p.x !== undefined);
- const strauchMesh = instances(blob(1, 2, 0.06, 7, 0.8), foliage(LAUB.strauch), straeucher.map((p) => {
+ if (foto) {
+ for (const p of straeucher) {
+ const s = (p.r ?? 0.35) * 1.05;
+ const m = new Mesh(blob(s, 2, 0.08, Math.round(p.x! * 7), 0.8), KERN());
+ m.name = 'Beerenstrauch';
+ 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));
+ }
+ }
+ const strauchMesh = foto ? null : instances(blob(1, 2, 0.06, 7, 0.8), foliage(LAUB.strauch), straeucher.map((p) => {
const s = (p.r ?? 0.35) * 1.1;
return { x: p.x!, y: height(p.x!, p.z!) + s * 0.75, z: p.z!, s, rot: r() * 6.28 };
}), 'Beerensträucher');
@@ -502,7 +544,17 @@ export function buildVegetation(
// --- Kraeuter und Stauden -----------------------------------------------
const kraeuter = plants.filter((p) => p.type === 'kraut' && p.x !== undefined);
- const krautMesh = instances(blob(1, 2, 0.10, 11, 0.7), foliage(LAUB.kraut, 0.98), kraeuter.map((p) => {
+ if (foto) {
+ for (const p of kraeuter) {
+ const rr = Math.max(0.10, (p.r ?? 0.14) * 0.85);
+ const m = new Mesh(blob(rr, 2, 0.1, Math.round(p.z! * 7), 0.7), KERN());
+ 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));
+ }
+ }
+ const krautMesh = foto ? null : instances(blob(1, 2, 0.10, 11, 0.7), foliage(LAUB.kraut, 0.98), kraeuter.map((p) => {
const rr = Math.max(0.10, (p.r ?? 0.14) * 0.85);
return { x: p.x!, y: height(p.x!, p.z!) + rr * 0.5, z: p.z!, s: rr, rot: r() * 6.28 };
}), 'Kräuter');
@@ -524,7 +576,9 @@ export function buildVegetation(
const along: Vec2[] = (x1 - x0) > (z1 - z0)
? [[x0, (z0 + z1) / 2], [x1, (z0 + z1) / 2]]
: [[(x0 + x1) / 2, z0], [(x0 + x1) / 2, z1]];
- group.add(hedgeMesh(along, p.height ?? 1.7, p.width ?? 0.75, 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);
+ if (foto) group.add(belauben(hecke, 'liguster', 160, 0.18, 911, 0.3, true));
}
return group;
diff --git a/src/scene/walls.ts b/src/scene/walls.ts
index 42dc4bb..a21c57b 100644
--- a/src/scene/walls.ts
+++ b/src/scene/walls.ts
@@ -26,6 +26,9 @@ function material(belag: tex.Belag, extra: MeshStandardMaterialParameters = {})
map: belag.map,
normalMap: belag.normalMap ?? null,
normalScale: belag.normalScale,
+ roughnessMap: belag.roughnessMap ?? null,
+ aoMap: belag.aoMap ?? null,
+ aoMapIntensity: 0.8,
roughness: belag.roughness,
metalness: 0,
...extra,
diff --git a/src/style.css b/src/style.css
index 1d282b6..bd02457 100644
--- a/src/style.css
+++ b/src/style.css
@@ -74,6 +74,32 @@ html, body {
.topbar-actions { display: flex; gap: 0.5rem; }
+/* Umschalter Stilisiert / Fotorealistisch: eine Pille mit zwei Haelften */
+#ansicht {
+ position: absolute;
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ padding: 3px;
+ border-radius: 999px;
+ border: 1px solid rgba(255, 255, 255, 0.28);
+ background: rgba(14, 20, 12, 0.45);
+ backdrop-filter: blur(6px);
+ pointer-events: auto;
+}
+#hud #ansicht button {
+ border: 0;
+ background: transparent;
+ padding: 0.36rem 0.95rem;
+}
+#hud #ansicht button:hover { background: rgba(255, 255, 255, 0.10); }
+#hud #ansicht button[aria-checked="true"] { background: var(--accent); color: #fff; }
+@media (max-width: 760px) {
+ #ansicht { position: static; transform: none; }
+ #topbar { flex-wrap: wrap; }
+ .brand { display: none; }
+}
+
#hud button {
font: inherit;
font-size: 0.78rem;
diff --git a/src/ui/hud.ts b/src/ui/hud.ts
index ad83f8c..12e6922 100644
--- a/src/ui/hud.ts
+++ b/src/ui/hud.ts
@@ -4,9 +4,12 @@
import type { SiteData, StartView } from '../site';
import type { SunState } from '../scene/environment';
import { isTouch } from '../controls/walker';
+import type { Quality } from '../scene/quality';
export interface HudOptions {
site: SiteData;
+ /** Wechsel zwischen stilisierter und fotorealistischer Darstellung */
+ onAnsicht(q: Quality): void;
onView(view: StartView): void;
onMode(mode: 'walk' | 'overview'): void;
onJoystick(x: number, y: number, active: boolean): void;
@@ -17,6 +20,7 @@ export interface Hud {
setMode(mode: 'walk' | 'overview'): void;
setPointerLocked(locked: boolean): void;
setSunReadout(sun: SunState): void;
+ setAnsicht(q: Quality): void;
}
const MONTHS = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
@@ -63,6 +67,15 @@ export function setupHud(opts: HudOptions): Hud {
}
nav.querySelector('button')?.setAttribute('aria-pressed', 'true');
+ // -- Darstellung ---------------------------------------------------------
+ const ansichtButtons = Array.from(document.querySelectorAll('#ansicht button'));
+ for (const btn of ansichtButtons) {
+ btn.addEventListener('click', () => opts.onAnsicht(btn.dataset.ansicht as Quality));
+ }
+ const setAnsicht = (q: Quality) => {
+ for (const btn of ansichtButtons) btn.setAttribute('aria-checked', String(btn.dataset.ansicht === q));
+ };
+
// -- Tageszeit -----------------------------------------------------------
const syncTime = () => {
timeOut.value = timeLabel(+timeInput.value);
@@ -149,6 +162,13 @@ export function setupHud(opts: HudOptions): Hud {
(Ost- und Westzugang), Geländer und Entwässerungsrinnen
+
Zwei Darstellungen
+
Oben in der Mitte schaltet man zwischen Stilisiert und
+ Fotorealistisch um. Beide zeigen dasselbe Modell mit denselben
+ Massen. Die fotorealistische Ansicht rechnet mit hoch aufgelösten Materialien,
+ einzelnen Blättern und Grashalmen, physikalischem Himmelslicht und
+ Umgebungsverdeckung – sie braucht einen leistungsfähigeren Rechner.
+
Bedienung
Rundgang: auf den Boden klicken, dann läuft man dorthin. Mit der Maus ziehen
@@ -211,6 +231,7 @@ export function setupHud(opts: HudOptions): Hud {
day: +seasonInput.value,
}),
setMode,
+ setAnsicht,
setPointerLocked(locked) {
crosshair.classList.toggle('on', locked);
if (locked) hint.classList.add('fade');