Skip to content

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 ​

FactMethod
npm versions/dateshttps://registry.npmjs.org/<pkg> (dist-tags + time) on 2026-11-09
Browser support + usage weightscaniuse-lite@1.0.30001823 (unpacked data/features/*.js + agents.usage_global), and Fyrd/caniuse raw feature JSON for the per-note text
Safari/iOS behaviourApple Safari release notes (.md variants) + webkit.org posts
Renderer bundle sizesesbuild 0.28.2, local builds, gzip -9 / brotli -q 11
Blenderblender.org download page + Blender Python API and manual pages
Server capabilityssh 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:

PackageVersionPublished
three0.187.02026-10-30
@react-three/fiber9.8.22026-10-14
@react-three/drei10.7.92026-09-25
babylonjs / @babylonjs/core9.35.02026-11-06
playcanvas2.24.02026-11-09
react19.3.12026-10-14
vite8.3.72026-11-09
typescript7.0.22026-07-08
tailwindcss4.3.52026-11-06
@capacitor/core8.6.02026-10-28
@tauri-apps/cli2.12.42026-10-21
react-native-webview14.0.12026-06-20
@gltf-transform/cli4.5.12026-09-28
gltfpack (npm)1.3.02026-09-25
@playcanvas/react0.11.72026-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).

Buildrawgzip -9brotli -q 11
three, minimal (WebGLRenderer)537,265133,182110,539
three, minimal, three/webgpu (WebGPURenderer)818,507221,704180,982
three, loading path only (GLTFLoader+Draco+KTX2+meshopt)498,362128,924107,885
three, minimal + loading path715,278189,184157,217
Babylon minimal (@babylonjs/core deep imports)1,618,195381,493294,151
Babylon, minimal + glTF/Draco/BasisU2,964,113694,429500,015
PlayCanvas, minimal (pc.Application)2,103,670539,859419,084
PlayCanvas, minimal + container loader2,103,801539,931419,237
React DOM only (control)223,56569,089—
React DOM + r3f + drei + three (one cube, OrbitControls)1,174,497322,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.0Babylon.js 9.35.0PlayCanvas 2.24.0
Release cadencemonthly (r187 2026-10-30, r186 2026-09-24 — https://github.com/mrdoob/three.js/releases)9.35.0 2026-11-062.24.0 2026-11-09 (near-daily patch/builds — https://github.com/playcanvas/engine/releases)
glTF 2.0GLTFLoader 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-processingexamples/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/)
WebGPUthree/webgpu entry, WebGPURenderer with an in-core WebGL2 fallback backendWebGPUEngine, since Babylon 5.0 (2022), WGSL-native shaders since 2024 — https://doc.babylonjs.com/setup/support/webGPUWebGPURenderer alongside WebGL2; active fixes each release (volume textures, compressed mip uploads, cubemap depth on WebGPU — v2.24.0 notes)
TypeScript sourceyes, .d.ts shipped; JSDoc-typed JS sourceyes, TS-first, richest typings of the threeTS source, generated declarations; the editor is the product, the engine is ESM lib
React integrationreact-three-fiber 9.8.2 (peer react >=19 <19.4, three >=0.156), drei 10.7.9no first-class React renderer; @babylonjs/react-native 2.0.8 is RN-only@playcanvas/react 0.11.7 (young, 0.x)
Editornone (code-first)sandbox + Node Material Editorfull hosted editor, asset pipeline, publishing
LicenseMITApache-2.0MIT

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_WEBGL are 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):

BrowserCurrent versionStatusShare of that browser's global usage that supports it
Chrome Android (and_chr)157y44.02% of 44.02% → 100%
iOS Safari (ios_saf)27.2y from 26.010.23% of 13.23% → 77.3%
Chrome desktop157+y23.61% of 24.96% → 94.6%
Safari desktop27.3a #2 #7 (caniuse flags partial)0% counted as y
Edge desktop156y5.01% of 5.05% → 99.2%
Firefox162a d #1 #80% (Windows-only, behind partial)
Samsung Internet31y1.91%
Firefox Android159n d #10%
Opera Mobile (op_mob)—y1.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:

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:

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:

  1. With three, choosing three/webgpu means committing material code to TSL/NodeMaterial, which is a different material authoring model than ShaderMaterial/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.
  2. 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 ​

RuleWhy
One canvas, position: fixed, full viewport, z-index: 0; UI in sibling DOM at z-index: 1. Never nest the canvas inside a scrolling containerAvoids 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 panelsA 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 listscss-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 surfacesBlur is a per-frame full-screen read on a tile GPU [INFERENCE]
touch-action: none on the canvas, overscroll-behavior: contain on scroll panelsStops 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-timeFill 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 crashMandated 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 ​

CapabilityState on iOSSource
Install any site to home screen as a web appYes. 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 ManifestPartial. 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 chromeYes, when the manifest sets display: standalone/fullscreen; otherwise it is a bookmark that opens in the default browserhttps://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/
Web PushOnly 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 neededsame 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 WorkersYes (ios_saf ~99.9% of usage)caniuse serviceworkers
Background SyncNo. ios_saf 27.x = n (Chrome/Edge/Samsung = y)caniuse background-sync
Periodic Background SyncAbsent from iOS entirely; the feature is not even in caniuse-lite's feature set (Chromium-origin API)caniuse-lite features dir
Storage quotaOrigin 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/
EvictionLRU per origin; navigator.storage.persist() granted by heuristic ("whether the website is opened as a Home Screen Web App"); navigator.storage.estimate() for usage/quotasame
7-day ITP wipe of script-writable storageExempt 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 cachingService worker + Cache API, full control. No background sync means writes queue client-side and flush on next launchsynthesis of the rows above

Consequences for the design, not just for the stack:

  1. 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).
  2. 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.
  3. 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.
  4. 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.0Tauri 2.12.4 (mobile)React Native WebView 14.0.1
One codebase preservedYes — it is your web app in a WKWebView/Android WebView, plus a plugin bridgeYes, 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 costNode 22+, Xcode 26.0 minimum for Capacitor 8; Android Studio; npx cap add ios/androidRust toolchain + Xcode + Android SDK/NDK; Rust build step in CIFull 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-notificationsYou write the native push yourself (plugin or Rust crate)Community packages
Privacy costNone forced, but FCM on Android means a Google dependency in the buildSame class of cost, more of it you write yourselfSame
Storage/runtime caveatsWKWebView is an "other app" for WebKit storage policy → 15% origin quota (vs 60% in Safari)Same WebKit policySame
Store overheadApp Store + Play review per release, $99/yr + $25 one-offsamesame
When it winsNeed APNs push without install, store presence, badge/capability accessNeed 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:

ExtensionUseEvidence
EXT_meshopt_compressionvertex/index compression, decoded at loadgltfpack: "-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_compressionalternative, smaller, slower decodeBlender exporter exposes both (export_draco_mesh_compression_enable, export_meshopt_compression_enable)
KHR_texture_basisuKTX2 + Basis Universal texturesgltf-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:

Formatbits/pxVRAM
RGBA8 (PNG/JPEG decoded)3216.0 MB
ETC2 RGB42.0 MB
ASTC 4×4 RGBA84.0 MB
ASTC 6×6 RGBA3.561.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:

BudgetTargetRationale
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 ≤ 40Each ship 1–3 meshes (hull, engines, decals); instance repeated props. Draw-call CPU cost dominates long before tri count on mobile [INFERENCE]
Textures per ship1 albedo (+1 emissive for engine glow), 1024² albedo / 512² emissive1024² ASTC 4×4 = 1.0 MB, 512² = 0.25 MB; 20 ships ≈ 25 MB of texture memory
VRAM total (scene)≤ 200 MBLeaves headroom for the browser compositor and the DOM layer [INFERENCE]
GLB per ship≤ 300 KB after meshopt + KTX220 ships = 6 MB; acceptable on first load with progressive loading [INFERENCE]
Materials1 PBR-lite material per ship, no normal maps in v1Saves a texture per ship and 4 bytes/vertex of tangents (export_tangents=False)
Shadows1 directional shadow map at 1024², or noneShadow maps double geometry submission [INFERENCE]
Post-processingnone or a cheap bloom on a half-res targetEffectComposer passes cost full-screen reads [INFERENCE]
LOD2 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:

ParameterPurpose
export_format='GLB'binary glTF 2.0
export_draco_mesh_compression_enable, export_draco_mesh_compression_level=6, export_draco_position_quantization=14Draco 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=Falsecontrol what reaches the GPU
export_animations, export_skins, export_morphoff 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 --background mode 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.250 is Arch (kernel 7.2.6-arch2-1, 12 cores, 31 GB RAM, glibc new enough for Blender 5.x's glibc 2.28 floor), has DISPLAY unset 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.com is 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 magic

Then 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.

  1. build_ship.py — procedural low-poly hulls from primitives + modifiers, seeded RNG so output is deterministic, export per-ship GLB with export_apply=True (bake modifiers) and no tangents/normal maps.
  2. Post-process outside Blender with @gltf-transform/cli@4.5.1: gltf-transform optimize, then meshopt (or draco) and etc1s (or uastc) for textures — deterministic, versioned in package.json, runs in CI without Blender.
  3. Validate: gltf-transform inspect prints triangle counts, texture sizes and extensions; assert the per-ship budgets from §6.2 fail the build.
  4. 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)"
done

Browser 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, keep forceWebGL fallback.
  • 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.

Galaxy design documentation. Nothing here is implemented yet.