OverviewFilesCommitsIssuesDocsReleasesPeers

terrain-lookdev commits / 48766db

lookdev: bake_tiles.py, because covering the world by panning is 7 hours of panning
The streamer is view-driven on purpose -- /explore asks for what is on screen and the server caps the pending set at 16, so a moving view cannot grow a queue it will never want. That is right for looking at terrain and useless for filling a world. bake_tiles walks a REGION nearest-first and hands tiles to the SAME TileStore the server uses, so it shares the worker, the cache layout, the backoff and the .hblk publishing. The only thing it replaces is who decides what to ask for; nothing in it knows the tile format. Defaults to the 119x119 window the ENGINE can address, derived from the same expression the shader emitter uses (erosion_heightmap_world(28*2^15, 256*30) -> n=119, tx0=-59) rather than hardcoded. Tiles outside it cannot be loaded by the map -- 130 such already sit in the cache being refused -- so --all is opt-in. Resumable: tiles are skipped by TileStore.state(), which reports "disk" for a cached tile, and the nearest-first order is deterministic, so ^C then re-run continues rather than restarting. Preflight only treats a CONNECTION failure as "service down". The probe is a tiny 8 px tile, which is not a representative request and which the service is entitled to reject; counting an HTTPError as down would refuse to bake against a perfectly healthy backend. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
48766dba1d608e9a102ce49dce12246ac092c837
by plankatron <fleeky@gmail.com> · 2026-09-03T21:54:05.000Z

Changed files (1) +191 -0

bake_tiles.py +191 -0
……@@ -0,0 +1,191 @@
1#!/usr/bin/env python3
2"""Generate diffusion tiles STEADILY, without a browser driving the view.
3
4The lookdev's streamer is view-driven on purpose: /explore asks for what is on
5screen and the server caps the pending set at 16 so a moving view cannot grow a
6queue it will never want. That is right for looking at terrain and useless for
7filling a world -- covering 119x119 by panning means panning for seven hours.
8
9This walks a REGION instead, nearest-first from a centre, and hands the tiles to
10the SAME TileStore the server uses. So it shares the worker, the cache layout,
11the backoff, and the .hblk publishing; the only thing it replaces is who decides
12what to ask for. Nothing here knows the tile format.
13
14 python bake_tiles.py # the whole 119x119 world window
15 python bake_tiles.py --radius 20 # a 41x41 disc around the centre
16 python bake_tiles.py --centre 10,-5 --radius 8
17 python bake_tiles.py --limit 200 # stop after N new tiles
18 python bake_tiles.py --dry-run # say what it would do, generate nothing
19
20Interrupting is safe. Every tile is written before the next is started, and a
21re-run skips whatever is already cached -- so ^C then re-run resumes.
22"""
23import argparse, math, os, signal, sys, time
24
25HERE = os.path.dirname(os.path.abspath(__file__))
26sys.path.insert(0, HERE)
27
28import server # config + fetch, no Flask app is started
29from tiles import TileStore
30
31# The world window the ENGINE can address, from the same expression the shader
32# emitter uses: erosion_heightmap_world(28*2^15*1.0, 256*30) -> n = 119,
33# tx0 = ty0 = -(n/2) = -59. Generating outside it is not wasted exactly -- the
34# cache is the cache -- but the tile CANNOT be loaded by the map, and 130 such
35# tiles already sit in the cache being refused. Default to the addressable set.
36OCTREE_SPAN_M = 28 * (2 ** 15) * 1.0
37TILE_SPAN_M = 256 * 30.0
38WORLD_N = int(OCTREE_SPAN_M / TILE_SPAN_M)
39WORLD_LO = -(WORLD_N // 2)
40WORLD_HI = WORLD_LO + WORLD_N - 1
41
42
43def preflight(td_url):
44 """Fail HERE if the backend is not up.
45
46 tiles.py backs off on failure precisely because a downed service once
47 burned a whole queue into 962 errors in seconds. Better to not start.
48
49 Only a CONNECTION failure counts as down. A tiny probe tile is not a
50 representative request and the service is entitled to reject it -- treating
51 that as "down" would refuse to bake against a perfectly healthy backend.
52 """
53 import urllib.error
54 try:
55 server.fetch_diffusion(td_url, 0, 0, 8)
56 return True
57 except urllib.error.HTTPError:
58 return True # it answered, and that is all this asks
59 except (urllib.error.URLError, ConnectionError, OSError) as ex:
60 print(f"diffusion service not answering at {td_url}\n {ex}\n\n"
61 " cd /storage/projects/code/godot/terrain-diffusion\n"
62 " .venv/bin/python -m terrain_diffusion.inference.api \\\n"
63 " xandergos/terrain-diffusion-30m --seed 1234 --no-compile \\\n"
64 " --host 127.0.0.1 --port 8817", file=sys.stderr)
65 return False
66
67
68def targets(centre, radius, lo, hi):
69 """Region tiles, nearest-first. Ties broken deterministically so an
70 interrupted bake and its resume walk the same order."""
71 cx, cy = centre
72 out = []
73 for tx in range(lo, hi + 1):
74 for ty in range(lo, hi + 1):
75 d2 = (tx - cx) ** 2 + (ty - cy) ** 2
76 if radius is not None and d2 > radius * radius:
77 continue
78 out.append((d2, tx, ty))
79 out.sort()
80 return [(tx, ty) for _d, tx, ty in out]
81
82
83def human(sec):
84 if sec < 90:
85 return f"{sec:.0f}s"
86 if sec < 5400:
87 return f"{sec/60:.1f}m"
88 return f"{sec/3600:.1f}h"
89
90
91def main():
92 ap = argparse.ArgumentParser()
93 ap.add_argument("--centre", default="0,0", metavar="TX,TY")
94 ap.add_argument("--radius", type=int, default=None,
95 help="tiles from centre; default = the whole world window")
96 ap.add_argument("--limit", type=int, default=0, help="stop after N new tiles")
97 ap.add_argument("--all", action="store_true",
98 help="ignore the 119x119 addressable window")
99 ap.add_argument("--td-url", default="http://127.0.0.1:8817")
100 ap.add_argument("--tile-px", type=int, default=256)
101 ap.add_argument("--depth", type=int, default=24,
102 help="pending tiles kept queued; the worker is serial either way")
103 ap.add_argument("--dry-run", action="store_true")
104 a = ap.parse_args()
105
106 cx, cy = [int(v) for v in a.centre.split(",")]
107 lo, hi = (-10**6, 10**6) if a.all else (WORLD_LO, WORLD_HI)
108 want = targets((cx, cy), a.radius, lo, hi)
109
110 print(f"world window : {WORLD_N}x{WORLD_N} tiles, tx/ty {WORLD_LO}..{WORLD_HI}"
111 + (" (IGNORED, --all)" if a.all else ""))
112 print(f"region : centre ({cx},{cy})"
113 + (f", radius {a.radius}" if a.radius is not None else ", whole window")
114 + f" -> {len(want)} tiles")
115 print(f"npy cache : {server.TILE_CACHE}")
116 print(f"hblk out : {server.HBLK_CACHE}")
117
118 if a.dry_run:
119 have = sum(1 for t in want
120 if os.path.exists(os.path.join(
121 server.TILE_CACHE, f"t2_{a.tile_px}_{t[0]}_{t[1]}.npy")))
122 todo = len(want) - have
123 print(f"\ncached {have}, would generate {todo}"
124 + (f" (capped to {a.limit})" if a.limit and todo > a.limit else ""))
125 print(f"at the measured 1.72 s/tile median that is "
126 f"~{human(min(todo, a.limit or todo) * 1.72)}")
127 return 0
128
129 if not preflight(a.td_url):
130 return 1
131
132 def _fetch(i1, j1, n):
133 return server.fetch_diffusion(a.td_url, i1, j1, n)[:2]
134
135 ts = TileStore(_fetch, server.TILE_CACHE, tile_px=a.tile_px,
136 log=lambda *_: None, hblk_dir=server.HBLK_CACHE)
137 n = ts.backfill_hblk()
138 if n:
139 print(f"backfilled {n} .hblk from the existing cache")
140
141 todo = [t for t in want if ts.state(*t) not in ("cached", "disk")]
142 if a.limit:
143 todo = todo[:a.limit]
144 print(f"\n{len(want) - len(todo)} already cached, generating {len(todo)}\n")
145 if not todo:
146 return 0
147
148 stop = {"now": False}
149 signal.signal(signal.SIGINT, lambda *_: stop.__setitem__("now", True))
150
151 t0 = time.time()
152 done0 = ts.stats["generated"]
153 err0 = ts.stats["errors"]
154 i = 0
155 last = ""
156 while (i < len(todo) or ts.queue_depth()) and not stop["now"]:
157 room = a.depth - ts.queue_depth()
158 if room > 0 and i < len(todo):
159 ts.request(todo[i:i + room], (cx, cy))
160 i += room
161 time.sleep(0.4)
162
163 done = ts.stats["generated"] - done0
164 errs = ts.stats["errors"] - err0
165 el = time.time() - t0
166 rate = done / el if el > 0 else 0
167 eta = (len(todo) - done) / rate if rate > 0 else 0
168 last = (f"\r {done}/{len(todo)} {100*done/len(todo):5.1f}% "
169 f"{rate*3600:6.0f} tiles/h elapsed {human(el)} "
170 f"eta {human(eta) if rate > 0 else '--'}"
171 + (f" errors {errs}" if errs else "") + " ")
172 sys.stdout.write(last); sys.stdout.flush()
173
174 # A backend that went away mid-bake: tiles.py backs off, so this is a
175 # slow bleed rather than a burst. Stop rather than grind for hours.
176 if errs >= 20 and done == 0:
177 print("\n\naborting: 20 failures and no successes — is the service up?")
178 return 1
179
180 el = time.time() - t0
181 done = ts.stats["generated"] - done0
182 print(f"\n\n{'interrupted' if stop['now'] else 'done'}: "
183 f"{done} tiles in {human(el)}"
184 + (f" ({done/el*3600:.0f} tiles/h)" if el > 0 and done else ""))
185 if stop["now"]:
186 print("re-run the same command to resume; cached tiles are skipped.")
187 return 0
188
189
190if __name__ == "__main__":
191 sys.exit(main())