| … | … | @@ -0,0 +1,191 @@ |
| 1 | #!/usr/bin/env python3 |
| 2 | """Generate diffusion tiles STEADILY, without a browser driving the view. |
| 3 | |
| 4 | The lookdev's streamer is view-driven on purpose: /explore asks for what is on |
| 5 | screen and the server caps the pending set at 16 so a moving view cannot grow a |
| 6 | queue it will never want. That is right for looking at terrain and useless for |
| 7 | filling a world -- covering 119x119 by panning means panning for seven hours. |
| 8 | |
| 9 | This walks a REGION instead, nearest-first from a centre, and hands the tiles to |
| 10 | the SAME TileStore the server uses. So it shares the worker, the cache layout, |
| 11 | the backoff, and the .hblk publishing; the only thing it replaces is who decides |
| 12 | what 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 | |
| 20 | Interrupting is safe. Every tile is written before the next is started, and a |
| 21 | re-run skips whatever is already cached -- so ^C then re-run resumes. |
| 22 | """ |
| 23 | import argparse, math, os, signal, sys, time |
| 24 | |
| 25 | HERE = os.path.dirname(os.path.abspath(__file__)) |
| 26 | sys.path.insert(0, HERE) |
| 27 | |
| 28 | import server # config + fetch, no Flask app is started |
| 29 | from 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. |
| 36 | OCTREE_SPAN_M = 28 * (2 ** 15) * 1.0 |
| 37 | TILE_SPAN_M = 256 * 30.0 |
| 38 | WORLD_N = int(OCTREE_SPAN_M / TILE_SPAN_M) |
| 39 | WORLD_LO = -(WORLD_N // 2) |
| 40 | WORLD_HI = WORLD_LO + WORLD_N - 1 |
| 41 | |
| 42 | |
| 43 | def 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 | |
| 68 | def 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 | |
| 83 | def 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 | |
| 91 | def 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 | |
| 190 | if __name__ == "__main__": |
| 191 | sys.exit(main()) |