Fotorealistische Darstellung mit Umschalter
Deploy App / deploy (push) Successful in 29s

Oben in der Mitte schaltet ein Knopf zwischen Stilisiert und
Fotorealistisch. Beide Varianten entstehen aus derselben Geometrie; die
zweite wird beim ersten Umschalten aufgebaut und bleibt danach bestehen.

Fotorealistisch:
- Texturen doppelt so fein, mit Rauheits- und Verdeckungskarten aus dem
  Hoehenbild. Das Motiv wird skaliert auf die groessere Leinwand gezeichnet,
  damit Scheite, Kiesel und Poren gleich gross bleiben.
- Laub aus Blattkarten (Baumlaub, Eibennadeln, Liguster, Straeucher,
  Kraeuter) mit Volumen-Normalen und Alpha-Test-Schatten.
- Baeume rekursiv gewachsen, mit gekruemmten Aesten und Blattwolken.
- Rasen und Schotterwiese aus Grasbueschelen, die im Wind wiegen.
- Physikalischer Himmel mit Umgebungslicht, Sonne zu Himmel etwa 5:1,
  Verlaufshimmel in der Daemmerung, 4096er Schatten.
- GTAO, leichtes Bloom, MSAA.
- Wiese ohne sichtbares Kachelmuster.

Nebenbei behoben:
- Normalmaps hatten die y-Steigung verkehrt: Poren und Fugen standen als
  Buckel hervor. Betrifft auch die stilisierte Ansicht.
- Umlandkranz schliesst jetzt luecklos an das Raster an; vorher sah man
  von oben als helle Linie den Himmel durch den Spalt.
- Glas im Fotomodus fast spiegelnd, Holzfassade mit Relief.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 11:15:22 +02:00
co-authored by Claude Opus 5
parent 1ac99b1660
commit 62f4d72355
17 changed files with 1247 additions and 78 deletions
+22
View File
@@ -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 eine geschlossene Wand. Ohne sie schaut man von aussen durch die Fassade in den
Sitzplatz hinein. 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 ### 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
+4
View File
@@ -25,6 +25,10 @@
<strong>Garten Egglihaldenstrasse&nbsp;9a</strong> <strong>Garten Egglihaldenstrasse&nbsp;9a</strong>
<span>Abtwil SG &middot; Gartenplan 1, Garten Design AG</span> <span>Abtwil SG &middot; Gartenplan 1, Garten Design AG</span>
</div> </div>
<div id="ansicht" role="radiogroup" aria-label="Darstellung">
<button type="button" role="radio" data-ansicht="stil" title="Leichte, stilisierte Darstellung">Stilisiert</button>
<button type="button" role="radio" data-ansicht="foto" title="Hoch aufgelöste Materialien, echtes Laub und Gras, Himmelslicht">Fotorealistisch</button>
</div>
<div class="topbar-actions"> <div class="topbar-actions">
<button id="btn-mode" type="button" title="Zwischen Rundgang und Übersicht wechseln">Übersicht</button> <button id="btn-mode" type="button" title="Zwischen Rundgang und Übersicht wechseln">Übersicht</button>
<button id="btn-info" type="button" title="Informationen zum Plan">Info</button> <button id="btn-info" type="button" title="Informationen zum Plan">Info</button>
+131 -38
View File
@@ -6,21 +6,24 @@
* Projektplaenen von EHLE Architekten. Siehe tools/ fuer die Datenpipeline. * Projektplaenen von EHLE Architekten. Siehe tools/ fuer die Datenpipeline.
*/ */
import { import {
ACESFilmicToneMapping, Group, Mesh, MeshBasicMaterial, PCFSoftShadowMap, PerspectiveCamera, ACESFilmicToneMapping, Group, Mesh, MeshBasicMaterial, PCFSoftShadowMap,
Raycaster, RingGeometry, Scene, SRGBColorSpace, Vector2, Vector3, WebGLRenderer, PerspectiveCamera, Raycaster, RingGeometry, Scene, SRGBColorSpace, Vector2, Vector3, WebGLRenderer,
type Object3D, type Object3D,
} from 'three'; } from 'three';
import './style.css'; import './style.css';
import { loadSite, makeHeightSampler, type SiteData } from './site'; import { loadSite, makeHeightSampler, type SiteData } from './site';
import { buildTerrain } from './scene/terrain'; import { buildTerrain } from './scene/terrain';
import { buildSurfaces } from './scene/surfaces'; 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 { buildWalls } from './scene/walls';
import { buildVegetation } from './scene/vegetation'; import { buildVegetation } from './scene/vegetation';
import { buildBuilding, buildNeighbourBuildings } from './scene/building'; 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 { buildPost, type Post } from './scene/post';
import { wind } from './scene/gras';
const CENTER: [number, number] = [-9.5, -1.0]; const CENTER: [number, number] = [-9.5, -1.0];
const BOUNDS = { x0: -24.0, z0: -12.5, x1: 7.5, z1: 8.5 }; 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 site: SiteData = await loadSite();
const height = makeHeightSampler(site.terrain); const height = makeHeightSampler(site.terrain);
await progress(22, 'Gelände wird modelliert …'); // -- Zwei Darstellungen aus derselben Geometrie --------------------------
const terrainGroup = buildTerrain(site.terrain); // Stilisiert und fotorealistisch teilen Plan, Masse und Rundgang; nur
scene.add(terrainGroup); // 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<Record<Quality, Welt>> = {};
type Melde = (pct: number, text: string) => Promise<void>;
await progress(38, 'Beläge werden verlegt …'); async function baueWelt(q: Quality, melde: Melde): Promise<Welt> {
const surfaceGroup = buildSurfaces(site.surfaces, site.slabs, height); setQuality(q);
scene.add(surfaceGroup); const foto = q === 'foto';
const group = new Group();
group.name = foto ? 'Fotorealistisch' : 'Stilisiert';
await progress(52, 'Mauern und Bauteile …'); await melde(22, foto ? 'Fotorealistisches Gelände …' : 'Gelände wird modelliert …');
const wallGroup = buildWalls(site.walls, height); const terrainGroup = buildTerrain(site.terrain);
scene.add(wallGroup); group.add(terrainGroup);
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 progress(66, 'Haus und Nachbarschaft …'); await melde(38, foto ? 'Beläge und Rasen in hoher Auflösung …' : 'Beläge werden verlegt …');
scene.add(buildBuilding(site.building)); const surfaceGroup = buildSurfaces(site.surfaces, site.slabs, height);
scene.add(buildNeighbourBuildings(site.neighbourBuildings)); group.add(surfaceGroup);
await progress(80, 'Bepflanzung wird gesetzt …'); await melde(52, 'Mauern und Bauteile …');
scene.add(buildVegetation(site.plants, site.trees, height)); 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 …'); await melde(66, 'Haus und Nachbarschaft …');
const lamps = buildLamps(site.lamps, height); group.add(buildBuilding(site.building));
scene.add(lamps.group); group.add(buildNeighbourBuildings(site.neighbourBuildings));
const env = buildEnvironment(scene, {
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, lat: site.meta.location.lat,
lon: site.meta.location.lon, lon: site.meta.location.lon,
northBearingOfSiteX: site.meta.location.northBearingOfSiteX, northBearingOfSiteX: site.meta.location.northBearingOfSiteX,
@@ -161,14 +204,22 @@ async function main() {
} }
} }
// Tore merken wir uns: ihr Hindernis faellt weg, solange sie offen stehen. // 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) { for (const p of site.props) {
// Stufen sind begehbar, kein Hindernis - sie stehen in `tritte`. // Stufen sind begehbar, kein Hindernis - sie stehen in `tritte`.
if (['schacht', 'wasserhahn', 'stufe', 'torbogen'].includes(p.type)) continue; 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 }; 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); obstacles.push(o);
const obj = p.type === 'tor' ? propGroup.getObjectByName(p.id) : undefined; const obj = p.type === 'tor' ? welt().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 }); 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) { for (const pl of site.plants) {
if (pl.type !== 'hecke' || !pl.polygon) continue; if (pl.type !== 'hecke' || !pl.polygon) continue;
@@ -196,12 +247,11 @@ async function main() {
const ray = new Raycaster(); const ray = new Raycaster();
ray.far = 70; ray.far = 70;
const ndc = new Vector2(); const ndc = new Vector2();
const pickTargets = [terrainGroup, surfaceGroup, wallGroup, linearGroup, propGroup];
const pick = (clientX: number, clientY: number) => { const pick = (clientX: number, clientY: number) => {
const r = canvas.getBoundingClientRect(); const r = canvas.getBoundingClientRect();
ndc.set(((clientX - r.left) / r.width) * 2 - 1, -((clientY - r.top) / r.height) * 2 + 1); ndc.set(((clientX - r.left) / r.width) * 2 - 1, -((clientY - r.top) / r.height) * 2 + 1);
ray.setFromCamera(ndc, camera); 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; return hit ? { x: hit.point.x, z: hit.point.z } : null;
}; };
@@ -228,7 +278,7 @@ async function main() {
for (const tor of tore) { for (const tor of tore) {
const d = Math.hypot(walker.position.x - tor.x, walker.position.z - tor.z); const d = Math.hypot(walker.position.x - tor.x, walker.position.z - tor.z);
if (d > 3) continue; 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; tor.hand = !offen;
} }
}); });
@@ -276,8 +326,45 @@ async function main() {
orbit.apply(); orbit.apply();
}, { passive: false }); }, { 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 --------------------------------------------------- // -- Bedienoberflaeche ---------------------------------------------------
const hud = setupHud({ const hud = setupHud({
onAnsicht: (q) => { void setAnsicht(q); },
site, site,
onView: (v) => { onView: (v) => {
if (orbit.active) hud.setMode('walk'); if (orbit.active) hud.setMode('walk');
@@ -304,11 +391,13 @@ 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();
} }
window.addEventListener('resize', resize); window.addEventListener('resize', resize);
resize(); darstellung(ansicht);
hud.setAnsicht(ansicht);
let last = performance.now(); let last = performance.now();
let lastSun = { hours: -1, day: -1 }; let lastSun = { hours: -1, day: -1 };
@@ -320,7 +409,7 @@ async function main() {
const t = hud.time(); const t = hud.time();
if (t.hours !== lastSun.hours || t.day !== lastSun.day) { if (t.hours !== lastSun.hours || t.day !== lastSun.day) {
const sun = env.update(t.day, t.hours); const sun = env.update(t.day, t.hours);
lamps.setOn(env.isDark()); welt().lamps.setOn(env.isDark());
hud.setSunReadout(sun); hud.setSunReadout(sun);
lastSun = t; lastSun = t;
} }
@@ -339,14 +428,16 @@ async function main() {
if (tor.hand !== null && d > 4) tor.hand = null; if (tor.hand !== null && d > 4) tor.hand = null;
let auf = tor.hand ?? (!orbit.active && d < 2.2); let auf = tor.hand ?? (!orbit.active && d < 2.2);
if (d < 0.9) auf = true; 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; const schritt = 2.4 * dt;
tor.winkel += Math.max(-schritt, Math.min(schritt, ziel - tor.winkel)); tor.winkel += Math.max(-schritt, Math.min(schritt, ziel - tor.winkel));
tor.obj.userData.fluegel.rotation.y = tor.winkel; for (const f of tor.fluegel) f.rotation.y = tor.winkel;
tor.obstacle.disabled = tor.winkel - tor.obj.userData.zu > 0.6; 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); requestAnimationFrame(frame);
} }
@@ -360,6 +451,8 @@ async function main() {
(window as unknown as { garten: unknown }).garten = { (window as unknown as { garten: unknown }).garten = {
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,
get post() { return post; },
}; };
} }
+18
View File
@@ -14,6 +14,7 @@ import {
import type { Building, NeighbourBuilding, Opening, Vec2 } from '../site'; import type { Building, NeighbourBuilding, Opening, Vec2 } from '../site';
import { boxGeometry, mergeGeometries } from './geometry'; import { boxGeometry, mergeGeometries } from './geometry';
import * as tex from './textures'; import * as tex from './textures';
import { isFoto } from './quality';
/** Rechteck in Fassadenkoordinaten (u entlang der Wand, y in der Hoehe). */ /** Rechteck in Fassadenkoordinaten (u entlang der Wand, y in der Hoehe). */
interface Rect { u0: number; u1: number; y0: number; y1: number } interface Rect { u0: number; u1: number; y0: number; y1: number }
@@ -79,6 +80,15 @@ function faceGeometry(
} }
function glassMaterial() { 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({ return new MeshPhysicalMaterial({
color: 0x1d2a2e, roughness: 0.06, metalness: 0.1, color: 0x1d2a2e, roughness: 0.06, metalness: 0.1,
transmission: 0, opacity: 0.72, transparent: true, transmission: 0, opacity: 0.72, transparent: true,
@@ -94,6 +104,14 @@ export function buildBuilding(b: Building): Group {
const holz = tex.fassadeHolz(); const holz = tex.fassadeHolz();
const holzMat = new MeshStandardMaterial({ map: holz.map, roughness: holz.roughness, metalness: 0 }); 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 sockelTex = tex.sockel();
const sockelMat = new MeshStandardMaterial({ map: sockelTex.map, roughness: sockelTex.roughness }); const sockelMat = new MeshStandardMaterial({ map: sockelTex.map, roughness: sockelTex.roughness });
const glasMat = glassMaterial(); const glasMat = glassMaterial();
+118 -4
View File
@@ -7,8 +7,11 @@
*/ */
import { import {
AmbientLight, BackSide, Color, DirectionalLight, Fog, Group, HemisphereLight, AmbientLight, BackSide, Color, DirectionalLight, Fog, Group, HemisphereLight,
Mesh, ShaderMaterial, SphereGeometry, Scene, Vector3, Mesh, PMREMGenerator, ShaderMaterial, SphereGeometry, Scene, Vector3,
type WebGLRenderer, type WebGLRenderTarget,
} from 'three'; } from 'three';
import { Sky } from 'three/examples/jsm/objects/Sky.js';
import 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. */
@@ -80,6 +83,7 @@ const SKY_FRAG = /* glsl */ `
uniform vec3 uGround; uniform vec3 uGround;
uniform vec3 uSun; uniform vec3 uSun;
uniform float uSunIntensity; uniform float uSunIntensity;
uniform float uAlpha;
varying vec3 vWorld; varying vec3 vWorld;
void main() { void main() {
@@ -89,7 +93,7 @@ const SKY_FRAG = /* glsl */ `
sky = mix(uGround, sky, smoothstep(-0.10, 0.02, h)); sky = mix(uGround, sky, smoothstep(-0.10, 0.02, h));
float d = max(dot(dir, normalize(uSun)), 0.0); 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); 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; update(dayOfYear: number, hours: number): SunState;
/** true, sobald die Gartenbeleuchtung eingeschaltet werden soll. */ /** true, sobald die Gartenbeleuchtung eingeschaltet werden soll. */
isDark(): boolean; 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; lat: number; lon: number; northBearingOfSiteX: number; center: [number, number]; radius: number;
}): Environment { }): Environment {
const group = new Group(); const group = new Group();
@@ -115,7 +121,9 @@ export function buildEnvironment(scene: Scene, opts: {
uGround: { value: new Color(0x6f7a63) }, uGround: { value: new Color(0x6f7a63) },
uSun: { value: new Vector3(0, 1, 0) }, uSun: { value: new Vector3(0, 1, 0) },
uSunIntensity: { value: 1 }, uSunIntensity: { value: 1 },
uAlpha: { value: 1 },
}, },
transparent: true,
vertexShader: SKY_VERT, vertexShader: SKY_VERT,
fragmentShader: SKY_FRAG, fragmentShader: SKY_FRAG,
side: BackSide, side: BackSide,
@@ -123,6 +131,10 @@ export function buildEnvironment(scene: Scene, opts: {
}); });
const sky = new Mesh(new SphereGeometry(900, 32, 20), skyMat); const sky = new Mesh(new SphereGeometry(900, 32, 20), skyMat);
sky.name = 'Himmel'; 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); group.add(sky);
const sun = new DirectionalLight(0xfff3e0, 2.6); const sun = new DirectionalLight(0xfff3e0, 2.6);
@@ -144,9 +156,63 @@ export function buildEnvironment(scene: Scene, opts: {
scene.fog = new Fog(0xc4d6e6, 90, 620); 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; let dark = false;
function update(dayOfYear: number, hours: number): SunState { function update(dayOfYear: number, hours: number): SunState {
last = { day: dayOfYear, hours };
// Sommerzeit grob: Ende Maerz bis Ende Oktober // Sommerzeit grob: Ende Maerz bis Ende Oktober
const utcOffset = dayOfYear > 86 && dayOfYear < 303 ? 2 : 1; const utcOffset = dayOfYear > 86 && dayOfYear < 303 ? 2 : 1;
const s = sunPosition(dayOfYear, hours, opts.lat, opts.lon, utcOffset, opts.northBearingOfSiteX); 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); (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 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 };
} }
+147
View File
@@ -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 <common>', '#include <common>\nuniform float uWind;')
.replace('#include <begin_vertex>', `#include <begin_vertex>
#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;
}
+445
View File
@@ -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<Blattart, Art> = {
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<Blattart, CanvasTexture>();
/** 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<Blattart, { mat: MeshStandardMaterial; depth: MeshDepthMaterial }>();
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 <common>', '#include <common>\nattribute vec3 aCenter;')
.replace('#include <defaultnormal_vertex>', `#include <defaultnormal_vertex>
#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 <normal_fragment_begin>',
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;
}
+67
View File
@@ -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);
},
};
}
+16
View File
@@ -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'; }
+3
View File
@@ -17,6 +17,9 @@ function material(belag: tex.Belag, extra: MeshStandardMaterialParameters = {})
map: belag.map, map: belag.map,
normalMap: belag.normalMap ?? null, normalMap: belag.normalMap ?? null,
normalScale: belag.normalScale, normalScale: belag.normalScale,
roughnessMap: belag.roughnessMap ?? null,
aoMap: belag.aoMap ?? null,
aoMapIntensity: 0.8,
roughness: belag.roughness, roughness: belag.roughness,
metalness: 0, metalness: 0,
...extra, ...extra,
+13
View File
@@ -20,6 +20,8 @@ import {
surfaceEdgeGeometry, surfaceGeometryMulti, surfaceEdgeGeometry, surfaceGeometryMulti,
} from './geometry'; } from './geometry';
import * as tex from './textures'; import * as tex from './textures';
import { isFoto } from './quality';
import { grasnarbe, RASEN, SCHOTTERRASEN } from './gras';
interface BelagDef { interface BelagDef {
belag: () => tex.Belag; belag: () => tex.Belag;
@@ -60,6 +62,9 @@ function material(belag: tex.Belag, extra: Record<string, unknown> = {}) {
map: belag.map, map: belag.map,
normalMap: belag.normalMap ?? null, normalMap: belag.normalMap ?? null,
normalScale: belag.normalScale, normalScale: belag.normalScale,
roughnessMap: belag.roughnessMap ?? null,
aoMap: belag.aoMap ?? null,
aoMapIntensity: 0.8,
roughness: belag.roughness, roughness: belag.roughness,
metalness: 0, metalness: 0,
...extra, ...extra,
@@ -146,6 +151,14 @@ export function buildSurfaces(
group.add(band); 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 ------------------------ // --- Einzeln verlegte Travertinplatten 40x60x3 cm ------------------------
if (slabs.length) { if (slabs.length) {
const belag = tex.travertinSlab(); const belag = tex.travertinSlab();
+74 -10
View File
@@ -7,6 +7,52 @@ import {
} from 'three'; } from 'three';
import type { Terrain } from '../site'; import type { Terrain } from '../site';
import { rohboden } from './textures'; 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 <common>', '#include <common>\nvarying vec3 vWelt;')
.replace('#include <begin_vertex>', '#include <begin_vertex>\nvWelt = (modelMatrix * vec4(transformed, 1.0)).xyz;');
sh.fragmentShader = sh.fragmentShader
.replace('#include <common>', '#include <common>\nvarying vec3 vWelt;' + NOISE_GLSL)
.replace('#include <map_fragment>', `
#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 { export function buildTerrain(terrain: Terrain): Group {
const group = new Group(); const group = new Group();
@@ -42,30 +88,35 @@ export function buildTerrain(terrain: Terrain): Group {
geo.setIndex(idx); geo.setIndex(idx);
geo.computeVertexNormals(); 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, map: belag.map, roughness: belag.roughness, metalness: 0,
})); }));
ground.name = 'Terrain'; ground.name = 'Terrain';
ground.receiveShadow = true; ground.receiveShadow = true;
group.add(ground); group.add(ground);
// --- Umland --------------------------------------------------------------- // --- Umland ---------------------------------------------------------------
// Das Grundstueck liegt an einem Hang ueber dem Tal. Das Umland wird als // Das Grundstueck liegt an einem Hang ueber dem Tal. Das Umland wird als
// Kranz direkt an die Randpunkte des Rasters gehaengt - so entsteht keine // Kranz direkt an die Randpunkte des Rasters gehaengt - so entsteht keine
// sichtbare Kante - und faellt gegen aussen ab, vor allem nach Suedwesten. // 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 cx = x0 + ((nx - 1) * cell) / 2;
const cz = z0 + ((nz - 1) * cell) / 2; const cz = z0 + ((nz - 1) * cell) / 2;
/** /**
* Randpunkte des Rasters im Umlauf, bewusst grob abgetastet: bei jedem * Randpunkte des Rasters im Umlauf - jeder einzelne. Nur so schliesst der
* Rasterpunkt entstuenden extrem schlanke Dreiecke, deren Normalen * Kranz luecklos an; mit groeber Abtastung blieb zwischen Rand und Kranz ein
* unbrauchbar werden und sich als dunkle Streifen abzeichnen. * 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 border: { x: number; z: number; y: number }[] = [];
const at = (i: number, j: 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 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)); 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). // Nach Suedwesten faellt das Gelaende deutlich staerker ab (Blick ins Tal).
const sw = (1 - Math.cos(Math.atan2(dz, dx) - 2.4)) / 2; const sw = (1 - Math.cos(Math.atan2(dz, dx) - 2.4)) / 2;
ringPos.push(x, p.y - t * (6 + 26 * sw * sw), z); 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++) { 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.setAttribute('uv', new BufferAttribute(new Float32Array(ringUv), 2));
far.setIndex(ringIdx); far.setIndex(ringIdx);
far.computeVertexNormals(); far.computeVertexNormals();
const surroundings = new Mesh(far, new MeshStandardMaterial({ // Ruhige Normalen: das Umland ist ein weicher Hang, die Beleuchtung soll
map: wiese.map, roughness: 1, metalness: 0, color: 0xa9b096, side: DoubleSide, // 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'; surroundings.name = 'Umland';
group.add(surroundings); group.add(surroundings);
+78 -19
View File
@@ -10,8 +10,19 @@ import {
CanvasTexture, RepeatWrapping, SRGBColorSpace, LinearMipmapLinearFilter, CanvasTexture, RepeatWrapping, SRGBColorSpace, LinearMipmapLinearFilter,
LinearSRGBColorSpace, Vector2, LinearSRGBColorSpace, Vector2,
} from 'three'; } 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; 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. */ /** Deterministischer Zufall, damit jeder Aufbau gleich aussieht. */
function mulberry32(seed: number) { function mulberry32(seed: number) {
@@ -26,9 +37,10 @@ function mulberry32(seed: number) {
function canvas(): [HTMLCanvasElement, CanvasRenderingContext2D] { function canvas(): [HTMLCanvasElement, CanvasRenderingContext2D] {
const c = document.createElement('canvas'); const c = document.createElement('canvas');
c.width = SIZE; c.width = PHYS;
c.height = SIZE; c.height = PHYS;
const ctx = c.getContext('2d', { willReadFrequently: true })!; const ctx = c.getContext('2d', { willReadFrequently: true })!;
ctx.setTransform(PHYS / SIZE, 0, 0, PHYS / SIZE, 0, 0);
return [c, ctx]; return [c, ctx];
} }
@@ -38,17 +50,18 @@ function noiseTile(ctx: CanvasRenderingContext2D, seed: number, cells: number, a
const g: number[] = []; const g: number[] = [];
for (let i = 0; i < cells * cells; i++) g.push(rnd()); 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 at = (i: number, j: number) => g[((j + cells) % cells) * cells + ((i + cells) % cells)];
const img = ctx.getImageData(0, 0, SIZE, SIZE); const N = ctx.canvas.width;
const step = SIZE / cells; const img = ctx.getImageData(0, 0, N, N);
for (let y = 0; y < SIZE; y++) { const step = N / cells;
for (let y = 0; y < N; y++) {
const fy = y / step, j = Math.floor(fy), ty = fy - j; 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 fx = x / step, i = Math.floor(fx), tx = fx - i;
const sx = tx * tx * (3 - 2 * tx), sy = ty * ty * (3 - 2 * ty); const sx = tx * tx * (3 - 2 * tx), sy = ty * ty * (3 - 2 * ty);
const v = const v =
at(i, j) * (1 - sx) * (1 - sy) + at(i + 1, j) * sx * (1 - sy) + 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; 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; 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] = 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)); img.data[k + 1] = Math.max(0, Math.min(255, img.data[k + 1] + d));
@@ -103,6 +116,9 @@ function slabs(
export interface Belag { export interface Belag {
map: CanvasTexture; map: CanvasTexture;
/** Nur fotorealistisch: Rauheit und Fugenschatten aus dem Hoehenbild. */
roughnessMap?: CanvasTexture;
aoMap?: CanvasTexture;
/** Aus dem Fugenbild abgeleitete Normalmap - gibt den Belaegen Tiefe. */ /** Aus dem Fugenbild abgeleitete Normalmap - gibt den Belaegen Tiefe. */
normalMap?: CanvasTexture; normalMap?: CanvasTexture;
normalScale?: Vector2; normalScale?: Vector2;
@@ -114,37 +130,71 @@ export interface Belag {
const cache = new Map<string, Belag>(); const cache = new Map<string, Belag>();
function make(key: string, tile: number, roughness: number, draw: (ctx: CanvasRenderingContext2D) => void): Belag { 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; if (hit) return hit;
PHYS = sizeFor();
const [c, ctx] = canvas(); const [c, ctx] = canvas();
draw(ctx); draw(ctx);
const belag: Belag = { map: texture(c), tile, roughness }; 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; 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). * Rechnet ein Hoehenbild in eine Normalmap um (Sobel, kachelnd).
* Damit bekommen Fugen und Steinkanten echte Tiefe im Streiflicht. * Damit bekommen Fugen und Steinkanten echte Tiefe im Streiflicht.
*/ */
function normalFromHeight(src: HTMLCanvasElement, strength: number): CanvasTexture { function normalFromHeight(src: HTMLCanvasElement, strength: number): CanvasTexture {
const sctx = src.getContext('2d', { willReadFrequently: true })!; 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 [c, ctx] = canvas();
const out = ctx.createImageData(SIZE, SIZE); const out = ctx.createImageData(N, N);
const at = (x: number, y: number) => const at = (x: number, y: number) =>
h[(((y + SIZE) % SIZE) * SIZE + ((x + SIZE) % SIZE)) * 4] / 255; h[(((y + N) % N) * N + ((x + N) % N)) * 4] / 255;
for (let y = 0; y < SIZE; y++) { for (let y = 0; y < N; y++) {
for (let x = 0; x < SIZE; x++) { for (let x = 0; x < N; x++) {
const dx = 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) -
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 = 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) -
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 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] = ((nx / len) * 0.5 + 0.5) * 255;
out.data[k + 1] = ((ny / 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; out.data[k + 2] = ((nz / len) * 0.5 + 0.5) * 255;
@@ -169,20 +219,29 @@ function makeRelief(
draw: (ctx: CanvasRenderingContext2D) => void, draw: (ctx: CanvasRenderingContext2D) => void,
drawHeight: (ctx: CanvasRenderingContext2D) => void, drawHeight: (ctx: CanvasRenderingContext2D) => void,
): Belag { ): Belag {
const hit = cache.get(key); const k = keyFor(key);
const hit = cache.get(k);
if (hit) return hit; if (hit) return hit;
PHYS = sizeFor();
const [c, ctx] = canvas(); const [c, ctx] = canvas();
draw(ctx); draw(ctx);
const [hc, hctx] = canvas(); const [hc, hctx] = canvas();
drawHeight(hctx); drawHeight(hctx);
const belag: Belag = { const belag: Belag = {
map: texture(c), 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), normalScale: new Vector2(1, 1),
tile, tile,
roughness, 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; return belag;
} }
+61 -7
View File
@@ -12,6 +12,32 @@ 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 { 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) { function rng(seed: number) {
let a = seed >>> 0; let a = seed >>> 0;
@@ -424,7 +450,8 @@ export function buildVegetation(
const group = new Group(); const group = new Group();
group.name = 'Bepflanzung'; 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); 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 [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); const h = hedgeHeight(mx, mz);
if (row.length >= 2) { 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 { } else {
// Einzelstand: als schmales, aufrechtes Polster // Einzelstand: als schmales, aufrechtes Polster
const [x, z] = row[0]; const [x, z] = row[0];
const geo = blob(0.28, 2, 0.05, 3, h / 0.56); const geo = blob(foto ? 0.25 : 0.28, 2, 0.05, 3, h / 0.56);
const m = new Mesh(geo, eibeMat); const m = new Mesh(geo, foto ? KERN() : eibeMat);
m.name = 'Eibe';
m.position.set(x, height(x, z) + h / 2, z); m.position.set(x, height(x, z) + h / 2, z);
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));
} }
} }
// --- Beerenstraeucher und Heidelbeeren ---------------------------------- // --- Beerenstraeucher und Heidelbeeren ----------------------------------
const straeucher = plants.filter((p) => p.type === 'strauch' && p.x !== undefined); 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; 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 }; return { x: p.x!, y: height(p.x!, p.z!) + s * 0.75, z: p.z!, s, rot: r() * 6.28 };
}), 'Beerensträucher'); }), 'Beerensträucher');
@@ -502,7 +544,17 @@ export function buildVegetation(
// --- Kraeuter und Stauden ----------------------------------------------- // --- Kraeuter und Stauden -----------------------------------------------
const kraeuter = plants.filter((p) => p.type === 'kraut' && p.x !== undefined); 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); 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 }; return { x: p.x!, y: height(p.x!, p.z!) + rr * 0.5, z: p.z!, s: rr, rot: r() * 6.28 };
}), 'Kräuter'); }), 'Kräuter');
@@ -524,7 +576,9 @@ export function buildVegetation(
const along: Vec2[] = (x1 - x0) > (z1 - z0) const along: Vec2[] = (x1 - x0) > (z1 - z0)
? [[x0, (z0 + z1) / 2], [x1, (z0 + z1) / 2]] ? [[x0, (z0 + z1) / 2], [x1, (z0 + z1) / 2]]
: [[(x0 + x1) / 2, z0], [(x0 + x1) / 2, z1]]; : [[(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; return group;
+3
View File
@@ -26,6 +26,9 @@ function material(belag: tex.Belag, extra: MeshStandardMaterialParameters = {})
map: belag.map, map: belag.map,
normalMap: belag.normalMap ?? null, normalMap: belag.normalMap ?? null,
normalScale: belag.normalScale, normalScale: belag.normalScale,
roughnessMap: belag.roughnessMap ?? null,
aoMap: belag.aoMap ?? null,
aoMapIntensity: 0.8,
roughness: belag.roughness, roughness: belag.roughness,
metalness: 0, metalness: 0,
...extra, ...extra,
+26
View File
@@ -74,6 +74,32 @@ html, body {
.topbar-actions { display: flex; gap: 0.5rem; } .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 { #hud button {
font: inherit; font: inherit;
font-size: 0.78rem; font-size: 0.78rem;
+21
View File
@@ -4,9 +4,12 @@
import type { SiteData, StartView } from '../site'; import type { SiteData, StartView } from '../site';
import type { SunState } from '../scene/environment'; import type { SunState } from '../scene/environment';
import { isTouch } from '../controls/walker'; import { isTouch } from '../controls/walker';
import type { Quality } from '../scene/quality';
export interface HudOptions { export interface HudOptions {
site: SiteData; site: SiteData;
/** Wechsel zwischen stilisierter und fotorealistischer Darstellung */
onAnsicht(q: Quality): void;
onView(view: StartView): void; onView(view: StartView): void;
onMode(mode: 'walk' | 'overview'): void; onMode(mode: 'walk' | 'overview'): void;
onJoystick(x: number, y: number, active: boolean): void; onJoystick(x: number, y: number, active: boolean): void;
@@ -17,6 +20,7 @@ export interface Hud {
setMode(mode: 'walk' | 'overview'): void; setMode(mode: 'walk' | 'overview'): void;
setPointerLocked(locked: boolean): void; setPointerLocked(locked: boolean): void;
setSunReadout(sun: SunState): void; setSunReadout(sun: SunState): void;
setAnsicht(q: Quality): void;
} }
const MONTHS = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; 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'); nav.querySelector('button')?.setAttribute('aria-pressed', 'true');
// -- Darstellung ---------------------------------------------------------
const ansichtButtons = Array.from(document.querySelectorAll<HTMLButtonElement>('#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 ----------------------------------------------------------- // -- Tageszeit -----------------------------------------------------------
const syncTime = () => { const syncTime = () => {
timeOut.value = timeLabel(+timeInput.value); timeOut.value = timeLabel(+timeInput.value);
@@ -149,6 +162,13 @@ export function setupHud(opts: HudOptions): Hud {
(Ost- und Westzugang), Geländer und Entwässerungsrinnen</li> (Ost- und Westzugang), Geländer und Entwässerungsrinnen</li>
</ul> </ul>
<h3>Zwei Darstellungen</h3>
<p>Oben in der Mitte schaltet man zwischen <strong>Stilisiert</strong> und
<strong>Fotorealistisch</strong> 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 &ndash; sie braucht einen leistungsfähigeren Rechner.</p>
<h3>Bedienung</h3> <h3>Bedienung</h3>
<ul> <ul>
<li>Rundgang: auf den Boden klicken, dann l&auml;uft man dorthin. Mit der Maus ziehen <li>Rundgang: auf den Boden klicken, dann l&auml;uft man dorthin. Mit der Maus ziehen
@@ -211,6 +231,7 @@ export function setupHud(opts: HudOptions): Hud {
day: +seasonInput.value, day: +seasonInput.value,
}), }),
setMode, setMode,
setAnsicht,
setPointerLocked(locked) { setPointerLocked(locked) {
crosshair.classList.toggle('on', locked); crosshair.classList.toggle('on', locked);
if (locked) hint.classList.add('fade'); if (locked) hint.classList.add('fade');