Client stack — Galaxy (research, 2026-11-09)
Mobile-first, web-first 3D space MMO. Server-authoritative, F2P, no tracking, 80% dense DOM UI / 20% 3D viewport.
Everything below was checked against a live source on 2026-11-09. Numbers I measured locally are marked [measured] with the exact command in the appendix; anything I could not execute is marked [UNVERIFIED]; anything derived by reasoning rather than observed is marked [INFERENCE].
0. What was verified, and how
| Fact | Method |
|---|---|
| npm versions/dates | https://registry.npmjs.org/<pkg> (dist-tags + time) on 2026-11-09 |
| Browser support + usage weights | caniuse-lite@1.0.30001823 (unpacked data/features/*.js + agents.usage_global), and Fyrd/caniuse raw feature JSON for the per-note text |
| Safari/iOS behaviour | Apple Safari release notes (.md variants) + webkit.org posts |
| Renderer bundle sizes | esbuild 0.28.2, local builds, gzip -9 / brotli -q 11 |
| Blender | blender.org download page + Blender Python API and manual pages |
| Server capability | ssh inspection of koren@192.168.1.250 and koren@agnes.pukogames.com |
The two servers were inspected, not modified. Neither has Blender installed (which blender → nothing on either; dev box /usr/local/sbin:/usr/local/bin:/usr/bin, prod Debian 13.7), so section 7's "runs headless" claim is documentary plus hardware-capability, not an executed run.
Version snapshot, 2026-11-09:
| Package | Version | Published |
|---|---|---|
three | 0.187.0 | 2026-10-30 |
@react-three/fiber | 9.8.2 | 2026-10-14 |
@react-three/drei | 10.7.9 | 2026-09-25 |
babylonjs / @babylonjs/core | 9.35.0 | 2026-11-06 |
playcanvas | 2.24.0 | 2026-11-09 |
react | 19.3.1 | 2026-10-14 |
vite | 8.3.7 | 2026-11-09 |
typescript | 7.0.2 | 2026-07-08 |
tailwindcss | 4.3.5 | 2026-11-06 |
@capacitor/core | 8.6.0 | 2026-10-28 |
@tauri-apps/cli | 2.12.4 | 2026-10-21 |
react-native-webview | 14.0.1 | 2026-06-20 |
@gltf-transform/cli | 4.5.1 | 2026-09-28 |
gltfpack (npm) | 1.3.0 | 2026-09-25 |
@playcanvas/react | 0.11.7 | 2026-09-25 |
1. Renderer: Three.js vs Babylon.js vs PlayCanvas
1.1 Measured bundle size [measured]
Minimal scene = one box mesh, one material, one light, perspective camera, render loop. "All" adds the glTF loading path each engine needs for our content: GLTFLoader + Draco + KTX2 + meshopt (three), SceneLoader + glTF 2.0 loader + Draco + BasisU extensions (Babylon), Asset container loader (PlayCanvas).
| Build | raw | gzip -9 | brotli -q 11 |
|---|---|---|---|
| three, minimal (WebGLRenderer) | 537,265 | 133,182 | 110,539 |
three, minimal, three/webgpu (WebGPURenderer) | 818,507 | 221,704 | 180,982 |
| three, loading path only (GLTFLoader+Draco+KTX2+meshopt) | 498,362 | 128,924 | 107,885 |
| three, minimal + loading path | 715,278 | 189,184 | 157,217 |
Babylon minimal (@babylonjs/core deep imports) | 1,618,195 | 381,493 | 294,151 |
| Babylon, minimal + glTF/Draco/BasisU | 2,964,113 | 694,429 | 500,015 |
PlayCanvas, minimal (pc.Application) | 2,103,670 | 539,859 | 419,084 |
| PlayCanvas, minimal + container loader | 2,103,801 | 539,931 | 419,237 |
| React DOM only (control) | 223,565 | 69,089 | — |
| React DOM + r3f + drei + three (one cube, OrbitControls) | 1,174,497 | 322,109 | — |
Read: three is 3.7× smaller gzipped than Babylon for the same job with a glTF pipeline, 2.9× smaller than PlayCanvas. r3f + drei + three adds 253 KB gzip on top of a React-DOM baseline — that is the price of the React integration layer, and it is dominated by drei + three itself, not by r3f.
(Babylon's deep-import build is already tree-shaken by hand; PlayCanvas was built from its playcanvas ESM entry with a single import * as pc and esbuild tree-shaking enabled. The Babylon minimal build still pulls the engine's core scene/effect/material machinery, which is where its weight comes from. Babylon's own ES6 doc acknowledges the bundling burden: "by using ES6 packages, you will need to rely on extra tooling to bundle and test your library but on the bright side you will be able to tree shake your application and reduce its final size", plus the side-effect imports trap for MeshBuilder — https://doc.babylonjs.com/setup/frameworkPackages/es6Support.)
1.2 Feature comparison
| Three.js 0.187.0 | Babylon.js 9.35.0 | PlayCanvas 2.24.0 | |
|---|---|---|---|
| Release cadence | monthly (r187 2026-10-30, r186 2026-09-24 — https://github.com/mrdoob/three.js/releases) | 9.35.0 2026-11-06 | 2.24.0 2026-11-09 (near-daily patch/builds — https://github.com/playcanvas/engine/releases) |
| glTF 2.0 | GLTFLoader in three/examples/jsm/loaders/, covers Draco, KTX2 (KHR_texture_basisu), EXT_meshopt_compression, extensions pluggable | @babylonjs/loaders glTF 2.0 + Draco + BasisU extensions (separate package, verified resolvable at 9.35.0) | pc.Asset type container, Draco tutorial documented (https://developer.playcanvas.com/tutorials/loading-draco-compressed-glbs/) |
| Post-processing | examples/jsm/postprocessing/EffectComposer (extra imports, nothing in core) | built in: @babylonjs/core/PostProcesses/RenderPipeline/Pipelines/defaultRenderingPipeline + bloomEffect etc. ship in core 9.35.0 (verified in node_modules) | built in: camera postEffects queue — bloom, vignette/fringing, sepia (https://developer.playcanvas.com/user-manual/graphics/posteffects/) |
| WebGPU | three/webgpu entry, WebGPURenderer with an in-core WebGL2 fallback backend | WebGPUEngine, since Babylon 5.0 (2022), WGSL-native shaders since 2024 — https://doc.babylonjs.com/setup/support/webGPU | WebGPURenderer alongside WebGL2; active fixes each release (volume textures, compressed mip uploads, cubemap depth on WebGPU — v2.24.0 notes) |
| TypeScript source | yes, .d.ts shipped; JSDoc-typed JS source | yes, TS-first, richest typings of the three | TS source, generated declarations; the editor is the product, the engine is ESM lib |
| React integration | react-three-fiber 9.8.2 (peer react >=19 <19.4, three >=0.156), drei 10.7.9 | no first-class React renderer; @babylonjs/react-native 2.0.8 is RN-only | @playcanvas/react 0.11.7 (young, 0.x) |
| Editor | none (code-first) | sandbox + Node Material Editor | full hosted editor, asset pipeline, publishing |
| License | MIT | Apache-2.0 | MIT |
Three's WebGL2 fallback is not marketing: src/renderers/webgpu/WebGPURenderer.js imports WebGLBackend, documents forceWebGL ("If set to true, the renderer uses a WebGL 2 backend no matter if WebGPU is supported or not") and wires parameters.getFallback = () => new WebGLBackend( parameters ) when WebGPU is unavailable — https://raw.githubusercontent.com/mrdoob/three.js/dev/src/renderers/webgpu/WebGPURenderer.js.
r3f v9 explicitly handles the WebGPU path (it releases the device/context in dispose() and notes "WebGPURenderer.dispose() is async from three r186" in its dist source), so r3f is not a WebGL-only dead end.
1.3 Mobile performance characteristics
- The three engines are all thin wrappers over the same driver reality: on mid-range Android and iOS the authoritative limits are tile-based-GPU fill rate, texture bandwidth and draw-call count, not API overhead. At ~20 low-poly ships with one directional light and no shadow maps, all three are far from any limit.
- WebGL specifics that matter for mobile: compressed texture formats reduce texture memory and bandwidth ("This reduces texture memory bandwidth, which is precious on mobile"), and
OUT_OF_MEMORY/CONTEXT_LOST_WEBGLare the only errors a well-formed page produces — https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/WebGL_best_practices. Every engine choice must include context-loss handling. - Babylon's advantage is real but different: it optimises for feature density (PBR, shadows, particles, post FX, inspector) at the cost of startup bytes and CPU-side scene overhead. PlayCanvas optimises for editor-driven content production and has the best out-of-the-box "load a .glb and view it".
- Verdict on performance: not a differentiator at our scale; byte weight and React integration are.
1.4 Verdict
Three.js. It is the only one of the three that (a) stays under 200 KB gzip with a full compressed-glTF pipeline, (b) has a mature React renderer, and (c) has a single renderer class that spans WebGPU and WebGL2 — so the 3D code does not become dual-path if we later move to WebGPU. Babylon is the better choice for a team that wants shadows/particles/PBR out of the box and accepts +500 KB; PlayCanvas is the better choice for a studio that wants a browser editor, which we do not (assets are procedural, generated by a Blender script).
2. WebGPU vs WebGL2 in late 2026
2.1 Actual support, with usage weighting
From caniuse-lite@1.0.30001823, feature webgpu, weighted by agents.<browser>.usage_global (global usage share, not pageviews):
| Browser | Current version | Status | Share of that browser's global usage that supports it |
|---|---|---|---|
Chrome Android (and_chr) | 157 | y | 44.02% of 44.02% → 100% |
iOS Safari (ios_saf) | 27.2 | y from 26.0 | 10.23% of 13.23% → 77.3% |
| Chrome desktop | 157+ | y | 23.61% of 24.96% → 94.6% |
| Safari desktop | 27.3 | a #2 #7 (caniuse flags partial) | 0% counted as y |
| Edge desktop | 156 | y | 5.01% of 5.05% → 99.2% |
| Firefox | 162 | a d #1 #8 | 0% (Windows-only, behind partial) |
| Samsung Internet | 31 | y | 1.91% |
| Firefox Android | 159 | n d #1 | 0% |
Opera Mobile (op_mob) | — | y | 1.16% of 1.16% → 100% |
Summing the supported share across all tracked browsers: ≈ 86% of global browser usage has WebGPU today, and the browsers where it is missing are desktop Safari/Firefox plus the 22.7% of iOS Safari usage on iOS < 26.
Notes text from https://raw.githubusercontent.com/Fyrd/caniuse/main/features-json/webgpu.json: #2 "Can be enabled in Safari with the WebGPU feature flag", #7 "Partial support refers to only being enabled by default on macOS 26 Tahoe or later", #8 "Only enabled by default on Windows as well as macOS 26 Tahoe or later on Apple Silicon", #9 "Support on Linux depends on hardware and drivers".
For WebGL2, the same measurement gives ios_saf 13.16 / 13.23 (99.5% of iOS Safari usage) and and_chr 44.02 / 44.02 (100%).
Authoritative version gates:
- Apple: Safari 26 release notes, "### WebGPU — Added support for WebGPU. (145801580)" — https://developer.apple.com/documentation/safari-release-notes/safari-26-release-notes. Safari 26 shipped in the 2025 release cycle (caniuse still tracks iOS 26.6 as a live, sizeable population alongside 27.x), so WebGPU has been shipping on iOS for roughly a year. [UNVERIFIED: exact ship month]
- MDN browser-compat-data:
api.GPU→safari: 26,safari_ios: "mirror",chrome_android: 121,chrome: 113 partial until 144,firefox: 141 partial (no service-worker contexts)— https://github.com/mdn/browser-compat-data/blob/main/api/GPU.json. - Chrome Platform Status for WebGPU: desktop 113, "Enabled by default".
The caniuse desktop-Safari a row contradicts MDN and Apple's own release note; caniuse's own note #7 explains it as an OS-version gate. Treat MDN + Apple as authoritative: Safari 26+ = WebGPU available on iOS.
2.2 Feature-parity drift, which is the actual risk
Safari's WebGPU is not stale, but it is behind Chromium feature-by-feature, and the gap closes release by release:
- Safari 27.0: "now supports the
clip_distancesbuilt-in value in WGSL shaders" — https://webkit.org/blog/18325/webkit-features-for-safari-27-0/ - Safari 27.3: "Added support for subgroups in WebGPU" — https://developer.apple.com/documentation/safari-release-notes/safari-27_3-release-notes
- Safari 26.2: "Added WebGPU support for WebXR", plus
GPUTextureas depth-stencil/resolve attachments. - Safari 26.1: "Fixed an issue where WebGPU video textures failed to load in Three.js panoramas" — i.e. Apple is fixing three.js-specific bugs against our likely library.
So: compute shaders, render passes, MSAA, WebXR all exist; optional limits/features (subgroups, clip_distances) are recent. Anything gated must be behind adapter.limits / feature checks, never assumed.
2.3 Verdict
WebGPU is safe as a primary path with a WebGL2 fallback, but it buys us nothing today, and the migration is not free in the other direction. Two reasons for the second half:
- With three, choosing
three/webgpumeans committing material code to TSL/NodeMaterial, which is a different material authoring model thanShaderMaterial/MeshStandardMaterial. Going WebGL2-first and switching to WebGPU later means rewriting the custom materials; going WebGPU-first and switching back is trivial (forceWebGL). The cost is one-way. - Our workload (20 low-poly ships, one light, procedural starfield) is nowhere near WebGL2's limits. The measured +88 KB gzip for the WebGPU renderer is the only immediate price.
Recommendation: WebGL2 (THREE.WebGLRenderer) for v1, behind a createRenderer() seam. Revisit when (a) we want GPU compute for particles/star systems, or (b) iOS WebGPU share crosses ~95% and three's TSL has stabilised — then flip the seam to three/webgpu and port the handful of custom materials.
Three's own guidance points the same way: its manual says WebGPURenderer "is designed to be the modern alternative to the long-standing WebGLRenderer… if a device/browser doesn't support WebGPU, the renderer can automatically fall back to using a WebGL 2 backend" (https://github.com/mrdoob/three.js/blob/dev/manual/pages/webgpurenderer.html), and its machine-readable guidance (docs/llms.txt) says use WebGPURenderer for TSL, compute shaders and node materials, and use WebGLRenderer for "maximum browser compatibility… well-established, many years of development" (https://github.com/mrdoob/three.js/blob/dev/docs/llms.txt). We need none of the three triggers yet.
3. UI layer: DOM/React over one canvas
3.1 In-canvas UI is wrong for this game
At 80% informational UI, canvas-drawn UI would mean reimplementing text layout, scrolling with momentum, text selection, form inputs, IME for non-Latin input, accessibility, and copy/paste — and then paying per-frame text rasterisation cost on a tile-based mobile GPU. DOM gives all of it for free, and the browser can composite it without touching the GL loop. Decision: DOM + React + Tailwind, one full-viewport <canvas> behind it.
3.2 Composition that does not kill mobile performance
| Rule | Why |
|---|---|
One canvas, position: fixed, full viewport, z-index: 0; UI in sibling DOM at z-index: 1. Never nest the canvas inside a scrolling container | Avoids iOS scroll-time repaint of the GL surface and re-layout of the canvas on every scroll frame [INFERENCE] |
| Render loop is independent of React; game state lives outside React (a store with subscriptions), React reads snapshots for panels | A 60 Hz setState is a 60 Hz reconciliation; DOM UI at 80% of the app makes that fatal [INFERENCE] |
Panels use contain: strict / content-visibility: auto for long lists | css-content-visibility is supported on current iOS Safari and Android Chrome (caniuse-lite: ios_saf 27.x = y, and_chr 157 = y) |
Scrolling containers animate only transform/opacity; no backdrop-filter on large surfaces | Blur is a per-frame full-screen read on a tile GPU [INFERENCE] |
touch-action: none on the canvas, overscroll-behavior: contain on scroll panels | Stops pull-to-refresh and rubber-band stealing drags; both css-touch-action and css-overscroll-behavior are y on ios_saf 27.x per caniuse-lite |
Cap devicePixelRatio at 2 and scale render resolution by frame-time | Fill rate is the mobile budget; retina 3× triples fragment work for no legibility gain (the UI is DOM, so its text stays crisp regardless) |
Handle webglcontextlost → re-create renderer, do not crash | Mandated by MDN best practices; iOS reclaims contexts aggressively |
3.3 Safe areas and notches
viewport-fit=cover in the viewport meta, then pad with env(safe-area-inset-*): MDN documents safe-area-inset-top/right/bottom/left as the values "provided by the iOS browser to allow developers to place their content in a safe area of the viewport, and not be obscured by device notches or rounded corners", plus safe-area-max-inset-* as their static maximums for animating between states — https://developer.mozilla.org/en-US/docs/Web/CSS/env. Practical shape: app shell padding = env(safe-area-inset-*), bottom tab bar padding-bottom includes env(safe-area-inset-bottom), and the canvas deliberately does extend under the notch (space wants the pixels) while UI does not.
60 fps scroll on iOS: the DOM scroll is compositor-driven; keep the GL loop's per-frame CPU work under ~4 ms (frame budget 16.7 ms) and never schedule React work from scroll handlers — throttle to requestAnimationFrame + passive listeners. Marking this as [INFERENCE] from the platform model; it must be verified on a physical mid-range iPhone in step 3 of the build.
4. PWA reality check on iOS, 2026
| Capability | State on iOS | Source |
|---|---|---|
| Install any site to home screen as a web app | Yes. Safari 26: "Added support for any website to become a web app on iOS or iPadOS. (113034903)"; also fixed the Add-to-Home-Screen load bug (154655565) | https://developer.apple.com/documentation/safari-release-notes/safari-26-release-notes |
| Web App Manifest | Partial. caniuse marks ios_saf 27.x = a; manifest supported members are a subset (caniuse note: "Safari on iOS does not support A2HS in WebViews") | caniuse web-app-manifest |
| Standalone window / no browser chrome | Yes, when the manifest sets display: standalone/fullscreen; otherwise it is a bookmark that opens in the default browser | https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/ |
| Web Push | Only after install to Home Screen. caniuse push-api for ios_saf 26.x–27.x is a #7, note #7 = "Requires website to first be added to the Home Screen." Permission must come from direct user interaction; delivery goes through APNs, and no Apple Developer Program membership is needed | same two sources |
| Web Push in a wrapper (WKWebView) | Not available. push-api note #6: "Supported in Safari, not WKWebView nor SFSafariViewController" | caniuse push-api |
| Service Workers | Yes (ios_saf ~99.9% of usage) | caniuse serviceworkers |
| Background Sync | No. ios_saf 27.x = n (Chrome/Edge/Samsung = y) | caniuse background-sync |
| Periodic Background Sync | Absent from iOS entirely; the feature is not even in caniuse-lite's feature set (Chromium-origin API) | caniuse-lite features dir |
| Storage quota | Origin quota up to 60% of disk for a browser app, 15% for other apps; overall quota 80% / 20% (Safari 17+, iOS 17+). A standalone Home Screen web app "has the same origin quota and overall quota as when it is opened in a browser app" | https://webkit.org/blog/14403/updates-to-storage-policy/ |
| Eviction | LRU per origin; navigator.storage.persist() granted by heuristic ("whether the website is opened as a Home Screen Web App"); navigator.storage.estimate() for usage/quota | same |
| 7-day ITP wipe of script-writable storage | Exempt for Home Screen web apps. "The first-party domain of home screen web applications is exempt from ITP's 7-day cap on all script-writeable storage… In addition, the website data of home screen web applications is kept isolated from Safari" | https://webkit.org/tracking-prevention/ |
| Offline caching | Service worker + Cache API, full control. No background sync means writes queue client-side and flush on next launch | synthesis of the rows above |
Consequences for the design, not just for the stack:
- Notification-dependent features require install on iOS. Notifications must never gate core gameplay; "your fleet arrived" push is a bonus, and the in-app queue must be authoritative (which it already is: server-authoritative ticks).
- No background sync: any offline action is optimistic UI + a queued mutation replayed on next open. Do not design anything that depends on syncing while the app is closed.
- Storage is generous and durable once installed (60% of disk, ITP-exempt,
persist()heuristics favour installed apps). Asset caching for a 3D game is viable; still treat the cache as disposable and always be able to re-download. - The install flow is a UX problem, not a capability problem. On Android it is a
beforeinstallprompt; on iOS it is Share → Add to Home Screen — offered in every iOS browser since iOS 16.4 ("third-party browsers can now offer their users the ability to add websites and web apps to the Home Screen from the Share menu", https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/), but not from inside an embedded WebView of a third-party app (caniuse: "Safari on iOS does not support A2HS in WebViews like Chrome and Firefox"). Onboarding must explain it once, at the moment notifications become relevant.
Do we need a native wrapper? No, not for v1. Everything we need exists in a PWA on both platforms except (a) push without installation on iOS, (b) background work while closed, (c) store presence. All three are optional for an OGame-lineage game, where "check in twice a day" is the intended loop.
5. Native wrapper options, if a wrapper ever becomes necessary
| Capacitor 8.6.0 | Tauri 2.12.4 (mobile) | React Native WebView 14.0.1 | |
|---|---|---|---|
| One codebase preserved | Yes — it is your web app in a WKWebView/Android WebView, plus a plugin bridge | Yes, same idea; Rust host process, platform webview underneath — "Tauri uses Microsoft Edge WebView2 on Windows, WKWebView on macOS and webkitgtk on Linux", and the platform system webview on iOS/Android (https://v2.tauri.app/concept/process-model/) | Only if the whole app is a RN shell around a WebView — you inherit RN's build system for a wrapper |
| Setup cost | Node 22+, Xcode 26.0 minimum for Capacitor 8; Android Studio; npx cap add ios/android | Rust toolchain + Xcode + Android SDK/NDK; Rust build step in CI | Full RN toolchain + Metro; WebView is one component among many |
| Push | @capacitor/push-notifications 8.1.3 → iOS needs the Push Notifications capability/APNs, Android needs FCM (google-services.json) — https://capacitorjs.com/docs/apis/push-notifications | You write the native push yourself (plugin or Rust crate) | Community packages |
| Privacy cost | None forced, but FCM on Android means a Google dependency in the build | Same class of cost, more of it you write yourself | Same |
| Storage/runtime caveats | WKWebView is an "other app" for WebKit storage policy → 15% origin quota (vs 60% in Safari) | Same WebKit policy | Same |
| Store overhead | App Store + Play review per release, $99/yr + $25 one-off | same | same |
| When it wins | Need APNs push without install, store presence, badge/capability access | Need a Rust core (we do not; our core is a server) | Never, for us |
Capacitor is the only one I would pick: it changes nothing about the web code, has a maintained plugin for the two native capabilities we might want (push, status bar/safe-area), and its cost is a second release pipeline. Tauri's Rust host buys security/memory advantages that are irrelevant when the logic lives on the server, and RN-WebView is the worst of both. Sources: https://capacitorjs.com/docs/getting-started/environment-setup, https://v2.tauri.app/start/prerequisites/.
6. Asset pipeline
6.1 Format and compression
glTF 2.0 (GLB) with two Khronos extensions:
| Extension | Use | Evidence |
|---|---|---|
EXT_meshopt_compression | vertex/index compression, decoded at load | gltfpack: "-c … use meshoptimizer codecs to reduce the download size further"; three since r122 via GLTFLoader.setMeshoptDecoder, Babylon since 5.0 "without further setup" — https://raw.githubusercontent.com/zeux/meshoptimizer/master/gltf/README.md |
KHR_draco_mesh_compression | alternative, smaller, slower decode | Blender exporter exposes both (export_draco_mesh_compression_enable, export_meshopt_compression_enable) |
KHR_texture_basisu | KTX2 + Basis Universal textures | gltf-transform etc1s / uastc commands (https://gltf-transform.dev/cli); spec allows a PNG fallback or none — https://raw.githubusercontent.com/KhronosGroup/glTF/main/extensions/2.0/Khronos/KHR_texture_basisu/README.md |
Choose meshopt over Draco for our content: meshopt decode is "3-6 GB/s on modern desktop CPUs" with "typically around 2-4x" vertex compression vs already-quantized data (meshoptimizer README), it decodes on the main thread without a WASM worker round-trip for our sizes, and its bitstreams remain gzip-compressible. Draco wins only when the transfer size dominates at the cost of a WASM decode. [UNVERIFIED] — no head-to-head on our own assets yet; measure on the first real hull.
Textures: KTX2 with Basis Universal, ETC1S for colour/albedo/emissive, UASTC where quality matters (normal maps, UI-critical decals). ETC1S is "designed for very fast transcoding … low/medium quality but high compression", and transcodes to ASTC/ETC/PVRTC on device — https://raw.githubusercontent.com/BinomialLLC/basis_universal/master/README.md. One KTX2 file serves iOS (ASTC/PVRTC) and Android (ETC2/ASTC) with no per-platform asset branch.
Exact memory math (the reason to care), for a 2048×2048 texture:
| Format | bits/px | VRAM |
|---|---|---|
| RGBA8 (PNG/JPEG decoded) | 32 | 16.0 MB |
| ETC2 RGB | 4 | 2.0 MB |
| ASTC 4×4 RGBA | 8 | 4.0 MB |
| ASTC 6×6 RGBA | 3.56 | 1.8 MB |
A 2048² RGBA8 texture is 16 MB — four of those and a mid-range Android phone is out of budget. The same texture as ASTC 4×4 is 4 MB, as ETC2 RGB 2 MB. This, not tri count, is what kills mobile scenes.
6.2 Budgets for ~20 ships on a mid-range Android phone
Targets, not measurements — the first playable build must confirm them on device:
| Budget | Target | Rationale |
|---|---|---|
| Triangles on screen | ≤ 120k (20 ships × ~5k + background shell) | 5k tris is generous for a low-poly hull; mobile GPUs are vertex-throughput-fine at this level, fill rate is the constraint [INFERENCE] |
| Draw calls | ≤ 100, ideally ≤ 40 | Each ship 1–3 meshes (hull, engines, decals); instance repeated props. Draw-call CPU cost dominates long before tri count on mobile [INFERENCE] |
| Textures per ship | 1 albedo (+1 emissive for engine glow), 1024² albedo / 512² emissive | 1024² ASTC 4×4 = 1.0 MB, 512² = 0.25 MB; 20 ships ≈ 25 MB of texture memory |
| VRAM total (scene) | ≤ 200 MB | Leaves headroom for the browser compositor and the DOM layer [INFERENCE] |
| GLB per ship | ≤ 300 KB after meshopt + KTX2 | 20 ships = 6 MB; acceptable on first load with progressive loading [INFERENCE] |
| Materials | 1 PBR-lite material per ship, no normal maps in v1 | Saves a texture per ship and 4 bytes/vertex of tangents (export_tangents=False) |
| Shadows | 1 directional shadow map at 1024², or none | Shadow maps double geometry submission [INFERENCE] |
| Post-processing | none or a cheap bloom on a half-res target | EffectComposer passes cost full-screen reads [INFERENCE] |
| LOD | 2 levels (LOD0 full, LOD1 ~25%) | Enough for a "20 ships at varying distance" view |
Background space (stars, nebulae) should be shader-generated, not textured: one full-screen quad or a cubemap-gradient shader, zero asset bytes, and it inherits the "procedural rendering" visual direction.
7. Headless Blender as an asset pipeline
7.1 Current version
Blender 5.2.3 LTS, "Released October 22, 2026" — https://www.blender.org/download/. 5.3 is announced for "Expected November 10, 2026" (i.e. tomorrow), so pin 5.2 LTS for reproducibility.
7.2 Headless invocation
Both flags are documented in the Blender manual's command-line reference — https://docs.blender.org/manual/en/latest/advanced/command_line/arguments.html:
-b, --background— "Run in background (often used for UI-less rendering)."-P, --python— "Run the given Python script file."
So blender --background --factory-startup --python build_ship.py -- <args> is the supported shape. --factory-startup keeps user config out of the build; arguments after -- are read by the script from sys.argv.
7.3 glTF 2.0 export with Draco and KTX2 — confirmed via the API signature
bpy.ops.export_scene.gltf (https://docs.blender.org/api/current/bpy.ops.export_scene.html) exposes exactly what this pipeline needs:
| Parameter | Purpose |
|---|---|
export_format='GLB' | binary glTF 2.0 |
export_draco_mesh_compression_enable, export_draco_mesh_compression_level=6, export_draco_position_quantization=14 | Draco path |
export_meshopt_compression_enable, export_meshopt_extension='EXT_meshopt_compression' | meshopt path |
export_use_gltfpack, export_gltfpack_tc ("KTX2 Compression — Convert all textures to KTX2 with BasisU supercompression"), export_gltfpack_si (simplification ratio), export_gltfpack_vp/vt/vn (quantization) | gltfpack-integrated KTX2 + simplification |
export_apply=True (bake modifiers), export_normals=True, export_texcoords=True, export_tangents=False | control what reaches the GPU |
export_animations, export_skins, export_morph | off for static hulls |
The manual's glTF page documents the same two sections ("Draco Compression" — "Higher compression results in slower encoding and decoding"; "Meshopt Compression") — https://docs.blender.org/manual/en/latest/addons/scene_gltf2.html. The exporter lives in addons_core/io_scene_gltf2 (the API page's File: line confirms the addons_core location, i.e. bundled with Blender, not a separate install).
7.4 No GPU, no display?
- UI/render requirements are the strict part: "GPU 2 GB VRAM with OpenGL 4.3, Vulkan 1.3" and "OpenGL: Version 4.3 or newer" — https://www.blender.org/download/requirements/.
- In
--backgroundmode there is no UI, and glTF export is pure Python + data manipulation; the components that want a GPU are the viewport render engines (EEVEE, Workbench) and GPU-accelerated Cycles. A headless glTF build therefore needs no GPU, and neither do the Draco/meshopt/KTX2 encoding paths (they are CPU codecs). [INFERENCE from the documentation] — not executed, because Blender is absent from both servers and the task forbids installing. - The target dev box can run it two ways regardless:
koren@192.168.1.250is Arch (kernel 7.2.6-arch2-1, 12 cores, 31 GB RAM, glibc new enough for Blender 5.x's glibc 2.28 floor), hasDISPLAYunset but/dev/dri/{card1,renderD128}present, i.e. a render node exists so even an EGL/Vulkan headless context is available if a future step ever needs EEVEE. - Prod
koren@agnes.pukogames.comis Debian 13.7, 12 cores; fine for a CPU-only export job but it is the customer-facing host, so asset builds belong on the dev box or in CI, not there.
Verification recipe, to run on any Linux box once Blender 5.2.3 is on it (tarball link pattern from https://www.blender.org/download/):
bash
cd /tmp
curl -LO https://download.blender.org/release/Blender5.2/blender-5.2.3-linux-x64.tar.xz
tar -xf blender-5.2.3-linux-x64.tar.xz && cd blender-5.2.3-linux-x64
cat > /tmp/check.py <<'PY'
import sys, bpy
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.mesh.primitive_cube_add()
bpy.ops.export_scene.gltf(
filepath="/tmp/check.glb",
export_format="GLB",
export_draco_mesh_compression_enable=True,
export_meshopt_compression_enable=False,
export_apply=True,
)
print("EXPORTED with", sys.version)
PY
./blender --background --factory-startup --python /tmp/check.py
ls -l /tmp/check.glb
head -c 12 /tmp/check.glb | od -c | head -2 # glTF magicThen check the KTX2 half of the claim separately: re-run with export_use_gltfpack=True, export_gltfpack_tc=True on a textured object, which the exporter routes through gltfpack + BasisU.
7.5 Recommended pipeline shape
build_ship.py— procedural low-poly hulls from primitives + modifiers, seeded RNG so output is deterministic, export per-ship GLB withexport_apply=True(bake modifiers) and no tangents/normal maps.- Post-process outside Blender with
@gltf-transform/cli@4.5.1:gltf-transform optimize, thenmeshopt(ordraco) andetc1s(oruastc) for textures — deterministic, versioned inpackage.json, runs in CI without Blender. - Validate:
gltf-transform inspectprints triangle counts, texture sizes and extensions; assert the per-ship budgets from §6.2 fail the build. - Publish immutable, hash-named files to a CDN; the client manifest maps asset id → URL + LODs.
Blender stays a content generator, not a runtime dependency, and the compression step is reproducible without it.
Appendix — reproducing the measurements
Local machine: macOS 25.6.0 arm64, Node 22.22.0, npm 10.9.4, esbuild 0.28.2.
bash
mkdir -p /tmp/bench && cd /tmp/bench && npm init -y
npm i three@0.187.0 @babylonjs/core@9.35.0 @babylonjs/loaders@9.35.0 \
playcanvas@2.24.0 react@19.3.1 react-dom@19.3.1 \
@react-three/fiber@9.8.2 @react-three/drei@10.7.9 esbuild
# src/*.js: minimal scene per engine (box + light + camera + loop),
# "-all" variants add the engine's glTF/Draco/KTX2/meshopt loaders.
for f in src/*.js; do
npx esbuild "$f" --bundle --format=esm --minify --target=es2022 \
--outfile="out/$(basename $f .js).min.js" --log-level=error
done
for f in out/*.js; do
printf "%-26s %8s %8s %8s\n" "$(basename $f)" \
"$(wc -c <$f)" "$(gzip -9 -c $f|wc -c)" "$(brotli -q 11 -c $f|wc -c)"
doneBrowser support numbers were read from caniuse-lite@1.0.30001823 (installed in /tmp/cu), joining dist/unpacker/feature output with dist/unpacker/agents usage_global, and cross-checked against Fyrd/caniuse raw JSON notes_by_num and MDN BCD api/GPU.json.
Recommendation
Renderer: three@0.187.0 with WebGLRenderer (WebGL2). — Smallest full pipeline of the three (189 KB gzip vs 694 KB Babylon, 540 KB PlayCanvas, measured), MIT, and the one with a mature React renderer. 3D↔React: @react-three/fiber@9.8.2 + react@19.3.1 (+ @react-three/drei@10.7.9 only for orbit/controls). — Peer deps require react >=19 <19.4, satisfied; keeps the viewport in the same component model as the 80% DOM UI. UI: react@19.3.1, tailwindcss@4.3.5, vite@8.3.7, typescript@7.0.2. — One full-viewport canvas + DOM overlays; Tailwind keeps dense list/table UI CSS-only; Vite 8 is the current bundler for r3f and Tailwind's first-class plugin. Graphics API: WebGL2 now, three/webgpu behind a single createRenderer() seam later. — WebGPU is available on 100% of Android Chrome usage and 77% of iOS Safari usage, so it will be the right long-term path, but our scene does not need it and switching back is free while switching materials forward is not. PWA: yes, installable, no native wrapper. — iOS 26 installs any site as a web app; push works after install via APNs; storage is 60% of disk and ITP-exempt once installed (WebKit docs). Asset format: GLB + EXT_meshopt_compression + KHR_texture_basisu (ETC1S, UASTC where needed). — meshopt decodes at 3–6 GB/s with 2–4× compression and both three and Babylon read it natively. Asset generation: Blender 5.2.3 LTS headless (--background --factory-startup --python) → GLB → @gltf-transform/cli@4.5.1. — Export op exposes Draco/meshopt/KTX2-via-gltfpack; the box it runs on needs no GPU or display. If a wrapper is ever required: @capacitor/core@8.6.0. — Only wrapper that leaves the web code untouched; costs Xcode 26 + a second release pipeline.
Do not add: analytics SDKs, error-tracking SaaS, in-canvas UI, a physics engine, a game-networking library (a socket + server ticks is enough), or a second asset pipeline.
What would make me change my mind
- WebGPU feature we actually need (GPU particles, compute-driven starfield, WebXR) — flip the seam to
three/webgpu, keepforceWebGLfallback. - Custom shaders exceeding ~10 in number and still needing a rewrite for TSL — cheaper to migrate early than late; revisit before the shader library grows.
- Measured frame times on a mid-range Android above 16.7 ms at the §6.2 budgets — then cut draw calls and resolution before touching engines; if WebGL2 is genuinely the bottleneck, re-evaluate WebGPU-first.
- iOS WebGPU stuck below ~90% of iOS usage in a year — no reason to plan a WebGPU migration at all.
- A requirement for push without installation, home-screen badges, or App Store presence — Capacitor 8, accepting APNs/FCM and the 15% WKWebView storage quota.
- Offline writes becoming core gameplay (build queues that must complete while the app is closed, offline combat resolution) — Web background sync does not exist on iOS; either redesign so the server owns all timers (preferred) or go native.
- Blender's headless export failing on the actual server (needs
libX11/EGL at import time in some builds) — fall back to Blender in a container or to a Node-side procedural mesh generator, since the glTF-transform step is already independent of Blender. - Three's WebGPURenderer or r3f breaking on a release — both are fast-moving; if a version bump costs more than a day, pin and re-evaluate against Babylon (which has the strongest single-engine WebGPU story but 3.7× the bytes).
- Team composition: if the 3D work is handed to someone who wants shadows, PBR and particles out of the box and does not care about 500 KB, Babylon 9.x becomes the pragmatic pick.