The core trick
Nine heads, sixty frames a second, and every head follows the pointer. That only works if the whole surface is not resampled every frame.
The problem
kopfPunkt is heavy on trigonometry: sine and cosine for
u and v, plus the wave of the bump and the
radial bump of the boxiness. One head needs around 6000 such points,
nine heads around 55,000 – per frame, sixty times a second. That is
not affordable.
The way out sits in one observation: kopfPunkt depends
only on the head shape. Not on the rotation, not on
the size. When a head turns towards the pointer, the only thing that
changes per frame is the pose.
So: sample the surface once (wolke()),
and per frame only rotate, project and sort into angular bins
(umriss()). A trigonometry battle becomes one matrix
multiplication and one division per point.
The cloud
A Wolke is a Float32Array with
x, y, z one after another – not an array of point
objects. The reason is blunt: the per-frame loop runs over tens of
thousands of points, and every object would be a pointer the CPU has
to follow. A flat number array lies in memory in one piece.
wolke() takes bounds in u and v
– for a hairdo the lower bound is the hairline, and that is a function
of u, not a number – and optionally a dicke
along the surface normal. Without thickness the cloud lies on the
skin, with it it floats above: that is the difference between colour
on the skull and a hairdo with an outline of its own.
The normal is computed from differences, not analytically. An
ellipsoid normal would be off as soon as kegel,
scheitelHoch, beule or kastig
are in play – and they are always in play.
The outline
umriss() rotates the cloud with a
matrix computed once per frame from the pose –
instead of six trigonometric functions per point. It is the same
matrix that stands behind drehUm: yaw, pitch, roll (see
Space).
After that every projected point goes into an angular bin, and per bin the largest radius survives. Out of 96 radii comes a closed contour.
Why smoothing follows is inherent in the method: neighbouring bins
catch points from different latitudes. One ring is only just
inside the bin, the next is already just outside, and the contour gets
a sawtooth that looks like a drawing error. Three smoothing passes
with a 1/(1+k²) kernel take it out without shifting the
shape.
And before that comes a step that is easy to overlook – until it is missing.
A soft indentation sits in the silhouette, usually at cheek height.
It travels along with the rotation and does not look like an error
but like head shape – which is why one looks for it in
kopfPunkt for a long time.
Cause: an empty angular bin. At the widest point of
the head the projected y jumps between two neighbouring
rings by more than one bin width – exactly there a bin is left
without a point and its radius stays at zero. If smoothing then
runs, the zero pulls its neighbours inwards with it. A single empty
bin is enough.
Fix: two precautions, and both stand in the code.
fuelleLuecken runs before the smoothing and lays a
straight line between the occupied neighbours. And the smoothing
itself skips zeros (if (q === 0) continue) instead of
averaging them in. The first carries long gaps, the second single
ones.
The default is the sampling of the sheet, and there too two bins are empty – one at the widest place of each cheek. The contour is round nonetheless. Only when both switches are off does the smoothing pull the zero into its neighbours, and the pale line beside it shows how it should be.
Two sliders say where the gaps come from. The pitch: with a
horizontal equatorial plane the outermost points lie exactly on a bin
boundary, and the neighbouring bins come away empty. And the
sampling v: what decides is not the number of longitudes but
the spacing of the latitudes. At the widest point the
projected y jumps between two rings by more than one bin
width – with the hairdo shells, which are sampled at
nv: 16, that is no longer an edge case.
The angle approximation
Sorting needs the angle of every point. At ~50,000 points per frame
atan2 is expensive enough to become visible: around 5 ms
against under 1 ms. So there stands an approximation that gets by with
one division and a third-degree polynomial.
That is only defensible if the error is smaller than what the result
resolves. At 96 bins one bin is 0.0654 rad wide. The
comment in the code names 0.0038 rad as a bound;
re-measured it is 0.0002 rad – three parts per thousand
of a bin width. The approximation cannot miss a bin.
Why the cache hangs only off the seed
Everything is computed in unit measure: a head is
about two units tall, the target size only joins when drawing, as
ctx.scale. That is not a convenience but the precondition
for the cache.
The projection is homothetic:
F = BRENNWEITE · mass, and all radii scale with
mass. So mass cancels out of
s = F/(F−z). An outline at size 1, times
mass, is the outline at mass.
That is why kopfdaten in
styles/klar/index.ts hangs only off the
seed – not off the cell size. The same computed
cloud carries the small grid and the close-up, and a head you click
on does not have to be resampled.
Partial arcs and occlusion
The outline of a skull is closed. The outline of a hairdo is not: it
often covers the head only partly, and its outer edge is an
arc. For that there is bogenUm – a screen
direction around which the connected arc is taken
(−π/2 is up).
Single empty bins are skipped in the process: those come from the sampling, not from the shape. But there is no bridging all the way round. A bridge across the free spot in front of the forehead would drag the outline across the face.
And then there is the back side. A shell has one – it is an inflated skull, after all – and when sorting into angular bins every point counts the same, no matter whether it lies in front or behind.
The hairdo is suddenly a filled surface running across the whole face. Not on every head, not in every rotation – but reliably, as soon as the head turns far enough.
Cause: the outline takes the back side of the shell along. With a shell that has a low bottom edge – the afro – that hangs down below the middle of the head. When the head turns, it slips into the lower angular bins; the arc then finds mass all around, closes into a ring, and the fill runs across the face as a flap. The flap is the back of the hairdo, seen through the head.
Fix: hinter takes the contour of the body the shell
sits on. A point only counts if it can be seen – in front of the
body's centre or outside its contour.
The threshold for that is at rz < −0.15 and not at
zero. At the silhouette itself the shell lies at rz
around zero, and its outermost point in a bin may sit a little behind
that – and it belongs to the outline. Only what lies
clearly behind and inside the contour is
really occluded. A threshold at zero would thin out the silhouette of
the hairdo, and the error there is only less noticeable than the flap,
not smaller.
The convenient single case
Not everything is an outline. A hairline is eighty points, a strand a
dozen. For that there is schalenPunkt: a single point of
the shell, with thickness, rotated and projected.
It is the same computation as inside umriss, only without
bins and without a loop. At dozens of points the convenience is worth
more than the matrix; at tens of thousands it would be a mistake.
Measured: ~3 ms per frame for nine heads at DPR 2 on 1000×1080 – contour, hairdos, faces and paper together. Without the cache the sampling alone costs more than that.