🎨

WebGPUでシェーダーアートをやってみる(Vite、TypeScript)

2024/04/07に公開

はじめに

下記の素晴らしいGLSLを使ったシェーダーアートのチュートリアル動画の内容をWebGPUでやってみたのでその覚書です。WebGLは以前少しだけ齧ったことがありましたがWebGPUについては完全に知識ゼロなので入門のお題としてやってみた感じです。チュートリアル動画ではShadertoyを使っておりフラグメントシェーダーに記述する部分のみの解説なので、そもそもフラグメントシェーダーを使ってブラウザで描画させるまでの工程も含めてWebGPUで再現してみるという感じです。自分も完全に理解できているわけではないのでおかしい部分があるかもしれませんがご容赦ください。またWebGPUはまだWorking Draft(草案)段階なので今後いろいろと変更される可能性があることにも注意が必要です。

https://youtu.be/f4s1h2YETNY?feature=shared

実際に作ったもの

リポジトリ
https://github.com/t-shiratori/webgpu-vite-ts-dev/tree/main/pages/sample-wgsl-art-board

codesandbox
https://codesandbox.io/p/devbox/webgpu-vite-ts-9jj8dq?file=%2Fsrc%2Fshader%2Ffragment.wgsl%3A54%2C2

実装について

ざっくりとした全体の作りとしては四角形のポリゴンを作ってそれをキャンバスと同じサイズで配置し、そのポリゴンにフラグメントシェーダーを使って描画します。

開発環境

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で指定しています。

sample-wgsl-art-board/index.html
<!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の構成を設定します。最後にブラウザのサイズに合わせてキャンバスをリサイズする処理を書いています。

sample-wgsl-art-board/initialize.ts
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型の配列に入れておいてあとで頂点バッファに保存します。そしてパイプラインを作成する際にその配列内のどこからどこまでが何のデータなのかを知らせるために、メモリ割り当てにおける配列のサイズと、その中のアドレスのオフセット位置を指定する必要があります。そのための値を前もって定義しています。

sample-wgsl-art-board/geometry.ts
/** 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)

フラグメントシェーダーで timescreen_size の値を使用しますが、これらをユニフォームバッファを使ってシェーダーに送るために必要な値とデータを定義しています。

sample-wgsl-art-board/uniform.ts
/** 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)

ユニフォームバッファを作成し、時間とウィンドウサイズをバッファに保存しています。

sample-wgsl-art-board/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'
sample-wgsl-art-board/getPipeline.ts
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で頂点用のバッファを作成しバッファにデータをセット
  • 頂点のインデックス順指定用のバッファを作成しバッファにデータをセット
  • バインドグループレイアウトを作成
  • パイプラインの作成
  • ユニフォームバッファを作成
  • ユニフォーム用のバインドグループを作成
  • レンダリング処理のループを開始
sample-wgsl-art-board/main.ts
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でリソースの中身のデータの定義を行い、定義したバインドグループレイアウトをレンダーパイプラインのレイアウトに設定する必要があります。
具体的にはGPUBindGroupLayoutentriesで各リソースを定義します。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に送信します。

sample-wgsl-art-board/render.ts
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から受け取った頂点データをそのままセットしているだけです。

sample-wgsl-art-board/shader/vertex.wgsl
@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)

ユニフォームでtimescreen_sizeを受け取り、ビルトインの座標を使って描画処理を書いています。
描画部分に関する説明については、一応コード内にコメントを書いてますが自分も100%理解しているわけではないのと、そもそも言葉で説明するのが難しいのであまり参考にならないかもしれません。チュートリアル動画を見ていただくのがいいかと思います。

pages/sample-wgsl-art-board/shader/fragment.wgsl
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

pages/sample-wgsl-art-board/shader/fragment.wgsl

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

pages/sample-wgsl-art-board/shader/fragment.wgsl

// 前略

@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

pages/sample-wgsl-art-board/shader/fragment.wgsl

// 前略

@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

pages/sample-wgsl-art-board/shader/fragment.wgsl

// 前略

@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

pages/sample-wgsl-art-board/shader/fragment.wgsl

// 前略

@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

pages/sample-wgsl-art-board/shader/fragment.wgsl

// 前略

@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

pages/sample-wgsl-art-board/shader/fragment.wgsl

// 前略

@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

pages/sample-wgsl-art-board/shader/fragment.wgsl

// 前略

@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

pages/sample-wgsl-art-board/shader/fragment.wgsl

// 前略

@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

pages/sample-wgsl-art-board/shader/fragment.wgsl

// 前略

@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

pages/sample-wgsl-art-board/shader/fragment.wgsl

// 前略

@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

pages/sample-wgsl-art-board/shader/fragment.wgsl

// 前略

@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

pages/sample-wgsl-art-board/shader/fragment.wgsl

// 前略

@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

pages/sample-wgsl-art-board/shader/fragment.wgsl

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

pages/sample-wgsl-art-board/shader/fragment.wgsl

// 前略

@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

pages/sample-wgsl-art-board/shader/fragment.wgsl

// 前略

@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

pages/sample-wgsl-art-board/shader/fragment.wgsl

// 前略

@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

pages/sample-wgsl-art-board/shader/fragment.wgsl

// 前略

@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

pages/sample-wgsl-art-board/shader/fragment.wgsl

// 前略

@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

pages/sample-wgsl-art-board/shader/fragment.wgsl

// 前略

@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

pages/sample-wgsl-art-board/shader/fragment.wgsl

// 前略

@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に比べるとGPUBindGroupLayoutGPUPipelineLayoutがあることで構造的にわかりやすく扱いやすくもなっているのかなという気がします。あとはやはりW3Cで策定されているのがいいですね。

参考

Discussion