Skip to content

Commit f64fb09

Browse files
pengdevgithub-actions[bot]
authored andcommitted
Reset stale texture ID in BitmapWidgetRenderer after glDeleteTextures (#10780)
## Summary Fixes https://mapbox.atlassian.net/browse/MAPSAND-2215 - Stale texture ID reuse in `BitmapWidgetRenderer` causing rendering artifacts on Android Auto after surface recreation - Adds `textures[0] = 0` in `release()` after `glDeleteTextures` to prevent the old ID from being reused on re-attach - New unit tests in `BitmapWidgetRendererTest.kt` verifying texture reallocation and `needRender` state after release ## Problem Customers report intermittent rendering artifacts and reversed/large bitmaps on Android Auto when using `BitmapWidget` (e.g., speedometer). Reported on multiple devices (OnePlus Nord 4, Samsung Galaxy S25, Pixel 7, Fairphone 5) all running Android 15, Maps SDK 11.6.0. ### Root Cause `BitmapWidgetRenderer.release()` deletes the GL texture but does **not** reset `textures[0] = 0`: ```kotlin // BEFORE fix override fun release() { lock.withLock { if (program != 0) { GLES20.glDeleteTextures(textures.size, textures, 0) // BUG: textures[0] still holds the old (now freed) ID! GLES20.glDeleteProgram(program) program = 0 } needRender = false } } ``` Meanwhile, `textureFromBitmapIfChanged()` only allocates a new texture when `textures[0] == 0`: ```kotlin if (textures[0] == 0) { // skipped when textures[0] still holds stale ID GLES20.glGenTextures(1, textures, 0) } ``` ### What Happens on Android Auto The widget renderer and native map renderer **share a texture namespace** (via a shared EGL context), so texture IDs can collide between them. ```mermaid sequenceDiagram participant AA as Android Auto participant W as BitmapWidgetRenderer participant GPU as GPU Driver participant Map as Native Map Renderer Note over AA: Car head unit connected AA->>W: prepare() + render() W->>GPU: glGenTextures → ID 42 W->>GPU: texImage2D(42, speedometer bitmap) Note over W: textures[0] = 42 Note over AA: Surface destroyed (disconnect/reconnect) AA->>W: release() W->>GPU: glDeleteTextures(42) Note over W,GPU: ID 42 freed, but textures[0] still holds 42! Note over Map: Native renderer allocates textures... Map->>GPU: glGenTextures → ID 42 (recycled!) Map->>GPU: texImage2D(42, map tile bitmap) Note over AA: New surface created AA->>W: prepare() + render() Note over W: textures[0] = 42 ≠ 0, skip glGenTextures W->>GPU: glBindTexture(42) W->>GPU: texImage2D(42, speedometer bitmap) Note over GPU: OVERWRITES the map tile texture! Note over GPU: Frame composited: map tile shows<br/>speedometer bitmap = giant reversed image ``` The artifact appears as a **large reversed bitmap** because: - **Large**: The speedometer bitmap overwrites a map tile texture, which is drawn at map-tile scale (covering a large screen area) - **Reversed**: Map tile UV coordinates differ from widget UV coordinates, so the bitmap appears flipped ### Why Android Auto Specifically? Android Auto has significantly more surface lifecycle churn than a normal `MapView`: - Connecting/disconnecting from the car head unit destroys and recreates the surface - Google's `SurfaceContainer` can call `onSurfaceAvailable` without a prior `onSurfaceDestroyed` (acknowledged in `CarMapSurfaceOwner.kt` referencing [Google issue #235121269](https://issuetracker.google.com/issues/235121269)) - Each cycle triggers `release()` → re-`prepare()`, hitting this bug A regular `MapView` in an Activity rarely goes through this cycle, which is why the bug was only reported on Android Auto. ## Fix One line — reset `textures[0]` after deletion so the next render allocates a fresh ID: ```kotlin GLES20.glDeleteTextures(textures.size, textures, 0) textures[0] = 0 // ← ensures glGenTextures is called on next render ``` This matches the pattern already used correctly in `MapboxWidgetRenderer.deleteFrameBufferWithTexture()`. ## Key Changes - **`BitmapWidgetRenderer.kt`**: Add `textures[0] = 0` after `glDeleteTextures` in `release()` - **`BitmapWidgetRendererTest.kt`** (new): Robolectric tests — `release then re-render allocates fresh texture` and `release sets needRender to false` ## Test plan - [x] Unit tests added and passing - [x] Manual verification on Android Auto (surface destroy/recreate cycle) cc @mapbox/maps-android cc @mapbox/sdk-platform GitOrigin-RevId: 457b011818610755601dd0c12b5f1a9cf56d5e4b
1 parent c4de168 commit f64fb09

3 files changed

Lines changed: 117 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Mapbox welcomes participation and contributions from everyone.
1414
* Fix NPE crash in `PointAnnotationClusterActivity` example when the remote GeoJSON endpoint returns a non-successful HTTP response.
1515
* Fix `MapSurface.setMaximumFps` not working correctly on secondary displays (e.g. Android Auto). Use `Context.getDisplay()` on API 30+ to get the actual display refresh rate instead of always using the primary display's rate.
1616
* Fix `PointAnnotationManager.iconImageBitmap` setter not registering the bitmap image with the style, causing group-level bitmap icons to be invisible.
17+
* Fix intermittent rendering artifacts (reversed/large bitmaps) on Android Auto caused by stale texture ID reuse in `BitmapWidgetRenderer` after surface recreation.
1718

1819
# 11.19.0 February 24, 2026
1920

maps-sdk/src/main/java/com/mapbox/maps/renderer/widget/BitmapWidgetRenderer.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@ internal class BitmapWidgetRenderer(
235235
GLES20.glDeleteShader(vertexShader)
236236
GLES20.glDeleteShader(fragmentShader)
237237
GLES20.glDeleteTextures(textures.size, textures, 0)
238+
textures[0] = 0
238239
GLES20.glDeleteProgram(program)
239240
program = 0
240241
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package com.mapbox.maps.renderer.widget
2+
3+
import android.graphics.Bitmap
4+
import android.opengl.GLES20
5+
import android.opengl.GLUtils
6+
import com.mapbox.maps.logE
7+
import com.mapbox.maps.logI
8+
import com.mapbox.maps.logW
9+
import io.mockk.*
10+
import org.junit.After
11+
import org.junit.Assert.assertFalse
12+
import org.junit.Assert.assertTrue
13+
import org.junit.Before
14+
import org.junit.Test
15+
import org.junit.runner.RunWith
16+
import org.robolectric.RobolectricTestRunner
17+
18+
@RunWith(RobolectricTestRunner::class)
19+
internal class BitmapWidgetRendererTest {
20+
21+
private lateinit var renderer: BitmapWidgetRenderer
22+
private lateinit var bitmap: Bitmap
23+
private var genTexturesCallCount = 0
24+
25+
@Before
26+
fun setUp() {
27+
genTexturesCallCount = 0
28+
29+
mockkStatic("com.mapbox.maps.MapboxLogger")
30+
every { logE(any(), any()) } just Runs
31+
every { logI(any(), any()) } just Runs
32+
every { logW(any(), any()) } just Runs
33+
34+
mockkStatic("android.opengl.GLES20")
35+
mockkStatic("android.opengl.GLUtils")
36+
37+
every { GLES20.glGetIntegerv(any(), any(), any()) } just Runs
38+
every { GLES20.glCreateShader(any()) } returns 1
39+
every { GLES20.glShaderSource(any(), any()) } just Runs
40+
every { GLES20.glCompileShader(any()) } just Runs
41+
every { GLES20.glGetShaderiv(any(), any(), any(), any()) } answers {
42+
val params = arg<IntArray>(2)
43+
params[0] = GLES20.GL_TRUE
44+
}
45+
every { GLES20.glCreateProgram() } returns 1
46+
every { GLES20.glAttachShader(any(), any()) } just Runs
47+
every { GLES20.glLinkProgram(any()) } just Runs
48+
every { GLES20.glGetUniformLocation(any(), any()) } returns 1
49+
every { GLES20.glGetAttribLocation(any(), any()) } returns 1
50+
every { GLES20.glGetError() } returns GLES20.GL_NO_ERROR
51+
every { GLES20.glGenTextures(any(), any<IntArray>(), any()) } answers {
52+
val texArray = arg<IntArray>(1)
53+
texArray[0] = 42 + genTexturesCallCount
54+
genTexturesCallCount++
55+
}
56+
every { GLES20.glBindTexture(any(), any()) } just Runs
57+
every { GLES20.glTexParameterf(any(), any(), any()) } just Runs
58+
every { GLES20.glUniformMatrix4fv(any(), any(), any(), any()) } just Runs
59+
every { GLES20.glUniform1i(any(), any()) } just Runs
60+
every { GLES20.glEnableVertexAttribArray(any()) } just Runs
61+
every { GLES20.glVertexAttribPointer(any(), any(), any(), any(), any(), any<java.nio.Buffer>()) } just Runs
62+
every { GLES20.glDrawArrays(any(), any(), any()) } just Runs
63+
every { GLES20.glDisableVertexAttribArray(any()) } just Runs
64+
every { GLES20.glBindBuffer(any(), any()) } just Runs
65+
every { GLES20.glUseProgram(any()) } just Runs
66+
every { GLES20.glDeleteTextures(any(), any<IntArray>(), any()) } just Runs
67+
every { GLES20.glDeleteProgram(any()) } just Runs
68+
every { GLES20.glDeleteShader(any()) } just Runs
69+
every { GLES20.glDetachShader(any(), any()) } just Runs
70+
every { GLES20.glEnable(any()) } just Runs
71+
every { GLES20.glBlendFunc(any(), any()) } just Runs
72+
every { GLUtils.texImage2D(any(), any(), any<Bitmap>(), any()) } just Runs
73+
74+
bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)
75+
renderer = BitmapWidgetRenderer(bitmap, WidgetPosition { })
76+
}
77+
78+
@After
79+
fun tearDown() {
80+
unmockkStatic("android.opengl.GLES20")
81+
unmockkStatic("android.opengl.GLUtils")
82+
unmockkStatic("com.mapbox.maps.MapboxLogger")
83+
}
84+
85+
@Test
86+
fun `release then re-render allocates fresh texture`() {
87+
renderer.onSurfaceChanged(800, 600)
88+
renderer.prepare()
89+
renderer.render()
90+
91+
// First render should have called glGenTextures once
92+
verify(exactly = 1) { GLES20.glGenTextures(1, any(), 0) }
93+
94+
// Release deletes the texture
95+
renderer.release()
96+
verify { GLES20.glDeleteTextures(any(), any<IntArray>(), any()) }
97+
98+
// Re-render after release: render() internally calls prepare() when program == 0,
99+
// and should call glGenTextures again because textures[0] was reset to 0
100+
renderer.onSurfaceChanged(800, 600)
101+
renderer.render()
102+
103+
// glGenTextures should have been called a second time for the new texture
104+
verify(exactly = 2) { GLES20.glGenTextures(1, any(), 0) }
105+
}
106+
107+
@Test
108+
fun `release sets needRender to false`() {
109+
renderer.onSurfaceChanged(800, 600)
110+
renderer.prepare()
111+
assertTrue(renderer.needRender)
112+
renderer.release()
113+
assertFalse(renderer.needRender)
114+
}
115+
}

0 commit comments

Comments
 (0)