CustomRenderTargetその2

ここで3Dの計算の詳細(ビューやプロジェクション変換行列の中身の計算とか)について説明するのは何か違うような気がするのでそういうのは省略するとして。って説明できるほど詳しくもない。数学苦手だし。
そうは言ってもDirect3Dはそのような行列を駆使して頂点変換し、その流れでロジックを構成するようになっているので、中身はさておき計算の概要は知っておかないと何もできない。
今回はそういう話。

続きを読む

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を使う。
ステンシルバッファとはOpenGLDirectXに昔からあるマスクを作るバッファである。フレームバッファと同様に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

結果

面倒だったので:round指定の場合の結果だけ。

miterとかの仕組みはHTML5Canvasと同様なのでこのへんのサイトlineJoinとかmiterLimitとかを調べるとよくわかると思う。そもそもNanoVGはHTML5CanvasライクなAPIなのでかなり似ている。
今回のコードはこちら