SuperAPI AI Directory

blender-hardsurface

Design & creation

View repository

Updated 10-15 12:38

About this skill

Hard-surface spacecraft in Blender

Everything here was learned building a 162 m survey tender end to end. The techniques matter more than the ship: primitive assembly has a hard ceiling, and no amount of lighting or framing gets you past it.

Working code for all of it is in reference/: bmcp_client.py (socket), build_tender.py (the full ship — loft, recess, greeble, materials, lights), cinematic.py (texturing, planet, starfield, camera move, lens). Run cat build_tender.py cinematic.py | python3 bmcp_client.py code to rebuild the whole scene from scratch in about two seconds.

Connecting

BlenderMCP's addon opens a JSON socket on localhost:9876. The MCP tools load at session start, so if the server was registered mid-session they will not be in your tool list — talk to the socket directly instead. It is the same channel:

import json, socket
def send(cmd, params=None, timeout=900.0):
    s = socket.socket(); s.settimeout(timeout); s.connect(("localhost", 9876))
    s.sendall(json.dumps({"type": cmd, "params": params or {}}).encode())
    buf = b""
    while True:
        buf += s.recv(65536)
        try: return json.loads(buf.decode())
        except json.JSONDecodeError: continue

def run(code): return send("execute_code", {"code": code})

Commands: get_scene_info, get_object_info, execute_code, get_viewport_screenshot. execute_code is the one that matters — everything below is bpy sent through it.

The addon cannot start its server in background mode. Blender must be open with a GUI, sidebar (N) → BlenderMCP → Connect to Claude. The socket only exists while that toggle is on. If you install the addon while Blender is already running, that instance does not have it — restart Blender.

Modelling: loft, recess, hierarchy

Three techniques, in order of impact.

1. Loft cross-sections; never stack primitives. Define a profile whose width, height and chamfer vary along the length, sweep it through stations, and bridge into one continuous skin. An octagon with independent chamfer widths reads as rolled plate; a superellipse reads as a moulded shuttle. Sweeping the "squareness" lets a boxy stern become a faceted bow with no seam.

def oct_ring(w, h, cx, cz):
    a, b = w * 0.5, h * 0.5
    return [( a, b - cz), ( a - cx,  b), (-(a - cx),  b), (-a,  b - cz),
            (-a, -(b - cz)), (-(a - cx), -b), ( a - cx, -b), ( a, -(b - cz))]

def loft(name, sections, mat, cuts=0):   # sections: (y, w, h, cx, cz, dz)
    bm = bmesh.new()
    rings = [[bm.verts.new((x, y, z + dz)) for (x, z) in oct_ring(w, h, cx, cz)]
             for (y, w, h, cx, cz, dz) in sections]
    for A, B in zip(rings, rings[1:]):
        for i in range(8):
            j = (i + 1) % 8
            bm.faces.new((A[i], A[j], B[j], B[i]))
    bm.faces.new(list(reversed(rings[0]))); bm.faces.new(rings[-1])
    if cuts:
        bmesh.ops.subdivide_edges(bm, edges=list(bm.edges), cuts=cuts,
                                  use_grid_fill=True)
    ...

Make the profile stepped, not smoothly tapered — a parallel-sided midbody, a shoulder, a blunt bow. The steps are what the eye measures length against. A spear-point nose reads as a fighter; blunt reads as a working ship.

2. Cut detail into the skin. Panel bays, trenches, keel channels and hangars are bmesh.ops.inset_region on selected faces, then translated inward along the face normal. Boxes glued to a surface always look glued on.

sel = [f for f in bm.faces if pick(f)]
bmesh.ops.inset_region(bm, faces=sel, thickness=1.2, depth=0.0,
                       use_even_offset=True, use_boundary=True)
for f in sel:
    bmesh.ops.translate(bm, verts=list(f.verts), vec=f.normal.normalized() * -0.7)
    f.material_index = i_struct        # a different material in the recess

3. Detail hierarchy, including restraint. Three tiers: primary masses, medium panel bays, fine greebles at roughly 1/60 of the hull length. Greeble via inset_individual + nudge proud or sunk, so it is integrated geometry. Critically: leave clean plate. A first pass that greebled every face read as noise; zoning it and leaving the dorsal alone is what made it read as detail. Detail only registers against something undetailed.

Flat-shade the hull. Plate, not plastic.

Texturing (procedural — no image textures)

Two nodes do most of the work of making paint look built:

  • Per-plate tonal variation. TexVoronoi on object coords (scale ~0.16), RGBToBW, remap to 0.9–1.1, multiply into base colour. Real plate is welded from batches that never quite match, and that mismatch is most of the read.
  • Edge wear. NewGeometry → Pointiness remapped over ~0.49–0.56, mixed toward bare metal and driving Metallic up. Free on every convex corner, and exactly where paint goes in life. Cycles only.

Also useful: bleach on star-facing normals (dot(Normal, sun_dir)) and soot toward the stern (object-space Y), which is what turns a coloured solid into a used object.

Lighting the void

One hard key (SUN, angle ≈ 0.9°), a large weak area light for nebula/planetshine fill tinted toward the key's complement, and a cold rim so nothing dissolves into the background. Shadows are never black.

Put the camera on the key side. A hero angle shot from the shadow side is carried entirely by fill and reads washed out — this cost two wasted renders.

Stars: Voronoi, not thresholded noise. Noise cannot separate density from size — raise the frequency to shrink the points and you get thousands; widen the band to brighten them and you get falling snow. With Voronoi, cell scale sets how many, distance threshold sets how big, and the per-cell random value throws most away and varies brightness:

  • cells over the sphere ≈ 4π·scale², and a frame sees under 1% of it, so scale=30 puts about fifteen cells in shot. Use scale ≈ 110.
  • angular radius = threshold / scale; move the two together.

Rendering and the camera

Derive the camera from the scene bounding box, never by hand — hand-placed cameras end up inside the ship:

pts = [o.matrix_world @ Vector(c) for o in meshes for c in o.bound_box]
ctr = (lo + hi) / 2; rad = max((p - ctr).length for p in pts)
cam.location = ctr + direction * (rad / math.tan(cam.data.angle / 2) * 1.05)
cam.rotation_euler = (ctr - cam.location).to_track_quat("-Z", "Y").to_euler()

Compositor for the lens: Glare Fog Glow (halation) → Glare Streaks (anamorphic) → Lensdist dispersion (CA). Use AgX; do not touch renderer.toneMapping equivalents.

Draft at 64 samples / 1280×720 (~2 s), final at 96–400 / 1920×1080 (~4–8 s on an M-series GPU). Iteration is cheap — render constantly and look.

Traps that cost real time

  • A camera inside geometry costs 80× the render time. 166 s versus 2 s for the same scene, from near-field bounces. A sudden slow frame means check the camera before you touch sample counts.
  • Backdrop geometry must be camera-visible only. A 2600-unit emissive atmosphere shell became an area light every shading point importance-sampled: 145 s → 4.3 s by setting visible_diffuse/glossy/transmission/ volume_scatter/shadow = False. Always do this for planets and skyboxes.
  • o.scale = size then a Bevel modifier makes pillows. Bevel works in local space, so a 0.4 m chamfer on a unit cube is a 40% round-over that then gets stretched. bpy.ops.object.transform_apply(scale=True) first.
  • matrix_parent_inverse = parent.matrix_world.inverted() cancels the parent. That is the "keep transform" idiom. If you want a child positioned in the parent's space and inheriting its rotation, leave the parent inverse at identity. Symptom: rotating the parent does nothing.
  • A rotation about the wrong axis silently does nothing visible. A panel whose normal is +X does not change facing when rotated about X. Measure the world normal against the view vector rather than reasoning about it.
  • Map Range with To Min > To Max and clamp on collapses to zero. It clamps to an inverted interval. Invert with a SUBTRACT node instead.
  • A Mix node in RGBA mode may silently drop a branch. It carries several sockets named "Factor"; setting by name can hit an unused one. For combining world contributions use two Background nodes into an Add Shader — that is what it actually is.
  • The image preview downsamples. 4-px stars average away to nothing in a 1920×1080 preview. Crop 1:1 and count bright pixels numerically before concluding something did not render.

Blender 5.x API changes (4.x code fails on all of these)

  • Scene.node_tree is gone → Scene.compositing_node_group, and CompositorNodeComposite no longer exists; the group's output is the result.
  • The compositing group must contain a CompositorNodeRLayers. Feed it from a Group Input and nothing depends on the render, so Blender skips rendering entirely and writes a transparent black frame in 0.1 s. A suspiciously fast render is this bug.
  • Action.fcurves is gone (slotted actions). Set interpolation via context.preferences.edit.keyframe_new_interpolation_type before inserting keys.
  • Glare/Lensdist settings moved from properties onto sockets, and the enums take display names: "Fog Glow", "Streaks", "High" — not FOG_GLOW.
  • Legacy addons still install to ~/Library/Application Support/Blender/<ver>/scripts/addons/.

Exporting to this game

The game builds hulls procedurally (src/gfx/greeble.js, src/ship/hull.js) and ships no build-time assets — there is no GLTFLoader in the tree. So:

  • The idiomatic path is porting the loft/recess method into the greeble kit, not loading a mesh.
  • If you do export: bpy.ops.export_scene.gltf(export_format='GLB', use_selection=True, export_apply=True, export_yup=True) into public/models/. Procedural node materials do not survive — export flat base colours and let the game's dress() do bleach/soot/plate.
  • Model in metres; the game is 1 unit = 1 km, so scale by 0.001 on a fresh root (and never setScalar on anything the greeble kit built).
  • Join by material before exporting. One Blender object becomes one glTF node becomes one draw call — a loose export of this ship was 78 of them for 11 materials. bpy.ops.object.join() per material group first, so the mesh arrives as ~11 draws. survey.mjs reports draw calls; check it after adding anything.
  • KHR_materials_emissive_strength does survive the round trip, so nav lights, window panes and Choir emissives keep their authored intensity.