From 71571d29d670e04503aa71f791d92c5d58ddc865 Mon Sep 17 00:00:00 2001 From: Oleksandr Karpovich Date: Mon, 17 Aug 2026 19:07:12 +0000 Subject: [PATCH 01/26] WebGL demo --- compose/mpp/demo/build.gradle.kts | 18 +- .../androidx/compose/mpp/demo/Main.web.kt | 4 + .../mpp/demo/webgl/AdoptedGlScene.web.kt | 445 ++++++++++++++++++ .../mpp/demo/webgl/AdoptedGlTexture.web.kt | 122 +++++ .../mpp/demo/webgl/AdoptedTextureUi.web.kt | 119 +++++ .../mpp/demo/webgl/TextureAdoptionDemo.web.kt | 320 +++++++++++++ .../mpp/demo/webgl/ThreeAdoptedScene.web.kt | 258 ++++++++++ .../mpp/demo/webgl/ThreeJsInterop.web.kt | 246 ++++++++++ .../webgl/ThreeTextureAdoptionDemo.web.kt | 305 ++++++++++++ .../webgl/WebGlTextureRegistration.web.kt | 70 +++ .../ui/window/ComposeWindowInternal.web.kt | 23 + 11 files changed, 1929 insertions(+), 1 deletion(-) create mode 100644 compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/AdoptedGlScene.web.kt create mode 100644 compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/AdoptedGlTexture.web.kt create mode 100644 compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/AdoptedTextureUi.web.kt create mode 100644 compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/TextureAdoptionDemo.web.kt create mode 100644 compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeAdoptedScene.web.kt create mode 100644 compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsInterop.web.kt create mode 100644 compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt create mode 100644 compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/WebGlTextureRegistration.web.kt diff --git a/compose/mpp/demo/build.gradle.kts b/compose/mpp/demo/build.gradle.kts index 5b3a281effa28..9b20d43dc8102 100644 --- a/compose/mpp/demo/build.gradle.kts +++ b/compose/mpp/demo/build.gradle.kts @@ -31,6 +31,9 @@ plugins { alias(libs.plugins.kotlinSerialization) } +/** three.js release r180, used by the WebGL texture adoption demo. */ +val threeJsVersion = "0.180.0" + kotlin { applyDefaultHierarchyTemplate() jvm("desktop") @@ -168,12 +171,25 @@ kotlin { dependencies { implementation(libs.kotlinSerializationJson) + // Shared org.w3c/org.khronos declarations for both js and wasmJs (see webgl demos). + api(libs.kotlinXw3c) + } + } + + // three.js is used by the WebGL texture adoption demo in webMain, but npm dependencies are + // per-target, so both web targets declare it. The Kotlin side reaches it with a dynamic + // import(), because typed @JsModule externals cannot be shared between js and wasmJs: + // Kotlin/JS requires @JsNonModule alongside @JsModule when compiling to UMD, and Kotlin/Wasm + // has no @JsNonModule. + val jsMain by getting { + dependencies { + implementation(npm("three", threeJsVersion)) } } val wasmJsMain by getting { dependencies { - api(libs.kotlinXw3c) + implementation(npm("three", threeJsVersion)) } } diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/Main.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/Main.web.kt index 26eea01b10e1c..741dc4a1fcf1e 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/Main.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/Main.web.kt @@ -19,6 +19,8 @@ package androidx.compose.mpp.demo import androidx.compose.mpp.demo.bugs.BugsScreen import androidx.compose.mpp.demo.components.text.loadResource import androidx.compose.mpp.demo.interops.HtmlInteropDemos +import androidx.compose.mpp.demo.webgl.TextureAdoptionScreen +import androidx.compose.mpp.demo.webgl.ThreeTextureAdoptionScreen import androidx.compose.runtime.LaunchedEffect import androidx.compose.mpp.demo.embedded.embeddedScrollDemo import androidx.compose.runtime.mutableStateOf @@ -71,6 +73,8 @@ fun defaultComposeDemo() { }, HtmlInteropDemos, HapticFeedbackExample, + TextureAdoptionScreen, + ThreeTextureAdoptionScreen, ) ) } diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/AdoptedGlScene.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/AdoptedGlScene.web.kt new file mode 100644 index 0000000000000..16440ed2ec5c9 --- /dev/null +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/AdoptedGlScene.web.kt @@ -0,0 +1,445 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalWasmJsInterop::class) + +package androidx.compose.mpp.demo.webgl + +import androidx.compose.ui.unit.IntSize +import kotlin.js.ExperimentalWasmJsInterop +import kotlin.js.JsAny +import kotlin.js.JsBoolean +import kotlin.js.toBoolean +import kotlin.js.unsafeCast +import kotlin.math.cos +import kotlin.math.sin +import org.jetbrains.skia.DirectContext +import org.jetbrains.skia.Image +import org.khronos.webgl.Float32Array +import org.khronos.webgl.Uint8Array +import org.khronos.webgl.WebGLBuffer +import org.khronos.webgl.WebGLFramebuffer +import org.khronos.webgl.WebGLProgram +import org.khronos.webgl.WebGLRenderingContext +import org.khronos.webgl.WebGLRenderingContext.Companion.ARRAY_BUFFER +import org.khronos.webgl.WebGLRenderingContext.Companion.BLEND +import org.khronos.webgl.WebGLRenderingContext.Companion.CLAMP_TO_EDGE +import org.khronos.webgl.WebGLRenderingContext.Companion.COLOR_ATTACHMENT0 +import org.khronos.webgl.WebGLRenderingContext.Companion.COLOR_BUFFER_BIT +import org.khronos.webgl.WebGLRenderingContext.Companion.COMPILE_STATUS +import org.khronos.webgl.WebGLRenderingContext.Companion.CULL_FACE +import org.khronos.webgl.WebGLRenderingContext.Companion.DEPTH_TEST +import org.khronos.webgl.WebGLRenderingContext.Companion.FLOAT +import org.khronos.webgl.WebGLRenderingContext.Companion.FRAGMENT_SHADER +import org.khronos.webgl.WebGLRenderingContext.Companion.FRAMEBUFFER +import org.khronos.webgl.WebGLRenderingContext.Companion.FRAMEBUFFER_COMPLETE +import org.khronos.webgl.WebGLRenderingContext.Companion.LINEAR +import org.khronos.webgl.WebGLRenderingContext.Companion.LINK_STATUS +import org.khronos.webgl.WebGLRenderingContext.Companion.ONE +import org.khronos.webgl.WebGLRenderingContext.Companion.ONE_MINUS_SRC_ALPHA +import org.khronos.webgl.WebGLRenderingContext.Companion.RGBA +import org.khronos.webgl.WebGLRenderingContext.Companion.SCISSOR_TEST +import org.khronos.webgl.WebGLRenderingContext.Companion.STATIC_DRAW +import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE0 +import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_2D +import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_MAG_FILTER +import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_MIN_FILTER +import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_WRAP_S +import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_WRAP_T +import org.khronos.webgl.WebGLRenderingContext.Companion.TRIANGLE_STRIP +import org.khronos.webgl.WebGLRenderingContext.Companion.UNSIGNED_BYTE +import org.khronos.webgl.WebGLRenderingContext.Companion.VERTEX_SHADER +import org.khronos.webgl.WebGLShader +import org.khronos.webgl.WebGLTexture +import org.khronos.webgl.WebGLUniformLocation +import org.khronos.webgl.set +import org.w3c.dom.HTMLCanvasElement + +/** Vertices of a viewport-filling triangle strip, in clip space. */ +private val QUAD_VERTICES = floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f) + +/** + * Renders a hand-written WebGL scene into a texture and lets Compose draw that texture without ever + * copying pixels through the CPU. + * + * The whole thing rests on three facts: + * 1. Asking the `` Compose renders into for a `"webgl2"` context returns the very same + * `WebGL2RenderingContext` Skiko created — a canvas never hands out a second context. WebGL has + * no share groups, so this is the only context whose textures Skia is allowed to touch. + * 2. [pushTexture] publishes our `WebGLTexture` in Emscripten's texture table, which is what turns + * it into the numeric id Skia's GL API speaks. Skiko only exposes that helper to Kotlin/Wasm, so + * this demo re-implements it for both web targets in `WebGlTextureRegistration.web.kt`. + * 3. An adopted [Image] can only be drawn by the [DirectContext] that adopted it, so the context + * Compose renders with has to be the one passed to [renderFrame]. + * + * [renderFrame] is meant to be called once per frame from a `withFrameNanos` callback, which runs + * before Compose measures, lays out and draws. Drawing sites then only draw [image]; they never + * touch GL. That split matters: the canvas a composable draws on is usually a graphics layer's + * display-list recorder, so GL work cannot happen there. + */ +internal class AdoptedGlScene private constructor(private val gl: WebGLRenderingContext) { + + companion object { + /** + * Returns a scene rendering into [canvas]'s WebGL2 context — the context Skiko uses — or + * `null` if that context cannot be obtained. + */ + fun createOrNull(canvas: HTMLCanvasElement): AdoptedGlScene? = + webGl2ContextOf(canvas)?.let(::AdoptedGlScene) + } + + /** How much the plasma field is domain-warped. */ + var warp: Float = 0.45f + + /** Offset into the cosine palette. */ + var hue: Float = 0.1f + + /** Brightness of the spinning quad. */ + var glow: Float = 0.85f + + /** + * When `true`, a fresh texture is allocated, registered and adopted on every frame and the + * previous [Image] is closed — which is Skia's cue to delete the previous GL texture through + * Emscripten. When `false` (the interesting mode) one texture is adopted once and then + * re-rendered in place forever. + */ + var recreateTextureEveryFrame: Boolean = false + + /** Resolution of the offscreen texture. Changing it adopts a new texture of that size. */ + var textureSize: IntSize = IntSize(1024, 640) + + /** Human readable state, surfaced by the demo UI. */ + var status: String = "waiting for the first frame" + private set + + /** Emscripten id of the texture Skia currently owns, or `-1`. */ + var adoptedTextureId: Int = -1 + private set + + /** How many textures have been handed over to Skia so far. */ + var adoptedTextureCount: Int = 0 + private set + + private var plasmaProgram: GlProgram? = null + private var quadProgram: GlProgram? = null + private var vertexBuffer: WebGLBuffer? = null + private var framebuffer: WebGLFramebuffer? = null + + /** A texture the demo keeps ownership of; only our own shader ever samples it. */ + private var patternTexture: WebGLTexture? = null + + private var target: AdoptedGlTexture? = null + + /** + * The previous frame's image in "new texture every frame" mode. It is closed one frame late, + * because a display list recorded during the previous frame may still reference it. + */ + private var retiredImage: Image? = null + private var adoptedSize = IntSize.Zero + private var failed = false + + /** The adopted texture to draw, or `null` until the first frame has been rendered. */ + val image: Image? get() = target?.image + + /** + * Renders one frame of the WebGL scene into the adopted texture, adopting a new texture first if + * needed. Call once per frame from `withFrameNanos`, passing the context Compose renders with. + */ + fun renderFrame(context: DirectContext, timeSeconds: Float) { + if (failed) return + + val size = IntSize( + width = textureSize.width.coerceIn(16, 4096), + height = textureSize.height.coerceIn(16, 4096), + ) + + try { + createGlObjectsIfNeeded() + + retiredImage?.close() + retiredImage = null + + val previous = target + val current = when { + previous == null || adoptedSize != size || recreateTextureEveryFrame -> { + gl.adoptNewTexture(context, size).also { + retiredImage = previous?.image + adoptedSize = size + adoptedTextureId = it.textureId + adoptedTextureCount++ + } + } + else -> previous + } + target = current + + renderSceneInto(current, timeSeconds, size) + + // Everything above went behind Skia's back: the framebuffer, program, buffer and + // texture bindings it had cached are stale now. Without this, Compose renders garbage. + context.resetAll() + + status = if (recreateTextureEveryFrame) { + "adopting a new ${size.width}×${size.height} texture every frame" + } else { + "one adopted ${size.width}×${size.height} texture, re-rendered in place" + } + } catch (throwable: Throwable) { + failed = true + status = "failed: ${throwable.message}" + } + } + + /** + * Demonstrates the non-owning half of the API. [patternTexture] stays ours, so after publishing + * it in Emscripten's table the id has to be taken back out by hand. [unregisterTexture] only + * drops that id: the texture keeps living, and the spinning quad keeps sampling it. + */ + fun registrationRoundTrip(): String { + val texture = patternTexture ?: return "GL objects are not created yet" + val id = pushTexture(texture) + unregisterTexture(id) + return "pushTexture(pattern) returned id $id, released again with unregisterTexture($id) — " + + "the texture itself is untouched and still being sampled" + } + + fun dispose() { + retiredImage?.close() + retiredImage = null + target?.image?.close() + target = null + adoptedSize = IntSize.Zero + adoptedTextureId = -1 + releaseGlObjects() + } + + private fun renderSceneInto(target: AdoptedGlTexture, timeSeconds: Float, size: IntSize) { + val plasma = plasmaProgram ?: error("shader programs are not compiled") + val quad = quadProgram ?: error("shader programs are not compiled") + val aspect = size.width.toFloat() / size.height.toFloat() + + gl.bindFramebuffer(FRAMEBUFFER, framebuffer) + gl.framebufferTexture2D(FRAMEBUFFER, COLOR_ATTACHMENT0, TEXTURE_2D, target.texture, 0) + check(gl.checkFramebufferStatus(FRAMEBUFFER) == FRAMEBUFFER_COMPLETE) { + "the adopted texture is not a complete framebuffer attachment" + } + + gl.viewport(0, 0, size.width, size.height) + gl.disable(DEPTH_TEST) + gl.disable(SCISSOR_TEST) + gl.disable(CULL_FACE) + gl.clearColor(0f, 0f, 0f, 0f) + gl.clear(COLOR_BUFFER_BIT) + gl.enable(BLEND) + gl.blendFunc(ONE, ONE_MINUS_SRC_ALPHA) // premultiplied source + gl.bindBuffer(ARRAY_BUFFER, vertexBuffer) + + gl.useProgram(plasma.program) + bindQuadVertices(plasma.positionAttribute) + gl.uniform1f(plasma.uniform("uTime"), timeSeconds) + gl.uniform1f(plasma.uniform("uWarp"), warp) + gl.uniform1f(plasma.uniform("uHue"), hue) + gl.uniform1f(plasma.uniform("uAspect"), aspect) + gl.drawArrays(TRIANGLE_STRIP, 0, 4) + + val angle = timeSeconds * 0.8f + gl.useProgram(quad.program) + bindQuadVertices(quad.positionAttribute) + gl.uniform2f(quad.uniform("uRotation"), cos(angle), sin(angle)) + gl.uniform1f(quad.uniform("uScale"), 0.46f + 0.04f * sin(timeSeconds * 1.7f)) + gl.uniform1f(quad.uniform("uAspect"), aspect) + gl.uniform1f(quad.uniform("uTime"), timeSeconds) + gl.uniform1f(quad.uniform("uGlow"), glow) + gl.activeTexture(TEXTURE0) + gl.bindTexture(TEXTURE_2D, patternTexture) + gl.uniform1i(quad.uniform("uPattern"), 0) + gl.drawArrays(TRIANGLE_STRIP, 0, 4) + + // Hand the default framebuffer — the one Skia renders Compose into — back. + gl.bindFramebuffer(FRAMEBUFFER, null) + } + + private fun bindQuadVertices(attribute: Int) { + gl.enableVertexAttribArray(attribute) + gl.vertexAttribPointer(attribute, 2, FLOAT, false, 0, 0) + } + + private fun createGlObjectsIfNeeded() { + if (framebuffer != null) return + + framebuffer = gl.createFramebuffer() ?: error("gl.createFramebuffer() returned null") + plasmaProgram = GlProgram(gl, SCENE_VERTEX_SHADER, PLASMA_FRAGMENT_SHADER) + quadProgram = GlProgram(gl, SPINNING_QUAD_VERTEX_SHADER, SPINNING_QUAD_FRAGMENT_SHADER) + + val vertices = Float32Array(QUAD_VERTICES.size) + QUAD_VERTICES.forEachIndexed { index, value -> vertices[index] = value } + vertexBuffer = (gl.createBuffer() ?: error("gl.createBuffer() returned null")).also { + gl.bindBuffer(ARRAY_BUFFER, it) + gl.bufferData(ARRAY_BUFFER, vertices, STATIC_DRAW) + } + + patternTexture = createPatternTexture() + } + + private fun releaseGlObjects() { + framebuffer?.let { gl.deleteFramebuffer(it) } + framebuffer = null + vertexBuffer?.let { gl.deleteBuffer(it) } + vertexBuffer = null + patternTexture?.let { gl.deleteTexture(it) } + patternTexture = null + plasmaProgram?.dispose() + plasmaProgram = null + quadProgram?.dispose() + quadProgram = null + } + + /** A small procedural texture the demo keeps for itself, sampled by the spinning quad. */ + private fun createPatternTexture(): WebGLTexture { + val side = 64 + val pixels = Uint8Array(side * side * 4) + for (y in 0 until side) { + for (x in 0 until side) { + val checker = if (((x / 8) + (y / 8)) % 2 == 0) 1f else 0.55f + val gradient = y.toFloat() / (side - 1) + val offset = (y * side + x) * 4 + pixels[offset] = (255 * checker * (0.35f + 0.65f * gradient)).toInt().toByte() + pixels[offset + 1] = (255 * checker * (0.75f - 0.35f * gradient)).toInt().toByte() + pixels[offset + 2] = (255 * checker).toInt().toByte() + pixels[offset + 3] = 0xFF.toByte() + } + } + + val texture = gl.createTexture() ?: error("gl.createTexture() returned null") + gl.bindTexture(TEXTURE_2D, texture) + gl.texImage2D(TEXTURE_2D, 0, RGBA, side, side, 0, RGBA, UNSIGNED_BYTE, pixels) + gl.texParameteri(TEXTURE_2D, TEXTURE_MIN_FILTER, LINEAR) + gl.texParameteri(TEXTURE_2D, TEXTURE_MAG_FILTER, LINEAR) + gl.texParameteri(TEXTURE_2D, TEXTURE_WRAP_S, CLAMP_TO_EDGE) + gl.texParameteri(TEXTURE_2D, TEXTURE_WRAP_T, CLAMP_TO_EDGE) + gl.bindTexture(TEXTURE_2D, null) + return texture + } +} + +private class GlProgram( + private val gl: WebGLRenderingContext, + vertexShaderSource: String, + fragmentShaderSource: String, +) { + private val vertexShader = gl.createCompiledShader(VERTEX_SHADER, vertexShaderSource) + private val fragmentShader = gl.createCompiledShader(FRAGMENT_SHADER, fragmentShaderSource) + private val uniforms = mutableMapOf() + + val program: WebGLProgram = (gl.createProgram() ?: error("gl.createProgram() returned null")) + .also { program -> + gl.attachShader(program, vertexShader) + gl.attachShader(program, fragmentShader) + gl.linkProgram(program) + check(gl.getProgramParameter(program, LINK_STATUS).isTrue()) { + "program linking failed: ${gl.getProgramInfoLog(program)}" + } + } + + val positionAttribute: Int = gl.getAttribLocation(program, "aPosition") + + fun uniform(name: String): WebGLUniformLocation? = + uniforms.getOrPut(name) { gl.getUniformLocation(program, name) } + + fun dispose() { + gl.deleteProgram(program) + gl.deleteShader(vertexShader) + gl.deleteShader(fragmentShader) + } +} + +private fun WebGLRenderingContext.createCompiledShader(type: Int, source: String): WebGLShader { + val shader = createShader(type) ?: error("gl.createShader() returned null") + shaderSource(shader, source) + compileShader(shader) + check(getShaderParameter(shader, COMPILE_STATUS).isTrue()) { + "shader compilation failed: ${getShaderInfoLog(shader)}" + } + return shader +} + +private fun JsAny?.isTrue(): Boolean = this?.unsafeCast()?.toBoolean() == true + +private const val SCENE_VERTEX_SHADER = """ + attribute vec2 aPosition; + varying vec2 vUv; + void main() { + vUv = aPosition * 0.5 + 0.5; + gl_Position = vec4(aPosition, 0.0, 1.0); + } +""" + +private const val PLASMA_FRAGMENT_SHADER = """ + precision mediump float; + varying vec2 vUv; + uniform float uTime; + uniform float uWarp; + uniform float uHue; + uniform float uAspect; + + vec3 palette(float t) { + return 0.5 + 0.5 * cos(6.28318 * (vec3(0.0, 0.33, 0.67) + t)); + } + + void main() { + vec2 p = (vUv * 2.0 - 1.0) * vec2(uAspect, 1.0); + for (int i = 0; i < 3; i++) { + p += uWarp * 0.35 * vec2(sin(p.y * 3.0 + uTime), cos(p.x * 3.0 - uTime * 0.7)); + } + float field = sin(p.x * 3.0 + uTime) + + sin(p.y * 3.5 - uTime * 0.8) + + sin(length(p) * 5.0 - uTime * 1.3); + vec3 color = palette(field * 0.15 + uHue); + float alpha = smoothstep(1.35, 0.2, length(vUv * 2.0 - 1.0)); + gl_FragColor = vec4(color * alpha, alpha); + } +""" + +private const val SPINNING_QUAD_VERTEX_SHADER = """ + attribute vec2 aPosition; + uniform vec2 uRotation; + uniform float uScale; + uniform float uAspect; + varying vec2 vUv; + void main() { + vUv = aPosition * 0.5 + 0.5; + vec2 rotated = vec2( + aPosition.x * uRotation.x - aPosition.y * uRotation.y, + aPosition.x * uRotation.y + aPosition.y * uRotation.x + ) * uScale; + gl_Position = vec4(rotated.x / uAspect, rotated.y, 0.0, 1.0); + } +""" + +private const val SPINNING_QUAD_FRAGMENT_SHADER = """ + precision mediump float; + varying vec2 vUv; + uniform sampler2D uPattern; + uniform float uTime; + uniform float uGlow; + + void main() { + vec4 pattern = texture2D(uPattern, vUv + vec2(uTime * 0.04, uTime * 0.02)); + float mask = smoothstep(0.5, 0.36, length(vUv - 0.5)); + float alpha = mask * 0.9; + gl_FragColor = vec4(pattern.rgb * uGlow * alpha, alpha); + } +""" diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/AdoptedGlTexture.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/AdoptedGlTexture.web.kt new file mode 100644 index 0000000000000..73e6c4d06589b --- /dev/null +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/AdoptedGlTexture.web.kt @@ -0,0 +1,122 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalWasmJsInterop::class) + +package androidx.compose.mpp.demo.webgl + +import androidx.compose.ui.unit.IntSize +import kotlin.js.ExperimentalWasmJsInterop +import org.jetbrains.skia.BackendTexture +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ColorType +import org.jetbrains.skia.DirectContext +import org.jetbrains.skia.Image +import org.jetbrains.skia.SurfaceOrigin +import org.jetbrains.skia.impl.use +import org.khronos.webgl.WebGLRenderingContext +import org.khronos.webgl.WebGLRenderingContext.Companion.CLAMP_TO_EDGE +import org.khronos.webgl.WebGLRenderingContext.Companion.LINEAR +import org.khronos.webgl.WebGLRenderingContext.Companion.RGBA +import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_2D +import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_MAG_FILTER +import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_MIN_FILTER +import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_WRAP_S +import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_WRAP_T +import org.khronos.webgl.WebGLRenderingContext.Companion.UNSIGNED_BYTE +import org.khronos.webgl.WebGLTexture +import org.w3c.dom.HTMLCanvasElement + +/** `GL_RGBA8`, the sized format Skia expects for an `RGBA` / `UNSIGNED_BYTE` texture. */ +internal const val GL_RGBA8 = 0x8058 + +/** + * A WebGL texture that belongs to Skia now. + * + * [texture] is kept only so that it can be re-attached to a framebuffer; it must not be deleted, and + * [textureId] must not be unregistered. Closing [image] does both. + */ +internal class AdoptedGlTexture( + val texture: WebGLTexture, + val textureId: Int, + val image: Image, +) + +/** + * Allocates an `RGBA8` texture of [size], publishes it in Emscripten's texture table and hands it to + * Skia. Once [Image.adoptTextureFrom] returns, the GL texture belongs to [context]. + * + * This is the whole trick behind both texture adoption demos: whoever renders into + * [AdoptedGlTexture.texture] afterwards — hand-written shaders or a third-party engine — is drawing + * straight into an image Skia can sample, with no pixel copies in between. + */ +internal fun WebGLRenderingContext.adoptNewTexture( + context: DirectContext, + size: IntSize, +): AdoptedGlTexture { + val texture = createTexture() ?: error("gl.createTexture() returned null") + bindTexture(TEXTURE_2D, texture) + texImage2D(TEXTURE_2D, 0, RGBA, size.width, size.height, 0, RGBA, UNSIGNED_BYTE, null) + // No mipmaps and plain LINEAR filtering keep Skia on the "just sample the texture" path, so that + // re-rendering into the texture shows up immediately instead of serving a cached copy. + texParameteri(TEXTURE_2D, TEXTURE_MIN_FILTER, LINEAR) + texParameteri(TEXTURE_2D, TEXTURE_MAG_FILTER, LINEAR) + texParameteri(TEXTURE_2D, TEXTURE_WRAP_S, CLAMP_TO_EDGE) + texParameteri(TEXTURE_2D, TEXTURE_WRAP_T, CLAMP_TO_EDGE) + bindTexture(TEXTURE_2D, null) + + val textureId = pushTexture(texture) + var ownershipTransferred = false + try { + // The descriptor is closed as soon as the image exists; the texture it described is Skia's + // from that point on. + val image = BackendTexture.makeGL( + size.width, + size.height, + /* isMipmapped = */ false, + textureId, + /* textureTarget = */ TEXTURE_2D, + /* textureFormat = */ GL_RGBA8, + ).use { backendTexture -> + // BOTTOM_LEFT because the scene is rendered into a framebuffer, and PREMUL because the + // producers write premultiplied colors. Together they are what makes the texture blend + // correctly with the Compose content behind and in front of it. + Image.adoptTextureFrom( + context, + backendTexture, + SurfaceOrigin.BOTTOM_LEFT, + ColorType.RGBA_8888, + ColorAlphaType.PREMUL, + ) + } + ownershipTransferred = true + return AdoptedGlTexture(texture, textureId, image) + } finally { + if (!ownershipTransferred) { + // Skia never took the texture, so both the table entry and the texture are ours. + unregisterTexture(textureId) + deleteTexture(texture) + } + } +} + +/** + * Skiko already created a `"webgl2"` context for this canvas, and a canvas never hands out a second + * context — so this returns the exact context Skia renders with. WebGL has no share groups, which + * makes this the only context whose textures Skia is allowed to touch. + */ +internal fun webGl2ContextOf(canvas: HTMLCanvasElement): WebGLRenderingContext? = + js("canvas.getContext('webgl2')") diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/AdoptedTextureUi.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/AdoptedTextureUi.web.kt new file mode 100644 index 0000000000000..81765a407d893 --- /dev/null +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/AdoptedTextureUi.web.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.mpp.demo.webgl + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Slider +import androidx.compose.material.Switch +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.skiaCanvas +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import kotlin.math.min +import kotlin.math.roundToInt +import org.jetbrains.skia.Image +import org.jetbrains.skia.Rect + +/** + * Draws an adopted [Image] — nothing else. All GL work already happened in this frame's + * `withFrameNanos` callback, which is what lets this composable live inside graphics layers (`clip`, + * `blur`, `graphicsLayer`): the draw is merely recorded here and replayed into the GPU surface later, + * which is fine for an image that already belongs to Skia's context. + * + * [invalidation] is read inside the draw scope, which is what schedules the next redraw without + * recomposing anything. + */ +@Composable +internal fun AdoptedTextureSurface( + modifier: Modifier, + invalidation: State, + image: () -> Image?, +) { + Canvas(modifier) { + drawIntoCanvas { canvas -> + invalidation.value + val skiaCanvas = canvas.skiaCanvas + val adopted = image() ?: return@drawIntoCanvas + + // Center-crop so that square tiles do not squash a wide texture. + val scale = min(adopted.width / size.width, adopted.height / size.height) + val cropWidth = size.width * scale + val cropHeight = size.height * scale + val source = Rect.makeXYWH( + (adopted.width - cropWidth) / 2f, + (adopted.height - cropHeight) / 2f, + cropWidth, + cropHeight, + ) + skiaCanvas.drawImageRect(adopted, source, Rect.makeWH(size.width, size.height)) + } + } +} + +@Composable +internal fun LabelledSlider( + label: String, + value: Float, + valueRange: ClosedFloatingPointRange, + valueText: String = ((value * 100).roundToInt() / 100f).toString(), + onValueChange: (Float) -> Unit, +) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(label, Modifier.width(110.dp), style = MaterialTheme.typography.body2) + Slider( + value = value, + onValueChange = onValueChange, + valueRange = valueRange, + modifier = Modifier.weight(1f), + ) + Text( + valueText, + Modifier.padding(start = 12.dp), + style = MaterialTheme.typography.caption, + ) + } +} + +@Composable +internal fun Toggle(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { + Row(verticalAlignment = Alignment.CenterVertically) { + Switch(checked = checked, onCheckedChange = onCheckedChange) + Text(label, Modifier.padding(start = 8.dp), style = MaterialTheme.typography.body2) + } +} + +@Composable +internal fun StatusLine(label: String, value: String) { + Row(Modifier.fillMaxWidth()) { + Text(label, Modifier.width(170.dp), style = MaterialTheme.typography.caption) + Text( + value, + style = MaterialTheme.typography.caption, + fontFamily = FontFamily.Monospace, + ) + } +} diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/TextureAdoptionDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/TextureAdoptionDemo.web.kt new file mode 100644 index 0000000000000..1079bf18e60e5 --- /dev/null +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/TextureAdoptionDemo.web.kt @@ -0,0 +1,320 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// The demo reaches for ComposeWindow to get the DirectContext Compose renders with; it is internal +// API, hence the suppression (the same trick the rest of this demo module uses). +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + +package androidx.compose.mpp.demo.webgl + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.Card +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.mpp.demo.Screen +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.LocalComposeWindow +import kotlin.math.roundToInt +import org.jetbrains.skia.DirectContext + +/** + * Draws a WebGL scene inside Compose with no pixel copies: the scene is rendered into a + * `WebGLTexture` that Skia has adopted, and Compose draws that texture like any other GPU image — + * clipped, rotated, blurred and composited with regular Compose content on top of it. + */ +val TextureAdoptionScreen = Screen.Example("WebGL texture adoption") { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + TextureAdoptionDemo() + } +} + +/** One tick of the demo clock. */ +private data class Frame(val index: Long, val timeSeconds: Float, val fps: Float) + +@Composable +private fun TextureAdoptionDemo() { + val composeWindow = LocalComposeWindow.current + val scene = remember(composeWindow) { + composeWindow?.let { AdoptedGlScene.createOrNull(it.htmlCanvas) } + } + DisposableEffect(scene) { onDispose { scene?.dispose() } } + + if (scene == null) { + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text( + "Could not obtain the WebGL context Compose renders with, so there is nothing to " + + "adopt a texture from.", + textAlign = TextAlign.Center, + ) + } + return + } + + var running by remember { mutableStateOf(true) } + var speed by remember { mutableStateOf(1f) } + var warp by remember { mutableStateOf(scene.warp) } + var hue by remember { mutableStateOf(scene.hue) } + var glow by remember { mutableStateOf(scene.glow) } + var recreateEveryFrame by remember { mutableStateOf(false) } + var textureSide by remember { mutableStateOf(1024f) } + var roundTripLog by remember { mutableStateOf(null) } + + // Read only from draw scopes, so that a new frame invalidates the drawing and not the whole UI. + val frame = remember { mutableStateOf(Frame(0, 0f, 0f)) } + // Read from composition, and therefore refreshed a few times per second instead of every frame. + var stats by remember { mutableStateOf(Frame(0, 0f, 0f)) } + + scene.warp = warp + scene.hue = hue + scene.glow = glow + scene.recreateTextureEveryFrame = recreateEveryFrame + scene.textureSize = IntSize(textureSide.roundToInt(), (textureSide * 0.625f).roundToInt()) + + // withFrameNanos callbacks run inside the frame, before Compose measures, lays out and draws — + // so this is where the WebGL pass belongs: the texture holds this frame's content by the time + // Skia submits the frame that samples it. + LaunchedEffect(running, scene) { + if (!running) return@LaunchedEffect + var previousNanos = 0L + while (true) { + withFrameNanos { nanos -> + val deltaSeconds = + if (previousNanos == 0L) 0f else (nanos - previousNanos) / 1_000_000_000f + previousNanos = nanos + val current = frame.value + val next = Frame( + index = current.index + 1, + timeSeconds = current.timeSeconds + deltaSeconds * speed, + fps = if (deltaSeconds > 0f) { + current.fps * 0.9f + (1f / deltaSeconds) * 0.1f + } else { + current.fps + }, + ) + // Null until Compose has rendered its first frame and captured the context. + val directContext = composeWindow?.skiaDirectContext + if (directContext != null) { + scene.renderFrame(directContext, next.timeSeconds) + } + + frame.value = next + if (next.index % 20 == 0L) stats = next + } + } + } + + Column( + modifier = Modifier.width(600.dp).verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Hero(scene, frame) + Variants(scene, frame) + Controls( + running = running, + onRunningChange = { running = it }, + speed = speed, + onSpeedChange = { speed = it }, + warp = warp, + onWarpChange = { warp = it }, + hue = hue, + onHueChange = { hue = it }, + glow = glow, + onGlowChange = { glow = it }, + textureSide = textureSide, + onTextureSideChange = { textureSide = it }, + recreateEveryFrame = recreateEveryFrame, + onRecreateEveryFrameChange = { recreateEveryFrame = it }, + onRoundTrip = { roundTripLog = scene.registrationRoundTrip() }, + ) + Status(scene, stats, composeWindow?.skiaDirectContext, roundTripLog) + } +} + +/** + * The adopted texture as the hero: tilted in 3D by dragging, clipped to a rounded rectangle, and + * with Compose content composited on top of it. + */ +@Composable +private fun Hero(scene: AdoptedGlScene, frame: State) { + var tiltX by remember { mutableStateOf(0f) } + var tiltY by remember { mutableStateOf(0f) } + + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1.6f) + .pointerInput(Unit) { + detectDragGestures { _, dragAmount -> + tiltY = (tiltY + dragAmount.x * 0.15f).coerceIn(-35f, 35f) + tiltX = (tiltX - dragAmount.y * 0.15f).coerceIn(-35f, 35f) + } + } + .graphicsLayer { + rotationX = tiltX + rotationY = tiltY + cameraDistance = 16f * density + } + .clip(RoundedCornerShape(28.dp)) + // A gradient underneath proves the texture arrives with a real alpha channel. + .background(Brush.linearGradient(listOf(Color(0xFF12123A), Color(0xFF3A1250)))), + contentAlignment = Alignment.BottomStart, + ) { + AdoptedTextureSurface(Modifier.fillMaxSize(), frame) { scene.image } + Column(Modifier.padding(20.dp)) { + Text( + "Compose draws on top", + color = Color.White, + style = MaterialTheme.typography.h6, + ) + Text( + "drag to tilt · WebGL below, Compose above, one GPU texture", + color = Color.White.copy(alpha = 0.75f), + style = MaterialTheme.typography.caption, + ) + } + } +} + +/** The same adopted texture, reused several times in one frame with different Compose treatments. */ +@Composable +private fun Variants(scene: AdoptedGlScene, frame: State) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + AdoptedTextureSurface(Modifier.size(96.dp).clip(CircleShape), frame) { scene.image } + AdoptedTextureSurface( + Modifier.size(96.dp) + .clip(RoundedCornerShape(16.dp)) + .graphicsLayer { + rotationZ = 12f + alpha = 0.75f + }, + frame, + ) { scene.image } + AdoptedTextureSurface( + Modifier.size(96.dp).clip(RoundedCornerShape(16.dp)).blur(6.dp), + frame, + ) { scene.image } + } +} + +@Composable +private fun Controls( + running: Boolean, + onRunningChange: (Boolean) -> Unit, + speed: Float, + onSpeedChange: (Float) -> Unit, + warp: Float, + onWarpChange: (Float) -> Unit, + hue: Float, + onHueChange: (Float) -> Unit, + glow: Float, + onGlowChange: (Float) -> Unit, + textureSide: Float, + onTextureSideChange: (Float) -> Unit, + recreateEveryFrame: Boolean, + onRecreateEveryFrameChange: (Boolean) -> Unit, + onRoundTrip: () -> Unit, +) { + Card(Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + LabelledSlider("warp", warp, 0f..1.2f, onValueChange = onWarpChange) + LabelledSlider("palette", hue, 0f..1f, onValueChange = onHueChange) + LabelledSlider("glow", glow, 0f..1.5f, onValueChange = onGlowChange) + LabelledSlider("speed", speed, 0f..3f, onValueChange = onSpeedChange) + LabelledSlider( + label = "texture width", + value = textureSide, + valueRange = 256f..2048f, + onValueChange = onTextureSideChange, + valueText = "${textureSide.roundToInt()} px", + ) + Toggle("animate", running, onRunningChange) + Toggle( + label = "adopt a new texture every frame", + checked = recreateEveryFrame, + onCheckedChange = onRecreateEveryFrameChange, + ) + Button(onClick = onRoundTrip, modifier = Modifier.padding(top = 8.dp)) { + Text("pushTexture + unregisterTexture round trip") + } + } + } +} + +@Composable +private fun Status( + scene: AdoptedGlScene, + frame: Frame, + directContext: DirectContext?, + roundTripLog: String?, +) { + Card(Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + StatusLine("skia context", directContext?.toString() ?: "not captured yet") + StatusLine("state", scene.status) + StatusLine("adopted texture id", scene.adoptedTextureId.toString()) + StatusLine("textures handed to Skia", scene.adoptedTextureCount.toString()) + StatusLine("frame", "${frame.index} · ${frame.fps.roundToInt()} fps") + if (roundTripLog != null) { + StatusLine("round trip", roundTripLog) + } + } + } +} diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeAdoptedScene.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeAdoptedScene.web.kt new file mode 100644 index 0000000000000..312b6c365dc25 --- /dev/null +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeAdoptedScene.web.kt @@ -0,0 +1,258 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalWasmJsInterop::class) + +package androidx.compose.mpp.demo.webgl + +import androidx.compose.ui.unit.IntSize +import kotlin.js.ExperimentalWasmJsInterop +import org.jetbrains.skia.DirectContext +import org.jetbrains.skia.Image +import org.khronos.webgl.WebGLFramebuffer +import org.khronos.webgl.WebGLRenderbuffer +import org.khronos.webgl.WebGLRenderingContext +import org.khronos.webgl.WebGLRenderingContext.Companion.COLOR_ATTACHMENT0 +import org.khronos.webgl.WebGLRenderingContext.Companion.DEPTH_ATTACHMENT +import org.khronos.webgl.WebGLRenderingContext.Companion.DEPTH_COMPONENT16 +import org.khronos.webgl.WebGLRenderingContext.Companion.FRAMEBUFFER +import org.khronos.webgl.WebGLRenderingContext.Companion.FRAMEBUFFER_COMPLETE +import org.khronos.webgl.WebGLRenderingContext.Companion.RENDERBUFFER +import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_2D +import org.khronos.webgl.WebGLTexture +import org.w3c.dom.HTMLCanvasElement + +/** + * The same zero-copy texture adoption as [AdoptedGlScene], except that the pixels are produced by + * three.js instead of hand-written shaders. + * + * Delegating to a third-party renderer adds exactly three requirements to the adoption plumbing: + * 1. The library has to render in *Skiko's* WebGL context. `WebGLRenderer({ canvas, context })` is + * how three.js accepts one; a library that insists on creating its own context could never produce + * a texture Skia may read, because WebGL has no share groups. + * 2. The destination has to stay ours. Skia takes ownership of the adopted texture, so three.js is + * pointed at the framebuffer this class owns through + * [ThreeRenderer.setRenderTargetFramebuffer] — the hook WebXR uses — instead of allocating a + * render target of its own. Nothing is ever copied, and no object has two owners. + * 3. Both sides have to invalidate their GL state caches every frame: + * [ThreeRenderer.resetState] before three.js draws, [DirectContext.resetAll] after it is done. + * Skipping either one is the classic "two WebGL libraries in one context" bug, where one of them + * silently stops drawing. + * + * Unlike the hand-written demo, the framebuffer here also carries a depth attachment: a torus knot + * self-occludes, so three.js needs a depth buffer, and since three never sets up this render target + * it never allocates one either. + */ +internal class ThreeAdoptedScene private constructor( + private val gl: WebGLRenderingContext, + private val three: ThreeModule, + private val canvas: HTMLCanvasElement, +) { + companion object { + /** + * Loads three.js and binds it to the WebGL2 context Skiko renders with, or returns `null` if + * that context cannot be obtained. + */ + suspend fun createOrNull(canvas: HTMLCanvasElement): ThreeAdoptedScene? { + val gl = webGl2ContextOf(canvas) ?: return null + val three = loadThreeModule() ?: return null + return ThreeAdoptedScene(gl, three, canvas) + } + } + + /** Rotation speed of the knot, in revolutions-ish per second. */ + var spin: Float = 1f + + /** Hue of the knot's material. */ + var hue: Float = 0.55f + + var roughness: Float = 0.28f + + var metalness: Float = 0.62f + + var lightIntensity: Float = 3.4f + + /** Resolution of the offscreen texture. Changing it adopts a new texture of that size. */ + var textureSize: IntSize = IntSize(1024, 640) + + /** Human readable state, surfaced by the demo UI. */ + var status: String = "waiting for the first frame" + private set + + /** Emscripten id of the texture Skia currently owns, or `-1`. */ + var adoptedTextureId: Int = -1 + private set + + /** How many textures have been handed over to Skia so far. */ + var adoptedTextureCount: Int = 0 + private set + + private var threeObjects: ThreeObjects? = null + private var framebuffer: WebGLFramebuffer? = null + private var depthBuffer: WebGLRenderbuffer? = null + private var renderTarget: ThreeRenderTarget? = null + private var target: AdoptedGlTexture? = null + + /** + * The previous frame's image, closed one frame late because a display list recorded during the + * previous frame may still reference it. + */ + private var retiredImage: Image? = null + private var adoptedSize = IntSize.Zero + private var angle = 0f + private var failed = false + + /** The adopted texture to draw, or `null` until the first frame has been rendered. */ + val image: Image? get() = target?.image + + /** + * Renders one frame with three.js into the adopted texture. Call once per frame from + * `withFrameNanos`, passing the context Compose renders with. + */ + fun renderFrame(context: DirectContext, deltaSeconds: Float) { + if (failed) return + + val size = IntSize( + width = textureSize.width.coerceIn(16, 4096), + height = textureSize.height.coerceIn(16, 4096), + ) + + try { + // Constructing the renderer queries capabilities and touches GL state, so it happens here + // rather than at load time: this method always ends with DirectContext.resetAll(), which is + // what lets Skia recover from any state three.js changed. + val (renderer, knotScene) = threeObjects + ?: ThreeObjects( + renderer = createThreeRenderer(three, canvas, gl), + knotScene = createKnotScene(three), + ).also { threeObjects = it } + + val framebuffer = framebuffer + ?: (gl.createFramebuffer() ?: error("gl.createFramebuffer() returned null")) + .also { this.framebuffer = it } + + retiredImage?.close() + retiredImage = null + + var current = target + if (current == null || adoptedSize != size) { + val previous = current + current = gl.adoptNewTexture(context, size) + retiredImage = previous?.image + adoptedSize = size + adoptedTextureId = current.textureId + adoptedTextureCount++ + attachToFramebuffer(framebuffer, current.texture, size) + // The render target carries the viewport three.js renders with, and it is never + // resized in place (that would make three dispose of our framebuffer), so a new + // texture size means a new descriptor. + renderTarget = createRenderTarget(three, size.width, size.height) + knotScene.camera.aspect = size.width.toDouble() / size.height.toDouble() + knotScene.camera.updateProjectionMatrix() + } + target = current + val renderTarget = renderTarget ?: error("the render target was not created") + + angle += deltaSeconds * spin + knotScene.knot.rotation.x = (angle * 0.6f).toDouble() + knotScene.knot.rotation.y = angle.toDouble() + knotScene.material.roughness = roughness.toDouble() + knotScene.material.metalness = metalness.toDouble() + knotScene.material.color.setHSL(hue.toDouble(), 0.72, 0.6) + knotScene.keyLight.intensity = lightIntensity.toDouble() + + // Skia rendered the previous frame through this very context, so everything three.js + // believes about the GL state is stale. + renderer.resetState() + // Our framebuffer, with the texture Skia adopted attached to it. + renderer.setRenderTargetFramebuffer(renderTarget, framebuffer) + renderer.setRenderTarget(renderTarget) + renderer.render(knotScene.scene, knotScene.camera) + // Hand the default framebuffer — the one Skia renders Compose into — back. + renderer.setRenderTarget(null) + gl.bindFramebuffer(FRAMEBUFFER, null) + + // And now the mirror image of resetState(): everything Skia cached is stale too. + context.resetAll() + + status = "three.js renders into one adopted ${size.width}×${size.height} texture" + } catch (throwable: Throwable) { + failed = true + status = "failed: ${throwable.message}" + } + } + + /** + * [context] is only used to let Skia recover from the GL work done here, since three's own + * disposal touches the shared context as well. + */ + fun dispose(context: DirectContext?) { + retiredImage?.close() + retiredImage = null + target?.image?.close() + target = null + adoptedSize = IntSize.Zero + adoptedTextureId = -1 + // Only a descriptor pointing at our framebuffer: disposing it would make three.js delete a + // framebuffer it never created, so it is simply dropped. + renderTarget = null + + threeObjects?.let { (renderer, knotScene) -> + disposeKnotScene(knotScene) + renderer.dispose() + } + threeObjects = null + + framebuffer?.let { gl.deleteFramebuffer(it) } + framebuffer = null + depthBuffer?.let { gl.deleteRenderbuffer(it) } + depthBuffer = null + + gl.bindFramebuffer(FRAMEBUFFER, null) + context?.resetAll() + } + + /** + * Attaches the adopted texture as color attachment 0, plus a depth buffer sized to match, and + * verifies that three.js will be able to render into the result. + */ + private fun attachToFramebuffer( + framebuffer: WebGLFramebuffer, + texture: WebGLTexture, + size: IntSize, + ) { + val depthBuffer = depthBuffer + ?: (gl.createRenderbuffer() ?: error("gl.createRenderbuffer() returned null")) + .also { this.depthBuffer = it } + + gl.bindRenderbuffer(RENDERBUFFER, depthBuffer) + gl.renderbufferStorage(RENDERBUFFER, DEPTH_COMPONENT16, size.width, size.height) + gl.bindRenderbuffer(RENDERBUFFER, null) + + gl.bindFramebuffer(FRAMEBUFFER, framebuffer) + gl.framebufferTexture2D(FRAMEBUFFER, COLOR_ATTACHMENT0, TEXTURE_2D, texture, 0) + gl.framebufferRenderbuffer(FRAMEBUFFER, DEPTH_ATTACHMENT, RENDERBUFFER, depthBuffer) + check(gl.checkFramebufferStatus(FRAMEBUFFER) == FRAMEBUFFER_COMPLETE) { + "the adopted texture is not a complete framebuffer attachment" + } + gl.bindFramebuffer(FRAMEBUFFER, null) + } + + private data class ThreeObjects( + val renderer: ThreeRenderer, + val knotScene: ThreeKnotScene, + ) +} diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsInterop.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsInterop.web.kt new file mode 100644 index 0000000000000..1ea614e54c448 --- /dev/null +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsInterop.web.kt @@ -0,0 +1,246 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalWasmJsInterop::class) + +package androidx.compose.mpp.demo.webgl + +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.js.ExperimentalWasmJsInterop +import kotlin.js.JsAny +import kotlin.js.Promise +import kotlin.js.unsafeCast +import kotlinx.coroutines.suspendCancellableCoroutine +import org.khronos.webgl.WebGLFramebuffer +import org.khronos.webgl.WebGLRenderingContext +import org.w3c.dom.HTMLCanvasElement + +/* + * Typed bindings for the slice of three.js this demo uses. + * + * The three module is reached with a dynamic `import('three')` instead of `@JsModule` externals on + * purpose: `@JsModule` requires `@JsNonModule` alongside it when the Kotlin/JS target compiles to + * UMD (which this demo does), and Kotlin/Wasm has no `@JsNonModule` at all. A statically imported, + * typed three.js binding therefore cannot live in a source set shared by js and wasmJs. Loading the + * module dynamically and typing the objects it hands back keeps every line below in `webMain`, and + * webpack still resolves the literal `import('three')` against the npm dependency at bundle time. + */ + +/** The `three` module namespace object, as returned by `import('three')`. */ +internal external interface ThreeModule : JsAny + +internal external interface ThreeRenderer : JsAny { + var autoClear: Boolean + + /** + * Points [renderTarget] at a framebuffer three.js did not create — the hook WebXR uses, and the + * reason this demo can keep owning the texture Skia adopted. Once set, `setRenderTarget` skips + * three's own render target setup entirely, so it never allocates a texture, a framebuffer or a + * depth buffer for that target. + */ + fun setRenderTargetFramebuffer(renderTarget: ThreeRenderTarget, framebuffer: WebGLFramebuffer?) + + fun setRenderTarget(renderTarget: ThreeRenderTarget?) + + fun setClearColor(color: Int, alpha: Double) + + fun render(scene: ThreeScene, camera: ThreeCamera) + + /** + * Makes three.js forget the GL state it cached. Mandatory here: Skia rendered the previous frame + * through the very same context and left its own state behind. three.js documents this method as + * being "mostly relevant for applications which share a single WebGL context across multiple + * WebGL libraries", which is exactly this situation. + */ + fun resetState() + + /** Releases three's own GL objects (programs, buffers, VAOs). Does not touch the context. */ + fun dispose() +} + +/** + * A three.js render target that is only a descriptor: its framebuffer is ours, set through + * [ThreeRenderer.setRenderTargetFramebuffer]. Deliberately opaque, because neither `setSize()` nor + * `dispose()` may ever be called on it — three's disposal path would delete a framebuffer it never + * created, i.e. ours. Size changes create a new instance instead. + */ +internal external interface ThreeRenderTarget : JsAny + +internal external interface ThreeObject3D : JsAny { + val position: ThreeVector3 + val rotation: ThreeEuler +} + +internal external interface ThreeScene : ThreeObject3D + +internal external interface ThreeCamera : ThreeObject3D + +internal external interface ThreePerspectiveCamera : ThreeCamera { + var aspect: Double + + fun updateProjectionMatrix() +} + +internal external interface ThreeMesh : ThreeObject3D + +internal external interface ThreeStandardMaterial : JsAny { + val color: ThreeColor + var roughness: Double + var metalness: Double +} + +internal external interface ThreeLight : ThreeObject3D { + var intensity: Double +} + +internal external interface ThreeColor : JsAny { + fun setHSL(h: Double, s: Double, l: Double) +} + +internal external interface ThreeVector3 : JsAny { + var x: Double + var y: Double + var z: Double + + fun set(x: Double, y: Double, z: Double) +} + +internal external interface ThreeEuler : JsAny { + var x: Double + var y: Double + var z: Double +} + +/** The handles the demo mutates every frame, bundled by [createKnotScene]. */ +internal external interface ThreeKnotScene : JsAny { + val scene: ThreeScene + val camera: ThreePerspectiveCamera + val knot: ThreeMesh + val material: ThreeStandardMaterial + val keyLight: ThreeLight +} + +/** Loads the `three` npm package. */ +internal suspend fun loadThreeModule(): ThreeModule? = + importThree().await()?.unsafeCast() + +/** + * Creates the renderer on top of the canvas *and* the context Skiko already owns. + * + * Passing `context` is what keeps everything in one WebGL context — WebGL has no share groups, so a + * renderer with its own context could never produce a texture Skia is allowed to read. The renderer + * must therefore never be asked to resize anything (`setSize`, `setPixelRatio`) or to drop the + * context (`forceContextLoss`): the canvas and the context belong to Compose. + */ +// language=js +internal fun createThreeRenderer( + three: ThreeModule, + canvas: HTMLCanvasElement, + gl: WebGLRenderingContext, +): ThreeRenderer = js( + """(function() { + const renderer = new three.WebGLRenderer({ + canvas: canvas, + context: gl, + alpha: true, + premultipliedAlpha: true, + }); + renderer.autoClear = true; + // Transparent, premultiplied clear so that Compose content shows through the texture, and so + // that the adopted image blends the way ColorAlphaType.PREMUL promises. + renderer.setClearColor(0x000000, 0); + return renderer; + })()""" +) + +/** + * Creates the render target descriptor for a texture of [width] x [height]. Its `viewport` is derived + * from that size, which is what three.js renders with once the target is active. + */ +// language=js +internal fun createRenderTarget(three: ThreeModule, width: Int, height: Int): ThreeRenderTarget = js( + """(new three.WebGLRenderTarget(width, height))""" +) + +/** Builds the scene graph: a lit torus knot, entirely procedural, no external assets. */ +// language=js +internal fun createKnotScene(three: ThreeModule): ThreeKnotScene = js( + """(function() { + const scene = new three.Scene(); + + const camera = new three.PerspectiveCamera(42, 1.6, 0.1, 100); + camera.position.set(0, 0, 4.2); + + const material = new three.MeshStandardMaterial({ + color: 0x66d9ff, + roughness: 0.28, + metalness: 0.62, + }); + const geometry = new three.TorusKnotGeometry(0.85, 0.28, 220, 32, 2, 3); + const knot = new three.Mesh(geometry, material); + scene.add(knot); + + const keyLight = new three.DirectionalLight(0xffffff, 3.4); + keyLight.position.set(2.5, 3.0, 4.0); + scene.add(keyLight); + + const rimLight = new three.DirectionalLight(0xff5fa2, 2.2); + rimLight.position.set(-3.0, -1.5, -2.0); + scene.add(rimLight); + + scene.add(new three.AmbientLight(0x223355, 1.4)); + + return { + scene: scene, + camera: camera, + knot: knot, + material: material, + keyLight: keyLight, + }; + })()""" +) + +/** Disposes the geometries and materials [createKnotScene] allocated. */ +// language=js +internal fun disposeKnotScene(knotScene: ThreeKnotScene): Unit = js( + """(function() { + knotScene.scene.traverse(function(object) { + if (object.geometry) object.geometry.dispose(); + if (object.material) object.material.dispose(); + }); + })()""" +) + +// A bundler may hand back either the ES module namespace or a CommonJS interop wrapper, so the +// namespace is normalized here rather than at every call site. +// language=js +private fun importThree(): Promise = js("import('three').then(function(m) { return m.default || m; })") + +// language=js +private fun describeJsFailure(error: JsAny?): String = js("String(error)") + +private suspend fun Promise.await(): JsAny? = suspendCancellableCoroutine { continuation -> + then( + onFulfilled = { value -> continuation.resume(value); null }, + onRejected = { error -> + continuation.resumeWithException( + IllegalStateException("import('three') failed: ${describeJsFailure(error)}") + ) + null + }, + ) +} diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt new file mode 100644 index 0000000000000..706a8ac07feed --- /dev/null +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt @@ -0,0 +1,305 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Reaches for ComposeWindow to get the DirectContext Compose renders with; it is internal API, hence +// the suppression (the same trick the rest of this demo module uses). +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + +package androidx.compose.mpp.demo.webgl + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Card +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.mpp.demo.Screen +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.LocalComposeWindow +import kotlin.math.roundToInt +import org.jetbrains.skia.DirectContext + +/** + * The texture adoption demo with the WebGL work delegated to three.js: three renders a lit torus knot + * into a framebuffer whose color attachment is a texture Skia has adopted, and Compose then draws that + * texture like any other GPU image — tilted, clipped, blurred and composited with Compose content. + * + * Everything interesting about sharing one WebGL context between Skia and a third-party renderer lives + * in [ThreeAdoptedScene]. + */ +val ThreeTextureAdoptionScreen = Screen.Example("WebGL texture adoption (three.js)") { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + ThreeTextureAdoptionDemo() + } +} + +/** One tick of the demo clock. */ +private data class ThreeFrame(val index: Long, val fps: Float) + +private sealed interface SceneState { + object Loading : SceneState + + class Ready(val scene: ThreeAdoptedScene) : SceneState + + class Failed(val message: String) : SceneState +} + +@Composable +private fun ThreeTextureAdoptionDemo() { + val composeWindow = LocalComposeWindow.current + var sceneState by remember { mutableStateOf(SceneState.Loading) } + + // three.js arrives through a dynamic import, so the scene can only be built asynchronously. + LaunchedEffect(composeWindow) { + val canvas = composeWindow?.htmlCanvas + sceneState = if (canvas == null) { + SceneState.Failed( + "Could not obtain the WebGL context Compose renders with, so there is nothing to " + + "adopt a texture from." + ) + } else { + try { + val scene = ThreeAdoptedScene.createOrNull(canvas) + if (scene != null) { + SceneState.Ready(scene) + } else { + SceneState.Failed("three.js or the WebGL2 context Skiko uses is unavailable.") + } + } catch (throwable: Throwable) { + SceneState.Failed("Loading three.js failed: ${throwable.message}") + } + } + } + + val state = sceneState + DisposableEffect(state) { + onDispose { + if (state is SceneState.Ready) { + state.scene.dispose(composeWindow?.skiaDirectContext) + } + } + } + + when (state) { + is SceneState.Loading -> Centered("loading three.js…") + is SceneState.Failed -> Centered(state.message) + // skiaDirectContext is a plain field that stays null until Compose has rendered its first + // frame, so it is read through a lambda instead of being captured during composition. + is SceneState.Ready -> + ThreeSceneContent(state.scene) { composeWindow?.skiaDirectContext } + } +} + +@Composable +private fun Centered(message: String) { + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text(message, textAlign = TextAlign.Center) + } +} + +@Composable +private fun ThreeSceneContent(scene: ThreeAdoptedScene, directContext: () -> DirectContext?) { + var running by remember { mutableStateOf(true) } + var spin by remember { mutableStateOf(scene.spin) } + var hue by remember { mutableStateOf(scene.hue) } + var roughness by remember { mutableStateOf(scene.roughness) } + var metalness by remember { mutableStateOf(scene.metalness) } + var lightIntensity by remember { mutableStateOf(scene.lightIntensity) } + var textureSide by remember { mutableStateOf(1024f) } + + // Read only from draw scopes, so that a new frame invalidates the drawing and not the whole UI. + val frame = remember { mutableStateOf(ThreeFrame(0, 0f)) } + // Read from composition, and therefore refreshed a few times per second instead of every frame. + var stats by remember { mutableStateOf(ThreeFrame(0, 0f)) } + + scene.spin = spin + scene.hue = hue + scene.roughness = roughness + scene.metalness = metalness + scene.lightIntensity = lightIntensity + scene.textureSize = IntSize(textureSide.roundToInt(), (textureSide * 0.625f).roundToInt()) + + // withFrameNanos callbacks run inside the frame, before Compose measures, lays out and draws — so + // this is where three.js belongs: the texture holds this frame's content by the time Skia submits + // the frame that samples it. Drawing sites below only draw the resulting image. + LaunchedEffect(running, scene) { + if (!running) return@LaunchedEffect + var previousNanos = 0L + while (true) { + withFrameNanos { nanos -> + val deltaSeconds = + if (previousNanos == 0L) 0f else (nanos - previousNanos) / 1_000_000_000f + previousNanos = nanos + val current = frame.value + val next = ThreeFrame( + index = current.index + 1, + fps = if (deltaSeconds > 0f) { + current.fps * 0.9f + (1f / deltaSeconds) * 0.1f + } else { + current.fps + }, + ) + val context = directContext() + if (context != null) { + scene.renderFrame(context, deltaSeconds) + } + + frame.value = next + if (next.index % 20 == 0L) stats = next + } + } + } + + Column( + modifier = Modifier.width(600.dp).verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Hero(scene, frame) + Variants(scene, frame) + Card(Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + LabelledSlider("spin", spin, 0f..3f) { spin = it } + LabelledSlider("material hue", hue, 0f..1f) { hue = it } + LabelledSlider("roughness", roughness, 0f..1f) { roughness = it } + LabelledSlider("metalness", metalness, 0f..1f) { metalness = it } + LabelledSlider("key light", lightIntensity, 0f..8f) { lightIntensity = it } + LabelledSlider( + label = "texture width", + value = textureSide, + valueRange = 256f..2048f, + onValueChange = { textureSide = it }, + valueText = "${textureSide.roundToInt()} px", + ) + Toggle("animate", running) { running = it } + } + } + Card(Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + StatusLine("skia context", directContext()?.toString() ?: "not captured yet") + StatusLine("state", scene.status) + StatusLine("adopted texture id", scene.adoptedTextureId.toString()) + StatusLine("textures handed to Skia", scene.adoptedTextureCount.toString()) + StatusLine("frame", "${stats.index} · ${stats.fps.roundToInt()} fps") + } + } + } +} + +/** The three.js output as the hero: tilted in 3D by dragging, clipped, with Compose content on top. */ +@Composable +private fun Hero(scene: ThreeAdoptedScene, frame: State) { + var tiltX by remember { mutableStateOf(0f) } + var tiltY by remember { mutableStateOf(0f) } + + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1.6f) + .pointerInput(Unit) { + detectDragGestures { _, dragAmount -> + tiltY = (tiltY + dragAmount.x * 0.15f).coerceIn(-35f, 35f) + tiltX = (tiltX - dragAmount.y * 0.15f).coerceIn(-35f, 35f) + } + } + .graphicsLayer { + rotationX = tiltX + rotationY = tiltY + cameraDistance = 16f * density + } + .clip(RoundedCornerShape(28.dp)) + // A gradient underneath proves the texture arrives with a real alpha channel: three.js + // clears it to transparent, so this shows through everywhere the knot is not. + .background(Brush.linearGradient(listOf(Color(0xFF0E1B33), Color(0xFF3A1250)))), + contentAlignment = Alignment.BottomStart, + ) { + AdoptedTextureSurface(Modifier.fillMaxSize(), frame) { scene.image } + Column(Modifier.padding(20.dp)) { + Text( + "three.js below, Compose above", + color = Color.White, + style = MaterialTheme.typography.h6, + ) + Text( + "drag to tilt · one WebGL context, one GPU texture, no copies", + color = Color.White.copy(alpha = 0.75f), + style = MaterialTheme.typography.caption, + ) + } + } +} + +/** The same adopted texture, reused several times in one frame with different Compose treatments. */ +@Composable +private fun Variants(scene: ThreeAdoptedScene, frame: State) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + AdoptedTextureSurface(Modifier.size(96.dp).clip(CircleShape), frame) { scene.image } + AdoptedTextureSurface( + Modifier.size(96.dp) + .clip(RoundedCornerShape(16.dp)) + .graphicsLayer { + rotationZ = 12f + alpha = 0.75f + }, + frame, + ) { scene.image } + AdoptedTextureSurface( + Modifier.size(96.dp).clip(RoundedCornerShape(16.dp)).blur(6.dp), + frame, + ) { scene.image } + } +} diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/WebGlTextureRegistration.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/WebGlTextureRegistration.web.kt new file mode 100644 index 0000000000000..b66a9adac1474 --- /dev/null +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/WebGlTextureRegistration.web.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Skiko ships `pushTexture`/`unregisterTexture` in its Kotlin/Wasm source set only, while the +// Emscripten `GL` handle those functions need is declared in Skiko's shared web source set (as an +// internal API, hence the suppression). Re-implementing the two helpers here on top of that handle +// is what lets this demo live in `webMain` and run on both Kotlin/JS and Kotlin/Wasm. +// TODO: delete this file and use org.jetbrains.skiko.pushTexture/unregisterTexture once Skiko +// exposes them for both web targets. +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") +@file:OptIn(ExperimentalWasmJsInterop::class) + +package androidx.compose.mpp.demo.webgl + +import kotlin.js.ExperimentalWasmJsInterop +import kotlin.js.JsAny +import org.jetbrains.skiko.GL +import org.jetbrains.skiko.GLInterface + +/** + * Registers an externally-created `WebGLTexture` in Emscripten's GL texture table. + * + * The returned id can be passed to Skia GL APIs that expect a numeric texture id, such as + * [org.jetbrains.skia.BackendTexture.makeGL]. The texture must belong to the same WebGL context + * that Skiko is using. + * + * This function only creates the Emscripten table entry. If the returned id is passed to a Skia API + * that takes ownership of the texture, Skia will delete the GL texture through Emscripten and the + * table entry will be cleared there. If ownership is not transferred to Skia, call + * [unregisterTexture] when the id is no longer needed to avoid leaking the table entry. + */ +internal fun pushTexture(texture: JsAny): Int = pushTexture(GL, texture) + +/** + * Removes a texture table entry previously created with [pushTexture]. + * + * This does not delete the underlying `WebGLTexture`; it only releases Skiko/Emscripten's numeric id + * mapping. Use it only when the id was not handed to a Skia API that takes ownership of the texture. + */ +internal fun unregisterTexture(textureId: Int): Unit = unregisterTexture(GL, textureId) + +/** + * `GL.textures` is the array Emscripten's GL layer indexes with the ids Skia's GL backend speaks, + * and `getNewId` is how Emscripten itself allocates a free slot in it. + */ +// language=js +private fun pushTexture(gl: GLInterface, texture: JsAny): Int = js( + """(function() { + const textureHandle = gl.getNewId(gl.textures); + gl.textures[textureHandle] = texture; + return textureHandle; + })()""" +) + +// language=js +private fun unregisterTexture(gl: GLInterface, textureId: Int): Unit = + js("(gl.textures[textureId] = null)") diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt index 5b4092939a2bb..82c29fbce387e 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt @@ -110,6 +110,7 @@ import kotlinx.coroutines.channels.Channel.Factory.CONFLATED import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.receiveAsFlow +import org.jetbrains.skia.DirectContext import org.jetbrains.skiko.SkiaLayer import org.jetbrains.skiko.SkikoRenderDelegate import org.jetbrains.skiko.hostOs @@ -391,8 +392,30 @@ internal class ComposeWindow( get() = configuration.isClearFocusOnMouseDownEnabled } + /** + * The canvas Compose renders into. Asking it for a `"webgl2"` context returns the very context + * Skiko renders with, which is the only context whose textures Skia is able to use. + */ + internal val htmlCanvas: HTMLCanvasElement get() = canvas + + /** + * Skia's GPU context, captured from the surface canvas on the first rendered frame, or `null` + * before that. + * + * It is only reachable here: the canvas passed to a composable's draw is usually a graphics + * layer's display-list recorder, whose `recordingContext` is legitimately `null`. Code that + * needs the context (for instance to adopt an externally created WebGL texture into a Skia + * image) has to read it from here instead. The context lives as long as the canvas, so it is + * safe to hold on to; it must not be closed by the reader. + */ + internal var skiaDirectContext: DirectContext? = null + private set + private val skiaLayer: SkiaLayer = SkiaLayer().apply { renderDelegate = SkikoRenderDelegate { canvas, _, _, nanoTime -> + if (skiaDirectContext == null) { + skiaDirectContext = canvas.recordingContext + } with(sceneRenderingScope) { scene.render(frameRecomposer, canvas.asComposeCanvas(), nanoTime) } From 10b8d077a7c60bdebfba4d2beed17e5fc4809773 Mon Sep 17 00:00:00 2001 From: "Oleksandr.Karpovich" Date: Tue, 18 Aug 2026 14:41:01 +0200 Subject: [PATCH 02/26] refactoring --- compose/mpp/demo/build.gradle.kts | 24 +- .../androidx/compose/mpp/demo/Main.web.kt | 6 +- .../mpp/demo/webgl/AdoptedGlScene.web.kt | 445 ------------------ .../mpp/demo/webgl/TextureAdoptionDemo.web.kt | 320 ------------- .../mpp/demo/webgl/ThreeAdoptedScene.web.kt | 44 +- .../mpp/demo/webgl/ThreeJsInterop.web.kt | 5 +- .../webgl/ThreeTextureAdoptionDemo.web.kt | 91 ++-- .../webgl/WebGlTextureRegistration.web.kt | 32 +- 8 files changed, 61 insertions(+), 906 deletions(-) delete mode 100644 compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/AdoptedGlScene.web.kt delete mode 100644 compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/TextureAdoptionDemo.web.kt diff --git a/compose/mpp/demo/build.gradle.kts b/compose/mpp/demo/build.gradle.kts index 9b20d43dc8102..3c80e38d0ccd7 100644 --- a/compose/mpp/demo/build.gradle.kts +++ b/compose/mpp/demo/build.gradle.kts @@ -31,9 +31,6 @@ plugins { alias(libs.plugins.kotlinSerialization) } -/** three.js release r180, used by the WebGL texture adoption demo. */ -val threeJsVersion = "0.180.0" - kotlin { applyDefaultHierarchyTemplate() jvm("desktop") @@ -171,25 +168,10 @@ kotlin { dependencies { implementation(libs.kotlinSerializationJson) - // Shared org.w3c/org.khronos declarations for both js and wasmJs (see webgl demos). - api(libs.kotlinXw3c) - } - } - - // three.js is used by the WebGL texture adoption demo in webMain, but npm dependencies are - // per-target, so both web targets declare it. The Kotlin side reaches it with a dynamic - // import(), because typed @JsModule externals cannot be shared between js and wasmJs: - // Kotlin/JS requires @JsNonModule alongside @JsModule when compiling to UMD, and Kotlin/Wasm - // has no @JsNonModule. - val jsMain by getting { - dependencies { - implementation(npm("three", threeJsVersion)) - } - } + implementation(libs.kotlinXw3c) - val wasmJsMain by getting { - dependencies { - implementation(npm("three", threeJsVersion)) + // https://github.com/mrdoob/three.js/ for WebGl demo + implementation(npm("three", "0.185.0")) } } diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/Main.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/Main.web.kt index 741dc4a1fcf1e..867ad5e90dacc 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/Main.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/Main.web.kt @@ -19,8 +19,7 @@ package androidx.compose.mpp.demo import androidx.compose.mpp.demo.bugs.BugsScreen import androidx.compose.mpp.demo.components.text.loadResource import androidx.compose.mpp.demo.interops.HtmlInteropDemos -import androidx.compose.mpp.demo.webgl.TextureAdoptionScreen -import androidx.compose.mpp.demo.webgl.ThreeTextureAdoptionScreen +import androidx.compose.mpp.demo.webgl.ThreeJsTextureAdoptionScreen import androidx.compose.runtime.LaunchedEffect import androidx.compose.mpp.demo.embedded.embeddedScrollDemo import androidx.compose.runtime.mutableStateOf @@ -73,8 +72,7 @@ fun defaultComposeDemo() { }, HtmlInteropDemos, HapticFeedbackExample, - TextureAdoptionScreen, - ThreeTextureAdoptionScreen, + ThreeJsTextureAdoptionScreen, ) ) } diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/AdoptedGlScene.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/AdoptedGlScene.web.kt deleted file mode 100644 index 16440ed2ec5c9..0000000000000 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/AdoptedGlScene.web.kt +++ /dev/null @@ -1,445 +0,0 @@ -/* - * Copyright 2026 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@file:OptIn(ExperimentalWasmJsInterop::class) - -package androidx.compose.mpp.demo.webgl - -import androidx.compose.ui.unit.IntSize -import kotlin.js.ExperimentalWasmJsInterop -import kotlin.js.JsAny -import kotlin.js.JsBoolean -import kotlin.js.toBoolean -import kotlin.js.unsafeCast -import kotlin.math.cos -import kotlin.math.sin -import org.jetbrains.skia.DirectContext -import org.jetbrains.skia.Image -import org.khronos.webgl.Float32Array -import org.khronos.webgl.Uint8Array -import org.khronos.webgl.WebGLBuffer -import org.khronos.webgl.WebGLFramebuffer -import org.khronos.webgl.WebGLProgram -import org.khronos.webgl.WebGLRenderingContext -import org.khronos.webgl.WebGLRenderingContext.Companion.ARRAY_BUFFER -import org.khronos.webgl.WebGLRenderingContext.Companion.BLEND -import org.khronos.webgl.WebGLRenderingContext.Companion.CLAMP_TO_EDGE -import org.khronos.webgl.WebGLRenderingContext.Companion.COLOR_ATTACHMENT0 -import org.khronos.webgl.WebGLRenderingContext.Companion.COLOR_BUFFER_BIT -import org.khronos.webgl.WebGLRenderingContext.Companion.COMPILE_STATUS -import org.khronos.webgl.WebGLRenderingContext.Companion.CULL_FACE -import org.khronos.webgl.WebGLRenderingContext.Companion.DEPTH_TEST -import org.khronos.webgl.WebGLRenderingContext.Companion.FLOAT -import org.khronos.webgl.WebGLRenderingContext.Companion.FRAGMENT_SHADER -import org.khronos.webgl.WebGLRenderingContext.Companion.FRAMEBUFFER -import org.khronos.webgl.WebGLRenderingContext.Companion.FRAMEBUFFER_COMPLETE -import org.khronos.webgl.WebGLRenderingContext.Companion.LINEAR -import org.khronos.webgl.WebGLRenderingContext.Companion.LINK_STATUS -import org.khronos.webgl.WebGLRenderingContext.Companion.ONE -import org.khronos.webgl.WebGLRenderingContext.Companion.ONE_MINUS_SRC_ALPHA -import org.khronos.webgl.WebGLRenderingContext.Companion.RGBA -import org.khronos.webgl.WebGLRenderingContext.Companion.SCISSOR_TEST -import org.khronos.webgl.WebGLRenderingContext.Companion.STATIC_DRAW -import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE0 -import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_2D -import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_MAG_FILTER -import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_MIN_FILTER -import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_WRAP_S -import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_WRAP_T -import org.khronos.webgl.WebGLRenderingContext.Companion.TRIANGLE_STRIP -import org.khronos.webgl.WebGLRenderingContext.Companion.UNSIGNED_BYTE -import org.khronos.webgl.WebGLRenderingContext.Companion.VERTEX_SHADER -import org.khronos.webgl.WebGLShader -import org.khronos.webgl.WebGLTexture -import org.khronos.webgl.WebGLUniformLocation -import org.khronos.webgl.set -import org.w3c.dom.HTMLCanvasElement - -/** Vertices of a viewport-filling triangle strip, in clip space. */ -private val QUAD_VERTICES = floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f) - -/** - * Renders a hand-written WebGL scene into a texture and lets Compose draw that texture without ever - * copying pixels through the CPU. - * - * The whole thing rests on three facts: - * 1. Asking the `` Compose renders into for a `"webgl2"` context returns the very same - * `WebGL2RenderingContext` Skiko created — a canvas never hands out a second context. WebGL has - * no share groups, so this is the only context whose textures Skia is allowed to touch. - * 2. [pushTexture] publishes our `WebGLTexture` in Emscripten's texture table, which is what turns - * it into the numeric id Skia's GL API speaks. Skiko only exposes that helper to Kotlin/Wasm, so - * this demo re-implements it for both web targets in `WebGlTextureRegistration.web.kt`. - * 3. An adopted [Image] can only be drawn by the [DirectContext] that adopted it, so the context - * Compose renders with has to be the one passed to [renderFrame]. - * - * [renderFrame] is meant to be called once per frame from a `withFrameNanos` callback, which runs - * before Compose measures, lays out and draws. Drawing sites then only draw [image]; they never - * touch GL. That split matters: the canvas a composable draws on is usually a graphics layer's - * display-list recorder, so GL work cannot happen there. - */ -internal class AdoptedGlScene private constructor(private val gl: WebGLRenderingContext) { - - companion object { - /** - * Returns a scene rendering into [canvas]'s WebGL2 context — the context Skiko uses — or - * `null` if that context cannot be obtained. - */ - fun createOrNull(canvas: HTMLCanvasElement): AdoptedGlScene? = - webGl2ContextOf(canvas)?.let(::AdoptedGlScene) - } - - /** How much the plasma field is domain-warped. */ - var warp: Float = 0.45f - - /** Offset into the cosine palette. */ - var hue: Float = 0.1f - - /** Brightness of the spinning quad. */ - var glow: Float = 0.85f - - /** - * When `true`, a fresh texture is allocated, registered and adopted on every frame and the - * previous [Image] is closed — which is Skia's cue to delete the previous GL texture through - * Emscripten. When `false` (the interesting mode) one texture is adopted once and then - * re-rendered in place forever. - */ - var recreateTextureEveryFrame: Boolean = false - - /** Resolution of the offscreen texture. Changing it adopts a new texture of that size. */ - var textureSize: IntSize = IntSize(1024, 640) - - /** Human readable state, surfaced by the demo UI. */ - var status: String = "waiting for the first frame" - private set - - /** Emscripten id of the texture Skia currently owns, or `-1`. */ - var adoptedTextureId: Int = -1 - private set - - /** How many textures have been handed over to Skia so far. */ - var adoptedTextureCount: Int = 0 - private set - - private var plasmaProgram: GlProgram? = null - private var quadProgram: GlProgram? = null - private var vertexBuffer: WebGLBuffer? = null - private var framebuffer: WebGLFramebuffer? = null - - /** A texture the demo keeps ownership of; only our own shader ever samples it. */ - private var patternTexture: WebGLTexture? = null - - private var target: AdoptedGlTexture? = null - - /** - * The previous frame's image in "new texture every frame" mode. It is closed one frame late, - * because a display list recorded during the previous frame may still reference it. - */ - private var retiredImage: Image? = null - private var adoptedSize = IntSize.Zero - private var failed = false - - /** The adopted texture to draw, or `null` until the first frame has been rendered. */ - val image: Image? get() = target?.image - - /** - * Renders one frame of the WebGL scene into the adopted texture, adopting a new texture first if - * needed. Call once per frame from `withFrameNanos`, passing the context Compose renders with. - */ - fun renderFrame(context: DirectContext, timeSeconds: Float) { - if (failed) return - - val size = IntSize( - width = textureSize.width.coerceIn(16, 4096), - height = textureSize.height.coerceIn(16, 4096), - ) - - try { - createGlObjectsIfNeeded() - - retiredImage?.close() - retiredImage = null - - val previous = target - val current = when { - previous == null || adoptedSize != size || recreateTextureEveryFrame -> { - gl.adoptNewTexture(context, size).also { - retiredImage = previous?.image - adoptedSize = size - adoptedTextureId = it.textureId - adoptedTextureCount++ - } - } - else -> previous - } - target = current - - renderSceneInto(current, timeSeconds, size) - - // Everything above went behind Skia's back: the framebuffer, program, buffer and - // texture bindings it had cached are stale now. Without this, Compose renders garbage. - context.resetAll() - - status = if (recreateTextureEveryFrame) { - "adopting a new ${size.width}×${size.height} texture every frame" - } else { - "one adopted ${size.width}×${size.height} texture, re-rendered in place" - } - } catch (throwable: Throwable) { - failed = true - status = "failed: ${throwable.message}" - } - } - - /** - * Demonstrates the non-owning half of the API. [patternTexture] stays ours, so after publishing - * it in Emscripten's table the id has to be taken back out by hand. [unregisterTexture] only - * drops that id: the texture keeps living, and the spinning quad keeps sampling it. - */ - fun registrationRoundTrip(): String { - val texture = patternTexture ?: return "GL objects are not created yet" - val id = pushTexture(texture) - unregisterTexture(id) - return "pushTexture(pattern) returned id $id, released again with unregisterTexture($id) — " + - "the texture itself is untouched and still being sampled" - } - - fun dispose() { - retiredImage?.close() - retiredImage = null - target?.image?.close() - target = null - adoptedSize = IntSize.Zero - adoptedTextureId = -1 - releaseGlObjects() - } - - private fun renderSceneInto(target: AdoptedGlTexture, timeSeconds: Float, size: IntSize) { - val plasma = plasmaProgram ?: error("shader programs are not compiled") - val quad = quadProgram ?: error("shader programs are not compiled") - val aspect = size.width.toFloat() / size.height.toFloat() - - gl.bindFramebuffer(FRAMEBUFFER, framebuffer) - gl.framebufferTexture2D(FRAMEBUFFER, COLOR_ATTACHMENT0, TEXTURE_2D, target.texture, 0) - check(gl.checkFramebufferStatus(FRAMEBUFFER) == FRAMEBUFFER_COMPLETE) { - "the adopted texture is not a complete framebuffer attachment" - } - - gl.viewport(0, 0, size.width, size.height) - gl.disable(DEPTH_TEST) - gl.disable(SCISSOR_TEST) - gl.disable(CULL_FACE) - gl.clearColor(0f, 0f, 0f, 0f) - gl.clear(COLOR_BUFFER_BIT) - gl.enable(BLEND) - gl.blendFunc(ONE, ONE_MINUS_SRC_ALPHA) // premultiplied source - gl.bindBuffer(ARRAY_BUFFER, vertexBuffer) - - gl.useProgram(plasma.program) - bindQuadVertices(plasma.positionAttribute) - gl.uniform1f(plasma.uniform("uTime"), timeSeconds) - gl.uniform1f(plasma.uniform("uWarp"), warp) - gl.uniform1f(plasma.uniform("uHue"), hue) - gl.uniform1f(plasma.uniform("uAspect"), aspect) - gl.drawArrays(TRIANGLE_STRIP, 0, 4) - - val angle = timeSeconds * 0.8f - gl.useProgram(quad.program) - bindQuadVertices(quad.positionAttribute) - gl.uniform2f(quad.uniform("uRotation"), cos(angle), sin(angle)) - gl.uniform1f(quad.uniform("uScale"), 0.46f + 0.04f * sin(timeSeconds * 1.7f)) - gl.uniform1f(quad.uniform("uAspect"), aspect) - gl.uniform1f(quad.uniform("uTime"), timeSeconds) - gl.uniform1f(quad.uniform("uGlow"), glow) - gl.activeTexture(TEXTURE0) - gl.bindTexture(TEXTURE_2D, patternTexture) - gl.uniform1i(quad.uniform("uPattern"), 0) - gl.drawArrays(TRIANGLE_STRIP, 0, 4) - - // Hand the default framebuffer — the one Skia renders Compose into — back. - gl.bindFramebuffer(FRAMEBUFFER, null) - } - - private fun bindQuadVertices(attribute: Int) { - gl.enableVertexAttribArray(attribute) - gl.vertexAttribPointer(attribute, 2, FLOAT, false, 0, 0) - } - - private fun createGlObjectsIfNeeded() { - if (framebuffer != null) return - - framebuffer = gl.createFramebuffer() ?: error("gl.createFramebuffer() returned null") - plasmaProgram = GlProgram(gl, SCENE_VERTEX_SHADER, PLASMA_FRAGMENT_SHADER) - quadProgram = GlProgram(gl, SPINNING_QUAD_VERTEX_SHADER, SPINNING_QUAD_FRAGMENT_SHADER) - - val vertices = Float32Array(QUAD_VERTICES.size) - QUAD_VERTICES.forEachIndexed { index, value -> vertices[index] = value } - vertexBuffer = (gl.createBuffer() ?: error("gl.createBuffer() returned null")).also { - gl.bindBuffer(ARRAY_BUFFER, it) - gl.bufferData(ARRAY_BUFFER, vertices, STATIC_DRAW) - } - - patternTexture = createPatternTexture() - } - - private fun releaseGlObjects() { - framebuffer?.let { gl.deleteFramebuffer(it) } - framebuffer = null - vertexBuffer?.let { gl.deleteBuffer(it) } - vertexBuffer = null - patternTexture?.let { gl.deleteTexture(it) } - patternTexture = null - plasmaProgram?.dispose() - plasmaProgram = null - quadProgram?.dispose() - quadProgram = null - } - - /** A small procedural texture the demo keeps for itself, sampled by the spinning quad. */ - private fun createPatternTexture(): WebGLTexture { - val side = 64 - val pixels = Uint8Array(side * side * 4) - for (y in 0 until side) { - for (x in 0 until side) { - val checker = if (((x / 8) + (y / 8)) % 2 == 0) 1f else 0.55f - val gradient = y.toFloat() / (side - 1) - val offset = (y * side + x) * 4 - pixels[offset] = (255 * checker * (0.35f + 0.65f * gradient)).toInt().toByte() - pixels[offset + 1] = (255 * checker * (0.75f - 0.35f * gradient)).toInt().toByte() - pixels[offset + 2] = (255 * checker).toInt().toByte() - pixels[offset + 3] = 0xFF.toByte() - } - } - - val texture = gl.createTexture() ?: error("gl.createTexture() returned null") - gl.bindTexture(TEXTURE_2D, texture) - gl.texImage2D(TEXTURE_2D, 0, RGBA, side, side, 0, RGBA, UNSIGNED_BYTE, pixels) - gl.texParameteri(TEXTURE_2D, TEXTURE_MIN_FILTER, LINEAR) - gl.texParameteri(TEXTURE_2D, TEXTURE_MAG_FILTER, LINEAR) - gl.texParameteri(TEXTURE_2D, TEXTURE_WRAP_S, CLAMP_TO_EDGE) - gl.texParameteri(TEXTURE_2D, TEXTURE_WRAP_T, CLAMP_TO_EDGE) - gl.bindTexture(TEXTURE_2D, null) - return texture - } -} - -private class GlProgram( - private val gl: WebGLRenderingContext, - vertexShaderSource: String, - fragmentShaderSource: String, -) { - private val vertexShader = gl.createCompiledShader(VERTEX_SHADER, vertexShaderSource) - private val fragmentShader = gl.createCompiledShader(FRAGMENT_SHADER, fragmentShaderSource) - private val uniforms = mutableMapOf() - - val program: WebGLProgram = (gl.createProgram() ?: error("gl.createProgram() returned null")) - .also { program -> - gl.attachShader(program, vertexShader) - gl.attachShader(program, fragmentShader) - gl.linkProgram(program) - check(gl.getProgramParameter(program, LINK_STATUS).isTrue()) { - "program linking failed: ${gl.getProgramInfoLog(program)}" - } - } - - val positionAttribute: Int = gl.getAttribLocation(program, "aPosition") - - fun uniform(name: String): WebGLUniformLocation? = - uniforms.getOrPut(name) { gl.getUniformLocation(program, name) } - - fun dispose() { - gl.deleteProgram(program) - gl.deleteShader(vertexShader) - gl.deleteShader(fragmentShader) - } -} - -private fun WebGLRenderingContext.createCompiledShader(type: Int, source: String): WebGLShader { - val shader = createShader(type) ?: error("gl.createShader() returned null") - shaderSource(shader, source) - compileShader(shader) - check(getShaderParameter(shader, COMPILE_STATUS).isTrue()) { - "shader compilation failed: ${getShaderInfoLog(shader)}" - } - return shader -} - -private fun JsAny?.isTrue(): Boolean = this?.unsafeCast()?.toBoolean() == true - -private const val SCENE_VERTEX_SHADER = """ - attribute vec2 aPosition; - varying vec2 vUv; - void main() { - vUv = aPosition * 0.5 + 0.5; - gl_Position = vec4(aPosition, 0.0, 1.0); - } -""" - -private const val PLASMA_FRAGMENT_SHADER = """ - precision mediump float; - varying vec2 vUv; - uniform float uTime; - uniform float uWarp; - uniform float uHue; - uniform float uAspect; - - vec3 palette(float t) { - return 0.5 + 0.5 * cos(6.28318 * (vec3(0.0, 0.33, 0.67) + t)); - } - - void main() { - vec2 p = (vUv * 2.0 - 1.0) * vec2(uAspect, 1.0); - for (int i = 0; i < 3; i++) { - p += uWarp * 0.35 * vec2(sin(p.y * 3.0 + uTime), cos(p.x * 3.0 - uTime * 0.7)); - } - float field = sin(p.x * 3.0 + uTime) - + sin(p.y * 3.5 - uTime * 0.8) - + sin(length(p) * 5.0 - uTime * 1.3); - vec3 color = palette(field * 0.15 + uHue); - float alpha = smoothstep(1.35, 0.2, length(vUv * 2.0 - 1.0)); - gl_FragColor = vec4(color * alpha, alpha); - } -""" - -private const val SPINNING_QUAD_VERTEX_SHADER = """ - attribute vec2 aPosition; - uniform vec2 uRotation; - uniform float uScale; - uniform float uAspect; - varying vec2 vUv; - void main() { - vUv = aPosition * 0.5 + 0.5; - vec2 rotated = vec2( - aPosition.x * uRotation.x - aPosition.y * uRotation.y, - aPosition.x * uRotation.y + aPosition.y * uRotation.x - ) * uScale; - gl_Position = vec4(rotated.x / uAspect, rotated.y, 0.0, 1.0); - } -""" - -private const val SPINNING_QUAD_FRAGMENT_SHADER = """ - precision mediump float; - varying vec2 vUv; - uniform sampler2D uPattern; - uniform float uTime; - uniform float uGlow; - - void main() { - vec4 pattern = texture2D(uPattern, vUv + vec2(uTime * 0.04, uTime * 0.02)); - float mask = smoothstep(0.5, 0.36, length(vUv - 0.5)); - float alpha = mask * 0.9; - gl_FragColor = vec4(pattern.rgb * uGlow * alpha, alpha); - } -""" diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/TextureAdoptionDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/TextureAdoptionDemo.web.kt deleted file mode 100644 index 1079bf18e60e5..0000000000000 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/TextureAdoptionDemo.web.kt +++ /dev/null @@ -1,320 +0,0 @@ -/* - * Copyright 2026 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// The demo reaches for ComposeWindow to get the DirectContext Compose renders with; it is internal -// API, hence the suppression (the same trick the rest of this demo module uses). -@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") - -package androidx.compose.mpp.demo.webgl - -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Button -import androidx.compose.material.Card -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.mpp.demo.Screen -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.withFrameNanos -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.blur -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.LocalComposeWindow -import kotlin.math.roundToInt -import org.jetbrains.skia.DirectContext - -/** - * Draws a WebGL scene inside Compose with no pixel copies: the scene is rendered into a - * `WebGLTexture` that Skia has adopted, and Compose draws that texture like any other GPU image — - * clipped, rotated, blurred and composited with regular Compose content on top of it. - */ -val TextureAdoptionScreen = Screen.Example("WebGL texture adoption") { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - TextureAdoptionDemo() - } -} - -/** One tick of the demo clock. */ -private data class Frame(val index: Long, val timeSeconds: Float, val fps: Float) - -@Composable -private fun TextureAdoptionDemo() { - val composeWindow = LocalComposeWindow.current - val scene = remember(composeWindow) { - composeWindow?.let { AdoptedGlScene.createOrNull(it.htmlCanvas) } - } - DisposableEffect(scene) { onDispose { scene?.dispose() } } - - if (scene == null) { - Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { - Text( - "Could not obtain the WebGL context Compose renders with, so there is nothing to " + - "adopt a texture from.", - textAlign = TextAlign.Center, - ) - } - return - } - - var running by remember { mutableStateOf(true) } - var speed by remember { mutableStateOf(1f) } - var warp by remember { mutableStateOf(scene.warp) } - var hue by remember { mutableStateOf(scene.hue) } - var glow by remember { mutableStateOf(scene.glow) } - var recreateEveryFrame by remember { mutableStateOf(false) } - var textureSide by remember { mutableStateOf(1024f) } - var roundTripLog by remember { mutableStateOf(null) } - - // Read only from draw scopes, so that a new frame invalidates the drawing and not the whole UI. - val frame = remember { mutableStateOf(Frame(0, 0f, 0f)) } - // Read from composition, and therefore refreshed a few times per second instead of every frame. - var stats by remember { mutableStateOf(Frame(0, 0f, 0f)) } - - scene.warp = warp - scene.hue = hue - scene.glow = glow - scene.recreateTextureEveryFrame = recreateEveryFrame - scene.textureSize = IntSize(textureSide.roundToInt(), (textureSide * 0.625f).roundToInt()) - - // withFrameNanos callbacks run inside the frame, before Compose measures, lays out and draws — - // so this is where the WebGL pass belongs: the texture holds this frame's content by the time - // Skia submits the frame that samples it. - LaunchedEffect(running, scene) { - if (!running) return@LaunchedEffect - var previousNanos = 0L - while (true) { - withFrameNanos { nanos -> - val deltaSeconds = - if (previousNanos == 0L) 0f else (nanos - previousNanos) / 1_000_000_000f - previousNanos = nanos - val current = frame.value - val next = Frame( - index = current.index + 1, - timeSeconds = current.timeSeconds + deltaSeconds * speed, - fps = if (deltaSeconds > 0f) { - current.fps * 0.9f + (1f / deltaSeconds) * 0.1f - } else { - current.fps - }, - ) - // Null until Compose has rendered its first frame and captured the context. - val directContext = composeWindow?.skiaDirectContext - if (directContext != null) { - scene.renderFrame(directContext, next.timeSeconds) - } - - frame.value = next - if (next.index % 20 == 0L) stats = next - } - } - } - - Column( - modifier = Modifier.width(600.dp).verticalScroll(rememberScrollState()).padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Hero(scene, frame) - Variants(scene, frame) - Controls( - running = running, - onRunningChange = { running = it }, - speed = speed, - onSpeedChange = { speed = it }, - warp = warp, - onWarpChange = { warp = it }, - hue = hue, - onHueChange = { hue = it }, - glow = glow, - onGlowChange = { glow = it }, - textureSide = textureSide, - onTextureSideChange = { textureSide = it }, - recreateEveryFrame = recreateEveryFrame, - onRecreateEveryFrameChange = { recreateEveryFrame = it }, - onRoundTrip = { roundTripLog = scene.registrationRoundTrip() }, - ) - Status(scene, stats, composeWindow?.skiaDirectContext, roundTripLog) - } -} - -/** - * The adopted texture as the hero: tilted in 3D by dragging, clipped to a rounded rectangle, and - * with Compose content composited on top of it. - */ -@Composable -private fun Hero(scene: AdoptedGlScene, frame: State) { - var tiltX by remember { mutableStateOf(0f) } - var tiltY by remember { mutableStateOf(0f) } - - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1.6f) - .pointerInput(Unit) { - detectDragGestures { _, dragAmount -> - tiltY = (tiltY + dragAmount.x * 0.15f).coerceIn(-35f, 35f) - tiltX = (tiltX - dragAmount.y * 0.15f).coerceIn(-35f, 35f) - } - } - .graphicsLayer { - rotationX = tiltX - rotationY = tiltY - cameraDistance = 16f * density - } - .clip(RoundedCornerShape(28.dp)) - // A gradient underneath proves the texture arrives with a real alpha channel. - .background(Brush.linearGradient(listOf(Color(0xFF12123A), Color(0xFF3A1250)))), - contentAlignment = Alignment.BottomStart, - ) { - AdoptedTextureSurface(Modifier.fillMaxSize(), frame) { scene.image } - Column(Modifier.padding(20.dp)) { - Text( - "Compose draws on top", - color = Color.White, - style = MaterialTheme.typography.h6, - ) - Text( - "drag to tilt · WebGL below, Compose above, one GPU texture", - color = Color.White.copy(alpha = 0.75f), - style = MaterialTheme.typography.caption, - ) - } - } -} - -/** The same adopted texture, reused several times in one frame with different Compose treatments. */ -@Composable -private fun Variants(scene: AdoptedGlScene, frame: State) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - AdoptedTextureSurface(Modifier.size(96.dp).clip(CircleShape), frame) { scene.image } - AdoptedTextureSurface( - Modifier.size(96.dp) - .clip(RoundedCornerShape(16.dp)) - .graphicsLayer { - rotationZ = 12f - alpha = 0.75f - }, - frame, - ) { scene.image } - AdoptedTextureSurface( - Modifier.size(96.dp).clip(RoundedCornerShape(16.dp)).blur(6.dp), - frame, - ) { scene.image } - } -} - -@Composable -private fun Controls( - running: Boolean, - onRunningChange: (Boolean) -> Unit, - speed: Float, - onSpeedChange: (Float) -> Unit, - warp: Float, - onWarpChange: (Float) -> Unit, - hue: Float, - onHueChange: (Float) -> Unit, - glow: Float, - onGlowChange: (Float) -> Unit, - textureSide: Float, - onTextureSideChange: (Float) -> Unit, - recreateEveryFrame: Boolean, - onRecreateEveryFrameChange: (Boolean) -> Unit, - onRoundTrip: () -> Unit, -) { - Card(Modifier.fillMaxWidth()) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - LabelledSlider("warp", warp, 0f..1.2f, onValueChange = onWarpChange) - LabelledSlider("palette", hue, 0f..1f, onValueChange = onHueChange) - LabelledSlider("glow", glow, 0f..1.5f, onValueChange = onGlowChange) - LabelledSlider("speed", speed, 0f..3f, onValueChange = onSpeedChange) - LabelledSlider( - label = "texture width", - value = textureSide, - valueRange = 256f..2048f, - onValueChange = onTextureSideChange, - valueText = "${textureSide.roundToInt()} px", - ) - Toggle("animate", running, onRunningChange) - Toggle( - label = "adopt a new texture every frame", - checked = recreateEveryFrame, - onCheckedChange = onRecreateEveryFrameChange, - ) - Button(onClick = onRoundTrip, modifier = Modifier.padding(top = 8.dp)) { - Text("pushTexture + unregisterTexture round trip") - } - } - } -} - -@Composable -private fun Status( - scene: AdoptedGlScene, - frame: Frame, - directContext: DirectContext?, - roundTripLog: String?, -) { - Card(Modifier.fillMaxWidth()) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - StatusLine("skia context", directContext?.toString() ?: "not captured yet") - StatusLine("state", scene.status) - StatusLine("adopted texture id", scene.adoptedTextureId.toString()) - StatusLine("textures handed to Skia", scene.adoptedTextureCount.toString()) - StatusLine("frame", "${frame.index} · ${frame.fps.roundToInt()} fps") - if (roundTripLog != null) { - StatusLine("round trip", roundTripLog) - } - } - } -} diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeAdoptedScene.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeAdoptedScene.web.kt index 312b6c365dc25..a694c093be902 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeAdoptedScene.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeAdoptedScene.web.kt @@ -35,68 +35,32 @@ import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_2D import org.khronos.webgl.WebGLTexture import org.w3c.dom.HTMLCanvasElement -/** - * The same zero-copy texture adoption as [AdoptedGlScene], except that the pixels are produced by - * three.js instead of hand-written shaders. - * - * Delegating to a third-party renderer adds exactly three requirements to the adoption plumbing: - * 1. The library has to render in *Skiko's* WebGL context. `WebGLRenderer({ canvas, context })` is - * how three.js accepts one; a library that insists on creating its own context could never produce - * a texture Skia may read, because WebGL has no share groups. - * 2. The destination has to stay ours. Skia takes ownership of the adopted texture, so three.js is - * pointed at the framebuffer this class owns through - * [ThreeRenderer.setRenderTargetFramebuffer] — the hook WebXR uses — instead of allocating a - * render target of its own. Nothing is ever copied, and no object has two owners. - * 3. Both sides have to invalidate their GL state caches every frame: - * [ThreeRenderer.resetState] before three.js draws, [DirectContext.resetAll] after it is done. - * Skipping either one is the classic "two WebGL libraries in one context" bug, where one of them - * silently stops drawing. - * - * Unlike the hand-written demo, the framebuffer here also carries a depth attachment: a torus knot - * self-occludes, so three.js needs a depth buffer, and since three never sets up this render target - * it never allocates one either. - */ -internal class ThreeAdoptedScene private constructor( +internal class ThreeJsAdoptedScene private constructor( private val gl: WebGLRenderingContext, private val three: ThreeModule, private val canvas: HTMLCanvasElement, ) { companion object { /** - * Loads three.js and binds it to the WebGL2 context Skiko renders with, or returns `null` if - * that context cannot be obtained. + * Loads three.js and binds it to the WebGL2 context managed by Skiko */ - suspend fun createOrNull(canvas: HTMLCanvasElement): ThreeAdoptedScene? { + suspend fun createOrNull(canvas: HTMLCanvasElement): ThreeJsAdoptedScene? { val gl = webGl2ContextOf(canvas) ?: return null val three = loadThreeModule() ?: return null - return ThreeAdoptedScene(gl, three, canvas) + return ThreeJsAdoptedScene(gl, three, canvas) } } - /** Rotation speed of the knot, in revolutions-ish per second. */ var spin: Float = 1f - - /** Hue of the knot's material. */ var hue: Float = 0.55f - var roughness: Float = 0.28f - var metalness: Float = 0.62f - var lightIntensity: Float = 3.4f - - /** Resolution of the offscreen texture. Changing it adopts a new texture of that size. */ var textureSize: IntSize = IntSize(1024, 640) - - /** Human readable state, surfaced by the demo UI. */ var status: String = "waiting for the first frame" private set - - /** Emscripten id of the texture Skia currently owns, or `-1`. */ var adoptedTextureId: Int = -1 private set - - /** How many textures have been handed over to Skia so far. */ var adoptedTextureCount: Int = 0 private set diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsInterop.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsInterop.web.kt index 1ea614e54c448..5046a5d670742 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsInterop.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsInterop.web.kt @@ -230,15 +230,12 @@ internal fun disposeKnotScene(knotScene: ThreeKnotScene): Unit = js( // language=js private fun importThree(): Promise = js("import('three').then(function(m) { return m.default || m; })") -// language=js -private fun describeJsFailure(error: JsAny?): String = js("String(error)") - private suspend fun Promise.await(): JsAny? = suspendCancellableCoroutine { continuation -> then( onFulfilled = { value -> continuation.resume(value); null }, onRejected = { error -> continuation.resumeWithException( - IllegalStateException("import('three') failed: ${describeJsFailure(error)}") + IllegalStateException("import('three') failed: $error}") ) null }, diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt index 706a8ac07feed..c7fd3afcd16af 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt @@ -65,14 +65,10 @@ import kotlin.math.roundToInt import org.jetbrains.skia.DirectContext /** - * The texture adoption demo with the WebGL work delegated to three.js: three renders a lit torus knot - * into a framebuffer whose color attachment is a texture Skia has adopted, and Compose then draws that - * texture like any other GPU image — tilted, clipped, blurred and composited with Compose content. - * - * Everything interesting about sharing one WebGL context between Skia and a third-party renderer lives - * in [ThreeAdoptedScene]. + * The texture adoption demo with the WebGL implementation delegated to three.js: + * it renders a lit torus knot into a texture, which is then adopted by Skiko and rendered as Skiko Image. */ -val ThreeTextureAdoptionScreen = Screen.Example("WebGL texture adoption (three.js)") { +val ThreeJsTextureAdoptionScreen = Screen.Example("WebGL texture adoption / Three.js integration") { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { ThreeTextureAdoptionDemo() } @@ -84,7 +80,7 @@ private data class ThreeFrame(val index: Long, val fps: Float) private sealed interface SceneState { object Loading : SceneState - class Ready(val scene: ThreeAdoptedScene) : SceneState + class Ready(val scene: ThreeJsAdoptedScene) : SceneState class Failed(val message: String) : SceneState } @@ -104,7 +100,7 @@ private fun ThreeTextureAdoptionDemo() { ) } else { try { - val scene = ThreeAdoptedScene.createOrNull(canvas) + val scene = ThreeJsAdoptedScene.createOrNull(canvas) if (scene != null) { SceneState.Ready(scene) } else { @@ -143,31 +139,31 @@ private fun Centered(message: String) { } @Composable -private fun ThreeSceneContent(scene: ThreeAdoptedScene, directContext: () -> DirectContext?) { +private fun ThreeSceneContent(threeJsScene: ThreeJsAdoptedScene, directContext: () -> DirectContext?) { var running by remember { mutableStateOf(true) } - var spin by remember { mutableStateOf(scene.spin) } - var hue by remember { mutableStateOf(scene.hue) } - var roughness by remember { mutableStateOf(scene.roughness) } - var metalness by remember { mutableStateOf(scene.metalness) } - var lightIntensity by remember { mutableStateOf(scene.lightIntensity) } - var textureSide by remember { mutableStateOf(1024f) } + var spin by remember { mutableStateOf(threeJsScene.spin) } + var hue by remember { mutableStateOf(threeJsScene.hue) } + var roughness by remember { mutableStateOf(threeJsScene.roughness) } + var metalness by remember { mutableStateOf(threeJsScene.metalness) } + var lightIntensity by remember { mutableStateOf(threeJsScene.lightIntensity) } + var textureSide by remember { mutableStateOf(512f) } // Read only from draw scopes, so that a new frame invalidates the drawing and not the whole UI. val frame = remember { mutableStateOf(ThreeFrame(0, 0f)) } // Read from composition, and therefore refreshed a few times per second instead of every frame. var stats by remember { mutableStateOf(ThreeFrame(0, 0f)) } - scene.spin = spin - scene.hue = hue - scene.roughness = roughness - scene.metalness = metalness - scene.lightIntensity = lightIntensity - scene.textureSize = IntSize(textureSide.roundToInt(), (textureSide * 0.625f).roundToInt()) + threeJsScene.spin = spin + threeJsScene.hue = hue + threeJsScene.roughness = roughness + threeJsScene.metalness = metalness + threeJsScene.lightIntensity = lightIntensity + threeJsScene.textureSize = IntSize(textureSide.roundToInt(), (textureSide * 0.625f).roundToInt()) // withFrameNanos callbacks run inside the frame, before Compose measures, lays out and draws — so // this is where three.js belongs: the texture holds this frame's content by the time Skia submits // the frame that samples it. Drawing sites below only draw the resulting image. - LaunchedEffect(running, scene) { + LaunchedEffect(running, threeJsScene) { if (!running) return@LaunchedEffect var previousNanos = 0L while (true) { @@ -186,7 +182,7 @@ private fun ThreeSceneContent(scene: ThreeAdoptedScene, directContext: () -> Dir ) val context = directContext() if (context != null) { - scene.renderFrame(context, deltaSeconds) + threeJsScene.renderFrame(context, deltaSeconds) } frame.value = next @@ -200,8 +196,7 @@ private fun ThreeSceneContent(scene: ThreeAdoptedScene, directContext: () -> Dir verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Hero(scene, frame) - Variants(scene, frame) + AdoptedImageRender({ threeJsScene.image }, frame) Card(Modifier.fillMaxWidth()) { Column( modifier = Modifier.padding(16.dp), @@ -215,7 +210,7 @@ private fun ThreeSceneContent(scene: ThreeAdoptedScene, directContext: () -> Dir LabelledSlider( label = "texture width", value = textureSide, - valueRange = 256f..2048f, + valueRange = 16f..2048f, onValueChange = { textureSide = it }, valueText = "${textureSide.roundToInt()} px", ) @@ -228,9 +223,9 @@ private fun ThreeSceneContent(scene: ThreeAdoptedScene, directContext: () -> Dir verticalArrangement = Arrangement.spacedBy(4.dp), ) { StatusLine("skia context", directContext()?.toString() ?: "not captured yet") - StatusLine("state", scene.status) - StatusLine("adopted texture id", scene.adoptedTextureId.toString()) - StatusLine("textures handed to Skia", scene.adoptedTextureCount.toString()) + StatusLine("state", threeJsScene.status) + StatusLine("adopted texture id", threeJsScene.adoptedTextureId.toString()) + StatusLine("textures handed to Skia", threeJsScene.adoptedTextureCount.toString()) StatusLine("frame", "${stats.index} · ${stats.fps.roundToInt()} fps") } } @@ -239,14 +234,14 @@ private fun ThreeSceneContent(scene: ThreeAdoptedScene, directContext: () -> Dir /** The three.js output as the hero: tilted in 3D by dragging, clipped, with Compose content on top. */ @Composable -private fun Hero(scene: ThreeAdoptedScene, frame: State) { +private fun AdoptedImageRender(imageProvider: () -> org.jetbrains.skia.Image?, frame: State) { var tiltX by remember { mutableStateOf(0f) } var tiltY by remember { mutableStateOf(0f) } Box( modifier = Modifier .fillMaxWidth() - .aspectRatio(1.6f) + .aspectRatio(1.3f) .pointerInput(Unit) { detectDragGestures { _, dragAmount -> tiltY = (tiltY + dragAmount.x * 0.15f).coerceIn(-35f, 35f) @@ -259,12 +254,17 @@ private fun Hero(scene: ThreeAdoptedScene, frame: State) { cameraDistance = 16f * density } .clip(RoundedCornerShape(28.dp)) - // A gradient underneath proves the texture arrives with a real alpha channel: three.js - // clears it to transparent, so this shows through everywhere the knot is not. .background(Brush.linearGradient(listOf(Color(0xFF0E1B33), Color(0xFF3A1250)))), contentAlignment = Alignment.BottomStart, ) { - AdoptedTextureSurface(Modifier.fillMaxSize(), frame) { scene.image } + Box(modifier = Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) { + Text( + "Text rendered by Compose", + color = Color.White.copy(alpha = 0.5f), + style = MaterialTheme.typography.h2 + ) + } + AdoptedTextureSurface(Modifier.fillMaxSize(), frame, imageProvider) Column(Modifier.padding(20.dp)) { Text( "three.js below, Compose above", @@ -278,28 +278,33 @@ private fun Hero(scene: ThreeAdoptedScene, frame: State) { ) } } + + Variants(imageProvider, frame) } /** The same adopted texture, reused several times in one frame with different Compose treatments. */ @Composable -private fun Variants(scene: ThreeAdoptedScene, frame: State) { +private fun Variants(imageProvider: () -> org.jetbrains.skia.Image?, frame: State) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.SpaceEvenly ) { - AdoptedTextureSurface(Modifier.size(96.dp).clip(CircleShape), frame) { scene.image } + AdoptedTextureSurface(Modifier.size(96.dp).clip(CircleShape).background(Color.LightGray), frame, imageProvider) AdoptedTextureSurface( Modifier.size(96.dp) - .clip(RoundedCornerShape(16.dp)) + .clip(RoundedCornerShape(32.dp)) + .background(Color.DarkGray) .graphicsLayer { - rotationZ = 12f + rotationX = 45f alpha = 0.75f }, frame, - ) { scene.image } + imageProvider, + ) AdoptedTextureSurface( - Modifier.size(96.dp).clip(RoundedCornerShape(16.dp)).blur(6.dp), + Modifier.size(96.dp).clip(RoundedCornerShape(8.dp)).background(Color.Gray).blur(2.dp), frame, - ) { scene.image } + imageProvider + ) } } diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/WebGlTextureRegistration.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/WebGlTextureRegistration.web.kt index b66a9adac1474..fb2b22e1598f5 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/WebGlTextureRegistration.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/WebGlTextureRegistration.web.kt @@ -14,12 +14,6 @@ * limitations under the License. */ -// Skiko ships `pushTexture`/`unregisterTexture` in its Kotlin/Wasm source set only, while the -// Emscripten `GL` handle those functions need is declared in Skiko's shared web source set (as an -// internal API, hence the suppression). Re-implementing the two helpers here on top of that handle -// is what lets this demo live in `webMain` and run on both Kotlin/JS and Kotlin/Wasm. -// TODO: delete this file and use org.jetbrains.skiko.pushTexture/unregisterTexture once Skiko -// exposes them for both web targets. @file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") @file:OptIn(ExperimentalWasmJsInterop::class) @@ -30,32 +24,12 @@ import kotlin.js.JsAny import org.jetbrains.skiko.GL import org.jetbrains.skiko.GLInterface -/** - * Registers an externally-created `WebGLTexture` in Emscripten's GL texture table. - * - * The returned id can be passed to Skia GL APIs that expect a numeric texture id, such as - * [org.jetbrains.skia.BackendTexture.makeGL]. The texture must belong to the same WebGL context - * that Skiko is using. - * - * This function only creates the Emscripten table entry. If the returned id is passed to a Skia API - * that takes ownership of the texture, Skia will delete the GL texture through Emscripten and the - * table entry will be cleared there. If ownership is not transferred to Skia, call - * [unregisterTexture] when the id is no longer needed to avoid leaking the table entry. - */ -internal fun pushTexture(texture: JsAny): Int = pushTexture(GL, texture) +// TODO: delete this file when we have these declaration in Skiko webMain. +// see https://github.com/JetBrains/skiko/pull/1270 -/** - * Removes a texture table entry previously created with [pushTexture]. - * - * This does not delete the underlying `WebGLTexture`; it only releases Skiko/Emscripten's numeric id - * mapping. Use it only when the id was not handed to a Skia API that takes ownership of the texture. - */ +internal fun pushTexture(texture: JsAny): Int = pushTexture(GL, texture) internal fun unregisterTexture(textureId: Int): Unit = unregisterTexture(GL, textureId) -/** - * `GL.textures` is the array Emscripten's GL layer indexes with the ids Skia's GL backend speaks, - * and `getNewId` is how Emscripten itself allocates a free slot in it. - */ // language=js private fun pushTexture(gl: GLInterface, texture: JsAny): Int = js( """(function() { From 96b32bb5b3bb2c003a19505ad1f0469c61b94031 Mon Sep 17 00:00:00 2001 From: "Oleksandr.Karpovich" Date: Tue, 18 Aug 2026 15:07:11 +0200 Subject: [PATCH 03/26] add opacity --- .../mpp/demo/webgl/ThreeAdoptedScene.web.kt | 14 ++++++----- .../mpp/demo/webgl/ThreeJsInterop.web.kt | 16 ++++++++----- .../webgl/ThreeTextureAdoptionDemo.web.kt | 24 +++++++++++++------ 3 files changed, 35 insertions(+), 19 deletions(-) diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeAdoptedScene.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeAdoptedScene.web.kt index a694c093be902..926adb00664d9 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeAdoptedScene.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeAdoptedScene.web.kt @@ -51,11 +51,12 @@ internal class ThreeJsAdoptedScene private constructor( } } - var spin: Float = 1f - var hue: Float = 0.55f - var roughness: Float = 0.28f - var metalness: Float = 0.62f - var lightIntensity: Float = 3.4f + var spin: Float = 2f + var hue: Float = 0.85f + var roughness: Float = 0.3f + var metalness: Float = 0.6f + var opacity: Float = 0.8f + var lightIntensity: Float = 3f var textureSize: IntSize = IntSize(1024, 640) var status: String = "waiting for the first frame" private set @@ -135,7 +136,8 @@ internal class ThreeJsAdoptedScene private constructor( knotScene.knot.rotation.y = angle.toDouble() knotScene.material.roughness = roughness.toDouble() knotScene.material.metalness = metalness.toDouble() - knotScene.material.color.setHSL(hue.toDouble(), 0.72, 0.6) + knotScene.material.opacity = opacity.toDouble() + knotScene.material.color.setHSL(hue.toDouble(), 0.75, 0.6) knotScene.keyLight.intensity = lightIntensity.toDouble() // Skia rendered the previous frame through this very context, so everything three.js diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsInterop.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsInterop.web.kt index 5046a5d670742..a09a2bebdfe99 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsInterop.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsInterop.web.kt @@ -101,6 +101,7 @@ internal external interface ThreeStandardMaterial : JsAny { val color: ThreeColor var roughness: Double var metalness: Double + var opacity: Double } internal external interface ThreeLight : ThreeObject3D { @@ -187,22 +188,25 @@ internal fun createKnotScene(three: ThreeModule): ThreeKnotScene = js( const material = new three.MeshStandardMaterial({ color: 0x66d9ff, - roughness: 0.28, - metalness: 0.62, + roughness: 0.3, + metalness: 0.6, + transparent: true, + opacity: 0.8, + side: three.DoubleSide, }); - const geometry = new three.TorusKnotGeometry(0.85, 0.28, 220, 32, 2, 3); + const geometry = new three.TorusKnotGeometry(0.85, 0.3, 220, 32, 2, 3); const knot = new three.Mesh(geometry, material); scene.add(knot); - const keyLight = new three.DirectionalLight(0xffffff, 3.4); + const keyLight = new three.DirectionalLight(0xffffff, 3); keyLight.position.set(2.5, 3.0, 4.0); scene.add(keyLight); - const rimLight = new three.DirectionalLight(0xff5fa2, 2.2); + const rimLight = new three.DirectionalLight(0xff5fa2, 2); rimLight.position.set(-3.0, -1.5, -2.0); scene.add(rimLight); - scene.add(new three.AmbientLight(0x223355, 1.4)); + scene.add(new three.AmbientLight(0x223355, 1)); return { scene: scene, diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt index c7fd3afcd16af..d12b6af0d982f 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt @@ -53,6 +53,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer @@ -145,6 +146,7 @@ private fun ThreeSceneContent(threeJsScene: ThreeJsAdoptedScene, directContext: var hue by remember { mutableStateOf(threeJsScene.hue) } var roughness by remember { mutableStateOf(threeJsScene.roughness) } var metalness by remember { mutableStateOf(threeJsScene.metalness) } + var opacity by remember { mutableStateOf(threeJsScene.opacity) } var lightIntensity by remember { mutableStateOf(threeJsScene.lightIntensity) } var textureSide by remember { mutableStateOf(512f) } @@ -157,6 +159,7 @@ private fun ThreeSceneContent(threeJsScene: ThreeJsAdoptedScene, directContext: threeJsScene.hue = hue threeJsScene.roughness = roughness threeJsScene.metalness = metalness + threeJsScene.opacity = opacity threeJsScene.lightIntensity = lightIntensity threeJsScene.textureSize = IntSize(textureSide.roundToInt(), (textureSide * 0.625f).roundToInt()) @@ -203,10 +206,11 @@ private fun ThreeSceneContent(threeJsScene: ThreeJsAdoptedScene, directContext: verticalArrangement = Arrangement.spacedBy(4.dp), ) { LabelledSlider("spin", spin, 0f..3f) { spin = it } - LabelledSlider("material hue", hue, 0f..1f) { hue = it } + LabelledSlider("color", hue, 0f..1f) { hue = it } LabelledSlider("roughness", roughness, 0f..1f) { roughness = it } LabelledSlider("metalness", metalness, 0f..1f) { metalness = it } - LabelledSlider("key light", lightIntensity, 0f..8f) { lightIntensity = it } + LabelledSlider("opacity", opacity, 0.05f..1f) { opacity = it } + LabelledSlider("light", lightIntensity, 0f..8f) { lightIntensity = it } LabelledSlider( label = "texture width", value = textureSide, @@ -259,9 +263,9 @@ private fun AdoptedImageRender(imageProvider: () -> org.jetbrains.skia.Image?, f ) { Box(modifier = Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) { Text( - "Text rendered by Compose", + "This is Compose Text", color = Color.White.copy(alpha = 0.5f), - style = MaterialTheme.typography.h2 + style = MaterialTheme.typography.h3 ) } AdoptedTextureSurface(Modifier.fillMaxSize(), frame, imageProvider) @@ -282,7 +286,7 @@ private fun AdoptedImageRender(imageProvider: () -> org.jetbrains.skia.Image?, f Variants(imageProvider, frame) } -/** The same adopted texture, reused several times in one frame with different Compose treatments. */ +/** The same adopted texture, reused several times in one frame with different transformations. */ @Composable private fun Variants(imageProvider: () -> org.jetbrains.skia.Image?, frame: State) { Row( @@ -295,14 +299,20 @@ private fun Variants(imageProvider: () -> org.jetbrains.skia.Image?, frame: Stat .clip(RoundedCornerShape(32.dp)) .background(Color.DarkGray) .graphicsLayer { - rotationX = 45f alpha = 0.75f + scaleX = -0.75f + scaleY = 0.75f + }, frame, imageProvider, ) AdoptedTextureSurface( - Modifier.size(96.dp).clip(RoundedCornerShape(8.dp)).background(Color.Gray).blur(2.dp), + Modifier.size(96.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color.Gray) + .blur(2.dp) + .scale(1f, -1f), frame, imageProvider ) From 8ac712647bef4de53aa3fc4d83ef958ad580abea Mon Sep 17 00:00:00 2001 From: "Oleksandr.Karpovich" Date: Tue, 18 Aug 2026 15:34:26 +0200 Subject: [PATCH 04/26] refactoring --- .../mpp/demo/webgl/AdoptedGlTexture.web.kt | 44 ++++++----------- .../mpp/demo/webgl/AdoptedTextureUi.web.kt | 8 ++-- .../mpp/demo/webgl/ThreeAdoptedScene.web.kt | 47 +++++++++++++------ .../webgl/ThreeTextureAdoptionDemo.web.kt | 10 ++-- 4 files changed, 57 insertions(+), 52 deletions(-) diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/AdoptedGlTexture.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/AdoptedGlTexture.web.kt index 73e6c4d06589b..02c6818996c52 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/AdoptedGlTexture.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/AdoptedGlTexture.web.kt @@ -40,14 +40,16 @@ import org.khronos.webgl.WebGLRenderingContext.Companion.UNSIGNED_BYTE import org.khronos.webgl.WebGLTexture import org.w3c.dom.HTMLCanvasElement -/** `GL_RGBA8`, the sized format Skia expects for an `RGBA` / `UNSIGNED_BYTE` texture. */ +// See https://registry.khronos.org/OpenGL/api/GL/glcorearb.h +// #define GL_RGBA8 0x8058 internal const val GL_RGBA8 = 0x8058 /** - * A WebGL texture that belongs to Skia now. + * A helper wrapper for values associated with the WebGL texture. * - * [texture] is kept only so that it can be re-attached to a framebuffer; it must not be deleted, and - * [textureId] must not be unregistered. Closing [image] does both. + * @param texture - the WebGL texture in the same WebGL context as Skiko + * @param textureId - the id that Emscripten associates with the [texture] + * @param image - Skiko Image which "adopted" the [texture] */ internal class AdoptedGlTexture( val texture: WebGLTexture, @@ -56,12 +58,9 @@ internal class AdoptedGlTexture( ) /** - * Allocates an `RGBA8` texture of [size], publishes it in Emscripten's texture table and hands it to - * Skia. Once [Image.adoptTextureFrom] returns, the GL texture belongs to [context]. - * - * This is the whole trick behind both texture adoption demos: whoever renders into - * [AdoptedGlTexture.texture] afterwards — hand-written shaders or a third-party engine — is drawing - * straight into an image Skia can sample, with no pixel copies in between. + * @param context - the rendering context of Skiko canvas + * @param size - the size of the texture + * @return - a wrapper [AdoptedGlTexture] */ internal fun WebGLRenderingContext.adoptNewTexture( context: DirectContext, @@ -70,8 +69,6 @@ internal fun WebGLRenderingContext.adoptNewTexture( val texture = createTexture() ?: error("gl.createTexture() returned null") bindTexture(TEXTURE_2D, texture) texImage2D(TEXTURE_2D, 0, RGBA, size.width, size.height, 0, RGBA, UNSIGNED_BYTE, null) - // No mipmaps and plain LINEAR filtering keep Skia on the "just sample the texture" path, so that - // re-rendering into the texture shows up immediately instead of serving a cached copy. texParameteri(TEXTURE_2D, TEXTURE_MIN_FILTER, LINEAR) texParameteri(TEXTURE_2D, TEXTURE_MAG_FILTER, LINEAR) texParameteri(TEXTURE_2D, TEXTURE_WRAP_S, CLAMP_TO_EDGE) @@ -81,19 +78,14 @@ internal fun WebGLRenderingContext.adoptNewTexture( val textureId = pushTexture(texture) var ownershipTransferred = false try { - // The descriptor is closed as soon as the image exists; the texture it described is Skia's - // from that point on. val image = BackendTexture.makeGL( - size.width, - size.height, - /* isMipmapped = */ false, - textureId, - /* textureTarget = */ TEXTURE_2D, - /* textureFormat = */ GL_RGBA8, + width = size.width, + height = size.height, + isMipmapped = false, + textureId = textureId, + textureTarget = TEXTURE_2D, + textureFormat = GL_RGBA8, ).use { backendTexture -> - // BOTTOM_LEFT because the scene is rendered into a framebuffer, and PREMUL because the - // producers write premultiplied colors. Together they are what makes the texture blend - // correctly with the Compose content behind and in front of it. Image.adoptTextureFrom( context, backendTexture, @@ -106,17 +98,11 @@ internal fun WebGLRenderingContext.adoptNewTexture( return AdoptedGlTexture(texture, textureId, image) } finally { if (!ownershipTransferred) { - // Skia never took the texture, so both the table entry and the texture are ours. unregisterTexture(textureId) deleteTexture(texture) } } } -/** - * Skiko already created a `"webgl2"` context for this canvas, and a canvas never hands out a second - * context — so this returns the exact context Skia renders with. WebGL has no share groups, which - * makes this the only context whose textures Skia is allowed to touch. - */ internal fun webGl2ContextOf(canvas: HTMLCanvasElement): WebGLRenderingContext? = js("canvas.getContext('webgl2')") diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/AdoptedTextureUi.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/AdoptedTextureUi.web.kt index 81765a407d893..3f045561aea76 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/AdoptedTextureUi.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/AdoptedTextureUi.web.kt @@ -77,16 +77,16 @@ internal fun AdoptedTextureSurface( @Composable internal fun LabelledSlider( label: String, - value: Float, + value: Double, valueRange: ClosedFloatingPointRange, valueText: String = ((value * 100).roundToInt() / 100f).toString(), - onValueChange: (Float) -> Unit, + onValueChange: (Double) -> Unit, ) { Row(verticalAlignment = Alignment.CenterVertically) { Text(label, Modifier.width(110.dp), style = MaterialTheme.typography.body2) Slider( - value = value, - onValueChange = onValueChange, + value = value.toFloat(), + onValueChange = { onValueChange(it.toDouble()) }, valueRange = valueRange, modifier = Modifier.weight(1f), ) diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeAdoptedScene.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeAdoptedScene.web.kt index 926adb00664d9..d7273d99d900b 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeAdoptedScene.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeAdoptedScene.web.kt @@ -51,12 +51,12 @@ internal class ThreeJsAdoptedScene private constructor( } } - var spin: Float = 2f - var hue: Float = 0.85f - var roughness: Float = 0.3f - var metalness: Float = 0.6f - var opacity: Float = 0.8f - var lightIntensity: Float = 3f + var hue: Double = 0.85 + var spin: Double = 2.0 + var roughness: Double = 0.3 + var metalness: Double = 0.6 + var opacity: Double = 0.8 + var lightIntensity: Double = 3.0 var textureSize: IntSize = IntSize(1024, 640) var status: String = "waiting for the first frame" private set @@ -77,7 +77,7 @@ internal class ThreeJsAdoptedScene private constructor( */ private var retiredImage: Image? = null private var adoptedSize = IntSize.Zero - private var angle = 0f + private var angle = 0.0 private var failed = false /** The adopted texture to draw, or `null` until the first frame has been rendered. */ @@ -132,13 +132,15 @@ internal class ThreeJsAdoptedScene private constructor( val renderTarget = renderTarget ?: error("the render target was not created") angle += deltaSeconds * spin - knotScene.knot.rotation.x = (angle * 0.6f).toDouble() - knotScene.knot.rotation.y = angle.toDouble() - knotScene.material.roughness = roughness.toDouble() - knotScene.material.metalness = metalness.toDouble() - knotScene.material.opacity = opacity.toDouble() - knotScene.material.color.setHSL(hue.toDouble(), 0.75, 0.6) - knotScene.keyLight.intensity = lightIntensity.toDouble() + + knotScene.updateValues( + angle = angle, + roughness = roughness, + metalness = metalness, + opacity = opacity, + lightIntensity = lightIntensity, + hue = hue + ) // Skia rendered the previous frame through this very context, so everything three.js // believes about the GL state is stale. @@ -161,6 +163,23 @@ internal class ThreeJsAdoptedScene private constructor( } } + private fun ThreeKnotScene.updateValues( + angle: Double, + roughness: Double, + metalness: Double, + opacity: Double, + lightIntensity: Double, + hue: Double, + ) { + knot.rotation.x = (angle * 0.6f) + knot.rotation.y = angle + material.roughness = roughness + material.metalness = metalness + material.opacity = opacity + material.color.setHSL(hue, 0.75, 0.6) + keyLight.intensity = lightIntensity + } + /** * [context] is only used to let Skia recover from the GL work done here, since three's own * disposal touches the shared context as well. diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt index d12b6af0d982f..eeaeaf3103cea 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt @@ -148,7 +148,7 @@ private fun ThreeSceneContent(threeJsScene: ThreeJsAdoptedScene, directContext: var metalness by remember { mutableStateOf(threeJsScene.metalness) } var opacity by remember { mutableStateOf(threeJsScene.opacity) } var lightIntensity by remember { mutableStateOf(threeJsScene.lightIntensity) } - var textureSide by remember { mutableStateOf(512f) } + var textureSize by remember { mutableStateOf(512) } // Read only from draw scopes, so that a new frame invalidates the drawing and not the whole UI. val frame = remember { mutableStateOf(ThreeFrame(0, 0f)) } @@ -161,7 +161,7 @@ private fun ThreeSceneContent(threeJsScene: ThreeJsAdoptedScene, directContext: threeJsScene.metalness = metalness threeJsScene.opacity = opacity threeJsScene.lightIntensity = lightIntensity - threeJsScene.textureSize = IntSize(textureSide.roundToInt(), (textureSide * 0.625f).roundToInt()) + threeJsScene.textureSize = IntSize(textureSize, (textureSize * 0.625f).roundToInt()) // withFrameNanos callbacks run inside the frame, before Compose measures, lays out and draws — so // this is where three.js belongs: the texture holds this frame's content by the time Skia submits @@ -213,10 +213,10 @@ private fun ThreeSceneContent(threeJsScene: ThreeJsAdoptedScene, directContext: LabelledSlider("light", lightIntensity, 0f..8f) { lightIntensity = it } LabelledSlider( label = "texture width", - value = textureSide, + value = textureSize.toDouble(), valueRange = 16f..2048f, - onValueChange = { textureSide = it }, - valueText = "${textureSide.roundToInt()} px", + onValueChange = { textureSize = it.roundToInt() }, + valueText = "${textureSize} px", ) Toggle("animate", running) { running = it } } From 1a877f8e3ab38579c2953cf16629f84367e3cdaa Mon Sep 17 00:00:00 2001 From: "Oleksandr.Karpovich" Date: Tue, 18 Aug 2026 16:16:23 +0200 Subject: [PATCH 05/26] refactoring --- .../mpp/demo/webgl/ThreeAdoptedScene.web.kt | 176 +++++++++--------- .../webgl/ThreeTextureAdoptionDemo.web.kt | 2 +- 2 files changed, 92 insertions(+), 86 deletions(-) diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeAdoptedScene.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeAdoptedScene.web.kt index d7273d99d900b..c280ed4a649fb 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeAdoptedScene.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeAdoptedScene.web.kt @@ -37,7 +37,7 @@ import org.w3c.dom.HTMLCanvasElement internal class ThreeJsAdoptedScene private constructor( private val gl: WebGLRenderingContext, - private val three: ThreeModule, + private val threeJsModule: ThreeModule, private val canvas: HTMLCanvasElement, ) { companion object { @@ -51,6 +51,10 @@ internal class ThreeJsAdoptedScene private constructor( } } + // angle is the main dynaminc state in this demo, it's updated every frame + private var knotAngle = 0.0 + + // other knot properties var hue: Double = 0.85 var spin: Double = 2.0 var roughness: Double = 0.3 @@ -65,27 +69,22 @@ internal class ThreeJsAdoptedScene private constructor( var adoptedTextureCount: Int = 0 private set - private var threeObjects: ThreeObjects? = null + private var threeJsObjects: ThreeJsObjects? = null private var framebuffer: WebGLFramebuffer? = null - private var depthBuffer: WebGLRenderbuffer? = null + private var renderedBuffer: WebGLRenderbuffer? = null private var renderTarget: ThreeRenderTarget? = null private var target: AdoptedGlTexture? = null - - /** - * The previous frame's image, closed one frame late because a display list recorded during the - * previous frame may still reference it. - */ - private var retiredImage: Image? = null private var adoptedSize = IntSize.Zero - private var angle = 0.0 private var failed = false - /** The adopted texture to draw, or `null` until the first frame has been rendered. */ - val image: Image? get() = target?.image /** - * Renders one frame with three.js into the adopted texture. Call once per frame from - * `withFrameNanos`, passing the context Compose renders with. + * Skiko Image which adopted the WebGL texture + */ + val imageToRender: Image? get() = target?.image + + /** + * Renders one frame with three.js into the adopted texture. Call once per frame. */ fun renderFrame(context: DirectContext, deltaSeconds: Float) { if (failed) return @@ -96,45 +95,12 @@ internal class ThreeJsAdoptedScene private constructor( ) try { - // Constructing the renderer queries capabilities and touches GL state, so it happens here - // rather than at load time: this method always ends with DirectContext.resetAll(), which is - // what lets Skia recover from any state three.js changed. - val (renderer, knotScene) = threeObjects - ?: ThreeObjects( - renderer = createThreeRenderer(three, canvas, gl), - knotScene = createKnotScene(three), - ).also { threeObjects = it } - - val framebuffer = framebuffer - ?: (gl.createFramebuffer() ?: error("gl.createFramebuffer() returned null")) - .also { this.framebuffer = it } - - retiredImage?.close() - retiredImage = null - - var current = target - if (current == null || adoptedSize != size) { - val previous = current - current = gl.adoptNewTexture(context, size) - retiredImage = previous?.image - adoptedSize = size - adoptedTextureId = current.textureId - adoptedTextureCount++ - attachToFramebuffer(framebuffer, current.texture, size) - // The render target carries the viewport three.js renders with, and it is never - // resized in place (that would make three dispose of our framebuffer), so a new - // texture size means a new descriptor. - renderTarget = createRenderTarget(three, size.width, size.height) - knotScene.camera.aspect = size.width.toDouble() / size.height.toDouble() - knotScene.camera.updateProjectionMatrix() - } - target = current - val renderTarget = renderTarget ?: error("the render target was not created") - - angle += deltaSeconds * spin + val (renderer, knotScene) = ensureThreeJsObjects() + ensureAdoptedTexture(context, size, knotScene) + knotAngle += deltaSeconds * spin knotScene.updateValues( - angle = angle, + angle = knotAngle, roughness = roughness, metalness = metalness, opacity = opacity, @@ -142,18 +108,7 @@ internal class ThreeJsAdoptedScene private constructor( hue = hue ) - // Skia rendered the previous frame through this very context, so everything three.js - // believes about the GL state is stale. - renderer.resetState() - // Our framebuffer, with the texture Skia adopted attached to it. - renderer.setRenderTargetFramebuffer(renderTarget, framebuffer) - renderer.setRenderTarget(renderTarget) - renderer.render(knotScene.scene, knotScene.camera) - // Hand the default framebuffer — the one Skia renders Compose into — back. - renderer.setRenderTarget(null) - gl.bindFramebuffer(FRAMEBUFFER, null) - - // And now the mirror image of resetState(): everything Skia cached is stale too. + renderThreeFrame(renderer, knotScene, framebuffer = ensureFramebuffer()) context.resetAll() status = "three.js renders into one adopted ${size.width}×${size.height} texture" @@ -180,63 +135,114 @@ internal class ThreeJsAdoptedScene private constructor( keyLight.intensity = lightIntensity } + private fun ensureThreeJsObjects(): ThreeJsObjects = threeJsObjects + ?: ThreeJsObjects( + renderer = createThreeRenderer(threeJsModule, canvas, gl), + knotScene = createKnotScene(threeJsModule), + ).also { threeJsObjects = it } + + /** + * Returns the adopted texture to draw, + * creating a new one if there is none yet or the texture size changed. + */ + private fun ensureAdoptedTexture( + context: DirectContext, + size: IntSize, + knotScene: ThreeKnotScene, + ): AdoptedGlTexture { + val current = target + if (current != null && adoptedSize == size) return current + + current?.image?.close() // after resize + + val adopted = gl.adoptNewTexture(context, size) + target = adopted + adoptedSize = size + adoptedTextureId = adopted.textureId + adoptedTextureCount++ + + adopted.texture.attachToFramebuffer(ensureFramebuffer(), size) + renderTarget = createRenderTarget(threeJsModule, size.width, size.height) + knotScene.camera.aspect = size.width.toDouble() / size.height.toDouble() + knotScene.camera.updateProjectionMatrix() + return adopted + } + + /** + * Renders one three.js frame into the adopted texture. + */ + private fun renderThreeFrame( + renderer: ThreeRenderer, + knotScene: ThreeKnotScene, + framebuffer: WebGLFramebuffer, + ) { + val renderTarget = renderTarget ?: error("the render target was not created") + // Skia rendered the previous frame through this very context, so everything three.js + // believes about the GL state is stale. + renderer.resetState() + // Our framebuffer, with the texture Skia adopted attached to it. + renderer.setRenderTargetFramebuffer(renderTarget, framebuffer) + renderer.setRenderTarget(renderTarget) + renderer.render(knotScene.scene, knotScene.camera) + // Hand the default framebuffer — the one Skia renders Compose into — back. + renderer.setRenderTarget(null) + gl.bindFramebuffer(FRAMEBUFFER, null) + } + + private fun ensureFramebuffer(): WebGLFramebuffer = framebuffer + ?: (gl.createFramebuffer() ?: error("gl.createFramebuffer() returned null")) + .also { this.framebuffer = it } + /** * [context] is only used to let Skia recover from the GL work done here, since three's own * disposal touches the shared context as well. */ fun dispose(context: DirectContext?) { - retiredImage?.close() - retiredImage = null target?.image?.close() target = null adoptedSize = IntSize.Zero adoptedTextureId = -1 - // Only a descriptor pointing at our framebuffer: disposing it would make three.js delete a - // framebuffer it never created, so it is simply dropped. renderTarget = null - threeObjects?.let { (renderer, knotScene) -> + threeJsObjects?.let { (renderer, knotScene) -> disposeKnotScene(knotScene) renderer.dispose() } - threeObjects = null + threeJsObjects = null framebuffer?.let { gl.deleteFramebuffer(it) } framebuffer = null - depthBuffer?.let { gl.deleteRenderbuffer(it) } - depthBuffer = null + renderedBuffer?.let { gl.deleteRenderbuffer(it) } + renderedBuffer = null gl.bindFramebuffer(FRAMEBUFFER, null) context?.resetAll() } - /** - * Attaches the adopted texture as color attachment 0, plus a depth buffer sized to match, and - * verifies that three.js will be able to render into the result. - */ - private fun attachToFramebuffer( + private fun ensureRendererBuffer(): WebGLRenderbuffer { + return renderedBuffer ?: (gl.createRenderbuffer() ?: error("gl.createRenderbuffer() returned null")) + .also { this.renderedBuffer = it } + } + + private fun WebGLTexture.attachToFramebuffer( framebuffer: WebGLFramebuffer, - texture: WebGLTexture, size: IntSize, ) { - val depthBuffer = depthBuffer - ?: (gl.createRenderbuffer() ?: error("gl.createRenderbuffer() returned null")) - .also { this.depthBuffer = it } - - gl.bindRenderbuffer(RENDERBUFFER, depthBuffer) + val renderbuffer = ensureRendererBuffer() + gl.bindRenderbuffer(RENDERBUFFER, renderbuffer) gl.renderbufferStorage(RENDERBUFFER, DEPTH_COMPONENT16, size.width, size.height) gl.bindRenderbuffer(RENDERBUFFER, null) gl.bindFramebuffer(FRAMEBUFFER, framebuffer) - gl.framebufferTexture2D(FRAMEBUFFER, COLOR_ATTACHMENT0, TEXTURE_2D, texture, 0) - gl.framebufferRenderbuffer(FRAMEBUFFER, DEPTH_ATTACHMENT, RENDERBUFFER, depthBuffer) + gl.framebufferTexture2D(FRAMEBUFFER, COLOR_ATTACHMENT0, TEXTURE_2D, this, 0) + gl.framebufferRenderbuffer(FRAMEBUFFER, DEPTH_ATTACHMENT, RENDERBUFFER, renderbuffer) check(gl.checkFramebufferStatus(FRAMEBUFFER) == FRAMEBUFFER_COMPLETE) { "the adopted texture is not a complete framebuffer attachment" } gl.bindFramebuffer(FRAMEBUFFER, null) } - private data class ThreeObjects( + private data class ThreeJsObjects( val renderer: ThreeRenderer, val knotScene: ThreeKnotScene, ) diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt index eeaeaf3103cea..e7f045fd71c95 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt @@ -199,7 +199,7 @@ private fun ThreeSceneContent(threeJsScene: ThreeJsAdoptedScene, directContext: verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - AdoptedImageRender({ threeJsScene.image }, frame) + AdoptedImageRender({ threeJsScene.imageToRender }, frame) Card(Modifier.fillMaxWidth()) { Column( modifier = Modifier.padding(16.dp), From 4e6467e7a0cf334bd531f81d41a8d6abe459d967 Mon Sep 17 00:00:00 2001 From: "Oleksandr.Karpovich" Date: Tue, 18 Aug 2026 16:19:39 +0200 Subject: [PATCH 06/26] refactoring --- .../compose/ui/window/ComposeWindowInternal.web.kt | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt index 82c29fbce387e..e3d665eb30cec 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt @@ -392,21 +392,11 @@ internal class ComposeWindow( get() = configuration.isClearFocusOnMouseDownEnabled } - /** - * The canvas Compose renders into. Asking it for a `"webgl2"` context returns the very context - * Skiko renders with, which is the only context whose textures Skia is able to use. - */ internal val htmlCanvas: HTMLCanvasElement get() = canvas /** - * Skia's GPU context, captured from the surface canvas on the first rendered frame, or `null` - * before that. - * - * It is only reachable here: the canvas passed to a composable's draw is usually a graphics - * layer's display-list recorder, whose `recordingContext` is legitimately `null`. Code that - * needs the context (for instance to adopt an externally created WebGL texture into a Skia - * image) has to read it from here instead. The context lives as long as the canvas, so it is - * safe to hold on to; it must not be closed by the reader. + * Skia's GPU context, captured from the surface canvas on the first rendered frame; + * It's used by WebGL texture adoption demo. */ internal var skiaDirectContext: DirectContext? = null private set From 0bf76312537f67ee951d9d8af5d75cb4e5bb776d Mon Sep 17 00:00:00 2001 From: "Oleksandr.Karpovich" Date: Tue, 18 Aug 2026 20:52:45 +0200 Subject: [PATCH 07/26] extract api --- ...edTextureUi.web.kt => DemoControls.web.kt} | 55 +--- .../mpp/demo/webgl/ThreeAdoptedScene.web.kt | 249 --------------- .../mpp/demo/webgl/ThreeJsKnotRenderer.web.kt | 133 ++++++++ .../webgl/ThreeTextureAdoptionDemo.web.kt | 281 ++++++++--------- .../webgl/WebGlTextureRegistration.web.kt | 44 --- .../graphics/webgl/AdoptedGLTexture.web.kt} | 74 +++-- .../ui/graphics/webgl/WebGLRenderScope.web.kt | 90 ++++++ .../ui/graphics/webgl/WebGLTextureDraw.web.kt | 116 +++++++ .../graphics/webgl/WebGLTextureSurface.web.kt | 294 ++++++++++++++++++ .../ui/window/ComposeWindowInternal.web.kt | 3 +- 10 files changed, 829 insertions(+), 510 deletions(-) rename compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/{AdoptedTextureUi.web.kt => DemoControls.web.kt} (54%) delete mode 100644 compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeAdoptedScene.web.kt create mode 100644 compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt delete mode 100644 compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/WebGlTextureRegistration.web.kt rename compose/{mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/AdoptedGlTexture.web.kt => ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/AdoptedGLTexture.web.kt} (57%) create mode 100644 compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLRenderScope.web.kt create mode 100644 compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLTextureDraw.web.kt create mode 100644 compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLTextureSurface.web.kt diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/AdoptedTextureUi.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/DemoControls.web.kt similarity index 54% rename from compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/AdoptedTextureUi.web.kt rename to compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/DemoControls.web.kt index 3f045561aea76..09252ecf7f8ab 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/AdoptedTextureUi.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/DemoControls.web.kt @@ -16,7 +16,6 @@ package androidx.compose.mpp.demo.webgl -import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -26,53 +25,11 @@ import androidx.compose.material.Slider import androidx.compose.material.Switch import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.State import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.drawscope.drawIntoCanvas -import androidx.compose.ui.graphics.skiaCanvas import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp -import kotlin.math.min import kotlin.math.roundToInt -import org.jetbrains.skia.Image -import org.jetbrains.skia.Rect - -/** - * Draws an adopted [Image] — nothing else. All GL work already happened in this frame's - * `withFrameNanos` callback, which is what lets this composable live inside graphics layers (`clip`, - * `blur`, `graphicsLayer`): the draw is merely recorded here and replayed into the GPU surface later, - * which is fine for an image that already belongs to Skia's context. - * - * [invalidation] is read inside the draw scope, which is what schedules the next redraw without - * recomposing anything. - */ -@Composable -internal fun AdoptedTextureSurface( - modifier: Modifier, - invalidation: State, - image: () -> Image?, -) { - Canvas(modifier) { - drawIntoCanvas { canvas -> - invalidation.value - val skiaCanvas = canvas.skiaCanvas - val adopted = image() ?: return@drawIntoCanvas - - // Center-crop so that square tiles do not squash a wide texture. - val scale = min(adopted.width / size.width, adopted.height / size.height) - val cropWidth = size.width * scale - val cropHeight = size.height * scale - val source = Rect.makeXYWH( - (adopted.width - cropWidth) / 2f, - (adopted.height - cropHeight) / 2f, - cropWidth, - cropHeight, - ) - skiaCanvas.drawImageRect(adopted, source, Rect.makeWH(size.width, size.height)) - } - } -} @Composable internal fun LabelledSlider( @@ -90,11 +47,7 @@ internal fun LabelledSlider( valueRange = valueRange, modifier = Modifier.weight(1f), ) - Text( - valueText, - Modifier.padding(start = 12.dp), - style = MaterialTheme.typography.caption, - ) + Text(valueText, Modifier.padding(start = 12.dp), style = MaterialTheme.typography.caption) } } @@ -110,10 +63,6 @@ internal fun Toggle(label: String, checked: Boolean, onCheckedChange: (Boolean) internal fun StatusLine(label: String, value: String) { Row(Modifier.fillMaxWidth()) { Text(label, Modifier.width(170.dp), style = MaterialTheme.typography.caption) - Text( - value, - style = MaterialTheme.typography.caption, - fontFamily = FontFamily.Monospace, - ) + Text(value, style = MaterialTheme.typography.caption, fontFamily = FontFamily.Monospace) } } diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeAdoptedScene.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeAdoptedScene.web.kt deleted file mode 100644 index c280ed4a649fb..0000000000000 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeAdoptedScene.web.kt +++ /dev/null @@ -1,249 +0,0 @@ -/* - * Copyright 2026 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@file:OptIn(ExperimentalWasmJsInterop::class) - -package androidx.compose.mpp.demo.webgl - -import androidx.compose.ui.unit.IntSize -import kotlin.js.ExperimentalWasmJsInterop -import org.jetbrains.skia.DirectContext -import org.jetbrains.skia.Image -import org.khronos.webgl.WebGLFramebuffer -import org.khronos.webgl.WebGLRenderbuffer -import org.khronos.webgl.WebGLRenderingContext -import org.khronos.webgl.WebGLRenderingContext.Companion.COLOR_ATTACHMENT0 -import org.khronos.webgl.WebGLRenderingContext.Companion.DEPTH_ATTACHMENT -import org.khronos.webgl.WebGLRenderingContext.Companion.DEPTH_COMPONENT16 -import org.khronos.webgl.WebGLRenderingContext.Companion.FRAMEBUFFER -import org.khronos.webgl.WebGLRenderingContext.Companion.FRAMEBUFFER_COMPLETE -import org.khronos.webgl.WebGLRenderingContext.Companion.RENDERBUFFER -import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_2D -import org.khronos.webgl.WebGLTexture -import org.w3c.dom.HTMLCanvasElement - -internal class ThreeJsAdoptedScene private constructor( - private val gl: WebGLRenderingContext, - private val threeJsModule: ThreeModule, - private val canvas: HTMLCanvasElement, -) { - companion object { - /** - * Loads three.js and binds it to the WebGL2 context managed by Skiko - */ - suspend fun createOrNull(canvas: HTMLCanvasElement): ThreeJsAdoptedScene? { - val gl = webGl2ContextOf(canvas) ?: return null - val three = loadThreeModule() ?: return null - return ThreeJsAdoptedScene(gl, three, canvas) - } - } - - // angle is the main dynaminc state in this demo, it's updated every frame - private var knotAngle = 0.0 - - // other knot properties - var hue: Double = 0.85 - var spin: Double = 2.0 - var roughness: Double = 0.3 - var metalness: Double = 0.6 - var opacity: Double = 0.8 - var lightIntensity: Double = 3.0 - var textureSize: IntSize = IntSize(1024, 640) - var status: String = "waiting for the first frame" - private set - var adoptedTextureId: Int = -1 - private set - var adoptedTextureCount: Int = 0 - private set - - private var threeJsObjects: ThreeJsObjects? = null - private var framebuffer: WebGLFramebuffer? = null - private var renderedBuffer: WebGLRenderbuffer? = null - private var renderTarget: ThreeRenderTarget? = null - private var target: AdoptedGlTexture? = null - private var adoptedSize = IntSize.Zero - private var failed = false - - - /** - * Skiko Image which adopted the WebGL texture - */ - val imageToRender: Image? get() = target?.image - - /** - * Renders one frame with three.js into the adopted texture. Call once per frame. - */ - fun renderFrame(context: DirectContext, deltaSeconds: Float) { - if (failed) return - - val size = IntSize( - width = textureSize.width.coerceIn(16, 4096), - height = textureSize.height.coerceIn(16, 4096), - ) - - try { - val (renderer, knotScene) = ensureThreeJsObjects() - - ensureAdoptedTexture(context, size, knotScene) - knotAngle += deltaSeconds * spin - knotScene.updateValues( - angle = knotAngle, - roughness = roughness, - metalness = metalness, - opacity = opacity, - lightIntensity = lightIntensity, - hue = hue - ) - - renderThreeFrame(renderer, knotScene, framebuffer = ensureFramebuffer()) - context.resetAll() - - status = "three.js renders into one adopted ${size.width}×${size.height} texture" - } catch (throwable: Throwable) { - failed = true - status = "failed: ${throwable.message}" - } - } - - private fun ThreeKnotScene.updateValues( - angle: Double, - roughness: Double, - metalness: Double, - opacity: Double, - lightIntensity: Double, - hue: Double, - ) { - knot.rotation.x = (angle * 0.6f) - knot.rotation.y = angle - material.roughness = roughness - material.metalness = metalness - material.opacity = opacity - material.color.setHSL(hue, 0.75, 0.6) - keyLight.intensity = lightIntensity - } - - private fun ensureThreeJsObjects(): ThreeJsObjects = threeJsObjects - ?: ThreeJsObjects( - renderer = createThreeRenderer(threeJsModule, canvas, gl), - knotScene = createKnotScene(threeJsModule), - ).also { threeJsObjects = it } - - /** - * Returns the adopted texture to draw, - * creating a new one if there is none yet or the texture size changed. - */ - private fun ensureAdoptedTexture( - context: DirectContext, - size: IntSize, - knotScene: ThreeKnotScene, - ): AdoptedGlTexture { - val current = target - if (current != null && adoptedSize == size) return current - - current?.image?.close() // after resize - - val adopted = gl.adoptNewTexture(context, size) - target = adopted - adoptedSize = size - adoptedTextureId = adopted.textureId - adoptedTextureCount++ - - adopted.texture.attachToFramebuffer(ensureFramebuffer(), size) - renderTarget = createRenderTarget(threeJsModule, size.width, size.height) - knotScene.camera.aspect = size.width.toDouble() / size.height.toDouble() - knotScene.camera.updateProjectionMatrix() - return adopted - } - - /** - * Renders one three.js frame into the adopted texture. - */ - private fun renderThreeFrame( - renderer: ThreeRenderer, - knotScene: ThreeKnotScene, - framebuffer: WebGLFramebuffer, - ) { - val renderTarget = renderTarget ?: error("the render target was not created") - // Skia rendered the previous frame through this very context, so everything three.js - // believes about the GL state is stale. - renderer.resetState() - // Our framebuffer, with the texture Skia adopted attached to it. - renderer.setRenderTargetFramebuffer(renderTarget, framebuffer) - renderer.setRenderTarget(renderTarget) - renderer.render(knotScene.scene, knotScene.camera) - // Hand the default framebuffer — the one Skia renders Compose into — back. - renderer.setRenderTarget(null) - gl.bindFramebuffer(FRAMEBUFFER, null) - } - - private fun ensureFramebuffer(): WebGLFramebuffer = framebuffer - ?: (gl.createFramebuffer() ?: error("gl.createFramebuffer() returned null")) - .also { this.framebuffer = it } - - /** - * [context] is only used to let Skia recover from the GL work done here, since three's own - * disposal touches the shared context as well. - */ - fun dispose(context: DirectContext?) { - target?.image?.close() - target = null - adoptedSize = IntSize.Zero - adoptedTextureId = -1 - renderTarget = null - - threeJsObjects?.let { (renderer, knotScene) -> - disposeKnotScene(knotScene) - renderer.dispose() - } - threeJsObjects = null - - framebuffer?.let { gl.deleteFramebuffer(it) } - framebuffer = null - renderedBuffer?.let { gl.deleteRenderbuffer(it) } - renderedBuffer = null - - gl.bindFramebuffer(FRAMEBUFFER, null) - context?.resetAll() - } - - private fun ensureRendererBuffer(): WebGLRenderbuffer { - return renderedBuffer ?: (gl.createRenderbuffer() ?: error("gl.createRenderbuffer() returned null")) - .also { this.renderedBuffer = it } - } - - private fun WebGLTexture.attachToFramebuffer( - framebuffer: WebGLFramebuffer, - size: IntSize, - ) { - val renderbuffer = ensureRendererBuffer() - gl.bindRenderbuffer(RENDERBUFFER, renderbuffer) - gl.renderbufferStorage(RENDERBUFFER, DEPTH_COMPONENT16, size.width, size.height) - gl.bindRenderbuffer(RENDERBUFFER, null) - - gl.bindFramebuffer(FRAMEBUFFER, framebuffer) - gl.framebufferTexture2D(FRAMEBUFFER, COLOR_ATTACHMENT0, TEXTURE_2D, this, 0) - gl.framebufferRenderbuffer(FRAMEBUFFER, DEPTH_ATTACHMENT, RENDERBUFFER, renderbuffer) - check(gl.checkFramebufferStatus(FRAMEBUFFER) == FRAMEBUFFER_COMPLETE) { - "the adopted texture is not a complete framebuffer attachment" - } - gl.bindFramebuffer(FRAMEBUFFER, null) - } - - private data class ThreeJsObjects( - val renderer: ThreeRenderer, - val knotScene: ThreeKnotScene, - ) -} diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt new file mode 100644 index 0000000000000..86732eb6d3c1a --- /dev/null +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt @@ -0,0 +1,133 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalComposeUiApi::class) + +package androidx.compose.mpp.demo.webgl + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.graphics.webgl.WebGLRenderScope +import androidx.compose.ui.graphics.webgl.WebGLTextureSurface + +/** + * A three.js renderer of a lit torus knot, rendering into whatever framebuffer Compose hands it. + * + * Everything here is three.js-specific; nothing here knows about Skia, textures or Compose drawing: + * that is what [WebGLTextureSurface] takes care of. + */ +internal class ThreeJsKnotRenderer private constructor(private val three: ThreeModule) { + companion object { + /** Loads three.js, or returns `null` when the module is unavailable. */ + suspend fun createOrNull(): ThreeJsKnotRenderer? = + loadThreeModule()?.let(::ThreeJsKnotRenderer) + } + + // The angle is the main dynamic state in this demo, it's updated every frame. + private var knotAngle = 0.0 + + // Other knot properties. + var hue: Double = 0.85 + var spin: Double = 2.0 + var roughness: Double = 0.3 + var metalness: Double = 0.6 + var opacity: Double = 0.8 + var lightIntensity: Double = 3.0 + + var status: String = "waiting for the first frame" + private set + + private var renderer: ThreeRenderer? = null + private var knotScene: ThreeKnotScene? = null + private var renderTarget: ThreeRenderTarget? = null + private var targetGeneration = 0 + private var failed = false + + fun renderFrame(scope: WebGLRenderScope): Unit = + with(scope) { + if (failed) return + try { + val renderer = ensureRenderer() + val knotScene = ensureKnotScene() + val renderTarget = ensureRenderTarget(renderer, knotScene) + + knotAngle += deltaNanos / 1_000_000_000.0 * spin + knotScene.updateValues() + + // Skia rendered the previous frame through this very context, so everything + // three.js + // believes about the GL state is stale. + renderer.resetState() + // Our framebuffer, with the texture Skia adopted attached to it. + renderer.setRenderTargetFramebuffer(renderTarget, framebuffer) + renderer.setRenderTarget(renderTarget) + renderer.render(knotScene.scene, knotScene.camera) + // Hand the default framebuffer — the one Skia renders Compose into — back. + renderer.setRenderTarget(null) + + status = "three.js renders into one adopted ${size.width}×${size.height} texture" + } catch (throwable: Throwable) { + failed = true + status = "failed: ${throwable.message}" + } + } + + private fun WebGLRenderScope.ensureRenderer(): ThreeRenderer = + renderer ?: createThreeRenderer(three, htmlCanvas, webGLContext).also { renderer = it } + + private fun ensureKnotScene(): ThreeKnotScene = + knotScene ?: createKnotScene(three).also { knotScene = it } + + /** + * The render target is only a descriptor for the framebuffer Compose owns, so it has to be + * replaced whenever Compose recreated that framebuffer. + */ + private fun WebGLRenderScope.ensureRenderTarget( + renderer: ThreeRenderer, + knotScene: ThreeKnotScene, + ): ThreeRenderTarget { + val current = renderTarget + if (current != null && targetGeneration == generation) return current + + targetGeneration = generation + knotScene.camera.aspect = size.width.toDouble() / size.height.toDouble() + knotScene.camera.updateProjectionMatrix() + return createRenderTarget(three, size.width, size.height).also { renderTarget = it } + } + + private fun ThreeKnotScene.updateValues() { + knot.rotation.x = knotAngle * 0.6 + knot.rotation.y = knotAngle + material.roughness = roughness + material.metalness = metalness + material.opacity = opacity + material.color.setHSL(hue, 0.75, 0.6) + keyLight.intensity = lightIntensity + } + + /** + * Releases three's own GL objects. Since that touches the context Compose renders through, + * [WebGLTextureSurface.resetSkiaState] has to be called afterwards. + */ + fun dispose(surface: WebGLTextureSurface?) { + knotScene?.let(::disposeKnotScene) + knotScene = null + renderer?.dispose() + renderer = null + renderTarget = null + targetGeneration = 0 + surface?.resetSkiaState() + } +} diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt index e7f045fd71c95..906f1b56e3edb 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt @@ -14,12 +14,11 @@ * limitations under the License. */ -// Reaches for ComposeWindow to get the DirectContext Compose renders with; it is internal API, hence -// the suppression (the same trick the rest of this demo module uses). -@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") +@file:OptIn(ExperimentalComposeUiApi::class) package androidx.compose.mpp.demo.webgl +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.Arrangement @@ -43,13 +42,14 @@ import androidx.compose.mpp.demo.Screen import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip @@ -57,81 +57,70 @@ import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.webgl.WebGLTextureSurface +import androidx.compose.ui.graphics.webgl.drawWebGLTexture +import androidx.compose.ui.graphics.webgl.rememberWebGLTextureSurface import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.LocalComposeWindow import kotlin.math.roundToInt -import org.jetbrains.skia.DirectContext /** - * The texture adoption demo with the WebGL implementation delegated to three.js: - * it renders a lit torus knot into a texture, which is then adopted by Skiko and rendered as Skiko Image. + * A demo of [WebGLTextureSurface]: three.js renders a lit torus knot into a texture Compose owns + * and Skia adopted, and Compose then draws that texture like any other image. */ -val ThreeJsTextureAdoptionScreen = Screen.Example("WebGL texture adoption / Three.js integration") { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - ThreeTextureAdoptionDemo() +val ThreeJsTextureAdoptionScreen = + Screen.Example("WebGL texture adoption / Three.js integration") { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + ThreeTextureAdoptionDemo() + } } -} - -/** One tick of the demo clock. */ -private data class ThreeFrame(val index: Long, val fps: Float) - -private sealed interface SceneState { - object Loading : SceneState - - class Ready(val scene: ThreeJsAdoptedScene) : SceneState - - class Failed(val message: String) : SceneState -} @Composable private fun ThreeTextureAdoptionDemo() { - val composeWindow = LocalComposeWindow.current - var sceneState by remember { mutableStateOf(SceneState.Loading) } + var textureWidth by remember { mutableStateOf(512) } + val textureSize = IntSize(textureWidth, (textureWidth * 0.625f).roundToInt()) - // three.js arrives through a dynamic import, so the scene can only be built asynchronously. - LaunchedEffect(composeWindow) { - val canvas = composeWindow?.htmlCanvas - sceneState = if (canvas == null) { - SceneState.Failed( - "Could not obtain the WebGL context Compose renders with, so there is nothing to " + - "adopt a texture from." - ) - } else { - try { - val scene = ThreeJsAdoptedScene.createOrNull(canvas) - if (scene != null) { - SceneState.Ready(scene) - } else { - SceneState.Failed("three.js or the WebGL2 context Skiko uses is unavailable.") - } - } catch (throwable: Throwable) { - SceneState.Failed("Loading three.js failed: ${throwable.message}") - } - } + val surface = rememberWebGLTextureSurface(textureSize) + if (surface == null) { + Centered( + "Compose does not render through a WebGL2 canvas here, so there is no texture to adopt." + ) + return } - val state = sceneState - DisposableEffect(state) { - onDispose { - if (state is SceneState.Ready) { - state.scene.dispose(composeWindow?.skiaDirectContext) - } + // three.js arrives through a dynamic import, so the renderer can only be built asynchronously. + val loadState by produceState(LoadState.Loading) { + value = try { + ThreeJsKnotRenderer.createOrNull()?.let(LoadState::Ready) + ?: LoadState.Failed("three.js is unavailable.") + } catch (throwable: Throwable) { + LoadState.Failed("Loading three.js failed: ${throwable.message}") } } - when (state) { - is SceneState.Loading -> Centered("loading three.js…") - is SceneState.Failed -> Centered(state.message) - // skiaDirectContext is a plain field that stays null until Compose has rendered its first - // frame, so it is read through a lambda instead of being captured during composition. - is SceneState.Ready -> - ThreeSceneContent(state.scene) { composeWindow?.skiaDirectContext } + when (val state = loadState) { + LoadState.Loading -> Centered("loading three.js…") + is LoadState.Failed -> Centered(state.message) + is LoadState.Ready -> + ThreeSceneContent( + surface = surface, + threeJs = state.renderer, + textureWidth = textureWidth, + onTextureWidthChange = { textureWidth = it }, + ) } } +private sealed interface LoadState { + object Loading : LoadState + + class Ready(val renderer: ThreeJsKnotRenderer) : LoadState + + class Failed(val message: String) : LoadState +} + @Composable private fun Centered(message: String) { Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { @@ -140,55 +129,59 @@ private fun Centered(message: String) { } @Composable -private fun ThreeSceneContent(threeJsScene: ThreeJsAdoptedScene, directContext: () -> DirectContext?) { +private fun ThreeSceneContent( + surface: WebGLTextureSurface, + threeJs: ThreeJsKnotRenderer, + textureWidth: Int, + onTextureWidthChange: (Int) -> Unit, +) { var running by remember { mutableStateOf(true) } - var spin by remember { mutableStateOf(threeJsScene.spin) } - var hue by remember { mutableStateOf(threeJsScene.hue) } - var roughness by remember { mutableStateOf(threeJsScene.roughness) } - var metalness by remember { mutableStateOf(threeJsScene.metalness) } - var opacity by remember { mutableStateOf(threeJsScene.opacity) } - var lightIntensity by remember { mutableStateOf(threeJsScene.lightIntensity) } - var textureSize by remember { mutableStateOf(512) } + var spin by remember { mutableStateOf(threeJs.spin) } + var hue by remember { mutableStateOf(threeJs.hue) } + var roughness by remember { mutableStateOf(threeJs.roughness) } + var metalness by remember { mutableStateOf(threeJs.metalness) } + var opacity by remember { mutableStateOf(threeJs.opacity) } + var lightIntensity by remember { mutableStateOf(threeJs.lightIntensity) } - // Read only from draw scopes, so that a new frame invalidates the drawing and not the whole UI. - val frame = remember { mutableStateOf(ThreeFrame(0, 0f)) } - // Read from composition, and therefore refreshed a few times per second instead of every frame. - var stats by remember { mutableStateOf(ThreeFrame(0, 0f)) } + threeJs.spin = spin + threeJs.hue = hue + threeJs.roughness = roughness + threeJs.metalness = metalness + threeJs.opacity = opacity + threeJs.lightIntensity = lightIntensity - threeJsScene.spin = spin - threeJsScene.hue = hue - threeJsScene.roughness = roughness - threeJsScene.metalness = metalness - threeJsScene.opacity = opacity - threeJsScene.lightIntensity = lightIntensity - threeJsScene.textureSize = IntSize(textureSize, (textureSize * 0.625f).roundToInt()) + DisposableEffect(threeJs, surface) { onDispose { threeJs.dispose(surface) } } - // withFrameNanos callbacks run inside the frame, before Compose measures, lays out and draws — so - // this is where three.js belongs: the texture holds this frame's content by the time Skia submits - // the frame that samples it. Drawing sites below only draw the resulting image. - LaunchedEffect(running, threeJsScene) { + // Everything three.js does happens inside the frame, before Compose measures, lays out and + // draws, so the texture holds this frame's content by the time Skia submits the frame that + // samples it. The drawing sites below only draw the result. + LaunchedEffect(surface, threeJs, running) { + while (running) { + withFrameNanos { frameTimeNanos -> + surface.render(frameTimeNanos, { threeJs.renderFrame(this) }) + } + } + } + + // Read from composition, and therefore refreshed a few times per second instead of every frame. + var stats by remember { mutableStateOf(FrameStats(0, 0f)) } + LaunchedEffect(running) { if (!running) return@LaunchedEffect var previousNanos = 0L while (true) { withFrameNanos { nanos -> - val deltaSeconds = - if (previousNanos == 0L) 0f else (nanos - previousNanos) / 1_000_000_000f + val deltaSeconds = if (previousNanos == 0L) { + 0f + } else { + (nanos - previousNanos) / 1_000_000_000f + } previousNanos = nanos - val current = frame.value - val next = ThreeFrame( - index = current.index + 1, - fps = if (deltaSeconds > 0f) { - current.fps * 0.9f + (1f / deltaSeconds) * 0.1f - } else { - current.fps - }, + val next = FrameStats( + index = stats.index + 1, + fps = + if (deltaSeconds > 0f) stats.fps * 0.9f + (1f / deltaSeconds) * 0.1f + else stats.fps, ) - val context = directContext() - if (context != null) { - threeJsScene.renderFrame(context, deltaSeconds) - } - - frame.value = next if (next.index % 20 == 0L) stats = next } } @@ -199,7 +192,8 @@ private fun ThreeSceneContent(threeJsScene: ThreeJsAdoptedScene, directContext: verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - AdoptedImageRender({ threeJsScene.imageToRender }, frame) + Hero(surface) + Variants(surface) Card(Modifier.fillMaxWidth()) { Column( modifier = Modifier.padding(16.dp), @@ -213,10 +207,10 @@ private fun ThreeSceneContent(threeJsScene: ThreeJsAdoptedScene, directContext: LabelledSlider("light", lightIntensity, 0f..8f) { lightIntensity = it } LabelledSlider( label = "texture width", - value = textureSize.toDouble(), + value = textureWidth.toDouble(), valueRange = 16f..2048f, - onValueChange = { textureSize = it.roundToInt() }, - valueText = "${textureSize} px", + onValueChange = { onTextureWidthChange(it.roundToInt()) }, + valueText = "$textureWidth px", ) Toggle("animate", running) { running = it } } @@ -226,49 +220,52 @@ private fun ThreeSceneContent(threeJsScene: ThreeJsAdoptedScene, directContext: modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - StatusLine("skia context", directContext()?.toString() ?: "not captured yet") - StatusLine("state", threeJsScene.status) - StatusLine("adopted texture id", threeJsScene.adoptedTextureId.toString()) - StatusLine("textures handed to Skia", threeJsScene.adoptedTextureCount.toString()) + StatusLine("texture size", "${surface.size.width}×${surface.size.height}") + StatusLine("state", threeJs.status) StatusLine("frame", "${stats.index} · ${stats.fps.roundToInt()} fps") } } } } -/** The three.js output as the hero: tilted in 3D by dragging, clipped, with Compose content on top. */ +/** One tick of the demo clock, sampled a few times per second. */ +private data class FrameStats(val index: Long, val fps: Float) + +/** + * The three.js output as the hero: tilted in 3D by dragging, clipped, with Compose content on top. + */ @Composable -private fun AdoptedImageRender(imageProvider: () -> org.jetbrains.skia.Image?, frame: State) { +private fun Hero(surface: WebGLTextureSurface) { var tiltX by remember { mutableStateOf(0f) } var tiltY by remember { mutableStateOf(0f) } Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1.3f) - .pointerInput(Unit) { - detectDragGestures { _, dragAmount -> - tiltY = (tiltY + dragAmount.x * 0.15f).coerceIn(-35f, 35f) - tiltX = (tiltX - dragAmount.y * 0.15f).coerceIn(-35f, 35f) + modifier = + Modifier.fillMaxWidth() + .aspectRatio(1.3f) + .pointerInput(Unit) { + detectDragGestures { _, dragAmount -> + tiltY = (tiltY + dragAmount.x * 0.15f).coerceIn(-35f, 35f) + tiltX = (tiltX - dragAmount.y * 0.15f).coerceIn(-35f, 35f) + } } - } - .graphicsLayer { - rotationX = tiltX - rotationY = tiltY - cameraDistance = 16f * density - } - .clip(RoundedCornerShape(28.dp)) - .background(Brush.linearGradient(listOf(Color(0xFF0E1B33), Color(0xFF3A1250)))), + .graphicsLayer { + rotationX = tiltX + rotationY = tiltY + cameraDistance = 16f * density + } + .clip(RoundedCornerShape(28.dp)) + .background(Brush.linearGradient(listOf(Color(0xFF0E1B33), Color(0xFF3A1250)))), contentAlignment = Alignment.BottomStart, ) { Box(modifier = Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) { Text( "This is Compose Text", color = Color.White.copy(alpha = 0.5f), - style = MaterialTheme.typography.h3 + style = MaterialTheme.typography.h3, ) } - AdoptedTextureSurface(Modifier.fillMaxSize(), frame, imageProvider) + Canvas(Modifier.fillMaxSize()) { drawWebGLTexture(surface) } Column(Modifier.padding(20.dp)) { Text( "three.js below, Compose above", @@ -282,19 +279,16 @@ private fun AdoptedImageRender(imageProvider: () -> org.jetbrains.skia.Image?, f ) } } - - Variants(imageProvider, frame) } -/** The same adopted texture, reused several times in one frame with different transformations. */ +/** The same adopted texture, drawn several times in one frame with different transformations. */ @Composable -private fun Variants(imageProvider: () -> org.jetbrains.skia.Image?, frame: State) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly - ) { - AdoptedTextureSurface(Modifier.size(96.dp).clip(CircleShape).background(Color.LightGray), frame, imageProvider) - AdoptedTextureSurface( +private fun Variants(surface: WebGLTextureSurface) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { + Canvas(Modifier.size(96.dp).clip(CircleShape).background(Color.LightGray)) { + drawWebGLTexture(surface) + } + Canvas( Modifier.size(96.dp) .clip(RoundedCornerShape(32.dp)) .background(Color.DarkGray) @@ -302,19 +296,18 @@ private fun Variants(imageProvider: () -> org.jetbrains.skia.Image?, frame: Stat alpha = 0.75f scaleX = -0.75f scaleY = 0.75f - - }, - frame, - imageProvider, - ) - AdoptedTextureSurface( + } + ) { + drawWebGLTexture(surface) + } + Canvas( Modifier.size(96.dp) .clip(RoundedCornerShape(8.dp)) .background(Color.Gray) .blur(2.dp) - .scale(1f, -1f), - frame, - imageProvider - ) + .scale(1f, -1f) + ) { + drawWebGLTexture(surface) + } } } diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/WebGlTextureRegistration.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/WebGlTextureRegistration.web.kt deleted file mode 100644 index fb2b22e1598f5..0000000000000 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/WebGlTextureRegistration.web.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright 2026 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") -@file:OptIn(ExperimentalWasmJsInterop::class) - -package androidx.compose.mpp.demo.webgl - -import kotlin.js.ExperimentalWasmJsInterop -import kotlin.js.JsAny -import org.jetbrains.skiko.GL -import org.jetbrains.skiko.GLInterface - -// TODO: delete this file when we have these declaration in Skiko webMain. -// see https://github.com/JetBrains/skiko/pull/1270 - -internal fun pushTexture(texture: JsAny): Int = pushTexture(GL, texture) -internal fun unregisterTexture(textureId: Int): Unit = unregisterTexture(GL, textureId) - -// language=js -private fun pushTexture(gl: GLInterface, texture: JsAny): Int = js( - """(function() { - const textureHandle = gl.getNewId(gl.textures); - gl.textures[textureHandle] = texture; - return textureHandle; - })()""" -) - -// language=js -private fun unregisterTexture(gl: GLInterface, textureId: Int): Unit = - js("(gl.textures[textureId] = null)") diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/AdoptedGlTexture.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/AdoptedGLTexture.web.kt similarity index 57% rename from compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/AdoptedGlTexture.web.kt rename to compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/AdoptedGLTexture.web.kt index 02c6818996c52..b56b0f5dc0db5 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/AdoptedGlTexture.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/AdoptedGLTexture.web.kt @@ -16,10 +16,11 @@ @file:OptIn(ExperimentalWasmJsInterop::class) -package androidx.compose.mpp.demo.webgl +package androidx.compose.ui.graphics.webgl import androidx.compose.ui.unit.IntSize import kotlin.js.ExperimentalWasmJsInterop +import kotlin.js.JsAny import org.jetbrains.skia.BackendTexture import org.jetbrains.skia.ColorAlphaType import org.jetbrains.skia.ColorType @@ -42,30 +43,40 @@ import org.w3c.dom.HTMLCanvasElement // See https://registry.khronos.org/OpenGL/api/GL/glcorearb.h // #define GL_RGBA8 0x8058 -internal const val GL_RGBA8 = 0x8058 +private const val GL_RGBA8 = 0x8058 /** - * A helper wrapper for values associated with the WebGL texture. + * A WebGL texture that a Skia [Image] has adopted: the image samples the texture directly, so + * whatever is rendered into the texture is what Compose draws, with no copies in between. * - * @param texture - the WebGL texture in the same WebGL context as Skiko - * @param textureId - the id that Emscripten associates with the [texture] - * @param image - Skiko Image which "adopted" the [texture] + * @param texture the WebGL texture, living in the same WebGL context as Skia + * @param textureId the id Emscripten associates with [texture] + * @param image the Skia image which adopted [texture] */ -internal class AdoptedGlTexture( +internal class AdoptedGLTexture( val texture: WebGLTexture, val textureId: Int, val image: Image, -) + val size: IntSize, +) { + fun dispose() { + // Closing the image releases Skia's reference to the texture; Skia owns the texture since + // it adopted it, so it is not deleted here. + image.close() + unregisterTexture(textureId) + } +} /** - * @param context - the rendering context of Skiko canvas - * @param size - the size of the texture - * @return - a wrapper [AdoptedGlTexture] + * Allocates an RGBA8 texture in this context and hands it over to Skia. + * + * @param context the [DirectContext] Skia renders this canvas with + * @param size the size of the texture, in pixels */ internal fun WebGLRenderingContext.adoptNewTexture( context: DirectContext, size: IntSize, -): AdoptedGlTexture { +): AdoptedGLTexture { val texture = createTexture() ?: error("gl.createTexture() returned null") bindTexture(TEXTURE_2D, texture) texImage2D(TEXTURE_2D, 0, RGBA, size.width, size.height, 0, RGBA, UNSIGNED_BYTE, null) @@ -87,15 +98,15 @@ internal fun WebGLRenderingContext.adoptNewTexture( textureFormat = GL_RGBA8, ).use { backendTexture -> Image.adoptTextureFrom( - context, - backendTexture, - SurfaceOrigin.BOTTOM_LEFT, - ColorType.RGBA_8888, - ColorAlphaType.PREMUL, + context = context, + backendTexture = backendTexture, + origin = SurfaceOrigin.BOTTOM_LEFT, + colorType = ColorType.RGBA_8888, + alphaType = ColorAlphaType.PREMUL, ) } ownershipTransferred = true - return AdoptedGlTexture(texture, textureId, image) + return AdoptedGLTexture(texture, textureId, image, size) } finally { if (!ownershipTransferred) { unregisterTexture(textureId) @@ -104,5 +115,30 @@ internal fun WebGLRenderingContext.adoptNewTexture( } } -internal fun webGl2ContextOf(canvas: HTMLCanvasElement): WebGLRenderingContext? = +internal fun webGl2ContextOrNull(canvas: HTMLCanvasElement): WebGLRenderingContext? = js("canvas.getContext('webgl2')") + +@Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") +// TODO: delete the two helpers below once Skiko exposes them. +// See https://github.com/JetBrains/skiko/pull/1270 +private fun pushTexture(texture: JsAny): Int = pushTexture(org.jetbrains.skiko.GL, texture) + +@Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") +private fun unregisterTexture(textureId: Int): Unit = + unregisterTexture(org.jetbrains.skiko.GL, textureId) + +@Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") +// language=js +private fun pushTexture(gl: org.jetbrains.skiko.GLInterface, texture: JsAny): Int = + js( + """(function() { + const textureHandle = gl.getNewId(gl.textures); + gl.textures[textureHandle] = texture; + return textureHandle; + })()""" + ) + +@Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") +// language=js +private fun unregisterTexture(gl: org.jetbrains.skiko.GLInterface, textureId: Int): Unit = + js("(gl.textures[textureId] = null)") diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLRenderScope.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLRenderScope.web.kt new file mode 100644 index 0000000000000..a71560ecf1171 --- /dev/null +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLRenderScope.web.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.graphics.webgl + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.unit.IntSize +import org.khronos.webgl.WebGLFramebuffer +import org.khronos.webgl.WebGLRenderingContext +import org.w3c.dom.HTMLCanvasElement + +/** + * Everything a non-Compose renderer needs in order to render one frame into a + * [WebGLTextureSurface]. + * + * The scope is only valid inside the [WebGLTextureSurface.render] call that provided it: neither it + * nor any of its values may be retained, because the surface may replace the underlying GL objects + * between frames (see [generation]). + * + * Everything here is shared with Compose, so a renderer is expected to: + * - restore any global GL state it changes, or at the very least not rely on the state it left + * behind in the previous frame, because Skia rendered a frame through the same context in + * between; + * - never resize [htmlCanvas], change its pixel ratio or force a context loss on [webGLContext]; + * - never delete [framebuffer] or the texture attached to it. + * + * Compose restores the default framebuffer and lets Skia recover its own cached GL state after + * every [WebGLTextureSurface.render] call, so a renderer does not need to do that. + */ +@ExperimentalComposeUiApi +sealed interface WebGLRenderScope { + /** + * The WebGL2 context Compose renders through. + * + * Rendering through this very context is what makes the result usable by Compose without a + * copy: WebGL has no share groups, so a texture created in another context could never be read + * by Skia. + */ + val webGLContext: WebGLRenderingContext + + /** + * The `` element Compose renders into. Exposed because libraries commonly require a + * canvas alongside a context; its size and its context are owned by Compose. + */ + val htmlCanvas: HTMLCanvasElement + + /** + * The framebuffer the frame has to be rendered into. It is bound as [FRAMEBUFFER] when the + * render block is entered, and has a color texture and a depth-stencil attachment of [size]. + */ + val framebuffer: WebGLFramebuffer + + /** The size of [framebuffer], in pixels. */ + val size: IntSize + + /** + * The time of the frame being rendered, in nanoseconds, as reported by + * [androidx.compose.runtime.withFrameNanos]. + */ + val frameTimeNanos: Long + + /** + * The time elapsed since the previously rendered frame, in nanoseconds, or `0` for the first + * frame rendered into the surface. This is the value to advance animations by. + */ + val deltaNanos: Long + + /** + * Incremented every time the surface recreated [framebuffer] and its attachments, which happens + * on the first frame and whenever [WebGLTextureSurface.size] changed. + * + * Renderers that cache anything derived from the render target — a render target descriptor, a + * viewport, a projection matrix — should refresh it whenever this value differs from the one + * seen in the previous frame. + */ + val generation: Int +} diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLTextureDraw.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLTextureDraw.web.kt new file mode 100644 index 0000000000000..37b6710aba86f --- /dev/null +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLTextureDraw.web.kt @@ -0,0 +1,116 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.graphics.webgl + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.isSpecified +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.skiaCanvas +import androidx.compose.ui.layout.ContentScale +import org.jetbrains.skia.Rect + +/** + * Draws the latest frame rendered into [surface]. + * + * Nothing is rendered here: the GL work already happened in this frame's + * [androidx.compose.runtime.withFrameNanos] callback, so this only records a draw of an image that + * already belongs to Skia. That is what makes it safe inside graphics layers such as `clip`, `blur` + * or `graphicsLayer`, and what makes it possible to draw one surface several times per frame with + * different transformations. + * + * Reading [WebGLTextureSurface.invalidation] is part of the draw, so a newly rendered frame + * invalidates the drawing without recomposing anything. + * + * Draws nothing while [WebGLTextureSurface.image] is still `null`, i.e. before the first frame has + * been rendered. + * + * @param surface the surface to draw + * @param dstOffset the top-left corner of the destination, in local coordinates + * @param dstSize the size of the destination; defaults to the whole draw scope + * @param contentScale how the texture is fitted into the destination when their aspect ratios + * differ + */ +@ExperimentalComposeUiApi +fun DrawScope.drawWebGLTexture( + surface: WebGLTextureSurface, + dstOffset: Offset = Offset.Zero, + dstSize: Size = size, + contentScale: ContentScale = ContentScale.Crop, +) { + // Schedules the next redraw once this frame's content is rendered, without recomposing. + surface.invalidation.value + + val image = surface.image ?: return + if (!dstSize.isSpecified || dstSize.width <= 0f || dstSize.height <= 0f) return + + val srcSize = Size(image.width.toFloat(), image.height.toFloat()) + if (srcSize.width <= 0f || srcSize.height <= 0f) return + + val scale = contentScale.computeScaleFactor(srcSize, dstSize) + if (scale.scaleX <= 0f || scale.scaleY <= 0f) return + + // Per axis: when the scaled texture covers the destination, the source is cropped; when it does + // not, the destination is inset. This yields the expected result for Crop, Fit, FillBounds, + // Inside and None alike. + val (srcX, srcWidth, dstX, dstWidth) = + axis(srcSize.width, dstSize.width, scale.scaleX, dstOffset.x) + val (srcY, srcHeight, dstY, dstHeight) = + axis(srcSize.height, dstSize.height, scale.scaleY, dstOffset.y) + + drawIntoCanvas { canvas -> + canvas.skiaCanvas.drawImageRect( + image, + Rect.makeXYWH(srcX, srcY, srcWidth, srcHeight), + Rect.makeXYWH(dstX, dstY, dstWidth, dstHeight), + ) + } +} + +private data class AxisPlacement( + val src: Float, + val srcExtent: Float, + val dst: Float, + val dstExtent: Float, +) + +private fun axis( + srcExtent: Float, + dstExtent: Float, + scale: Float, + dstOrigin: Float, +): AxisPlacement { + val scaledExtent = srcExtent * scale + return if (scaledExtent >= dstExtent) { + val visibleSrcExtent = dstExtent / scale + AxisPlacement( + src = (srcExtent - visibleSrcExtent) / 2f, + srcExtent = visibleSrcExtent, + dst = dstOrigin, + dstExtent = dstExtent, + ) + } else { + AxisPlacement( + src = 0f, + srcExtent = srcExtent, + dst = dstOrigin + (dstExtent - scaledExtent) / 2f, + dstExtent = scaledExtent, + ) + } +} diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLTextureSurface.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLTextureSurface.web.kt new file mode 100644 index 0000000000000..2782b5cb177a1 --- /dev/null +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLTextureSurface.web.kt @@ -0,0 +1,294 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.graphics.webgl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LongState +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.window.LocalComposeWindow +import org.jetbrains.skia.DirectContext +import org.jetbrains.skia.Image +import org.khronos.webgl.WebGLFramebuffer +import org.khronos.webgl.WebGLRenderbuffer +import org.khronos.webgl.WebGLRenderingContext +import org.khronos.webgl.WebGLRenderingContext.Companion.COLOR_ATTACHMENT0 +import org.khronos.webgl.WebGLRenderingContext.Companion.FRAMEBUFFER +import org.khronos.webgl.WebGLRenderingContext.Companion.FRAMEBUFFER_COMPLETE +import org.khronos.webgl.WebGLRenderingContext.Companion.RENDERBUFFER +import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_2D +import org.w3c.dom.HTMLCanvasElement + +// WebGL2 only; see https://registry.khronos.org/OpenGL/api/GL/glcorearb.h +// #define GL_DEPTH24_STENCIL8 0x88F0 +// #define GL_DEPTH_STENCIL_ATTACHMENT 0x821A +private const val GL_DEPTH24_STENCIL8 = 0x88F0 +private const val GL_DEPTH_STENCIL_ATTACHMENT = 0x821A + +/** + * An offscreen GPU surface that non-Compose WebGL code can render into, and that Compose can draw + * without copying anything. + * + * It owns a texture allocated in the very same WebGL context Compose renders through, wrapped in a + * framebuffer with a depth-stencil attachment. Skia adopts that texture, which turns it into an + * [image] that can be drawn as many times as needed, anywhere in the composition, including inside + * graphics layers (`clip`, `blur`, `graphicsLayer`). + * + * Obtain an instance with [rememberWebGLTextureSurface], render into it with [renderFrames] (or + * [render]) and draw it with [androidx.compose.ui.graphics.webgl.drawWebGLTexture]: + * ``` + * val surface = rememberWebGLTextureSurface(IntSize(1024, 640)) ?: return + * + * LaunchedEffect(surface) { + * surface.renderFrames { + * // `this` is a WebGLRenderScope: gl, canvas, framebuffer, size, generation, timing. + * myRenderer.render(gl, framebuffer, size, generation, deltaNanos) + * } + * } + * + * Canvas(Modifier.fillMaxSize()) { drawWebGLTexture(surface) } + * ``` + * + * The texture is RGBA8 with premultiplied alpha, sampled with `LINEAR` filtering and + * `CLAMP_TO_EDGE` wrapping, without mipmaps and without multisampling. Clearing it to a transparent + * premultiplied color is what lets Compose content show through the drawn result. + * + * Instances are not thread-safe and are meant to be used from the frame loop only. + */ +@ExperimentalComposeUiApi +@Stable +class WebGLTextureSurface +internal constructor( + private val canvas: HTMLCanvasElement, + private val gl: WebGLRenderingContext, + private val directContext: () -> DirectContext?, + size: IntSize, +) { + /** + * The size of the color texture, in pixels, coerced to at least one pixel in each dimension. + * + * Changing it discards the current texture and [image] and allocates new ones on the next + * [render], which also bumps [WebGLRenderScope.generation]. Allocating a texture is not cheap, + * so avoid driving this from a value that changes every frame. + */ + var size: IntSize = size.coerceAtLeastOnePixel() + set(value) { + field = value.coerceAtLeastOnePixel() + } + + /** + * The image that samples the color texture, or `null` until the first [render] succeeded. + * + * The surface owns it: it must not be closed, and it must not be retained across frames, since + * a [size] change replaces it. + */ + val image: Image? + get() = adopted?.image + + private val _invalidation = mutableLongStateOf(0L) + + /** + * A counter incremented after every successful [render]. + * + * Read it from a draw scope rather than from composition, so that a rendered frame invalidates + * the drawing without recomposing anything. + * [androidx.compose.ui.graphics.webgl.drawWebGLTexture] already does that. + */ + val invalidation: LongState + get() = _invalidation + + private var adopted: AdoptedGLTexture? = null + private var framebuffer: WebGLFramebuffer? = null + private var depthStencil: WebGLRenderbuffer? = null + private var webGLRenderScope: WegGLRenderScopeImpl? = null + private var generation = 0 + private var isDisposed = false + private var isRendering = false + private var hasRenderedFrame = false + private var previousFrameTimeNanos = 0L + + /** + * Renders one frame of foreign WebGL content into this surface, then makes the result available + * through [image] and bumps [invalidation]. + * + * This must be called from a [withFrameNanos] callback, so that the texture already holds this + * frame's content by the time Skia submits the frame that samples it; [renderFrames] does that + * and is the recommended way to drive a surface. It must never be called from a draw or layout + * scope: bumping [invalidation] there would invalidate the very drawing that is in progress. + * + * The call allocates the texture and the framebuffer if needed, binds the framebuffer, invokes + * [block], and afterwards rebinds the default framebuffer and makes Skia drop the GL state it + * had cached before [block] ran. + * + * @param frameTimeNanos the time of the frame being rendered, as received from + * [withFrameNanos]. It is what [WebGLRenderScope.frameTimeNanos] and + * [WebGLRenderScope.deltaNanos] report to [block]. + * @return `false` when the surface could not be prepared, which happens while Compose has not + * rendered its first frame yet and therefore has no GPU context to share; [block] is not + * invoked in that case. + */ + fun render(frameTimeNanos: Long, block: WebGLRenderScope.() -> Unit): Boolean { + if (isDisposed) return false + check(!isRendering) { + "render() is already running: it must not be called from within another render() call, " + + "nor from a draw or layout scope" + } + val context = directContext() ?: return false + val scope = prepareWebGLRenderScope(context, size) + scope.frameTimeNanos = frameTimeNanos + scope.deltaNanos = if (hasRenderedFrame) frameTimeNanos - previousFrameTimeNanos else 0L + + isRendering = true + gl.bindFramebuffer(FRAMEBUFFER, scope.framebuffer) + try { + scope.block() + } finally { + isRendering = false + gl.bindFramebuffer(FRAMEBUFFER, null) + // Everything above went through the context Skia renders Compose with, so whatever Skia + // believes about the GL state is stale by now. + context.resetAll() + } + previousFrameTimeNanos = frameTimeNanos + hasRenderedFrame = true + _invalidation.value++ + return true + } + + /** + * Tells Compose that WebGL state was changed outside of [render], so that Skia drops the GL + * state it had cached. + * + * Needed for GL work that cannot happen inside [render], typically a library's own teardown: + * disposing programs and buffers touches the context Compose renders through as well. + * + * @return `false` when Compose has no GPU context to reset, in which case there is nothing to + * do. + */ + fun resetSkiaState(): Boolean { + val context = directContext() ?: return false + gl.bindFramebuffer(FRAMEBUFFER, null) + context.resetAll() + return true + } + + private fun prepareWebGLRenderScope( + context: DirectContext, + size: IntSize + ): WegGLRenderScopeImpl { + val current = adopted + if (current != null && current.size == size) return webGLRenderScope!! + + current?.dispose() + adopted = null + + val framebuffer = framebuffer ?: gl.createFramebuffer() ?: error("createFramebuffer failed") + this.framebuffer = framebuffer + val depthStencil = depthStencil ?: gl.createRenderbuffer() ?: error("createRenderbuffer failed") + this.depthStencil = depthStencil + + val adopted = gl.adoptNewTexture(context, size) + this.adopted = adopted + + gl.bindRenderbuffer(RENDERBUFFER, depthStencil) + gl.renderbufferStorage(RENDERBUFFER, GL_DEPTH24_STENCIL8, size.width, size.height) + gl.bindRenderbuffer(RENDERBUFFER, null) + + gl.bindFramebuffer(FRAMEBUFFER, framebuffer) + gl.framebufferTexture2D(FRAMEBUFFER, COLOR_ATTACHMENT0, TEXTURE_2D, adopted.texture, 0) + gl.framebufferRenderbuffer( + FRAMEBUFFER, + GL_DEPTH_STENCIL_ATTACHMENT, + RENDERBUFFER, + depthStencil, + ) + val status = gl.checkFramebufferStatus(FRAMEBUFFER) + gl.bindFramebuffer(FRAMEBUFFER, null) + check(status == FRAMEBUFFER_COMPLETE) { + "the adopted texture is not a complete framebuffer attachment (status $status)" + } + + webGLRenderScope = WegGLRenderScopeImpl(gl, canvas, framebuffer, size, ++generation) + return webGLRenderScope!! + } + + private class WegGLRenderScopeImpl( + override val webGLContext: WebGLRenderingContext, + override val htmlCanvas: HTMLCanvasElement, + override val framebuffer: WebGLFramebuffer, + override val size: IntSize, + override val generation: Int, + ) : WebGLRenderScope { + override var frameTimeNanos: Long = 0L + override var deltaNanos: Long = 0L + } + + /** + * Releases the texture, the image and the framebuffer. Called by [rememberWebGLTextureSurface] + * when the surface leaves the composition; calling it twice is a no-op. + */ + internal fun dispose() { + if (isDisposed) return + isDisposed = true + adopted?.dispose() + adopted = null + webGLRenderScope = null + framebuffer?.let(gl::deleteFramebuffer) + framebuffer = null + depthStencil?.let(gl::deleteRenderbuffer) + depthStencil = null + gl.bindFramebuffer(FRAMEBUFFER, null) + directContext()?.resetAll() + } +} + +/** + * Creates and remembers a [WebGLTextureSurface] of [size] pixels, disposing it when it leaves the + * composition. + * + * @return `null` when Compose does not render through a WebGL2 canvas, in which case there is no + * context to share and no texture to adopt. Callers are expected to render a fallback. + */ +@ExperimentalComposeUiApi +@Composable +fun rememberWebGLTextureSurface(size: IntSize): WebGLTextureSurface? { + val window = LocalComposeWindow.current ?: return null + val surface = + remember(window) { + val canvas = window.htmlCanvas + val gl = webGl2ContextOrNull(canvas) + if (gl == null) { + null + } else { + WebGLTextureSurface(canvas, gl, { window.skiaDirectContext }, size) + } + } ?: return null + + SideEffect(size) { surface.size = size } + DisposableEffect(surface) { onDispose { surface.dispose() } } + return surface +} + +private fun IntSize.coerceAtLeastOnePixel(): IntSize = + if (width >= 1 && height >= 1) this + else IntSize(width.coerceAtLeast(1), height.coerceAtLeast(1)) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt index e3d665eb30cec..376c67ffb3a24 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt @@ -205,8 +205,9 @@ internal class DefaultWindowState(private val viewportContainer: Element) : Comp @VisibleForTesting // This value is for internal usage, for example, to call ComposeWindow.dispose() in the tests +// `null` when Compose is not hosted by a ComposeWindow, e.g. in some tests. internal val LocalComposeWindow: ProvidableCompositionLocal = staticCompositionLocalOf { - error("ComposeWindow is not available in this composition") + null } @OptIn(InternalComposeApi::class) From d1ad3fe95a8e0aec2143a556422c05c3a65d446d Mon Sep 17 00:00:00 2001 From: "Oleksandr.Karpovich" Date: Tue, 18 Aug 2026 21:31:43 +0200 Subject: [PATCH 08/26] plain webgl demo --- .../androidx/compose/mpp/demo/Main.web.kt | 3 +- .../mpp/demo/webgl/PlainWebGlDemo.web.kt | 299 ++++++++++++++++++ .../webgl/ThreeTextureAdoptionDemo.web.kt | 2 +- .../graphics/webgl/WebGLTextureSurface.web.kt | 43 +-- 4 files changed, 326 insertions(+), 21 deletions(-) create mode 100644 compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/Main.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/Main.web.kt index 867ad5e90dacc..0a74d7f9f0197 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/Main.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/Main.web.kt @@ -19,6 +19,7 @@ package androidx.compose.mpp.demo import androidx.compose.mpp.demo.bugs.BugsScreen import androidx.compose.mpp.demo.components.text.loadResource import androidx.compose.mpp.demo.interops.HtmlInteropDemos +import androidx.compose.mpp.demo.webgl.PlainWebGlScreen import androidx.compose.mpp.demo.webgl.ThreeJsTextureAdoptionScreen import androidx.compose.runtime.LaunchedEffect import androidx.compose.mpp.demo.embedded.embeddedScrollDemo @@ -66,13 +67,13 @@ fun defaultComposeDemo() { val fontsLoaded = remember { mutableStateOf(false) } val app = remember { App( extraScreens = listOf( + Screen.Selection("WebGL", ThreeJsTextureAdoptionScreen, PlainWebGlScreen), BugsScreen, Screen.Example("Web Clipboard API example") { WebClipboardDemo() }, HtmlInteropDemos, HapticFeedbackExample, - ThreeJsTextureAdoptionScreen, ) ) } diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt new file mode 100644 index 0000000000000..a03e211f8ce5e --- /dev/null +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt @@ -0,0 +1,299 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalComposeUiApi::class) + +package androidx.compose.mpp.demo.webgl + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.mpp.demo.Screen +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.webgl.WebGLRenderScope +import androidx.compose.ui.graphics.webgl.WebGLTextureSurface +import androidx.compose.ui.graphics.webgl.drawWebGLTexture +import androidx.compose.ui.graphics.webgl.rememberWebGLTextureSurface +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import kotlin.math.sin +import org.khronos.webgl.WebGLProgram +import org.khronos.webgl.WebGLRenderingContext +import org.khronos.webgl.WebGLRenderingContext.Companion.BLEND +import org.khronos.webgl.WebGLRenderingContext.Companion.COLOR_BUFFER_BIT +import org.khronos.webgl.WebGLRenderingContext.Companion.DEPTH_TEST +import org.khronos.webgl.WebGLRenderingContext.Companion.FRAGMENT_SHADER +import org.khronos.webgl.WebGLRenderingContext.Companion.SCISSOR_TEST +import org.khronos.webgl.WebGLRenderingContext.Companion.TRIANGLES +import org.khronos.webgl.WebGLRenderingContext.Companion.VERTEX_SHADER +import org.khronos.webgl.WebGLUniformLocation + +/** + * Two independent [WebGLTextureSurface]s rendered with plain WebGL2 — no third-party library. + */ +val PlainWebGlScreen = Screen.Example("WebGL texture adoption / plain WebGL") { + PlainWebGlDemo() +} + +@Composable +private fun PlainWebGlDemo() { + var isAnimating by remember { mutableStateOf(true) } + + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) { + RotatingTriangle(isAnimating) + ColorPulse(isAnimating) + } + Toggle("animate", isAnimating) { isAnimating = it } + } +} + + +@Composable +private fun RotatingTriangle(isAnimating: Boolean) { + val surface = rememberWebGLTextureSurface(IntSize(512, 320))!! + val triangle = remember { TriangleRenderer() } + + DisposableEffect(triangle, surface) { + onDispose { triangle.dispose(surface) } + } + + LaunchedEffect(surface, isAnimating) { + while (isAnimating) { + withFrameNanos { frameTimeNanos -> + surface.render(frameTimeNanos) { triangle.render(this) } + } + } + } + + LabelledContent("512×320 texture\na shader-drawn triangle") { + Canvas( + modifier = Modifier.fillMaxSize(), + onDraw = { + drawWebGLTexture(surface) + } + ) + } +} + +@Composable +private fun ColorPulse(isAnimating: Boolean) { + val surface = rememberWebGLTextureSurface(IntSize(64, 64))!! + val pulse = remember { PulseRenderer() } + + LaunchedEffect(surface, isAnimating) { + while (isAnimating) { + withFrameNanos { frameTimeNanos -> + surface.render(frameTimeNanos) { pulse.render(this) } + } + } + } + + LabelledContent("64×64 texture\nnothing but a pulsing clear color") { + Canvas( + modifier = Modifier.fillMaxSize(), + onDraw = { + drawWebGLTexture(surface) + } + ) + } +} + +@Composable +private fun LabelledContent( + caption: String, + content: @Composable () -> Unit +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = + Modifier.size(200.dp) + .clip(RoundedCornerShape(16.dp)) + .background(Brush.linearGradient(listOf(Color(0xFF0E1B33), Color(0xFF3A1250)))), + contentAlignment = Alignment.Center, + ) { + Text( + "Compose", + color = Color.White.copy(alpha = 0.4f), + style = MaterialTheme.typography.h5, + ) + content() + } + Text( + caption, + modifier = Modifier.padding(top = 8.dp).width(200.dp), + style = MaterialTheme.typography.caption, + textAlign = TextAlign.Center, + ) + } +} + +/** + * Draws a spinning, vertex-colored triangle. The geometry lives in the vertex shader, so there is + * no buffer and no attribute to set up: the whole renderer is one program and two uniforms. + */ +private class TriangleRenderer { + /** Kept only so that [dispose] can release the program; the render path uses the scope's. */ + private var capturedGl: WebGLRenderingContext? = null + private var program: WebGLProgram? = null + private var angleUniform: WebGLUniformLocation? = null + private var aspectUniform: WebGLUniformLocation? = null + private var angle = 0f + + fun render(scope: WebGLRenderScope): Unit = + with(scope) { + val program = ensureProgram(webGLContext) + angle += deltaNanos / 1_000_000_000f * 0.9f + + // Skia rendered the previous frame through this very context and left its own state + // behind, so everything this frame depends on is set here, every frame. Leaving the + // scissor test alone in particular would let Skia's last scissor rect clip both the + // clear and the draw below. + webGLContext.viewport(0, 0, size.width, size.height) + webGLContext.disable(SCISSOR_TEST) + webGLContext.disable(DEPTH_TEST) + webGLContext.disable(BLEND) + + // Transparent premultiplied clear: the Compose content under the texture shows through. + webGLContext.clearColor(0f, 0f, 0f, 0f) + webGLContext.clear(COLOR_BUFFER_BIT) + + webGLContext.useProgram(program) + webGLContext.uniform1f(angleUniform, angle) + webGLContext.uniform1f(aspectUniform, size.width.toFloat() / size.height.toFloat()) + webGLContext.drawArrays(TRIANGLES, 0, 3) + } + + private fun ensureProgram(gl: WebGLRenderingContext): WebGLProgram { + program?.let { + return it + } + val created = gl.createProgram(VERTEX_SHADER_SOURCE, FRAGMENT_SHADER_SOURCE) + capturedGl = gl + program = created + angleUniform = gl.getUniformLocation(created, "angle") + aspectUniform = gl.getUniformLocation(created, "aspect") + return created + } + + /** Releases the program; [surface] is what lets Skia recover from that GL work. */ + fun dispose(surface: WebGLTextureSurface) { + val gl = capturedGl ?: return + program?.let(gl::deleteProgram) + program = null + capturedGl = null + surface.resetSkiaState() + } + + companion object { + private val VERTEX_SHADER_SOURCE = """ + #version 300 es + const vec2 positions[3] = vec2[3](vec2(0.0, 0.85), vec2(-0.75, -0.6), vec2(0.75, -0.6)); + const vec3 colors[3] = vec3[3]( + vec3(1.0, 0.35, 0.45), + vec3(0.3, 0.95, 0.6), + vec3(0.45, 0.55, 1.0) + ); + uniform float angle; + uniform float aspect; + out vec3 vertexColor; + void main() { + vec2 position = positions[gl_VertexID]; + float s = sin(angle); + float c = cos(angle); + vec2 rotated = vec2(position.x * c - position.y * s, position.x * s + position.y * c); + gl_Position = vec4(rotated.x / aspect, rotated.y, 0.0, 1.0); + vertexColor = colors[gl_VertexID]; + } + """.trimIndent() + + private val FRAGMENT_SHADER_SOURCE = """ + #version 300 es + precision mediump float; + in vec3 vertexColor; + out vec4 fragmentColor; + void main() { + fragmentColor = vec4(vertexColor, 1.0); + } + """.trimIndent() + + } +} + +private fun WebGLRenderingContext.createProgram( + vertexSource: String, + fragmentSource: String, +): WebGLProgram { + val program = createProgram() ?: error("gl.createProgram() returned null") + for ((type, source) in + listOf(VERTEX_SHADER to vertexSource, FRAGMENT_SHADER to fragmentSource)) { + val shader = createShader(type) ?: error("gl.createShader() returned null") + shaderSource(shader, source) + compileShader(shader) + val log = getShaderInfoLog(shader) + check(log.isNullOrBlank()) { "shader compilation reported: $log" } + attachShader(program, shader) + // The program keeps the shader alive until the program itself is deleted. + deleteShader(shader) + } + linkProgram(program) + val log = getProgramInfoLog(program) + check(log.isNullOrBlank()) { "program linking reported: $log" } + return program +} + + +/** Clears the texture to a pulsing color. No shaders, no resources, nothing to dispose. */ +private class PulseRenderer { + private var phase = 0f + + fun render(scope: WebGLRenderScope): Unit = + with(scope) { + 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) + } +} \ No newline at end of file diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt index 906f1b56e3edb..2db4f24da324d 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt @@ -71,7 +71,7 @@ import kotlin.math.roundToInt * and Skia adopted, and Compose then draws that texture like any other image. */ val ThreeJsTextureAdoptionScreen = - Screen.Example("WebGL texture adoption / Three.js integration") { + Screen.Example("Three.js integration") { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { ThreeTextureAdoptionDemo() } diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLTextureSurface.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLTextureSurface.web.kt index 2782b5cb177a1..5b330157ddc36 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLTextureSurface.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLTextureSurface.web.kt @@ -54,15 +54,20 @@ private const val GL_DEPTH_STENCIL_ATTACHMENT = 0x821A * [image] that can be drawn as many times as needed, anywhere in the composition, including inside * graphics layers (`clip`, `blur`, `graphicsLayer`). * - * Obtain an instance with [rememberWebGLTextureSurface], render into it with [renderFrames] (or - * [render]) and draw it with [androidx.compose.ui.graphics.webgl.drawWebGLTexture]: + * Obtain an instance with [rememberWebGLTextureSurface], render into it with [render] and draw it + * with [androidx.compose.ui.graphics.webgl.drawWebGLTexture]: * ``` * val surface = rememberWebGLTextureSurface(IntSize(1024, 640)) ?: return * * LaunchedEffect(surface) { - * surface.renderFrames { - * // `this` is a WebGLRenderScope: gl, canvas, framebuffer, size, generation, timing. - * myRenderer.render(gl, framebuffer, size, generation, deltaNanos) + * while (true) { + * withFrameNanos { frameTimeNanos -> + * surface.render(frameTimeNanos) { + * // `this` is a WebGLRenderScope: webGLContext, htmlCanvas, framebuffer, size, + * // generation and the frame timing. + * myRenderer.render(webGLContext, framebuffer, size, generation, deltaNanos) + * } + * } * } * } * @@ -132,9 +137,9 @@ internal constructor( * through [image] and bumps [invalidation]. * * This must be called from a [withFrameNanos] callback, so that the texture already holds this - * frame's content by the time Skia submits the frame that samples it; [renderFrames] does that - * and is the recommended way to drive a surface. It must never be called from a draw or layout - * scope: bumping [invalidation] there would invalidate the very drawing that is in progress. + * frame's content by the time Skia submits the frame that samples it. It must never be called + * from a draw or layout scope: bumping [invalidation] there would invalidate the very drawing + * that is in progress. * * The call allocates the texture and the framebuffer if needed, binds the framebuffer, invokes * [block], and afterwards rebinds the default framebuffer and makes Skia drop the GL state it @@ -204,7 +209,8 @@ internal constructor( val framebuffer = framebuffer ?: gl.createFramebuffer() ?: error("createFramebuffer failed") this.framebuffer = framebuffer - val depthStencil = depthStencil ?: gl.createRenderbuffer() ?: error("createRenderbuffer failed") + val depthStencil = + depthStencil ?: gl.createRenderbuffer() ?: error("createRenderbuffer failed") this.depthStencil = depthStencil val adopted = gl.adoptNewTexture(context, size) @@ -273,16 +279,15 @@ internal constructor( @Composable fun rememberWebGLTextureSurface(size: IntSize): WebGLTextureSurface? { val window = LocalComposeWindow.current ?: return null - val surface = - remember(window) { - val canvas = window.htmlCanvas - val gl = webGl2ContextOrNull(canvas) - if (gl == null) { - null - } else { - WebGLTextureSurface(canvas, gl, { window.skiaDirectContext }, size) - } - } ?: return null + val surface = remember(window) { + val canvas = window.htmlCanvas + val gl = webGl2ContextOrNull(canvas) + if (gl == null) { + null + } else { + WebGLTextureSurface(canvas, gl, { window.skiaDirectContext }, size) + } + } ?: return null SideEffect(size) { surface.size = size } DisposableEffect(surface) { onDispose { surface.dispose() } } From 9a530ede04fc186fe359d1cb4aa95f11437b0840 Mon Sep 17 00:00:00 2001 From: "Oleksandr.Karpovich" Date: Tue, 18 Aug 2026 21:53:55 +0200 Subject: [PATCH 09/26] refactoring --- .../mpp/demo/webgl/PlainWebGlDemo.web.kt | 55 +++++-------------- .../mpp/demo/webgl/ThreeJsKnotRenderer.web.kt | 28 ++++------ .../webgl/ThreeTextureAdoptionDemo.web.kt | 4 +- .../graphics/webgl/WebGLTextureSurface.web.kt | 50 ++++++++++------- 4 files changed, 56 insertions(+), 81 deletions(-) diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt index a03e211f8ce5e..144f903fd5f2d 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt @@ -65,12 +65,8 @@ import org.khronos.webgl.WebGLRenderingContext.Companion.TRIANGLES import org.khronos.webgl.WebGLRenderingContext.Companion.VERTEX_SHADER import org.khronos.webgl.WebGLUniformLocation -/** - * Two independent [WebGLTextureSurface]s rendered with plain WebGL2 — no third-party library. - */ -val PlainWebGlScreen = Screen.Example("WebGL texture adoption / plain WebGL") { - PlainWebGlDemo() -} +/** Two independent [WebGLTextureSurface]s rendered with plain WebGL2 — no third-party library. */ +val PlainWebGlScreen = Screen.Example("Plain WebGL") { PlainWebGlDemo() } @Composable private fun PlainWebGlDemo() { @@ -89,11 +85,10 @@ private fun PlainWebGlDemo() { } } - @Composable private fun RotatingTriangle(isAnimating: Boolean) { val surface = rememberWebGLTextureSurface(IntSize(512, 320))!! - val triangle = remember { TriangleRenderer() } + val triangle = remember(surface) { TriangleRenderer(surface.webGLContext) } DisposableEffect(triangle, surface) { onDispose { triangle.dispose(surface) } @@ -141,10 +136,7 @@ private fun ColorPulse(isAnimating: Boolean) { } @Composable -private fun LabelledContent( - caption: String, - content: @Composable () -> Unit -) { +private fun LabelledContent(caption: String, content: @Composable () -> Unit) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Box( modifier = @@ -173,17 +165,15 @@ private fun LabelledContent( * Draws a spinning, vertex-colored triangle. The geometry lives in the vertex shader, so there is * no buffer and no attribute to set up: the whole renderer is one program and two uniforms. */ -private class TriangleRenderer { - /** Kept only so that [dispose] can release the program; the render path uses the scope's. */ - private var capturedGl: WebGLRenderingContext? = null - private var program: WebGLProgram? = null - private var angleUniform: WebGLUniformLocation? = null - private var aspectUniform: WebGLUniformLocation? = null +private class TriangleRenderer(private val gl: WebGLRenderingContext) { + private val program: WebGLProgram = + gl.createProgram(VERTEX_SHADER_SOURCE, FRAGMENT_SHADER_SOURCE) + private val angleUniform: WebGLUniformLocation? = gl.getUniformLocation(program, "angle") + private val aspectUniform: WebGLUniformLocation? = gl.getUniformLocation(program, "aspect") private var angle = 0f fun render(scope: WebGLRenderScope): Unit = with(scope) { - val program = ensureProgram(webGLContext) angle += deltaNanos / 1_000_000_000f * 0.9f // Skia rendered the previous frame through this very context and left its own state @@ -205,24 +195,11 @@ private class TriangleRenderer { webGLContext.drawArrays(TRIANGLES, 0, 3) } - private fun ensureProgram(gl: WebGLRenderingContext): WebGLProgram { - program?.let { - return it - } - val created = gl.createProgram(VERTEX_SHADER_SOURCE, FRAGMENT_SHADER_SOURCE) - capturedGl = gl - program = created - angleUniform = gl.getUniformLocation(created, "angle") - aspectUniform = gl.getUniformLocation(created, "aspect") - return created - } - - /** Releases the program; [surface] is what lets Skia recover from that GL work. */ + /** + * Releases the program + */ fun dispose(surface: WebGLTextureSurface) { - val gl = capturedGl ?: return - program?.let(gl::deleteProgram) - program = null - capturedGl = null + gl.deleteProgram(program) surface.resetSkiaState() } @@ -266,8 +243,7 @@ private fun WebGLRenderingContext.createProgram( fragmentSource: String, ): WebGLProgram { val program = createProgram() ?: error("gl.createProgram() returned null") - for ((type, source) in - listOf(VERTEX_SHADER to vertexSource, FRAGMENT_SHADER to fragmentSource)) { + for ((type, source) in listOf(VERTEX_SHADER to vertexSource, FRAGMENT_SHADER to fragmentSource)) { val shader = createShader(type) ?: error("gl.createShader() returned null") shaderSource(shader, source) compileShader(shader) @@ -283,7 +259,6 @@ private fun WebGLRenderingContext.createProgram( return program } - /** Clears the texture to a pulsing color. No shaders, no resources, nothing to dispose. */ private class PulseRenderer { private var phase = 0f @@ -296,4 +271,4 @@ private class PulseRenderer { webGLContext.clearColor(0.15f * level, 0.55f * level, level, level) webGLContext.clear(COLOR_BUFFER_BIT) } -} \ No newline at end of file +} diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt index 86732eb6d3c1a..56dd77004e671 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt @@ -28,11 +28,12 @@ import androidx.compose.ui.graphics.webgl.WebGLTextureSurface * Everything here is three.js-specific; nothing here knows about Skia, textures or Compose drawing: * that is what [WebGLTextureSurface] takes care of. */ -internal class ThreeJsKnotRenderer private constructor(private val three: ThreeModule) { +internal class ThreeJsKnotRenderer +private constructor(private val three: ThreeModule, surface: WebGLTextureSurface) { companion object { /** Loads three.js, or returns `null` when the module is unavailable. */ - suspend fun createOrNull(): ThreeJsKnotRenderer? = - loadThreeModule()?.let(::ThreeJsKnotRenderer) + suspend fun createOrNull(surface: WebGLTextureSurface): ThreeJsKnotRenderer? = + loadThreeModule()?.let { ThreeJsKnotRenderer(it, surface) } } // The angle is the main dynamic state in this demo, it's updated every frame. @@ -49,8 +50,8 @@ internal class ThreeJsKnotRenderer private constructor(private val three: ThreeM var status: String = "waiting for the first frame" private set - private var renderer: ThreeRenderer? = null - private var knotScene: ThreeKnotScene? = null + private var renderer: ThreeRenderer? = createThreeRenderer(three, surface.htmlCanvas, surface.webGLContext) + private var knotScene: ThreeKnotScene? = createKnotScene(three) private var renderTarget: ThreeRenderTarget? = null private var targetGeneration = 0 private var failed = false @@ -59,9 +60,9 @@ internal class ThreeJsKnotRenderer private constructor(private val three: ThreeM with(scope) { if (failed) return try { - val renderer = ensureRenderer() - val knotScene = ensureKnotScene() - val renderTarget = ensureRenderTarget(renderer, knotScene) + val renderer = renderer ?: error("the renderer was disposed") + val knotScene = knotScene ?: error("the scene was disposed") + val renderTarget = ensureRenderTarget(knotScene) knotAngle += deltaNanos / 1_000_000_000.0 * spin knotScene.updateValues() @@ -84,20 +85,11 @@ internal class ThreeJsKnotRenderer private constructor(private val three: ThreeM } } - private fun WebGLRenderScope.ensureRenderer(): ThreeRenderer = - renderer ?: createThreeRenderer(three, htmlCanvas, webGLContext).also { renderer = it } - - private fun ensureKnotScene(): ThreeKnotScene = - knotScene ?: createKnotScene(three).also { knotScene = it } - /** * The render target is only a descriptor for the framebuffer Compose owns, so it has to be * replaced whenever Compose recreated that framebuffer. */ - private fun WebGLRenderScope.ensureRenderTarget( - renderer: ThreeRenderer, - knotScene: ThreeKnotScene, - ): ThreeRenderTarget { + private fun WebGLRenderScope.ensureRenderTarget(knotScene: ThreeKnotScene): ThreeRenderTarget { val current = renderTarget if (current != null && targetGeneration == generation) return current diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt index 2db4f24da324d..654d5ba3b91df 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt @@ -91,9 +91,9 @@ private fun ThreeTextureAdoptionDemo() { } // three.js arrives through a dynamic import, so the renderer can only be built asynchronously. - val loadState by produceState(LoadState.Loading) { + val loadState by produceState(LoadState.Loading, surface) { value = try { - ThreeJsKnotRenderer.createOrNull()?.let(LoadState::Ready) + ThreeJsKnotRenderer.createOrNull(surface)?.let(LoadState::Ready) ?: LoadState.Failed("three.js is unavailable.") } catch (throwable: Throwable) { LoadState.Failed("Loading three.js failed: ${throwable.message}") diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLTextureSurface.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLTextureSurface.web.kt index 5b330157ddc36..7ddc2de3c3144 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLTextureSurface.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLTextureSurface.web.kt @@ -84,8 +84,8 @@ private const val GL_DEPTH_STENCIL_ATTACHMENT = 0x821A @Stable class WebGLTextureSurface internal constructor( - private val canvas: HTMLCanvasElement, - private val gl: WebGLRenderingContext, + val htmlCanvas: HTMLCanvasElement, + val webGLContext: WebGLRenderingContext, private val directContext: () -> DirectContext?, size: IntSize, ) { @@ -164,12 +164,12 @@ internal constructor( scope.deltaNanos = if (hasRenderedFrame) frameTimeNanos - previousFrameTimeNanos else 0L isRendering = true - gl.bindFramebuffer(FRAMEBUFFER, scope.framebuffer) + webGLContext.bindFramebuffer(FRAMEBUFFER, scope.framebuffer) try { scope.block() } finally { isRendering = false - gl.bindFramebuffer(FRAMEBUFFER, null) + webGLContext.bindFramebuffer(FRAMEBUFFER, null) // Everything above went through the context Skia renders Compose with, so whatever Skia // believes about the GL state is stale by now. context.resetAll() @@ -192,14 +192,14 @@ internal constructor( */ fun resetSkiaState(): Boolean { val context = directContext() ?: return false - gl.bindFramebuffer(FRAMEBUFFER, null) + webGLContext.bindFramebuffer(FRAMEBUFFER, null) context.resetAll() return true } private fun prepareWebGLRenderScope( context: DirectContext, - size: IntSize + size: IntSize, ): WegGLRenderScopeImpl { val current = adopted if (current != null && current.size == size) return webGLRenderScope!! @@ -207,34 +207,42 @@ internal constructor( current?.dispose() adopted = null - val framebuffer = framebuffer ?: gl.createFramebuffer() ?: error("createFramebuffer failed") + val framebuffer = + framebuffer ?: webGLContext.createFramebuffer() ?: error("createFramebuffer failed") this.framebuffer = framebuffer val depthStencil = - depthStencil ?: gl.createRenderbuffer() ?: error("createRenderbuffer failed") + depthStencil ?: webGLContext.createRenderbuffer() ?: error("createRenderbuffer failed") this.depthStencil = depthStencil - val adopted = gl.adoptNewTexture(context, size) + val adopted = webGLContext.adoptNewTexture(context, size) this.adopted = adopted - gl.bindRenderbuffer(RENDERBUFFER, depthStencil) - gl.renderbufferStorage(RENDERBUFFER, GL_DEPTH24_STENCIL8, size.width, size.height) - gl.bindRenderbuffer(RENDERBUFFER, null) + webGLContext.bindRenderbuffer(RENDERBUFFER, depthStencil) + webGLContext.renderbufferStorage(RENDERBUFFER, GL_DEPTH24_STENCIL8, size.width, size.height) + webGLContext.bindRenderbuffer(RENDERBUFFER, null) - gl.bindFramebuffer(FRAMEBUFFER, framebuffer) - gl.framebufferTexture2D(FRAMEBUFFER, COLOR_ATTACHMENT0, TEXTURE_2D, adopted.texture, 0) - gl.framebufferRenderbuffer( + webGLContext.bindFramebuffer(FRAMEBUFFER, framebuffer) + webGLContext.framebufferTexture2D( + FRAMEBUFFER, + COLOR_ATTACHMENT0, + TEXTURE_2D, + adopted.texture, + 0, + ) + webGLContext.framebufferRenderbuffer( FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, RENDERBUFFER, depthStencil, ) - val status = gl.checkFramebufferStatus(FRAMEBUFFER) - gl.bindFramebuffer(FRAMEBUFFER, null) + val status = webGLContext.checkFramebufferStatus(FRAMEBUFFER) + webGLContext.bindFramebuffer(FRAMEBUFFER, null) check(status == FRAMEBUFFER_COMPLETE) { "the adopted texture is not a complete framebuffer attachment (status $status)" } - webGLRenderScope = WegGLRenderScopeImpl(gl, canvas, framebuffer, size, ++generation) + webGLRenderScope = + WegGLRenderScopeImpl(webGLContext, htmlCanvas, framebuffer, size, ++generation) return webGLRenderScope!! } @@ -259,11 +267,11 @@ internal constructor( adopted?.dispose() adopted = null webGLRenderScope = null - framebuffer?.let(gl::deleteFramebuffer) + framebuffer?.let(webGLContext::deleteFramebuffer) framebuffer = null - depthStencil?.let(gl::deleteRenderbuffer) + depthStencil?.let(webGLContext::deleteRenderbuffer) depthStencil = null - gl.bindFramebuffer(FRAMEBUFFER, null) + webGLContext.bindFramebuffer(FRAMEBUFFER, null) directContext()?.resetAll() } } From 86135c26f7690bd43b64f88de6775496acc50214 Mon Sep 17 00:00:00 2001 From: "Oleksandr.Karpovich" Date: Wed, 19 Aug 2026 16:38:01 +0200 Subject: [PATCH 10/26] update api --- .../mpp/demo/webgl/PlainWebGlDemo.web.kt | 22 +++++++++++++--- .../mpp/demo/webgl/ThreeJsKnotRenderer.web.kt | 7 +++++- .../webgl/ThreeTextureAdoptionDemo.web.kt | 2 +- .../ui/graphics/webgl/WebGLRenderScope.web.kt | 12 --------- .../graphics/webgl/WebGLTextureSurface.web.kt | 25 +++++-------------- 5 files changed, 31 insertions(+), 37 deletions(-) diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt index 144f903fd5f2d..88584c7c7afd9 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt @@ -97,7 +97,7 @@ private fun RotatingTriangle(isAnimating: Boolean) { LaunchedEffect(surface, isAnimating) { while (isAnimating) { withFrameNanos { frameTimeNanos -> - surface.render(frameTimeNanos) { triangle.render(this) } + surface.render { triangle.render(this, frameTimeNanos) } } } } @@ -120,7 +120,7 @@ private fun ColorPulse(isAnimating: Boolean) { LaunchedEffect(surface, isAnimating) { while (isAnimating) { withFrameNanos { frameTimeNanos -> - surface.render(frameTimeNanos) { pulse.render(this) } + surface.render { pulse.render(this, frameTimeNanos) } } } } @@ -171,9 +171,16 @@ private class TriangleRenderer(private val gl: WebGLRenderingContext) { private val angleUniform: WebGLUniformLocation? = gl.getUniformLocation(program, "angle") private val aspectUniform: WebGLUniformLocation? = gl.getUniformLocation(program, "aspect") private var angle = 0f + private var previousFrameTimeNanos = 0L - fun render(scope: WebGLRenderScope): Unit = + fun render(scope: WebGLRenderScope, frameTimeNanos: Long): Unit = with(scope) { + val deltaNanos = if (previousFrameTimeNanos == 0L) { + 0L + } else { + frameTimeNanos - previousFrameTimeNanos + } + previousFrameTimeNanos = frameTimeNanos angle += deltaNanos / 1_000_000_000f * 0.9f // Skia rendered the previous frame through this very context and left its own state @@ -262,9 +269,16 @@ private fun WebGLRenderingContext.createProgram( /** Clears the texture to a pulsing color. No shaders, no resources, nothing to dispose. */ private class PulseRenderer { private var phase = 0f + private var previousFrameTimeNanos = 0L - fun render(scope: WebGLRenderScope): Unit = + fun render(scope: WebGLRenderScope, frameTimeNanos: Long): Unit = with(scope) { + 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) diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt index 56dd77004e671..77bfc9c615232 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt @@ -55,10 +55,15 @@ private constructor(private val three: ThreeModule, surface: WebGLTextureSurface private var renderTarget: ThreeRenderTarget? = null private var targetGeneration = 0 private var failed = false + private var previousFrameTimeNanos = 0L - fun renderFrame(scope: WebGLRenderScope): Unit = + fun renderFrame(scope: WebGLRenderScope, frameTimeNanos: Long): Unit = with(scope) { if (failed) return + val deltaNanos = + if (previousFrameTimeNanos == 0L) 0L + else frameTimeNanos - previousFrameTimeNanos + previousFrameTimeNanos = frameTimeNanos try { val renderer = renderer ?: error("the renderer was disposed") val knotScene = knotScene ?: error("the scene was disposed") diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt index 654d5ba3b91df..a906b454e794c 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt @@ -158,7 +158,7 @@ private fun ThreeSceneContent( LaunchedEffect(surface, threeJs, running) { while (running) { withFrameNanos { frameTimeNanos -> - surface.render(frameTimeNanos, { threeJs.renderFrame(this) }) + surface.render { threeJs.renderFrame(this, frameTimeNanos) } } } } diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLRenderScope.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLRenderScope.web.kt index a71560ecf1171..ae94ed3b66da5 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLRenderScope.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLRenderScope.web.kt @@ -66,18 +66,6 @@ sealed interface WebGLRenderScope { /** The size of [framebuffer], in pixels. */ val size: IntSize - /** - * The time of the frame being rendered, in nanoseconds, as reported by - * [androidx.compose.runtime.withFrameNanos]. - */ - val frameTimeNanos: Long - - /** - * The time elapsed since the previously rendered frame, in nanoseconds, or `0` for the first - * frame rendered into the surface. This is the value to advance animations by. - */ - val deltaNanos: Long - /** * Incremented every time the surface recreated [framebuffer] and its attachments, which happens * on the first frame and whenever [WebGLTextureSurface.size] changed. diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLTextureSurface.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLTextureSurface.web.kt index 7ddc2de3c3144..f230a9ae0a3e0 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLTextureSurface.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLTextureSurface.web.kt @@ -62,10 +62,10 @@ private const val GL_DEPTH_STENCIL_ATTACHMENT = 0x821A * LaunchedEffect(surface) { * while (true) { * withFrameNanos { frameTimeNanos -> - * surface.render(frameTimeNanos) { - * // `this` is a WebGLRenderScope: webGLContext, htmlCanvas, framebuffer, size, - * // generation and the frame timing. - * myRenderer.render(webGLContext, framebuffer, size, generation, deltaNanos) + * surface.render { + * // `this` is a WebGLRenderScope: webGLContext, htmlCanvas, framebuffer, size + * // and generation. Pass frameTimeNanos to your renderer if it needs timing. + * myRenderer.render(frameTimeNanos, this) * } * } * } @@ -129,8 +129,6 @@ internal constructor( private var generation = 0 private var isDisposed = false private var isRendering = false - private var hasRenderedFrame = false - private var previousFrameTimeNanos = 0L /** * Renders one frame of foreign WebGL content into this surface, then makes the result available @@ -145,14 +143,11 @@ internal constructor( * [block], and afterwards rebinds the default framebuffer and makes Skia drop the GL state it * had cached before [block] ran. * - * @param frameTimeNanos the time of the frame being rendered, as received from - * [withFrameNanos]. It is what [WebGLRenderScope.frameTimeNanos] and - * [WebGLRenderScope.deltaNanos] report to [block]. * @return `false` when the surface could not be prepared, which happens while Compose has not * rendered its first frame yet and therefore has no GPU context to share; [block] is not * invoked in that case. */ - fun render(frameTimeNanos: Long, block: WebGLRenderScope.() -> Unit): Boolean { + fun render(block: WebGLRenderScope.() -> Unit): Boolean { if (isDisposed) return false check(!isRendering) { "render() is already running: it must not be called from within another render() call, " + @@ -160,9 +155,6 @@ internal constructor( } val context = directContext() ?: return false val scope = prepareWebGLRenderScope(context, size) - scope.frameTimeNanos = frameTimeNanos - scope.deltaNanos = if (hasRenderedFrame) frameTimeNanos - previousFrameTimeNanos else 0L - isRendering = true webGLContext.bindFramebuffer(FRAMEBUFFER, scope.framebuffer) try { @@ -174,8 +166,6 @@ internal constructor( // believes about the GL state is stale by now. context.resetAll() } - previousFrameTimeNanos = frameTimeNanos - hasRenderedFrame = true _invalidation.value++ return true } @@ -252,10 +242,7 @@ internal constructor( override val framebuffer: WebGLFramebuffer, override val size: IntSize, override val generation: Int, - ) : WebGLRenderScope { - override var frameTimeNanos: Long = 0L - override var deltaNanos: Long = 0L - } + ) : WebGLRenderScope /** * Releases the texture, the image and the framebuffer. Called by [rememberWebGLTextureSurface] From 4a63cc8254ad7669e59add1f8d8986152926e198 Mon Sep 17 00:00:00 2001 From: "Oleksandr.Karpovich" Date: Thu, 20 Aug 2026 12:59:01 +0200 Subject: [PATCH 11/26] update kdocs --- .../mpp/demo/webgl/PlainWebGlDemo.web.kt | 18 +- .../mpp/demo/webgl/ThreeJsKnotRenderer.web.kt | 16 +- .../webgl/ThreeTextureAdoptionDemo.web.kt | 16 +- .../ui/graphics/webgl/WebGLRenderScope.web.kt | 78 -------- .../webgl/AdoptedGLTexture.web.kt | 14 +- .../ui/platform/webgl/WebGLRenderScope.web.kt | 58 ++++++ .../webgl/WebGLRenderTarget.web.kt} | 178 ++++++++++-------- .../webgl/WebGLTextureDraw.web.kt | 34 ++-- 8 files changed, 196 insertions(+), 216 deletions(-) delete mode 100644 compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLRenderScope.web.kt rename compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/{graphics => platform}/webgl/AdoptedGLTexture.web.kt (87%) create mode 100644 compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderScope.web.kt rename compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/{graphics/webgl/WebGLTextureSurface.web.kt => platform/webgl/WebGLRenderTarget.web.kt} (57%) rename compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/{graphics => platform}/webgl/WebGLTextureDraw.web.kt (72%) diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt index 88584c7c7afd9..090d96a551e27 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt @@ -46,10 +46,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.webgl.WebGLRenderScope -import androidx.compose.ui.graphics.webgl.WebGLTextureSurface -import androidx.compose.ui.graphics.webgl.drawWebGLTexture -import androidx.compose.ui.graphics.webgl.rememberWebGLTextureSurface +import androidx.compose.ui.platform.webgl.WebGLRenderScope +import androidx.compose.ui.platform.webgl.WebGLRenderTarget +import androidx.compose.ui.platform.webgl.drawWebGLTexture +import androidx.compose.ui.platform.webgl.rememberWebGLRenderTarget import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp @@ -65,7 +65,7 @@ import org.khronos.webgl.WebGLRenderingContext.Companion.TRIANGLES import org.khronos.webgl.WebGLRenderingContext.Companion.VERTEX_SHADER import org.khronos.webgl.WebGLUniformLocation -/** Two independent [WebGLTextureSurface]s rendered with plain WebGL2 — no third-party library. */ +/** Two independent [WebGLRenderTarget]s rendered with plain WebGL2 — no third-party library. */ val PlainWebGlScreen = Screen.Example("Plain WebGL") { PlainWebGlDemo() } @Composable @@ -87,7 +87,7 @@ private fun PlainWebGlDemo() { @Composable private fun RotatingTriangle(isAnimating: Boolean) { - val surface = rememberWebGLTextureSurface(IntSize(512, 320))!! + val surface = rememberWebGLRenderTarget(IntSize(512, 320))!! val triangle = remember(surface) { TriangleRenderer(surface.webGLContext) } DisposableEffect(triangle, surface) { @@ -114,7 +114,7 @@ private fun RotatingTriangle(isAnimating: Boolean) { @Composable private fun ColorPulse(isAnimating: Boolean) { - val surface = rememberWebGLTextureSurface(IntSize(64, 64))!! + val surface = rememberWebGLRenderTarget(IntSize(64, 64))!! val pulse = remember { PulseRenderer() } LaunchedEffect(surface, isAnimating) { @@ -205,9 +205,9 @@ private class TriangleRenderer(private val gl: WebGLRenderingContext) { /** * Releases the program */ - fun dispose(surface: WebGLTextureSurface) { + fun dispose(surface: WebGLRenderTarget) { gl.deleteProgram(program) - surface.resetSkiaState() + surface.restoreGLState() } companion object { diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt index 77bfc9c615232..294c38bd95322 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt @@ -19,20 +19,20 @@ package androidx.compose.mpp.demo.webgl import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.graphics.webgl.WebGLRenderScope -import androidx.compose.ui.graphics.webgl.WebGLTextureSurface +import androidx.compose.ui.platform.webgl.WebGLRenderScope +import androidx.compose.ui.platform.webgl.WebGLRenderTarget /** * A three.js renderer of a lit torus knot, rendering into whatever framebuffer Compose hands it. * * Everything here is three.js-specific; nothing here knows about Skia, textures or Compose drawing: - * that is what [WebGLTextureSurface] takes care of. + * that is what [WebGLRenderTarget] takes care of. */ internal class ThreeJsKnotRenderer -private constructor(private val three: ThreeModule, surface: WebGLTextureSurface) { +private constructor(private val three: ThreeModule, surface: WebGLRenderTarget) { companion object { /** Loads three.js, or returns `null` when the module is unavailable. */ - suspend fun createOrNull(surface: WebGLTextureSurface): ThreeJsKnotRenderer? = + suspend fun createOrNull(surface: WebGLRenderTarget): ThreeJsKnotRenderer? = loadThreeModule()?.let { ThreeJsKnotRenderer(it, surface) } } @@ -116,15 +116,15 @@ private constructor(private val three: ThreeModule, surface: WebGLTextureSurface /** * Releases three's own GL objects. Since that touches the context Compose renders through, - * [WebGLTextureSurface.resetSkiaState] has to be called afterwards. + * [WebGLRenderTarget.restoreGLState] has to be called afterwards. */ - fun dispose(surface: WebGLTextureSurface?) { + fun dispose(surface: WebGLRenderTarget?) { knotScene?.let(::disposeKnotScene) knotScene = null renderer?.dispose() renderer = null renderTarget = null targetGeneration = 0 - surface?.resetSkiaState() + surface?.restoreGLState() } } diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt index a906b454e794c..9fea047d31db1 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt @@ -57,9 +57,9 @@ import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.webgl.WebGLTextureSurface -import androidx.compose.ui.graphics.webgl.drawWebGLTexture -import androidx.compose.ui.graphics.webgl.rememberWebGLTextureSurface +import androidx.compose.ui.platform.webgl.WebGLRenderTarget +import androidx.compose.ui.platform.webgl.drawWebGLTexture +import androidx.compose.ui.platform.webgl.rememberWebGLRenderTarget import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.IntSize @@ -67,7 +67,7 @@ import androidx.compose.ui.unit.dp import kotlin.math.roundToInt /** - * A demo of [WebGLTextureSurface]: three.js renders a lit torus knot into a texture Compose owns + * A demo of [WebGLRenderTarget]: three.js renders a lit torus knot into a texture Compose owns * and Skia adopted, and Compose then draws that texture like any other image. */ val ThreeJsTextureAdoptionScreen = @@ -82,7 +82,7 @@ private fun ThreeTextureAdoptionDemo() { var textureWidth by remember { mutableStateOf(512) } val textureSize = IntSize(textureWidth, (textureWidth * 0.625f).roundToInt()) - val surface = rememberWebGLTextureSurface(textureSize) + val surface = rememberWebGLRenderTarget(textureSize) if (surface == null) { Centered( "Compose does not render through a WebGL2 canvas here, so there is no texture to adopt." @@ -130,7 +130,7 @@ private fun Centered(message: String) { @Composable private fun ThreeSceneContent( - surface: WebGLTextureSurface, + surface: WebGLRenderTarget, threeJs: ThreeJsKnotRenderer, textureWidth: Int, onTextureWidthChange: (Int) -> Unit, @@ -235,7 +235,7 @@ private data class FrameStats(val index: Long, val fps: Float) * The three.js output as the hero: tilted in 3D by dragging, clipped, with Compose content on top. */ @Composable -private fun Hero(surface: WebGLTextureSurface) { +private fun Hero(surface: WebGLRenderTarget) { var tiltX by remember { mutableStateOf(0f) } var tiltY by remember { mutableStateOf(0f) } @@ -283,7 +283,7 @@ private fun Hero(surface: WebGLTextureSurface) { /** The same adopted texture, drawn several times in one frame with different transformations. */ @Composable -private fun Variants(surface: WebGLTextureSurface) { +private fun Variants(surface: WebGLRenderTarget) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { Canvas(Modifier.size(96.dp).clip(CircleShape).background(Color.LightGray)) { drawWebGLTexture(surface) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLRenderScope.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLRenderScope.web.kt deleted file mode 100644 index ae94ed3b66da5..0000000000000 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLRenderScope.web.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright 2026 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.ui.graphics.webgl - -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.unit.IntSize -import org.khronos.webgl.WebGLFramebuffer -import org.khronos.webgl.WebGLRenderingContext -import org.w3c.dom.HTMLCanvasElement - -/** - * Everything a non-Compose renderer needs in order to render one frame into a - * [WebGLTextureSurface]. - * - * The scope is only valid inside the [WebGLTextureSurface.render] call that provided it: neither it - * nor any of its values may be retained, because the surface may replace the underlying GL objects - * between frames (see [generation]). - * - * Everything here is shared with Compose, so a renderer is expected to: - * - restore any global GL state it changes, or at the very least not rely on the state it left - * behind in the previous frame, because Skia rendered a frame through the same context in - * between; - * - never resize [htmlCanvas], change its pixel ratio or force a context loss on [webGLContext]; - * - never delete [framebuffer] or the texture attached to it. - * - * Compose restores the default framebuffer and lets Skia recover its own cached GL state after - * every [WebGLTextureSurface.render] call, so a renderer does not need to do that. - */ -@ExperimentalComposeUiApi -sealed interface WebGLRenderScope { - /** - * The WebGL2 context Compose renders through. - * - * Rendering through this very context is what makes the result usable by Compose without a - * copy: WebGL has no share groups, so a texture created in another context could never be read - * by Skia. - */ - val webGLContext: WebGLRenderingContext - - /** - * The `` element Compose renders into. Exposed because libraries commonly require a - * canvas alongside a context; its size and its context are owned by Compose. - */ - val htmlCanvas: HTMLCanvasElement - - /** - * The framebuffer the frame has to be rendered into. It is bound as [FRAMEBUFFER] when the - * render block is entered, and has a color texture and a depth-stencil attachment of [size]. - */ - val framebuffer: WebGLFramebuffer - - /** The size of [framebuffer], in pixels. */ - val size: IntSize - - /** - * Incremented every time the surface recreated [framebuffer] and its attachments, which happens - * on the first frame and whenever [WebGLTextureSurface.size] changed. - * - * Renderers that cache anything derived from the render target — a render target descriptor, a - * viewport, a projection matrix — should refresh it whenever this value differs from the one - * seen in the previous frame. - */ - val generation: Int -} diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/AdoptedGLTexture.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/AdoptedGLTexture.web.kt similarity index 87% rename from compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/AdoptedGLTexture.web.kt rename to compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/AdoptedGLTexture.web.kt index b56b0f5dc0db5..023ae86dbfc29 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/AdoptedGLTexture.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/AdoptedGLTexture.web.kt @@ -16,7 +16,7 @@ @file:OptIn(ExperimentalWasmJsInterop::class) -package androidx.compose.ui.graphics.webgl +package androidx.compose.ui.platform.webgl import androidx.compose.ui.unit.IntSize import kotlin.js.ExperimentalWasmJsInterop @@ -60,8 +60,6 @@ internal class AdoptedGLTexture( val size: IntSize, ) { fun dispose() { - // Closing the image releases Skia's reference to the texture; Skia owns the texture since - // it adopted it, so it is not deleted here. image.close() unregisterTexture(textureId) } @@ -76,16 +74,8 @@ internal class AdoptedGLTexture( internal fun WebGLRenderingContext.adoptNewTexture( context: DirectContext, size: IntSize, + texture: WebGLTexture, ): AdoptedGLTexture { - val texture = createTexture() ?: error("gl.createTexture() returned null") - bindTexture(TEXTURE_2D, texture) - texImage2D(TEXTURE_2D, 0, RGBA, size.width, size.height, 0, RGBA, UNSIGNED_BYTE, null) - texParameteri(TEXTURE_2D, TEXTURE_MIN_FILTER, LINEAR) - texParameteri(TEXTURE_2D, TEXTURE_MAG_FILTER, LINEAR) - texParameteri(TEXTURE_2D, TEXTURE_WRAP_S, CLAMP_TO_EDGE) - texParameteri(TEXTURE_2D, TEXTURE_WRAP_T, CLAMP_TO_EDGE) - bindTexture(TEXTURE_2D, null) - val textureId = pushTexture(texture) var ownershipTransferred = false try { diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderScope.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderScope.web.kt new file mode 100644 index 0000000000000..4c77928a62973 --- /dev/null +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderScope.web.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.platform.webgl + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.unit.IntSize +import org.khronos.webgl.WebGLFramebuffer +import org.khronos.webgl.WebGLRenderingContext +import org.w3c.dom.HTMLCanvasElement + +/** + * Scope provided to external renderers for drawing a frame into a [WebGLRenderTarget]. + * + * Valid only inside [WebGLRenderTarget.render]—do not retain this scope or its resources. + * Compose automatically restores the default framebuffer and clears cached GL state after each call. + * + * **Renderer expectations:** + * - Do not rely on GL state persisting between frames. + * - Do not resize [htmlCanvas] or manipulate context lifecycle. + * - Do not delete [framebuffer] or its attached textures. + */ +@ExperimentalComposeUiApi +sealed interface WebGLRenderScope { + /** The WebGL2 context shared with Compose */ + val webGLContext: WebGLRenderingContext + + /** The `` element owned by Compose, exposed for third-party library initialization. */ + val htmlCanvas: HTMLCanvasElement + + /** + * The framebuffer owned by this render target. It is bound when the render block starts, + * but external code may temporarily change the binding and must restore it before returning. + */ + val framebuffer: WebGLFramebuffer + + /** Framebuffer dimensions in pixels. */ + val size: IntSize + + /** + * Incremented whenever [framebuffer] or its attachments are recreated (initial setup or size changes). + * Renderers should check this value to invalidate cached viewports, matrices, or descriptors. + */ + val generation: Int +} diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLTextureSurface.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTarget.web.kt similarity index 57% rename from compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLTextureSurface.web.kt rename to compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTarget.web.kt index f230a9ae0a3e0..9e34d5248cdfe 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLTextureSurface.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTarget.web.kt @@ -14,11 +14,10 @@ * limitations under the License. */ -package androidx.compose.ui.graphics.webgl +package androidx.compose.ui.platform.webgl import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LongState import androidx.compose.runtime.SideEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableLongStateOf @@ -32,11 +31,20 @@ import org.jetbrains.skia.Image import org.khronos.webgl.WebGLFramebuffer import org.khronos.webgl.WebGLRenderbuffer import org.khronos.webgl.WebGLRenderingContext +import org.khronos.webgl.WebGLRenderingContext.Companion.CLAMP_TO_EDGE import org.khronos.webgl.WebGLRenderingContext.Companion.COLOR_ATTACHMENT0 import org.khronos.webgl.WebGLRenderingContext.Companion.FRAMEBUFFER import org.khronos.webgl.WebGLRenderingContext.Companion.FRAMEBUFFER_COMPLETE +import org.khronos.webgl.WebGLRenderingContext.Companion.LINEAR import org.khronos.webgl.WebGLRenderingContext.Companion.RENDERBUFFER +import org.khronos.webgl.WebGLRenderingContext.Companion.RGBA import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_2D +import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_MAG_FILTER +import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_MIN_FILTER +import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_WRAP_S +import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_WRAP_T +import org.khronos.webgl.WebGLRenderingContext.Companion.UNSIGNED_BYTE +import org.khronos.webgl.WebGLTexture import org.w3c.dom.HTMLCanvasElement // WebGL2 only; see https://registry.khronos.org/OpenGL/api/GL/glcorearb.h @@ -46,83 +54,75 @@ private const val GL_DEPTH24_STENCIL8 = 0x88F0 private const val GL_DEPTH_STENCIL_ATTACHMENT = 0x821A /** - * An offscreen GPU surface that non-Compose WebGL code can render into, and that Compose can draw - * without copying anything. + * Represents a render target backed by an offscreen WebGL texture created in the same WebGL context + * that Compose uses for rendering. + * Its primary purpose is to render WebGL content into a texture that can be drawn alongside Compose + * content in the same . * - * It owns a texture allocated in the very same WebGL context Compose renders through, wrapped in a - * framebuffer with a depth-stencil attachment. Skia adopts that texture, which turns it into an - * [image] that can be drawn as many times as needed, anywhere in the composition, including inside - * graphics layers (`clip`, `blur`, `graphicsLayer`). + * Obtain an instance with [rememberWebGLRenderTarget]. + * [render] allows callers to execute custom WebGL rendering code using the provided context. + * After a successful [render], the target’s texture will be implicitly used by [drawWebGLTexture]. + * Use [drawWebGLTexture] to draw the frame. * - * Obtain an instance with [rememberWebGLTextureSurface], render into it with [render] and draw it - * with [androidx.compose.ui.graphics.webgl.drawWebGLTexture]: + * Usage example: * ``` - * val surface = rememberWebGLTextureSurface(IntSize(1024, 640)) ?: return + * val renderTarget = rememberWebGLRenderTarget(IntSize(1024, 640)) ?: return * - * LaunchedEffect(surface) { + * LaunchedEffect(renderTarget) { * while (true) { * withFrameNanos { frameTimeNanos -> - * surface.render { - * // `this` is a WebGLRenderScope: webGLContext, htmlCanvas, framebuffer, size - * // and generation. Pass frameTimeNanos to your renderer if it needs timing. - * myRenderer.render(frameTimeNanos, this) + * renderTarget.render { + * val phase = (frameTimeNanos % 1_000_000_000L).toFloat() / 1_000_000_000f + * webGLContext.clearColor(phase, 0.2f, 0.4f, 1f) + * webGLContext.clear(WebGLRenderingContext.COLOR_BUFFER_BIT) * } * } * } * } * - * Canvas(Modifier.fillMaxSize()) { drawWebGLTexture(surface) } + * Canvas(Modifier.fillMaxSize()) { drawWebGLTexture(renderTarget) } * ``` - * - * The texture is RGBA8 with premultiplied alpha, sampled with `LINEAR` filtering and - * `CLAMP_TO_EDGE` wrapping, without mipmaps and without multisampling. Clearing it to a transparent - * premultiplied color is what lets Compose content show through the drawn result. - * - * Instances are not thread-safe and are meant to be used from the frame loop only. */ @ExperimentalComposeUiApi @Stable -class WebGLTextureSurface +class WebGLRenderTarget internal constructor( val htmlCanvas: HTMLCanvasElement, val webGLContext: WebGLRenderingContext, private val directContext: () -> DirectContext?, - size: IntSize, + private val textureFactory: (IntSize) -> WebGLTexture, + initialSize: IntSize, ) { + /** - * The size of the color texture, in pixels, coerced to at least one pixel in each dimension. + * Color texture size in pixels (minimum 1x1). * - * Changing it discards the current texture and [image] and allocates new ones on the next - * [render], which also bumps [WebGLRenderScope.generation]. Allocating a texture is not cheap, - * so avoid driving this from a value that changes every frame. + * Modifying this triggers a texture reallocation on the next [render] and bumps + * [WebGLRenderScope.generation]. Avoid updating this per-frame due to allocation cost. */ - var size: IntSize = size.coerceAtLeastOnePixel() + var size: IntSize = initialSize.coerceAtLeastOnePixel() set(value) { field = value.coerceAtLeastOnePixel() } /** - * The image that samples the color texture, or `null` until the first [render] succeeded. + * A lightweight Skiko [Image] wrapping [adoptedTexture]'s GPU memory without copying pixel data. * - * The surface owns it: it must not be closed, and it must not be retained across frames, since - * a [size] change replaces it. + * Returns `null` if no texture is currently adopted. */ - val image: Image? - get() = adopted?.image + internal val image: Image? + get() = adoptedTexture?.image private val _invalidation = mutableLongStateOf(0L) /** - * A counter incremented after every successful [render]. - * - * Read it from a draw scope rather than from composition, so that a rendered frame invalidates - * the drawing without recomposing anything. - * [androidx.compose.ui.graphics.webgl.drawWebGLTexture] already does that. + * Observes frame invalidation from a draw operation */ - val invalidation: LongState - get() = _invalidation + internal fun observeInvalidation() { + _invalidation.value + } - private var adopted: AdoptedGLTexture? = null + private var adoptedTexture: AdoptedGLTexture? = null private var framebuffer: WebGLFramebuffer? = null private var depthStencil: WebGLRenderbuffer? = null private var webGLRenderScope: WegGLRenderScopeImpl? = null @@ -131,21 +131,15 @@ internal constructor( private var isRendering = false /** - * Renders one frame of foreign WebGL content into this surface, then makes the result available - * through [image] and bumps [invalidation]. + * Renders a frame of WebGL content into this surface, updates [image], and triggers a Compose redraw. * - * This must be called from a [withFrameNanos] callback, so that the texture already holds this - * frame's content by the time Skia submits the frame that samples it. It must never be called - * from a draw or layout scope: bumping [invalidation] there would invalidate the very drawing - * that is in progress. + * Must be called within a [withFrameNanos] callback (before Skia samples the frame) and never + * inside a draw or layout scope. * - * The call allocates the texture and the framebuffer if needed, binds the framebuffer, invokes - * [block], and afterwards rebinds the default framebuffer and makes Skia drop the GL state it - * had cached before [block] ran. + * Allocates resources as needed, binds the offscreen framebuffer, executes [block], and restores + * the default GL state afterward. * - * @return `false` when the surface could not be prepared, which happens while Compose has not - * rendered its first frame yet and therefore has no GPU context to share; [block] is not - * invoked in that case. + * @return `false` (and skips [block]) if the GPU context is unavailable, such as before Compose's first frame. */ fun render(block: WebGLRenderScope.() -> Unit): Boolean { if (isDisposed) return false @@ -171,16 +165,14 @@ internal constructor( } /** - * Tells Compose that WebGL state was changed outside of [render], so that Skia drops the GL - * state it had cached. + * Restores the rendering context back to a clean state expected by Compose. * - * Needed for GL work that cannot happen inside [render], typically a library's own teardown: - * disposing programs and buffers touches the context Compose renders through as well. + * Compose assumes exclusive control over the underlying graphics context. + * This call informs the context that the GL state was modified outiside of [render]. * - * @return `false` when Compose has no GPU context to reset, in which case there is nothing to - * do. + * Note: Calling this frequently carries a performance penalty due to GL state cache invalidation. */ - fun resetSkiaState(): Boolean { + fun restoreGLState(): Boolean { val context = directContext() ?: return false webGLContext.bindFramebuffer(FRAMEBUFFER, null) context.resetAll() @@ -191,11 +183,11 @@ internal constructor( context: DirectContext, size: IntSize, ): WegGLRenderScopeImpl { - val current = adopted + val current = adoptedTexture if (current != null && current.size == size) return webGLRenderScope!! current?.dispose() - adopted = null + adoptedTexture = null val framebuffer = framebuffer ?: webGLContext.createFramebuffer() ?: error("createFramebuffer failed") @@ -204,8 +196,8 @@ internal constructor( depthStencil ?: webGLContext.createRenderbuffer() ?: error("createRenderbuffer failed") this.depthStencil = depthStencil - val adopted = webGLContext.adoptNewTexture(context, size) - this.adopted = adopted + val adopted = webGLContext.adoptNewTexture(context, size, textureFactory(size)) + this.adoptedTexture = adopted webGLContext.bindRenderbuffer(RENDERBUFFER, depthStencil) webGLContext.renderbufferStorage(RENDERBUFFER, GL_DEPTH24_STENCIL8, size.width, size.height) @@ -245,14 +237,14 @@ internal constructor( ) : WebGLRenderScope /** - * Releases the texture, the image and the framebuffer. Called by [rememberWebGLTextureSurface] + * Releases the texture, the image and the framebuffer. Called by [rememberWebGLRenderTarget] * when the surface leaves the composition; calling it twice is a no-op. */ internal fun dispose() { if (isDisposed) return isDisposed = true - adopted?.dispose() - adopted = null + adoptedTexture?.dispose() + adoptedTexture = null webGLRenderScope = null framebuffer?.let(webGLContext::deleteFramebuffer) framebuffer = null @@ -263,30 +255,56 @@ internal constructor( } } +private fun WebGLRenderingContext.defaultWebGLTexture(size: IntSize): WebGLTexture { + val gl = this + val texture = gl.createTexture() ?: error("gl.createTexture() returned null") + gl.bindTexture(TEXTURE_2D, texture) + // Configure the texture + gl.texImage2D(TEXTURE_2D, 0, RGBA, size.width, size.height, 0, RGBA, UNSIGNED_BYTE, null) + // LINEAR for smoother scaling: + gl.texParameteri(TEXTURE_2D, TEXTURE_MIN_FILTER, LINEAR) + gl.texParameteri(TEXTURE_2D, TEXTURE_MAG_FILTER, LINEAR) + // Prevents Edge Artifacts + gl.texParameteri(TEXTURE_2D, TEXTURE_WRAP_S, CLAMP_TO_EDGE) + gl.texParameteri(TEXTURE_2D, TEXTURE_WRAP_T, CLAMP_TO_EDGE) + gl.bindTexture(TEXTURE_2D, null) + return texture +} + /** - * Creates and remembers a [WebGLTextureSurface] of [size] pixels, disposing it when it leaves the - * composition. + * Remembers a [WebGLRenderTarget] of the given [size], automatically disposing it when + * leaving the composition. * - * @return `null` when Compose does not render through a WebGL2 canvas, in which case there is no - * context to share and no texture to adopt. Callers are expected to render a fallback. + * Updating [size] recreates the underlying GPU resources. + * + * @return The target, or `null` if WebGL2 is unsupported. */ @ExperimentalComposeUiApi @Composable -fun rememberWebGLTextureSurface(size: IntSize): WebGLTextureSurface? { +fun rememberWebGLRenderTarget( + size: IntSize +): WebGLRenderTarget? { val window = LocalComposeWindow.current ?: return null - val surface = remember(window) { + val renderTarget = remember(window) { val canvas = window.htmlCanvas val gl = webGl2ContextOrNull(canvas) if (gl == null) { null } else { - WebGLTextureSurface(canvas, gl, { window.skiaDirectContext }, size) + WebGLRenderTarget( + htmlCanvas = canvas, + webGLContext = gl, + directContext = { window.skiaDirectContext }, + textureFactory = { size -> + gl.defaultWebGLTexture(size) + }, + initialSize = size + ) } } ?: return null - - SideEffect(size) { surface.size = size } - DisposableEffect(surface) { onDispose { surface.dispose() } } - return surface + SideEffect(size) { renderTarget.size = size } + DisposableEffect(renderTarget) { onDispose { renderTarget.dispose() } } + return renderTarget } private fun IntSize.coerceAtLeastOnePixel(): IntSize = diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLTextureDraw.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLTextureDraw.web.kt similarity index 72% rename from compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLTextureDraw.web.kt rename to compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLTextureDraw.web.kt index 37b6710aba86f..ce31e24c596a1 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/graphics/webgl/WebGLTextureDraw.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLTextureDraw.web.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package androidx.compose.ui.graphics.webgl +package androidx.compose.ui.platform.webgl import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.geometry.Offset @@ -27,37 +27,29 @@ import androidx.compose.ui.layout.ContentScale import org.jetbrains.skia.Rect /** - * Draws the latest frame rendered into [surface]. + * Draws the latest frame from [renderTarget]. + * Does nothing when [renderTarget] has no frame ready yet. * - * Nothing is rendered here: the GL work already happened in this frame's - * [androidx.compose.runtime.withFrameNanos] callback, so this only records a draw of an image that - * already belongs to Skia. That is what makes it safe inside graphics layers such as `clip`, `blur` - * or `graphicsLayer`, and what makes it possible to draw one surface several times per frame with - * different transformations. + * Performs no GL rendering directly — only records a draw of the pre-rendered image. + * Safe for graphics layers (`clip`, `blur`) and multiple draws per frame. Automatically + * invalidates drawing on new frames without triggering recomposition. * - * Reading [WebGLTextureSurface.invalidation] is part of the draw, so a newly rendered frame - * invalidates the drawing without recomposing anything. - * - * Draws nothing while [WebGLTextureSurface.image] is still `null`, i.e. before the first frame has - * been rendered. - * - * @param surface the surface to draw - * @param dstOffset the top-left corner of the destination, in local coordinates - * @param dstSize the size of the destination; defaults to the whole draw scope - * @param contentScale how the texture is fitted into the destination when their aspect ratios - * differ + * @param renderTarget Target surface to draw. + * @param dstOffset Top-left destination offset in local coordinates. + * @param dstSize Destination size (defaults to full draw bounds). + * @param contentScale Scaling behavior when aspect ratios differ. */ @ExperimentalComposeUiApi fun DrawScope.drawWebGLTexture( - surface: WebGLTextureSurface, + renderTarget: WebGLRenderTarget, dstOffset: Offset = Offset.Zero, dstSize: Size = size, contentScale: ContentScale = ContentScale.Crop, ) { // Schedules the next redraw once this frame's content is rendered, without recomposing. - surface.invalidation.value + renderTarget.observeInvalidation() - val image = surface.image ?: return + val image = renderTarget.image ?: return if (!dstSize.isSpecified || dstSize.width <= 0f || dstSize.height <= 0f) return val srcSize = Size(image.width.toFloat(), image.height.toFloat()) From c7fb01654783652038613d636530aa3312438697 Mon Sep 17 00:00:00 2001 From: "Oleksandr.Karpovich" Date: Thu, 20 Aug 2026 15:02:59 +0200 Subject: [PATCH 12/26] add video player demo --- .../androidx/compose/mpp/demo/Main.web.kt | 4 +- .../mpp/demo/webgl/VideoWebGlDemo.web.kt | 314 ++++++++++++++++++ .../mpp/demo/webgl/WebGLDemoScreens.kt | 26 ++ 3 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/VideoWebGlDemo.web.kt create mode 100644 compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/WebGLDemoScreens.kt diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/Main.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/Main.web.kt index 0a74d7f9f0197..75e0691583f3f 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/Main.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/Main.web.kt @@ -21,8 +21,10 @@ import androidx.compose.mpp.demo.components.text.loadResource import androidx.compose.mpp.demo.interops.HtmlInteropDemos import androidx.compose.mpp.demo.webgl.PlainWebGlScreen import androidx.compose.mpp.demo.webgl.ThreeJsTextureAdoptionScreen +import androidx.compose.mpp.demo.webgl.VideoWebGlScreen import androidx.compose.runtime.LaunchedEffect import androidx.compose.mpp.demo.embedded.embeddedScrollDemo +import androidx.compose.mpp.demo.webgl.WebGLDemoScreen import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.ExperimentalComposeUiApi @@ -67,7 +69,7 @@ fun defaultComposeDemo() { val fontsLoaded = remember { mutableStateOf(false) } val app = remember { App( extraScreens = listOf( - Screen.Selection("WebGL", ThreeJsTextureAdoptionScreen, PlainWebGlScreen), + WebGLDemoScreen, BugsScreen, Screen.Example("Web Clipboard API example") { WebClipboardDemo() diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/VideoWebGlDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/VideoWebGlDemo.web.kt new file mode 100644 index 0000000000000..dec96cf75838f --- /dev/null +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/VideoWebGlDemo.web.kt @@ -0,0 +1,314 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalComposeUiApi::class) + +package androidx.compose.mpp.demo.webgl + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.mpp.demo.Screen +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.webgl.WebGLRenderScope +import androidx.compose.ui.platform.webgl.WebGLRenderTarget +import androidx.compose.ui.platform.webgl.drawWebGLTexture +import androidx.compose.ui.platform.webgl.rememberWebGLRenderTarget +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import org.khronos.webgl.WebGLTexture +import org.khronos.webgl.WebGLRenderingContext +import org.khronos.webgl.WebGLRenderingContext.Companion.CLAMP_TO_EDGE +import org.khronos.webgl.WebGLRenderingContext.Companion.COLOR_BUFFER_BIT +import org.khronos.webgl.WebGLRenderingContext.Companion.FRAGMENT_SHADER +import org.khronos.webgl.WebGLRenderingContext.Companion.LINEAR +import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_2D +import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_MAG_FILTER +import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_MIN_FILTER +import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_WRAP_S +import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_WRAP_T +import org.khronos.webgl.WebGLRenderingContext.Companion.TRIANGLE_STRIP +import org.khronos.webgl.WebGLRenderingContext.Companion.VERTEX_SHADER +import org.khronos.webgl.WebGLProgram +import org.khronos.webgl.WebGLUniformLocation +import kotlin.js.JsAny +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.delay + +private const val VIDEO_URL = + "http://docs.evostream.com/sample_content/assets/bunny.mp4" + +val VideoWebGlScreen = Screen.Example("WebGL video") { VideoWebGlDemo() } + +@Composable +private fun VideoWebGlDemo() { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("HTML video uploaded to a WebGL texture and drawn by Compose") + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + VideoPlayer(modifier = Modifier.fillMaxWidth(0.8f).aspectRatio(16f / 9f)) + } + } +} + +@Composable +private fun VideoPlayer( + modifier: Modifier, +) { + val renderTarget = rememberWebGLRenderTarget(IntSize(1280, 720)) ?: return + val videoRenderer = remember(renderTarget) { + VideoTextureRenderer(renderTarget.webGLContext).also { it.init(VIDEO_URL) } + } + + var isPlaying by remember { mutableStateOf(false) } + var controlsVisible by remember { mutableStateOf(true) } + var pointerActivity by remember { mutableStateOf(0) } + + Box( + modifier = modifier.pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + if (event.type == PointerEventType.Move) pointerActivity++ + } + } + }, + contentAlignment = Alignment.Center, + ) { + Canvas(Modifier.fillMaxSize()) { + drawWebGLTexture(renderTarget) + } + if (!isPlaying || controlsVisible) { + Box( + Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.28f)) + ) + Button( + onClick = { + if (isPlaying) { + videoRenderer.pause() + isPlaying = false + } else { + videoRenderer.play() + isPlaying = true + } + }, + ) { + Text(if (isPlaying) "Pause" else "Play") + } + } + } + + LaunchedEffect(pointerActivity, isPlaying) { + controlsVisible = true + if (isPlaying) { + delay(1.seconds) + controlsVisible = false + } + } + + DisposableEffect(videoRenderer, renderTarget) { + onDispose { + videoRenderer.stop() + videoRenderer.dispose() + renderTarget.restoreGLState() + } + } + + LaunchedEffect(videoRenderer, renderTarget) { + while (true) { + withFrameNanos { frameTimeNanos -> + renderTarget.render { videoRenderer.renderFrame(this, frameTimeNanos) } + } + } + } +} + +private class VideoTextureRenderer(private val gl: WebGLRenderingContext) { + private val program: WebGLProgram = + gl.createProgram(VERTEX_SHADER_SOURCE, FRAGMENT_SHADER_SOURCE) + private val videoUniform: WebGLUniformLocation? = gl.getUniformLocation(program, "videoTexture") + private var video: JsAny? = null + private var texture: WebGLTexture? = null + private var textureAllocated = false + + fun init(url: String) { + video = createVideo(url) + texture = gl.createTexture() ?: error("gl.createTexture() returned null") + gl.bindTexture(TEXTURE_2D, texture) + gl.texParameteri(TEXTURE_2D, TEXTURE_MIN_FILTER, LINEAR) + gl.texParameteri(TEXTURE_2D, TEXTURE_MAG_FILTER, LINEAR) + gl.texParameteri(TEXTURE_2D, TEXTURE_WRAP_S, CLAMP_TO_EDGE) + gl.texParameteri(TEXTURE_2D, TEXTURE_WRAP_T, CLAMP_TO_EDGE) + gl.bindTexture(TEXTURE_2D, null) + } + + fun renderFrame(scope: WebGLRenderScope, frameTimeNanos: Long) { + val video = video ?: return + val texture = texture ?: return + if (!textureAllocated) { + if (!allocateVideoTexture(gl, texture, video)) return + textureAllocated = true + } + if (!uploadVideoFrame(gl, texture, video)) return + + with(scope) { + webGLContext.viewport(0, 0, size.width, size.height) + webGLContext.disable(org.khronos.webgl.WebGLRenderingContext.Companion.SCISSOR_TEST) + webGLContext.clearColor(0f, 0f, 0f, 0f) + webGLContext.clear(COLOR_BUFFER_BIT) + webGLContext.useProgram(program) + webGLContext.activeTexture(org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE0) + webGLContext.bindTexture(TEXTURE_2D, texture) + webGLContext.uniform1i(videoUniform, 0) + webGLContext.drawArrays(TRIANGLE_STRIP, 0, 4) + webGLContext.bindTexture(TEXTURE_2D, null) + } + } + + fun play() { + video?.let(::playVideo) + } + + fun pause() { + video?.let(::pauseVideo) + } + + fun stop() { + video?.let(::stopVideo) + video = null + } + + fun dispose() { + stop() + texture?.let(gl::deleteTexture) + texture = null + textureAllocated = false + gl.deleteProgram(program) + } + + companion object { + private val VERTEX_SHADER_SOURCE = """ + #version 300 es + out vec2 uv; + const vec2 positions[4] = vec2[4]( + vec2(-1.0, -1.0), vec2(1.0, -1.0), vec2(-1.0, 1.0), vec2(1.0, 1.0) + ); + void main() { + vec2 position = positions[gl_VertexID]; + uv = vec2((position.x + 1.0) * 0.5, 1.0 - (position.y + 1.0) * 0.5); + gl_Position = vec4(position, 0.0, 1.0); + } + """.trimIndent() + + private val FRAGMENT_SHADER_SOURCE = """ + #version 300 es + precision mediump float; + uniform sampler2D videoTexture; + in vec2 uv; + out vec4 color; + void main() { color = texture(videoTexture, uv); } + """.trimIndent() + } +} + +private fun createVideo(url: String): JsAny = js( + """(function() { + const video = document.createElement('video'); + video.crossOrigin = 'anonymous'; + video.muted = false; + video.loop = true; + video.playsInline = true; + video.src = url; + video.load(); + return video; + })()""" +) + +private fun playVideo(video: JsAny): Unit = js("video.play().catch(function() {})") +private fun pauseVideo(video: JsAny): Unit = js("video.pause()") +private fun stopVideo(video: JsAny): Unit = js("video.pause()") + +private fun allocateVideoTexture( + gl: WebGLRenderingContext, + texture: WebGLTexture, + video: JsAny, +): Boolean = js( + """(function() { + if (video.readyState < 2 || video.videoWidth === 0) return false; + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, video.videoWidth, video.videoHeight); + return true; + })()""" +) + +private fun uploadVideoFrame( + gl: WebGLRenderingContext, + texture: WebGLTexture, + video: JsAny, +): Boolean = js( + """(function() { + if (video.readyState < 2 || video.videoWidth === 0) return false; + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, video); + return true; + })()""" +) + +private fun WebGLRenderingContext.createProgram( + vertexSource: String, + fragmentSource: String, +): WebGLProgram { + val program = createProgram() ?: error("gl.createProgram() returned null") + for ((type, source) in listOf( + VERTEX_SHADER to vertexSource, + FRAGMENT_SHADER to fragmentSource + )) { + val shader = createShader(type) ?: error("gl.createShader() returned null") + shaderSource(shader, source) + compileShader(shader) + check(getShaderInfoLog(shader).isNullOrBlank()) + attachShader(program, shader) + deleteShader(shader) + } + linkProgram(program) + check(getProgramInfoLog(program).isNullOrBlank()) + return program +} diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/WebGLDemoScreens.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/WebGLDemoScreens.kt new file mode 100644 index 0000000000000..0752af1a74c8f --- /dev/null +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/WebGLDemoScreens.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.mpp.demo.webgl + +import androidx.compose.mpp.demo.Screen + +val WebGLDemoScreen = Screen.Selection( + "WebGL", + ThreeJsTextureAdoptionScreen, + PlainWebGlScreen, + VideoWebGlScreen +) \ No newline at end of file From da8a96909a34eb923f37ebd3144ce486dedd01ac Mon Sep 17 00:00:00 2001 From: "Oleksandr.Karpovich" Date: Thu, 20 Aug 2026 17:08:58 +0200 Subject: [PATCH 13/26] add html-in-canvas demo --- .../androidx/compose/mpp/demo/Main.web.kt | 1 + .../demo/webgl/HtmlInCanvasWebGlDemo.web.kt | 317 ++++++++++++++++++ .../mpp/demo/webgl/WebGLDemoScreens.kt | 3 +- 3 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/HtmlInCanvasWebGlDemo.web.kt diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/Main.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/Main.web.kt index 75e0691583f3f..8c59601462afa 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/Main.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/Main.web.kt @@ -22,6 +22,7 @@ import androidx.compose.mpp.demo.interops.HtmlInteropDemos import androidx.compose.mpp.demo.webgl.PlainWebGlScreen import androidx.compose.mpp.demo.webgl.ThreeJsTextureAdoptionScreen import androidx.compose.mpp.demo.webgl.VideoWebGlScreen +import androidx.compose.mpp.demo.webgl.HtmlInCanvasWebGlScreen import androidx.compose.runtime.LaunchedEffect import androidx.compose.mpp.demo.embedded.embeddedScrollDemo import androidx.compose.mpp.demo.webgl.WebGLDemoScreen diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/HtmlInCanvasWebGlDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/HtmlInCanvasWebGlDemo.web.kt new file mode 100644 index 0000000000000..3905eecd25e4a --- /dev/null +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/HtmlInCanvasWebGlDemo.web.kt @@ -0,0 +1,317 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + */ + +@file:OptIn(ExperimentalComposeUiApi::class) + +package androidx.compose.mpp.demo.webgl + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Text +import androidx.compose.mpp.demo.Screen +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.webgl.WebGLRenderScope +import androidx.compose.ui.platform.webgl.drawWebGLTexture +import androidx.compose.ui.platform.webgl.rememberWebGLRenderTarget +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import kotlin.math.roundToInt +import org.khronos.webgl.WebGLProgram +import org.khronos.webgl.WebGLRenderingContext +import org.khronos.webgl.WebGLRenderingContext.Companion.CLAMP_TO_EDGE +import org.khronos.webgl.WebGLRenderingContext.Companion.FRAGMENT_SHADER +import org.khronos.webgl.WebGLRenderingContext.Companion.LINEAR +import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_2D +import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_MAG_FILTER +import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_MIN_FILTER +import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_WRAP_S +import org.khronos.webgl.WebGLRenderingContext.Companion.TEXTURE_WRAP_T +import org.khronos.webgl.WebGLRenderingContext.Companion.TRIANGLE_STRIP +import org.khronos.webgl.WebGLRenderingContext.Companion.VERTEX_SHADER +import org.khronos.webgl.WebGLTexture +import kotlin.js.JsAny +import org.w3c.dom.HTMLDivElement +import org.w3c.dom.HTMLElement + +val HtmlInCanvasWebGlScreen = Screen.Example("HTML in WebGL") { HtmlInCanvasWebGlDemo() } + +@Composable +private fun HtmlInCanvasWebGlDemo() { + var boundsPx by remember { mutableStateOf(IntSize(640, 360)) } + var originPx by remember { mutableStateOf(Offset.Zero) } + + val target = rememberWebGLRenderTarget(boundsPx) ?: return + val htmlRenderer = remember(target) { HtmlTextureRenderer(target.webGLContext, target.htmlCanvas) } + val supported = remember(htmlRenderer) { htmlRenderer.initialize() } + + DisposableEffect(htmlRenderer, target) { + onDispose { + htmlRenderer.dispose() + target.restoreGLState() + } + } + + if (!supported) { + Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("HTML-in-Canvas is not available in this browser.") + Text("Try Chrome Canary with the canvas-draw-element flag enabled and the HTML-in-Canvas origin trial.") + } + return + } + + val density = LocalDensity.current.density + + SideEffect { + htmlRenderer.syncElementBox( + widthCss = boundsPx.width / density, + heightCss = boundsPx.height / density, + leftCss = originPx.x / density, + topCss = originPx.y / density, + ) + } + + LaunchedEffect(htmlRenderer, target) { + while (true) { + withFrameNanos { frameTimeNanos -> + target.render { htmlRenderer.renderFrame(this, frameTimeNanos) } + } + } + } + + var circleOffset by remember { mutableStateOf(Offset.Zero) } + + Box(modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + event.changes.fastForEach { + circleOffset = it.position + } + } + } + }) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("Interactive HTML rendered into a WebGL texture") + Box(Modifier.fillMaxWidth().aspectRatio(640f / 360f)) { + Canvas( + Modifier.fillMaxSize() + .onGloballyPositioned { coordinates -> + val bounds = coordinates.boundsInWindow() + boundsPx = + IntSize(bounds.width.roundToInt(), bounds.height.roundToInt()) + originPx = Offset(bounds.left, bounds.top) + } + ) { + drawWebGLTexture(target) + } + } + } + + Box(modifier = Modifier + .graphicsLayer { + translationX = circleOffset.x + translationY = circleOffset.y + } + .clip(CircleShape) + .size(100.dp) + .background(Color.Gray.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center + ) { + Text("Compose") + } + } +} + +private class HtmlTextureRenderer( + private val gl: WebGLRenderingContext, + private val canvas: org.w3c.dom.HTMLCanvasElement, +) { + private val program = gl.createProgram(VERTEX_SHADER_SOURCE, FRAGMENT_SHADER_SOURCE) + private val texture = gl.createTexture() ?: error("gl.createTexture() returned null") + private var element: HTMLElement? = null + + fun initialize(): Boolean { + val element = createInteractiveElement(canvas) ?: return false + this.element = element + gl.bindTexture(TEXTURE_2D, texture) + gl.texParameteri(TEXTURE_2D, TEXTURE_MIN_FILTER, LINEAR) + gl.texParameteri(TEXTURE_2D, TEXTURE_MAG_FILTER, LINEAR) + gl.texParameteri(TEXTURE_2D, TEXTURE_WRAP_S, CLAMP_TO_EDGE) + gl.texParameteri(TEXTURE_2D, TEXTURE_WRAP_T, CLAMP_TO_EDGE) + gl.bindTexture(TEXTURE_2D, null) + return true + } + + fun syncElementBox(widthCss: Float, heightCss: Float, leftCss: Float, topCss: Float) { + val element = element ?: return + if (widthCss <= 0f || heightCss <= 0f) return + syncElementBox(element, widthCss.toDouble(), heightCss.toDouble(), leftCss.toDouble(), topCss.toDouble()) + } + + fun renderFrame(scope: WebGLRenderScope, frameTimeNanos: Long) { + val element = element ?: return + if (!uploadElement(gl, texture, element, scope.size.width, scope.size.height)) return + with(scope) { + webGLContext.viewport(0, 0, size.width, size.height) + webGLContext.disable(WebGLRenderingContext.SCISSOR_TEST) + webGLContext.clearColor(0f, 0f, 0f, 0f) + webGLContext.clear(WebGLRenderingContext.COLOR_BUFFER_BIT) + webGLContext.useProgram(program) + webGLContext.activeTexture(WebGLRenderingContext.TEXTURE0) + webGLContext.bindTexture(TEXTURE_2D, texture) + webGLContext.uniform1i(gl.getUniformLocation(program, "source"), 0) + webGLContext.drawArrays(TRIANGLE_STRIP, 0, 4) + webGLContext.bindTexture(TEXTURE_2D, null) + } + } + + fun dispose() { + element?.let(::removeElement) + gl.deleteTexture(texture) + gl.deleteProgram(program) + } + + companion object { + private val VERTEX_SHADER_SOURCE = """ + #version 300 es + out vec2 uv; + const vec2 p[4] = vec2[4](vec2(-1,-1), vec2(1,-1), vec2(-1,1), vec2(1,1)); + void main() { gl_Position = vec4(p[gl_VertexID], 0, 1); uv = vec2((p[gl_VertexID].x+1.)*.5, 1.-(p[gl_VertexID].y+1.)*.5); } + """.trimIndent() + private val FRAGMENT_SHADER_SOURCE = """ + #version 300 es + precision mediump float; + uniform sampler2D source; + in vec2 uv; + out vec4 color; + void main() { color = texture(source, uv); } + """.trimIndent() + } +} + +/** Returns `null` when the HTML-in-Canvas API is unavailable. */ +private fun createInteractiveElement(canvas: org.w3c.dom.HTMLCanvasElement): HTMLDivElement? = js( + """(function() { + if (!('texElementImage2D' in WebGL2RenderingContext.prototype)) return null; + // Opt canvas children into layout/hit-testing. Must be set before the child is appended. + if ('layoutSubtree' in canvas) canvas.layoutSubtree = true; + else canvas.setAttribute('layoutsubtree', ''); + const card = document.createElement('div'); + card.style.cssText = 'user-select:text;box-sizing:border-box;transform-origin:0 0;pointer-events:auto;padding:48px;background:linear-gradient(135deg,#182848,#4b6cb7);color:white;font:28px sans-serif;text-align:center;'; + card.innerHTML = 'HTML in Canvas
Real DOM text and controls

'; + const button = card.querySelector('button'); + const output = card.querySelector('p'); + let clicks = 0; + button.addEventListener('click', function(event) { + clicks++; + output.textContent = '✅ The DOM button works: ' + clicks + + (clicks === 1 ? ' click' : ' clicks'); + }); + canvas.appendChild(card); + if (canvas.requestPaint) canvas.requestPaint(); + return card; + })(canvas)""" +) + +/** + * Aligns the element's DOM box with the box it is drawn into, so that hit testing, focus and + * accessibility (which all use the DOM location) match what the user sees. The element is a child + * of the canvas, so it is laid out at the canvas' origin; a plain translation is enough as long as + * the CSS size equals the destination size. For non-trivial draw transforms use + * `canvas.getElementTransform(element, drawTransform)` instead. + */ +private fun syncElementBox( + element: HTMLElement, + widthCss: Double, + heightCss: Double, + leftCss: Double, + topCss: Double, +): Unit = js( + """(function() { + element.style.width = widthCss + 'px'; + element.style.height = heightCss + 'px'; + element.style.transformOrigin = '0 0'; + element.style.transform = 'translate(' + leftCss + 'px, ' + topCss + 'px)'; + })()""" +) + +private fun uploadElement( + gl: WebGLRenderingContext, + texture: WebGLTexture, + element: HTMLElement, + widthPx: Int, + heightPx: Int, +): Boolean = js( + """(function() { + try { + if (!gl.texElementImage2D) return false; + gl.bindTexture(gl.TEXTURE_2D, texture); + try { + // Current API + gl.texElementImage2D(gl.TEXTURE_2D, gl.RGBA8, element, + { width: widthPx, height: heightPx }); + } catch (signatureError) { + // Legacy Chrome builds + gl.texElementImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, element); + } + return true; + } catch (error) { + return false; + } + })()""" +) + +private fun removeElement(element: JsAny): Unit = js("element.remove()") + +private fun WebGLRenderingContext.createProgram(vertex: String, fragment: String): WebGLProgram { + val program = createProgram() ?: error("gl.createProgram() returned null") + for ((type, source) in listOf(VERTEX_SHADER to vertex, FRAGMENT_SHADER to fragment)) { + val shader = createShader(type) ?: error("gl.createShader() returned null") + shaderSource(shader, source) + compileShader(shader) + check(getShaderInfoLog(shader).isNullOrBlank()) + attachShader(program, shader) + deleteShader(shader) + } + linkProgram(program) + check(getProgramInfoLog(program).isNullOrBlank()) + return program +} diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/WebGLDemoScreens.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/WebGLDemoScreens.kt index 0752af1a74c8f..3e77f30e82d6d 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/WebGLDemoScreens.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/WebGLDemoScreens.kt @@ -22,5 +22,6 @@ val WebGLDemoScreen = Screen.Selection( "WebGL", ThreeJsTextureAdoptionScreen, PlainWebGlScreen, - VideoWebGlScreen + VideoWebGlScreen, + HtmlInCanvasWebGlScreen ) \ No newline at end of file From 6c6088fdb3bc971eacef13f6d253dd1daad38b2a Mon Sep 17 00:00:00 2001 From: "Oleksandr.Karpovich" Date: Thu, 20 Aug 2026 17:34:57 +0200 Subject: [PATCH 14/26] api dump --- compose/ui/ui/api/ui.klib.api | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/compose/ui/ui/api/ui.klib.api b/compose/ui/ui/api/ui.klib.api index 9690a920fe643..6b9c5c5e67895 100644 --- a/compose/ui/ui/api/ui.klib.api +++ b/compose/ui/ui/api/ui.klib.api @@ -4935,6 +4935,9 @@ final val androidx.compose.ui.dom/domEventOrNull // androidx.compose.ui.dom/domE // Targets: [js, wasmJs] final val androidx.compose.ui.input.pointer/androidx_compose_ui_input_pointer_DummyPointerIcon$stableprop // androidx.compose.ui.input.pointer/androidx_compose_ui_input_pointer_DummyPointerIcon$stableprop|#static{}androidx_compose_ui_input_pointer_DummyPointerIcon$stableprop[0] +// Targets: [js, wasmJs] +final val androidx.compose.ui.platform.webgl/androidx_compose_ui_platform_webgl_WebGLRenderTarget$stableprop // androidx.compose.ui.platform.webgl/androidx_compose_ui_platform_webgl_WebGLRenderTarget$stableprop|#static{}androidx_compose_ui_platform_webgl_WebGLRenderTarget$stableprop[0] + // Targets: [js, wasmJs] final val androidx.compose.ui.platform/androidx_compose_ui_platform_W3CTemporaryClipboard$stableprop // androidx.compose.ui.platform/androidx_compose_ui_platform_W3CTemporaryClipboard$stableprop|#static{}androidx_compose_ui_platform_W3CTemporaryClipboard$stableprop[0] @@ -4974,6 +4977,9 @@ final fun androidx.compose.ui.input.pointer/PointerIcon(kotlin/String): androidx // Targets: [js, wasmJs] final fun androidx.compose.ui.input.pointer/androidx_compose_ui_input_pointer_DummyPointerIcon$stableprop_getter(): kotlin/Int // androidx.compose.ui.input.pointer/androidx_compose_ui_input_pointer_DummyPointerIcon$stableprop_getter|androidx_compose_ui_input_pointer_DummyPointerIcon$stableprop_getter(){}[0] +// Targets: [js, wasmJs] +final fun androidx.compose.ui.platform.webgl/androidx_compose_ui_platform_webgl_WebGLRenderTarget$stableprop_getter(): kotlin/Int // androidx.compose.ui.platform.webgl/androidx_compose_ui_platform_webgl_WebGLRenderTarget$stableprop_getter|androidx_compose_ui_platform_webgl_WebGLRenderTarget$stableprop_getter(){}[0] + // Targets: [js, wasmJs] final fun androidx.compose.ui.platform/androidx_compose_ui_platform_W3CTemporaryClipboard$stableprop_getter(): kotlin/Int // androidx.compose.ui.platform/androidx_compose_ui_platform_W3CTemporaryClipboard$stableprop_getter|androidx_compose_ui_platform_W3CTemporaryClipboard$stableprop_getter(){}[0] From 522332009f6e4c9911baffa2c95b35d561c7731b Mon Sep 17 00:00:00 2001 From: "Oleksandr.Karpovich" Date: Thu, 20 Aug 2026 18:08:16 +0200 Subject: [PATCH 15/26] simplify api --- .../demo/webgl/HtmlInCanvasWebGlDemo.web.kt | 8 +- .../mpp/demo/webgl/PlainWebGlDemo.web.kt | 9 +- .../mpp/demo/webgl/ThreeJsKnotRenderer.web.kt | 7 +- .../mpp/demo/webgl/VideoWebGlDemo.web.kt | 5 +- .../ui/platform/webgl/WebGLRenderScope.web.kt | 58 ------------ .../platform/webgl/WebGLRenderTarget.web.kt | 90 ++++++++++++------- 6 files changed, 71 insertions(+), 106 deletions(-) delete mode 100644 compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderScope.web.kt diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/HtmlInCanvasWebGlDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/HtmlInCanvasWebGlDemo.web.kt index 3905eecd25e4a..97510a59588bb 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/HtmlInCanvasWebGlDemo.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/HtmlInCanvasWebGlDemo.web.kt @@ -41,7 +41,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.webgl.WebGLRenderScope +import androidx.compose.ui.platform.webgl.WebGLRenderTarget import androidx.compose.ui.platform.webgl.drawWebGLTexture import androidx.compose.ui.platform.webgl.rememberWebGLRenderTarget import androidx.compose.ui.unit.IntSize @@ -186,10 +186,10 @@ private class HtmlTextureRenderer( syncElementBox(element, widthCss.toDouble(), heightCss.toDouble(), leftCss.toDouble(), topCss.toDouble()) } - fun renderFrame(scope: WebGLRenderScope, frameTimeNanos: Long) { + fun renderFrame(target: WebGLRenderTarget, frameTimeNanos: Long) { val element = element ?: return - if (!uploadElement(gl, texture, element, scope.size.width, scope.size.height)) return - with(scope) { + if (!uploadElement(gl, texture, element, target.size.width, target.size.height)) return + with(target) { webGLContext.viewport(0, 0, size.width, size.height) webGLContext.disable(WebGLRenderingContext.SCISSOR_TEST) webGLContext.clearColor(0f, 0f, 0f, 0f) diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt index 090d96a551e27..bdd8716b23b41 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt @@ -46,7 +46,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.webgl.WebGLRenderScope import androidx.compose.ui.platform.webgl.WebGLRenderTarget import androidx.compose.ui.platform.webgl.drawWebGLTexture import androidx.compose.ui.platform.webgl.rememberWebGLRenderTarget @@ -173,8 +172,8 @@ private class TriangleRenderer(private val gl: WebGLRenderingContext) { private var angle = 0f private var previousFrameTimeNanos = 0L - fun render(scope: WebGLRenderScope, frameTimeNanos: Long): Unit = - with(scope) { + fun render(target: WebGLRenderTarget, frameTimeNanos: Long): Unit = + with(target) { val deltaNanos = if (previousFrameTimeNanos == 0L) { 0L } else { @@ -271,8 +270,8 @@ private class PulseRenderer { private var phase = 0f private var previousFrameTimeNanos = 0L - fun render(scope: WebGLRenderScope, frameTimeNanos: Long): Unit = - with(scope) { + fun render(target: WebGLRenderTarget, frameTimeNanos: Long): Unit = + with(target) { val deltaNanos = if (previousFrameTimeNanos == 0L) { 0L } else { diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt index 294c38bd95322..05097133df073 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt @@ -19,7 +19,6 @@ package androidx.compose.mpp.demo.webgl import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.platform.webgl.WebGLRenderScope import androidx.compose.ui.platform.webgl.WebGLRenderTarget /** @@ -57,8 +56,8 @@ private constructor(private val three: ThreeModule, surface: WebGLRenderTarget) private var failed = false private var previousFrameTimeNanos = 0L - fun renderFrame(scope: WebGLRenderScope, frameTimeNanos: Long): Unit = - with(scope) { + fun renderFrame(target: WebGLRenderTarget, frameTimeNanos: Long): Unit = + with(target) { if (failed) return val deltaNanos = if (previousFrameTimeNanos == 0L) 0L @@ -94,7 +93,7 @@ private constructor(private val three: ThreeModule, surface: WebGLRenderTarget) * The render target is only a descriptor for the framebuffer Compose owns, so it has to be * replaced whenever Compose recreated that framebuffer. */ - private fun WebGLRenderScope.ensureRenderTarget(knotScene: ThreeKnotScene): ThreeRenderTarget { + private fun WebGLRenderTarget.ensureRenderTarget(knotScene: ThreeKnotScene): ThreeRenderTarget { val current = renderTarget if (current != null && targetGeneration == generation) return current diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/VideoWebGlDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/VideoWebGlDemo.web.kt index dec96cf75838f..00904dd6f5cfb 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/VideoWebGlDemo.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/VideoWebGlDemo.web.kt @@ -44,7 +44,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.webgl.WebGLRenderScope import androidx.compose.ui.platform.webgl.WebGLRenderTarget import androidx.compose.ui.platform.webgl.drawWebGLTexture import androidx.compose.ui.platform.webgl.rememberWebGLRenderTarget @@ -180,7 +179,7 @@ private class VideoTextureRenderer(private val gl: WebGLRenderingContext) { gl.bindTexture(TEXTURE_2D, null) } - fun renderFrame(scope: WebGLRenderScope, frameTimeNanos: Long) { + fun renderFrame(target: WebGLRenderTarget, frameTimeNanos: Long) { val video = video ?: return val texture = texture ?: return if (!textureAllocated) { @@ -189,7 +188,7 @@ private class VideoTextureRenderer(private val gl: WebGLRenderingContext) { } if (!uploadVideoFrame(gl, texture, video)) return - with(scope) { + with(target) { webGLContext.viewport(0, 0, size.width, size.height) webGLContext.disable(org.khronos.webgl.WebGLRenderingContext.Companion.SCISSOR_TEST) webGLContext.clearColor(0f, 0f, 0f, 0f) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderScope.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderScope.web.kt deleted file mode 100644 index 4c77928a62973..0000000000000 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderScope.web.kt +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright 2026 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.ui.platform.webgl - -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.unit.IntSize -import org.khronos.webgl.WebGLFramebuffer -import org.khronos.webgl.WebGLRenderingContext -import org.w3c.dom.HTMLCanvasElement - -/** - * Scope provided to external renderers for drawing a frame into a [WebGLRenderTarget]. - * - * Valid only inside [WebGLRenderTarget.render]—do not retain this scope or its resources. - * Compose automatically restores the default framebuffer and clears cached GL state after each call. - * - * **Renderer expectations:** - * - Do not rely on GL state persisting between frames. - * - Do not resize [htmlCanvas] or manipulate context lifecycle. - * - Do not delete [framebuffer] or its attached textures. - */ -@ExperimentalComposeUiApi -sealed interface WebGLRenderScope { - /** The WebGL2 context shared with Compose */ - val webGLContext: WebGLRenderingContext - - /** The `` element owned by Compose, exposed for third-party library initialization. */ - val htmlCanvas: HTMLCanvasElement - - /** - * The framebuffer owned by this render target. It is bound when the render block starts, - * but external code may temporarily change the binding and must restore it before returning. - */ - val framebuffer: WebGLFramebuffer - - /** Framebuffer dimensions in pixels. */ - val size: IntSize - - /** - * Incremented whenever [framebuffer] or its attachments are recreated (initial setup or size changes). - * Renderers should check this value to invalidate cached viewports, matrices, or descriptors. - */ - val generation: Int -} diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTarget.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTarget.web.kt index 9e34d5248cdfe..65d18053e1867 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTarget.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTarget.web.kt @@ -60,7 +60,9 @@ private const val GL_DEPTH_STENCIL_ATTACHMENT = 0x821A * content in the same . * * Obtain an instance with [rememberWebGLRenderTarget]. - * [render] allows callers to execute custom WebGL rendering code using the provided context. + * [render] allows callers to execute custom WebGL rendering code with this target as the receiver: + * inside the block, [framebuffer] is bound and [size] describes it. + * * After a successful [render], the target’s texture will be implicitly used by [drawWebGLTexture]. * Use [drawWebGLTexture] to draw the frame. * @@ -73,6 +75,7 @@ private const val GL_DEPTH_STENCIL_ATTACHMENT = 0x821A * withFrameNanos { frameTimeNanos -> * renderTarget.render { * val phase = (frameTimeNanos % 1_000_000_000L).toFloat() / 1_000_000_000f + * webGLContext.viewport(0, 0, size.width, size.height) * webGLContext.clearColor(phase, 0.2f, 0.4f, 1f) * webGLContext.clear(WebGLRenderingContext.COLOR_BUFFER_BIT) * } @@ -94,16 +97,49 @@ internal constructor( initialSize: IntSize, ) { + private var requestedSize: IntSize = initialSize.coerceAtLeastOnePixel() + /** - * Color texture size in pixels (minimum 1x1). + * Size in pixels of the currently allocated color texture, or [IntSize.Zero] before the first + * successful [render]. * - * Modifying this triggers a texture reallocation on the next [render] and bumps - * [WebGLRenderScope.generation]. Avoid updating this per-frame due to allocation cost. + * This is the size of the framebuffer that [render] binds — use it for `viewport` and projection + * math. A size change requested via [rememberWebGLRenderTarget] is reflected here only after the + * [render] that applies it. */ - var size: IntSize = initialSize.coerceAtLeastOnePixel() - set(value) { - field = value.coerceAtLeastOnePixel() - } + var size: IntSize = IntSize.Zero + private set + + /** + * Requests a new color texture size in pixels (coerced to at least 1x1). + * + * The request is applied by the next [render], which reallocates the texture and bumps + * [generation]; [size] keeps describing the previous allocation until then. + * + * Internal: the size is owned by the `size` argument of [rememberWebGLRenderTarget], so that + * the composition stays the single source of truth. Reallocation is costly, so avoid changing + * that argument per-frame. + */ + internal fun requestNewSize(size: IntSize) { + requestedSize = size.coerceAtLeastOnePixel() + } + + /** + * The framebuffer owned by this render target, or `null` before the first successful [render]. + * + * It is bound for the duration of the [render] block. Code that changes the binding must restore + * it before returning, and must never delete the framebuffer or its attachments. + */ + var framebuffer: WebGLFramebuffer? = null + private set + + /** + * Incremented whenever [framebuffer] or its attachments are recreated (initial setup or size + * changes). Renderers should check this value to invalidate cached viewports, matrices, or + * descriptors. + */ + var generation: Int = 0 + private set /** * A lightweight Skiko [Image] wrapping [adoptedTexture]'s GPU memory without copying pixel data. @@ -123,10 +159,7 @@ internal constructor( } private var adoptedTexture: AdoptedGLTexture? = null - private var framebuffer: WebGLFramebuffer? = null private var depthStencil: WebGLRenderbuffer? = null - private var webGLRenderScope: WegGLRenderScopeImpl? = null - private var generation = 0 private var isDisposed = false private var isRendering = false @@ -141,18 +174,18 @@ internal constructor( * * @return `false` (and skips [block]) if the GPU context is unavailable, such as before Compose's first frame. */ - fun render(block: WebGLRenderScope.() -> Unit): Boolean { + fun render(block: WebGLRenderTarget.() -> Unit): Boolean { if (isDisposed) return false check(!isRendering) { "render() is already running: it must not be called from within another render() call, " + "nor from a draw or layout scope" } val context = directContext() ?: return false - val scope = prepareWebGLRenderScope(context, size) + val framebuffer = prepareFramebuffer(context, requestedSize) isRendering = true - webGLContext.bindFramebuffer(FRAMEBUFFER, scope.framebuffer) + webGLContext.bindFramebuffer(FRAMEBUFFER, framebuffer) try { - scope.block() + block() } finally { isRendering = false webGLContext.bindFramebuffer(FRAMEBUFFER, null) @@ -179,12 +212,12 @@ internal constructor( return true } - private fun prepareWebGLRenderScope( + private fun prepareFramebuffer( context: DirectContext, size: IntSize, - ): WegGLRenderScopeImpl { + ): WebGLFramebuffer { val current = adoptedTexture - if (current != null && current.size == size) return webGLRenderScope!! + if (current != null && current.size == size) return framebuffer!! current?.dispose() adoptedTexture = null @@ -223,19 +256,11 @@ internal constructor( "the adopted texture is not a complete framebuffer attachment (status $status)" } - webGLRenderScope = - WegGLRenderScopeImpl(webGLContext, htmlCanvas, framebuffer, size, ++generation) - return webGLRenderScope!! + this.size = size + generation++ + return framebuffer } - private class WegGLRenderScopeImpl( - override val webGLContext: WebGLRenderingContext, - override val htmlCanvas: HTMLCanvasElement, - override val framebuffer: WebGLFramebuffer, - override val size: IntSize, - override val generation: Int, - ) : WebGLRenderScope - /** * Releases the texture, the image and the framebuffer. Called by [rememberWebGLRenderTarget] * when the surface leaves the composition; calling it twice is a no-op. @@ -245,7 +270,7 @@ internal constructor( isDisposed = true adoptedTexture?.dispose() adoptedTexture = null - webGLRenderScope = null + size = IntSize.Zero framebuffer?.let(webGLContext::deleteFramebuffer) framebuffer = null depthStencil?.let(webGLContext::deleteRenderbuffer) @@ -275,7 +300,8 @@ private fun WebGLRenderingContext.defaultWebGLTexture(size: IntSize): WebGLTextu * Remembers a [WebGLRenderTarget] of the given [size], automatically disposing it when * leaving the composition. * - * Updating [size] recreates the underlying GPU resources. + * [size] is the only way to size the target: changing it recreates the underlying GPU resources. + * Reallocation is costly, so avoid changing it per-frame. * * @return The target, or `null` if WebGL2 is unsupported. */ @@ -302,7 +328,7 @@ fun rememberWebGLRenderTarget( ) } } ?: return null - SideEffect(size) { renderTarget.size = size } + SideEffect(size) { renderTarget.requestNewSize(size) } DisposableEffect(renderTarget) { onDispose { renderTarget.dispose() } } return renderTarget } From c29f416e4335890087b36e52cc46f9f07e6aa7f5 Mon Sep 17 00:00:00 2001 From: "Oleksandr.Karpovich" Date: Thu, 20 Aug 2026 20:11:36 +0200 Subject: [PATCH 16/26] update KDocs --- .../demo/webgl/HtmlInCanvasWebGlDemo.web.kt | 2 +- .../mpp/demo/webgl/PlainWebGlDemo.web.kt | 2 +- .../mpp/demo/webgl/ThreeJsKnotRenderer.web.kt | 8 +- .../mpp/demo/webgl/VideoWebGlDemo.web.kt | 2 +- .../ui/platform/webgl/AdoptedGLTexture.web.kt | 6 +- .../platform/webgl/WebGLRenderTarget.web.kt | 118 +++++++++--------- .../ui/platform/webgl/WebGLTextureDraw.web.kt | 18 +-- 7 files changed, 79 insertions(+), 77 deletions(-) diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/HtmlInCanvasWebGlDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/HtmlInCanvasWebGlDemo.web.kt index 97510a59588bb..2768cb58fe2f5 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/HtmlInCanvasWebGlDemo.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/HtmlInCanvasWebGlDemo.web.kt @@ -79,7 +79,7 @@ private fun HtmlInCanvasWebGlDemo() { DisposableEffect(htmlRenderer, target) { onDispose { htmlRenderer.dispose() - target.restoreGLState() + target.markGLStateStale() } } diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt index bdd8716b23b41..39338333ecbee 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt @@ -206,7 +206,7 @@ private class TriangleRenderer(private val gl: WebGLRenderingContext) { */ fun dispose(surface: WebGLRenderTarget) { gl.deleteProgram(program) - surface.restoreGLState() + surface.markGLStateStale() } companion object { diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt index 05097133df073..41da80a58d522 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt @@ -115,15 +115,15 @@ private constructor(private val three: ThreeModule, surface: WebGLRenderTarget) /** * Releases three's own GL objects. Since that touches the context Compose renders through, - * [WebGLRenderTarget.restoreGLState] has to be called afterwards. + * [WebGLRenderTarget.markGLStateStale] has to be called afterwards. */ - fun dispose(surface: WebGLRenderTarget?) { + fun dispose(renderTarget: WebGLRenderTarget?) { knotScene?.let(::disposeKnotScene) knotScene = null renderer?.dispose() renderer = null - renderTarget = null + this@ThreeJsKnotRenderer.renderTarget = null targetGeneration = 0 - surface?.restoreGLState() + renderTarget?.markGLStateStale() } } diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/VideoWebGlDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/VideoWebGlDemo.web.kt index 00904dd6f5cfb..28ec064c77615 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/VideoWebGlDemo.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/VideoWebGlDemo.web.kt @@ -147,7 +147,7 @@ private fun VideoPlayer( onDispose { videoRenderer.stop() videoRenderer.dispose() - renderTarget.restoreGLState() + renderTarget.markGLStateStale() } } diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/AdoptedGLTexture.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/AdoptedGLTexture.web.kt index 023ae86dbfc29..652e571871ee8 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/AdoptedGLTexture.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/AdoptedGLTexture.web.kt @@ -52,6 +52,7 @@ private const val GL_RGBA8 = 0x8058 * @param texture the WebGL texture, living in the same WebGL context as Skia * @param textureId the id Emscripten associates with [texture] * @param image the Skia image which adopted [texture] + * @param size the size of [texture], in pixels */ internal class AdoptedGLTexture( val texture: WebGLTexture, @@ -66,10 +67,11 @@ internal class AdoptedGLTexture( } /** - * Allocates an RGBA8 texture in this context and hands it over to Skia. + * Hands [texture] over to Skia as an RGBA8 image, deleting it again if adoption fails. * * @param context the [DirectContext] Skia renders this canvas with - * @param size the size of the texture, in pixels + * @param size the size of [texture], in pixels + * @param texture the texture to adopt, already allocated in this context */ internal fun WebGLRenderingContext.adoptNewTexture( context: DirectContext, diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTarget.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTarget.web.kt index 65d18053e1867..476cca8c1ec8e 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTarget.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTarget.web.kt @@ -54,17 +54,17 @@ private const val GL_DEPTH24_STENCIL8 = 0x88F0 private const val GL_DEPTH_STENCIL_ATTACHMENT = 0x821A /** - * Represents a render target backed by an offscreen WebGL texture created in the same WebGL context - * that Compose uses for rendering. - * Its primary purpose is to render WebGL content into a texture that can be drawn alongside Compose - * content in the same . + * An offscreen render target that lets WebGL content take part in Compose rendering: WebGL code + * draws into it inside [render], and Compose displays the result with [drawWebGLTexture]. * - * Obtain an instance with [rememberWebGLRenderTarget]. - * [render] allows callers to execute custom WebGL rendering code with this target as the receiver: - * inside the block, [framebuffer] is bound and [size] describes it. + * It takes care of everything that hand-off needs. It owns the GPU resources — a [framebuffer] with + * a color texture and a depth/stencil buffer, in the very WebGL context and `` Compose + * renders with — restores the GL state Compose's renderer expects after each frame, and redraws the + * Compose content once a new frame is ready. Compose draws the color texture as it is, copying no + * pixels. * - * After a successful [render], the target’s texture will be implicitly used by [drawWebGLTexture]. - * Use [drawWebGLTexture] to draw the frame. + * Obtain an instance with [rememberWebGLRenderTarget], which also disposes it when it leaves the + * composition. * * Usage example: * ``` @@ -100,60 +100,47 @@ internal constructor( private var requestedSize: IntSize = initialSize.coerceAtLeastOnePixel() /** - * Size in pixels of the currently allocated color texture, or [IntSize.Zero] before the first - * successful [render]. + * Size in pixels of the [framebuffer], i.e. the area to render into: pass it to + * [WebGLRenderingContext.viewport] and base projection matrices on it. + * [IntSize.Zero] until the first successful [render]. * - * This is the size of the framebuffer that [render] binds — use it for `viewport` and projection - * math. A size change requested via [rememberWebGLRenderTarget] is reflected here only after the - * [render] that applies it. + * Changing the size passed to [rememberWebGLRenderTarget] updates this on the next [render], so + * it always describes the framebuffer the current frame draws into. */ var size: IntSize = IntSize.Zero private set - /** - * Requests a new color texture size in pixels (coerced to at least 1x1). - * - * The request is applied by the next [render], which reallocates the texture and bumps - * [generation]; [size] keeps describing the previous allocation until then. - * - * Internal: the size is owned by the `size` argument of [rememberWebGLRenderTarget], so that - * the composition stays the single source of truth. Reallocation is costly, so avoid changing - * that argument per-frame. - */ + /** Applied by the next [render]; see the `size` parameter of [rememberWebGLRenderTarget]. */ internal fun requestNewSize(size: IntSize) { requestedSize = size.coerceAtLeastOnePixel() } /** - * The framebuffer owned by this render target, or `null` before the first successful [render]. + * The framebuffer to render into, bound for the duration of the [render] block, or `null` + * until the first successful [render]. Exposed for engines that need the raw handle, such as + * three.js. * - * It is bound for the duration of the [render] block. Code that changes the binding must restore - * it before returning, and must never delete the framebuffer or its attachments. + * Rebinding it inside [render] is allowed as long as the binding is restored before returning. + * Deleting it or its attachments is not — they belong to this target. */ var framebuffer: WebGLFramebuffer? = null private set /** - * Incremented whenever [framebuffer] or its attachments are recreated (initial setup or size - * changes). Renderers should check this value to invalidate cached viewports, matrices, or - * descriptors. + * Bumped whenever [framebuffer] and its attachments are recreated, which happens on the first + * [render] and after every size change. Use it to drop anything cached from [framebuffer] or + * [size], such as projection matrices or a third-party render target wrapping them. */ var generation: Int = 0 private set - /** - * A lightweight Skiko [Image] wrapping [adoptedTexture]'s GPU memory without copying pixel data. - * - * Returns `null` if no texture is currently adopted. - */ + /** The Skia image sampling the color texture, or `null` until the first successful [render]. */ internal val image: Image? get() = adoptedTexture?.image private val _invalidation = mutableLongStateOf(0L) - /** - * Observes frame invalidation from a draw operation - */ + /** Makes the calling draw operation repeat whenever a new frame is rendered. */ internal fun observeInvalidation() { _invalidation.value } @@ -164,15 +151,30 @@ internal constructor( private var isRendering = false /** - * Renders a frame of WebGL content into this surface, updates [image], and triggers a Compose redraw. + * Renders one frame into this target and invalidates every [drawWebGLTexture] that displays it, + * so they all show the new frame. + * + * Allocates or reallocates GPU resources if needed, binds [framebuffer], runs [block], then + * restores the GL state Compose's renderer expects. [block] receives this target as its + * receiver, so [size] and [framebuffer] describe the frame being drawn. * - * Must be called within a [withFrameNanos] callback (before Skia samples the frame) and never - * inside a draw or layout scope. + * Prefer calling this from a [withFrameNanos] callback: the frame is then ready before Compose + * draws, so the new content appears immediately. Rendering at another time is allowed, but the + * content only appears in a later Compose frame. * - * Allocates resources as needed, binds the offscreen framebuffer, executes [block], and restores - * the default GL state afterward. + * Never call this from a draw scope, such as a `Canvas` or `Modifier.drawBehind`: the GL state + * would be reset while Compose is drawing the frame, and the invalidation would come from + * within the drawing it invalidates, keeping that drawing repeating with no loop to stop: + * ``` + * Canvas(Modifier.fillMaxSize()) { + * // Wrong: render() must not run while Compose is drawing. + * renderTarget.render { renderer.drawFrame(this) } + * drawWebGLTexture(renderTarget) + * } + * ``` * - * @return `false` (and skips [block]) if the GPU context is unavailable, such as before Compose's first frame. + * @return `false`, skipping [block], if the GPU context is not available yet — which is the + * case until Compose has drawn its first frame. */ fun render(block: WebGLRenderTarget.() -> Unit): Boolean { if (isDisposed) return false @@ -198,18 +200,17 @@ internal constructor( } /** - * Restores the rendering context back to a clean state expected by Compose. + * Marks the GL state as changed outside of [render], so that Compose's renderer stops assuming + * the state it last set is still in place. * - * Compose assumes exclusive control over the underlying graphics context. - * This call informs the context that the GL state was modified outiside of [render]. - * - * Note: Calling this frequently carries a performance penalty due to GL state cache invalidation. + * [render] does this for its own block already, so this is only needed when code touches + * [webGLContext] on its own — typically while setting up or tearing down a third-party engine. + * The renderer then has to reapply its whole state, so avoid calling this per frame. */ - fun restoreGLState(): Boolean { - val context = directContext() ?: return false + fun markGLStateStale() { + val context = directContext() ?: return webGLContext.bindFramebuffer(FRAMEBUFFER, null) context.resetAll() - return true } private fun prepareFramebuffer( @@ -262,8 +263,8 @@ internal constructor( } /** - * Releases the texture, the image and the framebuffer. Called by [rememberWebGLRenderTarget] - * when the surface leaves the composition; calling it twice is a no-op. + * Releases the framebuffer and its attachments. Called by [rememberWebGLRenderTarget] when the + * target leaves the composition; calling it twice is a no-op. */ internal fun dispose() { if (isDisposed) return @@ -297,13 +298,12 @@ private fun WebGLRenderingContext.defaultWebGLTexture(size: IntSize): WebGLTextu } /** - * Remembers a [WebGLRenderTarget] of the given [size], automatically disposing it when - * leaving the composition. + * Remembers a [WebGLRenderTarget] of [size] pixels, disposing it when it leaves the composition. * - * [size] is the only way to size the target: changing it recreates the underlying GPU resources. - * Reallocation is costly, so avoid changing it per-frame. + * This is the only way to size the target: a changed [size] reallocates its GPU resources on the + * next [WebGLRenderTarget.render], so avoid changing it per frame. * - * @return The target, or `null` if WebGL2 is unsupported. + * @return The target, or `null` if the browser does not support WebGL2. */ @ExperimentalComposeUiApi @Composable diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLTextureDraw.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLTextureDraw.web.kt index ce31e24c596a1..6550cc9ed3901 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLTextureDraw.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLTextureDraw.web.kt @@ -27,17 +27,17 @@ import androidx.compose.ui.layout.ContentScale import org.jetbrains.skia.Rect /** - * Draws the latest frame from [renderTarget]. - * Does nothing when [renderTarget] has no frame ready yet. + * Draws the last frame rendered into [renderTarget], or nothing if there is no frame yet. * - * Performs no GL rendering directly — only records a draw of the pre-rendered image. - * Safe for graphics layers (`clip`, `blur`) and multiple draws per frame. Automatically - * invalidates drawing on new frames without triggering recomposition. + * This only records a draw of the already rendered texture, so it issues no GL commands of its own: + * it is safe inside graphics layers such as `clip` and `blur`, and can be called several times per + * frame to show the same frame in several places. Each new frame repeats the drawing on its own, + * without recomposing. * - * @param renderTarget Target surface to draw. - * @param dstOffset Top-left destination offset in local coordinates. - * @param dstSize Destination size (defaults to full draw bounds). - * @param contentScale Scaling behavior when aspect ratios differ. + * @param renderTarget The target whose last frame to draw. + * @param dstOffset Top-left of the destination, in local coordinates. + * @param dstSize Size of the destination. Defaults to the whole draw bounds. + * @param contentScale How to fit the frame into the destination when their aspect ratios differ. */ @ExperimentalComposeUiApi fun DrawScope.drawWebGLTexture( From e8d44bb46d21413daadd3baa7210e01d29fb0ddb Mon Sep 17 00:00:00 2001 From: "Oleksandr.Karpovich" Date: Thu, 20 Aug 2026 20:19:16 +0200 Subject: [PATCH 17/26] fix html demo --- .../compose/mpp/demo/webgl/HtmlInCanvasWebGlDemo.web.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/HtmlInCanvasWebGlDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/HtmlInCanvasWebGlDemo.web.kt index 2768cb58fe2f5..5d91f6d13e6eb 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/HtmlInCanvasWebGlDemo.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/HtmlInCanvasWebGlDemo.web.kt @@ -125,12 +125,12 @@ private fun HtmlInCanvasWebGlDemo() { } }) { Column( - modifier = Modifier.padding(24.dp), + modifier = Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Text("Interactive HTML rendered into a WebGL texture") - Box(Modifier.fillMaxWidth().aspectRatio(640f / 360f)) { + Box(Modifier.fillMaxWidth(0.8f).aspectRatio(640f / 360f)) { Canvas( Modifier.fillMaxSize() .onGloballyPositioned { coordinates -> From c56c9963e386934e91e965e95c63fee0280c96d0 Mon Sep 17 00:00:00 2001 From: "Oleksandr.Karpovich" Date: Thu, 20 Aug 2026 21:21:01 +0200 Subject: [PATCH 18/26] add tests --- .../ui/platform/webgl/WebGLTextureDraw.web.kt | 39 ++- .../platform/webgl/WebGLRenderTargetTests.kt | 293 ++++++++++++++++++ .../webgl/WebGLTexturePlacementTests.kt | 131 ++++++++ 3 files changed, 452 insertions(+), 11 deletions(-) create mode 100644 compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt create mode 100644 compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLTexturePlacementTests.kt diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLTextureDraw.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLTextureDraw.web.kt index 6550cc9ed3901..2a9254615a1cc 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLTextureDraw.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLTextureDraw.web.kt @@ -50,13 +50,33 @@ fun DrawScope.drawWebGLTexture( renderTarget.observeInvalidation() val image = renderTarget.image ?: return - if (!dstSize.isSpecified || dstSize.width <= 0f || dstSize.height <= 0f) return - val srcSize = Size(image.width.toFloat(), image.height.toFloat()) - if (srcSize.width <= 0f || srcSize.height <= 0f) return + val placement = + webGLTexturePlacement(srcSize, dstSize, dstOffset, contentScale) ?: return + + drawIntoCanvas { canvas -> + canvas.skiaCanvas.drawImageRect(image, placement.src, placement.dst) + } +} + +/** Source and destination rectangles for one [drawWebGLTexture] call. */ +internal class WebGLTexturePlacement(val src: Rect, val dst: Rect) + +/** + * Maps a texture of [srcSize] into [dstSize] at [dstOffset] under [contentScale], or returns `null` + * when there is nothing to draw. + */ +internal fun webGLTexturePlacement( + srcSize: Size, + dstSize: Size, + dstOffset: Offset, + contentScale: ContentScale, +): WebGLTexturePlacement? { + if (!dstSize.isSpecified || dstSize.width <= 0f || dstSize.height <= 0f) return null + if (srcSize.width <= 0f || srcSize.height <= 0f) return null val scale = contentScale.computeScaleFactor(srcSize, dstSize) - if (scale.scaleX <= 0f || scale.scaleY <= 0f) return + if (scale.scaleX <= 0f || scale.scaleY <= 0f) return null // Per axis: when the scaled texture covers the destination, the source is cropped; when it does // not, the destination is inset. This yields the expected result for Crop, Fit, FillBounds, @@ -66,13 +86,10 @@ fun DrawScope.drawWebGLTexture( val (srcY, srcHeight, dstY, dstHeight) = axis(srcSize.height, dstSize.height, scale.scaleY, dstOffset.y) - drawIntoCanvas { canvas -> - canvas.skiaCanvas.drawImageRect( - image, - Rect.makeXYWH(srcX, srcY, srcWidth, srcHeight), - Rect.makeXYWH(dstX, dstY, dstWidth, dstHeight), - ) - } + return WebGLTexturePlacement( + src = Rect.makeXYWH(srcX, srcY, srcWidth, srcHeight), + dst = Rect.makeXYWH(dstX, dstY, dstWidth, dstHeight), + ) } private data class AxisPlacement( diff --git a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt new file mode 100644 index 0000000000000..d54f2fdbe3738 --- /dev/null +++ b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt @@ -0,0 +1,293 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalComposeUiApi::class) + +package androidx.compose.ui.platform.webgl + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.OnCanvasTests +import androidx.compose.ui.WebApplicationScope +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.khronos.webgl.WebGLRenderingContext +import org.khronos.webgl.WebGLRenderingContext.Companion.COLOR_BUFFER_BIT +import org.khronos.webgl.WebGLRenderingContext.Companion.NO_ERROR + +/** Opaque red as `0xRRGGBBAA`, chosen because every channel is exact in RGBA8. */ +private const val OPAQUE_RED = (255 shl 24) or 255 + +/** The whole "renderer": clear the target to opaque red, like the ColorPulse demo. */ +private fun WebGLRenderTarget.clearToRed() { + webGLContext.viewport(0, 0, size.width, size.height) + webGLContext.clearColor(1f, 0f, 0f, 1f) + webGLContext.clear(COLOR_BUFFER_BIT) +} + +class WebGLRenderTargetTests : OnCanvasTests { + + /** + * The simplest possible renderer: clear the target to a known color. Verifies that a frame + * reaches the texture, that Compose draws it, and that WebGL reports no error along the way. + */ + @Test + fun clearingToAKnownColorProducesAFrameWithoutGLErrors() = runApplicationTest { + val frames = 3 + var renderTarget: WebGLRenderTarget? = null + var renderedFrames = 0 + var drawnFrames = 0 + + createComposeWindow { + val target = rememberWebGLRenderTarget(IntSize(64, 64)) + renderTarget = target + if (target != null) { + LaunchedEffect(target) { + repeat(frames) { + withFrameNanos { + if (target.render { clearToRed() }) renderedFrames++ + } + } + } + Canvas(Modifier.size(64.dp)) { + drawnFrames++ + drawWebGLTexture(target) + } + } + } + + val target = renderTarget ?: return@runApplicationTest skipWithoutWebGL2() + repeat(frames + 2) { awaitAnimationFrame() } + awaitIdle() + + assertTrue(renderedFrames > 0, "render() never ran the block") + assertTrue(drawnFrames > 0, "drawWebGLTexture() was never called") + + assertEquals(IntSize(64, 64), target.size, "unexpected allocated size") + assertNotNull(target.framebuffer, "framebuffer was not allocated") + assertNotNull(target.image, "the color texture was not adopted") + assertTrue(target.generation > 0, "generation was never bumped") + + // Read the frame back from the target's own framebuffer, which render() keeps bound. + var centerPixel = 0 + var glError = -1 + val rendered = target.render { + clearToRed() + centerPixel = readPixelRgba8(webGLContext, size.width / 2, size.height / 2) + glError = webGLContext.getError() + } + + assertTrue(rendered, "render() did not run after Compose's first frame") + assertEquals(NO_ERROR, glError, "the WebGL context reported an error") + assertEquals( + OPAQUE_RED.toHexString(), + centerPixel.toHexString(), + "the cleared color did not reach the texture" + ) + } + + /** A new size given to [rememberWebGLRenderTarget] must reallocate on the next render. */ + @Test + fun changingTheSizeReallocatesTheTexture() = runApplicationTest { + val requestedSize = mutableStateOf(IntSize(32, 32)) + var renderTarget: WebGLRenderTarget? = null + + createComposeWindow { + val size by requestedSize + val target = rememberWebGLRenderTarget(size) + renderTarget = target + if (target != null) { + Canvas(Modifier.size(32.dp)) { drawWebGLTexture(target) } + } + } + + val target = renderTarget ?: return@runApplicationTest skipWithoutWebGL2() + awaitAnimationFrame() + awaitIdle() + + assertTrue(target.render { clearToRed() }, "the first render() did not run") + assertEquals(IntSize(32, 32), target.size, "unexpected initial size") + val generationBefore = target.generation + + requestedSize.value = IntSize(48, 24) + awaitAnimationFrame() + awaitIdle() + + assertTrue(target.render { clearToRed() }, "render() did not run after the size change") + assertEquals(IntSize(48, 24), target.size, "the new size was not applied") + assertTrue( + target.generation > generationBefore, + "generation did not change although the texture was reallocated" + ) + assertEquals(NO_ERROR, target.webGLContext.getError(), "reallocation reported a GL error") + } + + /** + * The whole pipeline: a frame rendered into the texture must end up in the pixels of the + * Compose canvas, drawn where the composable is. + * + * Reading those pixels needs `preserveDrawingBuffer`, since WebGL discards the drawing buffer + * once the browser has composited it. + */ + @Test + fun theRenderedFrameReachesTheComposeCanvas() = runApplicationTest { + assertTrue(forcePreserveDrawingBuffer(), "could not force preserveDrawingBuffer") + try { + var renderTarget: WebGLRenderTarget? = null + + createComposeWindow { + val target = rememberWebGLRenderTarget(IntSize(64, 64)) + renderTarget = target + if (target != null) { + LaunchedEffect(target) { + repeat(30) { withFrameNanos { target.render { clearToRed() } } } + } + // 100.dp is 200px at the test density, well inside the canvas. + Canvas(Modifier.size(100.dp)) { drawWebGLTexture(target) } + } + } + + val target = renderTarget ?: return@runApplicationTest skipWithoutWebGL2() + val gl = target.webGLContext + assertEquals( + "true", + contextAttribute(gl, "preserveDrawingBuffer"), + "the Compose context ignored the forced attribute" + ) + + // readPixels() counts rows from the bottom, Compose from the top. + val canvasHeight = getCanvas().height + val insideY = canvasHeight - 100 + val outsideY = canvasHeight - 300 + + val inside = awaitCanvasPixel(gl, x = 100, y = insideY, expected = OPAQUE_RED) + assertEquals( + OPAQUE_RED.toHexString(), + inside.toHexString(), + "the texture did not reach the canvas inside the composable" + ) + assertNotEquals( + OPAQUE_RED.toHexString(), + readCanvasPixelRgba8(gl, 600, outsideY).toHexString(), + "the texture was drawn outside the composable" + ) + } finally { + restorePreserveDrawingBuffer() + } + } + + /** Polls up to [frames] Compose frames for [expected] to show up at ([x], [y]). */ + private suspend fun WebApplicationScope.awaitCanvasPixel( + gl: WebGLRenderingContext, + x: Int, + y: Int, + expected: Int, + frames: Int = 30, + ): Int { + var pixel = 0 + repeat(frames) { + awaitAnimationFrame() + pixel = readCanvasPixelRgba8(gl, x, y) + if (pixel == expected) return pixel + } + return pixel + } + + /** + * Compose only creates a render target when it renders through a WebGL2 canvas, so a missing + * target is a valid outcome - as long as the canvas really has no WebGL2 context. + */ + private fun skipWithoutWebGL2() { + assertFalse( + getCanvas().getContext("webgl2") != null, + "no render target although the Compose canvas has a WebGL2 context" + ) + println("skipped: the Compose canvas does not have a WebGL2 context") + } +} + +private fun Int.toHexString(): String = toUInt().toString(16) + +/** Reads one pixel of the bound framebuffer as `0xRRGGBBAA`. */ +// language=js +private fun readPixelRgba8(gl: WebGLRenderingContext, x: Int, y: Int): Int = js( + """(function() { + const pixel = new Uint8Array(4); + gl.readPixels(x, y, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel); + return (pixel[0] << 24) | (pixel[1] << 16) | (pixel[2] << 8) | pixel[3]; + })()""" +) + +// language=js +private fun forcePreserveDrawingBuffer(): Boolean = js( + """(function() { + const proto = HTMLCanvasElement.prototype; + const original = proto.__composeTestOriginalGetContext || proto.getContext; + proto.__composeTestOriginalGetContext = original; + proto.getContext = function(type, attributes) { + if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') { + attributes = Object.assign({}, attributes || {}, { preserveDrawingBuffer: true }); + } + return original.call(this, type, attributes); + }; + return true; + })()""" +) + +// language=js +private fun contextAttribute(gl: WebGLRenderingContext, name: String): String = js( + """(function() { + const attributes = gl.getContextAttributes(); + return attributes ? String(attributes[name]) : 'n/a'; + })()""" +) + +/** Undoes [forcePreserveDrawingBuffer], so that other tests see the default context attributes. */ +// language=js +private fun restorePreserveDrawingBuffer(): Unit = js( + """(function() { + const proto = HTMLCanvasElement.prototype; + if (proto.__composeTestOriginalGetContext) { + proto.getContext = proto.__composeTestOriginalGetContext; + proto.__composeTestOriginalGetContext = undefined; + } + })()""" +) + +/** Reads one pixel of the *default* framebuffer (the Compose canvas) as `0xRRGGBBAA`. */ +// language=js +private fun readCanvasPixelRgba8(gl: WebGLRenderingContext, x: Int, y: Int): Int = js( + """(function() { + const previous = gl.getParameter(gl.FRAMEBUFFER_BINDING); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + const pixel = new Uint8Array(4); + gl.readPixels(x, y, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel); + gl.bindFramebuffer(gl.FRAMEBUFFER, previous); + return (pixel[0] << 24) | (pixel[1] << 16) | (pixel[2] << 8) | pixel[3]; + })()""" +) diff --git a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLTexturePlacementTests.kt b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLTexturePlacementTests.kt new file mode 100644 index 0000000000000..10dcfb68721ea --- /dev/null +++ b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLTexturePlacementTests.kt @@ -0,0 +1,131 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.platform.webgl + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.layout.ContentScale +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import org.jetbrains.skia.Rect + +/** + * Placement math behind [drawWebGLTexture]. Pure arithmetic, so it needs no canvas: the texture is + * cropped where the scaled image covers the destination, and the destination is inset where it does + * not. + */ +class WebGLTexturePlacementTests { + + private val wide = Size(64f, 32f) + private val square = Size(100f, 100f) + + @Test + fun cropFillsTheDestinationAndCropsTheLongerAxis() { + val placement = placement(wide, square, ContentScale.Crop) + // scale = max(100/64, 100/32) = 3.125, so 64x32 becomes 200x100: too wide by 100px. + assertRect(Rect.makeXYWH(16f, 0f, 32f, 32f), placement.src, "src") + assertRect(Rect.makeXYWH(0f, 0f, 100f, 100f), placement.dst, "dst") + } + + @Test + fun fitShowsTheWholeTextureAndCentersTheShorterAxis() { + val placement = placement(wide, square, ContentScale.Fit) + // scale = min(100/64, 100/32) = 1.5625, so 64x32 becomes 100x50: 50px of empty height. + assertRect(Rect.makeXYWH(0f, 0f, 64f, 32f), placement.src, "src") + assertRect(Rect.makeXYWH(0f, 25f, 100f, 50f), placement.dst, "dst") + } + + @Test + fun fillBoundsStretchesBothAxes() { + val placement = placement(wide, square, ContentScale.FillBounds) + assertRect(Rect.makeXYWH(0f, 0f, 64f, 32f), placement.src, "src") + assertRect(Rect.makeXYWH(0f, 0f, 100f, 100f), placement.dst, "dst") + } + + @Test + fun noneCentersTheTextureAtItsOwnSize() { + val placement = placement(wide, square, ContentScale.None) + assertRect(Rect.makeXYWH(0f, 0f, 64f, 32f), placement.src, "src") + assertRect(Rect.makeXYWH(18f, 34f, 64f, 32f), placement.dst, "dst") + } + + @Test + fun insideDownscalesOnlyWhenTheTextureIsLarger() { + val larger = placement(Size(200f, 100f), square, ContentScale.Inside) + // scale = min(1, min(0.5, 1)) = 0.5, so 200x100 becomes 100x50. + assertRect(Rect.makeXYWH(0f, 0f, 200f, 100f), larger.src, "downscaled src") + assertRect(Rect.makeXYWH(0f, 25f, 100f, 50f), larger.dst, "downscaled dst") + + val smaller = placement(wide, square, ContentScale.Inside) + assertRect(Rect.makeXYWH(18f, 34f, 64f, 32f), smaller.dst, "untouched dst") + } + + @Test + fun dstOffsetShiftsTheDestination() { + val cropped = placement(wide, square, ContentScale.Crop, Offset(10f, 20f)) + assertRect(Rect.makeXYWH(10f, 20f, 100f, 100f), cropped.dst, "cropped dst") + + // The offset applies before centering, so an inset axis keeps its inset. + val fitted = placement(wide, square, ContentScale.Fit, Offset(10f, 20f)) + assertRect(Rect.makeXYWH(10f, 45f, 100f, 50f), fitted.dst, "fitted dst") + } + + @Test + fun nothingIsDrawnWithoutAUsableSize() { + assertNull( + webGLTexturePlacement(wide, Size.Unspecified, Offset.Zero, ContentScale.Crop), + "unspecified destination" + ) + assertNull( + webGLTexturePlacement(wide, Size(100f, 0f), Offset.Zero, ContentScale.Crop), + "empty destination" + ) + assertNull( + webGLTexturePlacement(Size(0f, 32f), square, Offset.Zero, ContentScale.Crop), + "empty texture" + ) + assertNull( + webGLTexturePlacement(wide, square, Offset.Zero, ZeroScale), + "zero scale factor" + ) + } + + private fun placement( + srcSize: Size, + dstSize: Size, + contentScale: ContentScale, + dstOffset: Offset = Offset.Zero, + ): WebGLTexturePlacement = + assertNotNull( + webGLTexturePlacement(srcSize, dstSize, dstOffset, contentScale), + "no placement for $srcSize in $dstSize" + ) + + private fun assertRect(expected: Rect, actual: Rect, name: String) { + assertEquals(expected.left, actual.left, "$name left") + assertEquals(expected.top, actual.top, "$name top") + assertEquals(expected.right, actual.right, "$name right") + assertEquals(expected.bottom, actual.bottom, "$name bottom") + } +} + +private object ZeroScale : ContentScale { + override fun computeScaleFactor(srcSize: Size, dstSize: Size) = + androidx.compose.ui.layout.ScaleFactor(0f, 0f) +} From 59be59a1c07f8ca6ca9adfc30171b7d5b00d6a4d Mon Sep 17 00:00:00 2001 From: "Oleksandr.Karpovich" Date: Fri, 21 Aug 2026 09:41:21 +0200 Subject: [PATCH 19/26] set Density in theRenderedFrameReachesTheComposeCanvas for test reproducibility --- .../compose/ui/platform/webgl/WebGLRenderTargetTests.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt index d54f2fdbe3738..1ee6253185aa1 100644 --- a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt +++ b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt @@ -20,6 +20,7 @@ package androidx.compose.ui.platform.webgl import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.size +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -28,6 +29,8 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.OnCanvasTests import androidx.compose.ui.WebApplicationScope +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import kotlin.test.Test @@ -167,8 +170,10 @@ class WebGLRenderTargetTests : OnCanvasTests { LaunchedEffect(target) { repeat(30) { withFrameNanos { target.render { clearToRed() } } } } - // 100.dp is 200px at the test density, well inside the canvas. - Canvas(Modifier.size(100.dp)) { drawWebGLTexture(target) } + // Setting the density to 2 so the test works correctly on all displays + CompositionLocalProvider(LocalDensity provides Density(2f)) { + Canvas(Modifier.size(100.dp)) { drawWebGLTexture(target) } + } } } From e6c267997584537a5378d21c1b3606202ab88264 Mon Sep 17 00:00:00 2001 From: "Oleksandr.Karpovich" Date: Mon, 24 Aug 2026 21:26:02 +0200 Subject: [PATCH 20/26] refactor api --- .../demo/webgl/HtmlInCanvasWebGlDemo.web.kt | 28 +-- .../mpp/demo/webgl/PlainWebGlDemo.web.kt | 32 +-- .../webgl/ThreeTextureAdoptionDemo.web.kt | 66 +++--- .../mpp/demo/webgl/VideoWebGlDemo.web.kt | 13 +- .../platform/webgl/WebGLRenderTarget.web.kt | 52 ++++- .../webgl/WebGLRenderTargetPainter.web.kt | 84 +++++++ .../ui/platform/webgl/WebGLTextureDraw.web.kt | 125 ----------- .../webgl/WebGLRenderTargetPainterTests.kt | 209 ++++++++++++++++++ .../platform/webgl/WebGLRenderTargetTests.kt | 23 +- .../webgl/WebGLTexturePlacementTests.kt | 131 ----------- 10 files changed, 434 insertions(+), 329 deletions(-) create mode 100644 compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetPainter.web.kt delete mode 100644 compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLTextureDraw.web.kt create mode 100644 compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetPainterTests.kt delete mode 100644 compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLTexturePlacementTests.kt diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/HtmlInCanvasWebGlDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/HtmlInCanvasWebGlDemo.web.kt index 5d91f6d13e6eb..2cba4c64250c4 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/HtmlInCanvasWebGlDemo.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/HtmlInCanvasWebGlDemo.web.kt @@ -8,7 +8,7 @@ package androidx.compose.mpp.demo.webgl -import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -42,7 +42,7 @@ import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.webgl.WebGLRenderTarget -import androidx.compose.ui.platform.webgl.drawWebGLTexture +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.webgl.rememberWebGLRenderTarget import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp @@ -131,17 +131,19 @@ private fun HtmlInCanvasWebGlDemo() { ) { Text("Interactive HTML rendered into a WebGL texture") Box(Modifier.fillMaxWidth(0.8f).aspectRatio(640f / 360f)) { - Canvas( - Modifier.fillMaxSize() - .onGloballyPositioned { coordinates -> - val bounds = coordinates.boundsInWindow() - boundsPx = - IntSize(bounds.width.roundToInt(), bounds.height.roundToInt()) - originPx = Offset(bounds.left, bounds.top) - } - ) { - drawWebGLTexture(target) - } + Image( + painter = target.painter, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = + Modifier.fillMaxSize() + .onGloballyPositioned { coordinates -> + val bounds = coordinates.boundsInWindow() + boundsPx = + IntSize(bounds.width.roundToInt(), bounds.height.roundToInt()) + originPx = Offset(bounds.left, bounds.top) + }, + ) } } diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt index 39338333ecbee..3a7be92121d4a 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt @@ -18,7 +18,7 @@ package androidx.compose.mpp.demo.webgl -import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -47,7 +47,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.webgl.WebGLRenderTarget -import androidx.compose.ui.platform.webgl.drawWebGLTexture +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.webgl.rememberWebGLRenderTarget import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.IntSize @@ -86,27 +86,27 @@ private fun PlainWebGlDemo() { @Composable private fun RotatingTriangle(isAnimating: Boolean) { - val surface = rememberWebGLRenderTarget(IntSize(512, 320))!! - val triangle = remember(surface) { TriangleRenderer(surface.webGLContext) } + val renderTarget = rememberWebGLRenderTarget(IntSize(512, 320))!! + val triangle = remember(renderTarget) { TriangleRenderer(renderTarget.webGLContext) } - DisposableEffect(triangle, surface) { - onDispose { triangle.dispose(surface) } + DisposableEffect(triangle, renderTarget) { + onDispose { triangle.dispose(renderTarget) } } - LaunchedEffect(surface, isAnimating) { + LaunchedEffect(renderTarget, isAnimating) { while (isAnimating) { withFrameNanos { frameTimeNanos -> - surface.render { triangle.render(this, frameTimeNanos) } + renderTarget.render { triangle.render(this, frameTimeNanos) } } } } LabelledContent("512×320 texture\na shader-drawn triangle") { - Canvas( + Image( + painter = renderTarget.painter, + contentDescription = null, + contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), - onDraw = { - drawWebGLTexture(surface) - } ) } } @@ -125,11 +125,11 @@ private fun ColorPulse(isAnimating: Boolean) { } LabelledContent("64×64 texture\nnothing but a pulsing clear color") { - Canvas( + Image( + painter = surface.painter, + contentDescription = null, + contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), - onDraw = { - drawWebGLTexture(surface) - } ) } } diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt index 9fea047d31db1..a35c70711def2 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt @@ -18,7 +18,7 @@ package androidx.compose.mpp.demo.webgl -import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.Arrangement @@ -58,7 +58,7 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.webgl.WebGLRenderTarget -import androidx.compose.ui.platform.webgl.drawWebGLTexture +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.webgl.rememberWebGLRenderTarget import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.style.TextAlign @@ -265,7 +265,12 @@ private fun Hero(surface: WebGLRenderTarget) { style = MaterialTheme.typography.h3, ) } - Canvas(Modifier.fillMaxSize()) { drawWebGLTexture(surface) } + Image( + painter = surface.painter, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) Column(Modifier.padding(20.dp)) { Text( "three.js below, Compose above", @@ -285,29 +290,36 @@ private fun Hero(surface: WebGLRenderTarget) { @Composable private fun Variants(surface: WebGLRenderTarget) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { - Canvas(Modifier.size(96.dp).clip(CircleShape).background(Color.LightGray)) { - drawWebGLTexture(surface) - } - Canvas( - Modifier.size(96.dp) - .clip(RoundedCornerShape(32.dp)) - .background(Color.DarkGray) - .graphicsLayer { - alpha = 0.75f - scaleX = -0.75f - scaleY = 0.75f - } - ) { - drawWebGLTexture(surface) - } - Canvas( - Modifier.size(96.dp) - .clip(RoundedCornerShape(8.dp)) - .background(Color.Gray) - .blur(2.dp) - .scale(1f, -1f) - ) { - drawWebGLTexture(surface) - } + Image( + painter = surface.painter, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(96.dp).clip(CircleShape).background(Color.LightGray), + ) + Image( + painter = surface.painter, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = + Modifier.size(96.dp) + .clip(RoundedCornerShape(32.dp)) + .background(Color.DarkGray) + .graphicsLayer { + alpha = 0.75f + scaleX = -0.75f + scaleY = 0.75f + }, + ) + Image( + painter = surface.painter, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = + Modifier.size(96.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color.Gray) + .blur(2.dp) + .scale(1f, -1f), + ) } } diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/VideoWebGlDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/VideoWebGlDemo.web.kt index 28ec064c77615..0729084d97567 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/VideoWebGlDemo.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/VideoWebGlDemo.web.kt @@ -18,7 +18,7 @@ package androidx.compose.mpp.demo.webgl -import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -45,7 +45,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.webgl.WebGLRenderTarget -import androidx.compose.ui.platform.webgl.drawWebGLTexture +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.webgl.rememberWebGLRenderTarget import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp @@ -112,9 +112,12 @@ private fun VideoPlayer( }, contentAlignment = Alignment.Center, ) { - Canvas(Modifier.fillMaxSize()) { - drawWebGLTexture(renderTarget) - } + Image( + painter = renderTarget.painter, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) if (!isPlaying || controlsVisible) { Box( Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.28f)) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTarget.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTarget.web.kt index 476cca8c1ec8e..70b999c216e44 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTarget.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTarget.web.kt @@ -20,10 +20,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.unit.IntSize import androidx.compose.ui.window.LocalComposeWindow import org.jetbrains.skia.DirectContext @@ -55,7 +59,7 @@ private const val GL_DEPTH_STENCIL_ATTACHMENT = 0x821A /** * An offscreen render target that lets WebGL content take part in Compose rendering: WebGL code - * draws into it inside [render], and Compose displays the result with [drawWebGLTexture]. + * draws into it inside [render], and Compose displays the result through its [painter]. * * It takes care of everything that hand-off needs. It owns the GPU resources — a [framebuffer] with * a color texture and a depth/stencil buffer, in the very WebGL context and `` Compose @@ -83,13 +87,17 @@ private const val GL_DEPTH_STENCIL_ATTACHMENT = 0x821A * } * } * - * Canvas(Modifier.fillMaxSize()) { drawWebGLTexture(renderTarget) } + * Image( + * painter = renderTarget.painter, + * contentDescription = null, + * contentScale = ContentScale.Crop, + * modifier = Modifier.fillMaxSize(), + * ) * ``` */ @ExperimentalComposeUiApi @Stable -class WebGLRenderTarget -internal constructor( +class WebGLRenderTarget internal constructor( val htmlCanvas: HTMLCanvasElement, val webGLContext: WebGLRenderingContext, private val directContext: () -> DirectContext?, @@ -106,8 +114,12 @@ internal constructor( * * Changing the size passed to [rememberWebGLRenderTarget] updates this on the next [render], so * it always describes the framebuffer the current frame draws into. + * + * Backed by snapshot state, so layout that derives from it - such as a [Painter] sized by + * [Painter.intrinsicSize] - is redone when the size changes. Only written from [render], which + * must never run while Compose is drawing. */ - var size: IntSize = IntSize.Zero + var size: IntSize by mutableStateOf(IntSize.Zero) private set /** Applied by the next [render]; see the `size` parameter of [rememberWebGLRenderTarget]. */ @@ -138,6 +150,30 @@ internal constructor( internal val image: Image? get() = adoptedTexture?.image + /** + * Draws the last frame rendered into this target, for the standard Compose drawing APIs: + * ``` + * Image(renderTarget.painter, contentDescription = null, contentScale = ContentScale.Crop) + * Box(Modifier.paint(renderTarget.painter, contentScale = ContentScale.Fit)) + * Canvas(Modifier.fillMaxSize()) { with(renderTarget.painter) { draw(size) } } + * ``` + * + * Its [Painter.intrinsicSize] is [size] as soon as a frame exists, and `Size.Unspecified` + * before that, so scaling and alignment are up to the caller, as for any other painter. Prefer + * `Image`, which clips the frame to its bounds: the painter fills the size it is given, so + * `ContentScale.Crop` scales the frame beyond that size and `Modifier.paint` alone would let it + * spill over its neighbours unless `Modifier.clipToBounds` is added. Note also that + * `Modifier.paint` defaults to `ContentScale.Inside`, which never scales a frame up. + * + * Drawing it issues no GL commands, only a draw of the texture that [render] filled, so it is + * safe inside graphics layers such as `clip` and `blur`, and can draw the same frame in several + * places. Each new frame repeats the drawing on its own, without recomposing. + * + * The same instance is returned every time, so that drawing it does not restart on every + * recomposition. + */ + val painter: Painter by lazy(LazyThreadSafetyMode.NONE) { WebGLRenderTargetPainter(this) } + private val _invalidation = mutableLongStateOf(0L) /** Makes the calling draw operation repeat whenever a new frame is rendered. */ @@ -151,8 +187,8 @@ internal constructor( private var isRendering = false /** - * Renders one frame into this target and invalidates every [drawWebGLTexture] that displays it, - * so they all show the new frame. + * Renders one frame into this target and invalidates everything that draws its [painter], so + * it all shows the new frame. * * Allocates or reallocates GPU resources if needed, binds [framebuffer], runs [block], then * restores the GL state Compose's renderer expects. [block] receives this target as its @@ -169,7 +205,7 @@ internal constructor( * Canvas(Modifier.fillMaxSize()) { * // Wrong: render() must not run while Compose is drawing. * renderTarget.render { renderer.drawFrame(this) } - * drawWebGLTexture(renderTarget) + * with(renderTarget.painter) { draw(size) } * } * ``` * diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetPainter.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetPainter.web.kt new file mode 100644 index 0000000000000..7fb571b47fd1e --- /dev/null +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetPainter.web.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.platform.webgl + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.skiaCanvas +import androidx.compose.ui.unit.IntSize +import org.jetbrains.skia.Rect + +/** + * The [Painter] behind [WebGLRenderTarget.painter]: draws the last frame rendered into + * [renderTarget], scaled to whatever bounds the caller gives it. + * + * Follows the [Painter] contract and fills the size it receives, rather than placing the frame + * itself. Scaling and alignment then come from the caller - `Image`, `Modifier.paint` - which + * derive them from [intrinsicSize]. + */ +@OptIn(ExperimentalComposeUiApi::class) +internal class WebGLRenderTargetPainter( + private val renderTarget: WebGLRenderTarget, +) : Painter() { + + /** + * The size of the rendered frame, or `Size.Unspecified` while there is none, which lets the + * painter fill the bounds it is given instead of collapsing them to zero. + * + * Read during layout, so it relies on [WebGLRenderTarget.size] being snapshot state to have a + * new size trigger a new layout. + */ + override val intrinsicSize: Size + get() { + val size = renderTarget.size + return if (size == IntSize.Zero) Size.Unspecified + else Size(size.width.toFloat(), size.height.toFloat()) + } + + override fun DrawScope.onDraw() { + // Schedules the next redraw once a new frame is rendered, without recomposing. + renderTarget.observeInvalidation() + + val image = renderTarget.image ?: return + if (size.width <= 0f || size.height <= 0f) return + if (image.width <= 0 || image.height <= 0) return + + drawIntoCanvas { canvas -> + canvas.skiaCanvas.drawImageRect( + image, + Rect.makeWH(image.width.toFloat(), image.height.toFloat()), + Rect.makeWH(size.width, size.height), + ) + } + } + + /** + * Identity, unlike the value equality [Painter] asks for: this painter stands for the mutable + * GPU resources of one target, which nothing else can be equal to. [WebGLRenderTarget.painter] + * hands out a single instance per target, so identity is all callers can observe anyway. + */ + override fun equals(other: Any?): Boolean = + this === other || + (other is WebGLRenderTargetPainter && renderTarget === other.renderTarget) + + override fun hashCode(): Int = renderTarget.hashCode() + + override fun toString(): String = "WebGLRenderTargetPainter(size=${renderTarget.size})" +} diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLTextureDraw.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLTextureDraw.web.kt deleted file mode 100644 index 2a9254615a1cc..0000000000000 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLTextureDraw.web.kt +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright 2026 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.ui.platform.webgl - -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.geometry.isSpecified -import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.graphics.drawscope.drawIntoCanvas -import androidx.compose.ui.graphics.skiaCanvas -import androidx.compose.ui.layout.ContentScale -import org.jetbrains.skia.Rect - -/** - * Draws the last frame rendered into [renderTarget], or nothing if there is no frame yet. - * - * This only records a draw of the already rendered texture, so it issues no GL commands of its own: - * it is safe inside graphics layers such as `clip` and `blur`, and can be called several times per - * frame to show the same frame in several places. Each new frame repeats the drawing on its own, - * without recomposing. - * - * @param renderTarget The target whose last frame to draw. - * @param dstOffset Top-left of the destination, in local coordinates. - * @param dstSize Size of the destination. Defaults to the whole draw bounds. - * @param contentScale How to fit the frame into the destination when their aspect ratios differ. - */ -@ExperimentalComposeUiApi -fun DrawScope.drawWebGLTexture( - renderTarget: WebGLRenderTarget, - dstOffset: Offset = Offset.Zero, - dstSize: Size = size, - contentScale: ContentScale = ContentScale.Crop, -) { - // Schedules the next redraw once this frame's content is rendered, without recomposing. - renderTarget.observeInvalidation() - - val image = renderTarget.image ?: return - val srcSize = Size(image.width.toFloat(), image.height.toFloat()) - val placement = - webGLTexturePlacement(srcSize, dstSize, dstOffset, contentScale) ?: return - - drawIntoCanvas { canvas -> - canvas.skiaCanvas.drawImageRect(image, placement.src, placement.dst) - } -} - -/** Source and destination rectangles for one [drawWebGLTexture] call. */ -internal class WebGLTexturePlacement(val src: Rect, val dst: Rect) - -/** - * Maps a texture of [srcSize] into [dstSize] at [dstOffset] under [contentScale], or returns `null` - * when there is nothing to draw. - */ -internal fun webGLTexturePlacement( - srcSize: Size, - dstSize: Size, - dstOffset: Offset, - contentScale: ContentScale, -): WebGLTexturePlacement? { - if (!dstSize.isSpecified || dstSize.width <= 0f || dstSize.height <= 0f) return null - if (srcSize.width <= 0f || srcSize.height <= 0f) return null - - val scale = contentScale.computeScaleFactor(srcSize, dstSize) - if (scale.scaleX <= 0f || scale.scaleY <= 0f) return null - - // Per axis: when the scaled texture covers the destination, the source is cropped; when it does - // not, the destination is inset. This yields the expected result for Crop, Fit, FillBounds, - // Inside and None alike. - val (srcX, srcWidth, dstX, dstWidth) = - axis(srcSize.width, dstSize.width, scale.scaleX, dstOffset.x) - val (srcY, srcHeight, dstY, dstHeight) = - axis(srcSize.height, dstSize.height, scale.scaleY, dstOffset.y) - - return WebGLTexturePlacement( - src = Rect.makeXYWH(srcX, srcY, srcWidth, srcHeight), - dst = Rect.makeXYWH(dstX, dstY, dstWidth, dstHeight), - ) -} - -private data class AxisPlacement( - val src: Float, - val srcExtent: Float, - val dst: Float, - val dstExtent: Float, -) - -private fun axis( - srcExtent: Float, - dstExtent: Float, - scale: Float, - dstOrigin: Float, -): AxisPlacement { - val scaledExtent = srcExtent * scale - return if (scaledExtent >= dstExtent) { - val visibleSrcExtent = dstExtent / scale - AxisPlacement( - src = (srcExtent - visibleSrcExtent) / 2f, - srcExtent = visibleSrcExtent, - dst = dstOrigin, - dstExtent = dstExtent, - ) - } else { - AxisPlacement( - src = 0f, - srcExtent = srcExtent, - dst = dstOrigin + (dstExtent - scaledExtent) / 2f, - dstExtent = scaledExtent, - ) - } -} diff --git a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetPainterTests.kt b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetPainterTests.kt new file mode 100644 index 0000000000000..7bca0234e5e80 --- /dev/null +++ b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetPainterTests.kt @@ -0,0 +1,209 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalComposeUiApi::class) + +package androidx.compose.ui.platform.webgl + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.OnCanvasTests +import androidx.compose.ui.draw.paint +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.isUnspecified +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.khronos.webgl.WebGLRenderingContext.Companion.COLOR_BUFFER_BIT + +/** + * Draws [painter] over [size], sparing every call site the `with(painter) { draw(size) }` dance + * that [Painter.draw] being a member extension otherwise forces. + */ +private fun DrawScope.drawPainter(painter: Painter, size: Size = this.size) { + with(painter) { draw(size) } +} + +/** The whole "renderer": clear the target to opaque red, like the ColorPulse demo. */ +private fun WebGLRenderTarget.clearToRed() { + webGLContext.viewport(0, 0, size.width, size.height) + webGLContext.clearColor(1f, 0f, 0f, 1f) + webGLContext.clear(COLOR_BUFFER_BIT) +} + +class WebGLRenderTargetPainterTests : OnCanvasTests { + + /** + * A painter with no frame yet must not claim a size, so that layout keeps giving it the bounds + * it would get from any other painter without an intrinsic size. + */ + @Test + fun theIntrinsicSizeIsUnspecifiedUntilTheFirstFrame() = runApplicationTest { + var renderTarget: WebGLRenderTarget? = null + + createComposeWindow { + renderTarget = rememberWebGLRenderTarget(IntSize(32, 32)) + } + + val target = renderTarget ?: return@runApplicationTest skipWithoutWebGL2() + awaitAnimationFrame() + awaitIdle() + + assertEquals(IntSize.Zero, target.size, "the target allocated without a render()") + assertTrue( + target.painter.intrinsicSize.isUnspecified, + "the painter claimed a size although no frame was rendered" + ) + } + + /** + * Once a frame exists, the painter is as big as that frame - and it follows the frame when the + * requested size changes, which is what makes layout pick up a new size. + */ + @Test + fun theIntrinsicSizeFollowsTheRenderedFrame() = runApplicationTest { + val requestedSize = mutableStateOf(IntSize(32, 32)) + var renderTarget: WebGLRenderTarget? = null + + createComposeWindow { + val currentSize by requestedSize + val target = rememberWebGLRenderTarget(currentSize) + renderTarget = target + if (target != null) { + // The way callers are meant to use the painter, rather than drawing it by hand. + Box(Modifier.size(32.dp).paint(target.painter)) + } + } + + val target = renderTarget ?: return@runApplicationTest skipWithoutWebGL2() + awaitAnimationFrame() + awaitIdle() + + assertTrue(target.render { clearToRed() }, "the first render() did not run") + assertEquals( + Size(32f, 32f), + target.painter.intrinsicSize, + "the painter did not take the size of the first frame" + ) + + requestedSize.value = IntSize(48, 24) + awaitAnimationFrame() + awaitIdle() + + assertTrue(target.render { clearToRed() }, "render() did not run after the size change") + assertEquals( + Size(48f, 24f), + target.painter.intrinsicSize, + "the painter kept the size of the previous frame" + ) + } + + /** + * Drawing the painter must repeat on every new frame, and must do so from the draw phase alone: + * a frame that recomposes would defeat the point of drawing an already rendered texture. + */ + @Test + fun everyRenderedFrameIsDrawnAgainWithoutRecomposing() = runApplicationTest { + val frames = 3 + var renderTarget: WebGLRenderTarget? = null + var compositions = 0 + var renderedFrames = 0 + var draws = 0 + + createComposeWindow { + compositions++ + val target = rememberWebGLRenderTarget(IntSize(64, 64)) + renderTarget = target + if (target != null) { + LaunchedEffect(target) { + repeat(frames) { + withFrameNanos { if (target.render { clearToRed() }) renderedFrames++ } + } + } + // Drawn by hand, so that the draw counter sits in the very scope that the + // painter's invalidation has to repeat. + Canvas(Modifier.size(64.dp)) { + draws++ + drawPainter(target.painter) + } + } + } + + val target = renderTarget ?: return@runApplicationTest skipWithoutWebGL2() + val compositionsBefore = compositions + repeat(frames + 2) { awaitAnimationFrame() } + awaitIdle() + + assertTrue(renderedFrames > 0, "render() never ran the block") + assertTrue(draws >= renderedFrames, "a rendered frame was not drawn again: $draws draws") + assertEquals( + compositionsBefore, + compositions, + "drawing the painter recomposed instead of only redrawing" + ) + assertEquals( + Size(64f, 64f), + target.painter.intrinsicSize, + "the painter did not take the size of the rendered frames" + ) + } + + /** + * The painter is part of the target's identity: handing out a new instance per read would make + * `Image(target.painter, ...)` restart its layout and drawing on every recomposition. + */ + @Test + fun thePainterIsOneStableInstancePerTarget() = runApplicationTest { + var renderTarget: WebGLRenderTarget? = null + + createComposeWindow { + renderTarget = rememberWebGLRenderTarget(IntSize(16, 16)) + } + + val target = renderTarget ?: return@runApplicationTest skipWithoutWebGL2() + awaitIdle() + + val painter = target.painter + assertSame(painter, target.painter, "the target handed out a second painter") + assertEquals(painter, target.painter, "the painter is not equal to itself") + assertEquals(painter.hashCode(), target.painter.hashCode(), "unstable hashCode") + } + + /** + * Compose only creates a render target when it renders through a WebGL2 canvas, so a missing + * target is a valid outcome - as long as the canvas really has no WebGL2 context. + */ + private fun skipWithoutWebGL2() { + assertFalse( + getCanvas().getContext("webgl2") != null, + "no render target although the Compose canvas has a WebGL2 context" + ) + println("skipped: the Compose canvas does not have a WebGL2 context") + } +} diff --git a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt index 1ee6253185aa1..59a51dac2e8b3 100644 --- a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt +++ b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt @@ -19,6 +19,7 @@ package androidx.compose.ui.platform.webgl import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.size import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -29,6 +30,7 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.OnCanvasTests import androidx.compose.ui.WebApplicationScope +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntSize @@ -77,9 +79,12 @@ class WebGLRenderTargetTests : OnCanvasTests { } } } + // Drawn by hand rather than with Image, so that the counter sits in the very draw + // scope that a new frame has to repeat. Canvas(Modifier.size(64.dp)) { drawnFrames++ - drawWebGLTexture(target) + val bounds = this.size + with(target.painter) { draw(bounds) } } } } @@ -89,7 +94,7 @@ class WebGLRenderTargetTests : OnCanvasTests { awaitIdle() assertTrue(renderedFrames > 0, "render() never ran the block") - assertTrue(drawnFrames > 0, "drawWebGLTexture() was never called") + assertTrue(drawnFrames > 0, "the painter was never drawn") assertEquals(IntSize(64, 64), target.size, "unexpected allocated size") assertNotNull(target.framebuffer, "framebuffer was not allocated") @@ -125,7 +130,12 @@ class WebGLRenderTargetTests : OnCanvasTests { val target = rememberWebGLRenderTarget(size) renderTarget = target if (target != null) { - Canvas(Modifier.size(32.dp)) { drawWebGLTexture(target) } + Image( + painter = target.painter, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(32.dp), + ) } } @@ -172,7 +182,12 @@ class WebGLRenderTargetTests : OnCanvasTests { } // Setting the density to 2 so the test works correctly on all displays CompositionLocalProvider(LocalDensity provides Density(2f)) { - Canvas(Modifier.size(100.dp)) { drawWebGLTexture(target) } + Image( + painter = target.painter, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(100.dp), + ) } } } diff --git a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLTexturePlacementTests.kt b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLTexturePlacementTests.kt deleted file mode 100644 index 10dcfb68721ea..0000000000000 --- a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLTexturePlacementTests.kt +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright 2026 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.ui.platform.webgl - -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.layout.ContentScale -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import org.jetbrains.skia.Rect - -/** - * Placement math behind [drawWebGLTexture]. Pure arithmetic, so it needs no canvas: the texture is - * cropped where the scaled image covers the destination, and the destination is inset where it does - * not. - */ -class WebGLTexturePlacementTests { - - private val wide = Size(64f, 32f) - private val square = Size(100f, 100f) - - @Test - fun cropFillsTheDestinationAndCropsTheLongerAxis() { - val placement = placement(wide, square, ContentScale.Crop) - // scale = max(100/64, 100/32) = 3.125, so 64x32 becomes 200x100: too wide by 100px. - assertRect(Rect.makeXYWH(16f, 0f, 32f, 32f), placement.src, "src") - assertRect(Rect.makeXYWH(0f, 0f, 100f, 100f), placement.dst, "dst") - } - - @Test - fun fitShowsTheWholeTextureAndCentersTheShorterAxis() { - val placement = placement(wide, square, ContentScale.Fit) - // scale = min(100/64, 100/32) = 1.5625, so 64x32 becomes 100x50: 50px of empty height. - assertRect(Rect.makeXYWH(0f, 0f, 64f, 32f), placement.src, "src") - assertRect(Rect.makeXYWH(0f, 25f, 100f, 50f), placement.dst, "dst") - } - - @Test - fun fillBoundsStretchesBothAxes() { - val placement = placement(wide, square, ContentScale.FillBounds) - assertRect(Rect.makeXYWH(0f, 0f, 64f, 32f), placement.src, "src") - assertRect(Rect.makeXYWH(0f, 0f, 100f, 100f), placement.dst, "dst") - } - - @Test - fun noneCentersTheTextureAtItsOwnSize() { - val placement = placement(wide, square, ContentScale.None) - assertRect(Rect.makeXYWH(0f, 0f, 64f, 32f), placement.src, "src") - assertRect(Rect.makeXYWH(18f, 34f, 64f, 32f), placement.dst, "dst") - } - - @Test - fun insideDownscalesOnlyWhenTheTextureIsLarger() { - val larger = placement(Size(200f, 100f), square, ContentScale.Inside) - // scale = min(1, min(0.5, 1)) = 0.5, so 200x100 becomes 100x50. - assertRect(Rect.makeXYWH(0f, 0f, 200f, 100f), larger.src, "downscaled src") - assertRect(Rect.makeXYWH(0f, 25f, 100f, 50f), larger.dst, "downscaled dst") - - val smaller = placement(wide, square, ContentScale.Inside) - assertRect(Rect.makeXYWH(18f, 34f, 64f, 32f), smaller.dst, "untouched dst") - } - - @Test - fun dstOffsetShiftsTheDestination() { - val cropped = placement(wide, square, ContentScale.Crop, Offset(10f, 20f)) - assertRect(Rect.makeXYWH(10f, 20f, 100f, 100f), cropped.dst, "cropped dst") - - // The offset applies before centering, so an inset axis keeps its inset. - val fitted = placement(wide, square, ContentScale.Fit, Offset(10f, 20f)) - assertRect(Rect.makeXYWH(10f, 45f, 100f, 50f), fitted.dst, "fitted dst") - } - - @Test - fun nothingIsDrawnWithoutAUsableSize() { - assertNull( - webGLTexturePlacement(wide, Size.Unspecified, Offset.Zero, ContentScale.Crop), - "unspecified destination" - ) - assertNull( - webGLTexturePlacement(wide, Size(100f, 0f), Offset.Zero, ContentScale.Crop), - "empty destination" - ) - assertNull( - webGLTexturePlacement(Size(0f, 32f), square, Offset.Zero, ContentScale.Crop), - "empty texture" - ) - assertNull( - webGLTexturePlacement(wide, square, Offset.Zero, ZeroScale), - "zero scale factor" - ) - } - - private fun placement( - srcSize: Size, - dstSize: Size, - contentScale: ContentScale, - dstOffset: Offset = Offset.Zero, - ): WebGLTexturePlacement = - assertNotNull( - webGLTexturePlacement(srcSize, dstSize, dstOffset, contentScale), - "no placement for $srcSize in $dstSize" - ) - - private fun assertRect(expected: Rect, actual: Rect, name: String) { - assertEquals(expected.left, actual.left, "$name left") - assertEquals(expected.top, actual.top, "$name top") - assertEquals(expected.right, actual.right, "$name right") - assertEquals(expected.bottom, actual.bottom, "$name bottom") - } -} - -private object ZeroScale : ContentScale { - override fun computeScaleFactor(srcSize: Size, dstSize: Size) = - androidx.compose.ui.layout.ScaleFactor(0f, 0f) -} From ad18c6d2e1e548a07c4e478c92d39e7d67543148 Mon Sep 17 00:00:00 2001 From: "Oleksandr.Karpovich" Date: Tue, 25 Aug 2026 09:44:47 +0200 Subject: [PATCH 21/26] refactor api --- .../demo/webgl/HtmlInCanvasWebGlDemo.web.kt | 3 +- .../mpp/demo/webgl/PlainWebGlDemo.web.kt | 15 ++-- .../mpp/demo/webgl/ThreeJsKnotRenderer.web.kt | 5 +- .../mpp/demo/webgl/VideoWebGlDemo.web.kt | 3 +- .../ui/platform/webgl/WebGLRenderScope.web.kt | 85 +++++++++++++++++++ .../platform/webgl/WebGLRenderTarget.web.kt | 64 +++++++------- .../webgl/WebGLRenderTargetPainterTests.kt | 2 +- .../platform/webgl/WebGLRenderTargetTests.kt | 11 ++- 8 files changed, 144 insertions(+), 44 deletions(-) create mode 100644 compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderScope.web.kt diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/HtmlInCanvasWebGlDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/HtmlInCanvasWebGlDemo.web.kt index 2cba4c64250c4..94262bdc7513e 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/HtmlInCanvasWebGlDemo.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/HtmlInCanvasWebGlDemo.web.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.webgl.WebGLRenderScope import androidx.compose.ui.platform.webgl.WebGLRenderTarget import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.webgl.rememberWebGLRenderTarget @@ -188,7 +189,7 @@ private class HtmlTextureRenderer( syncElementBox(element, widthCss.toDouble(), heightCss.toDouble(), leftCss.toDouble(), topCss.toDouble()) } - fun renderFrame(target: WebGLRenderTarget, frameTimeNanos: Long) { + fun renderFrame(target: WebGLRenderScope, frameTimeNanos: Long) { val element = element ?: return if (!uploadElement(gl, texture, element, target.size.width, target.size.height)) return with(target) { diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt index 3a7be92121d4a..6e08cbfd910ae 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.webgl.WebGLRenderScope import androidx.compose.ui.platform.webgl.WebGLRenderTarget import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.webgl.rememberWebGLRenderTarget @@ -113,20 +114,20 @@ private fun RotatingTriangle(isAnimating: Boolean) { @Composable private fun ColorPulse(isAnimating: Boolean) { - val surface = rememberWebGLRenderTarget(IntSize(64, 64))!! + val webGLRenderTarger = rememberWebGLRenderTarget(IntSize(64, 64))!! val pulse = remember { PulseRenderer() } - LaunchedEffect(surface, isAnimating) { + LaunchedEffect(webGLRenderTarger, isAnimating) { while (isAnimating) { withFrameNanos { frameTimeNanos -> - surface.render { pulse.render(this, frameTimeNanos) } + webGLRenderTarger.render { pulse.render(this, frameTimeNanos) } } } } LabelledContent("64×64 texture\nnothing but a pulsing clear color") { Image( - painter = surface.painter, + painter = webGLRenderTarger.painter, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), @@ -172,7 +173,7 @@ private class TriangleRenderer(private val gl: WebGLRenderingContext) { private var angle = 0f private var previousFrameTimeNanos = 0L - fun render(target: WebGLRenderTarget, frameTimeNanos: Long): Unit = + fun render(target: WebGLRenderScope, frameTimeNanos: Long): Unit = with(target) { val deltaNanos = if (previousFrameTimeNanos == 0L) { 0L @@ -270,8 +271,8 @@ private class PulseRenderer { private var phase = 0f private var previousFrameTimeNanos = 0L - fun render(target: WebGLRenderTarget, frameTimeNanos: Long): Unit = - with(target) { + fun render(scope: WebGLRenderScope, frameTimeNanos: Long): Unit = + with(scope) { val deltaNanos = if (previousFrameTimeNanos == 0L) { 0L } else { diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt index 41da80a58d522..22a955b1e839f 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt @@ -19,6 +19,7 @@ package androidx.compose.mpp.demo.webgl import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.webgl.WebGLRenderScope import androidx.compose.ui.platform.webgl.WebGLRenderTarget /** @@ -56,7 +57,7 @@ private constructor(private val three: ThreeModule, surface: WebGLRenderTarget) private var failed = false private var previousFrameTimeNanos = 0L - fun renderFrame(target: WebGLRenderTarget, frameTimeNanos: Long): Unit = + fun renderFrame(target: WebGLRenderScope, frameTimeNanos: Long): Unit = with(target) { if (failed) return val deltaNanos = @@ -93,7 +94,7 @@ private constructor(private val three: ThreeModule, surface: WebGLRenderTarget) * The render target is only a descriptor for the framebuffer Compose owns, so it has to be * replaced whenever Compose recreated that framebuffer. */ - private fun WebGLRenderTarget.ensureRenderTarget(knotScene: ThreeKnotScene): ThreeRenderTarget { + private fun WebGLRenderScope.ensureRenderTarget(knotScene: ThreeKnotScene): ThreeRenderTarget { val current = renderTarget if (current != null && targetGeneration == generation) return current diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/VideoWebGlDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/VideoWebGlDemo.web.kt index 0729084d97567..aea890a2a31ed 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/VideoWebGlDemo.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/VideoWebGlDemo.web.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.webgl.WebGLRenderScope import androidx.compose.ui.platform.webgl.WebGLRenderTarget import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.webgl.rememberWebGLRenderTarget @@ -182,7 +183,7 @@ private class VideoTextureRenderer(private val gl: WebGLRenderingContext) { gl.bindTexture(TEXTURE_2D, null) } - fun renderFrame(target: WebGLRenderTarget, frameTimeNanos: Long) { + fun renderFrame(target: WebGLRenderScope, frameTimeNanos: Long) { val video = video ?: return val texture = texture ?: return if (!textureAllocated) { diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderScope.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderScope.web.kt new file mode 100644 index 0000000000000..50281842e2f61 --- /dev/null +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderScope.web.kt @@ -0,0 +1,85 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.platform.webgl + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.unit.IntSize +import org.khronos.webgl.WebGLFramebuffer +import org.khronos.webgl.WebGLRenderingContext +import org.w3c.dom.HTMLCanvasElement + +/** + * The frame being rendered: everything [WebGLRenderTarget.render] guarantees for the duration of + * its block, and nothing else. + * + * It is the receiver of that block, so a renderer can reach the context and the framebuffer it + * draws into without qualifying them: + * ``` + * renderTarget.render { + * webGLContext.viewport(0, 0, size.width, size.height) + * webGLContext.clearColor(0f, 0.2f, 0.4f, 1f) + * webGLContext.clear(WebGLRenderingContext.COLOR_BUFFER_BIT) + * } + * ``` + * + * Deliberately narrower than [WebGLRenderTarget] itself: rendering, disposal and + * [WebGLRenderTarget.markGLStateStale] make no sense while a frame is being drawn - the last one + * would even unbind [framebuffer] halfway through it - so they are out of scope here. Reach them + * through the target itself if you really mean to. + * + * Valid only for the duration of one [WebGLRenderTarget.render] call. Everything it exposes can + * change with the next frame, so read it per frame rather than keeping it around. + */ +@ExperimentalComposeUiApi +class WebGLRenderScope internal constructor(private val renderTarget: WebGLRenderTarget) { + + /** The `` Compose renders into, which owns [webGLContext]. */ + val htmlCanvas: HTMLCanvasElement + get() = renderTarget.htmlCanvas + + /** The WebGL2 context to render with - the very one Compose renders itself with. */ + val webGLContext: WebGLRenderingContext + get() = renderTarget.webGLContext + + /** + * Size in pixels of the area to render into: pass it to [WebGLRenderingContext.viewport] and + * base projection matrices on it. + */ + val size: IntSize + get() = renderTarget.size + + /** + * Bumped whenever the attachments behind [framebuffer] are reallocated, which happens on the + * first frame and after every size change. Use it to drop anything derived from [framebuffer] + * or [size], such as projection matrices or a third-party render target wrapping them. + */ + val generation: Int + get() = renderTarget.generation + + /** + * The framebuffer this frame draws into, bound for the whole block. Exposed for engines that + * need the raw handle, such as three.js. The same one for the whole life of the target, so only + * its attachments change when [size] does - watch [generation] for that. + * + * Rebinding it is allowed as long as the binding is restored before the block returns. Deleting + * it or its attachments is not - they belong to the target. + */ + val framebuffer: WebGLFramebuffer + get() = renderTarget.framebuffer + + override fun toString(): String = "WebGLRenderScope(size=$size, generation=$generation)" +} diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTarget.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTarget.web.kt index 70b999c216e44..8b85fdc020035 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTarget.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTarget.web.kt @@ -128,22 +128,31 @@ class WebGLRenderTarget internal constructor( } /** - * The framebuffer to render into, bound for the duration of the [render] block, or `null` - * until the first successful [render]. Exposed for engines that need the raw handle, such as - * three.js. + * The framebuffer to render into, bound for the duration of the [render] block. Exposed for + * engines that need the raw handle, such as three.js. + * + * Created together with this target and never replaced, so it can be handed to an engine once, + * at setup: a size change reallocates its attachments, not the framebuffer itself. It only + * becomes a complete framebuffer once the first [render] allocated those attachments, and + * [dispose] deletes it, after which it must not be used. * * Rebinding it inside [render] is allowed as long as the binding is restored before returning. * Deleting it or its attachments is not — they belong to this target. */ - var framebuffer: WebGLFramebuffer? = null - private set + val framebuffer: WebGLFramebuffer = + webGLContext.createFramebuffer() ?: error("gl.createFramebuffer() returned null") + + /** The depth/stencil attachment of [framebuffer]; like it, created once and only resized. */ + private val depthStencil: WebGLRenderbuffer = + webGLContext.createRenderbuffer() ?: error("gl.createRenderbuffer() returned null") /** - * Bumped whenever [framebuffer] and its attachments are recreated, which happens on the first - * [render] and after every size change. Use it to drop anything cached from [framebuffer] or - * [size], such as projection matrices or a third-party render target wrapping them. + * Bumped whenever the attachments of [framebuffer] are reallocated, which happens on the first + * [render] and after every size change. Use it to drop anything derived from [size] or from the + * color texture, such as projection matrices or a third-party render target wrapping them. + * [framebuffer] itself is stable, so it never needs to be read again. */ - var generation: Int = 0 + internal var generation: Int = 0 private set /** The Skia image sampling the color texture, or `null` until the first successful [render]. */ @@ -181,8 +190,11 @@ class WebGLRenderTarget internal constructor( _invalidation.value } + /** The receiver of every [render] block; a view of this target, so it needs no per-frame state. */ + private val renderScope: WebGLRenderScope by + lazy(LazyThreadSafetyMode.NONE) { WebGLRenderScope(this) } + private var adoptedTexture: AdoptedGLTexture? = null - private var depthStencil: WebGLRenderbuffer? = null private var isDisposed = false private var isRendering = false @@ -191,8 +203,9 @@ class WebGLRenderTarget internal constructor( * it all shows the new frame. * * Allocates or reallocates GPU resources if needed, binds [framebuffer], runs [block], then - * restores the GL state Compose's renderer expects. [block] receives this target as its - * receiver, so [size] and [framebuffer] describe the frame being drawn. + * restores the GL state Compose's renderer expects. [block] receives a [WebGLRenderScope], so + * the context, the size and the framebuffer of the frame being drawn are in scope - but not + * this target's own lifecycle, which has no meaning mid-frame. * * Prefer calling this from a [withFrameNanos] callback: the frame is then ready before Compose * draws, so the new content appears immediately. Rendering at another time is allowed, but the @@ -212,18 +225,18 @@ class WebGLRenderTarget internal constructor( * @return `false`, skipping [block], if the GPU context is not available yet — which is the * case until Compose has drawn its first frame. */ - fun render(block: WebGLRenderTarget.() -> Unit): Boolean { + fun render(block: WebGLRenderScope.() -> Unit): Boolean { if (isDisposed) return false check(!isRendering) { "render() is already running: it must not be called from within another render() call, " + "nor from a draw or layout scope" } val context = directContext() ?: return false - val framebuffer = prepareFramebuffer(context, requestedSize) + prepareAttachments(context, requestedSize) isRendering = true webGLContext.bindFramebuffer(FRAMEBUFFER, framebuffer) try { - block() + renderScope.block() } finally { isRendering = false webGLContext.bindFramebuffer(FRAMEBUFFER, null) @@ -249,23 +262,17 @@ class WebGLRenderTarget internal constructor( context.resetAll() } - private fun prepareFramebuffer( + /** Allocates the attachments of [framebuffer] for [size], unless they already have that size. */ + private fun prepareAttachments( context: DirectContext, size: IntSize, - ): WebGLFramebuffer { + ) { val current = adoptedTexture - if (current != null && current.size == size) return framebuffer!! + if (current != null && current.size == size) return current?.dispose() adoptedTexture = null - val framebuffer = - framebuffer ?: webGLContext.createFramebuffer() ?: error("createFramebuffer failed") - this.framebuffer = framebuffer - val depthStencil = - depthStencil ?: webGLContext.createRenderbuffer() ?: error("createRenderbuffer failed") - this.depthStencil = depthStencil - val adopted = webGLContext.adoptNewTexture(context, size, textureFactory(size)) this.adoptedTexture = adopted @@ -295,7 +302,6 @@ class WebGLRenderTarget internal constructor( this.size = size generation++ - return framebuffer } /** @@ -308,10 +314,8 @@ class WebGLRenderTarget internal constructor( adoptedTexture?.dispose() adoptedTexture = null size = IntSize.Zero - framebuffer?.let(webGLContext::deleteFramebuffer) - framebuffer = null - depthStencil?.let(webGLContext::deleteRenderbuffer) - depthStencil = null + webGLContext.deleteFramebuffer(framebuffer) + webGLContext.deleteRenderbuffer(depthStencil) webGLContext.bindFramebuffer(FRAMEBUFFER, null) directContext()?.resetAll() } diff --git a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetPainterTests.kt b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetPainterTests.kt index 7bca0234e5e80..9ba071571072d 100644 --- a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetPainterTests.kt +++ b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetPainterTests.kt @@ -51,7 +51,7 @@ private fun DrawScope.drawPainter(painter: Painter, size: Size = this.size) { } /** The whole "renderer": clear the target to opaque red, like the ColorPulse demo. */ -private fun WebGLRenderTarget.clearToRed() { +private fun WebGLRenderScope.clearToRed() { webGLContext.viewport(0, 0, size.width, size.height) webGLContext.clearColor(1f, 0f, 0f, 1f) webGLContext.clear(COLOR_BUFFER_BIT) diff --git a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt index 59a51dac2e8b3..db8e7163d7f0a 100644 --- a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt +++ b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt @@ -40,6 +40,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertNotNull +import kotlin.test.assertSame import kotlin.test.assertTrue import org.khronos.webgl.WebGLRenderingContext import org.khronos.webgl.WebGLRenderingContext.Companion.COLOR_BUFFER_BIT @@ -49,7 +50,7 @@ import org.khronos.webgl.WebGLRenderingContext.Companion.NO_ERROR private const val OPAQUE_RED = (255 shl 24) or 255 /** The whole "renderer": clear the target to opaque red, like the ColorPulse demo. */ -private fun WebGLRenderTarget.clearToRed() { +private fun WebGLRenderScope.clearToRed() { webGLContext.viewport(0, 0, size.width, size.height) webGLContext.clearColor(1f, 0f, 0f, 1f) webGLContext.clear(COLOR_BUFFER_BIT) @@ -97,7 +98,6 @@ class WebGLRenderTargetTests : OnCanvasTests { assertTrue(drawnFrames > 0, "the painter was never drawn") assertEquals(IntSize(64, 64), target.size, "unexpected allocated size") - assertNotNull(target.framebuffer, "framebuffer was not allocated") assertNotNull(target.image, "the color texture was not adopted") assertTrue(target.generation > 0, "generation was never bumped") @@ -146,6 +146,7 @@ class WebGLRenderTargetTests : OnCanvasTests { assertTrue(target.render { clearToRed() }, "the first render() did not run") assertEquals(IntSize(32, 32), target.size, "unexpected initial size") val generationBefore = target.generation + val framebufferBefore = target.framebuffer requestedSize.value = IntSize(48, 24) awaitAnimationFrame() @@ -157,6 +158,12 @@ class WebGLRenderTargetTests : OnCanvasTests { target.generation > generationBefore, "generation did not change although the texture was reallocated" ) + // Only the attachments are reallocated, so an engine may keep the handle from setup. + assertSame( + framebufferBefore, + target.framebuffer, + "the framebuffer itself was replaced by the size change" + ) assertEquals(NO_ERROR, target.webGLContext.getError(), "reallocation reported a GL error") } From 312edc5468e613c1a7ce3d2d766eed60252ea5c0 Mon Sep 17 00:00:00 2001 From: "Oleksandr.Karpovich" Date: Tue, 25 Aug 2026 10:00:31 +0200 Subject: [PATCH 22/26] refactor api --- .../demo/webgl/HtmlInCanvasWebGlDemo.web.kt | 18 ++-- .../mpp/demo/webgl/PlainWebGlDemo.web.kt | 32 ++++--- .../mpp/demo/webgl/ThreeJsKnotRenderer.web.kt | 22 +++-- .../webgl/ThreeTextureAdoptionDemo.web.kt | 36 ++++---- .../mpp/demo/webgl/VideoWebGlDemo.web.kt | 14 +-- .../ui/platform/webgl/WebGLRenderScope.web.kt | 85 ------------------- .../platform/webgl/WebGLRenderTarget.web.kt | 15 ++-- .../webgl/WebGLRenderTargetPainterTests.kt | 14 +-- .../platform/webgl/WebGLRenderTargetTests.kt | 26 +++--- 9 files changed, 98 insertions(+), 164 deletions(-) delete mode 100644 compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderScope.web.kt diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/HtmlInCanvasWebGlDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/HtmlInCanvasWebGlDemo.web.kt index 94262bdc7513e..d5e32c3f300b1 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/HtmlInCanvasWebGlDemo.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/HtmlInCanvasWebGlDemo.web.kt @@ -41,7 +41,6 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.webgl.WebGLRenderScope import androidx.compose.ui.platform.webgl.WebGLRenderTarget import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.webgl.rememberWebGLRenderTarget @@ -74,7 +73,7 @@ private fun HtmlInCanvasWebGlDemo() { var originPx by remember { mutableStateOf(Offset.Zero) } val target = rememberWebGLRenderTarget(boundsPx) ?: return - val htmlRenderer = remember(target) { HtmlTextureRenderer(target.webGLContext, target.htmlCanvas) } + val htmlRenderer = remember(target) { HtmlTextureRenderer(target) } val supported = remember(htmlRenderer) { htmlRenderer.initialize() } DisposableEffect(htmlRenderer, target) { @@ -106,7 +105,7 @@ private fun HtmlInCanvasWebGlDemo() { LaunchedEffect(htmlRenderer, target) { while (true) { withFrameNanos { frameTimeNanos -> - target.render { htmlRenderer.renderFrame(this, frameTimeNanos) } + htmlRenderer.renderFrame(frameTimeNanos) } } } @@ -163,10 +162,9 @@ private fun HtmlInCanvasWebGlDemo() { } } -private class HtmlTextureRenderer( - private val gl: WebGLRenderingContext, - private val canvas: org.w3c.dom.HTMLCanvasElement, -) { +private class HtmlTextureRenderer(private val target: WebGLRenderTarget) { + private val gl = target.webGLContext + private val canvas = target.htmlCanvas private val program = gl.createProgram(VERTEX_SHADER_SOURCE, FRAGMENT_SHADER_SOURCE) private val texture = gl.createTexture() ?: error("gl.createTexture() returned null") private var element: HTMLElement? = null @@ -189,7 +187,11 @@ private class HtmlTextureRenderer( syncElementBox(element, widthCss.toDouble(), heightCss.toDouble(), leftCss.toDouble(), topCss.toDouble()) } - fun renderFrame(target: WebGLRenderScope, frameTimeNanos: Long) { + fun renderFrame(frameTimeNanos: Long) { + target.render { renderFrameInTarget(frameTimeNanos) } + } + + private fun renderFrameInTarget(frameTimeNanos: Long) { val element = element ?: return if (!uploadElement(gl, texture, element, target.size.width, target.size.height)) return with(target) { diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt index 6e08cbfd910ae..9cc963125bec4 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt @@ -46,7 +46,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.webgl.WebGLRenderScope import androidx.compose.ui.platform.webgl.WebGLRenderTarget import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.webgl.rememberWebGLRenderTarget @@ -88,7 +87,7 @@ private fun PlainWebGlDemo() { @Composable private fun RotatingTriangle(isAnimating: Boolean) { val renderTarget = rememberWebGLRenderTarget(IntSize(512, 320))!! - val triangle = remember(renderTarget) { TriangleRenderer(renderTarget.webGLContext) } + val triangle = remember(renderTarget) { TriangleRenderer(renderTarget) } DisposableEffect(triangle, renderTarget) { onDispose { triangle.dispose(renderTarget) } @@ -97,7 +96,7 @@ private fun RotatingTriangle(isAnimating: Boolean) { LaunchedEffect(renderTarget, isAnimating) { while (isAnimating) { withFrameNanos { frameTimeNanos -> - renderTarget.render { triangle.render(this, frameTimeNanos) } + triangle.render(frameTimeNanos) } } } @@ -115,12 +114,12 @@ private fun RotatingTriangle(isAnimating: Boolean) { @Composable private fun ColorPulse(isAnimating: Boolean) { val webGLRenderTarger = rememberWebGLRenderTarget(IntSize(64, 64))!! - val pulse = remember { PulseRenderer() } + val pulse = remember(webGLRenderTarger) { PulseRenderer(webGLRenderTarger) } LaunchedEffect(webGLRenderTarger, isAnimating) { while (isAnimating) { withFrameNanos { frameTimeNanos -> - webGLRenderTarger.render { pulse.render(this, frameTimeNanos) } + pulse.render(frameTimeNanos) } } } @@ -165,7 +164,8 @@ private fun LabelledContent(caption: String, content: @Composable () -> Unit) { * Draws a spinning, vertex-colored triangle. The geometry lives in the vertex shader, so there is * no buffer and no attribute to set up: the whole renderer is one program and two uniforms. */ -private class TriangleRenderer(private val gl: WebGLRenderingContext) { +private class TriangleRenderer(private val target: WebGLRenderTarget) { + private val gl = target.webGLContext private val program: WebGLProgram = gl.createProgram(VERTEX_SHADER_SOURCE, FRAGMENT_SHADER_SOURCE) private val angleUniform: WebGLUniformLocation? = gl.getUniformLocation(program, "angle") @@ -173,7 +173,11 @@ private class TriangleRenderer(private val gl: WebGLRenderingContext) { private var angle = 0f private var previousFrameTimeNanos = 0L - fun render(target: WebGLRenderScope, frameTimeNanos: Long): Unit = + fun render(frameTimeNanos: Long) { + target.render { renderFrame(frameTimeNanos) } + } + + private fun renderFrame(frameTimeNanos: Long): Unit = with(target) { val deltaNanos = if (previousFrameTimeNanos == 0L) { 0L @@ -205,9 +209,9 @@ private class TriangleRenderer(private val gl: WebGLRenderingContext) { /** * Releases the program */ - fun dispose(surface: WebGLRenderTarget) { + fun dispose(renderTarget: WebGLRenderTarget) { gl.deleteProgram(program) - surface.markGLStateStale() + renderTarget.markGLStateStale() } companion object { @@ -267,12 +271,16 @@ private fun WebGLRenderingContext.createProgram( } /** Clears the texture to a pulsing color. No shaders, no resources, nothing to dispose. */ -private class PulseRenderer { +private class PulseRenderer(private val target: WebGLRenderTarget) { private var phase = 0f private var previousFrameTimeNanos = 0L - fun render(scope: WebGLRenderScope, frameTimeNanos: Long): Unit = - with(scope) { + fun render(frameTimeNanos: Long) { + target.render { renderFrame(frameTimeNanos) } + } + + private fun renderFrame(frameTimeNanos: Long): Unit = + with(target) { val deltaNanos = if (previousFrameTimeNanos == 0L) { 0L } else { diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt index 22a955b1e839f..180d3ea792aa7 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeJsKnotRenderer.web.kt @@ -19,7 +19,6 @@ package androidx.compose.mpp.demo.webgl import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.platform.webgl.WebGLRenderScope import androidx.compose.ui.platform.webgl.WebGLRenderTarget /** @@ -29,11 +28,14 @@ import androidx.compose.ui.platform.webgl.WebGLRenderTarget * that is what [WebGLRenderTarget] takes care of. */ internal class ThreeJsKnotRenderer -private constructor(private val three: ThreeModule, surface: WebGLRenderTarget) { +private constructor( + private val three: ThreeModule, + private val webGLRenderTarget: WebGLRenderTarget, +) { companion object { /** Loads three.js, or returns `null` when the module is unavailable. */ - suspend fun createOrNull(surface: WebGLRenderTarget): ThreeJsKnotRenderer? = - loadThreeModule()?.let { ThreeJsKnotRenderer(it, surface) } + suspend fun createOrNull(webGLRenderTarget: WebGLRenderTarget): ThreeJsKnotRenderer? = + loadThreeModule()?.let { ThreeJsKnotRenderer(it, webGLRenderTarget) } } // The angle is the main dynamic state in this demo, it's updated every frame. @@ -50,15 +52,19 @@ private constructor(private val three: ThreeModule, surface: WebGLRenderTarget) var status: String = "waiting for the first frame" private set - private var renderer: ThreeRenderer? = createThreeRenderer(three, surface.htmlCanvas, surface.webGLContext) + private var renderer: ThreeRenderer? = createThreeRenderer(three, webGLRenderTarget.htmlCanvas, webGLRenderTarget.webGLContext) private var knotScene: ThreeKnotScene? = createKnotScene(three) private var renderTarget: ThreeRenderTarget? = null private var targetGeneration = 0 private var failed = false private var previousFrameTimeNanos = 0L - fun renderFrame(target: WebGLRenderScope, frameTimeNanos: Long): Unit = - with(target) { + fun renderFrame(frameTimeNanos: Long) { + webGLRenderTarget.render { renderFrameInTarget(frameTimeNanos) } + } + + private fun renderFrameInTarget(frameTimeNanos: Long): Unit = + with(webGLRenderTarget) { if (failed) return val deltaNanos = if (previousFrameTimeNanos == 0L) 0L @@ -94,7 +100,7 @@ private constructor(private val three: ThreeModule, surface: WebGLRenderTarget) * The render target is only a descriptor for the framebuffer Compose owns, so it has to be * replaced whenever Compose recreated that framebuffer. */ - private fun WebGLRenderScope.ensureRenderTarget(knotScene: ThreeKnotScene): ThreeRenderTarget { + private fun WebGLRenderTarget.ensureRenderTarget(knotScene: ThreeKnotScene): ThreeRenderTarget { val current = renderTarget if (current != null && targetGeneration == generation) return current diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt index a35c70711def2..ef7123a708d25 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/ThreeTextureAdoptionDemo.web.kt @@ -82,8 +82,8 @@ private fun ThreeTextureAdoptionDemo() { var textureWidth by remember { mutableStateOf(512) } val textureSize = IntSize(textureWidth, (textureWidth * 0.625f).roundToInt()) - val surface = rememberWebGLRenderTarget(textureSize) - if (surface == null) { + val renderTarget = rememberWebGLRenderTarget(textureSize) + if (renderTarget == null) { Centered( "Compose does not render through a WebGL2 canvas here, so there is no texture to adopt." ) @@ -91,9 +91,9 @@ private fun ThreeTextureAdoptionDemo() { } // three.js arrives through a dynamic import, so the renderer can only be built asynchronously. - val loadState by produceState(LoadState.Loading, surface) { + val loadState by produceState(LoadState.Loading, renderTarget) { value = try { - ThreeJsKnotRenderer.createOrNull(surface)?.let(LoadState::Ready) + ThreeJsKnotRenderer.createOrNull(renderTarget)?.let(LoadState::Ready) ?: LoadState.Failed("three.js is unavailable.") } catch (throwable: Throwable) { LoadState.Failed("Loading three.js failed: ${throwable.message}") @@ -105,7 +105,7 @@ private fun ThreeTextureAdoptionDemo() { is LoadState.Failed -> Centered(state.message) is LoadState.Ready -> ThreeSceneContent( - surface = surface, + renderTarget = renderTarget, threeJs = state.renderer, textureWidth = textureWidth, onTextureWidthChange = { textureWidth = it }, @@ -130,7 +130,7 @@ private fun Centered(message: String) { @Composable private fun ThreeSceneContent( - surface: WebGLRenderTarget, + renderTarget: WebGLRenderTarget, threeJs: ThreeJsKnotRenderer, textureWidth: Int, onTextureWidthChange: (Int) -> Unit, @@ -150,15 +150,15 @@ private fun ThreeSceneContent( threeJs.opacity = opacity threeJs.lightIntensity = lightIntensity - DisposableEffect(threeJs, surface) { onDispose { threeJs.dispose(surface) } } + DisposableEffect(threeJs, renderTarget) { onDispose { threeJs.dispose(renderTarget) } } // Everything three.js does happens inside the frame, before Compose measures, lays out and // draws, so the texture holds this frame's content by the time Skia submits the frame that // samples it. The drawing sites below only draw the result. - LaunchedEffect(surface, threeJs, running) { + LaunchedEffect(renderTarget, threeJs, running) { while (running) { withFrameNanos { frameTimeNanos -> - surface.render { threeJs.renderFrame(this, frameTimeNanos) } + threeJs.renderFrame(frameTimeNanos) } } } @@ -192,8 +192,8 @@ private fun ThreeSceneContent( verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Hero(surface) - Variants(surface) + Hero(renderTarget) + Variants(renderTarget) Card(Modifier.fillMaxWidth()) { Column( modifier = Modifier.padding(16.dp), @@ -220,7 +220,7 @@ private fun ThreeSceneContent( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - StatusLine("texture size", "${surface.size.width}×${surface.size.height}") + StatusLine("texture size", "${renderTarget.size.width}×${renderTarget.size.height}") StatusLine("state", threeJs.status) StatusLine("frame", "${stats.index} · ${stats.fps.roundToInt()} fps") } @@ -235,7 +235,7 @@ private data class FrameStats(val index: Long, val fps: Float) * The three.js output as the hero: tilted in 3D by dragging, clipped, with Compose content on top. */ @Composable -private fun Hero(surface: WebGLRenderTarget) { +private fun Hero(renderTarget: WebGLRenderTarget) { var tiltX by remember { mutableStateOf(0f) } var tiltY by remember { mutableStateOf(0f) } @@ -266,7 +266,7 @@ private fun Hero(surface: WebGLRenderTarget) { ) } Image( - painter = surface.painter, + painter = renderTarget.painter, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), @@ -288,16 +288,16 @@ private fun Hero(surface: WebGLRenderTarget) { /** The same adopted texture, drawn several times in one frame with different transformations. */ @Composable -private fun Variants(surface: WebGLRenderTarget) { +private fun Variants(renderTarget: WebGLRenderTarget) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { Image( - painter = surface.painter, + painter = renderTarget.painter, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.size(96.dp).clip(CircleShape).background(Color.LightGray), ) Image( - painter = surface.painter, + painter = renderTarget.painter, contentDescription = null, contentScale = ContentScale.Crop, modifier = @@ -311,7 +311,7 @@ private fun Variants(surface: WebGLRenderTarget) { }, ) Image( - painter = surface.painter, + painter = renderTarget.painter, contentDescription = null, contentScale = ContentScale.Crop, modifier = diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/VideoWebGlDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/VideoWebGlDemo.web.kt index aea890a2a31ed..7b6a1d7e6423f 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/VideoWebGlDemo.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/VideoWebGlDemo.web.kt @@ -44,7 +44,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.webgl.WebGLRenderScope import androidx.compose.ui.platform.webgl.WebGLRenderTarget import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.webgl.rememberWebGLRenderTarget @@ -95,7 +94,7 @@ private fun VideoPlayer( ) { val renderTarget = rememberWebGLRenderTarget(IntSize(1280, 720)) ?: return val videoRenderer = remember(renderTarget) { - VideoTextureRenderer(renderTarget.webGLContext).also { it.init(VIDEO_URL) } + VideoTextureRenderer(renderTarget).also { it.init(VIDEO_URL) } } var isPlaying by remember { mutableStateOf(false) } @@ -158,13 +157,14 @@ private fun VideoPlayer( LaunchedEffect(videoRenderer, renderTarget) { while (true) { withFrameNanos { frameTimeNanos -> - renderTarget.render { videoRenderer.renderFrame(this, frameTimeNanos) } + videoRenderer.renderFrame(frameTimeNanos) } } } } -private class VideoTextureRenderer(private val gl: WebGLRenderingContext) { +private class VideoTextureRenderer(private val target: WebGLRenderTarget) { + private val gl = target.webGLContext private val program: WebGLProgram = gl.createProgram(VERTEX_SHADER_SOURCE, FRAGMENT_SHADER_SOURCE) private val videoUniform: WebGLUniformLocation? = gl.getUniformLocation(program, "videoTexture") @@ -183,7 +183,11 @@ private class VideoTextureRenderer(private val gl: WebGLRenderingContext) { gl.bindTexture(TEXTURE_2D, null) } - fun renderFrame(target: WebGLRenderScope, frameTimeNanos: Long) { + fun renderFrame(frameTimeNanos: Long) { + target.render { renderFrameInTarget(frameTimeNanos) } + } + + private fun renderFrameInTarget(frameTimeNanos: Long) { val video = video ?: return val texture = texture ?: return if (!textureAllocated) { diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderScope.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderScope.web.kt deleted file mode 100644 index 50281842e2f61..0000000000000 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderScope.web.kt +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright 2026 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.ui.platform.webgl - -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.unit.IntSize -import org.khronos.webgl.WebGLFramebuffer -import org.khronos.webgl.WebGLRenderingContext -import org.w3c.dom.HTMLCanvasElement - -/** - * The frame being rendered: everything [WebGLRenderTarget.render] guarantees for the duration of - * its block, and nothing else. - * - * It is the receiver of that block, so a renderer can reach the context and the framebuffer it - * draws into without qualifying them: - * ``` - * renderTarget.render { - * webGLContext.viewport(0, 0, size.width, size.height) - * webGLContext.clearColor(0f, 0.2f, 0.4f, 1f) - * webGLContext.clear(WebGLRenderingContext.COLOR_BUFFER_BIT) - * } - * ``` - * - * Deliberately narrower than [WebGLRenderTarget] itself: rendering, disposal and - * [WebGLRenderTarget.markGLStateStale] make no sense while a frame is being drawn - the last one - * would even unbind [framebuffer] halfway through it - so they are out of scope here. Reach them - * through the target itself if you really mean to. - * - * Valid only for the duration of one [WebGLRenderTarget.render] call. Everything it exposes can - * change with the next frame, so read it per frame rather than keeping it around. - */ -@ExperimentalComposeUiApi -class WebGLRenderScope internal constructor(private val renderTarget: WebGLRenderTarget) { - - /** The `` Compose renders into, which owns [webGLContext]. */ - val htmlCanvas: HTMLCanvasElement - get() = renderTarget.htmlCanvas - - /** The WebGL2 context to render with - the very one Compose renders itself with. */ - val webGLContext: WebGLRenderingContext - get() = renderTarget.webGLContext - - /** - * Size in pixels of the area to render into: pass it to [WebGLRenderingContext.viewport] and - * base projection matrices on it. - */ - val size: IntSize - get() = renderTarget.size - - /** - * Bumped whenever the attachments behind [framebuffer] are reallocated, which happens on the - * first frame and after every size change. Use it to drop anything derived from [framebuffer] - * or [size], such as projection matrices or a third-party render target wrapping them. - */ - val generation: Int - get() = renderTarget.generation - - /** - * The framebuffer this frame draws into, bound for the whole block. Exposed for engines that - * need the raw handle, such as three.js. The same one for the whole life of the target, so only - * its attachments change when [size] does - watch [generation] for that. - * - * Rebinding it is allowed as long as the binding is restored before the block returns. Deleting - * it or its attachments is not - they belong to the target. - */ - val framebuffer: WebGLFramebuffer - get() = renderTarget.framebuffer - - override fun toString(): String = "WebGLRenderScope(size=$size, generation=$generation)" -} diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTarget.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTarget.web.kt index 8b85fdc020035..26a43adca489a 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTarget.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTarget.web.kt @@ -190,10 +190,6 @@ class WebGLRenderTarget internal constructor( _invalidation.value } - /** The receiver of every [render] block; a view of this target, so it needs no per-frame state. */ - private val renderScope: WebGLRenderScope by - lazy(LazyThreadSafetyMode.NONE) { WebGLRenderScope(this) } - private var adoptedTexture: AdoptedGLTexture? = null private var isDisposed = false private var isRendering = false @@ -203,9 +199,8 @@ class WebGLRenderTarget internal constructor( * it all shows the new frame. * * Allocates or reallocates GPU resources if needed, binds [framebuffer], runs [block], then - * restores the GL state Compose's renderer expects. [block] receives a [WebGLRenderScope], so - * the context, the size and the framebuffer of the frame being drawn are in scope - but not - * this target's own lifecycle, which has no meaning mid-frame. + * restores the GL state Compose's renderer expects. The [block] runs while this target's + * framebuffer is bound; use this target's context, size and framebuffer to draw. * * Prefer calling this from a [withFrameNanos] callback: the frame is then ready before Compose * draws, so the new content appears immediately. Rendering at another time is allowed, but the @@ -217,7 +212,7 @@ class WebGLRenderTarget internal constructor( * ``` * Canvas(Modifier.fillMaxSize()) { * // Wrong: render() must not run while Compose is drawing. - * renderTarget.render { renderer.drawFrame(this) } + * renderTarget.render { renderer.drawFrame(renderTarget) } * with(renderTarget.painter) { draw(size) } * } * ``` @@ -225,7 +220,7 @@ class WebGLRenderTarget internal constructor( * @return `false`, skipping [block], if the GPU context is not available yet — which is the * case until Compose has drawn its first frame. */ - fun render(block: WebGLRenderScope.() -> Unit): Boolean { + fun render(block: () -> Unit): Boolean { if (isDisposed) return false check(!isRendering) { "render() is already running: it must not be called from within another render() call, " + @@ -236,7 +231,7 @@ class WebGLRenderTarget internal constructor( isRendering = true webGLContext.bindFramebuffer(FRAMEBUFFER, framebuffer) try { - renderScope.block() + block() } finally { isRendering = false webGLContext.bindFramebuffer(FRAMEBUFFER, null) diff --git a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetPainterTests.kt b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetPainterTests.kt index 9ba071571072d..b1c0e81dfd1bf 100644 --- a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetPainterTests.kt +++ b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetPainterTests.kt @@ -51,10 +51,10 @@ private fun DrawScope.drawPainter(painter: Painter, size: Size = this.size) { } /** The whole "renderer": clear the target to opaque red, like the ColorPulse demo. */ -private fun WebGLRenderScope.clearToRed() { - webGLContext.viewport(0, 0, size.width, size.height) - webGLContext.clearColor(1f, 0f, 0f, 1f) - webGLContext.clear(COLOR_BUFFER_BIT) +private fun clearToRed(target: WebGLRenderTarget): Boolean = target.render { + target.webGLContext.viewport(0, 0, target.size.width, target.size.height) + target.webGLContext.clearColor(1f, 0f, 0f, 1f) + target.webGLContext.clear(COLOR_BUFFER_BIT) } class WebGLRenderTargetPainterTests : OnCanvasTests { @@ -105,7 +105,7 @@ class WebGLRenderTargetPainterTests : OnCanvasTests { awaitAnimationFrame() awaitIdle() - assertTrue(target.render { clearToRed() }, "the first render() did not run") + assertTrue(clearToRed(target), "the first render() did not run") assertEquals( Size(32f, 32f), target.painter.intrinsicSize, @@ -116,7 +116,7 @@ class WebGLRenderTargetPainterTests : OnCanvasTests { awaitAnimationFrame() awaitIdle() - assertTrue(target.render { clearToRed() }, "render() did not run after the size change") + assertTrue(clearToRed(target), "render() did not run after the size change") assertEquals( Size(48f, 24f), target.painter.intrinsicSize, @@ -143,7 +143,7 @@ class WebGLRenderTargetPainterTests : OnCanvasTests { if (target != null) { LaunchedEffect(target) { repeat(frames) { - withFrameNanos { if (target.render { clearToRed() }) renderedFrames++ } + withFrameNanos { if (clearToRed(target)) renderedFrames++ } } } // Drawn by hand, so that the draw counter sits in the very scope that the diff --git a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt index db8e7163d7f0a..0eeeee371ab33 100644 --- a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt +++ b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt @@ -50,10 +50,10 @@ import org.khronos.webgl.WebGLRenderingContext.Companion.NO_ERROR private const val OPAQUE_RED = (255 shl 24) or 255 /** The whole "renderer": clear the target to opaque red, like the ColorPulse demo. */ -private fun WebGLRenderScope.clearToRed() { - webGLContext.viewport(0, 0, size.width, size.height) - webGLContext.clearColor(1f, 0f, 0f, 1f) - webGLContext.clear(COLOR_BUFFER_BIT) +private fun clearToRed(target: WebGLRenderTarget): Boolean = target.render { + target.webGLContext.viewport(0, 0, target.size.width, target.size.height) + target.webGLContext.clearColor(1f, 0f, 0f, 1f) + target.webGLContext.clear(COLOR_BUFFER_BIT) } class WebGLRenderTargetTests : OnCanvasTests { @@ -76,7 +76,7 @@ class WebGLRenderTargetTests : OnCanvasTests { LaunchedEffect(target) { repeat(frames) { withFrameNanos { - if (target.render { clearToRed() }) renderedFrames++ + if (clearToRed(target)) renderedFrames++ } } } @@ -105,9 +105,13 @@ class WebGLRenderTargetTests : OnCanvasTests { var centerPixel = 0 var glError = -1 val rendered = target.render { - clearToRed() - centerPixel = readPixelRgba8(webGLContext, size.width / 2, size.height / 2) - glError = webGLContext.getError() + with (target) { + webGLContext.viewport(0, 0, size.width, size.height) + webGLContext.clearColor(1f, 0f, 0f, 1f) + webGLContext.clear(COLOR_BUFFER_BIT) + centerPixel = readPixelRgba8(webGLContext, size.width / 2, size.height / 2) + glError = webGLContext.getError() + } } assertTrue(rendered, "render() did not run after Compose's first frame") @@ -143,7 +147,7 @@ class WebGLRenderTargetTests : OnCanvasTests { awaitAnimationFrame() awaitIdle() - assertTrue(target.render { clearToRed() }, "the first render() did not run") + assertTrue(clearToRed(target), "the first render() did not run") assertEquals(IntSize(32, 32), target.size, "unexpected initial size") val generationBefore = target.generation val framebufferBefore = target.framebuffer @@ -152,7 +156,7 @@ class WebGLRenderTargetTests : OnCanvasTests { awaitAnimationFrame() awaitIdle() - assertTrue(target.render { clearToRed() }, "render() did not run after the size change") + assertTrue(clearToRed(target), "render() did not run after the size change") assertEquals(IntSize(48, 24), target.size, "the new size was not applied") assertTrue( target.generation > generationBefore, @@ -185,7 +189,7 @@ class WebGLRenderTargetTests : OnCanvasTests { renderTarget = target if (target != null) { LaunchedEffect(target) { - repeat(30) { withFrameNanos { target.render { clearToRed() } } } + repeat(30) { withFrameNanos { clearToRed(target) } } } // Setting the density to 2 so the test works correctly on all displays CompositionLocalProvider(LocalDensity provides Density(2f)) { From e1a9d1ac1cdc1efc50b5fefc5e753843e806f8e5 Mon Sep 17 00:00:00 2001 From: "Oleksandr.Karpovich" Date: Tue, 25 Aug 2026 11:31:13 +0200 Subject: [PATCH 23/26] refactor api --- .../platform/webgl/WebGLRenderTarget.web.kt | 52 ++++++++++++++----- .../platform/webgl/WebGLRenderTargetTests.kt | 5 ++ 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTarget.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTarget.web.kt index 26a43adca489a..a15d5044fb2e6 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTarget.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTarget.web.kt @@ -101,7 +101,6 @@ class WebGLRenderTarget internal constructor( val htmlCanvas: HTMLCanvasElement, val webGLContext: WebGLRenderingContext, private val directContext: () -> DirectContext?, - private val textureFactory: (IntSize) -> WebGLTexture, initialSize: IntSize, ) { @@ -139,12 +138,24 @@ class WebGLRenderTarget internal constructor( * Rebinding it inside [render] is allowed as long as the binding is restored before returning. * Deleting it or its attachments is not — they belong to this target. */ - val framebuffer: WebGLFramebuffer = + val framebuffer: WebGLFramebuffer by lazy { webGLContext.createFramebuffer() ?: error("gl.createFramebuffer() returned null") + } + + /** + * The WebGL texture backing this render target. The texture object remains stable for the + * lifetime of the [WebGLRenderTarget]. Its storage is configured and resized by the + * [WebGLRenderTarget]. Callers may bind and attach it to their own framebuffer, but must not + * delete it or change its storage or texture parameters. + */ + val webGlTexture: WebGLTexture by lazy { + webGLContext.createTexture() ?: error("gl.createTexture() returned null") + } /** The depth/stencil attachment of [framebuffer]; like it, created once and only resized. */ - private val depthStencil: WebGLRenderbuffer = + private val depthStencil: WebGLRenderbuffer by lazy { webGLContext.createRenderbuffer() ?: error("gl.createRenderbuffer() returned null") + } /** * Bumped whenever the attachments of [framebuffer] are reallocated, which happens on the first @@ -181,7 +192,14 @@ class WebGLRenderTarget internal constructor( * The same instance is returned every time, so that drawing it does not restart on every * recomposition. */ - val painter: Painter by lazy(LazyThreadSafetyMode.NONE) { WebGLRenderTargetPainter(this) } + val painter: Painter by lazy { WebGLRenderTargetPainter(this) } + + /** + * Called before the current texture-backed render resource becomes unavailable. + * It happens when the texture is about to be reconfigured for a new size or the + * [WebGLRenderTarget] is being disposed. + */ + var onTextureWillBeInvalidated: (() -> Unit)? = null private val _invalidation = mutableLongStateOf(0L) @@ -265,10 +283,15 @@ class WebGLRenderTarget internal constructor( val current = adoptedTexture if (current != null && current.size == size) return - current?.dispose() + if (current != null) { + onTextureWillBeInvalidated?.invoke() + current.dispose() + } + adoptedTexture = null - val adopted = webGLContext.adoptNewTexture(context, size, textureFactory(size)) + webGLContext.configureWebGLTexture(webGlTexture, size) + val adopted = webGLContext.adoptNewTexture(context, size, webGlTexture) this.adoptedTexture = adopted webGLContext.bindRenderbuffer(RENDERBUFFER, depthStencil) @@ -306,8 +329,11 @@ class WebGLRenderTarget internal constructor( internal fun dispose() { if (isDisposed) return isDisposed = true - adoptedTexture?.dispose() - adoptedTexture = null + if (adoptedTexture != null) { + onTextureWillBeInvalidated?.invoke() + adoptedTexture?.dispose() + adoptedTexture = null + } size = IntSize.Zero webGLContext.deleteFramebuffer(framebuffer) webGLContext.deleteRenderbuffer(depthStencil) @@ -316,9 +342,11 @@ class WebGLRenderTarget internal constructor( } } -private fun WebGLRenderingContext.defaultWebGLTexture(size: IntSize): WebGLTexture { +private fun WebGLRenderingContext.configureWebGLTexture( + texture: WebGLTexture, + size: IntSize +) { val gl = this - val texture = gl.createTexture() ?: error("gl.createTexture() returned null") gl.bindTexture(TEXTURE_2D, texture) // Configure the texture gl.texImage2D(TEXTURE_2D, 0, RGBA, size.width, size.height, 0, RGBA, UNSIGNED_BYTE, null) @@ -329,7 +357,6 @@ private fun WebGLRenderingContext.defaultWebGLTexture(size: IntSize): WebGLTextu gl.texParameteri(TEXTURE_2D, TEXTURE_WRAP_S, CLAMP_TO_EDGE) gl.texParameteri(TEXTURE_2D, TEXTURE_WRAP_T, CLAMP_TO_EDGE) gl.bindTexture(TEXTURE_2D, null) - return texture } /** @@ -356,9 +383,6 @@ fun rememberWebGLRenderTarget( htmlCanvas = canvas, webGLContext = gl, directContext = { window.skiaDirectContext }, - textureFactory = { size -> - gl.defaultWebGLTexture(size) - }, initialSize = size ) } diff --git a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt index 0eeeee371ab33..54f97675a3abd 100644 --- a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt +++ b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt @@ -144,10 +144,14 @@ class WebGLRenderTargetTests : OnCanvasTests { } val target = renderTarget ?: return@runApplicationTest skipWithoutWebGL2() + var invalidationCount = 0 + target.onTextureWillBeInvalidated = { invalidationCount++ } + awaitAnimationFrame() awaitIdle() assertTrue(clearToRed(target), "the first render() did not run") + assertEquals(0, invalidationCount, "the first render unexpectedly invalidated the texture") assertEquals(IntSize(32, 32), target.size, "unexpected initial size") val generationBefore = target.generation val framebufferBefore = target.framebuffer @@ -157,6 +161,7 @@ class WebGLRenderTargetTests : OnCanvasTests { awaitIdle() assertTrue(clearToRed(target), "render() did not run after the size change") + assertEquals(1, invalidationCount, "the texture invalidation listener was not invoked") assertEquals(IntSize(48, 24), target.size, "the new size was not applied") assertTrue( target.generation > generationBefore, From ebb114311c5b1f988eaf501f9e5aeaa282085baa Mon Sep 17 00:00:00 2001 From: "Oleksandr.Karpovich" Date: Tue, 25 Aug 2026 11:46:31 +0200 Subject: [PATCH 24/26] refactor api --- .../platform/webgl/WebGLRenderTarget.web.kt | 135 +++++++++--------- .../webgl/WebGLRenderTargetPainter.web.kt | 2 +- .../platform/webgl/WebGLRenderTargetTests.kt | 29 ++++ 3 files changed, 98 insertions(+), 68 deletions(-) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTarget.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTarget.web.kt index a15d5044fb2e6..fc517e6c50237 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTarget.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTarget.web.kt @@ -28,7 +28,10 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.toIntSize import androidx.compose.ui.window.LocalComposeWindow import org.jetbrains.skia.DirectContext import org.jetbrains.skia.Image @@ -58,16 +61,13 @@ private const val GL_DEPTH24_STENCIL8 = 0x88F0 private const val GL_DEPTH_STENCIL_ATTACHMENT = 0x821A /** - * An offscreen render target that lets WebGL content take part in Compose rendering: WebGL code - * draws into it inside [render], and Compose displays the result through its [painter]. + * An offscreen render target that lets WebGL content take part in Compose rendering. * - * It takes care of everything that hand-off needs. It owns the GPU resources — a [framebuffer] with - * a color texture and a depth/stencil buffer, in the very WebGL context and `` Compose - * renders with — restores the GL state Compose's renderer expects after each frame, and redraws the - * Compose content once a new frame is ready. Compose draws the color texture as it is, copying no - * pixels. + * WebGL code renders into this target with [render]. Compose displays the resulting texture through + * [painter]. The target owns the framebuffer, color texture, and depth/stencil buffer, and restores + * the GL state expected by Compose after each frame. * - * Obtain an instance with [rememberWebGLRenderTarget], which also disposes it when it leaves the + * Obtain an instance with [rememberWebGLRenderTarget], which disposes it when it leaves the * composition. * * Usage example: @@ -107,16 +107,13 @@ class WebGLRenderTarget internal constructor( private var requestedSize: IntSize = initialSize.coerceAtLeastOnePixel() /** - * Size in pixels of the [framebuffer], i.e. the area to render into: pass it to - * [WebGLRenderingContext.viewport] and base projection matrices on it. - * [IntSize.Zero] until the first successful [render]. + * The size, in pixels, of the [framebuffer] and its color texture. * - * Changing the size passed to [rememberWebGLRenderTarget] updates this on the next [render], so - * it always describes the framebuffer the current frame draws into. + * This is [IntSize.Zero] until the first successful [render]. A size supplied to + * [rememberWebGLRenderTarget] is applied by the next [render]. * - * Backed by snapshot state, so layout that derives from it - such as a [Painter] sized by - * [Painter.intrinsicSize] - is redone when the size changes. Only written from [render], which - * must never run while Compose is drawing. + * This is snapshot state, so changes invalidate layout that depends on it, such as a painter's + * intrinsic size. */ var size: IntSize by mutableStateOf(IntSize.Zero) private set @@ -127,26 +124,27 @@ class WebGLRenderTarget internal constructor( } /** - * The framebuffer to render into, bound for the duration of the [render] block. Exposed for - * engines that need the raw handle, such as three.js. + * The framebuffer used by [render]. * - * Created together with this target and never replaced, so it can be handed to an engine once, - * at setup: a size change reallocates its attachments, not the framebuffer itself. It only - * becomes a complete framebuffer once the first [render] allocated those attachments, and - * [dispose] deletes it, after which it must not be used. + * This framebuffer is created once and remains stable for the target's lifetime. Its color and + * depth/stencil attachments are configured and resized as needed. It is complete only after the + * first successful [render]. * - * Rebinding it inside [render] is allowed as long as the binding is restored before returning. - * Deleting it or its attachments is not — they belong to this target. + * Callers may temporarily rebind it inside [render], but must restore the binding before + * returning. Callers must not delete the framebuffer or its attachments. */ - val framebuffer: WebGLFramebuffer by lazy { + val framebuffer: WebGLFramebuffer by lazy { webGLContext.createFramebuffer() ?: error("gl.createFramebuffer() returned null") } /** - * The WebGL texture backing this render target. The texture object remains stable for the - * lifetime of the [WebGLRenderTarget]. Its storage is configured and resized by the - * [WebGLRenderTarget]. Callers may bind and attach it to their own framebuffer, but must not - * delete it or change its storage or texture parameters. + * The WebGL texture backing this render target. + * + * Its storage is allocated lazily and resized when necessary in [render]. + * + * Callers may register or attach this texture to another framebuffer, but must not delete it, + * reallocate its storage, or change its texture parameters. Callers must stop using it when + * [onTextureWillBeInvalidated] is invoked. */ val webGlTexture: WebGLTexture by lazy { webGLContext.createTexture() ?: error("gl.createTexture() returned null") @@ -158,39 +156,26 @@ class WebGLRenderTarget internal constructor( } /** - * Bumped whenever the attachments of [framebuffer] are reallocated, which happens on the first - * [render] and after every size change. Use it to drop anything derived from [size] or from the - * color texture, such as projection matrices or a third-party render target wrapping them. - * [framebuffer] itself is stable, so it never needs to be read again. + * Increments whenever the target is first configured or its size changes. + * Use this to refresh external render-target metadata derived from [size] or [webGlTexture]. */ - internal var generation: Int = 0 + var generation: Int = 0 private set - /** The Skia image sampling the color texture, or `null` until the first successful [render]. */ - internal val image: Image? - get() = adoptedTexture?.image - /** - * Draws the last frame rendered into this target, for the standard Compose drawing APIs: + * A painter that draws the most recently rendered frame. + * + * Its [Painter.intrinsicSize] is [size] after the first successful [render], and unspecified + * before then. Drawing the painter issues no WebGL commands; it samples the texture populated by + * [render]. + * + * The same painter instance is returned on every access. + * Examples: * ``` * Image(renderTarget.painter, contentDescription = null, contentScale = ContentScale.Crop) * Box(Modifier.paint(renderTarget.painter, contentScale = ContentScale.Fit)) * Canvas(Modifier.fillMaxSize()) { with(renderTarget.painter) { draw(size) } } * ``` - * - * Its [Painter.intrinsicSize] is [size] as soon as a frame exists, and `Size.Unspecified` - * before that, so scaling and alignment are up to the caller, as for any other painter. Prefer - * `Image`, which clips the frame to its bounds: the painter fills the size it is given, so - * `ContentScale.Crop` scales the frame beyond that size and `Modifier.paint` alone would let it - * spill over its neighbours unless `Modifier.clipToBounds` is added. Note also that - * `Modifier.paint` defaults to `ContentScale.Inside`, which never scales a frame up. - * - * Drawing it issues no GL commands, only a draw of the texture that [render] filled, so it is - * safe inside graphics layers such as `clip` and `blur`, and can draw the same frame in several - * places. Each new frame repeats the drawing on its own, without recomposing. - * - * The same instance is returned every time, so that drawing it does not restart on every - * recomposition. */ val painter: Painter by lazy { WebGLRenderTargetPainter(this) } @@ -208,7 +193,7 @@ class WebGLRenderTarget internal constructor( _invalidation.value } - private var adoptedTexture: AdoptedGLTexture? = null + internal var adoptedTexture: AdoptedGLTexture? = null private var isDisposed = false private var isRendering = false @@ -216,21 +201,17 @@ class WebGLRenderTarget internal constructor( * Renders one frame into this target and invalidates everything that draws its [painter], so * it all shows the new frame. * - * Allocates or reallocates GPU resources if needed, binds [framebuffer], runs [block], then - * restores the GL state Compose's renderer expects. The [block] runs while this target's - * framebuffer is bound; use this target's context, size and framebuffer to draw. - * - * Prefer calling this from a [withFrameNanos] callback: the frame is then ready before Compose - * draws, so the new content appears immediately. Rendering at another time is allowed, but the - * content only appears in a later Compose frame. + * Allocates or resizes GPU resources as needed, binds [framebuffer], invokes [block], then + * restores the GL state expected by Compose. * - * Never call this from a draw scope, such as a `Canvas` or `Modifier.drawBehind`: the GL state - * would be reset while Compose is drawing the frame, and the invalidation would come from - * within the drawing it invalidates, keeping that drawing repeating with no loop to stop: + * Prefer calling this from a [withFrameNanos] callback. Do not call it from a draw or layout + * scope, such as a `Canvas` or `Modifier.drawBehind`. * ``` * Canvas(Modifier.fillMaxSize()) { * // Wrong: render() must not run while Compose is drawing. - * renderTarget.render { renderer.drawFrame(renderTarget) } + * renderTarget.render { + * // webGL commands + * } * with(renderTarget.painter) { draw(size) } * } * ``` @@ -323,8 +304,10 @@ class WebGLRenderTarget internal constructor( } /** - * Releases the framebuffer and its attachments. Called by [rememberWebGLRenderTarget] when the - * target leaves the composition; calling it twice is a no-op. + * Disposes the target's GPU resources. + * + * Called automatically by [rememberWebGLRenderTarget] when the target leaves the composition. + * Calling this more than once has no effect. */ internal fun dispose() { if (isDisposed) return @@ -392,6 +375,24 @@ fun rememberWebGLRenderTarget( return renderTarget } +/** + * Remembers a [WebGLRenderTarget] of [size] in density-independent pixels, disposing it when it + * leaves the composition. + * + * The size is converted to physical pixels using [LocalDensity]. A changed [size] reallocates the + * target's GPU resources on the next [WebGLRenderTarget.render]. + * + * @return The target, or `null` if the browser does not support WebGL2. + */ +@ExperimentalComposeUiApi +@Composable +fun rememberWebGLRenderTarget( + size: DpSize +): WebGLRenderTarget? { + val density = LocalDensity.current + return rememberWebGLRenderTarget(with(density) { size.toSize().toIntSize() }) +} + private fun IntSize.coerceAtLeastOnePixel(): IntSize = if (width >= 1 && height >= 1) this else IntSize(width.coerceAtLeast(1), height.coerceAtLeast(1)) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetPainter.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetPainter.web.kt index 7fb571b47fd1e..c28a71809e0cd 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetPainter.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetPainter.web.kt @@ -56,7 +56,7 @@ internal class WebGLRenderTargetPainter( // Schedules the next redraw once a new frame is rendered, without recomposing. renderTarget.observeInvalidation() - val image = renderTarget.image ?: return + val image = renderTarget.adoptedTexture?.image ?: return if (size.width <= 0f || size.height <= 0f) return if (image.width <= 0 || image.height <= 0) return diff --git a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt index 54f97675a3abd..9d86c58faf8e6 100644 --- a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt +++ b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.WebApplicationScope import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import kotlin.test.Test @@ -43,7 +44,10 @@ import kotlin.test.assertNotNull import kotlin.test.assertSame import kotlin.test.assertTrue import org.khronos.webgl.WebGLRenderingContext +import org.khronos.webgl.WebGLRenderingContext.Companion.COLOR_ATTACHMENT0 import org.khronos.webgl.WebGLRenderingContext.Companion.COLOR_BUFFER_BIT +import org.khronos.webgl.WebGLRenderingContext.Companion.FRAMEBUFFER +import org.khronos.webgl.WebGLRenderingContext.Companion.FRAMEBUFFER_ATTACHMENT_OBJECT_NAME import org.khronos.webgl.WebGLRenderingContext.Companion.NO_ERROR /** Opaque red as `0xRRGGBBAA`, chosen because every channel is exact in RGBA8. */ @@ -58,6 +62,24 @@ private fun clearToRed(target: WebGLRenderTarget): Boolean = target.render { class WebGLRenderTargetTests : OnCanvasTests { + @Test + fun dpSizeIsConvertedToPixelsUsingCurrentDensity() = runApplicationTest { + var renderTarget: WebGLRenderTarget? = null + + createComposeWindow { + CompositionLocalProvider(LocalDensity provides Density(3f)) { + renderTarget = rememberWebGLRenderTarget(DpSize(32.dp, 16.dp)) + } + } + + val target = renderTarget ?: return@runApplicationTest skipWithoutWebGL2() + awaitAnimationFrame() + awaitIdle() + + assertTrue(clearToRed(target), "render() did not run") + assertEquals(IntSize(96, 48), target.size, "DpSize was converted incorrectly") + } + /** * The simplest possible renderer: clear the target to a known color. Verifies that a frame * reaches the texture, that Compose draws it, and that WebGL reports no error along the way. @@ -111,6 +133,13 @@ class WebGLRenderTargetTests : OnCanvasTests { webGLContext.clear(COLOR_BUFFER_BIT) centerPixel = readPixelRgba8(webGLContext, size.width / 2, size.height / 2) glError = webGLContext.getError() + + val attachedTexture = webGLContext.getFramebufferAttachmentParameter( + FRAMEBUFFER, + COLOR_ATTACHMENT0, + FRAMEBUFFER_ATTACHMENT_OBJECT_NAME, + ) + assertSame(target.webGlTexture, attachedTexture, "unexpected framebuffer texture") } } From 3b580be53fc45894007ec186d5f413266f3edefa Mon Sep 17 00:00:00 2001 From: "Oleksandr.Karpovich" Date: Tue, 25 Aug 2026 12:16:45 +0200 Subject: [PATCH 25/26] fix tests compilation --- .../compose/ui/platform/webgl/WebGLRenderTargetTests.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt index 9d86c58faf8e6..f721b9d5f8134 100644 --- a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt +++ b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/webgl/WebGLRenderTargetTests.kt @@ -120,7 +120,7 @@ class WebGLRenderTargetTests : OnCanvasTests { assertTrue(drawnFrames > 0, "the painter was never drawn") assertEquals(IntSize(64, 64), target.size, "unexpected allocated size") - assertNotNull(target.image, "the color texture was not adopted") + assertNotNull(target.adoptedTexture?.image, "the color texture was not adopted") assertTrue(target.generation > 0, "generation was never bumped") // Read the frame back from the target's own framebuffer, which render() keeps bound. From 291589733c98151b23951a5ab4ecaf4bfb611a4d Mon Sep 17 00:00:00 2001 From: "Oleksandr.Karpovich" Date: Tue, 25 Aug 2026 12:23:30 +0200 Subject: [PATCH 26/26] fix demo --- .../compose/mpp/demo/webgl/PlainWebGlDemo.web.kt | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt index 9cc963125bec4..28df131b1d0d6 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/webgl/PlainWebGlDemo.web.kt @@ -113,10 +113,16 @@ private fun RotatingTriangle(isAnimating: Boolean) { @Composable private fun ColorPulse(isAnimating: Boolean) { - val webGLRenderTarger = rememberWebGLRenderTarget(IntSize(64, 64))!! - val pulse = remember(webGLRenderTarger) { PulseRenderer(webGLRenderTarger) } + val webGLRenderTarget = rememberWebGLRenderTarget(IntSize(64, 64)) - LaunchedEffect(webGLRenderTarger, isAnimating) { + if (webGLRenderTarget == null) { + Text("webGLRenderTarget is null") + return + } + + val pulse = remember(webGLRenderTarget) { PulseRenderer(webGLRenderTarget) } + + LaunchedEffect(webGLRenderTarget, isAnimating) { while (isAnimating) { withFrameNanos { frameTimeNanos -> pulse.render(frameTimeNanos) @@ -126,7 +132,7 @@ private fun ColorPulse(isAnimating: Boolean) { LabelledContent("64×64 texture\nnothing but a pulsing clear color") { Image( - painter = webGLRenderTarger.painter, + painter = webGLRenderTarget.painter, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(),