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.

The dent in the cheek

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 flap across the face

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.