From 71571d29d670e04503aa71f791d92c5d58ddc865 Mon Sep 17 00:00:00 2001 From: Oleksandr Karpovich Date: Mon, 17 Aug 2026 19:07:12 +0000 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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