WebGPUでシェーダーアートをやってみる(Vite、TypeScript)
はじめに
下記の素晴らしいGLSLを使ったシェーダーアートのチュートリアル動画の内容をWebGPUでやってみたのでその覚書です。WebGLは以前少しだけ齧ったことがありましたがWebGPUについては完全に知識ゼロなので入門のお題としてやってみた感じです。チュートリアル動画ではShadertoyを使っておりフラグメントシェーダーに記述する部分のみの解説なので、そもそもフラグメントシェーダーを使ってブラウザで描画させるまでの工程も含めてWebGPUで再現してみるという感じです。自分も完全に理解できているわけではないのでおかしい部分があるかもしれませんがご容赦ください。またWebGPUはまだWorking Draft(草案)段階なので今後いろいろと変更される可能性があることにも注意が必要です。
実際に作ったもの
リポジトリ
codesandbox
実装について
ざっくりとした全体の作りとしては四角形のポリゴンを作ってそれをキャンバスと同じサイズで配置し、そのポリゴンにフラグメントシェーダーを使って描画します。
開発環境
ViteでTypeScript環境を作ってます。
WebGPU関連の型定義がエラーになるので @webgpu/types を追加しています。
主要なファイル構成は以下のようになってます。
sample-wgsl-art-board
┣ shader
┃ ┣ fragment.wgsl
┃ ┗ vertex.wgsl
┣ geometry.ts
┣ getPipeline.ts
┣ index.html
┣ initialize.ts
┣ main.ts
┣ render.ts
┣ uniform.ts
┗ writeUniformBuffer.ts
html(index.html)
canvasタグを置いて、main.tsを読み込んでます。
ブラウザでキャンバスがウィンドウいっぱいに広がるようにcssで指定しています。
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + TS</title>
<style>
html,
body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
canvas {
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<canvas id="world"></canvas>
<script type="module" src="./main.ts"></script>
</body>
</html>
初期化処理(initialize.ts)
WebGPUでcanvasに描画するための最初の準備部分を書いています。
WebGPU API には一般モデルという機構があるのでそれらを利用して作っていきます。
まずはアダプターというのを取得します。これはマシンの物理GPUを表現するオブジェクトです。そしてアダプターから論理デバイスを取得します。GPUとのほとんどのやり取りはこの論理デバイスを通じで行います。アダプターとデバイスの説明は以下のページの説明がわかりやすかったです。
What's the difference between a GPUAdapter and GPUDevice in the WebGPU api? - Stack Overflow
canvasからWebGPU用のコンテキストを取得してコンテキストに対してWebGPUの構成を設定します。最後にブラウザのサイズに合わせてキャンバスをリサイズする処理を書いています。
export const initialize = async () => {
const GPU_ADAPTER = await navigator.gpu.requestAdapter()
const GPU_DEVICE = await GPU_ADAPTER!.requestDevice()
const canvas: HTMLCanvasElement | null = document.querySelector('#world')
const GPU_CANVAS_CONTEXT = canvas?.getContext('webgpu')
if (!GPU_ADAPTER) {
return Promise.reject(new Error('Could not find GPU adapter'))
}
if (!GPU_DEVICE) {
return Promise.reject(new Error('Could not find GPU device'))
}
if (!GPU_CANVAS_CONTEXT) {
return Promise.reject(new Error('Could not find GPU canvas context'))
}
/**
* コンテキストの設定
*/
const CANVAS_FORMAT = navigator.gpu.getPreferredCanvasFormat()
GPU_CANVAS_CONTEXT.configure({
device: GPU_DEVICE,
format: CANVAS_FORMAT,
alphaMode: 'opaque',
})
/**
* リサイズ処理
*/
const scale = window.devicePixelRatio
const reportWindowSize = () => {
GPU_CANVAS_CONTEXT.canvas.width = Math.floor(window.innerHeight * scale)
GPU_CANVAS_CONTEXT.canvas.height = Math.floor(window.innerWidth * scale)
}
window.onresize = reportWindowSize
window.dispatchEvent(new Event('resize'))
return {
GPU_ADAPTER,
GPU_DEVICE,
GPU_CANVAS_CONTEXT,
CANVAS_FORMAT,
}
}
頂点データ(geometry.ts)
正方形を描くための頂点に関するデータを定義しています。
今回は座標のみですが、頂点に関するいろいろなデータ(座標やカラー値)を一つのFloat32Array型の配列に入れておいてあとで頂点バッファに保存します。そしてパイプラインを作成する際にその配列内のどこからどこまでが何のデータなのかを知らせるために、メモリ割り当てにおける配列のサイズと、その中のアドレスのオフセット位置を指定する必要があります。そのための値を前もって定義しています。
/** 1つの値のメモリサイズ */
const perByteSize = Float32Array.BYTES_PER_ELEMENT // 4
/** squareVertexArray内の各頂点ごとのスキップサイズ */
export const squareVertexSize = perByteSize * 2
/** squareVertexArray内の各頂点データのオフセットの位置 */
export const squarePositionOffset = 0
/**
* 座標データ
*/
// prettier-ignore
export const squareVertexArray = new Float32Array([
// position[x, y]
-1, 1,
-1, -1,
1, -1,
1, 1,
]);
/**
* 頂点インデックス用のデータ
* 4つの頂点のインデックス順を指定
*/
export const squareIndexArray = new Uint16Array([0, 1, 2, 0, 2, 3])
スキップサイズとオフセット値について補足
Float32Arrayは32bitのデータの配列です。
配列の1つのデータが32bitで、メモリのアドレスは1byte(8bit)ごとに割り当てられます。
そのためメモリの番地のオフセットを考えるにはbyte単位で考える必要があります。
そうすると1つの値につき4byteのメモリサイズが必要になります。
例えば1つの頂点におけるデータで最初の4つが座標の値で、後半の4つの値がカラー値である場合を考えます。
1つの頂点データ(座標とカラー値)
[x, y, z, _, r, g, b, a]
↓
[32ibt, 32ibt, 32ibt, 32ibt, 32ibt, 32ibt, 32ibt, 32ibt]
↓
[4byte, 4byte, 4byte, 4byte, 4byte, 4byte, 4byte, 4byte]
一つの頂点データのメモリサイズは4byte * 8
になります。
そのうち座標データは最初の4つなのでメモリのオフセット位置は4byte * 0
、カラー値のほうは後半の4つなので4byte * 4
となります。
このあたりの話は構造体とメモリレイアウトに詳しく書いてありましたので参考にさせていただきました。
(例)
const pipelineDescriptor: GPURenderPipelineDescriptor = {
vertex: {
module: shaderModule,
entryPoint: 'vertex_main',
buffers: [
{
attributes: [
{
shaderLocation: 0, // position
offset: 0, // `4byte * 8`
format: 'float32x4',
},
{
shaderLocation: 1, // color
offset: 16, // `4byte * 4`
format: 'float32x4',
},
],
arrayStride: 32, // `4byte * 8
stepMode: 'vertex',
},
],
},
fragment: {
module: shaderModule,
entryPoint: 'fragment_main',
targets: [
{
format: navigator.gpu.getPreferredCanvasFormat(),
},
],
},
primitive: {
topology: 'triangle-list',
},
layout: 'auto',
}
ユニフォーム(uniform.ts)
フラグメントシェーダーで time
と screen_size
の値を使用しますが、これらをユニフォームバッファを使ってシェーダーに送るために必要な値とデータを定義しています。
/** 1つの値のメモリサイズ */
const perByteSize = Float32Array.BYTES_PER_ELEMENT // 4
/** ユニフォームバッファのサイズ */
export const uniformBufferSize =
perByteSize * 1 /** time */ +
perByteSize * 1 /** バッファサイズ調整用(最小サイズが16バイトのため) */ +
perByteSize * 2 /** window_size(x,y) */
/** ユニフォームバッファのデータ */
export const uniformValues = new Float32Array(uniformBufferSize / perByteSize)
/** uniformValues内の time データのオフセットの位置を指定 */
export const timeUniformOffset = 0
/** uniformValues内の screen_size データのオフセットの位置を指定 */
export const screenSizeUniformOffset = 2
ユニフォームバッファの作成(writeUniformBuffer.ts)
ユニフォームバッファを作成し、時間とウィンドウサイズをバッファに保存しています。
import { timeUniformOffset, uniformValues, screenSizeUniformOffset } from './uniform'
const startTime = Date.now()
type TArgs = {
uniformBuffer: GPUBuffer
GPU_DEVICE: GPUDevice
GPU_CANVAS_CONTEXT: GPUCanvasContext
}
export const writeUniformBuffer = ({ uniformBuffer, GPU_DEVICE, GPU_CANVAS_CONTEXT }: TArgs) => {
/**
* Set time into uniformValues
* --------------------------------------*/
const millis = (Date.now() - startTime) / 1000
uniformValues.set([millis], timeUniformOffset)
/**
* Set Screen Size into the uniformValues
* --------------------------------------*/
const width = GPU_CANVAS_CONTEXT.canvas.width
const height = GPU_CANVAS_CONTEXT.canvas.height
uniformValues.set([width, height], screenSizeUniformOffset)
// Write data into the buffer
GPU_DEVICE.queue.writeBuffer(uniformBuffer, 0, uniformValues)
}
パイプラインの定義(getPipeline.ts)
頂点シェーダーとフラグメントシェーダーのステージを制御するパイプラインの定義をしています。
使用するシェーダープログラム、頂点バッファ内のデータの解釈方法、レンダリングするジオメトリの種類(線分、点、三角形)など、ジオメトリをどのように描画するかを指定します。
GPU_DEVICE.createRenderPipeline()
でパイプラインを作成し、vertexとfragmentに対してそれぞれGPU_DEVICE.createShaderModule()
でシェーダーモジュールを作成し登録します。
Viteではimportする際にファイルの拡張子に?raw
をつけてやることでwgslファイルを読み込むことができます。
import vertexWGSL from './shader/vertex.wgsl?raw'
import fragmentWGSL from './shader/fragment.wgsl?raw'
import vertexWGSL from './shader/vertex.wgsl?raw'
import fragmentWGSL from './shader/fragment.wgsl?raw'
import { squarePositionOffset, squareVertexSize } from './geometry'
type TGetPipelineArgs = {
GPU_DEVICE: GPUDevice
CANVAS_FORMAT: GPUTextureFormat
bindGroupLayout: GPUBindGroupLayout
}
export const getPipeline = ({ GPU_DEVICE, CANVAS_FORMAT, bindGroupLayout }: TGetPipelineArgs) => {
const pipelineLayout = GPU_DEVICE.createPipelineLayout({
label: 'Pipeline Layout',
bindGroupLayouts: [bindGroupLayout],
})
return GPU_DEVICE.createRenderPipeline({
layout: pipelineLayout,
vertex: {
module: GPU_DEVICE.createShaderModule({
label: 'vertex shader',
code: vertexWGSL,
}),
entryPoint: 'vertexMain',
buffers: [
{
arrayStride: squareVertexSize,
attributes: [
{
// position
shaderLocation: 0, // vertex.wgsl vertexMain関数の @location(0) に対応
offset: squarePositionOffset,
format: 'float32x2', // 各頂点の座標データの容量に合わせたフォーマット。ここでは4byteが2つで一つの座標なので'float32x2'を指定。
},
],
},
],
},
fragment: {
module: GPU_DEVICE.createShaderModule({
label: 'fragment shader',
code: fragmentWGSL,
}),
entryPoint: 'fragmentMain',
targets: [
{
format: CANVAS_FORMAT,
},
],
},
})
}
メイン(main.ts)
初期化処理からレンダリングまでの一連の処理を繋いでいます。
主にやっているのは以下の処理です。
- GPU_DEVICEで頂点用のバッファを作成しバッファにデータをセット
- 頂点のインデックス順指定用のバッファを作成しバッファにデータをセット
- バインドグループレイアウトを作成
- パイプラインの作成
- ユニフォームバッファを作成
- ユニフォーム用のバインドグループを作成
- レンダリング処理のループを開始
import { squareIndexArray, squareVertexArray } from './geometry.ts'
import { getPipeline } from './getPipeline.ts'
import { initialize } from './initialize.ts'
import { render } from './render.ts'
import { uniformBufferSize } from './uniform.ts'
initialize()
.then((result) => {
const { GPU_DEVICE, GPU_CANVAS_CONTEXT, CANVAS_FORMAT } = result
/**
* Setting Vertex Buffer
* --------------------------------------*/
const verticesBuffer = GPU_DEVICE.createBuffer({
label: 'verticesBuffer',
size: squareVertexArray.byteLength,
usage: GPUBufferUsage.VERTEX,
mappedAtCreation: true,
})
// Set data into buffer
new Float32Array(verticesBuffer.getMappedRange()).set(squareVertexArray)
verticesBuffer.unmap()
/**
* Setting Index Buffer
* --------------------------------------*/
const indicesBuffer = GPU_DEVICE.createBuffer({
label: 'indicesBuffer',
size: squareIndexArray.byteLength,
usage: GPUBufferUsage.INDEX,
mappedAtCreation: true,
})
// Set data into buffer
new Uint16Array(indicesBuffer.getMappedRange()).set(squareIndexArray)
indicesBuffer.unmap()
/**
* Create BindGroupLayout
*/
const bindGroupLayout = GPU_DEVICE.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.FRAGMENT,
buffer: {},
},
],
})
/**
* Create Pipeline
* --------------------------------------*/
const pipeline = getPipeline({ GPU_DEVICE, CANVAS_FORMAT, bindGroupLayout })
/**
* Setting Uniform Buffer
* --------------------------------------*/
const uniformBuffer = GPU_DEVICE.createBuffer({
label: 'uniformBuffer',
size: uniformBufferSize,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
})
/**
* Uniform Bind Group
* --------------------------------------*/
const uniformBindGroup = GPU_DEVICE.createBindGroup({
layout: bindGroupLayout,
entries: [
{
binding: 0, // fragment.wgslの @group(0) @binding(0) に対応
resource: {
buffer: uniformBuffer,
},
},
],
})
/**
* Start Animation
* --------------------------------------*/
const loop = () => {
render({
GPU_CANVAS_CONTEXT,
GPU_DEVICE,
pipeline,
verticesBuffer,
uniformBuffer,
uniformBindGroup,
indicesBuffer,
})
requestAnimationFrame(loop)
}
loop()
})
.catch((error) => {
console.error(error)
})
バッファの設定について
頂点座標などのデータはバッファに格納することでGPU側で利用できるようになります。以下MDNの引用です。
The GPUBuffer interface of the WebGPU API represents a block of memory that can be used to store raw data to use in GPU operations.
WebGPU API のGPUBufferインターフェースは、GPU操作で使用する生データを格納するために使用できるメモリブロックを表します。
(引用元:GPUBuffer - Web APIs | MDN)
バッファはデバイスのcreateBuffer()
で作成します。
またバッファにはmapStateがあり、mapped
の状態の場合はGPUBuffer.getMappedRange()を使ってJavaScriptからGPUBufferの内容にアクセスできるようになります。そして作成時にmappedAtCreationを有効にしておくことでバッファはマップされた状態で作成されます。そうすることで作成直後にGPUBuffer.getMappedRange()を呼び出すことができ、バッファに対してデータをセットすることができます。(これについてはバッファのデータをセットする最良の方法の一つとしてこちらの記事で紹介されていました。)
それからGPUはマップされたGPUBufferにアクセスできません。
Important point: GPUBuffer mapping is done as an ownership transfer between the CPU and the GPU. At each instant, only one of the two can access the buffer, so no race is possible. In summary, GPU cannot access mapped buffers, and CPU cannot access unmapped ones.
重要なポイントGPUBuffer のマッピングは、CPU と GPU 間の所有権移転として行われます。各瞬間において、どちらか一方だけがバッファにアクセスできるので、競合は起こりえません。まとめると、GPU はマッピングされたバッファにアクセスできず、CPU はマッピングされていないバッファにアクセスできません。
(引用元:WebGPU-For-Dummies#buffer-mapping)
なのでGPUBufferの操作が終了したらGPUBuffer.unmap()を呼び出してマップを解除し、GPUが再びアクセスできるようにします。
const verticesBuffer = GPU_DEVICE.createBuffer({
label: 'verticesBuffer',
size: squareVertexArray.byteLength,
usage: GPUBufferUsage.VERTEX,
mappedAtCreation: true, // 有効にしておくとバッファはマップされた状態で作成される
})
new Float32Array(verticesBuffer.getMappedRange()).set(squareVertexArray)
verticesBuffer.unmap()
バインドグループレイアウトとシェーダーの関係について
各シェーダー(バーテックス、フラグメント、コンピュート)で何かしらのリソースを利用したい場合に、ユニフォームバッファなどのグローバル変数的なものを利用することができます(設定できるリソースについては#binding-resource-typeを参照)。リソースをシェーダーから参照できるようにするためには、GPUBindGroupLayoutでリソースのレイアウトとGPUBindGroupでリソースの中身のデータの定義を行い、定義したバインドグループレイアウトをレンダーパイプラインのレイアウトに設定する必要があります。
具体的にはGPUBindGroupLayout
のentries
で各リソースを定義します。visibility
でどのシェーダーステージかを指定します。そしてbinding
の値をGPUBindGroup
で定義するリソースのbinding
とシェーダー(GPUShaderModule)内の@binding(n)
の値に一致させるとことで紐付けします。
例として図にすると下記のような感じです。
こちらはyour-first-webgpu-appの内容を参考にしたもので本記事のコードの内容を図にしてものではありません。
レンダリングの処理
ここではキャンバスに対してGPUに描画させるための処理を書いています。
最初にやっているのはユニフォームバッファのデータを書き込んで、時間とウィンドウサイズの値を更新です。
デバイスを使用してキャンバスの内容を変更するためにはGPUに処理内容を指示するコマンドを送信する必要があります。そのためにGPUコマンドをエンコードするための GPUCommandEncoder
をデバイスで作成します。
WebGPU におけるすべての描画操作は、レンダリングパスを通して実行されます。各レンダリングパスは、beginRenderPass()
の呼び出しで始まります。このメソッドでは、実行されたすべての描画コマンドの出力を受け取るテクスチャを定義します。ここで指定するテクスチャは、GPU_CANVAS_CONTEXT.getCurrentTexture()
を呼び出して、作成したキャンバスのコンテキストから取得します。このメソッドは、キャンバスの width および height 属性に一致するピクセル幅と高さ、そして GPU_CANVAS_CONTEXT.configure()
を呼び出したときに指定した format を持つテクスチャを返します。
用意したレンダリングパスにパイプラインやバッファの割り当て描画方法などの実行コマンドを記録します。
最後にコマンドエンコーダで finish()
を実行することで記録されたコマンドをカプセル化した GPUCommandBuffer
が作成されます。そのコマンドバッファを GPU_DEVICE.queue.submit()
の引数に渡して実行することでGPUに送信します。
import { squareIndexArray } from './geometry'
import { writeUniformBuffer } from './writeUniformBuffer'
type TRenderArgs = {
GPU_CANVAS_CONTEXT: GPUCanvasContext
GPU_DEVICE: GPUDevice
pipeline: GPURenderPipeline
verticesBuffer: GPUBuffer
uniformBuffer: GPUBuffer
uniformBindGroup: GPUBindGroup
indicesBuffer: GPUBuffer
}
export const render = ({
GPU_CANVAS_CONTEXT,
GPU_DEVICE,
pipeline,
verticesBuffer,
uniformBuffer,
uniformBindGroup,
indicesBuffer,
}: TRenderArgs) => {
/** Update uniform buffer */
writeUniformBuffer({ uniformBuffer, GPU_DEVICE, GPU_CANVAS_CONTEXT })
/** GPUに発行されるコマンドをエンコードするためのエンコーダーを作成 */
const commandEncoder = GPU_DEVICE.createCommandEncoder()
/**
* レンダリングパスを作成し、レンダリングに関する処理の実行コマンドを記録しレンダリングパスを終了する
*/
const renderPassEncoder = commandEncoder.beginRenderPass({
colorAttachments: [
// fragment.wgsl fragmentMain関数の戻り値の @location(0) に対応
{
view: GPU_CANVAS_CONTEXT.getCurrentTexture().createView(),
clearValue: { r: 0.0, g: 0.0, b: 0.0, a: 1.0 },
loadOp: 'clear',
storeOp: 'store',
},
],
})
renderPassEncoder.setPipeline(pipeline)
renderPassEncoder.setBindGroup(0, uniformBindGroup) // fragment.wgsl の @group(0) に対応
renderPassEncoder.setVertexBuffer(0, verticesBuffer) // vertex.wgsl vertexMain関数の @location(0) に対応
renderPassEncoder.setIndexBuffer(indicesBuffer, 'uint16')
renderPassEncoder.drawIndexed(squareIndexArray.length)
renderPassEncoder.end()
/**
* レンダリングパスによって記録されたコマンドをコマンドバッファでラップしてGPUに送信する
*/
GPU_DEVICE.queue.submit([commandEncoder.finish()])
}
シェーダー
WebGLの場合はglslでしたが、WebGPUの場合はwgslを使います。WebGLについては以下が参考になりました。
頂点シェーダー(shader/vertex.wgsl)
こちらはjsから受け取った頂点データをそのままセットしているだけです。
@vertex
fn vertexMain(
@location(0) pos: vec2<f32> // Pipeline の shaderLocation: 0 に対応
) -> @builtin(position) vec4<f32> { // @builtin(position) は GLSL の gl_Position に相当
return vec4f(pos, 0, 1);
}
フラグメントシェーダー(shader/fragment.wgsl)
ユニフォームでtime
とscreen_size
を受け取り、ビルトインの座標を使って描画処理を書いています。
描画部分に関する説明については、一応コード内にコメントを書いてますが自分も100%理解しているわけではないのと、そもそも言葉で説明するのが難しいのであまり参考にならないかもしれません。チュートリアル動画を見ていただくのがいいかと思います。
struct Uniforms {
time: f32,
screen_size: vec2<f32>,
};
@group(0) @binding(0) var<uniform> uniforms : Uniforms;
fn colorPalette(t: f32) -> vec3<f32> {
let a = vec3(0.5, 0.5, 0.5);
let b = vec3(0.5, 0.5, 0.5);
let c = vec3(1.0, 1.0, 1.0);
let d = vec3(0.00, 0.10, 0.20);
return a + b * cos(6.28318*(c*t+d));
}
struct FragmentInputs {
@builtin(position) position : vec4<f32>, // GLSL の gl_FragCoord に相当
};
@fragment
fn fragmentMain(input: FragmentInputs) -> @location(0) vec4<f32> {
// 座標の正規化
var uv = input.position.xy / uniforms.screen_size.xy * 2.0 - 1.0 ;
uv = vec2(uv.x * uniforms.screen_size.y / uniforms.screen_size.x, uv.y);
var uv0 = uv;
var finalColor = vec3(0.0);
// fractで作った各タイル内の描画サイクルを増やす
for (var i: i32 = 0; i < 4 ; i++) {
// スクリーン座標を繰り返す(スクリーン全体をタイル状にを分割する)
uv = fract(uv * 1.5) - 0.5;
// タイルの真ん中を中心とする円形のグラデーションを作る、exp()で偏差をつけている
var distance = length(uv) * exp(-length(uv0));
let index = f32(i);
// スクリーン全体のカラーをグラデーションにする
var gradationColor = colorPalette(length(uv0) + index * 0.8 + uniforms.time * 0.5 );
// サークルを波紋状にする
distance = sin(distance * 8 /* 波紋の周期の数 */ + uniforms.time * 1.4 /* 波紋の速さ */ );
distance = abs(distance);
distance = pow(0.08 / distance, 1.2); // 波紋のラインの太さ(シャープさ)調整
finalColor += gradationColor * distance;
}
return vec4<f32>(finalColor, 1.0);
}
フラグメントシェーダーの完成形までのステップ
一応チュートリアル動画の内容に沿ってどういうステップで完成形になっているかだけ記しておこうと思います。
step1
struct Uniforms {
time: f32,
screen_size: vec2<f32>,
};
@group(0) @binding(0) var<uniform> uniforms : Uniforms;
struct FragmentInputs {
@builtin(position) position : vec4<f32>, // GLSL の gl_FragCoord に相当
};
@fragment
fn fragmentMain(input: FragmentInputs) -> @location(0) vec4<f32> {
// 座標の正規化
var uv = input.position.xy / uniforms.screen_size.xy;
return vec4<f32>(uv, 0, 1.0);
}
単純に座標をスクリーンサイズで割って0~1.0の値に正規化したものをカラー値(red, green)にセットしています。
左上が(0,0)で右下が(1,1)になっています。
ちなみにWebGLのglslとはY座標が逆になります。glslのY座標は左下が(0,0)、右上が(1,1)になります。
step2
// 前略
@fragment
fn fragmentMain(input: FragmentInputs) -> @location(0) vec4<f32> {
// 座標の正規化
var uv = input.position.xy / uniforms.screen_size.xy;
+ uv = uv - 0.5;
return vec4<f32>(uv, 0, 1.0);
}
全ての座標から0.5をマイナスすることで(0,0)の位置をずらすことができます。
step3
// 前略
@fragment
fn fragmentMain(input: FragmentInputs) -> @location(0) vec4<f32> {
// 座標の正規化
> var uv = input.position.xy / uniforms.screen_size.xy * 2.0 - 1.0;
- uv = uv - 0.5;
+ let d = length(uv);
return vec4<f32>(d, d, d, 1.0);
}
uvに* 2.0 - 1.0
を追加して座標(0,0)をスクリーンの中心に移動させています。
仮にスクリーンが(縦1.0,横1.0)のサイズだったとして、
2倍にします
1を引きます
これで(0,0)がスクリーンの中央に移動しましました。
そしてuv座標の距離をlength()で取得しカラー値に指定することで円を描けます。
ただしこれだとスクリーンサイズが長方形になったときに楕円形になってしまいます。
step4
// 前略
@fragment
fn fragmentMain(input: FragmentInputs) -> @location(0) vec4<f32> {
// 座標の正規化
var uv = input.position.xy / uniforms.screen_size.xy * 2.0 - 1.0 ;
+ uv = vec2(uv.x * uniforms.screen_size.y / uniforms.screen_size.x, uv.y);
var d = length(uv);
return vec4<f32>(d, d, d, 1.0);
}
アスペクト比をかけて調整します。
以下のように1行でやりたかったんですが、これだと何故かうまくいかなかったので断念しました。
var uv = (input.position.xy * 2.0 - uniforms.screen_size.xy) / min(uniforms.screen_size.x, uniforms.screen_size.y);
step5
// 前略
@fragment
fn fragmentMain(input: FragmentInputs) -> @location(0) vec4<f32> {
// 座標の正規化
var uv = input.position.xy / uniforms.screen_size.xy * 2.0 - 1.0 ;
uv = vec2(uv.x * uniforms.screen_size.y / uniforms.screen_size.x, uv.y);
var d = length(uv);
+ d -= 0.5;
return vec4<f32>(d, d, d, 1.0);
}
全部の座標の距離から0.5をマイナスすることで(0,0)地点をオフセットできます。
step6
// 前略
@fragment
fn fragmentMain(input: FragmentInputs) -> @location(0) vec4<f32> {
// 座標の正規化
var uv = input.position.xy / uniforms.screen_size.xy * 2.0 - 1.0 ;
uv = vec2(uv.x * uniforms.screen_size.y / uniforms.screen_size.x, uv.y);
var d = length(uv);
d -= 0.5;
+ d = abs(d);
return vec4<f32>(d, d, d, 1.0);
}
abs()
を使ってマイナスの符号を無くすことで、(0,0)が黒く両サイドが白くなっていきます。
step7
// 前略
@fragment
fn fragmentMain(input: FragmentInputs) -> @location(0) vec4<f32> {
// 座標の正規化
var uv = input.position.xy / uniforms.screen_size.xy * 2.0 - 1.0 ;
uv = vec2(uv.x * uniforms.screen_size.y / uniforms.screen_size.x, uv.y);
var d = length(uv);
d -= 0.5;
d = abs(d);
+ d = step(0.1, d);
return vec4<f32>(d, d, d, 1.0);
}
step()
を使うと数値の切り替えを0か1にしてグラデーションを無くすことができます。
step8
// 前略
@fragment
fn fragmentMain(input: FragmentInputs) -> @location(0) vec4<f32> {
// 座標の正規化
var uv = input.position.xy / uniforms.screen_size.xy * 2.0 - 1.0 ;
uv = vec2(uv.x * uniforms.screen_size.y / uniforms.screen_size.x, uv.y);
var d = length(uv);
d -= 0.5;
d = abs(d);
- d = step(0.1, d);
+ d = smoothstep(0.0, 0.1, d);
return vec4<f32>(d, d, d, 1.0);
}
smoothstep()
を使うことで縁の色の変化を滑らかにできます。
step9
// 前略
@fragment
fn fragmentMain(input: FragmentInputs) -> @location(0) vec4<f32> {
// 座標の正規化
var uv = input.position.xy / uniforms.screen_size.xy * 2.0 - 1.0 ;
uv = vec2(uv.x * uniforms.screen_size.y / uniforms.screen_size.x, uv.y);
var d = length(uv);
- d -= 0.5;
+ d = sin(d);
d = abs(d);
d = smoothstep(0.0, 0.1, d);
return vec4<f32>(d, d, d, 1.0);
}
座標の距離をsin()
にかけてみます。この時点では中央に小さい円が描かれます。
step10
// 前略
@fragment
fn fragmentMain(input: FragmentInputs) -> @location(0) vec4<f32> {
// 座標の正規化
var uv = input.position.xy / uniforms.screen_size.xy * 2.0 - 1.0 ;
uv = vec2(uv.x * uniforms.screen_size.y / uniforms.screen_size.x, uv.y);
var d = length(uv);
> d = sin(d*8.)/8.;
d = abs(d);
d = smoothstep(0.0, 0.1, d);
return vec4<f32>(d, d, d, 1.0);
}
サイン波の周波数を細かくすることで波紋のようは絵が描けます。
step11
// 前略
@fragment
fn fragmentMain(input: FragmentInputs) -> @location(0) vec4<f32> {
// 座標の正規化
var uv = input.position.xy / uniforms.screen_size.xy * 2.0 - 1.0 ;
uv = vec2(uv.x * uniforms.screen_size.y / uniforms.screen_size.x, uv.y);
var d = length(uv);
> d = sin(d*8. + uniforms.time)/8.;
d = abs(d);
d = smoothstep(0.0, 0.1, d);
return vec4<f32>(d, d, d, 1.0);
}
サイン関数に時間の変化を加えることで波紋がアニメーションするようになります。
step12
// 前略
@fragment
fn fragmentMain(input: FragmentInputs) -> @location(0) vec4<f32> {
// 座標の正規化
var uv = input.position.xy / uniforms.screen_size.xy * 2.0 - 1.0 ;
uv = vec2(uv.x * uniforms.screen_size.y / uniforms.screen_size.x, uv.y);
var d = length(uv);
d = sin(d*8. + uniforms.time)/8.;
d = abs(d);
- d = smoothstep(0.0, 0.1, d);
+ d = 0.02 / d;
return vec4<f32>(d, d, d, 1.0);
}
少数を距離で割ることで色を反転させます。
step13
// 前略
@fragment
fn fragmentMain(input: FragmentInputs) -> @location(0) vec4<f32> {
// 座標の正規化
var uv = input.position.xy / uniforms.screen_size.xy * 2.0 - 1.0 ;
uv = vec2(uv.x * uniforms.screen_size.y / uniforms.screen_size.x, uv.y);
+ var col = vec3(1.0, 2.0, 3.0);
var d = length(uv);
d = sin(d*8. + uniforms.time)/8.;
d = abs(d);
d = 0.02 / d;
+ col *= d;
> return vec4<f32>(col, 1.0);
}
波紋に色を加えてみます。
step14
struct Uniforms {
time: f32,
screen_size: vec2<f32>,
};
@group(0) @binding(0) var<uniform> uniforms : Uniforms;
+ fn colorPalette(t: f32) -> vec3<f32> {
+ let a = vec3(0.5, 0.5, 0.5);
+ let b = vec3(0.5, 0.5, 0.5);
+ let c = vec3(1.0, 1.0, 1.0);
+ let d = vec3(0.00, 0.10, 0.20);
+ return a + b * cos(6.28318*(c*t+d));
+ }
struct FragmentInputs {
@builtin(position) position : vec4<f32>, // GLSL の gl_FragCoord に相当
};
@fragment
fn fragmentMain(input: FragmentInputs) -> @location(0) vec4<f32> {
// 座標の正規化
var uv = input.position.xy / uniforms.screen_size.xy * 2.0 - 1.0 ;
uv = vec2(uv.x * uniforms.screen_size.y / uniforms.screen_size.x, uv.y);
+ var d = length(uv);
> var col = colorPalette(d);
- var d = length(uv);
d = sin(d*8. + uniforms.time)/8.;
d = abs(d);
d = 0.02 / d;
col *= d;
return vec4<f32>(col, 1.0);
}
カラーパレット用の関数を用意してスクリーン座標全体をカラフルなグラデーションに変化させます。
※参考: Inigo Quilez :: computer graphics, mathematics, shaders, fractals, demoscene and more
step15
// 前略
@fragment
fn fragmentMain(input: FragmentInputs) -> @location(0) vec4<f32> {
// 座標の正規化
var uv = input.position.xy / uniforms.screen_size.xy * 2.0 - 1.0 ;
uv = vec2(uv.x * uniforms.screen_size.y / uniforms.screen_size.x, uv.y);
+ uv = fract(uv);
var d = length(uv);
> var col = colorPalette(d + uniforms.time);
d = sin(d*8. + uniforms.time)/8.;
d = abs(d);
d = 0.02 / d;
col *= d;
return vec4<f32>(col, 1.0);
}
fract()
を使うことでリピートさせることができます。
step16
// 前略
@fragment
fn fragmentMain(input: FragmentInputs) -> @location(0) vec4<f32> {
// 座標の正規化
var uv = input.position.xy / uniforms.screen_size.xy * 2.0 - 1.0 ;
uv = vec2(uv.x * uniforms.screen_size.y / uniforms.screen_size.x, uv.y);
> uv = fract(uv * 2.0) - 0.5;
var d = length(uv);
var col = colorPalette(d + uniforms.time);
d = sin(d*8. + uniforms.time)/8.;
d = abs(d);
d = 0.02 / d;
col *= d;
return vec4<f32>(col, 1.0);
}
fract()
内の係数を増やすことでリピート数が増えます。
また、-0.5することで各セルの中央に移動させることができます。
step17
// 前略
@fragment
fn fragmentMain(input: FragmentInputs) -> @location(0) vec4<f32> {
// 座標の正規化
var uv = input.position.xy / uniforms.screen_size.xy * 2.0 - 1.0 ;
uv = vec2(uv.x * uniforms.screen_size.y / uniforms.screen_size.x, uv.y);
+ var uv0 = uv;
uv = fract(uv * 2.0) - 0.5;
var d = length(uv);
> var col = colorPalette(length(uv0) + uniforms.time);
d = sin(d*8. + uniforms.time)/8.;
d = abs(d);
d = 0.02 / d;
col *= d;
return vec4<f32>(col, 1.0);
}
スクリーン全体の座標に対して時間の経過を加えることでグラデーションをアニメーションさせます。
step18
// 前略
@fragment
fn fragmentMain(input: FragmentInputs) -> @location(0) vec4<f32> {
// 座標の正規化
var uv = input.position.xy / uniforms.screen_size.xy * 2.0 - 1.0 ;
uv = vec2(uv.x * uniforms.screen_size.y / uniforms.screen_size.x, uv.y);
var uv0 = uv;
+ var finalColor = vec3(0.0);
+ for (var i:i32 = 0; i < 2; i++) {
uv = fract(uv * 2.0) - 0.5;
var d = length(uv);
var col = colorPalette(length(uv0) + uniforms.time);
d = sin(d*8. + uniforms.time)/8.;
d = abs(d);
d = 0.02 / d;
> finalColor += col * d;
+ }
> return vec4<f32>(finalColor, 1.0);
}
for
文で回すことで各セルの中にさらに円を描くようにできます。
step19
// 前略
@fragment
fn fragmentMain(input: FragmentInputs) -> @location(0) vec4<f32> {
// 座標の正規化
var uv = input.position.xy / uniforms.screen_size.xy * 2.0 - 1.0 ;
uv = vec2(uv.x * uniforms.screen_size.y / uniforms.screen_size.x, uv.y);
var uv0 = uv;
var finalColor = vec3(0.0);
> for (var i:i32 = 0; i < 3; i++) {
> uv = fract(uv * 1.5) - 0.5;
> var d = length(uv) * exp(-length(uv0));
var col = colorPalette(length(uv0) + uniforms.time);
d = sin(d*8. + uniforms.time)/8.;
d = abs(d);
d = 0.02 / d;
finalColor += col * d;
}
return vec4<f32>(finalColor, 1.0);
}
for文の回数と、fract()の係数を変えて形に変化をつけます。
exp()
のグラフの値を加えることでさらに色の変化を加えます。
※参考: Graphtoy - exp()
step20
// 前略
@fragment
fn fragmentMain(input: FragmentInputs) -> @location(0) vec4<f32> {
// 座標の正規化
var uv = input.position.xy / uniforms.screen_size.xy * 2.0 - 1.0 ;
uv = vec2(uv.x * uniforms.screen_size.y / uniforms.screen_size.x, uv.y);
var uv0 = uv;
var finalColor = vec3(0.0);
for (var i:i32 = 0; i < 3; i++) {
uv = fract(uv * 1.5) - 0.5;
var d = length(uv) * exp(-length(uv0));
+ let index = f32(i);
> var col = colorPalette(length(uv0) + index * 0.4 + uniforms.time);
d = sin(d*8. + uniforms.time)/8.;
d = abs(d);
> d = 0.01 / d;
finalColor += col * d;
}
return vec4<f32>(finalColor, 1.0);
}
for文のインデックス値を使ってさらに色の変化をつけます。
step21
// 前略
@fragment
fn fragmentMain(input: FragmentInputs) -> @location(0) vec4<f32> {
// 座標の正規化
var uv = input.position.xy / uniforms.screen_size.xy * 2.0 - 1.0 ;
uv = vec2(uv.x * uniforms.screen_size.y / uniforms.screen_size.x, uv.y);
var uv0 = uv;
var finalColor = vec3(0.0);
> for (var i:i32 = 0; i < 4; i++) {
uv = fract(uv * 1.5) - 0.5;
var d = length(uv) * exp(-length(uv0));
let index = f32(i);
> var col = colorPalette(length(uv0) + index * 0.4 + uniforms.time * 0.4);
d = sin(d * 8.0 + uniforms.time);
d = abs(d);
> d = pow(0.08 / d, 1.2);
finalColor += col * d;
}
return vec4<f32>(finalColor, 1.0);
}
for文で回す回数を増やして、グラデーションの変化のスピードを遅くします。
pow()
のグラフを使って中央から外側に行くほどぼやけるように色の変化を加えます。
※参考: Graphtoy - pow()
最後に
今回やってみてWebGLもそうですがやはり3DのAPIは難しいですね。複雑で情報量がとても多いのでなかなか体系的に理解できず苦労しました。特にlocationやbindingなどのjsとシェーダー間のデータの受け渡し部分が理解できるまで時間がかかりました。その点で個人的には初めての WebGPU アプリがとても役に立ちました。小さくステップを踏んで実装していく形式になっていて、何がどういう役割でどういうふうに連動しているかといったところがだいぶイメージできるようになった気がします。
WebGLに比べるとGPUBindGroupLayoutやGPUPipelineLayoutがあることで構造的にわかりやすく扱いやすくもなっているのかなという気がします。あとはやはりW3Cで策定されているのがいいですね。
参考
- WebGPU入門
- https://youtu.be/f4s1h2YETNY?feature=shared
- Graphtoy
- Inigo Quilez :: computer graphics, mathematics, shaders, fractals, demoscene and more
- dev.thi.ng/gradients/
- 初めての WebGPU アプリ
- WebGPUの基本
- WebGPU API - Web API | MDN
- WebGPU
- WebGPU Shading Language
- Tour of WGSL
- WebGPU WGSL
- What's the difference between a GPUAdapter and GPUDevice in the WebGPU api? - Stack Overflow
Discussion