AwesomeWM上でZoomを使っているときにポップアップウィンドウに邪魔されないようにする

2021/08/21 追記

まる一日運用してみたところ色々とバグがあったので全体的に更新した。

Motivation

AwesomeWMのタイルレイアウト上でZoomを使っていると、 "You are co-host now" や "hoge has started screen sharing" のようなポップアップメッセージが出たときにウィンドウレイアウトが大きく変わってしまい、かつそのポップアップウィンドウがフォーカスを持っていってしまうため、作業が中断される。 Zoomのポップアップウィンドウ これは大変に鬱陶しい。 幸い、AwesomeWMはウィンドウ(AwesomeWM用語としてはclient)が生成されるたびにコールバックを通してウィンドウのプロパティをいじれるので、その機能を使ってできるだけ邪魔をされないようにする。

Methods

$HOME/.config/awesome/rc.lua に以下の通り設定することで、Zoomのポップアップウィンドウが生成されたときに

  • floatingになる(既存のウィンドウレイアウトが崩れない)
  • focusされない(作業の邪魔にならない)

ようになる。

awful.rules.rules = {
  -- ... 既存の設定 ...

  {
    rule = { class = "zoom", type = "normal", name = "zoom" },
    properties = { focus = false, focusable = false, floating = true },
    -- 08/21 以下追記
    callback = function(c)
      f = function(_c)
        _c:disconnect_signal("property::name", f)
        if _c.name ~= "zoom" then
          _c.focus = false
          _c.focusable = true
          _c.floating = false
        end
      end
      c:connect_signal("property::name", f)
    end
  },
}

解説 & Tips

基本的にはawful.rulesのAPI docを読めば必要なことは書いてある。 新しいclientが生成されたとき(manage シグナルが発行されたとき)、rule = {...} にマッチするものについて、指定された properties を適用したり callback を実行したりできる。

したがって、新しいruleを追加するためには、操作したいclientのclass/type/name等の属性を知る必要がある。 このとき、簡便にこれらの情報を表示させるには以下のようにdesktop notificationを発行してしまうのが便利だと思う。

client.connect_signal("manage", function (c)
  naughty.notify({ preset = naughty.config.presets.critical,
                   title = "New client",
                   text = c.class .. ":" .. c.type .. ":" .. c.name })
end)

当然AwesomeWMのログに出力しても良いのだが、notificationだとその場でパッと確認できるのでやはり便利である。 ちなみに、notificationのレベルには critical normal low の3段階が(デフォルトで)用意されているが、 low または normal を設定すると発行後数秒で消えてしまい確認が困難になるため critical を使うのが良い。

このnotificationにより、対応すべきclientは class = "zoom", type = "normal", name = "zoom" であるとわかる。 ここで、 name = "zoom" とするのは対象が広くなりすぎるリスクがあるように感じるのだが、どうやら他のZoom関連のウィンドウはすべて "Zoom hogefuga" というように、頭のZが大文字で、かつ何らかの文字列が後ろに続くため、問題にならないようである。 このあたりは環境依存だったりZoomのバージョン依存であったりする可能性は否定されないので、なにか不具合が起こる可能性は残っている。

08/21 追記

やはり name = "zoom" で一網打尽にしてしまうのは問題があった。 というのも、Zoomのクライアントソフトウェアを立ち上げると "Zoom - Free Account" という name を持つメインウィンドウが表示されるのだが、これは一瞬 "zoom" という name のウィンドウが生成された直後にrenameされるものであるようだ。 awful.rules で設定した規則はウィンドウの生成時に解釈・適用されるため、この時点での name に依存して floating = true とか focusable = false が適用されてしまい、Zoomのメインウィンドウを操作できなくなってしまう。 そこで、 property::name イベントに対するコールバック関数として、名前が "zoom" でなくなったら属性を再度変更するという処理を行うことにした。

おまけ(08/21 更新)

以下のように設定すると、そもそもポップアップウィンドウが生成されようとした瞬間にcloseする(i.e., 現れなくする)こともできると考えたが、上記と同様の理由により、この設定を行うとZoomのメインウィンドウ自体が閉じられてしまうのでボツとなった。

awful.rules.rules = {
  -- ... 既存の設定 ...

  {
    rule = { class = "zoom", type = "normal", name = "zoom" },
    callback = function(c)
      c:kill()
    end
  }
}

X Window Systemを使ってリモートサーバでOpenGLなプログラムを走らせる方法

動機

SSHでログインしたリモートサーバ(SSHサーバ)上でOpenGLによるGUIアプリケーションを動かし、ローカルコンピュータ(SSHクライアント)の画面に表示したい。ときのメモ書き。

TL; DR

  • SSHクライアント(Xサーバ)側で、X起動時に+iglxオプション。
  • ssh -XYでログインする。
  • ローカルとリモートでlibGL.soの系列を揃える必要がある。ローカルがmesaならリモートもmesaに合わせる。LD_PRELOAD環境変数を使う。

参考

基本事項

X Window System

『ビットマップディスプレイ上でウィンドウシステムを提供する表示プロトコル』(wikipediaより引用)。GUIを構築するための基本的なツールキットを提供する。実際に絵が表示される画面を管理する「Xサーバ」をデーモンとして実行し、各GUIプログラム(GnomeやAwesomeWMのようなウィンドウマネージャやその他のGUIアプリケーションなど)は「Xクライアント」となってXサーバに描画命令を送信する。Xサーバ・Xクライアント間の通信プロトコルX Window Systemの一部として規定されている。

LinuxGUIマシンとして用いる場合、普通はX Window Systemのリファレンス実装であるXorgを使っていると思って差し支えない(2018/01現在。今後Waylandとか新しいのが主流になるかもしれないがここでは割愛)。

なお、XサーバとXクライアントは別のマシンで動いていても良い。ただし、ローカルコンピュータ(SSHクライアント)からリモートサーバ(SSHサーバ)にアクセスし、リモートサーバ上で実行したGUIアプリケーションの画面をローカルコンピュータに表示する場合、X Window Systemの観点から見るとローカルコンピュータで「Xサーバ」デーモンが実行されており、リモートサーバ上で動かすGUIアプリケーションが「Xクライアント」となってこのXサーバに接続することになる。SSHサーバとSSHクライアントの関係とは逆向きなので混乱しないよう注意。

OpenGL

2D/3DのコンピュータグラフィックスAPI。描画のコア機能はソフトウェアライブラリではなく対応するGPU上にハードウェア実装されるが、一般に「OpenGL」と呼ぶときはこのハードウェア実装をソフトウェアから利用するためのAPI群を指す。

GLX

Xサーバが動いているものと同じマシンでOpenGLアプリケーションを実行するとき、このアプリケーションはXクライアントとしてウィンドウの「枠だけ」はXサーバに管理してもらいつつ*1、その「枠」の中に表示する絵(つまり3D CG)はGPUに直接(X Window Systemを介さず)描画命令を送ってレンダリングする。これをdirect renderingと呼ぶ。

ここで、XサーバとOpenGLアプリケーション(Xクライアント)が異なるコンピュータで動いている場合、当然OpenGLアプリケーションはXサーバが動いているマシンのGPUに直接アクセスすることはできず、Xの通信プロトコルの枠組みの中でGPUに描画命令を送る必要がある。このようにXを介してOpenGLの描画命令を送ることをindirect renderingと呼び、このindirect renderingを実現するためにXの通信プロトコルを拡張したAPI定義がGLXである。

Xサーバ(ローカルコンピュータ)の設定

記事執筆時点で最新のXorgは1.19系列だが、Xorg1.17以降、Xサーバと別のマシンで動くXクライアントからのGLXアクセスはデフォルトで拒否するようになっているので、これを許可する。これは、Xサーバが動いているローカルコンピュータ(つまりSSHクライアント)で、X(/usr/bin/Xorg)を起動する際に+iglxオプションをつけることで実現できる。

ローカルコンピュータを起動する際にどこでXが起動されているかは各マシンの設定に依るので統一的なことは言えないが、多くの環境ではlightdmやgdmのようなdisplay managerがXを起動する責任を持つ。筆者はubuntuとarchlinuxを利用しており、いずれの環境でもlightdmをdisplay managerとして用いている(ubuntuではlightdmがデフォルト)ため、例としてlightdmの設定を示す。

Ubuntuの場合、/etc/lightdm/lightdm.conf.d/以下に50-xserver-command.confファイルを作り、以下の2行を指定する。

[SeatDefaults]
xserver-command=X -core +iglx

Archlinuxでpacman経由でlightdmをインストールした場合、/etc/lightdm/lightdm.confにすでにxserver-commandプレースホルダが用意されているため、これを編集すれば良い。

リモートサーバに接続

SSHのコネクションの中でXの通信プロトコルを扱えるようにするX11 Forwardingを用いてリモートサーバに接続する。ssh -XYコマンドオプションを使うか、$HOME/.ssh/configForwadX11 yesおよびForwardX11Trusted yesを指定する。

なお、一般にSSHサーバ上でGUIアプリケーションを動かすためにはForwardX11 yesつまりssh -Xだけで十分であるが、ForwardX11はXの通信プロトコルの一部を無効化する。これはXがキー入力などの情報も扱っているため、Xクライアントに悪意がある場合はXサーバが動いているマシンの機密情報を盗まれてしまう危険があるためだが、このとき無効化されるものの中にGLXも含まれる。 したがってGLXを使えるようにするためには、SSHサーバ上で動くXクライアントを「信頼」する必要があり、このためのオプションがForwardX11Trusted yesつまりssh -Yコマンドオプションである*2

別解(非推奨)

/usr/bin/Xorgを起動する際、-listen tcpオプションをつけることでSSHとは別にX専用のTCPコネクションを張ることができる。しかし、このためにはXサーバの該当ポートはファイヤウォールに穴を開ける必要があり、XサーバとXクライアントの認証もxauthを使って独自に管理する必要があるなど、手間とセキュリティリスクが大きいため、本稿では推奨しない。ただし、SSHの暗号化プロセスなどをすべてスキップすることによりパフォーマンスは最大化されるため、よほど性能要求が厳しい場合には検討しても良いかもしれないが、得られる性能向上はそこまで大きくない。

OpenGLアプリケーションの実行

以上の手順を踏んでリモートサーバでOpenGLアプリケーションを実行すれば、ローカルコンピュータの画面にその結果を表示することができるはずである。

しかし、場合によっては以下のようなエラーによりアプリケーションがうまく起動しないことがある(glxgearsは歯車が回転するアニメーションのOpenGLのデモプログラム)。

$ glxgears
X Error of failed request:  255
  Major opcode of failed request:  155 (GLX)
  Minor opcode of failed request:  1 (X_GLXRender)
  Serial number of failed request:  898
  Current serial number in output stream:  1064

これは様々な理由によって起こるため一概に原因を示すことができないが、よくある原因として「ローカルとリモートでlibGL.soの中身が違う」というものがある。libGL.soは一般にGPUドライバの一部としてパッケージされており、APIOpenGLの規格に従って整備されているが、内部の実装はGPUベンダまたはドライバの配布元によってバラバラである。

一例として、nVidiaGeForceを積んだリモートサーバ(nVidiaプロプライエタリなドライバが入っている)に、intelのCPU統合グラフィックスしかないローカルマシン(mesaのオープンソースドライバで動いている)で接続したとしよう。このとき、リモートサーバで動かすOpenGLアプリケーションはnVidiaのドライバに含まれるlibGL.soをリンクして使用するが、nVidialibGL.soによって生成されるGLXのコマンドをローカルのXサーバに送っても、ローカルのXサーバがGPUを駆動するために利用しているmesaのlibGL.soはこのGLXコマンドを解釈できず、上記のようなエラーとなる。

これを解決するためには、リモートで動かすOpenGLアプリケーションが「mesaの」libGL.soを利用するように強制してやれば良い。これにはLD_PRELOAD環境変数を使う。つまり

export LD_PRELOAD=/usr/lib/x86_64-linux-gnu/mesa/libGL.so.1.2.0
glxgears

のようにすることで正常に起動するようになると思う(LD_PRELOADの値は各自のローカルで動いているGPUやサーバで動いているOSの種類により異なる)。ただし、描画性能は壊滅的である場合が多い。現代的なGPUを積んだマシンでglxgearsを実行すると数万FPSに達することもあるそうだが、この方法でではせいぜい5FPS程度になる。これはどうしようもないので、解決したければサーバに積んであるものと同じベンダのGPUを買うしかない。

*1:OpenGLアプリケーションをフルスクリーンで実行する場合はウィンドウ枠の管理も不要になるので一旦Xを無効化してGPUレンダリング結果だけを直接画面に送るのか?この辺は勉強不足でよくわかっていない

*2:このときssh -Yは「Xクライアントを信頼する」だけであり、実際に「Xプロトコルをforwardする」という意味は含まない。したがって-Xなしで-Yだけ指定してもGUIアプリケーションを動かせないことに注意

Scalaメタプログラミング今昔物語 - 本編

DEPRECATED WARNING

2018/12/05 この記事の内容は、すでに開発が放棄されたプロジェクトに基づいています。ScalaにおけるメタプログラミングはDotty / Scala3世代で大きく変わることが予定されており、すでにコンパイラ本体と統合された開発が進行しています。この記事はすでに「歴史的文書」としてのもの以上の価値を持たないものですので、ご覧になる場合はそのことを念頭に置いて読み進めていただくようお願いします。

なお、この記事の前編として執筆した基本単語の解説記事は一般的なものですので、そちらの内容は現在でも役に立つものと思います。


今日が天皇誕生日なのも来年までということになりましたが、これはscala advent calendar 2017の23日目の記事です。昨日はnobkz さんのScalaJSの話、明日はmoc-yutoさんのAkkaStreamの話です。

さて、この記事では、scalaメタプログラミング機能について、投稿時点で最新、かつ日本語で最も詳しい解説を試みます。 最新版はGeneration 3なので、歴史的経緯に興味がなければGeneration 0/1/2は適宜飛ばしてください。 なお、本稿で使う単語を説明するための別記事を用意してありますので、わからない用語が出てきた場合には適宜ご参照ください。

12/26追記 Twitterでいただいたいくつかのご指摘を元に、一部加筆修正を行いました。

宣伝

ScalaMatsuri2018に「Scalaメタプログラミング今昔物語」というタイトルでCFPを提出しました。ぜひ皆さん投票をお願いいたします。

最初に結論

いつscalaメタプログラミング機能を使い始めるべきか? → 「全くもって今じゃないです」

いつscalaメタプログラミング機能に貢献すべきか? → 「過去最大のチャンスです」

ネタ元

本稿のネタ元は以下の論文およびブログ記事、そして5つのレポジトリです。

Generation 0: java.lang.reflect

scala 2.9まで、scalaは独自のメタプログラミング機能を持たず、メタプロを実現するためにはJavaのリフレクション機能を用いる必要がありました。 しかし、(当然ながら)JavaのリフレクションはscalaコンパイラJVMのclassファイルを吐き出した後にしか使えませんので、scalaのASTを知ることができません。 さらに、コンパイラによる型消去が完了した後なので、高カインド型の型引数情報なども利用できません(List[Int]List[String]はどちらもListとしてしか扱えない)。

これではさすがに貧弱だろうということで、scala独自のリフレクション機能としてGeneration 1が開発されます。

12/26追記 @xuwei_kさんから以下のようなご指摘をいただきました。

これは私は全く知らなかったのですが、まずScalaSignatureというscala独自のメタ情報をjavaのclassファイルに埋め込むための企画があって、そのScalaSignatureをscalacで書き込むのに使われるのがscalapということのようです。ですが、このscalapをランタイムにScalaSignature用のパーサとして利用することもできるらしく、これを使ってリフレクションができるんだそうです。詳しくは上記ツイート内のリンクを参照ください。

なお、次のscala.reflectはこのScalaSignatureを扱うためのユーザーフレンドリーなAPIとして開発され始めたという側面もあるということです。

また、これに関連して@phenanさんからも以下のご指摘をいただきました。

その後Twitterで少し議論させていただいたのですが、結論として「『JVMが型消去を行う』ということを知っているscalacは型消去されたjavaバイトコードを出力するが、そこで欠失する情報を補うためにScalaSignatureを書き込む」ということのようです(まだ私も完全には理解しきれていません)。

ご指摘いただいたお二人に感謝いたします。

追記ここまで

Generation 1: scala.reflect

scalaにおいて実行時・コンパイル時両方のリフレクション機能を実現した初のライブラリです。 scala 2.10-2.13で使えます。 2.14でdeprecated警告が出るようになるとのことです。Dottyには含まれません。

なお、Generation 1については公式のドキュメントをSBT開発チームのメンバーとして有名なEugene Yokotaさんが翻訳してくださっていますので、より詳しい解説はそちらを参照してください。

実行時リフレクション: ユニバースとミラー

scala.reflectで重要になる用語として「ユニバース (universe)」と「ミラー (mirror)」があります*1

ユニバースはリフレクションが実行される環境が持つ情報の集合です。 実行時リフレクションであれば、JVMが現在実行中のスコープに存在する変数・関数やそれらの型情報などの集合がユニバースです。 scala.reflect.runtime.universe(uは小文字)でアクセスできます。 一方でコンパイル時リフレクションであればコンパイラが持つ全ての情報の集合がユニバースとなります。 scala.reflect.macros.Universe(Uは大文字)でアクセスできます(ただしこれを明示的に使うことはありません。後述)。

ミラーは文字通り鏡です。 つまり、ユニバースに含まれる情報を鏡(ミラー)で反射(reflect)させて映し出し、その情報をもとに処理を行うのがリフレクションというわけですね。

実行時リフレクションを例で見てみましょう(REPLでそのまま実行できます)。

class C { val x = List(0) }
// universeを取得
val ru = scala.reflect.runtime.universe
// universeに対応するmirrorを取得
val m = ru.runtimeMirror(getClass.getClassLoader)
// mirrorでuniverseの中の情報をreflectすると、像 (image) が得られる
val image = m.reflect(new C)
// class Cのフィールドxを取得; これはuniverseに存在する型定義を直接参照しており、mirrorは不要
val fieldX = ru.typeOf[C].decl(ru.TermName("x")).asTerm.accessed.asTerm
// Cのインスタンス全体の像から、フィールドxの部分だけを取り出す
val fmX = image.reflectField(fieldX)

println(fmX.symbol.info.toString) // => List[scala.Int] 高カインド型の情報もそのまま扱える
println(fmX.get) // => List(0)

また、java.lang.reflectでは使えなかった高カインド型の型情報を実行時環境に持ち込むための専用オブジェクトである型タグなどの機能もあります。

コンパイル時リフレクション: コンテキストとmacroキーワード

ここまでユニバースとミラーに関して解説してきました。これは本質的には実行時リフレクションでもコンパイル時リフレクションでも同等なものですが、コンパイル時リフレクションではscala.reflect.macros.Universeをさらにラップしたコンテキスト (context)というものを用います。繰り返しになりますが、ユニバースとコンテキスト本質的に同じもので、マクロ、つまりコンパイル時リフレクションの記述のための便利機能を付加したものがコンテキストです。

*2をもとに見ていきましょう(REPLで実行できます)。

import scala.reflect.macros.Context
import scala.language.experimental.macros // macroキーワードのために必要
def assertImpl(c: Context)(cond: c.Expr[Boolean], msg: c.Expr[Any]) : c.Expr[Unit] = {
  import c.universe._ // 準クオートのために必要
  c.Expr[Unit](q"if ($cond) () else throw new AssertionError($msg)")
}
def myAssert(cond: Boolean, msg: Any): Unit = macro assertImpl

val x = 0
myAssert(x == 1, "x must be 1!!") // => "x must be 1!!"

def assertはアプリケーションコードで利用されるマクロのAPIです。 scalaコンパイラmacroキーワードを見つけると、このキーワードに引き続く名前を以下の規則でメソッド呼び出しに変換します。

def m(arg: T): U = macro impl // original
def m(arg: T): U = impl(c)(arg) // converted

最終的にアプリケーションコードが変換されるまでには以下のような段階があります。

  1. (マクロコンパイル時)impl(c: Context) => c.Expr[T] => c.Expr[U]という型を持つことを検査
  2. (マクロコンパイル時)macro implimpl(c)(arg)に変換
  3. (アプリケーションコードコンパイル時)現在のコンテキストをcとして埋め込み
  4. (アプリケーションコードコンパイル時)impl(c)(arg)を実行し、展開されたASTをもとのアプリケーションコードの位置に挿入

得られる結果はC系言語の#defineと似たものですが、マクロコンパイル時にimplの型を検査するためマクロAPIの型T => Uが崩れないことを保証できたり、AST変換が型安全であることにより変換後のASTがvalidであることを保証できるたりするため、マクロにより不適切なコードが生成されてしまってデバッグに苦労するというケースは減らせると考えます。

なお、この形式のマクロはdefにより関数として定義するためdefマクロと呼ばれます。

問題点

scala.reflectは「実験的機能」という位置づけでしたが、scalaメタプログラミングをしたかった人々により広く使われるようになりました。一方でその過程で多くの問題点が指摘されました。 マクロの定義を宣言とimplにわけなければならないというような小さな不満も含みますが、最大の問題点はコンパイラの内部実装、具体的にはASTの定義に強く依存してしまうことです。

一般的な事実として、「異なるAST定義が等価なコードを生成しうる」というものがあります(詳しくは23日の記事)が、実際にコンパイラのバージョンが変わるとASTの定義は変更されます。 scala2.x系とDottyでは全くことなるASTが採用されており、scala.reflectで記述したメタプログラムはDottyでは確実に動きません。

さらに、IntelliJのようなIDEは様々な言語に対して独自のASTを定義している場合が多いです。 これは定義元ジャンプや静的解析による警告表示などの「IDEらしい」機能を実現するためのサポートをASTレベルで実装したいという動機によるものですが、 この独自ASTの帰結として、IDE上ではscala.reflectによるリフレクションがコードにどのように作用するか知ることができません。 したがって適切なIDEサポートが得られず、プログラミング体験を大きく損なうということが指摘されています。

Generation 2: scala.metaとマクロアノテーション

scala.meta

コンパイラごとにAST定義が違っていて困るのであれば「標準AST」を定義すればいいではないか、ということで開発が始まったのがscala.metaプロジェクトです。 scala.metaで定義された標準ASTはこのファイルで一覧することができます。 この標準ASTを使ってマクロコードを記述することで、コンパイラが変わったとしても同じマクロが動くことを保証できます。

注意すべき点として、scala.metaはリフレクションのためのライブラリではありません。あくまでも標準ASTを定義し、このASTに関するレイフィケーションの機能を提供するものです。 scala.meta単体で何ができるかという解説は公式のチュートリアルが大変わかりやすくておすすめです。 scalaのlintツールであるscalafixscalafmtなどがscala.metaを利用していますが、 プログラム自身を操作するプログラムという意味での「メタプログラム」の定義からは少し外れます。

paradiseとマクロアノテーション

Generation 2のメタプログラミング機能は、paradiseという名前のコンパイラプラグインによって実現されるコンパイル時リフレクション「マクロアノテーション (macro annotation)」です。 ちなみに、私は一時期このparadiseプラグインの3番手コントリビュータでした(最終的には4番手)。

paradiseプラグイン、およびマクロアノテーションはGeneration 1にも存在しますが、前節で紹介したマクロ(defマクロと呼ばれます)に比べて明らかに影が薄いです。 大枠はGeneration1でも2でもあまり変わらず、Generation 1でscala.reflectだった部分をscala.metaで置き換えただけと言ってしまうこともできますが、 細かい「書きやすさ」のようなものの向上は感じられます。

マクロアノテーション@annotationの構文を使って既存のクラス定義などを拡張するのに用いられます。 例を見てみましょう(レポジトリ)。 このコードは、objectの中に裸で入れられた処理を、プログラムのエントリーポイントであるmainメソッドに作り変えるサンプルです。

// macro.scala
import scala.meta._
class main extends scala.annotation.StaticAnnotation {
  inline def apply(defn: Any): Any = meta {
    // 準クオートを使ってobjectの名前nameとコンストラクタの手続きstatsを取得
    val q"object $name { ..$stats }" = defn
    // statsを使って新しいメソッドmainを作成
    val main = q"def main(args: Array[String]): Unit = { ..$stats }"
    // nameとmainメソッド(のAST)を使って新しいobject(のAST)を作成し、返す
    q"object $name { $main }"
  }
}
// core.scala
@main object Hello {
  println("Hello world!") // この行がstatsとなる
}

このコードを動かすためにはparadiseプラグインなどが必要なため、上記のレポジトリをクローンしてお試しください。 レポジトリのREADMEの通りに動かすと、まずマクロアノテーションコンパイルされ、次いでparadiseプラグインアノテーションを使ってアプリケーションコードを

object Hello {
  def main(args: Array[String]): Unit = {
    println("Hello world!")
  }
}

のように展開し、それからこの生成されたコードがコンパイルされて、おなじみのHello worldを出力します。

動作としては、まずStaticAnnotationを継承したmainクラスをコンパイルします。 マクロアノテーションのためのクラスはinline def apply(defn: Any): Anyというシグネチャを持つメソッドを持つ必要があり、本体はmeta { ... }で囲みます。 なお、inlinemetaはscalametaのv1.0-1.8で提供される新しいキーワードです(scalameta v2からは削除、後述) このメソッドは、アプリケーションコード内でアノテーションが付けられたコードのまとまり(Annotteeといいます)をAST(defn)として受け取り、新しいASTを返します。 細かい動作はコード中のコメントを読んでいただければだいたいイメージができると思いますが、大雑把には

  • 準クオートでannotteeの中の構文要素(上の例だとnameとかstatsとか)を取得
  • それらの構文要素を使って新しいASTを作成

これだけです。

実際、マクロアノテーションは大変書きやすい上に柔軟性が高く、便利な存在でした(※アノテーションに引数を付けられるなど、いろいろニッチな需要もカバーされていて本当に使いやすいのですが、本稿では割愛)。 私が使った主な用途はボイラープレートの生成で、例えばplay frameworkのjson formatterを自動生成させるアノテーションなどを作って今も使っています。

問題点

登場当初からコミュニティにおいて高い評価を得て、一時は次の公式マクロシステムの座を射止めたかに見えたマクロアノテーションですが、現在ではロードマップから外され、deprecatedの扱いとなってしまいました。

Generation 1と異なり、2では標準ASTを採用することによって「コンパイラのバージョンが変わった時に、マクロコードを書き換える必要性」を無くしました。 一方で、「コンパイラのAST」から「標準AST」への変換器は、やはりコンパイラごとに必要になってしまいます。 そしてこの変換器を正しく作るのは恐ろしく骨の折れる作業でした。 実際私も初期の開発プロセス(scala2.11.3からの変換だったと思います)に参加し、苦労した思い出があります。 これをscalacのみならずDottyやIntelliJコンパイラにも対応させ続けようと思うと、持続性が問題になってくるのは明らかでした。

Liu・Burmako両氏はこの問題を2017年の論文で詳しく論じ、次のGeneration 3への開発へと移行することになりました。 この論文では、AST変換に伴うデメリットとして以下の4つを挙げています。

  • コンパイラASTに付随する型情報を標準ASTに持ち込むのが難しい
  • コンパイラASTのメタ情報(コードの行番号など)も標準ASTに持ち込みにくい
  • 変換にかかる計算コストが大きい(遅い)
  • 変換器の作成コストが大きい(前述)

また、AST変換による問題の他にもいくつか細かい制約がありました。 例えば、scala本体の文法規約として、アノテーションは「定義」にしかつけることができません。 つまり、trait, class, object, def, val, varにしかマクロアノテーションを利用できず、普通の式(1+1など)を元にして展開することはできません。 package, package objectは「定義」ではなく「参照」なので、これらに対してもマクロアノテーションを利用できません。 また、何もないところにコードを生成することもできません。必ず@macro Hoge {}のように空のオブジェクトにアノテーションを付けて、その中に展開する必要があります。

Generation 3: scala.macrosscalagen

さてここからが本題なのですが、実際にはまだまだ未定な部分が多い段階です。以降全ての文に「2017年12月現在では」や「おそらく」「多分」という接頭辞を付けるつもりで読んでください。

Generation 3ではメタプログラミング機能が2つに分裂します。 Generation 1のような式ベースの記述macro(1+1)は新開発のscala.macrosに引き継がれます。 Generation 2のような定義の拡張やコード生成はscala.metaをベースとしたscalagenが担うことになりそうな雰囲気です。 順にみていきます。

scala.macros

コンパイルごとに異なるASTを直接扱いたくない→標準ASTへの変換はコストが大きすぎる、という流れを経てたどり着いた考え方が「標準構文(standard abstract syntax)」という考え方です。 「標準AST(standard abstract syntax tree)」からtreeが抜けただけのようにも見えますがこれらは本質的に異なります。 以下、F. Liu & E. Burmako (2017)の解説を通して、両者の違いを見ていきます。

標準ASTの場合

まず標準ASTを用いる場合ですが、これは前節で述べたように「コンパイラASTから標準ASTへの変換」が必要になります。 マクロによるコード展開の流れを擬似コードで見てみましょう。

// 標準ASTであるscala.metaを使用
import scala.meta
// マクロは標準ASTから標準ASTへの変換として定義される
type Macro = meta.Tree => meta.Tree
// コンパイラASTから標準ASTへの変換
def toMeta(cTree: <compiler>.Tree): meta.Tree
// 標準ASTからコンパイラASTへの逆変換
def fromMeta(mTree: meta.Tree): <compiler>.Tree
// マクロの適用
def expand(macro: Macro, tree: <compiler>.Tree): <compiler>.Tree =
    fromMeta(macro(toMeta(tree)))

つまり、meta.Tree<compiler>.Treeと同等のコードを表すASTですが、コンパイラASTとは全く別のオブジェクトです。

標準構文の場合

次に標準構文を用いる場合を見てみましょう。 これは、コンパイラASTのオブジェクトはそのまま利用しつつ、標準コンストラクタと標準抽出子を利用して見かけだけ標準化する手法です。

// scala.macrosライブラリ

// 標準構文のパッケージ
package macros {
  // 標準構文で用いるTreeは単なるコンパイラASTのエイリアス
  type Tree = <compiler>.Tree
  // Term(Treeのうち式として扱えるもの全体)もコンパイラのTermのエイリアス
  type Term = <compiler>.Term
  // If式の標準構文
  def TermIf: TermIfImpl
  // 標準コンストラクタと標準抽出子の定義
  trait TermIfImpl {
    // 標準If構文を書くと、内部ではコンパイラのIf構文のオブジェクトを作る
    def apply(condTerm: Term, thenTerm: Term, elseTerm: Term): Term =
      <compiler>.If(condTerm, thenTerm, elseTerm)
    // treeがコンパイラIf構文として解釈可能であれば、標準If構文の引数を返す
    def unapply(tree: Tree): Option[(Term, Term, Term)] = {
      case <compiler>.If(condTerm, thenTerm, elseTerm) => Some((condTerm, thenTerm, elseTerm))
      case _ => None
    }
  }
}
// my_macro.scala

// 標準構文パッケージのインポート
import scala.macros
// マクロはあくまで標準構文から標準構文への変換として定義
type Macro = macros.Tree => macros.Tree
// マクロ定義
val macro: Macro = tree => {
  // 標準抽出子を使ってIf構文の要素を取り出し、thenとelseをひっくり返す
  case macros.TermIf(c, t, e) => macros.TermIf(c, e, t)
}
// マクロの適用
def expand(macro: Macro, tree: <compiler>.Tree): <compiler>.Tree =
  // 戻り値としてmacros.Treeを返すが、この実態はコンパイラASTなのでそのままコンパイラに返して良い
  macro(tree)

初見だと型エイリアスが飛び回るので激しく混乱すると思いますが、雰囲気は掴んでいただけたでしょうか。 最も大事な点は(繰り返しになりますが)ASTを構成するオブジェクトは、コンパイラのASTで使っているものをそのまま使いまわすということです。 macroUniverse.TermIfなどの標準コンストラクタ・標準抽出子はコンパイラASTの中から所望の文法構造を探し出すための道具に過ぎません。

こうすることで、マクロを記述する際には「見た目は標準構文」「実際に扱っているのはコンパイラAST」という、一件矛盾する要求を解決できます。 Scalaのunapplyがいかに強力かということを示す例でもありますね。 また、新しいオブジェクトを作らないので省メモリだったり、高速になったりという副効果も期待できます。

(想定)FAQ

「結局コンストラクタ・抽出子はコンパイラごとに作る必要があるのでは?」 → まったくもってそのとおりです。コンパイラが変わればコンストラクタと抽出子は別のものになります。

ですが、変換器を作るのとはコストが雲泥の差です。 一番の理由は、そもそも同一なオブジェクトを利用することで型情報やメタ情報について心配する必要がなくなることです。 形式的な構文にのみ注意を払ってコンストラクタと抽出子を作ればその他の情報は自然と付いてくる、という意味で「標準構文」という名前になっています。

開発状況

scalacについて言えば、標準コンストラクタについては実装済みです。標準抽出子はどうやら全く手付かずです。 Dottyは一部抽出子も実装されているっぽいですが見た感じ不完全と思われます。

標準抽出子がないということはすなわち既存のコードをタネにしてマクロで展開することができないという意味です。 コードをタネとしない、つまり無から有を生み出すようなマクロはすでに書くことができます。 ただし、対応する準クオートが実装されていないため標準コンストラクタを全て手で書くことになりますが…(

scalagen

scala.macrosがGeneration 1のdefマクロと似た機能を提供しようとするのに対し、scalagenはGeneration 2のマクロアノテーションを置き換えるものになる予定です。

12/26追記 これは言い過ぎでした。Scala centerのÓlafur Páll Geirssonさんから直々にDMをいただきました(本人の許可を得てDMのスクリーンショットを転載します)。

f:id:iTakeshi:20171226161957p:plain

この後の会話の中で、「scalagenはあくまでもDavid Dudsonさんの個人プロジェクトであり、「マクロアノテーションを置き換える」ことが正式に決まったものではないということを強調されました。 これについては私の認識の誤りでしたので、お詫びして訂正いたします。

ただし、scalagen自体の開発は進行中ですので、個人的にはGeneration 2からの良い乗り換え先となることを強く期待しています。

Thanks Olafur for your kind review!

追記ここまで

コードを展開するロジックはscala.metaを用いてGeneration 2とほぼ全く同じ形式で記述します。

大きく異なる点として、scalagen静的コードジェネレータです。つまりコンパイル時にマクロとしてコードを展開するのではなく、静的なファイルとして生成したコードを吐き出し、それをコンパイラに渡します。 したがって、これはコンパイラプラグインとしてではなくビルドマネージャのプラグインとして提供されます(まずはSBTプラグイン)。 コンパイラから独立させることで、変換前のASTは単にscala.metaに付属のパーサで解析したものを用いることができ、出力もscala.metaに付属のprettyprint機能が使えます。 よって、SBTプラグインとの入出力を管理するインターフェースだけ適切に定義してやればよいため、scalagen自体はscala.metaのごく薄いラッパーとして機能するようになるはずです。

機構が単純なため、特にこれ以上コメントすべき点はありません。

開発状況

始まったばかりで、コード生成器が実装すべきtrait Generatorと、それを使ってコードを展開するclass ExtensionRunnerのプロトタイプがだいたいできあがったところです。 機能要件もまだ流動的ですので、何かしら提案してみると議論がもりあがったりして楽しいです()。

結論

冒頭の「最初に結論」に戻ります。

いつscalaメタプログラミング機能を使い始めるべきか?

「全くもって今じゃないです」

今動くシステム、つまりGeneration 1と2はすでにdeprecatedで将来的に使えなくなることが確定しており、開発中のGeneration 3は未だ実用に程遠い状況(しかもscala.macrosは停滞気味、scalagenは始まったばかり)です。 どうしても今すぐメタプログラミングしたくてたまらない場合にどうすればよいかと言うと、これも迷わしいところです。 ひとつの提言としては、Generation 2のマクロアノテーションで書いたコードはごく僅かな修正でscalagenに使える可能性が高いです(今の所)ので、どうしてもという場合は検討しても良いでしょう。

いつscalaメタプログラミング機能に貢献すべきか?

「過去最大のチャンスです」

前述の通り、scala.macrosの開発は未完了かつ停滞気味で、scalagenは始まったばかりです。 各々のニーズに従い、scala.macrosをプッシュするでもよし、scalagenに便利機能を提案するでもよし、できることはいくらでもあります。 特にscalagenはまだ1000行くらいしかありませんので追いつくのも簡単です。 私もscalagenの開発には積極的に関わっていくつもりですが、みなさんもご一緒にいかがですか?


ここまでお読みいただいた方、ありがとうございます。scalaメタプログラミングについて、現状私が把握していることの概略はお伝えできていると期待しますが、わかりにくい点・間違っている点などありましたらぜひご指摘をお願いします。 それでは、Merry Metaprogramming!!

Scalaメタプログラミング今昔物語 - 導入編

お久しぶりです。これはscala advent calendar 2017の2日目の記事です(ということにします。空いていたので穴埋め)。 前日はpoad1010さんのJupyterでScala - Qiita、翌日はokapiesさんのAkka HTTP クライアントを使う - Okapies' Archiveでした。

さて、この記事は本来23日目に予定していた私のアドベントカレンダーがあまりにも大長編になりそうなことがわかったので前後編に分割した前半部分です。 Scalaメタプログラミング周辺の話をするに当たって最低限必要になる用語を整理します。 ひとつのまとまりのある記事というよりは、辞典としてお使いください。 23日の記事からも適宜参照として使います。

その前に宣伝

ScalaMatsuri2018に「Scalaメタプログラミング今昔物語」というタイトルでCFPを提出しました。ぜひ皆さん投票をお願いいたします。

では始めましょう。

リフレクション

リフレクション (reflection) とは、プログラムが自身を形式的に解釈(注:実行とは異なる)し、または変更を加えることができる能力*1です。 つまり、「プログラムを変更できるプログラムの記述」=「メタプログラミング」に必要な言語機能がリフレクションです。 リフレクションには実行時リフレクションとコンパイル時リフレクションが存在し、様々なプログラミング言語がそれぞれの哲学に基づいてこれらの一方または両方を実装しています。

実行時リフレクションはプログラム実行時にランタイムに存在する変数や関数を操作するものです。主にインタプリタ型の言語で(狭義の)メタプログラミングと呼ばれるものがこれに当たります。JVMも歴史的にはインタプリタとして開発されており、したがってJVM言語であるscalaも実行時リフレクションの機能を持ちます(※やや語弊があり、23日の記事で詳述します)。

一方、コンパイル時リフレクションは、静的言語においてコンパイラがプログラムの文字列そのものあるいは抽象構文木を操作するもので、一般にマクロ (macro) と呼ばれます。 C系の言語で#defineを用いて定義されるものは単なる文字列置換*2ですが、scalaのマクロでは抽象構文木を操作します。

抽象構文木

抽象構文木 (abstract syntax tree; AST) は、テキストとして記述されたプログラムを、より意味の取りやすい構造をもったとして表現したものです。 ほぼあらゆるプログラミング言語の処理系が最初に行う処理は、単なるテキストであるプログラムを解析 (parse) し、ASTに変換する作業であると言っても過言ではありません。

例えば、

if (str.startsWith("abc")) 1 else 0

のようなコードは、下図のような抽象構文木に変換できます。

f:id:iTakeshi:20171217124459p:plain

scalaのリフレクションではASTをcase classを使って表記するため、上記の例は

case class Expr(...)
case class IfExpr(condExpr: Expr, thenExpr: Expr, elseExpr: Option[Expr])
val sample = IfExpr(<str.startsWith("abc")>, <1>, Some(<0>))

となります。else節はあってもなくても合法な構文なので、case classのフィールドとしてもOption型で表しています。

このcase classはあくまで説明のための例であり、実際にscalaのリフレクションで使われているものとは異なります。また<>は「<>で囲まれた中身のコードのAST」を表すための省略記法として用います。

さて、ここで重要なことは、等価なコードを表すためのASTは一通りに定まらないということです。 例えば、上記の例は以下のようにも表すことができます。 f:id:iTakeshi:20171217124517p:plain

case class ElseClause(expr: Expr)
case class IfExpr(condExpr: Expr, thenExpr: Expr, elseClause: Option[ElseClause])
val sample = IfExpr(<str.startsWith("abc")>, <1>, Some(ElseClause(<0>)))

この例ではElseClauseを独立した文法単位として切り出し、IfExprOption[ElseClause]を持つという構造になっています。 コンパイラの実装に際してelseを独立して扱うほうが都合が良いという事情があればこのような表現も可能である、ということがわかります。

この「異なるASTが同一のコードと対応し得る」という事実は23日の記事で大変重要になりますので、頭の片隅に入れておいてください。

レイフィケーション

レイフィケーション (reification) とは、テキストとして記述されたプログラムを解析し、ASTを構築する手続きを指します。動詞はreifyです。

準クオート

準クオート (quasiquote)とは、抽象構文木を操作するプログラムの記述を簡便にするために導入された記法で、一種の構文糖衣です*3。 具体的にはq"..."という形式のStringContext*4として実装されており、 文字列として記述したコードをreifyしたものを返します。

以下の説明ではscala 2.10で導入されたscala.reflectで実装された準クオートに基づいて話を進めますが、後発世代のメタプログラミング環境でも同等の準クオートが実装されています。 なお、以下のコードサンプルはscalaのREPLで実行可能です。

つまり、

import scala.reflect.runtime.universe._
val expr = q"1 + 1" // 1 + 1を表すAST

と記述できるため、大変便利です。実際、同等のexprを準クオートを用いずに記述しようとすると

import scala.reflect.runtime.universe._
val expr = Apply(Select(Literal(Constant(1)), TermName("+")), List(Literal(Constant(1))))

となります。この程度ならまだ我慢できますが、長くなるとやってられません。

さらに、準クオートはパターンマッチのためにも用いることができます。

import scala.reflect.runtime.universe._
val expr = q"10 + 1"
val q"$numA + 1" = expr
println(numA) // => 10 実際には Literal(Constant(10))

これは画期的なことで、マクロなどを作る際、ASTから必要な情報を手早く抜き出してくるために極めて便利です。

引数など、文法上可変長であることが許されている構造もList[Tree]として抜き出すことが可能です。

import scala.reflect.runtime.universe._

val expr1 = q"List(1, 2, 3)"
val q"List(..$args)" = expr1 // dot2つ
println(args) // => List(1, 2, 3): List[Literal]

val expr2 = q"hoge(1, 2, 3)(curried)"
val q"hoge(...$argss)" = expr2 // dot3つでカリー化
println(argss) // => List(List(1, 2, 3), List("curried")): List[List[Tree]]

名前とシンボル

名前 (name) とは、ASTの中に存在する特定のノードを表すための識別子として用いられる文字列です。 単なる文字列であり、それ以上の情報は持ちません。

シンボル (symbol) とは、名前とその名前が参照するクラスやメソッドのような実体 (entity) を関連付けるために用いられる*5概念です。 つまり、AST内で何らかの名前を持つ変数や関数に対して、型をはじめとする各種の情報を付加するために必要なブリッジと言えます。

例えば

val str = "abc"

というコードを書いたとします。 このコードをASTに変換すると、strという「名前」が取り出されます。この時点では名前だけですので、コンパイラstrがどのような性質を持つのか知りません。 しかし、コンパイラ型推論能力を持つため、右辺の"abc"によりstrString型であると決定することができます。 したがって、コンパイラは名前strに対応するシンボルを作成し、ここにStringという型情報を注記するわけです。


さて、以上で導入編は終了です。もちろんこの記事の内容が全てというわけではありませんが、scalaメタプログラミングに関する議論の多くを理解するのに必要十分な内容は抑えていると思います。 それでは23日、本編でお目にかかります。

ロボットシミュレータ自作物語 - Day 2: arrayfireで線形代数 & Rustの生ポインタ

引っ越しの準備に追われてあまりコードを書いている時間がありません。

TL; DR

arrayfireでArrayの中身をRustの配列として取り出すのがちょっとめんどくさい

今日の目標

キーボード入力によってティーポットをぐるぐる動かせるようにします。

成果物

f:id:iTakeshi:20170221123803g:plain

gliumでキー入力

キー入力は、描画される各フレームごとにglium::glutin::Event::KeyboardInputとして取得できます。KeyboardInput

pub enum Event {
    // ...
    KeyboardInput(ElementState, ScanCode, Option<VirtualKeyCode>),
    // ...
}

として定義されているので、ここからVirtualKeyCodeを取り出してやるとどのキーが押されたか判定できます。

// application main loop
loop {
    for ev in display.poll_events() {
        use glium::glutin::{ElementState, Event, VirtualKeyCode};
        match ev {
            Event::Closed => return, // Windowが閉じられたらloopを抜ける→アプリケーション終了
            Event::KeyboardInput(ElementState::Pressed, _, Some(code)) => {
                match code {
                    VirtualKeyCode::S => model_z -= 1.0, // move backward
                    VirtualKeyCode::W => model_z += 1.0, // move forward
                    // other moves
                    _ => (), // 他のキーに対するfallbackを用意しておかないとコンパイルが通らない
                }
            },
            _ => ()
        }
    }
}

arrayfireでモデル変換行列を計算

※脚注※ この記事を書いている最中に気づいたことですが、gliumの依存crateとしてnalgeblaという線形代数ライブラリが入っており、普通はこっちを使うのだと思います。しかもnalgebraにはCGで使う基本的な変換行列(translation, rotation, transformation, …)を一発で生成できる関数も入っているようなので書き換える予定です。

前回の記事でも紹介したこの記事にあるような変換行列を用意し、Vertex shaderに渡してやるとティーポット動かしたり回したりできます。三次元空間を扱うのになぜ4x4の行列なのか?という疑問があるかもしれませんが、この4行目・4列目を加えることによって複数の変換行列に対して単純に内積を求めるだけですべての変換を適用できるというすばらしいメリットのためです(以下のコード中のpositionpitchについて、紙と鉛筆で内積を取ってみましょう!)

線形代数演算にはarrayfire-rustを使用します。これはCargoで入れる前にいくつかdependencyがありますので、READMEを参照して準備してください。 注意点として、OpenGLに渡す行列はすべてColumn-major orderです。要するに「行が横」ではなく「列が横」です*1。したがって、position行列で座標は4目ではなく4目になり、回転行列ではsinの符号が直感とは逆になっています。

extern crate arrayfire as af
fn model_matrix(pos: &[f32; 3], rot: &[f32; 3]) -> [[f32; 4]; 4] {
    use af::{Array, Dim4};

    // ティーポットモデルは -100 <= x, y, z <= 100 の座標空間で作られているので、1/100に縮小
    let base = Array::new(&[
        0.01, 0.0 , 0.0 , 0.0,
        0.0 , 0.01, 0.0 , 0.0,
        0.0 , 0.0 , 0.01, 0.0,
        0.0 , 0.0 , 0.0 , 1.0f32,
    ], Dim4::new(&[4, 4, 1, 1]));

    // 平行移動
    let position = Array::new(&[
        1.0, 0.0, 0.0, 0.0,
        0.0, 1.0, 0.0, 0.0,
        0.0, 0.0, 1.0, 0.0,
        pos[0], pos[1], pos[2], 1.0f32,
    ], Dim4::new(&[4, 4, 1, 1]));

    // x軸まわりの回転
    let pitch = Array::new(&[
        1.0, 0.0, 0.0, 0.0,
        0.0,  rot[0].cos(), rot[0].sin(), 0.0,
        0.0, -rot[0].sin(), rot[0].cos(), 0.0,
        0.0, 0.0, 0.0, 1.0f32,
    ], Dim4::new(&[4, 4, 1, 1]));

    // y軸まわりの回転
    let yaw = Array::new(&[
         rot[1].cos(), 0.0, rot[1].sin(), 0.0,
        0.0, 1.0, 0.0, 0.0,
        -rot[1].sin(), 0.0, rot[1].cos(), 0.0,
        0.0, 0.0, 0.0, 1.0f32,
    ], Dim4::new(&[4, 4, 1, 1]));

    // z軸まわりの回転
    let roll = Array::new(&[
         rot[2].cos(), rot[2].sin(), 0.0, 0.0,
        -rot[2].sin(), rot[2].cos(), 0.0, 0.0,
        0.0, 0.0, 1.0, 0.0,
        0.0, 0.0, 0.0, 1.0f32,
    ], Dim4::new(&[4, 4, 1, 1]));

    use af::matmul;
    use af::MatProp::NONE;
    let res = matmul(&matmul(&matmul(&matmul(&roll, &yaw, NONE, NONE), &pitch, NONE, NONE), &position, NONE, NONE), &base, NONE, NONE);

    // 生ポインタを利用してRust配列の取り出し。次節にて解説。
    unsafe {
        *(res.device_ptr() as *const [[f32; 4]; 4])
    }
}

※脚注※ この実装では各種変換行列をすべて内積しひとつにまとめてからOpenGLに渡していますが、それぞれの変換行列をOpenGLに渡してVertex shaderでかけざんしてもらってもいいです、というかそっちのほうが速いような気がするのでこのコードは消滅する予定です。

Rustの生ポインタ

arrayfireはRustのFFI(Foreign Fuction Interface)を利用しています。つまり、arrayfire (C++ Ver.)をbackendとして、arrayfire-rustがそのwrapperになっています。mutmulで行列の内積を計算すると、その結果はC++が確保したメモリの中にあり、Rustからは直接利用できません。af::Array::device_ptr()は、この計算結果に対するC++のポインタ(unsigned long long)を返します。Rustからこの値を取得するためには、

  1. まずポインタが指している型が何なのかを指定し: res.device_ptr() as *const [[f32; 4]; 4]
  2. dereferenceし: *(...)
  3. さらに、外部メモリを参照する行為は闇のパワーを利用する業なのでそのことを明示します: unsafe { ... }

Step1で型名[[f32; 4]; 4]の前についている*constは生ポインタの指す値を定数として利用するときに使い、逆にその値を書き換える場合には*mutとして参照します。その他詳しいことは公式docへ。

Day 3に向けて

よくわからないままコードを書き足してきたのでぐちゃぐちゃです(この記事にコードスニペットしか載っておらずgithubへのリンクがないのはそのためです)。 必要な範囲でリファクタリングしてGitHubに公開します。そしてまだBlenderがよくわかってないのでそっちの勉強もしていきます。

*1:なぜこんな紛らわしいことになっているのかと思わないでもないですが、おそらくGPU上で性能良く演算するためにはこっちのほうが良いのでしょう

ロボットシミュレータ自作物語 - Day 1: RustでOpenGL入門 & 3D CGの基礎

たいそうご無沙汰しております、ぼくたち宇宙人のお時間です。15ヶ月ぶりくらいみたいですね。

さて、4月から専門分野をガラっと変えてヒューマノイドロボットの運動制御について研究する修士の学生になるにあたり、春休み中に素振りとしてロボットシミュレータを自作してみようと思います(学部の卒業も無事に決まりましたしね)。需要があるのか不明ですが解説付きで開発日誌をつけてみようと思いますので三日坊主にならないことを祈ってくださいお付き合いくださいませ。

TL; DR

gliumの公式チュートリアルを丁寧に写経すべし。

今日の目標

とりあえず3D CGをある程度自由に描けるようになりたいので、OpenGL(Wikipedia)で簡単なモデルを表示できるようにしたいと思います。 ただし、OpenGLAPIを生で喋るのはちょっとつらいので避けたいところです。 そこで、今もっとも興味を持っている言語であるRustにもついでに入門してしまうことにして、OpenGL wrapperであるglium*1を使って開発していきます。

環境構築 & コーディング & コーディング & コーディング

RustおよびCargo(Rustのパッケージマネージャ)を適宜な方法でインストールしてください。OpenGLGPUのドライバと一緒にすでにインストールされている可能性が高いです。というか手元のArch Linuxではそうだったので手動で入れるやりかたがわかりません。

あとは上記チュートリアル参照。写経大事。

成果物

f:id:iTakeshi:20170218231952p:plain

OpenGL基礎の基礎

※脚注※ 当初はこの節以降で3D CGの基礎数学についても解説を試みる予定でしたが、参考資料を探している間に傑作を発見してしまいましたのでそちらに譲りたいと思います。6回構成でとても丁寧に解説されており読みやすいです → けんごのお屋敷。以下、5分で概要を知りたい方向けです。

3D CGの三要素(私が勝手に命名しました)は「モデル、光源、カメラ」です。仮想3D空間に粘土細工(モデル)を配置し、仮想光源で光を当てて、仮想空間内に設置したカメラで撮影したものが、私たちがテレビやモニタで見ている絵になります。アニメーションである場合はフレームごとにこの計算を繰り返します。

OpenGLに限らず多くの3Dグラフィックスライブラリでは、CPUが計算したモデルの現在位置をGPUが受け取ってから画面に描画するまでの一連の流れを「パイプライン」と呼んでいくつかのステップに分割し、順序よく仮想3D空間を2次元に落とし込んでいきます。各ステップには各々のライブラリに固有の機能を実現するものなどもありますが、最も原始的で絶対に必要になるものは Vertex shaderによる頂点位置の計算ラスタライズFragment shaderによる各ピクセルの色の計算 の3ステップです。順に見ていきましょう。

Vertex shaderによる頂点位置の計算

CGにおいて「滑らかな表面」というものは存在しません。滑らかに見えているものは、無数の小さな三角形の集合体です。三角形は「三次元空間内に任意の相異なる3点を取ると、かならずその3点を含む平面がただ一つ求まる」「3つの頂点の位置が与えられた場合、三角形の各辺の長さおよび3つの内角がすべて一意に求まる」などの便利な性質があるため、3D CGではあらゆるモデルを三角形に分解して表現します。例えばイルカならこんな感じ↓ f:id:iTakeshi:20170218232626p:plain

https://ja.wikipedia.org/wiki/%E3%83%9D%E3%83%AA%E3%82%B4%E3%83%B3%E3%83%A1%E3%83%83%E3%82%B7%E3%83%A5#/media/File:Dolphin_triangle_mesh.png - パブリックドメイン

Vertex shaderは、光も影もない灰色の世界でモデルをカメラで撮影したとき、モデルを構成する無数の小さな三角形のそれぞれの頂点の位置(三次元)が写真の上でどのような座標(二次元)にあるのかを計算します。

上で「CPUがモデルの現在位置を計算」と簡単に書きましたが、これはあくまでも「モデルの姿勢を規定するのに必要なパラメータを計算」するだけです。例えば下図のような単純なロボットアームであれば腕の長さと \theta_1 \theta_2の角度さえわかればモデルの形は決まってしまうので、CPUが計算するのはこれらの値だけであとはGPUに投げてしまって良いということになります*2f:id:iTakeshi:20170218222423p:plain

Vertex shaderは、腕の長さとθ1とθ2の角度をもとにロボットアームのモデルを空間内に配置し、そのモデルを構成するすべての三角形のすべての頂点の位置をまず三次元で計算します。その後、仮想空間にカメラを設置し、そのカメラでとった写真の上の座標に三次元の位置を対応させます。

※このとき、遠近法に基づき遠くのものを小さく、手前のものを大きく変形する必要がありますが、上で紹介した「けんごの部屋」の記事ではこの部分が解説されていませんので、この記事を補足としてリンクしておきます。

ラスタライズ

さて、Vertex shaderで各頂点の二次元座標がわかりましたが、まだモニタに表示することはできません。Vertex shaderで計算した座標は -1 \le x, y \le 1浮動小数点(画面の左端が x = -1)で表されており、これを例えば800x600pxのモニタのピクセルと対応させる必要があり、この工程をラスタライズと呼びます。ラスタライズはOpenGLが自動で行うため、プログラマがあえて命令する必要はありません(というか普通カスタマイズできません)。

Fragment shaderによる色の計算

ラスタライズによってモニタ上の各ピクセルがモデルのどの三角形と対応しているかが決定されましたが、まだ世界は灰色のままです。Fragment shaderでは、三角形そのものに指定されている色情報、またはテクスチャ(モデルの表面にはりつける質感画像)によりピクセルの色を計算します。さらに、三次元空間内に光源を仮定し、モデルの光のあたり具合によって色を明るくしたり影をつけたりします*3

ロボットシミュレータにおいてはロボットの「動き」そのものが大事でありあまり見た目にはこだわらないので、このプロジェクトではfragment shaderによる処理は最低限のものになるでしょう。

Day 2 に向けて

現状はただティーポットが画面上に浮いているだけなので、矢印キーでモデルを回したりカメラ位置を変えたりできるようにしていくつもりです。またBlenderを使ってロボットの3Dモデルを作り始めます。

*1:RustのOpenGL wrapperとしてはもうひとつglutinというのがありますが、これはGLFW相当のやや低いレイヤを扱うライブラリであり、glutinのREADMEにも「もう一段階高レベルな抽象化レイヤを導入すべきやで」との記載があります。gliumとglutinの作者は同一人物ですので、つまるところすなおにgliumを使えということでしょう

*2:物理演算をする場合などはもっとCPU側でいろいろな計算(衝突判定etc)をしますがこの記事の範囲外なので省略

*3:この説明には誤りを含みます。厳密には光源情報もVertex shaderで前処理されている必要があり、Fragment shaderではそこで加工された情報をもとにライティングを行います。

Neobundle.vimのload_cacheを複数ファイル対応にするPRを投げたらマージされた

2015年も年の瀬が押し迫ったところで本年ブログ初めです、ご無沙汰しております。

特に大きなPullReqでもないし記事にするほどでも無い気はするけど、多分同じ問題に当たったことのある人は多いだろう(ほぼ同じ内容のissueが過去にもあった)し、何よりShougowareに初コントリビュートできたので記念記事。

github.com

問題

Neobundle.vimに今年前半に実装された、TOMLファイルでプラグインを管理する機能とbundle設定をcacheする機能を同時に試していて、以下のようなvimrcを書きました。

set runtimepath+=~/.config/nvim/bundle/neobundle.vim

call neobundle#begin('~/.config/nvim/bundle/')

if neobundle#load_cache()
  call neobundle#load_toml('plugin.define.toml')
endif

call neobundle#end()

しかしこの状態でplugin.define.tomlを更新してもNeobundle.vimのcacheが更新されず、:NeoBundleInstallしても新しく登録したプラグインがインストールされません。TOMLファイルの扱いにバグがあるのかと思い、call neobundle#load_toml('plugin.define.toml')source 'plugin.define.vim'として素直にvimscriptで書いても状況は変わらず。

原因

Neobundle.vimのコードを読んでいると、neobundle#load_cache()$MYVIMRCの更新日時がcacheの更新日時よりも新しい場合に限りcacheを再構築することが判明。neobundle#begin()neobundle#end()に挟まれた部分で使っているファイルは適当に読み込みなおしてくれるだろうとかタカをくくっていたのが大間違いでした。

結論

ということで、load_cache()複数ファイル指定ができるような修正を加えてPullReqしました。幸い大きな問題もなくマージしていただけたのでNeobundle.vimを更新すれば使えます。

if neobundle#load_cache(
     \ $MYVIMRC,
     \ 'plugin.define.toml'
     \ )
  call neobundle#load_toml('plugin.define.toml')
endif

引数を省略した場合は従来通り$MYVIMRCのみチェックします。指定できるファイル数に制限はありません。

偉大なプラグインを生み出し続けている暗黒美夢王先生に感謝しつつ、何か引っかかった時は積極的にコントリビュートできるように頑張りたいと思いました。