Deploy App / deploy (push) Successful in 30s
Die Fotoansicht lief weder auf dem Handy noch auf dem Laptop rund. Pro Bild braucht sie jetzt rund ein Fuenftel der Rechenzeit (gemessen relativ im Software-Renderer), die stilisierte rund ein Viertel. - Schattenkarten nur noch bei Sonnenstand-, Ansichts- oder Torwechsel neu (shadowMap.autoUpdate = false). Gilt fuer beide Ansichten. - GTAO und Bloom entfernt: GTAO hat die Szene ein zweites Mal gezeichnet. - Gras in Felder von 3 x 3 m, nur bis 22 m gezeichnet, Dichte halbiert. - Blattkarten mit Lambert statt physikalischer Beleuchtung, Eigenleuchten aus der Blatttextur als Himmelsfuellung, das dem Tageslicht folgt. Etwa ein Drittel so viele Karten, dafuer etwas groesser. - Anpassungsfaehige Pixeldichte: unter rund 40 Bildern/s sinkt sie schrittweise bis 0.55, bei Reserve steigt sie wieder. - Ohne Bewegung wird nach knapp 2 s nicht mehr gezeichnet. - Umgebungskarte nur bei merklicher Sonnenstandsaenderung neu. - Auf Geraeten ohne Maus: 55 % der Blaetter und Halme, Travertin 1024 px, Schatten 2048. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
924 lines
34 KiB
TypeScript
924 lines
34 KiB
TypeScript
/**
|
|
* Prozedurale Texturen. Die Anwendung soll ohne externe Assets auskommen,
|
|
* deshalb werden alle Belaege zur Laufzeit auf ein Canvas gezeichnet.
|
|
*
|
|
* Konvention: Jede Textur bildet eine quadratische Weltflaeche von `tile`
|
|
* Metern ab. Die Meshes bekommen weltbezogene UVs (x/tile, z/tile), damit
|
|
* Plattenraster ueber Flaechengrenzen hinweg durchlaufen - so wie im Plan.
|
|
*/
|
|
import {
|
|
CanvasTexture, RepeatWrapping, SRGBColorSpace, LinearMipmapLinearFilter,
|
|
LinearSRGBColorSpace, Texture, Vector2,
|
|
} from 'three';
|
|
import { isFoto, leichtesGeraet } from './quality';
|
|
import { bild, fotoBereit, FOTO_M, type Satz } from './fotomaterial';
|
|
|
|
/**
|
|
* 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) {
|
|
let a = seed >>> 0;
|
|
return () => {
|
|
a = (a + 0x6d2b79f5) >>> 0;
|
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
};
|
|
}
|
|
|
|
function canvas(): [HTMLCanvasElement, CanvasRenderingContext2D] {
|
|
const c = document.createElement('canvas');
|
|
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];
|
|
}
|
|
|
|
/** Kachelbares Wertrauschen, wird fuer Koernung und Farbschwankung genutzt. */
|
|
function noiseTile(ctx: CanvasRenderingContext2D, seed: number, cells: number, alpha: number, dark = true) {
|
|
const rnd = mulberry32(seed);
|
|
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 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 < 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 * 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));
|
|
img.data[k + 2] = Math.max(0, Math.min(255, img.data[k + 2] + d));
|
|
}
|
|
}
|
|
ctx.putImageData(img, 0, 0);
|
|
}
|
|
|
|
/** Feine Koernung als Ueberlagerung, ohne die Kachelbarkeit zu stoeren. */
|
|
function grain(ctx: CanvasRenderingContext2D, seed: number, amount: number, dotSize = 1) {
|
|
const rnd = mulberry32(seed);
|
|
const n = Math.floor((SIZE * SIZE) / (24 * dotSize));
|
|
for (let i = 0; i < n; i++) {
|
|
const v = Math.floor(rnd() * 255);
|
|
ctx.fillStyle = `rgba(${v},${v},${v},${amount})`;
|
|
ctx.fillRect(rnd() * SIZE, rnd() * SIZE, dotSize, dotSize);
|
|
}
|
|
}
|
|
|
|
function texture(c: HTMLCanvasElement): CanvasTexture {
|
|
const t = new CanvasTexture(c);
|
|
t.wrapS = t.wrapT = RepeatWrapping;
|
|
t.colorSpace = SRGBColorSpace;
|
|
t.anisotropy = 8;
|
|
t.minFilter = LinearMipmapLinearFilter;
|
|
t.generateMipmaps = true;
|
|
return t;
|
|
}
|
|
|
|
/** Zeichnet ein Plattenraster mit Fuge. slabW/slabH in Metern. */
|
|
function slabs(
|
|
ctx: CanvasRenderingContext2D, tile: number, slabW: number, slabH: number,
|
|
base: string, jointColor: string, jointM: number, seed: number, variation: number,
|
|
) {
|
|
const px = SIZE / tile;
|
|
ctx.fillStyle = jointColor;
|
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
|
const rnd = mulberry32(seed);
|
|
const cols = Math.round(tile / slabW);
|
|
const rows = Math.round(tile / slabH);
|
|
const j = Math.max(1, jointM * px);
|
|
const [r, g, b] = base.match(/\d+/g)!.map(Number);
|
|
for (let row = 0; row < rows; row++) {
|
|
for (let col = 0; col < cols; col++) {
|
|
const d = (rnd() - 0.5) * 2 * variation;
|
|
ctx.fillStyle = `rgb(${Math.round(r + d)},${Math.round(g + d)},${Math.round(b + d)})`;
|
|
ctx.fillRect(col * slabW * px + j / 2, row * slabH * px + j / 2, slabW * px - j, slabH * px - j);
|
|
}
|
|
}
|
|
}
|
|
|
|
export interface Belag {
|
|
map: Texture;
|
|
/** Nur fotorealistisch: Rauheit und Fugenschatten aus dem Hoehenbild. */
|
|
roughnessMap?: Texture;
|
|
aoMap?: Texture;
|
|
/** Aus dem Fugenbild abgeleitete Normalmap - gibt den Belaegen Tiefe. */
|
|
normalMap?: Texture;
|
|
normalScale?: Vector2;
|
|
tile: number;
|
|
roughness: number;
|
|
color?: number;
|
|
}
|
|
|
|
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;
|
|
PHYS = sizeFor();
|
|
const [c, ctx] = canvas();
|
|
draw(ctx);
|
|
const belag: Belag = { map: texture(c), tile, roughness };
|
|
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 N = PHYS;
|
|
const h = sctx.getImageData(0, 0, N, N).data;
|
|
const [c, ctx] = canvas();
|
|
const out = ctx.createImageData(N, N);
|
|
const at = (x: number, y: number) =>
|
|
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);
|
|
// 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 * 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;
|
|
out.data[k + 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;
|
|
}
|
|
|
|
/**
|
|
* Wie `make`, zusaetzlich mit einem Hoehenbild fuer die Normalmap.
|
|
* `drawHeight` zeichnet Graustufen: hell = hoch, dunkel = Fuge.
|
|
*/
|
|
function makeRelief(
|
|
key: string, tile: number, roughness: number, normalStrength: number,
|
|
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;
|
|
PHYS = sizeFor();
|
|
const [c, ctx] = canvas();
|
|
draw(ctx);
|
|
const [hc, hctx] = canvas();
|
|
drawHeight(hctx);
|
|
const belag: Belag = {
|
|
map: texture(c),
|
|
// 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,
|
|
};
|
|
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;
|
|
}
|
|
|
|
/** Zeichnet dasselbe Plattenraster als Hoehenbild: Platte hell, Fuge dunkel. */
|
|
function slabHeight(
|
|
ctx: CanvasRenderingContext2D, tile: number, slabW: number, slabH: number,
|
|
jointM: number, chamfer = 0.012,
|
|
) {
|
|
const px = SIZE / tile;
|
|
ctx.fillStyle = 'rgb(40,40,40)';
|
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
|
const cols = Math.round(tile / slabW);
|
|
const rows = Math.round(tile / slabH);
|
|
const j = Math.max(1.5, jointM * px);
|
|
const ch = Math.max(1, chamfer * px);
|
|
for (let row = 0; row < rows; row++) {
|
|
for (let col = 0; col < cols; col++) {
|
|
const x = col * slabW * px + j / 2;
|
|
const y = row * slabH * px + j / 2;
|
|
const w = slabW * px - j, hgt = slabH * px - j;
|
|
// Fase aussen, Plattenspiegel innen
|
|
ctx.fillStyle = 'rgb(170,170,170)';
|
|
ctx.fillRect(x, y, w, hgt);
|
|
ctx.fillStyle = 'rgb(235,235,235)';
|
|
ctx.fillRect(x + ch, y + ch, w - 2 * ch, hgt - 2 * ch);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Belaege
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Travertin-Oberflaeche: warme Wolken und feine Poren. */
|
|
function travertinFinish(ctx: CanvasRenderingContext2D, seed: number) {
|
|
noiseTile(ctx, seed, 48, 0.035);
|
|
noiseTile(ctx, seed + 100, 144, 0.025);
|
|
const rnd = mulberry32(seed + 7);
|
|
// Poren und Adern, typisch fuer Travertin
|
|
for (let i = 0; i < 900; i++) {
|
|
ctx.fillStyle = `rgba(146,130,104,${0.05 + rnd() * 0.09})`;
|
|
ctx.beginPath();
|
|
ctx.ellipse(rnd() * SIZE, rnd() * SIZE, 1 + rnd() * 5, 0.6 + rnd() * 1.6, rnd() * 0.6, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
for (let i = 0; i < 40; i++) {
|
|
ctx.strokeStyle = `rgba(146,130,104,${0.06 + rnd() * 0.08})`;
|
|
ctx.lineWidth = 1 + rnd() * 2;
|
|
ctx.beginPath();
|
|
const y = rnd() * SIZE;
|
|
ctx.moveTo(0, y);
|
|
for (let x = 0; x <= SIZE; x += 32) ctx.lineTo(x, y + Math.sin(x / 40 + i) * 3);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
/** Travertinplatten 80 x 80 x 3 cm, Hauptsitzplatz. */
|
|
export const travertin80 = () => makeRelief('travertin80', 3.2, 0.70, 3.2,
|
|
(ctx) => {
|
|
slabs(ctx, 3.2, 0.8, 0.8, 'rgb(224,214,192)', 'rgb(176,166,146)', 0.010, 11, 6);
|
|
travertinFinish(ctx, 21);
|
|
},
|
|
(ctx) => slabHeight(ctx, 3.2, 0.8, 0.8, 0.010, 0.015));
|
|
|
|
/** Einzelne Travertinplatte 40 x 60 x 3 cm - Textur fuer die losen Platten. */
|
|
export const travertinSlab = () => makeRelief('travertinSlab', 0.8, 0.70, 2.2,
|
|
(ctx) => {
|
|
ctx.fillStyle = 'rgb(228,218,196)';
|
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
|
travertinFinish(ctx, 26);
|
|
},
|
|
(ctx) => { ctx.fillStyle = 'rgb(210,210,210)'; ctx.fillRect(0, 0, SIZE, SIZE); noiseTile(ctx, 27, 64, 0.06); });
|
|
|
|
/** Travertinstellplatten als schmaler Randstreifen. */
|
|
export const travertinstell = () => makeRelief('travertinstell', 2.4, 0.73, 3.0,
|
|
(ctx) => {
|
|
slabs(ctx, 2.4, 0.4, 0.4, 'rgb(221,210,188)', 'rgb(172,162,142)', 0.012, 13, 7);
|
|
travertinFinish(ctx, 23);
|
|
},
|
|
(ctx) => slabHeight(ctx, 2.4, 0.4, 0.4, 0.012, 0.012));
|
|
|
|
/** Keramikplatten 60 x 60 x 2 cm, grau - bauseits vorhanden (siehe Fotos). */
|
|
export const keramik = () => makeRelief('keramik', 2.4, 0.52, 2.6,
|
|
(ctx) => {
|
|
slabs(ctx, 2.4, 0.6, 0.6, 'rgb(160,158,154)', 'rgb(118,117,114)', 0.006, 14, 5);
|
|
noiseTile(ctx, 24, 48, 0.035);
|
|
noiseTile(ctx, 124, 160, 0.02);
|
|
grain(ctx, 34, 0.04, 1);
|
|
},
|
|
(ctx) => slabHeight(ctx, 2.4, 0.6, 0.6, 0.006, 0.008));
|
|
|
|
/** Tegula-Pflastersteine grau, kleinformatig und leicht unregelmaessig. */
|
|
export const tegula = () => makeRelief('tegula', 1.2, 0.84, 3.4,
|
|
(ctx) => {
|
|
const px = SIZE / 1.2;
|
|
ctx.fillStyle = 'rgb(104,102,100)';
|
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
|
const rnd = mulberry32(15);
|
|
const sw = 0.15, sh = 0.1;
|
|
const cols = Math.round(1.2 / sw), rows = Math.round(1.2 / sh);
|
|
for (let row = 0; row < rows; row++) {
|
|
const off = (row % 2) * 0.5;
|
|
for (let col = -1; col <= cols; col++) {
|
|
const v = 138 + rnd() * 36;
|
|
ctx.fillStyle = `rgb(${v | 0},${(v - 2) | 0},${(v - 6) | 0})`;
|
|
ctx.fillRect((col + off) * sw * px + 1.6, row * sh * px + 1.6, sw * px - 3.2, sh * px - 3.2);
|
|
}
|
|
}
|
|
noiseTile(ctx, 25, 24, 0.06);
|
|
grain(ctx, 35, 0.10, 1);
|
|
},
|
|
(ctx) => {
|
|
const px = SIZE / 1.2;
|
|
ctx.fillStyle = 'rgb(40,40,40)';
|
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
|
const rnd = mulberry32(115);
|
|
const sw = 0.15, sh = 0.1;
|
|
for (let row = 0; row < Math.round(1.2 / sh); row++) {
|
|
const off = (row % 2) * 0.5;
|
|
for (let col = -1; col <= Math.round(1.2 / sw); col++) {
|
|
const v = 200 + rnd() * 45;
|
|
ctx.fillStyle = `rgb(${v | 0},${v | 0},${v | 0})`;
|
|
ctx.fillRect((col + off) * sw * px + 2.2, row * sh * px + 2.2, sw * px - 4.4, sh * px - 4.4);
|
|
}
|
|
}
|
|
});
|
|
|
|
/** Verbundsteine des Nachbarn, etwas dunkler als Tegula. */
|
|
export const verbundstein = () => makeRelief('verbundstein', 1.2, 0.88, 3.0,
|
|
(ctx) => {
|
|
const px = SIZE / 1.2;
|
|
ctx.fillStyle = 'rgb(100,98,96)';
|
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
|
const rnd = mulberry32(16);
|
|
for (let row = 0; row < 8; row++) {
|
|
for (let col = 0; col < 8; col++) {
|
|
const v = 124 + rnd() * 32;
|
|
ctx.fillStyle = `rgb(${v | 0},${v | 0},${(v - 4) | 0})`;
|
|
ctx.fillRect(col * 0.15 * px + 1.8, row * 0.15 * px + 1.8, 0.15 * px - 3.6, 0.15 * px - 3.6);
|
|
}
|
|
}
|
|
grain(ctx, 36, 0.08, 1);
|
|
},
|
|
(ctx) => slabHeight(ctx, 1.2, 0.15, 0.15, 0.008, 0.006));
|
|
|
|
/** Rasen: gemaehte Flaeche mit leichten Bahnen. */
|
|
export const rasen = () => make('rasen', 2.0, 0.95, (ctx) => {
|
|
ctx.fillStyle = 'rgb(96,133,62)';
|
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
|
noiseTile(ctx, 41, 16, 0.09);
|
|
noiseTile(ctx, 42, 64, 0.07);
|
|
const rnd = mulberry32(43);
|
|
for (let i = 0; i < 5200; i++) {
|
|
const x = rnd() * SIZE, y = rnd() * SIZE;
|
|
const v = rnd();
|
|
ctx.strokeStyle = `rgba(${(78 + v * 62) | 0},${(118 + v * 66) | 0},${(44 + v * 40) | 0},0.55)`;
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, y);
|
|
ctx.lineTo(x + (rnd() - 0.5) * 3, y - 2 - rnd() * 4);
|
|
ctx.stroke();
|
|
}
|
|
});
|
|
|
|
/** Schotterwiese: magerer Kiesrasen, heller und offener als der Rasen. */
|
|
export const schotterwiese = () => make('schotterwiese', 2.0, 0.98, (ctx) => {
|
|
ctx.fillStyle = 'rgb(139,146,104)';
|
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
|
noiseTile(ctx, 44, 20, 0.1);
|
|
const rnd = mulberry32(45);
|
|
for (let i = 0; i < 2600; i++) {
|
|
const v = 150 + rnd() * 70;
|
|
ctx.fillStyle = `rgba(${v | 0},${(v - 4) | 0},${(v - 18) | 0},0.8)`;
|
|
const r = 1 + rnd() * 2.4;
|
|
ctx.beginPath();
|
|
ctx.arc(rnd() * SIZE, rnd() * SIZE, r, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
for (let i = 0; i < 1800; i++) {
|
|
const x = rnd() * SIZE, y = rnd() * SIZE;
|
|
ctx.strokeStyle = `rgba(112,138,64,0.5)`;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, y);
|
|
ctx.lineTo(x + (rnd() - 0.5) * 3, y - 3 - rnd() * 3);
|
|
ctx.stroke();
|
|
}
|
|
});
|
|
|
|
/** Dekorkies in den Pflanzflaechen und um den Flammengrill. */
|
|
export const dekorkies = () => make('dekorkies', 1.0, 0.99, (ctx) => {
|
|
ctx.fillStyle = 'rgb(118,111,99)';
|
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
|
const rnd = mulberry32(46);
|
|
for (let i = 0; i < 6500; i++) {
|
|
const v = 104 + rnd() * 78;
|
|
ctx.fillStyle = `rgb(${v | 0},${(v - 5) | 0},${(v - 16) | 0})`;
|
|
ctx.beginPath();
|
|
ctx.ellipse(rnd() * SIZE, rnd() * SIZE, 2 + rnd() * 4, 2 + rnd() * 3, rnd() * 3.14, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
noiseTile(ctx, 47, 24, 0.07);
|
|
});
|
|
|
|
/** Pflanzbeet: dunkler Humus mit Rindenanteil. */
|
|
export const beet = () => make('beet', 1.5, 0.99, (ctx) => {
|
|
ctx.fillStyle = 'rgb(74,58,44)';
|
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
|
noiseTile(ctx, 48, 20, 0.12);
|
|
const rnd = mulberry32(49);
|
|
for (let i = 0; i < 2200; i++) {
|
|
const v = 60 + rnd() * 60;
|
|
ctx.fillStyle = `rgb(${v | 0},${(v - 12) | 0},${(v - 24) | 0})`;
|
|
ctx.save();
|
|
ctx.translate(rnd() * SIZE, rnd() * SIZE);
|
|
ctx.rotate(rnd() * 3.14);
|
|
ctx.fillRect(0, 0, 3 + rnd() * 9, 2 + rnd() * 3);
|
|
ctx.restore();
|
|
}
|
|
});
|
|
|
|
/** Spielsand. */
|
|
export const spielsand = () => make('spielsand', 1.5, 1.0, (ctx) => {
|
|
ctx.fillStyle = 'rgb(211,192,155)';
|
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
|
noiseTile(ctx, 50, 28, 0.05);
|
|
grain(ctx, 51, 0.16, 1);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bauteile
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Travertin als Steinoberflaeche - warm, geschichtet, mit den typischen
|
|
* Poren. Die Schichtung der Mauer entsteht in walls.ts aus echten Bloecken,
|
|
* die Textur liefert nur das Material.
|
|
*/
|
|
export const travertinMauer = () => makeRelief('travertinMauer', 0.7, 0.68, 1.9,
|
|
(ctx) => {
|
|
ctx.fillStyle = 'rgb(221,208,182)';
|
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
|
const rnd = mulberry32(61);
|
|
// liegende Sedimentbaender, bewusst zurueckhaltend - sonst wirkt der Stein
|
|
// aus der Naehe fleckig statt geschichtet
|
|
for (let i = 0; i < 34; i++) {
|
|
const y = rnd() * SIZE;
|
|
const hgt = 3 + rnd() * 12;
|
|
ctx.fillStyle = `rgba(${(206 + rnd() * 18) | 0},${(192 + rnd() * 16) | 0},${(166 + rnd() * 14) | 0},0.32)`;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y);
|
|
for (let x = 0; x <= SIZE; x += 24) ctx.lineTo(x, y + Math.sin(x / 55 + i) * 4);
|
|
for (let x = SIZE; x >= 0; x -= 24) ctx.lineTo(x, y + hgt + Math.sin(x / 48 + i * 1.7) * 4);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
}
|
|
travertinFinish(ctx, 62);
|
|
grain(ctx, 63, 0.05, 2);
|
|
},
|
|
(ctx) => {
|
|
ctx.fillStyle = 'rgb(190,190,190)';
|
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
|
const rnd = mulberry32(64);
|
|
// Poren als feine Vertiefungen
|
|
for (let i = 0; i < 2400; i++) {
|
|
ctx.fillStyle = `rgba(60,60,60,${0.18 + rnd() * 0.32})`;
|
|
ctx.beginPath();
|
|
ctx.ellipse(rnd() * SIZE, rnd() * SIZE, 0.8 + rnd() * 2.2, 0.6 + rnd() * 1.1, rnd() * 0.7, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
noiseTile(ctx, 65, 96, 0.07);
|
|
});
|
|
|
|
/** Sichtbeton der Winkelelemente und der Nachbarmauer. */
|
|
export const beton = () => make('beton', 2.0, 0.8, (ctx) => {
|
|
ctx.fillStyle = 'rgb(176,174,169)';
|
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
|
noiseTile(ctx, 64, 12, 0.05);
|
|
noiseTile(ctx, 65, 48, 0.025);
|
|
grain(ctx, 66, 0.04, 1);
|
|
// Schalungsstoesse
|
|
ctx.strokeStyle = 'rgba(120,118,114,0.35)';
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, SIZE / 2); ctx.lineTo(SIZE, SIZE / 2);
|
|
ctx.moveTo(SIZE / 2, 0); ctx.lineTo(SIZE / 2, SIZE);
|
|
ctx.stroke();
|
|
ctx.fillStyle = 'rgba(120,118,114,0.4)';
|
|
for (const [x, y] of [[SIZE * 0.25, SIZE * 0.25], [SIZE * 0.75, SIZE * 0.25], [SIZE * 0.25, SIZE * 0.75], [SIZE * 0.75, SIZE * 0.75]]) {
|
|
ctx.beginPath(); ctx.arc(x, y, 3.5, 0, Math.PI * 2); ctx.fill();
|
|
}
|
|
});
|
|
|
|
/** Gestapelte Holzscheiter der Scheiterwand hinter dem Flammengrill. */
|
|
export const holzscheiter = () => make('holzscheiter', 1.0, 0.9, (ctx) => {
|
|
ctx.fillStyle = 'rgb(48,38,30)';
|
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
|
const rnd = mulberry32(67);
|
|
for (let i = 0; i < 260; i++) {
|
|
const r = 12 + rnd() * 16;
|
|
const x = rnd() * SIZE, y = rnd() * SIZE;
|
|
const v = 128 + rnd() * 74;
|
|
ctx.fillStyle = `rgb(${v | 0},${(v - 26) | 0},${(v - 56) | 0})`;
|
|
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
|
|
ctx.strokeStyle = 'rgba(60,44,32,0.75)';
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
ctx.strokeStyle = 'rgba(90,66,46,0.5)';
|
|
ctx.lineWidth = 1;
|
|
for (let k = 1; k <= 2; k++) {
|
|
ctx.beginPath(); ctx.arc(x, y, (r * k) / 3, 0, Math.PI * 2); ctx.stroke();
|
|
}
|
|
}
|
|
});
|
|
|
|
/** Vertikale Holzschalung der Fassade - graubraun vergraut, wie auf den Fotos. */
|
|
export const fassadeHolz = () => makeRelief('fassadeHolz', 1.2, 0.78, 3.6,
|
|
(ctx) => {
|
|
const px = SIZE / 1.2;
|
|
ctx.fillStyle = 'rgb(96,88,76)';
|
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
|
const rnd = mulberry32(71);
|
|
const w = 0.06;
|
|
for (let col = 0; col < Math.round(1.2 / w); col++) {
|
|
const v = 150 + rnd() * 34;
|
|
ctx.fillStyle = `rgb(${v | 0},${(v - 9) | 0},${(v - 22) | 0})`;
|
|
ctx.fillRect(col * w * px + 1.5, 0, w * px - 3, SIZE);
|
|
// Maserung in Faserrichtung
|
|
ctx.strokeStyle = 'rgba(84,76,66,0.42)';
|
|
ctx.lineWidth = 1;
|
|
for (let i = 0; i < 16; i++) {
|
|
const y = rnd() * SIZE;
|
|
ctx.beginPath();
|
|
ctx.moveTo(col * w * px + 2, y);
|
|
ctx.lineTo(col * w * px + w * px - 3, y + (rnd() - 0.5) * 6);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
noiseTile(ctx, 72, 32, 0.035);
|
|
},
|
|
(ctx) => {
|
|
// Hoehenbild: jede Latte gewoelbt, dazwischen die Schattenfuge
|
|
const px = SIZE / 1.2;
|
|
ctx.fillStyle = 'rgb(30,30,30)';
|
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
|
const w = 0.06;
|
|
for (let col = 0; col < Math.round(1.2 / w); col++) {
|
|
const x0 = col * w * px + 2.2, wPx = w * px - 4.4;
|
|
const g = ctx.createLinearGradient(x0, 0, x0 + wPx, 0);
|
|
g.addColorStop(0, 'rgb(150,150,150)');
|
|
g.addColorStop(0.5, 'rgb(240,240,240)');
|
|
g.addColorStop(1, 'rgb(150,150,150)');
|
|
ctx.fillStyle = g;
|
|
ctx.fillRect(x0, 0, wPx, SIZE);
|
|
}
|
|
});
|
|
|
|
/** Heller Betonsockel unter der Holzschalung. */
|
|
export const sockel = () => make('sockel', 2.0, 0.72, (ctx) => {
|
|
ctx.fillStyle = 'rgb(214,211,204)';
|
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
|
noiseTile(ctx, 73, 16, 0.035);
|
|
grain(ctx, 74, 0.03, 1);
|
|
});
|
|
|
|
/** Extensive Dachbegruenung der Flachdaecher. */
|
|
export const gruendach = () => make('gruendach', 2.0, 1.0, (ctx) => {
|
|
ctx.fillStyle = 'rgb(108,116,72)';
|
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
|
noiseTile(ctx, 75, 18, 0.13);
|
|
const rnd = mulberry32(76);
|
|
for (let i = 0; i < 2800; i++) {
|
|
const v = rnd();
|
|
ctx.fillStyle = `rgba(${(96 + v * 80) | 0},${(112 + v * 60) | 0},${(58 + v * 40) | 0},0.75)`;
|
|
ctx.beginPath();
|
|
ctx.arc(rnd() * SIZE, rnd() * SIZE, 1.5 + rnd() * 3.5, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
});
|
|
|
|
/** Dunkler Spritzschutz-Kiesstreifen entlang der Fassade (siehe Fotos). */
|
|
export const spritzschutz = () => make('spritzschutz', 1.0, 0.98, (ctx) => {
|
|
ctx.fillStyle = 'rgb(62,62,66)';
|
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
|
const rnd = mulberry32(77);
|
|
for (let i = 0; i < 4200; i++) {
|
|
const v = 52 + rnd() * 78;
|
|
ctx.fillStyle = `rgb(${v | 0},${v | 0},${(v + 6) | 0})`;
|
|
ctx.beginPath();
|
|
ctx.ellipse(rnd() * SIZE, rnd() * SIZE, 2 + rnd() * 4, 2 + rnd() * 3, rnd() * 3.14, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Rohboden mit spaerlichem Bewuchs. Das ist der Grundton des Gelaendes -
|
|
* die eigentlichen Flaechen des Plans liegen als eigene Meshes darueber.
|
|
*/
|
|
export const rohboden = () => make('rohboden', 2.0, 0.99, (ctx) => {
|
|
ctx.fillStyle = 'rgb(104,122,68)';
|
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
|
noiseTile(ctx, 81, 14, 0.11);
|
|
noiseTile(ctx, 82, 56, 0.06);
|
|
const rnd = mulberry32(83);
|
|
// Wiesenhalme, unregelmaessiger und matter als der gemaehte Rasen
|
|
for (let i = 0; i < 4200; i++) {
|
|
const x = rnd() * SIZE, y = rnd() * SIZE;
|
|
const v = rnd();
|
|
ctx.strokeStyle = `rgba(${(88 + v * 66) | 0},${(112 + v * 62) | 0},${(52 + v * 44) | 0},0.6)`;
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, y);
|
|
ctx.lineTo(x + (rnd() - 0.5) * 4, y - 3 - rnd() * 5);
|
|
ctx.stroke();
|
|
}
|
|
// vereinzelt offene Bodenstellen
|
|
for (let i = 0; i < 500; i++) {
|
|
ctx.fillStyle = `rgba(122,110,84,${0.15 + rnd() * 0.25})`;
|
|
ctx.beginPath();
|
|
ctx.arc(rnd() * SIZE, rnd() * SIZE, 2 + rnd() * 6, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
grain(ctx, 84, 0.04, 1);
|
|
});
|
|
|
|
/**
|
|
* Laub fuer geschnittene Hecken: dichte, kleinblaettrige Oberflaeche mit
|
|
* Relief. Ohne das wirken die Heckenkoerper wie lackierte Bloecke.
|
|
*/
|
|
function laub(key: string, base: string, seed: number, leaf: number): Belag {
|
|
return makeRelief(key, 0.55, 0.97, 2.6,
|
|
(ctx) => {
|
|
ctx.fillStyle = base;
|
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
|
const rnd = mulberry32(seed);
|
|
const [r, g, b] = base.match(/\d+/g)!.map(Number);
|
|
for (let i = 0; i < 5200; i++) {
|
|
const v = (rnd() - 0.45) * 70;
|
|
ctx.fillStyle = `rgb(${Math.max(0, Math.min(255, r + v)) | 0},${Math.max(0, Math.min(255, g + v * 1.15)) | 0},${Math.max(0, Math.min(255, b + v * 0.7)) | 0})`;
|
|
ctx.save();
|
|
ctx.translate(rnd() * SIZE, rnd() * SIZE);
|
|
ctx.rotate(rnd() * Math.PI);
|
|
ctx.beginPath();
|
|
ctx.ellipse(0, 0, leaf * (0.7 + rnd() * 0.6), leaf * (0.4 + rnd() * 0.35), 0, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
noiseTile(ctx, seed + 5, 12, 0.10);
|
|
},
|
|
(ctx) => {
|
|
ctx.fillStyle = 'rgb(120,120,120)';
|
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
|
const rnd = mulberry32(seed + 11);
|
|
for (let i = 0; i < 3600; i++) {
|
|
const v = 90 + rnd() * 150;
|
|
ctx.fillStyle = `rgb(${v | 0},${v | 0},${v | 0})`;
|
|
ctx.save();
|
|
ctx.translate(rnd() * SIZE, rnd() * SIZE);
|
|
ctx.rotate(rnd() * Math.PI);
|
|
ctx.beginPath();
|
|
ctx.ellipse(0, 0, leaf * 0.9, leaf * 0.5, 0, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
noiseTile(ctx, seed + 13, 10, 0.16);
|
|
});
|
|
}
|
|
|
|
/** Eibe: dunkel, feinnadelig. */
|
|
export const laubEibe = () => laub('laubEibe', 'rgb(64,92,52)', 201, 3.4);
|
|
/** Baumkrone: groesseres, helleres Blatt mit staerkerer Streuung. */
|
|
export const laubBaum = () => laub('laubBaum', 'rgb(105,132,68)', 223, 7.0);
|
|
/** Liguster: mittelgruen, groesseres Blatt. */
|
|
export const laubLiguster = () => laub('laubLiguster', 'rgb(85,118,55)', 211, 5.2);
|
|
|
|
/**
|
|
* Travertin fuer die aufrechten Stelen. Der Stein ist gesaegt und zeigt die
|
|
* Schichtung als feine, liegende Baender - so wie auf dem Referenzbild des
|
|
* Planers. Die Kachel ist klein, weil eine Stele nur 50 cm breit ist.
|
|
*/
|
|
export const travertinStele = () => makeRelief('travertinStele', 0.5, 0.62, 1.7,
|
|
(ctx) => {
|
|
ctx.fillStyle = 'rgb(226,214,190)';
|
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
|
const rnd = mulberry32(91);
|
|
for (let i = 0; i < 46; i++) {
|
|
const y = rnd() * SIZE;
|
|
const hgt = 2 + rnd() * 9;
|
|
ctx.fillStyle = `rgba(${(204 + rnd() * 22) | 0},${(190 + rnd() * 20) | 0},${(162 + rnd() * 18) | 0},0.42)`;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y);
|
|
for (let x = 0; x <= SIZE; x += 32) ctx.lineTo(x, y + Math.sin(x / 70 + i) * 2.5);
|
|
for (let x = SIZE; x >= 0; x -= 32) ctx.lineTo(x, y + hgt + Math.sin(x / 62 + i * 1.4) * 2.5);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
}
|
|
travertinFinish(ctx, 92);
|
|
grain(ctx, 93, 0.04, 1);
|
|
},
|
|
(ctx) => {
|
|
ctx.fillStyle = 'rgb(196,196,196)';
|
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
|
const rnd = mulberry32(94);
|
|
for (let i = 0; i < 1800; i++) {
|
|
ctx.fillStyle = `rgba(70,70,70,${0.15 + rnd() * 0.3})`;
|
|
ctx.beginPath();
|
|
ctx.ellipse(rnd() * SIZE, rnd() * SIZE, 0.7 + rnd() * 2.0, 0.5 + rnd() * 0.9, 0, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
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 = leichtesGeraet() ? Math.min(d.px, 1024) : 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,
|
|
};
|
|
}
|