Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions compose/mpp/demo/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -168,12 +168,10 @@ kotlin {

dependencies {
implementation(libs.kotlinSerializationJson)
}
}
implementation(libs.kotlinXw3c)

val wasmJsMain by getting {
dependencies {
api(libs.kotlinXw3c)
// https://github.com/mrdoob/three.js/ for WebGl demo
implementation(npm("three", "0.185.0"))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package androidx.compose.mpp.demo
import androidx.compose.mpp.demo.bugs.BugsScreen
import androidx.compose.mpp.demo.components.text.loadResource
import androidx.compose.mpp.demo.interops.HtmlInteropDemos
import androidx.compose.mpp.demo.webgl.ThreeJsTextureAdoptionScreen
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.mpp.demo.embedded.embeddedScrollDemo
import androidx.compose.runtime.mutableStateOf
Expand Down Expand Up @@ -71,6 +72,7 @@ fun defaultComposeDemo() {
},
HtmlInteropDemos,
HapticFeedbackExample,
ThreeJsTextureAdoptionScreen,
)
) }

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* 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

// See https://registry.khronos.org/OpenGL/api/GL/glcorearb.h
// #define GL_RGBA8 0x8058
internal const val GL_RGBA8 = 0x8058

/**
* A helper wrapper for values associated with the WebGL texture.
*
* @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,
val textureId: Int,
val image: Image,
)

/**
* @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,
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)
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 {
val image = BackendTexture.makeGL(
width = size.width,
height = size.height,
isMipmapped = false,
textureId = textureId,
textureTarget = TEXTURE_2D,
textureFormat = GL_RGBA8,
).use { backendTexture ->
Image.adoptTextureFrom(
context,
backendTexture,
SurfaceOrigin.BOTTOM_LEFT,
ColorType.RGBA_8888,
ColorAlphaType.PREMUL,
)
}
ownershipTransferred = true
return AdoptedGlTexture(texture, textureId, image)
} finally {
if (!ownershipTransferred) {
unregisterTexture(textureId)
deleteTexture(texture)
}
}
}

internal fun webGl2ContextOf(canvas: HTMLCanvasElement): WebGLRenderingContext? =
js("canvas.getContext('webgl2')")
Original file line number Diff line number Diff line change
@@ -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<Any?>,
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: Double,
valueRange: ClosedFloatingPointRange<Float>,
valueText: String = ((value * 100).roundToInt() / 100f).toString(),
onValueChange: (Double) -> Unit,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(label, Modifier.width(110.dp), style = MaterialTheme.typography.body2)
Slider(
value = value.toFloat(),
onValueChange = { onValueChange(it.toDouble()) },
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,
)
}
}
Loading
Loading