Haskell
Gloss
graphics

glossではじめるグラフィック描画 :: Haskell入門の次に読む記事

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への依存を記述してください。

package.yaml
 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を以下のように編集します。

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関数を以下のように編集します。

app/Main.hs
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 などといった色を表す関数もデフォルトで用意されているので便利に使えます。Colotに関する型はGraphics.Gloss.Data.Colorにまとまっているので一度目を通しておくと良いでしょう。

試しに背景色をシアンにしたウィンドウを作ってみましょう。

app/Main.hs
main :: IO ()
main = display window cyan blank

ちゃんと背景色が変わりましたね!

Pictureについては後の章で詳しく説明していきます。

animate

animate関数は文字通りアニメーションを描画するための関数です。

animate :: Display            -- ^ 描画モード
        -> Color              -- ^ 背景色
        -> (Float -> Picture) -- ^ 次のフレームのグラフィックを生成する関数
                              -- ^ プログラムが開始してから経過した時間が秒単位で引数として与えられる。
        -> IO ()

さっきのdisplay関数と比較すると3番目の引数が関数になってることがわかります。この関数にはプログラムが開始してから経過した時間が与えられるので、その時間で表示するグラフィックを返すことでアニメーションを生成することが出来るというわけです。

例として円が時間とともに大きくなっていくようなプログラムを書いてみましょう。

app/Main.hs
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つを用意するのがポイントです。

例としてよくあるスクリーンセーバーを作ってみましょう。

app/Main.hs
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はウィンドウの大きさが変更された時に変更後のウィンドウのサイズを保持しています。

例として方向キーで移動する四角形を作ってみましょう。

app/Main.hs
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です。実際に表示してみましょう。

app/Main.hs
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がなんのために存在するかというとPictureMonoidのインスタンスになっており、例えばfold関数で複数のPictureを足し合わせたりする時に単位元の役割を果たしてくれるのがBlankとなります。

Blankの例で使った app/Main.hs のコードはpicture関数を差し替えながら今後も使いまわして行くことにしましょう。

Polygon

Polygonは多角形を描画するためのPictureです。保持しているPath

type Path = [Point]
type Point = (Float, Float)

と定義されていて(x, y)平面の点のリストになっています。このリストで指定された点に沿って多角形が描画されるというわけです。実際に表示してみましょう。

app/Main.hs
picture = polygon [(0, 150), (150, 50), (100, -100), (-100, -100), (-150, 50)]

簡単に五角形を書くことができました!

特に四角形はよく使う図形ですが、毎回Pathを指定するのは手間です。glossではrectangleSolidという幅と高さを指定して四角形のPictureを作る関数が用意されています。

picture = rectangleSolid 200 150

簡単に四角形が書けましたね!

Line

Lineは線分を描画するためのPictureです。さっそく表示してみましょう。

app/Main.hs
picture = line [(0, 150), (150, 50), (100, -100), (-100, -100), (-150, 50)]

わかりやすいようにPolygonの時と同じパラメータで描画してみました。Polygonと違う点は

  • 中が塗りつぶされていない
  • 終点として始点を指定しないと線が閉じない

といったところでしょうか。2点目に関しては明示的に閉じなくても閉じた線分を書いてくれる lineLoopという関数も用意されています。

Lineに関しても四角形を簡単に書く関数が用意されています。

picture = rectangleWire 200 150

Circle

Circleは与えられた半径の円を描画するPictureです。

app/Main.hs
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を編集して依存するバージョンを指定します。

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を編集して依存関係を指定します。

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を使って作ったことがあります。

(ソースコードはこちら

みなさんもglossを使ってアイデアを実現してHaskellプログラミングを楽しんでください(^^)/