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:
@@ -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
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
<strong>Garten Egglihaldenstrasse 9a</strong>
|
<strong>Garten Egglihaldenstrasse 9a</strong>
|
||||||
<span>Abtwil SG · Gartenplan 1, Garten Design AG</span>
|
<span>Abtwil SG · 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
@@ -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; },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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'; }
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 – sie braucht einen leistungsfähigeren Rechner.</p>
|
||||||
|
|
||||||
<h3>Bedienung</h3>
|
<h3>Bedienung</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Rundgang: auf den Boden klicken, dann läuft man dorthin. Mit der Maus ziehen
|
<li>Rundgang: auf den Boden klicken, dann läuft man dorthin. Mit der Maus ziehen
|
||||||
@@ -211,6 +231,7 @@ export function setupHud(opts: HudOptions): Hud {
|
|||||||
day: +seasonInput.value,
|
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');
|
||||||
|
|||||||
Reference in New Issue
Block a user