Skip to content

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

\[ \Delta(W,P)=P-\operatorname{floorMod}(W,P), \qquad 0 < \Delta \le P. \]

The corresponding Android uptime target is

\[ U_{\text{target}}=U_{\text{now}}+\Delta(W_{\text{now}},P). \]

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:

  1. a precomposed dark interior, copper frame, mounting hardware, support wire, and ten unlit cathodes;
  2. a tinted soft-glow mask for the active digit;
  3. tinted outer and inner wire masks for its hot cathode; and
  4. 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

\[ I_{\text{new}}(t)= \begin{cases} 0, & t\le 0,\\ (1+o)\dfrac{t}{T_i}, & 0<t\le T_i,\\ (1+o)-o\dfrac{t-T_i}{T_s-T_i}, & T_i<t\le T_s,\\ 1, & t>T_s. \end{cases} \]

The old cathode leaves an exponential ghost with an explicit end:

\[ I_{\text{old}}(t)= \begin{cases} 1, & t\le 0,\\ e^{-t/\tau}, & 0<t<T_d,\\ 0, & t\ge T_d. \end{cases} \]

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

\[ w(p)=\sin^2(\pi p), \]

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

\[ \theta(t)= \begin{cases} 0, & t\le0,\\ 180(t/400)^2, & 0<t<400,\\ 180-7\sin\!\left(\pi\dfrac{t-400}{100}\right), & 400\le t<500,\\ 180, & t\ge500. \end{cases} \]

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

\[ L(\theta)=1-0.55\lvert\sin(\theta)\rvert, \]

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\),

\[ x_n=\operatorname{round}\!\left(M\sin(0.11n)\right),\qquad y_n=\operatorname{round}\!\left(M\sin(0.073n+1.3)\right). \]

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.