スマホで砂に絵を描けるWebサービスをWebGLで作ってみた

**"砂"に描いた絵や文字って何故か、喜びだったり哀愁のような人の思いを感じさせます。**
ということで、**"砂"に文字や絵を描けるWebサービス**を作ってみました。
砂の凹凸はWebGLを使って3Dで表現しています。

タッチイベントにも対応しているので、スマホでアクセスすれば、指を使って砂に絵を描くことができます。
~~また描いた絵は、そのまま画像付きでTwitterに投稿できるようにしています。~~

**Pittura**
※元々heroku上で動かしていましたが、Github Pagesに載せ替えて動かしています。
　アップロードやツイート等の機能はすべて外しています。
https://negi141.github.io/pittura-demo/

▽技術的に詰まった箇所や、ややこしい箇所を書いていきます。
目次：

1. [実行環境](https://qiita.com/negi3d/items/2f9d582ff49c56a906eb#%E5%AE%9F%E8%A3%85)
1. [iOS、AndroidのWebGL対応状況](https://qiita.com/negi3d/items/2f9d582ff49c56a906eb#iosandroid%E3%81%AEwebgl%E5%AF%BE%E5%BF%9C%E7%8A%B6%E6%B3%81)
1. [Canvas上のタッチイベント処理について](https://qiita.com/negi3d/items/2f9d582ff49c56a906eb#canvas%E4%B8%8A%E3%81%AE%E3%82%BF%E3%83%83%E3%83%81%E3%82%A4%E3%83%99%E3%83%B3%E3%83%88%E5%87%A6%E7%90%86%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6)
1. [凹凸の表現について](https://qiita.com/negi3d/items/2f9d582ff49c56a906eb#%E5%87%B9%E5%87%B8%E3%81%AE%E8%A1%A8%E7%8F%BE%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6)
1. [波の表現について](https://qiita.com/negi3d/items/2f9d582ff49c56a906eb#%E6%B3%A2%E3%81%AE%E8%A1%A8%E7%8F%BE%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6)
1. [画像付きでツイートするコード](https://qiita.com/negi3d/items/2f9d582ff49c56a906eb#%E7%94%BB%E5%83%8F%E4%BB%98%E3%81%8D%E3%81%A7%E3%83%84%E3%82%A4%E3%83%BC%E3%83%88%E3%81%99%E3%82%8B%E3%82%B3%E3%83%BC%E3%83%89)

# 実行環境
- サーバー：Heroku
- 言語/フレームワーク：Ruby On Rails
- DB：MySQL
- グラフィックス：WebGL(three.js) 

# iOS、AndroidのWebGL対応状況
iOSならiOS8以上、Androidなら基本的にはAndroid4以上のChromeであれば、WebGLが動作するようです。
(参考: https://tips.spacely.co.jp/spvr_devices/)
2016年12月の記事ですが、国内のスマホ全体では98%以上が対応とありますので、現時点では、ほとんどのスマホでWebGLは使えると考えて良さそうです。

念のため、Detector.jsを使って、ブラウザ・端末がWebGLに対応しているかの判定を行うのがよいです。

# Canvas上のタッチイベント処理について
作成したCanvas要素のマウス・タッチイベントのハンドリングは、以下のような流れで実装しました。

1. Canvasのタッチ・マウスイベント処理をバインドする
2. スクロール位置を加えたCanvas要素の座標を取得
3. ページ内のタッチ/マウス座標を取得 (タッチの場合はイベントオブジェクトtargetTouchesがあるかどうかで判定)
4. Canvas内のタッチ/クリック座標を計算
5. イベントのタイプにより、処理を行う (タッチ系のタイプはtouchstart, touchend, touchcancel, touchmove)
6. 最後にe.preventDefault()で、イベントをキャンセルする (スマホでスクロールしないようにするため)

```javascript:javascript(jQuery使用)
// Canvasのタッチ・マウスイベント処理をバインド
$("canvas").bind('click mousedown mouseup mousemove mouseleave mouseout touchstart touchmove touchend touchcancel', onEvent);

// スクロール位置も加えたCanvas要素の座標
var getElementPosition = function (elem){
    var position = elem.getBoundingClientRect();
    return {
        left: window.scrollX + position.left,
        top: window.scrollY + position.top
    }
}

// イベント処理
var onEvent = function (e) {
    // スクロール位置も加えたCanvas要素の座標
    var canvasPos = getElementPosition(e.target)
    // ページ内のタッチ/マウス座標
    var pageMouse = { x: 0, y: 0 };  
    if (e.originalEvent && e.originalEvent.targetTouches && e.originalEvent.targetTouches.length != 0) {
        pageMouse.x = e.originalEvent.targetTouches[0].pageX;
        pageMouse.y = e.originalEvent.targetTouches[0].pageY;
    } else {
        pageMouse.x = e.pageX;
        pageMouse.y = e.pageY;
    }
    // Canvas内のタッチ/クリック座標
    var mouse = { x: 0, y: 0 };  
    mouse.x =  Math.round(pageMouse.x - canvasPos.left);
    mouse.y =  Math.round(pageMouse.y - canvasPos.top);
    switch (e.type) {
        case 'mousedown':
        case 'touchstart':
            painting = true;
            /*～～押下開始時の処理～～*/
            break;
        case 'mouseup':
        case 'mouseout':
        case 'mouseleave':
        case 'touchend':
        case 'touchcancel':
            painting = false;
            /*～～押下終了時の処理～～*/
            break;
        case 'mousemove':
        case 'touchmove':
            break;
        }
    if (painting) {
        /*～～押下＋移動時の処理～～*/
    }
    e.preventDefault();
};
```

# 凹凸の表現について
今回作成したサービスの肝の部分ですが、砂の凹凸の表現は、バンプマッピングをリアルタイムで更新して行っています。
砂部分がタッチされたら、画面上には表示していないCanvasにブラシで描いたような太い線を描画していき、それをバンプマップとして読み込ませています。

実際の砂に線を描くと、中央部分は凹み、周辺は少し盛り上がります。
そこで、バンプマップは最初はグレーrgba(200, 200, 200)にしておき、タッチした中央は凹ませるために黒く塗り、周辺は盛るために白く塗ります。
また盛った部分はピクセル事にノイズをつけて、それっぽくしています。
![sand1.png](https://qiita-image-store.s3.amazonaws.com/0/27057/633fe685-2d21-fea9-afb3-0b73c01e5084.png)
![sand2.png](https://qiita-image-store.s3.amazonaws.com/0/27057/a53f87f6-40cd-b674-1854-5092e5ef4ec5.png)


# 波の表現について
今回、砂の文字を消すために"波"で消せるようにしています。
波の元となる映像は別途撮影なりしておき、
その合成は、かなり力技でやっています。
65枚の波の画像を事前に（Canvasの初回生成時に）テクスチャデータとして読み込んでおき、
それをボタンが押されたタイミングで、0.07秒刻みで切り替えることでアニメーションさせています。

```javascript:javascript
// 事前に読み込んでおく
waveTexture = [];
for(var i=1 ; i<=65 ; i++){
    var t = new THREE.ImageUtils.loadTexture('/images/tex_wave/_' + i + '.jpg');
    waveTexture.push(t);
}

//～(中略)～

// テクスチャ―を切り替える
waveMaterial.map = waveTexture[step];
```

波は、以下の設定で加算合成させて、それっぽく見えるようにしています。

```javascript:javascript
waveMaterial = new THREE.MeshBasicMaterial({transparent: true, depthTest: false });
waveMaterial.blending = THREE.CustomBlending;
waveMaterial.blendEquation = THREE.AddEquation;
waveMaterial.blendSrc = THREE.DstColorFactor;
waveMaterial.blendDst = THREE.SrcColorFactor;
```

![wav1.jpg](https://qiita-image-store.s3.amazonaws.com/0/27057/d2f58424-1439-292a-6b45-ae01895aeb18.jpeg)  ![wav2.png](https://qiita-image-store.s3.amazonaws.com/0/27057/4b2422c7-c61d-0d06-a43b-bc6c48e2d352.png)


# 画像付きでツイートするコード

```javascript:javascript
// preserveDrawingBuffeをtrueにする
var context = renderer.domElement.getContext("experimental-webgl", {preserveDrawingBuffer: true});
// base64でデータ取得
var dataUrl = renderer.domElement.toDataURL().replace(/^.*,/, '');
// hiddenフィールドに格納
$("#data_url").val(dataUrl);
// hiddenの格納が完了しないまま、submitされてしまうことがあるので、
// 確実にデータが入っているのを確認してからsubmitしています
setTimeout(function(){
    if ($("#data_url").val() !== "") $("#data_post").submit();
}, 100);
```

```ruby:ruby
# base64デコードしてファイル作成
File.open(fname, 'wb') do|f|
    f.write(Base64.decode64(params['data_url']))
end

# Twitter Gemを使用
client = Twitter::REST::Client.new do |config|
    config.consumer_key       = Rails.application.secrets.twitter_api_key
    config.consumer_secret    = Rails.application.secrets.twitter_api_secret
    config.access_token        = current_user.token
    config.access_token_secret = current_user.secret
end

# CommentにURLやハッシュタグを追加
str_out = params['comment'] + " www.～～ #～～"
# 作成した画像付きで投稿
client.update_with_media(str_out, open(fname))
```

以上です！
もし要望・バグ報告ありましたらTwitter [@negi3d](https://twitter.com/negi3d) 宛まで m(_ _)m