Build1 distinct publisher3 min readPublished
Raxxo Studios got a Three.js game approved in 17 days. The four bugs that nearly stopped it all failed quietly, so each cost about a day of debugging the wrong layer, starting with one headless Chrome flag.
The Engineer · Build desk

Compiled by The EngineerSomething wrong?How this is made
The first number in the write-up is one you can check without a device. Raxxo Studios puts the first-pass offscreen render at roughly two and a half minutes per frame [3], and describes that as a little over two hours of compute for one second of 30 fps footage [7]. 150 seconds times 30 frames is 4,500 seconds, or 75 minutes [1]. The pipeline was unusable at either figure. A number quoted in prose does not tell you what to budget a render farm against.
The cause was the launch line. The tool was starting Chrome with `--use-gl=angle --use-angle=swiftshader --enable-unsafe-swiftshader` [4]. SwiftShader rasterizes on the CPU, which is the correct default for a CI box with no GPU and the wrong one for a workstation that has a GPU [5]. Switching to `--use-angle=metal --enable-gpu` gave 38 to 60 fps on the same code, machine and browser [6]. That is a frame time of 17 to 26 milliseconds against 150 seconds, so roughly 5,700x to 9,000x [2]. A software renderer does not throw an error; it simply takes longer, and that is how you lose days optimizing geometry that was never the problem [8].
For the 38 to 60 fps to transfer, your capture host needs a GPU that ANGLE's Metal backend can address. On a Linux CI runner you get SwiftShader, and 2.5 minutes per frame is the figure that applies to you. Both configurations still live in the author's tools directory on purpose, because determinism and speed are different jobs [9]. Keeping the slow one and demoting it from default is better engineering than deleting it.
The WKWebView failure is the one worth copying. iOS kills the web view's GPU process when it decides memory is tight, and the app does not crash: the game loop keeps ticking and the score keeps counting while the world renders black [10]. Observed on device, no `webglcontextlost` event was dispatched at all [11]. So the shipped build stops waiting to be told. `isContextLost()` is polled about every two seconds [12], and a draw-call floor treats 120 consecutive frames under ten draws, well past boot, as a dead pipeline even when the context object still reports itself healthy [13]. At 60 fps that window is two seconds of wall clock, four at 30 [3], so the floor trips inside roughly the same window as the poll and does not depend on the context object telling the truth.
The ten is workload-specific. It holds because this game's post-processing chain alone submits more than ten draws per composed frame [13]. Port the detector to something that composites with one fullscreen quad and it either never trips or trips at the title screen.
Recovery banks the run so the score survives, then reloads [14]. Reload is also the most expensive thing the app does, replaying parse, scene build and shader warm-up on a device that just ran out of memory, which can trigger the same kill; the author's term for the unbounded version is "a hang with extra steps" [15]. Three recoveries inside ten minutes ends the cycle [16].
The world-fold bug is arithmetic. Folding every 3,000 m over a city that repeats every 540 m leaves a 300 m offset on every fold [20]. 540 times 5 is 2,700 and 540 times 6 is 3,240 [4], so the fold distance was never free to be a round number.
Ranked by verification strength, evidence, and original report placement.
The author shipped a Three.js game to the iOS App Store, running in a WKWebView through Capacitor with zero network calls, going from first commit to approved in 17 days across 247 commits.
All four of the problems that nearly sank the project failed silently: nothing threw, nothing logged, and every one of them looked like something else for at least a day.
The first pass at rendering portrait video and App Store screenshots offscreen and headless ran at roughly two and a half minutes per frame.
Headless Chrome was launching with --use-gl=angle --use-angle=swiftshader --enable-unsafe-swiftshader.
SwiftShader is a software rasterizer; the author calls it the right default for a CI box with no GPU and completely wrong for a machine that has one.
Switching to --use-angle=metal --enable-gpu rendered the same scene at 38 to 60 fps, with the same code, same machine and same browser.
Distinct publishers with included, body-backed reporting in this cluster.
dev.to
1 article · August 31, 2026
Follow any of these and your For You feed starts watching them — no settings page required.
build
Spline V2 turns the 3D editor into an endpoint, with the desktop app as the only door1 distinct publisher
build
WebKit charges 39 MB for the tab Kestrel's simulator priced at 32 KB1 distinct publisher
build
An H100's MIG slices hand Chromium's WebGL straight back to the CPU rasteriser1 distinct publisher
build
canvas.toBlob hands you a PNG and calls it WebP: check blob.type, not the user agent1 distinct publisher
Evidence-backed comparisons of source perspectives and observed adoption signals. Read the methodology
Which Builder, Operator, and Investor concerns the observed source mix emphasized—not a truth score.
Evidence, demonstrated adoption, hype gap, incentives, and confidence are assessed independently, each on its own current evidence. How these are measured.
One engineer's logbook, checkable only against itself
Every load number, flag string and threshold in this story comes from the studio that shipped the app: no repository, no device logs, no second developer confirming that WebKit skipped the event. What keeps it from being unusable is specificity - named launch arguments, a two-second poll, ten draws, 120 frames, three recoveries per ten minutes are all falsifiable by anyone who tries them. The one figure that can be checked without a device fails: 150 seconds a frame at 30 fps is 75 minutes for a second of footage, not the two hours claimed.
One approved app, no second user of the pattern
There is a real artifact at the end of this rather than a proposal: an approved 1.0 with six purchases and no ads, running the detectors as described. But it is one app from one studio, and no other team is shown polling isContextLost() or treating a draw-call floor as a liveness check. Install counts, revenue, and how often the recovery actually fired in the field are all absent, so the recovery cap is a design decision without an operating record behind it.
Restrained for a five-thousand-fold headline
A post that could have sold a thousands-of-times speedup instead names the flag, keeps the slow configuration on purpose, and concedes SwiftShader is the correct default on a GPU-less CI box - that is the opposite of inflation. The overreach is narrow and specific: one arithmetic slip that nearly doubles the stated pain of the bad path, and one device's silence written up as WebKit's general behaviour. The 17-days-to-approved framing also does quiet promotional work for a paid app.
Post-mortem doing double duty as a launch note
The write-up promotes a commercial product - six in-app purchases, version 1.0, freshly approved - and links twice to the same studio's earlier posts, so the tidy four-bugs-in-17-days structure serves reach as well as instruction. The pull is toward looking competent, and the details most flattering to that goal (fast approval, elegant thresholds) are the ones nobody can audit, while the ones that would embarrass a studio, such as the unbounded recovery loop the author admits he nearly shipped without, are still disclosed.
Trust the build, not the generalisations
Two different confidences are tangled here. What this particular app does - the flags, the three detectors, the cap, the marker - is described with enough precision that it is probably accurate, and the author is the only possible witness. Whether the ten-draw floor, the two-second interval, the three-per-ten-minutes cap or WebKit's missing event hold on other hardware, other iOS builds, or scenes with a different post-processing chain is untested, and one arithmetic error in the piece is a reminder that no editor or reviewer stood between the notebook and publication.