Skip to content

Commit a11256b

Browse files
authored
Add suspend fun Bitmap.installPixelsFromArrayBuffer for web (#1158)
This function will copy the pixels bytes directly from JS ArrayBuffer to skiko linear memory. The allocated memory will be freed when SkBitmap is removed.
1 parent f30f9ee commit a11256b

8 files changed

Lines changed: 237 additions & 1 deletion

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package org.jetbrains.skia.webext
2+
3+
import kotlinx.coroutines.await
4+
import org.jetbrains.skiko.wasm.awaitSkiko
5+
6+
internal actual suspend fun getSkikoWasm(): SkikoWasm {
7+
return awaitSkiko.await().unsafeCast<SkikoWasm>()
8+
}
9+
10+
internal actual fun skikoArrayBuffer(skikoWasm: SkikoWasm): WebArrayBufferExt =
11+
js("skikoWasm.wasmExports.memory.buffer")
12+
13+
internal actual fun copyBuffer(
14+
src: WebArrayBufferExt,
15+
dst: WebArrayBufferExt,
16+
size: Int,
17+
dstOffset: Int
18+
) {
19+
js("""
20+
var dstView = new Uint8Array(dst);
21+
var srcView = new Uint8Array(src);
22+
dstView.set(srcView, dstOffset);
23+
""")
24+
}

skiko/src/jsMain/kotlin/org/jetbrains/skiko/wasm/WrapperExternal.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,4 @@ package org.jetbrains.skiko.wasm
55

66
import kotlin.js.Promise
77

8-
external val awaitSkiko: Promise<Unit>
8+
internal external val awaitSkiko: Promise<Any>

skiko/src/nativeJsMain/cpp/Bitmap.cc

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,20 @@ SKIKO_EXPORT KBoolean org_jetbrains_skia_Bitmap__1nInstallPixels
159159
return instance->installPixels(imageInfo, copy, rowBytes, deletePixelsBytes, nullptr);
160160
}
161161

162+
// Currently for web only:
163+
SKIKO_EXPORT KBoolean org_jetbrains_skia_Bitmap__1nInstallPixelsFromPointer
164+
(KNativePointer ptr, KInt width, KInt height, KInt colorType, KInt alphaType, KNativePointer colorSpacePtr, KNativePointer pixelsPtr, KInt rowBytes) {
165+
SkBitmap* instance = reinterpret_cast<SkBitmap*>(ptr);
166+
SkColorSpace* colorSpace = reinterpret_cast<SkColorSpace*>(colorSpacePtr);
167+
SkImageInfo imageInfo = SkImageInfo::Make(width,
168+
height,
169+
static_cast<SkColorType>(colorType),
170+
static_cast<SkAlphaType>(alphaType),
171+
sk_ref_sp<SkColorSpace>(colorSpace));
172+
173+
return instance->installPixels(imageInfo, pixelsPtr, rowBytes, deletePixelsBytes, nullptr);
174+
}
175+
162176
SKIKO_EXPORT KBoolean org_jetbrains_skia_Bitmap__1nAllocPixels
163177
(KNativePointer ptr) {
164178
SkBitmap* instance = reinterpret_cast<SkBitmap*>((ptr));
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package org.jetbrains.skia.webext
2+
3+
import kotlinx.coroutines.await
4+
import org.jetbrains.skiko.wasm.awaitSkiko
5+
6+
internal actual suspend fun getSkikoWasm(): SkikoWasm {
7+
return awaitSkiko.await()
8+
}
9+
10+
internal actual fun skikoArrayBuffer(skikoWasm: SkikoWasm): WebArrayBufferExt =
11+
js("skikoWasm.wasmExports.memory.buffer")
12+
13+
internal actual fun copyBuffer(
14+
src: WebArrayBufferExt,
15+
dst: WebArrayBufferExt,
16+
size: Int,
17+
dstOffset: Int
18+
) {
19+
js("""
20+
var dstView = new Uint8Array(dst);
21+
var srcView = new Uint8Array(src);
22+
dstView.set(srcView, dstOffset);
23+
""")
24+
}

skiko/src/wasmJsMain/kotlin/org/jetbrains/skiko/wasm/Wrapper.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package org.jetbrains.skiko.wasm
22

3+
import kotlinx.coroutines.await
34
import org.jetbrains.skia.impl.NativePointer
45
import org.jetbrains.skiko.GL
56
import org.jetbrains.skiko.w3c.HTMLCanvasElement
7+
import org.khronos.webgl.ArrayBuffer
68

79
@JsFun(
810
"""() => {
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
@file:OptIn(ExperimentalSkikoApi::class)
2+
3+
package org.jetbrains.skia.webext
4+
5+
import org.jetbrains.skia.impl.Native
6+
import org.jetbrains.skia.impl.NativePointer
7+
import org.jetbrains.skia.impl._malloc
8+
import org.jetbrains.skiko.ExperimentalSkikoApi
9+
10+
internal expect fun skikoArrayBuffer(skikoWasm: SkikoWasm): WebArrayBufferExt
11+
12+
/**
13+
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer
14+
*/
15+
@ExperimentalSkikoApi
16+
external interface WebArrayBufferExt {
17+
val byteLength: Int
18+
operator fun set(ix: Int, value: Byte)
19+
}
20+
internal external interface SkikoWasm
21+
22+
internal suspend fun copyBufferToSkiko(
23+
src: WebArrayBufferExt
24+
): NativePointer {
25+
val ptr = _malloc(src.byteLength)
26+
if (ptr != Native.NullPointer) {
27+
val skikoArrayBuffer = skikoArrayBuffer(getSkikoWasm())
28+
copyBuffer(src, skikoArrayBuffer, src.byteLength, ptr)
29+
}
30+
return ptr
31+
}
32+
33+
internal expect suspend fun getSkikoWasm(): SkikoWasm
34+
35+
internal expect fun copyBuffer(src: WebArrayBufferExt, dst: WebArrayBufferExt, size: Int, dstOffset: Int)
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package org.jetbrains.skia.webext
2+
3+
import org.jetbrains.skia.Bitmap
4+
import org.jetbrains.skia.ExternalSymbolName
5+
import org.jetbrains.skia.ImageInfo
6+
import org.jetbrains.skia.impl.Native
7+
import org.jetbrains.skia.impl.NativePointer
8+
import org.jetbrains.skia.impl.getPtr
9+
import org.jetbrains.skiko.ExperimentalSkikoApi
10+
11+
@ExperimentalSkikoApi
12+
suspend fun Bitmap.installPixelsFromArrayBuffer(
13+
info: ImageInfo,
14+
pixelsArrayBuffer: WebArrayBufferExt,
15+
rowBytes: Int
16+
): Boolean {
17+
val pixelsPtr = copyBufferToSkiko(pixelsArrayBuffer)
18+
if (pixelsPtr == Native.NullPointer) return false
19+
return _nInstallPixelsFromPointer(
20+
_ptr,
21+
info.width,
22+
info.height,
23+
info.colorInfo.colorType.ordinal,
24+
info.colorInfo.alphaType.ordinal,
25+
getPtr(info.colorInfo.colorSpace),
26+
pixelsPtr,
27+
rowBytes
28+
)
29+
}
30+
31+
@ExternalSymbolName("org_jetbrains_skia_Bitmap__1nInstallPixelsFromPointer")
32+
private external fun _nInstallPixelsFromPointer(
33+
ptr: NativePointer,
34+
width: Int,
35+
height: Int,
36+
colorType: Int,
37+
alphaType: Int,
38+
colorSpacePtr: NativePointer,
39+
pixelsPointer: NativePointer,
40+
rowBytes: Int
41+
): Boolean
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package org.jetbrains.skia.webext
2+
3+
import org.jetbrains.skia.Bitmap
4+
import org.jetbrains.skia.ColorAlphaType
5+
import org.jetbrains.skia.ColorType
6+
import org.jetbrains.skia.ImageInfo
7+
import org.jetbrains.skia.impl.NativePointer
8+
import org.jetbrains.skia.impl._free
9+
import org.jetbrains.skia.impl.skia_memGetByte
10+
import org.jetbrains.skiko.tests.runTest
11+
import kotlin.test.Test
12+
import kotlin.test.assertEquals
13+
import kotlin.time.measureTimedValue
14+
15+
class WebExtTest {
16+
17+
@Test
18+
fun canCopyArrayBufferToSkikoMemory() = runTest {
19+
val bytes = ByteArray(16_000_000) { (it and 0xFF).toByte() }
20+
val ba = createInt8ArrayToCopy(bytes.size)
21+
for (i in bytes.indices) ba[i] = bytes[i]
22+
23+
val ptr = measureTimedValue<NativePointer> { copyBufferToSkiko(ba.buffer) }
24+
println("copyBufferToSkiko took ${ptr.duration}\n")
25+
26+
// Sanity check
27+
assertEquals(bytes[2], skia_memGetByte(ptr.value + 2))
28+
assertEquals(bytes[228], skia_memGetByte(ptr.value + 228))
29+
assertEquals(bytes[5555], skia_memGetByte(ptr.value + 5555))
30+
assertEquals(bytes[10_000_000], skia_memGetByte(ptr.value + 10_000_000))
31+
assertEquals(bytes[14_000_000], skia_memGetByte(ptr.value + 14_000_000))
32+
_free(ptr.value)
33+
}
34+
35+
@Test
36+
fun canCreateBitmapAndSetPixelsFromArrayBuffer() = runTest {
37+
val width = 100
38+
val height = 100
39+
val bytesPerPixel = 4 // RGBA_8888
40+
val bytes = ByteArray(width * height * bytesPerPixel) { i ->
41+
val pixelIndex = i / 4
42+
val x = pixelIndex % width
43+
val y = pixelIndex / width
44+
when (i % 4) {
45+
0 -> x.toByte() // R: gradient by X
46+
1 -> y.toByte() // G: gradient by Y
47+
2 -> (x + y).toByte() // B: combined gradient
48+
3 -> 255.toByte() // A: fully opaque
49+
else -> 0
50+
}
51+
}
52+
53+
val ba = createInt8ArrayToCopy(bytes.size)
54+
for (i in bytes.indices) ba[i] = bytes[i]
55+
56+
val info = ImageInfo(width, height, ColorType.RGBA_8888, ColorAlphaType.PREMUL)
57+
val bitmap = Bitmap()
58+
59+
val success = measureTimedValue {
60+
bitmap.installPixelsFromArrayBuffer(info, ba.buffer, width * bytesPerPixel)
61+
}
62+
63+
println("installPixelsFromArrayBuffer took ${success.duration}\n")
64+
65+
@Suppress("RedundantIf") // Weird? Yes. on js success is not a boolean, it's = 1
66+
assertEquals(true, if (success.value) true else false)
67+
68+
// Check some specific pixels
69+
// (0,0) -> R=0, G=0, B=0, A=255 -> 0xFF000000
70+
assertEquals(0xFF000000.toInt(), bitmap.getColor(0, 0))
71+
72+
// (50, 0) -> R=50, G=0, B=50, A=255 -> 0xFF320032 (50 is 0x32)
73+
assertEquals(0xFF320032.toInt(), bitmap.getColor(50, 0))
74+
75+
// (0, 50) -> R=0, G=50, B=50, A=255 -> 0xFF003232
76+
assertEquals(0xFF003232.toInt(), bitmap.getColor(0, 50))
77+
78+
// (99, 99) -> R=99, G=99, B=198, A=255 -> 0xFF6363C6 (99 is 0x63, 198 is 0xC6)
79+
assertEquals(0xFF6363C6.toInt(), bitmap.getColor(99, 99))
80+
}
81+
}
82+
83+
private fun createInt8ArrayToCopy(length: Int): Int8ArrayInternal =
84+
js("new Int8Array(length)")
85+
86+
private external interface Int8ArrayInternal {
87+
val buffer: WebArrayBufferExt
88+
}
89+
90+
private operator fun Int8ArrayInternal.set(index: Int, value: Byte) {
91+
int8ArraySet(this, index, value)
92+
}
93+
94+
private fun int8ArraySet(arrayInternal: Int8ArrayInternal, index: Int, value: Byte) {
95+
js("arrayInternal[index] = value;")
96+
}

0 commit comments

Comments
 (0)