tower / commit
fix(interior): stars are a pixel wide, so the sky stops flickering
decisions.md | 34 ++++++++++++++++++++++++++++++++++ src/views/interior.ts | 40 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/decisions.md b/decisions.md index e866ee9..990fe03 100644 --- a/decisions.md +++ b/decisions.md @@ -1673,3 +1673,37 @@ either. It reads from both sides, which is right: it is structure, not a reflect The cost is that the largest object in the scene is now a dark one. The label, the beam converging on it and the lit rim carry it — and being hard to see is the point, since Tower has never seen this thing lit. + +## D-056 — Stars are drawn a pixel wide, so the sky stops flickering + +The star field was a spike: brightness `(1 - d/r)^4` over a radius of about 0.11°, +which on a retina panel at the default field is a core roughly one pixel across. The +sky is sampled once per pixel, at the pixel's centre. So what got drawn was that spike +read at one arbitrary point along it, and half a pixel of head movement moved the +reading from the peak to a third of it. Every star did that independently, every +frame, and the whole sky boiled whenever anyone looked around. + +**That is wrong twice over.** Vacuum has no air to twinkle through — the sun cap +window looks at deep space and the far cap at the anti-sun sky, and neither has an +atmosphere in front of it. And the interior view's whole claim is that nothing in +Tower moves except the pinwheel: a sky that seethes contradicts the one thing §1.4 +promises about it. + +**The star becomes a gaussian one pixel wide.** Sigma is tied to the pixel footprint, +so a star is one pixel on any display and at any zoom, and a curve that smooth barely +changes between neighbouring samples. This is not a compromise with the physics; it is +closer to it. No optic renders a point source as a point — it renders it as a blob the +width of its own resolution, and here the resolution is the pixel. + +The footprint is measured from the view direction rather than from the projected star +coordinates, because those carry the branch cut of an `atan` and a derivative across +that seam is a line of garbage down the middle of the sky. + +**Measured, facing sunward from The Capital at night on a 2x display, turning one +pixel at a time over sixteen frames:** the light in star-like points varied by 8.1% of +its mean before and 5.0% after, and the count of them by 5.2% before and 3.9% after. +About a quarter more stars are visible at any moment — not because the field got +denser, but because a star no longer has to land near a pixel centre to be seen at its +true brightness. The variation that remains is mostly stars genuinely entering and +leaving the frame as the view turns, which is not flicker; per star, the worst case +went from a factor of three between neighbouring samples to about 16%. diff --git a/src/views/interior.ts b/src/views/interior.ts index a13d438..33703a0 100644 --- a/src/views/interior.ts +++ b/src/views/interior.ts @@ -318,6 +318,35 @@ vec3 starField(vec3 dir) { vec2 p = vec2(cos(ph), sin(ph)) * th * STAR_SCALE; vec2 cell = floor(p); vec2 f = p - cell; + /** + * How wide to draw a star, and the whole reason the sky used to flicker. + * + * A star was a spike: brightness (1 - d/0.09)^4, which on a retina panel is a + * core about a pixel across. The sky is sampled once per pixel at the pixel's + * centre, so what got drawn was that spike read at one arbitrary point on it — + * and half a pixel of head movement moved the reading from the peak to a third + * of it. Every star in the field did that independently, every frame. That is + * flicker, and it is wrong twice over: vacuum has no air to twinkle through, + * and the interior is deliberately still. + * + * The fix is to stop drawing a shape smaller than the thing sampling it. The + * star becomes a gaussian one pixel wide — sigma tied to the pixel footprint, + * so it is one pixel on any display and at any zoom — which is what a point + * source genuinely looks like through any real optic: an Airy blob the width of + * the aperture's resolution, not a mathematical point. A curve that smooth + * barely changes between neighbouring samples, so the star sits still and the + * eye reads it as a point anyway. + * + * The footprint is measured from the direction vector, not from p: p carries + * the branch cut of that atan, and a derivative across the seam is a garbage + * line down the middle of the sky. |dd/dpixel| on a unit vector is the pixel's + * angular size in radians, which is the same quantity without the seam. + * + * Clamped at the top because the cell search below is only 3x3: a sigma near + * the cell size would want light from stars this loop never visits. + */ + float sg = clamp(length(fwidth(d)) * STAR_SCALE * 0.85, 1e-4, 0.3); + float inv2s2 = 0.5 / (sg * sg); vec3 sum = vec3(0.0); for (int j = -1; j <= 1; j++) { for (int i = -1; i <= 1; i++) { @@ -325,8 +354,8 @@ vec3 starField(vec3 dir) { vec2 g = cell + o; float mag = starHash(g); if (mag < 0.78) continue; // most of the sky is empty - float b = pow(max(0.0, 1.0 - length(o + starHash2(g + 11.7) - f) / 0.09), 4.0) - * ((mag - 0.78) / 0.22); + vec2 at = o + starHash2(g + 11.7) - f; + float b = exp(-dot(at, at) * inv2s2) * ((mag - 0.78) / 0.22); // A few warm ones among the cool. Without it the field reads as noise. sum += b * mix(vec3(0.72, 0.80, 1.0), vec3(1.0, 0.86, 0.68), starHash(g + 5.1)); } @@ -340,7 +369,12 @@ vec3 starField(vec3 dir) { * It lands at the observer's own radius on the window — the axis is horizontal * from the floor, so this is a star on the horizon, not overhead. */ - sum += pow(max(0.0, 1.0 - th * STAR_SCALE / 0.11), 4.0) * vec3(1.0, 0.95, 0.86) * 1.6; + float pole = th * STAR_SCALE; + // Drawn a little wider than the rest, as the one star that is placed rather than + // rolled: it is the fixed point the pinwheel is read against, and it has to be + // findable before anything can be seen to turn about it. + float ps = sg * 1.6; + sum += exp(-pole * pole * 0.5 / (ps * ps)) * vec3(1.0, 0.95, 0.86) * 1.6; return sum * 2.6 * uStarGain; } `