Created
December 4, 2025 22:31
-
-
Save Sal7one/17f52fd43b2b243a03a09cc9d1b48d64 to your computer and use it in GitHub Desktop.
Web GPU example
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | |
| } | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
implementation("androidx.webgpu:webgpu:1.0.0-alpha01")