🔖

【Maya】モデルのメッシュと頂点情報の変更履歴を保存して再現する、モデリング作業記録ツールの検証

2023/12/19に公開

概要

僕は普段は仕事でTAをしています。

Mayaでのモデリングの作業工程を記録してアニメーションで再現したいと思ったので検証しました。

よくYouTubeでみるようなモデリング講座ってOBSなどで画面収録してると思います。外部に公開するような動機があれば、画面収録するとは思いますが、普段画面収録してたらPCの容量いくつあっても足りませんよね。なのでMaya上で操作単位でのモデルの変更履歴を保存するツールを考えたので検証を行いました。もし実現できればモデリングの工程をSNSにあげてドヤることもできますね。完成後に1から作業工程っぽくアニメーションを付けてる方もいらっしゃいました。(大変そう)

今回はオブジェクトの状態の記録と頂点の記録の2つを検証しました。

オブジェクトの変形、移動の記録


ObjectLoggerツール

メッシュを選択してStartRecordを押すと、ScriptJobに選択したすべてメッシュの属性変更されたタイミングで実行する処理をScriptJobに追加します。

for obj in selected_objects:
            for attr in ["translateX", "translateY", "translateZ", "rotateX", "rotateY", "rotateZ", "scaleX", "scaleY",
                         "scaleZ"]:
                job_id = cmds.scriptJob(
                    attributeChange=[f"{obj}.{attr}", lambda x=obj, y=attr: self.on_attribute_change(x, y)])
                self.script_job_ids.append(job_id)

1秒間の間に行われた操作を1フレーム単位で保存します。

StopRecordを押すと、監視をやめて、記録した情報を元にキーフレームを打ちます。このタイミングで別シーンに保存するなどしておけばアニメーションシーンとして記録できます。

結果


↑オブジェクトに対して操作した内容がキーフレームとして打ち込まれた様子

操作がちゃんと記録されてアニメーション化できました。

ObjectLogger.py

ObjectLogger.py
import maya.cmds as cmds
from PySide2 import QtWidgets
import time


class ObjectLogger(QtWidgets.QWidget):
    def __init__(self, parent=None):
        super(ObjectLogger, self).__init__(parent)
        self.setWindowTitle("ObjectLogger")
        self.setLayout(QtWidgets.QVBoxLayout())

        self.record_btn = QtWidgets.QPushButton("Start/Stop Recording")
        self.layout().addWidget(self.record_btn)
        self.record_btn.clicked.connect(self.toggle_recording)

        self.is_recording = False
        self.transform_log = []
        self.script_job_ids = []

        self.change_buffer = []
        self.buffered_changes = []
        self.last_record_time = time.time()
        self.last_change_time = 0
        self.frame_number = 1

    def toggle_recording(self):
        self.is_recording = not self.is_recording
        if self.is_recording:
            self.record_btn.setText("Stop Recording")
            self.start_recording()
        else:
            self.record_btn.setText("Start Recording")
            self.stop_recording()

    def start_recording(self):
        self.current_frame = 1
        self.transform_log = []
        selected_objects = cmds.ls(selection=True)
        for obj in selected_objects:
            for attr in ["translateX", "translateY", "translateZ", "rotateX", "rotateY", "rotateZ", "scaleX", "scaleY",
                         "scaleZ"]:
                job_id = cmds.scriptJob(
                    attributeChange=[f"{obj}.{attr}", lambda x=obj, y=attr: self.on_attribute_change(x, y)])
                self.script_job_ids.append(job_id)

    def on_attribute_change(self, obj, attr):
        if not self.is_recording:
            return
        current_time = time.time()
        if current_time - self.last_change_time >= 1.0:
            self.frame_number += 1
            self.last_change_time = current_time
        value = cmds.getAttr(f"{obj}.{attr}")
        self.transform_log.append((self.frame_number, obj, attr, value))

    def process_buffered_changes(self):
        for frame, obj, attr, value in self.transform_log:
            cmds.currentTime(frame)
            cmds.setAttr(f"{obj}.{attr}", value)
            cmds.setKeyframe(obj, attribute=attr, time=(frame, frame))

    def stop_recording(self):
        for job_id in self.script_job_ids:
            cmds.scriptJob(kill=job_id, force=True)
        self.script_job_ids = []

        self.process_buffered_changes()

        self.is_recording = False


def show_logger():
    global logger_window
    try:
        logger_window.close()
        logger_window.deleteLater()
    except:
        pass
    logger_window = ObjectLogger()
    logger_window.show()


show_logger()


頂点の記録

モデリング作業なので、頂点情報の扱いが重要です。
頂点の変更はScriptJobでは取れないため、ポーリングしてすべての頂点情報を取得します。


VertexTrackerツール

頂点の位置の取得

def get_current_vertex_positions(self, mesh):
  vertex_count = cmds.polyEvaluate(mesh, vertex=True)
  return [cmds.pointPosition(f'{mesh}.vtx[{i}]', world=True) for i inn range(vertex_count)]

threading

負荷が重そうなポーリング中の頂点の差分検知はthresholdを使ってMayaのUIの操作を邪魔しないように考慮します。

self.tracking_thread = threading.Thread(target=self.track_changes)
    def track_changes(self):
        while self.running:
            for mesh, old_positions in self.vertex_data.items():
                new_positions = self.get_current_vertex_positions(mesh)
                for i, (old_pos, new_pos) in enumerate(zip(old_positions, new_positions)):
                    if self.is_significant_change(old_pos, new_pos):
                        self.record_vertex_change(mesh, i, new_pos)
                self.vertex_data[mesh] = new_positions
            time.sleep(0.1)

閾値の設定

再現しなくてもいいわずかな位置変更はできるだけ無視し、一定の閾値以上の変更のみ保存するようにしています。

def is_significant_change(self, old_pos, new_pos):
  return any(abs(a - b) > self.threshold for a, b in zip(old_pos, new_pos))

記録

頂点 vtx[61]を動かして、記録した際のログです。一応取れてそうです。オブジェクトのように1秒ごとのバッファリング処理は入れてないので、ちょっと動かすだけで大量にログが流れます。

Frame 1: pCubeShape1 vtx[61] -> [6.62453191158092, -9.871323108673096e-07, 33.928231090664866]
Frame 2: pCubeShape1 vtx[61] -> [6.62453191158092, -9.871323108673096e-07, 56.519302870428085]
Frame 3: pCubeShape1 vtx[61] -> [6.62453191158092, -9.871323108673096e-07, 72.52843305572891]

記録は取れてることがわかります。頂点を300以上記録しても重くなったりしなかったので、現時点では思ったより重い処理ではなさそうですが、プリミティブの頂点を動かしただけなので、もっと大きいシーンで検証する必要があるのと、頂点の追加・削除の記録に対応する場合、すべての頂点数を比較し続ける必要があったり、ちゃんと対応していくと重くなるんだと思います。

その場合は、操作してないときに記録を止める、バッファリングをしっかり設定する、OpenMayaやC++プラグインへの変更をするなどが必要になってきそうです。

VertexTracker.py

VertexTracker.py
import maya.cmds as cmds
import threading
import time

class VertexTracker:
    def __init__(self, threshold=0.1):
        self.threshold = threshold
        self.running = False
        self.vertex_data = {}
        self.frame_number = 0
        self.transform_log = []
        self.tracking_thread = None

    def start_tracking(self):
        if not self.running:
            self.running = True
            self.init_vertex_data()
            self.tracking_thread = threading.Thread(target=self.track_changes)
            self.tracking_thread.start()

    def stop_tracking(self):
        if self.running:
            self.running = False
            self.tracking_thread.join()
            self.output_logged_changes()

    def init_vertex_data(self):
        selected_meshes = cmds.ls(selection=True, dag=True, type='mesh')
        for mesh in selected_meshes:
            self.vertex_data[mesh] = self.get_current_vertex_positions(mesh)

    def get_current_vertex_positions(self, mesh):
        vertex_count = cmds.polyEvaluate(mesh, vertex=True)
        return [cmds.pointPosition(f'{mesh}.vtx[{i}]', world=True) for i in range(vertex_count)]

    def track_changes(self):
        while self.running:
            for mesh, old_positions in self.vertex_data.items():
                new_positions = self.get_current_vertex_positions(mesh)
                for i, (old_pos, new_pos) in enumerate(zip(old_positions, new_positions)):
                    if self.is_significant_change(old_pos, new_pos):
                        self.record_vertex_change(mesh, i, new_pos)
                self.vertex_data[mesh] = new_positions
            time.sleep(0.1)

    def is_significant_change(self, old_pos, new_pos):
        return any(abs(a - b) > self.threshold for a, b in zip(old_pos, new_pos))

    def record_vertex_change(self, mesh, vertex_index, new_position):
        self.frame_number += 1
        self.transform_log.append((self.frame_number, mesh, f'vtx[{vertex_index}]', new_position))

    def output_logged_changes(self):
        print("Logged Vertex Changes:")
        for change in self.transform_log:
            frame, mesh, vtx_info, local_position = change
            print(f"Frame {frame}: {mesh} {vtx_info} -> {local_position}")

def create_ui():
    window_name = "VertexTrackerUI"
    if cmds.window(window_name, exists=True):
        cmds.deleteUI(window_name)
    cmds.window(window_name, title="Vertex Tracker")
    cmds.columnLayout(adjustableColumn=True)
    cmds.button(label="Start Tracking", command=lambda x: tracker.start_tracking())
    cmds.button(label="Stop Tracking", command=lambda x: tracker.stop_tracking())
    cmds.showWindow(window_name)

tracker = VertexTracker()

create_ui()

まとめ

オブジェクトの状態と頂点の記録の2つを検証するスクリプトで実現したかったことをすることができました。

モデリング操作記録ツールとして作り込むには道のりは遠そうですが、頂点の扱いやScriptJobの遅延や待機の仕組み、Threadingなど、Mayaの仕組みに触れることが出来たのでいい機会になりました。

キャラモデリングで100時間くらいかけてモデルを作ったことありますが、もうその時の作り方を忘れてしまっています。モデリングの上達には工程を記録していつでも、復習できるような仕組みがあったらもっと上達が速くなるのは間違いないので、気が向いたらこのツールの実現に挑戦してみようかなと思ってます。今回は頂点やメッシュのみですが、テクスチャ、マテリアル、その他操作を再現するならもっと色々やることありますよね。

Discussion