Der Kern-Trick

Neun Köpfe, sechzig Bilder je Sekunde, und jeder Kopf folgt dem Zeiger. Das geht nur, wenn pro Bild nicht die ganze Oberfläche neu abgetastet wird.

Das Problem

kopfPunkt ist trigonometrielastig: Sinus und Kosinus für u und v, dazu die Welle der Beule und die radiale Beule der Kastigkeit. Ein Kopf braucht rund 6000 solcher Punkte, neun Köpfe rund 55 000 – je Bild, sechzigmal in der Sekunde. Das ist nicht zu bezahlen.

Der Ausweg steckt in einer Beobachtung: kopfPunkt hängt nur an der Kopfform. Nicht an der Drehung, nicht an der Größe. Wenn ein Kopf sich zum Zeiger dreht, ändert sich pro Bild ausschließlich die Pose.

Also: die Oberfläche einmal abtasten (wolke()), und pro Bild nur noch drehen, projizieren und in Winkelfächer einsortieren (umriss()). Aus einer Trigonometrieschlacht wird eine Matrixmultiplikation und eine Division je Punkt.

Die Wolke

Eine Wolke ist ein Float32Array mit x, y, z hintereinander – kein Array aus Punktobjekten. Der Grund ist stumpf: die Schleife pro Bild läuft über zehntausende Punkte, und jedes Objekt wäre ein Zeiger, dem die CPU folgen muss. Ein flaches Zahlenfeld liegt am Stück im Speicher.

wolke() nimmt Grenzen in u und v – bei einer Frisur ist die untere Grenze der Haaransatz, und die ist eine Funktion von u, keine Zahl – und optional eine dicke entlang der Flächennormale. Ohne Dicke liegt die Wolke auf der Haut, mit ihr schwebt sie darüber: das ist der Unterschied zwischen Farbe auf dem Schädel und einer Frisur mit eigenem Umriss.

Die Normale wird über Differenzen gerechnet, nicht analytisch. Eine Ellipsoid-Normale läge daneben, sobald kegel, scheitelHoch, beule oder kastig im Spiel sind – und sie sind immer im Spiel.

Der Umriss

umriss() dreht die Wolke mit einer Matrix, die einmal je Bild aus der Pose gerechnet wird – statt sechs Winkelfunktionen je Punkt. Es ist dieselbe Matrix, die auch hinter drehUm steht: yaw, pitch, roll (siehe Raum).

Danach kommt jeder projizierte Punkt in ein Winkelfach, und je Fach überlebt der größte Radius. Aus 96 Radien wird eine geschlossene Kontur.

Warum danach geglättet wird, steckt im Verfahren selbst: benachbarte Fächer erwischen Punkte verschiedener Breitengrade. Der eine Ring liegt gerade eben noch im Fach, der nächste schon knapp daneben, und die Kontur bekommt einen Sägezahn, der wie ein Zeichenfehler aussieht. Drei Glättungspässe mit einem 1/(1+k²)-Kern nehmen ihn heraus, ohne die Form zu verschieben.

Und davor steht ein Schritt, der leicht zu übersehen ist – bis er fehlt.

Die Delle in der Wange

In der Silhouette sitzt eine weiche Einbuchtung, meist auf Wangenhöhe. Sie wandert bei der Drehung mit und sieht nicht nach Fehler aus, sondern nach Kopfform – deshalb sucht man sie lange in kopfPunkt.

Ursache: ein leeres Winkelfach. Am breitesten Punkt des Kopfes springt das projizierte y zwischen zwei benachbarten Ringen um mehr als eine Fachbreite – genau dort bleibt ein Fach ohne Punkt und sein Radius auf null. Wird danach geglättet, zieht die Null ihre Nachbarn mit nach innen. Ein einzelnes leeres Fach reicht.

Lösung: zwei Vorkehrungen, und beide stehen im Code. fuelleLuecken läuft vor dem Glätten und legt zwischen die belegten Nachbarn eine Gerade. Und die Glättung selbst überspringt Nullen (if (q === 0) continue), statt sie mitzumitteln. Die erste trägt lange Lücken, die zweite einzelne.

Die Voreinstellung ist die Abtastung des Blattes, und auch dort sind zwei Fächer leer – je eines an der breitesten Stelle einer Wange. Die Kontur ist trotzdem rund. Erst wenn beide Schalter aus sind, zieht die Glättung die Null in ihre Nachbarn, und die blasse Linie zeigt daneben, wie es sein sollte.

Zwei Regler sagen, woher die Lücken kommen. Der pitch: bei waagerechter Äquator-Ebene liegen die äußersten Punkte genau auf einer Fachgrenze, und die Nachbarfächer gehen leer aus. Und die Abtastung v: nicht die Zahl der Längengrade entscheidet, sondern der Abstand der Breitengrade. Am breitesten Punkt springt das projizierte y zwischen zwei Ringen um mehr als eine Fachbreite – bei den Frisurenschalen, die mit nv: 16 abgetastet werden, ist das kein Randfall mehr.

Die Winkel-Näherung

Für das Einsortieren braucht es den Winkel jedes Punktes. Bei ~50 000 Punkten je Bild ist atan2 teuer genug, um sichtbar zu werden: rund 5 ms gegen unter 1 ms. Also steht dort eine Näherung, die mit einer Division und einem Polynom dritten Grades auskommt.

Das ist nur zu verantworten, wenn der Fehler kleiner ist, als das Ergebnis auflöst. Bei 96 Fächern ist ein Fach 0.0654 rad breit. Der Kommentar im Code nennt 0.0038 rad als Schranke; nachgemessen sind es 0.0002 rad – drei Promille einer Fachbreite. Die Näherung kann ein Fach nicht verfehlen.

Warum der Cache nur am Seed hängt

Gerechnet wird durchweg im Einheitsmaß: ein Kopf ist rund zwei Einheiten hoch, die Zielgröße kommt erst beim Zeichnen als ctx.scale dazu. Das ist keine Bequemlichkeit, sondern die Voraussetzung für den Cache.

Die Projektion ist homothetisch: F = BRENNWEITE · mass, und alle Radien skalieren mit mass. Damit kürzt sich mass aus s = F/(F−z) heraus. Ein Umriss bei Größe 1, mal mass, ist der Umriss bei mass.

Deshalb hängt kopfdaten in styles/klar/index.ts nur am Seed – nicht an der Zellgröße. Dieselbe gerechnete Wolke trägt das kleine Raster und die Großansicht, und ein Kopf, den man anklickt, muss nicht neu abgetastet werden.

Teilbögen und Verdeckung

Der Umriss eines Schädels ist geschlossen. Der Umriss einer Frisur ist es nicht: sie deckt den Kopf oft nur teilweise, und ihr Außenrand ist ein Bogen. Dafür gibt es bogenUm – eine Bildschirmrichtung, um die herum der zusammenhängende Bogen genommen wird (−π/2 ist oben).

Einzelne leere Fächer werden dabei übersprungen: die kommen aus der Abtastung, nicht aus der Form. Aber es wird nicht rundum überbrückt. Eine Brücke über die freie Stelle vor der Stirn zöge den Umriss quer übers Gesicht.

Und dann ist da noch die Rückseite. Eine Schale hat eine – sie ist ja ein aufgeblasener Schädel –, und beim Einsortieren in Winkelfächer zählt jeder Punkt gleich viel, egal ob er vorn oder hinten liegt.

Der Lappen quer über das Gesicht

Die Frisur ist plötzlich eine gefüllte Fläche, die über das ganze Gesicht läuft. Nicht bei jedem Kopf, nicht in jeder Drehung – aber zuverlässig, sobald der Kopf sich weit genug dreht.

Ursache: der Umriss nimmt die Rückseite der Schale mit. Bei einer Schale mit tiefer Unterkante – dem Afro – hängt die bis unter die Kopfmitte. Dreht sich der Kopf, rutscht sie in die unteren Winkelfächer; der Bogen findet dann rundum Masse, schließt sich zum Ring, und die Füllung läuft als Lappen über das Gesicht. Der Lappen ist die Rückseite der Frisur, gesehen durch den Kopf hindurch.

Lösung: hinter nimmt die Kontur des Körpers entgegen, auf dem die Schale sitzt. Ein Punkt zählt nur, wenn er zu sehen ist – vor der Mitte des Körpers oder außerhalb seiner Kontur.

Die Schwelle dafür liegt bei rz < −0.15 und nicht bei null. An der Silhouette selbst liegt die Schale bei rz um null, und ihr äußerster Punkt in einem Fach kann ein Stück dahinter sitzen – der gehört zum Umriss. Erst was deutlich hinten liegt und innerhalb der Kontur, ist wirklich verdeckt. Eine Schwelle bei null würde die Silhouette der Frisur ausdünnen, und der Fehler dabei ist nur weniger auffällig als der Lappen, nicht kleiner.

Der bequeme Einzelfall

Nicht alles ist ein Umriss. Eine Ansatzlinie sind achtzig Punkte, eine Strähne ein Dutzend. Dafür gibt es schalenPunkt: ein einzelner Punkt der Schale, mit Dicke, gedreht und projiziert.

Es ist dieselbe Rechnung wie im Innern von umriss, nur ohne Fächer und ohne Schleife. Bei Dutzenden Punkten ist die Bequemlichkeit mehr wert als die Matrix; bei Zehntausenden wäre sie ein Fehler.

Gemessen: ~3 ms je Bild für neun Köpfe bei DPR 2 auf 1000×1080 – Kontur, Frisuren, Gesichter und Papier zusammen. Ohne den Cache kostet allein die Abtastung mehr als das.