/** * 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(); 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 = { 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 = { 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, }; }