Files
blender-vrm/exporter/abstract_base_vrm_exporter.py
小煜 091ad6a49a 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
2026-01-01 14:21:56 +08:00

385 lines
14 KiB
Python

# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
import secrets
import string
from abc import ABC, abstractmethod
from collections.abc import Iterator, Mapping, Sequence
from contextlib import contextmanager
from typing import Optional, Union
import bmesh
from bpy.types import Armature, Context, Mesh, NodesModifier, Object
from mathutils import Matrix, Vector
from ..common import shader
from ..common.convert import Json
from ..common.deep import make_json
from ..common.logger import get_logger
from ..editor.extension import get_armature_extension, get_material_extension
from ..editor.property_group import BonePropertyGroup, BonePropertyGroupType
from ..editor.search import MESH_CONVERTIBLE_OBJECT_TYPES
from ..external import io_scene_gltf2_support
logger = get_logger(__name__)
class AbstractBaseVrmExporter(ABC):
def __init__(
self,
context: Context,
export_objects: Sequence[Object],
armature: Object,
) -> None:
self.context = context
self.export_objects = export_objects
self.armature = armature
self.export_id = "BlenderVrmAddonExport" + (
"".join(secrets.choice(string.digits) for _ in range(10))
)
self.gltf2_addon_export_settings = (
io_scene_gltf2_support.create_export_settings()
)
armature_data = self.armature.data
if not isinstance(armature_data, Armature):
message = f"{type(armature_data)} is not an Armature"
raise TypeError(message)
@abstractmethod
def export_vrm(self) -> Optional[bytes]:
pass
@staticmethod
def enter_clear_blend_shape_proxy_previews(
armature_data: Armature,
) -> tuple[Sequence[float], Mapping[str, float]]:
ext = get_armature_extension(armature_data)
saved_vrm0_previews: list[float] = []
for blend_shape_group in ext.vrm0.blend_shape_master.blend_shape_groups:
saved_vrm0_previews.append(blend_shape_group.preview)
blend_shape_group.preview = 0
saved_vrm1_previews: dict[str, float] = {}
for (
name,
expression,
) in ext.vrm1.expressions.all_name_to_expression_dict().items():
saved_vrm1_previews[name] = expression.preview
expression.preview = 0
return saved_vrm0_previews, saved_vrm1_previews
@staticmethod
def leave_clear_blend_shape_proxy_previews(
armature_data: Armature,
saved_vrm0_previews: Sequence[float],
saved_vrm1_previews: Mapping[str, float],
) -> None:
ext = get_armature_extension(armature_data)
for blend_shape_group, blend_shape_preview in zip(
ext.vrm0.blend_shape_master.blend_shape_groups, saved_vrm0_previews
):
blend_shape_group.preview = blend_shape_preview
for (
name,
expression,
) in ext.vrm1.expressions.all_name_to_expression_dict().items():
expression_preview = saved_vrm1_previews.get(name)
if expression_preview is not None:
expression.preview = expression_preview
@contextmanager
def clear_blend_shape_proxy_previews(
self, armature_data: Armature
) -> Iterator[None]:
saved_vrm0_previews, saved_vrm1_previews = (
self.enter_clear_blend_shape_proxy_previews(armature_data)
)
try:
yield
# After yield, native bpy objects may be deleted or frames may advance,
# becoming invalid. Accessing them in this state causes crashes, so
# be careful not to access potentially invalid native objects after yield
finally:
self.leave_clear_blend_shape_proxy_previews(
armature_data, saved_vrm0_previews, saved_vrm1_previews
)
def enter_enable_deform_for_all_referenced_bones(
self, armature_data: Armature
) -> list[str]:
ext = get_armature_extension(armature_data)
modified_non_deform_bone_names = list[str]()
for (
bone_property_group,
bone_property_group_type,
) in BonePropertyGroup.get_all_bone_property_groups(armature_data):
bone = armature_data.bones.get(bone_property_group.bone_name)
if not bone or bone.use_deform:
continue
if (
ext.is_vrm0()
and BonePropertyGroupType.is_vrm0(bone_property_group_type)
) or (
ext.is_vrm1()
and BonePropertyGroupType.is_vrm1(bone_property_group_type)
):
bone.use_deform = True
modified_non_deform_bone_names.append(bone.name)
return modified_non_deform_bone_names
def leave_enable_deform_for_all_referenced_bones(
self, armature_data: Armature, modified_non_deform_bone_names: list[str]
) -> None:
for modified_non_deform_bone_name in modified_non_deform_bone_names:
bone = armature_data.bones.get(modified_non_deform_bone_name)
if bone and bone.use_deform:
bone.use_deform = False
@contextmanager
def enable_deform_for_all_referenced_bones(
self, armature_data: Armature
) -> Iterator[None]:
modified_non_deform_bone_names = (
self.enter_enable_deform_for_all_referenced_bones(armature_data)
)
try:
yield
finally:
self.leave_enable_deform_for_all_referenced_bones(
armature_data, modified_non_deform_bone_names
)
@staticmethod
def enter_hide_mtoon1_outline_geometry_nodes(
context: Context,
) -> dict[str, list[tuple[str, bool, bool]]]:
object_name_to_modifiers: dict[str, list[tuple[str, bool, bool]]] = {}
for obj in context.blend_data.objects:
for modifier in obj.modifiers:
if not modifier.show_viewport:
continue
if modifier.type != "NODES":
continue
if not isinstance(modifier, NodesModifier):
continue
node_group = modifier.node_group
if (
not node_group
or node_group.name != shader.OUTLINE_GEOMETRY_GROUP_NAME
):
continue
modifiers = object_name_to_modifiers.get(obj.name)
if modifiers is None:
modifiers = []
object_name_to_modifiers[obj.name] = modifiers
modifiers = object_name_to_modifiers[obj.name]
modifiers.append(
(
modifier.name,
modifier.show_render,
modifier.show_viewport,
)
)
if modifier.show_render:
modifier.show_render = False
if modifier.show_viewport:
modifier.show_viewport = False
return object_name_to_modifiers
@staticmethod
def exit_hide_mtoon1_outline_geometry_nodes(
context: Context,
object_name_to_modifiers: dict[str, list[tuple[str, bool, bool]]],
) -> None:
for object_name, modifiers in object_name_to_modifiers.items():
for modifier_name, render, viewport in modifiers:
obj = context.blend_data.objects.get(object_name)
if not obj:
continue
modifier = obj.modifiers.get(modifier_name)
if (
not modifier
or modifier.type != "NODES"
or not isinstance(modifier, NodesModifier)
):
continue
node_group = modifier.node_group
if (
not node_group
or node_group.name != shader.OUTLINE_GEOMETRY_GROUP_NAME
):
continue
if modifier.show_render != render:
modifier.show_render = render
if modifier.show_viewport != viewport:
modifier.show_viewport = viewport
@staticmethod
@contextmanager
def hide_mtoon1_outline_geometry_nodes(context: Context) -> Iterator[None]:
object_name_to_modifier_names = (
AbstractBaseVrmExporter.enter_hide_mtoon1_outline_geometry_nodes(context)
)
try:
yield
# After yield, native bpy objects may be deleted or frames may advance,
# becoming invalid. Accessing them in this state causes crashes, so
# be careful not to access potentially invalid native objects after yield
finally:
AbstractBaseVrmExporter.exit_hide_mtoon1_outline_geometry_nodes(
context, object_name_to_modifier_names
)
@staticmethod
def setup_mtoon_gltf_fallback_nodes(context: Context, *, is_vrm0: bool) -> None:
"""Reflect MToon node values to nodes used for glTF fallback values.
When MToon nodes are directly edited, glTF fallback values are not
automatically set. Therefore, we explicitly set values during export.
"""
for material in context.blend_data.materials:
mtoon1 = get_material_extension(material).mtoon1
if not mtoon1.enabled:
continue
mtoon1.pbr_metallic_roughness.base_color_factor = (
mtoon1.pbr_metallic_roughness.base_color_factor
)
mtoon1.emissive_factor = mtoon1.emissive_factor
mtoon1.normal_texture.scale = mtoon1.normal_texture.scale
mtoon1.extensions.khr_materials_emissive_strength.emissive_strength = (
mtoon1.extensions.khr_materials_emissive_strength.emissive_strength
)
for texture in mtoon1.all_textures(downgrade_to_mtoon0=is_vrm0):
texture.source = texture.get_connected_node_image()
def assign_dict(
target: dict[str, Json],
key: str,
value: Union[Json, tuple[float, float, float], tuple[float, float, float, float]],
default_value: Json = None,
) -> bool:
if value is None or value == default_value:
return False
target[key] = make_json(value)
return True
def generate_evaluated_mesh(context: Context, obj: Object) -> Optional[Mesh]:
obj_data = obj.data
if obj_data is None:
return None
# https://docs.blender.org/api/2.80/Depsgraph.html
# TODO: Shape keys may sometimes break
depsgraph = context.evaluated_depsgraph_get()
evaluated_obj = obj.evaluated_get(depsgraph)
evaluated_temporary_mesh = evaluated_obj.to_mesh(
preserve_all_data_layers=True, depsgraph=depsgraph
)
if not evaluated_temporary_mesh:
return None
# The documentation says to use BlendDataMeshes.new_from_object(), but
# that doesn't preserve shape keys.
if isinstance(obj_data, Mesh):
evaluated_mesh = obj_data.copy()
else:
logger.error(
"Unexpected object type: %s name=%s",
type(obj_data),
obj_data.name,
)
evaluated_mesh = context.blend_data.meshes.new(name=obj_data.name)
bm = bmesh.new()
bm.from_mesh(evaluated_temporary_mesh)
bm.to_mesh(evaluated_mesh)
bm.free()
evaluated_obj.to_mesh_clear()
return evaluated_mesh
def force_apply_modifiers(
context: Context,
obj: Object,
*,
preserve_shape_keys: bool,
transform: Optional[Matrix] = None,
) -> Optional[Mesh]:
if obj.type not in MESH_CONVERTIBLE_OBJECT_TYPES:
return None
evaluated_mesh = generate_evaluated_mesh(context, obj)
if evaluated_mesh is None:
return None
if transform:
evaluated_mesh.transform(transform)
if not preserve_shape_keys:
return evaluated_mesh
obj_data = obj.data
if obj_data is None:
return evaluated_mesh
if not isinstance(obj_data, Mesh):
return evaluated_mesh
shape_keys = obj_data.shape_keys
if not shape_keys:
return evaluated_mesh
evaluated_mesh_shape_keys = evaluated_mesh.shape_keys
if not evaluated_mesh_shape_keys:
return evaluated_mesh
# If the mesh has shape keys, reproduce them as much as possible
for shape_key in shape_keys.key_blocks:
evaluated_mesh_shape_key = evaluated_mesh_shape_keys.key_blocks.get(
shape_key.name
)
if not evaluated_mesh_shape_key:
continue
if shape_key.name == shape_keys.reference_key.name:
continue
shape_key.value = 1.0
context.view_layer.update()
depsgraph = context.evaluated_depsgraph_get()
baked_shape_key_obj = obj.evaluated_get(depsgraph)
baked_shape_key_mesh = baked_shape_key_obj.to_mesh(
preserve_all_data_layers=True, depsgraph=depsgraph
)
if baked_shape_key_mesh:
if transform:
baked_shape_key_mesh.transform(transform)
evaluated_mesh_shape_key_data = evaluated_mesh_shape_key.data
baked_shape_key_mesh_vertices = baked_shape_key_mesh.vertices
# TODO: If the number of vertices is different, we should use advanced
# graph matching algorithm.
for i in range(
min(
len(evaluated_mesh_shape_key_data),
len(baked_shape_key_mesh_vertices),
)
):
evaluated_mesh_shape_key_data[i].co = Vector(
baked_shape_key_mesh_vertices[i].co
)
baked_shape_key_obj.to_mesh_clear()
shape_key.value = 0.0
context.view_layer.update()
return evaluated_mesh