例題11-2:swap_buffersを乗り越える

A: さて、勢いが途切れないうちにswap_buffers()の話をしておくか。コンピュータにはビデオメモリと呼ばれるメモリがある。 グラフィックカードのカタログを見るとメモリが896MBとか書いてあるのがそうだ。VRAMと呼ばれることもあるな。 グラフィック機能を統合したチップセットなんかはメインメモリの一部がビデオメモリとして利用する。

B: ちょ、ちょっと、Aさん、前置きもなしにいきなり?

A: 次の講義の時間が迫ってるんだよ。脱線せずにずばっと行くぞ。

B: はあ、Aさんのキャラ設定がよくわからなくなってきました。Aさんは教員でしたっけ?

A: 作者がなーんも考えてないからな。そら、すぐそうやって脱線させようとする。えーと、どこまで話したっけ。

B: ビデオメモリがどうとかこうとか。

A: そうそう、ビデオメモリ。 swap_buffers()の動作を理解するためには、ビデオメモリのことを多少知っておく必要がある。 パソコンの中で、ディスプレイに描画するデータを保持しているのがビデオメモリだ。 パソコンは、適切なタイミングを見計らってディスプレイにビデオメモリの内容を転送し、受け取ったディスプレイがそれを描画する。

../_images/11-2-01.png

B: 適切なタイミング?

A: そう。パソコンの画面は1秒間に何十回コマも描画してアニメーションさせていることは知っていると思うけど、パソコンからディスプレイにじゃんじゃん送られている画像データはぱっと見た限りではどこからどこまでが1コマなのかそれ自体ではよくわからない。 最近のディスプレイではすっかり見かけなくなったが、昔のCRT方式(いわゆるブラウン管)のティスプレイだとこんな風に画面の真ん中に変なスジが出てその上側に本来の画面の下半分、下側に上半分が表示されているなんて状態になってしまう事があった。 これは描画のタイミングがずれている時の典型的な症状のひとつなんだ。

../_images/11-2-02.png

B: 子どものころに実家にあった古いテレビでこんな感じの画面見た事ありますよ。

A: そうか、全く見たことないとか言われたらどうしようかと思った。けど今の若い人だとそろそろ見たことない人も増えているだろうな。 なぜこのような事になるかは、この図を見てもらえばわかる。昔のCRT方式のディスプレイは、パソコンから送られてきたデータに従って、画面の上から順番に1ラインずつ描画する。 1ライン描画したらすぐ下のラインの先頭に戻って描画を続け、一番下のラインを描画し終わったらまた一番上のラインの描画に戻る。

B: ふむふむ。

../_images/11-2-03.png

A: この時、どこからが1コマの画面の最初のラインですよというのをディスプレイに教えてやるために、 垂直同期 と呼ばれる信号がディスプレイに送られる。 ディスプレイは垂直同期信号にかぶらないように描画をすればきちんと1コマずつ画面を描画することが出来る。 もしこれがなんらかの調整不良でずれてしまうと、パソコン側から送られてくる一番上のラインがディスプレイ上の途中のラインに対応してしまって、さっきの例のような乱れが生じる。

../_images/11-2-04.png

B: うーん、なんだか原始的というかなんというか、不思議な感じ…。

A: そんなわけで、この垂直同期信号というのは適切に画像データをディスプレイに転送するためにとても重要な信号なんだ。 画面に描画するデータが転送されている間にビデオメモリは描きかえられるべきではないから、パソコンがビデオメモリを描きかえる時にはこの垂直同期信号を監視する必要がある。

B: ? なぜ描きかえちゃいけないんですか?

A: パソコンとディスプレイのタイミングがばっちり合っていても、転送されるデータ自体が描きかえられちゃったら駄目だろ。 例えば大きな円が画面上を右に動くアニメーションを描いているとして、データ転送の途中に次のコマのデータに切り替えてしまったら、こんなふうに画面の途中で上半分は前のコマ、下半分は次のコマになっちゃったりする。

B: あ、そうか。

../_images/11-2-05.png

A: さて、改めてそんなわけで、パソコンの側は垂直同期信号を監視して、ビデオメモリの内容が転送される合間にビデオメモリを描きかえないといけない。 この時間はかなり短いので、その間に描画のための処理をするのでは処理が追いつかない恐れがある。 そこで出てきたのがダブルバッファという方式だ。

B: ダブルバッファ… 最初の頃に聞いたことがあるような。例題1でしたっけ?

A: 例題1-4。あの時には「VisionEggでは2枚のスクリーンを用意して、後ろのスクリーンにviewport.draw()で描画した後swap_buffers()で入れ替える」と説明したわけだが、 なぜ そのような手続きを踏まないといけないのかは説明しなかった。

B: あの頃は「ちゃんとした資料を見ろ」とか「いずれ」ばっかりでしたからね。今もあまり変わってないけど。

A: 何か言ったか? ダブルバッファ方式では、フロントバッファとバックバッファの2枚の画面を用意して、ディスプレイへのデータ転送にはフロントバッファを、描画にはバックバッファを用いる。 描きこみをする画面とディスプレイへ送る画面が独立しているので、いつ描きこみを行ってもディスプレイへ送る画面が乱れることはない。そして、描きこみが終了したら、垂直同期信号を見張ってディスプレイへの転送の合間にフロントバッファとバックバッファを入れ替える。この入れ替えを行うのが…

B: swap_buffers()、というわけですか。ふむふむ。

A: その通り。

../_images/11-2-06.png

B: うーん、なるほど。画面ひとつ描くにしてもコンピュータの中ではいろんなことが行われてるんですねえ。なんだか不思議だ。

A: ちなみに、画面の乱れの例はCRT方式のアナログディスプレイの話で、現在多く用いられている液晶ディスプレイや、デジタル方式のデータ転送(DVI)ではまた話が違ってくる。

B: へぇ。どうなるんですか。

A: ちゃんと調べてないからよくわからん。ちゃんと調べた人からメーカーや機種によって違うとかいう話をちらっと聞いたことがあるけど、一応これでも心理屋さんなんでそこまで機械の事に首突っ込む気もないし。

B: 十分突っ込んでると思いますが…。

A: とにかく、swap_buffers()の働きはこれでわかったかな。

B: はあ、おぼろげですが。

A: よしよし。さて、じゃあ今日のサンプルプログラムだが。

B: あの、ちょっと待ってください。

A: ん? 何?

B: swap_buffers()の働きはわかったんですが、なんでswap_buffers()の処理に時間がかかるのかわかりません。

A: ああ、忘れてた。swap_buffers()は要するにこんなwhileループのようなものだから、バックバッファへの描きこみが短時間で終了してしまったら、後はひたすら入れ替えのタイミングが来るまで待つだけなんだ。

while 入れ替え出来るタイミングではない:
    pass

B: ええと、passってなんでしたっけ。

A: 「何もしない」という命令だな。だから、何もせずにひたすら待ってるだけだ。

B: じゃあ、前回のプログラムでswap_buffers()が16ミリ秒近くかかったのは…

A: 前回のプログラムでは画面をclear()するだけで何も複雑な処理をしていないから、一瞬で描きこみ作業が終わってしまって、後はスワップ出来るタイミングが来るのをずっと待ってたってことだ。スワップの周期は17ミリ秒弱、恐らくこれは16.66…ミリ秒に対応している。これは何Hz?

B: ええと、60Hz。

A: その通り。この60Hzというのはディスプレイのリフレッシュレートに対応している。 結局のところ、ひとつのwhileループの中でevent.get()して反応を確認してswap_buffers()を実行するというプログラムを書く限り、ディスプレイのリフレッシュレートより高い周波数で反応を計測することは出来ないのさ。

B: そうだったのか…。じゃあ、リフレッシュレートの高いディスプレイと組み合わせればもっと正確に反応を測れるということですか?

A: まあ、そりゃその通りだが、リフレッシュレートの高いディスプレイはとても高価だぞ。おまけに大枚はたいて買ったとしてもせいぜい200Hzとかでそれほど劇的に改善しない。昔調べた時にモノクロディスプレイでもっと高い周波数が出るものを見つけたけど、今でも手に入るのかどうか。

B: じゃあ、どうすればいいんですか?

A: んー。一番手っ取り早いのは あきらめる こったな。

B: えええ、そんなぁ。そんなんでいいんですか?

A: んー。心理実験に限らず計測一般に言えることだと思うけど、測定の精度は高ければ高いほど良いかも知れない。しかし、精度を高めるためのコストと目標達成のために必要な精度のバランスを考えると、 ある程度の測定精度で妥協する という選択肢を選ぶ方が良いことも多い。反応時間の測定は、どれほど正確であるべきだろうか。B君はどう思う?

B: え? えーと。どれくらいだろう。…じゃあ逆に聞きますけど、反応時間の測定で17ミリ秒弱の誤差はあっても構わないんですか?

A: 質問で返してきたか。まあこの辺の議論は正解があるわけじゃないので難しいわなあ。一度あちこちの先生たちにアンケートしてみたいくらいだ。 いろいろな論文を読んでいると、条件間で反応時間が数十ミリ秒異なることを根拠に議論を展開している場合がある。こういう論文を読んでいると、17ミリ秒弱という誤差はいかにも問題があるように思える。 しかし、反応時間にはこういうPCの側の要因以外にもさまざまな統制不能な要因が影響していて、課題によっては試行毎に数百ミリ秒以上のオーダーで変動するもんなんだよな。 そして、そのような変動の中から条件による反応時間の差を読み取るために、私たちは統計学的な手法を使うわけだ。

B: …。

A: swap_buffers()による反応時間の誤差が条件間などで異なると考えるべき理由があれば議論がややこしくなるが、そう考える理由は特にないだろう。 結局のところ、心理実験で扱う多くの状況において、この程度の誤差は統計的な手法によって乗り越えられると思う。これが私の意見かな。

B: うーん。難しいなあ。「多くの状況」って、「やっぱりまずいよ」って話になる状況もあるんですか?

A: 脳波と反応時間を正確に対応させるとか、そういう生理指標とのマッチングで非常に高い精度が必要になるケースがあるみたいだね。

B: じゃあ、そういう時はどうしたらいいんですか?

A: どうしても対策の話に持っていきたいみたいだな。 実は、反応時間の計測が心理実験において非常に重要な問題なのにずっと扱いを後回しにしていたのは、対策の話が出てくるからなんだ。 正直なところ、まだきちんと「こうすればいいよ」というサンプルプログラムを準備出来ていないんだが、そのヒントになるようなプログラムは書いてある。それを紹介して今回の例題はひとまず締めくくりにしようと思っていたんだが、なんだか脱線が長くなったな。これがそのプログラムだ。

  • 行番号なしのソースファイルをダウンロード→ 11-2.py

 1import VisionEgg
 2import VisionEgg.Core
 3import VisionEgg.MoreStimuli
 4import pygame
 5
 6from pygame.locals import *
 7import math
 8import threading
 9
10screen = VisionEgg.Core.get_default_screen()
11stim = VisionEgg.MoreStimuli.Target2D(size=(50,50),color=(1.0,1.0,1.0))
12viewport = VisionEgg.Core.Viewport(screen = screen, stimuli=[stim])
13
14isDone = False
15t = []
16
17def tht():
18    global isDone
19    global t
20    counter = 0
21    while not isDone:
22        counter += 1
23        if counter % 1000 == 0:
24            counter = 0
25            t.append(VisionEgg.time_func())
26            if len(t)>=10000:
27                isDone = True
28                break
29
30th = threading.Thread(target=tht)
31
32starttime = VisionEgg.time_func()
33isStartThread = False
34
35while not isDone:
36    if (not isStartThread) and VisionEgg.time_func()-starttime > 0.5:
37        stim.parameters.color = (1.0,0.0,0.0)
38        th.start()
39        isStartThread = True
40
41    stim.parameters.position = (200*math.cos(4*VisionEgg.time_func())+512,200*math.sin(4*VisionEgg.time_func())+384)
42    for e in pygame.event.get():
43        if e.type==KEYDOWN and e.key==K_SPACE:
44            isDone = True
45    screen.clear()
46    viewport.draw()
47    VisionEgg.Core.swap_buffers()
48
49
50screen.close()

A: もう疲れてきたのでぱぱっと説明してしまうと、ここでは スレッド と呼ばれる技術を使っている。スレッドってのが何かは…まあ検索してくれ。気が向いたらいずれ書き直す。

B: なんだか急速にくたびれてきましたね。大丈夫ですか。

A: 大丈夫。8行目でthreadingパッケージをimportしている。このパッケージがこのサンプルプログラムの鍵だ。 35行目からは基本的に前回のサンプルプログラムと同一で、画面がさびしいのでくるくる回る正方形を1個描画してあるがあまり深い意味はない。 32行目で変数starttimeにwhileループ開始時刻を記録して、1秒経ったところで正方形を赤色に切り替えてthreading.Thread.start()というメソッドを呼び出している。38行目だね。 threadingはその名の通りpythonでスレッドを扱うためのクラスで、30行目でthreading.Threadのインスタンスを生成している。この時コンストラクタの引数としてtht()という関数を与えているが、start()する事によってtht()関数が別スレッドで実行される。

B: あのー、スレッドというのがよくわからないんでさっぱりわからないんですが。

A: うーん、なんて言ったらいいのかな。言うなれば、swap_buffers()を実行するとひたすらスワップのタイミングを待っちゃうので、その前に「分身の術」しておいて本体はスワップのタイミングをひたすら待ち、分身がその間に別の仕事をするというか。

B: 詳細はさっぱりわかりませんが、雰囲気はなんとなく。

A: 「本体」は38行目でスレッドを生みだした後も、引き続きwhileループを回り続けてキーボードの入力やスワップのタイミングを見張り続ける。 「分身」はtht関数の中身を実行するわけだが、こちらもwhileループになっていて、カウンターを1ずつ増加させて1000になったらその時の時刻をリストtに追加。もしtの長さが10000以上になったらisDoneにTrueをセットして終了する。tとisDoneはグローバル変数になっている事に注意。

B: ??? これは一体何をしてるんですか?

A: 前回のサンプルプログラムと同じで、ひたすらwhileループを回り続けて、どのくらいの周期でVisionEgg.time_func()を実行できるか確認しているわけだ。 これが17ミリ秒弱より短い周期でまわるのなら、「本体」で反応時間を測ろうとするよりも高い精度で測定できることになる。 まあ、実行してみればわかることだが、あっという間にtは膨大な長さになってメモリが足りなくなってしまうので、ループ1000回に1回appendして、10000個のサンプルを集めたら終了するように作ってある。 そういう気配りをしないのであれば、tht()は以下のようなシンプルな関数でいい。 今解説を書きながら気づいたけど、最後のbreakも別に要らないな。

def tht()
    global isDone
    global t
    while not isDone:
        t.append(VisionEgg.time_func())
        if len(t)>=10000 #適当な終了条件
            isDone = True
            break

B: なんだかわかったようなわからないような感じですが、とにかく実行してみますよ。

A: 前回と同じようにtの値を後でプロットするのでpylabからrunしてくれ。

B: おっと、了解了解。run 11-2.pyっと。正方形が回って…。あれっ、赤くなったと思ったらすぐ止まっちゃいましたよ?

A: tht()関数のisDoneはグローバル変数だからな。 35行目からの本体のwhileループもisDoneがFalseの間回り続けるようになっているから、tht()の中でisDoneにTrueが代入されたら本体のwhileループも終了する。

B: へ? じゃあもうtの長さが10000になっちゃったんですか?

A: pylabから立ち上げてるんだから確認すればいい。

B: len(t)は、と。確かに10000ありますね。

A: t(9999)-t(0)は?

B: 約1.57ですね。

A: 60Hzなら1秒間で60周しかループしないわけだから、10000周するには10000/60=約166秒かかる。圧倒的に速いことがわかるだろ。前回と同じようにnumpy.ndarrayに変換して差分をプロットしてみて。

B: ええと、t2 = array(t)。で、plot(diff(t2))。

A: ありゃ、縦軸の単位が秒のままだとわかりにくいな。ミリ秒にして。

B: plot(1000*diff(t2))、と。

../_images/11-2-07.png

注:実行環境はIntel Core i7 920, ASUS P6T, Windows7(x64), python2.5.4(x86)

B: ずいぶんギザギザしたグラフですね。縦軸はミリ秒だから、whileループ1周0.5ミリ秒もかからないってことですか。こりゃすごいな。

A: ループ1000周につき1サンプルだから、1周はおよそその1/1000だな。ここまで短時間になるとVisionEgg.time_func()の実行時間も無視できなくなるからそこまで話は単純じゃないが。

B: うひゃー。これだけ速ければ反応時間の計測には何も問題ないんじゃないですか?

A: ところがそう簡単にはいかないんだな。単純に考えれば、このサンプルプログラムの42行目から44行目のキー入力の部分をtht()に持っていけばそれで解決するように思うだろ。 ところが、pygameパッケージの制限により、スクリーンとイベントは同一スレッドにないと動作しないんだ。

B: えーと、相変わらずスレッドというのがよくわかってないんですが、要するにダメなんですか?

A: 要するにダメなんです。pygameはもともとpythonでゲームを作るためのパッケージで、複数のスレッドで別々に描画とキー入力を処理するなんて変態なプログラミングは想定していないんだろう。pygameをベースにした環境を使っている限り、これは避けて通れない。

B: 変態ですか。じゃあどうしたらいいんですか?

A: 要するにpygameの力を借りずに入力を拾えればいい。ひとつ考えられるのは、デジタル入出力(DIO)ボードなどを増設して、ボードにボタンスイッチをつないでそいつを読みに行く方法。ボードを増設するまでもなく、シリアルポートがある機種ならばそちらから何とか出来るかも知れない。 どちらの方法を取るにしても、一般的なパソコンを購入した時に標準的に付いてくるハードウェアの範囲を超えることになる。

B: お金を使わないというAさんのポリシーに反しますね。

A: まあ、これだけ高い精度を必要とする実験をしている人は、きっとすんごく高い装置を使ってるラボの人だろうから、DIOボードの一枚くらいなんともないとは思うが。 注意しとかないといけないのは、DIOボードを取り付けたとしても、 DIOボードの状態を読みとるための処理が必要 になるので、実際には0.5ミリ秒未満とかいった短い周期で入力をチェックできるとは限らないってことだ。 この辺りは実際にやってみないとわからないので、本当はDIOボードできちんと高精度で反応時間を取れるというサンプルプログラムを作ってから反応時間の話題を取り上げたかったんだが、なかなか取り組む時間がないのでとりあえずここまでの内容でまとめることにした。

B: いや、これはこれでいろいろ勉強になりました。お疲れさまです。

A: 最後にもうひとつの可能性を。 パソコンに入っているビデオドライバによっては、垂直同期を無視するという設定が出来る場合がある。こういうパソコンを使っているのならば、画像が乱れる可能性があることを承知の上で垂直同期を無視するという方法も取れるかも知れない。

../_images/11-2-08.png

B: へえ。これ、AさんのPCの画面ですよね? やってみたんですか?

A: うん。それがな、何も考えずにFource offしてみても全然改善しなかったんだ。もうちょっときちんと設定を煮詰めて確認しなきゃならないんだが、忙しくてなかなか手を付けられない状態。この記事を読んだ誰かがやってくれたらいいんだけど。

B: そもそもこんなページ読んでる人がいるんだかどうだか。

A: ま、とにかく今回の例題はこれでおしまい。いっぱい宿題を残してしまったから、いつか続きを書きたいな。じゃあまた次回。

補足その1

もしかしたらpygame.key.get_pressed()はスクリーンと別スレッドで実行できるかもしれません。 もっとも仮にpygame.key.get_pressed()を実行できたとしても、参照しているキーの状態がディスプレイのリフレッシュレートよりも高い周波数で更新されていなければ意味がありませんが。 誰か自動キー押し機でも作って検証してくれませんかねえ、とか言ってみたり。

補足その2

拡張ボードを使用する方法を 例題13-1 で取り上げました。DIOじゃなくてAD/DAですが。