CustomRenderTarget
では新機能について説明。
CustomRenderTarget
リファレンスにも書いてあるけども。RenderTargetを継承して作られている。現在はまだ残っているがdraw系メソッドは呼び出しても何も起こらない。消す予定。ユーザはCustomRenderTargetを継承してcustom_renderメソッドを自分で実装して使う。custom_renderに渡される引数は低レベルインターフェイスモジュールで、こいつを経由して低レベルの描画機能を呼び出す。
VertexBuffer
頂点バッファ。頂点のフォーマットをnewに渡して生成する。頂点情報の値は配列にrubyオブジェクトでIntegerやFloatを詰めてVertexBuffer#vertices=に渡す。こいつをcustom_render内で使う。
ということで以下もうちょい具体的な話。
DXRuby1.5.21dev
久しぶりの開発版リリース。DXRuby1.5.21devをWiki(避難所)に置いておいた。なんか20が飛んだような気がしないでもないがまあ数字に意味は無いので気にしない。
今回はCustomRenderTargetクラスとVertexBufferクラスを追加した。これらは今までよりも低レベルなインターフェイスで描画ロジックを記述することができる仕組みで、応用すると3D描画もできる。開発版なのでいろいろと荒いところがあったり、今後いきなり変わったりすることもあるんじゃないかと思うが、なんとなく動いているし新しいものなので興味ある人にどぞ〜って感じで。個人的には割と気に入っているのでいつぞやの3D機能のように無くなることはないんじゃないかなと思っている。
CustomRenderTargetとVertexBuffer
いちおうリファレンスには書いてみたのだが、これの説明はなかなか難しい。ぶっちゃけてしまうとDirectX9のインターフェイスをほぼそのままRuby側に出しただけ。それでもRuby的じゃなさすぎるところや面倒すぎるところ、使わないと思われるところは隠蔽・削除してある。
具体的な機能としては頂点バッファを作ってデータを詰め込んで、シェーダに食わせて描画することができる。これはDirectX9の非常に基本的な使い方であり、3Dにとどまらず広い範囲で活用できる。GPUの機能として3Dに特化したものが数多くあるのでメイン用途は3Dとなるのだろうが、俺としては3Dがやりたくて作ったわけではなくて、応用の利くインターフェイスにしておけば3Dにも使えると思った次第である。ベクタグラフィックスができて3Dもできるものを作ろうとしたらこうなった。
解説記事とか
しばらく使い方みたいなのを記事に書いていければいいな〜と思っている。実際これはサンプルが無いと使い方もよくわからんということになりそうなので。
個人的にはDirectX9の機能をすごく簡単に呼べて動かせるので、そもそもの原理的なところから順番に説明してもよさげかと。できる限りそぎ落とした基本的なコードから、どこで何をするとどうなるのか、どういう処理をどこでやるようにDirectXは設計されているのか、みたいなことを把握できるようなチュートリアルが作れそうな気がしている。
それなりの数の人が期待しているであろう「簡単に3Dができるもの」ではないが、現代においてそういうものを作るにはツールやファイルフォーマット的なところから揃えていく必要があるので個人ではちと厳しい。あと興味無い。できるようにしておくのでできる人がやってくれたらよいと思う。俺はこのぐらいのレベルでRubyで色々遊べるものが欲しかったのである。
ベクタグラフィックスその8
大物としては最後になる塗りつぶし機能。NanoVGではベジェ曲線だろうがなんだろうが囲まれた部分を指定の色やグラデーションで塗りつぶすことができる。塗りつぶした三角形なら簡単に描画できるが、形状が不定のものをどうやって塗りつぶすかという問題である。
理屈
直線4本を連続で描画してこのような図形を作るとする。
これを塗りつぶすと左右の三角形に色が付くことになるのだが、塗りつぶした三角形でこれを描画するには真ん中の交点が必要となる。このレベルの形状なら簡単だが、複雑な形状になるとそれを計算するのは非常に難しくなるので、NanoVGではそのような手法を使わず、ステンシルバッファとTriangleFanを使う。
ステンシルバッファとはOpenGLやDirectXに昔からあるマスクを作るバッファである。フレームバッファと同様に1ピクセル単位で値を持ち、ポリゴンを描画して、条件により値を書き込むことができる。TriangleFanはググるとたくさん出てくるが、連続した三角形を少ない頂点数で描画する表現方法の一つである。
で、この2つをどう組み合わせるかというと、まず塗りつぶす頂点を順番に用意しておいて、おもむろにTriangleFanでステンシルバッファに描画する。描画条件として、左回りは-1、右回りは+1と設定する(逆かもしれないがどっちでもいい)。これにより、左右の両方で同じ回数描画された領域は0になる。最後に、形状を覆うサイズの矩形でステンシルバッファが0以外のところに色を描くと、綺麗に塗りつぶされた画像が作れる。この方式を非ゼロ回転数ルール(参考)と言うらしい。
もうちょい具体的に。
さきほどの画像をTriangleFanで描画すると、ステンシルバッファに123を結ぶ三角形と134を結ぶ三角形が描画される。123は左回り、134は右回りとなり、両方が描画されるエリアは0になるので色が置かれない。なるほどうまいことできている。
実装する
NanoVGではアンチエイリアス用バックエンドのためにちょと面倒なコードがあるが、今のところアンチエイリアスする機能が無いのでそのへんは省く。まずfill。
def fill flatten_paths expand_fill(0, :miter, 2.4) render_fill end
strokeと似た感じ。次にexpand_fill。
def expand_fill(w, line_join, miter_limit) calculate_joins(w, line_join, miter_limit) # 頂点生成 @subpaths.each do |subpath| ([subpath.points.last] + subpath.points).each do |p0| subpath.verts << p0.v subpath.verts << p0.v end end end
色々計算はするけど特に使うこともなく全部の点を素直に結ぶ。んでrender_fill。
private def render_fill s = Array.new(@image.height){Array.new(@image.width){0}} # ステンシルバッファ # ステンシルバッファにマスクを描画する @subpaths.each do |subpath| bp = Vector.new(subpath.verts[0].x, subpath.verts[0].y) subpath.verts[1..-1].each_cons(2).with_index do |ary| p0, p1 = ary stencil_triangle(bp.x, bp.y, p0.x, p0.y, p1.x, p1.y, s) end end # ステンシルバッファのマスクを適用した描画 @subpaths.each do |subpath| @image.height.times do |y| @image.width.times do |x| @image[x, y] = @paint.calc_color(x, y) if s[y][x] != 0 end end end end
ステンシルバッファは単純に配列の配列で作る。TriangleFanと同じように頂点を指定してstencil_triangleを呼ぶことで配列の配列にマスクデータを構築する。その後にImage全体でマスクが0以外の場所に色を書き込む。stencil_triangleはこんな感じに。
private def stencil_triangle(x1, y1, x2, y2, x3, y3, s) cross = (x3-x2) * (y2-y1) - (x2-x1) * (y3-y2) if cross > 0.0 rasterize(x2, y2, x1, y1, x3, y3) do |x, y| s[y][x] -= 1 end else rasterize(x1, y1, x2, y2, x3, y3) do |x, y| s[y][x] += 1 end end @triangles << [x1, y1, x2, y2, x3, y3] end
三角形がどっち回りかによってステンシルバッファを足したり引いたりする。
ベクタグラフィックスその7
今まで放置していた色をつける機能を追加する。NanoVGではnvgStrokeColorでベタ塗りの色を指定して、nvgStrokePaintでグラデーションを設定できる。どちらも内部的にはNVGstate構造体のstrokeメンバにNVGpaint構造体を格納しているだけである。よってベタ塗りとグラデーションは関数こそ違うが設定は後勝ちとなる。NanoVGではベタ塗りも3種類あるグラデーションもすべて一つのNVGpaint構造体で表現できるようになっていて、この中身のパラメータをシェーダでごにょごにょ計算して色を生成する。
OreVGではラスタライザもRubyで書いているのでこのいかにも重そうな計算をピクセルごとにやるのはちょっと精神衛生上よろしくない。それぞれ分けることにする。
ラスタライザ
とりあえずOreVGクラスにインスタンス変数@paintを追加して、ベタ塗り用もしくはグラデーション用のオブジェクトを格納することにする。このオブジェクトはcalc_colorメソッドを持っていて、座標を渡すと色が返ってくるように作る。なのでラスタライザのtriangleは以下のようになる。
private def triangle(x1, y1, x2, y2, x3, y3) rasterize(x1, y1, x2, y2, x3, y3) do |x, y| @image[x,y] = @paint.calc_color(x, y) end @triangles << [x1, y1, x2, y2, x3, y3] end
ベタ塗り
ベタ塗りの場合はstroke_colorメソッドで色を指定する。渡す値はDXRuby用の色配列ということにしておく。
def stroke_color=(color) @paint = FillColor.new(color) end
FillColorクラスは渡された色を保持してcalc_colorで無条件に返すだけになる。
class FillColor def initialize(col) @col = col end def calc_color(x, y) @col end end
簡単である。
線形グラデーション
線形グラデーションをするためにNanoVGと同様にlinear_gradientメソッドを作ってオブジェクトを返し、stroke_paintメソッドで設定するようにする。
def linear_gradient(x1, y1, x2, y2, incol, outcol) LinearGradient.new(x1, y1, x2, y2, incol, outcol) end def stroke_paint(paint) @paint = paint end
このincolは(x1,y1)地点での色、outcolは(x2,y2)地点での色で、この間が中間色になる。この範囲外はそれぞれincol、outcol固定。これが水平、垂直だけなら簡単なのだが斜めにもグラデーションできてしまう仕様なのでそこだけちょっと考える必要がある。
# 線形グラデーション class LinearGradient def initialize(x1, y1, x2, y2, incol, outcol) v = Vector.new(x2, y2) - Vector.new(x1, y1) @x1 = x1 @y1 = y1 @len = v.len @incol = incol @outcol = outcol @dx, @dy = v.normalize end def calc_color(x, y) # 渡された座標を0〜1にマッピング t = ((x - @x1) * @dx + (y - @y1) * @dy) / @len if t <= 0 @incol # 0以下の色 elsif t >= 1 @outcol # 1以上の色 else # 中間色算出 @incol.zip(@outcol).map do |ary| ary[0] * (1-t) + ary[1] * t end end end end
ちなみにまだ作っていない機能に座標のアフィン変換があって、これで座標を変形するとグラデーションの座標も同様に変形しないといけないのだが、それは当然まだ入っていない。変形するタイミングはstroke_paintなのでそこの中を変更するだけになる。
結果
このように線形グラデーションができるようになる。あと円状のグラデーションと箱状のグラデーションがあるが、クラス追加するだけなのでたいして難しくないはず。
今回のコードはこちら。
ベクタグラフィックスその6
今回は3次ベジェ曲線を作る。ベジェ曲線というのは始点終点であるアンカー2つと制御点を使って曲線を描画するアルゴリズムである。具体的にはベジェ曲線 - Wikipediaの下のほうの作図法を見てもらうとわかりやすい。また、3次ベジェ曲線体験ツールを作ったのでこれを動かしてもらえると実際どのように描画されるのかが目で見てわかる。アンカー、ハンドル、スライダーをドラッグようにしてあるのでどうすればどうなるかが理解しやすい。ちょっと小さくて使いにくいけど。
あと、ベジエ曲線について - s.h’s pageを見てもらえばいろいろと具体的なことがわかると思う。
tesselate_bezier
ベジェ曲線は任意の位置で2つのベジェ曲線に分割することができるので、NanoVGではこの特性を利用して再帰で二等分していって、直線で表現できるレベルまで細かくする。後は分割された点を配列に格納して直線として描画するだけである。この分割する関数がtesselate_bezierで、このようになる。
def tesselate_bezier(x1, y1, x2, y2, x3, y3, x4, y4, level, type) return if level > 10 dx = x4 - x1 dy = y4 - y1 d2 = ((x2 - x4) * dy - (y2 - y4) * dx).abs d3 = ((x3 - x4) * dy - (y3 - y4) * dx).abs if (d2 + d3) * (d2 + d3) < 0.25 * (dx * dx + dy * dy) point = Point.new(Vector.new(x4, y4)) point.corner = type @points << point return end x12 = (x1 + x2) * 0.5 y12 = (y1 + y2) * 0.5 x23 = (x2 + x3) * 0.5 y23 = (y2 + y3) * 0.5 x34 = (x3 + x4) * 0.5 y34 = (y3 + y4) * 0.5 x123 = (x12 + x23) * 0.5 y123 = (y12 + y23) * 0.5 x234 = (x23 + x34) * 0.5 y234 = (y23 + y34) * 0.5 x1234 = (x123 + x234) * 0.5 y1234 = (y123 + y234) * 0.5 tesselate_bezier(x1, y1, x12, y12, x123, y123, x1234, y1234, level + 1, false) tesselate_bezier(x1234, y1234, x234, y234, x34, y34, x4, y4, level + 1, type) end end
大きな曲線を描画するとものすごく細かくなってしまうので、とりあえず10回で再帰を諦めるようになっている。最後の引数はcornerフラグに使われる。ベジェ曲線の途中では線の接続処理はしないという意味になる。このメソッドはSubPathクラスに追加した。ちなみに3次ベジェ曲線体験ツールの描画もこのメソッドを使っている。
他
ベジェ曲線描画用のコマンドクラスは
class CmdBezierTo < Struct.new(:h1x, :h1y, :h2x, :h2y, :x, :y);end
となる。ユーザが呼ぶメソッドは
def bezier_to(h1x, h1y, h2x, h2y, x, y) append_commands(CmdBezierTo.new(h1x, h1y, h2x, h2y, x, y)) self end
で、flatten_pathsは
private def flatten_paths @commands.each do |cmd| case cmd when CmdMoveTo @subpaths << SubPath.new # 新規サブパス追加 point = Point.new(cmd) point.corner = true @subpaths.last.points << point when CmdLineTo point = Point.new(cmd) point.corner = true @subpaths.last.points << point when CmdBezierTo last = @subpaths.last.points.last.v @subpaths.last.tesselate_bezier(last.x, last.y, cmd.h1x, cmd.h1y, cmd.h2x, cmd.h2y, cmd.x, cmd.y, 0, true) when CmdClose @subpaths.last.closed = true end end
結果
ベジェ曲線を描画できるようになった。
また、
def ellipse(cx, cy, rx, ry) append_commands(CmdMoveTo.new(cx-rx, cy)) append_commands(CmdBezierTo.new(cx-rx, cy+ry*KAPPA90, cx-rx*KAPPA90, cy+ry, cx, cy+ry)) append_commands(CmdBezierTo.new(cx+rx*KAPPA90, cy+ry, cx+rx, cy+ry*KAPPA90, cx+rx, cy)) append_commands(CmdBezierTo.new(cx+rx, cy-ry*KAPPA90, cx+rx*KAPPA90, cy-ry, cx, cy-ry)) append_commands(CmdBezierTo.new(cx-rx*KAPPA90, cy-ry, cx-rx, cy-ry*KAPPA90, cx-rx, cy)) append_commands(CmdClose.new) self end
このようなメソッドで楕円を描画できるようにもなる。KAPPA90は
KAPPA90 = 0.5522847493
ベクタグラフィックスその5
端の処理はあるが接続点の処理が無いので今回はこれを作る。
各種フラグ
まずPointクラスに項目を追加して各種フラグを持てるようにする。
class Point < Struct.new(:v, :dv, :dmv, :len, :left, :inner_bevel, :bevel, :corner);end
lenは線の長さでフラグではないが、その次の4つはフラグとなる。それぞれ、左回りフラグ、接続点が形状にめり込むフラグ、接続点の処理をするフラグ、接続点の処理が可能フラグ、みたいな意味合いとなる。calculate_joinsの中でcorner以外の3つを設定する。cornerはベジェ曲線を構成するときに使うので今回はflatten_pathsの中でtrue固定にしている。
private def calculate_joins(w, line_join, miter_limit) iw = w > 0 ? 1.0 / w : 0 @subpaths.each do |subpath| # 点の両側の線の中間を算出する ([subpath.points.last] + subpath.points).each_cons(2) do |p0, p1| p1.dmv = (p0.dv.rperp + p1.dv.rperp) * 0.5 dmr2 = p1.dmv.x * p1.dmv.x + p1.dmv.y * p1.dmv.y if dmr2 > 0.000001 scale = 1.0 / dmr2 if scale > 600.0 scale = 600.0 end p1.dmv *= scale end # 左回りフラグ cross = p1.dv.x * p0.dv.y - p0.dv.x * p1.dv.y if cross > 0 p1.left = true end # 交点が算出できないフラグ? limit = [1.01, [p0.len, p1.len].min * iw].max if dmr2 * limit * limit < 1.0 p1.inner_bevel = true end # join処理が必要フラグ if p1.corner if dmr2 * miter_limit * miter_limit < 1.0 or line_join == :bevel or line_join == :round p1.bevel = true end end end end end
expand_stroke
頂点を生成するところでこのフラグとOreVGのインスタンス変数@line_joinを見て丸い接続点(round)と切り捨てる接続点(bevel)の2種類を呼び分ける。ループするほうだけ抜粋。
private def expand_stroke(w, line_cap, line_join, miter_limit) ncap = curve_divs(w, Math::PI, 0.25) calculate_joins(w, line_join, miter_limit) @subpaths.each do |subpath| if subpath.closed # ループしている場合 # 頂点生成 ([subpath.points.last] + subpath.points).each_cons(2) do |p0, p1| if p1.inner_bevel or p1.bevel if line_join == :round subpath.round_join(p0, p1, w, w, ncap) else subpath.bevel_join(p0, p1, w, w) end else subpath.verts << p1.v + p1.dmv * w subpath.verts << p1.v - p1.dmv * w end end subpath.verts << subpath.verts[0] subpath.verts << subpath.verts[1]
join系メソッドの引数でwが2個あるのは無駄に見えるが、これは塗りつぶし系の処理で呼ぶときに違う値を入れて使うっぽい。
bevel_join
大きく左回りと右回りにロジックが分かれていて、bevelフラグが立っているかどうかで更に分かれる。左右のロジックの違いは符号と頂点順だけである。
# bevel join def bevel_join(p0, p1, lw, rw) dlv0 = p0.dv.rperp dlv1 = p1.dv.rperp if p1.left lv0, lv1 = choose_bevel(p1.inner_bevel, p0, p1, lw) @verts << lv0 @verts << p1.v - dlv0 * rw if p1.bevel @verts << lv0 @verts << p1.v - dlv0 * lw @verts << lv1 @verts << p1.v - dlv1 * lw else lv = p1.v - p1.dmv * rw @verts << p1.v @verts << p1.v - dlv0 * rw @verts << rv @verts << rv @verts << p1.v @verts << p1.v - dlv1 * rw end @verts << lv1 @verts << p1.v - dlv1 * rw else rv0, rv1 = choose_bevel(p1.inner_bevel, p0, p1, -rw) @verts << p1.v + dlv0 * lw @verts << rv0 if p1.bevel @verts << p1.v + dlv0 * lw @verts << rv0 @verts << p1.v + dlv1 * lw @verts << rv1 else lv = p1.v + p1.dmv * lw @verts << p1.v + dlv0 * lw @verts << p1.v @verts << lv @verts << lv @verts << p1.v + dlv1 * lw @verts << p1.v end @verts << p1.v + dlv1 * lw @verts << rv1 end end end
bevelフラグが立ってないのにbevel_joinが呼ばれるってどゆこと?って感じだが、inner_bevelが立っていると呼ばれる。line_joinが:miterの時にinner_bevelが立つとここに来るわけだ。なのでif bevelのelse側は:miter指定時の形状がめり込んでるときの特殊処理と考えればたぶん正解だろう。:bevel指定時のinner_bevelはthen側だが、この場合はchoose_bevel内で細工がされているようだ。このへんはNanoVGのコードをそのまま持ってきたような状態なのできちんと理解できているかどうか怪しい。choose_bevelはこんな感じ。
def choose_bevel(bevel, p0, p1, w) if bevel [ p1.v + p0.dv.rperp * w, p1.v + p1.dv.rperp * w ] else [ p1.v + p1.dmv * w, p1.v + p1.dmv * w ] end end
round_join
こっちは:miter指定の時に来ないのでちょっと簡単だがその代わりに回転する処理があるのでそのぶん面倒になる。
def round_join(p0, p1, lw, rw, ncap) dlv0 = p0.dv.rperp dlv1 = p1.dv.rperp if p1.left lv0, lv1 = choose_bevel(p1.inner_bevel, p0, p1, lw) a0 = Math.atan2(-dlv0.y, -dlv0.x) a1 = Math.atan2(-dlv1.y, -dlv1.x) a1 -= Math::PI*2 if a1 > a0 @verts << lv0 @verts << p1.v - dlv0 * rw n = (((a0 - a1) / Math::PI) * ncap).ceil n = 2 if n < 2 n = ncap if n > ncap n.times do |i| u = i / (n - 1.0) a = a0 + u * (a1 - a0) rx = p1.v.x + Math.cos(a) * rw ry = p1.v.y + Math.sin(a) * rw @verts << p1.v @verts << Vector.new(rx, ry) end @verts << lv1 @verts << p1.v - dlv1 * rw else rv0, rv1 = choose_bevel(p1.inner_bevel, p0, p1, -rw) a0 = Math.atan2(dlv0.y, dlv0.x) a1 = Math.atan2(dlv1.y, dlv1.x) a1 = Math::PI*2 if a1 < a0 @verts << p1.v + dlv0 * rw @verts << rv0 n = (((a1 - a0) / Math::PI) * ncap).ceil n = 2 if n < 2 n = ncap if n > ncap n.times do |i| u = i / (n - 1.0) a = a0 + u * (a1 - a0) lx = p1.v.x + Math.cos(a) * rw ly = p1.v.y + Math.sin(a) * rw @verts << Vector.new(lx, ly) @verts << p1.v end @verts << p1.v + dlv1 * rw @verts << rv1 end end