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>
755 lines
27 KiB
TypeScript
755 lines
27 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, 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) {
|
|
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: 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;
|
|
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 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 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);
|
|
});
|