Amazonで商品を見るセール会場へ

Unity│Prefab参照構造ビューア

UnityのPrefab参照構造をノード形式で可視化するアセットは既に存在しますが、GoogleスプレッドシートやGoogle Apps Script(GAS)上でも確認できるようにツールを作成しました。

グラフ描画ライブラリのCytoscape.jsを利用してノードを表示しています。

プルダウンからPrefab名を選択すると、関連するPrefabの参照関係が表示されます。

プルダウンで選択したPrefabの関連する参照関係がノードで表示されている画面の様子。

参照先フォルダごとにノードの色分けにも対応しています。

目次

サンプルデータ

UnityのEditorスクリプトを実行し、実際のPrefabデータをCSV形式で書き出します。

書き出したPrefabデータをGoogleスプレッドシートにインポートしたテーブル情報の例。

書き出したCSVをGoogleスプレッドシートにインポートしたテーブル情報の例です。

親Prefab名フォルダ名子Prefab名関係種別
aCommonBase
bCommonaNested
bCommonaVariant
cDialogbNested
dCommonBase
eDialogdNested
fDialogcNested
fDialogeNested
gDialogBase
hDialoggNested
hDialoggVariant

Prefab参照構造ビューアのコード

実装に使用した各スクリプトのコードです。

Prefab参照構造ビューアの実装に使用したスクリプトのコード。

Unityのほかの記事は、まとめからご覧いただけます。

ExportPrefabRelationships.cs

Projectウィンドウで対象フォルダを選択し、その階層下にあるPrefabの参照情報をCSVとして書き出します。

/**
 * ExportPrefabRelationships.cs  (Assets/Editor に配置)
 *
 * 選択フォルダ配下の Prefab 依存関係を日時入り CSV で書き出す。
 * 列   : 親Prefab名, フォルダ名, 子Prefab名, 関係種別
 * 種別 : Variant … Variant → Base(+ 疑似 Nested 行)
 *        Nested  … 親 Prefab にネストされている子 Prefab
 *        Base    … ほかの Prefab から参照されるが、自身は何も参照しない Prefab
 *        単独    … 参照関係を一切持たない Prefab
 */

using UnityEngine;
using UnityEditor;
using System;
using System.IO;
using System.Text;
using System.Linq;
using System.Collections.Generic;

public static class ExportPrefabRelationships
{
    private const string MENU = "Tools/選択フォルダのPrefab関係をCSV出力";

    [MenuItem(MENU)]
    private static void Export()
    {
        /* ---------- 1. 選択フォルダ確認 ---------- */
        var roots = Selection.GetFiltered<UnityEngine.Object>(SelectionMode.Assets)
                             .Select(AssetDatabase.GetAssetPath)
                             .Where(AssetDatabase.IsValidFolder)
                             .ToArray();

        if (roots.Length == 0)
        {
            EditorUtility.DisplayDialog("エラー", "Project ウインドウでフォルダを選択してください。", "OK");
            return;
        }

        /* ---------- 2. Prefab 一覧 ---------- */
        string[] prefabGUIDs = AssetDatabase.FindAssets("t:Prefab", roots);
        string[] prefabPaths = prefabGUIDs.Select(AssetDatabase.GUIDToAssetPath).ToArray();
        var pathByName       = prefabPaths.ToDictionary(p => Path.GetFileNameWithoutExtension(p), p => p);

        /* ---------- 3. 結果格納 ---------- */
        var relations = new HashSet<(string parent, string folder, string child, string type)>();

        /* ===== 3-A) Variant → Base ===== */
        foreach (string path in prefabPaths)
        {
            var variantGO = AssetDatabase.LoadAssetAtPath<GameObject>(path);
            if (PrefabUtility.GetPrefabAssetType(variantGO) != PrefabAssetType.Variant) continue;

            var baseGO = PrefabUtility.GetCorrespondingObjectFromSource(variantGO);
            if (baseGO == null) continue;

            string folder      = GetTopFolder(path, roots);
            string variantName = variantGO.name;
            string baseName    = baseGO.name;

            relations.Add((variantName, folder, baseName, "Variant"));
            relations.Add((variantName, folder, baseName, "Nested"));   // 疑似 Nested 行
        }

        /* ===== 3-B) Nested(直下のみ)===== */
        foreach (string parentPath in prefabPaths)
        {
            string parentName = Path.GetFileNameWithoutExtension(parentPath);
            string folderName = GetTopFolder(parentPath, roots);

            var seen = new HashSet<string>(); // 親ごと重複排除

            using var scope = new PrefabScope(parentPath);
            foreach (Transform tr in scope.Root.transform)
            {
                // 子Prefabインスタンスの直下のみ検査(Transform階層1つ下)
                if (!PrefabUtility.IsPartOfPrefabInstance(tr.gameObject)) continue;

                GameObject instRoot = PrefabUtility.GetNearestPrefabInstanceRoot(tr.gameObject);
                if (instRoot == null || instRoot == scope.Root) continue;

                var src = PrefabUtility.GetCorrespondingObjectFromSource(instRoot);
                if (src == null) continue;

                string childName = src.name;
                if (!seen.Add(childName)) continue;

                relations.Add((parentName, folderName, childName, "Nested"));
            }
        }

        /* ===== 3-C) 単独・Base ===== */
        var parents = relations.Select(r => r.parent).ToHashSet();
        var childs  = relations.Select(r => r.child).Where(c => !string.IsNullOrEmpty(c)).ToHashSet();

        foreach (string prefabName in pathByName.Keys)
        {
            bool isParent = parents.Contains(prefabName);
            bool isChild  = childs.Contains(prefabName);

            if (isParent && isChild) continue;                // 親にも子にも出ている → 既に行が存在
            string folder = GetTopFolder(pathByName[prefabName], roots);

            if (!isParent && !isChild)
            {   // 参照を全く持たない
                relations.Add((prefabName, folder, "", "単独"));
            }
            else if (!isParent && isChild)
            {   // 子にしか出てこない(=Base)
                relations.Add((prefabName, folder, "", "Base"));
            }
            // 親のみ(isParent && !isChild)は既に Variant/Nested 行があるので追加不要
        }

        /* ---------- 4. CSV 出力 ---------- */
        var sb = new StringBuilder();
        sb.AppendLine("親Prefab名,フォルダ名,子Prefab名,関係種別");

        foreach (var r in relations
                         .OrderBy(r => r.parent)
                         .ThenBy(r => r.type)
                         .ThenBy(r => r.child))
        {
            sb.AppendLine($"{r.parent},{r.folder},{r.child},{r.type}");
        }

        string ts     = DateTime.Now.ToString("yyyyMMdd_HHmmss");
        string fname  = $"PrefabRelationships_{ts}.csv";
        string saveTo = EditorUtility.SaveFilePanel("CSV を保存", Application.dataPath, fname, "csv");
        if (string.IsNullOrEmpty(saveTo)) return;

        File.WriteAllText(saveTo, sb.ToString(), Encoding.UTF8);
        EditorUtility.RevealInFinder(saveTo);
        Debug.Log($"CSV 出力完了: {saveTo}");
    }

    /* ---------- ルート直下フォルダ名を取得 ---------- */
    private static string GetTopFolder(string prefabPath, string[] roots)
    {
        foreach (string root in roots)
        {
            if (!prefabPath.StartsWith(root)) continue;
            string rel = prefabPath.Substring(root.Length).TrimStart('/');
            string first = rel.Split('/')[0];
            return string.IsNullOrEmpty(first) ? Path.GetFileName(root) : first;
        }
        return Path.GetFileName(Path.GetDirectoryName(prefabPath));
    }

    /* ---------- PrefabContents スコープ ---------- */
    private readonly struct PrefabScope : IDisposable
    {
        public readonly GameObject Root;
        public PrefabScope(string path) => Root = PrefabUtility.LoadPrefabContents(path);
        public void Dispose()           => PrefabUtility.UnloadPrefabContents(Root);
    }
}

続いて、スプレッドシート側に組み込むGASおよびWeb表示用ファイルです。

Code.gs

/* Code.gs  ─ サーバー側ロジック */

function doGet() {
  return HtmlService.createTemplateFromFile('index').evaluate().setTitle('Prefab Viewer');
}
function include(f) { return HtmlService.createHtmlOutputFromFile(f).getContent(); }

/* スプレッドシート → ノード/エッジ構造 */
function getPrefabGraphData() {
  const sh   = SpreadsheetApp.getActive().getSheetByName('シート1');
  const rows = sh.getDataRange().getValues().slice(1);        // ヘッダー除去

  const relPrio = { Base: 3, Variant: 2, Nested: 1 };
  const nodes   = new Map();                                   // id → { id, folder, relation }
  const edges   = [];                                          // { source(child), target(parent) }

  /** ノードを追加/更新  
   *  ・フォルダは“最初に付いた値”を優先(Common があとで Dialog に書き換わらない)  
   *  ・relation は Base > Variant > Nested 優先 */
  function upsert(id, folder, rel) {
    if (!id) return;
    const ex = nodes.get(id);
    if (!ex) {
      nodes.set(id, { id, folder, relation: rel });
    } else {
      if (ex.folder === 'その他') ex.folder = folder;          // まだ未確定なら書き換え
      if (relPrio[rel] > relPrio[ex.relation]) ex.relation = rel;
    }
  }

  rows.forEach(r => {
    const parent = String(r[0] || '').trim();                  // 親Prefab名
    const folder = String(r[1] || 'その他').trim();            // フォルダ名
    const child  = String(r[2] || '').trim();                  // 子Prefab名
    const rel    = String(r[3] || '').trim();                  // 関係種別

    // 親ノード(自身が Base なら Base、Variant 行なら Variant、通常行なら Nested)
    const parentRel = rel === 'Base' ? 'Base' :
                      rel === 'Variant' ? 'Variant' : 'Nested';
    upsert(parent, folder, parentRel);

    // 子ノード + エッジ(子 → 親)※子フォルダは未知なので「その他」で仮登録
    if (child) {
      const childRel = (rel === 'Variant') ? 'Base' : 'Nested';  // 子は Base(派生元) か Nested
      upsert(child, 'その他', childRel);
      edges.push({ source: child, target: parent });
    }
  });

  return { nodes: [...nodes.values()], edges };
}

index.html

<!DOCTYPE html>
<html>
<head>
  <base target="_top">
  <script src="https://unpkg.com/cytoscape@3.23.0/dist/cytoscape.min.js"></script>
  <!-- 共通ノード/エッジスタイル -->
  <?!= include('css'); ?>
</head>
<body>
  <h2 class="title-header">
    Prefab 参照構造ビューア
    <span class="legend">
      <span class="legend-item">
        <span class="legend-color parent"></span> 親Prefab
      </span>
      <span class="legend-item">
        <span class="legend-color variant"></span> Variant
      </span>
      <span class="legend-item">
        <span class="legend-color standalone"></span> 単独Prefab
      </span>
    </span>
  </h2>

  <!-- プルダウンメニュー -->
  <label for="prefabSelect">親Prefabを選択:</label>
  <select id="prefabSelect">
    <option value="">(すべて表示)</option>
  </select>

  <label><input type="radio" name="direction" value="down" checked> 親→子</label>
  <label><input type="radio" name="direction" value="up"> 子→親</label>

  <div id="cy" class="graph-area"></div>

  <?!= include('js'); ?>
</body>
</html>

js.html

<script>
let fullData = null;
let cy = null;

google.script.run.withSuccessHandler(init).getPrefabGraphData();

function init(data) {
  fullData = data;

  // プルダウン生成
  const prefabNames = [...new Set(data.nodes.map(n => n.id))].sort();
  const select = document.getElementById('prefabSelect');
  prefabNames.forEach(name => {
    const opt = document.createElement('option');
    opt.value = name;
    opt.textContent = name;
    select.appendChild(opt);
  });

  // プルダウン変更時
  select.addEventListener('change', () => {
    drawGraph(select.value);
  });

  // ラジオボタン変更時
  document.querySelectorAll('input[name="direction"]').forEach(r => {
    r.addEventListener('change', () => {
      drawGraph(select.value);
    });
  });

  drawGraph(); // 初期表示
}

function drawGraph(parentFilter = '') {
  const direction = document.querySelector('input[name="direction"]:checked').value;
  const els = [];
  const nodeMap = new Map();
  fullData.nodes.forEach(n => nodeMap.set(n.id, n));

  const targetNodeIds = new Set();

  // 親→子を辿る
  function collectDescendantsOneWay(currentId) {
    if (targetNodeIds.has(currentId)) return;
    targetNodeIds.add(currentId);

    fullData.edges.forEach(e => {
      if (e.source === currentId && !targetNodeIds.has(e.target)) {
        collectDescendantsOneWay(e.target);
      }
    });
  }

  // 子→親を辿る
  function collectAncestorsOneWay(currentId) {
    if (targetNodeIds.has(currentId)) return;
    targetNodeIds.add(currentId);

    fullData.edges.forEach(e => {
      if (e.target === currentId && !targetNodeIds.has(e.source)) {
        collectAncestorsOneWay(e.source);
      }
    });
  }

  if (parentFilter) {
    if (direction === 'down') {
      collectDescendantsOneWay(parentFilter);
    } else {
      collectAncestorsOneWay(parentFilter);
    }
  } else {
    fullData.nodes.forEach(n => targetNodeIds.add(n.id));
  }

  // ノード追加
  targetNodeIds.forEach(id => {
    const n = nodeMap.get(id);
    if (n) els.push({ data: { id: n.id, folder: n.folder, relation: n.relation } });
  });

  // エッジ追加
  const addedEdges = new Set();
  fullData.edges.forEach(e => {
    const key = `${e.source}->${e.target}`;
    if (
      targetNodeIds.has(e.source) &&
      targetNodeIds.has(e.target) &&
      !addedEdges.has(key)
    ) {
      els.push({ data: { source: e.source, target: e.target } });
      addedEdges.add(key);
    }
  });

  // ルート(入力のないノード)特定
  const hasIn = {};
  els.filter(el => el.data.target).forEach(el => hasIn[el.data.target] = true);
  const roots = Array.from(targetNodeIds).filter(id => !hasIn[id]);

  if (cy) cy.destroy();
  cy = cytoscape({
    container: document.getElementById('cy'),
    elements : els,
    layout   : {
      name: 'breadthfirst',
      roots: roots,
      spacingFactor: 1.5,
      padding: 10
    },
    style: window.cyStyle
  });
}
</script>

css.html

<style>
/* ===============================
   HTML用のCSSスタイル
   =============================== */
.title-header {
  display: flex;
  align-items: center;
  gap: 1rem;
}

.legend {
  display: flex;
  gap: 0.5rem;
  font-size: 0.9em;
}

.legend-item {
  display: flex;
  align-items: center;
}

.legend-color {
  width: 16px;
  height: 16px;
  display: inline-block;
  margin-right: 4px;
  border-radius: 3px;
}

.legend-color.parent {
  background-color: #3498db;
}

.legend-color.variant {
  background-color: #add8e6;
}

.legend-color.standalone {
  background-color: white;
  border: 2px solid #3498db;
}

.graph-area {
  width: 100%;
  height: 720px;
  margin-top: 1rem;
}
</style>
<script>
/* ------------------------------------------------------------------
   window.cyStyle : ノード / エッジ共通スタイル
   ------------------------------------------------------------------ */
window.cyStyle = [
  /* デフォルト(Nested 用:枠のみ) */
  {
    selector: 'node',
    style: {
      label: 'data(id)',
      'text-valign': 'center',
      'text-halign': 'center',
      'font-size': '16px',
      shape: 'roundrectangle',
      width: 100,
      height: 50,
      'border-width': 2,
      'border-color': '#000',
      'background-opacity': 0      /* ← 枠のみ */
    }
  },

  /* Base = 不透明塗りつぶし */
  { selector: 'node[relation = "Base"]',
    style: { 'background-opacity': 1 } },

  /* Variant = 半透明塗りつぶし */
  { selector: 'node[relation = "Variant"]',
    style: { 'background-opacity': 0.3 } },

  /* フォルダ別カラー(枠・塗り共通) */
  { selector: 'node[folder = "Common"]',
    style: { 'border-color': '#3498db', 'background-color': '#3498db' } },
  { selector: 'node[folder = "Dialog"]',
    style: { 'border-color': '#2ecc71', 'background-color': '#2ecc71' } },
  { selector: 'node[folder = "その他"]',
    style: { 'border-color': '#95a5a6', 'background-color': '#95a5a6' } },

  /* エッジ */
  { selector: 'edge',
    style: {
      width: 2,
      'line-color': '#666',
      'target-arrow-shape': 'triangle',
      'target-arrow-color': '#666',
      'curve-style': 'bezier'
    }
  }
];
</script>

エディタ拡張の記事は、ほかにもあります。

次に学ぶ・作業環境を選ぶ

学習を続けたい方や、作業環境を整えたい方は、目的に合うガイドをご覧ください。

Unityのおすすめ書籍

開発PCの選び方

周辺機器の優先度

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次