Repository: NASA-AMMOS/3DTilesRendererJS (js)

Issue:
LRUCache: Provide ability to set a min and max threshold based on estimated tile byte size
**Is your feature request related to a problem? Please describe.**

The LRUCache ensures we don't load too many tiles, and also appropriately load/unload tiles as they are used/unused. If every tile loaded was the same, this idea would work well. However some tiles are heavier than others. The size of the mesh, the textures or materials loaded along with them, etc. So for some 3d-tiles having a cache limit of 600 might be totally viable and work well, where as a very high res photo textured mesh may actually crash the browser after keep 600 tiles in memory.

**Describe the solution you'd like**

I would like the ability to add a callback to calculate the "weight" of a tile. This could be used to better determine how expensive some tiles are over others. For example if I load tile that consists of a cube and its coloured red, that is a relatively light tile that is cheap to store in memory. But if I load a tile that is a complicated mesh and its material is photo realistic textures, that is much more expensive to store in memory. This callback would allow us to add our own logic to calculate a weight (default could be 1 to avoid breaking any existing behaviour), and use that to get better control over how much data we actually cache at any given time. 

Again we don't have to assume we know how users want to weigh their tiles, but if we give them the option to override the weights that seems like an easy compromise.

**Describe alternatives you've considered**

I have thought about adjusting the size of the cache based on the 3d-tile that is loaded, but when loading a root tile we don't always know how heavy a given 3d-tile set will be. This also makes sharing the cache much more difficult if I enable both a CAD tileset and a photo mesh tileset. One number might work for one but not the other.


**Additional context**

Currently I am having a difficult time configuring an appropriate cache min/max because what might be totally acceptable for some 3d-tiles is way too much for others depending on how the 3d-tile was built and parsed.

Pull request description:
LRUCache: Add support for memory-based cache limits
Fix #384 
Somewhat related to #428 

Requires latest version of three.js.

**TODO**
- Hone default cache size
- Prevent alternation of adding then removing items based on increase in size during first load 

Patch:
diff --git a/README.md b/README.md
index af2cb77..32be754 100644
--- a/README.md
+++ b/README.md
@@ -623,7 +623,7 @@ Utility class for the TilesRenderer to keep track of currently used items so ren
 maxSize = 800 : number
 ```
 
-The maximum cached size. If that current amount of cached items is equal to this value then no more items can be cached.
+The maximum cached size in number of items. If that current amount of cached items is equal to this value then no more items can be cached.
 
 ### .minSize
 
@@ -631,7 +631,23 @@ The maximum cached size. If that current amount of cached items is equal to this
 minSize = 600 : number
 ```
 
-The minimum cache size. Above this cached data will be unloaded if it's unused.
+The minimum cache size in number of items. Above this cached data will be unloaded if it's unused.
+
+### .maxByteSize
+
+```js
+maxByteSize = 0.3 * 2**30 : Number
+```
+
+The maximum cached size in bytes. If that current amount of cached bytes is equal to this value then no more items can be cached.
+
+### .minByteSize
+
+```js
+minByteSize = 0.2 * 2**30 : Number
+```
+
+The minimum cache size in number of bytes. Above this cached data will be unloaded if it's unused.
 
 ### .unloadPercent
 
diff --git a/example/src/plugins/TileCompressionPlugin.js b/example/src/plugins/TileCompressionPlugin.js
index cb02021..a471a6d 100644
--- a/example/src/plugins/TileCompressionPlugin.js
+++ b/example/src/plugins/TileCompressionPlugin.js
@@ -105,7 +105,6 @@ export class TileCompressionPlugin {
 
 	constructor( options ) {
 
-		this.tiles = null;
 		this._options = {
 			// whether to generate normals if they don't already exist.
 			generateNormals: false,
@@ -130,108 +129,94 @@ export class TileCompressionPlugin {
 
 	}
 
-	init( tiles ) {
+	processTileModel( scene, tile ) {
 
-		this.tiles = tiles;
+		const {
+			generateNormals,
 
-		this._onLoadModel = ( { scene } ) => {
+			disableMipmaps,
+			compressIndex,
+			compressUvs,
+			compressNormals,
+			compressPosition,
 
-			const {
-				generateNormals,
+			uvType,
+			normalType,
+			positionType,
+		} = this._options;
 
-				disableMipmaps,
-				compressIndex,
-				compressUvs,
-				compressNormals,
-				compressPosition,
+		scene.traverse( c => {
 
-				uvType,
-				normalType,
-				positionType,
-			} = this._options;
+			// handle materials
+			if ( c.material && disableMipmaps ) {
 
-			scene.traverse( c => {
+				const material = c.material;
+				for ( const key in material ) {
 
-				// handle materials
-				if ( c.material && disableMipmaps ) {
+					const value = material[ key ];
+					if ( value && value.isTexture ) {
 
-					const material = c.material;
-					for ( const key in material ) {
-
-						const value = material[ key ];
-						if ( value && value.isTexture ) {
-
-							value.generateMipmaps = false;
-
-						}
+						value.generateMipmaps = false;
 
 					}
 
 				}
 
-				// handle geometry attribute compression
-				if ( c.geometry ) {
+			}
 
-					const geometry = c.geometry;
-					const attributes = geometry.attributes;
-					if ( compressUvs ) {
+			// handle geometry attribute compression
+			if ( c.geometry ) {
 
-						const { uv, uv1, uv2, uv3 } = attributes;
-						if ( uv ) attributes.uv = compressAttribute( uv, uvType );
-						if ( uv1 ) attributes.uv1 = compressAttribute( uv1, uvType );
-						if ( uv2 ) attributes.uv2 = compressAttribute( uv2, uvType );
-						if ( uv3 ) attributes.uv3 = compressAttribute( uv3, uvType );
+				const geometry = c.geometry;
+				const attributes = geometry.attributes;
+				if ( compressUvs ) {
 
-					}
+					const { uv, uv1, uv2, uv3 } = attributes;
+					if ( uv ) attributes.uv = compressAttribute( uv, uvType );
+					if ( uv1 ) attributes.uv1 = compressAttribute( uv1, uvType );
+					if ( uv2 ) attributes.uv2 = compressAttribute( uv2, uvType );
+					if ( uv3 ) attributes.uv3 = compressAttribute( uv3, uvType );
 
-					if ( generateNormals && ! attributes.normals ) {
+				}
 
-						geometry.computeVertexNormals();
+				if ( generateNormals && ! attributes.normals ) {
 
-					}
+					geometry.computeVertexNormals();
 
-					if ( compressNormals && attributes.normals ) {
+				}
 
-						attributes.normals = compressAttribute( attributes.normals, normalType );
+				if ( compressNormals && attributes.normals ) {
 
-					}
+					attributes.normals = compressAttribute( attributes.normals, normalType );
 
-					if ( compressPosition ) {
+				}
 
-						compressPositionAttribute( c, positionType );
+				if ( compressPosition ) {
 
-					}
+					compressPositionAttribute( c, positionType );
 
-					if ( compressIndex && geometry.index ) {
+				}
 
-						const vertCount = attributes.position.count;
-						const index = geometry.index;
-						const type = vertCount > 65535 ? Uint32Array : vertCount > 255 ? Uint16Array : Uint8Array;
-						if ( ! ( index.array instanceof type ) ) {
+				if ( compressIndex && geometry.index ) {
 
-							const array = new type( geometry.index.count );
-							array.set( index.array );
+					const vertCount = attributes.position.count;
+					const index = geometry.index;
+					const type = vertCount > 65535 ? Uint32Array : vertCount > 255 ? Uint16Array : Uint8Array;
+					if ( ! ( index.array instanceof type ) ) {
 
-							const attribute = new BufferAttribute( array, 1 );
-							geometry.setIndex( attribute );
+						const array = new type( geometry.index.count );
+						array.set( index.array );
 
-						}
+						const attribute = new BufferAttribute( array, 1 );
+						geometry.setIndex( attribute );
 
 					}
 
 				}
 
-			} );
-
-		};
-
-		tiles.addEventListener( 'load-model', this._onLoadModel );
-
-	}
-
-	dispose() {
+			}
 
-		this.tiles.removeEventListener( 'load-model', this._onLoadModel );
+		} );
 
 	}
 
diff --git a/example/src/plugins/UpdateOnChangePlugin.js b/example/src/plugins/UpdateOnChangePlugin.js
index da3758f..43f2d2e 100644
--- a/example/src/plugins/UpdateOnChangePlugin.js
+++ b/example/src/plugins/UpdateOnChangePlugin.js
@@ -34,6 +34,9 @@ export class UpdateOnChangePlugin {
 
 		};
 
+		// dispose tile is included here because the LRUCache can evict tiles that are actively used if they're
+		// above the byte cap causing tile gaps
+		tiles.addEventListener( 'dispose-model', this._needsUpdateCallback );
 		tiles.addEventListener( 'camera-resolution-change', this._needsUpdateCallback );
 		tiles.addEventListener( 'load-content', this._needsUpdateCallback );
 		tiles.addEventListener( 'add-camera', this._onCameraAdd );
@@ -69,6 +72,7 @@ export class UpdateOnChangePlugin {
 	dispose() {
 
 		const tiles = this.tiles;
+		tiles.removeEventListener( 'dispose-model', this._needsUpdateCallback );
 		tiles.removeEventListener( 'camera-resolution-change', this._needsUpdateCallback );
 		tiles.removeEventListener( 'content-load', this._needsUpdateCallback );
 		tiles.removeEventListener( 'camera-add', this._onCameraAdd );
diff --git a/package-lock.json b/package-lock.json
index df65323..5d98065 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,7 +12,7 @@
         "@babel/core": "^7.21.8",
         "@babel/preset-env": "^7.21.5",
         "@types/eslint": "^7.28.1",
-        "@types/three": "^0.165.0",
+        "@types/three": "^0.166.0",
         "@typescript-eslint/eslint-plugin": "^7.14.1",
         "@typescript-eslint/parser": "^7.14.1",
         "babel-jest": "^29.5.0",
@@ -22,7 +22,7 @@
         "eslint-plugin-jest": "^28.6.0",
         "jest": "^27.1.1",
         "jest-cli": "^27.1.1",
-        "three": "^0.165.0",
+        "three": "^0.166.0",
         "typescript": "^5.1.3",
         "vite": "^5.3.1"
       },
@@ -4282,12 +4282,12 @@
       "dev": true
     },
     "node_modules/@types/three": {
-      "version": "0.165.0",
-      "resolved": "https://registry.npmjs.org/@types/three/-/three-0.165.0.tgz",
-      "integrity": "sha512-AJK8JZAFNBF0kBXiAIl5pggYlzAGGA8geVYQXAcPCEDRbyA+oEjkpUBcJJrtNz6IiALwzGexFJGZG2yV3WsYBw==",
+      "version": "0.166.0",
+      "resolved": "https://registry.npmjs.org/@types/three/-/three-0.166.0.tgz",
+      "integrity": "sha512-FHMnpcdhdbdOOIYbfkTkUVpYMW53odxbTRwd0/xJpYnTzEsjnVnondGAvHZb4z06UW0vo6WPVuvH0/9qrxKx7g==",
       "dev": true,
       "dependencies": {
-        "@tweenjs/tween.js": "~23.1.1",
+        "@tweenjs/tween.js": "~23.1.2",
         "@types/stats.js": "*",
         "@types/webxr": "*",
         "fflate": "~0.8.2",
@@ -11274,9 +11274,9 @@
       "peer": true
     },
     "node_modules/three": {
-      "version": "0.165.0",
-      "resolved": "https://registry.npmjs.org/three/-/three-0.165.0.tgz",
-      "integrity": "sha512-cc96IlVYGydeceu0e5xq70H8/yoVT/tXBxV/W8A/U6uOq7DXc4/s1Mkmnu6SqoYGhSRWWYFOhVwvq6V0VtbplA==",
+      "version": "0.166.1",
+      "resolved": "https://registry.npmjs.org/three/-/three-0.166.1.tgz",
+      "integrity": "sha512-LtuafkKHHzm61AQA1be2MAYIw1IjmhOUxhBa0prrLpEMWbV7ijvxCRHjSgHPGp2493wLBzwKV46tA9nivLEgKg==",
       "dev": true
     },
     "node_modules/throat": {
@@ -14949,12 +14949,12 @@
       "dev": true
     },
     "@types/three": {
-      "version": "0.165.0",
-      "resolved": "https://registry.npmjs.org/@types/three/-/three-0.165.0.tgz",
-      "integrity": "sha512-AJK8JZAFNBF0kBXiAIl5pggYlzAGGA8geVYQXAcPCEDRbyA+oEjkpUBcJJrtNz6IiALwzGexFJGZG2yV3WsYBw==",
+      "version": "0.166.0",
+      "resolved": "https://registry.npmjs.org/@types/three/-/three-0.166.0.tgz",
+      "integrity": "sha512-FHMnpcdhdbdOOIYbfkTkUVpYMW53odxbTRwd0/xJpYnTzEsjnVnondGAvHZb4z06UW0vo6WPVuvH0/9qrxKx7g==",
       "dev": true,
       "requires": {
-        "@tweenjs/tween.js": "~23.1.1",
+        "@tweenjs/tween.js": "~23.1.2",
         "@types/stats.js": "*",
         "@types/webxr": "*",
         "fflate": "~0.8.2",
@@ -20251,9 +20251,9 @@
       "peer": true
     },
     "three": {
-      "version": "0.165.0",
-      "resolved": "https://registry.npmjs.org/three/-/three-0.165.0.tgz",
-      "integrity": "sha512-cc96IlVYGydeceu0e5xq70H8/yoVT/tXBxV/W8A/U6uOq7DXc4/s1Mkmnu6SqoYGhSRWWYFOhVwvq6V0VtbplA==",
+      "version": "0.166.1",
+      "resolved": "https://registry.npmjs.org/three/-/three-0.166.1.tgz",
+      "integrity": "sha512-LtuafkKHHzm61AQA1be2MAYIw1IjmhOUxhBa0prrLpEMWbV7ijvxCRHjSgHPGp2493wLBzwKV46tA9nivLEgKg==",
       "dev": true
     },
     "throat": {
diff --git a/package.json b/package.json
index d6bc324..628a4a8 100644
--- a/package.json
+++ b/package.json
@@ -40,7 +40,7 @@
   "devDependencies": {
     "@babel/core": "^7.21.8",
     "@babel/preset-env": "^7.21.5",
-    "@types/three": "^0.165.0",
+    "@types/three": "^0.166.0",
     "@typescript-eslint/eslint-plugin": "^7.14.1",
     "@typescript-eslint/parser": "^7.14.1",
     "babel-jest": "^29.5.0",
@@ -51,7 +51,7 @@
     "eslint-plugin-jest": "^28.6.0",
     "jest": "^27.1.1",
     "jest-cli": "^27.1.1",
-    "three": "^0.165.0",
+    "three": "^0.166.0",
     "typescript": "^5.1.3",
     "vite": "^5.3.1"
   },
diff --git a/src/base/TilesRendererBase.js b/src/base/TilesRendererBase.js
index 2ecbd6e..8e5ddcb 100644
--- a/src/base/TilesRendererBase.js
+++ b/src/base/TilesRendererBase.js
@@ -156,7 +156,11 @@ export class TilesRendererBase {
 
 		this.plugins.push( plugin );
 		plugin[ PLUGIN_REGISTERED ] = true;
-		plugin.init( this );
+		if ( plugin.init ) {
+
+			plugin.init( this );
+
+		}
 
 	}
 
@@ -283,6 +287,20 @@ export class TilesRendererBase {
 
 	disposeTile( tile ) {
 
+		if ( tile.__visible ) {
+
+			this.setTileVisible( tile, false );
+			tile.__visible = false;
+
+		}
+
+		if ( tile.__active ) {
+
+			this.setTileActive( tile, false );
+			tile.__active = false;
+
+		}
+
 	}
 
 	preprocessNode( tile, tileSetDir, parentTile = null ) {
@@ -688,6 +706,7 @@ export class TilesRendererBase {
 
 				stats.parsing --;
 				tile.__loadingState = LOADED;
+				lruCache.updateMemoryUsage( tile );
 
 				if ( tile.__wasSetVisible ) {
 
diff --git a/src/three/TilesRenderer.js b/src/three/TilesRenderer.js
index ecef92b..324691e 100644
--- a/src/three/TilesRenderer.js
+++ b/src/three/TilesRenderer.js
@@ -17,6 +17,7 @@ import { raycastTraverse, raycastTraverseFirstHit } from './raycastTraverse.js';
 import { readMagicBytes } from '../utilities/readMagicBytes.js';
 import { TileBoundingVolume } from './math/TileBoundingVolume.js';
 import { ExtendedFrustum } from './math/ExtendedFrustum.js';
+import { estimateBytesUsed } from './utilities.js';
 
 // In three.js r165 and higher raycast traversal can be ended early
 const REVISION_165 = parseInt( REVISION ) < 165;
@@ -75,6 +76,8 @@ export class TilesRenderer extends TilesRendererBase {
 		this._eventDispatcher = new EventDispatcher();
 		this._upRotationMatrix = new Matrix4();
 
+		this.lruCache.getMemoryUsageCallback = tile => tile.cached.bytesUsed || 0;
+
 		// flag indicating whether frustum culling should be disabled
 		this._autoDisableRendererCulling = true;
 
@@ -779,6 +782,7 @@ export class TilesRenderer extends TilesRendererBase {
 		cached.textures = textures;
 		cached.scene = scene;
 		cached.metadata = metadata;
+		cached.bytesUsed = estimateBytesUsed( scene );
 
 		// dispatch an event indicating that this model has completed
 		this.dispatchEvent( {
@@ -799,6 +803,8 @@ export class TilesRenderer extends TilesRendererBase {
 
 	disposeTile( tile ) {
 
+		super.disposeTile( tile );
+
 		// This could get called before the tile has finished downloading
 		const cached = tile.cached;
 		if ( cached.scene ) {
@@ -871,8 +877,6 @@ export class TilesRenderer extends TilesRendererBase {
 
 		}
 
-		this.activeTiles.delete( tile );
-		this.visibleTiles.delete( tile );
 		cached._loadIndex ++;
 
 	}
diff --git a/src/three/utilities.js b/src/three/utilities.js
new file mode 100644
index 0000000..adb1d10
--- /dev/null
+++ b/src/three/utilities.js
@@ -0,0 +1,54 @@
+import { estimateBytesUsed as _estimateBytesUsed } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
+import * as THREE from 'three';
+
+// Returns the estimated number of bytes used by the object
+export function estimateBytesUsed( object ) {
+
+	// NOTE: This is for backwards compatibility and should be removed later
+	const { TextureUtils } = THREE;
+	if ( ! TextureUtils ) {
+
+		return 0;
+
+	}
+
+	const dedupeSet = new Set();
+
+	let totalBytes = 0;
+	object.traverse( c => {
+
+		// get geometry bytes
+		if ( c.geometry && ! dedupeSet.has( c.geometry ) ) {
+
+			totalBytes += _estimateBytesUsed( c.geometry );
+			dedupeSet.add( c.geometry );
+
+		}
+
+		// get material bytes
+		if ( c.material ) {
+
+			const material = c.material;
+			for ( const key in material ) {
+
+				const value = material[ key ];
+				if ( value && value.isTexture && ! dedupeSet.has( value ) ) {
+
+					const { format, type, image } = value;
+					const { width, height } = image;
+					const bytes = TextureUtils.getByteLength( width, height, format, type );
+					totalBytes += value.generateMipmaps ? bytes * 4 / 3 : bytes;
+
+					dedupeSet.add( value );
+
+				}
+
+			}
+
+		}
+
+	} );
+
+	return totalBytes;
+
+}
diff --git a/src/utilities/LRUCache.js b/src/utilities/LRUCache.js
index 4099cc4..a2a569e 100644
--- a/src/utilities/LRUCache.js
+++ b/src/utilities/LRUCache.js
@@ -1,3 +1,5 @@
+const GIGABYTE_BYTES = 2 ** 30;
+
 class LRUCache {
 
 	get unloadPriorityCallback() {
@@ -35,6 +37,8 @@ class LRUCache {
 		// options
 		this.maxSize = 800;
 		this.minSize = 600;
+		this.minBytesSize = 0.3 * GIGABYTE_BYTES;
+		this.maxBytesSize = 0.4 * GIGABYTE_BYTES;
 		this.unloadPercent = 0.05;
 
 		// "itemSet" doubles as both the list of the full set of items currently
@@ -46,8 +50,11 @@ class LRUCache {
 		this.callbacks = new Map();
 		this.markUnusedQueued = false;
 		this.unloadingHandle = - 1;
+		this.cachedBytes = 0;
+		this.bytesMap = new Map();
 
 		this._unloadPriorityCallback = null;
+		this.getMemoryUsageCallback = () => 0;
 
 		const itemSet = this.itemSet;
 		this.defaultPriorityCallback = item => itemSet.get( item );
@@ -57,7 +64,7 @@ class LRUCache {
 	// Returns whether or not the cache has reached the maximum size
 	isFull() {
 
-		return this.itemSet.size >= this.maxSize;
+		return this.itemSet.size >= this.maxSize || this.cachedBytes >= this.maxBytesSize;
 
 	}
 
@@ -85,11 +92,16 @@ class LRUCache {
 		const usedSet = this.usedSet;
 		const itemList = this.itemList;
 		const callbacks = this.callbacks;
+		const bytesMap = this.bytesMap;
 		itemList.push( item );
 		usedSet.add( item );
 		itemSet.set( item, Date.now() );
 		callbacks.set( item, removeCb );
 
+		const bytes = this.getMemoryUsageCallback( item );
+		this.cachedBytes += bytes;
+		bytesMap.set( item, bytes );
+
 		return true;
 
 	}
@@ -99,10 +111,14 @@ class LRUCache {
 		const usedSet = this.usedSet;
 		const itemSet = this.itemSet;
 		const itemList = this.itemList;
+		const bytesMap = this.bytesMap;
 		const callbacks = this.callbacks;
 
 		if ( itemSet.has( item ) ) {
 
+			this.cachedBytes -= bytesMap.get( item );
+			bytesMap.delete( item );
+
 			callbacks.get( item )( item );
 
 			const index = itemList.indexOf( item );
@@ -119,6 +135,24 @@ class LRUCache {
 
 	}
 
+	updateMemoryUsage( item ) {
+
+		const itemSet = this.itemSet;
+		const bytesMap = this.bytesMap;
+		if ( ! itemSet.has( item ) ) {
+
+			return;
+
+		}
+
+		this.cachedBytes -= bytesMap.get( item );
+
+		const bytes = this.getMemoryUsageCallback( item );
+		bytesMap.set( item, bytes );
+		this.cachedBytes += bytes;
+
+	}
+
 	markUsed( item ) {
 
 		if ( this.markUnusedQueued ) {
@@ -155,18 +189,28 @@ class LRUCache {
 	// Maybe call it "cleanup" or "unloadToMinSize"
 	unloadUnusedContent() {
 
-		const unloadPercent = this.unloadPercent;
-		const targetSize = this.minSize;
-		const itemList = this.itemList;
-		const itemSet = this.itemSet;
-		const usedSet = this.usedSet;
-		const callbacks = this.callbacks;
+		const {
+			unloadPercent,
+			minSize,
+			maxSize,
+			itemList,
+			itemSet,
+			usedSet,
+			callbacks,
+			bytesMap,
+			minBytesSize,
+			maxBytesSize,
+		} = this;
+
 		const unused = itemList.length - usedSet.size;
-		const excess = itemList.length - targetSize;
+		const excessNodes = Math.max( Math.min( itemList.length - minSize, unused ), 0 );
+		const excessBytes = this.cachedBytes - minBytesSize;
 		const unloadPriorityCallback = this.unloadPriorityCallback || this.defaultPriorityCallback;
-		let remaining = excess;
+		let remaining = excessNodes;
 
-		if ( excess > 0 && unused > 0 ) {
+		const hasNodesToUnload = excessNodes > 0 && unused > 0 || itemList.length > maxSize;
+		const hasBytesToUnload = unused && this.cachedBytes > minBytesSize || this.cachedBytes > maxBytesSize;
+		if ( hasBytesToUnload || hasNodesToUnload ) {
 
 			// used items should be at the end of the array
 			itemList.sort( ( a, b ) => {
@@ -195,22 +239,57 @@ class LRUCache {
 
 			// address corner cases where the minSize might be zero or smaller than maxSize - minSize,
 			// which would result in a very small or no items being unloaded.
-			const unusedExcess = Math.min( excess, unused );
-			const maxUnload = Math.max( targetSize * unloadPercent, unusedExcess * unloadPercent );
-			let nodesToUnload = Math.min( maxUnload, unused );
-			nodesToUnload = Math.ceil( nodesToUnload );
-			remaining = excess - nodesToUnload;
+			const maxUnload = Math.max( minSize * unloadPercent, excessNodes * unloadPercent );
+			const nodesToUnload = Math.ceil( Math.min( maxUnload, unused, excessNodes ) );
+			const maxBytesUnload = Math.max( unloadPercent * excessBytes, unloadPercent * minBytesSize );
+			const bytesToUnload = Math.min( maxBytesUnload, excessBytes );
+
+			let removedNodes = 0;
+			let removedBytes = 0;
+			while ( true ) {
+
+				const item = itemList[ removedNodes ];
+				const bytes = bytesMap.get( item );
+
+				// note that these conditions ensure we keep one tile over the byte cap so we can
+				// align with the the isFull function reports.
+
+				// base while condition
+				const doContinue =
+					removedNodes < nodesToUnload ||
+					removedBytes < bytesToUnload ||
+					this.cachedBytes - removedBytes - bytes > maxBytesSize ||
+					itemList.length - removedNodes > maxSize;
+
+				// don't unload any used tiles unless we're above our size cap
+				if (
+					! doContinue ||
+					removedNodes >= unused &&
+					this.cachedBytes - removedBytes - bytes <= maxBytesSize &&
+					itemList.length - removedNodes <= maxSize
+				) {
+
+					break;
+
+				}
 
-			const removedItems = itemList.splice( 0, nodesToUnload );
-			for ( let i = 0, l = removedItems.length; i < l; i ++ ) {
+				removedBytes += bytes;
+				removedNodes ++;
 
-				const item = removedItems[ i ];
+				bytesMap.delete( item );
 				callbacks.get( item )( item );
 				itemSet.delete( item );
 				callbacks.delete( item );
 
 			}
 
+			itemList.splice( 0, removedNodes );
+			this.cachedBytes -= removedBytes;
+
+			// if we didn't remove enough nodes or we still have excess bytes and there are nodes to removed
+			// then we want to fire another round of unloading
+			remaining = removedNodes < excessNodes || removedBytes < excessBytes && removedNodes < unused;
+
 		}
 
 		if ( remaining ) {


Test patch:
diff --git a/test/LRUCache.test.js b/test/LRUCache.test.js
index f7a5ae5..4170584 100644
--- a/test/LRUCache.test.js
+++ b/test/LRUCache.test.js
@@ -95,4 +95,90 @@ describe( 'LRUCache', () => {
 
 	} );
 
+	it( 'should evict items if they are the max item length even if they are used.', () => {
+
+		const cache = new LRUCache();
+		cache.unloadPriorityCallback = ( itemA, itemB ) => itemA.priority - itemB.priority;
+		cache.minSize = 0;
+		cache.maxSize = 10;
+
+		for ( let i = 0; i < 10; i ++ ) {
+
+			const item = { priority: 1 };
+			cache.add( item, () => {} );
+			cache.markUsed( item );
+
+		}
+
+		expect( cache.itemList.length ).toEqual( 10 );
+
+		cache.maxSize = 3;
+		cache.unloadUnusedContent();
+		expect( cache.itemList.length ).toEqual( 3 );
+
+	} );
+
+	it( 'should limit the amount of bytes allowed in the cache.', () => {
+
+		const cache = new LRUCache();
+		cache.minBytesSize = 5;
+		cache.maxBytesSize = 25;
+		cache.unloadPercent = 1;
+		cache.getMemoryUsageCallback = () => 4;
+
+		for ( let i = 0; i < 10; i ++ ) {
+
+			const item = { priority: 1 };
+			cache.add( item, () => {} );
+
+		}
+
+		expect( cache.itemList.length ).toEqual( 7 );
+		expect( cache.cachedBytes ).toEqual( 28 );
+
+		cache.markAllUnused();
+		cache.unloadUnusedContent();
+		expect( cache.itemList.length ).toEqual( 1 );
+		expect( cache.cachedBytes ).toEqual( 4 );
+
+	} );
+
+	it( 'should update memory usage when the items are triggers.', () => {
+
+		const cache = new LRUCache();
+		cache.minBytesSize = 10;
+		cache.maxBytesSize = 25;
+		cache.unloadPercent = 1;
+		cache.getMemoryUsageCallback = () => 1;
+
+		const items = new Array( 10 ).fill().map( () => ( { priority: 1 } ) );
+		for ( let i = 0; i < 10; i ++ ) {
+
+			cache.add( items[ i ], () => {} );
+
+		}
+
+		expect( cache.cachedBytes ).toEqual( 10 );
+		expect( cache.itemList.length ).toEqual( 10 );
+
+		cache.unloadUnusedContent();
+		expect( cache.cachedBytes ).toEqual( 10 );
+		expect( cache.itemList.length ).toEqual( 10 );
+
+		cache.getMemoryUsageCallback = () => 4;
+		for ( let i = 0; i < 10; i ++ ) {
+
+			cache.updateMemoryUsage( items[ i ] );
+
+		}
+
+		expect( cache.cachedBytes ).toEqual( 40 );
+		expect( cache.itemList.length ).toEqual( 10 );
+
+		cache.unloadUnusedContent();
+		expect( cache.cachedBytes ).toEqual( 28 );
+		expect( cache.itemList.length ).toEqual( 7 );
+
+	} );
+
 } );