1、有些时候动画k完了,发现物体的轴心不是在自己想要的位置,这时候需要用这个工具进行调整,动画不变。
2、有些时候要把动画从子物体里拿到世界空间,又想把子物体从世界空间拿到目标物体下面,动画不变。




offset_animation_c4d.pyp
python
import c4d
from c4d import gui, plugins
# -----------------------------------------------------------------------------
# Plugin IDs
# -----------------------------------------------------------------------------
# 注意:正式发布前请到 Maxon 官网申请唯一的 Plugin ID
# 这里使用临时 ID (1000000 - 1000010 是预留给测试用的)
PLUGIN_ID = 1060001
# -----------------------------------------------------------------------------
# UI Constants
# -----------------------------------------------------------------------------
DLG_ID_X = 1001
DLG_ID_Y = 1002
DLG_ID_Z = 1003
DLG_BTN_ADD = 1004
DLG_BTN_SUB = 1005
DLG_BTN_BAKE_WORLD = 1006
DLG_LINKBOX = 1007
DLG_BTN_READ_POS = 1008
DLG_BTN_BAKE_LOCAL = 1009
DLG_RB_FRAME_MODE = 1010
DLG_RB_RANGE_MODE = 1011
DLG_SLIDER_THRESH = 1012
DLG_BTN_OPTIMIZE = 1013
# -----------------------------------------------------------------------------
# Dialog Class
# -----------------------------------------------------------------------------
class OffsetDialog(gui.GeDialog):
def CreateLayout(self):
self.SetTitle("动画整体偏移工具 (插件版)")
# --- 目标物体选择区域 ---
self.GroupBegin(0, c4d.BFH_SCALEFIT, 2, 0)
self.AddStaticText(0, c4d.BFH_LEFT, 0, 0, "目标/参考物体:", 0)
# LinkBox 用于拖入物体
self.linkbox = self.AddCustomGui(DLG_LINKBOX, c4d.CUSTOMGUI_LINKBOX, "", c4d.BFH_SCALEFIT, 0, 0)
self.GroupEnd()
self.AddButton(DLG_BTN_READ_POS, c4d.BFH_SCALEFIT, 0, 0, "读取目标物体坐标 -> 填入下方")
self.AddSeparatorH(0, c4d.BFH_SCALEFIT)
# --- 输入区域 ---
self.GroupBegin(0, c4d.BFH_SCALEFIT, 2, 0)
self.AddStaticText(0, c4d.BFH_LEFT, 0, 0, "X 偏移:", 0)
self.AddEditNumberArrows(DLG_ID_X, c4d.BFH_SCALEFIT, 80, 0)
self.AddStaticText(0, c4d.BFH_LEFT, 0, 0, "Y 偏移:", 0)
self.AddEditNumberArrows(DLG_ID_Y, c4d.BFH_SCALEFIT, 80, 0)
self.AddStaticText(0, c4d.BFH_LEFT, 0, 0, "Z 偏移:", 0)
self.AddEditNumberArrows(DLG_ID_Z, c4d.BFH_SCALEFIT, 80, 0)
self.GroupEnd()
self.AddSeparatorH(0, c4d.BFH_SCALEFIT)
# --- 偏移按钮区域 ---
self.GroupBegin(0, c4d.BFH_SCALEFIT, 2, 0)
self.AddButton(DLG_BTN_ADD, c4d.BFH_SCALEFIT, 0, 0, "加上偏移 (+)")
self.AddButton(DLG_BTN_SUB, c4d.BFH_SCALEFIT, 0, 0, "减去偏移 (-)")
self.GroupEnd()
self.AddSeparatorH(0, c4d.BFH_SCALEFIT)
# --- 烘焙设置 ---
self.GroupBegin(0, c4d.BFH_SCALEFIT, 1, 0, "烘焙设置", 0)
self.GroupBorderSpace(5, 5, 5, 5)
self.AddStaticText(0, c4d.BFH_LEFT, 0, 0, "帧模式:", 0)
self.AddRadioGroup(DLG_RB_FRAME_MODE, c4d.BFH_SCALEFIT, 2, 1)
self.AddChild(DLG_RB_FRAME_MODE, 0, "保持帧数")
self.AddChild(DLG_RB_FRAME_MODE, 1, "烘焙所有帧")
self.AddStaticText(0, c4d.BFH_LEFT, 0, 0, "范围模式:", 0)
self.AddRadioGroup(DLG_RB_RANGE_MODE, c4d.BFH_SCALEFIT, 2, 1)
self.AddChild(DLG_RB_RANGE_MODE, 0, "所有关键帧")
self.AddChild(DLG_RB_RANGE_MODE, 1, "播放范围")
self.AddChild(DLG_RB_RANGE_MODE, 2, "选择的帧")
self.GroupEnd()
self.AddSeparatorH(0, c4d.BFH_SCALEFIT)
# --- 烘焙按钮区域 ---
self.AddButton(DLG_BTN_BAKE_WORLD, c4d.BFH_SCALEFIT, 0, 0, "转换动画为世界坐标 (解绑父级+烘焙)")
self.AddButton(DLG_BTN_BAKE_LOCAL, c4d.BFH_SCALEFIT, 0, 0, "转换动画为物体坐标 (绑定到目标+烘焙)")
self.AddSeparatorH(0, c4d.BFH_SCALEFIT)
# --- 优化关键帧 ---
self.GroupBegin(0, c4d.BFH_SCALEFIT, 1, 0, "优化关键帧", 0)
self.GroupBorderSpace(5, 5, 5, 5)
self.GroupBegin(0, c4d.BFH_SCALEFIT, 2, 0)
self.AddStaticText(0, c4d.BFH_LEFT, 0, 0, "优化阈值:", 0)
self.AddEditNumberArrows(DLG_SLIDER_THRESH, c4d.BFH_SCALEFIT, 80, 0)
self.GroupEnd()
self.AddButton(DLG_BTN_OPTIMIZE, c4d.BFH_SCALEFIT, 0, 0, "优化曲线")
self.GroupEnd()
return True
def InitValues(self):
# 初始化为 0
self.SetFloat(DLG_ID_X, 0.0)
self.SetFloat(DLG_ID_Y, 0.0)
self.SetFloat(DLG_ID_Z, 0.0)
self.SetInt32(DLG_RB_FRAME_MODE, 0) # 默认保持帧数
self.SetInt32(DLG_RB_RANGE_MODE, 0) # 默认所有关键帧
self.SetFloat(DLG_SLIDER_THRESH, 0.001) # 默认阈值
return True
def Command(self, id, msg):
# 获取输入值
x = self.GetFloat(DLG_ID_X)
y = self.GetFloat(DLG_ID_Y)
z = self.GetFloat(DLG_ID_Z)
vector = c4d.Vector(x, y, z)
if id == DLG_BTN_ADD:
self.ApplyOffset(vector, is_add=True)
return True
if id == DLG_BTN_SUB:
self.ApplyOffset(vector, is_add=False)
return True
if id == DLG_BTN_BAKE_WORLD:
self.BakeToWorld()
return True
if id == DLG_BTN_BAKE_LOCAL:
self.BakeToLocal()
return True
if id == DLG_BTN_READ_POS:
self.ReadPosFromLink()
return True
if id == DLG_BTN_OPTIMIZE:
self.OptimizeKeys()
return True
return True
def ReadPosFromLink(self):
"""从 LinkBox 读取物体坐标并填入输入框"""
target_obj = self.linkbox.GetLink()
if not target_obj:
gui.MessageDialog("请先将物体拖入上方选框中!")
return
# 读取世界坐标 (因为偏移通常是基于世界空间的向量)
pos = target_obj.GetMg().off
self.SetFloat(DLG_ID_X, pos.x)
self.SetFloat(DLG_ID_Y, pos.y)
self.SetFloat(DLG_ID_Z, pos.z)
print(f"已读取参考坐标: {pos}")
def GetKeyTimes(self, obj, doc, only_selected=False):
"""Collect unique keyframe times (frames) from an object."""
fps = doc.GetFps()
times = set()
# Check Position, Rotation, Scale tracks
param_ids = [c4d.ID_BASEOBJECT_REL_POSITION, c4d.ID_BASEOBJECT_REL_ROTATION, c4d.ID_BASEOBJECT_REL_SCALE]
for pid in param_ids:
# Check main vector track
did = c4d.DescID(c4d.DescLevel(pid))
track = obj.FindCTrack(did)
if track:
curve = track.GetCurve()
cnt = curve.GetKeyCount()
for i in range(cnt):
key = curve.GetKey(i)
if only_selected and not key.GetNBit(c4d.BIT_ACTIVE):
continue
times.add(key.GetTime().GetFrame(fps))
# Check sub-tracks (X, Y, Z)
for sub_id in [c4d.VECTOR_X, c4d.VECTOR_Y, c4d.VECTOR_Z]:
did_sub = c4d.DescID(c4d.DescLevel(pid), c4d.DescLevel(sub_id))
track_sub = obj.FindCTrack(did_sub)
if track_sub:
curve = track_sub.GetCurve()
cnt = curve.GetKeyCount()
for i in range(cnt):
key = curve.GetKey(i)
if only_selected and not key.GetNBit(c4d.BIT_ACTIVE):
continue
times.add(key.GetTime().GetFrame(fps))
return sorted(list(times))
def GetBakeTimes(self, objs, doc):
"""根据设置获取需要烘焙的帧列表"""
frame_mode = self.GetInt32(DLG_RB_FRAME_MODE) # 0=Keep, 1=Bake All
range_mode = self.GetInt32(DLG_RB_RANGE_MODE) # 0=All Keys, 1=Playback, 2=Selected
fps = doc.GetFps()
min_frame = 0
max_frame = 0
only_selected = (range_mode == 2)
# 确定时间范围
if range_mode == 1: # Playback Range
min_frame = doc.GetMinTime().GetFrame(fps)
max_frame = doc.GetMaxTime().GetFrame(fps)
else: # All Keys or Selected - need to find min/max
if frame_mode == 1: # Bake All
all_keys = []
for obj in objs:
all_keys.extend(self.GetKeyTimes(obj, doc, only_selected=only_selected))
if all_keys:
min_frame = min(all_keys)
max_frame = max(all_keys)
else:
current = doc.GetTime().GetFrame(fps)
min_frame, max_frame = current, current
final_times = set()
if frame_mode == 0: # Keep Frames
for obj in objs:
times = self.GetKeyTimes(obj, doc, only_selected=only_selected)
for t in times:
if range_mode == 1:
if min_frame <= t <= max_frame:
final_times.add(t)
else:
final_times.add(t)
else: # Bake All
if min_frame is not None and max_frame is not None:
for f in range(int(min_frame), int(max_frame) + 1):
final_times.add(f)
sorted_times = sorted(list(final_times))
if not sorted_times:
sorted_times = [doc.GetTime().GetFrame(fps)]
return sorted_times
def BakeToLocal(self):
"""将选中物体的动画转换为目标物体的局部坐标(绑定父级并烘焙)"""
target_obj = self.linkbox.GetLink()
if not target_obj:
gui.MessageDialog("请先将【父级目标物体】拖入上方选框中!\n此操作需要一个目标作为新父级。")
return
doc = c4d.documents.GetActiveDocument()
objs = doc.GetActiveObjects(c4d.GETACTIVEOBJECTFLAGS_NONE)
if not objs:
gui.MessageDialog("请选择至少一个要处理的物体!")
return
# 排除目标物体自己
if target_obj in objs:
gui.MessageDialog("错误:目标物体不能在选中列表中(不能做自己的父级)!")
return
# 确认提示
if not gui.QuestionDialog(f"此操作将:\n1. 将选中物体设为【{target_obj.GetName()}】的子集\n2. 根据设置烘焙关键帧\n\n是否继续?"):
return
fps = doc.GetFps()
doc.StartUndo()
# 1. 获取烘焙时间点
unique_bake_times = self.GetBakeTimes(objs, doc)
# 2. Cache World Transforms AT KEY TIMES
world_matrices = {} # {obj: {frame: mg}}
for obj in objs:
world_matrices[obj] = {}
for f in unique_bake_times:
t = c4d.BaseTime(f, fps)
doc.SetTime(t)
doc.ExecutePasses(None, True, True, True, c4d.BUILDFLAGS_NONE)
for obj in objs:
world_matrices[obj][f] = obj.GetMg()
# 3. 绑定父级
for obj in objs:
doc.AddUndo(c4d.UNDOTYPE_CHANGE, obj)
current_mg = obj.GetMg() # 保持当前位置
obj.Remove()
obj.InsertUnder(target_obj)
obj.SetMg(current_mg) # 恢复当前位置
# 4. 更新关键帧
for f in unique_bake_times:
t = c4d.BaseTime(f, fps)
doc.SetTime(t) # Ensure context is at the correct time
for obj in objs:
if f not in world_matrices[obj]: continue
m_world = world_matrices[obj][f]
# 直接设置世界矩阵,让 C4D 自动计算相对于新父级的局部矩阵
obj.SetMg(m_world)
# 获取自动计算后的局部变换
pos = obj.GetRelPos()
rot = obj.GetRelRot() # HPB
scale = obj.GetRelScale()
# Helper to set key
def set_key_val(pid, sid, val):
did = c4d.DescID(c4d.DescLevel(pid), c4d.DescLevel(sid))
track = obj.FindCTrack(did)
if not track:
track = c4d.CTrack(obj, did)
obj.InsertTrackSorted(track)
curve = track.GetCurve()
k_dict = curve.AddKey(t)
if k_dict:
key = k_dict['key']
key.SetValue(curve, val)
set_key_val(c4d.ID_BASEOBJECT_REL_POSITION, c4d.VECTOR_X, pos.x)
set_key_val(c4d.ID_BASEOBJECT_REL_POSITION, c4d.VECTOR_Y, pos.y)
set_key_val(c4d.ID_BASEOBJECT_REL_POSITION, c4d.VECTOR_Z, pos.z)
set_key_val(c4d.ID_BASEOBJECT_REL_ROTATION, c4d.VECTOR_X, rot.x)
set_key_val(c4d.ID_BASEOBJECT_REL_ROTATION, c4d.VECTOR_Y, rot.y)
set_key_val(c4d.ID_BASEOBJECT_REL_ROTATION, c4d.VECTOR_Z, rot.z)
set_key_val(c4d.ID_BASEOBJECT_REL_SCALE, c4d.VECTOR_X, scale.x)
set_key_val(c4d.ID_BASEOBJECT_REL_SCALE, c4d.VECTOR_Y, scale.y)
set_key_val(c4d.ID_BASEOBJECT_REL_SCALE, c4d.VECTOR_Z, scale.z)
doc.EndUndo()
c4d.EventAdd()
print("✅ 物体坐标烘焙完成。")
def BakeToWorld(self):
"""将选中物体的动画转换为世界坐标(烘焙并解绑父级)"""
doc = c4d.documents.GetActiveDocument()
objs = doc.GetActiveObjects(c4d.GETACTIVEOBJECTFLAGS_NONE)
if not objs:
gui.MessageDialog("请选择至少一个物体!")
return
# 确认提示
if not gui.QuestionDialog("此操作将:\n1. 将选中物体从父级中移出(放到世界层级)\n2. 根据设置烘焙关键帧\n\n是否继续?"):
return
fps = doc.GetFps()
doc.StartUndo()
# 1. 获取烘焙时间点
unique_bake_times = self.GetBakeTimes(objs, doc)
# 2. Cache World Transforms
world_matrices = {}
for obj in objs:
world_matrices[obj] = {}
for f in unique_bake_times:
t = c4d.BaseTime(f, fps)
doc.SetTime(t)
doc.ExecutePasses(None, True, True, True, c4d.BUILDFLAGS_NONE)
for obj in objs:
world_matrices[obj][f] = obj.GetMg()
# 3. 解绑父级
for obj in objs:
doc.AddUndo(c4d.UNDOTYPE_CHANGE, obj)
current_mg = obj.GetMg()
obj.Remove()
doc.InsertObject(obj)
obj.SetMg(current_mg)
# 4. 更新关键帧
for f in unique_bake_times:
t = c4d.BaseTime(f, fps)
doc.SetTime(t) # Ensure context is at the correct time
for obj in objs:
if f not in world_matrices[obj]: continue
mg = world_matrices[obj][f]
# World Bake: Set World Matrix directly
obj.SetMg(mg)
# Get local values (which are now World values since parent is World/None)
pos = obj.GetRelPos()
rot = obj.GetRelRot()
scale = obj.GetRelScale()
# Helper to set key
def set_key_val(pid, sid, val):
did = c4d.DescID(c4d.DescLevel(pid), c4d.DescLevel(sid))
track = obj.FindCTrack(did)
if not track:
track = c4d.CTrack(obj, did)
obj.InsertTrackSorted(track)
curve = track.GetCurve()
k_dict = curve.AddKey(t)
if k_dict:
key = k_dict['key']
key.SetValue(curve, val)
set_key_val(c4d.ID_BASEOBJECT_REL_POSITION, c4d.VECTOR_X, pos.x)
set_key_val(c4d.ID_BASEOBJECT_REL_POSITION, c4d.VECTOR_Y, pos.y)
set_key_val(c4d.ID_BASEOBJECT_REL_POSITION, c4d.VECTOR_Z, pos.z)
set_key_val(c4d.ID_BASEOBJECT_REL_ROTATION, c4d.VECTOR_X, rot.x)
set_key_val(c4d.ID_BASEOBJECT_REL_ROTATION, c4d.VECTOR_Y, rot.y)
set_key_val(c4d.ID_BASEOBJECT_REL_ROTATION, c4d.VECTOR_Z, rot.z)
set_key_val(c4d.ID_BASEOBJECT_REL_SCALE, c4d.VECTOR_X, scale.x)
set_key_val(c4d.ID_BASEOBJECT_REL_SCALE, c4d.VECTOR_Y, scale.y)
set_key_val(c4d.ID_BASEOBJECT_REL_SCALE, c4d.VECTOR_Z, scale.z)
doc.EndUndo()
c4d.EventAdd()
print("✅ 世界坐标烘焙完成。")
def ApplyOffset(self, offset_vector, is_add):
"""执行偏移逻辑"""
doc = c4d.documents.GetActiveDocument()
selected_objects = doc.GetActiveObjects(c4d.GETACTIVEOBJECTFLAGS_NONE)
if not selected_objects:
gui.MessageDialog("请先选择至少一个物体!")
return
# 如果是减去模式,反转向量
if not is_add:
offset_vector = -offset_vector
print(f"执行偏移: {offset_vector}")
doc.StartUndo()
for obj in selected_objects:
doc.AddUndo(c4d.UNDOTYPE_CHANGE, obj)
print(f"正在处理: {obj.GetName()}")
# 1. 修改静态坐标
mg = obj.GetMg() # 获取全局矩阵
mg.off += offset_vector
obj.SetMg(mg) # 设置回全局矩阵
# 2. 修改动画关键帧 (Position Track)
axes = [
(c4d.VECTOR_X, offset_vector.x, "X"),
(c4d.VECTOR_Y, offset_vector.y, "Y"),
(c4d.VECTOR_Z, offset_vector.z, "Z")
]
for vector_id, offset_val, axis_name in axes:
if offset_val == 0:
continue
track_id = c4d.DescID(
c4d.DescLevel(c4d.ID_BASEOBJECT_REL_POSITION, c4d.DTYPE_VECTOR, 0),
c4d.DescLevel(vector_id, c4d.DTYPE_REAL, 0)
)
track = obj.FindCTrack(track_id)
if track:
curve = track.GetCurve()
key_count = curve.GetKeyCount()
for i in range(key_count):
key = curve.GetKey(i)
old_val = key.GetValue()
key.SetValue(curve, old_val + offset_val)
doc.EndUndo()
c4d.EventAdd()
print("✅ 偏移完成。")
def OptimizeKeys(self):
"""优化关键帧,移除冗余关键帧"""
doc = c4d.documents.GetActiveDocument()
objs = doc.GetActiveObjects(c4d.GETACTIVEOBJECTFLAGS_NONE)
if not objs:
gui.MessageDialog("请选择至少一个物体!")
return
threshold = self.GetFloat(DLG_SLIDER_THRESH)
range_mode = self.GetInt32(DLG_RB_RANGE_MODE) # 0=All Keys, 1=Playback, 2=Selected
fps = doc.GetFps()
min_f = None
max_f = None
if range_mode == 1:
min_f = doc.GetMinTime().GetFrame(fps)
max_f = doc.GetMaxTime().GetFrame(fps)
doc.StartUndo()
count = 0
for obj in objs:
doc.AddUndo(c4d.UNDOTYPE_CHANGE, obj)
# 遍历所有 Track
tracks = obj.GetCTracks()
for track in tracks:
curve = track.GetCurve()
if not curve: continue
key_count = curve.GetKeyCount()
if key_count <= 2: continue
keys_to_delete_indices = []
# 读取所有数据
key_data = [] # list of (id, time_frame, value, selected)
for i in range(key_count):
key = curve.GetKey(i)
t = key.GetTime().GetFrame(fps)
val = key.GetValue()
sel = key.GetNBit(c4d.BIT_ACTIVE)
key_data.append({'id': i, 't': t, 'val': val, 'sel': sel})
last_val = None
for i in range(len(key_data)):
data = key_data[i]
val = data['val']
t = data['t']
sel = data['sel']
# 范围检查
in_range = True
if range_mode == 1:
if t < min_f or t > max_f:
in_range = False
elif range_mode == 2: # Selected
if not sel:
in_range = False
if in_range:
if last_val is None:
last_val = val
else:
# 总是保留最后一个点(在该范围内)
is_last_in_scope = (i == len(key_data) - 1)
if is_last_in_scope:
last_val = val
else:
dist = abs(val - last_val)
if dist > threshold:
last_val = val
else:
# 标记删除
keys_to_delete_indices.append(data['id'])
else:
last_val = val
# 执行删除 (倒序)
if keys_to_delete_indices:
for idx in sorted(keys_to_delete_indices, reverse=True):
curve.DelKey(idx)
count += 1
doc.EndUndo()
c4d.EventAdd()
print(f"✅ 优化完成,共移除了 {count} 个关键帧。")
# -----------------------------------------------------------------------------
# Command Plugin Class
# -----------------------------------------------------------------------------
class OffsetAnimationPlugin(plugins.CommandData):
dialog = None
def Execute(self, doc):
if self.dialog is None:
self.dialog = OffsetDialog()
return self.dialog.Open(c4d.DLG_TYPE_ASYNC, pluginid=PLUGIN_ID, defaultw=300, defaulth=350)
def RestoreLayout(self, sec_ref):
if self.dialog is None:
self.dialog = OffsetDialog()
return self.dialog.Restore(PLUGIN_ID, secret=sec_ref)
if __name__ == "__main__":
plugins.RegisterCommandPlugin(
id=PLUGIN_ID,
str="动画整体偏移工具 (CN)",
info=0,
icon=None,
help="动画偏移与烘焙工具",
dat=OffsetAnimationPlugin()
)
offset_animation_max_plugin.ms
python
macroScript OffsetAnimationTool
category:"AnimationTools"
tooltip:"Offset Animation Tool"
buttonText:"OffsetAnim"
(
rollout ro_offset_anim "动画位移工具" width:220
(
group "目标对象"
(
pickbutton pbtn_target "拾取目标对象" width:190 autoDisplay:true
button btn_read_coords "读取坐标" width:190
)
group "偏移数值"
(
label lbl_x "X 偏移:" across:2 align:#left
spinner spn_x "" range:[-1e9, 1e9, 0] type:#float fieldwidth:80 align:#right
label lbl_y "Y 偏移:" across:2 align:#left
spinner spn_y "" range:[-1e9, 1e9, 0] type:#float fieldwidth:80 align:#right
label lbl_z "Z 偏移:" across:2 align:#left
spinner spn_z "" range:[-1e9, 1e9, 0] type:#float fieldwidth:80 align:#right
button btn_add "加上偏移 (+)" width:90 across:2
button btn_sub "减去偏移 (-)" width:90
)
group "烘焙设置"
(
radiobuttons rdo_frame_mode labels:#("保持帧数不变", "烘焙所有帧") default:1
radiobuttons rdo_range_mode labels:#("所有关键帧", "播放范围的", "选择的帧") default:1
)
group "烘焙动画"
(
button btn_bake_world "烘焙到世界 (解绑)" width:190 height:30
button btn_bake_local "烘焙到局部 (绑定)" width:190 height:30
)
group "优化关键帧"
(
spinner spn_opt_threshold "阈值:" range:[0, 1000, 0.01] type:#float fieldwidth:50 align:#center
button btn_optimize "执行优化" width:190 height:30
)
-- Variables
local target_obj = undefined
-- Event Handlers
on pbtn_target picked obj do
(
target_obj = obj
pbtn_target.text = obj.name
)
on btn_read_coords pressed do
(
if isValidNode target_obj then
(
local pos = target_obj.pos
spn_x.value = pos.x
spn_y.value = pos.y
spn_z.value = pos.z
)
else
(
messageBox "请先拾取有效的目标对象。" title:"目标丢失"
)
)
fn apply_offset sign =
(
local offset_vec = [spn_x.value * sign, spn_y.value * sign, spn_z.value * sign]
undo "Offset Animation" on
(
for obj in selection do
(
local processed = false
-- 1. Try Position_XYZ sub-controllers
try
(
local has_sub_keys = false
if isProperty obj.pos.controller #x_position do
(
if obj.pos.controller.x_position.controller.keys.count > 0 do has_sub_keys = true
if obj.pos.controller.y_position.controller.keys.count > 0 do has_sub_keys = true
if obj.pos.controller.z_position.controller.keys.count > 0 do has_sub_keys = true
)
if has_sub_keys then
(
if obj.pos.controller.x_position.controller.keys.count > 0 do
for k in obj.pos.controller.x_position.controller.keys do k.value += offset_vec.x
if obj.pos.controller.y_position.controller.keys.count > 0 do
for k in obj.pos.controller.y_position.controller.keys do k.value += offset_vec.y
if obj.pos.controller.z_position.controller.keys.count > 0 do
for k in obj.pos.controller.z_position.controller.keys do k.value += offset_vec.z
processed = true
)
) catch()
-- 2. Try generic controller keys
if not processed do
try
(
if obj.pos.controller.keys.count > 0 then
(
for k in obj.pos.controller.keys do k.value += offset_vec
processed = true
)
) catch()
-- 3. Fallback: Just Move
if not processed do
(
move obj offset_vec
)
)
)
)
on btn_add pressed do apply_offset 1.0
on btn_sub pressed do apply_offset -1.0
fn get_key_times obj only_selected:false =
(
local times = #()
-- Helper to collect keys
fn collect_keys cntrl arr only_sel =
(
if cntrl != undefined and cntrl.keys.count > 0 do
(
for k in cntrl.keys do
(
if (not only_sel) or k.selected do append arr k.time
)
)
)
-- Collect keys from Position, Rotation, Scale
collect_keys obj.pos.controller times only_selected
collect_keys obj.rotation.controller times only_selected
collect_keys obj.scale.controller times only_selected
-- Also check sub-controllers for Position List / XYZ
try (
if isProperty obj.pos.controller #x_position do collect_keys obj.pos.controller.x_position.controller times only_selected
if isProperty obj.pos.controller #y_position do collect_keys obj.pos.controller.y_position.controller times only_selected
if isProperty obj.pos.controller #z_position do collect_keys obj.pos.controller.z_position.controller times only_selected
) catch()
-- Remove duplicates (Efficient Sort + Linear Scan)
if times.count == 0 do return #()
sort times
local unique_times = #(times[1])
for i = 2 to times.count do
(
if times[i] != times[i-1] do append unique_times times[i]
)
return unique_times
)
fn bake_animation new_parent =
(
if selection.count == 0 do
(
messageBox "请选择要烘焙的对象。" title:"未选择对象"
return false
)
local sel_objs = selection as array
undo "Bake Animation" on
(
disableSceneRedraw()
try
(
-- 1. Identify Keyframes (Smart Bake)
local unique_bake_times = #()
local raw_keys = #()
-- Determine Range
local range_start = animationRange.start
local range_end = animationRange.end
-- Collect keys if needed (for Range determination or Keep Keyframes mode)
-- Optimization: Collect once
local objs_keys = #() -- Array of arrays
local only_sel_keys = (rdo_range_mode.state == 3)
if rdo_range_mode.state == 1 or rdo_range_mode.state == 3 or rdo_frame_mode.state == 1 do
(
for obj in sel_objs do append objs_keys (get_key_times obj only_selected:only_sel_keys)
)
if rdo_range_mode.state == 1 or rdo_range_mode.state == 3 then -- All Keyframes OR Selected Keys
(
local min_t = undefined
local max_t = undefined
for keys in objs_keys do
(
if keys.count > 0 do
(
if min_t == undefined or keys[1] < min_t do min_t = keys[1]
if max_t == undefined or keys[keys.count] > max_t do max_t = keys[keys.count]
)
)
-- If keys exist, use their range; otherwise keep animationRange
if min_t != undefined do range_start = min_t
if max_t != undefined do range_end = max_t
)
-- Determine Frames to Bake
if rdo_frame_mode.state == 1 then -- Keep Keyframes
(
-- Merge all keys
for keys in objs_keys do join raw_keys keys
if raw_keys.count > 0 then
(
sort raw_keys
-- Deduplicate and Filter by Range
local last_added = undefined
for t in raw_keys do
(
if t >= range_start and t <= range_end do
(
if last_added == undefined or t != last_added do
(
append unique_bake_times t
last_added = t
)
)
)
)
)
else -- Bake All Frames
(
-- Loop through the determined range BY FRAME (not Ticks!)
local s_frame = range_start.frame as integer
local e_frame = range_end.frame as integer
for f = s_frame to e_frame do append unique_bake_times (f as time)
)
sort unique_bake_times
-- If no keys found (e.g. static object in Keep Keys mode), just use current time
if unique_bake_times.count == 0 do unique_bake_times = #(currentTime)
-- 2. Cache World Transforms AT KEY TIMES
local cached_transforms = #()
for i = 1 to sel_objs.count do cached_transforms[i] = #()
for t in unique_bake_times do
(
at time t
(
for i = 1 to sel_objs.count do
(
append cached_transforms[i] sel_objs[i].transform
)
)
)
-- 3. Reparent (Do NOT delete keys, we want to preserve them if possible)
-- However, standard reparenting in Max might keep world pos but mess up keys if "Compensation" is on.
-- We want to control the keys explicitly.
-- Strategy: Reparent -> Update keys at keyframes.
for obj in sel_objs do
(
obj.parent = new_parent
)
-- 4. Apply Transforms at Key Times
animate on
(
for idx = 1 to unique_bake_times.count do
(
local t = unique_bake_times[idx]
at time t
(
for i = 1 to sel_objs.count do
(
local world_tm = cached_transforms[i][idx]
-- Apply world transform.
-- Since object is already parented, Max will calculate the correct local transform.
-- Since we are in 'animate on', this updates the key at this time.
sel_objs[i].transform = world_tm
)
)
)
)
-- Optional: Clean up keys?
-- If we created keys where there were none (due to union of times), it's acceptable.
)
catch
(
messageBox ("烘焙出错: " + getCurrentException())
)
enableSceneRedraw()
redrawViews()
completeRedraw()
)
)
on btn_bake_world pressed do
(
bake_animation undefined
)
on btn_bake_local pressed do
(
if isValidNode target_obj then
(
-- Ensure target is not in selection
if (findItem (selection as array) target_obj) > 0 then
(
messageBox "目标对象不能在选择集中。" title:"错误"
)
else
(
bake_animation target_obj
)
)
else
(
messageBox "请先拾取有效的目标对象。" title:"目标丢失"
)
)
fn bake_controller_keys cntrl range_start range_end =
(
if not (isProperty cntrl #keys) do return false
local s_frame = range_start.frame as integer
local e_frame = range_end.frame as integer
for f = s_frame to e_frame do
(
addNewKey cntrl (f as time)
)
)
fn optimize_controller_keys cntrl threshold range_start range_end check_range only_selected =
(
-- Check if keys array is accessible and has enough keys
-- Some controllers might throw error when accessing .keys directly if not keyable or empty
local key_count = 0
try (key_count = cntrl.keys.count) catch(return undefined)
if key_count > 2 do
(
local keys = cntrl.keys
local keys_to_delete = #()
local last_val = undefined
-- We need to find the first valid key within range to start comparison
-- If not checking range, start from key 1
-- Iterate through all keys
for i = 1 to key_count do
(
local k = undefined
try (k = keys[i]) catch()
if k != undefined do
(
local t = k.time
-- Check Scope (Range or Selection)
local in_scope = true
if check_range do
(
if t < range_start or t > range_end do in_scope = false
)
if only_selected do
(
if not k.selected do in_scope = false
)
if in_scope then
(
local val = undefined
try (val = k.value) catch()
if val != undefined do
(
-- Always keep the first key we encounter in range (or global first key if range starts before)
-- Also, if it's the very first or very last key of the controller, we generally want to keep it
-- unless we are strictly optimizing a segment.
-- Let's keep the logic: Keep first key in range as anchor.
if last_val == undefined then
(
last_val = val
)
else
(
-- Check if it's the last key in range?
-- If it is the last key of the controller, keep it.
if i == key_count then
(
-- Keep last key
last_val = val
)
else
(
local dist = 0.0
-- Handle different value types
if (classof val) == Quat then
(
local d = dot val last_val
if d < 0 do d = -d
if d > 1.0 do d = 1.0
dist = (2.0 * acos d)
)
else if (classof val) == Point3 then
(
dist = distance val last_val
)
else if (classof val) == Float then
(
dist = abs (val - last_val)
)
else
(
if val != last_val do dist = threshold + 1.0
)
if dist > threshold then
(
last_val = val -- Keep this key, update baseline
)
else
(
append keys_to_delete i
)
)
)
)
)
else
(
-- If not in scope, update last_val so we bridge the gap correctly
-- This ensures we compare the next in-scope key against the last existing key
try (last_val = k.value) catch()
)
)
)
-- Delete keys (reverse order)
if keys_to_delete.count > 0 do
(
for i = keys_to_delete.count to 1 by -1 do deleteKey cntrl keys_to_delete[i]
)
)
)
on btn_optimize pressed do
(
if selection.count == 0 do
(
messageBox "请选择要优化关键帧的对象。" title:"未选择对象"
return false
)
local threshold = spn_opt_threshold.value
-- Determine Range based on Radio Buttons (using same UI as Bake)
local check_range = false
local only_selected = false
local range_start = animationRange.start
local range_end = animationRange.end
if rdo_range_mode.state == 2 then -- Playback Range
(
check_range = true
)
else if rdo_range_mode.state == 3 then -- Selected Keys
(
only_selected = true
)
-- Determine Frame Mode (Resample before Optimize?)
local resample = (rdo_frame_mode.state == 2) -- Bake Every Frame
-- If Resample is ON, we need a valid range.
-- If Range Mode is 'All Keyframes' (1) or 'Selected Keys' (3), we need to calculate the range first.
if resample and (rdo_range_mode.state == 1 or rdo_range_mode.state == 3) do
(
-- Reusing logic to find range:
local min_t = undefined
local max_t = undefined
for obj in selection do
(
local keys = get_key_times obj only_selected:only_selected
if keys.count > 0 do
(
if min_t == undefined or keys[1] < min_t do min_t = keys[1]
if max_t == undefined or keys[keys.count] > max_t do max_t = keys[keys.count]
)
)
if min_t != undefined do range_start = min_t
if max_t != undefined do range_end = max_t
)
undo "Optimize Keys" on
(
for obj in selection do
(
-- Helper to process controller
fn process_cntrl c thresh start end check sel resamp =
(
if c != undefined do
(
if resamp do bake_controller_keys c start end
optimize_controller_keys c thresh start end check sel
)
)
-- Position
process_cntrl obj.pos.controller threshold range_start range_end check_range only_selected resample
-- Rotation
process_cntrl obj.rotation.controller threshold range_start range_end check_range only_selected resample
-- Scale
process_cntrl obj.scale.controller threshold range_start range_end check_range only_selected resample
-- Sub-controllers (X/Y/Z)
try (
if isProperty obj.pos.controller #x_position do process_cntrl obj.pos.controller.x_position.controller threshold range_start range_end check_range only_selected resample
if isProperty obj.pos.controller #y_position do process_cntrl obj.pos.controller.y_position.controller threshold range_start range_end check_range only_selected resample
if isProperty obj.pos.controller #z_position do process_cntrl obj.pos.controller.z_position.controller threshold range_start range_end check_range only_selected resample
if isProperty obj.rotation.controller #x_rotation do process_cntrl obj.rotation.controller.x_rotation.controller threshold range_start range_end check_range only_selected resample
if isProperty obj.rotation.controller #y_rotation do process_cntrl obj.rotation.controller.y_rotation.controller threshold range_start range_end check_range only_selected resample
if isProperty obj.rotation.controller #z_rotation do process_cntrl obj.rotation.controller.z_rotation.controller threshold range_start range_end check_range only_selected resample
) catch()
)
)
messageBox "关键帧优化完成!" title:"完成"
)
)
on execute do
(
try(destroyDialog ro_offset_anim)catch()
createDialog ro_offset_anim
)
)
offset_animation_maya_plugin.py
python
# -*- coding: utf-8 -*-
import sys
import math
import maya.cmds as cmds
import maya.mel as mel
import maya.api.OpenMaya as om
# -----------------------------------------------------------------------------
# 核心逻辑 (Core Logic)
# -----------------------------------------------------------------------------
class OffsetAnimationTool:
def __init__(self):
self.window_name = "offsetAnimWindowPlugin"
def show_ui(self):
if cmds.window(self.window_name, exists=True):
cmds.deleteUI(self.window_name)
self.window = cmds.window(self.window_name, title="动画位移工具", widthHeight=(300, 350))
main_layout = cmds.columnLayout(adjustableColumn=True, rowSpacing=10, columnAttach=('both', 10))
# 目标部分
cmds.frameLayout(label="目标对象", collapsable=False, parent=main_layout)
col_target = cmds.columnLayout(adjustableColumn=True, rowSpacing=5)
self.ff_target = cmds.textFieldButtonGrp(label="目标:", text="", buttonLabel="拾取", buttonCommand=self.pick_target)
cmds.button(label="读取坐标", command=self.read_coords)
cmds.setParent(main_layout)
# 偏移部分
cmds.frameLayout(label="偏移数值", collapsable=False, parent=main_layout)
col_offset = cmds.columnLayout(adjustableColumn=True, rowSpacing=5)
self.ff_offset = cmds.floatFieldGrp(numberOfFields=3, label="XYZ 偏移:", value1=0.0, value2=0.0, value3=0.0, precision=3)
row_btns = cmds.rowLayout(numberOfColumns=2, columnWidth2=(135, 135), adjustableColumn=1, adjustableColumn2=2)
cmds.button(label="加上偏移 (+)", command=lambda x: self.apply_offset(1.0))
cmds.button(label="减去偏移 (-)", command=lambda x: self.apply_offset(-1.0))
cmds.setParent(col_offset)
cmds.setParent(main_layout)
# 烘焙设置
cmds.frameLayout(label="烘焙设置", collapsable=False, parent=main_layout)
cmds.columnLayout(adjustableColumn=True)
self.rb_frame_mode = cmds.radioButtonGrp(label="", labelArray2=['保持帧数', '烘焙所有帧'], numberOfRadioButtons=2, select=1, columnWidth2=[100, 100], adj=1)
self.rb_range_mode = cmds.radioButtonGrp(label="", labelArray3=['所有关键帧', '播放范围', '选择的帧'], numberOfRadioButtons=3, select=1, columnWidth3=[80, 80, 80], adj=1)
cmds.setParent(main_layout)
# 烘焙部分
cmds.frameLayout(label="烘焙动画", collapsable=False, parent=main_layout)
col_bake = cmds.columnLayout(adjustableColumn=True, rowSpacing=5)
cmds.button(label="烘焙到世界 (解绑)", command=lambda x: self.bake("world"))
cmds.button(label="烘焙到局部 (绑定)", command=lambda x: self.bake("local"))
cmds.setParent(main_layout)
# 优化部分
cmds.frameLayout(label="优化关键帧", collapsable=False, parent=main_layout)
cmds.columnLayout(adjustableColumn=True)
cmds.rowLayout(numberOfColumns=2, adjustableColumn=2)
cmds.text(label="阈值: ")
self.ff_optimize_threshold = cmds.floatField(value=0.01, precision=3)
cmds.setParent("..")
cmds.button(label="执行优化", command=self.optimize_keys_callback)
cmds.setParent(main_layout)
cmds.showWindow(self.window)
def pick_target(self, *args):
sel = cmds.ls(selection=True)
if len(sel) == 1:
cmds.textFieldButtonGrp(self.ff_target, edit=True, text=sel[0])
else:
cmds.warning("请选择一个目标对象。")
def read_coords(self, *args):
target = cmds.textFieldButtonGrp(self.ff_target, query=True, text=True)
if target and cmds.objExists(target):
pos = cmds.xform(target, query=True, worldSpace=True, translation=True)
cmds.floatFieldGrp(self.ff_offset, edit=True, value1=pos[0], value2=pos[1], value3=pos[2])
else:
cmds.warning("目标对象无效。")
def apply_offset(self, sign):
vec = cmds.floatFieldGrp(self.ff_offset, query=True, value=True)
vec = [v * sign for v in vec]
sel = cmds.ls(selection=True)
if not sel: return
for obj in sel:
# 1. 移动静态位置
cmds.move(vec[0], vec[1], vec[2], obj, relative=True, worldSpace=True)
# 2. 移动动画曲线
anim_curves = cmds.listConnections(obj, type="animCurve") or []
for curve in anim_curves:
plugs = cmds.listConnections(curve, plugs=True) or []
for plug in plugs:
attr = plug.split(".")[-1]
idx = -1
if attr == "translateX": idx = 0
elif attr == "translateY": idx = 1
elif attr == "translateZ": idx = 2
if idx != -1 and vec[idx] != 0:
cmds.keyframe(curve, edit=True, relative=True, valueChange=vec[idx])
def get_key_times(self, obj, only_selected=False):
times = set()
try:
kt = cmds.keyframe(obj, at=['translate', 'rotate', 'scale'], query=True, timeChange=True, selected=only_selected)
if kt:
times.update(kt)
except:
pass
return sorted(list(times))
def manual_bake(self, objects, target, start, end):
"""
手动逐帧计算矩阵并烘焙关键帧 (Maya API 2.0)
target: 目标对象名称。如果为 None,则表示烘焙到世界。
"""
# Get UI Settings
frame_mode = cmds.radioButtonGrp(self.rb_frame_mode, query=True, select=True) # 1=Keep, 2=Bake All
range_mode = cmds.radioButtonGrp(self.rb_range_mode, query=True, select=True) # 1=All Keys, 2=Playback, 3=Selected
# 1. Identify Keyframes
unique_bake_times = set()
only_selected = (range_mode == 3)
if frame_mode == 1: # Keep Keyframes
for obj in objects:
unique_bake_times.update(self.get_key_times(obj, only_selected=only_selected))
if range_mode == 2: # Playback Range
unique_bake_times = {t for t in unique_bake_times if start <= t <= end}
else: # Bake All Frames
min_t = start
max_t = end
if range_mode == 1 or range_mode == 3: # All Keyframes Range OR Selected Keys Range
found_keys = False
calc_min = None
calc_max = None
for obj in objects:
times = self.get_key_times(obj, only_selected=only_selected)
if times:
if calc_min is None or times[0] < calc_min: calc_min = times[0]
if calc_max is None or times[-1] > calc_max: calc_max = times[-1]
found_keys = True
if found_keys:
min_t = calc_min
max_t = calc_max
if min_t is not None and max_t is not None:
for f in range(int(min_t), int(max_t) + 1):
unique_bake_times.add(f)
unique_bake_times = sorted(list(unique_bake_times))
# If no keys, bake current frame
if not unique_bake_times:
unique_bake_times = [cmds.currentTime(query=True)]
# 1. 预先缓存所有帧的世界矩阵
# 结构: { obj_name: { frame: MMatrix } }
world_matrices = {}
# 确保对象列表是长名,避免重名冲突
objects = cmds.ls(objects, long=True)
if target:
target = cmds.ls(target, long=True)[0]
for obj in objects:
world_matrices[obj] = {}
# 遍历每一帧记录世界状态
# 注意: 必须移动时间滑块来获取每一帧的正确求值状态
for f in unique_bake_times:
cmds.currentTime(f)
# 记录对象矩阵
for obj in objects:
mat_list = cmds.getAttr(obj + ".worldMatrix[0]")
world_matrices[obj][f] = om.MMatrix(mat_list)
# 2. 设置父子关系 并 追踪新名称
# 当物体改变父级时,其 DAG 路径(长名)会发生变化
# 我们需要维护一个映射:旧长名 -> 新长名
obj_map = {obj: obj for obj in objects}
for obj in objects:
if target:
# 检查当前父级
parents = cmds.listRelatives(obj, parent=True, fullPath=True)
if not parents or parents[0] != target:
# parent 返回新名称列表
new_names = cmds.parent(obj, target)
# 获取新的长名
if new_names:
new_long_name = cmds.ls(new_names[0], long=True)[0]
obj_map[obj] = new_long_name
else:
# 烘焙到世界
if cmds.listRelatives(obj, parent=True):
new_names = cmds.parent(obj, world=True)
if new_names:
new_long_name = cmds.ls(new_names[0], long=True)[0]
obj_map[obj] = new_long_name
# 3. 逐帧应用变换并打关键帧
for f in unique_bake_times:
cmds.currentTime(f)
for obj in objects:
if f not in world_matrices[obj]:
continue
# 获取当前时刻该物体的正确名称(新 DAG 路径)
current_obj = obj_map[obj]
desired_world_mg = world_matrices[obj][f]
# 直接设置世界矩阵,Maya 自动计算局部变换
# Flatten the matrix for cmds.xform
flat_matrix = [v for row in desired_world_mg for v in row]
cmds.xform(current_obj, matrix=flat_matrix, worldSpace=True)
# 打关键帧
cmds.setKeyframe(current_obj, at=['translate', 'rotate', 'scale'])
def optimize_keys_callback(self, *args):
threshold = cmds.floatField(self.ff_optimize_threshold, query=True, value=True)
# Get range mode
range_mode = cmds.radioButtonGrp(self.rb_range_mode, query=True, select=True) # 1=All, 2=Playback, 3=Selected
start = cmds.playbackOptions(query=True, min=True)
end = cmds.playbackOptions(query=True, max=True)
sel = cmds.ls(selection=True)
if not sel:
cmds.warning("请选择要优化的对象")
return
cmds.undoInfo(openChunk=True)
try:
for obj in sel:
self.optimize_curve(obj, threshold, start, end, range_mode)
print("关键帧优化完成")
finally:
cmds.undoInfo(closeChunk=True)
def optimize_curve(self, obj, threshold, start, end, range_mode):
# Find all anim curves
anim_curves = cmds.listConnections(obj, type="animCurve", destination=False, source=True) or []
for curve in anim_curves:
# Check if curve is connected to transform attributes
# Simplification: optimize all found curves for the object
# Get key times and values
key_count = cmds.keyframe(curve, query=True, keyframeCount=True)
if not key_count or key_count <= 2:
continue
times = cmds.keyframe(curve, query=True, timeChange=True)
values = cmds.keyframe(curve, query=True, valueChange=True)
# Pre-fetch selected times if needed
selected_times = set()
if range_mode == 3:
st = cmds.keyframe(curve, query=True, timeChange=True, selected=True)
if st:
selected_times = set(st)
keys_to_delete = []
last_val = None
for i in range(key_count):
t = times[i]
val = values[i]
in_range = True
if range_mode == 2: # Playback
if t < start or t > end:
in_range = False
elif range_mode == 3: # Selected
if t not in selected_times:
in_range = False
if in_range:
if last_val is None:
last_val = val
else:
# Check if last key
if i == key_count - 1:
last_val = val
else:
dist = abs(val - last_val)
if dist > threshold:
last_val = val
else:
keys_to_delete.append(t)
if keys_to_delete:
for t in keys_to_delete:
cmds.cutKey(curve, time=(t,t))
def bake(self, mode):
sel = cmds.ls(selection=True)
if not sel: return
target = cmds.textFieldButtonGrp(self.ff_target, query=True, text=True)
start = cmds.playbackOptions(query=True, min=True)
end = cmds.playbackOptions(query=True, max=True)
if mode == "local":
if not target or not cmds.objExists(target):
cmds.warning("目标对象无效。")
return
# 确保目标不在选中列表中
if target in sel:
cmds.warning("目标对象不能在选中列表中。")
return
self.manual_bake(sel, target, start, end)
elif mode == "world":
self.manual_bake(sel, None, start, end)
print(f"Bake to {mode} completed (Manual Calculation).")
# -----------------------------------------------------------------------------
# 插件注册 (Plugin Registration)
# -----------------------------------------------------------------------------
# 保持工具实例引用
_tool_instance = None
def show_offset_anim_ui(*args):
global _tool_instance
if _tool_instance is None:
_tool_instance = OffsetAnimationTool()
_tool_instance.show_ui()
def create_menu_item():
# 延迟创建菜单,确保主窗口已加载
main_window = mel.eval("$tmp = $gMainWindow")
if not cmds.menu("OffsetAnimMenu", exists=True):
menu = cmds.menu("OffsetAnimMenu", label="Offset Anim", parent=main_window, tearOff=True)
cmds.menuItem(label="Offset Animation Tool", command=show_offset_anim_ui)
def delete_menu_item():
if cmds.menu("OffsetAnimMenu", exists=True):
cmds.deleteUI("OffsetAnimMenu")
# Maya 插件接口
def initializePlugin(mobject):
# 当插件加载时调用
create_menu_item()
def uninitializePlugin(mobject):
# 当插件卸载时调用
delete_menu_item()
blender(offset_animation_blender_addon)
init.py
python
bl_info = {
"name": "动画整体偏移工具 (Offset Animation Tool)",
"author": "Assistant",
"version": (1, 0),
"blender": (2, 80, 0),
"location": "View3D > Sidebar > Animation",
"description": "偏移动画曲线并烘焙相对于世界或局部目标的变换。",
"warning": "",
"wiki_url": "",
"category": "Animation",
}
import bpy
from bpy.props import PointerProperty
# Import classes from sub-modules
from .properties import OffsetToolProperties
from .operators import (
OT_ReadTargetPos,
OT_ApplyOffset,
OT_BakeToWorld,
OT_BakeToLocal,
OT_OptimizeKeys
)
from .ui import PT_AnimOffsetPanel
# Classes to register
classes = (
OffsetToolProperties,
OT_ReadTargetPos,
OT_ApplyOffset,
OT_BakeToWorld,
OT_BakeToLocal,
OT_OptimizeKeys,
PT_AnimOffsetPanel,
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.Scene.anim_offset_props = PointerProperty(type=OffsetToolProperties)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
del bpy.types.Scene.anim_offset_props
if __name__ == "__main__":
register()
python
import bpy
from bpy.types import Operator
from mathutils import Matrix
# ------------------------------------------------------------------------
# 属性定义 (UI 数据)
# 3. 烘焙 (手动矩阵计算逻辑)
def get_key_times(obj, only_selected=False):
"""
Get all unique keyframe times from the object's action fcurves.
"""
times = set()
if obj.animation_data and obj.animation_data.action:
for fcurve in obj.animation_data.action.fcurves:
for kp in fcurve.keyframe_points:
if only_selected and not kp.select_control_point:
continue
times.add(kp.co[0]) # co[0] is time/frame
return sorted(list(times))
def get_bake_times(context, objects):
"""根据设置获取需要烘焙的帧列表"""
props = context.scene.anim_offset_props
frame_mode = props.frame_mode
range_mode = props.range_mode
scene = context.scene
only_selected = (range_mode == 'SELECTED')
final_times = set()
if range_mode == 'PLAYBACK':
min_frame = scene.frame_start
max_frame = scene.frame_end
if frame_mode == 'KEEP':
for obj in objects:
times = get_key_times(obj, only_selected=False)
for t in times:
if min_frame <= t <= max_frame:
final_times.add(t)
else: # BAKE All
start_f = int(min_frame)
end_f = int(max_frame)
for f in range(start_f, end_f + 1):
final_times.add(float(f))
else: # ALL Keys or SELECTED Keys
all_keys = []
for obj in objects:
all_keys.extend(get_key_times(obj, only_selected=only_selected))
if frame_mode == 'KEEP':
final_times.update(all_keys)
else: # BAKE All
if all_keys:
min_f = int(min(all_keys))
max_f = int(max(all_keys))
for f in range(min_f, max_f + 1):
final_times.add(float(f))
sorted_times = sorted(list(final_times))
if not sorted_times:
sorted_times = [float(scene.frame_current)]
return sorted_times
def manual_bake_animation(context, objects, target):
"""
手动逐帧计算矩阵并烘焙关键帧,确保坐标变换准确。
target: 新的父级对象。如果为 None,则表示烘焙到世界 (清除父级)。
"""
# 1. Collect unique bake times
unique_bake_times = get_bake_times(context, objects)
# 2. 预先缓存所有帧的世界矩阵
# 结构: { obj_name: { frame: matrix_world } }
world_matrices = {}
for obj in objects:
world_matrices[obj.name] = {}
# 遍历每一帧记录世界状态
for f in unique_bake_times:
context.scene.frame_set(int(f))
for obj in objects:
world_matrices[obj.name][f] = obj.matrix_world.copy()
# 3. 设置父子关系
# 只需要设置一次,之后逐帧写入 World 矩阵让 Blender 自动计算 Local
for obj in objects:
if target:
if obj.parent != target:
obj.parent = target
# 保持世界变换不变 (Visual Transform)
obj.matrix_parent_inverse = obj.parent.matrix_world.inverted()
else:
if obj.parent:
# Clear parent, keep transformation
matrix_world = obj.matrix_world.copy()
obj.parent = None
obj.matrix_world = matrix_world
# 4. 逐帧应用变换并打关键帧
for f in unique_bake_times:
context.scene.frame_set(int(f))
for obj in objects:
if obj.name not in world_matrices or f not in world_matrices[obj.name]:
continue
desired_world_mg = world_matrices[obj.name][f]
# 直接设置世界矩阵,Blender 会自动计算局部矩阵
obj.matrix_world = desired_world_mg
# 插入关键帧
# 必须在设置矩阵后立即插入,否则帧切换会重置
obj.keyframe_insert(data_path="location")
if obj.rotation_mode == 'QUATERNION':
obj.keyframe_insert(data_path="rotation_quaternion")
elif obj.rotation_mode == 'AXIS_ANGLE':
obj.keyframe_insert(data_path="rotation_axis_angle")
else:
obj.keyframe_insert(data_path="rotation_euler")
obj.keyframe_insert(data_path="scale")
# 更新视图
context.view_layer.update()
# 1. 读取坐标
class OT_ReadTargetPos(Operator):
bl_idname = "anim_offset.read_pos"
bl_label = "读取目标坐标"
bl_description = "读取目标物体的世界坐标填入下方偏移量"
def execute(self, context):
props = context.scene.anim_offset_props
target = props.target_object
if not target:
self.report({'ERROR'}, "请先选择目标物体!")
return {'CANCELLED'}
# 获取世界坐标
loc = target.matrix_world.translation
props.offset_x = loc.x
props.offset_y = loc.y
props.offset_z = loc.z
self.report({'INFO'}, f"已读取坐标: {loc}")
return {'FINISHED'}
# 2. 应用偏移 (加/减)
class OT_ApplyOffset(Operator):
bl_idname = "anim_offset.apply"
bl_label = "应用偏移"
bl_description = "将偏移量应用到选中物体的动画上"
operation: bpy.props.EnumProperty(
items=[('ADD', "Add", ""), ('SUB', "Subtract", "")],
default='ADD'
)
def execute(self, context):
props = context.scene.anim_offset_props
# 构建偏移向量
vec = [props.offset_x, props.offset_y, props.offset_z]
if self.operation == 'SUB':
vec = [-v for v in vec]
selected_objects = context.selected_objects
if not selected_objects:
self.report({'ERROR'}, "请选择至少一个物体!")
return {'CANCELLED'}
for obj in selected_objects:
# 1. 移动静态位置 (当前帧)
obj.location.x += vec[0]
obj.location.y += vec[1]
obj.location.z += vec[2]
# 2. 移动关键帧 (如果有动画)
if obj.animation_data and obj.animation_data.action:
action = obj.animation_data.action
for fcurve in action.fcurves:
# 只处理位置曲线 (location)
if fcurve.data_path == "location":
idx = fcurve.array_index # 0=x, 1=y, 2=z
offset_val = vec[idx]
if offset_val != 0:
for kp in fcurve.keyframe_points:
kp.co[1] += offset_val
kp.handle_left[1] += offset_val
kp.handle_right[1] += offset_val
self.report({'INFO'}, "偏移已应用")
return {'FINISHED'}
# 3a. 烘焙到世界
class OT_BakeToWorld(Operator):
bl_idname = "anim_offset.bake_world"
bl_label = "烘焙到世界坐标"
bl_description = "解绑父级并烘焙动画为世界坐标"
def execute(self, context):
objs = context.selected_objects
if not objs: return {'CANCELLED'}
# 使用手动烘焙逻辑,target=None 表示世界
manual_bake_animation(context, objs, None)
self.report({'INFO'}, "已转换为世界坐标动画")
return {'FINISHED'}
# 3b. 烘焙到局部 (绑定目标)
class OT_BakeToLocal(Operator):
bl_idname = "anim_offset.bake_local"
bl_label = "烘焙到局部坐标"
bl_description = "绑定到目标物体并烘焙局部动画"
def execute(self, context):
props = context.scene.anim_offset_props
target = props.target_object
objs = context.selected_objects
if not target:
self.report({'ERROR'}, "请先设置目标物体!")
return {'CANCELLED'}
if not objs:
return {'CANCELLED'}
if target in objs:
self.report({'ERROR'}, "目标物体不能在选中列表中!")
return {'CANCELLED'}
# 使用手动烘焙逻辑,传入 target
manual_bake_animation(context, objs, target)
self.report({'INFO'}, "已转换为局部坐标动画")
return {'FINISHED'}
# 4. 优化关键帧
class OT_OptimizeKeys(Operator):
bl_idname = "anim_offset.optimize_keys"
bl_label = "优化关键帧"
bl_description = "移除冗余的关键帧"
def execute(self, context):
props = context.scene.anim_offset_props
threshold = props.optimize_threshold
range_mode = props.range_mode
objs = context.selected_objects
if not objs:
self.report({'ERROR'}, "请选择至少一个物体!")
return {'CANCELLED'}
# 确定范围
min_f = -float('inf')
max_f = float('inf')
if range_mode == 'PLAYBACK':
min_f = context.scene.frame_start
max_f = context.scene.frame_end
count = 0
for obj in objs:
if not obj.animation_data or not obj.animation_data.action:
continue
action = obj.animation_data.action
for fcurve in action.fcurves:
kp_list = fcurve.keyframe_points
if len(kp_list) <= 2:
continue
to_delete = []
points_data = [] # list of (index, frame, value, selected)
for i, kp in enumerate(kp_list):
points_data.append((i, kp.co[0], kp.co[1], kp.select_control_point))
valid_points = []
for p in points_data:
idx, t, val, sel = p
# 范围检查
if range_mode == 'PLAYBACK':
if t < min_f or t > max_f:
continue
elif range_mode == 'SELECTED':
if not sel:
continue
valid_points.append(p)
if not valid_points:
continue
last_val = None
for i in range(len(valid_points)):
p = valid_points[i]
idx, t, val = p
if last_val is None:
last_val = val
else:
is_last_in_scope = (i == len(valid_points) - 1)
if is_last_in_scope:
last_val = val
else:
dist = abs(val - last_val)
if dist > threshold:
last_val = val
else:
to_delete.append(idx)
if to_delete:
kps_to_remove = []
for idx in to_delete:
kps_to_remove.append(kp_list[idx])
for kp in reversed(kps_to_remove):
try:
kp_list.remove(kp)
count += 1
except:
pass
fcurve.update()
self.report({'INFO'}, f"优化完成,移除了 {count} 个关键帧")
return {'FINISHED'}
python
import bpy
from bpy.props import FloatProperty, PointerProperty, EnumProperty
from bpy.types import PropertyGroup
class OffsetToolProperties(PropertyGroup):
offset_x: FloatProperty(name="X 偏移", default=0.0)
offset_y: FloatProperty(name="Y 偏移", default=0.0)
offset_z: FloatProperty(name="Z 偏移", default=0.0)
# 目标物体 (用于读取坐标 或 作为父级)
target_object: PointerProperty(name="目标物体", type=bpy.types.Object)
# 烘焙设置
frame_mode: EnumProperty(
name="帧模式",
items=[
('KEEP', "保持帧数", "只在原有关键帧处烘焙"),
('BAKE', "烘焙所有帧", "在指定范围内逐帧烘焙")
],
default='KEEP'
)
range_mode: EnumProperty(
name="范围模式",
items=[
('ALL', "所有关键帧", "使用物体自身的关键帧范围"),
('PLAYBACK', "播放范围", "使用场景的播放范围"),
('SELECTED', "选择的帧", "只处理选中的关键帧")
],
default='ALL'
)
# 优化设置
optimize_threshold: FloatProperty(name="优化阈值", default=0.001, min=0.0, precision=4)
python
import bpy
from bpy.types import Panel
class PT_AnimOffsetPanel(Panel):
bl_label = "动画整体偏移工具"
bl_idname = "VIEW3D_PT_anim_offset"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = 'Animation' # 在侧边栏的 Animation 标签页
def draw(self, context):
layout = self.layout
props = context.scene.anim_offset_props
# 目标选择
layout.label(text="目标/参考物体:")
layout.prop(props, "target_object", text="")
layout.operator("anim_offset.read_pos", text="读取目标坐标", icon='EYEDROPPER')
layout.separator()
# 偏移输入
col = layout.column(align=True)
col.prop(props, "offset_x")
col.prop(props, "offset_y")
col.prop(props, "offset_z")
row = layout.row(align=True)
op_add = row.operator("anim_offset.apply", text="加上偏移 (+)", icon='ADD')
op_add.operation = 'ADD'
op_sub = row.operator("anim_offset.apply", text="减去偏移 (-)", icon='REMOVE')
op_sub.operation = 'SUB'
layout.separator()
# 烘焙操作
layout.operator("anim_offset.bake_world", text="转为世界坐标 (解绑+烘焙)", icon='WORLD')
layout.operator("anim_offset.bake_local", text="转为局部坐标 (绑定+烘焙)", icon='CONSTRAINT')
layout.separator()
# 烘焙设置
box = layout.box()
box.label(text="烘焙设置")
box.prop(props, "frame_mode", expand=True)
box.prop(props, "range_mode", expand=True)
layout.separator()
# 优化关键帧
box = layout.box()
box.label(text="优化关键帧")
box.prop(props, "optimize_threshold")
box.operator("anim_offset.optimize_keys", text="优化曲线", icon='GRAPH')