feat: Add VRM Blender addon with complete import/export functionality

- Add core VRM addon infrastructure with manifest and registration
- Add common utilities module with file system, logging, and conversion helpers
- Add human bone mapper with support for multiple rigging standards (Mixamo, MMD, Unreal, Rigify, etc.)
- Add VRM 0.x and 1.x format support with property groups and handlers
- Add editor UI panels for VRM metadata, spring bones, and MToon materials
- Add exporter with glTF2 extension support for VRM format serialization
- Add importer with scene reconstruction and armature generation
- Add MToon shader support with auto-setup and material migration
- Add spring bone physics simulation with constraint handling
- Add node constraint editor for advanced rigging control
- Add comprehensive validation and error handling with user dialogs
- Add scene watcher for real-time property synchronization
- Add workspace management and preference system
- Include Python cache files and Blender manifest configuration
- This is the initial commit establishing the complete VRM addon ecosystem for Blender
This commit is contained in:
2026-01-01 14:21:56 +08:00
commit 091ad6a49a
243 changed files with 60636 additions and 0 deletions

1
editor/vrm1/__init__.py Normal file
View File

@@ -0,0 +1 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

45
editor/vrm1/handler.py Normal file
View File

@@ -0,0 +1,45 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
import bpy
from bpy.app.handlers import persistent
from ...common.logger import get_logger
from ...common.scene_watcher import trigger_scene_watcher
from .property_group import Vrm1ExpressionPropertyGroup
from .scene_watcher import LookAtPreviewUpdater
logger = get_logger(__name__)
@persistent
def frame_change_pre(_unused: object) -> None:
Vrm1ExpressionPropertyGroup.frame_change_post_shape_key_updates.clear()
@persistent
def frame_change_post(_unused: object) -> None:
context = bpy.context
for (
shape_key_name,
key_block_name,
), value in Vrm1ExpressionPropertyGroup.frame_change_post_shape_key_updates.items():
shape_key = context.blend_data.shape_keys.get(shape_key_name)
if not shape_key:
continue
key_blocks = shape_key.key_blocks
if not key_blocks:
continue
key_block = key_blocks.get(key_block_name)
if not key_block:
continue
key_block.value = value
Vrm1ExpressionPropertyGroup.frame_change_post_shape_key_updates.clear()
# Update materials
Vrm1ExpressionPropertyGroup.update_materials(context)
@persistent
def depsgraph_update_pre(_unused: object) -> None:
trigger_scene_watcher(LookAtPreviewUpdater)

26
editor/vrm1/menu.py Normal file
View File

@@ -0,0 +1,26 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from bpy.types import Context, Menu
from ...common.logger import get_logger
from ..ops import layout_operator
from ..search import current_armature
from .ops import VRM_OT_restore_vrm1_expression_morph_target_bind_object
logger = get_logger(__name__)
class VRM_MT_vrm1_expression(Menu):
bl_label = "Expression Menu"
bl_idname = "VRM_MT_vrm1_expression"
def draw(self, context: Context) -> None:
layout = self.layout
armature = current_armature(context)
if not armature:
return
op = layout_operator(
layout, VRM_OT_restore_vrm1_expression_morph_target_bind_object
)
op.armature_object_name = armature.name

281
editor/vrm1/migration.py Normal file
View File

@@ -0,0 +1,281 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from bpy.types import Armature, Context, Material, Object
from idprop.types import IDPropertyGroup
from mathutils import Vector
from ...common import convert, ops, shader
from ..extension import get_armature_extension
from .property_group import (
Vrm1ExpressionPropertyGroup,
Vrm1ExpressionsPropertyGroup,
Vrm1HumanBonesPropertyGroup,
Vrm1MaterialColorBindPropertyGroup,
Vrm1PropertyGroup,
)
def migrate_old_expression_layout(
old_expression: object, expression: Vrm1ExpressionPropertyGroup
) -> None:
if not isinstance(old_expression, IDPropertyGroup):
return
old_morph_target_binds = convert.iterator_or_none(
old_expression.get("morph_target_binds")
)
if old_morph_target_binds is not None:
for old_morph_target_bind in old_morph_target_binds:
if not isinstance(old_morph_target_bind, IDPropertyGroup):
continue
morph_target_bind = expression.morph_target_binds.add()
old_node = old_morph_target_bind.get("node")
if isinstance(old_node, IDPropertyGroup):
old_bpy_object = old_node.get("bpy_object")
if isinstance(old_bpy_object, Object) and old_bpy_object.type == "MESH":
morph_target_bind.node.mesh_object_name = old_bpy_object.name
old_node.clear()
old_index = old_morph_target_bind.get("index")
if isinstance(old_index, str):
morph_target_bind.index = old_index
old_weight = old_morph_target_bind.get("weight")
if isinstance(old_weight, (float, int)):
morph_target_bind.weight = old_weight
old_morph_target_bind.clear()
old_material_color_binds = convert.iterator_or_none(
old_expression.get("material_color_binds")
)
if old_material_color_binds is not None:
for old_material_color_bind in old_material_color_binds:
if not isinstance(old_material_color_bind, IDPropertyGroup):
continue
material_color_bind = expression.material_color_binds.add()
old_material = old_material_color_bind.get("material")
if isinstance(old_material, Material):
material_color_bind.material = old_material
old_type = next(
(
enum.identifier
for enum in Vrm1MaterialColorBindPropertyGroup.type_enum
if old_material_color_bind.get("type") == enum.value
),
None,
)
if old_type is not None:
material_color_bind.type = old_type
old_target_value = shader.rgba_or_none(
old_material_color_bind.get("target_value"), 0.0, 1.0
)
if old_target_value:
material_color_bind.target_value = old_target_value
old_material_color_bind.clear()
old_texture_transform_binds = convert.iterator_or_none(
old_expression.get("texture_transform_binds")
)
if old_texture_transform_binds is not None:
for old_texture_transform_bind in old_texture_transform_binds:
if not isinstance(old_texture_transform_bind, IDPropertyGroup):
continue
texture_transform_bind = expression.texture_transform_binds.add()
old_material = old_texture_transform_bind.get("material")
if isinstance(old_material, Material):
texture_transform_bind.material = old_material
old_scale = convert.float2_or_none(old_texture_transform_bind.get("scale"))
if old_scale:
texture_transform_bind.scale = old_scale
old_offset = convert.float2_or_none(
old_texture_transform_bind.get("offset")
)
if old_offset:
texture_transform_bind.offset = old_offset
old_texture_transform_bind.clear()
old_is_binary = old_expression.get("is_binary")
if isinstance(old_is_binary, int):
expression.is_binary = bool(old_is_binary)
old_override_blink = next(
(
enum.identifier
for enum in Vrm1ExpressionPropertyGroup.expression_override_type_enum
if old_expression.get("override_blink") == enum.value
),
None,
)
if old_override_blink is not None:
expression.override_blink = old_override_blink
old_override_look_at = next(
(
enum.identifier
for enum in Vrm1ExpressionPropertyGroup.expression_override_type_enum
if old_expression.get("override_look_at") == enum.value
),
None,
)
if old_override_look_at is not None:
expression.override_look_at = old_override_look_at
old_override_mouth = next(
(
enum.identifier
for enum in Vrm1ExpressionPropertyGroup.expression_override_type_enum
if old_expression.get("override_mouth") == enum.value
),
None,
)
if old_override_mouth is not None:
expression.override_mouth = old_override_mouth
def migrate_old_expressions_layout(expressions: Vrm1ExpressionsPropertyGroup) -> None:
for name, expression in expressions.preset.name_to_expression_dict().items():
property_name = {
"blinkLeft": "blink_left",
"blinkRight": "blink_right",
"lookUp": "look_up",
"lookDown": "look_down",
"lookLeft": "look_left",
"lookRight": "look_right",
}.get(name)
if property_name is None:
property_name = name
old_expression = expressions.get(property_name)
migrate_old_expression_layout(old_expression, expression)
expression.name = name
for expression in expressions.custom:
old_expression = expression.get("expression")
migrate_old_expression_layout(old_expression, expression)
def migrate_pose(context: Context, armature: Object, armature_data: Armature) -> None:
ext = get_armature_extension(armature_data)
if tuple(ext.addon_version) >= (2, 20, 34):
return
humanoid = ext.vrm1.humanoid
if isinstance(humanoid.get("pose"), int):
return
if tuple(ext.addon_version) == ext.INITIAL_ADDON_VERSION:
if ext.has_vrm_model_metadata(armature):
humanoid.pose = humanoid.POSE_CURRENT_POSE.identifier
return
action = humanoid.pose_library
if action and action.name in context.blend_data.actions:
humanoid.pose = humanoid.POSE_CUSTOM_POSE.identifier
elif armature_data.pose_position == "REST":
humanoid.pose = humanoid.POSE_REST_POSITION_POSE.identifier
else:
humanoid.pose = humanoid.POSE_CURRENT_POSE.identifier
def migrate_auto_pose(_context: Context, armature_data: Armature) -> None:
ext = get_armature_extension(armature_data)
if tuple(ext.addon_version) == ext.INITIAL_ADDON_VERSION or tuple(
ext.addon_version
) >= (2, 20, 81):
return
humanoid = ext.vrm1.humanoid
if not isinstance(humanoid.get("pose"), int):
humanoid.pose = humanoid.POSE_CURRENT_POSE.identifier
def migrate(context: Context, vrm1: Vrm1PropertyGroup, armature: Object) -> None:
armature_data = armature.data
if not isinstance(armature_data, Armature):
return
human_bones = vrm1.humanoid.human_bones
human_bones.last_bone_names_str = ""
Vrm1HumanBonesPropertyGroup.fixup_human_bones(armature)
Vrm1HumanBonesPropertyGroup.update_all_node_candidates(context, armature_data.name)
if human_bones.initial_automatic_bone_assignment:
human_bones.initial_automatic_bone_assignment = False
human_bone_name_to_human_bone = human_bones.human_bone_name_to_human_bone()
if all(not b.node.bone_name for b in human_bone_name_to_human_bone.values()):
ops.vrm.assign_vrm1_humanoid_human_bones_automatically(
armature_object_name=armature.name
)
if tuple(get_armature_extension(armature_data).addon_version) <= (2, 14, 10):
ext = get_armature_extension(armature_data)
head_bone_name = ext.vrm1.humanoid.human_bones.head.node.bone_name
head_bone = armature_data.bones.get(head_bone_name)
if head_bone:
look_at = get_armature_extension(armature_data).vrm1.look_at
world_translation = (
armature.matrix_world @ head_bone.matrix_local
).to_quaternion() @ Vector(look_at.offset_from_head_bone)
look_at.offset_from_head_bone = list(world_translation)
if tuple(get_armature_extension(armature_data).addon_version) <= (2, 15, 5):
# Apply lower limit value
look_at = get_armature_extension(armature_data).vrm1.look_at
look_at.range_map_horizontal_inner.input_max_value = (
look_at.range_map_horizontal_inner.input_max_value
)
look_at.range_map_horizontal_outer.input_max_value = (
look_at.range_map_horizontal_outer.input_max_value
)
look_at.range_map_vertical_down.input_max_value = (
look_at.range_map_vertical_down.input_max_value
)
look_at.range_map_vertical_up.input_max_value = (
look_at.range_map_vertical_up.input_max_value
)
if tuple(get_armature_extension(armature_data).addon_version) < (2, 18, 0):
migrate_old_expressions_layout(
get_armature_extension(armature_data).vrm1.expressions
)
if tuple(get_armature_extension(armature_data).addon_version) < (2, 20, 0):
look_at = get_armature_extension(armature_data).vrm1.look_at
look_at.offset_from_head_bone = (
look_at.offset_from_head_bone[0],
look_at.offset_from_head_bone[2],
-look_at.offset_from_head_bone[1],
)
migrate_pose(context, armature, armature_data)
migrate_auto_pose(context, armature_data)
# Set a name for the expression preset.
# It's not necessary for management, but I want to set it because it's
# displayed in the animation keyframes.
expressions = get_armature_extension(armature_data).vrm1.expressions
preset_name_to_expression_dict = expressions.preset.name_to_expression_dict()
for preset_name, preset_expression in preset_name_to_expression_dict.items():
if preset_expression.name != preset_name:
preset_expression.name = preset_name
if tuple(get_armature_extension(armature_data).addon_version) < (3, 9, 0):
for expression in expressions.preset.name_to_expression_dict().values():
for morph_target_bind in expression.morph_target_binds:
morph_target_bind.node.saved_mesh_object_name_to_restore = (
morph_target_bind.node.mesh_object_name
)
Vrm1HumanBonesPropertyGroup.update_all_node_candidates(
context,
armature_data.name,
force=True,
)
ops.vrm.update_vrm1_expression_ui_list_elements()

2730
editor/vrm1/ops.py Normal file

File diff suppressed because it is too large Load Diff

1222
editor/vrm1/panel.py Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,93 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from dataclasses import dataclass
from bpy.types import Armature, Context
from mathutils import Vector
from ...common import ops
from ...common.logger import get_logger
from ...common.scene_watcher import RunState, SceneWatcher
from ..extension import get_armature_extension
from .property_group import Vrm1LookAtPropertyGroup
logger = get_logger(__name__)
@dataclass
class LookAtPreviewUpdater(SceneWatcher):
armature_index: int = 0
def reset_run_progress(self) -> None:
self.armature_index = 0
def run(self, context: Context) -> RunState:
"""Detect updates to the target object of Look At and update the state."""
blend_data = context.blend_data
if not blend_data.armatures:
return RunState.FINISH
count = 20
armatures_len = len(blend_data.armatures)
if self.armature_index >= armatures_len:
self.armature_index = 0
start_armature_index = self.armature_index
end_armature_index = min(self.armature_index + count, armatures_len)
changed = False
for armature in blend_data.armatures[start_armature_index:end_armature_index]:
ext = get_armature_extension(armature)
if not ext.is_vrm1():
continue
look_at = ext.vrm1.look_at
if not look_at.enable_preview:
continue
preview_target_bpy_object = look_at.preview_target_bpy_object
if not preview_target_bpy_object:
continue
if (
Vector(look_at.previous_preview_target_bpy_object_location)
- preview_target_bpy_object.location
).length_squared > 0:
changed = True
break
if changed:
Vrm1LookAtPropertyGroup.update_all_previews(context)
return RunState.FINISH
self.armature_index += count
if end_armature_index < armatures_len:
return RunState.PREEMPT
return RunState.FINISH
def create_fast_path_performance_test_objects(self, context: Context) -> None:
blend_data = context.blend_data
for i in range(300):
if i % 3 != 0:
mesh = blend_data.meshes.new(f"Mesh#{i}")
obj = blend_data.objects.new(f"Object#{i}", mesh)
context.scene.collection.objects.link(obj)
continue
ops.icyp.make_basic_armature()
active_object = context.active_object
if not active_object:
message = f"Not an armature: {active_object}"
raise ValueError(message)
armature = active_object.data
if not isinstance(armature, Armature):
raise TypeError
ext = get_armature_extension(armature)
ext.spec_version = ext.SPEC_VERSION_VRM1
look_at = ext.vrm1.look_at
look_at.type = ext.vrm1.look_at.TYPE_BONE.identifier
look_at.preview_target_bpy_object = blend_data.objects[
i * 5 % len(blend_data.objects)
]
ext.vrm1.look_at.enable_preview = True

302
editor/vrm1/ui_list.py Normal file
View File

@@ -0,0 +1,302 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from bpy.types import Context, Mesh, UILayout, UIList
from ...common.logger import get_logger
from ..property_group import StringPropertyGroup
from .property_group import (
Vrm1ExpressionsPropertyGroup,
Vrm1FirstPersonPropertyGroup,
Vrm1MaterialColorBindPropertyGroup,
Vrm1MeshAnnotationPropertyGroup,
Vrm1MetaPropertyGroup,
Vrm1MorphTargetBindPropertyGroup,
Vrm1TextureTransformBindPropertyGroup,
)
logger = get_logger(__name__)
class VRM_UL_vrm1_meta_author(UIList):
bl_idname = "VRM_UL_vrm1_meta_author"
def draw_item(
self,
_context: Context,
layout: UILayout,
meta: object,
author: object,
_icon: int,
_active_data: object,
_active_prop_name: str,
index: int,
_flt_flag: int,
) -> None:
if not isinstance(meta, Vrm1MetaPropertyGroup):
return
if not isinstance(author, StringPropertyGroup):
return
icon = "USER"
if self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", translate=False, icon=icon)
return
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
if index == meta.active_author_index:
layout.prop(author, "value", icon=icon, text="", translate=False)
else:
layout.label(text=author.value, icon=icon, translate=False)
class VRM_UL_vrm1_meta_reference(UIList):
bl_idname = "VRM_UL_vrm1_meta_reference"
def draw_item(
self,
_context: Context,
layout: UILayout,
meta: object,
reference: object,
_icon: int,
_active_data: object,
_active_prop_name: str,
index: int,
_flt_flag: int,
) -> None:
if not isinstance(meta, Vrm1MetaPropertyGroup):
return
if not isinstance(reference, StringPropertyGroup):
return
icon = "URL"
if self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", translate=False, icon=icon)
return
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
if index == meta.active_reference_index:
layout.prop(reference, "value", icon=icon, text="", translate=False)
else:
layout.label(text=reference.value, icon=icon, translate=False)
class VRM_UL_vrm1_first_person_mesh_annotation(UIList):
bl_idname = "VRM_UL_vrm1_first_person_mesh_annotation"
def draw_item(
self,
_context: Context,
layout: UILayout,
first_person: object,
mesh_annotation: object,
_icon: int,
_active_data: object,
_active_prop_name: str,
index: int,
_flt_flag: int,
) -> None:
if not isinstance(first_person, Vrm1FirstPersonPropertyGroup):
return
if not isinstance(mesh_annotation, Vrm1MeshAnnotationPropertyGroup):
return
icon = "OUTLINER_OB_MESH"
if self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", translate=False, icon=icon)
return
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
row = layout.split(factor=0.6, align=True)
if index == first_person.active_mesh_annotation_index:
row.prop(
mesh_annotation.node,
"bpy_object",
text="",
translate=False,
icon=icon,
)
else:
row.label(
text=mesh_annotation.node.mesh_object_name,
translate=False,
icon=icon,
)
row.prop(mesh_annotation, "type", text="", translate=False)
class VRM_UL_vrm1_expression(UIList):
bl_idname = "VRM_UL_vrm1_expression"
def draw_item(
self,
_context: Context,
layout: UILayout,
expressions: object,
_item: object,
_icon: int,
_active_data: object,
_active_prop_name: str,
index: int,
_flt_flag: int,
) -> None:
if not isinstance(expressions, Vrm1ExpressionsPropertyGroup):
return
preset_expression_items = list(
expressions.preset.name_to_expression_dict().items()
)
if index < len(preset_expression_items):
name, expression = preset_expression_items[index]
icon = expressions.preset.get_icon(name)
if not icon:
logger.error("Unknown preset expression: %s", name)
icon = "SHAPEKEY_DATA"
else:
custom_expressions = expressions.custom
custom_index = index - len(preset_expression_items)
if custom_index >= len(custom_expressions):
return
expression = custom_expressions[custom_index]
name = expression.custom_name
icon = "SHAPEKEY_DATA"
if self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", translate=False, icon=icon)
return
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
split = layout.split(align=True, factor=0.55)
split.label(text=name, translate=False, icon=icon)
split.prop(expression, "preview", text="Preview")
class VRM_UL_vrm1_morph_target_bind(UIList):
bl_idname = "VRM_UL_vrm1_morph_target_bind"
def draw_item(
self,
context: Context,
layout: UILayout,
_data: object,
morph_target_bind: object,
icon: int,
_active_data: object,
_active_prop_name: str,
_index: int,
_flt_flag: int,
) -> None:
blend_data = context.blend_data
if not isinstance(morph_target_bind, Vrm1MorphTargetBindPropertyGroup):
return
if self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", icon_value=icon)
return
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
name = morph_target_bind.node.mesh_object_name
mesh_object = blend_data.objects.get(morph_target_bind.node.mesh_object_name)
if mesh_object:
mesh_data = mesh_object.data
if isinstance(mesh_data, Mesh):
shape_keys = mesh_data.shape_keys
if shape_keys:
keys = shape_keys.key_blocks.keys()
if morph_target_bind.index in keys:
name += " / " + morph_target_bind.index
layout.label(text=name, translate=False, icon="MESH_DATA")
class VRM_UL_vrm1_material_color_bind(UIList):
bl_idname = "VRM_UL_vrm0_material_color_bind"
def draw_item(
self,
_context: Context,
layout: UILayout,
_data: object,
material_color_bind: object,
icon: int,
_active_data: object,
_active_prop_name: str,
_index: int,
_flt_flag: int,
) -> None:
if not isinstance(material_color_bind, Vrm1MaterialColorBindPropertyGroup):
return
if self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", translate=False, icon_value=icon)
return
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
name = ""
material = material_color_bind.material
if material:
name = material.name
type_name = next(
(
enum.name
for enum in Vrm1MaterialColorBindPropertyGroup.type_enum
if enum.identifier == material_color_bind.type
),
None,
)
if type_name:
name += " / " + type_name
layout.label(text=name, translate=False, icon="MATERIAL")
class VRM_UL_vrm1_texture_transform_bind(UIList):
bl_idname = "VRM_UL_vrm1_texture_transform_bind"
def draw_item(
self,
_context: Context,
layout: UILayout,
_data: object,
texture_transform_bind: object,
icon: int,
_active_data: object,
_active_prop_name: str,
_index: int,
_flt_flag: int,
) -> None:
if not isinstance(
texture_transform_bind, Vrm1TextureTransformBindPropertyGroup
):
return
if self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", translate=False, icon_value=icon)
return
if self.layout_type not in {"DEFAULT", "COMPACT"}:
return
name = ""
material = texture_transform_bind.material
if material:
name = material.name
layout.label(text=name, translate=False, icon="MATERIAL")