Skip to content

Commit 5bec024

Browse files
EMaksymenkoclaude
andcommitted
3D Tiles: byte-budget eager texture binds across frames
The adaptive install cap counted mesh installs, but every texture from the installed meshes eager-bound in a single frame - and one 2048 mipmapped texture is ~21 MB of glTexImage2D. City-scale loading produced 20-150 ms GL-thread stalls in the eager-bind drawable while the pipeline stayed "within budget". Freshly-installed texture keys now queue on the layer and each frame binds a byte-capped batch; the rest carries over, with a redraw requested while the queue drains. The budget derives from MEASURED upload throughput: each batch times its own GL-thread duration, an EWMA tracks bytes-per-ms, and the budget targets a 3 ms bind slice (clamped 2-32 MB). The whole-frame EWMA that drives the install cap cannot control this tail - a rare oversized batch spikes one frame yet barely moves a frame-work average, so that controller kept raising the budget it should have cut. An oversized texture still binds alone. Keys instead of Texture refs so a cache eviction while pending is a skip, not a resurrected orphan GL texture. Bitmap recycling - the reason eager binds exist - is delayed by at most a few frames and stays bounded by the batch drain. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXPy4LJSC8yVMp2RUfUE5q
1 parent 01c11f0 commit 5bec024

1 file changed

Lines changed: 67 additions & 17 deletions

File tree

worldwind/src/commonMain/kotlin/earth/worldwind/layer/ogc3d/Ogc3dTilesLayer.kt

Lines changed: 67 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import kotlin.concurrent.Volatile
7373
import kotlin.time.Clock
7474
import kotlin.time.Duration.Companion.milliseconds
7575
import kotlin.time.Instant
76+
import kotlin.time.TimeSource
7677
import kotlinx.coroutines.CancellationException
7778
import kotlinx.coroutines.CoroutineScope
7879
import 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

Comments
 (0)