墨絵シェーダー(Sumi-e Shader)


~にじんでゆく~~




■はじめに

突発的にやりたくなったので、NPR のネタの1つ、動的な墨絵シェーダーを作ってみました。
本当は、「波」を作ろうかと思ったのですけど、動的にテクスチャーを生成するネタで墨絵シェーダーが浮かんだので、実装しました。
マウスで画面の上をドラッグすると、点を打ちます。その点がいい感じにぼやけて広がっていきます。

ちなみに使用前

点を、置いているのがわかります。

まぁ、いつものように適当にファイルが入っています。

sumie.psh頂点シェーダー。
sumie.pshピクセルシェーダー。
draw.cppメインの描画部分。
draw.h描画の各関数の定義。
main.h基本的な定数など。今回も出番無し。
main.cpp描画に関係しないシステム的な部分。変更が無いので、出番無し。
load.cppロード。
load.hロードのインターフェイス。
mask.bmp (点を打つときに使う)

あと、実行ファイル及び、プロジェクトファイルが入っています。

■頂点シェーダー

ぼかすために、4つのテクスチャーを重ねています。

中心に基準となる1つと、ピクセルを1つ分ずらして、正三角形になるように配置しました。

頂点シェーダーは、透視変換済みの頂点をコピーし、テクスチャー座標は固定レジスタを使って基準の位置からずらし、4つおけるようにします。

0001: ; sumie.vsh
0002: 
0003: vs.1.1
0004: 
0005: mov oPos, v0
0006: 
0007: add oT0, v7, c20
0008: add oT1, v7, c21
0009: add oT2, v7, c22
0010: add oT3, v7, c23

■ピクセルシェーダー

ピクセルシェーダープログラムですが、入力の型情報とプログラムで成り立っています。

0001: ; sumie.psh
0002: ;
0003: ps.1.0
0004: 
0005: def c0, 0.05f, 0.05f, 0.05f, 0.0f
0006: 
0007: ; テクスチャーの色を引っ張ってくる
0008: tex t0      ; 0:0 0 0  1:0 1 0  2:0 0 0  3:0 0 0
0009: tex t1      ;   0 1 0    0 0 0    0 0 0    0 0 0
0010: tex t2      ;   0 0 0    0 0 0    1 0 0    0 0 1
0011: tex t3
0012: 
0013: mov r1, t0
0014: add r0, t1, -r1
0015: mad r0, r0, c0, t0
0016: 
0017: add r1, t2, -r1
0018: mad r0, r1, c0, r0
0019: 
0020: mov r1, t0
0021: add r1, t3, -r1
0022: mad r0, r1, c0, r0

さて、ここが曲者です。
ざっと見ると、

出力 = t0 + 0.05(t1-t0) + 0.05(t2-t0) + 0.05(t3-t0)

に見えます。
実はそうではありません。
なぜなら、上の計算では、周りの色が暗い限り散逸が発生するので、最終的には均一な色になるからです。
今回の計算は、アンダーフローを積極的に利用しています。
DirectXのピクセルシェーダーの精度は、最低 8 ビットと決まっています。しかし、8ビットしか持っていない場合がほとんどです (将来的には精度は上がるでしょうから、そのときにはシェーダーを変更しなくてはなりません)。
-1~1の間の8ビットなので、実質、7ビットが小数の値として使えます。それぞれの値は、

2^-1=0.5
2^-2=0.25
2^-3=0.125
2^-4=0.0625
2^-5=0.03125
2^-6=0.015625
2^-7=0.0078125

です。この精度で、0.05は、0.05 = 2^-5 + 2^-6になります。
従って、t1-t0 の値が、2^-3以下だったら、その積は、2^-7よりも小さいので、アンダーフローを起こして0になります。
色にすると、2^-3=0.125に128倍して、16が有効な色の差になります。
隣との色の差が16以内だったら、同じ色として扱われて、散逸は発生しなくなります。
色が256段階とすると、16段階の階層分けが行われます。1テクセル1段階とすると、16テクセルになります。
墨絵の絵のにじみの大きさも、大体そんな具合ではないでしょうか。

と、考えているけど、ほんとかなぁ?
何にせよ、短くまとめてしまうと、散逸して黒い部分が消えてしまうので、絶妙なバランスをとっていることは確かです。

ほんとは、別の方法で実装しようと考えていたのですが、命令数が足りなくなって、この方法に落ち着きました。

■最後に

また、一発芸をしました。
今回の方法のように、座標をずらしたテクスチャーを重ねれば、「波」が作れます。

■追記:Cg言語

Cg Shaderscgshaders.org contestに、 この墨絵をCg言語で記述したものを応募してみました(結果は芳しくありませんでしたが)。

内容は似たようなもんです。

sumie.psh頂点シェーダー。
sumie.vshピクセルシェーダー。
draw.cppメインの描画部分。
draw.h描画の各関数の定義。
main.h基本的な定数など。
main.cpp描画に関係しないシステム的な部分。
license.txtフリー宣言のライセンス

mask.bmp (点を打つときに使う)

一番苦労した点は、「そのままではコンパイルが通らない」ということです。
コンパイラの最適化によると思うのですが、ピクセルシェーダーの命令数が少なくて、べたに数式に落としたときに通りませんでした。
試行錯誤を繰り返した結果、最終的に次の式でコンパイルが通りました。

O.col = 0.15f*(t2-t0+t3-t0+t1-t0)+t0;

t0がいたるところにありますが、これを一箇所にまとめると、アンダーフローが起きなくなって墨は完全に紙に吸い込まれてしまいます。
同じ見た目を出すためにパラメータを調整した結果、係数は0.15になりました。3つ分足してるので、誤差が小さくなってるんですかね?

英語できちんと表現できる能力が欲しいと痛烈に感じた、今日この頃です。




もどる

imagire@gmail.com