Skip to content

Add experimental API to render WebGL textures on the Compose' canvas - #3323

Merged
Oleksandr Karpovich (eymar) merged 26 commits into
jb-mainfrom
ok/web-webgl-texture-adoption-demo_plus_api
Aug 27, 2026
Merged

Add experimental API to render WebGL textures on the Compose' canvas #3323
Oleksandr Karpovich (eymar) merged 26 commits into
jb-mainfrom
ok/web-webgl-texture-adoption-demo_plus_api

Conversation

@eymar

@eymar Oleksandr Karpovich (eymar) commented Aug 20, 2026

Copy link
Copy Markdown
Member

New public experimental APIs:

class WebGLRenderTarget internal constructor( // instantiated in rememberWebGLRenderTarget
    val htmlCanvas: HTMLCanvasElement,
    val webGLContext: WebGLRenderingContext,
    private val directContext: () -> DirectContext?,
    private val textureFactory: (IntSize) -> WebGLTexture,
    initialSize: IntSize,
) {
	
	val painter: Painter // Compose type used for drawing
	val size: IntSize
	val framebuffer: WebGLFramebuffer
	val webGlTexture: WebGLTexture
	var onTextureWillBeInvalidated: (() -> Unit)?
	fun render(block: () -> Unit): Boolean // the most important public method in this class
	fun markGLStateStale()
}

@Composable
fun rememberWebGLRenderTarget(
    size: IntSize
): WebGLRenderTarget?

@Composable
fun rememberWebGLRenderTarget(
    size: DpSize
): WebGLRenderTarget?

Simplest usage example:

/** Clears the texture to a pulsing color. No shaders, no resources, nothing to dispose. */
private class PulseRenderer(private val target: WebGLRenderTarget) {
    private var phase = 0f
    private var previousFrameTimeNanos = 0L

    fun render(frameTimeNanos: Long) {
        target.render { renderFrame(frameTimeNanos) }
    }

    private fun renderFrame(frameTimeNanos: Long): Unit =
        with(target) {
            val deltaNanos = if (previousFrameTimeNanos == 0L) {
                0L
            } else {
                frameTimeNanos - previousFrameTimeNanos
            }
            previousFrameTimeNanos = frameTimeNanos
            phase += deltaNanos / 1_000_000_000f * 1.5f
            val level = sin(phase) * 0.5f + 0.5f
            webGLContext.disable(SCISSOR_TEST)
            webGLContext.clearColor(0.15f * level, 0.55f * level, level, level)
            webGLContext.clear(COLOR_BUFFER_BIT)
        }
}

@Composable
private fun ColorPulse(isAnimating: Boolean) {
    val webGLRenderTarget = rememberWebGLRenderTarget(IntSize(64, 64))
    
    if (webGLRenderTarget == null) {
        Text("webGLRenderTarget is null")
        return
    }
    
    val pulse = remember(webGLRenderTarget) { PulseRenderer(webGLRenderTarget) }

    LaunchedEffect(webGLRenderTarget, isAnimating) {
        while (isAnimating) {
            withFrameNanos { frameTimeNanos ->
                pulse.render(frameTimeNanos)
            }
        }
    }

    LabelledContent("64×64 texture\nnothing but a pulsing clear color") {
        Image(
            painter = webGLRenderTarget.painter,
            contentDescription = null,
            contentScale = ContentScale.Crop,
            modifier = Modifier.fillMaxSize(),
        )
    }
}

Fixes https://youtrack.jetbrains.com/issue/CMP-9245/WebGL-interop-for-Wasm-target

Screen.Recording.2026-08-18.at.16.24.24.mov

Testing

  • Manual tests using the new demo screens
  • Added unit tests

Release Notes

Features - Web

  • Add experimental API to render WebGL textures

@eymar Oleksandr Karpovich (eymar) changed the title Add experimental API to render WebGL textures Add experimental API to render WebGL textures on the same canvas as Compose Aug 20, 2026
@eymar
Oleksandr Karpovich (eymar) marked this pull request as draft August 20, 2026 15:46
@eymar
Oleksandr Karpovich (eymar) marked this pull request as ready for review August 20, 2026 18:22
@eymar Oleksandr Karpovich (eymar) changed the title Add experimental API to render WebGL textures on the same canvas as Compose Add experimental API to render WebGL textures on the Compose' canvas Aug 20, 2026
@sargunv

Sargun Vohra (sargunv) commented Aug 23, 2026

Copy link
Copy Markdown

This is quite close to what MapLibre Compose needs. I use Compose/Skiko's WebGL2 context and share it with MapLibre GL JS, MapLibre renders into an offscreen RGBA8 texture and Skia samples that texture in the Compose draw frame.

Today I do this with some fragile Emscripten context discovery and skiko DirectContext interception.

For our current MapLibre GL JS integration, the framebuffer exposed by this API is sufficient: I can render MapLibre into it and use drawWebGLTexture to place the result in the Compose draw tree. This PR should let MapLibre Compose remove this fragile hack on the web target.

I would also like to use this API with something like my MapLibre Native Wasm prototype (maplibre/maplibre-native-ffi#443). That renderer runs in a separate Emscripten module, so it needs access to the actual WebGLTexture, not only the framebuffer. It registers that browser texture in its own Emscripten object table and passes the resulting handle to MapLibre Native.

It would help to expose the color texture as a borrowed resource. We would also need a notification before the texture is replaced or released. On resize, MapLibre must destroy the native render session that refers to the old texture before Compose releases it.

Additional context from a Codex / GPT 5.6 Sol agent

• The JetBrains API is the right abstraction for MapLibre Compose. It would replace most of our fragile Skiko/Emscripten integration. I would support the PR,
  while asking for one addition before treating it as sufficient for the native-FFI use case: expose the color texture and define its release lifecycle.

  ### How it maps to our integration

   Current MapLibre Compose seam                                                           Effect of JetBrains PR
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
   Discover Compose’s canvas and context through Emscripten globals in lib/maplibre-       Replaced by a target scoped to the correct Compose window
   compose/src/jsMain/kotlin/org/maplibre/compose/gljs/EmscriptenGl.kt:13
  ──────────────────────────────────────────────────────────────────────────────────────  ───────────────────────────────────────────────────────────────────
   Hook mangled Skiko exports and construct a borrowed DirectContext stand-in in lib/      Replaced completely
   maplibre-compose/src/jsMain/kotlin/org/maplibre/compose/gljs/SkikoGpuBridge.kt:14
  ──────────────────────────────────────────────────────────────────────────────────────  ───────────────────────────────────────────────────────────────────
   Allocate, register, adopt, and dispose the texture ourselves in lib/maplibre-           Replaced by rememberWebGLRenderTarget and drawWebGLTexture
   compose/src/jsMain/kotlin/org/maplibre/compose/gljs/GlJsRenderTarget.kt:12
  ──────────────────────────────────────────────────────────────────────────────────────  ───────────────────────────────────────────────────────────────────
   Lend the context to GL JS, redirect its default framebuffer, suppress context loss,     Remains MapLibre-specific
   and intercept repaint requests in lib/maplibre-compose/src/jsMain/kotlin/org/
   maplibre/compose/gljs/GlJsRuntime.kt:66
  ──────────────────────────────────────────────────────────────────────────────────────  ───────────────────────────────────────────────────────────────────
   Render synchronously inside the Compose draw callback in lib/maplibre-compose/src/      Moves into withFrameNanos; the texture draw remains inside Canvas
   jsMain/kotlin/org/maplibre/compose/map/GlJsMapSession.kt:153

  This also removes our current one-Compose-renderer-per-page limitation and should support multiple targets in one viewport naturally.

  ### The native-FFI gap

  maplibre-native-ffi PR #443 (https://github.com/maplibre/maplibre-native-ffi/pull/443) uses a second Emscripten module. It must register the same
  JavaScript WebGLTexture in that module’s GL table before passing its numeric handle to the borrowed-texture render session.

  JetBrains PR #3323 (https://github.com/JetBrains/compose-multiplatform-core/pull/3323) exposes the WebGLFramebuffer, canvas, and context, but keeps the
  attached WebGLTexture private. That is enough for MapLibre GL JS and three.js, which can render into a foreign framebuffer. It is not enough for the
  existing native-FFI borrowed-texture API without extracting the attachment through raw WebGL calls.

  I would request:

  - A read-only borrowed colorTexture: WebGLTexture?.
  - A lifecycle contract or callback that runs before that texture is released or replaced.
  - Ideally, a resource snapshot containing texture, framebuffer, size, and generation.

  The current generation is insufficient for ordered native teardown: it changes inside render() after the previous adopted texture has already been
  disposed. PR #443 deliberately destroys the native session before unregistering or releasing the texture.

  Two smaller requests:

  - Type the context as WebGL2, or explicitly document the supported downcast. The implementation requires WebGL2.
  - Document that state invalidation is bidirectional. render() resets Skia’s cache afterward, but the external renderer must invalidate its own cache before
    rendering. Our recent framebuffer-size fix (https://github.com/maplibre/maplibre-compose/pull/1051) and sampler-state fix
    (https://github.com/maplibre/maplibre-compose/pull/1052) are concrete examples of shared-context behavior that an engine cannot assume away.

  I would also ask for resize/disposal tests involving retained graphicsLayer display lists and a defined context-loss/recreation contract.

@eymar

Oleksandr Karpovich (eymar) commented Aug 25, 2026

Copy link
Copy Markdown
Member Author

Sargun Vohra (@sargunv) thanks for the feedback.

I updated the API to simplify your use case:

val webGlTexture: WebGLTexture
var onTextureWillBeInvalidated: (() -> Unit)?

@sargunv

Copy link
Copy Markdown

I test drove this build, it does work and mostly renders my previous hacks obsolete.

CleanShot 2026-08-25 at 16 16 18@2x

Still todo: on resize, the map disappears on resize and Compose UI gets corrupted. I'm investigating whether that's an integration issue on my end or yours

@eymar

Copy link
Copy Markdown
Member Author

Still todo: on resize, the map disappears on resize and Compose UI gets corrupted. I'm investigating whether that's an integration issue on my end or yours

I believe we have older skiko in this branch. Try to test with 0.152.0-alpha02

There was a problem that resize would cause webgl context recreation. And this PR assumes the context is stable.

It's fixed in latest skiko.

@sargunv

Copy link
Copy Markdown

works great with one more fix in #3344

you can see my integration here: maplibre/maplibre-compose#1114

@sargunv

Copy link
Copy Markdown

and confirmed works great on Kotlin/Wasm too, in this draft.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is 🔥🔥🔥

@eymar
Oleksandr Karpovich (eymar) merged commit bb293f4 into jb-main Aug 27, 2026
23 checks passed
@eymar
Oleksandr Karpovich (eymar) deleted the ok/web-webgl-texture-adoption-demo_plus_api branch August 27, 2026 13:25
Oleksandr Karpovich (eymar) pushed a commit that referenced this pull request Aug 27, 2026
Fix `WebGLRenderTarget` rendering after its size changes.

`Image.adoptTextureFrom` transfers ownership of the WebGL texture to
Skia, so closing the old image deletes that texture. The previous resize
path then tried to allocate storage on the deleted texture, which left
the framebuffer incomplete. This change creates a texture for each size
generation and releases the previous texture through one path after
notifying borrowers.

PR #3323 and its Skiko update are now on `jb-main`. This PR is the
focused resize follow-up.

## Testing

In this repo:

- `./gradlew :compose:ui:ui:jsBrowserTest --no-daemon
--no-configuration-cache`
- `./gradlew :mpp:publishComposeJbToMavenLocal -Pcompose.platforms=web
-Pjetbrains.publication.libraries=COMPOSE --no-daemon
--no-configuration-cache`

In maplibre/maplibre-compose#1114:

- MapLibre Compose demo startup and resize in Chromium, Safari, and
Firefox.
- MapLibre Compose JS browser suite: 204 tests, no failures or skips.


## Release Notes
N/A
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants