ひつじTips

技術系いろいろつまみ食います。

Shaderで囲碁を打てるようにする実装

めっちゃ前にShaderでプレイアブルな囲碁を作ったのですが、その実装の解説をしまっす。

mu-777.hatenablog.com

要は、これ↓をどう実装したか、というネタです(盤面をクリックしてみてください、石が打てます!(と思ったら、Shadertoyのiframeバグってる?動かせない場合はshadertoyへ))


だいぶ前のネタではあるのですが、久々にシェーダで遊ぼうかなと思ってて、復習がてら実装を見直してみた次第です。

時間をあけて見てみると全然シェーダっぽくない非効率的な実装になっていて参考にはならないかと思います*1が、まぁ実時間で動いてるのでいいかなと(コンパイルはちょっと長いですが。。)

実際の実装は、ShaderToyを見てください(githubにも上げてますが、ShaderToy上でしか動かないので。。)

前置き・スコープ

  • 以下、Shadertoyで使われるフラグメントシェーダのことを断りなく「シェーダ」と呼びます
  • 囲碁の基本的な知識は持っている人向けの記事になります(多少は触れますが)
  • シェーダの基本的な知識は持っている人向けの記事になります(多少は触れますが)
  • 本記事中に出てくるコードは、shardertoy上のコードと異なる場合があります
    • 記事中では少しコードをわかりやすく変更しています
  • 囲碁・シェーダに関して間違ったことを言っているかもです。そのときはごめんなさい(免責事項)

全体戦略

囲碁の実装の基本は、こちらを大変参考にさせていただきました! 以下、こちらの内容を随時参照させていただいております🙏

usapyon.game.coocan.jp


この記事を見る人は基本知ってると思いますが、囲碁は19✕19の交点に交互に石を置いていくゲームです。また、自分の石で相手の石を囲むと、囲んだ相手の石を取ることができます。

このとき、プログラム上でやるべきことは

  • 盤面を表示する(出力)
  • 石を打つことができる(着手位置の入力)
  • 石を取る処理ができる
  • 合法手(ルールに違反しない手)の判断ができる
    • 既に石のあるところには新規に打てない
    • 四方が相手の石に囲まれるところには新規に打てない
    • コウダテを打たないといけないときのコウには打てない
  • 交互に着手できる
  • 終局できる

となります(ref: 囲碁プログラムの作り方(基本編)

実際には、

  • 19✕19の二次元配列を使って、盤状態を保持(どこにどの石があるか/ないか)
  • ① マウス操作などのユーザ操作によって石を打つ
  • 新しい手が合法手かを判定し、合法であれば盤状態を更新
  • 石が囲まれているかを判定、囲まれていれば石を取った状態に盤状態を更新
  • ④ 盤状態を使って盤面を描画

のような実装を考えます。

全体フロー図

ただ、今回はこれをシェーダで実現するという謎の縛りがあります。つまり、盤状態もシェーダでバッファ(盤状態バッファ)に書きこむことで永続化する必要があります。

今回は、マルチパスシェーダ的に

  1. マウス操作で石を打ち、新しい盤状態をバッファ1に書きこむ(フロー図①・②)
  2. バッファ1を参照し、石が取れるかを判定、それを踏まえ盤状態を更新しバッファ2に書きこむ(フロー図③)
  3. バッファ2を参照し、実際の碁盤の表示を行う(フロー図④)

という多段階の処理*2にします。

盤状態バッファの作り方(内容)

盤状態バッファは19✕19の二次元配列のようなものだと言ってきましたが、囲み判定に盤外の情報が欲しく、実際には21✕21のバッファを使います。

イメージとしては、21✕21pxの画像を用意し、[1, 1] ~ [19, 19]のピクセルに盤面の情報(黒 or 白 or 空白)を、それ以外には盤外であることの情報を持たせるような感じです。

また、永続化したい情報もこのバッファに持たせる必要があります。具体的に永続化したい情報とは、

  • マウス操作の情報(今どの座標が選択されているか、ドラッグ中かどうか)
  • 今はどちらの手番か(黒 or 白)
  • コウに関する情報(コウの状態かどうか、そうであればどちらが(黒 or 白)どの座標が禁止点か)
  • アゲハマの情報(どちらに何個あるか)

あたりです。

これに加えて今回は、

  • マルチパスシェーダの2パス目で更新する必要があるか(石が打たれていなければ、囲み判定等を行う2パス目の処理はスキップできる)

も永続化します。

具体的には下図のように情報を持たせました。各pxは色情報(RGBA)を持ちますが、実際にはvec4型なので要素はx, y, z, wになります。

盤状態バッファの内訳

今回、永続化したい情報は、21✕21px画像の要素の盤外のうち盤外判定に使わない角([0, 0], [0, 20], [20, 0], [20, 20])に格納しました。
(実際にshardertoyのバッファは21x21pxではないので必ずしも21✕21に収める必要はありませんが、、)

盤状態バッファの作り方(実装)

上述の通り、盤状態バッファには2つ必要です。

Shadertoyでは、「+」ボタンからバッファが最大4つ作れます。今回は2つ必要なのでBuffer ABuffer Bを作ります。

shadertoyでバッファを作る

Buffer BのシェーダはBuffer Aを参照する必要があります。そのため、Buffer Bを選択した状態でUI下部のiChannel0Buffer Aを登録します。これによってtexelFetch(iChannel0, ivec2, 0)でバッファ要素にアクセスできるようになります。

また、Buffer Aのシェーダは前フレームの盤状態、つまりBuffer Bを参照する必要があります。同様に、Buffer Aを選択した状態でUI下部のiChannel0Buffer Bを登録します。

Buffer AのiChannel0にBuffer Bを登録する

細かい実装は、Shadertoy上で確認ください。

盤の描画と座標系

盤をきれいに描画し、管理しやすくするためには、うまく座標系を持つことが重要です。

以下、盤状態バッファがある前提で、素の盤・石・アゲハマの描画とその座標系についてまとめます。

実装としては、メインとなるImageシェーダにあたります。

github.com

基本の座標系

まず、画面の中に盤を表示する場所を決めないといけません。このとき、うまく座標系を定義してあげると描画が楽になります。

今回は、下図のような盤面座標系(BoardCoord)を定義しました。

盤面座標系(BoardCoord)

改めてですが、フラグメントシェーダはピクセルごとに処理が並列実行されます。その処理とは、処理対象のピクセルの座標位置を受け取り、そのピクセルが何色かを返すものです。

盤面座標系を使うには、入力となる処理対象の座標位置(fragCoord.xy)を盤面座標系における位置に変換します。fragCoordは、画面左下を原点・右が+X方向・上が+Y方向となる座標系で定義されています。

今回は、盤のサイズ[px](boardSizePx)を、画面の縦or横の小さい方の90%のサイズとして定義しておき、

float boardSizePx = min(iResolution.x, iResolution.y) * 0.9;


fragCoordを、原点が中心・右が+X方向・下が+Y方向、とした座標系(centerPxCoord)に変換しておいた上で、boardSizePxの半分をオフセットし、0~19に正規化します

vec2 centerPxCoord = vec2(fragCoord.x - 0.5*iResolution.x, 0.5*iResolution.y - fragCoord.y);
vec2 boardCoord = (centerPxCoord + vec2(boardSizePx*0.5)) / (boardSizePx/19)


これで、盤面左上を原点・右が+X方向・下が+Y方向の盤面座標系における、処理対象の座標位置(boardCoord)が得られます。

以下は、この盤面座標系を使って盤面を描画していきます。

素の盤の描画

これ↓を描きます。具体的には、①正方形の盤面上に②縦線/横線③星(9つの黒丸点)を描画します。

素の盤

①正方形の盤面は、盤面座標系の[0, 0] ~ [19, 19]の内側であれば盤の色を返すようにします

if (boardCoord.x >= 0.0 && boardCoord.x <= 19.0 
        && boardCoord.y >= 0.0 && boardCoord.y <= 19.0) {
    fragColor = boardColor
}


②縦線/横線は、盤面座標系を詳細に見てみると、盤面座標系の各マスの中心線を描いていけばよさそうです。

盤面座標系と盤面の描画

シェーダでの繰り返し描画は、小数部を返す関数(glslではfract、hlslではfracを使うと簡単にできます。詳しい説明は以下のページの「図形を複製してパターンを描画する」にまとまっているので参考にしてください。

nn-hokuson.hatenablog.com


③星(9つの黒丸点)は、盤面座標系を原点中心に変換(boardCenteredCoordしたうえで、absを使うことで1つの象限での描画を点対称に描画させることができます。

boardCenteredCoordでは、[0, 0](天元)、[6, 6]、[6, 0]、[0, 6]を中心とした丸を描画します。(丸の描画は石の描画のところで触れます)

float boardCoordToPx = boardSizePx/19.0
vec2 boardCenteredCoord = boardCoord - vec2(ibc.boardNum*0.5);

if(length(boardCenteredCoord)*boardCoordToPx< boardStarRadiusPx
    || length(abs(boardCenteredCoord) - vec2(6.0, 6.0))*boardCoordToPx < boardStarRadiusPx
    || length(abs(boardCenteredCoord) - vec2(6.0, 0.0))*boardCoordToPx < boardStarRadiusPx
    || length(abs(boardCenteredCoord) - vec2(0.0, 6.0))*boardCoordToPx < boardStarRadiusPx
  ){
  fragColor = boardLineColor;
}

石の描画

シェーダで丸を描画するところから考えます。

シェーダでは、入力である処理対象のピクセルの座標位置が丸の内側かどうかを判定し、内側なら色を返すようにします(参考: 【Unityシェーダ入門】シェーダだけで描く図形10選 - おもちゃラボ 『円を描く』)

例えば[100, 100]pxを中心とする半径10pxの赤い丸を描画する場合、処理対象のピクセルの座標位置と[100, 100]の間の距離を算出し、それが10以下かどうかで内外判定をします。

今回は、まず処理対象のピクセルが、どの盤上位置([1, 一] ~ [19, 十九])にあたるかを算出します。これは、floorで小数部を切り捨てて1を足すことでわかります。

vec2 boardPos = ivec2(int(floor(boardCoord.x)) + 1, int(floor(boardCoord.y)) + 1)

盤面座標系から、盤の位置を算出する


盤上位置がわかれば、盤状態バッファから石が置かれているかどうかを取得できます。

float boardState = texelFetch(iChannel0, boardPos, 0).w;  // 0.1=空白、0.2=黒、0.3=白


石が置かれていればその交点位置を盤面座標系で表現(pointAtBoardCoord)し、処理対象のピクセル(boardCoord)との距離が石の半径以下であれば色を塗るようにします。

vec2 pointAtBoardCoord = vec2(boardPos) - vec2(0.5);  // [1, 一]~[19, 十九] to (0.0, 0.0)~(19.0, 19.0)
if(length(boardCoord.xy - pointAtBoardCoord.xy)*boardCoordToPx < stoneRadiusPx ){
  fragColor = stoneColor;  // 黒 or 白
}

アゲハマの描画と座標系

盤の外側にアゲハマの石を描きます。

右下に先手(黒番)の、左上に後手(白番)のアゲハマの石が描画される

今回は、盤面座標系を盤面外にも拡張し、盤面座標系における画面の右下・左上の座標(screenRectInBoardCoord.xy, screenRectInBoardCoord.zw)を算出、そこからアゲハマの数だけ順番に石を描画するようにしました。

石の描画処理自体は、盤面上に描画するのと変わらない(描画位置が[1, 一]~[19, 十九]の外側になるだけ)ので楽ちんです。

アゲハマの描画エリアの座標系

石を打つ

実装としては、Buffer Aにあたります。

github.com

これはそれほどややこしくなく、Shadertoyのマウス入力のiMouseを使うだけです。

iMouse.xyで画面座標系におけるカーソル位置が取得できるので、例によって盤面座標系(BoardCoord)における位置に変換してやり、その位置を保持します。

また、今回は盤面上をドラッグすると石を打つ前に半透明の石が表示されるようにしたので、iMouse.zからマウスの状態(Press or not)を確認し、また前フレームの状態も盤状態バッファに保持してドラッグ中か、クリックしたか、クリックしていないか、も判定しています。

このへんはシェーダならでは、という感じでもないので詳細は省きます。

囲みに関する処理

ここは結局シェーダの良さを活かせない、冗長な実装になってしまっています。。が、現状のメモということで、現状の実装内容について記載します。

実装としては、Buffer Bにあたります。

github.com

まず、囲みに関する条件を整理します。

  • 最新の手(もしくはそれにつながっている自分の石すべて)が、相手石に囲まれていれば打つことはできない
  • ただし、相手石に囲まれていたとしても、最新の手によって相手石を囲むことができていれば打つことができる
  • ただし、最新の手で相手石を囲めても、その手がコウであれば打つことはできない

うーん、ややこしい。ですが、処理しやすいように言い換えると

  • コウの着手禁止手であれば、そこは打てない
  • コウでない場合、最新手で相手石を囲めていれば、そこは打てる
  • コウでなく、相手石を囲めない場合、最新手によって相手石に囲まれることになれば、そこは打てない

という判定フローにできそうです。

コウの判定

盤状態バッファの[20, 0]にコウの着手禁止手の座標と対象の手番(黒 or 白)がキャッシュされているので、最新の手がそれに当てはまればNGとします。

このキャッシュの更新は囲み判定のところで行います。石を囲んでいて、かつ囲まれている石が1つだけのとき、その取られる石の位置を着手禁止手として盤状態バッファに書き込みます。

この場合、実際にはコウの状態でなくてもコウの着手禁止手としてキャッシュに書かれますが、どちらにせよ着手禁止の手にはなるので気にしないことにします。

赤点線部に白石があったが黒の手で取られたとき、左はコウで右はコウではないが、どちらの場合でもコウの着手禁止手として赤点線部の位置がキャッシュされる。そして、どちらも場合でも次の手で赤点線部に打つことはできない

コウに関するキャッシュは、キャッシュされている対象の手番でなくなった時点でクリアされます。

囲みの判定

コウでなければ、次は最新の手によって相手石を囲めているかを判定する必要があります。

まず、ある盤上位置([1, 一] ~ [19, 十九])を入力とし、その石、またはそれが連結しているすべての石が相手石に囲まれているかを判定する関数(IsAroundByTheOther関数)を用意します。

IsAroundByTheOther関数は、以下のように処理します。

  • 入力の盤上位置の上下左右に空白があるかを確認
    • 空白があれば、Falseを返し終了(相手に囲まれていない)
  • 囲まれている場合、何に囲まれているか(黒 or 白 or 盤外)を取得
    • 囲まれている石がすべて相手石 or 盤外なら、Trueを返し終了(相手に囲まれている)
  • 囲まれている石のうち、自分の石(=連結している石)の盤上位置を配列(willCheck)にキャッシュ
  • 囲まれている石の位置をキャッシュする配列(aroundedStones)を用意し、willCheckが空になるまで以下の処理をWhileで回す
    • willCheckからチェック対象の盤上位置(targetPos)をpop
    • targetPosaroundedStonesに含まれているか確認、含む場合はcontinue(多重にチェックすることを防ぐ)
    • targetPosの上下左右の4つがaroundedStonesに含まれているか確認し、含まれていない位置は空白かを確認
      • 空白があれば、Falseを返し終了(相手に囲まれていない)
    • 空白がない場合targetPosaroundedStonesにpushし(targetPosは囲まれている)、targetPosの上下左右の4つのうち自分の石の位置はwillCheckにpushする(調査対象に加える)
  • willCheckが空になったらTruearoundedStonesを返す(囲まれている+囲まれている石の位置のリストを返す)

シンプルに、上下左右を次々とチェックしていくような処理になります。本当は再帰処理のようにすべきですが、確か再帰関数が使えず、上記のような処理にしています。

IsAroundByTheOther関数があれば、最新の手によって相手石を囲めているかの判定は簡単で、

  • 最新の手の上下左右4つが相手石かを確認
  • 相手石の場合はIsAroundByTheOther関数で囲めているか確認

とすればOKです。

終わりに

なんか19x19の配列でステートを管理したり,それを可視化したりするのってシェーダは得意そう、という気持ちでやってみてましたが、結果的には全然シェーダには向いていない処理ばかりで、結局並列性は活かせず、無駄な処理が多くコンパイルの長い冗長な実装になってしまいましたね。。

が、チマチマ実装するのは楽しいのでまぁOKです👍 また何か楽しいシェーダを書けるといいな。。

*1:誰が何の参考にするためにこのエントリを見るんでしょうかw

*2:1パスと2パスを1つにすることは恐らくできるのですが、石が囲まれているかの判定が複雑になるので分けています。まず仮置きした盤状態を作った上で、それをベースに囲みの判定をするのが楽だからです。頑張れば1パスで書けるかも?どなたか試してほしい。。