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
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
+4
View File
@@ -25,6 +25,10 @@
<strong>Garten Egglihaldenstrasse&nbsp;9a</strong>
<span>Abtwil SG &middot; Gartenplan 1, Garten Design AG</span>
</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">
<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>
+131 -38
View File
@@ -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<Record<Quality, Welt>> = {};
type Melde = (pct: number, text: string) => Promise<void>;
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<Welt> {
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; },
};
}
+18
View File
@@ -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();
+118 -4
View File
@@ -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 };
}
+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,
normalMap: belag.normalMap ?? null,
normalScale: belag.normalScale,
roughnessMap: belag.roughnessMap ?? null,
aoMap: belag.aoMap ?? null,
aoMapIntensity: 0.8,
roughness: belag.roughness,
metalness: 0,
...extra,
+13
View File
@@ -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<string, unknown> = {}) {
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();
+74 -10
View File
@@ -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 <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 {
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);
+78 -19
View File
@@ -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<string, 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;
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;
}
+61 -7
View File
@@ -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;
+3
View File
@@ -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,
+26
View File
@@ -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;
+21
View File
@@ -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<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 -----------------------------------------------------------
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</li>
</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>
<ul>
<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,
}),
setMode,
setAnsicht,
setPointerLocked(locked) {
crosshair.classList.toggle('on', locked);
if (locked) hint.classList.add('fade');