実践的なPrepalertの設定

この記事はMackerel Advent Calendar 2023 の22日目です。
こんにちは、SREチーム所属の@mashiikeです。

先日の、Mackerel Meetup #15 Tokyo ではパネルディスカッションでパネリストの一人として参加させていただきました。とても楽しかったです。ありがとうございます。

さて、この記事ではTechBlogで以前紹介したことのあるOSS『Prepalert』の実践的な設定について話したいと思います。 techblog.kayac.com

Prepalertとは

https://github.com/mashiike/prepaletgithub.com

Prepalertは、『重要性の低いモニタリング アラートの確認』というトイルを削減するためのツールです。
Mackerelを運用している方の中には『SeverityがWarningのアラート』を設定していることがあると思いますが、このWarningのアラートを確認するために各種ログや詳細メトリックを毎回人の手で調査するのは、代表的なトイルになると思います。
そこで、PrepalertではMackerelからWebhookを受け取り、そのWebhookと設定ファイルをもとに各種ログや詳細メトリックの情報を問い合わせして、問い合わせ結果をアラートのメモに貼り付けるということを行います。
これにより、自動的にアラートの1次調査に必要な情報が出揃い、トイルを減らすことができます。

冒頭で登場した以前の記事では、『アラートがオープンされたときに、対応する監視名が [prepalert]のプレフィックスを持っている場合は、CloudWatch Insights を使って applcation-logs のロググループから [error] のログを拾ってくる』という設定例を紹介しました。 今回は、より別の実践的な設定について紹介したいと思います。

S3 Select を使ってログを取得する設定

Prepalertでは自分がよく問い合わせる先をいくつかBuilt-inで実装しています。その一つとしてS3 SelectObjectContent APIがあります。 例えば、S3にあるJSON LinesやCSVを雑にメモに貼り付けたいときは、この機能がとても便利です。

中でも重宝する実践的な例として、ALBのログを取得してくる場合を取り上げます。

以下の設定は、 your-alb-log-bucketに出力された hogehoge-albのログを取得することを想定しています。 そして、その取得されたログが貼り付けられるのは、監視IDが XXXXXXXX でアラートがオープンされたときを想定しています。

./config.hcl

prepalert {
  required_version = ">=v1.0.0"
  sqs_queue_name   = "prepalert"
  
  // ここを設定しておくと、WebhookのエンドポイントにBasic認証がかかります。
  auth {
    client_id     = "prepalert"
    client_secret = must_env("PREPALERT_WEBHOOK_CLIENT_SECRET")
  }
}

provider "s3_select" {
  region = "ap-northeast-1"
}

query "s3_select" "alb_5xx_logs" {
  bucket_name = "your-alb-log-bucket"
  object_key_prefix = join("/", [
    "hogehoge-alb/AWSLogs/123456789012/elasticloadbalancing/ap-northeast-1",
    strftime("%Y/%m/%d/", webhook.alert.opened_at),
  ])
  expression = file("./queries/get_alb_5xx_log.sql")
  params = {
    start_at = strftime_in_zone("%Y-%m-%dT%H:%M:%SZ", "UTC", webhook.alert.opened_at - duration("15m"))
    end_at   = strftime_in_zone("%Y-%m-%dT%H:%M:%SZ", "UTC", coalesce(webhook.alert.closed_at, now()))
  }
  input_serialization {
    compression_type = "GZIP"
    csv {
      field_delimiter  = " "
      record_delimiter = "\n"
    }
  }
}

rule "alb_monitor" {
  when = [
    get_monitor(webhook.alert).id == "XXXXXXXX",
    webhook.alert.is_open == true,
  ]
  update_alert {
    memo = result_to_jsonlines(query.s3_select.alb_5xx_logs),
  }
}

./queries/get_alb_5xx_log.sql

SELECT
    s._1 as "type"
    ,s._2 as "time"
    ,s._3 as "elb"
    ,s._4 as "client_port"
    ,s._5 as "target_port"
    ,s._6 as "request_processing_time"
    ,s._7 as "target_processing_time"
    ,s._8 as "response_processing_time"
    ,s._9 as "elb_status_code"
    ,s._10 as "target_status_code"
    ,s._11 as "received_bytes"
    ,s._12 as "sent_bytes"
    ,s._13 as "request"
    ,s._14 as "user_agent"
    ,s._15 as "ssl_cipher"
    ,s._16 as "ssl_protocol"
    ,s._17 as "target_group_arn"
    ,s._18 as "trace_id"
    ,s._19 as "domain_name"
    ,s._20 as "chosen_cert_arn"
    ,s._21 as "matched_rule_priority"
    ,s._22 as "request_creation_time"
    ,s._23 as "actions_executed"
    ,s._24 as "redirect_url"
    ,s._25 as "error_reason"
    ,s._26 as "target_port_list"
    ,s._27 as "target_status_code_list"
    ,s._28 as "classification"
    ,s._29 as "classification_reason"
FROM s3object s
WHERE TO_TIMESTAMP(s._2) >= TO_TIMESTAMP( :start_at )
AND TO_TIMESTAMP(s._2) <= TO_TIMESTAMP( :end_at )
AND cast(s._9 as INT) >= 500

PrepalertのHCLでは4つのブロックがあります。

  • prepalertブロック: Prepalert全体の設定に関する部分です。アクセス制御や、使用するSQS Queueの指定などがあります。
  • providerブロック: Prepalertが情報を取得する先に関する設定です。
  • queryブロック: providerで指定した設定を用いて、具体的にどのように情報を取得するか?を設定します。
  • ruleブロック: 実際に発生したアラートに関して、どのようなメモを貼るか?の設定です。

この設定例で重要なのはqueryブロックとruleブロックになります。

先に、queryブロックについて説明します。

queryブロック

./config.hclquery ブロックには、 s3_select というproviderを指定しています。 形として

query "provider_name" "query_name" {
// ...
}

となっていて、ブロックの中身は使用するproviderによって変わってきます。

s3_select providerを使う場合は、おもにS3のSelectObjectContent APIにアクセスするための情報を記述します。 docs.aws.amazon.com

API自体は、1つのS3 Objectに対してSQLを実行して、その結果を取得するものです。 Prepalertでは object_key_prefix で指定した文字列に合致するS3 Object全てに対してSelectOjbectContent APIを実行して、その結果を取得するようにしています。

ALBログはPartitionのように日付ごとに別れてるので、効率よく取得するためにアラートの時刻発生時刻ベースでobject_key_prefixを指定したいです。

そこでHCLの関数を使って動的に object_key_prefix、を指定しています。 Prepalertでは時間関係のHCL関数として 以下を用意しています。

  • strftime_in_zone(format, timezone, unixSeconds): unixSeconds(float)を指定したFormatとタイムゾーンで文字列に変換する
  • strftime(format, unixSeconds): unixSeconds(float)を指定したFormatで文字列に変換する
  • now(): 現在時刻をunixSeconds(float)で返す
  • duration(str): 指定した文字列をdurationとしてパースして、seconds(float)で返す

つまり、strftime("%Y/%m/%d/", webhook.alert.opened_at) は、アラートがオープンされたときの時刻を YYYY/MM/DD/の形式で実行ランタイムのローカルタイムゾーンにしたがって文字列に変換します。

join(delimiter,[str, str,...])は 与えられたdelimiterであとの方の文字列の配列を結合します。

ですので、

  object_key_prefix = join("/", [
    "hogehoge-alb/AWSLogs/123456789012/elasticloadbalancing/ap-northeast-1",
    strftime("%Y/%m/%d/", webhook.alert.opened_at),
  ])

の部分は、アラートのオープン時刻が 2021-12-22T12:34:56Z だった場合には、 hogehoge-alb/AWSLogs/123456789012/elasticloadbalancing/ap-northeast-1/2021/12/22/ という文字列になります。

このようにして、実際に受け取ったwebhookのbodyベースで、情報の取得ができるようになっています。

次に expression部分についてです。

  expression = file("./queries/get_alb_5xx_log.sql")
  params = {
    start_at = strftime_in_zone("%Y-%m-%dT%H:%M:%SZ", "UTC", webhook.alert.opened_at - duration("15m"))
    end_at   = strftime_in_zone("%Y-%m-%dT%H:%M:%SZ", "UTC", coalesce(webhook.alert.closed_at, now()))
  }

expression で指定しているところには、S3 SelectObjectContent APIで使用するSQLを記述します。   ここには単純に文字列を書いても良いのですが、それだと管理上困ることがあると思いますので、file(filepath) という関数で別ファイルの内容をテキストで読めるようになっています。これは、設定ファイルのDirからの相対パスを指定する形になっています。 expressionで指定したSQLでは :var_name という形のプレースホルダーを使用できるようにしています。 このプレースホルダーの中身は params で指定するようになっています。
今回の例では、 :start_at:end_at を渡しています。
そして、この2つにはUTCのISO8601形式の文字列で、『アラートの開始時刻から15分前』と『アラートの終了時刻もしくは現在時刻』を渡しています。 input_serialization は S3 SelectObjectContent APIのドキュメントから雰囲気で察せると思いますので、スキップします。

このように、どうやって情報を取得するのか?を指定するのが query ブロックになります。

さて、次に rule ブロックの説明をします。

ruleブロック

ruleブロック中では、queryブロックを参照できるようになっています。
ruleブロック中で参照されたqueryが実際に実行されて、その結果をもとにメモの描画を行います。 上記の例の中では、result_to_jsonlines(query.s3_select.alb_5xx_logs) というのが具体的な参照部分です。 こうすると、queryの結果をJSON Lines形式で取得できます。 他にも次のようなHCL関数を用意しています。

  • result_to_table(query.provider_name.query_name): MySQL Clientっぽい感じのTableで出力します。
  • result_to_vertical(query.provider_name.query_name): MySQL Clientで \Gを指定したっぽい感じの縦長フォーマットで出力します。
  • result_to_markdown(query.provider_name.query_name): MarkdownのTable形式で出力します。
  • result_to_borderless(query.provider_name.query_name): 外枠なしな感じのTableで出力します。

update_alert.memo には 文字列なら何でも指定できるようになっています。 例えば、JSON Linesの情報だけでは味気ないと思ったら、query ブロックの object_key_prefix のときと同様に join(delimiter,[str, str,...]) で説明を付け加えることも可能です。

  update_alert {
    memo = join("\n", [
        "説明だよーーーー!",
        result_to_jsonlines(query.s3_select.alb_5xx_logs),
    ])
  }

他にも、長い説明を付け加えたいときには templatefile(filepath, params) という関数を使うと便利です。

  update_alert {
    memo = tmplfile("./templates/memo.tpl.md", {
        query_result = result_to_jsonlines(query.s3_select.alb_5xx_logs),
    })
  }

と指定して、テンプレートには以下のようにかけます。

説明だよ〜

- 確認項目1
- 確認項目2

${query_result}

ALBのログをアラート発生時に見たいことはよくあると思いますので、試しに使ってみてください。

Redshift Data API を使ってログを取得する設定

Cloudwatch Logs Insights と S3 Select 以外にもう一つ Redshift Data API を使ってログを取得する設定も紹介します。 prepalertブロックと ruleブロックは、先ほどのS3 Selectのときと同様ですので省略しています。

/get_orders.hcl

provider "redshift_data" {
  cluster_identifier = "warehouse"
  database           = must_env("ENV")
  db_user            = "${must_env("ENV")}__prepalert"
}

/* Serverlessをお使いの場合はこんな感じ
provider "redshift_data" {
  workgroup_name = "default"
  database       = "dev"
}
*/

query "redshift_data" "orders" {
  sql = file("./queries/get_orders.sql")
  params = {
    start_at = strftime_in_zone("%Y-%m-%d %H:%M:%S", "UTC", webhook.alert.opened_at - duration("15m"))
    end_at   = strftime_in_zone("%Y-%m-%d %H:%M:%S", "UTC", coalesce(webhook.alert.closed_at, now()))
  }
}

./queries/get_orders.sql

SELECT
  order_id
  ,order_date
  ,order_status
  ,order_total
  ,order_items
FROM orders
WHERE order_date BETWEEN (:start_at)::DATE AND (:end_at)::DATE

こうすることで、Redshiftにあるデータにアクセスして、その結果をメモに貼り付けられます。

localsブロックで設定を使いまわしたい。

Prepalertは、HCLで設定を記述します。 HCLは、Terraformで使われており、Terraformを使ってて便利だなと感じたHCL関数や機能は、Prepalertでも使えるようにしています。 その一つとして、localsブロックがあります。

例えば、文字列が特定のprefixを持っているか?というbool値を返すHCL関数 has_prefix(str, prefix) と組み合わせて、次のように使うことができます。

locals {
  org_name        = "mashiike"
  target_prefix   = "[prepalert]"
  default_message =  <<EOF
How do you respond to alerts?
Describe information about your alert response here.
EOF
}

rule "simple" {
    when = [
        webhook.org_name == local.org_name,
        has_prefix(webhook.alert.monitor_name, local.target_prefix),
    ]
    update_alert {
        memo = local.default_message
    }
}

localsブロックで定義したものは、どこでも使えるようにしているので、設定を使いまわしたいときに便利です。 このように、他のHCLで書かれる製品で便利だなと思ったものは、どんどん取り込んでいます。
この関数使えるかな?と気になった方は、こちらを見ていただければなんとなくわかるかもしれません。 github.com

メモのFullTextをS3に保存して、メモの一部とFullTextURLを貼る設定

ログをそのままアラートのメモに貼っていると、情報が多くなりすぎて困ることがおきます。 そんなときに、次のような設定をすると便利です。

./config.hcl

prepalert {
  required_version = ">=1.0.0"

  sqs_queue_name = "${must_env("ENV")}-prepalert"

  backend "s3" {
    bucket_name                 = "s3://prepalert-your-memo-data-bucket/"
    object_key_prefix           = "alerts/"
    viewer_base_url             = must_env("VIEWER_BASE_URL")
    viewer_google_client_id     = must_env("GOOGLE_CLIENT_ID")
    viewer_google_client_secret = must_env("GOOGLE_CLIENT_SECRET")
    viewer_session_encrypt_key  = "<32byte データのbase64エンコード>"
  }
}

指定したs3 bucketに、メモを保存してなおかつ簡易ビューワーが利用できるようになります。 簡易的にGoogle OAuthを利用して、ユーザーの制限をできるようにはしています。 viewer_session_encrypt_keyは、32byteのランダムなデータをbase64エンコードしたものを指定してください。 次のように作ると良いです。

$ head -c 32 /dev/random | base64

この設定をすると、アラートのメモには次のような情報が貼られるようになります。

簡易ビューワーはこんな感じになります。

まとめ

Prepalertは、アラート対応で必要となる情報をMackerelに集約するために生まれたトイル削減ツールです。
情報を拾ってくる方法に多種多様なバリエーションがあるため、設定が複雑になりがちです。
そこで、PrepalertではHCLを採用しています。
今回は、Prepalertを使う上でよく使うような設定例を紹介しました。 便利そうだなと思った方は、使っていただければ幸いです。

カヤックではSREの仕事を削減するエンジニアを募集しています

hubspot.kayac.com

【WebGL2】GPU Instancing x Transform Feedback で大量のインスタンスの計算と描画をGPUで行う

~ このエントリは 【カヤック】面白法人グループ Advent Calendar 2023 の22日目の記事です。~

こんにちは!ハイパーカジュアルゲームチームの深澤です。

WebGL2において GPU Instancing でメッシュを大量に表示しつつ、Transform Feedback を使ってインスタンスごとの情報計算もGPUに任せてみたいと思います。

↓ デモはこちらになります。画像かURLから飛ぶことができます

デモ: https://takumifukasawa.github.io/webgl-transform-feedback-gpu-instancing/

↓ リポジトリのURL

github.com

メッシュ1つあたりの頂点数は24です。描画色は、インスタンスごとの色をふまえて平行光源の拡散光だけ計算しています。 GPU Instancing を使っていて、ドローコールは1回です。

端末負荷的には、

  • MacBookPro 2020製 M1 Memory16GB Chrome では100,000インスタンス以上は60FPSを保ちながら描画できていそう

  • 私物の iPhoneX safari でも近いぐらいは出ていそう

  • ハイカジ開発でよく使う私物の Android S4-KC では 15,000 インスタンスぐらいまでは30FPSは出ていそうだが、それ以上は徐々にFPSが下がった

という具合になっています。


約3,000インスタンス

約30,000インスタンス

約100,000インスタンス


実装方針を考える

GPU Instancing

メッシュのデータは Vertex Array Object(以下、VAO)に格納することにします。
GPU Instancing は 1つの VAO をもとに 1回のドローコールで複数のインスタンスを表示することができます。
Cubeの形状が1つ入ったVAOなのにCubeを複数個描画できる、というイメージですね。
具体的には、VAOにインスタンスごとの情報(位置や速度、色など)が入った頂点バッファも持たせ、メッシュのシェーダー側でインスタンスごとの情報を受け取り処理していくという流れになります。

問題は「インスタンスごとの情報の更新」をどこでどう処理するか、です。
位置や速度などは毎フレーム計算を行いたいので、インスタンス数によっては1フレームあたりの計算量が相当な量になることが想定されます。

Javascript で Web Worker などを駆使しつつ計算すること自体はもちろんできますが、数万以上のインスタンスの情報の更新を Javascript 側で毎フレーム行うのはループ数的にあまり現実的ではありません。特にモバイル端末ではなお厳しいでしょう。

Transform Feedback

そこで GPU の出番です。WebGL2では Compute Shader が使えないので、かわりに Transform Feedback を使って近うアプローチをとります。Transform Feedback という機能は WebGL2 から標準化されました。

Transform Feedback の利点は頂点シェーダーの結果を頂点バッファに直接書き込むことができる点です。かつ、ラスタライズ処理をスキップできるので、テクスチャに情報を書き込み、シェーダーでテクスチャをフェッチして情報を抜き出して...というような方法が必要なくなるわけですね。
以下の図のイメージです。

また、頂点バッファに書いてしまえば Javascript 側から getBufferSubData 関数で読み込むことができるのも便利な点です。
(ex. 重い計算をシェーダーで行い Javascript で頂点バッファからデータを取り出すという、計算機のような使い方ができる)

つまり、Transform Feedback を使って、描画は行わず、大量の計算を GPU に任せてしまうというGPGPU的なアプローチが可能になります。

今回は、Transform Feedback を使ってインスタンスごとの情報を持った頂点バッファを毎フレーム更新し、メッシュのVAOにバインドしなおす、ということをします。
また、徐々に速度を上げたり下げたりするなど、前フレームの情報を参照して速度を調整することで連続的な速度の変化をつけていきます。

GPU Instancing 対応を考える

データ構造

Transform Feedback 対応の前に、まず単純な三角形を GPU Instancing でインスタンスごとに別の位置で描画する際のVAOのデータ構造を整理してみます。
仮に、VAOを作る処理を以下とします。

    ...

    const vao = gl.createVertexArray();
    gl.bindVertexArray(vao);

    attributes.forEach(attribute => {
        const {data, size, location, divisor, usage} = attribute;
        const vbo = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
        gl.bufferData(gl.ARRAY_BUFFER, data, usage);
        gl.enableVertexAttribArray(location);

        gl.vertexAttribPointer(location, size, gl.FLOAT, false, 0, 0); // 今回は頂点データはfloat32限定

        if (divisor) {
            gl.vertexAttribDivisor(location, divisor);
        }
    });

    gl.bindBuffer(gl.ARRAY_BUFFER, null);

    gl.bindVertexArray(null);

    ...

三角形の各頂点をp0,p1,p2とします。今はまだUVや法線は考えず、頂点座標だけに絞ります。インターリーブ、インデックスも考えません。

インスタンスごとの座標は instance0_position, instance1_position ... と続いていくものとします。

// 頂点属性群
attributes = [
    // 三角形の頂点の位置
    {
        data: new Float32Array([
            p0.x, p0.y, p0.z,
            p1.x, p1.y, p1.z,
            p2.x, p2.y, p2.z
        ]),
        size: 3,
        location: 0,
        usage: gl.STATIC_DRAW,
    },
    // インスタンスごとの位置をまとめて格納
    {
        data: new Float32Array([
            instance0_position.x, instance0_position.y, instance0_position.z,
            instance1_position.x, instance1_position.y, instance1_position.z,
            instance2_position.x, instance2_position.y, instance2_position.z,
            ...
        ]),
        size: 3,
        location: 1,
        usage: gl.STATIC_DRAW
        divisor: 1,
    }
];

一つのVAOの中に、三角形の頂点の座標が入った頂点バッファ、インスタンスごとのデータがまとまって入った頂点バッファが同居する形になっていますね。

VAOに頂点バッファをバインドする際に gl.vertexAttribDivisor(location, divisor); を呼ぶことで頂点バッファをインスタンスごとの情報に分割することができます(詳しくは後述)。

GPU Instancing を使って描画するメソッドは

gl.drawArraysInstanced(glPrimitiveType, startOffset, drawCount, instanceCount);

の形になります。さきほどのVertexArrayObjectで100個のインスタンスを表示する場合、

gl.drawArraysInstanced(gl.TRIANGLES, 0, 3, 100);

となりますね。
頂点数は3つなのでdrawCountは3、インスタンス数は100なのでinstanceCountの部分が100になる、というわけです。

シェーダー側

シェーダー側では GPU Instancing への特別な対応は必要ありません。なぜなら gl.vertexAttribDivisor 関数によって「頂点シェーダーにデータが渡される時点で、インスタンシング用の情報が入った頂点バッファがインスタンスごとに分割済みの状態」が作られているからです。詳しく見ていきます。

たとえば以下のようにローカル座標をオフセットしてインスタンスごとの位置を決定する頂点シェーダーを書くことができます。

#version 300 es

layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec3 aInstancePosition;

void main() {
    gl_Position = projectionMatrix * viewMatrix * worldMatrix * vec4(aPosition + aInstancePosition, 1.);

    ...

以下は先程のインスタンスごとのデータの再掲です。location = 1, size = 3, divisor = 1 です。インターリーブは考慮しません。

まず gl.vertexAttribPointer(location, size, gl.FLOAT, false, 0, 0); を呼び、location = 1 の頂点属性を vec3 として認識させます。

ここで gl.vertexAttribDivisor(location, divisor); を呼ぶと divisor = 1 によって size * divisor = 3 ずつ頂点バッファが分割されるので、
上記頂点シェーダーで処理される頂点0番目では aInstancePositioninstance0_position.x, instance0_position.y, instance0_position.z の3つが vec3 の各要素として渡されるようになります。

このように gl.vertexAttribDivisor によって頂点バッファを分割する機能を使い、「頂点シェーダーにデータが渡される時点で、インスタンシング用の情報が入った頂点バッファがインスタンスごとに分割済みの状態」を作ることができるので、シェーダー側で GPU Instancing への特別な対応は必要がないというわけですね 。

    {
        data: new Float32Array([
            instance0_position.x, instance0_position.y, instance0_position.z,
            instance1_position.x, instance1_position.y, instance1_position.z,
            instance2_position.x, instance2_position.y, instance2_position.z,
            ...
        ]),
        size: 3,
        location: 1,
        usage: gl.STATIC_DRAW
        divisor: 1,
    },

これで GPU Instancing を使用する際のデータ構造 / シェーダー側での処理は整理されてきました。

あとはVAOにバインドされている「インスタンスごとの頂点データ」をなんらかの方法で更新していくことができればインスタンスの位置を動的に変えていくことができそうです。

いよいよ Transform Feedback の出番です。

Transform Feedback でインスタンスごとの情報を更新

前述のように、徐々に速度を上げ下げするなど、前フレームの情報を参照して速度を調整することで連続的な速度の変化を実現できます。

ここで、前フレームの情報を利用するにあたって考慮の必要な問題が一つ出てきます。それは「1つの頂点バッファを同時に書き込み先/参照元に使うことはできない」というWebGLの仕様の存在です。
具体的には Transform Feedback で更新する際の、参照用にVAOにバインドする頂点バッファと、更新する頂点バッファにおいて、同時に同じものを使うことができません。つまり、1つの頂点バッファだけでフレームをまたぎながらのインスタンシング用の情報の更新はできないということになります。

それを回避する方法が Double Buffer 的な考え方です。

Double Buffer

いわゆる Double Buffer はテクスチャ/レンダーターゲットを2枚使うものがメジャーかなと思います。片方のテクスチャを元に情報を読み込んでからもう片方のテクスチャに情報を書き込み、次のフレームではその役割を入れ替えることで逐次的に情報を更新していくものです。
テクスチャ/レンダーターゲットも、書き込み先/参照元に同時に同じものを使うことができないが故の工夫ですね。

これと同じように、Transform Feedback と頂点バッファを2つずつ用意して、フレームごとに読み書きを入れ替えながら頂点バッファに位置と速度を更新していくという方法をとります。

そして Transform Feedback で更新した頂点バッファをメッシュのVAOにバインドし直すことで、メッシュのインスタンシング用の情報が更新されていく、というわけですね。
下図のイメージです。

実装

これで準備が整いました。いよいよ具体的な実装方法を見ていきます。
以下、サンプル実装から抜粋してきたコードになります。

VAO関連

VAOをラップした関数を用意し、頂点バッファを取得する関数などを追加しています。

また、サンプルでは Index Buffer Object を使用しているのでその実装も入っています。

function createVertexArrayObjectWrapper(gl, attributes, indicesData) {
    const vao = gl.createVertexArray();
    let ibo;
    let indices = null;

    const vertices = []; // { name, vbo, usage, location, size, divisor } の配列 

    gl.bindVertexArray(vao);

    const getBuffers = () => {
        return vertices.map(({vbo}) => vbo);
    }

    const findBuffer = (name) => {
        return vertices.find(elem => elem.name === name).vbo;
    }

    attributes.forEach(attribute => {
        const {name, data, size, location, divisor, usage} = attribute;
        const vbo = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
        gl.bufferData(gl.ARRAY_BUFFER, data, usage);
        gl.enableVertexAttribArray(location);

        gl.vertexAttribPointer(location, size, gl.FLOAT, false, 0, 0); // 今回は頂点データはfloat32限定

        if (divisor) {
            gl.vertexAttribDivisor(location, divisor);
        }

        vertices.push({
            name,
            vbo,
            usage,
            location,
            size,
            divisor
        });
    });

    if (indicesData) {
        ibo = gl.createBuffer();
        gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
        gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indicesData), gl.STATIC_DRAW);
        indices = indicesData;
    }

    gl.bindBuffer(gl.ARRAY_BUFFER, null); // unbind array buffer

    gl.bindVertexArray(null); // unbind vertex array to webgl context

    if (ibo) {
        gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null); // unbind index buffer
    }

    return {
        vao,
        indices,
        vertices,
        getBuffers,
        findBuffer
    }
}

function setBufferToVAO(vaoWrapper, name, newBuffer) {
    const target = vaoWrapper.vertices.find(elem => elem.name === name);
    target.buffer = newBuffer;
    gl.bindVertexArray(vaoWrapper.vao);
    gl.bindBuffer(gl.ARRAY_BUFFER, newBuffer);
    gl.enableVertexAttribArray(target.location);
    gl.vertexAttribPointer(target.location, target.size, gl.FLOAT, false, 0, 0);
    gl.bindVertexArray(null);
}

上記の setBufferToVAO が「Tranform Feedback で計算した頂点バッファをメッシュのVAOにバインドし直す」処理になります。

VAOをバインド → 頂点バッファをバインドする という順番になりますが、divisorの指定を再度する必要がないという点以外は、VAOを生成するときにバッファを生成してバインドする際の処理とほぼ同じに流れになっていますね。

※もしかすると、バインドしなおすのではなく頂点バッファにbufferSubDataなどを使ってデータをコピーする方が早い可能性もありそうですが、今回は試していません。

Transform Feedback 関連

function createTransformFeedback(gl, buffers) {
    const transformFeedback = gl.createTransformFeedback();
    gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, transformFeedback);
    for (let i = 0; i < buffers.length; i++) {
        const buffer = buffers[i];
        gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
        gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, i, buffer);
        gl.bindBuffer(gl.ARRAY_BUFFER, null);
    }
    gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, null);

    return transformFeedback;
}

function createTransformFeedbackDoubleBuffer(gl, vertexShader, fragmentShader, attributes, varyings, count) {
    let shader;
    const buffers = [];
    let drawCount;

    // 前フレームで更新されたVAO
    const getReadVAOWrapper = () =>  buffers[0].vertexArrayObjectWrapper; 

    // 現在フレームで更新するVAOと、現在フレームで使用する Transform Feedback
    const getWriteTargets = () => {
        return {
            vertexArrayObjectWrapper: buffers[1].vertexArrayObjectWrapper,
            transformFeedback: buffers[1].transformFeedback,
        }
    }

    const swap = () => {
        buffers.reverse();
    }

    shader = createShader(gl, vertexShader, fragmentShader, varyings);
    drawCount = count;

    attributes.forEach((attribute, i) => {
        attribute.location = i;
        attribute.divisor = 0;
    });

    const attributes1 = attributes;
    const attributes2 = attributes.map(attribute => ({...attribute}));

    const vertexArrayObjectWrapper1 = createVertexArrayObjectWrapper(
        gl,
        attributes1,
    );
    const vertexArrayObjectWrapper2 = createVertexArrayObjectWrapper(
        gl,
        attributes2,
    );

    const transformFeedback1 = createTransformFeedback(
        gl,
        vertexArrayObjectWrapper1.getBuffers()
    );
    const transformFeedback2 = createTransformFeedback(
        gl,
        vertexArrayObjectWrapper2.getBuffers()
    );

    buffers.push({
        vertexArrayObjectWrapper: vertexArrayObjectWrapper1,
        transformFeedback: transformFeedback1,
    })
    buffers.push({
        vertexArrayObjectWrapper: vertexArrayObjectWrapper2,
        transformFeedback: transformFeedback2,
    });

    return {
        getReadVAOWrapper,
        getWriteTargets,
        swap,
        shader,
        drawCount
    }
}

...

// 呼び出し例
const transformFeedbackDoubleBuffer = createTransformFeedbackDoubleBuffer(
    gl,
    vertexShader,
    fragmentShader,
    varyingNames,
    count
);

Transform Feedback 用のシェーダー

位置や速度を更新していく Transform Feedback の頂点シェーダーです。
※ もしかするとデモのURL先では数値調整など異なった内容になっているかもしれませんが、大枠は同じのはずです。

マウス/タップの位置に向かってなんとなく近づくようにしています。インスタンスごとに速度に変化を持たせたいので gl_VertexID で変化をつけます。
Transform Feedback における頂点ごとのデータなので gl_InstanceID ではなく gl_VertexID を使います。

gl_VertexID を使って乱数を生成し、その乱数を用いて速度だったり動く幅だったりを調整している、という感じになります。

#version 300 es

precision mediump float;

layout (location = 0) in vec3 aPosition;
layout (location = 1) in vec3 aVelocity;

out vec3 vPosition;
out vec3 vVelocity;

uniform vec3 uChaseTargetPosition;
uniform float uTime;
uniform float uDeltaTime;
uniform float uBaseSpeed;
uniform float uBaseAttractRate;

// ref: https://thebookofshaders.com/10/
float rand(vec2 co){
    return fract(sin(dot(co, vec2(12.9898, 78.233))) * 43758.5453); // 0 ~ 1
}

void main() {
    float fid = float(gl_VertexID);

    float hashA = rand(vec2(fid * .2, fid * .3));
    float hashB = rand(vec2(fid * .3, fid * .4));
    float hashC = rand(vec2(fid * .4, fid * .5));

    vec3 targetPositionOffset = vec3(  // 追従させたい方向を少しずらす
        cos(uTime * (hashA + hashA * 1.) + hashA * 10.) * (.6 + hashA * .2),
        sin(uTime * (hashB + hashB * 1.1) + hashB * 20.) * (.6 + hashB * .2),
        sin(uTime * (hashC + hashC * 1.2) + hashC * 30.) * (1. + hashC * .2) + 2.2
    );
    vec3 targetPosition = uChaseTargetPosition + targetPositionOffset;

    vVelocity = mix( // 追従させたい方向へmixを使って近づける
        aVelocity,
        normalize(targetPosition - aPosition) * (uBaseSpeed + hashA * hashB),
        uBaseAttractRate + hashC * .01
    );

    vPosition = aPosition + aVelocity * uDeltaTime;
}

ループ内での処理

以下、ループ内での Transform Feedback の更新まわりの抜粋になります。

        ...

        // 書き込み用の Transform Feedback と VAO を取得
        const writeBufferTargets = transformFeedbackDoubleBuffer.getWriteTargets();
        // 前フレームの vertex array object を取得
        const readVAO = transformFeedbackDoubleBuffer.getReadVAOWrapper().vao;

        gl.bindVertexArray(writeBufferTargets.vertexArrayObjectWrapper.vao);

        gl.useProgram(transformFeedbackDoubleBuffer.shader);

        // 各種 uniform を更新していく
        gl.uniform3fv(
            gl.getUniformLocation(transformFeedbackDoubleBuffer.shader, 'uChaseTargetPosition'),
            chaseTargetPosition.elements
        );

        ...

        gl.enable(gl.RASTERIZER_DISCARD); // ラスタライズ処理をスキップ

        gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, writeBufferTargets.transformFeedback);
        gl.beginTransformFeedback(gl.POINTS);
        gl.drawArrays(gl.POINTS, 0, debuggerStates.instanceCount.currentValue);
        gl.endTransformFeedback();
        gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, null);

        gl.disable(gl.RASTERIZER_DISCARD); // ラスタライズ処理のスキップを解除

        gl.useProgram(null);

        gl.bindVertexArray(null);

        // transform feedback で更新したバッファを、描画するメッシュのバッファに割り当て
        setBufferToVAO(
            boxVertexArrayObjectWrapper,
            "instancePosition",
            writeBufferTargets.vertexArrayObjectWrapper.findBuffer("position")
        );
        setBufferToVAO(
            boxVertexArrayObjectWrapper,
            "instanceVelocity",
            writeBufferTargets.vertexArrayObjectWrapper.findBuffer("velocity")
        );

        transformFeedbackDoubleBuffer.swap();  // 書き込みと読み込みをしたのでswap

        ...

        // 描画: サンプルではインデックス描画を使用しているので drawElementsInstanced を呼ぶ
        gl.drawElementsInstanced(gl.TRIANGLES, meshDrawCount, gl.UNSIGNED_SHORT, 0, debuggerStates.instanceCount.currentValue);

最後に

いかがだったでしょうか。ブラウザでも数万個単位のメッシュが描画できるのは魅力的かつ、大量描画は情報量が圧倒的で純粋に楽しいなと思います。

ここまで読んでいただきありがとうございました!

↓ 改めて、デモはこちらになります。画像かURLから飛ぶことができます

デモ: https://takumifukasawa.github.io/webgl-transform-feedback-gpu-instancing/

↓ リポジトリのURL

github.com

カヤックでは、WebGL が好きなエンジニアも募集しております!

hubspot.kayac.com

参考

wgld.org | WebGL: VAO(vertex array object) |

wgld.org | WebGL2: Transform Feedback の基礎 |

WebGL2 GPGPU