Files
Gartenvisualisierung/src/scene/terrain.ts
T
admGitAICDSandClaude Opus 5 134fb22937
Deploy App / deploy (push) Successful in 29s
Fotografierte Materialien fuer die fotorealistische Ansicht
Zwoelf CC0-Materialsaetze von ambientCG, neu komprimiert auf 11 MB
(public/materialien, Quellenliste in QUELLEN.md). Sie werden erst geladen,
wenn auf "Fotorealistisch" geschaltet wird.

- Travertin (Travertine009, 2K): 80x80-Platten und Stellplatten werden aus
  Fotoausschnitten zusammengesetzt - jede Platte ein eigener Ausschnitt,
  dazu Fugen, Fasen und Fugenschatten. Lose Platten, Stufen, Stelen und
  Mauern bekommen das Foto direkt.
- Keramik 60x60 aus Concrete017, ebenfalls als Plattenraster.
- Tegula, Verbundstein, Dekorkies, Schotterwiese, Spielsand, Beete, Rasen,
  Wiese, Holzschalung, Beton und Sockel direkt als Fotomaterial.
- Rauheit fuer Gras, Erde und Stein angehoben: poliert fotografierter Stein
  und Gras spiegelten draussen sonst den Himmel grau.
- Boden der Umgebungskarte als warmes Rueckstrahllicht - Decken und
  Unterseiten lagen vorher fast schwarz im Schatten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 11:44:09 +02:00

181 lines
7.2 KiB
TypeScript

/**
* Gelaende: Basismesh aus dem Hoehenraster, plus eine weite, flache Umgebung,
* damit der Garten nicht im Nichts steht.
*/
import {
BufferAttribute, BufferGeometry, DoubleSide, Mesh, MeshStandardMaterial, Group,
} 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: 0xe6e8dc, 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.70, 1.12, 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();
group.name = 'Gelände';
const { x0, z0, cell, nx, nz } = terrain.grid;
const h = terrain.heights;
// --- Hauptgelaende -------------------------------------------------------
const geo = new BufferGeometry();
const verts = new Float32Array(nx * nz * 3);
const uvs = new Float32Array(nx * nz * 2);
const belag = rohboden();
for (let j = 0; j < nz; j++) {
for (let i = 0; i < nx; i++) {
const k = j * nx + i;
const x = x0 + i * cell, z = z0 + j * cell;
verts[k * 3] = x;
verts[k * 3 + 1] = h[k];
verts[k * 3 + 2] = z;
uvs[k * 2] = x / belag.tile;
uvs[k * 2 + 1] = -z / belag.tile;
}
}
const idx: number[] = [];
for (let j = 0; j < nz - 1; j++) {
for (let i = 0; i < nx - 1; i++) {
const a = j * nx + i, b = a + 1, c = a + nx, d = c + 1;
idx.push(a, c, b, b, c, d);
}
}
geo.setAttribute('position', new BufferAttribute(verts, 3));
geo.setAttribute('uv', new BufferAttribute(uvs, 2));
geo.setIndex(idx);
geo.computeVertexNormals();
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 umland = rohboden();
const cx = x0 + ((nx - 1) * cell) / 2;
const cz = z0 + ((nz - 1) * cell) / 2;
/**
* 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 = 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],
});
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 i = nx - 1; i > 0; i -= STEP) border.push(at(i, nz - 1));
for (let j = nz - 1; j > 0; j -= STEP) border.push(at(0, j));
// Der Kranz entsteht durch reine Skalierung des Randpolygons um den
// Mittelpunkt. Das kann sich nicht selbst durchdringen, egal wie weit
// hinausskaliert wird, und schliesst luecklos an das Raster an.
const RINGS = 26;
const SCALE_MAX = 18;
const ringPos: number[] = [];
const ringUv: number[] = [];
const ringIdx: number[] = [];
const n = border.length;
for (let k = 0; k <= RINGS; k++) {
const t = (k / RINGS) ** 2.4;
const s = 1 + (SCALE_MAX - 1) * t;
for (const p of border) {
const dx = p.x - cx, dz = p.z - cz;
const x = cx + dx * s;
const z = cz + dz * s;
// 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 / umland.tile, -z / umland.tile);
}
}
for (let k = 0; k < RINGS; k++) {
for (let i = 0; i < n; i++) {
const a = k * n + i, b = k * n + ((i + 1) % n);
const c = (k + 1) * n + i, d = (k + 1) * n + ((i + 1) % n);
ringIdx.push(a, c, b, b, c, d);
}
}
const far = new BufferGeometry();
far.setAttribute('position', new BufferAttribute(new Float32Array(ringPos), 3));
far.setAttribute('uv', new BufferAttribute(new Float32Array(ringUv), 2));
far.setIndex(ringIdx);
far.computeVertexNormals();
// 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);
return group;
}