Oben in der Mitte schaltet ein Knopf zwischen Stilisiert und Fotorealistisch. Beide Varianten entstehen aus derselben Geometrie; die zweite wird beim ersten Umschalten aufgebaut und bleibt danach bestehen. Fotorealistisch: - Texturen doppelt so fein, mit Rauheits- und Verdeckungskarten aus dem Hoehenbild. Das Motiv wird skaliert auf die groessere Leinwand gezeichnet, damit Scheite, Kiesel und Poren gleich gross bleiben. - Laub aus Blattkarten (Baumlaub, Eibennadeln, Liguster, Straeucher, Kraeuter) mit Volumen-Normalen und Alpha-Test-Schatten. - Baeume rekursiv gewachsen, mit gekruemmten Aesten und Blattwolken. - Rasen und Schotterwiese aus Grasbueschelen, die im Wind wiegen. - Physikalischer Himmel mit Umgebungslicht, Sonne zu Himmel etwa 5:1, Verlaufshimmel in der Daemmerung, 4096er Schatten. - GTAO, leichtes Bloom, MSAA. - Wiese ohne sichtbares Kachelmuster. Nebenbei behoben: - Normalmaps hatten die y-Steigung verkehrt: Poren und Fugen standen als Buckel hervor. Betrifft auch die stilisierte Ansicht. - Umlandkranz schliesst jetzt luecklos an das Raster an; vorher sah man von oben als helle Linie den Himmel durch den Spalt. - Glas im Fotomodus fast spiegelnd, Holzfassade mit Relief. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+78
-19
@@ -10,8 +10,19 @@ import {
|
||||
CanvasTexture, RepeatWrapping, SRGBColorSpace, LinearMipmapLinearFilter,
|
||||
LinearSRGBColorSpace, Vector2,
|
||||
} from 'three';
|
||||
import { isFoto } from './quality';
|
||||
|
||||
/**
|
||||
* Die Zeichenfunktionen rechnen mit einer logischen Kantenlaenge von SIZE.
|
||||
* Fuer die fotorealistische Variante wird dasselbe Motiv auf eine doppelt so
|
||||
* grosse Leinwand gezeichnet (PHYS), der Kontext skaliert mit. So bleiben
|
||||
* Holzscheite, Kiesel und Poren gleich gross, werden aber schaerfer - und die
|
||||
* Pixeloperationen (Rauschen, Normalen) arbeiten mit der vollen Aufloesung.
|
||||
*/
|
||||
const SIZE = 512;
|
||||
let PHYS = 512;
|
||||
const sizeFor = () => (isFoto() ? 1024 : 512);
|
||||
const keyFor = (key: string) => (isFoto() ? key + ':foto' : key);
|
||||
|
||||
/** Deterministischer Zufall, damit jeder Aufbau gleich aussieht. */
|
||||
function mulberry32(seed: number) {
|
||||
@@ -26,9 +37,10 @@ function mulberry32(seed: number) {
|
||||
|
||||
function canvas(): [HTMLCanvasElement, CanvasRenderingContext2D] {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = SIZE;
|
||||
c.height = SIZE;
|
||||
c.width = PHYS;
|
||||
c.height = PHYS;
|
||||
const ctx = c.getContext('2d', { willReadFrequently: true })!;
|
||||
ctx.setTransform(PHYS / SIZE, 0, 0, PHYS / SIZE, 0, 0);
|
||||
return [c, ctx];
|
||||
}
|
||||
|
||||
@@ -38,17 +50,18 @@ function noiseTile(ctx: CanvasRenderingContext2D, seed: number, cells: number, a
|
||||
const g: number[] = [];
|
||||
for (let i = 0; i < cells * cells; i++) g.push(rnd());
|
||||
const at = (i: number, j: number) => g[((j + cells) % cells) * cells + ((i + cells) % cells)];
|
||||
const img = ctx.getImageData(0, 0, SIZE, SIZE);
|
||||
const step = SIZE / cells;
|
||||
for (let y = 0; y < SIZE; y++) {
|
||||
const N = ctx.canvas.width;
|
||||
const img = ctx.getImageData(0, 0, N, N);
|
||||
const step = N / cells;
|
||||
for (let y = 0; y < N; y++) {
|
||||
const fy = y / step, j = Math.floor(fy), ty = fy - j;
|
||||
for (let x = 0; x < SIZE; x++) {
|
||||
for (let x = 0; x < N; x++) {
|
||||
const fx = x / step, i = Math.floor(fx), tx = fx - i;
|
||||
const sx = tx * tx * (3 - 2 * tx), sy = ty * ty * (3 - 2 * ty);
|
||||
const v =
|
||||
at(i, j) * (1 - sx) * (1 - sy) + at(i + 1, j) * sx * (1 - sy) +
|
||||
at(i, j + 1) * (1 - sx) * sy + at(i + 1, j + 1) * sx * sy;
|
||||
const k = (y * SIZE + x) * 4;
|
||||
const k = (y * N + x) * 4;
|
||||
const d = (dark ? -1 : 1) * (v - 0.5) * 2 * alpha * 255;
|
||||
img.data[k] = Math.max(0, Math.min(255, img.data[k] + d));
|
||||
img.data[k + 1] = Math.max(0, Math.min(255, img.data[k + 1] + d));
|
||||
@@ -103,6 +116,9 @@ function slabs(
|
||||
|
||||
export interface Belag {
|
||||
map: CanvasTexture;
|
||||
/** Nur fotorealistisch: Rauheit und Fugenschatten aus dem Hoehenbild. */
|
||||
roughnessMap?: CanvasTexture;
|
||||
aoMap?: CanvasTexture;
|
||||
/** Aus dem Fugenbild abgeleitete Normalmap - gibt den Belaegen Tiefe. */
|
||||
normalMap?: CanvasTexture;
|
||||
normalScale?: Vector2;
|
||||
@@ -114,37 +130,71 @@ export interface Belag {
|
||||
const cache = new Map<string, Belag>();
|
||||
|
||||
function make(key: string, tile: number, roughness: number, draw: (ctx: CanvasRenderingContext2D) => void): Belag {
|
||||
const hit = cache.get(key);
|
||||
const k = keyFor(key);
|
||||
const hit = cache.get(k);
|
||||
if (hit) return hit;
|
||||
PHYS = sizeFor();
|
||||
const [c, ctx] = canvas();
|
||||
draw(ctx);
|
||||
const belag: Belag = { map: texture(c), tile, roughness };
|
||||
cache.set(key, belag);
|
||||
if (isFoto()) {
|
||||
// Ohne eigenes Hoehenbild: Rauheit aus der Helligkeit ableiten - dunkle
|
||||
// Stellen (Erde, Fugen, Schatten im Kies) sind matter.
|
||||
belag.roughnessMap = derivedMap(c, (v) => 0.82 + (1 - v) * 0.3);
|
||||
}
|
||||
cache.set(k, belag);
|
||||
return belag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Leitet aus einem Bild eine lineare Graustufenkarte ab (fuer Rauheit oder
|
||||
* Umgebungsverdeckung). `f` bekommt die Helligkeit 0..1 und liefert 0..1.
|
||||
*/
|
||||
function derivedMap(src: HTMLCanvasElement, f: (v: number) => number): CanvasTexture {
|
||||
const sctx = src.getContext('2d', { willReadFrequently: true })!;
|
||||
const d = sctx.getImageData(0, 0, PHYS, PHYS).data;
|
||||
const [c, ctx] = canvas();
|
||||
const out = ctx.createImageData(PHYS, PHYS);
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const v = (d[i] * 0.3 + d[i + 1] * 0.59 + d[i + 2] * 0.11) / 255;
|
||||
const g = Math.max(0, Math.min(255, f(v) * 255));
|
||||
out.data[i] = out.data[i + 1] = out.data[i + 2] = g;
|
||||
out.data[i + 3] = 255;
|
||||
}
|
||||
ctx.putImageData(out, 0, 0);
|
||||
const t = new CanvasTexture(c);
|
||||
t.wrapS = t.wrapT = RepeatWrapping;
|
||||
t.colorSpace = LinearSRGBColorSpace;
|
||||
t.anisotropy = 8;
|
||||
t.minFilter = LinearMipmapLinearFilter;
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rechnet ein Hoehenbild in eine Normalmap um (Sobel, kachelnd).
|
||||
* Damit bekommen Fugen und Steinkanten echte Tiefe im Streiflicht.
|
||||
*/
|
||||
function normalFromHeight(src: HTMLCanvasElement, strength: number): CanvasTexture {
|
||||
const sctx = src.getContext('2d', { willReadFrequently: true })!;
|
||||
const h = sctx.getImageData(0, 0, SIZE, SIZE).data;
|
||||
const N = PHYS;
|
||||
const h = sctx.getImageData(0, 0, N, N).data;
|
||||
const [c, ctx] = canvas();
|
||||
const out = ctx.createImageData(SIZE, SIZE);
|
||||
const out = ctx.createImageData(N, N);
|
||||
const at = (x: number, y: number) =>
|
||||
h[(((y + SIZE) % SIZE) * SIZE + ((x + SIZE) % SIZE)) * 4] / 255;
|
||||
for (let y = 0; y < SIZE; y++) {
|
||||
for (let x = 0; x < SIZE; x++) {
|
||||
h[(((y + N) % N) * N + ((x + N) % N)) * 4] / 255;
|
||||
for (let y = 0; y < N; y++) {
|
||||
for (let x = 0; x < N; x++) {
|
||||
const dx =
|
||||
at(x - 1, y - 1) + 2 * at(x - 1, y) + at(x - 1, y + 1) -
|
||||
at(x + 1, y - 1) - 2 * at(x + 1, y) - at(x + 1, y + 1);
|
||||
const dy =
|
||||
at(x - 1, y - 1) + 2 * at(x, y - 1) + at(x + 1, y - 1) -
|
||||
at(x - 1, y + 1) - 2 * at(x, y + 1) - at(x + 1, y + 1);
|
||||
const nx = dx * strength, ny = dy * strength, nz = 1;
|
||||
// Leinwand-y laeuft nach unten, Textur-v nach oben: die y-Steigung muss
|
||||
// umgedreht werden, sonst stehen Poren und Fugen als Buckel hervor.
|
||||
const nx = dx * strength, ny = -dy * strength, nz = 1;
|
||||
const len = Math.hypot(nx, ny, nz);
|
||||
const k = (y * SIZE + x) * 4;
|
||||
const k = (y * N + x) * 4;
|
||||
out.data[k] = ((nx / len) * 0.5 + 0.5) * 255;
|
||||
out.data[k + 1] = ((ny / len) * 0.5 + 0.5) * 255;
|
||||
out.data[k + 2] = ((nz / len) * 0.5 + 0.5) * 255;
|
||||
@@ -169,20 +219,29 @@ function makeRelief(
|
||||
draw: (ctx: CanvasRenderingContext2D) => void,
|
||||
drawHeight: (ctx: CanvasRenderingContext2D) => void,
|
||||
): Belag {
|
||||
const hit = cache.get(key);
|
||||
const k = keyFor(key);
|
||||
const hit = cache.get(k);
|
||||
if (hit) return hit;
|
||||
PHYS = sizeFor();
|
||||
const [c, ctx] = canvas();
|
||||
draw(ctx);
|
||||
const [hc, hctx] = canvas();
|
||||
drawHeight(hctx);
|
||||
const belag: Belag = {
|
||||
map: texture(c),
|
||||
normalMap: normalFromHeight(hc, normalStrength),
|
||||
// Bei doppelter Aufloesung ist ein Pixel halb so gross - die Steigung im
|
||||
// Hoehenbild halbiert sich, die Staerke wird entsprechend angehoben.
|
||||
normalMap: normalFromHeight(hc, normalStrength * (PHYS / SIZE)),
|
||||
normalScale: new Vector2(1, 1),
|
||||
tile,
|
||||
roughness,
|
||||
};
|
||||
cache.set(key, belag);
|
||||
if (isFoto()) {
|
||||
// Fugen und Vertiefungen sind rauer und liegen im Schatten.
|
||||
belag.roughnessMap = derivedMap(hc, (h) => 0.78 + (1 - h) * 0.4);
|
||||
belag.aoMap = derivedMap(hc, (h) => 0.35 + Math.min(1, h * 1.25) * 0.65);
|
||||
}
|
||||
cache.set(k, belag);
|
||||
return belag;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user