2024年4月29日月曜日

Text Services Framework (TSF)のつづき

以前TSFについて実装した。
TSFにまつわる文字入力と、それを表示する仕組みの部分にいくつかのバグが見つかり、ここ何日かで直していた。


バグその1


開発当初から気づいてはいたんだけど、放置していたバグ。
入力文字をテクスチャに展開していき用意していた領域全部を使ってしまった場合、先頭に戻って1行分をクリアして再利用するようにしている。
このとき、表示するデータの中に1行目のテクスチャを利用する文字が含まれていた場合表示されなくなってしまう。

この不具合の修正は、次に消えるであろう数行に表示データが含まれている場合は、最新部分に移してしまうようにすることで対処してみた。

実際に実装はしてみたがそのチェック処理が若干重めになるのと、実際に不具合が起きた際に起きる表示が1フレームだけ表示されない(もしくは別の文字で表示される)だけなので、あえて元のままにした。


バグその2


漢字変換を通さない文字入力(半角アルファベット入力など)をした直後に、漢字変換をしようと、文字入力をすると入力できないというもの。
状況によって、変換候補だけが表示されたり、更に文字入力すると表示されるようになったり、ならなかったりという現象が起こった。

現象はMicrosoftIMEで起きて、Google日本語入力では起きない。
幸い現象は100%起こせて、いつでも検証出来たがなかなか原因がつかめなかった。

何回も試しているうちになんとなく原因はわかった。
通常はITextStoreACPの継承関数がコールバックとしてTextStore側から通知される形だけど、文字変換を通さない入力の場合、ロックを掛けて、カーソル位置や内部文字を変更してアンロックをして、TextStore側は変更内容を知らない状態。勝手にこっち側で内容を変更しただけなので、文字列が増えたことが伝わってない状態で文字列外のカーソル位置に入力しているように見えてるのではないかと推測した。
GetSelectionや、GetTextなどが呼ばれて内容が変わってるのはわかってるはずなんだけど、MicrosoftIMEはわからないらしい。

その線で調べてみた結果、ITextStoreACPSink::OnTextChangeを呼べばいいのではないかと言うことにたどり着いた。
試しに実装してみると正しく入力できるようになった。
他にもこちらだけで内容を変更している箇所がいくつかあったので通知を加え、ついでにカーソル位置が変わったらITextStoreACPSink::OnSelectionChangeも呼ぶようにしてみた。


バグその3


毎フレーム登録して描画するのは非効率なので、ラベルとしてID付きで登録できるようにして、更新がなければStructuredBufferの更新をしないようにした。また一部のラベルで更新があった場合でも他のラベルのバイナリを再作成不要なようにバイナリを保持するようにした。

試しに100個のラベルを登録して毎フレームの登録がなくなりCPU使用率が下がって、GPU使用率だけが増えることを確認しようとしたところプログラムが落ちてしまった。

このデータ管理に自作ライブラリのHashMapを利用していたが、今まで発覚しなかったバグを見つけた。
調べてみるとHashMapの拡張時にデータの移行がうまく行っていなかった。
キャパシティの75%以上になったタイミングでデータ拡張を行い今までのデータを新しく確保した領域に移す。
テンプレートに指定された型がstd::is_trivially_copyable_vがtrueなら要素をまとめてmemcpy、falseなら1要素毎にnewする仕組みに振り分けている。
moveで例外を飛ばさない(std::is_nothrow_move_constructible_v)という暗黙の条件もつけていた。この条件に合致しない型を指定した状態でmoveを行うと何もしないようにしていた。
今回指定した型がちょうどstd::is_nothrow_move_constructible_vでfalseを返しており、データ拡張時元データを引き継いでいない状態になっていた。

構造体にいくつかの型が含まれているが、自作ライブラリのすべてをstd::is_nothrow_move_constructible_vにしているつもりだったけど、そうでないものが含まれていた。
UTF-8用の文字列クラスがあり、バイナリクラス(vectorのunsigned char型)を継承して作っていた。バイナリクラスはstd::is_nothrow_move_constructible_vはtrueで問題ないが、これをテンプレートクラスで継承した型の場合、スーパクラス側の自身のmoveコンストラクタとmove operator=が表面に届いていないようで、別途定義する必要があった。

必要な定義を追加して、move時std::is_nothrow_move_constructible_vでない場合はassertを入れつつ、copyを呼び出すように修正。対応していない型のmoveに気づくようにするのと、最悪このままreleaseビルドした場合でも動くようにした。


バグその4


UTF-8型の文字列クラスにもう1つバグがあって、ICUの機能を組み込んで見た目の文字数を取得できるようにする際、BreakIteratorのキャッシュを用意していた。
一度書記素単位に分けた情報を作ったら、次回はそれを使い回す。
今自分で書いていてもすぐに起こるであろうバグそのままが起きていた。
登録されている文字の変化があった場合、キャッシュのクリアが必要だったがクリアしていなかったためバグその3を直したあとに文字化けするようになった。
内容が変化し得る箇所にクリア処理を追加した。


バグその5


「バグその3」を引き起こした機能の実装でバイナリをキャッシュに問題があった。
データの更新に合わせてキャッシュはクリアしている。
ただ「バグその1」で起こっていた、テクスチャが書き換わった際のフォローが抜けている。

テクスチャの書き換えが起こったら、すべてのキャッシュを削除するようにした。
本来は利用していたものだけキャッシュを削除すればスマートだが、厳密にチェックするとなると重くなる。「バグその1」で妥協したように処理済みデータに対してキャッシュを消してしまっても、表示されないのは1フレームのみなのでこちらも妥協することにした。

これも対応してみたはいいけど、テクスチャ書き換えの度に文字がフラッシュするので、キャッシュ消す必要もない。どうせフラッシュするならそのままにすることにより影響受けるとこだけに抑えられる。結局何もしないことにした。
「バグその1」はフラッシュしないところまで実装したけど、こっちの方は今のところフラッシュしない方法を思いついてない。





2024年3月16日土曜日

シェーダに符号付き8ビットデータを渡す

前回のステンシルバッファでテキストのクリッピングをやろうとしたけど、単純な矩形をステンシルバッファへ書き込む方法がまだわからないので方針を変えることにした。

ステンシルバッファをClearDepthStencilViewの最後の引数に複数渡せる矩形情報でクリッピング範囲を指定できたら良かったんだけど、クリアの値が作成時のクリア値以外で書き込むとパフォーマンスが落ちるらしいので諦めた。


テキストのクリッピング処理


テキストをレンダリングするシェーダに渡しているSRVに16ビットのオフセット情報を増やして、書き込む幅を調整できるように考えた。

今まで渡していた情報はすべてuint16_tで描画する位置のx, yと、フォントマスタのインデックスno、カラー番号cno。
このデータサイズが毎フレーム更新に影響を与えるために極力小さくするチューニングを行って現在の形になっている。データを増やすのはかなり抵抗があるが致し方ない。

int16_tでオフセットofsを追加した。
マイナスの場合は左から、プラスの場合は右からオフセット分描画しないようにする。


普通に2バイトのデータを書き込んでシェーダ側でint16_tとして解釈すればこれについてはなんの問題もなかった。
ただ、1文字内で左右どちらもクリッピングはできないという制限はあるがこれは許容した。

ここまで出来て、縦方向も欲しくなった。エディットボックス内の文字列描画で、枠以上の文字列入力が可能な場合にクリッピング処理が発生する想定で作っていたから、縦方向は仕様次第で不要な状況にできるかなと思っていたけど作ることにした。

符号付きの16ビットの範囲は32767~-32768なので1文字に使う範囲としてはもったいない。これを符号付き8ビットにすると、127~-128なのでちょうど良い感じ。8ビットシフトして、上位8ビットに水平方向のオフセット、下位8ビットに垂直方向のオフセットを渡すようにした。

HLSL側でもシフトが普通に使えるので、水平方向のオフセットは右シフトで普通に取り出し、下位8ビットは「& 0x00FF」で上位8ビットを切り捨てた。
ところがこれだと渡した値が、プラスの場合は問題ないが、マイナスの場合正しく評価されない。

試してうまく行ったのは、一度左に8ビットシフトしてから、右に8ビットシフトする方法。上位8ビットについては右シフトで算術シフトできることはわかっていたので、下位8ビットも、最上位ビットにタッチさせて右シフトすれば行けるかと思ったら行けた。

他にもいろいろやり方はあるだろうけど、条件分岐一切なしにできたのでこれが良さそう。専用の関数(8ビット→16ビット)があれば別だけど。


今回の対応はVertexBufferにしてやれば、オフセット情報は増やす必要なく、書き込む位置とUVの調整で済むし、1文字内で両端のクリッピングにも対応できるけど、どうなんだろ?
フォントのマスタが不要になる一方、1文字4点分のデータ書き込みが必要になるから微妙か。

2024年3月7日木曜日

Stencil Buffer

今回はステンシルバッファについて


フォーマット



深度バッファのみのときは、DXGI_FORMAT_D32_FLOATを使っていた。
ステンシルバッファを使う場合は、DXGI_FORMAT_D24_UNORM_S8_UINTを使う。
Dの部分が深度バッファの精度で、ステンシルバッファに8ビット使ってしまうため、24に落ちる。
精度を落としたくなければDXGI_FORMAT_D32_FLOAT_S8X24_UINTというものもあるらしいけど、24ビット丸々無駄にするらしいし、4バイトの範囲に収まらないのでなんだか遅そう。


D3D12_RESOURCE_DESC1.Format


深度バッファのみのときは、DXGI_FORMAT_R32_TYPELESS
ステンシルバッファを使う場合は、DXGI_FORMAT_R24G8_TYPELESS


D3D12_CLEAR_VALUE.Format


深度バッファのみのときは、DXGI_FORMAT_D32_FLOAT
ステンシルバッファを使う場合は、DXGI_FORMAT_D24_UNORM_S8_UINT


CreatePlacedResource2.CastFormat


深度バッファのみのときは、
CastFormat[0]=DXGI_FORMAT_D32_FLOAT
CastFormat[1]=DXGI_FORMAT_R32_FLOAT
ステンシルバッファを使う場合は指定なし。

これがいまいち分からず、指定するとエラーになりCreateできない。
深度バッファの方は、指定する場合は両方指定しないとランタイムエラーになる。
指定なしにしたら動くので、下手に指定する必要がないかも。


D3D12_DEPTH_STENCIL_VIEW_DESC.Format


深度バッファのみのときは、DXGI_FORMAT_D32_FLOAT
ステンシルバッファを使う場合は、DXGI_FORMAT_D24_UNORM_S8_UINT

D3D12_GRAPHICS_PIPELINE_STATE_DESC.DSVFormat


深度バッファのみのときは、DXGI_FORMAT_D32_FLOAT
ステンシルバッファを使う場合は、DXGI_FORMAT_D24_UNORM_S8_UINT


D3D12_SHADER_RESOURCE_VIEW_DESC.Format


深度バッファのみのときは、DXGI_FORMAT_R32_FLOAT
ステンシルバッファを使う場合は、DXGI_FORMAT_R24_UNORM_X8_TYPELESS

ここを見るとPlaneSlice0にDXGI_FORMAT_R24_UNORM_X8_TYPELESS、1にDXGI_FORMAT_X24_TYPELESS_G8_UINTを指定する様に書かれている。
SRVでPlaneSliceを複数指定する方法がわからないけど、2つ指定するとそれぞれ深度バッファと、ステンシルバッファが参照できるのだろう。

今のところステンシルありのバッファをテクスチャ利用する予定はないので、必要が出てきたら調べることにする。


PSO


ステンシルバッファを使う場合は、DepthStencilState.StencilEnableをTRUEにする。

ステンシルバッファ書き込み


DepthStencilState.StencilReadMask = D3D12_DEFAULT_STENCIL_READ_MASK ;
DepthStencilState.StencilWriteMask = D3D12_DEFAULT_STENCIL_WRITE_MASK ;
DepthStencilState.FrontFace = {
    D3D12_STENCIL_OP_KEEP,
    D3D12_STENCIL_OP_KEEP,
    D3D12_STENCIL_OP_REPLACE,
    D3D12_COMPARISON_FUNC_ALWAYS
} ;
DepthStencilState.BackFace = DepthStencilState.FrontFace ;

上記は描画した場所をOMSetStencilRefで設定した値で塗りつぶす場合の設定(D3D12_STENCIL_OP_REPLACE)
D3D12_STENCIL_OPは3つ設定できて、最初がステンシルテストに失敗した場合、次がステンシルテストは成功したけど、Zバッファのテストが失敗した場合、最後が両方のテストが成功した場合。
ステンシルバッファの書き込みだけで、描画は行わない場合BlendState.RenderTarget[i].RenderTargetWriteMaskも0にするといいらしいけど、そもそもレンダーターゲットを指定しなければいいんじゃないのか?
バッファ書き込みはやってないので分からず。


ステンシルテスト


DepthStencilState.StencilReadMask = D3D12_DEFAULT_STENCIL_READ_MASK ;
DepthStencilState.StencilWriteMask = 0 ;
DepthStencilState.FrontFace = {
    D3D12_STENCIL_OP_KEEP,
    D3D12_STENCIL_OP_KEEP,
    D3D12_STENCIL_OP_KEEP,
    D3D12_COMPARISON_FUNC_EQUAL
} ;
DepthStencilState.BackFace = DepthStencilState.FrontFace ;

上記は、OMSetStencilRefで設定した値と同じ箇所だけ描画する場合の設定。


ClearDepthStencilViewでD3D12_CLEAR_FLAG_DEPTH | D3D12_CLEAR_FLAG_STENCILを指定して、深度バッファとステンシルバッファを初期化したあと、ClearDepthStencilViewの最後の引数に矩形を指定して、ステンシルバッファだけを10で初期化。OMSetStencilRefにも10を設定して描画してみると、こんな感じになった。

Hが欠けてる

指定通りの動作になっているけど、デバッグレイヤには下記の警告が出続ける。

D3D12 WARNING: ID3D12CommandList::ClearDepthStencilView: The clear values do not match those passed to resource creation. The clear operation is typically slower as a result; but will still clear to the desired value. [ EXECUTION WARNING #821: CLEARDEPTHSTENCILVIEW_MISMATCHINGCLEARVALUE]

ステンシルバッファの書き換えにClearDepthStencilViewを利用したのがだめみたい。
矩形で指定範囲を書き換えたい時、わざわざシェーダを通さずClearDepthStencilViewで手軽にクリップ領域を指定できるかと思ったけど、この使い方はだめみたい。



2024年2月27日火曜日

Multithread GPU Command

マルチスレッドでコマンドを発行する仕組みを作ったけど、久しぶりに見たらところどころ忘れてしまってたので資料としてまとめておくことにした。


コマンド発行の仕組み


コマンド発行の全体図


クラスはCommand、CommandList、ComanndAgent、CommandQueueの4つで構成されている。


CommandQueue


CommandQueueは以前はGraphics、Compute、Copyの3つ用意してそれぞれに発行できるようにしていたけど、フレームの途中で同期はしないようにすることを考えるとGraphicsのみで良いという考えに至り、1つのみ用意している。
またGPU処理完了でブロックしないように、Queue1つに付き、1スレッドが待機している。


Command


GPUに命令する最低単位。1命令文=1Commandで、要求するコマンドに必要なパラメータを管理している。


CommandList


DirectX12のID3D12CommandAllocatorとID3D12CommandListを内包するクラス。


CommandAgent


コマンドを内包し、実行タイミング要求で別スレッド経由でコマンドリストに追加する。
Agent1つに付き、1スレッドが待機している。


処理の流れ


①GetCommandAgent


動作スレッド:メイン
描画開始時、BeginRender内でプールからCommandAgentを取得する。

②AddCommand


動作スレッド:メイン
CommandAgentに対して、各種コマンドを追加していく。

③Execute


動作スレッド:メイン
CommandQueueにCommandAgentを移管して、CommandAgent内に眠っているスレッドを起こす。
同時にCommandQueue内に眠っているスレッドも起こす。

④GetCommandList


動作スレッド:CommandAgent
CommandAgent内のスレッドが、コマンド発行用のリストをプールから取得する。

⑤AddCommandList


動作スレッド:CommandAgent
ComanndAgent内に溜め込んだコマンドを1つずつコマンドリストに追加していく。

⑥ExecuteCommandLists


動作スレッド:CommandQueue
CommandQueue内のスレッドがCommandAgentを監視して、現在管理中のCommandAgentの処理が全て完了していたら、ExecuteCommandListsを呼び出し、GPUの処理を開始する。

⑦ReleaseCommandAgent


動作スレッド:CommandQueue
CommandAgentを返却する。
その際CommandAgentが抱えている各種リソースの開放を行う。
※厳密には、NextFrame内で呼び出される。

⑧NextFrame


動作スレッド:CommandQueue
切り替え先のフレームのGPU命令が完了しているかチェックし、終わっていなければ待つ。
終わっている場合はReleaseCommandAgentを呼び出す
次のフレームに切り替える。



課題


最初の頃はコマンドを追加するたびに、別スレッドでコマンドリストに追加して、全コマンドが追加できたらExecuteCommandListsを発行していた。
現在はCommandQueueにExecuteする単位でExecuteCommandListsする仕組みを用意はしているけど、実際にExecuteするタイミングは全コマンドを追加したらで変わっていない。
nVIDIAのドキュメントによると、1フレームで5~10回に分けてExecuteCommandListsを呼び出すと良いと書かれている。手動で分割するか、動的に判断するか今後考える。

またCommandAgent内のCommandListは複数持てるように設計しているが、現状利用しているのは1つのみになっている。更に、1フレームで複数CommandAgentを扱えるようにしているけど、現状利用しているのは1つのみ。全体で15~30くらいCommandListを保持して使い回すらしいので、複数のCommandListをまとめてExecuteするようにしたい。

※nVIDIAのドキュメントが以前「DX12 Do's And Don'ts」という題名のBlogだったけど、それぞれのトピックに分けて再構成されていた。最初なくなってしまったのかと思ったけど、1つ1つをよく見ると前と同じ内容だった。



2023年12月13日水曜日

DirectX12 DrawText その2

前にDrawTextをする為の仕組みをGetGlyphOutlineを使って作った。
Unicodeの特殊事例に対して対処できたのはサロゲートペアだけで、結合文字や絵文字に対してはGetGlyphOutlineでは扱えずビットマップが取得できない。
この時はDirect2DとGDIのどちらにするか迷ってGDIを選択したけど、絵文字も表示するとなるとDirect2D(DirectWrite)しか選択肢がなかった。


IDWriteTextRenderer


GDIのGetGlyphOutlineに当たる機能はDirect2Dでは何になるかを調べたところ下記になるらしい。
グリフ メトリック -- IDWriteFontFace::GetDesignGlyphMetrics
実際のアウトライン情報 --IDwriteFontFace::GetGlyphRunOutline
グリフ ビットマップ -- IDWriteRenderBitmapRenderTarget::DrawGlyphRun

更に調べていくと、マイクロソフトのサンプルのDWriteHelloWorld/CustomTextReadererにたどり着いた。

CustomTextReadererはIDWriteTextRendererを継承して作られたクラス。
前回、WicBitmapRenderTarget経由でIWICBitmapに書き込むことは出来ていたけど、今回はCustomTextReadererで書き込むことに成功。
今回はビットマップのフォーマットをGUID_WICPixelFormat8bppAlphaにすることにより、GetGlyphOutlineで得られるデータに近いものが得られるようにした。

で、Bitmap自体は取得できたけど、グリフ情報は得られていない。
IDWriteFontFace::GetDesignGlyphMetricsで得られるらしいけど、IDWriteFontFaceを得る方法がかなり難しそうで、ファイルを指定しないといけなかったり、IDWriteFontFamilyやIDWriteFontCollectionからもたどり着くのが難しい。
一体どうすればいいのか悩んでいたけど、CustomTextReadererの中に答えがあった。

CustomTextReaderer::DrawGlyphRun関数の引数内にglyphRunがあり、glyphRun.fontFaceがIDWriteFontFaceだった。
これを利用すれば、GetDesignGlyphMetricsが呼び出せてDWRITE_GLYPH_METRICSが取得できる。
IDWriteFontFace::GetMetrics関数でDWRITE_FONT_METRICSが取得でき、この2つを組み合わせると下記の情報が得られる。

fm:DWRITE_FONT_METRICS
gm:DWRITE_GLYPH_METRICS



gm.advanceWidthが次の文字までの距離で、GLYPHMETRICSで言うところのgmCellIncXに当たる値。

書き始めの基準が薄い赤の枠の左端で、次の文字の書き始めが右端になる。
左端はDraw関数に指定した位置で、右端はadvanceWidthを足した位置になる。
上辺は、Draw関数に指定した位置にfm.ascentを足して、gm.verticalOriginYを引いた位置で、下辺は、Draw関数に指定した位置にfm.ascentとfm.descentを足した位置になる。

実際の文字のデータが含まれる範囲(濃い赤枠)を得るには、薄い枠の範囲にleftSideBearing、rightSideBearing、topSideBearing、bottomSideBearingを引いた値となる。
Aの場合はすべての値がプラスで薄い赤枠の内側になる。
jの場合は、leftSideBearing、topSideBearingはマイナスで薄い青枠の外側になり、描画開始位置が前の文字と被ることになる。
(GLYPHMETRICSのgmptGlyphOrigin.xがマイナスの場合と同じ)

また、これらの値の単位が違うので変換する(fm.designUnitsPerEmで割って、glyphRun->fontEmSizeを掛ける)必要がある。
得られるのは小数点を含むデータでピクセル単位に変換する際、top、leftは切り捨て、bottom、rightは切り上げした。メイリオの通常ではこれで問題ないけど、別フォントでイタリックにすると、縦が1ドット足りない場合はあった。必要に応じてマージンを設定するといいかも。


絵文字対応



通常の文字であれば、1つのグリフ情報だけが得られてそのSideBearingを計算すればよかったけど、例えば「👨‍👩‍👧‍👦」の絵文字の場合得られるグリフ情報が4つ返却され、それぞれの絵文字のSideBearingを見るだけだと計算が狂ってしまう。

glyphRunにはglyphAdvancesとglyphOffsets(※)があり、glyphOffsetsにはさらにadvanceOffsetとascenderOffsetが含まれている。
これらの値は、前述の単位変換は不要。
※glyphOffsetsはnullの場合があり、その時は0扱いとする。

メイリオ24で「👨‍👩‍👧‍👦」のDrawを行うと下記のグリフ情報が得られる。
X Y W H A OX OY
父 2.0 4.6 14.4 17.0 16.4 4.9 0.0
母 13.1 4.7 15.2 16.9 13.6 0.0 0.0
娘 1.9 15.7 13.9 15.1 0.0 -25.4 0.0
息子 13.9 16.0 13.5 14.9 0.0 -13.6 0.0

X:advanceOffset + leftSideBearing
Y:ascent + topSideBearing - verticalOriginY - ascenderOffset
W:advanceWidth - leftSideBearing - rightSideBearing
H: advanceHeight - topSideBearing - bottomSideBearing
A:glyphAdvances
OX:advanceOffset
OY:ascenderOffset

父の描画範囲はadvanceOffset(4.9) + leftSideBearing(-2.9) = X(2.0)、ascent(25.8) + topSideBearing(-3.7) - verticalOriginY(17.4) - ascenderOffset(0.0) = Y(4.6)、advanceWidth(9.8) - leftSideBearing(-2.9) - rightSideBearing(-1.6) = W(14.4)、advanceHeight(22.5) - topSideBearing(-3.7) - bottomSideBearing(9.2) = H(17.0)という感じで算出される。それぞれの範囲を計算して、left、topの最小、right、bottomの最大を求めたのが絵文字全体の描画範囲となる。

上の方で「gm.advanceWidthが次の文字までの距離」と書いたが、実際にはglyphAdvancesの合計が次の文字までの距離となる。通常の1文字であればadvanceWidth=glyphAdvancesとなっているが、複数要素の絵文字の場合advanceWidthは、その要素単位の幅であって全体の幅には使えない。


結合文字対応



基本的には絵文字対応の内容で問題ないけど、よく5chなどでこんな書き込みがある。




m9(ด็็็็็็็็็็็็็็็็็Дด็็็็็็็็็็็็็็็็็)プギャーw

ブラウザ、VisualStudioのエディタはこの文字列の表示に対応してた。

VisualStudioエディタ

メモ帳は1行の範囲でクリッピングされていた。

メモ帳


いくらでも文字を合成できてしまうため、メモ帳と同じように制限を加える。
一時領域に描画しているため、水平方向に関してはマイナス、一時領域の幅以上の場合はクリッピング、垂直方向はマイナス、ascent + descentを超えた分はクリッピングする。

こうして見ると、ブラウザとメモ帳でadvanceOffsetの解釈が逆になっていて、VisualStudioはadvanceOffsetを無視してるっぽい。

スペース対応



最後にスペースの文字列について。
スペースはビットマップの範囲が0になっていたため、ビットマップ参照の為のLock関数でエラーになっていた。幅0の場合はビットマップ参照はスキップして、文字情報だけ書き込むようにする。
また描画の際、幅0の場合は何もせず次の文字までの幅だけを足すようにする。



2023年11月5日日曜日

Text Services Framework (TSF)

フルスクリーンでゲームを作る場合、IMEを制御して自動で表示されるものをすべて止めて、自前で描画する必要がある。

かなり昔に作ったことがあり、今回そのソースを移植して対応してみた。

一応動くようになったんだけど、その頃と違い最近は予測変換が表示されるようになっている。
このリストがうまく取得できない。
色々調べてみると、IME32は一度廃止された?されそうになった?がまた復活したという経緯があるらしい。
(個人のブログでIME32がなくなるから、今後は使えないというような記事を見つけた)

では新しいAPIは何になるのかというと、TSFというものらしい。
もしかするとこっちのAPIを使えば予測変換でも正しくイベントを受けられるかもと思い対応してみることにした。


TSF


Vistaの頃にできたものらしく、そこまで新しくはないAPIでCOMベースで作られているためかなり使うのにハードルが高い。
まったく知らなかった。

サンプルプログラムをダウンロードして実行してみるもいまいちよくわからない。
どうやら、TIP(Text Input Processor)を使う側と作る側両方のソースが含まれているようだ。
まずは個人で最低限のサンプルを載せてくれている人のソースをいくつか参考にしながら調査すると、ほんとの最小限は「ITextStoreACP」と「ITfContextOwnerCompositionSink」だけを実装すれば動くっぽい。

サンプルほぼ丸写しで実装してみた。
TextStoreはその名前の通り、EditBoxの持ってるテキストを管理するクラスという認識。
IME32の場合は、変換中の文字列だけが管理され変換後は別途管理が必要だったので一元管理できる分こっちのほうがいいかも。前後の文字列から変換結果を変えたり、変換済みの文字列を再変換できる機能があるからだと思われる。

内部で持つものは、「ITfDocumentMgr」「ITfContext」「ITfProperty」「ITfCategoryMgr」「ITfDisplayAttributeMgr」「ITextStoreACPSink」

これらは内部で保持せず、使うたびに取り出す実装になっているのも見たがどちらがいいのかは判断つかず。MS-IMEとGoogle日本語入力を切り替えてもそのまま使えているので、切り替えタイミングをイベントで受けて、そのたびに作り直しということもないので保持しておくことにした。

最後のTextStoreACPSinkというのがイベントを通知させるためのもので、これをAdviseSinkした人にイベントが通知される。

ここでややこしいのが、TextStoreACPSinkを受け取るのがTIPの本体の方で自分ではない。
MS-IMEやGoogle日本語入力がAdviseSinkをしに来るから、それらに対してこちらからイベントを通知してあげる側になる。


変換文字列


先ほどのTextStoreACPSinkとは逆で、今度はこちらがイベントを受けることにより変換文字列の変更通知を受け取れるようにする。
「ITfContextOwnerCompositionSink」を継承して、必要なイベント関数を実装する。
***SinkはAdviseSinkすると通知されるようになるはずなんだけど、これの為のAdviseSinkは見当たらない。CreateContextにITextStoreACP*として、自分自身を指定しているのでこれが代わりになっているのかも?

ここまでできるとImmGetCompositionStringの代わりができたことになる。

Property->EnumRangesで「IEnumTfRanges」を取得
EnumRanges->Nextで「ITfRange」を取得
Property->GetValueにRangeを指定して、「TfGuidAtom」を取得
CategoryMgr->GetGUIDにGuidAtomを指定して、「GUID」を取得
DisplayAttributeMgr->GetDisplayAttributeInfoにGUIDを指定して、「ITfDisplayAttributeInfo」を取得
DisplayAttributeInfo->GetAttributeInfoで「TF_DISPLAYATTRIBUTE」を取得
Range->QueryInterfaceで「ITfRangeACP」を取得
RangeACP->GetExtentで、変換中の区切り位置を取得
Enum分ループすると、それぞれのTF_DISPLAYATTRIBUTE.bAttrと、区切り位置で、変換中の文字列の区切り位置と属性が得られる。


変換候補


次に変換候補のリストを取得してみる。
メンバに「ITfUIElementMgr」を追加する。
イベントを受け取るためのインターフェース「ITfUIElementSink」を継承して、必要なイベント関数を実装する。
「ITfThreadMgrEx」からQueryInterfaceで「ITfSource」を取り出し、Source->AdviseSinkで自分自身を登録する。
BeginUIElementを受けた際、一緒に渡されるbShowにFALSEを返却すると、変換候補のWindowが表示されなくなる。
また、ルール通りにTIP側が実装されていれば、FALSEを返した場合即座にUpdateUIElementが呼ばれるらしい。なので、リスト取得の実装はUpdateの方にのみしておけばいいみたい。

このイベントが受けられるようになるとImmGetCandidateListの代わりができたことになる。

UpdateUIElementに渡されたElementIDを使って、UIElementMgr->GetUIElementで、「ITfUIElement」を取得
UIElement->QueryInterfaceで「ITfCandidateListUIElementBehavior」を取得

※未解決
Google日本語入力だと、予測変換のタイミングでCandidateListの取得は失敗する。
Tabや↓矢印キー押下後は普通に取得できる。
MS-IMEでは予測変換のタイミングで取得できる。
ただ、MS-IMEでは予測変換時でも先頭の項目が選択された状態になっているため、↓矢印を押下すると2番目の項目が選択されてしまい、どちらも微妙。
変換候補を画面表示した状態であれば未選択状態になっており、↓矢印選択時に1番目が選択状態になる。これと同じ挙動にしたいが、やり方がわからず。

CandidateListUIElement->GetSelectionでリストの選択位置を取得
CandidateListUIElement->GetUpdatedFlagsでリストの前回からの変更点を取得
CandidateListUIElement->GetCurrentPageでリストの現在のページ番号を取得
CandidateListUIElement->GetCountでリストの項目数を取得
CandidateListUIElement->GetStringでリストの指定インデックスの文字列を取得

今のところ、UpdatedFlagsで取得した値が「TF_CLUIE_COUNT | TF_CLUIE_STRING」の時に、リストをすべて取り直すようにしている。
GetPageIndexを使えば、現在のページのみに絞れるためこっちを使った方がいいかも。


変換モード


通常タスクバーに埋まってる「A」とか「あ」とかの状態を取得する方法

変換モード

これがなかなか見つからなくて1週間くらい探してやっと見つけた。

メンバに「ITfCompartmentMgr」を追加する。
イベントを受け取るためのインターフェース「ITfCompartmentEventSink」を継承して、必要なイベント関数を実装する。

登録は、CompartmentMgr->GetCompartmentで、「ITfCompartment」を取得
Compartment->QueryInterfaceで、「ITfSource」を取得
Source->AdviseSinkで自分自身を登録するという流れ。

ただ2回行う必要があって、1つ目のGetCompartmentはGUIDにGUID_COMPARTMENT_KEYBOARD_OPENCLOSEを指定する。2つ目は、GUID_COMPARTMENT_KEYBOARD_INPUTMODE_CONVERSIONを指定する。
1つ目はIMEのOpenとCloseのタイミングを得るためで、ImmGetOpenStatusの代わりになるもの。
2つ目は、ImmGetConversionStatusの代わりになるもの。

ちょっと癖があって、Close状態でも変換モードは直前のものが取れてしまうため、自分で「A」にする必要がある。Open状態はInputModeの値をそのまま使えばいい。
取得できるEnum値はTF_CONVERSIONMODE_*で、IME_CMODE_*と同じ値らしい。


これでImm系の関数を一切使わず、TSFに移行できた。

当初の目的の予測変換のタイミングについては、MS-IMEについては完ぺきに取得できるようになったが、候補リストの先頭が選択されている状態になっている問題は残っている。
Google日本語入力に関しては、予測変換が表示されるタイミングでOnUpdateUIElementは呼び出されはするが、候補リストを取得できない問題が残っている。



1/22追記


変換候補については、自前で描画せずに任せることにした。変換中は別Windowsが表示されることになるが大した問題ではないと思うことにした。
変換候補に実際は描画しない文字列もたくさん含まれているため、描画用のテクスチャがどんどん消費されていくのも防げる。

ItextStoreAcp::GetTextExtの引数に渡されるRECT型の変数に、変換中の矩形をスクリーン座標で返すと変換候補の画面を出す位置を調整してくれるようになる。
似たような関数でItextStoreAcp::GetScreenExtがあるが、面倒なので同じ座標を返すようにしている。今のところ不都合は発生していない。



2023年10月22日日曜日

International Components for Unicode (ICU)

Windowsで文字列を扱う際UTF-16を使っているが、ユーザ入力データを扱う場合まともには扱えないということがわかった。


サロゲートペア


UTF-16でも1文字は2バイトで表現しきれないので、サロゲートペアという苦肉の策が取られた。
これは今までのShiftJISなんかと同じで、最初のコードがこの範囲ならその後も含めて1文字と表すような感じで、ありがたみがない。
ただShiftJISと違って普段使うほとんどの文字は2バイトで表せるため、仮にサロゲートペアに対応してなくても不具合が顕在化しない場合もある。

内部処理的には問題はほとんど起きないが、文字数を制限するような場合に問題になる。
サロゲートペアが文字列中に含まれていると、含まれている文字数分文字数が増えてしまう。
10文字制限の場合、サロゲートペアが1文字含まれていると9文字入力した時点で制限にかかってしまい、ユーザの見た目とギャップが生まれる。

また、EditBoxなどに任せずに自前で1文字ずつ追加、削除をする場合、サロゲートペアを考慮してない場合は上位のみ削除して下位が残ったままだと表示に影響が出る。

サロゲートペアだけであれば、適切に範囲チェックを行い、文字数のカウントもサロゲートペアを考慮してやれば比較的簡単に対応できる。

ただ問題なのが結合文字や絵文字だ。


結合文字 合字 絵文字


例えばMS-IMEで、「あ」を入力して変換確定後、続けて「3099」と入力してF5を押下すると、「あ」とこのコードが合体して「あ゙」となる。
合体させるのはいくらでもよくて、ここに書かれているようなコードを何回も同じように付け足すと、どんどん付け足されて1文字扱いとなる。
この仕様はやばい。
どうやら、単純なルールは存在せず、ここに書かれていることをすべて理解して実装すれば文字の区切り判断はできるらしい・・・が、そんなことやってたら時間が足りないし、今後も仕様が追加されていくので実装しきれない。


ICU


というわけで、ICUというライブラリの出番。
この中のICU4CというのがC/C++用のライブラリなのでGitHubからVisualStudioで開いてみた。
allinoneというソリューションを開いてビルドすると何やらいろいろビルドされた。
やりたいことは「書記素単位で文字を取得する」ということになるので、インクルードファイルは「ubrk.h」、ライブラリは「icuuc.lib」でコンパイルは通った。
早速動かしてみると、「icuucNN.dll」がないと怒られる。
(NNはバージョン。試したときは74)
binから持ってきて再実行すると、「icudtNN.dll」がないと怒られる。
binから持ってきて再実行すると、U_MISSING_RESOURCE_ERRORというエラーになる。
icudt.dllはunicodeのデータが入っていて、本来は20MBくらいあるみたい。
自分の環境で出来上がったファイルは3KBしかなく、中身がなさそう。
どうやったら中身がある状態でビルドできるのかわからなかった。
ファイル指定もできるらしく、chromiumとかに含まれているicudt.datとかがおそらくこれのことっぽい。icudt.datを配置してu_setDataDirectoryで指定してみたけどうまく動かなかった。

次に試したのがnugetにあるicu4cのライブラリ。
古いものしかなくて一番新しそうな58というのをインストールしてみた。
こっちは問題なく動いた。icudt58.dllのサイズは19MBぐらい。

さらに調べてみると、Windows10以降にはマイクロソフト版ICUが含まれているらしい。
何も入れる必要ないみたいだ。
早速、インクルードファイル「icu.h」、ライブラリファイル「icu.lib」で試したら問題なく動いた。

「あ゙」はもちろん、「👨‍👩‍👧‍👦」も1文字として判定できるようになった。
対処してないと、「あ゙」は2文字、サロゲートペアを考慮しても2文字、「👨‍👩‍👧‍👦」は11文字、サロゲートペアを考慮しても7文字として判定されてしまう。