Resource Manager: Materialien-Tab mit PBR-Kugel-Vorschau, Suche und Kategorie-Chips

Geteilter Offscreen-three.js-Renderer (ein Kontext, serielle Queue, Cache
per Map-Signatur) rendert 128px-Kugeln lazy via IntersectionObserver.
Kategorie-Chips aus der Bibliothek abgeleitet, uneinheitliche Manifest-
Schreibweisen normalisiert; Suche und Kategorie kombinierbar. Kachel-Klick
markiert aktiv (gleiche Sprache wie MaterialPicker). Puppeteer-Probe
scripts/probe-material-tiles.mjs prüft Grid, Lazy-Render und Filter.
This commit is contained in:
2026-07-03 02:04:33 +02:00
parent 519735c782
commit 87d12b976f
6 changed files with 597 additions and 1 deletions
+223
View File
@@ -0,0 +1,223 @@
// PBR-Materialkugel-Vorschau (CAD/DCC-üblich) für den Ressourcen-Manager.
// EIN geteilter offscreen three.js-Renderer erzeugt nacheinander pro Material
// eine Kugel und cached das Ergebnis als PNG-DataURL — bewusst NICHT ein
// WebGL-Context je Kachel (Browser begrenzt gleichzeitige Kontexte). Aufträge
// laufen seriell über eine Warteschlange, damit Renderer/Szene/Material sich
// nie zwei Materialien gleichzeitig "gehören".
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import * as THREE from "three";
import type { MaterialAsset, MaterialMapKind } from "./library";
/** Kantenlänge der gerenderten Vorschau in Pixeln (CSS skaliert die Kachel). */
const PREVIEW_SIZE = 128;
type PreviewStatus = "pending" | "ready" | "error";
interface PreviewEntry {
status: PreviewStatus;
url?: string;
subscribers: Set<(url: string) => void>;
}
/** Fertige/aktive Vorschauen, Schlüssel = stabile Material-Signatur. */
const cache = new Map<string, PreviewEntry>();
/**
* Stabile Cache-Signatur eines Assets: ändert sich, sobald sich eine Karten-
* URL ändert (z. B. weil die Bibliothek neu generiert wurde) — dadurch wird
* eine veraltete Vorschau automatisch verworfen, auch bei gleicher ID.
*/
function signatureOf(asset: MaterialAsset): string {
const m = asset.maps;
return [
asset.id,
m.color ?? "",
m.normal ?? "",
m.roughness ?? "",
m.metalness ?? "",
m.ao ?? "",
m.displacement ?? "",
].join("|");
}
// ── Geteilte Render-Infrastruktur (eine Instanz für die gesamte App-Laufzeit) ─
let renderer: THREE.WebGLRenderer | null = null;
let scene: THREE.Scene | null = null;
let camera: THREE.PerspectiveCamera | null = null;
let sphereMesh: THREE.Mesh<THREE.SphereGeometry, THREE.MeshStandardMaterial> | null =
null;
function ensureRenderer(): void {
if (renderer) return;
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
preserveDrawingBuffer: true, // nötig für toDataURL() nach dem Render.
});
renderer.setSize(PREVIEW_SIZE, PREVIEW_SIZE);
renderer.setPixelRatio(1);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setClearColor(0x000000, 0); // transparent — die Kachel liefert den Hintergrund.
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(32, 1, 0.1, 10);
camera.position.set(0, 0.12, 3.1);
camera.lookAt(0, 0, 0);
// Neutrale Studio-Beleuchtung: Himmelslicht (füllt Schatten weich auf) plus
// ein Key-Light für Glanzlichter/Rauheits-Lesbarkeit, ein dezentes Fülllicht
// gegen zu harte Kernschatten.
scene.add(new THREE.HemisphereLight(0xffffff, 0x2c2c28, 1.15));
const key = new THREE.DirectionalLight(0xffffff, 2.2);
key.position.set(2.2, 2.6, 2.4);
scene.add(key);
const fill = new THREE.DirectionalLight(0xffffff, 0.45);
fill.position.set(-2.2, 0.5, 1.3);
scene.add(fill);
const geometry = new THREE.SphereGeometry(1, 48, 48);
const material = new THREE.MeshStandardMaterial({ roughness: 1, metalness: 0 });
sphereMesh = new THREE.Mesh(geometry, material);
scene.add(sphereMesh);
}
// ── Textur-Ladecache (geteilte Quell-Texturen je URL) ──────────────────────
const textureLoader = new THREE.TextureLoader();
const textureCache = new Map<string, THREE.Texture>();
function loadTexture(url: string, srgb: boolean): Promise<THREE.Texture> {
const key = `${srgb ? "s" : "l"}|${url}`;
const cached = textureCache.get(key);
if (cached) return Promise.resolve(cached);
return new Promise((resolve, reject) => {
textureLoader.load(
url,
(tex) => {
tex.colorSpace = srgb ? THREE.SRGBColorSpace : THREE.NoColorSpace;
textureCache.set(key, tex);
resolve(tex);
},
undefined,
() => reject(new Error(`Textur nicht ladbar: ${url}`)),
);
});
}
const MAP_SLOTS: { kind: MaterialMapKind; srgb: boolean }[] = [
{ kind: "color", srgb: true },
{ kind: "normal", srgb: false },
{ kind: "roughness", srgb: false },
{ kind: "metalness", srgb: false },
{ kind: "ao", srgb: false },
{ kind: "displacement", srgb: false },
];
/** Rendert die Vorschau-Kugel eines Assets und liefert eine PNG-DataURL. */
async function renderPreview(asset: MaterialAsset): Promise<string> {
ensureRenderer();
const mat = sphereMesh!.material;
// Karten der zuvor gerenderten Kugel lösen (kein Überstand ins nächste Material).
mat.map = null;
mat.normalMap = null;
mat.roughnessMap = null;
mat.metalnessMap = null;
mat.aoMap = null;
mat.displacementMap = null;
mat.displacementScale = 0;
const loaded = await Promise.allSettled(
MAP_SLOTS.map(async ({ kind, srgb }) => {
const url = asset.maps[kind];
if (!url) return null;
const tex = await loadTexture(url, srgb);
return { kind, tex };
}),
);
for (const res of loaded) {
if (res.status !== "fulfilled" || !res.value) continue;
const { kind, tex } = res.value;
switch (kind) {
case "color":
mat.map = tex;
break;
case "normal":
mat.normalMap = tex;
break;
case "roughness":
mat.roughnessMap = tex;
break;
case "metalness":
mat.metalnessMap = tex;
break;
case "ao":
mat.aoMap = tex;
break;
case "displacement":
mat.displacementMap = tex;
// Dezent — die eigentliche Tiefe liest sich über die Normal-Map.
mat.displacementScale = 0.04;
break;
}
}
// Weiß als Albedo-Basis (Farb-Karte bleibt unverfälscht), sonst neutrales Grau.
mat.color.set(asset.maps.color ? 0xffffff : 0xcccccc);
mat.metalness = asset.maps.metalness ? 1 : 0;
mat.needsUpdate = true;
renderer!.render(scene!, camera!);
return renderer!.domElement.toDataURL("image/png");
}
// ── Serielle Warteschlange (ein Render-Auftrag zur Zeit, geteilter Kontext) ─
let queue: Promise<void> = Promise.resolve();
/**
* Fordert die Kugel-Vorschau eines Bibliotheks-Materials an. Liefert sofort
* die gecachte DataURL, falls schon fertig gerendert; andernfalls wird der
* Render-Auftrag (serialisiert über die geteilte Warteschlange) eingereiht und
* `onReady` genau einmal mit der fertigen URL aufgerufen. Wiederholte Aufrufe
* mit demselben Material treffen den Cache (kein erneutes Rendern) — außer die
* Karten-Signatur hat sich geändert (Bibliothek aktualisiert).
*/
export function requestMaterialPreview(
asset: MaterialAsset,
onReady: (url: string) => void,
): string | undefined {
const key = signatureOf(asset);
let entry = cache.get(key);
if (!entry) {
entry = { status: "pending", subscribers: new Set() };
cache.set(key, entry);
queue = queue
.then(() => renderPreview(asset))
.then((url) => {
entry!.status = "ready";
entry!.url = url;
for (const cb of entry!.subscribers) cb(url);
entry!.subscribers.clear();
})
.catch((err) => {
entry!.status = "error";
entry!.subscribers.clear();
console.error("[spherePreview] Vorschau-Render fehlgeschlagen:", err);
});
}
if (entry.status === "ready" && entry.url) return entry.url;
entry.subscribers.add(onReady);
return undefined;
}
/** Meldet einen Abonnenten wieder ab (Aufräumen bei Unmount vor Fertigstellung). */
export function cancelMaterialPreview(
asset: MaterialAsset,
onReady: (url: string) => void,
): void {
cache.get(signatureOf(asset))?.subscribers.delete(onReady);
}