Windows でペンタブ・液タブの筆圧情報などを取ろうとした場合、「WinTab」という API と「Tablet PC API」という主に 2 つの API がある。
2 つのうち WinTab の方はMicroSoftでなくWacom社が出しているものであるため、他の会社のペンタブとの互換性をあまり期待できない。なので Wacom と生涯を共にし永遠の愛を誓うつもりが無いなら、基本的には Tablet PC API の方を使うべきだと思う。
その一方で Tablet PC API の方を用いた筆圧取得については日本語圏にあまり分かりやすい解説記事が無いため、他の人にも役立つようここにまとめてみる。
サンプルコード
最初にサンプルコードを置いておく。ペンなどでタッチした位置、筆圧などを表示する動作デモ。
目次
Tablet PC API について
Tablet PC APIは、別にペンタブの入力データを取るためだけの API ではない。手書き入力の認識なども含めた多機能な API 群である。
純粋にペン入力の値だけを取りたい場合、Tablet PC API の一部分であるRealTimeStylus APIを用いることになる。
いわゆる COM の形で提供されている API なので COM の基礎知識があると学習しやすい。いわゆるCoCreateInstance
とかやるやつである。COM の基礎知識については以下の記事シリーズが分かりやすかった。ところどころ翻訳が怪しいが、例を交えてとても分かりやすく書いてあるので一読の価値はある。
RealTimeStylus APIの大雑把な使い方
雑に説明すればこんな感じである。
- イベントハンドラを作成する
- RealTimeStylusオブジェクトを作成して初期化
- ペン入力に応じてイベントハンドラにイベントが飛んでくる
細かい実装はともかく、大雑把な仕組みとしてはそこまで複雑ではない。
どうやら後続のイベントハンドラの為に情報をフィルタするミドルウェアっぽい使い方もできるようなのだが、使い道があんまり思いつかない。
インクルードファイル
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 の定石通りに実装すればよいが、QueryInterface
でIMarshal
をサポートしなければならないことに注意。
IStylusSyncPlugin
とIStylusAsyncPlugin
の切り替えは基本的に継承するインターフェースさえ変えれば問題ないと思われる。
ハンドラメソッドの実装
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
);
pPackets
にLONG
型の配列が入っている。
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ではないのだが、細かく説明すると結構な分量になってしまった。
説明を読むよりサンプルコードをざっと見てみた方がいいと思う。
参考資料
この記事を書くにあたって以下のサイトを参考にさせて頂いた。
- Under Pressure - Backworlds
- 基本的なコードは全てここを参考にした。Web Archiveの中にしか残っていなかったがとても有用だった。
- こちらのコードではマウス座標をペン座標の代用にしていた。
- Wintabを使わないタブレット入力
- インク座標⇒ピクセルの変換はここを参考にした。
- VBで書かれていたので気合でC++に直した。
- Windows-Based プログラムでの COM の使用
- COMを今までよく理解していなかったので大変参考になった。