fix(startseite): Porträts scharf statt weich, besonders die hinteren
Sinas Bild wirkte unscharf. Die Datei ist es nicht — sie hat wie alle anderen 512 Bildpunkte. Ihr Kreis erscheint aber nur rund 170 Bildpunkte gross, weil sie am weitesten hinten sitzt. Bei so starker Verkleinerung greift die Grafikkarte auf eine vorberechnete, viertel so grosse Zwischenfassung zurück; das Ergebnis ist ein weiches Gesicht. Drei Änderungen: - Jedes Porträt wird beim Laden auf seine tatsächliche Anzeigegrösse gerechnet (Ferdi 384, Livia 275, Sina 229, Thomas 226 statt überall 512). Die Grafikkarte zeigt damit die Vorlage nahezu Punkt für Punkt, die Zwischenfassung entfällt. - Nach dem Verkleinern wird nachgeschärft, in der Stärke abhängig davon, wie weit verkleinert wurde. Ohne das verliert ein herunter gerechnetes Foto seine Zeichnung. - Die Szene rechnet intern mindestens doppelt so fein wie der Bildschirm darstellt. Das kostet wenig, weil nicht laufend gezeichnet wird, und hilft auch Fensterprofilen und Kulisse. Geladen wird dafür von Hand statt über den TextureLoader von three.js: dessen Rückruf liefert die Bildmasse nicht zuverlässig, und ohne sie 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. Der quadratische Zuschnitt sitzt jetzt ebenfalls hier statt in den Texturkoordinaten. 422 Tests, Lint und Build sauber. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
403161283c
commit
55274488d1
1 file changed
+129
-27
@@ -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<string, string | undefined>,
|
||||
): 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()
|
||||
|
||||
Reference in new issue
Block a user