Skip to content

Blender headless asset pipeline — end-to-end proof (research, 2026-11-09) ​

Answers the [UNVERIFIED] claim in docs/research/client-stack.md §7: that Galaxy can generate its 3D assets by running Blender headless, with no GPU and no display, and get GLB with meshopt and KTX2 out of it.

Verdict: yes, it works, and it is fast enough to regenerate the whole fleet on every commit. Everything below was executed on the dev box koren@192.168.1.250 on 2026-11-09. Numbers marked [measured] come from a command quoted in this document; things I could not execute are marked [UNVERIFIED].

One thing did not survive contact: the pipeline shape. @gltf-transform/cli works but its KTX2 path needs an external KTX-Software binary; the native gltfpack binary does meshopt + KTX2 in one step, with nothing else installed, and produces smaller files. Recommended shape, and the rest of this document proves each step:

Blender (procedural Python) -> ship.glb (plain)  ->  gltfpack -cc -tc  ->  ship.gp.glb
                                                      verify with @gltf-transform/cli inspect/validate

1. What runs, exactly ​

Blender is not installed on the host (Arch, which blender → nothing). It runs from a Docker image built from the official blender.org tarball, so the host stays clean and CI runs the exact same toolchain bytes.

FactValue
Imagegalaxy-blender:5.2.3, built here from Dockerfile in §9, base ubuntu:24.04
Blender5.2.3 LTS, build hash e13b728c6880, build date 2026-10-22 [measured]
Tarballblender-5.2.3-linux-x64.tar.xz, 368 MB, sha256 78486c018b4443efe5e75ab54c290ed8c15abafd249b862c2c9ce2530a918eec [measured]
Image size1.94 GB (Blender itself is 1.2 GB extracted; the OS + libs are ~740 MB)
Python3.13, bundled
Invocationblender --background --factory-startup --python <script> -- <args>

The exact working command (from /mnt/hdd/blender-proof):

bash
mkdir -p work/out
docker run --rm --network none --user "$(id -u):$(id -g)" -e HOME=/work \
  -v "$PWD/work:/work" galaxy-blender:5.2.3 \
  --background --factory-startup --python /work/build_ship.py -- --out-dir /work/out --seed 7

--user matters: without it the container writes root-owned files into the bind mount. -e HOME=/work keeps Blender from trying to write a config dir it cannot own. --network none is there to prove nothing is fetched at runtime — it is not required.

pacman -S blender (5.2.2 on the Arch extra repo) is a viable fallback and would skip Docker entirely, but it drags its dependency list onto the host — 60+ packages including a full X stack and mesa, from pacman -Si [INFERENCE on the resolved count] — and it is one patch version behind. Not used.

2. No GPU, no display ​

The thing that decides CI viability. Verified positively, not by inference:

$ docker run --rm --network none galaxy-blender:min2 --version
Blender 5.2.3 LTS

$ docker run --rm --network none --entrypoint bash galaxy-blender:5.2.3 -c 'ls /dev/dri; echo DISPLAY=${DISPLAY:-unset}'
ls: cannot access '/dev/dri': No such file or directory
DISPLAY=unset

The min2 image is the minimal-library build from §3, deleted after the test; the Dockerfile in §9 rebuilds it exactly.

No /dev/dri, no DISPLAY, no --gpus, no NVIDIA runtime, no X server. The image does contain mesa's userspace GLX/DRI stack, because apt resolves libgl1 through libglx0 to libglx-mesa0 (see §3) — but nothing ever opens a device or creates a context, so it is dead weight, not a driver requirement. The glTF export path is pure CPU data manipulation and completes normally under these conditions (§4).

CPU Cycles rendering works too, which means CI can produce previews on the same image:

$ docker run --rm --network none --user 1000:1000 -e HOME=/work \
    -v "$PWD/work:/work" galaxy-blender:min2 \
    --background --factory-startup --python /work/preview.py -- /work/final/ship-0007.glb /work/final/preview-min.png
RENDERED /work/final/preview-min.png meshes=1
real 0m3.406s

720×480, 48 samples, denoising off, no GPU. The render is what the ship actually looks like: pointed hull, two wing plates, dorsal fin, two nacelles with emissive engine caps.

preview.py — re-imports the shipped GLB, so the render also proves the file round-trips back into Blender:

python
"""Re-import a shipped GLB and render it with Cycles on CPU (no GPU, no display)."""

import math
import sys

import bpy
from mathutils import Vector

argv = sys.argv[sys.argv.index("--") + 1:]
src, out_png = argv[0], argv[1]

bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=src)

meshes = [o for o in bpy.context.scene.objects if o.type == 'MESH']
lo = Vector((1e9, 1e9, 1e9))
hi = Vector((-1e9, -1e9, -1e9))
for ob in meshes:
    for corner in ob.bound_box:
        world = ob.matrix_world @ Vector(corner)
        lo = Vector(map(min, lo, world))
        hi = Vector(map(max, hi, world))
center = (lo + hi) / 2
radius = max((hi - lo).length / 2, 0.01)

cam_data = bpy.data.cameras.new("cam")
cam = bpy.data.objects.new("cam", cam_data)
bpy.context.collection.objects.link(cam)
cam.location = center + Vector((radius * 1.6, -radius * 2.0, radius * 1.1))
direction = center - cam.location
cam.rotation_euler = direction.to_track_quat('-Z', 'Y').to_euler()
cam_data.lens = 50
bpy.context.scene.camera = cam

sun_data = bpy.data.lights.new("sun", 'SUN')
sun_data.energy = 4.0
sun = bpy.data.objects.new("sun", sun_data)
bpy.context.collection.objects.link(sun)
sun.rotation_euler = (math.radians(55), math.radians(10), math.radians(35))

world = bpy.data.worlds.new("w")
world.use_nodes = True
bg = world.node_tree.nodes["Background"]
bg.inputs[0].default_value = (0.02, 0.03, 0.05, 1.0)
bg.inputs[1].default_value = 1.0
bpy.context.scene.world = world

scene = bpy.context.scene
scene.render.engine = 'CYCLES'
scene.cycles.device = 'CPU'
scene.cycles.samples = 48
scene.cycles.use_denoising = False
scene.render.resolution_x = 720
scene.render.resolution_y = 480
scene.render.filepath = out_png
scene.render.image_settings.file_format = 'PNG'

bpy.ops.render.render(write_still=True)
print("RENDERED", out_png, "meshes=%d" % len(meshes))

3. Required system libraries ​

Not optional and not obvious: the binary links X client and GL loader libraries at load time, even in --background where they are never used. A bare ubuntu:24.04 fails immediately:

$ docker run --rm galaxy-blender:5.2.3 --version          # before adding libs
blender: error while loading shared libraries: libGL.so.1: cannot open shared object file

The binary hard-links 8 packages; everything else it needs ships in its own lib/ directory. apt resolves those 8 to 47 installed packages, because libgl1 → libglx0 → libglx-mesa0 drags mesa's userspace GL stack along (libgl1-mesa-dri, mesa-libgallium, libllvm20, libvulkan1).

bash
libgl1 libgomp1 libxrender1 libxfixes3 libxi6 libxkbcommon0 libsm6 libice6

Verified by building galaxy-blender:min2 with exactly those: --version OK, a ship builds, ldd ... | grep -c "not found" → 0, image 1.94 GB. Dropping the six X libs leaves 6 unresolved (libXrender.so.1, libXfixes.so.3, libXi.so.6, libxkbcommon.so.0, libSM.so.6, libICE.so.6) and nothing runs.

This is the risk client-stack.md §7.4 flagged; it is real, and it is 8 apt packages to solve, with no driver and no server. There is no clean way to avoid mesa here: the binary needs libGL.so.1 at load time, and only libgl1 provides it. It costs ~20 MB of image over an Ubuntu base that has no GL at all.

4. The generator ​

build_ship.py, verbatim, 222 lines. Deterministic, parametric, no hand-modelled mesh: hull from swept cross-section rings, nacelles and wings and fins from bmesh primitives, one generated 128×128 panel texture, two materials (hull, emissive engine).

python
"""Procedural low-poly ship generator for Galaxy.

Deterministic: same seed + same args -> identical geometry.
Runs inside headless Blender:

    blender --background --factory-startup --python build_ship.py -- \
        --out-dir ./out --count 1 --seed 7 --meshopt
"""

import argparse
import math
import os
import random
import sys
import time

import bmesh
import bpy
from mathutils import Matrix

argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []

ap = argparse.ArgumentParser()
ap.add_argument("--out-dir", default="/work/out")
ap.add_argument("--count", type=int, default=1)
ap.add_argument("--seed", type=int, default=7)
ap.add_argument("--length", type=float, default=8.0)
ap.add_argument("--beam", type=float, default=1.6)
ap.add_argument("--hull-sides", type=int, default=8)
ap.add_argument("--nacelles", type=int, default=2)
ap.add_argument("--fins", type=int, default=3)
ap.add_argument("--meshopt", action="store_true")
args = ap.parse_args(argv)


def new_mesh_object(name, bm):
    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    me = bpy.data.meshes.new(name)
    bm.to_mesh(me)
    bm.free()
    ob = bpy.data.objects.new(name, me)
    bpy.context.collection.objects.link(ob)
    return ob


def add_hull(length, beam, sides):
    """Cross-section rings swept along +X, radius from a hand-authored profile."""
    profile = [(0.00, 0.14), (0.10, 0.58), (0.30, 0.96), (0.52, 1.00),
               (0.74, 0.84), (0.90, 0.58), (1.00, 0.34)]
    bm = bmesh.new()
    rings = []
    for t, s in profile:
        ring = [bm.verts.new((t * length,
                              math.cos(2.0 * math.pi * i / sides) * beam * 0.5 * s,
                              math.sin(2.0 * math.pi * i / sides) * beam * 0.5 * s * 0.7))
                for i in range(sides)]
        rings.append(ring)
    for lo, hi in zip(rings, rings[1:]):
        for i in range(sides):
            j = (i + 1) % sides
            bm.faces.new((lo[i], lo[j], hi[j], hi[i]))
    bm.faces.new(tuple(reversed(rings[0])))
    bm.faces.new(tuple(rings[-1]))
    return new_mesh_object("hull", bm)


def add_nacelle(length, radius, span_y, side, rng):
    """Tapered cylinder along +X, sitting on the wing tip."""
    depth = length * (0.52 + 0.06 * rng.random())
    mat = Matrix.Translation((length * 0.62, span_y * side, -0.05)) @ \
        Matrix.Rotation(math.radians(90.0), 4, 'Y')
    bm = bmesh.new()
    bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=8,
                          radius1=radius, radius2=radius * 0.72, depth=depth, matrix=mat)
    return new_mesh_object("nacelle", bm)


def add_plate(name, root_x, span, chord, thickness, axis, sweep):
    """Thin swept plate used for wings (axis 'y') and fins (axis 'z')."""
    bm = bmesh.new()
    bmesh.ops.create_cube(bm, size=1.0)
    for v in bm.verts:
        v.co.x *= chord
        v.co.y *= span if axis == 'y' else thickness
        v.co.z *= thickness if axis == 'y' else span
    for v in bm.verts:
        v.co.x -= sweep * abs(v.co.y if axis == 'y' else v.co.z)
    for v in bm.verts:
        v.co.x += root_x
        if axis == 'z':
            v.co.z += span * 0.5 + thickness
    return new_mesh_object(name, bm)


def make_hull_material():
    img = bpy.data.images.new("hull_panels", 128, 128, alpha=False)
    px = [0.0] * (128 * 128 * 4)
    for y in range(128):
        for x in range(128):
            cell = ((x // 16) + (y // 16)) % 2
            noise = 0.02 * (((x * 7 + y * 13) % 5) - 2)
            v = 0.42 + 0.10 * cell + noise
            i = (y * 128 + x) * 4
            px[i] = v * 0.85
            px[i + 1] = v * 0.92
            px[i + 2] = v
            px[i + 3] = 1.0
    img.pixels = px
    img.pack()

    mat = bpy.data.materials.new("hull_mat")
    mat.use_nodes = True
    nt = mat.node_tree
    bsdf = next(n for n in nt.nodes if n.type == 'BSDF_PRINCIPLED')
    tex = nt.nodes.new("ShaderNodeTexImage")
    tex.image = img
    nt.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
    bsdf.inputs["Metallic"].default_value = 0.35
    bsdf.inputs["Roughness"].default_value = 0.55
    return mat


def make_engine_material():
    mat = bpy.data.materials.new("engine_mat")
    mat.use_nodes = True
    bsdf = next(n for n in mat.node_tree.nodes if n.type == 'BSDF_PRINCIPLED')
    bsdf.inputs["Base Color"].default_value = (0.02, 0.05, 0.08, 1.0)
    if "Emission Color" in bsdf.inputs:
        bsdf.inputs["Emission Color"].default_value = (0.25, 0.75, 1.0, 1.0)
        bsdf.inputs["Emission Strength"].default_value = 4.0
    return mat


def export(path, meshopt):
    kwargs = {
        "filepath": path,
        "export_format": "GLB",
        "export_apply": True,
        "export_yup": True,
        "export_tangents": False,
        "export_animations": False,
        "export_skins": False,
        "export_morph": False,
        "export_image_format": "AUTO",
    }
    if meshopt:
        kwargs["export_meshopt_compression_enable"] = True
        kwargs["export_meshopt_extension"] = "EXT_meshopt_compression"
    supported = set(bpy.ops.export_scene.gltf.get_rna_type().properties.keys())
    skipped = sorted(k for k in kwargs if k != "filepath" and k not in supported)
    bpy.ops.export_scene.gltf(**{k: v for k, v in kwargs.items()
                                 if k == "filepath" or k in supported})
    return skipped


def build_ship(seed, path):
    rng = random.Random(seed)
    bpy.ops.wm.read_factory_settings(use_empty=True)
    hull_mat = make_hull_material()
    engine_mat = make_engine_material()

    parts = [add_hull(args.length, args.beam, args.hull_sides)]
    parts[0].data.materials.append(hull_mat)

    span_y = args.beam * 0.9
    for side in (1, -1):
        wing = add_plate("wing", args.length * 0.42, span_y, args.length * 0.30,
                         args.beam * 0.06, 'y', 0.55)
        wing.data.materials.append(hull_mat)
        parts.append(wing)

    if args.fins >= 3:
        fin = add_plate("fin", args.length * 0.55, args.beam * 0.75, args.length * 0.22,
                        args.beam * 0.05, 'z', 0.45)
        fin.data.materials.append(hull_mat)
        parts.append(fin)

    for n in range(args.nacelles):
        side = 1 if n % 2 == 0 else -1
        nac = add_nacelle(args.length, args.beam * 0.28, span_y * 0.95, side, rng)
        nac.data.materials.append(hull_mat)
        parts.append(nac)

        bm = bmesh.new()
        glow_mat = Matrix.Translation((args.length * 0.92, span_y * 0.95 * side, -0.05)) @ \
            Matrix.Rotation(math.radians(90.0), 4, 'Y')
        bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=8,
                              radius1=args.beam * 0.10, radius2=args.beam * 0.10,
                              depth=args.length * 0.04, matrix=glow_mat)
        glow = new_mesh_object("engine_glow", bm)
        glow.data.materials.append(engine_mat)
        parts.append(glow)

    for ob in bpy.context.scene.objects:
        ob.select_set(True)
    bpy.context.view_layer.objects.active = parts[0]
    bpy.ops.object.join()
    ship = bpy.context.view_layer.objects.active
    ship.name = "ship_seed_%d" % seed

    bpy.ops.object.mode_set(mode='EDIT')
    bpy.ops.mesh.select_all(action='SELECT')
    bpy.ops.uv.smart_project(angle_limit=1.15, island_margin=0.02)
    bpy.ops.object.mode_set(mode='OBJECT')
    bpy.ops.object.shade_flat()

    ship.data.calc_loop_triangles()
    skipped = export(path, args.meshopt)
    size = os.path.getsize(path)
    print("SHIP seed=%d verts=%d tris=%d bytes=%d unsupported_args=%s"
          % (seed, len(ship.data.vertices), len(ship.data.loop_triangles), size,
             ",".join(skipped) if skipped else "none"))


os.makedirs(args.out_dir, exist_ok=True)
t_all = time.perf_counter()
for i in range(args.count):
    seed = args.seed + i
    t0 = time.perf_counter()
    build_ship(seed, os.path.join(args.out_dir, "ship-%04d.glb" % seed))
    print("SHIP seed=%d ms=%.0f" % (seed, (time.perf_counter() - t0) * 1000.0))
print("FLEET count=%d total_ms=%.0f per_ship_ms=%.0f"
      % (args.count, (time.perf_counter() - t_all) * 1000.0,
         (time.perf_counter() - t_all) * 1000.0 / args.count))

Output for the reference ship (seed 7), plain and with Blender-side meshopt:

SHIP seed=7 verts=144 tris=256 bytes=21176 unsupported_args=none
SHIP seed=7 verts=144 tris=256 bytes=13932 unsupported_args=none   # --meshopt

The parameters are real, not decorative:

ArgsvertstrisGLB (meshopt)
defaults (--hull-sides 8 --nacelles 2 --fins 3)14425613,932 B
--hull-sides 1620036816,376 B
--hull-sides 24 --beam 2.4 --length 1225648018,836 B
--nacelles 4 --fins 020035616,920 B
--nacelles 0 --fins 0721328,392 B

Determinism, verified by hash — same seed twice, separate processes:

2929ffc03799860a95a113b6e3f2d2a06b9165046976224c2b5e191a08daf741  det-a/ship-0042.glb
2929ffc03799860a95a113b6e3f2d2a06b9165046976224c2b5e191a08daf741  det-b/ship-0042.glb

Tri counts are far under the ~5k/ship budget in client-stack.md §6.2 — there is room to add detail; 256 tris is a placeholder hull, not a production one.

5. Post-process: compression and KTX2 ​

Four variants of the same ship, all measured:

StageCommandBytesvs plain
Blender, plain GLBexport_scene.gltf(export_format='GLB')21,176—
Blender meshopt+ export_meshopt_compression_enable=True13,932−34%
gltf-transformoptimize --compress meshopt --texture-compress ktx29,984−53%
gltfpack (native)-cc -tc8,800−58%

5.1 Blender's own meshopt is real ​

The exporter uses its bundled bridge, not a Python reimplementation:

INFO Draco is available, use library at /opt/blender-5.2.3-linux-x64/lib/libbf_intern_draco_bridge.so
INFO MeshOptimizer is available, use library at /opt/blender-5.2.3-linux-x64/lib/libbf_intern_meshopt_bridge.so

and the extension lands in the file as required, so a client that cannot decode it must refuse the asset rather than silently render nothing:

extensionsUsed     EXT_meshopt_compression, KHR_materials_emissive_strength
extensionsRequired EXT_meshopt_compression

export_meshopt_compression_level does not exist in 5.2.3 — only _enable and _extension. Passing it is a hard failure, so the script filters its kwargs against the operator's RNA properties before calling:

TypeError: Converting py args to operator properties:: keyword
"export_meshopt_compression_level" unrecognized

5.2 @gltf-transform/cli works; its KTX2 path needs a binary ​

inspect and validate run with nothing but npm. KTX2 does not:

$ npx gltf-transform etc1s ship.glb ship-ktx2.glb
error: Command failed: command -v ktx 2>/dev/null && { echo >&1 ktx; exit 0; }

It needs KTX-Software's ktx CLI on PATH (release v4.4.2, KTX-Software-4.4.2-Linux-x86_64.tar.bz2, tested). With it, ETC1S conversion succeeds:

info: ship-0007.glb (21.18 KB) → ship-ktx2.glb (20.93 KB)

Barely smaller — a 128² texture is too small for the container overhead to pay off — but the point of KTX2 is VRAM, and that is where it wins:

PNGKTX2 ETC1S
file2.09 KB1.86–1.93 KB
GPU memory (inspect gpuSize)87.38 KB10.92 KB

optimize --compress meshopt --texture-compress ktx2 does the whole thing in ~0.5 s: 21,176 B → 9,984 B, with KHR_mesh_quantization, KHR_texture_basisu and EXT_meshopt_compression all required.

5.3 The npm gltfpack cannot do KTX2, the native one can ​

$ npx gltfpack -i ship.glb -o out.glb -tc
Error: gltfpack was built without BasisU support, texture compression is not available
Note: node.js builds do not support BasisU due to lack of platform features; download a native build

Downloading gltfpack-ubuntu.zip from meshoptimizer v1.3 (1.9 MB zip, 4.1 MB static binary, no dependencies) gives the full thing — meshopt and BasisU KTX2 in one call, and the smallest output of all four variants:

$ ./gltfpack -i ship-0007.glb -o ship-0007-gp.glb -cc -tc
8800 bytes

Output extensions: KHR_mesh_quantization, EXT_meshopt_compression, KHR_texture_basisu, KHR_texture_transform, KHR_materials_emissive_strength. Attributes are quantized (POSITION:u16, NORMAL:i8_norm, TEXCOORD_0:u16_norm), which three handles natively.

Determinism holds through this step too (two runs, same sha256 b5375904…1ffef).

5.4 Blender can call gltfpack itself — if you point it at a binary ​

export_use_gltfpack and export_gltfpack_tc exist in 5.2.3, but out of the box the export dies with PermissionError: [Errno 13] Permission denied: '' because the addon runs:

python
gltfpack_binary_file_path = bpy.context.preferences.addons['io_scene_gltf2'].preferences.gltfpack_path_ui

— an empty string by default, and the gltfpack panel in the UI is hidden unless that preference is set. Set it to the native binary and the same export succeeds (GLTFPACK_INTERNAL_OK). Simplest to skip: run gltfpack as its own step (§9), which also keeps Blender out of the compression policy.

6. Does the GLB actually load? ​

Four independent readers. (a)–(c) run on the 8,800-byte gltfpack output except where noted; (d) round-trips the plain Blender export.

a) gltf-transform inspect — 2 primitives, 256 triangles, 472 vertices, u16 indices, 2 materials, one 128×128 ETC1S texture at 10.92 KB GPU.

b) gltf-transform validate — info: No errors found. Two UNSUPPORTED_EXTENSION warnings (EXT_meshopt_compression, KHR_texture_basisu) are the Khronos validator's own gap, not the file's — it does not know those extensions. One UNUSED_OBJECT on /images/0 is cosmetic.

c) three.js 0.187.0, headless under node 26.8.2 — the renderer we picked, parsing the meshopt GLB with its real GLTFLoader + MeshoptDecoder, no shims for the geometry path:

json
{
  "file": "ship-0007.glb",
  "bytes": 13932,
  "meshes": 2,
  "triangles": 256,
  "materials": ["engine_mat", "hull_mat"],
  "bboxSize": [8, 1.84, 3.632],
  "assetExtensionsUsed": ["EXT_meshopt_compression", "KHR_materials_emissive_strength"],
  "assetExtensionsRequired": ["EXT_meshopt_compression"]
}

256 triangles agrees with Blender's own count, both materials resolve, and the bounding box matches the parametric hull (8 long, 1.84 tall, 3.63 wide including wings).

The gltfpack variant parses too (same counts, plus KHR_mesh_quantization), with two caveats that are about node, not about the file:

  • three refuses KHR_texture_basisu without KTX2Loader.setKTX2Loader() — Error: THREE.GLTFLoader: setKTX2Loader must be called before loading KTX2 textures. That is the standard browser setup, with the transcoder from three/examples/jsm/libs/basis/.
  • the texture upload cannot complete under node at all (no createImageBitmap, no GL context), so the material's map came back null while its geometry and base parameters loaded. The KTX2 payload itself is valid: the glTF SDK reads it as ETC1S, 128×128, 10.92 KB GPU.

So the Node test proves the asset graph, the geometry and the extension declarations; a real GL context is only needed for the last step, which is the same work the browser does anyway.

d) Blender re-import + CPU Cycles render — the strongest one: the exported GLB goes back into Blender and renders (§2). Round-trip clean.

7. Timing ​

Measured on the dev box, 12 cores, idle; "in-process" excludes Blender start-up.

WhatTime
Blender --version0.08 s
One ship, whole process (start + build + export)0.59 s
One ship, in-process (build + export)140 ms
20 ships, one process1.42 s (48 ms/ship amortized)
20 ships, one container (warm)3.02 s
Container start, warm0.22 s
Container start, first run after build (cold page-in)18–49 s, one-off
gltfpack -cc -tc, 20 ships4.65 s (232 ms/ship)
gltf-transform optimize, one ship~0.5 s
inspect on one ship~0.5 s
Cycles CPU preview, 720×480 / 48 spp2.4 s (host), 3.4 s (container)

End to end: ~6 s for a 20-ship fleet, ~0.3 s per ship. Regenerating the whole fleet on every commit is not a question of whether we can afford it; gltfpack's BasisU encoding is the only non-trivial cost and even it is 232 ms/ship.

Caveat: these ships carry a 128² texture. KTX2 encode cost scales with texture area, so 1024² albedo maps will cost more per ship — [UNVERIFIED], needs re-measuring on a real hull. Even at 10× the encode cost a 20-ship fleet stays under a minute.

Note the container numbers were taken while the box was building another image; in-process single-ship times ranged 140 ms (idle) to 771 ms (loaded). The idle figures are the honest ones for CI.

8. Corrections to client-stack.md ​

  • §7.4 "No GPU, no display?" — confirmed, not inferred: no /dev/dri, no DISPLAY, --network none, and a CPU Cycles render succeeds. The caveat about X/EGL libraries at import time is real: 6 X client libs plus libGL.so.1 are mandatory (§3).
  • §7.3 export table — export_meshopt_compression_level does not exist; only export_meshopt_compression_enable and export_meshopt_extension. The gltfpack parameters exist but are inert unless the addon preference gltfpack_path_ui points at a native gltfpack binary, and the UI hides them otherwise.
  • §7.5 pipeline shape — "Blender → @gltf-transform/cli for meshopt and etc1s" is workable but needs an extra KTX-Software install. Native gltfpack -cc -tc does both steps, needs one dependency-free binary, and wins on size (8.8 KB vs 10.0 KB on the reference ship). gltf-transform is still the right tool for inspect/validate as a CI gate.
  • §6.1 "meshopt vs Draco — [UNVERIFIED], measure on the first real hull" — still open on our content, but the meshopt path is now proven to work end to end at both ends (Blender encode, three decode).
  • §7.1 — Blender 5.2.3 LTS is exactly what blender.org serves for Blender5.2/blender-5.2.3-linux-x64.tar.xz; pacman carries 5.2.2.

9. Reproduce ​

Paste-able, from scratch, on any Linux box with Docker. No GPU, no display, no root on the host.

bash
export GP=/tmp/galaxy-blender && mkdir -p "$GP" && cd "$GP"

# 1. Inputs: Blender 5.2.3 LTS + the native gltfpack (meshopt + BasisU KTX2)
curl -LO https://download.blender.org/release/Blender5.2/blender-5.2.3-linux-x64.tar.xz
curl -L -o gltfpack.zip https://github.com/zeux/meshoptimizer/releases/download/v1.3/gltfpack-ubuntu.zip
unzip -o gltfpack.zip && chmod +x gltfpack

cat > Dockerfile <<'EOF'
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
# The eight packages Blender 5.2.3 links at load time. No GPU, no X server needed.
RUN apt-get update && apt-get install -y --no-install-recommends \
      libgl1 libgomp1 libxrender1 libxfixes3 libxi6 libxkbcommon0 libsm6 libice6 xz-utils \
    && rm -rf /var/lib/apt/lists/*
COPY blender-5.2.3-linux-x64.tar.xz /opt/
RUN tar -xf /opt/blender-5.2.3-linux-x64.tar.xz -C /opt && rm /opt/blender-5.2.3-linux-x64.tar.xz
ENTRYPOINT ["/opt/blender-5.2.3-linux-x64/blender"]
EOF

docker build -t galaxy-blender:5.2.3 .

# 2. The generator (paste build_ship.py from §4 here)
cat > build_ship.py <<'PY'
# ... build_ship.py from this document ...
PY

# 3. Build one ship, no GPU, no display, no network
mkdir -p out
docker run --rm --network none --user "$(id -u):$(id -g)" -e HOME=/work \
  -v "$GP:/work" galaxy-blender:5.2.3 \
  --background --factory-startup --python /work/build_ship.py -- --out-dir /work/out --seed 7
# -> SHIP seed=7 verts=144 tris=256 bytes=21176 unsupported_args=none

# 4. Whole fleet in one process
docker run --rm --network none --user "$(id -u):$(id -g)" -e HOME=/work \
  -v "$GP:/work" galaxy-blender:5.2.3 \
  --background --factory-startup --python /work/build_ship.py -- \
  --out-dir /work/out --count 20 --seed 100 --meshopt

# 5. meshopt + KTX2 in one pass
./gltfpack -i out/ship-0007.glb -o out/ship-0007-gp.glb -cc -tc

# 6. Verify it is a valid, loadable glTF
npx --yes @gltf-transform/cli@4.5.1 validate out/ship-0007-gp.glb    # info: No errors found.
npx --yes @gltf-transform/cli@4.5.1 inspect  out/ship-0007-gp.glb

The apt line in step 1 is the 8-package set from §3, verified as galaxy-blender:min2 (1.94 GB, 0 unresolved libraries, builds ships). Most measurements in this document were taken on a broader image that also carried freetype/tiff/webp and libegl1; identical behaviour, 1.96 GB instead of 1.94 GB.

Optional CPU render, no GPU — paste preview.py from §2 into $GP/preview.py first:

bash
docker run --rm --network none --user "$(id -u):$(id -g)" -e HOME=/work \
  -v "$GP:/work" galaxy-blender:5.2.3 \
  --background --factory-startup --python /work/preview.py -- \
  /work/out/ship-0007.glb /work/out/preview.png

Variant using @gltf-transform instead of gltfpack — needs KTX-Software's ktx on PATH:

bash
curl -L -o ktx.tar.bz2 https://github.com/KhronosGroup/KTX-Software/releases/download/v4.4.2/KTX-Software-4.4.2-Linux-x86_64.tar.bz2
tar -xjf ktx.tar.bz2 && export PATH="$PWD/KTX-Software-4.4.2-Linux-x86_64/bin:$PATH"
npx --yes @gltf-transform/cli@4.5.1 optimize out/ship-0007.glb out/ship-opt.glb \
  --compress meshopt --texture-compress ktx2

Two host quirks hit on this box, worth knowing:

  • Extracting the tarball onto an NTFS (fuseblk) mount loses the executable bit; chmod +x blender-5.2.3-linux-x64/blender fixes it. Irrelevant inside the Docker image.
  • First docker run of the 1.9 GB image costs 18–49 s of page-in; steady state is 0.22 s.

10. What was left behind ​

The host was not modified (no pacman install, no files outside /mnt/hdd and /tmp), the running home-lab containers were not touched, and prod (agnes.pukogames.com) was never contacted. Scratch is removed: the 368 MB tarball, its 1.2 GB extraction, all /tmp working trees and every generated GLB are deleted, along with the two throwaway images used to isolate the minimal library set.

What remains on the dev box:

  • galaxy-blender:5.2.3 (1.94 GB) — the working image, kept so a rebuild is one docker run away.
  • /mnt/hdd/blender-proof (414 KB) — Dockerfile (working set), Dockerfile.min2 (the 8-package set from §3), Dockerfile.minimal (the failed libGL-only attempt). No binaries, no assets.

What would make me change my mind ​

  • KTX2 encode cost on real 1024² albedo maps exceeding ~2 s/ship — then cache textures and re-encode only when the source changes, rather than every build.
  • A hull that needs more than the primitive vocabulary here (boolean cutouts, bevel+solidify stacks that survive export_apply, hard-surface detail) — Blender's modifier stack and Python API are the reason to stay; a Node-side mesh generator would lose that quickly.
  • Docker unavailable in CI — the tarball runs bare, it only needs the 8 libraries from §3; the fallback is packaging them into the runner image rather than a Node-side generator, since gltf-transform/gltfpack are already independent of Blender.

Galaxy design documentation. Nothing here is implemented yet.