Fotografierte Materialien fuer die fotorealistische Ansicht
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>
This commit is contained in:
2026-09-13 11:44:09 +02:00
co-authored by Claude Opus 5
parent 62f4d72355
commit 134fb22937
47 changed files with 281 additions and 9 deletions
+1 -1
View File
@@ -221,7 +221,7 @@ 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`) |
| Materialien | **Fotografierte Materialien von ambientCG (CC0)** für Travertin, Pflaster, Kies, Sand, Erde, Rasen, Holzschalung, Beton und Keramik – Liste in `public/materialien/QUELLEN.md`, rund 11 MB, erst beim Umschalten geladen (`fotomaterial.ts`). Plattenbeläge werden aus Fotoausschnitten zusammengesetzt, jede Platte mit eigenem Ausschnitt und echten Fugen. Was kein Foto hat, wird doppelt so fein gezeichnet |
| 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`) |
+24
View File
@@ -0,0 +1,24 @@
# Fotomaterialien
Alle Materialien stammen von **ambientCG** (https://ambientcg.com) und stehen
unter der Lizenz **CC0 1.0** (gemeinfrei, keine Namensnennung nötig). Sie
wurden als 1K- bzw. 2K-JPG heruntergeladen und für das Web neu komprimiert
(Farbe 86 %, Normalen 90 %, Rauheit/Verdeckung 82 %).
| Ordner | ambientCG-Satz | Auflösung | Verwendung |
| --- | --- | --- | --- |
| travertin | Travertine009 | 2K | Travertinplatten 80×80, Stellplatten, lose Platten, Stufen, Stelen, Mauern |
| tegula | PavingStones111 | 1K | Tegula-Pflastersteine grau |
| verbundstein | PavingStones099 | 1K | Verbundsteine |
| kies | Gravel023 | 1K | Dekorkies, Spritzschutz |
| schotter | Gravel036L | 1K | Schotterwiese |
| sand | Ground091 | 1K | Spielsand |
| erde | Ground048 | 1K | Pflanzbeete |
| rasen | Grass004 | 1K | Rasen, Wiese |
| holz | Planks001 | 1K | Holzschalung der Fassade, Gartenschrank |
| beton | Concrete034 | 1K | Winkelelemente, Betonmauer, Sockel |
| keramik | Concrete017 | 1K | Keramikplatten 60×60 (gedeckter Sitzplatz) |
Plattenbeläge werden im Browser aus Ausschnitten dieser Bilder zusammengesetzt –
jede Platte bekommt einen eigenen Ausschnitt, dazu Fugen und Fasen
(`fotoPlatten` in `src/scene/textures.ts`).
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 608 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 601 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 607 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

+7
View File
@@ -24,6 +24,7 @@ import { setupHud } from './ui/hud';
import { setQuality, type Quality } from './scene/quality';
import { buildPost, type Post } from './scene/post';
import { wind } from './scene/gras';
import { ladeFotomaterialien } from './scene/fotomaterial';
const CENTER: [number, number] = [-9.5, -1.0];
const BOUNDS = { x0: -24.0, z0: -12.5, x1: 7.5, z1: 8.5 };
@@ -75,6 +76,12 @@ async function main() {
const group = new Group();
group.name = foto ? 'Fotorealistisch' : 'Stilisiert';
if (foto) {
// Fotografierte Materialien (ambientCG, CC0) - erst jetzt geladen, damit
// die stilisierte Ansicht leicht bleibt.
await melde(10, 'Fotomaterialien werden geladen …');
await ladeFotomaterialien((anteil) => { void melde(10 + anteil * 10, 'Fotomaterialien werden geladen …'); });
}
await melde(22, foto ? 'Fotorealistisches Gelände …' : 'Gelände wird modelliert …');
const terrainGroup = buildTerrain(site.terrain);
group.add(terrainGroup);
+4 -1
View File
@@ -253,7 +253,10 @@ export function buildEnvironment(scene: Scene, renderer: WebGLRenderer, opts: {
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);
// Der Boden in der Umgebungskarte ist das zurueckgeworfene Licht von Rasen
// und hellem Travertin. Zu dunkel gewaehlt, liegen Decken, Vordaecher und
// Unterseiten im Fotomodus fast schwarz und blaustichig im Schatten.
(envBoden.material as ShaderMaterial).uniforms.uTone.value.setRGB(0.30, 0.28, 0.21).multiplyScalar(0.15 + 0.85 * dusk);
envTarget?.dispose();
envTarget = pmrem.fromScene(envScene, 0.02, 0.1, 200);
scene.environment = envTarget.texture;
+69
View File
@@ -0,0 +1,69 @@
/**
* Fotografierte Materialien fuer die fotorealistische Darstellung.
*
* Quelle: ambientCG (https://ambientcg.com), Lizenz CC0 - frei nutzbar, auch
* ohne Namensnennung. Die Saetze liegen neu komprimiert unter
* public/materialien/<satz>/{color,normal,rough,ao}.jpg; welche Karte woher
* stammt, steht in public/materialien/QUELLEN.md.
*
* Die Bilder werden erst geladen, wenn jemand auf "Fotorealistisch" schaltet -
* die stilisierte Ansicht bleibt so leicht wie bisher.
*/
export type Satz =
| 'travertin' | 'tegula' | 'verbundstein' | 'kies' | 'schotter' | 'sand'
| 'erde' | 'rasen' | 'holz' | 'beton' | 'keramik';
export type Karte = 'color' | 'normal' | 'rough' | 'ao';
/** Kantenlaenge, die ein Bild in der Natur abdeckt (Meter). */
export const FOTO_M: Record<Satz, number> = {
travertin: 1.2, tegula: 1.0, verbundstein: 2.0, kies: 1.5, schotter: 1.5, sand: 1.5,
erde: 1.4, rasen: 1.4, holz: 0.9, beton: 1.1, keramik: 1.5,
};
/** Welche Karten es je Satz gibt - nicht jeder Satz hat eine Verdeckungskarte. */
const KARTEN: Record<Satz, Karte[]> = {
travertin: ['color', 'normal', 'rough', 'ao'],
tegula: ['color', 'normal', 'rough', 'ao'],
verbundstein: ['color', 'normal', 'rough'],
kies: ['color', 'normal', 'rough', 'ao'],
schotter: ['color', 'normal', 'rough', 'ao'],
sand: ['color', 'normal', 'rough', 'ao'],
erde: ['color', 'normal', 'rough', 'ao'],
rasen: ['color', 'normal', 'rough', 'ao'],
holz: ['color', 'normal', 'rough'],
beton: ['color', 'normal', 'rough'],
keramik: ['color', 'normal', 'rough'],
};
const bilder = new Map<string, HTMLImageElement>();
let bereit = false;
let laden: Promise<void> | null = null;
export function fotoBereit(): boolean { return bereit; }
export function bild(satz: Satz, karte: Karte): HTMLImageElement | undefined {
return bilder.get(`${satz}/${karte}`);
}
/** Laedt alle Saetze; mehrfacher Aufruf laedt nur einmal. */
export function ladeFotomaterialien(fortschritt?: (anteil: number) => void): Promise<void> {
if (laden) return laden;
const auftraege: [string, string][] = [];
for (const [satz, karten] of Object.entries(KARTEN)) {
for (const k of karten) auftraege.push([`${satz}/${k}`, `./materialien/${satz}/${k}.jpg`]);
}
let fertig = 0;
laden = Promise.all(auftraege.map(async ([key, url]) => {
const img = new Image();
img.src = url;
try {
await img.decode();
bilder.set(key, img);
} catch {
// Ein fehlendes Bild faellt auf die gezeichnete Textur zurueck.
console.warn('Material fehlt:', url);
}
fortschritt?.(++fertig / auftraege.length);
})).then(() => { bereit = true; });
return laden;
}
+2 -2
View File
@@ -29,7 +29,7 @@ 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,
color: 0xe6e8dc, side: DoubleSide,
});
mat.onBeforeCompile = (sh) => {
sh.vertexShader = sh.vertexShader
@@ -45,7 +45,7 @@ function wieseFoto(): MeshStandardMaterial {
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);
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);
+174 -5
View File
@@ -8,9 +8,10 @@
*/
import {
CanvasTexture, RepeatWrapping, SRGBColorSpace, LinearMipmapLinearFilter,
LinearSRGBColorSpace, Vector2,
LinearSRGBColorSpace, Texture, Vector2,
} from 'three';
import { isFoto } from './quality';
import { bild, fotoBereit, FOTO_M, type Satz } from './fotomaterial';
/**
* Die Zeichenfunktionen rechnen mit einer logischen Kantenlaenge von SIZE.
@@ -115,12 +116,12 @@ function slabs(
}
export interface Belag {
map: CanvasTexture;
map: Texture;
/** Nur fotorealistisch: Rauheit und Fugenschatten aus dem Hoehenbild. */
roughnessMap?: CanvasTexture;
aoMap?: CanvasTexture;
roughnessMap?: Texture;
aoMap?: Texture;
/** Aus dem Fugenbild abgeleitete Normalmap - gibt den Belaegen Tiefe. */
normalMap?: CanvasTexture;
normalMap?: Texture;
normalScale?: Vector2;
tile: number;
roughness: number;
@@ -130,6 +131,8 @@ export interface Belag {
const cache = new Map<string, Belag>();
function make(key: string, tile: number, roughness: number, draw: (ctx: CanvasRenderingContext2D) => void): Belag {
const foto = fotoVariante(key);
if (foto) return foto;
const k = keyFor(key);
const hit = cache.get(k);
if (hit) return hit;
@@ -219,6 +222,8 @@ function makeRelief(
draw: (ctx: CanvasRenderingContext2D) => void,
drawHeight: (ctx: CanvasRenderingContext2D) => void,
): Belag {
const foto = fotoVariante(key);
if (foto) return foto;
const k = keyFor(key);
const hit = cache.get(k);
if (hit) return hit;
@@ -752,3 +757,167 @@ export const travertinStele = () => makeRelief('travertinStele', 0.5, 0.62, 1.7,
}
noiseTile(ctx, 95, 120, 0.06);
});
// ---------------------------------------------------------------------------
// Fotografierte Materialien (nur fotorealistische Darstellung)
// ---------------------------------------------------------------------------
interface Direkt {
satz: Satz;
/** Rauheit anheben (0..1): polierter Stein wirkt draussen sonst lackiert. */
rauh?: number;
/** Helligkeit anheben (0..1) oder absenken (negativ). */
hell?: number;
}
interface Platten extends Direkt {
tile: number; slabW: number; slabH: number; fuge: number; fugenfarbe: string;
/** Helligkeitsschwankung zwischen den einzelnen Platten */
streuung: number;
fase: number; relief: number;
/** Leinwandgroesse in Pixeln */
px: number;
}
/**
* Welcher gezeichnete Belag im Fotomodus durch welches Foto ersetzt wird.
* Plattenbelaege werden aus Fotoausschnitten zusammengesetzt - jede Platte
* bekommt einen eigenen Ausschnitt des Steins, dazu echte Fugen und Fasen.
*/
const FOTO_DIREKT: Record<string, Direkt> = {
travertinSlab: { satz: 'travertin', rauh: 0.38 },
travertinMauer: { satz: 'travertin', rauh: 0.45, hell: -0.04 },
travertinStele: { satz: 'travertin', rauh: 0.4 },
tegula: { satz: 'tegula', hell: 0.08 },
verbundstein: { satz: 'verbundstein' },
dekorkies: { satz: 'kies' },
spritzschutz: { satz: 'kies', hell: -0.1 },
spielsand: { satz: 'sand' },
beet: { satz: 'erde', rauh: 0.3 },
// Gras glaenzt nicht - ohne angehobene Rauheit spiegelt es den Himmel grau.
rasen: { satz: 'rasen', rauh: 0.6 },
schotterwiese: { satz: 'schotter', rauh: 0.3 },
rohboden: { satz: 'rasen', rauh: 0.6 },
beton: { satz: 'beton' },
sockel: { satz: 'beton', hell: -0.05 },
fassadeHolz: { satz: 'holz', hell: 0.04 },
};
const FOTO_PLATTEN: Record<string, Platten> = {
travertin80: {
satz: 'travertin', tile: 3.2, slabW: 0.8, slabH: 0.8, fuge: 0.008, fugenfarbe: 'rgb(176,168,150)',
streuung: 0.06, fase: 0.012, relief: 3.2, px: 2048, rauh: 0.38,
},
travertinstell: {
satz: 'travertin', tile: 2.4, slabW: 0.4, slabH: 0.4, fuge: 0.01, fugenfarbe: 'rgb(170,162,146)',
streuung: 0.06, fase: 0.01, relief: 3.0, px: 1024, rauh: 0.38,
},
keramik: {
satz: 'keramik', tile: 2.4, slabW: 0.6, slabH: 0.6, fuge: 0.006, fugenfarbe: 'rgb(84,84,82)',
streuung: 0.04, fase: 0.006, relief: 2.6, px: 1024, hell: 0.22, rauh: 0.1,
},
};
function fotoVariante(key: string): Belag | null {
if (!isFoto() || !fotoBereit()) return null;
const k = key + ':foto';
const hit = cache.get(k);
if (hit) return hit;
let belag: Belag | null = null;
if (FOTO_PLATTEN[key]) belag = fotoPlatten(key, FOTO_PLATTEN[key]);
else if (FOTO_DIREKT[key]) belag = fotoDirekt(FOTO_DIREKT[key]);
if (belag) cache.set(k, belag);
return belag;
}
function bildTextur(src: HTMLImageElement | HTMLCanvasElement, farbe: boolean): Texture {
const tx = src instanceof HTMLCanvasElement ? new CanvasTexture(src) : new Texture(src);
tx.wrapS = tx.wrapT = RepeatWrapping;
tx.colorSpace = farbe ? SRGBColorSpace : LinearSRGBColorSpace;
tx.anisotropy = 8;
tx.minFilter = LinearMipmapLinearFilter;
tx.generateMipmaps = true;
tx.needsUpdate = true;
return tx;
}
/** Bild auf eine eigene Leinwand kopieren und dabei auf- oder abhellen. */
function nachbearbeitet(img: HTMLImageElement, hell: number): HTMLCanvasElement {
const c = document.createElement('canvas');
c.width = img.width; c.height = img.height;
const ctx = c.getContext('2d')!;
ctx.drawImage(img, 0, 0);
if (hell) {
ctx.fillStyle = hell > 0 ? `rgba(255,255,255,${hell})` : `rgba(0,0,0,${-hell})`;
ctx.fillRect(0, 0, c.width, c.height);
}
return c;
}
function fotoDirekt(d: Direkt): Belag {
const color = bild(d.satz, 'color')!;
const normal = bild(d.satz, 'normal');
const rough = bild(d.satz, 'rough');
const ao = bild(d.satz, 'ao');
return {
map: bildTextur(d.hell ? nachbearbeitet(color, d.hell) : color, true),
normalMap: normal ? bildTextur(normal, false) : undefined,
normalScale: new Vector2(1, 1),
roughnessMap: rough ? bildTextur(d.rauh ? nachbearbeitet(rough, d.rauh) : rough, false) : undefined,
aoMap: ao ? bildTextur(ao, false) : undefined,
tile: FOTO_M[d.satz],
roughness: 1,
};
}
function fotoPlatten(key: string, d: Platten): Belag {
const color = bild(d.satz, 'color')!;
const rough = bild(d.satz, 'rough');
PHYS = d.px;
const [c, ctx] = canvas();
const [rc, rctx] = canvas();
const [hc, hctx] = canvas();
const pxm = SIZE / d.tile;
ctx.fillStyle = d.fugenfarbe;
ctx.fillRect(0, 0, SIZE, SIZE);
rctx.fillStyle = 'rgb(250,250,250)';
rctx.fillRect(0, 0, SIZE, SIZE);
let seed = 7;
for (let i = 0; i < key.length; i++) seed = (seed * 31 + key.charCodeAt(i)) >>> 0;
const rnd = mulberry32(seed);
const cols = Math.round(d.tile / d.slabW), rows = Math.round(d.tile / d.slabH);
const j = d.fuge * pxm;
const physM = FOTO_M[d.satz];
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const x = col * d.slabW * pxm + j / 2, y = row * d.slabH * pxm + j / 2;
const w = d.slabW * pxm - j, h = d.slabH * pxm - j;
// eigener Ausschnitt des Steins je Platte
const sw = (d.slabW / physM) * color.width, sh = (d.slabH / physM) * color.height;
const sx = rnd() * Math.max(0, color.width - sw), sy = rnd() * Math.max(0, color.height - sh);
ctx.drawImage(color, sx, sy, sw, sh, x, y, w, h);
const v = (rnd() - 0.5) * 2 * d.streuung + (d.hell ?? 0);
if (v) {
ctx.fillStyle = v > 0 ? `rgba(255,250,240,${v})` : `rgba(40,34,28,${-v})`;
ctx.fillRect(x, y, w, h);
}
if (rough) {
const k = rough.width / color.width;
rctx.drawImage(rough, sx * k, sy * k, sw * k, sh * k, x, y, w, h);
if (d.rauh) {
rctx.fillStyle = `rgba(255,255,255,${d.rauh})`;
rctx.fillRect(x, y, w, h);
}
}
}
}
slabHeight(hctx, d.tile, d.slabW, d.slabH, d.fuge, d.fase);
return {
map: bildTextur(c, true),
normalMap: normalFromHeight(hc, d.relief * (PHYS / SIZE)),
normalScale: new Vector2(1, 1),
roughnessMap: bildTextur(rc, false),
aoMap: derivedMap(hc, (hh) => 0.35 + Math.min(1, hh * 1.25) * 0.65),
tile: d.tile,
roughness: 1,
};
}