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

Unityでストップウォッチを作る その5 サークルを動かす

今回は、前々回から作ってきた「TimeCircle」をストップウォッチの計測時間に合わせて動く様にしたいと思います。

スクリプトの追加と修正

新たに「AngleAnimation.cs」と「TimeCircleController」の2つのC#スクリプトを追加して以下のコードをコピーしてください。また、「BSWUtility.cs」と「Stopwatch.cs」も修正してください。修正と言っても各スクリプト内のコードは全部載せているのでマルっと上書きしてしまって大丈夫です。

//
// AngleAnimation.cs
//
using UnityEngine;
using System.Collections;
public class AngleAnimation : MonoBehaviour {
 
 float fromAngle;
 float toAngle;
 float duration;
 float time = 0.0f;
 bool animating = false;
 bool isLerpAngle = false;
 
 void Update () {
   
   if (animating) {
     
     time += Time.deltaTime;
     
     float t = time / duration;
     
     if (t >= 1.0f) {
       t = 1.0f;
       animating = false;
     }
     
     t = BSWUtility.EaseOutValue(t);
     
     float angle = (isLerpAngle) ? Mathf.LerpAngle(fromAngle, toAngle, t) : Mathf.Lerp(fromAngle, toAngle, t);
     
     transform.localEulerAngles = new Vector3(0, 0, angle);
     
   }
   
 }
 
 public void SetAngle(float fromAngle, float toAngle, bool animate = false, float duration = 0.5f, bool isLerpAngle = true) {
   
   if (animate) {
     
     this.duration = duration;
     this.toAngle = toAngle;
     this.fromAngle = fromAngle;
     time = 0.0f;
     animating = true;
     this.isLerpAngle = isLerpAngle;
     
   } if (animating) {
     
     this.toAngle = toAngle;
     
   } else {
     
     transform.localEulerAngles = new Vector3(0, 0, toAngle);
     
   }
 }
 
 public void SetAngle(float angle, bool animate = false, float duration = 0.5f) {
   
   SetAngle(transform.localEulerAngles.z, angle, animate, duration);
   
 }
}
//
// TimeCircleController.cs
//
using UnityEngine;
using System;
using System.Collections;
public class TimeCircleController : MonoBehaviour {
 public AngleAnimation secCircleAngleAnimation;
 public float circleAnimDuration = 0.2f;
 
 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);
   }
 }
}
//
// BSWUtility.cs
//
using UnityEngine;
using System.Collections;
public class BSWUtility : MonoBehaviour {
 static public void DrawRect(Texture2D tex, Rect rect, Color col) {
   
   int minX = (int)rect.x;
   int minY = (int)rect.y;
   int maxX = (int)rect.xMax;
   int maxY = (int)rect.yMax;
   
   for (int x = minX; x < maxX; x++) {
     for (int y = minY; y < maxY; y++) {
       tex.SetPixel(x, y, col);
     }
   }
 }
 
 static public Rect CreateRectForClear(int originX, int originY, Vector2 lineSize, int padding, int border, int scale) {
   
   return new Rect(
     originX,
     originY,
     padding * 2 + (lineSize.x + border * 2) * scale,
     padding * 2 + (lineSize.y + border * 2) * scale);
 }
 
 static public Rect CreateRectForDraw(int originX, int originY, Vector2 lineSize, int padding, int border, int scale) {
   
   return new Rect(
     originX + padding + border * scale,
     originY + padding + border * scale,
     lineSize.x * scale,
     lineSize.y * scale);
 }
 
// 修正ここから ->
 static public float EaseOutValue(float t) {
   
   return Mathf.Sin(Mathf.PI * 0.5f * t);
   
 }
// <- 修正ここまで
}
//
// 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) {
   
   if (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));
   }
 }
}

オブジェクトの設定

スクリプトの作成と修正が済んだら、「AngleAnimation.cs」を「SecTimeCircle」オブジェクトのComponentに追加してください。

unitysw_5_1.png

「TimeCircleController.cs」はどこに置いても良かったりするんですが役割を考えて「Circles」オブジェクトに追加し、インスペクタ内の「Sec Time Angle Animation」に「SecTimeCircle」オブジェクトをアサインしてください。

unitysw_5_2.png

また、「Stopwatch」オブジェクトのインスペクタに「Time Circle Controller」が追加されているので「Circles」オブジェクトをアサインしてください。

unitysw_5_3.png

ここまで作業を終えればサークルがストップウォッチの表示時間に合わせて動くと思います。

解説

実際にサークルを回しているのは「AngleAnimation」で、「TimeCircleController」は「Stopwatch」から表示時間を受け取ってサークルの角度に変換する役割をしています。

表示タイムの角度でそのまま回転させてしまうと、再生中や停止するときは良いのですが、リセットしたときにいきなりゼロ秒にパッと戻ってしまっていまいちです。なので「AngleAnimation」というスクリプトを挟み込んで、リセットするときにはいきなり戻らずアニメーションをして戻るようにしています。

ちなみに「AngleAnimation」に常にアニメーションをtrueにして角度をセットすると常に動きがウォンウォン補間されて気持ちよいきもするのですが、そうすると計測秒数を表す位置がズレて分かりにくくなってしまうのでやらない方が良いと思います。必要なポイントだけちゃんとアニメーションして表示が飛んだりしないようにするくらいでとどめておくのがスマートではないでしょうか。

この「AngleAnimation」は後で使い回したいのでアニメーション位置の補間をLerpAngleでやるか普通にLerpでやるかを選べる様にしています。今回のサークルのアニメーションでは30秒を過ぎていれば60秒に向かって、過ぎていなければ0秒に向かってというように角度的に近い方戻したかったのでLerpAngleを使っています。

浮動小数点数の精度問題

このストップウォッチのサークルのように、大きい数値を元にオブジェクトを回転させる事になる場合は、必ず一旦0度〜360度(0〜2π)の中に収まるような値にしてから渡すようにします。値が浮動小数点数なので、オブジェクトのRotationや三角関数などへ大きな値を直接渡してしまうと、精度が足りずに動きがおかしくなってしまうからです。

たとえば、TimeCircleController.csのコードでは...

float secAngle = (float)((ts.TotalMinutes - Math.Truncate(ts.TotalMinutes)) * 360.0);

としていて、doubleで取得した「分」の値を、小数点以下の必要な部分だけにしてから角度に変換していますが、これを以下の様にして...

float secAngle = (float)((ts.TotalMinutes + 20000.0) * 360.0);

まったく切り捨てず、さらに約2週間ほど足した状態にしてみると、明らかにサークルの動きがガクガクしているのが分かると思います。このストップウォッチのサークルのように数値の上がり方が緩やかであれば1日くらいはなんとかスムーズさを保ってくれますが、1kHzのサイン波の音を鳴らす場合などでは、単純に加算していった数値をSin関数に渡してしまうとわずか数秒でノイズが含まれてしまい破綻します。

このあたりの精度問題は回転に限らずタイム表示の方でも起きるのですが、このチュートリアルのUnityバージョンでは値の取得にTimeSpanを使っているので気にしなくても大丈夫だと思います。

今回のWebPlayerビルド

今回は以上です。これでサークルが回る様になったのですが、このままだとどこが計測タイムの位置なのかわかりません。次回はタイムを指す針を追加したいと思います。

前へ | 次へ

Unityでストップウォッチを作る その4 秒数の表示

今回は秒数のサークルに数字を追加します。これの事です。

unitysw_4_1.png

数字のプレファブを作る

TextMeshを使って数字の表示をしますが、前回のようにまとめてメッシュを作るということまではせず、数字ひとつごとにゲームオブジェクトを作って置いていきます。

まずひとつの数字のプレファブをつくります。メニューから「GameObject / Create Other / 3DText」を選択し、シーンにオブジェクトを作成してください。

unitysw_4_0.png

出来たら「Number」と名前を変更して、TextMeshの設定を以下の様に変更してください

・Character Size > 4
・Alignment > center
・Anchor > middle center
・Font Size > 60

unitysw_4_2.png

Projectウィンドウに「Prefab」フォルダを作成し、その中に「Number」オブジェクトをドラッグ&ドロップしてプレファブを作成してください。プレファブが出来たらシーンからは削除してください。

unitysw_4_3.png

サークルに数字を表示する

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

using UnityEngine;
using System.Collections;
[ExecuteInEditMode()]
[RequireComponent(typeof(MeshRenderer))]
[RequireComponent(typeof(MeshFilter))]
public class TimeCircle : MonoBehaviour {
 
 public float lineRadius = 640.0f;
 public Vector2 largeLineSize = new Vector2(2.0f, 12.0f);
 public Vector2 smallLineSize = new Vector2(1.5f, 8.0f);
 public int largeLineCount = 60;
 public int partLineCount = 10;
 public Color largeLineColor = Color.white;
 public Color smallLineColor = Color.gray;
 public int textureOffsetX = 0;
 public int textureOffsetY = 0;
 
 float prevLineRadius = 0;
 Vector2 prevLargeLineSize = Vector2.zero;
 Vector2 prevSmallLineSize = Vector2.zero;
 int prevLargeLineCount = 0;
 int prevPartLineCount = 0;
 Color prevLargeLineColor = Color.clear;
 Color prevSmallLineColor = Color.clear;
 int prevTextureOffsetX = -1;
 int prevTextureOffsetY = -1;
 
 // 追加ここから ->
 public GameObject numberPrefab;
 public float numberRadius = 620.0f;
 
 float prevNumberRadius = 0;
 
 GameObject numberRoot = null;
 string numberRootName = "NumberRoot";
 // <- 追加ここまで
 
 MeshFilter meshFilter;
 
 void Start () {
   
   UpdateLines();
   // 追加ここから ->
   UpdateNumbers();
   // <- 追加ここまで
   
 }
 
 void Update () {
   
   if (IsLinePropertyChanged()) {
     
     UpdateLines();
   }
   
   if (IsNumberPropertyChanged()) {
     
     UpdateNumbers();
     
   }
 }
 
 bool IsLinePropertyChanged() {
   
   if (lineRadius != prevLineRadius ||
     largeLineSize != prevLargeLineSize ||
     smallLineSize != prevSmallLineSize ||
     largeLineCount != prevLargeLineCount ||
     partLineCount != prevPartLineCount ||
     largeLineColor != prevLargeLineColor ||
     smallLineColor != prevSmallLineColor ||
     textureOffsetX != prevTextureOffsetX ||
     textureOffsetY != prevTextureOffsetY) {
     
     return true;
   }
   
   return false;
 }
 
 bool IsNumberPropertyChanged() {
   
   if (numberRadius != prevNumberRadius) {
     
     return true;
   }
   
   return false;
 }
 
 void UpdateLines()
 {
   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 padding = 1;
   int border = 1;
   int scale = 2;
   int originX = textureOffsetX;
   int originY = textureOffsetY;
   int texWidth = tex2d.width;
   int texHeight = tex2d.height;
   
   Rect largeClearRect = BSWUtility.CreateRectForClear(originX, originY, largeLineSize, padding, border, scale);
   BSWUtility.DrawRect(tex2d, largeClearRect, Color.clear);
   
   Rect largeDrawRect = BSWUtility.CreateRectForDraw(originX, originY, largeLineSize, padding, border, scale);
   BSWUtility.DrawRect(tex2d, largeDrawRect, largeLineColor);
   
   Vector2[] largeUv = CreateUv(originX, originY, largeLineSize, texWidth, texHeight, padding, border, scale);
   
   originX += (int)(padding * 2 + (largeLineSize.x + border * 2) * scale);
   
   Rect smallClearRect = BSWUtility.CreateRectForClear(originX, originY, smallLineSize, padding, border, scale);
   BSWUtility.DrawRect(tex2d, smallClearRect, Color.clear);
   
   Rect smallDrawRect = BSWUtility.CreateRectForDraw(originX, originY, smallLineSize, padding, border, scale);
   BSWUtility.DrawRect(tex2d, smallDrawRect, smallLineColor);
   
   Vector2[] smallUv = CreateUv(originX, originY, smallLineSize, texWidth, texHeight, padding, border, scale);
   
   tex2d.Apply();
   
   renderer.sharedMaterial.mainTexture = tex2d;
   
   // Create Mesh
   
   int lineCount = largeLineCount * partLineCount;
   int vertexCount = lineCount * 4;
   int triangleCount = lineCount * 6;
   
   Vector3[] vertices = new Vector3[vertexCount];
   int[] triangles = new int[triangleCount];
   Vector2[] uv = new Vector2[vertexCount];
   
   Vector3[] largeLinePos = CreateLinePositions(largeLineSize, border, lineRadius);
   Vector3[] smallLinePos = CreateLinePositions(smallLineSize, border, lineRadius);
   
   for (int i = 0; i < lineCount; i++) {
     
     int vTopIndex = i * 4;
     int tTopIndex = i * 6;
     
     float angle = - ((float)i / lineCount) * 360.0f;
     var rot = Quaternion.Euler(0, 0, angle);
           var m = Matrix4x4.TRS(Vector3.zero, rot, Vector3.one);
     
     Vector3[] currentPos;
     Vector2[] currentUv;
     
     if (i % partLineCount == 0) {
       
       currentPos = largeLinePos;
       currentUv = largeUv;
     
     } else {
       
       currentPos = smallLinePos;
       currentUv = smallUv;
       
     }
     
     for (int j = 0; j < 4; j++) {
       
       int index = vTopIndex + j;
       vertices[index] = m.MultiplyPoint3x4(currentPos[j]);
       uv[index] = currentUv[j];
       
     }
     
     triangles[tTopIndex] = vTopIndex;
     triangles[tTopIndex + 2] = triangles[tTopIndex + 3] = vTopIndex + 1;
     triangles[tTopIndex + 1] = triangles[tTopIndex + 4] = vTopIndex + 2;
     triangles[tTopIndex + 5] = vTopIndex + 3;
   }
   
   mesh.vertices = vertices;
   mesh.triangles = triangles;
   mesh.uv = uv;
   
   mesh.RecalculateNormals();
   mesh.RecalculateBounds();
   mesh.Optimize();
   
   // Keep Properties
   
   prevLineRadius = lineRadius;
   prevLargeLineSize = largeLineSize;
   prevSmallLineSize = smallLineSize;
   prevLargeLineCount = largeLineCount;
   prevPartLineCount = partLineCount;
   prevLargeLineColor = largeLineColor;
   prevSmallLineColor = smallLineColor;
   prevTextureOffsetX = textureOffsetX;
   prevTextureOffsetY = textureOffsetY;
 }
 
 static public Vector3[] CreateLinePositions(Vector2 lineSize, int border, float radius) {
   
   float minX = - lineSize.x * 0.5f - border;
   float maxX = lineSize.x * 0.5f + border;
   float minY = - lineSize.y * 0.5f - border + radius;
   float maxY = lineSize.y * 0.5f + border + radius;
   
   return new Vector3[] {
     new Vector3(minX, minY, 0.0f),
     new Vector3(maxX, minY, 0.0f),
     new Vector3(minX, maxY, 0.0f),
     new Vector3(maxX, maxY, 0.0f)
   };
   
 }
 
 // 追加ここから ->
 void UpdateNumbers()
 {
   if (numberPrefab == null)
     return;
   
   foreach (Transform child in transform) {
     if (child.gameObject.name == numberRootName) {
       DestroyImmediate(child.gameObject);
     }
   }
   
   numberRoot = new GameObject(numberRootName);
   numberRoot.transform.parent = transform;
   numberRoot.transform.localScale = Vector3.one;
   numberRoot.transform.localPosition = Vector3.zero;
   numberRoot.transform.localRotation = Quaternion.identity;
   
   for (int i = 0; i < largeLineCount; i++) {
     
     GameObject handleObj = new GameObject("NumberHandle_"+i);
     handleObj.transform.parent = numberRoot.transform;
     handleObj.transform.localRotation = Quaternion.Euler(0, 0, - (float)i / largeLineCount * 360.0f);
     handleObj.transform.localScale = Vector3.one;
     handleObj.transform.localPosition = Vector3.zero;
     
     GameObject numObj = Instantiate(numberPrefab) as GameObject;
     numObj.transform.parent = handleObj.transform;
     numObj.transform.localScale = Vector3.one;
     numObj.transform.localRotation = Quaternion.identity;
     
     TextMesh textMesh = numObj.GetComponent<textMesh>();
     
     if (textMesh != null) {
       
       textMesh.text = i.ToString();
       numObj.name = "Number_" + textMesh.text;
       textMesh.transform.localPosition = new Vector3(0, numberRadius, 0);
       
     }
   }
   
   prevNumberRadius = numberRadius;
 }
 // <- 追加ここまで
 
 static public Vector2[] CreateUv(int originX, int originY, Vector2 lineSize, int texWidth, int texHeight, int padding, int border, int scale) {
   
   float minX = (float)(originX + padding) / texWidth;
   float maxX = (float)(originX + padding + (border * 2 + lineSize.x) * scale) / (float)texWidth;
   float minY = (float)(originY + padding) / texHeight;
   float maxY = (float)(originY + padding + (border * 2 + lineSize.y) * scale) / (float)texHeight;
   
   Vector2[] uv = new Vector2[] {
     new Vector2(minX, minY),
     new Vector2(maxX, minY),
     new Vector2(minX, maxY),
     new Vector2(maxX, maxY)
   };
   
   return uv;
 }
}

「TimeCircle」のインスペクタに「Number Prefab」の項目が追加されていますので、先ほど作成した「Number」プレファブをアサインしてください。サークルの目盛りの内側に数字が表示される様になっていると思います。

unitysw_4_4.png

スクリプトで「Number」プレファブを秒の数だけ作って回転させた位置においています。前回の様にメッシュを自力でまとめたりはしていませんが、この段階でDrawcallを見てもおそらく2しか使ってないと思います。おそらく「TimeCircle」で1、「TextMesh」で1使っているだけでうまいことDynamic Batchが効いているんじゃないでしょうか。

今回のWebPlayerビルド

今回はここで終わって、次回はサークルを動かしたいと思います。

前へ | 次へ

Unityでストップウォッチを作る その3 目盛りのメッシュを作る

今回は秒数目盛りのメッシュを表示するオブジェクトを作りたいと思います。こんな感じの1分で1周するアナログのストップウォッチ的な目盛りです。

unitysw_3_1.png

このように画面よりもかなり大きい目盛りの円(以後サークルと呼びます)を作りたいので、一枚の大きな画像を表示するのではなく、目盛りひとつの画像をたくさん並べて表示するという作り方をします。

スクリプトの作成

Projectウィンドウの「Script」フォルダの中に「BSWUtililty.cs」と「TimeCircle.cs」の2つのC#スクリプトを追加して、以下のコードをコピーしてください。

//
// BSWUtility.cs
//
using UnityEngine;
using System.Collections;
public class BSWUtility : MonoBehaviour {
  static public void DrawRect(Texture2D tex, Rect rect, Color col) {
   
    int minX = (int)rect.x;
    int minY = (int)rect.y;
    int maxX = (int)rect.xMax;
    int maxY = (int)rect.yMax;
   
    for (int x = minX; x < maxX; x++) {
      for (int y = minY; y < maxY; y++) {
        tex.SetPixel(x, y, col);
      }
    }
  }
 
  static public Rect CreateRectForClear(int originX, int originY, Vector2 lineSize, int padding, int border, int scale) {
   
    return new Rect(
      originX,
      originY,
      padding * 2 + (lineSize.x + border * 2) * scale,
      padding * 2 + (lineSize.y + border * 2) * scale);
  }
 
  static public Rect CreateRectForDraw(int originX, int originY, Vector2 lineSize, int padding, int border, int scale) {
   
    return new Rect(
      originX + padding + border * scale,
      originY + padding + border * scale,
      lineSize.x * scale,
      lineSize.y * scale);
  }
}
//
// TimeCircle.cs
//
using UnityEngine;
using System.Collections;
[ExecuteInEditMode()]
[RequireComponent(typeof(MeshRenderer))]
[RequireComponent(typeof(MeshFilter))]
public class TimeCircle : MonoBehaviour {
 
 public float lineRadius = 640.0f;
 public Vector2 largeLineSize = new Vector2(2.0f, 12.0f);
 public Vector2 smallLineSize = new Vector2(1.5f, 8.0f);
 public int largeLineCount = 60;
 public int partLineCount = 10;
 public Color largeLineColor = Color.white;
 public Color smallLineColor = Color.gray;
 public int textureOffsetX = 0;
 public int textureOffsetY = 0;
 
 float prevLineRadius = 0;
 Vector2 prevLargeLineSize = Vector2.zero;
 Vector2 prevSmallLineSize = Vector2.zero;
 int prevLargeLineCount = 0;
 int prevPartLineCount = 0;
 Color prevLargeLineColor = Color.clear;
 Color prevSmallLineColor = Color.clear;
 int prevTextureOffsetX = -1;
 int prevTextureOffsetY = -1;
 
 MeshFilter meshFilter;
 
 void Start () {
   
   UpdateLines();
   
 }
 
 void Update () {
   
   if (IsLinePropertyChanged()) {
     
     UpdateLines();
   }
 }
 
 bool IsLinePropertyChanged() {
   
   if (lineRadius != prevLineRadius ||
     largeLineSize != prevLargeLineSize ||
     smallLineSize != prevSmallLineSize ||
     largeLineCount != prevLargeLineCount ||
     partLineCount != prevPartLineCount ||
     largeLineColor != prevLargeLineColor ||
     smallLineColor != prevSmallLineColor ||
     textureOffsetX != prevTextureOffsetX ||
     textureOffsetY != prevTextureOffsetY) {
     
     return true;
   }
   
   return false;
 }
 
 void UpdateLines()
 {
   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 padding = 1;
   int border = 1;
   int scale = 2;
   int originX = textureOffsetX;
   int originY = textureOffsetY;
   int texWidth = tex2d.width;
   int texHeight = tex2d.height;
   
   Rect largeClearRect = BSWUtility.CreateRectForClear(originX, originY, largeLineSize, padding, border, scale);
   BSWUtility.DrawRect(tex2d, largeClearRect, Color.clear);
   
   Rect largeDrawRect = BSWUtility.CreateRectForDraw(originX, originY, largeLineSize, padding, border, scale);
   BSWUtility.DrawRect(tex2d, largeDrawRect, largeLineColor);
   
   Vector2[] largeUv = CreateUv(originX, originY, largeLineSize, texWidth, texHeight, padding, border, scale);
   
   originX += (int)(padding * 2 + (largeLineSize.x + border * 2) * scale);
   
   Rect smallClearRect = BSWUtility.CreateRectForClear(originX, originY, smallLineSize, padding, border, scale);
   BSWUtility.DrawRect(tex2d, smallClearRect, Color.clear);
   
   Rect smallDrawRect = BSWUtility.CreateRectForDraw(originX, originY, smallLineSize, padding, border, scale);
   BSWUtility.DrawRect(tex2d, smallDrawRect, smallLineColor);
   
   Vector2[] smallUv = CreateUv(originX, originY, smallLineSize, texWidth, texHeight, padding, border, scale);
   
   tex2d.Apply();
   
   renderer.sharedMaterial.mainTexture = tex2d;
   
   // Create Mesh
   
   int lineCount = largeLineCount * partLineCount;
   int vertexCount = lineCount * 4;
   int triangleCount = lineCount * 6;
   
   Vector3[] vertices = new Vector3[vertexCount];
   int[] triangles = new int[triangleCount];
   Vector2[] uv = new Vector2[vertexCount];
   
   Vector3[] largeLinePos = CreateLinePositions(largeLineSize, border, lineRadius);
   Vector3[] smallLinePos = CreateLinePositions(smallLineSize, border, lineRadius);
   
   for (int i = 0; i < lineCount; i++) {
     
     int vTopIndex = i * 4;
     int tTopIndex = i * 6;
     
     float angle = - ((float)i / lineCount) * 360.0f;
     var rot = Quaternion.Euler(0, 0, angle);
           var m = Matrix4x4.TRS(Vector3.zero, rot, Vector3.one);
     
     Vector3[] currentPos;
     Vector2[] currentUv;
     
     if (i % partLineCount == 0) {
       
       currentPos = largeLinePos;
       currentUv = largeUv;
     
     } else {
       
       currentPos = smallLinePos;
       currentUv = smallUv;
       
     }
     
     for (int j = 0; j < 4; j++) {
       
       int index = vTopIndex + j;
       vertices[index] = m.MultiplyPoint3x4(currentPos[j]);
       uv[index] = currentUv[j];
       
     }
     
     triangles[tTopIndex] = vTopIndex;
     triangles[tTopIndex + 2] = triangles[tTopIndex + 3] = vTopIndex + 1;
     triangles[tTopIndex + 1] = triangles[tTopIndex + 4] = vTopIndex + 2;
     triangles[tTopIndex + 5] = vTopIndex + 3;
   }
   
   mesh.vertices = vertices;
   mesh.triangles = triangles;
   mesh.uv = uv;
   
   mesh.RecalculateNormals();
   mesh.RecalculateBounds();
   mesh.Optimize();
   
   // Keep Properties
   
   prevLineRadius = lineRadius;
   prevLargeLineSize = largeLineSize;
   prevSmallLineSize = smallLineSize;
   prevLargeLineCount = largeLineCount;
   prevPartLineCount = partLineCount;
   prevLargeLineColor = largeLineColor;
   prevSmallLineColor = smallLineColor;
   prevTextureOffsetX = textureOffsetX;
   prevTextureOffsetY = textureOffsetY;
 }
 
 static public Vector3[] CreateLinePositions(Vector2 lineSize, int border, float radius) {
   
   float minX = - lineSize.x * 0.5f - border;
   float maxX = lineSize.x * 0.5f + border;
   float minY = - lineSize.y * 0.5f - border + radius;
   float maxY = lineSize.y * 0.5f + border + radius;
   
   return new Vector3[] {
     new Vector3(minX, minY, 0.0f),
     new Vector3(maxX, minY, 0.0f),
     new Vector3(minX, maxY, 0.0f),
     new Vector3(maxX, maxY, 0.0f)
   };
   
 }
 
 static public Vector2[] CreateUv(int originX, int originY, Vector2 lineSize, int texWidth, int texHeight, int padding, int border, int scale) {
   
   float minX = (float)(originX + padding) / texWidth;
   float maxX = (float)(originX + padding + (border * 2 + lineSize.x) * scale) / (float)texWidth;
   float minY = (float)(originY + padding) / texHeight;
   float maxY = (float)(originY + padding + (border * 2 + lineSize.y) * scale) / (float)texHeight;
   
   Vector2[] uv = new Vector2[] {
     new Vector2(minX, minY),
     new Vector2(maxX, minY),
     new Vector2(minX, maxY),
     new Vector2(maxX, maxY)
   };
   
   return uv;
 }
}

オブジェクトの作成と設定

スクリプトが作成できたら、シーンの中の「Root」オブジェクトの子に「Root/Circles/SecTimeCircle」という配置でゲームオブジェクトを2つ作成してください。それぞれのPositionを「Circles」は「X=0 / Y=-640 / Z=0」に、「SecTimeCircle」は「X=0 / Y=0 / Z=0」にしてください。

「SecTimeCircle」オブジェクトのComponentに「TimeCircle.cs」を追加します。

このままでは何も表示するメッシュやテクスチャがありませんので、前回のスクリプトを使って作成します。

ProjectウィンドウのCreateメニューから「Empty Mesh」を選択してメッシュを作成し、「SecTimeCircleMesh」と名前を変更してください。今後いくつかメッシュを作る事になるので「Mesh」というフォルダを作成してその中に移動しておいてください。

同じくProjectウィンドウのCreateメニューから、今度は「Texture...」を選択し、開いたウィンドウのなかの「Width」と「Height」が両方とも「128」、「Texture Format」が「ARGB32」になっている事を確認したら「Create」ボタンをクリックしてテクスチャを作成してください。テクスチャは今後もこの一枚しか使わないので適当に「Texture/Texture」な感じに置いておいてください。テクスチャ設定の「Filter Mode」は「Bilinear」にしておきます。

このテクスチャを使うのにはMaterialが必要なので作っておきます。Projectウィンドウの「Create/Material」で作成し、これも今後一つしか使わないので適当な名前ですが「Material/Material」のように置いておいてください。「Texture」をこのマテリアルにアサインし、shaderは「Unlit/Transparent」を選択します。このシェーダはラインティングの影響を受けず、テクスチャの透明部分を透過するシェーダーです。実はこのシェーダーを使うとちょっと問題があるのですが、その修正はチュートリアルの最後の方でやりますので、今の所はこれで我慢します。

先ほどシーン上に作った「SecTimeCircle」オブジェクトに「Material」と「SecTimeCircleMesh」をアサインします

この時点で画面上に秒数目盛りのラインが表示されていると思います。あと、タイムのテキストがサークルと被ってしまっているので、TimeTextのYを100くらいに移動しておいてください。

ここまで作業を終えると、プロジェクトの状態は以下のような感じになっていると思います。もし表示されていなかったら一旦Unityを再生してみると表示されるかもしれません。

unitysw_3_2.png

解説

「TimeCircle」のインスペクタのパラメータは以下のような感じです。

・lineRadius > サークルの半径
・largeLineSize > 長いラインのサイズ
・smallLineSize > 短いラインのサイズ
・largeLineCount > 長いラインの数(サークル全体が1分なら60で1秒)
・partLineCount > 長いラインの間を短いラインで分ける数(1秒につき0.1秒ごとなら10)
・largeLineColor > 長いラインの色
・smallLineColor > 短いラインの色
・textureOffsetX > テクスチャに描画する左の位置
・textureOffsetY > テクスチャに描画する下の位置

とりあえず良い感じに表示するようにパラメータの初期値を入れていますが、これらのパラメータを変更するとUnityを再生していなくてもリアルタイムで表示が変更されていると思います。これはスクリプトに「[ExecuteInEditMode]」と記述しているからで、これによって再生していないエディット中でもStart()やUpdate()が呼ばれます。ただ、毎アップデートごとにデータを更新するのはさすがに処理が重いので、データを更新したら「prev〜」という変数に保存して余計なアップデートをしない様にしています。実際にアプリをリリースするときにはStart()だけでやればいい処理なので、Updateはリリースビルド時に削除するようなマクロを仕込んでおいた方がいいかもしれません。

「UpdateLines()」というメソッドの中ではテクスチャへの描画とメッシュの作成をしています。やっていることは、2種類の目盛りの画像をテクスチャに描画するのと、その目盛りをたくさん並べた大きなサークルをひとつのメッシュとして作成する事です。Unityでテクスチャへ描画するのはドットを打つくらいしか出来ないのですが、BigStopWatchでは単純な四角くらいしか必要としないので十分です。

UnityのメッシュはOpenGLでいうところのGL_TRIANGLESで描画されます。頂点配列をmesh.verticesに、UV配列をmesh.uvに同じ要素数になるように作り、mesh.trianglesでその頂点番号の配列を作ってメッシュへ設定しています。

頂点配列(vertices)は必要な分だけ作れば良いのですが、頂点番号の配列(triangles)は、三角形ごとの頂点単位、つまり3つ単位でないといけません。たとえば四角形をひとつ作るなら、三角形を二つ組み合わせるけど2つの頂点は共通で使えるので、verticesとuvの配列の要素数は4、trianglesは6という事になります。

「UpdateLines()」の中の処理で、後々共通で使いたいものは「BSWUtility」の方に記述しています。四角をテクスチャに描画したり座標を取得するところあたりです。

目盛りはこんな感じで、四角に少しマージンを持たせて描画しています。

unitysw_3_3.png

四角いメッシュを単純に塗りつぶせばいいんじゃないかと思うかもしれませんが、やってみるとジャギーでひどい事になってしまいます。最近のiOSではマルチサンプリングが使えたりして多少ごまけたりもしますが、補間をテクスチャに任せたほうが遥かにきれいな線が表示できます。ただ、それは回転させるからの話で、回転させない四角は、ぴったりとピクセルが合う様に配置して補間させない方がきれいです。

メッシュは「lineRadius」の値だけY軸方向に移動させた位置に目盛りひとつ分の座標を「CreateLinePositions()」でつくって、それを必要な数だけMatrixで回転させてメッシュ全体を作っている感じです。

わざわざ1個のメッシュにしないで、目盛り1個のゲームオブジェクトを複製して作っても見た目は同じでコードも分かりやすいと思うのですが、やはり処理の負荷を考えると最初からまとめられるメッシュはまとめておいた方が良いと思います。UnityではDynamicBatchという機能があって勝手にメッシュをまとめてくれたりもしますが、一度にまとめられる頂点数に制限があったりとかあまり当てにしすぎると裏切られたりするので、自分でやっておくにこしたことはないです。

今回のWebPlayerビルド

以上で今回は終わりです。次回はこのサークルに数字を追加します。

前へ | 次へ

Unityでストップウォッチを作る その2 メッシュとテクスチャの作成

前回はタイム表示をしたので、次は目盛りのラインなどを描画したいと思う訳ですが、Unityで描画をするにはまずなによりメッシュが必要になります。しかし、Unityのプロジェクトを作った段階では、CubeやらSphereなどの3D用のものが数種類と、無駄に細かいメッシュのPlaneしか作れません。BigStopWatchのラインひとつひとつを標準のPlaneで置いていくのはちょっと無駄な感じです。

さらにBigStopWatchではきれいなラインを引くために、テクスチャにラインを描画して少しマージンを付けたものをメッシュに割り当てて描画しています。メッシュを単純に塗りつぶすとメッシュのエッジ部分がガタガタになってしまいます。なのでそのテクスチャも作りたいのですが、Unityのメニューのどこを見渡してもテクスチャを作成するような項目は見つかりません。

しかし、なければ自分で作ってしまえるというのもUnityの良い所です。今回はエディタ拡張をちょっとだけ使ってメッシュとテクスチャを作れるようにしたいと思います。

メッシュとテクスチャをスクリプトで作る

Projectウィンドウ内のメニューから「Create/Folder」を選択して「Editor」というフォルダを作成します。このフォルダ名は重要です。以後作っていくフォルダ名は適当で良いですが、これはちゃんと「Editor」としてください。

その「Editor」フォルダ内に
「BSWEditorUtility.cs」
「CreateEmptyMesh.cs」
「CreateTexture.cs」
という3つのC#スクリプトを作成してください。

unitysw_2_1.png

それぞれのスクリプトは以下になります。

//
// BSWEditorUtility
//
using UnityEngine;
using UnityEditor;
using System.IO;
using System.Collections;
public class BSWEditorUtility : MonoBehaviour {
  static public string GetSelectedDirectoryPath() {
     
    string path = null;
     
    foreach (UnityEngine.Object obj in Selection.objects) {
         
      path = AssetDatabase.GetAssetPath(obj);
         
      if (!string.IsNullOrEmpty(path) && !Directory.Exists(path)) {
        path = Path.GetDirectoryName(path);
      }
         
      break;
    }
     
    if (string.IsNullOrEmpty(path)) {
     return "Assets";
    }
     
    return path;
  }
}
//
// CreateTexture.cs
//
using UnityEngine;
using UnityEditor;
using System.Collections;
public class CreateTexture : ScriptableWizard {
 
  public string textureName;
  public int width = 256;
  public int height = 256;
  public TextureFormat textureFormat = TextureFormat.ARGB32;
  
  [MenuItem ("Assets/Create/Texture...")]
  static void CreateWizard()
  {
    ScriptableWizard.DisplayWizard("Create Texture", typeof(CreateTexture));
  }
  
  void OnWizardCreate() {
    
    if (string.IsNullOrEmpty(textureName)) {
      textureName = "New Texture " + width + "x" + height;
    }
     
    string path = BSWEditorUtility.GetSelectedDirectoryPath() + "/" + textureName + ".asset";
    path = AssetDatabase.GenerateUniqueAssetPath(path);
    
    Texture2D tex = new Texture2D(width, height, textureFormat, false);
    tex.name = textureName;
     
    ClearTexture(tex);
    
    AssetDatabase.CreateAsset(tex, path);
  }
   
  static public void ClearTexture(Texture2D tex) {
     
    int texWidth = tex.width;
    int texHeight = tex.height;
     
    for (int x = 0; x < texWidth; x++) {
      for (int y = 0; y < texHeight; y++) {
        tex.SetPixel(x, y, Color.clear);
      }
    }
  }
}
//
// CreateEmptyMesh.cs
//
using UnityEngine;
using UnityEditor;
using System.Collections;
public class CreateEmptyMesh : MonoBehaviour {
  [MenuItem ("Assets/Create/Empty Mesh")]
  static void Create() {
   
    string path = BSWEditorUtility.GetSelectedDirectoryPath() + "/New Empty Mesh.asset";
    path = AssetDatabase.GenerateUniqueAssetPath(path);
   
    Mesh mesh = new Mesh();
   
    AssetDatabase.CreateAsset(mesh, path);
  }
}

これらのコードを追加する事でメニューの「Assets/Create/」の中に新たにメニュー項目が追加されます。「Empty Mesh」を選択すれば空のメッシュが作成でき、「Texture...」を選択すればサイズを指定してテクスチャが作成できるようになります。

ちょっと解説

今回のスクリプトには全て「using UnityEditor」という宣言が追加されています。この名前空間にはエディタとしての機能を利用できるクラス、例えば今回だとAssetDatabaseというプロジェクトウィンドウ内のアセットにアクセスできるクラスなどがあります。UnityEditorの名前空間を使うスクリプトは「Editor」フォルダに入れておかないといけないようです。と言っても、エディター上で実行しているだけなら「Editor」フォルダに入れていなくても問題なく使えてしまいます。ただ、ビルドするときにはエラーが出てしまうので、何かの理由でEditorフォルダに入れたくないということがあるようでしたら、以下のページに解決法が書いてありましたので、参考にすると良いと思います。
【Unity Tips】using UnityEditorを使うけどEditorに入れたくない

メニューに項目を追加して実行できるようにしているのは「[MenuItem ("〜")]」という一行です。これによってメニューに項目が追加され、このあとに記述されたメソッドがメニューを選択すると実行されます。ちなみに、「Asset/Create/」というメニューのパスで指定されたものは、画面上のメニューのAssetsに表示されるだけでなく、ProjectウィンドウのCreateメニューにも表示されます。

unitysw_2_3.png

「BSWEditorUtility」はメッシュとテクスチャの両方共通の処理があったので作りました。GetSelectedDirectoryPath()というのがそれで、Projectウィンドウで選択されているフォルダを取得するものです。Selectionクラスで選択されているフォルダorファイルが取得できるので、選択されていればその階層のフォルダを返し、選択されていない場合は最上位階層の「Assets」を返すようにしています。

「CreateEmptyMesh」と「CreateTexture」は、それぞれ新たにメッシュやテクスチャを作成してアセットとして保存するということをやっています。ただ単に「new Mesh()」とするだけではいずれどこかに消えてなくなりますが、AssetDatabase.CreateAsset()でアセットとして保存して残せます。

今回は以上です。次回は実際にメッシュの中身を作成します。

前へ | 次へ

Unityでストップウォッチを作る その1 基本機能

はじめに

しばらく何も開発ネタを書いていなかったので、Unityを使って簡単なBigStopWatch風のストップウォッチをチュートリアル形式で作っていこうと思います。

Unityというと3Dが得意なのは間違いないですが、2Dものを作るときにもエディタ上で逐一確認しながら作れるので非常に便利です。このチュートリアルでは使いませんが、NGUIとかUni2Dとか他にもいろいろ2D用のアセットがAsset Storeにありますので工夫をすれば2Dゲームもちゃんと作れます。

本屋さんに行くとUnityの入門書がまた最近増えているのを見かけますが、だいたいどれも、無料のアセットを使ってゲームを作ろう的な内容だったりするので(当たり前ですが)ここでは違うアプローチで、外部アセットはいっさい使わず素材ゼロの状態から2Dツールアプリ作っていこうと思います。実際、BigStopWatchはアイコン以外のリソースは全く使っていません。

いちおう対象は、Unityの入門書1冊くらいは読み終えてUnityの基本的な使い方が分かっている方を想定しています。Unityのインストールの仕方のような初歩の初歩からは解説しませんが、出来るだけ手順は飛ばさない様にするつもりです。

たぶん、全部で10回くらいに分けて投稿する予定です。もし、「ここのコードはこうした方が良い」とか「ここの部分がよくわからない」といったところなどがありましたらコメントをいただければ幸いです。大部分はもう書き終わってはいますが、ご意見があれば少々修正しながら公開していこうかなと思っております。

Unityのバージョンは4.2を使用しています。

プロジェクトの作成と設定

それでは早速Unityで新規にプロジェクトを作ってください。

プロジェクトが開いたらMain Cameraだけが置いてあるシーンが出来ていると思いますので、とりあえず「Main」というシーン名で保存しておきましょう。

ここではiPhoneの横画面向けに作っていきますので、開発しやすい設定をしておきます。

「Build Settings」でiOSにSwitch Platformしてください(今はUnity iOSは無料ですのでユーザー登録さえしておけば最初から選べる様になっているかと思います)。また、横向きのレイアウト固定にしますので、「Player Settings」の「Resolution and Presentation」の「Default Orientation」をAuto Rotationにして、「Landscape Right」と「Landscape Left」にチェックを入れてください。これでiOSでビルドして試すときにも横向きに表示されます。

unitysw_1_0.png

エディターの再生時に使うGameウィンドウのアスペクト比の設定を「iPhone Wide」または「iPhone 4G Wide」にします。お使いのディスプレイの大きさによって全体が見える方を選んでください。基本的にはiPhone4の画面サイズでレイアウトをして、iPhone5の横長の画面サイズにしても問題ないような作り方にしようと思います。iPadを考えると画面比率的には横幅が狭くなってしまうのですが、それは気にしない事にします。

unitysw_1_0_1.png

とりあえずiPhone向けの設定をしましたが、以後このチュートリアルの中では特にビルドしたりなどの記述はしないつもりです。モバイル実機で動かしたい方は、ご自身で適当にビルドして試してください。Androidで動かしたい方はAndroid向けの設定をしておいても良いと思います。

では、ここからはシーンを編集していきましょう。

カメラの設定

既にシーンに置いてある「Main Camera」オブジェクトのパラメータをインスペクタで以下のように変更してください。

・Position > X = 0 / Y = 0 / Z = 0
・Clear Flags > Solid Color
・Background > 黒
・Projection > Orthographic
・size > 160
・Clipping Planes > Near = -1 / Far = 1

unitysw_1_1.png

2Dモノしか描画させないのでOrthographicにして遠近感を無くし、Unity上での縦の長さをiPhoneの横幅のサイズと同じ320と一致させたいのでSizeを160にしています。

Backgroundの色は別に何色でもいいのですが、とりあえずBigStopWatchと同じ黒にしてあります。

オブジェクトは基本的にZを0の位置に置いて、そんなに前後は使わないつもりですので、カメラの表示範囲を-1〜1にしています。

タイム表示をするテキストを作成

ストップウォッチのタイム表示をするテキストを作成しましょう。

新規にゲームオブジェクトをシーンに作成して「Root」と名前を変更し、Positionを「X=0 / Y=0 / Z=0」にしてください。このオブジェクトは以降、時間表示をするオブジェクトの親にします。

メニューから「GameObject / Create Other / 3DText」を選択してオブジェクトを作成し、「Root」オブジェクトの子に移動します。名前は「TimeText」に変更してください。これもPositionを「X=0 / Y=0 / Z=0」にしてください。

「TimeText」のTextMeshのインスペクタで以下のように変更してください。

・Character Size > 4
・Anchor > middle center
・Alignment > center
・Font Size > 100

unitysw_1_2.png

TextMeshの文字がテクスチャに描画される大きさはFont Sizeで決まり、Character Sizeでメッシュの大きさを調整できます。カメラが表示するスケールを非Retinaの解像度と一致させてRetinaで表示する事を考えると、Character Sizeは4くらいがちょうど良い解像度になると思います。

このエントリーの頭でも宣言した通り外部アセットはいっさい使わずに行くのでフォントはそのままArialを使っていきますが、あまりかっこいいフォントではないので、気に入らない人はネットに転がっているフリーのフォントに差し替えてみると良いと思います。iOSに搭載されているいろいろなフォントを自由に使いたい場合には、Unity内部の機能ではなくプラグインを使ってiOSネイティブの機能でテクスチャに書き込まないといけないのですが、それはこのチュートリアルでは解説しません。

Stopwatchクラスの作成

ストップウォッチの基本機能を実装したStopwatchクラスを作成します。

メニューの「Asset / Create / C# Script」で「Stopwatch.cs」という新規スクリプトを作成してください。後々アセットが増えてきたときの事も考えて、「Script」というフォルダを作成し「Stopwatch.cs」を中に入れて整理しておいてください。

Stopwatch.csに記述するコードは以下になります。

//
// Stopwatch.cs
//
using UnityEngine;
using System;
using System.Collections;
public class Stopwatch : MonoBehaviour {
    
    enum StopwatchState {
        Zero,
        Play,
        Pause
    }
    public TextMesh timeText;
    StopwatchState state = StopwatchState.Zero;
    TimeSpan lastStopTimeSpan;
    DateTime startDateTime;
    void Update () {
        if (Input.GetMouseButtonDown(0)) {
            ChangeState();
        }
        UpdateTime();
    }
    void ChangeState() {
        if (state == StopwatchState.Pause) {
            lastStopTimeSpan = new TimeSpan(0);
            startDateTime = DateTime.UtcNow;
            state = StopwatchState.Zero;
        } 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() {
        TimeSpan currentTs;
        if (state == StopwatchState.Play) {
            TimeSpan ts = DateTime.UtcNow - startDateTime;
            currentTs = ts + lastStopTimeSpan;
        } else {
            currentTs = lastStopTimeSpan;
        }
        if (timeText != null) {
            timeText.text = ConvertTimeSpanToString(currentTs);
        }
    }
    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」という名前でゲームオブジェクトを作成し、Componentに追加してください。その「Stopwatch」オブジェクトの「timeText」プロパティに先ほど作成した「TimeText」オブジェクトをアサインします。

unitysw_1_6.png

この状態でストップウォッチとして機能するようになっているはずです。Unityを再生して画面上をタップ or クリックすると、「再生」→「停止」→「ゼロにリセット」→「再生」→…と動きます。

Stopwatchクラスの解説

Stopwatchクラスに関して、いくつかポイントとなる所を解説していきます。

ストップウォッチの状態を「StopwatchState」というenumで作りました。画面をクリックしたら、状態が「Zero」「Play」「Pause」を順に切り替わる様な動作にしています。拙作のBigStopWatchではもうちょっといろんな機能があって状態の種類も多いのですが、このチュートリアルではそんなに多機能にはしないので、この3種類だけ使います。

時間の管理にはDateTimeとTimeSpanを使っています。DateTimeは時刻を、TimeSpanは時間間隔をそれぞれ表す構造体です。

詳しいリファレンスはこちら
DateTime 構造体
TimeSpan 構造体

Unityで時間というと、よく使われるTimeクラスがありますが、これはアプリが起動してからの時間でしかもアプリが停止中は時間が加算されないので、今回のようなストップウォッチの計測時間を記録するのには使えません。ですので、ストップウォッチの経過時間を計るために、実時間を表すDateTimeを使って取得しています。

計測時間がゼロのときの表示タイムはうむをいわさず0分0秒です。

画面をタップして計測を開始し始めたら、そのタップしたときの時刻を記録しておいて、現在の時間との差分を計算してタイム表示しています。

一時停止してから計測を再開する場合には、再開時点での表示タイムと、再開したタイミングの時刻からの現在までの差分を足します。今のコードではそのような積算の機能はないのですが、チュートリアルの後半で実装する予定です。

ちなみに、時刻の取得にはDateTime.NowではなくDateTime.UtcNowを使っています。DateTime.Nowだと、デバイスのタイムゾーンを計測途中に変えたりした場合に時間のずれが発生してしまうので、UTCにしておく事でそのような設定変更によるずれが起きないようにしています。

今回のWebPlayerビルド

今回は以上です。次回はメッシュやテクスチャの作成を出来るようにしたいと思います。

次へ

AudioQueueProcessingTap

iOS6からAudioQueueServiceにProcessingTapという機能が追加されました。

これを利用すると、Queueにバッファされた後のデータにエフェクトなど処理を挟み込む事ができるようになります。AudioQueueのピッチ処理はiOSでは機能していないと思いますが、AudioUnitを挟み込む事もできるので、VarispeedとかNewTimePitchとか使えば実現する事ができます。

AudioQueueProcessingTapNew

ProcessingTapをAudioQueueで使えるようにするのが以下の関数です。

extern OSStatus
AudioQueueProcessingTapNew(
        AudioQueueRef inAQ,
        AudioQueueProcessingTapCallback inCallback,
        void *inClientData,
        UInt32 inFlags,
        UInt32 *outMaxFrames,
        AudioStreamBasicDescription *   outProcessingFormat,
        AudioQueueProcessingTapRef *    outAQTap)

AudioQueueProcessingTapNewでAudioQueueProcessingTapを作って、既に作ってあるAudioQueueに追加します。なお、ひとつのAudioQueueにつき、ひとつのProcessingTapしか割り当てられないようです。

inAQにはProcessingTapを入れたいAudioQueueを指定します。

inCallbackには処理をするコールバックの関数を指定します。引数の構成はAudioUnitのRenderCallbackと似たような感じです。

inFlagsではCallbackの動作の仕方を指定する事ができます。使えるのは以下の3つです。

kAudioQueueProcessingTap_PreEffects
kAudioQueueProcessingTap_PostEffects
kAudioQueueProcessingTap_Siphon

PreEffectsとPostEffectsは必ずどちらかを指定しないといけません。ここでのEffectというのはMacでのみ使えるAudioQueueのピッチ処理の事のようで、その前後のどちらかを選択できるようです。iOSだと、今の所どちらを選択しても動作的にはおそらく変わりません。

Siphonをフラグに足しておくと、CallbackのioDataにバッファからのデータが入った状態で来ます。フラグを入れなければ、AudioQueueProcessingTapGetSourceAudioという関数を使ってバッファからデータを読み込まなければいけません。ProcessingTapのコールバックの中だけでゴリゴリ自分で処理をするならSiphoneを入れる、AudioUnitを使って入力側のコールバックにデータが欲しい場合やスピードを変更したい場合はSiphonを入れないでAudioQueueProcessingTapGetSourceAudioを呼ぶ、という事になると思います。

outMaxFramesやoutProcessingFormatには、ProcessingTapコールバックでの最大のフレーム数や、フォーマットが返ってきます。たぶん自分でフォーマットを指定したりとかはできません。オーディオファイルをリニアPCMに解凍した状態のフォーマットが返ってくると思います。AudioUnitのエフェクトを使いたい場合は、AUConverterなどを使ってoutProcessingFormatに変換したり、outMaxFramesのフレーム数をmaximumPerFramesに設定する必要があると思います。

AudioQueueにProcessingTapを追加する簡単なコードは以下のような感じです。

//
// インスタンス変数とか(AudioQueueは既にセットアップ済みを想定)
// AudioQueueRef _queue;
// AudioQueueProcessingTapRef _processingTap;
//
static void Callback(
        void *inClientData,
        AudioQueueProcessingTapRef inAQTap,
        UInt32 inNumberFrames,
        AudioTimeStamp *ioTimeStamp,
        UInt32 *ioFlags,
        UInt32 *outNumberFrames,
        AudioBufferList *ioData)
{
    AudioQueueProcessingTapGetSourceAudio(
            inAQTap,
            inNumberFrames,
            ioTimeStamp,
            ioFlags,
            outNumberFrames,
            ioData);
    // ここで何か処理をする
}
- (void)setupAudioQueueProcessingTap
{
    UInt32 processingMaxFrames;
    AudioStreamBasicDescription processingFormat;
    BOOL isPost = YES;
    BOOL isSiphon = NO;
    UInt32 flags = isPost ? kAudioQueueProcessingTap_PostEffects :
                       kAudioQueueProcessingTap_PreEffects;
    if (isSiphon) flags |= kAudioQueueProcessingTap_Siphon;
    AudioQueueProcessingTapNew(
            _queue,
            Callback,
            NULL,
            flags,
            &processingMaxFrames,
            &processingFormat,
            &_processingTap);
}

このコードではエフェクトが何もかからないスルー状態です。Siphonをフラグに入れていないのでAudioQueueProcessingTapGetSourceAudioを呼んでいます。何か処理をしたい場合は、GetSourceAudioの後にioDataの中身をいじってください。もしSiphonをいれたらAudioQueueProcessingTapGetSourceAudioの行は全くなしでも音が鳴ります。

AudioUnitのエフェクトを使う場合には、AudioUnitの入力側のコールバックでGetSourceAudioを呼ぶ事になります。

あと、スピードを変更する場合、AudioUnitのVariSpeedを使うなら気にしなくてもいいのですが、自分でやる場合にはGetSourceAudioに渡すTimeStampのsampleTimeをちゃんと使ったフレーム分だけ毎コールバック進めないといけません。ProcessingTapに来ているTimeStampそのまま渡すと、ノーマルスピードで進んでいると判断してデータを取ってきてしまいます。

iOS 5のオーディオ新機能

世の中iOS 5といったらiCloudだSiriだと騒いでいますが、オーディオの新機能も実は結構あります。とりあえずどんなものがあるかだけ書いておきます。

AudioUnitプラグイン

AudioUnitのプラグインがかなり追加されてます。Macでは以前からあるもののサブセット的な感じです。

【Generator】
・ScheduledSoundPlayer
・AudioFilePlayer

【MusicDevice】
・Sampler

音を再生するものはGeneratorとMusicDeviceですね。特にSamplerはSoundFont&MIDI対応のプレイバックシンセなので、SoundFontを作ってしまえばMIDI対応も簡単にできてしまいます。「なんとかピアノ」とか「シンギングなんとか」とか、AVAudioPlayerで無理矢理つくるんじゃなくて、これ使った方が良いです。AudioFilePlayerは、これはこれでいいんですが、使い方が独特ってのもありますし、個人的にはAudioQueueのAudioUnitプラグイン版を作ってほしいなぁと思います。

【FormatConverter】
・VariSpeed
・iPodTimeOther

僕は使ってませんが、VariSpeedつかえば再生スピードの変更も簡単です。AUConverterでも良いんじゃないの?と思うかもしれませんが、VariSpeedならスピードを変えたときにプツっとならずにスムーズに変わります。ただ、すごく速くとか、すごく遅くとか出来ませんので、「Touch the Wave」でやってるようなスクラッチを実現するのは難しいです。

iPodTimeOtherというのは「Touch the Wave for iPad」でも早速使っています。iPodTimeよりもクオリティの上がったタイムストレッチです。

【Effect】
・LowPassFilter
・HiPassFilter
・BandPassFilter
・HighShelfFilter
・LowShelfFilter
・NBandEQ
・ParametricEQ
・PeakLimiter
・DynamicProcessor
・Reverb2
・Distortion

Effectは、半分くらいEQですが、結構増えました。リバーブの追加はうれしいです。

プラグインがこれだけ用意されていると、ようやくAUGraphの便利さが活用できそうです。はっきりいって今までは、RemoteIOだけ使って全部自分で作んなくちゃいけませんでしたから。

MusicPlayer

MIDIシーケンサーです。SMFが読み込めます。中にSamplerを含んだAUGraphを持っているので、入れ替えてあげれば自分で用意した音も鳴らせます。もちろんMIDIメッセージだけ再生させて利用する事も出来ます。

MIDINetworkSession

MIDINetworkSessionがシミュレータに対応してます。実機につながなくてもMacだけでMIDIの送受信ができるので、デバッグが楽になるんじゃないでしょうか。あとiOS4だとMIDINetworkSessionってMIDI使い始めたら勝手にオンになっていたと思うのですが、iOS5だとちゃんとenabledをYESにしたりとかしておかないと使えないみたいです。

Float32対応 (2011/10/23追加)

iOS 5からAudioConverterがFloat32対応になっています。上記でも紹介したAudioUnitプラグインなどは、floatにしか対応していないようです。AUGraphなんかでつなぐ場合はあまり意識しなくても良いのですが、整数で用意したデータを渡すときにはAUConverterを前に挟み込む必要がありそうです。

iOSのCore MIDI

iOSの勉強会用にCoreMIDIを調べてみたりしたのはいいものの、まだ発表する機会がなさそうなのと、気がつけば3ヶ月もブログを更新していなかったので、ちょっと書いておこうかなと思います。とりあえずこのエントリは、CoreMIDI対応アプリを開発する前の準備の話です。(※2011/7/26に内容を変更しています)

CoreMIDIはiPadだけ?

iOS 4.3.3より前のOSだと、外部とMIDIデータの送受信をできるのはiPadだけだったのですが、iOS 4.3.3以降だとiPhoneでもWi-fi経由でiPadと同じく使えるようになったようです。また、iPadではMIDIインターフェースをCamera Connection Kitにつなげれば使えたのですが、最近YAMAHAなどからドックに直接つなげるタイプのMIDIインターフェースが発売されましたので、iPhoneでもそれらをつかえばMIDIの送受信を実機だけで出来るようになりました。

デバッグはWi-Fiで!

MIDIインターフェースをドックに付けた状態だと、Macとつなげる事が出来ないので非常にデバッグがめんどくさいです。なので開発中はドックにつながず、iPhone/iPad(実機)とMacをWi-Fiで接続してWi-Fi経由でMIDIを送受信するようにした方が良いと思います。MacにMIDIインターフェースをつなげるのも良いですが、何かMIDIを送受信出来るアプリケーションがあれば別にMIDIインターフェースがなくても問題ありません。むしろ、いろいろなMIDIメッセージを送信出来るMacアプリケーションを作ってデバッグに活用する方が良いんじゃないでしょうか。

以下は、Macと実機をWi-Fi経由でMIDIの送受信をする接続方法を解説します。これはアプリの開発者に関わらず、MacのシーケンスソフトなどからiOSアプリの音をならしたい、でもケーブルでごちゃごちゃしたくないというユーザーの方にも参考になると思います。ですがWi-Fi経由だと、直接MIDIインターフェースをドックにつなげた場合と違って、それなりに発音の遅れや揺れがあると思いますのでご注意ください。

Wi-Fi経由のCore MIDIの接続方法

まず、実機になにかCoreMIDI対応アプリを入れておきましょう。何も持っていなければ無料の「Midi Monitor」あたりをダウンロードしておけば良いと思います(僕の環境ではしょっちゅう落ちるアプリですが…)。もちろん、自分のアプリを開発するときにはそれを立ち上げればいいので、それで。

Macと実機を同じWi-Fiネットワークに接続してください。Wi-Fiアクセスポイントがない場所でも、MacでAd-Hocネットワークを作成して接続する方法もありますので、わからない方は適当にぐぐってください。

Macの「Audio MIDI 設定」アプリケーションを起動して、「MIDI スタジオ」というウィンドウが開いていなければ、メニューのウィンドウの「MIDI ウィンドウを開く」を選択して開いてください。

MIDIスタジオのなかにネットワークというアイコンがありますのでダブルクリックして「MIDIネットワーク設定」というウィンドウを開きます。

CoreMIDI01.jpg

ウィンドウ左上の「自分のセッション」の「+」をクリックして、ネットワークセッションを作ります。

CoreMIDI03.jpg

おそらく「セッション1」という名前のセッションが出来ると思いますので、そこの左のボタンにチェックを入れます。

CoreMIDI04.jpg

実機側で、CoreMIDI対応のアプリを起動してください。実機では、アプリでMIDIClientを作成する事で自動的にネットワークセッションが作成されます。ちゃんと実機側のネットワークセッションがMacで認識されていれば、ウィンドウ左真ん中の「ディレクトリ」に実機の名前が表示されています。もし、CoreMIDI対応アプリを起動しても名前が現れない場合は、一度実機を再起動してみてからアプリを起動しなおすと良いかもしれません。

CoreMIDI05.jpg

実機の名前が現れたら選択して、下の「接続」ボタンをクリックしてください。接続に成功すれば、右側の「構成」のところに実機の名前が表示されているはずです。

CoreMIDI06.jpg

MacのMIDIアプリケーションからMIDIを送受信する場合は、この時点でMacアプリケーションのMIDIインやアウトでネットワークセッションを選択出来るようになっていると思いますので、Audio MIDI 設定での設定は以上です。

アプリケーションを使わずMIDIインターフェースと実機をつなげたい場合は、ウィンドウ右下のライブルーティングを使います。実機へMIDIメッセージを送りたいMIDIインターフェース(MIDI IN)を上のポップアップボタンで選択してください。逆に実機から送りたいMIDIインターフェース(MIDI OUT)は下のポップアップボタンで選択してください。(手元にUSB接続のMIDIキーボードしかなかったので、下のスクリーンショットではIN側だけ選択した状態です。)

CoreMIDI07.jpg

あとは実機アプリ側の設定ですが、MIDI INやOUTが選択出来るアプリであれば、「Network Session 1」というのがあるはずですので選択してください。そのような設定がないものはつながるものは全部つなげてしまう仕様になっていると思いますので、特に設定する必要はないと思います。

と、今回はこの辺で。

CMSampleBufferからオーディオデータを取り出す

前回の続きというほどのものでもない内容になってしまいますが、CMSampleBufferからオーディオのデータを取り出す方法です。CMSampleBufferGetAudioBufferListWithRetainedBlockBufferという関数を使います。前回の内容も含めてDeveloper Forumsに書いてあったコードを参考に調べていたのですが、削るに削ってこれだけになりました。

CMSampleBufferRef sampleBuffer = [audioMixOutput copyNextSampleBuffer];
CMBlockBufferRef blockBuffer;
AudioBufferList audioBufferList;
        
CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer(sampleBuffer,
                                                        NULL,
                                                        &audioBufferList,
                                                        sizeof(audioBufferList),
                                                        NULL,
                                                        NULL,
                                                        0,
                                                        &blockBuffer);
//
//    ここでAudioBufferListから読み出す処理など
//
CFRelease(sampleBuffer);
CFRelease(blockBuffer);

とりあえずInterleavedなオーディオデータが読み出せればよいのであれば、上記のコードのようにAudioBufferListを作って、その中のデータを保持するCMBlockBufferをこの関数で取得するというだけで良いようです。引数がたくさんあって他にもいろいろ取得できるようですが、結構みんなNULLであっさり通ったので、あえてそれらを使って何かするのでなければいらないんじゃないでしょうか。

AVAssetReaderとAVAssetWriter

iOS4.0のAVAssetExportSessionでiPod Libraryからファイルの書き出しができるようになったことは以前のエントリーに書きましたが、さらにiOS4.1からは、AVAssetReaderによって事前にコピーすること無く、直接iPodLibraryのオーディオファイルのデータを生のデータで読み込むことが出来るようになっています。また、AVAssetWriterという、オーディオファイルを作ることの出来るクラスも追加されています。

AVAssetExportSessionがMP3などを読み込むのにかなり使い勝手の悪いものだったのですが、AVAssetReaderはExtAudioFile的に簡単に好きなフォーマットに変換していろんなファイルが読み込めるので、かなり良さげな感じです。

ちなみに、ここではオーディオファイルだけを扱いますが、ビデオの読み込みや書き出しも出来るはずですので、興味のある方はいろいろ調べてみることをお勧めします。

AVAssetReaderはiPod Libraryから直接リアルタイム再生できたりするのが一番の利点だと思うのですが、ここではあえてAVAssetReaderとAVAssetWriterを使って、ExportSession的にファイルの書き出しをやってみたいと思います。自分も情報の少ない中しらべて成功したという感じでもありますので、おかしなところがありましたらご指摘いただけると助かります。

以下が、サンプルソースです。MPMediaPickerControllerなどでMPMediaItem取得してこのメソッドに渡すとアプリのドキュメントフォルダに書き出されます。セットアップしている途中で引っかかりそうなところはエラー処理的にreturn NOで終わるようにしてあります。

必要なフレームワークは、AVFoundation.FrameworkとCoreMedia.Framework、あとMPMediaItemの取得でMediaPlayer.Frameworkといったところです。

- (BOOL)exportItem:(MPMediaItem *)item
{
    NSError *error = nil;
    
    NSDictionary *audioSetting = [NSDictionary dictionaryWithObjectsAndKeys:
                                  [NSNumber numberWithFloat:44100.0],AVSampleRateKey,
                                  [NSNumber numberWithInt:2],AVNumberOfChannelsKey,
                                  [NSNumber numberWithInt:16],AVLinearPCMBitDepthKey,
                                  [NSNumber numberWithInt:kAudioFormatLinearPCM], AVFormatIDKey,
                                  [NSNumber numberWithBool:NO], AVLinearPCMIsFloatKey,
                                  [NSNumber numberWithBool:0], AVLinearPCMIsBigEndianKey,
                                  [NSNumber numberWithBool:NO], AVLinearPCMIsNonInterleaved,
                                  [NSData data], AVChannelLayoutKey, nil];
    
    //読み込み側のセットアップ
    
    NSURL *url = [item valueForProperty:MPMediaItemPropertyAssetURL];
    AVURLAsset *URLAsset = [AVURLAsset URLAssetWithURL:url options:nil];
    if (!URLAsset) return NO;
    
    AVAssetReader *assetReader = [AVAssetReader assetReaderWithAsset:URLAsset error:&error];
    if (error) return NO;
    
    NSArray *tracks = [URLAsset tracksWithMediaType:AVMediaTypeAudio];
    if (![tracks count]) return NO;
    
    AVAssetReaderAudioMixOutput *audioMixOutput = [AVAssetReaderAudioMixOutput
                                                   assetReaderAudioMixOutputWithAudioTracks:tracks
                                                   audioSettings:audioSetting];
    
    if (![assetReader canAddOutput:audioMixOutput]) return NO;
    
    [assetReader addOutput:audioMixOutput];
    
    if (![assetReader startReading]) return NO;
    
    
    //書き込み側のセットアップ
    
    NSString *title = [item valueForProperty:MPMediaItemPropertyTitle];
    NSArray *docDirs = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
    NSString *docDir = [docDirs objectAtIndex:0];
    NSString *outPath = [[docDir stringByAppendingPathComponent:title]
                         stringByAppendingPathExtension:@"wav"];
    
    NSURL *outURL = [NSURL fileURLWithPath:outPath];
    AVAssetWriter *assetWriter = [AVAssetWriter assetWriterWithURL:outURL
                                                          fileType:AVFileTypeWAVE
                                                             error:&error];
    if (error) return NO;
    
    AVAssetWriterInput *assetWriterInput = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeAudio
                                                                              outputSettings:audioSetting];
    assetWriterInput.expectsMediaDataInRealTime = NO;
    
    if (![assetWriter canAddInput:assetWriterInput]) return NO;
    
    [assetWriter addInput:assetWriterInput];
    
    if (![assetWriter startWriting]) return NO;
    
    
    
    //コピー処理
    
    [assetReader retain];
    [assetWriter retain];
    
    [assetWriter startSessionAtSourceTime:kCMTimeZero];
    
    dispatch_queue_t queue = dispatch_queue_create("assetWriterQueue", NULL);
    
    [assetWriterInput requestMediaDataWhenReadyOnQueue:queue usingBlock:^{
        
        NSLog(@"start");
        
        while (1)
        {
            if ([assetWriterInput isReadyForMoreMediaData]) {
                
                CMSampleBufferRef sampleBuffer = [audioMixOutput copyNextSampleBuffer];
                
                if (sampleBuffer) {
                    [assetWriterInput appendSampleBuffer:sampleBuffer];
                    CFRelease(sampleBuffer);
                } else {
                    [assetWriterInput markAsFinished];
                    break;
                }
            }
        }
        
        [assetWriter finishWriting];
        [assetReader release];
        [assetWriter release];
        
        NSLog(@"finish");
    }];
    
    dispatch_release(queue);
    
    return YES;
}

全体の手順としては

① MPMediaItemから読み込みたい音楽のAVURLAssetを取得
② 読み込む時のフォーマットをNSDictionaryで作成
③ AVAssetReaderを作成
④ AVAssetReaderAudioMixOutputを作成してAVAssetReaderに取り付け
⑤ AVAssetReaderのstartReadingで読み出しの準備

⑥ 書き出し先のNSURLを作成
⑦ 書き出すファイルのフォーマットをNSDictionaryで作成(今回は読み込み側と共通)
⑧ AVAssetWriterを作成
⑨ AVAssetWriterInputを作成してAVAssetWriterに取り付け
⑩ AVAssetWriterのstartWritingで書き込みの準備
 
⑪ AVAssetWriterのstartSessionAtSourceTimeで書き出しセッションの開始
⑫ AVAssetWriteInputのrequestMediaDataWhenReadyOnQueue:usingBlock:で書き出し開始

⑬ AVAssetReaderAudioMixOutputのcopySampleBufferでCMSampleBufferを取得
⑭ AVAssetWriterInputのappendSampleBuffer:で書き込み
⑮ 終わるまで⑬と⑭を繰り返し
⑯ 終わったらAVAssetWriterInputのmarkAsFinishedとAVAssetWriteのfinishWritingでファイルを閉じる

となっています。

ちょっと解説をしていきますと、最初にNSDictionaryで作っているのが書き出したいWAVEファイルのフォーマットです。AVAssetReaderで読み込むときのフォーマットであればもうちょっと無くても大丈夫なパラメータもあるのですが、AVAssetWriterで書き出すときは結構細かく全部指定しないと怒られます。AVChannelLayoutKeyなんかは指定したくなくても空のNSDataを渡してあげないといけません。今回は読み込みも書き出しも同じフォーマットを使っています。

AVAssetReaderやAVAssetWriterは、直接読み込んだり書き出したりという構造にはなっていないらしく、AVAssetReaderにはAVAssetReaderAudioMixOutputを、AVAssetWriterにはAVAssetWriteInputをそれぞれ取り付けて、それ経由で読み込みや書き出しを行わなければいけないようです。AVPlayerの様にお手軽ではないのですが、CMSampleBufferという生のオーディオデータを保持しているものが使えるので、そこからAudioBufferListを取得してあれこれ自由にデータを扱うことが出来ます。

AVAssetWriterのデータ書き込みは今回で一番調べるのに苦労したところで、最後のコピー処理のところでrequestMediaDataWhenReadyOnQueue:usingBlock:というメソッドを使っています。マイクからの入力をちょっとずつ書き込むなんて言う場合には直接appendSampleBufferで書き込んじゃっても大丈夫かもしれませんが、今回のようにオフラインで1曲分ガンガン連続で書き込む場合には、試すと頭の数秒程度しか書き込まれていない状態で終わってしまいます。AVAssetWriterInputに渡したデータがファイルに書き出しされないうちに、AVAssetWriterInputの持ってるバッファを超えてデータを渡そうとしても、isReadyForMoreMediaDataでNOが返ってきて書き込ませてくれません。なので、isReadyForMoreMediaDataがYESになるまで待ってから次のデータを書き込むということをdispatchを使ってバックグラウンドでやっているという感じです。

単純なファイルコピーに関しては以上ですが、リアルタイム再生用などでCMSampleBufferから生のデータを取得する方法は、また次のエントリに書いてみようと思います。