たくさんの自由帳

Android アプリで OpenGL ES の代わりに WebGPU を使って描画する

投稿日 : | 0 日前

文字数(だいたい) : 30845

目次

どうもこんにちは。ディメンション凸ラバース!! 攻略しました。
体験版でかたんちゃんのカップラーメンのくだりまで見て面白くて買った!めちゃめちゃ面白い!めっちゃ作りこまれてそうだった

とつらば

かたんちゃんルートだけなんかスケールがデカかったのは気のせいなのか私が見過ごしたのか・・
かたんちゃんルートの最後のらんかすたさん、よかったのだわ

とつらば

とつらば

いもうとちゃん!!その服後ろがら空きなのでは!?
えちえちしーんの言葉がえっちだった

とつらば

!?!?

とつらば

そふぃーちゃんのこのかおすき、声かわいい
らんかすたさんのルート分岐だけそれっぽくない(?)名前なのなるほど

とつらば

とつらば

・・・まァ

とつらば

とつらば

しふくだ!

とつらば

とつらば

!?!?!?!?!?!??!!?!?!

とつらば

ざんねんながらみおさんルートはないわけですが、主人公のお兄ちゃんと妙に仲がいいの、グランドエンディングでわかってはえ~ってなった。

とつらば

桜ちゃん!?!?かわいい!ちなみにいちばんえちえちでした
ここの返しすき

とつらば

ジト目かわいい
のと初日に惹かれた話が判明するわけですがシリアス展開にならなくてよかった!!

とつらば

とつらば

とつらば

とつらば

ここのくだりセーブしてある、かわいい

とつらば

とんちきシーン集

とつらば

とつらば

とつらば

すごいよかった、おすすめ!!
(めっちゃ細かくTIPS書いてあるのあってわらった)

本題

Google I/O 2026は見ましたか!?Androidの発表のGPUの話。

OpenGL ES(GLES)Vulkan低レイヤーなグラフィックス APIで、これらはGPUに直接指示しないと速度が出ないアプリを作るときに仕方なく使うAPIです。
なので9割5分のアプリには関係ない話ですね!)(ゲームとかはUnityのゲームエンジンが代わりに叩くので、これらを直接扱うのは稀です!)

発表によるとOpenGL ESを使っているアプリはANGLE(?)が翻訳をしてVulkanで動くようになるらしい、
Android 17でなるのか将来の話なのかは分からない。でもSoCOpenGL ES ドライバーを捨てるとかなんとかはどっかで発表してて気がする。

googleio_youtube_gpu_api_overview

そしてVulkanを使うための新しい方法があるとのこと!
ブラウザに搭載されている低レイヤーグラフィックス APIであるWebGPUが、Androidアプリ開発で使えるように移植された。

googleio_youtube_webgpu_introduction

今までよりも高性能で簡潔と言っています、ちょっと試してみましたが本当っぽい!。
というわけで今回はAndroidに移植されたWebGPUSurfaceViewに描画してみようと思います!

ちなみに、このWebGPU Android 移植版の話の直後にAdobe Premiere Android 版の話をしています。
世の中にあるAndroidの動画編集アプリというのは多分OpenGL ESを使っていて(media3ライブラリがそう)、OpenGL ESの代替であるWebGPUを使っているという話は多分正解な気がします。

googleio_youtube_webgpu_maybe_using_adore_premiure

多分正解?なのはalpha段階のWebGPUを本番アプリで使うのは怖くないのかという疑問と、移植されたWebGPUには今のところ動画の映像を渡す特別な方法が存在しない。
後述しますが、OpenGL ESの時はSurfaceTextureクラスを使うことでGPUカメラ、動画の映像を簡単に渡すことが出来た。一方今の段階のWebGPUにはその機能相当が存在しないハズ。
特別に用意とかされてるのか知らんけど、今のところその機能がないため、普通にテクスチャとして送る必要があって、これは地味にめんどくさい!!(自動ではやってくれないのでYUVからRGBに変換する処理を自分で書かないといけない!)

動画編集であるのに動画の映像を効率よく渡す方法が今のところないWebGPUを使っているのかは微妙・・・

べつに大企業の名前引っ張ってこなくてもすばらしいのにねWebGPU

先に感想

ドパガキ向け(悪口すぎ)

めちゃめちゃ良いよ、WebGPU

  • WebGL / OpenGLES よりもはるかに良い。
  • エラーがとにかくわかりやすい
    • エラー1
      • WGSL文法エラー
      • androidx.webgpu.ValidationException: Error while parsing WGSL: :49:34 error: expected '}' for function body
      • return vec4<f32>(color, 1.0);
    • エラ-2
      • renderPass.draw()の数が違った
      • androidx.webgpu.ValidationException: Vertex range (first: 0, count: 27) requires a larger buffer (324) than the bound buffer size (216) of the vertex buffer at slot 0 with stride 12.
      • While encoding [RenderPassEncoder (unlabeled)].Draw(27, 1, 0, 0).
      • While finishing [CommandEncoder (unlabeled)].
    • エラー3
      • WGSL組み込み関数の使い方ミス
      • androidx.webgpu.ValidationException: Error while parsing WGSL: :39:67 error: no matching constructor for 'mat4x4<f32>(mat2x2<f32>)'
      • 7 candidate constructors:
      • • 'mat4x4<T ✓ >(mat4x4<T> ✗ ) -> mat4x4<T>' where:
      • ✓ 'T' is 'f32' or 'f16'
      • • 'mat4x4<T ✓ >() -> mat4x4<T>' where:
      • ✗ overload expects 0 arguments, call passed 1 argument
      • ✓ 'T' is 'f32' or 'f16'
  • wgsl、構造体を返すのいいね。in / outみたいなのでtexCoordを渡してたけどstructで返せるのいいね。
  • もうとにかくエラーがわかりやすい
  • フラグメントシェーダとバーテックスシェーダーが同じなのがいい、構造体を返してフラグメントシェーダに渡るのもglslより直感的だと思う、
  • バーテックスシェーダーの中に直接三角形の頂点の配列を記載できるの、解説がわかりやすくていいと思う
  • @locationのインデックスで紐づけるの結構いいかも!気軽にuniform変数をリネームできるぜ!
  • サンプラーを要求してくるのはGLESとの違いか?
  • 事あるごとに例外を投げてくれるのがいい!glErrorをいつ呼んているのか問題がある。ドローループ?gl呼び出しことに?
  • WebGPUSurfaceを渡してセットアップできる。難解だからEGLを捨てたい。
    • OpenGL ESAndroidにおいてはGLSurfaceViewを使わない場合は、自力でEGLの関数を呼び出す必要がある。複雑すぎてつらい

気になる点

メインスレッド以外だとなんか動かなくない?私のせい?なんかDispatchers.DefaultnewSingleThreadContext()だと、renderPass.draw()を繰り返し呼び出してしばらく経過すると落ちてる気がする。
スタックトレースは後述、しかもネイティブコードで落ちてんだけど・・

あとカメラ映像動画の映像を効率よくWebGPUへ渡す方法が今のところ存在しない点です。
OpenGL ESSurfaceTextureVulkanexternal memory + AHardwareBuffer相当がない、見た感じ。一回CPUを経由してからWebGPUに転送するしかなさそうに見える。

今のところはImageReaderYUV_420_888でテクスチャを転送してみるとかなり高速に動く。
効率が良いかといわれると怪しいけど、コピーするにしてもYUVなので速いのかな。

WebGPU とは

あーここではゲームに関しては触れません、アプリでどうしてもリアルタイムで映像を加工するみたいな場合を想定しています。
ゲームに関してはUnityとかのゲームエンジンが代わりに低レイヤーグラフィックスAPIを叩いてくれているので。

おもしろい記事があるからそっちを見てきてください

おもしろい記事があるので私の話よりもこっちを見てきてください。アーカイブにしか存在しないのが本当に惜しい;;なんで;;

I want to talk about WebGPU

WebGPU is the new WebGL. That means it is the new way to draw 3D in web browsers. It is, in my opinion, very good actually. It is so good I think it will also replace Canvas and become the new way to draw 2D in web browsers. In fact it is so good I think it will replace Vulkan as well as normal OpenGL, and become just the standard way to draw, in any kind of software, from any programming language. This is pretty exciting to me. WebGPU is a little bit irritating— but only a little bit, and it is massively less irritating than any of the things it replaces. WebGPU goes live… today, actually. Chrome 113 shipped in the final minutes of me finishing this post and should be available in the "About Chrome" dialog right this second. If you click here [https://data.runhello.com/j/wgpu/1/], and you see a rainbow triangle, your web browser has WebGPU. By the end of the year WebGPU will be everywhere, in every browser. (All of this refers to desktop computers. On phones, it won't be in Chrome until later this year; and Apple I don't know. Maybe one additional year after that.) If you are not a programmer, this probably doesn't affect you. It might get us closer to a world where you can just play games in your web browser as a normal thing like you used to be able to with Flash. But probably not because WebGL wasn't the only problem there. If you are a programmer, let me tell you what I think this means for you. Sections below: * A history of graphics APIs (You can skip this) * What's it like? * How do I use it? * Typescript / NPM world * I don't know what a NPM is I Just wanna write CSS and my stupid little script tags * Rust / C++ / Posthuman Intersecting Tetrahedron ---------------------------------------- A HISTORY OF GRAPHICS APIS (YOU CAN SKIP THIS) Yo! Yogi [https://staging.cohostcdn.org/attachment/823fc920-3542-45ad-a90d-a59e8d486631/1991.jpg] 1991 Back in the dawn of time there were two ways to make 3D on a computer: You did a bunch of math; or you bought an SGI machine. SGI were the first people who were designing circuitry to do the rendering parts of a 3D engine for you. They had this C API for describing your 3D models to the hardware. At some point it became clear that people were going to start making plugin cards for regular desktop computers that could do the same acceleration as SGI's big UNIX boxes, so SGI released a public version of their API so it would be possible to write code that would work both on the UNIX boxes and on the hypothetical future PC cards. This was OpenGL. `color()` and `rectf()` in IRIS GL became `glColor()` and `glRectf()` in OpenGL. "Waterfalls" by TLC [https://staging.cohostcdn.org/attachment/88a68e08-9a4f-48c9-9f21-92ef3a64ca92/1995-min.jpg] 1995 When the PC 3D cards actually became a real thing you could buy, things got real messy for a bit. Instead of signing on with OpenGL Microsoft had decided to develop their own thing (Direct3D) and some of the 3D card vendors also developed their own API standards, so for a while certain games were only accelerated on certain graphics cards and people writing games had to write their 3D pipelines like four times, once as a software renderer and a separate one for each card type they wanted to support. My perception is it was Direct3D, not OpenGL, which eventually managed to wrangle all of this into a standard, which really sucked if you were using a non-Microsoft OS at the time. It really seemed like DirectX (and the "X Box" standalone console it spawned) were an attempt to lock game companies into Microsoft OSes by getting them to wire Microsoft exclusivity into their code at the lowest level, and for a while it really worked. Shrek [https://staging.cohostcdn.org/attachment/fde78cb9-8aab-47d4-9c5c-2ebebeb04e3f/2000-min.jpg] 2000 It is the case though it wasn't very long into the Direct3D lifecycle before you started hearing from Direct3D users that it was much, much nicer to use than OpenGL, and OpenGL quickly got to a point where it was literally years behind Direct3D in terms of implementing critical early features like shaders, because the Architecture Review Board of card vendors that defined OpenGL would spend forever bickering over details whereas Microsoft could just implement stuff and expect the card vendor to work it out. Let's talk about shaders. The original OpenGL was a "fixed function renderer", meaning someone had written down the steps in a 3D renderer and it performed those steps in order. [API] → Primitive Processing → (1) Transform and Lighting → Primitive Assembly → Rasterizer → (2) Texture Environment → (2) Color sum → (2) Fog → (2) Alpha Test → Depth/Stencil → Color-buffer Blend → Dither → [Frame Buffer] [https://staging.cohostcdn.org/attachment/dc77a277-6ca9-4d8a-bdf9-e96d6a89982c/FIXED-FUNCTION-FIXED.png] Modified Khronos Group image Each box in the "pipeline" had some dials on the side so you could configure how each feature behaved, but you were pretty much limited to the features the card vendor gave you. If you had shadows, or fog, it was because OpenGL or an extension had exposed a feature for drawing shadows or fog. What if you want some other feature the ARB didn't think of, or want to do shadows or fog in a unique way that makes your game look different from other games? Sucks to be you. This was obnoxious, so eventually "programmable shaders" were introduced. Notice some of the boxes above are yellow? Those boxes became replaceable. The (1) boxes got collapsed into the "Vertex Shader", and the (2) boxes became the "Fragment Shader"². The software would upload a computer program in a simple C-like language (upload the actual text of the program, you weren't expected to compile it like a normal program)³ into the video driver at runtime, and the driver would convert that into configurations of ALUs (or whatever the card was actually doing on the inside) and your program would become that chunk of the pipeline. This opened things up a lot, but more importantly it set card design on a kinda strange path. Suddenly video cards weren't specialized rendering tools anymore. They ran software. Time Magazine, "What kind of President would John Kerry be?" [https://staging.cohostcdn.org/attachment/e51a906b-af08-41a6-87bd-b1bbfbaf1455/2004.jpg] 2004 Pretty shortly after this was another change. Handheld devices were starting to get to the point it made sense to do 3D rendering on them (or at least, to do 2D compositing using 3D video card hardware like desktop machines had started doing). DirectX was never in the running for these applications. But implementing OpenGL on mid-00s mobile silicon was rough. OpenGL was kind of… large, at this point. It had all these leftover functions from the SGI IRIX era, and then it had this new shiny OpenGL 2.0 way of doing things with the shaders and everything and not only did this mean you basically had two unrelated APIs sitting side by side in the same API, but also a lot of the OpenGL 1.x features were traps. The spec said that every video card had to support every OpenGL feature, but it didn't say it had to support them in Hardware, so there were certain early-90s features that 00s card vendors had decided nobody really uses, and so if you used those features the driver would render the screen, copy the entire screen into regular RAM, perform the feature on the CPU and then copy the results back to the video card. Accidentally activating one of these trap features could easily move you from 60 FPS to 1 FPS. All this legacy baggage promised a lot of extra work for the manufacturers of the new mobile GPUs, so to make it easier Khronos (which is what the ARB had become by this point) introduced an OpenGL "ES", which stripped out everything except the features you absolutely needed. Instead of being able to call a function for each polygon or each vertex you had to use the newer API of giving OpenGL a list of coordinates in a block in memory⁴, you had to use either the fixed function or the shader pipeline with no mixing (depending on whether you were using ES 1.x or ES 2.x), etc. This partially made things simpler for programmers, and partially prompted some annoying rewrites. But as with shaders, what's most important is the long-term strange-ing this change presaged: Starting at this point, the decisions of Khronos increasingly were driven entirely by the needs and wants of hardware manufacturers, not programmers. The Apple iPhone [https://staging.cohostcdn.org/attachment/a48dd6a7-3d2e-4a17-bd0f-6950ce16523f/2008-min.jpg] 2008 With OpenGL ES devices in the world, OpenGL started to graduate from being "that other graphics API that exists, I guess" and actually take off. The iPhone, which used OpenGL ES, gave a solid mass-market reason to learn and use OpenGL. Nintendo consoles started to use OpenGL or something like it. OpenGL had more or less caught up with DirectX in features, especially if you were willing to use extensions. Browser vendors, in that spurt of weird hubris that gave us the original WebAudio API, adapted OpenGL ES into JavaScript as "WebGL", which makes no sense because as mentioned OpenGL ES was all about packing bytes into arrays full of geometry and JavaScript doesn't have direct memory access or even integers, but they added packed binary arrays to the language [https://web.dev/webgl-typed-arrays/#history-of-typed-arrays] and did it anyway. So with all this activity, sounds like things are going great, right? Steven Universe [https://staging.cohostcdn.org/attachment/ca876a6c-6753-4de1-bc29-703b0c82bab1/2013.webp] 2013 No! Everything was terrible! As it matured, OpenGL fractured into a variety of slightly different standards with varying degrees of cross-compatibility. OpenGL ES 2.0 was the same as OpenGL 3.3, somehow. WebGL 2.0 is very almost OpenGL ES 3.0 but not quite. Every attempt to resolve OpenGL's remaining early mistakes seemed to wind up duplicating the entire API as new functions with slightly different names and slightly different signatures. A big usability issue with OpenGL was even after the 2.0 rework it had a lot of shared global state, but the add-on systems that were supposed to resolve this (VAOs and VBOs) only wound up being even more global state you had to keep track of. A big trend in the 10s was "GPGPU" (General Purpose GPU); programmers started to realize that graphics cards worked as well as, but were slightly easier to program than, a CPU's vector units, so they just started accelerating random non-graphics programs by doing horrible hacks like stuffing them in pixel shaders and reading back a texture containing an encoded result. Before finally resolving on compute shaders (in other words: before giving up and copying DirectX's solution), Khronos's original steps toward actually catering to this were either poorly adopted (OpenCL) or just plain bad ideas (geometry shaders). It all built up. Just like in the pre-ES era, OpenGL had basically become several unrelated APIs sitting in the same header file, some of which only worked on some machines. Worse, nothing worked quite as well as you wanted it to; different video card vendors botched the complexity, implementing features slightly differently (especially tragically, implementing slightly different versions of the shader language) or just badly, especially in the infamously bad Windows OpenGL drivers. The way out came from, this is how I see it anyway, a short-lived idea called "AZDO [https://www.youtube.com/watch?v=GiDsLRQg_g4]" which technically consisted of one GDC talk⁵, but also technically it's the idea that spawned Vulkan, DirectX 12, and Metal. "Approaching Zero Driver Overhead". Here is the idea: By 2015 video cards had pretty much standardized on a particular way of working and that way was known and that way wasn't expected to change for ten years at least. Graphics APIs were originally designed around the functionality they exposed, but that functionality hadn't been a 1:1 map to how GPUs look on the inside for ten years at least. Drivers had become complex beasts that rather than just doing what you told them tried to intuit what you were trying to do and then do that in the most optimized way, but often they guessed wrong, leaving software authors in the ugly position of trying to intuit what the driver would intuit in any one scenario. AZDO was about threading your way through the needle of the graphics API in such a way your function calls happened to align precisely with what the hardware was actually doing, such that the driver had nothing to do and stuff just happened. Star Wars: The Force Awakens [https://staging.cohostcdn.org/attachment/b735d6be-49e1-4b9b-b446-aa23aab526f2/tfa-alt.jpg] 2016 Or we could just design the graphics API to be AZDO from the start. That's Vulkan. (And DirectX 12, and Metal.) The modern generation of graphics APIs are about basically throwing out the driver, or rather, letting your program be the driver. The API primitives map directly to GPU internal functionality⁶, and the GPU does what you ask without second guessing. This gives you an incredible amount of power and control. Remember that "pipeline" diagram up top? The modern APIs let you define "pipeline objects"; while graphics shaders let you replace boxes within the diagram, and compute shaders let you replace the diagram with one big shader program, pipeline objects let you draw your own diagram. You decide what blocks of GPU memory are the sources, and which are the destinations, and how they are interpreted, and what the GPU does with them, and what shaders get called. All the old sources of confusion get resolved. State is bound up in neatly defined objects instead of being global. Card vendors always designed their shader compilers different, so we'll replace the textual shader language with a bytecode format that's unambiguous to implement and easier to write compilers for. Vulkan goes so far as to allow⁷ you to write your own allocator/deallocator for GPU memory. So this is all very cool. There is only one problem, which is that with all this fine-grained complexity, Vulkan winds up being basically impossible for humans to write. Actually, that's not really fair. DX12 and Metal offer more or less the same degree of fine-grained complexity, and by all accounts they're not so bad to write. The actual problem is that Vulkan is not designed for humans to write. Literally. Khronos does not want you to write Vulkan, or rather, they don't want you to write it directly. I was in the room when Vulkan was announced, across the street from GDC in 2015, and what they explained to our faces was that game developers were increasingly not actually targeting the gaming API itself, but rather targeting high-level middleware, Unity or Unreal or whatever, and so Vulkan was an API designed for writing middleware. The middleware developers were also in the room at the time, the Unity and Epic and Valve guys. They were beaming as the Khronos guy explained this. Their lives were about to get much, much easier. My life was about to get harder. Vulkan is weird— but it's weird in a way that makes a certain sort of horrifying machine sense. Every Vulkan call involves passing in one or two huge structures which are themselves a forest of other huge structures, and every structure and sub-structure begins with a little protocol header explaining what it is and how big it is. Before you allocate memory you have to fill out a structure to get back a structure that tells you what structure you're supposed to structure your memory allocation request in. None of it makes any sense— unless you've designed a programming language before, in which case everything you're reading jumps out to you as "oh, this is contrived like this because it's designed to be easy to bind to from languages with weird memory-management techniques" "this is a way of designing a forward-compatible ABI while making no assumptions about programming language" etc. The docs are written in a sort of alien English that fosters no understanding— but it's also written exactly the way a hardware implementor would want in order to remove all ambiguity about what a function call does. In short, Vulkan is not for you. It is a byzantine contract between hardware manufacturers and middleware providers, and people like… well, me, are just not part of the transaction. Khronos did not forget about you and me. They just made a judgement, and this actually does make a sort of sense, that they were never going to design the perfectly ergonomic developer API anyway, so it would be better to not even try and instead make it as easy as possible for the perfectly ergonomic API to be written on top, as a library. Khronos thought within a few years of Vulkan⁸ being released there would be a bunch of high-quality open source wrapper libraries that people would use instead of Vulkan directly. These libraries basically did not materialize. It turns out writing software is work and open source projects do not materialize just because people would like them to⁹. Star Wars: The Rise of Skywalker [https://staging.cohostcdn.org/attachment/4ce3c81e-f2d3-479e-aa25-1c60e89a1e65/tros-alt.jpg] 2019 This leads us to the other problem, the one Vulkan developed after the fact. The Apple problem. The theory on Vulkan was it would change the balance of power where Microsoft continually released a high-quality cutting-edge graphics API and OpenGL was the sloppy open-source catch up. Instead, the GPU vendors themselves would provide the API, and Vulkan would be the universal standard while DirectX would be reduced to a platform-specific oddity. But then Apple said no. Apple (who had already launched their own thing, Metal) announced not only would they never support Vulkan, they would not support OpenGL, anymore¹⁰. From my perspective, this is just DirectX again; the dominant OS vendor of our era, as Microsoft was in the 90s, is pushing proprietary graphics tech to foster developer lock-in. But from Apple's perspective it probably looks like— well, the way DirectX probably looked from Microsoft's perspective in the 90s. They're ignoring the jagged-metal thing from the hardware vendors and shipping something their developers will actually want to use. With Apple out, the scene looked different. Suddenly there was a next-gen API for Windows, a next-gen API for Mac/iPhone, and a next-gen API for Linux/Android. Except Linux has a severe driver problem with Vulkan and a lot of the Linux devices I've been checking out don't support Vulkan even now after it's been out seven years. So really the only platform where Vulkan runs natively is Android. This isn't that bad. Vulkan does work on Windows and there are mostly no problems, though people who have the resources to write a DX12 backend seem to prefer doing so. The entire point of these APIs is that they're flyweight things resting very lightly on top of the hardware layer, which means they aren't really that different, to the extent that a Vulkan-on-Metal emulation layer named MoltenVK exists and reportedly adds almost no overhead. But if you're an open source kind of person who doesn't have the resources to pay three separate people to write vaguely-similar platform backends, this isn't great. Your code can technically run on all platforms, but you're writing in the least pleasant of the three APIs to work with and you get the advantage of using a true-native API on neither of the two major platforms. You might even have an easier time just writing DX12 and Metal and forgetting Vulkan (and Android) altogether. In short, Vulkan solves all of OpenGL's problems at the cost of making something that no one wants to use and no one has a reason to use. The way out turned out to be something called ANGLE. Let me back up a bit. Super Meat Boy [https://staging.cohostcdn.org/attachment/d7c61730-2d91-44ae-a57d-2efa441e4ba2/SuperMeatBoy_cover.png] 2010, again WebGL was designed around OpenGL ES. But it was never exactly the same as OpenGL ES, and also technically OpenGL ES never really ran on desktops, and also regular OpenGL on desktops had Problems. So the browser people eventually realized that if you wanted to ship an OpenGL compatibility layer on Windows, it was actually easier to write an OpenGL emulator in DirectX than it was to use OpenGL directly and have to negotiate the various incompatibilities between OpenGL implementations of different video card drivers. The browser people also realized that if slight compatibility differences between different OpenGL drivers was hell, slight incompatibility differences between four different browsers times three OSes times different graphics card drivers would be the worst thing ever. From what I can only assume was desperation, the most successful example I've ever seen of true cross-company open source collaboration emerged: ANGLE, a BSD-licensed OpenGL emulator originally written by Google but with honest-to-goodness contributions from both Firefox and Apple, which is used for WebGL support in literally every web browser. But nobody actually wants to use WebGL, right? We want a "modern" API, one of those AZDO thingies. So a W3C working group sat down to make Web Vulkan, which they named WebGPU. I'm not sure my perception of events is to be trusted, but my perception of how this went from afar was that Apple was the most demanding participant in the working group, and also the participant everyone would naturally by this point be most afraid of just spiking the entire endeavor, so reportedly Apple just got absolutely everything they asked for and WebGPU really looks a lot like Metal. But Metal was always reportedly the nicest of the three modern graphics APIs to use, so that's… good? Encouraged by the success with ANGLE (which by this point was starting to see use as a standalone library in non-web apps¹¹), and mindful people would want to use this new API with WebASM [https://webassembly.org/], they took the step of defining the standard simultaneously as a JavaScript IDL and a C header file, so non-browser apps could use it as a library. WGPU [https://staging.cohostcdn.org/attachment/7fcaa405-7420-4db7-bc18-8204dd47cef0/logo.min.svg] 2023 WebGPU is the child of ANGLE and Metal. WebGPU is the missing open-source "ergonomic layer" for Vulkan. WebGPU is in the web browser, and Microsoft and Apple are on the browser standards committee, so they're "bought in", not only does WebGPU work good-as-native on their platforms but anything WebGPU can do will remain perpetually feasible on their OSes regardless of future developer lock-in efforts. (You don't have to worry about feature drift like we're already seeing with MoltenVK.) WebGPU will be on day one (today) available with perfectly equal compatibility for JavaScript/TypeScript (because it was designed for JavaScript in the first place), for C++ (because the Chrome implementation is in C, and it's open source) and for Rust (because the Firefox implementation is in Rust, and it's open source). I feel like WebGPU is what I've been waiting for this entire time. ---------------------------------------- WHAT'S IT LIKE? I can't compare to DirectX or Metal, as I've personally used neither. But especially compared to OpenGL and Vulkan, I find WebGPU really refreshing to use. I have tried, really tried, to write Vulkan, and been defeated by the complexity each time. By contrast WebGPU does a good job of adding complexity only when the complexity adds something. There are a lot of different objects to keep track of, especially during initialization (see below), but every object represents some Real Thing that I don't think you could eliminate from the API without taking away a useful ability. (And there is at least the nice property that you can stuff all the complexity into init time and make the process of actually drawing a frame very terse.) WebGPU caters to the kind of person who thinks it might be fun to write their own raymarcher, without requiring every programmer to be the kind of person who thinks it would be fun to write their own implementation of malloc. THE PROBLEMS There are three Problems. I will summarize them thusly: * Text * Lines * The Abomination Text and lines are basically the same problem. WebGPU kind of doesn't… have them. It can draw lines, but they're only really for debugging– single-pixel width and you don't have control over antialiasing. So if you want a "normal looking" line you're going to be doing some complicated stuff with small bespoke meshes and an SDF shader. Similarly with text, you will be getting no assistance– you will be parsing OTF font files yourself and writing your own MSDF shader, or more likely finding a library that does text for you. This (no lines or text unless you implement it yourself) is a totally normal situation for a low-level graphics API, but it's a little annoying to me because the web browser already has a sophisticated anti-aliased line renderer (the original Canvas API) and the most advanced text renderer in the world. (There is some way to render text into a Canvas API texture and then transfer the Canvas contents into WebGPU as a texture, which should help for some purposes.) Then there's WGSL, or as I think of it, The Abomination. You will probably not be as annoyed by this as I am. Basically: One of the benefits of Vulkan is that you aren't required to use a particular shader language. OpenGL uses GLSL, DirectX uses HLSL. Vulkan used a bytecode, called SPIR-V, so you could target it from any shader language you wanted. WebGPU was going to use SPIR-V, but then Apple said no¹². So now WebGPU uses WGSL, a new thing developed just for WebGPU, as its only shader language. As far as shader languages go, it is fine. Maybe it is even good. I'm sure it's better than GLSL. For pure JavaScript users, it's probably objectively an improvement to be able to upload shaders as text files instead of having to compile to bytecode. But gosh, it would have been nice to have that choice! (The "desktop" versions of WebGPU still keep SPIR-V as an option.) ---------------------------------------- HOW DO I USE IT? You have three choices for using WebGPU: Use it in JavaScript in the browser, use it in Rust/C++ in WebASM inside the browser, or use it in Rust/C++ in a standalone app. The Rust/C++ APIs are as close to the JavaScript version as language differences will allow; the in-browser/out-of-browser APIs for Rust and C++ are identical (except for standalone-specific features like SPIR-V). In standalone apps you embed the WebASM components from Chrome or Firefox as a library; your code doesn't need to know if the WebGPU library is a real library or if it's just routing through your calls to the browser. Regardless of language, the official WebGPU spec document [https://www.w3.org/TR/webgpu/] on w3.org is a clear, readable reference guide to the language, suitable for just reading in a way standard specifications sometimes aren't. (I haven't spent as much time looking at the WGSL spec [https://www.w3.org/TR/WGSL/] but it seems about the same.) If you get lost while writing WebGPU, I really do recommend checking the spec. Most of the "work" in WebGPU, other than writing shaders, consists of the construction (when your program/scene first boots) of one or more "pipeline" objects, one per "pass", which describe "what shaders am I running, and what kind of data can get fed into them?"¹³. You can chain pipelines end-to-end within a queue: have a compute pass generate a vertex buffer, have a render pass render into a texture, do a final render pass which renders the computed vertices with the rendered texture. Here, in diagram form, are all the things you need to create to initially set up WebGPU and then draw a frame. This might look a little overwhelming. Don't worry about it! In practice you're just going to be copying and pasting a big block of boilerplate from some sample code. However at some point you're going to need to go back and change that copypasted boilerplate, and then you'll want to come back and look up what the difference between any of these objects is. At init: Context: One <canvas> or window. Exists at boot. WebGPU instance: navigator.gpu. Exists at boot. Adapter: If there’s more than one video card, you can pick one. Feed this to Canvas Configuration. Vends a Device. Vends a Queue. Canvas Configuration: You make this. Feed to Context. Queue: Executes work batches in order. You’ll use this later. Device: An open connection to the adapter. Gives color format to the Canvas Configuration. Vends Buffers, Textures, and Pipelines and compiles code to Shaders. Buffer: A chunk of GPU memory. You’ll use this later. Texture:GPU memory formatted as an image. You’ll use this later. Shader: Vertex, Fragment, or Compute program. Feed to Pipeline. Buffer Layout: Describes how to interpret bytes in a Buffer. Like a C Struct definition. Describes a Buffer. Feed to Pipeline. Vertex Layout: Buffer layout specialized for meshes/triangle lists. Describes a Buffer. Feed to Pipeline. [https://staging.cohostcdn.org/attachment/45fea200-d670-4fab-9788-6462930f8eba/wgpu1-2.0.png] For each frame: Step one: Take a Buffer which you wish to update this frame. This will vend a Mapped Range, which is a Typed array that can read/write data from part of a GPU buffer. When you "unmap" the mapped range, the changes are automatically synchronized with the appropriate queue at that moment Step two: Device vends a Command Encoder. Context vends the Current Texture for this frame. Feed this to the Command Encoder and get a Render Pass. (The Command Encoder can also vend Compute Passes. Feed Viewport and Scissor rects (these are just numbers) to the Render Pass. Feed a Pipeline to the scissor rect. Feed Buffers (uniforms, vertices, indices) to the Render Pass. Feed Textures (inputs to shaders) to the Render Pass. Feed Render Passes and Compute Passes to the Queue. [https://staging.cohostcdn.org/attachment/0f2e871b-f37b-48cb-847f-d908704d4c39/wgpu2-2.0.png] Some observations in no particular order: * When describing a "mesh" (a 3D model to draw), a "vertex" buffer is the list of points in space, and the "index" is an optional buffer containing the order in which to draw the points. Not sure if you knew that. * Right now the "queue" object seems a little pointless because there's only ever one global queue. But someday WebGPU will add threading and then there might be more than one. * A command encoder can only be working on one pass at a time; you have to mark one pass as complete before you request the next one. But you can make more than one command encoder and submit them all to the queue at once. * Back in OpenGL when you wanted to set a uniform, attribute, or texture on a shader, you did it by name. In WebGPU you have to assign these things numbers in the shader and you address them by number.¹⁴ * Although textures and buffers are two different things, you can instruct the GPU to just turn a texture into a buffer or vice versa. * I do not list "pipeline layout" or "bind group layout" objects above because I honestly don't understand what they do. I've only ever set them to default/blank. * In the Rust API, a "Context" is called a "Surface". I don't know if there's a difference. Getting a little more platform-specific: TYPESCRIPT / NPM WORLD The best way to learn WebGPU for TypeScript I know is Alain Galvin's "Raw WebGPU" tutorial [https://alain.xyz/blog/raw-webgpu]. It is a little friendlier to someone who hasn't used a low-level graphics API before than my sandbag introduction above, and it has a list of further resources at the end. Since code snippets don't get you something runnable, Alain's tutorial links a completed source repo with the tutorial code, and also I have a sample repo [https://github.com/mcclure/ts-hello/tree/canvas-gpu] which is based on Alain's tutorial code and adds simple animation as well as Preact¹⁵. Both my and Alain's examples use NPM and WebPack¹⁶. If you don't like TypeScript: I would recommend using TypeScript anyway for WGPU. You don't actually have to add types to anything except your WGPU calls, you can type everything "any". But building that pipeline object involves big trees of descriptors containing other descriptors, and it's all just plain JavaScript dictionaries, which is nice, until you misspell a key, or forget a key, or accidentally pass the GPUPrimitiveState table where it wanted the GPUVertexState table. Your choices are to let TypeScript tell you what errors you made, or be forced to reload over and over watching things break one at a time. I DON'T KNOW WHAT A NPM IS I JUST WANNA WRITE CSS AND MY STUPID LITTLE SCRIPT TAGS If you're writing simple JS embedded in web pages rather than joining the NPM hivemind, honestly you might be happier using something like three.js [https://threejs.org/]¹⁷ in the first place, instead of putting up with WebGPU's (relatively speaking) hyper-low-level verbosity. You can include three.js directly in a script tag using existing CDNs [https://cdnjs.com/libraries/three.js] (although I would recommend putting in a subresource SHA hash [https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity] to protect yourself from the CDN going rogue). But! If you want to use WebGPU, Alain Galvin's tutorial [https://alain.xyz/blog/raw-webgpu], or renderer.ts from his sample code, still gets you what you want. Just go through and anytime there's a little : GPUBlah wart on a variable delete it and the TypeScript is now JavaScript. And as I've said, the complexity of WebGPU is mostly in pipeline init. So I could imagine writing a single <script> that sets up a pipeline object that is good for various purposes, and then including that script in a bunch of small pages that each import¹⁸ the pipeline, feed some floats into a buffer mapped range, and draw. You could do the whole client page in like ten lines probably. RUST So as I've mentioned, one of the most exciting things about WebGPU to me is you can seamlessly cross-compile code that uses it without changes for either a browser or for desktop. The desktop code uses library-ized versions of the actual browser implementations so there is low chance of behavior divergence. If "include part of a browser in your app" makes you think you're setting up for a code-bloated headache, not in this case; I was able to get my Rust "Hello World" down to 3.3 MB, which isn't much worse than SDL, without even trying. (The browser hello world is like 250k plus a 50k autogenerated loader, again before I've done any serious minification work.) If you want to write WebGPU in Rust¹⁹, I'd recommend checking out this official tutorial from the wgpu project [https://sotrh.github.io/learn-wgpu/], or the examples in the wgpu source repo [https://github.com/gfx-rs/wgpu/tree/trunk/wgpu/examples/]. As of this writing, it's actually a lot easier to use Rust WebGPU on desktop than in browser; the libraries seem to mostly work fine on web, but the Rust-to-wasm build experience is still a bit rough. I did find a pretty good tutorial for wasm-pack here [https://developer.mozilla.org/en-US/docs/WebAssembly/Rust_to_wasm]²⁰. However most Rust-on-web developers seem to use (and love) something called "Trunk [https://trunkrs.dev/]". I haven't used Trunk yet but it replaces wasm-pack as a frontend, and seems to address all the specific frustrations I had with wasm-pack. I do have also a sample Rust repo I made for WebGPU [https://github.com/mcclure/rs-hello], since the examples in the wgpu repo don't come with build scripts. My sample repo is very basic²¹ and is just the "hello-triangle" sample from the wgpu project but with a Cargo.toml added. It does come with working single-line build instructions for web, and when run on desktop with --release it minimizes disk usage. (It also prints an error message when run on web without WebGPU, which the wgpu sample doesn't.) You can see this sample's compiled form running in a browser here [https://data.runhello.com/j/wgpu/2/]. C++ If you're using C++, the library you want to use is called "Dawn". I haven't touched this but there's an excellently detailed-looking Dawn/C++ tutorial/intro here [https://eliemichel.github.io/LearnWebGPU/]. Try that first. POSTHUMAN INTERSECTING TETRAHEDRON I have strange, chaotic daydreams of the future [https://pbs.twimg.com/media/E7Rqz1CXMBYcbOD?format=jpg&name=medium]. There's an experimental project called rust-gpu [https://github.com/EmbarkStudios/rust-gpu] that can compile Rust to SPIR-V. SPIR-V to WGSL compilers already exist, so in principle it should already be possible to write WebGPU shaders in Rust, it's just a matter of writing build tooling that plugs the correct components together. (I do feel, and complained above, that the WGSL requirement creates a roadblock for use of alternate shader languages in dynamic languages, or languages like C++ with a broken or no build system— but Rust is pretty good at complex pre-build processing, so as long as you're not literally constructing shaders on the fly then probably it could make this easy.) I imagine a pure-Rust program where certain functions are tagged as compile-to-shader, and I can share math helper functions between my shaders and my CPU code, or I can quickly toggle certain functions between "run this as a filter before writing to buffer" or "run this as a compute shader" depending on performance considerations and whim. I have an existing project that uses compute shaders and answering the question "would this be faster on the CPU, or in a compute shader?"²² involved writing all my code twice and then writing complex scaffold code to handle switching back and forth. That could have all been automatic. Could I make things even weirder than this? I like Rust for low-level engine code, but sometimes I'd prefer to be writing TypeScript for business logic/"game" code. In the browser I can already mix Rust and TypeScript, there's copious example code for that. Could I mix Rust and TypeScript on desktop too? If wgpu is already my graphics engine, I could shove in Servo or QuickJS or something, and write a cross-platform program that runs in browser as TypeScript with wasm-bindgen Rust embedded inside or runs on desktop as Rust with a TypeScript interpreter inside. Most Rust GUI/game libraries work in wasm already, and there's this pure Rust WebAudio implementation [https://github.com/orottier/web-audio-api-rs] (it's currently not a drop-in replacement for wasm-bindgen WebAudio but that could be fixed). I imagine creating a tiny faux-web game engine that is all the benefits of Electron without any the downsides. Or I could just use Tauri [https://tauri.app/] for the same thing and that would work now without me doing any work at all. Could I make it weirder than that? WebGPU's spec is available as a machine-parseable WebIDL file; would that make it unusually easy to generate bindings for, say, Lua? If I can compile Rust to WGSL and so write a pure-Rust-including-shaders program, could I compile TypeScript, or AssemblyScript or something, to WGSL and write a pure-TypeScript-including-shaders program? Or if what I care about is not having to write my program in two languages and not so much which language I'm writing, why not go the other way? Write an LLVM backend for WGSL, compile it to native+wasm and write an entire-program-including-shaders in WGSL. If the w3 thinks WGSL is supposed to be so great, then why not? Okay that's my blog post. ---------------------------------------- ¹ 113 or newer ² "Fragment" is OpenGL for "Pixel". ³ I am still trying to figure out whether modern video cards are simply based on the internal architecture of Quake 3. ⁴ And those coordinates HAD to describe triangles, now. Want to draw a rectangle? Fuck you, apparently! ⁵ (And a series of OpenGL techniques and extensions no one seems to have really got the chance to use before OpenGL was sunset.) ⁶ Why is a "push constant" different from a "uniform", in Vulkan/WebGPU? Well, because those are two different things inside of the GPU chip. Why would you use one rather than the other? Well, learn what the GPU chip is doing, and then you'll understand why either of these might be more appropriate in certain situations. Does this sound like a lot of mental overhead? Well, sometimes, but honestly, it's less mental overhead than trying to understand whatever "VAO"s were. ⁷ Require ⁸ By the way, have you noticed the cheesy Star Trek joke yet? The companies with seats on the Khronos board have a combined market capitalization of 6.1 trillion dollars. This is the sense of humor that 6.1 trillion dollars buys you. ⁹ There are decent Vulkan-based OSS game engines, though. LÖVR [https://lovr.org/], the Lua-based game engine I use for my job [https://mermaid.industries/], has a very nice pared-down Lua frontend on top of its Vulkan backend that is usable by beginners but exposes most of the GPU flexibility you actually care about. (The Lua API is also itself a thin wrapper atop a LÖVR-specific C API, and the graphics module is designed to be separable from LÖVR in principle, so if I didn't have WebGPU I'd actually probably be using LÖVR's C frontend even outside Lua now.) ¹⁰ This made OpenGL's fragmentation problem even worse, as the "final" form of OpenGL is basically version 4.4-4.6 somewheres, whereas Apple got to 4.1 and simply stopped. So if you want to release OpenGL software on a Mac, for however longer that's allowed, you are targeting something that is almost, but not quite, the final full-featured version of the API. This sucks! There is some important stuff in 4.3. ¹¹ Microsoft shipped ANGLE in Windows 11 as the OpenGL component of their Android compatibility layer, and ANGLE has also been shipped as the graphics engine in a small number of games such as, uh… [checking Wikipedia] Shovel Knight?! You might see it used more if ANGLE had been designed for library reuse from day one like WebGPU was, or if anyone wanted to use OpenGL. ¹² If I were a cynical, paranoid conspiracy theorist, I would float the theory here that Apple at some point decided they wanted to leave open the capability to sue the other video card developers on the Khronos board, so they are aggressively refusing to let their code touch anything that has touched the Vulkan patent pool to insulate themselves from counter-suits. Or that is what I would say if I were a cynical, paranoid conspiracy theorist. Hypothetically. ¹³ If you pay close attention here you'll notice something weird: Pipelines combine buffer interfaces with specific shaders, so you can use a single pipeline with many different buffers but only one shader or shader pair. What early users of both WebGPU and Vulkan have found is that you wind up needing a lot of pipeline objects in a fair-sized program, and although the pipeline objects themselves are lightweight, creating the pipeline objects can be kind of slow, especially if you have to create more than one of them on a single frame. So this is an identified pain point, having to think ahead to all the pipeline objects you'll need and cache them ahead of time, and Vulkan has already tried to address this by introducing something called "shader objects" like one month ago. Hopefully the WebGPU WG will look into doing something similar in the next revision. ¹⁴ This annoys me, but I've talked to people who like it better, I guess because they had problems with typo'ing their uniform names. ¹⁵ This sample is a little less complete than I hoped to have it by the time I posted this. Known problems as of this second: It comes with a Preact Canvas wrapper that enforces aspect ratio and integer-multiple size requirements for the canvas, but it doesn't have an option to run full screen; there are unnecessary scroll bars that appear if you open the sample in a non-WebGPU browser (and possibly under other circumstances as well); there is an unused file named "canvas2image.ts", which was supposed to be used to let you download the state as a PNG and ought to be either wired up or removed; if you do add canvas2image back in it doesn't work, and I don't know if the problem is at my end or Chrome's [https://bugs.chromium.org/p/chromium/issues/detail?id=1431714&q=&can=4]; the comments refer to some concepts from 2021 WebGPU, like swapchains. ¹⁶ If you don't like WebPack, that implies you know enough about JavaScript you already know how to replace the WebPack in the example with something else. ¹⁷ Not a specific three.js endorsement. I've never used it. People seem to like it. There [https://www.babylonjs.com/] (BabylonJS) are [https://github.com/redcamel/RedGPU] (RedGPU) alternatives [https://playcanvas.com/] (PlayCanvas, which by the way is incredibly cool). ¹⁸ Wait, do JS modules/import just work in browsers now? I don't even know lol ¹⁹ If you're using Rust, it's quite possible that you are using WebGPU already. The Rust library quickly got far ahead of its Firefox parent software and has for some time now already been adopted as the base graphics layer in emerging GUI libraries such as Iced [https://iced.rs/]. So you could maybe just use Iced or Bevy for high-level stuff and then do additional drawing in raw WebGPU. I haven't tried. ²⁰ Various warnings if you go this way: If you're on Windows I recommend installing the wasm-pack binary package [https://rustwasm.github.io/wasm-pack/installer/] instead of trying to install it through cargo. If you're making a web build from scratch instead of using my sample, note the slightly alarming "as of 2022-9-20" note here [https://github.com/gfx-rs/wgpu/wiki/Running-on-the-Web-with-WebGPU-and-WebGL] in the wgpu wiki. ²¹ This sample also has as of this writing some caveats: It can only fill the window, it can't do aspect ratios or integer-multiple restrictions; it has no animation; in order to get the fill-the-window behavior, I had to base it on a winit PR [https://github.com/rust-windowing/winit/pull/2074], so the version of winit used is a little older than it could be; there are outstanding warnings; I am unclear on the license status of the wgpu sample code I used, so until I can get clarification or rewrite it you should probably follow the wgpu MIT license even when using this sample on web. I plan to eventually expand this example to include controller support and sound. ²² Horrifyingly, the answer turned out to be "it depends on which device you're running on".

https://web.archive.org/web/20230503112509/https://cohost.org/mcc/post/1406157-i-want-to-talk-about

Canvas が使えないユースケース

WebGPU低レイヤーグラフィックス APIです。OpenGL ES(GLES)Vulkanと同じ仲間です。
低レイヤーなため、Canvasにある文字を書くとか線を引く機能はありません。もし低レイヤー APIで文字を書きたい場合は一旦Canvasで書いて画像(テクスチャと呼びたいですね)をGPUに転送しフラグメントシェーダで書くのが一番早いと思います。

基本的にはCanvasを使えばよいわけですが、本当に一部のアプリはCanvasが使えない場合があります。

カメラや動画の映像はCanvasではなくSurfaceViewを使い描画するため、Canvasで加工することは出来ません。
SurfaceViewが特殊なViewなのはCanvasに書けないからです。

補足すると、速度が出なくてもよい場合はCanvasを頑張って使う方法もあるかと思いますが(映像を1枚1枚画像にするなど)、あまり一般的ではないと思います。(無駄に電池を消費するとか、、)

余談 media3 と cameraX

動画再生ライブラリであるmedia3Camera2 APIを代わりに叩いてくれるCameraXでは、どちらも映像を加工するのにOpenGL ESを利用しています。
予想でしかないですが彼らもOpenGL ESを書くとつらいはずだから、WebGPUが移植されたのかな~って

OpenGLES と Vulkan

というわけでCanvasが使えないとなると何を使うのかというわけですが、OpenGL ESVulkanですね。
ほかのプラットフォームではDirect3D (Windows)とか、Metal (Apple デバイス)になります。

GPUプログラミングといえば三角形

文字を書くとか図形を書くみたいな機能はもちろん無くて、基本的には三角形を描画することしかできない。GLESとかの練習で三角形が最初に出るのはそういうことです。
三角形なのはこれを組み合わせればどんな図形も立体も書けるから。らしい。
苦労して三角形書いてだから何?って。

三角形を書くためにはいくつかの関数呼び出しと、それとは別にフラグメントシェーダー・バーテックスシェーダーと呼ばれるプログラムを書く必要があります。
バーテックスシェーダーが三角形の位置を決めて、フラグメントシェーダーが三角形に色を付けます。
三角形に画像をあてはめたい場合はGPUに画像を転送し、フラグメントシェーダーで画像の色を取り出して色を決定する、、、みたいなことをします。

カメラや動画の映像がなぜ低レイヤーAPIだと高速に扱えるのかというと、このシェーダーたちは同時に実行されます。
例えば、フルHDの画面なら1920x1080の数だけ色を準備する必要があるわけですが、これらの色を決めるためにに並列でシェーダーを実行することでリアルタイムな描画ができているわけ。すごい!
(ほかにも理由はあると思いますが)

世の中にはshadertoyと呼ばれるサイトがあり、ブラウザ版 OpenGL ESであるWebGLを使いGPUで高速に描画できるのをいいことに、Canvasでは表現できないような力作が投稿されまくっているサイトがあります。

OpenGL ESVulkanが何なのかはこの通りで、Vulkanは比較的新しいのでOpenGL ESよりも速いらしいです。書いたことがないので分かりませんが。

Vulkan 難しすぎ問題

昔からあるOpenGL ESと、割と新しくて高速なVulkanがあるわけですが、選択肢は一択です。OpenGL ES一択です。(2026年記述時時点、WebGPUが流行ったら変わるかもしれない!)

速いのになぜVulkanを書かないのか?という質問が来ていました。
Vulkan は人間が書くのが無理なほど難しいと言われております。
OpenGL ESですら難しいのに><)

そもそも難しい上に、AndroidではC++で記述する必要があります。C++!?!??!
OpenGL ESJava/Kotlinから呼び出して描画することが出来たのですが、VulkanではC++を書く必要があります。
(え~Android開発各位は16KB ページサイズが記憶に新しいのでAndroid NDKを用意するのは嫌ですかね・・)

なんかOpenGL ESはスマホアプリで使ってほしそうな雰囲気を感じますが、それと違いVulkanは用途が違うように思えます。
多分ゲームではなくゲームエンジンを作っている、とか、機械学習だから画面出力なしでGPUを使う。みたいな用途のためにこんなにも難しくなっているんでは?

先述のブログ曰く、Metalのがまだマシらしく、Vulkanがとにかく難しいらしい。。

いやなんかMetalのがよさそうじゃないか(隣の芝生は青く何とかかんとか)

WebGPU

もともとはブラウザでOpenGL ESを使えるようにするWebGLという技術があり、その後継としてWebGPUがあります。
作った由来としてはWebGLが出た時点で、すでにOpenGL以外の高性能な選択肢(VulkanWindowsDirect3D12AppleMetal)があり、これらをブラウザから使うために作られたとかなんとか。

OpenGL ESを大体そのまま移植したWebGLと違い、WebGPUはブラウザで動かすためWindows / Android / Apple デバイスで動かないといけない。
それぞれのプラットフォームにあるグラフィックス APIを呼び出す形なので、Vulkanを置き換えるとかではなく、Vulkanの上で動くことになる。

WebGLと比べてWebGPUの方が高性能ゆえ難しいとされているが、ちょっと書いてみた限りWebGPUの方がモダンなAPIだし分かりやすくない???
OpenGL ESよりも罠が少ない気がする。

samplerを要求するんだ~くらいしか引っかかるところがなかった(まあ大したコードを書いていないというのがある)

Android に話を戻す

Androidに話を戻すと、Androidに移植されたWebGPUですが、ゼロから作ったわけではなくChromeにあるWebGPUの実装をAndroidに持ってきたみたいです。

What's New in WebGPU (Chrome 144)  |  Blog  |  Chrome for Developers

WGSL subgroup_id and uniform_buffer_standard_layout extensions and WebGPU rolling out on Linux.

https://developer.chrome.com/blog/new-in-webgpu-144

Vulkanの上で動くため、ライブラリをいれるとVulkanを使ったWebGPU実装のネイティブライブラリ(.so)が入ります。
これをJNIを経由してKotlinから叩くことが出来るため、Vulkanを書かずとも高性能を享受できる!という寸法!

OpenGLES を WebGPUにすると嬉しいこと

モダンな API

人間でも書ける、人間に書いてほしそうなAPIがここにはあります。

何か間違えたら関数呼び出しの箇所で例外を投げてくれます。

OpenGL ESの時はglなんとかかんとか()みたいな関数を呼び出しても失敗したかどうかは分かりません。
失敗したかどうかはGLES20.glGetError()を別に呼び出すことで、さっきの関数呼び出しが失敗しているのかを知ることが出来ます。
なのでGlUtil.checkGlError()みたいなstatic 関数がどのプロジェクトにもあって、これを開発者の気分によって呼び出したり呼び出さなかったりされてる。
(いまだにわからない、OpenGL ESのエラーチェック、どの程度でやってんの?glなんとかかんとか()を呼び出すたびにやってるの?)

glerror_hex

そもそもエラーが得られたとして、謎の16進数でエラーを表現するからやる気なくすんだよな。
人間が読めるエラーなんて返してくれません。

WebGPUはマジで良くできていて、失敗した関数呼び出しで例外を投げてくれる。
どこで失敗しているのかがすごくわかりやすい。人間が読めるエラーも出してくれる。バイト数が間違ってるとか、構文が違うとか。親切すぎ。

ほかにも、フラグメントシェーダーバーテックスシェーダーがコンパイルに失敗しても例外を勝手に投げてくれます。
OpenGL ESの時は、コンパイルに失敗したらGLES20.glGetShaderInfoLogを呼び出すことでシェーダーでどこが間違っているかを教えてくれます。逆に呼ばないと迷宮入り。

WebGPUはとにかくエラーが分かりやすいと思います。

あとはWebGPUのシェーダーWebGPU Shading Language (WGSL)も結構よいと思います。
先述のブログ曰く、もともとはAppleKhronos Groupで会社同士の仲が悪くて作られたものらしい。

見た目がモダンなのを除くと、フラグメントシェーダーバーテックスシェーダーを1か所に書けるのは結構よいと思いました。
また、頂点の配列CPUから渡さずにバーテックスシェーダーの中に直接書けるようになってて?(OpenGL ESで出来たっけ?)これは説明をする分や、とりあえず動かす分には分かりやすくてよいと思いました。複雑になるので基本的にはCPUから渡すと思います。

バーテックスシェーダー構造体を返してフラグメントシェーダーに渡すのもなんか直観的になった気がします。そもそも構造体なんてものが使えるだと・・・!
GLSLvaryingin / out変数よりもバーテックスシェーダーが返すのが直観的というか(2回目)

なんか GooglePixel は Vulkan なら性能が良いらしい

まあPixelはゲームを売りにしているわけではないし、ゲームしたいならPixelを選ばないと思うのでどうでもいい話なのはその通りなのですが、、

GooglePixelシリーズはSoCGoogle Tensorになってから同期のAndroidと比べて特にGPUがイマイチになってます。
特にPixel 10 シリーズにおいては、どういうわけか一つ前の世代よりも性能が悪化しているらしい。

というわけでネットの海をさまよった結果、どうやら OpenGL ES のドライバーがイマイチなだけで
Vulkan なら一つ前と同じくらいの性能が出るらしい!

ところが、先述の通りVulkanを使うにはC++を書く必要と、すごく難しいであろうAPIを叩くことになるので試すことが出来なかった。

今回作るもの

なに作ろうかな~というわけで、万華鏡みたいなのを作ろうかと。
よく見ると三角形を組み合わせて作っているのでそこまで難しくなさそうでいいかもしれない。難しいのはカメラ映像をWebGPUに渡すところかな、、、

三角形を組み合わせれば作れそう

もう疲れたので APK だけくれませんか

はい

環境

なまえあたい
Android StudioAndroid Studio Quail 3 2026.1.3
たんまつXperia 1 VIII / Pixel Pro Fold 10
androidx.webgpu:webgpu1.0.0-alpha05

流れ

  • カメラ権限を得る
  • カメラ映像を受け取っていい感じに YUV_420 にする
  • WebGPU の設定をする
  • カメラ映像を受け取って描画する

WebGPU ライブラリを入れる

app/build.gradle.ktsの中のdependenciesandroidx.webgpuを入れてください。いまんとこalphaです。

dependencies {
    // WebGPU
    implementation("androidx.webgpu:webgpu:1.0.0-alpha05")

    // 以下省略...

ざっくり WebGPU 入門

ココにあります!

わたしも、知ってる限り、できる限り説明します!!
OpenGL ESやってなくてもモダンなAPIでやろー

三角形を書く MainActivity

とりあえずWebGpuSurfaceViewとかいうComposableを作り、中身はSurfaceViewを置いただけです。
サンプルコードではAndroidExternalSurfaceを使っているのですが、あんまり信用してない(???)のでAndroidViewで行きます。

SurfaceViewは画面回転とかで再生成されるため、collectLatestを使い描画するためのクラスも破棄して作り直すようにしました。
WebGpuRenderer()クラスを作っていないのでエラーになります。この後作ります。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            AndroidWebGpuKaleidoscopeTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    WebGpuSurfaceView(modifier = Modifier.padding(innerPadding))
                }
            }
        }
    }
}

@Composable
fun WebGpuSurfaceView(modifier: Modifier = Modifier) {
    val surfaceSize = remember { MutableStateFlow<IntSize?>(null) }
    val surfaceFlow = remember { MutableStateFlow<Surface?>(null) }

    LaunchedEffect(key1 = Unit) {
        // サイズと surface が得られること、得られない場合は return している
        combine(
            surfaceSize,
            surfaceFlow,
            ::Pair
        ).collectLatest { (size, surface) ->
            if (size != null && surface != null) {
                val renderer = WebGpuRenderer()
                try {
                    renderer.init(surface, size.width, size.height)
                    renderer.render()
                } finally {
                    // surface が再生成された、破棄されたとき
                    renderer.cleanup()
                }
            }
        }
    }

    AndroidView(
        modifier = modifier.onSizeChanged { surfaceSize.value = it },
        factory = { context ->
            SurfaceView(context).apply {
                holder.addCallback(object : SurfaceHolder.Callback {
                    override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
                        // do nothing
                    }

                    override fun surfaceCreated(holder: SurfaceHolder) {
                        surfaceFlow.value = holder.surface
                    }

                    override fun surfaceDestroyed(holder: SurfaceHolder) {
                        surfaceFlow.value = null
                    }
                })
            }
        }
    )
}

そういえば、サンプル通りにwithContext(Dispatchers.Default)で描画してると、最初のころはうまく動くんだけど、何回か繰り返しrender()を呼び出していると謎のエラーで落ちるんだけど、これは何?
仕方なく今回はメインスレッドで呼び出しています;;

Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0 in tid 7821 (DefaultDispatch), pid 7763 (webgpumangekyou)
Executable: /system/bin/app_process64
Cmdline: io.github.takusan23.androidwebgpumangekyou
pid: 7763, ppid: 1064, tid: 7821, name: DefaultDispatch  >>> io.github.takusan23.androidwebgpumangekyou <<<
uid: 10470
tagged_addr_ctrl: 0000000000000001 (PR_TAGGED_ADDR_ENABLE)
pac_enabled_keys: 000000000000000f (PR_PAC_APIAKEY, PR_PAC_APIBKEY, PR_PAC_APDAKEY, PR_PAC_APDBKEY)
esr: 0000000092000006 (Data Abort Exception 0x24)
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0000000000000000 (read)
Cause: null pointer dereference

    x0  b400007313869af0  x1  00000070ca330820  x2  0000000000000060  x3  00000070ca330780
    x4  00000070ca3307e0  x5  0000000000000000  x6  0000000000000000  x7  0000000000000000
    x8  0000000000000048  x9  0000000000010000  x10 0000000000000400  x11 0000000000000048
    x12 00000070ca330808  x13 0000000000000001  x14 b400007273877cc0  x15 0000000000000000
    x16 0000000000000000  x17 000000742f6c50c0  x18 00000070c91e8000  x19 00000070ca3308e8
    x20 0000000000000000  x21 00000070ca3307e0  x22 b40000720388fef0  x23 000000003b9f9490
    x24 0000000000000000  x25 00000070ca330780  x26 0000000000000000  x27 0000000000000400
    x28 00000070ca3307e0  x29 00000070ca330720
    lr  00016af12dfb166c  sp  00000070ca3306b0  pc  000000712deddbd8  pst 0000000060001000
    esr 0000000092000006  vg  0000000000000002

51 total frames
backtrace:
      #00 pc 00000000000acbd8  /vendor/lib64/hw/vulkan.powervr.so (CmdPipelineBarrier+40) (BuildId: c5a24e633e33df683ac924d335f0fa5e)
      #01 pc 0000000000180668  /vendor/lib64/hw/vulkan.powervr.so (IMG_vkCmdPipelineBarrier+600) (BuildId: c5a24e633e33df683ac924d335f0fa5e)
      #02 pc 00000000002a5d90  /data/app/~~a9tWnlc2Ui69foMsJvwU-w==/io.github.takusan23.androidwebgpumangekyou-2HqmwQPXD1IXlHvS7R8ZNQ==/base.apk!libwebgpu_c_bundled.so (offset 0xa4000) (BuildId: 3b2a4919ce8bcfa4a301e256d6bcc7ff)
      #03 pc 00000000002a42fc  /data/app/~~a9tWnlc2Ui69foMsJvwU-w==/io.github.takusan23.androidwebgpumangekyou-2HqmwQPXD1IXlHvS7R8ZNQ==/base.apk!libwebgpu_c_bundled.so (offset 0xa4000) (BuildId: 3b2a4919ce8bcfa4a301e256d6bcc7ff)

以下省略...

三角形を書く WebGpuRenderer

これはサンプル通りで、分かりにくい部分をちょっと改行して分かりやすくしただけです。
このクラスを用意するとMainActivityの方のエラーが消えるので、これで実行してみましょう。

とりあえず三角形を見てから超簡単に解説します。

class WebGpuRenderer {
    private lateinit var webGpu: WebGpu
    private lateinit var renderPipeline: GPURenderPipeline

    suspend fun init(surface: Surface, width: Int, height: Int) {
        // 1. Create Instance & Device
        webGpu = createWebGpu(surface)
        val device = webGpu.device

        // 2. Setup Pipeline (compile shaders)
        initPipeline(device)

        // 3. Configure the Surface
        webGpu.webgpuSurface.configure(
            GPUSurfaceConfiguration(
                device,
                width,
                height,
                TextureFormat.RGBA8Unorm,
            )
        )
    }

    fun render() {
        if (!::webGpu.isInitialized) {
            return
        }

        val gpu = webGpu

        // 1. Get the next available texture from the screen
        val surfaceTexture = gpu.webgpuSurface.getCurrentTexture()

        // 2. Create a command encoder
        val commandEncoder = gpu.device.createCommandEncoder()

        // 3. Begin a render pass (clearing the screen to blue)
        val renderPass = commandEncoder.beginRenderPass(
            GPURenderPassDescriptor(
                colorAttachments = arrayOf(
                    GPURenderPassColorAttachment(
                        GPUColor(0.0, 0.0, 0.5, 1.0),
                        surfaceTexture.texture.createView(),
                        loadOp = LoadOp.Clear,
                        storeOp = StoreOp.Store,
                    )
                )
            )
        )

        // 4. Draw
        renderPass.setPipeline(renderPipeline)
        renderPass.draw(3) // Draw 3 vertices
        renderPass.end()

        // 5. Submit and Present
        gpu.device.queue.submit(arrayOf(commandEncoder.finish()))
        gpu.webgpuSurface.present()
    }

    fun cleanup() {
        if (::webGpu.isInitialized) {
            webGpu.close()
        }
    }

    private fun initPipeline(device: GPUDevice) {
        val shaderCode = """
        @vertex fn vs_main(@builtin(vertex_index) vertexIndex : u32) -> @builtin(position) vec4f {
            const pos = array(vec2f(0.0, 0.5), vec2f(-0.5, -0.5), vec2f(0.5, -0.5));
            return vec4f(pos[vertexIndex], 0, 1);
        }
        
        @fragment fn fs_main() -> @location(0) vec4f {
            return vec4f(1, 0, 0, 1);
        }
    """

        // Create Shader Module
        val shaderModule = device.createShaderModule(
            GPUShaderModuleDescriptor(shaderSourceWGSL = GPUShaderSourceWGSL(shaderCode))
        )

        // Create Render Pipeline
        renderPipeline = device.createRenderPipeline(
            descriptor = GPURenderPipelineDescriptor(
                vertex = GPUVertexState(
                    shaderModule,
                ),
                fragment = GPUFragmentState(
                    shaderModule,
                    targets = arrayOf(GPUColorTargetState(TextureFormat.RGBA8Unorm))
                ),
                primitive = GPUPrimitiveState(PrimitiveTopology.TriangleList)
            )
        )
    }
}

三角形が表示できた!

赤い三角形が表示されてますね!

webgpu_triangle_not_fix_aspect

なんか歪んでない?、スマホが縦長だからそれに追従して縦に長くなってない?

なぜ三角形が表示されるのか

suspend fun init()WebGPUの初期化をしている箇所です。名前通りイニシャライズですね。
この辺はお作法なので飛ばします。OpenGL ESの時のEGLほにゃららの時よりはるかに分かりやすいAPIだ。

三角形はfun render()で描画をしています。
重要なのはdraw(3)と、initPipeline()shaderCodeの文字列ですね。それ以外はお作法です(CPUから値を渡すUniformとかが無いシンプルなものなのでもうお作法)

以下のコードがシェーダーと呼ばれるもので、Rustっぽい雰囲気(変換できる)を感じますね!。WebGPU Shader Language (WGSL)と呼びます。
この関数たちはGPU側で動作します。それっぽくなってきましたね!

@vertex fn vs_main(@builtin(vertex_index) vertexIndex : u32) -> @builtin(position) vec4f {
    const pos = array(vec2f(0.0, 0.5), vec2f(-0.5, -0.5), vec2f(0.5, -0.5));
    return vec4f(pos[vertexIndex], 0, 1);
}

@fragment fn fs_main() -> @location(0) vec4f {
    return vec4f(1, 0, 0, 1);
}

draw(3)を呼び出すと、3なので三回vs_main()関数が呼び出されます。引数のvertexIndexがそれぞれ0,1,2で呼び出される感じですね。
これはバーテックスシェーダーと呼ばれています。位置を決めるのに使われます。

const pos = array(...)が、三角形の頂点の座標です。0.5という数字は、WebGPUの座標系による数字です。(OpenGL ESの時と同じような感じですが)

というのも、WebGPUX/Y 座標1280とか720とかではなく、-1から1の範囲に正規化されます。
三角形を見た時に縦に長くなっていると思ったと思いますが、これは縦も横も-1から1の範囲に無理やり押し込まれているからなんですね。
縦の-1 ~ 1と、横の-1 ~ 1が異なるため、縦長になってしまう。

webgpu_座標は-1から1の範囲になる

コードのconst pos = array(vec2f(0.0, 0.5), vec2f(-0.5, -0.5), vec2f(0.5, -0.5));だと

  • 最初の頂点はx=0で、y=0.5(上)
  • 次の頂点はx=-0.5で、y=0.5(左下)
  • 最後の頂点がx=0.5で、y=-0.5(右下)

となります。pos[vertexIndex]でそれぞれの配列から取り出してreturnしているので、これで三角形が書けます。

webgpu_座標と三角形の説明

次はfs_main()が呼びだされます。フラグメントシェーダーと呼ばれています。これは色を決める関数です。各ピクセルに対して呼ばれるわけですね!
が、ここでは省略するためvec4f(1, 0, 0, 1);を常に返していますね。vec4はそれぞれ赤色, 緑色, 青色, 透明度の順番で0から1の値を取ります。この例では(と透明)だけが1なので、赤色になります。
画像を表示するとかの場合はここに書けばよいわけですね!

例えば四角形を書くなら?

四角形はよく見てみると、縦に長くした三角形を並べて二つ描画することで四角形が描画できるということに気付きますね!
というわけで、draw(6)にして(三角形を二つ書くため)、頂点の配列をこんな感じにすることで四角形を描画することが出来るはずです。

const pos = array(
    vec2<f32>(-1.0, -1.0), // 左下
    vec2<f32>( 1.0, -1.0), // 右下
    vec2<f32>(-1.0,  1.0), // 左上
    vec2<f32>(-1.0,  1.0), // 左上
    vec2<f32>( 1.0, -1.0), // 右下
    vec2<f32>( 1.0,  1.0)  // 右上
);

変換行列

コンピューターグラフィックの世界ではカメラ?とかの専門用語があるみたいなのですが、まあよく分からないんため、ここでは変換行列と呼ぶことにします。

三角形が歪んでいます。これは先述の通りスマホの画面が縦長ゆえに、縦の-1 ~ 1と横の-1 ~ 1が違うことが原因です。
(なのでたまたま正方形なら問題ないかもしれません)

三角形を動かしたり、サイズを変えたり、スケールを変えたりすることが出来るFloat 型を 16 個取る魔法みたいな配列があります。これが変換行列ですね。
コンピューターグラフィックスの世界においては配列と呼ぶより、ベクトル行列と呼んだ方が正しいかもしれません。

専門じゃないので本当によく分からないわけですが、OpenGL ESWebGPUのシェーダーでは配列と配列の足し算引き算掛け算割り算が文字通り出来るようになってます。
vec4f() * vec4f()みたいな。そのままだね。

そして、先述の三角形の頂点と変換行列を掛け算することで、さっきの三角形回転させたり、移動させたり、スケールを変えて横に引き延ばしたりできます。

例えば三角形を90度傾けてみる場合はこんな感じ。
回転するだけな、変換行列は2x24つのfloat配列で済むらしい。

@vertex fn vs_main(@builtin(vertex_index) vertexIndex : u32) -> @builtin(position) vec4f {
    const pos = array(vec2f(0.0, 0.5), vec2f(-0.5, -0.5), vec2f(0.5, -0.5));
    
    // WGSL 側で回転行列を作る
    let camera_cosTheta = cos(radians(90));
    let camera_sinTheta = sin(radians(90));
    let rotate_matrix = mat2x2f(
        vec2f(camera_cosTheta, camera_sinTheta),
        vec2f(-camera_sinTheta, camera_cosTheta)
    );
    
    // 回転する
    let xy = pos[vertexIndex];
    let position = rotate_matrix * xy; // ベクトル同士の掛け算!
    
    return vec4f(position, 0, 1);
}

webgpu_回転行列を掛け算して回転した三角形

ところで、変換行列は作るのが難しいため(正直に言うと↑の回転する行列はAIに書いてもらった)、自力では書かないと思います。
変換行列で調べるとなんだか怖い数式が出ますが、わたしたちは自力では書きません。

代わりにAndroidにはandroid.opengl.Matrixクラスが存在し、16個Floatを持つ配列を渡すだけで、好きなように移動させたり回転させたりする行列を書いてくれます!やったぜ!
しかしこれを使うにはCPUGPUのメモリを超える必要があります。Kotlinで作った行列をWebGPUに渡す必要があります。

変換行列を使って歪んでいる三角形を直す

まずは変数を宣言します。
transformMatrixUniformBufferbindGroupです。

private lateinit var webGpu: WebGpu
private lateinit var renderPipeline: GPURenderPipeline

// 変換行列を渡す
private lateinit var transformMatrixUniformBuffer: GPUBuffer
private lateinit var bindGroup: GPUBindGroup

private var width = 0
private var height = 0

次に、initPipeline()関数の中に書き足します。lateinit varに代入します。

private fun initPipeline(device: GPUDevice) {

    // 省略...

    // CPU から値を渡す準備
    transformMatrixUniformBuffer = device.createBuffer(
        GPUBufferDescriptor(
            size = 64,
            usage = BufferUsage.Uniform or BufferUsage.CopyDst
        )
    )
    bindGroup = webGpu.device.createBindGroup(
        descriptor = GPUBindGroupDescriptor(
            layout = renderPipeline.getBindGroupLayout(0),
            entries = arrayOf(
                GPUBindGroupEntry(binding = 0, buffer = transformMatrixUniformBuffer)
            )
        )
    )
}

同様にwidthheightinit()関数で代入するようにしました。

suspend fun init(surface: Surface, width: Int, height: Int) {
    this.width = width
    this.height = height

    // 以下省略...
}

次にシェーダーを修正します。
CPUから受け取った行列を使えるようにしているのが@group(0) @binding(0) var<uniform> transformMatrix: Uniforms;ですね。
型はstruct Uniforms { ... }を使っています。今回はシンプルにベクトルを一つだけ定義してます。

構造体なので複数の値を定義してCPUからGPUへ値を渡すことが出来ますが、もしそうする場合はdevice.createBuffer()で構造体が使うメモリの量をちゃんと計算して、
後述するdevice.queue.writeBuffer()でデータをつなげる必要があると思います。

また行列を適用できるように、バーテックスシェーダーvec4f(pos[vertexIndex], 0, 1)returnする前に、変換行列を掛け算を追加します。
見てもらえばわかるかと思いますが本当に掛け算です。

    val shaderCode = """
    // Uniforms 構造体
    struct Uniforms {
        matrix: mat4x4<f32>,
    }
        
    // CPU から受け取る
    @group(0) @binding(0) var<uniform> transformMatrix: Uniforms;
        
    @vertex fn vs_main(@builtin(vertex_index) vertexIndex : u32) -> @builtin(position) vec4f {
        const pos = array(vec2f(0.0, 0.5), vec2f(-0.5, -0.5), vec2f(0.5, -0.5));
        let transformedVec = vec4f(pos[vertexIndex], 0, 1) * transformMatrix.matrix; // 変換行列を適用
        return transformedVec;
    }
    
    @fragment fn fs_main() -> @location(0) vec4f {
        return vec4f(1, 0, 0, 1);
    }
"""

これでCPUからGPU (シェーダー)側へ配列(ベクトル、行列)を渡せるようになりました。
次はMatrixクラスを使って変換行列を用意します。

まずはFloatの配列をByteBufferに変換する拡張関数を書きました。Floatの配列ではなくByteBufferでやり取りするみたいなので。

private fun FloatArray.toByteBuffer(): ByteBuffer {
    val bufferSize = this.size * Float.SIZE_BYTES
    val byteBuffer = ByteBuffer.allocateDirect(bufferSize)
        .order(ByteOrder.nativeOrder())
        .also { byteBuffer -> byteBuffer.asFloatBuffer().put(this).rewind() }
    return byteBuffer
}

render()関数でGPUに転送しますか、render()関数で変換行列を作ってGPUに転送します。

fun render() {
    if (!::webGpu.isInitialized) {
        return
    }

    val gpu = webGpu

    // 変換行列を用意
    // 三角形が歪まないようにする
    val transformMatrix = FloatArray(16)
    Matrix.setIdentityM(transformMatrix, 0)
    // WebGPU 側を正方形にする、SurfaceView は画面いっぱいなので縦長のママだが、見切れる前提で正方形にする。これで三角形が歪まなくなる
    if (width < height) {
        val scale = (height / width.toFloat())
        Matrix.scaleM(transformMatrix, 0, scale, 1f, 1f)
    } else {
        val scale = (width / height.toFloat())
        Matrix.scaleM(transformMatrix, 0, 1f, scale, 1f)
    }
    // GPU に転送
    gpu.device.queue.writeBuffer(transformMatrixUniformBuffer, 0, transformMatrix.toByteBuffer())

    // 以下省略...
}

どういうことをやっているのかというと、縦と横で長さが違うのに-1 ~ 1に収められているのが悪い。なので、縦に長い分だけ横に引き延ばすスケールを適用する行列?を作っています。
縦と同じ長さになるように横を引き延ばします。縦と横の長さが同じになれば三角形も正三角形になるハズです!

webgpu_やる前の図、横にスケールを伸ばした図、実際に伸ばしたためスマホの画面外に突っ込んでいる説明の図

これで実行してみますと、CPUで作った変換行列が適用されて、歪みのないきれいな三角形になっているのではないでしょうか!?
おめでとう!そして低レイヤーグラフィックス APIは難しい。。。

webgpu_triangle_fixed_aspect

回転しようぜ

Matrixクラスに回転を変換行列へ追加できる関数があるのでこれを呼べば回転もできます。
変換行列の注意点としては、適用する順番がちゃんとあって、順番を間違えると期待通りになりません。

private var rotate = 0f

fun render() {
    if (!::webGpu.isInitialized) {
        return
    }

    val gpu = webGpu


    // 変換行列を用意
    // 三角形が歪まないようにする
    val transformMatrix = FloatArray(16)
    Matrix.setIdentityM(transformMatrix, 0)
    // 回転
    rotate++
    if (rotate == 360f) {
        rotate = 0f
    }
    Matrix.rotateM(transformMatrix, 0, rotate, 0f, 0f, 1f)
    // WebGPU 側を正方形にする、SurfaceView は画面いっぱいなので縦長のママだが、見切れる前提で正方形にする。これで三角形が歪まなくなる
    if (width < height) {
        val scale = (height / width.toFloat())
        Matrix.scaleM(transformMatrix, 0, scale, 1f, 1f)
    } else {
        val scale = (width / height.toFloat())
        Matrix.scaleM(transformMatrix, 0, 1f, scale, 1f)
    }
    // 小さくする
    Matrix.scaleM(transformMatrix, 0, .5f, .5f, .5f)
    // GPU に転送
    gpu.device.queue.writeBuffer(transformMatrixUniformBuffer, 0, transformMatrix.toByteBuffer())

    // 以下省略...

あとはrender()を繰り返し呼ぶようにすればrotateがインクリメントされ続けるのでくるくる回るようになります。

LaunchedEffect(key1 = Unit) {
    // サイズと surface が得られること、得られない場合は return している
    combine(
        surfaceSize,
        surfaceFlow,
        ::Pair
    ).collectLatest { (size, surface) ->
        if (size != null && surface != null) {
            val renderer = WebGpuRenderer()
            try {
                renderer.init(surface, size.width, size.height)
                // 繰り返し呼ぶ
                while (true) {
                    delay(16.milliseconds) // 60fps
                    renderer.render()
                }
            } finally {
                // surface が再生成された、破棄されたとき
                renderer.cleanup()
            }
        }
    }
}

webgpu_triangle_rotate

以上!入門!これからは本題の万華鏡をつくるぞ!

ここまでのコード全部

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            AndroidWebGpuMangekyouTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    WebGpuSurfaceView(modifier = Modifier.padding(innerPadding))
                }
            }
        }
    }
}

@Composable
fun WebGpuSurfaceView(modifier: Modifier = Modifier) {
    val surfaceSize = remember { MutableStateFlow<IntSize?>(null) }
    val surfaceFlow = remember { MutableStateFlow<Surface?>(null) }

    LaunchedEffect(key1 = Unit) {
        // サイズと surface が得られること、得られない場合は return している
        combine(
            surfaceSize,
            surfaceFlow,
            ::Pair
        ).collectLatest { (size, surface) ->
            if (size != null && surface != null) {
                val renderer = WebGpuRenderer()
                try {
                    renderer.init(surface, size.width, size.height)
                    // 繰り返し呼ぶ
                    while (true) {
                        delay(16.milliseconds)
                        renderer.render()
                    }
                } finally {
                    // surface が再生成された、破棄されたとき
                    renderer.cleanup()
                }
            }
        }
    }

    AndroidView(
        modifier = modifier.onSizeChanged { surfaceSize.value = it },
        factory = { context ->
            SurfaceView(context).apply {
                holder.addCallback(object : SurfaceHolder.Callback {
                    override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
                        // do nothing
                    }

                    override fun surfaceCreated(holder: SurfaceHolder) {
                        surfaceFlow.value = holder.surface
                    }

                    override fun surfaceDestroyed(holder: SurfaceHolder) {
                        surfaceFlow.value = null
                    }
                })
            }
        }
    )
}
class WebGpuRenderer {
    private lateinit var webGpu: WebGpu
    private lateinit var renderPipeline: GPURenderPipeline

    // 変換行列を渡す
    private lateinit var transformMatrixUniformBuffer: GPUBuffer
    private lateinit var bindGroup: GPUBindGroup

    private var width = 0
    private var height = 0

    suspend fun init(surface: Surface, width: Int, height: Int) {
        this.width = width
        this.height = height

        // 1. Create Instance & Device
        webGpu = createWebGpu(surface)
        val device = webGpu.device

        // 2. Setup Pipeline (compile shaders)
        initPipeline(device)

        // 3. Configure the Surface
        webGpu.webgpuSurface.configure(
            GPUSurfaceConfiguration(
                device,
                width,
                height,
                TextureFormat.RGBA8Unorm,
            )
        )
    }

    private var rotate = 0f

    fun render() {
        if (!::webGpu.isInitialized) {
            return
        }

        val gpu = webGpu


        // 変換行列を用意
        // 三角形が歪まないようにする
        val transformMatrix = FloatArray(16)
        Matrix.setIdentityM(transformMatrix, 0)
        // 回転
        rotate++
        if (rotate == 360f) {
            rotate = 0f
        }
        Matrix.rotateM(transformMatrix, 0, rotate, 0f, 0f, 1f)
        // WebGPU 側を正方形にする、SurfaceView は画面いっぱいなので縦長のママだが、見切れる前提で正方形にする。これで三角形が歪まなくなる
        if (width < height) {
            val scale = (height / width.toFloat())
            Matrix.scaleM(transformMatrix, 0, scale, 1f, 1f)
        } else {
            val scale = (width / height.toFloat())
            Matrix.scaleM(transformMatrix, 0, 1f, scale, 1f)
        }
        // 小さくする
        Matrix.scaleM(transformMatrix, 0, .5f, .5f, .5f)
        // GPU に転送
        gpu.device.queue.writeBuffer(transformMatrixUniformBuffer, 0, transformMatrix.toByteBuffer())


        // 1. Get the next available texture from the screen
        val surfaceTexture = gpu.webgpuSurface.getCurrentTexture()

        // 2. Create a command encoder
        val commandEncoder = gpu.device.createCommandEncoder()

        // 3. Begin a render pass (clearing the screen to blue)
        val renderPass = commandEncoder.beginRenderPass(
            GPURenderPassDescriptor(
                colorAttachments = arrayOf(
                    GPURenderPassColorAttachment(
                        GPUColor(0.0, 0.0, 0.5, 1.0),
                        surfaceTexture.texture.createView(),
                        loadOp = LoadOp.Clear,
                        storeOp = StoreOp.Store,
                    )
                )
            )
        )

        // 4. Draw
        renderPass.setPipeline(renderPipeline)
        renderPass.setBindGroup(0, bindGroup) // @group(0) なので 0
        renderPass.draw(3) // 三角形の頂点の数が3個
        renderPass.end()

        // 5. Submit and Present
        gpu.device.queue.submit(arrayOf(commandEncoder.finish()))
        gpu.webgpuSurface.present()
    }

    fun cleanup() {
        if (::webGpu.isInitialized) {
            webGpu.close()
        }
    }

    private fun FloatArray.toByteBuffer(): ByteBuffer {
        val bufferSize = this.size * Float.SIZE_BYTES
        val byteBuffer = ByteBuffer.allocateDirect(bufferSize)
            .order(ByteOrder.nativeOrder())
            .also { byteBuffer -> byteBuffer.asFloatBuffer().put(this).rewind() }
        return byteBuffer
    }

    private fun initPipeline(device: GPUDevice) {
        val shaderCode = """
    // Uniforms 構造体
    struct Uniforms {
        matrix: mat4x4<f32>,
    }
        
    // CPU から受け取る
    @group(0) @binding(0) var<uniform> transformMatrix: Uniforms;
        
    @vertex fn vs_main(@builtin(vertex_index) vertexIndex : u32) -> @builtin(position) vec4f {
        const pos = array(vec2f(0.0, 0.5), vec2f(-0.5, -0.5), vec2f(0.5, -0.5));
        let transformedVec = vec4f(pos[vertexIndex], 0, 1) * transformMatrix.matrix; // 変換行列を適用
        return transformedVec;
    }
    
    @fragment fn fs_main() -> @location(0) vec4f {
        return vec4f(1, 0, 0, 1);
    }
"""

        // Create Shader Module
        val shaderModule = device.createShaderModule(
            GPUShaderModuleDescriptor(shaderSourceWGSL = GPUShaderSourceWGSL(shaderCode))
        )

        // Create Render Pipeline
        renderPipeline = device.createRenderPipeline(
            descriptor = GPURenderPipelineDescriptor(
                vertex = GPUVertexState(
                    shaderModule,
                ),
                fragment = GPUFragmentState(
                    shaderModule,
                    targets = arrayOf(GPUColorTargetState(TextureFormat.RGBA8Unorm))
                ),
                primitive = GPUPrimitiveState(PrimitiveTopology.TriangleList)
            )
        )

        // CPU から値を渡す準備
        transformMatrixUniformBuffer = device.createBuffer(
            GPUBufferDescriptor(
                size = 64,
                usage = BufferUsage.Uniform or BufferUsage.CopyDst
            )
        )
        bindGroup = webGpu.device.createBindGroup(
            descriptor = GPUBindGroupDescriptor(
                layout = renderPipeline.getBindGroupLayout(0),
                entries = arrayOf(
                    GPUBindGroupEntry(binding = 0, buffer = transformMatrixUniformBuffer)
                )
            )
        )
    }
}

万華鏡をつくる

流れとしてはこれに、カメラを用意していい感じにGPUに転送して、三角形を増やして描画する感じになります。

万華鏡用に MainActivity を書き換える

さっきのWebGpuRendererとは別物のクラスを作る予定で、また、カメラの権限を許可してもらう必要があり、別のComposable関数を作ってMainActivityに設置することにします。
MangekyouWebGpuRendererはこれから作ります。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            AndroidWebGpuMangekyouTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    MangekyouGpuSurfaceView(modifier = Modifier.padding(innerPadding))
                }
            }
        }
    }
}

@Composable
fun MangekyouGpuSurfaceView(modifier: Modifier = Modifier) {
    val surfaceSize = remember { MutableStateFlow<IntSize?>(null) }
    val surfaceFlow = remember { MutableStateFlow<Surface?>(null) }

    val context = LocalContext.current
    val isPermissionGranted = remember { Channel<Boolean>() }
    val permissionRequester = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestPermission(),
        onResult = { isGranted ->
            isPermissionGranted.trySend(isGranted)
        }
    )

    LaunchedEffect(key1 = Unit) {
        // 権限を要求して待つ
        permissionRequester.launch(android.Manifest.permission.CAMERA)
        if (!isPermissionGranted.receive()) {
            println("権限が付与されませんでした...")
        }

        // サイズと surface が得られること、得られない場合は return している
        combine(
            surfaceSize,
            surfaceFlow,
            ::Pair
        ).collectLatest { (size, surface) ->
            if (size != null && surface != null) {
                val renderer = MangekyouWebGpuRenderer(context)
                try {
                    renderer.init(surface, size.width, size.height)
                    // 繰り返し呼ぶ
                    while (true) {
                        delay(16.milliseconds)
                        renderer.render()
                    }
                } finally {
                    // surface が再生成された、破棄されたとき
                    renderer.cleanup()
                }
            }
        }
    }

    AndroidView(
        modifier = modifier.onSizeChanged { surfaceSize.value = it },
        factory = { context ->
            SurfaceView(context).apply {
                holder.addCallback(object : SurfaceHolder.Callback {
                    override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
                        // do nothing
                    }

                    override fun surfaceCreated(holder: SurfaceHolder) {
                        surfaceFlow.value = holder.surface
                    }

                    override fun surfaceDestroyed(holder: SurfaceHolder) {
                        surfaceFlow.value = null
                    }
                })
            }
        }
    )
}

描画するクラスを作る

さっきまで作っていたクラスを基礎にするので、とりあえず新しいクラスを作りますが、中身は同じ。

class MangekyouWebGpuRenderer {
    private lateinit var webGpu: WebGpu
    private lateinit var renderPipeline: GPURenderPipeline

    // 変換行列を渡す
    private lateinit var transformMatrixUniformBuffer: GPUBuffer
    private lateinit var bindGroup: GPUBindGroup

    private var width = 0
    private var height = 0

    suspend fun init(surface: Surface, width: Int, height: Int) {
        this.width = width
        this.height = height

        // 1. Create Instance & Device
        webGpu = createWebGpu(surface)
        val device = webGpu.device

        // 2. Setup Pipeline (compile shaders)
        initPipeline(device)

        // 3. Configure the Surface
        webGpu.webgpuSurface.configure(
            GPUSurfaceConfiguration(
                device,
                width,
                height,
                TextureFormat.RGBA8Unorm,
            )
        )
    }
    
    fun render() {
        if (!::webGpu.isInitialized) {
            return
        }

        val gpu = webGpu


        // 変換行列を用意
        // 三角形が歪まないようにする
        val transformMatrix = FloatArray(16)
        Matrix.setIdentityM(transformMatrix, 0)
        // WebGPU 側を正方形にする、SurfaceView は画面いっぱいなので縦長のママだが、見切れる前提で正方形にする。これで三角形が歪まなくなる
        if (width < height) {
            val scale = (height / width.toFloat())
            Matrix.scaleM(transformMatrix, 0, scale, 1f, 1f)
        } else {
            val scale = (width / height.toFloat())
            Matrix.scaleM(transformMatrix, 0, 1f, scale, 1f)
        }
        // 小さくする
        Matrix.scaleM(transformMatrix, 0, .5f, .5f, .5f)
        // GPU に転送
        gpu.device.queue.writeBuffer(transformMatrixUniformBuffer, 0, transformMatrix.toByteBuffer())


        // 1. Get the next available texture from the screen
        val surfaceTexture = gpu.webgpuSurface.getCurrentTexture()

        // 2. Create a command encoder
        val commandEncoder = gpu.device.createCommandEncoder()

        // 3. Begin a render pass (clearing the screen to blue)
        val renderPass = commandEncoder.beginRenderPass(
            GPURenderPassDescriptor(
                colorAttachments = arrayOf(
                    GPURenderPassColorAttachment(
                        GPUColor(0.0, 0.0, 0.5, 1.0),
                        surfaceTexture.texture.createView(),
                        loadOp = LoadOp.Clear,
                        storeOp = StoreOp.Store,
                    )
                )
            )
        )

        // 4. Draw
        renderPass.setPipeline(renderPipeline)
        renderPass.setBindGroup(0, bindGroup) // @group(0) なので 0
        renderPass.draw(3) // 三角形の頂点の数が3個
        renderPass.end()

        // 5. Submit and Present
        gpu.device.queue.submit(arrayOf(commandEncoder.finish()))
        gpu.webgpuSurface.present()
    }

    fun cleanup() {
        if (::webGpu.isInitialized) {
            webGpu.close()
        }
    }

    private fun FloatArray.toByteBuffer(): ByteBuffer {
        val bufferSize = this.size * Float.SIZE_BYTES
        val byteBuffer = ByteBuffer.allocateDirect(bufferSize)
            .order(ByteOrder.nativeOrder())
            .also { byteBuffer -> byteBuffer.asFloatBuffer().put(this).rewind() }
        return byteBuffer
    }

    private fun initPipeline(device: GPUDevice) {
        val shaderCode = """
    // Uniforms 構造体
    struct Uniforms {
        matrix: mat4x4<f32>,
    }
        
    // CPU から受け取る
    @group(0) @binding(0) var<uniform> transformMatrix: Uniforms;
        
    @vertex fn vs_main(@builtin(vertex_index) vertexIndex : u32) -> @builtin(position) vec4f {
        const pos = array(vec2f(0.0, 0.5), vec2f(-0.5, -0.5), vec2f(0.5, -0.5));
        let transformedVec = vec4f(pos[vertexIndex], 0, 1) * transformMatrix.matrix; // 変換行列を適用
        return transformedVec;
    }
    
    @fragment fn fs_main() -> @location(0) vec4f {
        return vec4f(1, 0, 0, 1);
    }
"""

        // Create Shader Module
        val shaderModule = device.createShaderModule(
            GPUShaderModuleDescriptor(shaderSourceWGSL = GPUShaderSourceWGSL(shaderCode))
        )

        // Create Render Pipeline
        renderPipeline = device.createRenderPipeline(
            descriptor = GPURenderPipelineDescriptor(
                vertex = GPUVertexState(
                    shaderModule,
                ),
                fragment = GPUFragmentState(
                    shaderModule,
                    targets = arrayOf(GPUColorTargetState(TextureFormat.RGBA8Unorm))
                ),
                primitive = GPUPrimitiveState(PrimitiveTopology.TriangleList)
            )
        )

        // CPU から値を渡す準備
        transformMatrixUniformBuffer = device.createBuffer(
            GPUBufferDescriptor(
                size = 64,
                usage = BufferUsage.Uniform or BufferUsage.CopyDst
            )
        )
        bindGroup = webGpu.device.createBindGroup(
            descriptor = GPUBindGroupDescriptor(
                layout = renderPipeline.getBindGroupLayout(0),
                entries = arrayOf(
                    GPUBindGroupEntry(binding = 0, buffer = transformMatrixUniformBuffer)
                )
            )
        )
    }
}

カメラの準備

AndroidManifest.xmlにカメラ権限を追加します。

<uses-permission android:name="android.permission.CAMERA" />

つぎに、MangekyouWebGpuRendererCamera2用の関数とかを用意します。中身は後で!
また、Camera2APIのためにContextが必要だったのでコンストラクタ引数でとることにします。width と heightもこうやって取ればよかった、、

あとcleanup()に破棄する処理を足しておきました、あと映像サイズをcompanionに置いておきます、CAMERA_WIDTH

class MangekyouWebGpuRenderer(private val context: Context) {

    // 省略...

    // カメラの映像を得る
    private val latestImageChannel = Channel<Image>(capacity = Channel.CONFLATED, onUndeliveredElement = { it.close() })
    private var imageReader: ImageReader? = null
    private var cameraDevice: CameraDevice? = null
    private val cameraExecutor = Executors.newSingleThreadExecutor()

    // 省略...

    fun cleanup() {
        if (::webGpu.isInitialized) {
            webGpu.close()
        }
        cameraDevice?.close()
        imageReader?.close()
    }

    // 省略...

    private suspend fun initCamera2Api() {
        // TODO これから
    }

    companion object {
        private const val CAMERA_WIDTH = 1280
        private const val CAMERA_HEIGHT = 720
    }
}

今回はCamera2APIをそのまま使います、CameraXは使ったことが無くて、、
Camera2APIのお作法的なコードが続きます。カメラ映像はKotlin CoroutinesChannelを使ってWebGPUの描画処理であるrender()関数へ渡そうと思うので、ここでは受け取ってtrySend()しています。

ImageReaderですが、今回はYUV_420_888を使っています。
Camera2APIを使ったことがあればこれは難しい選択をしたと思うでしょう。だってYUVからBitmap、つまりRGBの配列にするのはけっこー面倒くさい。

JPEGの方が楽です。何も考えずにImageReaderから出てきたバイト配列がすでにJPEGなので、BitmapにしてWebGPUのテクスチャにすれば、フラグメントシェーダーで利用可能なのですから。
なのですが、JPEGはとにかく遅いです。全然速度が出ません。なので難しいYUVの方を使っています。こちらは難しい代わりに結構速度が出ています。

まあそもそもの話、OpenGL ESではSurfaceTextureクラスを使うことで、カメラ映像動画の映像OpenGL ESのテクスチャとして使うことが出来ます。
VulkanにもHardwareBufferクラスがあり、ImageReaderから出てきたImageHardwareBufferをそのまま、Vulkanのテクスチャとして使えるそうです。
AndroidWebGPU移植版にはこれに相当するクラスが存在しないっぽいです。なので速度が出るYUVをいい感じにRGBに変換して使うことにします。

@SuppressLint("MissingPermission") // 権限チェックするべきです
private suspend fun initCamera2Api() {
    // 出力先の ImageReader、カメラ映像は Channel で別の関数へおくる
    imageReader = ImageReader.newInstance(CAMERA_WIDTH, CAMERA_HEIGHT, ImageFormat.YUV_420_888, 2)
    imageReader?.setOnImageAvailableListener(
        { imageReader ->
            latestImageChannel.trySend(element = imageReader?.acquireLatestImage() ?: return@setOnImageAvailableListener)
        },
        null
    )

    // Camera2API
    val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
    val (frontCameraId, _) = cameraManager
        .cameraIdList
        .map { cameraId -> cameraId to cameraManager.getCameraCharacteristics(cameraId) }
        .firstOrNull { (_, characteristic) -> characteristic.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_BACK } ?: return

    cameraDevice = suspendCancellableCoroutine { cont ->
        cameraManager.openCamera(frontCameraId, object : CameraDevice.StateCallback() {
            override fun onOpened(device: CameraDevice) {
                cont.resume(device)
            }

            override fun onDisconnected(device: CameraDevice) {
                cont.resume(null)
            }

            override fun onError(device: CameraDevice, error: Int) {
                cont.resume(null)
            }
        }, null)
    }

    cameraDevice ?: return
    val captureRequest = cameraDevice!!.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW).apply {
        addTarget(imageReader!!.surface)
    }
    val captureSession = suspendCancellableCoroutine { continuation ->
        // OutputConfiguration を作る
        val outputConfigurationList = listOf(OutputConfiguration(imageReader!!.surface))
        val sessionConfiguration = SessionConfiguration(SessionConfiguration.SESSION_REGULAR, outputConfigurationList, cameraExecutor, object : CameraCaptureSession.StateCallback() {
            override fun onConfigured(captureSession: CameraCaptureSession) {
                continuation.resume(captureSession)
            }

            override fun onConfigureFailed(p0: CameraCaptureSession) {
                continuation.resume(null)
            }
        })
        cameraDevice!!.createCaptureSession(sessionConfiguration)
    }

    captureSession ?: return
    captureSession.setRepeatingRequest(captureRequest.build(), null, null)
}

万華鏡の頂点の配列

さっきの入門ではバーテックスシェーダー内に三角形の頂点の座標を書いていましたが、本当はCPU側から送ってあげるのが良いはずです。
先述の通り、三角形が組み合わさってできているので、いい感じに三角形を並べていけばよさそうですね!

とりあえず六個の三角形を描画しようと思います。全然万華鏡って感じはしないと思いますが。とにかく動くところまで!

companion object {
    private const val CAMERA_WIDTH = 1280
    private const val CAMERA_HEIGHT = 720

    private val VERTEX_ARRAY = floatArrayOf(
        // 下 真ん中
        0.0f, 0.0f,
        -0.5f, -1.0f,
        0.5f, -1.0f,

        // 下 右
        0.0f, 0.0f,
        0.5f, -1.0f,
        1.0f, 0.0f,

        // 下 左
        0.0f, 0.0f,
        -1.0f, 0.0f,
        -0.5f, -1.0f,

        // 上 真ん中
        0.0f, 0.0f,
        -0.5f, 1.0f,
        0.5f, 1.0f,

        // 上 右
        0.0f, 0.0f,
        0.5f, 1.0f,
        1.0f, 0.0f,

        // 上 左
        0.0f, 0.0f,
        -0.5f, 1.0f,
        -1.0f, 0.0f,
    )
}

頂点をGPUへ渡す

vertexBufferlateinit varに追加します。

private lateinit var webGpu: WebGpu
private lateinit var renderPipeline: GPURenderPipeline
private lateinit var vertexBuffer: GPUBuffer // これ

次にinitPipeline()関数で、createRenderPipeline()を呼び出している箇所より前で、頂点バッファーを作ります。
作ってもうすぐにwriteBufferで送ってしまいましょう。

// 頂点
vertexBuffer = device.createBuffer(
    descriptor = GPUBufferDescriptor(
        size = (VERTEX_ARRAY.size * Float.SIZE_BYTES).toLong(),
        usage = BufferUsage.Vertex or BufferUsage.CopyDst
    )
)
device.queue.writeBuffer(vertexBuffer, 0, VERTEX_ARRAY.toByteBuffer())

// Create Render Pipeline
// 以下省略...

次にすぐ下のcreateRenderPipeline()を書き足します。GPUVertexState()buffersの部分ですね。
arrayStrideですが、後述しますが構造体が登場するのですが、今回はこの構造体にX,Yという感じでvec2fしか使ってないんですね。つーわけでFloatが二つ。
つぎのshaderLocationも構造体のところで出るのですが、今回はこの構造体に先述のx,yしか入れないためlocation0になります。

renderPipeline = device.createRenderPipeline(
    descriptor = GPURenderPipelineDescriptor(
        vertex = GPUVertexState(
            shaderModule,
            // 頂点を
            buffers = arrayOf(
                GPUVertexBufferLayout(
                    arrayStride = (2 * Float.SIZE_BYTES).toLong(), // 頂点(x,y)しか渡してないため 2 * FloatSize
                    attributes = arrayOf(
                        // 今回は vertex の構造体に vec2f で足りる x,y しか入れていないため
                        GPUVertexAttribute(
                            shaderLocation = 0,
                            offset = 0,
                            format = VertexFormat.Float32x2
                        )
                    )
                )
            )
        ),
        fragment = GPUFragmentState(
            shaderModule,
            targets = arrayOf(GPUColorTargetState(TextureFormat.RGBA8Unorm))
        ),
        primitive = GPUPrimitiveState(PrimitiveTopology.TriangleList),
    )
)

最後、render()でもsetVertexBuffer()を呼ぶ必要と、draw()の頂点の数がずれているので直しましょう!

// 4. Draw
renderPass.setPipeline(renderPipeline)
renderPass.setVertexBuffer(0, vertexBuffer)
renderPass.setBindGroup(0, bindGroup) // @group(0) なので 0
renderPass.draw(VERTEX_ARRAY.size / 2) // 三角形の頂点の数、x,y,x,y... の並びをした Float 配列なので、サイズを取って半分にすればよい
renderPass.end()

シェーダーを直す

全部貼ります、どーん

頂点を渡すためにstruct Vertex { }構造体を作りました。もしかしたら引数に直接@location(0) ..と書いてもよかったかもしれません。
shaderLocationで指定した通り0番目にx,yが入ってくるようになります。

vs_mainではこの構造体を受け取るようにします。すると、draw()の呼び出し回数を数えてくれていた@builtin(vertex_index) vertexIndex : u32が消えます。
その代わりにKotlinで書いてたVERTEX_ARRAYから対応する回数の座標のx,yを直接貰えるようになります。

        val shaderCode = """
    // Uniforms 構造体
    struct Uniforms {
        matrix: mat4x4<f32>,
    }
    
    // CPU から頂点を受け取る
    struct Vertex {
        @location(0) position: vec2f,
    }
        
    // CPU から受け取る
    @group(0) @binding(0) var<uniform> transformMatrix: Uniforms;
        
    @vertex fn vs_main(vertex: Vertex) -> @builtin(position) vec4f {
        let transformedVec = vec4f(vertex.position, 0, 1) * transformMatrix.matrix; // 変換行列を適用
        return transformedVec;
    }
    
    @fragment fn fs_main() -> @location(0) vec4f {
        return vec4f(1, 0, 0, 1);
    }
"""

六角形が映っている!

できたぜ!次はカメラですね

webgpu_万華鏡_図形のみ

カメラ映像の話

YUV の話

Camera2APIでもらった映像データはImageReaderによってYUVとか言うのになります。
これはRGBではありません。プレーンというのはまあその色が入っているバイト配列です。

YプレーンUプレーンVプレーンで色が分かれていて、それぞれ画像としても表示できます。
Yプレーンが雑に言うとモノクロ画像で、それに色を付けるのがUプレーンYプレーンになります。

ただ、これに加えてクロマサンプリングと呼ばれる技術を使い人間にバレないように情報を減らす技術が使われています。
詳しくはこの後話しますが、Yプレーンのモノクロ画像の大きさと、UプレーンVプレーンの画像の大きさは異なることがあります(データ量を減らすが画質は維持できてる)。というか今回は異なります。

なので、ざっくりYプレーンUプレーンVプレーンを使ってYUVからRGBに変換する式を適用すると、RGBになるハズです。
難しいように思えますが、フラグメントシェーダーを使って描画するとそこまで難しく無いです。
(というか、GPUだと座標が0~1に正規化さ、むしろCPUで座標を計算するよりも簡単かもしれません!!)
(また、ベクトル同士の掛け算がシェーダーだと文字通り出来るのも簡単にできる要因かもしれません)

YUV の 444 とか 420 とかの話

クロマサンプリングの話を、今回これがあるから避けて通れない。
YUVの中でも種類があり4:4:4とか4:2:0とかあります。

もしかしたらYプレーンと比べて、UプレーンVプレーンはバイト配列が半分(半分の画像サイズ)になっているという噂を聞いたかもしれません。

これは、Yプレーン(モノクロ画像)は1ピクセルごとに色を保存します。
一方、UプレーンVプレーンの色は1ピクセルごとに保存しなくても人間は鈍感だから分からないのでは???
2ピクセルの分を同じ色にすれば、Yプレーンの半分のデータ容量で済むし人間にはバレないというわけです。

例えば、横4ピクセル2ピクセルの例を出します。

YUV 444の場合は1ピクセルごとにY,U,V保存するのでこうなりますね。

 Y              U               V
+-------------+ +-------------+ +-------------+
| Y1 Y2 Y3 Y4 | | U1 U2 U3 U4 | | V1 V2 V3 V4 |
| Y5 Y6 Y7 Y8 | | U5 U6 U7 U8 | | V5 V6 V7 V8 |
+-------------+ +-------------+ +-------------+

YUV 422の場合はこうです。横2ピクセル分が同じ色になっています。

 Y              U               V
+-------------+ +-------------+ +-------------+
| Y1 Y2 Y3 Y4 | | U1 U1 U2 U2 | | V1 V1 V2 V2 |
| Y5 Y6 Y7 Y8 | | U3 U3 U4 U4 | | V3 V3 V4 V4 |
+-------------+ +-------------+ +-------------+

YUV 420の場合はこうです。横2ピクセルと縦2ピクセル分が同じ色になっています。

 Y              U               V
+-------------+ +-------------+ +-------------+
| Y1 Y2 Y3 Y4 | | U1 U1 U2 U2 | | V1 V1 V2 V2 |
| Y5 Y6 Y7 Y8 | | U1 U1 U2 U2 | | V1 V1 V2 V2 |
+-------------+ +-------------+ +-------------+

インターリーブの話

最後にこれ、Androidの場合はインターリーブされています。
ImageReaderクラスからYUVが得られるわけです。YUV 420なのでYプレーンと比べて、UプレーンVプレーン4分の1になります。

インターリーブ、今回の文脈での使い道の説明だと、Yプレーンと同じバイト配列のサイズを作って、UプレーンVプレーンのデータを交互に入れることを指しています。
UプレーンYプレーンを足すとYプレーンのサイズと同じになりますね!。
ハードウェア由来??の理由らしいですが、UプレーンVプレーンで別々のバイト配列を確保するよりも良いらしいです。

Yプレーン = [Y1,Y2,Y3,Y4 ...]
Uプレーン = [U1,V1,U2,V2 ...]
Vプレーン = [V1,U2,V2    ...]

インターリーブされているため、ImageReaderから出てきたYUVUプレーンVプレーンも、AndroidOS的には同じバイト配列の参照のはずです。
ただ、Uプレーンの場合は最初にUプレーンのデータから配列が始まるようになっています。同様にVプレーンも。

YUV だのクロマサンプリングだの意味わかんなすぎ

わかる。まだAndroidWebGPUは時期尚早。

OpenGL ESの場合はカメラ動画の映像をOpenGL ESのテクスチャとして使えるSurfaceTextureクラスが存在します。
もう本当にカメラ動画プレイヤーの出力先として使って、あとはOpenGL ESのテクスチャのuniformに設定して、、、、みたいな感じ。YUVとかは出てこない。
全部GPUがやってくれてOpenGL ESに映像を転送してくれる。

Vulkanにも同様の機能がある。

Androidに移植されたWebGPUにも同様の効率よく映像をやり取りする仕組みができるまで、手を出さなくてもいいかもね。

インターリーブされたデータを元に戻す

つまり我々の課題は二つ、YUVなのでRGBにするシェーダーを書くのと、前提としてインターリーブされているのでYUVに分離するための処理を書かないといけませんね。

まずはインターリーブされたプレーンの場合は元に戻す拡張関数を書きました。
Image.PlanepixelStride1以外の場合はインターリーブされており、pixelStrideindexをインクリメントすると次のデータが得られるという形になります。

private fun Image.Plane.fixIfInterleaveRePutPlane(yPlaneWidth: Int, yPlaneHeight: Int): ByteBuffer {
    // U/V プレーンは Y プレーンの半分のサイズ
    val uvPlaneWidth = yPlaneWidth / 2
    val uvPlaneHeight = yPlaneHeight / 2

    val result = ByteBuffer.allocateDirect(uvPlaneWidth * uvPlaneHeight)

    // pixelStride が
    // 1 の場合は、[ Y1,Y2,Y3,Y4 ] のように、バイト配列に連続してデータが入っている
    // 2 とかの場合、U/V プレーンを別々に取得しても、[ U1,V1,U2,V2 ] のように、一つのバイト配列に二種類のデータが交互に入っている(インターリーブ)
    // 後者の場合はそのままでは WebGPU/OpenGLES のテクスチャとして使うことができないため、それぞれ切り離す必要がある
    // ---
    // また、後者の場合、実際には U/V 両方のデータが入っているため U/V プレーンは Y プレーンの半分。ですらない。
    // それぞれのプレーンにすれば半分のサイズで収まります。
    if (pixelStride == 1) {
        buffer.put(result)
        return result
    }

    // 例として [ U1, V1, U2 V2 Padding, U3, V3, U4, V4 ] の場合
    // pixelStride が 2 になる。次の同じデータが 2 バイト先にあることを表している
    // 二種類のデータとは別に、Padding が含まれていて、Padding 込みの横一列は rowStride で取得可能。基本的に Padding で次の横一列になるハズ
    repeat(uvPlaneHeight) { y ->
        // この行の先頭位置を rowStride から計算
        val rowStartIdx = y * rowStride
        // 2つ先を読む(1byte 飛ばして 1byte 読む)
        for (x in 0 until uvPlaneWidth) {
            val readPosition = rowStartIdx + (x * pixelStride)
            // 一応
            if (readPosition < buffer.capacity()) {
                result.put(buffer[readPosition])
            }
        }
    }
    return result
}

GPU へ YUV を送る準備

コンピューターグラフィックスの世界ではGPUで描画する画像のことをテクスチャと呼ぶらしいです。
YUVもそれぞれのプレーンは画像として表すため、テクスチャとしてGPUへ送ります。

というわけでWebGPUにテクスチャを登録しましょう。クラスの変数にテクスチャとsamplerを宣言します。
samplerOpenGL ESだと省略できた?ので目新しいかも。

class MangekyouWebGpuRenderer(private val context: Context) {
    // 省略

    // WebGPU テクスチャ
    private lateinit var sampler: GPUSampler
    private lateinit var textureYuvY: GPUTexture
    private lateinit var textureYuvU: GPUTexture
    private lateinit var textureYuvV: GPUTexture

    // 省略

次にinitPipeline()bindGroupを作る前でテクスチャを作ります。
OpenGL ESの時のglなんとかかんとか()と比べると本当に分かりやすい!!!1!

ところで今回、BitmapのようなRGBのバイト配列を渡すわけではなく、YUVはそれぞれの画像を見ると単色しか使ってないんですよね。
なのでformatrだけを使うものにしました。rgbの画像であればrだけじゃもちろんダメです!

作れたらGPUBindGroupDescriptor()で登録しましょう。bindingの数字は後でシェーダーを書くときに使うので覚えておいてください!
(覚えなくてもカンニングしてよいのでいいです)

private fun initPipeline(device: GPUDevice) {
    // 省略...

    // テクスチャ。よく考えると YUV それぞれは単色である、なので RGB も確保せずとも R だけで足りる
    // U/V プレーンは半分
    val textureUvWidth = CAMERA_WIDTH / 2
    val textureUvHeight = CAMERA_HEIGHT / 2
    sampler = webGpu.device.createSampler()
    textureYuvY = webGpu.device.createTexture(
        descriptor = GPUTextureDescriptor(
            usage = TextureUsage.TextureBinding or TextureUsage.CopyDst,
            size = GPUExtent3D(CAMERA_WIDTH, CAMERA_HEIGHT),
            format = TextureFormat.R8Unorm
        )
    )
    textureYuvU = webGpu.device.createTexture(
        descriptor = GPUTextureDescriptor(
            usage = TextureUsage.TextureBinding or TextureUsage.CopyDst,
            size = GPUExtent3D(textureUvWidth, textureUvHeight),
            format = TextureFormat.R8Unorm
        )
    )
    textureYuvV = webGpu.device.createTexture(
        descriptor = GPUTextureDescriptor(
            usage = TextureUsage.TextureBinding or TextureUsage.CopyDst,
            size = GPUExtent3D(textureUvWidth, textureUvHeight),
            format = TextureFormat.R8Unorm
        )
    )

    bindGroup = webGpu.device.createBindGroup(
        descriptor = GPUBindGroupDescriptor(
            layout = renderPipeline.getBindGroupLayout(0),
            entries = arrayOf(
                // 変換行列
                GPUBindGroupEntry(binding = 0, buffer = transformMatrixUniformBuffer),
                // テクスチャ
                GPUBindGroupEntry(binding = 1, sampler = sampler),
                GPUBindGroupEntry(binding = 2, textureView = textureYuvY.createView()),
                GPUBindGroupEntry(binding = 3, textureView = textureYuvU.createView()),
                GPUBindGroupEntry(binding = 4, textureView = textureYuvV.createView())
            )
        )
    )
}

フラグメントシェーダーで YUV を RGB にする

まずはWikiにあるYUVRGBにする公式を見てください。分かんないですよね!
なんかよく分からないデカい波かっこがある、が、先述の通りよく分からくてもこの手のフラグメントシェーダーは息を吸うのと同じようにベクトル同士の掛け算ができるので問題ないです。

この中で使うのはBT.709です(Rec.709)。HDRではないカメラ映像の場合は十中八九BT.709です(SDRですね)。
BT.709というのは色空間と呼ばれ、使える色の範囲の事です。HDR動画だとBT.2020が使われより鮮やかで眩しいですが、これはBT.709よりも使える色が多いからですね。

色空間とかガンマカーブとかはAndroid で HDR 動画を扱う記事で触れたのでそっちで・・・

Android で HDR 動画の撮影、動画編集アプリを作る - たくさんの自由帳

https://takusan.negitoro.dev/posts/android_hdr_camera_video_editor/

シェーダーでやることは、テクスチャを受け取る、テクスチャを描画できるようにバーテックスシェーダーからテクスチャ座標?を返す、YUVからRGBの変換式をフラグメントシェーダーで書く。

まず簡単なのは@group(0) @binding(2) var textureYuvY: texture_2d<f32>;ですかね。
YUVで三つ分のテクスチャと(bindingの番号気を付けて!)、samplerを受け取ります。

次にバーテックスシェーダーから構造体を返せるようにします。
@builtin(position) position: vec4fに加えて、テクスチャ座標に変換したもの返します。
WebGPUは縦横が-1 から 1の範囲に正規化されると言いましたが、これとは別にテクスチャ座標というのがあって、これは0 から 1になります。

複数の値を返すためにstruct VertexOutput { }構造体を作りました。vs_main()の返り値を-> VertexOutputに直して、returnで構造体を返すようにします。
また、fs_main()の引数をoutput: vertexOutputにしてバーテックスシェーダーの結果を受け取れるようにします。

最後のRGB変換は、Wikiの通りです。
mat3x3はよくわかりません。このよく分からない数字の塊とvec3f(Y,U,V)のベクトルを掛け算するとRGBになるらしいです。

    private fun initPipeline(device: GPUDevice) {
        val shaderCode = """
    // Uniforms 構造体
    struct Uniforms {
        matrix: mat4x4<f32>,
    }
    
    // CPU から頂点を受け取る
    struct Vertex {
        @location(0) position: vec2f,
    }
    
    // フラグメントシェーダーへ渡す
    struct VertexOutput {
        @builtin(position) position: vec4f,
        @location(0) uv : vec2f,
    }
        
    // CPU から受け取る
    @group(0) @binding(0) var<uniform> transformMatrix: Uniforms;
    @group(0) @binding(1) var smp: sampler;
    @group(0) @binding(2) var textureYuvY: texture_2d<f32>;
    @group(0) @binding(3) var textureYuvU: texture_2d<f32>;
    @group(0) @binding(4) var textureYuvV: texture_2d<f32>;
        
    @vertex fn vs_main(vertex: Vertex) -> VertexOutput {
        let transformedVec = vec4f(vertex.position, 0, 1) * transformMatrix.matrix; // 変換行列を適用
        var output: VertexOutput;
        output.position = transformedVec;
        output.uv = transformedVec.xy *  0.5 + 0.5; // テクスチャ座標に変換する
        return output;
    }
    
    @fragment fn fs_main(output: VertexOutput) -> @location(0) vec4f {

        // 位置に対応する YUV とりだし
        // 以下、R, G, B, Yの値域は[0, 1]。Cb, Crの値域は[-0.5, 0.5]
        let y = (textureSample(textureYuvY, smp, output.uv).r * (255.0 / 235.0) - (16.0 / 235.0));
        let u = (textureSample(textureYuvU, smp, output.uv).r - 0.5);
        let v = (textureSample(textureYuvV, smp, output.uv).r - 0.5);
    
        // YUV から BT.709 RGB の変換式は Wiki
        // https://ja.wikipedia.org/wiki/YUV#RGBからの変換
        let yuvToRgbBt709 = mat3x3<f32>(
            vec3f(1.0, 1.0, 1.0),
            vec3f(0.0, -0.187324, 1.8556),
            vec3f(1.5748, -0.468124, 0.0)
        );
    
        let rgb = yuvToRgbBt709 * vec3f(y, u, v);
        return vec4<f32>(rgb, 1.0);
    }
"""

    // 以下省略...

Wiki じゃない方法

違う方法

Wikiに書いてある数字の塊はR, G, B, Yの値域は[0, 1]。Cb, Crの値域は[-0.5, 0.5]の前提のコードです。
一方、先にYUV テクスチャの色を0~1にせずに調整した後にRGBの変換式に入れる方法もあります。16.0 / 255.0のところとかですね。

@fragment fn fs_main(output: VertexOutput) -> @location(0) vec4f {

    let yuvToRgbBt709 = mat3x3<f32>(
        vec3<f32>(1.164, 1.164, 1.164),
        vec3<f32>(0.0, -0.213, 2.112),
        vec3<f32>(1.793, -0.533, 0.0)
    );

    let y = (textureSample(textureYuvY, smp, output.uv).r - (16.0 / 255.0));
    let u = (textureSample(textureYuvU, smp, output.uv).r - (128.0 / 255.0));
    let v = (textureSample(textureYuvV, smp, output.uv).r - (128.0 / 255.0));

    let rgb = yuvToRgbBt709 * vec3<f32>(y, u, v);
    return vec4<f32>(rgb, 1.0);
}

画面に描画する処理

最後にImageReaderから貰うYUVGPUに転送し、あとはWebGPUで描画します。
Kotlin CoroutinesImageReaderのコールバックで貰えるImageChannelを使って、この関数で受け取ります。

YUVはさっき作った拡張関数でインターリーブを戻して、GPUに渡します!。OpenGL ESの時はべらぼうに引数が多かった気がするのでめっちゃ人間よりだな~って。

render()関数を書き換えます!

suspend fun render() {
    if (!::webGpu.isInitialized) {
        return
    }

    val gpu = webGpu


    // YUV を受け取って GPU に転送する処理
    val image = latestImageChannel.receive()
    val yuvY = image.planes[0].buffer
    val yuvU = image.planes[1].fixIfInterleaveRePutPlane(CAMERA_WIDTH, CAMERA_HEIGHT)
    val yuvV = image.planes[2].fixIfInterleaveRePutPlane(CAMERA_WIDTH, CAMERA_HEIGHT)
    image.close()
    // YUV を GPU に送信
    val yuvUvWidth = CAMERA_WIDTH / 2
    val yuvUvHeight = CAMERA_HEIGHT / 2
    webGpu.device.queue.writeTexture(
        destination = GPUTexelCopyTextureInfo(textureYuvY),
        data = yuvY,
        writeSize = GPUExtent3D(CAMERA_WIDTH, CAMERA_HEIGHT),
        dataLayout = GPUTexelCopyBufferLayout(bytesPerRow = CAMERA_WIDTH) // 1ピクセル1バイトしか使ってない (TextureFormat.R8Unorm)
    )
    webGpu.device.queue.writeTexture(
        destination = GPUTexelCopyTextureInfo(textureYuvU),
        data = yuvU,
        writeSize = GPUExtent3D(yuvUvWidth, yuvUvHeight),
        dataLayout = GPUTexelCopyBufferLayout(bytesPerRow = yuvUvWidth)
    )
    webGpu.device.queue.writeTexture(
        destination = GPUTexelCopyTextureInfo(textureYuvV),
        data = yuvV,
        writeSize = GPUExtent3D(yuvUvWidth, yuvUvHeight),
        dataLayout = GPUTexelCopyBufferLayout(bytesPerRow = yuvUvWidth)
    )


    // 変換行列を用意
    // 三角形が歪まないようにする
    val transformMatrix = FloatArray(16)

    // 以下省略...

カメラ映像が表示されたけど違う!!!

でもなんか違う!!!全然万華鏡じゃない、しかもなんか回転している。

webgpu_万華鏡_六角形にカメラ映像が描画される

テクスチャ座標を操作する変換行列

三角形はこのままでよいでしょう。問題はそれぞれの三角形で描画している画像が問題です。

それぞれの三角形で描画されていない問題と、それぞれの三角形で描画する際に回転されていてほしい問題カメラ映像がなんか回転、反転している問題

カメラが回転、反転している

そもそもなんで回転されているのかはわかりません。Camera2 APIのせい????

反転しているのは多分WebGPUテクスチャ座標の仕様です。テクスチャ座標は-1から1ではなく!0から1になります。
そして反転しているためWebGPUテクスチャ座標は上方向に0に近づく仕様です。左上がX=0,Y=0になり、右下がX=1,Y=1になります。

webgpu_テクスチャ座標は上下反転

話を戻して、テクスチャ座標に適用する変換行列を用意すればよい気がします!
Unfirom構造体にテクスチャ用の変換行列を追加して受け取れるようにします。

まずはUniforms構造体にtextureMatrix: mat4x4<f32>,を追加しました。
また、output.uvに入れる前にlet uv = transformedVec * transformMatrix.textureMatrix;で計算をし、テクスチャ用の変換行列を適用します。
三角形の位置はそのまま、テクスチャ(フラグメントシェーダー)にのみ回転とか反転が適用されます!

    private fun initPipeline(device: GPUDevice) {
        val shaderCode = """
    // Uniforms 構造体
    struct Uniforms {
        matrix: mat4x4<f32>,
        textureMatrix: mat4x4<f32>,
    }
    
    // CPU から頂点を受け取る
    struct Vertex {
        @location(0) position: vec2f,
    }
    
    // フラグメントシェーダーへ渡す
    struct VertexOutput {
        @builtin(position) position: vec4f,
        @location(0) uv : vec2f,
    }
        
    // CPU から受け取る
    @group(0) @binding(0) var<uniform> transformMatrix: Uniforms;
    @group(0) @binding(1) var smp: sampler;
    @group(0) @binding(2) var textureYuvY: texture_2d<f32>;
    @group(0) @binding(3) var textureYuvU: texture_2d<f32>;
    @group(0) @binding(4) var textureYuvV: texture_2d<f32>;
        
    @vertex fn vs_main(vertex: Vertex) -> VertexOutput {
        let transformedVec = vec4f(vertex.position, 0, 1) * transformMatrix.matrix; // 変換行列を適用
        let uv = transformedVec * transformMatrix.textureMatrix;
        var output: VertexOutput;
        output.position = transformedVec;
        output.uv = uv.xy * 0.5 + 0.5; // テクスチャ座標に変換する
        return output;
    }
    
    @fragment fn fs_main(output: VertexOutput) -> @location(0) vec4f {

        // 位置に対応する YUV とりだし
        // 以下、Yの値域は[0, 1]。Cb, Crの値域は[-0.5, 0.5]
        let y = (textureSample(textureYuvY, smp, output.uv).r * (255.0 / 235.0) - (16.0 / 235.0));
        let u = (textureSample(textureYuvU, smp, output.uv).r - 0.5);
        let v = (textureSample(textureYuvV, smp, output.uv).r - 0.5);
    
        // YUV から BT.709 RGB の変換式は Wiki
        // https://ja.wikipedia.org/wiki/YUV#RGBからの変換
        let yuvToRgbBt709 = mat3x3<f32>(
            vec3f(1.0, 1.0, 1.0),
            vec3f(0.0, -0.187324, 1.8556),
            vec3f(1.5748, -0.468124, 0.0)
        );
    
        let rgb = yuvToRgbBt709 * vec3f(y, u, v);
        return vec4<f32>(rgb, 1.0);
    }
"""

次にUniformバッファーを作っているコードまで移動して、構造体の中身が増えたためサイズを修正します。
今回はもう一つmat4x4<f32>が追加されたので64を2倍すればよいですね!

// CPU から値を渡す準備
transformMatrixUniformBuffer = device.createBuffer(
    GPUBufferDescriptor(
        size = 64 * 2, // mat4x4<f32> が2つあるので
        usage = BufferUsage.Uniform or BufferUsage.CopyDst
    )
)

最後にrender()関数の変換行列を作っている箇所まで移動し、新たにテクスチャ座標に適用する変換行列を作り、writeBuffer()します。
structに2つmat4x4があり、これをどうやってCPUから渡せばいいのかと言うと単にバイト配列を連結するだけです。
Kotlinには演算子オーバーロードがありFloatArray + FloatArrayが出来るので、つなげてJavaByteBufferにするだけ!楽!

// 変換行列を用意
// 三角形が歪まないようにする
val transformMatrix = FloatArray(16)
Matrix.setIdentityM(transformMatrix, 0)
// WebGPU 側を正方形にする、SurfaceView は画面いっぱいなので縦長のママだが、見切れる前提で正方形にする。これで三角形が歪まなくなる
if (width < height) {
    val scale = (height / width.toFloat())
    Matrix.scaleM(transformMatrix, 0, scale, 1f, 1f)
} else {
    val scale = (width / height.toFloat())
    Matrix.scaleM(transformMatrix, 0, 1f, scale, 1f)
}
// 小さくする
Matrix.scaleM(transformMatrix, 0, .5f, .5f, .5f)
// テクスチャに適用する変換行列
// カメラが回転している + 反転もしている(多分既に回転されてる状態なので Y 軸に対して反転をする必要が?)
val textureTransformMatrix = FloatArray(16)
Matrix.setIdentityM(textureTransformMatrix, 0)
Matrix.setRotateM(textureTransformMatrix, 0, 270f, 0f, 0f, 1f)
Matrix.scaleM(textureTransformMatrix, 0, 1f, -1f, 1f)
// GPU に転送
// mat4x4<f32> が2つある構造体なので足す
val structFloatArray = transformMatrix + textureTransformMatrix
gpu.device.queue.writeBuffer(transformMatrixUniformBuffer, 0, structFloatArray.toByteBuffer())

これでカメラの回転は修正されたと思います!

webgpu_六角形の形に描画されたカメラが正しい向きに描画される

各三角形の中にそれぞれ回転したカメラ映像を流したい

そもそも一体なぜこうなっているかというと、三角形の頂点をテクスチャ座標としても使っているから。
以下のように、一つ目の三角形は、左端X座標 -0.5fから右端 X座標 0.5fなのに対して、二つ目の三角形は左端 X座標 0.5fから右端 X座標 1.0になっている。
三角形の位置としては並べたいので正しいが、三角形にすべて同じテクスチャを描画する場合はX座標がズレてるといけないですね。

// 下 真ん中
0.0f, 0.0f,
-0.5f, -1.0f,
0.5f, -1.0f,

// 下 右
0.0f, 0.0f,
0.5f, -1.0f,
1.0f, 0.0f,

いやマジで説明が難しいな。
画像で説明する、極端に横に二つ並べるとこう。今の段階では頂点の位置をテクスチャ座標として利用しようとしている。
このせいで、それぞれの三角形で同じ画像が出ず、切り抜いたというか、、なんだろう、水玉コラみたいな(???)。

webgpu_三角形の頂点とは別にテクスチャ座標を渡す

なので、多分一般的なのは、三角形の頂点とは別にテクスチャ座標を別に渡すのが良いと思います。
というわけで渡します。見ての通り三角形の頂点は三角形を並べるのでそのまま、テクスチャ座標はそれぞれの三角形が同じ画像になるように同じ値を渡しています。先述の通りそれぞれの三角形で同じテクスチャを描画するためです

private val VERTEX_ARRAY = floatArrayOf(
    // それぞれ X座標、Y座標、テクスチャX座標、 テクスチャY座標

    // 下 真ん中
    0.0f, 0.0f, 0.5f, 1.0f,
    -0.5f, -1.0f, 0f, 0f,
    0.5f, -1.0f, 1.0f, 0f,

    // 下 右
    0.0f, 0.0f, 0.5f, 1.0f,
    0.5f, -1.0f, 0f, 0f,
    1.0f, 0.0f, 1.0f, 0f,

    // 下 左
    0.0f, 0.0f, 0.5f, 1.0f,
    -1.0f, 0.0f, 0f, 0f,
    -0.5f, -1.0f, 1.0f, 0f,

    // 上 真ん中
    0.0f, 0.0f, 0.5f, 1.0f,
    -0.5f, 1.0f, 0f, 0f,
    0.5f, 1.0f, 1.0f, 0f,

    // 上 右
    0.0f, 0.0f, 0.5f, 1.0f,
    0.5f, 1.0f, 0f, 0f,
    1.0f, 0.0f, 1.0f, 0f,

    // 上 左
    0.0f, 0.0f, 0.5f, 1.0f,
    -0.5f, 1.0f, 0f, 0f,
    -1.0f, 0.0f, 1.0f, 0f,
)

次に、device.createRenderPipeline()のコードまで移動して、バーテックスシェーダーの引数でテクスチャ座標の値を受け取れるようにします。型としてはvec2fになりますね。x,yなので!
VERTEX_ARRAYx,yの後の2つがテクスチャ座標のX/Yなので、そうするように指示します。

arrayStrideは、X,Yvec2f2 * Float.SIZE_BYTES消費しているので、vec2fが増えたらそれに加えて二倍すればよいですね。
GPUVertexAttributeテクスチャ座標 vec2fを新設します。offsetは頂点のvec2fのバイトサイズを飛ばしたらテクスチャ座標vec2fが得られますのでそうします。

// Create Render Pipeline
renderPipeline = device.createRenderPipeline(
    descriptor = GPURenderPipelineDescriptor(
        vertex = GPUVertexState(
            shaderModule,
            // 頂点を
            buffers = arrayOf(
                GPUVertexBufferLayout(
                    arrayStride = Float.SIZE_BYTES * 4L, // 頂点(x,y) とテクスチャ座標(x,y) で 2 * 2 * FloatSize
                    attributes = arrayOf(
                        // 三角形の頂点
                        GPUVertexAttribute(
                            shaderLocation = 0,
                            offset = 0,
                            format = VertexFormat.Float32x2
                        ),
                        // テクスチャ座標
                        GPUVertexAttribute(
                            shaderLocation = 1,
                            offset = Float.SIZE_BYTES * 2L,
                            format = VertexFormat.Float32x2
                        )
                    )
                )
            )
        ),
        fragment = GPUFragmentState(
            shaderModule,
            targets = arrayOf(GPUColorTargetState(TextureFormat.RGBA8Unorm))
        ),
        primitive = GPUPrimitiveState(PrimitiveTopology.TriangleList),
    )
)

つぎにシェーダーを修正します。
バーテックスシェーダーから三角形の頂点に加えてテクスチャ座標を受け取るようにします。

テクスチャ座標を受け取って、先述のテクスチャ座標に適用する変換行列を掛け算します。この時vec2fmat4x4で直接掛け算できないため、vec2fvec4fにしています。
vec4fにした都合上なんか戻さないといけなくなってしまったので* 0.5 + 0.5は引き続き・・・

    private fun initPipeline(device: GPUDevice) {
        val shaderCode = """
    // Uniforms 構造体
    struct Uniforms {
        matrix: mat4x4<f32>,
        textureMatrix: mat4x4<f32>,
    }
    
    // CPU から頂点を受け取る
    struct Vertex {
        @location(0) position: vec2f,
        @location(1) uv: vec2f,
    }
    
    // フラグメントシェーダーへ渡す
    struct VertexOutput {
        @builtin(position) position: vec4f,
        @location(0) uv : vec2f,
    }
        
    // CPU から受け取る
    @group(0) @binding(0) var<uniform> transformMatrix: Uniforms;
    @group(0) @binding(1) var smp: sampler;
    @group(0) @binding(2) var textureYuvY: texture_2d<f32>;
    @group(0) @binding(3) var textureYuvU: texture_2d<f32>;
    @group(0) @binding(4) var textureYuvV: texture_2d<f32>;
        
    @vertex fn vs_main(vertex: Vertex) -> VertexOutput {
        let transformedVec = vec4f(vertex.position, 0, 1) * transformMatrix.matrix; // 変換行列を適用
        let uvVec4 = vec4f(vertex.uv, 0, 1) * transformMatrix.textureMatrix; // mat4x4 の変換行列を適用するために vec4f() にする
        var output: VertexOutput;
        output.position = transformedVec;
        output.uv = uvVec4.xy * 0.5 + 0.5; // vec4f にしてしまったのでテクスチャ座標にもどす
        return output;
    }
    
    @fragment fn fs_main(output: VertexOutput) -> @location(0) vec4f {

        // 位置に対応する YUV とりだし
        // 以下、Yの値域は[0, 1]。Cb, Crの値域は[-0.5, 0.5]
        let y = (textureSample(textureYuvY, smp, output.uv).r * (255.0 / 235.0) - (16.0 / 235.0));
        let u = (textureSample(textureYuvU, smp, output.uv).r - 0.5);
        let v = (textureSample(textureYuvV, smp, output.uv).r - 0.5);
    
        // YUV から BT.709 RGB の変換式は Wiki
        // https://ja.wikipedia.org/wiki/YUV#RGBからの変換
        let yuvToRgbBt709 = mat3x3<f32>(
            vec3f(1.0, 1.0, 1.0),
            vec3f(0.0, -0.187324, 1.8556),
            vec3f(1.5748, -0.468124, 0.0)
        );
    
        let rgb = yuvToRgbBt709 * vec3f(y, u, v);
        return vec4<f32>(rgb, 1.0);
    }
"""

最後にdraw()の部分、2で割ることで三角形の頂点の数を出していましたが、頂点の数だけx,y,テクスチャx,テクスチャyのデータがあるため、4で割る必要があります。

renderPass.setPipeline(renderPipeline)
renderPass.setVertexBuffer(0, vertexBuffer)
renderPass.setBindGroup(0, bindGroup) // @group(0) なので 0
renderPass.draw(VERTEX_ARRAY.size / 4) // 頂点の数だけ x,y,テクスチャx,テクスチャy のデータがあるため割り算
renderPass.end()

ついに!できた!!!!!!!!!!
同じテクスチャが描画されていますね!

webgpu_万華鏡が出来た、各三角形に映像が描画されている

完成

ちょっと面白いですねこれ(手前味噌)

WebGPUで万華鏡完成

WebGPU、マジでいいな。エラーが遥かに分かりやすい。

全部書いたコード

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            AndroidWebGpuMangekyouTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    MangekyouGpuSurfaceView(modifier = Modifier.padding(innerPadding))
                }
            }
        }
    }
}

@Composable
fun MangekyouGpuSurfaceView(modifier: Modifier = Modifier) {
    val surfaceSize = remember { MutableStateFlow<IntSize?>(null) }
    val surfaceFlow = remember { MutableStateFlow<Surface?>(null) }

    val context = LocalContext.current
    val isPermissionGranted = remember { Channel<Boolean>() }
    val permissionRequester = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestPermission(),
        onResult = { isGranted ->
            isPermissionGranted.trySend(isGranted)
        }
    )

    LaunchedEffect(key1 = Unit) {
        // 権限を要求して待つ
        permissionRequester.launch(android.Manifest.permission.CAMERA)
        if (!isPermissionGranted.receive()) {
            println("権限が付与されませんでした...")
        }

        // サイズと surface が得られること、得られない場合は return している
        combine(
            surfaceSize,
            surfaceFlow,
            ::Pair
        ).collectLatest { (size, surface) ->
            if (size != null && surface != null) {
                val renderer = MangekyouWebGpuRenderer(context)
                try {
                    renderer.init(surface, size.width, size.height)
                    // 繰り返し呼ぶ
                    while (true) {
                        delay(16.milliseconds)
                        renderer.render()
                    }
                } finally {
                    // surface が再生成された、破棄されたとき
                    renderer.cleanup()
                }
            }
        }
    }

    AndroidView(
        modifier = modifier.onSizeChanged { surfaceSize.value = it },
        factory = { context ->
            SurfaceView(context).apply {
                holder.addCallback(object : SurfaceHolder.Callback {
                    override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
                        // do nothing
                    }

                    override fun surfaceCreated(holder: SurfaceHolder) {
                        surfaceFlow.value = holder.surface
                    }

                    override fun surfaceDestroyed(holder: SurfaceHolder) {
                        surfaceFlow.value = null
                    }
                })
            }
        }
    )
}
class MangekyouWebGpuRenderer(private val context: Context) {
    private lateinit var webGpu: WebGpu
    private lateinit var renderPipeline: GPURenderPipeline
    private lateinit var vertexBuffer: GPUBuffer

    // 変換行列を渡す
    private lateinit var transformMatrixUniformBuffer: GPUBuffer
    private lateinit var bindGroup: GPUBindGroup

    private var width = 0
    private var height = 0

    // カメラの映像を得る
    private val latestImageChannel = Channel<Image>(capacity = Channel.CONFLATED, onUndeliveredElement = { it.close() })
    private var imageReader: ImageReader? = null
    private var cameraDevice: CameraDevice? = null
    private val cameraExecutor = Executors.newSingleThreadExecutor()

    // WebGPU テクスチャ
    private lateinit var sampler: GPUSampler
    private lateinit var textureYuvY: GPUTexture
    private lateinit var textureYuvU: GPUTexture
    private lateinit var textureYuvV: GPUTexture

    suspend fun init(surface: Surface, width: Int, height: Int) {
        this.width = width
        this.height = height

        // 1. Create Instance & Device
        webGpu = createWebGpu(surface)
        val device = webGpu.device

        // 2. Setup Pipeline (compile shaders)
        initPipeline(device)

        // 3. Configure the Surface
        webGpu.webgpuSurface.configure(
            GPUSurfaceConfiguration(
                device,
                width,
                height,
                TextureFormat.RGBA8Unorm,
            )
        )

        // camera2 api init
        initCamera2Api()
    }

    suspend fun render() {
        if (!::webGpu.isInitialized) {
            return
        }

        val gpu = webGpu


        // YUV を受け取って GPU に転送する処理
        val image = latestImageChannel.receive()
        val yuvY = image.planes[0].buffer
        val yuvU = image.planes[1].fixIfInterleaveRePutPlane(CAMERA_WIDTH, CAMERA_HEIGHT)
        val yuvV = image.planes[2].fixIfInterleaveRePutPlane(CAMERA_WIDTH, CAMERA_HEIGHT)
        image.close()
        // YUV を GPU に送信
        val yuvUvWidth = CAMERA_WIDTH / 2
        val yuvUvHeight = CAMERA_HEIGHT / 2
        webGpu.device.queue.writeTexture(
            destination = GPUTexelCopyTextureInfo(textureYuvY),
            data = yuvY,
            writeSize = GPUExtent3D(CAMERA_WIDTH, CAMERA_HEIGHT),
            dataLayout = GPUTexelCopyBufferLayout(bytesPerRow = CAMERA_WIDTH) // 1ピクセル1バイトしか使ってない (TextureFormat.R8Unorm)
        )
        webGpu.device.queue.writeTexture(
            destination = GPUTexelCopyTextureInfo(textureYuvU),
            data = yuvU,
            writeSize = GPUExtent3D(yuvUvWidth, yuvUvHeight),
            dataLayout = GPUTexelCopyBufferLayout(bytesPerRow = yuvUvWidth)
        )
        webGpu.device.queue.writeTexture(
            destination = GPUTexelCopyTextureInfo(textureYuvV),
            data = yuvV,
            writeSize = GPUExtent3D(yuvUvWidth, yuvUvHeight),
            dataLayout = GPUTexelCopyBufferLayout(bytesPerRow = yuvUvWidth)
        )


        // 変換行列を用意
        // 三角形が歪まないようにする
        val transformMatrix = FloatArray(16)
        Matrix.setIdentityM(transformMatrix, 0)
        // WebGPU 側を正方形にする、SurfaceView は画面いっぱいなので縦長のママだが、見切れる前提で正方形にする。これで三角形が歪まなくなる
        if (width < height) {
            val scale = (height / width.toFloat())
            Matrix.scaleM(transformMatrix, 0, scale, 1f, 1f)
        } else {
            val scale = (width / height.toFloat())
            Matrix.scaleM(transformMatrix, 0, 1f, scale, 1f)
        }
        // 小さくする
        Matrix.scaleM(transformMatrix, 0, .5f, .5f, .5f)
        // テクスチャに適用する変換行列
        // カメラが回転している + 反転もしている(多分既に回転されてる状態なので Y 軸に対して反転をする必要が?)
        val textureTransformMatrix = FloatArray(16)
        Matrix.setIdentityM(textureTransformMatrix, 0)
        Matrix.setRotateM(textureTransformMatrix, 0, 270f, 0f, 0f, 1f)
        Matrix.scaleM(textureTransformMatrix, 0, 1f, -1f, 1f)
        // GPU に転送
        // mat4x4<f32> が2つある構造体なので足す
        val structFloatArray = transformMatrix + textureTransformMatrix
        gpu.device.queue.writeBuffer(transformMatrixUniformBuffer, 0, structFloatArray.toByteBuffer())


        // 1. Get the next available texture from the screen
        val surfaceTexture = gpu.webgpuSurface.getCurrentTexture()

        // 2. Create a command encoder
        val commandEncoder = gpu.device.createCommandEncoder()

        // 3. Begin a render pass (clearing the screen to blue)
        val renderPass = commandEncoder.beginRenderPass(
            GPURenderPassDescriptor(
                colorAttachments = arrayOf(
                    GPURenderPassColorAttachment(
                        GPUColor(0.0, 0.0, 0.5, 1.0),
                        surfaceTexture.texture.createView(),
                        loadOp = LoadOp.Clear,
                        storeOp = StoreOp.Store,
                    )
                )
            )
        )

        // 4. Draw
        renderPass.setPipeline(renderPipeline)
        renderPass.setVertexBuffer(0, vertexBuffer)
        renderPass.setBindGroup(0, bindGroup) // @group(0) なので 0
        renderPass.draw(VERTEX_ARRAY.size / 4) // 頂点の数だけ x,y,テクスチャx,テクスチャy のデータがあるため割り算
        renderPass.end()

        // 5. Submit and Present
        gpu.device.queue.submit(arrayOf(commandEncoder.finish()))
        gpu.webgpuSurface.present()
    }

    fun cleanup() {
        if (::webGpu.isInitialized) {
            webGpu.close()
        }
        cameraDevice?.close()
        imageReader?.close()
    }

    private fun FloatArray.toByteBuffer(): ByteBuffer {
        val bufferSize = this.size * Float.SIZE_BYTES
        val byteBuffer = ByteBuffer.allocateDirect(bufferSize)
            .order(ByteOrder.nativeOrder())
            .also { byteBuffer -> byteBuffer.asFloatBuffer().put(this).rewind() }
        return byteBuffer
    }

    private fun initPipeline(device: GPUDevice) {
        val shaderCode = """
    // Uniforms 構造体
    struct Uniforms {
        matrix: mat4x4<f32>,
        textureMatrix: mat4x4<f32>,
    }
    
    // CPU から頂点を受け取る
    struct Vertex {
        @location(0) position: vec2f,
        @location(1) uv: vec2f,
    }
    
    // フラグメントシェーダーへ渡す
    struct VertexOutput {
        @builtin(position) position: vec4f,
        @location(0) uv : vec2f,
    }
        
    // CPU から受け取る
    @group(0) @binding(0) var<uniform> transformMatrix: Uniforms;
    @group(0) @binding(1) var smp: sampler;
    @group(0) @binding(2) var textureYuvY: texture_2d<f32>;
    @group(0) @binding(3) var textureYuvU: texture_2d<f32>;
    @group(0) @binding(4) var textureYuvV: texture_2d<f32>;
        
    @vertex fn vs_main(vertex: Vertex) -> VertexOutput {
        let transformedVec = vec4f(vertex.position, 0, 1) * transformMatrix.matrix; // 変換行列を適用
        let uvVec4 = vec4f(vertex.uv, 0, 1) * transformMatrix.textureMatrix; // mat4x4 の変換行列を適用するために vec4f() にする
        var output: VertexOutput;
        output.position = transformedVec;
        output.uv = uvVec4.xy * 0.5 + 0.5; // vec4f にしてしまったのでテクスチャ座標にもどす
        return output;
    }
    
    @fragment fn fs_main(output: VertexOutput) -> @location(0) vec4f {

        // 位置に対応する YUV とりだし
        // 以下、Yの値域は[0, 1]。Cb, Crの値域は[-0.5, 0.5]
        let y = (textureSample(textureYuvY, smp, output.uv).r * (255.0 / 235.0) - (16.0 / 235.0));
        let u = (textureSample(textureYuvU, smp, output.uv).r - 0.5);
        let v = (textureSample(textureYuvV, smp, output.uv).r - 0.5);
    
        // YUV から BT.709 RGB の変換式は Wiki
        // https://ja.wikipedia.org/wiki/YUV#RGBからの変換
        let yuvToRgbBt709 = mat3x3<f32>(
            vec3f(1.0, 1.0, 1.0),
            vec3f(0.0, -0.187324, 1.8556),
            vec3f(1.5748, -0.468124, 0.0)
        );
    
        let rgb = yuvToRgbBt709 * vec3f(y, u, v);
        return vec4<f32>(rgb, 1.0);
    }
"""

        // Create Shader Module
        val shaderModule = device.createShaderModule(
            GPUShaderModuleDescriptor(shaderSourceWGSL = GPUShaderSourceWGSL(shaderCode))
        )

        // 頂点
        vertexBuffer = device.createBuffer(
            descriptor = GPUBufferDescriptor(
                size = (VERTEX_ARRAY.size * Float.SIZE_BYTES).toLong(),
                usage = BufferUsage.Vertex or BufferUsage.CopyDst
            )
        )
        device.queue.writeBuffer(vertexBuffer, 0, VERTEX_ARRAY.toByteBuffer())

        // Create Render Pipeline
        renderPipeline = device.createRenderPipeline(
            descriptor = GPURenderPipelineDescriptor(
                vertex = GPUVertexState(
                    shaderModule,
                    // 頂点を
                    buffers = arrayOf(
                        GPUVertexBufferLayout(
                            arrayStride = Float.SIZE_BYTES * 4L, // 頂点(x,y) とテクスチャ座標(x,y) で 2 * 2 * FloatSize
                            attributes = arrayOf(
                                // 三角形の頂点
                                GPUVertexAttribute(
                                    shaderLocation = 0,
                                    offset = 0,
                                    format = VertexFormat.Float32x2
                                ),
                                // テクスチャ座標
                                GPUVertexAttribute(
                                    shaderLocation = 1,
                                    offset = Float.SIZE_BYTES * 2L,
                                    format = VertexFormat.Float32x2
                                )
                            )
                        )
                    )
                ),
                fragment = GPUFragmentState(
                    shaderModule,
                    targets = arrayOf(GPUColorTargetState(TextureFormat.RGBA8Unorm))
                ),
                primitive = GPUPrimitiveState(PrimitiveTopology.TriangleList),
            )
        )

        // CPU から値を渡す準備
        transformMatrixUniformBuffer = device.createBuffer(
            GPUBufferDescriptor(
                size = 64 * 2, // mat4x4<f32> が2つあるので
                usage = BufferUsage.Uniform or BufferUsage.CopyDst
            )
        )

        // テクスチャ。よく考えると YUV それぞれは単色である、なので RGB も確保せずとも R だけで足りる
        // U/V プレーンは半分
        val textureUvWidth = CAMERA_WIDTH / 2
        val textureUvHeight = CAMERA_HEIGHT / 2
        sampler = webGpu.device.createSampler()
        textureYuvY = webGpu.device.createTexture(
            descriptor = GPUTextureDescriptor(
                usage = TextureUsage.TextureBinding or TextureUsage.CopyDst,
                size = GPUExtent3D(CAMERA_WIDTH, CAMERA_HEIGHT),
                format = TextureFormat.R8Unorm
            )
        )
        textureYuvU = webGpu.device.createTexture(
            descriptor = GPUTextureDescriptor(
                usage = TextureUsage.TextureBinding or TextureUsage.CopyDst,
                size = GPUExtent3D(textureUvWidth, textureUvHeight),
                format = TextureFormat.R8Unorm
            )
        )
        textureYuvV = webGpu.device.createTexture(
            descriptor = GPUTextureDescriptor(
                usage = TextureUsage.TextureBinding or TextureUsage.CopyDst,
                size = GPUExtent3D(textureUvWidth, textureUvHeight),
                format = TextureFormat.R8Unorm
            )
        )

        bindGroup = webGpu.device.createBindGroup(
            descriptor = GPUBindGroupDescriptor(
                layout = renderPipeline.getBindGroupLayout(0),
                entries = arrayOf(
                    // 変換行列
                    GPUBindGroupEntry(binding = 0, buffer = transformMatrixUniformBuffer),
                    // テクスチャ
                    GPUBindGroupEntry(binding = 1, sampler = sampler),
                    GPUBindGroupEntry(binding = 2, textureView = textureYuvY.createView()),
                    GPUBindGroupEntry(binding = 3, textureView = textureYuvU.createView()),
                    GPUBindGroupEntry(binding = 4, textureView = textureYuvV.createView()),
                )
            )
        )
    }

    @SuppressLint("MissingPermission") // 権限チェックするべきです
    private suspend fun initCamera2Api() {
        // 出力先の ImageReader、カメラ映像は Channel で別の関数へおくる
        imageReader = ImageReader.newInstance(CAMERA_WIDTH, CAMERA_HEIGHT, ImageFormat.YUV_420_888, 2)
        imageReader?.setOnImageAvailableListener(
            { imageReader ->
                latestImageChannel.trySend(element = imageReader?.acquireLatestImage() ?: return@setOnImageAvailableListener)
            },
            null
        )

        // Camera2API
        val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
        val (frontCameraId, _) = cameraManager
            .cameraIdList
            .map { cameraId -> cameraId to cameraManager.getCameraCharacteristics(cameraId) }
            .firstOrNull { (_, characteristic) -> characteristic.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_BACK } ?: return

        cameraDevice = suspendCancellableCoroutine { cont ->
            cameraManager.openCamera(frontCameraId, object : CameraDevice.StateCallback() {
                override fun onOpened(device: CameraDevice) {
                    cont.resume(device)
                }

                override fun onDisconnected(device: CameraDevice) {
                    cont.resume(null)
                }

                override fun onError(device: CameraDevice, error: Int) {
                    cont.resume(null)
                }
            }, null)
        }

        cameraDevice ?: return
        val captureRequest = cameraDevice!!.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW).apply {
            addTarget(imageReader!!.surface)
        }
        val captureSession = suspendCancellableCoroutine { continuation ->
            // OutputConfiguration を作る
            val outputConfigurationList = listOf(OutputConfiguration(imageReader!!.surface))
            val sessionConfiguration = SessionConfiguration(SessionConfiguration.SESSION_REGULAR, outputConfigurationList, cameraExecutor, object : CameraCaptureSession.StateCallback() {
                override fun onConfigured(captureSession: CameraCaptureSession) {
                    continuation.resume(captureSession)
                }

                override fun onConfigureFailed(p0: CameraCaptureSession) {
                    continuation.resume(null)
                }
            })
            cameraDevice!!.createCaptureSession(sessionConfiguration)
        }

        captureSession ?: return
        captureSession.setRepeatingRequest(captureRequest.build(), null, null)
    }

    private fun Image.Plane.fixIfInterleaveRePutPlane(yPlaneWidth: Int, yPlaneHeight: Int): ByteBuffer {
        // U/V プレーンは Y プレーンの半分のサイズ
        val uvPlaneWidth = yPlaneWidth / 2
        val uvPlaneHeight = yPlaneHeight / 2

        val result = ByteBuffer.allocateDirect(uvPlaneWidth * uvPlaneHeight)

        // pixelStride が
        // 1 の場合は、[ Y1,Y2,Y3,Y4 ] のように、バイト配列に連続してデータが入っている
        // 2 とかの場合、U/V プレーンを別々に取得しても、[ U1,V1,U2,V2 ] のように、一つのバイト配列に二種類のデータが交互に入っている(インターリーブ)
        // 後者の場合はそのままでは WebGPU/OpenGLES のテクスチャとして使うことができないため、それぞれ切り離す必要がある
        // ---
        // また、後者の場合、実際には U/V 両方のデータが入っているため U/V プレーンは Y プレーンの半分。ですらない。
        // それぞれのプレーンにすれば半分のサイズで収まります。
        if (pixelStride == 1) {
            buffer.put(result)
            return result
        }

        // 例として [ U1, V1, U2 V2 Padding, U3, V3, U4, V4 ] の場合
        // pixelStride が 2 になる。次の同じデータが 2 バイト先にあることを表している
        // 二種類のデータとは別に、Padding が含まれていて、Padding 込みの横一列は rowStride で取得可能。基本的に Padding で次の横一列になるハズ
        repeat(uvPlaneHeight) { y ->
            // この行の先頭位置を rowStride から計算
            val rowStartIdx = y * rowStride
            // 2つ先を読む(1byte 飛ばして 1byte 読む)
            for (x in 0 until uvPlaneWidth) {
                val readPosition = rowStartIdx + (x * pixelStride)
                // 一応
                if (readPosition < buffer.capacity()) {
                    result.put(buffer[readPosition])
                }
            }
        }
        return result
    }


    companion object {
        private const val CAMERA_WIDTH = 1280
        private const val CAMERA_HEIGHT = 720

        private val VERTEX_ARRAY = floatArrayOf(
            // それぞれ X座標、Y座標、テクスチャX座標、 テクスチャY座標

            // 下 真ん中
            0.0f, 0.0f, 0.5f, 1.0f,
            -0.5f, -1.0f, 0f, 0f,
            0.5f, -1.0f, 1.0f, 0f,

            // 下 右
            0.0f, 0.0f, 0.5f, 1.0f,
            0.5f, -1.0f, 0f, 0f,
            1.0f, 0.0f, 1.0f, 0f,

            // 下 左
            0.0f, 0.0f, 0.5f, 1.0f,
            -1.0f, 0.0f, 0f, 0f,
            -0.5f, -1.0f, 1.0f, 0f,

            // 上 真ん中
            0.0f, 0.0f, 0.5f, 1.0f,
            -0.5f, 1.0f, 0f, 0f,
            0.5f, 1.0f, 1.0f, 0f,

            // 上 右
            0.0f, 0.0f, 0.5f, 1.0f,
            0.5f, 1.0f, 0f, 0f,
            1.0f, 0.0f, 1.0f, 0f,

            // 上 左
            0.0f, 0.0f, 0.5f, 1.0f,
            -0.5f, 1.0f, 0f, 0f,
            -1.0f, 0.0f, 1.0f, 0f,
        )
    }
}

番外編

本当に Pixel10 シリーズは Vulkan 経由なら一世代前に追いつけるのですか

文字にして書くとやっぱおかしいよな、なんでPixel10 シリーズよりもPixel 9の方がGPU 性能よかったんだ。。。
WebGPUVulkanを叩いてくれるので!まあ実質Vulkanと言っていいのでは!?

Camera2APIからYUVのインターリーブされているデータをそれぞれのバイト配列に戻す処理とかも含まれていて、これはCPU性能によるので、GPUだけを計測するにはそもそも正確ではないことに注意する必要があります。
とりあえず繰り返し呼び出すrender()関数が何ミリ秒かかっているか計測します。

また、Jetpack Composeはリリースビルドで最高速度を出すので、デバッグ中ではあんまり参考にならないかも
(が、GPUに関してはリリースもデバッグも変わらないハズ。。。)

@Composable
fun MangekyouGpuSurfaceView(modifier: Modifier = Modifier) {
    val surfaceSize = remember { MutableStateFlow<IntSize?>(null) }
    val surfaceFlow = remember { MutableStateFlow<Surface?>(null) }

    val context = LocalContext.current
    val isPermissionGranted = remember { Channel<Boolean>() }
    val permissionRequester = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestPermission(),
        onResult = { isGranted ->
            isPermissionGranted.trySend(isGranted)
        }
    )

    val fps = remember { mutableStateOf("0 FPS") }

    LaunchedEffect(key1 = Unit) {
        // 権限を要求して待つ
        permissionRequester.launch(android.Manifest.permission.CAMERA)
        if (!isPermissionGranted.receive()) {
            println("権限が付与されませんでした...")
        }

        // サイズと surface が得られること、得られない場合は return している
        combine(
            surfaceSize,
            surfaceFlow,
            ::Pair
        ).collectLatest { (size, surface) ->
            if (size != null && surface != null) {
                val renderer = MangekyouWebGpuRenderer(context)
                try {
                    renderer.init(surface, size.width, size.height)
                    // 繰り返し呼ぶ
                    while (true) {
                        val time = measureTimeMillis {
                            delay(16.milliseconds)
                            renderer.render()
                        }
                        fps.value = ("${1000 / time} FPS")
                    }
                } finally {
                    // surface が再生成された、破棄されたとき
                    renderer.cleanup()
                }
            }
        }
    }

    Box {
        AndroidView(
            modifier = modifier.onSizeChanged { surfaceSize.value = it },
            factory = { context ->
                SurfaceView(context).apply {
                    holder.addCallback(object : SurfaceHolder.Callback {
                        override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
                            // do nothing
                        }

                        override fun surfaceCreated(holder: SurfaceHolder) {
                            surfaceFlow.value = holder.surface
                        }

                        override fun surfaceDestroyed(holder: SurfaceHolder) {
                            surfaceFlow.value = null
                        }
                    })
                }
            }
        )

        Text(
            modifier = Modifier.statusBarsPadding(),
            text = fps.value,
            fontSize = 30.sp,
            color = Color.Red
        )
    }
}

APK上の方に置いてあるので試してみたい方はどうぞ!

xperia1viiとpixel8proとpixel10profold

Xperia 1 VII (Adreno)Pixel 8 Pro (Mali)Pixel 10 Pro Fold (PowerVR)

結果ですが、Pixel 8 Pro も Pixel 10 Pro Fold もあんまり変わらず、Pixel 10 が1、2くらい高いくらい。ほんのちょっと高いくらいで変わらないと思う。
OpenGL ESだとPixel 8 シリーズが速かったがVulkanだと健闘している?よく分からない。

端っこのXperiaはずっと1819を行き来してました、というかこの程度でたったこれしか出ないのは普通に私が悪くね?

まあそもそもカメラ映像を渡す処理とかが入ってるので、再掲しますが正確な計測ではないです!!!

三角形の数を増やす

6個の三角形しか描画してないのであんまりおもしろくないかも。
三角形の数というか頂点は先述の通りVERTEX_ARRAYで定義しているので、ここで三角形を増やしたい場合は頂点を書き足せばよいですね。

もうちょっと頭がこんがらかってるので、VERTEX_ARRAYの続きだけはAIに書かせました、さーせん。

private val VERTEX_ARRAY = floatArrayOf(
    // それぞれ X座標、Y座標、テクスチャX座標、 テクスチャY座標

    // 下 真ん中
    0.0f, 0.0f, 0.5f, 1.0f,
    -0.5f, -1.0f, 0f, 0f,
    0.5f, -1.0f, 1.0f, 0f,

    // 下 右
    0.0f, 0.0f, 0.5f, 1.0f,
    0.5f, -1.0f, 0f, 0f,
    1.0f, 0.0f, 1.0f, 0f,

    // 下 左
    0.0f, 0.0f, 0.5f, 1.0f,
    -1.0f, 0.0f, 0f, 0f,
    -0.5f, -1.0f, 1.0f, 0f,

    // 上 真ん中
    0.0f, 0.0f, 0.5f, 1.0f,
    -0.5f, 1.0f, 0f, 0f,
    0.5f, 1.0f, 1.0f, 0f,

    // 上 右
    0.0f, 0.0f, 0.5f, 1.0f,
    0.5f, 1.0f, 0f, 0f,
    1.0f, 0.0f, 1.0f, 0f,

    // 上 左
    0.0f, 0.0f, 0.5f, 1.0f,
    -0.5f, 1.0f, 0f, 0f,
    -1.0f, 0.0f, 1.0f, 0f,

    // --- ここから追加分 ---、すいません続きは AI に書かせました

    0.0f, -2.0f, 0.5f, 1.0f,
    0.5f, -1.0f, 0f, 0f,
    -0.5f, -1.0f, 1.0f, 0f,

    1.5f, -1.0f, 0.5f, 1.0f,
    1.0f, 0.0f, 0f, 0f,
    0.5f, -1.0f, 1.0f, 0f,

    -1.5f, -1.0f, 0.5f, 1.0f,
    -0.5f, -1.0f, 0f, 0f,
    -1.0f, 0.0f, 1.0f, 0f,

    0.0f, 2.0f, 0.5f, 1.0f,
    0.5f, 1.0f, 0f, 0f,
    -0.5f, 1.0f, 1.0f, 0f,

    1.5f, 1.0f, 0.5f, 1.0f,
    1.0f, 0.0f, 0f, 0f,
    0.5f, 1.0f, 1.0f, 0f,

    -1.5f, 1.0f, 0.5f, 1.0f,
    -1.0f, 0.0f, 0f, 0f,
    -0.5f, 1.0f, 1.0f, 0f,

    0.0f, -2.0f, 0.5f, 1.0f,
    0.5f, -1.0f, 0f, 0f,
    1.0f, -2.0f, 1.0f, 0f,

    1.5f, -1.0f, 0.5f, 1.0f,
    1.0f, 0.0f, 0f, 0f,
    2.0f, 0.0f, 1.0f, 0f,

    1.5f, 1.0f, 0.5f, 1.0f,
    1.0f, 0.0f, 0f, 0f,
    2.0f, 0.0f, 1.0f, 0f,

    1.5f, 1.0f, 0.5f, 1.0f,
    0.5f, 1.0f, 0f, 0f,
    1.0f, 2.0f, 1.0f, 0f,

    0.0f, 2.0f, 0.5f, 1.0f,
    -0.5f, 1.0f, 0f, 0f,
    -1.0f, 2.0f, 1.0f, 0f,

    -1.5f, 1.0f, 0.5f, 1.0f,
    -1.0f, 0.0f, 0f, 0f,
    -2.0f, 0.0f, 1.0f, 0f,

    -1.5f, -1.0f, 0.5f, 1.0f,
    -1.0f, 0.0f, 0f, 0f,
    -2.0f, 0.0f, 1.0f, 0f,

    0.0f, -2.0f, 0.5f, 1.0f,
    -0.5f, -1.0f, 0f, 0f,
    -1.0f, -2.0f, 1.0f, 0f,

    1.5f, -1.0f, 0.5f, 1.0f,
    1.0f, -2.0f, 0f, 0f,
    0.5f, -1.0f, 1.0f, 0f,

    -1.5f, -1.0f, 0.5f, 1.0f,
    -0.5f, -1.0f, 0f, 0f,
    -1.0f, -2.0f, 1.0f, 0f,

    -1.5f, 1.0f, 0.5f, 1.0f,
    -0.5f, 1.0f, 0f, 0f,
    -1.0f, 2.0f, 1.0f, 0f,

    0.0f, 2.0f, 0.5f, 1.0f,
    1.0f, 2.0f, 0f, 0f,
    0.5f, 1.0f, 1.0f, 0f,
)

ソースコード

どうぞ!

おわりに

夏休みの自由研究はこれで決まり!(難しいので他を選びましょう)

つーかもう折り返し地点じゃねえか

おわりに2

仕事のせいでゲーム積みまくってる、ヤバい!
ロープラも買ってる

おわりに3

AndroidWebフロントエンドからインスピレーションを受けてそうな技術

  • WebGPU
    • はやく正式版来てほしいです
  • Remote Compose
    • どこで使われてるのかすらわからないけどReact Server Componentsみたいなやつ
  • Jetpack Compose
    • これReact.jsだよマジで
    • スタイリングもTailwindCSSに近い
    • え?もっとWeb技術を転用したいって?
  • Jetpack Compose FlexBox
    • CSSにあるdisplay: flexJetpack Composeで使えるようにしたものらしい
    • わたしはCSS flexを雰囲気でつかってますがTailwindCSSでしか使ったことないので、CSS素そのままだったらつらいかもしれません
  • Jetpack Compose Grid
    • CSSにあるdisplay: gridらしい
    • 先述の通りCSSflexしか使ったことないので使い心地はわからない...
  • Jetpack Compose Strong skipping mode
    • React Compilerみたいなやつ、Androidはデフォルトになった

ReactにあるReactQuery (なんか名前変わってた気がする)みたいなのAndroidに来てほしいなあと一瞬思ったけど、複雑なライフサイクルを持つAndroidには合わなさそうな気がしてきたので思ってないです。

おわりに4

Camera2 API利用中(?)にスクショを取ると音が出る!!!
ちゃんと塞がれてんのくさ