【Godot】Godot 4.2のコンピュートシェーダで遊ぼう

Godot Advent Calendar 2023の6日目の記事です。

Godot Engine 4.2がリリースされましたね。めでたい!🎉

2023年は4.0から始まり、3回の大型アップデートがありました。

godotengine.org

さて4.2アップデート内容に気になる項目はありましたか?

個人的には沢山ありますが、中でもコンピュートシェーダのところが目を引いたので解説していきたいと思います。

コンピュートシェーダとは名前の通り計算するシェーダです。GPUで処理するので単純で大量の計算をするような用途が得意です。 例えば万単位の大量のオブジェクトを動かしたり、物理シミュレーション、大きい画像の解析処理などに使われます。

コンピュートシェーダの強化

まず経緯を説明します。 Godot 4.0からコンピュートシェーダは使えるようになったのですが、これをリアルタイムレンダリングで活用するにはイマイチでした。 大量のオブジェクトを更新したり、水面の波動を計算してレンダリングに反映させることが難しかったのです。

Godot 4.2より前

Godot 4.2より前のコンピュートシェーダの使用手順は次の通り。

  • RenderingDeviceを作成
  • バッファなどリソースを作成。CPUからデータを転送する
  • コンピュートシェーダで計算処理。結果はGPUに出力される
  • 結果のGPU側のデータをCPU側に転送する

レンダリングではない用途や事前処理系ならこれでも問題ないのですが、コンピュートシェーダの結果を利用してリアルタイムレンダリングに活用しようとすると、RenderingServerへデータを転送する必要があります。これではCPUとGPU間でデータが毎フレーム行ったり来たりするという無駄が発生してしまい、せっかくのパフォーマンスが落ちてしまいますね。

Godot 4.2以降

Godot 4.2ではコンピュートシェーダについて、新しく次の3つのことができるようになりました。

  • RenderingServer内のRenderingDeviceを利用できる機能
  • コンピュート処理とレンダリングを同期する機能
  • コンピュート出力したテクスチャをレンダリングで使う機能

他のエンジンだと普通にできていることだったりするので若干いまさら感ありますが、これでGodotでもまともにリアルタイムレンダリング用途でコンピュートシェーダを使っていけるようになったということです。

コンピュートシェーダの使い方

基本的にはこちらに書いてあります。(英語)

docs.godotengine.org

…が、ここに書かれている方法は従来の使い方で、コンピュートシェーダの出力をレンダリングで活用することはできません。

2023年12月現在、公式ドキュメントは追いついていなさそうです。そこでBastiaanOlij氏が作った公式のデモを見てみましょう。

github.com

実行してみます。画面をクリックすると波が発生して広がっていくデモでした。

デモの中身を見てみる

こんな感じ(エディタ上で波が起きている…)

デモのコードを見る前に、水面を波が伝わるやり方について簡単に解説します。下の図のように特定の地点の水面の高さをグリッド的に配置しておき、毎フレーム隣接している水面の高さに影響を与えていけばそれっぽい挙動になります。

さてコードはres://water_plane/water_plane.gdなので見てみましょう。コメントは意訳しています。

コンピュートシェーダとバッファの初期化

まず_ready()を見てみます。

var texture : Texture2DRD
var next_texture : int = 0

func _ready():
    # レンダリングスレッドで`_initialize_compute_code`を呼び出す
    RenderingServer.call_on_render_thread(_initialize_compute_code.bind(texture_size))

    # 子のMeshInstance3Dノードからマテリアルを取得
    var material : ShaderMaterial = $MeshInstance3D.material_override
    if material:
        # マテリアルにテクスチャサイズを指定
        material.set_shader_parameter("effect_texture_size", texture_size)
        # マテリアルからTexture2DRDを取得
        texture = material.get_shader_parameter("effect_texture")

RenderingServer.call_on_render_threadはGodot 4.2から使用できる新しいAPIです。 コンピュートシェーダの初期化処理はレンダリングスレッドで行わせるため、このAPI_initialize_compute_codeを登録しています。

また、マテリアルからTexture2DRDを取得しています。ここはあらかじめインスペクタからTexture2DRDが指定されていたものです。別にTexture2DRD.new()で新しく作ってmaterial.set_shader_parameterでもいいと思います。

次に_initialize_compute_codeを含むコンピュートシェーダの初期化周りのコードを見てみます。

var rd : RenderingDevice

var shader : RID  # コンピュートシェーダ
var pipeline : RID  # コンピュートパイプライン

# 波の高さを表すバッファとして使うテクスチャ
# - 1つは現在レンダリングするフレームで使われる
# - 1つは前回レンダリングされたフレームで使われたもの
# - 1つは前々回レンダリングのフレームで使われたもの
var texture_rds : Array = [ RID(), RID(), RID() ]
var texture_sets : Array = [ RID(), RID(), RID() ]

func _create_uniform_set(texture_rd : RID) -> RID:
    var uniform := RDUniform.new()
    uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_IMAGE
    uniform.binding = 0
    uniform.add_id(texture_rd)
    # Even though we're using 3 sets, they are identical, so we're kinda cheating.
    return rd.uniform_set_create([uniform], shader, 0)

func _initialize_compute_code(init_with_texture_size):
    # レンダリングフレーム中にレンダリングスレッドから呼ばれる
    # RenderingServerからRenderingDeviceを借りてくる
    rd = RenderingServer.get_rendering_device()

    # コンピュートシェーダを作成
    var shader_file = load("res://water_plane/water_compute.glsl")
    var shader_spirv: RDShaderSPIRV = shader_file.get_spirv()
    shader = rd.shader_create_from_spirv(shader_spirv)
    pipeline = rd.compute_pipeline_create(shader)

    # 波の高さを表すバッファのテクスチャを作成
    var tf : RDTextureFormat = RDTextureFormat.new()
    tf.format = RenderingDevice.DATA_FORMAT_R32_SFLOAT
    tf.texture_type = RenderingDevice.TEXTURE_TYPE_2D
    tf.width = init_with_texture_size.x
    tf.height = init_with_texture_size.y
    tf.depth = 1
    tf.array_layers = 1
    tf.mipmaps = 1
    tf.usage_bits = RenderingDevice.TEXTURE_USAGE_SAMPLING_BIT + RenderingDevice.TEXTURE_USAGE_COLOR_ATTACHMENT_BIT + RenderingDevice.TEXTURE_USAGE_STORAGE_BIT + RenderingDevice.TEXTURE_USAGE_CAN_UPDATE_BIT + RenderingDevice.TEXTURE_USAGE_CAN_COPY_TO_BIT

    for i in range(3):
        # RDテクスチャを作成してクリア
        texture_rds[i] = rd.texture_create(tf, RDTextureView.new(), [])
        rd.texture_clear(texture_rds[i], Color(0, 0, 0, 0), 0, 1, 0, 1)
        # テクスチャを参照するUniformSetを作成
        texture_sets[i] = _create_uniform_set(texture_rds[i])

コンピュートシェーダ

本体となるコンピュートシェーダ(water_plane.glsl)のコードがこちらです。

#[compute]
#version 450

// 8x8単位で処理する
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;

// 現在のバッファ、前回のバッファ、出力用のバッファ
layout(r32f, set = 0, binding = 0) uniform restrict readonly image2D current_image;
layout(r32f, set = 1, binding = 0) uniform restrict readonly image2D previous_image;
layout(r32f, set = 2, binding = 0) uniform restrict writeonly image2D output_image;

// 制御用の情報
layout(push_constant, std430) uniform Params {
    vec4 add_wave_point;
    vec2 texture_size;
    float damp;
    float res2;
} params;

void main() {
    ivec2 tl = ivec2(0, 0);
    ivec2 size = ivec2(params.texture_size.x - 1, params.texture_size.y - 1);
    ivec2 uv = ivec2(gl_GlobalInvocationID.xy);

    // 領域外の読み書きをしないようにする処理
    if ((uv.x > size.x) || (uv.y > size.y)) {
        return;
    }

    // バッファから読み込む
    float current_v = imageLoad(current_image, uv).r;
    float up_v = imageLoad(current_image, clamp(uv - ivec2(0, 1), tl, size)).r;
    float down_v = imageLoad(current_image, clamp(uv + ivec2(0, 1), tl, size)).r;
    float left_v = imageLoad(current_image, clamp(uv - ivec2(1, 0), tl, size)).r;
    float right_v = imageLoad(current_image, clamp(uv + ivec2(1, 0), tl, size)).r;
    float previous_v = imageLoad(previous_image, uv).r;

    // 前回の波の高さと周囲の波の高さから、新しい波の高さを計算する
    float new_v = 2.0 * current_v - previous_v + 0.25 * (up_v + down_v + left_v + right_v - 4.0 * current_v);
    new_v = new_v - (params.damp * new_v * 0.001);

    // 新しい衝撃を加える
    if (params.add_wave_point.z > 0.0 && uv.x == floor(params.add_wave_point.x) && uv.y == floor(params.add_wave_point.y)) {
        new_v = params.add_wave_point.z;
    }

    if (new_v < 0.0) {
        new_v = 0.0;
    }
    vec4 result = vec4(new_v, new_v, new_v, 1.0);

    // 結果をバッファに書き込む
    imageStore(output_image, uv, result);
}

コンピュートシェーダの拡張子はglslです。なぜか現時点でGodotのエディタでは開くことができないです。(ナンデ?) コンパイルが成功したということは教えてくれますが、編集するにはVSCodeなど別のエディタを使いましょう。

コンピュート処理の実行とバッファの更新

さて実際にコンピュートシェーダを実行するコード周りを見ていきましょう。

func _process(delta):
    # 今回使用するテクスチャの番号を更新する
    next_texture = (next_texture + 1) % 3

    # 描画に使用するテクスチャを更新する
    # `_initialize_compute_code` はまだ実行されていないかもしれないので、最初のフレームは空のRIDになることに注意
    if texture:
        texture.texture_rd_rid = texture_rds[next_texture]

    # `render_process`をレンダリングスレッドで実行する
    # `_render_process`は並列に実行される可能性があるため、必要なパラメータを引数として渡しています
    RenderingServer.call_on_render_thread(_render_process.bind(next_texture, add_wave_point, texture_size, damp))

_render_processはおそらく描画前にレンダリングスレッドで呼ばれるため、描画と同期してコンピュートシェーダは実行されます。

func _render_process(with_next_texture, wave_point, tex_size, damp):
    # シェーダ内で使用している構造体を作る
    var push_constant : PackedFloat32Array = PackedFloat32Array()
    push_constant.push_back(wave_point.x)
    push_constant.push_back(wave_point.y)
    push_constant.push_back(wave_point.z)
    push_constant.push_back(wave_point.w)

    push_constant.push_back(tex_size.x)
    push_constant.push_back(tex_size.y)
    push_constant.push_back(damp)
    push_constant.push_back(0.0)

    # Dispatchグループのサイズを計算
    # シェーダ内でlocal_size_x,local_size_yの数値で割る
    var x_groups = (tex_size.x - 1) / 8 + 1
    var y_groups = (tex_size.y - 1) / 8 + 1

    var next_set = texture_sets[with_next_texture]
    var current_set = texture_sets[(with_next_texture - 1) % 3]
    var previous_set = texture_sets[(with_next_texture - 2) % 3]

    # シェーダ内部で使用するUniformSetを指定してコンピュートシェーダを実行
    var compute_list := rd.compute_list_begin()
    rd.compute_list_bind_compute_pipeline(compute_list, pipeline)
    rd.compute_list_bind_uniform_set(compute_list, current_set, 0)
    rd.compute_list_bind_uniform_set(compute_list, previous_set, 1)
    rd.compute_list_bind_uniform_set(compute_list, next_set, 2)
    rd.compute_list_set_push_constant(compute_list, push_constant.to_byte_array(), push_constant.size() * 4)
    rd.compute_list_dispatch(compute_list, x_groups, y_groups, 1)
    rd.compute_list_end()

    # 今回は同期のためのバリアは必要ない(Godotが描画で使う際に自動的に内部のバリアが使われるため)
    # ただし、コンピュートシェーダーの出力を別のコンピュートシェーダーの入力として使いたい場合は、バリアを追加する必要がある
    #rd.barrier(RenderingDevice.BARRIER_MASK_COMPUTE)

コンピュートシェーダとバッファの破棄

コンピュートシェーダのリソースの破棄は手動で行う必要があります。そしてまたレンダリングスレッドで破棄する必要があります。少し面倒ですね。

func _exit_tree():
    # 描画用のテクスチャに無効なRIDを指定して参照されないようにする
    if texture:
        texture.texture_rd_rid = RID()

    # レンダリングスレッドで`_free_compute_resources`を呼び出す
    RenderingServer.call_on_render_thread(_free_compute_resources)

func _free_compute_resources():
    # バッファ用テクスチャを破棄 (UniformSetは自動的に解放されるらしい)
    for i in range(3):
        if texture_rds[i]:
            rd.free_rid(texture_rds[i])

    # コンピュートシェーダを破棄 (Pipelineは自動的に解放されるらしい)
    if shader:
        rd.free_rid(shader)

コンピュート結果をレンダリングで使う

コンピュートシェーダで計算した波の高さを水面のレンダリングで活用します。 シェーダソースはwater_shader.gdshaderです。これは普通のGodotシェーダですね。

shader_type spatial;
render_mode blend_mix, depth_draw_opaque, cull_back, diffuse_burley, specular_schlick_ggx;

uniform vec3 albedo : source_color;
uniform float metalic : hint_range(0.0, 1.0, 0.1) = 0.8;
uniform float roughness : hint_range(0.0, 1.0, 0.1) = 0.2;
uniform sampler2D effect_texture;  // コンピュート結果のバッファをテクスチャとして使う
uniform vec2 effect_texture_size;  // コンピュート結果のバッファのテクスチャサイズ

varying vec2 uv_tangent;
varying vec2 uv_binormal;

void vertex() {
    vec2 pixel_size = vec2(1.0, 1.0) / effect_texture_size;

    uv_tangent = UV + vec2(pixel_size.x, 0.0);
    uv_binormal = UV + vec2(0.0, pixel_size.y);
}

void fragment() {
    // その場所の波の高さと、隣接の波の高さを読み込む
    float f1 = texture(effect_texture, UV).r;
    float f2 = texture(effect_texture, uv_tangent).r;
    float f3 = texture(effect_texture, uv_binormal).r;

    // 波の高さを水面の傾きとして法線を計算(ライティングはGodotにおまかせ)
    vec3 tangent = normalize(vec3(1.0, 0.0, f2 - f1));
    vec3 binormal = normalize(vec3(0.0, 1.0, f3 - f1));
    NORMAL_MAP = normalize(cross(binormal, tangent)) * 0.5 + 0.5;

    ALBEDO = albedo.rgb;
    METALLIC = metalic;
    ROUGHNESS = roughness;
    SPECULAR = 0.5;
}

まとめ

Godot 4.2以降のコンピュート処理の結果をレンダリングで使う方法のまとめです

  • コンピュート関連の初期化、更新処理、終了処理はRenderingServer.call_on_render_threadレンダリングスレッドから実行する
  • コンピュート処理を行うためのRenderingDeviceはRenderingServer.get_rendering_device()から取得する
  • コンピュート結果のテクスチャはTexture2DRDtexture_rd_ridにセットする

草を靡かせてみる

ここからは応用編です。

こちらの記事を参考に。Godotで似たようなことをしてみましょう。

nagakagachi.hatenablog.com

完成したものがこちら

youtu.be

GitHubでGodotプロジェクト一式を公開しています。

github.com

完成物を少し解説

草の動き(コンピュート処理)

基本的に波を更新する部分は元と同じですが、そのままだと草がプルプルしすぎてキモイのでもう少し自然に動くように調整しています。

  • 動きの加速度を調整して草がゆっくりと動くようにする
  • 草の動きはそこまで広がらないので減衰しやすくする
  • プレイヤーのコライダーで継続的に力が加わるようにする

草のレンダリング

  • 草のレンダリングはMeshMultiInstance3Dを使用
  • 草1本の三角錐のメッシュを大量にインスタンス描画する
  • コンピュート結果のテクスチャを頂点シェーダで使い草に動きを付ける

草1本のベースメッシュを生成して、MultiMeshのインスタンスを量産するコードは次の通りです。

func _initialize_mesh() -> void:
    # 草1本を表現する三角錐の細長いメッシュを作る
    var st = SurfaceTool.new()
    st.begin(Mesh.PRIMITIVE_TRIANGLES)

    for i in range(3):
        var t1 := i * PI * 2 / 3
        var t2 := (i + 1) * PI * 2 / 3
        var v0 := Vector3(0.0, 1.0, 0.0)
        var v1 := Vector3(cos(t1), 0.0, sin(t1)) * 0.04
        var v2 := Vector3(cos(t2), 0.0, sin(t2)) * 0.04
        st.set_normal((v2 - v0).normalized().cross((v1 - v0).normalized()))
        st.add_vertex(v0)
        st.add_vertex(v1)
        st.add_vertex(v2)

    instance.multimesh.mesh = st.commit()

    # Area3D内を埋め尽くす草のインスタンスを作成する
    var box_shape: BoxShape3D = shape
    var area_size := box_shape.size
    var area_step := Vector3(area_size.x / count_x, 0.0, area_size.z / count_z)
    var area_offset := -area_size / 2 + Vector3(area_step.x * 0.5, 0.0, area_step.z * 0.5)

    instance.multimesh.instance_count = count_x * count_z
    for z in range(count_z):
        for x in range(count_z):
            var index := x + z * count_x
            # 草1本のトランスフォームを作成
            var xform := Transform3D()
            # 草が生える位置の中心を計算
            xform.origin = area_offset + Vector3(x * area_size.x / count_x, 0.0, z * area_size.z / count_z)
            # ランダムで草が生える位置を散らす
            xform.origin += area_step * Vector3(randf_range(-0.5, 0.5), 0.5, randf_range(-0.5, 0.5))
            # ランダムで草の長さを散らす
            xform.basis = xform.basis.scaled(Vector3.ONE * randf_range(0.5, 0.8))
            # ランダムで草が生える角度を散らす
            xform.basis *= Basis.from_euler(Vector3(randf_range(-PI/16, PI/16), randf_range(-PI, PI), randf_range(-PI/16, PI/16)), EULER_ORDER_YXZ)
            # インスタンスに作成したトランスフォームを指定
            instance.multimesh.set_instance_transform(index, xform)

まとめ

コンピュートシェーダ楽しい ✌('ω'✌ )三✌('ω')✌三( ✌'ω')✌

草の実装について、YouTubeを探すと詳しい動画がいくつか見つかります。 www.youtube.com