#経緯
先日まで『カラクリショウジョの涙と終』というゲームを制作していました。
このゲームはアニメーションRPGというジャンルをとっており、全てのグラフィックを手書きアニメーションの要領で構成していています。
制作の過程で、いくつかの作業をエディタ拡張によって効率化したため、そちらについてまとめました。
(アニメーション表現自体はこちらでまとめております。興味がございましたら。)
使用したUnityのバージョンは2019.4.4f1です。
#発光処理の工程削減
今回のグラフィックは『発光』を強調する方向性で効果を加えているのですが、絵における発光部位の指定には白黒のマップ画像を用いています。
マップ画像作成の工程は『発光させたいを白に、それ以外の色は黒に塗りつぶす』と非常に単純です。
またアニメーションの絵は、制作の都合上色の境目がくっきりとしておりアンチエイリアスもかかっていません。(ジャギーが発生している状態です)
そのため特定色の抽出を非常に容易かつ機械的に行うことができます。この性質を活かして自動生成処理をエディタ拡張で実装し作業コストの軽減を図りました。
#マップ生成
まず発光部分の色を指定する処理からです。同じキャラクターの絵を扱う際には指定する色も同じになるため、色の情報はSciptableObject化し再利用可能にしています。
using UnityEngine;
[CreateAssetMenu(fileName = "MappingTargetColorData",menuName ="EmissionMapGenerate/MappingTargetColorData",order =0)]
public class MappingTargetColorData :ScriptableObject
{
[SerializeField] Color[] targetColors;
public Color[] TargetColors { get { return targetColors; } }
}
(目のハイライトと白目の部分は同じ白に見えますが、RGBの値を調整しハイライトのみ指定するよう工夫しています)
マップ生成処理自体は以下のようになっています。
private void DrawMap(Texture2D origin) {
//同じサイズの空のテクスチャを生成
Texture2D generatedMap = new Texture2D(origin.width, origin.height, TextureFormat.RGB565, false);
//元の絵のピクセルを一つずつ確認し、発光させたい色だったら白、そうでなれば黒を塗る。
for (int y = 0; y < generatedMap.height; y++) {
for (int x = 0; x < generatedMap.width; x++) {
if (mappingTargetColorData.TargetColors.Contains(origin.GetPixel(x, y))) {
generatedMap.SetPixel(x, y, Color.white);
}
else {
generatedMap.SetPixel(x, y, Color.black);
}
}
}
//画像の変更を保存
generatedMap.Apply();
//画像形式にして書き込み。
string mapPath = /* パスを入れてください。".jpeg" など拡張子を忘れずに。 */;
var bytes = generatedMap.EncodeToJPG();
File.WriteAllBytes(mapPath, bytes);
EditorUtility.SetDirty(generatedMap);
}
この処理を扱うウィンドウをEditorWindow
で合わせて実装しました。
コードは以下の通りです。
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using System.IO;
using System.Linq;
using UniRx;
public class EmissionMapGenerator : EditorWindow {
List<Texture2D> originTextures = new List<Texture2D>();
string saveDirectoryPath;
[SerializeField] MappingTargetColorData mappingTargetColorData;
[MenuItem("Window/EmissionMapGenerator")]
static void Init() {
var window = (EmissionMapGenerator)GetWindow(typeof(EmissionMapGenerator));
window.Show();
}
private const string emissionHeader = "Emission_";
private const string AssetPathHeader = "Assets/";
private string SaveDirectoryPathResult { get { return AssetPathHeader + saveDirectoryPath; } }
public void OnGUI() {
var own = new SerializedObject(this);
own.Update();
using (new GUILayout.HorizontalScope()) {
saveDirectoryPath = EditorGUILayout.TextField("Save Directory Path", saveDirectoryPath);
}
using (new GUILayout.HorizontalScope()) {
EditorGUILayout.PropertyField(own.FindProperty("mappingTargetColorData"), false);
}
own.ApplyModifiedProperties();
using (new GUILayout.HorizontalScope()) {
if (saveDirectoryPath == "" || saveDirectoryPath == null) {
EditorGUILayout.HelpBox("保存先パスが未入力です。", MessageType.Warning);
}
}
using (new GUILayout.HorizontalScope()) {
if (mappingTargetColorData == null) {
EditorGUILayout.HelpBox("カラーデータが選択されていません。", MessageType.Error);
}
}
if (GUILayout.Button("Genarate")) {
if (mappingTargetColorData == null) return;
originTextures.Clear();
List<string> originTexturePaths = Directory.GetFiles("Assets/MapGenerateTarget")//Assets直下に専用のファイルを作成し、その中の画像からマップを生成します。
.Where((path) => IsTexturePath(path))
.Select((path) => {
ChangeTextureToDrawableMap(path);//元の画像から情報を取得できるようにしています。
return path;
})
.ToList();
originTextures = originTexturePaths
.Select((path) => AssetDatabase.LoadAssetAtPath<Texture2D>(path))
.ToList();
GenerateMaps();
for (int i = 0; i < originTexturePaths.Count; i++) {
string path = originTexturePaths[i];
ChangeTextureNotToReadable(path);
//元の画像とマップ画像を同じファイルにまとめています。
AssetDatabase.MoveAsset(path, SaveDirectoryPathResult + "/" + originTextures[i].name + Path.GetExtension(path));
}
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
}
}
private bool IsTexturePath(string path) {
if (path.Contains(".meta")) return false;
if (path.Contains(".DS_Store")) return false;
return true;
}
private void ChangeTextureToDrawableMap(string path) {
var textureImporter = AssetImporter.GetAtPath(path) as TextureImporter;
textureImporter.isReadable = true;
textureImporter.SaveAndReimport();
}
private void ChangeTextureNotToReadable(string path) {
var textureImporter = AssetImporter.GetAtPath(path) as TextureImporter;
textureImporter.isReadable = false;
textureImporter.SaveAndReimport();
}
private void GenerateMaps() {
Directory.CreateDirectory(SaveDirectoryPathResult);
foreach (var origin in originTextures) {
DrawMap(origin);
}
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
}
private void DrawMap(Texture2D origin) {
//同じサイズの空のテクスチャを生成
Texture2D generatedMap = new Texture2D(origin.width, origin.height, TextureFormat.RGB565, false);
//元の絵のピクセルを一つずつ確認し、発光させたい色だったら白、そうでなれば黒を塗る。
for (int y = 0; y < generatedMap.height; y++) {
for (int x = 0; x < generatedMap.width; x++) {
if (mappingTargetColorData.TargetColors.Contains(origin.GetPixel(x, y))) {
generatedMap.SetPixel(x, y, Color.white);
}
else {
generatedMap.SetPixel(x, y, Color.black);
}
}
}
//画像の変更を保存
generatedMap.Apply();
string mapPath = SaveDirectoryPathResult + "/" + emissionHeader + origin.name + ".jpeg";
//画像形式にして書き込み。
var bytes = generatedMap.EncodeToJPG();
File.WriteAllBytes(mapPath, bytes);
EditorUtility.SetDirty(generatedMap);
}
}
※ウィンドウ名が異なっていますがプログラムの方を参考にしていただければ大丈夫です。
#アニメーションさせるにあたって
今回のアニメーションはパラパラ漫画のように画像を切り替えるだけですので、Unity標準のキーフレーム処理のみで構成されておりシンプルです。ですが元のアニメーションに合わせてマップ画像も変更する必要があるため、同じタイミングに二つもキーフレームを打たなければなりませんでした。
この二度手間をなくすために、マップ切り替えのキーフレームをエディタ拡張で自動入力するようにしています。
また今回はAnimationClipだけでなく、Timelineから直で入力したキーフレームにも対応させています。
#PlayableAssetの取り扱い
Timelineで直に入力したアニメーションは、情報を保持するPlayableAssetの下に子オブジェクトのように存在します。
このようなアセットは『SubAsset』という方法で管理されており、アクセスするにはLoadAllAssetsAtPath()
を使います。
取得したAsset郡には親にあたるPlayableAssetの情報も含まれているため、AnimationClipのみ抜き出したい場合は注意が必要です。
(SubAssetについてはこちらを参考にしました。)
#マップ画像の抽出
次に元の絵に対応したマップ画像をAssetの中から検索する必要があります。
前述した通り今回はマップ画像を自動生成していたため、命名にも規則ができていました。(元画像名の冒頭に"Emission_"が加わるだけ)
そのため今回はその規則に基づき、名前からマップ画像を検索、抽出しています。
#キーフレーム入力
続いて実装内容についてです。
まず元のアニメーションの情報を取得する必要があります。
EditorCurveBinding[] curveBindings = AnimationUtility.GetObjectReferenceCurveBindings(clip);
//対応するEditorCurveBindingが無い=>絵の切り替えを取り扱っていないAnimationClipのためreturn
if (!curveBindings.Any(binding => binding.propertyName == "元の絵を扱う変数名")) return;
//絵の切り替えを行なっているEditorCurveBindingを取得
var targetCurveBinding = curveBindings.First(binding => binding.propertyName == "元の絵を扱う変数名");
情報はEditorCurveBinding
というクラスで管理されており、このクラスはアニメーションで扱う値ごとにAnimationClip内に存在します。
その情報をAnimationUtility.GetObjectReferenceCurveBindings()
でまとめて取得し、そこから絵の切り替えを取り扱っているものを抽出します。
判定にはEditorCurveBinding
に用意されているpropertyName
(アニメーションさせている変数名)を利用しました。
次に元のアニメーション情報を利用して新たにキーフレームを定義します。
var keyframes = new List<ObjectReferenceKeyframe>();
foreach (var reference in AnimationUtility.GetObjectReferenceCurve(clip, targetCurveBinding)) {
//マップ画像の名前
string emissionMapName = "Emission_" + reference.value.name;
//マップ画像のパスを取得
var targetMapPath = texturePaths.FirstOrDefault(target => target.Contains(emissionMapName));
//パスからマップ画像を取得
var emissionMap = AssetDatabase.LoadAssetAtPath<Texture2D>(targetMapPath);
//時間に対応させてキーフレームを定義
keyframes.Add(new ObjectReferenceKeyframe {
time = reference.time, //時間
value = emissionMap //入力内容
});
}
キーフレームの情報はObjectReferenceKeyframe
クラスで扱われており、AnimationUtility.GetObjectReferenceCurve()
でAnimationClipから配列で取得できます。
そして取得したキーフレームそれぞれの時間を参照し、同じタイミングでマップ画像を切り替えるようキーフレームを定義する、といった流れです。
ObjectReferenceKeyframe
に関してはこちらを参考にしました。
最後に、新たなEditorCurveBinding
を生成し、AnimationUtility.SetObjectReferenceCurve()
でAnimationClipに適用します
EditorCurveBinding emissionMapCurve = new EditorCurveBinding() {
path = string.Empty,
type = typeof(/*マップ画像の変更を取り扱うクラス*/),
propertyName = /*マップ画像を扱う変数名*/
};
//適用
AnimationUtility.SetObjectReferenceCurve(clip, emissionMapCurve, keyframes.ToArray());
#コード全文
以上の処理を利用するにあたって、前述したマップ画像生成と同じくEditorWindow
を用いています。
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using UnityEngine.Playables;
using System.IO;
using System.Linq;
public class EmissionKeyFrameSetter : EditorWindow {
[MenuItem("Window/EmissionKeyFrameSetter")]
static void Init() {
var window = (EmissionKeyFrameSetter)GetWindow(typeof(EmissionKeyFrameSetter));
window.Show();
}
string saveDirectoryPath;
private const string AssetPathHeader = "Assets/";
string[] texturePaths;
public void OnGUI() {
using (new GUILayout.HorizontalScope()) {
saveDirectoryPath = EditorGUILayout.TextField("Save Directory Path", saveDirectoryPath);
}
using (new GUILayout.HorizontalScope()) {
if (IsDirectoryEmpty) {
EditorGUILayout.HelpBox("保存先パスが入力されていません", MessageType.Warning);
}
}
if (GUILayout.Button("Set")) {
string saveDirectoryPathResult = AssetPathHeader + saveDirectoryPath;
if (!Directory.Exists(saveDirectoryPathResult)) {
Directory.CreateDirectory(saveDirectoryPathResult);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
}
//専用のファイルをAsset直下に作り、そこにあるAnimationClipやPlayableAssetに対して処理する
var clipPaths = Directory.GetFiles("Assets/EmissionSetter").Where(path => !path.Contains("meta"));
//SubAsset取得
var subClips = new List<AnimationClip>();
foreach (string path in clipPaths.Where(path => path.Contains("playable"))) {
subClips.AddRange(AssetDatabase.LoadAllAssetsAtPath(path)
.Where(asset => IsRecordedAnimAsset(asset))
.Select(asset => (AnimationClip)asset)
.ToList());
//PlayableAssetを保存先に移動
string newPath = saveDirectoryPathResult + "/" + Path.GetFileName(path);
AssetDatabase.MoveAsset(path, newPath);
EditorUtility.SetDirty(AssetDatabase.LoadAssetAtPath<PlayableAsset>(newPath));
}
//全テクスチャ情報を取得
texturePaths = Directory.GetFiles("Assets/Textures", "*", SearchOption.AllDirectories)
.Where(texturePath => !texturePath.Contains("meta"))
.ToArray();
//AnimationClipに対して処理
foreach (var path in clipPathes) {
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(path);
if (clip == null) break;
SetCurve(clip);
AssetDatabase.MoveAsset(path, saveDirectoryPathResult + "/" + Path.GetFileName(path));
EditorUtility.SetDirty(clip);
}
//SubAssetに対して処理
foreach (var subClip in subClips) {
if (subClip == null) break;
SetCurve(subClip);
}
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
}
}
private bool IsDirectoryEmpty {
get {
if (saveDirectoryPath == null) return true;
if (saveDirectoryPath == "") return true;
return false;
}
}
private bool IsRecordedAnimAsset(Object asset) {
if (!asset.name.Contains("Recorded")) return false;
if (asset.GetType() != typeof(AnimationClip)) return false;
return true;
}
void SetCurve(AnimationClip clip) {
EditorCurveBinding[] curveBindings = AnimationUtility.GetObjectReferenceCurveBindings(clip);
//対応するEditorCurveBindingが無い=>絵の切り替えを取り扱っていないAnimationClipのためreturn
if (!curveBindings.Any(binding => binding.propertyName == "元の絵を扱う変数名")) return;
//絵の切り替えを行なっているEditorCurveBindingを取得
var targetCurveBinding = curveBindings.First(binding => binding.propertyName == "元の絵を扱う変数名");
var keyframes = new List<ObjectReferenceKeyframe>();
foreach (var reference in AnimationUtility.GetObjectReferenceCurve(clip, targetCurveBinding)) {
//マップ画像の名前
string emissionMapName = "Emission_" + reference.value.name;
//マップ画像のパスを取得
var targetMapPath = texturePaths.FirstOrDefault(target => target.Contains(emissionMapName));
//パスからマップ画像を取得
var emissionMap = AssetDatabase.LoadAssetAtPath<Texture2D>(targetMapPath);
//時間に対応させてキーフレームを定義
keyframes.Add(new ObjectReferenceKeyframe {
time = reference.time, //時間
value = emissionMap //入力内容
});
}
EditorCurveBinding emissionMapCurve = new EditorCurveBinding() {
path = string.Empty,
type = typeof(/*マップ画像の変更を取り扱うクラス*/),
propertyName = /*マップ画像を扱う変数名*/
};
//適用
AnimationUtility.SetObjectReferenceCurve(clip, emissionMapCurve, keyframes.ToArray());
}
}
#まとめ
以上のような自動化により単純な作業を減らすことで、作業効率は大きく改善されました。
ここまで深くエディタ拡張に触れたのは初めてでしたが、どなたかの参考になりましたら幸いです。
#参考にさせていただいた記事