チュートリアル / カットシーンのデータ管理
第5回:カメラに映る情報から出力対象を取得してみよう!①

  • Maya
  • ゲーム
  • コラム
  • スクリプト・API
  • チュートリアル
  • 学生・初心者
「カメラに映る情報から出力対象を取得してみよう!①」解説記事のキービジュアル

みなさんこんにちは。
このコラムでは、ゲーム制作におけるカットシーンのデータ管理について、少し変わったアプローチによる効率化事例を紹介しています。

ここまで、

第一回目「TimeEditorのデータ構造に関して理解を深める」
第二回目「TimeEditorをスクリプトで操作してみる」
第三回目「TimeEditorの変則的な使い方とカスタマイズ」
第四回目「TimeEditorを拡張し、対象ノードをFBX出力・登録情報をcsv出力できるようにする」

といった形で、TimeEditorの応用的な運用やカスタマイズについて解説してきました。

今回は、『カメラに映る情報から出力対象を取得する』方法について詳しくご紹介します。

実際の運用とフィードバック

前回までのカスタマイズにより、TimeEditorをデータベース的に運用し、カットシーンのアニメーションを出力・管理できるようになりました。

「登録の手間すら省きたい」という現場の声

しかし、実際に運用してみると、
「出力対象をTimeEditorにクリップ登録していく手順が面倒」
というフィードバックが寄せられました。

要は「登録の手間すら省きたい」というわけです。

しかし考えてみると、登場するキャラクターやカメラが多い場合、確かにこの登録作業は回数を重ねるほど手間になっていきます。
これ以前の「出力情報をエクセル管理」していた手間からは大分効率化できたと思いますが、残念ながらまだ最適ではなかった訳です。

やはり開発現場は一筋縄ではいきませんね。

「カメラドリブン」という手法:いっそ「カメラに映るものを対象にしてしまえ」

それならいっそ、特別な登録作業は一切せず、シンプルに「カメラに映ったものを出力対象にする」という運用ではどうでしょうか。

当時関わっていたプロジェクトでは、Maya上でカットシーンをある程度作成した後、Playblast動画を撮影してチェックに回していました。

このフローに着目して、Playblast撮影時に「カメラに映っているメッシュを出力対象として記録する」仕組みを組み込めば、シーン内に出力対象情報を保持できます。
そして、その情報を後のFBXやCSV出力時に利用する、という流れです。

もしも途中でカットに変更がかかり出力対象のノードが変わってしまっても、ワークフロー上カットシーンに変更があればPlayblastの確認動画は必ず出すため、この問題も大丈夫そうです。

この方法であれば、既存の制作ワークフローに自然に組み込めるため、作業負荷を増やすことなく効率化が可能です。

このように、カメラを基準に対象を決定する手法を、ここでは「カメラドリブン」と呼びます。

今回はその概要を、次回では実運用への組み込みについて紹介していきます。

指定のカメラに映るノードを取得する

では、「カメラに映るノード」をリストアップするには一体どうしたらいいのでしょうか。
一つずつ方法を考えてみます。

①レイを飛ばして取得する方法

まず思いつく方法としては、カメラからレイを飛ばしてMeshノードとの衝突を見る方法。
あらかじめ候補となるMeshノードをリストアップしておき、レイとの衝突判定を行ってカメラから見える一番手前のノードを取得するやり方です。

カメラからレイを飛ばしてMeshノードとの衝突を判定するイメージ図

これは、MayaAPI2.0のMFnMeshクラスにある関数、closestIntersection()を使えば、対象のメッシュノードと指定したレイとの衝突をチェックすることができます。

問題なのは処理の重さ(試行回数の多さ)

ただ問題なのは、処理の重さです。

あらかじめ列挙したMeshノードごとにレイとの衝突判定を行うため、for文による繰り返し処理が多くなってしまうわけです。
候補となるノードの数やレイの密度によってはとんでもない待ち時間になります。

密度の高いレイを多数のメッシュに対して判定するイメージ図

処理の高速化のためには、

・対象にするmeshノードをカメラ範囲内(いわゆる視錐台内にあるノード)に限定する
・Meshノードの命名規則で対象を絞る

…など、とにかく試行回数を減らす工夫が必要になりますが、最終的にはレイの密度を落とさざるを得ません。

レイの密度を落としたことで小さなメッシュを検出できなくなるイメージ図

しかし出力対象ノードの検出はなるべく正確にしたいので、あまり精度を落とすわけにはいかず…。

これでは、アーティストに「待ち時間」を発生させてしまうことになります。
仕事効率化のためのToolなので、待ち時間はなるべく発生させたくありません。

②もっとシンプルに考えて 「Camera-based-selection」

いったん頭をリフレッシュして。
手作業の時、ビューに表示されているノードだけをさっと選択する場面を想像してみます。

おそらく、選択Toolの「Camera-based-selection」オプションをONにして、画面いっぱい左ドラッグしてさっと選択しているはずで、この操作によるmayaの処理は大体高速です。

選択ToolのCamera-based-selectionをONにしてビューを左ドラッグで選択する操作のアニメーション

これをスクリプトで実行する方法を考えてみましょう。

「Camera-based-selection」をスクリプトで実現する

現在のカメラに映るノードを取得する、これは実はOpenMayaを使えば非常に簡単で…
om.MGlobal.selectFromScreen()
この関数を使えば、現在のアクティブなスクリーンからノードを選択することができます。

具体的には以下の通り。

from maya import cmds
import maya.api.OpenMaya as om

def get_nodesFromCurrentViewport():
    # 現在のアクティブなビューパネルを取得する
    activeView = ''
    for panel in cmds.getPanel(type="modelPanel"):
        if cmds.modelEditor(panel, query=True, activeView=True):
            activeView = panel

    # パネルのサイズを取得する
    control = cmds.modelPanel(panel, q=True, control=True) # モデルパネルの親のサイズを調べる
    width = cmds.control(control, q=True, width=True)
    height = cmds.control(control, q=True, height=True)

    cmds.refresh(force=True) # 念のため描画リフレッシュ
    
    # 選択マスクの設定
    original_mask = om.MGlobal.objectSelectionMask()
    mask = om.MSelectionMask(
        om.MSelectionMask.kSelectMeshes
    )
    om.MGlobal.setObjectSelectionMask(mask)
    
    original_selectPref = cmds.selectPref(q=True, useDepth=True)
    cmds.selectPref(useDepth=True) # Camera-based selectionをオンにする
    try:
        # 現在の viewport の selection method を使って画面全域選択
        om.MGlobal.selectFromScreen(
            0, # 幅 0ピクセル起点
            0, # 高さ 0ピクセル起点
            width - 1, # 幅の数値から-1して、0ピクセル起点に合わせる
            height - 1, # 高さの数値から-1して、0ピクセル起点に合わせる
            om.MGlobal.kReplaceList, # 必ず新規の選択リストを返すように、kReplaceListを指定
            om.MGlobal.kSurfaceSelectMethod # オブジェクトサーフェスをベースにした選択メソッドを指定
        )
    finally:
        # 選択のマスクや設定を元に戻す
        om.MGlobal.setObjectSelectionMask(original_mask)
        cmds.selectPref(useDepth=original_selectPref)

    selection = cmds.ls(sl=True)
    cmds.select(cl=True)

    return selection 

# 動作確認:カメラに映るノードを試しに選択してみる。
cmds.select(get_nodesFromCurrentViewport())

以上のような、非常に短いコードで実現することができます。

注意:
om.MGlobal.selectFromScreenは、Mayaの選択優先度の設定によってはメッシュノードではなくジョイントのみを選択するなど、想定と違った結果を返すことがあります。
そのため、上記のスクリプトはMeshノードのみを選択するようSelectionMaskが設定されています。

試しに挙動を見てみましょう。
3Dビュー上に、以下のようなプリミティブを配置します。

3Dビュー上にCube・Sphere・Coneなどのプリミティブを配置したシーン

CubeはSphereに隠れるようにして、選択されないようにし…

Sphereの後ろに隠れるように配置したCube

Coneはすこしだけはみ出してみましょう。

Cubeの陰から少しだけはみ出すように配置したCone

実行してみると…

スクリプトを実行してカメラから見えているノードだけが選択される様子のアニメーション

このように、カメラから見えているノードだけを取得することができました。
大変お手軽です。

取得したノードを出力対象にする

では次に、上記で取得できたノード一覧から、出力対象を抽出する必要があります。

また、これらのノード群は「カメラに映ったノード」のため、骨などはカメラに映らずに取得されていないことが考えられます。
そのことを考慮して、スキニングされたメッシュからはバインド情報をたどり、骨を別途取得する必要もあります。

命名規則で対象ノードをルール化

対象物として判別する手法としては、やはり命名規則によるフィルタリングが手っ取り早く、効果的です。
実装もシンプルで済みますし、命名規則の定義を別ファイルで管理しておけば、Toolとしての汎用性も担保できます。

処理の内容としては、取得したノードの階層をたどり、トップノードの名前を命名規則と比較。
もしも命名規則に則ったノードなら、出力対象に含める…といった処理にします。

命名規則を正規表現をつかって対象ノードか確認する

命名規則の表記とチェックは、正規表現を使って確認します。

あらかじめ対象ノードの命名規則を、このようなcsvで作成した外部ファイルに保存しておきます。

types,mesh_group,bone_root
character,.*:chr_\d{4}_mesh,.*:root
enemy,.*:enm_\d{4}_mesh,.*:root
obj,.*:obj_\d{4}_mesh,.*:root
boss,.*:bos_\d{4}_mesh,.*:root
mob,.*:mob_\d{4}_mesh,.*:root

csvはエクセルでも開けるので、編集しやすいですね。

命名規則のcsvをエクセルで開いた画面

この命名規則をまとめたcsvは、適宜バージョン管理を行った上でチーム内で共有しておきます。
そうすれば、急な命名規則の変更やルールの追加があっても運用が楽になります。

あらかじめ、上記csvを読み込み、ノード名と命名規則を比較する関数を書いておきましょう。

import csv
import re

def get_naming_definitions_from_csv(csvPath=''):
    with open(csvPath) as f:
        reader = csv.DictReader(f)
        naming_definition_list = [row for row in reader]
    
    return naming_definition_list

def checkName(node, naming_definition_csvPath):
    naming_definition_list = get_naming_definitions_from_csv(naming_definition_csvPath)
    naming_definitions = []
    for definition in naming_definition_list:
        def_str = definition['mesh_group']
        naming_definitions.append(re.compile(def_str))
        def_str = definition['bone_root']
        naming_definitions.append(re.compile(def_str))
    
    for name_def in naming_definitions:
        if name_def.match(node):
            return True
    return False

命名規則にあっていればTrue、あっていなければFalseを返します。

注意)この関数ではネームスペースの有無も加味するため、先頭にコロンを置いています。
実際にチェックする際にはネームスペースを絶対名にしてノード名を取得し、評価するようにしてください。

※正規表現の記法に関する解説は割愛いたします。
わかりやすい記事がネット上に沢山ありますのでそちらを参照するか、AIに聞けばすぐにわかるのでオススメです。

取得ノードの階層をたどってノード名を確認する。

取得ノードの階層をたどり、ノード名を確認するスクリプトを書いてみます。

def get_rootNodeName(node, *args, **kwargs):
    root = cmds.ls(node, l=True)[0].split('|')[1]
    if not root[0] == ':':
        # 絶対ネームスペースを有効にする
        root = ':{}'.format(root)
    return root

こんなかんじ。

スキニングされたメッシュから骨をたどる

スキニングされたメッシュから、バインドしている骨をたどるには、メッシュに紐づくskinClusterノードをたどります。
対象のメッシュに紐づくskinClusterノードを取得するには、melのfindRelatedSkinCluster (string $skinObj)が便利です。
https://help.autodesk.com/cloudhelp/2027/JPN/Maya-Tech-Docs/Commands/findRelatedSkinCluster.html
メッシュを渡して実行すれば、関連するSkinClusterノードを検索して教えてくれる便利なスクリプトです。

ではこれを用いて、対象のメッシュからスキン情報を取得してたどり、バインドジョイントのルートノードを返す関数を書いてみます。

from maya import mel

def get_jointRootFromMesh(mesh):
    skinCluster = mel.eval('findRelatedSkinCluster "{}"'.format(mesh))
    if not skinCluster:
        return ''

    inflences = cmds.skinCluster(skinCluster, q=True, inf=True)
    joints = cmds.ls(inflences, type='joint', l=True)
    joints.sort()

    root = joints[0].split('|')[-1] # フルパス表示先端の"|"を取り除く
    if not root[0] == ':':
        # 絶対ネームスペースを有効にする
        root = ':{}'.format(root)
    return root

試しに、以下のようなサンプルシーンで実行してみます。

pCube1メッシュにジョイントがバインドされたサンプルシーンのアニメーション

pCube1メッシュにジョイントノードがバインドされています。

先ほどの関数を実行してみると…

print(get_jointRootFromMesh('pCube1'))
    
:joint1

はい、このような形でバインドジョイントのルートノードの名前を取得することができました。

「カメラドリブン」でのノード取得処理の作成

では、ここまでまとめた処理で「カメラドリブン」によるノード取得処理を書いてみます。

from maya import cmds, mel
import maya.api.OpenMaya as om
import csv
import re

naming_definition_csvPath = "<命名規則csvファイルのフルパス>"

# カメラからノードを取得
allNodes = get_nodesFromCurrentViewport()

rootNodes = []
jointRoots = []

for node in allNodes:
    # カメラに映ったノードのルートノードを取得
    rootNode = get_rootNodeName(node)
    # ルートノードの命名規則チェック
    if not checkName(rootNode, naming_definition_csvPath):
        continue
        
    rootNodes.append(rootNode)
    if not cmds.listRelatives(
                node, shapes=True, 
                noIntermediate=True, type='mesh'
            ):
        continue
    
    # メッシュノードなら、骨ルートを検索
    jointRoot = get_jointRootFromMesh(node)
    if not jointRoot:
        continue
    # 骨ルートの命名規則チェック
    if not checkName(jointRoot, naming_definition_csvPath):
        continue
    jointRoots.append(jointRoot)

rootNodes = list(set(rootNodes))
jointRoots = list(set(jointRoots))

# 動作確認:出力対象ノードを試しに選択してみる。
cmds.select(rootNodes, jointRoots)

ざっとこんな感じです。
取得できたノード群は、処理の最後で選択して止めるようにしてあります。
実行後にOutlinerの表示を確認してみましょう。

サンプルシーンはこのようなものを準備しました。

命名規則に沿ったキャラクターが箱の陰から覗いているサンプルシーン

命名規則に沿ったキャラクターが、物陰からひょっこりしています。

実は物陰には、命名規則を合わせたConeが一つ隠れています。

箱の陰に隠れている命名規則を合わせたCone

スクリプトの実行結果では、対象のキャラクターとバインドジョイントのルートノードが選択されれば成功です。

Outlinerでキャラクターとバインドジョイントのルートノードが選択された状態

では実験してみましょう。

スクリプトを実行してカメラに映るキャラクターとジョイントのルートだけが選択される様子のアニメーション

…できました!

物陰のConeも選択されていません。

まとめ

今回は「カメラドリブン」によるカットシーンデータ管理の第一歩として、
「カメラに映ったノードの取得と登録」について解説しました。

次回はこれらの処理をPlayblast撮影用ツールに組み込み、実際の制作現場で活用できる形に落とし込んでいきます。

それでは、また次回!

製品購入に関するお問い合わせ
オートデスク メディア&エンターテインメント 製品のご購入に関してご連絡を希望される場合は、こちらからお問い合わせください。