/** * 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 ', '#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.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; }