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
importer/__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.

File diff suppressed because it is too large Load Diff

83
importer/file_handler.py Normal file
View File

@@ -0,0 +1,83 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from typing import TYPE_CHECKING
from bpy.props import StringProperty
from bpy.types import Context, Event, FileHandler, Operator
from ..common import ops
class VRM_OT_import_vrm_via_file_handler(Operator):
bl_idname = "vrm.import_vrm_via_file_handler"
bl_label = "Import VRM via FileHandler"
filepath: StringProperty( # type: ignore[valid-type]
subtype="FILE_PATH",
options={"SKIP_SAVE"},
)
@classmethod
def poll(cls, _context: Context) -> bool:
return True
def execute(self, _context: Context) -> set[str]:
return ops.import_scene.vrm(filepath=self.filepath, use_addon_preferences=True)
def invoke(self, context: Context, _event: Event) -> set[str]:
return self.execute(context)
if TYPE_CHECKING:
# This code is auto generated.
# To regenerate, run the `uv run tools/property_typing.py` command.
filepath: str # type: ignore[no-redef]
class VRM_OT_import_vrma_via_file_handler(Operator):
bl_idname = "vrm.import_vrma_via_file_handler"
bl_label = "Import VRMA via FileHandler"
filepath: StringProperty( # type: ignore[valid-type]
subtype="FILE_PATH",
options={"SKIP_SAVE"},
)
@classmethod
def poll(cls, _context: Context) -> bool:
return True
def execute(self, _context: Context) -> set[str]:
return ops.import_scene.vrma(filepath=self.filepath)
def invoke(self, context: Context, _event: Event) -> set[str]:
return self.execute(context)
if TYPE_CHECKING:
# This code is auto generated.
# To regenerate, run the `uv run tools/property_typing.py` command.
filepath: str # type: ignore[no-redef]
class VRM_FH_vrm_import(FileHandler):
bl_idname = "VRM_FH_vrm_import"
bl_label = "Import VRM"
bl_import_operator = VRM_OT_import_vrm_via_file_handler.bl_idname
bl_export_operator = "export_scene.vrm"
bl_file_extensions = ".vrm"
@classmethod
def poll_drop(cls, context: Context) -> bool:
_ = context
return True
class VRM_FH_vrma_import(FileHandler):
bl_idname = "VRM_FH_vrma_import"
bl_label = "Import VRMA"
bl_import_operator = VRM_OT_import_vrma_via_file_handler.bl_idname
bl_export_operator = "export_scene.vrma"
bl_file_extensions = ".vrma"
@classmethod
def poll_drop(cls, context: Context) -> bool:
_ = context
return True

View File

@@ -0,0 +1,62 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
import secrets
import string
from typing import Optional
from bpy.types import Image
from ..common.convert import sequence_or_none
from ..common.logger import get_logger
logger = get_logger(__name__)
class glTF2ImportUserExtension:
current_import_id: Optional[str] = None
@classmethod
def update_current_import_id(cls) -> str:
import_id = "BlenderVrmAddonImport" + (
"".join(secrets.choice(string.digits) for _ in range(10))
)
cls.current_import_id = import_id
return import_id
@classmethod
def clear_current_import_id(cls) -> None:
cls.current_import_id = None
# https://github.com/KhronosGroup/glTF-Blender-IO/blob/6f9d0d9fc1bb30e2b0bb019342ffe86bd67358fc/addons/io_scene_gltf2/blender/imp/gltf2_blender_image.py#L51
def gather_import_image_after_hook(
self, image: object, bpy_image: object, gltf_importer: object
) -> None:
if self.current_import_id is None:
return
if not isinstance(bpy_image, Image):
logger.warning(
"gather_import_image_after_hook: bpy_image is not a Image but %s",
type(bpy_image),
)
return
images = sequence_or_none(
getattr(getattr(gltf_importer, "data", None), "images", None)
)
if images is None:
logger.warning(
"gather_import_image_after_hook:"
" gltf_importer is unexpected structure: %s",
gltf_importer,
)
return
if image not in images:
logger.warning(
"gather_import_image_after_hook: %s not in %s", image, images
)
return
index = images.index(image)
bpy_image[self.current_import_id] = index

559
importer/import_scene.py Normal file
View File

@@ -0,0 +1,559 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
import traceback
from collections.abc import Set as AbstractSet
from os import environ
from pathlib import Path
from typing import TYPE_CHECKING
import bpy
from bpy.app.translations import pgettext
from bpy.props import BoolProperty, CollectionProperty, StringProperty
from bpy.types import (
Armature,
Context,
Event,
Operator,
Panel,
PropertyGroup,
SpaceFileBrowser,
)
from bpy_extras.io_utils import ImportHelper
from ..common import ops, version
from ..common.error_dialog import show_error_dialog
from ..common.logger import get_logger
from ..common.preferences import (
ImportPreferencesProtocol,
copy_import_preferences,
create_import_preferences_dict,
draw_import_preferences_layout,
get_preferences,
)
from ..editor import search
from ..editor.extension import get_armature_extension
from ..editor.ops import VRM_OT_open_url_in_web_browser, layout_operator
from ..editor.property_group import CollectionPropertyProtocol, StringPropertyGroup
from .abstract_base_vrm_importer import AbstractBaseVrmImporter, parse_vrm_json
from .license_validation import LicenseConfirmationRequiredError
from .uni_vrm_vrm_animation_importer import UniVrmVrmAnimationImporter
from .vrm0_importer import Vrm0Importer
from .vrm1_importer import Vrm1Importer
logger = get_logger(__name__)
class LicenseConfirmation(PropertyGroup):
message: StringProperty() # type: ignore[valid-type]
url: StringProperty() # type: ignore[valid-type]
json_key: StringProperty() # type: ignore[valid-type]
if TYPE_CHECKING:
# This code is auto generated.
# To regenerate, run the `uv run tools/property_typing.py` command.
message: str # type: ignore[no-redef]
url: str # type: ignore[no-redef]
json_key: str # type: ignore[no-redef]
def import_vrm_update_addon_preferences(
import_op: "IMPORT_SCENE_OT_vrm", context: Context
) -> None:
if import_op.use_addon_preferences:
copy_import_preferences(source=import_op, destination=get_preferences(context))
class IMPORT_SCENE_OT_vrm(Operator, ImportHelper):
bl_idname = "import_scene.vrm"
bl_label = "Open"
bl_description = "import VRM"
bl_options: AbstractSet[str] = {"REGISTER", "UNDO"}
filename_ext = ".vrm"
filter_glob: StringProperty( # type: ignore[valid-type]
default="*.vrm",
options={"HIDDEN"},
)
use_addon_preferences: BoolProperty( # type: ignore[valid-type]
name="Import using add-on preferences",
description="Import using add-on preferences instead of operator arguments",
)
extract_textures_into_folder: BoolProperty( # type: ignore[valid-type]
name="Extract texture images into the folder",
update=import_vrm_update_addon_preferences,
default=False,
)
make_new_texture_folder: BoolProperty( # type: ignore[valid-type]
name="Don't overwrite existing texture folder",
update=import_vrm_update_addon_preferences,
default=True,
)
set_shading_type_to_material_on_import: BoolProperty( # type: ignore[valid-type]
name='Set shading type to "Material"',
update=import_vrm_update_addon_preferences,
default=True,
)
set_view_transform_to_standard_on_import: BoolProperty( # type: ignore[valid-type]
name='Set view transform to "Standard"',
update=import_vrm_update_addon_preferences,
default=True,
)
set_armature_display_to_wire: BoolProperty( # type: ignore[valid-type]
name='Set an imported armature display to "Wire"',
update=import_vrm_update_addon_preferences,
default=True,
)
set_armature_display_to_show_in_front: BoolProperty( # type: ignore[valid-type]
name='Set an imported armature display to show "In-Front"',
update=import_vrm_update_addon_preferences,
default=True,
)
set_armature_bone_shape_to_default: BoolProperty( # type: ignore[valid-type]
name="Set an imported bone shape to default",
update=import_vrm_update_addon_preferences,
default=True,
)
enable_mtoon_outline_preview: BoolProperty( # type: ignore[valid-type]
name="Enable MToon Outline Preview",
default=True,
)
def execute(self, context: Context) -> set[str]:
try:
filepath = Path(self.filepath)
if not filepath.is_file():
return {"CANCELLED"}
if self.use_addon_preferences:
copy_import_preferences(
source=get_preferences(context), destination=self
)
license_error = None
try:
return import_vrm(
filepath,
self,
context,
license_validation=True,
)
except LicenseConfirmationRequiredError as e:
license_error = e # Prevent traceback dump on another exception
except Exception:
show_error_dialog(
pgettext("Failed to import VRM."),
traceback.format_exc(),
)
raise
logger.warning(license_error.description())
execution_context = "INVOKE_DEFAULT"
import_anyway = False
if environ.get("BLENDER_VRM_AUTOMATIC_LICENSE_CONFIRMATION") == "true":
execution_context = "EXEC_DEFAULT"
import_anyway = True
return ops.wm.vrm_license_warning(
execution_context,
import_anyway=import_anyway,
license_confirmations=license_error.license_confirmations(),
filepath=str(filepath),
**create_import_preferences_dict(self),
)
def invoke(self, context: Context, event: Event) -> set[str]:
self.use_addon_preferences = True
copy_import_preferences(source=get_preferences(context), destination=self)
if "gltf" not in dir(bpy.ops.import_scene):
return ops.wm.vrm_gltf2_addon_disabled_warning("INVOKE_DEFAULT")
return ImportHelper.invoke(self, context, event)
def draw(self, _context: Context) -> None:
pass # Is needed to get panels available
if TYPE_CHECKING:
# This code is auto generated.
# To regenerate, run the `uv run tools/property_typing.py` command.
filter_glob: str # type: ignore[no-redef]
use_addon_preferences: bool # type: ignore[no-redef]
extract_textures_into_folder: bool # type: ignore[no-redef]
make_new_texture_folder: bool # type: ignore[no-redef]
set_shading_type_to_material_on_import: bool # type: ignore[no-redef]
set_view_transform_to_standard_on_import: bool # type: ignore[no-redef]
set_armature_display_to_wire: bool # type: ignore[no-redef]
set_armature_display_to_show_in_front: bool # type: ignore[no-redef]
set_armature_bone_shape_to_default: bool # type: ignore[no-redef]
enable_mtoon_outline_preview: bool # type: ignore[no-redef]
class VRM_PT_import_file_browser_tool_props(Panel):
bl_idname = "VRM_PT_import_file_browser_tool_props"
bl_space_type = "FILE_BROWSER"
bl_region_type = "TOOL_PROPS"
bl_parent_id = "FILE_PT_operator"
bl_label = ""
bl_options: AbstractSet[str] = {"HIDE_HEADER"}
@classmethod
def poll(cls, context: Context) -> bool:
space_data = context.space_data
if not isinstance(space_data, SpaceFileBrowser):
return False
return space_data.active_operator.bl_idname == "IMPORT_SCENE_OT_vrm"
def draw(self, context: Context) -> None:
space_data = context.space_data
if not isinstance(space_data, SpaceFileBrowser):
return
operator = space_data.active_operator
if not isinstance(operator, IMPORT_SCENE_OT_vrm):
return
layout = self.layout
draw_import_preferences_layout(operator, layout)
class VRM_PT_import_unsupported_blender_version_warning(Panel):
bl_idname = "VRM_PT_import_unsupported_blender_version_warning"
bl_space_type = "FILE_BROWSER"
bl_region_type = "TOOL_PROPS"
bl_parent_id = "FILE_PT_operator"
bl_label = ""
bl_options: AbstractSet[str] = {"HIDE_HEADER"}
@classmethod
def poll(cls, context: Context) -> bool:
space_data = context.space_data
if not isinstance(space_data, SpaceFileBrowser):
return False
if space_data.active_operator.bl_idname != "IMPORT_SCENE_OT_vrm":
return False
return bool(version.panel_warning_message())
def draw(self, _context: Context) -> None:
warning_message = version.panel_warning_message()
if warning_message is None:
return
box = self.layout.box()
warning_column = box.column(align=True)
for index, warning_line in enumerate(warning_message.splitlines()):
warning_column.label(
text=warning_line,
translate=False,
icon="NONE" if index else "ERROR",
)
class WM_OT_vrm_license_confirmation(Operator):
bl_label = "VRM License Confirmation"
bl_idname = "wm.vrm_license_warning"
bl_options: AbstractSet[str] = {"REGISTER"}
filepath: StringProperty() # type: ignore[valid-type]
license_confirmations: CollectionProperty(type=LicenseConfirmation) # type: ignore[valid-type]
import_anyway: BoolProperty( # type: ignore[valid-type]
name="Import Anyway",
)
extract_textures_into_folder: BoolProperty() # type: ignore[valid-type]
make_new_texture_folder: BoolProperty() # type: ignore[valid-type]
set_shading_type_to_material_on_import: BoolProperty() # type: ignore[valid-type]
set_view_transform_to_standard_on_import: BoolProperty() # type: ignore[valid-type]
set_armature_display_to_wire: BoolProperty() # type: ignore[valid-type]
set_armature_display_to_show_in_front: BoolProperty() # type: ignore[valid-type]
set_armature_bone_shape_to_default: BoolProperty() # type: ignore[valid-type]
enable_mtoon_outline_preview: BoolProperty() # type: ignore[valid-type]
def execute(self, context: Context) -> set[str]:
try:
filepath = Path(self.filepath)
if not filepath.is_file():
return {"CANCELLED"}
if not self.import_anyway:
return {"CANCELLED"}
return import_vrm(
filepath,
self,
context,
license_validation=False,
)
except Exception:
show_error_dialog(
pgettext("Failed to import VRM."),
traceback.format_exc(),
)
raise
def invoke(self, context: Context, _event: Event) -> set[str]:
return context.window_manager.invoke_props_dialog(self, width=600)
def draw(self, _context: Context) -> None:
layout = self.layout
layout.label(text=self.filepath, translate=False)
for license_confirmation in self.license_confirmations:
box = layout.box()
for line in license_confirmation.message.split("\n"):
box.label(text=line, translate=False, icon="INFO")
if license_confirmation.json_key:
box.label(
text=pgettext("For more information please check following URL.")
)
if VRM_OT_open_url_in_web_browser.supported(license_confirmation.url):
split = box.split(factor=0.85, align=True)
split.prop(
license_confirmation,
"url",
text=license_confirmation.json_key,
translate=False,
)
op = layout_operator(split, VRM_OT_open_url_in_web_browser)
op.url = license_confirmation.url
else:
box.prop(
license_confirmation,
"url",
text=license_confirmation.json_key,
translate=False,
)
layout.prop(self, "import_anyway")
if TYPE_CHECKING:
# This code is auto generated.
# To regenerate, run the `uv run tools/property_typing.py` command.
filepath: str # type: ignore[no-redef]
license_confirmations: CollectionPropertyProtocol[ # type: ignore[no-redef]
LicenseConfirmation
]
import_anyway: bool # type: ignore[no-redef]
extract_textures_into_folder: bool # type: ignore[no-redef]
make_new_texture_folder: bool # type: ignore[no-redef]
set_shading_type_to_material_on_import: bool # type: ignore[no-redef]
set_view_transform_to_standard_on_import: bool # type: ignore[no-redef]
set_armature_display_to_wire: bool # type: ignore[no-redef]
set_armature_display_to_show_in_front: bool # type: ignore[no-redef]
set_armature_bone_shape_to_default: bool # type: ignore[no-redef]
enable_mtoon_outline_preview: bool # type: ignore[no-redef]
def import_vrm(
filepath: Path,
preferences: ImportPreferencesProtocol,
context: Context,
*,
license_validation: bool,
) -> set[str]:
parse_result = parse_vrm_json(filepath, license_validation=license_validation)
if parse_result.spec_version_number >= (1,):
vrm_importer: AbstractBaseVrmImporter = Vrm1Importer(
context,
parse_result,
preferences,
)
else:
vrm_importer = Vrm0Importer(
context,
parse_result,
preferences,
)
vrm_importer.import_vrm()
return {"FINISHED"}
def menu_import(
menu_op: Operator, _context: Context
) -> None: # Same as test/blender_io.py for now
vrm_import_op = layout_operator(
menu_op.layout, IMPORT_SCENE_OT_vrm, text="VRM (.vrm)"
)
vrm_import_op.use_addon_preferences = True
vrma_import_op = layout_operator(
menu_op.layout, IMPORT_SCENE_OT_vrma, text="VRM Animation (.vrma)"
)
vrma_import_op.armature_object_name = ""
class IMPORT_SCENE_OT_vrma(Operator, ImportHelper):
bl_idname = "import_scene.vrma"
bl_label = "Open"
bl_description = "Import VRM Animation"
bl_options: AbstractSet[str] = {"REGISTER", "UNDO"}
filename_ext = ".vrma"
filter_glob: StringProperty( # type: ignore[valid-type]
default="*.vrma",
options={"HIDDEN"},
)
armature_object_name: StringProperty( # type: ignore[valid-type]
options={"HIDDEN"},
)
def execute(self, context: Context) -> set[str]:
try:
errors = WM_OT_vrma_import_prerequisite.detect_errors(
context, self.armature_object_name
)
if errors:
for error in errors:
logger.error(error)
return {"CANCELLED"}
filepath = Path(self.filepath)
if not filepath.is_file():
return {"CANCELLED"}
armature = None
if self.armature_object_name:
armature = context.blend_data.objects.get(self.armature_object_name)
if not armature:
return {"CANCELLED"}
if not armature:
armature = search.current_armature(context)
if not armature:
added = ops.icyp.make_basic_armature()
if added != {"FINISHED"}:
return added
armature = search.current_armature(context)
if not armature:
return {"CANCELLED"}
armature_data = armature.data
if not isinstance(armature_data, Armature):
return {"CANCELLED"}
ext = get_armature_extension(armature_data)
ext.spec_version = ext.SPEC_VERSION_VRM1
return UniVrmVrmAnimationImporter.execute(context, filepath, armature)
except Exception:
show_error_dialog(
pgettext("Failed to import VRM Animation."),
traceback.format_exc(),
)
raise
def invoke(self, context: Context, event: Event) -> set[str]:
if WM_OT_vrma_import_prerequisite.detect_errors(
context, self.armature_object_name
):
return ops.wm.vrma_import_prerequisite(
"INVOKE_DEFAULT",
armature_object_name=self.armature_object_name,
)
return ImportHelper.invoke(self, context, event)
if TYPE_CHECKING:
# This code is auto generated.
# To regenerate, run the `uv run tools/property_typing.py` command.
filter_glob: str # type: ignore[no-redef]
armature_object_name: str # type: ignore[no-redef]
class WM_OT_vrma_import_prerequisite(Operator):
bl_label = "VRM Animation Import Prerequisite"
bl_idname = "wm.vrma_import_prerequisite"
bl_options: AbstractSet[str] = {"REGISTER", "UNDO"}
armature_object_name: StringProperty( # type: ignore[valid-type]
options={"HIDDEN"},
)
armature_object_name_candidates: CollectionProperty( # type: ignore[valid-type]
type=StringPropertyGroup,
options={"HIDDEN"},
)
@staticmethod
def detect_errors(context: Context, armature_object_name: str) -> list[str]:
error_messages: list[str] = []
if not armature_object_name:
armature = search.current_armature(context)
else:
armature = context.blend_data.objects.get(armature_object_name)
if not armature:
return error_messages
armature_data = armature.data
if not isinstance(armature_data, Armature):
return error_messages
ext = get_armature_extension(armature_data)
if get_armature_extension(armature_data).is_vrm1():
humanoid = ext.vrm1.humanoid
if not humanoid.human_bones.all_required_bones_are_assigned():
error_messages.append(pgettext("Please assign required human bones"))
else:
error_messages.append(pgettext("Please set the version of VRM to 1.0"))
return error_messages
def execute(self, _context: Context) -> set[str]:
return ops.import_scene.vrma(
"INVOKE_DEFAULT", armature_object_name=self.armature_object_name
)
def invoke(self, context: Context, _event: Event) -> set[str]:
if not self.armature_object_name:
armature_object = search.current_armature(context)
if armature_object:
self.armature_object_name = armature_object.name
self.armature_object_name_candidates.clear()
for obj in context.blend_data.objects:
if obj.type != "ARMATURE":
continue
candidate = self.armature_object_name_candidates.add()
candidate.value = obj.name
return context.window_manager.invoke_props_dialog(self, width=800)
def draw(self, context: Context) -> None:
layout = self.layout
layout.label(
text="VRM Animation import requires a VRM 1.0 armature",
icon="INFO",
)
error_messages = WM_OT_vrma_import_prerequisite.detect_errors(
context, self.armature_object_name
)
layout.prop_search(
self,
"armature_object_name",
self,
"armature_object_name_candidates",
icon="OUTLINER_OB_ARMATURE",
text="Armature to be animated",
)
if error_messages:
error_column = layout.box().column(align=True)
for error_message in error_messages:
error_column.label(text=error_message, icon="ERROR", translate=False)
open_op = layout_operator(
layout,
VRM_OT_open_url_in_web_browser,
icon="URL",
text="Open help in a Web Browser",
)
open_op.url = pgettext("https://vrm-addon-for-blender.info/en-us/animation/")
if TYPE_CHECKING:
# This code is auto generated.
# To regenerate, run the `uv run tools/property_typing.py` command.
armature_object_name: str # type: ignore[no-redef]
armature_object_name_candidates: CollectionPropertyProtocol[ # type: ignore[no-redef]
StringPropertyGroup
]

View File

@@ -0,0 +1,198 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
import contextlib
from typing import Optional
from urllib.parse import ParseResult, parse_qsl, urlparse
from bpy.app.translations import pgettext
from ..common.convert import Json
class LicenseConfirmationRequiredProp:
def __init__(
self,
url: Optional[str],
json_key: Optional[str],
message: str,
) -> None:
self.url = url
self.json_key = json_key
self.message = message
def description(self) -> str:
return f"""class=LicenseConfirmationRequired
url={self.url}
json_key={self.json_key}
message={self.message}
"""
class LicenseConfirmationRequiredError(Exception):
def __init__(self, props: list[LicenseConfirmationRequiredProp]) -> None:
self.props = props
super().__init__(self.description())
def description(self) -> str:
return "\n".join(prop.description() for prop in self.props)
def license_confirmations(self) -> list[dict[str, str]]:
return [
{
"name": "LicenseConfirmation" + str(index),
"url": prop.url or "",
"json_key": prop.json_key or "",
"message": prop.message or "",
}
for index, prop in enumerate(self.props)
]
def validate_license_url(
url_str: str, json_key: str, props: list[LicenseConfirmationRequiredProp]
) -> None:
if not url_str:
return
url = None
with contextlib.suppress(ValueError):
url = urlparse(url_str)
if url:
query_dict = dict(parse_qsl(url.query))
if validate_vroid_hub_license_url(
url, query_dict, json_key, props
) or validate_uni_virtual_license_url(url, query_dict, json_key, props):
return
props.append(
LicenseConfirmationRequiredProp(
url_str,
json_key,
pgettext("This VRM is not allowed to be edited. Please check its license"),
)
)
def validate_vroid_hub_license_url(
url: ParseResult,
query_dict: dict[str, str],
json_key: str,
props: list[LicenseConfirmationRequiredProp],
) -> bool:
# https://hub.vroid.com/en/license?allowed_to_use_user=everyone&characterization_allowed_user=everyone&corporate_commercial_use=allow&credit=unnecessary&modification=allow&personal_commercial_use=profit&redistribution=allow&sexual_expression=allow&version=1&violent_expression=allow
if url.hostname != "hub.vroid.com" or not url.path.endswith("/license"):
return False
if query_dict.get("modification") == "disallow":
props.append(
LicenseConfirmationRequiredProp(
url.geturl(),
json_key,
pgettext(
'This VRM is licensed by VRoid Hub License "Alterations: No".'
),
)
)
return True
def validate_uni_virtual_license_url(
url: ParseResult,
query_dict: dict[str, str],
json_key: str,
props: list[LicenseConfirmationRequiredProp],
) -> bool:
# https://uv-license.com/en/license?utf8=%E2%9C%93&pcu=true
if url.hostname != "uv-license.com" or not url.path.endswith("/license"):
return False
if query_dict.get("remarks") == "true":
props.append(
LicenseConfirmationRequiredProp(
url.geturl(),
json_key,
pgettext('This VRM is licensed by UV License with "Remarks".'),
)
)
return True
def validate_vrm1_license(
json_dict: dict[str, Json], _confirmations: list[LicenseConfirmationRequiredProp]
) -> None:
extensions_dict = json_dict.get("extensions")
if not isinstance(extensions_dict, dict):
return
vrmc_vrm_dict = extensions_dict.get("VRMC_vrm")
if not isinstance(vrmc_vrm_dict, dict):
return
return
def validate_vrm0_license(
json_dict: dict[str, Json], confirmations: list[LicenseConfirmationRequiredProp]
) -> None:
extensions_dict = json_dict.get("extensions")
if not isinstance(extensions_dict, dict):
return
vrm_dict = extensions_dict.get("VRM")
if not isinstance(vrm_dict, dict):
return
meta_dict = vrm_dict.get("meta")
if not isinstance(meta_dict, dict):
return
license_name = meta_dict.get("licenseName")
if license_name in [
# https://github.com/vrm-c/vrm-specification/blob/master/specification/0.0/schema/vrm.meta.schema.json#L56
"CC_BY_ND",
"CC_BY_NC_ND",
]:
confirmations.append(
LicenseConfirmationRequiredProp(
None,
None,
pgettext(
"This VRM is not allowed to be edited. Please check its license"
),
)
)
if license_name == "Other":
other_license_url = meta_dict.get("otherLicenseUrl")
if other_license_url is None:
confirmations.append(
LicenseConfirmationRequiredProp(
None,
None,
pgettext(
'The VRM selects "Other" license but no license url is found.'
),
)
)
else:
validate_license_url(
str(other_license_url), "otherLicenseUrl", confirmations
)
other_permission_url = meta_dict.get("otherPermissionUrl")
if other_permission_url is not None:
validate_license_url(
str(other_permission_url),
"otherPermissionUrl",
confirmations,
)
def validate_license(
json_dict: dict[str, Json], spec_version_number: tuple[int, int]
) -> None:
"""Validate that the license is not a non-modifiable license, such as CC_ND."""
confirmations: list[LicenseConfirmationRequiredProp] = []
if tuple(spec_version_number) >= (1, 0):
validate_vrm1_license(json_dict, confirmations)
else:
validate_vrm0_license(json_dict, confirmations)
if confirmations:
raise LicenseConfirmationRequiredError(confirmations)

View File

@@ -0,0 +1,781 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
import itertools
import math
from dataclasses import dataclass
from pathlib import Path
from bpy.types import Armature, Context, Object
from mathutils import Matrix, Quaternion, Vector
from ..common import convert
from ..common.convert import Json
from ..common.debug import dump
from ..common.gltf import (
parse_glb,
read_accessor_as_animation_sampler_input,
read_accessor_as_animation_sampler_rotation_output,
read_accessor_as_animation_sampler_translation_output,
)
from ..common.logger import get_logger
from ..common.rotation import (
get_rotation_as_quaternion,
insert_rotation_keyframe,
set_rotation_without_mode_change,
)
from ..common.vrm1.human_bone import HumanBoneName
from ..common.workspace import save_workspace
from ..editor.extension import get_armature_extension
from ..editor.t_pose import setup_humanoid_t_pose
logger = get_logger(__name__)
class UniVrmVrmAnimationImporter:
"""Import VRM animation. The import result is the same as UniVRM.
https://github.com/vrm-c/UniVRM
"""
@staticmethod
def execute(context: Context, path: Path, armature: Object) -> set[str]:
armature_data = armature.data
if not isinstance(armature_data, Armature):
return {"CANCELLED"}
ext = get_armature_extension(armature_data)
humanoid = ext.vrm1.humanoid
if not humanoid.human_bones.all_required_bones_are_assigned():
return {"CANCELLED"}
with (
setup_humanoid_t_pose(context, armature),
save_workspace(context, armature, mode="POSE"),
):
result = import_vrm_animation(context, path, armature)
look_at_preview_enabled = ext.vrm1.look_at.enable_preview
ext.vrm1.look_at.enable_preview = look_at_preview_enabled
return result
def find_root_node_index(
node_dicts: list[Json], node_index: int, skip_node_indices: set[int]
) -> int:
for parent_node_index, node_dict in enumerate(node_dicts):
if parent_node_index in skip_node_indices:
continue
if not isinstance(node_dict, dict):
continue
child_node_indices = node_dict.get("children")
if not isinstance(child_node_indices, list):
continue
for child_node_index in child_node_indices:
if child_node_index in skip_node_indices:
continue
if child_node_index != node_index:
continue
skip_node_indices.add(node_index)
return find_root_node_index(
node_dicts, parent_node_index, skip_node_indices
)
return node_index
def import_vrm_animation(context: Context, path: Path, armature: Object) -> set[str]:
if not path.exists():
return {"CANCELLED"}
armature_data = armature.data
if not isinstance(armature_data, Armature):
return {"CANCELLED"}
humanoid = get_armature_extension(armature_data).vrm1.humanoid
if not humanoid.human_bones.all_required_bones_are_assigned():
return {"CANCELLED"}
look_at = get_armature_extension(armature_data).vrm1.look_at
vrma_dict, buffer0_bytes = parse_glb(path.read_bytes())
node_dicts = vrma_dict.get("nodes")
if not isinstance(node_dicts, list) or not node_dicts:
return {"CANCELLED"}
animation_dicts = vrma_dict.get("animations")
if not isinstance(animation_dicts, list) or not animation_dicts:
return {"CANCELLED"}
animation_dict = animation_dicts[0]
if not isinstance(animation_dict, dict):
return {"CANCELLED"}
animation_channel_dicts = animation_dict.get("channels")
if not isinstance(animation_channel_dicts, list) or not animation_channel_dicts:
return {"CANCELLED"}
animation_sampler_dicts = animation_dict.get("samplers")
if not isinstance(animation_sampler_dicts, list) or not animation_sampler_dicts:
return {"CANCELLED"}
extensions_dict = vrma_dict.get("extensions")
if not isinstance(extensions_dict, dict):
return {"CANCELLED"}
vrmc_vrm_animation_dict = extensions_dict.get("VRMC_vrm_animation")
if not isinstance(vrmc_vrm_animation_dict, dict):
return {"CANCELLED"}
humanoid_dict = vrmc_vrm_animation_dict.get("humanoid")
if not isinstance(humanoid_dict, dict):
return {"CANCELLED"}
human_bones_dict = humanoid_dict.get("humanBones")
if not isinstance(human_bones_dict, dict):
return {"CANCELLED"}
hips_dict = human_bones_dict.get("hips")
if not isinstance(hips_dict, dict):
return {"CANCELLED"}
hips_node_index = hips_dict.get("node")
if not isinstance(hips_node_index, int):
return {"CANCELLED"}
if not 0 <= hips_node_index < len(node_dicts):
return {"CANCELLED"}
hips_node_dict = node_dicts[hips_node_index]
if not isinstance(hips_node_dict, dict):
return {"CANCELLED"}
expression_name_to_node_index: dict[str, int] = {}
expressions_dict = vrmc_vrm_animation_dict.get("expressions")
if isinstance(expressions_dict, dict):
preset_dict = expressions_dict.get("preset")
if isinstance(preset_dict, dict):
for name, node_dict in preset_dict.items():
if not isinstance(node_dict, dict):
continue
node_index = node_dict.get("node")
if not isinstance(node_index, int):
continue
expression_name_to_node_index[name] = node_index
custom_dict = expressions_dict.get("custom")
if isinstance(custom_dict, dict):
for name, node_dict in custom_dict.items():
if not isinstance(node_dict, dict):
continue
if name in expression_name_to_node_index:
continue
node_index = node_dict.get("node")
if not isinstance(node_index, int):
continue
expression_name_to_node_index[name] = node_index
node_index_to_human_bone_name: dict[int, HumanBoneName] = {}
for human_bone_name_str, human_bone_dict in human_bones_dict.items():
if not isinstance(human_bone_dict, dict):
continue
node_index = human_bone_dict.get("node")
if not isinstance(node_index, int):
continue
if not 0 <= node_index < len(node_dicts):
continue
human_bone_name = HumanBoneName.from_str(human_bone_name_str)
if not human_bone_name:
continue
node_index_to_human_bone_name[node_index] = human_bone_name
root_node_index = find_root_node_index(node_dicts, hips_node_index, set())
node_rest_pose_trees = NodeRestPoseTree.build(
node_dicts, root_node_index, is_root=True
)
if len(node_rest_pose_trees) != 1:
return {"CANCELLED"}
node_rest_pose_tree = node_rest_pose_trees[0]
accessor_dicts = vrma_dict.get("accessors")
if not isinstance(accessor_dicts, list):
return {"CANCELLED"}
buffer_view_dicts = vrma_dict.get("bufferViews")
if not isinstance(buffer_view_dicts, list):
return {"CANCELLED"}
buffer_dicts = vrma_dict.get("buffers")
if not isinstance(buffer_dicts, list):
return {"CANCELLED"}
humanoid_action = context.blend_data.actions.new(name="Humanoid")
if not armature.animation_data:
armature.animation_data_create()
armature_animation_data = armature.animation_data
if not armature_animation_data:
message = "armature.animation_data is None"
raise ValueError(message)
armature_animation_data.action = humanoid_action
expression_action = context.blend_data.actions.new(name="Expressions")
if not armature_data.animation_data:
armature_data.animation_data_create()
armature_data_animation_data = armature_data.animation_data
if not armature_data_animation_data:
message = "armature_data.animation_data is None"
raise ValueError(message)
armature_data_animation_data.action = expression_action
node_index_to_translation_keyframes: dict[
int, tuple[tuple[float, Vector], ...]
] = {}
node_index_to_rotation_keyframes: dict[
int, tuple[tuple[float, Quaternion], ...]
] = {}
for animation_channel_dict in animation_channel_dicts:
if not isinstance(animation_channel_dict, dict):
continue
target_dict = animation_channel_dict.get("target")
if not isinstance(target_dict, dict):
continue
node_index = target_dict.get("node")
if not isinstance(node_index, int):
continue
if not 0 <= node_index < len(node_dicts):
continue
animation_path = target_dict.get("path")
animation_sampler_index = animation_channel_dict.get("sampler")
if not isinstance(animation_sampler_index, int):
continue
if not 0 <= animation_sampler_index < len(animation_sampler_dicts):
continue
animation_sampler_dict = animation_sampler_dicts[animation_sampler_index]
if not isinstance(animation_sampler_dict, dict):
continue
input_index = animation_sampler_dict.get("input")
if not isinstance(input_index, int):
continue
if not 0 <= input_index < len(accessor_dicts):
continue
input_accessor_dict = accessor_dicts[input_index]
if not isinstance(input_accessor_dict, dict):
continue
output_index = animation_sampler_dict.get("output")
if not isinstance(output_index, int):
continue
if not 0 <= output_index < len(accessor_dicts):
continue
output_accessor_dict = accessor_dicts[output_index]
if not isinstance(output_accessor_dict, dict):
continue
if animation_path == "translation":
timestamps = read_accessor_as_animation_sampler_input(
input_accessor_dict, buffer_view_dicts, buffer_dicts, buffer0_bytes
)
if timestamps is None:
continue
translations = read_accessor_as_animation_sampler_translation_output(
output_accessor_dict, buffer_view_dicts, buffer_dicts, buffer0_bytes
)
if translations is None:
continue
translation_keyframes = tuple(sorted(zip(timestamps, translations)))
node_index_to_translation_keyframes[node_index] = translation_keyframes
elif animation_path == "rotation":
timestamps = read_accessor_as_animation_sampler_input(
input_accessor_dict, buffer_view_dicts, buffer_dicts, buffer0_bytes
)
if timestamps is None:
continue
rotations = read_accessor_as_animation_sampler_rotation_output(
output_accessor_dict, buffer_view_dicts, buffer_dicts, buffer0_bytes
)
if rotations is None:
continue
rotation_keyframes = tuple(sorted(zip(timestamps, rotations)))
node_index_to_rotation_keyframes[node_index] = rotation_keyframes
expression_name_to_default_preview_value: dict[str, float] = {}
expression_name_to_translation_keyframes: dict[
str, tuple[tuple[float, Vector], ...]
] = {}
for expression_name, node_index in expression_name_to_node_index.items():
if not 0 <= node_index < len(node_dicts):
continue
node_dict = node_dicts[node_index]
if not isinstance(node_dict, dict):
continue
translation = node_dict.get("translation")
if isinstance(translation, list) and translation:
default_preview_value = translation[0] # TODO: In case of Matrix
if not isinstance(default_preview_value, (float, int)):
default_preview_value = 0.0
else:
default_preview_value = 0.0
expression_name_to_default_preview_value[expression_name] = (
default_preview_value
)
expression_translation_keyframes = node_index_to_translation_keyframes.get(
node_index
)
if expression_translation_keyframes is None:
continue
expression_name_to_translation_keyframes[expression_name] = (
expression_translation_keyframes
)
timestamps = [
timestamp
for keyframes in itertools.chain(
node_index_to_translation_keyframes.values(),
node_index_to_rotation_keyframes.values(),
)
for (timestamp, _) in keyframes
]
timestamps.sort()
if not timestamps:
return {"CANCELLED"}
first_timestamp = timestamps[0]
last_timestamp = timestamps[-1]
logger.debug(
"first_timestamp=%s ... last_timestamp=%s",
first_timestamp,
last_timestamp,
)
look_at_target_object = None
look_at_translation_keyframes = None
look_at_dict = vrmc_vrm_animation_dict.get("lookAt")
if isinstance(look_at_dict, dict):
look_at_target_node_index = look_at_dict.get("node")
if isinstance(
look_at_target_node_index, int
) and 0 <= look_at_target_node_index < len(node_dicts):
look_at_translation_keyframes = node_index_to_translation_keyframes.get(
look_at_target_node_index
)
look_at_target_node_dict = node_dicts[look_at_target_node_index]
if look_at_translation_keyframes and isinstance(
look_at_target_node_dict, dict
):
look_at_target_translation = convert.float3_or_none(
look_at_target_node_dict.get("translation")
)
look_at_target_name = look_at_target_node_dict.get("name")
if not isinstance(look_at_target_name, str) or not look_at_target_name:
look_at_target_name = "LookAtTarget"
if look_at_target_translation is not None:
look_at_target_object = context.blend_data.objects.new(
name=look_at_target_name, object_data=None
)
look_at_target_object.empty_display_size = 0.125
x, y, z = look_at_target_translation
look_at_target_object.location = Vector((x, -z, y))
context.scene.collection.objects.link(look_at_target_object)
look_at.enable_preview = True
look_at.preview_target_bpy_object = look_at_target_object
first_zero_origin_frame_count: int = math.floor(
first_timestamp * context.scene.render.fps / context.scene.render.fps_base
)
if abs(last_timestamp - first_timestamp) > 0:
last_zero_origin_frame_count: int = math.ceil(
last_timestamp * context.scene.render.fps / context.scene.render.fps_base
)
else:
last_zero_origin_frame_count = first_zero_origin_frame_count
for zero_origin_frame_count in range(
first_zero_origin_frame_count, last_zero_origin_frame_count + 1
):
timestamp = (
zero_origin_frame_count
* context.scene.render.fps_base
/ context.scene.render.fps
)
frame_count = zero_origin_frame_count + 1
assign_humanoid_keyframe(
armature,
node_rest_pose_tree,
node_index_to_human_bone_name,
node_index_to_translation_keyframes,
node_index_to_rotation_keyframes,
frame_count,
timestamp,
humanoid_parent_rest_world_matrix=Matrix(),
intermediate_rest_local_matrix=Matrix(),
intermediate_pose_local_matrix=Matrix(),
parent_node_rest_pose_world_matrix=Matrix(),
)
assign_expression_keyframe(
armature_data,
expression_name_to_default_preview_value,
expression_name_to_translation_keyframes,
frame_count,
timestamp,
)
if look_at_target_object and look_at_translation_keyframes:
assign_look_at_keyframe(
look_at_target_object,
look_at_translation_keyframes,
frame_count,
timestamp,
)
# This process should be done last because bone state changes occur.
# If translation is assigned to hips and hips is connected to parent
# with "use_connect", the movement animation will not be reflected, so
# we need to disconnect it
if node_index_to_translation_keyframes.get(hips_node_index):
with save_workspace(context, armature, mode="EDIT"):
armature_data = armature.data
if not isinstance(armature_data, Armature):
raise TypeError
hips_bone = armature_data.edit_bones.get(
humanoid.human_bones.hips.node.bone_name
)
if hips_bone and hips_bone.use_connect:
hips_bone.use_connect = False
return {"FINISHED"}
def assign_look_at_keyframe(
look_at_target_object: Object,
translation_keyframes: tuple[tuple[float, Vector], ...],
frame_count: int,
timestamp: float,
) -> None:
if not translation_keyframes:
return
animation_translation = None
begin_timestamp, begin_translation = translation_keyframes[0]
for end_timestamp, end_translation in translation_keyframes:
if end_timestamp >= timestamp:
timestamp_duration = end_timestamp - begin_timestamp
if timestamp_duration > 0:
animation_translation = (
begin_translation
+ (end_translation - begin_translation)
* (timestamp - begin_timestamp)
/ timestamp_duration
)
else:
animation_translation = begin_translation
break
begin_timestamp = end_timestamp
begin_translation = end_translation
if animation_translation is None:
animation_translation = begin_translation
current_location = look_at_target_object.location.copy()
look_at_target_object.location = animation_translation
look_at_target_object.keyframe_insert(data_path="location", frame=frame_count)
look_at_target_object.location = current_location
def assign_expression_keyframe(
armature_data: Armature,
expression_name_to_default_preview_value: dict[str, float],
expression_name_to_translation_keyframes: dict[
str, tuple[tuple[float, Vector], ...]
],
frame_count: int,
timestamp: float,
) -> None:
expressions = get_armature_extension(armature_data).vrm1.expressions
expression_name_to_expression = expressions.all_name_to_expression_dict()
for (
expression_name,
translation_keyframes,
) in expression_name_to_translation_keyframes.items():
if expression_name in [
"lookUp",
"lookDown",
"lookLeft",
"lookRight",
]:
continue
expression = expression_name_to_expression.get(expression_name)
if not expression:
continue
if translation_keyframes:
preview = None
begin_timestamp, begin_translation = translation_keyframes[0]
for end_timestamp, end_translation in translation_keyframes:
if end_timestamp >= timestamp:
timestamp_duration = end_timestamp - begin_timestamp
if timestamp_duration > 0:
preview = (
begin_translation.x
+ (end_translation.x - begin_translation.x)
* (timestamp - begin_timestamp)
/ timestamp_duration
)
else:
preview = begin_translation.x
break
begin_timestamp = end_timestamp
begin_translation = end_translation
if preview is None:
preview = begin_translation.x
else:
preview = (
expression_name_to_default_preview_value.get(expression_name) or 0.0
)
current_preview = expression.preview
expression.preview = preview
expression.keyframe_insert(data_path="preview", frame=frame_count)
expression.preview = current_preview
@dataclass(frozen=True)
class NodeRestPoseTree:
node_index: int
local_matrix: Matrix
children: tuple["NodeRestPoseTree", ...]
is_root: bool
@staticmethod
def build(
node_dicts: list[Json],
node_index: int,
*,
is_root: bool,
) -> list["NodeRestPoseTree"]:
if not 0 <= node_index < len(node_dicts):
return []
node_dict = node_dicts[node_index]
if not isinstance(node_dict, dict):
return []
translation_float3 = convert.float3_or_none(node_dict.get("translation"))
if translation_float3:
x, y, z = translation_float3
translation = Vector((x, -z, y))
else:
translation = Vector((0.0, 0.0, 0.0))
rotation_float4 = convert.float4_or_none(node_dict.get("rotation"))
if rotation_float4:
x, y, z, w = rotation_float4
rotation = Quaternion((w, x, -z, y)).normalized()
else:
rotation = Quaternion()
scale_float3 = convert.float3_or_none(node_dict.get("scale"))
if scale_float3:
x, y, z = scale_float3
scale = Vector((x, z, y))
else:
scale = Vector((1, 1, 1))
local_matrix = (
Matrix.Translation(translation)
@ rotation.to_matrix().to_4x4()
@ Matrix.Diagonal(scale).to_4x4()
)
# TODO: Is it an Euler angle in the case of 3 elements?
# TODO: Decompose if it's a Matrix
child_indices = node_dict.get("children")
if isinstance(child_indices, list):
children = tuple(
itertools.chain(
*(
NodeRestPoseTree.build(node_dicts, child_index, is_root=False)
for child_index in child_indices
if isinstance(child_index, int)
)
)
)
else:
children = ()
return [
NodeRestPoseTree(
node_index=node_index,
local_matrix=local_matrix,
children=children,
is_root=is_root,
)
]
def assign_humanoid_keyframe(
armature: Object,
node_rest_pose_tree: NodeRestPoseTree,
node_index_to_human_bone_name: dict[int, HumanBoneName],
node_index_to_translation_keyframes: dict[int, tuple[tuple[float, Vector], ...]],
node_index_to_rotation_keyframes: dict[int, tuple[tuple[float, Quaternion], ...]],
frame_count: int,
timestamp: float,
humanoid_parent_rest_world_matrix: Matrix,
intermediate_rest_local_matrix: Matrix,
intermediate_pose_local_matrix: Matrix,
parent_node_rest_pose_world_matrix: Matrix,
) -> None:
armature_data = armature.data
if not isinstance(armature_data, Armature):
return
translation_keyframes = node_index_to_translation_keyframes.get(
node_rest_pose_tree.node_index
)
if translation_keyframes:
keyframe_translation = None
begin_timestamp, begin_translation = translation_keyframes[0]
for end_timestamp, end_translation in translation_keyframes:
if end_timestamp >= timestamp:
timestamp_duration = end_timestamp - begin_timestamp
if timestamp_duration > 0:
keyframe_translation = begin_translation.lerp(
end_translation,
(timestamp - begin_timestamp) / timestamp_duration,
)
else:
keyframe_translation = begin_translation
break
begin_timestamp = end_timestamp
begin_translation = end_translation
if keyframe_translation is None:
keyframe_translation = begin_translation
else:
keyframe_translation = node_rest_pose_tree.local_matrix.to_translation()
rotation_keyframes = node_index_to_rotation_keyframes.get(
node_rest_pose_tree.node_index
)
if rotation_keyframes:
keyframe_rotation = None
begin_timestamp, begin_rotation = rotation_keyframes[0]
for end_timestamp, end_rotation in rotation_keyframes:
if end_timestamp >= timestamp:
timestamp_duration = end_timestamp - begin_timestamp
if timestamp_duration > 0:
keyframe_rotation = begin_rotation.slerp(
end_rotation, (timestamp - begin_timestamp) / timestamp_duration
)
else:
keyframe_rotation = begin_rotation
break
begin_timestamp = end_timestamp
begin_rotation = end_rotation
if keyframe_rotation is None:
keyframe_rotation = begin_rotation
else:
keyframe_rotation = node_rest_pose_tree.local_matrix.to_quaternion()
humanoid_rest_world_matrix = humanoid_parent_rest_world_matrix
rest_local_matrix = (
intermediate_rest_local_matrix @ node_rest_pose_tree.local_matrix
)
pose_local_matrix = (
intermediate_pose_local_matrix
@ Matrix.Translation(keyframe_translation)
@ keyframe_rotation.to_matrix().to_4x4()
@ Matrix.Diagonal(node_rest_pose_tree.local_matrix.to_scale()).to_4x4()
)
human_bone_name = node_index_to_human_bone_name.get(node_rest_pose_tree.node_index)
human_bones = get_armature_extension(armature_data).vrm1.humanoid.human_bones
if (
human_bone_name
and human_bone_name not in [HumanBoneName.LEFT_EYE, HumanBoneName.RIGHT_EYE]
and (
human_bone := human_bones.human_bone_name_to_human_bone().get(
human_bone_name
)
)
and (bone := armature.pose.bones.get(human_bone.node.bone_name))
):
humanoid_rest_world_matrix = humanoid_parent_rest_world_matrix
rest_world_matrix = humanoid_rest_world_matrix @ rest_local_matrix
pose_world_matrix = humanoid_rest_world_matrix @ pose_local_matrix
# rest_to_pose_matrix = rest_world_matrix.inverted() @ pose_world_matrix
# rest_to_pose_matrix = rest_local_matrix.inverted() @ pose_local_matrix
rest_to_pose_matrix = rest_local_matrix.inverted() @ pose_local_matrix
axis, angle = rest_to_pose_matrix.to_quaternion().to_axis_angle()
axis.rotate(rest_world_matrix.to_quaternion())
rest_to_pose_world_rotation = Quaternion(axis, angle).copy()
target_axis, target_angle = rest_to_pose_world_rotation.to_axis_angle()
target_axis.rotate(bone.matrix.to_quaternion().inverted())
rest_to_pose_target_local_rotation = Quaternion(
target_axis, target_angle
).copy()
if rotation_keyframes:
logger.debug(
"================= %s =================", human_bone_name.value
)
logger.debug("humanoid world matrix = %s", dump(humanoid_rest_world_matrix))
logger.debug("rest_local_matrix = %s", dump(rest_local_matrix))
logger.debug("pose_local_matrix = %s", dump(pose_local_matrix))
logger.debug("rest_world_matrix = %s", dump(rest_world_matrix))
logger.debug("pose_world_matrix = %s", dump(pose_world_matrix))
logger.debug("rest_to_pose_matrix = %s", dump(rest_to_pose_matrix))
logger.debug(
"rest_to_pose_world_rotation = %s",
dump(rest_to_pose_world_rotation),
)
logger.debug(
"rest_to_pose_target_local_rotation = %s",
dump(rest_to_pose_target_local_rotation),
)
backup_rotation_quaternion = get_rotation_as_quaternion(bone)
# logger.debug("parent bone matrix = %s", dump(parent_matrix))
logger.debug(" bone matrix = %s", dump(bone.matrix))
logger.debug("current bone rotation = %s", dump(backup_rotation_quaternion))
set_rotation_without_mode_change(
bone, backup_rotation_quaternion @ rest_to_pose_target_local_rotation
)
insert_rotation_keyframe(bone, frame=frame_count)
set_rotation_without_mode_change(bone, backup_rotation_quaternion)
if human_bone_name == HumanBoneName.HIPS and translation_keyframes:
translation = (
bone.matrix.to_quaternion().inverted()
@ humanoid_rest_world_matrix.to_quaternion()
@ (
pose_local_matrix.to_translation()
- rest_local_matrix.to_translation()
)
)
# TODO: UniVRM seems to adjust the movement amount by the ratio of
# hips height. This is not described in the specification.
# Investigation of how it works in UniVRM source code is needed.
rest_world_translation_z = rest_world_matrix.to_translation().z
if abs(rest_world_translation_z) > 0:
world_height_ratio = (
bone.matrix.to_translation().z / rest_world_translation_z
)
translation *= world_height_ratio
# logger.debug(f"translation = {dump(translation)}")
backup_translation = bone.location.copy()
bone.location = translation
bone.keyframe_insert(data_path="location", frame=frame_count)
bone.location = backup_translation
humanoid_rest_world_matrix = (
humanoid_parent_rest_world_matrix @ rest_local_matrix
)
rest_local_matrix = Matrix()
pose_local_matrix = Matrix()
for child in node_rest_pose_tree.children:
assign_humanoid_keyframe(
armature,
child,
node_index_to_human_bone_name,
node_index_to_translation_keyframes,
node_index_to_rotation_keyframes,
frame_count,
timestamp,
humanoid_rest_world_matrix,
rest_local_matrix,
pose_local_matrix,
parent_node_rest_pose_world_matrix @ node_rest_pose_tree.local_matrix,
)

1562
importer/vrm0_importer.py Normal file

File diff suppressed because it is too large Load Diff

1875
importer/vrm1_importer.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,776 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
import itertools
import math
from dataclasses import dataclass
from pathlib import Path
from bpy.types import Armature, Context, Object
from mathutils import Matrix, Quaternion, Vector
from ..common import convert
from ..common.convert import Json
from ..common.debug import dump
from ..common.gltf import (
parse_glb,
read_accessor_as_animation_sampler_input,
read_accessor_as_animation_sampler_rotation_output,
read_accessor_as_animation_sampler_translation_output,
)
from ..common.logger import get_logger
from ..common.rotation import (
get_rotation_as_quaternion,
insert_rotation_keyframe,
set_rotation_without_mode_change,
)
from ..common.vrm1.human_bone import HumanBoneName
from ..common.workspace import save_workspace
from ..editor.extension import get_armature_extension
from ..editor.t_pose import setup_humanoid_t_pose
logger = get_logger(__name__)
@dataclass(frozen=True)
class NodeRestPoseTree:
node_index: int
local_matrix: Matrix
children: tuple["NodeRestPoseTree", ...]
is_root: bool
@staticmethod
def build(
node_dicts: list[Json],
node_index: int,
*,
is_root: bool,
) -> list["NodeRestPoseTree"]:
if not 0 <= node_index < len(node_dicts):
return []
node_dict = node_dicts[node_index]
if not isinstance(node_dict, dict):
return []
translation_float3 = convert.float3_or_none(node_dict.get("translation"))
if translation_float3:
x, y, z = translation_float3
translation = Vector((x, -z, y))
else:
translation = Vector((0.0, 0.0, 0.0))
rotation_float4 = convert.float4_or_none(node_dict.get("rotation"))
if rotation_float4:
x, y, z, w = rotation_float4
rotation = Quaternion((w, x, -z, y)).normalized()
else:
rotation = Quaternion()
scale_float3 = convert.float3_or_none(node_dict.get("scale"))
if scale_float3:
x, y, z = scale_float3
scale = Vector((x, z, y))
else:
scale = Vector((1, 1, 1))
local_matrix = (
Matrix.Translation(translation)
@ rotation.to_matrix().to_4x4()
@ Matrix.Diagonal(scale).to_4x4()
)
# TODO: Is it an Euler angle in the case of 3 elements?
# TODO: Decompose if it's a Matrix
child_indices = node_dict.get("children")
if isinstance(child_indices, list):
children = tuple(
itertools.chain(
*(
NodeRestPoseTree.build(node_dicts, child_index, is_root=False)
for child_index in child_indices
if isinstance(child_index, int)
)
)
)
else:
children = ()
return [
NodeRestPoseTree(
node_index=node_index,
local_matrix=local_matrix,
children=children,
is_root=is_root,
)
]
class VrmAnimationImporter:
@staticmethod
def execute(context: Context, path: Path, armature: Object) -> set[str]:
armature_data = armature.data
if not isinstance(armature_data, Armature):
return {"CANCELLED"}
ext = get_armature_extension(armature_data)
humanoid = ext.vrm1.humanoid
if not humanoid.human_bones.all_required_bones_are_assigned():
return {"CANCELLED"}
with (
setup_humanoid_t_pose(context, armature),
save_workspace(context, armature, mode="POSE"),
):
result = import_vrm_animation(context, path, armature)
look_at_preview_enabled = ext.vrm1.look_at.enable_preview
ext.vrm1.look_at.enable_preview = look_at_preview_enabled
return result
def find_root_node_index(
node_dicts: list[Json], node_index: int, skip_node_indices: set[int]
) -> int:
for parent_node_index, node_dict in enumerate(node_dicts):
if parent_node_index in skip_node_indices:
continue
if not isinstance(node_dict, dict):
continue
child_node_indices = node_dict.get("children")
if not isinstance(child_node_indices, list):
continue
for child_node_index in child_node_indices:
if child_node_index in skip_node_indices:
continue
if child_node_index != node_index:
continue
skip_node_indices.add(node_index)
return find_root_node_index(
node_dicts, parent_node_index, skip_node_indices
)
return node_index
def import_vrm_animation(context: Context, path: Path, armature: Object) -> set[str]:
if not path.exists():
return {"CANCELLED"}
armature_data = armature.data
if not isinstance(armature_data, Armature):
return {"CANCELLED"}
humanoid = get_armature_extension(armature_data).vrm1.humanoid
if not humanoid.human_bones.all_required_bones_are_assigned():
return {"CANCELLED"}
look_at = get_armature_extension(armature_data).vrm1.look_at
vrma_dict, buffer0_bytes = parse_glb(path.read_bytes())
node_dicts = vrma_dict.get("nodes")
if not isinstance(node_dicts, list) or not node_dicts:
return {"CANCELLED"}
animation_dicts = vrma_dict.get("animations")
if not isinstance(animation_dicts, list) or not animation_dicts:
return {"CANCELLED"}
animation_dict = animation_dicts[0]
if not isinstance(animation_dict, dict):
return {"CANCELLED"}
animation_channel_dicts = animation_dict.get("channels")
if not isinstance(animation_channel_dicts, list) or not animation_channel_dicts:
return {"CANCELLED"}
animation_sampler_dicts = animation_dict.get("samplers")
if not isinstance(animation_sampler_dicts, list) or not animation_sampler_dicts:
return {"CANCELLED"}
extensions_dict = vrma_dict.get("extensions")
if not isinstance(extensions_dict, dict):
return {"CANCELLED"}
vrmc_vrm_animation_dict = extensions_dict.get("VRMC_vrm_animation")
if not isinstance(vrmc_vrm_animation_dict, dict):
return {"CANCELLED"}
humanoid_dict = vrmc_vrm_animation_dict.get("humanoid")
if not isinstance(humanoid_dict, dict):
return {"CANCELLED"}
human_bones_dict = humanoid_dict.get("humanBones")
if not isinstance(human_bones_dict, dict):
return {"CANCELLED"}
hips_dict = human_bones_dict.get("hips")
if not isinstance(hips_dict, dict):
return {"CANCELLED"}
hips_node_index = hips_dict.get("node")
if not isinstance(hips_node_index, int):
return {"CANCELLED"}
if not 0 <= hips_node_index < len(node_dicts):
return {"CANCELLED"}
hips_node_dict = node_dicts[hips_node_index]
if not isinstance(hips_node_dict, dict):
return {"CANCELLED"}
expression_name_to_node_index: dict[str, int] = {}
expressions_dict = vrmc_vrm_animation_dict.get("expressions")
if isinstance(expressions_dict, dict):
preset_dict = expressions_dict.get("preset")
if isinstance(preset_dict, dict):
for name, node_dict in preset_dict.items():
if not isinstance(node_dict, dict):
continue
node_index = node_dict.get("node")
if not isinstance(node_index, int):
continue
expression_name_to_node_index[name] = node_index
custom_dict = expressions_dict.get("custom")
if isinstance(custom_dict, dict):
for name, node_dict in custom_dict.items():
if not isinstance(node_dict, dict):
continue
if name in expression_name_to_node_index:
continue
node_index = node_dict.get("node")
if not isinstance(node_index, int):
continue
expression_name_to_node_index[name] = node_index
node_index_to_human_bone_name: dict[int, HumanBoneName] = {}
for human_bone_name_str, human_bone_dict in human_bones_dict.items():
if not isinstance(human_bone_dict, dict):
continue
node_index = human_bone_dict.get("node")
if not isinstance(node_index, int):
continue
if not 0 <= node_index < len(node_dicts):
continue
human_bone_name = HumanBoneName.from_str(human_bone_name_str)
if not human_bone_name:
continue
node_index_to_human_bone_name[node_index] = human_bone_name
root_node_index = find_root_node_index(node_dicts, hips_node_index, set())
node_rest_pose_trees = NodeRestPoseTree.build(
node_dicts, root_node_index, is_root=True
)
if len(node_rest_pose_trees) != 1:
return {"CANCELLED"}
node_rest_pose_tree = node_rest_pose_trees[0]
accessor_dicts = vrma_dict.get("accessors")
if not isinstance(accessor_dicts, list):
return {"CANCELLED"}
buffer_view_dicts = vrma_dict.get("bufferViews")
if not isinstance(buffer_view_dicts, list):
return {"CANCELLED"}
buffer_dicts = vrma_dict.get("buffers")
if not isinstance(buffer_dicts, list):
return {"CANCELLED"}
humanoid_action = context.blend_data.actions.new(name="Humanoid")
if not armature.animation_data:
armature.animation_data_create()
armature_animation_data = armature.animation_data
if not armature_animation_data:
message = "armature.animation_data is None"
raise ValueError(message)
armature_animation_data.action = humanoid_action
expression_action = context.blend_data.actions.new(name="Expressions")
if not armature_data.animation_data:
armature_data.animation_data_create()
armature_data_animation_data = armature_data.animation_data
if not armature_data_animation_data:
message = "armature_data.animation_data is None"
raise ValueError(message)
armature_data_animation_data.action = expression_action
node_index_to_translation_keyframes: dict[
int, tuple[tuple[float, Vector], ...]
] = {}
node_index_to_rotation_keyframes: dict[
int, tuple[tuple[float, Quaternion], ...]
] = {}
for animation_channel_dict in animation_channel_dicts:
if not isinstance(animation_channel_dict, dict):
continue
target_dict = animation_channel_dict.get("target")
if not isinstance(target_dict, dict):
continue
node_index = target_dict.get("node")
if not isinstance(node_index, int):
continue
if not 0 <= node_index < len(node_dicts):
continue
animation_path = target_dict.get("path")
animation_sampler_index = animation_channel_dict.get("sampler")
if not isinstance(animation_sampler_index, int):
continue
if not 0 <= animation_sampler_index < len(animation_sampler_dicts):
continue
animation_sampler_dict = animation_sampler_dicts[animation_sampler_index]
if not isinstance(animation_sampler_dict, dict):
continue
input_index = animation_sampler_dict.get("input")
if not isinstance(input_index, int):
continue
if not 0 <= input_index < len(accessor_dicts):
continue
input_accessor_dict = accessor_dicts[input_index]
if not isinstance(input_accessor_dict, dict):
continue
output_index = animation_sampler_dict.get("output")
if not isinstance(output_index, int):
continue
if not 0 <= output_index < len(accessor_dicts):
continue
output_accessor_dict = accessor_dicts[output_index]
if not isinstance(output_accessor_dict, dict):
continue
if animation_path == "translation":
timestamps = read_accessor_as_animation_sampler_input(
input_accessor_dict, buffer_view_dicts, buffer_dicts, buffer0_bytes
)
if timestamps is None:
continue
translations = read_accessor_as_animation_sampler_translation_output(
output_accessor_dict, buffer_view_dicts, buffer_dicts, buffer0_bytes
)
if translations is None:
continue
translation_keyframes = tuple(sorted(zip(timestamps, translations)))
node_index_to_translation_keyframes[node_index] = translation_keyframes
elif animation_path == "rotation":
timestamps = read_accessor_as_animation_sampler_input(
input_accessor_dict, buffer_view_dicts, buffer_dicts, buffer0_bytes
)
if timestamps is None:
continue
rotations = read_accessor_as_animation_sampler_rotation_output(
output_accessor_dict, buffer_view_dicts, buffer_dicts, buffer0_bytes
)
if rotations is None:
continue
rotation_keyframes = tuple(sorted(zip(timestamps, rotations)))
node_index_to_rotation_keyframes[node_index] = rotation_keyframes
expression_name_to_default_preview_value: dict[str, float] = {}
expression_name_to_translation_keyframes: dict[
str, tuple[tuple[float, Vector], ...]
] = {}
for expression_name, node_index in expression_name_to_node_index.items():
if not 0 <= node_index < len(node_dicts):
continue
node_dict = node_dicts[node_index]
if not isinstance(node_dict, dict):
continue
translation = node_dict.get("translation")
if isinstance(translation, list) and translation:
default_preview_value = translation[0] # TODO: In case of Matrix
if not isinstance(default_preview_value, (float, int)):
default_preview_value = 0.0
else:
default_preview_value = 0.0
expression_name_to_default_preview_value[expression_name] = (
default_preview_value
)
expression_translation_keyframes = node_index_to_translation_keyframes.get(
node_index
)
if expression_translation_keyframes is None:
continue
expression_name_to_translation_keyframes[expression_name] = (
expression_translation_keyframes
)
timestamps = [
timestamp
for keyframes in itertools.chain(
node_index_to_translation_keyframes.values(),
node_index_to_rotation_keyframes.values(),
)
for (timestamp, _) in keyframes
]
timestamps.sort()
if not timestamps:
return {"CANCELLED"}
first_timestamp = timestamps[0]
last_timestamp = timestamps[-1]
logger.debug(
"first_timestamp=%s ... last_timestamp=%s",
first_timestamp,
last_timestamp,
)
look_at_target_object = None
look_at_translation_keyframes = None
look_at_dict = vrmc_vrm_animation_dict.get("lookAt")
if isinstance(look_at_dict, dict):
look_at_target_node_index = look_at_dict.get("node")
if isinstance(
look_at_target_node_index, int
) and 0 <= look_at_target_node_index < len(node_dicts):
look_at_translation_keyframes = node_index_to_translation_keyframes.get(
look_at_target_node_index
)
look_at_target_node_dict = node_dicts[look_at_target_node_index]
if look_at_translation_keyframes and isinstance(
look_at_target_node_dict, dict
):
look_at_target_translation = convert.float3_or_none(
look_at_target_node_dict.get("translation")
)
look_at_target_name = look_at_target_node_dict.get("name")
if not isinstance(look_at_target_name, str) or not look_at_target_name:
look_at_target_name = "LookAtTarget"
if look_at_target_translation is not None:
look_at_target_object = context.blend_data.objects.new(
name=look_at_target_name, object_data=None
)
look_at_target_object.empty_display_size = 0.125
x, y, z = look_at_target_translation
look_at_target_object.location = Vector((x, -z, y))
context.scene.collection.objects.link(look_at_target_object)
look_at.enable_preview = True
look_at.preview_target_bpy_object = look_at_target_object
first_zero_origin_frame_count: int = math.floor(
first_timestamp * context.scene.render.fps / context.scene.render.fps_base
)
if abs(last_timestamp - first_timestamp) > 0:
last_zero_origin_frame_count: int = math.ceil(
last_timestamp * context.scene.render.fps / context.scene.render.fps_base
)
else:
last_zero_origin_frame_count = first_zero_origin_frame_count
for zero_origin_frame_count in range(
first_zero_origin_frame_count, last_zero_origin_frame_count + 1
):
timestamp = (
zero_origin_frame_count
* context.scene.render.fps_base
/ context.scene.render.fps
)
frame_count = zero_origin_frame_count + 1
assign_humanoid_keyframe(
armature,
node_rest_pose_tree,
node_index_to_human_bone_name,
node_index_to_translation_keyframes,
node_index_to_rotation_keyframes,
frame_count,
timestamp,
humanoid_parent_rest_world_matrix=Matrix(),
intermediate_rest_local_matrix=Matrix(),
intermediate_pose_local_matrix=Matrix(),
parent_node_rest_pose_world_matrix=Matrix(),
)
assign_expression_keyframe(
armature_data,
expression_name_to_default_preview_value,
expression_name_to_translation_keyframes,
frame_count,
timestamp,
)
if look_at_target_object and look_at_translation_keyframes:
assign_look_at_keyframe(
look_at_target_object,
look_at_translation_keyframes,
frame_count,
timestamp,
)
# This process should be done last because bone state changes occur.
# If translation is assigned to hips and hips is connected to parent
# with "use_connect", the movement animation will not be reflected, so
# we need to disconnect it
if node_index_to_translation_keyframes.get(hips_node_index):
with save_workspace(context, armature, mode="EDIT"):
armature_data = armature.data
if not isinstance(armature_data, Armature):
raise TypeError
hips_bone = armature_data.edit_bones.get(
humanoid.human_bones.hips.node.bone_name
)
if hips_bone and hips_bone.use_connect:
hips_bone.use_connect = False
return {"FINISHED"}
def assign_look_at_keyframe(
look_at_target_object: Object,
translation_keyframes: tuple[tuple[float, Vector], ...],
frame_count: int,
timestamp: float,
) -> None:
if not translation_keyframes:
return
animation_translation = None
begin_timestamp, begin_translation = translation_keyframes[0]
for end_timestamp, end_translation in translation_keyframes:
if end_timestamp >= timestamp:
timestamp_duration = end_timestamp - begin_timestamp
if timestamp_duration > 0:
animation_translation = (
begin_translation
+ (end_translation - begin_translation)
* (timestamp - begin_timestamp)
/ timestamp_duration
)
else:
animation_translation = begin_translation
break
begin_timestamp = end_timestamp
begin_translation = end_translation
if animation_translation is None:
animation_translation = begin_translation
current_location = look_at_target_object.location.copy()
look_at_target_object.location = animation_translation
look_at_target_object.keyframe_insert(data_path="location", frame=frame_count)
look_at_target_object.location = current_location
def assign_expression_keyframe(
armature_data: Armature,
expression_name_to_default_preview_value: dict[str, float],
expression_name_to_translation_keyframes: dict[
str, tuple[tuple[float, Vector], ...]
],
frame_count: int,
timestamp: float,
) -> None:
expressions = get_armature_extension(armature_data).vrm1.expressions
expression_name_to_expression = expressions.all_name_to_expression_dict()
for (
expression_name,
translation_keyframes,
) in expression_name_to_translation_keyframes.items():
if expression_name in [
"lookUp",
"lookDown",
"lookLeft",
"lookRight",
]:
continue
expression = expression_name_to_expression.get(expression_name)
if not expression:
continue
if translation_keyframes:
preview = None
begin_timestamp, begin_translation = translation_keyframes[0]
for end_timestamp, end_translation in translation_keyframes:
if end_timestamp >= timestamp:
timestamp_duration = end_timestamp - begin_timestamp
if timestamp_duration > 0:
preview = (
begin_translation.x
+ (end_translation.x - begin_translation.x)
* (timestamp - begin_timestamp)
/ timestamp_duration
)
else:
preview = begin_translation.x
break
begin_timestamp = end_timestamp
begin_translation = end_translation
if preview is None:
preview = begin_translation.x
else:
preview = (
expression_name_to_default_preview_value.get(expression_name) or 0.0
)
current_preview = expression.preview
expression.preview = preview
expression.keyframe_insert(data_path="preview", frame=frame_count)
expression.preview = current_preview
def assign_humanoid_keyframe(
armature: Object,
node_rest_pose_tree: NodeRestPoseTree,
node_index_to_human_bone_name: dict[int, HumanBoneName],
node_index_to_translation_keyframes: dict[int, tuple[tuple[float, Vector], ...]],
node_index_to_rotation_keyframes: dict[int, tuple[tuple[float, Quaternion], ...]],
frame_count: int,
timestamp: float,
humanoid_parent_rest_world_matrix: Matrix,
intermediate_rest_local_matrix: Matrix,
intermediate_pose_local_matrix: Matrix,
parent_node_rest_pose_world_matrix: Matrix,
) -> None:
armature_data = armature.data
if not isinstance(armature_data, Armature):
return
translation_keyframes = node_index_to_translation_keyframes.get(
node_rest_pose_tree.node_index
)
if translation_keyframes:
keyframe_translation = None
begin_timestamp, begin_translation = translation_keyframes[0]
for end_timestamp, end_translation in translation_keyframes:
if end_timestamp >= timestamp:
timestamp_duration = end_timestamp - begin_timestamp
if timestamp_duration > 0:
keyframe_translation = begin_translation.lerp(
end_translation,
(timestamp - begin_timestamp) / timestamp_duration,
)
else:
keyframe_translation = begin_translation
break
begin_timestamp = end_timestamp
begin_translation = end_translation
if keyframe_translation is None:
keyframe_translation = begin_translation
else:
keyframe_translation = node_rest_pose_tree.local_matrix.to_translation()
rotation_keyframes = node_index_to_rotation_keyframes.get(
node_rest_pose_tree.node_index
)
if rotation_keyframes:
keyframe_rotation = None
begin_timestamp, begin_rotation = rotation_keyframes[0]
for end_timestamp, end_rotation in rotation_keyframes:
if end_timestamp >= timestamp:
timestamp_duration = end_timestamp - begin_timestamp
if timestamp_duration > 0:
keyframe_rotation = begin_rotation.slerp(
end_rotation, (timestamp - begin_timestamp) / timestamp_duration
)
else:
keyframe_rotation = begin_rotation
break
begin_timestamp = end_timestamp
begin_rotation = end_rotation
if keyframe_rotation is None:
keyframe_rotation = begin_rotation
else:
keyframe_rotation = node_rest_pose_tree.local_matrix.to_quaternion()
humanoid_rest_world_matrix = humanoid_parent_rest_world_matrix
rest_local_matrix = (
intermediate_rest_local_matrix @ node_rest_pose_tree.local_matrix
)
pose_local_matrix = (
intermediate_pose_local_matrix
@ Matrix.Translation(keyframe_translation)
@ keyframe_rotation.to_matrix().to_4x4()
@ Matrix.Diagonal(node_rest_pose_tree.local_matrix.to_scale()).to_4x4()
)
human_bone_name = node_index_to_human_bone_name.get(node_rest_pose_tree.node_index)
human_bones = get_armature_extension(armature_data).vrm1.humanoid.human_bones
if (
human_bone_name
and human_bone_name not in [HumanBoneName.LEFT_EYE, HumanBoneName.RIGHT_EYE]
and (
human_bone := human_bones.human_bone_name_to_human_bone().get(
human_bone_name
)
)
and (bone := armature.pose.bones.get(human_bone.node.bone_name))
):
humanoid_rest_world_matrix = humanoid_parent_rest_world_matrix
rest_world_matrix = humanoid_rest_world_matrix @ rest_local_matrix
pose_world_matrix = humanoid_rest_world_matrix @ pose_local_matrix
# rest_to_pose_matrix = rest_world_matrix.inverted() @ pose_world_matrix
# rest_to_pose_matrix = rest_local_matrix.inverted() @ pose_local_matrix
rest_to_pose_matrix = rest_local_matrix.inverted() @ pose_local_matrix
axis, angle = rest_to_pose_matrix.to_quaternion().to_axis_angle()
axis.rotate(rest_world_matrix.to_quaternion())
rest_to_pose_world_rotation = Quaternion(axis, angle).copy()
target_axis, target_angle = rest_to_pose_world_rotation.to_axis_angle()
target_axis.rotate(bone.matrix.to_quaternion().inverted())
rest_to_pose_target_local_rotation = Quaternion(
target_axis, target_angle
).copy()
if rotation_keyframes:
logger.debug(
"================= %s =================", human_bone_name.value
)
logger.debug("humanoid world matrix = %s", dump(humanoid_rest_world_matrix))
logger.debug("rest_local_matrix = %s", dump(rest_local_matrix))
logger.debug("pose_local_matrix = %s", dump(pose_local_matrix))
logger.debug("rest_world_matrix = %s", dump(rest_world_matrix))
logger.debug("pose_world_matrix = %s", dump(pose_world_matrix))
logger.debug("rest_to_pose_matrix = %s", dump(rest_to_pose_matrix))
logger.debug(
"rest_to_pose_world_rotation = %s",
dump(rest_to_pose_world_rotation),
)
logger.debug(
"rest_to_pose_target_local_rotation = %s",
dump(rest_to_pose_target_local_rotation),
)
backup_rotation_quaternion = get_rotation_as_quaternion(bone)
# logger.debug("parent bone matrix = %s", dump(parent_matrix))
logger.debug(" bone matrix = %s", dump(bone.matrix))
logger.debug("current bone rotation = %s", dump(backup_rotation_quaternion))
set_rotation_without_mode_change(
bone, backup_rotation_quaternion @ rest_to_pose_target_local_rotation
)
insert_rotation_keyframe(bone, frame=frame_count)
set_rotation_without_mode_change(bone, backup_rotation_quaternion)
if human_bone_name == HumanBoneName.HIPS and translation_keyframes:
translation = (
bone.matrix.to_quaternion().inverted()
@ humanoid_rest_world_matrix.to_quaternion()
@ (
pose_local_matrix.to_translation()
- rest_local_matrix.to_translation()
)
)
# TODO: UniVRM seems to adjust the movement amount by the ratio of
# hips height. This is not described in the specification.
# Investigation of how it works in UniVRM source code is needed.
rest_world_translation_z = rest_world_matrix.to_translation().z
if abs(rest_world_translation_z) > 0:
world_height_ratio = (
bone.matrix.to_translation().z / rest_world_translation_z
)
translation *= world_height_ratio
# logger.debug(f"translation = {dump(translation)}")
backup_translation = bone.location.copy()
bone.location = translation
bone.keyframe_insert(data_path="location", frame=frame_count)
bone.location = backup_translation
humanoid_rest_world_matrix = (
humanoid_parent_rest_world_matrix @ rest_local_matrix
)
rest_local_matrix = Matrix()
pose_local_matrix = Matrix()
for child in node_rest_pose_tree.children:
assign_humanoid_keyframe(
armature,
child,
node_index_to_human_bone_name,
node_index_to_translation_keyframes,
node_index_to_rotation_keyframes,
frame_count,
timestamp,
humanoid_rest_world_matrix,
rest_local_matrix,
pose_local_matrix,
parent_node_rest_pose_world_matrix @ node_rest_pose_tree.local_matrix,
)

310
importer/vrm_diff.py Normal file
View File

@@ -0,0 +1,310 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
import math
import re
from sys import float_info
from mathutils import Quaternion
from ..common import deep, gltf
from ..common.convert import Json
from ..common.deep import make_json
from ..common.gltf import read_accessors
def human_bone_sort_key(human_bone_dict: Json) -> int:
if not isinstance(human_bone_dict, dict):
return -1
node = human_bone_dict.get("node")
if not isinstance(node, int):
return -1
return node
asset_generator_pattern = (
r"\AVRM Add-on for Blender v999\.999\.999"
+ r" with Khronos glTF Blender I/O v[0-9]+\.[0-9]+\.[0-9]+\Z"
)
fixed_asset_generator_value = (
"VRM Add-on for Blender v999.999.999"
+ " with Khronos glTF Blender I/O v999.999.999"
)
def create_vrm_json_dict(data: bytes) -> dict[str, Json]:
json_dict, buffer0_bytes = gltf.parse_glb(data)
json_dict["__decoded_accessors"] = make_json(
read_accessors(json_dict, buffer0_bytes)
)
extensions_dict = json_dict.get("extensions")
if isinstance(extensions_dict, dict):
is_vrm0 = bool(extensions_dict.get("VRM"))
else:
is_vrm0 = False
asset_dict = json_dict.get("asset")
if isinstance(asset_dict, dict):
asset_generator = asset_dict.get("generator")
if isinstance(asset_generator, str):
asset_dict["generator"] = re.sub(
asset_generator_pattern,
fixed_asset_generator_value,
asset_generator,
)
node_dicts = json_dict.get("nodes")
if isinstance(node_dicts, list):
for node_dict in node_dicts:
if not isinstance(node_dict, dict):
continue
if is_vrm0:
skin_index = node_dict.get("skin")
if isinstance(skin_index, int):
skin_dicts = json_dict.get("skins")
if (
isinstance(skin_dicts, list)
and skin_dicts
and 0 <= skin_index < len(skin_dicts)
and not deep.diff(skin_dicts[0], skin_dicts[skin_index])
):
node_dict["skin"] = 0
if "matrix" in node_dict:
continue
if "scale" not in node_dict:
node_dict["scale"] = [1.0, 1.0, 1.0]
rotation = node_dict.get("rotation")
if not isinstance(rotation, list) or len(rotation) != 4:
continue
rotation_x, rotation_y, rotation_z, rotation_w = rotation
if (
not isinstance(rotation_x, (float, int))
or not isinstance(rotation_y, (float, int))
or not isinstance(rotation_z, (float, int))
or not isinstance(rotation_w, (float, int))
):
node_dict["rotation"] = [0.0, 0.0, 0.0, 1.0]
continue
axis, angle = Quaternion(
(rotation_w, rotation_x, rotation_y, rotation_z)
).to_axis_angle()
if abs(angle - math.pi) > 0.000001:
continue
if abs(axis.x) > abs(axis.y) and abs(axis.x) > abs(axis.z):
if axis.x < 0:
axis = -axis
elif abs(axis.y) > abs(axis.z):
if axis.y < 0:
axis = -axis
elif axis.z < 0:
axis = -axis
q = Quaternion(axis, angle).normalized()
node_dict["rotation"] = [q.x, q.y, q.z, q.w]
if is_vrm0:
skin_dicts = json_dict.get("skins")
if isinstance(skin_dicts, list) and skin_dicts:
first_skin_dict = skin_dicts[0]
if all(
not deep.diff(skin_dict, first_skin_dict)
for skin_dict in skin_dicts
if isinstance(skin_dict, dict)
):
while len(skin_dicts) > 1:
skin_dicts.pop()
scene_dicts = json_dict.get("scenes")
if isinstance(scene_dicts, list):
for scene_dict in scene_dicts:
if not isinstance(scene_dict, dict):
continue
scene_extras_dict = scene_dict.get("extras")
if not isinstance(scene_extras_dict, dict):
continue
for k in ["show_mmd_tabs", "embed_textures", "ui_lang"]:
scene_extras_dict.pop(k, None)
if not scene_extras_dict:
scene_dict.pop("extras", None)
extensions_dict = json_dict.get("extensions")
if not isinstance(extensions_dict, dict):
return json_dict
vrm0_extension_dict = extensions_dict.get("VRM")
if not isinstance(vrm0_extension_dict, dict):
return json_dict
vrm0_first_person_dict = vrm0_extension_dict.get("firstPerson")
if not isinstance(vrm0_first_person_dict, dict):
vrm0_first_person_dict = {}
vrm0_extension_dict["firstPerson"] = vrm0_first_person_dict
vrm0_humanoid_dict = vrm0_extension_dict.get("humanoid")
if isinstance(vrm0_humanoid_dict, dict):
vrm0_human_bone_dicts = vrm0_humanoid_dict.get("humanBones")
if isinstance(vrm0_human_bone_dicts, list):
vrm0_humanoid_dict["humanBones"] = sorted(
vrm0_human_bone_dicts, key=human_bone_sort_key
)
vrm0_first_person_bone = vrm0_first_person_dict.get("firstPersonBone")
if vrm0_first_person_bone in [None, -1]:
for vrm0_human_bone_dict in vrm0_human_bone_dicts:
if not isinstance(vrm0_human_bone_dict, dict):
continue
node = vrm0_human_bone_dict.get("node")
if not isinstance(node, int) or node < 0:
continue
if vrm0_human_bone_dict.get("bone") == "head":
vrm0_first_person_dict["firstPersonBone"] = node
break
for look_at_key in [
"lookAtHorizontalInner",
"lookAtHorizontalOuter",
"lookAtVerticalDown",
"lookAtVerticalUp",
]:
if look_at_key not in vrm0_first_person_dict:
vrm0_first_person_dict[look_at_key] = {}
look_at_dict = vrm0_first_person_dict[look_at_key]
if not isinstance(look_at_dict, dict):
continue
if "curve" not in look_at_dict:
look_at_dict["curve"] = [0, 0, 0, 1, 1, 1, 1, 0]
vrm0_blend_shape_master_dict = vrm0_extension_dict.get("blendShapeMaster")
if isinstance(vrm0_blend_shape_master_dict, dict):
vrm0_blend_shape_group_dicts = vrm0_blend_shape_master_dict.get(
"blendShapeGroups"
)
if isinstance(vrm0_blend_shape_group_dicts, list):
for vrm0_blend_shape_group_dict in vrm0_blend_shape_group_dicts:
if not isinstance(vrm0_blend_shape_group_dict, dict):
continue
if "isBinary" not in vrm0_blend_shape_group_dict:
vrm0_blend_shape_group_dict["isBinary"] = False
if "binds" not in vrm0_blend_shape_group_dict:
vrm0_blend_shape_group_dict["binds"] = []
if "materialValues" not in vrm0_blend_shape_group_dict:
vrm0_blend_shape_group_dict["materialValues"] = []
vrm0_secondary_animation = vrm0_extension_dict.get("secondaryAnimation")
if isinstance(vrm0_secondary_animation, dict):
vrm0_collider_group_dicts = vrm0_secondary_animation.get("colliderGroups")
if isinstance(vrm0_collider_group_dicts, list):
def sort_collider_groups_with_index_key(
collider_group_with_index: tuple[int, Json],
) -> int:
(_, collider_group) = collider_group_with_index
if not isinstance(collider_group, dict):
return -999
node = collider_group.get("node")
if not isinstance(node, int):
return -999
return node
sorted_collider_groups_with_original_index = sorted(
enumerate(vrm0_collider_group_dicts),
key=sort_collider_groups_with_index_key,
)
vrm0_collider_group_dicts = [
collider_group
for _, collider_group in sorted_collider_groups_with_original_index
]
original_index_to_sorted_index = {
original_index: sorted_index
for (sorted_index, (original_index, _)) in enumerate(
sorted_collider_groups_with_original_index
)
}
vrm0_bone_groups = vrm0_secondary_animation.get("boneGroups")
if isinstance(vrm0_bone_groups, list):
for vrm0_bone_group in vrm0_bone_groups:
if not isinstance(vrm0_bone_group, dict):
continue
if "comment" not in vrm0_bone_group:
vrm0_bone_group["comment"] = ""
collider_groups = vrm0_bone_group.get("colliderGroups")
if not isinstance(collider_groups, list):
continue
for i, collider_group in enumerate(list(collider_groups)):
if not isinstance(collider_group, int):
continue
if collider_group not in original_index_to_sorted_index:
collider_groups[i] = -999
continue
collider_groups[i] = original_index_to_sorted_index[
collider_group
]
vrm0_material_properties = vrm0_extension_dict.get("materialProperties")
if isinstance(vrm0_material_properties, list):
for vrm0_material_property in vrm0_material_properties:
if not isinstance(vrm0_material_property, dict):
continue
if vrm0_material_property.get("shader") != "VRM/MToon":
continue
keyword_map = vrm0_material_property.get("keywordMap")
if not isinstance(keyword_map, dict):
keyword_map = {}
vrm0_float_properties = vrm0_material_property.get("floatProperties")
if isinstance(vrm0_float_properties, dict):
if vrm0_float_properties.get("_OutlineWidthMode", 0) == 0:
vrm0_float_properties["_OutlineWidth"] = 0
vrm0_float_properties["_OutlineColorMode"] = 0
vrm0_float_properties["_OutlineLightingMix"] = 0
keyword_map.pop("MTOON_OUTLINE_COLOR_FIXED", None)
keyword_map.pop("MTOON_OUTLINE_COLOR_MIXED", None)
if vrm0_float_properties.get("_OutlineWidthMode") != 2:
vrm0_float_properties["_OutlineScaledMaxDistance"] = 1
if vrm0_float_properties.get("_OutlineColorMode") == 0:
vrm0_float_properties["_OutlineLightingMix"] = 0
outline_lighting_mix = vrm0_float_properties.get("_OutlineLightingMix")
if (
isinstance(outline_lighting_mix, (float, int))
and abs(outline_lighting_mix) < float_info.epsilon
):
vrm0_float_properties["_OutlineColorMode"] = 0
keyword_map.pop("MTOON_OUTLINE_COLOR_MIXED", None)
if "MTOON_OUTLINE_COLOR_FIXED" not in keyword_map:
keyword_map["MTOON_OUTLINE_COLOR_FIXED"] = True
vrm0_vector_properties = vrm0_material_property.get("vectorProperties")
if isinstance(vrm0_vector_properties, dict):
vrm0_emission_color = vrm0_vector_properties.get("_EmissionColor")
if (
isinstance(vrm0_emission_color, list)
and len(vrm0_emission_color) == 4
):
vrm0_emission_color[3] = 1
vrm0_outline_color = vrm0_vector_properties.get("_OutlineColor")
if (
isinstance(vrm0_outline_color, list)
and len(vrm0_outline_color) == 4
):
vrm0_outline_color[3] = 1
return json_dict
def vrm_diff(before: bytes, after: bytes, float_tolerance: float) -> list[str]:
return deep.diff(
create_vrm_json_dict(before), create_vrm_json_dict(after), float_tolerance
)