プログラミング」カテゴリーアーカイブ

iOSDC JAPAN 2017

iOSDC JAPAN 2017の前夜祭で「Objective-C++を使ってMRCで快適に開発する」という発表をしてきました。

Objective-Cに関するCfpは少ないだろうから選ばれる可能性が高そうだったのと、マニアックな話題にすると前夜祭に選ばれて発表が終わった後にスッキリとした気持ちで本編を見ることができるだろうという見込みがピッタリはまって、前夜祭で発表することとなりました。

「すごく参考になりました!早速使ってみます!」みたいなコメントは僕の知る限りひとつもいただけなかったのですが(それは当然)、すでにObjective-C++を使っている方が少なからずいらっしゃることもわかりましたし、MRCで書くかどうかはさておいてObjective-C++はパフォーマンスを追求するような分野では細々と使われていくのではないかなという印象です。

発表の内容に関して興味のある方は、以下のスライドやサンプルコードをご参照ください。

スライド


https://speakerdeck.com/objectiveaudio/objective-c-plus-plus-woshi-tutemrcdekuai-shi-nikai-fa-suru

サンプルコード

https://github.com/objective-audio/objc_ptr_sample

来年も開催されるようでしたら、またマニアックなネタで前夜祭を狙っていきたいと思います。

AVAudioEngine

iOS8からAVAudioEngineという新たなオーディオの機能がAVFoundationに追加されました。AVAudioEngineというのはAUGraphのObjective-C版のような感じのものです。オーディオのプレイヤーやエフェクトやミキサーなどがNodeと呼ばれる部品で用意されていて、それらを繋いで音を鳴らす事が出来ます。

AUGraphよりもかなり簡単に使えるようになっているのですが、その反面、AUGraphを使うときのように細かい設定が出来るわけではなかったり、AUGraphで出来た事が出来なかったりする部分もあるので、いままでAUGraphを使っていた人にとっては物足りないかもしれません。でも、そんなに複雑な事をやらないのであれば、簡単にエフェクトをかけられたりするので便利だと思います。

AVAudioEngineに関してはWWDC2014でも解説されていますので、ちゃんとしたApple公式の解説を聞きたい・読みたいという方はそちらをご覧ください。

#501 – What’s New in Core Audio
#502 – AVAudioEngine in Practice

他だと、@shu223さんのiOS8-SamplerというgitリポジトリにもAVAudioEngineのサンプルコードが入っているようですので、参考にされると良いと思います。
https://github.com/shu223/iOS8-Sampler

サンプルコード

では簡単に、AVAudioEngineでオーディオファイルを鳴らして見たいと思います。Xcode 6でSingle View Applicationなどで適当なiOSのプロジェクトを作ったら、ViewController.mを以下のコードに差し替えてください。

//
//  ViewController.m
//
#import "ViewController.h"
#import <avfoundation/AVFoundation.h>
@interface ViewController ()
@property (nonatomic) AVAudioEngine *audioEngine;
@property (nonatomic) AVAudioUnitReverb *reverbNode;
@property (nonatomic) AVAudioPlayerNode *playerNode;
@property (nonatomic) AVAudioFile *audioFile;
@end
@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    if (![self setup]) {
        NSLog(@"AudioEngine Setup Failed.");
    }
    [self play:nil];
}
- (BOOL)setup
{
    NSError *error = nil;
    // AudioEngineの生成
    self.audioEngine = [[AVAudioEngine alloc] init];
    // オーディオファイルの生成
    NSURL *fileURL = [[NSBundle mainBundle] URLForAuxiliaryExecutable:@"Rhythm.wav"];
    self.audioFile = [[AVAudioFile alloc] initForReading:fileURL error:&error];
    if (error) {
        return NO;
    }
    // オーディオプレイヤーノードの生成
    self.playerNode = [[AVAudioPlayerNode alloc] init];
    // リバーブエフェクトノードの生成
    self.reverbNode = [[AVAudioUnitReverb alloc] init];
    [self.reverbNode loadFactoryPreset:AVAudioUnitReverbPresetMediumHall];
    self.reverbNode.wetDryMix = 50.0f;
    // ノードをAudioEngineへ追加
    [self.audioEngine attachNode:self.playerNode];
    [self.audioEngine attachNode:self.reverbNode];
    // ノードを接続
    [self.audioEngine connect:self.playerNode
                           to:self.reverbNode
                       format:self.audioFile.processingFormat];
    [self.audioEngine connect:self.reverbNode
                           to:self.audioEngine.mainMixerNode
                       format:self.audioFile.processingFormat];
    // AudioEngineの開始
    if (![self.audioEngine startAndReturnError:&error]) {
        return NO;
    }
    return YES;
}
- (IBAction)play:(id)sender
{
    // オーディオプレイヤーノードの開始
    [self.playerNode play];
    // オーディオファイルを再生
    [self.playerNode scheduleFile:self.audioFile
                           atTime:nil
                completionHandler:nil];
}
@end

また、何かオーディオファイルをXcodeのプロジェクトに追加して”Rhythm.wav”と名前を付けてください。逆に、コード上の”Rhythm.wav”の部分を、用意したオーディオのファイルの名前に書き換えてもかまいません。拡張子はwavにしていますが、mp3でも何でも、iOSで普通にならせるオーディオのフォーマットであれば何でも構いません。

オーディオファイルを追加したら、ビルドして実行すると1回オーディオファイルが再生されると思います。UIButtonとかをplay:メソッドに接続すればタップして何度も鳴らす事が出来ます。

解説

上記のコードでは以下の図のような構成でAudioEngineをセットアップしています。ただ音を鳴らすだけではAVAudioEngineを使う意味が無いので、オーディオファイルの音にリバーブをかけて再生しています。

20140614AVAudioEngine.001

セットアップの全体の流れは、「AudioEngineを生成」→「Nodeを生成」→「必要があればNodeのプロパティなどを設定(だいたい後でも可)」→「Node同士を接続」→「AudioEngineをスタート」という感じです。Nodeの接続がうまくいってない場合はAudioEngineをスタートする(startAndReturnError:メソッドを呼ぶ)時点でエラーが返ってきます。

クラス

AVFoundationにAVAudioEngine関連で追加されたクラスには以下のものがあります。

  • AVAudioEngine(オーディオグラフ全体の管理)
  • AVAudioNodeとそのサブクラス(グラフ内のオーディオの部品)
  • AVAudioBuffer・AVAudioPCMBuffer(オーディオのバッファ)
  • AVAudioFile(オーディオファイル)
  • AVAudioFormat(オーディオのフォーマット。AudioStreamBasicDescription+αの情報)
  • AVAudioTime(オーディオ時間。再生するタイミングなど)
  • AVAudioChannelLayout、他
  • 以前からAVFoundationにあったクラスと結構名前が似通ったものがあるので注意してください(AVAudioPlayerとAVAudioPlayerNodeや、AVAudioMixとAVAudioMixingなど)。あとAudioUnitEffectとそのサブクラスはAVAudioNodeのサブクラスではあるのですが、クラス名にNodeと付いていないのでちょっと分かりにくいかもしれません。

    ノード同士の接続

    Node同士を接続するにはconnect:to:format:メソッドを使います。「接続元のNode」と「接続先のNode」と「接続間のフォーマット」を引数に渡します。フォーマットに関しては、今回みたいにオーディオファイルを鳴らすならAVAudioFileのprocessingFormatにいい感じのフォーマットが入っているのでそれを渡せばOKです。そんなのが無いという場合はAVAudioFormatのinitStandardFormat〜という初期化メソッドがあるのでそれで生成すれば、いい感じのやつを作ってくれます。AVAudioEngineではAudioStreamBasicDescriptionの全要素を自分で埋める場面というのはほとんど無いと思います。

    なお、セットアップ図にある「AVAudioMixerNode」と「AVAudioOutputNode」というのは最初から「mainMixerNode」と「outputNode」というプロパティでAVAudioEngineに用意されていて、なおかつmainMixerNodeとoutputNodeは最初から接続もされています。最初からAVAudioEngineに用意されているものはほかにも、inputNodeやmusicSequenceなどがあります。

    また、サンプルでは使っていませんがNodeのBusを指定して接続するメソッド(connect:to:fromBus:toBus:format:)もあって、ミキサーに複数接続する場合は別々のBusを指定して接続すれば同時に複数のNodeを繋げて鳴らす事が出来ます。

    オーディオプレイヤー

    オーディオファイルの再生には「AVAudioPlayerNode」というNodeを使っています。これはAVFoundationのAVAudioPlayerとは使い勝手が違って、AudioUnitのAudioFilePlayerがベースになっています。AVAudioPlayerのようにオーディオファイルのオブジェクトがあってそれを再生するというのではなく、プレイヤーがあって、そこにいろいろなオーディオファイルの鳴らしたいタイミングを登録していくという感じです。サンプルではatTimeのところの引数がnilなのですぐに鳴りますが、ここにAVAudioTimeで時間を指定すれば何秒後に鳴らすといったことが可能です。また、複数登録しておく事も出来ますが、ひとつのプレイヤーからは同時にひとつの音源しか鳴らせないので、複数のオーディオファイルを同時に鳴らしたいという場合はプレイヤーを複数作る必要があります。

    AVAudioPlayerNodeではオーディオファイルを直接ならす以外にも、AVAudioPCMBufferにオーディオデータを書き込んでおいて、そこから再生する事も出来ます。その場合にはループ再生をするオプションも指定できます。

    ボリューム

    AVAudioEngineの特徴であるのですが、ミキサーのインプット側のボリュームの設定はミキサーには無く、接続元の音源であるNode側にあります。ミキサーでボリューム調整ができるNodeはAVAudioMixingというプロトコルに準拠していてvolumeプロパティを持っています。ミキサーがインプットに接続されているNodeを辿っていって、AVAudioMixingに準拠しているNodeがあったらそのvolumeを見てレベル調整をしているようです。

    まとめ

    AVAudioEngineの利点は、サンプルのコードを見てもらうとわかるとおり、ちょっとエフェクトをかけたいというときにはすごく少ないコード量で実現できるというところです。Swiftからも特に制限無くAVAudioEngineの機能にアクセスできるので、Swift推進派な人には良いかもしれません。

    逆に欠点としては、ProcessingTap的なものが無くて独自の処理をオーディオグラフの途中に挟み込めないということと、SplitterやConverterのNodeがなくて接続の自由度が低いということです。この辺りの機能が追加されるなら、完全にAUGraphからAVAudioEngineに乗り換えられると思うので、今後に期待したいですね。

    mediaServiceWereResetNotificationが呼ばれるタイミング

    AVAudioSessionのmediaServiceWereResetNotificationですが、たまたま確実に呼ばれるタイミングを見つけられたのでメモしておきます。

    1、AudioSessionのCategoryをAVAudioSessionCategoryMultiRouteにする。
    2、iPad本体にヘッドホンを接続し、Lightning端子に外部出力を接続する。
    3、ヘッドホンとLightningケーブルを同時に素早く抜き取る。

    LightningやiPadと書きましたが、たぶんDockケーブルやiPhoneでもMultiRouteな状態からケーブルを一気に引き抜けば同じく呼ばれると思います。呼ばれたときにはAudioSessionがリセットされていたりしますので、オーディオ関連のオブジェクトの再生成とAudioSessionの再セットアップを行いましょう。

    参考:Technical Q&A QA1749

    なかなか普通はMultiRouteを使ったりはしないと思いますのでこのパターンで遭遇する事は無いと思います。もし、他に呼ばれるタイミングをご存知な方がいらっしゃいましたら教えていただけるとうれしいです。

    Multi Route Audio

    iOS6からの機能なので今さら感がありますが、拙作のTouch the Waveでも導入したMulti Route Audioの使い方をまとめておこうと思います。

    Multi Route Audioとは

    USBオーディオインターフェースやHDMIなどの外部出力を接続したときに、ヘッドホンへ別系統の音を出力できる機能です。外部出力2chとヘッドホン2chの計4ch出力する事が出来ます。残念ながらマルチイン・アウトが搭載されているオーディオインターフェースをマルチで使えるという機能ではなく、USBオーディオインターフェースはイン・アウト2chのみで、それとは別にヘッドホンにも別の音をだせるというだけの機能です。

    ちなみにUSBオーディオインターフェースの繋げ方を知らない方に説明しておきますと、カメラコネクションキットまたはUSBカメラアダプタを使ってUSBオーディオインターフェースを接続すると、内蔵スピーカーではなくオーディオインターフェースから音を出す事が出来ます。市販されているたいていのUSBオーディオインターフェースは対応しているはずです。ただし、USB接続に対応しているのはiPadのみで、HDMI接続ならiPhone・iPad両方大丈夫です。また、バスパワーで動作するオーディオインターフェースはほぼ電源供給が足りないので、電源付きのUSBハブを介して接続する必要があります。

    入力が2系統受けられる機能に関してはiOS5から搭載されているようですが、出力に関してはiOS6からの新機能です。今回のエントリでは出力のみ扱います。なお、WWDC2012のSession 505でも解説されていますので、英語に抵抗無い方はApple純正の解説をご覧いただくのも良いと思います。

    音を出す前の準備として大まかに以下のような作業が必要になります。

    ① AudioSessionのCategoryをMultiRouteにする
    ② RouteChangeの通知を受け取る
    ③ 出力ポートの情報を取得する
    ④ 再生する音の出力チャンネルを設定する

    AudioSessionのCategoryをMultiRouteにして、外部オーディオデバイスとヘッドホンを両方接続した状態で初めてMulti Route Audioが使えるようになります。

    実際に音を出すのには、RemoteIOを使う方法と、AVAudioPlayerを使う方法の2つがあります。基本的にRemoteIOの方法を解説します。AVAudioPlayerは個人的に使わないので簡単に触れる程度にします。

    AudioSessionのCategoryをMultiRouteにする

    AVAudioSessionのCategoryにAVAudioSessionCategoryMultiRouteをセットし、通常オーディオを鳴らす前の準備と同じくAudioSessionをアクティブにしてください。

    NSError *error = nil;
       
    [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryMultiRoute error:&error];
    [[AVAudioSession sharedInstance] setActive:YES error:&error];
    

    RouteChangeの通知を受け取る

    通知センターからAVAudioSessionRouteChangeNotificationの通知を受け取るように登録します。これで、オーディオデバイスやヘッドホンを抜き差ししたときに通知が飛んできます。

    // 通知を登録する
    NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
    [center addObserver:self selector:@selector(routeChangeNotification:)
    name:AVAudioSessionRouteChangeNotification object:nil];
    
    // 通知を受け取る
    - (void)routeChangeNotification:(NSNotification *)note
    {
        NSDictionary *dict = note.userInfo;
        AVAudioSessionRouteChangeReason reason =
            [[dict objectForKey:AVAudioSessionRouteChangeReasonKey] unsignedIntegerValue];
        
        // ... 通知を受け取った後の処理
    }
    

    出力ポートの情報を取得する

    通知を受け取った後や、一番最初にセットアップをするときに出力ポートの情報を取得します。

    // 現在接続されているオーディオルートの情報を取得する
    AVAudioSessionRouteDescription *routeDesc =
        [[AVAudioSession sharedInstance] currentRoute];
    // 出力の情報を取得する
    NSArray *outputs = routeDesc.outputs;
        
    for (AVAudioSessionPortDescription *portDesc in outputs) {
       NSString *portType = portDesc.portType; // AVAudioSessionPortHeadphonesなど
       NSString *portName = portDesc.portName;
       NSArray *channels = portDesc.channels; // AVAudioSessionChannelDescription
    }
    

    AudioSessionのcurrentRouteから現在のオーディオ接続状態をAVAudioSessionRouteDescriptionで取得できます。さらにそのAVAudioSessionRouteDescriptionのoutputsから現在の出力ポートの情報がAVAudioSessionPortDescriptionのNSArrayで取得できます。

    ポートというのは基本的に接続されている機器です。USB Audioであったり、ヘッドホンであったり内蔵スピーカーの事です。出力であればだいたい2chずつチャンネルを持っています。

    outputsが2つあればマルチで出力できるという事ですし、1つであれば1系統しか出力が無いという事です。AudioSessionでMultiRouteにCategoryがセットされていなければ、常に1つだけになります。CategoryがMultiRouteでかつマルチで出力できる接続があるときにoutputsが2つになります。

    AVAudioPlayerで出力を選択するには、AVAudioSessionPortDescriptionのchannelsで取得できるAVAudioSessionChannelDescriptionをchannelAssignmentsにセットすれば良いようです。

    RemoteIOのChannelMapを設定する

    出力ポートの情報を取得してMulti Routeの状態であることが確認できたら、RemoteIOからどの出力へアサインするかをChannelMapで設定します。

    UInt32 map[4] = {0, 1, -1, -1};//ひとつめのデバイスに出力
    // UInt32 map[4] = {-1, -1, 0, 1};//ふたつめのデバイスに出力
    // UInt32 map[4] = {0, 1, 0, 1};// 両方のデバイスに同じ音を出力
    UInt32 size = 4 * sizeof(UInt32);
        
    AudioUnitSetProperty(_audioUnit, kAudioOutputUnitProperty_ChannelMap, kAudioUnitScope_Output, 0, map, size);
    

    重要な注意点として、RemoteIOはひとつにつき2chしか出力する事が出来ません。マルチ出力でUSB Audioとヘッドホンの合計4chへバラバラの音を出力したい場合は、RemoteIOを2つ作成する必要があります。ただ、同じ音を複数の出力へ割り当てる事は出来ますので、外部出力とヘッドホン出力で左右を入れ替えるというだけだったり(そんなことはしないと思いますが…)、モノラルを2系統出力するという用途であればRemoteIOひとつでも大丈夫です。

    ChannelMapはUInt32型の配列で設定します。4chの出力がある状態ならば、要素数が4つの配列にRemoteIOのアウトのどのチャンネルを割り当てるのかを記述します。0と1にすればRemoteIOのそれぞれのチャンネルの音が出力されますが、-1にすれば出力されません。(符号無しの型なのに-1を記述するのはちょっと気持ち悪いですが、-1で設定するように指定されています)。なお、MultiRouteの状態でなければChannelMapの設定は無視されるようです。

    ChannelMap配列の出力の順番は、AudioSessionのcurrentRouteのoutputsの順番と一致しています。ただ、接続するオーディオデバイスによって順番が変わったりしますので、ちゃんとポートの中身を判断して設定したり、ユーザーに設定をしてもらう必要があります。USBでしか試していないと「本線(USB)・ヘッドホン」の順番で固定だと思ってしまいますが、HDMIだと「ヘッドホン・本線(HDMI)」となるので、注意が必要です。

    Unityでストップウォッチを作る その11 データの保存

    Unityでストップウォッチを作るシリーズ半年ぶりの更新で、最後になります。今回はアプリを終了しても継続して計測できるようにデータを保存したいと思います。

    Stopwatch.csに以下のコードを追加してください。ChangeState関数に関しては、最後に1行だけの追加になります。

    //
    // Stopwatch.csの一部
    //
    void Awake() {
        Load();
    }
    const string lastStopTimeKey = "LastStopTime";
    const string startDateTimeKey = "StartDateTime";
    const string stateKey = "State";
    void Save() {
        string lastStopTimeString = lastStopTimeSpan.Ticks.ToString();
        string dateTimeString = startDateTime.Ticks.ToString();
        PlayerPrefs.SetString(lastStopTimeKey, lastStopTimeString);
        PlayerPrefs.SetString(startDateTimeKey, dateTimeString);
        PlayerPrefs.SetInt(stateKey, (int)state);
    }
    void Load() {
        if (PlayerPrefs.HasKey(lastStopTimeKey) &&
            PlayerPrefs.HasKey(startDateTimeKey) &&
            PlayerPrefs.HasKey(stateKey)) {
            string lastStopTimeString = PlayerPrefs.GetString(lastStopTimeKey);
            if (!string.IsNullOrEmpty(lastStopTimeString)) {
                lastStopTimeSpan = new TimeSpan(long.Parse(lastStopTimeString));
            }
            string dateTimeString = PlayerPrefs.GetString(startDateTimeKey);
            if (!string.IsNullOrEmpty(dateTimeString)) {
                startDateTime = new DateTime(long.Parse(dateTimeString));
            }
            state = (StopwatchState)PlayerPrefs.GetInt(stateKey);
        }
    }
    void ChangeState(ref bool circleAnim) {
        ButtonType buttonType = ButtonType.Background;
        
        // ... 中略
        
            FlashBackground(0);
        }
        
        // 次の行を追加
        Save();
    }
    

    解説

    保存したいデータは3つだけですので、簡単に保存が出来るPlayerPrefsを使っています。ただし、PlayerPrefsに保存できる型は「int」「float」「string」の3つだけです。

    Save()関数でデータの保存をしています。今回のコードで保存しているのはStopwatchクラスの変数の「state」「lastStopTime」および「startDateTime」の3つです。stateはintで大丈夫なのですが、他の2つはTicksというlong(リファレンスを見ると64bit)の値を保存したいので、intを使うのはちょっと不安です。なので、一旦文字列に変換して、stringで保存をしています。

    Load()関数では保存したデータから読み込みをしています。long.Parseで文字列から数値に変換していますが、このParse関数は文字列から数値を探せなかったときに例外を発生させます。今回のコードのように何もしていない状態で例外が飛んでくると問題が起きますので、念のためにtryで囲って例外をキャッチしたら無視するなり、エラーダイアログを出すなりした方がよいと思います。

    サンプルプロジェクト

    これで最後という事で、プロジェクトをgithubにアップしてあります。
    BigStopWatchForUnity

    前へ

    Unityでストップウォッチを作る その10 シェーダーを変更する

    今回は、見た目でちょっと気になる所を修正したいと思います。
    背景を黒くしているとあまり分からないのですが、試しに背景を白くしてみると目盛りのフチがやや黒ずんでいるのが分かります。特に針は輪郭がはっきり出ています。
    unitysw_10_1.png
    このようにテクスチャ内の不透明な部分と透明な部分を補間した境目が黒ずんでしまうのですが、なぜ起きるのかについては以下のページで補間アルファの問題として丁寧に解説されていたので、気になる方は参考にしてみると良いと思います。

    乗算済みアルファとは? その1:補間アルファの問題点

    ちなみに、テクスチャの設定を「Billinear」から「Point」に変えて補間を効かなくすればなくなるのですが、それだときれいに描画されなくなってしまうので、補間の設定は切らずに、上記のページでも解説されている「乗算済みアルファ」という状態にしつつシェーダーのブレンドモードも変えて、解決しようと思います。
    まず、テクスチャの方を乗算済みにして描画するようにします。BSWUtility.csのDrawRect()を以下の様に変更してください。

    //
    // BSWUtility.csの一部
    //
    static public void DrawRect(Texture2D tex, Rect rect, Color col) {
       // 追加ここから
       col.r *= col.a;
       col.g *= col.a;
       col.b *= col.a;
       //

    上記のコードではアルファの値をRGBにかけています。コードを見るからに、アルファを効かせていなければ結果は変わりませんが、半透明の状態がテクスチャに含まれる場合は必要な処理です。
    次に、シェーダーを作成します。ProjectウィンドウのCreateメニューから「Shader」を選択し、「Unlit-Alpha Premultiplied」と名前をつけて作成してください。また、一応「Shader」フォルダを作ってその中に移動しておいてください。
    unitysw_10_2.png
    作成出来たら以下のコードで上書きしてください。

    //
    // Unlit-Alpha Premultiplied.shader
    //
    Shader "Unlit/Transparent Premultiplied Alpha" { //

    「Material」のシェーダーの項目の中に「Unlit/Transparent Premultiplied Alpha」が追加されていると思いますので、そちらに変更してください。
    unitysw_10_3.png
    このシェーダーコードは、Unity標準で入っている「Unlit/Transparent」のコードを2行ほど変更しているだけです。Unity標準搭載のシェーダーはUnity上ではコードが見れませんが、Web上に公開されていて、以下のページからダウンロードする事が出来ます。
    http://download-cdn.unity3d.com/unity/download/archive
    今回ベースにしているシェーダーはダウンロードしたフォルダ内の「DefaultResourcesExtra/Unlit/Unlit-Alpha.shader」にあります。
    シェーダーの変更箇所は、シェーダーの名前と「Blend One OneMinusSrcAlpha」の部分です。左の値の「One」が元は「SrcAlpha」でした。「SrcAlpha」というのはこれから描画する色にアルファの値をかけるモードなのですが、それはテクスチャ側でやる事にしたので「One」に変更してアルファをかけずそのまま描画する事にしています。右の値は描画される背景側のモードなのでそのままにしておきます。

    今回のWebPlayerビルド


    今回は以上です。次回は最後の予定ですが、計測中のデータを保存してアプリを落としても計測が中断しないようにしたいと思います。
     
    前へ | 次へ

    Unityでストップウォッチを作る その9 リセットのアニメーション

    今回は「RESET」の文字をアニメーションさせるのに加えて、画面全体の背景もタップしたときにアニメーションさせようと思います。そんなにこったものではなく、フェードさせたり回転させたりするくらいのものです。

    「RESET」を表示するのに使っているTextMeshは、テキストにHTMLのようなタグを付けて1文字単位で色を変えたり装飾をしたりする事が出来ます。ですが、今回は文字列全体にしか色を指定しませんし、フェードさせたりもしたいので、文字列全体にタグをつける処理をするスクリプトを作ります。

    新たに「ColoredText.cs」という名前でC#スクリプトをProjectウィンドウの「Script」フォルダに作成し、以下のコードをコピーしてください。

    //
    // ColoredText.cs
    //
    using UnityEngine;
    using System.Collections;
    [ExecuteInEditMode()]
    [RequireComponent(typeof(TextMesh))]
    [RequireComponent(typeof(MeshRenderer))]
    public class ColoredText : MonoBehaviour {
     
     public string text = "empty";
     public Color color = Color.white;
     
     string prevText = null;
     Color prevColor = Color.clear;
     
     float duration = 0;
     bool animating = false;
     float fromAlpha;
     float toAlpha;
     float time = 0;
     
     TextMesh textMesh;
     
     // Use this for initialization
     void Start () {
       UpdateText();
     }
     
     // Update is called once per frame
     void Update () {
       
       bool needsUpdate = false;
       
       if (animating) {
         
         time += Time.deltaTime;
         float t = time / duration;
         
         if (t >= 1.0f) {
           t = 1.0f;
           animating = false;
         }
         
         float alpha = Mathf.Lerp(fromAlpha, toAlpha, t);
         color.a = alpha;
         
         needsUpdate = true;
         
       } else if (IsPropertyChanged()) {
         
         needsUpdate = true;
         
       }
         
       if (needsUpdate) {
         UpdateText();
       }
     }
     
     bool IsPropertyChanged() {
       if (text != prevText ||
         color != prevColor) {
         return true;
       }
       return false;
     }
     
     void UpdateText() {
       
       if (textMesh == null) {
         textMesh = GetComponent<textMesh>();
         textMesh.richText = true;
       }
       
       textMesh.text = HtmlColorText(text, color);
       
       prevText = text;
       prevColor = color;
     }
     
     public void SetAlpha(float alpha, float duration = 0.0f) {
       
       if (duration <= 0.0f) {
         
         animating = false;
         color.a = alpha;
         UpdateText();
         
       } else {
         
         fromAlpha = color.a;
         toAlpha = alpha;
         animating = true;
         time = 0;
         this.duration = duration;
       }
     }
     
     static private string HtmlColorText(string text, Color col) {
       
       int redValue = (int)(col.r * 255.0f);
       int greenValue = (int)(col.g * 255.0f);
       int blueValue = (int)(col.b * 255.0f);
       int alphaValue = (int)(col.a * 255.0f);
       string colorText = string.Format("#{0:x2}{1:x2}{2:x2}{3:x2}", redValue, greenValue, blueValue, alphaValue);
       return string.Format("<color={0}>{1}</color>", colorText, text);
       
     }
    }
    

    出来たら「ResetText」オブジェクトに追加してください。この「ColoredText」にTextMeshで表示したいテキストと色を渡す事で、わざわざタグを書かなくても色をつける事が出来ます。また、時間を指定してフェードイン・アウトをする事も出来ます。

    unitysw_9_1.png

    BigStopWatchでは「RESET」を表示・非表示するときにグルっと回転させています。これも実現したいので「ResetButtonRoot」オブジェクトのComponentに「AngleAnimation.cs」を追加してください。

    unitysw_9_2.png

    背景の色もタップしたら光るようなイメージで時間をかけて変えたりしたいと思います。新たに「BackgroundColor.cs」というスクリプトをProjectウィンドウの「Script」フォルダに作成して、以下のコードをコピーしてください。

    //
    // BackgoundColor.cs
    //
    using UnityEngine;
    using System.Collections;
    [ExecuteInEditMode]
    public class BackgroundColor : MonoBehaviour {
     
     public Color[] colors;
     public Color offColor = Color.black;
     float flashDuration = 1.0f;
     
     Color prevOffColor = Color.clear;
     
     bool animating = false;
     float duration;
     int colorIndex;
     float time;
     
     // Use this for initialization
     void Start () {
       
       if (camera != null) {
         
         camera.backgroundColor = offColor;
         
       }
       
     }
     
     // Update is called once per frame
     void Update () {
       
       if (camera == null) return;
       
       if (animating) {
         
         time += Time.deltaTime;
         float t = time / flashDuration;
         
         if (t >= 1.0f) {
           t = 1.0f;
           animating = false;
         }
         
         camera.backgroundColor = Color.Lerp(colors[colorIndex], offColor, t);
         
       } else if (IsPropertyChanged()) {
         
         camera.backgroundColor = offColor;
         
         prevOffColor = offColor;
         
       }
     }
     
     public bool IsPropertyChanged() {
       
       if (offColor != prevOffColor) {
         
         return true;
       }
       
       return false;
       
     }
     
     public void Flash(int colorIndex) {
       
       if (colorIndex < 0 || colors.Length <= colorIndex) {
         return;
       }
       
       this.colorIndex = colorIndex;
       animating = true;
       time = 0.0f;
       
     }
    }
    

    作成したら、「Main Camera」オブジェクトへ追加してください。

    unitysw_9_3.png

    タップした場所によって光る背景の色を変えたいので、「BackgroundColor」では複数の色を持つようにしています。インスペクタにある「offColor」は待機状態の単独の色、「colors」はタップしたときに使う複数の色の配列となっています。

    「BackgroundColor」の「Off Color」は黒、「Colors」の各色は以下のような感じにしてみてください。

    unitysw_9_4.png

    表示する側の準備はできたので、コントロールする側のコードを追加します。「Stopwatch.cs」を以下の様に修正してください。

    //
    // Stopwatch.cs
    //
    using UnityEngine;
    using System;
    using System.Collections;
    public class Stopwatch : MonoBehaviour {
     
     enum StopwatchState {
       Zero,
       Play,
       Pause
     }
     
     public TextMesh timeText;
     public TimeCircleController timeCircleController;
     // ここから追加 ->
     public AngleAnimation resetButtonAngleAnimation;
     public ColoredText resetButtonText;
     public float buttonAnimDuration = 0.2f;
     
     public BackgroundColor bgColor;
     // <- ここまで追加
     StopwatchState state = StopwatchState.Zero;
     TimeSpan lastStopTimeSpan;
     DateTime startDateTime;
     
     // ここから追加 ->
     void Start () {
       float alpha = (state == StopwatchState.Pause) ? 1.0f : 0.0f;
       resetButtonText.SetAlpha(alpha, 0.0f);
     }
     // <- ここまで追加
     
     void Update () {
       
       bool circleAnim = false;
       
       if (Input.GetMouseButtonDown(0)) {
         ChangeState(ref circleAnim);
       }
       
       UpdateTime(circleAnim);
     }
     
     void ChangeState(ref bool circleAnim) {
       
       ButtonType buttonType = ButtonType.Background;
         
       Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
       RaycastHit hit;
       
       if (Physics.Raycast(ray, out hit, 2.0f)) {
         Button button = hit.collider.gameObject.GetComponent<Button>();
         if (button != null) {
           buttonType = button.type;
         }
       }
       
       if (buttonType == ButtonType.Reset && state == StopwatchState.Pause) {
         
         lastStopTimeSpan = new TimeSpan(0);
         startDateTime = DateTime.UtcNow;
         
         state = StopwatchState.Zero;
         
         circleAnim = true;
         
         // ここから追加 ->
         SetVisibleResetButton(false);
         FlashBackground(1);
         // ここまで追加
         
       } else if (state == StopwatchState.Play) {
         
         TimeSpan ts = DateTime.UtcNow - startDateTime;
         lastStopTimeSpan = ts + lastStopTimeSpan;
         
         state = StopwatchState.Pause;
         
         // ここから追加 ->
         SetVisibleResetButton(true);
         FlashBackground(0);
         // ここまで追加
         
       } else {
         
         startDateTime = DateTime.UtcNow;
         
         state = StopwatchState.Play;
         
         // ここから追加 ->
         SetVisibleResetButton(false);
         FlashBackground(0);
         // ここまで追加
         
       }
       
     }
     
     void UpdateTime(bool circleAnim) {
       
       TimeSpan currentTs;
       
       if (state == StopwatchState.Play) {
         
         TimeSpan ts = DateTime.UtcNow - startDateTime;
         currentTs = ts + lastStopTimeSpan;
         
       } else {
         
         currentTs = lastStopTimeSpan;
         
       }
       
       if (timeText != null) {
         
         timeText.text = ConvertTimeSpanToString(currentTs);
         
       }
       
       if (timeCircleController != null) {
         
         timeCircleController.SetTime(currentTs, circleAnim);
         
       }
       
     }
     
     // ここから追加 ->
     void SetVisibleResetButton(bool visible) {
       
       if (resetButtonText != null) {
         
         float alpha = visible ? 1.0f : 0.0f;
         resetButtonText.SetAlpha(alpha, buttonAnimDuration);
         
       }
       
       if (resetButtonAngleAnimation != null) {
         
         float fromAngle = visible ? 360.0f : 0.0f;
         float toAngle = visible ? 0.0f : -360.0f;
         resetButtonAngleAnimation.SetAngle(fromAngle, toAngle, true, buttonAnimDuration, false);
         
       }
       
     }
     
     void FlashBackground(int colorIndex) {
       
       if (bgColor != null) {
         
         bgColor.Flash(colorIndex);
         
       }
     }
     // <- ここまで追加
     
     static public string ConvertTimeSpanToString(TimeSpan ts) {
       
       if (ts.Hours > 0 || ts.Days > 0) {
         return string.Format("{0}:{1:D2}:{2:D2}.{3}", ts.Days * 24 + ts.Hours, ts.Minutes, ts.Seconds, ts.Milliseconds.ToString("000").Substring(0, 2));
       } else {
         return string.Format("{0}:{1:D2}.{2}", ts.Minutes, ts.Seconds, ts.Milliseconds.ToString("000").Substring(0, 2));
       }
     }
    }
    

    「Stopwatch」オブジェクトのインスペクタに「Reset Button Angle Animation」と「Reset Button Text」と「Bg Color」という項目が追加されていますのでそれぞれ以下のようにアサインしてください。

    unitysw_9_5.png

    これで「RESET」は計測のポーズ中だけ現れ、画面をタップしたときには画面全体が光るような感じになっていると思います。

    今回のWebPlayerビルド

    今回は以上です。次回はとりあえず放っておいていたシェーダーを差し替えたいと思います。

    前へ | 次へ

    Unityでストップウォッチを作る その8 リセットボタンを作る

    今回はリセットボタンを作りたいと思います。ボタンと言ってもBigStopWatchではいかにも押せるような普通のボタンではないのですが、一応ボタンと表現しておきます。個人的にはリセットボタンの無い状態の方がスマートで好きなのですが、さすがにストップウォッチの機能として単純すぎるかなと思って機能を追加しています。ただ、そういう積算できないアナログのストップウォッチというのも存在しています。

    オブジェクトの作成

    リセットボタンを置くスペースを作りたいのでシーンの「Root」オブジェクトのPositionのXを「-60」にしてください。Gameウィンドウで見てこのくらいでしょうか。

    unitysw_8_1.png

    新たにゲームオブジェクトを作成して「ResetButtonRoot」と名前を変更してください。置き場所はそのまま一番上の階層で大丈夫です。「Position」を「X = 170 / Y = 110 / Z = 0」あたりにします。

    unitysw_8_2.png

    「ResetButtonRoot」の子に3DTextを作成して「ResetText」と名前を変更し、インスペクタのパラメータを以下の様にしてください。

    ・Text > RESET
    ・Character Size > 4
    ・Font Size > 50

    unitysw_8_3.png

    この「RESET」の文字あたりをタップしたことを知りたいのでコリジョンを作ります。四角い領域のBox Colliderとかでもいいのですが、四角より円にしたいと思います。Sphere Colliderでも円の判定は作れそうですが、ちょっとメッシュの量が無駄な気がしますので、オリジナルのメッシュでMesh Colliderを作ります。

    新たに「Button.cs」と「CircleMesh.cs」というスクリプトをProjectウィンドウの「Script」フォルダに作成してください。コードは以下になります。

    //
    // Button.cs
    //
    using UnityEngine;
    using System.Collections;
    public enum ButtonType {
     Background,
     Reset
    }
    public class Button : MonoBehaviour {
     
     public ButtonType type = ButtonType.Reset;
     
    }
    
    //
    // CircleMesh.cs
    //
    using UnityEngine;
    using System.Collections;
    [ExecuteInEditMode()]
    [RequireComponent(typeof(MeshRenderer))]
    [RequireComponent(typeof(MeshFilter))]
    public class CircleMesh : MonoBehaviour {
     
     public float radius = 50.0f;
     public int triangleCount = 6;
     public int textureOffsetX = 40;
     public int textureOffsetY = 0;
     
     float prevRadius = 0.0f;
     int prevTriangleCount = 0;
     int prevTextureOffsetX = -1;
     int prevTextureOffsetY = -1;
     Vector2 uvPoint;
     
     MeshFilter meshFilter;
     
     // Use this for initialization
     void Start () {
       UpdateMesh();
     }
     
     // Update is called once per frame
     void Update () {
       if (IsPropertyChanged()) {
         UpdateMesh();
       }
     }
     
     bool IsPropertyChanged() {
       
       if (radius != prevRadius ||
         triangleCount != prevTriangleCount ||
         textureOffsetX != prevTextureOffsetX ||
         textureOffsetY != prevTextureOffsetY) {
         
         return true;
       }
       
       return false;
     }
     
     void UpdateMesh() {
       
       if (meshFilter == null) {
         meshFilter = GetComponent<meshFilter>();
       }
       
       Mesh mesh = meshFilter.sharedMesh;
       if (mesh == null) return;
       
       mesh.Clear();
       
       Material mat = renderer.sharedMaterial;
       if (mat == null) return;
       
       Texture tex = mat.mainTexture;
       if (tex == null) return;
       
       Texture2D tex2d = (Texture2D)tex;
       if (tex2d == null) return;
       
       // Draw Texture
       
       int texWidth = tex2d.width;
       int texHeight = tex2d.height;
       Vector2 size = Vector2.zero;
       int padding = 1;
       int border = 0;
       int scale = 1;
       
       uvPoint = new Vector2(
         (float)(textureOffsetX + padding) / (float)texWidth,
         (float)(textureOffsetY + padding) / (float)texHeight);
       
       Rect clearRect = BSWUtility.CreateRectForClear(textureOffsetX, textureOffsetY, size, padding, border, scale);
       BSWUtility.DrawRect(tex2d, clearRect, Color.clear);
       
       tex2d.Apply();
       
       // Create Mesh
       
       if (triangleCount < 1) {
         triangleCount = 1;
       }
       
       int verticesCount = triangleCount + 2;
       Vector3[] vertices = new Vector3[verticesCount];
       int[] triangles = new int[triangleCount * 3];
       Vector2[] uv = new Vector2[verticesCount];
       
       for (int i = 0; i < verticesCount; i++) {
         
         float phase = Mathf.PI * 2.0f / (float)verticesCount * (float)i;
         vertices[i] = new Vector3(Mathf.Sin(phase) * radius, Mathf.Cos(phase) * radius, 0.0f);
         uv[i] = uvPoint;
         
       }
       
       for (int i = 0; i < triangleCount; i++) {
         
         triangles[i * 3] = 0;
         triangles[i * 3 + 1] = i + 1;
         triangles[i * 3 + 2] = (i + 2) % verticesCount;
         
       }
       
       mesh.vertices = vertices;
       mesh.triangles = triangles;
       mesh.uv = uv;
       
       mesh.RecalculateNormals();
       mesh.RecalculateBounds();
       mesh.Optimize();
       
       prevRadius = radius;
       prevTriangleCount = triangleCount;
       prevTextureOffsetX = textureOffsetX;
       prevTextureOffsetY = textureOffsetY;
     }
    }
    

    ResetButtonRootオブジェクトの子に「ResetButton」という名前でゲームオブジェクトを作成してください。作成できたら「CircleMesh.cs」と「Button.cs」を「ResetButton」オブジェクトに追加します。

    またここでも新たにメッシュを作ります。Projectウィンドウの「Create / Empty Mesh」を選択してメッシュを作成し、「Mesh」フォルダの中に「ResetButtonMesh」と名前をつけて入れてください。この「ResetButtonMesh」を先ほど作成した「ResetButton」オブジェクトのMeshFilterにアサインします。マテリアルもいままで使ってきたものと同じ「Material」をMeshRendererにアサインしてください。さらに同じく「ResetButton」オブジェクトに「Mesh Collider」を追加します。「ResetButton」オブジェクトを選択した状態で、メニューの「Component / Physics / Mesh Collider」を選択してください。

    unitysw_8_4.png

    「Stopwatch.cs」を以下のコードに差し替えてください。

    //
    // Stopwatch.cs
    //
    using UnityEngine;
    using System;
    using System.Collections;
    public class Stopwatch : MonoBehaviour {
     
     enum StopwatchState {
       Zero,
       Play,
       Pause
     }
     
     public TextMesh timeText;
     public TimeCircleController timeCircleController;
     
     StopwatchState state = StopwatchState.Zero;
     TimeSpan lastStopTimeSpan;
     DateTime startDateTime;
     
     void Update () {
       
       bool circleAnim = false;
       
       if (Input.GetMouseButtonDown(0)) {
         ChangeState(ref circleAnim);
       }
       
       UpdateTime(circleAnim);
     }
     
     void ChangeState(ref bool circleAnim) {
       
       // 追加ここから ->
       ButtonType buttonType = ButtonType.Background;
         
       Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
       RaycastHit hit;
       
       if (Physics.Raycast(ray, out hit, 2.0f)) {
         Button button = hit.collider.gameObject.GetComponent<Button>();
         if (button != null) {
           buttonType = button.type;
         }
       }
       
       if (buttonType == ButtonType.Reset && state == StopwatchState.Pause) {
         // <- 追加ここまで
         lastStopTimeSpan = new TimeSpan(0);
         startDateTime = DateTime.UtcNow;
         
         state = StopwatchState.Zero;
         
         circleAnim = true;
         
       } else if (state == StopwatchState.Play) {
         
         TimeSpan ts = DateTime.UtcNow - startDateTime;
         lastStopTimeSpan = ts + lastStopTimeSpan;
         
         state = StopwatchState.Pause;
         
       } else {
         
         startDateTime = DateTime.UtcNow;
         
         state = StopwatchState.Play;
         
       }
       
     }
     
     void UpdateTime(bool circleAnim) {
       
       TimeSpan currentTs;
       
       if (state == StopwatchState.Play) {
         
         TimeSpan ts = DateTime.UtcNow - startDateTime;
         currentTs = ts + lastStopTimeSpan;
         
       } else {
         
         currentTs = lastStopTimeSpan;
         
       }
       
       if (timeText != null) {
         
         timeText.text = ConvertTimeSpanToString(currentTs);
         
       }
       
       if (timeCircleController != null) {
         
         timeCircleController.SetTime(currentTs, circleAnim);
         
       }
       
     }
     
     static public string ConvertTimeSpanToString(TimeSpan ts) {
       
       if (ts.Hours > 0 || ts.Days > 0) {
         return string.Format("{0}:{1:D2}:{2:D2}.{3}", ts.Days * 24 + ts.Hours, ts.Minutes, ts.Seconds, ts.Milliseconds.ToString("000").Substring(0, 2));
       } else {
         return string.Format("{0}:{1:D2}.{2}", ts.Minutes, ts.Seconds, ts.Milliseconds.ToString("000").Substring(0, 2));
       }
     }
    }
    

    これで、ストップウォッチを停止中にRESETの文字付近をクリックorタップしたときにはゼロに戻り、その他の領域をタップしたときには続きを再生する様になります。

    解説

    「Button.cs」クラスは特に機能がある訳ではなく、どの種類のボタンかのタグを付けているだけのものです。ボタンの種類はButtonTypeというenumでを作って表す様にしました。今回のチュートリアルのように単純な場合はUnity標準機能のタグを使ってもいいのですが、もしボタン以外にも何かタグを付けて判別をしたくなった場合に1種類のタグを使い回すのは良くないかなと思い、このように別のものを作っています。

    「Stopwatch.cs」に追加したのは、クリックした位置がRESETに当たったかどうかを判定するコードです。ストップウォッチの停止中にRaycastがRESETのコリジョンに当たって、そのオブジェクトについている「Button」コンポーネントのButtonTypeが「Reset」である場合にリセットするというような処理です。

    Input.mousePositionでクリックしたスクリーン上の位置を取得して、表示しているMain CameraでRaycastを飛ばしています。カメラの奥行きは-1〜1なのでRaycastの距離は2にしています。

    今回のWebPlayerビルド

    これで計測時間が積算できる様になりましたが、RESETが機能しないときも表示しっぱなしなので、次回はストップウォッチのステートの状態に合わせてRESETの表示・非表示を切り替える様にしたいと思います。

    前へ | 次へ

    Unityでストップウォッチを作る その7 「分」のサークルを作る

    前回で「秒」を表示するサークルが出来上がりましたので、これをコピーして「分」のサークルも作ります。

    オブジェクトの複製と設定

    シーンの「SecTimeCircle」を選択した状態でコピー&ペーストをして複製します。新たに出来たオブジェクトの名前を「MinTimeCircle」と変更してください。

    unitysw_7_1.png

    複製したオブジェクトはほぼそのまま再利用しますが、メッシュだけは変更します。Projectウィンドウで新たに2つメッシュを作ってそれぞれ「MinTimeCircleMesh」「MinNeedleMesh」と名前を変更し、「MinTimeCircle」とその中にある「Needle」オブジェクトのメッシュを差し替えてください。

    unitysw_7_2.png
    unitysw_7_3.png

    「MinTimeCircle」のインスペクタの値を以下の様に変更してください。

    ・Line Radius > 530
    ・Number Radius > 554
    ・Part Line Count > 6
    ・Texture Offset X > 20

    unitysw_7_4.png

    「MinTImeCircle」内の「Needle」のインスペクタの値を以下の様に変更してください。

    ・Position Y > 506
    ・Rotation Z > 180
    ・Texture Offset X > 20

    unitysw_7_5.png

    「TimeCircleController.cs」に「分」のサークルをコントロールするコードを追加します。「TimeCircleController.cs」を以下のコードに差し替えてください。

    using UnityEngine;
    using System;
    using System.Collections;
    public class TimeCircleController : MonoBehaviour {
     public AngleAnimation secCircleAngleAnimation;
     public AngleAnimation minCircleAngleAnimation; // <- 1行追加
     public float circleAnimDuration = 0.2f;
     
     public AngleAnimation secNeedleAngleAnimation;
     public AngleAnimation minNeedleAngleAnimation; // <- 1行追加
     public float needleAnimDuration = 0.1f;
     
     public void SetTime(TimeSpan ts, bool animate = false)
     {
       float secAngle = (float)((ts.TotalMinutes - Math.Truncate(ts.TotalMinutes)) * 360.0);
       
       if (secCircleAngleAnimation) {
         secCircleAngleAnimation.SetAngle(secAngle, animate, circleAnimDuration);
       }
       
       if (secNeedleAngleAnimation) {
         secNeedleAngleAnimation.SetAngle(-secAngle, animate, needleAnimDuration);
       }
       
       // ここから追加 ->
       float minAngle = (float)((ts.TotalHours - Math.Truncate(ts.TotalHours)) * 360.0);
       
       if (minCircleAngleAnimation) {
         minCircleAngleAnimation.SetAngle(minAngle, animate, circleAnimDuration);
       }
       
       if (minNeedleAngleAnimation) {
         minNeedleAngleAnimation.SetAngle(-minAngle, animate, needleAnimDuration);
       }
       // <- ここまで追加
     }
    }
    

    「Circles」オブジェクトの「TimeCircleController」のインスペクタに新たに「Min Circle Angle Animation」と「Min Needle Angle Animation」という項目が追加されています。「Min Circle Angle Animation」に「MinTimeCircle」オブジェクトをアサインし、「Min Needle Angle Animation」には「MinTimeCircle」内の「NeedleHandle」オブジェクトをアサインしてください。

    unitysw_7_6.png

    これで「秒」と「分」のサークルがそれぞれ計測時間に応じてきちんと動く様になっていると思います。

    解説

    「MinTimeCircle」や「Needle」で変更したところは主に位置調整です。「Part Line Count」で1分の間の目盛りの間隔を10秒単位の6にしています。あと、「Texture Offset X」でテクスチャに描画する位置を「秒」のものと被らないようにしていますので、目盛りや針の色を変えることも出来る様になっています。

    今回のWebPlayerビルド

    ここまで出来るとだいぶBigStopWatchっぽくなってきましたが、今の状態だと積算できないストップウォッチのままなので、次回はリセットボタンを作りたいと思います。

    前へ | 次へ

    Unityでストップウォッチを作る その6 針を作る

    今回はサークル上で計測時間を指し示す針を作っていきます。

    スクリプトの追加と修正

    まず、Projectウィンドウに「Needle.cs」という名前でC#スクリプトを作り、以下のコードをコピーしてください。

    //
    // Needle.cs
    //
    using UnityEngine;
    using System.Collections;
    [ExecuteInEditMode()]
    [RequireComponent(typeof(MeshRenderer))]
    [RequireComponent(typeof(MeshFilter))]
    public class Needle : MonoBehaviour {
     
     public Vector2 size = new Vector2(10.0f, 30.0f);
     public float bottomMargin = 4.0f;
     public Color color = Color.white;
     public int textureOffsetX = 0;
     public int textureOffsetY = 0;
     
     Vector2 prevSize = Vector2.zero;
     float prevBottomMargin = 0.0f;
     Color prevColor = Color.clear;
     int prevTextureOffsetX = -1;
     int prevTextureOffsetY = -1;
     
     MeshFilter meshFilter;
     
     void Start () {
       UpdateNeedle();
     }
     
     void Update () {
       
       if (IsNeedlePropertyChanged()) {
         UpdateNeedle();
       }
     }
     
     bool IsNeedlePropertyChanged()
     {
       if (size != prevSize ||
         color != prevColor ||
         bottomMargin != prevBottomMargin ||
         textureOffsetX != prevTextureOffsetX ||
         textureOffsetY != prevTextureOffsetY) {
         
         return true;
       }
       return false;
     }
     
     void UpdateNeedle()
     {
       if (meshFilter == null) {
         meshFilter = GetComponent<meshFilter>();
       }
       
       Mesh mesh = meshFilter.sharedMesh;
       if (mesh == null) return;
       
       Material mat = renderer.sharedMaterial;
       if (mat == null) return;
       
       Texture tex = mat.mainTexture;
       if (tex == null) return;
       
       Texture2D tex2d = (Texture2D)tex;
       if (tex2d == null) return;
       
       // Draw Texture
       
       int padding = 1;
       int border = 1;
       int scale = 1;
       
       float nHeight = size.y;
       float nHalfWidth = size.x * 0.5f;
       
       int drawHeight = (int)Mathf.Sqrt(nHeight * nHeight + nHalfWidth * nHalfWidth * 0.25f);
       float rad = Mathf.Atan2(nHalfWidth, nHeight);
       int drawWidth = Mathf.CeilToInt(nHeight * Mathf.Sin(rad));
       
       Vector2 drawSize = new Vector2(drawWidth, drawHeight);
       
       int texWidth = tex2d.width;
       int texHeight = tex2d.height;
       
       Rect clearRect = BSWUtility.CreateRectForClear(textureOffsetX, textureOffsetY, drawSize, padding, border, scale);
       BSWUtility.DrawRect(tex2d, clearRect, Color.clear);
       
       Rect drawRect = BSWUtility.CreateRectForDraw(textureOffsetX, textureOffsetY, drawSize, padding, border, scale);
       BSWUtility.DrawRect(tex2d, drawRect, color);
       
       float bottomRate = bottomMargin / size.y;
       Vector2[] needleUv = CreateUv(textureOffsetX, textureOffsetY, drawSize, texWidth, texHeight, padding, border, scale, bottomRate);
       
       tex2d.Apply();
       
       
       // Create Mesh
       
       float halfHeight = size.y * 0.5f;
       float halfWidth = size.x * 0.5f;
       float yBorder = Mathf.Sin(rad);
       float xBorder = Mathf.Cos(rad);
       float bottomY = - halfHeight + size.y * bottomRate;
       float bottomHalfWidth = halfWidth * bottomRate;
       
       Vector3[] vertices = new Vector3[] {
         
         // triangle 0 ~ 3
         new Vector3(0.0f, bottomY, 0.0f),
         new Vector3(- halfWidth, halfHeight, 0.0f),
         new Vector3(0.0f, halfHeight, 0.0f),
         new Vector3(halfWidth, halfHeight, 0.0f),
         
         // left border 4 ~ 5
         new Vector3(- bottomHalfWidth - xBorder, bottomY - yBorder, 0.0f),
         new Vector3(- xBorder - halfWidth, halfHeight - yBorder, 0.0f),
         
         // right border 6 ~ 7
         new Vector3(bottomHalfWidth + xBorder, bottomY - yBorder, 0.0f),
         new Vector3(xBorder + halfWidth, halfHeight - yBorder, 0.0f),
         
         // top border 8 ~ 10
         new Vector3(- halfWidth, halfHeight + border, 0.0f),
         new Vector3(0.0f, halfHeight + border, 0.0f),
         new Vector3(halfWidth, halfHeight + border, 0.0f),
         
         // bottom side 11 ~ 12
         new Vector3(- bottomHalfWidth, bottomY, 0.0f),
         new Vector3(bottomHalfWidth, bottomY, 0.0f),
         
         // bottom border 13 ~ 15
         new Vector3(- bottomHalfWidth, bottomY - border, 0.0f),
         new Vector3(bottomHalfWidth, bottomY - border, 0.0f),
         new Vector3(0.0f, bottomY - border, 0.0f)
       };
       
       int[] triangles = new int[] {
         // triangle
         11, 1, 2,
         11, 2, 0,
         0, 2, 3,
         0, 3, 12,
         // side
         4, 5, 11, 11, 5, 1,
         12, 3, 6, 6, 3, 7,
         // top
         1, 8, 2, 2, 8, 9,
         2, 9, 3, 3, 9, 10,
         // bottom
         13, 11, 0, 13, 0, 15,
         15, 0, 12, 15, 12, 14,
         // corner
         11, 13, 4,
         14, 12, 6,
         1, 5, 8,
         3, 10, 7
       };
       
       Vector2[] uv = new Vector2[] {
         // triangle
         needleUv[7],
         needleUv[1],
         needleUv[2],
         needleUv[1],
         // side
         needleUv[3],
         needleUv[4],
         needleUv[3],
         needleUv[4],
         // top
         needleUv[5],
         needleUv[6],
         needleUv[5],
         // bottom side
         needleUv[0],
         needleUv[0],
         // bottom border
         needleUv[8],
         needleUv[8],
         needleUv[9]
       };
       
       mesh.vertices = vertices;
       mesh.triangles = triangles;
       mesh.uv = uv;
       
       mesh.RecalculateNormals();
       mesh.RecalculateBounds();
       mesh.Optimize();
       
       // Keep properties
       
       prevSize = size;
       prevColor = color;
       prevTextureOffsetX = textureOffsetX;
       prevTextureOffsetY = textureOffsetY;
     }
     
     static public Vector2[] CreateUv(int originX, int originY, Vector2 squareSize, int texWidth, int texHeight, int padding, int border, int scale, float bottomRate) {
       
       float minX = (float)(originX + padding) / texWidth;
       float minY = (float)(originY + padding) / texHeight;
       float maxY = (float)(originY + padding + (border * 2 + squareSize.y) * scale) / (float)texHeight;
       
       float sqMinX = (float)(originX + padding + border * scale) / (float)texWidth;
       float sqMidX = (float)(originX + padding + (border + squareSize.x * bottomRate) * scale) / (float)texWidth;
       float sqMaxX = (float)(originX + padding + (border + squareSize.x - 1) * scale) / (float)texWidth;
       float sqMinY = (float)(originY + padding + border * scale) / (float)texHeight;
       float sqMaxY = (float)(originY + padding + (border + squareSize.y) * scale) / (float)texHeight;
       
       Vector2[] uv = new Vector2[] {
         new Vector2(sqMinX, sqMinY),
         new Vector2(sqMinX, sqMaxY),
         new Vector2(sqMaxX, sqMaxY),
         new Vector2(minX, sqMinY),
         new Vector2(minX, sqMaxY),
         new Vector2(sqMinX, maxY),
         new Vector2(sqMaxX, maxY),
         new Vector2(sqMidX, sqMinY),
         new Vector2(sqMinX, minY),
         new Vector2(sqMidX, minY),
       };
       
       return uv;
     }
    }
    

    また、既に作成している「TimeCircleController.cs」を以下の様に修正してください。

    using UnityEngine;
    using System;
    using System.Collections;
    public class TimeCircleController : MonoBehaviour {
     public AngleAnimation secCircleAngleAnimation;
     public float circleAnimDuration = 0.2f;
     
     // 追加ここから ->
     public AngleAnimation secNeedleAngleAnimation;
     public float needleAnimDuration = 0.1f;
     // <- 追加ここまで
     
     public void SetTime(TimeSpan ts, bool animate = false)
     {
       float secAngle = (float)((ts.TotalMinutes - Math.Truncate(ts.TotalMinutes)) * 360.0);
       
       if (secCircleAngleAnimation) {
         secCircleAngleAnimation.SetAngle(secAngle, animate, circleAnimDuration);
       }
       
       // 追加ここから ->
       if (secNeedleAngleAnimation) {
         secNeedleAngleAnimation.SetAngle(-secAngle, animate, needleAnimDuration);
       }
       // <- 追加ここまで
     }
    }
    

    オブジェクトの追加と設定

    「SecTimeCircle」の子に「SecTimeCircle / NeedleHandle / Needle」という感じでオブジェクトを作成して配置してください。「NeedleHandle」のPositionは「X=0 / Y=0 / Z=0」にしてください。また「NeedleHandle」オブジェクトに前回使ったスクリプト「AngleAnimation.cs」を追加してください。

    unitysw_6_1.png

    「Needle」オブジェクトに「Needle.cs」を追加してPositionを「X=0 / Y=664 / Z=0」にしてください。また、Projectウィンドウの「Create / Empty Mesh」で新たにメッシュを作成して「Needle」オブジェクトのMeshFilterにアサインしてください。名前は「SecNeedleMesh」しておきます。「Material」も同じく「Needle」オブジェクトに追加してください。

    unitysw_6_2.png

    「Circles」オブジェクトの「TimeCircleController」のインスペクタに「Sec Needle Angle Animation」という項目が追加されていますので、「NeedleHandle」オブジェクトをアサインします。

    unitysw_6_3.png

    これでサークル上に針が表示される様になっていると思います。

    解説

    針は、本当は外部の画像編集ソフトで作って単純な四角形のメッシュに表示するのが単純ではあるのですが、このチュートリアルの最初に外部アセットは使わないと宣言したので、ちょっと工夫してUnityだけできれいな三角形になるように作ってみました。UVやメッシュは以下のような感じで作っています。

    unitysw_6_4.png

    テクスチャには長方形を描画しておいて、オレンジの線の部分を中心に三角形の左側と右側を左右対象にして組み合わせています。さらに、その周囲の水色の部分のエッジの外側にマージンをつけて補間を効かせているという感じです。

    「Needle.cs」でやっている事はテクスチャへの描画とメッシュの作成なので「TimeCircle」とそんなに違わないと思います。まだ説明はしていませんでしたが「Texture Offset X」と「Texture Offset Y」というパラメータが「TimeCircle.cs」と「Needle.cs」にあります。これはテクスチャに描画するときの左下の原点の位置です。「Needle」では「Texture Offset Y」の初期値を30にしているので最初から「TimeCircle」の描画領域と被らないようになっています。普通、テクスチャアトラスを作るソフトなどでは、組み込む画像を指定すると自動的に描画する座標を良い感じで決めてくれたりしますが、ここでそれをやるのはめんどくさいので、もしサイズを変更してかぶってしまう場合は手動で位置を調整してください。

    「TimeCircleController」に追加した部分は針の回転位置をAngleAnimationに渡す処理を追加しています。サークル上の計測秒数の位置に動かしていますが、逆方向にサークルが回っているので常に針は固定した位置にいるという感じになっています。

    今回のWebPlayerビルド

    これでようやく秒のサークルが出来上がりました。1サークル分の素材が全部そろったので、次回はこれをそのまま活用して「分」のサークルを作ります。

    前へ | 次へ