fuzzyworld

tower / commit

fix(interior): stars are a pixel wide, so the sky stops flickering

c48030fbeda1b063dc4865fabfdb70009b6c299d · Pavel Safronov · 2026-09-08 22:44:34 -0700 · parents 472390e279db

fix(interior): stars are a pixel wide, so the sky stops flickering

A star was a spike about one pixel across, and 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 did that independently,
every frame.

That is wrong twice over: vacuum has no air to twinkle through, and the
interior is deliberately still. The star becomes a gaussian one pixel
wide, with sigma tied to the pixel footprint, which is what a point source
looks like through any real optic. The footprint is measured from the view
direction rather than the projected coordinates, because those carry the
branch cut of an atan and a derivative across it is a line of garbage down
the middle of the sky.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 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;
 }
 `