Kirino 的游戏宝藏
← Back to issue 022
Game observation

Fifty-Nine Thousand Empty Triangles on One Shader Core, and an Exact 2× Upscale: How Falllinie Turned Dual-Screen Snowboarding from Missed Refreshes into Sixty Frames on a Mali-G52

If the default story of handheld ports often stops at “lower the resolution, kill effects, then write that it was optimized for portable hardware,” then what happened on the…

Saved articles ↗

If the default story of handheld ports often stops at “lower the resolution, kill effects, then write that it was optimized for portable hardware,” then what happened on the evening of 14 October 2026 on an Anbernic RG DS Plus almost rewrites that sentence in another grammar. Falllinie, a native C++/OpenGL ES port of a browser snowboarding game, runs on two 1024×768 panels under ROCKNIX and Sway. During live play it was missing about one refresh in thirteen. By the end of that evening, the release build held every refresh, raised the internal resolution to a sharper 512×384, and appeared in the handheld’s Ports menu.

The image that runs through this piece is the far-slope triangle that covers no sample. It sounds like a joke, yet it is what this single-core Mali-G52 was paying for. About fifty-nine percent of submitted triangles covered no pixel sample at all. Terrain rows spaced 0.8 m apart shrivel into sub-pixel debris at grazing angles, and still fill the geometry phase. The numbers, acceptance rules, and one rejected shortcut below all orbit that fact.

I cross-checked two public engineering notes: the full deep-dive from that evening, and the performance review from the day before. The counters, replay signatures, and acceptance tables are hard enough. What follows sticks to verifiable facts and does not invent scenes the device logs never recorded.

Device capture, terrain LOD at 60 m versus full detail crop

I. 16.667 ms: how a missed refresh becomes a whole blank beat

The budget is written into the hardware. The RK3568 has four Cortex-A55 cores; the GPU is a Mali-G52 with **one** shader core at 800 MHz, roughly 975 MiB of RAM and no swap. Both 1024×768 panels refresh at 60 Hz, so the game must deliver a new frame to each screen every cycle. The frame period is about 16.667 ms. In that window the GPU can be active for at most

800 × 10^6 cycles/s × 16.667 ms = 13.33 × 10^6 cycles.

Each view runs three GPU passes. The scene pass draws terrain, objects, the rider and particles into a small offscreen image; the print pass turns the encoded image into halftone dots, ink outlines and a procedural sky; the output pass copies the result into the 1024×768 window and draws the HUD. A single main thread issues all of this and hands both windows to Sway. Top panel third-person, bottom first-person: one core eats two sets of geometry and fragments.

At first, “slow” is easy to read as continuous frame-rate sag. Measurement looks like another shape. Live autoplay with audio started at a mean interval of about 17.88 ms, with 7.6% of frames at or above 25 ms and a p95 of about 27.8 ms. After the review split many `frames.csv` files, the mean interval is almost exactly 16.667 ms plus one full refresh for each late frame. Frame time on this device is closer to a count of missed refreshes. One overrun often drops the whole next beat. Pacing is stuck on display refresh and window-buffer acquire; the swap call itself costs about 0.3 ms.

An explicit clear later pinned the wait. On one window’s first framebuffer operation, wall time was about 9.5 ms while main-thread CPU stayed under 0.5 ms. Which window waits longer shifts with session phase. So the optimization target has to change. A pretty mean is not enough. What players see is the share of missed refreshes. A saving smaller than the remaining margin is almost invisible while the GPU is still saturated; once the whole frame fits the refresh window, the same saving suddenly erases long frames.

After the integrated game added trees, rails, particles, an articulated rider and reverberant audio, it sat near 50 FPS with a median loop interval of about 19.8 ms. The earlier playable port had held 60 at 384×288 with a smaller feature set. The goal was blunt. Recover 60 without giving up visual or audio fidelity. Repository rules treat original shader bodies as a hard constraint; every adaptation must be registered explicitly.

II. Geometry fate: how fifty-nine thousand triangles equal zero pixels

Early investigation already held three facts that stayed unwelded into one diagnosis for a long time. GPU-active roughly equalled the clock; fragment activity was present for about ninety-nine percent of that time; main-thread CPU sat well under the frame budget. Together: the GPU was saturated. The question that decided everything after was what that saturation was eating.

At the shipping size, geometry counters produced a hard table. Per loop frame, both views: primitives submitted about 424,955; covers no sample about 251,571; outside near/far about 84,813; facing or side planes about 22,243; visible about 66,328. Position-shader threads about 447,477; full varying-shader threads about 76,585. Non-fragment active cycles about 7.21 M against about 13.95 M GPU-active. Roughly six in ten triangles never produce a pixel; geometry occupies about half of active time.

The source is the terrain mesh. Rows every 0.8 m down the slope, 107 columns each; a cell of about 110 m holds about 29,256 triangles; seven cells resident, one behind the rider and five ahead. Terrain alone is about 205,000 triangles per view against 110,592 internal pixels at 384×288, about 1.85 triangles per pixel. Fog is full at 400 m, yet drawing still reaches about 660 m ahead. Distant rows shrink to pixel debris at grazing angles; a tile-based GPU still pays position shading, setup and discard. On one shader core, geometry and fragments compete for the same execution hardware. Sub-pixel triangles also drag in quad overshading. Those fifty-nine percent of sample-less triangles on the far slope are the most expensive silence on the single-core bill.

The review is blunt. Measurement discipline excellent, prioritisation weak; geometry density and “fog full yet still drawn” were named late. For indie handheld ports, that line is industry coordinate more than scolding. Precise instruments aimed at the wrong question only fill calendars. Decisive data often comes from cheap experiments and one counter set aimed at the right question. Name the limiting resource before choosing a prettier probe.

III. Four image-preserving cuts, and one rejected shortcut

Image-preserving work had a written promotion rule. Start behind a flag; replay signature unchanged; eight images at five checkpoints byte-identical; not slower in ABBA order; no GL errors and complete measurement records. Every replay in this report produced signature `6081555844518115723`. Change any pixel, and take side-by-side device captures to the project owner; do not sneak it into the default.

First cut: shadow bounds. The scene shader evaluates three ray-to-segment shadow terms against the rider’s body and board for every terrain fragment. A conservative test projects along the light into a 2D coordinate, bounds the rider segments, and runs the original loop only inside the bound. At shipping 384×288 in replay: original shadows mean 17.273 ms, p95 20.399, frames ≥25 ms 3.9%; with bounds 16.620 / 17.611 / 0.0%. With audio, frames ≥25 ms fell from 6.5% to 0.5%. Bounds alone brought the shipping configuration to 60 Hz. The GPU stayed essentially full; headroom near zero. When saturated, active cycles per frame are only clock divided by frame rate; savings must be measured elsewhere.

Second cut: row-block culling. Terrain indices are written row by row, so sixteen consecutive rows (about 12.8 m of slope) are a contiguous index range. Each block stores the AABB of its vertices. Before a view draw, the eight corners are transformed into clip space. If all eight lie outside the same half-space, the block is skipped. Clip coordinates are linear in position; any point inside the box is a convex combination of the corners. If every corner is outside one half-space, every point of the box is too. No triangle in the block can produce a fragment, so skipping the draw changes nothing. Remaining blocks merge into as few draws as possible, in original order. A CPU test evaluated the same float expression on about 7.78 million sampled points from 19,460 rejected boxes. Culling whole 110 m cells removes almost nothing, because each cell is so wide it almost always intersects both views.

Third cut: 16-bit indices. A cell has 14,873 vertices, so every index fits in 16 bits. Narrowing at upload keeps values and order unchanged and halves index traffic. Dynamic meshes, the rider and the multi-cell static fixture keep 32-bit indices.

Fourth item is measurement. With the output-pass clear inside its own timing scope, that ~9.5 ms wall wait is pinned as buffer acquire, no longer mixed into a vague “rendering is slow.” Swap is about 0.3 ms. The loop is paced by buffer acquire on the first draw into a window, even with swap interval 0.

One cut had a sound argument and byte-identical images, and was still rejected. Fog writes a constant at 400 m; fully fogged blocks could use a short-circuit shader. The offline compiler showed the extra branch lengthened the longest arithmetic path from 11.03 to 11.20 for every fragment, while only a thin far band benefits. At 512×384 the mean moved from about 20.571 to a worse 20.712. The shortcut stayed opt-in and was recorded as rejected. A pretty proof does not outrank a longer common path.

After the three cuts became defaults, live autoplay with audio saw frames ≥25 ms fall from 7.6% to 0.1%, and p95 from 27.8 to 17.2 ms. The GPU was still about 98% busy. Image-preserving work had pushed the shipping size to the budget edge with almost no margin left to buy a sharper internal resolution.

IV. One image-changing cut, and spending the headroom on exact 2×

Culling cannot touch the largest cost: triangles that are on screen but smaller than a sample. Removing them means drawing distant terrain with fewer triangles. The design keeps the change as small and as safe as possible.

For each 16-row block, LOD is chosen by distance. Level L keeps every 2^L-th row and all 107 columns. Quads use the same two triangles and winding as the original strips. First and last rows of a block are always included at full column resolution; neighbours share those boundary vertices, so mismatched levels leave no cracks or T-junctions. Inside near distance N, full detail; beyond, floor(log2(d/N))+1, capped at 4. The element buffer stores original indices then each block’s decimated ranges; adjacent same-level blocks merge into one draw. Row-block culling and 16-bit indices still apply.

Measured on device against full-detail defaults: submitted triangles about 368,600 → 134,000 (−64%); covers-no-sample about 252,000 → 54,500; non-fragment active about 5.26 M → 2.80 M; external reads about 21.2 MB → 9.2 MB; GPU busy at shipping size about 99% → about 69%. At 512×384 dual view, mean about 20.12 ms / 21.0% frames ≥25 ms fell to about 16.62 ms / 0.0%. A 120 m near distance changed fewer pixels and left the GPU about 77% busy, saving less. The owner reviewed side-by-side, swipe and difference pages and chose 60 m. Across five checkpoints, each 1024×768 window changed about 0.6% to 1.4% of pixels, concentrated on distant ridges, halftone dots along them and some contour lines.

The headroom was not spent on further load reduction. The owner spent it on 512×384. Against 1024×768 that is exact 2×. Nearest-neighbour from 384×288 is 2.667×; texels land in a repeating 3-3-2 column pattern. The print shader’s halftone pitch is about 4.4 screen pixels; interference with the uneven grid makes a plaid in halftone areas and lumps outlines across the 2-pixel columns. At 512×384 every texel becomes a 2×2 block, the pitch is 2.2 texels, and the pattern reproduces evenly. Output copies are cheap per pixel (about one cycle) but large: both windows together write about 1.57 million fragment threads, roughly twelve percent of the budget. The copies stay; HUD text still draws at full window resolution.

With 60 m terrain and audio, both internal sizes hold 60 Hz in replay and live autoplay with no frames ≥25 ms. 512×384 uses about 93% of the GPU against about 69% at 384×288. Three live sessions of about 110 seconds each, seeds 42, 7 and 1234, totalled 19,605 frames with **zero** missed refreshes; mean stuck to 16.667 ms; p95 about 16.73 to 16.75; longest about 19.75 to 21.74 ms; GPU busy about 89.5% to 90.6%; peak memory 99 to 102 MiB; no GL errors, no audio underruns.

Release defaults: shadow bounds, row-block culling, 16-bit indices, `--terrain-lod 60`, launcher `--render-size 512x384`. Deployed to `/storage/apps/falllinie-spike`, Ports menu name Falllinie, binary sha256 `7f86667cc49c1aa15e725ee636f8db8f58445f1c79cf7fc6988c980f16363398`, commit `0bf9969`. Previous binary kept as `falllinie-spike.previous`; one `mv` rolls back.

Device capture, 384 versus 512 horizon crop

Closing: rewriting “optimize” on a single-core bill

Put Falllinie back into the 2026 handheld and indie-port floor, and it hits another kind of inertia. Some people read a slightly worse mean as “turn the quality slider down one notch”; some assume that cutting CPU submission on a saturated GPU will buy frame rate. An image-preserving shortcut with a pretty proof still may not deserve to ship.

The counter-example this path offers is clumsy and hard. Name the limiting resource before choosing the instrument; measure savings on an overloaded configuration when the GPU is saturated; make “submitted / visible / culled by reason” the most informative table; prove image identity, then check it on the device; put image-changing decisions in front of a person with real captures. The review line “excellent measurement, weak prioritisation” reads more like an industry coordinate. Shadow bounds and the deterministic replay are the durable assets of this round.

The executable checklist is short enough to paste into an indie or handheld iteration.

  • Write down your frame budget and limiting resource. Dual-screen 60 Hz means ≤16.667 ms per frame and ≤13.33M GPU cycles on one core; first confirm whether saturation is geometry, fragments or presentation wait.
  • Report missed-refresh share or long-frame ratio beside the mean. Under presentation pacing, an overrun often drops the whole next beat.
  • Build a geometry-fate table: submitted, covers no sample, near/far, facing, visible; divide by internal pixels for triangles per pixel. Meshes still drawn past full fog go on the suspect list first.
  • Promote image-preserving candidates with a written rule: signature, byte-identical checkpoints, ABBA, no GL errors. Reject branches whose argument is sound but whose longest path lengthens for every fragment.
  • Decide image-changing work only from side-by-side device images. Falllinie traded 60 m LOD for about −64% submitted triangles and about −30% GPU, changing 0.6% to 1.4% of window pixels, then spent the headroom killing 2.667× moiré with exact 2×.
  • Accept with long live sessions that include audio; muted short replays are only a control. Three 110-second runs, 19,605 frames, zero missed refreshes: that is the ground under the Ports menu line named Falllinie.

A single core will not forgive triangles that cover no sample. It only asks whether you counted them first, then chose the cut among bounds, culling, indices, or the one distance that thins the far slope. Exact 2× upscale is the one sharpness purchase that still makes sense once a 3-3-2 texel cycle is written into the panel ratio.

小光

Sources and further reading

Project report, Falllinie at 60 FPS (2026-10-14), Geometry, Presentation and a Crisp 512×384 Release on a Single-Core Mali-G52

Project report, Falllinie Performance Review (2026-10-14), Measurement Discipline, Missed Bottlenecks and a Path to 60 FPS

Sign in to keep your reading history.

View your likes and saved articles across devices.

Sign in