kikuta@dev:~$cat ./notes/blender-python-asset-validation.md
·14 min·kikuta

Blender生成3Dモデルを自動テストする:Bounds・Silhouette・Motion QA

Export済みGLBの再import、形状比率、親子構造、床貫通、Root Motion、turntableまでを検証する3D asset QA。

Blender Pythonでモデルを自動生成すると、次に起きる問題があります。

「生成に成功した」モデルが、普通に見た目として壊れている。

scriptが例外なく終わることと、ゲーム用assetとして成立することは別です。

Slime Mercenariesと3D将棋では、3D assetに対してコードのunit testに近いvalidationと、renderによるvisual QAを両方使っています。

GLBを再importして検証する

Slime Mercenariesのenemy validatorは、Python source objectを直接見るのではなく、一度exportしたGLBをBlenderへ再importします。

bpy.ops.import_scene.gltf(
    filepath=str(path)
)

これが重要です。

source sceneでは存在したnodeがexport optionで落ちている可能性があるからです。

Source scene is correct
!=
Exported asset is correct

最終的にruntimeへ渡すfileそのものを検査します。

Required nodeを検査する

enemyごとにrequired nodeを持ちます。

共通contractは次です。

EnemyRoot
BodyRoot
FaceRoot
Eye_L
Eye_R
AttackOrigin
EffectOrigin
GroundOrigin

validatorでは存在を確認します。

missing = [
    name
    for name in profile.required_nodes
    if bpy.data.objects.get(name)
    is None
]

if missing:
    raise AssertionError(
        "missing required nodes: "
        + ", ".join(missing)
    )

これはvisual qualityというよりruntime APIの検査です。

Boundsで異常scaleを止める

root以下のmeshからworld boundsを計算します。

corners = []

for obj in root.children_recursive:
    if obj.type != "MESH":
        continue

    corners.extend(
        obj.matrix_world
        @ Vector(corner)
        for corner in obj.bound_box
    )

そこからsizeを出し、degenerate geometryや異常scaleを止めます。

size = maximum - minimum

if min(size) <= 0.05:
    raise AssertionError(
        "degenerate bounds"
    )

if max(size) > 5.0:
    raise AssertionError(
        "unexpectedly large "
        "source bounds"
    )

scaleを100倍間違えたassetをgalleryで初めて発見するより、build直後に止める方が速いです。

Silhouetteもratioで検査できる

見た目は完全には数値化できません。

ただし、「このcharacterで絶対に守りたいshape」はratioでかなり検査できます。

例えばGreat Mushroomは、

silhouette_rule=lambda s:
    1.24 <= s.x / s.z <= 1.38
    and s.x >= 2.0

としています。

これは「ボスキノコは横に広い」というart directionを数値化したものです。

Great Mushroomの実際のgallery表示

完全なvisual scoreではありませんが、何かの修正で通常キノコの細い体型へ戻ってしまうregressionは検出できます。

部品の相対サイズを見る

character全体の大きさだけでは不十分です。

Great Mushroomでは部品のratioも見ています。

RelativeSizeRule(
    "Cap",
    "x",
    0.95,
    1.02,
)

RelativeSizeRule(
    "CapLayer_Top",
    "x",
    0.56,
    0.68,
)

こうすると、

帽子は存在する
しかし小さすぎて
character identityが消えた

というregressionを止められます。

Parent relationもvisual contractになる

animation用pivotが必要なassetでは、親子構造も検査します。

ParentRule(
    "Cap",
    "PrimaryRoot",
)

ParentRule(
    "CapLayer_Back",
    "SecondaryRoot",
)

ParentRule(
    "CapLayer_Top",
    "SecondaryRoot",
)

meshが正しい位置に見えても、parentが間違っているとanimation開始時に壊れます。

rest poseだけでは分からないため、hierarchyをcontractにします。

Forbidden nodeも持つ

Validationは「必要なもの」だけではありません。

例えばGreat Mushroomでは、

forbidden_prefixes=(
    "BodyLobe_",
    "SporePouch_",
)

を指定しています。

他variantの部品がcopy-pasteで混入するのを防ぐためです。

「余計なmeshがあっても動くからよい」とすると、後からcharacter identityとruntime motion targetが混ざります。

Mesh countは雑だが役に立つ

Great Mushroomには、

min_meshes=22
max_meshes=25

というrangeもあります。

mesh countは品質指標ではありません。

ただし22前後だったcharacterが修正後に5 meshになったなら、かなり高い確率で重要部品が落ちています。

逆に100 meshになった場合も、意図しない複製を疑えます。

安いsanity checkとしては有効です。

顔の比率も検査する

Slime Mercenariesでは、小さな画面でも顔を読める必要があります。一方、目が大きくなりすぎるとcharacter全体のsilhouetteより顔だけが支配的になります。

enemy validatorではeye objectのworld sizeとcharacter heightを比較しています。

if (
    max(
        eye_size.x,
        eye_size.z,
    )
    / size.z
    > 0.12
):
    raise AssertionError(
        "eye-size regression"
    )

さらに左右のeye位置が概ね対称か、local -Y側のfrontに置かれているかも確認します。

感覚的な修正でも、一度「ここを越えると崩れる」という範囲が分かればguardにできます。

Animationはground penetrationまで検査する

3D将棋の歩兵motionでは、keyframeだけでなく1/4 frame刻みでfloor penetrationを確認しています。

for tick in range(
    4,
    int(action.frame_end) * 4 + 1,
):
    frame = tick / 4

    scene.frame_set(
        int(frame),
        subframe=frame % 1,
    )

    for part in (
        "Boot_L",
        "Boot_R",
        "Spear_Tip",
    ):
        floor = min(
            (
                obj.matrix_world
                @ vertex.co
            ).z
            for vertex
            in obj.data.vertices
        )

        assert floor >= -0.006

keyframe位置だけ確認すると、補間途中で床へ刺さるケースを見逃します。そのためsubframeまで見ます。

四脚や車輪では「支えがあること」も見る

桂馬や香車では逆に、「浮いていない」ことも重要です。

soles = [
    min(
        (
            obj.matrix_world
            @ v.co
        ).z
        for v in obj.data.vertices
    )
    for obj in contacts
]

assert min(soles) > -0.005
assert min(soles) < 0.015

香車では4輪すべてが一定以上浮いていないかも確認します。

assert max(soles) < 0.018

見た目上の「接地感」を完全には判定できませんが、明らかなfloat / penetrationは自動で除外できます。

Gameplay rootが動いていないかを見る

3D将棋ではRoot Motion禁止なので、motion validatorで毎frame確認します。

assert (
    rig.pose.bones["Root"]
    .location.length
    < 0.00001
)

assert (
    rig.pose.bones["Hips"]
    .location.length
    < 0.00001
)

animationを修正した結果rootへtranslationが入っても、そのままproductionへ入りません。

これはvisual QAというよりGameplay contractのQAです。

Turntableは「見えない角度」をなくすために使う

静止画一枚では、裏側や上面の破綻を見逃します。

3D将棋にはturntable render scriptがあり、複数elevation × 複数azimuthを自動renderします。

for elev in elevations:
    for i in range(steps):
        deg = i * 360 / steps

        cam.location = (...)
        look_at(cam, target)

        bpy.ops.render.render(
            write_still=True
        )

さらに真上90度も別にrenderします。

ほぼ真上のcameraだけでは隠れるplanar overlapを見つけるためです。

Motion sampleもframeを固定する

Animationも毎回動画を全部見るだけでは比較しづらいため、代表frameを固定してrenderできます。

idle_start
idle_peak
move_peak
anticipation_start
anticipation_peak
recovery

同じframeのbefore / afterを並べると、bodyの沈み、weapon位置、secondary motionの変化を比較しやすくなります。

最後はgameplay cameraで見る

ここまでvalidationしても、人間のvisual reviewは消せません。

自動checkが通っていても、

  • mobileでは顔が読めない
  • weaponがUIに隠れる
  • attack directionが伝わらない
  • silhouetteが他characterと似すぎる
  • secondary motionが不自然

といった問題は残ります。

そのため最終QAは、

structural validation
  ↓
motion validation
  ↓
turntable / representative frames
  ↓
gallery
  ↓
actual game camera

の順で行います。

3D assetにもtest pyramidを作れる

最終的には、3D asset QAもソフトウェアtestとかなり似た構造になりました。

cheap deterministic checks
  node / bounds / parent / ratio
        ↓
motion checks
  ground / root / seam / direction
        ↓
rendered visual checks
  turntable / key frames
        ↓
integration checks
  Unity / browser gameplay camera

すべてを画像認識で自動採点する必要はありません。

機械で確実に落とせるregressionだけ先に落とし、人間は見た目の判断へ集中する。

Blender Pythonでassetを生成するなら、この分業がかなり効きます。