AndroidJava/Pathで描く、その2

パスで絵を描く、その2(2019-08-14、追記:2023-07-09)


Pathですこし複雑な絵を描いてみる話です。


そもそもPathとは、という話は「AndroidJava/Pathで描く、その1」で。

動的な絵を描く例

Pathで何かを書く時は、全体的に「動的に」書くことができます。

Path全体の色を徐々に変える、透明にする、など
画面エフェクトを付ける。ページをめくる絵とか。


実例として、以下では「ページをめくる」ような影を描いています。
まずは座標が動いているな〜、くらいの雰囲気が伝わればと思います。

ペラペラしたやつを描くの図

そのページをめくるコード

   private void drawMoveEffect(Canvas canvas, float pf){
       Path path = new Path();
       Paint paint = new Paint();
       paint.setColor(Color.GRAY); // ページの影の色
       paint.setStyle(Paint.Style.FILL);
 
       float base_x = canvas.getWidth(), base_y = canvas.getHeight();
       if(pf > 1f) return; // 描画の終了
       if(pf < 0) pf=0;
       float mf=1f-pf;
       float aj_x0 = -base_x*0.1f; // 左へのX調整。ページ端を少し画面の左へはみ出す
       float aj_y0 = -10; // 上へのY調整。ページ端を少し画面の上へはみ出す
       float aj_y1 = 10; // 下へのY調整。ページ端を少し画面の下へはみ出す
       float x0 = base_x*mf +aj_x0; // ページの左端
       float y0 = aj_y0-1f; // ページの左上
       float y1 = base_y*(0.5f+0.5f*pf)+aj_y1; // ページの左下。画面50%から、100%へと下げる
       float x2 = x0 + base_x*0.3f*mf; // ページ先端。
       float y2 = y1 - base_y*0.3f*mf; // ページ先端。
       path.moveTo(x0, y0); // 左上
       path.cubicTo(x0 - base_x*0.03f*mf, base_y*0.3f*mf, // 左上から下へのハンドル1、下と少し左からゼロへ
               x0-base_x*0.02f*mf, y1-base_y*0.12f*mf, // ハンドル2。左下を斜め上へ引っ張る感じで
               x0+base_x*0.03f*mf, y1-base_y*0.03f*mf // 左下。一瞬遅れて到着する感じで。
       );
       path.cubicTo(x0+base_x*0.025f, y1+0.15f*mf, // ハンドル1、左下を斜め下へ引っ張る
               base_x*0.8f, base_y+aj_y1, // ハンドル2、固定、動く左下を終点へ誘導する
               base_x, base_y+aj_y1 // 右下、固定
       );
       path.cubicTo(base_x*(1-0.2f*(float)sin010(pf,1f)), base_y*(0.95f+0.05f*pf), // ハンドル1、ページ先端に膨らみをつける
               x2, y2, x2, y2 // ハンドル2とページ先端
       );
       path.cubicTo(
               x2, y2, // ハンドル1はページ先端と同じで
               x0+base_x*0.05f*mf, y0+base_y*0.2f*(float)sin010(pf,1f), // この前の、ハンドル1のXYを入れ替えたイメージ
               x0, y0
       );
  
       paint.setAlpha((int)(70 * sin01(pf, 1f))); // 徐々に表示だけど、sinで変化を加える
       canvas.drawPath(path, paint);
   }
 
   public static double sin010(double now, double total){
       double d;
       double total2 = total * 2;
       if(total2 == 0){
           d = Math.sin(now); // 0除算の回避
       }else {
           d = Math.sin(Math.PI * 2 * ((now % (total)) / total2)); // 0 -> 1.0 -> 0 を1サイクルとする
       }
       return (d >= 0 ? d : d * -1.0);
   }
   public static double sin01(double now, double total){
       double d;
       double total4 = total * 4;
       if(total4 == 0){
           d = Math.sin(now); // 0除算の回避
       }else {
           d = Math.sin(Math.PI * 2 * ((now % total) / total4)); // 0 -> 1.0 を1サイクルとする
       }
       return (d >= 0 ? d : d * -1.0);
   }
   // 前述の関数をこんな風に呼ぶ
   // View などの Canvas を渡す
   float percent = System.currentTimeMillis() % 2000 / 2000f; // 2秒間かけて動かす
   drawMoveEffect(canvas, percent);

...こんな複雑なコードは、作者自身でも読むの辛いです。
少しずつ書き足す内に、徐々にこうなりました。雰囲気だけ分かれば良いです。

Math.sinについての解説は別途「AndroidJava/sinで放物線や明滅をつくる」で。
動きを直線的では無く、緩急をつけたものにするのが目的です。

ただ、
理屈は単純で、上記の例だと10個くらいある座標について、
1点を(残りの9点は一旦忘れて)動かす、というのを10回繰り返したら、こうなります。
いや、ほんと。そうやって作りました。

動画ではなく、あくまで点を動かしている、と考えましょう。
イメージ通りに動くかどうかは、考えるよりも実際に動かしながら確認したほうが早いです。

たとえば画面左上の点(始点)を、右端(終点)へ移動させるなら、
始点(0, 0) から 終点(0+canvas.getWidth(), 0) へ移動するわけで、
それに時間経過がつけば (0 + (canvas.getWidth() * draw_percent), 0)
ということを、
10個全ての座標に適用...って文章で書くと分かりにくいですね。

もし興味があれば、まず簡単な図形を動かしてみるのからやってみるのをオススメします。

フィルターをかける&切り抜く

PaintでStyle.FILLを選んで、画面や絵にフィルターをかける場合があると思います。
(何かの画像やパーツを canvas.drawRectで部分的に塗りつぶしたり)

画面フィルターの図

画像の例のように、スポットライト照明のように画面を円形に「切り抜く」ようなフィルターをかける場合には、
drawRectやらdrawCircleではなく、Pathを使うという手があります。

画面フィルターの図 画面フィルターの図

つまり、画面を切り抜くというよりは、そこ以外のすべてを塗るかんじです。
画像の例では隙間を空けてPathを書いていますが、実際は隙間は無しで。
canvasに描画する時は、座標(0, 0)から (canvas.getWidth(), canvas.getHeight())めがけて描く、というよりは、
座標(-100, -100)というように、画面外も塗りつぶす勢いで問題ありません。
座標が画面外やマイナス値を指定しても、画面からはみ出るだけで、特に問題はありませんので。
最初のスポットライト画像の例は、それを2つ重ねています。塗る透明度(paint.setAlpha)を薄めにして画面全体を2回塗りつぶしています。
さらに、それを踏まえて、

画面フィルターの図

雪を降らせるような?複数の切り抜きを行う場合の例ですが、
将棋盤、碁盤の目のようなマス目をイメージしてください。
そのひとマス毎に、同じように前述の円の切り抜き作業を繰り返します。
雪を「降らせる」なら、マス目全体の座標を下へ向かって加算していく感じです。
最初は1マスから初めてみて、問題なければマス目を増やしてみましょう。


SVGファイルをjavaのコードに変換する例

そもそも、
gimp2とかの画像エディタで描いたパスを、
javaのPathの座標にする、という機械的な作業をやるならば、
svg を直接 lintTo() 関数に変換するツールを作れば、話が早いです。

(gimp2画像エディタだと、
 パス一覧ダイアログ画面で対象のパスを右クリックすると、
 パスをエクスポート、というメニューからsvgファイルを生成できます)

右クリックでエクスポートの図

探せばそういうツールがありそうな気がしますが、
見つからなかったので、以下に例をメモします。
スクリプトはperlで、gimpのsvgでしか動作確認していません。
コードはまだ改善の余地ありありですが、
svgファイルという定形書式のデータを使って、直接Path関数描いちゃえ、
の、雰囲気が伝われば...

#!/usr/bin/perl -w

# gimp2 から拾った svgファイルから
# Android java の path へ変換する例
# 
# gimp2から以下のようなファイルを
# 第一引数の ARGV[0]から読み込んでいく ---------------------------------------
# <?xml version="1.0" encoding="UTF-8" standalone="no"?>   # ここは無視
# <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"      # ここも無視
#              "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd"> #無視
# 
# <svg xmlns="http://www.w3.org/2000/svg"  # 無視
#     width="1.38889in" height="1.38889in" # 無視
#     viewBox="0 0 100 100">               # 横幅を基準値として使う
#  <path id="PATH_NAME"                    # 名称として使う
#        fill="none" stroke="black" stroke-width="1" # 無視する
#        d="M 62.93,28.55                            # Path関数へ変換
#           C 62.51,23.24 60.75,18.73 57.37,14.00    # Path関数へ変換
#        (中略)
#              79.99,65.61 84.64,65.45 84.64,65.45 Z" /> # 数字は拾う。Zは無視する
# </svg>                                                 # "</" を終端に使う
#------------------------------------------------------------------------------
#
#

my %option;

my $svg_file = "$ARGV[0]";
my $out_file = "";
if(defined $ARGV[1] and length($ARGV[1]) > 0){
  $out_file = "$ARGV[1]";
}
my $size = 0;
my $path_id = "";
my $path_id_count = 0;
my %used_id;
my $line = "";
my $is_skip = 0;
my $last_x = 0;
my $last_y = 0;

my $state = "NONE";
  # state は以下の状態遷移を繰り返す
  # PATH_ID           path id を拾った状態
  #  -> PATH_SET      とりあえず MかCを待つ
  #     PATH_M        moveTo する
  #     PATH_C        lineTo または cubicTo する
  #      -> PATH_END  おわり

open($IN, "< $svg_file") or die "$! [$svg_file]\n";

if(length($out_file) <= 0){
 if($svg_file =~ /\/([^\.\/]+)\..+/){
    $out_file = $1 . "_out.txt";
  }
  elsif($svg_file =~ /([^\.]+)\..+/){
    $out_file = $1 . "_out.txt";
  }
  else{
    $out_file ="./output.txt";
  }
}

if($out_file =~ /STDOUT/ or $out_file =~ /stdout/ or $out_file =~ /\-/){
  # ファイルではなく標準出力に書き込む
  $OUT = STDOUT;
}else{
  # ファイルに上書きする
  printf("out:%s\n", $out_file);
  open($OUT, "> $out_file" . "_out.txt") or die "$! [$out_file]\n";
}

#
# svgファイルの座標を、java ソースコードに変換する
# 例として関数ごとまるっと作成するが、
# 実際に使う時は switch 文の中身だけを作成して、適宜差し替える
#
my $head_java=<<"EOL";
    static Path get(Path ready_path, float left, float top, float width, float h,  int id, boolean is_reverse){
        Path pt;
        if(ready_path == null) pt = new Path();
        else pt = ready_path;

        float x = left, y = top, w = width;
        if(w <= 0 || h <= 0) return pt; // 長さ0なら意味がないので、すぐに戻す

        if(is_reverse){
            // 左右反転を試みる。ただしPATHの内容次第。
            x = left + width;
            w *= -1f;
        }
        long cur = System.currentTimeMillis();

        switch (id){
EOL
# 注: EOL はヒアドキュメントなので、先頭にスペースを入れないこと
print $head_java;

#
# 以下、switch 文の中身として Path.lineTo やら Path.cubicTo を書く
#
while(<$IN>){
  if(/viewBox="([\d\.]+) +[\d\.]+ +([\d\.]+) +[\d\.]+"/){
    # 横幅をサイズ基準値(100%)として保持する
    $size = $2 - $1;
    dbg_printf("size:%.1f[%.2f - %.2f]\n", $size, $2, $1);
  }
  
  if(/path id="([^\s"]+)/){
    $state = "PATH_ID";
    $path_id = $1;
    dbg_printf("id:%s\n", $path_id);
    if(defined $used_id{$path_id} ){
      # 1つのsvgファイルに同じ path id が二回出てきてしまった場合
      err("--- already defined:%s\n", $path_id);
      $is_skip = 1;
    }else{
      $path_id = $1;
      $used_id{$path_id} = $path_id_count;
      $path_id_count++;
      $is_skip = 0;
      printf($OUT "case %s:\n", $path_id);
    }
  }
  
  if(($state eq "PATH_ID") and ($_ =~ / +d="(.+)/)){
    $state = "PATH_SET";
    $line = $1;
    dbg_printf("start path(%s) ---\n", $line);
  } elsif(($state eq "PATH_SET")
     or ($state eq "PATH_M")
     or ($state eq "PATH_C")
    ){
    $line = $_;
  }
  
  while(($state eq "PATH_SET")
     or ($state eq "PATH_M")
     or ($state eq "PATH_C")
    ){
      dbg_printf("%s\n", $line);
      if(length($line) < 1 || $line =~ /^ *$/){
        last;
      }
   
      if($line =~ /^ *([MC])/){
        my $head = $1;
        if($head =~ /M/ and $line =~ /M +([\-\d\.]+),([\-\d\.]+)(.*)/){
          $state = "PATH_M";
          $line = $3;
          dbg_printf(" -PATH_M\n");
          if($is_skip == 0){
            my $a1 = pp($1 / $size), $a2 = pp($2 / $size);
            printf($OUT "  pt.moveTo(x+w*%.2ff, y+h*%.2ff);\n",
                       $a1, $a2 );
                       $last_x = $a1;
                       $last_y = $a2;
          }
        }
        elsif($head =~ /C/ and $line =~ /C +([\-\d\.]+),([\-\d\.]+) +([\-\d\.]+),([\-\d\.]+) +([\-\d\.]+),([\-\d\.]+)(.*)/){
          $state = "PATH_C";
          $line = $7;
          dbg_printf(" -PATH_C\n");
          if($is_skip == 0){
            my $a1 = pp($1 / $size), $a2 = pp($2 / $size), $a3 = pp($3 / $size), $a4 = pp($4 / $size), $a5 = pp($5 / $size),$a6 = pp($6 / $size);
            if($last_x == $a1 && $last_y == $a2 && $a3 == $a5 && $a4 == $a6){
              printf($OUT "  pt.lineTo(x+w*%.2ff, y+h*%.2ff);\n",
                       $a5, $a6 );
            }else{
              printf($OUT "  pt.cubicTo(x+w*%.2ff, y+h*%.2ff, x+w*%.2ff, y+h*%.2ff,x+w*%.2ff, y+h*%.2ff);\n",
                       $a1, $a2, $a3, $a4, $a5, $a6 );
            }
            $last_x = $a5;
            $last_y = $a6;
          }
        }
      }
      elsif(
          ($line =~ /([\-\d\.]+),([\-\d\.]+) +([\-\d\.]+),([\-\d\.]+) +([\-\d\.]+),([\-\d\.]+)(.*)/)
            and $state eq "PATH_C"){
        $state = "PATH_C";
        $line = $7;
        dbg_printf(" -PATH_C(continue)\n");
        if($is_skip == 0){
            my $a1 = pp($1 / $size), $a2 = pp($2 / $size), $a3 = pp($3 / $size), $a4 = pp($4 / $size), $a5 = pp($5 / $size),$a6 = pp($6 / $size);
            if($last_x == $a1 && $last_y == $a2 && $a3 == $a5 && $a4 == $a6){
              printf($OUT "  pt.lineTo(x+w*%.2ff, y+h*%.2ff);\n",
                       $a5, $a6 );
            }else{
              printf($OUT "  pt.cubicTo(x+w*%.2ff, y+h*%.2ff, x+w*%.2ff, y+h*%.2ff,x+w*%.2ff, y+h*%.2ff);\n",
                       $a1, $a2, $a3, $a4, $a5, $a6 );
            }
            $last_x = $a5;
            $last_y = $a6;
        }
      }
      elsif($line =~ / Z(.*)/){
        #$state = "PATH_Z"; # 現状は特に何もしない
        dbg_printf(" -PATH_Z\n");
        $line = $1;
      }
      elsif($line =~ /\/\>/){
        $state = "PATH_END";
        $path_id = "";
        dbg_printf(" -PATH_END\n");
        if($is_skip == 0){
          printf($OUT "  break;\n");
        }
      }
      else{
        err("unexpexted format:%s\n", $line);
        last;
      }
  }
}

# svgファイルを読み終えたので、javaソースコードのswitch 文を閉じる
my $tail_java=<<"EOL";
  
            default:
                return pt;
        }
        return pt;
    }
}
EOL
# 注: EOL はヒアドキュメントなので、先頭にスペースを入れないこと
print $tail_java;

#
# 最後に、これまで書いたPATHを static 変数化する
#
printf($OUT "//-------------\n");
printf($OUT "static final int\n");
my $before_key = "";
foreach (sort { $used_id{$a} <=> $used_id{$b}}(keys(%used_id))){
   my $key = $_;
   if(length($before_key) <= 0){
     printf($OUT "%s = 1", $key);
   }else{
     printf($OUT ",\n%s = %s + 1", $key, $before_key);
   }
   $before_key = $key;
}
printf($OUT ";\n");
close($OUT);

#--- 四捨五入して丸める関数 ---------------
sub pp {
  # 例では小数点以下2桁なので、%.2f とあわせて必要な桁数に調整する
  #   -> 桁数を丸めることで、
  #      0.001 ピクセル程度の誤差なら同じ座標とみなす
  #      これをやらないと lineToが消え、cubicToが量産されてしまう
  #   -> ただ、最初から cubicToで精緻な絵を描く予定なら
  #      今度は小数点4桁くらいに増やしてください
  #      100 -> 10000 と、 %.2f -> %.4f です。
  my $val = shift;
  my $a = ($val > 0) ? 0.5 : -0.5;
  return  int($val * 100 + $a) / 100; 
}

#--- 以下はデバッグログ関数 ----------------
sub dbg_printf {
  #printf STDOUT @_ ;
  #$|=1;
}
sub err {
  printf STDOUT @_ ;
  $|=1;
}

私の場合は、そもそもパスという専門用語?を知らない状態で、
座標なんかで絵なんて描けるわけないじゃん、とか思ってたのが、
試行錯誤している内に、ここに行き着いた感じです。

PathってBitmapよりも、メモリや処理が軽くてラクだなぁ、としみじみ思います。