Signature 360
A working eight-room residence tour, rendered in Blender and delivered as one HTML file with no 3D library. This skill is the pipeline, including the mistakes, because the mistakes cost more than the code did.
Drawn from a shipped client project. The project itself is private, so the room names, coordinates and figures below are illustrative; the settings, the measured costs and the failure modes are real.
What the pipeline actually is
.blend scene
| persistent headless Blender server on a dedicated port
| detached python drivers, one per render generation
v
pNN-<room>.jpg 4096x2048 equirectangular, Cycles
pNN-<room>-m.jpg 2048x1024 mobile variant, made at publish time
| publisher rewrites ONE string in the viewer
v
tour.html dependency-free WebGL, hotspots, minimap, view cone
Four ideas carry the whole thing: a Blender that stays alive between calls, quality tiers so you never wait for a look-dev render, generation prefixes so publishing and rollback are one string change, and a viewer that aims its hotspots at doorways rather than at cameras.
The three laws
These are the expensive ones. Everything else you can rediscover cheaply.
1. Trust the output file, never the client
"Render stalled" is almost always false. Blender finishes the render and
writes the file, while the python client times out and reports
[Errno 32] Broken pipe immediately after a Saved: line. One real log shows
the same room rendered three times, each render succeeding, each reported as a
failure, each triggering another restart. Roughly 15 GPU-minutes wasted in a
single excerpt, and hours across the project.
The cause is architectural: the client's receive loop breaks on timeout and
closes the socket while the server is still inside exec, so the server's
sendall hits a dead peer. Do not try to fix it in the socket layer. Poll the
output file's modification time and size instead.
Encode it as a resume rule so a killed batch never redoes finished work:
p = os.path.join(OUT, 'p32-%s.jpg' % room)
if os.path.exists(p) and os.path.getsize(p) > 400000:
continue # a 4096x2048 q92 JPEG is 380 to 810 KB
2. When a render looks cheap, it is almost never the materials
The single most reusable diagnosis in the archive:
Resolution deficit, not material flatness, was the primary visual quality issue. A 2048-wide panorama shows only 427 real pixels at 75 degree FOV on a 1440px display, a 3.4x upscale.
Everyone reads that blur as "the materials look flat" and starts rewriting shaders. The fix is pixels. Render at 4096x2048 minimum, 6144x3072 if you want pixel-native display on a large screen.
When resolution is not the problem, a scored comparison against a good reference render put the remaining gap here, and it generalises well:
| Missing element | Points |
|---|---|
| Furniture geometry (cushion sag, pillow detail, real chaise not a flat box) | 2.0 |
| Styling and props (a glass, a throw, a book, rather than an empty surface) | 1.0 |
| Lighting design (a four-layer scheme rather than downlights only) | 1.0 |
| Curtains with real folds and daylight through them | 0.5 |
| Camera and grade (depth of field, balanced exposure) | 0.5 |
Note that albedo and material tweaking is not on the list. Spend the effort on geometry and props.
3. Never AI-upscale an equirectangular image
Upscalers hallucinate different detail on the left and right edges of the wrap seam, so the panorama no longer joins, and they smear the poles. Native 8K at about 238 seconds per panorama is time-comparable to upscaling and artifact-free. This also rules out most generative video tools for tour work: one was measured at $0.85/s with a 30 second cap and no equirect output at all.
Render tiers
Never wait for a publish render while doing look-dev. Three tiers, all measured on this project:
| Tier | Resolution | Samples | Time per room | Use |
|---|---|---|---|---|
| Look-dev | 4096x2048 | 64 | ~25 s | Is the change in the right direction |
| Rehearsal | 2048x1024 | 128 | ~35 s | Composition and lighting review |
| Publish | 4096x2048 | 448 to 512 | 5 to 17 min | The real thing |
Full publish settings:
S.render.engine = 'CYCLES' # GPU, MetalRT on Apple silicon
S.cycles.samples = 448 # 512 on earlier generations
S.cycles.use_denoising = True # OpenImageDenoise, Albedo + Normal
S.render.resolution_x, S.render.resolution_y = 4096, 2048
S.render.image_settings.file_format = 'JPEG'
S.render.image_settings.quality = 92
# view transform AgX, look "AgX - Punchy", exposure -1.70
Light paths: total 12, diffuse 6 to 8, glossy 6, transmission 8 to 12, filter glossy 0.5 to 1.0, clamp indirect 10, adaptive threshold 0.01 (0.005 for finals), min samples 64. Caustics off, both reflective and refractive.
Two measured findings worth not re-deriving:
- 512 samples is not worth double the time. Noise falls as
1/sqrt(samples): 0.264 at 64, 0.142 at 256. Beyond 256 the gain is imperceptible in a viewer where the user is panning. - Light portals did nothing here, 1.3% noise improvement at 64 spp and 0.5% at 256, because floor-to-ceiling glazing already samples the environment trivially. They can even be slower.
Rooms cost very different amounts: a balcony is mostly sky and renders in ~4.6 minutes, while a small bathroom full of glossy stone takes ~17.5. Budget by room type, not by count.
The equirectangular camera type lives in the .blend camera datablocks
(pano_<room>), not in the scripts. Scripts only select the camera and set
resolution. Set clip_end generously (60,000 m here) or the default 1 km will
cut the sea off below the horizon.
What this skill bundles
| File | What it is |
|---|---|
scripts/blserver.py |
The persistent headless Blender server. Copy it next to your .blend files and launch it with --python. Reusable as-is. |
scripts/bl.py |
The client. bl.run(code) executes python inside the live Blender and returns whatever it printed. Also bl.rendered(path), the file-based completeness check that replaces trusting the socket. |
references/pipeline-ops.md |
Ports, detached launches, generation prefixes, publishing, checkpoints, QA. |
references/viewer.md |
The web viewer: shader, hotspots, minimap, input, onboarding, embedding. |
Quick start:
cp ~/.claude/skills/signature-360/scripts/{blserver.py,bl.py} ./tour-pipeline/
lsof -iTCP:9877 -sTCP:LISTEN # must be empty before you start
nohup /Applications/Blender.app/Contents/MacOS/Blender \
--background checkpoints/current.blend --python blserver.py > blserver.log 2>&1 &
# wait for "listening" in blserver.log, then:
BLSERVER_PORT=9877 python3 bl.py info
Operating the pipeline
The mechanics, the port discipline, the detached-launch rules and the publisher
are in references/pipeline-ops.md. The short version:
- Headless Blender listens on a dedicated port with
scripts/blserver.py. The stock Blender MCP addon refuses to serve in--background, which is why this 43-line replacement exists. - Never share a port with a GUI Blender. A GUI instance squatting the default port once answered production render calls with its own default cube and overwrote a checkpoint.
- Long renders run detached, never through a foreground shell call, because the harness kills at 600 seconds and rooms take 11 to 16 minutes.
- The socket is serial. During a render every other call times out with "no data". That means busy, retry later. It is not a fault to diagnose.
The viewer
references/viewer.md has the full design. The two ideas that matter most:
Aim hotspots at the doorway, not at the destination camera. The naive
implementation put the hall-to-bedroom marker 40.7 degrees off, flat on a blank
wall. Give each link a via waypoint holding the coordinates of the actual
opening.
A room with one exit needs an explicit arrival yaw. Normally you arrive facing the way you travelled, but in a bathroom whose arch is the only way out, that leaves every exit off screen and the room reads as having no way out.
Checkpoints and scripts
- Checkpoint
.blendfiles are numbered and named for what they contain (22_before7fixes.blend,23_sevenfixes.blend). Always keep the immediately-prior state as the rollback point, and pack textures so a checkpoint is self-contained. - Every mutation script is idempotent and prints before and after. The server captures stdout and returns it as the call's result, so a script that prints is a script you can audit. A silent mutation is unreviewable and cannot be safely re-run.
- Write fix scripts to be coordinate-free where possible: measure the room from its own walls rather than hardcoding positions, so the script survives a scene edit.
The loop
- Fix the scene with an idempotent, printing script at look-dev quality.
- Rehearse the whole set at 128 spp and actually look at the images.
- Render the publish tier detached, into a new generation prefix.
- QA every room: ten views per room, checking seams, poles, exits, and that every hotspot lands on an opening.
- Publish by rewriting the one prefix string, and generate mobile variants.
- Load the live URL on a real phone viewport before calling it done.
Rollback at any point is a single sed back to the previous prefix, which is
the entire reason the prefix scheme exists.