Deploy App / deploy (push) Successful in 29s
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>
181 lines
7.2 KiB
TypeScript
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;
|
|
}
|