FLA 素材(已分类)→ 生成 JSON → Unity 自动建 Timeline → 播放预览 → 微调 → 导出视频
🎯 完整工作流
┌─────────────────┐ ┌──────────────────┐ ┌───────────────────┐
│ 1. FLA 导出 │────▶│ 2. 生成 JSON │────▶│ 3. Unity 导入 │
│ PNG 序列帧 │ │ Python 扫描目录 │ │ 自动创建 Timeline │
└─────────────────┘ └──────────────────┘ └───────────────────┘
│
▼
┌───────────────────┐
│ 4. 预览 & 微调 │
│ Timeline 编辑器 │
└───────────────────┘
│
▼
┌───────────────────┐
│ 5. 导出视频 │
│ Unity Recorder │
└───────────────────┘
📦 安装
Unity 侧
- 将
Scripts/Editor/AnimationJsonImporter.cs复制到 Unity 项目的
Assets/Editor/目录下(没有就新建) - Unity 会自动编译,菜单栏出现 Tools > 2D Animation Pipeline
Python 侧(可选)
- 确保 Python 3.6+ 已安装
- 无额外依赖,直接运行
Tools/generate_anim_json.py
🚀 三步上手
第 1 步:准备素材
从 Adobe Animate / Flash 导出 PNG 序列帧:
File → Export → Export Movie → Format: PNG Sequence
按类别存放到文件夹中(每个子文件夹 = 一条轨道):
Assets/Sprites/
├── Background/
│ ├── bg_001.png
│ └── bg_002.png
├── Character/
│ ├── idle_001.png
│ ├── idle_002.png
│ ├── walk_001.png
│ └── walk_002.png
├── Character>Hand/ ← 用 > 指定父子关系
│ ├── hand_001.png
│ └── hand_002.png
└── Effect_Fire/
├── fire_001.png
└── fire_002.png
⚠ 重要:导入 Unity 后,PNG 需要设为 Sprite 模式。
菜单 Tools > 2D Animation Pipeline > Force Sprite Import Settings 可一键批量转换。
第 2 步:生成 JSON
# 基本用法
python generate_anim_json.py /path/to/sprites -o animation.json
# 指定帧率和帧步进(每张 sprite 占 2 帧)
python generate_anim_json.py /path/to/sprites --fps 24 --step 2 -o animation.json
# 自定义名称和分辨率
python generate_anim_json.py /path/to/sprites --name "IntroAnim" --width 1280 --height 720
参数说明:
| 参数 | 默认值 | 说明 |
|---|---|---|
folder | 必填 | 素材文件夹路径 |
-o | animation.json | 输出文件名 |
--fps | 24 | 帧率 |
--step | 0(自动检测) | 每张 Sprite 占几帧 |
--name | Animation | 动画名称 |
--width/height | 1920×1080 | 分辨率 |
--sprites-root | Assets/Sprites | Unity 中 Sprite 根目录 |
--z-start | 0 | 起始排序值 |
--z-gap | 10 | 轨道间排序间隔 |
自动检测逻辑:
- 文件名有数字(
walk_001.png)→ 按序列号分配帧号 - 文件名无数字(
bg.png)→ 逐帧顺序分配 --step 0→ 自动检测序列号间隔
第 3 步:Unity 导入
- Unity 菜单 Tools > 2D Animation Pipeline > Import Animation JSON
- 点击 浏览… 选择生成的 JSON 文件
- 确认设置(Sprite 根目录、输出目录、帧率)
- 点击 🚀 导入并生成 Timeline
- 按 Play 即可预览动画!
导入完成后自动创建:
Timeline资产(.playable文件)- 每条轨道对应的
GameObject(带 SpriteRenderer) AnimationDirector对象(带 PlayableDirector)- 所有 AnimationClip + 关键帧已就位
📋 JSON 格式参考
完整结构
{
"name": "动画名称",
"fps": 24,
"width": 1920,
"height": 1080,
"spritesRoot": "Assets/Sprites",
"tracks": [ ... ]
}
Track(轨道)
{
"name": "轨道名称",
"zOrder": 10,
"parent": "",
"sprites": [ ... ],
"position": [ ... ],
"scale": [ ... ],
"rotation": [ ... ],
"opacity": [ ... ]
}
| 字段 | 类型 | 说明 |
|---|---|---|
name | string | 轨道名(对应 GameObject 名) |
zOrder | int | SpriteRenderer 排序层级 |
parent | string | 父轨道名(空 = 无父级) |
sprites | array | Sprite 切换关键帧 |
position | array | 位移关键帧(Unity 世界单位) |
scale | array | 缩放关键帧 |
rotation | array | Z 轴旋转关键帧(角度) |
opacity | array | 透明度关键帧(0~1) |
Sprite 关键帧
{ "frame": 12, "path": "Character/walk_001.png" }
frame:帧号(0 起始)path:相对于spritesRoot的路径
Vec2 关键帧(position / scale)
{ "frame": 24, "x": 3.0, "y": -1.5, "ease": "smooth" }
Float 关键帧(rotation / opacity)
{ "frame": 24, "value": 45.0, "ease": "linear" }
缓动类型(ease)
| 值 | 效果 |
|---|---|
smooth | Unity 自动贝塞尔曲线(默认,平滑过渡) |
linear | 线性插值(匀速) |
step | 阶跃/保持(不插值,到帧瞬间切换) |
🎬 手动微调指南
导入后所有内容都在 Timeline 中,可以自由调整:
Sprite 切换
- 在 Timeline 的 AnimationClip 中编辑 sprite 关键帧
- 可以替换 sprite 引用、调整帧位置
运动轨迹
- 选中 AnimationClip → 在 Animation 窗口中编辑曲线
- 支持调整切线手柄、添加/删除关键帧
时间控制
- 拖动 Clip 边界调整起止时间
- 拆分 Clip 做分段动画
- 调整 Clip 之间的混合(Blend)
轨道管理
- 添加/删除轨道
- 调整轨道绑定
- 添加 Activation Track 控制显隐
📹 导出视频
推荐使用 Unity Recorder(Package Manager 安装):
- Window > General > Recorder > Recorder Window
- Add Recorder > Movie
- 设置输出格式(H.264 / ProRes / WebM)
- 设置分辨率(与 JSON 中 width/height 一致)
- 选择 Recording Mode: PlayableDirector
- 绑定 AnimationDirector
- 点击 Start Recording
🔧 高级用法
父子关系
文件夹名用 > 分隔即可指定父子关系:
# 目录结构
sprites/
├── Character/
└── Character>Hand/ ← Hand 是 Character 的子物体
# 生成的 JSON
{ "name": "Hand", "parent": "Character", ... }
导入后 Hand 会自动成为 Character 的子对象,跟随移动。
手写 JSON 模板
Python 生成的 JSON 只包含 Sprite 关键帧和默认 position/scale/opacity。
你可以在此基础上手动添加运动关键帧:
{
"name": "Character",
"sprites": [
{"frame": 0, "path": "Character/walk_001.png"},
{"frame": 2, "path": "Character/walk_002.png"}
],
"position": [
{"frame": 0, "x": -5.0, "y": 0.0, "ease": "smooth"},
{"frame": 48, "x": 5.0, "y": 0.0, "ease": "smooth"}
],
"opacity": [
{"frame": 0, "value": 0.0, "ease": "smooth"},
{"frame": 6, "value": 1.0, "ease": "linear"}
]
}
坐标系说明
| 属性 | 说明 |
|---|---|
| position | Unity 世界坐标,Y 轴向上,单位由 Sprite 的 Pixels Per Unit 决定 |
| scale | 相对缩放,1.0 = 原始大小 |
| rotation | 角度制,绕 Z 轴旋转,正值 = 逆时针 |
| opacity | 0 = 全透明,1 = 全不透明 |
FLA → Unity 坐标转换:
- FLA 的 Y 轴向下,Unity 的 Y 轴向上 → Y 值取反
- FLA 坐标通常以像素为单位 → 除以 Pixels Per Unit 转为 Unity 单位
多段动画
可以创建多个 JSON 文件,分别导入生成多个 Timeline。
也可以在同一个 Timeline 中手动组织多个 Clip。
❓ 常见问题
Q: 导入后 Sprite 显示为粉色/缺失?
A: PNG 没有设置为 Sprite 导入模式。使用菜单 Tools > 2D Animation Pipeline > Force Sprite Import Settings 批量转换。
Q: 位置偏了 / 比例不对?
A: 检查 Sprite 的 Pixels Per Unit 设置。默认 100,如果素材分辨率高可以适当增大(如 200-500),让 Sprite 在场景中大小合适。
Q: 旋转动画看起来不对?
A: 旋转使用四元数插值,大角度旋转(>180°)可能出现非预期路径。建议拆分为多个小角度关键帧,或在 Unity Animation 窗口中手动调整旋转曲线。
Q: 如何调整 Sprite 切换的帧率?
A: 在 JSON 的 sprites 中调整 frame 值。例如 frame: 0, 2, 4, 6 表示每 2 帧切换一次(12fps @ 24fps 时间线)。
Q: 支持多个 Sprite 在同一帧出现吗?
A: 每条轨道同一帧只能显示一个 Sprite。如果需要同时显示多个元素,为每个元素创建单独的轨道。
Q: 可以循环播放吗?
A: 在 PlayableDirector 的 Wrap 模式中选择 Loop 即可。
📁 文件清单
Unity2DAnimPipeline/
├── README.md ← 你正在读的
├── Scripts/Editor/
│ └── AnimationJsonImporter.cs ← Unity Editor 导入工具
├── Tools/
│ └── generate_anim_json.py ← Python 文件夹扫描器
└── Examples/
└── sample_animation.json ← 示例 JSON
⚡ 快速开始(极简版)
# 1. 扫描素材生成 JSON
python generate_anim_json.py ./my_sprites -o my_anim.json
# 2. 把素材和 JSON 放入 Unity 项目
# 3. Unity: Tools > 2D Animation Pipeline > Import Animation JSON
# 4. 选择 JSON → 导入 → Play → 预览 → 微调 → 导出视频
AnimationJsonImporter.cs
// AnimationJsonImporter.cs
// Unity 2D Animation Pipeline — JSON → Timeline 自动导入
//
// 功能:
// - 读取动画 JSON,自动创建 Timeline 资产、AnimationTrack、关键帧
// - 支持 Sprite 切换、位移、缩放、Z 轴旋转、透明度
// - 支持轨道父子关系、排序层级
// - 自动在场景中创建 GameObject 并绑定到 Timeline
//
// 安装:将此文件放入 Unity 项目 Assets/Editor/ 目录下
// 使用:Unity 菜单 Tools > 2D Animation Pipeline > Import Animation JSON
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;
namespace AnimPipeline
{
// ═══════════════════════════════════════════════
// JSON 数据结构(JsonUtility 兼容)
// ═══════════════════════════════════════════════
[Serializable]
public class AnimJson
{
public string name = "Animation";
public int fps = 24;
public int width = 1920;
public int height = 1080;
public string spritesRoot = "Assets/Sprites";
public TrackData[] tracks = Array.Empty<TrackData>();
}
[Serializable]
public class TrackData
{
public string name = "Track";
public int zOrder = 0;
public string parent = ""; // 父轨道名(空=无父级)
public SpriteKeyframe[] sprites = Array.Empty<SpriteKeyframe>();
public Vec2Keyframe[] position = Array.Empty<Vec2Keyframe>();
public Vec2Keyframe[] scale = Array.Empty<Vec2Keyframe>();
public FloatKeyframe[] rotation = Array.Empty<FloatKeyframe>(); // Z 轴角度(度)
public FloatKeyframe[] opacity = Array.Empty<FloatKeyframe>(); // 0~1
}
[Serializable]
public class SpriteKeyframe
{
public int frame; // 帧号
public string path; // 相对于 spritesRoot 的路径
}
[Serializable]
public class Vec2Keyframe
{
public int frame;
public float x;
public float y;
public string ease = "smooth"; // smooth | linear | step
}
[Serializable]
public class FloatKeyframe
{
public int frame;
public float value;
public string ease = "smooth";
}
// ═══════════════════════════════════════════════
// Editor Window
// ═══════════════════════════════════════════════
public class AnimationJsonImporterWindow : EditorWindow
{
private string _jsonPath = "";
private string _spritesRoot = "Assets/Sprites";
private string _outputFolder = "Assets/Timelines";
private int _fps = 24;
private bool _createInScene = true;
private Vector2 _scrollPos;
private string _previewInfo = "";
[MenuItem("Tools/2D Animation Pipeline/Import Animation JSON")]
public static void ShowWindow()
{
var w = GetWindow<AnimationJsonImporterWindow>("2D Anim Pipeline");
w.minSize = new Vector2(420, 400);
}
private void OnGUI()
{
_scrollPos = EditorGUILayout.BeginScrollView(_scrollPos);
// ── 标题 ──
GUILayout.Label("2D Animation Pipeline", EditorStyles.boldLabel);
GUILayout.Label("FLA 素材 → JSON → Unity Timeline", EditorStyles.miniLabel);
EditorGUILayout.Space(8);
// ── JSON 文件 ──
GUILayout.Label("动画 JSON 文件", EditorStyles.boldLabel);
EditorGUILayout.BeginHorizontal();
_jsonPath = EditorGUILayout.TextField(_jsonPath);
if (GUILayout.Button("浏览…", GUILayout.Width(60)))
{
var path = EditorUtility.OpenFilePanel(
"选择动画 JSON", Application.dataPath, "json");
if (!string.IsNullOrEmpty(path))
{
_jsonPath = path;
TryAutoFill(path);
PreviewJson(path);
}
}
EditorGUILayout.EndHorizontal();
if (!string.IsNullOrEmpty(_previewInfo))
{
EditorGUILayout.HelpBox(_previewInfo, MessageType.Info);
}
EditorGUILayout.Space(6);
// ── 设置 ──
GUILayout.Label("导入设置", EditorStyles.boldLabel);
_spritesRoot = EditorGUILayout.TextField("Sprite 根目录", _spritesRoot);
_outputFolder = EditorGUILayout.TextField("Timeline 输出目录", _outputFolder);
_fps = EditorGUILayout.IntField("默认帧率 (FPS)", _fps);
_createInScene = EditorGUILayout.Toggle("在场景中创建对象", _createInScene);
EditorGUILayout.Space(10);
// ── 导入按钮 ──
bool canImport = !string.IsNullOrEmpty(_jsonPath) && File.Exists(_jsonPath);
EditorGUI.BeginDisabledGroup(!canImport);
if (GUILayout.Button("🚀 导入并生成 Timeline", GUILayout.Height(36)))
{
DoImport();
}
EditorGUI.EndDisabledGroup();
EditorGUILayout.Space(10);
// ── 辅助工具 ──
GUILayout.Label("辅助工具", EditorStyles.boldLabel);
if (GUILayout.Button("批量设置 Sprite 导入格式(PNG → Sprite)"))
{
ForceSpriteImportSettings();
}
EditorGUILayout.Space(10);
// ── 格式速查 ──
GUILayout.Label("JSON 格式速查", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
"tracks[].sprites → Sprite 切换 (frame + path)\n" +
"tracks[].position → 位移 (frame, x, y, ease)\n" +
"tracks[].scale → 缩放 (frame, x, y, ease)\n" +
"tracks[].rotation → Z 轴旋转 (frame, value[°], ease)\n" +
"tracks[].opacity → 透明度 (frame, value[0~1], ease)\n" +
"ease: smooth | linear | step\n" +
"parent: 父轨道名称(空字符串=无父级)",
MessageType.Info);
EditorGUILayout.EndScrollView();
}
// ─── 自动填充 ───
private void TryAutoFill(string path)
{
try
{
var json = File.ReadAllText(path);
var data = JsonUtility.FromJson<AnimJson>(json);
if (!string.IsNullOrEmpty(data.spritesRoot))
_spritesRoot = data.spritesRoot;
if (data.fps > 0)
_fps = data.fps;
}
catch { /* 静默忽略 */ }
}
private void PreviewJson(string path)
{
try
{
var json = File.ReadAllText(path);
var data = JsonUtility.FromJson<AnimJson>(json);
var sb = new System.Text.StringBuilder();
sb.AppendLine($"动画: {data.name} | {data.fps}fps | {data.width}×{data.height}");
sb.AppendLine($"轨道数: {data.tracks?.Length ?? 0}");
if (data.tracks != null)
{
foreach (var t in data.tracks)
{
int sp = t.sprites?.Length ?? 0;
int pos = t.position?.Length ?? 0;
int scl = t.scale?.Length ?? 0;
int rot = t.rotation?.Length ?? 0;
int opc = t.opacity?.Length ?? 0;
sb.AppendLine($" • {t.name} z={t.zOrder} " +
$"sprite={sp} pos={pos} scale={scl} rot={rot} opacity={opc}");
}
}
_previewInfo = sb.ToString();
}
catch (Exception e)
{
_previewInfo = $"预览失败: {e.Message}";
}
}
// ─── 执行导入 ───
private void DoImport()
{
try
{
AnimationJsonImporter.Import(
_jsonPath, _spritesRoot, _outputFolder, _fps, _createInScene);
EditorUtility.DisplayDialog("完成 ✅", "Timeline 创建成功!\n按 Play 即可预览动画。", "好的");
}
catch (Exception e)
{
EditorUtility.DisplayDialog("导入失败 ❌", e.Message, "知道了");
Debug.LogException(e);
}
}
// ─── 批量 Sprite 导入设置 ───
[MenuItem("Tools/2D Animation Pipeline/Force Sprite Import Settings")]
private static void ForceSpriteImportSettings()
{
var folder = EditorUtility.OpenFolderPanel(
"选择 Sprite 文件夹", Application.dataPath, "");
if (string.IsNullOrEmpty(folder)) return;
if (!folder.StartsWith(Application.dataPath))
{
EditorUtility.DisplayDialog("错误", "请选择项目 Assets 目录下的文件夹", "OK");
return;
}
var assetsPath = "Assets" + folder.Substring(Application.dataPath.Length);
var guids = AssetDatabase.FindAssets("t:Texture2D", new[] { assetsPath });
int count = 0;
foreach (var guid in guids)
{
var assetPath = AssetDatabase.GUIDToAssetPath(guid);
var importer = AssetImporter.GetAtPath(assetPath) as TextureImporter;
if (importer != null && importer.textureType != TextureImporterType.Sprite)
{
importer.textureType = TextureImporterType.Sprite;
importer.spriteImportMode = SpriteImportMode.Single;
importer.SaveAndReimport();
count++;
}
}
AssetDatabase.Refresh();
EditorUtility.DisplayDialog("完成",
$"已将 {count} 个贴图转换为 Sprite 导入模式", "好的");
}
}
// ═══════════════════════════════════════════════
// 导入器核心
// ═══════════════════════════════════════════════
public static class AnimationJsonImporter
{
public static void Import(
string jsonPath,
string spritesRoot,
string outputFolder,
int defaultFps,
bool createInScene)
{
// ── 1. 读取 JSON ──
var json = File.ReadAllText(jsonPath);
var data = JsonUtility.FromJson<AnimJson>(json);
if (data.tracks == null || data.tracks.Length == 0)
throw new Exception("JSON 中没有轨道 (tracks 为空)。");
int fps = data.fps > 0 ? data.fps : defaultFps;
spritesRoot = !string.IsNullOrEmpty(data.spritesRoot)
? data.spritesRoot : spritesRoot;
// ── 2. 确保输出目录 ──
EnsureFolderExists(outputFolder);
// ── 3. 创建 Timeline ──
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
timeline.name = data.name;
// ── 4. 计算总时长 ──
double maxDuration = CalcMaxDuration(data.tracks, fps);
if (maxDuration <= 0) maxDuration = 5.0;
// ── 5. 映射表 ──
var trackGoMap = new Dictionary<string, GameObject>();
var animTrackMap = new Dictionary<string, AnimationTrack>();
// ── 6. 逐轨道创建 ──
for (int i = 0; i < data.tracks.Length; i++)
{
var td = data.tracks[i];
var trackName = string.IsNullOrEmpty(td.name) ? $"Track_{i}" : td.name;
EditorUtility.DisplayProgressBar(
"导入动画", $"创建轨道: {trackName}",
(float)i / data.tracks.Length);
// 创建 AnimationTrack
var animTrack = timeline.CreateTrack<AnimationTrack>(null, trackName);
animTrackMap[trackName] = animTrack;
// 创建默认 Clip
var tlClip = animTrack.CreateDefaultClip();
tlClip.start = 0;
tlClip.duration = maxDuration;
var animAsset = tlClip.asset as AnimationPlayableAsset;
var animClip = animAsset.clip;
animClip.name = trackName + "_clip";
// ── Sprite 关键帧 ──
if (td.sprites != null && td.sprites.Length > 0)
SetSpriteKeyframes(animClip, td.sprites, spritesRoot, fps);
// ── 位移 ──
if (td.position != null && td.position.Length > 0)
{
animClip.SetCurve("", typeof(Transform), "m_LocalPosition.x",
CurveBuilder.FromVec2(td.position, k => k.x, fps));
animClip.SetCurve("", typeof(Transform), "m_LocalPosition.y",
CurveBuilder.FromVec2(td.position, k => k.y, fps));
}
// ── 缩放 ──
if (td.scale != null && td.scale.Length > 0)
{
animClip.SetCurve("", typeof(Transform), "m_LocalScale.x",
CurveBuilder.FromVec2(td.scale, k => k.x, fps));
animClip.SetCurve("", typeof(Transform), "m_LocalScale.y",
CurveBuilder.FromVec2(td.scale, k => k.y, fps));
// Z 轴缩放恒为 1
animClip.SetCurve("", typeof(Transform), "m_LocalScale.z",
new AnimationCurve(new Keyframe(0, 1f)));
}
// ── 旋转(Z 轴,四元数方式)──
if (td.rotation != null && td.rotation.Length > 0)
SetRotationKeyframes(animClip, td.rotation, fps);
// ── 透明度 ──
if (td.opacity != null && td.opacity.Length > 0)
{
animClip.SetCurve("", typeof(SpriteRenderer), "m_Color.a",
CurveBuilder.FromFloat(td.opacity, k => k.value, fps));
}
// ── 场景 GameObject ──
if (createInScene)
{
var go = new GameObject(trackName);
var sr = go.AddComponent<SpriteRenderer>();
sr.sortingOrder = td.zOrder;
if (td.sprites != null && td.sprites.Length > 0)
{
var firstSpritePath = spritesRoot + "/" + td.sprites[0].path;
sr.sprite = AssetDatabase.LoadAssetAtPath<Sprite>(firstSpritePath);
}
trackGoMap[trackName] = go;
}
}
EditorUtility.DisplayProgressBar("导入动画", "设置父子关系…", 0.75f);
// ── 7. 父子关系 ──
if (createInScene)
{
foreach (var td in data.tracks)
{
var childName = string.IsNullOrEmpty(td.name) ? "" : td.name;
if (!string.IsNullOrEmpty(td.parent)
&& trackGoMap.ContainsKey(td.parent)
&& trackGoMap.ContainsKey(childName))
{
trackGoMap[childName].transform.SetParent(
trackGoMap[td.parent].transform);
}
}
}
EditorUtility.DisplayProgressBar("导入动画", "保存资产…", 0.85f);
// ── 8. 保存 Timeline 资产 ──
var timelinePath = outputFolder + "/" + data.name + ".playable";
AssetDatabase.CreateAsset(timeline, timelinePath);
AssetDatabase.SaveAssets();
// ── 9. PlayableDirector ──
if (createInScene)
{
var directorObj = new GameObject("AnimationDirector");
var director = directorObj.AddComponent<PlayableDirector>();
director.playableAsset = timeline;
director.playOnAwake = false;
// 绑定轨道 → GameObject
foreach (var td in data.tracks)
{
var trackName = string.IsNullOrEmpty(td.name) ? "" : td.name;
if (animTrackMap.TryGetValue(trackName, out var at)
&& trackGoMap.TryGetValue(trackName, out var go))
{
director.SetGenericBinding(at, go);
}
}
Selection.activeGameObject = directorObj;
// 打开 Timeline 编辑器
try { UnityEditor.Timeline.TimelineEditor.selectedDirector = director; }
catch { /* 旧版 Unity 可能没有此 API */ }
}
EditorUtility.ClearProgressBar();
AssetDatabase.Refresh();
// Ping Timeline 资产
var savedTimeline = AssetDatabase.LoadAssetAtPath<TimelineAsset>(timelinePath);
if (savedTimeline != null)
EditorGUIUtility.PingObject(savedTimeline);
Debug.Log($"[AnimPipeline] ✅ Timeline '{data.name}' 创建完成 → {timelinePath}\n" +
$" 轨道: {data.tracks.Length} 帧率: {fps}fps 时长: {maxDuration:F2}s\n" +
$" 按 Play 即可预览,在 Timeline 窗口手动微调后导出视频。");
}
// ─── Sprite 关键帧 ───
private static void SetSpriteKeyframes(
AnimationClip clip, SpriteKeyframe[] spriteKeys,
string spritesRoot, int fps)
{
var binding = new EditorCurveBinding
{
type = typeof(SpriteRenderer),
path = "",
propertyName = "m_Sprite"
};
var keyframes = new List<ObjectReferenceKeyframe>();
foreach (var sk in spriteKeys)
{
var fullPath = spritesRoot + "/" + sk.path;
var sprite = AssetDatabase.LoadAssetAtPath<Sprite>(fullPath);
if (sprite == null)
{
Debug.LogWarning($"[AnimPipeline] ⚠ Sprite 未找到: {fullPath}");
continue;
}
keyframes.Add(new ObjectReferenceKeyframe
{
time = (float)sk.frame / fps,
value = sprite
});
}
if (keyframes.Count > 0)
AnimationUtility.SetObjectReferenceCurve(clip, binding, keyframes.ToArray());
}
// ─── 旋转关键帧(Z 轴 → 四元数)───
private static void SetRotationKeyframes(
AnimationClip clip, FloatKeyframe[] rotKeys, int fps)
{
var zCurve = new AnimationCurve();
var wCurve = new AnimationCurve();
foreach (var kf in rotKeys)
{
float t = (float)kf.frame / fps;
float halfRad = kf.value * Mathf.Deg2Rad / 2f;
zCurve.AddKey(new Keyframe(t, Mathf.Sin(halfRad)));
wCurve.AddKey(new Keyframe(t, Mathf.Cos(halfRad)));
}
CurveBuilder.ApplyEasing(zCurve, rotKeys.Select(k => k.ease ?? "smooth").ToArray());
CurveBuilder.ApplyEasing(wCurve, rotKeys.Select(k => k.ease ?? "smooth").ToArray());
clip.SetCurve("", typeof(Transform), "m_LocalRotation.z", zCurve);
clip.SetCurve("", typeof(Transform), "m_LocalRotation.w", wCurve);
// X, Y 保持 0
var zero = new AnimationCurve(new Keyframe(0, 0f));
clip.SetCurve("", typeof(Transform), "m_LocalRotation.x", zero);
clip.SetCurve("", typeof(Transform), "m_LocalRotation.y", zero);
}
// ─── 辅助 ───
private static double CalcMaxDuration(TrackData[] tracks, int fps)
{
int maxFrame = 0;
foreach (var t in tracks)
{
if (t.sprites != null) maxFrame = Math.Max(maxFrame, t.sprites.Max(f => f.frame));
if (t.position != null && t.position.Length > 0) maxFrame = Math.Max(maxFrame, t.position.Max(k => k.frame));
if (t.scale != null && t.scale.Length > 0) maxFrame = Math.Max(maxFrame, t.scale.Max(k => k.frame));
if (t.rotation != null && t.rotation.Length > 0) maxFrame = Math.Max(maxFrame, t.rotation.Max(k => k.frame));
if (t.opacity != null && t.opacity.Length > 0) maxFrame = Math.Max(maxFrame, t.opacity.Max(k => k.frame));
}
return maxFrame > 0 ? (double)(maxFrame + 1) / fps : 0;
}
private static void EnsureFolderExists(string path)
{
if (AssetDatabase.IsValidFolder(path)) return;
var parts = path.Split('/');
string current = parts[0];
for (int i = 1; i < parts.Length; i++)
{
var next = current + "/" + parts[i];
if (!AssetDatabase.IsValidFolder(next))
AssetDatabase.CreateFolder(current, parts[i]);
current = next;
}
}
}
// ═══════════════════════════════════════════════
// AnimationCurve 构建器
// ═══════════════════════════════════════════════
internal static class CurveBuilder
{
/// <summary>从 Vec2Keyframe 构建 AnimationCurve</summary>
public static AnimationCurve FromVec2(
Vec2Keyframe[] keys, Func<Vec2Keyframe, float> selector, int fps)
{
var curve = new AnimationCurve();
foreach (var kf in keys)
curve.AddKey(new Keyframe((float)kf.frame / fps, selector(kf)));
ApplyEasing(curve, keys.Select(k => k.ease ?? "smooth").ToArray());
return curve;
}
/// <summary>从 FloatKeyframe 构建 AnimationCurve</summary>
public static AnimationCurve FromFloat(
FloatKeyframe[] keys, Func<FloatKeyframe, float> selector, int fps)
{
var curve = new AnimationCurve();
foreach (var kf in keys)
curve.AddKey(new Keyframe((float)kf.frame / fps, selector(kf)));
ApplyEasing(curve, keys.Select(k => k.ease ?? "smooth").ToArray());
return curve;
}
/// <summary>
/// 根据缓动类型设置切线
/// smooth — Unity 自动贝塞尔(默认)
/// linear — 相邻关键帧间直线
/// step — 阶跃/保持当前值
/// </summary>
public static void ApplyEasing(AnimationCurve curve, string[] easings)
{
bool anyNonSmooth = easings.Any(e =>
e != "smooth" && !string.IsNullOrEmpty(e));
if (!anyNonSmooth) return; // 全 smooth 就不改
var keys = curve.keys;
for (int i = 0; i < keys.Length && i < easings.Length; i++)
{
switch (easings[i])
{
case "linear":
if (i < keys.Length - 1)
keys[i].outTangent =
(keys[i + 1].value - keys[i].value) /
(keys[i + 1].time - keys[i].time);
if (i > 0)
keys[i].inTangent =
(keys[i].value - keys[i - 1].value) /
(keys[i].time - keys[i - 1].time);
break;
case "step":
keys[i].outTangent = float.PositiveInfinity;
break;
// smooth / 其他:保持 Unity 默认 auto tangent
}
}
curve.keys = keys;
}
}
}
generate_anim_json.py
#!/usr/bin/env python3
"""
generate_anim_json.py — 从素材文件夹自动生成动画 JSON
用法:
python generate_anim_json.py /path/to/sprites --fps 24 --step 2 -o animation.json
工作原理:
扫描指定目录下的子文件夹,每个子文件夹对应一条 Timeline 轨道。
自动检测文件名中的序列号(如 walk_001.png → 帧号 0),
生成可直接导入 Unity 的动画 JSON 文件。
目录结构示例:
sprites/
├── Background/
│ └── bg.png
├── Character/
│ ├── idle_001.png
│ ├── idle_002.png
│ ├── idle_003.png
│ ├── walk_001.png
│ └── walk_002.png
└── Effect_Fire/
├── fire_001.png
└── fire_002.png
生成结果:
3 条轨道: Background, Character, Effect_Fire
每条轨道包含 sprites 关键帧 + 默认 position/scale/opacity
"""
import argparse
import json
import os
import re
import sys
from pathlib import Path
from typing import List, Dict, Tuple, Optional
# ─── 支持的图片格式 ───
IMAGE_EXTS = {'.png', '.jpg', '.jpeg', '.tga', '.psd', '.tif', '.tiff', '.bmp', '.gif'}
def extract_sequence_number(filename: str) -> Tuple[int, int]:
"""
从文件名提取最后一个数字作为序列号。
返回 (序列号, 数字位数)
例: walk_001.png → (1, 3)
fire_12.png → (12, 2)
bg.png → (0, 0) 无序列号
"""
stem = Path(filename).stem
matches = re.findall(r'(\d+)', stem)
if matches:
last_num = matches[-1]
return int(last_num), len(last_num)
return 0, 0
def detect_frame_step(images: List[Path]) -> int:
"""
自动检测序列帧步进。
如果序列号连续 (1,2,3...) → step=1
如果序列号间隔 (1,3,5...) → step=2
"""
if len(images) < 2:
return 1
nums = []
for img in images:
n, _ = extract_sequence_number(img.name)
if n > 0:
nums.append(n)
if len(nums) < 2:
return 1
nums.sort()
diffs = [nums[i+1] - nums[i] for i in range(len(nums)-1)]
# 取中位数差值作为步进
diffs.sort()
median = diffs[len(diffs) // 2]
return max(1, median)
def scan_folder(root: str, frame_step: int = 0,
start_z: int = 0, z_gap: int = 10) -> List[dict]:
"""
扫描文件夹,生成轨道数据。
frame_step=0 表示自动检测。
"""
tracks = []
z_order = start_z
root_path = Path(root)
# 收集子文件夹(按名称排序)
subfolders = sorted(
[d for d in root_path.iterdir() if d.is_dir()],
key=lambda d: d.name
)
if not subfolders:
# 没有子文件夹 → 把根目录作为单轨道
subfolders = [root_path]
for subfolder in subfolders:
track_name = subfolder.name
# 收集图片文件(按名称排序)
images = sorted(
[f for f in subfolder.iterdir()
if f.is_file() and f.suffix.lower() in IMAGE_EXTS],
key=lambda f: f.name
)
if not images:
print(f" ⚠ 跳过空文件夹: {track_name}")
continue
# 检测帧步进
if frame_step <= 0:
step = detect_frame_step(images)
else:
step = frame_step
# 检测是否所有文件都有序列号
has_sequence = all(extract_sequence_number(img.name)[1] > 0 for img in images)
sprites = []
if has_sequence and len(images) > 1:
# 有序列号:根据序列号分配帧号
for img in images:
seq_num, _ = extract_sequence_number(img.name)
frame = (seq_num - 1) * step # 序列号通常从 1 开始
rel_path = f"{track_name}/{img.name}"
sprites.append({"frame": frame, "path": rel_path})
# 按帧号排序
sprites.sort(key=lambda s: s["frame"])
else:
# 无序列号或单张图片:逐帧分配
for i, img in enumerate(images):
frame = i * step
rel_path = f"{track_name}/{img.name}"
sprites.append({"frame": frame, "path": rel_path})
# 从文件夹名解析 parent(用 > 分隔)
# 例: "Character>Hand" → name="Hand", parent="Character"
parent = ""
name = track_name
if ">" in track_name:
parts = track_name.split(">", 1)
parent = parts[0].strip()
name = parts[1].strip()
track = {
"name": name,
"zOrder": z_order,
"parent": parent,
"sprites": sprites,
"position": [{"frame": 0, "x": 0.0, "y": 0.0, "ease": "linear"}],
"scale": [{"frame": 0, "x": 1.0, "y": 1.0, "ease": "linear"}],
"rotation": [],
"opacity": [{"frame": 0, "value": 1.0, "ease": "linear"}]
}
tracks.append(track)
z_order += z_gap
return tracks
def main():
parser = argparse.ArgumentParser(
description="从素材文件夹生成 Unity 2D 动画 JSON",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
# 基本用法
python generate_anim_json.py ./sprites -o my_anim.json
# 指定帧率和步进(每张 sprite 占 2 帧)
python generate_anim_json.py ./sprites --fps 24 --step 2 -o my_anim.json
# 自定义设置
python generate_anim_json.py ./sprites --fps 30 --name "Intro" --width 1280 --height 720
# 父子关系:文件夹名用 > 分隔
# sprites/Character>Hand/ → name=Hand, parent=Character
""")
parser.add_argument("folder", help="素材文件夹路径")
parser.add_argument("-o", "--output", default="animation.json",
help="输出 JSON 文件名(默认 animation.json)")
parser.add_argument("--fps", type=int, default=24,
help="帧率(默认 24)")
parser.add_argument("--step", type=int, default=0,
help="帧步进/每张 Sprite 占几帧(0=自动检测)")
parser.add_argument("--name", default="Animation",
help="动画名称(默认 Animation)")
parser.add_argument("--width", type=int, default=1920,
help="分辨率宽度(默认 1920)")
parser.add_argument("--height", type=int, default=1080,
help="分辨率高度(默认 1080)")
parser.add_argument("--sprites-root", default="Assets/Sprites",
help="Unity 项目中 Sprite 根目录(默认 Assets/Sprites)")
parser.add_argument("--z-start", type=int, default=0,
help="起始 Z 排序值(默认 0)")
parser.add_argument("--z-gap", type=int, default=10,
help="轨道间 Z 排序间隔(默认 10)")
args = parser.parse_args()
if not os.path.isdir(args.folder):
print(f"❌ 错误:文件夹不存在: {args.folder}", file=sys.stderr)
sys.exit(1)
print(f"📂 扫描文件夹: {args.folder}")
tracks = scan_folder(args.folder, args.step, args.z_start, args.z_gap)
if not tracks:
print("❌ 错误:未找到任何图片文件", file=sys.stderr)
sys.exit(1)
# 构建完整 JSON
anim_json = {
"name": args.name,
"fps": args.fps,
"width": args.width,
"height": args.height,
"spritesRoot": args.sprites_root,
"tracks": tracks
}
# 写入文件
with open(args.output, 'w', encoding='utf-8') as f:
json.dump(anim_json, f, indent=2, ensure_ascii=False)
# 输出统计
total_sprites = sum(len(t['sprites']) for t in tracks)
print(f"\n✅ 已生成: {args.output}")
print(f" 动画: {args.name} | {args.fps}fps | {args.width}×{args.height}")
print(f" 轨道数: {len(tracks)} | Sprite 总数: {total_sprites}")
print()
for t in tracks:
parent_info = f" (parent: {t['parent']})" if t['parent'] else ""
print(f" 📌 {t['name']}{parent_info} z={t['zOrder']} "
f"sprites={len(t['sprites'])}")
print()
print("💡 下一步:")
print(f" 1. 将素材复制到 Unity 项目 {args.sprites_root}/ 目录下")
print(f" 2. Unity 菜单: Tools > 2D Animation Pipeline > Import Animation JSON")
print(f" 3. 选择 {args.output} → 导入 → 按 Play 预览")
if __name__ == "__main__":
main()
sample_animation.json
{
"name": "DemoAnimation",
"fps": 24,
"width": 1920,
"height": 1080,
"spritesRoot": "Assets/Sprites",
"tracks": [
{
"name": "Background",
"zOrder": 0,
"parent": "",
"sprites": [
{ "frame": 0, "path": "Background/bg_001.png" },
{ "frame": 48, "path": "Background/bg_002.png" }
],
"position": [
{ "frame": 0, "x": 0.0, "y": 0.0, "ease": "linear" },
{ "frame": 96, "x": -5.0, "y": 0.0, "ease": "linear" }
],
"scale": [
{ "frame": 0, "x": 1.2, "y": 1.2, "ease": "linear" }
],
"rotation": [],
"opacity": [
{ "frame": 0, "value": 1.0, "ease": "linear" }
]
},
{
"name": "Character",
"zOrder": 10,
"parent": "",
"sprites": [
{ "frame": 0, "path": "Character/idle_001.png" },
{ "frame": 2, "path": "Character/idle_002.png" },
{ "frame": 4, "path": "Character/idle_003.png" },
{ "frame": 6, "path": "Character/idle_004.png" },
{ "frame": 8, "path": "Character/idle_005.png" },
{ "frame": 10, "path": "Character/idle_006.png" },
{ "frame": 24, "path": "Character/walk_001.png" },
{ "frame": 26, "path": "Character/walk_002.png" },
{ "frame": 28, "path": "Character/walk_003.png" },
{ "frame": 30, "path": "Character/walk_004.png" },
{ "frame": 32, "path": "Character/walk_005.png" },
{ "frame": 34, "path": "Character/walk_006.png" },
{ "frame": 36, "path": "Character/walk_007.png" },
{ "frame": 38, "path": "Character/walk_008.png" }
],
"position": [
{ "frame": 0, "x": -3.0, "y": -1.5, "ease": "smooth" },
{ "frame": 24, "x": -3.0, "y": -1.5, "ease": "smooth" },
{ "frame": 72, "x": 3.0, "y": -1.5, "ease": "smooth" }
],
"scale": [
{ "frame": 0, "x": 1.0, "y": 1.0, "ease": "linear" }
],
"rotation": [
{ "frame": 0, "value": 0.0, "ease": "smooth" },
{ "frame": 24, "value": -5.0, "ease": "smooth" },
{ "frame": 30, "value": 5.0, "ease": "smooth" },
{ "frame": 36, "value": -5.0, "ease": "smooth" },
{ "frame": 42, "value": 0.0, "ease": "smooth" }
],
"opacity": [
{ "frame": 0, "value": 0.0, "ease": "smooth" },
{ "frame": 12, "value": 1.0, "ease": "linear" }
]
},
{
"name": "Effect_Fire",
"zOrder": 20,
"parent": "Character",
"sprites": [
{ "frame": 48, "path": "Effect_Fire/fire_001.png" },
{ "frame": 49, "path": "Effect_Fire/fire_002.png" },
{ "frame": 50, "path": "Effect_Fire/fire_003.png" },
{ "frame": 51, "path": "Effect_Fire/fire_004.png" },
{ "frame": 52, "path": "Effect_Fire/fire_005.png" },
{ "frame": 53, "path": "Effect_Fire/fire_006.png" }
],
"position": [
{ "frame": 48, "x": 0.8, "y": 0.3, "ease": "linear" }
],
"scale": [
{ "frame": 48, "x": 0.3, "y": 0.3, "ease": "smooth" },
{ "frame": 51, "x": 1.2, "y": 1.2, "ease": "smooth" },
{ "frame": 53, "x": 0.8, "y": 0.8, "ease": "smooth" }
],
"rotation": [],
"opacity": [
{ "frame": 48, "value": 0.0, "ease": "smooth" },
{ "frame": 49, "value": 1.0, "ease": "linear" },
{ "frame": 52, "value": 1.0, "ease": "smooth" },
{ "frame": 53, "value": 0.0, "ease": "smooth" }
]
},
{
"name": "Title",
"zOrder": 30,
"parent": "",
"sprites": [
{ "frame": 60, "path": "Title/title.png" }
],
"position": [
{ "frame": 60, "x": 0.0, "y": 2.0, "ease": "smooth" },
{ "frame": 72, "x": 0.0, "y": 0.5, "ease": "smooth" }
],
"scale": [
{ "frame": 60, "x": 0.5, "y": 0.5, "ease": "smooth" },
{ "frame": 72, "x": 1.0, "y": 1.0, "ease": "smooth" }
],
"rotation": [],
"opacity": [
{ "frame": 60, "value": 0.0, "ease": "smooth" },
{ "frame": 66, "value": 1.0, "ease": "linear" },
{ "frame": 90, "value": 1.0, "ease": "smooth" },
{ "frame": 96, "value": 0.0, "ease": "smooth" }
]
}
]
}
1万+

被折叠的 条评论
为什么被折叠?



