Skip to content

Instantly share code, notes, and snippets.

@Sal7one
Created December 4, 2025 22:31
Show Gist options
  • Select an option

  • Save Sal7one/17f52fd43b2b243a03a09cc9d1b48d64 to your computer and use it in GitHub Desktop.

Select an option

Save Sal7one/17f52fd43b2b243a03a09cc9d1b48d64 to your computer and use it in GitHub Desktop.
Web GPU example
package com.sal7one.webgputest
import android.os.Bundle
import android.view.SurfaceHolder
import android.view.SurfaceView
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.sal7one.webgputest.ui.theme.WebgputestTheme
class MainActivity : ComponentActivity() {
companion object {
init {
// Try to load the bundled WebGPU native library.
// If this fails, the WebGPU layer may fall back to its own loading logic.
try {
System.loadLibrary("webgpu_c_bundled")
} catch (_: Throwable) {
// Swallow: not fatal if you also support default loading elsewhere.
}
}
}
/** Current renderer bound to the active Surface. */
private var renderer: WebGpuChartsRenderer? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
WebgputestTheme {
ChartsScreen { holder ->
// Always release any previous renderer before creating a new one
// (e.g. on surface size change or configuration change).
renderer?.release()
renderer = WebGpuChartsRenderer(holder).also { it.start() }
}
}
}
}
override fun onDestroy() {
// Make sure GPU resources are released when the Activity goes away.
renderer?.release()
super.onDestroy()
}
}
// -----------------------------------------------------------------------------
// Simple palette + labels used by the legend overlay (purely UI, not WebGPU).
// -----------------------------------------------------------------------------
private val palette = listOf(
Color(0xFF2ED8B7), // Teal
Color(0xFFF25A73), // Coral
Color(0xFF688BFF), // Periwinkle
Color(0xFFF9BE3F), // Amber
Color(0xFFB375E5), // Lavender
Color(0xFF4BD989) // Mint
)
private val labels = listOf("Revenue", "Growth", "Users", "Sales", "Traffic", "Profit")
private val values = listOf("$2.4M", "+34%", "12.8K", "$890K", "1.2M", "$456K")
/**
* Top-level screen:
*
* - Fills the window with a [SurfaceView] that WebGPU renders into.
* - Draws a composable legend overlay on top (bottom-right corner).
*
* [onSurfaceReady] is called whenever the underlying [SurfaceHolder] is
* created or changed, so the caller can (re)create / reconfigure the renderer.
*/
@Composable
private fun ChartsScreen(
onSurfaceReady: (SurfaceHolder) -> Unit
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Box(modifier = Modifier.fillMaxSize()) {
// Bridge classic Android View into Compose.
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
// We use a raw SurfaceView so WebGPU can render directly
// using ANativeWindow from this Surface.
SurfaceView(context).apply {
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
// First time the surface is ready → create renderer.
onSurfaceReady(holder)
}
override fun surfaceChanged(
holder: SurfaceHolder,
format: Int,
width: Int,
height: Int
) {
// Surface size / format changed → let renderer re-configure.
onSurfaceReady(holder)
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
// Renderer cleanup is handled in Activity.onDestroy()
// or before creating the next renderer.
}
})
}
}
)
// Compose-driven UI overlay rendered on top of the WebGPU content.
LegendOverlay()
}
}
}
/**
* Legend / HUD overlay describing the series that the 3D chart is showing.
* Pure Compose UI, independent from WebGPU; just draws a semi-transparent
* card in the bottom-right corner.
*/
@Composable
private fun LegendOverlay() {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.BottomEnd
) {
Surface(
shape = RoundedCornerShape(12.dp),
color = Color(0xE6101016), // dark translucent background
tonalElevation = 2.dp
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
labels.forEachIndexed { index, label ->
Row(verticalAlignment = Alignment.CenterVertically) {
// Color swatch
Spacer(
modifier = Modifier
.size(12.dp)
.background(palette[index], RoundedCornerShape(3.dp))
)
// Label + value
Text(
text = "$label • ${values[index]}",
modifier = Modifier.padding(start = 10.dp),
color = Color(0xFFE8E8F0)
)
}
}
}
}
}
}
/**
* Preview of the legend card by itself (no WebGPU background).
*/
@Preview(showBackground = true)
@Composable
private fun LegendPreview() {
WebgputestTheme {
LegendOverlay()
}
}
/////////////////////////////////////////
//////////////////////////////////////////
//////////////////////////////////////////
package com.sal7one.webgputest
import android.annotation.SuppressLint
import android.util.Log
import android.view.SurfaceHolder
import androidx.webgpu.*
import androidx.webgpu.helper.Util
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.Executor
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* Minimal WebGPU demo renderer that draws animated 3D bar or pie charts.
*
* Lifecycle / init order (important for Android WebGPU):
* 1. Create GPUInstance
* 2. Create Surface (from SurfaceHolder → native window)
* 3. Request Adapter with `compatibleSurface`
* 4. Request Device
* 5. Configure Surface (swapchain)
* 6. Create pipeline + buffers
* 7. Enter render loop
*/
class WebGpuChartsRenderer(
// NOTE: context was removed because it was unused in this file.
// If your createInstance() helper needs it, just add it back.
private val surfaceHolder: SurfaceHolder
) {
/** Coroutine scope for the render loop (off main thread). */
private val scope = CoroutineScope(Dispatchers.Default + Job())
/** Flag to keep the render loop running. */
private val running = AtomicBoolean(false)
/** Single-thread executor for WebGPU callbacks (adapter / device). */
private val callbackExecutor: Executor = Executors.newSingleThreadExecutor()
// --- Core WebGPU objects -------------------------------------------------
private var instance: GPUInstance? = null
private var adapter: GPUAdapter? = null
private var device: GPUDevice? = null
private var queue: GPUQueue? = null
private var gpuSurface: GPUSurface? = null
private var depthTexture: GPUTexture? = null
private var pipeline: GPURenderPipeline? = null
// --- GPU buffers ---------------------------------------------------------
// Per-frame uniforms: camera, model, light, time, etc.
private var uniformBuffer: GPUBuffer? = null
// Instance data:
// - colorBuffer: per-instance RGBA
// - posBuffer: per-instance position (vec3)
// - scaleBuffer: per-instance scale / extra params (vec3)
private var colorBuffer: GPUBuffer? = null
private var posBuffer: GPUBuffer? = null
private var scaleBuffer: GPUBuffer? = null
// Vertex / normal buffers for the bar (cube) mesh
private var barVertBuffer: GPUBuffer? = null
private var barNormBuffer: GPUBuffer? = null
// Vertex / normal buffers for the pie wedge mesh
private var pieVertBuffer: GPUBuffer? = null
private var pieNormBuffer: GPUBuffer? = null
// --- Render loop state ---------------------------------------------------
private var renderJob: Job? = null
/** Current surface width / height in pixels. */
private var width = 0
private var height = 0
/** Current chart type we are drawing (bars or pie). */
private var chartType: ChartType = ChartType.Bars
/** Global rotation angle for camera animation. */
private var rotation = 0f
/** Sample data values for bars / pie segments. */
private val data = floatArrayOf(0.65f, 1.4f, 0.9f, 0.75f, 1.2f, 1.0f)
/** Number of vertices in bar / pie geometry. */
private var barVertCount = 0
private var pieVertCount = 0
/** Swapchain / surface format (chosen from capabilities). */
@SuppressLint("RestrictedApi")
private var preferredFormat: Int = TextureFormat.RGBA8Unorm
// ------------------------------------------------------------------------
// Public API
// ------------------------------------------------------------------------
/** Start the renderer (idempotent). */
fun start() {
if (running.getAndSet(true)) return
renderJob = scope.launch { initAndLoop() }
}
/**
* Stop rendering and free GPU resources.
* Call from your Surface lifecycle (e.g. onDestroy / surfaceDestroyed).
*/
fun release() {
running.set(false)
renderJob?.cancel()
// Try to unconfigure the surface safely
try {
gpuSurface?.unconfigure()
} catch (_: Throwable) {
}
// Destroy GPU resources (order here is not super strict, just consistent)
depthTexture?.close()
uniformBuffer?.close()
colorBuffer?.close()
posBuffer?.close()
scaleBuffer?.close()
barVertBuffer?.close()
barNormBuffer?.close()
pieVertBuffer?.close()
pieNormBuffer?.close()
pipeline?.close()
queue?.close()
device?.close()
adapter?.close()
instance?.close()
}
/** Switch between bar chart and pie chart. */
fun setChartType(type: ChartType) {
chartType = type
}
// ------------------------------------------------------------------------
// Init + render loop
// ------------------------------------------------------------------------
@SuppressLint("RestrictedApi")
private suspend fun initAndLoop() {
try {
// 1) GPUInstance
instance = createInstance()
if (instance == null) {
Log.e(TAG, "Failed to create GPUInstance")
return
}
Log.d(TAG, "Instance created")
// 2) Surface from SurfaceHolder / native window
if (!createSurface()) {
Log.e(TAG, "Failed to create surface")
return
}
Log.d(TAG, "Surface created")
// 3) GPUAdapter that is compatible with this surface
adapter = requestAdapterAsync()
if (adapter == null) {
Log.e(TAG, "Failed to get adapter")
return
}
Log.d(TAG, "Adapter obtained")
// 4) GPUDevice from adapter
device = requestDeviceAsync()
if (device == null) {
Log.e(TAG, "Failed to get device")
return
}
queue = device?.queue
Log.d(TAG, "Device obtained")
// 5) Configure swapchain for the surface
if (!configureSurface()) {
Log.e(TAG, "Failed to configure surface")
return
}
Log.d(TAG, "Surface configured")
// 6) Build pipeline & geometry (only once)
buildPipeline()
buildGeometry()
Log.d(TAG, "Pipeline and geometry ready")
// 7) Render loop (~60 FPS via delay(16))
while (running.get() && scope.isActive) {
renderFrame()
delay(16L)
}
} catch (t: Throwable) {
Log.e(TAG, "Render loop error", t)
}
}
// ------------------------------------------------------------------------
// Instance / surface / adapter / device
// ------------------------------------------------------------------------
/**
* Create the WebGPU instance.
*
* NOTE: This function is **not** shown in your original snippet.
* If you already have a global helper, keep using it.
* You can wire it here however your current WebGPU alpha expects.
*/
/**
* Create a GPUSurface from the Android SurfaceHolder.
* Uses Util.windowFromSurface to get ANativeWindow pointer.
*/
@SuppressLint("RestrictedApi")
private fun createSurface(): Boolean {
val surfObj = surfaceHolder.surface
if (surfObj == null || !surfObj.isValid) {
Log.e(TAG, "Surface is null/invalid")
return false
}
// SurfaceFrame gives us the current size
val frame = surfaceHolder.surfaceFrame
width = frame.width()
height = frame.height()
if (width == 0 || height == 0) {
Log.e(TAG, "Invalid surface size: ${width}x$height")
return false
}
// Get native window handle from the Android Surface
val nativeWindow = Util.windowFromSurface(surfObj)
if (nativeWindow == 0L) {
Log.e(TAG, "Native window handle is 0")
return false
}
Log.d(TAG, "Native window handle: $nativeWindow, size: ${width}x$height")
gpuSurface = instance?.createSurface(
SurfaceDescriptor(
surfaceSourceAndroidNativeWindow = SurfaceSourceAndroidNativeWindow(nativeWindow)
)
)
return gpuSurface != null
}
/**
* Request a GPUAdapter that is compatible with our surface.
* Uses the new `compatibleSurface` flow recommended for WebGPU.
*/
@SuppressLint("RestrictedApi")
private suspend fun requestAdapterAsync(): GPUAdapter? = suspendCoroutine { cont ->
val inst = instance
if (inst == null) {
cont.resume(null)
return@suspendCoroutine
}
val opts = RequestAdapterOptions(
powerPreference = PowerPreference.HighPerformance,
compatibleSurface = gpuSurface
)
inst.requestAdapter(
callbackExecutor,
opts
) { status: Int, message: String, adapter: GPUAdapter? ->
Log.d(TAG, "requestAdapter → status=$status msg=$message adapter=$adapter")
if (adapter == null) {
Log.e(TAG, "requestAdapter failed: status=$status msg=$message")
cont.resume(null)
} else {
cont.resume(adapter)
}
}
}
/**
* Request a GPUDevice from the adapter.
* Also plugs uncaptured error / device-lost callbacks for debugging.
*/
@SuppressLint("RestrictedApi")
private suspend fun requestDeviceAsync(): GPUDevice? = suspendCoroutine { cont ->
val adap = adapter
if (adap == null) {
cont.resume(null)
return@suspendCoroutine
}
val desc = DeviceDescriptor(
label = "ChartsDevice",
requiredFeatures = intArrayOf(), // no special features for this demo
requiredLimits = null,
defaultQueue = QueueDescriptor(label = "ChartsQueue"),
deviceLostCallbackExecutor = callbackExecutor,
deviceLostCallback = { device: GPUDevice, reason: Int, msg: String ->
Log.e(TAG, "Device lost: device=$device reason=$reason msg=$msg")
},
uncapturedErrorCallbackExecutor = callbackExecutor,
uncapturedErrorCallback = { device: GPUDevice, error: Int, msg: String ->
Log.e(TAG, "Uncaptured: device=$device error=$error msg=$msg")
}
)
adap.requestDevice(
callbackExecutor,
desc
) { status: Int, message: String, device: GPUDevice? ->
Log.d(TAG, "requestDevice → status=$status msg=$message device=$device")
if (device == null) {
Log.e(TAG, "requestDevice failed: status=$status msg=$message")
cont.resume(null)
} else {
cont.resume(device)
}
}
}
/**
* Configure swapchain / surface and create depth buffer.
* Uses `getCapabilities` to select a supported format (new WebGPU style).
*/
@SuppressLint("RestrictedApi")
private fun configureSurface(): Boolean {
val dev = device ?: return false
val surf = gpuSurface ?: return false
val adap = adapter ?: return false
return try {
// Query surface capabilities to pick a compatible format
val caps = surf.getCapabilities(adap)
preferredFormat = caps.formats.firstOrNull() ?: TextureFormat.RGBA8Unorm
Log.d(TAG, "Using texture format: $preferredFormat (available: ${caps.formats.toList()})")
surf.configure(
SurfaceConfiguration(
device = dev,
width = width,
height = height,
format = preferredFormat,
usage = TextureUsage.RenderAttachment
)
)
// Depth buffer for Depth24Plus
depthTexture?.close()
depthTexture = dev.createTexture(
TextureDescriptor(
size = Extent3D(width, height, 1),
format = TextureFormat.Depth24Plus,
usage = TextureUsage.RenderAttachment
)
)
true
} catch (t: Throwable) {
Log.e(TAG, "configureSurface failed", t)
false
}
}
// ------------------------------------------------------------------------
// Pipeline + geometry
// ------------------------------------------------------------------------
/**
* Build shader, pipeline, and uniform buffer.
* Single pipeline that supports both bars and pie (instance data controls shape).
*/
@SuppressLint("RestrictedApi")
private fun buildPipeline() {
val dev = device ?: return
// WGSL for vertex + fragment
val shaderCode = """
struct Uniforms {
viewProj : mat4x4<f32>,
model : mat4x4<f32>,
lightDir : vec3<f32>,
time : f32,
cameraPos : vec3<f32>,
_pad : f32,
};
@group(0) @binding(0) var<uniform> uniforms : Uniforms;
struct VSOut {
@builtin(position) position : vec4<f32>,
@location(0) worldPos : vec3<f32>,
@location(1) normal : vec3<f32>,
@location(2) color : vec4<f32>,
};
@vertex
fn vs_main(
@location(0) position : vec3<f32>,
@location(1) normal : vec3<f32>,
@location(2) color : vec4<f32>,
@location(3) instancePos : vec3<f32>,
@location(4) instanceScale : vec3<f32>
) -> VSOut {
var out : VSOut;
var pos = position * instanceScale + instancePos;
var norm = normal;
let world = uniforms.model * vec4<f32>(pos, 1.0);
out.position = uniforms.viewProj * world;
out.worldPos = world.xyz;
out.normal = normalize((uniforms.model * vec4<f32>(norm, 0.0)).xyz);
out.color = color;
return out;
}
@fragment
fn fs_main(input : VSOut) -> @location(0) vec4<f32> {
let N = normalize(input.normal);
let L = normalize(uniforms.lightDir);
let V = normalize(uniforms.cameraPos - input.worldPos);
let H = normalize(L + V);
let ambient = 0.2;
let diffuse = max(dot(N, L), 0.0) * 0.6;
let spec = pow(max(dot(N, H), 0.0), 32.0) * 0.4;
let rim = pow(1.0 - max(dot(N, V), 0.0), 3.0) * 0.2;
var color = input.color.rgb * (ambient + diffuse) + vec3<f32>(spec) + input.color.rgb * rim;
// Simple tone mapping + gamma
color = pow(color / (color + vec3<f32>(1.0)), vec3<f32>(1.0/2.2));
return vec4<f32>(color, 1.0);
}
""".trimIndent()
val shader = dev.createShaderModule(
ShaderModuleDescriptor(shaderSourceWGSL = ShaderSourceWGSL(shaderCode))
)
// Vertex layout:
// 0: position (vec3)
// 1: normal (vec3)
// 2: color (vec4, per-instance)
// 3: position (vec3, per-instance)
// 4: scale (vec3, per-instance)
val vbLayouts = arrayOf(
VertexBufferLayout(
arrayStride = 12, // 3 * 4 bytes
attributes = arrayOf(
VertexAttribute(VertexFormat.Float32x3, 0, 0)
)
),
VertexBufferLayout(
arrayStride = 12,
attributes = arrayOf(
VertexAttribute(VertexFormat.Float32x3, 0, 1)
)
),
VertexBufferLayout(
arrayStride = 16,
stepMode = VertexStepMode.Instance,
attributes = arrayOf(
VertexAttribute(VertexFormat.Float32x4, 0, 2)
)
),
VertexBufferLayout(
arrayStride = 12,
stepMode = VertexStepMode.Instance,
attributes = arrayOf(
VertexAttribute(VertexFormat.Float32x3, 0, 3)
)
),
VertexBufferLayout(
arrayStride = 12,
stepMode = VertexStepMode.Instance,
attributes = arrayOf(
VertexAttribute(VertexFormat.Float32x3, 0, 4)
)
)
)
pipeline = dev.createRenderPipeline(
RenderPipelineDescriptor(
vertex = VertexState(
module = shader,
entryPoint = "vs_main",
buffers = vbLayouts
),
fragment = FragmentState(
module = shader,
entryPoint = "fs_main",
targets = arrayOf(
ColorTargetState(format = preferredFormat)
)
),
primitive = PrimitiveState(
topology = PrimitiveTopology.TriangleList,
frontFace = FrontFace.CCW,
cullMode = CullMode.Back
),
depthStencil = DepthStencilState(
format = TextureFormat.Depth24Plus,
depthWriteEnabled = OptionalBool.True,
depthCompare = CompareFunction.Less
),
multisample = MultisampleState()
)
)
// Uniform buffer layout:
// viewProj (64 bytes) + model (64) + lightDir (12) + time (4)
// + cameraPos (12) + padding (4) = 160 bytes
uniformBuffer = dev.createBuffer(
BufferDescriptor(
size = 160L,
usage = BufferUsage.Uniform or BufferUsage.CopyDst
)
)
shader.close()
}
/**
* Build bar (cube) and pie wedge geometry and upload it to GPU buffers.
* Also allocates per-instance buffers (colors, positions, scales).
*/
@SuppressLint("RestrictedApi")
private fun buildGeometry() {
val dev = device ?: return
val q = queue ?: return
// --- BAR GEOMETRY (unit cube from y=0 to y=1) -----------------------
val barVerts = mutableListOf<Float>()
val barNorms = mutableListOf<Float>()
fun face(n: FloatArray, v: Array<FloatArray>) {
// 2 triangles = 6 vertices per quad
arrayOf(
v[0], v[1], v[2],
v[0], v[2], v[3]
).forEach { p ->
barVerts.addAll(p.toList())
barNorms.addAll(n.toList())
}
}
// Front
face(
floatArrayOf(0f, 0f, 1f),
arrayOf(
floatArrayOf(-0.5f, 0f, 0.5f),
floatArrayOf(0.5f, 0f, 0.5f),
floatArrayOf(0.5f, 1f, 0.5f),
floatArrayOf(-0.5f, 1f, 0.5f)
)
)
// Back
face(
floatArrayOf(0f, 0f, -1f),
arrayOf(
floatArrayOf(0.5f, 0f, -0.5f),
floatArrayOf(-0.5f, 0f, -0.5f),
floatArrayOf(-0.5f, 1f, -0.5f),
floatArrayOf(0.5f, 1f, -0.5f)
)
)
// Right
face(
floatArrayOf(1f, 0f, 0f),
arrayOf(
floatArrayOf(0.5f, 0f, 0.5f),
floatArrayOf(0.5f, 0f, -0.5f),
floatArrayOf(0.5f, 1f, -0.5f),
floatArrayOf(0.5f, 1f, 0.5f)
)
)
// Left
face(
floatArrayOf(-1f, 0f, 0f),
arrayOf(
floatArrayOf(-0.5f, 0f, -0.5f),
floatArrayOf(-0.5f, 0f, 0.5f),
floatArrayOf(-0.5f, 1f, 0.5f),
floatArrayOf(-0.5f, 1f, -0.5f)
)
)
// Top
face(
floatArrayOf(0f, 1f, 0f),
arrayOf(
floatArrayOf(-0.5f, 1f, 0.5f),
floatArrayOf(0.5f, 1f, 0.5f),
floatArrayOf(0.5f, 1f, -0.5f),
floatArrayOf(-0.5f, 1f, -0.5f)
)
)
// Bottom
face(
floatArrayOf(0f, -1f, 0f),
arrayOf(
floatArrayOf(-0.5f, 0f, -0.5f),
floatArrayOf(0.5f, 0f, -0.5f),
floatArrayOf(0.5f, 0f, 0.5f),
floatArrayOf(-0.5f, 0f, 0.5f)
)
)
barVertCount = barVerts.size / 3
barVertBuffer = dev.createBuffer(
BufferDescriptor(
size = barVerts.size * 4L,
usage = BufferUsage.Vertex or BufferUsage.CopyDst
)
)
barNormBuffer = dev.createBuffer(
BufferDescriptor(
size = barNorms.size * 4L,
usage = BufferUsage.Vertex or BufferUsage.CopyDst
)
)
q.writeBuffer(barVertBuffer!!, 0, barVerts.toFloatArray().toByteBuffer())
q.writeBuffer(barNormBuffer!!, 0, barNorms.toFloatArray().toByteBuffer())
// --- PIE WEDGE GEOMETRY ---------------------------------------------
val wedgeVerts = mutableListOf<Float>()
val wedgeNorms = mutableListOf<Float>()
val segments = 24 // radial subdivisions per wedge
for (i in 0 until segments) {
val a1 = (i.toFloat() / segments)
val a2 = ((i + 1).toFloat() / segments)
val x1 = sin(a1)
val z1 = cos(a1)
val x2 = sin(a2)
val z2 = cos(a2)
// Top triangle fan
wedgeVerts.addAll(
listOf(
0f, 1f, 0f,
x1, 1f, z1,
x2, 1f, z2
)
)
wedgeNorms.addAll(
listOf(
0f, 1f, 0f,
0f, 1f, 0f,
0f, 1f, 0f
)
)
// Bottom (flipped)
wedgeVerts.addAll(
listOf(
0f, 0f, 0f,
x2, 0f, z2,
x1, 0f, z1
)
)
wedgeNorms.addAll(
listOf(
0f, -1f, 0f,
0f, -1f, 0f,
0f, -1f, 0f
)
)
// Side quad (two triangles)
wedgeVerts.addAll(
listOf(
x1, 0f, z1,
x2, 0f, z2,
x2, 1f, z2,
x1, 0f, z1,
x2, 1f, z2,
x1, 1f, z1
)
)
val nx = (x1 + x2) * 0.5f
val nz = (z1 + z2) * 0.5f
val len = kotlin.math.sqrt(nx * nx + nz * nz)
repeat(6) {
wedgeNorms.addAll(listOf(nx / len, 0f, nz / len))
}
}
pieVertCount = wedgeVerts.size / 3
pieVertBuffer = dev.createBuffer(
BufferDescriptor(
size = wedgeVerts.size * 4L,
usage = BufferUsage.Vertex or BufferUsage.CopyDst
)
)
pieNormBuffer = dev.createBuffer(
BufferDescriptor(
size = wedgeNorms.size * 4L,
usage = BufferUsage.Vertex or BufferUsage.CopyDst
)
)
q.writeBuffer(pieVertBuffer!!, 0, wedgeVerts.toFloatArray().toByteBuffer())
q.writeBuffer(pieNormBuffer!!, 0, wedgeNorms.toFloatArray().toByteBuffer())
// --- Instance buffers (6 bars / wedges) -----------------------------
colorBuffer = dev.createBuffer(
BufferDescriptor(
size = 6 * 16L, // 6 * vec4
usage = BufferUsage.Vertex or BufferUsage.CopyDst
)
)
posBuffer = dev.createBuffer(
BufferDescriptor(
size = 6 * 12L, // 6 * vec3
usage = BufferUsage.Vertex or BufferUsage.CopyDst
)
)
scaleBuffer = dev.createBuffer(
BufferDescriptor(
size = 6 * 12L, // 6 * vec3
usage = BufferUsage.Vertex or BufferUsage.CopyDst
)
)
}
// ------------------------------------------------------------------------
// Per-frame rendering
// ------------------------------------------------------------------------
/**
* Single frame:
* - Update camera + uniforms
* - Update instance data (bars or pie)
* - Acquire surface texture
* - Encode commands + draw
* - Present
*/
@SuppressLint("RestrictedApi")
private fun renderFrame() {
val surface = gpuSurface ?: return
val dev = device ?: return
val q = queue ?: return
val pipe = pipeline ?: return
// Animate rotation (~0.6 rad/sec)
rotation += 0.6f * (16f / 1000f)
// Simple orbiting camera
val camDist = 5.5f
val camHeight = 3f
val camPos = floatArrayOf(
sin(rotation) * camDist,
camHeight,
cos(rotation) * camDist
)
// View / projection / model matrices
val view = lookAt(
eye = camPos,
center = floatArrayOf(0f, 0.8f, 0f),
up = floatArrayOf(0f, 1f, 0f)
)
val proj = perspective(
fovY = PI.toFloat() / 4f,
aspect = width.toFloat() / height,
near = 0.1f,
far = 100f
)
val viewProj = multiply(view, proj)
val model = identity()
val lightDir = normalize(floatArrayOf(1f, 2f, 1.5f))
// Write uniforms into CPU buffer then upload:
// viewProj (16) + model (16) + lightDir (3) + time (1)
// + cameraPos (3) + pad (1) = 40 floats = 160 bytes
val u = ByteBuffer
.allocateDirect(160)
.order(ByteOrder.LITTLE_ENDIAN)
u.asFloatBuffer().apply {
put(viewProj)
put(model)
put(lightDir)
put(rotation)
put(camPos)
put(0f) // padding
}
q.writeBuffer(uniformBuffer!!, 0, u)
// Instance colors (6 fixed nice colors)
val colors = floatArrayOf(
0.18f, 0.80f, 0.72f, 1f,
0.95f, 0.35f, 0.45f, 1f,
0.40f, 0.55f, 0.95f, 1f,
0.98f, 0.75f, 0.25f, 1f,
0.70f, 0.45f, 0.90f, 1f,
0.30f, 0.85f, 0.55f, 1f
)
// Instance positions / scales
val positions = FloatArray(18) // 6 * vec3
val scales = FloatArray(18) // 6 * vec3
if (chartType == ChartType.Bars) {
// Bars are arranged in a line on X axis
for (i in 0 until 6) {
positions[i * 3] = (i - 2.5f) * 1.1f
positions[i * 3 + 1] = 0f
positions[i * 3 + 2] = 0f
scales[i * 3] = 0.35f
scales[i * 3 + 1] = data[i] * 2.5f
scales[i * 3 + 2] = 0.35f
}
} else {
// Pie segments distributed around a circle
var angle = 0f
val total = data.sum()
for (i in 0 until 6) {
val segAngle = (data[i] / total) * (2f * PI.toFloat())
val midAngle = angle + segAngle / 2f
// Center each wedge slightly offset from origin
positions[i * 3] = sin(midAngle) * 0.35f
positions[i * 3 + 1] = 0f
positions[i * 3 + 2] = cos(midAngle) * 0.35f
// In scale we encode:
// x = start angle (approx, for shading tricks / not strictly used)
// y = height (based on data)
// z = angular size (controls wedge span)
scales[i * 3] = angle + segAngle * 0.04f
scales[i * 3 + 1] = 0.6f + data[i]
scales[i * 3 + 2] = segAngle * 0.92f
angle += segAngle
}
}
// Upload per-instance data
q.writeBuffer(colorBuffer!!, 0, colors.toByteBuffer())
q.writeBuffer(posBuffer!!, 0, positions.toByteBuffer())
q.writeBuffer(scaleBuffer!!, 0, scales.toByteBuffer())
// Acquire current swapchain texture
val surfaceTexture = try {
surface.getCurrentTexture()
} catch (e: Exception) {
Log.e(TAG, "getCurrentTexture failed", e)
return
}
// Handle suboptimal / lost surface states gracefully
if (surfaceTexture.status != SurfaceGetCurrentTextureStatus.SuccessOptimal &&
surfaceTexture.status != SurfaceGetCurrentTextureStatus.SuccessSuboptimal
) {
Log.w(TAG, "Surface texture status: ${surfaceTexture.status}")
return
}
// Create color + depth views
val viewTex = surfaceTexture.texture.createView(
TextureViewDescriptor(
usage = TextureUsage.RenderAttachment,
dimension = TextureViewDimension._2D
)
)
val depthView = depthTexture!!.createView(
TextureViewDescriptor(
usage = TextureUsage.RenderAttachment,
aspect = TextureAspect.DepthOnly,
dimension = TextureViewDimension._2D
)
)
// Encode commands
val encoder = dev.createCommandEncoder(CommandEncoderDescriptor())
val bindGroup = dev.createBindGroup(
BindGroupDescriptor(
layout = pipe.getBindGroupLayout(0),
entries = arrayOf(
BindGroupEntry(
binding = 0,
buffer = uniformBuffer!!
)
)
)
)
val pass = encoder.beginRenderPass(
RenderPassDescriptor(
colorAttachments = arrayOf(
RenderPassColorAttachment(
view = viewTex,
loadOp = LoadOp.Clear,
storeOp = StoreOp.Store,
clearValue = Color(0.02, 0.02, 0.03, 1.0)
)
),
depthStencilAttachment = RenderPassDepthStencilAttachment(
view = depthView,
depthLoadOp = LoadOp.Clear,
depthStoreOp = StoreOp.Store,
depthClearValue = 1f,
depthReadOnly = false
)
)
)
pass.setPipeline(pipe)
pass.setBindGroup(0, bindGroup)
// Select which geometry to draw
if (chartType == ChartType.Bars) {
pass.setVertexBuffer(0, barVertBuffer!!)
pass.setVertexBuffer(1, barNormBuffer!!)
} else {
pass.setVertexBuffer(0, pieVertBuffer!!)
pass.setVertexBuffer(1, pieNormBuffer!!)
}
pass.setVertexBuffer(2, colorBuffer!!)
pass.setVertexBuffer(3, posBuffer!!)
pass.setVertexBuffer(4, scaleBuffer!!)
val vertCount = if (chartType == ChartType.Bars) barVertCount else pieVertCount
pass.draw(vertCount, 6) // 6 instances (bars / wedges)
pass.end()
val command = encoder.finish()
q.submit(arrayOf(command))
surface.present()
// Cleanup transient WebGPU objects (views, bind group, encoder)
viewTex.close()
depthView.close()
bindGroup.close()
command.close()
encoder.close()
}
// ------------------------------------------------------------------------
// Math helpers
// ------------------------------------------------------------------------
/** Convert FloatArray to a direct little-endian ByteBuffer. */
private fun FloatArray.toByteBuffer(): ByteBuffer {
val buf = ByteBuffer
.allocateDirect(size * 4)
.order(ByteOrder.LITTLE_ENDIAN)
buf.asFloatBuffer().put(this)
return buf
}
/** Identity 4x4 matrix. */
private fun identity() = floatArrayOf(
1f, 0f, 0f, 0f,
0f, 1f, 0f, 0f,
0f, 0f, 1f, 0f,
0f, 0f, 0f, 1f
)
/** Matrix multiply: out = a * b (column-major 4x4). */
private fun multiply(a: FloatArray, b: FloatArray): FloatArray {
val out = FloatArray(16)
for (i in 0..3) {
for (j in 0..3) {
out[i * 4 + j] =
a[i * 4 + 0] * b[0 * 4 + j] +
a[i * 4 + 1] * b[1 * 4 + j] +
a[i * 4 + 2] * b[2 * 4 + j] +
a[i * 4 + 3] * b[3 * 4 + j]
}
}
return out
}
/** Classic lookAt view matrix. */
private fun lookAt(eye: FloatArray, center: FloatArray, up: FloatArray): FloatArray {
val f = normalize(
floatArrayOf(
center[0] - eye[0],
center[1] - eye[1],
center[2] - eye[2]
)
)
val s = normalize(cross(f, up))
val u = cross(s, f)
return floatArrayOf(
s[0], u[0], -f[0], 0f,
s[1], u[1], -f[1], 0f,
s[2], u[2], -f[2], 0f,
-dot(s, eye), -dot(u, eye), dot(f, eye), 1f
)
}
/** Perspective projection matrix. */
private fun perspective(fovY: Float, aspect: Float, near: Float, far: Float): FloatArray {
val f = 1f / kotlin.math.tan(fovY / 2f)
val nf = 1f / (near - far)
return floatArrayOf(
f / aspect, 0f, 0f, 0f,
0f, f, 0f, 0f,
0f, 0f, (far + near) * nf, -1f,
0f, 0f, (2f * far * near) * nf, 0f
)
}
/** Normalize a vec3. */
private fun normalize(v: FloatArray): FloatArray {
val len = kotlin.math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2])
return if (len > 0f) {
floatArrayOf(v[0] / len, v[1] / len, v[2] / len)
} else {
floatArrayOf(0f, 0f, 0f)
}
}
/** Dot product of two vec3. */
private fun dot(a: FloatArray, b: FloatArray) =
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
/** Cross product of two vec3. */
private fun cross(a: FloatArray, b: FloatArray) = floatArrayOf(
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0]
)
// ------------------------------------------------------------------------
// Types
// ------------------------------------------------------------------------
enum class ChartType {
Bars,
Pie
}
companion object {
private const val TAG = "WebGpuCharts"
}
}
@Sal7one
Copy link
Author

Sal7one commented Dec 4, 2025

    implementation("androidx.webgpu:webgpu:1.0.0-alpha01")

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment