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 geometricError ≈ 0.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
- 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.
- Place the camera ~100 m above flat ground — e.g. Dubai, lat 25.118 / lon 55.133, looking down at ~45°.
- 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.
- (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
Description
With
enableTileSplitting(default on),ImageOverlayPluginextends 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
geometricError≈ 0.21–0.6 m. At ~100 m altitude that's at or belowerrorTarget = 2, so traversal never (or barely) elects to refine into the split children at all. The overlay'sshouldSplit()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
geometricErrorto the standard level ladder (~77067 / 2^levelm) inprocessTileModel, 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 inheritedgeometricErrorthat prevents it from ever being selected.geometricErrorfloored to level laddervirtualChildCount0→4 as expectedtiling.maxLevel)Driving
errorTargetdown instead doesn't realistically help: reaching imagery level 19 here would neederrorTarget≈ 0.1 px, which explodes geometry refinement globally (related: theerrorTarget = 0.25workaround 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
geometricErrorat the overlay's projected texel size for the child's image level (the same world-space-pixel notionEllipsoidProjectionTilesPluginuses 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
alreadySplitgate at L613, since split children are created withoutinternal.virtualChildCount— butpreprocessNodepre-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
CesiumIonOverlay(e.g. Bing Aerial, ion asset 2) viaImageOverlayPlugin; leaveenableTileSplittingon anderrorTargetat 2.image_overlay_tile_splitchildren exist but never load; the imagery level on visible tiles never approaches the overlay'smaxLevel.geometricErrorto the level ladder in aprocessTileModelhook → splits now recurse and imagery reaches level 19–20 in the same scene.Code
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_TOKENand 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):
geometricError0.21–0.6 m; imagery levels on visible tiles{ …, 13: 3, 14: 4 }, nothing deeper; 80image_overlay_tile_splittiles in the tree, all chain depth 1, 0 loaded.{ 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