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
+78 -19
View File
@@ -10,8 +10,19 @@ import {
CanvasTexture, RepeatWrapping, SRGBColorSpace, LinearMipmapLinearFilter,
LinearSRGBColorSpace, Vector2,
} from 'three';
import { isFoto } from './quality';
/**
* Die Zeichenfunktionen rechnen mit einer logischen Kantenlaenge von SIZE.
* Fuer die fotorealistische Variante wird dasselbe Motiv auf eine doppelt so
* grosse Leinwand gezeichnet (PHYS), der Kontext skaliert mit. So bleiben
* Holzscheite, Kiesel und Poren gleich gross, werden aber schaerfer - und die
* Pixeloperationen (Rauschen, Normalen) arbeiten mit der vollen Aufloesung.
*/
const SIZE = 512;
let PHYS = 512;
const sizeFor = () => (isFoto() ? 1024 : 512);
const keyFor = (key: string) => (isFoto() ? key + ':foto' : key);
/** Deterministischer Zufall, damit jeder Aufbau gleich aussieht. */
function mulberry32(seed: number) {
@@ -26,9 +37,10 @@ function mulberry32(seed: number) {
function canvas(): [HTMLCanvasElement, CanvasRenderingContext2D] {
const c = document.createElement('canvas');
c.width = SIZE;
c.height = SIZE;
c.width = PHYS;
c.height = PHYS;
const ctx = c.getContext('2d', { willReadFrequently: true })!;
ctx.setTransform(PHYS / SIZE, 0, 0, PHYS / SIZE, 0, 0);
return [c, ctx];
}
@@ -38,17 +50,18 @@ function noiseTile(ctx: CanvasRenderingContext2D, seed: number, cells: number, a
const g: number[] = [];
for (let i = 0; i < cells * cells; i++) g.push(rnd());
const at = (i: number, j: number) => g[((j + cells) % cells) * cells + ((i + cells) % cells)];
const img = ctx.getImageData(0, 0, SIZE, SIZE);
const step = SIZE / cells;
for (let y = 0; y < SIZE; y++) {
const N = ctx.canvas.width;
const img = ctx.getImageData(0, 0, N, N);
const step = N / cells;
for (let y = 0; y < N; y++) {
const fy = y / step, j = Math.floor(fy), ty = fy - j;
for (let x = 0; x < SIZE; x++) {
for (let x = 0; x < N; x++) {
const fx = x / step, i = Math.floor(fx), tx = fx - i;
const sx = tx * tx * (3 - 2 * tx), sy = ty * ty * (3 - 2 * ty);
const v =
at(i, j) * (1 - sx) * (1 - sy) + at(i + 1, j) * sx * (1 - sy) +
at(i, j + 1) * (1 - sx) * sy + at(i + 1, j + 1) * sx * sy;
const k = (y * SIZE + x) * 4;
const k = (y * N + x) * 4;
const d = (dark ? -1 : 1) * (v - 0.5) * 2 * alpha * 255;
img.data[k] = Math.max(0, Math.min(255, img.data[k] + d));
img.data[k + 1] = Math.max(0, Math.min(255, img.data[k + 1] + d));
@@ -103,6 +116,9 @@ function slabs(
export interface Belag {
map: CanvasTexture;
/** Nur fotorealistisch: Rauheit und Fugenschatten aus dem Hoehenbild. */
roughnessMap?: CanvasTexture;
aoMap?: CanvasTexture;
/** Aus dem Fugenbild abgeleitete Normalmap - gibt den Belaegen Tiefe. */
normalMap?: CanvasTexture;
normalScale?: Vector2;
@@ -114,37 +130,71 @@ export interface Belag {
const cache = new Map<string, Belag>();
function make(key: string, tile: number, roughness: number, draw: (ctx: CanvasRenderingContext2D) => void): Belag {
const hit = cache.get(key);
const k = keyFor(key);
const hit = cache.get(k);
if (hit) return hit;
PHYS = sizeFor();
const [c, ctx] = canvas();
draw(ctx);
const belag: Belag = { map: texture(c), tile, roughness };
cache.set(key, belag);
if (isFoto()) {
// Ohne eigenes Hoehenbild: Rauheit aus der Helligkeit ableiten - dunkle
// Stellen (Erde, Fugen, Schatten im Kies) sind matter.
belag.roughnessMap = derivedMap(c, (v) => 0.82 + (1 - v) * 0.3);
}
cache.set(k, belag);
return belag;
}
/**
* Leitet aus einem Bild eine lineare Graustufenkarte ab (fuer Rauheit oder
* Umgebungsverdeckung). `f` bekommt die Helligkeit 0..1 und liefert 0..1.
*/
function derivedMap(src: HTMLCanvasElement, f: (v: number) => number): CanvasTexture {
const sctx = src.getContext('2d', { willReadFrequently: true })!;
const d = sctx.getImageData(0, 0, PHYS, PHYS).data;
const [c, ctx] = canvas();
const out = ctx.createImageData(PHYS, PHYS);
for (let i = 0; i < d.length; i += 4) {
const v = (d[i] * 0.3 + d[i + 1] * 0.59 + d[i + 2] * 0.11) / 255;
const g = Math.max(0, Math.min(255, f(v) * 255));
out.data[i] = out.data[i + 1] = out.data[i + 2] = g;
out.data[i + 3] = 255;
}
ctx.putImageData(out, 0, 0);
const t = new CanvasTexture(c);
t.wrapS = t.wrapT = RepeatWrapping;
t.colorSpace = LinearSRGBColorSpace;
t.anisotropy = 8;
t.minFilter = LinearMipmapLinearFilter;
return t;
}
/**
* Rechnet ein Hoehenbild in eine Normalmap um (Sobel, kachelnd).
* Damit bekommen Fugen und Steinkanten echte Tiefe im Streiflicht.
*/
function normalFromHeight(src: HTMLCanvasElement, strength: number): CanvasTexture {
const sctx = src.getContext('2d', { willReadFrequently: true })!;
const h = sctx.getImageData(0, 0, SIZE, SIZE).data;
const N = PHYS;
const h = sctx.getImageData(0, 0, N, N).data;
const [c, ctx] = canvas();
const out = ctx.createImageData(SIZE, SIZE);
const out = ctx.createImageData(N, N);
const at = (x: number, y: number) =>
h[(((y + SIZE) % SIZE) * SIZE + ((x + SIZE) % SIZE)) * 4] / 255;
for (let y = 0; y < SIZE; y++) {
for (let x = 0; x < SIZE; x++) {
h[(((y + N) % N) * N + ((x + N) % N)) * 4] / 255;
for (let y = 0; y < N; y++) {
for (let x = 0; x < N; x++) {
const dx =
at(x - 1, y - 1) + 2 * at(x - 1, y) + at(x - 1, y + 1) -
at(x + 1, y - 1) - 2 * at(x + 1, y) - at(x + 1, y + 1);
const dy =
at(x - 1, y - 1) + 2 * at(x, y - 1) + at(x + 1, y - 1) -
at(x - 1, y + 1) - 2 * at(x, y + 1) - at(x + 1, y + 1);
const nx = dx * strength, ny = dy * strength, nz = 1;
// Leinwand-y laeuft nach unten, Textur-v nach oben: die y-Steigung muss
// umgedreht werden, sonst stehen Poren und Fugen als Buckel hervor.
const nx = dx * strength, ny = -dy * strength, nz = 1;
const len = Math.hypot(nx, ny, nz);
const k = (y * SIZE + x) * 4;
const k = (y * N + x) * 4;
out.data[k] = ((nx / len) * 0.5 + 0.5) * 255;
out.data[k + 1] = ((ny / len) * 0.5 + 0.5) * 255;
out.data[k + 2] = ((nz / len) * 0.5 + 0.5) * 255;
@@ -169,20 +219,29 @@ function makeRelief(
draw: (ctx: CanvasRenderingContext2D) => void,
drawHeight: (ctx: CanvasRenderingContext2D) => void,
): Belag {
const hit = cache.get(key);
const k = keyFor(key);
const hit = cache.get(k);
if (hit) return hit;
PHYS = sizeFor();
const [c, ctx] = canvas();
draw(ctx);
const [hc, hctx] = canvas();
drawHeight(hctx);
const belag: Belag = {
map: texture(c),
normalMap: normalFromHeight(hc, normalStrength),
// Bei doppelter Aufloesung ist ein Pixel halb so gross - die Steigung im
// Hoehenbild halbiert sich, die Staerke wird entsprechend angehoben.
normalMap: normalFromHeight(hc, normalStrength * (PHYS / SIZE)),
normalScale: new Vector2(1, 1),
tile,
roughness,
};
cache.set(key, belag);
if (isFoto()) {
// Fugen und Vertiefungen sind rauer und liegen im Schatten.
belag.roughnessMap = derivedMap(hc, (h) => 0.78 + (1 - h) * 0.4);
belag.aoMap = derivedMap(hc, (h) => 0.35 + Math.min(1, h * 1.25) * 0.65);
}
cache.set(k, belag);
return belag;
}