跳到正文
Qiita · Unity 开发社区· 来源· tech-tsumiki·· 3 小时前AI 评分51

【Maya × Claude】自动化轮胎旋转(Car Wheel Rig Tool 详解)

【Maya × Claude】タイヤの回転を自動化する(Car Wheel Rig Tool 解説)

正文 · AI 翻译

Final.gif

我使用 Claude 制作了一套让乘用车轮胎随移动自动旋转的绑定及相应工具。无需为轮胎打关键帧,即使之后修改行驶速度或时间轴,旋转也会自动跟随。

轮胎的自动旋转本身是一种广为人知的技术。不过,测量半径和曲线长度、代入公式、每次绑定变更时都要手动修正,这些操作相当繁琐。因此,我们开发了一个 GUI 工具「Car Wheel Rig Tool」,将测量和表达式创建整合在一起。

已在 Maya 2026 中完成功能验证。

轮胎旋转的机制

轮胎的旋转角度由行进距离是轮胎周长(一整圈的长度)的多少倍来决定。

旋转角度的求法

1.周长 = 半径 × 2 × 3.14
2.转数 = 行进距离 ÷ 周长
3.旋转角度(度) = 转数 × 360

例:半径为30的轮胎,周长为 30 × 2 × 3.14 = 188.4。

回転角 = 進んだ距離 ÷ (半径 × 2 × 3.14) × 360

行进距离的求法

行进距离因车辆的操作方式而异。

操作方式 行进距离
Transform(直线移动) 移动量原值(例:translateZ)
Motion Path(沿曲线移动) uValue × 曲线长度

Motion Path 的 uValue 在 Parametric Length 为 ON 时,取值为 0〜1,表示相对于整条曲线的行进比例。它本身不是距离,因此需要乘以曲线长度来换算为距离。例如,曲线长度为 1000、uValue 为 0.25 时,行进距离为 250。
 

如果写成表达式

将上述计算用表达式写出,即如下形式。
半径 和 曲线长度 中填入测量得到的值。
 

Transform で動かす場合:
FrontWheel_L_r.rotateX = 360 * (Car_GP.translateZ / (2 * 3.14159265 * 半径));
Motion Path で動かす場合:
FrontWheel_L_r.rotateX = 360 * ((motionPath1.uValue * カーブの長さ) / (2 * 3.14159265 * 半径));

Rig 的整体结构

将用于转动的 _r 嵌套到用于转向的 _h 内部,这是该 Rig 的核心。

后轮不会通过方向盘改变朝向(不转向),因此将左右两个轮子合并为一个网格,仅用不含 _h 的 BackWheel_r 进行旋转。

后轮的处理方式适用于普通乘用车。对于后轮也参与转向的车辆,或像叉车那样用后轮转向的车辆,请同样为后轮添加 _h。

为每个轮胎创建一个定位器,放置在车轴中心(Rotate 0 / Scale 1)。最顶层的分组和车身网格请使用不同的名称,例如 Car_GP 和 Car。

Car_GP 的枢轴点位置设置为后轮中心。Motion Path 沿曲线移动的是枢轴点的位置。因此,后轮会沿曲线行驶,从而减轻后轮的打滑。

事前準備

如果使用 MotionPath 来移动,请在使用该工具之前,先添加 Motion Path,并将 Car_GP 设置为行驶状态。

确认 Parametric Length 的状态:在 Attribute Editor 的 motionPath 选项卡中,查看 U Value 下方的"Parametric Length"。

在 Parametric Length 状态下,uValue 的含义会发生变化。工具中的"uValue 换算"会根据此状态进行选择。

参数化长度 **uValue(U値)** は、建築物や部材(壁、窓、屋根など)の**熱貫流係数**(熱貫通率)を示す指標です。 - **単位**:W/(m²·K)(ワット每平方米ケルビン) - **意味**:室内外の温度差が1Kのとき、1 m²の部材を1秒間で伝わるとされる熱流量。 - **小さいほど**断熱性能が高く、大きいほど熱が逃げやすい(保温性低い)ことを示します。 例: - 高断熱の窓:uValue ≈ 0.5–1.0 W/(m²·K) - 一般的な単板ガラス窓:uValue ≈ 5–6 W/(m²·K) 建築基準法(日本)やエネルギー消費基準などで、各建蔽部位の uValue の上限が定められており、省エネ設計の重要なパラメータとして使われます。 工具的"uValue 换算"
ON(Fraction Mode 为 ON) 相对于曲线长度的比例(0〜1) 按比例(0〜1)使用
OFF 曲线参数值(范围因曲线而异,例如 0〜10) 通过对照表精确换算(推荐)

在任一状态下,工具都可以在不更改 uValue 的关键帧(动画)的情况下进行计算。
①按下"测量"后,会根据当前状态自动切换"uValue 的换算"。

如果已在 uValue 上打过关键帧,之后请不要切换 Parametric Length 的勾选。关键帧数值的含义会改变,车辆的运动也会随之改变。不要切换,请将工具的"uValue 的换算"调整为与当前状态一致。

Car Wheel Rig Tool 的使用方法

通过"测量 → 确认 → 创建"三个阶段制作轮胎旋转表达式。测量和确认阶段不更改场景,只有最后的"创建"阶段会改变场景。
 

启动

该工具会在场景中创建表达式等节点。使用前,将场景另存为会比较安全。

将工具脚本(wheel_rig_tool.py)的内容粘贴到 Script Editor 的 Python 标签页中并执行,窗口就会打开。

wheel_rig_tool.py

wheel_rig_tool.py

# -*- coding: utf-8 -*-
"""
wheel_rig_tool.py
------------------------------------------------------
タイヤ回転用エクスプレッションの 測定 → 確認 → 作成 を行う GUI ツール (Maya 2026)

使い方
  1. このファイルの内容を Script Editor (Python タブ) に貼り付けて実行 → ウィンドウが開く
  2. 「① 測定」を押す
        タイヤの半径・直径、カーブの長さが入力欄に入り、エクスプレッションが生成される
  3. 数値やエクスプレッションの内容を確認し、必要なら直接編集する
        (数値だけ変えた場合は「② 式を再生成」を押す)
  4. 「③ Expression を作成/更新」を押すと、シーンにエクスプレッションが作成される
        (何度押しても、既存の CarWheel_rollExp を置き換えるだけ)

  「コピー」を押すと、エクスプレッションをクリップボードにコピーできる。
  (Expression Editor に自分で貼り付けたい場合に使う)

① と ② はシーンを変更しません。シーンが変わるのは ③ だけです(Undo 可能)。
------------------------------------------------------
"""

import math
import re
import maya.cmds as cmds

PI = 3.14159265
WIN_NAME = 'carWheelRigToolWin'
EXP_NAME = 'CarWheel_rollExp'
LOOKUP_NAME = 'CarWheel_distCurve'   # uValue → 進んだ距離 の対応表

# 今回のリグ構成に合わせた初期値(画面上で書き換え可能)
DEFAULT_CAR = 'Car_GP'
DEFAULT_WHEELS = [
    ('FrontWheel_L', 'FrontWheel_L_r'),
    ('FrontWheel_R', 'FrontWheel_R_r'),
    ('BackWheel',    'BackWheel_r'),
]


# ======================================================
# 測定・検出まわり(読み取りのみ)
# ======================================================
def measure_wheel_radius(mesh, axis='x'):
    """
    車軸中心から最も遠い頂点までの距離を半径として返す。
    溝があっても、路面に接する外周(トレッド外側)が半径になる。
    """
    idx = {'x': 0, 'y': 1, 'z': 2}[axis.lower()]
    others = [i for i in range(3) if i != idx]

    pts = cmds.xform(mesh + '.vtx[*]', query=True, worldSpace=True, translation=True)
    verts = [pts[i:i + 3] for i in range(0, len(pts), 3)]

    center = {}
    for i in others:
        lo = min(v[i] for v in verts)
        hi = max(v[i] for v in verts)
        center[i] = (lo + hi) / 2.0

    return max(
        math.hypot(v[others[0]] - center[others[0]], v[others[1]] - center[others[1]])
        for v in verts
    )


def _curve_shape(node):
    if cmds.nodeType(node) == 'nurbsCurve':
        return node
    shapes = cmds.listRelatives(node, shapes=True, type='nurbsCurve', fullPath=True)
    if not shapes:
        raise RuntimeError('カーブのシェイプが見つかりません: {}'.format(node))
    return shapes[0]


def _find_motion_path(car_group):
    if car_group and cmds.objExists(car_group):
        linked = cmds.listConnections(car_group, source=True, destination=False,
                                      type='motionPath') or []
        if linked:
            return linked[0]
    all_mp = cmds.ls(type='motionPath') or []
    return all_mp[0] if len(all_mp) == 1 else None


def _find_curve(motion_path):
    src = cmds.listConnections(motion_path + '.geometryPath',
                               source=True, destination=False) or []
    return src[0] if src else None


def _curve_length(curve):
    """(長さ, 警告文 or None) を返す"""
    shape = _curve_shape(curve)
    length = cmds.arclen(shape, constructionHistory=False)
    warn = None
    parent = cmds.listRelatives(shape, parent=True)
    if parent:
        sc = [cmds.getAttr('{}.scale{}'.format(parent[0], a)) for a in 'XYZ']
        if any(abs(s - 1.0) > 1e-4 for s in sc):
            warn = ('カーブ {} にスケール {} がかかっています。curveInfo の値(ワールド空間)と'
                    'ズレる場合は Freeze Transformations してください。'.format(parent[0], sc))
    return length, warn


def _curve_info_name(curve_shape):
    """接続済みの curveInfo があればその名前、なければ作成予定の名前"""
    existing = cmds.listConnections(curve_shape + '.worldSpace[0]',
                                    destination=True, source=False,
                                    type='curveInfo') or []
    if existing:
        return existing[0]
    return curve_shape.split('|')[-1] + '_info'


def _get_or_create_curve_info(curve_shape):
    existing = cmds.listConnections(curve_shape + '.worldSpace[0]',
                                    destination=True, source=False,
                                    type='curveInfo') or []
    if existing:
        return existing[0]
    info = cmds.createNode('curveInfo', name=curve_shape.split('|')[-1] + '_info')
    cmds.connectAttr(curve_shape + '.worldSpace[0]', info + '.inputCurve', force=True)
    return info


def _safe(name):
    return re.sub(r'\W', '_', name)


# シーンの単位 → 1cm が何単位にあたるか(内部の単位は cm)
_UNITS_PER_CM = {'mm': 10.0, 'cm': 1.0, 'm': 0.01,
                 'in': 1.0 / 2.54, 'ft': 1.0 / 30.48, 'yd': 1.0 / 91.44}


def _ui_units_per_cm():
    """
    シーンの単位(Working Units の Linear)で、内部の 1(= 1cm)が何になるかを返す。
    uValue は「距離」型の属性なので、エクスプレッションで読むと、この倍率が掛かる
    (mm のシーンでは、割合の 1 が 10 として読まれる)。cm のシーンでは 1.0。
    """
    unit = cmds.currentUnit(query=True, linear=True)
    return _UNITS_PER_CM.get(unit, 1.0)


def _param_range(curve):
    """カーブのパラメーター範囲(Parametric Length が OFF のときの uValue の範囲)"""
    shape = _curve_shape(curve)
    return cmds.getAttr(shape + '.minValue'), cmds.getAttr(shape + '.maxValue')


def _apply_expression(text):
    """
    エクスプレッションを作る。すでにあれば、ノードを削除せずに、中身だけを書き換える。
    (以前の版では、削除した瞬間に、uValue のキーが消える現象が起きたため)
    戻り値: 'created'(新規作成) または 'updated'(書き換え)
    """
    if cmds.objExists(EXP_NAME):
        cmds.expression(EXP_NAME, edit=True, string=text, alwaysEvaluate=True,
                        unitConversion='all')
        return 'updated'
    cmds.expression(string=text, name=EXP_NAME, alwaysEvaluate=True, unitConversion='all')
    return 'created'


def _remove_lookup(name):
    """
    対応表を削除する。uValue からの接続を先に外してから、ノードを削除する。
    (uValue の側のデータに影響が出ないようにするため)
    """
    for src in cmds.listConnections(name + '.input', source=True, destination=False,
                                    plugs=True) or []:
        node = src.split('.')[0]
        if cmds.nodeType(node) == 'unitConversion':
            cmds.delete(node)      # 自動で挿入された変換ノードごと、接続を外す
        else:
            cmds.disconnectAttr(src, name + '.input')
    cmds.delete(name)


def build_distance_lookup(motion_path, curve, name=LOOKUP_NAME, samples=200, trace=None):
    """
    「uValue → 進んだ距離」の対応表(animCurveUU)を作り、uValue につなぐ。

    Parametric Length が OFF のとき、uValue はカーブのパラメーター値(例:0〜10)で、
    長さとは比例しない。カーブの形から、各パラメーター値までの長さを求めて表にする。
    uValue のキー(アニメーション)は一切変更しない。
    trace : 各段階のあとに呼ばれる関数 trace('段階の名前')(キーの変化の追跡用)
    """
    import maya.api.OpenMaya as om2
    mark = trace or (lambda label: None)

    shape = _curve_shape(curve)
    sel = om2.MSelectionList()
    sel.add(shape)
    fn = om2.MFnNurbsCurve(sel.getDagPath(0))
    pmin, pmax = _param_range(curve)
    scale = _ui_units_per_cm()   # エクスプレッションが読む uValue は、シーンの単位に変換される

    params = [pmin + (pmax - pmin) * i / float(samples - 1) for i in range(samples)]
    positions = [p * scale for p in params]
    values = [fn.findLengthFromParam(p) for p in params]

    # すでに同じ形の対応表があれば、削除せずに使い回す。
    # (uValue につながった対応表を削除すると、uValue のキーが消える現象が起きたため)
    if cmds.objExists(name):
        old_pos = cmds.keyframe(name, query=True, floatChange=True) or []
        old_val = cmds.keyframe(name, query=True, valueChange=True) or []
        if len(old_pos) == samples and \
                all(abs(a - b) < 1e-4 for a, b in zip(old_pos, positions)):
            changed = [i for i in range(samples)
                       if abs(old_val[i] - values[i]) > 1e-3 * max(1.0, abs(values[i]))]
            for i in changed:
                cmds.setKeyframe(name, float=positions[i], value=values[i],
                                 inTangentType='linear', outTangentType='linear')
            mark('対応表の値の更新({} 個)'.format(len(changed)))
            if not cmds.listConnections(name + '.input', source=True, destination=False):
                cmds.connectAttr(motion_path + '.uValue', name + '.input', force=True)
                mark('uValue から対応表への接続')
            return name
        # 形が違う(カーブのパラメーター範囲が変わった等)ときだけ、作り直す
        _remove_lookup(name)
        mark('古い対応表の削除')

    anim = cmds.createNode('animCurveUU', name=name)
    mark('対応表ノードの作成')
    for pos, val in zip(positions, values):
        cmds.setKeyframe(anim, float=pos, value=val,
                         inTangentType='linear', outTangentType='linear')
    mark('対応表のキーの設定')
    cmds.connectAttr(motion_path + '.uValue', anim + '.input', force=True)
    mark('uValue から対応表への接続')
    return anim


def _uvalue_keys(motion_path):
    """uValue のキーの (時間, 値) の一覧を返す。キーのカーブがなければ (None, [])"""
    anims = cmds.listConnections(motion_path + '.uValue', source=True, destination=False,
                                 type='animCurve') or []
    if not anims:
        return None, []
    times = cmds.keyframe(anims[0], query=True, timeChange=True) or []
    values = cmds.keyframe(anims[0], query=True, valueChange=True) or []
    return anims[0], list(zip(times, values))


def _format_keys(keys):
    """キーの一覧を、ログ用の短い文字列にする"""
    if not keys:
        return 'なし'
    items = ['{:g}F={:g}'.format(t, v) for t, v in keys[:6]]
    more = ' …(計 {} 個)'.format(len(keys)) if len(keys) > 6 else ''
    return '{} 個 [{}]{}'.format(len(keys), ', '.join(items), more)


def _foreign_sources(plug, own_exp):
    """
    plug につながっている接続元のうち、自作のエクスプレッション(own_exp)以外を返す。

    エクスプレッションは、単位変換ノード(unitConversion)を間に挟んでつながる。
    変換ノードの先が自作のエクスプレッションなら、自作の接続とみなす。
    """
    foreign = []
    for n in cmds.listConnections(plug, source=True, destination=False) or []:
        if n == own_exp:
            continue
        if cmds.nodeType(n) == 'unitConversion':
            ups = cmds.listConnections(n + '.input', source=True, destination=False) or []
            if not ups or all(u == own_exp for u in ups):
                continue
            foreign.extend(u for u in ups if u != own_exp)
            continue
        foreign.append(n)
    return foreign


def _delete_orphan_conversions(plug):
    """元のエクスプレッションを消したあとに残った、入力のない単位変換ノードを削除する"""
    for n in cmds.listConnections(plug, source=True, destination=False) or []:
        if cmds.nodeType(n) == 'unitConversion' and \
                not cmds.listConnections(n + '.input', source=True, destination=False):
            cmds.delete(n)


# ======================================================
# エクスプレッション文の生成(純粋な文字列処理)
# ======================================================
def build_expression(wheels, mode, car, motion_path, dist_attr,
                     curve_len, curve_info, use_dir, comments, invert_all=False,
                     uvalue_scale=1.0, u_range=None, lookup=None):
    """
    wheels     : [{'mesh','roll','axis','radius','invert'(任意)}, ...]
                 'invert' が True のタイヤは、回転方向を反転する
    invert_all : True なら全タイヤの回転方向を反転する($dir にマイナスを付ける)
    uvalue_scale : シーンの単位による uValue の倍率(mm のシーンなら 10)。1.0 以外なら、
                 uValue をこの値で割って、元の割合(0〜1)に戻す
    u_range    : (最小, 最大)。指定すると、uValue をこの範囲で 0〜1 に換算して使う(簡易)
    lookup     : 対応表(animCurveUU)のノード名。指定すると、uValue から距離を、表で求める
    mode       : 'motion' or 'transform'
    curve_info : curveInfo ノード名(指定時は arcLength を参照)。None なら curve_len の数値を埋め込む
    """
    def c(text):
        return ('   // ' + text) if comments else ''

    lines = []
    if comments:
        lines.append('// ===== 入力値(ここを編集) =====')
    if mode == 'motion' and not lookup:
        if curve_info:
            lines.append('float $curveLen = {}.arcLength;'.format(curve_info)
                         + c('カーブの長さ(curveInfo から自動取得)'))
        else:
            lines.append('float $curveLen = {:.4f};'.format(curve_len) + c('カーブの長さ'))
        if u_range:
            lines.append('float $uMin = {:.4f};'.format(u_range[0]) + c('uValue の最小(パスの始点)'))
            lines.append('float $uMax = {:.4f};'.format(u_range[1]) + c('uValue の最大(パスの終点)'))
    for w in wheels:
        lines.append('float $r_{s} = {r:.4f};'.format(s=_safe(w['mesh']), r=w['radius'])
                     + c('{} の半径'.format(w['mesh'])))
    lines.append('')
    if comments:
        lines.append('// ===== 計算 =====')

    if mode == 'motion':
        if lookup:
            lines.append('float $dist = {}.output;'.format(lookup)
                         + c('進んだ距離 = uValue から距離への対応表(カーブの形から作成)'))
        elif u_range:
            lines.append('float $dist = ({mp}.uValue - $uMin) / ($uMax - $uMin) * $curveLen;'.format(
                mp=motion_path)
                + c('進んだ距離 = (uValue を 0〜1 に換算) × カーブの長さ'))
        elif abs(uvalue_scale - 1.0) > 1e-9:
            lines.append('float $dist = {mp}.uValue / {s:g} * $curveLen;'.format(
                mp=motion_path, s=uvalue_scale)
                + c('進んだ距離 = 進行割合 × カーブの長さ(uValue は距離型で、シーンの単位に変換されて'
                    '読まれるため、{s:g} で割って割合に戻す)'.format(s=uvalue_scale)))
        else:
            lines.append('float $dist = {}.uValue * $curveLen;'.format(motion_path)
                         + c('進んだ距離 = 進行割合 × カーブの長さ'))
    else:
        lines.append('float $dist = {};'.format(dist_attr) + c('進んだ距離 = 親グループの移動量'))

    dir_src = '{}.wheelDir'.format(car) if use_dir else '1'
    if invert_all:
        dir_src = '-' + dir_src
    dir_note = '前進=1 / 向き切り替え後=-1' + ('  ※全タイヤの回転方向を反転中' if invert_all else '')
    lines.append('float $dir = {};'.format(dir_src) + c(dir_note))

    for w in wheels:
        inv = bool(w.get('invert'))
        lines.append('{roll}.rotate{ax} = {sg}$dir * 360 * ($dist / (2 * {pi} * $r_{s}));'.format(
            roll=w['roll'], ax=w['axis'].upper(), sg='-' if inv else '',
            pi=PI, s=_safe(w['mesh']))
            + c('距離 ÷ 円周 × 360' + ('  ※このタイヤは回転方向を反転' if inv else '')))
    return '\n'.join(lines)


# ======================================================
# GUI
# ======================================================
class WheelRigTool(object):

    def __init__(self):
        self.f = {}       # 主要な UI 部品
        self.rows = []    # タイヤ行
        self.wheel_col = None

    # ---------- ウィンドウ構築 ----------
    def show(self):
        if cmds.window(WIN_NAME, exists=True):
            cmds.deleteUI(WIN_NAME)
        cmds.window(WIN_NAME, title='Car Wheel Rig Tool', widthHeight=(680, 860))
        cmds.scrollLayout(childResizable=True)
        cmds.columnLayout(adjustableColumn=True, rowSpacing=6)

        self._build_basic()
        self._build_wheels()
        self._build_actions()
        self._build_result()

        cmds.showWindow(WIN_NAME)
        self._on_mode_changed()
        self._log('①「測定」を押すと、半径・カーブ長・エクスプレッションが入ります。')

    def _text_row(self, label, default='', pick=True, extra=None):
        cmds.rowLayout(numberOfColumns=3, adjustableColumn=2,
                       columnWidth=[(1, 112), (3, 74)])
        cmds.text(label=label, align='right', width=106)
        tf = cmds.textField(text=default)
        if extra:
            cmds.button(label=extra[0], width=70, command=extra[1])
        elif pick:
            cmds.button(label='◀ 選択', width=70, command=lambda *_: self._pick(tf))
        else:
            cmds.text(label='')
        cmds.setParent('..')
        return tf

    def _build_basic(self):
        cmds.frameLayout(label='1. 基本設定', collapsable=True, marginWidth=8, marginHeight=6)
        cmds.columnLayout(adjustableColumn=True, rowSpacing=4)

        self.f['car'] = self._text_row('車のグループ', DEFAULT_CAR)

        cmds.rowLayout(numberOfColumns=2, columnWidth=[(1, 112)])
        cmds.text(label='車軸(転がり軸)', align='right', width=106)
        self.f['axis'] = cmds.optionMenu()
        for a in ('x', 'y', 'z'):
            cmds.menuItem(label=a)
        cmds.setParent('..')

        self.f['mode'] = cmds.radioButtonGrp(
            numberOfRadioButtons=2,
            labelArray2=['Motion Path  (uValue × カーブの長さ)', 'Transform  (移動量をそのまま距離に)'],
            select=1, columnWidth2=(300, 300),
            onCommand1=self._on_mode_changed, onCommand2=self._on_mode_changed)

        # Motion Path 用
        self.f['motion_box'] = cmds.columnLayout(adjustableColumn=True, rowSpacing=4)
        self.f['mp'] = self._text_row('motionPath', '', pick=False,
                                      extra=('自動検出', self._on_auto_detect))
        self.f['curve'] = self._text_row('カーブ', '')
        self.f['umode'] = cmds.radioButtonGrp(
            numberOfRadioButtons=3,
            vertical=True,
            label='uValue の換算',
            labelArray3=['割合(0〜1)のまま使う  ※Parametric Length が ON のとき',
                         '範囲で割って 0〜1 にする(簡易)  ※Parametric Length が OFF のとき',
                         '対応表で正確に換算する  ※Parametric Length が OFF のとき(推奨)'],
            select=1, columnWidth=[(1, 100), (2, 480)])
        self.f['live'] = cmds.checkBox(
            label='カーブの長さを curveInfo で自動追従させる(curveInfo ノードを作成します)',
            value=False)
        cmds.setParent('..')

        # Transform 用
        self.f['tr_box'] = cmds.columnLayout(adjustableColumn=True, rowSpacing=4)
        self.f['dist_attr'] = self._text_row('距離に使う属性', DEFAULT_CAR + '.translateZ', pick=False)
        cmds.setParent('..')

        self.f['add_dir'] = cmds.checkBox(
            label='wheelDir 属性(前進=1 / 切り替え後=-1)を車のグループに追加する', value=True)
        self.f['invert_all'] = cmds.checkBox(
            label='回転方向を反転する(全タイヤ)  ※前進でタイヤが逆に回る場合にチェック', value=False)
        self.f['comments'] = cmds.checkBox(
            label='エクスプレッションにコメントを付ける', value=True)

        cmds.setParent('..')
        cmds.setParent('..')

    def _build_wheels(self):
        cmds.frameLayout(label='2. タイヤ', collapsable=True, marginWidth=8, marginHeight=6)
        cmds.columnLayout(adjustableColumn=True, rowSpacing=2)

        widths = [(1, 30), (2, 130), (3, 40), (4, 130), (5, 40), (6, 80), (7, 90), (8, 50)]
        cmds.rowLayout(numberOfColumns=8, columnWidth=widths)
        for t in ('使用', 'メッシュ(測定対象)', '', '回転ロケーター(_r)', '', '半径', '直径', '反転'):
            cmds.text(label=t, align='left')
        cmds.setParent('..')

        self.wheel_col = cmds.columnLayout(adjustableColumn=True, rowSpacing=2)
        for mesh, roll in DEFAULT_WHEELS:
            self._add_row(mesh, roll)
        cmds.setParent('..')

        cmds.button(label='+ タイヤ行を追加', command=lambda *_: self._add_row())
        cmds.setParent('..')
        cmds.setParent('..')

    def _add_row(self, mesh_name='', roll_name=''):
        widths = [(1, 30), (2, 130), (3, 40), (4, 130), (5, 40), (6, 80), (7, 90), (8, 50)]
        cmds.rowLayout(numberOfColumns=8, columnWidth=widths, parent=self.wheel_col)
        use = cmds.checkBox(label='', value=True)
        mesh = cmds.textField(text=mesh_name)
        cmds.button(label='◀', command=lambda *_: self._pick(mesh))
        roll = cmds.textField(text=roll_name)
        cmds.button(label='◀', command=lambda *_: self._pick(roll))
        rad = cmds.floatField(value=0.0, precision=4,
                              changeCommand=lambda *_: self._refresh_dia(rad, dia))
        dia = cmds.text(label='-', align='left')
        inv = cmds.checkBox(label='', value=False)
        cmds.setParent('..')
        self.rows.append({'use': use, 'mesh': mesh, 'roll': roll, 'rad': rad, 'dia': dia,
                          'inv': inv})

    def _build_actions(self):
        cmds.frameLayout(label='3. 操作', collapsable=False, marginWidth=8, marginHeight=6)
        cmds.columnLayout(adjustableColumn=True, rowSpacing=6)
        cmds.rowColumnLayout(numberOfColumns=2, columnWidth=[(1, 322), (2, 322)],
                             columnSpacing=[(2, 6)], rowSpacing=[(2, 6)])
        cmds.button(label='① 測定', height=32, backgroundColor=(0.35, 0.5, 0.65),
                    command=self.on_measure)
        cmds.button(label='② 式を再生成', height=32, command=self.on_generate)
        cmds.button(label='③ Expression を作成/更新', height=32,
                    backgroundColor=(0.4, 0.6, 0.4), command=self.on_create)
        cmds.button(label='コピー', height=32, command=self.on_copy)
        cmds.setParent('..')
        cmds.setParent('..')
        cmds.setParent('..')

    def _build_result(self):
        cmds.frameLayout(label='4. 結果', collapsable=False, marginWidth=8, marginHeight=6)
        cmds.columnLayout(adjustableColumn=True, rowSpacing=4)

        cmds.rowLayout(numberOfColumns=3, columnWidth=[(1, 112), (2, 130)])
        cmds.text(label='カーブの長さ', align='right', width=106)
        self.f['curveLen'] = cmds.floatField(value=0.0, precision=4, width=120)
        cmds.text(label='  (直接編集できます。編集後は ② を押す)', align='left')
        cmds.setParent('..')

        cmds.text(label='ログ', align='left')
        self.f['log'] = cmds.scrollField(editable=False, wordWrap=True, height=150)

        cmds.text(label='エクスプレッション(直接編集できます。③ でこの内容が作成されます)', align='left')
        self.f['expr'] = cmds.scrollField(editable=True, wordWrap=False, height=260,
                                          font='fixedWidthFont')
        cmds.setParent('..')
        cmds.setParent('..')

    # ---------- 小さな補助 ----------
    def _pick(self, field):
        sel = cmds.ls(selection=True)
        if sel:
            cmds.textField(field, edit=True, text=sel[0])
        else:
            cmds.warning('オブジェクトが選択されていません。')

    def _refresh_dia(self, rad_field, dia_label):
        r = cmds.floatField(rad_field, query=True, value=True)
        cmds.text(dia_label, edit=True, label='{:.4f}'.format(r * 2.0) if r > 0 else '-')

    def _on_mode_changed(self, *_):
        motion = cmds.radioButtonGrp(self.f['mode'], query=True, select=True) == 1
        cmds.columnLayout(self.f['motion_box'], edit=True, enable=motion)
        cmds.columnLayout(self.f['tr_box'], edit=True, enable=not motion)

    def _on_auto_detect(self, *_):
        car = cmds.textField(self.f['car'], query=True, text=True).strip()
        mp = _find_motion_path(car)
        if not mp:
            self._log('motionPath を自動検出できませんでした。手動で入力してください。')
            return
        cmds.textField(self.f['mp'], edit=True, text=mp)
        curve = _find_curve(mp)
        if curve:
            cmds.textField(self.f['curve'], edit=True, text=curve)
        self._log('検出: motionPath={} / curve={}'.format(mp, curve))

    def _log(self, msg, clear=False):
        cur = '' if clear else cmds.scrollField(self.f['log'], query=True, text=True)
        text = (cur + '\n' if cur else '') + msg
        cmds.scrollField(self.f['log'], edit=True, text=text)

    def _settings(self):
        f = self.f
        return {
            'car': cmds.textField(f['car'], query=True, text=True).strip(),
            'axis': cmds.optionMenu(f['axis'], query=True, value=True),
            'mode': 'motion' if cmds.radioButtonGrp(f['mode'], query=True, select=True) == 1
                    else 'transform',
            'mp': cmds.textField(f['mp'], query=True, text=True).strip(),
            'curve': cmds.textField(f['curve'], query=True, text=True).strip(),
            'live': cmds.checkBox(f['live'], query=True, value=True),
            'umode': cmds.radioButtonGrp(f['umode'], query=True, select=True),
            'dist_attr': cmds.textField(f['dist_attr'], query=True, text=True).strip(),
            'add_dir': cmds.checkBox(f['add_dir'], query=True, value=True),
            'comments': cmds.checkBox(f['comments'], query=True, value=True),
            'invert_all': cmds.checkBox(f['invert_all'], query=True, value=True),
            'curve_len': cmds.floatField(f['curveLen'], query=True, value=True),
        }

    def _rows_data(self):
        out = []
        for r in self.rows:
            if not cmds.checkBox(r['use'], query=True, value=True):
                continue
            out.append({
                'mesh': cmds.textField(r['mesh'], query=True, text=True).strip(),
                'roll': cmds.textField(r['roll'], query=True, text=True).strip(),
                'radius': cmds.floatField(r['rad'], query=True, value=True),
                'invert': cmds.checkBox(r['inv'], query=True, value=True),
                'rad_field': r['rad'],
                'dia_label': r['dia'],
            })
        return out

    # ---------- ① 測定 ----------
    def on_measure(self, *_):
        self._log('', clear=True)
        s = self._settings()
        rows = self._rows_data()
        if not rows:
            self._log('使用するタイヤがありません。')
            return

        # 車のグループ
        if not s['car'] or not cmds.objExists(s['car']):
            self._log('[警告] 車のグループが見つかりません: {}'.format(s['car']))
        elif len(cmds.ls(s['car'])) > 1:
            self._log('[警告] "{}" と同名のオブジェクトが複数あります。'
                      'エクスプレッションがエラーになるので名前を変更してください。'.format(s['car']))

        # タイヤの測定
        measured = []
        self._log('【タイヤの測定結果】(車軸中心から最も遠い頂点までを半径とする)')
        for r in rows:
            if not r['mesh'] or not cmds.objExists(r['mesh']):
                self._log('  [エラー] メッシュが見つかりません: "{}"'.format(r['mesh']))
                continue
            rot = [cmds.getAttr('{}.rotate{}'.format(r['mesh'], a)) for a in 'XYZ']
            if any(abs(v) > 1e-4 for v in rot):
                self._log('  [警告] {} の Rotate が 0 ではありません {}。測定がずれる可能性があります。'
                          .format(r['mesh'], rot))
            radius = measure_wheel_radius(r['mesh'], s['axis'])
            cmds.floatField(r['rad_field'], edit=True, value=radius)
            self._refresh_dia(r['rad_field'], r['dia_label'])
            measured.append((r['mesh'], radius))
            self._log('  {}: 直径 {:.4f} / 半径 {:.4f} / 円周 {:.4f}'.format(
                r['mesh'], radius * 2.0, radius, 2.0 * PI * radius))

        # Motion Path / カーブ
        mp, curve = s['mp'], s['curve']
        if s['mode'] == 'motion':
            if not mp:
                mp = _find_motion_path(s['car'])
                if mp:
                    cmds.textField(self.f['mp'], edit=True, text=mp)
            if mp and not curve:
                curve = _find_curve(mp)
                if curve:
                    cmds.textField(self.f['curve'], edit=True, text=curve)

        self._log('')
        self._log('【カーブの長さ】')
        curve_len = None
        if curve and cmds.objExists(curve):
            curve_len, warn = _curve_length(curve)
            cmds.floatField(self.f['curveLen'], edit=True, value=curve_len)
            self._log('  {} : {:.4f}'.format(curve, curve_len))
            if warn:
                self._log('  [警告] ' + warn)
            for mesh, radius in measured:
                self._log('  → {} は、カーブ全長を走ると約 {:.2f} 回転'.format(
                    mesh, curve_len / (2.0 * PI * radius)))
        else:
            self._log('  カーブを取得できませんでした。motionPath / カーブ欄を確認してください。')

        if s['mode'] == 'motion' and mp and cmds.objExists(mp):
            for line in self._fraction_report(mp):
                self._log(line)
            fraction_on = cmds.getAttr(mp + '.fractionMode')
            if not fraction_on and s['umode'] == 1:
                cmds.radioButtonGrp(self.f['umode'], edit=True, select=3)
                self._log('  → Parametric Length が OFF のため、「uValue の換算」を'
                          '「対応表で正確に換算する」に切り替えました。')
            elif fraction_on and s['umode'] != 1:
                cmds.radioButtonGrp(self.f['umode'], edit=True, select=1)
                self._log('  → Parametric Length が ON のため、「uValue の換算」を'
                          '「割合(0〜1)のまま使う」に切り替えました。')

        self._log('')
        self.on_generate()

    def _fraction_report(self, mp):
        """
        Parametric Length(Fraction Mode)の状態と、uValue のキーの範囲を調べ、表示用の行を返す。
        OFF のままだと uValue がカーブの長さの割合にならず、タイヤが何倍もの速さで回る。
        """
        lines = []
        on = cmds.getAttr(mp + '.fractionMode')
        lines.append('  motionPath: {} / Parametric Length(Fraction Mode): {}'.format(
            mp, 'ON' if on else 'OFF'))

        # uValue は距離型のため、画面やキーの値は、シーンの単位に変換されている
        scale = _ui_units_per_cm()
        unit = cmds.currentUnit(query=True, linear=True)
        if abs(scale - 1.0) > 1e-9:
            lines.append('  [注意] シーンの単位が {} です(cm ではありません)。'
                         'uValue は画面上で {:g} 倍の値で表示されます。'.format(unit, scale))
            lines.append('         式では {:g} で割って、元の割合(0〜1)に戻しています。'.format(scale))

        _, key_list = _uvalue_keys(mp)
        lines.append('  uValue のキー: {}'.format(_format_keys(key_list)))
        keys = [k / scale for k in
                (cmds.keyframe(mp + '.uValue', query=True, valueChange=True) or [])]
        if keys:
            lines.append('  uValue のキーの範囲(割合に直した値): {:.3f} 〜 {:.3f}'.format(
                min(keys), max(keys)))

        if not on:
            lines.append('  [注意] Parametric Length が OFF です(uValue はカーブのパラメーター値)。')
            lines.append('         「uValue の換算」を「対応表」にすると、キー(アニメーション)を'
                         '変えずに、正確に計算できます。')
        elif keys and max(keys) > 1.0 + 1e-6:
            lines.append('  [警告] uValue のキーが 1 を超えています。Fraction Mode では、'
                         '0〜1 がカーブの全長です。')
            lines.append('         車は 1 でパスの終点に着いて止まり、タイヤだけが回り続けます。')
            lines.append('         キーがパラメーター値で打たれている場合は、Parametric Length を OFF に戻し、'
                         '「uValue の換算」を「対応表」にしてください。')
        return lines

    # ---------- ② 式を生成 ----------
    def _collect_for_generate(self):
        """入力を検証して (設定, タイヤ, エラー一覧) を返す"""
        s = self._settings()
        rows = self._rows_data()
        errors = []

        if not rows:
            errors.append('使用するタイヤがありません。')
        for r in rows:
            if not r['roll'] or not cmds.objExists(r['roll']):
                errors.append('回転ロケーターが見つかりません: "{}"'.format(r['roll']))
            if r['radius'] <= 0:
                errors.append('{} の半径が 0 です。① 測定を実行するか、直接入力してください。'
                              .format(r['mesh']))

        if s['mode'] == 'motion':
            if not s['mp'] or not cmds.objExists(s['mp']):
                errors.append('motionPath が見つかりません: "{}"'.format(s['mp']))
            elif s['umode'] in (2, 3) and cmds.getAttr(s['mp'] + '.fractionMode'):
                errors.append('「uValue の換算」の2・3は、Parametric Length が OFF のとき用です。'
                              'ON のときは「割合(0〜1)のまま使う」を選んでください。')
            if s['umode'] in (2, 3) or s['live']:
                if not s['curve'] or not cmds.objExists(s['curve']):
                    errors.append('カーブが見つかりません: "{}"'.format(s['curve']))
            if s['umode'] != 3 and not s['live'] and s['curve_len'] <= 0:
                errors.append('カーブの長さが 0 です。① 測定を実行するか、直接入力してください。')
        else:
            if not s['dist_attr'] or not cmds.objExists(s['dist_attr']):
                errors.append('距離に使う属性が見つかりません: "{}"'.format(s['dist_attr']))
            else:
                owner = s['dist_attr'].split('.')[0]
                history = cmds.listHistory(owner, pruneDagObjects=True) or []
                if any(cmds.nodeType(n) == 'motionPath' for n in history):
                    errors.append('{} は Motion Path で動かされているため、Transform 用は使えません。'
                                  '「距離の求め方」を Motion Path にしてください。'.format(owner))

        wheels = [{'mesh': r['mesh'], 'roll': r['roll'], 'axis': s['axis'],
                   'radius': r['radius'], 'invert': r['invert']} for r in rows]
        return s, wheels, errors

    def on_generate(self, *_):
        s, wheels, errors = self._collect_for_generate()
        if errors:
            for e in errors:
                self._log('[エラー] ' + e)
            return False

        curve_info = None
        if s['mode'] == 'motion' and s['live'] and s['umode'] != 3:
            curve_info = _curve_info_name(_curve_shape(s['curve']))

        u_range, lookup = None, None
        if s['mode'] == 'motion' and s['umode'] == 2:
            pmin, pmax = _param_range(s['curve'])
            k = _ui_units_per_cm()   # エクスプレッションが読む uValue は、シーンの単位に変換される
            u_range = (pmin * k, pmax * k)
        elif s['mode'] == 'motion' and s['umode'] == 3:
            lookup = LOOKUP_NAME

        use_dir = s['add_dir'] or (
            cmds.objExists(s['car']) and cmds.attributeQuery('wheelDir', node=s['car'], exists=True))

        text = build_expression(
            wheels, s['mode'], s['car'], s['mp'], s['dist_attr'],
            s['curve_len'], curve_info, use_dir, s['comments'], s['invert_all'],
            uvalue_scale=_ui_units_per_cm(), u_range=u_range, lookup=lookup)
        cmds.scrollField(self.f['expr'], edit=True, text=text)
        self._log('エクスプレッションを生成しました。内容を確認してください。')
        return True

    # ---------- ③ 作成/更新 ----------
    def on_create(self, *_):
        text = cmds.scrollField(self.f['expr'], query=True, text=True).strip()
        if not text:
            self._log('[エラー] エクスプレッションが空です。先に ① 測定 か ② 式を再生成 を押してください。')
            return

        s, wheels, errors = self._collect_for_generate()
        if errors:
            for e in errors:
                self._log('[エラー] ' + e)
            return

        # uValue のキーの見張り(作成の前後で、キーが変わっていないか確認する)
        keys_before = None
        if s['mp'] and cmds.objExists(s['mp']):
            _, keys_before = _uvalue_keys(s['mp'])
            self._log('uValue のキー(作成前): {}'.format(_format_keys(keys_before)))

        last = [keys_before]

        def mark(label):
            """各処理のあとで uValue のキーを調べ、変わっていたらログに出す(原因の追跡用)"""
            if keys_before is None:
                return
            _, now = _uvalue_keys(s['mp'])
            if now != last[0]:
                self._log('[追跡] 「{}」のあとで、uValue のキーが変わりました: {} → {}'.format(
                    label, _format_keys(last[0]), _format_keys(now)))
                last[0] = now

        cmds.undoInfo(openChunk=True)
        try:
            # 回転属性に、既存のエクスプレッション以外の接続やキーがないか確認
            for w in wheels:
                plug = '{}.rotate{}'.format(w['roll'], w['axis'].upper())
                srcs = _foreign_sources(plug, EXP_NAME)
                if srcs:
                    raise RuntimeError('{} には既に接続やキーがあります ({})。'
                                       '先に削除してください。'.format(plug, ', '.join(srcs)))

            mark('接続の確認')

            # wheelDir 属性
            if s['add_dir'] and cmds.objExists(s['car']) and \
                    not cmds.attributeQuery('wheelDir', node=s['car'], exists=True):
                cmds.addAttr(s['car'], longName='wheelDir', attributeType='long',
                             minValue=-1, maxValue=1, defaultValue=1, keyable=True)
                self._log('{} に wheelDir 属性を追加しました。'.format(s['car']))

            # 対応表を使う設定なのに、式が対応表を参照していない場合は止める
            if s['mode'] == 'motion' and s['umode'] == 3 and LOOKUP_NAME not in text:
                raise RuntimeError('式が対応表を使っていません。先に ② 式を再生成 を押してください。')

            # curveInfo
            if s['mode'] == 'motion' and s['live'] and s['umode'] != 3:
                shape = _curve_shape(s['curve'])
                planned = _curve_info_name(shape)
                actual = _get_or_create_curve_info(shape)
                if actual != planned:
                    text = text.replace(planned, actual)
                    cmds.scrollField(self.f['expr'], edit=True, text=text)
                self._log('curveInfo: {}'.format(actual))

            # 残っている単位変換ノードの片付け(エクスプレッション自体は削除しない)
            for w in wheels:
                _delete_orphan_conversions('{}.rotate{}'.format(w['roll'], w['axis'].upper()))
            mark('残った変換ノードの削除')

            # 対応表を使わない設定でも、以前に作った対応表は削除しない
            # (削除すると uValue のキーが消える現象が起きたため。残っていても、害はありません)
            if not (s['mode'] == 'motion' and s['umode'] == 3) and cmds.objExists(LOOKUP_NAME):
                self._log('使わない対応表 {} は、そのまま残しています(式では使われません)。'.format(
                    LOOKUP_NAME))

            # uValue → 進んだ距離 の対応表(uValue のキーは変更しない)
            if s['mode'] == 'motion' and s['umode'] == 3:
                anim = build_distance_lookup(s['mp'], s['curve'], trace=mark)
                self._log('対応表: {}(uValue → 進んだ距離。uValue のキーは変更していません)'.format(anim))
            result = _apply_expression(text)
            mark('エクスプレッションの書き換え' if result == 'updated' else 'エクスプレッションの作成')
            self._log('エクスプレッション "{}" を{}しました。'.format(
                EXP_NAME, '書き換え' if result == 'updated' else '作成'))
            self._log('確認: Windows > Animation Editors > Expression Editor')
            if keys_before is not None:
                self._check_uvalue_keys(s['mp'], keys_before)
            if s['mode'] == 'motion':
                report = self._fraction_report(s['mp'])
                if any('[警告]' in line for line in report):
                    for line in report:
                        self._log(line)
        except Exception as e:
            self._log('[エラー] ' + str(e))
            cmds.warning(str(e))
        finally:
            cmds.undoInfo(closeChunk=True)

    def _check_uvalue_keys(self, mp, keys_before):
        """作成の前後で uValue のキーが変わっていないか確認し、失われたキーは元に戻す"""
        _, keys_after = _uvalue_keys(mp)
        self._log('uValue のキー(作成後): {}'.format(_format_keys(keys_after)))

        def has(keys, key):
            return any(abs(key[0] - k[0]) < 1e-6 and abs(key[1] - k[1]) < 1e-6 for k in keys)

        lost = [k for k in keys_before if not has(keys_after, k)]
        if not lost:
            return
        self._log('[警告] uValue のキーが失われました: {}'.format(
            ', '.join('{:g}F={:g}'.format(t, v) for t, v in lost)))
        try:
            for t, v in lost:
                cmds.setKeyframe(mp, attribute='uValue', time=t, value=v)
            self._log('         失われたキーを元に戻しました(タンジェントは標準の設定になります)。')
            self._log('         どの操作でキーが消えたかを、教えてください。')
        except Exception as e:
            self._log('         元に戻せませんでした: {}'.format(e))

    # ---------- コピー ----------
    def on_copy(self, *_):
        text = cmds.scrollField(self.f['expr'], query=True, text=True)
        if not text.strip():
            self._log('[エラー] コピーするエクスプレッションがありません。')
            return
        qt = None
        try:
            from PySide6 import QtWidgets as qt
        except ImportError:
            try:
                from PySide2 import QtWidgets as qt
            except ImportError:
                qt = None
        if qt:
            qt.QApplication.clipboard().setText(text)
            self._log('エクスプレッションをクリップボードにコピーしました。')
        else:
            self._log('[警告] クリップボードを使えませんでした。'
                      'エクスプレッション欄を全選択(Ctrl+A)してコピーしてください。')


def show():
    WheelRigTool().show()


# Script Editor で実行するとウィンドウが開く
if __name__ == '__main__':
    show()

屏幕项目


 

項目 設定内容
车辆组 带有 Motion Path 的组(Car_GP)
车轴(滚动轴) 轮胎滚动的轴。通常为 x
距离的求法 Motion Path(uValue × 曲线长度)或 Transform(将移动量直接作为距离使用)
motionPath / 曲线 输入「自动检测」
通过 curveInfo 自动跟随 开启后,将从 curveInfo 获取曲线长度
添加 wheelDir 属性 开启后,将向 Car_GP 添加前进/后退切换属性
反转旋转方向(所有轮胎) 前进时轮胎反向旋转的情况下,将其设为 ON。
轮胎表 网格、旋转定位器(_r)、半径、直径、每个轮胎的反转
uValue 的换算 使用 Motion Path 时,根据 Parametric Length 的状态进行选择。①在测量模式下会自动切换。

操作流程

1.①测量:轮胎的半径和直径、弯道长度将填入输入栏,并生成表达式。
2.确认:查看日志和输入栏中的数值。数值可直接编辑。日志中还会显示跑完整个弯道全长时的旋转次数,可作为数值是否合理的参考依据。
3.②重新生成公式:在修改数值或勾选状态后点击此按钮。手动编辑的公式将被重新构建并清除。
4.③创建/更新表达式:将创建表达式 CarWheel_rollExp。无论点击多少次,都只是覆盖已有表达式的内容。

轮胎半径的测量方法

半径是车轴中心到最远顶点的距离。即使轮胎有沟槽,与路面接触的胎面外侧即为半径,因此与滚动计算中所需的值相一致。即使是像后轮那样将两个轮子合并为一个网格,由于是在除车轴方向外的两个方向上进行测量,因此可以顺利测得。
 

使用 Transform 进行动画时

若不使用运动路径,而是通过 Car_GP 的位移(translateZ 等)来移动车辆,请将工具的"距离求法"设置为 Transform。在"距离所用属性"中指定前进方向的属性(Car_GP.translateZ 等),位移量即可直接作为距离。
Transform.gif

 
然而,当车辆边转弯边前进时,此方法不适用。因为移动量与实际行驶的距离将无法保持一致。请在接近直线的运动中使用此方法。

另外,如果 Car_GP 在 Motion Path 上移动,由于 translateZ 对应路径上的位置,因此无法使用 Transform 版本。在这种情况下,工具将报错并停止。在 Motion Path 场景中,请将"距离的求解方式"设为 Motion Path。
 

动画确定后

动画确定后,请执行 Bake。它会将由表达式驱动的轮胎旋转转换为关键帧。对象是 _r 定位器的旋转。
 

令人着迷之处与小贴士

这里总结一下实际操作中遇到的问题及相应的解决办法。

在运动路径中,轮胎以数倍的转速旋转:检查 uValue 的关键帧是否超过了 1(例如变成了 0〜10 这样的数值)。在 Parametric Length 为 OFF 的情况下,如果「uValue 换算」仍设置为「按比率(0〜1)使用」,距离就会被计算为几倍。当为 OFF 时,应改为「对应表」。

旋转看起来偏平滑:半径存在偏差。如果骨架上应用了缩放,请以实际尺寸而非外观重新测量半径。若多边形数量较少,请将半径降低几个百分点。
 

最后

本次,我们利用 Claude 为 Maya 制作了一个轮胎自动旋转工具。通过将期望实现的功能以文本形式描述,并在多轮对话中逐步迭代成形,工具的制作也变得轻松可行了。

公司内部也在推进 Claude 的应用,将 AI 融入日常制作中。今后,将在确认所制作工具运行正常的基础上投入实际工作,以此推动作业效率的提升和表现手法的拓展。

0

查看点赞用户列表

0

注册为新用户,更方便地使用 Qiita

  1. 你可以获得符合你需求的文章
  2. 你可以高效地回顾有用的信息
  3. 你可以使用深色主题

注册后你可以做什么

来源:Qiita · Unity 开发社区 · qiita.com