A seven-tiered pagoda with a windmill at its peak, rising out of forest above a bay at golden hour
Baseline / world photography · field notes

Photographing a world nobody had time to look at

Three million building pieces. One person. The only way to see what people had built was to stop flying the camera by hand and teach the geometry to place it instead.

Pirate's Haven · 4,124 pieces · 1 builder · bearing orbit1 · time of day 0.64 · aesthetic 6.31
What came out

The pictures first

Everything below this is machinery. This is what the machinery is for: photographs of other people's buildings, framed by arithmetic, on a server where no human ever lined up the shot.

Watchtower Forest — a player-built valheim structure photographed from a planned camera position
Watchtower Forest6.28
Skywatch Tower — a player-built valheim structure photographed from a planned camera position
Skywatch Tower6.26
Cliffside Fort — a player-built valheim structure photographed from a planned camera position
Cliffside Fort6.24
Cliffside Hideout — a player-built valheim structure photographed from a planned camera position
Cliffside Hideout6.24
Treehouse Oasis — a player-built valheim structure photographed from a planned camera position
Treehouse Oasis6.22
Coastal Hideout · 1,810 — a player-built valheim structure photographed from a planned camera position
Coastal Hideout · 1,8106.20
Six structures, each the highest-scoring frame of its build. Camera position, bearing, elevation, time of day and weather were all chosen by the planner from the save file's geometry. No human flew any of these shots.
Sixty frames sampled evenly across the ranked run — best at top-left, weakest at bottom-right. 1,411 photographs came out of 54 sessions. The point of the ranking is that nobody has to look at all of them.
3,146,002building pieces scanned
1,833structures found
161structures photographed
1,411frames captured
54unattended sessions
0shots framed by hand
The shape of it

Three million pieces, sixty good pictures

The world is a community server sixteen eras old. Its save file holds roughly 3.15 million player-placed building pieces. Somewhere in there are the builds worth looking at, and there is no index of them — a Valheim save is a flat bag of objects with coordinates, not a list of buildings.

So the whole job is a funnel: turn a bag of pieces into a list of structures, rank the structures, photograph the top of the list, then rank the photographs.

3,146,002building piecesflat bag of objectsin the save filecluster1,833structures3-D connectedcomponentsrank + cut161photographedtop of theranked listfly the plan1,411frames6 per structure,54 sessionsscore each frame60worth showingtop of thescored listevery stage discards most of the last
Each stage throws away most of what the previous one produced. The two reductions that matter are clustering (pieces to structures) and scoring (frames to a shortlist) — everything else is transport.
Data flow

Where the human used to be

The first version of this had a person in the middle: read a shot list off a second monitor, teleport, look around, press a key, repeat. That works for twenty shots. It does not work for fourteen hundred.

The fix was to make the plan a file the game reads at startup, and to make the game write back a receipt for every frame it takes. Once both halves exist, the loop closes and the operator's only job is launching it.

offline · pythonin-game · bepinex c#delivery · python + browserworld cacheComfyStewardViewDuckDB, read-onlyscan_clusters.pyunion-find on a16 m gridclusters.json1,833 recordsbox + statsplan_shots.pytrig from thebounding boxshotplan.tsv6 lines perstructurecopied intoBepInEx/configInvoke-OrbitCapturearms the mod,launches SteamAutoBootload world,wait for spawnRunShotPlanteleport → aim →set sun + weatherCaptureHerehide chrome,raycast, shootpng + receiptone jsonl lineper framenext shot — 1,411 times, unattendedbuild_valheim_index.pyjoin receipts to files,resize to webpscore_images.pyCLIP ViT-L/14 +aesthetic headindex.json1,411 frames,rankedgallerystatic html,filter + lightboxa humanpicks thekeepers
Three lanes. Offline is Python against a read-only analytics cache. In-game is a BepInEx plugin that boots, reads a plan, and flies it. Delivery joins receipts to files on disk and scores what survived. The dashed hop is the only manual step left: a launcher script copies the plan into the game's config directory and starts Steam.
Contracts

Four files, four promises

The two halves are written in different languages, run in different processes, and cannot call each other. Everything they share is a file. That constraint turned out to be a feature — each handoff is inspectable, replayable, and diff-able.

One deliberately unglamorous choice: the plan the mod reads is tab-separated text, not JSON. The plugin has no JSON parser, it scrapes with regex, and one flat line per shot is far harder to misparse than nested objects.

scan → plannerclusters.jsoncluster_idcenter_x/y/zsize_x/y/zmin_y max_ypieces scoredistinct_creatorsregion skyone row per structure,with a true 3-D boxplanner → gameshotplan.tsvcluster_id shotcam_x cam_y cam_zyaw pitchenv timeaim_x/y/zlabeltab-separated on purpose:the plugin has no JSON parsergame → indexreceipts.jsonlrun index fileplanned {x,y,z}placed {x,y,z}lens {x,y,z}occludedpieces_near_aimatasked-for vs actually-done,which is what makes it auditableindex → galleryindex.jsonid run variantaestheticlabel kindpieces height_mbuilderspublished: falseeverything the page needs,nothing it should not haveno shared memory · no rpc · no clock — every handoff is a file you can open, diff and replayDifferent languages, different processes, no shared clock — which is why every stage re-runs on its own.
Every arrow is a file on disk. The receipt is the important one: it records what the planner asked for and what the engine did, which is how a frame can later be trusted, or thrown away.
Tech stack

Nothing here was written for this

Every layer already existed for another reason. The world cache was built to answer steward questions. The perception model was on the box for a photo library. The mod started life as a proof that a camera could be moved at all. The work was wiring, not invention.

deliverystatic HTML + vanilla JSno framework, no build step, no server — a file you can openscoringPyTorch · open_clip · CLIP ViT-L/14 · LAION aesthetic headalready on the box for an unrelated photo librarynamingvision model over the photograph itselfbecause the prefab table is hashes, not wordsin-gameBepInEx 5 · Harmony · Unity · C# net4721,787 lines — teleport, aim, sun, weather, shutterorchestrationPowerShell 5.1arms the plugin, launches Steam, polls the receipt fileplanningPython 3 · DuckDB · stdlib mathunion-find clustering and secondary-school trigstorageValheim world save → ComfyStewardView DuckDB cacheread-only; the world is never re-parsed or written
Roughly 2,300 lines of Python and PowerShell against 1,787 lines of C#. The heaviest dependency — a CLIP vision transformer — is the one that took the least code to use.
The math

How a camera gets placed

There are four small pieces of arithmetic in this, and none of them are clever. What made them work was measuring instead of guessing.

1 — Finding a building in a bag of pieces

Building pieces are snapped to a 16 m grid. A cell holding at least four pieces is "occupied". Occupied cells touching in a 26-neighbourhood get unioned together. What falls out is one cluster per structure, with a true 3-D bounding box.

Doing this in three dimensions rather than two is not optional here. This world has builds stacked vertically, and a flat grid merges a sky platform with whatever sits on the ground beneath it — producing "structures" with nonsense five-kilometre bounding boxes.

score = 2·log10(pieces) + min(h,60)/20 + min(prefabs,60)/30 + min(4·pieces/area, 2) mass + vertical relief + material variety + compactness. Deliberately transparent and deliberately untuned — it penalises sprawl, because a field of scattered fence posts covers ground without ever filling a frame.

2 — Where to stand

Given a bounding box and a field of view, the standoff distance is secondary-school trigonometry. The part that took a second attempt was deciding what to frame.

The first version framed on the bounding diagonal, and every photograph came back as a smudge in the middle of a hazy frame. A cluster's diagonal is inflated by sprawl — outbuildings, walls, a dock — so framing on it pushes the camera back past the thing people came to see. Framing on the compact extent instead fixed it: on one build the diagonal wanted the camera 267 m out, the compact extent wanted 136 m. The second one is the photograph.

subject = max( size_y, min(size_x, size_z), 8 ) distance = (subject / 2) / tan(fov_v / 2) × margin elevation = max( 18°, min(40°, 40° − 60° · size_y/width) ) azimuth[i] = (base + 45° + 90°·i) mod 360° fov_v is 65° and the window is 16:9, so height is the binding constraint. Elevation tilts down on flat things and levels off on tall ones: shooting a 100 m spire from 40° up gives you a roof. The 45° azimuth offset puts every shot on a corner — dead-on the narrow face of a long hall is the least informative angle there is.
elevation — how far background — i.e. the lowest foundation piecesubjectmax(size_y,min(size_x,size_z))camerafov_v 65°dθfloor: base + 3 md = (subject / 2) / tan(fov_v / 2) × marginθ = max(18°, min(40°, 40° − 60°·size_y/width))tilt down on flat things, level off on tall onesplan — which way roundlong axisorbit145°orbit2135°orbit3225°orbit4315°every bearing sits 45° off the long axisso each frame shows a long face and a short one together
Left: the framing triangle in elevation. Right: the four bearings in plan, offset 45° from the structure's long axis. The camera is clamped to stay at least 3 m above the lowest foundation piece, which is the only place terrain enters the calculation.

There is no heightmap. Valheim generates terrain from the world seed and the save holds only objects, so no offline elevation data exists at all. The trick is that a cluster's lowest piece is ground at that build, because a foundation rests on it. Only the in-game runner needs a real ground query, and only to stop a camera landing inside a hillside.

3 — When to shoot

The first planner used time-of-day 0.70 and 0.30 for the two light variants, because those sound like golden hour. Then a 207-frame sweep measured luminance and contrast across the whole day, and both guesses turned out to sit past the good light.

Time of dayLuminanceContrast
0.2974.9105.2the old dawn value — already falling off
0.32121.5157.4adopted: morning
0.64121.6159.8adopted: peak contrast in the sweep
0.67118.8153.7
0.7083.1118.2the old orbit value — 26% less contrast
The falloff either side of midday is steep enough that being 0.03 late costs a quarter of the contrast in the frame.
The same structure at morning
morning · 0.255.48
The same structure at noon
noon · 0.505.76
The same structure at sunset
sunset · 0.725.55
The same structure at night
night · 0.905.39
One structure, four times of day, camera identical. Nothing moved but the sun. These four are samples from the sweep, not the two values the planner settled on — they are what the measurement above was taken from.

4 — Which frame is any good

The number on a gallery tile used to be the structure's ranking score, which is identical across all six photographs of the same building. It ranks places. It cannot tell a good picture of a place from a bad one, which is the thing a gallery actually needs.

So each frame now goes through a CLIP ViT-L/14 image encoder and a small LAION aesthetic head — the same model and weights already sitting on the box for an unrelated photo library. No new download, no new dependency.

e = CLIP_ViT-L/14 image encoder (frame) → 768 floats e = e / norm(e) → L2-normalised aesthetic = MLP(e) 768 → 1024 → 128 → 64 → 16 → 1 It is still a machine's opinion, and it is still not taste. But it is an opinion about this frame rather than about the building in it. Across the 1,411 photographs it spread them from 3.86 to 6.31, median 5.55.
Stage by stage

What each step actually emits

The clearest way to explain a pipeline is to show what falls out of each end of it. These are real records from the run that produced the photographs above, with coordinates removed — they attribute real builds to real people, and this is a public page.

scan_clusters.py → clusters.json — one record per structure
{
  "world": "ComfyEra16.db",
  "method": "3-D connected-component clustering of BUILDING ZDOs on a
             16x16x16 m grid (min 4 pieces/cell, min 400 pieces/cluster)",
  "count": 1833,
  "clusters": [{
    "cluster_id": 51,        "region": "in-world",   "sky": false,
    "pieces": 21464,        "score": 15.663,      "distinct_prefabs": 132,
    "center_x": ·····,      "center_y": ·····,     "center_z": ·····,
    "size_x": 150.2,       "size_y": 100.0,      "size_z": 176.4,
    "footprint_m2": 26494.0, "diagonal_m": 252.3,
    "suggested_standoff_m": 227.1, "suggested_camera_y": 135.1,
    "distinct_creators": 5,  "portals": 4,        "signs": 2857
  }, … 1832 more]
}
plan_shots.py → shotplan.tsv — six lines per structure, one per frame. This is the file the plugin reads.
# cluster_id  shot     cam_x cam_y cam_z   yaw    pitch  env    time  aim  label
439           orbit1   ····  ····  ····   225.0  27.39  Clear  0.64  ···  Pirate's Haven
439           orbit2   ····  ····  ····   315.0  27.39  Clear  0.64  ···  Pirate's Haven
439           orbit3   ····  ····  ····    45.0  27.39  Clear  0.64  ···  Pirate's Haven
439           orbit4   ····  ····  ····   135.0  27.39  Clear  0.64  ···  Pirate's Haven
439           dawn     ····  ····  ····   225.0  27.39  Clear  0.32  ···  Pirate's Haven
439           weather  ····  ····  ····   225.0  27.39  Misty  0.66  ···  Pirate's Haven
The same structure seen from bearing orbit1
orbit16.31
The same structure seen from bearing orbit2
orbit26.21
The same structure seen from bearing orbit3
orbit36.01
The same structure seen from bearing orbit4
orbit45.93
Those first four lines, executed. The same structure on four bearings 90° apart, each one offset 45° from its long axis so no shot is dead-on a face. The leftmost frame is the one at the top of this page.
the plugin → shotplan-receipts.jsonl — one line per frame, written from inside the running game
{
  "run": "20260806-110505", "index": 478, "cluster_id": 407,
  "shot": "dawn", "file": "0407_dawn.png",
  "planned":  { x: ···, y: 97.800, z: ··· },   ← what the planner asked for
  "placed":   { x: ···, y: 97.800, z: ··· },   ← where the engine put the player
  "lens":     { x: ···, y: 99.386, z: ··· },   ← where the camera actually was
  "lens_offset_m": 1.648,        "clearance": "planned",
  "yaw": 45, "pitch": 26.31, "fov": 65,
  "environment": "Clear", "time_of_day": 0.32,
  "pieces_near_aim": 3377, "occluded": false,
  "at": "2026-08-06T12:35:28.109-07:00"
}

Three positions, not one. planned is the request, placed is where the engine actually dropped the player after its own ground clamp, and lens is the camera — 1.6 m above the player origin, because the eye is not the feet. Keeping all three is what lets a frame be audited a week later instead of trusted on faith. occluded and pieces_near_aim come from a raycast at capture time: if nothing is in front of the lens, the frame is a photograph of fog and gets dropped.

build_valheim_index.py + score_images.py → index.json — receipts joined to files on disk, then scored
{ "n": 1411, "runs": 54, "joined": 1411,
  "environments": ["Clear","Misty","Rain","ThunderStorm"],
  "kinds": ["build","hub","major hub","museum","settlement","sky build",
            "sky museum","sprawl","tall hall","tower","shared build"],
  "images": [{
    "id": "20260806-071848_0439_orbit1",  "variant": "orbit1",
    "aesthetic": 6.313,     "score": 11.013,   "cluster_rank": 692,
    "label": "Pirate's Haven",  "kind": "hub",  "builders": 3,
    "pieces": 4124,       "height_m": 25.8,  "footprint_m2": 7049.8,
    "occluded": false,    "published": false
  }, … 1410 more]
}

"published": false is the default on every single record, and it is deliberate. Ingest is automatic; exposure is not. These are other people's houses.

One more thing

Naming things by looking at them

A cluster's identity in the save file is cluster 439. That is useless in a gallery. The obvious fix — read the prefab names and infer "this is a longhouse" — does not work: the analytics cache stores prefabs as hashes like hash:538325542, and the offline name table holds 617 item names and no building pieces at all.

So the naming step skips the metadata entirely and shows a vision model the photograph. It gets back a name, a one-line description, and a feature list.

name_structures.py → cluster-descriptions.json
{
  "22": {
    "name": "Pride's Portal",
    "blurb": "A grand entrance to an ethereal realm with glowing
              lanterns and a golden portal.",
    "features": ["Glowing Lanterns", "Golden Portal",
                 "Architectural Columns"],
    "from_image": "20260806-015620_a_clear_t38",  "pieces": 9603
  }
}

Names that came back this way include Tree of Light, Skyward Tower, Marble Oasis, Gothic Ruin and Kleenex Gate. The last one is not a mistake — it is a real build, and the model read the sign.

Still open

What this does not do

  • Prefab names are hashes. "Dominant material" is a stable ID, not a word. The fix is a one-time in-game dump of the prefab table, joined thereafter.
  • Builders are numbers. 351 distinct creator IDs across the ranked clusters, none of them zero — so attribution works, but turning an ID into a name needs records that only exist in the running server.
  • No metric can judge a photograph. The aesthetic head sorts competently and is confidently wrong at the edges. It is a filter that makes a 1,411-frame pile reviewable, not a critic.
  • Dense forest still eats the camera. The occlusion raycast catches a wall in front of the lens. It does not catch a pine branch across a third of the frame.
  • It runs on one machine. The world cache, the model weights, the game install and the gallery are all local. Nothing here is a service, and this write-up is not a promise that it will become one.