clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

main
atlas / render / globe / texture.ts
texture.ts 9.1K
1// The sphere's skin: an equirectangular composite of a lens's own tiles.2//3// A world that declares `atlas.geometry.surface: sphere` also declares the4// window of world pixels its raster fills and the degrees that window5// pictures (format.md §6.7). Equirectangular is the one projection that6// drapes a sphere without resampling — which is why the globe asks for it by7// name — so the composite is a straight copy: the declared window's pixels,8// laid into a texture whose edges are the declared degrees.9//10// ONE PASS, and its size is the reason there is only one. The base skin is11// composited once per lens, from the shallowest complete level big enough to12// read; it is what a reader sees the moment the sphere comes up, and it never13// changes. It is also a ceiling: 4096 pixels across the whole equirect window14// is level four exactly, so a deeper tile composited in here would be drawn15// at a quarter of the pitch it was captured at and three quarters of the16// capture thrown away before anything reached the screen.17//18// So the detail under the camera is NOT composited here. It is draped as its19// own mesh per tile, wearing its own texture at its own size — see20// `refreshDetail` in ./element.ts. This file used to hold a second pass that21// drew those tiles into this canvas, and the two passes sharing one texture,22// one counter and one key is what once left the sphere black on first entry.23// There is one pass now, and that whole class of defect went with the other.2425import { logger } from "../log.ts";26import type { Lens, TileGrid } from "../data/payload.ts";27import { LensCoverage } from "../data/pyramid.ts";2829const log = logger("globe");3031/** The texture's own size. Big enough for the base skin, small enough to hold. */32const TEXTURE_WIDTH = 4096;33const TEXTURE_HEIGHT = 2048;3435/** The base skin wants at least this many pixels across before it reads. */36const BASE_TARGET = 2048;3738/** A rectangle of world pixels, as the world declares its equirect window. */39export interface Window {40 readonly x: number;41 readonly y: number;42 readonly width: number;43 readonly height: number;44}4546/** The composite, and the account of what went into it. */47export class Skin {48 readonly canvas: HTMLCanvasElement;49 /** The pyramid the skin is woven from. */50 lens = "";51 private readonly paper: CanvasRenderingContext2D | null;52 /**53 * What the texture is known to hold, and what a live pass is in the middle54 * of laying down.55 *56 * A key is committed only when its paint finishes un-superseded, because a57 * paint that was abandoned half way left the texture holding neither the58 * composite it was replacing nor the one it was drawing. Committing on59 * entry -- which is what the first cut of this file did -- makes an60 * abandoned pass permanent: the key says the skin is there, so nobody ever61 * asks for it again, and the sphere stays black.62 */63 private baseKey = "";64 private basePainting = "";65 /**66 * Which composite is current. A paint walks tiles one image at a time and67 * awaits each, so a lens that changed -- or a sphere that was put away --68 * while it was walking would otherwise go on drawing a pyramid nobody is69 * over any more.70 */71 private baseGeneration = 0;7273 /**74 * The window of world pixels this composite is a picture of.75 *76 * Not readonly, because it is the *world's* declaration and one sphere77 * outlives a world: flipping from one sphere world to another keeps the78 * renderer, the canvas and the texture hanging on it, and every one of them79 * is right — the only thing that was about the old world is where its pixels80 * landed. `retarget` is that, and it is the whole of the skin's lifecycle81 * beyond being built.82 */83 private window: Window;84 private readonly grid: TileGrid;8586 constructor(window: Window, grid: TileGrid) {87 this.window = window;88 this.grid = grid;89 this.canvas = document.createElement("canvas");90 this.canvas.width = TEXTURE_WIDTH;91 this.canvas.height = TEXTURE_HEIGHT;92 this.paper = this.canvas.getContext("2d", { willReadFrequently: false });93 }9495 /**96 * Forget the skin. A pass still arriving is cancelled with it.97 *98 * The texture's pixels are left alone -- what was painted stays painted --99 * but nothing is claimed as current any more, so whatever comes back asks100 * for its composite again.101 */102 clear(): void {103 this.baseGeneration++;104 this.basePainting = "";105 this.lens = "";106 }107108 /**109 * Take another world's window, and give up everything painted for the last.110 *111 * A different world is a different rectangle of world pixels and a different112 * picture inside it, so nothing on this canvas means anything any more: the113 * paint is cancelled, the key goes back to naming nothing, and the pixels114 * are wiped rather than left to show through wherever the new world's window115 * is smaller than the last one's. The alternative was a new `Skin` per116 * world, which would take the texture out from under the material wearing it117 * and cost a fresh four-megapixel canvas to say the same thing.118 */119 retarget(window: Window): void {120 this.window = window;121 this.clear();122 this.baseKey = "";123 this.paper?.clearRect(0, 0, TEXTURE_WIDTH, TEXTURE_HEIGHT);124 }125126 /** Where a world pixel lands on the texture. */127 private place(x: number, y: number): [number, number] {128 return [129 ((x - this.window.x) / this.window.width) * TEXTURE_WIDTH,130 ((y - this.window.y) / this.window.height) * TEXTURE_HEIGHT,131 ];132 }133134 /** The shallowest level whose picture of the window is worth showing. */135 baseLevel(lens: Lens): number {136 const fraction = this.window.width / this.grid.size;137 let z = lens.minZoom;138 while (z < lens.fullZoom && this.grid.tileSize * 2 ** z * fraction < BASE_TARGET) z++;139 return z;140 }141142 /**143 * Composite the base skin, once per lens.144 *145 * A newer lens supersedes an older one in flight: the texture is about to146 * be wiped, so what it held is no longer what `baseKey` says it held, and147 * the key goes back to naming nothing until this paint earns it.148 */149 async base(150 base: string,151 world: string,152 lens: Lens,153 url: (z: number, x: number, y: number) => string | null,154 changed: () => void,155 ): Promise<void> {156 // THE WORLD IS IN THE KEY, and it is the guard against a stale skin. Two157 // worlds of one volume are served off the same base and may name their158 // pyramids the same thing -- `tiles` is a path inside a world, not across159 // one -- so a key of base and pyramid alone says "already composited"160 // about a picture of somewhere else. That is the reference's own161 // `texturedFor`, which is volume, world and lens together.162 const key = `${base}/${world}/${lens.tiles}`;163 if (key === this.baseKey || key === this.basePainting) return;164 const mine = ++this.baseGeneration;165 this.basePainting = key;166 this.baseKey = "";167 this.lens = lens.tiles;168 this.paper?.clearRect(0, 0, TEXTURE_WIDTH, TEXTURE_HEIGHT);169 const z = this.baseLevel(lens);170 if (!await this.paint(mine, z, lens, url, changed)) return;171 this.baseKey = key;172 this.basePainting = "";173 log.info("the sphere has its skin", { op: "render", lens: lens.name, z });174 }175176 /**177 * Walk the tiles of one pass, drawing each as it arrives.178 *179 * Returns whether the walk finished as itself. `false` means a newer pass180 * -- or a clear -- came through while this one was waiting on an image, and181 * the caller must not claim the texture holds what it was drawing.182 */183 private async paint(184 mine: number,185 z: number,186 lens: Lens,187 url: (z: number, x: number, y: number) => string | null,188 changed: () => void,189 ): Promise<boolean> {190 const paper = this.paper;191 if (!paper) return false;192 paper.imageSmoothingEnabled = lens.interpolate;193 const coverage = new LensCoverage(lens);194 const edge = this.grid.size / 2 ** z;195 const scale = (edge / this.window.width) * TEXTURE_WIDTH;196 const scaleY = (edge / this.window.height) * TEXTURE_HEIGHT;197 for (const [x, y] of this.windowTiles(edge)) {198 if (!coverage.has(this.grid, z, x, y)) continue;199 const at = url(z, x, y);200 if (!at) continue;201 const image = await load(at);202 if (mine !== this.baseGeneration) return false;203 if (!image) continue;204 const [px, py] = this.place(x * edge, y * edge);205 paper.drawImage(image, px, py, scale, scaleY);206 changed();207 }208 return true;209 }210211 /** Every tile of one level the declared window covers, column by column. */212 private *windowTiles(edge: number): Generator<[number, number]> {213 const x0 = Math.floor(this.window.x / edge);214 const x1 = Math.ceil((this.window.x + this.window.width) / edge);215 const y0 = Math.floor(this.window.y / edge);216 const y1 = Math.ceil((this.window.y + this.window.height) / edge);217 for (let x = x0; x < x1; x++) {218 for (let y = y0; y < y1; y++) yield [x, y];219 }220 }221}222223function load(url: string): Promise<HTMLImageElement | null> {224 return new Promise((resolve) => {225 const image = new Image();226 image.crossOrigin = "anonymous";227 image.onload = () => resolve(image);228 image.onerror = () => resolve(null);229 image.src = url;230 });231}