Chrome でビルドする

マルチデバイス ウェブに LEGO® ブロックを導入する

パソコン版 Chrome ユーザー向けの楽しい試験運用版である Build with Chrome は、オーストラリアで最初にリリースされましたが、2014 年に再リリースされ、世界中で利用可能になり、THE LEGO® MOVIE™ とのタイアップが実現し、モバイル デバイスのサポートが新たに追加されました。この記事では、このプロジェクトから得られた学びをご紹介します。特に、デスクトップ専用のエクスペリエンスから、マウス入力とタップ入力の両方をサポートするマルチスクリーン ソリューションへの移行について説明します。

Build with Chrome の歴史

Build with Chrome の最初のバージョンは、2012 年にオーストラリアでリリースされました。Google は、ウェブの力をまったく新しい方法で実証し、Chrome をまったく新しいユーザーに紹介したいと考えました。

このサイトには、ユーザーが LEGO ブロックを使用して作品を作成できる「ビルド」モードと、LEGO バージョンの Google マップ上で作品をブラウジングできる「探索」モードの 2 つの主要な部分がありました。

ユーザーに最高のレゴ組み立て体験を提供するには、インタラクティブな 3D が不可欠でした。2012 年当時、WebGL はパソコンのブラウザでのみ一般公開されていたため、Build はパソコン専用のエクスペリエンスとして開発されました。探索では Google マップを使用して作成物を表示していましたが、ズームを十分に近づけると、作成物を 3D で表示する WebGL 実装の地図に切り替わりました。この場合も、ベースプレート テクスチャとして Google マップが使用されています。あらゆる年齢の LEGO 愛好家が、簡単に直感的に創造性を発揮し、お互いの創作物を探索できる環境を構築したいと考えました。

2013 年、Google は Build with Chrome を新しいウェブ技術に拡大することを決定しました。その技術の一つが Chrome for Android の WebGL で、これにより Build with Chrome をモバイル エクスペリエンスに進化させることが可能になりました。まず、タップ プロトタイプを開発し、次に「ビルダー ツール」のハードウェアについて検討し、モバイルアプリと比較してブラウザで発生する可能性のあるジェスチャーの動作と触覚の応答性を把握しました。

レスポンシブ フロントエンド

タッチ入力とマウス入力の両方に対応したデバイスをサポートする必要がありました。ただし、小さなタッチスクリーンで同じ UI を使用すると、スペースの制約により最適なソリューションにはなりません。

ビルドでは、ズームインとズームアウト、ブロックの色の変更、そしてもちろんブロックの選択、回転、配置など、さまざまなインタラクティブな操作が可能です。ユーザーが長時間使用するツールであるため、頻繁に使用するすべての機能にすばやくアクセスでき、快適に操作できる必要があります。

高度なインタラクティブ タップ アプリケーションを設計する際には、画面がすぐに小さく感じられ、ユーザーが操作中に画面の大部分を覆う傾向があることに気付くでしょう。これは、ビルダーを操作する際に明らかになりました。デザインを行う際は、グラフィックのピクセルではなく、物理的な画面サイズを考慮する必要があります。ボタンやコントロールの数を最小限に抑えて、実際のコンテンツにできるだけ多くの画面領域を割り当てることが重要になります。

タッチデバイスで Build を自然に使用できるようにすることが目標でした。そのため、元のデスクトップ実装にタッチ入力を追加するだけでなく、タッチ専用に作られたように感じられるものにしました。最終的に、2 種類の UI を作成しました。1 つは画面の大きなデスクトップとタブレット用、もう 1 つは画面の小さなモバイル デバイス用です。可能であれば、単一の実装を使用して、モード間のスムーズな遷移を実現することをおすすめします。Google では、この 2 つのモードのユーザー エクスペリエンスに大きな違いがあるため、特定のブレークポイントを使用することを決定しました。2 つのバージョンには多くの共通機能があり、ほとんどの処理を 1 つのコード実装で行うようにしましたが、UI の一部は 2 つのバージョンで動作が異なります。

YouTube では、ユーザー エージェント データを使用してモバイル デバイスを検出し、ビューポートのサイズを確認して、小画面のモバイル UI を使用するかどうかを判断します。物理的な画面サイズの信頼できる値を取得するのは難しいため、「大」画面のブレークポイントを選択するのは少し難しいです。幸い、このケースでは、大画面のタッチデバイスに小画面の UI を表示しても問題ありません。ツールは正常に動作しますが、一部のボタンが少し大きすぎるように感じることがあります。最終的に、ブレークポイントを 1, 000 ピクセルに設定しました。1,000 ピクセルより広いウィンドウからサイトを読み込むと(横向きモードの場合)、大画面バージョンが表示されます。

2 つの画面サイズとエクスペリエンスについて説明します。

大画面、マウスとタッチのサポート

大画面バージョンは、マウスをサポートするすべてのデスクトップ パソコンと、大画面のタッチデバイス(Google Nexus 10 など)に提供されます。このバージョンでは、利用できるナビゲーション コントロールの種類は元のデスクトップ ソリューションに近いものの、タップ サポートと一部のジェスチャーが追加されています。ウィンドウのサイズに応じて UI が調整されるため、ユーザーがウィンドウのサイズを変更すると、一部の UI が削除またはサイズ変更されることがあります。これは CSS メディアクエリを使用して行います。

例: 使用可能な高さが 730 ピクセル未満の場合、使い方・ヒント モードのズーム スライダー コントロールは非表示になります。

@media only screen and (max-height: 730px) {
    .zoom-slider {
        display: none;
    }
}

小画面、タップのみ

このバージョンは、モバイル デバイスと小型タブレット(ターゲット デバイス Nexus 4 と Nexus 7)に配信されます。このバージョンではマルチタッチがサポートされている必要があります。

小画面のデバイスでは、コンテンツにできるだけ多くの画面領域を割り当てる必要があります。そのため、スペースを最大限に活用するために、主に使用頻度の低い要素を非表示にするなど、いくつかの調整を行いました。

  • ビルド中に、ビルド ブリック選択ツールが色選択ツールに最小化されます。
  • ズームと向きのコントロールをマルチタッチ操作に置き換えました。
  • Chrome の全画面表示機能は、画面領域を広くするのにも役立ちます。
大画面でビルドする
大画面でビルドします。ブリック選択ツールは常に表示され、右側にいくつかのコントロールがあります。
小さい画面で作成する
小さな画面で作成します。ブリック選択ツールが最小化され、一部のボタンが削除されました。

WebGL のパフォーマンスとサポート

最新のタッチデバイスには非常に強力な GPU が搭載されていますが、デスクトップ デバイスの GPU にはまだまだ及びません。そのため、特に多くの作成物を同時にレンダリングする必要がある「3D を探索」モードでは、パフォーマンスに課題があることはわかっていました。

クリエイティブな面では、複雑な形状や透明性のある新しい種類のブリックをいくつか追加しました。これらの機能は通常、GPU に負荷をかけます。ただし、下位互換性を維持し、第 1 バージョンの作成物を引き続きサポートする必要があったため、作成物の合計ブロック数を大幅に減らすなど、新しい制限を設定することはできませんでした。

最初のバージョンの Build では、1 つの作成で使用できるブリックの上限がありました。残りのレンガの数を示す「レンガメーター」がありました。新しい実装では、一部の新しいブリックが標準のブリックよりもブリックメーターにより影響するため、ブリックの最大合計数が若干減りました。これは、パフォーマンスを維持しながら新しいブリックを追加する 1 つの方法でした。

3D 探索モードでは、ベースプレート テクスチャの読み込み、作成物の読み込み、作成物のアニメーションとレンダリングなど、多くの処理が同時に行われます。これには GPU と CPU の両方が必要となるため、Chrome DevTools で多くのフレーム プロファイリングを行い、これらの部分を可能な限り最適化しました。モバイル デバイスでは、作成物を同時にレンダリングする数を減らすため、作成物を少しズームインすることにしました。

一部のデバイスでは、WebGL シェーダーの一部を見直して簡素化する必要がありましたが、常に解決策を見つけて先に進むことができました。

WebGL 以外のデバイスをサポートする

訪問者のデバイスが WebGL に対応していない場合でも、サイトをある程度利用できるようにしたいと考えました。キャンバス ソリューションや CSS3D 機能を使用して、3D を簡素な方法で表現できる場合もあります。残念ながら、WebGL を使用せずに 3D の作成と探索機能を再現できる十分なソリューションは見つかっていません。

一貫性を保つため、作成物のビジュアル スタイルはすべてのプラットフォームで同じにする必要があります。2.5D ソリューションを試すこともできました。しかし、その場合、作成物が一部異なる外観になる可能性があります。また、Build with Chrome の最初のバージョンで作成された作成物が、新しいバージョンのサイトで最初のバージョンと同じように表示され、スムーズに動作するようにする方法も検討する必要がありました。

新しい作品を作成したり、3D で探索したりすることはできませんが、WebGL 以外のデバイスでも引き続き 2D モードで探索できます。そのため、WebGL 対応デバイスを使用しているユーザーは、プロジェクトの深さと、このツールを使用して作成できる内容を把握できます。WebGL をサポートしていないユーザーにとって、このサイトは価値が低いかもしれませんが、少なくともテイザーとして機能し、ユーザーが試すきっかけになるはずです。

WebGL ソリューションの代替バージョンを維持できない場合もあります。パフォーマンス、ビジュアル スタイル、開発とメンテナンスの費用など、さまざまな理由が考えられます。ただし、代替手段を実装しない場合は、少なくとも WebGL を有効にしていないユーザーに対応し、サイトに完全にアクセスできない理由を説明し、WebGL をサポートするブラウザを使用して問題を解決する方法の手順を説明する必要があります。

アセット管理

2013 年に Google は、リリース以来最も大幅な UI 変更を加えた Google マップの新バージョンをリリースしました。そこで、新しい Google マップの UI に合わせて Build with Chrome を再設計することにしました。その際、他の要素も考慮に入れました。新しいデザインは比較的フラットで、クリーンな単色とシンプルな形状を採用しています。これにより、多くの UI 要素で純粋な CSS を使用できるようになり、画像の使用を最小限に抑えることができました。

探索では、作成物のサムネイル画像、ベースプレートのマップ テクスチャ、実際の 3D 作成物など、多くの画像を読み込む必要があります。新しい画像を絶えず読み込む際にメモリリークが発生しないように、Google は細心の注意を払っています。

3D 作成物は、PNG 画像としてパッケージ化されたカスタム ファイル形式で保存されます。3D 作成データを画像として保存することで、基本的には、作成物をレンダリングするシェーダーにデータを直接渡すことができました。

ユーザー作成のすべての画像で、すべてのプラットフォームで同じ画像サイズを使用できるため、ストレージと帯域幅の使用量を最小限に抑えることができます。

画面の向きの管理

縦向きから横向きに切り替えたり、その逆に切り替えたりしたときに、画面のアスペクト比がどれだけ変化するかを忘れがちです。モバイル デバイス用に適応させる場合は、最初からこの点を考慮する必要があります。

スクロールが有効になっている従来のウェブサイトでは、CSS ルールを適用して、コンテンツとメニューを再配置するレスポンシブ サイトを作成できます。スクロール機能が使用できる限り、これはかなり管理しやすいものです。

Build でもこの方法を使用していましたが、コンテンツを常に表示しつつ、さまざまなコントロールやボタンに簡単にアクセスできるようにする必要があったため、レイアウトの解決方法に若干の制限がありました。ニュース サイトのような純粋なコンテンツ サイトでは、フルード レイアウトは非常に理にかなっています。しかし、私たちのようなゲームアプリでは、苦労しました。横向きと縦向きの両方で機能し、コンテンツの概要を把握しやすく、操作しやすいレイアウトを見つけることは、大きな課題でした。最終的には、Build を横向き専用にすることにし、デバイスを回転するようユーザーに伝えることにしました。

どちらの向きでも、Explore は簡単に解答できました。一貫したエクスペリエンスを実現するために、画面の向きに応じて 3D のズームレベルを調整するだけでした。

コンテンツのレイアウトのほとんどは CSS で制御されていますが、画面の向きに関連する一部の要素は JavaScript で実装する必要がありました。window.orientation を使用して向きを特定するクロスデバイス ソリューションが適切なものがないことが判明したため、最終的には window.innerWidth と window.innerHeight を比較してデバイスの向きを特定することにしました。

if( window.innerWidth > window.innerHeight ){
  //landscape
} else {
  //portrait
}

タッチサポートの追加

ウェブ コンテンツにタップ操作のサポートを追加するのは比較的簡単です。クリック イベントなどの基本的なインタラクションは、パソコンとタッチ対応デバイスで同じように機能しますが、より高度なインタラクションの場合は、タッチイベント(touchstart、touchmove、touchend)も処理する必要があります。こちらの記事では、これらのイベントの基本的な使用方法について説明します。Internet Explorer ではタッチイベントはサポートされていませんが、代わりにポインタ イベント(pointerdown、pointermove、pointerup)が使用されます。ポインタ イベントは標準化のために W3C に提出されていますが、現時点では Internet Explorer でのみ実装されています。

3D 探索モードでは、標準の Google マップの実装と同じナビゲーションを実現したいと考えました。つまり、1 本の指で地図をパンし、2 本の指でピンチしてズームする操作です。作成物は 3D であるため、2 本指の回転ジェスチャーも追加しました。通常、これはタップイベントの使用を必要とします。

イベント ハンドラで 3D の更新やレンダリングなどの負荷の高い計算を避けることがベスト プラクティスです。代わりに、タップ入力を変数に保存し、requestAnimationFrame レンダリング ループで入力に反応します。また、マウスの実装を同時に行うことも簡単になります。対応するマウス値を同じ変数に格納するだけです。

まず、入力を保存するオブジェクトを初期化し、touchstart イベント リスナーを追加します。各イベント ハンドラで event.preventDefault() を呼び出します。これは、ブラウザがタップイベントの処理を続行しないようにするためです。タップイベントの処理が続行されると、ページ全体のスクロールやスケーリングなど、予期しない動作が発生する可能性があります。

var input = {dragStartX:0, dragStartY:0, dragX:0, dragY:0, dragDX:0, dragDY:0, dragging:false};
plateContainer.addEventListener('touchstart', onTouchStart);

function onTouchStart(event) {
  event.preventDefault();
  if( event.touches.length === 1){
    handleDragStart(event.touches[0].clientX , event.touches[0].clientY);
    //start listening to all needed touchevents to implement the dragging
    document.addEventListener('touchmove', onTouchMove);
    document.addEventListener('touchend', onTouchEnd);
    document.addEventListener('touchcancel', onTouchEnd);
  }
}

function onTouchMove(event) {
  event.preventDefault();
  if( event.touches.length === 1){
    handleDragging(event.touches[0].clientX, event.touches[0].clientY);
  }
}

function onTouchEnd(event) {
  event.preventDefault();
  if( event.touches.length === 0){
    handleDragStop();
    //remove all eventlisteners but touchstart to minimize number of eventlisteners
    document.removeEventListener('touchmove', onTouchMove);
    document.removeEventListener('touchend', onTouchEnd);
    //also listen to touchcancel event to avoid unexpected behavior when switching tabs and some other situations
    document.removeEventListener('touchcancel', onTouchEnd);
  }
}

入力の実際の保存は、イベント ハンドラではなく、別個のハンドラ(handleDragStart、handleDragging、handleDragStop)で行います。これは、マウス イベント ハンドラからも呼び出せるようにするためです。ユーザーがタップとマウスを同時に使用することはあまりありませんが、その可能性も考慮してください。そのようなケースを直接処理するのではなく、問題が起きないようにします。

function handleDragStart(x ,y ){
  input.dragging = true;
  input.dragStartX = input.dragX = x;
  input.dragStartY = input.dragY = y;
}

function handleDragging(x ,y ){
  if(input.dragging) {
    input.dragDX = x - input.dragX;
    input.dragDY = y - input.dragY;
    input.dragX = x;
    input.dragY = y;
  }
}

function handleDragStop(){
  if(input.dragging) {
    input.dragging = false;
    input.dragDX = 0;
    input.dragDY = 0;
  }
}

touchmove に基づいてアニメーションを行う場合は、最後のイベントからの移動量も保存することがよくあります。たとえば、ユーザーはベースプレートをドラッグするのではなく、実際にカメラを動かすため、Explore ですべてのベースプレートを移動する際のカメラの速度のパラメータとして使用しています。

function onAnimationFrame() {
  requestAnimationFrame( onAnimationFrame );

  //execute animation based on input.dragDX, input.dragDY, input.dragX or input.dragY
 /*
  /
  */

  //because touchmove is only fired when finger is actually moving we need to reset the delta values each frame
  input.dragDX=0;
  input.dragDY=0;
}

埋め込みの例: タップイベントを使用してオブジェクトをドラッグします。Build with Chrome で 3D 地図をドラッグする場合と同様の実装: http://cdpn.io/qDxvo

マルチタッチ ジェスチャー

HammerQuoJS などのフレームワークやライブラリを使用すると、マルチタッチ ジェスチャーの管理を簡素化できますが、複数のジェスチャーを組み合わせて完全に制御する場合は、最初から作成することをおすすめします。

ピンチ操作と回転操作を管理するために、2 本目の指が画面に置かれたときの 2 本の指の距離と角度を保存します。

//variables representing the actual scale/rotation of the object we are affecting
var currentScale = 1;
var currentRotation = 0;

function onTouchStart(event) {
  event.preventDefault();
  if( event.touches.length === 1){
    handleDragStart(event.touches[0].clientX , event.touches[0].clientY);
  }else if( event.touches.length === 2 ){
    handleGestureStart(event.touches[0].clientX, event.touches[0].clientY, event.touches[1].clientX, event.touches[1].clientY );
  }
}

function handleGestureStart(x1, y1, x2, y2){
  input.isGesture = true;
  //calculate distance and angle between fingers
  var dx = x2 - x1;
  var dy = y2 - y1;
  input.touchStartDistance=Math.sqrt(dx*dx+dy*dy);
  input.touchStartAngle=Math.atan2(dy,dx);
  //we also store the current scale and rotation of the actual object we are affecting. This is needed to support incremental rotation/scaling. We can't assume that an object is always the same scale when gesture starts.
  input.startScale=currentScale;
  input.startAngle=currentRotation;
}

タップ移動イベントでは、2 本の指の間の距離と角度を継続的に測定します。開始距離と現在の距離の差を使用してスケールを設定し、開始角度と現在の角度の差を使用して角度を設定します。

function onTouchMove(event) {
  event.preventDefault();
  if( event.touches.length  === 1){
    handleDragging(event.touches[0].clientX, event.touches[0].clientY);
  }else if( event.touches.length === 2 ){
    handleGesture(event.touches[0].clientX, event.touches[0].clientY, event.touches[1].clientX, event.touches[1].clientY );
  }
}

function handleGesture(x1, y1, x2, y2){
  if(input.isGesture){
    //calculate distance and angle between fingers
    var dx = x2 - x1;
    var dy = y2 - y1;
    var touchDistance = Math.sqrt(dx*dx+dy*dy);
    var touchAngle = Math.atan2(dy,dx);
    //calculate the difference between current touch values and the start values
    var scalePixelChange = touchDistance - input.touchStartDistance;
    var angleChange = touchAngle - input.touchStartAngle;
    //calculate how much this should affect the actual object
    currentScale = input.startScale + scalePixelChange*0.01;
    currentRotation = input.startAngle+(angleChange*180/Math.PI);
    //upper and lower limit of scaling
    if(currentScale<0.5) currentScale = 0.5;
    if(currentScale>3) currentScale = 3;
  }
}

ドラッグの例と同様に、各 touchmove イベント間の距離の変化を使用することもできますが、このアプローチは、連続した移動が必要な場合に便利です。

function onAnimationFrame() {
  requestAnimationFrame( onAnimationFrame );
  //execute transform based on currentScale and currentRotation
  /*
  /
  */

  //because touchmove is only fired when finger is actually moving we need to reset the delta values each frame
  input.dragDX=0;
  input.dragDY=0;
}

必要に応じて、ピンチ操作と回転操作を行っている間、オブジェクトのドラッグを有効にすることもできます。その場合は、2 本の指の中心点をドラッグ ハンドラへの入力として使用します。

埋め込みの例: 2D でオブジェクトを回転およびスケーリングします。地図で探す機能で実装されている方法と同様です。http://cdpn.io/izloq

同じハードウェアでマウスとタッチをサポートする

現在、Chromebook Pixel など、マウス入力とタッチ入力の両方をサポートするノートパソコンがいくつかあります。注意しないと、予期しない動作が発生する可能性があります。

重要な点として、タップ操作のサポートを検出してマウス入力を無視するのではなく、両方を同時にサポートする必要があります。

タッチイベント ハンドラで event.preventDefault() を使用していない場合でも、タッチ以外の最適化済みサイトのほとんどが引き続き機能するように、エミュレートされたマウスイベントも発生します。たとえば、画面を 1 回タップすると、これらのイベントが次の順序で迅速に発生することがあります。

  1. touchstart
  2. touchmove
  3. touchend
  4. マウスオーバー
  5. mousemove
  6. mousedown
  7. mouseup
  8. クリック

インタラクションの複雑さが増すと、これらのマウスイベントによって予期しない動作が発生し、実装が混乱する可能性があります。多くの場合、タッチイベント ハンドラで event.preventDefault() を使用し、別のイベント ハンドラでマウス入力を管理することをおすすめします。タッチイベント ハンドラで event.preventDefault() を使用すると、スクロールやクリック イベントなどのデフォルトの動作も妨げられることに注意してください。

「Build with Chrome では、ほとんどのブラウザでは標準であるにもかかわらず、サイトをダブルタップしたときにズームが発生しないようにしました。そのため、ビューポート メタタグを使用して、ユーザーがダブルタップしたときにズームしないようにブラウザに指示します。また、クリック時の 300 ミリ秒の遅延も解消され、サイトの応答性が向上します。(クリックの遅延は、ダブルタップによるズームが有効になっている場合に、シングルタップとダブルタップを区別するために設定されています)。

<meta name="viewport" content="width=device-width,user-scalable=no">

この機能を使用する場合は、ユーザーがズームインできないため、すべての画面サイズでサイトを読みやすくする必要があります。

マウス、タップ、キーボードによる入力

3D 探索モードでは、マウス(ドラッグ)、タップ(ドラッグ、ピンチしてズーム、回転)、キーボード(矢印キーで移動)の 3 つの方法で地図を操作できるようにしました。これらのナビゲーション方法はすべて動作が若干異なりますが、イベント ハンドラで変数を設定し、requestAnimationFrame ループでその変数に基づいて動作するという、同じアプローチをすべてに使用しました。requestAnimationFrame ループは、ナビゲーションに使用されているメソッドを認識する必要はありません。

たとえば、3 つの入力方法すべてで地図の移動(dragDX と dragDY)を設定できます。キーボードの実装は次のとおりです。

document.addEventListener('keydown', onKeyDown );
document.addEventListener('keyup', onKeyUp );

function onKeyDown( event ) {
  input.keyCodes[ "k" + event.keyCode ] = true;
  input.shiftKey = event.shiftKey;
}

function onKeyUp( event ) {
  input.keyCodes[ "k" + event.keyCode ] = false;
  input.shiftKey = event.shiftKey;
}

//this needs to be called every frame before animation is executed
function handleKeyInput(){
  if(input.keyCodes.k37){
    input.dragDX = -5; //37 arrow left
  } else if(input.keyCodes.k39){
    input.dragDX = 5; //39 arrow right
  }
  if(input.keyCodes.k38){
    input.dragDY = -5; //38 arrow up
  } else if(input.keyCodes.k40){
    input.dragDY = 5; //40 arrow down
  }
}

function onAnimationFrame() {
  requestAnimationFrame( onAnimationFrame );
  //because keydown events are not fired every frame we need to process the keyboard state first
  handleKeyInput();
  //implement animations based on what is stored in input
   /*
  /
  */

  //because touchmove is only fired when finger is actually moving we need to reset the delta values each frame
  input.dragDX = 0;
  input.dragDY = 0;
}

埋め込みの例:マウス、タップ、キーボードを使用して移動する: http://cdpn.io/catlf

概要

さまざまな画面サイズのタッチデバイスをサポートするように Build with Chrome を調整することは、学びの機会となりました。タッチデバイスでこのレベルのインタラクティビティを実現した経験があまりなかったため、開発チームは多くのことを学びました。

最大の課題は、ユーザー エクスペリエンスとデザインをどう解決するかでした。技術的な課題は、さまざまな画面サイズ、タッチイベント、パフォーマンスの問題を管理することでした。

タップデバイスでの WebGL シェーダーにはいくつかの課題がありましたが、想定していたよりもうまく機能しました。デバイスはますます高性能になり、WebGL の実装は急速に進歩しています。今後、デバイスで WebGL がより多く使用されるようになると考えています。

まだ作成していない場合は、素晴らしいものを作成しましょう。