Skip to content

ImageOverlayPlugin: tile splitting stalls over flat quantized-mesh terrain because split children inherit the terrain tile's geometricError #1636

Description

@dhruvsjain

Description

With enableTileSplitting (default on), ImageOverlayPlugin extends overlay refinement past the geometry leaves by creating virtual split children. Those children are created with:

https://github.com/NASA-AMMOS/3DTilesRendererJS/blob/v0.4.28/src/three/plugins/images/ImageOverlayPlugin.js#L761-L769

i.e. geometricError: tile.geometricError * 0.5. This couples overlay refinement to the terrain mesh error, which has no relationship to the overlay's remaining image resolution.

Over flat ground, quantized-mesh terrain (Cesium World Terrain) reports honest, tiny mesh errors — at the repro location below the deepest real tiles (depth 14) have geometricError0.21–0.6 m. At ~100 m altitude that's at or below errorTarget = 2, so traversal never (or barely) elects to refine into the split children at all. The overlay's shouldSplit() still returns true (calculateLevel(range) < tiling.maxLevel, src) — the splits are created, but traversal's SSE test discards them because their inherited error is centimeter-scale.

Net effect: imagery is capped at the terrain leaf's level and stays visibly blurry at street-level altitudes, even though the overlay has many more native levels available. In my measurements (details below): visible imagery capped at level 14 of maxLevel 20, with 0 of 80 created split tiles ever loaded.

To confirm the mechanism I registered a small plugin that only floors each terrain tile's geometricError to the standard level ladder (~77067 / 2^level m) in processTileModel, changing nothing else. With that single change the splits recurse correctly (nested splits, 6 levels deep) and visible imagery reaches levels 19–20 — so the split machinery itself works; it's only the inherited geometricError that prevents it from ever being selected.

stock v0.4.28 with terrain geometricError floored to level ladder
split tiles loaded 0 (80 created, all at chain depth 1) recursion to chain depth 6, virtualChildCount 0→4 as expected
imagery level on visible tiles ≤ 14 up to 20 (= overlay tiling.maxLevel)

Driving errorTarget down instead doesn't realistically help: reaching imagery level 19 here would need errorTarget ≈ 0.1 px, which explodes geometry refinement globally (related: the errorTarget = 0.25 workaround mentioned at the end of #1334).

This is adjacent to the level-mapping discussion in #1278 but narrower and concrete: the existing tile-splitting feature is effectively non-functional over flat quantized-mesh terrain because of the inherited error value. A possible direction, in line with the texel-size ideas in #1278: floor the split child's geometricError at the overlay's projected texel size for the child's image level (the same world-space-pixel notion EllipsoidProjectionTilesPlugin uses for image-tile geometric error), e.g. max(parentError * 0.5, texelError(level + 1)), so splits keep being selected exactly while the overlay still has native resolution to show.

(While debugging I initially suspected the alreadySplit gate at L613, since split children are created without internal.virtualChildCount — but preprocessNode pre-seeds the defaults before any content can load, so the gate behaves correctly and nested expansion works once traversal refines. Verified against v0.4.28 and current master (2d21dc5).)

Reproduction steps

  1. Load Cesium World Terrain (ion asset 1, quantized mesh) with a CesiumIonOverlay (e.g. Bing Aerial, ion asset 2) via ImageOverlayPlugin; leave enableTileSplitting on and errorTarget at 2.
  2. Place the camera ~100 m above flat ground — e.g. Dubai, lat 25.118 / lon 55.133, looking down at ~45°.
  3. Let streaming settle, then observe the ground imagery stays blurry (~level 14). Walking the tile tree shows the image_overlay_tile_split children exist but never load; the imagery level on visible tiles never approaches the overlay's maxLevel.
  4. (Mechanism check) Floor terrain tiles' geometricError to the level ladder in a processTileModel hook → splits now recurse and imagery reaches level 19–20 in the same scene.

Code

import { TilesRenderer } from '3d-tiles-renderer';
import { CesiumIonAuthPlugin } from '3d-tiles-renderer/core/plugins';
import { ImageOverlayPlugin, CesiumIonOverlay, QuantizedMeshPlugin } from '3d-tiles-renderer/plugins';

const tiles = new TilesRenderer();
tiles.registerPlugin( new CesiumIonAuthPlugin( {
	apiToken: ION_TOKEN,
	assetId: '1', // Cesium World Terrain
	assetTypeHandler: ( type, tilesBase ) => {

		if ( type === 'TERRAIN' && tilesBase.getPluginByName( 'QUANTIZED_MESH_PLUGIN' ) === null ) {

			tilesBase.registerPlugin( new QuantizedMeshPlugin( { useRecommendedSettings: true } ) );

		}

	},
} ) );
tiles.registerPlugin( new ImageOverlayPlugin( {
	renderer,
	overlays: [ new CesiumIonOverlay( { assetId: 2, apiToken: ION_TOKEN } ) ], // Bing Aerial
} ) );
tiles.errorTarget = 2;

// camera ~100 m above flat ground (Dubai): lat 25.118, lon 55.133, tilted ~45deg

// --- diagnostics used for the numbers above (run after streaming settles) ---
const splitRe = /image_overlay_tile_split/;
const chains = {};
( function walk( tile, chain ) {

	const uri = tile.content?.uri ?? '';
	const c = splitRe.test( uri ) ? chain + 1 : 0;
	if ( c ) {

		chains[ c ] = chains[ c ] || { n: 0, loaded: 0 };
		chains[ c ].n ++;
		if ( tile.internal?.loadingState === 4 /* LOADED */ ) chains[ c ].loaded ++;

	}
	tile.children.forEach( ch => walk( ch, c ) );

} )( tiles.root, 0 );

const plugin = tiles.getPluginByName( 'IMAGE_OVERLAY_PLUGIN' );
const overlay = plugin.overlays[ 0 ];
const info = plugin.overlayInfo.get( overlay );
const levels = {};
tiles.visibleTiles.forEach( tile => {

	const ti = info.tileInfo.get( tile );
	if ( ti?.range ) {

		const lvl = overlay.calculateLevel( ti.range );
		levels[ lvl ] = ( levels[ lvl ] || 0 ) + 1;

	}

} );
console.log( { chains, levels, maxLevel: overlay.tiling.maxLevel } );

// --- the "mechanism check" plugin that makes splitting work ---
const LEVEL0_ERROR = ( 6378137 * 2 * Math.PI * 0.25 ) / ( 65 * 2 );
tiles.registerPlugin( {
	name: 'TERRAIN_ERROR_FLOOR',
	priority: - 500,
	processTileModel( scene, tile ) {

		const match = /(\d+)\/\d+\/\d+\.terrain/.exec( tile.content?.uri ?? '' );
		if ( match ) tile.geometricError = Math.max( tile.geometricError, LEVEL0_ERROR / 2 ** Number( match[ 1 ] ) );

	},
} );

Live example

Not possible to share a runnable fiddle since the repro needs a Cesium ion token — the snippet above is complete apart from ION_TOKEN and standard three.js scene boilerplate (the globe fiddle template from the issue form works as a base).

Screenshots & Tile Set

Measured numbers in the table above (Dubai 25.118 / 55.133, camera 100 m, 45° tilt, errorTarget 2, ~750 px viewport height):

  • stock: terrain leaves at depth 14 with geometricError 0.21–0.6 m; imagery levels on visible tiles { …, 13: 3, 14: 4 }, nothing deeper; 80 image_overlay_tile_split tiles in the tree, all chain depth 1, 0 loaded.
  • with the geometricError floor only: split chains to depth 6; imagery levels on visible tiles include { 18: 3, 19: 18, 20: 8 }.

Tile sets: Cesium ion assets 1 (Cesium World Terrain) + 2 (Bing Maps Aerial).

Library Version

v0.4.28 (also checked the relevant source on master @ 2d21dc5 — unchanged)

Three.js Version

r184

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions