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

Zwei Darstellungen

+

Oben in der Mitte schaltet man zwischen Stilisiert und + Fotorealistisch um. Beide zeigen dasselbe Modell mit denselben + Massen. Die fotorealistische Ansicht rechnet mit hoch aufgelösten Materialien, + einzelnen Blättern und Grashalmen, physikalischem Himmelslicht und + Umgebungsverdeckung – sie braucht einen leistungsfähigeren Rechner.

+

Bedienung

  • Rundgang: auf den Boden klicken, dann läuft man dorthin. Mit der Maus ziehen @@ -211,6 +231,7 @@ export function setupHud(opts: HudOptions): Hud { day: +seasonInput.value, }), setMode, + setAnsicht, setPointerLocked(locked) { crosshair.classList.toggle('on', locked); if (locked) hint.classList.add('fade');