A real-time 3D clock visualizer written from scratch in pure C17.
Inspired by bondbond's official clock visualizer
Everything is rendered on the CPU: a custom software rasterizer (clipping, perspective projection, 28.4 fixed-point edge functions, flat shading with banding, depth buffering). No GPU, no asset files, the entire clock is built from procedural meshes.
The aim is to run on an STM32G474 (Cortex-M4F @ 170 MHz) specifically.
The core library (clock3d_core) is SDL-free and single-threaded, performs
no heap allocation during a rendered frame, and ships MCU-shaped profiles
(320x240 / 160x120 RGB565 with uint16 inverse-Z depth and fixed-point edge
functions) that are exercised on the host by the stm32-proxy build. The
Linux desktop build is the reference renderer: 640x480 XRGB8888, float
depth, 30 FPS, wall-clock time with smooth fractional-second hand motion and
a camera riding the second hand.
Requires CMake >= 3.24, GCC or Clang, SDL3 >= 3.4.14 (Fedora: sudo dnf install SDL3-devel). Fallback: cmake -DCLOCK3D_FETCH_SDL=ON fetches SDL3
pinned to tag release-3.4.14.
cmake --preset debug && cmake --build --preset debug
ctest --preset debugPresets: debug, sanitized (ASan+UBSan), release, stm32-proxy,
debug-clang. Compile with strict warnings (-Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wsign-conversion -Wdouble-promotion -Wformat=2 -Wundef); fix warnings rather than suppressing them.
Launching without any parameters starts with the angle2 framing — the
visualizer look inspired by bondbond's official video:
./build/debug/clock3d_linux # default: angle2 visualizer framing
./build/debug/clock3d_linux --zero # start at 00:00:00
./build/debug/clock3d_linux -t 10:08:37.500 # start at a fixed time (HH:MM[:SS[.fff]])
./build/debug/clock3d_linux --stop -t 10:08:37.500 # frozen single-frame view (time does not advance)
./build/debug/clock3d_linux --config angled # pitch 12 variant of the default framing
./build/debug/clock3d_linux --cam-fov 90 # narrower lens (deg, 60..140)
./build/debug/clock3d_linux --cam-height 0.1 --cam-pitch 3 --cam-roll 20 --cam-radial 0.1 --cam-tangential 0.1 # camera tuning
./build/debug/clock3d_linux --profile stm32 --strip-height 8 # MCU-shaped pipeline preview
./build/debug/clock3d_render_frame --time 10:08:37.500 --cam-roll 20 --output out.ppm # deterministic PPM
./build/debug/clock3d_benchmark --profile desktop
./build/debug/clock3d_benchmark --fast --profile desktop-u16 --tiles 64 # u16 depth + tiled--config NAME applies a bundled set of launch overrides in one flag.
angle2 is the default framing and expands to --cam-pitch 11 --cam-roll -21 --cam-height 0.19 --cam-radial 0 --hub-radius 0.13 --cam-fov 100 --cam-tangential 0.02; angled is the pitch-12 variant of
the original framing (cam-height 0.11). Any extra flags on the command
line AFTER --config
override those values (last wins), e.g. --config angled --cam-fov 120.
Camera launch overrides (offsets added to the active preset; also accepted
by clock3d_render_frame): --cam-height R (height offset in clock radii;
0.1 is a noticeable step), --cam-pitch DEG (pitch offset; + = more
downward), --cam-roll DEG (bank about the view axis; 0 = level horizon,
e.g. -21 for the banked visualizer look), --cam-radial R (radial offset
along the second hand's axis; + = toward the rim), --cam-tangential R
(offset perpendicular to the second hand's axis, moving the camera
left/right of the hand). Values are clamped (height [-0.50, 2.00] R, pitch
±85°, radial [-0.25, 1.20] R, tangential [-0.45, 0.45] R; beyond ~1.0 R
radial the dial is behind the camera) and survive the C/R keys (R resets to
the launch configuration).
--hub-radius R sets the central pin cylinder radius (clock radii, launch
default 0.13, clamped 0.02..0.40; also accepted by clock3d_render_frame).
--cam-fov DEG sets the horizontal field of view (launch default 100,
clamped 60..140; the interactive +/- keys still fine-tune from there).
--stop starts paused: the first frame renders and time never advances
(combine with -t/--zero to freeze at a specific time; Space resumes 1x from
the frozen time, speed keys re-anchor). Space always pauses at the current
clock time (realtime mode included — it really freezes, not just the label)
and resumes from there.
Keys: Esc quit, Space pause, 1 realtime (wall clock; with --zero/-t it returns to 1x on the manual timeline instead of jumping to the wall clock), 2/3/4 speed presets 2x/1.5x/1.25x (preserve the current manual time), F1 overlay (shows clock time), F2 wireframe, F3 depth view, F4 culling toggle, F5 clipping diagnostics, C camera presets, R reset camera, +/- FOV, [ ] quality profile.
The renderer is designed for the STM32G474 from the ground up:
- single-threaded, no heap allocation during a rendered frame;
- MCU profiles:
stm32= 320x240 RGB565 + uint16 inverse-Z depth,stm32-low= 160x120; both use 28.4 fixed-point edge functions and flat shading — no float division in the per-pixel path; - strip rendering (
--strip-height N) renders the frame in horizontal strips, keeping the working set small enough for the MCU's RAM and matching a scanline-ordered DMA framebuffer; - tiled rendering (
renderer_render_tiled,--tiles N) renders with a reusable per-tile depth buffer (8 KB for a 64x64 u16 tile — L1-resident; bit-identical to full-frame, verified by tests). Measured slower than full-frame on x86 (sequential access beats per-tile setup there) — it exists for the MCU, where the depth buffer is RAM and L1 residency is the win; - the
stm32-proxyCMake preset builds the exact MCU code paths on the host so the target behavior is verified in CI-style tests before any firmware work starts.
An additional, non-interactive build of the same core: fixed angle2
framing, no CLI parameters, no camera keys, no overlay, per-pixel stats off,
vsync-aligned present. The interactive clock3d_linux is untouched.
./build/release/clock3d_linux_lite # fixed angle2 framing, 30 fps
cmake -B build/release -DCLOCK3D_LITE_NATIVE=ON # + -march=native -flto (machine-specific)What the lite build changes under the hood (fast_raster):
- Incremental single-precision inverse-depth rasterization. The
reference rasterizer computes depth per pixel with double-precision
W*invzproducts; the fast kernels interpolate1/zwith one float add per pixel, re-anchored to the exact edge values (worst measured error 6.9e-5 — 8.8x below the uint16 depth quantization step). This is the STM32G474-enabling change: the M4F FPU is single-precision only, so per-pixel doubles run as soft-float library calls (~10-50x slower). - Span-based interior rasterization (v2). A triangle's intersection
with a horizontal row is a single contiguous interval (convex set ×
line), so the covered span
[xs, xe]is found by two short ramp walks (from the region edges) and the interior runs a pure depth test — no per-pixel coverage branch. Output is bit-identical to the pre-span kernel (same edge walk, same write order). Measured: raster 2.32 → 1.75 ms at 640x480 (~24% faster than the previous fast path, and ~28% faster than the double-precision reference at 1280x960). - AVX2 interior spans (x86-64 only, opt-in
CLOCK3D_SIMD_AVX2, default ON).src/core/raster_simd.c(compiled-mavx2) processes the interior 8 pixels at a time: depth values asanchor + i*ddxlanes, an 8-wide depth test, masked 32-bit stores (no read-modify-write). The dispatcher gates on__builtin_cpu_supports("avx2")so binaries stay portable, and a runtime switch (clock3d_simd_avx2_enabled) lets the tests A/B it. Lane arithmetic is ~1 ulp more precise than the scalar sequential walk; the SIMD-vs-scalar gate (≤ 0.1 % color diff) is enforced intests/test_raster_fast.c— on the desktop scene the AVX2 kernels are bit-identical to the scalar kernels. Never compiled for the M4F build. - Tiled rendering API (
renderer_render_tiled,--tiles N). Renders the scene tile-by-tile with a reusable per-tile depth buffer (8 KB for a 64x64 u16 tile — L1-resident). Bit-identical to full-frame (disjoint regions, per-region depth, in-order triangles; verified by the tiled-vs-full equivalence test). Measured SLOWER than full-frame on x86 (the full-frame depth walk is sequential and prefetcher-friendly, so the per-tile setup overhead dominates) — the lite build therefore renders full-frame. The API exists for the STM32G474 port, where the depth buffer lives in RAM and per-tile L1 residency is the point. - uint16 depth-test optimization. The u16 kernels test in the float
domain (
v = dval*dscale + doffset >= (float)(stored+1)— exact, no per-pixel clamp/convert); on x86 the u16 path stays slightly behind float depth (the map cost beats the traffic saving), so the lite build uses float depth and u16 remains the MCU profile (RAM necessity). - Prebaked static-vertex transform. The camera rides the second hand,
so face/rim/markers/hub move only by a rotation about the world z-axis
(
V(θ)·v == V(0)·Rz(θ)·v, verified numerically to 1.2e-4 px). Per frame those vertices need a 2D rotation + three basis-row dots + one reciprocal instead of a full 4x4 transform + two divides (1/clip.w == -1/zexactly in IEEE). - No per-pixel statistics, no input handling, no overlay — the hot loop is pure raster.
Quality contract: the fast path must keep the color buffer identical
(tests/test_raster_fast.c sweeps 20 times × 3 presets × 4 format/depth
combos, full-frame and strip rendering; hard gate ≤ 0.1% differing pixels,
memcmp 0 expected; the AVX2 kernels add an A/B sweep against the scalar
kernels, and the tiled path is memcmp-checked against full-frame). Depth
ties on coplanar shared edges may resolve differently than the reference —
every such pair in the scene is same-material, so no visible difference;
all different-material pairs have ≥ 5x depth margin. Acceptance gates:
./scripts/perf_accept.sh (fast raster must stay ≥ 15% faster than the
reference, p99 within 0.5 ms, frame ≤ 7.5% of the 30 fps budget).
Measured windowed CPU (i5-1235U, SDL dummy driver): lite ≈ 6% of one core at 640x480/30 fps (was ≈ 10.5%) — the app is sleep-dominated (30 fps pacing leaves ~94% of the frame idle). The lite build's decisive value remains the STM32G474 path: the fast kernels remove the soft-float doubles, halve the per-vertex divides, and the span/tile machinery is exactly the MCU memory strategy (u16 depth, L1-resident per-tile depth, scanline DMA).
clock3d_core— SDL-free portable core: math, camera, clock time, scene, mesh builder, clipping, projection, rasterizer, renderer.clock3d_linux— SDL3 platform: window, input, time, streaming texture.clock3d_linux_lite— the non-interactive low-CPU build (see above).clock3d_tests— unit + golden-image tests (~3000 checks: projection, clock time/parsers, scene structure, renderer determinism, strip==full equivalence, leak checks under ASan, 6 golden images inoutput/goldens/).clock3d_render_frame— headless deterministic PPM renderer.clock3d_benchmark— deterministic core benchmark.
No heap allocation during a rendered frame. Single-threaded rasterizer.
GNU General Public License v3.0 or later — see LICENSE. Copyright (c) 2026
Cem ASLAN.
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
Copyleft: every distributed copy of this software — including modified versions and firmware images built for the STM32G474 target — must be made available in source form under the same license.