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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 11:15:22 +02:00
co-authored by Claude Opus 5
parent 1ac99b1660
commit 62f4d72355
17 changed files with 1247 additions and 78 deletions
+74 -10
View File
@@ -7,6 +7,52 @@ import {
} from 'three';
import type { Terrain } from '../site';
import { rohboden } from './textures';
import { isFoto } from './quality';
/** Wertrauschen im Shader, fuer grossflaechige Farbwechsel der Wiese. */
const NOISE_GLSL = /* glsl */ `
float hash21(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
float vnoise(vec2 p) {
vec2 i = floor(p), f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(hash21(i), hash21(i + vec2(1, 0)), u.x), mix(hash21(i + vec2(0, 1)), hash21(i + vec2(1, 1)), u.x), u.y);
}
`;
/**
* Wiese fuer die fotorealistische Darstellung. Die Kachel wird in zwei
* Massstaeben gemischt und grossflaechig in Farbe und Helligkeit variiert -
* sonst erkennt man das Wiederholungsmuster sofort, sobald man von oben
* auf das Umland schaut.
*/
function wieseFoto(): MeshStandardMaterial {
const belag = rohboden();
const mat = new MeshStandardMaterial({
map: belag.map, roughness: 1, metalness: 0, roughnessMap: belag.roughnessMap ?? null,
color: 0xc4c9ae, side: DoubleSide,
});
mat.onBeforeCompile = (sh) => {
sh.vertexShader = sh.vertexShader
.replace('#include <common>', '#include <common>\nvarying vec3 vWelt;')
.replace('#include <begin_vertex>', '#include <begin_vertex>\nvWelt = (modelMatrix * vec4(transformed, 1.0)).xyz;');
sh.fragmentShader = sh.fragmentShader
.replace('#include <common>', '#include <common>\nvarying vec3 vWelt;' + NOISE_GLSL)
.replace('#include <map_fragment>', `
#ifdef USE_MAP
vec4 kA = texture2D(map, vMapUv);
vec4 kB = texture2D(map, vMapUv * 0.29 + vec2(0.37, 0.11));
float mischung = vnoise(vWelt.xz * 0.09);
diffuseColor *= mix(kA, kB, smoothstep(0.25, 0.75, mischung));
#endif
float gross = vnoise(vWelt.xz * 0.035) * 0.6 + vnoise(vWelt.xz * 0.16) * 0.4;
diffuseColor.rgb *= mix(0.80, 1.10, gross);
// trockenere, gelblichere Stellen
float trocken = smoothstep(0.62, 0.85, vnoise(vWelt.xz * 0.021 + 7.3));
diffuseColor.rgb = mix(diffuseColor.rgb, diffuseColor.rgb * vec3(1.12, 1.05, 0.78), trocken * 0.7);
`);
};
return mat;
}
export function buildTerrain(terrain: Terrain): Group {
const group = new Group();
@@ -42,30 +88,35 @@ export function buildTerrain(terrain: Terrain): Group {
geo.setIndex(idx);
geo.computeVertexNormals();
const ground = new Mesh(geo, new MeshStandardMaterial({
const foto = isFoto();
const wiese = foto ? wieseFoto() : null;
const ground = new Mesh(geo, wiese ?? new MeshStandardMaterial({
map: belag.map, roughness: belag.roughness, metalness: 0,
}));
ground.name = 'Terrain';
ground.receiveShadow = true;
group.add(ground);
// --- Umland ---------------------------------------------------------------
// Das Grundstueck liegt an einem Hang ueber dem Tal. Das Umland wird als
// Kranz direkt an die Randpunkte des Rasters gehaengt - so entsteht keine
// sichtbare Kante - und faellt gegen aussen ab, vor allem nach Suedwesten.
const wiese = rohboden();
const umland = rohboden();
const cx = x0 + ((nx - 1) * cell) / 2;
const cz = z0 + ((nz - 1) * cell) / 2;
/**
* Randpunkte des Rasters im Umlauf, bewusst grob abgetastet: bei jedem
* Rasterpunkt entstuenden extrem schlanke Dreiecke, deren Normalen
* unbrauchbar werden und sich als dunkle Streifen abzeichnen.
* Randpunkte des Rasters im Umlauf - jeder einzelne. Nur so schliesst der
* Kranz luecklos an; mit groeber Abtastung blieb zwischen Rand und Kranz ein
* Spalt, durch den man von oben als helle Linie den Himmel sah. Die dabei
* entstehenden schlanken Dreiecke bekommen weiter unten ruhige Normalen,
* sonst zeichnen sie sich als dunkle Streifen ab.
*/
const STEP = 10;
const STEP = 1;
const border: { x: number; z: number; y: number }[] = [];
const at = (i: number, j: number) => ({
x: x0 + i * cell, z: z0 + j * cell, y: h[j * nx + i] - 0.05,
x: x0 + i * cell, z: z0 + j * cell, y: h[j * nx + i],
});
for (let i = 0; i < nx - 1; i += STEP) border.push(at(i, 0));
for (let j = 0; j < nz - 1; j += STEP) border.push(at(nx - 1, j));
@@ -91,7 +142,7 @@ export function buildTerrain(terrain: Terrain): Group {
// Nach Suedwesten faellt das Gelaende deutlich staerker ab (Blick ins Tal).
const sw = (1 - Math.cos(Math.atan2(dz, dx) - 2.4)) / 2;
ringPos.push(x, p.y - t * (6 + 26 * sw * sw), z);
ringUv.push(x / wiese.tile, -z / wiese.tile);
ringUv.push(x / umland.tile, -z / umland.tile);
}
}
for (let k = 0; k < RINGS; k++) {
@@ -106,8 +157,21 @@ export function buildTerrain(terrain: Terrain): Group {
far.setAttribute('uv', new BufferAttribute(new Float32Array(ringUv), 2));
far.setIndex(ringIdx);
far.computeVertexNormals();
const surroundings = new Mesh(far, new MeshStandardMaterial({
map: wiese.map, roughness: 1, metalness: 0, color: 0xa9b096, side: DoubleSide,
// Ruhige Normalen: das Umland ist ein weicher Hang, die Beleuchtung soll
// nicht die Dreiecksstruktur nachzeichnen.
const fn = far.getAttribute('normal') as BufferAttribute;
// Der Kranz ist so gewickelt, dass man von oben seine Rueckseite sieht
// (DoubleSide dreht die Normale dort um) - also zur selben Seite hin glaetten.
let sum = 0;
for (let i = 0; i < fn.count; i++) sum += fn.getY(i);
const oben = sum >= 0 ? 1 : -1;
for (let i = 0; i < fn.count; i++) {
const nx2 = fn.getX(i) * 0.35, ny2 = fn.getY(i) * 0.35 + 0.65 * oben, nz2 = fn.getZ(i) * 0.35;
const l = Math.hypot(nx2, ny2, nz2) || 1;
fn.setXYZ(i, nx2 / l, ny2 / l, nz2 / l);
}
const surroundings = new Mesh(far, wiese ?? new MeshStandardMaterial({
map: umland.map, roughness: 1, metalness: 0, color: 0xa9b096, side: DoubleSide,
}));
surroundings.name = 'Umland';
group.add(surroundings);