20200807のAndroidに関する記事は5件です。

【Android】In-App Review APIを使ってレビュー機能をサクッと作ろう

In-App Review APIとは

iar-flow.jpg

The Google Play In-App Review API lets you prompt users to submit Play Store ratings and reviews without the inconvenience of leaving your app or game.

https://developer.android.com/guide/playcore/in-app-review

アプリやゲームから離れずストア評価・レビューをすることができるAPIで、

During the flow, the user has the ability to rate your app using the 1 to 5 star system and to add an optional comment. Once submitted, the review is sent to the Play Store and eventually displayed.

ユーザーには星1~5段階評価 + コメントを送信してもらうことが可能となっています。
今まで独自にUIを作成しストア内アプリページへ誘導するなど行っていた方も多いと思いますが、置き換えることができそうです。

※Android 5.0 (API level 21)以上が対応デバイスとなっています。

導入方法

gradle

利用するmodule(app等)のbuild.gradleにGoogle play core library(v1.8.0以上)を追加します。

build.gradle
dependencies {
    implementation 'com.google.android.play:core:1.8.0'
or
    implementation 'com.google.android.play:core-ktx:1.8.0'
    ...
}

利用方法

SampleActivity.kt
import com.google.android.play.core.review.ReviewManagerFactory

button.setOnClickListener {
  val manager = ReviewManagerFactory.create(requireContext())
  val request = manager.requestReviewFlow()
  request.addOnCompleteListener { task: Task<ReviewInfo?> ->
      when {
          task.isSuccessful -> {
            val reviewInfo = task.result
            val flow = manager.launchReviewFlow(requireActivity(), reviewInfo)
            flow.addOnCompleteListener { task1: Task<Void?>? ->
              // The flow has finished.  
            }
          }
          else -> {
            // error or something
          }
       }
   }
}

ReviewInfo Objectは利用時間が限られているので注意が必要です。
https://developer.android.com/guide/playcore/in-app-review/kotlin-java#kotlin

テスト方法

1. Google Play Storeの内部テストor内部アプリ共有にて確認

UI込みで確認する場合はこちら一択になります。

2. FakeReviewManager

flowのコールバック確認などを行いたい場合はこちらが有効です。

SampleActivity.kt
import com.google.android.play.core.review.ReviewManagerFactory

button.setOnClickListener {
  // ここが変わる
  val manager = FakeReviewManager(context)
  val request = manager.requestReviewFlow()
  request.addOnCompleteListener { task: Task<ReviewInfo?> ->
      when {
          task.isSuccessful -> {
            val reviewInfo = task.result
            val flow = manager.launchReviewFlow(requireActivity(), reviewInfo)
            flow.addOnCompleteListener { task1: Task<Void?>? ->
              // The flow has finished.  
            }
          }
          else -> {
            // error or something
          }
       }
   }
}

試しに1であげてた際のスクショが↓↓↓

レビュー前
iar-1.png

レビュー後
iar-2.png

最後に

内部テストからのレビュー時には非公開レビューとしてストアに表示されるので安心してテストを行うこともできそうです。(仕様として特に書いてない為 あくまで2020/8/7時点での挙動としてご留意ください)

また、ドキュメントには、レビューをリクエストするタイミングデザインガイドラインの記載もあるので実際に利用する際には一読しておくことをオススメします。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ボタン連打防止用コード

自分用メモ
・ボタン連打防止用コード
※参考にする場合は、ご自身で検証してください。(検証不十分なため)

qiita.java
public class TestActivity {

  // !!!自分用メモでテスト不十分なことをご承知ください!!!

  // バックキー連打防止用フラグ
  private boolean backKeyEnabled = true;

  @Override
  public boolean dispatchKeyEvent(KeyEvent event) {
    // 連打しようとしたらボタン無効
    if(!backKeyEnabled) {
      return false;
    }
    // ボタンを押したら
    backKeyEnabled = false;
    // ボタンをしばらく押せないようにする
    new Handler().postDelayed(new Runnable() {
      @Override
      public void run() {
        backKeyEnabled = true; // 1sec後に押せるようにする
      }
    }, 1000);

    // 以後処理 
  }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

BottomSheet の上に Snackbar を表示する

あまり機会はないかもしれませんが、BottomSheet の上に何かしらのメッセージを Snackbar で表示する方法を調べました。
設定する値を変えれば Spinner などの上でも表示可能かもしれません。

やり方

方法は色々あるようですが、getView#setZ を利用して高さ(という呼び方でいいのか悩ましいところですが)を変えてあげるのが 1番簡単そうでした。

sample
Snackbar.make(
  view,
  message,
  Snackbar.LENGTH_LONG
).apply { view.z = 200f }.show()

余談

要件次第ではあると思いますが、本来あるべき順序を変えることになるので、よく検討して利用しましょう。
例えば今回のように結果を表示だけであれば、Toast の方が望ましいように思います。
色んな方法がありますね!

参考

以下を参考にさせていただきました<(_ _)>

https://stackoverflow.com/questions/44293412/place-snackbar-at-highest-z-order-to-avoid-from-being-blocked-by-autocompletetex#answer-46718789

同じようなことで困った方の参考になれば幸いです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Android】WorkManager+MVVM+Hilt

WorkManagerとは?

Android Jetpackのライブラリの一つ。
ForegroundServiceやJobSchedulerの代わりになる。
https://developer.android.com/topic/libraries/architecture/workmanager?hl=ja

やりたいこと

  1. アプリから切り離して長時間の処理を行わせたい
  2. ロジック部分はWorkManager外に定義したい
  3. ついでに通知に結果を流し込みたい

1. アプリから切り離して長時間の処理を行わせたい

WorkManager基礎部分

MyWorker.kt
class MyWorker constructor(
    private val context: Context,
    private val params: WorkerParameters
) : CoroutineWorker(context, params) {
  override suspend fun doWork(): Result {
    // 処理
    return Result.success()
  }
}
MyViewModel.kt
class MyViewModel : ViewModel() {
  fun run(){
    WorkManager.getInstance(context)
            .enqueue(OneTimeWorkRequest.from(MyWorker::class.java))
  }
}

WorkerManagerをViewModel等から呼び出し、一度きり指定でMyWorkerを実行させます。
doWorkの中の処理がバックグラウンドで実行され、終わるとResult.success()が返却されます。

ForeGroundService化

MyWorker.kt
  override suspend fun doWork(): Result {
    setForeground(ForegroundInfo(notificationId, notificationBuilder.build()))
    // 処理
  }
}

普通にNotificationBuilderを生成したら(割愛)、ForegroundInfo()に突っ込んでsetForegroundを呼び出します。
ForegroundServiceと違ってWorkManager側が実行をForeground化するかどうか決定出来るので、見る場所がスマートになっていい感じ。

2. ロジック部分はWorkManager外に定義したい

MVVMパターンとHiltによるDIと組み合わせて、Repositoryに書いた処理をworkManagerから実行させたい。

だめなパターン.kt
@AndroidEntryPoint// つけられない
class MyWorker constructor(
  private val context: Context,
  private val params: WorkerParameters
) : CoroutineWorker(context, params) {
  @Inject
  lateinit var myRepository: MyRepository
  override suspend fun doWork(): Result {
    myRepository.doSomething()// not initialized
    return Result.success()
  }
}

WorkManagerはServiceではないので@AndroidEntryPointが設定出来ません。
そのため、Injectが作用せずnot initializedと怒られてしまいます。
ではどうするか。

MyApplication.kt
@HiltAndroidApp
class MyApplication @Inject constructor() : Application() {
  lateinit var myRepository: MyRepository
  override fun create(){
    WorkManager.initialize(
      this,
      Configuration.Builder()
        .setWorkerFactory(MyWorkerFactory(myRepository)).build()
    )
  }
}
MyWorkerFactory.kt
class ThetaWorkerFactory @Inject constructor(
  private val myRepository: MyRepository
) : WorkerFactory() {
  override fun createWorker(
      appContext: Context,
      workerClassName: String,
      workerParameters: WorkerParameters
  ): ListenableWorker? {
      return MyWorker(myRepository, appContext, workerParameters)
  }
}
MyWorker.kt
class MyWorker constructor(
  private val myRepository: MyRepository,
  private val context: Context,
  private val params: WorkerParameters
) : CoroutineWorker(context, params) {
  override suspend fun doWork(): Result {
    myRepository.doSomething()
    return Result.success()
  }
}
AndroidManifest.xml
<application>
  <provider
    android:name="androidx.work.impl.WorkManagerInitializer"
    android:authorities="${applicationId}.workmanager-init"
    android:exported="false"
    tools:node="remove" />

ApplicationクラスでWorkerFactoryを上書きし、Workerに引数でrepositoryを渡すことで達成出来ました。
Workerは初期化が一度しか出来ないので、Manifestに初期化するなよとおまじないしなきゃいけないっていうやつは使うたび忘れそう気が利かない。

3. ついでに通知に結果を流し込みたい

長くなったので別記事に分けようと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

THETA1台でライブImageBasedLighting

はじめに

リコーのYuuki_Sです。
弊社ではRICOH THETAという全周囲360度撮れるカメラを出しています。
RICOH THETA VやTHETA Z1は、OSにAndroidを採用しており、Androidアプリを作る感覚でTHETAをカスタマイズすることもでき、そのカスタマイズ機能を「プラグイン」と呼んでいます。(詳細は本記事の末尾を参照)。


イメージベースドライティング(IBL : Image-based lighting)という技術をご存知でしょうか?
現実世界の全方向の光情報を環境情報として、CGのレンダリングに利用する手法のことです。
周辺の光源の位置や色合いをCGに反映することで、現実に溶け込んだ表現が可能になるため映像作品やゲームで使用されています。
IBL比較.jpg
↑IBL有無の比較(Blenderにて。背景画像はHDRI Havenより)

この手法には、前述の通り全方位の光情報、すなわち全天球画像を用います。
そして、全天球画像を取得できる手軽なデバイスとしてTHETAが存在します。
つまりIBLは、THETAととても相性が良い技術です。
実際、初代THETA発売時にIBLを試して頂いてるブログ記事IBL用の全天球画像作成フローを紹介したCG専門雑誌の記事も存在します。

こんなTHETAと相性のいいIBLですが、今回はこれをTHETA1台でプレビューできる環境を作ってみました。
THETAとスマホがあれば、以下の様にどこでもIBLしたCGを見ることができます。

仕組み紹介

THETA1台とスマホでIBLをプレビューするためには、THETAの画像を利用してCGのレンダリングをおこなう必要があります。
一つの方法としては、THETAのAPIを利用して画像取得しレンダリングするスマホアプリを作る方法があります。しかし、冒頭で紹介した通り、THETA自体がAndroidを搭載しているため、THETA内で動くプラグインを作ることでよりシンプルに実現できます。
THETAプラグイン_記事4_構成.png
上図の通り、THETAプラグインで画像取得をおこないつつWEBサーバーも立ち上げます。このサーバー上にWebGLを用いたレンダリング環境を配置し、スマホ側からアクセスすることでブラウザ上にIBLしたCGを表示します。もちろん、パソコンからでも見ることができます。

※なお、今回はJPEG画像を利用しますが、IBLを厳密におこなう際にはHDR画像を用いる事が一般的です。ここで言うHDRとはHDR撮影したJPEG画像ではなく画像の階調を明暗含め広く保った画像を指します。通常のJPEG等の形式が1色8bitに対して、こういったHDR画像は1色16bitで情報が保存されており、より表現力の高いCG表現が可能です。

ライブプレビューを利用したIBL

それでは、実際にライブプレビューを環境情報として利用するプラグインを書いていきます。
プラグイン開発のベースとなるプロジェクトファイル一式は、@KA-2さんの以下記事で解説したものを使用します。
THETAプラグインでライブプリビューを扱いやすくする

まずは、上記のプロジェクトにWebGLを扱うために必要な諸々を追加していきます。
最初に、WebGLを簡単に扱える便利なライブラリであるthree.jsをhttps://threejs.org/のダウンロードより入手します。
圧縮ファイルを解凍したフォルダより、"build"内のthree.min.jsを"assets"の"js"フォルダにコピーします。
次に、"assets"フォルダ内に"three"というフォルダを新規作成し、解凍したフォルダ内のsrcフォルダ、buildフォルダをコピーします。
更に、"three"フォルダ内に"examples"という名前の新規フォルダを作成し、その中に解凍したフォルダの"examples"内の"jsm"フォルダをコピーします。
最後に、”assets"フォルダ内に"models"という名前のフォルダを新規作成し、解凍したフォルダの"examples\models\gltf"にある”DamagedHelmet”フォルダをコピーします。ここまで終えると、"assets"フォルダ内は以下のような構成になります。
image.png

これで下準備は終わったので、処理を書いていきます。WebGLを扱うためには、Androidアプリ内にサーバーを立てる必要がありますが、実はtheta-plugin-extendedpreviewには既にその機能が備わっています。なので、既に存在するindex.htmlを編集するだけでWebGLによる表示が可能になります。

編集箇所ですが、要所を記載します。(コード全文は本章最後に折りたたんで記載します。)

まず、冒頭部分にタイトルと読み込むJavaScriptを記載します。

index.html
 <head>
    <title>WebGL Image Based Lighting</title>
    <meta charset="utf-8">
    <script src="js/preview.js"></script> 
    <script src="js/three.min.js"></script>
  </head>

次にbody内でWebGLを表示する画面要素を追加します。※three.jsのサンプルコードを参考にしました。

index.html
<body onLoad="startLivePreview();updatePreviwFrame();">
   <div id="container"> </div> 
   ~
</body>

そして諸々のインポートをおこないます。

index.html
    <script type="module">
      import * as THREE from './three/build/three.module.js';
      import { OrbitControls } from './three/examples/jsm/controls/OrbitControls.js';
      import { GLTFLoader } from './three/examples/jsm/loaders/GLTFLoader.js';
      import { RGBELoader } from './three/examples/jsm/loaders/RGBELoader.js';
      import { RoughnessMipmapper } from './three/examples/jsm/utils/RoughnessMipmapper.js';

次に初期化する関数内でプレビュー画像をシーンの背景に設定します。
既にプレビュー画像はlvimgというimg要素に入るようになってますのでそれを参照します。

index.html
    scene = new THREE.Scene();
        image = document.getElementById('lvimg');
        texture = textureLoader.load(image.src);
        texture.mapping = THREE.UVMapping;
        var roughnessMipmapper = new RoughnessMipmapper( renderer );
        scene.background = new THREE.WebGLCubeRenderTarget( 1920, options ).fromEquirectangularTexture( renderer, texture );
        scene.environment = new THREE.WebGLCubeRenderTarget( 1920, options ).fromEquirectangularTexture( renderer, texture );

そしてシーンにglTFファイルを読み込みます。

index.html
        var loader = new GLTFLoader().setPath('models/DamagedHelmet/glTF/');
        loader.load( 'DamagedHelmet.gltf', function ( gltf ) {
          gltf.scene.traverse( function ( child ) {} );
          scene.add( gltf.scene );
          roughnessMipmapper.dispose();
          render();
        } );

最後に毎フレームで背景を更新するようにします。

index.html
 function render() 
        {      
          textureLoader.load(image.src, function ( texture ) {
          scene.background.fromEquirectangularTexture(renderer, texture);
          scene.environment.fromEquirectangularTexture(renderer, texture);
          });          
          renderer.render( scene, camera );
        }

これでindex.htmlの編集は終了です。
ですが、このままだとWebサーバー側からJavaScriptファイルを渡す際にmimeTypeが設定されないので、そこを修正します。
WebServer.javaの以下の関数に拡張子”.js”ならmimeTypeを”text/javascript”と設定するようにします。

WebServer.java
     private Response serveAssetFiles(String uri)
     {
        if (uri.equals("/")){
            uri = "/index.html";
        }
        ContentResolver contentResolver = context.getContentResolver();
        String mimeType = contentResolver.getType(Uri.parse(uri));

        if(mimeType == null)//追加
        {
            String tmp = uri.substring(uri.lastIndexOf("."));
            if(tmp.equals(".js"))
            {
                mimeType = "text/javascript";
            }
        }
        ~
     }

ここまで済ませたら、アプリをTHETAにビルド&インストールし、スマホかパソコンからTHETAのWifiに接続、http://127.0.0.1:8888にアクセスします。
すると、以下の通り、背景にライブプレビューが設定されたCGが表示されます。
GIF001.gif

ドラッグしてカメラを動かすと写り込んでいる映像も変わります。
これにて完成!、と言いたいところですが背景がなんだかボケて見えます。

これは純粋にライブプレビューの解像度が低いためです。このプレビューでは1024x512を用いています。これより解像度の高い設定として1920x960も選択できますが、その場合フレームレートが8fpsになってしまいます。また、そもそもIBLの背景にするには1920x960でも心もとないです。
(一例としてIBL用のHDRI画像をフリーで公開しているHDRI Havenでは最大16K(16384x8192)で提供されています。)

なので、ライブプレビューは確認用として、高解像度な撮影画像も背景に設定できるように機能追加しましょう。
その前に、ここまでのコードを全文を以下に置いておきます。

ライブプレビュー表示のindex.html全文
index.html
<html>
  <head>
    <title>WebGL Image Based Lighting</title>
    <meta charset="utf-8">
    <script src="js/preview.js"></script> 
    <script src="js/three.min.js"></script>
  </head>
  <body onLoad="startLivePreview();updatePreviwFrame();">
    <div id="container"> </div>
    <img hidden id="lvimg" src="" width="640" height="320">
    <br>
    <br>
    <br>
    <input name="preview" type="radio" value="off" onclick="stopLivePreview();"> OFF
    <input name="preview" type="radio" value="on"  onclick="startLivePreview();"> ON
    <br>
    <br>
    <input name="ev" type="radio" value=-2.0 onclick="setEv(value);"> -2.0
    <input name="ev" type="radio" value=-1.7 onclick="setEv(value);"> -1.7
    <input name="ev" type="radio" value=-1.3 onclick="setEv(value);"> -1.3
    <input name="ev" type="radio" value=-1.0 onclick="setEv(value);"> -1.0
    <input name="ev" type="radio" value=-0.7 onclick="setEv(value);"> -0.7
    <input name="ev" type="radio" value=-0.3 onclick="setEv(value);"> -0.3
    <input name="ev" type="radio" value=0.0 onclick="setEv(value);"> 0.0
    <input name="ev" type="radio" value=0.3 onclick="setEv(value);"> 0.3
    <input name="ev" type="radio" value=0.7 onclick="setEv(value);"> 0.7
    <input name="ev" type="radio" value=1.0 onclick="setEv(value);"> 1.0
    <input name="ev" type="radio" value=1.3 onclick="setEv(value);"> 1.3
    <input name="ev" type="radio" value=1.7 onclick="setEv(value);"> 1.7
    <input name="ev" type="radio" value=2.0 onclick="setEv(value);"> 2.0
    <br>
    <br>
    <input type="button" value="Take Picture" onclick="takePicture()">
    <script type="module">
      import * as THREE from './three/build/three.module.js';
      import { OrbitControls } from './three/examples/jsm/controls/OrbitControls.js';
      import { GLTFLoader } from './three/examples/jsm/loaders/GLTFLoader.js';
        import { RGBELoader } from './three/examples/jsm/loaders/RGBELoader.js';
      import { RoughnessMipmapper } from './three/examples/jsm/utils/RoughnessMipmapper.js';

      var container, controls;
            var camera, scene, renderer;
      var texture,image;
      var textureLoader = new THREE.TextureLoader();
      var options = {
            generateMipmaps: true,
            minFilter: THREE.LinearMipmapLinearFilter,
            magFilter: THREE.LinearFilter
            };

      init();

      function init() {
        container = document.getElementById('container')

        renderer = new THREE.WebGLRenderer( { antialias: true } );
        renderer.setPixelRatio( window.devicePixelRatio );
        renderer.setSize( window.innerWidth, window.innerHeight );
        renderer.toneMapping = THREE.ACESFilmicToneMapping;
        renderer.toneMappingExposure = 1;
        renderer.outputEncoding = THREE.sRGBEncoding;
        container.appendChild( renderer.domElement );

        camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.25, 20 );
        camera.position.set( - 1.8, 0.6, 0.7 );

        scene = new THREE.Scene();
        image = document.getElementById('lvimg');
        texture = textureLoader.load(image.src);
        texture.mapping = THREE.UVMapping;
        var roughnessMipmapper = new RoughnessMipmapper( renderer );
        scene.background = new THREE.WebGLCubeRenderTarget( 1920, options ).fromEquirectangularTexture( renderer, texture );
        scene.environment = new THREE.WebGLCubeRenderTarget( 1920, options ).fromEquirectangularTexture( renderer, texture );

        let mixer;
        let clock = new THREE.Clock();
        var loader = new GLTFLoader().setPath('models/DamagedHelmet/glTF/');
        loader.load( 'DamagedHelmet.gltf', function ( gltf ) {
          gltf.scene.traverse( function ( child ) {
          } );

          scene.add( gltf.scene );
          roughnessMipmapper.dispose();
          render();
        } );


        var pmremGenerator = new THREE.PMREMGenerator( renderer );
        pmremGenerator.compileEquirectangularShader();

        controls = new OrbitControls( camera, renderer.domElement );
        controls.addEventListener( 'change', render ); // use if there is no animation loop
        controls.minDistance = 0.5;
        controls.maxDistance = 2;
        controls.target.set( 0, 0, - 0.2 );
        controls.update();
        window.addEventListener( 'resize', onWindowResize, false );
        animate();
        }

        function animate() 
        {
          requestAnimationFrame( animate );        
          render();
        }

        function onWindowResize() 
        {
          camera.aspect = window.innerWidth / window.innerHeight;
          camera.updateProjectionMatrix();
          renderer.setSize( window.innerWidth, window.innerHeight );
          render();
              }

        function render() 
        {      
          textureLoader.load(image.src, function ( texture ) {
          scene.background.fromEquirectangularTexture(renderer, texture);
          scene.environment.fromEquirectangularTexture(renderer, texture);
          });          
          renderer.render( scene, camera );
        }

    </script>
  </body>
</html>

撮影画像によるIBL表示

画面UI上の撮影ボタンを押したら撮影され、その後、設定ボタンを押すことで背景に反映される流れで実装します。
撮影機能は既にあるpreview.jsのtakePicture関数を利用しますが、撮影後、画像のURLを知りたいため、撮影完了を監視するwatchTpComplete関数を修正します。レスポンスのJSONから撮影完了の"done"を見つけ、それに続くURLを取得します。

preview.js
 xmlHttpRequest.onreadystatechange = function() {
    if (this.readyState === READYSTATE_COMPLETED &&
      this.status === HTTP_STATUS_OK) {
      var responseJson = JSON.parse(this.responseText);
      if (responseJson.state=="done") {
        var targetText = document.getElementById('img_url');
        var TargetImage =  responseJson.results.fileUrl;
        getTpImage(TargetImage);
        targetText.textContent = responseJson.results.fileUrl;
        startLivePreview();
      } else {
         setTimeout("watchTpComplete()",5);       
      }
    } else {
      console.log('setOptions failed');
    }
  };

取得したURLは、html側の要素に保持しておきます。このために、index.htmlに以下を追加します。

index.html
<div hidden id="img_url"></div>

次に取得したURLをサーバーに渡し、画像を要求する関数を追加します。

preview.js
var IMAGE_GET = 'TpImage/';

function getTpImage(fname) {
  var command = {
    fname,
  };
  var xmlHttpRequest = new XMLHttpRequest();
  xmlHttpRequest.open("GET", IMAGE_GET + fname, true);
  xmlHttpRequest.responseType = 'blob';
  xmlHttpRequest.send(null);
  xmlHttpRequest.onload = function() {
    var data = new Uint8Array(this.response);
    var img = document.getElementById('TpImage');
    img.src = URL.createObjectURL(this.response);
  };
}

また、この要求に従い画像を返す仕組みをサーバー側に追加します。
追加箇所はserve関数内とTpImageSend関数追加の2点です。TpImageSend関数は、先程の画像URLを受け取り撮影画像を返す部分になります。

WebServer.java
  public Response serve(IHTTPSession session) {
   ~
        if( uri.startsWith("/osc/") ) {
            //Divert THETA webAPI(OSC) commands.
            return serveOsc(method, uri, postData);
            } ~
            else if(uri.startsWith("/TpImage"))//追加部分
            {
              return TpImageSend(uri);
            }
            else 
            ~
    }

     public static final String DCIM = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).getPath();

     private Response TpImageSend(String uri) {
        Matcher matcher = Pattern.compile("/\\d{3}RICOH.*").matcher(uri);
        String fileUrl = "";
        if (matcher.find()) {
            fileUrl = DCIM + matcher.group();
            Log.d(TAG,matcher.group());
        }
        File file = new File(fileUrl);
        FileInputStream fis = null;
        try {
            fis =  new FileInputStream(file);
        } catch (IOException e) {
            e.printStackTrace();
        }
        String mimeType = "image/jpeg";
        return newChunkedResponse(Response.Status.OK, mimeType, fis);
    }

また、この画像読み込みのためにAndroidManifest.xmlにアクセス権限を追加します

AndroidManifest.xml
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>

これで、撮影画像を利用する準備も出来ました。あとは、これをライブプレビューと切り替えて利用できるUIを追加してやります。
このUIですが今回せっかくWebGLを利用しているので、その上で動作するUIライブラリdat.guiを利用します。
GitHubよりdat.gui.jsを持ってきて、他のJavaScriptと同じフォルダに入れて読み込みます。
使用方法は、以下の通りindex.html内でUIのボタン要素ごとに関数を割り当てるだけです。

index.html
        var GuiElement = function () {
          this.Take_Picture = function () { takePicture();}
          this.Set_Picture = function (){setTpImage();}
          this.Set_Live_Image = function (){setLvImage();}              
        };

        var elements = new GuiElement();
        var gui = new dat.GUI();
        gui.add(elements, 'Take_Picture');
        gui.add(elements, 'Set_Picture');
        gui.add(elements, 'Set_Live_Image');

そして、それぞれの関数を定義すれば完成です。実装としては、ライブプレビューか撮影画像かの選択状態を"state"という要素に持たせ、それをrender時に確認しています。また、撮影画像の場合、1度読み込めば毎フレーム読み込む必要はないので、その確認もおこなっています。

index.html
      function render() 
      {
        var status = document.getElementById('state');
        if(status.textContent== "1" && TpImageLoaded == false)
        {
          var Tpimg = document.getElementById('TpImage');
          textureLoader.load(Tpimg.src, function ( texture ) {
           scene.background.fromEquirectangularTexture(renderer, texture);
           scene.environment.fromEquirectangularTexture(renderer, texture);
          });
          TpImageLoaded = true;
        }
        else if (status.textContent == "0")
        {
          textureLoader.load(image.src, function ( texture ) {
          scene.background.fromEquirectangularTexture(renderer, texture);
          scene.environment.fromEquirectangularTexture(renderer, texture);
        });
        }
      renderer.render( scene, camera );
      }

      function setTpImage() {
        var status = document.getElementById('state');
        status.textContent = "1";
        TpImageLoaded = false;
      }
      function setLvImage() {
        var status = document.getElementById('state');
        status.textContent = "0";
      }

これで実装は完了です。試してみましょう。(初回起動時は、Vysorによる権限付与が必要になります)
GIF002.gif
一目瞭然で綺麗になりました。(角度が変わるのは天頂補正によるものです)

なお、今回使用しているglTFというファイル形式はアニメーションも対応しています。
glTFは3DにおけるJPEGを狙う次世代フォーマットなので、Blenderを始めとした多くのアプリケーションで作成が可能です。
検索するとフリーのモデルも多くありますので、ぜひ好みのCGでIBLしてみて下さい。
GIF003.gif

最後に、全文を掲載しておきます。

撮影画像表示対応のindex.html全文
index.html
<html>
  <head>
    <title>WebGL Image Based Lighting</title>
    <meta charset="utf-8">
    <script src="js/preview.js"></script> 
    <script src="js/three.min.js"></script>
    <script src="js/dat.gui.js"></script>
  </head>
  <body onLoad="startLivePreview();updatePreviwFrame();">
    <div id="container"> </div>
    <img hidden id="lvimg" src="" width="640" height="320">
    <img hidden id="TpImage" src="" width="1024" height="512">   
    <br>
    <br>
    LivePreview
    <br>
    <input name="preview" type="radio" value="off" onclick="stopLivePreview();"> OFF
    <input name="preview" type="radio" value="on"  onclick="startLivePreview();"> ON
    <br>
    <br>
    setEv
    <br>
    <input name="ev" type="radio" value=-2.0 onclick="setEv(value);"> -2.0
    <input name="ev" type="radio" value=-1.7 onclick="setEv(value);"> -1.7
    <input name="ev" type="radio" value=-1.3 onclick="setEv(value);"> -1.3
    <input name="ev" type="radio" value=-1.0 onclick="setEv(value);"> -1.0
    <input name="ev" type="radio" value=-0.7 onclick="setEv(value);"> -0.7
    <input name="ev" type="radio" value=-0.3 onclick="setEv(value);"> -0.3
    <input name="ev" type="radio" value=0.0 onclick="setEv(value);"> 0.0
    <input name="ev" type="radio" value=0.3 onclick="setEv(value);"> 0.3
    <input name="ev" type="radio" value=0.7 onclick="setEv(value);"> 0.7
    <input name="ev" type="radio" value=1.0 onclick="setEv(value);"> 1.0
    <input name="ev" type="radio" value=1.3 onclick="setEv(value);"> 1.3
    <input name="ev" type="radio" value=1.7 onclick="setEv(value);"> 1.7
    <input name="ev" type="radio" value=2.0 onclick="setEv(value);"> 2.0
    <br>
    <br>
    <div hidden id="img_url"></div>
    <div hidden id="state">0</div>
    <script type="module">
      import * as THREE from './three/build/three.module.js';
      import { OrbitControls } from './three/examples/jsm/controls/OrbitControls.js';
      import { GLTFLoader } from './three/examples/jsm/loaders/GLTFLoader.js';
        import { RGBELoader } from './three/examples/jsm/loaders/RGBELoader.js';
      import { RoughnessMipmapper } from './three/examples/jsm/utils/RoughnessMipmapper.js';

      var container, controls;
            var camera, scene, renderer;
      var texture,image;
      var state = false;
      var TpImageLoaded = false;
      var textureLoader = new THREE.TextureLoader();
      var options = {
            generateMipmaps: true,
            minFilter: THREE.LinearMipmapLinearFilter,
            magFilter: THREE.LinearFilter
            };

      init();

      function init() {
        container = document.getElementById('container')

        renderer = new THREE.WebGLRenderer( { antialias: true } );
        renderer.setPixelRatio( window.devicePixelRatio );
        renderer.setSize( window.innerWidth, window.innerHeight );
        renderer.toneMapping = THREE.ACESFilmicToneMapping;
        renderer.toneMappingExposure = 1;
        renderer.outputEncoding = THREE.sRGBEncoding;
        container.appendChild( renderer.domElement );

        camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.25, 20 );
        camera.position.set( - 1.8, 0.6, 0.7 );

        scene = new THREE.Scene();
        image = document.getElementById('lvimg');
        texture = textureLoader.load(image.src);
        texture.mapping = THREE.UVMapping;
        var roughnessMipmapper = new RoughnessMipmapper( renderer );
        scene.background = new THREE.WebGLCubeRenderTarget( 1920, options ).fromEquirectangularTexture( renderer, texture );
        scene.environment = new THREE.WebGLCubeRenderTarget( 1920, options ).fromEquirectangularTexture( renderer, texture );

        let mixer;
        let clock = new THREE.Clock();
        var loader = new GLTFLoader().setPath('models/DamagedHelmet/glTF/');
        loader.load( 'DamagedHelmet.gltf', function ( gltf ) {
          gltf.scene.traverse( function ( child ) {
          } );
          scene.add( gltf.scene );
          roughnessMipmapper.dispose();
          render();
        } );


        var pmremGenerator = new THREE.PMREMGenerator( renderer );
        pmremGenerator.compileEquirectangularShader();


        var GuiElement = function () {
          this.Take_Picture = function () { takePicture();}
          this.Set_Picture = function (){setTpImage();}
          this.Set_Live_Image = function (){setLvImage();}

        };

        var elements = new GuiElement();
        var gui = new dat.GUI();
        gui.add(elements, 'Take_Picture');
        gui.add(elements, 'Set_Picture');
        gui.add(elements, 'Set_Live_Image');

        controls = new OrbitControls( camera, renderer.domElement );
        controls.addEventListener( 'change', render ); // use if there is no animation loop
        controls.minDistance = 0.5;
        controls.maxDistance = 2;
        controls.target.set( 0, 0, - 0.2 );
        controls.update();
        window.addEventListener( 'resize', onWindowResize, false );
        animate();
        }

        function animate() 
        {
          requestAnimationFrame( animate );        
          render();
        }

        function onWindowResize() 
        {
          camera.aspect = window.innerWidth / window.innerHeight;
          camera.updateProjectionMatrix();
          renderer.setSize( window.innerWidth, window.innerHeight );
          render();
              }

        function render() 
      {
        var status = document.getElementById('state');
        if(status.textContent== "1" && TpImageLoaded == false)
        {
          var Tpimg = document.getElementById('TpImage');
          textureLoader.load(Tpimg.src, function ( texture ) {
           scene.background.fromEquirectangularTexture(renderer, texture);
           scene.environment.fromEquirectangularTexture(renderer, texture);
          });
          TpImageLoaded = true;
        }
        else if (status.textContent == "0")
        {
          textureLoader.load(image.src, function ( texture ) {
          scene.background.fromEquirectangularTexture(renderer, texture);
          scene.environment.fromEquirectangularTexture(renderer, texture);
        });
        }
                renderer.render( scene, camera );
      }

      function setTpImage() {
        var status = document.getElementById('state');
        status.textContent = "1";
        TpImageLoaded = false;
      }
      function setLvImage() {
        var status = document.getElementById('state');
        status.textContent = "0";
      }

    </script>
  </body>
</html>

まとめ

今回は、THETA1台(+スマホ)でIBLする方法を紹介しました。
THETA側にサーバーを立ててWebGLを利用することで、全天球画像を利用したインタラクティブなアプリケーションも簡単かつスタンドアロンに構築することが出来ます。ぜひ、お試し下さい。

RICOH THETAプラグインパートナープログラムについて

THETAプラグインをご存じない方はこちらをご覧ください。
パートナープログラムへの登録方法はこちらにもまとめてあります。
QiitaのRICOH THETAプラグイン開発者コミュニティ TOPページ「About」に便利な記事リンク集もあります。
興味を持たれた方はTwitterのフォローとTHETAプラグイン開発コミュニティ(Slack)への参加もよろしくおねがいします。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む