diff --git a/packages/exojs-tilemap/src/TileLayerNode.ts b/packages/exojs-tilemap/src/TileLayerNode.ts index 707dadc5..23d5d89f 100644 --- a/packages/exojs-tilemap/src/TileLayerNode.ts +++ b/packages/exojs-tilemap/src/TileLayerNode.ts @@ -32,6 +32,14 @@ export interface TileLayerNodeOptions { * `offset` is applied to this node's transform; `visible` and `opacity` are * read live from the runtime layer on every frame (no rebuild required). * + * A `parallax` factor other than `1` makes this node's rendered position + * camera-dependent, resolved per frame in {@link _collectContent} by patching + * the node position against the camera centre, then restoring it — the cull + * test runs before that patch, so a bounded layer with parallax would be + * culled at its static base-offset bounds while actually rendering elsewhere. + * Such a layer therefore opts out of view culling (`cullable = false`), same + * as an unbounded layer (exactly like {@link import('./ImageLayerNode').ImageLayerNode}). + * * The node references — but never owns — the {@link TileLayer}: destroying it * frees its chunk nodes and their cached geometry but leaves the layer, map, * and Loader-owned textures intact. @@ -93,7 +101,7 @@ export class TileLayerNode extends Container { this.setPosition(layer.offsetX, layer.offsetY); this._buildChunkNodes(); - if (!this._layer.bounded) { + if (!this._layer.bounded || layer.parallaxX !== 1 || layer.parallaxY !== 1) { this.cullable = false; } diff --git a/packages/exojs-tilemap/test/nodes.test.ts b/packages/exojs-tilemap/test/nodes.test.ts index 13ac6567..4cc43552 100644 --- a/packages/exojs-tilemap/test/nodes.test.ts +++ b/packages/exojs-tilemap/test/nodes.test.ts @@ -143,6 +143,20 @@ describe('TileLayerNode', () => { expect(node.y).toBe(20); }); + it('bounded parallax layer opts out of static-bounds culling', () => { + const tileset = makeTileset(); + const layer = new TileLayer({ + id: 1, name: 'bg', width: 4, height: 4, tileWidth: 32, tileHeight: 32, + tilesets: [tileset], parallaxX: 0.5, + }); + const node = new TileLayerNode(layer); + + // The cull test runs before the collect-time parallax patch, so a bounded + // parallax layer's static bounds no longer match its render-time position + // — it must opt out of culling entirely, exactly like an unbounded layer. + expect(node.cullable).toBe(false); + }); + it('reports local bounds as the layer pixel rect (even when empty)', () => { const tileset = makeTileset(); const layer = makeLayer(tileset, { width: 5, height: 3 }); diff --git a/site/src/content/api/tile-layer-node.json b/site/src/content/api/tile-layer-node.json index 30deb335..f7cfbc7d 100644 --- a/site/src/content/api/tile-layer-node.json +++ b/site/src/content/api/tile-layer-node.json @@ -1,6 +1,6 @@ { "title": "TileLayerNode", - "description": "A scene node that renders one generic TileLayer as a Container of per-chunk TileChunkNode drawables. Each non-empty loaded chunk becomes one child positioned at its pixel origin, so the engine's existing per-node culling drops individual chunks and the render-plan optimiser batches them by tileset texture. The layer's pixel `offset` is applied to this node's transform; `visible` and `opacity` are read live from the runtime layer on every frame (no rebuild required). The node references — but never owns — the TileLayer: destroying it frees its chunk nodes and their cached geometry but leaves the layer, map, and Loader-owned textures intact. Structural changes made via `_adoptChunk`/`_evictChunk` (chunk streaming) are picked up incrementally as they happen, without a full rebuild. TileLayerNode.refresh remains available for callers that mutate chunk structure by other means (e.g. a bulk edit that bypasses `_adoptChunk`/`_evictChunk`) and need a full resync. In-place tile edits to existing chunks are picked up automatically via chunk revisions.", + "description": "A scene node that renders one generic TileLayer as a Container of per-chunk TileChunkNode drawables. Each non-empty loaded chunk becomes one child positioned at its pixel origin, so the engine's existing per-node culling drops individual chunks and the render-plan optimiser batches them by tileset texture. The layer's pixel `offset` is applied to this node's transform; `visible` and `opacity` are read live from the runtime layer on every frame (no rebuild required). A `parallax` factor other than `1` makes this node's rendered position camera-dependent, resolved per frame in _collectContent by patching the node position against the camera centre, then restoring it — the cull test runs before that patch, so a bounded layer with parallax would be culled at its static base-offset bounds while actually rendering elsewhere. Such a layer therefore opts out of view culling (`cullable = false`), same as an unbounded layer (exactly like import('./ImageLayerNode').ImageLayerNode). The node references — but never owns — the TileLayer: destroying it frees its chunk nodes and their cached geometry but leaves the layer, map, and Loader-owned textures intact. Structural changes made via `_adoptChunk`/`_evictChunk` (chunk streaming) are picked up incrementally as they happen, without a full rebuild. TileLayerNode.refresh remains available for callers that mutate chunk structure by other means (e.g. a bulk edit that bypasses `_adoptChunk`/`_evictChunk`) and need a full resync. In-place tile edits to existing chunks are picked up automatically via chunk revisions.", "symbol": "TileLayerNode", "kind": "class", "subsystem": "tilemap", @@ -21,6 +21,7 @@ "paragraphs": [ "A scene node that renders one generic TileLayer as a Container of per-chunk TileChunkNode drawables.", "Each non-empty loaded chunk becomes one child positioned at its pixel origin, so the engine's existing per-node culling drops individual chunks and the render-plan optimiser batches them by tileset texture. The layer's pixel `offset` is applied to this node's transform; `visible` and `opacity` are read live from the runtime layer on every frame (no rebuild required).", + "A `parallax` factor other than `1` makes this node's rendered position camera-dependent, resolved per frame in _collectContent by patching the node position against the camera centre, then restoring it — the cull test runs before that patch, so a bounded layer with parallax would be culled at its static base-offset bounds while actually rendering elsewhere. Such a layer therefore opts out of view culling (`cullable = false`), same as an unbounded layer (exactly like import('./ImageLayerNode').ImageLayerNode).", "The node references — but never owns — the TileLayer: destroying it frees its chunk nodes and their cached geometry but leaves the layer, map, and Loader-owned textures intact.", "Structural changes made via `_adoptChunk`/`_evictChunk` (chunk streaming) are picked up incrementally as they happen, without a full rebuild. TileLayerNode.refresh remains available for callers that mutate chunk structure by other means (e.g. a bulk edit that bypasses `_adoptChunk`/`_evictChunk`) and need a full resync. In-place tile edits to existing chunks are picked up automatically via chunk revisions." ],