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.
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.
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.