Clock rendering algorithms¶
- Status: Implemented reference
- Scope: time alignment, cached artwork, Nixie envelopes, split-flap motion, luminance control, and burn-in movement
The clock is designed around an old Adreno 320 GPU: spend work when the view
size changes, cache the result, and leave the graphics pipeline idle between
visible events. Both faces use one custom View; neither builds a hierarchy of
digit views or performs blur work per frame.
This page's timings and bitmap measurements apply to the clock faces only. The dashboard uses server-rendered SkiaSharp cards and a separate Android image cache/presentation path; its device memory acceptance results are recorded in the testing and quality guide, not these clock measurements.
Dashboard card rendering (separate backend pipeline)¶
The backend uses SkiaSharp 4.153.1 with the matching
SkiaSharp.NativeAssets.Linux.NoDependencies package. Its API image targets
linux-musl-x64. SkiaCardRenderer loads the bundled IBM Plex Sans typefaces
once; each card render creates and disposes its own opaque 1280×720 RGBA
SKSurface, paints and snapshots it, encodes a PNG, then disposes the Skia
image and encoded data. Fonts and paints used while laying out and drawing text
are also disposed after their use. The bundled IBM Plex Sans Regular and
SemiBold files include the SIL Open Font License.
CardLayouter keeps text inside 96-pixel margins, limits the headline template
to three items and two lines per item, and measures text with the same fonts
used for drawing. For stacked primary and secondary text, it steps supported
font sizes down in 4-pixel increments to fit, then wraps or ellipsizes overflow
at Unicode text-element boundaries.
Input normalization and the provider parsers bound and clean source text before
it reaches the card. FeedComposer serializes rendering and memoizes an image
by source ID and display time zone; it reuses that image while normalized
content and its confirmed timestamp are unchanged.
Card labels, statuses, dates, and numbers are currently formatted with
invariant English-oriented strings. Instants are converted through the
configured profile time zone, with IANA zone data supplied by tzdata in the
Alpine image. Localized card wording and culture-aware formatting remain future
work.
The final image copies the published tree with --chown=app:app; this includes
configuration and bundled fonts. A restrictive private build mask must not
leave them unreadable by the non-root renderer. Production's read-only root
filesystem remains in force.
One opaque RGBA surface contains 1280×720×4 = 3,686,400 bytes, about 3.52 MiB, before Skia image, PNG encoding, memoized PNGs, and other process allocations. This arithmetic is one surface's pixel size; it is not a whole-process or peak-memory measurement. Early dashboard spike measurements are not whole-app memory proof. Whole-application memory and container resource measurements are recorded in the release candidate receipt and testing and quality guide.
Rendering pipeline¶
flowchart TD
Size[View size changes] --> Baker[BackgroundBaker]
Baker --> Worker[Background executor]
Worker --> Static[Static ARGB bitmap]
Worker --> Masks[Reusable ALPHA_8 masks]
Static --> Publish{Generation and size still current?}
Masks --> Publish
Publish -->|yes| Swap[Atomic asset-set replacement]
Publish -->|no| Drop[Drop stale result]
Tick[Aligned clock tick] --> Draw[onDraw]
Effect[Effect or flip frame] --> Draw
Swap --> Draw
Draw --> Idle{Animation still active?}
Idle -->|yes| Vsync[postInvalidateOnAnimation]
Idle -->|no| Sleep[No frame callbacks]
BackgroundBaker gives every bake a generation and target size. Publication
on the main thread succeeds only if that generation is still current, the size
still matches, and the view remains attached. The previous asset set stays
live until replacement is ready. Detach advances the generation, shuts down
the executor, removes queued publications, and drops references.
The code does not call Bitmap.recycle(). On API 22 the pixel data is on the
Java heap, and a RenderThread display list may still refer to a just-replaced
bitmap. Normal reference lifetime and garbage collection avoid freeing pixels
under the renderer.
Boundary-aligned time¶
A repeating one-second delay slowly inherits callback and scheduling delay.
Instead, ClockTicker recalculates the next epoch-aligned boundary after every
callback. For wall time \(W\) and period \(P\), the remaining delay is
The corresponding Android uptime target is
This conversion matters because Handler.postAtTime expects uptime, not epoch
milliseconds. It also works for negative epoch values because the code uses
floor modulus. The target is recomputed after time changes, time-zone changes,
and every tick.
Nixie face¶
Layer model¶
Each of the four cells is drawn back to front:
- a precomposed dark interior, copper frame, mounting hardware, support wire, and ten unlit cathodes;
- a tinted soft-glow mask for the active digit;
- tinted outer and inner wire masks for its hot cathode; and
- a dark honeycomb mesh mask over the glow.
The digit centerlines are hand-authored command lists of lines, cubic curves,
and arcs in a unit box. They are adapted to android.graphics.Path only at the
rendering boundary, which keeps bounds and geometry testable in an ordinary
JVM. No installed font determines the Nixie digit shapes.
The expensive blur is performed once while baking the half-resolution glow mask into a software canvas. The mask is enlarged with bilinear filtering at draw time. All four cells share the same baked assets.
Cathode change¶
A real cathode switches too quickly to make a decorative animation, so the view uses a restrained hobby-clock crossfade. Let \(t\) be milliseconds since the digit changed, ignition \(T_i=150\), settle \(T_s=260\), cutoff \(T_d=400\), overshoot \(o=0.05\), and decay constant \(\tau=100\).
The incoming cathode level is
The old cathode leaves an exponential ghost with an explicit end:
Effects are postponed around this single monotonic transition rather than mixed into it. Disabling motion turns the change into an immediate switch.
Electrical envelopes¶
Every scheduled effect is a deterministic function of its type, intensity, seed, and elapsed time. With duration \(D\), normalized progress \(p=t/D\), and the smooth window
the four visual characters are:
| Effect | Implemented envelope |
|---|---|
| Buzz | A positive surge \(1+w(p)[a+0.03\sin(2\pi f t+\phi)]\), where \(a=0.08+0.12i\) and \(2\le f\le4\) Hz. Glow uses \(1.6a\). |
| Brownout | Smoothstep sag over the first 20%, a low hold with at most 3% wobble through 70%, then a smooth recovery with 3–5% overshoot. The target low level is \(0.75-0.40i\). Glow is brightness raised to \(1.5\). |
| Stutter | Two raised-cosine dips centered near 25% and 70% of the event, each 12–18% of its duration wide. The minimum is clamped to 0.45. |
| Hum | One slow signed swell \(1\pm(0.03+0.03i)w(p)\); its audio counterpart is intentionally silent. |
Here \(i\in[0,1]\) is intensity. NixieEffect also enforces a universal
brightness floor of 0.25 and returns exactly 1 before and after the event.
Durations are 250–700 ms for buzz, 250–800 ms for brownout, 200–700 ms for
stutter, and 1–2 seconds for hum.
The scheduler uses a seeded random source:
- ordinary gaps are uniform from 12 to 50 seconds;
- quiet-hours rate scaling stretches those gaps;
- intensity is \(r^2\) for uniform \(r\in[0,1)\), biasing events toward subtle;
- 15% of ordinary events receive a weaker aftershock 0.5–2 seconds later;
- 60% affect every cell and the rest target one cell; and
- a shuffled four-item bag emits every type before refilling and prevents a repeat across the bag boundary.
One luminance authority¶
Every effect sample passes through one LuminanceGuard before drawing. It
tracks reversals of at least 0.10 away from a running extreme. At most four
such changes—two down/up flash pairs—may enter any rolling one-second window.
Once the budget is full, a requested reversal is clamped to just under the
threshold from the held extreme.
This is a conservative engineering guard, not a certification. The display is large, red-orange, and used in darkness, so compliance is not claimed until the behavior is measured on the physical screen. Effects also avoid overlap, never modulate above 4 Hz, and never fall below 25% of their normal content level.
Split-flap face¶
The static body—stacked edges, gradients, split gap, hinge notches, and axle caps—is one baked opaque bitmap per card size. Numerals are drawn live twice, clipped to their upper and lower halves; the gap is redrawn last. Dimming uses color scaling rather than alpha so the rotating card never becomes transparent.
Fall and settle¶
For a 400 ms fall followed by a 100 ms settle, the card angle is
The quadratic first phase produces gravity-like acceleration. The second phase backs away from the resting angle by at most 7 degrees and returns once. Apparent brightness follows
so the flap is darkest edge-on and fully lit at either face. Android's
Camera and Matrix perform the perspective rotation about the hinge. Before
90 degrees the plate shows the old upper half; after 90 degrees its back shows
the new lower half landing.
Each card holds one FlipCard state. A new value during a flip abandons the
old target and starts toward the latest one; there is no animation queue. A
reading more than 90 seconds after the previous reading jumps immediately, so
a resumed Activity does not replay stale minutes.
Burn-in movement¶
The small shift is a sampled Lissajous-like path. For step number \(n=\lfloor t/150000\rfloor\) and configured maximum \(M\),
The unequal frequencies keep the path from obviously repeating. An hourly anchor cycles through center and four diagonal positions; its epoch is shifted by 17 minutes so that it does not coincide with the hour boundary. The final offset is the sum of the step and anchor.
Cost model¶
Let \(W\times H\) be a cell or card size, \(D=10\) digit masks, and \(C=4\) Nixie cells. \(D\) and \(C\) are product constants, but naming them makes the tradeoff visible.
| Operation | Time | Additional space | Notes |
|---|---|---|---|
| Asset bake after a size change | \(O(DWH)\) | \(O(DWH)\) masks plus static bitmap | Runs off the main thread; half-resolution glow reduces its constant. |
| Idle clock tick | \(O(C)\) | \(O(1)\) | Four cell composites or two cards; no continuous invalidation. |
| Nixie animation frame | \(O(C)\) | \(O(1)\) | Cached bitmap/mask draws and scalar envelope samples only. |
| Split-flap frame | \(O(1)\) per active card | \(O(1)\) | One transform and clipped live text for each of at most two cards. |
| Effect scheduling | \(O(1)\) amortized | \(O(1)\) | Four-item shuffle bag. |
| Luminance filtering | \(O(1)\) amortized | \(O(F)\) | The deque contains only recent threshold changes; \(F\le4\) under the guard. |
On the XT1058, the optimized Nixie asset set for 260×433 cells measured about 2.9 MB. Cropping digit masks and precompositing static layers reduced effect frames over 16.7 ms from 29 of 128 to 1 of 128, with an 8.9 ms average. Minute switch frames stayed at or below 13.6 ms after the initial texture upload. Split-flap frames were typically 11.6–13.9 ms, with one over-budget frame per observed flip. These are targeted device measurements, not guarantees for every driver or layout.
Test boundaries¶
Pure Java tests cover tick math, digit geometry, every envelope's range and
end behavior, scheduler distributions, adversarial luminance sequences, flap
timing, no-queue behavior, schedule resolution, and burn-in bounds. Device
checks cover the things a local JVM cannot: Canvas composition, GPU upload and
frame times, bitmap memory, real panel levels, SoundPool, and lifecycle on
API 22.