Tech Notes

Windows APIでペンタブの入力を取得する(RealtimeStylus)

Windows でペンタブ・液タブの筆圧情報などを取ろうとした場合、「WinTab」という API と「Tablet PC API」という主に 2 つの API がある。

2 つのうち WinTab の方はMicroSoftでなくWacom社が出しているものであるため、他の会社のペンタブとの互換性をあまり期待できない。なので Wacom と生涯を共にし永遠の愛を誓うつもりが無いなら、基本的には Tablet PC API の方を使うべきだと思う。

その一方で Tablet PC API の方を用いた筆圧取得については日本語圏にあまり分かりやすい解説記事が無いため、他の人にも役立つようここにまとめてみる。


サンプルコード

最初にサンプルコードを置いておく。ペンなどでタッチした位置、筆圧などを表示する動作デモ。

sample.cpp


目次

Tablet PC API について

Tablet PC APIは、別にペンタブの入力データを取るためだけの API ではない。手書き入力の認識なども含めた多機能な API 群である。

純粋にペン入力の値だけを取りたい場合、Tablet PC API の一部分であるRealTimeStylus APIを用いることになる。

いわゆる COM の形で提供されている API なので COM の基礎知識があると学習しやすい。いわゆるCoCreateInstanceとかやるやつである。COM の基礎知識については以下の記事シリーズが分かりやすかった。ところどころ翻訳が怪しいが、例を交えてとても分かりやすく書いてあるので一読の価値はある。

Windows-Based プログラムでの COM の使用

RealTimeStylus APIの大雑把な使い方

雑に説明すればこんな感じである。

  1. イベントハンドラを作成する
  2. RealTimeStylusオブジェクトを作成して初期化
  3. ペン入力に応じてイベントハンドラにイベントが飛んでくる

細かい実装はともかく、大雑把な仕組みとしてはそこまで複雑ではない。

どうやら後続のイベントハンドラの為に情報をフィルタするミドルウェアっぽい使い方もできるようなのだが、使い道があんまり思いつかない。

インクルードファイル

RealTimeStylus APIを使うには以下のファイルをインクルードする。

#include <ole2.h>
#include <rtscom.h>
#include <rtscom_i.c>

CComPtrを使う場合はatlbase.hも追加

初期化処理

大体こんな感じになる。

CComPtr<IRealTimeStylus> pRTS;
pRTS.CoCreateInstance(__uuidof(RealTimeStylus));
handler = new MyEventHandler();     // 後述
pRTS->AddStylusAsyncPlugin(0, handler);

GUID lWantedProps[] = {
    GUID_PACKETPROPERTY_GUID_X,
    GUID_PACKETPROPERTY_GUID_Y,
    GUID_PACKETPROPERTY_GUID_NORMAL_PRESSURE,
};
pRTS->SetDesiredPacketDescription(std::size(lWantedProps), lWantedProps);
pRTS->put_HWND((HANDLE_PTR)hWnd);
pRTS->put_Enabled(true);

各行について順に説明する。

IRealTimeStylusオブジェクトの生成

まずIRealTimeStylusインターフェースを実装するRealTimeStylusオブジェクトを作成する。

IRealTimeStylus *pRTS;
CoCreateInstance(__uuidof(RealTimeStylus), NULL, CLSCTX_ALL, IID_PPV_ARGS(&pRTS));

CComPtr を利用するならこう

CComPtr<IRealTimeStylus> pRTS;
pRTS.CoCreateInstance(__uuidof(RealTimeStylus));

イベントハンドラの指定

イベントを受信するためのハンドラオブジェクトをここに指定する。ハンドラの実装については後述。

MyEventHandler* handler = new MyEventHandler;
pRTS->AddStylusAsyncPlugin(0, handler);  // AsyncPluginを使う場合
pRTS->AddStylusSyncPlugin(0, handler);  // SyncPluginを使う場合

受け取るデータの指定

GUID lWantedProps[] = {
    GUID_PACKETPROPERTY_GUID_X,
    GUID_PACKETPROPERTY_GUID_Y,
    GUID_PACKETPROPERTY_GUID_NORMAL_PRESSURE,
};
pRTS->SetDesiredPacketDescription(std::size(lWantedProps), lWantedProps);

送られてる入力イベントには複数の情報が付いてくる。デバイスによって位置、筆圧、ペンの向きなどが含まれるが、アプリケーションはそれらの中から欲しいものだけを受け取ることができる。アプリケーションが受信を希望する&デバイスが提供しているデータのみがハンドラに送られてくる。

受信したい入力情報はSetDesiredPacketDescriptionで指定すればよい。使える定数は以下のURLにまとまっている。

https://learn.microsoft.com/ja-jp/windows/win32/tablet/packetpropertyguids-constants

その他の初期化

// 対象ウィンドウの指定
pRTS->put_HWND((HANDLE_PTR)hWnd);
// 入力の有効化
pRTS->put_Enabled(true);

これでイベントの受信が始まる。

イベントハンドラの実装

入力イベントを受け取るにはイベントハンドラを定義する必要がある。公式にはPluginと呼ばれている模様。 ペンでタッチされただの、ペンが動いただのという情報はここに送られてくる。

ハンドラクラスはIStylusSyncPluginまたはIStylusAsyncPluginインターフェースを継承して作る。

どちらも使えるのだが、Sync の方だとイベント処理に時間がかかった場合にその間も待機されるため、後続のイベント含めて反応が遅れるような挙動になる。処理が重くなる可能性があるならおそらく AsyncPluginを使った方がよい。この辺の使い分けの明確な基準は正直よく分かっていない。

IStylusSyncPlugin/IStylusAsyncPluginのメソッドは17(+IUnknown の 3 つ)あるが、興味のあるイベントに対応するものだけ実装すれば良いので、そう大変なものではない。興味の無いものは単に空にしてS_OKを返せばよい。

STDMETHOD(Packets)(IRealTimeStylus *pStylus, const StylusInfo *pStylusInfo, ULONG nPackets, ULONG nPacketBuf, LONG *pPackets, ULONG *nOutPackets, LONG **ppOutPackets);
STDMETHOD(InAirPackets)(IRealTimeStylus *pStylus, const StylusInfo *pStylusInfo, ULONG nPackets, ULONG nPacketBuf, LONG *pPackets, ULONG *nOutPackets, LONG **ppOutPackets);
STDMETHOD(DataInterest)(RealTimeStylusDataInterest *pEventInterest);
STDMETHOD(StylusDown)(IRealTimeStylus *pStylus, const StylusInfo *pStylusInfo, ULONG nPropCountPerPkt, LONG *_pPackets, LONG **ppOutPackets);
STDMETHOD(StylusUp)(IRealTimeStylus *pStylus, const StylusInfo *pStylusInfo, ULONG nPropCountPerPkt, LONG *_pPackets, LONG **ppOutPackets);
STDMETHOD(RealTimeStylusEnabled)(IRealTimeStylus *pStylus, ULONG nPropCountPerPkt, const TABLET_CONTEXT_ID *pTcid);
STDMETHOD(RealTimeStylusDisabled)(IRealTimeStylus *pStylus, ULONG nPropCountPerPkt, const TABLET_CONTEXT_ID *pTcid);
STDMETHOD(StylusInRange)(IRealTimeStylus *pStylus, TABLET_CONTEXT_ID tcid, STYLUS_ID sid);
STDMETHOD(StylusOutOfRange)(IRealTimeStylus *pStylus, TABLET_CONTEXT_ID tcid, STYLUS_ID sid);
STDMETHOD(StylusButtonUp)(IRealTimeStylus *pStylus, STYLUS_ID sid, const GUID *pGuid, POINT *pStylusPos);
STDMETHOD(StylusButtonDown)(IRealTimeStylus *pStylus, STYLUS_ID sid, const GUID *pGuid, POINT *pStylusPos);
STDMETHOD(SystemEvent)(IRealTimeStylus *pStylus, TABLET_CONTEXT_ID tcid, STYLUS_ID sid, SYSTEM_EVENT ev, SYSTEM_EVENT_DATA evData);
STDMETHOD(TabletAdded)(IRealTimeStylus *pStylus, IInkTablet *piTablet);
STDMETHOD(TabletRemoved)(IRealTimeStylus *pStylus, LONG iTabletIndex);
STDMETHOD(CustomStylusDataAdded)(IRealTimeStylus *pStylus, const GUID *pGuid, ULONG cbData, const BYTE *pbData);
STDMETHOD(Error)(IRealTimeStylus *pStylus, IStylusPlugin *, RealTimeStylusDataInterest, HRESULT hrErr, LONG_PTR *lptrKey);
STDMETHOD(UpdateMapping)(IRealTimeStylus *pStylus);

この中で必須なのは実質DataInterest()だけで、これは受信する入力イベントを選択するためのメソッド。

STDMETHOD(DataInterest)(RealTimeStylusDataInterest *pEventInterest) {
    *pEventInterest = (RealTimeStylusDataInterest)(RTSDI_AllData);
    return S_OK;
}

イベントの種類はビットマスクで表されていて、全て受け取る場合はRTSDI_AllDataを指定すればよい。

使える定数の一覧はこちらを参照: https://learn.microsoft.com/en-us/windows/win32/api/rtscom/ne-rtscom-realtimestylusdatainterest

ハンドラの最小コード例

class MyEventHandler : public IStylusSyncPlugin {
    LONG cRef;
    IUnknown *pMarshaller;
  public:
    MyEventHandler() : cRef(1) {
        if(FAILED(CoCreateFreeThreadedMarshaler(this, &pMarshaller))) throw std::exception("failed to init Handler");
    }
    virtual ~MyEventHandler() {
        if (pMarshaller != NULL) pMarshaller->Release();
    }

    STDMETHOD_(ULONG, AddRef)() {
        return InterlockedIncrement(&cRef);
    }
    STDMETHOD_(ULONG, Release)() {
        ULONG nNewRef = InterlockedDecrement(&cRef);
         if (nNewRef == 0) delete this;
        return nNewRef;
    }
    STDMETHOD(QueryInterface)(REFIID riid, LPVOID *ppvObj) {
        if ((riid == IID_IStylusSyncPlugin) || (riid == IID_IUnknown)) {
            *ppvObj = this;
            AddRef();
            return S_OK;
        } else if ((riid == IID_IMarshal) && (pMarshaller != NULL)) {
            return pMarshaller->QueryInterface(riid, ppvObj);
        }
        *ppvObj = NULL;
        return E_NOINTERFACE;
    }

    STDMETHOD(DataInterest)(RealTimeStylusDataInterest *pEventInterest) {
        // 必要なイベントをビットマスクで指定
        *pEventInterest = (RealTimeStylusDataInterest)(RTSDI_AllData);
        return S_OK;
    }

    // 興味のあるものを実装
    STDMETHOD(Packets)(IRealTimeStylus *, const StylusInfo *, ULONG, ULONG, LONG *, ULONG *, LONG **) { return S_OK; }
    STDMETHOD(InAirPackets)(IRealTimeStylus *, const StylusInfo *, ULONG, ULONG, LONG *, ULONG *, LONG **) { return S_OK; }
    STDMETHOD(StylusDown)(IRealTimeStylus *, const StylusInfo *, ULONG, LONG *_pPackets, LONG **) { return S_OK; }
    STDMETHOD(StylusUp)(IRealTimeStylus *, const StylusInfo *, ULONG, LONG *_pPackets, LONG **) { return S_OK; }
    STDMETHOD(RealTimeStylusEnabled)(IRealTimeStylus *, ULONG, const TABLET_CONTEXT_ID *) { return S_OK; }
    STDMETHOD(RealTimeStylusDisabled)(IRealTimeStylus *, ULONG, const TABLET_CONTEXT_ID *) { return S_OK; }
    STDMETHOD(StylusInRange)(IRealTimeStylus *, TABLET_CONTEXT_ID, STYLUS_ID) { return S_OK; }
    STDMETHOD(StylusOutOfRange)(IRealTimeStylus *, TABLET_CONTEXT_ID, STYLUS_ID) { return S_OK; }
    STDMETHOD(StylusButtonUp)(IRealTimeStylus *, STYLUS_ID, const GUID *, POINT *) { return S_OK; }
    STDMETHOD(StylusButtonDown)(IRealTimeStylus *, STYLUS_ID, const GUID *, POINT *) { return S_OK; }
    STDMETHOD(SystemEvent)(IRealTimeStylus *, TABLET_CONTEXT_ID, STYLUS_ID, SYSTEM_EVENT, SYSTEM_EVENT_DATA) { return S_OK; }
    STDMETHOD(TabletAdded)(IRealTimeStylus *, IInkTablet *) { return S_OK; }
    STDMETHOD(TabletRemoved)(IRealTimeStylus *, LONG) { return S_OK; }
    STDMETHOD(CustomStylusDataAdded)(IRealTimeStylus *, const GUID *, ULONG, const BYTE *) { return S_OK; }
    STDMETHOD(Error)(IRealTimeStylus *, IStylusPlugin *, RealTimeStylusDataInterest, HRESULT, LONG_PTR *) { return S_OK; }
    STDMETHOD(UpdateMapping)(IRealTimeStylus *) { return S_OK; }
};

COM なのでIUnknownも実装する必要がある。この 3 つは COM の定石通りに実装すればよいが、QueryInterfaceIMarshalをサポートしなければならないことに注意。

IStylusSyncPluginIStylusAsyncPluginの切り替えは基本的に継承するインターフェースさえ変えれば問題ないと思われる。

ハンドラメソッドの実装

16あるハンドラメソッドの内、一番重要なのはPackets()で、アプリケーション次第ではこれだけ実装すれば十分と思われる。 これはタッチされたペン先が動いたときに呼び出される。

あとは必要に応じてStylusDown, StylusUp, InAirPacketsあたりを実装するとよい。StylusDown/Upはそれぞれペン先がタッチされた/離されたときに呼ばれるメソッドで、InAirPacketsはタッチせずに画面上でペンを動かしたときに呼び出される。

パケットデータの解釈

ペン入力のデータがPackets()などの引数に送られてくるが、この入力データは「パケット」という単位になっているらしい。 各パケットは複数の「プロパティ」を持つ。

パケットデータの形式

Packets()の引数リストは以下のようになっている。

STDMETHOD(Packets)(
    IRealTimeStylus *pStylus,
    const StylusInfo *pStylusInfo,
    ULONG nPackets,
    ULONG nPacketBuf,
    LONG *pPackets,
    ULONG *nOutPackets,
    LONG **ppOutPackets
);

pPacketsLONG型の配列が入っている。

nPacketBufがこの配列全体の大きさで、nPacketsがパケットの数になっている。

パケット1つに含まれるプロパティの数はnPacketBuf / nPacketsで取得できて、pPacketsの配列をこの数で区切ると各パケットが取り出せる。

各プロパティ情報の取得

RealTimeStylusオブジェクトからプロパティの形式情報を取得できる。

float ScaleX, ScaleY;
ULONG nPacketProps;
PACKET_PROPERTY *pPacketProps;
pRTS->GetPacketDescriptionData(tcid, &ScaleX, &ScaleY, &nPacketProps, &pPacketProps);

for (ULONG i = 0; i < nPacketProps; i++) {
    pPacketProps[i].guid;                           // プロパティの種類
    pPacketProps[i].PropertyMetrics.fResolution;    // 解像度
    pPacketProps[i].PropertyMetrics.nLogicalMax;    // 最大値
    pPacketProps[i].PropertyMetrics.nLogicalMin;    // 最小値
    pPacketProps[i].PropertyMetrics.Units;          // 単位
}

CoTaskMemFree(pPacketProps);

ここで得られる配列の順番はパケットに入っているプロパティの順番に対応している。

プロパティの種類のGUIDはSetDesiredPacketDescriptionに指定したものと同じものだ。

パケットのプロパティのLONG値をPropertyMetrics.fResolutionで割ればPropertyMetrics.Unitsの単位の値になる。

この辺は文章で解説しても分かりにくいのでサンプルコードを見てもらった方が早いと思う。

インク座標からピクセル座標への変換

大抵のアプリケーションではタッチ位置の座標はピクセル単位でほしいところだが、上記の方法でタッチ位置(GUID_PACKETPROPERTY_GUID_X, GUID_PACKETPROPERTY_GUID_Y)を取得してもセンチメートル単位の値が出てくる。 これでは使い物にならない。

PropertyMetrics.fResolutionで割る前の整数値はどうなのかというと、これは「インク座標」と呼ばれる専用の座標系による値であり、ピクセル単位ではない。

じゃあどうやったらピクセル単位のタッチ位置が取れるのかというと、InkRendererというまた別のAPIを利用することになるようだ。

CComPtr<IInkRenderer> pInkRenderer;
pInkRenderer.CoCreateInstance(__uuidof(InkRenderer));

HDC hDC = GetDC(hWnd);

pInkRenderer->InkSpaceToPixel(reinterpret_cast<LONG_PTR>(hDC), &x, &y);

これでピクセル単位の座標が得られる。

まとめ

COMさえ分かっていればそこまで複雑なAPIではないのだが、細かく説明すると結構な分量になってしまった。

説明を読むよりサンプルコードをざっと見てみた方がいいと思う。

参考資料

この記事を書くにあたって以下のサイトを参考にさせて頂いた。

コメント