diff --git a/src/components/staff/meetingRoomScene.ts b/src/components/staff/meetingRoomScene.ts index 4f7421e..d5d119c 100644 --- a/src/components/staff/meetingRoomScene.ts +++ b/src/components/staff/meetingRoomScene.ts @@ -292,6 +292,113 @@ function addTable(scene: THREE.Scene) { scene.add(shadow) } +/** + * Nachschärfen nach dem Verkleinern (Unschärfemaske). + * + * Beim Herunterrechnen eines Fotos verschwimmen Kanten — Wimpern, Brillenrand, + * einzelne Haare. Das Bild minus seiner weichgezeichneten Fassung ergibt genau + * diese Kantenzeichnung; verstärkt zurückgegeben holt sie die Zeichnung wieder + * heran. Jedes Bildbearbeitungsprogramm macht das nach einer Verkleinerung. + * + * Die Stärke richtet sich danach, wie weit verkleinert wurde: wer kaum + * verkleinert, braucht kaum Schärfung, sonst wirkt das Ergebnis überzeichnet. + */ +function schaerfeNach(g: CanvasRenderingContext2D, flaeche: HTMLCanvasElement, ziel: number, kante: number) { + const staerke = Math.min(0.6, Math.max(0, (kante / ziel - 1) * 0.5)) + if (staerke < 0.05) return + + const weich = document.createElement('canvas') + weich.width = ziel + weich.height = ziel + const wg = weich.getContext('2d') + if (!wg) return + wg.filter = 'blur(0.8px)' + wg.drawImage(flaeche, 0, 0) + + const scharf = g.getImageData(0, 0, ziel, ziel) + const unscharf = wg.getImageData(0, 0, ziel, ziel) + const a = scharf.data + const b = unscharf.data + for (let i = 0; i < a.length; i += 4) { + for (let c = 0; c < 3; c++) { + const wert = a[i + c] + staerke * (a[i + c] - b[i + c]) + a[i + c] = wert < 0 ? 0 : wert > 255 ? 255 : wert + } + } + g.putImageData(scharf, 0, 0) +} + +/** + * Porträt laden und dabei auf seine Anzeigegrösse bringen (Runde 7, Nachtrag). + * + * Die Vorlagen haben 512 Bildpunkte, die Kreise erscheinen je nach Platz aber + * nur 170 bis 330 gross. Bei so starker Verkleinerung greift die Grafikkarte + * nicht auf die Vorlage zurück, sondern auf eine vorberechnete, halb oder + * viertel so grosse Zwischenfassung. Das Ergebnis sind weiche Gesichter, + * am deutlichsten bei den weiter hinten sitzenden Agenten. + * + * Wird die Textur vorab auf ihre Anzeigegrösse gerechnet, entfällt dieser + * Zwischenschritt. Verkleinert wird einmalig auf einer Zeichenfläche, und die + * rechnet dabei über alle Quellpunkte — deutlich sorgfältiger als die schnelle + * Zwischenfassung der Grafikkarte. + * + * Zugleich entsteht hier der mittige quadratische Ausschnitt. Ohne ihn zöge die + * Kreisfläche eine liegende Vorlage in die Breite. + * + * Geladen wird bewusst von Hand statt über `TextureLoader`: dessen Rückruf + * liefert das fertige Bild nicht zuverlässig, und ohne Bildmasse lässt sich + * weder zuschneiden noch massgerecht verkleinern. Die Zählung beim + * Ladeverwalter bleibt erhalten, damit die Szene nach dem letzten Bild wie + * bisher einmal neu zeichnet. + */ +function ladePortraet(url: string, seat: MeetingSeat, leinwandHoehe: number, maxAnisotropy: number) { + const tex = new THREE.Texture() + tex.colorSpace = THREE.SRGBColorSpace + // Anisotrope Filterung: die Kreise stehen schräg im Raum, ohne sie werden + // die Porträts an den Rändern matschig. + tex.anisotropy = maxAnisotropy + tex.generateMipmaps = true + tex.minFilter = THREE.LinearMipmapLinearFilter + tex.magFilter = THREE.LinearFilter + + const verwalter = THREE.DefaultLoadingManager + verwalter.itemStart(url) + + const bild = new Image() + bild.onload = () => { + const kante = Math.min(bild.width, bild.height) + + // Sichtbarer Durchmesser des Kreises in Bildpunkten der Leinwand + const abstand = Math.hypot(seat.x, CAMERA.z - seat.z) + const durchmesser = (seat.radius * leinwandHoehe) / (abstand * Math.tan((CAMERA.fov / 2) * Math.PI / 180)) + // Etwas Reserve für Fenster, die grösser sind als das aktuelle + const ziel = Math.round(Math.min(kante, Math.max(160, durchmesser * 1.35))) + + const flaeche = document.createElement('canvas') + flaeche.width = ziel + flaeche.height = ziel + const g = flaeche.getContext('2d') + if (g) { + g.imageSmoothingEnabled = true + g.imageSmoothingQuality = 'high' + g.drawImage(bild, (bild.width - kante) / 2, (bild.height - kante) / 2, kante, kante, 0, 0, ziel, ziel) + schaerfeNach(g, flaeche, ziel, kante) + tex.image = flaeche + } else { + tex.image = bild + } + tex.needsUpdate = true + verwalter.itemEnd(url) + } + bild.onerror = () => { + verwalter.itemError(url) + verwalter.itemEnd(url) + } + bild.src = url + + return tex +} + /** * Ein Agent am Tisch (§3). * @@ -299,21 +406,16 @@ function addTable(scene: THREE.Scene) { * keine Arme. Die früheren Sitzmöbel und Rümpfe wirkten unnatürlich und sind * ersatzlos entfallen (§3.1). * - * Das Porträt hängt in einer Kreisfläche. Weil die Vorlagen unterschiedliche - * Seitenverhältnisse haben, wird die Textur auf einen mittigen quadratischen - * Ausschnitt begrenzt: ohne diese Beschneidung zieht die Kreisgeometrie ein - * liegendes Foto in die Breite, was Gesichter verzerrt und Schärfe kostet (§3.3). - * * Das Porträt schaut zum Betrachter. Voll zur Tischmitte gedreht sähe man von * einem frontalen Porträtfoto nur die Kante; eine leichte Drehung zum Tisch * genügt, damit die Runde zusammengehört. */ function addSeatedAgent( scene: THREE.Scene, - loader: THREE.TextureLoader, seat: MeetingSeat, photoUrl: string | undefined, maxAnisotropy: number, + leinwandHoehe: number, ): SeatHandle { const group = new THREE.Group() group.position.set(seat.x, 0, seat.z) @@ -340,25 +442,7 @@ function addSeatedAgent( head.add(ring) if (photoUrl) { - const tex = loader.load(photoUrl, t => { - // Mittigen quadratischen Ausschnitt wählen, sobald die Masse bekannt sind. - const { width, height } = t.image as { width: number; height: number } - if (width > height) { - t.repeat.set(height / width, 1) - t.offset.set((1 - height / width) / 2, 0) - } else if (height > width) { - t.repeat.set(1, width / height) - t.offset.set(0, (1 - width / height) / 2) - } - t.needsUpdate = true - }) - tex.colorSpace = THREE.SRGBColorSpace - // Anisotrope Filterung: die Kreise stehen schräg im Raum, ohne sie werden - // die Porträts an den Rändern matschig. - tex.anisotropy = maxAnisotropy - tex.generateMipmaps = true - tex.minFilter = THREE.LinearMipmapLinearFilter - tex.magFilter = THREE.LinearFilter + const tex = ladePortraet(photoUrl, seat, leinwandHoehe, maxAnisotropy) const face = new THREE.Mesh( new THREE.CircleGeometry(radius, 96), @@ -412,7 +496,15 @@ export function createMeetingRoomScene( photos: Record, ): MeetingRoomScene { const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false }) - renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) + /* + * Mindestens doppelte Auflösung, unabhängig vom Bildschirm. + * + * Wird intern feiner gerechnet als der Bildschirm darstellt, gewinnen alle + * feinen Kanten — Fensterprofile, Kulisse und vor allem die Porträtkreise. + * Das kostet hier wenig: die Szene wird nicht laufend, sondern nur bei + * Grössenänderung, Mausbewegung und Zustandswechsel neu gezeichnet. + */ + renderer.setPixelRatio(Math.min(Math.max(window.devicePixelRatio, 2), 3)) renderer.outputColorSpace = THREE.SRGBColorSpace const scene = new THREE.Scene() @@ -443,8 +535,18 @@ export function createMeetingRoomScene( addGlassFacades(scene) addTable(scene) + /* + * Höhe der Leinwand in Bildpunkten, aus der die Porträts ihre Auflösung + * ableiten. Zum Zeitpunkt des Aufbaus steht sie noch nicht fest, weil die + * Grösse erst danach gesetzt wird. Der Ausgangswert deckt den üblichen + * Bereich ab; wächst das Fenster später deutlich, werden die Bilder etwas + * vergrössert dargestellt — weit weniger auffällig als eine zu grobe + * Zwischenfassung. + */ + const leinwandHoehe = Math.max(canvas.clientHeight, 700) * renderer.getPixelRatio() + const handles = seats.map(seat => - addSeatedAgent(scene, loader, seat, photos[seat.id], maxAnisotropy), + addSeatedAgent(scene, seat, photos[seat.id], maxAnisotropy, leinwandHoehe), ) const baseCam = camera.position.clone()