glossはとても簡単にグラフィックを描画できるHaskellのライブラリです。この記事はgloss入門ということで関数・型・型クラスなどのHaskellの基本的な文法は理解した人向けにglossの使い方を丁寧に書いていきたいと思います。
環境構築
プロジェクトの管理はstackを使って進めていきます。stackを知らない/まだ使っていない人は以下の記事を参考にstackを使える環境を構築してください。
stack
コマンドが使えるようになったら早速プロジェクトを作りましょう。
$ stack new gloss-tutorial
以下のようなファイルが生成されるかと思います(ちなみに使用しているstackのバージョンは1.7.1です)。
$ tree gloss-tutorial
gloss-tutorial
├── ChangeLog.md
├── LICENSE
├── README.md
├── Setup.hs
├── app
│ └── Main.hs
├── gloss-tutorial.cabal
├── package.yaml
├── src
│ └── Lib.hs
├── stack.yaml
└── test
└── Spec.hs
glossを使うためにライブラリの依存関係を指定します。package.yaml
を開いてexecutablesの項目に以下のようにglossへの依存を記述してください。
executables:
gloss-tutorial-exe:
main: Main.hs
source-dirs: app
ghc-options:
- -threaded
- -rtsopts
- -with-rtsopts=-N
dependencies:
+ - gloss
- gloss-tutorial
以上でglossを使ったプログラミングを始める準備が整いました!
Hello World
まずは何も考えずに定番の"Hello World"と画面に出力するプログラムを書いてみましょう。app/Main.hs
を以下のように編集します。
module Main where
import Graphics.Gloss
window :: Display
window = InWindow "Hello World" (640, 480) (100, 100)
main :: IO ()
main = display window white (text "Hello world")
保存したら以下のようにコマンドを打って実行してみましょう。
$ stack build
$ stack exec gloss-tutorial-exe
初回のビルドだとglossを依存関係を含めてインストールするのに時間がかかるかもしれません。うまく実行できれば以下のような画面が表示されるはずです。
"World"がどこかに行ってしまいましたね?
文字が大きすぎることが原因のようで画面からはみ出してしまってるみたいです。文字の大きさを小さくしてついでに画面の中央に文字が表示されるように移動させてみましょう。app/Main.hs
のmain関数を以下のように編集します。
main :: IO ()
main = display window white
(translate (-150) (-10) . scale 0.5 0.5 $ text "Hello world")
ビルドして実行してみましょう。
ちゃんと文字が真ん中に表示されましたね!
glossの基本
それでは順を追ってglossの使い方を見ていきましょう。glossを使うときはまずGraphics.Glossをimportするのが基本です。この中でまず紹介したいのはプログラム全体が何を行うかを決めるdisplay
, animate
, simulate
, play
という4つの関数です。以下ではこれらの関数についてそれぞれ説明していきます。
display
Hello Worldでも使用したdisplay
関数は単純なグラフィックを描画するために使われます。
display :: Display -- ^ 描画モード
-> Color -- ^ 背景色
-> Picture -- ^ 描画するグラフィック
-> IO ()
型を見ればすぐわかるように、描画モード・背景色・描画するグラフィックの3つを指定すればウィンドウを生成し、そのとおりにグラフィックを描画してくれます。
data Display = InWindow String (Int, Int) (Int, Int)
| FullScreen
Display
型は2つの値から成ります。InWindow
はウィンドウのタイトル・ウィンドウのサイズ・ウィンドウのポジションを指定して設定したとおりのウィンドウを表す値となります。FullScreen
は文字通りフルスクリーンモードです。
Color
型は文字通り色を表す方です。makeColor
という関数を使えばRGBAを指定して色を作ることができます。
makeColor :: Float -- ^ Red
-> Float -- ^ Green
-> Float -- ^ Blue
-> Float -- ^ Alpha
-> Color
またblack
, whit
, red
, blue
, green
などといった色を表す関数もデフォルトで用意されているので便利に使えます。Color
に関する型はGraphics.Gloss.Data.Colorにまとまっているので一度目を通しておくと良いでしょう。
試しに背景色をシアンにしたウィンドウを作ってみましょう。
main :: IO ()
main = display window cyan blank
ちゃんと背景色が変わりましたね!
Picture
については後の章で詳しく説明していきます。
animate
animate
関数は文字通りアニメーションを描画するための関数です。
animate :: Display -- ^ 描画モード
-> Color -- ^ 背景色
-> (Float -> Picture) -- ^ 次のフレームのグラフィックを生成する関数
-- ^ プログラムが開始してから経過した時間が秒単位で引数として与えられる。
-> IO ()
さっきのdisplay
関数と比較すると3番目の引数が関数になってることがわかります。この関数にはプログラムが開始してから経過した時間が与えられるので、その時間で表示するグラフィックを返すことでアニメーションを生成することが出来るというわけです。
例として円が時間とともに大きくなっていくようなプログラムを書いてみましょう。
main :: IO ()
main = animate window white (\t -> circle (5 * t))
ビルドして実行してみましょう。
じわっと円が大きくなっていくアニメーションが作れましたね!
animate
を使ってマンデルブロ集合を描画した例もあって面白いですよ。
simulate
simulate
関数は状態を管理することが可能なシミュレーションを行うための関数です。
simulate :: Display -- ^ 描画モード
-> Color -- ^ 背景色
-> Int -- ^ 1秒あたりのステップ数
-> model -- ^ 初期状態
-> (model -> Picture) -- ^ 状態を描画する関数
-> (ViewPort -> Float -> model -> model) -- ^ 現在の状態を次の状態に発展させる関数
-- ^ 引数としてビューポートと前のステップから経過した時間が与えられる
-> IO ()
- 状態
- 状態を描画する関数
- 現在の状態を次の状態に発展させる関数
の3つを用意するのがポイントです。
例としてよくあるスクリーンセーバーを作ってみましょう。
module Main where
import Graphics.Gloss
import Graphics.Gloss.Data.ViewPort
-------------------
-- Display の設定
-------------------
windowWidth, windowHeight :: Num a => a
windowWidth = 640
windowHeight = 480
window :: Display
window = InWindow "Hello World" (windowWidth, windowHeight) (100, 100)
--------------------------
-- シミュレーションの実装
--------------------------
boxWidth, boxHeight :: Float
boxWidth = 50
boxHeight = 50
data BoxState = BoxState
{ _x :: Float -- x 座標の位置
, _y :: Float -- y 座標の位置
, _vx :: Float -- x 方向の速度
, _vy :: Float -- y 方向の速度
}
initialBox :: BoxState
initialBox = BoxState 0 0 150 150
drawBox :: BoxState -> Picture
drawBox box = translate (_x box) (_y box) $ rectangleSolid boxWidth boxHeight
nextBox :: ViewPort -> Float -> BoxState -> BoxState
nextBox vp dt box =
let -- 速度を考慮した次のステップでの位置を計算
x = _x box + _vx box * dt
y = _y box + _vy box * dt
-- 壁との当たり判定
isOverTop = y > (windowHeight - boxHeight) / 2
isOverBottom = y < -(windowHeight - boxHeight) / 2
isOverRight = x > (windowWidth - boxWidth) / 2
isOverLeft = x < -(windowWidth - boxWidth) / 2
-- 壁と衝突していれば速度を反転させる
vx = if isOverRight || isOverLeft then (-_vx box) else (_vx box)
vy = if isOverTop || isOverBottom then (-_vy box) else (_vy box)
in BoxState x y vx vy
-------------
-- main 関数
-------------
main :: IO ()
main = simulate window white 24 initialBox drawBox nextBox
ビルドして実行してみましょう。
よくあるスクリーンセーバーが作れましたね!
細かいテクニックですが
windowWidth, windowHeight :: Num a => a
このように型を指定しておくことでwindowWidth
,windowHeight
をIntとしてもFloatとしても使えたりするので便利です。
simulate
関数を使えば運動方程式を解く物理シミュレーションだったりライフゲームなんかも作ることができます。
例の中では使いませんでしたがViewPort
について説明しておきましょう。実はこれまでに紹介したdisplay
, animate
, simulate
の3つの関数にはデフォルトで以下の機能が備わっています。
- 終了
- Escで終了する
- 画面の移動
- 方向キーで移動する
- 左クリック&ドラッグで移動する
- ズーム
- page up/downキーでズームイン/アウト
- Control+左クリック&ドラッグでズームイン/アウト
- 右クリック&ドラッグでズームイン/アウト
- マウスホイールでズームイン/アウト
- 回転
- home/endキーで回転
- Alt+左クリック&ドラッグで回転
- ビューポートのリセット
-
r
キーを押してリセット
-
多機能ですね!ViewPort
にはユーザーによって操作された現在のビューポートの情報が入っています。
data ViewPort = ViewPort
{ viewPortTranslate :: !(Float, Float) -- 移動した座標
, viewPortRotate :: !Float -- 回転した角度
, viewPortScale :: !Float -- ズームしたスケール
}
play
play
関数はシミュレーションに加えて、マウスやキーボードのイベントを取得することが出来るためユーザーとのインタラクティブなアクションを実装することができる関数です。
play :: Display -- ^ 描画モード
-> Color -- ^ 背景色
-> Int -- ^ 1秒あたりのステップ数
-> world -- ^ 初期状態
-> (world -> Picture) -- ^ 状態を描画する関数
-> (Event -> world -> world) -- ^ イベントを処理して状態を発展させる関数
-> (Float -> world -> world) -- ^ 現在の状態を次の状態に発展させる関数
-- ^ 引数として前のステップから経過した時間が与えられる
-> IO ()
simulate
関数との違いはViewPortが引数に与えられなくなったところとイベントを処理して状態を発展させる関数が増えたところだと思います。Event
は全てのイベントを表す型です。
data Event = EventKey Key KeyState Modifiers (Float, Float) -- ^ キーボードの押下とマウスのクリックイベント
| EventMotion (Float, Float) -- ^ マウスの移動イベント
| EventResize (Int, Int) -- ^ ウィンドウのリサイズイベント
deriving (Eq, Show)
1つずつ説明していきましょう。EventKey
はキーボードの押下とマウスのクリックを表すイベントで、4つの値を保持しています。
型 | 説明 |
---|---|
Key |
押下されたキーもしくはマウスのクリックの種類 |
KeyState |
押されたのか、離されたのか |
Modifiers |
Shift, Ctrl, Altキーの状態 |
(Float, Float) |
マウスカーソルの位置 |
Key
は以下のような型になっています。
data Key = Char Char -- 文字キー
| SpecialKey SpecialKey -- 特殊キー
| MouseButton MouseButton -- マウスクリック
deriving (Show, Eq, Ord)
abcなど通常も文字キーが押された場合はChar
に押された文字が保持された状態で渡されます。SpecialKey
はスペースや十字キーやファンクションキーなどです。非常に種類が多いのでリンク先のリストを見てもらえると理解しやすいかと思います(SpecialKey
の定義)。MouseButton
は文字通りクリックされたマウスのボタンの種類で以下のような型になっています。
data MouseButton = LeftButton -- ^ 左クリック
| MiddleButton -- ^ ホイールクリック
| RightButton -- ^ 右クリック
| WheelUp -- ^ ホイール上回転
| WheelDown -- ^ ホイール下回転
| AdditionalButton Int -- ^ その他のボタン
deriving (Show, Eq, Ord)
KeyState
は単純で以下のような型です。
data KeyState = Down -- ^ 押された
| Up -- ^ 離された
deriving (Show, Eq, Ord)
Modifiers
にはShift, Ctrl, Altの状態がレコードとして保持されています。
data Modifiers = Modifiers
{ shift :: KeyState -- ^ Shiftキーの状態
, ctrl :: KeyState -- ^ Ctrlキーの状態
, alt :: KeyState -- ^ Altキーの状態
} deriving (Show, Eq, Ord)
2つ目のイベントの値であるEventMotion
はウィンドウ上でマウスを移動させたときのウィンドウの座標でのマウスカーソルの位置を保持しています。
3つ目のイベントの値であるEventResize
はウィンドウの大きさが変更された時に変更後のウィンドウのサイズを保持しています。
例として方向キーで移動する四角形を作ってみましょう。
module Main where
import Graphics.Gloss
import Graphics.Gloss.Interface.IO.Game
-------------------
-- Display の設定
-------------------
windowWidth, windowHeight :: Num a => a
windowWidth = 640
windowHeight = 480
window :: Display
window = InWindow "Hello World" (windowWidth, windowHeight) (100, 100)
--------------------------
-- シミュレーションの実装
--------------------------
boxWidth, boxHeight :: Float
boxWidth = 50
boxHeight = 50
data BoxState = BoxState
{ _x :: Float -- x 座標の位置
, _y :: Float -- y 座標の位置
, _vx :: Float -- x 方向の速度
, _vy :: Float -- y 方向の速度
}
initialBox :: BoxState
initialBox = BoxState 0 0 0 0
drawBox :: BoxState -> Picture
drawBox box = translate (_x box) (_y box) $ rectangleSolid boxWidth boxHeight
-- | イベントを処理する関数。EventKey以外のイベントは無視する
updateBox :: Event -> BoxState -> BoxState
updateBox (EventKey key ks _ _) box = updateBoxWithKey key ks box
updateBox (EventMotion _) box = box
updateBox (EventResize _) box = box
-- | 上下左右の速度を与える関数
up, down, right, left :: BoxState -> BoxState
up box = box { _vy = _vy box + 100 }
down box = box { _vy = _vy box - 100 }
right box = box { _vx = _vx box + 100 }
left box = box { _vx = _vx box - 100 }
-- | 方向キーとWASDキーに対応して四角形を移動させる
updateBoxWithKey :: Key -> KeyState -> BoxState -> BoxState
updateBoxWithKey (SpecialKey KeyUp) ks = if ks == Down then up else down
updateBoxWithKey (SpecialKey KeyDown) ks = if ks == Down then down else up
updateBoxWithKey (SpecialKey KeyRight) ks = if ks == Down then right else left
updateBoxWithKey (SpecialKey KeyLeft) ks = if ks == Down then left else right
updateBoxWithKey (Char 'w') ks = if ks == Down then up else down
updateBoxWithKey (Char 's') ks = if ks == Down then down else up
updateBoxWithKey (Char 'd') ks = if ks == Down then right else left
updateBoxWithKey (Char 'a') ks = if ks == Down then left else right
updateBoxWithKey _ _ = id
nextBox :: Float -> BoxState -> BoxState
nextBox dt box =
let -- 速度を考慮した次のステップでの位置を計算
x = _x box + _vx box * dt
y = _y box + _vy box * dt
in box { _x = x, _y = y }
-------------
-- main 関数
-------------
main :: IO ()
main = play window white 24 initialBox drawBox updateBox nextBox
ビルドして実行してみましょう。
うまく動いていますね!
play関数の中で動的にファイルを読み込んだり外部に通信したりしたくなることがあるかもしれません。その時はGraphics.Gloss.Interface.IO.Game
で公開されているplayIO
関数が使えます。
playIO :: Display
-> Color
-> Int
-> world
-> (world -> IO Picture)
-> (Event -> world -> IO world)
-> (Float -> world -> IO world)
-> IO ()
play関数とほとんど同じ型ですが、描画・イベントハンドリング・状態を発展させる関数それぞれの返り値がIOに包まれているためこれらの関数の中で副作用を起こすことができます。
Picture
Picture
はglossでのグラフィック描画において一番大事な概念です。
data Picture =
-- | プリミティブ系
Blank -- ^ なにもない空白のPicture
| Polygon Path -- ^ 内部が塗りつぶされた多角形
| Line Path -- ^ パスに沿った線
| Circle Float -- ^ 与えられた半径の円(内部は塗りつぶされていない)
| ThickCircle Float Float -- ^ 厚みのある円
-- ^ thicknessが0となるときはCircleに一致する
| Arc Float Float Float -- ^ 与えられた2つの角度の間の円弧
| ThickArc Float Float Float Float -- ^ 与えられた2つの角度の間の厚みのある円弧
-- ^ thicknessが0となるときはArcに一致する
| Text String -- ^ ベクターフォントで描画されるテキスト
| Bitmap Int Int BitmapData Bool -- ^ ビットマップデータ
-- | 変換系
| Color Color Picture -- ^ 特定の色で塗られた Picture
| Translate Float Float Picture -- ^ x, y軸方向に移動したPicture
| Rotate Float Picture -- ^ 回転したPicture
| Scale Float Float Picture -- ^ x, y方向にスケールしたPicture
-- | 複合系
| Pictures [Picture] -- ^ いくつかのPictureをまとめたもの
deriving (Show, Eq, Data, Typeable)
それぞれの値構築子に対応した小文字の関数も用意されており、通常はそちらを使ってPictureを組み立てていきます。それぞれどのような図形が描画できるのか、1つずつ見ていきましょう。
Blank
Blank
はなにもないPictureです。実際に表示してみましょう。
import Graphics.Gloss
window :: Display
window = InWindow "Hello World" (640, 480) (100, 100)
picture :: Picture
picture = blank
main :: IO ()
main = display window white picture
本当に何も表示されず、背景色の白が見えているだけですね。Blankがなんのために存在するかというとPicture
はMonoid
のインスタンスになっており、例えばfold関数で複数のPictureを足し合わせたりする時に単位元の役割を果たしてくれるのがBlankとなります。
Blankの例で使った app/Main.hs
のコードはpicture関数を差し替えながら今後も使いまわして行くことにしましょう。
Polygon
Polygonは多角形を描画するためのPictureです。保持しているPath
は
type Path = [Point]
type Point = (Float, Float)
と定義されていて(x, y)平面の点のリストになっています。このリストで指定された点に沿って多角形が描画されるというわけです。実際に表示してみましょう。
picture = polygon [(0, 150), (150, 50), (100, -100), (-100, -100), (-150, 50)]
簡単に五角形を書くことができました!
特に四角形はよく使う図形ですが、毎回Pathを指定するのは手間です。glossではrectangleSolid
という幅と高さを指定して四角形のPictureを作る関数が用意されています。
picture = rectangleSolid 200 150
簡単に四角形が書けましたね!
Line
Lineは線分を描画するためのPictureです。さっそく表示してみましょう。
picture = line [(0, 150), (150, 50), (100, -100), (-100, -100), (-150, 50)]
わかりやすいようにPolygonの時と同じパラメータで描画してみました。Polygonと違う点は
- 中が塗りつぶされていない
- 終点として始点を指定しないと線が閉じない
といったところでしょうか。2点目に関しては明示的に閉じなくても閉じた線分を書いてくれる lineLoop
という関数も用意されています。
Lineに関しても四角形を簡単に書く関数が用意されています。
picture = rectangleWire 200 150
Circle
Circleは与えられた半径の円を描画するPictureです。
picture = circle 100
見事な円が書けました!中身が塗りつぶされないということもわかると思います。
中身が塗りつぶされた円を書くためにはcircleSolid
という関数を使います。
picture = circleSolid 100
ThickCircle
ThickCircleは厚みのある円を描画するためのPictureです。
picture = thickCircle 100 10
半径100, 厚み10の円を書いているため、一番外側の円の半径は厚みの半分がプラスされた105となっています。
Arc
Arcは円弧を描画するためのPictureです。
picture = arc 30 150 100
arc関数には開始の角度・終了の角度・半径という順番で引数を与えます。角度は度数法で指定します(つまり一周が360となります)。
円弧だけでなく半径も描画して扇形を作るにはsectorWire
関数を使います。
picture = sectorWire 30 150 100
更に塗りつぶした扇形を描画するにはarcSolid
関数を使います。
picture = arcSolid 30 150 100
ThickArc
ThickArcは厚みのある円弧を描画するためのPictureです。
picture = thickArc 30 150 100 10
thickArc関数には開始の角度・終了の角度・半径・厚みという順番で引数を与えます。角度は度数法で指定します。
Text
Textは文字を描画するためのPictureです。Hello Worldの際にも使用しましたね。
picture = text "gloss"
しかしTextでは日本語が表示できないので注意が必要です。もし日本語を表示したいのであれば画像ファイルとして書き出したものを後述する方法で読み込んで表示するのが良いでしょう。
Bitmap
Bitmapは画像データを表示するためのPictureですが直接使うことはあまりないでしょう。いわずもがな画像データはBitmapだけでなくjpgやpngなど沢山の種類があります。Haskellで画像を扱うライブラリとしてはJuicyPixelsが有名ですが、JuicyPixelsの機能を利用して色んな種類の画像ファイルをglossのPictureとして読み込んでくれるgloss-juicyというライブラリを使って画像を表示してみましょう。
gloss-juicyは残念ながらStackageに取り込まれていないのでstack.yaml
を編集して依存するバージョンを指定します。
# Dependency packages to be pulled from upstream that are not in the resolver
# using the same syntax as the packages field.
# (e.g., acme-missiles-0.3)
-# extra-deps: []
+extra-deps:
+- gloss-juicy-0.2.2
次にpackage.yaml
を編集して依存関係を指定します。
- -rtsopts
- -with-rtsopts=-N
dependencies:
- gloss
+ - gloss-juicy
- gloss-tutorial
表示する画像image.png
を準備して表示してみましょう。
import Graphics.Gloss
import Graphics.Gloss.Juicy
window :: Display
window = InWindow "Hello World" (640, 480) (100, 100)
main :: IO ()
main = do
Just img <- loadJuicy "image.png"
display window white (scale 0.5 0.5 img)
画像はそのままだと少し大きかったので後述するscaleを使って半分のサイズにしています。画像ファイルはいらすとやさんで配布されているものを使用させていただきました。
重要なのはloadJuicy
という関数で画像ファイルのパスを渡せばglossのPictureとして読み込んでくれるというものです。
loadJuicy :: FilePath -> IO (Maybe Picture)
内部では読み込んだ画像をBitmapに変換してPictureにしています。
Color
Colorは元のPictureの色を変更させたPictureを作ります。
picture = color red $ rectangleSolid 200 150
Translate
Translateは元のPictureをx, y軸方向に平行移動させたPictureを作ります。
picture = translate 50 100 $ rectangleSolid 200 150
glossの座標系は画面の中心が原点となっており、y軸は上方向が正、x軸は右方向が正となっています。
Rotate
Rotateは元のPictureを回転させたPictureを作ります。
picture = rotate 30 $ rectangleSolid 200 150
Scale
Scaleは元のPictureの拡大縮小を行ったPictureを作ります。
picture = scale 2 0.5 $ polygon [(0, 150), (150, 50), (100, -100), (-100, -100), (-150, 50)]
Polygonのところで書いた五角形を横に伸ばし縦に潰してみました。
Pictures
Picturesは複数のPictureを組み合わせて1つのPictureを作ります。これを使うことで画面上にたくさんのPictureを並べることができます。
picture = pictures
[ translate 100 100 $ color red $ circleSolid 50
, translate 100 (-100) $ color blue $ rectangleSolid 60 80
, translate (-100) (-100) $ color green $ arcSolid 30 150 100
, translate (-100) 100 $ color orange $ rectangleWire 100 100
]
まとめ
以上2Dグラフィック描画ライブラリglossの基本的な使い方を見てきました。何か作りたいもののアイデアは湧いてきたでしょうか?単純なシミュレーションであればglossを使えば本当に一瞬で作ることができます。僕自身、二重振り子のシミュレーションをglossを使って作ったことがあります。
Haskellで二重振り子のシミュレーションを書いた。実はこの振り子50本あって初期値を1兆分の1ぐらいずらしてあるので途中からめちゃくちゃ分岐する pic.twitter.com/3dh4Metcpj
— lotz△ (@lotz84_) 2017年5月22日
(ソースコードはこちら)
みなさんもglossを使ってアイデアを実現してHaskellプログラミングを楽しんでください(^^)/