From 7a7cfcfb46b08ca327e25052ef35d31607f3d300 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 16 Jul 2026 07:39:40 +0200 Subject: [PATCH 1/7] docs(examples): infinite procedural terrain example (sampled chunk source) --- examples/examples.json | 17 +++ examples/tilemap/infinite-terrain.js | 192 ++++++++++++++++++++++++ examples/tilemap/infinite-terrain.ts | 211 +++++++++++++++++++++++++++ 3 files changed, 420 insertions(+) create mode 100644 examples/tilemap/infinite-terrain.js create mode 100644 examples/tilemap/infinite-terrain.ts diff --git a/examples/examples.json b/examples/examples.json index 68ac678b..393a1762 100644 --- a/examples/examples.json +++ b/examples/examples.json @@ -1877,6 +1877,23 @@ "intgrid" ], "level": "advanced" + }, + { + "slug": "infinite-terrain", + "path": "tilemap/infinite-terrain.js", + "language": "typescript", + "title": "Infinite Procedural Terrain", + "description": "Stream an unbounded TileMap from a deterministic value-noise sampler: a ChunkStreamer loads chunks around the free-flying camera via createSampledChunkSource and evicts what you leave behind — same seed, same world.", + "backend": "core", + "tags": [ + "tilemap", + "procedural", + "streaming" + ], + "level": "intermediate", + "capabilities": [ + "keyboard" + ] } ], "tweens-animation": [ diff --git a/examples/tilemap/infinite-terrain.js b/examples/tilemap/infinite-terrain.js new file mode 100644 index 00000000..cb46e1f4 --- /dev/null +++ b/examples/tilemap/infinite-terrain.js @@ -0,0 +1,192 @@ +// Auto-generated from infinite-terrain.ts — edit the .ts source, not this file. +import { Application, Asset, Color, Container, Keyboard, Scene, Spritesheet, TextureRegion, View } from '@codexo/exojs'; +import { ChunkStreamer, createSampledChunkSource, TILE_TRANSFORM_IDENTITY, TileLayer, TileMap, tilemapExtension, TileSet } from '@codexo/exojs-tilemap'; +import { mountControlPanel, mountControls } from '@examples/runtime'; +// An infinite, procedurally generated world: the TileLayer has NO width/height +// (unbounded), and a ChunkStreamer keeps only the chunks near the camera +// resident. Terrain comes from a deterministic value-noise fBm sampler fed +// through createSampledChunkSource — same seed, same world, every visit. +const TILE = 64; +const FEATURE_SIZE = 28; +const MOVE_SPEED = 420; +// Deterministic integer-lattice hash → [0, 1). Any change here changes every +// world; the worker copy in the worker example must stay byte-identical. +function hash2D(seed, x, y) { + let h = (seed ^ Math.imul(x, 0x27d4eb2d) ^ Math.imul(y, 0x165667b1)) | 0; + h = Math.imul(h ^ (h >>> 15), 0x85ebca6b); + h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35); + h ^= h >>> 16; + return (h >>> 0) / 4294967296; +} +function valueNoise(seed, x, y) { + const x0 = Math.floor(x); + const y0 = Math.floor(y); + const fx = x - x0; + const fy = y - y0; + const sx = fx * fx * (3 - 2 * fx); + const sy = fy * fy * (3 - 2 * fy); + const n00 = hash2D(seed, x0, y0); + const n10 = hash2D(seed, x0 + 1, y0); + const n01 = hash2D(seed, x0, y0 + 1); + const n11 = hash2D(seed, x0 + 1, y0 + 1); + const nx0 = n00 + (n10 - n00) * sx; + const nx1 = n01 + (n11 - n01) * sx; + return nx0 + (nx1 - nx0) * sy; +} +// 4 octaves, persistence 0.5, lacunarity 2 → result in ~[0, 0.94). +function fbm(seed, x, y) { + let value = 0; + let amplitude = 0.5; + let frequency = 1; + for (let octave = 0; octave < 4; octave++) { + value += amplitude * valueNoise(seed + octave, x * frequency, y * frequency); + amplitude *= 0.5; + frequency *= 2; + } + return value; +} +// Biome mapping (elevation-style bands; localTileId values are solid +// full-square terrain-center tiles read off mapPack_tilesheet.png — 17 +// columns, index = row * 17 + column). +const TILE_DEEP_WATER = 185; // patterned blue (row 10, col 15) +const TILE_WATER = 169; // plain light blue (row 9, col 16) +const TILE_SAND = 18; // beige center (row 1, col 1) +const TILE_GRASS = 23; // green center (row 1, col 6) +const TILE_ROCK = 28; // gray center (row 1, col 11) +const TILE_SNOW = 86; // white center (row 5, col 1) +function biomeTileId(value) { + if (value < 0.34) + return TILE_DEEP_WATER; + if (value < 0.42) + return TILE_WATER; + if (value < 0.5) + return TILE_SAND; + if (value < 0.68) + return TILE_GRASS; + if (value < 0.8) + return TILE_ROCK; + return TILE_SNOW; +} +const app = new Application({ + canvas: { width: 1280, height: 720, mount: document.body, sizingMode: 'fit' }, + clearColor: new Color(38, 82, 128), // deep-water blue behind unloaded chunks + extensions: [tilemapExtension], +}); +class InfiniteTerrainScene extends Scene { + camera; + explorer; + worldRoot; + mapView; + terrain; + tileset; + streamer; + seed = 1337; + moveX = 0; + moveY = 0; + hudTimer = 0; + hud; + async init() { + const tilesTexture = await this.loader.load(Asset.kind('texture', assets.demo.tilesets.map.image)); + this.tileset = new TileSet({ + name: 'biomes', + texture: new TextureRegion(tilesTexture, { x: 0, y: 0, width: tilesTexture.width, height: tilesTexture.height }), + tileWidth: TILE, + tileHeight: TILE, + tileCount: 204, + columns: 17, + }); + // No width/height: the layer (and map) are unbounded — chunks exist + // only where something writes them. + this.terrain = new TileLayer({ id: 1, name: 'terrain', tileWidth: TILE, tileHeight: TILE, tilesets: [this.tileset] }); + const map = new TileMap({ name: 'infinite-world', tileWidth: TILE, tileHeight: TILE, tilesets: [this.tileset], layers: [this.terrain] }); + this.mapView = map.createView({ bands: { terrain: ['terrain'] } }); + const characters = new Spritesheet(this.loader.get(assets.demo.spritesheets.platformerCharacters.image), (await this.loader.load(Asset.kind('json', assets.demo.spritesheets.platformerCharacters.data)))); + this.explorer = characters.getFrameSprite('character_green_front').setAnchor(0.5); + this.explorer.setPosition(0, 0); + this.explorer.setScale(1.25); + const actorLayer = new Container(); + actorLayer.addChild(this.explorer); + this.worldRoot = new Container(); + this.worldRoot.addChild(this.mapView.band('terrain'), actorLayer); + // Camera follows the explorer — no setBounds: an unbounded map has no + // edges to clamp the camera to. + const { width, height } = app.canvas; + this.camera = new View(this.explorer.x, this.explorer.y, width, height); + this.camera.follow(this.explorer, { lerp: 0.12 }); + this.rebuildStreamer(); + this.setupInput(); + this.setupHud(); + } + rebuildStreamer() { + // destroy() evicts every chunk this streamer loaded; the next update() + // of the replacement streamer loads the whole initial wanted set + // unbudgeted, so a seed change repopulates the screen in one frame. + this.streamer?.destroy(); + const seed = this.seed; + const source = createSampledChunkSource(this.terrain, { + sample: (tx, ty) => fbm(seed, tx / FEATURE_SIZE, ty / FEATURE_SIZE), + mapValueToTile: value => ({ tileset: this.tileset, localTileId: biomeTileId(value), transform: TILE_TRANSFORM_IDENTITY }), + }); + this.streamer = new ChunkStreamer(this.terrain, source, this.camera); + } + setupInput() { + this.inputs.onActive(Keyboard.A, () => (this.moveX = -1)); + this.inputs.onStop(Keyboard.A, () => { + if (this.moveX < 0) + this.moveX = 0; + }); + this.inputs.onActive(Keyboard.D, () => (this.moveX = 1)); + this.inputs.onStop(Keyboard.D, () => { + if (this.moveX > 0) + this.moveX = 0; + }); + this.inputs.onActive(Keyboard.W, () => (this.moveY = -1)); + this.inputs.onStop(Keyboard.W, () => { + if (this.moveY < 0) + this.moveY = 0; + }); + this.inputs.onActive(Keyboard.S, () => (this.moveY = 1)); + this.inputs.onStop(Keyboard.S, () => { + if (this.moveY > 0) + this.moveY = 0; + }); + } + setupHud() { + this.hud = mountControls({ + title: 'Infinite Procedural Terrain', + controls: [ + { keys: 'WASD', action: 'fly across the endless world' }, + { keys: 'panel', action: 'reroll the seed' }, + ], + status: '', + hint: 'The map has no width or height. A ChunkStreamer loads chunks around the camera and evicts the ones you leave behind — revisited terrain is regenerated identically from the seed.', + }); + const panel = mountControlPanel({ title: 'World' }); + panel.addButton({ + label: 'New seed', + onClick: () => { + this.seed = (Math.random() * 0x7fffffff) | 0; + this.rebuildStreamer(); + }, + }); + } + update(delta) { + if (this.moveX !== 0 || this.moveY !== 0) { + const length = Math.hypot(this.moveX, this.moveY) || 1; + this.explorer.move((this.moveX / length) * MOVE_SPEED * delta.seconds, (this.moveY / length) * MOVE_SPEED * delta.seconds); + } + this.streamer.update(); + this.hudTimer += delta.seconds; + if (this.hudTimer >= 0.25) { + this.hudTimer = 0; + const tx = Math.floor(this.explorer.x / TILE); + const ty = Math.floor(this.explorer.y / TILE); + this.hud.setStatus(`${this.streamer.residentCount} chunks resident · tile ${tx}, ${ty} · seed ${this.seed}`); + } + } + draw(context) { + context.backend.clear(); + context.render(this.worldRoot, { view: this.camera }); + } +} +app.start(new InfiniteTerrainScene()); diff --git a/examples/tilemap/infinite-terrain.ts b/examples/tilemap/infinite-terrain.ts new file mode 100644 index 00000000..887f0809 --- /dev/null +++ b/examples/tilemap/infinite-terrain.ts @@ -0,0 +1,211 @@ +import { Application, Asset, Color, Container, Keyboard, type RenderingContext, Scene, Sprite, Spritesheet, type SpritesheetData, TextureRegion, type Time, View } from '@codexo/exojs'; +import { ChunkStreamer, createSampledChunkSource, TILE_TRANSFORM_IDENTITY, TileLayer, TileMap, tilemapExtension, TileSet, type TileMapView } from '@codexo/exojs-tilemap'; +import { mountControlPanel, mountControls } from '@examples/runtime'; + +// An infinite, procedurally generated world: the TileLayer has NO width/height +// (unbounded), and a ChunkStreamer keeps only the chunks near the camera +// resident. Terrain comes from a deterministic value-noise fBm sampler fed +// through createSampledChunkSource — same seed, same world, every visit. + +const TILE = 64; +const FEATURE_SIZE = 28; +const MOVE_SPEED = 420; + +// Deterministic integer-lattice hash → [0, 1). Any change here changes every +// world; the worker copy in the worker example must stay byte-identical. +function hash2D(seed: number, x: number, y: number): number { + let h = (seed ^ Math.imul(x, 0x27d4eb2d) ^ Math.imul(y, 0x165667b1)) | 0; + h = Math.imul(h ^ (h >>> 15), 0x85ebca6b); + h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35); + h ^= h >>> 16; + return (h >>> 0) / 4294967296; +} + +function valueNoise(seed: number, x: number, y: number): number { + const x0 = Math.floor(x); + const y0 = Math.floor(y); + const fx = x - x0; + const fy = y - y0; + const sx = fx * fx * (3 - 2 * fx); + const sy = fy * fy * (3 - 2 * fy); + const n00 = hash2D(seed, x0, y0); + const n10 = hash2D(seed, x0 + 1, y0); + const n01 = hash2D(seed, x0, y0 + 1); + const n11 = hash2D(seed, x0 + 1, y0 + 1); + const nx0 = n00 + (n10 - n00) * sx; + const nx1 = n01 + (n11 - n01) * sx; + return nx0 + (nx1 - nx0) * sy; +} + +// 4 octaves, persistence 0.5, lacunarity 2 → result in ~[0, 0.94). +function fbm(seed: number, x: number, y: number): number { + let value = 0; + let amplitude = 0.5; + let frequency = 1; + for (let octave = 0; octave < 4; octave++) { + value += amplitude * valueNoise(seed + octave, x * frequency, y * frequency); + amplitude *= 0.5; + frequency *= 2; + } + return value; +} + +// Biome mapping (elevation-style bands; localTileId values are solid +// full-square terrain-center tiles read off mapPack_tilesheet.png — 17 +// columns, index = row * 17 + column). +const TILE_DEEP_WATER = 185; // patterned blue (row 10, col 15) +const TILE_WATER = 169; // plain light blue (row 9, col 16) +const TILE_SAND = 18; // beige center (row 1, col 1) +const TILE_GRASS = 23; // green center (row 1, col 6) +const TILE_ROCK = 28; // gray center (row 1, col 11) +const TILE_SNOW = 86; // white center (row 5, col 1) + +function biomeTileId(value: number): number { + if (value < 0.34) return TILE_DEEP_WATER; + if (value < 0.42) return TILE_WATER; + if (value < 0.5) return TILE_SAND; + if (value < 0.68) return TILE_GRASS; + if (value < 0.8) return TILE_ROCK; + return TILE_SNOW; +} + +const app = new Application({ + canvas: { width: 1280, height: 720, mount: document.body, sizingMode: 'fit' }, + clearColor: new Color(38, 82, 128), // deep-water blue behind unloaded chunks + extensions: [tilemapExtension], +}); + +class InfiniteTerrainScene extends Scene { + private camera!: View; + private explorer!: Sprite; + private worldRoot!: Container; + private mapView!: TileMapView; + private terrain!: TileLayer; + private tileset!: TileSet; + private streamer!: ChunkStreamer; + private seed = 1337; + private moveX = 0; + private moveY = 0; + private hudTimer = 0; + private hud!: ReturnType; + + override async init(): Promise { + const tilesTexture = await this.loader.load(Asset.kind('texture', assets.demo.tilesets.map.image)); + this.tileset = new TileSet({ + name: 'biomes', + texture: new TextureRegion(tilesTexture, { x: 0, y: 0, width: tilesTexture.width, height: tilesTexture.height }), + tileWidth: TILE, + tileHeight: TILE, + tileCount: 204, + columns: 17, + }); + + // No width/height: the layer (and map) are unbounded — chunks exist + // only where something writes them. + this.terrain = new TileLayer({ id: 1, name: 'terrain', tileWidth: TILE, tileHeight: TILE, tilesets: [this.tileset] }); + const map = new TileMap({ name: 'infinite-world', tileWidth: TILE, tileHeight: TILE, tilesets: [this.tileset], layers: [this.terrain] }); + this.mapView = map.createView({ bands: { terrain: ['terrain'] } }); + + const characters = new Spritesheet( + this.loader.get(assets.demo.spritesheets.platformerCharacters.image), + (await this.loader.load(Asset.kind('json', assets.demo.spritesheets.platformerCharacters.data))) as SpritesheetData, + ); + + this.explorer = characters.getFrameSprite('character_green_front').setAnchor(0.5); + this.explorer.setPosition(0, 0); + this.explorer.setScale(1.25); + + const actorLayer = new Container(); + actorLayer.addChild(this.explorer); + + this.worldRoot = new Container(); + this.worldRoot.addChild(this.mapView.band('terrain'), actorLayer); + + // Camera follows the explorer — no setBounds: an unbounded map has no + // edges to clamp the camera to. + const { width, height } = app.canvas; + + this.camera = new View(this.explorer.x, this.explorer.y, width, height); + this.camera.follow(this.explorer, { lerp: 0.12 }); + + this.rebuildStreamer(); + this.setupInput(); + this.setupHud(); + } + + private rebuildStreamer(): void { + // destroy() evicts every chunk this streamer loaded; the next update() + // of the replacement streamer loads the whole initial wanted set + // unbudgeted, so a seed change repopulates the screen in one frame. + this.streamer?.destroy(); + const seed = this.seed; + const source = createSampledChunkSource(this.terrain, { + sample: (tx, ty) => fbm(seed, tx / FEATURE_SIZE, ty / FEATURE_SIZE), + mapValueToTile: value => ({ tileset: this.tileset, localTileId: biomeTileId(value), transform: TILE_TRANSFORM_IDENTITY }), + }); + this.streamer = new ChunkStreamer(this.terrain, source, this.camera); + } + + private setupInput(): void { + this.inputs.onActive(Keyboard.A, () => (this.moveX = -1)); + this.inputs.onStop(Keyboard.A, () => { + if (this.moveX < 0) this.moveX = 0; + }); + this.inputs.onActive(Keyboard.D, () => (this.moveX = 1)); + this.inputs.onStop(Keyboard.D, () => { + if (this.moveX > 0) this.moveX = 0; + }); + this.inputs.onActive(Keyboard.W, () => (this.moveY = -1)); + this.inputs.onStop(Keyboard.W, () => { + if (this.moveY < 0) this.moveY = 0; + }); + this.inputs.onActive(Keyboard.S, () => (this.moveY = 1)); + this.inputs.onStop(Keyboard.S, () => { + if (this.moveY > 0) this.moveY = 0; + }); + } + + private setupHud(): void { + this.hud = mountControls({ + title: 'Infinite Procedural Terrain', + controls: [ + { keys: 'WASD', action: 'fly across the endless world' }, + { keys: 'panel', action: 'reroll the seed' }, + ], + status: '', + hint: 'The map has no width or height. A ChunkStreamer loads chunks around the camera and evicts the ones you leave behind — revisited terrain is regenerated identically from the seed.', + }); + const panel = mountControlPanel({ title: 'World' }); + panel.addButton({ + label: 'New seed', + onClick: () => { + this.seed = (Math.random() * 0x7fffffff) | 0; + this.rebuildStreamer(); + }, + }); + } + + override update(delta: Time): void { + if (this.moveX !== 0 || this.moveY !== 0) { + const length = Math.hypot(this.moveX, this.moveY) || 1; + + this.explorer.move((this.moveX / length) * MOVE_SPEED * delta.seconds, (this.moveY / length) * MOVE_SPEED * delta.seconds); + } + + this.streamer.update(); + this.hudTimer += delta.seconds; + if (this.hudTimer >= 0.25) { + this.hudTimer = 0; + const tx = Math.floor(this.explorer.x / TILE); + const ty = Math.floor(this.explorer.y / TILE); + this.hud.setStatus(`${this.streamer.residentCount} chunks resident · tile ${tx}, ${ty} · seed ${this.seed}`); + } + } + + override draw(context: RenderingContext): void { + context.backend.clear(); + context.render(this.worldRoot, { view: this.camera }); + } +} + +app.start(new InfiniteTerrainScene()); From 88f841aad889a05d861aad699328572424e3373e Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 16 Jul 2026 07:41:10 +0200 Subject: [PATCH 2/7] docs(examples): regenerate stale ldtk-world-import.js twin The .ts source gained the unbounded width/height guard when TileLayer went optional-dimensions, but the committed generated twin was never re-synced. Surfaced by running examples:sync for the new infinite-terrain example. --- examples/tilemap/ldtk-world-import.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/tilemap/ldtk-world-import.js b/examples/tilemap/ldtk-world-import.js index 0f9bc030..7b430151 100644 --- a/examples/tilemap/ldtk-world-import.js +++ b/examples/tilemap/ldtk-world-import.js @@ -91,7 +91,7 @@ class LdtkWorldImportScene extends Scene { buildIntGridOverlay(level) { const overlay = new Graphics(); const walls = level.layers.find(layer => layer.name === 'Walls'); - if (!walls) { + if (!walls || walls.width === undefined || walls.height === undefined) { return overlay; } for (let ty = 0; ty < walls.height; ty++) { From efa488e0a20590e913964ab71ab08e70db876b0b Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 16 Jul 2026 11:36:20 +0200 Subject: [PATCH 3/7] docs(examples): worker-streamed terrain example (worker sampled chunk source) --- examples/examples.json | 17 + examples/tilemap/worker-streamed-terrain.js | 326 ++++++++++++++++++ examples/tilemap/worker-streamed-terrain.ts | 352 ++++++++++++++++++++ 3 files changed, 695 insertions(+) create mode 100644 examples/tilemap/worker-streamed-terrain.js create mode 100644 examples/tilemap/worker-streamed-terrain.ts diff --git a/examples/examples.json b/examples/examples.json index 393a1762..732bc3b9 100644 --- a/examples/examples.json +++ b/examples/examples.json @@ -1894,6 +1894,23 @@ "capabilities": [ "keyboard" ] + }, + { + "slug": "worker-streamed-terrain", + "path": "tilemap/worker-streamed-terrain.js", + "language": "typescript", + "title": "Worker-Streamed Terrain", + "description": "Move expensive terrain sampling off the main thread with createWorkerSampledChunkSource: the noise lives inside a self-contained workerSource script. Toggle sync vs. worker and raise the sampling cost to feel the main-thread jank disappear.", + "backend": "core", + "tags": [ + "tilemap", + "procedural", + "worker" + ], + "level": "advanced", + "capabilities": [ + "keyboard" + ] } ], "tweens-animation": [ diff --git a/examples/tilemap/worker-streamed-terrain.js b/examples/tilemap/worker-streamed-terrain.js new file mode 100644 index 00000000..fd7d271c --- /dev/null +++ b/examples/tilemap/worker-streamed-terrain.js @@ -0,0 +1,326 @@ +// Auto-generated from worker-streamed-terrain.ts — edit the .ts source, not this file. +import { Application, Asset, Color, Container, Keyboard, Scene, Spritesheet, TextureRegion, View } from '@codexo/exojs'; +import { ChunkStreamer, createSampledChunkSource, createWorkerSampledChunkSource, TILE_TRANSFORM_IDENTITY, TileLayer, TileMap, tilemapExtension, TileSet } from '@codexo/exojs-tilemap'; +import { mountControlPanel, mountControls } from '@examples/runtime'; +// The same infinite, procedurally generated world as "Infinite Procedural +// Terrain", but the noise sampling can run off the main thread via +// createWorkerSampledChunkSource. Toggle "Provider" between sync/worker and +// raise "Sample cost" to make each tile artificially expensive to sample — +// on the sync path the main thread stalls and the spinning marker + camera +// motion visibly hitch; on the worker path they stay smooth. +const TILE = 64; +const FEATURE_SIZE = 28; +const MOVE_SPEED = 420; +// Deterministic integer-lattice hash → [0, 1). Any change here changes every +// world; the worker copy built below must stay byte-identical. +function hash2D(seed, x, y) { + let h = (seed ^ Math.imul(x, 0x27d4eb2d) ^ Math.imul(y, 0x165667b1)) | 0; + h = Math.imul(h ^ (h >>> 15), 0x85ebca6b); + h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35); + h ^= h >>> 16; + return (h >>> 0) / 4294967296; +} +function valueNoise(seed, x, y) { + const x0 = Math.floor(x); + const y0 = Math.floor(y); + const fx = x - x0; + const fy = y - y0; + const sx = fx * fx * (3 - 2 * fx); + const sy = fy * fy * (3 - 2 * fy); + const n00 = hash2D(seed, x0, y0); + const n10 = hash2D(seed, x0 + 1, y0); + const n01 = hash2D(seed, x0, y0 + 1); + const n11 = hash2D(seed, x0 + 1, y0 + 1); + const nx0 = n00 + (n10 - n00) * sx; + const nx1 = n01 + (n11 - n01) * sx; + return nx0 + (nx1 - nx0) * sy; +} +// 4 octaves, persistence 0.5, lacunarity 2 → result in ~[0, 0.94). +function fbm(seed, x, y) { + let value = 0; + let amplitude = 0.5; + let frequency = 1; + for (let octave = 0; octave < 4; octave++) { + value += amplitude * valueNoise(seed + octave, x * frequency, y * frequency); + amplitude *= 0.5; + frequency *= 2; + } + return value; +} +// Biome mapping (elevation-style bands; localTileId values are solid +// full-square terrain-center tiles read off mapPack_tilesheet.png — 17 +// columns, index = row * 17 + column). +const TILE_DEEP_WATER = 185; // patterned blue (row 10, col 15) +const TILE_WATER = 169; // plain light blue (row 9, col 16) +const TILE_SAND = 18; // beige center (row 1, col 1) +const TILE_GRASS = 23; // green center (row 1, col 6) +const TILE_ROCK = 28; // gray center (row 1, col 11) +const TILE_SNOW = 86; // white center (row 5, col 1) +function biomeTileId(value) { + if (value < 0.34) + return TILE_DEEP_WATER; + if (value < 0.42) + return TILE_WATER; + if (value < 0.5) + return TILE_SAND; + if (value < 0.68) + return TILE_GRASS; + if (value < 0.8) + return TILE_ROCK; + return TILE_SNOW; +} +// The worker must carry its own copy of the noise code: a Blob-URL worker +// shares no scope with this module — nothing declared above exists inside +// it, which is exactly why createWorkerSampledChunkSource takes a source +// string instead of a live function. The functions below are a plain-JS +// transcription of hash2D/valueNoise/fbm above (annotations stripped only; +// the string is never type-checked) and must stay byte-identical to them — +// otherwise the worker and sync providers would render different worlds +// for the same seed. Seed and cost are baked in at build time, so changing +// either rebuilds the provider from scratch. +function buildWorkerSource(seed, extraCost) { + return ` +"use strict"; + +function hash2D(seed, x, y) { + let h = (seed ^ Math.imul(x, 0x27d4eb2d) ^ Math.imul(y, 0x165667b1)) | 0; + h = Math.imul(h ^ (h >>> 15), 0x85ebca6b); + h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35); + h ^= h >>> 16; + return (h >>> 0) / 4294967296; +} + +function valueNoise(seed, x, y) { + const x0 = Math.floor(x); + const y0 = Math.floor(y); + const fx = x - x0; + const fy = y - y0; + const sx = fx * fx * (3 - 2 * fx); + const sy = fy * fy * (3 - 2 * fy); + const n00 = hash2D(seed, x0, y0); + const n10 = hash2D(seed, x0 + 1, y0); + const n01 = hash2D(seed, x0, y0 + 1); + const n11 = hash2D(seed, x0 + 1, y0 + 1); + const nx0 = n00 + (n10 - n00) * sx; + const nx1 = n01 + (n11 - n01) * sx; + return nx0 + (nx1 - nx0) * sy; +} + +function fbm(seed, x, y) { + let value = 0; + let amplitude = 0.5; + let frequency = 1; + for (let octave = 0; octave < 4; octave++) { + value += amplitude * valueNoise(seed + octave, x * frequency, y * frequency); + amplitude *= 0.5; + frequency *= 2; + } + return value; +} + +const SEED = ${seed}; +const FEATURE_SIZE = ${FEATURE_SIZE}; +const EXTRA_COST = ${extraCost}; + +self.onmessage = (event) => { + const { requestId, cx, cy, chunkWidth, chunkHeight } = event.data; + try { + const values = new Float64Array(chunkWidth * chunkHeight); + for (let localTy = 0; localTy < chunkHeight; localTy++) { + for (let localTx = 0; localTx < chunkWidth; localTx++) { + const tx = cx * chunkWidth + localTx; + const ty = cy * chunkHeight + localTy; + let value = fbm(SEED, tx / FEATURE_SIZE, ty / FEATURE_SIZE); + // Burns deterministic CPU to simulate an expensive sampler — + // the recomputed value is discarded except for the last pass. + for (let i = 0; i < EXTRA_COST; i++) { + value = fbm(SEED, tx / FEATURE_SIZE, ty / FEATURE_SIZE); + } + values[localTy * chunkWidth + localTx] = value; + } + } + // Exactly one reply per request, transferring the buffer for a + // zero-copy handoff back to the main thread. + self.postMessage({ requestId, values }, [values.buffer]); + } catch (error) { + // Still exactly one reply — the error branch must also answer, or + // ChunkStreamer treats this chunk as forever in flight (no timeout). + self.postMessage({ requestId, error: String(error) }); + } +}; +`; +} +const app = new Application({ + canvas: { width: 1280, height: 720, mount: document.body, sizingMode: 'fit' }, + clearColor: new Color(38, 82, 128), // deep-water blue behind unloaded chunks + extensions: [tilemapExtension], +}); +class WorkerStreamedTerrainScene extends Scene { + camera; + explorer; + marker; + worldRoot; + mapView; + terrain; + tileset; + streamer; + seed = 1337; + providerMode = 'worker'; + extraCost = 200; + workerSourceHandle = null; + moveX = 0; + moveY = 0; + hudTimer = 0; + frameMs = 0; + hud; + async init() { + const tilesTexture = await this.loader.load(Asset.kind('texture', assets.demo.tilesets.map.image)); + this.tileset = new TileSet({ + name: 'biomes', + texture: new TextureRegion(tilesTexture, { x: 0, y: 0, width: tilesTexture.width, height: tilesTexture.height }), + tileWidth: TILE, + tileHeight: TILE, + tileCount: 204, + columns: 17, + }); + // No width/height: the layer (and map) are unbounded — chunks exist + // only where something writes them. + this.terrain = new TileLayer({ id: 1, name: 'terrain', tileWidth: TILE, tileHeight: TILE, tilesets: [this.tileset] }); + const map = new TileMap({ name: 'infinite-world', tileWidth: TILE, tileHeight: TILE, tilesets: [this.tileset], layers: [this.terrain] }); + this.mapView = map.createView({ bands: { terrain: ['terrain'] } }); + const characters = new Spritesheet(this.loader.get(assets.demo.spritesheets.platformerCharacters.image), (await this.loader.load(Asset.kind('json', assets.demo.spritesheets.platformerCharacters.data)))); + this.explorer = characters.getFrameSprite('character_green_front').setAnchor(0.5); + this.explorer.setPosition(0, 0); + this.explorer.setScale(1.25); + // Jank indicator: this sprite spins at a constant rate every frame + // regardless of provider mode — any hitch on the main thread (the + // sync provider under a high sample cost) is immediately visible as + // a stutter in its rotation. + this.marker = characters.getFrameSprite('character_beige_front').setAnchor(0.5); + this.marker.setScale(0.75); + this.marker.setPosition(96, -96); + const actorLayer = new Container(); + actorLayer.addChild(this.explorer, this.marker); + this.worldRoot = new Container(); + this.worldRoot.addChild(this.mapView.band('terrain'), actorLayer); + // Camera follows the explorer — no setBounds: an unbounded map has no + // edges to clamp the camera to. + const { width, height } = app.canvas; + this.camera = new View(this.explorer.x, this.explorer.y, width, height); + this.camera.follow(this.explorer, { lerp: 0.12 }); + this.rebuildStreamer(); + this.setupInput(); + this.setupHud(); + } + rebuildStreamer() { + // destroy() evicts every chunk this streamer loaded; the next + // update() of the replacement streamer loads the whole initial + // wanted set unbudgeted, so a mode/cost/seed change repopulates the + // screen in one frame. + this.streamer?.destroy(); + // Terminate the previous Worker on every rebuild — it leaks + // otherwise, since createWorkerSampledChunkSource has no lifecycle + // hook of its own beyond the destroy() it returns. + this.workerSourceHandle?.destroy(); + this.workerSourceHandle = null; + const seed = this.seed; + const cost = this.extraCost; + if (this.providerMode === 'worker') { + this.workerSourceHandle = createWorkerSampledChunkSource(this.terrain, { + workerSource: buildWorkerSource(seed, cost), + mapValueToTile: value => ({ tileset: this.tileset, localTileId: biomeTileId(value), transform: TILE_TRANSFORM_IDENTITY }), + }); + this.streamer = new ChunkStreamer(this.terrain, this.workerSourceHandle, this.camera); + } + else { + const source = createSampledChunkSource(this.terrain, { + sample: (tx, ty) => { + let value = fbm(seed, tx / FEATURE_SIZE, ty / FEATURE_SIZE); + for (let i = 0; i < cost; i++) { + value = fbm(seed, tx / FEATURE_SIZE, ty / FEATURE_SIZE); + } + return value; + }, + mapValueToTile: value => ({ tileset: this.tileset, localTileId: biomeTileId(value), transform: TILE_TRANSFORM_IDENTITY }), + }); + this.streamer = new ChunkStreamer(this.terrain, source, this.camera); + } + } + setupInput() { + this.inputs.onActive(Keyboard.A, () => (this.moveX = -1)); + this.inputs.onStop(Keyboard.A, () => { + if (this.moveX < 0) + this.moveX = 0; + }); + this.inputs.onActive(Keyboard.D, () => (this.moveX = 1)); + this.inputs.onStop(Keyboard.D, () => { + if (this.moveX > 0) + this.moveX = 0; + }); + this.inputs.onActive(Keyboard.W, () => (this.moveY = -1)); + this.inputs.onStop(Keyboard.W, () => { + if (this.moveY < 0) + this.moveY = 0; + }); + this.inputs.onActive(Keyboard.S, () => (this.moveY = 1)); + this.inputs.onStop(Keyboard.S, () => { + if (this.moveY > 0) + this.moveY = 0; + }); + } + setupHud() { + this.hud = mountControls({ + title: 'Worker-Streamed Terrain', + controls: [ + { keys: 'WASD', action: 'fly across the endless world' }, + { keys: 'panel', action: 'switch provider / raise sample cost' }, + ], + status: '', + hint: 'createWorkerSampledChunkSource runs the noise sampler on a Worker thread; createSampledChunkSource runs it on the main thread. Raise the sample cost and switch providers to see which one keeps the frame time flat.', + }); + const panel = mountControlPanel({ title: 'Provider' }); + panel.addCycle({ + label: 'Provider', + options: ['worker', 'sync'], + index: 0, + onChange: (_, mode) => { + this.providerMode = mode; + this.rebuildStreamer(); + }, + }); + panel.addSlider({ + label: 'Sample cost', + min: 0, + max: 500, + step: 50, + value: 200, + onChange: value => { + this.extraCost = value; + this.rebuildStreamer(); + }, + }); + } + update(delta) { + if (this.moveX !== 0 || this.moveY !== 0) { + const length = Math.hypot(this.moveX, this.moveY) || 1; + this.explorer.move((this.moveX / length) * MOVE_SPEED * delta.seconds, (this.moveY / length) * MOVE_SPEED * delta.seconds); + } + this.marker.rotation += 2 * delta.seconds; + this.streamer.update(); + // Exponential moving average smooths out single-frame noise so the + // readout reflects sustained jank rather than every GC blip. + this.frameMs = this.frameMs * 0.9 + delta.milliseconds * 0.1; + this.hudTimer += delta.seconds; + if (this.hudTimer >= 0.25) { + this.hudTimer = 0; + const tx = Math.floor(this.explorer.x / TILE); + const ty = Math.floor(this.explorer.y / TILE); + this.hud.setStatus(`${this.providerMode} · ${this.frameMs.toFixed(1)} ms/frame · ${this.streamer.residentCount} chunks · tile ${tx}, ${ty} · cost ${this.extraCost}`); + } + } + draw(context) { + context.backend.clear(); + context.render(this.worldRoot, { view: this.camera }); + } +} +app.start(new WorkerStreamedTerrainScene()); diff --git a/examples/tilemap/worker-streamed-terrain.ts b/examples/tilemap/worker-streamed-terrain.ts new file mode 100644 index 00000000..8739c00e --- /dev/null +++ b/examples/tilemap/worker-streamed-terrain.ts @@ -0,0 +1,352 @@ +import { Application, Asset, Color, Container, Keyboard, type RenderingContext, Scene, Sprite, Spritesheet, type SpritesheetData, TextureRegion, type Time, View } from '@codexo/exojs'; +import { ChunkStreamer, type ChunkSource, createSampledChunkSource, createWorkerSampledChunkSource, TILE_TRANSFORM_IDENTITY, TileLayer, TileMap, tilemapExtension, TileSet, type TileMapView } from '@codexo/exojs-tilemap'; +import { mountControlPanel, mountControls } from '@examples/runtime'; + +// The same infinite, procedurally generated world as "Infinite Procedural +// Terrain", but the noise sampling can run off the main thread via +// createWorkerSampledChunkSource. Toggle "Provider" between sync/worker and +// raise "Sample cost" to make each tile artificially expensive to sample — +// on the sync path the main thread stalls and the spinning marker + camera +// motion visibly hitch; on the worker path they stay smooth. + +const TILE = 64; +const FEATURE_SIZE = 28; +const MOVE_SPEED = 420; + +// Deterministic integer-lattice hash → [0, 1). Any change here changes every +// world; the worker copy built below must stay byte-identical. +function hash2D(seed: number, x: number, y: number): number { + let h = (seed ^ Math.imul(x, 0x27d4eb2d) ^ Math.imul(y, 0x165667b1)) | 0; + h = Math.imul(h ^ (h >>> 15), 0x85ebca6b); + h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35); + h ^= h >>> 16; + return (h >>> 0) / 4294967296; +} + +function valueNoise(seed: number, x: number, y: number): number { + const x0 = Math.floor(x); + const y0 = Math.floor(y); + const fx = x - x0; + const fy = y - y0; + const sx = fx * fx * (3 - 2 * fx); + const sy = fy * fy * (3 - 2 * fy); + const n00 = hash2D(seed, x0, y0); + const n10 = hash2D(seed, x0 + 1, y0); + const n01 = hash2D(seed, x0, y0 + 1); + const n11 = hash2D(seed, x0 + 1, y0 + 1); + const nx0 = n00 + (n10 - n00) * sx; + const nx1 = n01 + (n11 - n01) * sx; + return nx0 + (nx1 - nx0) * sy; +} + +// 4 octaves, persistence 0.5, lacunarity 2 → result in ~[0, 0.94). +function fbm(seed: number, x: number, y: number): number { + let value = 0; + let amplitude = 0.5; + let frequency = 1; + for (let octave = 0; octave < 4; octave++) { + value += amplitude * valueNoise(seed + octave, x * frequency, y * frequency); + amplitude *= 0.5; + frequency *= 2; + } + return value; +} + +// Biome mapping (elevation-style bands; localTileId values are solid +// full-square terrain-center tiles read off mapPack_tilesheet.png — 17 +// columns, index = row * 17 + column). +const TILE_DEEP_WATER = 185; // patterned blue (row 10, col 15) +const TILE_WATER = 169; // plain light blue (row 9, col 16) +const TILE_SAND = 18; // beige center (row 1, col 1) +const TILE_GRASS = 23; // green center (row 1, col 6) +const TILE_ROCK = 28; // gray center (row 1, col 11) +const TILE_SNOW = 86; // white center (row 5, col 1) + +function biomeTileId(value: number): number { + if (value < 0.34) return TILE_DEEP_WATER; + if (value < 0.42) return TILE_WATER; + if (value < 0.5) return TILE_SAND; + if (value < 0.68) return TILE_GRASS; + if (value < 0.8) return TILE_ROCK; + return TILE_SNOW; +} + +// The worker must carry its own copy of the noise code: a Blob-URL worker +// shares no scope with this module — nothing declared above exists inside +// it, which is exactly why createWorkerSampledChunkSource takes a source +// string instead of a live function. The functions below are a plain-JS +// transcription of hash2D/valueNoise/fbm above (annotations stripped only; +// the string is never type-checked) and must stay byte-identical to them — +// otherwise the worker and sync providers would render different worlds +// for the same seed. Seed and cost are baked in at build time, so changing +// either rebuilds the provider from scratch. +function buildWorkerSource(seed: number, extraCost: number): string { + return ` +"use strict"; + +function hash2D(seed, x, y) { + let h = (seed ^ Math.imul(x, 0x27d4eb2d) ^ Math.imul(y, 0x165667b1)) | 0; + h = Math.imul(h ^ (h >>> 15), 0x85ebca6b); + h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35); + h ^= h >>> 16; + return (h >>> 0) / 4294967296; +} + +function valueNoise(seed, x, y) { + const x0 = Math.floor(x); + const y0 = Math.floor(y); + const fx = x - x0; + const fy = y - y0; + const sx = fx * fx * (3 - 2 * fx); + const sy = fy * fy * (3 - 2 * fy); + const n00 = hash2D(seed, x0, y0); + const n10 = hash2D(seed, x0 + 1, y0); + const n01 = hash2D(seed, x0, y0 + 1); + const n11 = hash2D(seed, x0 + 1, y0 + 1); + const nx0 = n00 + (n10 - n00) * sx; + const nx1 = n01 + (n11 - n01) * sx; + return nx0 + (nx1 - nx0) * sy; +} + +function fbm(seed, x, y) { + let value = 0; + let amplitude = 0.5; + let frequency = 1; + for (let octave = 0; octave < 4; octave++) { + value += amplitude * valueNoise(seed + octave, x * frequency, y * frequency); + amplitude *= 0.5; + frequency *= 2; + } + return value; +} + +const SEED = ${seed}; +const FEATURE_SIZE = ${FEATURE_SIZE}; +const EXTRA_COST = ${extraCost}; + +self.onmessage = (event) => { + const { requestId, cx, cy, chunkWidth, chunkHeight } = event.data; + try { + const values = new Float64Array(chunkWidth * chunkHeight); + for (let localTy = 0; localTy < chunkHeight; localTy++) { + for (let localTx = 0; localTx < chunkWidth; localTx++) { + const tx = cx * chunkWidth + localTx; + const ty = cy * chunkHeight + localTy; + let value = fbm(SEED, tx / FEATURE_SIZE, ty / FEATURE_SIZE); + // Burns deterministic CPU to simulate an expensive sampler — + // the recomputed value is discarded except for the last pass. + for (let i = 0; i < EXTRA_COST; i++) { + value = fbm(SEED, tx / FEATURE_SIZE, ty / FEATURE_SIZE); + } + values[localTy * chunkWidth + localTx] = value; + } + } + // Exactly one reply per request, transferring the buffer for a + // zero-copy handoff back to the main thread. + self.postMessage({ requestId, values }, [values.buffer]); + } catch (error) { + // Still exactly one reply — the error branch must also answer, or + // ChunkStreamer treats this chunk as forever in flight (no timeout). + self.postMessage({ requestId, error: String(error) }); + } +}; +`; +} + +const app = new Application({ + canvas: { width: 1280, height: 720, mount: document.body, sizingMode: 'fit' }, + clearColor: new Color(38, 82, 128), // deep-water blue behind unloaded chunks + extensions: [tilemapExtension], +}); + +class WorkerStreamedTerrainScene extends Scene { + private camera!: View; + private explorer!: Sprite; + private marker!: Sprite; + private worldRoot!: Container; + private mapView!: TileMapView; + private terrain!: TileLayer; + private tileset!: TileSet; + private streamer!: ChunkStreamer; + private seed = 1337; + private providerMode: 'worker' | 'sync' = 'worker'; + private extraCost = 200; + private workerSourceHandle: (ChunkSource & { destroy(): void }) | null = null; + private moveX = 0; + private moveY = 0; + private hudTimer = 0; + private frameMs = 0; + private hud!: ReturnType; + + override async init(): Promise { + const tilesTexture = await this.loader.load(Asset.kind('texture', assets.demo.tilesets.map.image)); + this.tileset = new TileSet({ + name: 'biomes', + texture: new TextureRegion(tilesTexture, { x: 0, y: 0, width: tilesTexture.width, height: tilesTexture.height }), + tileWidth: TILE, + tileHeight: TILE, + tileCount: 204, + columns: 17, + }); + + // No width/height: the layer (and map) are unbounded — chunks exist + // only where something writes them. + this.terrain = new TileLayer({ id: 1, name: 'terrain', tileWidth: TILE, tileHeight: TILE, tilesets: [this.tileset] }); + const map = new TileMap({ name: 'infinite-world', tileWidth: TILE, tileHeight: TILE, tilesets: [this.tileset], layers: [this.terrain] }); + this.mapView = map.createView({ bands: { terrain: ['terrain'] } }); + + const characters = new Spritesheet( + this.loader.get(assets.demo.spritesheets.platformerCharacters.image), + (await this.loader.load(Asset.kind('json', assets.demo.spritesheets.platformerCharacters.data))) as SpritesheetData, + ); + + this.explorer = characters.getFrameSprite('character_green_front').setAnchor(0.5); + this.explorer.setPosition(0, 0); + this.explorer.setScale(1.25); + + // Jank indicator: this sprite spins at a constant rate every frame + // regardless of provider mode — any hitch on the main thread (the + // sync provider under a high sample cost) is immediately visible as + // a stutter in its rotation. + this.marker = characters.getFrameSprite('character_beige_front').setAnchor(0.5); + this.marker.setScale(0.75); + this.marker.setPosition(96, -96); + + const actorLayer = new Container(); + actorLayer.addChild(this.explorer, this.marker); + + this.worldRoot = new Container(); + this.worldRoot.addChild(this.mapView.band('terrain'), actorLayer); + + // Camera follows the explorer — no setBounds: an unbounded map has no + // edges to clamp the camera to. + const { width, height } = app.canvas; + + this.camera = new View(this.explorer.x, this.explorer.y, width, height); + this.camera.follow(this.explorer, { lerp: 0.12 }); + + this.rebuildStreamer(); + this.setupInput(); + this.setupHud(); + } + + private rebuildStreamer(): void { + // destroy() evicts every chunk this streamer loaded; the next + // update() of the replacement streamer loads the whole initial + // wanted set unbudgeted, so a mode/cost/seed change repopulates the + // screen in one frame. + this.streamer?.destroy(); + // Terminate the previous Worker on every rebuild — it leaks + // otherwise, since createWorkerSampledChunkSource has no lifecycle + // hook of its own beyond the destroy() it returns. + this.workerSourceHandle?.destroy(); + this.workerSourceHandle = null; + + const seed = this.seed; + const cost = this.extraCost; + if (this.providerMode === 'worker') { + this.workerSourceHandle = createWorkerSampledChunkSource(this.terrain, { + workerSource: buildWorkerSource(seed, cost), + mapValueToTile: value => ({ tileset: this.tileset, localTileId: biomeTileId(value), transform: TILE_TRANSFORM_IDENTITY }), + }); + this.streamer = new ChunkStreamer(this.terrain, this.workerSourceHandle, this.camera); + } else { + const source = createSampledChunkSource(this.terrain, { + sample: (tx, ty) => { + let value = fbm(seed, tx / FEATURE_SIZE, ty / FEATURE_SIZE); + for (let i = 0; i < cost; i++) { + value = fbm(seed, tx / FEATURE_SIZE, ty / FEATURE_SIZE); + } + return value; + }, + mapValueToTile: value => ({ tileset: this.tileset, localTileId: biomeTileId(value), transform: TILE_TRANSFORM_IDENTITY }), + }); + this.streamer = new ChunkStreamer(this.terrain, source, this.camera); + } + } + + private setupInput(): void { + this.inputs.onActive(Keyboard.A, () => (this.moveX = -1)); + this.inputs.onStop(Keyboard.A, () => { + if (this.moveX < 0) this.moveX = 0; + }); + this.inputs.onActive(Keyboard.D, () => (this.moveX = 1)); + this.inputs.onStop(Keyboard.D, () => { + if (this.moveX > 0) this.moveX = 0; + }); + this.inputs.onActive(Keyboard.W, () => (this.moveY = -1)); + this.inputs.onStop(Keyboard.W, () => { + if (this.moveY < 0) this.moveY = 0; + }); + this.inputs.onActive(Keyboard.S, () => (this.moveY = 1)); + this.inputs.onStop(Keyboard.S, () => { + if (this.moveY > 0) this.moveY = 0; + }); + } + + private setupHud(): void { + this.hud = mountControls({ + title: 'Worker-Streamed Terrain', + controls: [ + { keys: 'WASD', action: 'fly across the endless world' }, + { keys: 'panel', action: 'switch provider / raise sample cost' }, + ], + status: '', + hint: 'createWorkerSampledChunkSource runs the noise sampler on a Worker thread; createSampledChunkSource runs it on the main thread. Raise the sample cost and switch providers to see which one keeps the frame time flat.', + }); + const panel = mountControlPanel({ title: 'Provider' }); + panel.addCycle({ + label: 'Provider', + options: ['worker', 'sync'], + index: 0, + onChange: (_, mode) => { + this.providerMode = mode as 'worker' | 'sync'; + this.rebuildStreamer(); + }, + }); + panel.addSlider({ + label: 'Sample cost', + min: 0, + max: 500, + step: 50, + value: 200, + onChange: value => { + this.extraCost = value; + this.rebuildStreamer(); + }, + }); + } + + override update(delta: Time): void { + if (this.moveX !== 0 || this.moveY !== 0) { + const length = Math.hypot(this.moveX, this.moveY) || 1; + + this.explorer.move((this.moveX / length) * MOVE_SPEED * delta.seconds, (this.moveY / length) * MOVE_SPEED * delta.seconds); + } + + this.marker.rotation += 2 * delta.seconds; + + this.streamer.update(); + + // Exponential moving average smooths out single-frame noise so the + // readout reflects sustained jank rather than every GC blip. + this.frameMs = this.frameMs * 0.9 + delta.milliseconds * 0.1; + + this.hudTimer += delta.seconds; + if (this.hudTimer >= 0.25) { + this.hudTimer = 0; + const tx = Math.floor(this.explorer.x / TILE); + const ty = Math.floor(this.explorer.y / TILE); + this.hud.setStatus( + `${this.providerMode} · ${this.frameMs.toFixed(1)} ms/frame · ${this.streamer.residentCount} chunks · tile ${tx}, ${ty} · cost ${this.extraCost}`, + ); + } + } + + override draw(context: RenderingContext): void { + context.backend.clear(); + context.render(this.worldRoot, { view: this.camera }); + } +} + +app.start(new WorkerStreamedTerrainScene()); From b262a729f8000e943c7372e6728173038b7161a7 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 16 Jul 2026 11:46:22 +0200 Subject: [PATCH 4/7] =?UTF-8?q?docs(guide):=20infinite=20maps=20chapter=20?= =?UTF-8?q?=E2=80=94=20unbounded=20layers,=20chunk=20streaming,=20procedur?= =?UTF-8?q?al=20providers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/guide/rendering/infinite-maps.mdx | 279 ++++++++++++++++++ .../guide/rendering/retained-containers.mdx | 2 + site/src/lib/guide-structure.ts | 12 + 3 files changed, 293 insertions(+) create mode 100644 site/src/content/guide/rendering/infinite-maps.mdx diff --git a/site/src/content/guide/rendering/infinite-maps.mdx b/site/src/content/guide/rendering/infinite-maps.mdx new file mode 100644 index 00000000..0d5a1519 --- /dev/null +++ b/site/src/content/guide/rendering/infinite-maps.mdx @@ -0,0 +1,279 @@ +--- +title: 'Infinite maps' +description: 'Leave width and height off a TileLayer and let a ChunkStreamer keep only the chunks near the camera resident — procedurally sampled on the main thread, off-thread in a Worker, or re-sliced from a Tiled infinite map.' +--- + +import ExamplePreview from '../../../components/ExamplePreview.astro'; +import Callout from '../../../components/Callout.astro'; + +# Infinite maps + +Most tilemaps have edges: a fixed `width` and `height`, a grid you can walk to the end of. An **unbounded** map has none. You construct a [`TileLayer`](/ExoJS/en/api/tile-layer/) (and its [`TileMap`](/ExoJS/en/api/tile-map/)) with no `width` or `height`, and chunk storage stops being clamped to a fixed grid — any signed chunk coordinate becomes valid. Chunks exist only where something writes them; the rest of the plane is simply empty until you populate it. + +Everything else is the ordinary tilemap machinery you already know. Rendering, culling, band composition, parallax, and pixel snapping all work exactly as they do on a bounded map — an unbounded layer is not a special renderer, just a layer that never says "no" to a coordinate. What is new is the piece that decides _which_ chunks are worth keeping in memory as the camera moves, and where their tiles come from: a [`ChunkStreamer`](/ExoJS/en/api/chunk-streamer/) driven by a [`ChunkSource`](/ExoJS/en/api/chunk-source/). + +## Unbounded layers + +Omit `width` and `height` together (they are paired — provide both or neither) and the layer is unbounded. Two properties report the difference: + +- [`bounded`](/ExoJS/en/api/tile-layer/) is `false`. +- `pixelWidth` and `pixelHeight` are `undefined` — **not** `Infinity`. There is no finite extent to report, so there is no number to give. + +```ts +import { TileLayer, TileMap } from '@codexo/exojs-tilemap'; + +// No width/height: the layer (and map) are unbounded. Chunk storage now +// accepts any signed chunk coordinate instead of a fixed 0..N grid. +const terrain = new TileLayer({ id: 1, name: 'terrain', tileWidth: 64, tileHeight: 64, tilesets: [] }); +const map = new TileMap({ name: 'infinite-world', tileWidth: 64, tileHeight: 64, layers: [terrain] }); + +terrain.bounded; // false +terrain.pixelWidth; // undefined — never Infinity +map.pixelHeight; // undefined +``` + +Because there is no finite extent, there is nothing to clamp a camera to. Do not call `setBounds` on the [`View`](/ExoJS/en/api/view/) that follows your player — an unbounded world has no edges, and clamping to a bound it does not have would just pin the camera at the origin. Let the view follow freely. + +## Streaming chunks with ChunkStreamer + +A `ChunkStreamer` watches a `View` and keeps the chunks near it resident: chunks that scroll into range are requested from a `ChunkSource` and installed, chunks that scroll far enough away are evicted. It touches only the layer's data — the tilemap scene node reacts to adopt/evict on its own — so it has no rendering dependency. Construct one with the layer, a source, and the view, then tick it once per frame from your update loop: + +```ts +import { View } from '@codexo/exojs'; +import { ChunkStreamer, createSampledChunkSource, TileLayer } from '@codexo/exojs-tilemap'; + +const terrain = new TileLayer({ id: 1, name: 'terrain', tileWidth: 64, tileHeight: 64, tilesets: [] }); +const view = new View(0, 0, 1280, 720); + +// Any ChunkSource works; the next section builds a real procedural one. This +// placeholder leaves every chunk empty. +const source = createSampledChunkSource(terrain, { + sample: () => 0, + mapValueToTile: () => null, +}); + +// loadRadius / unloadRadius are a hysteresis band (defaults 1 / 2). The gap +// between them prevents load/unload thrashing when the view sits on a chunk +// boundary. unloadRadius must be >= loadRadius or the constructor throws. +const streamer = new ChunkStreamer(terrain, source, view, { + loadRadius: 2, + unloadRadius: 3, + maxChunkLoadsPerFrame: 8, +}); + +// Tick from update(). The very first call loads the whole initial wanted set +// unbudgeted, so the starting screen never pops in; every later call is +// capped at maxChunkLoadsPerFrame (default 8). +streamer.update(); + +// On teardown, destroy() evicts exactly the chunks this instance loaded — +// nothing that predated it or another source installed. Idempotent. +streamer.destroy(); +``` + +Chunks within `loadRadius` chunk-units (Chebyshev distance) of the visible range are loaded; own-resident chunks beyond `unloadRadius` are evicted. The `maxChunkLoadsPerFrame` budget spreads the cost of a fast camera across frames rather than loading an unbounded burst in one — with the deliberate exception of the first `update()`, which loads everything the starting view wants at once. The streamer tracks only its _own_ resident set, so several sources can coexist on one layer without fighting over each other's chunks. + +It works on bounded layers too. A large finite map that you do not want fully resident benefits from the same eviction; there the wanted range is simply clamped to the layer's [`chunkRange()`](/ExoJS/en/api/tile-layer/) so the streamer never asks for chunks outside the grid. + + +Evicted chunks are thrown away and regenerated from the source the next time you revisit them. If the source is not a pure function of tile coordinates (plus a fixed seed), the regenerated chunk differs from the one you saw before — terrain silently rewrites itself behind the player's back as they walk away and come back. Same seed in, same world out: that is the whole contract. No `Math.random()` anywhere on the sampling path. + + +## Procedural terrain with createSampledChunkSource + +[`createSampledChunkSource`](/ExoJS/en/api/tilemap-functions/) turns a per-tile sampling function into a `ChunkSource`. It takes two callbacks, and both are required on purpose — the engine has no opinion on which noise algorithm you use or how your tilesheet's ids map to values: + +- `sample(tx, ty)` returns a scalar for one tile. +- `mapValueToTile(value, tx, ty)` turns that scalar into a resolved tile (a `tileset` + `localTileId` + transform), or `null` for an empty cell. + +Both must be pure and deterministic, for the reason the callout above spells out. Here is a complete provider built on value-noise fBm and elevation-style biome bands: + +```ts +import { View } from '@codexo/exojs'; +import { ChunkStreamer, createSampledChunkSource, TILE_TRANSFORM_IDENTITY, TileLayer, type TileSet } from '@codexo/exojs-tilemap'; + +// The tileset you built from your tilesheet (see the Tilesets guide). +declare const tileset: TileSet; + +const FEATURE_SIZE = 28; // tiles per base-octave noise cell + +// Deterministic integer-lattice hash → [0, 1). Any change here changes every +// world. +function hash2D(seed: number, x: number, y: number): number { + let h = (seed ^ Math.imul(x, 0x27d4eb2d) ^ Math.imul(y, 0x165667b1)) | 0; + h = Math.imul(h ^ (h >>> 15), 0x85ebca6b); + h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35); + h ^= h >>> 16; + return (h >>> 0) / 4294967296; +} + +function valueNoise(seed: number, x: number, y: number): number { + const x0 = Math.floor(x); + const y0 = Math.floor(y); + const fx = x - x0; + const fy = y - y0; + const sx = fx * fx * (3 - 2 * fx); + const sy = fy * fy * (3 - 2 * fy); + const n00 = hash2D(seed, x0, y0); + const n10 = hash2D(seed, x0 + 1, y0); + const n01 = hash2D(seed, x0, y0 + 1); + const n11 = hash2D(seed, x0 + 1, y0 + 1); + const nx0 = n00 + (n10 - n00) * sx; + const nx1 = n01 + (n11 - n01) * sx; + return nx0 + (nx1 - nx0) * sy; +} + +// 4 octaves, persistence 0.5, lacunarity 2 → result in ~[0, 0.94). +function fbm(seed: number, x: number, y: number): number { + let value = 0; + let amplitude = 0.5; + let frequency = 1; + for (let octave = 0; octave < 4; octave++) { + value += amplitude * valueNoise(seed + octave, x * frequency, y * frequency); + amplitude *= 0.5; + frequency *= 2; + } + return value; +} + +// Elevation bands → solid terrain-center tile ids in the tilesheet. +const TILE_DEEP_WATER = 185; +const TILE_WATER = 169; +const TILE_SAND = 18; +const TILE_GRASS = 23; +const TILE_ROCK = 28; +const TILE_SNOW = 86; + +function biomeTileId(value: number): number { + if (value < 0.34) return TILE_DEEP_WATER; + if (value < 0.42) return TILE_WATER; + if (value < 0.5) return TILE_SAND; + if (value < 0.68) return TILE_GRASS; + if (value < 0.8) return TILE_ROCK; + return TILE_SNOW; +} + +const terrain = new TileLayer({ id: 1, name: 'terrain', tileWidth: 64, tileHeight: 64, tilesets: [tileset] }); +const view = new View(0, 0, 1280, 720); + +const seed = 1337; +const source = createSampledChunkSource(terrain, { + sample: (tx, ty) => fbm(seed, tx / FEATURE_SIZE, ty / FEATURE_SIZE), + mapValueToTile: value => ({ tileset, localTileId: biomeTileId(value), transform: TILE_TRANSFORM_IDENTITY }), +}); + +const streamer = new ChunkStreamer(terrain, source, view); +``` + +The noise here is illustrative. Swap `sample` for a Simplex generator, a third-party noise library, or a heightmap lookup — anything pure and deterministic works, because the provider makes no assumptions about it beyond that contract. + + + +## Off the main thread with createWorkerSampledChunkSource + +A sampling function that is expensive per tile can stall the main thread while a chunk generates — the frame hitches, the camera stutters. [`createWorkerSampledChunkSource`](/ExoJS/en/api/tilemap-functions/) is the async counterpart that runs the sampler on a Web Worker instead. + +You cannot hand it a live function: functions cannot cross a `postMessage` boundary. Instead you pass `workerSource` — a complete, self-contained worker script as a **string** (a template literal in your own module). It is Blob-URL'd into a real `Worker` at construction, the same technique the audio package uses to spin up AudioWorklet processors from a source string. Because it shares no scope with your module, the worker must carry its own copy of the sampling code. The script implements a small request/response protocol: + +```ts +import { createWorkerSampledChunkSource, TILE_TRANSFORM_IDENTITY, type TileLayer, type TileSet } from '@codexo/exojs-tilemap'; + +declare const terrain: TileLayer; +declare const tileset: TileSet; + +// A complete worker script. It shares no scope with this module, so it must +// carry its own copy of the sampling code — nothing outside this string exists +// inside the worker. +const workerSource = ` +"use strict"; + +// Your sampler, transcribed to plain JS (kept byte-identical to the main-thread +// copy so both render the same world for the same seed). +function sample(tx, ty) { + return (Math.sin(tx * 0.1) + Math.cos(ty * 0.1)) * 0.25 + 0.5; +} + +self.onmessage = (event) => { + const { requestId, cx, cy, chunkWidth, chunkHeight } = event.data; + try { + const values = new Float64Array(chunkWidth * chunkHeight); + for (let localTy = 0; localTy < chunkHeight; localTy++) { + for (let localTx = 0; localTx < chunkWidth; localTx++) { + const tx = cx * chunkWidth + localTx; + const ty = cy * chunkHeight + localTy; + values[localTy * chunkWidth + localTx] = sample(tx, ty); + } + } + // Reply once, transferring the buffer for a zero-copy handoff. + self.postMessage({ requestId, values }, [values.buffer]); + } catch (error) { + // The error path must ALSO reply, with the same requestId. + self.postMessage({ requestId, error: String(error) }); + } +}; +`; + +// mapValueToTile always stays on the main thread — a ResolvedTile references a +// TileSet/Texture, neither of which can cross a postMessage boundary. +const source = createWorkerSampledChunkSource(terrain, { + workerSource, + mapValueToTile: value => ({ tileset, localTileId: value < 0.5 ? 169 : 23, transform: TILE_TRANSFORM_IDENTITY }), +}); + +// The returned source owns a real Worker — you MUST call destroy() when done +// (e.g. alongside ChunkStreamer.destroy()). ChunkSource has no lifecycle hook, +// so this is not automatic; the Worker leaks otherwise. +source.destroy(); +``` + +The worker receives `{ requestId, cx, cy, chunkWidth, chunkHeight }`, computes one value per tile in row-major order (`localTy * chunkWidth + localTx`), and replies with `{ requestId, values }` — transferring `values.buffer` so the array moves without a copy. The main-thread `mapValueToTile` then turns each value into a tile, exactly as in the sync provider. + +If your deployment sets a Content-Security-Policy, it must permit `blob:` in `worker-src` (or `script-src` as a fallback) — that is how the source string becomes a `Worker`. + + +Every request must get exactly one reply — a success or an error message — bearing the same `requestId`. A request left unanswered pins its chunk as in-flight forever: there is no timeout and no retry, so the streamer will never ask for that chunk again. The error path matters just as much as the success path: if your sampler throws, catch it and post `{ requestId, error }` rather than letting the exception swallow the reply. + + + + +## Streaming a Tiled infinite map + +You do not have to generate terrain procedurally — you can stream a hand-authored infinite map made in Tiled. [`TiledMap.toTileMap()`](/ExoJS/en/api/tiled-map/) accepts `infinite: true` maps: each chunked tile layer converts to an **unbounded** runtime `TileLayer` with no tiles populated eagerly. Its data streams in on demand through a `ChunkSource` that `toTileMap()` builds as a side effect, retrievable with [`getChunkSource(layerId)`](/ExoJS/en/api/tiled-map/). That source re-slices Tiled's on-disk chunks onto the runtime chunk grid lazily, one requested chunk at a time. + +```ts +import { View } from '@codexo/exojs'; +import { ChunkStreamer } from '@codexo/exojs-tilemap'; +import type { TiledMap } from '@codexo/exojs-tiled'; + +declare const tiled: TiledMap; // loaded through the @codexo/exojs-tiled Loader + +// Build the runtime map. An infinite: true map's chunked layers become +// unbounded TileLayers; this call is also what builds the chunk sources. +const map = tiled.toTileMap(); +const terrain = map.getTileLayer('terrain'); +const view = new View(0, 0, 1280, 720); + +if (terrain) { + // getChunkSource returns undefined for a finite (data-based) layer — only + // chunked infinite-map layers get one. Call toTileMap() before this. + const source = tiled.getChunkSource(terrain.id); + if (source) { + const streamer = new ChunkStreamer(terrain, source, view); + streamer.update(); + } +} +``` + +From there it is the same `ChunkStreamer` loop as the procedural providers — the only difference is where the tiles come from. + +## Performance notes + +- **Chunk loads are budgeted.** A camera moving faster than `maxChunkLoadsPerFrame` can keep up with will show brief pop-in at the frontier before the band fills. That is the budget doing its job — spreading load cost across frames instead of spiking one. For fast movers, raise `loadRadius` so chunks are requested further ahead of the visible edge, and raise the per-frame budget to fill them faster. +- **Mind the retained tier.** A streamed layer mutates its structure every time a chunk is adopted or evicted. If it lives inside a `RetainedContainer` shared with unrelated static content, every chunk boundary the camera crosses drops and re-captures the _whole_ group — you pay the retention bookkeeping for content that never changed. Give a streamed layer its own [`RetainedContainer`](/ExoJS/en/guide/rendering/retained-containers/), or none at all. See the [Retained containers](/ExoJS/en/guide/rendering/retained-containers/) chapter for the full rule. + +## Where to go next + +- The [Infinite Procedural Terrain](/ExoJS/en/examples/tilemap/infinite-terrain/) example is the sync provider end to end — fly an endless value-noise world and reroll the seed. +- The [Worker-Streamed Terrain](/ExoJS/en/examples/tilemap/worker-streamed-terrain/) example toggles between the sync and worker providers under a tunable sample cost, so you can watch the main thread hitch or stay smooth. +- [Retained containers](/ExoJS/en/guide/rendering/retained-containers/) covers the group-invalidation rule a streamed layer interacts with. +- [Tiled maps](/ExoJS/en/guide/assets/tiled-maps/) covers loading `.tmj` maps and `toTileMap()` in full. diff --git a/site/src/content/guide/rendering/retained-containers.mdx b/site/src/content/guide/rendering/retained-containers.mdx index fef8440b..de695265 100644 --- a/site/src/content/guide/rendering/retained-containers.mdx +++ b/site/src/content/guide/rendering/retained-containers.mdx @@ -28,6 +28,8 @@ A subtree is a good `RetainedContainer` when all of these hold: Reach for immediate mode instead when the content is procedural or changes completely every frame; reach for a plain container when children animate independently. `RetainedContainer` is specifically the "thousands of things that sit still while the camera glides over them" case. +One special case: a **streamed tile layer** (see [Infinite maps](/ExoJS/en/guide/rendering/infinite-maps/)) mutates structurally every time a chunk loads or unloads. Give it its own `RetainedContainer` or none at all — sharing a group with unrelated static content drags that whole group through re-capture on every chunk boundary the camera crosses. + There is no runtime toggle and no configuration. You choose the tier by constructing a `RetainedContainer` instead of a `Container`; everything else is the normal node API. If the subtree turns out to be a poor fit, swapping back is a one-word edit. diff --git a/site/src/lib/guide-structure.ts b/site/src/lib/guide-structure.ts index 4746702b..cd9f3089 100644 --- a/site/src/lib/guide-structure.ts +++ b/site/src/lib/guide-structure.ts @@ -400,6 +400,18 @@ const RAW_PARTS: ReadonlyArray = [ examples: ['scene-graph/retained-container'], apiLinks: ['retained-container', 'container', 'scene-node', 'view'], }, + { + slug: 'infinite-maps', + level: 'advanced', + learningGoals: [ + 'create unbounded tile maps and stream chunks near the camera with ChunkStreamer', + 'generate deterministic procedural terrain with createSampledChunkSource', + 'move expensive sampling off the main thread with createWorkerSampledChunkSource', + ], + prerequisites: ['assets/tiled-maps'], + examples: ['tilemap/infinite-terrain', 'tilemap/worker-streamed-terrain'], + apiLinks: ['tile-map', 'tile-layer', 'chunk-streamer', 'chunk-source', 'tilemap-functions', 'tiled-map'], + }, ], }, { From f4cc3703b41073609ed707510f468213d1673b5c Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 16 Jul 2026 11:48:58 +0200 Subject: [PATCH 5/7] docs(guide): tiled-maps chapter no longer claims infinite maps throw --- site/src/content/guide/assets/tiled-maps.mdx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/site/src/content/guide/assets/tiled-maps.mdx b/site/src/content/guide/assets/tiled-maps.mdx index 78c0e01c..201e18f5 100644 --- a/site/src/content/guide/assets/tiled-maps.mdx +++ b/site/src/content/guide/assets/tiled-maps.mdx @@ -80,7 +80,7 @@ Once the extension is active, load a `.tmj` map the same way you load any asset by path, or by config-map. All three resolve to the same handler. -XML (`.tmx`/`.tsx`), infinite maps, isometric or hexagonal orientations, collection-of-images tilesets, and `zstd` compression each raise a clear error rather than loading. Export an orthogonal JSON map (`.tmj`) with CSV, gzip, or zlib data. +XML (`.tmx`/`.tsx`), isometric or hexagonal orientations, collection-of-images tilesets, and `zstd` compression each raise a clear error rather than loading. Export an orthogonal JSON map (`.tmj`) with CSV, gzip, or zlib data. Infinite (chunked) maps **are** supported: `toTileMap()` converts each chunked layer to an unbounded runtime layer whose data streams in on demand via `getChunkSource()` — see the [Infinite maps](/ExoJS/en/guide/rendering/infinite-maps/) chapter. ```js no-check @@ -332,6 +332,9 @@ The extension covers the common orthogonal workflow, including format details be - Orthogonal orientation - Tile layers, including base64 and gzip/zlib-compressed `data` +- Infinite (chunked) maps — each chunked layer becomes an unbounded runtime layer whose data + streams in on demand via `getChunkSource()` (see the + [Infinite maps](/ExoJS/en/guide/rendering/infinite-maps/) chapter) - Image-based (atlas) tilesets, embedded in the map or referenced externally (`.tsj`) - Object layers — rectangle, ellipse, point, polygon, polyline, tile, and text objects - Image layers @@ -345,7 +348,6 @@ These raise a clear error or are intentionally absent from this release: - **`.tmx` maps and `.tsx` tilesets (XML)** — only the JSON format is parsed: `.tmj` for maps, `.tsj` for external tilesets. Export from Tiled as JSON. -- **Infinite maps** — chunked/infinite maps throw; use a fixed-size map. - **Non-orthogonal orientations** — isometric, staggered, and hexagonal throw. - **Collection-of-images tilesets** — a tileset built from one image per tile, rather than a single atlas, throws in `toTileMap()`. Use an atlas tileset. From 695ce04c3fa3a3e7c3065f3a6b1faf3d7838cedd Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 16 Jul 2026 17:02:02 +0200 Subject: [PATCH 6/7] docs(examples): correct biome tile ids after visual verification --- examples/tilemap/infinite-terrain.js | 4 ++-- examples/tilemap/infinite-terrain.ts | 4 ++-- examples/tilemap/worker-streamed-terrain.js | 4 ++-- examples/tilemap/worker-streamed-terrain.ts | 4 ++-- site/src/content/guide/rendering/infinite-maps.mdx | 6 +++--- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/examples/tilemap/infinite-terrain.js b/examples/tilemap/infinite-terrain.js index cb46e1f4..3e9f3f4c 100644 --- a/examples/tilemap/infinite-terrain.js +++ b/examples/tilemap/infinite-terrain.js @@ -48,8 +48,8 @@ function fbm(seed, x, y) { // Biome mapping (elevation-style bands; localTileId values are solid // full-square terrain-center tiles read off mapPack_tilesheet.png — 17 // columns, index = row * 17 + column). -const TILE_DEEP_WATER = 185; // patterned blue (row 10, col 15) -const TILE_WATER = 169; // plain light blue (row 9, col 16) +const TILE_DEEP_WATER = 203; // patterned blue (row 11, col 16) +const TILE_WATER = 186; // plain light blue (row 10, col 16) const TILE_SAND = 18; // beige center (row 1, col 1) const TILE_GRASS = 23; // green center (row 1, col 6) const TILE_ROCK = 28; // gray center (row 1, col 11) diff --git a/examples/tilemap/infinite-terrain.ts b/examples/tilemap/infinite-terrain.ts index 887f0809..384dfde9 100644 --- a/examples/tilemap/infinite-terrain.ts +++ b/examples/tilemap/infinite-terrain.ts @@ -53,8 +53,8 @@ function fbm(seed: number, x: number, y: number): number { // Biome mapping (elevation-style bands; localTileId values are solid // full-square terrain-center tiles read off mapPack_tilesheet.png — 17 // columns, index = row * 17 + column). -const TILE_DEEP_WATER = 185; // patterned blue (row 10, col 15) -const TILE_WATER = 169; // plain light blue (row 9, col 16) +const TILE_DEEP_WATER = 203; // patterned blue (row 11, col 16) +const TILE_WATER = 186; // plain light blue (row 10, col 16) const TILE_SAND = 18; // beige center (row 1, col 1) const TILE_GRASS = 23; // green center (row 1, col 6) const TILE_ROCK = 28; // gray center (row 1, col 11) diff --git a/examples/tilemap/worker-streamed-terrain.js b/examples/tilemap/worker-streamed-terrain.js index fd7d271c..d43a063b 100644 --- a/examples/tilemap/worker-streamed-terrain.js +++ b/examples/tilemap/worker-streamed-terrain.js @@ -50,8 +50,8 @@ function fbm(seed, x, y) { // Biome mapping (elevation-style bands; localTileId values are solid // full-square terrain-center tiles read off mapPack_tilesheet.png — 17 // columns, index = row * 17 + column). -const TILE_DEEP_WATER = 185; // patterned blue (row 10, col 15) -const TILE_WATER = 169; // plain light blue (row 9, col 16) +const TILE_DEEP_WATER = 203; // patterned blue (row 11, col 16) +const TILE_WATER = 186; // plain light blue (row 10, col 16) const TILE_SAND = 18; // beige center (row 1, col 1) const TILE_GRASS = 23; // green center (row 1, col 6) const TILE_ROCK = 28; // gray center (row 1, col 11) diff --git a/examples/tilemap/worker-streamed-terrain.ts b/examples/tilemap/worker-streamed-terrain.ts index 8739c00e..987c8357 100644 --- a/examples/tilemap/worker-streamed-terrain.ts +++ b/examples/tilemap/worker-streamed-terrain.ts @@ -55,8 +55,8 @@ function fbm(seed: number, x: number, y: number): number { // Biome mapping (elevation-style bands; localTileId values are solid // full-square terrain-center tiles read off mapPack_tilesheet.png — 17 // columns, index = row * 17 + column). -const TILE_DEEP_WATER = 185; // patterned blue (row 10, col 15) -const TILE_WATER = 169; // plain light blue (row 9, col 16) +const TILE_DEEP_WATER = 203; // patterned blue (row 11, col 16) +const TILE_WATER = 186; // plain light blue (row 10, col 16) const TILE_SAND = 18; // beige center (row 1, col 1) const TILE_GRASS = 23; // green center (row 1, col 6) const TILE_ROCK = 28; // gray center (row 1, col 11) diff --git a/site/src/content/guide/rendering/infinite-maps.mdx b/site/src/content/guide/rendering/infinite-maps.mdx index 0d5a1519..3f1b86c4 100644 --- a/site/src/content/guide/rendering/infinite-maps.mdx +++ b/site/src/content/guide/rendering/infinite-maps.mdx @@ -137,8 +137,8 @@ function fbm(seed: number, x: number, y: number): number { } // Elevation bands → solid terrain-center tile ids in the tilesheet. -const TILE_DEEP_WATER = 185; -const TILE_WATER = 169; +const TILE_DEEP_WATER = 203; +const TILE_WATER = 186; const TILE_SAND = 18; const TILE_GRASS = 23; const TILE_ROCK = 28; @@ -217,7 +217,7 @@ self.onmessage = (event) => { // TileSet/Texture, neither of which can cross a postMessage boundary. const source = createWorkerSampledChunkSource(terrain, { workerSource, - mapValueToTile: value => ({ tileset, localTileId: value < 0.5 ? 169 : 23, transform: TILE_TRANSFORM_IDENTITY }), + mapValueToTile: value => ({ tileset, localTileId: value < 0.5 ? 186 : 23, transform: TILE_TRANSFORM_IDENTITY }), }); // The returned source owns a real Worker — you MUST call destroy() when done From ecd98b827ed4a3dd1b347fff411483ead2cbbbf3 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 16 Jul 2026 17:20:48 +0200 Subject: [PATCH 7/7] docs(examples): await spritesheet texture so frames survive the load self-heal A texture passed while unhydrated gets its Spritesheet-sliced frames reset to the full atlas by the Sprite texture-load self-heal once it hydrates. Await the load like the tileset texture already does. --- examples/tilemap/infinite-terrain.js | 2 +- examples/tilemap/infinite-terrain.ts | 2 +- examples/tilemap/worker-streamed-terrain.js | 2 +- examples/tilemap/worker-streamed-terrain.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/tilemap/infinite-terrain.js b/examples/tilemap/infinite-terrain.js index 3e9f3f4c..2a11f926 100644 --- a/examples/tilemap/infinite-terrain.js +++ b/examples/tilemap/infinite-terrain.js @@ -100,7 +100,7 @@ class InfiniteTerrainScene extends Scene { this.terrain = new TileLayer({ id: 1, name: 'terrain', tileWidth: TILE, tileHeight: TILE, tilesets: [this.tileset] }); const map = new TileMap({ name: 'infinite-world', tileWidth: TILE, tileHeight: TILE, tilesets: [this.tileset], layers: [this.terrain] }); this.mapView = map.createView({ bands: { terrain: ['terrain'] } }); - const characters = new Spritesheet(this.loader.get(assets.demo.spritesheets.platformerCharacters.image), (await this.loader.load(Asset.kind('json', assets.demo.spritesheets.platformerCharacters.data)))); + const characters = new Spritesheet(await this.loader.load(Asset.kind('texture', assets.demo.spritesheets.platformerCharacters.image)), (await this.loader.load(Asset.kind('json', assets.demo.spritesheets.platformerCharacters.data)))); this.explorer = characters.getFrameSprite('character_green_front').setAnchor(0.5); this.explorer.setPosition(0, 0); this.explorer.setScale(1.25); diff --git a/examples/tilemap/infinite-terrain.ts b/examples/tilemap/infinite-terrain.ts index 384dfde9..e19903f7 100644 --- a/examples/tilemap/infinite-terrain.ts +++ b/examples/tilemap/infinite-terrain.ts @@ -107,7 +107,7 @@ class InfiniteTerrainScene extends Scene { this.mapView = map.createView({ bands: { terrain: ['terrain'] } }); const characters = new Spritesheet( - this.loader.get(assets.demo.spritesheets.platformerCharacters.image), + await this.loader.load(Asset.kind('texture', assets.demo.spritesheets.platformerCharacters.image)), (await this.loader.load(Asset.kind('json', assets.demo.spritesheets.platformerCharacters.data))) as SpritesheetData, ); diff --git a/examples/tilemap/worker-streamed-terrain.js b/examples/tilemap/worker-streamed-terrain.js index d43a063b..3be956bd 100644 --- a/examples/tilemap/worker-streamed-terrain.js +++ b/examples/tilemap/worker-streamed-terrain.js @@ -188,7 +188,7 @@ class WorkerStreamedTerrainScene extends Scene { this.terrain = new TileLayer({ id: 1, name: 'terrain', tileWidth: TILE, tileHeight: TILE, tilesets: [this.tileset] }); const map = new TileMap({ name: 'infinite-world', tileWidth: TILE, tileHeight: TILE, tilesets: [this.tileset], layers: [this.terrain] }); this.mapView = map.createView({ bands: { terrain: ['terrain'] } }); - const characters = new Spritesheet(this.loader.get(assets.demo.spritesheets.platformerCharacters.image), (await this.loader.load(Asset.kind('json', assets.demo.spritesheets.platformerCharacters.data)))); + const characters = new Spritesheet(await this.loader.load(Asset.kind('texture', assets.demo.spritesheets.platformerCharacters.image)), (await this.loader.load(Asset.kind('json', assets.demo.spritesheets.platformerCharacters.data)))); this.explorer = characters.getFrameSprite('character_green_front').setAnchor(0.5); this.explorer.setPosition(0, 0); this.explorer.setScale(1.25); diff --git a/examples/tilemap/worker-streamed-terrain.ts b/examples/tilemap/worker-streamed-terrain.ts index 987c8357..3764b22a 100644 --- a/examples/tilemap/worker-streamed-terrain.ts +++ b/examples/tilemap/worker-streamed-terrain.ts @@ -196,7 +196,7 @@ class WorkerStreamedTerrainScene extends Scene { this.mapView = map.createView({ bands: { terrain: ['terrain'] } }); const characters = new Spritesheet( - this.loader.get(assets.demo.spritesheets.platformerCharacters.image), + await this.loader.load(Asset.kind('texture', assets.demo.spritesheets.platformerCharacters.image)), (await this.loader.load(Asset.kind('json', assets.demo.spritesheets.platformerCharacters.data))) as SpritesheetData, );