開発

出来る Windows Phone アプリ開発

カテゴリ
ブックマーク数
このエントリーを含むはてなブックマーク はてなブックマーク - 出来る Windows Phone アプリ開発
このエントリーをはてなブックマークに追加
こんにちは!こんにちは!
開発部のやましーです。

今回は Windows Phone のアプリ開発-社内テスト-マーケットプレースへの提出の流れを簡単に紹介します。



Windows Phone 7.5 - Mango とは

Windows Phone 7.5 とはMicrosoft社製のスマートフォン用 OS が搭載されたスマートフォン端末です。既に英語圏の国では Windows Phone 7.0 が発売済みでしたが、Windows Phone 7.5 で日本語を含むアジア言語がサポートされ、日本でも2011年8月25日にひっそりと発売となりました。

Windows Phone 7.5 の開発コードネームは Mango(マンゴー)と呼ばれているため、Windows Phone 系の情報サイトでは「Windows Phone Mango がリリース」や「Mango 端末」などと表現されることがあります。

Mango
mango

日本では2011年8月25日にフライング気味に発売されたようですが、実際のリリースは2011年の秋(9月末頃?)とのことです。

Windows Phone Mango 紹介サイト



アプリの開発に必要なもの

以下を準備することでアプリを開発してマーケットプレースへ提出できます。

アプリの開発に必要なもの

Windows Phone 7.1 SDK には下記の内容が含まれています。
  • Visual Studio 2010 Express for Windows Phone (C#/VB.NETでコードを書いてビルドする統合IDE)
  • Expression Blend 4 (ページデザインツール、必須ではないが使うと便利)
  • XNA Game Studio 4.0 Refresh (ゲーム開発用)
  • Windows Phone Emulator (エミュレーター)
  • Windows Phone Developer Registration (実機を開発者アンロックするツール)
  • Application Deployment (個別デプロイツール、普段は使わない)
Windows Phone 7.1 SDK のみでアプリの開発エミュレーター上での動作確認まで可能です。
※ 実機で動作確認をするためには下記が必要です。

実機で動作確認をするために必要なもの

App Hub メンバーシップに加入して実機を開発者アンロック状態にすることで、ビルドしたアプリを実機へデプロイしてのデバッグが可能になります。
※ App Hub メンバーシップではなく ChevronWP7 というサイトで開発者アンロック権を購入するような手段も準備されているようですが、まだ開始していないようです。

開発者アンロック
App Hub メンバーシップ登録を終えた後に、Zune ソフトウェアを起動して、開発者アンロックをしたい端末を接続して、端末の画面ロックを解除した状態で、Windows Phone SDK 7.1 に付属の Windows Phone Developer Registration を起動して、App Hub のログインに使う Windows Live ID とパスワードを入力して「登録」ボタンをクリックすると、その端末は開発者アンロック状態になります。

マーケットプレースへの提出に必要なもの

App Hub のアカウント作成(登録)やメンバーシップの購入方法などは App Hub の下記のページに詳しく載っています。

App Hub - アプリケーションの配布および販売に必要な手続きについて



アプリの開発

今回は簡単なテストアプリを作ります。
内容は twitter の ld_tech ユーザーのツイートをリスト表示するだけです(細かいことは次回)。

新規プロジェクトの作成

新規プロジェクトを作成します。

  1. Visual Studio 2010 を起動(スタートメニュー→すべてのプログラム→Microsoft Visual Studio 2010 Express→Microsoft Visual Studio 2010 Express for Windows Phone)
  2. メニューのファイル→新規作成→プロジェクトを選択する
  3. 「新しいプロジェクト」ダイアログが表示される
  4. 左上の「インストールされたテンプレート」で「Visual C#」を選択する
  5. 中央の「Windows Phone アプリケーション Visual C#」を選択(通常はデフォルトで選択されています)
  6. 下の「名前」を PhoneApp1 から適当な名前に変更してOKを押す(今回はテストアプリなので PhoneApp1 のまま作成)
  7. 「新しい Windows Phone アプリケーション」ダイアログが表示される
  8. 「対象の Windows Phone OS のバージョン」を選択してOKを押す(通常はそのまま Windows Phone OS 7.1 で作成)
これで新規プロジェクトの作成が完成しました。
画面の左側にアプリのデザインプレビュー、中央にxaml(ザムル)、右側にソリューション エクスプローラーが表示されています。

アプリタイトル・ページタイトルの変更

アプリとページのタイトルを変更します。

  1. 画面右下に「プロパティ」ウィンドウが表示されていなければメニューの表示→プロパティ ウィンドウで表示
  2. デザインプレビュー上の「マイ アプリケーション」をクリック
  3. プロパティの「Text」を「livedoor Techブログ」に変更(xaml内のテキストを直接変更する方法もあります)
  4. デザインプレビュー上の「ページ名」をクリック
  5. プロパティの「Text」を「一覧」に変更
これでアプリとページのタイトルの変更が完了しました。

ツイートの取得

アプリが起動したら twitter の検索APIを使って ld_tech ユーザーのツイートを取得する処理を書きます。
ウェブサービスからRSS/Atomフィードを取得する処理は様々な方法で書けますが、今回のようにお手軽に実装する場合は 大西 彰のお仕事でのブログ Windows Phone “Mango”: 短いコードでRSS/Atomフィードを一覧表示する方法 ページが参考になります(※ ただしRSS 2.0 / Atom 1.0に限る)。

Syndication アセンブリを追加
RSSフィードを取得するのに使う Syndication というアセンブリを追加します。

  1. メニューのプロジェクト→参照の追加
  2. 「参照の追加」ダイアログが表示される
  3. 「参照」タブに移動する
  4. C:\Program Files\Microsoft SDKs\Silverlight\v4.0\Libraries\Client の System.ServiceModel.Syndication.dll を選択してOKを押す
    (64ビットOSの場合は C:\Program Files (x86)\Microsoft SDKs\Silverlight\v4.0\Libraries\Client)
  5. 「Windows Phone XNA アセンブリには、安全に…」という警告が表示されるが「はい」を押す

RSSフィードを取得するコードを書く
アプリが起動したらRSSフィードを取得するコードを書きます。

  1. ソリューション エクスプローラーの MainPage.xaml の下の階層にある MainPage.xaml.cs ファイルをダブルクリックして開く
  2. コードの最初にある using 群の下に下記を追加
  3. コンストラクターの InitializeComponent(); の下に下記を追加。ただし cli.DownloadStringCompleted += の行は手入力して += の入力後にタブキーを2回押してメソッドの自動生成を行う。最後に忘れずに cli.DownloadStringAsync(uri); を入力。
  4. 自動生成された cli_DownloadStringCompleted メソッド内の throw 処理を削除して下記を追加
コードの入力は以上です。

ここで動作確認をしてみます。
F5 キーを押すとビルド、エミュレーターの起動(起動していなければ)、デプロイ、デバッグ開始となります。

実行結果
app-capture-001

アプリとページのタイトルのみ表示されれば正常です。エミュレーターの左下の「戻る」(矢印)ボタンでデバッグを終了します。

次はアプリのデザインをやります。



アプリのデザイン

アプリのデザインは Visual Studio でも出来ますが、今回はお手軽にデザインするために、Microsoft Expression Blend を使います。

Microsoft Expression Blend

Microsoft Expression Blend は xaml ファイルをビジュアルデザインするツールです。
今回は取得したRSSフィードの中身をリスト表示するデザインです。

アプリにリストを追加
アプリのメインとなるリストを追加します。

  1. Blend を起動する(Visual Studio のメニュー→プロジェクト→Expression Blend を開く)
  2. Blend 画面の右上にある「データ」タブを開く
  3. 「プロジェクト」と「このドキュメント」がリスト表示される
  4. その右上にあるアイコン create-sample-data-icon をクリックする
  5. 「クラスからのサンプル データの作成」を選択する
  6. 「クラスからのサンプル データの作成」ダイアログが表示される
  7. 一番下にある「すべてのアセンブリを表示する」をチェックする
  8. 「クラスの選択」リスト内の System.ServiceModel.Syndication を展開、その中の SyndicationFeed を選択、OKボタンを押す
  9. 「プロジェクト」に「SyndicationFeedSampleData」が追加される
  10. 「SyndicationFeedSampleData」の SyndicationFeed を展開する
  11. 続いて Items、Title を展開する
  12. Title の中の Text : (String) をアプリ画面のメインエリアにドラッグ&ドロップする
  13. 意味不明なアルファベットの羅列のオブジェクトが配置される
  14. そのオブジェクトを右クリックして自動サイズ設定→ページ幅に合わせるを選択する
  15. そのオブジェクトがアプリのメインエリア全体に広がる
  16. そのオブジェクトがアプリのメインとなるリストです
  17. 意味不明なアルファベットの羅列はRSSフィードのタイトル項目のダミー値です
現時点ではこんな感じです。
design-capture-001

リスト内にもう1つ項目を追加
追加されたリストにはすでにタイトル項目がありますが、更にテキスト項目を追加します。

  1. Blend 画面の左下にある「オブジェクトとタイムライン」ウィンドウで一番下の ListBox が選択されていることを確認する
  2. 選択されていなければ選択する
  3. メニューのオブジェクト→追加テンプレートの編集→生成されたアイテム の編集→現在のテンプレートの編集を選択する
  4. 「オブジェクトとタイムライン」内のリストがリスト内のデザイン用に切り替わる
  5. TextBlock をクリックして選択する
  6. もう一度クリックすると名前を編集できるようになるので名前を Title に変更する
  7. これがRSSフィードのタイトル項目です
  8. 続いて Blend 画面の一番左のアイコン text-block-icon をダブルクリックする
  9. TextBlock が1つ追加される
  10. 追加された TextBlock をクリックして名前を Summary に変更する
  11. 右上の「データ」タブの SyndicationFeed から Summary の中の Text : (String) を、先ほど名前を付けた Summary の上にドラッグ&ドロップする
  12. これがRSSフィードのテキスト項目です

リストの整形
リスト内の項目の整形をします。

  1. 「データ」タブが開かれているところの左にある「プロパティ」タブをクリックする
  2. 「オブジェクトとタイムライン」内の StackPanel を右クリックする
  3. グループ化設定→Borderを選択する
  4. 追加された Border を全て展開する
  5. Border、StackPanel、Title、Summary の4つのオブジェクトがあることを確認する
  6. Border をクリックして選択する
  7. プロパティの「概観」内にある Background が No brush になっているのを確認する
  8. No brush の右にある小さい四角をクリックする
  9. システム リソース→PhoneAccentBrushを選択する
  10. 項目の一部が水色に変わる
  11. 次に角を丸くするためにプロパティの「概観」内にある CornerRadius を 10 にする
  12. 角が丸くなる
  13. プロパティの「レイアウト」内にある Margin の上下をそれぞれ 5 に変更する
  14. リストアイテムの上下に隙間が空く
  15. 同じく Padding の上下左右を 10 に変更する
  16. リストアイテム内に余白ができる
  17. 「オブジェクトとタイムライン」内の StackPanel を選択する
  18. プロパティの「レイアウト」内の Width を 450 に変更する
  19. 続いて高さを自動調整するために Height の数値の右にある全画面表示のようなアイコンをクリックする
だいたいこんな感じで。
design-capture-002

ためしに F5 キーを押して実行してみます。

実行結果
design-capture-003

あれ、なぜか完成していますね。
細かいレイアウトはその他のプロパティの値を調整することで、デザイン画面を見ながら調整できます。

デザインが終わったので Blend を閉じて Visual Studio に戻ります。

次はマーケットプレースへの提出をやります。



マーケットプレースへの提出(社内テスト用)

マーケットプレースへの提出には配布対象が2種類あって、ひとつは パブリック Marketplace、もうひとつは プライベート ベータテスト です。後者のプライベート ベータテストは iOS アプリのベータ版を配布するときに便利な TestFlight のようなもの です。今回は社内テスト向けなのでプライベート ベータテスト向けに配布します。

マーケットプレースへの提出の際に必要なもの

マーケットプレースへ提出する際に下記のものが必要となるので準備しましょう。

  • アプリのアイコン画像3種類(99x99、173x173、200x200)
  • スクリーンショット(480x800、最低1枚必要)
  • マーケットプレース背景画像(1000x800、オプション)
アイコン画像は自分で作るとして、スクリーンショットはエミュレーターの右上にあるツールで撮ることができます。デバッグ実行状態だと実行画面の右側にパフォーマンス値が表示されてしまうので、エミュレーター上でホームボタンを押してスタート画面に戻り、左にフリックしてアプリ一覧からアプリを起動すると、パフォーマンス値が表示されない状態で起動できます。

マーケットプレースへの提出の前に - Marketplace Testkit

マーケットプレースでは恐らく機械による自動テストと人間による手動テストでアプリの動作チェックが行われています。提出してから承認されるまでに数日を要することが多いため、テストの失敗を未然に防ぐために事前チェック項目のテストができる Marketplace Testkit というツールがあります。

今回は Marketplace Testkit を使って提出前のテストを実施します。

Visual Studio のメニュー→プロジェクト→Marketplace Testkit を開くを選択すると Marketplace Testkit を開始します。
左の「アプリケーションの詳細」「自動テスト」「監視対象のテスト」「手動テスト」の順に進みます。

アプリケーションの詳細
ここではマーケットプレースで必要となるアイコン、スクリーンショットを参照します。まずは大きなアプリケーション タイル、小さなアプリケーション タイル、Marketplace タイルを設定します。そしてアプリケーションのスクリーン ショットを1つ設定します。

自動テスト
ここではプロジェクトの実行ファイル、アイコン、スクリーンショットが正常かどうかテストします。実施にはプロジェクトがリリース用にビルドされている必要があります。リリース用にビルドするには Visual Studio のツールバー上にある Debug を Release に切り替えてからメニューのビルド→ソリューションのビルドでビルドします。

「テストの実行」ボタンを押すと下記のような結果が表示されます。

結果 テスト名 テストの説明 結果の詳細
成功 XAP パッケージの用件 XAP ファイルサイズとコンテンツファイルの検証
成功 機能の検証 アプリケーションの機能の検証 [情報]:アプリケーションで使用される機能:ID_CAP_NETWORKING
成功 図像 アプリケーションのアイコンの検証
成功 スクリーンショット スクリーンショットの検証
アイコンやスクリーンショットのサイズが正しくないと警告されます。また、機能の検証の結果の詳細に ID_CAP_NETWORKING と表示されて、このアプリがネットワークを利用することを自動検出してくれます。カメラや電話帳などにアクセスするコードが含まれる場合には ID_CAP_ISV_CAMERA や ID_CAP_CONTACTS などが表示されます。これらは WMAppManifest.xml ファイルに記載します。標準でほとんどの機能を「使う」ようになっていますが、アプリが利用する機能は自動判別なので特に開発者が ID_CAP_* を追加したり削除する必要はありません。特定の機能を利用するコードを書いた覚えがないのに ID_CAP_* が表示される場合は、どこかで利用されているのでチェックするなどします。ただし、今回検出された ID_CAP_NETWORKING はネットワークを使わない場合でも付与されるらしく、全くネットワークを使わないアプリの場合には WMAppManifest.xml ファイルを編集して ID_CAP_NETWORKING を削除すれば検出されなくなります。

監視対象のテスト
ここではテスト実施者がアプリケーションを実際に使用して、全ての操作を実施した場合の状況をテストします。このテストは実機で行う必要があります。Zune ソフトウェアを起動して、開発者アンロック状態の端末を接続して、端末の画面ロックを解除した状態で、Visual Studio のツールバー上のプロジェクトのターゲットを Windows Phone Device に切り替えます。

「アプリケーションを起動」を押すと実機でアプリが起動するので、自分で操作します。

今回はRSSフィードを表示する1ページのみなので、リストを下までスクロールして、上まで戻ることを何度か繰り返し、最後の「戻る」ボタンでアプリを終了します。しばらくすると結果が表示されます。

結果 テスト名 テストの説明 結果の詳細
成功 起動時間 アプリケーションの起動時間の検証です。 [警告]:アプリケーションの起動に 5.2 秒かかりました。これは、起動時間が 5 秒という許容範囲に迫っています。
成功 ピーク時のメモリ消費量 アプリケーションのピーク時のメモリ消費量の検証 [情報]:アプリケーションで使用されるピーク メモリは 15.84 MB です。
成功 アプリケーションの終了 すべての例外が処理され、アプリケーションが予期せず終了されていないことの検証です。 [情報]:ハンドルされない例外は発生しませんでした。例外が発生して処理されている場合は、アプリケーションでユーザー フレンドリなメッセージが表示され、応答していることを確認してください。
成功 [戻る] ボタンの仕様 [戻る] ボタンを押したときの適切な動作の検証です。
起動時間の項目で警告がでています。実装方法に問題があるのかも知れませんが今回はテストアプリなのでこのままにします。
その他、アプリのピーク時のメモリ消費量が多い場合や、ハンドルされていない例外が発生した場合、戻るボタンのテストが正しく実施されていない場合などに警告がでます。

手動テスト
これは、アプリケーションのページ遷移の挙動や応答速度などのテストすべき項目を手動でテストして成功・失敗を付けていくチェックシートです。今回は割愛します。

マーケットプレースへの提出

今回は 社内テスト 用に プライベート ベータテスト 向けにアプリを提出します。
マーケットプレースへのアプリの提出手順については App Hub の下記のページに詳しく載っています。

App Hub - Windows Phone 7 アプリの提出フロー
プライベート ベータテストにアプリを提出する手順の説明 ← これを見ながら

以下のような手順です。

  1. App Hub で実行ファイルをアップロードする
  2. 説明を入力する
  3. 画像をアップロードする
  4. ベータテスターのメールアドレスを入力する
  5. 提出完了
  6. App Hub から完了通知のメールが届く
  7. ダウンロードリンクをベータテスターへ通知
  8. ベータテスターが実機にてダウンロードする
  9. ベータテスターが実機にてレビュー(星の数とコメント)を投稿する
社内テストを行う際は、テストを行う実機端末がサインインしている Windows Live ID を ベータテスターのメールアドレス を入力してくださいの欄に入力します。メールアドレスは最大 100 件 まで入力できるようです。テストを行う実機端末は開発者アンロックされている必要はありません

マーケットプレースへの提出が完了して、しばらくすると Congratulations! テストアプリ for Windows Phone is now available to the Windows Live ID participants you named as reviewers. という内容のメールが届いて、そのメール内にテストアプリのダウンロードリンクが書かれているので、そのダウンロードリンクをテスターに通知してテストしてもらいます。

プライベート ベータテスト向けに配布したアプリは他からは見えないので、個人レベルであればアプリの レビュー機能 を使って不具合報告をすることで、App Hub 内でレビュー結果(星の数とコメント)を閲覧することができます。社内テストであれば独自のバグ追跡システムなどで管理するでしょう。

テストが完了したら App Hub 内で「ベータを終了」を指示することで、ベータテスターへの配布が停止します。また、プライベート ベータテスト向けに配布したアプリの有効期限は90日であり、それを過ぎるとダウンロードできなくなります。

全てのテストが完了すれば、今度は 、パブリック Marketplace の方に提出することで、正式リリースとなります。



おわり

以上が Windows Phone 7.5 のアプリ開発-社内テスト-マーケットプレースへの提出の流れとなります。

次回はアプリ開発時のTIPSなどを紹介します。

スマートフォンでお手軽3D

カテゴリ
ブックマーク数
このエントリーを含むはてなブックマーク はてなブックマーク - スマートフォンでお手軽3D
このエントリーをはてなブックマークに追加
こんにちわ。最近引っ越した家の周りを探索するのにはまっている中村です。
今回は3D関係について書きたいと思います。
なお、サンプル画像と動画が記事の最後にあります!!

概要


iPhoneのOpenGL用にさまざまなライブラリおよびミドルウェアは出ていますけれど、今回は、iPhoneの3Dチップメーカーが出しているライブラリの紹介をしたいと思います。一応公式のチップメーカーがだしているし、便利でお手軽なライブラリなのにいまいち検索しても日本語のサイトが出てこないので、開発の方に広く知ってもらいたいと思い、書こうと思いました。

iPhoneやAndroidのOpenGL Viewの使い方は一部紹介しますが、検索すればいくらでも出てくるのでそこは調べてもらうものとします。また、スキンドメッシュの原理に関しては、ちょこっとだけ解説しますが、詳しくは長くなるので、グラフィックスの専門のサイトにまかせてライブラリそのものの使い方について書きたいと思います。

ライブラリを出しているメーカーについて


ライブラリを出しているメーカーはImagination社という会社で、古くはゲーム機のDreamCastの3DチップメーカーとしてPowerVRを開発したメーカーです。そのメーカーがiPhone用の3Dチップを作っているというのも面白いですね。そこがiPhone用、Android用にスキンメッシュ(ポリゴンにつなぎ目がなく曲げることができる技術)まで対応したライブラリを開発してくれています。

ライブラリについて


車輪の再発明(時には大事ですが)をしなくとも、お手軽に3Dのデータを表示できるのでありがたく使わせてもらいましょう。もちろんチップメーカーが開発しているので、速度面に関してもいうことはなく最適化されていますし、MAYA、3DStudioMax、Blenderという商用のお高いソフトからフリーの3Dソフトまで対応していて、3Dのデータを出力してくれるプラグインまで開発してくれているので、フリーの環境でそろえたいという方から、商用のソフトはあるんだけどプラグインまで開発したくないなぁという方まで、便利に使えるSDKのセットになっています。

また、ある一部のゲームメーカーのプラットフォームで標準になっている3DのXML形式のColladaからも、ライブラリで使用できるPodという形式(アニメーション付き3Dフォーマット)に変換できるようになっています。

スキンメッシュの原理の簡単な説明


3Dオブジェクトとボーン(関節のような頂点を変形するもの)の関係は下の図のようになっています。

bone

この図をみると、3Dのオブジェクトに対してピンク色の菱形の物体が内部にはいっていると思います。

そのピンク色のボーンと呼ばれる物体が3Dオブジェクトの頂点をどの程度移動するかそれぞれの頂点に保存されており、この値をウェイトと呼びますが、このウェイト値とボーンの回転とか移動とかのマトリックスを乗算して頂点をどの程度まげるか決め、頂点に乗算し3Dポリゴンを変形します。

このライブラリを使う利点と欠点


このライブラリを使う利点は、お手軽に他のミドルウェアにありがちな、細かい処理が見えにくいということがなく、シンプルに3Dのアニメーションオブジェクトを表示することができることと、その割には、MAYA、3DStudioMax、Blenderという3つのソフトに対応していることです。

ただし弱点もあって、細かい仕様になりますが、4つ以上のサーフェース(マテリアルをもつポリゴン)にボーンがまたがって影響を及ぼすようなモデルには対応していないということです。まぁゲームとかですと、1サーフェースでUV(テクスチャを貼付けるための値)を1つだけ持つのが多かったりするし、そこは我慢しましょう。

iPhoneの場合


準備


まず、SDKをダウンロードしてきましょう、日本のImaginationのサイトもあるのですが、SDKが本家のサイトからダウンロードしてきた方が最新なので、本家から持ってきましょう。

登録してSDKをダウンロードしてきます。OpenGLES2.0のSDKのライブラリはShaderを使ったものになっていて、使いにくいので、1.0のSDKを持ってきます。

1.0のImaginationのSDKは、iPhoneのSDKのバージョンが古いもので作られたものらしく、バージョンの変更や、一部ソースを書き換えなくてはサンプルなどがコンパイルできなくなっていますが、ライブラリは特に変更しなくとも利用することが可能です。

ライブラリを変更するまえにOpenGLを利用できるViewについて


OpenGLを利用するためにちょっとUIViewを継承したViewを作る必要があります。

XCodeには、標準でOpenGLのViewを作るためのウィザードがあるので、それで作ったEAGLViewというのを改造して今回のライブラリが利用できるように変更しましょう。

ViewにはContextというものがあるのですが、そのGL用のContextには今のところ2種類ありまして、gl~Matrix(glPushMatrixとか)が使えないマトリックスを全部シェーダーで書かなければいけないモードと、gl~Matrix系の関数はすべて使えてシェーダーを使わないで構築するモードとがあります。

今回のライブラリはマトリックスを使ってやるライブラリになっていますので、それを使います。

ウィザードで作られたOpenGL Viewは、両方に対応していて、kEAGLRenderingAPIOpenGLES2というモード(マトリックスが使えないモード)が使えない場合は、kEAGLRenderingAPIOpenGLES1というモードを使うようになっています。ただし、iphone3GS以降はkEAGLRenderingAPIOpenGLES2が使えますので、強制的にkEAGLRenderingAPIOpenGLES1のモードを選択するようにしてやる必要があります。どちらかを選択する部分があると思いますのでそこを改造します。

あとは、drawFrame、または、renderメソッドに描画処理をいれたりすれば描画ができます。

お手軽に利用するには


サンプルにSkinningというもろに使えるサンプルがあるので、そこからソースを利用させてもらいましょう。ただし一般的に利用するためにはちょっと変更する必要があります。

サンプルを変更し自分用のライブラリへ


TutorialにSkinningというのがあるので、それのサンプルのなかにOGLESSkinning.cppというソースがあり、そのソースのテクスチャロードの部分のメソッドを変更します。

元のソースでは、テクスチャファイル名がハードコードされているので、マテリアルからテクスチャ名をとってきてテクスチャをロードする部分を作成します。

サンプルの方では、ライブラリの固有のテクスチャ圧縮形式であるpvr形式というものが使われているようですが、マテリアルの中に書いてあるファイル名は普通の画像形式(3Dソフトのプラグインから出力された時のテクスチャ名)が入っているので、それをロードするコーディングをします。

LoadTexturesメソッドの中身を書き換えて、PVRTextureLoadFromPVRを使ってテクスチャ画像をファイル名から読み込んでいる部分を書き換えます。具体的な書き換え方は、CoreGraphics系のライブラリを使って、画像を読み込み、それをglBindTextureとかglTexImage2Dとかを使って設定します。

bool OGLESSkinning::LoadTextures(CPVRTString* const pErrorStr)
{
	/*if(PVRTTextureLoadFromPVR(c_szBodyTexFile, &m_uiBodyTex) != PVR_SUCCESS)
	{
		*pErrorStr = "ERROR: Failed to load body texture.";
		return false;
	}

	if(PVRTTextureLoadFromPVR(c_szLegTexFile, &m_uiLegTex) != PVR_SUCCESS)
	{
		*pErrorStr = "ERROR: Failed to load leg texture.";
		return false;
	}

	if(PVRTTextureLoadFromPVR(c_szBeltTexFile, &m_uiBeltTex) != PVR_SUCCESS)
	{
		*pErrorStr = "ERROR: Failed to load belt texture.";
		return false;
	}*/
	
	m_puiTextures = new GLuint[m_Scene.nNumMaterial];
	
	if(!m_puiTextures)
	{
		//*pErrorStr = "ERROR: Insufficient memory.";
		return false;
	}
	m_textureNum=0;
	for(int i = 0; i < (int) m_Scene.nNumMaterial; ++i)
	{
		m_puiTextures[i] = 0;
		SPODMaterial* pMaterial = &m_Scene.pMaterial[i];
		
		if(pMaterial->nIdxTexDiffuse != -1)
		{
			/*
			 Using the tools function PVRTTextureLoadFromPVR load the textures required by the pod file.
			 
			 Note: This function only loads .pvr files. You can set the textures in 3D Studio Max to .pvr
			 files using the PVRTexTool plug-in for max. Alternatively, the pod material properties can be
			 modified in PVRShaman.
			 */
			
			CPVRTString sTextureName = m_Scene.pTexture[pMaterial->nIdxTexDiffuse].pszName;
			int len = strlen(sTextureName.c_str());
			char * strc = (char *)malloc(sizeof(char)*(len+1));
			strcpy(strc,sTextureName.c_str());
			CPVRTString fullpath=CPVRTResourceFile::GetReadPath();
			len =strlen(fullpath.c_str());
			char * full = (char *)malloc(sizeof(char)*(len+1));
			strcpy(full,fullpath.c_str());
			NSString *patht= [[NSString alloc] initWithUTF8String:full];
			NSString *str = [[NSString alloc] initWithUTF8String:strc];
			NSString *pathname=[patht  stringByAppendingString:str];
			free(strc);
			free(full);
			
			CGImageRef image  = [UIImage imageWithContentsOfFile:pathname].CGImage;
			
			NSInteger  width  = CGImageGetWidth(image);
			NSInteger  height = CGImageGetHeight(image);
			GLubyte*   bits   = (GLubyte*)malloc(width * height * 4);
			CGImageAlphaInfo info;
			BOOL             hasAlpha;
			size_t           bitsPerComponent;
			info=CGImageGetAlphaInfo(image);
			//アルファ成分チェック
			hasAlpha=((info==kCGImageAlphaPremultipliedLast) || 
					  (info==kCGImageAlphaPremultipliedFirst) || 
					  (info==kCGImageAlphaLast) || 
					  (info==kCGImageAlphaFirst)?YES:NO);
			if (hasAlpha) {
				bitsPerComponent=kCGImageAlphaPremultipliedLast;
			} else {
				bitsPerComponent=kCGImageAlphaNoneSkipLast;
			}
            CGColorSpaceRef colorSpace=CGColorSpaceCreateDeviceRGB();
			CGContextRef textureContext =
			CGBitmapContextCreate(bits, width, height, 8, width * 4,
								  colorSpace, bitsPerComponent);
			CGContextScaleCTM(textureContext, 1, -1);
			CGContextTranslateCTM(textureContext, 0, -(double)height);
			
			CGColorSpaceRelease(colorSpace);
			CGContextClearRect(textureContext,CGRectMake(0.0, 0.0, width, height));			
			CGContextDrawImage(textureContext, CGRectMake(0.0, 0.0, width, height), image);
			CGContextRelease(textureContext);
			
			glGenTextures(1, &m_puiTextures[i]);
			glBindTexture(GL_TEXTURE_2D, m_puiTextures[i]);
			glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR); 
			glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_LINEAR);
			glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, bits);
			
			glBindTexture(GL_TEXTURE_2D, 0);
			free(bits);
		}
	}
	return true;
}


CPVRTModelPOD型の変数の簡単な説明


CPVRTModelPODは3Dのシーンを管理しているクラスで、3Dシーンファイル(Pod形式)の読み込みのメソッドや、アニメーション部分のマトリックス(クォータニオン)の計算部分を自動でやってくれるクラスになっています。

CPVRTModelPODクラスに大してSetFrameして、あとはOpenGLのExtensionにマトリックスを突っ込んでやればオブジェクトの変形をしてくれるようになっています。こんな感じです。

void OGLESSkinning::DrawModel()
{
	//Set the frame number
	m_Scene.SetFrame(m_fFrame);

	// Enable lighting
	if(lightsw)
		glEnable(GL_LIGHTING);
	else 
		glDisable(GL_LIGHTING);

	// Enable States
	glEnableClientState(GL_VERTEX_ARRAY);
	glEnableClientState(GL_NORMAL_ARRAY);
	

	
	m_MeshNum=0;
	//Iterate through all the mesh nodes in the scene
	for(int iNode = 0; iNode < (int)m_Scene.nNumMeshNode; ++iNode)
	{
		//Get the mesh node.
		SPODNode* pNode = &m_Scene.pNode[iNode];

		//Get the mesh that the mesh node uses.
		SPODMesh* pMesh = &m_Scene.pMesh[pNode->nIdx];

		// bind the VBO for the mesh
		glBindBuffer(GL_ARRAY_BUFFER, m_puiVbo[pNode->nIdx]);

		// bind the index buffer, won't hurt if the handle is 0
		glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_puiIndexVbo[pNode->nIdx]);

		// Loads the correct texture using our texture lookup table
		if(pNode->nIdxMaterial == -1 )
			glBindTexture(GL_TEXTURE_2D, 0); // It has no pMaterial defined. Use blank texture (0)
		else
			glBindTexture(GL_TEXTURE_2D, m_puiTextures[pNode->nIdxMaterial]);

		//If the mesh has bone weight data then we must be skinning.
		bool bSkinning = pMesh->sBoneWeight.n != 0;

		if(bSkinning)
		{
			//If we are skinning then enable the relevant states.
			glEnableClientState(GL_MATRIX_INDEX_ARRAY_OES);
			glEnableClientState(GL_WEIGHT_ARRAY_OES);
			if(iNode==0)
			{
				if(m_fFrame==0)
				{
					m_max_vertex[0]=m_max_vertex[1]=m_max_vertex[2]=FLT_MIN;
					m_min_vertex[0]=m_min_vertex[1]=m_min_vertex[2]=FLT_MAX;
				}
				
				m_max_vertex_anim[0]=m_max_vertex_anim[1]=m_max_vertex_anim[2]=FLT_MIN;
				m_min_vertex_anim[0]=m_min_vertex_anim[1]=m_min_vertex_anim[2]=FLT_MAX;			}
		}
		else 
		{
			// If we're not using matrix palette then get the world matrix for the mesh 
			// and transform the model view matrix by it.
			PVRTMat4 worldMatrix;
			m_Scene.GetWorldMatrix(worldMatrix, *pNode);

			//Push the modelview matrix
			glPushMatrix();
			glMultMatrixf(m_mTransform.f);
			glMultMatrixf(worldMatrix.f);
			
			
		}

		// Set Data Pointers
		// Used to display non interleaved geometry
		
		glVertexPointer(pMesh->sVertex.n, GL_FLOAT, pMesh->sVertex.nStride, pMesh->sVertex.pData);
		glNormalPointer(GL_FLOAT, pMesh->sNormals.nStride, pMesh->sNormals.pData);
		if(pMesh->psUVW)
		{
			glEnableClientState(GL_TEXTURE_COORD_ARRAY);
			glTexCoordPointer(pMesh->psUVW[0].n, GL_FLOAT, pMesh->psUVW[0].nStride, pMesh->psUVW[0].pData);
		}

		if(bSkinning)
		{
			//Set up the indexes into the matrix palette.
			m_Extensions.glMatrixIndexPointerOES(pMesh->sBoneIdx.n, GL_UNSIGNED_BYTE, pMesh->sBoneIdx.nStride, pMesh->sBoneIdx.pData);
			m_Extensions.glWeightPointerOES(pMesh->sBoneWeight.n, GL_FLOAT, pMesh->sBoneWeight.nStride, pMesh->sBoneWeight.pData);
		}

		// Draw

		
		int i32Strip = 0;
		int i32Offset = 0;

		for(int i32Batch = 0; i32Batch <pMesh->sBoneBatches.nBatchCnt; ++i32Batch)
		{
			// If the mesh is used for skining then set up the matrix palettes.
			if(bSkinning)
			{
				//Enable the matrix palette extension
				glEnable(GL_MATRIX_PALETTE_OES);
				/*
					Enables the matrix palette stack extension, and apply subsequent
					matrix operations to the matrix palette stack.
				*/
				glMatrixMode(GL_MATRIX_PALETTE_OES);

				PVRTMat4	mBoneWorld;
				int			i32NodeID;

				//	Iterate through all the bones in the batch
				for(int j = 0; j < pMesh->sBoneBatches.pnBatchBoneCnt[i32Batch]; ++j)
				{
					/*
						Set the current matrix palette that we wish to change. An error
						will be returned if the index (j) is not between 0 and
						GL_MAX_PALETTE_MATRICES_OES. The value of GL_MAX_PALETTE_MATRICES_OES
						can be retrieved using glGetIntegerv, the initial value is 9.

						GL_MAX_PALETTE_MATRICES_OES does not mean you need to limit
						your character to 9 bones as you can overcome this limitation
						by using bone batching which splits the mesh up into sub-meshes
						which use only a subset of the bones.
					*/

					m_Extensions.glCurrentPaletteMatrixOES(j);

					// Generates the world matrix for the given bone in this batch.
					i32NodeID = pMesh->sBoneBatches.pnBatches[i32Batch * pMesh->sBoneBatches.nBatchBoneMax + j];
					m_Scene.GetBoneWorldMatrix(mBoneWorld, *pNode, m_Scene.pNode[i32NodeID]);

					
					// Multiply the bone's world matrix by our transformation matrix and the view matrix
					mBoneWorld = m_mView * m_mTransform * mBoneWorld;

					// Load the bone matrix into the current palette matrix.
					glLoadMatrixf(mBoneWorld.f);
				}
			}
			else
			{
				//If we're not skinning then disable the matrix palette.
				glDisable(GL_MATRIX_PALETTE_OES);
			}

			//Switch to the modelview matrix.
			glMatrixMode(GL_MODELVIEW);

			// Calculate the number of triangles in the current batch
			int i32Tris;

			if(i32Batch + 1 < pMesh->sBoneBatches.nBatchCnt)
				i32Tris = pMesh->sBoneBatches.pnBatchOffset[i32Batch+1] - pMesh->sBoneBatches.pnBatchOffset[i32Batch];
			else
				i32Tris = pMesh->nNumFaces - pMesh->sBoneBatches.pnBatchOffset[i32Batch];

			// Indexed Triangle list
			if(pMesh->nNumStrips == 0)
			{
				
					glDrawElements(GL_TRIANGLES, i32Tris * 3, GL_UNSIGNED_SHORT, &((unsigned short*)0)[3 * pMesh->sBoneBatches.pnBatchOffset[i32Batch]]);
				
				
				
			}
			else // Indexed Triangle strips
			{
				int i32TrisDrawn = 0;

				while(i32TrisDrawn < i32Tris)
				{
					
						glDrawElements(GL_TRIANGLE_STRIP, pMesh->pnStripLength[i32Strip]+2, GL_UNSIGNED_SHORT, &((GLshort*)0)[i32Offset]);
										
					
					
					i32Offset += pMesh->pnStripLength[i32Strip]+2;
					i32TrisDrawn += pMesh->pnStripLength[i32Strip];

					++i32Strip;
				}
			}
		}
		
		if(!pMesh->sBoneBatches.nBatchCnt)
		{
			
				glDrawElements(GL_TRIANGLES, pMesh->nNumFaces*3, GL_UNSIGNED_SHORT, 0);
			
		}
		
		if(bSkinning)
		{
			glDisableClientState(GL_MATRIX_INDEX_ARRAY_OES);
			glDisableClientState(GL_WEIGHT_ARRAY_OES);

			// We are finished with the matrix pallete so disable it.
			glDisable(GL_MATRIX_PALETTE_OES);
		}
		else
		{
			//Reset the modelview matrix back to what it was before we transformed by the mesh node.
			glPopMatrix();
		}
	}

	
	
	
	
	// Disable States
	glDisableClientState(GL_VERTEX_ARRAY);
	glDisableClientState(GL_NORMAL_ARRAY);
	glDisableClientState(GL_TEXTURE_COORD_ARRAY);

	// unbind the vertex buffers as we don't need them bound anymore
	glBindBuffer(GL_ARRAY_BUFFER, 0);
	glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
	
	

}


Androidの場合


準備


Androidの場合もSDKをダウンロードしてきましょう。
そのときにダウンロードしてくるのは、OpenGLES1.1のものをダウンロードしてきましょう。Androidの場合、OpenGLの部分はJavaではなく、C++で書かれているため、合わせてNDKを使ったアプリがコンパイルできる環境を準備しましょう。

お手軽に利用するには


Android版のほうはスキンアニメのサンプルがなぜか入ってないので、その部分を作らなければならないのですが、描画する部分はiPhone版のソースからそのまま持ってくれば使えるので大丈夫だと思います。

ただ、注意しなくてはならないのは、Android版は3Dのオブジェクトのデータをリソースなどに持っているのではなく、Cのソースにしてリンクしてそれを呼び出す感じになってしまっているので、それが嫌な場合は、Java側のソースでリソースのデータを/data/ディレクトリに書き出して、それをC側のソースから読み込んでやるようにしてやればよいと思います。

リソースをNDKで読み出す方法については、詳しくは検索してもらうとして、サンプルがないので0から作るのも大変なので、OGLESIntroducingPODを改造して作るとしましょう。

サンプルを変更し自分用のライブラリへ


まず、変更しなくてはいけないところはモデルを描画するところです。OGLESIntroducingPod.cppのOGLESIntroducingPOD::RenderSceneというメソッドの中身をちょっと変更します。そのメソッドの最後のほうにメッシュを描画している部分がありますが、その部分を全部コメントアウトして、独自のメソッドDrawModelを追加しましょう。

そのDrawModelメソッドは次の通りです(ほとんどiPhoneのソースから持ってきたものです)。

void OGLESIntroducingPOD::DrawModel()
{
	//Set the frame number
	m_Scene.SetFrame(m_fFrame);

	// Enable lighting
	
	glEnable(GL_LIGHTING);
	

	// Enable States
	glEnableClientState(GL_VERTEX_ARRAY);
	glEnableClientState(GL_NORMAL_ARRAY);	
	//Iterate through all the mesh nodes in the scene
	for(int iNode = 0; iNode < (int)m_Scene.nNumMeshNode; ++iNode)
	{
		//Get the mesh node.
		SPODNode* pNode = &m_Scene.pNode[iNode];

		//Get the mesh that the mesh node uses.
		SPODMesh* pMesh = &m_Scene.pMesh[pNode->nIdx];

		// bind the VBO for the mesh
		glBindBuffer(GL_ARRAY_BUFFER, m_puiVbo[pNode->nIdx]);

		// bind the index buffer, won't hurt if the handle is 0
		glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_puiIndexVbo[pNode->nIdx]);

		// Loads the correct texture using our texture lookup table
		if(pNode->nIdxMaterial == -1 || !texsw)
			glBindTexture(GL_TEXTURE_2D, 0); // It has no pMaterial defined. Use blank texture (0)
		else
			glBindTexture(GL_TEXTURE_2D, m_puiTextures[pNode->nIdxMaterial]);

		//If the mesh has bone weight data then we must be skinning.
		bool bSkinning = pMesh->sBoneWeight.n != 0;

		if(bSkinning)
		{
			//If we are skinning then enable the relevant states.
			glEnableClientState(GL_MATRIX_INDEX_ARRAY_OES);
			glEnableClientState(GL_WEIGHT_ARRAY_OES);
			
		}
		else 
		{
			// If we're not using matrix palette then get the world matrix for the mesh 
			// and transform the model view matrix by it.
			PVRTMat4 worldMatrix;
			m_Scene.GetWorldMatrix(worldMatrix, *pNode);

			//Push the modelview matrix
			glPushMatrix();
			glMultMatrixf(m_mTransform.f);
			glMultMatrixf(worldMatrix.f);
			
			
		}

		// Set Data Pointers
		// Used to display non interleaved geometry
		
		glVertexPointer(pMesh->sVertex.n, GL_FLOAT, pMesh->sVertex.nStride, pMesh->sVertex.pData);
		glNormalPointer(GL_FLOAT, pMesh->sNormals.nStride, pMesh->sNormals.pData);
		if(pMesh->psUVW)
		{
			glEnableClientState(GL_TEXTURE_COORD_ARRAY);
			glTexCoordPointer(pMesh->psUVW[0].n, GL_FLOAT, pMesh->psUVW[0].nStride, pMesh->psUVW[0].pData);
		}

		if(bSkinning)
		{
			//Set up the indexes into the matrix palette.
			glMatrixIndexPointerOES(pMesh->sBoneIdx.n, GL_UNSIGNED_BYTE, pMesh->sBoneIdx.nStride, pMesh->sBoneIdx.pData);
			glWeightPointerOES(pMesh->sBoneWeight.n, GL_FLOAT, pMesh->sBoneWeight.nStride, pMesh->sBoneWeight.pData);
		}

		// Draw


		int i32Strip = 0;
		int i32Offset = 0;



		for(int i32Batch = 0; i32Batch <pMesh->sBoneBatches.nBatchCnt; ++i32Batch)
		{
			// If the mesh is used for skining then set up the matrix palettes.
			if(bSkinning)
			{
				//Enable the matrix palette extension
				glEnable(GL_MATRIX_PALETTE_OES);

				glMatrixMode(GL_MATRIX_PALETTE_OES);

				PVRTMat4	mBoneWorld;
				int			i32NodeID;

				//	Iterate through all the bones in the batch
				for(int j = 0; j < pMesh->sBoneBatches.pnBatchBoneCnt[i32Batch]; ++j)
				{

				glCurrentPaletteMatrixOES(j);

					// Generates the world matrix for the given bone in this batch.
					i32NodeID = pMesh->sBoneBatches.pnBatches[i32Batch * pMesh->sBoneBatches.nBatchBoneMax + j];
					m_Scene.GetBoneWorldMatrix(mBoneWorld, *pNode, m_Scene.pNode[i32NodeID]);

					
					// Multiply the bone's world matrix by our transformation matrix and the view matrix
					mBoneWorld = m_mView  * mBoneWorld;

					// Load the bone matrix into the current palette matrix.
					glLoadMatrixf(mBoneWorld.f);
				}
			}
			else
			{
				//If we're not skinning then disable the matrix palette.
				glDisable(GL_MATRIX_PALETTE_OES);
			}

			//Switch to the modelview matrix.
			glMatrixMode(GL_MODELVIEW);

			// Calculate the number of triangles in the current batch
			int i32Tris;

			if(i32Batch + 1 < pMesh->sBoneBatches.nBatchCnt)
				i32Tris = pMesh->sBoneBatches.pnBatchOffset[i32Batch+1] - pMesh->sBoneBatches.pnBatchOffset[i32Batch];
			else
				i32Tris = pMesh->nNumFaces - pMesh->sBoneBatches.pnBatchOffset[i32Batch];

			// Indexed Triangle list
			if(pMesh->nNumStrips == 0)
			{
				
					glDrawElements(GL_TRIANGLES, i32Tris * 3, GL_UNSIGNED_SHORT, &((unsigned short*)0)[3 * pMesh->sBoneBatches.pnBatchOffset[i32Batch]]);
				
				
				
			}
			else // Indexed Triangle strips
			{
				int i32TrisDrawn = 0;

				while(i32TrisDrawn < i32Tris)
				{
					
						glDrawElements(GL_TRIANGLE_STRIP, pMesh->pnStripLength[i32Strip]+2, GL_UNSIGNED_SHORT, &((GLshort*)0)[i32Offset]);
									
					
					
					i32Offset += pMesh->pnStripLength[i32Strip]+2;
					i32TrisDrawn += pMesh->pnStripLength[i32Strip];

					++i32Strip;
				}
			}
		}
		
		if(!pMesh->sBoneBatches.nBatchCnt)
		{
			
				glDrawElements(GL_TRIANGLES, pMesh->nNumFaces*3, GL_UNSIGNED_SHORT, 0);
			
			
			
		}
		
		if(bSkinning)
		{
			glDisableClientState(GL_MATRIX_INDEX_ARRAY_OES);
			glDisableClientState(GL_WEIGHT_ARRAY_OES);

			// We are finished with the matrix pallete so disable it.
			glDisable(GL_MATRIX_PALETTE_OES);
		}
		else
		{
			//Reset the modelview matrix back to what it was before we transformed by the mesh node.
			glPopMatrix();
		}
	}

		
	// Disable States
	glDisableClientState(GL_VERTEX_ARRAY);
	glDisableClientState(GL_NORMAL_ARRAY);
	glDisableClientState(GL_TEXTURE_COORD_ARRAY);

	// unbind the vertex buffers as we don't need them bound anymore
	glBindBuffer(GL_ARRAY_BUFFER, 0);
	glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);

}


注意しなければならないのは、Android版の場合m_Extensionというクラス(OpenGL Extensionのメソッドをロードするクラス)がエクテンションの関数をロードできない(NDKには、Extensionの関数をロードする関数が実装されてない)ので、エクステンションの関数はOpenGLのextensionの関数そのまま使うようにしなければならないことです。

ただしここでも罠があって、そのままの状態だとNDKのOpenGL ESの関数定義には、matrix_palleteのExtensionが使える関数定義がされないでincludeされるので、glext.hがインクルードされる前にdefineで、次の定義をしておきます。

それがGL_GLEXT_PROTOTYPESで、

#define  GL_GLEXT_PROTOTYPES


と、おまじない程度に書いておきましょう。

ここでは、テクスチャの部分も変えないと「pvr」という特殊な画像形式を読むようになっているのですが、そこはlibpngなどを使って、読み込み部分を書いたりするのでしょうが、検索すれば、NDKを使った読み込みの仕方が書いてあるサイトもあるので、そこを参照してください。また、もう一つ頂点をロードしている部分も書き換えます。次のように書き換えます。ここもiPhoneと一緒のコードです。

bool OGLESIntroducingPOD::LoadVbos(CPVRTString* pErrorStr)
{
        if(m_Scene.nNumMesh == 0) // If there are no VBO to create return                                                        
                return true;
        if(!m_puiVbo)
                m_puiVbo = new GLuint[m_Scene.nNumMesh];

        if(!m_puiIndexVbo)
                m_puiIndexVbo = new GLuint[m_Scene.nNumMesh];


        glGenBuffers(m_Scene.nNumMesh, m_puiVbo);

        for(unsigned int i = 0; i < m_Scene.nNumMesh; ++i)
          {
            SPODMesh& Mesh = m_Scene.pMesh[i];
            unsigned int uiSize = Mesh.nNumVertex * Mesh.sVertex.nStride;

            glBindBuffer(GL_ARRAY_BUFFER, m_puiVbo[i]);
            glBufferData(GL_ARRAY_BUFFER, uiSize, Mesh.pInterleaved, GL_STATIC_DRAW);                                                                 
            m_puiIndexVbo[i] = 0;

            if(Mesh.sFaces.pData)
              {
                glGenBuffers(1, &m_puiIndexVbo[i]);
                uiSize = PVRTModelPODCountIndices(Mesh) * sizeof(GLshort);
                glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_puiIndexVbo[i]);
                glBufferData(GL_ELEMENT_ARRAY_BUFFER, uiSize, Mesh.sFaces.pData, GL_STATIC_DRAW);
              }
          }

        glBindBuffer(GL_ARRAY_BUFFER, 0);
        glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);

        return true;
}


ライブラリ使用のサンプル


実際にライブラリを使用してサンプルを作ってみました。データ的には、秒間約50万ポリゴンぐらいですが、秒間100万ポリゴンぐらいでも大丈夫なようです。この数値がどのくらいかというと、PSPぐらいの性能というとわかりやすいんでしょうか。


サンプル動画




サンプル画像


写真 3 写真 2
写真 1 写真
device


変な CAPTCHA だっていいじゃないか にほんじんだもの

カテゴリ
ブックマーク数
このエントリーを含むはてなブックマーク はてなブックマーク - 変な CAPTCHA だっていいじゃないか にほんじんだもの
このエントリーをはてなブックマークに追加
こんにちは。開発のシニアスペシャリストをやっております、通称「にぽたん」こと谷口公一です。

私たちが日頃使っているネット上のサービスで、web 上のフォームの最後等に「ここに書かれている文字を入力してください」のように、絵に描かれた文字を目で読み取って入力するよう求められる場合があります。
言うまでもありませんが、これは通称「キャプチャ (CAPTCHA)」と呼ばれているもので、画像データは、人間ならそれを肉眼で認識して入力が行なえますが、機械には少し難しくなるため、フォームに文字を入力しているのが、機械ではないことを判定するためにこういう仕組みが存在します。

世の中にある CAPTCHA は、やたらと字がグチャグチャで読み取るのが非常に困難なケースもあります。
これは当然、安易に、白地に黒い文字を書いたような単純な画像を CAPTCHA に用いれば、それを解読するシステムは簡単に開発出来てしまいますので、「肉眼で認識」をしていない機械がこのフォームを通過してしまいます。それを防ぐためにわざと解読しにくいようにそうしているのです。
解読しにくいようにする工夫といえば、ざっと
  • 表示色を変える
  • ドットや線などの不規則なノイズを加える
  • フォントを数種類使う
  • 文字の表示位置を揃えない
  • 文字をひねる
等ですが、これらを複数組み合わせて行なうことによって、より機械による判別が困難になります。

我々が提供する livedoor のシステムは、主に Perl で開発されており、Perl で CAPTCHA を生成するためには、GD::SecurityImage という CPAN モジュールが広く使用されています。
このモジュールは、GDImage::Magick をバックエンドとして利用出来ますが、GD とか Image::Magick とかって、若干イマドキではない感があって、このモジュールを使うためだけにインストールするのはあまり嬉しくありません。
とりあえず、そんな理由から、GD 等よりもモダンな Imager を使って、CAPTCHA を作ってみたいとおもいます。

ついでに言うと、私達は日本人向けのサービスを主に作っていますので、アルファベットや数字の組み合わせのみの CAPTCHA よりは、日本語文字を使ったほうが、非日本語圏からのアタックが減るし、より一層機械による解読が困難になるのでは?と何気なく思いつきました。
GD::SecurityImage で日本語を扱うのはどうも一筋縄ではいかないようですが、Imager を使って自作してしまえば簡単に出来そうな気もします。

ということで、今回は Imager で CAPTCHA を作ってみました。

#!perl

use strict;
use warnings;
use POSIX qw(floor);
use Imager;

my $width  = 250; # CAPTCHA の幅
my $height = 50;  # CAPTCHA の高さ

# 2 つの引数の間の整数をランダムで返す
sub random {
    my($min, $max) = sort { $a <=> $b } @_;
    return floor($min + rand($max - $min + 1));
}

# 2 つの色の間の色をランダムで返す
sub random_color {
    my($color_a, $color_b) = @_;
    my @rgb_a = map { hex $_ }
        $color_a =~ /^\#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i;
    my @rgb_b = map { hex $_ }
        $color_b =~ /^\#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i;
    my $r = random($rgb_a[0], $rgb_b[0]);
    my $g = random($rgb_a[1], $rgb_b[1]);
    my $b = random($rgb_a[2], $rgb_b[2]);
    return sprintf '#%02x%02x%02x', $r, $g, $b;
}

# CAPTCHA を作る
my $imager = Imager->new(xsize => $width, ysize => $height);
$imager->box(filled => 1, color => random_color('#cccccc', '#ffffff'));
とりあえず、こんなんしておいて、
$imager->write(file => 'captcha.png', type => 'png');
すると、

ただの四角

このような横 250 x 50 の適当な色の四角が出来ました。
あ、ちなみに、これは実行するたびに色がコロコロかわります。

では、ここに 200 〜 300 個程度のブツブツを加えて、コンニャクっぽくします。
for (1 .. random(200, 300)) {
    $imager->setpixel(
        x     => random(0, $width - 1),
        y     => random(0, $height - 1),
        color => random_color('#000000', '#666666'),
    );
}


コンニャクみたい

まさにコンニャクっぽくなりましたけど、なんか、点がドット感が強くて嫌な感じなので、軽くガウスぼかしをかけます。
$imager->filter(type => 'gaussian', stddev => 0.5);


もっとコンニャクみたい

より一層コンニャクっぽくなりました。おいしそう。

次に適当な線を 3 〜 5 本程度入れます。
for (1 .. random(3, 5)) {
    $imager->line(
        color => random_color('#000000', '#666666'),
        x1    => random(0, $width - 1),
        y1    => random(0, $height - 1),
        x2    => random(0, $width - 1),
        y2    => random(0, $height - 1),
        aa    => 1,
    );
}


だいぶ邪魔くさい

いい感じで邪魔くさい線が現れました。

次にいよいよ文字を入れるのですが、適当な文字を返す関数を作ります。
ひらがなとカタカナを返すようにしましょう。
use List::Util qw(shuffle);
use utf8;

sub random_char {
    my($min, $max) = @_;
    my $length = random($min, $max);
    my @chr = ();
    push @chr, map { chr($_) } (ord('あ') .. ord('ん'));
    push @chr, map { chr($_) } (ord('ア') .. ord('ン'));
    return join '', (shuffle(@chr))[0 .. $length - 1];
}
これで、random_char()を呼び出すと、指定した文字数内で適当に組み合わせたひらがなとカタカナを組み合わせた単語が返ります。

次にフォントのオブジェクトを作ります。
use Imager::Font;

my @fonts = map { sprintf '/path/to/%s.ttc', $_ } qw(msgothic msmincho);

my $font = Imager::Font->new(
    file => (shuffle(@fonts))[0],
    size => 35,
    aa   => 1,
    type => 'ft2',
);

my $word;
my $bbox;
while (1) {
    $word = random_char(5, 8);
    $bbox = $font->bounding_box(string => $word);
    next if $bbox->total_width >= $width;
    last;
}
5 〜 8 文字程度のランダムな文字を作りますが、文字を生成した時に、文字の表示幅が全体枠の大きさを超えないように、ハミ出た場合はもう一度別の単語を作るようにします。

そして、単語が決定したら、文字を加工しながら一文字一文字乗せていきます。
use Imager::Matrix2d;

my $x = floor(($width - $bbox->total_width) / 2);
for my $char (split //, $word) {
    my $bbox = $font->bounding_box(string => $char);
    # 軽くヒネリを加えます
    my $matrix = Imager::Matrix2d->shear(
        x => (random(-3, 3) / 10),
        y => (random(-3, 3) / 10),
    );
    $font->transform(matrix => $matrix);
    # 高さはランダムに配置します
    my $y = random(10, floor($height - $bbox->font_height) * 2 - 10);
    $imager->string(
        align  => 0,
        x      => $x,
        y      => $y,
        halign => 'center',
        string => $char,
        font   => $font,
        color  => random_color('#000000', '#666666'),
    );
    $x += $bbox->total_width;
}


ネみスぢピニゾ

もう、この時点でだいぶ CAPTCHA として十分なものが出来上がりました。

しかし、文字が一番上にあるので、色を辿ると文字が解読出来そうな感じなので、その上に被せる別のノイズも加えておきます。

for (1 .. random(10, 15)) {
    my $angle = random(0, 360);
    my $x = random(0, $width);
    my $y = random(0, $height);
    my $radius = random(0, $width - $x);
    $imager->arc(
        color => random_color('#000000', '#666666'),
        x     => $x,
        y     => $y,
        r     => $radius,
        d1    => $angle,
        d2    => $angle + 1,
        aa    => 1,
    );
}


にわゾあのラ

こんな感じで、とりあえず、だいぶ CAPTCHA っぽくなりましたね。

今回は Imager を使って CAPTCHA 的なものを簡単に作る手順の紹介にかこつけ、なんとなく一度作ってみたかった日本語 CAPTCHA を作ってみました。

実際にこれを CAPTCHA としてフォームで利用する場合は、画像をどのように出力するか (ファイルを吐き出すか、WAF の中からそのまま画像データを出力するのかとか、キャッシュとかどうするのかとか) や、本当に日本語でいいのか (数字だけにするとか、英語にするならランダムにするのかとか、/usr/share/dict/words 等から適当な単語を引っ張ってくるようにするのかとか) や、紛らわしい文字 (拗音や促音、濁点や半濁点) を含めるのか、フォームから submit された文字の validation 方法をどうするのか、等々について考慮する必要があり、これを表示するだけで済む問題ではありませんが、これを元に Imager を使ってあなた好みの CAPTCHA を作られてみてはいかがでしょうか。

それでは今年もよろしくお願いします。

#!perl

use strict;
use warnings;
use utf8;
use POSIX qw(floor);
use Imager;
use Imager::Font;
use Imager::Matrix2d;
use List::Util qw(shuffle);

my $width  = 250; # CAPTCHA の幅
my $height = 50;  # CAPTCHA の高さ

# 2 つの引数の間の整数をランダムで返す
sub random {
    my($min, $max) = sort { $a <=> $b } @_;
    return floor($min + rand($max - $min + 1));
}

# 2 つの色の間の色をランダムで返す
sub random_color {
    my($color_a, $color_b) = @_;
    my @rgb_a = map { hex $_ }
        $color_a =~ /^\#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i;
    my @rgb_b = map { hex $_ }
        $color_b =~ /^\#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i;
    my $r = random($rgb_a[0], $rgb_b[0]);
    my $g = random($rgb_a[1], $rgb_b[1]);
    my $b = random($rgb_a[2], $rgb_b[2]);
    return sprintf '#%02x%02x%02x', $r, $g, $b;
}

# CAPTCHA を作る
my $imager = Imager->new(xsize => $width, ysize => $height);
$imager->box(filled => 1, color => random_color('#cccccc', '#ffffff'));

for (1 .. random(200, 300)) {
    $imager->setpixel(
        x     => random(0, $width - 1),
        y     => random(0, $height - 1),
        color => random_color('#000000', '#666666'),
    );
}

$imager->filter(type => 'gaussian', stddev => 0.5);

for (1 .. random(3, 5)) {
    $imager->line(
        color => random_color('#000000', '#666666'),
        x1    => random(0, $width - 1),
        y1    => random(0, $height - 1),
        x2    => random(0, $width - 1),
        y2    => random(0, $height - 1),
        aa    => 1,
    );
}

sub random_char {
    my($min, $max) = @_;
    my $length = random($min, $max);
    my @chr = ();
    push @chr, map { chr($_) } (ord('あ') .. ord('ん'));
    push @chr, map { chr($_) } (ord('ア') .. ord('ン'));
    return join '', (shuffle(@chr))[0 .. $length - 1];
}

my @fonts = map { sprintf '/path/to/%s.ttc', $_ } qw(msgothic msmincho);

my $font = Imager::Font->new(
    file => (shuffle(@fonts))[0],
    size => 35,
    aa   => 1,
    type => 'ft2',
);

my $word;
my $bbox;
while (1) {
    $word = random_char(5, 10);
    $bbox = $font->bounding_box(string => $word);
    next if $bbox->total_width >= $width;
    last;
}

my $x = floor(($width - $bbox->total_width) / 2);
for my $char (split //, $word) {
    my $bbox = $font->bounding_box(string => $char);
    # 軽くヒネリを加えます
    my $matrix = Imager::Matrix2d->shear(
        x => (random(-3, 3) / 10),
        y => (random(-3, 3) / 10),
    );
    $font->transform(matrix => $matrix);
    # 高さはランダムに配置します
    my $y = random(10, floor($height - $bbox->font_height) * 2 - 10);
    $imager->string(
        align  => 0,
        x      => $x,
        y      => $y,
        halign => 'center',
        string => $char,
        font   => $font,
        color  => random_color('#000000', '#666666'),
    );
    $x += $bbox->total_width;
}

for (1 .. random(10, 15)) {
    my $angle = random(0, 360);
    my $x = random(0, $width);
    my $y = random(0, $height);
    my $radius = random(0, $width - $x);
    $imager->arc(
        color => random_color('#000000', '#666666'),
        x     => $x,
        y     => $y,
        r     => $radius,
        d1    => $angle,
        d2    => $angle + 1,
        aa    => 1,
    );
}

$imager->write(file => 'captcha.png', type => 'png');

mod_rewrite マニアックス

カテゴリ
ブックマーク数
このエントリーを含むはてなブックマーク はてなブックマーク - mod_rewrite マニアックス
このエントリーをはてなブックマークに追加

こんにちは。開発部の池邉です。

既に色んなところで発表していますが、ライブドアではWebサーバとして殆んどのサービスでApacheを使用しています。
Apache の特徴として、モジュールによる機能の追加、挙動の制御があります。その中でもよく利用されていながら、深く使っていくとハマりどころや謎の機能の多いモジュールとして mod_rewrite があります。Apacheの公式サイトでも以下のように書かれている事からも、本家でもその点については認めているという事でしょう。
今回はそんな mod_rewrite のちょっとマニアックな拡張方法について紹介したいと思います。
Despite the tons of examples and docs, mod_rewrite is voodoo. Damned cool voodoo, but still voodoo.

変数を利用したプログラム的な設定

ライブドアでは携帯電話等の特定の端末を制御するため、時間指定で特定のページを見せるため等の理由で mod_rewrite を多用しています。mod_rewrite ではサーバ内部の状態、クライアントから送信されるヘッダ等の情報を変数として扱い、プログラム的な制御構造を持った設定を記述する事が出来ます。

たとえば、ある決められた日付だけページを差し替える場合の RewriteRule は以下のようになります。この設定では、2010年の4月1日のエイプリルフールだけ別のファイルを見せる事が出来ます。
 RewriteEngine On
 RewriteCond %{TIME_YEAR}%{TIME_MON}%{TIME_DAY} =20100401
 RewriteRule ^/$ /path/to/april_fool.html [L]
mod_rewrite で参照出来る変数の種類は非常に多く、大抵の事が出来るのですが、その結果として httpd.conf は巨大になり、テキストファイルながら 数10KB程度になってしまいます。mod_rewrite では、Apache のディレクティブの記法でプログラミング的な制御構造を表現するため、プログラミング言語に比べてどうしても冗長で直感的では無い記述になってしまいます。ある程度複雑な条件になってくると、不自由な mod_rewrite のディレクティブでは無く、まともな言語でプログラムを書いた方が楽に思えてきます。

独自のプログラムでの制御

mod_rewrite では独自のプログラムで URL の変換を行う仕組みもあります。RewriteMap の MapType で prg を指定する事で、外部プログラムを使用する事が出来ます。任意の言語でプログラミングが出来るため、一見便利に見えますが、RewriteLock ディレクティブによるロックが必要だったり、外部プロセスなので遅いというような問題があり、あまり使われていないのが実際のところです。
 RewriteLock /tmp/rewritelock
 # RewriteMap で使用する Map の種類とプログラムまでのパスを指定
 RewriteMap example prg:/path/to/example.pl
 RewriteRule ^/(.*) ${example:$1}

#!/usr/bin/env perl
use strict;

# 標準入力から読み込み、値を書き換えて標準出力に出力する。
$| = 1;
while(my $line = ) {
    chomp $line;
    my $path = "/". $line. "-foo";
    print $path, "\n";
}

C言語での拡張

RewriteMap のMapType に指定出来る値は以下の5種類です。この中で int は Internal Function の略で、内部的な関数を呼びだします。
  • txt
  • rnd
  • dbm
  • int
  • prg
公式ドキュメントでは以下のように記述されており、独自の関数は作れない事になっています。しかし、ソースコードを読んでみると、独自の関数を追加する仕組みが仕込まれている事が分かります。
MapType: int, MapSource: Internal Apache function
Here, the source is an internal Apache function. Currently you cannot create your own, but the following functions already exist:
mod_rewrite に独自の RewriteMap 用の関数を追加するためには mod_rewrite から公開されている ap_register_rewrite_mapfunc という関数を使用して Apache モジュールを書きます。
外部プログラムと比較しての利点としては、Apache のライフサイクルの中で実行されるため、外部プログラムと比べて高速であり、request_rec 構造体を扱えるのでHTTPヘッダや Cookie のような HTTP 固有の情報を容易に扱う事が出来ます。値の書き換えだけを実装し、肝心の URL 変換部分は mod_rewrite にまかせてしまうため、Apache モジュールとしては非常に少ない行数で済むのも利点です。
#include "httpd.h"
#include "http_config.h"
#include "http_protocol.h"
#include "ap_config.h"
#include "apr_strings.h"
#include "mod_rewrite.h"

static char *rewrite_myfunc_handler(request_rec *r, char *key) {
    /* 末尾に "-foo" という文字列を連結 */
    return apr_pstrcat(r->pool, key, "-foo", NULL);
}

static int rewrite_myfunc_pre_config(apr_pool_t *pconf, apr_pool_t *plog, apr_pool_t *ptemp) {
    APR_OPTIONAL_FN_TYPE(ap_register_rewrite_mapfunc) *map_pfn_register;
    map_pfn_register = APR_RETRIEVE_OPTIONAL_FN(ap_register_rewrite_mapfunc);
    if (map_pfn_register) {
        /* 関数を登録 */
        map_pfn_register("myfunc", rewrite_myfunc_handler);
    }
    return OK;
}

static void rewrite_myfunc_register_hooks(apr_pool_t *p) {
    ap_hook_pre_config(rewrite_myfunc_pre_config, NULL, NULL, APR_HOOK_MIDDLE);
}

/* Dispatch list for API hooks */
module AP_MODULE_DECLARE_DATA rewrite_myfunc_module = {
    STANDARD20_MODULE_STUFF, 
    NULL,                  /* create per-dir    config structures */
    NULL,                  /* merge  per-dir    config structures */
    NULL,                  /* create per-server config structures */
    NULL,                  /* merge  per-server config structures */
    NULL,                  /* table of config file commands       */
    rewrite_myfunc_register_hooks  /* register hooks                      */
};

最後に

今回は mod_rewrite の拡張方法として、C言語での拡張方法を紹介しました。少し前に mod_rewirte のソースを読んでいて、拡張が出来るという事を発見したのが嬉しかったのでエントリーにしてみました。
いつも使い慣れてるソフトウェアも、ちゃんとソースコードを読むのは大事だなとあらためて思った次第です。

それでは今年もよろしくお願いします。

気持ち悪いと思う感覚

カテゴリ
ブックマーク数
このエントリーを含むはてなブックマーク はてなブックマーク - 気持ち悪いと思う感覚
このエントリーをはてなブックマークに追加
ネットサービス事業部のコミュニティサービス部、検索ビジネス部の開発グループ)シニアマネージャをしている笹塚です。
伊勢さんから新年なので何か書こうよ!と指示がきたので、担当しているコンテンツの紹介と、開発の雰囲気が分かっていただけそうなエントリーを書かせていただこうかなと思います。技術ネタは次回!

検索ビジネス部では、livedoorポータルで運営している各検索サービス(サイト検索ブログ検索動画検索など)の担当と、 100%子会社のJリスティングの開発(Jアドリスティング、Jエントリーなど)のお手伝いもさせていただいています。
コミュニティサービス部では、youbride.jp(婚活サイト)、YYC(出会い応援サイト)、DLsiteモバイル(同人誌)といった、ちょっとキャラの濃い(?)コンテンツの開発を担当しています。

この中で、youbrideとYYCはすでに10年間運用を続けており、システム面でもコンテンツ面でも課題が多くあります。
負荷対策やシステムの一部リプレースと同時にコンテンツの新機能の追加も行っているため、いつでも直せそうな細かい項目については、気にはなっているもののどうしても後回しになっていました。
他の事を何もしない時間を確保して一気に進めたいのですが、部全体でまとまった時間を平日に用意するのは難しく悶々としていたのですが、部長と相談してみたところ「土曜日やりましょう!」というお言葉をいただき、つい先日実施できました。 今回は、そのときの様子をご紹介したいと思います。

■参加人数

部長、プログラマ4名、ディレクタ4名の合計9名

■当日の様子

12:00〜13:00 : ピザ

photo4

休日出勤でこの人数いたら頼まないといけない空気が。おいしゅうございました。

13:00〜15:30 : 改善ポイントの洗い出し

参加者全員まとまって意見するとぼんやりしそうだったので、3チーム(A、B、C)にわかれ、それぞれのグループで話ながらコンテンツの気になるところを出し合いました。
ツールは社内irc。ただのぼやきと改善ポイントの発言が混ざるので、改善ポイントの発言には頭に★つけてガシガシあげていきます。
他チームの発言も斜め読みしていたおかげか、後半の各グループの擦り合わせでもあまり重複項目はありませんでした。

2時間強で出した数は以下のような感じ。

  • Aチーム : 15
  • Bチーム : 76
  • Cチーム : 60 <- 私ここ

「○○の絵文字がいけてない」「○○のスペースが気になる」や、「改行がないので文字が繋がってる」など細かいものがあるとはいえ、多いですね。。

Aチームは見つけたところから修正を同時に進めていたので数としては控えめでした。 最初は見つけ次第直していこうと話をしていたのですが、修正は後にしたほうが効率が良さそうだったので進め方を変えることになり、その連絡が(自分のチームに夢中で)うまく伝わってなかったようです。すいませーん。
量が多かったのがショックだったのか、それとも飽きたのか、このあたりで部長が黄昏れるというアクシデント発生。放置して次に進みます。

photo1

部長、せめて自分であげたブラインドは降ろしてください。お願いします。

15:30〜16:30 : 対応可能かどうかの判断

各グループごとに項目を精査しつつ、対応できるかどうかを4段階に分類しました。

  • a. 今日修正できる
  • b. 後日修正できる
  • c. 修正には検討が必要
  • d. 保留

a.とb.は分類する必要はなかったんですが、すぐに対応できるものとちょっと時間が欲しいものというニュアンスでわけました。

photo2

日頃からだいぶ細かくコンテンツに突っ込みを入れる部長ですが、この日は途中でミーティングのために中途半端なまま離席という事がなかったため、メンバーとじっくり話ができたようです。

16:30〜18:00 : 各グループの結果を全員で共有

それぞれのグループで重複していた項目をつぶしたり、検討が必要としてあげた項目について決められるものについては決めてしまい、今日もしくは後日修正というステータスに変更しました。
最終的には修正可能が全体の6割程度、残りは検討が必要ということで落ち着きました。(保留は6,7個です)。

18:00〜20:30 : 修正可能な項目について対応

今日もしくは後日修正できるとした項目についての修正作業と本番化をしました。 改行の位置やスペース、細かな導線、絵文字の変更などで約40箇所修正できました。

photo3

同じグループのディレクタが「テンプレートの修正なら任せろ」と言ってくれたので、ディレクタに修正を押し付けたの図。
嫌がっているようにも見えますが気にしないことにします。

21:00〜 飲み

おつかれ様でしたっ

DSC00225

あ、参加したみなさん。振休はちゃんととってくださいね。

ノープランでスタートしたので迷走するかなぁとも思っていましたが、思ったよりうまく回りました。
特に、誰がどこを気持ち悪いと思っていたのかを共有できたのは、UIの暗黙知の共有に効果がありそうです。あとは残りの項目を潰しながら、通常業務でも気持ち悪いところを見つけたら直していく癖をつけていければと思います。

開発のリソースも時間も限られていますが、今後もいろいろなスタイルを試して、売り上げも達成しつつ、「気持ち悪いと思う感覚」を大切にコンテンツも磨き続けていく、そんな一年にしたいと思っています。

本年もよろしくお願い致します。