@@ -73,6 +73,7 @@ import kotlin.concurrent.Volatile
7373import kotlin.time.Clock
7474import kotlin.time.Duration.Companion.milliseconds
7575import kotlin.time.Instant
76+ import kotlin.time.TimeSource
7677import kotlinx.coroutines.CancellationException
7778import kotlinx.coroutines.CoroutineScope
7879import kotlinx.coroutines.Dispatchers
@@ -505,11 +506,26 @@ open class Ogc3dTilesLayer(
505506 minOf(adaptiveInstallCap + 1 , MAX_INSTALL_CAP )
506507 else -> adaptiveInstallCap
507508 }
509+ // Eager-bind budget from MEASURED upload throughput: each batch times its own
510+ // GL-thread duration, and the budget targets a fixed bind slice. A rare oversized
511+ // batch spikes one frame yet barely moves a whole-frame-work average, so the
512+ // install cap's EWMA controller cannot push back on the bind tail.
513+ val bindBytes = lastEagerBindBytes
514+ if (bindBytes > 0 ) {
515+ lastEagerBindBytes = 0
516+ val bindMs = lastEagerBindNanos / 1_000_000.0
517+ if (bindMs > 0.05 ) {
518+ val bytesPerMs = bindBytes / bindMs
519+ ewmaBindBytesPerMs =
520+ if (ewmaBindBytesPerMs == 0.0 ) bytesPerMs
521+ else bytesPerMs * EWMA_ALPHA + ewmaBindBytesPerMs * (1.0 - EWMA_ALPHA )
522+ adaptiveEagerBindBudget = (ewmaBindBytesPerMs * TARGET_EAGER_BIND_MS ).toInt()
523+ .coerceIn(MIN_EAGER_BIND_BUDGET , MAX_EAGER_BIND_BUDGET )
524+ }
525+ }
508526 var loadedAny = false
509527 var stoppedAtBudget = false
510528 var uploaded = 0
511- // Lazy-init — most frames upload nothing and shouldn't pay for an ArrayList.
512- var newlyUploadedTextures: ArrayList <Texture >? = null
513529 while (uploaded < adaptiveInstallCap) {
514530 val entry = pendingMeshUploads.tryReceive().getOrNull() ? : break
515531 uploaded++
@@ -520,13 +536,7 @@ open class Ogc3dTilesLayer(
520536 entry.tile.content = entry.shell
521537 entry.tile.loadState = Tile3d .LoadState .LOADED
522538 entry.shell.submeshes?.forEach { sub ->
523- sub.baseColorTextureKey?.let { key ->
524- (rc.renderResourceCache[key] as ? Texture )?.let { tex ->
525- val list = newlyUploadedTextures
526- ? : ArrayList <Texture >(8 ).also { newlyUploadedTextures = it }
527- list.add(tex)
528- }
529- }
539+ sub.baseColorTextureKey?.let { key -> pendingEagerBinds.addLast(key) }
530540 }
531541 loadedAny = true
532542 } catch (t: Throwable ) {
@@ -538,25 +548,55 @@ open class Ogc3dTilesLayer(
538548 }
539549 }
540550 if (uploaded >= adaptiveInstallCap) stoppedAtBudget = true
541- newlyUploadedTextures?.let { rc.offerBackgroundDrawable(TextureEagerBindDrawable (it)) }
542- // Redraw if we installed new content this frame OR if we hit the cap with
543- // entries still pending — without the latter, an idle camera could leave the
544- // channel sitting with un-installed uploads.
545- if (loadedAny || stoppedAtBudget) rc.requestRedraw()
551+ // Byte-budgeted eager-bind batch; the rest carries over to following frames. Binding
552+ // every fresh texture in one frame stalled the GL thread 20-150 ms in city scenes -
553+ // a single 2048 mipmapped texture is ~21 MB of glTexImage2D. An oversized texture
554+ // still binds alone. Keys (not Texture refs) so an eviction while pending is a skip,
555+ // not a resurrected orphan GL texture.
556+ if (pendingEagerBinds.isNotEmpty()) {
557+ var batchBytes = 0
558+ var batch: ArrayList <Texture >? = null
559+ while (pendingEagerBinds.isNotEmpty() && batchBytes < adaptiveEagerBindBudget) {
560+ val texture = rc.renderResourceCache[pendingEagerBinds.removeFirst()] as ? Texture ? : continue
561+ batchBytes + = texture.byteCount
562+ (batch ? : ArrayList <Texture >(8 ).also { batch = it }).add(texture)
563+ }
564+ batch?.let { rc.offerBackgroundDrawable(TextureEagerBindDrawable (it, batchBytes)) }
565+ }
566+ // Redraw if we installed new content this frame OR if we hit the cap with entries
567+ // still pending OR if eager binds carried over — without it, an idle camera could
568+ // leave the channel or the bind queue sitting with work.
569+ if (loadedAny || stoppedAtBudget || pendingEagerBinds.isNotEmpty()) rc.requestRedraw()
546570 }
547571
548572 // Adaptive throttling state — read + written only on the render thread, so plain vars.
549573 private var ewmaGLFrameWorkMs: Double = 0.0
550574 private var adaptiveInstallCap: Int = INITIAL_INSTALL_CAP
551575
576+ /* * RR-cache keys of freshly-installed textures awaiting their first GL bind; drained a
577+ * byte-budgeted batch per frame by [drainPendingMeshUploads]. Render thread only. */
578+ private val pendingEagerBinds = ArrayDeque <Any >()
579+ private var adaptiveEagerBindBudget: Int = INITIAL_EAGER_BIND_BUDGET
580+ /* * EWMA of measured texture-upload throughput in bytes per GL-thread millisecond. */
581+ private var ewmaBindBytesPerMs = 0.0
582+ /* * Last batch measurement, written by the GL thread ([TextureEagerBindDrawable.draw]) and
583+ * consumed once by the render thread. Nanos written BEFORE bytes - bytes > 0 publishes
584+ * the pair. */
585+ @Volatile private var lastEagerBindNanos = 0L
586+ @Volatile private var lastEagerBindBytes = 0
587+
552588 /* * Force-binds freshly-cached textures so their first [Texture.allocTexImage] runs
553589 * this frame, recycling the CPU-side Bitmap that would otherwise sit in Android
554590 * native heap until the tile happens to draw. */
555- private class TextureEagerBindDrawable (private val textures : List <Texture >) : Drawable {
591+ private inner class TextureEagerBindDrawable (
592+ private val textures : List <Texture >,
593+ private val bytes : Int ,
594+ ) : Drawable {
556595 override fun draw (dc : DrawContext ) {
557- // GL cost is captured by the WorldWind.drawFrame whole-frame wall-time measurement
558- // — no per-drawable self-timing needed.
596+ val start = TimeSource .Monotonic .markNow()
559597 for (texture in textures) texture.bindTexture(dc)
598+ lastEagerBindNanos = start.elapsedNow().inWholeNanoseconds
599+ lastEagerBindBytes = bytes
560600 }
561601 override fun recycle () {}
562602 }
@@ -1260,6 +1300,16 @@ open class Ogc3dTilesLayer(
12601300 private const val MIN_INSTALL_CAP : Int = 1
12611301 private const val MAX_INSTALL_CAP : Int = 8
12621302
1303+ /* * GL-thread milliseconds one eager-bind batch may take; the byte budget is this
1304+ * slice times the measured upload throughput. Sub-half-vsync at 120 Hz. */
1305+ private const val TARGET_EAGER_BIND_MS : Double = 3.0
1306+
1307+ /* * Eager-bind byte-budget bounds and starting value before the first batch
1308+ * measurement lands; the working value tracks measured throughput. */
1309+ private const val INITIAL_EAGER_BIND_BUDGET : Int = 8 * 1024 * 1024
1310+ private const val MIN_EAGER_BIND_BUDGET : Int = 2 * 1024 * 1024
1311+ private const val MAX_EAGER_BIND_BUDGET : Int = 32 * 1024 * 1024
1312+
12631313 /* * Pending-upload channel capacity. Back-pressures parsers when uploads can't keep
12641314 * up. Each in-flight prep retains a decoded bitmap + combined-vertex bytes (~5 MB
12651315 * CPU memory for Photoreal); 4 in-flight keeps Android native-heap headroom even
0 commit comments